@kaelio/ktx 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/python/kaelio_ktx-0.16.0-py3-none-any.whl +0 -0
- package/assets/python/manifest.json +4 -4
- package/dist/.tsbuildinfo +1 -1
- package/dist/commands/setup-commands.js +1 -0
- package/dist/connection-drivers.d.ts +1 -1
- package/dist/connection-drivers.js +2 -0
- package/dist/connection.js +1 -0
- package/dist/connectors/athena/connector.d.ts +160 -0
- package/dist/connectors/athena/connector.js +378 -0
- package/dist/connectors/athena/dialect.d.ts +31 -0
- package/dist/connectors/athena/dialect.js +146 -0
- package/dist/connectors/athena/live-database-introspection.d.ts +10 -0
- package/dist/connectors/athena/live-database-introspection.js +24 -0
- package/dist/connectors/duckdb/connector.d.ts +60 -0
- package/dist/connectors/duckdb/connector.js +282 -0
- package/dist/connectors/duckdb/dialect.d.ts +31 -0
- package/dist/connectors/duckdb/dialect.js +155 -0
- package/dist/connectors/duckdb/federated-attach.js +7 -0
- package/dist/connectors/duckdb/federated-executor.js +7 -13
- package/dist/connectors/duckdb/live-database-introspection.d.ts +8 -0
- package/dist/connectors/duckdb/live-database-introspection.js +24 -0
- package/dist/connectors/shared/duckdb-json-safe.d.ts +3 -0
- package/dist/connectors/shared/duckdb-json-safe.js +12 -0
- package/dist/context/connections/connection-type-dialect.d.ts +2 -0
- package/dist/context/connections/connection-type-dialect.js +20 -0
- package/dist/context/connections/connection-type.d.ts +6 -15
- package/dist/context/connections/connection-type.js +1 -10
- package/dist/context/connections/dialects.js +4 -0
- package/dist/context/connections/drivers.js +38 -0
- package/dist/context/connections/federation.d.ts +1 -1
- package/dist/context/connections/federation.js +6 -5
- package/dist/context/connections/local-warehouse-descriptor.d.ts +1 -0
- package/dist/context/connections/local-warehouse-descriptor.js +10 -0
- package/dist/context/connections/project-sql-executor.d.ts +24 -0
- package/dist/context/connections/project-sql-executor.js +56 -0
- package/dist/context/connections/query-policy.d.ts +12 -0
- package/dist/context/connections/query-policy.js +46 -0
- package/dist/context/core/config-reference.js +11 -1
- package/dist/context/daemon/semantic-layer-compute.d.ts +16 -0
- package/dist/context/daemon/semantic-layer-compute.js +44 -2
- package/dist/context/ingest/adapters/looker/mapping.d.ts +1 -0
- package/dist/context/ingest/adapters/looker/mapping.js +3 -10
- package/dist/context/ingest/adapters/looker/types.d.ts +6 -15
- package/dist/context/ingest/adapters/metabase/mapping.d.ts +1 -0
- package/dist/context/ingest/adapters/metabase/mapping.js +1 -0
- package/dist/context/mcp/context-tools.js +1 -0
- package/dist/context/mcp/local-project-ports.d.ts +1 -1
- package/dist/context/mcp/local-project-ports.js +53 -51
- package/dist/context/project/config.d.ts +92 -0
- package/dist/context/project/driver-schemas.d.ts +46 -0
- package/dist/context/project/driver-schemas.js +8 -0
- package/dist/context/scan/local-scan.js +3 -1
- package/dist/context/scan/types.d.ts +1 -1
- package/dist/context/sl/local-query.js +23 -6
- package/dist/context/sl/semantic-layer.service.d.ts +0 -4
- package/dist/context/sl/semantic-layer.service.js +3 -20
- package/dist/context/sl/tools/sl-warehouse-validation.d.ts +1 -1
- package/dist/context/sl/tools/sl-warehouse-validation.js +2 -2
- package/dist/context/sl/types.d.ts +1 -0
- package/dist/context/sql-analysis/dialect-notes.d.ts +1 -1
- package/dist/context/sql-analysis/dialect-notes.js +3 -2
- package/dist/context/sql-analysis/dialect.js +1 -0
- package/dist/context/sql-analysis/dialects/athena.md +12 -0
- package/dist/context/sql-analysis/dialects/duckdb.md +10 -0
- package/dist/local-adapters.js +17 -0
- package/dist/setup-databases.d.ts +1 -1
- package/dist/setup-databases.js +52 -2
- package/dist/sql.js +9 -14
- package/dist/status-project.js +2 -1
- package/dist/telemetry/events.d.ts +1 -1
- package/package.json +3 -1
- package/assets/python/kaelio_ktx-0.15.0-py3-none-any.whl +0 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { columnDisplayPartCount, formatDialectDisplayRef, formatDialectTableName, parseDialectDisplayRef, } from '../../context/connections/dialect-helpers.js';
|
|
2
|
+
/** @internal */
|
|
3
|
+
export class KtxAthenaDialect {
|
|
4
|
+
type = 'athena';
|
|
5
|
+
dimensionTypeMappings = {
|
|
6
|
+
timestamp: 'time',
|
|
7
|
+
date: 'time',
|
|
8
|
+
bigint: 'number',
|
|
9
|
+
int: 'number',
|
|
10
|
+
integer: 'number',
|
|
11
|
+
tinyint: 'number',
|
|
12
|
+
smallint: 'number',
|
|
13
|
+
double: 'number',
|
|
14
|
+
float: 'number',
|
|
15
|
+
real: 'number',
|
|
16
|
+
boolean: 'boolean',
|
|
17
|
+
};
|
|
18
|
+
quoteIdentifier(identifier) {
|
|
19
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
20
|
+
}
|
|
21
|
+
formatTableName(table) {
|
|
22
|
+
return formatDialectTableName(table, this.quoteIdentifier.bind(this), 'ansi');
|
|
23
|
+
}
|
|
24
|
+
formatDisplayRef(table) {
|
|
25
|
+
return formatDialectDisplayRef(table, 'ansi');
|
|
26
|
+
}
|
|
27
|
+
parseDisplayRef(display) {
|
|
28
|
+
return parseDialectDisplayRef(display, 'ansi');
|
|
29
|
+
}
|
|
30
|
+
columnDisplayTablePartCount() {
|
|
31
|
+
return columnDisplayPartCount('ansi');
|
|
32
|
+
}
|
|
33
|
+
mapDataType(nativeType) {
|
|
34
|
+
const base = nativeType.toLowerCase().trim().split('<')[0].split('(')[0].trim();
|
|
35
|
+
const typeMap = {
|
|
36
|
+
string: 'VARCHAR',
|
|
37
|
+
varchar: 'VARCHAR',
|
|
38
|
+
char: 'CHAR',
|
|
39
|
+
binary: 'VARBINARY',
|
|
40
|
+
bigint: 'BIGINT',
|
|
41
|
+
int: 'INTEGER',
|
|
42
|
+
integer: 'INTEGER',
|
|
43
|
+
tinyint: 'TINYINT',
|
|
44
|
+
smallint: 'SMALLINT',
|
|
45
|
+
double: 'DOUBLE',
|
|
46
|
+
float: 'FLOAT',
|
|
47
|
+
real: 'REAL',
|
|
48
|
+
decimal: 'DECIMAL',
|
|
49
|
+
boolean: 'BOOLEAN',
|
|
50
|
+
timestamp: 'TIMESTAMP',
|
|
51
|
+
date: 'DATE',
|
|
52
|
+
array: 'ARRAY',
|
|
53
|
+
map: 'MAP',
|
|
54
|
+
struct: 'STRUCT',
|
|
55
|
+
uniontype: 'UNION',
|
|
56
|
+
};
|
|
57
|
+
return typeMap[base] ?? nativeType.toUpperCase();
|
|
58
|
+
}
|
|
59
|
+
mapToDimensionType(nativeType) {
|
|
60
|
+
const base = nativeType.toLowerCase().trim().split('<')[0].split('(')[0].trim();
|
|
61
|
+
const mapped = this.dimensionTypeMappings[base];
|
|
62
|
+
if (mapped)
|
|
63
|
+
return mapped;
|
|
64
|
+
if (base.includes('timestamp') || base.includes('date'))
|
|
65
|
+
return 'time';
|
|
66
|
+
if (base.includes('int') || base.includes('float') || base.includes('double') || base.includes('decimal') || base.includes('real'))
|
|
67
|
+
return 'number';
|
|
68
|
+
if (base.includes('bool'))
|
|
69
|
+
return 'boolean';
|
|
70
|
+
return 'string';
|
|
71
|
+
}
|
|
72
|
+
generateSampleQuery(tableName, limit, columns) {
|
|
73
|
+
const columnList = columns && columns.length > 0 ? columns.map((c) => this.quoteIdentifier(c)).join(', ') : '*';
|
|
74
|
+
return `SELECT ${columnList} FROM ${tableName} LIMIT ${limit}`;
|
|
75
|
+
}
|
|
76
|
+
generateColumnSampleQuery(tableName, columnName, limit) {
|
|
77
|
+
const quoted = this.quoteIdentifier(columnName);
|
|
78
|
+
return `SELECT ${quoted} FROM ${tableName} WHERE ${quoted} IS NOT NULL LIMIT ${limit}`;
|
|
79
|
+
}
|
|
80
|
+
generateCardinalitySampleQuery(tableName, columnName, sampleSize) {
|
|
81
|
+
return `
|
|
82
|
+
SELECT approx_distinct(${columnName}) AS cardinality
|
|
83
|
+
FROM (
|
|
84
|
+
SELECT ${columnName}
|
|
85
|
+
FROM ${tableName}
|
|
86
|
+
WHERE ${columnName} IS NOT NULL
|
|
87
|
+
LIMIT ${sampleSize}
|
|
88
|
+
)
|
|
89
|
+
`;
|
|
90
|
+
}
|
|
91
|
+
generateRandomizedCardinalitySampleQuery(tableName, columnName, sampleSize) {
|
|
92
|
+
return `
|
|
93
|
+
SELECT approx_distinct(${columnName}) AS cardinality
|
|
94
|
+
FROM (
|
|
95
|
+
SELECT ${columnName}
|
|
96
|
+
FROM ${tableName}
|
|
97
|
+
WHERE ${columnName} IS NOT NULL
|
|
98
|
+
ORDER BY rand()
|
|
99
|
+
LIMIT ${sampleSize}
|
|
100
|
+
)
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
103
|
+
generateDistinctValuesQuery(tableName, columnName, limit) {
|
|
104
|
+
return `
|
|
105
|
+
SELECT DISTINCT CAST(${columnName} AS VARCHAR) AS val
|
|
106
|
+
FROM ${tableName}
|
|
107
|
+
WHERE ${columnName} IS NOT NULL
|
|
108
|
+
ORDER BY val
|
|
109
|
+
LIMIT ${limit}
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
112
|
+
generateColumnStatisticsQuery(_schemaName, _tableName) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
getNullCountExpression(column) {
|
|
116
|
+
return `COUNT_IF(${column} IS NULL)`;
|
|
117
|
+
}
|
|
118
|
+
getDistinctCountExpression(column) {
|
|
119
|
+
return `approx_distinct(${column})`;
|
|
120
|
+
}
|
|
121
|
+
textLengthExpression(columnSql) {
|
|
122
|
+
return `LENGTH(CAST(${columnSql} AS VARCHAR))`;
|
|
123
|
+
}
|
|
124
|
+
castToText(columnSql) {
|
|
125
|
+
return `CAST(${columnSql} AS VARCHAR)`;
|
|
126
|
+
}
|
|
127
|
+
getSampleValueAggregation(innerSql) {
|
|
128
|
+
return `(SELECT array_join(array_agg(CAST(value AS VARCHAR)), '\u001f') FROM (${innerSql}) AS relationship_profile_values)`;
|
|
129
|
+
}
|
|
130
|
+
getLimitOffsetClause(limit, offset) {
|
|
131
|
+
const safeLimit = Math.max(1, Math.floor(limit));
|
|
132
|
+
const safeOffset = offset !== undefined ? Math.floor(offset) : 0;
|
|
133
|
+
return safeOffset > 0 ? `OFFSET ${safeOffset} LIMIT ${safeLimit}` : `LIMIT ${safeLimit}`;
|
|
134
|
+
}
|
|
135
|
+
getTopClause(_limit) {
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
getTableSampleClause(_samplePct) {
|
|
139
|
+
return '';
|
|
140
|
+
}
|
|
141
|
+
getRandomSampleFilter(samplePct) {
|
|
142
|
+
if (samplePct <= 0 || samplePct >= 1)
|
|
143
|
+
return '';
|
|
144
|
+
return `rand() < ${samplePct}`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LiveDatabaseIntrospectionPort } from '../../context/ingest/adapters/live-database/types.js';
|
|
2
|
+
import type { KtxProjectConnectionConfig } from '../../context/project/config.js';
|
|
3
|
+
import { type KtxAthenaClientFactory } from './connector.js';
|
|
4
|
+
interface CreateAthenaLiveDatabaseIntrospectionOptions {
|
|
5
|
+
connections: Record<string, KtxProjectConnectionConfig>;
|
|
6
|
+
clientFactory?: KtxAthenaClientFactory;
|
|
7
|
+
now?: () => Date;
|
|
8
|
+
}
|
|
9
|
+
export declare function createAthenaLiveDatabaseIntrospection(options: CreateAthenaLiveDatabaseIntrospectionOptions): LiveDatabaseIntrospectionPort;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { KtxAthenaScanConnector, } from './connector.js';
|
|
2
|
+
export function createAthenaLiveDatabaseIntrospection(options) {
|
|
3
|
+
return {
|
|
4
|
+
async extractSchema(connectionId, introspectionOptions) {
|
|
5
|
+
const connection = options.connections[connectionId];
|
|
6
|
+
const connector = new KtxAthenaScanConnector({
|
|
7
|
+
connectionId,
|
|
8
|
+
connection,
|
|
9
|
+
clientFactory: options.clientFactory,
|
|
10
|
+
now: options.now,
|
|
11
|
+
});
|
|
12
|
+
try {
|
|
13
|
+
return await connector.introspect({
|
|
14
|
+
connectionId,
|
|
15
|
+
driver: 'athena',
|
|
16
|
+
...(introspectionOptions?.tableScope ? { tableScope: introspectionOptions.tableScope } : {}),
|
|
17
|
+
}, { runId: `athena-${connectionId}` });
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
await connector.cleanup();
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxConnectorTestResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaSnapshot, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js';
|
|
2
|
+
export interface KtxDuckDbConnectionConfig {
|
|
3
|
+
driver?: string;
|
|
4
|
+
path?: string;
|
|
5
|
+
url?: string;
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
}
|
|
8
|
+
/** @internal */
|
|
9
|
+
export interface DuckDbDatabasePathInput {
|
|
10
|
+
connectionId: string;
|
|
11
|
+
projectDir?: string;
|
|
12
|
+
connection: KtxDuckDbConnectionConfig | undefined;
|
|
13
|
+
}
|
|
14
|
+
export interface KtxDuckDbScanConnectorOptions extends DuckDbDatabasePathInput {
|
|
15
|
+
now?: () => Date;
|
|
16
|
+
}
|
|
17
|
+
export interface KtxDuckDbColumnDistinctValuesOptions {
|
|
18
|
+
maxCardinality: number;
|
|
19
|
+
limit: number;
|
|
20
|
+
sampleSize?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface KtxDuckDbColumnDistinctValuesResult {
|
|
23
|
+
values: string[] | null;
|
|
24
|
+
cardinality: number;
|
|
25
|
+
}
|
|
26
|
+
export declare function isKtxDuckDbConnectionConfig(connection: KtxDuckDbConnectionConfig | undefined): connection is KtxDuckDbConnectionConfig;
|
|
27
|
+
/** @internal */
|
|
28
|
+
export declare function duckDbDatabasePathFromConfig(input: DuckDbDatabasePathInput): string;
|
|
29
|
+
export declare class KtxDuckDbScanConnector implements KtxScanConnector {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly driver: "duckdb";
|
|
32
|
+
readonly capabilities: import("../../context/scan/types.js").KtxConnectorCapabilities;
|
|
33
|
+
private readonly connectionId;
|
|
34
|
+
private readonly dbPath;
|
|
35
|
+
private readonly now;
|
|
36
|
+
private readonly dialect;
|
|
37
|
+
private instance;
|
|
38
|
+
private connection;
|
|
39
|
+
constructor(options: KtxDuckDbScanConnectorOptions);
|
|
40
|
+
testConnection(): Promise<KtxConnectorTestResult>;
|
|
41
|
+
introspect(input: KtxScanInput, _ctx: KtxScanContext): Promise<KtxSchemaSnapshot>;
|
|
42
|
+
listSchemas(): Promise<string[]>;
|
|
43
|
+
listTables(_schemas?: string[]): Promise<KtxTableListEntry[]>;
|
|
44
|
+
sampleTable(input: KtxTableSampleInput, _ctx: KtxScanContext): Promise<KtxTableSampleResult>;
|
|
45
|
+
sampleColumn(input: KtxColumnSampleInput, _ctx: KtxScanContext): Promise<KtxColumnSampleResult>;
|
|
46
|
+
columnStats(_input: KtxColumnStatsInput, _ctx: KtxScanContext): Promise<KtxColumnStatsResult | null>;
|
|
47
|
+
executeReadOnly(input: KtxReadOnlyQueryInput, _ctx: KtxScanContext): Promise<KtxQueryResult>;
|
|
48
|
+
getColumnDistinctValues(table: KtxTableRef, columnName: string, options: KtxDuckDbColumnDistinctValuesOptions): Promise<KtxDuckDbColumnDistinctValuesResult | null>;
|
|
49
|
+
getTableRowCount(tableName: string): Promise<number>;
|
|
50
|
+
qTableName(table: Pick<KtxTableRef, 'name'>): string;
|
|
51
|
+
quoteIdentifier(identifier: string): string;
|
|
52
|
+
cleanup(): Promise<void>;
|
|
53
|
+
private db;
|
|
54
|
+
private query;
|
|
55
|
+
private readTableRows;
|
|
56
|
+
private readTable;
|
|
57
|
+
private readPrimaryKeyColumns;
|
|
58
|
+
private readForeignKeys;
|
|
59
|
+
private assertConnection;
|
|
60
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { DuckDBInstance } from '@duckdb/node-api';
|
|
2
|
+
import { existsSync, statSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { resolveStringReference } from '../shared/string-reference.js';
|
|
6
|
+
import { getSqlDialectForDriver } from '../../context/connections/dialects.js';
|
|
7
|
+
import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js';
|
|
8
|
+
import { normalizeQueryRows } from '../../context/connections/query-executor.js';
|
|
9
|
+
import { toJsonSafeRows } from '../shared/duckdb-json-safe.js';
|
|
10
|
+
import { connectorTestFailure, createKtxConnectorCapabilities, } from '../../context/scan/types.js';
|
|
11
|
+
import { scopedTableNames } from '../../context/scan/table-ref.js';
|
|
12
|
+
const MAIN_SCHEMA = 'main';
|
|
13
|
+
// `path` may be an env:/file: reference; `url` resolves env: only, since file:
|
|
14
|
+
// on a url is a native URI form (handled by duckDbPathFromUrl), not a file read.
|
|
15
|
+
function stringConfigValue(connection, key) {
|
|
16
|
+
const value = connection?.[key];
|
|
17
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const trimmed = value.trim();
|
|
21
|
+
if (key === 'url') {
|
|
22
|
+
return trimmed.startsWith('env:') ? (process.env[trimmed.slice('env:'.length)] ?? '') : trimmed;
|
|
23
|
+
}
|
|
24
|
+
return resolveStringReference(trimmed, process.env);
|
|
25
|
+
}
|
|
26
|
+
function duckDbPathFromUrl(url) {
|
|
27
|
+
if (url.startsWith('file:')) {
|
|
28
|
+
return fileURLToPath(url);
|
|
29
|
+
}
|
|
30
|
+
if (url.startsWith('duckdb:')) {
|
|
31
|
+
const parsed = new URL(url);
|
|
32
|
+
return decodeURIComponent(parsed.pathname);
|
|
33
|
+
}
|
|
34
|
+
return url;
|
|
35
|
+
}
|
|
36
|
+
export function isKtxDuckDbConnectionConfig(connection) {
|
|
37
|
+
return String(connection?.driver ?? '').toLowerCase() === 'duckdb';
|
|
38
|
+
}
|
|
39
|
+
/** @internal */
|
|
40
|
+
export function duckDbDatabasePathFromConfig(input) {
|
|
41
|
+
const inputDriver = input.connection?.driver ?? 'unknown';
|
|
42
|
+
if (!isKtxDuckDbConnectionConfig(input.connection)) {
|
|
43
|
+
throw new Error(`Native DuckDB connector cannot run driver "${inputDriver}"`);
|
|
44
|
+
}
|
|
45
|
+
const configuredPath = stringConfigValue(input.connection, 'path') ?? duckDbPathFromUrl(stringConfigValue(input.connection, 'url') ?? '');
|
|
46
|
+
if (!configuredPath) {
|
|
47
|
+
throw new Error(`Native DuckDB connector requires connections.${input.connectionId}.path or url`);
|
|
48
|
+
}
|
|
49
|
+
return isAbsolute(configuredPath) ? configuredPath : resolve(input.projectDir ?? process.cwd(), configuredPath);
|
|
50
|
+
}
|
|
51
|
+
export class KtxDuckDbScanConnector {
|
|
52
|
+
id;
|
|
53
|
+
driver = 'duckdb';
|
|
54
|
+
capabilities = createKtxConnectorCapabilities({
|
|
55
|
+
tableSampling: true,
|
|
56
|
+
columnSampling: true,
|
|
57
|
+
columnStats: false,
|
|
58
|
+
readOnlySql: true,
|
|
59
|
+
nestedAnalysis: false,
|
|
60
|
+
formalForeignKeys: true,
|
|
61
|
+
estimatedRowCounts: true,
|
|
62
|
+
});
|
|
63
|
+
connectionId;
|
|
64
|
+
dbPath;
|
|
65
|
+
now;
|
|
66
|
+
dialect = getSqlDialectForDriver('duckdb');
|
|
67
|
+
instance = null;
|
|
68
|
+
connection = null;
|
|
69
|
+
constructor(options) {
|
|
70
|
+
this.connectionId = options.connectionId;
|
|
71
|
+
this.dbPath = duckDbDatabasePathFromConfig(options);
|
|
72
|
+
this.now = options.now ?? (() => new Date());
|
|
73
|
+
this.id = `duckdb:${options.connectionId}`;
|
|
74
|
+
}
|
|
75
|
+
async testConnection() {
|
|
76
|
+
try {
|
|
77
|
+
if (!existsSync(this.dbPath) || !statSync(this.dbPath).isFile()) {
|
|
78
|
+
return { success: false, error: `File not found: ${this.dbPath}` };
|
|
79
|
+
}
|
|
80
|
+
await this.query('SELECT 1');
|
|
81
|
+
return { success: true };
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return connectorTestFailure(error);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async introspect(input, _ctx) {
|
|
88
|
+
this.assertConnection(input.connectionId);
|
|
89
|
+
const scopedNames = input.tableScope ? scopedTableNames(input.tableScope, { catalog: null, db: null }) : null;
|
|
90
|
+
const tableRows = await this.readTableRows(scopedNames);
|
|
91
|
+
const tables = [];
|
|
92
|
+
for (const row of tableRows) {
|
|
93
|
+
tables.push(await this.readTable(row));
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
connectionId: this.connectionId,
|
|
97
|
+
driver: 'duckdb',
|
|
98
|
+
extractedAt: this.now().toISOString(),
|
|
99
|
+
scope: {},
|
|
100
|
+
metadata: {
|
|
101
|
+
file_path: this.dbPath,
|
|
102
|
+
table_count: tables.length,
|
|
103
|
+
total_columns: tables.reduce((sum, table) => sum + table.columns.length, 0),
|
|
104
|
+
},
|
|
105
|
+
tables,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
async listSchemas() {
|
|
109
|
+
return [MAIN_SCHEMA];
|
|
110
|
+
}
|
|
111
|
+
async listTables(_schemas) {
|
|
112
|
+
const rows = await this.readTableRows(null);
|
|
113
|
+
return rows.map((row) => ({
|
|
114
|
+
catalog: null,
|
|
115
|
+
schema: MAIN_SCHEMA,
|
|
116
|
+
name: row.table_name,
|
|
117
|
+
kind: row.table_type === 'VIEW' ? 'view' : 'table',
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
async sampleTable(input, _ctx) {
|
|
121
|
+
this.assertConnection(input.connectionId);
|
|
122
|
+
const result = await this.query(this.dialect.generateSampleQuery(this.qTableName(input.table), input.limit, input.columns));
|
|
123
|
+
return { headers: result.headers, rows: result.rows, totalRows: result.totalRows };
|
|
124
|
+
}
|
|
125
|
+
async sampleColumn(input, _ctx) {
|
|
126
|
+
this.assertConnection(input.connectionId);
|
|
127
|
+
const result = await this.query(this.dialect.generateColumnSampleQuery(this.qTableName(input.table), input.column, input.limit));
|
|
128
|
+
const values = result.rows.filter((row) => row.length > 0 && row[0] !== null).map((row) => row[0]);
|
|
129
|
+
return { values, nullCount: null, distinctCount: null };
|
|
130
|
+
}
|
|
131
|
+
async columnStats(_input, _ctx) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
async executeReadOnly(input, _ctx) {
|
|
135
|
+
this.assertConnection(input.connectionId);
|
|
136
|
+
const result = await this.query(limitSqlForExecution(input.sql, input.maxRows));
|
|
137
|
+
return { ...result, rowCount: result.rows.length };
|
|
138
|
+
}
|
|
139
|
+
async getColumnDistinctValues(table, columnName, options) {
|
|
140
|
+
const sampleSize = options.sampleSize ?? 10000;
|
|
141
|
+
const tableName = this.qTableName(table);
|
|
142
|
+
const quotedColumn = this.dialect.quoteIdentifier(columnName);
|
|
143
|
+
const cardinalityResult = await this.query(this.dialect.generateCardinalitySampleQuery(tableName, quotedColumn, sampleSize));
|
|
144
|
+
if (cardinalityResult.rows.length === 0) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const cardinality = Number(cardinalityResult.rows[0][0]);
|
|
148
|
+
if (Number.isNaN(cardinality)) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
if (cardinality === 0) {
|
|
152
|
+
return { values: [], cardinality: 0 };
|
|
153
|
+
}
|
|
154
|
+
if (cardinality > options.maxCardinality) {
|
|
155
|
+
return { values: null, cardinality };
|
|
156
|
+
}
|
|
157
|
+
const valuesResult = await this.query(this.dialect.generateDistinctValuesQuery(tableName, quotedColumn, options.limit));
|
|
158
|
+
return {
|
|
159
|
+
values: valuesResult.rows.filter((row) => row.length > 0 && row[0] !== null).map((row) => String(row[0])),
|
|
160
|
+
cardinality,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
async getTableRowCount(tableName) {
|
|
164
|
+
const result = await this.query(`SELECT COUNT(*) AS count FROM ${this.dialect.quoteIdentifier(tableName)}`);
|
|
165
|
+
return Number(result.rows[0]?.[0] ?? 0);
|
|
166
|
+
}
|
|
167
|
+
qTableName(table) {
|
|
168
|
+
return this.dialect.formatTableName(table);
|
|
169
|
+
}
|
|
170
|
+
quoteIdentifier(identifier) {
|
|
171
|
+
return this.dialect.quoteIdentifier(identifier);
|
|
172
|
+
}
|
|
173
|
+
async cleanup() {
|
|
174
|
+
this.connection?.closeSync();
|
|
175
|
+
this.instance?.closeSync();
|
|
176
|
+
this.connection = null;
|
|
177
|
+
this.instance = null;
|
|
178
|
+
}
|
|
179
|
+
async db() {
|
|
180
|
+
if (!this.connection) {
|
|
181
|
+
// DuckDBInstance.create() creates the file if missing, so this pre-check
|
|
182
|
+
// enforces the never-create rule. Do not remove it.
|
|
183
|
+
if (!existsSync(this.dbPath) || !statSync(this.dbPath).isFile()) {
|
|
184
|
+
throw new Error(`File not found: ${this.dbPath}`);
|
|
185
|
+
}
|
|
186
|
+
this.instance = await DuckDBInstance.create(this.dbPath, { access_mode: 'read_only' });
|
|
187
|
+
this.connection = await this.instance.connect();
|
|
188
|
+
}
|
|
189
|
+
return this.connection;
|
|
190
|
+
}
|
|
191
|
+
async query(sql) {
|
|
192
|
+
const connection = await this.db();
|
|
193
|
+
const reader = await connection.runAndReadAll(assertReadOnlySql(sql));
|
|
194
|
+
const rows = toJsonSafeRows(normalizeQueryRows(reader.getRows()));
|
|
195
|
+
return {
|
|
196
|
+
headers: reader.columnNames(),
|
|
197
|
+
rows,
|
|
198
|
+
totalRows: rows.length,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
async readTableRows(scopedNames) {
|
|
202
|
+
if (scopedNames && scopedNames.length === 0) {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
const scopeClause = scopedNames
|
|
206
|
+
? `AND table_name IN (${scopedNames.map((name) => `'${name.replaceAll("'", "''")}'`).join(', ')})`
|
|
207
|
+
: '';
|
|
208
|
+
const result = await this.query(`SELECT table_name, table_type
|
|
209
|
+
FROM information_schema.tables
|
|
210
|
+
WHERE table_schema = '${MAIN_SCHEMA}' ${scopeClause}
|
|
211
|
+
ORDER BY table_name`);
|
|
212
|
+
return result.rows.map((row) => ({ table_name: String(row[0]), table_type: String(row[1]) }));
|
|
213
|
+
}
|
|
214
|
+
async readTable(table) {
|
|
215
|
+
const columnsResult = await this.query(`SELECT column_name, data_type, is_nullable
|
|
216
|
+
FROM information_schema.columns
|
|
217
|
+
WHERE table_schema = '${MAIN_SCHEMA}' AND table_name = '${table.table_name.replaceAll("'", "''")}'
|
|
218
|
+
ORDER BY ordinal_position`);
|
|
219
|
+
const columns = columnsResult.rows.map((row) => ({
|
|
220
|
+
column_name: String(row[0]),
|
|
221
|
+
data_type: String(row[1]),
|
|
222
|
+
is_nullable: String(row[2]),
|
|
223
|
+
}));
|
|
224
|
+
const primaryKeys = await this.readPrimaryKeyColumns(table.table_name);
|
|
225
|
+
const isView = table.table_type === 'VIEW';
|
|
226
|
+
const estimatedRows = isView ? null : await this.getTableRowCount(table.table_name);
|
|
227
|
+
return {
|
|
228
|
+
catalog: null,
|
|
229
|
+
db: null,
|
|
230
|
+
name: table.table_name,
|
|
231
|
+
kind: isView ? 'view' : 'table',
|
|
232
|
+
comment: null,
|
|
233
|
+
estimatedRows,
|
|
234
|
+
columns: columns.map((column) => ({
|
|
235
|
+
name: column.column_name,
|
|
236
|
+
nativeType: column.data_type,
|
|
237
|
+
normalizedType: this.dialect.mapDataType(column.data_type),
|
|
238
|
+
dimensionType: this.dialect.mapToDimensionType(column.data_type),
|
|
239
|
+
nullable: column.is_nullable === 'YES' && !primaryKeys.has(column.column_name),
|
|
240
|
+
primaryKey: primaryKeys.has(column.column_name),
|
|
241
|
+
comment: null,
|
|
242
|
+
})),
|
|
243
|
+
foreignKeys: await this.readForeignKeys(table.table_name),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
async readPrimaryKeyColumns(tableName) {
|
|
247
|
+
const result = await this.query(`SELECT kcu.column_name
|
|
248
|
+
FROM information_schema.table_constraints tc
|
|
249
|
+
JOIN information_schema.key_column_usage kcu
|
|
250
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
251
|
+
WHERE tc.table_schema = '${MAIN_SCHEMA}'
|
|
252
|
+
AND tc.table_name = '${tableName.replaceAll("'", "''")}'
|
|
253
|
+
AND tc.constraint_type = 'PRIMARY KEY'`);
|
|
254
|
+
return new Set(result.rows.map((row) => String(row[0])));
|
|
255
|
+
}
|
|
256
|
+
async readForeignKeys(tableName) {
|
|
257
|
+
// information_schema.constraint_column_usage in DuckDB returns the constrained
|
|
258
|
+
// columns (source), not the referenced columns. Use duckdb_constraints() which
|
|
259
|
+
// exposes constraint_column_names and referenced_column_names directly.
|
|
260
|
+
const result = await this.query(`SELECT unnest(constraint_column_names) AS from_column,
|
|
261
|
+
referenced_table,
|
|
262
|
+
unnest(referenced_column_names) AS to_column,
|
|
263
|
+
constraint_name
|
|
264
|
+
FROM duckdb_constraints()
|
|
265
|
+
WHERE schema_name = '${MAIN_SCHEMA}'
|
|
266
|
+
AND table_name = '${tableName.replaceAll("'", "''")}'
|
|
267
|
+
AND constraint_type = 'FOREIGN KEY'`);
|
|
268
|
+
return result.rows.map((row) => ({
|
|
269
|
+
fromColumn: String(row[0]),
|
|
270
|
+
toCatalog: null,
|
|
271
|
+
toDb: null,
|
|
272
|
+
toTable: String(row[1]),
|
|
273
|
+
toColumn: String(row[2]),
|
|
274
|
+
constraintName: row[3] === null ? null : String(row[3]),
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
assertConnection(connectionId) {
|
|
278
|
+
if (connectionId !== this.connectionId) {
|
|
279
|
+
throw new Error(`ktx DuckDB connector ${this.id} cannot serve connection ${connectionId}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { KtxSqlDialect } from '../../context/connections/dialects.js';
|
|
2
|
+
import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/types.js';
|
|
3
|
+
type DuckDbTableNameRef = Pick<KtxTableRef, 'name'> & Partial<Pick<KtxTableRef, 'catalog' | 'db'>>;
|
|
4
|
+
/** @internal */
|
|
5
|
+
export declare class KtxDuckDbDialect implements KtxSqlDialect {
|
|
6
|
+
readonly type: "duckdb";
|
|
7
|
+
private readonly typeMappings;
|
|
8
|
+
quoteIdentifier(identifier: string): string;
|
|
9
|
+
formatTableName(table: DuckDbTableNameRef): string;
|
|
10
|
+
formatDisplayRef(table: DuckDbTableNameRef): string;
|
|
11
|
+
parseDisplayRef(display: string): KtxTableRef | null;
|
|
12
|
+
columnDisplayTablePartCount(): 1 | 2 | 3;
|
|
13
|
+
mapDataType(nativeType: string): string;
|
|
14
|
+
mapToDimensionType(nativeType: string): KtxSchemaDimensionType;
|
|
15
|
+
generateSampleQuery(tableName: string, limit: number, columns?: string[]): string;
|
|
16
|
+
generateColumnSampleQuery(tableName: string, columnName: string, limit: number): string;
|
|
17
|
+
getRandomSampleFilter(samplePct: number): string;
|
|
18
|
+
getTableSampleClause(samplePct: number): string;
|
|
19
|
+
getLimitOffsetClause(limit: number, offset?: number): string;
|
|
20
|
+
getTopClause(_limit: number): string;
|
|
21
|
+
getNullCountExpression(column: string): string;
|
|
22
|
+
getDistinctCountExpression(column: string): string;
|
|
23
|
+
textLengthExpression(columnSql: string): string;
|
|
24
|
+
castToText(columnSql: string): string;
|
|
25
|
+
getSampleValueAggregation(innerSql: string): string;
|
|
26
|
+
generateCardinalitySampleQuery(tableName: string, columnName: string, sampleSize: number): string;
|
|
27
|
+
generateDistinctValuesQuery(tableName: string, columnName: string, limit: number): string;
|
|
28
|
+
generateColumnStatisticsQuery(_schemaName: string, _tableName: string): string | null;
|
|
29
|
+
generateRandomizedCardinalitySampleQuery(tableName: string, columnName: string, sampleSize: number): string;
|
|
30
|
+
}
|
|
31
|
+
export {};
|