mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
refactor: remove legacy ktx compatibility shims (#211)
* refactor: remove legacy ktx compatibility shims * fix: restore overlay collision guidance
This commit is contained in:
parent
a954a29a76
commit
96952fb43c
59 changed files with 294 additions and 342 deletions
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<SupportedDriver, KtxDialect> = {
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@ export function createDefaultLocalQueryExecutor(options: DefaultLocalQueryExecut
|
|||
return {
|
||||
async execute(input: KtxSqlQueryExecutionInput): Promise<KtxSqlQueryExecutionResult> {
|
||||
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'}".`);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -20,10 +20,8 @@ export interface LocalConnectionInfo {
|
|||
|
||||
const DRIVER_TO_CONNECTION_TYPE: Record<string, ConnectionType> = {
|
||||
postgres: 'POSTGRESQL',
|
||||
postgresql: 'POSTGRESQL',
|
||||
sqlite: 'SQLITE',
|
||||
sqlserver: 'SQLSERVER',
|
||||
mssql: 'SQLSERVER',
|
||||
mysql: 'MYSQL',
|
||||
clickhouse: 'CLICKHOUSE',
|
||||
snowflake: 'SNOWFLAKE',
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export function createPostgresQueryExecutor(options: PostgresQueryExecutorOption
|
|||
async execute(input: KtxSqlQueryExecutionInput): Promise<KtxSqlQueryExecutionResult> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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'}".`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ describe('createDaemonLiveDatabaseIntrospection', () => {
|
|||
const introspection = createDaemonLiveDatabaseIntrospection({
|
||||
connections: {
|
||||
warehouse: {
|
||||
driver: 'postgresql',
|
||||
driver: 'postgres',
|
||||
url: 'postgres://localhost:5432/warehouse',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -168,7 +168,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
|
||||
const historicSqlDialectByDriver = new Map<string, 'postgres' | 'bigquery' | 'snowflake'>([
|
||||
['postgres', 'postgres'],
|
||||
['postgresql', 'postgres'],
|
||||
['bigquery', 'bigquery'],
|
||||
['snowflake', 'snowflake'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) };
|
||||
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<string, unknown>) };
|
||||
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<string, unknown>) };
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -24,17 +24,14 @@ interface CreateLocalProjectMcpContextPortsOptions {
|
|||
function dialectForDriver(driver: string | undefined): string {
|
||||
const normalized = (driver ?? 'postgres').toUpperCase();
|
||||
const map: Record<string, string> = {
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -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<KtxSemanticLayerMcpPort['query']>().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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
|
||||
const warehouseDrivers = [
|
||||
'postgres',
|
||||
'postgresql',
|
||||
'mysql',
|
||||
'snowflake',
|
||||
'bigquery',
|
||||
|
|
@ -41,7 +40,6 @@ function warehouseConnectionSchema<const Driver extends WarehouseDriver>(driver:
|
|||
|
||||
const warehouseConnectionSchemas = [
|
||||
warehouseConnectionSchema('postgres'),
|
||||
warehouseConnectionSchema('postgresql'),
|
||||
warehouseConnectionSchema('mysql'),
|
||||
warehouseConnectionSchema('snowflake'),
|
||||
warehouseConnectionSchema('bigquery'),
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'}"`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<KtxTableRefKey> = tableRefSet([
|
||||
{ catalog: null, db: 'public', name: 'users' },
|
||||
{ catalog: 'A', db: 'public', name: 'users' },
|
||||
|
|
|
|||
|
|
@ -33,8 +33,7 @@ export function tableRefSet(refs: readonly KtxTableRef[]): ReadonlySet<KtxTableR
|
|||
|
||||
/**
|
||||
* Return the bare table names from a scope that fall within the given
|
||||
* (catalog, db) namespace. `catalog: null` is treated as a wildcard so that
|
||||
* legacy 2-part `"db.name"` entries continue to match. Same for `db: null`.
|
||||
* (catalog, db) namespace.
|
||||
*/
|
||||
export function scopedTableNames(
|
||||
scope: ReadonlySet<KtxTableRefKey>,
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { KtxTableRefKey } from './table-ref.js';
|
|||
export type KtxConnectionDriver =
|
||||
| 'sqlite'
|
||||
| 'postgres'
|
||||
| 'postgresql'
|
||||
| 'sqlserver'
|
||||
| 'bigquery'
|
||||
| 'snowflake'
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -48,17 +48,14 @@ function assertSafeConnectionId(connectionId: string): string {
|
|||
function dialectForDriver(driver: string | undefined): string {
|
||||
const normalized = (driver ?? 'postgres').toUpperCase();
|
||||
const map: Record<string, string> = {
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -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')]),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> => 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[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -1082,17 +1082,14 @@ export class SemanticLayerService {
|
|||
static mapDialect(connectionType: string): string {
|
||||
const normalized = connectionType.toUpperCase();
|
||||
const map: Record<string, string> = {
|
||||
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<string
|
|||
for (const column of computedColumns) {
|
||||
if (baseByLowerName.has(column.name.toLowerCase())) {
|
||||
throw new ColumnNameCollisionError(
|
||||
`column '${column.name}' in columns patches a manifest column on '${base.name}' — move it to 'column_overrides:'`,
|
||||
`column '${column.name}' in columns already exists on manifest source '${base.name}'`,
|
||||
);
|
||||
}
|
||||
columnsByLowerName.set(column.name.toLowerCase(), column);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue