@smartive/graphql-magic 23.7.0 → 23.8.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.getCheckConstraintName(model, entry, i);
182
- const expression = entry.expression;
243
+ const constraintName = this.getConstraintName(model, entry, i);
244
+ up.push(() => {
245
+ this.addCheckConstraint(table, constraintName, entry.expression, entry.deferrable);
246
+ });
247
+ } else if (entry.kind === 'exclude') {
248
+ validateExcludeConstraint(model, entry);
249
+ const table = model.name;
250
+ const constraintName = this.getConstraintName(model, entry, i);
183
251
  up.push(() => {
184
- this.addCheckConstraint(table, constraintName, expression);
252
+ this.addExcludeConstraint(table, constraintName, entry);
253
+ });
254
+ } else if (entry.kind === 'constraint_trigger') {
255
+ const table = model.name;
256
+ const constraintName = this.getConstraintName(model, entry, i);
257
+ up.push(() => {
258
+ this.addConstraintTrigger(table, constraintName, entry);
185
259
  });
186
260
  }
187
261
  }
@@ -237,38 +311,90 @@ export class MigrationGenerator {
237
311
  this.updateFields(model, existingFields, up, down);
238
312
 
239
313
  if (model.constraints?.length) {
314
+ const existingExcludeMap = this.existingExcludeConstraints[model.name];
315
+ const existingTriggerMap = this.existingConstraintTriggers[model.name];
240
316
  for (let i = 0; i < model.constraints.length; i++) {
241
317
  const entry = model.constraints[i];
242
- if (entry.kind !== 'check') {
243
- continue;
244
- }
245
- validateCheckConstraint(model, entry);
246
318
  const table = model.name;
247
- const constraintName = this.getCheckConstraintName(model, entry, i);
248
- const existingConstraint = this.findExistingConstraint(table, entry, constraintName);
249
- if (!existingConstraint) {
250
- up.push(() => {
251
- this.addCheckConstraint(table, constraintName, entry.expression);
252
- });
253
- down.push(() => {
254
- this.dropCheckConstraint(table, constraintName);
255
- });
256
- } else if (
257
- !(await this.equalExpressions(
258
- table,
259
- existingConstraint.constraintName,
260
- existingConstraint.expression,
261
- entry.expression,
262
- ))
263
- ) {
264
- up.push(() => {
265
- this.dropCheckConstraint(table, existingConstraint.constraintName);
266
- this.addCheckConstraint(table, constraintName, entry.expression);
267
- });
268
- down.push(() => {
269
- this.dropCheckConstraint(table, constraintName);
270
- this.addCheckConstraint(table, existingConstraint.constraintName, existingConstraint.expression);
271
- });
319
+ const constraintName = this.getConstraintName(model, entry, i);
320
+ if (entry.kind === 'check') {
321
+ validateCheckConstraint(model, entry);
322
+ const existingConstraint = this.findExistingConstraint(table, entry, constraintName);
323
+ if (!existingConstraint) {
324
+ up.push(() => {
325
+ this.addCheckConstraint(table, constraintName, entry.expression, entry.deferrable);
326
+ });
327
+ down.push(() => {
328
+ this.dropCheckConstraint(table, constraintName);
329
+ });
330
+ } else if (
331
+ !(await this.equalExpressions(
332
+ table,
333
+ existingConstraint.constraintName,
334
+ existingConstraint.expression,
335
+ entry.expression,
336
+ ))
337
+ ) {
338
+ up.push(() => {
339
+ this.dropCheckConstraint(table, existingConstraint.constraintName);
340
+ this.addCheckConstraint(table, constraintName, entry.expression, entry.deferrable);
341
+ });
342
+ down.push(() => {
343
+ this.dropCheckConstraint(table, constraintName);
344
+ this.addCheckConstraint(
345
+ table,
346
+ existingConstraint.constraintName,
347
+ existingConstraint.expression,
348
+ );
349
+ });
350
+ }
351
+ } else if (entry.kind === 'exclude') {
352
+ validateExcludeConstraint(model, entry);
353
+ const newDef = this.normalizeExcludeDef(this.buildExcludeDef(entry));
354
+ const existing = existingExcludeMap?.get(constraintName);
355
+ if (existing === undefined) {
356
+ up.push(() => {
357
+ this.addExcludeConstraint(table, constraintName, entry);
358
+ });
359
+ down.push(() => {
360
+ this.dropExcludeConstraint(table, constraintName);
361
+ });
362
+ } else if (existing.normalized !== newDef) {
363
+ up.push(() => {
364
+ this.dropExcludeConstraint(table, constraintName);
365
+ this.addExcludeConstraint(table, constraintName, entry);
366
+ });
367
+ down.push(() => {
368
+ this.dropExcludeConstraint(table, constraintName);
369
+ const escaped = this.escapeExpressionForRaw(existing.raw);
370
+ this.writer.writeLine(
371
+ `await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" ${escaped}\`);`,
372
+ );
373
+ this.writer.blankLine();
374
+ });
375
+ }
376
+ } else if (entry.kind === 'constraint_trigger') {
377
+ const newDef = this.normalizeTriggerDef(this.buildConstraintTriggerDef(table, constraintName, entry));
378
+ const existing = existingTriggerMap?.get(constraintName);
379
+ if (existing === undefined) {
380
+ up.push(() => {
381
+ this.addConstraintTrigger(table, constraintName, entry);
382
+ });
383
+ down.push(() => {
384
+ this.dropConstraintTrigger(table, constraintName);
385
+ });
386
+ } else if (existing.normalized !== newDef) {
387
+ up.push(() => {
388
+ this.dropConstraintTrigger(table, constraintName);
389
+ this.addConstraintTrigger(table, constraintName, entry);
390
+ });
391
+ down.push(() => {
392
+ this.dropConstraintTrigger(table, constraintName);
393
+ const escaped = existing.raw.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
394
+ this.writer.writeLine(`await knex.raw(\`${escaped}\`);`);
395
+ this.writer.blankLine();
396
+ });
397
+ }
272
398
  }
273
399
  }
274
400
  }
@@ -761,10 +887,80 @@ export class MigrationGenerator {
761
887
  this.writer.writeLine(`table.renameColumn('${from}', '${to}');`);
762
888
  }
763
889
 
764
- private getCheckConstraintName(model: EntityModel, entry: { kind: string; name: string }, index: number): string {
890
+ private getConstraintName(model: EntityModel, entry: { kind: string; name: string }, index: number): string {
765
891
  return `${model.name}_${entry.name}_${entry.kind}_${index}`;
766
892
  }
767
893
 
894
+ private static readonly SQL_KEYWORDS = new Set([
895
+ 'and',
896
+ 'or',
897
+ 'not',
898
+ 'in',
899
+ 'is',
900
+ 'null',
901
+ 'true',
902
+ 'false',
903
+ 'between',
904
+ 'like',
905
+ 'exists',
906
+ 'all',
907
+ 'any',
908
+ 'asc',
909
+ 'desc',
910
+ 'with',
911
+ 'using',
912
+ 'as',
913
+ 'on',
914
+ 'infinity',
915
+ 'extract',
916
+ 'current_date',
917
+ 'current_timestamp',
918
+ ]);
919
+
920
+ private static readonly LITERAL_PLACEHOLDER = '\uE000';
921
+
922
+ private normalizeSqlIdentifiers(s: string): string {
923
+ const literals: string[] = [];
924
+ let result = s.replace(/'([^']|'')*'/g, (lit) => {
925
+ literals.push(lit);
926
+
927
+ return `${MigrationGenerator.LITERAL_PLACEHOLDER}${literals.length - 1}${MigrationGenerator.LITERAL_PLACEHOLDER}`;
928
+ });
929
+ result = result.replace(/"([^"]*)"/g, (_, ident) => `"${ident.toLowerCase()}"`);
930
+ result = result.replace(/\b([a-zA-Z_][a-zA-Z0-9_]*)\b(?!\s*\()/g, (match) =>
931
+ MigrationGenerator.SQL_KEYWORDS.has(match.toLowerCase()) ? match : `"${match.toLowerCase()}"`,
932
+ );
933
+ for (let i = 0; i < literals.length; i++) {
934
+ result = result.replace(
935
+ new RegExp(`${MigrationGenerator.LITERAL_PLACEHOLDER}${i}${MigrationGenerator.LITERAL_PLACEHOLDER}`, 'g'),
936
+ literals[i],
937
+ );
938
+ }
939
+
940
+ return result;
941
+ }
942
+
943
+ private normalizeExcludeDef(def: string): string {
944
+ const s = def
945
+ .replace(/\s+/g, ' ')
946
+ .replace(/\s*\(\s*/g, '(')
947
+ .replace(/\s*\)\s*/g, ')')
948
+ .trim();
949
+
950
+ return this.normalizeSqlIdentifiers(s);
951
+ }
952
+
953
+ private normalizeTriggerDef(def: string): string {
954
+ const s = def
955
+ .replace(/\s+/g, ' ')
956
+ .replace(/\s*\(\s*/g, '(')
957
+ .replace(/\s*\)\s*/g, ')')
958
+ .replace(/\bON\s+[a-zA-Z_][a-zA-Z0-9_]*\./gi, 'ON ')
959
+ .trim();
960
+
961
+ return this.normalizeSqlIdentifiers(s);
962
+ }
963
+
768
964
  private normalizeCheckExpression(expr: string): string {
769
965
  let normalized = expr.replace(/\s+/g, ' ').trim();
770
966
  while (this.isWrappedByOuterParentheses(normalized)) {
@@ -949,10 +1145,16 @@ export class MigrationGenerator {
949
1145
  return expr.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
950
1146
  }
951
1147
 
952
- private addCheckConstraint(table: string, constraintName: string, expression: string) {
1148
+ private addCheckConstraint(
1149
+ table: string,
1150
+ constraintName: string,
1151
+ expression: string,
1152
+ deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE',
1153
+ ) {
953
1154
  const escaped = this.escapeExpressionForRaw(expression);
1155
+ const deferrableClause = deferrable ? ` DEFERRABLE ${deferrable}` : '';
954
1156
  this.writer.writeLine(
955
- `await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" CHECK (${escaped})\`);`,
1157
+ `await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" CHECK (${escaped})${deferrableClause}\`);`,
956
1158
  );
957
1159
  this.writer.blankLine();
958
1160
  }
@@ -962,6 +1164,91 @@ export class MigrationGenerator {
962
1164
  this.writer.blankLine();
963
1165
  }
964
1166
 
1167
+ private buildExcludeDef(entry: {
1168
+ using: string;
1169
+ elements: ({ column: string; operator: string } | { expression: string; operator: string })[];
1170
+ where?: string;
1171
+ deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
1172
+ }): string {
1173
+ const elementsStr = entry.elements
1174
+ .map((el) => ('column' in el ? `"${el.column}" WITH ${el.operator}` : `${el.expression} WITH ${el.operator}`))
1175
+ .join(', ');
1176
+ const whereClause = entry.where ? ` WHERE (${entry.where})` : '';
1177
+ const deferrableClause = entry.deferrable ? ` DEFERRABLE ${entry.deferrable}` : '';
1178
+
1179
+ return `EXCLUDE USING ${entry.using} (${elementsStr})${whereClause}${deferrableClause}`;
1180
+ }
1181
+
1182
+ private addExcludeConstraint(
1183
+ table: string,
1184
+ constraintName: string,
1185
+ entry: {
1186
+ using: string;
1187
+ elements: ({ column: string; operator: string } | { expression: string; operator: string })[];
1188
+ where?: string;
1189
+ deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
1190
+ },
1191
+ ) {
1192
+ const def = this.buildExcludeDef(entry);
1193
+ const escaped = this.escapeExpressionForRaw(def);
1194
+ this.writer.writeLine(`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" ${escaped}\`);`);
1195
+ this.writer.blankLine();
1196
+ }
1197
+
1198
+ private dropExcludeConstraint(table: string, constraintName: string) {
1199
+ this.writer.writeLine(`await knex.raw('ALTER TABLE "${table}" DROP CONSTRAINT "${constraintName}"');`);
1200
+ this.writer.blankLine();
1201
+ }
1202
+
1203
+ private buildConstraintTriggerDef(
1204
+ table: string,
1205
+ constraintName: string,
1206
+ entry: {
1207
+ when: 'AFTER' | 'BEFORE';
1208
+ events: ('INSERT' | 'UPDATE')[];
1209
+ forEach: 'ROW' | 'STATEMENT';
1210
+ deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
1211
+ function: { name: string; args?: string[] };
1212
+ },
1213
+ ): string {
1214
+ const eventsStr = entry.events.join(' OR ');
1215
+ const deferrableClause = entry.deferrable ? ` DEFERRABLE ${entry.deferrable}` : '';
1216
+ const argsStr = entry.function.args?.length ? entry.function.args.map((a) => `"${a}"`).join(', ') : '';
1217
+ const executeClause = argsStr
1218
+ ? `EXECUTE FUNCTION ${entry.function.name}(${argsStr})`
1219
+ : `EXECUTE FUNCTION ${entry.function.name}()`;
1220
+
1221
+ return `CREATE CONSTRAINT TRIGGER "${constraintName}" ${entry.when} ${eventsStr} ON "${table}"${deferrableClause} FOR EACH ${entry.forEach} ${executeClause}`;
1222
+ }
1223
+
1224
+ private addConstraintTrigger(
1225
+ table: string,
1226
+ constraintName: string,
1227
+ entry: {
1228
+ when: 'AFTER' | 'BEFORE';
1229
+ events: ('INSERT' | 'UPDATE')[];
1230
+ forEach: 'ROW' | 'STATEMENT';
1231
+ deferrable?: 'INITIALLY DEFERRED' | 'INITIALLY IMMEDIATE';
1232
+ function: { name: string; args?: string[] };
1233
+ },
1234
+ ) {
1235
+ const eventsStr = entry.events.join(' OR ');
1236
+ const deferrableClause = entry.deferrable ? ` DEFERRABLE ${entry.deferrable}` : '';
1237
+ const argsStr = entry.function.args?.length ? entry.function.args.map((a) => `"${a}"`).join(', ') : '';
1238
+ const executeClause = argsStr
1239
+ ? `EXECUTE FUNCTION ${entry.function.name}(${argsStr})`
1240
+ : `EXECUTE FUNCTION ${entry.function.name}()`;
1241
+ this.writer.writeLine(
1242
+ `await knex.raw(\`CREATE CONSTRAINT TRIGGER "${constraintName}" ${entry.when} ${eventsStr} ON "${table}"${deferrableClause} FOR EACH ${entry.forEach} ${executeClause}\`);`,
1243
+ );
1244
+ this.writer.blankLine();
1245
+ }
1246
+
1247
+ private dropConstraintTrigger(table: string, constraintName: string) {
1248
+ this.writer.writeLine(`await knex.raw('ALTER TABLE "${table}" DROP CONSTRAINT "${constraintName}"');`);
1249
+ this.writer.blankLine();
1250
+ }
1251
+
965
1252
  private value(value: Value) {
966
1253
  if (typeof value === 'string') {
967
1254
  return `'${value}'`;
@@ -68,6 +68,7 @@ export const getDatabaseFunctions = async (knex: Knex): Promise<DatabaseFunction
68
68
  JOIN pg_namespace n ON p.pronamespace = n.oid
69
69
  WHERE n.nspname = 'public'
70
70
  AND NOT EXISTS (SELECT 1 FROM pg_aggregate a WHERE a.aggfnoid = p.oid)
71
+ AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e')
71
72
  ORDER BY p.proname, pg_get_function_identity_arguments(p.oid)
72
73
  `);
73
74
 
@@ -84,6 +85,7 @@ export const getDatabaseFunctions = async (knex: Knex): Promise<DatabaseFunction
84
85
  JOIN pg_aggregate a ON p.oid = a.aggfnoid
85
86
  JOIN pg_namespace n ON p.pronamespace = n.oid
86
87
  WHERE n.nspname = 'public'
88
+ AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e')
87
89
  ORDER BY p.proname, pg_get_function_identity_arguments(p.oid)
88
90
  `);
89
91
 
@@ -165,7 +165,26 @@ export type ModelDefinition = {
165
165
  */
166
166
  manyToManyRelation?: boolean;
167
167
 
168
- constraints?: { 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
  }
@@ -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 { extractColumnReferencesFromCheckExpression, validateCheckConstraint } from '../../src/models/utils';
2
+ import {
3
+ extractColumnReferencesFromCheckExpression,
4
+ validateCheckConstraint,
5
+ validateExcludeConstraint,
6
+ } from '../../src/models/utils';
3
7
 
4
- describe('check constraints', () => {
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
  });