refactor(io): extract shared result-table formatters from sql

Move formatValue/printJson/printPlain/printPretty/printResultTable and
the KtxResultTable interface out of sql.ts into io/result-table.ts so
the upcoming mongo-query command can reuse them verbatim.
This commit is contained in:
Kevin Messiaen 2026-07-06 19:59:34 +07:00
parent 9a2e215bdc
commit 89b8729950
No known key found for this signature in database
GPG key ID: 19FF750A17315202
3 changed files with 108 additions and 58 deletions

View file

@ -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);
}

View file

@ -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,

View file

@ -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');
});
});