@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.
- package/dist/control.d.mts +1 -1
- package/dist/control.d.mts.map +1 -1
- package/dist/control.mjs +515 -161
- package/dist/control.mjs.map +1 -1
- package/package.json +14 -14
- package/src/core/migrations/planner-reconciliation.ts +602 -0
- package/src/core/migrations/planner.ts +187 -224
- package/src/core/migrations/runner.ts +4 -16
|
@@ -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
|
-
|
|
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
|
|
100
|
-
|
|
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
|
-
...
|
|
120
|
-
...this.
|
|
121
|
-
...this.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
),
|
|
126
|
-
...this.
|
|
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: '
|
|
159
|
-
why: 'The
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
449
|
-
|
|
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
|
|
454
|
-
const
|
|
470
|
+
for (const [tableName, table] of tables) {
|
|
471
|
+
const lookup = schemaLookups.get(tableName);
|
|
455
472
|
for (const unique of table.uniques) {
|
|
456
|
-
if (
|
|
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:
|
|
497
|
-
|
|
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
|
|
502
|
-
const
|
|
518
|
+
for (const [tableName, table] of tables) {
|
|
519
|
+
const lookup = schemaLookups.get(tableName);
|
|
503
520
|
for (const index of table.indexes) {
|
|
504
|
-
if (
|
|
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:
|
|
550
|
-
|
|
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
|
|
555
|
-
const
|
|
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 (
|
|
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:
|
|
605
|
-
|
|
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
|
|
610
|
-
const
|
|
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 (
|
|
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
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = '
|
|
990
|
+
AND is_nullable = '${expected}'
|
|
978
991
|
)`;
|
|
979
992
|
}
|
|
980
993
|
|
|
@@ -1022,102 +1035,52 @@ function tableHasPrimaryKeyCheck(
|
|
|
1022
1035
|
}
|
|
1023
1036
|
|
|
1024
1037
|
/**
|
|
1025
|
-
*
|
|
1026
|
-
*
|
|
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
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
return
|
|
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
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
|
1111
|
-
|
|
1112
|
-
|
|
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> = {
|