@smartive/graphql-magic 23.5.0 → 23.6.0-next.2

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.
@@ -16,6 +16,7 @@ import {
16
16
  not,
17
17
  summonByName,
18
18
  typeToField,
19
+ validateCheckConstraint,
19
20
  } from '../models/utils';
20
21
  import { getColumnName } from '../resolvers';
21
22
  import { Value } from '../values';
@@ -37,6 +38,8 @@ export class MigrationGenerator {
37
38
  });
38
39
  private schema: SchemaInspectorType;
39
40
  private columns: Record<string, Column[]> = {};
41
+ /** table name -> constraint name -> check clause expression */
42
+ private existingCheckConstraints: Record<string, Map<string, string>> = {};
40
43
  private uuidUsed?: boolean;
41
44
  private nowUsed?: boolean;
42
45
  public needsMigration = false;
@@ -59,6 +62,26 @@ export class MigrationGenerator {
59
62
  this.columns[table] = await schema.columnInfo(table);
60
63
  }
61
64
 
65
+ const checkResult = await schema.knex.raw(
66
+ `SELECT tc.table_name, tc.constraint_name, cc.check_clause
67
+ FROM information_schema.table_constraints tc
68
+ JOIN information_schema.check_constraints cc
69
+ ON tc.constraint_schema = cc.constraint_schema AND tc.constraint_name = cc.constraint_name
70
+ WHERE tc.table_schema = ? AND tc.constraint_type = ?`,
71
+ ['public', 'CHECK'],
72
+ );
73
+ const rows: { table_name: string; constraint_name: string; check_clause: string }[] =
74
+ 'rows' in checkResult && Array.isArray((checkResult as { rows: unknown }).rows)
75
+ ? (checkResult as { rows: { table_name: string; constraint_name: string; check_clause: string }[] }).rows
76
+ : [];
77
+ for (const row of rows) {
78
+ const tableName = row.table_name;
79
+ if (!this.existingCheckConstraints[tableName]) {
80
+ this.existingCheckConstraints[tableName] = new Map();
81
+ }
82
+ this.existingCheckConstraints[tableName].set(row.constraint_name, row.check_clause);
83
+ }
84
+
62
85
  const up: Callbacks = [];
63
86
  const down: Callbacks = [];
64
87
 
@@ -148,6 +171,21 @@ export class MigrationGenerator {
148
171
  });
149
172
  });
150
173
 
174
+ if (model.constraints?.length) {
175
+ for (let i = 0; i < model.constraints.length; i++) {
176
+ const entry = model.constraints[i];
177
+ if (entry.kind === 'check') {
178
+ validateCheckConstraint(model, entry);
179
+ const table = model.name;
180
+ const constraintName = this.getCheckConstraintName(model, entry, i);
181
+ const expression = entry.expression;
182
+ up.push(() => {
183
+ this.addCheckConstraint(table, constraintName, expression);
184
+ });
185
+ }
186
+ }
187
+ }
188
+
151
189
  down.push(() => {
152
190
  this.dropTable(model.name);
153
191
  });
@@ -196,6 +234,40 @@ export class MigrationGenerator {
196
234
  (field) => (!field.generateAs || field.generateAs.type === 'expression') && this.hasChanged(model, field),
197
235
  );
198
236
  this.updateFields(model, existingFields, up, down);
237
+
238
+ if (model.constraints?.length) {
239
+ const existingMap = this.existingCheckConstraints[model.name];
240
+ for (let i = 0; i < model.constraints.length; i++) {
241
+ const entry = model.constraints[i];
242
+ if (entry.kind !== 'check') {
243
+ continue;
244
+ }
245
+ validateCheckConstraint(model, entry);
246
+ const table = model.name;
247
+ const constraintName = this.getCheckConstraintName(model, entry, i);
248
+ const newExpression = entry.expression;
249
+ const existingExpression = existingMap?.get(constraintName);
250
+ if (existingExpression === undefined) {
251
+ up.push(() => {
252
+ this.addCheckConstraint(table, constraintName, newExpression);
253
+ });
254
+ down.push(() => {
255
+ this.dropCheckConstraint(table, constraintName);
256
+ });
257
+ } else if (
258
+ this.normalizeCheckExpression(existingExpression) !== this.normalizeCheckExpression(newExpression)
259
+ ) {
260
+ up.push(() => {
261
+ this.dropCheckConstraint(table, constraintName);
262
+ this.addCheckConstraint(table, constraintName, newExpression);
263
+ });
264
+ down.push(() => {
265
+ this.dropCheckConstraint(table, constraintName);
266
+ this.addCheckConstraint(table, constraintName, existingExpression);
267
+ });
268
+ }
269
+ }
270
+ }
199
271
  }
200
272
 
