@prisma-next/target-postgres 0.3.0-dev.53 → 0.3.0-dev.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,25 +21,27 @@ import {
21
21
  plannerFailure,
22
22
  plannerSuccess,
23
23
  } from '@prisma-next/family-sql/control';
24
- import {
25
- arraysEqual,
26
- isIndexSatisfied,
27
- isUniqueConstraintSatisfied,
28
- verifySqlSchema,
29
- } from '@prisma-next/family-sql/schema-verify';
24
+ import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify';
30
25
  import type {
31
26
  ForeignKey,
32
27
  ReferentialAction,
33
- SqlContract,
34
- SqlStorage,
35
28
  StorageColumn,
36
29
  StorageTable,
37
30
  } from '@prisma-next/sql-contract/types';
38
31
  import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
39
32
  import { ifDefined } from '@prisma-next/utils/defined';
40
33
  import type { PostgresColumnDefault } from '../types';
41
-
42
- type OperationClass = 'extension' | 'type' | 'table' | 'unique' | 'index' | 'foreignKey';
34
+ import { buildReconciliationPlan } from './planner-reconciliation';
35
+
36
+ export type OperationClass =
37
+ | 'extension'
38
+ | 'type'
39
+ | 'table'
40
+ | 'column'
41
+ | 'primaryKey'
42
+ | 'unique'
43
+ | 'index'
44
+ | 'foreignKey';
43
45
 
44
46
  type PlannerFrameworkComponents = SqlMigrationPlannerPlanOptions extends {
45
47
  readonly frameworkComponents: infer T;
@@ -73,6 +75,12 @@ interface PlannerConfig {
73
75
  readonly defaultSchema: string;
74
76
  }
75
77
 
78
+ export interface PlanningMode {
79
+ readonly includeExtraObjects: boolean;
80
+ readonly allowWidening: boolean;
81
+ readonly allowDestructive: boolean;
82
+ }
83
+
76
84
  const DEFAULT_PLANNER_CONFIG: PlannerConfig = {
77
85
  defaultSchema: 'public',
78
86
  };
@@ -96,10 +104,8 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
96
104
  return policyResult;
97
105
  }
98
106
 
99
- const classification = this.classifySchema(options);
100
- if (classification.kind === 'conflict') {
101
- return plannerFailure(classification.conflicts);
102
- }
107
+ const planningMode = this.resolvePlanningMode(options.policy);
108
+ const schemaIssues = this.collectSchemaIssues(options, planningMode.includeExtraObjects);
103
109
 
104
110
  // Extract codec control hooks once at entry point for reuse across all operations.
105
111
  // This avoids repeated iteration over frameworkComponents for each method that needs hooks.
@@ -107,34 +113,40 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
107
113
 
108
114
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
109
115
 
116
+ const reconciliationPlan = buildReconciliationPlan({
117
+ contract: options.contract,
118
+ issues: schemaIssues,
119
+ schemaName,
120
+ mode: planningMode,
121
+ policy: options.policy,
122
+ });
123
+ if (reconciliationPlan.conflicts.length > 0) {
124
+ return plannerFailure(reconciliationPlan.conflicts);
125
+ }
126
+
110
127
  const storageTypePlan = this.buildStorageTypeOperations(options, schemaName, codecHooks);
111
128
  if (storageTypePlan.conflicts.length > 0) {
112
129
  return plannerFailure(storageTypePlan.conflicts);
113
130
  }
114
131
 
132
+ // Sort table entries once for reuse across all additive operation builders.
133
+ const sortedTables = sortedEntries(options.contract.storage.tables);
134
+
135
+ // Pre-compute constraint lookups once per schema table for O(1) checks across all builders.
136
+ const schemaLookups = buildSchemaLookupMap(options.schema);
137
+
115
138
  // Build extension operations from component-owned database dependencies
