graphile-bulk-mutations 0.2.0
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/LICENSE +23 -0
- package/README.md +197 -0
- package/augmentations.d.ts +102 -0
- package/augmentations.js +11 -0
- package/esm/augmentations.d.ts +102 -0
- package/esm/augmentations.js +9 -0
- package/esm/index.d.ts +30 -0
- package/esm/index.js +29 -0
- package/esm/plugins/BulkDeletePlugin.d.ts +13 -0
- package/esm/plugins/BulkDeletePlugin.js +181 -0
- package/esm/plugins/BulkInflectionPlugin.d.ts +9 -0
- package/esm/plugins/BulkInflectionPlugin.js +103 -0
- package/esm/plugins/BulkInsertPlugin.d.ts +21 -0
- package/esm/plugins/BulkInsertPlugin.js +214 -0
- package/esm/plugins/BulkRelationalPlugin.d.ts +31 -0
- package/esm/plugins/BulkRelationalPlugin.js +127 -0
- package/esm/plugins/BulkTypesPlugin.d.ts +18 -0
- package/esm/plugins/BulkTypesPlugin.js +391 -0
- package/esm/plugins/BulkUpdatePlugin.d.ts +13 -0
- package/esm/plugins/BulkUpdatePlugin.js +211 -0
- package/esm/plugins/BulkUpsertPlugin.d.ts +12 -0
- package/esm/plugins/BulkUpsertPlugin.js +165 -0
- package/esm/plugins/index.d.ts +7 -0
- package/esm/plugins/index.js +7 -0
- package/esm/preset.d.ts +35 -0
- package/esm/preset.js +74 -0
- package/esm/types.d.ts +40 -0
- package/esm/types.js +7 -0
- package/esm/utils/index.d.ts +4 -0
- package/esm/utils/index.js +2 -0
- package/esm/utils/relations.d.ts +42 -0
- package/esm/utils/relations.js +83 -0
- package/esm/utils/sql-builder.d.ts +56 -0
- package/esm/utils/sql-builder.js +121 -0
- package/index.d.ts +30 -0
- package/index.js +44 -0
- package/package.json +61 -0
- package/plugins/BulkDeletePlugin.d.ts +13 -0
- package/plugins/BulkDeletePlugin.js +184 -0
- package/plugins/BulkInflectionPlugin.d.ts +9 -0
- package/plugins/BulkInflectionPlugin.js +106 -0
- package/plugins/BulkInsertPlugin.d.ts +21 -0
- package/plugins/BulkInsertPlugin.js +217 -0
- package/plugins/BulkRelationalPlugin.d.ts +31 -0
- package/plugins/BulkRelationalPlugin.js +130 -0
- package/plugins/BulkTypesPlugin.d.ts +18 -0
- package/plugins/BulkTypesPlugin.js +394 -0
- package/plugins/BulkUpdatePlugin.d.ts +13 -0
- package/plugins/BulkUpdatePlugin.js +214 -0
- package/plugins/BulkUpsertPlugin.d.ts +12 -0
- package/plugins/BulkUpsertPlugin.js +168 -0
- package/plugins/index.d.ts +7 -0
- package/plugins/index.js +17 -0
- package/preset.d.ts +35 -0
- package/preset.js +77 -0
- package/types.d.ts +40 -0
- package/types.js +10 -0
- package/utils/index.d.ts +4 -0
- package/utils/index.js +9 -0
- package/utils/relations.d.ts +42 -0
- package/utils/relations.js +86 -0
- package/utils/sql-builder.d.ts +56 -0
- package/utils/sql-builder.js +126 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BulkTypesPlugin = void 0;
|
|
4
|
+
require("../augmentations");
|
|
5
|
+
const pg_1 = require("@dataplan/pg");
|
|
6
|
+
const grafast_1 = require("grafast");
|
|
7
|
+
const version = '0.1.0';
|
|
8
|
+
/**
|
|
9
|
+
* Checks if a resource is suitable for bulk mutations:
|
|
10
|
+
* - Must have attributes (is a table)
|
|
11
|
+
* - Must not be polymorphic
|
|
12
|
+
* - Must not be anonymous
|
|
13
|
+
* - Must have at least one unique constraint (for ON CONFLICT)
|
|
14
|
+
* - Must not have parameters (not a function)
|
|
15
|
+
*/
|
|
16
|
+
function isBulkMutationCandidate(resource) {
|
|
17
|
+
return (!resource.parameters &&
|
|
18
|
+
!!resource.codec.attributes &&
|
|
19
|
+
!resource.codec.polymorphism &&
|
|
20
|
+
!resource.codec.isAnonymous &&
|
|
21
|
+
!!resource.uniques?.length);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* BulkTypesPlugin
|
|
25
|
+
*
|
|
26
|
+
* Registers all shared types for bulk mutations:
|
|
27
|
+
* 1. ConflictAction enum (IGNORE)
|
|
28
|
+
* 2. Per-table unique constraint enums (e.g. UserUniqueConstraint)
|
|
29
|
+
* 3. Per-table column enums (e.g. UserColumn)
|
|
30
|
+
* 4. Per-table ON CONFLICT input types
|
|
31
|
+
* 5. Per-table values item input types
|
|
32
|
+
* 6. Per-table input types (wrapping values + onConflict)
|
|
33
|
+
* 7. Per-table payload types (affectedCount + returning)
|
|
34
|
+
*
|
|
35
|
+
* Behavior: OFF by default. Tables must opt in via smart tags:
|
|
36
|
+
* @behavior +bulkInsert +bulkUpsert +bulkUpdate +bulkDelete
|
|
37
|
+
*/
|
|
38
|
+
exports.BulkTypesPlugin = {
|
|
39
|
+
name: 'BulkTypesPlugin',
|
|
40
|
+
version,
|
|
41
|
+
description: 'Registers bulk mutation types (enums, inputs, payloads)',
|
|
42
|
+
after: ['PgMutationCreatePlugin', 'PgMutationUpdateDeletePlugin', 'BulkInflectionPlugin'],
|
|
43
|
+
schema: {
|
|
44
|
+
behaviorRegistry: {
|
|
45
|
+
add: {
|
|
46
|
+
bulkInsert: {
|
|
47
|
+
description: 'Enable bulk insert mutation for this resource',
|
|
48
|
+
entities: ['pgResource']
|
|
49
|
+
},
|
|
50
|
+
bulkUpsert: {
|
|
51
|
+
description: 'Enable bulk upsert mutation for this resource',
|
|
52
|
+
entities: ['pgResource']
|
|
53
|
+
},
|
|
54
|
+
bulkUpdate: {
|
|
55
|
+
description: 'Enable bulk update mutation for this resource',
|
|
56
|
+
entities: ['pgResource']
|
|
57
|
+
},
|
|
58
|
+
bulkDelete: {
|
|
59
|
+
description: 'Enable bulk delete mutation for this resource',
|
|
60
|
+
entities: ['pgResource']
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
// Default: OFF. Tables must explicitly opt in via smart tags.
|
|
65
|
+
entityBehavior: {
|
|
66
|
+
pgResource: {
|
|
67
|
+
inferred(behavior) {
|
|
68
|
+
return [behavior, '-bulkInsert -bulkUpsert -bulkUpdate -bulkDelete'];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
hooks: {
|
|
73
|
+
init(_, build) {
|
|
74
|
+
const { inflection, options: { bulkInsert: enableInsert = true, bulkUpsert: enableUpsert = true, bulkUpdate: enableUpdate = true, bulkDelete: enableDelete = true } } = build;
|
|
75
|
+
// Register the ConflictAction enum (shared across all tables)
|
|
76
|
+
build.registerEnumType('ConflictAction', {}, () => ({
|
|
77
|
+
description: 'Action to take when a conflict occurs during bulk insert.',
|
|
78
|
+
values: {
|
|
79
|
+
IGNORE: {
|
|
80
|
+
value: 'IGNORE',
|
|
81
|
+
description: 'Skip the conflicting row (ON CONFLICT DO NOTHING).'
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}), 'ConflictAction enum for bulk mutations');
|
|
85
|
+
// Iterate over all resources to register per-table types
|
|
86
|
+
const pgRegistry = build.input.pgRegistry;
|
|
87
|
+
for (const [resourceName, resource] of Object.entries(pgRegistry.pgResources)) {
|
|
88
|
+
if (!isBulkMutationCandidate(resource))
|
|
89
|
+
continue;
|
|
90
|
+
const hasInsert = enableInsert &&
|
|
91
|
+
build.behavior.pgResourceMatches(resource, 'bulkInsert');
|
|
92
|
+
const hasUpsert = enableUpsert &&
|
|
93
|
+
build.behavior.pgResourceMatches(resource, 'bulkUpsert');
|
|
94
|
+
const hasUpdate = enableUpdate &&
|
|
95
|
+
build.behavior.pgResourceMatches(resource, 'bulkUpdate');
|
|
96
|
+
const hasDelete = enableDelete &&
|
|
97
|
+
build.behavior.pgResourceMatches(resource, 'bulkDelete');
|
|
98
|
+
if (!hasInsert && !hasUpsert && !hasUpdate && !hasDelete)
|
|
99
|
+
continue;
|
|
100
|
+
const typeName = inflection.tableType(resource.codec);
|
|
101
|
+
// Per-table unique constraint enum
|
|
102
|
+
if (hasInsert || hasUpsert) {
|
|
103
|
+
const constraintEnumName = inflection.uniqueConstraintEnumType(typeName);
|
|
104
|
+
const values = {};
|
|
105
|
+
for (const unique of resource.uniques) {
|
|
106
|
+
// Use column-based conflict targeting (ON CONFLICT (col1, col2))
|
|
107
|
+
// rather than constraint names, since PgResourceUnique doesn't
|
|
108
|
+
// store the actual PostgreSQL constraint name.
|
|
109
|
+
const enumKey = unique.isPrimary
|
|
110
|
+
? `${resourceName}_pkey`
|
|
111
|
+
: `${resourceName}_${unique.attributes.join('_')}_key`;
|
|
112
|
+
const enumValue = inflection
|
|
113
|
+
.constantCase(enumKey)
|
|
114
|
+
.toUpperCase();
|
|
115
|
+
// Store comma-separated column list as the enum value
|
|
116
|
+
values[enumValue] = {
|
|
117
|
+
value: unique.attributes.join(','),
|
|
118
|
+
description: unique.isPrimary
|
|
119
|
+
? `Primary key constraint on ${resourceName}`
|
|
120
|
+
: `Unique constraint on (${unique.attributes.join(', ')})`
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
build.registerEnumType(constraintEnumName, { isBulkMutationConstraintEnum: true }, () => ({
|
|
124
|
+
description: `Unique constraints on ${typeName} for ON CONFLICT targeting.`,
|
|
125
|
+
values
|
|
126
|
+
}), `Unique constraint enum for ${typeName}`);
|
|
127
|
+
}
|
|
128
|
+
// Per-table column enum
|
|
129
|
+
if (hasUpsert) {
|
|
130
|
+
const columnEnumName = inflection.columnEnumType(typeName);
|
|
131
|
+
const values = {};
|
|
132
|
+
for (const [attrName, attr] of Object.entries(resource.codec.attributes)) {
|
|
133
|
+
if (attr.extensions?.isInsertable === false)
|
|
134
|
+
continue;
|
|
135
|
+
const enumValue = inflection
|
|
136
|
+
.constantCase(attrName)
|
|
137
|
+
.toUpperCase();
|
|
138
|
+
values[enumValue] = {
|
|
139
|
+
value: attrName,
|
|
140
|
+
description: `Column ${attrName}`
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
build.registerEnumType(columnEnumName, { isBulkMutationColumnEnum: true }, () => ({
|
|
144
|
+
description: `Columns of ${typeName} available for upsert SET clause.`,
|
|
145
|
+
values
|
|
146
|
+
}), `Column enum for ${typeName}`);
|
|
147
|
+
}
|
|
148
|
+
// Per-table ON CONFLICT input for insert
|
|
149
|
+
if (hasInsert) {
|
|
150
|
+
const onConflictName = inflection.bulkInsertOnConflictInputType(typeName);
|
|
151
|
+
const constraintEnumName = inflection.uniqueConstraintEnumType(typeName);
|
|
152
|
+
build.registerInputObjectType(onConflictName, { isBulkMutationOnConflictInput: true }, () => ({
|
|
153
|
+
description: `ON CONFLICT options for bulk creating ${typeName}.`,
|
|
154
|
+
fields: ({ fieldWithHooks }) => ({
|
|
155
|
+
constraint: fieldWithHooks({ fieldName: 'constraint' }, () => ({
|
|
156
|
+
description: 'The unique constraint to use for conflict detection.',
|
|
157
|
+
type: build.getTypeByName(constraintEnumName)
|
|
158
|
+
})),
|
|
159
|
+
action: fieldWithHooks({ fieldName: 'action' }, () => ({
|
|
160
|
+
description: 'The action to take on conflict.',
|
|
161
|
+
type: new build.graphql.GraphQLNonNull(build.getTypeByName('ConflictAction'))
|
|
162
|
+
}))
|
|
163
|
+
})
|
|
164
|
+
}), `ON CONFLICT input for bulk insert ${typeName}`);
|
|
165
|
+
}
|
|
166
|
+
// Per-table ON CONFLICT input for upsert
|
|
167
|
+
if (hasUpsert) {
|
|
168
|
+
const onConflictName = inflection.bulkUpsertOnConflictInputType(typeName);
|
|
169
|
+
const constraintEnumName = inflection.uniqueConstraintEnumType(typeName);
|
|
170
|
+
const columnEnumName = inflection.columnEnumType(typeName);
|
|
171
|
+
build.registerInputObjectType(onConflictName, { isBulkMutationOnConflictInput: true }, () => ({
|
|
172
|
+
description: `ON CONFLICT options for bulk upserting ${typeName}.`,
|
|
173
|
+
fields: ({ fieldWithHooks }) => ({
|
|
174
|
+
constraint: fieldWithHooks({ fieldName: 'constraint' }, () => ({
|
|
175
|
+
description: 'The unique constraint to use for conflict detection.',
|
|
176
|
+
type: new build.graphql.GraphQLNonNull(build.getTypeByName(constraintEnumName))
|
|
177
|
+
})),
|
|
178
|
+
updateColumns: fieldWithHooks({ fieldName: 'updateColumns' }, () => ({
|
|
179
|
+
description: 'Columns to update on conflict. If omitted, all non-key columns are updated.',
|
|
180
|
+
type: new build.graphql.GraphQLList(new build.graphql.GraphQLNonNull(build.getTypeByName(columnEnumName)))
|
|
181
|
+
}))
|
|
182
|
+
})
|
|
183
|
+
}), `ON CONFLICT input for bulk upsert ${typeName}`);
|
|
184
|
+
}
|
|
185
|
+
// Per-table values item input type (for insert)
|
|
186
|
+
if (hasInsert || hasUpsert) {
|
|
187
|
+
const itemTypeName = hasInsert
|
|
188
|
+
? inflection.bulkInsertValuesItemType(typeName)
|
|
189
|
+
: inflection.bulkUpsertValuesItemType(typeName);
|
|
190
|
+
build.registerInputObjectType(itemTypeName, {
|
|
191
|
+
isBulkMutationValuesItem: true,
|
|
192
|
+
bulkMutationResourceName: resourceName
|
|
193
|
+
}, () => ({
|
|
194
|
+
description: `A single row to insert for ${typeName}.`,
|
|
195
|
+
fields: () => {
|
|
196
|
+
const result = {};
|
|
197
|
+
for (const [attrName, attr] of Object.entries(resource.codec.attributes)) {
|
|
198
|
+
if (attr.extensions?.isInsertable === false)
|
|
199
|
+
continue;
|
|
200
|
+
const fieldName = inflection.attribute({
|
|
201
|
+
attributeName: attrName,
|
|
202
|
+
codec: resource.codec
|
|
203
|
+
});
|
|
204
|
+
const inputType = build.getGraphQLTypeByPgCodec(attr.codec, 'input');
|
|
205
|
+
if (!inputType)
|
|
206
|
+
continue;
|
|
207
|
+
const isRequired = !!attr.notNull && !attr.hasDefault;
|
|
208
|
+
result[fieldName] = {
|
|
209
|
+
description: `Value for ${attrName}`,
|
|
210
|
+
type: isRequired
|
|
211
|
+
? new build.graphql.GraphQLNonNull(inputType)
|
|
212
|
+
: inputType
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
}), `Values item input for bulk insert ${typeName}`);
|
|
218
|
+
// Register upsert item separately if both exist
|
|
219
|
+
if (hasInsert && hasUpsert) {
|
|
220
|
+
const upsertItemTypeName = inflection.bulkUpsertValuesItemType(typeName);
|
|
221
|
+
build.registerInputObjectType(upsertItemTypeName, {
|
|
222
|
+
isBulkMutationValuesItem: true,
|
|
223
|
+
bulkMutationResourceName: resourceName
|
|
224
|
+
}, () => ({
|
|
225
|
+
description: `A single row to upsert for ${typeName}.`,
|
|
226
|
+
fields: () => {
|
|
227
|
+
const result = {};
|
|
228
|
+
for (const [attrName, attr] of Object.entries(resource.codec.attributes)) {
|
|
229
|
+
if (attr.extensions?.isInsertable === false)
|
|
230
|
+
continue;
|
|
231
|
+
const fieldName = inflection.attribute({
|
|
232
|
+
attributeName: attrName,
|
|
233
|
+
codec: resource.codec
|
|
234
|
+
});
|
|
235
|
+
const inputType = build.getGraphQLTypeByPgCodec(attr.codec, 'input');
|
|
236
|
+
if (!inputType)
|
|
237
|
+
continue;
|
|
238
|
+
const isRequired = !!attr.notNull && !attr.hasDefault;
|
|
239
|
+
result[fieldName] = {
|
|
240
|
+
description: `Value for ${attrName}`,
|
|
241
|
+
type: isRequired
|
|
242
|
+
? new build.graphql.GraphQLNonNull(inputType)
|
|
243
|
+
: inputType
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
}), `Values item input for bulk upsert ${typeName}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Per-table wrapper input types
|
|
252
|
+
if (hasInsert) {
|
|
253
|
+
const inputName = inflection.bulkInsertInputType(typeName);
|
|
254
|
+
const itemTypeName = inflection.bulkInsertValuesItemType(typeName);
|
|
255
|
+
const onConflictName = inflection.bulkInsertOnConflictInputType(typeName);
|
|
256
|
+
build.registerInputObjectType(inputName, { isBulkMutationInput: true }, () => ({
|
|
257
|
+
description: `Input for bulk creating ${typeName} rows.`,
|
|
258
|
+
fields: ({ fieldWithHooks }) => ({
|
|
259
|
+
values: fieldWithHooks({ fieldName: 'values' }, () => ({
|
|
260
|
+
description: 'The rows to insert.',
|
|
261
|
+
type: new build.graphql.GraphQLNonNull(new build.graphql.GraphQLList(new build.graphql.GraphQLNonNull(build.getTypeByName(itemTypeName))))
|
|
262
|
+
})),
|
|
263
|
+
onConflict: fieldWithHooks({ fieldName: 'onConflict' }, () => ({
|
|
264
|
+
description: 'Optional ON CONFLICT handling for duplicate keys.',
|
|
265
|
+
type: build.getTypeByName(onConflictName)
|
|
266
|
+
}))
|
|
267
|
+
})
|
|
268
|
+
}), `Input for bulk insert ${typeName}`);
|
|
269
|
+
}
|
|
270
|
+
if (hasUpsert) {
|
|
271
|
+
const inputName = inflection.bulkUpsertInputType(typeName);
|
|
272
|
+
const itemTypeName = inflection.bulkUpsertValuesItemType(typeName);
|
|
273
|
+
const onConflictName = inflection.bulkUpsertOnConflictInputType(typeName);
|
|
274
|
+
build.registerInputObjectType(inputName, { isBulkMutationInput: true }, () => ({
|
|
275
|
+
description: `Input for bulk upserting ${typeName} rows.`,
|
|
276
|
+
fields: ({ fieldWithHooks }) => ({
|
|
277
|
+
values: fieldWithHooks({ fieldName: 'values' }, () => ({
|
|
278
|
+
description: 'The rows to upsert.',
|
|
279
|
+
type: new build.graphql.GraphQLNonNull(new build.graphql.GraphQLList(new build.graphql.GraphQLNonNull(build.getTypeByName(itemTypeName))))
|
|
280
|
+
})),
|
|
281
|
+
onConflict: fieldWithHooks({ fieldName: 'onConflict' }, () => ({
|
|
282
|
+
description: 'The unique constraint to target and which columns to update.',
|
|
283
|
+
type: new build.graphql.GraphQLNonNull(build.getTypeByName(onConflictName))
|
|
284
|
+
}))
|
|
285
|
+
})
|
|
286
|
+
}), `Input for bulk upsert ${typeName}`);
|
|
287
|
+
}
|
|
288
|
+
if (hasUpdate) {
|
|
289
|
+
const inputName = inflection.bulkUpdateInputType(typeName);
|
|
290
|
+
const patchTypeName = inflection.patchType(typeName);
|
|
291
|
+
build.registerInputObjectType(inputName, { isBulkMutationInput: true }, () => ({
|
|
292
|
+
description: `Input for bulk updating ${typeName} rows.`,
|
|
293
|
+
fields: ({ fieldWithHooks }) => ({
|
|
294
|
+
where: fieldWithHooks({ fieldName: 'where' }, () => {
|
|
295
|
+
// Try to use connection-filter type if available
|
|
296
|
+
const filterTypeName = `${typeName}Filter`;
|
|
297
|
+
const filterType = build.getTypeByName(filterTypeName);
|
|
298
|
+
// Fall back to PostGraphile's built-in condition type
|
|
299
|
+
const conditionTypeName = inflection.conditionType(typeName);
|
|
300
|
+
const conditionType = build.getTypeByName(conditionTypeName);
|
|
301
|
+
const whereType = filterType || conditionType;
|
|
302
|
+
return {
|
|
303
|
+
description: 'Condition to select which rows to update.',
|
|
304
|
+
type: whereType
|
|
305
|
+
? new build.graphql.GraphQLNonNull(whereType)
|
|
306
|
+
: build.graphql.GraphQLString
|
|
307
|
+
};
|
|
308
|
+
}),
|
|
309
|
+
patch: fieldWithHooks({ fieldName: 'patch' }, () => ({
|
|
310
|
+
description: 'The fields to update.',
|
|
311
|
+
type: new build.graphql.GraphQLNonNull(build.getTypeByName(patchTypeName))
|
|
312
|
+
}))
|
|
313
|
+
})
|
|
314
|
+
}), `Input for bulk update ${typeName}`);
|
|
315
|
+
}
|
|
316
|
+
if (hasDelete) {
|
|
317
|
+
const inputName = inflection.bulkDeleteInputType(typeName);
|
|
318
|
+
build.registerInputObjectType(inputName, { isBulkMutationInput: true }, () => ({
|
|
319
|
+
description: `Input for bulk deleting ${typeName} rows.`,
|
|
320
|
+
fields: ({ fieldWithHooks }) => ({
|
|
321
|
+
where: fieldWithHooks({ fieldName: 'where' }, () => {
|
|
322
|
+
const filterTypeName = `${typeName}Filter`;
|
|
323
|
+
const filterType = build.getTypeByName(filterTypeName);
|
|
324
|
+
const conditionTypeName = inflection.conditionType(typeName);
|
|
325
|
+
const conditionType = build.getTypeByName(conditionTypeName);
|
|
326
|
+
const whereType = filterType || conditionType;
|
|
327
|
+
return {
|
|
328
|
+
description: 'Condition to select which rows to delete.',
|
|
329
|
+
type: whereType
|
|
330
|
+
? new build.graphql.GraphQLNonNull(whereType)
|
|
331
|
+
: build.graphql.GraphQLString
|
|
332
|
+
};
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
}), `Input for bulk delete ${typeName}`);
|
|
336
|
+
}
|
|
337
|
+
// Payload types
|
|
338
|
+
const registerPayload = (payloadName, description, payloadResource) => {
|
|
339
|
+
build.registerObjectType(payloadName, { isBulkMutationPayload: true }, () => ({
|
|
340
|
+
description,
|
|
341
|
+
fields: ({ fieldWithHooks }) => {
|
|
342
|
+
const outputType = build.getTypeByName(typeName);
|
|
343
|
+
const fields = {
|
|
344
|
+
clientMutationId: fieldWithHooks({ fieldName: 'clientMutationId' }, () => ({
|
|
345
|
+
description: 'The exact same clientMutationId that was provided in the mutation input.',
|
|
346
|
+
type: build.graphql.GraphQLString
|
|
347
|
+
})),
|
|
348
|
+
affectedCount: fieldWithHooks({
|
|
349
|
+
fieldName: 'affectedCount',
|
|
350
|
+
isBulkMutationPayloadAffectedCountField: true
|
|
351
|
+
}, () => ({
|
|
352
|
+
description: 'The number of rows affected by the mutation.',
|
|
353
|
+
type: new build.graphql.GraphQLNonNull(build.graphql.GraphQLInt)
|
|
354
|
+
}))
|
|
355
|
+
};
|
|
356
|
+
if (outputType) {
|
|
357
|
+
fields.returning = fieldWithHooks({
|
|
358
|
+
fieldName: 'returning',
|
|
359
|
+
isBulkMutationPayloadReturningField: true
|
|
360
|
+
}, () => ({
|
|
361
|
+
description: 'The rows affected by the mutation.',
|
|
362
|
+
type: new build.graphql.GraphQLNonNull(new build.graphql.GraphQLList(new build.graphql.GraphQLNonNull(outputType))),
|
|
363
|
+
plan($parent) {
|
|
364
|
+
const $records = (0, grafast_1.access)($parent, 'returning');
|
|
365
|
+
return (0, pg_1.pgSelectFromRecords)(payloadResource, $records);
|
|
366
|
+
}
|
|
367
|
+
}));
|
|
368
|
+
}
|
|
369
|
+
fields.query = fieldWithHooks({ fieldName: 'query' }, () => ({
|
|
370
|
+
description: 'Access the root query type. Useful for re-fetching data after a mutation.',
|
|
371
|
+
type: build.getTypeByName('Query')
|
|
372
|
+
}));
|
|
373
|
+
return fields;
|
|
374
|
+
}
|
|
375
|
+
}), `${description} payload`);
|
|
376
|
+
};
|
|
377
|
+
if (hasInsert) {
|
|
378
|
+
registerPayload(inflection.bulkInsertPayloadType(typeName), `The output of the bulk create ${typeName} mutation.`, resource);
|
|
379
|
+
}
|
|
380
|
+
if (hasUpsert) {
|
|
381
|
+
registerPayload(inflection.bulkUpsertPayloadType(typeName), `The output of the bulk upsert ${typeName} mutation.`, resource);
|
|
382
|
+
}
|
|
383
|
+
if (hasUpdate) {
|
|
384
|
+
registerPayload(inflection.bulkUpdatePayloadType(typeName), `The output of the bulk update ${typeName} mutation.`, resource);
|
|
385
|
+
}
|
|
386
|
+
if (hasDelete) {
|
|
387
|
+
registerPayload(inflection.bulkDeletePayloadType(typeName), `The output of the bulk delete ${typeName} mutation.`, resource);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return _;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import '../augmentations';
|
|
2
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
3
|
+
/**
|
|
4
|
+
* BulkUpdatePlugin
|
|
5
|
+
*
|
|
6
|
+
* Registers `bulkUpdateX` mutation fields for each resource with the
|
|
7
|
+
* `bulkUpdate` behavior. Uses UPDATE ... SET ... WHERE with conditions
|
|
8
|
+
* from graphile-connection-filter (or PostGraphile's built-in condition type).
|
|
9
|
+
*
|
|
10
|
+
* Uses `RETURNING <pk_columns>` (not `RETURNING *`) + follow-up SELECT
|
|
11
|
+
* to respect column-level SELECT grants.
|
|
12
|
+
*/
|
|
13
|
+
export declare const BulkUpdatePlugin: GraphileConfig.Plugin;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BulkUpdatePlugin = void 0;
|
|
4
|
+
require("../augmentations");
|
|
5
|
+
const pg_1 = require("@dataplan/pg");
|
|
6
|
+
const version = '0.1.0';
|
|
7
|
+
/**
|
|
8
|
+
* BulkUpdatePlugin
|
|
9
|
+
*
|
|
10
|
+
* Registers `bulkUpdateX` mutation fields for each resource with the
|
|
11
|
+
* `bulkUpdate` behavior. Uses UPDATE ... SET ... WHERE with conditions
|
|
12
|
+
* from graphile-connection-filter (or PostGraphile's built-in condition type).
|
|
13
|
+
*
|
|
14
|
+
* Uses `RETURNING <pk_columns>` (not `RETURNING *`) + follow-up SELECT
|
|
15
|
+
* to respect column-level SELECT grants.
|
|
16
|
+
*/
|
|
17
|
+
exports.BulkUpdatePlugin = {
|
|
18
|
+
name: 'BulkUpdatePlugin',
|
|
19
|
+
version,
|
|
20
|
+
description: 'Adds bulk update (bulkUpdateX) mutations',
|
|
21
|
+
after: ['BulkTypesPlugin'],
|
|
22
|
+
schema: {
|
|
23
|
+
hooks: {
|
|
24
|
+
GraphQLObjectType_fields(fields, build, context) {
|
|
25
|
+
const { scope: { isRootMutation } } = context;
|
|
26
|
+
if (!isRootMutation)
|
|
27
|
+
return fields;
|
|
28
|
+
const { inflection, sql, graphql: { GraphQLNonNull }, options: { bulkUpdate: enableUpdate = true, bulkRequireWhere = true } } = build;
|
|
29
|
+
if (!enableUpdate)
|
|
30
|
+
return fields;
|
|
31
|
+
const pgRegistry = build.input.pgRegistry;
|
|
32
|
+
for (const [resourceName, resource] of Object.entries(pgRegistry.pgResources)) {
|
|
33
|
+
if (resource.parameters)
|
|
34
|
+
continue;
|
|
35
|
+
if (!resource.codec.attributes)
|
|
36
|
+
continue;
|
|
37
|
+
if (resource.codec.polymorphism)
|
|
38
|
+
continue;
|
|
39
|
+
if (resource.codec.isAnonymous)
|
|
40
|
+
continue;
|
|
41
|
+
if (!resource.uniques?.length)
|
|
42
|
+
continue;
|
|
43
|
+
if (!build.behavior.pgResourceMatches(resource, 'bulkUpdate'))
|
|
44
|
+
continue;
|
|
45
|
+
const typeName = inflection.tableType(resource.codec);
|
|
46
|
+
const fieldName = inflection.bulkUpdateField(resourceName);
|
|
47
|
+
const inputTypeName = inflection.bulkUpdateInputType(typeName);
|
|
48
|
+
const payloadTypeName = inflection.bulkUpdatePayloadType(typeName);
|
|
49
|
+
const inputType = build.getTypeByName(inputTypeName);
|
|
50
|
+
const payloadType = build.getTypeByName(payloadTypeName);
|
|
51
|
+
if (!inputType || !payloadType)
|
|
52
|
+
continue;
|
|
53
|
+
// Build field-to-attribute mapping for patch resolution
|
|
54
|
+
const fieldToAttr = {};
|
|
55
|
+
const attrToSqlType = {};
|
|
56
|
+
for (const [attrName, attr] of Object.entries(resource.codec.attributes)) {
|
|
57
|
+
const gqlFieldName = inflection.attribute({
|
|
58
|
+
attributeName: attrName,
|
|
59
|
+
codec: resource.codec
|
|
60
|
+
});
|
|
61
|
+
fieldToAttr[gqlFieldName] = attrName;
|
|
62
|
+
attrToSqlType[attrName] = sql.compile(attr.codec.sqlType).text;
|
|
63
|
+
}
|
|
64
|
+
// Extract primary key columns for RETURNING clause
|
|
65
|
+
const primaryUnique = resource.uniques.find((u) => u.isPrimary) ?? resource.uniques[0];
|
|
66
|
+
const pkColumns = primaryUnique.attributes;
|
|
67
|
+
const pkReturning = pkColumns.map((c) => `"${c}"`).join(', ');
|
|
68
|
+
const compiledFrom = sql.compile(resource.from).text;
|
|
69
|
+
// Capture for plan closure
|
|
70
|
+
const executor = resource.executor;
|
|
71
|
+
const requireWhere = bulkRequireWhere;
|
|
72
|
+
fields = build.extend(fields, {
|
|
73
|
+
[fieldName]: context.fieldWithHooks({ fieldName }, {
|
|
74
|
+
description: `Bulk update ${typeName} rows matching the given condition.`,
|
|
75
|
+
type: payloadType,
|
|
76
|
+
args: {
|
|
77
|
+
input: {
|
|
78
|
+
type: new GraphQLNonNull(inputType)
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
plan(_$root, args) {
|
|
82
|
+
const $input = args.getRaw('input');
|
|
83
|
+
const $result = (0, pg_1.sideEffectWithPgClient)(executor, $input, async (pgClient, input) => {
|
|
84
|
+
if (requireWhere && (!input.where || Object.keys(input.where).length === 0)) {
|
|
85
|
+
throw new Error('Bulk update requires a non-empty where condition. Set bulkRequireWhere: false to allow unrestricted updates.');
|
|
86
|
+
}
|
|
87
|
+
if (!input.patch || Object.keys(input.patch).length === 0) {
|
|
88
|
+
throw new Error('Bulk update requires a non-empty patch.');
|
|
89
|
+
}
|
|
90
|
+
// Build SET clause from patch
|
|
91
|
+
const setClauses = [];
|
|
92
|
+
const values = [];
|
|
93
|
+
for (const [key, val] of Object.entries(input.patch)) {
|
|
94
|
+
if (val === undefined)
|
|
95
|
+
continue;
|
|
96
|
+
const attrName = fieldToAttr[key];
|
|
97
|
+
if (!attrName)
|
|
98
|
+
continue;
|
|
99
|
+
const sqlType = attrToSqlType[attrName];
|
|
100
|
+
values.push(val);
|
|
101
|
+
setClauses.push(`"${attrName}" = $${values.length}::${sqlType}`);
|
|
102
|
+
}
|
|
103
|
+
if (setClauses.length === 0) {
|
|
104
|
+
return { affectedCount: 0, returning: [] };
|
|
105
|
+
}
|
|
106
|
+
// Build WHERE clause from condition
|
|
107
|
+
// Supports both simple equality (Condition type: { name: "X" })
|
|
108
|
+
// and operator-based (Filter type: { name: { equalTo: "X" } })
|
|
109
|
+
const whereClauses = [];
|
|
110
|
+
if (input.where) {
|
|
111
|
+
for (const [key, spec] of Object.entries(input.where)) {
|
|
112
|
+
const attrName = fieldToAttr[key];
|
|
113
|
+
if (!attrName)
|
|
114
|
+
continue;
|
|
115
|
+
const sqlType = attrToSqlType[attrName];
|
|
116
|
+
if (spec === null) {
|
|
117
|
+
whereClauses.push(`"${attrName}" IS NULL`);
|
|
118
|
+
}
|
|
119
|
+
else if (spec !== undefined && typeof spec !== 'object') {
|
|
120
|
+
// Simple equality (Condition type)
|
|
121
|
+
values.push(spec);
|
|
122
|
+
whereClauses.push(`"${attrName}" = $${values.length}::${sqlType}`);
|
|
123
|
+
}
|
|
124
|
+
else if (spec && typeof spec === 'object') {
|
|
125
|
+
// Operator-based (Filter type)
|
|
126
|
+
for (const [op, val] of Object.entries(spec)) {
|
|
127
|
+
values.push(val);
|
|
128
|
+
const paramRef = `$${values.length}::${sqlType}`;
|
|
129
|
+
switch (op) {
|
|
130
|
+
case 'equalTo':
|
|
131
|
+
whereClauses.push(`"${attrName}" = ${paramRef}`);
|
|
132
|
+
break;
|
|
133
|
+
case 'notEqualTo':
|
|
134
|
+
whereClauses.push(`"${attrName}" != ${paramRef}`);
|
|
135
|
+
break;
|
|
136
|
+
case 'greaterThan':
|
|
137
|
+
whereClauses.push(`"${attrName}" > ${paramRef}`);
|
|
138
|
+
break;
|
|
139
|
+
case 'greaterThanOrEqualTo':
|
|
140
|
+
whereClauses.push(`"${attrName}" >= ${paramRef}`);
|
|
141
|
+
break;
|
|
142
|
+
case 'lessThan':
|
|
143
|
+
whereClauses.push(`"${attrName}" < ${paramRef}`);
|
|
144
|
+
break;
|
|
145
|
+
case 'lessThanOrEqualTo':
|
|
146
|
+
whereClauses.push(`"${attrName}" <= ${paramRef}`);
|
|
147
|
+
break;
|
|
148
|
+
case 'in':
|
|
149
|
+
if (Array.isArray(val)) {
|
|
150
|
+
const placeholders = val.map((v) => {
|
|
151
|
+
values.push(v);
|
|
152
|
+
return `$${values.length}::${sqlType}`;
|
|
153
|
+
});
|
|
154
|
+
values.pop();
|
|
155
|
+
whereClauses.push(`"${attrName}" IN (${placeholders.join(', ')})`);
|
|
156
|
+
}
|
|
157
|
+
break;
|
|
158
|
+
case 'isNull':
|
|
159
|
+
values.pop();
|
|
160
|
+
if (val) {
|
|
161
|
+
whereClauses.push(`"${attrName}" IS NULL`);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
whereClauses.push(`"${attrName}" IS NOT NULL`);
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
default:
|
|
168
|
+
values.pop();
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (whereClauses.length === 0 && requireWhere) {
|
|
176
|
+
throw new Error('Bulk update: no valid where conditions resolved. At least one condition is required.');
|
|
177
|
+
}
|
|
178
|
+
const whereStr = whereClauses.length > 0
|
|
179
|
+
? whereClauses.join(' AND ')
|
|
180
|
+
: 'TRUE';
|
|
181
|
+
// Use RETURNING <pk_columns> instead of RETURNING *
|
|
182
|
+
const text = `UPDATE ${compiledFrom}\nSET ${setClauses.join(', ')}\nWHERE ${whereStr}\nRETURNING ${pkReturning}`;
|
|
183
|
+
const mutationResult = await pgClient.query(text, values);
|
|
184
|
+
const affectedCount = mutationResult.rowCount ?? 0;
|
|
185
|
+
// Follow-up SELECT using PKs to respect column-level grants
|
|
186
|
+
let returning = [];
|
|
187
|
+
if (mutationResult.rows && mutationResult.rows.length > 0) {
|
|
188
|
+
const pkRows = mutationResult.rows;
|
|
189
|
+
const pkConditions = pkRows.map((pkRow, rowIdx) => {
|
|
190
|
+
return pkColumns.map((col, colIdx) => {
|
|
191
|
+
const paramIdx = rowIdx * pkColumns.length + colIdx + 1;
|
|
192
|
+
return `"${col}" = $${paramIdx}`;
|
|
193
|
+
}).join(' AND ');
|
|
194
|
+
});
|
|
195
|
+
const selectWhere = pkConditions.map((c) => `(${c})`).join(' OR ');
|
|
196
|
+
const selectParams = pkRows.flatMap((pkRow) => pkColumns.map((col) => pkRow[col]));
|
|
197
|
+
const selectResult = await pgClient.query(`SELECT * FROM ${compiledFrom} WHERE ${selectWhere}`, selectParams);
|
|
198
|
+
returning = selectResult.rows || [];
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
affectedCount,
|
|
202
|
+
returning
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
return $result;
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
}, `Adding bulk update field for ${typeName}`);
|
|
209
|
+
}
|
|
210
|
+
return fields;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import '../augmentations';
|
|
2
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
3
|
+
/**
|
|
4
|
+
* BulkUpsertPlugin
|
|
5
|
+
*
|
|
6
|
+
* Registers `bulkUpsertX` mutation fields for each resource with the
|
|
7
|
+
* `bulkUpsert` behavior. Uses INSERT ... ON CONFLICT DO UPDATE SET.
|
|
8
|
+
*
|
|
9
|
+
* Uses `RETURNING <pk_columns>` (not `RETURNING *`) + follow-up SELECT
|
|
10
|
+
* to respect column-level SELECT grants.
|
|
11
|
+
*/
|
|
12
|
+
export declare const BulkUpsertPlugin: GraphileConfig.Plugin;
|