@smartive/graphql-magic 23.7.0-next.4 → 23.7.0

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.
@@ -40,7 +40,6 @@ import {
40
40
  summonByName,
41
41
  typeToField,
42
42
  validateCheckConstraint,
43
- validateExcludeConstraint,
44
43
  } from './utils';
45
44
 
46
45
  // These might one day become classes
@@ -358,7 +357,7 @@ export class EntityModel extends Model {
358
357
  defaultOrderBy?: OrderBy[];
359
358
  fields: EntityField[];
360
359
 
361
- constraints?: EntityModelDefinition['constraints'];
360
+ constraints?: { kind: 'check'; name: string; expression: string }[];
362
361
 
363
362
  // temporary fields for the generation of migrations
364
363
  deleted?: true;
@@ -393,8 +392,6 @@ export class EntityModel extends Model {
393
392
  for (const constraint of this.constraints) {
394
393
  if (constraint.kind === 'check') {
395
394
  validateCheckConstraint(this, constraint);
396
- } else if (constraint.kind === 'exclude') {
397
- validateExcludeConstraint(this, constraint);
398
395
  }
399
396
  }
400
397
  }
@@ -259,23 +259,3 @@ 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,11 +1,7 @@
1
1
  import { ModelDefinitions, Models } from '../../src/models';
2
- import {
3
- extractColumnReferencesFromCheckExpression,
4
- validateCheckConstraint,
5
- validateExcludeConstraint,
6
- } from '../../src/models/utils';
2
+ import { extractColumnReferencesFromCheckExpression, validateCheckConstraint } from '../../src/models/utils';
7
3
 
8
- describe('constraints', () => {
4
+ describe('check constraints', () => {
9
5
  const modelDefinitions: ModelDefinitions = [
10
6
  {
11
7
  kind: 'entity',
@@ -84,96 +80,4 @@ describe('constraints', () => {
84
80
  ).toThrow(/Valid columns:.*\bscore\b.*\bstatus\b/);
85
81
  });
86
82
  });
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
- });
179
83
  });
