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.
Files changed (63) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +197 -0
  3. package/augmentations.d.ts +102 -0
  4. package/augmentations.js +11 -0
  5. package/esm/augmentations.d.ts +102 -0
  6. package/esm/augmentations.js +9 -0
  7. package/esm/index.d.ts +30 -0
  8. package/esm/index.js +29 -0
  9. package/esm/plugins/BulkDeletePlugin.d.ts +13 -0
  10. package/esm/plugins/BulkDeletePlugin.js +181 -0
  11. package/esm/plugins/BulkInflectionPlugin.d.ts +9 -0
  12. package/esm/plugins/BulkInflectionPlugin.js +103 -0
  13. package/esm/plugins/BulkInsertPlugin.d.ts +21 -0
  14. package/esm/plugins/BulkInsertPlugin.js +214 -0
  15. package/esm/plugins/BulkRelationalPlugin.d.ts +31 -0
  16. package/esm/plugins/BulkRelationalPlugin.js +127 -0
  17. package/esm/plugins/BulkTypesPlugin.d.ts +18 -0
  18. package/esm/plugins/BulkTypesPlugin.js +391 -0
  19. package/esm/plugins/BulkUpdatePlugin.d.ts +13 -0
  20. package/esm/plugins/BulkUpdatePlugin.js +211 -0
  21. package/esm/plugins/BulkUpsertPlugin.d.ts +12 -0
  22. package/esm/plugins/BulkUpsertPlugin.js +165 -0
  23. package/esm/plugins/index.d.ts +7 -0
  24. package/esm/plugins/index.js +7 -0
  25. package/esm/preset.d.ts +35 -0
  26. package/esm/preset.js +74 -0
  27. package/esm/types.d.ts +40 -0
  28. package/esm/types.js +7 -0
  29. package/esm/utils/index.d.ts +4 -0
  30. package/esm/utils/index.js +2 -0
  31. package/esm/utils/relations.d.ts +42 -0
  32. package/esm/utils/relations.js +83 -0
  33. package/esm/utils/sql-builder.d.ts +56 -0
  34. package/esm/utils/sql-builder.js +121 -0
  35. package/index.d.ts +30 -0
  36. package/index.js +44 -0
  37. package/package.json +61 -0
  38. package/plugins/BulkDeletePlugin.d.ts +13 -0
  39. package/plugins/BulkDeletePlugin.js +184 -0
  40. package/plugins/BulkInflectionPlugin.d.ts +9 -0
  41. package/plugins/BulkInflectionPlugin.js +106 -0
  42. package/plugins/BulkInsertPlugin.d.ts +21 -0
  43. package/plugins/BulkInsertPlugin.js +217 -0
  44. package/plugins/BulkRelationalPlugin.d.ts +31 -0
  45. package/plugins/BulkRelationalPlugin.js +130 -0
  46. package/plugins/BulkTypesPlugin.d.ts +18 -0
  47. package/plugins/BulkTypesPlugin.js +394 -0
  48. package/plugins/BulkUpdatePlugin.d.ts +13 -0
  49. package/plugins/BulkUpdatePlugin.js +214 -0
  50. package/plugins/BulkUpsertPlugin.d.ts +12 -0
  51. package/plugins/BulkUpsertPlugin.js +168 -0
  52. package/plugins/index.d.ts +7 -0
  53. package/plugins/index.js +17 -0
  54. package/preset.d.ts +35 -0
  55. package/preset.js +77 -0
  56. package/types.d.ts +40 -0
  57. package/types.js +10 -0
  58. package/utils/index.d.ts +4 -0
  59. package/utils/index.js +9 -0
  60. package/utils/relations.d.ts +42 -0
  61. package/utils/relations.js +86 -0
  62. package/utils/sql-builder.d.ts +56 -0
  63. 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;