@smartive/graphql-magic 23.7.0 → 23.8.0-next.2
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 +3 -3
- package/dist/bin/gqm.cjs +307 -34
- package/dist/cjs/index.cjs +309 -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 +18 -1
- package/dist/esm/migrations/generate.js +292 -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 +379 -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
- package/tests/unit/migration-constraints.spec.ts +47 -1
|
@@ -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
|
-
const expression = entry.expression;
|
|
243
|
+
const constraintName = this.getConstraintName(model, entry, i);
|
|
183
244
|
up.push(() => {
|
|
184
|
-
this.addCheckConstraint(table, constraintName, expression);
|
|
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);
|
|
251
|
+
up.push(() => {
|
|
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,86 @@ 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(table, existingConstraint.constraintName, existingConstraint.expression);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
} else if (entry.kind === 'exclude') {
|
|
348
|
+
validateExcludeConstraint(model, entry);
|
|
349
|
+
const newDef = this.normalizeExcludeDef(this.buildExcludeDef(entry));
|
|
350
|
+
const existing = existingExcludeMap?.get(constraintName);
|
|
351
|
+
if (existing === undefined) {
|
|
352
|
+
up.push(() => {
|
|
353
|
+
this.addExcludeConstraint(table, constraintName, entry);
|
|
354
|
+
});
|
|
355
|
+
down.push(() => {
|
|
356
|
+
this.dropExcludeConstraint(table, constraintName);
|
|
357
|
+
});
|
|
358
|
+
} else if (existing.normalized !== newDef) {
|
|
359
|
+
up.push(() => {
|
|
360
|
+
this.dropExcludeConstraint(table, constraintName);
|
|
361
|
+
this.addExcludeConstraint(table, constraintName, entry);
|
|
362
|
+
});
|
|
363
|
+
down.push(() => {
|
|
364
|
+
this.dropExcludeConstraint(table, constraintName);
|
|
365
|
+
const escaped = this.escapeExpressionForRaw(existing.raw);
|
|
366
|
+
this.writer.writeLine(
|
|
367
|
+
`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" ${escaped}\`);`,
|
|
368
|
+
);
|
|
369
|
+
this.writer.blankLine();
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
} else if (entry.kind === 'constraint_trigger') {
|
|
373
|
+
const newDef = this.normalizeTriggerDef(this.buildConstraintTriggerDef(table, constraintName, entry));
|
|
374
|
+
const existing = existingTriggerMap?.get(constraintName);
|
|
375
|
+
if (existing === undefined) {
|
|
376
|
+
up.push(() => {
|
|
377
|
+
this.addConstraintTrigger(table, constraintName, entry);
|
|
378
|
+
});
|
|
379
|
+
down.push(() => {
|
|
380
|
+
this.dropConstraintTrigger(table, constraintName);
|
|
381
|
+
});
|
|
382
|
+
} else if (existing.normalized !== newDef) {
|
|
383
|
+
up.push(() => {
|
|
384
|
+
this.dropConstraintTrigger(table, constraintName);
|
|
385
|
+
this.addConstraintTrigger(table, constraintName, entry);
|
|
386
|
+
});
|
|
387
|
+
down.push(() => {
|
|
388
|
+
this.dropConstraintTrigger(table, constraintName);
|
|
389
|
+
const escaped = existing.raw.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
|
390
|
+
this.writer.writeLine(`await knex.raw(\`${escaped}\`);`);
|
|
391
|
+
this.writer.blankLine();
|
|
392
|
+
});
|
|
393
|
+
}
|
|
272
394
|
}
|
|
273
395
|
}
|
|
274
396
|
}
|
|
@@ -761,10 +883,141 @@ export class MigrationGenerator {
|
|
|
761
883
|
this.writer.writeLine(`table.renameColumn('${from}', '${to}');`);
|
|
762
884
|
}
|
|
763
885
|
|
|
764
|
-
private
|
|
886
|
+
private getConstraintName(model: EntityModel, entry: { kind: string; name: string }, index: number): string {
|
|
765
887
|
return `${model.name}_${entry.name}_${entry.kind}_${index}`;
|
|
766
888
|
}
|
|
767
889
|
|
|
890
|
+
private static readonly SQL_KEYWORDS = new Set([
|
|
891
|
+
'and',
|
|
892
|
+
'or',
|
|
893
|
+
'not',
|
|
894
|
+
'in',
|
|
895
|
+
'is',
|
|
896
|
+
'null',
|
|
897
|
+
'true',
|
|
898
|
+
'false',
|
|
899
|
+
'between',
|
|
900
|
+
'like',
|
|
901
|
+
'exists',
|
|
902
|
+
'all',
|
|
903
|
+
'any',
|
|
904
|
+
'asc',
|
|
905
|
+
'desc',
|
|
906
|
+
'with',
|
|
907
|
+
'using',
|
|
908
|
+
'as',
|
|
909
|
+
'on',
|
|
910
|
+
'infinity',
|
|
911
|
+
'extract',
|
|
912
|
+
'current_date',
|
|
913
|
+
'current_timestamp',
|
|
914
|
+
]);
|
|
915
|
+
|
|
916
|
+
private static readonly LITERAL_PLACEHOLDER = '\uE000';
|
|
917
|
+
|
|
918
|
+
private static readonly IDENT_PLACEHOLDER = '\uE001';
|
|
919
|
+
|
|
920
|
+
private normalizeSqlIdentifiers(s: string): string {
|
|
921
|
+
const literals: string[] = [];
|
|
922
|
+
let result = s.replace(/'([^']|'')*'/g, (lit) => {
|
|
923
|
+
literals.push(lit);
|
|
924
|
+
|
|
925
|
+
return `${MigrationGenerator.LITERAL_PLACEHOLDER}${literals.length - 1}${MigrationGenerator.LITERAL_PLACEHOLDER}`;
|
|
926
|
+
});
|
|
927
|
+
const quotedIdents: string[] = [];
|
|
928
|
+
result = result.replace(/"([^"]*)"/g, (_, ident) => {
|
|
929
|
+
quotedIdents.push(`"${ident.toLowerCase()}"`);
|
|
930
|
+
|
|
931
|
+
return `${MigrationGenerator.IDENT_PLACEHOLDER}${quotedIdents.length - 1}${MigrationGenerator.IDENT_PLACEHOLDER}`;
|
|
932
|
+
});
|
|
933
|
+
result = result.replace(/\b([a-zA-Z_][a-zA-Z0-9_]*)\b(?!\s*\()/g, (match) =>
|
|
934
|
+
MigrationGenerator.SQL_KEYWORDS.has(match.toLowerCase()) ? match : `"${match.toLowerCase()}"`,
|
|
935
|
+
);
|
|
936
|
+
for (let i = 0; i < quotedIdents.length; i++) {
|
|
937
|
+
result = result.replace(
|
|
938
|
+
new RegExp(`${MigrationGenerator.IDENT_PLACEHOLDER}${i}${MigrationGenerator.IDENT_PLACEHOLDER}`, 'g'),
|
|
939
|
+
quotedIdents[i],
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
for (let i = 0; i < literals.length; i++) {
|
|
943
|
+
result = result.replace(
|
|
944
|
+
new RegExp(`${MigrationGenerator.LITERAL_PLACEHOLDER}${i}${MigrationGenerator.LITERAL_PLACEHOLDER}`, 'g'),
|
|
945
|
+
literals[i],
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
return result;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
private normalizeExcludeDef(def: string): string {
|
|
953
|
+
let s = def
|
|
954
|
+
.replace(/\s+/g, ' ')
|
|
955
|
+
.replace(/\s*\(\s*/g, '(')
|
|
956
|
+
.replace(/\s*\)\s*/g, ')')
|
|
957
|
+
.replace(/::\s*timestamp\s+with\s+time\s+zone\b/gi, '::timestamptz')
|
|
958
|
+
.replace(/::\s*timestamp\s+without\s+time\s+zone\b/gi, '::timestamp')
|
|
959
|
+
.trim();
|
|
960
|
+
|
|
961
|
+
const whereMatch = s.match(/\bWHERE\s*\(/i);
|
|
962
|
+
if (whereMatch) {
|
|
963
|
+
const openParen = (whereMatch.index ?? 0) + whereMatch[0].length - 1;
|
|
964
|
+
const closeParen = this.findMatchingParen(s, openParen);
|
|
965
|
+
if (closeParen !== -1) {
|
|
966
|
+
let cond = s.slice(openParen + 1, closeParen).trim();
|
|
967
|
+
while (this.isWrappedByOuterParentheses(cond)) {
|
|
968
|
+
cond = cond.slice(1, -1).trim();
|
|
969
|
+
}
|
|
970
|
+
s = s.slice(0, openParen + 1) + cond + s.slice(closeParen);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
return this.normalizeSqlIdentifiers(s);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
private findMatchingParen(s: string, openIndex: number): number {
|
|
978
|
+
let depth = 1;
|
|
979
|
+
let inSingleQuote = false;
|
|
980
|
+
for (let i = openIndex + 1; i < s.length; i++) {
|
|
981
|
+
const char = s[i];
|
|
982
|
+
const next = s[i + 1];
|
|
983
|
+
|
|
984
|
+
if (char === "'") {
|
|
985
|
+
if (inSingleQuote && next === "'") {
|
|
986
|
+
i++;
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
inSingleQuote = !inSingleQuote;
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if (inSingleQuote) {
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if (char === '(') {
|
|
998
|
+
depth++;
|
|
999
|
+
} else if (char === ')') {
|
|
1000
|
+
depth--;
|
|
1001
|
+
if (depth === 0) {
|
|
1002
|
+
return i;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
return -1;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
private normalizeTriggerDef(def: string): string {
|
|
1011
|
+
const s = def
|
|
1012
|
+
.replace(/\s+/g, ' ')
|
|
1013
|
+
.replace(/\s*\(\s*/g, '(')
|
|
1014
|
+
.replace(/\s*\)\s*/g, ')')
|
|
1015
|
+
.replace(/\bON\s+[a-zA-Z_][a-zA-Z0-9_]*\./gi, 'ON ')
|
|
1016
|
+
.trim();
|
|
1017
|
+
|
|
1018
|
+
return this.normalizeSqlIdentifiers(s);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
768
1021
|
private normalizeCheckExpression(expr: string): string {
|
|
769
1022
|
let normalized = expr.replace(/\s+/g, ' ').trim();
|
|
770
1023
|
while (this.isWrappedByOuterParentheses(normalized)) {
|
|
@@ -949,10 +1202,16 @@ export class MigrationGenerator {
|
|
|
949
1202
|
return expr.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
|
950
1203
|
}
|
|
951
1204
|
|
|
952
|
-
private addCheckConstraint(
|
|
1205
|
+
private addCheckConstraint(
|
|
1206
|
+
table: string,
|
|
1207
|
+
constraintName: string,
|
|
1208
|
+
expression: string,
|
|
1209
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE',
|
|
1210
|
+
) {
|
|
953
1211
|
const escaped = this.escapeExpressionForRaw(expression);
|
|
1212
|
+
const deferrableClause = deferrable ? ` DEFERRABLE ${deferrable}` : '';
|
|
954
1213
|
this.writer.writeLine(
|
|
955
|
-
`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" CHECK (${escaped})\`);`,
|
|
1214
|
+
`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" CHECK (${escaped})${deferrableClause}\`);`,
|
|
956
1215
|
);
|
|
957
1216
|
this.writer.blankLine();
|
|
958
1217
|
}
|
|
@@ -962,6 +1221,91 @@ export class MigrationGenerator {
|
|
|
962
1221
|
this.writer.blankLine();
|
|
963
1222
|
}
|
|
964
1223
|
|
|
1224
|
+
private buildExcludeDef(entry: {
|
|
1225
|
+
using: string;
|
|
1226
|
+
elements: ({ column: string; operator: string } | { expression: string; operator: string })[];
|
|
1227
|
+
where?: string;
|
|
1228
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
|
|
1229
|
+
}): string {
|
|
1230
|
+
const elementsStr = entry.elements
|
|
1231
|
+
.map((el) => ('column' in el ? `"${el.column}" WITH ${el.operator}` : `${el.expression} WITH ${el.operator}`))
|
|
1232
|
+
.join(', ');
|
|
1233
|
+
const whereClause = entry.where ? ` WHERE (${entry.where})` : '';
|
|
1234
|
+
const deferrableClause = entry.deferrable ? ` DEFERRABLE ${entry.deferrable}` : '';
|
|
1235
|
+
|
|
1236
|
+
return `EXCLUDE USING ${entry.using} (${elementsStr})${whereClause}${deferrableClause}`;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
private addExcludeConstraint(
|
|
1240
|
+
table: string,
|
|
1241
|
+
constraintName: string,
|
|
1242
|
+
entry: {
|
|
1243
|
+
using: string;
|
|
1244
|
+
elements: ({ column: string; operator: string } | { expression: string; operator: string })[];
|
|
1245
|
+
where?: string;
|
|
1246
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
|
|
1247
|
+
},
|
|
1248
|
+
) {
|
|
1249
|
+
const def = this.buildExcludeDef(entry);
|
|
1250
|
+
const escaped = this.escapeExpressionForRaw(def);
|
|
1251
|
+
this.writer.writeLine(`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" ${escaped}\`);`);
|
|
1252
|
+
this.writer.blankLine();
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
private dropExcludeConstraint(table: string, constraintName: string) {
|
|
1256
|
+
this.writer.writeLine(`await knex.raw('ALTER TABLE "${table}" DROP CONSTRAINT "${constraintName}"');`);
|
|
1257
|
+
this.writer.blankLine();
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
private buildConstraintTriggerDef(
|
|
1261
|
+
table: string,
|
|
1262
|
+
constraintName: string,
|
|
1263
|
+
entry: {
|
|
1264
|
+
when: 'AFTER' | 'BEFORE';
|
|
1265
|
+
events: ('INSERT' | 'UPDATE')[];
|
|
1266
|
+
forEach: 'ROW' | 'STATEMENT';
|
|
1267
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
|
|
1268
|
+
function: { name: string; args?: string[] };
|
|
1269
|
+
},
|
|
1270
|
+
): string {
|
|
1271
|
+
const eventsStr = entry.events.join(' OR ');
|
|
1272
|
+
const deferrableClause = entry.deferrable ? ` DEFERRABLE ${entry.deferrable}` : '';
|
|
1273
|
+
const argsStr = entry.function.args?.length ? entry.function.args.map((a) => `"${a}"`).join(', ') : '';
|
|
1274
|
+
const executeClause = argsStr
|
|
1275
|
+
? `EXECUTE FUNCTION ${entry.function.name}(${argsStr})`
|
|
1276
|
+
: `EXECUTE FUNCTION ${entry.function.name}()`;
|
|
1277
|
+
|
|
1278
|
+
return `CREATE CONSTRAINT TRIGGER "${constraintName}" ${entry.when} ${eventsStr} ON "${table}"${deferrableClause} FOR EACH ${entry.forEach} ${executeClause}`;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
private addConstraintTrigger(
|
|
1282
|
+
table: string,
|
|
1283
|
+
constraintName: string,
|
|
1284
|
+
entry: {
|
|
1285
|
+
when: 'AFTER' | 'BEFORE';
|
|
1286
|
+
events: ('INSERT' | 'UPDATE')[];
|
|
1287
|
+
forEach: 'ROW' | 'STATEMENT';
|
|
1288
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
|
|
1289
|
+
function: { name: string; args?: string[] };
|
|
1290
|
+
},
|
|
1291
|
+
) {
|
|
1292
|
+
const eventsStr = entry.events.join(' OR ');
|
|
1293
|
+
const deferrableClause = entry.deferrable ? ` DEFERRABLE ${entry.deferrable}` : '';
|
|
1294
|
+
const argsStr = entry.function.args?.length ? entry.function.args.map((a) => `"${a}"`).join(', ') : '';
|
|
1295
|
+
const executeClause = argsStr
|
|
1296
|
+
? `EXECUTE FUNCTION ${entry.function.name}(${argsStr})`
|
|
1297
|
+
: `EXECUTE FUNCTION ${entry.function.name}()`;
|
|
1298
|
+
this.writer.writeLine(
|
|
1299
|
+
`await knex.raw(\`CREATE CONSTRAINT TRIGGER "${constraintName}" ${entry.when} ${eventsStr} ON "${table}"${deferrableClause} FOR EACH ${entry.forEach} ${executeClause}\`);`,
|
|
1300
|
+
);
|
|
1301
|
+
this.writer.blankLine();
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
private dropConstraintTrigger(table: string, constraintName: string) {
|
|
1305
|
+
this.writer.writeLine(`await knex.raw('ALTER TABLE "${table}" DROP CONSTRAINT "${constraintName}"');`);
|
|
1306
|
+
this.writer.blankLine();
|
|
1307
|
+
}
|
|
1308
|
+
|
|
965
1309
|
private value(value: Value) {
|
|
966
1310
|
if (typeof value === 'string') {
|
|
967
1311
|
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
|
});
|