@@ -0,0 +1,300 @@
1
+ import { MigrationGenerator } from '../../src/migrations/generate';
2
+ import { ModelDefinitions, Models } from '../../src/models';
3
+
4
+ jest.mock('knex-schema-inspector', () => ({
5
+ SchemaInspector: jest.fn(() => ({})),
6
+ }));
7
+
8
+ jest.mock('code-block-writer', () => {
9
+ const Writer = class {
10
+ private output = '';
11
+
12
+ write(value: string) {
13
+ this.output += value;
14
+ return this;
15
+ }
16
+
17
+ writeLine(value: string) {
18
+ this.output += `${value}\n`;
19
+ return this;
20
+ }
21
+
22
+ blankLine() {
23
+ this.output += '\n';
24
+ return this;
25
+ }
26
+
27
+ newLine() {
28
+ this.output += '\n';
29
+ return this;
30
+ }
31
+
32
+ inlineBlock(fn: () => void) {
33
+ this.output += ' {\n';
34
+ fn();
35
+ this.output += '}';
36
+ return this;
37
+ }
38
+
39
+ block(fn: () => void) {
40
+ this.output += ' {\n';
41
+ fn();
42
+ this.output += '}\n';
43
+ return this;
44
+ }
45
+
46
+ toString() {
47
+ return this.output;
48
+ }
49
+ };
50
+
51
+ return { __esModule: true, default: { default: Writer } };
52
+ });
53
+
54
+ type MockColumn = {
55
+ name: string;
56
+ data_type: string;
57
+ is_nullable: boolean;
58
+ generation_expression?: string | null;
59
+ numeric_precision?: number | null;
60
+ numeric_scale?: number | null;
61
+ max_length?: number | null;
62
+ };
63
+
64
+ type CheckRow = {
65
+ table_name: string;
66
+ constraint_name: string;
67
+ check_clause: string;
68
+ };
69
+
70
+ const normalizeForCanonicalizationMock = (expr: string) => {
71
+ let normalized = expr.replace(/\s+/g, ' ').trim();
72
+ while (normalized.startsWith('(') && normalized.endsWith(')')) {
73
+ normalized = normalized.slice(1, -1).trim();
74
+ }
75
+
76
+ return normalized.replace(/"([a-z_][a-z0-9_]*)"/g, '$1');
77
+ };
78
+
79
+ const baseColumnsByTable: Record<string, MockColumn[]> = {
80
+ Product: [
81
+ { name: 'id', data_type: 'uuid', is_nullable: false },
82
+ { name: 'deleted', data_type: 'boolean', is_nullable: true },
83
+ { name: 'startDate', data_type: 'timestamp without time zone', is_nullable: true },
84
+ { name: 'endDate', data_type: 'timestamp without time zone', is_nullable: true },
85
+ { name: 'score', data_type: 'integer', is_nullable: true },
86
+ ],
87
+ };
88
+
89
+ const createProductModels = (constraints: { kind: 'check'; name: string; expression: string }[]) => {
90
+ const definitions: ModelDefinitions = [
91
+ {
92
+ kind: 'entity',
93
+ name: 'Product',
94
+ fields: [{ name: 'score', type: 'Int' }],
95
+ constraints,
96
+ },
97
+ ];
98
+
99
+ return new Models(definitions);
100
+ };
101
+
102
+ const createGenerator = (rows: CheckRow[], models: Models) => {
103
+ const raw = jest.fn().mockResolvedValue({ rows });
104
+ const knexLike = Object.assign(
105
+ jest.fn().mockReturnValue({
106
+ where: jest.fn().mockReturnValue({
107
+ select: jest.fn().mockResolvedValue([]),
108
+ }),
109
+ }),
110
+ { raw },
111
+ );
112
+
113
+ const schema = {
114
+ knex: knexLike,
115
+ tables: jest.fn().mockResolvedValue(['Product']),
116
+ columnInfo: jest.fn(async (table: string) => baseColumnsByTable[table] ?? []),
117
+ };
118
+
119
+ const generator = new MigrationGenerator({} as never, models);
120
+ (generator as unknown as { schema: unknown }).schema = schema;
121
+
122
+ return generator;
123
+ };
124
+
125
+ describe('MigrationGenerator check constraints', () => {
126
+ beforeEach(() => {
127
+ jest
128
+ .spyOn(MigrationGenerator.prototype as any, 'canonicalizeCheckExpressionWithPostgres')
129
+ .mockImplementation(async (...args: unknown[]) => normalizeForCanonicalizationMock(args[1] as string));
130
+ });
131
+
132
+ afterEach(() => {
133
+ jest.restoreAllMocks();
134
+ });
135
+
136
+ it('does not detect changes for equivalent expressions with extra parentheses', async () => {
137
+ const models = createProductModels([{ kind: 'check', name: 'score_non_negative', expression: '"score" >= 0' }]);
138
+ const generator = createGenerator(
139
+ [
140
+ {
141
+ table_name: 'Product',
142
+ constraint_name: 'Product_score_non_negative_check_0',
143
+ check_clause: ' (( "score" >= 0 )) ',
144
+ },
145
+ ],
146
+ models,
147
+ );
148
+
149
+ await generator.generate();
150
+
151
+ expect(generator.needsMigration).toBe(false);
152
+ });
153
+
154
+ it('does not detect changes for equivalent expressions with quoted vs unquoted lowercase identifiers', async () => {
155
+ jest
156
+ .spyOn(MigrationGenerator.prototype as any, 'canonicalizeCheckExpressionWithPostgres')
157
+ .mockImplementation(
158
+ async () => '(deleted = true) OR ("endDate" IS NULL) OR ("startDate" <= "endDate")',
159
+ );
160
+ const models = new Models([
161
+ {
162
+ kind: 'entity',
163
+ name: 'Product',
164
+ fields: [
165
+ { name: 'deleted', type: 'Boolean' },
166
+ { name: 'startDate', type: 'DateTime' },
167
+ { name: 'endDate', type: 'DateTime' },
168
+ ],
169
+ constraints: [
170
+ {
171
+ kind: 'check',
172
+ name: 'period_start_before_end',
173
+ expression: '"deleted" = true OR "endDate" IS NULL OR "startDate" <= "endDate"',
174
+ },
175
+ ],
176
+ },
177
+ ]);
178
+ const generator = createGenerator(
179
+ [
180
+ {
181
+ table_name: 'Product',
182
+ constraint_name: 'Product_period_start_before_end_check_0',
183
+ check_clause: '((deleted = true) OR ("endDate" IS NULL) OR ("startDate" <= "endDate"))',
184
+ },
185
+ ],
186
+ models,
187
+ );
188
+
189
+ await generator.generate();
190
+
191
+ expect(generator.needsMigration).toBe(false);
192
+ });
193
+
194
+ it('does not detect changes if only generated constraint index changed but expression is identical', async () => {
195
+ const models = createProductModels([
196
+ { kind: 'check', name: 'first', expression: '"score" >= 0' },
197
+ { kind: 'check', name: 'second', expression: '"score" <= 100' },
198
+ ]);
199
+ const generator = createGenerator(
200
+ [
201
+ {
202
+ table_name: 'Product',
203
+ constraint_name: 'Product_first_check_1',
204
+ check_clause: '(("score" >= 0))',
205
+ },
206
+ {
207
+ table_name: 'Product',
208
+ constraint_name: 'Product_second_check_0',
209
+ check_clause: '(("score" <= 100))',
210
+ },
211
+ ],
212
+ models,
213
+ );
214
+
215
+ await generator.generate();
216
+
217
+ expect(generator.needsMigration).toBe(false);
218
+ });
219
+
220
+ it('still detects actual expression changes', async () => {
221
+ const models = createProductModels([{ kind: 'check', name: 'score_non_negative', expression: '"score" >= 0' }]);
222
+ const generator = createGenerator(
223
+ [
224
+ {
225
+ table_name: 'Product',
226
+ constraint_name: 'Product_score_non_negative_check_0',
227
+ check_clause: '"score" > 0',
228
+ },
229
+ ],
230
+ models,
231
+ );
232
+
233
+ const migration = await generator.generate();
234
+
235
+ expect(generator.needsMigration).toBe(true);
236
+ expect(migration).toContain('DROP CONSTRAINT "Product_score_non_negative_check_0"');
237
+ expect(migration).toContain('ADD CONSTRAINT "Product_score_non_negative_check_0" CHECK ("score" >= 0)');
238
+ });
239
+
240
+ it('warns and treats constraint as changed when canonicalization fails', async () => {
241
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
242
+ jest
243
+ .spyOn(MigrationGenerator.prototype as any, 'canonicalizeCheckExpressionWithPostgres')
244
+ .mockRejectedValue(new Error('canonicalization failed'));
245
+ const models = createProductModels([{ kind: 'check', name: 'score_non_negative', expression: '"score" >= 0' }]);
246
+ const generator = createGenerator(
247
+ [
248
+ {
249
+ table_name: 'Product',
250
+ constraint_name: 'Product_score_non_negative_check_0',
251
+ check_clause: '"score" >= 0',
252
+ },
253
+ ],
254
+ models,
255
+ );
256
+
257
+ await generator.generate();
258
+
259
+ expect(generator.needsMigration).toBe(true);
260
+ expect(warnSpy).toHaveBeenCalled();
261
+ });
262
+ });
263
+
264
+ describe('MigrationGenerator canonicalizeCheckExpressionWithPostgres', () => {
265
+ afterEach(() => {
266
+ jest.restoreAllMocks();
267
+ });
268
+
269
+ it('creates temp table using LIKE source table shape', async () => {
270
+ const raw = jest
271
+ .fn()
272
+ .mockResolvedValueOnce(undefined)
273
+ .mockResolvedValueOnce(undefined)
274
+ .mockResolvedValueOnce({
275
+ rows: [
276
+ {
277
+ constraint_definition: 'CHECK (((deleted = true) OR ("endDate" IS NULL) OR ("startDate" <= "endDate")))',
278
+ },
279
+ ],
280
+ });
281
+ const rollback = jest.fn().mockResolvedValue(undefined);
282
+ const trx = { raw, rollback };
283
+ const knexLike = {
284
+ transaction: jest.fn().mockResolvedValue(trx),
285
+ };
286
+ const generator = new MigrationGenerator(knexLike as never, createProductModels([]));
287
+
288
+ const canonical = await (generator as any).canonicalizeCheckExpressionWithPostgres(
289
+ 'RevenueSplit',
290
+ '((deleted = true) OR ("endDate" IS NULL) OR ("startDate" <= "endDate"))',
291
+ );
292
+
293
+ const createTempSql = raw.mock.calls[0]?.[0] as string;
294
+ expect(createTempSql).toContain('CREATE TEMP TABLE');
295
+ expect(createTempSql).toContain('(LIKE "RevenueSplit")');
296
+ expect(createTempSql).not.toContain('gqm_check_value');
297
+ expect(canonical).toBe('(deleted = true) OR ("endDate" IS NULL) OR ("startDate" <= "endDate")');
298
+ expect(rollback).toHaveBeenCalled();
299
+ });
300
+ });