@smartive/graphql-magic 19.3.0 → 19.3.1-next.1

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.
@@ -10,10 +10,13 @@ import { anyDateToLuxon } from '../utils';
10
10
  import { resolve } from './resolver';
11
11
  import { AliasGenerator, fetchDisplay, getTechnicalDisplay } from './utils';
12
12
 
13
+ const withTransaction = async <T extends MutationContext>(ctx: T, fn: (ctx: T) => Promise<any>) =>
14
+ await ctx.knex.transaction(async (knex) => fn({ ...ctx, knex }));
15
+
13
16
  export const mutationResolver = async (_parent: any, args: any, partialCtx: Context, info: GraphQLResolveInfo) => {
14
- return await partialCtx.knex.transaction(async (knex) => {
15
- const [, mutation, modelName] = it(info.fieldName.match(/^(create|update|delete|restore)(.+)$/));
16
- const ctx = { ...partialCtx, knex, info, aliases: new AliasGenerator() };
17
+ const [, mutation, modelName] = it(info.fieldName.match(/^(create|update|delete|restore)(.+)$/));
18
+
19
+ return await withTransaction({ ...partialCtx, info, aliases: new AliasGenerator() }, async (ctx) => {
17
20
  switch (mutation) {
18
21
  case 'create': {
19
22
  const id = await createEntity(modelName, args.data, ctx, 'mutation');
@@ -53,78 +56,80 @@ export const createEntity = async (
53
56
  input: Entity,
54
57
  ctx: MutationContext,
55
58
  trigger: Trigger = 'direct-call',
56
- ) => {
57
- const model = ctx.models.getModel(modelName, 'entity');
58
- const normalizedInput = { ...input };
59
- if (!normalizedInput.id) {
60
- normalizedInput.id = uuid();
61
- }
62
- const id = normalizedInput.id as string;
63
- if (!normalizedInput.createdAt) {
64
- normalizedInput.createdAt = ctx.now;
65
- }
66
- if (!normalizedInput.createdById) {
67
- normalizedInput.createdById = ctx.user?.id;
68
- }
69
- if (model.parent) {
70
- normalizedInput.type = model.name;
71
- }
72
- sanitize(ctx, model, normalizedInput);
73
-
74
- await checkCanWrite(ctx, model, normalizedInput, 'CREATE');
75
- await ctx.handleUploads?.(normalizedInput);
76
-
77
- await ctx.mutationHook?.({
78
- model,
79
- action: 'create',
80
- trigger,
81
- when: 'before',
82
- data: { prev: {}, input, normalizedInput, next: normalizedInput },
83
- ctx,
84
- });
59
+ ) =>
60
+ withTransaction(ctx, async (ctx) => {
61
+ const model = ctx.models.getModel(modelName, 'entity');
62
+ const normalizedInput = { ...input };
63
+ if (!normalizedInput.id) {
64
+ normalizedInput.id = uuid();
65
+ }
66
+ const id = normalizedInput.id as string;
67
+ if (!normalizedInput.createdAt) {
68
+ normalizedInput.createdAt = ctx.now;
69
+ }
70
+ if (!normalizedInput.createdById) {
71
+ normalizedInput.createdById = ctx.user?.id;
72
+ }
73
+ if (model.parent) {
74
+ normalizedInput.type = model.name;
75
+ }
76
+ sanitize(ctx, model, normalizedInput);
85
77
 
86
- if (model.parent) {
87
- const rootInput = {};
88
- const childInput = { id };
89
- for (const field of model.fields) {
90
- const columnName = field.kind === 'relation' ? `${field.name}Id` : field.name;
91
- if (columnName in normalizedInput) {
92
- if (field.inherited) {
93
- rootInput[columnName] = normalizedInput[columnName];
94
- } else {
95
- childInput[columnName] = normalizedInput[columnName];
78
+ await checkCanWrite(ctx, model, normalizedInput, 'CREATE');
79
+ await ctx.handleUploads?.(normalizedInput);
80
+
81
+ await ctx.mutationHook?.({
82
+ model,
83
+ action: 'create',
84
+ trigger,
85
+ when: 'before',
86
+ data: { prev: {}, input, normalizedInput, next: normalizedInput },
87
+ ctx,
88
+ });
89
+
90
+ if (model.parent) {
91
+ const rootInput = {};
92
+ const childInput = { id };
93
+ for (const field of model.fields) {
94
+ const columnName = field.kind === 'relation' ? `${field.name}Id` : field.name;
95
+ if (columnName in normalizedInput) {
96
+ if (field.inherited) {
97
+ rootInput[columnName] = normalizedInput[columnName];
98
+ } else {
99
+ childInput[columnName] = normalizedInput[columnName];
100
+ }
96
101
  }
97
102
  }
103
+ await ctx.knex(model.parent).insert(rootInput);
104
+ await ctx.knex(model.name).insert(childInput);
105
+ } else {
106
+ await ctx.knex(model.name).insert(normalizedInput);
98
107
  }
99
- await ctx.knex(model.parent).insert(rootInput);
100
- await ctx.knex(model.name).insert(childInput);
101
- } else {
102
- await ctx.knex(model.name).insert(normalizedInput);
103
- }
104
- await createRevision(model, normalizedInput, ctx);
105
- await ctx.mutationHook?.({
106
- model,
107
- action: 'create',
108
- trigger,
109
- when: 'after',
110
- data: { prev: {}, input, normalizedInput, next: normalizedInput },
111
- ctx,
112
- });
108
+ await createRevision(model, normalizedInput, ctx);
109
+ await ctx.mutationHook?.({
110
+ model,
111
+ action: 'create',
112
+ trigger,
113
+ when: 'after',
114
+ data: { prev: {}, input, normalizedInput, next: normalizedInput },
115
+ ctx,
116
+ });
113
117
 
114
- return normalizedInput.id as string;
115
- };
118
+ return normalizedInput.id as string;
119
+ });
116
120
 
117
121
  export const updateEntities = async (
118
122
  modelName: string,
119
123
  where: Record<string, unknown>,
120
124
  updateFields: Entity,
121
125
  ctx: MutationContext,
122
- ) => {
123
- const entities = await ctx.knex(modelName).where(where).select('id');
124
- for (const entity of entities) {
125
- await updateEntity(modelName, entity.id, updateFields, ctx);
126
- }
127
- };
126
+ ) =>
127
+ withTransaction(ctx, async (ctx) => {
128
+ const entities = await ctx.knex(modelName).where(where).select('id');
129
+ for (const entity of entities) {
130
+ await updateEntity(modelName, entity.id, updateFields, ctx);
131
+ }
132
+ });
128
133
 
129
134
  export const updateEntity = async (
130
135
  modelName: string,
@@ -132,55 +137,57 @@ export const updateEntity = async (
132
137
  input: Entity,
133
138
  ctx: MutationContext,
134
139
  trigger: Trigger = 'direct-call',
135
- ) => {
136
- const model = ctx.models.getModel(modelName, 'entity');
137
- const normalizedInput = { ...input };
140
+ ) =>
141
+ withTransaction(ctx, async (ctx) => {
142
+ const model = ctx.models.getModel(modelName, 'entity');
143
+ const normalizedInput = { ...input };
138
144
 
139
- sanitize(ctx, model, normalizedInput);
145
+ sanitize(ctx, model, normalizedInput);
140
146
 
141
- const currentEntity = await getEntityToMutate(ctx, model, { id }, 'UPDATE');
147
+ const currentEntity = await getEntityToMutate(ctx, model, { id }, 'UPDATE');
142
148
 
143
- // Remove data that wouldn't mutate given that it's irrelevant for permissions
144
- for (const key of Object.keys(normalizedInput)) {
145
- if (normalizedInput[key] === currentEntity[key]) {
146
- delete normalizedInput[key];
149
+ // Remove data that wouldn't mutate given that it's irrelevant for permissions
150
+ for (const key of Object.keys(normalizedInput)) {
151
+ if (normalizedInput[key] === currentEntity[key]) {
152
+ delete normalizedInput[key];
153
+ }
147
154
  }
148
- }
149
-
150
- if (Object.keys(normalizedInput).length > 0) {
151
- await checkCanWrite(ctx, model, normalizedInput, 'UPDATE');
152
- await ctx.handleUploads?.(normalizedInput);
153
155
 
154
- await ctx.mutationHook?.({
155
- model,
156
- action: 'update',
157
- trigger,
158
- when: 'before',
159
- data: { prev: currentEntity, input, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
160
- ctx,
161
- });
162
- await doUpdate(model, currentEntity, normalizedInput, ctx);
163
- await ctx.mutationHook?.({
164
- model,
165
- action: 'update',
166
- trigger,
167
- when: 'after',
168
- data: { prev: currentEntity, input, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
169
- ctx,
170
- });
171
- }
172
- };
156
+ if (Object.keys(normalizedInput).length > 0) {
157
+ await checkCanWrite(ctx, model, normalizedInput, 'UPDATE');
158
+ await ctx.handleUploads?.(normalizedInput);
159
+
160
+ await ctx.mutationHook?.({
161
+ model,
162
+ action: 'update',
163
+ trigger,
164
+ when: 'before',
165
+ data: { prev: currentEntity, input, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
166
+ ctx,
167
+ });
168
+ await doUpdate(model, currentEntity, normalizedInput, ctx);
169
+ await ctx.mutationHook?.({
170
+ model,
171
+ action: 'update',
172
+ trigger,
173
+ when: 'after',
174
+ data: { prev: currentEntity, input, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
175
+ ctx,
176
+ });
177
+ }
178
+ });
173
179
 
174
180
  type Callbacks = (() => Promise<void>)[];
175
181
 
176
- export const deleteEntities = async (modelName: string, where: Record<string, unknown>, ctx: MutationContext) => {
177
- const entities = await ctx.knex(modelName).where(where).select('id');
178
- for (const entity of entities) {
179
- await deleteEntity(modelName, entity.id, ctx, {
180
- trigger: 'direct-call',
181
- });
182
- }
183
- };
182
+ export const deleteEntities = async (modelName: string, where: Record<string, unknown>, ctx: MutationContext) =>
183
+ withTransaction(ctx, async (ctx) => {
184
+ const entities = await ctx.knex(modelName).where(where).select('id');
185
+ for (const entity of entities) {
186
+ await deleteEntity(modelName, entity.id, ctx, {
187
+ trigger: 'direct-call',
188
+ });
189
+ }
190
+ });
184
191
 
185
192
  export const deleteEntity = async (
186
193
  modelName: string,
@@ -193,338 +200,335 @@ export const deleteEntity = async (
193
200
  dryRun?: boolean;
194
201
  trigger?: Trigger;
195
202
  } = {},
196
- ) => {
197
- const model = ctx.models.getModel(modelName, 'entity');
198
- const rootModel = model.rootModel;
199
- const entity = await getEntityToMutate(ctx, rootModel, { id }, 'DELETE');
200
-
201
- if (entity.deleted) {
202
- throw new ForbiddenError(`${getTechnicalDisplay(model, entity)} is already deleted.`);
203
- }
203
+ ) =>
204
+ withTransaction(ctx, async (ctx) => {
205
+ const model = ctx.models.getModel(modelName, 'entity');
206
+ const rootModel = model.rootModel;
207
+ const entity = await getEntityToMutate(ctx, rootModel, { id }, 'DELETE');
208
+
209
+ if (entity.deleted) {
210
+ throw new ForbiddenError(`${getTechnicalDisplay(model, entity)} is already deleted.`);
211
+ }
204
212
 
205
- const toDelete: Record<string, Record<string, string>> = {};
206
- const toUnlink: Record<
207
- string,
208
- Record<
213
+ const toDelete: Record<string, Record<string, string>> = {};
214
+ const toUnlink: Record<
209
215
  string,
210
- {
211
- display: string;
212
- fields: string[];
213
- }
214
- >
215
- > = {};
216
- const restricted: Record<
217
- string,
218
- Record<
216
+ Record<
217
+ string,
218
+ {
219
+ display: string;
220
+ fields: string[];
221
+ }
222
+ >
223
+ > = {};
224
+ const restricted: Record<
219
225
  string,
220
- {
221
- display: string;
222
- fields: string[];
223
- }
224
- >
225
- > = {};
226
+ Record<
227
+ string,
228
+ {
229
+ display: string;
230
+ fields: string[];
231
+ }
232
+ >
233
+ > = {};
226
234
 
227
- const beforeHooks: Callbacks = [];
228
- const mutations: Callbacks = [];
229
- const afterHooks: Callbacks = [];
235
+ const beforeHooks: Callbacks = [];
236
+ const mutations: Callbacks = [];
237
+ const afterHooks: Callbacks = [];
230
238
 
231
- const mutationHook = ctx.mutationHook;
232
- const deleteCascade = async (currentModel: EntityModel, currentEntity: Entity, currentTrigger: Trigger) => {
233
- if (!(currentModel.name in toDelete)) {
234
- toDelete[currentModel.name] = {};
235
- }
236
- if ((currentEntity.id as string) in toDelete[currentModel.name]) {
237
- return;
238
- }
239
- toDelete[currentModel.name][currentEntity.id as string] = await fetchDisplay(ctx.knex, currentModel, currentEntity);
240
-
241
- if (!dryRun) {
242
- const normalizedInput = {
243
- deleted: true,
244
- deletedAt: ctx.now,
245
- deletedById: ctx.user?.id,
246
- deleteRootType: rootModel.name,
247
- deleteRootId: entity.id,
248
- };
249
- if (mutationHook) {
250
- beforeHooks.push(async () => {
251
- await mutationHook({
252
- model: currentModel,
253
- action: 'delete',
254
- trigger: currentTrigger,
255
- when: 'before',
256
- data: { prev: currentEntity, input: {}, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
257
- ctx,
258
- });
259
- });
239
+ const mutationHook = ctx.mutationHook;
240
+ const deleteCascade = async (currentModel: EntityModel, currentEntity: Entity, currentTrigger: Trigger) => {
241
+ if (!(currentModel.name in toDelete)) {
242
+ toDelete[currentModel.name] = {};
260
243
  }
261
- mutations.push(async () => {
262
- await doUpdate(currentModel, currentEntity, normalizedInput, ctx);
263
- });
264
- if (mutationHook) {
265
- afterHooks.push(async () => {
266
- await mutationHook({
267
- model: currentModel,
268
- action: 'delete',
269
- trigger: currentTrigger,
270
- when: 'after',
271
- data: { prev: currentEntity, input: {}, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
272
- ctx,
244
+ if ((currentEntity.id as string) in toDelete[currentModel.name]) {
245
+ return;
246
+ }
247
+ toDelete[currentModel.name][currentEntity.id as string] = await fetchDisplay(ctx.knex, currentModel, currentEntity);
248
+
249
+ if (!dryRun) {
250
+ const normalizedInput = {
251
+ deleted: true,
252
+ deletedAt: ctx.now,
253
+ deletedById: ctx.user?.id,
254
+ deleteRootType: rootModel.name,
255
+ deleteRootId: entity.id,
256
+ };
257
+ if (mutationHook) {
258
+ beforeHooks.push(async () => {
259
+ await mutationHook({
260
+ model: currentModel,
261
+ action: 'delete',
262
+ trigger: currentTrigger,
263
+ when: 'before',
264
+ data: { prev: currentEntity, input: {}, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
265
+ ctx,
266
+ });
273
267
  });
268
+ }
269
+ mutations.push(async () => {
270
+ await doUpdate(currentModel, currentEntity, normalizedInput, ctx);
274
271
  });
272
+ if (mutationHook) {
273
+ afterHooks.push(async () => {
274
+ await mutationHook({
275
+ model: currentModel,
276
+ action: 'delete',
277
+ trigger: currentTrigger,
278
+ when: 'after',
279
+ data: { prev: currentEntity, input: {}, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
280
+ ctx,
281
+ });
282
+ });
283
+ }
275
284
  }
276
- }
277
285
 
278
- for (const {
279
- targetModel: descendantModel,
280
- field: { name, foreignKey, onDelete },
281
- } of currentModel.reverseRelations.filter((reverseRelation) => !reverseRelation.field.inherited)) {
282
- const query = ctx.knex(descendantModel.name).where({ [foreignKey]: currentEntity.id, deleted: false });
283
- const descendants = await query;
284
- if (descendants.length) {
285
- switch (onDelete) {
286
- case 'set-null':
287
- for (const descendant of descendants) {
288
- if (dryRun) {
289
- if (!toUnlink[descendantModel.name]) {
290
- toUnlink[descendantModel.name] = {};
291
- }
292
- if (!toUnlink[descendantModel.name][descendant.id]) {
293
- toUnlink[descendantModel.name][descendant.id] = {
294
- display: await fetchDisplay(ctx.knex, descendantModel, descendant),
295
- fields: [],
296
- };
297
- }
298
- toUnlink[descendantModel.name][descendant.id].fields.push(name);
299
- } else {
300
- const normalizedInput = { [`${name}Id`]: null };
301
- if (mutationHook) {
302
- beforeHooks.push(async () => {
303
- await mutationHook({
304
- model: descendantModel,
305
- action: 'update',
306
- trigger: 'set-null',
307
- when: 'before',
308
- data: { prev: descendant, input: {}, normalizedInput, next: { ...descendant, ...normalizedInput } },
309
- ctx,
286
+ for (const {
287
+ targetModel: descendantModel,
288
+ field: { name, foreignKey, onDelete },
289
+ } of currentModel.reverseRelations.filter((reverseRelation) => !reverseRelation.field.inherited)) {
290
+ const query = ctx.knex(descendantModel.name).where({ [foreignKey]: currentEntity.id, deleted: false });
291
+ const descendants = await query;
292
+ if (descendants.length) {
293
+ switch (onDelete) {
294
+ case 'set-null':
295
+ for (const descendant of descendants) {
296
+ if (dryRun) {
297
+ if (!toUnlink[descendantModel.name]) {
298
+ toUnlink[descendantModel.name] = {};
299
+ }
300
+ if (!toUnlink[descendantModel.name][descendant.id]) {
301
+ toUnlink[descendantModel.name][descendant.id] = {
302
+ display: await fetchDisplay(ctx.knex, descendantModel, descendant),
303
+ fields: [],
304
+ };
305
+ }
306
+ toUnlink[descendantModel.name][descendant.id].fields.push(name);
307
+ } else {
308
+ const normalizedInput = { [`${name}Id`]: null };
309
+ if (mutationHook) {
310
+ beforeHooks.push(async () => {
311
+ await mutationHook({
312
+ model: descendantModel,
313
+ action: 'update',
314
+ trigger: 'set-null',
315
+ when: 'before',
316
+ data: { prev: descendant, input: {}, normalizedInput, next: { ...descendant, ...normalizedInput } },
317
+ ctx,
318
+ });
310
319
  });
320
+ }
321
+ mutations.push(async () => {
322
+ await doUpdate(descendantModel, descendant, normalizedInput, ctx);
311
323
  });
312
- }
313
- mutations.push(async () => {
314
- await doUpdate(descendantModel, descendant, normalizedInput, ctx);
315
- });
316
- if (mutationHook) {
317
- afterHooks.push(async () => {
318
- await mutationHook({
319
- model: descendantModel,
320
- action: 'update',
321
- trigger: 'set-null',
322
- when: 'after',
323
- data: { prev: descendant, input: {}, normalizedInput, next: { ...descendant, ...normalizedInput } },
324
- ctx,
324
+ if (mutationHook) {
325
+ afterHooks.push(async () => {
326
+ await mutationHook({
327
+ model: descendantModel,
328
+ action: 'update',
329
+ trigger: 'set-null',
330
+ when: 'after',
331
+ data: { prev: descendant, input: {}, normalizedInput, next: { ...descendant, ...normalizedInput } },
332
+ ctx,
333
+ });
325
334
  });
326
- });
335
+ }
327
336
  }
328
337
  }
329
- }
330
- break;
331
- case 'restrict':
332
- if (dryRun) {
333
- if (!restricted[descendantModel.name]) {
334
- restricted[descendantModel.name] = {};
335
- }
336
- for (const descendant of descendants) {
337
- if (!restricted[descendantModel.name][descendant.id]) {
338
- restricted[descendantModel.name][descendant.id] = {
339
- display: await fetchDisplay(ctx.knex, descendantModel, descendant),
340
- fields: [name],
341
- };
338
+ break;
339
+ case 'restrict':
340
+ if (dryRun) {
341
+ if (!restricted[descendantModel.name]) {
342
+ restricted[descendantModel.name] = {};
343
+ }
344
+ for (const descendant of descendants) {
345
+ if (!restricted[descendantModel.name][descendant.id]) {
346
+ restricted[descendantModel.name][descendant.id] = {
347
+ display: await fetchDisplay(ctx.knex, descendantModel, descendant),
348
+ fields: [name],
349
+ };
350
+ }
351
+ restricted[descendantModel.name][descendant.id].fields.push(name);
342
352
  }
343
- restricted[descendantModel.name][descendant.id].fields.push(name);
353
+ } else {
354
+ throw new ForbiddenError(
355
+ `${getTechnicalDisplay(model, entity)} cannot be deleted because it has ${getTechnicalDisplay(descendantModel, descendants[0])}${descendants.length > 1 ? ` (among others)` : ''}.`,
356
+ );
344
357
  }
345
- } else {
346
- throw new ForbiddenError(
347
- `${getTechnicalDisplay(model, entity)} cannot be deleted because it has ${getTechnicalDisplay(descendantModel, descendants[0])}${descendants.length > 1 ? ` (among others)` : ''}.`,
348
- );
349
- }
350
- break;
351
- case 'cascade':
352
- default: {
353
- if (!descendantModel.deletable) {
354
- throw new ForbiddenError(
355
- `${getTechnicalDisplay(model, entity)} depends on ${getTechnicalDisplay(descendantModel, descendants[0])}${descendants.length > 1 ? ` (among others)` : ''} which cannot be deleted.`,
356
- );
357
- }
358
- applyPermissions(ctx, descendantModel.name, descendantModel.name, query, 'DELETE');
359
- const deletableDescendants = await query;
360
- const notDeletableDescendants = descendants.filter(
361
- (descendant) => !deletableDescendants.some((d) => d.id === descendant.id),
362
- );
363
- if (notDeletableDescendants.length) {
364
- throw new ForbiddenError(
365
- `${getTechnicalDisplay(model, entity)} depends on ${descendantModel.labelPlural} which you have no permissions to delete.`,
358
+ break;
359
+ case 'cascade':
360
+ default: {
361
+ if (!descendantModel.deletable) {
362
+ throw new ForbiddenError(
363
+ `${getTechnicalDisplay(model, entity)} depends on ${getTechnicalDisplay(descendantModel, descendants[0])}${descendants.length > 1 ? ` (among others)` : ''} which cannot be deleted.`,
364
+ );
365
+ }
366
+ applyPermissions(ctx, descendantModel.name, descendantModel.name, query, 'DELETE');
367
+ const deletableDescendants = await query;
368
+ const notDeletableDescendants = descendants.filter(
369
+ (descendant) => !deletableDescendants.some((d) => d.id === descendant.id),
366
370
  );
371
+ if (notDeletableDescendants.length) {
372
+ throw new ForbiddenError(
373
+ `${getTechnicalDisplay(model, entity)} depends on ${descendantModel.labelPlural} which you have no permissions to delete.`,
374
+ );
375
+ }
376
+ for (const descendant of descendants) {
377
+ await deleteCascade(descendantModel, descendant, 'cascade');
378
+ }
379
+ break;
367
380
  }
368
- for (const descendant of descendants) {
369
- await deleteCascade(descendantModel, descendant, 'cascade');
370
- }
371
- break;
372
381
  }
373
382
  }
374
383
  }
375
- }
376
- };
384
+ };
377
385
 
378
- await deleteCascade(rootModel, entity, trigger);
386
+ await deleteCascade(rootModel, entity, trigger);
379
387
 
380
- for (const callback of [...beforeHooks, ...mutations, ...afterHooks]) {
381
- await callback();
382
- }
388
+ for (const callback of [...beforeHooks, ...mutations, ...afterHooks]) {
389
+ await callback();
390
+ }
383
391
 
384
- if (dryRun) {
385
- throw new GraphQLError(`Delete dry run:`, {
386
- code: 'DELETE_DRY_RUN',
387
- toDelete,
388
- toUnlink,
389
- restricted,
390
- });
391
- }
392
- };
392
+ if (dryRun) {
393
+ throw new GraphQLError(`Delete dry run:`, {
394
+ code: 'DELETE_DRY_RUN',
395
+ toDelete,
396
+ toUnlink,
397
+ restricted,
398
+ });
399
+ }
400
+ });
393
401
 
394
- export const restoreEntity = async (
395
- modelName: string,
396
- id: string,
397
- ctx: MutationContext,
398
- trigger: Trigger = 'direct-call',
399
- ) => {
400
- const model = ctx.models.getModel(modelName, 'entity');
401
- const rootModel = model.rootModel;
402
+ export const restoreEntity = async (modelName: string, id: string, ctx: MutationContext, trigger: Trigger = 'direct-call') =>
403
+ withTransaction(ctx, async (ctx) => {
404
+ const model = ctx.models.getModel(modelName, 'entity');
405
+ const rootModel = model.rootModel;
402
406
 
403
- const entity = await getEntityToMutate(ctx, rootModel, { id }, 'RESTORE');
407
+ const entity = await getEntityToMutate(ctx, rootModel, { id }, 'RESTORE');
404
408
 
405
- if (!entity.deleted) {
406
- throw new ForbiddenError(`${getTechnicalDisplay(model, entity)} is not deleted.`);
407
- }
409
+ if (!entity.deleted) {
410
+ throw new ForbiddenError(`${getTechnicalDisplay(model, entity)} is not deleted.`);
411
+ }
408
412
 
409
- if (entity.deleteRootId) {
410
- if (!(entity.deleteRootType === rootModel.name && entity.deleteRootId === entity.id)) {
411
- throw new ForbiddenError(
412
- `Can't restore ${getTechnicalDisplay(model, entity)} directly. To restore it, restore ${entity.deleteRootType} ${entity.deleteRootId}.`,
413
- );
413
+ if (entity.deleteRootId) {
414
+ if (!(entity.deleteRootType === rootModel.name && entity.deleteRootId === entity.id)) {
415
+ throw new ForbiddenError(
416
+ `Can't restore ${getTechnicalDisplay(model, entity)} directly. To restore it, restore ${entity.deleteRootType} ${entity.deleteRootId}.`,
417
+ );
418
+ }
414
419
  }
415
- }
416
420
 
417
- const toRestore: Record<string, Set<string>> = {};
421
+ const toRestore: Record<string, Set<string>> = {};
418
422
 
419
- const beforeHooks: Callbacks = [];
420
- const mutations: Callbacks = [];
421
- const afterHooks: Callbacks = [];
423
+ const beforeHooks: Callbacks = [];
424
+ const mutations: Callbacks = [];
425
+ const afterHooks: Callbacks = [];
422
426
 
423
- const restoreCascade = async (currentModel: EntityModel, currentEntity: Entity, currentTrigger: Trigger) => {
424
- if (entity.deleteRootId || currentEntity.deleteRootId) {
425
- if (!(currentEntity.deleteRootType === model.name && currentEntity.deleteRootId === entity.id)) {
427
+ const restoreCascade = async (currentModel: EntityModel, currentEntity: Entity, currentTrigger: Trigger) => {
428
+ if (entity.deleteRootId || currentEntity.deleteRootId) {
429
+ if (!(currentEntity.deleteRootType === model.name && currentEntity.deleteRootId === entity.id)) {
430
+ return;
431
+ }
432
+
433
+ // Legacy heuristic
434
+ } else if (
435
+ !anyDateToLuxon(currentEntity.deletedAt, ctx.timeZone)!.equals(anyDateToLuxon(entity.deletedAt, ctx.timeZone)!)
436
+ ) {
426
437
  return;
427
438
  }
428
439
 
429
- // Legacy heuristic
430
- } else if (
431
- !anyDateToLuxon(currentEntity.deletedAt, ctx.timeZone)!.equals(anyDateToLuxon(entity.deletedAt, ctx.timeZone)!)
432
- ) {
433
- return;
434
- }
435
-
436
- if (!(currentModel.name in toRestore)) {
437
- toRestore[currentModel.name] = new Set();
438
- }
439
- toRestore[currentModel.name].add(currentEntity.id as string);
440
-
441
- const normalizedInput: Entity = {
442
- deleted: false,
443
- deletedAt: null,
444
- deletedById: null,
445
- deleteRootType: null,
446
- deleteRootId: null,
447
- };
448
- if (ctx.mutationHook) {
449
- beforeHooks.push(async () => {
450
- await ctx.mutationHook!({
451
- model: currentModel,
452
- action: 'restore',
453
- trigger: currentTrigger,
454
- when: 'before',
455
- data: { prev: currentEntity, input: {}, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
456
- ctx,
440
+ if (!(currentModel.name in toRestore)) {
441
+ toRestore[currentModel.name] = new Set();
442
+ }
443
+ toRestore[currentModel.name].add(currentEntity.id as string);
444
+
445
+ const normalizedInput: Entity = {
446
+ deleted: false,
447
+ deletedAt: null,
448
+ deletedById: null,
449
+ deleteRootType: null,
450
+ deleteRootId: null,
451
+ };
452
+ if (ctx.mutationHook) {
453
+ beforeHooks.push(async () => {
454
+ await ctx.mutationHook!({
455
+ model: currentModel,
456
+ action: 'restore',
457
+ trigger: currentTrigger,
458
+ when: 'before',
459
+ data: { prev: currentEntity, input: {}, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
460
+ ctx,
461
+ });
457
462
  });
458
- });
459
- }
460
- mutations.push(async () => {
461
- for (const relation of currentModel.relations) {
462
- const parentId = currentEntity[relation.field.foreignKey] as string | undefined;
463
- if (!parentId) {
464
- continue;
463
+ }
464
+ mutations.push(async () => {
465
+ for (const relation of currentModel.relations) {
466
+ const parentId = currentEntity[relation.field.foreignKey] as string | undefined;
467
+ if (!parentId) {
468
+ continue;
469
+ }
470
+ if (toRestore[relation.targetModel.name]?.has(parentId)) {
471
+ continue;
472
+ }
473
+ const parent = await ctx.knex(relation.targetModel.name).where({ id: parentId }).first();
474
+ if (parent?.deleted) {
475
+ throw new ForbiddenError(
476
+ `Can't restore ${getTechnicalDisplay(model, entity)} because it depends on deleted ${relation.targetModel.name} ${parentId}.`,
477
+ );
478
+ }
465
479
  }
466
- if (toRestore[relation.targetModel.name]?.has(parentId)) {
467
- continue;
480
+
481
+ await doUpdate(currentModel, currentEntity, normalizedInput, ctx);
482
+ });
483
+ if (ctx.mutationHook) {
484
+ afterHooks.push(async () => {
485
+ await ctx.mutationHook!({
486
+ model: currentModel,
487
+ action: 'restore',
488
+ trigger: currentTrigger,
489
+ when: 'after',
490
+ data: { prev: currentEntity, input: {}, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
491
+ ctx,
492
+ });
493
+ });
494
+ }
495
+
496
+ for (const {
497
+ targetModel: descendantModel,
498
+ field: { foreignKey },
499
+ } of currentModel.reverseRelations
500
+ .filter((reverseRelation) => !reverseRelation.field.inherited)
501
+ .filter(({ targetModel: { deletable } }) => deletable)) {
502
+ const query = ctx.knex(descendantModel.name).where({ [foreignKey]: currentEntity.id, deleted: true });
503
+ if (currentEntity.deleteRootId) {
504
+ query.where({ deleteRootType: currentEntity.deleteRootType, deleteRootId: currentEntity.deleteRootId });
505
+ } else {
506
+ // Legacy heuristic
507
+ query.where({ deletedAt: currentEntity.deletedAt });
468
508
  }
469
- const parent = await ctx.knex(relation.targetModel.name).where({ id: parentId }).first();
470
- if (parent?.deleted) {
509
+ const descendantsToRestore = await query;
510
+ applyPermissions(ctx, descendantModel.name, descendantModel.name, query, 'RESTORE');
511
+ const restorableDescendants = await query;
512
+ const notRestorableDescendants = descendantsToRestore.filter(
513
+ (descendant) => !restorableDescendants.some((d) => d.id === descendant.id),
514
+ );
515
+ if (notRestorableDescendants.length) {
471
516
  throw new ForbiddenError(
472
- `Can't restore ${getTechnicalDisplay(model, entity)} because it depends on deleted ${relation.targetModel.name} ${parentId}.`,
517
+ `${getTechnicalDisplay(currentModel, currentEntity)} depends on ${descendantModel.labelPlural} which you have no permissions to restore.`,
473
518
  );
474
519
  }
520
+ for (const descendant of descendantsToRestore) {
521
+ await restoreCascade(descendantModel, descendant, 'cascade');
522
+ }
475
523
  }
524
+ };
476
525
 
477
- await doUpdate(currentModel, currentEntity, normalizedInput, ctx);
478
- });
479
- if (ctx.mutationHook) {
480
- afterHooks.push(async () => {
481
- await ctx.mutationHook!({
482
- model: currentModel,
483
- action: 'restore',
484
- trigger: currentTrigger,
485
- when: 'after',
486
- data: { prev: currentEntity, input: {}, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
487
- ctx,
488
- });
489
- });
490
- }
526
+ await restoreCascade(rootModel, entity, trigger);
491
527
 
492
- for (const {
493
- targetModel: descendantModel,
494
- field: { foreignKey },
495
- } of currentModel.reverseRelations
496
- .filter((reverseRelation) => !reverseRelation.field.inherited)
497
- .filter(({ targetModel: { deletable } }) => deletable)) {
498
- const query = ctx.knex(descendantModel.name).where({ [foreignKey]: currentEntity.id, deleted: true });
499
- if (currentEntity.deleteRootId) {
500
- query.where({ deleteRootType: currentEntity.deleteRootType, deleteRootId: currentEntity.deleteRootId });
501
- } else {
502
- // Legacy heuristic
503
- query.where({ deletedAt: currentEntity.deletedAt });
504
- }
505
- const descendantsToRestore = await query;
506
- applyPermissions(ctx, descendantModel.name, descendantModel.name, query, 'RESTORE');
507
- const restorableDescendants = await query;
508
- const notRestorableDescendants = descendantsToRestore.filter(
509
- (descendant) => !restorableDescendants.some((d) => d.id === descendant.id),
510
- );
511
- if (notRestorableDescendants.length) {
512
- throw new ForbiddenError(
513
- `${getTechnicalDisplay(currentModel, currentEntity)} depends on ${descendantModel.labelPlural} which you have no permissions to restore.`,
514
- );
515
- }
516
- for (const descendant of descendantsToRestore) {
517
- await restoreCascade(descendantModel, descendant, 'cascade');
518
- }
528
+ for (const callback of [...beforeHooks, ...mutations, ...afterHooks]) {
529
+ await callback();
519
530
  }
520
- };
521
-
522
- await restoreCascade(rootModel, entity, trigger);
523
-
524
- for (const callback of [...beforeHooks, ...mutations, ...afterHooks]) {
525
- await callback();
526
- }
527
- };
531
+ });
528
532
 
529
533
  export const createRevision = async (model: EntityModel, data: Entity, ctx: MutationContext) => {
530
534
  if (model.updatable) {