mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
* feat(cli): define full warehouse dialect contract
* test(cli): keep dialect edge tests focused
* fix(cli): stabilize dialect contract foundation
* refactor(connectors): own read-only query preparation
* refactor(connectors): resolve dialects through registry
* refactor(connectors): keep concrete dialect classes internal
* chore(workspace): enforce dialect import boundary
* refactor(cli): resolve relationship dialect at scan boundary
* refactor(cli): use dialect display parsing for entity details
* refactor(cli): use dialect display parsing for warehouse catalog
* refactor(cli): use dialect SQL in relationship workflows
* test(cli): verify solid dialect scan workflow closure
* test: split cli tests from source tree
* refactor(cli): standardize BigQuery scope listing
* feat(sqlite): implement connector scope listing
* test(connectors): cover required table listing
* feat(cli): add warehouse driver registry
* refactor(setup): route scope discovery through driver registry
* refactor(cli): route local query execution through driver registry
* refactor(historic-sql): route dialect support through driver registry
* refactor(cli): test warehouse connections through driver registry
* fix(cli): close driver registry type export gaps
* Improve setup daemon diagnostics
* refactor(setup): centralize rail-prefixed diagnostics + query-history fallback
Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput
into clack.ts so the setup wizard, managed daemons, and embedding/agent steps
share one rail-formatted writer. setup-databases.ts also adds a
"disable query history and retry" option when the schema-context build fails
and query history is the likely culprit, surfaced via a new
failed-query-history-unavailable status.
* fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match
The setup picker's KtxTableListEntry was a 2-level { schema, name }, so
qualifiedTableId always wrote db.name into enabled_tables. When BigQuery,
Snowflake, or SQL Server later ran fast ingest, their introspect step filtered
the scope set with scopedTableNames(scope, { catalog: projectId|database, db })
— catalog was non-null on the introspect side but null in the scope refs, so
every entry was rejected, the live-database adapter staged zero table files,
and detect() failed with 'Adapter "live-database" did not recognize fetched
source output'.
Align the picker boundary with the canonical 3-level KtxTableRef:
- Add catalog: string | null to KtxTableListEntry.
- BigQuery/Snowflake/SQL Server listTables populate catalog from the
resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null.
- qualifiedTableId emits catalog.schema.name when catalog is non-null
(resolveEnabledTables already accepts the 3-part shape) and
schemasFromEnabledTables now goes through parseDottedTableEntry so it
recovers the schema correctly from both 2-part and 3-part entries.
- Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker
reuse.
Update listTables expectations in all seven connector tests and the setup /
picker test fixtures. Add a picker regression test that covers the
catalog-bearing round-trip (save + refine).
* fix(cli): allow debug telemetry under opt-out env
385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import type { StagedExploreFile, StagedLookmlModelsFile } from '../../../../../src/context/ingest/adapters/looker/types.js';
|
|
import {
|
|
buildLookerPullConfigFromInputs,
|
|
collectExploreParseItems,
|
|
computeLookerMappingDrift,
|
|
discoverLookerConnections,
|
|
lookerDialectToConnectionType,
|
|
projectParsedIdentifier,
|
|
refreshLookerMappingPlaceholders,
|
|
sqlglotDialectForConnectionType,
|
|
suggestKtxConnectionForLookerConnection,
|
|
validateLookerMappings,
|
|
validateLookerWarehouseTarget,
|
|
} from '../../../../../src/context/ingest/adapters/looker/mapping.js';
|
|
|
|
const liveConnections = [
|
|
{
|
|
name: 'b2b_sandbox_bq',
|
|
host: 'warehouse.example.com',
|
|
database: 'analytics',
|
|
schema: null,
|
|
dialect: 'bigquery_standard_sql',
|
|
},
|
|
{
|
|
name: 'pg_runtime',
|
|
host: 'pg.internal:5432',
|
|
database: 'app',
|
|
schema: 'public',
|
|
dialect: 'postgres',
|
|
},
|
|
];
|
|
|
|
const mappedExplore: StagedExploreFile = {
|
|
modelName: 'b2b',
|
|
exploreName: 'sales_pipeline',
|
|
label: 'Sales Pipeline',
|
|
description: null,
|
|
rawSqlTableName: 'proj.analytics.opportunities AS opportunities',
|
|
connectionName: 'b2b_sandbox_bq',
|
|
viewName: 'opportunities',
|
|
fields: { dimensions: [], measures: [] },
|
|
joins: [
|
|
{
|
|
name: 'accounts',
|
|
type: 'left_outer',
|
|
relationship: 'many_to_one',
|
|
rawSqlTableName: 'proj.analytics.accounts',
|
|
sqlOn: null,
|
|
from: null,
|
|
targetTable: null,
|
|
},
|
|
],
|
|
targetWarehouseConnectionId: null,
|
|
targetTable: null,
|
|
};
|
|
|
|
const models: StagedLookmlModelsFile = {
|
|
models: [{ name: 'b2b', label: 'B2B', explores: [{ name: 'sales_pipeline', label: 'Sales Pipeline' }] }],
|
|
};
|
|
|
|
describe('discoverLookerConnections', () => {
|
|
it('delegates to the runtime client connection discovery method', async () => {
|
|
const client = { listLookerConnections: vi.fn().mockResolvedValue(liveConnections) };
|
|
|
|
await expect(discoverLookerConnections(client)).resolves.toEqual(liveConnections);
|
|
expect(client.listLookerConnections).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('looker dialect and target validation helpers', () => {
|
|
it('maps Looker dialect names to KTX connection types', () => {
|
|
expect(lookerDialectToConnectionType('bigquery_standard_sql')).toBe('BIGQUERY');
|
|
expect(lookerDialectToConnectionType('postgres')).toBe('POSTGRESQL');
|
|
expect(lookerDialectToConnectionType('mssql')).toBeNull();
|
|
expect(lookerDialectToConnectionType('tsql')).toBeNull();
|
|
expect(lookerDialectToConnectionType('unknown')).toBeNull();
|
|
});
|
|
|
|
it('maps supported warehouse connection types to sqlglot dialects', () => {
|
|
expect(sqlglotDialectForConnectionType('BIGQUERY')).toBe('bigquery');
|
|
expect(sqlglotDialectForConnectionType('POSTGRESQL')).toBe('postgres');
|
|
expect(sqlglotDialectForConnectionType('LOOKER')).toBeNull();
|
|
});
|
|
|
|
it('returns a structured failure for unsupported Looker warehouse targets', () => {
|
|
expect(validateLookerWarehouseTarget('LOOKER')).toEqual({
|
|
ok: false,
|
|
reason: 'Connection type LOOKER cannot be used as a Looker warehouse mapping target',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('suggestKtxConnectionForLookerConnection', () => {
|
|
it('returns the single deterministic target with matching type, host, and database', () => {
|
|
expect(
|
|
suggestKtxConnectionForLookerConnection({
|
|
lookerConnection: liveConnections[1],
|
|
candidateConnections: [
|
|
{
|
|
id: 'wrong-type',
|
|
connection_type: 'MYSQL',
|
|
connection_params: { host: 'pg.internal', database: 'app' },
|
|
},
|
|
{
|
|
id: 'pg-target',
|
|
connection_type: 'POSTGRESQL',
|
|
connection_params: { host: 'PG.INTERNAL', database: 'APP' },
|
|
},
|
|
],
|
|
}),
|
|
).toBe('pg-target');
|
|
});
|
|
|
|
it('returns null when more than one target matches', () => {
|
|
expect(
|
|
suggestKtxConnectionForLookerConnection({
|
|
lookerConnection: liveConnections[1],
|
|
candidateConnections: [
|
|
{
|
|
id: 'first',
|
|
connection_type: 'POSTGRESQL',
|
|
connection_params: { host: 'pg.internal', database: 'app' },
|
|
},
|
|
{
|
|
id: 'second',
|
|
connection_type: 'POSTGRESQL',
|
|
connection_params: { host: 'pg.internal:5432', database: 'APP' },
|
|
},
|
|
],
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('refreshLookerMappingPlaceholders', () => {
|
|
it('adds newly discovered placeholders and refreshes live metadata without dropping saved targets', () => {
|
|
expect(
|
|
refreshLookerMappingPlaceholders({
|
|
stored: [
|
|
{
|
|
lookerConnectionName: 'b2b_sandbox_bq',
|
|
ktxConnectionId: 'warehouse',
|
|
lookerHost: null,
|
|
lookerDatabase: null,
|
|
lookerDialect: null,
|
|
},
|
|
],
|
|
live: liveConnections,
|
|
}),
|
|
).toEqual({
|
|
changed: true,
|
|
mappings: [
|
|
{
|
|
lookerConnectionName: 'b2b_sandbox_bq',
|
|
ktxConnectionId: 'warehouse',
|
|
lookerHost: 'warehouse.example.com',
|
|
lookerDatabase: 'analytics',
|
|
lookerDialect: 'bigquery_standard_sql',
|
|
},
|
|
{
|
|
lookerConnectionName: 'pg_runtime',
|
|
ktxConnectionId: null,
|
|
lookerHost: 'pg.internal:5432',
|
|
lookerDatabase: 'app',
|
|
lookerDialect: 'postgres',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('computeLookerMappingDrift and validateLookerMappings', () => {
|
|
it('reports unmapped live connections, stale stored mappings, and in-sync mappings', () => {
|
|
expect(
|
|
computeLookerMappingDrift({
|
|
storedMappings: [
|
|
{
|
|
lookerConnectionName: 'b2b_sandbox_bq',
|
|
ktxConnectionId: 'warehouse',
|
|
lookerHost: null,
|
|
lookerDatabase: null,
|
|
lookerDialect: null,
|
|
},
|
|
{
|
|
lookerConnectionName: 'stale_runtime',
|
|
ktxConnectionId: 'warehouse',
|
|
lookerHost: null,
|
|
lookerDatabase: null,
|
|
lookerDialect: null,
|
|
},
|
|
],
|
|
discovered: liveConnections,
|
|
}),
|
|
).toEqual({
|
|
unmappedDiscovered: [liveConnections[1]],
|
|
staleMappings: [{ lookerConnectionName: 'stale_runtime', reason: 'looker_connection_not_found' }],
|
|
inSync: [{ lookerConnectionName: 'b2b_sandbox_bq', ktxConnectionId: 'warehouse' }],
|
|
});
|
|
});
|
|
|
|
it('validates missing and unsupported target connection ids', () => {
|
|
expect(
|
|
validateLookerMappings({
|
|
mappings: [
|
|
{
|
|
lookerConnectionName: 'b2b_sandbox_bq',
|
|
ktxConnectionId: 'missing',
|
|
lookerHost: null,
|
|
lookerDatabase: null,
|
|
lookerDialect: null,
|
|
},
|
|
{
|
|
lookerConnectionName: 'pg_runtime',
|
|
ktxConnectionId: 'looker-target',
|
|
lookerHost: null,
|
|
lookerDatabase: null,
|
|
lookerDialect: null,
|
|
},
|
|
],
|
|
knownKtxConnectionIds: new Set(['looker-target']),
|
|
knownConnectionTypes: new Map([['looker-target', 'LOOKER']]),
|
|
}),
|
|
).toEqual({
|
|
ok: false,
|
|
errors: [
|
|
{ key: 'b2b_sandbox_bq', reason: 'KTX connection missing does not exist' },
|
|
{
|
|
key: 'pg_runtime',
|
|
reason: 'Connection type LOOKER cannot be used as a Looker warehouse mapping target',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('collectExploreParseItems and projectParsedIdentifier', () => {
|
|
it('collects base explore and join parser inputs for mapped explores', () => {
|
|
expect(
|
|
collectExploreParseItems({
|
|
explore: mappedExplore,
|
|
connectionMappings: { b2b_sandbox_bq: 'warehouse' },
|
|
targetConnections: new Map([['warehouse', { id: 'warehouse', connection_type: 'BIGQUERY' }]]),
|
|
}),
|
|
).toEqual({
|
|
parsedTargetTables: {},
|
|
parseItems: [
|
|
{
|
|
key: 'b2b.sales_pipeline',
|
|
sql_table_name: 'proj.analytics.opportunities AS opportunities',
|
|
dialect: 'bigquery',
|
|
},
|
|
{
|
|
key: 'b2b.sales_pipeline.accounts',
|
|
sql_table_name: 'proj.analytics.accounts',
|
|
dialect: 'bigquery',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('projects successful and failed parser rows into KTX parsed target tables', () => {
|
|
expect(
|
|
projectParsedIdentifier({
|
|
ok: true,
|
|
catalog: 'proj',
|
|
schema: 'analytics',
|
|
name: 'accounts',
|
|
canonical_table: 'proj.analytics.accounts',
|
|
}),
|
|
).toEqual({
|
|
ok: true,
|
|
catalog: 'proj',
|
|
schema: 'analytics',
|
|
name: 'accounts',
|
|
canonicalTable: 'proj.analytics.accounts',
|
|
});
|
|
|
|
expect(projectParsedIdentifier({ ok: false, reason: 'derived_table_not_supported' })).toEqual({
|
|
ok: false,
|
|
reason: 'derived_table_not_supported',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('buildLookerPullConfigFromInputs', () => {
|
|
it('builds the hosted-equivalent Looker pull config from caller-loaded inputs', async () => {
|
|
const parser = {
|
|
parse: vi.fn().mockResolvedValue({
|
|
'b2b.sales_pipeline': {
|
|
ok: true,
|
|
catalog: 'proj',
|
|
schema: 'analytics',
|
|
name: 'opportunities',
|
|
canonical_table: 'proj.analytics.opportunities',
|
|
},
|
|
'b2b.sales_pipeline.accounts': {
|
|
ok: true,
|
|
catalog: 'proj',
|
|
schema: 'analytics',
|
|
name: 'accounts',
|
|
canonical_table: 'proj.analytics.accounts',
|
|
},
|
|
}),
|
|
};
|
|
const client = {
|
|
listLookmlModels: vi.fn().mockResolvedValue(models),
|
|
getExplore: vi.fn().mockResolvedValue(mappedExplore),
|
|
};
|
|
|
|
await expect(
|
|
buildLookerPullConfigFromInputs({
|
|
lookerConnectionId: 'prod-looker',
|
|
cursors: {
|
|
dashboardsLastSyncedAt: '2026-05-01T00:00:00.000Z',
|
|
looksLastSyncedAt: null,
|
|
},
|
|
refreshedMappings: [
|
|
{
|
|
lookerConnectionName: 'b2b_sandbox_bq',
|
|
ktxConnectionId: 'warehouse',
|
|
lookerHost: 'warehouse.example.com',
|
|
lookerDatabase: 'analytics',
|
|
lookerDialect: 'bigquery_standard_sql',
|
|
},
|
|
],
|
|
targetConnections: new Map([['warehouse', { id: 'warehouse', connection_type: 'BIGQUERY' }]]),
|
|
client,
|
|
parser,
|
|
}),
|
|
).resolves.toEqual({
|
|
lookerConnectionId: 'prod-looker',
|
|
dashboardUpdatedSince: '2026-05-01T00:00:00.000Z',
|
|
lookUpdatedSince: null,
|
|
connectionMappings: { b2b_sandbox_bq: 'warehouse' },
|
|
connectionTypes: { b2b_sandbox_bq: 'BIGQUERY' },
|
|
parsedTargetTables: {
|
|
'b2b.sales_pipeline': {
|
|
ok: true,
|
|
catalog: 'proj',
|
|
schema: 'analytics',
|
|
name: 'opportunities',
|
|
canonicalTable: 'proj.analytics.opportunities',
|
|
},
|
|
'b2b.sales_pipeline.accounts': {
|
|
ok: true,
|
|
catalog: 'proj',
|
|
schema: 'analytics',
|
|
name: 'accounts',
|
|
canonicalTable: 'proj.analytics.accounts',
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('marks parser failures as parse_error without blocking pull-config construction', async () => {
|
|
const parser = { parse: vi.fn().mockRejectedValue(new Error('python unavailable')) };
|
|
const client = {
|
|
listLookmlModels: vi.fn().mockResolvedValue(models),
|
|
getExplore: vi.fn().mockResolvedValue(mappedExplore),
|
|
};
|
|
|
|
const config = await buildLookerPullConfigFromInputs({
|
|
lookerConnectionId: 'prod-looker',
|
|
cursors: { dashboardsLastSyncedAt: null, looksLastSyncedAt: null },
|
|
refreshedMappings: [
|
|
{
|
|
lookerConnectionName: 'b2b_sandbox_bq',
|
|
ktxConnectionId: 'warehouse',
|
|
lookerHost: null,
|
|
lookerDatabase: null,
|
|
lookerDialect: null,
|
|
},
|
|
],
|
|
targetConnections: new Map([['warehouse', { id: 'warehouse', connection_type: 'BIGQUERY' }]]),
|
|
client,
|
|
parser,
|
|
});
|
|
|
|
expect(config.parsedTargetTables).toMatchObject({
|
|
'b2b.sales_pipeline': { ok: false, reason: 'parse_error' },
|
|
'b2b.sales_pipeline.accounts': { ok: false, reason: 'parse_error' },
|
|
});
|
|
});
|
|
});
|