@smartive/graphql-magic 23.6.1-next.2 → 23.7.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/.gqmrc.json +2 -1
- package/CHANGELOG.md +3 -3
- package/dist/bin/gqm.cjs +278 -115
- package/dist/cjs/index.cjs +288 -125
- package/dist/esm/db/generate.js +5 -5
- package/dist/esm/db/generate.js.map +1 -1
- package/dist/esm/migrations/generate.d.ts +13 -1
- package/dist/esm/migrations/generate.js +197 -41
- package/dist/esm/migrations/generate.js.map +1 -1
- package/dist/esm/migrations/index.d.ts +2 -1
- package/dist/esm/migrations/index.js +2 -1
- package/dist/esm/migrations/index.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 +16 -7
- package/dist/esm/models/utils.js +16 -6
- package/dist/esm/models/utils.js.map +1 -1
- package/dist/esm/permissions/check.js +2 -2
- package/dist/esm/permissions/check.js.map +1 -1
- package/dist/esm/resolvers/mutations.js +6 -6
- package/dist/esm/resolvers/mutations.js.map +1 -1
- package/dist/esm/resolvers/resolvers.js +3 -9
- package/dist/esm/resolvers/resolvers.js.map +1 -1
- package/dist/esm/schema/generate.js +3 -9
- package/dist/esm/schema/generate.js.map +1 -1
- package/docker-compose.yml +2 -3
- package/docs/docs/2-models.md +18 -4
- package/docs/docs/5-migrations.md +11 -5
- package/package.json +3 -3
- package/src/bin/gqm/parse-knexfile.ts +1 -0
- package/src/bin/gqm/settings.ts +4 -0
- package/src/db/generate.ts +5 -15
- package/src/migrations/generate.ts +257 -42
- package/src/migrations/index.ts +2 -1
- package/src/models/model-definitions.ts +20 -1
- package/src/models/models.ts +4 -1
- package/src/models/utils.ts +27 -8
- package/src/permissions/check.ts +2 -2
- package/src/resolvers/mutations.ts +6 -6
- package/src/resolvers/resolvers.ts +7 -13
- package/src/schema/generate.ts +28 -26
- package/tests/unit/constraints.spec.ts +98 -2
- package/tests/unit/generate-as.spec.ts +6 -6
- package/tests/utils/functions.ts +1 -0
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
and,
|
|
10
10
|
get,
|
|
11
11
|
isCreatableModel,
|
|
12
|
-
isGenerateAsField,
|
|
13
12
|
isInherited,
|
|
13
|
+
isStoredInDatabase,
|
|
14
14
|
isUpdatableField,
|
|
15
15
|
isUpdatableModel,
|
|
16
16
|
modelNeedsTable,
|
|
@@ -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 -> exclude definition (normalized) */
|
|
46
|
+
private existingExcludeConstraints: Record<string, Map<string, string>> = {};
|
|
47
|
+
/** table name -> constraint name -> trigger definition (normalized) */
|
|
48
|
+
private existingConstraintTriggers: Record<string, Map<string, string>> = {};
|
|
44
49
|
private uuidUsed?: boolean;
|
|
45
50
|
private nowUsed?: boolean;
|
|
46
51
|
public needsMigration = false;
|
|
@@ -83,9 +88,56 @@ 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, this.normalizeExcludeDef(row.constraint_def));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const triggerResult = await schema.knex.raw(
|
|
110
|
+
`SELECT c.conrelid::regclass::text as table_name, c.conname as constraint_name, pg_get_triggerdef(t.oid) as trigger_def
|
|
111
|
+
FROM pg_constraint c
|
|
112
|
+
JOIN pg_trigger t ON t.tgconstraint = c.oid
|
|
113
|
+
JOIN pg_namespace n ON c.connamespace = n.oid
|
|
114
|
+
WHERE n.nspname = 'public' AND c.contype = 't'`,
|
|
115
|
+
);
|
|
116
|
+
const triggerRows: { table_name: string; constraint_name: string; trigger_def: string }[] =
|
|
117
|
+
'rows' in triggerResult && Array.isArray((triggerResult as { rows: unknown }).rows)
|
|
118
|
+
? (triggerResult as { rows: { table_name: string; constraint_name: string; trigger_def: string }[] }).rows
|
|
119
|
+
: [];
|
|
120
|
+
for (const row of triggerRows) {
|
|
121
|
+
const tableName = row.table_name.split('.').pop()?.replace(/^"|"$/g, '') ?? row.table_name;
|
|
122
|
+
if (!this.existingConstraintTriggers[tableName]) {
|
|
123
|
+
this.existingConstraintTriggers[tableName] = new Map();
|
|
124
|
+
}
|
|
125
|
+
this.existingConstraintTriggers[tableName].set(row.constraint_name, this.normalizeTriggerDef(row.trigger_def));
|
|
126
|
+
}
|
|
127
|
+
|
|
86
128
|
const up: Callbacks = [];
|
|
87
129
|
const down: Callbacks = [];
|
|
88
130
|
|
|
131
|
+
const needsBtreeGist = models.entities.some((model) =>
|
|
132
|
+
model.constraints?.some((c) => c.kind === 'exclude' && c.elements.some((el) => 'column' in el && el.operator === '=')),
|
|
133
|
+
);
|
|
134
|
+
if (needsBtreeGist) {
|
|
135
|
+
up.unshift(() => {
|
|
136
|
+
this.writer.writeLine(`await knex.raw('CREATE EXTENSION IF NOT EXISTS btree_gist');`);
|
|
137
|
+
this.writer.blankLine();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
89
141
|
this.createEnums(
|
|
90
142
|
this.models.enums.filter((enm) => !enums.includes(lowerFirst(enm.name))),
|
|
91
143
|
up,
|
|
@@ -178,10 +230,22 @@ export class MigrationGenerator {
|
|
|
178
230
|
if (entry.kind === 'check') {
|
|
179
231
|
validateCheckConstraint(model, entry);
|
|
180
232
|
const table = model.name;
|
|
181
|
-
const constraintName = this.
|
|
182
|
-
const expression = entry.expression;
|
|
233
|
+
const constraintName = this.getConstraintName(model, entry, i);
|
|
183
234
|
up.push(() => {
|
|
184
|
-
this.addCheckConstraint(table, constraintName, expression);
|
|
235
|
+
this.addCheckConstraint(table, constraintName, entry.expression, entry.deferrable);
|
|
236
|
+
});
|
|
237
|
+
} else if (entry.kind === 'exclude') {
|
|
238
|
+
validateExcludeConstraint(model, entry);
|
|
239
|
+
const table = model.name;
|
|
240
|
+
const constraintName = this.getConstraintName(model, entry, i);
|
|
241
|
+
up.push(() => {
|
|
242
|
+
this.addExcludeConstraint(table, constraintName, entry);
|
|
243
|
+
});
|
|
244
|
+
} else if (entry.kind === 'constraint_trigger') {
|
|
245
|
+
const table = model.name;
|
|
246
|
+
const constraintName = this.getConstraintName(model, entry, i);
|
|
247
|
+
up.push(() => {
|
|
248
|
+
this.addConstraintTrigger(table, constraintName, entry);
|
|
185
249
|
});
|
|
186
250
|
}
|
|
187
251
|
}
|
|
@@ -237,35 +301,83 @@ export class MigrationGenerator {
|
|
|
237
301
|
this.updateFields(model, existingFields, up, down);
|
|
238
302
|
|
|
239
303
|
if (model.constraints?.length) {
|
|
240
|
-
const
|
|
304
|
+
const existingCheckMap = this.existingCheckConstraints[model.name];
|
|
305
|
+
const existingExcludeMap = this.existingExcludeConstraints[model.name];
|
|
306
|
+
const existingTriggerMap = this.existingConstraintTriggers[model.name];
|
|
241
307
|
for (let i = 0; i < model.constraints.length; i++) {
|
|
242
308
|
const entry = model.constraints[i];
|
|
243
|
-
if (entry.kind !== 'check') {
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
validateCheckConstraint(model, entry);
|
|
247
309
|
const table = model.name;
|
|
248
|
-
const constraintName = this.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
310
|
+
const constraintName = this.getConstraintName(model, entry, i);
|
|
311
|
+
if (entry.kind === 'check') {
|
|
312
|
+
validateCheckConstraint(model, entry);
|
|
313
|
+
const newExpression = entry.expression;
|
|
314
|
+
const existingExpression = existingCheckMap?.get(constraintName);
|
|
315
|
+
if (existingExpression === undefined) {
|
|
316
|
+
up.push(() => {
|
|
317
|
+
this.addCheckConstraint(table, constraintName, newExpression, entry.deferrable);
|
|
318
|
+
});
|
|
319
|
+
down.push(() => {
|
|
320
|
+
this.dropCheckConstraint(table, constraintName);
|
|
321
|
+
});
|
|
322
|
+
} else if (
|
|
323
|
+
this.normalizeCheckExpression(existingExpression) !== this.normalizeCheckExpression(newExpression)
|
|
324
|
+
) {
|
|
325
|
+
up.push(() => {
|
|
326
|
+
this.dropCheckConstraint(table, constraintName);
|
|
327
|
+
this.addCheckConstraint(table, constraintName, newExpression, entry.deferrable);
|
|
328
|
+
});
|
|
329
|
+
down.push(() => {
|
|
330
|
+
this.dropCheckConstraint(table, constraintName);
|
|
331
|
+
this.addCheckConstraint(table, constraintName, existingExpression);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
} else if (entry.kind === 'exclude') {
|
|
335
|
+
validateExcludeConstraint(model, entry);
|
|
336
|
+
const newDef = this.normalizeExcludeDef(this.buildExcludeDef(entry));
|
|
337
|
+
const existingDef = existingExcludeMap?.get(constraintName);
|
|
338
|
+
if (existingDef === undefined) {
|
|
339
|
+
up.push(() => {
|
|
340
|
+
this.addExcludeConstraint(table, constraintName, entry);
|
|
341
|
+
});
|
|
342
|
+
down.push(() => {
|
|
343
|
+
this.dropExcludeConstraint(table, constraintName);
|
|
344
|
+
});
|
|
345
|
+
} else if (existingDef !== newDef) {
|
|
346
|
+
up.push(() => {
|
|
347
|
+
this.dropExcludeConstraint(table, constraintName);
|
|
348
|
+
this.addExcludeConstraint(table, constraintName, entry);
|
|
349
|
+
});
|
|
350
|
+
down.push(() => {
|
|
351
|
+
this.dropExcludeConstraint(table, constraintName);
|
|
352
|
+
const escaped = this.escapeExpressionForRaw(existingDef);
|
|
353
|
+
this.writer.writeLine(
|
|
354
|
+
`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" ${escaped}\`);`,
|
|
355
|
+
);
|
|
356
|
+
this.writer.blankLine();
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
} else if (entry.kind === 'constraint_trigger') {
|
|
360
|
+
const newDef = this.normalizeTriggerDef(this.buildConstraintTriggerDef(table, constraintName, entry));
|
|
361
|
+
const existingDef = existingTriggerMap?.get(constraintName);
|
|
362
|
+
if (existingDef === undefined) {
|
|
363
|
+
up.push(() => {
|
|
364
|
+
this.addConstraintTrigger(table, constraintName, entry);
|
|
365
|
+
});
|
|
366
|
+
down.push(() => {
|
|
367
|
+
this.dropConstraintTrigger(table, constraintName);
|
|
368
|
+
});
|
|
369
|
+
} else if (existingDef !== newDef) {
|
|
370
|
+
up.push(() => {
|
|
371
|
+
this.dropConstraintTrigger(table, constraintName);
|
|
372
|
+
this.addConstraintTrigger(table, constraintName, entry);
|
|
373
|
+
});
|
|
374
|
+
down.push(() => {
|
|
375
|
+
this.dropConstraintTrigger(table, constraintName);
|
|
376
|
+
const escaped = existingDef.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
|
377
|
+
this.writer.writeLine(`await knex.raw(\`${escaped}\`);`);
|
|
378
|
+
this.writer.blankLine();
|
|
379
|
+
});
|
|
380
|
+
}
|
|
269
381
|
}
|
|
270
382
|
}
|
|
271
383
|
}
|
|
@@ -299,9 +411,7 @@ export class MigrationGenerator {
|
|
|
299
411
|
writer.writeLine(`deleteRootId: row.deleteRootId,`);
|
|
300
412
|
}
|
|
301
413
|
|
|
302
|
-
for (const { name, kind } of model.fields
|
|
303
|
-
.filter(isUpdatableField)
|
|
304
|
-
.filter(not(isGenerateAsField))) {
|
|
414
|
+
for (const { name, kind } of model.fields.filter(and(isUpdatableField, isStoredInDatabase))) {
|
|
305
415
|
const col = kind === 'relation' ? `${name}Id` : name;
|
|
306
416
|
|
|
307
417
|
writer.writeLine(`${col}: row.${col},`);
|
|
@@ -332,11 +442,9 @@ export class MigrationGenerator {
|
|
|
332
442
|
);
|
|
333
443
|
|
|
334
444
|
const missingRevisionFields = model.fields
|
|
335
|
-
.filter(isUpdatableField)
|
|
336
|
-
.filter(not(isGenerateAsField))
|
|
445
|
+
.filter(and(isUpdatableField, isStoredInDatabase))
|
|
337
446
|
.filter(
|
|
338
447
|
({ name, ...field }) =>
|
|
339
|
-
field.kind !== 'custom' &&
|
|
340
448
|
!this.getColumn(revisionTable, field.kind === 'relation' ? field.foreignKey || `${name}Id` : name),
|
|
341
449
|
);
|
|
342
450
|
|
|
@@ -535,7 +643,7 @@ export class MigrationGenerator {
|
|
|
535
643
|
});
|
|
536
644
|
|
|
537
645
|
if (isUpdatableModel(model)) {
|
|
538
|
-
const updatableFields = fields.filter(isUpdatableField)
|
|
646
|
+
const updatableFields = fields.filter(and(isUpdatableField, isStoredInDatabase));
|
|
539
647
|
if (!updatableFields.length) {
|
|
540
648
|
return;
|
|
541
649
|
}
|
|
@@ -589,7 +697,7 @@ export class MigrationGenerator {
|
|
|
589
697
|
});
|
|
590
698
|
|
|
591
699
|
if (isUpdatableModel(model)) {
|
|
592
|
-
const updatableFields = fields.filter(isUpdatableField)
|
|
700
|
+
const updatableFields = fields.filter(and(isUpdatableField, isStoredInDatabase));
|
|
593
701
|
if (!updatableFields.length) {
|
|
594
702
|
return;
|
|
595
703
|
}
|
|
@@ -633,7 +741,7 @@ export class MigrationGenerator {
|
|
|
633
741
|
}
|
|
634
742
|
}
|
|
635
743
|
|
|
636
|
-
for (const field of model.fields.filter(and(isUpdatableField, not(isInherited)))
|
|
744
|
+
for (const field of model.fields.filter(and(isUpdatableField, not(isInherited), isStoredInDatabase))) {
|
|
637
745
|
this.column(field, { setUnique: false, setDefault: false });
|
|
638
746
|
}
|
|
639
747
|
});
|
|
@@ -762,7 +870,7 @@ export class MigrationGenerator {
|
|
|
762
870
|
this.writer.writeLine(`table.renameColumn('${from}', '${to}');`);
|
|
763
871
|
}
|
|
764
872
|
|
|
765
|
-
private
|
|
873
|
+
private getConstraintName(model: EntityModel, entry: { kind: string; name: string }, index: number): string {
|
|
766
874
|
return `${model.name}_${entry.name}_${entry.kind}_${index}`;
|
|
767
875
|
}
|
|
768
876
|
|
|
@@ -770,15 +878,37 @@ export class MigrationGenerator {
|
|
|
770
878
|
return expr.replace(/\s+/g, ' ').trim();
|
|
771
879
|
}
|
|
772
880
|
|
|
881
|
+
private normalizeExcludeDef(def: string): string {
|
|
882
|
+
return def
|
|
883
|
+
.replace(/\s+/g, ' ')
|
|
884
|
+
.replace(/\s*\(\s*/g, '(')
|
|
885
|
+
.replace(/\s*\)\s*/g, ')')
|
|
886
|
+
.trim();
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
private normalizeTriggerDef(def: string): string {
|
|
890
|
+
return def
|
|
891
|
+
.replace(/\s+/g, ' ')
|
|
892
|
+
.replace(/\s*\(\s*/g, '(')
|
|
893
|
+
.replace(/\s*\)\s*/g, ')')
|
|
894
|
+
.trim();
|
|
895
|
+
}
|
|
896
|
+
|
|
773
897
|
/** Escape expression for embedding inside a template literal in generated code */
|
|
774
898
|
private escapeExpressionForRaw(expr: string): string {
|
|
775
899
|
return expr.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
|
776
900
|
}
|
|
777
901
|
|
|
778
|
-
private addCheckConstraint(
|
|
902
|
+
private addCheckConstraint(
|
|
903
|
+
table: string,
|
|
904
|
+
constraintName: string,
|
|
905
|
+
expression: string,
|
|
906
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE',
|
|
907
|
+
) {
|
|
779
908
|
const escaped = this.escapeExpressionForRaw(expression);
|
|
909
|
+
const deferrableClause = deferrable ? ` DEFERRABLE ${deferrable}` : '';
|
|
780
910
|
this.writer.writeLine(
|
|
781
|
-
`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" CHECK (${escaped})\`);`,
|
|
911
|
+
`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" CHECK (${escaped})${deferrableClause}\`);`,
|
|
782
912
|
);
|
|
783
913
|
this.writer.blankLine();
|
|
784
914
|
}
|
|
@@ -788,6 +918,91 @@ export class MigrationGenerator {
|
|
|
788
918
|
this.writer.blankLine();
|
|
789
919
|
}
|
|
790
920
|
|
|
921
|
+
private buildExcludeDef(entry: {
|
|
922
|
+
using: string;
|
|
923
|
+
elements: ({ column: string; operator: string } | { expression: string; operator: string })[];
|
|
924
|
+
where?: string;
|
|
925
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
|
|
926
|
+
}): string {
|
|
927
|
+
const elementsStr = entry.elements
|
|
928
|
+
.map((el) => ('column' in el ? `"${el.column}" WITH ${el.operator}` : `${el.expression} WITH ${el.operator}`))
|
|
929
|
+
.join(', ');
|
|
930
|
+
const whereClause = entry.where ? ` WHERE (${entry.where})` : '';
|
|
931
|
+
const deferrableClause = entry.deferrable ? ` DEFERRABLE ${entry.deferrable}` : '';
|
|
932
|
+
|
|
933
|
+
return `EXCLUDE USING ${entry.using} (${elementsStr})${whereClause}${deferrableClause}`;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
private addExcludeConstraint(
|
|
937
|
+
table: string,
|
|
938
|
+
constraintName: string,
|
|
939
|
+
entry: {
|
|
940
|
+
using: string;
|
|
941
|
+
elements: ({ column: string; operator: string } | { expression: string; operator: string })[];
|
|
942
|
+
where?: string;
|
|
943
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
|
|
944
|
+
},
|
|
945
|
+
) {
|
|
946
|
+
const def = this.buildExcludeDef(entry);
|
|
947
|
+
const escaped = this.escapeExpressionForRaw(def);
|
|
948
|
+
this.writer.writeLine(`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" ${escaped}\`);`);
|
|
949
|
+
this.writer.blankLine();
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
private dropExcludeConstraint(table: string, constraintName: string) {
|
|
953
|
+
this.writer.writeLine(`await knex.raw('ALTER TABLE "${table}" DROP CONSTRAINT "${constraintName}"');`);
|
|
954
|
+
this.writer.blankLine();
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
private buildConstraintTriggerDef(
|
|
958
|
+
table: string,
|
|
959
|
+
constraintName: string,
|
|
960
|
+
entry: {
|
|
961
|
+
when: 'AFTER' | 'BEFORE';
|
|
962
|
+
events: ('INSERT' | 'UPDATE')[];
|
|
963
|
+
forEach: 'ROW' | 'STATEMENT';
|
|
964
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
|
|
965
|
+
function: { name: string; args?: string[] };
|
|
966
|
+
},
|
|
967
|
+
): string {
|
|
968
|
+
const eventsStr = entry.events.join(' OR ');
|
|
969
|
+
const deferrableClause = entry.deferrable ? ` DEFERRABLE ${entry.deferrable}` : '';
|
|
970
|
+
const argsStr = entry.function.args?.length ? entry.function.args.map((a) => `"${a}"`).join(', ') : '';
|
|
971
|
+
const executeClause = argsStr
|
|
972
|
+
? `EXECUTE FUNCTION ${entry.function.name}(${argsStr})`
|
|
973
|
+
: `EXECUTE FUNCTION ${entry.function.name}()`;
|
|
974
|
+
|
|
975
|
+
return `CREATE CONSTRAINT TRIGGER "${constraintName}" ${entry.when} ${eventsStr} ON "${table}" FOR EACH ${entry.forEach}${deferrableClause} ${executeClause}`;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
private addConstraintTrigger(
|
|
979
|
+
table: string,
|
|
980
|
+
constraintName: string,
|
|
981
|
+
entry: {
|
|
982
|
+
when: 'AFTER' | 'BEFORE';
|
|
983
|
+
events: ('INSERT' | 'UPDATE')[];
|
|
984
|
+
forEach: 'ROW' | 'STATEMENT';
|
|
985
|
+
deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
|
|
986
|
+
function: { name: string; args?: string[] };
|
|
987
|
+
},
|
|
988
|
+
) {
|
|
989
|
+
const eventsStr = entry.events.join(' OR ');
|
|
990
|
+
const deferrableClause = entry.deferrable ? ` DEFERRABLE ${entry.deferrable}` : '';
|
|
991
|
+
const argsStr = entry.function.args?.length ? entry.function.args.map((a) => `"${a}"`).join(', ') : '';
|
|
992
|
+
const executeClause = argsStr
|
|
993
|
+
? `EXECUTE FUNCTION ${entry.function.name}(${argsStr})`
|
|
994
|
+
: `EXECUTE FUNCTION ${entry.function.name}()`;
|
|
995
|
+
this.writer.writeLine(
|
|
996
|
+
`await knex.raw(\`CREATE CONSTRAINT TRIGGER "${constraintName}" ${entry.when} ${eventsStr} ON "${table}" FOR EACH ${entry.forEach}${deferrableClause} ${executeClause}\`);`,
|
|
997
|
+
);
|
|
998
|
+
this.writer.blankLine();
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
private dropConstraintTrigger(table: string, constraintName: string) {
|
|
1002
|
+
this.writer.writeLine(`await knex.raw('ALTER TABLE "${table}" DROP CONSTRAINT "${constraintName}"');`);
|
|
1003
|
+
this.writer.blankLine();
|
|
1004
|
+
}
|
|
1005
|
+
|
|
791
1006
|
private value(value: Value) {
|
|
792
1007
|
if (typeof value === 'string') {
|
|
793
1008
|
return `'${value}'`;
|
package/src/migrations/index.ts
CHANGED
|
@@ -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
|
@@ -59,9 +59,9 @@ export const isInputModel = (model: Model): model is InputModel => model instanc
|
|
|
59
59
|
|
|
60
60
|
export const isInterfaceModel = (model: Model): model is InterfaceModel => model instanceof InterfaceModel;
|
|
61
61
|
|
|
62
|
-
export const isCreatableModel = (model: EntityModel) => model.creatable && model.fields.some(isCreatableField);
|
|
62
|
+
export const isCreatableModel = (model: EntityModel) => !!model.creatable && model.fields.some(isCreatableField);
|
|
63
63
|
|
|
64
|
-
export const isUpdatableModel = (model: EntityModel) => model.updatable && model.fields.some(isUpdatableField);
|
|
64
|
+
export const isUpdatableModel = (model: EntityModel) => !!model.updatable && model.fields.some(isUpdatableField);
|
|
65
65
|
|
|
66
66
|
export const isCreatableField = (field: EntityField) => !field.inherited && !!field.creatable;
|
|
67
67
|
|
|
@@ -88,19 +88,18 @@ export const isQueriableField = ({ queriable }: EntityField) => queriable !== fa
|
|
|
88
88
|
|
|
89
89
|
export const isCustomField = (field: EntityField): field is CustomField => field.kind === 'custom';
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
export const isGenerateAsField = (field: EntityField) => !!field.generateAs;
|
|
91
|
+
export const isDynamicField = (field: EntityField) => !!field.generateAs || isCustomField(field);
|
|
93
92
|
|
|
94
|
-
/** True if field exists as a column in the DB (excludes expression-only fields). */
|
|
95
|
-
export const isStoredInDatabase = (field: EntityField) => field.generateAs?.type !== 'expression';
|
|
93
|
+
/** True if field exists as a column in the DB (excludes custom and expression-only fields). */
|
|
94
|
+
export const isStoredInDatabase = (field: EntityField) => !isCustomField(field) && field.generateAs?.type !== 'expression';
|
|
96
95
|
|
|
97
96
|
export const isVisible = ({ hidden }: EntityField) => hidden !== true;
|
|
98
97
|
|
|
99
98
|
export const isSimpleField = and(not(isRelation), not(isCustomField));
|
|
100
99
|
|
|
101
|
-
export const isUpdatable = ({ updatable }: EntityField) => !!updatable;
|
|
100
|
+
export const isUpdatable = ({ updatable }: EntityField | EntityModel) => !!updatable;
|
|
102
101
|
|
|
103
|
-
export const isCreatable = ({ creatable }: EntityField) => !!creatable;
|
|
102
|
+
export const isCreatable = ({ creatable }: EntityField | EntityModel) => !!creatable;
|
|
104
103
|
|
|
105
104
|
export const isQueriableBy = (role: string) => (field: EntityField) =>
|
|
106
105
|
field.queriable !== false &&
|
|
@@ -260,3 +259,23 @@ export const validateCheckConstraint = (model: EntityModel, constraint: { name:
|
|
|
260
259
|
}
|
|
261
260
|
}
|
|
262
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
|
+
};
|
package/src/permissions/check.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Knex } from 'knex';
|
|
|
2
2
|
import { FullContext } from '../context';
|
|
3
3
|
import { NotFoundError, PermissionError } from '../errors';
|
|
4
4
|
import { EntityModel } from '../models/models';
|
|
5
|
-
import { get,
|
|
5
|
+
import { get, isRelation, isStoredInDatabase } from '../models/utils';
|
|
6
6
|
import { AliasGenerator, getColumnName, hash, ors } from '../resolvers/utils';
|
|
7
7
|
import { PermissionAction, PermissionLink, PermissionStack } from './generate';
|
|
8
8
|
|
|
@@ -156,7 +156,7 @@ export const checkCanWrite = async (
|
|
|
156
156
|
let linked = false;
|
|
157
157
|
|
|
158
158
|
for (const field of model.fields
|
|
159
|
-
.filter(
|
|
159
|
+
.filter(isStoredInDatabase)
|
|
160
160
|
.filter((field) => field.generated || (action === 'CREATE' ? field.creatable : field.updatable))) {
|
|
161
161
|
const fieldPermissions = field[action === 'CREATE' ? 'creatable' : 'updatable'];
|
|
162
162
|
const role = getRole(ctx);
|
|
@@ -4,7 +4,7 @@ import { Context } from '../context';
|
|
|
4
4
|
import { ForbiddenError, GraphQLError } from '../errors';
|
|
5
5
|
import { EntityField, EntityModel } from '../models/models';
|
|
6
6
|
import { Entity, MutationContext, Trigger } from '../models/mutation-hook';
|
|
7
|
-
import { get,
|
|
7
|
+
import { and, get, isDynamicField, isPrimitive, isUpdatableField, it, not, typeToField } from '../models/utils';
|
|
8
8
|
import { applyPermissions, checkCanWrite, getEntityToMutate } from '../permissions/check';
|
|
9
9
|
import { anyDateToLuxon } from '../utils';
|
|
10
10
|
import { resolve } from './resolver';
|
|
@@ -87,7 +87,7 @@ export const createEntity = async (
|
|
|
87
87
|
if (model.parent) {
|
|
88
88
|
const rootInput = {};
|
|
89
89
|
const childInput = { id };
|
|
90
|
-
for (const field of model.fields.filter(not(
|
|
90
|
+
for (const field of model.fields.filter(not(isDynamicField))) {
|
|
91
91
|
const columnName = field.kind === 'relation' ? `${field.name}Id` : field.name;
|
|
92
92
|
if (columnName in normalizedInput) {
|
|
93
93
|
if (field.inherited) {
|
|
@@ -101,7 +101,7 @@ export const createEntity = async (
|
|
|
101
101
|
await ctx.knex(model.name).insert(childInput);
|
|
102
102
|
} else {
|
|
103
103
|
const insertData = { ...normalizedInput };
|
|
104
|
-
for (const field of model.fields.filter(
|
|
104
|
+
for (const field of model.fields.filter(isDynamicField)) {
|
|
105
105
|
const columnName = field.kind === 'relation' ? `${field.name}Id` : field.name;
|
|
106
106
|
delete insertData[columnName];
|
|
107
107
|
}
|
|
@@ -560,7 +560,7 @@ export const createRevision = async (model: EntityModel, data: Entity, ctx: Muta
|
|
|
560
560
|
}
|
|
561
561
|
const childRevisionData = { id: revisionId };
|
|
562
562
|
|
|
563
|
-
for (const field of model.fields.filter((
|
|
563
|
+
for (const field of model.fields.filter(and(isUpdatableField, not(isDynamicField)))) {
|
|
564
564
|
const col = field.kind === 'relation' ? `${field.name}Id` : field.name;
|
|
565
565
|
let value;
|
|
566
566
|
if (field.nonNull && (!(col in data) || col === undefined || col === null)) {
|
|
@@ -636,7 +636,7 @@ const doUpdate = async (model: EntityModel, currentEntity: Entity, update: Entit
|
|
|
636
636
|
if (model.parent) {
|
|
637
637
|
const rootInput = {};
|
|
638
638
|
const childInput = {};
|
|
639
|
-
for (const field of model.fields.filter(not(
|
|
639
|
+
for (const field of model.fields.filter(not(isDynamicField))) {
|
|
640
640
|
const columnName = field.kind === 'relation' ? `${field.name}Id` : field.name;
|
|
641
641
|
if (columnName in update) {
|
|
642
642
|
if (field.inherited) {
|
|
@@ -654,7 +654,7 @@ const doUpdate = async (model: EntityModel, currentEntity: Entity, update: Entit
|
|
|
654
654
|
}
|
|
655
655
|
} else {
|
|
656
656
|
const updateData = { ...update };
|
|
657
|
-
for (const field of model.fields.filter(
|
|
657
|
+
for (const field of model.fields.filter(isDynamicField)) {
|
|
658
658
|
const columnName = field.kind === 'relation' ? `${field.name}Id` : field.name;
|
|
659
659
|
delete updateData[columnName];
|
|
660
660
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Models } from '../models/models';
|
|
2
|
-
import { isRootModel, merge, not, typeToField } from '../models/utils';
|
|
2
|
+
import { and, isCreatable, isRootModel, isUpdatable, merge, not, typeToField } from '../models/utils';
|
|
3
3
|
import { mutationResolver } from './mutations';
|
|
4
4
|
import { queryResolver } from './resolver';
|
|
5
5
|
|
|
@@ -27,18 +27,12 @@ export const getResolvers = (models: Models) => {
|
|
|
27
27
|
]),
|
|
28
28
|
};
|
|
29
29
|
const mutations = [
|
|
30
|
-
...models.entities
|
|
31
|
-
.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
...models.entities
|
|
37
|
-
.filter(not(isRootModel))
|
|
38
|
-
.filter(({ updatable }) => updatable)
|
|
39
|
-
.map((model) => ({
|
|
40
|
-
[`update${model.name}`]: mutationResolver,
|
|
41
|
-
})),
|
|
30
|
+
...models.entities.filter(and(not(isRootModel), isCreatable)).map((model) => ({
|
|
31
|
+
[`create${model.name}`]: mutationResolver,
|
|
32
|
+
})),
|
|
33
|
+
...models.entities.filter(and(not(isRootModel), isUpdatable)).map((model) => ({
|
|
34
|
+
[`update${model.name}`]: mutationResolver,
|
|
35
|
+
})),
|
|
42
36
|
...models.entities
|
|
43
37
|
.filter(not(isRootModel))
|
|
44
38
|
.filter(({ deletable }) => deletable)
|