mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-25 08:48:08 +02:00
feat(cli): add read-only sql command (#126)
* feat(cli): add read-only sql command * fix(cli): rename sql connection flag
This commit is contained in:
parent
c89af7733a
commit
33a142f769
11 changed files with 742 additions and 1 deletions
99
packages/cli/src/commands/sql-commands.test.ts
Normal file
99
packages/cli/src/commands/sql-commands.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { Command } from '@commander-js/extra-typings';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { KtxCliCommandContext } from '../cli-program.js';
|
||||
import { registerSqlCommands } from './sql-commands.js';
|
||||
|
||||
function makeContext(overrides: Partial<KtxCliCommandContext> = {}): KtxCliCommandContext {
|
||||
let exitCode = 0;
|
||||
return {
|
||||
io: {
|
||||
stdout: { write: vi.fn() },
|
||||
stderr: { write: vi.fn() },
|
||||
},
|
||||
deps: {},
|
||||
packageInfo: { name: '@ktx/cli', version: '0.0.0-test', contextPackageName: '@ktx/context' },
|
||||
setExitCode: (code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
runInit: vi.fn(),
|
||||
writeDebug: vi.fn(),
|
||||
...overrides,
|
||||
get exitCode() {
|
||||
return exitCode;
|
||||
},
|
||||
} as KtxCliCommandContext;
|
||||
}
|
||||
|
||||
describe('registerSqlCommands', () => {
|
||||
it('routes positional SQL through the sql runner', async () => {
|
||||
const program = new Command().exitOverride().option('--project-dir <path>');
|
||||
const sql = vi.fn(async () => 0);
|
||||
const context = makeContext({ deps: { sql } });
|
||||
registerSqlCommands(program, context);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(
|
||||
['--project-dir', '/tmp/ktx-sql', 'sql', '--connection', 'warehouse', 'select', '1'],
|
||||
{ from: 'user' },
|
||||
),
|
||||
).resolves.toBe(program);
|
||||
|
||||
expect(sql).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'execute',
|
||||
projectDir: '/tmp/ktx-sql',
|
||||
connectionId: 'warehouse',
|
||||
sql: 'select 1',
|
||||
maxRows: 1000,
|
||||
output: undefined,
|
||||
json: false,
|
||||
cliVersion: '0.0.0-test',
|
||||
},
|
||||
context.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports the short connection flag', async () => {
|
||||
const program = new Command().exitOverride().option('--project-dir <path>');
|
||||
const sql = vi.fn(async () => 0);
|
||||
const context = makeContext({ deps: { sql } });
|
||||
registerSqlCommands(program, context);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(['--project-dir', '/tmp/ktx-sql', 'sql', '-c', 'warehouse', 'select 1'], {
|
||||
from: 'user',
|
||||
}),
|
||||
).resolves.toBe(program);
|
||||
|
||||
expect(sql).toHaveBeenCalledWith(expect.objectContaining({ connectionId: 'warehouse', sql: 'select 1' }), context.io);
|
||||
});
|
||||
|
||||
it('rejects missing SQL before invoking the runner', async () => {
|
||||
const program = new Command().exitOverride().option('--project-dir <path>');
|
||||
const sql = vi.fn(async () => 0);
|
||||
registerSqlCommands(program, makeContext({ deps: { sql } }));
|
||||
|
||||
await expect(
|
||||
program.parseAsync(['--project-dir', '/tmp/ktx-sql', 'sql', '--connection', 'warehouse'], {
|
||||
from: 'user',
|
||||
}),
|
||||
).rejects.toThrow('missing required argument');
|
||||
|
||||
expect(sql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects maxRows above the CLI cap', async () => {
|
||||
const program = new Command().exitOverride().option('--project-dir <path>');
|
||||
const sql = vi.fn(async () => 0);
|
||||
registerSqlCommands(program, makeContext({ deps: { sql } }));
|
||||
|
||||
await expect(
|
||||
program.parseAsync(
|
||||
['--project-dir', '/tmp/ktx-sql', 'sql', '--connection', 'warehouse', '--max-rows', '10001', 'select 1'],
|
||||
{ from: 'user' },
|
||||
),
|
||||
).rejects.toThrow('must be an integer between 1 and 10000');
|
||||
|
||||
expect(sql).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
62
packages/cli/src/commands/sql-commands.ts
Normal file
62
packages/cli/src/commands/sql-commands.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-typings';
|
||||
import { type KtxCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import type { KtxSqlArgs } from '../sql.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/sql-commands');
|
||||
|
||||
const DEFAULT_MAX_ROWS = 1000;
|
||||
const MAX_ROWS_CAP = 10_000;
|
||||
|
||||
function parseSqlMaxRowsOption(value: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_ROWS_CAP) {
|
||||
throw new InvalidArgumentError(`must be an integer between 1 and ${MAX_ROWS_CAP}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function runSqlArgs(context: KtxCliCommandContext, args: KtxSqlArgs): Promise<void> {
|
||||
const runner = context.deps.sql ?? (await import('../sql.js')).runKtxSql;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
export function registerSqlCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
program
|
||||
.command('sql')
|
||||
.description('Execute parser-validated read-only SQL against a configured connection')
|
||||
.argument('<sql...>', 'SQL query to execute')
|
||||
.requiredOption('-c, --connection <id>', 'KTX connection id')
|
||||
.option('--max-rows <n>', 'Maximum rows to return', parseSqlMaxRowsOption, DEFAULT_MAX_ROWS)
|
||||
.addOption(
|
||||
new Option('--output <mode>', 'Output mode: pretty (default), plain (TSV), or json').choices([
|
||||
'pretty',
|
||||
'plain',
|
||||
'json',
|
||||
]),
|
||||
)
|
||||
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
|
||||
.action(
|
||||
async (
|
||||
sqlParts: string[],
|
||||
options: {
|
||||
connection: string;
|
||||
maxRows: number;
|
||||
output?: 'pretty' | 'plain' | 'json';
|
||||
json?: boolean;
|
||||
},
|
||||
command,
|
||||
) => {
|
||||
await runSqlArgs(context, {
|
||||
command: 'execute',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connection,
|
||||
sql: sqlParts.join(' '),
|
||||
maxRows: options.maxRows,
|
||||
output: options.output,
|
||||
json: options.json === true,
|
||||
cliVersion: context.packageInfo.version,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue