@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
|
@@ -6,8 +6,10 @@ const warehouseDrivers = [
|
|
|
6
6
|
'snowflake',
|
|
7
7
|
'bigquery',
|
|
8
8
|
'sqlite',
|
|
9
|
+
'duckdb',
|
|
9
10
|
'clickhouse',
|
|
10
11
|
'sqlserver',
|
|
12
|
+
'athena',
|
|
11
13
|
];
|
|
12
14
|
function warehouseConnectionSchema(driver) {
|
|
13
15
|
return z
|
|
@@ -28,6 +30,10 @@ function warehouseConnectionSchema(driver) {
|
|
|
28
30
|
.positive()
|
|
29
31
|
.optional()
|
|
30
32
|
.describe('Maximum execution time for a single read-only query, in milliseconds (default 30000). Enforced as a server-side statement timeout for remote engines and by SIGKILL-ing a forked query subprocess for in-process SQLite. A query exceeding it is cancelled and returns a "query exceeded Ns" error so the agent can revise.'),
|
|
33
|
+
query_policy: z
|
|
34
|
+
.enum(['read-only-sql', 'semantic-layer-only'])
|
|
35
|
+
.optional()
|
|
36
|
+
.describe('Agent-facing query authorship policy (default "read-only-sql"). "read-only-sql" allows parser-validated read-only SQL plus semantic-layer queries. "semantic-layer-only" rejects raw SQL on this connection (`ktx sql`, the sql_execution tool, and federated queries that include it) and restricts semantic-layer queries to measures predefined in the semantic-layer sources. ktx-internal scan and ingest queries are unaffected.'),
|
|
31
37
|
})
|
|
32
38
|
.describe(`${driver} warehouse connection. Additional driver-tunable fields (e.g. context.queryHistory) are accepted and passed through.`);
|
|
33
39
|
}
|
|
@@ -37,8 +43,10 @@ const warehouseConnectionSchemas = [
|
|
|
37
43
|
warehouseConnectionSchema('snowflake'),
|
|
38
44
|
warehouseConnectionSchema('bigquery'),
|
|
39
45
|
warehouseConnectionSchema('sqlite'),
|
|
46
|
+
warehouseConnectionSchema('duckdb'),
|
|
40
47
|
warehouseConnectionSchema('clickhouse'),
|
|
41
48
|
warehouseConnectionSchema('sqlserver'),
|
|
49
|
+
warehouseConnectionSchema('athena'),
|
|
42
50
|
];
|
|
43
51
|
const mongodbConnectionSchema = z
|
|
44
52
|
.looseObject({
|
|
@@ -43,15 +43,17 @@ function normalizeDriver(driver) {
|
|
|
43
43
|
const normalized = (driver ?? '').toLowerCase();
|
|
44
44
|
if (normalized === 'postgres' ||
|
|
45
45
|
normalized === 'sqlite' ||
|
|
46
|
+
normalized === 'duckdb' ||
|
|
46
47
|
normalized === 'mysql' ||
|
|
47
48
|
normalized === 'clickhouse' ||
|
|
48
49
|
normalized === 'sqlserver' ||
|
|
49
50
|
normalized === 'bigquery' ||
|
|
50
51
|
normalized === 'snowflake' ||
|
|
52
|
+
normalized === 'athena' ||
|
|
51
53
|
normalized === 'mongodb') {
|
|
52
54
|
return normalized;
|
|
53
55
|
}
|
|
54
|
-
throw new Error(`Standalone ktx scan supports postgres/sqlite/mysql/clickhouse/sqlserver/bigquery/snowflake/mongodb in this phase, received "${driver ?? 'unknown'}"`);
|
|
56
|
+
throw new Error(`Standalone ktx scan supports postgres/sqlite/duckdb/mysql/clickhouse/sqlserver/bigquery/snowflake/athena/mongodb in this phase, received "${driver ?? 'unknown'}"`);
|
|
55
57
|
}
|
|
56
58
|
function tablePathCount(paths) {
|
|
57
59
|
return paths.filter((path) => path.startsWith('tables/') && path.endsWith('.json')).length;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { KtxTableRefKey } from './table-ref.js';
|
|
2
|
-
export type KtxConnectionDriver = 'sqlite' | 'postgres' | 'sqlserver' | 'bigquery' | 'snowflake' | 'mysql' | 'clickhouse' | 'mongodb';
|
|
2
|
+
export type KtxConnectionDriver = 'sqlite' | 'duckdb' | 'postgres' | 'sqlserver' | 'bigquery' | 'snowflake' | 'mysql' | 'clickhouse' | 'athena' | 'mongodb';
|
|
3
3
|
/** Canonical scan-mode registry. Runtime validation derives its allowlist here. */
|
|
4
4
|
export declare const KTX_SCAN_MODES: readonly ["structural", "relationships", "enriched"];
|
|
5
5
|
export type KtxScanMode = (typeof KTX_SCAN_MODES)[number];
|
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
import { isSqlQueryableDriver } from '../connections/dialects.js';
|
|
2
2
|
import { FEDERATED_CONNECTION_ID } from '../connections/federation.js';
|
|
3
|
+
import { connectionQueryPolicy, restrictedFederatedMemberIds } from '../connections/query-policy.js';
|
|
3
4
|
import { resolveRequiredConnectionId } from '../connections/resolve-connection.js';
|
|
4
5
|
import { sqlAnalysisDialectForDriver } from '../sql-analysis/dialect.js';
|
|
5
6
|
import { loadLocalSlSourceRecords } from './local-sl.js';
|
|
6
7
|
import { toResolvedWire } from './semantic-layer.service.js';
|
|
7
8
|
import { assertSafeConnectionId } from './source-files.js';
|
|
8
9
|
const COMPILE_ONLY_REASON = 'Local semantic-layer query compiled SQL but no data-source execution adapter is configured.';
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const FEDERATED_SL_QUERY_PREFIX = `Semantic-layer queries are per-connection and cannot target the federated connection '${FEDERATED_CONNECTION_ID}'. `;
|
|
11
|
+
// The raw-SQL fallback is only valid when federated raw SQL is allowed; when a
|
|
12
|
+
// member is restricted (query_policy: semantic-layer-only), assertRawSqlAllowed
|
|
13
|
+
// rejects the same path, so directing the agent there would burn a guaranteed
|
|
14
|
+
// failure. Derive the message from the restricted-member set instead.
|
|
15
|
+
function federatedSlQueryUnsupportedMessage(project) {
|
|
16
|
+
const restricted = restrictedFederatedMemberIds(project.config, project.projectDir);
|
|
17
|
+
if (restricted.length > 0) {
|
|
18
|
+
return (FEDERATED_SL_QUERY_PREFIX +
|
|
19
|
+
`Cross-database SQL through '${FEDERATED_CONNECTION_ID}' is also disabled because member connection(s) ` +
|
|
20
|
+
`${restricted.map((id) => `'${id}'`).join(', ')} are restricted to semantic-layer queries ` +
|
|
21
|
+
'(query_policy: semantic-layer-only). Query each connection on its own through the semantic layer ' +
|
|
22
|
+
'(the sl_query tool or `ktx sl query` with its connection id).');
|
|
23
|
+
}
|
|
24
|
+
return (FEDERATED_SL_QUERY_PREFIX +
|
|
25
|
+
`Run a cross-database query as read-only SQL instead — ktx sql -c ${FEDERATED_CONNECTION_ID} "SELECT ..." or the sql_execution tool — ` +
|
|
26
|
+
'using catalog-qualified table names (connectionId.schema.table, or connectionId.table for sqlite; ' +
|
|
27
|
+
'double-quote ids that are not bare identifiers, e.g. "books-db".public.books).');
|
|
28
|
+
}
|
|
13
29
|
function resolveLocalConnectionId(project, requested) {
|
|
14
30
|
return assertSafeConnectionId(resolveRequiredConnectionId(project.config, requested));
|
|
15
31
|
}
|
|
@@ -39,7 +55,7 @@ function headersFromColumns(columns) {
|
|
|
39
55
|
}
|
|
40
56
|
export async function compileLocalSlQuery(project, options) {
|
|
41
57
|
if (options.connectionId === FEDERATED_CONNECTION_ID) {
|
|
42
|
-
throw new Error(
|
|
58
|
+
throw new Error(federatedSlQueryUnsupportedMessage(project));
|
|
43
59
|
}
|
|
44
60
|
await options.onProgress?.({ progress: 0, message: 'Compiling query' });
|
|
45
61
|
const connectionId = resolveLocalConnectionId(project, options.connectionId);
|
|
@@ -50,11 +66,12 @@ export async function compileLocalSlQuery(project, options) {
|
|
|
50
66
|
}
|
|
51
67
|
const dialect = sqlAnalysisDialectForDriver(driver);
|
|
52
68
|
const sources = await loadComputableSources(project, connectionId);
|
|
69
|
+
const predefinedMeasuresOnly = connectionQueryPolicy(project.config.connections[connectionId]) === 'semantic-layer-only';
|
|
53
70
|
await options.onProgress?.({ progress: 0.3, message: 'Generating SQL' });
|
|
54
71
|
const response = await options.compute.query({
|
|
55
72
|
sources,
|
|
56
73
|
dialect,
|
|
57
|
-
query: options.query,
|
|
74
|
+
query: { ...options.query, predefined_measures_only: predefinedMeasuresOnly },
|
|
58
75
|
});
|
|
59
76
|
if (!options.execute) {
|
|
60
77
|
await options.onProgress?.({ progress: 1, message: 'Fetched 0 rows' });
|
|
@@ -147,10 +147,6 @@ export declare class SemanticLayerService {
|
|
|
147
147
|
descriptions: Record<string, string>;
|
|
148
148
|
}>;
|
|
149
149
|
}>;
|
|
150
|
-
/**
|
|
151
|
-
* All callers should use this instead of maintaining their own dialect maps.
|
|
152
|
-
*/
|
|
153
|
-
static mapDialect(connectionType: string): string;
|
|
154
150
|
/**
|
|
155
151
|
* Execute a semantic layer query: load composed sources, generate SQL via
|
|
156
152
|
* the python SL engine, and execute the generated SQL against the data source.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import YAML from 'yaml';
|
|
2
2
|
import { noopLogger } from '../../context/core/config.js';
|
|
3
|
+
import { dialectForConnectionType } from '../connections/connection-type-dialect.js';
|
|
3
4
|
import { normalizeSemanticLayerDescriptions } from './description-normalization.js';
|
|
4
5
|
import { isOverlaySource, resolvedSourceSchema, sourceDefinitionSchema, sourceOverlaySchema } from './schemas.js';
|
|
5
6
|
import { isSlYamlPath, resolveSlSourceFile, slDeclaredSourceName, slSourceFilePath } from './source-files.js';
|
|
@@ -572,7 +573,7 @@ export class SemanticLayerService {
|
|
|
572
573
|
if (!connection) {
|
|
573
574
|
throw new Error(`Data source not found: ${connectionId}`);
|
|
574
575
|
}
|
|
575
|
-
return
|
|
576
|
+
return dialectForConnectionType(connection.connectionType);
|
|
576
577
|
}
|
|
577
578
|
async listFilesForConnection(connectionId) {
|
|
578
579
|
const dir = `${SL_DIR_PREFIX}/${connectionId}`;
|
|
@@ -937,24 +938,6 @@ export class SemanticLayerService {
|
|
|
937
938
|
}
|
|
938
939
|
return { columns, tables };
|
|
939
940
|
}
|
|
940
|
-
/**
|
|
941
|
-
* All callers should use this instead of maintaining their own dialect maps.
|
|
942
|
-
*/
|
|
943
|
-
static mapDialect(connectionType) {
|
|
944
|
-
const normalized = connectionType.toUpperCase();
|
|
945
|
-
const map = {
|
|
946
|
-
POSTGRES: 'postgres',
|
|
947
|
-
BIGQUERY: 'bigquery',
|
|
948
|
-
SNOWFLAKE: 'snowflake',
|
|
949
|
-
MYSQL: 'mysql',
|
|
950
|
-
SQLSERVER: 'tsql',
|
|
951
|
-
SQLITE: 'sqlite',
|
|
952
|
-
DUCKDB: 'duckdb',
|
|
953
|
-
CLICKHOUSE: 'clickhouse',
|
|
954
|
-
DATABRICKS: 'databricks',
|
|
955
|
-
};
|
|
956
|
-
return map[normalized] ?? 'postgres';
|
|
957
|
-
}
|
|
958
941
|
/**
|
|
959
942
|
* Execute a semantic layer query: load composed sources, generate SQL via
|
|
960
943
|
* the python SL engine, and execute the generated SQL against the data source.
|
|
@@ -980,7 +963,7 @@ export class SemanticLayerService {
|
|
|
980
963
|
if (!connection) {
|
|
981
964
|
throw new Error(`Data source not found: ${connectionId}`);
|
|
982
965
|
}
|
|
983
|
-
const dialect =
|
|
966
|
+
const dialect = dialectForConnectionType(connection.connectionType);
|
|
984
967
|
// 3. Generate SQL via python SL engine
|
|
985
968
|
const { data: slResult, error: slError } = await this.python.query({
|
|
986
969
|
sources: sources.map(toResolvedWire),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GitService } from '../../../context/core/git.service.js';
|
|
2
2
|
import type { KtxFileStorePort } from '../../../context/core/file-store.js';
|
|
3
3
|
import type { SlConnectionCatalogPort, SlSourcesIndexPort } from '../ports.js';
|
|
4
|
-
import { SemanticLayerService } from '../semantic-layer.service.js';
|
|
4
|
+
import type { SemanticLayerService } from '../semantic-layer.service.js';
|
|
5
5
|
export interface SlValidationDeps {
|
|
6
6
|
semanticLayerService: SemanticLayerService;
|
|
7
7
|
connections: SlConnectionCatalogPort;
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import YAML from 'yaml';
|
|
2
2
|
import { SYSTEM_GIT_AUTHOR } from '../../../context/tools/authors.js';
|
|
3
|
+
import { dialectForConnectionType } from '../../connections/connection-type-dialect.js';
|
|
3
4
|
import { sourceOverlaySchema } from '../schemas.js';
|
|
4
|
-
import { SemanticLayerService } from '../semantic-layer.service.js';
|
|
5
5
|
import { resolveSlSourceFile, slSourceFilePath } from '../source-files.js';
|
|
6
6
|
import { sourceDefinitionSchema } from './base-semantic-layer.tool.js';
|
|
7
7
|
function resolveDialect(warehouse) {
|
|
8
8
|
if (!warehouse) {
|
|
9
9
|
return null;
|
|
10
10
|
}
|
|
11
|
-
return
|
|
11
|
+
return dialectForConnectionType(warehouse);
|
|
12
12
|
}
|
|
13
13
|
function wrapWithZeroRowQuery(sql, dialect) {
|
|
14
14
|
if (dialect === 'tsql') {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SqlAnalysisDialect } from './ports.js';
|
|
2
2
|
/** @internal Dialects with an authored ./dialects/<dialect>.md file. */
|
|
3
|
-
export declare const DIALECTS_WITH_NOTES: readonly ["postgres", "mysql", "snowflake", "bigquery", "sqlite", "clickhouse", "tsql"];
|
|
3
|
+
export declare const DIALECTS_WITH_NOTES: readonly ["postgres", "mysql", "snowflake", "bigquery", "sqlite", "duckdb", "clickhouse", "tsql", "athena"];
|
|
4
4
|
/**
|
|
5
5
|
* SQL syntax notes for a resolved dialect. Falls back to `postgres` — the
|
|
6
6
|
* resolver's own default for unrecognized drivers — so any SQL connection yields
|
|
@@ -4,8 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
// dialect), served by the sql_dialect_notes MCP tool. They are package-internal:
|
|
5
5
|
// copy-runtime-assets.mjs ships them to dist, and they are never installed onto an
|
|
6
6
|
// agent target. The set covers every dialect reachable from a configured warehouse
|
|
7
|
-
// driver;
|
|
8
|
-
// them.
|
|
7
|
+
// driver; databricks is intentionally absent because no connector produces it.
|
|
9
8
|
/** @internal Dialects with an authored ./dialects/<dialect>.md file. */
|
|
10
9
|
export const DIALECTS_WITH_NOTES = [
|
|
11
10
|
'postgres',
|
|
@@ -13,8 +12,10 @@ export const DIALECTS_WITH_NOTES = [
|
|
|
13
12
|
'snowflake',
|
|
14
13
|
'bigquery',
|
|
15
14
|
'sqlite',
|
|
15
|
+
'duckdb',
|
|
16
16
|
'clickhouse',
|
|
17
17
|
'tsql',
|
|
18
|
+
'athena',
|
|
18
19
|
];
|
|
19
20
|
const notesCache = new Map();
|
|
20
21
|
function readDialectNotes(dialect) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
**athena** SQL conventions (Trino engine over the Glue Data Catalog):
|
|
2
|
+
- **FQTN:** `database.table` (e.g. `analytics.orders`); a bare `table` resolves against the query's default database. Cross-catalog is `catalog.database.table` (e.g. `awsdatacatalog.analytics.orders`).
|
|
3
|
+
- **Identifiers:** case-insensitive and folded to lowercase; double-quote (`"Name"`) to keep case, spaces, or a reserved word. String literals use single quotes only.
|
|
4
|
+
- **Date/time:** native `DATE`/`TIMESTAMP`. Bucket with `date_trunc('month', ts)`, pull parts with `EXTRACT(YEAR FROM ts)`, shift with `date_add('day', -30, current_date)`, difference with `date_diff('day', a, b)`, and format with `date_format(ts, '%Y-%m')`. Parse text with `date_parse(str, '%Y-%m-%d')` or `from_iso8601_timestamp(str)`; `current_date` / `now()` are available.
|
|
5
|
+
- **Top-N / windows:** Athena has no `QUALIFY` — wrap the window in a subquery and filter it: `SELECT * FROM (SELECT ..., ROW_NUMBER() OVER (PARTITION BY key ORDER BY x DESC) AS rn FROM t) WHERE rn = 1` returns one row per key. Use `ORDER BY ... LIMIT n` for a global top-N, and paginate with `OFFSET m LIMIT n` (offset first — `LIMIT n OFFSET m` is a syntax error).
|
|
6
|
+
- **Series:** `CROSS JOIN UNNEST(sequence(DATE '2023-01-01', DATE '2023-12-01', INTERVAL '1' MONTH)) AS s(d)` expands a generated array into a date spine (use `sequence(1, 12)` for integers), then `LEFT JOIN` the aggregated facts onto it so empty periods still appear.
|
|
7
|
+
- **Rolling window over time:** a native `RANGE` frame spans real dates and tolerates gaps — `AVG(amount) OVER (ORDER BY day RANGE BETWEEN INTERVAL '29' DAY PRECEDING AND CURRENT ROW)` is a trailing 30-day average without a spine; guard minimum periods with `COUNT(*) OVER (<same frame>)`.
|
|
8
|
+
- **Approximate aggregates:** `approx_distinct(x)` for cardinality and `approx_percentile(x, 0.5)` for quantiles are far cheaper than exact `COUNT(DISTINCT ...)` on large scans.
|
|
9
|
+
- **Arrays & maps:** explode with `CROSS JOIN UNNEST(arr) AS t(x)` (add `WITH ORDINALITY` for an index); build with `array_agg(x)`, join with `array_join(arr, ',')`, index 1-based (`arr[1]`), and read a map with `element_at(m, key)`.
|
|
10
|
+
- **Safe cast:** `TRY_CAST(x AS DOUBLE)` yields `NULL` for a value that does not parse instead of raising, so counting residual `NULL`s catches an encoding the sample missed; `TRY(expr)` swallows other runtime errors.
|
|
11
|
+
- **Integer division:** `/` between integers truncates (`5 / 2` → `2`); cast an operand (`x / CAST(y AS DOUBLE)`) to keep the fraction, and round only in the final projection.
|
|
12
|
+
- **JSON:** `json_extract_scalar(col, '$.a.b')` returns varchar, `json_extract(col, '$.a')` returns json; cast a JSON string with `CAST(json_parse(col) AS ...)`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
**duckdb** SQL conventions:
|
|
2
|
+
- **FQTN:** `schema.table` within one database (e.g. `main.orders`); a bare `table` resolves against `main`. A second, attached database is `db.schema.table` (e.g. `sales.main.orders`).
|
|
3
|
+
- **Identifiers:** case-insensitive; double-quote (`"Name"`) to keep a name with spaces or a reserved word.
|
|
4
|
+
- **Date/time:** native `DATE`/`TIMESTAMP`. Bucket with `date_trunc('month', ts)`, pull parts with `EXTRACT(YEAR FROM ts)`, format with `strftime(ts, '%Y-%m')`, and use `CURRENT_DATE`; cast text with `col::DATE` (or `TRY_CAST(col AS DATE)` to null bad values).
|
|
5
|
+
- **Series:** `FROM generate_series(DATE '2023-01-01', DATE '2023-12-01', INTERVAL 1 MONTH) AS s(d)` builds a date spine (use `range(...)` for integers), then `LEFT JOIN` the aggregated facts onto it so empty periods still appear.
|
|
6
|
+
- **Rolling window over time:** a native calendar-range frame spans real dates and tolerates gaps — `AVG(amount) OVER (ORDER BY day RANGE BETWEEN INTERVAL 29 DAYS PRECEDING AND CURRENT ROW)` is a trailing 30-day average without a spine; guard minimum periods with `COUNT(*) OVER (<same frame>)`.
|
|
7
|
+
- **Integer division:** unlike postgres, `/` is true division (`5 / 2` → `2.5`), so a ratio keeps its fraction; use `//` for floor division (`5 // 2` → `2`) when you want the integer quotient, and round only in the final projection.
|
|
8
|
+
- **Safe cast:** duckdb has `TRY_CAST` — `TRY_CAST(x AS DOUBLE)` yields `NULL` for a value that does not parse instead of raising, so counting residual `NULL`s among non-sentinel rows catches an encoding the sample missed without a regex guard.
|
|
9
|
+
- **Top-N / windows:** filter a window inline with `QUALIFY` — `SELECT ... QUALIFY ROW_NUMBER() OVER (PARTITION BY key ORDER BY x DESC) = 1` returns one row per key without a wrapping CTE; use `ORDER BY ... LIMIT n` for a global top-N.
|
|
10
|
+
- **JSON / semi-structured:** `col->'k'` returns JSON, `col->>'k'` returns text, deep path `json_extract(col, '$.a.b')`; duckdb also has native `STRUCT`, `LIST`, and `MAP` — read a struct field with `col.field` and a list element with `col[1]` (1-indexed).
|
package/dist/local-adapters.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createAthenaLiveDatabaseIntrospection } from './connectors/athena/live-database-introspection.js';
|
|
2
|
+
import { isKtxAthenaConnectionConfig } from './connectors/athena/connector.js';
|
|
1
3
|
import { createBigQueryLiveDatabaseIntrospection } from './connectors/bigquery/live-database-introspection.js';
|
|
2
4
|
import { isKtxBigQueryConnectionConfig, KtxBigQueryScanConnector } from './connectors/bigquery/connector.js';
|
|
3
5
|
import { createClickHouseLiveDatabaseIntrospection } from './connectors/clickhouse/live-database-introspection.js';
|
|
@@ -9,6 +11,8 @@ import { isKtxPostgresConnectionConfig } from './connectors/postgres/connector.j
|
|
|
9
11
|
import { KtxPostgresHistoricSqlQueryClient } from './connectors/postgres/historic-sql-query-client.js';
|
|
10
12
|
import { createSqliteLiveDatabaseIntrospection } from './connectors/sqlite/live-database-introspection.js';
|
|
11
13
|
import { isKtxSqliteConnectionConfig } from './connectors/sqlite/connector.js';
|
|
14
|
+
import { createDuckDbLiveDatabaseIntrospection } from './connectors/duckdb/live-database-introspection.js';
|
|
15
|
+
import { isKtxDuckDbConnectionConfig } from './connectors/duckdb/connector.js';
|
|
12
16
|
import { createSqlServerLiveDatabaseIntrospection } from './connectors/sqlserver/live-database-introspection.js';
|
|
13
17
|
import { isKtxSqlServerConnectionConfig } from './connectors/sqlserver/connector.js';
|
|
14
18
|
import { BigQueryHistoricSqlQueryHistoryReader } from './context/ingest/adapters/historic-sql/bigquery-query-history-reader.js';
|
|
@@ -74,6 +78,10 @@ function createKtxCliLiveDatabaseIntrospection(project, options = {}) {
|
|
|
74
78
|
projectDir: project.projectDir,
|
|
75
79
|
connections: project.config.connections,
|
|
76
80
|
});
|
|
81
|
+
const duckdb = createDuckDbLiveDatabaseIntrospection({
|
|
82
|
+
projectDir: project.projectDir,
|
|
83
|
+
connections: project.config.connections,
|
|
84
|
+
});
|
|
77
85
|
const mysql = createMysqlLiveDatabaseIntrospection({
|
|
78
86
|
connections: project.config.connections,
|
|
79
87
|
});
|
|
@@ -89,6 +97,9 @@ function createKtxCliLiveDatabaseIntrospection(project, options = {}) {
|
|
|
89
97
|
const bigquery = createBigQueryLiveDatabaseIntrospection({
|
|
90
98
|
connections: project.config.connections,
|
|
91
99
|
});
|
|
100
|
+
const athena = createAthenaLiveDatabaseIntrospection({
|
|
101
|
+
connections: project.config.connections,
|
|
102
|
+
});
|
|
92
103
|
return {
|
|
93
104
|
async extractSchema(connectionId, options) {
|
|
94
105
|
const connection = project.config.connections[connectionId];
|
|
@@ -109,6 +120,9 @@ function createKtxCliLiveDatabaseIntrospection(project, options = {}) {
|
|
|
109
120
|
if (isKtxSqliteConnectionConfig(connection)) {
|
|
110
121
|
return sqlite.extractSchema(connectionId, options);
|
|
111
122
|
}
|
|
123
|
+
if (isKtxDuckDbConnectionConfig(connection)) {
|
|
124
|
+
return duckdb.extractSchema(connectionId, options);
|
|
125
|
+
}
|
|
112
126
|
if (isKtxMysqlConnectionConfig(connection)) {
|
|
113
127
|
return mysql.extractSchema(connectionId, options);
|
|
114
128
|
}
|
|
@@ -121,6 +135,9 @@ function createKtxCliLiveDatabaseIntrospection(project, options = {}) {
|
|
|
121
135
|
if (isKtxBigQueryConnectionConfig(connection)) {
|
|
122
136
|
return bigquery.extractSchema(connectionId, options);
|
|
123
137
|
}
|
|
138
|
+
if (isKtxAthenaConnectionConfig(connection)) {
|
|
139
|
+
return athena.extractSchema(connectionId, options);
|
|
140
|
+
}
|
|
124
141
|
if (hasSnowflakeDriver(connection)) {
|
|
125
142
|
const { createSnowflakeLiveDatabaseIntrospection } = await import('./connectors/snowflake/live-database-introspection.js');
|
|
126
143
|
const { isKtxSnowflakeConnectionConfig } = await import('./connectors/snowflake/connector.js');
|
|
@@ -9,7 +9,7 @@ import { type DatabaseScopePickResult, type PickDatabaseScopeArgs } from './data
|
|
|
9
9
|
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
|
|
10
10
|
import type { ManagedPythonCoreDaemonOptions } from './managed-python-http.js';
|
|
11
11
|
import { type KtxSetupPromptOption } from './setup-prompts.js';
|
|
12
|
-
export type KtxSetupDatabaseDriver = 'sqlite' | 'postgres' | 'mysql' | 'clickhouse' | 'sqlserver' | 'bigquery' | 'snowflake' | 'mongodb';
|
|
12
|
+
export type KtxSetupDatabaseDriver = 'sqlite' | 'duckdb' | 'postgres' | 'mysql' | 'clickhouse' | 'sqlserver' | 'bigquery' | 'snowflake' | 'athena' | 'mongodb';
|
|
13
13
|
export interface KtxSetupDatabasesArgs {
|
|
14
14
|
projectDir: string;
|
|
15
15
|
inputMode: 'auto' | 'disabled';
|
package/dist/setup-databases.js
CHANGED
|
@@ -37,8 +37,10 @@ const DRIVER_OPTIONS = [
|
|
|
37
37
|
{ value: 'mysql', label: 'MySQL' },
|
|
38
38
|
{ value: 'clickhouse', label: 'ClickHouse' },
|
|
39
39
|
{ value: 'sqlserver', label: 'SQL Server' },
|
|
40
|
+
{ value: 'athena', label: 'Amazon Athena' },
|
|
40
41
|
{ value: 'mongodb', label: 'MongoDB' },
|
|
41
42
|
{ value: 'sqlite', label: 'SQLite' },
|
|
43
|
+
{ value: 'duckdb', label: 'DuckDB' },
|
|
42
44
|
];
|
|
43
45
|
const DRIVER_LABELS = Object.fromEntries(DRIVER_OPTIONS.map((option) => [option.value, option.label]));
|
|
44
46
|
const HISTORIC_SQL_DIALECT_BY_DRIVER = {
|
|
@@ -48,12 +50,14 @@ const HISTORIC_SQL_DIALECT_BY_DRIVER = {
|
|
|
48
50
|
};
|
|
49
51
|
const DEFAULT_CONNECTION_IDS = {
|
|
50
52
|
sqlite: 'sqlite-local',
|
|
53
|
+
duckdb: 'duckdb-local',
|
|
51
54
|
postgres: 'postgres-warehouse',
|
|
52
55
|
mysql: 'mysql-warehouse',
|
|
53
56
|
clickhouse: 'clickhouse-warehouse',
|
|
54
57
|
sqlserver: 'sqlserver-warehouse',
|
|
55
58
|
bigquery: 'bigquery-warehouse',
|
|
56
59
|
snowflake: 'snowflake-warehouse',
|
|
60
|
+
athena: 'athena-warehouse',
|
|
57
61
|
mongodb: 'mongodb-source',
|
|
58
62
|
};
|
|
59
63
|
const SUGGESTED_SCOPE_PATTERN = /^(mart|prod|analytics|core|dim|fact|gold)(_|$)/i;
|
|
@@ -118,6 +122,13 @@ const SCOPE_DISCOVERY_SPECS = {
|
|
|
118
122
|
configSingleField: 'schema_name',
|
|
119
123
|
suggest: defaultSuggest,
|
|
120
124
|
},
|
|
125
|
+
athena: {
|
|
126
|
+
noun: 'database',
|
|
127
|
+
nounPlural: 'databases',
|
|
128
|
+
promptLabel: 'Glue databases',
|
|
129
|
+
configArrayField: 'databases',
|
|
130
|
+
suggest: defaultSuggest,
|
|
131
|
+
},
|
|
121
132
|
};
|
|
122
133
|
const DRIVER_CONNECTION_DEFAULTS = {
|
|
123
134
|
postgres: { port: '5432' },
|
|
@@ -541,6 +552,15 @@ async function buildConnectionConfig(input) {
|
|
|
541
552
|
return 'back';
|
|
542
553
|
return path ? { driver: 'sqlite', path } : null;
|
|
543
554
|
}
|
|
555
|
+
if (driver === 'duckdb') {
|
|
556
|
+
if (args.inputMode === 'disabled' && !args.databaseUrl)
|
|
557
|
+
return null;
|
|
558
|
+
const path = args.databaseUrl ??
|
|
559
|
+
(await promptText(prompts, 'DuckDB database file\nEnter a relative or absolute path, for example ./warehouse.duckdb.', stringConfigField(input.existingConnection, 'path')));
|
|
560
|
+
if (path === undefined)
|
|
561
|
+
return 'back';
|
|
562
|
+
return path ? { driver: 'duckdb', path } : null;
|
|
563
|
+
}
|
|
544
564
|
if (driver === 'postgres' || driver === 'mysql' || driver === 'clickhouse' || driver === 'sqlserver') {
|
|
545
565
|
return await buildUrlConnectionConfig({
|
|
546
566
|
driver,
|
|
@@ -664,6 +684,34 @@ async function buildConnectionConfig(input) {
|
|
|
664
684
|
...(role ? { role } : {}),
|
|
665
685
|
};
|
|
666
686
|
}
|
|
687
|
+
if (driver === 'athena') {
|
|
688
|
+
if (args.inputMode === 'disabled' && !args.databaseUrl)
|
|
689
|
+
return null;
|
|
690
|
+
const region = await promptText(prompts, 'AWS region\nFor example us-east-1.', stringConfigField(input.existingConnection, 'region'));
|
|
691
|
+
if (region === undefined)
|
|
692
|
+
return 'back';
|
|
693
|
+
if (!region)
|
|
694
|
+
return null;
|
|
695
|
+
const s3StagingDir = await promptText(prompts, 'S3 staging directory\nAthena writes query results here. For example s3://my-bucket/athena-results/.', stringConfigField(input.existingConnection, 's3_staging_dir'));
|
|
696
|
+
if (s3StagingDir === undefined)
|
|
697
|
+
return 'back';
|
|
698
|
+
if (!s3StagingDir)
|
|
699
|
+
return null;
|
|
700
|
+
const workgroup = await promptText(prompts, 'Athena workgroup (optional)\nPress Enter to use the default workgroup "primary".', stringConfigField(input.existingConnection, 'workgroup'));
|
|
701
|
+
if (workgroup === undefined)
|
|
702
|
+
return 'back';
|
|
703
|
+
const catalog = await promptText(prompts, 'Glue Data Catalog name (optional)\nPress Enter to use the default "AwsDataCatalog".', stringConfigField(input.existingConnection, 'catalog'));
|
|
704
|
+
if (catalog === undefined)
|
|
705
|
+
return 'back';
|
|
706
|
+
return {
|
|
707
|
+
driver: 'athena',
|
|
708
|
+
region,
|
|
709
|
+
s3_staging_dir: s3StagingDir,
|
|
710
|
+
...(workgroup ? { workgroup } : {}),
|
|
711
|
+
...(catalog ? { catalog } : {}),
|
|
712
|
+
...scriptedScopeConfigForDriver('athena', args.databaseSchemas),
|
|
713
|
+
};
|
|
714
|
+
}
|
|
667
715
|
throw new Error(`Unsupported database driver: ${driver}`);
|
|
668
716
|
}
|
|
669
717
|
async function maybeApplyHistoricSqlConfig(input) {
|
|
@@ -1031,9 +1079,11 @@ async function maybeConfigureDatabaseScope(input) {
|
|
|
1031
1079
|
const project = await loadKtxProject({ projectDir: input.projectDir });
|
|
1032
1080
|
const connection = project.config.connections[input.connectionId];
|
|
1033
1081
|
const driver = normalizeDriver(connection?.driver);
|
|
1034
|
-
|
|
1082
|
+
const spec = driver ? SCOPE_DISCOVERY_SPECS[driver] : undefined;
|
|
1083
|
+
// Drivers with no scope spec are single-namespace (sqlite, duckdb): there is no
|
|
1084
|
+
// schema to choose, so skip the scope picker and ingest every table.
|
|
1085
|
+
if (!driver || !spec)
|
|
1035
1086
|
return okValidateResult();
|
|
1036
|
-
const spec = SCOPE_DISCOVERY_SPECS[driver];
|
|
1037
1087
|
const existingTables = connection?.enabled_tables;
|
|
1038
1088
|
const hasExistingTables = Array.isArray(existingTables) && existingTables.length > 0;
|
|
1039
1089
|
const existingScope = spec ? configuredScopeValues(connection, spec) : [];
|
package/dist/sql.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FEDERATED_CONNECTION_ID } from './context/connections/federation.js';
|
|
2
|
-
import {
|
|
2
|
+
import { executeProjectRawSql } from './context/connections/project-sql-executor.js';
|
|
3
3
|
import { assertSqlQueryableConnection } from './context/connections/dialects.js';
|
|
4
4
|
import { resolveConfiguredConnection } from './context/connections/resolve-connection.js';
|
|
5
5
|
import { loadKtxProject } from './context/project/project.js';
|
|
@@ -91,6 +91,8 @@ export async function runKtxSql(args, io = process, deps = {}) {
|
|
|
91
91
|
const connection = isFederated ? undefined : resolveConfiguredConnection(project.config, args.connectionId);
|
|
92
92
|
driver = isFederated ? 'duckdb' : String(connection?.driver ?? 'unknown').toLowerCase();
|
|
93
93
|
demoConnection = isFederated ? false : isDemoConnection(args.connectionId, connection);
|
|
94
|
+
// Fail fast before creating the SQL-analysis daemon port; executeProjectRawSql
|
|
95
|
+
// re-asserts this for every caller.
|
|
94
96
|
if (!isFederated) {
|
|
95
97
|
assertSqlQueryableConnection(args.connectionId, connection?.driver);
|
|
96
98
|
}
|
|
@@ -103,25 +105,18 @@ export async function runKtxSql(args, io = process, deps = {}) {
|
|
|
103
105
|
}));
|
|
104
106
|
const analysisPort = createSqlAnalysis();
|
|
105
107
|
const dialect = isFederated ? 'duckdb' : sqlAnalysisDialectForDriver(connection?.driver);
|
|
106
|
-
const validation = await analysisPort.validateReadOnly(args.sql, dialect);
|
|
107
|
-
if (!validation.ok) {
|
|
108
|
-
throw new Error(validation.error ?? 'SQL is not read-only.');
|
|
109
|
-
}
|
|
110
|
-
const referencedTableCount = await safeReferencedTableCount(analysisPort, args.sql, dialect);
|
|
111
108
|
const createScanConnector = deps.createScanConnector ?? createKtxCliScanConnector;
|
|
112
|
-
const result = await
|
|
109
|
+
const result = await executeProjectRawSql({
|
|
113
110
|
project,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
sql: args.sql,
|
|
119
|
-
maxRows: args.maxRows,
|
|
120
|
-
},
|
|
111
|
+
connectionId: args.connectionId,
|
|
112
|
+
sql: args.sql,
|
|
113
|
+
maxRows: args.maxRows,
|
|
114
|
+
sqlAnalysis: analysisPort,
|
|
121
115
|
createConnector: (connectionId) => createScanConnector(project, connectionId),
|
|
122
116
|
executeFederated: deps.executeFederated,
|
|
123
117
|
runId: 'cli-sql',
|
|
124
118
|
});
|
|
119
|
+
const referencedTableCount = await safeReferencedTableCount(analysisPort, args.sql, dialect);
|
|
125
120
|
const mode = resolveOutputMode({ explicit: args.output, json: args.json, io });
|
|
126
121
|
printSqlResult(resultOutput(args.connectionId, result), mode, io);
|
|
127
122
|
await emitTelemetryEvent({
|
package/dist/status-project.js
CHANGED
|
@@ -236,6 +236,7 @@ function buildConnectionStatus(name, conn, env) {
|
|
|
236
236
|
const hint = envHint(conn.credentials_json);
|
|
237
237
|
return warn(hint ? `credentials missing (env: ${hint})` : 'credentials not set', hint ? `Set ${hint}` : 'Rerun `ktx setup`');
|
|
238
238
|
}
|
|
239
|
+
case 'duckdb':
|
|
239
240
|
case 'sqlite': {
|
|
240
241
|
const path = conn.path;
|
|
241
242
|
if (typeof path === 'string' && path.length > 0)
|
|
@@ -361,7 +362,7 @@ async function buildQueryHistoryStatus(project, options) {
|
|
|
361
362
|
return statuses;
|
|
362
363
|
}
|
|
363
364
|
const ADAPTER_DRIVER_REQUIREMENT = {
|
|
364
|
-
'live-database': ['postgres', 'mysql', 'snowflake', 'bigquery', 'clickhouse', 'sqlite', 'sqlserver'],
|
|
365
|
+
'live-database': ['postgres', 'mysql', 'snowflake', 'bigquery', 'clickhouse', 'sqlite', 'duckdb', 'sqlserver'],
|
|
365
366
|
dbt: ['dbt', 'dbt-core', 'dbt-cloud'],
|
|
366
367
|
notion: ['notion'],
|
|
367
368
|
metabase: ['metabase'],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaelio/ktx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Standalone ktx context layer for data agents",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Kaelio",
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
"@ai-sdk/devtools": "0.0.18",
|
|
37
37
|
"@ai-sdk/google-vertex": "^4.0.134",
|
|
38
38
|
"@anthropic-ai/claude-agent-sdk": "0.3.146",
|
|
39
|
+
"@aws-sdk/client-athena": "^3.1068.0",
|
|
40
|
+
"@aws-sdk/client-glue": "^3.1068.0",
|
|
39
41
|
"@clack/core": "1.3.1",
|
|
40
42
|
"@clack/prompts": "1.4.0",
|
|
41
43
|
"@clickhouse/client": "^1.18.5",
|
|
Binary file
|