@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.
- package/.github/workflows/docs.yml +3 -3
- package/.github/workflows/release.yml +2 -2
- package/.github/workflows/testing.yml +1 -1
- package/CHANGELOG.md +3 -3
- package/dist/bin/gqm.cjs +117 -0
- package/dist/cjs/index.cjs +122 -1
- package/dist/esm/migrations/generate.d.ts +8 -0
- package/dist/esm/migrations/generate.js +83 -1
- package/dist/esm/migrations/generate.js.map +1 -1
- package/dist/esm/models/model-definitions.d.ts +5 -0
- package/dist/esm/models/models.d.ts +5 -0
- package/dist/esm/models/models.js +9 -2
- package/dist/esm/models/models.js.map +1 -1
- package/dist/esm/models/utils.d.ts +14 -0
- package/dist/esm/models/utils.js +33 -0
- package/dist/esm/models/utils.js.map +1 -1
- package/dist/esm/resolvers/arguments.js +1 -1
- package/dist/esm/resolvers/arguments.js.map +1 -1
- package/docker-compose.yml +1 -1
- package/docs/docs/2-models.md +28 -0
- package/docs/docs/5-migrations.md +9 -0
- package/eslint.config.mjs +4 -1
- package/package.json +2 -1
- package/renovate.json +1 -29
- package/src/migrations/generate.ts +98 -0
- package/src/models/model-definitions.ts +2 -0
- package/src/models/models.ts +18 -2
- package/src/models/utils.ts +38 -1
- package/src/resolvers/arguments.ts +1 -1
- package/tests/unit/constraints.spec.ts +83 -0
|
@@ -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;
|
package/src/models/models.ts
CHANGED
|
@@ -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 {
|
|
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) {
|
package/src/models/utils.ts
CHANGED
|
@@ -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 (
|
|
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
|
+
});
|