diff --git a/README.md b/README.md index 285f92a6..cb9d25b0 100644 --- a/README.md +++ b/README.md @@ -166,8 +166,8 @@ agent also needs pinned `ktx` admin commands. After setup, **ktx** prints **Required before using agents** with the exact commands to run. If the output includes `ktx mcp start --project-dir ...`, run -it before opening your agent. Claude Desktop uses its own launcher and prints -separate skill upload steps under `.ktx/agents/claude/`. +it before opening your agent. Claude Desktop gets a stdio MCP config entry and +prints separate skill upload steps under `.ktx/agents/claude/`. ## Workspace layout diff --git a/docs-site/content/docs/configuration/ktx-yaml.mdx b/docs-site/content/docs/configuration/ktx-yaml.mdx index 2220814a..873a8acd 100644 --- a/docs-site/content/docs/configuration/ktx-yaml.mdx +++ b/docs-site/content/docs/configuration/ktx-yaml.mdx @@ -105,7 +105,7 @@ context-source drivers share the map. | Driver | Kind | Required fields | Common optional fields | |--------|------|-----------------|------------------------| -| `postgres` / `postgresql` | Warehouse | `driver` | `url`, `enabled_tables`, `historicSql`, `context.queryHistory` | +| `postgres` | Warehouse | `driver` | `url`, `enabled_tables`, `historicSql`, `context.queryHistory` | | `mysql` | Warehouse | `driver` | `url`, `enabled_tables` | | `sqlite` | Warehouse | `driver` | `url` or `path`, `enabled_tables` | | `sqlserver` | Warehouse | `driver` | `url`, `enabled_tables` | diff --git a/docs-site/content/docs/integrations/agent-clients.mdx b/docs-site/content/docs/integrations/agent-clients.mdx index f7281dda..36aef1c3 100644 --- a/docs-site/content/docs/integrations/agent-clients.mdx +++ b/docs-site/content/docs/integrations/agent-clients.mdx @@ -183,10 +183,8 @@ Claude Desktop skill packages for the **ktx** workflows: - `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%AppData%/Claude/claude_desktop_config.json` (Windows) gets an - `mcpServers.ktx` entry that runs the **ktx** MCP server over stdio via a local - launcher shim at `.ktx/agents/claude/ktx-plugin-runner.sh`. The shim locates - a usable Node.js (Volta, NVM, Homebrew, system) so Claude Desktop can spawn - the server without needing `node` in PATH. + `mcpServers.ktx` entry that runs the **ktx** MCP server over stdio with the + current Node.js executable and the installed `ktx` CLI entrypoint. - `.ktx/agents/claude/ktx-analytics.zip` contains the `ktx-analytics` skill. If you choose **Ask data questions + manage ktx with CLI commands**, **ktx** also generates `.ktx/agents/claude/ktx.zip` with the admin `ktx` skill. Claude diff --git a/packages/cli/src/connection.ts b/packages/cli/src/connection.ts index bb99d4fd..335bfb47 100644 --- a/packages/cli/src/connection.ts +++ b/packages/cli/src/connection.ts @@ -274,9 +274,7 @@ async function testConnectionByDriver( if ( driver === 'sqlite' || - driver === 'sqlite3' || driver === 'postgres' || - driver === 'postgresql' || driver === 'mysql' || driver === 'clickhouse' || driver === 'sqlserver' || diff --git a/packages/cli/src/connectors/postgres/connector.test.ts b/packages/cli/src/connectors/postgres/connector.test.ts index 346c2ef2..0ab23a0a 100644 --- a/packages/cli/src/connectors/postgres/connector.test.ts +++ b/packages/cli/src/connectors/postgres/connector.test.ts @@ -99,7 +99,7 @@ function metadataResults(): Map { describe('KtxPostgresScanConnector', () => { it('resolves configuration safely', () => { expect(isKtxPostgresConnectionConfig({ driver: 'postgres', url: 'env:DATABASE_URL' })).toBe(true); - expect(isKtxPostgresConnectionConfig({ driver: 'postgresql', host: 'db', database: 'analytics' })).toBe(true); + expect(isKtxPostgresConnectionConfig({ driver: 'postgresql', host: 'db', database: 'analytics' })).toBe(false); expect(isKtxPostgresConnectionConfig({ driver: 'mysql', host: 'db' })).toBe(false); expect( postgresPoolConfigFromConfig({ diff --git a/packages/cli/src/connectors/postgres/connector.ts b/packages/cli/src/connectors/postgres/connector.ts index 5cb94bf4..44bd58b6 100644 --- a/packages/cli/src/connectors/postgres/connector.ts +++ b/packages/cli/src/connectors/postgres/connector.ts @@ -276,7 +276,7 @@ export function isKtxPostgresConnectionConfig( connection: KtxPostgresConnectionConfig | undefined, ): connection is KtxPostgresConnectionConfig { const driver = String(connection?.driver ?? '').toLowerCase(); - return driver === 'postgres' || driver === 'postgresql'; + return driver === 'postgres'; } /** @internal */ diff --git a/packages/cli/src/connectors/sqlite/connector.ts b/packages/cli/src/connectors/sqlite/connector.ts index 17b33a71..504e427d 100644 --- a/packages/cli/src/connectors/sqlite/connector.ts +++ b/packages/cli/src/connectors/sqlite/connector.ts @@ -125,7 +125,7 @@ export function isKtxSqliteConnectionConfig( connection: KtxSqliteConnectionConfig | undefined, ): connection is KtxSqliteConnectionConfig { const driver = String(connection?.driver ?? '').toLowerCase(); - return driver === 'sqlite' || driver === 'sqlite3'; + return driver === 'sqlite'; } /** @internal */ diff --git a/packages/cli/src/context-build-view.ts b/packages/cli/src/context-build-view.ts index 49ddd3eb..9a06d39a 100644 --- a/packages/cli/src/context-build-view.ts +++ b/packages/cli/src/context-build-view.ts @@ -694,7 +694,7 @@ function isLocalSqlAnalysisConnectionRefused(input: { capturedOutput?: string; f function friendlyDriverName(driver: string): string { const normalized = driver.toLowerCase(); - if (normalized === 'postgres' || normalized === 'postgresql') return 'PostgreSQL'; + if (normalized === 'postgres') return 'PostgreSQL'; if (normalized === 'mysql') return 'MySQL'; if (normalized === 'sqlserver') return 'SQL Server'; if (normalized === 'bigquery') return 'BigQuery'; diff --git a/packages/cli/src/context/connections/dialects.test.ts b/packages/cli/src/context/connections/dialects.test.ts index 6c9b6c41..d4f77997 100644 --- a/packages/cli/src/context/connections/dialects.test.ts +++ b/packages/cli/src/context/connections/dialects.test.ts @@ -4,7 +4,6 @@ import { getDialectForDriver } from './dialects.js'; describe('getDialectForDriver', () => { it.each([ ['postgres', '"public"."orders"'], - ['postgresql', '"public"."orders"'], ['mysql', '`public`.`orders`'], ['clickhouse', '`public`.`orders`'], ['sqlite', '"orders"'], @@ -24,7 +23,12 @@ describe('getDialectForDriver', () => { it('throws with a supported-driver list for unknown drivers', () => { expect(() => getDialectForDriver('oracle')).toThrow( - 'Unsupported warehouse driver "oracle". Supported drivers: bigquery, clickhouse, mysql, postgres, postgresql, sqlite, sqlite3, snowflake, sqlserver', + 'Unsupported warehouse driver "oracle". Supported drivers: bigquery, clickhouse, mysql, postgres, sqlite, snowflake, sqlserver', ); }); + + it('rejects legacy driver aliases', () => { + expect(() => getDialectForDriver('postgresql')).toThrow('Unsupported warehouse driver "postgresql"'); + expect(() => getDialectForDriver('sqlite3')).toThrow('Unsupported warehouse driver "sqlite3"'); + }); }); diff --git a/packages/cli/src/context/connections/dialects.ts b/packages/cli/src/context/connections/dialects.ts index 75a8ae4c..5c6cc27f 100644 --- a/packages/cli/src/context/connections/dialects.ts +++ b/packages/cli/src/context/connections/dialects.ts @@ -2,14 +2,12 @@ import type { KtxSchemaDimensionType, KtxTableRef } from '../scan/types.js'; type SupportedDriver = | 'postgres' - | 'postgresql' | 'mysql' | 'sqlserver' | 'snowflake' | 'bigquery' | 'clickhouse' - | 'sqlite' - | 'sqlite3'; + | 'sqlite'; export interface KtxDialect { readonly type: SupportedDriver; @@ -23,9 +21,7 @@ const supportedDrivers: SupportedDriver[] = [ 'clickhouse', 'mysql', 'postgres', - 'postgresql', 'sqlite', - 'sqlite3', 'snowflake', 'sqlserver', ]; @@ -83,11 +79,9 @@ function createDialect(type: SupportedDriver, quote: (identifier: string) => str const dialects: Record = { postgres: createDialect('postgres', doubleQuoted), - postgresql: createDialect('postgresql', doubleQuoted), mysql: createDialect('mysql', backtickQuoted), clickhouse: createDialect('clickhouse', backtickQuoted), sqlite: createDialect('sqlite', doubleQuoted, true), - sqlite3: createDialect('sqlite3', doubleQuoted, true), snowflake: createDialect('snowflake', doubleQuoted), bigquery: createDialect('bigquery', bigQueryQuoted), sqlserver: createDialect('sqlserver', bracketQuoted), diff --git a/packages/cli/src/context/connections/local-query-executor.ts b/packages/cli/src/context/connections/local-query-executor.ts index 9b5f2032..72cefe2a 100644 --- a/packages/cli/src/context/connections/local-query-executor.ts +++ b/packages/cli/src/context/connections/local-query-executor.ts @@ -22,10 +22,10 @@ export function createDefaultLocalQueryExecutor(options: DefaultLocalQueryExecut return { async execute(input: KtxSqlQueryExecutionInput): Promise { const driver = driverFor(input); - if (driver === 'postgres' || driver === 'postgresql') { + if (driver === 'postgres') { return postgres.execute(input); } - if (driver === 'sqlite' || driver === 'sqlite3') { + if (driver === 'sqlite') { return sqlite.execute(input); } throw new Error(`No local query executor is configured for driver "${input.connection?.driver ?? 'unknown'}".`); diff --git a/packages/cli/src/context/connections/local-warehouse-descriptor.test.ts b/packages/cli/src/context/connections/local-warehouse-descriptor.test.ts index 0eee9f34..7e62f1dc 100644 --- a/packages/cli/src/context/connections/local-warehouse-descriptor.test.ts +++ b/packages/cli/src/context/connections/local-warehouse-descriptor.test.ts @@ -53,6 +53,11 @@ describe('local connection info helpers', () => { expect(localConnectionTypeForConfig('snowflake', { driver: 'snowflake' })).toBe('SNOWFLAKE'); }); + it('keeps removed driver aliases as display-only labels', () => { + expect(localConnectionTypeForConfig('warehouse', { driver: 'postgresql' } as never)).toBe('postgresql'); + expect(localConnectionTypeForConfig('warehouse', { driver: 'mssql' } as never)).toBe('mssql'); + }); + it('keeps non-warehouse adapter labels for display-only local connection surfaces', () => { expect(localConnectionTypeForConfig('prod-metabase', { driver: 'metabase', api_url: 'https://metabase.example.com' })).toBe( 'metabase', diff --git a/packages/cli/src/context/connections/local-warehouse-descriptor.ts b/packages/cli/src/context/connections/local-warehouse-descriptor.ts index c2cc6516..4ad926df 100644 --- a/packages/cli/src/context/connections/local-warehouse-descriptor.ts +++ b/packages/cli/src/context/connections/local-warehouse-descriptor.ts @@ -20,10 +20,8 @@ export interface LocalConnectionInfo { const DRIVER_TO_CONNECTION_TYPE: Record = { postgres: 'POSTGRESQL', - postgresql: 'POSTGRESQL', sqlite: 'SQLITE', sqlserver: 'SQLSERVER', - mssql: 'SQLSERVER', mysql: 'MYSQL', clickhouse: 'CLICKHOUSE', snowflake: 'SNOWFLAKE', diff --git a/packages/cli/src/context/connections/postgres-query-executor.ts b/packages/cli/src/context/connections/postgres-query-executor.ts index b5f2d02e..842609f4 100644 --- a/packages/cli/src/context/connections/postgres-query-executor.ts +++ b/packages/cli/src/context/connections/postgres-query-executor.ts @@ -38,7 +38,7 @@ export function createPostgresQueryExecutor(options: PostgresQueryExecutorOption async execute(input: KtxSqlQueryExecutionInput): Promise { const driver = connectionDriver(input); const connection = input.connection; - if (driver !== 'postgres' && driver !== 'postgresql') { + if (driver !== 'postgres') { throw new Error(`Local Postgres execution cannot run driver "${connection?.driver ?? 'unknown'}".`); } if (typeof connection?.url !== 'string' || connection.url.trim().length === 0) { diff --git a/packages/cli/src/context/connections/sqlite-query-executor.ts b/packages/cli/src/context/connections/sqlite-query-executor.ts index 22c69005..40710c96 100644 --- a/packages/cli/src/context/connections/sqlite-query-executor.ts +++ b/packages/cli/src/context/connections/sqlite-query-executor.ts @@ -52,7 +52,7 @@ function sqlitePathFromUrl(url: string): string { /** @internal */ export function sqliteDatabasePathFromConnection(input: KtxSqlQueryExecutionInput): string { const driver = connectionDriver(input); - if (driver !== 'sqlite' && driver !== 'sqlite3') { + if (driver !== 'sqlite') { throw new Error(`Local SQLite execution cannot run driver "${input.connection?.driver ?? 'unknown'}".`); } diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/connection-dialect.ts b/packages/cli/src/context/ingest/adapters/historic-sql/connection-dialect.ts index 846ce098..c6b4c53b 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/connection-dialect.ts +++ b/packages/cli/src/context/ingest/adapters/historic-sql/connection-dialect.ts @@ -25,7 +25,7 @@ export function queryHistoryDialectForConnection(connection: unknown): HistoricS } const conn = recordOrNull(connection); const driver = String(conn?.driver ?? '').toLowerCase(); - if (driver === 'postgres' || driver === 'postgresql') return 'postgres'; + if (driver === 'postgres') return 'postgres'; if (driver === 'bigquery') return 'bigquery'; if (driver === 'snowflake') return 'snowflake'; return null; diff --git a/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.test.ts b/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.test.ts index 9310f148..ca62ec05 100644 --- a/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.test.ts +++ b/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.test.ts @@ -155,7 +155,7 @@ describe('createDaemonLiveDatabaseIntrospection', () => { const introspection = createDaemonLiveDatabaseIntrospection({ connections: { warehouse: { - driver: 'postgresql', + driver: 'postgres', url: 'postgres://localhost:5432/warehouse', }, }, diff --git a/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.ts b/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.ts index f71e332d..03e5953d 100644 --- a/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.ts +++ b/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.ts @@ -151,7 +151,7 @@ function optionalString(value: unknown): string | undefined { function normalizeDriver(driver: unknown): string { const normalized = String(driver ?? '').trim().toLowerCase(); - return normalized === 'postgresql' ? 'postgres' : normalized; + return normalized; } function requirePostgresConnection( diff --git a/packages/cli/src/context/ingest/adapters/looker/mapping.test.ts b/packages/cli/src/context/ingest/adapters/looker/mapping.test.ts index 796a9f05..c7b29b8a 100644 --- a/packages/cli/src/context/ingest/adapters/looker/mapping.test.ts +++ b/packages/cli/src/context/ingest/adapters/looker/mapping.test.ts @@ -72,7 +72,8 @@ 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')).toBe('SQLSERVER'); + expect(lookerDialectToConnectionType('mssql')).toBeNull(); + expect(lookerDialectToConnectionType('tsql')).toBeNull(); expect(lookerDialectToConnectionType('unknown')).toBeNull(); }); diff --git a/packages/cli/src/context/ingest/adapters/looker/mapping.ts b/packages/cli/src/context/ingest/adapters/looker/mapping.ts index cbd80e80..4da6344d 100644 --- a/packages/cli/src/context/ingest/adapters/looker/mapping.ts +++ b/packages/cli/src/context/ingest/adapters/looker/mapping.ts @@ -7,12 +7,9 @@ const LOOKER_DIALECT_TO_CONNECTION_TYPE = { bigquery_standard_sql: 'BIGQUERY', snowflake: 'SNOWFLAKE', postgres: 'POSTGRESQL', - postgresql: 'POSTGRESQL', mysql: 'MYSQL', sqlite: 'SQLITE', sqlserver: 'SQLSERVER', - mssql: 'SQLSERVER', - tsql: 'SQLSERVER', clickhouse: 'CLICKHOUSE', } as const; diff --git a/packages/cli/src/context/ingest/local-adapters.ts b/packages/cli/src/context/ingest/local-adapters.ts index e7c99146..4739f4e4 100644 --- a/packages/cli/src/context/ingest/local-adapters.ts +++ b/packages/cli/src/context/ingest/local-adapters.ts @@ -168,7 +168,6 @@ function isRecord(value: unknown): value is Record { const historicSqlDialectByDriver = new Map([ ['postgres', 'postgres'], - ['postgresql', 'postgres'], ['bigquery', 'bigquery'], ['snowflake', 'snowflake'], ]); diff --git a/packages/cli/src/context/mcp/__snapshots__/mcp-tools-list.json b/packages/cli/src/context/mcp/__snapshots__/mcp-tools-list.json index db84b328..10cb0b77 100644 --- a/packages/cli/src/context/mcp/__snapshots__/mcp-tools-list.json +++ b/packages/cli/src/context/mcp/__snapshots__/mcp-tools-list.json @@ -307,7 +307,7 @@ { "name": "sl_query", "title": "Semantic Layer Query", - "description": "Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: \"warehouse\", measures: [\"orders.order_count\"], dimensions: [{ dimension: \"orders.created_at\", granularity: \"month\" }] }).", + "description": "Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: \"warehouse\", measures: [\"orders.order_count\"], dimensions: [{ field: \"orders.created_at\", granularity: \"month\" }] }).", "inputSchema": { "type": "object", "properties": { @@ -349,7 +349,8 @@ "description": "Measures to select. Use semantic-layer keys when available." }, "dimensions": { - "description": "Dimensions to group by. Strings and {dimension, granularity} are accepted.", + "default": [], + "description": "Dimensions to group by. Use {field, granularity?} entries.", "type": "array", "items": { "type": "object", @@ -389,7 +390,8 @@ } }, "order_by": { - "description": "Sort clauses. Strings and Cube-style {id, desc} are accepted.", + "default": [], + "description": "Sort clauses. Use {field, direction?} entries.", "type": "array", "items": { "type": "object", @@ -489,7 +491,7 @@ { "name": "entity_details", "title": "Entity Details", - "description": "Read table and column metadata from the latest live-database scan snapshot. Example: entity_details({ connectionId: \"warehouse\", entities: [{ table: { schema: \"public\", table: \"orders\" }, columns: [\"id\"] }] }).", + "description": "Read table and column metadata from the latest live-database scan snapshot. Example: entity_details({ connectionId: \"warehouse\", entities: [{ table: { catalog: null, db: \"public\", name: \"orders\" }, columns: [\"id\"] }] }).", "inputSchema": { "type": "object", "properties": { @@ -549,7 +551,7 @@ ] } ], - "description": "Table display string or object ref. {schema, table} is accepted as an alias for {db, name}." + "description": "Table display string or canonical object ref." }, "columns": { "description": "Optional column filter.", @@ -560,7 +562,10 @@ "description": "Column name to inspect." } } - } + }, + "required": [ + "table" + ] }, "description": "Tables or columns to inspect. Maximum 20 entities." } diff --git a/packages/cli/src/context/mcp/context-tools.ts b/packages/cli/src/context/mcp/context-tools.ts index 963ab44f..aa593f7f 100644 --- a/packages/cli/src/context/mcp/context-tools.ts +++ b/packages/cli/src/context/mcp/context-tools.ts @@ -54,13 +54,13 @@ const toolDescriptions = { 'Search KTX wiki pages for reusable business context. Example: wiki_search({ query: "revenue recognition", limit: 5 }).', wiki_read: 'Read a KTX wiki page by key returned from wiki_search. Example: wiki_read({ key: "global/revenue" }).', entity_details: - 'Read table and column metadata from the latest live-database scan snapshot. Example: entity_details({ connectionId: "warehouse", entities: [{ table: { schema: "public", table: "orders" }, columns: ["id"] }] }).', + 'Read table and column metadata from the latest live-database scan snapshot. Example: entity_details({ connectionId: "warehouse", entities: [{ table: { catalog: null, db: "public", name: "orders" }, columns: ["id"] }] }).', dictionary_search: 'Search profile-sampled warehouse values to locate likely source columns for business values. Example: dictionary_search({ values: ["Acme Corp"], connectionId: "warehouse" }).', sl_read_source: 'Read a semantic-layer YAML source by connection id and source name. Example: sl_read_source({ connectionId: "warehouse", sourceName: "orders" }).', sl_query: - 'Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: "warehouse", measures: ["orders.order_count"], dimensions: [{ dimension: "orders.created_at", granularity: "month" }] }).', + 'Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: "warehouse", measures: ["orders.order_count"], dimensions: [{ field: "orders.created_at", granularity: "month" }] }).', sql_execution: 'Execute one parser-validated read-only SQL query against a configured KTX connection. Example: sql_execution({ connectionId: "warehouse", sql: "select count(*) from public.orders", maxRows: 100 }).', memory_ingest: @@ -93,44 +93,16 @@ const slQueryMeasureSchema = z.union([ }), ]); -const slQueryDimensionSchema = z.preprocess( - (value) => { - if (typeof value === 'string') return { field: value }; - if (value && typeof value === 'object' && !Array.isArray(value)) { - const obj = { ...(value as Record) }; - if (!('field' in obj) && typeof obj.dimension === 'string') obj.field = obj.dimension; - return obj; - } - return value; - }, - z.object({ +const slQueryDimensionSchema = z.object({ field: z.string().min(1).describe('Dimension to group by, e.g. "orders.created_at" or "orders.status".'), granularity: z .string() .min(1) .optional() .describe('Time grain for time dimensions: day, week, month, quarter, or year.'), - }), -); + }); -const slQueryOrderBySchema = z.preprocess( - (value) => { - if (typeof value === 'string') { - return { field: value }; - } - if (value && typeof value === 'object' && !Array.isArray(value)) { - const obj = { ...(value as Record) }; - if (!('field' in obj) && typeof obj.id === 'string') { - obj.field = obj.id; - } - if (!('direction' in obj) && 'desc' in obj) { - obj.direction = obj.desc === true ? 'desc' : 'asc'; - } - return obj; - } - return value; - }, - z.object({ +const slQueryOrderBySchema = z.object({ field: z .string() .min(1) @@ -141,8 +113,7 @@ const slQueryOrderBySchema = z.preprocess( .enum(['asc', 'desc']) .default('asc') .describe('Sort direction: "asc" or "desc". Defaults to "asc".'), - }), -); + }); const slQuerySchema = z.object({ connectionId: connectionIdSchema @@ -152,7 +123,7 @@ const slQuerySchema = z.object({ dimensions: z .array(slQueryDimensionSchema) .default([]) - .describe('Dimensions to group by. Strings and {dimension, granularity} are accepted.'), + .describe('Dimensions to group by. Use {field, granularity?} entries.'), filters: z .array(z.string().describe('Semantic-layer filter expression, e.g. "orders.status = paid".')) .default([]) @@ -164,28 +135,16 @@ const slQuerySchema = z.object({ order_by: z .array(slQueryOrderBySchema) .default([]) - .describe('Sort clauses. Strings and Cube-style {id, desc} are accepted.'), + .describe('Sort clauses. Use {field, direction?} entries.'), limit: z.number().int().min(0).default(1000).describe('Maximum rows to return. Defaults to 1000.'), include_empty: z.boolean().default(true).describe('Whether to include empty dimension groups. Defaults to true.'), }); -const entityDetailsTableRefSchema = z.preprocess( - (value) => { - if (value && typeof value === 'object' && !Array.isArray(value)) { - const obj = { ...(value as Record) }; - if (!('db' in obj) && typeof obj.schema === 'string') obj.db = obj.schema; - if (!('name' in obj) && typeof obj.table === 'string') obj.name = obj.table; - if (!('catalog' in obj)) obj.catalog = null; - return obj; - } - return value; - }, - z.object({ +const entityDetailsTableRefSchema = z.object({ catalog: z.string().nullable().describe('Catalog/project/database. Use null when not applicable.'), db: z.string().nullable().describe('Schema/database/dataset. Use null when not applicable.'), name: z.string().min(1).describe('Table name.'), - }), -); + }); const entityDetailsSchema = z.object({ connectionId: connectionIdSchema.describe('Connection id whose latest scan snapshot should be read.'), @@ -194,7 +153,7 @@ const entityDetailsSchema = z.object({ z.object({ table: z .union([z.string().min(1), entityDetailsTableRefSchema]) - .describe('Table display string or object ref. {schema, table} is accepted as an alias for {db, name}.'), + .describe('Table display string or canonical object ref.'), columns: z .array(z.string().min(1).describe('Column name to inspect.')) .optional() diff --git a/packages/cli/src/context/mcp/local-project-ports.ts b/packages/cli/src/context/mcp/local-project-ports.ts index dbbb36d2..4c820b14 100644 --- a/packages/cli/src/context/mcp/local-project-ports.ts +++ b/packages/cli/src/context/mcp/local-project-ports.ts @@ -24,17 +24,14 @@ interface CreateLocalProjectMcpContextPortsOptions { function dialectForDriver(driver: string | undefined): string { const normalized = (driver ?? 'postgres').toUpperCase(); const map: Record = { - POSTGRESQL: 'postgres', POSTGRES: 'postgres', BIGQUERY: 'bigquery', SNOWFLAKE: 'snowflake', MYSQL: 'mysql', SQLSERVER: 'tsql', - MSSQL: 'tsql', SQLITE: 'sqlite', DUCKDB: 'duckdb', CLICKHOUSE: 'clickhouse', - REDSHIFT: 'redshift', DATABRICKS: 'databricks', }; return map[normalized] ?? 'postgres'; diff --git a/packages/cli/src/context/mcp/server.test.ts b/packages/cli/src/context/mcp/server.test.ts index bee00c00..e6666364 100644 --- a/packages/cli/src/context/mcp/server.test.ts +++ b/packages/cli/src/context/mcp/server.test.ts @@ -466,7 +466,43 @@ describe('createKtxMcpServer', () => { }); }); - it('sl_query normalizes order_by from cube-style {id, desc} and bare strings to {field, direction}', async () => { + it('sl_query rejects cube-style order_by aliases and bare strings', async () => { + const fake = makeFakeServer(); + const semanticLayer: KtxSemanticLayerMcpPort = { + readSource: vi.fn(), + query: vi.fn().mockResolvedValue({ + sql: '', + headers: [], + rows: [], + totalRows: 0, + }), + }; + + createKtxMcpServer({ + server: fake.server, + userContext: { userId: 'local-user' }, + contextTools: { semanticLayer }, + }); + + await expect( + getTool(fake.tools, 'sl_query').handler({ + connectionId: 'warehouse', + measures: ['orders.count'], + order_by: [{ id: 'orders.quarter_label', desc: false }], + }), + ).resolves.toMatchObject({ isError: true }); + await expect( + getTool(fake.tools, 'sl_query').handler({ + connectionId: 'warehouse', + measures: ['orders.count'], + order_by: ['orders.segment'], + }), + ).resolves.toMatchObject({ isError: true }); + + expect(semanticLayer.query).not.toHaveBeenCalled(); + }); + + it('sl_query accepts canonical order_by entries', async () => { const fake = makeFakeServer(); const semanticLayer: KtxSemanticLayerMcpPort = { readSource: vi.fn(), @@ -489,9 +525,7 @@ describe('createKtxMcpServer', () => { measures: ['orders.count'], order_by: [ { field: 'orders.total', direction: 'desc' }, - { id: 'orders.quarter_label', desc: false }, - { id: 'orders.created_at', desc: true }, - 'orders.segment', + { field: 'orders.segment' }, ], }); @@ -501,8 +535,6 @@ describe('createKtxMcpServer', () => { query: expect.objectContaining({ order_by: [ { field: 'orders.total', direction: 'desc' }, - { field: 'orders.quarter_label', direction: 'asc' }, - { field: 'orders.created_at', direction: 'desc' }, { field: 'orders.segment', direction: 'asc' }, ], }), @@ -511,7 +543,35 @@ describe('createKtxMcpServer', () => { ); }); - it('sl_query normalizes cube-style dimensions to field dimensions', async () => { + it('sl_query rejects cube-style dimensions and bare strings', async () => { + const fake = makeFakeServer(); + const semanticLayer = makeAllContextTools().semanticLayer!; + + createKtxMcpServer({ + server: fake.server, + userContext: { userId: 'local-user' }, + contextTools: { semanticLayer }, + }); + + await expect( + getTool(fake.tools, 'sl_query').handler({ + connectionId: 'warehouse', + measures: ['orders.count'], + dimensions: [{ dimension: 'orders.created_at', granularity: 'month' }], + }), + ).resolves.toMatchObject({ isError: true }); + await expect( + getTool(fake.tools, 'sl_query').handler({ + connectionId: 'warehouse', + measures: ['orders.count'], + dimensions: ['orders.status'], + }), + ).resolves.toMatchObject({ isError: true }); + + expect(semanticLayer.query).not.toHaveBeenCalled(); + }); + + it('sl_query accepts canonical field dimensions', async () => { const fake = makeFakeServer(); const semanticLayer = makeAllContextTools().semanticLayer!; @@ -524,7 +584,7 @@ describe('createKtxMcpServer', () => { await getTool(fake.tools, 'sl_query').handler({ connectionId: 'warehouse', measures: ['orders.count'], - dimensions: [{ dimension: 'orders.created_at', granularity: 'month' }, 'orders.status'], + dimensions: [{ field: 'orders.created_at', granularity: 'month' }, { field: 'orders.status' }], }); expect(semanticLayer.query).toHaveBeenCalledWith( @@ -538,7 +598,27 @@ describe('createKtxMcpServer', () => { ); }); - it('entity_details normalizes sql-style schema table refs', async () => { + it('entity_details rejects sql-style schema table ref aliases', async () => { + const fake = makeFakeServer(); + const entityDetails = makeAllContextTools().entityDetails!; + + createKtxMcpServer({ + server: fake.server, + userContext: { userId: 'local-user' }, + contextTools: { entityDetails }, + }); + + await expect( + getTool(fake.tools, 'entity_details').handler({ + connectionId: 'warehouse', + entities: [{ table: { schema: 'public', table: 'orders' }, columns: ['id'] }], + }), + ).resolves.toMatchObject({ isError: true }); + + expect(entityDetails.read).not.toHaveBeenCalled(); + }); + + it('entity_details accepts canonical table refs', async () => { const fake = makeFakeServer(); const entityDetails = makeAllContextTools().entityDetails!; @@ -550,7 +630,7 @@ describe('createKtxMcpServer', () => { await getTool(fake.tools, 'entity_details').handler({ connectionId: 'warehouse', - entities: [{ table: { schema: 'public', table: 'orders' }, columns: ['id'] }], + entities: [{ table: { catalog: null, db: 'public', name: 'orders' }, columns: ['id'] }], }); expect(entityDetails.read).toHaveBeenCalledWith({ @@ -1018,7 +1098,7 @@ describe('createKtxMcpServer', () => { await getTool(fake.tools, 'sl_query').handler({ connectionId: '00000000-0000-4000-8000-000000000001', measures: ['orders.count'], - dimensions: ['orders.created_at'], + dimensions: [{ field: 'orders.created_at' }], filters: ['orders.status = paid'], limit: 25, }); diff --git a/packages/cli/src/context/project/driver-schemas.test.ts b/packages/cli/src/context/project/driver-schemas.test.ts index 252f428f..1c5b1276 100644 --- a/packages/cli/src/context/project/driver-schemas.test.ts +++ b/packages/cli/src/context/project/driver-schemas.test.ts @@ -4,7 +4,6 @@ import { connectionConfigSchema } from './driver-schemas.js'; describe('connectionConfigSchema (driver discriminated union)', () => { it.each([ ['postgres', 'postgres://user:pass@host:5432/db'], // pragma: allowlist secret - ['postgresql', 'postgresql://user:pass@host:5432/db'], // pragma: allowlist secret ['mysql', 'mysql://user:pass@host:3306/db'], // pragma: allowlist secret ['snowflake', 'snowflake://account/db'], ['bigquery', 'bigquery://project/dataset'], @@ -32,6 +31,10 @@ describe('connectionConfigSchema (driver discriminated union)', () => { it('rejects an unknown driver', () => { expect(() => connectionConfigSchema.parse({ driver: 'nope', url: 'x' })).toThrow(); }); + + it('rejects legacy warehouse driver aliases', () => { + expect(() => connectionConfigSchema.parse({ driver: 'postgresql', url: 'postgresql://host/db' })).toThrow(); + }); }); describe('connectionConfigSchema - context source drivers with mappings', () => { diff --git a/packages/cli/src/context/project/driver-schemas.ts b/packages/cli/src/context/project/driver-schemas.ts index 3d6d1a84..6b4dc017 100644 --- a/packages/cli/src/context/project/driver-schemas.ts +++ b/packages/cli/src/context/project/driver-schemas.ts @@ -7,7 +7,6 @@ import { const warehouseDrivers = [ 'postgres', - 'postgresql', 'mysql', 'snowflake', 'bigquery', @@ -41,7 +40,6 @@ function warehouseConnectionSchema(driver: const warehouseConnectionSchemas = [ warehouseConnectionSchema('postgres'), - warehouseConnectionSchema('postgresql'), warehouseConnectionSchema('mysql'), warehouseConnectionSchema('snowflake'), warehouseConnectionSchema('bigquery'), diff --git a/packages/cli/src/context/scan/enabled-tables.ts b/packages/cli/src/context/scan/enabled-tables.ts index 327992ac..d4f1009c 100644 --- a/packages/cli/src/context/scan/enabled-tables.ts +++ b/packages/cli/src/context/scan/enabled-tables.ts @@ -8,12 +8,8 @@ import type { KtxTableRef } from './types.js'; * * Accepted entry forms: * "catalog.db.name" — fully qualified - * "db.name" — schema-qualified (catalog = null; legacy / Postgres-shape) + * "db.name" — schema-qualified (catalog = null) * "name" — bare (catalog = db = null; SQLite-shape) - * { catalog?, db?, name } — escape hatch for identifiers containing dots - * - * The setup wizard writes the fully-qualified form going forward; the lenient - * parser keeps existing project configs working. */ export function resolveEnabledTables( connection: Record | undefined, @@ -33,16 +29,6 @@ function parseEnabledTableEntry(value: unknown): KtxTableRef | null { if (typeof value === 'string') { return parseDottedEntry(value); } - if (value && typeof value === 'object' && !Array.isArray(value)) { - const entry = value as { catalog?: unknown; db?: unknown; name?: unknown }; - const name = typeof entry.name === 'string' ? entry.name : null; - if (!name) return null; - return { - catalog: typeof entry.catalog === 'string' ? entry.catalog : null, - db: typeof entry.db === 'string' ? entry.db : null, - name, - }; - } return null; } diff --git a/packages/cli/src/context/scan/local-scan.test.ts b/packages/cli/src/context/scan/local-scan.test.ts index 7b5af5b0..cb7e0252 100644 --- a/packages/cli/src/context/scan/local-scan.test.ts +++ b/packages/cli/src/context/scan/local-scan.test.ts @@ -1878,6 +1878,15 @@ describe('resolveEnabledTables', () => { expect(result!.has(tableRefKey({ catalog: null, db: 'public', name: 'orders' }))).toBe(true); }); + it('ignores legacy enabled_tables object entries', () => { + expect( + resolveEnabledTables({ + driver: 'postgres', + enabled_tables: [{ catalog: null, db: 'public', name: 'orders' }], + }), + ).toBeNull(); + }); + it('returns null for undefined connection', () => { expect(resolveEnabledTables(undefined)).toBeNull(); }); diff --git a/packages/cli/src/context/scan/local-scan.ts b/packages/cli/src/context/scan/local-scan.ts index cb886991..0e2842da 100644 --- a/packages/cli/src/context/scan/local-scan.ts +++ b/packages/cli/src/context/scan/local-scan.ts @@ -126,19 +126,17 @@ function normalizeDriver(driver: string | undefined): KtxConnectionDriver { const normalized = (driver ?? '').toLowerCase(); if ( normalized === 'postgres' || - normalized === 'postgresql' || normalized === 'sqlite' || - normalized === 'sqlite3' || normalized === 'mysql' || normalized === 'clickhouse' || normalized === 'sqlserver' || normalized === 'bigquery' || normalized === 'snowflake' ) { - return normalized === 'sqlite3' ? 'sqlite' : normalized; + return normalized; } throw new Error( - `Standalone ktx scan supports postgres/postgresql/sqlite/mysql/clickhouse/sqlserver/bigquery/snowflake in this phase, received "${driver ?? 'unknown'}"`, + `Standalone ktx scan supports postgres/sqlite/mysql/clickhouse/sqlserver/bigquery/snowflake in this phase, received "${driver ?? 'unknown'}"`, ); } diff --git a/packages/cli/src/context/scan/table-ref.test.ts b/packages/cli/src/context/scan/table-ref.test.ts index eb52ac9b..510b6c82 100644 --- a/packages/cli/src/context/scan/table-ref.test.ts +++ b/packages/cli/src/context/scan/table-ref.test.ts @@ -47,9 +47,9 @@ describe('scopedTableNames', () => { expect(scopedTableNames(scope, { catalog: 'ANALYTICS', db: 'STAGING' })).toEqual(['LISTINGS']); }); - it('treats null in the scope entry as a wildcard for that segment', () => { + it('requires non-null scope segments to match the namespace', () => { const scope = tableRefSet([{ catalog: null, db: 'public', name: 'users' }]); - expect(scopedTableNames(scope, { catalog: 'any-catalog', db: 'public' })).toEqual(['users']); + expect(scopedTableNames(scope, { catalog: 'any-catalog', db: 'public' })).toEqual([]); }); it('returns empty when no scope entry matches the namespace', () => { @@ -57,7 +57,7 @@ describe('scopedTableNames', () => { expect(scopedTableNames(scope, { catalog: 'X', db: 'Y' })).toEqual([]); }); - it('dedupes when the same name appears under different catalog projections', () => { + it('dedupes exact namespace matches only', () => { const scope: ReadonlySet = tableRefSet([ { catalog: null, db: 'public', name: 'users' }, { catalog: 'A', db: 'public', name: 'users' }, diff --git a/packages/cli/src/context/scan/table-ref.ts b/packages/cli/src/context/scan/table-ref.ts index 1a2abd70..368d4adb 100644 --- a/packages/cli/src/context/scan/table-ref.ts +++ b/packages/cli/src/context/scan/table-ref.ts @@ -33,8 +33,7 @@ export function tableRefSet(refs: readonly KtxTableRef[]): ReadonlySet, @@ -45,8 +44,8 @@ export function scopedTableNames( const wantDb = namespace.db ?? null; for (const key of scope) { const ref = tableRefFromKey(key); - if (wantCatalog !== null && ref.catalog !== null && ref.catalog !== wantCatalog) continue; - if (wantDb !== null && ref.db !== null && ref.db !== wantDb) continue; + if (ref.catalog !== wantCatalog) continue; + if (ref.db !== wantDb) continue; names.add(ref.name); } return [...names]; diff --git a/packages/cli/src/context/scan/types.ts b/packages/cli/src/context/scan/types.ts index 95e6b590..d8e2aa5a 100644 --- a/packages/cli/src/context/scan/types.ts +++ b/packages/cli/src/context/scan/types.ts @@ -3,7 +3,6 @@ import type { KtxTableRefKey } from './table-ref.js'; export type KtxConnectionDriver = | 'sqlite' | 'postgres' - | 'postgresql' | 'sqlserver' | 'bigquery' | 'snowflake' diff --git a/packages/cli/src/context/scan/warehouse-catalog.ts b/packages/cli/src/context/scan/warehouse-catalog.ts index 2f360eeb..b8e91492 100644 --- a/packages/cli/src/context/scan/warehouse-catalog.ts +++ b/packages/cli/src/context/scan/warehouse-catalog.ts @@ -8,7 +8,7 @@ import type { KtxTableRef, } from './types.js'; -type CatalogDriver = KtxConnectionDriver | 'sqlite3'; +type CatalogDriver = KtxConnectionDriver; export interface WarehouseCatalogServiceDeps { fileStore: KtxFileStorePort; @@ -129,7 +129,7 @@ function splitDisplay(display: string): string[] { } function formatDisplay(driver: CatalogDriver, table: KtxTableRef): string { - if (driver === 'sqlite' || driver === 'sqlite3') { + if (driver === 'sqlite') { return table.name; } return [table.catalog, table.db, table.name].filter((part): part is string => Boolean(part)).join('.'); @@ -137,7 +137,7 @@ function formatDisplay(driver: CatalogDriver, table: KtxTableRef): string { function parseDisplay(driver: CatalogDriver, display: string): KtxTableRef | null { const parts = splitDisplay(display); - if (driver === 'sqlite' || driver === 'sqlite3') { + if (driver === 'sqlite') { return parts.length === 1 ? { catalog: null, db: null, name: parts[0]! } : null; } if (driver === 'bigquery' || driver === 'snowflake' || driver === 'sqlserver') { @@ -156,7 +156,7 @@ function parseDisplay(driver: CatalogDriver, display: string): KtxTableRef | nul } function expectedDisplayPartCount(driver: CatalogDriver): number { - if (driver === 'sqlite' || driver === 'sqlite3') { + if (driver === 'sqlite') { return 1; } if (driver === 'bigquery' || driver === 'snowflake' || driver === 'sqlserver') { diff --git a/packages/cli/src/context/sl/local-query.ts b/packages/cli/src/context/sl/local-query.ts index 4d71504e..781d8b94 100644 --- a/packages/cli/src/context/sl/local-query.ts +++ b/packages/cli/src/context/sl/local-query.ts @@ -48,17 +48,14 @@ function assertSafeConnectionId(connectionId: string): string { function dialectForDriver(driver: string | undefined): string { const normalized = (driver ?? 'postgres').toUpperCase(); const map: Record = { - POSTGRESQL: 'postgres', POSTGRES: 'postgres', BIGQUERY: 'bigquery', SNOWFLAKE: 'snowflake', MYSQL: 'mysql', SQLSERVER: 'tsql', - MSSQL: 'tsql', SQLITE: 'sqlite', DUCKDB: 'duckdb', CLICKHOUSE: 'clickhouse', - REDSHIFT: 'redshift', DATABRICKS: 'databricks', }; return map[normalized] ?? 'postgres'; diff --git a/packages/cli/src/context/sl/local-sl.test.ts b/packages/cli/src/context/sl/local-sl.test.ts index 18cc7392..3ba00a92 100644 --- a/packages/cli/src/context/sl/local-sl.test.ts +++ b/packages/cli/src/context/sl/local-sl.test.ts @@ -392,7 +392,7 @@ describe('local semantic-layer helpers', () => { ).rejects.toThrow('Invalid semantic-layer source'); }); - it('reports legacy overlay column patches with a file-attributed migration hint', async () => { + it('reports overlay columns that are not computed columns', async () => { const invalidYaml = [ 'name: orders', 'columns:', @@ -406,9 +406,7 @@ describe('local semantic-layer helpers', () => { validateLocalSlSource(invalidYaml, { project, connectionId: 'warehouse', sourceName: 'orders' }), ).resolves.toEqual({ valid: false, - errors: [ - "semantic-layer/warehouse/orders.yaml: column 'status' patches a manifest column but is in 'columns:' — move it to 'column_overrides:'", - ], + errors: expect.arrayContaining([expect.stringContaining('columns.0.type')]), }); }); diff --git a/packages/cli/src/context/sl/local-sl.ts b/packages/cli/src/context/sl/local-sl.ts index 18ec8417..243ba94d 100644 --- a/packages/cli/src/context/sl/local-sl.ts +++ b/packages/cli/src/context/sl/local-sl.ts @@ -266,23 +266,6 @@ export async function validateLocalSlSource( try { const parsed = parseYamlRecord(rawYaml); const schema = parsed.table || parsed.sql ? sourceDefinitionSchema : sourceOverlaySchema; - if (schema === sourceOverlaySchema && Array.isArray(parsed.columns)) { - const sourceName = options?.sourceName ?? (typeof parsed.name === 'string' ? parsed.name : 'source'); - const path = - options?.connectionId && isSafeConnectionId(options.connectionId) - ? `semantic-layer/${options.connectionId}/${sourceName}.yaml` - : `${sourceName}.yaml`; - const legacyColumnPatchErrors = parsed.columns - .filter((column): column is Record => isRecord(column)) - .filter((column) => typeof column.name === 'string' && (!column.expr || !column.type)) - .map( - (column) => - `${path}: column '${column.name}' patches a manifest column but is in 'columns:' — move it to 'column_overrides:'`, - ); - if (legacyColumnPatchErrors.length > 0) { - return { valid: false, errors: legacyColumnPatchErrors }; - } - } const result = schema.parse(parsed); const errors: string[] = []; diff --git a/packages/cli/src/context/sl/semantic-layer.service.test.ts b/packages/cli/src/context/sl/semantic-layer.service.test.ts index 0844a3c5..cd14d66a 100644 --- a/packages/cli/src/context/sl/semantic-layer.service.test.ts +++ b/packages/cli/src/context/sl/semantic-layer.service.test.ts @@ -847,7 +847,7 @@ describe('loadAllSources — standalone enrichment via inherits_columns_from', ( }); }); - it('reports file-attributed errors for legacy overlay column patches', async () => { + it('reports file-attributed errors for overlay columns that shadow manifest columns', async () => { const schemaPath = 'semantic-layer/conn-1/_schema/marts.yaml'; const overlayPath = 'semantic-layer/conn-1/orders.yaml'; configService.listFiles.mockResolvedValue({ files: [schemaPath, overlayPath] }); @@ -871,7 +871,8 @@ describe('loadAllSources — standalone enrichment via inherits_columns_from', ( const { loadErrors } = await service.loadAllSources('conn-1'); expect(loadErrors.join('\n')).toContain(overlayPath); - expect(loadErrors.join('\n')).toContain("move it to 'column_overrides:'"); + expect(loadErrors.join('\n')).toContain("column 'id' in columns already exists on manifest source 'orders'"); + expect(loadErrors.join('\n')).not.toContain('column_overrides'); }); it('reports and logs directory listing failures instead of treating them as empty sources', async () => { diff --git a/packages/cli/src/context/sl/semantic-layer.service.ts b/packages/cli/src/context/sl/semantic-layer.service.ts index e6afdeaf..28bf826d 100644 --- a/packages/cli/src/context/sl/semantic-layer.service.ts +++ b/packages/cli/src/context/sl/semantic-layer.service.ts @@ -1082,17 +1082,14 @@ export class SemanticLayerService { static mapDialect(connectionType: string): string { const normalized = connectionType.toUpperCase(); const map: Record = { - POSTGRESQL: 'postgres', POSTGRES: 'postgres', BIGQUERY: 'bigquery', SNOWFLAKE: 'snowflake', MYSQL: 'mysql', SQLSERVER: 'tsql', - MSSQL: 'tsql', SQLITE: 'sqlite', DUCKDB: 'duckdb', CLICKHOUSE: 'clickhouse', - REDSHIFT: 'redshift', DATABRICKS: 'databricks', }; return map[normalized] ?? 'postgres'; @@ -1513,7 +1510,7 @@ export function composeOverlay(base: SemanticLayerSource, overlay: Record { expect(testIo.stdout()).toContain('ktx setup'); }); - it('warns about stale and unsupported per-driver connection fields', async () => { + it('does not warn about removed-field migration hints', async () => { process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret process.env.WAREHOUSE_DATABASE_URL = 'postgresql://reader@example.test/warehouse'; process.env.NOTION_TOKEN = 'notion-secret'; @@ -644,10 +644,9 @@ describe('runKtxDoctor', () => { ).resolves.toBe(0); const out = testIo.stdout(); - expect(out).toContain('Warnings'); - expect(out).toContain('connections.warehouse.readonly is no longer used.'); - expect(out).toContain('connections.local.file_path was removed.'); - expect(out).toContain('connections.docs.last_successful_cursor is local sync state.'); + expect(out).not.toContain('connections.warehouse.readonly is no longer used.'); + expect(out).not.toContain('connections.local.file_path was removed.'); + expect(out).not.toContain('connections.docs.last_successful_cursor is local sync state.'); delete process.env.ANTHROPIC_API_KEY; delete process.env.WAREHOUSE_DATABASE_URL; delete process.env.NOTION_TOKEN; diff --git a/packages/cli/src/ingest-depth.ts b/packages/cli/src/ingest-depth.ts index 489c44e8..b8957763 100644 --- a/packages/cli/src/ingest-depth.ts +++ b/packages/cli/src/ingest-depth.ts @@ -5,7 +5,6 @@ export type KtxDatabaseContextDepth = 'fast' | 'deep'; const KTX_DATABASE_DRIVER_IDS = new Set([ 'sqlite', 'postgres', - 'postgresql', 'mysql', 'clickhouse', 'sqlserver', diff --git a/packages/cli/src/local-scan-connectors.ts b/packages/cli/src/local-scan-connectors.ts index 4f763be5..31fc158e 100644 --- a/packages/cli/src/local-scan-connectors.ts +++ b/packages/cli/src/local-scan-connectors.ts @@ -17,14 +17,14 @@ export async function createKtxCliScanConnector( `Connection "${connectionId}" has no \`driver\` field in ktx.yaml. Supported drivers: ${SUPPORTED_DRIVERS}.`, ); } - if (driver === 'sqlite' || driver === 'sqlite3') { + if (driver === 'sqlite') { const { KtxSqliteScanConnector, isKtxSqliteConnectionConfig } = await import('./connectors/sqlite/connector.js');; if (!isKtxSqliteConnectionConfig(connection)) { throw invalidConnectionConfigError(connectionId, driver); } return new KtxSqliteScanConnector({ connectionId, connection, projectDir: project.projectDir }); } - if (driver === 'postgres' || driver === 'postgresql') { + if (driver === 'postgres') { const { KtxPostgresScanConnector, isKtxPostgresConnectionConfig } = await import('./connectors/postgres/connector.js');; if (!isKtxPostgresConnectionConfig(connection)) { throw invalidConnectionConfigError(connectionId, driver); diff --git a/packages/cli/src/public-ingest.test.ts b/packages/cli/src/public-ingest.test.ts index 7c400752..d6ced94d 100644 --- a/packages/cli/src/public-ingest.test.ts +++ b/packages/cli/src/public-ingest.test.ts @@ -175,6 +175,35 @@ describe('buildPublicIngestPlan', () => { ).toEqual(['--deep affects database ingest only; ignoring it for docs.']); }); + it('does not infer deep ingest from legacy scanMode values', () => { + const project = projectWithConnections({ + warehouse: { driver: 'postgres' }, + }); + + const plan = buildPublicIngestPlan(project, { + projectDir: '/tmp/project', + targetConnectionId: 'warehouse', + all: false, + scanMode: 'enriched', + }); + + expect(plan.targets[0]).toMatchObject({ + connectionId: 'warehouse', + databaseDepth: 'fast', + steps: ['database-schema'], + }); + }); + + it('rejects stale local Looker source driver aliases', () => { + const project = projectWithConnections({ + local_looker: { driver: 'local_looker' } as never, + }); + + expect(() => buildPublicIngestPlan(project, { projectDir: '/tmp/project', all: true })).toThrow( + 'unsupported public ingest driver "local_looker"', + ); + }); + it('upgrades effective depth when query history is explicitly enabled', () => { const project = projectWithConnections({ warehouse: { driver: 'postgres', context: { queryHistory: { enabled: false } } }, @@ -1045,7 +1074,7 @@ describe('runKtxPublicIngest', () => { expect(io.stdout()).toContain('warehouse requires deep ingest readiness'); }); - it('can request enriched relationship scans for setup-managed context builds', async () => { + it('does not infer enriched relationship scans from legacy scanMode values', async () => { const io = makeIo(); const project = deepReadyProject({ warehouse: { driver: 'postgres' } }); const runScan = vi.fn(async () => 0); @@ -1074,8 +1103,8 @@ describe('runKtxPublicIngest', () => { command: 'run', projectDir: '/tmp/project', connectionId: 'warehouse', - mode: 'enriched', - detectRelationships: true, + mode: 'structural', + detectRelationships: false, dryRun: false, }, expect.objectContaining({ capturedOutput: expect.any(Function) }), diff --git a/packages/cli/src/public-ingest.ts b/packages/cli/src/public-ingest.ts index ce6c6344..60bceecd 100644 --- a/packages/cli/src/public-ingest.ts +++ b/packages/cli/src/public-ingest.ts @@ -134,7 +134,6 @@ const sourceAdapterByDriver = new Map([ ['metabase', 'metabase'], ['local_metabase', 'metabase'], ['looker', 'looker'], - ['local_looker', 'looker'], ['notion', 'notion'], ['metricflow', 'metricflow'], ['dbt', 'dbt'], @@ -143,7 +142,6 @@ const sourceAdapterByDriver = new Map([ const queryHistoryDialectByDriver = new Map([ ['postgres', 'postgres'], - ['postgresql', 'postgres'], ['bigquery', 'bigquery'], ['snowflake', 'snowflake'], ]); @@ -309,12 +307,6 @@ function queryHistoryPullConfig(input: { }; } -function depthFromLegacyScanMode( - mode: Extract['mode'] | undefined, -): KtxPublicIngestDepth | undefined { - return mode === 'enriched' || mode === 'relationships' ? 'deep' : undefined; -} - function sourceDirForConnection(connection: KtxProjectConnectionConfig): string | undefined { const value = connection.source_dir; return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; @@ -340,8 +332,7 @@ function resolveDatabaseTargetOptions(input: { const requestedQh = explicitQueryHistory === 'enabled' || (explicitQueryHistory !== 'disabled' && (windowOverrideRequested || storedEnabled)); - let depth = - input.args.depth ?? depthFromLegacyScanMode(input.args.scanMode) ?? databaseContextDepth(input.connection) ?? 'fast'; + let depth = input.args.depth ?? databaseContextDepth(input.connection) ?? 'fast'; const queryHistory = { enabled: false, ...(input.args.queryHistoryWindowDays !== undefined diff --git a/packages/cli/src/runtime-requirements.test.ts b/packages/cli/src/runtime-requirements.test.ts index 5f8831cf..35e94eae 100644 --- a/packages/cli/src/runtime-requirements.test.ts +++ b/packages/cli/src/runtime-requirements.test.ts @@ -26,6 +26,33 @@ describe('runtime requirement detection', () => { ); }); + it('does not treat stale local Looker driver aliases as Looker sources', () => { + const config: KtxProjectConfig = { + ...buildDefaultKtxProjectConfig(), + connections: { + stale: { driver: 'local_looker' } as never, + }, + }; + + expect(resolveProjectRuntimeRequirements(config).features).toEqual([]); + expect( + resolvePublicIngestRuntimeRequirements({ + projectDir: '/tmp/project', + warnings: [], + targets: [ + { + connectionId: 'stale', + driver: 'local_looker', + operation: 'source-ingest', + adapter: 'local_looker', + debugCommand: 'ktx ingest stale --debug', + steps: ['source-ingest'], + }, + ], + }).features, + ).toEqual([]); + }); + it('requires core for query-history ingest unless SQL analysis is externally configured', () => { const config: KtxProjectConfig = { ...buildDefaultKtxProjectConfig(), diff --git a/packages/cli/src/runtime-requirements.ts b/packages/cli/src/runtime-requirements.ts index 31ad1be0..0253db8c 100644 --- a/packages/cli/src/runtime-requirements.ts +++ b/packages/cli/src/runtime-requirements.ts @@ -96,7 +96,7 @@ export function resolveProjectRuntimeRequirements( for (const [connectionId, connection] of Object.entries(config.connections)) { const driver = normalizeDriver(connection.driver); - if ((driver === 'looker' || driver === 'local_looker') && !hasDaemonOverride(env)) { + if (driver === 'looker' && !hasDaemonOverride(env)) { requirements.push({ feature: 'core', reason: 'looker-source', @@ -141,7 +141,7 @@ export function resolvePublicIngestRuntimeRequirements( detail: `${target.connectionId} query-history ingest uses SQL analysis.`, }); } - if ((driver === 'looker' || driver === 'local_looker' || adapter === 'looker') && !hasDaemonOverride(env)) { + if ((driver === 'looker' || adapter === 'looker') && !hasDaemonOverride(env)) { requirements.push({ feature: 'core', reason: 'looker-source', diff --git a/packages/cli/src/scan.test.ts b/packages/cli/src/scan.test.ts index 5ec745e6..6db8243a 100644 --- a/packages/cli/src/scan.test.ts +++ b/packages/cli/src/scan.test.ts @@ -123,7 +123,7 @@ const createPostgresLiveDatabaseIntrospection = vi.hoisted(() => ); const isKtxPostgresConnectionConfig = vi.hoisted(() => vi.fn((connection: { driver?: string } | undefined) => - ['postgres', 'postgresql'].includes(String(connection?.driver ?? '').toLowerCase()), + String(connection?.driver ?? '').toLowerCase() === 'postgres', ), ); const KtxPostgresScanConnector = vi.hoisted( diff --git a/packages/cli/src/setup-agents.test.ts b/packages/cli/src/setup-agents.test.ts index a5f488d5..a8090780 100644 --- a/packages/cli/src/setup-agents.test.ts +++ b/packages/cli/src/setup-agents.test.ts @@ -82,7 +82,6 @@ describe('setup agents', () => { { kind: 'file', path: join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' }, ]); expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp' })).toEqual([ - { kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' }, { kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'), @@ -127,7 +126,6 @@ describe('setup agents', () => { { kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md') }, ]); expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' })).toEqual([ - { kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' }, { kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'), @@ -518,19 +516,15 @@ describe('setup agents', () => { const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'); await expect(stat(analyticsSkillPath)).resolves.toBeDefined(); await expect(stat(adminSkillPath)).rejects.toThrow(); - const launcherStat = await stat(launcherPath); - expect(launcherStat.mode & 0o111).not.toBe(0); - const launcher = await readFile(launcherPath, 'utf-8'); - expect(launcher).toContain('KTX_CLI_BIN='); - expect(launcher).toContain('.nvm/versions/node'); + await expect(stat(launcherPath)).rejects.toThrow(); const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json'); const config = JSON.parse(await readFile(configPath, 'utf-8')) as { mcpServers: { ktx: { command: string; args: string[]; env?: Record } }; }; expect(config.mcpServers.ktx).toEqual({ - command: launcherPath, - args: ['--project-dir', tempDir, 'mcp', 'stdio'], + command: process.execPath, + args: [expect.stringContaining('bin.js'), '--project-dir', tempDir, 'mcp', 'stdio'], }); expect(await readZipText(analyticsSkillPath, 'ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow'); @@ -901,7 +895,7 @@ describe('setup agents', () => { const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json'); await expect(stat(analyticsSkillPath)).resolves.toBeDefined(); await expect(stat(adminSkillPath)).resolves.toBeDefined(); - await expect(stat(launcherPath)).resolves.toBeDefined(); + await expect(stat(launcherPath)).rejects.toThrow(); const beforeConfig = JSON.parse(await readFile(configPath, 'utf-8')) as { mcpServers: Record; }; @@ -911,7 +905,6 @@ describe('setup agents', () => { await expect(stat(analyticsSkillPath)).rejects.toThrow(); await expect(stat(adminSkillPath)).rejects.toThrow(); - await expect(stat(launcherPath)).rejects.toThrow(); const afterConfig = JSON.parse(await readFile(configPath, 'utf-8')) as { mcpServers: Record; }; diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index 240622f6..113718cd 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, join, relative, resolve } from 'node:path'; import type { Writable } from 'node:stream'; import { fileURLToPath } from 'node:url'; @@ -55,7 +55,7 @@ export interface KtxAgentInstallManifest { | { kind: 'file'; path: string; - role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-desktop-skill-bundle' | 'launcher'; + role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-desktop-skill-bundle'; } | { kind: 'json-key'; path: string; jsonPath: string[] } >; @@ -312,15 +312,12 @@ function collectClaudeDesktopForwardedEnv(source: NodeJS.ProcessEnv): Record { +function claudeDesktopMcpEntry(input: { projectDir: string; env?: NodeJS.ProcessEnv }): Record { const captured = collectClaudeDesktopForwardedEnv(input.env ?? process.env); + const launcher = ktxCliLauncher(); return { - command: input.launcherPath, - args: ['--project-dir', input.projectDir, 'mcp', 'stdio'], + command: launcher.command, + args: [...launcher.args, '--project-dir', input.projectDir, 'mcp', 'stdio'], ...(Object.keys(captured).length > 0 ? { env: captured } : {}), }; } @@ -336,11 +333,10 @@ async function installMcpClientConfig(input: { if (input.target === 'claude-desktop') { const config = claudeDesktopConfigPath(); - const launcherPath = claudeDesktopLauncherPath(input.projectDir); await writeJsonKey( config.path, config.jsonPath, - claudeDesktopMcpEntry({ launcherPath, projectDir: input.projectDir }), + claudeDesktopMcpEntry({ projectDir: input.projectDir }), ); entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }); return { entries, snippets, notices }; @@ -406,10 +402,6 @@ function claudeDesktopAdminSkillBundlePath(projectDir: string): string { return join(resolve(projectDir), '.ktx/agents/claude/ktx.zip'); } -function claudeDesktopLauncherPath(projectDir: string): string { - return join(resolve(projectDir), '.ktx/agents/claude/ktx-plugin-runner.sh'); -} - /** @internal */ export function plannedKtxAgentFiles(input: { projectDir: string; @@ -449,7 +441,6 @@ export function plannedKtxAgentFiles(input: { } if (input.target === 'claude-desktop') { return [ - { kind: 'file', path: claudeDesktopLauncherPath(input.projectDir), role: 'launcher' as const }, { kind: 'file', path: claudeDesktopAnalyticsSkillBundlePath(input.projectDir), @@ -593,61 +584,6 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun ].join('\n'); } -function claudeDesktopLauncherContent(input: { launcher: KtxCliLauncher }): string { - const binPath = input.launcher.args[0]; - if (!binPath) { - throw new Error('Expected KTX CLI launcher to include a bin path.'); - } - const candidates = [ - input.launcher.command, - '/opt/homebrew/bin/node', - '/usr/local/bin/node', - '/usr/bin/node', - ]; - return [ - '#!/bin/sh', - 'set -eu', - '', - `KTX_CLI_BIN=${shellScriptQuote(binPath)}`, - '', - 'run_with_node() {', - ' node_bin=$1', - ' shift', - ' exec "$node_bin" "$KTX_CLI_BIN" "$@"', - '}', - '', - 'if [ -n "${KTX_NODE:-}" ] && [ -x "${KTX_NODE:-}" ]; then', - ' run_with_node "$KTX_NODE" "$@"', - 'fi', - '', - 'if [ -x "$HOME/.volta/bin/node" ]; then', - ' run_with_node "$HOME/.volta/bin/node" "$@"', - 'fi', - '', - ...candidates.map((candidate) => - [ - `if [ -x ${shellScriptQuote(candidate)} ]; then`, - ` run_with_node ${shellScriptQuote(candidate)} "$@"`, - 'fi', - ].join('\n'), - ), - '', - 'for candidate in "$HOME"/.nvm/versions/node/*/bin/node; do', - ' if [ -x "$candidate" ]; then', - ' run_with_node "$candidate" "$@"', - ' fi', - 'done', - '', - 'if command -v node >/dev/null 2>&1; then', - ' run_with_node "$(command -v node)" "$@"', - 'fi', - '', - 'echo "KTX Claude Desktop launcher could not find Node.js. Set KTX_NODE to a Node executable and rerun ktx setup --agents." >&2', - 'exit 127', - '', - ].join('\n'); -} - async function writeClaudeDesktopSkillBundle(input: { projectDir: string; path: string; @@ -675,15 +611,6 @@ function claudeDesktopSkillNameForBundle(path: string): 'ktx-analytics' | 'ktx' throw new Error(`Unsupported Claude Desktop skill bundle path: ${path}`); } -async function writeClaudeDesktopLauncher(input: { - path: string; - launcher: KtxCliLauncher; -}): Promise { - await mkdir(dirname(input.path), { recursive: true }); - await writeFile(input.path, claudeDesktopLauncherContent({ launcher: input.launcher }), 'utf-8'); - await chmod(input.path, 0o755); -} - function ruleInstructionContent(input: { projectDir: string }): string { return [ `Use the \`ktx\` CLI to query local semantic context and wiki knowledge for this project ` + @@ -941,10 +868,6 @@ export function formatInstallSummaryLines( lines.push(`${guidanceInstallLine(install.target)}.`); } - if (hasEntryRole(targetEntries, 'launcher')) { - lines.push('Starts KTX over stdio from Claude Desktop.'); - } - return { title: `${targetDisplayName(install.target)} · ${scopeDisplayName(install.scope)}`, lines, @@ -1139,10 +1062,6 @@ async function installTarget(input: { const launcher = ktxCliLauncher(); for (const entry of entries) { if (entry.kind !== 'file') continue; - if (entry.role === 'launcher') { - await writeClaudeDesktopLauncher({ path: entry.path, launcher }); - continue; - } if (entry.role === 'claude-desktop-skill-bundle') { await writeClaudeDesktopSkillBundle({ projectDir: input.projectDir, diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index 392c4761..058692ae 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -260,8 +260,6 @@ function createPromptAdapter(): KtxSetupDatabasesPromptAdapter { function normalizeDriver(driver: string | undefined): KtxSetupDatabaseDriver | null { const normalized = String(driver ?? '').toLowerCase(); - if (normalized === 'postgresql') return 'postgres'; - if (normalized === 'sqlite3') return 'sqlite'; return DRIVER_OPTIONS.some((option) => option.value === normalized) ? (normalized as KtxSetupDatabaseDriver) : null; } diff --git a/packages/cli/src/sql.ts b/packages/cli/src/sql.ts index 1b15f92e..bfae0608 100644 --- a/packages/cli/src/sql.ts +++ b/packages/cli/src/sql.ts @@ -43,16 +43,12 @@ function sqlAnalysisDialectForDriver(driver: string | undefined): SqlAnalysisDia const normalized = String(driver ?? '').trim().toLowerCase(); const map: Record = { postgres: 'postgres', - postgresql: 'postgres', bigquery: 'bigquery', snowflake: 'snowflake', mysql: 'mysql', sqlserver: 'tsql', - mssql: 'tsql', sqlite: 'sqlite', - sqlite3: 'sqlite', clickhouse: 'clickhouse', - redshift: 'redshift', }; return map[normalized] ?? 'postgres'; } diff --git a/packages/cli/src/status-project.ts b/packages/cli/src/status-project.ts index aaecff27..07ccc3c6 100644 --- a/packages/cli/src/status-project.ts +++ b/packages/cli/src/status-project.ts @@ -92,10 +92,6 @@ type ClaudeCodeAuthProbe = (input: { const PROJECT_READY_COMMANDS = KTX_NEXT_STEP_DIRECT_COMMANDS.map((step) => step.command); -function hasOwnField(value: Record, key: string): boolean { - return Object.prototype.hasOwnProperty.call(value, key); -} - interface LocalStatsIngestPerConnection { connectionId: string; adapter: string; @@ -332,7 +328,6 @@ function buildConnectionStatus( switch (driver) { case 'postgres': - case 'postgresql': case 'mysql': case 'clickhouse': case 'sqlserver': { @@ -701,7 +696,7 @@ async function buildQueryHistoryStatus( } const ADAPTER_DRIVER_REQUIREMENT: Record = { - 'live-database': ['postgres', 'postgresql', 'mysql', 'snowflake', 'bigquery', 'clickhouse', 'sqlite', 'sqlserver'], + 'live-database': ['postgres', 'mysql', 'snowflake', 'bigquery', 'clickhouse', 'sqlite', 'sqlserver'], dbt: ['dbt', 'dbt-core', 'dbt-cloud'], notion: ['notion'], metabase: ['metabase'], @@ -740,30 +735,6 @@ function buildWarnings( ): WarningItem[] { const warnings: WarningItem[] = []; - for (const [connectionId, connection] of Object.entries(config.connections)) { - const driver = String(connection.driver ?? '').toLowerCase(); - if (hasOwnField(connection, 'readonly')) { - warnings.push({ - message: `connections.${connectionId}.readonly is no longer used.`, - fix: `Remove connections.${connectionId}.readonly from ktx.yaml.`, - }); - } - - if ((driver === 'sqlite' || driver === 'sqlite3') && hasOwnField(connection, 'file_path')) { - warnings.push({ - message: `connections.${connectionId}.file_path was removed.`, - fix: `Rename connections.${connectionId}.file_path to path.`, - }); - } - - if (driver === 'notion' && hasOwnField(connection, 'last_successful_cursor')) { - warnings.push({ - message: `connections.${connectionId}.last_successful_cursor is local sync state.`, - fix: 'Remove it from ktx.yaml. KTX stores the Notion cursor in .ktx/db.sqlite.', - }); - } - } - for (const adapter of config.ingest.adapters) { const requiredDrivers = ADAPTER_DRIVER_REQUIREMENT[adapter]; if (!requiredDrivers) continue; diff --git a/python/ktx-daemon/src/ktx_daemon/database_introspection.py b/python/ktx-daemon/src/ktx_daemon/database_introspection.py index 82058f95..6ba84265 100644 --- a/python/ktx-daemon/src/ktx_daemon/database_introspection.py +++ b/python/ktx-daemon/src/ktx_daemon/database_introspection.py @@ -327,7 +327,7 @@ def introspect_database_response( now: NowProvider | None = None, ) -> DatabaseIntrospectionResponse: driver = _driver_name(request.driver) - if driver not in {"postgres", "postgresql"}: + if driver != "postgres": raise ValueError('database introspection supports only driver "postgres"') rows = (load_rows or _load_postgres_rows)(request) diff --git a/python/ktx-daemon/src/ktx_daemon/semantic_layer.py b/python/ktx-daemon/src/ktx_daemon/semantic_layer.py index e813575e..78f57338 100644 --- a/python/ktx-daemon/src/ktx_daemon/semantic_layer.py +++ b/python/ktx-daemon/src/ktx_daemon/semantic_layer.py @@ -13,7 +13,7 @@ from semantic_layer.models import QueryResult, SourceDefinition class SemanticLayerQueryRequest(BaseModel): - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(extra="forbid") sources: list[dict[str, Any]] query: dict[str, Any] diff --git a/python/ktx-daemon/src/ktx_daemon/sql_analysis.py b/python/ktx-daemon/src/ktx_daemon/sql_analysis.py index ebecf83c..e831e47f 100644 --- a/python/ktx-daemon/src/ktx_daemon/sql_analysis.py +++ b/python/ktx-daemon/src/ktx_daemon/sql_analysis.py @@ -5,7 +5,7 @@ from concurrent.futures import ProcessPoolExecutor from typing import Literal import sqlglot -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field from sqlglot import exp SqlAnalysisClause = Literal["select", "where", "join", "groupBy", "having", "orderBy"] @@ -23,8 +23,6 @@ class AnalyzeSqlBatchRequest(BaseModel): class AnalyzeSqlBatchResult(BaseModel): - model_config = ConfigDict(populate_by_name=True) - tables_touched: list[str] = Field(default_factory=list) columns_by_clause: dict[SqlAnalysisClause, list[str]] = Field(default_factory=dict) error: str | None = None diff --git a/python/ktx-daemon/src/ktx_daemon/table_identifier.py b/python/ktx-daemon/src/ktx_daemon/table_identifier.py index 748f2dd8..297c25b4 100644 --- a/python/ktx-daemon/src/ktx_daemon/table_identifier.py +++ b/python/ktx-daemon/src/ktx_daemon/table_identifier.py @@ -1,9 +1,8 @@ from __future__ import annotations -from dataclasses import asdict from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field from semantic_layer.table_identifier_parser import ( ParseTableIdentifierItem as SharedParseTableIdentifierItem, parse_table_identifier_batch, @@ -30,8 +29,6 @@ class ParseTableIdentifierBatchRequest(BaseModel): class ParsedIdentifier(BaseModel): - model_config = ConfigDict(populate_by_name=True) - ok: bool catalog: str | None = None schema_: str | None = Field(default=None, alias="schema") @@ -60,7 +57,15 @@ def parse_table_identifier_response( ) return ParseTableIdentifierBatchResponse( results={ - key: ParsedIdentifier.model_validate(asdict(value)) + key: ParsedIdentifier( + ok=value.ok, + catalog=value.catalog, + schema=value.schema_, + name=value.name, + canonical_table=value.canonical_table, + reason=value.reason, + detail=value.detail, + ) for key, value in shared_results.items() } ) diff --git a/python/ktx-daemon/tests/test_database_introspection.py b/python/ktx-daemon/tests/test_database_introspection.py index 0a018046..b0fb7a5b 100644 --- a/python/ktx-daemon/tests/test_database_introspection.py +++ b/python/ktx-daemon/tests/test_database_introspection.py @@ -138,6 +138,18 @@ def test_introspect_database_response_rejects_non_postgres_driver() -> None: ) +def test_introspect_database_response_rejects_legacy_postgresql_driver() -> None: + with pytest.raises(ValueError, match='supports only driver "postgres"'): + introspect_database_response( + DatabaseIntrospectionRequest( + connection_id="warehouse", + driver="postgresql", + url="postgresql://readonly@example.test/warehouse", + ), + load_rows=lambda request: DatabaseIntrospectionRows([], [], []), + ) + + def test_database_introspection_request_rejects_empty_schema_list() -> None: with pytest.raises(ValueError, match="at least one schema"): DatabaseIntrospectionRequest( diff --git a/python/ktx-daemon/tests/test_semantic_layer.py b/python/ktx-daemon/tests/test_semantic_layer.py index 8ebb7ad8..828e9359 100644 --- a/python/ktx-daemon/tests/test_semantic_layer.py +++ b/python/ktx-daemon/tests/test_semantic_layer.py @@ -3,6 +3,8 @@ from __future__ import annotations import json from pathlib import Path +import pytest + from ktx_daemon.semantic_layer import ( SemanticLayerQueryRequest, ValidateSourcesRequest, @@ -95,6 +97,16 @@ def test_query_semantic_layer_emits_plan_and_sql_debug_events( assert "public.orders" not in captured.err +def test_semantic_layer_request_rejects_project_id_field_name() -> None: + with pytest.raises(ValueError): + SemanticLayerQueryRequest( + sources=[], + dialect="postgres", + project_id="a" * 64, + query={"measures": ["orders.order_count"]}, + ) + + def test_validate_semantic_layer_reports_duplicate_measure_names() -> None: invalid_source = { **ORDERS_SOURCE, diff --git a/python/ktx-sl/semantic_layer/loader.py b/python/ktx-sl/semantic_layer/loader.py index 55a3a0ee..1f505fe5 100644 --- a/python/ktx-sl/semantic_layer/loader.py +++ b/python/ktx-sl/semantic_layer/loader.py @@ -201,7 +201,7 @@ class SourceLoader: name = col.get("name") if name in base_by_name: raise ValueError( - f"column '{name}' in columns patches a manifest column on '{base.name}' — move it to 'column_overrides:'" + f"column '{name}' in columns patches a manifest column on '{base.name}' - move it to 'column_overrides:'" ) source.columns.append(SourceColumn(**col))