diff --git a/packages/cli/src/io/result-table.ts b/packages/cli/src/io/result-table.ts new file mode 100644 index 00000000..98f301d0 --- /dev/null +++ b/packages/cli/src/io/result-table.ts @@ -0,0 +1,59 @@ +import type { KtxCliIo } from '../cli-runtime.js'; +import type { KtxOutputMode } from './mode.js'; + +export interface KtxResultTable { + connectionId: string; + headers: string[]; + headerTypes?: string[]; + rows: unknown[][]; + rowCount: number; +} + +/** @internal */ +export function formatValue(value: unknown): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value); + return JSON.stringify(value); +} + +function printJson(output: KtxResultTable, io: KtxCliIo): void { + io.stdout.write(`${JSON.stringify(output, null, 2)}\n`); +} + +function printPlain(output: KtxResultTable, io: KtxCliIo): void { + io.stdout.write(`${output.headers.join('\t')}\n`); + for (const row of output.rows) { + io.stdout.write(`${row.map(formatValue).join('\t')}\n`); + } +} + +function printPretty(output: KtxResultTable, io: KtxCliIo): void { + const rows = output.rows.map((row) => row.map(formatValue)); + const widths = output.headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)), + ); + const renderRow = (cells: string[]): string => + cells.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(' ').trimEnd(); + + if (output.headers.length > 0) { + io.stdout.write(`${renderRow(output.headers)}\n`); + io.stdout.write(`${renderRow(widths.map((width) => '-'.repeat(width)))}\n`); + } + for (const row of rows) { + io.stdout.write(`${renderRow(row)}\n`); + } + io.stdout.write(`\n${output.rowCount} ${output.rowCount === 1 ? 'row' : 'rows'}\n`); +} + +export function printResultTable(output: KtxResultTable, mode: KtxOutputMode, io: KtxCliIo): void { + if (mode === 'json') { + printJson(output, io); + return; + } + if (mode === 'plain') { + printPlain(output, io); + return; + } + printPretty(output, io); +} diff --git a/packages/cli/src/sql.ts b/packages/cli/src/sql.ts index fbf05975..bd29245a 100644 --- a/packages/cli/src/sql.ts +++ b/packages/cli/src/sql.ts @@ -9,6 +9,7 @@ import { sqlAnalysisDialectForDriver } from './context/sql-analysis/dialect.js'; import type { SqlAnalysisDialect, SqlAnalysisPort } from './context/sql-analysis/ports.js'; import type { KtxCliIo } from './cli-runtime.js'; import { type KtxOutputMode, resolveOutputMode } from './io/mode.js'; +import { type KtxResultTable, printResultTable } from './io/result-table.js'; import { createKtxCliScanConnector } from './local-scan-connectors.js'; import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js'; import { profileMark } from './startup-profile.js'; @@ -39,14 +40,6 @@ export interface KtxSqlDeps { executeFederated?: typeof executeFederatedQuery; } -interface SqlExecutionOutput { - connectionId: string; - headers: string[]; - headerTypes?: string[]; - rows: unknown[][]; - rowCount: number; -} - function queryVerb(sql: string): 'select' | 'explain' | 'show' | 'with' | 'other' { const first = sql.trim().split(/\s+/, 1)[0]?.toLowerCase(); if (first === 'select' || first === 'explain' || first === 'show' || first === 'with') { @@ -68,55 +61,7 @@ async function safeReferencedTableCount( } } -function formatValue(value: unknown): string { - if (value === null || value === undefined) return ''; - if (typeof value === 'string') return value; - if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value); - return JSON.stringify(value); -} - -function printJson(output: SqlExecutionOutput, io: KtxCliIo): void { - io.stdout.write(`${JSON.stringify(output, null, 2)}\n`); -} - -function printPlain(output: SqlExecutionOutput, io: KtxCliIo): void { - io.stdout.write(`${output.headers.join('\t')}\n`); - for (const row of output.rows) { - io.stdout.write(`${row.map(formatValue).join('\t')}\n`); - } -} - -function printPretty(output: SqlExecutionOutput, io: KtxCliIo): void { - const rows = output.rows.map((row) => row.map(formatValue)); - const widths = output.headers.map((header, index) => - Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)), - ); - const renderRow = (cells: string[]): string => - cells.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(' ').trimEnd(); - - if (output.headers.length > 0) { - io.stdout.write(`${renderRow(output.headers)}\n`); - io.stdout.write(`${renderRow(widths.map((width) => '-'.repeat(width)))}\n`); - } - for (const row of rows) { - io.stdout.write(`${renderRow(row)}\n`); - } - io.stdout.write(`\n${output.rowCount} ${output.rowCount === 1 ? 'row' : 'rows'}\n`); -} - -function printSqlResult(output: SqlExecutionOutput, mode: KtxSqlOutputMode, io: KtxCliIo): void { - if (mode === 'json') { - printJson(output, io); - return; - } - if (mode === 'plain') { - printPlain(output, io); - return; - } - printPretty(output, io); -} - -function resultOutput(connectionId: string, result: KtxSqlQueryExecutionResult): SqlExecutionOutput { +function resultOutput(connectionId: string, result: KtxSqlQueryExecutionResult): KtxResultTable { return { connectionId, headers: result.headers, @@ -168,7 +113,7 @@ export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps: }); const referencedTableCount = await safeReferencedTableCount(analysisPort, args.sql, dialect); const mode = resolveOutputMode({ explicit: args.output, json: args.json, io }); - printSqlResult(resultOutput(args.connectionId, result), mode, io); + printResultTable(resultOutput(args.connectionId, result), mode, io); await emitTelemetryEvent({ name: 'sql_completed', projectDir: args.projectDir, diff --git a/packages/cli/test/io/result-table.test.ts b/packages/cli/test/io/result-table.test.ts new file mode 100644 index 00000000..39508d37 --- /dev/null +++ b/packages/cli/test/io/result-table.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { formatValue, printResultTable } from '../../src/io/result-table.js'; +import type { KtxCliIo } from '../../src/cli-runtime.js'; + +function captureIo(): { io: KtxCliIo; out: () => string } { + let buffer = ''; + const io = { + stdout: { write: (s: string) => { buffer += s; return true; } }, + stderr: { write: () => true }, + } as unknown as KtxCliIo; + return { io, out: () => buffer }; +} + +describe('formatValue', () => { + it('renders null/undefined as empty, scalars as strings, objects as JSON', () => { + expect(formatValue(null)).toBe(''); + expect(formatValue(undefined)).toBe(''); + expect(formatValue('x')).toBe('x'); + expect(formatValue(42)).toBe('42'); + expect(formatValue(true)).toBe('true'); + expect(formatValue({ a: 1 })).toBe(JSON.stringify({ a: 1 })); + }); +}); + +describe('printResultTable', () => { + const table = { connectionId: 'mongo', headers: ['_id', 'city'], rows: [['a1', 'NY']], rowCount: 1 }; + + it('json mode prints the structured payload', () => { + const { io, out } = captureIo(); + printResultTable(table, 'json', io); + expect(JSON.parse(out())).toEqual(table); + }); + + it('plain mode prints tab-separated headers and rows', () => { + const { io, out } = captureIo(); + printResultTable(table, 'plain', io); + expect(out()).toBe('_id\tcity\na1\tNY\n'); + }); + + it('pretty mode prints a header rule and a row count', () => { + const { io, out } = captureIo(); + printResultTable(table, 'pretty', io); + expect(out()).toContain('_id'); + expect(out()).toContain('1 row'); + }); +});