@prisma-next/target-postgres 0.3.0-dev.10 → 0.3.0-dev.113
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/LICENSE +201 -0
- package/README.md +9 -2
- package/dist/control.d.mts +19 -0
- package/dist/control.d.mts.map +1 -0
- package/dist/control.mjs +3513 -0
- package/dist/control.mjs.map +1 -0
- package/dist/descriptor-meta-DxB8oZzB.mjs +13 -0
- package/dist/descriptor-meta-DxB8oZzB.mjs.map +1 -0
- package/dist/pack.d.mts +10 -0
- package/dist/pack.d.mts.map +1 -0
- package/dist/pack.mjs +9 -0
- package/dist/pack.mjs.map +1 -0
- package/dist/runtime.d.mts +9 -0
- package/dist/runtime.d.mts.map +1 -0
- package/dist/runtime.mjs +21 -0
- package/dist/runtime.mjs.map +1 -0
- package/package.json +34 -33
- package/src/core/migrations/planner-identity-values.ts +129 -0
- package/src/core/migrations/planner-recipes.ts +83 -0
- package/src/core/migrations/planner-reconciliation.ts +613 -0
- package/src/core/migrations/planner-sql.ts +329 -0
- package/src/core/migrations/planner-target-details.ts +16 -0
- package/src/core/migrations/planner.ts +411 -406
- package/src/core/migrations/runner.ts +32 -36
- package/src/core/migrations/statement-builders.ts +9 -7
- package/src/core/types.ts +5 -0
- package/src/exports/control.ts +56 -8
- package/src/exports/pack.ts +5 -2
- package/src/exports/runtime.ts +7 -12
- package/dist/chunk-RKEXRSSI.js +0 -14
- package/dist/chunk-RKEXRSSI.js.map +0 -1
- package/dist/core/descriptor-meta.d.ts +0 -9
- package/dist/core/descriptor-meta.d.ts.map +0 -1
- package/dist/core/migrations/planner.d.ts +0 -14
- package/dist/core/migrations/planner.d.ts.map +0 -1
- package/dist/core/migrations/runner.d.ts +0 -8
- package/dist/core/migrations/runner.d.ts.map +0 -1
- package/dist/core/migrations/statement-builders.d.ts +0 -30
- package/dist/core/migrations/statement-builders.d.ts.map +0 -1
- package/dist/exports/control.d.ts +0 -8
- package/dist/exports/control.d.ts.map +0 -1
- package/dist/exports/control.js +0 -1255
- package/dist/exports/control.js.map +0 -1
- package/dist/exports/pack.d.ts +0 -4
- package/dist/exports/pack.d.ts.map +0 -1
- package/dist/exports/pack.js +0 -11
- package/dist/exports/pack.js.map +0 -1
- package/dist/exports/runtime.d.ts +0 -12
- package/dist/exports/runtime.d.ts.map +0 -1
- package/dist/exports/runtime.js +0 -19
- package/dist/exports/runtime.js.map +0 -1
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
escapeLiteral,
|
|
3
|
+
normalizeSchemaNativeType,
|
|
4
|
+
parsePostgresDefault,
|
|
5
|
+
quoteIdentifier,
|
|
6
|
+
} from '@prisma-next/adapter-postgres/control';
|
|
1
7
|
import type { SchemaIssue } from '@prisma-next/core-control-plane/types';
|
|
2
8
|
import type {
|
|
9
|
+
CodecControlHooks,
|
|
10
|
+
ComponentDatabaseDependency,
|
|
3
11
|
MigrationOperationPolicy,
|
|
4
12
|
SqlMigrationPlanner,
|
|
5
13
|
SqlMigrationPlannerPlanOptions,
|
|
@@ -7,21 +15,45 @@ import type {
|
|
|
7
15
|
SqlPlannerConflict,
|
|
8
16
|
} from '@prisma-next/family-sql/control';
|
|
9
17
|
import {
|
|
18
|
+
collectInitDependencies,
|
|
10
19
|
createMigrationPlan,
|
|
20
|
+
extractCodecControlHooks,
|
|
11
21
|
plannerFailure,
|
|
12
22
|
plannerSuccess,
|
|
13
23
|
} from '@prisma-next/family-sql/control';
|
|
14
|
-
import {
|
|
15
|
-
import type {
|
|
16
|
-
|
|
17
|
-
SqlContract,
|
|
18
|
-
SqlStorage,
|
|
19
|
-
StorageColumn,
|
|
20
|
-
StorageTable,
|
|
21
|
-
} from '@prisma-next/sql-contract/types';
|
|
24
|
+
import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify';
|
|
25
|
+
import type { ForeignKey, StorageColumn, StorageTable } from '@prisma-next/sql-contract/types';
|
|
26
|
+
import { defaultIndexName } from '@prisma-next/sql-schema-ir/naming';
|
|
22
27
|
import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
import { ifDefined } from '@prisma-next/utils/defined';
|
|
29
|
+
import { resolveIdentityValue } from './planner-identity-values';
|
|
30
|
+
import {
|
|
31
|
+
buildAddColumnOperationIdentity,
|
|
32
|
+
buildAddNotNullColumnWithTemporaryDefaultOperation,
|
|
33
|
+
} from './planner-recipes';
|
|
34
|
+
import { buildReconciliationPlan } from './planner-reconciliation';
|
|
35
|
+
import {
|
|
36
|
+
buildAddColumnSql,
|
|
37
|
+
buildCreateTableSql,
|
|
38
|
+
buildForeignKeySql,
|
|
39
|
+
columnExistsCheck,
|
|
40
|
+
columnNullabilityCheck,
|
|
41
|
+
constraintExistsCheck,
|
|
42
|
+
qualifyTableName,
|
|
43
|
+
tableIsEmptyCheck,
|
|
44
|
+
toRegclassLiteral,
|
|
45
|
+
} from './planner-sql';
|
|
46
|
+
import { buildTargetDetails } from './planner-target-details';
|
|
47
|
+
|
|
48
|
+
export type OperationClass =
|
|
49
|
+
| 'dependency'
|
|
50
|
+
| 'type'
|
|
51
|
+
| 'table'
|
|
52
|
+
| 'column'
|
|
53
|
+
| 'primaryKey'
|
|
54
|
+
| 'unique'
|
|
55
|
+
| 'index'
|
|
56
|
+
| 'foreignKey';
|
|
25
57
|
|
|
26
58
|
type PlannerFrameworkComponents = SqlMigrationPlannerPlanOptions extends {
|
|
27
59
|
readonly frameworkComponents: infer T;
|
|
@@ -41,7 +73,6 @@ type PlannerDatabaseDependency = {
|
|
|
41
73
|
readonly id: string;
|
|
42
74
|
readonly label: string;
|
|
43
75
|
readonly install: readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[];
|
|
44
|
-
readonly verifyDatabaseDependencyInstalled: (schema: SqlSchemaIR) => readonly SchemaIssue[];
|
|
45
76
|
};
|
|
46
77
|
|
|
47
78
|
export interface PostgresPlanTargetDetails {
|
|
@@ -55,6 +86,12 @@ interface PlannerConfig {
|
|
|
55
86
|
readonly defaultSchema: string;
|
|
56
87
|
}
|
|
57
88
|
|
|
89
|
+
export interface PlanningMode {
|
|
90
|
+
readonly includeExtraObjects: boolean;
|
|
91
|
+
readonly allowWidening: boolean;
|
|
92
|
+
readonly allowDestructive: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
58
95
|
const DEFAULT_PLANNER_CONFIG: PlannerConfig = {
|
|
59
96
|
defaultSchema: 'public',
|
|
60
97
|
};
|
|
@@ -78,38 +115,64 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
|
|
|
78
115
|
return policyResult;
|
|
79
116
|
}
|
|
80
117
|
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
118
|
+
const planningMode = this.resolvePlanningMode(options.policy);
|
|
119
|
+
const schemaIssues = this.collectSchemaIssues(options, planningMode.includeExtraObjects);
|
|
120
|
+
|
|
121
|
+
// Extract codec control hooks once at entry point for reuse across all operations.
|
|
122
|
+
// This avoids repeated iteration over frameworkComponents for each method that needs hooks.
|
|
123
|
+
const codecHooks = extractCodecControlHooks(options.frameworkComponents);
|
|
85
124
|
|
|
86
125
|
const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
|
|
87
126
|
|
|
127
|
+
const reconciliationPlan = buildReconciliationPlan({
|
|
128
|
+
contract: options.contract,
|
|
129
|
+
issues: schemaIssues,
|
|
130
|
+
schemaName,
|
|
131
|
+
mode: planningMode,
|
|
132
|
+
policy: options.policy,
|
|
133
|
+
codecHooks,
|
|
134
|
+
});
|
|
135
|
+
if (reconciliationPlan.conflicts.length > 0) {
|
|
136
|
+
return plannerFailure(reconciliationPlan.conflicts);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const storageTypePlan = this.buildStorageTypeOperations(options, schemaName, codecHooks);
|
|
140
|
+
if (storageTypePlan.conflicts.length > 0) {
|
|
141
|
+
return plannerFailure(storageTypePlan.conflicts);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Sort table entries once for reuse across all additive operation builders.
|
|
145
|
+
const sortedTables = sortedEntries(options.contract.storage.tables);
|
|
146
|
+
|
|
147
|
+
// Pre-compute constraint lookups once per schema table for O(1) checks across all builders.
|
|
148
|
+
const schemaLookups = buildSchemaLookupMap(options.schema);
|
|
149
|
+
|
|
88
150
|
// Build extension operations from component-owned database dependencies
|
|
89
151
|
operations.push(
|
|
90
152
|
...this.buildDatabaseDependencyOperations(options),
|
|
91
|
-
...
|
|
92
|
-
...
|
|
93
|
-
...this.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
schemaName,
|
|
97
|
-
),
|
|
98
|
-
...this.buildUniqueOperations(options.contract.storage.tables, options.schema, schemaName),
|
|
99
|
-
...this.buildIndexOperations(options.contract.storage.tables, options.schema, schemaName),
|
|
100
|
-
...this.buildForeignKeyOperations(
|
|
101
|
-
options.contract.storage.tables,
|
|
153
|
+
...storageTypePlan.operations,
|
|
154
|
+
...reconciliationPlan.operations,
|
|
155
|
+
...this.buildTableOperations(sortedTables, options.schema, schemaName, codecHooks),
|
|
156
|
+
...this.buildColumnOperations(
|
|
157
|
+
sortedTables,
|
|
102
158
|
options.schema,
|
|
159
|
+
schemaLookups,
|
|
103
160
|
schemaName,
|
|
161
|
+
codecHooks,
|
|
104
162
|
),
|
|
163
|
+
...this.buildPrimaryKeyOperations(sortedTables, options.schema, schemaName),
|
|
164
|
+
...this.buildUniqueOperations(sortedTables, schemaLookups, schemaName),
|
|
165
|
+
...this.buildIndexOperations(sortedTables, schemaLookups, schemaName),
|
|
166
|
+
...this.buildFkBackingIndexOperations(sortedTables, schemaLookups, schemaName),
|
|
167
|
+
...this.buildForeignKeyOperations(sortedTables, schemaLookups, schemaName),
|
|
105
168
|
);
|
|
106
169
|
|
|
107
170
|
const plan = createMigrationPlan<PostgresPlanTargetDetails>({
|
|
108
171
|
targetId: 'postgres',
|
|
109
172
|
origin: null,
|
|
110
173
|
destination: {
|
|
111
|
-
|
|
112
|
-
...(
|
|
174
|
+
storageHash: options.contract.storageHash,
|
|
175
|
+
...ifDefined('profileHash', options.contract.profileHash),
|
|
113
176
|
},
|
|
114
177
|
operations,
|
|
115
178
|
});
|
|
@@ -122,8 +185,8 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
|
|
|
122
185
|
return plannerFailure([
|
|
123
186
|
{
|
|
124
187
|
kind: 'unsupportedOperation',
|
|
125
|
-
summary: '
|
|
126
|
-
why: 'The
|
|
188
|
+
summary: 'Migration planner requires additive operations be allowed',
|
|
189
|
+
why: 'The planner requires the "additive" operation class to be allowed in the policy.',
|
|
127
190
|
},
|
|
128
191
|
]);
|
|
129
192
|
}
|
|
@@ -142,14 +205,15 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
|
|
|
142
205
|
const seenDependencyIds = new Set<string>();
|
|
143
206
|
const seenOperationIds = new Set<string>();
|
|
144
207
|
|
|
208
|
+
const installedIds = new Set(options.schema.dependencies.map((d) => d.id));
|
|
209
|
+
|
|
145
210
|
for (const dependency of dependencies) {
|
|
146
211
|
if (seenDependencyIds.has(dependency.id)) {
|
|
147
212
|
continue;
|
|
148
213
|
}
|
|
149
214
|
seenDependencyIds.add(dependency.id);
|
|
150
215
|
|
|
151
|
-
|
|
152
|
-
if (issues.length === 0) {
|
|
216
|
+
if (installedIds.has(dependency.id)) {
|
|
153
217
|
continue;
|
|
154
218
|
}
|
|
155
219
|
|
|
@@ -158,41 +222,76 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
|
|
|
158
222
|
continue;
|
|
159
223
|
}
|
|
160
224
|
seenOperationIds.add(installOp.id);
|
|
161
|
-
// SQL family components are expected to provide compatible target details. This would be better if
|
|
162
|
-
// the type system could enforce it but it's not likely to occur in practice.
|
|
163
225
|
operations.push(installOp as SqlMigrationPlanOperation<PostgresPlanTargetDetails>);
|
|
164
226
|
}
|
|
165
227
|
}
|
|
166
228
|
|
|
167
229
|
return operations;
|
|
168
230
|
}
|
|
169
|
-
|
|
231
|
+
|
|
232
|
+
private buildStorageTypeOperations(
|
|
170
233
|
options: PlannerOptionsWithComponents,
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
234
|
+
schemaName: string,
|
|
235
|
+
codecHooks: Map<string, CodecControlHooks>,
|
|
236
|
+
): {
|
|
237
|
+
readonly operations: readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[];
|
|
238
|
+
readonly conflicts: readonly SqlPlannerConflict[];
|
|
239
|
+
} {
|
|
240
|
+
const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
|
|
241
|
+
const conflicts: SqlPlannerConflict[] = [];
|
|
242
|
+
const storageTypes = options.contract.storage.types ?? {};
|
|
243
|
+
|
|
244
|
+
for (const [typeName, typeInstance] of sortedEntries(storageTypes)) {
|
|
245
|
+
const hook = codecHooks.get(typeInstance.codecId);
|
|
246
|
+
const planResult = hook?.planTypeOperations?.({
|
|
247
|
+
typeName,
|
|
248
|
+
typeInstance,
|
|
249
|
+
contract: options.contract,
|
|
250
|
+
schema: options.schema,
|
|
251
|
+
schemaName,
|
|
252
|
+
policy: options.policy,
|
|
253
|
+
});
|
|
254
|
+
if (!planResult) {
|
|
179
255
|
continue;
|
|
180
256
|
}
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
257
|
+
for (const operation of planResult.operations) {
|
|
258
|
+
if (!options.policy.allowedOperationClasses.includes(operation.operationClass)) {
|
|
259
|
+
conflicts.push({
|
|
260
|
+
kind: 'missingButNonAdditive',
|
|
261
|
+
summary: `Storage type "${typeName}" requires "${operation.operationClass}" operation "${operation.id}"`,
|
|
262
|
+
location: {
|
|
263
|
+
type: typeName,
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
operations.push({
|
|
269
|
+
...operation,
|
|
270
|
+
target: {
|
|
271
|
+
id: operation.target.id,
|
|
272
|
+
details: this.buildTargetDetails('type', typeName, schemaName),
|
|
273
|
+
},
|
|
274
|
+
});
|
|
184
275
|
}
|
|
185
276
|
}
|
|
186
|
-
|
|
277
|
+
|
|
278
|
+
return { operations, conflicts };
|
|
279
|
+
}
|
|
280
|
+
private collectDependencies(
|
|
281
|
+
options: PlannerOptionsWithComponents,
|
|
282
|
+
): ReadonlyArray<PlannerDatabaseDependency> {
|
|
283
|
+
const dependencies = collectInitDependencies(options.frameworkComponents);
|
|
284
|
+
return sortDependencies(dependencies.filter(isPostgresPlannerDependency));
|
|
187
285
|
}
|
|
188
286
|
|
|
189
287
|
private buildTableOperations(
|
|
190
|
-
tables:
|
|
288
|
+
tables: ReadonlyArray<[string, StorageTable]>,
|
|
191
289
|
schema: SqlSchemaIR,
|
|
192
290
|
schemaName: string,
|
|
291
|
+
codecHooks: Map<string, CodecControlHooks>,
|
|
193
292
|
): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
|
|
194
293
|
const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
|
|
195
|
-
for (const [tableName, table] of
|
|
294
|
+
for (const [tableName, table] of tables) {
|
|
196
295
|
if (schema.tables[tableName]) {
|
|
197
296
|
continue;
|
|
198
297
|
}
|
|
@@ -215,7 +314,7 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
|
|
|
215
314
|
execute: [
|
|
216
315
|
{
|
|
217
316
|
description: `create table "${tableName}"`,
|
|
218
|
-
sql: buildCreateTableSql(qualified, table),
|
|
317
|
+
sql: buildCreateTableSql(qualified, table, codecHooks),
|
|
219
318
|
},
|
|
220
319
|
],
|
|
221
320
|
postcheck: [
|
|
@@ -230,91 +329,135 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
|
|
|
230
329
|
}
|
|
231
330
|
|
|
232
331
|
private buildColumnOperations(
|
|
233
|
-
tables:
|
|
332
|
+
tables: ReadonlyArray<[string, StorageTable]>,
|
|
234
333
|
schema: SqlSchemaIR,
|
|
334
|
+
schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
|
|
235
335
|
schemaName: string,
|
|
336
|
+
codecHooks: Map<string, CodecControlHooks>,
|
|
236
337
|
): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
|
|
237
338
|
const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
|
|
238
|
-
for (const [tableName, table] of
|
|
339
|
+
for (const [tableName, table] of tables) {
|
|
239
340
|
const schemaTable = schema.tables[tableName];
|
|
240
341
|
if (!schemaTable) {
|
|
241
342
|
continue;
|
|
242
343
|
}
|
|
344
|
+
const schemaLookup = schemaLookups.get(tableName);
|
|
243
345
|
for (const [columnName, column] of sortedEntries(table.columns)) {
|
|
244
346
|
if (schemaTable.columns[columnName]) {
|
|
245
347
|
continue;
|
|
246
348
|
}
|
|
247
|
-
operations.push(
|
|
349
|
+
operations.push(
|
|
350
|
+
this.buildAddColumnOperation({
|
|
351
|
+
schema: schemaName,
|
|
352
|
+
tableName,
|
|
353
|
+
table,
|
|
354
|
+
schemaTable,
|
|
355
|
+
schemaLookup,
|
|
356
|
+
columnName,
|
|
357
|
+
column,
|
|
358
|
+
codecHooks,
|
|
359
|
+
}),
|
|
360
|
+
);
|
|
248
361
|
}
|
|
249
362
|
}
|
|
250
363
|
return operations;
|
|
251
364
|
}
|
|
252
365
|
|
|
253
|
-
private buildAddColumnOperation(
|
|
254
|
-
schema: string
|
|
255
|
-
tableName: string
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
366
|
+
private buildAddColumnOperation(options: {
|
|
367
|
+
readonly schema: string;
|
|
368
|
+
readonly tableName: string;
|
|
369
|
+
readonly table: StorageTable;
|
|
370
|
+
readonly schemaTable: SqlSchemaIR['tables'][string];
|
|
371
|
+
readonly schemaLookup: SchemaTableLookup | undefined;
|
|
372
|
+
readonly columnName: string;
|
|
373
|
+
readonly column: StorageColumn;
|
|
374
|
+
readonly codecHooks: Map<string, CodecControlHooks>;
|
|
375
|
+
}): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
|
|
376
|
+
const { schema, tableName, table, schemaTable, schemaLookup, columnName, column, codecHooks } =
|
|
377
|
+
options;
|
|
260
378
|
const notNull = column.nullable === false;
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
{
|
|
289
|
-
description: `verify column "${columnName}" is NOT NULL`,
|
|
290
|
-
sql: columnIsNotNullCheck({ schema, table: tableName, column: columnName }),
|
|
291
|
-
},
|
|
292
|
-
]
|
|
293
|
-
: []),
|
|
294
|
-
];
|
|
379
|
+
const hasDefault = column.default !== undefined;
|
|
380
|
+
// Planner logic decides whether this column needs the coordinated multi-step
|
|
381
|
+
// strategy. The strategy recipe itself is built by a dedicated helper.
|
|
382
|
+
const needsTemporaryDefault = notNull && !hasDefault;
|
|
383
|
+
const temporaryDefault = needsTemporaryDefault
|
|
384
|
+
? resolveIdentityValue(column, codecHooks)
|
|
385
|
+
: null;
|
|
386
|
+
const canUseSharedTemporaryDefault =
|
|
387
|
+
needsTemporaryDefault &&
|
|
388
|
+
temporaryDefault !== null &&
|
|
389
|
+
canUseSharedTemporaryDefaultStrategy({
|
|
390
|
+
table,
|
|
391
|
+
schemaTable,
|
|
392
|
+
schemaLookup,
|
|
393
|
+
columnName,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
if (canUseSharedTemporaryDefault) {
|
|
397
|
+
return buildAddNotNullColumnWithTemporaryDefaultOperation({
|
|
398
|
+
schema,
|
|
399
|
+
tableName,
|
|
400
|
+
columnName,
|
|
401
|
+
column,
|
|
402
|
+
codecHooks,
|
|
403
|
+
temporaryDefault,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
295
406
|
|
|
407
|
+
const qualified = qualifyTableName(schema, tableName);
|
|
408
|
+
const requiresEmptyTableCheck = needsTemporaryDefault && !canUseSharedTemporaryDefault;
|
|
296
409
|
return {
|
|
297
|
-
|
|
298
|
-
label: `Add column ${columnName} to ${tableName}`,
|
|
299
|
-
summary: `Adds column ${columnName} to table ${tableName}`,
|
|
410
|
+
...buildAddColumnOperationIdentity(schema, tableName, columnName),
|
|
300
411
|
operationClass: 'additive',
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
412
|
+
precheck: [
|
|
413
|
+
{
|
|
414
|
+
description: `ensure column "${columnName}" is missing`,
|
|
415
|
+
sql: columnExistsCheck({ schema, table: tableName, column: columnName, exists: false }),
|
|
416
|
+
},
|
|
417
|
+
...(requiresEmptyTableCheck
|
|
418
|
+
? [
|
|
419
|
+
{
|
|
420
|
+
description: `ensure table "${tableName}" is empty before adding NOT NULL column without default`,
|
|
421
|
+
sql: tableIsEmptyCheck(qualified),
|
|
422
|
+
},
|
|
423
|
+
]
|
|
424
|
+
: []),
|
|
425
|
+
],
|
|
426
|
+
execute: [
|
|
427
|
+
{
|
|
428
|
+
description: `add column "${columnName}"`,
|
|
429
|
+
sql: buildAddColumnSql(qualified, columnName, column, codecHooks),
|
|
430
|
+
},
|
|
431
|
+
],
|
|
432
|
+
postcheck: [
|
|
433
|
+
{
|
|
434
|
+
description: `verify column "${columnName}" exists`,
|
|
435
|
+
sql: columnExistsCheck({ schema, table: tableName, column: columnName }),
|
|
436
|
+
},
|
|
437
|
+
...(notNull
|
|
438
|
+
? [
|
|
439
|
+
{
|
|
440
|
+
description: `verify column "${columnName}" is NOT NULL`,
|
|
441
|
+
sql: columnNullabilityCheck({
|
|
442
|
+
schema,
|
|
443
|
+
table: tableName,
|
|
444
|
+
column: columnName,
|
|
445
|
+
nullable: false,
|
|
446
|
+
}),
|
|
447
|
+
},
|
|
448
|
+
]
|
|
449
|
+
: []),
|
|
450
|
+
],
|
|
308
451
|
};
|
|
309
452
|
}
|
|
310
453
|
|
|
311
454
|
private buildPrimaryKeyOperations(
|
|
312
|
-
tables:
|
|
455
|
+
tables: ReadonlyArray<[string, StorageTable]>,
|
|
313
456
|
schema: SqlSchemaIR,
|
|
314
457
|
schemaName: string,
|
|
315
458
|
): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
|
|
316
459
|
const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
|
|
317
|
-
for (const [tableName, table] of
|
|
460
|
+
for (const [tableName, table] of tables) {
|
|
318
461
|
if (!table.primaryKey) {
|
|
319
462
|
continue;
|
|
320
463
|
}
|
|
@@ -358,15 +501,15 @@ PRIMARY KEY (${table.primaryKey.columns.map(quoteIdentifier).join(', ')})`,
|
|
|
358
501
|
}
|
|
359
502
|
|
|
360
503
|
private buildUniqueOperations(
|
|
361
|
-
tables:
|
|
362
|
-
|
|
504
|
+
tables: ReadonlyArray<[string, StorageTable]>,
|
|
505
|
+
schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
|
|
363
506
|
schemaName: string,
|
|
364
507
|
): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
|
|
365
508
|
const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
|
|
366
|
-
for (const [tableName, table] of
|
|
367
|
-
const
|
|
509
|
+
for (const [tableName, table] of tables) {
|
|
510
|
+
const lookup = schemaLookups.get(tableName);
|
|
368
511
|
for (const unique of table.uniques) {
|
|
369
|
-
if (
|
|
512
|
+
if (lookup && hasUniqueConstraint(lookup, unique.columns)) {
|
|
370
513
|
continue;
|
|
371
514
|
}
|
|
372
515
|
const constraintName = unique.name ?? `${tableName}_${unique.columns.join('_')}_key`;
|
|
@@ -406,18 +549,18 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
|
|
|
406
549
|
}
|
|
407
550
|
|
|
408
551
|
private buildIndexOperations(
|
|
409
|
-
tables:
|
|
410
|
-
|
|
552
|
+
tables: ReadonlyArray<[string, StorageTable]>,
|
|
553
|
+
schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
|
|
411
554
|
schemaName: string,
|
|
412
555
|
): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
|
|
413
556
|
const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
|
|
414
|
-
for (const [tableName, table] of
|
|
415
|
-
const
|
|
557
|
+
for (const [tableName, table] of tables) {
|
|
558
|
+
const lookup = schemaLookups.get(tableName);
|
|
416
559
|
for (const index of table.indexes) {
|
|
417
|
-
if (
|
|
560
|
+
if (lookup && hasIndex(lookup, index.columns)) {
|
|
418
561
|
continue;
|
|
419
562
|
}
|
|
420
|
-
const indexName = index.name ??
|
|
563
|
+
const indexName = index.name ?? defaultIndexName(tableName, index.columns);
|
|
421
564
|
operations.push({
|
|
422
565
|
id: `index.${tableName}.${indexName}`,
|
|
423
566
|
label: `Create index ${indexName} on ${tableName}`,
|
|
@@ -454,16 +597,76 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
|
|
|
454
597
|
return operations;
|
|
455
598
|
}
|
|
456
599
|
|
|
600
|
+
/**
|
|
601
|
+
* Generates FK-backing index operations for FKs with `index: true`,
|
|
602
|
+
* but only when no matching user-declared index exists in `contractTable.indexes`.
|
|
603
|
+
*/
|
|
604
|
+
private buildFkBackingIndexOperations(
|
|
605
|
+
tables: ReadonlyArray<[string, StorageTable]>,
|
|
606
|
+
schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
|
|
607
|
+
schemaName: string,
|
|
608
|
+
): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
|
|
609
|
+
const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
|
|
610
|
+
for (const [tableName, table] of tables) {
|
|
611
|
+
const lookup = schemaLookups.get(tableName);
|
|
612
|
+
// Collect column sets of user-declared indexes to avoid duplicates
|
|
613
|
+
const declaredIndexColumns = new Set(table.indexes.map((idx) => idx.columns.join(',')));
|
|
614
|
+
|
|
615
|
+
for (const fk of table.foreignKeys) {
|
|
616
|
+
if (fk.index === false) continue;
|
|
617
|
+
// Skip if user already declared an index with these columns
|
|
618
|
+
if (declaredIndexColumns.has(fk.columns.join(','))) continue;
|
|
619
|
+
// Skip if the index already exists in the database
|
|
620
|
+
if (lookup && hasIndex(lookup, fk.columns)) continue;
|
|
621
|
+
|
|
622
|
+
const indexName = defaultIndexName(tableName, fk.columns);
|
|
623
|
+
operations.push({
|
|
624
|
+
id: `index.${tableName}.${indexName}`,
|
|
625
|
+
label: `Create FK-backing index ${indexName} on ${tableName}`,
|
|
626
|
+
summary: `Creates FK-backing index ${indexName} on ${tableName}`,
|
|
627
|
+
operationClass: 'additive',
|
|
628
|
+
target: {
|
|
629
|
+
id: 'postgres',
|
|
630
|
+
details: this.buildTargetDetails('index', indexName, schemaName, tableName),
|
|
631
|
+
},
|
|
632
|
+
precheck: [
|
|
633
|
+
{
|
|
634
|
+
description: `ensure index "${indexName}" is missing`,
|
|
635
|
+
sql: `SELECT to_regclass(${toRegclassLiteral(schemaName, indexName)}) IS NULL`,
|
|
636
|
+
},
|
|
637
|
+
],
|
|
638
|
+
execute: [
|
|
639
|
+
{
|
|
640
|
+
description: `create FK-backing index "${indexName}"`,
|
|
641
|
+
sql: `CREATE INDEX ${quoteIdentifier(indexName)} ON ${qualifyTableName(
|
|
642
|
+
schemaName,
|
|
643
|
+
tableName,
|
|
644
|
+
)} (${fk.columns.map(quoteIdentifier).join(', ')})`,
|
|
645
|
+
},
|
|
646
|
+
],
|
|
647
|
+
postcheck: [
|
|
648
|
+
{
|
|
649
|
+
description: `verify index "${indexName}" exists`,
|
|
650
|
+
sql: `SELECT to_regclass(${toRegclassLiteral(schemaName, indexName)}) IS NOT NULL`,
|
|
651
|
+
},
|
|
652
|
+
],
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return operations;
|
|
657
|
+
}
|
|
658
|
+
|
|
457
659
|
private buildForeignKeyOperations(
|
|
458
|
-
tables:
|
|
459
|
-
|
|
660
|
+
tables: ReadonlyArray<[string, StorageTable]>,
|
|
661
|
+
schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
|
|
460
662
|
schemaName: string,
|
|
461
663
|
): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
|
|
462
664
|
const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
|
|
463
|
-
for (const [tableName, table] of
|
|
464
|
-
const
|
|
665
|
+
for (const [tableName, table] of tables) {
|
|
666
|
+
const lookup = schemaLookups.get(tableName);
|
|
465
667
|
for (const foreignKey of table.foreignKeys) {
|
|
466
|
-
if (
|
|
668
|
+
if (foreignKey.constraint === false) continue;
|
|
669
|
+
if (lookup && hasForeignKey(lookup, foreignKey)) {
|
|
467
670
|
continue;
|
|
468
671
|
}
|
|
469
672
|
const fkName = foreignKey.name ?? `${tableName}_${foreignKey.columns.join('_')}_fkey`;
|
|
@@ -489,12 +692,7 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
|
|
|
489
692
|
execute: [
|
|
490
693
|
{
|
|
491
694
|
description: `add foreign key "${fkName}"`,
|
|
492
|
-
sql:
|
|
493
|
-
ADD CONSTRAINT ${quoteIdentifier(fkName)}
|
|
494
|
-
FOREIGN KEY (${foreignKey.columns.map(quoteIdentifier).join(', ')})
|
|
495
|
-
REFERENCES ${qualifyTableName(schemaName, foreignKey.references.table)} (${foreignKey.references.columns
|
|
496
|
-
.map(quoteIdentifier)
|
|
497
|
-
.join(', ')})`,
|
|
695
|
+
sql: buildForeignKeySql(schemaName, tableName, fkName, foreignKey),
|
|
498
696
|
},
|
|
499
697
|
],
|
|
500
698
|
postcheck: [
|
|
@@ -515,240 +713,87 @@ REFERENCES ${qualifyTableName(schemaName, foreignKey.references.table)} (${forei
|
|
|
515
713
|
schema: string,
|
|
516
714
|
table?: string,
|
|
517
715
|
): PostgresPlanTargetDetails {
|
|
518
|
-
return
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
716
|
+
return buildTargetDetails(objectType, name, schema, table);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private resolvePlanningMode(policy: MigrationOperationPolicy): PlanningMode {
|
|
720
|
+
const allowWidening = policy.allowedOperationClasses.includes('widening');
|
|
721
|
+
const allowDestructive = policy.allowedOperationClasses.includes('destructive');
|
|
722
|
+
// `db init` uses additive-only policy and intentionally ignores extras.
|
|
723
|
+
// Any reconciliation-capable policy should inspect extras to reconcile strict equality.
|
|
724
|
+
const includeExtraObjects = allowWidening || allowDestructive;
|
|
725
|
+
return { includeExtraObjects, allowWidening, allowDestructive };
|
|
524
726
|
}
|
|
525
727
|
|
|
526
|
-
private
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
conflicts: SqlPlannerConflict[];
|
|
531
|
-
} {
|
|
728
|
+
private collectSchemaIssues(
|
|
729
|
+
options: PlannerOptionsWithComponents,
|
|
730
|
+
strict: boolean,
|
|
731
|
+
): readonly SchemaIssue[] {
|
|
532
732
|
const verifyOptions: VerifySqlSchemaOptionsWithComponents = {
|
|
533
733
|
contract: options.contract,
|
|
534
734
|
schema: options.schema,
|
|
535
|
-
strict
|
|
735
|
+
strict,
|
|
536
736
|
typeMetadataRegistry: new Map(),
|
|
537
737
|
frameworkComponents: options.frameworkComponents,
|
|
738
|
+
normalizeDefault: parsePostgresDefault,
|
|
739
|
+
normalizeNativeType: normalizeSchemaNativeType,
|
|
538
740
|
};
|
|
539
741
|
const verifyResult = verifySqlSchema(verifyOptions);
|
|
540
|
-
|
|
541
|
-
const conflicts = this.extractConflicts(verifyResult.schema.issues);
|
|
542
|
-
if (conflicts.length > 0) {
|
|
543
|
-
return { kind: 'conflict', conflicts };
|
|
544
|
-
}
|
|
545
|
-
return { kind: 'ok' };
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
private extractConflicts(issues: readonly SchemaIssue[]): SqlPlannerConflict[] {
|
|
549
|
-
const conflicts: SqlPlannerConflict[] = [];
|
|
550
|
-
for (const issue of issues) {
|
|
551
|
-
if (isAdditiveIssue(issue)) {
|
|
552
|
-
continue;
|
|
553
|
-
}
|
|
554
|
-
const conflict = this.convertIssueToConflict(issue);
|
|
555
|
-
if (conflict) {
|
|
556
|
-
conflicts.push(conflict);
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
return conflicts.sort(conflictComparator);
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
private convertIssueToConflict(issue: SchemaIssue): SqlPlannerConflict | null {
|
|
563
|
-
switch (issue.kind) {
|
|
564
|
-
case 'type_mismatch':
|
|
565
|
-
return this.buildConflict('typeMismatch', issue);
|
|
566
|
-
case 'nullability_mismatch':
|
|
567
|
-
return this.buildConflict('nullabilityConflict', issue);
|
|
568
|
-
case 'primary_key_mismatch':
|
|
569
|
-
return this.buildConflict('indexIncompatible', issue);
|
|
570
|
-
case 'unique_constraint_mismatch':
|
|
571
|
-
return this.buildConflict('indexIncompatible', issue);
|
|
572
|
-
case 'index_mismatch':
|
|
573
|
-
return this.buildConflict('indexIncompatible', issue);
|
|
574
|
-
case 'foreign_key_mismatch':
|
|
575
|
-
return this.buildConflict('foreignKeyConflict', issue);
|
|
576
|
-
default:
|
|
577
|
-
return null;
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
private buildConflict(kind: SqlPlannerConflict['kind'], issue: SchemaIssue): SqlPlannerConflict {
|
|
582
|
-
const location = buildConflictLocation(issue);
|
|
583
|
-
const meta =
|
|
584
|
-
issue.expected || issue.actual
|
|
585
|
-
? Object.freeze({
|
|
586
|
-
...(issue.expected ? { expected: issue.expected } : {}),
|
|
587
|
-
...(issue.actual ? { actual: issue.actual } : {}),
|
|
588
|
-
})
|
|
589
|
-
: undefined;
|
|
590
|
-
|
|
591
|
-
return {
|
|
592
|
-
kind,
|
|
593
|
-
summary: issue.message,
|
|
594
|
-
...(location ? { location } : {}),
|
|
595
|
-
...(meta ? { meta } : {}),
|
|
596
|
-
};
|
|
742
|
+
return verifyResult.schema.issues;
|
|
597
743
|
}
|
|
598
744
|
}
|
|
599
745
|
|
|
600
|
-
function
|
|
601
|
-
readonly
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
746
|
+
function canUseSharedTemporaryDefaultStrategy(options: {
|
|
747
|
+
readonly table: StorageTable;
|
|
748
|
+
readonly schemaTable: SqlSchemaIR['tables'][string];
|
|
749
|
+
readonly schemaLookup: SchemaTableLookup | undefined;
|
|
750
|
+
readonly columnName: string;
|
|
751
|
+
}): boolean {
|
|
752
|
+
const { table, schemaTable, schemaLookup, columnName } = options;
|
|
753
|
+
|
|
754
|
+
// Shared placeholders are only safe when later plan steps do not require
|
|
755
|
+
// row-specific values for this newly added column.
|
|
756
|
+
if (table.primaryKey?.columns.includes(columnName) && !schemaTable.primaryKey) {
|
|
606
757
|
return false;
|
|
607
758
|
}
|
|
608
|
-
const record = component as Record<string, unknown>;
|
|
609
759
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
760
|
+
for (const unique of table.uniques) {
|
|
761
|
+
if (!unique.columns.includes(columnName)) {
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
if (!schemaLookup || !hasUniqueConstraint(schemaLookup, unique.columns)) {
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
613
767
|
}
|
|
614
768
|
|
|
615
|
-
|
|
616
|
-
|
|
769
|
+
for (const foreignKey of table.foreignKeys) {
|
|
770
|
+
if (foreignKey.constraint === false || !foreignKey.columns.includes(columnName)) {
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (!schemaLookup || !hasForeignKey(schemaLookup, foreignKey)) {
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
617
776
|
}
|
|
618
|
-
|
|
619
|
-
return
|
|
777
|
+
|
|
778
|
+
return true;
|
|
620
779
|
}
|
|
621
780
|
|
|
622
781
|
function sortDependencies(
|
|
623
782
|
dependencies: ReadonlyArray<PlannerDatabaseDependency>,
|
|
624
783
|
): ReadonlyArray<PlannerDatabaseDependency> {
|
|
625
|
-
if (dependencies.length <= 1) {
|
|
626
|
-
return dependencies;
|
|
627
|
-
}
|
|
628
784
|
return [...dependencies].sort((a, b) => a.id.localeCompare(b.id));
|
|
629
785
|
}
|
|
630
786
|
|
|
631
|
-
function
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
quoteIdentifier(columnName),
|
|
636
|
-
column.nativeType,
|
|
637
|
-
column.nullable ? '' : 'NOT NULL',
|
|
638
|
-
].filter(Boolean);
|
|
639
|
-
return parts.join(' ');
|
|
640
|
-
},
|
|
641
|
-
);
|
|
642
|
-
|
|
643
|
-
const constraintDefinitions: string[] = [];
|
|
644
|
-
if (table.primaryKey) {
|
|
645
|
-
constraintDefinitions.push(
|
|
646
|
-
`PRIMARY KEY (${table.primaryKey.columns.map(quoteIdentifier).join(', ')})`,
|
|
647
|
-
);
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
const allDefinitions = [...columnDefinitions, ...constraintDefinitions];
|
|
651
|
-
return `CREATE TABLE ${qualifiedTableName} (\n ${allDefinitions.join(',\n ')}\n)`;
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
function qualifyTableName(schema: string, table: string): string {
|
|
655
|
-
return `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
function toRegclassLiteral(schema: string, name: string): string {
|
|
659
|
-
const regclass = `${quoteIdentifier(schema)}.${quoteIdentifier(name)}`;
|
|
660
|
-
return `'${escapeLiteral(regclass)}'`;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
/** Escapes and quotes a SQL identifier (table, column, schema name). */
|
|
664
|
-
function quoteIdentifier(identifier: string): string {
|
|
665
|
-
// TypeScript enforces string type - no runtime check needed for internal callers
|
|
666
|
-
return `"${identifier.replace(/"/g, '""')}"`;
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
function escapeLiteral(value: string): string {
|
|
670
|
-
return value.replace(/'/g, "''");
|
|
787
|
+
function isPostgresPlannerDependency(
|
|
788
|
+
dependency: ComponentDatabaseDependency<unknown>,
|
|
789
|
+
): dependency is PlannerDatabaseDependency {
|
|
790
|
+
return dependency.install.every((operation) => operation.target.id === 'postgres');
|
|
671
791
|
}
|
|
672
792
|
|
|
673
793
|
function sortedEntries<V>(record: Readonly<Record<string, V>>): Array<[string, V]> {
|
|
674
794
|
return Object.entries(record).sort(([a], [b]) => a.localeCompare(b)) as Array<[string, V]>;
|
|
675
795
|
}
|
|
676
796
|
|
|
677
|
-
function constraintExistsCheck({
|
|
678
|
-
constraintName,
|
|
679
|
-
schema,
|
|
680
|
-
exists = true,
|
|
681
|
-
}: {
|
|
682
|
-
constraintName: string;
|
|
683
|
-
schema: string;
|
|
684
|
-
exists?: boolean;
|
|
685
|
-
}): string {
|
|
686
|
-
const existsClause = exists ? 'EXISTS' : 'NOT EXISTS';
|
|
687
|
-
return `SELECT ${existsClause} (
|
|
688
|
-
SELECT 1 FROM pg_constraint c
|
|
689
|
-
JOIN pg_namespace n ON c.connamespace = n.oid
|
|
690
|
-
WHERE c.conname = '${escapeLiteral(constraintName)}'
|
|
691
|
-
AND n.nspname = '${escapeLiteral(schema)}'
|
|
692
|
-
)`;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
function columnExistsCheck({
|
|
696
|
-
schema,
|
|
697
|
-
table,
|
|
698
|
-
column,
|
|
699
|
-
exists = true,
|
|
700
|
-
}: {
|
|
701
|
-
schema: string;
|
|
702
|
-
table: string;
|
|
703
|
-
column: string;
|
|
704
|
-
exists?: boolean;
|
|
705
|
-
}): string {
|
|
706
|
-
const existsClause = exists ? '' : 'NOT ';
|
|
707
|
-
return `SELECT ${existsClause}EXISTS (
|
|
708
|
-
SELECT 1
|
|
709
|
-
FROM information_schema.columns
|
|
710
|
-
WHERE table_schema = '${escapeLiteral(schema)}'
|
|
711
|
-
AND table_name = '${escapeLiteral(table)}'
|
|
712
|
-
AND column_name = '${escapeLiteral(column)}'
|
|
713
|
-
)`;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
function columnIsNotNullCheck({
|
|
717
|
-
schema,
|
|
718
|
-
table,
|
|
719
|
-
column,
|
|
720
|
-
}: {
|
|
721
|
-
schema: string;
|
|
722
|
-
table: string;
|
|
723
|
-
column: string;
|
|
724
|
-
}): string {
|
|
725
|
-
return `SELECT EXISTS (
|
|
726
|
-
SELECT 1
|
|
727
|
-
FROM information_schema.columns
|
|
728
|
-
WHERE table_schema = '${escapeLiteral(schema)}'
|
|
729
|
-
AND table_name = '${escapeLiteral(table)}'
|
|
730
|
-
AND column_name = '${escapeLiteral(column)}'
|
|
731
|
-
AND is_nullable = 'NO'
|
|
732
|
-
)`;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
function tableIsEmptyCheck(qualifiedTableName: string): string {
|
|
736
|
-
return `SELECT NOT EXISTS (SELECT 1 FROM ${qualifiedTableName} LIMIT 1)`;
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
function buildAddColumnSql(
|
|
740
|
-
qualifiedTableName: string,
|
|
741
|
-
columnName: string,
|
|
742
|
-
column: StorageColumn,
|
|
743
|
-
): string {
|
|
744
|
-
const parts = [
|
|
745
|
-
`ALTER TABLE ${qualifiedTableName}`,
|
|
746
|
-
`ADD COLUMN ${quoteIdentifier(columnName)} ${column.nativeType}`,
|
|
747
|
-
column.nullable ? '' : 'NOT NULL',
|
|
748
|
-
].filter(Boolean);
|
|
749
|
-
return parts.join(' ');
|
|
750
|
-
}
|
|
751
|
-
|
|
752
797
|
function tableHasPrimaryKeyCheck(
|
|
753
798
|
schema: string,
|
|
754
799
|
table: string,
|
|
@@ -772,91 +817,51 @@ function tableHasPrimaryKeyCheck(
|
|
|
772
817
|
)`;
|
|
773
818
|
}
|
|
774
819
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
820
|
+
/**
|
|
821
|
+
* Pre-computed lookup sets for a schema table's constraints.
|
|
822
|
+
* Converts O(n*m) linear scans to O(1) Set lookups per constraint check.
|
|
823
|
+
*/
|
|
824
|
+
interface SchemaTableLookup {
|
|
825
|
+
readonly uniqueKeys: Set<string>;
|
|
826
|
+
readonly indexKeys: Set<string>;
|
|
827
|
+
readonly uniqueIndexKeys: Set<string>;
|
|
828
|
+
readonly fkKeys: Set<string>;
|
|
780
829
|
}
|
|
781
830
|
|
|
782
|
-
function
|
|
783
|
-
|
|
831
|
+
function buildSchemaLookupMap(schema: SqlSchemaIR): ReadonlyMap<string, SchemaTableLookup> {
|
|
832
|
+
const map = new Map<string, SchemaTableLookup>();
|
|
833
|
+
for (const [tableName, table] of Object.entries(schema.tables)) {
|
|
834
|
+
map.set(tableName, buildSchemaTableLookup(table));
|
|
835
|
+
}
|
|
836
|
+
return map;
|
|
784
837
|
}
|
|
785
838
|
|
|
786
|
-
function
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
839
|
+
function buildSchemaTableLookup(table: SqlSchemaIR['tables'][string]): SchemaTableLookup {
|
|
840
|
+
const uniqueKeys = new Set(table.uniques.map((u) => u.columns.join(',')));
|
|
841
|
+
const indexKeys = new Set(table.indexes.map((i) => i.columns.join(',')));
|
|
842
|
+
const uniqueIndexKeys = new Set(
|
|
843
|
+
table.indexes.filter((i) => i.unique).map((i) => i.columns.join(',')),
|
|
844
|
+
);
|
|
845
|
+
const fkKeys = new Set(
|
|
846
|
+
table.foreignKeys.map(
|
|
847
|
+
(fk) => `${fk.columns.join(',')}|${fk.referencedTable}|${fk.referencedColumns.join(',')}`,
|
|
848
|
+
),
|
|
792
849
|
);
|
|
850
|
+
return { uniqueKeys, indexKeys, uniqueIndexKeys, fkKeys };
|
|
793
851
|
}
|
|
794
852
|
|
|
795
|
-
function
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
case 'missing_column':
|
|
799
|
-
case 'extension_missing':
|
|
800
|
-
return true;
|
|
801
|
-
case 'primary_key_mismatch':
|
|
802
|
-
return issue.actual === undefined;
|
|
803
|
-
case 'unique_constraint_mismatch':
|
|
804
|
-
case 'index_mismatch':
|
|
805
|
-
case 'foreign_key_mismatch':
|
|
806
|
-
return issue.indexOrConstraint === undefined;
|
|
807
|
-
default:
|
|
808
|
-
return false;
|
|
809
|
-
}
|
|
853
|
+
function hasUniqueConstraint(lookup: SchemaTableLookup, columns: readonly string[]): boolean {
|
|
854
|
+
const key = columns.join(',');
|
|
855
|
+
return lookup.uniqueKeys.has(key) || lookup.uniqueIndexKeys.has(key);
|
|
810
856
|
}
|
|
811
857
|
|
|
812
|
-
function
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
column?: string;
|
|
816
|
-
constraint?: string;
|
|
817
|
-
} = {};
|
|
818
|
-
if (issue.table) {
|
|
819
|
-
location.table = issue.table;
|
|
820
|
-
}
|
|
821
|
-
if (issue.column) {
|
|
822
|
-
location.column = issue.column;
|
|
823
|
-
}
|
|
824
|
-
if (issue.indexOrConstraint) {
|
|
825
|
-
location.constraint = issue.indexOrConstraint;
|
|
826
|
-
}
|
|
827
|
-
return Object.keys(location).length > 0 ? location : undefined;
|
|
858
|
+
function hasIndex(lookup: SchemaTableLookup, columns: readonly string[]): boolean {
|
|
859
|
+
const key = columns.join(',');
|
|
860
|
+
return lookup.indexKeys.has(key) || lookup.uniqueKeys.has(key);
|
|
828
861
|
}
|
|
829
862
|
|
|
830
|
-
function
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const aLocation = a.location ?? {};
|
|
835
|
-
const bLocation = b.location ?? {};
|
|
836
|
-
const tableCompare = compareStrings(aLocation.table, bLocation.table);
|
|
837
|
-
if (tableCompare !== 0) {
|
|
838
|
-
return tableCompare;
|
|
839
|
-
}
|
|
840
|
-
const columnCompare = compareStrings(aLocation.column, bLocation.column);
|
|
841
|
-
if (columnCompare !== 0) {
|
|
842
|
-
return columnCompare;
|
|
843
|
-
}
|
|
844
|
-
const constraintCompare = compareStrings(aLocation.constraint, bLocation.constraint);
|
|
845
|
-
if (constraintCompare !== 0) {
|
|
846
|
-
return constraintCompare;
|
|
847
|
-
}
|
|
848
|
-
return compareStrings(a.summary, b.summary);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
function compareStrings(a?: string, b?: string): number {
|
|
852
|
-
if (a === b) {
|
|
853
|
-
return 0;
|
|
854
|
-
}
|
|
855
|
-
if (a === undefined) {
|
|
856
|
-
return -1;
|
|
857
|
-
}
|
|
858
|
-
if (b === undefined) {
|
|
859
|
-
return 1;
|
|
860
|
-
}
|
|
861
|
-
return a < b ? -1 : 1;
|
|
863
|
+
function hasForeignKey(lookup: SchemaTableLookup, fk: ForeignKey): boolean {
|
|
864
|
+
return lookup.fkKeys.has(
|
|
865
|
+
`${fk.columns.join(',')}|${fk.references.table}|${fk.references.columns.join(',')}`,
|
|
866
|
+
);
|
|
862
867
|
}
|