ktx/packages/cli/test/connectors/duckdb/connector.test.ts
Kevin Messiaen 3c4fcc27c7
feat: Add duckdb connector (#308)
* refactor(duckdb): extract shared json-safe bigint helper

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(duckdb): add and register the duckdb primary connector

Add KtxDuckDbDialect, KtxDuckDbScanConnector (local file-backed, read-only,
never-create, main-schema introspection via information_schema and
duckdb_constraints() for foreign keys), and register the duckdb driver across
the dialect factory, driver registry, connection-type enum, warehouse descriptor,
config schema, scan normalization, connection test drivers, and status display.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(duckdb): route live-database ingest through the DuckDB connector

Add the DuckDB live-database introspection bridge and dispatch duckdb
connections to it in local-adapters, matching the SQLite path. Repoint the
config-rejection test off duckdb (now a valid driver) onto the no-driver case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(duckdb): add duckdb to the setup database flow

Offer DuckDB in the interactive checklist and via ktx setup --database duckdb,
with a file-path prompt and duckdb-local default connection id, parallel to SQLite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(duckdb): attach native duckdb files in federation

Native .duckdb members ATTACH with (READ_ONLY) and no TYPE/INSTALL/LOAD, since
the duckdb format needs no extension. attachTypeForDriver returns null for the
native case; buildAttachStatements builds load statements from non-null types
only and emits a conditional ATTACH clause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(duckdb): document the duckdb primary-source connector

Add a DuckDB section to the primary-sources integration page (config, read-only
never-create behavior, main-schema scope, federation) and update the
supported-driver assertion in dialects.test.ts to include duckdb.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(duckdb): use single-namespace display shape for main-only refs

DuckDB v1 introspects the main schema and sets db=null on every table, so its
display refs are single-namespace like SQLite. The ansi shape emitted a 1-part
table display it then refused to parse, breaking column-level display resolution.
Switch the dialect to the sqlite display shape and add a round-trip test plus a
composite-foreign-key test that were missing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(duckdb): resolve connector dialect via getDialectForDriver

Route the connector's dialect through the shared factory like every other
connector, now that duckdb is registered. Single construction path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(duckdb): skip schema picker for single-file duckdb setup

DuckDB is a single-file, single-namespace ('main') database like SQLite,
but the setup scope step only skipped the schema picker for sqlite. DuckDB
fell into the multi-schema path with an empty schema list, rendering a
broken picker ("No matches found" for main). Extend the file-based-driver
early-return to cover duckdb so it ingests every table directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(duckdb): reuse shared config helper and derive scope skip

Route duckdb path resolution through the shared resolveStringReference
helper instead of a local third copy of env:/file: handling. Derive the
setup scope-picker skip from SCOPE_DISCOVERY_SPECS membership rather than
a hardcoded sqlite/duckdb driver list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(duckdb): use a genuinely unknown driver in the rejection test

The merged "rejects unknown drivers" test used `driver: duckdb` as its
unknown-driver stand-in, which stopped being unknown once this branch
added the duckdb connector. Switch to `nonsense` so it again exercises
the unsupported-driver config error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(duckdb): cover dialect, connector, and live-introspection branches

Codecov flagged uncovered branches as dead code; all are real connector,
dialect, and live-ingest behavior. Add unit tests instead of removing them.

- dialect: precedence ladder, sample/clause builders, profiling expressions
- connector: url/env config forms, error throws, never-create guard,
  cardinality cap branches, table-scope empty/non-empty paths
- live-introspection: full-schema and table-scope extraction

Functions 100%, lines ~99% across the duckdb connector dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: add DuckDB to supported-driver references

The DuckDB connector PR documented the connector itself but left the
scattered supported-driver enumerations stale. Add duckdb to the
federation concept page (participation table, activation, table naming,
limitations), the ktx setup CLI reference, the ktx.yaml warehouse-driver
table, the primary-sources field reference, and the quickstart driver
list (which also restores the missing ClickHouse entry).

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Andrey Avtomonov <andreybavt@gmail.com>
2026-07-01 12:06:02 +00:00

280 lines
10 KiB
TypeScript

import { DuckDBInstance } from '@duckdb/node-api';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
KtxDuckDbScanConnector,
duckDbDatabasePathFromConfig,
isKtxDuckDbConnectionConfig,
} from '../../../src/connectors/duckdb/connector.js';
import { tableRefSet } from '../../../src/context/scan/table-ref.js';
let dir: string;
let dbPath: string;
beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), 'ktx-duckdb-'));
dbPath = join(dir, 'warehouse.duckdb');
const instance = await DuckDBInstance.create(dbPath);
const connection = await instance.connect();
await connection.run('CREATE TABLE customers (id BIGINT PRIMARY KEY, name VARCHAR, big BIGINT)');
await connection.run(
`INSERT INTO customers VALUES (1, 'Ada', 9223372036854775807), (2, 'Lin', 10)`,
);
await connection.run('CREATE TABLE orders (id BIGINT, customer_id BIGINT REFERENCES customers(id))');
await connection.run('INSERT INTO orders VALUES (1, 1), (2, 2)');
// Composite primary key + composite foreign key, to exercise the parallel
// unnest() zip of constraint/referenced column names in readForeignKeys.
await connection.run('CREATE TABLE regions (country VARCHAR, code VARCHAR, PRIMARY KEY (country, code))');
await connection.run(
'CREATE TABLE stores (id BIGINT, country VARCHAR, code VARCHAR, FOREIGN KEY (country, code) REFERENCES regions(country, code))',
);
await connection.run('CREATE TABLE empty_table (id BIGINT)');
connection.closeSync();
instance.closeSync();
});
afterAll(async () => {
await rm(dir, { recursive: true, force: true });
});
function connector(connection: Record<string, unknown> = { driver: 'duckdb', path: dbPath }) {
return new KtxDuckDbScanConnector({ connectionId: 'warehouse', connection, projectDir: dir });
}
describe('isKtxDuckDbConnectionConfig', () => {
it('accepts duckdb driver, rejects others', () => {
expect(isKtxDuckDbConnectionConfig({ driver: 'duckdb' })).toBe(true);
expect(isKtxDuckDbConnectionConfig({ driver: 'sqlite' })).toBe(false);
});
});
describe('duckDbDatabasePathFromConfig', () => {
it('resolves a relative path against projectDir', () => {
const resolved = duckDbDatabasePathFromConfig({
connectionId: 'warehouse',
projectDir: dir,
connection: { driver: 'duckdb', path: 'warehouse.duckdb' },
});
expect(resolved).toBe(dbPath);
});
it('derives the path from a file: url', () => {
const resolved = duckDbDatabasePathFromConfig({
connectionId: 'warehouse',
connection: { driver: 'duckdb', url: pathToFileURL(dbPath).href },
});
expect(resolved).toBe(dbPath);
});
it('derives the path from a duckdb: url', () => {
const resolved = duckDbDatabasePathFromConfig({
connectionId: 'warehouse',
connection: { driver: 'duckdb', url: `duckdb://${dbPath}` },
});
expect(resolved).toBe(dbPath);
});
it('resolves an env: reference in path', () => {
process.env.KTX_TEST_DUCKDB_PATH = dbPath;
try {
const resolved = duckDbDatabasePathFromConfig({
connectionId: 'warehouse',
connection: { driver: 'duckdb', path: 'env:KTX_TEST_DUCKDB_PATH' },
});
expect(resolved).toBe(dbPath);
} finally {
delete process.env.KTX_TEST_DUCKDB_PATH;
}
});
it('rejects a non-duckdb driver', () => {
expect(() =>
duckDbDatabasePathFromConfig({
connectionId: 'warehouse',
connection: { driver: 'sqlite', path: dbPath },
}),
).toThrow(/cannot run driver "sqlite"/);
});
it('requires a path or url', () => {
expect(() =>
duckDbDatabasePathFromConfig({
connectionId: 'warehouse',
connection: { driver: 'duckdb' },
}),
).toThrow(/requires connections\.warehouse\.path or url/);
});
});
describe('KtxDuckDbScanConnector', () => {
it('testConnection succeeds for an existing file', async () => {
const c = connector();
expect(await c.testConnection()).toEqual({ success: true });
await c.cleanup();
});
it('testConnection fails (never creating) for a missing file', async () => {
const c = connector({ driver: 'duckdb', path: join(dir, 'absent.duckdb') });
const result = await c.testConnection();
expect(result.success).toBe(false);
await c.cleanup();
});
it('introspects main-schema tables, columns, and foreign keys', async () => {
const c = connector();
const snapshot = await c.introspect({ connectionId: 'warehouse', driver: 'duckdb' }, { runId: 't' });
const names = snapshot.tables.map((t) => t.name).sort();
expect(names).toEqual(['customers', 'empty_table', 'orders', 'regions', 'stores']);
const orders = snapshot.tables.find((t) => t.name === 'orders');
expect(orders?.foreignKeys[0]).toMatchObject({ fromColumn: 'customer_id', toTable: 'customers', toColumn: 'id' });
await c.cleanup();
});
it('maps a composite foreign key column-for-column to the referenced table', async () => {
const c = connector();
const snapshot = await c.introspect({ connectionId: 'warehouse', driver: 'duckdb' }, { runId: 't' });
const stores = snapshot.tables.find((t) => t.name === 'stores');
const fks = stores?.foreignKeys.map((fk) => ({ fromColumn: fk.fromColumn, toTable: fk.toTable, toColumn: fk.toColumn }));
expect(fks).toEqual([
{ fromColumn: 'country', toTable: 'regions', toColumn: 'country' },
{ fromColumn: 'code', toTable: 'regions', toColumn: 'code' },
]);
await c.cleanup();
});
it('lists tables', async () => {
const c = connector();
const tables = (await c.listTables()).map((t) => t.name).sort();
expect(tables).toEqual(['customers', 'empty_table', 'orders', 'regions', 'stores']);
await c.cleanup();
});
it('samples a table', async () => {
const c = connector();
const sample = await c.sampleTable(
{ connectionId: 'warehouse', table: { name: 'customers', catalog: null, db: null }, limit: 1 },
{ runId: 't' },
);
expect(sample.rows.length).toBe(1);
await c.cleanup();
});
it('stringifies BIGINT beyond 2^53 in read-only results', async () => {
const c = connector();
const result = await c.executeReadOnly(
{ connectionId: 'warehouse', sql: 'SELECT big FROM customers WHERE id = 1', maxRows: 10 },
{ runId: 't' },
);
expect(result.rows[0][0]).toBe('9223372036854775807');
await c.cleanup();
});
it('rejects non-read-only SQL', async () => {
const c = connector();
await expect(
c.executeReadOnly({ connectionId: 'warehouse', sql: 'DELETE FROM customers', maxRows: 10 }, { runId: 't' }),
).rejects.toThrow();
await c.cleanup();
});
it('returns distinct values under the cardinality cap', async () => {
const c = connector();
const distinct = await c.getColumnDistinctValues({ name: 'customers', catalog: null, db: null }, 'name', {
maxCardinality: 10,
limit: 10,
});
expect(distinct?.values?.sort()).toEqual(['Ada', 'Lin']);
await c.cleanup();
});
it('withholds values but reports the count when cardinality exceeds the cap', async () => {
const c = connector();
const distinct = await c.getColumnDistinctValues({ name: 'customers', catalog: null, db: null }, 'name', {
maxCardinality: 1,
limit: 10,
});
expect(distinct).toEqual({ values: null, cardinality: 2 });
await c.cleanup();
});
it('samples a single column, dropping null rows', async () => {
const c = connector();
const sample = await c.sampleColumn(
{ connectionId: 'warehouse', table: { name: 'customers', catalog: null, db: null }, column: 'name', limit: 10 },
{ runId: 't' },
);
expect(sample.values.sort()).toEqual(['Ada', 'Lin']);
expect(sample.nullCount).toBeNull();
await c.cleanup();
});
it('counts table rows', async () => {
const c = connector();
expect(await c.getTableRowCount('customers')).toBe(2);
await c.cleanup();
});
it('lists only the main schema and reports no column stats', async () => {
const c = connector();
expect(await c.listSchemas()).toEqual(['main']);
expect(await c.columnStats({ connectionId: 'warehouse', table: { name: 'customers', catalog: null, db: null }, column: 'id' }, { runId: 't' })).toBeNull();
await c.cleanup();
});
it('rejects operations for a mismatched connection id', async () => {
const c = connector();
await expect(
c.executeReadOnly({ connectionId: 'other', sql: 'SELECT 1', maxRows: 1 }, { runId: 't' }),
).rejects.toThrow(/cannot serve connection other/);
await c.cleanup();
});
it('exposes the dialect identifier quoting', () => {
expect(connector().quoteIdentifier('a"b')).toBe('"a""b"');
});
// Opening a connection must never create the file: the db() guard throws
// rather than letting DuckDBInstance.create() materialize a missing path.
it('refuses to open (never creating) a missing file when a query runs', async () => {
const c = connector({ driver: 'duckdb', path: join(dir, 'absent.duckdb') });
await expect(c.listTables()).rejects.toThrow(/File not found/);
await c.cleanup();
});
it('returns no tables for an empty table scope', async () => {
const c = connector();
const snapshot = await c.introspect(
{ connectionId: 'warehouse', driver: 'duckdb', tableScope: new Set() },
{ runId: 't' },
);
expect(snapshot.tables).toEqual([]);
await c.cleanup();
});
it('restricts introspection to the named tables in a non-empty scope', async () => {
const c = connector();
const snapshot = await c.introspect(
{
connectionId: 'warehouse',
driver: 'duckdb',
tableScope: tableRefSet([{ catalog: null, db: null, name: 'customers' }]),
},
{ runId: 't' },
);
expect(snapshot.tables.map((t) => t.name)).toEqual(['customers']);
await c.cleanup();
});
it('reports zero cardinality and an empty value list for an empty column', async () => {
const c = connector();
const distinct = await c.getColumnDistinctValues({ name: 'empty_table', catalog: null, db: null }, 'id', {
maxCardinality: 10,
limit: 10,
});
expect(distinct).toEqual({ values: [], cardinality: 0 });
await c.cleanup();
});
});