201
273
  if (isUpdatableModel(model)) {
@@ -691,6 +763,32 @@ export class MigrationGenerator {
691
763
  this.writer.writeLine(`table.renameColumn('${from}', '${to}');`);
692
764
  }
693
765
 
766
+ private getCheckConstraintName(model: EntityModel, entry: { kind: string; name: string }, index: number): string {
767
+ return `${model.name}_${entry.name}_${entry.kind}_${index}`;
768
+ }
769
+
770
+ private normalizeCheckExpression(expr: string): string {
771
+ return expr.replace(/\s+/g, ' ').trim();
772
+ }
773
+
774
+ /** Escape expression for embedding inside a template literal in generated code */
775
+ private escapeExpressionForRaw(expr: string): string {
776
+ return expr.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
777
+ }
778
+
779
+ private addCheckConstraint(table: string, constraintName: string, expression: string) {
780
+ const escaped = this.escapeExpressionForRaw(expression);
781
+ this.writer.writeLine(
782
+ `await knex.raw(\`ALTER TABLE "${table}" ADD CONSTRAINT "${constraintName}" CHECK (${escaped})\`);`,
783
+ );
784
+ this.writer.blankLine();
785
+ }
786
+
787
+ private dropCheckConstraint(table: string, constraintName: string) {
788
+ this.writer.writeLine(`await knex.raw('ALTER TABLE "${table}" DROP CONSTRAINT "${constraintName}"');`);
789
+ this.writer.blankLine();
790
+ }
791
+
694
792
  private value(value: Value) {
695
793
  if (typeof value === 'string') {
696
794
  return `'${value}'`;
@@ -165,6 +165,8 @@ export type ModelDefinition = {
165
165
  */
166
166
  manyToManyRelation?: boolean;
167
167
 
168
+ constraints?: { kind: 'check'; name: string; expression: string }[];
169
+
168
170
  // temporary fields for the generation of migrations
169
171
  deleted?: true;
170
172
  oldName?: string;
@@ -3,7 +3,6 @@ import cloneDeep from 'lodash/cloneDeep';
3
3
  import kebabCase from 'lodash/kebabCase';
4
4
  import omit from 'lodash/omit';
5
5
  import pick from 'lodash/pick';
6
- import { isRelation } from '.';
7
6
  import {
8
7
  CustomFieldDefinition,
9
8
  EnumFieldDefinition,
@@ -33,7 +32,15 @@ import {
33
32
  ObjectFieldDefinition,
34
33
  RelationFieldDefinition,
35
34
  } from './model-definitions';
36
- import { get, getLabel, isManyToManyRelationEntityModel, summonByName, typeToField } from './utils';
35
+ import {
36
+ get,
37
+ getLabel,
38
+ isManyToManyRelationEntityModel,
39
+ isRelation,
40
+ summonByName,
41
+ typeToField,
42
+ validateCheckConstraint,
43
+ } from './utils';
37
44
 
38
45
  // These might one day become classes
39
46
 
@@ -350,6 +357,8 @@ export class EntityModel extends Model {
350
357
  defaultOrderBy?: OrderBy[];
351
358
  fields: EntityField[];
352
359
 
360
+ constraints?: { kind: 'check'; name: string; expression: string }[];
361
+
353
362
  // temporary fields for the generation of migrations
354
363
  deleted?: true;
355
364
  oldName?: string;
@@ -379,6 +388,13 @@ export class EntityModel extends Model {
379
388
  for (const field of definition.fields) {
380
389
  this.fieldsByName[field.name] = field;
381
390
  }
391
+ if (this.constraints?.length) {
392
+ for (const constraint of this.constraints) {
393
+ if (constraint.kind === 'check') {
394
+ validateCheckConstraint(this, constraint);
395
+ }
396
+ }
397
+ }
382
398
  }
383
399
 
384
400
  public getField(name: string) {
@@ -1,7 +1,7 @@
1
1
  import camelCase from 'lodash/camelCase';
2
2
  import lodashGet from 'lodash/get';
3
3
  import startCase from 'lodash/startCase';
4
- import { EntityModelDefinition } from '..';
4
+ import { EntityModelDefinition, getColumnName } from '..';
5
5
  import {
6
6
  CustomField,
7
7
  EntityField,
@@ -217,3 +217,40 @@ export const isManyToManyRelationEntityModel = ({
217
217
 
218
218
  return manyToManyRelation;
219
219
  };
220
+
221
+ /**
222
+ * Extract column name candidates from a PostgreSQL CHECK constraint expression.
223
+ * Uses only double-quoted identifiers to avoid false positives (e.g. SQL functions).
224
+ * Matches the same naming as getColumnName in resolvers/utils (used for DB column names).
225
+ */
226
+ export const extractColumnReferencesFromCheckExpression = (expression: string): string[] => {
227
+ const normalized = expression.replace(/\s+/g, ' ').trim();
228
+ const quotedIdentifiers: string[] = [];
229
+ const re = /"([^"]+)"/g;
230
+ let match: RegExpExecArray | null;
231
+ while ((match = re.exec(normalized)) !== null) {
232
+ quotedIdentifiers.push(match[1]);
233
+ }
234
+
235
+ return [...new Set(quotedIdentifiers)];
236
+ };
237
+
238
+ /**
239
+ * Validate that every column referenced in the check constraint expression exists on the model.
240
+ * Throws with a clear message if an inferred column is not found.
241
+ */
242
+ export const validateCheckConstraint = (model: EntityModel, constraint: { name: string; expression: string }): void => {
243
+ const identifiers = extractColumnReferencesFromCheckExpression(constraint.expression);
244
+ if (identifiers.length === 0) {
245
+ return;
246
+ }
247
+ const validColumnNames = new Set(model.fields.map((f) => getColumnName(f)));
248
+ for (const identifier of identifiers) {
249
+ if (!validColumnNames.has(identifier)) {
250
+ const validList = [...validColumnNames].sort().join(', ');
251
+ throw new Error(
252
+ `Constraint "${constraint.name}" references column "${identifier}" which does not exist on model ${model.name}. Valid columns: ${validList}`,
253
+ );
254
+ }
255
+ }
256
+ };
@@ -114,7 +114,7 @@ export function normalizeValue(value: Value, type: TypeNode, schema: GraphQLSche
114
114
  }
115
115
 
116
116
  export const normalizeValueByTypeDefinition = (value: Value, type: Maybe<TypeDefinitionNode>, schema: GraphQLSchema) => {
117
- if (!type || type.kind !== Kind.INPUT_OBJECT_TYPE_DEFINITION) {
117
+ if (type?.kind !== Kind.INPUT_OBJECT_TYPE_DEFINITION) {
118
118
  return value;
119
119
  }
120
120
 
@@ -0,0 +1,83 @@
1
+ import { ModelDefinitions, Models } from '../../src/models';
2
+ import { extractColumnReferencesFromCheckExpression, validateCheckConstraint } from '../../src/models/utils';
3
+
4
+ describe('check constraints', () => {
5
+ const modelDefinitions: ModelDefinitions = [
6
+ {
7
+ kind: 'entity',
8
+ name: 'Product',
9
+ fields: [
10
+ { name: 'score', type: 'Int' },
11
+ { name: 'status', type: 'String' },
12
+ { name: 'parent', kind: 'relation', type: 'Product', toOne: true, reverse: 'children' },
13
+ ],
14
+ },
15
+ ];
16
+ const models = new Models(modelDefinitions);
17
+ const productModel = models.entities.find((e) => e.name === 'Product')!;
18
+
19
+ describe('extractColumnReferencesFromCheckExpression', () => {
20
+ it('returns empty array when expression has no double-quoted identifiers', () => {
21
+ expect(extractColumnReferencesFromCheckExpression('1 = 1')).toEqual([]);
22
+ expect(extractColumnReferencesFromCheckExpression("status = 'active'")).toEqual([]);
23
+ });
24
+
25
+ it('extracts single double-quoted column name', () => {
26
+ expect(extractColumnReferencesFromCheckExpression('"score" >= 0')).toEqual(['score']);
27
+ });
28
+
29
+ it('extracts multiple double-quoted column names and deduplicates', () => {
30
+ expect(extractColumnReferencesFromCheckExpression('"score" >= 0 AND "status" = \'ok\' AND "score" < 100')).toEqual([
31
+ 'score',
32
+ 'status',
33
+ ]);
34
+ });
35
+
36
+ it('normalizes whitespace before extracting', () => {
37
+ expect(extractColumnReferencesFromCheckExpression(' "score" >= 0 ')).toEqual(['score']);
38
+ });
39
+ });
40
+
41
+ describe('validateCheckConstraint', () => {
42
+ it('does not throw when expression has no column references', () => {
43
+ expect(() => validateCheckConstraint(productModel, { name: 'dummy', expression: '1 = 1' })).not.toThrow();
44
+ });
45
+
46
+ it('does not throw when all referenced columns exist on the model', () => {
47
+ expect(() => validateCheckConstraint(productModel, { name: 'valid', expression: '"score" >= 0' })).not.toThrow();
48
+ expect(() =>
49
+ validateCheckConstraint(productModel, {
50
+ name: 'valid',
51
+ expression: '"score" >= 0 AND "status" IS NOT NULL',
52
+ }),
53
+ ).not.toThrow();
54
+ });
55
+
56
+ it('uses relation column name (foreignKey) when validating', () => {
57
+ expect(() =>
58
+ validateCheckConstraint(productModel, {
59
+ name: 'valid',
60
+ expression: '"parentId" IS NOT NULL',
61
+ }),
62
+ ).not.toThrow();
63
+ });
64
+
65
+ it('throws when expression references a column that does not exist', () => {
66
+ expect(() =>
67
+ validateCheckConstraint(productModel, {
68
+ name: 'bad',
69
+ expression: '"unknown_column" >= 0',
70
+ }),
71
+ ).toThrow(/Constraint "bad" references column "unknown_column" which does not exist on model Product/);
72
+ });
73
+
74
+ it('error message includes valid column names', () => {
75
+ expect(() =>
76
+ validateCheckConstraint(productModel, {
77
+ name: 'bad',
78
+ expression: '"nope" = 1',
79
+ }),
80
+ ).toThrow(/Valid columns:.*\bscore\b.*\bstatus\b/);
81
+ });
82
+ });
83
+ });