116
139
  operations.push(
117
140
  ...this.buildDatabaseDependencyOperations(options),
118
141
  ...storageTypePlan.operations,
119
- ...this.buildTableOperations(options.contract.storage.tables, options.schema, schemaName),
120
- ...this.buildColumnOperations(options.contract.storage.tables, options.schema, schemaName),
121
- ...this.buildPrimaryKeyOperations(
122
- options.contract.storage.tables,
123
- options.schema,
124
- schemaName,
125
- ),
126
- ...this.buildUniqueOperations(options.contract.storage.tables, options.schema, schemaName),
127
- ...this.buildIndexOperations(options.contract.storage.tables, options.schema, schemaName),
128
- ...this.buildFkBackingIndexOperations(
129
- options.contract.storage.tables,
130
- options.schema,
131
- schemaName,
132
- ),
133
- ...this.buildForeignKeyOperations(
134
- options.contract.storage.tables,
135
- options.schema,
136
- schemaName,
137
- ),
142
+ ...reconciliationPlan.operations,
143
+ ...this.buildTableOperations(sortedTables, options.schema, schemaName),
144
+ ...this.buildColumnOperations(sortedTables, options.schema, schemaName),
145
+ ...this.buildPrimaryKeyOperations(sortedTables, options.schema, schemaName),
146
+ ...this.buildUniqueOperations(sortedTables, schemaLookups, schemaName),
147
+ ...this.buildIndexOperations(sortedTables, schemaLookups, schemaName),
148
+ ...this.buildFkBackingIndexOperations(sortedTables, schemaLookups, schemaName),
149
+ ...this.buildForeignKeyOperations(sortedTables, schemaLookups, schemaName),
138
150
  );
139
151
 
140
152
  const plan = createMigrationPlan<PostgresPlanTargetDetails>({
@@ -155,8 +167,8 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
155
167
  return plannerFailure([
156
168
  {
157
169
  kind: 'unsupportedOperation',
158
- summary: 'Init planner requires additive operations be allowed',
159
- why: 'The init planner only emits additive operations. Update the policy to include "additive".',
170
+ summary: 'Migration planner requires additive operations be allowed',
171
+ why: 'The planner requires the "additive" operation class to be allowed in the policy.',
160
172
  },
161
173
  ]);
162
174
  }
@@ -269,12 +281,12 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
269
281
  }
270
282
 
