From e5f0f31288891c38283a8db8da6a37706c987ff3 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sun, 24 May 2026 01:10:47 +0200 Subject: [PATCH] feat(sqlserver): soft-fail denied constraint discovery --- .../connectors/sqlserver/connector.test.ts | 48 ++++++++++++++- .../cli/src/connectors/sqlserver/connector.ts | 59 +++++++++++++++++-- 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/connectors/sqlserver/connector.test.ts b/packages/cli/src/connectors/sqlserver/connector.test.ts index 5c865c38..4e84ff9a 100644 --- a/packages/cli/src/connectors/sqlserver/connector.test.ts +++ b/packages/cli/src/connectors/sqlserver/connector.test.ts @@ -16,7 +16,7 @@ function result>(rows: T[], columnNames: strin return { recordset: recordset(rows, columnNames) }; } -function fakePoolFactory(): KtxSqlServerPoolFactory { +function fakePoolFactory(options: { primaryKeyError?: Error; foreignKeyError?: Error } = {}): KtxSqlServerPoolFactory { const query = vi.fn(async (sql: string): Promise => { if (sql.includes('INFORMATION_SCHEMA.TABLES')) { return result( @@ -55,6 +55,9 @@ function fakePoolFactory(): KtxSqlServerPoolFactory { ); } if (sql.includes("CONSTRAINT_TYPE = 'PRIMARY KEY'")) { + if (options.primaryKeyError) { + throw options.primaryKeyError; + } return result( [ { table_name: 'customers', column_name: 'id' }, @@ -64,6 +67,9 @@ function fakePoolFactory(): KtxSqlServerPoolFactory { ); } if (sql.includes('REFERENTIAL_CONSTRAINTS')) { + if (options.foreignKeyError) { + throw options.foreignKeyError; + } return result( [ { @@ -261,6 +267,46 @@ describe('KtxSqlServerScanConnector', () => { ]); }); + it('soft-fails denied SQL Server constraint discovery with scan warnings', async () => { + const connector = new KtxSqlServerScanConnector({ + connectionId: 'warehouse', + connection: { + driver: 'sqlserver', + host: 'db.example.test', + database: 'analytics', + username: 'reader', + schema: 'dbo', + }, + poolFactory: fakePoolFactory({ + primaryKeyError: Object.assign(new Error('SELECT permission denied'), { number: 229 }), + foreignKeyError: Object.assign(new Error('EXECUTE permission denied'), { number: 230 }), + }), + now: () => new Date('2026-04-29T16:00:00.000Z'), + }); + + const snapshot = await connector.introspect( + { connectionId: 'warehouse', driver: 'sqlserver' }, + { runId: 'scan-run-sqlserver-denied-constraints' }, + ); + + expect(snapshot.warnings).toEqual([ + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped primary-key discovery in dbo (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'dbo', kind: 'primary_key' }, + }, + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped foreign-key discovery in dbo (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'dbo', 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('runs samples, distinct values, read-only SQL, row count, schema list, and cleanup', async () => { const poolFactory = fakePoolFactory(); const connector = new KtxSqlServerScanConnector({ diff --git a/packages/cli/src/connectors/sqlserver/connector.ts b/packages/cli/src/connectors/sqlserver/connector.ts index c329f279..9895027f 100644 --- a/packages/cli/src/connectors/sqlserver/connector.ts +++ b/packages/cli/src/connectors/sqlserver/connector.ts @@ -1,6 +1,27 @@ import { assertReadOnlySql } 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 KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js'; +import { tryConstraintQuery } 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 { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; @@ -237,6 +258,14 @@ function firstNumber(value: unknown): number | null { return Number.isFinite(numberValue) ? numberValue : null; } +function isDeniedError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + const number = (error as { number?: unknown }).number; + return number === 229 || number === 230 || number === 297; +} + function limitSqlForSqlServerExecution(sqlText: string, maxRows: number | undefined): string { const trimmed = assertReadOnlySql(sqlText).replace(/;+\s*$/, ''); if (!maxRows) { @@ -352,11 +381,12 @@ export class KtxSqlServerScanConnector implements KtxScanConnector { async introspect(input: KtxScanInput, _ctx: KtxScanContext): Promise { this.assertConnection(input.connectionId); const tables: KtxSchemaTable[] = []; + const snapshotWarnings: KtxScanWarning[] = []; for (const schemaName of this.schemas) { const scopedNames = input.tableScope ? scopedTableNames(input.tableScope, { catalog: this.poolConfig.database, db: schemaName }) : null; - tables.push(...(await this.introspectSchema(schemaName, scopedNames))); + tables.push(...(await this.introspectSchema(schemaName, scopedNames, snapshotWarnings))); } return { connectionId: this.connectionId, @@ -371,6 +401,7 @@ export class KtxSqlServerScanConnector implements KtxScanConnector { total_columns: tables.reduce((sum, table) => sum + table.columns.length, 0), }, tables, + warnings: snapshotWarnings, }; } @@ -503,7 +534,11 @@ export class KtxSqlServerScanConnector implements KtxScanConnector { } } - private async introspectSchema(schemaName: string, scopedNames: readonly string[] | null): Promise { + private async introspectSchema( + schemaName: string, + scopedNames: readonly string[] | null, + snapshotWarnings: KtxScanWarning[], + ): Promise { if (scopedNames && scopedNames.length === 0) return []; const tableScope = tableScopeSql(scopedNames, 'TABLE_NAME'); const tables = await this.queryRaw<{ table_name: string; table_type: string }>( @@ -534,8 +569,22 @@ export class KtxSqlServerScanConnector implements KtxScanConnector { ); const tableComments = await this.tableComments(schemaName, scopedNames); const columnComments = await this.columnComments(schemaName, scopedNames); - const primaryKeys = await this.primaryKeys(schemaName, scopedNames); - const foreignKeys = await this.foreignKeys(schemaName, scopedNames); + const primaryKeysResult = await tryConstraintQuery( + { schema: schemaName, kind: 'primary_key', isDeniedError }, + () => this.primaryKeys(schemaName, scopedNames), + ); + const foreignKeysResult = await tryConstraintQuery( + { schema: schemaName, kind: 'foreign_key', isDeniedError }, + () => this.foreignKeys(schemaName, scopedNames), + ); + const primaryKeys = primaryKeysResult.ok ? primaryKeysResult.value : new Map>(); + const foreignKeys = foreignKeysResult.ok ? foreignKeysResult.value : []; + if (!primaryKeysResult.ok) { + snapshotWarnings.push(primaryKeysResult.warning); + } + if (!foreignKeysResult.ok) { + snapshotWarnings.push(foreignKeysResult.warning); + } const rowCounts = await this.rowCounts(schemaName, scopedNames); const columnsByTable = groupByTable(columns); const foreignKeysByTable = groupByTable(foreignKeys);