@smartive/graphql-magic 23.6.0 → 23.6.1-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.
@@ -1,5 +1,15 @@
1
1
  import CodeBlockWriter from 'code-block-writer';
2
- import { EntityField, get, getColumnName, isCustomField, isInTable, isRootModel, not } from '..';
2
+ import {
3
+ EntityField,
4
+ get,
5
+ getColumnName,
6
+ isCustomField,
7
+ isGenerateAsField,
8
+ isInTable,
9
+ isRootModel,
10
+ isStoredInDatabase,
11
+ not,
12
+ } from '..';
3
13
  import { Models } from '../models/models';
4
14
  import { DATE_CLASS, DATE_CLASS_IMPORT, DateLibrary } from '../utils/dates';
5
15
 
@@ -60,7 +70,7 @@ export const generateDBModels = (models: Models, dateLibrary: DateLibrary) => {
60
70
  writer
61
71
  .write(`export type ${model.name} = `)
62
72
  .inlineBlock(() => {
63
- for (const field of fields.filter(not(isCustomField))) {
73
+ for (const field of fields.filter(not(isCustomField)).filter(isStoredInDatabase)) {
64
74
  writer
65
75
  .write(`'${getColumnName(field)}': ${getFieldType(field, dateLibrary)}${field.nonNull ? '' : ' | null'};`)
66
76
  .newLine();
@@ -71,7 +81,7 @@ export const generateDBModels = (models: Models, dateLibrary: DateLibrary) => {
71
81
  writer
72
82
  .write(`export type ${model.name}Initializer = `)
73
83
  .inlineBlock(() => {
74
- for (const field of fields.filter(not(isCustomField)).filter(isInTable)) {
84
+ for (const field of fields.filter(not(isCustomField)).filter(isInTable).filter(not(isGenerateAsField))) {
75
85
  writer
76
86
  .write(
77
87
  `'${getColumnName(field)}'${field.nonNull && field.defaultValue === undefined ? '' : '?'}: ${getFieldType(
@@ -88,7 +98,7 @@ export const generateDBModels = (models: Models, dateLibrary: DateLibrary) => {
88
98
  writer
89
99
  .write(`export type ${model.name}Mutator = `)
90
100
  .inlineBlock(() => {
91
- for (const field of fields.filter(not(isCustomField)).filter(isInTable)) {
101
+ for (const field of fields.filter(not(isCustomField)).filter(isInTable).filter(not(isGenerateAsField))) {
92
102
  writer
93
103
  .write(
94
104
  `'${getColumnName(field)}'?: ${getFieldType(field, dateLibrary, true)}${field.list ? ' | string' : ''}${
@@ -104,7 +114,7 @@ export const generateDBModels = (models: Models, dateLibrary: DateLibrary) => {
104
114
  writer
105
115
  .write(`export type ${model.name}Seed = `)
106
116
  .inlineBlock(() => {
107
- for (const field of fields.filter(not(isCustomField))) {
117
+ for (const field of fields.filter(not(isCustomField)).filter(not(isGenerateAsField))) {
108
118
  if (model.parent && field.name === 'type') {
109
119
  continue;
110
120
  }
@@ -9,6 +9,7 @@ import {
9
9
  and,
10
10
  get,
11
11
  isCreatableModel,
12
+ isGenerateAsField,
12
13
  isInherited,
13
14
  isUpdatableField,
14
15
  isUpdatableModel,
@@ -300,7 +301,7 @@ export class MigrationGenerator {
300
301
 
301
302
  for (const { name, kind } of model.fields
302
303
  .filter(isUpdatableField)
303
- .filter((f) => !(f.generateAs?.type === 'expression'))) {
304
+ .filter(not(isGenerateAsField))) {
304
305
  const col = kind === 'relation' ? `${name}Id` : name;
305
306
 
306
307
  writer.writeLine(`${col}: row.${col},`);
@@ -332,10 +333,10 @@ export class MigrationGenerator {
332
333
 
333
334
  const missingRevisionFields = model.fields
334
335
  .filter(isUpdatableField)
336
+ .filter(not(isGenerateAsField))
335
337
  .filter(
336
338
  ({ name, ...field }) =>
337
339
  field.kind !== 'custom' &&
338
- !(field.generateAs?.type === 'expression') &&
339
340
  !this.getColumn(revisionTable, field.kind === 'relation' ? field.foreignKey || `${name}Id` : name),
340
341
  );
341
342
 
@@ -534,7 +535,7 @@ export class MigrationGenerator {
534
535
  });
535
536
 
536
537
  if (isUpdatableModel(model)) {
537
- const updatableFields = fields.filter(isUpdatableField).filter((f) => !(f.generateAs?.type === 'expression'));
538
+ const updatableFields = fields.filter(isUpdatableField).filter(not(isGenerateAsField));
538
539
  if (!updatableFields.length) {
539
540
  return;
540
541
  }
@@ -588,7 +589,7 @@ export class MigrationGenerator {
588
589
  });
589
590
 
590
591
  if (isUpdatableModel(model)) {
591
- const updatableFields = fields.filter(isUpdatableField).filter((f) => !(f.generateAs?.type === 'expression'));
592
+ const updatableFields = fields.filter(isUpdatableField).filter(not(isGenerateAsField));
592
593
  if (!updatableFields.length) {
593
594
  return;
594
595
  }
@@ -632,9 +633,7 @@ export class MigrationGenerator {
632
633
  }
633
634
  }
634
635
 
635
- for (const field of model.fields
636
- .filter(and(isUpdatableField, not(isInherited)))
637
- .filter((f) => !(f.generateAs?.type === 'expression'))) {
636
+ for (const field of model.fields.filter(and(isUpdatableField, not(isInherited))).filter(not(isGenerateAsField))) {
638
637
  this.column(field, { setUnique: false, setDefault: false });
639
638
  }
640
639
  });
@@ -88,6 +88,12 @@ export const isQueriableField = ({ queriable }: EntityField) => queriable !== fa
88
88
 
89
89
  export const isCustomField = (field: EntityField): field is CustomField => field.kind === 'custom';
90
90
 
91
+ /** True if field is computed (generateAs); not user-settable in insert/update. */
92
+ export const isGenerateAsField = (field: EntityField) => !!field.generateAs;
93
+
94
+ /** True if field exists as a column in the DB (excludes expression-only fields). */
95
+ export const isStoredInDatabase = (field: EntityField) => field.generateAs?.type !== 'expression';
96
+
91
97
  export const isVisible = ({ hidden }: EntityField) => hidden !== true;
92
98
 
93
99
  export const isSimpleField = and(not(isRelation), not(isCustomField));
@@ -2,7 +2,7 @@ import { Knex } from 'knex';
2
2
  import { FullContext } from '../context';
3
3
  import { NotFoundError, PermissionError } from '../errors';
4
4
  import { EntityModel } from '../models/models';
5
- import { get, isRelation } from '../models/utils';
5
+ import { get, isGenerateAsField, isRelation, not } from '../models/utils';
6
6
  import { AliasGenerator, getColumnName, hash, ors } from '../resolvers/utils';
7
7
  import { PermissionAction, PermissionLink, PermissionStack } from './generate';
8
8
 
@@ -155,9 +155,9 @@ export const checkCanWrite = async (
155
155
  const query = ctx.knex.first();
156
156
  let linked = false;
157
157
 
158
- for (const field of model.fields.filter(
159
- (field) => field.generated || (action === 'CREATE' ? field.creatable : field.updatable),
160
- )) {
158
+ for (const field of model.fields
159
+ .filter(not(isGenerateAsField))
160
+ .filter((field) => field.generated || (action === 'CREATE' ? field.creatable : field.updatable))) {
161
161
  const fieldPermissions = field[action === 'CREATE' ? 'creatable' : 'updatable'];
162
162
  const role = getRole(ctx);
163
163
  if (
@@ -4,7 +4,7 @@ import { Context } from '../context';
4
4
  import { ForbiddenError, GraphQLError } from '../errors';
5
5
  import { EntityField, EntityModel } from '../models/models';
6
6
  import { Entity, MutationContext, Trigger } from '../models/mutation-hook';
7
- import { get, isPrimitive, it, typeToField } from '../models/utils';
7
+ import { get, isGenerateAsField, isPrimitive, it, not, typeToField } from '../models/utils';
8
8
  import { applyPermissions, checkCanWrite, getEntityToMutate } from '../permissions/check';
9
9
  import { anyDateToLuxon } from '../utils';
10
10
  import { resolve } from './resolver';
@@ -87,7 +87,7 @@ export const createEntity = async (
87
87
  if (model.parent) {
88
88
  const rootInput = {};
89
89
  const childInput = { id };
90
- for (const field of model.fields) {
90
+ for (const field of model.fields.filter(not(isGenerateAsField))) {
91
91
  const columnName = field.kind === 'relation' ? `${field.name}Id` : field.name;
92
92
  if (columnName in normalizedInput) {
93
93
  if (field.inherited) {
@@ -100,7 +100,12 @@ export const createEntity = async (
100
100
  await ctx.knex(model.parent).insert(rootInput);
101
101
  await ctx.knex(model.name).insert(childInput);
102
102
  } else {
103
- await ctx.knex(model.name).insert(normalizedInput);
103
+ const insertData = { ...normalizedInput };
104
+ for (const field of model.fields.filter(isGenerateAsField)) {
105
+ const columnName = field.kind === 'relation' ? `${field.name}Id` : field.name;
106
+ delete insertData[columnName];
107
+ }
108
+ await ctx.knex(model.name).insert(insertData);
104
109
  }
105
110
  await createRevision(model, normalizedInput, ctx);
106
111
  await ctx.mutationHook?.({
@@ -555,7 +560,7 @@ export const createRevision = async (model: EntityModel, data: Entity, ctx: Muta
555
560
  }
556
561
  const childRevisionData = { id: revisionId };
557
562
 
558
- for (const field of model.fields.filter(({ updatable }) => updatable)) {
563
+ for (const field of model.fields.filter(({ updatable }) => updatable).filter(not(isGenerateAsField))) {
559
564
  const col = field.kind === 'relation' ? `${field.name}Id` : field.name;
560
565
  let value;
561
566
  if (field.nonNull && (!(col in data) || col === undefined || col === null)) {
@@ -631,7 +636,7 @@ const doUpdate = async (model: EntityModel, currentEntity: Entity, update: Entit
631
636
  if (model.parent) {
632
637
  const rootInput = {};
633
638
  const childInput = {};
634
- for (const field of model.fields) {
639
+ for (const field of model.fields.filter(not(isGenerateAsField))) {
635
640
  const columnName = field.kind === 'relation' ? `${field.name}Id` : field.name;
636
641
  if (columnName in update) {
637
642
  if (field.inherited) {
@@ -648,7 +653,12 @@ const doUpdate = async (model: EntityModel, currentEntity: Entity, update: Entit
648
653
  await ctx.knex(model.name).where({ id: currentEntity.id }).update(childInput);
649
654
  }
650
655
  } else {
651
- await ctx.knex(model.name).where({ id: currentEntity.id }).update(update);
656
+ const updateData = { ...update };
657
+ for (const field of model.fields.filter(isGenerateAsField)) {
658
+ const columnName = field.kind === 'relation' ? `${field.name}Id` : field.name;
659
+ delete updateData[columnName];
660
+ }
661
+ await ctx.knex(model.name).where({ id: currentEntity.id }).update(updateData);
652
662
  }
653
663
  await createRevision(model, { ...currentEntity, ...update }, ctx);
654
664
  };
@@ -1,6 +1,6 @@
1
1
  import { DefinitionNode, DocumentNode, GraphQLSchema, buildASTSchema, print } from 'graphql';
2
2
  import { Models } from '../models/models';
3
- import { isQueriableField, isRootModel, typeToField } from '../models/utils';
3
+ import { isGenerateAsField, isQueriableField, isRootModel, not, typeToField } from '../models/utils';
4
4
  import { Field, document, enm, iface, input, object, scalar, union } from './utils';
5
5
 
6
6
  export const generateDefinitions = ({
@@ -135,6 +135,7 @@ export const generateDefinitions = ({
135
135
  `Create${model.name}`,
136
136
  model.fields
137
137
  .filter(({ creatable }) => creatable)
138
+ .filter(not(isGenerateAsField))
138
139
  .map((field) =>
139
140
  field.kind === 'relation'
140
141
  ? { name: `${field.name}Id`, type: 'ID', nonNull: field.nonNull }
@@ -155,6 +156,7 @@ export const generateDefinitions = ({
155
156
  `Update${model.name}`,
156
157
  model.fields
157
158
  .filter(({ updatable }) => updatable)
159
+ .filter(not(isGenerateAsField))
158
160
  .map((field) =>
159
161
  field.kind === 'relation'
160
162
  ? { name: `${field.name}Id`, type: 'ID' }
@@ -0,0 +1,238 @@
1
+ import { Kind } from 'graphql';
2
+ import { generateDBModels } from '../../src/db/generate';
3
+ import {
4
+ isGenerateAsField,
5
+ isStoredInDatabase,
6
+ } from '../../src/models/utils';
7
+ import { ModelDefinitions, Models } from '../../src/models';
8
+ import type { EntityField } from '../../src/models/models';
9
+ import { generateDefinitions } from '../../src/schema/generate';
10
+
11
+ jest.mock('code-block-writer', () => {
12
+ const Writer = class {
13
+ private _out = '';
14
+
15
+ write(s: string) {
16
+ this._out += s;
17
+ return this;
18
+ }
19
+
20
+ blankLine() {
21
+ this._out += '\n';
22
+ return this;
23
+ }
24
+
25
+ newLine() {
26
+ this._out += '\n';
27
+ return this;
28
+ }
29
+
30
+ inlineBlock(fn: () => void) {
31
+ this._out += ' {\n';
32
+ fn();
33
+ this._out += '}';
34
+ return this;
35
+ }
36
+
37
+ toString() {
38
+ return this._out;
39
+ }
40
+ };
41
+ return { __esModule: true, default: { default: Writer } };
42
+ });
43
+
44
+ describe('generateAs helpers', () => {
45
+ describe('isGenerateAsField', () => {
46
+ it('returns false when field.generateAs is undefined', () => {
47
+ const field: EntityField = { name: 'price', type: 'Float' };
48
+ expect(isGenerateAsField(field)).toBe(false);
49
+ });
50
+
51
+ it('returns true when field has generateAs with type expression', () => {
52
+ const field: EntityField = {
53
+ name: 'total',
54
+ type: 'Float',
55
+ generateAs: { expression: 'price * quantity', type: 'expression' },
56
+ };
57
+ expect(isGenerateAsField(field)).toBe(true);
58
+ });
59
+
60
+ it('returns true when field has generateAs with type stored', () => {
61
+ const field: EntityField = {
62
+ name: 'total',
63
+ type: 'Float',
64
+ generateAs: { expression: 'price * quantity', type: 'stored' },
65
+ };
66
+ expect(isGenerateAsField(field)).toBe(true);
67
+ });
68
+
69
+ it('returns true when field has generateAs with type virtual', () => {
70
+ const field: EntityField = {
71
+ name: 'total',
72
+ type: 'Float',
73
+ generateAs: { expression: 'price * quantity', type: 'virtual' },
74
+ };
75
+ expect(isGenerateAsField(field)).toBe(true);
76
+ });
77
+ });
78
+
79
+ describe('isStoredInDatabase', () => {
80
+ it('returns true when field has no generateAs (normal column)', () => {
81
+ const field: EntityField = { name: 'price', type: 'Float' };
82
+ expect(isStoredInDatabase(field)).toBe(true);
83
+ });
84
+
85
+ it('returns false when field has generateAs.type expression', () => {
86
+ const field: EntityField = {
87
+ name: 'total',
88
+ type: 'Float',
89
+ generateAs: { expression: 'price * quantity', type: 'expression' },
90
+ };
91
+ expect(isStoredInDatabase(field)).toBe(false);
92
+ });
93
+
94
+ it('returns true when field has generateAs.type stored', () => {
95
+ const field: EntityField = {
96
+ name: 'total',
97
+ type: 'Float',
98
+ generateAs: { expression: 'price * quantity', type: 'stored' },
99
+ };
100
+ expect(isStoredInDatabase(field)).toBe(true);
101
+ });
102
+
103
+ it('returns true when field has generateAs.type virtual', () => {
104
+ const field: EntityField = {
105
+ name: 'total',
106
+ type: 'Float',
107
+ generateAs: { expression: 'price * quantity', type: 'virtual' },
108
+ };
109
+ expect(isStoredInDatabase(field)).toBe(true);
110
+ });
111
+ });
112
+ });
113
+
114
+ describe('generateDBModels', () => {
115
+ const productModelDefinitions: ModelDefinitions = [
116
+ {
117
+ kind: 'entity',
118
+ name: 'Product',
119
+ fields: [
120
+ { name: 'price', type: 'Float' },
121
+ { name: 'quantity', type: 'Int' },
122
+ {
123
+ name: 'totalExpression',
124
+ type: 'Float',
125
+ generateAs: { expression: 'price * quantity', type: 'expression' },
126
+ },
127
+ {
128
+ name: 'totalStored',
129
+ type: 'Float',
130
+ generateAs: { expression: 'price * quantity', type: 'stored' },
131
+ },
132
+ ],
133
+ },
134
+ ];
135
+
136
+ it('entity type includes normal and stored columns but not expression-only fields', () => {
137
+ const models = new Models(productModelDefinitions);
138
+ const output = generateDBModels(models, 'luxon');
139
+
140
+ expect(output).toContain("'price'");
141
+ expect(output).toContain("'quantity'");
142
+ expect(output).toContain("'totalStored'");
143
+ expect(output).not.toContain("'totalExpression'");
144
+ });
145
+
146
+ it('Initializer includes only user-settable fields (excludes all generateAs)', () => {
147
+ const models = new Models(productModelDefinitions);
148
+ const output = generateDBModels(models, 'luxon');
149
+
150
+ const start = output.indexOf('export type ProductInitializer');
151
+ const end = output.indexOf('export type', start + 1);
152
+ const block = end === -1 ? output.slice(start) : output.slice(start, end);
153
+ expect(block).toContain("'price'");
154
+ expect(block).toContain("'quantity'");
155
+ expect(block).not.toContain("'totalExpression'");
156
+ expect(block).not.toContain("'totalStored'");
157
+ });
158
+
159
+ it('Mutator includes only user-settable fields (excludes all generateAs)', () => {
160
+ const models = new Models(productModelDefinitions);
161
+ const output = generateDBModels(models, 'luxon');
162
+
163
+ const start = output.indexOf('export type ProductMutator');
164
+ const end = output.indexOf('export type', start + 1);
165
+ const block = end === -1 ? output.slice(start) : output.slice(start, end);
166
+ expect(block).toContain("'price'");
167
+ expect(block).toContain("'quantity'");
168
+ expect(block).not.toContain("'totalExpression'");
169
+ expect(block).not.toContain("'totalStored'");
170
+ });
171
+
172
+ it('Seed type excludes all generateAs fields', () => {
173
+ const models = new Models(productModelDefinitions);
174
+ const output = generateDBModels(models, 'luxon');
175
+
176
+ const start = output.indexOf('export type ProductSeed');
177
+ const end = output.indexOf('export type', start + 1);
178
+ const block = end === -1 ? output.slice(start) : output.slice(start, end);
179
+ expect(block).toContain("'price'");
180
+ expect(block).toContain("'quantity'");
181
+ expect(block).not.toContain("'totalExpression'");
182
+ expect(block).not.toContain("'totalStored'");
183
+ });
184
+ });
185
+
186
+ describe('generateDefinitions Create/Update inputs', () => {
187
+ const itemModelDefinitions: ModelDefinitions = [
188
+ { kind: 'entity', name: 'User', fields: [{ name: 'id', type: 'ID' }] },
189
+ {
190
+ kind: 'entity',
191
+ name: 'Item',
192
+ creatable: true,
193
+ updatable: true,
194
+ fields: [
195
+ { name: 'name', type: 'String', creatable: true, updatable: true },
196
+ {
197
+ name: 'computed',
198
+ type: 'Int',
199
+ creatable: true,
200
+ updatable: true,
201
+ generateAs: { expression: '1', type: 'expression' },
202
+ },
203
+ ],
204
+ },
205
+ ];
206
+
207
+ it('Create input does not include generateAs fields', () => {
208
+ const models = new Models(itemModelDefinitions);
209
+ const definitions = generateDefinitions(models);
210
+
211
+ const createInput = definitions.find(
212
+ (d) => d.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION && d.name?.value === 'CreateItem',
213
+ );
214
+ expect(createInput).toBeDefined();
215
+ expect(createInput!.kind).toBe(Kind.INPUT_OBJECT_TYPE_DEFINITION);
216
+
217
+ const fields = (createInput as { fields?: readonly { name: { value: string } }[] }).fields ?? [];
218
+ const fieldNames = fields.map((f) => f.name.value);
219
+ expect(fieldNames).toContain('name');
220
+ expect(fieldNames).not.toContain('computed');
221
+ });
222
+
223
+ it('Update input does not include generateAs fields', () => {
224
+ const models = new Models(itemModelDefinitions);
225
+ const definitions = generateDefinitions(models);
226
+
227
+ const updateInput = definitions.find(
228
+ (d) => d.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION && d.name?.value === 'UpdateItem',
229
+ );
230
+ expect(updateInput).toBeDefined();
231
+ expect(updateInput!.kind).toBe(Kind.INPUT_OBJECT_TYPE_DEFINITION);
232
+
233
+ const fields = (updateInput as { fields?: readonly { name: { value: string } }[] }).fields ?? [];
234
+ const fieldNames = fields.map((f) => f.name.value);
235
+ expect(fieldNames).toContain('name');
236
+ expect(fieldNames).not.toContain('computed');
237
+ });
238
+ });