diff --git a/packages/cli/src/connectors/mysql/connector.test.ts b/packages/cli/src/connectors/mysql/connector.test.ts index fbf898bc..6c69ea3d 100644 --- a/packages/cli/src/connectors/mysql/connector.test.ts +++ b/packages/cli/src/connectors/mysql/connector.test.ts @@ -86,7 +86,9 @@ function fakePoolFactory(): KtxMysqlPoolFactory { }; } -function multiSchemaMysqlPoolFactory(): KtxMysqlPoolFactory { +function multiSchemaMysqlPoolFactory( + options: { primaryKeyError?: Error; foreignKeyError?: Error } = {}, +): KtxMysqlPoolFactory { const query = vi.fn(async (sql: string, params?: unknown): Promise<[RowDataPacket[], FieldPacket[]]> => { if (sql.includes('INFORMATION_SCHEMA.TABLES')) { expect(params).toEqual(['analytics', 'mart']); @@ -141,6 +143,9 @@ function multiSchemaMysqlPoolFactory(): KtxMysqlPoolFactory { ); } if (sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE') && sql.includes("CONSTRAINT_NAME = 'PRIMARY'")) { + if (options.primaryKeyError) { + throw options.primaryKeyError; + } expect(params).toEqual(['analytics', 'mart']); return mysqlResult( [ @@ -151,6 +156,9 @@ function multiSchemaMysqlPoolFactory(): KtxMysqlPoolFactory { ); } if (sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE') && sql.includes('REFERENCED_TABLE_NAME IS NOT NULL')) { + if (options.foreignKeyError) { + throw options.foreignKeyError; + } expect(params).toEqual(['analytics', 'mart']); return mysqlResult([], []); } @@ -316,6 +324,65 @@ describe('KtxMysqlScanConnector', () => { ]); }); + it('soft-fails denied MySQL constraint discovery with one warning per schema and kind', async () => { + const connector = new KtxMysqlScanConnector({ + connectionId: 'warehouse', + connection: { + driver: 'mysql', + host: 'db.example.test', + database: 'analytics', + schemas: ['analytics', 'mart'], + username: 'reader', + password: 'secret', // pragma: allowlist secret + }, + poolFactory: multiSchemaMysqlPoolFactory({ + primaryKeyError: Object.assign(new Error('select command denied'), { + code: 'ER_TABLEACCESS_DENIED_ERROR', + errno: 1142, + }), + foreignKeyError: Object.assign(new Error('database access denied'), { + code: 'ER_DBACCESS_DENIED_ERROR', + errno: 1044, + }), + }), + now: () => new Date('2026-04-29T12:00:00.000Z'), + }); + + const snapshot = await connector.introspect( + { connectionId: 'warehouse', driver: 'mysql' }, + { runId: 'scan-run-mysql-denied-constraints' }, + ); + + expect(snapshot.warnings).toEqual([ + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped primary-key discovery in analytics (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'analytics', kind: 'primary_key' }, + }, + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped primary-key discovery in mart (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'mart', kind: 'primary_key' }, + }, + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped foreign-key discovery in analytics (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'analytics', kind: 'foreign_key' }, + }, + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped foreign-key discovery in mart (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'mart', kind: 'foreign_key' }, + }, + ]); + expect(snapshot.tables.every((table) => table.columns.every((column) => column.primaryKey === false))).toBe(true); + expect(snapshot.tables.every((table) => table.foreignKeys.length === 0)).toBe(true); + }); + it('limits introspection to tables in tableScope', async () => { const queries: Array<{ sql: string; params?: unknown }> = []; const poolFactory: KtxMysqlPoolFactory = { diff --git a/packages/cli/src/connectors/mysql/connector.ts b/packages/cli/src/connectors/mysql/connector.ts index 7e299dab..83c9712a 100644 --- a/packages/cli/src/connectors/mysql/connector.ts +++ b/packages/cli/src/connectors/mysql/connector.ts @@ -3,8 +3,33 @@ import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js'; -import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxTableListEntry, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js'; +import { + constraintDiscoveryWarning, + tryConstraintQuery, + type ConstraintDiscoveryKind, +} from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; +import { + createKtxConnectorCapabilities, + type KtxColumnSampleInput, + type KtxColumnSampleResult, + type KtxColumnStatsInput, + type KtxColumnStatsResult, + type KtxQueryResult, + type KtxReadOnlyQueryInput, + type KtxScanConnector, + type KtxScanContext, + type KtxScanInput, + type KtxScanWarning, + type KtxSchemaColumn, + type KtxSchemaForeignKey, + type KtxSchemaSnapshot, + type KtxSchemaTable, + type KtxTableListEntry, + type KtxTableRef, + type KtxTableSampleInput, + type KtxTableSampleResult, +} from '../../context/scan/types.js'; import { KtxMysqlDialect } from './dialect.js'; export interface KtxMysqlConnectionConfig { @@ -249,6 +274,28 @@ function primaryKeyMap(rows: MysqlPrimaryKeyRow[], fallbackDatabase: string): Ma return grouped; } +function isDeniedError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + const code = (error as { code?: unknown }).code; + return ( + code === 'ER_TABLEACCESS_DENIED_ERROR' || + code === 'ER_SPECIFIC_ACCESS_DENIED_ERROR' || + code === 'ER_DBACCESS_DENIED_ERROR' + ); +} + +function pushConstraintWarnings( + warnings: KtxScanWarning[], + schemas: readonly string[], + kind: ConstraintDiscoveryKind, +): void { + for (const schema of schemas) { + warnings.push(constraintDiscoveryWarning({ schema, kind })); + } +} + function queryParams(params: Record | unknown[] | undefined): unknown[] | undefined { if (!params) { return undefined; @@ -359,6 +406,7 @@ export class KtxMysqlScanConnector implements KtxScanConnector { async introspect(input: KtxScanInput, _ctx: KtxScanContext): Promise { this.assertConnection(input.connectionId); const databases = configuredMysqlSchemas(this.connection, this.poolConfig.database); + const snapshotWarnings: KtxScanWarning[] = []; const placeholders = databases.map(() => '?').join(', '); let allScopedTables: string[] | null = null; if (input.tableScope) { @@ -392,8 +440,11 @@ export class KtxMysqlScanConnector implements KtxScanConnector { `, [...databases, ...tableNameParams], ); - const primaryKeys = await this.queryRaw( - ` + const primaryKeysResult = await tryConstraintQuery( + { schema: databases[0] ?? this.poolConfig.database, kind: 'primary_key', isDeniedError }, + () => + this.queryRaw( + ` SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA IN (${placeholders}) @@ -401,10 +452,18 @@ export class KtxMysqlScanConnector implements KtxScanConnector { ${tableNameClause} ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION `, - [...databases, ...tableNameParams], + [...databases, ...tableNameParams], + ), ); - const foreignKeys = await this.queryRaw( - ` + const primaryKeys = primaryKeysResult.ok ? primaryKeysResult.value : []; + if (!primaryKeysResult.ok) { + pushConstraintWarnings(snapshotWarnings, databases, 'primary_key'); + } + const foreignKeysResult = await tryConstraintQuery( + { schema: databases[0] ?? this.poolConfig.database, kind: 'foreign_key', isDeniedError }, + () => + this.queryRaw( + ` SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME, CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA IN (${placeholders}) @@ -412,8 +471,13 @@ export class KtxMysqlScanConnector implements KtxScanConnector { ${tableNameClause} ORDER BY TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME `, - [...databases, ...tableNameParams], + [...databases, ...tableNameParams], + ), ); + const foreignKeys = foreignKeysResult.ok ? foreignKeysResult.value : []; + if (!foreignKeysResult.ok) { + pushConstraintWarnings(snapshotWarnings, databases, 'foreign_key'); + } const columnsByTable = groupByTable(columns, this.poolConfig.database); const primaryKeysByTable = primaryKeyMap(primaryKeys, this.poolConfig.database); @@ -441,6 +505,7 @@ export class KtxMysqlScanConnector implements KtxScanConnector { total_columns: schemaTables.reduce((sum, table) => sum + table.columns.length, 0), }, tables: schemaTables, + warnings: snapshotWarnings, }; }