@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.
Files changed (46) hide show
  1. package/.gqmrc.json +2 -1
  2. package/CHANGELOG.md +3 -3
  3. package/dist/bin/gqm.cjs +278 -115
  4. package/dist/cjs/index.cjs +288 -125
  5. package/dist/esm/db/generate.js +5 -5
  6. package/dist/esm/db/generate.js.map +1 -1
  7. package/dist/esm/migrations/generate.d.ts +13 -1
  8. package/dist/esm/migrations/generate.js +197 -41
  9. package/dist/esm/migrations/generate.js.map +1 -1
  10. package/dist/esm/migrations/index.d.ts +2 -1
  11. package/dist/esm/migrations/index.js +2 -1
  12. package/dist/esm/migrations/index.js.map +1 -1
  13. package/dist/esm/models/model-definitions.d.ts +27 -2
  14. package/dist/esm/models/models.d.ts +1 -5
  15. package/dist/esm/models/models.js +4 -1
  16. package/dist/esm/models/models.js.map +1 -1
  17. package/dist/esm/models/utils.d.ts +16 -7
  18. package/dist/esm/models/utils.js +16 -6
  19. package/dist/esm/models/utils.js.map +1 -1
  20. package/dist/esm/permissions/check.js +2 -2
  21. package/dist/esm/permissions/check.js.map +1 -1
  22. package/dist/esm/resolvers/mutations.js +6 -6
  23. package/dist/esm/resolvers/mutations.js.map +1 -1
  24. package/dist/esm/resolvers/resolvers.js +3 -9
  25. package/dist/esm/resolvers/resolvers.js.map +1 -1
  26. package/dist/esm/schema/generate.js +3 -9
  27. package/dist/esm/schema/generate.js.map +1 -1
  28. package/docker-compose.yml +2 -3
  29. package/docs/docs/2-models.md +18 -4
  30. package/docs/docs/5-migrations.md +11 -5
  31. package/package.json +3 -3
  32. package/src/bin/gqm/parse-knexfile.ts +1 -0
  33. package/src/bin/gqm/settings.ts +4 -0
  34. package/src/db/generate.ts +5 -15
  35. package/src/migrations/generate.ts +257 -42
  36. package/src/migrations/index.ts +2 -1
  37. package/src/models/model-definitions.ts +20 -1
  38. package/src/models/models.ts +4 -1
  39. package/src/models/utils.ts +27 -8
  40. package/src/permissions/check.ts +2 -2
  41. package/src/resolvers/mutations.ts +6 -6
  42. package/src/resolvers/resolvers.ts +7 -13
  43. package/src/schema/generate.ts +28 -26
  44. package/tests/unit/constraints.spec.ts +98 -2
  45. package/tests/unit/generate-as.spec.ts +6 -6
  46. 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.getCheckConstraintName(model, entry, i);
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 existingMap = this.existingCheckConstraints[model.name];
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.getCheckConstraintName(model, entry, i);
249
- const newExpression = entry.expression;
250
- const existingExpression = existingMap?.get(constraintName);
251
- if (existingExpression === undefined) {
252
- up.push(() => {
253
- this.addCheckConstraint(table, constraintName, newExpression);
254
- });
255
- down.push(() => {
256
- this.dropCheckConstraint(table, constraintName);
257
- });
258
- } else if (
259
- this.normalizeCheckExpression(existingExpression) !== this.normalizeCheckExpression(newExpression)
260
- ) {
261
- up.push(() => {
262
- this.dropCheckConstraint(table, constraintName);
263
- this.addCheckConstraint(table, constraintName, newExpression);
264
- });
265
- down.push(() => {
266
- this.dropCheckConstraint(table, constraintName);
267
- this.addCheckConstraint(table, constraintName, existingExpression);
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).filter(not(isGenerateAsField));
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).filter(not(isGenerateAsField));
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))).filter(not(isGenerateAsField))) {
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 getCheckConstraintName(model: EntityModel, entry: { kind: string; name: string }, index: number): string {
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(table: string, constraintName: string, expression: string) {
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}'`;
@@ -1,5 +1,6 @@
1
1
  // created from 'create-ts-index'
2
2
 
3
- export * from './generate';
4
3
  export * from './generate-functions';
4
+ export * from './generate';
5
+ export * from './types';
5
6
  export * from './update-functions';
@@ -165,7 +165,26 @@ export type ModelDefinition = {
165
165
  */
166
166
  manyToManyRelation?: boolean;
167
167
 
168
- constraints?: { kind: 'check'; name: string; expression: string }[];
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;
@@ -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?: { kind: 'check'; name: string; expression: string }[];
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
  }
@@ -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
- /** True if field is computed (generateAs); not user-settable in insert/update. */
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
+ };
@@ -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, isGenerateAsField, isRelation, not } from '../models/utils';
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(not(isGenerateAsField))
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, isGenerateAsField, isPrimitive, it, not, typeToField } from '../models/utils';
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(isGenerateAsField))) {
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(isGenerateAsField)) {
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(({ updatable }) => updatable).filter(not(isGenerateAsField))) {
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(isGenerateAsField))) {
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(isGenerateAsField)) {
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
- .filter(not(isRootModel))
32
- .filter(({ creatable }) => creatable)
33
- .map((model) => ({
34
- [`create${model.name}`]: mutationResolver,
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)