@smartive/graphql-magic 23.7.0 → 23.8.0-next.1
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/CHANGELOG.md +9 -2
- package/dist/bin/gqm.cjs +260 -34
- package/dist/cjs/index.cjs +262 -34
- package/dist/esm/migrations/generate-functions.js +2 -0
- package/dist/esm/migrations/generate-functions.js.map +1 -1
- package/dist/esm/migrations/generate.d.ts +16 -1
- package/dist/esm/migrations/generate.js +241 -29
- package/dist/esm/migrations/generate.js.map +1 -1
- package/dist/esm/migrations/update-functions.js +2 -0
- package/dist/esm/migrations/update-functions.js.map +1 -1
- package/dist/esm/models/model-definitions.d.ts +27 -2
- package/dist/esm/models/models.d.ts +1 -5
- package/dist/esm/models/models.js +4 -1
- package/dist/esm/models/models.js.map +1 -1
- package/dist/esm/models/utils.d.ts +10 -0
- package/dist/esm/models/utils.js +11 -0
- package/dist/esm/models/utils.js.map +1 -1
- package/docs/docs/2-models.md +18 -4
- package/docs/docs/5-migrations.md +11 -5
- package/package.json +1 -1
- package/src/bin/gqm/parse-knexfile.ts +1 -0
- package/src/bin/gqm/settings.ts +4 -0
- package/src/migrations/generate-functions.ts +2 -0
- package/src/migrations/generate.ts +322 -35
- package/src/migrations/update-functions.ts +2 -0
- package/src/models/model-definitions.ts +20 -1
- package/src/models/models.ts +4 -1
- package/src/models/utils.ts +20 -0
- package/tests/unit/constraints.spec.ts +98 -2
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
summonByName,
|
|
19
19
|
typeToField,
|
|
20
20
|
validateCheckConstraint,
|
|
21
|
+
validateExcludeConstraint,
|
|
21
22
|
} from '../models/utils';
|
|
22
23
|
import { getColumnName } from '../resolvers';
|
|
23
24
|
import { Value } from '../values';
|
|
@@ -41,6 +42,10 @@ export class MigrationGenerator {
|
|
|
41
42
|
private columns: Record<string, Column[]> = {};
|
|
42
43
|
/** table name -> constraint name -> check clause expression */
|
|
43
44
|
private existingCheckConstraints: Record<string, Map<string, string>> = {};
|
|
45
|
+
/** table name -> constraint name -> { normalized, raw } */
|
|
46
|
+
private existingExcludeConstraints: Record<string, Map<string, { normalized: string; raw: string }>> = {};
|
|
47
|
+
/** table name -> constraint name -> { normalized, raw } */
|
|
48
|
+
private existingConstraintTriggers: Record<string, Map<string, { normalized: string; raw: string }>> = {};
|
|
44
49
|
private uuidUsed?: boolean;
|
|
45
50
|
private nowUsed?: boolean;
|
|
46
51
|
public needsMigration = false;
|
|
@@ -83,9 +88,66 @@ export class MigrationGenerator {
|
|
|
83
88
|
this.existingCheckConstraints[tableName].set(row.constraint_name, row.check_clause);
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
const excludeResult = await schema.knex.raw(
|
|
92
|
+
`SELECT c.conrelid::regclass::text as table_name, c.conname as constraint_name, pg_get_constraintdef(c.oid) as constraint_def
|
|
93
|
+
FROM pg_constraint c
|
|
94
|
+
JOIN pg_namespace n ON c.connamespace = n.oid
|
|
95
|
+
WHERE n.nspname = 'public' AND c.contype = 'x'`,
|
|
96
|
+
);
|
|
97
|
+
const excludeRows: { table_name: string; constraint_name: string; constraint_def: string }[] =
|
|
98
|
+
'rows' in excludeResult && Array.isArray((excludeResult as { rows: unknown }).rows)
|
|
99
|
+
? (excludeResult as { rows: { table_name: string; constraint_name: string; constraint_def: string }[] }).rows
|
|
100
|
+
: [];
|
|
101
|
+
for (const row of excludeRows) {
|
|
102
|
+
const tableName = row.table_name.split('.').pop()?.replace(/^"|"$/g, '') ?? row.table_name;
|
|
103
|
+
if (!this.existingExcludeConstraints[tableName]) {
|
|
104
|
+
this.existingExcludeConstraints[tableName] = new Map();
|
|
105
|
+
}
|
|
106
|
+
this.existingExcludeConstraints[tableName].set(row.constraint_name, {
|
|
107
|
+
normalized: this.normalizeExcludeDef(row.constraint_def),
|
|
108
|
+
raw: row.constraint_def,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const triggerResult = await schema.knex.raw(
|
|
113
|
+
`SELECT c.conrelid::regclass::text as table_name, c.conname as constraint_name, pg_get_triggerdef(t.oid) as trigger_def
|
|
114
|
+
FROM pg_constraint c
|
|
115
|
+
JOIN pg_trigger t ON t.tgconstraint = c.oid
|
|
116
|
+
JOIN pg_namespace n ON c.connamespace = n.oid
|
|
117
|
+
WHERE n.nspname = 'public' AND c.contype = 't'`,
|
|
118
|
+
);
|
|
119
|
+
const triggerRows: { table_name: string; constraint_name: string; trigger_def: string }[] =
|
|
120
|
+
'rows' in triggerResult && Array.isArray((triggerResult as { rows: unknown }).rows)
|
|
121
|
+
? (triggerResult as { rows: { table_name: string; constraint_name: string; trigger_def: string }[] }).rows
|
|
122
|
+
: [];
|
|
123
|
+
for (const row of triggerRows) {
|
|
124
|
+
const tableName = row.table_name.split('.').pop()?.replace(/^"|"$/g, '') ?? row.table_name;
|
|
125
|
+
if (!this.existingConstraintTriggers[tableName]) {
|
|
126
|
+
this.existingConstraintTriggers[tableName] = new Map();
|
|
127
|
+
}
|
|
128
|
+
this.existingConstraintTriggers[tableName].set(row.constraint_name, {
|
|
129
|
+
normalized: this.normalizeTriggerDef(row.trigger_def),
|
|
130
|
+
raw: row.trigger_def,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
86
134
|
const up: Callbacks = [];
|
|
87
135
|
const down: Callbacks = [];
|
|
88
136
|
|
|
137
|
+
const wantsBtreeGist = models.entities.some((model) =>
|
|
138
|
+
model.constraints?.some((c) => c.kind === 'exclude' && c.elements.some((el) => 'column' in el && el.operator === '=')),
|
|
139
|
+
);
|
|
140
|
+
if (wantsBtreeGist) {
|
|
141
|
+
const extResult = await schema.knex('pg_extension').where('extname', 'btree_gist').select('oid').first();
|
|
142
|
+
const btreeGistInstalled = !!extResult;
|
|
143
|
+
if (!btreeGistInstalled) {
|
|
144
|
+
up.unshift(() => {
|
|
145
|
+
this.writer.writeLine(`await knex.raw('CREATE EXTENSION IF NOT EXISTS btree_gist');`);
|
|
146
|
+
this.writer.blankLine();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
89
151
|
this.createEnums(
|
|
90
152
|
this.models.enums.filter((enm) => !enums.includes(lowerFirst(enm.name))),
|
|
91
153
|
up,
|
|
@@ -178,10 +240,22 @@ export class MigrationGenerator {
|
|
|
178
240
|
if (entry.kind === 'check') {
|
|
179
241
|
validateCheckConstraint(model, entry);
|
|
180
242
|
const table = model.name;
|
|
181
|
-
const constraintName = this.
|
|
182
|
-
|
|
243
|
+
const constraintName = this.getConstraintName(model, entry, i);
|
|
244
|
+
up.push(() => {
|
|
245
|
+
this.addCheckConstraint(table, constraintName, entry.expression, entry.deferrable);
|
|
246
|
+
});
|
|
247
|
+
} else if (entry.kind === 'exclude') {
|
|
248
|
+
validateExcludeConstraint(model, entry);
|
|
249
|
+
const table = model.name;
|
|
250
|
+
const constraintName = this.getConstraintName(model, entry, i);
|
|
183
251
|
up.push(() => {
|
|
184
|
-
this.
|
|
252
|
+
this.addExcludeConstraint(table, constraintName, entry);
|
|
253
|
+
});
|
|
254
|
+
} else if (entry.kind === 'constraint_trigger') {
|
|
255
|
+
const table = model.name;
|
|
256
|
+
const constraintName = this.getConstraintName(model, entry, i);
|
|
257
|
+
up.push(() => {
|
|
258
|
+
this.addConstraintTrigger(table, constraintName, entry);
|
|
185
259
|
});
|
|
186
260
|
}
|
|
187
261
|
}
|
|
@@ -237,38 +311,90 @@ export class MigrationGenerator {
|
|
|
237
311
|
this.updateFields(model, existingFields, up, down);
|
|
238
312
|
|
|
239
313
|
if (model.constraints?.length) {
|
|
314
|
+
const existingExcludeMap = this.existingExcludeConstraints[model.name];
|
|
315
|
+
const existingTriggerMap = this.existingConstraintTriggers[model.name];
|
|
240
316
|
for (let i = 0; i < model.constraints.length; i++) {
|
|
241
317
|
const entry = model.constraints[i];
|
|
242
|
-
if (entry.kind !== 'check') {
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
validateCheckConstraint(model, entry);
|
|
246
318
|
const table = model.name;
|
|
247
|
-
const constraintName = this.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
319
|
+
const constraintName = this.getConstraintName(model, entry, i);
|
|
320
|
+
if (entry.kind === 'check') {
|
|
321
|
+
validateCheckConstraint(model, entry);
|
|
322
|
+
const existingConstraint = this.findExistingConstraint(table, entry, constraintName);
|
|
323
|
+
if (!existingConstraint) {
|
|
324
|
+
up.push(() => {
|
|
325
|
+
this.addCheckConstraint(table, constraintName, entry.expression, entry.deferrable);
|
|
326
|
+
});
|
|
327
|
+
down.push(() => {
|
|
328
|
+
this.dropCheckConstraint(table, constraintName);
|
|
329
|
+
});
|
|
330
|
+
} else if (
|
|
331
|
+
!(await this.equalExpressions(
|
|
332
|
+
table,
|
|
333
|
+
existingConstraint.constraintName,
|
|
334
|
+
existingConstraint.expression,
|
|
335
|
+
entry.expression,
|
|
336
|
+
))
|
|
337
|
+
) {
|
|
338
|
+
up.push(() => {
|
|
339
|
+
this.dropCheckConstraint(table, existingConstraint.constraintName);
|
|
340
|
+
this.addCheckConstraint(table, constraintName, entry.expression, entry.deferrable);
|
|
341
|
+
});
|
|
342
|
+
down.push(() => {
|
|
343
|
+
this.dropCheckConstraint(table, constraintName);
|
|
344
|
+
this.addCheckConstraint(
|
|
345
|
+
table,
|
|
346
|
+
existingConstraint.constraintName,
|
|
347
|
+
existingConstraint.expression,
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
} else if (entry.kind === 'exclude') {
|
|
352
|
+
validateExcludeConstraint(model, entry);
|
|
353
|
+
const newDef = this.normalizeExcludeDef(this.buildExcludeDef(entry));
|
|
354
|
+
const existing = existingExcludeMap?.get(constraintName);
|
|
355
|
+
if (existing === undefined) {
|
|
356
|
+
up.push(() => {
|
|
357
|
+
this.addExcludeConstraint(table, constraintName, entry);
|
|
358
|
+
});
|
|
359
|
+
down.push(() => {
|
|
360
|
+
this.dropExcludeConstraint(table, constraintName);
|
|
361
|
+
});
|
|
362
|
+
} else if (existing.normalized !== newDef) {
|
|
363
|
+
up.push(() => {
|
|
364
|
+
this.dropExcludeConstraint(table, constraintName);
|
|
365
|
+
this.addExcludeConstraint(table, constraintName, entry);
|
|
366
|
+
});
|
|
367
|
+
down.push(() => {
|
|
368
|
+
this.dropExcludeConstraint(table, constraintName);
|
|
369
|
+
const escaped = this.escapeExpressionForRaw(existing.raw);
|
|
370
|
+
this.writer.writeLine(
|
|
371
|
+
`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" ${escaped}\`);`,
|
|
372
|
+
);
|
|
373
|
+
this.writer.blankLine();
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
} else if (entry.kind === 'constraint_trigger') {
|
|
377
|
+
const newDef = this.normalizeTriggerDef(this.buildConstraintTriggerDef(table, constraintName, entry));
|
|
378
|
+
const existing = existingTriggerMap?.get(constraintName);
|
|
379
|
+
if (existing === undefined) {
|
|
380
|
+
up.push(() => {
|
|
381
|
+
this.addConstraintTrigger(table, constraintName, entry);
|
|
382
|
+
});
|
|
383
|
+
down.push(() => {
|
|
384
|
+
this.dropConstraintTrigger(table, constraintName);
|
|
385
|
+
});
|
|
386
|
+
} else if (existing.normalized !== newDef) {
|
|
387
|
+
up.push(() => {
|
|
388
|
+
this.dropConstraintTrigger(table, constraintName);
|
|
389
|
+
this.addConstraintTrigger(table, constraintName, entry);
|
|
390
|
+
});
|
|
391
|
+
down.push(() => {
|
|
392
|
+
this.dropConstraintTrigger(table, constraintName);
|
|
393
|
+
const escaped = existing.raw.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
|
394
|
+
this.writer.writeLine(`await knex.raw(\`${escaped}\`);`);
|
|
395
|
+
this.writer.blankLine();
|
|
396
|
+
});
|
|
397
|
+
}
|
|
272
398
|
}
|
|
273
399
|
}
|
|
274
400
|
}
|
|
@@ -761,10 +887,80 @@ export class MigrationGenerator {
|
|
|
761
887
|
this.writer.writeLine(`table.renameColumn('${from}', '${to}');`);
|
|
762
888
|
}
|
|
763
889
|
|
|
764
|
-
private
|
|
890
|
+
private getConstraintName(model: EntityModel, entry: { kind: string; name: string }, index: number): string {
|
|
765
891
|
return `${model.name}_${entry.name}_${entry.kind}_${index}`;
|
|
766
892
|
}
|
|
767
893
|
|
|
894
|
+
private static readonly SQL_KEYWORDS = new Set([
|
|
895
|
+
'and',
|
|
896
|
+
'or',
|
|
897
|
+
'not',
|
|
898
|
+
'in',
|
|
899
|
+
'is',
|
|
900
|
+
'null',
|
|
901
|
+
'true',
|
|
902
|
+
'false',
|
|
903
|
+
'between',
|
|
904
|
+
'like',
|
|
905
|
+
'exists',
|
|
906
|
+
'all',
|
|
907
|
+
'any',
|
|
908
|
+
'asc',
|
|
909
|
+
'desc',
|
|
910
|
+
'with',
|
|
911
|
+
'using',
|
|
912
|
+
'as',
|
|
913
|
+
'on',
|
|
914
|
+
'infinity',
|
|
915
|
+
'extract',
|
|
916
|
+
'current_date',
|
|
917
|
+
'current_timestamp',
|
|
918
|
+
]);
|
|
919
|
+
|
|
920
|
+
private static readonly LITERAL_PLACEHOLDER = '\uE000';
|
|
921
|
+
|
|
922
|
+
private normalizeSqlIdentifiers(s: string): string {
|
|
923
|
+
const literals: string[] = [];
|
|
924
|
+
let result = s.replace(/'([^']|'')*'/g, (lit) => {
|
|
925
|
+
literals.push(lit);
|
|
926
|
+
|
|
927
|
+
return `${MigrationGenerator.LITERAL_PLACEHOLDER}${literals.length - 1}${MigrationGenerator.LITERAL_PLACEHOLDER}`;
|
|
928
|
+
});
|
|
929
|
+
result = result.replace(/"([^"]*)"/g, (_, ident) => `"${ident.toLowerCase()}"`);
|
|
930
|
+
result = result.replace(/\b([a-zA-Z_][a-zA-Z0-9_]*)\b(?!\s*\()/g, (match) =>
|
|
931
|
+
MigrationGenerator.SQL_KEYWORDS.has(match.toLowerCase()) ? match : `"${match.toLowerCase()}"`,
|
|
932
|
+
);
|
|
933
|
+
for (let i = 0; i < literals.length; i++) {
|
|
934
|
+
result = result.replace(
|
|
935
|
+
new RegExp(`${MigrationGenerator.LITERAL_PLACEHOLDER}${i}${MigrationGenerator.LITERAL_PLACEHOLDER}`, 'g'),
|
|
936
|
+
literals[i],
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return result;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
private normalizeExcludeDef(def: string): string {
|
|
944
|
+
const s = def
|
|
945
|
+
.replace(/\s+/g, ' ')
|
|
946
|
+
.replace(/\s*\(\s*/g, '(')
|
|
947
|
+
.replace(/\s*\)\s*/g, ')')
|
|
948
|
+
.trim();
|
|
949
|
+
|
|
950
|
+
return this.normalizeSqlIdentifiers(s);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
private normalizeTriggerDef(def: string): string {
|
|
954
|
+
const s = def
|
|
955
|
+
.replace(/\s+/g, ' ')
|
|
956
|
+
.replace(/\s*\(\s*/g, '(')
|
|
957
|
+
.replace(/\s*\)\s*/g, ')')
|
|
958
|
+
.replace(/\bON\s+[a-zA-Z_][a-zA-Z0-9_]*\./gi, 'ON ')
|
|
959
|
+
.trim();
|
|
960
|
+
|
|
961
|
+
return this.normalizeSqlIdentifiers(s);
|
|
962
|
+
}
|
|
963
|
+
|
|
768
964
|
private normalizeCheckExpression(expr: string): string {
|
|
769
965
|
let normalized = expr.replace(/\s+/g, ' ').trim();
|
|
770
966
|
while (this.isWrappedByOuterParentheses(normalized)) {
|
|
@@ -949,10 +1145,16 @@ export class MigrationGenerator {
|
|
|
949
1145
|
return expr.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
|
950
1146
|
}
|
|
951
1147
|
|
|
952
|
-
private addCheckConstraint(
|
|
1148
|
+
private addCheckConstraint(
|
|
1149
|
+
table: string,
|
|
1150
|
+
constraintName: string,
|
|
1151
|
+
expression: string,
|
|
1152
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE',
|
|
1153
|
+
) {
|
|
953
1154
|
const escaped = this.escapeExpressionForRaw(expression);
|
|
1155
|
+
const deferrableClause = deferrable ? ` DEFERRABLE ${deferrable}` : '';
|
|
954
1156
|
this.writer.writeLine(
|
|
955
|
-
`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" CHECK (${escaped})\`);`,
|
|
1157
|
+
`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" CHECK (${escaped})${deferrableClause}\`);`,
|
|
956
1158
|
);
|
|
957
1159
|
this.writer.blankLine();
|
|
958
1160
|
}
|
|
@@ -962,6 +1164,91 @@ export class MigrationGenerator {
|
|
|
962
1164
|
this.writer.blankLine();
|
|
963
1165
|
}
|
|
964
1166
|
|
|
1167
|
+
private buildExcludeDef(entry: {
|
|
1168
|
+
using: string;
|
|
1169
|
+
elements: ({ column: string; operator: string } | { expression: string; operator: string })[];
|
|
1170
|
+
where?: string;
|
|
1171
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
|
|
1172
|
+
}): string {
|
|
1173
|
+
const elementsStr = entry.elements
|
|
1174
|
+
.map((el) => ('column' in el ? `"${el.column}" WITH ${el.operator}` : `${el.expression} WITH ${el.operator}`))
|
|
1175
|
+
.join(', ');
|
|
1176
|
+
const whereClause = entry.where ? ` WHERE (${entry.where})` : '';
|
|
1177
|
+
const deferrableClause = entry.deferrable ? ` DEFERRABLE ${entry.deferrable}` : '';
|
|
1178
|
+
|
|
1179
|
+
return `EXCLUDE USING ${entry.using} (${elementsStr})${whereClause}${deferrableClause}`;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
private addExcludeConstraint(
|
|
1183
|
+
table: string,
|
|
1184
|
+
constraintName: string,
|
|
1185
|
+
entry: {
|
|
1186
|
+
using: string;
|
|
1187
|
+
elements: ({ column: string; operator: string } | { expression: string; operator: string })[];
|
|
1188
|
+
where?: string;
|
|
1189
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
|
|
1190
|
+
},
|
|
1191
|
+
) {
|
|
1192
|
+
const def = this.buildExcludeDef(entry);
|
|
1193
|
+
const escaped = this.escapeExpressionForRaw(def);
|
|
1194
|
+
this.writer.writeLine(`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" ${escaped}\`);`);
|
|
1195
|
+
this.writer.blankLine();
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
private dropExcludeConstraint(table: string, constraintName: string) {
|
|
1199
|
+
this.writer.writeLine(`await knex.raw('ALTER TABLE "${table}" DROP CONSTRAINT "${constraintName}"');`);
|
|
1200
|
+
this.writer.blankLine();
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
private buildConstraintTriggerDef(
|
|
1204
|
+
table: string,
|
|
1205
|
+
constraintName: string,
|
|
1206
|
+
entry: {
|
|
1207
|
+
when: 'AFTER' | 'BEFORE';
|
|
1208
|
+
events: ('INSERT' | 'UPDATE')[];
|
|
1209
|
+
forEach: 'ROW' | 'STATEMENT';
|
|
1210
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
|
|
1211
|
+
function: { name: string; args?: string[] };
|
|
1212
|
+
},
|
|
1213
|
+
): string {
|
|
1214
|
+
const eventsStr = entry.events.join(' OR ');
|
|
1215
|
+
const deferrableClause = entry.deferrable ? ` DEFERRABLE ${entry.deferrable}` : '';
|
|
1216
|
+
const argsStr = entry.function.args?.length ? entry.function.args.map((a) => `"${a}"`).join(', ') : '';
|
|
1217
|
+
const executeClause = argsStr
|
|
1218
|
+
? `EXECUTE FUNCTION ${entry.function.name}(${argsStr})`
|
|
1219
|
+
: `EXECUTE FUNCTION ${entry.function.name}()`;
|
|
1220
|
+
|
|
1221
|
+
return `CREATE CONSTRAINT TRIGGER "${constraintName}" ${entry.when} ${eventsStr} ON "${table}"${deferrableClause} FOR EACH ${entry.forEach} ${executeClause}`;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
private addConstraintTrigger(
|
|
1225
|
+
table: string,
|
|
1226
|
+
constraintName: string,
|
|
1227
|
+
entry: {
|
|
1228
|
+
when: 'AFTER' | 'BEFORE';
|
|
1229
|
+
events: ('INSERT' | 'UPDATE')[];
|
|
1230
|
+
forEach: 'ROW' | 'STATEMENT';
|
|
1231
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
|
|
1232
|
+
function: { name: string; args?: string[] };
|
|
1233
|
+
},
|
|
1234
|
+
) {
|
|
1235
|
+
const eventsStr = entry.events.join(' OR ');
|
|
1236
|
+
const deferrableClause = entry.deferrable ? ` DEFERRABLE ${entry.deferrable}` : '';
|
|
1237
|
+
const argsStr = entry.function.args?.length ? entry.function.args.map((a) => `"${a}"`).join(', ') : '';
|
|
1238
|
+
const executeClause = argsStr
|
|
1239
|
+
? `EXECUTE FUNCTION ${entry.function.name}(${argsStr})`
|
|
1240
|
+
: `EXECUTE FUNCTION ${entry.function.name}()`;
|
|
1241
|
+
this.writer.writeLine(
|
|
1242
|
+
`await knex.raw(\`CREATE CONSTRAINT TRIGGER "${constraintName}" ${entry.when} ${eventsStr} ON "${table}"${deferrableClause} FOR EACH ${entry.forEach} ${executeClause}\`);`,
|
|
1243
|
+
);
|
|
1244
|
+
this.writer.blankLine();
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
private dropConstraintTrigger(table: string, constraintName: string) {
|
|
1248
|
+
this.writer.writeLine(`await knex.raw('ALTER TABLE "${table}" DROP CONSTRAINT "${constraintName}"');`);
|
|
1249
|
+
this.writer.blankLine();
|
|
1250
|
+
}
|
|
1251
|
+
|
|
965
1252
|
private value(value: Value) {
|
|
966
1253
|
if (typeof value === 'string') {
|
|
967
1254
|
return `'${value}'`;
|
|
@@ -68,6 +68,7 @@ export const getDatabaseFunctions = async (knex: Knex): Promise<DatabaseFunction
|
|
|
68
68
|
JOIN pg_namespace n ON p.pronamespace = n.oid
|
|
69
69
|
WHERE n.nspname = 'public'
|
|
70
70
|
AND NOT EXISTS (SELECT 1 FROM pg_aggregate a WHERE a.aggfnoid = p.oid)
|
|
71
|
+
AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e')
|
|
71
72
|
ORDER BY p.proname, pg_get_function_identity_arguments(p.oid)
|
|
72
73
|
`);
|
|
73
74
|
|
|
@@ -84,6 +85,7 @@ export const getDatabaseFunctions = async (knex: Knex): Promise<DatabaseFunction
|
|
|
84
85
|
JOIN pg_aggregate a ON p.oid = a.aggfnoid
|
|
85
86
|
JOIN pg_namespace n ON p.pronamespace = n.oid
|
|
86
87
|
WHERE n.nspname = 'public'
|
|
88
|
+
AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e')
|
|
87
89
|
ORDER BY p.proname, pg_get_function_identity_arguments(p.oid)
|
|
88
90
|
`);
|
|
89
91
|
|
|
@@ -165,7 +165,26 @@ export type ModelDefinition = {
|
|
|
165
165
|
*/
|
|
166
166
|
manyToManyRelation?: boolean;
|
|
167
167
|
|
|
168
|
-
constraints?:
|
|
168
|
+
constraints?: (
|
|
169
|
+
| { kind: 'check'; name: string; expression: string; deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE' }
|
|
170
|
+
| {
|
|
171
|
+
kind: 'exclude';
|
|
172
|
+
name: string;
|
|
173
|
+
using: 'gist';
|
|
174
|
+
elements: ({ column: string; operator: '=' } | { expression: string; operator: '&&' })[];
|
|
175
|
+
where?: string;
|
|
176
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
|
|
177
|
+
}
|
|
178
|
+
| {
|
|
179
|
+
kind: 'constraint_trigger';
|
|
180
|
+
name: string;
|
|
181
|
+
when: 'AFTER' | 'BEFORE';
|
|
182
|
+
events: ('INSERT' | 'UPDATE')[];
|
|
183
|
+
forEach: 'ROW' | 'STATEMENT';
|
|
184
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
|
|
185
|
+
function: { name: string; args?: string[] };
|
|
186
|
+
}
|
|
187
|
+
)[];
|
|
169
188
|
|
|
170
189
|
// temporary fields for the generation of migrations
|
|
171
190
|
deleted?: true;
|
package/src/models/models.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
summonByName,
|
|
41
41
|
typeToField,
|
|
42
42
|
validateCheckConstraint,
|
|
43
|
+
validateExcludeConstraint,
|
|
43
44
|
} from './utils';
|
|
44
45
|
|
|
45
46
|
// These might one day become classes
|
|
@@ -357,7 +358,7 @@ export class EntityModel extends Model {
|
|
|
357
358
|
defaultOrderBy?: OrderBy[];
|
|
358
359
|
fields: EntityField[];
|
|
359
360
|
|
|
360
|
-
constraints?:
|
|
361
|
+
constraints?: EntityModelDefinition['constraints'];
|
|
361
362
|
|
|
362
363
|
// temporary fields for the generation of migrations
|
|
363
364
|
deleted?: true;
|
|
@@ -392,6 +393,8 @@ export class EntityModel extends Model {
|
|
|
392
393
|
for (const constraint of this.constraints) {
|
|
393
394
|
if (constraint.kind === 'check') {
|
|
394
395
|
validateCheckConstraint(this, constraint);
|
|
396
|
+
} else if (constraint.kind === 'exclude') {
|
|
397
|
+
validateExcludeConstraint(this, constraint);
|
|
395
398
|
}
|
|
396
399
|
}
|
|
397
400
|
}
|
package/src/models/utils.ts
CHANGED
|
@@ -259,3 +259,23 @@ export const validateCheckConstraint = (model: EntityModel, constraint: { name:
|
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
};
|
|
262
|
+
|
|
263
|
+
export const validateExcludeConstraint = (
|
|
264
|
+
model: EntityModel,
|
|
265
|
+
constraint: {
|
|
266
|
+
name: string;
|
|
267
|
+
elements: ({ column: string; operator: string } | { expression: string; operator: string })[];
|
|
268
|
+
},
|
|
269
|
+
): void => {
|
|
270
|
+
const validColumnNames = new Set(model.fields.map((f) => getColumnName(f)));
|
|
271
|
+
for (const el of constraint.elements) {
|
|
272
|
+
if ('column' in el) {
|
|
273
|
+
if (!validColumnNames.has(el.column)) {
|
|
274
|
+
const validList = [...validColumnNames].sort().join(', ');
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Exclude constraint "${constraint.name}" references column "${el.column}" which does not exist on model ${model.name}. Valid columns: ${validList}`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
};
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { ModelDefinitions, Models } from '../../src/models';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
extractColumnReferencesFromCheckExpression,
|
|
4
|
+
validateCheckConstraint,
|
|
5
|
+
validateExcludeConstraint,
|
|
6
|
+
} from '../../src/models/utils';
|
|
3
7
|
|
|
4
|
-
describe('
|
|
8
|
+
describe('constraints', () => {
|
|
5
9
|
const modelDefinitions: ModelDefinitions = [
|
|
6
10
|
{
|
|
7
11
|
kind: 'entity',
|
|
@@ -80,4 +84,96 @@ describe('check constraints', () => {
|
|
|
80
84
|
).toThrow(/Valid columns:.*\bscore\b.*\bstatus\b/);
|
|
81
85
|
});
|
|
82
86
|
});
|
|
87
|
+
|
|
88
|
+
describe('validateExcludeConstraint', () => {
|
|
89
|
+
it('does not throw when column elements reference valid columns', () => {
|
|
90
|
+
expect(() =>
|
|
91
|
+
validateExcludeConstraint(productModel, {
|
|
92
|
+
name: 'valid',
|
|
93
|
+
elements: [
|
|
94
|
+
{ column: 'score', operator: '=' },
|
|
95
|
+
{ expression: 'tsrange("startDate", "endDate")', operator: '&&' },
|
|
96
|
+
],
|
|
97
|
+
}),
|
|
98
|
+
).not.toThrow();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('does not throw when only expression elements', () => {
|
|
102
|
+
expect(() =>
|
|
103
|
+
validateExcludeConstraint(productModel, {
|
|
104
|
+
name: 'valid',
|
|
105
|
+
elements: [{ expression: 'tsrange(now(), now())', operator: '&&' }],
|
|
106
|
+
}),
|
|
107
|
+
).not.toThrow();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('uses relation column name when validating', () => {
|
|
111
|
+
expect(() =>
|
|
112
|
+
validateExcludeConstraint(productModel, {
|
|
113
|
+
name: 'valid',
|
|
114
|
+
elements: [{ column: 'parentId', operator: '=' }],
|
|
115
|
+
}),
|
|
116
|
+
).not.toThrow();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('throws when column element references missing column', () => {
|
|
120
|
+
expect(() =>
|
|
121
|
+
validateExcludeConstraint(productModel, {
|
|
122
|
+
name: 'bad',
|
|
123
|
+
elements: [{ column: 'unknown_column', operator: '=' }],
|
|
124
|
+
}),
|
|
125
|
+
).toThrow(
|
|
126
|
+
/Exclude constraint "bad" references column "unknown_column" which does not exist on model Product/,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('exclude and constraint_trigger constraints', () => {
|
|
132
|
+
const allocationDefinitions: ModelDefinitions = [
|
|
133
|
+
{
|
|
134
|
+
kind: 'entity',
|
|
135
|
+
name: 'PortfolioAllocation',
|
|
136
|
+
fields: [
|
|
137
|
+
{ name: 'portfolio', kind: 'relation', type: 'Portfolio', reverse: 'allocations' },
|
|
138
|
+
{ name: 'startDate', type: 'DateTime' },
|
|
139
|
+
{ name: 'endDate', type: 'DateTime' },
|
|
140
|
+
{ name: 'deleted', type: 'Boolean', nonNull: true, defaultValue: false },
|
|
141
|
+
],
|
|
142
|
+
constraints: [
|
|
143
|
+
{
|
|
144
|
+
kind: 'exclude',
|
|
145
|
+
name: 'no_overlap_per_portfolio',
|
|
146
|
+
using: 'gist',
|
|
147
|
+
elements: [
|
|
148
|
+
{ column: 'portfolioId', operator: '=' },
|
|
149
|
+
{
|
|
150
|
+
expression: 'tsrange("startDate", COALESCE("endDate", \'infinity\'::timestamptz))',
|
|
151
|
+
operator: '&&',
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
where: '"deleted" = false',
|
|
155
|
+
deferrable: 'INITIALLY DEFERRED',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
kind: 'constraint_trigger',
|
|
159
|
+
name: 'contiguous_periods',
|
|
160
|
+
when: 'AFTER',
|
|
161
|
+
events: ['INSERT', 'UPDATE'],
|
|
162
|
+
forEach: 'ROW',
|
|
163
|
+
deferrable: 'INITIALLY DEFERRED',
|
|
164
|
+
function: { name: 'contiguous_periods_check' },
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
kind: 'entity',
|
|
170
|
+
name: 'Portfolio',
|
|
171
|
+
fields: [{ name: 'name', type: 'String' }],
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
it('constructs models with exclude and constraint_trigger without throwing', () => {
|
|
176
|
+
expect(() => new Models(allocationDefinitions)).not.toThrow();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
83
179
|
});
|