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