@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.
@@ -1,7 +1,7 @@
1
1
  import CodeBlockWriter from 'code-block-writer';
2
2
  import { SchemaInspector } from 'knex-schema-inspector';
3
3
  import lowerFirst from 'lodash/lowerFirst';
4
- import { and, get, isCreatableModel, isInherited, isStoredInDatabase, isUpdatableField, isUpdatableModel, modelNeedsTable, not, summonByName, typeToField, validateCheckConstraint, validateExcludeConstraint, } from '../models/utils';
4
+ import { and, get, isCreatableModel, isInherited, isStoredInDatabase, isUpdatableField, isUpdatableModel, modelNeedsTable, not, summonByName, typeToField, validateCheckConstraint, } from '../models/utils';
5
5
  import { getColumnName } from '../resolvers';
6
6
  import { getDatabaseFunctions, normalizeAggregateDefinition, normalizeFunctionBody, } from './update-functions';
7
7
  export class MigrationGenerator {
@@ -16,10 +16,6 @@ export class MigrationGenerator {
16
16
  columns = {};
17
17
  /** table name -> constraint name -> check clause expression */
18
18
  existingCheckConstraints = {};
19
- /** table name -> constraint name -> exclude definition (normalized) */
20
- existingExcludeConstraints = {};
21
- /** table name -> constraint name -> trigger definition (normalized) */
22
- existingConstraintTriggers = {};
23
19
  uuidUsed;
24
20
  nowUsed;
25
21
  needsMigration = false;
@@ -52,48 +48,8 @@ export class MigrationGenerator {
52
48
  }
53
49
  this.existingCheckConstraints[tableName].set(row.constraint_name, row.check_clause);
54
50
  }
55
- const excludeResult = await schema.knex.raw(`SELECT c.conrelid::regclass::text as table_name, c.conname as constraint_name, pg_get_constraintdef(c.oid) as constraint_def
56
- FROM pg_constraint c
57
- JOIN pg_namespace n ON c.connamespace = n.oid
58
- WHERE n.nspname = 'public' AND c.contype = 'x'`);
59
- const excludeRows = 'rows' in excludeResult && Array.isArray(excludeResult.rows)
60
- ? excludeResult.rows
61
- : [];
62
- for (const row of excludeRows) {
63
- const tableName = row.table_name.split('.').pop()?.replace(/^"|"$/g, '') ?? row.table_name;
64
- if (!this.existingExcludeConstraints[tableName]) {
65
- this.existingExcludeConstraints[tableName] = new Map();
66
- }
67
- this.existingExcludeConstraints[tableName].set(row.constraint_name, this.normalizeExcludeDef(row.constraint_def));
68
- }
69
- const triggerResult = await schema.knex.raw(`SELECT c.conrelid::regclass::text as table_name, c.conname as constraint_name, pg_get_triggerdef(t.oid) as trigger_def
70
- FROM pg_constraint c
71
- JOIN pg_trigger t ON t.tgconstraint = c.oid
72
- JOIN pg_namespace n ON c.connamespace = n.oid
73
- WHERE n.nspname = 'public' AND c.contype = 't'`);
74
- const triggerRows = 'rows' in triggerResult && Array.isArray(triggerResult.rows)
75
- ? triggerResult.rows
76
- : [];
77
- for (const row of triggerRows) {
78
- const tableName = row.table_name.split('.').pop()?.replace(/^"|"$/g, '') ?? row.table_name;
79
- if (!this.existingConstraintTriggers[tableName]) {
80
- this.existingConstraintTriggers[tableName] = new Map();
81
- }
82
- this.existingConstraintTriggers[tableName].set(row.constraint_name, this.normalizeTriggerDef(row.trigger_def));
83
- }
84
51
  const up = [];
85
52
  const down = [];
86
- const wantsBtreeGist = models.entities.some((model) => model.constraints?.some((c) => c.kind === 'exclude' && c.elements.some((el) => 'column' in el && el.operator === '=')));
87
- if (wantsBtreeGist) {
88
- const extResult = await schema.knex('pg_extension').where('extname', 'btree_gist').select('oid').first();
89
- const btreeGistInstalled = !!extResult;
90
- if (!btreeGistInstalled) {
91
- up.unshift(() => {
92
- this.writer.writeLine(`await knex.raw('CREATE EXTENSION IF NOT EXISTS btree_gist');`);
93
- this.writer.blankLine();
94
- });
95
- }
96
- }
97
53
  this.createEnums(this.models.enums.filter((enm) => !enums.includes(lowerFirst(enm.name))), up, down);
98
54
  await this.handleFunctions(up, down);
99
55
  for (const model of models.entities) {
@@ -173,24 +129,10 @@ export class MigrationGenerator {
173
129
  if (entry.kind === 'check') {
174
130
  validateCheckConstraint(model, entry);
175
131
  const table = model.name;
176
- const constraintName = this.getConstraintName(model, entry, i);
177
- up.push(() => {
178
- this.addCheckConstraint(table, constraintName, entry.expression, entry.deferrable);
179
- });
180
- }
181
- else if (entry.kind === 'exclude') {
182
- validateExcludeConstraint(model, entry);
183
- const table = model.name;
184
- const constraintName = this.getConstraintName(model, entry, i);
185
- up.push(() => {
186
- this.addExcludeConstraint(table, constraintName, entry);
187
- });
188
- }
189
- else if (entry.kind === 'constraint_trigger') {
190
- const table = model.name;
191
- const constraintName = this.getConstraintName(model, entry, i);
132
+ const constraintName = this.getCheckConstraintName(model, entry, i);
133
+ const expression = entry.expression;
192
134
  up.push(() => {
193
- this.addConstraintTrigger(table, constraintName, entry);
135
+ this.addCheckConstraint(table, constraintName, expression);
194
136
  });
195
137
  }
196
138
  }
@@ -229,84 +171,32 @@ export class MigrationGenerator {
229
171
  const existingFields = model.fields.filter((field) => (!field.generateAs || field.generateAs.type === 'expression') && this.hasChanged(model, field));
230
172
  this.updateFields(model, existingFields, up, down);
231
173
  if (model.constraints?.length) {
232
- const existingCheckMap = this.existingCheckConstraints[model.name];
233
- const existingExcludeMap = this.existingExcludeConstraints[model.name];
234
- const existingTriggerMap = this.existingConstraintTriggers[model.name];
235
174
  for (let i = 0; i < model.constraints.length; i++) {
236
175
  const entry = model.constraints[i];
237
- const table = model.name;
238
- const constraintName = this.getConstraintName(model, entry, i);
239
- if (entry.kind === 'check') {
240
- validateCheckConstraint(model, entry);
241
- const newExpression = entry.expression;
242
- const existingExpression = existingCheckMap?.get(constraintName);
243
- if (existingExpression === undefined) {
244
- up.push(() => {
245
- this.addCheckConstraint(table, constraintName, newExpression, entry.deferrable);
246
- });
247
- down.push(() => {
248
- this.dropCheckConstraint(table, constraintName);
249
- });
250
- }
251
- else if (this.normalizeCheckExpression(existingExpression) !== this.normalizeCheckExpression(newExpression)) {
252
- up.push(() => {
253
- this.dropCheckConstraint(table, constraintName);
254
- this.addCheckConstraint(table, constraintName, newExpression, entry.deferrable);
255
- });
256
- down.push(() => {
257
- this.dropCheckConstraint(table, constraintName);
258
- this.addCheckConstraint(table, constraintName, existingExpression);
259
- });
260
- }
176
+ if (entry.kind !== 'check') {
177
+ continue;
261
178
  }
262
- else if (entry.kind === 'exclude') {
263
- validateExcludeConstraint(model, entry);
264
- const newDef = this.normalizeExcludeDef(this.buildExcludeDef(entry));
265
- const existingDef = existingExcludeMap?.get(constraintName);
266
- if (existingDef === undefined) {
267
- up.push(() => {
268
- this.addExcludeConstraint(table, constraintName, entry);
269
- });
270
- down.push(() => {
271
- this.dropExcludeConstraint(table, constraintName);
272
- });
273
- }
274
- else if (existingDef !== newDef) {
275
- up.push(() => {
276
- this.dropExcludeConstraint(table, constraintName);
277
- this.addExcludeConstraint(table, constraintName, entry);
278
- });
279
- down.push(() => {
280
- this.dropExcludeConstraint(table, constraintName);
281
- const escaped = this.escapeExpressionForRaw(existingDef);
282
- this.writer.writeLine(`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" ${escaped}\`);`);
283
- this.writer.blankLine();
284
- });
285
- }
179
+ validateCheckConstraint(model, entry);
180
+ const table = model.name;
181
+ const constraintName = this.getCheckConstraintName(model, entry, i);
182
+ const existingConstraint = this.findExistingConstraint(table, entry, constraintName);
183
+ if (!existingConstraint) {
184
+ up.push(() => {
185
+ this.addCheckConstraint(table, constraintName, entry.expression);
186
+ });
187
+ down.push(() => {
188
+ this.dropCheckConstraint(table, constraintName);
189
+ });
286
190
  }
287
- else if (entry.kind === 'constraint_trigger') {
288
- const newDef = this.normalizeTriggerDef(this.buildConstraintTriggerDef(table, constraintName, entry));
289
- const existingDef = existingTriggerMap?.get(constraintName);
290
- if (existingDef === undefined) {
291
- up.push(() => {
292
- this.addConstraintTrigger(table, constraintName, entry);
293
- });
294
- down.push(() => {
295
- this.dropConstraintTrigger(table, constraintName);
296
- });
297
- }
298
- else if (existingDef !== newDef) {
299
- up.push(() => {
300
- this.dropConstraintTrigger(table, constraintName);
301
- this.addConstraintTrigger(table, constraintName, entry);
302
- });
303
- down.push(() => {
304
- this.dropConstraintTrigger(table, constraintName);
305
- const escaped = existingDef.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
306
- this.writer.writeLine(`await knex.raw(\`${escaped}\`);`);
307
- this.writer.blankLine();
308
- });
309
- }
191
+ else if (!(await this.equalExpressions(table, existingConstraint.constraintName, existingConstraint.expression, entry.expression))) {
192
+ up.push(() => {
193
+ this.dropCheckConstraint(table, existingConstraint.constraintName);
194
+ this.addCheckConstraint(table, constraintName, entry.expression);
195
+ });
196
+ down.push(() => {
197
+ this.dropCheckConstraint(table, constraintName);
198
+ this.addCheckConstraint(table, existingConstraint.constraintName, existingConstraint.expression);
199
+ });
310
200
  }
311
201
  }
312
202
  }
@@ -689,147 +579,160 @@ export class MigrationGenerator {
689
579
  renameColumn(from, to) {
690
580
  this.writer.writeLine(`table.renameColumn('${from}', '${to}');`);
691
581
  }
692
- getConstraintName(model, entry, index) {
582
+ getCheckConstraintName(model, entry, index) {
693
583
  return `${model.name}_${entry.name}_${entry.kind}_${index}`;
694
584
  }
695
- static SQL_KEYWORDS = new Set([
696
- 'and',
697
- 'or',
698
- 'not',
699
- 'in',
700
- 'is',
701
- 'null',
702
- 'true',
703
- 'false',
704
- 'between',
705
- 'like',
706
- 'exists',
707
- 'all',
708
- 'any',
709
- 'asc',
710
- 'desc',
711
- 'with',
712
- 'using',
713
- 'as',
714
- 'on',
715
- 'infinity',
716
- 'extract',
717
- 'current_date',
718
- 'current_timestamp',
719
- ]);
720
- normalizeSqlIdentifiers(s) {
721
- const literals = [];
722
- let result = s.replace(/'([^']|'')*'/g, (lit) => {
723
- literals.push(lit);
724
- return `\x00L${literals.length - 1}\x00`;
725
- });
726
- result = result.replace(/"([^"]*)"/g, (_, ident) => `"${ident.toLowerCase()}"`);
727
- result = result.replace(/\b([a-zA-Z_][a-zA-Z0-9_]*)\b(?!\s*\()/g, (match) => MigrationGenerator.SQL_KEYWORDS.has(match.toLowerCase()) ? match : `"${match.toLowerCase()}"`);
728
- for (let i = 0; i < literals.length; i++) {
729
- result = result.replace(new RegExp(`\x00L${i}\x00`, 'g'), literals[i]);
585
+ normalizeCheckExpression(expr) {
586
+ let normalized = expr.replace(/\s+/g, ' ').trim();
587
+ while (this.isWrappedByOuterParentheses(normalized)) {
588
+ normalized = normalized.slice(1, -1).trim();
730
589
  }
731
- return result;
590
+ return normalized;
732
591
  }
733
- normalizeCheckExpression(expr) {
734
- let s = expr.replace(/\s+/g, ' ').trim();
735
- while (s.length >= 2 && s.startsWith('(') && s.endsWith(')')) {
736
- let depth = 0;
737
- let match = true;
738
- for (let i = 0; i < s.length; i++) {
739
- if (s[i] === '(') {
740
- depth++;
592
+ isWrappedByOuterParentheses(expr) {
593
+ if (!expr.startsWith('(') || !expr.endsWith(')')) {
594
+ return false;
595
+ }
596
+ let depth = 0;
597
+ let inSingleQuote = false;
598
+ for (let i = 0; i < expr.length; i++) {
599
+ const char = expr[i];
600
+ const next = expr[i + 1];
601
+ if (char === "'") {
602
+ if (inSingleQuote && next === "'") {
603
+ i++;
604
+ continue;
741
605
  }
742
- else if (s[i] === ')') {
743
- depth--;
606
+ inSingleQuote = !inSingleQuote;
607
+ continue;
608
+ }
609
+ if (inSingleQuote) {
610
+ continue;
611
+ }
612
+ if (char === '(') {
613
+ depth++;
614
+ }
615
+ else if (char === ')') {
616
+ depth--;
617
+ if (depth === 0 && i !== expr.length - 1) {
618
+ return false;
744
619
  }
745
- if (depth === 0 && i < s.length - 1) {
746
- match = false;
747
- break;
620
+ if (depth < 0) {
621
+ return false;
748
622
  }
749
623
  }
750
- if (!match || depth !== 0) {
751
- break;
624
+ }
625
+ return depth === 0;
626
+ }
627
+ findExistingConstraint(table, entry, preferredConstraintName) {
628
+ const existingMap = this.existingCheckConstraints[table];
629
+ if (!existingMap) {
630
+ return null;
631
+ }
632
+ const preferredExpression = existingMap.get(preferredConstraintName);
633
+ if (preferredExpression !== undefined) {
634
+ return {
635
+ constraintName: preferredConstraintName,
636
+ expression: preferredExpression,
637
+ };
638
+ }
639
+ const normalizedNewExpression = this.normalizeCheckExpression(entry.expression);
640
+ const constraintPrefix = `${table}_${entry.name}_${entry.kind}_`;
641
+ for (const [constraintName, expression] of existingMap.entries()) {
642
+ if (!constraintName.startsWith(constraintPrefix)) {
643
+ continue;
644
+ }
645
+ if (this.normalizeCheckExpression(expression) !== normalizedNewExpression) {
646
+ continue;
752
647
  }
753
- s = s.slice(1, -1).trim();
648
+ return { constraintName, expression };
754
649
  }
755
- s = s
756
- .replace(/\s*\(\s*/g, '(')
757
- .replace(/\s*\)\s*/g, ')')
758
- .replace(/\s+AND\s+/gi, ' AND ')
759
- .replace(/\s+OR\s+/gi, ' OR ')
760
- .trim();
761
- return this.normalizeSqlIdentifiers(s);
650
+ return null;
762
651
  }
763
- normalizeExcludeDef(def) {
764
- const s = def
765
- .replace(/\s+/g, ' ')
766
- .replace(/\s*\(\s*/g, '(')
767
- .replace(/\s*\)\s*/g, ')')
768
- .trim();
769
- return this.normalizeSqlIdentifiers(s);
652
+ async equalExpressions(table, constraintName, existingExpression, newExpression) {
653
+ try {
654
+ const [canonicalExisting, canonicalNew] = await Promise.all([
655
+ this.canonicalizeCheckExpressionWithPostgres(table, existingExpression),
656
+ this.canonicalizeCheckExpressionWithPostgres(table, newExpression),
657
+ ]);
658
+ return canonicalExisting === canonicalNew;
659
+ }
660
+ catch (error) {
661
+ console.warn(`Failed to canonicalize check constraint "${constraintName}" on table "${table}". Treating it as changed.`, error);
662
+ return false;
663
+ }
770
664
  }
771
- normalizeTriggerDef(def) {
772
- return def
773
- .replace(/\s+/g, ' ')
774
- .replace(/\s*\(\s*/g, '(')
775
- .replace(/\s*\)\s*/g, ')')
776
- .trim();
665
+ async canonicalizeCheckExpressionWithPostgres(table, expression) {
666
+ const sourceTableIdentifier = table
667
+ .split('.')
668
+ .map((part) => this.quoteIdentifier(part))
669
+ .join('.');
670
+ const uniqueSuffix = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
671
+ const tableSlug = table.toLowerCase().replace(/[^a-z0-9_]/g, '_');
672
+ const tempTableName = `gqm_tmp_check_${tableSlug}_${uniqueSuffix}`;
673
+ const tempTableIdentifier = this.quoteIdentifier(tempTableName);
674
+ const constraintName = `gqm_tmp_check_${uniqueSuffix}`;
675
+ const constraintIdentifier = this.quoteIdentifier(constraintName);
676
+ const trx = await this.knex.transaction();
677
+ try {
678
+ await trx.raw(`CREATE TEMP TABLE ${tempTableIdentifier} (LIKE ${sourceTableIdentifier}) ON COMMIT DROP`);
679
+ await trx.raw(`ALTER TABLE ${tempTableIdentifier} ADD CONSTRAINT ${constraintIdentifier} CHECK (${expression})`);
680
+ const result = await trx.raw(`SELECT pg_get_constraintdef(c.oid, true) AS constraint_definition
681
+ FROM pg_constraint c
682
+ JOIN pg_class t
683
+ ON t.oid = c.conrelid
684
+ WHERE t.relname = ?
685
+ AND c.conname = ?
686
+ ORDER BY c.oid DESC
687
+ LIMIT 1`, [tempTableName, constraintName]);
688
+ const rows = 'rows' in result && Array.isArray(result.rows)
689
+ ? result.rows
690
+ : [];
691
+ const definition = rows[0]?.constraint_definition;
692
+ if (!definition) {
693
+ throw new Error(`Could not read canonical check definition for expression: ${expression}`);
694
+ }
695
+ return this.normalizeCheckExpression(this.extractCheckExpressionFromDefinition(definition));
696
+ }
697
+ finally {
698
+ try {
699
+ await trx.rollback();
700
+ }
701
+ catch {
702
+ // no-op: transaction may already be closed by driver after failure
703
+ }
704
+ }
705
+ }
706
+ extractCheckExpressionFromDefinition(definition) {
707
+ const trimmed = definition.trim();
708
+ const match = trimmed.match(/^CHECK\s*\(([\s\S]*)\)$/i);
709
+ if (!match) {
710
+ return trimmed;
711
+ }
712
+ return match[1];
713
+ }
714
+ quoteIdentifier(identifier) {
715
+ return `"${identifier.replace(/"/g, '""')}"`;
716
+ }
717
+ quoteQualifiedIdentifier(identifier) {
718
+ return identifier
719
+ .split('.')
720
+ .map((part) => this.quoteIdentifier(part))
721
+ .join('.');
777
722
  }
778
723
  /** Escape expression for embedding inside a template literal in generated code */
779
724
  escapeExpressionForRaw(expr) {
780
725
  return expr.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
781
726
  }
782
- addCheckConstraint(table, constraintName, expression, deferrable) {
727
+ addCheckConstraint(table, constraintName, expression) {
783
728
  const escaped = this.escapeExpressionForRaw(expression);
784
- const deferrableClause = deferrable ? ` DEFERRABLE ${deferrable}` : '';
785
- this.writer.writeLine(`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" CHECK (${escaped})${deferrableClause}\`);`);
729
+ this.writer.writeLine(`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" CHECK (${escaped})\`);`);
786
730
  this.writer.blankLine();
787
731
  }
788
732
  dropCheckConstraint(table, constraintName) {
789
733
  this.writer.writeLine(`await knex.raw('ALTER TABLE "${table}" DROP CONSTRAINT "${constraintName}"');`);
790
734
  this.writer.blankLine();
791
735
  }
792
- buildExcludeDef(entry) {
793
- const elementsStr = entry.elements
794
- .map((el) => ('column' in el ? `"${el.column}" WITH ${el.operator}` : `${el.expression} WITH ${el.operator}`))
795
- .join(', ');
796
- const whereClause = entry.where ? ` WHERE (${entry.where})` : '';
797
- const deferrableClause = entry.deferrable ? ` DEFERRABLE ${entry.deferrable}` : '';
798
- return `EXCLUDE USING ${entry.using} (${elementsStr})${whereClause}${deferrableClause}`;
799
- }
800
- addExcludeConstraint(table, constraintName, entry) {
801
- const def = this.buildExcludeDef(entry);
802
- const escaped = this.escapeExpressionForRaw(def);
803
- this.writer.writeLine(`await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" ${escaped}\`);`);
804
- this.writer.blankLine();
805
- }
806
- dropExcludeConstraint(table, constraintName) {
807
- this.writer.writeLine(`await knex.raw('ALTER TABLE "${table}" DROP CONSTRAINT "${constraintName}"');`);
808
- this.writer.blankLine();
809
- }
810
- buildConstraintTriggerDef(table, constraintName, entry) {
811
- const eventsStr = entry.events.join(' OR ');
812
- const deferrableClause = entry.deferrable ? ` DEFERRABLE ${entry.deferrable}` : '';
813
- const argsStr = entry.function.args?.length ? entry.function.args.map((a) => `"${a}"`).join(', ') : '';
814
- const executeClause = argsStr
815
- ? `EXECUTE FUNCTION ${entry.function.name}(${argsStr})`
816
- : `EXECUTE FUNCTION ${entry.function.name}()`;
817
- return `CREATE CONSTRAINT TRIGGER "${constraintName}" ${entry.when} ${eventsStr} ON "${table}"${deferrableClause} FOR EACH ${entry.forEach} ${executeClause}`;
818
- }
819
- addConstraintTrigger(table, constraintName, entry) {
820
- const eventsStr = entry.events.join(' OR ');
821
- const deferrableClause = entry.deferrable ? ` DEFERRABLE ${entry.deferrable}` : '';
822
- const argsStr = entry.function.args?.length ? entry.function.args.map((a) => `"${a}"`).join(', ') : '';
823
- const executeClause = argsStr
824
- ? `EXECUTE FUNCTION ${entry.function.name}(${argsStr})`
825
- : `EXECUTE FUNCTION ${entry.function.name}()`;
826
- this.writer.writeLine(`await knex.raw(\`CREATE CONSTRAINT TRIGGER "${constraintName}" ${entry.when} ${eventsStr} ON "${table}"${deferrableClause} FOR EACH ${entry.forEach} ${executeClause}\`);`);
827
- this.writer.blankLine();
828
- }
829
- dropConstraintTrigger(table, constraintName) {
830
- this.writer.writeLine(`await knex.raw('ALTER TABLE "${table}" DROP CONSTRAINT "${constraintName}"');`);
831
- this.writer.blankLine();
832
- }
833
736
  value(value) {
834
737
  if (typeof value === 'string') {
835
738
  return `'${value}'`;