feat(sqlite): implement connector scope listing

This commit is contained in:
Andrey Avtomonov 2026-05-25 13:36:06 +02:00
parent 8ec2acba40
commit 54dd9cc518
11 changed files with 82 additions and 1 deletions

View file

@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
import { getDialectForDriver } from '../../context/connections/dialects.js';
import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js';
import { normalizeQueryRows } from '../../context/connections/query-executor.js';
import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js';
import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
export interface KtxSqliteConnectionConfig {
@ -209,6 +209,30 @@ export class KtxSqliteScanConnector implements KtxScanConnector {
};
}
async listSchemas(): Promise<string[]> {
return [];
}
async listTables(_schemas?: string[]): Promise<KtxTableListEntry[]> {
const rows = this.database()
.prepare(
`
SELECT name, type
FROM sqlite_master
WHERE type IN ('table', 'view')
AND name NOT LIKE 'sqlite_%'
ORDER BY name
`,
)
.all() as SqliteMasterRow[];
return rows.map((row) => ({
schema: '',
name: row.name,
kind: row.type === 'view' ? ('view' as const) : ('table' as const),
}));
}
async sampleTable(input: KtxTableSampleInput, _ctx: KtxScanContext): Promise<KtxTableSampleResult> {
this.assertConnection(input.connectionId);
const result = this.query(this.dialect.generateSampleQuery(this.qTableName(input.table), input.limit, input.columns));

View file

@ -59,6 +59,8 @@ function nativeConnector(
introspect: vi.fn(async () => {
throw new Error('introspect should not be called from connection test');
}),
listSchemas: vi.fn(async () => []),
listTables: vi.fn(async () => []),
testConnection,
cleanup,
};

View file

@ -150,6 +150,20 @@ describe('KtxSqliteScanConnector', () => {
]);
});
it('lists schemaless tables and views for setup discovery', async () => {
const connector = new KtxSqliteScanConnector({
connectionId: 'warehouse',
connection: { driver: 'sqlite', path: dbPath },
});
await expect(connector.listSchemas()).resolves.toEqual([]);
await expect(connector.listTables(['ignored'])).resolves.toEqual([
{ schema: '', name: 'customers', kind: 'table' },
{ schema: '', name: 'orders', kind: 'table' },
{ schema: '', name: 'recent_orders', kind: 'view' },
]);
});
it('runs samples, distinct values, statistics, and read-only SQL', async () => {
const connector = new KtxSqliteScanConnector({
connectionId: 'warehouse',

View file

@ -56,6 +56,8 @@ describe('createLocalProjectMcpContextPorts', () => {
driver: snapshot.driver,
capabilities: createKtxConnectorCapabilities({ readOnlySql: queryResult !== undefined }),
introspect: vi.fn(async () => snapshot),
listSchemas: vi.fn(async () => []),
listTables: vi.fn(async () => []),
executeReadOnly: queryResult === undefined ? undefined : vi.fn(async () => queryResult),
cleanup: vi.fn(async () => {}),
};

View file

@ -72,6 +72,8 @@ function createConnector(): KtxScanConnector {
introspect: vi.fn(async () => {
throw new Error('introspection is not used by description generation');
}),
listSchemas: vi.fn(async () => []),
listTables: vi.fn(async () => []),
sampleColumn: vi.fn(async () => ({
values: ['paid', 'refunded', null],
nullCount: 1,

View file

@ -104,6 +104,8 @@ function connector(): KtxScanConnector {
columnStats: true,
}),
introspect: vi.fn(async () => snapshot),
listSchemas: vi.fn(async () => []),
listTables: vi.fn(async () => []),
sampleTable: vi.fn(async () => ({
headers: ['id', 'customer_id'],
rows: [[1, 10]],

View file

@ -16,6 +16,11 @@ import type {
KtxSchemaSnapshot,
} from '../../../src/context/scan/types.js';
const connectorScopeListing = {
listSchemas: vi.fn(async () => []),
listTables: vi.fn(async () => []),
};
function relationshipSqlResult(
input: KtxReadOnlyQueryInput,
options: { throwOnCoverage?: boolean } = {},
@ -254,6 +259,7 @@ function nativeScanConnector(options: { cleanup?: () => Promise<void> } = {}): K
formalForeignKeys: false,
estimatedRowCounts: false,
},
...connectorScopeListing,
introspect: vi.fn(async () => nativeScanSnapshot()),
sampleTable: vi.fn(async () => ({ headers: ['id'], rows: [[1]], totalRows: 1 })),
sampleColumn: vi.fn(async () => ({ values: ['1'], nullCount: 0, distinctCount: 1 })),
@ -656,6 +662,7 @@ describe('local scan', () => {
formalForeignKeys: false,
estimatedRowCounts: false,
},
...connectorScopeListing,
async introspect() {
return {
connectionId: 'warehouse',
@ -741,6 +748,7 @@ describe('local scan', () => {
formalForeignKeys: false,
estimatedRowCounts: true,
},
...connectorScopeListing,
async introspect() {
return {
connectionId: 'warehouse',
@ -930,6 +938,14 @@ describe('local scan', () => {
};
}
async listSchemas(): Promise<string[]> {
return [];
}
async listTables() {
return [];
}
async executeReadOnly(input: KtxReadOnlyQueryInput): Promise<KtxQueryResult> {
return relationshipSqlResult(input);
}
@ -972,6 +988,7 @@ describe('local scan', () => {
formalForeignKeys: false,
estimatedRowCounts: true,
},
...connectorScopeListing,
async introspect() {
return {
connectionId: 'warehouse',
@ -1073,6 +1090,7 @@ describe('local scan', () => {
formalForeignKeys: false,
estimatedRowCounts: true,
},
...connectorScopeListing,
async introspect() {
return {
connectionId: 'warehouse',
@ -1200,6 +1218,7 @@ describe('local scan', () => {
formalForeignKeys: false,
estimatedRowCounts: true,
},
...connectorScopeListing,
async introspect() {
return {
connectionId: 'warehouse',
@ -1340,6 +1359,7 @@ describe('local scan', () => {
formalForeignKeys: false,
estimatedRowCounts: true,
},
...connectorScopeListing,
async introspect() {
return {
connectionId: 'warehouse',
@ -1455,6 +1475,7 @@ describe('local scan', () => {
formalForeignKeys: false,
estimatedRowCounts: false,
},
...connectorScopeListing,
async introspect() {
return {
connectionId: 'warehouse',
@ -1550,6 +1571,7 @@ describe('local scan', () => {
formalForeignKeys: false,
estimatedRowCounts: false,
},
...connectorScopeListing,
async introspect() {
return {
connectionId: 'warehouse',
@ -1666,6 +1688,7 @@ describe('local scan', () => {
formalForeignKeys: false,
estimatedRowCounts: false,
},
...connectorScopeListing,
async introspect() {
return {
connectionId: 'warehouse',

View file

@ -213,6 +213,8 @@ function connector(executor: InMemorySqliteExecutor | null): KtxScanConnector {
columnSampling: false,
}),
introspect: async () => snapshot(),
listSchemas: async () => [],
listTables: async () => [],
executeReadOnly: executor ? executor.executeReadOnly.bind(executor) : undefined,
};
}
@ -645,6 +647,8 @@ describe('production relationship discovery', () => {
columnSampling: false,
}),
introspect: async () => maskedSnapshot,
listSchemas: async () => [],
listTables: async () => [],
executeReadOnly: async (input) => {
const rows = database.prepare(input.sql).all() as Record<string, unknown>[];
const headers = Object.keys(rows[0] ?? {});

View file

@ -93,6 +93,8 @@ describe('KTX scan contract types', () => {
expect(ctx.runId).toBe('scan-run-1');
return snapshot;
},
listSchemas: async () => [],
listTables: async () => [],
};
await expect(
@ -164,6 +166,8 @@ describe('KTX scan contract types', () => {
tables: [],
};
},
listSchemas: async () => [],
listTables: async () => [],
};
await expect(

View file

@ -31,6 +31,8 @@ function connector(overrides: Partial<KtxScanConnector> = {}): KtxScanConnector
})),
cleanup: vi.fn(async () => {}),
...overrides,
listSchemas: overrides.listSchemas ?? vi.fn(async () => []),
listTables: overrides.listTables ?? vi.fn(async () => []),
};
}

View file

@ -66,6 +66,8 @@ function makeConnector(overrides: Partial<KtxScanConnector> = {}): KtxScanConnec
})),
cleanup: vi.fn(async () => undefined),
...overrides,
listSchemas: overrides.listSchemas ?? vi.fn(async () => []),
listTables: overrides.listTables ?? vi.fn(async () => []),
};
}