diff --git a/packages/context/src/connections/dialects.test.ts b/packages/context/src/connections/dialects.test.ts new file mode 100644 index 00000000..6c9b6c41 --- /dev/null +++ b/packages/context/src/connections/dialects.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { getDialectForDriver } from './dialects.js'; + +describe('getDialectForDriver', () => { + it.each([ + ['postgres', '"public"."orders"'], + ['postgresql', '"public"."orders"'], + ['mysql', '`public`.`orders`'], + ['clickhouse', '`public`.`orders`'], + ['sqlite', '"orders"'], + ['snowflake', '"analytics"."public"."orders"'], + ['bigquery', '`analytics`.`public`.`orders`'], + ['sqlserver', '[analytics].[public].[orders]'], + ] as const)('formats table names for %s', (driver, expected) => { + const dialect = getDialectForDriver(driver); + expect( + dialect.formatTableName({ + catalog: driver === 'snowflake' || driver === 'bigquery' || driver === 'sqlserver' ? 'analytics' : null, + db: driver === 'sqlite' ? null : 'public', + name: 'orders', + }), + ).toBe(expected); + }); + + 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', + ); + }); +}); diff --git a/packages/context/src/connections/dialects.ts b/packages/context/src/connections/dialects.ts new file mode 100644 index 00000000..afac4bd2 --- /dev/null +++ b/packages/context/src/connections/dialects.ts @@ -0,0 +1,102 @@ +import type { KtxSchemaDimensionType, KtxTableRef } from '../scan/types.js'; + +export type SupportedDriver = + | 'postgres' + | 'postgresql' + | 'mysql' + | 'sqlserver' + | 'snowflake' + | 'bigquery' + | 'clickhouse' + | 'sqlite' + | 'sqlite3'; + +export interface KtxDialect { + readonly type: SupportedDriver; + quoteIdentifier(identifier: string): string; + formatTableName(table: KtxTableRef): string; + mapToDimensionType(nativeType: string): KtxSchemaDimensionType; +} + +const supportedDrivers: SupportedDriver[] = [ + 'bigquery', + 'clickhouse', + 'mysql', + 'postgres', + 'postgresql', + 'sqlite', + 'sqlite3', + 'snowflake', + 'sqlserver', +]; + +function doubleQuoted(identifier: string): string { + return `"${identifier.replace(/"/g, '""')}"`; +} + +function backtickQuoted(identifier: string): string { + return `\`${identifier.replace(/`/g, '``')}\``; +} + +function bigQueryQuoted(identifier: string): string { + return `\`${identifier.replace(/`/g, '\\`')}\``; +} + +function bracketQuoted(identifier: string): string { + return `[${identifier.replace(/\]/g, ']]')}]`; +} + +function inferDimensionType(nativeType: string): KtxSchemaDimensionType { + const normalized = nativeType.toLowerCase().trim(); + if (normalized.includes('date') || normalized.includes('time')) { + return 'time'; + } + if ( + normalized.includes('int') || + normalized.includes('num') || + normalized.includes('dec') || + normalized.includes('float') || + normalized.includes('double') || + normalized.includes('real') + ) { + return 'number'; + } + if (normalized.includes('bool') || normalized === 'bit') { + return 'boolean'; + } + return 'string'; +} + +function formatWithParts(table: KtxTableRef, quote: (identifier: string) => string, sqlite = false): string { + const parts = sqlite ? [table.name] : [table.catalog, table.db, table.name].filter((part): part is string => !!part); + return parts.map(quote).join('.'); +} + +function createDialect(type: SupportedDriver, quote: (identifier: string) => string, sqlite = false): KtxDialect { + return { + type, + quoteIdentifier: quote, + formatTableName: (table) => formatWithParts(table, quote, sqlite), + mapToDimensionType: inferDimensionType, + }; +} + +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), +}; + +export function getDialectForDriver(driver: string): KtxDialect { + const normalized = driver.toLowerCase().trim(); + if (normalized in dialects) { + return dialects[normalized as SupportedDriver]; + } + throw new Error(`Unsupported warehouse driver "${driver}". Supported drivers: ${supportedDrivers.join(', ')}`); +} diff --git a/packages/context/src/connections/index.ts b/packages/context/src/connections/index.ts index 513818fa..0917a7ca 100644 --- a/packages/context/src/connections/index.ts +++ b/packages/context/src/connections/index.ts @@ -3,7 +3,9 @@ export type { KtxSqlQueryExecutionResult, KtxSqlQueryExecutorPort, } from './query-executor.js'; +export type { KtxDialect, SupportedDriver } from './dialects.js'; export { createDefaultLocalQueryExecutor, type DefaultLocalQueryExecutorOptions } from './local-query-executor.js'; +export { getDialectForDriver } from './dialects.js'; export { normalizeQueryRows } from './query-executor.js'; export { createPostgresQueryExecutor } from './postgres-query-executor.js'; export { assertReadOnlySql, limitSqlForExecution } from './read-only-sql.js';