mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
* feat(completion): complete known argument values
* fix(completion): hide Commander-hidden subcommands from completions
Replace the `__`-prefix name heuristic with Commander's `_hidden` flag so
internal subcommands registered with { hidden: true } (e.g. `mcp serve-internal`)
are excluded from completions, mirroring `ktx --help`.
* test: cover wiki and sl read command routing
* test: cover raw wiki and sl reads
* feat: add wiki read command
* feat: add sl read command
* feat: complete read command entity names
* docs: document wiki and sl read commands
* test: include read commands in command tree
* feat(sl): read and validate unique sources by name
* feat(sl): make read and validate connection id optional
* fix(completion): dedupe semantic source names
* docs(sl): document connection-optional read and validate
* fix(sl): require connection id for query command
* docs(sl): clarify query connection requirement
* fix(completion): don't resolve option values as subcommands
resolveCommand skipped flag tokens but not the value consumed by a
value-taking option in the `--flag value` form, so a connection id like
`query` was matched as the `sl query` subcommand and yielded no `sl`
completions. Track value-taking options and skip their consumed value
before matching subcommands.
* test(telemetry): assert first-run notice via TELEMETRY_NOTICE constant
CI (which tests this branch merged with main) failed because #243 changed
the first-run notice wording in identity.ts (dropped "anonymous") but left
this test grepping for the old literal 'ktx collects anonymous usage data',
so indexOf returned -1. Assert against the exported TELEMETRY_NOTICE
constant instead so the test tracks the source of truth and cannot drift
when the notice text changes again.
1016 lines
28 KiB
TypeScript
1016 lines
28 KiB
TypeScript
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { stripVTControlCharacters } from 'node:util';
|
|
import Database from 'better-sqlite3';
|
|
import { initKtxProject } from '../src/context/project/project.js';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { runKtxSl } from '../src/sl.js';
|
|
|
|
const ORDERS_YAML = [
|
|
'name: orders',
|
|
'table: public.orders',
|
|
'grain:',
|
|
' - order_id',
|
|
'columns:',
|
|
' - name: order_id',
|
|
' type: string',
|
|
'',
|
|
].join('\n');
|
|
|
|
function makeIo(options: { isTTY?: boolean } = {}) {
|
|
let stdout = '';
|
|
let stderr = '';
|
|
return {
|
|
io: {
|
|
stdout: {
|
|
isTTY: options.isTTY,
|
|
write: (chunk: string) => {
|
|
stdout += chunk;
|
|
},
|
|
},
|
|
stderr: {
|
|
write: (chunk: string) => {
|
|
stderr += chunk;
|
|
},
|
|
},
|
|
},
|
|
stdout: () => stdout,
|
|
stderr: () => stderr,
|
|
};
|
|
}
|
|
|
|
async function seedSlSource(input: {
|
|
projectDir: string;
|
|
connectionId?: string;
|
|
sourceName?: string;
|
|
yaml?: string;
|
|
}): Promise<void> {
|
|
const project = await initKtxProject({ projectDir: input.projectDir });
|
|
await project.fileStore.writeFile(
|
|
`semantic-layer/${input.connectionId ?? 'warehouse'}/${input.sourceName ?? 'orders'}.yaml`,
|
|
input.yaml ?? ORDERS_YAML,
|
|
'ktx',
|
|
'ktx@example.com',
|
|
'Add semantic-layer source',
|
|
);
|
|
}
|
|
|
|
describe('runKtxSl', () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-sl-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllEnvs();
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('validates, lists, and searches semantic-layer sources', async () => {
|
|
const projectDir = join(tempDir, 'project');
|
|
await seedSlSource({ projectDir });
|
|
|
|
const validateIo = makeIo();
|
|
await expect(
|
|
runKtxSl({ command: 'validate', projectDir, connectionId: 'warehouse', sourceName: 'orders' }, validateIo.io),
|
|
).resolves.toBe(0);
|
|
expect(validateIo.stdout()).toContain('Valid semantic-layer source: warehouse/orders');
|
|
|
|
const listIo = makeIo();
|
|
await expect(
|
|
runKtxSl({ command: 'list', projectDir, connectionId: 'warehouse', cliVersion: '0.0.0-test' }, listIo.io),
|
|
).resolves.toBe(0);
|
|
expect(listIo.stdout()).toContain('warehouse\torders\tcolumns=1\tmeasures=0\tjoins=0');
|
|
|
|
const searchIo = makeIo();
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'search',
|
|
projectDir,
|
|
connectionId: 'warehouse',
|
|
query: 'order',
|
|
json: true,
|
|
cliVersion: '0.0.0-test',
|
|
},
|
|
searchIo.io,
|
|
),
|
|
).resolves.toBe(0);
|
|
expect(JSON.parse(searchIo.stdout())).toMatchObject({
|
|
kind: 'list',
|
|
data: {
|
|
items: [
|
|
expect.objectContaining({
|
|
connectionId: 'warehouse',
|
|
name: 'orders',
|
|
score: expect.any(Number),
|
|
}),
|
|
],
|
|
},
|
|
meta: { command: 'sl search' },
|
|
});
|
|
});
|
|
|
|
it('reads a semantic-layer source as raw YAML', async () => {
|
|
const projectDir = join(tempDir, 'read-project');
|
|
await seedSlSource({ projectDir });
|
|
|
|
const readIo = makeIo();
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'read',
|
|
projectDir,
|
|
connectionId: 'warehouse',
|
|
sourceName: 'orders',
|
|
},
|
|
readIo.io,
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(readIo.stdout()).toBe(ORDERS_YAML);
|
|
expect(readIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('reads a unique semantic-layer source without a connection id', async () => {
|
|
const projectDir = join(tempDir, 'read-unique-project');
|
|
const project = await initKtxProject({ projectDir });
|
|
await project.fileStore.writeFile(
|
|
'semantic-layer/warehouse/orders.yaml',
|
|
ORDERS_YAML,
|
|
'ktx',
|
|
'ktx@example.com',
|
|
'Add semantic-layer source',
|
|
);
|
|
await project.fileStore.writeFile(
|
|
'semantic-layer/analytics/tickets.yaml',
|
|
[
|
|
'name: tickets',
|
|
'table: public.tickets',
|
|
'grain:',
|
|
' - ticket_id',
|
|
'columns:',
|
|
' - name: ticket_id',
|
|
' type: string',
|
|
'',
|
|
].join('\n'),
|
|
'ktx',
|
|
'ktx@example.com',
|
|
'Add semantic-layer source',
|
|
);
|
|
|
|
const readIo = makeIo();
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'read',
|
|
projectDir,
|
|
sourceName: 'tickets',
|
|
},
|
|
readIo.io,
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(readIo.stdout()).toContain('name: tickets');
|
|
expect(readIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('reports ambiguous unscoped reads with sorted connection ids', async () => {
|
|
const projectDir = join(tempDir, 'read-ambiguous-project');
|
|
const project = await initKtxProject({ projectDir });
|
|
await project.fileStore.writeFile(
|
|
'semantic-layer/warehouse/orders.yaml',
|
|
ORDERS_YAML,
|
|
'ktx',
|
|
'ktx@example.com',
|
|
'Add semantic-layer source',
|
|
);
|
|
await project.fileStore.writeFile(
|
|
'semantic-layer/analytics/orders.yaml',
|
|
ORDERS_YAML,
|
|
'ktx',
|
|
'ktx@example.com',
|
|
'Add semantic-layer source',
|
|
);
|
|
|
|
const readIo = makeIo();
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'read',
|
|
projectDir,
|
|
sourceName: 'orders',
|
|
},
|
|
readIo.io,
|
|
),
|
|
).resolves.toBe(1);
|
|
|
|
expect(readIo.stdout()).toBe('');
|
|
expect(readIo.stderr()).toBe(
|
|
"Source 'orders' exists in multiple connections: analytics, warehouse. Re-run with --connection-id <id>.\n",
|
|
);
|
|
});
|
|
|
|
it('reports a clear error when an unscoped semantic-layer source is missing', async () => {
|
|
const projectDir = join(tempDir, 'missing-unscoped-read-project');
|
|
await seedSlSource({ projectDir });
|
|
|
|
const readIo = makeIo();
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'read',
|
|
projectDir,
|
|
sourceName: 'missing_orders',
|
|
},
|
|
readIo.io,
|
|
),
|
|
).resolves.toBe(1);
|
|
|
|
expect(readIo.stdout()).toBe('');
|
|
expect(readIo.stderr()).toBe("No semantic-layer source 'missing_orders'\n");
|
|
});
|
|
|
|
it('reports a clear error when a semantic-layer source is missing', async () => {
|
|
const projectDir = join(tempDir, 'missing-read-project');
|
|
await seedSlSource({ projectDir });
|
|
|
|
const readIo = makeIo();
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'read',
|
|
projectDir,
|
|
connectionId: 'warehouse',
|
|
sourceName: 'missing_orders',
|
|
},
|
|
readIo.io,
|
|
),
|
|
).resolves.toBe(1);
|
|
|
|
expect(readIo.stdout()).toBe('');
|
|
expect(readIo.stderr()).toBe("No semantic-layer source 'missing_orders' for connection 'warehouse'\n");
|
|
});
|
|
|
|
it('validates a unique semantic-layer source without a connection id', async () => {
|
|
const projectDir = join(tempDir, 'validate-unique-project');
|
|
const project = await initKtxProject({ projectDir });
|
|
await project.fileStore.writeFile(
|
|
'semantic-layer/warehouse/orders.yaml',
|
|
ORDERS_YAML,
|
|
'ktx',
|
|
'ktx@example.com',
|
|
'Add semantic-layer source',
|
|
);
|
|
await project.fileStore.writeFile(
|
|
'semantic-layer/analytics/tickets.yaml',
|
|
[
|
|
'name: tickets',
|
|
'table: public.tickets',
|
|
'grain:',
|
|
' - ticket_id',
|
|
'columns:',
|
|
' - name: ticket_id',
|
|
' type: string',
|
|
'',
|
|
].join('\n'),
|
|
'ktx',
|
|
'ktx@example.com',
|
|
'Add semantic-layer source',
|
|
);
|
|
|
|
const validateIo = makeIo();
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'validate',
|
|
projectDir,
|
|
sourceName: 'tickets',
|
|
},
|
|
validateIo.io,
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(validateIo.stdout()).toBe('Valid semantic-layer source: analytics/tickets\n');
|
|
expect(validateIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('reports ambiguous unscoped validation with sorted connection ids', async () => {
|
|
const projectDir = join(tempDir, 'validate-ambiguous-project');
|
|
const project = await initKtxProject({ projectDir });
|
|
await project.fileStore.writeFile(
|
|
'semantic-layer/warehouse/orders.yaml',
|
|
ORDERS_YAML,
|
|
'ktx',
|
|
'ktx@example.com',
|
|
'Add semantic-layer source',
|
|
);
|
|
await project.fileStore.writeFile(
|
|
'semantic-layer/analytics/orders.yaml',
|
|
ORDERS_YAML,
|
|
'ktx',
|
|
'ktx@example.com',
|
|
'Add semantic-layer source',
|
|
);
|
|
|
|
const validateIo = makeIo();
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'validate',
|
|
projectDir,
|
|
sourceName: 'orders',
|
|
},
|
|
validateIo.io,
|
|
),
|
|
).resolves.toBe(1);
|
|
|
|
expect(validateIo.stdout()).toBe('');
|
|
expect(validateIo.stderr()).toBe(
|
|
"Source 'orders' exists in multiple connections: analytics, warehouse. Re-run with --connection-id <id>.\n",
|
|
);
|
|
});
|
|
|
|
it('reports a clear error when an unscoped semantic-layer source validation target is missing', async () => {
|
|
const projectDir = join(tempDir, 'missing-unscoped-validate-project');
|
|
await seedSlSource({ projectDir });
|
|
|
|
const validateIo = makeIo();
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'validate',
|
|
projectDir,
|
|
sourceName: 'missing_orders',
|
|
},
|
|
validateIo.io,
|
|
),
|
|
).resolves.toBe(1);
|
|
|
|
expect(validateIo.stdout()).toBe('');
|
|
expect(validateIo.stderr()).toBe('Semantic-layer source "missing_orders" was not found\n');
|
|
});
|
|
|
|
it('keeps scoped validation not-found wording', async () => {
|
|
const projectDir = join(tempDir, 'missing-scoped-validate-project');
|
|
await seedSlSource({ projectDir });
|
|
|
|
const validateIo = makeIo();
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'validate',
|
|
projectDir,
|
|
connectionId: 'warehouse',
|
|
sourceName: 'missing_orders',
|
|
},
|
|
validateIo.io,
|
|
),
|
|
).resolves.toBe(1);
|
|
|
|
expect(validateIo.stdout()).toBe('');
|
|
expect(validateIo.stderr()).toBe('Semantic-layer source "warehouse/missing_orders" was not found\n');
|
|
});
|
|
|
|
it('prints semantic-layer search rank badges in pretty output', async () => {
|
|
const projectDir = join(tempDir, 'rank-project');
|
|
await seedSlSource({ projectDir });
|
|
|
|
const searchIo = makeIo();
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'search',
|
|
projectDir,
|
|
connectionId: 'warehouse',
|
|
query: 'order',
|
|
output: 'pretty',
|
|
cliVersion: '0.0.0-test',
|
|
},
|
|
searchIo.io,
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
const stdout = stripVTControlCharacters(searchIo.stdout());
|
|
expect(stdout).toMatch(/#1\s+orders/);
|
|
expect(stdout).not.toContain('%');
|
|
});
|
|
|
|
it('prints semantic-layer list and search as public JSON envelopes', async () => {
|
|
const projectDir = join(tempDir, 'project');
|
|
await seedSlSource({
|
|
projectDir,
|
|
yaml: [
|
|
'name: orders',
|
|
'table: public.orders',
|
|
'descriptions:',
|
|
' user: Paid order facts',
|
|
'grain: [order_id]',
|
|
'columns:',
|
|
' - name: order_id',
|
|
' type: string',
|
|
'',
|
|
].join('\n'),
|
|
});
|
|
|
|
const listIo = makeIo();
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'search',
|
|
projectDir,
|
|
connectionId: 'warehouse',
|
|
query: 'paid',
|
|
json: true,
|
|
cliVersion: '0.0.0-test',
|
|
},
|
|
listIo.io,
|
|
),
|
|
).resolves.toBe(0);
|
|
expect(JSON.parse(listIo.stdout())).toMatchObject({
|
|
kind: 'list',
|
|
data: {
|
|
items: [
|
|
expect.objectContaining({
|
|
connectionId: 'warehouse',
|
|
name: 'orders',
|
|
score: expect.any(Number),
|
|
matchReasons: expect.any(Array),
|
|
}),
|
|
],
|
|
},
|
|
meta: { command: 'sl search' },
|
|
});
|
|
});
|
|
|
|
it('fails validation when a table-backed source declares columns absent from a matching warehouse manifest', async () => {
|
|
const projectDir = join(tempDir, 'project');
|
|
const project = await initKtxProject({ projectDir });
|
|
await project.fileStore.writeFile(
|
|
'semantic-layer/postgres-warehouse/_schema/orbit_analytics.yaml',
|
|
`tables:
|
|
int_active_contract_arr:
|
|
table: orbit_analytics.int_active_contract_arr
|
|
columns:
|
|
- { name: contract_id, type: string }
|
|
- { name: contract_arr_cents, type: number }
|
|
`,
|
|
'ktx',
|
|
'ktx@example.com',
|
|
'Add warehouse manifest',
|
|
);
|
|
await project.fileStore.writeFile(
|
|
'semantic-layer/dbt-main/int_active_contract_arr.yaml',
|
|
`name: int_active_contract_arr
|
|
table: orbit_analytics.int_active_contract_arr
|
|
grain: [contract_id]
|
|
columns:
|
|
- { name: contract_id, type: string }
|
|
- { name: arr_cents, type: number }
|
|
measures:
|
|
- { name: arr, expr: sum(arr_cents) }
|
|
joins: []
|
|
`,
|
|
'ktx',
|
|
'ktx@example.com',
|
|
'Add invalid dbt source',
|
|
);
|
|
|
|
const validateIo = makeIo();
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'validate',
|
|
projectDir,
|
|
connectionId: 'dbt-main',
|
|
sourceName: 'int_active_contract_arr',
|
|
},
|
|
validateIo.io,
|
|
),
|
|
).resolves.toBe(1);
|
|
|
|
expect(validateIo.stderr()).toContain('arr_cents');
|
|
expect(validateIo.stderr()).toContain('absent from physical table');
|
|
});
|
|
|
|
it('runs sl query and prints SQL output', async () => {
|
|
const projectDir = join(tempDir, 'project');
|
|
const project = await initKtxProject({ projectDir });
|
|
project.config.connections.warehouse = { driver: 'postgres' };
|
|
await project.fileStore.writeFile(
|
|
'semantic-layer/warehouse/orders.yaml',
|
|
`name: orders
|
|
table: public.orders
|
|
grain: [id]
|
|
columns:
|
|
- name: id
|
|
type: number
|
|
measures:
|
|
- name: order_count
|
|
expr: count(*)
|
|
joins: []
|
|
`,
|
|
'ktx',
|
|
'ktx@example.com',
|
|
'Add orders source',
|
|
);
|
|
|
|
const stdout = { write: vi.fn() };
|
|
const stderr = { write: vi.fn() };
|
|
const loadProject = vi.fn(async () => project);
|
|
const createSemanticLayerCompute = vi.fn(() => ({
|
|
query: vi.fn(async () => ({
|
|
sql: 'select count(*) as order_count from public.orders',
|
|
dialect: 'postgres',
|
|
columns: [{ name: 'orders.order_count' }],
|
|
plan: {},
|
|
})),
|
|
validateSources: vi.fn(),
|
|
generateSources: vi.fn(),
|
|
}));
|
|
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'query',
|
|
projectDir: '/tmp/project',
|
|
connectionId: 'warehouse',
|
|
query: { measures: ['orders.order_count'], dimensions: [] },
|
|
format: 'sql',
|
|
execute: false,
|
|
cliVersion: '0.2.0',
|
|
runtimeInstallPolicy: 'auto',
|
|
},
|
|
{ stdout, stderr },
|
|
{ loadProject, createSemanticLayerCompute },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(stdout.write).toHaveBeenCalledWith('select count(*) as order_count from public.orders\n');
|
|
expect(stderr.write).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('emits debug telemetry for sl query without project paths', async () => {
|
|
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
|
|
vi.stubEnv('CI', '');
|
|
const projectDir = join(tempDir, 'project');
|
|
await seedSlSource({ projectDir });
|
|
const io = makeIo({ isTTY: true });
|
|
const createSemanticLayerCompute = vi.fn(() => ({
|
|
query: vi.fn(async () => ({
|
|
sql: 'select count(*) as order_count from public.orders',
|
|
dialect: 'postgres',
|
|
columns: [{ name: 'orders.order_count' }],
|
|
plan: {},
|
|
})),
|
|
validateSources: vi.fn(),
|
|
generateSources: vi.fn(),
|
|
}));
|
|
|
|
const code = await runKtxSl(
|
|
{
|
|
command: 'query',
|
|
projectDir,
|
|
connectionId: 'warehouse',
|
|
query: { measures: ['orders.order_count'], dimensions: [] },
|
|
format: 'json',
|
|
execute: false,
|
|
cliVersion: '0.2.0',
|
|
runtimeInstallPolicy: 'auto',
|
|
},
|
|
io.io,
|
|
{ createSemanticLayerCompute },
|
|
);
|
|
|
|
expect(code).toBe(0);
|
|
expect(io.stderr()).toContain('"event":"sl_query_completed"');
|
|
expect(io.stderr()).not.toContain(projectDir);
|
|
});
|
|
|
|
it('runs sl query from a JSON query file', async () => {
|
|
const projectDir = join(tempDir, 'project');
|
|
const project = await initKtxProject({ projectDir });
|
|
project.config.connections.warehouse = { driver: 'postgres' };
|
|
await project.fileStore.writeFile(
|
|
'semantic-layer/warehouse/orders.yaml',
|
|
`name: orders
|
|
table: public.orders
|
|
grain: [id]
|
|
columns:
|
|
- name: id
|
|
type: number
|
|
measures:
|
|
- name: order_count
|
|
expr: count(*)
|
|
joins: []
|
|
`,
|
|
'ktx',
|
|
'ktx@example.com',
|
|
'Add orders source',
|
|
);
|
|
const queryFile = join(tempDir, 'query.json');
|
|
await writeFile(queryFile, '{"measures":["orders.order_count"],"dimensions":[]}', 'utf-8');
|
|
|
|
const stdout = { write: vi.fn() };
|
|
const stderr = { write: vi.fn() };
|
|
const query = vi.fn(async () => ({
|
|
sql: 'select count(*) as order_count from public.orders',
|
|
dialect: 'postgres',
|
|
columns: [{ name: 'orders.order_count' }],
|
|
plan: {},
|
|
}));
|
|
const createSemanticLayerCompute = vi.fn(() => ({
|
|
query,
|
|
validateSources: vi.fn(),
|
|
generateSources: vi.fn(),
|
|
}));
|
|
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'query',
|
|
projectDir,
|
|
connectionId: 'warehouse',
|
|
queryFile,
|
|
format: 'json',
|
|
execute: false,
|
|
cliVersion: '0.2.0',
|
|
runtimeInstallPolicy: 'auto',
|
|
},
|
|
{ stdout, stderr },
|
|
{ createSemanticLayerCompute },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(query).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
query: { measures: ['orders.order_count'], dimensions: [] },
|
|
}),
|
|
);
|
|
expect(JSON.parse(String(stdout.write.mock.calls[0][0]))).toMatchObject({
|
|
sql: 'select count(*) as order_count from public.orders',
|
|
plan: { execution: { mode: 'compile_only' } },
|
|
});
|
|
expect(stderr.write).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('creates default sl query compute through the managed runtime helper', async () => {
|
|
const projectDir = join(tempDir, 'project');
|
|
const project = await initKtxProject({ projectDir });
|
|
project.config.connections.warehouse = { driver: 'postgres' };
|
|
await project.fileStore.writeFile(
|
|
'semantic-layer/warehouse/orders.yaml',
|
|
`name: orders
|
|
table: public.orders
|
|
grain: [id]
|
|
columns:
|
|
- name: id
|
|
type: number
|
|
measures:
|
|
- name: order_count
|
|
expr: count(*)
|
|
joins: []
|
|
`,
|
|
'ktx',
|
|
'ktx@example.com',
|
|
'Add orders source',
|
|
);
|
|
|
|
const stdout = { write: vi.fn() };
|
|
const stderr = { write: vi.fn() };
|
|
const compute = {
|
|
query: vi.fn(async () => ({
|
|
sql: 'select count(*) as order_count from public.orders',
|
|
dialect: 'postgres',
|
|
columns: [{ name: 'orders.order_count' }],
|
|
plan: {},
|
|
})),
|
|
validateSources: vi.fn(),
|
|
generateSources: vi.fn(),
|
|
};
|
|
const createManagedSemanticLayerCompute = vi.fn(async () => compute);
|
|
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'query',
|
|
projectDir,
|
|
connectionId: 'warehouse',
|
|
query: { measures: ['orders.order_count'], dimensions: [] },
|
|
format: 'sql',
|
|
execute: false,
|
|
cliVersion: '0.2.0',
|
|
runtimeInstallPolicy: 'auto',
|
|
},
|
|
{ stdout, stderr },
|
|
{ createManagedSemanticLayerCompute },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(createManagedSemanticLayerCompute).toHaveBeenCalledWith({
|
|
cliVersion: '0.2.0',
|
|
installPolicy: 'auto',
|
|
io: { stdout, stderr },
|
|
projectDir,
|
|
});
|
|
expect(stdout.write).toHaveBeenCalledWith('select count(*) as order_count from public.orders\n');
|
|
});
|
|
|
|
it('executes sl query through the injected query executor', async () => {
|
|
const projectDir = join(tempDir, 'project');
|
|
const project = await initKtxProject({ projectDir });
|
|
project.config.connections.warehouse = { driver: 'postgres', url: 'postgres://example/db' };
|
|
await project.fileStore.writeFile(
|
|
'semantic-layer/warehouse/orders.yaml',
|
|
`name: orders
|
|
table: public.orders
|
|
grain: [id]
|
|
columns:
|
|
- name: id
|
|
type: number
|
|
measures:
|
|
- name: order_count
|
|
expr: count(*)
|
|
joins: []
|
|
`,
|
|
'ktx',
|
|
'ktx@example.com',
|
|
'Add orders source',
|
|
);
|
|
|
|
const stdout = { write: vi.fn() };
|
|
const stderr = { write: vi.fn() };
|
|
const loadProject = vi.fn(async () => project);
|
|
const queryExecutor = {
|
|
execute: vi.fn(async () => ({
|
|
headers: ['orders.order_count'],
|
|
rows: [[4]],
|
|
totalRows: 1,
|
|
command: 'SELECT',
|
|
rowCount: 1,
|
|
})),
|
|
};
|
|
const createSemanticLayerCompute = vi.fn(() => ({
|
|
query: vi.fn(async () => ({
|
|
sql: 'select count(*) as order_count from public.orders',
|
|
dialect: 'postgres',
|
|
columns: [{ name: 'orders.order_count' }],
|
|
plan: {},
|
|
})),
|
|
validateSources: vi.fn(),
|
|
generateSources: vi.fn(),
|
|
}));
|
|
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'query',
|
|
projectDir,
|
|
connectionId: 'warehouse',
|
|
query: { measures: ['orders.order_count'], dimensions: [] },
|
|
format: 'json',
|
|
execute: true,
|
|
maxRows: 20,
|
|
cliVersion: '0.2.0',
|
|
runtimeInstallPolicy: 'auto',
|
|
},
|
|
{ stdout, stderr },
|
|
{
|
|
loadProject,
|
|
createSemanticLayerCompute,
|
|
createQueryExecutor: () => queryExecutor,
|
|
},
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(queryExecutor.execute).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
connectionId: 'warehouse',
|
|
maxRows: 20,
|
|
}),
|
|
);
|
|
expect(JSON.parse(String(stdout.write.mock.calls[0][0]))).toMatchObject({
|
|
rows: [[4]],
|
|
totalRows: 1,
|
|
plan: {
|
|
execution: {
|
|
mode: 'executed',
|
|
},
|
|
},
|
|
});
|
|
expect(stderr.write).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('executes sl query against a local SQLite connection through the default executor', async () => {
|
|
const projectDir = join(tempDir, 'project');
|
|
const project = await initKtxProject({ projectDir });
|
|
const dbPath = join(projectDir, 'warehouse.db');
|
|
const db = new Database(dbPath);
|
|
db.exec(`
|
|
CREATE TABLE orders (
|
|
id INTEGER PRIMARY KEY,
|
|
status TEXT NOT NULL
|
|
);
|
|
INSERT INTO orders (status) VALUES ('paid'), ('paid'), ('open');
|
|
`);
|
|
db.close();
|
|
|
|
project.config.connections.warehouse = { driver: 'sqlite', path: 'warehouse.db' };
|
|
await writeFile(
|
|
join(projectDir, 'ktx.yaml'),
|
|
[
|
|
'connections:',
|
|
' warehouse:',
|
|
' driver: sqlite',
|
|
' path: warehouse.db',
|
|
'',
|
|
].join('\n'),
|
|
'utf-8',
|
|
);
|
|
await project.fileStore.writeFile(
|
|
'semantic-layer/warehouse/orders.yaml',
|
|
`name: orders
|
|
table: orders
|
|
grain: [id]
|
|
columns:
|
|
- name: id
|
|
type: number
|
|
- name: status
|
|
type: string
|
|
measures:
|
|
- name: order_count
|
|
expr: count(*)
|
|
joins: []
|
|
`,
|
|
'ktx',
|
|
'ktx@example.com',
|
|
'Add orders source',
|
|
);
|
|
|
|
const stdout = { write: vi.fn() };
|
|
const stderr = { write: vi.fn() };
|
|
const createSemanticLayerCompute = vi.fn(() => ({
|
|
query: vi.fn(async () => ({
|
|
sql: 'select count(*) as order_count from orders',
|
|
dialect: 'sqlite',
|
|
columns: [{ name: 'orders.order_count' }],
|
|
plan: {},
|
|
})),
|
|
validateSources: vi.fn(),
|
|
generateSources: vi.fn(),
|
|
}));
|
|
|
|
const exitCode = await runKtxSl(
|
|
{
|
|
command: 'query',
|
|
projectDir,
|
|
connectionId: 'warehouse',
|
|
query: { measures: ['orders.order_count'], dimensions: [] },
|
|
format: 'json',
|
|
execute: true,
|
|
maxRows: 20,
|
|
cliVersion: '0.2.0',
|
|
runtimeInstallPolicy: 'auto',
|
|
},
|
|
{ stdout, stderr },
|
|
{ createSemanticLayerCompute },
|
|
);
|
|
|
|
expect(stderr.write).not.toHaveBeenCalled();
|
|
expect(exitCode).toBe(0);
|
|
expect(JSON.parse(String(stdout.write.mock.calls[0][0]))).toMatchObject({
|
|
connectionId: 'warehouse',
|
|
dialect: 'sqlite',
|
|
rows: [[3]],
|
|
totalRows: 1,
|
|
plan: {
|
|
execution: {
|
|
mode: 'executed',
|
|
driver: 'sqlite',
|
|
maxRows: 20,
|
|
rowCount: 1,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('emits sl list as a JSON envelope when output=json', async () => {
|
|
const projectDir = join(tempDir, 'project');
|
|
await seedSlSource({ projectDir });
|
|
|
|
const listIo = makeIo();
|
|
const code = await runKtxSl(
|
|
{ command: 'list', projectDir, connectionId: 'warehouse', output: 'json', cliVersion: '0.0.0-test' },
|
|
listIo.io,
|
|
);
|
|
expect(code).toBe(0);
|
|
expect(listIo.stderr()).toBe('');
|
|
|
|
const parsed = JSON.parse(listIo.stdout());
|
|
expect(parsed).toMatchObject({
|
|
kind: 'list',
|
|
data: {
|
|
items: expect.any(Array),
|
|
},
|
|
meta: {
|
|
command: 'sl list',
|
|
},
|
|
});
|
|
expect(parsed.data.items).toHaveLength(1);
|
|
expect(parsed.data.items[0]).toMatchObject({
|
|
connectionId: 'warehouse',
|
|
name: 'orders',
|
|
columnCount: 1,
|
|
measureCount: 0,
|
|
joinCount: 0,
|
|
});
|
|
});
|
|
|
|
it('search prints embeddings status when results are empty', async () => {
|
|
const stderr: string[] = [];
|
|
const io = {
|
|
stdout: { write: (_chunk: string) => {} },
|
|
stderr: {
|
|
write: (chunk: string) => {
|
|
stderr.push(chunk);
|
|
},
|
|
},
|
|
};
|
|
const projectDir = join(tempDir, 'empty-status');
|
|
const project = await initKtxProject({ projectDir });
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'search',
|
|
projectDir: project.projectDir,
|
|
query: 'nope',
|
|
cliVersion: '0.5.0',
|
|
},
|
|
io,
|
|
{
|
|
loadProject: async () => project,
|
|
resolveEmbeddingProvider: async () => ({
|
|
kind: 'managed-unavailable',
|
|
reason: 'managed embeddings daemon is not running',
|
|
}),
|
|
searchLocalSlSources: async () => [],
|
|
},
|
|
),
|
|
).resolves.toBe(0);
|
|
expect(stderr.join('')).toMatch(/embeddings: unavailable/);
|
|
expect(stderr.join('')).toMatch(/managed embeddings daemon is not running/);
|
|
});
|
|
|
|
it('passes a managed-daemon-backed embedding service into the search', async () => {
|
|
const projectDir = join(tempDir, 'resolver-project');
|
|
const project = await initKtxProject({ projectDir });
|
|
const search = vi.fn(async () => []);
|
|
const searchIo = makeIo();
|
|
await expect(
|
|
runKtxSl(
|
|
{
|
|
command: 'search',
|
|
projectDir: project.projectDir,
|
|
query: 'income',
|
|
cliVersion: '0.5.0',
|
|
json: true,
|
|
},
|
|
searchIo.io,
|
|
{
|
|
loadProject: async () => project,
|
|
resolveEmbeddingProvider: async () => ({
|
|
kind: 'managed-running',
|
|
provider: { id: 'fake' } as never,
|
|
baseUrl: 'http://127.0.0.1:51234',
|
|
}),
|
|
searchLocalSlSources: search,
|
|
},
|
|
),
|
|
).resolves.toBe(0);
|
|
expect(search).toHaveBeenCalledWith(
|
|
project,
|
|
expect.objectContaining({ embeddingService: expect.any(Object) }),
|
|
);
|
|
});
|
|
|
|
it('emits sl list with grouping and Clack-style framing when output=pretty', async () => {
|
|
const projectDir = join(tempDir, 'project');
|
|
await seedSlSource({ projectDir });
|
|
|
|
const listIo = makeIo();
|
|
const code = await runKtxSl(
|
|
{ command: 'list', projectDir, connectionId: 'warehouse', output: 'pretty', cliVersion: '0.0.0-test' },
|
|
listIo.io,
|
|
);
|
|
expect(code).toBe(0);
|
|
|
|
const stripAnsi = (s: string) => s.replace(/\[[0-9;]*m/g, '');
|
|
const out = stripAnsi(listIo.stdout());
|
|
expect(out).toContain('sl list');
|
|
expect(out).toContain('warehouse');
|
|
expect(out).toContain('orders');
|
|
expect(out).toContain('1 source');
|
|
});
|
|
});
|