271
283
  private buildTableOperations(
272
- tables: SqlContract<SqlStorage>['storage']['tables'],
284
+ tables: ReadonlyArray<[string, StorageTable]>,
273
285
  schema: SqlSchemaIR,
274
286
  schemaName: string,
275
287
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
276
288
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
277
- for (const [tableName, table] of sortedEntries(tables)) {
289
+ for (const [tableName, table] of tables) {
278
290
  if (schema.tables[tableName]) {
279
291
  continue;
280
292
  }
@@ -312,12 +324,12 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
312
324
  }
313
325
 
314
326
  private buildColumnOperations(
315
- tables: SqlContract<SqlStorage>['storage']['tables'],
327
+ tables: ReadonlyArray<[string, StorageTable]>,
316
328
  schema: SqlSchemaIR,
317
329
  schemaName: string,
318
330
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
319
331
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
320
- for (const [tableName, table] of sortedEntries(tables)) {
332
+ for (const [tableName, table] of tables) {
321
333
  const schemaTable = schema.tables[tableName];
322
334
  if (!schemaTable) {
323
335
  continue;
@@ -374,7 +386,12 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
374
386
  ? [
375
387
  {
376
388
  description: `verify column "${columnName}" is NOT NULL`,
377
- sql: columnIsNotNullCheck({ schema, table: tableName, column: columnName }),
389
+ sql: columnNullabilityCheck({
390
+ schema,
391
+ table: tableName,
392
+ column: columnName,
393
+ nullable: false,
394
+ }),
378
395
  },
379
396
  ]
380
397
  : []),
@@ -396,12 +413,12 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
396
413
  }
397
414
 
398
415
  private buildPrimaryKeyOperations(
399
- tables: SqlContract<SqlStorage>['storage']['tables'],
416
+ tables: ReadonlyArray<[string, StorageTable]>,
400
417
  schema: SqlSchemaIR,
401
418
  schemaName: string,
402
419
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
403
420
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
404
- for (const [tableName, table] of sortedEntries(tables)) {
421
+ for (const [tableName, table] of tables) {
405
422
  if (!table.primaryKey) {
406
423
  continue;
407
424
  }
@@ -445,15 +462,15 @@ PRIMARY KEY (${table.primaryKey.columns.map(quoteIdentifier).join(', ')})`,
445
462
  }
446
463
 
447
464
  private buildUniqueOperations(
448
- tables: SqlContract<SqlStorage>['storage']['tables'],
449
- schema: SqlSchemaIR,
465
+ tables: ReadonlyArray<[string, StorageTable]>,
466
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
450
467
  schemaName: string,
451
468
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
452
469
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
453
- for (const [tableName, table] of sortedEntries(tables)) {
454
- const schemaTable = schema.tables[tableName];
470
+ for (const [tableName, table] of tables) {
471
+ const lookup = schemaLookups.get(tableName);
455
472
  for (const unique of table.uniques) {
456
- if (schemaTable && hasUniqueConstraint(schemaTable, unique.columns)) {
473
+ if (lookup && hasUniqueConstraint(lookup, unique.columns)) {
457
474
  continue;
458
475
  }
459
476
  const constraintName = unique.name ?? `${tableName}_${unique.columns.join('_')}_key`;
@@ -493,15 +510,15 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
493
510
  }
494
511
 
495
512
  private buildIndexOperations(
496
- tables: SqlContract<SqlStorage>['storage']['tables'],
497
- schema: SqlSchemaIR,
513
+ tables: ReadonlyArray<[string, StorageTable]>,
514
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
498
515
  schemaName: string,
499
516
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
500
517
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
501
- for (const [tableName, table] of sortedEntries(tables)) {
502
- const schemaTable = schema.tables[tableName];
518
+ for (const [tableName, table] of tables) {
519
+ const lookup = schemaLookups.get(tableName);
503
520
  for (const index of table.indexes) {
504
- if (schemaTable && hasIndex(schemaTable, index.columns)) {
521
+ if (lookup && hasIndex(lookup, index.columns)) {
505
522
  continue;
506
523
  }
507
524
  const indexName = index.name ?? `${tableName}_${index.columns.join('_')}_idx`;
@@ -546,13 +563,13 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
546
563
  * but only when no matching user-declared index exists in `contractTable.indexes`.
547
564
  */
548
565
  private buildFkBackingIndexOperations(
549
- tables: SqlContract<SqlStorage>['storage']['tables'],
550
- schema: SqlSchemaIR,
566
+ tables: ReadonlyArray<[string, StorageTable]>,
567
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
551
568
  schemaName: string,
552
569
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
553
570
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
554
- for (const [tableName, table] of sortedEntries(tables)) {
555
- const schemaTable = schema.tables[tableName];
571
+ for (const [tableName, table] of tables) {
572
+ const lookup = schemaLookups.get(tableName);
556
573
  // Collect column sets of user-declared indexes to avoid duplicates
557
574
  const declaredIndexColumns = new Set(table.indexes.map((idx) => idx.columns.join(',')));
558
575
 
@@ -561,7 +578,7 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
561
578
  // Skip if user already declared an index with these columns
562
579
  if (declaredIndexColumns.has(fk.columns.join(','))) continue;
563
580
  // Skip if the index already exists in the database
564
- if (schemaTable && hasIndex(schemaTable, fk.columns)) continue;
581
+ if (lookup && hasIndex(lookup, fk.columns)) continue;
565
582
 
566
583
  const indexName = `${tableName}_${fk.columns.join('_')}_idx`;
567
584
  operations.push({
@@ -601,16 +618,16 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
601
618
  }
602
619
 
603
620
  private buildForeignKeyOperations(
604
- tables: SqlContract<SqlStorage>['storage']['tables'],
605
- schema: SqlSchemaIR,
621
+ tables: ReadonlyArray<[string, StorageTable]>,
622
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
606
623
  schemaName: string,
607
624
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
608
625
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
609
- for (const [tableName, table] of sortedEntries(tables)) {
610
- const schemaTable = schema.tables[tableName];
626
+ for (const [tableName, table] of tables) {
627
+ const lookup = schemaLookups.get(tableName);
611
628
  for (const foreignKey of table.foreignKeys) {
612
629
  if (foreignKey.constraint === false) continue;
613
- if (schemaTable && hasForeignKey(schemaTable, foreignKey)) {
630
+ if (lookup && hasForeignKey(lookup, foreignKey)) {
614
631
  continue;
615
632
  }
616
633
  const fkName = foreignKey.name ?? `${tableName}_${foreignKey.columns.join('_')}_fkey`;
@@ -657,87 +674,33 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
657
674
  schema: string,
658
675
  table?: string,
659
676
  ): PostgresPlanTargetDetails {
660
- return {
661
- schema,
662
- objectType,
663
- name,
664
- ...ifDefined('table', table),
665
- };
677
+ return buildTargetDetails(objectType, name, schema, table);
666
678
  }
667
679
 
668
- private classifySchema(options: PlannerOptionsWithComponents):
669
- | { kind: 'ok' }
670
- | {
671
- kind: 'conflict';
672
- conflicts: SqlPlannerConflict[];
673
- } {
680
+ private resolvePlanningMode(policy: MigrationOperationPolicy): PlanningMode {
681
+ const allowWidening = policy.allowedOperationClasses.includes('widening');
682
+ const allowDestructive = policy.allowedOperationClasses.includes('destructive');
683
+ // `db init` uses additive-only policy and intentionally ignores extras.
684
+ // Any reconciliation-capable policy should inspect extras to reconcile strict equality.
685
+ const includeExtraObjects = allowWidening || allowDestructive;
686
+ return { includeExtraObjects, allowWidening, allowDestructive };
687
+ }
688
+
689
+ private collectSchemaIssues(
690
+ options: PlannerOptionsWithComponents,
691
+ strict: boolean,
692
+ ): readonly SchemaIssue[] {
674
693
  const verifyOptions: VerifySqlSchemaOptionsWithComponents = {
675
694
  contract: options.contract,
676
695
  schema: options.schema,
677
- strict: false,
696
+ strict,
678
697
  typeMetadataRegistry: new Map(),
679
698
  frameworkComponents: options.frameworkComponents,
680
699
  normalizeDefault: parsePostgresDefault,
681
700
  normalizeNativeType: normalizeSchemaNativeType,
682
701
  };
683
702
  const verifyResult = verifySqlSchema(verifyOptions);
684
-
685
- const conflicts = this.extractConflicts(verifyResult.schema.issues);
686
- if (conflicts.length > 0) {
687
- return { kind: 'conflict', conflicts };
688
- }
689
- return { kind: 'ok' };
690
- }
691
-
692
- private extractConflicts(issues: readonly SchemaIssue[]): SqlPlannerConflict[] {
693
- const conflicts: SqlPlannerConflict[] = [];
694
- for (const issue of issues) {
695
- if (isAdditiveIssue(issue)) {
696
- continue;
697
- }
698
- const conflict = this.convertIssueToConflict(issue);
699
- if (conflict) {
700
- conflicts.push(conflict);
701
- }
702
- }
703
- return conflicts.sort(conflictComparator);
704
- }
705
-
706
- private convertIssueToConflict(issue: SchemaIssue): SqlPlannerConflict | null {
707
- switch (issue.kind) {
708
- case 'type_mismatch':
709
- return this.buildConflict('typeMismatch', issue);
710
- case 'nullability_mismatch':
711
- return this.buildConflict('nullabilityConflict', issue);
712
- case 'primary_key_mismatch':
713
- return this.buildConflict('indexIncompatible', issue);
714
- case 'unique_constraint_mismatch':
715
- return this.buildConflict('indexIncompatible', issue);
716
- case 'index_mismatch':
717
- return this.buildConflict('indexIncompatible', issue);
718
- case 'foreign_key_mismatch':
719
- return this.buildConflict('foreignKeyConflict', issue);
720
- default:
721
- return null;
722
- }
723
- }
724
-
725
- private buildConflict(kind: SqlPlannerConflict['kind'], issue: SchemaIssue): SqlPlannerConflict {
726
- const location = buildConflictLocation(issue);
727
- const meta =
728
- issue.expected || issue.actual
729
- ? Object.freeze({
730
- ...ifDefined('expected', issue.expected),
731
- ...ifDefined('actual', issue.actual),
732
- })
733
- : undefined;
734
-
735
- return {
736
- kind,
737
- summary: issue.message,
738
- ...ifDefined('location', location),
739
- ...ifDefined('meta', meta),
740
- };
703
+ return verifyResult.schema.issues;
741
704
  }
742
705
  }
743
706
 
@@ -796,11 +759,41 @@ function buildCreateTableSql(qualifiedTableName: string, table: StorageTable): s
796
759
  return `CREATE TABLE ${qualifiedTableName} (\n ${allDefinitions.join(',\n ')}\n)`;
797
760
  }
798
761
 
762
+ /**
763
+ * Pattern for safe PostgreSQL type names.
764
+ * Allows letters, digits, underscores, spaces (for "double precision", "character varying"),
765
+ * and trailing [] for array types.
766
+ */
767
+ const SAFE_NATIVE_TYPE_PATTERN = /^[a-zA-Z][a-zA-Z0-9_ ]*(\[\])?$/;
768
+
769
+ function assertSafeNativeType(nativeType: string): void {
770
+ if (!SAFE_NATIVE_TYPE_PATTERN.test(nativeType)) {
771
+ throw new Error(
772
+ `Unsafe native type name in contract: "${nativeType}". ` +
773
+ 'Native type names must match /^[a-zA-Z][a-zA-Z0-9_ ]*(\\[\\])?$/',
774
+ );
775
+ }
776
+ }
777
+
778
+ /**
779
+ * Sanity check against accidental SQL injection from malformed contract files.
780
+ * Rejects semicolons, SQL comment tokens, and dollar-quoting.
781
+ * Not a comprehensive security boundary — the contract is developer-authored.
782
+ */
783
+ function assertSafeDefaultExpression(expression: string): void {
784
+ if (expression.includes(';') || /--|\/\*|\$\$|\bSELECT\b/i.test(expression)) {
785
+ throw new Error(
786
+ `Unsafe default expression in contract: "${expression}". ` +
787
+ 'Default expressions must not contain semicolons, SQL comment tokens, dollar-quoting, or subqueries.',
788
+ );
789
+ }
790
+ }
791
+
799
792
  /**
800
793
  * Builds the column type SQL, handling autoincrement as a special case.
801
794
  * For autoincrement on int4/int8, we use SERIAL/BIGSERIAL types.
802
795
  */
803
- function buildColumnTypeSql(column: StorageColumn): string {
796
+ export function buildColumnTypeSql(column: StorageColumn): string {
804
797
  const columnDefault = column.default;
805
798
 
806
799
  // For autoincrement, use SERIAL/BIGSERIAL types instead of int4/int8
@@ -820,6 +813,8 @@ function buildColumnTypeSql(column: StorageColumn): string {
820
813
  return quoteIdentifier(column.nativeType);
821
814
  }
822
815
 
816
+ // Validate nativeType before using it unquoted in DDL
817
+ assertSafeNativeType(column.nativeType);
823
818
  return renderParameterizedTypeSql(column) ?? column.nativeType;
824
819
  }
825
820
 
@@ -868,7 +863,8 @@ function buildColumnDefaultSql(
868
863
  if (columnDefault.expression === 'autoincrement()') {
869
864
  return '';
870
865
  }
871
- return `DEFAULT ${columnDefault.expression}`;
866
+ assertSafeDefaultExpression(columnDefault.expression);
867
+ return `DEFAULT (${columnDefault.expression})`;
872
868
  }
873
869
  case 'sequence':
874
870
  // Sequence names use quoteIdentifier for safe identifier handling
@@ -907,11 +903,25 @@ function renderDefaultLiteral(value: unknown, column?: StorageColumn): string {
907
903
  return `'${escapeLiteral(json)}'`;
908
904
  }
909
905
 
910
- function qualifyTableName(schema: string, table: string): string {
906
+ export function buildTargetDetails(
907
+ objectType: OperationClass,
908
+ name: string,
909
+ schema: string,
910
+ table?: string,
911
+ ): PostgresPlanTargetDetails {
912
+ return {
913
+ schema,
914
+ objectType,
915
+ name,
916
+ ...ifDefined('table', table),
917
+ };
918
+ }
919
+
920
+ export function qualifyTableName(schema: string, table: string): string {
911
921
  return `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`;
912
922
  }
913
923
 
914
- function toRegclassLiteral(schema: string, name: string): string {
924
+ export function toRegclassLiteral(schema: string, name: string): string {
915
925
  const regclass = `${quoteIdentifier(schema)}.${quoteIdentifier(name)}`;
916
926
  return `'${escapeLiteral(regclass)}'`;
917
927
  }
@@ -920,7 +930,7 @@ function sortedEntries<V>(record: Readonly<Record<string, V>>): Array<[string, V
920
930
  return Object.entries(record).sort(([a], [b]) => a.localeCompare(b)) as Array<[string, V]>;
921
931
  }
922
932
 
923
- function constraintExistsCheck({
933
+ export function constraintExistsCheck({
924
934
  constraintName,
925
935
  schema,
926
936
  exists = true,
@@ -938,7 +948,7 @@ function constraintExistsCheck({
938
948
  )`;
939
949
  }
940
950
 
941
- function columnExistsCheck({
951
+ export function columnExistsCheck({
942
952
  schema,
943
953
  table,
944
954
  column,
@@ -959,22 +969,25 @@ function columnExistsCheck({
959
969
  )`;
960
970
  }
961
971
 
962
- function columnIsNotNullCheck({
972
+ export function columnNullabilityCheck({
963
973
  schema,
964
974
  table,
965
975
  column,
976
+ nullable,
966
977
  }: {
967
978
  schema: string;
968
979
  table: string;
969
980
  column: string;
981
+ nullable: boolean;
970
982
  }): string {
983
+ const expected = nullable ? 'YES' : 'NO';
971
984
  return `SELECT EXISTS (
972
985
  SELECT 1
973
986
  FROM information_schema.columns
974
987
  WHERE table_schema = '${escapeLiteral(schema)}'
975
988
  AND table_name = '${escapeLiteral(table)}'
976
989
  AND column_name = '${escapeLiteral(column)}'
977
- AND is_nullable = 'NO'
990
+ AND is_nullable = '${expected}'
978
991
  )`;
979
992
  }
980
993
 
@@ -1022,102 +1035,52 @@ function tableHasPrimaryKeyCheck(
1022
1035
  }
1023
1036
 
1024
1037
  /**
1025
- * Checks if table has a unique constraint satisfied by the given columns.
1026
- * Uses shared semantic satisfaction predicate from verify-helpers.
1038
+ * Pre-computed lookup sets for a schema table's constraints.
1039
+ * Converts O(n*m) linear scans to O(1) Set lookups per constraint check.
1027
1040
  */
1028
- function hasUniqueConstraint(
1029
- table: SqlSchemaIR['tables'][string],
1030
- columns: readonly string[],
1031
- ): boolean {
1032
- return isUniqueConstraintSatisfied(table.uniques, table.indexes, columns);
1041
+ interface SchemaTableLookup {
1042
+ readonly uniqueKeys: Set<string>;
1043
+ readonly indexKeys: Set<string>;
1044
+ readonly uniqueIndexKeys: Set<string>;
1045
+ readonly fkKeys: Set<string>;
1033
1046
  }
1034
1047
 
1035
- /**
1036
- * Checks if table has an index satisfied by the given columns.
1037
- * Uses shared semantic satisfaction predicate from verify-helpers.
1038
- */
1039
- function hasIndex(table: SqlSchemaIR['tables'][string], columns: readonly string[]): boolean {
1040
- return isIndexSatisfied(table.indexes, table.uniques, columns);
1048
+ function buildSchemaLookupMap(schema: SqlSchemaIR): ReadonlyMap<string, SchemaTableLookup> {
1049
+ const map = new Map<string, SchemaTableLookup>();
1050
+ for (const [tableName, table] of Object.entries(schema.tables)) {
1051
+ map.set(tableName, buildSchemaTableLookup(table));
1052
+ }
1053
+ return map;
1041
1054
  }
1042
1055
 
1043
- function hasForeignKey(table: SqlSchemaIR['tables'][string], fk: ForeignKey): boolean {
1044
- return table.foreignKeys.some(
1045
- (candidate) =>
1046
- arraysEqual(candidate.columns, fk.columns) &&
1047
- candidate.referencedTable === fk.references.table &&
1048
- arraysEqual(candidate.referencedColumns, fk.references.columns),
1056
+ function buildSchemaTableLookup(table: SqlSchemaIR['tables'][string]): SchemaTableLookup {
1057
+ const uniqueKeys = new Set(table.uniques.map((u) => u.columns.join(',')));
1058
+ const indexKeys = new Set(table.indexes.map((i) => i.columns.join(',')));
1059
+ const uniqueIndexKeys = new Set(
1060
+ table.indexes.filter((i) => i.unique).map((i) => i.columns.join(',')),
1049
1061
  );
1062
+ const fkKeys = new Set(
1063
+ table.foreignKeys.map(
1064
+ (fk) => `${fk.columns.join(',')}|${fk.referencedTable}|${fk.referencedColumns.join(',')}`,
1065
+ ),
1066
+ );
1067
+ return { uniqueKeys, indexKeys, uniqueIndexKeys, fkKeys };
1050
1068
  }
1051
1069
 
1052
- function isAdditiveIssue(issue: SchemaIssue): boolean {
1053
- switch (issue.kind) {
1054
- case 'type_missing':
1055
- case 'type_values_mismatch':
1056
- case 'missing_table':
1057
- case 'missing_column':
1058
- case 'extension_missing':
1059
- return true;
1060
- case 'primary_key_mismatch':
1061
- return issue.actual === undefined;
1062
- case 'unique_constraint_mismatch':
1063
- case 'index_mismatch':
1064
- case 'foreign_key_mismatch':
1065
- return issue.indexOrConstraint === undefined;
1066
- default:
1067
- return false;
1068
- }
1069
- }
1070
-
1071
- function buildConflictLocation(issue: SchemaIssue) {
1072
- const location: {
1073
- table?: string;
1074
- column?: string;
1075
- constraint?: string;
1076
- } = {};
1077
- if (issue.table) {
1078
- location.table = issue.table;
1079
- }
1080
- if (issue.column) {
1081
- location.column = issue.column;
1082
- }
1083
- if (issue.indexOrConstraint) {
1084
- location.constraint = issue.indexOrConstraint;
1085
- }
1086
- return Object.keys(location).length > 0 ? location : undefined;
1070
+ function hasUniqueConstraint(lookup: SchemaTableLookup, columns: readonly string[]): boolean {
1071
+ const key = columns.join(',');
1072
+ return lookup.uniqueKeys.has(key) || lookup.uniqueIndexKeys.has(key);
1087
1073
  }
1088
1074
 
1089
- function conflictComparator(a: SqlPlannerConflict, b: SqlPlannerConflict): number {
1090
- if (a.kind !== b.kind) {
1091
- return a.kind < b.kind ? -1 : 1;
1092
- }
1093
- const aLocation = a.location ?? {};
1094
- const bLocation = b.location ?? {};
1095
- const tableCompare = compareStrings(aLocation.table, bLocation.table);
1096
- if (tableCompare !== 0) {
1097
- return tableCompare;
1098
- }
1099
- const columnCompare = compareStrings(aLocation.column, bLocation.column);
1100
- if (columnCompare !== 0) {
1101
- return columnCompare;
1102
- }
1103
- const constraintCompare = compareStrings(aLocation.constraint, bLocation.constraint);
1104
- if (constraintCompare !== 0) {
1105
- return constraintCompare;
1106
- }
1107
- return compareStrings(a.summary, b.summary);
1075
+ function hasIndex(lookup: SchemaTableLookup, columns: readonly string[]): boolean {
1076
+ const key = columns.join(',');
1077
+ return lookup.indexKeys.has(key) || lookup.uniqueKeys.has(key);
1108
1078
  }
1109
1079
 
1110
- function compareStrings(a?: string, b?: string): number {
1111
- if (a === b) {
1112
- return 0;
1113
- }
1114
- if (a === undefined) {
1115
- return -1;
1116
- }
1117
- if (b === undefined) {
1118
- return 1;
1119
- }
1120
- return a < b ? -1 : 1;
1080
+ function hasForeignKey(lookup: SchemaTableLookup, fk: ForeignKey): boolean {
1081
+ return lookup.fkKeys.has(
1082
+ `${fk.columns.join(',')}|${fk.references.table}|${fk.references.columns.join(',')}`,
1083
+ );
1121
1084
  }
1122
1085
 
1123
1086
  const REFERENTIAL_ACTION_SQL: Record<ReferentialAction, string> = {