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,181 @@
|
|
|
1
|
+
import '../augmentations';
|
|
2
|
+
import { sideEffectWithPgClient } from '@dataplan/pg';
|
|
3
|
+
const version = '0.1.0';
|
|
4
|
+
/**
|
|
5
|
+
* BulkDeletePlugin
|
|
6
|
+
*
|
|
7
|
+
* Registers `bulkDeleteX` mutation fields for each resource with the
|
|
8
|
+
* `bulkDelete` behavior. Uses DELETE ... WHERE with conditions from
|
|
9
|
+
* graphile-connection-filter (or PostGraphile's built-in condition type).
|
|
10
|
+
*
|
|
11
|
+
* Uses `RETURNING <pk_columns>` (not `RETURNING *`) + follow-up SELECT
|
|
12
|
+
* to respect column-level SELECT grants.
|
|
13
|
+
*/
|
|
14
|
+
export const BulkDeletePlugin = {
|
|
15
|
+
name: 'BulkDeletePlugin',
|
|
16
|
+
version,
|
|
17
|
+
description: 'Adds bulk delete (bulkDeleteX) mutations',
|
|
18
|
+
after: ['BulkTypesPlugin'],
|
|
19
|
+
schema: {
|
|
20
|
+
hooks: {
|
|
21
|
+
GraphQLObjectType_fields(fields, build, context) {
|
|
22
|
+
const { scope: { isRootMutation } } = context;
|
|
23
|
+
if (!isRootMutation)
|
|
24
|
+
return fields;
|
|
25
|
+
const { inflection, sql, graphql: { GraphQLNonNull }, options: { bulkDelete: enableDelete = true, bulkRequireWhere = true } } = build;
|
|
26
|
+
if (!enableDelete)
|
|
27
|
+
return fields;
|
|
28
|
+
const pgRegistry = build.input.pgRegistry;
|
|
29
|
+
for (const [resourceName, resource] of Object.entries(pgRegistry.pgResources)) {
|
|
30
|
+
if (resource.parameters)
|
|
31
|
+
continue;
|
|
32
|
+
if (!resource.codec.attributes)
|
|
33
|
+
continue;
|
|
34
|
+
if (resource.codec.polymorphism)
|
|
35
|
+
continue;
|
|
36
|
+
if (resource.codec.isAnonymous)
|
|
37
|
+
continue;
|
|
38
|
+
if (!resource.uniques?.length)
|
|
39
|
+
continue;
|
|
40
|
+
if (!build.behavior.pgResourceMatches(resource, 'bulkDelete'))
|
|
41
|
+
continue;
|
|
42
|
+
const typeName = inflection.tableType(resource.codec);
|
|
43
|
+
const fieldName = inflection.bulkDeleteField(resourceName);
|
|
44
|
+
const inputTypeName = inflection.bulkDeleteInputType(typeName);
|
|
45
|
+
const payloadTypeName = inflection.bulkDeletePayloadType(typeName);
|
|
46
|
+
const inputType = build.getTypeByName(inputTypeName);
|
|
47
|
+
const payloadType = build.getTypeByName(payloadTypeName);
|
|
48
|
+
if (!inputType || !payloadType)
|
|
49
|
+
continue;
|
|
50
|
+
const fieldToAttr = {};
|
|
51
|
+
const attrToSqlType = {};
|
|
52
|
+
for (const [attrName, attr] of Object.entries(resource.codec.attributes)) {
|
|
53
|
+
const gqlFieldName = inflection.attribute({
|
|
54
|
+
attributeName: attrName,
|
|
55
|
+
codec: resource.codec
|
|
56
|
+
});
|
|
57
|
+
fieldToAttr[gqlFieldName] = attrName;
|
|
58
|
+
attrToSqlType[attrName] = sql.compile(attr.codec.sqlType).text;
|
|
59
|
+
}
|
|
60
|
+
// Extract primary key columns for RETURNING clause
|
|
61
|
+
const primaryUnique = resource.uniques.find((u) => u.isPrimary) ?? resource.uniques[0];
|
|
62
|
+
const pkColumns = primaryUnique.attributes;
|
|
63
|
+
const pkReturning = pkColumns.map((c) => `"${c}"`).join(', ');
|
|
64
|
+
const compiledFrom = sql.compile(resource.from).text;
|
|
65
|
+
// Capture for plan closure
|
|
66
|
+
const executor = resource.executor;
|
|
67
|
+
const requireWhere = bulkRequireWhere;
|
|
68
|
+
fields = build.extend(fields, {
|
|
69
|
+
[fieldName]: context.fieldWithHooks({ fieldName }, {
|
|
70
|
+
description: `Bulk delete ${typeName} rows matching the given condition.`,
|
|
71
|
+
type: payloadType,
|
|
72
|
+
args: {
|
|
73
|
+
input: {
|
|
74
|
+
type: new GraphQLNonNull(inputType)
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
plan(_$root, args) {
|
|
78
|
+
const $input = args.getRaw('input');
|
|
79
|
+
const $result = sideEffectWithPgClient(executor, $input, async (pgClient, input) => {
|
|
80
|
+
if (requireWhere && (!input.where || Object.keys(input.where).length === 0)) {
|
|
81
|
+
throw new Error('Bulk delete requires a non-empty where condition. Set bulkRequireWhere: false to allow unrestricted deletes.');
|
|
82
|
+
}
|
|
83
|
+
// Build WHERE clause from condition
|
|
84
|
+
// Supports both simple equality (Condition type: { name: "X" })
|
|
85
|
+
// and operator-based (Filter type: { name: { equalTo: "X" } })
|
|
86
|
+
const values = [];
|
|
87
|
+
const whereClauses = [];
|
|
88
|
+
if (input.where) {
|
|
89
|
+
for (const [key, spec] of Object.entries(input.where)) {
|
|
90
|
+
const attrName = fieldToAttr[key];
|
|
91
|
+
if (!attrName)
|
|
92
|
+
continue;
|
|
93
|
+
const sqlType = attrToSqlType[attrName];
|
|
94
|
+
if (spec === null) {
|
|
95
|
+
whereClauses.push(`"${attrName}" IS NULL`);
|
|
96
|
+
}
|
|
97
|
+
else if (spec !== undefined && typeof spec !== 'object') {
|
|
98
|
+
// Simple equality (Condition type)
|
|
99
|
+
values.push(spec);
|
|
100
|
+
whereClauses.push(`"${attrName}" = $${values.length}::${sqlType}`);
|
|
101
|
+
}
|
|
102
|
+
else if (spec && typeof spec === 'object') {
|
|
103
|
+
// Operator-based (Filter type)
|
|
104
|
+
for (const [op, val] of Object.entries(spec)) {
|
|
105
|
+
values.push(val);
|
|
106
|
+
const paramRef = `$${values.length}::${sqlType}`;
|
|
107
|
+
switch (op) {
|
|
108
|
+
case 'equalTo':
|
|
109
|
+
whereClauses.push(`"${attrName}" = ${paramRef}`);
|
|
110
|
+
break;
|
|
111
|
+
case 'notEqualTo':
|
|
112
|
+
whereClauses.push(`"${attrName}" != ${paramRef}`);
|
|
113
|
+
break;
|
|
114
|
+
case 'greaterThan':
|
|
115
|
+
whereClauses.push(`"${attrName}" > ${paramRef}`);
|
|
116
|
+
break;
|
|
117
|
+
case 'greaterThanOrEqualTo':
|
|
118
|
+
whereClauses.push(`"${attrName}" >= ${paramRef}`);
|
|
119
|
+
break;
|
|
120
|
+
case 'lessThan':
|
|
121
|
+
whereClauses.push(`"${attrName}" < ${paramRef}`);
|
|
122
|
+
break;
|
|
123
|
+
case 'lessThanOrEqualTo':
|
|
124
|
+
whereClauses.push(`"${attrName}" <= ${paramRef}`);
|
|
125
|
+
break;
|
|
126
|
+
case 'in':
|
|
127
|
+
if (Array.isArray(val)) {
|
|
128
|
+
const placeholders = val.map((v) => {
|
|
129
|
+
values.push(v);
|
|
130
|
+
return `$${values.length}::${sqlType}`;
|
|
131
|
+
});
|
|
132
|
+
values.pop();
|
|
133
|
+
whereClauses.push(`"${attrName}" IN (${placeholders.join(', ')})`);
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
case 'isNull':
|
|
137
|
+
values.pop();
|
|
138
|
+
if (val) {
|
|
139
|
+
whereClauses.push(`"${attrName}" IS NULL`);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
whereClauses.push(`"${attrName}" IS NOT NULL`);
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
default:
|
|
146
|
+
values.pop();
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (whereClauses.length === 0 && requireWhere) {
|
|
154
|
+
throw new Error('Bulk delete: no valid where conditions resolved. At least one condition is required.');
|
|
155
|
+
}
|
|
156
|
+
const whereStr = whereClauses.length > 0
|
|
157
|
+
? whereClauses.join(' AND ')
|
|
158
|
+
: 'TRUE';
|
|
159
|
+
// Use RETURNING <pk_columns> instead of RETURNING *
|
|
160
|
+
// For delete, we capture PKs before rows are gone
|
|
161
|
+
const text = `DELETE FROM ${compiledFrom}\nWHERE ${whereStr}\nRETURNING ${pkReturning}`;
|
|
162
|
+
const mutationResult = await pgClient.query(text, values);
|
|
163
|
+
const affectedCount = mutationResult.rowCount ?? 0;
|
|
164
|
+
// For delete, rows no longer exist so we can't do a
|
|
165
|
+
// follow-up SELECT. Return the PK values directly.
|
|
166
|
+
const returning = mutationResult.rows || [];
|
|
167
|
+
return {
|
|
168
|
+
affectedCount,
|
|
169
|
+
returning
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
return $result;
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
}, `Adding bulk delete field for ${typeName}`);
|
|
176
|
+
}
|
|
177
|
+
return fields;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import '../augmentations';
|
|
2
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
3
|
+
/**
|
|
4
|
+
* BulkInflectionPlugin
|
|
5
|
+
*
|
|
6
|
+
* Adds inflection methods for naming bulk mutation types, fields, and enums.
|
|
7
|
+
* Naming is configurable via the `bulkNaming` schema option.
|
|
8
|
+
*/
|
|
9
|
+
export declare const BulkInflectionPlugin: GraphileConfig.Plugin;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import '../augmentations';
|
|
2
|
+
/**
|
|
3
|
+
* Builds a mutation field name based on the naming strategy.
|
|
4
|
+
*
|
|
5
|
+
* - 'bulk': bulkCreateUser
|
|
6
|
+
* - 'pluralized': createUsers
|
|
7
|
+
* - 'many': createManyUsers
|
|
8
|
+
*/
|
|
9
|
+
function buildFieldName(inflection, verb, resourceName, naming) {
|
|
10
|
+
const typeName = inflection.upperCamelCase(resourceName);
|
|
11
|
+
switch (naming) {
|
|
12
|
+
case 'pluralized':
|
|
13
|
+
return inflection.camelCase(`${verb}_${inflection.pluralize(resourceName)}`);
|
|
14
|
+
case 'many':
|
|
15
|
+
return inflection.camelCase(`${verb}_many_${inflection.pluralize(resourceName)}`);
|
|
16
|
+
case 'bulk':
|
|
17
|
+
default:
|
|
18
|
+
return inflection.camelCase(`bulk_${verb}_${typeName}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* BulkInflectionPlugin
|
|
23
|
+
*
|
|
24
|
+
* Adds inflection methods for naming bulk mutation types, fields, and enums.
|
|
25
|
+
* Naming is configurable via the `bulkNaming` schema option.
|
|
26
|
+
*/
|
|
27
|
+
export const BulkInflectionPlugin = {
|
|
28
|
+
name: 'BulkInflectionPlugin',
|
|
29
|
+
version: '0.1.0',
|
|
30
|
+
description: 'Adds inflection methods for bulk mutation type naming',
|
|
31
|
+
inflection: {
|
|
32
|
+
add: {
|
|
33
|
+
// Mutation field names — read naming from preset schema options
|
|
34
|
+
bulkInsertField(_preset, resourceName) {
|
|
35
|
+
const naming = (_preset?.schema?.bulkNaming ?? 'bulk');
|
|
36
|
+
return buildFieldName(this, 'create', resourceName, naming);
|
|
37
|
+
},
|
|
38
|
+
bulkUpsertField(_preset, resourceName) {
|
|
39
|
+
const naming = (_preset?.schema?.bulkNaming ?? 'bulk');
|
|
40
|
+
return buildFieldName(this, 'upsert', resourceName, naming);
|
|
41
|
+
},
|
|
42
|
+
bulkUpdateField(_preset, resourceName) {
|
|
43
|
+
const naming = (_preset?.schema?.bulkNaming ?? 'bulk');
|
|
44
|
+
return buildFieldName(this, 'update', resourceName, naming);
|
|
45
|
+
},
|
|
46
|
+
bulkDeleteField(_preset, resourceName) {
|
|
47
|
+
const naming = (_preset?.schema?.bulkNaming ?? 'bulk');
|
|
48
|
+
return buildFieldName(this, 'delete', resourceName, naming);
|
|
49
|
+
},
|
|
50
|
+
// Payload type names
|
|
51
|
+
bulkInsertPayloadType(_preset, typeName) {
|
|
52
|
+
return `BulkCreate${typeName}Payload`;
|
|
53
|
+
},
|
|
54
|
+
bulkUpsertPayloadType(_preset, typeName) {
|
|
55
|
+
return `BulkUpsert${typeName}Payload`;
|
|
56
|
+
},
|
|
57
|
+
bulkUpdatePayloadType(_preset, typeName) {
|
|
58
|
+
return `BulkUpdate${typeName}Payload`;
|
|
59
|
+
},
|
|
60
|
+
bulkDeletePayloadType(_preset, typeName) {
|
|
61
|
+
return `BulkDelete${typeName}Payload`;
|
|
62
|
+
},
|
|
63
|
+
// Input type names
|
|
64
|
+
bulkInsertInputType(_preset, typeName) {
|
|
65
|
+
return `BulkCreate${typeName}Input`;
|
|
66
|
+
},
|
|
67
|
+
bulkUpsertInputType(_preset, typeName) {
|
|
68
|
+
return `BulkUpsert${typeName}Input`;
|
|
69
|
+
},
|
|
70
|
+
bulkUpdateInputType(_preset, typeName) {
|
|
71
|
+
return `BulkUpdate${typeName}Input`;
|
|
72
|
+
},
|
|
73
|
+
bulkDeleteInputType(_preset, typeName) {
|
|
74
|
+
return `BulkDelete${typeName}Input`;
|
|
75
|
+
},
|
|
76
|
+
// Per-table enum types
|
|
77
|
+
uniqueConstraintEnumType(_preset, typeName) {
|
|
78
|
+
return `${typeName}UniqueConstraint`;
|
|
79
|
+
},
|
|
80
|
+
columnEnumType(_preset, typeName) {
|
|
81
|
+
return `${typeName}Column`;
|
|
82
|
+
},
|
|
83
|
+
// ON CONFLICT input types
|
|
84
|
+
bulkInsertOnConflictInputType(_preset, typeName) {
|
|
85
|
+
return `BulkCreate${typeName}OnConflictInput`;
|
|
86
|
+
},
|
|
87
|
+
bulkUpsertOnConflictInputType(_preset, typeName) {
|
|
88
|
+
return `BulkUpsert${typeName}OnConflictInput`;
|
|
89
|
+
},
|
|
90
|
+
// Values item types
|
|
91
|
+
bulkInsertValuesItemType(_preset, typeName) {
|
|
92
|
+
return `${typeName}BulkCreateItem`;
|
|
93
|
+
},
|
|
94
|
+
bulkUpsertValuesItemType(_preset, typeName) {
|
|
95
|
+
return `${typeName}BulkUpsertItem`;
|
|
96
|
+
},
|
|
97
|
+
// Relational nested input type names
|
|
98
|
+
bulkNestedValuesItemType(_preset, parentTypeName, childTypeName) {
|
|
99
|
+
return `${childTypeName}BulkNestedIn${parentTypeName}Input`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import '../augmentations';
|
|
2
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
3
|
+
/**
|
|
4
|
+
* BulkInsertPlugin
|
|
5
|
+
*
|
|
6
|
+
* Registers `bulkCreateX` mutation fields on the root mutation type for
|
|
7
|
+
* each resource that has the `bulkInsert` behavior.
|
|
8
|
+
*
|
|
9
|
+
* Uses `sideEffectWithPgClient` for raw SQL execution. The mutation
|
|
10
|
+
* SQL uses `RETURNING <pk_columns>` (not `RETURNING *`) to respect
|
|
11
|
+
* column-level SELECT grants. A follow-up SELECT retrieves the full
|
|
12
|
+
* rows using only the returned PKs, ensuring the query runs through
|
|
13
|
+
* the normal grant-aware pipeline.
|
|
14
|
+
*
|
|
15
|
+
* When `bulkRelational` is enabled, nested child records are detected
|
|
16
|
+
* in the input and inserted in a layered fashion: parent rows first,
|
|
17
|
+
* then child rows with FK values auto-populated from the parent PKs.
|
|
18
|
+
*
|
|
19
|
+
* Pattern from: https://github.com/pyramation/graphile-column-privileges-mutations
|
|
20
|
+
*/
|
|
21
|
+
export declare const BulkInsertPlugin: GraphileConfig.Plugin;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import '../augmentations';
|
|
2
|
+
import { sideEffectWithPgClient } from '@dataplan/pg';
|
|
3
|
+
import { discoverNestedRelations } from '../utils/relations';
|
|
4
|
+
import { buildBulkInsertSQL } from '../utils/sql-builder';
|
|
5
|
+
const version = '0.1.0';
|
|
6
|
+
/**
|
|
7
|
+
* BulkInsertPlugin
|
|
8
|
+
*
|
|
9
|
+
* Registers `bulkCreateX` mutation fields on the root mutation type for
|
|
10
|
+
* each resource that has the `bulkInsert` behavior.
|
|
11
|
+
*
|
|
12
|
+
* Uses `sideEffectWithPgClient` for raw SQL execution. The mutation
|
|
13
|
+
* SQL uses `RETURNING <pk_columns>` (not `RETURNING *`) to respect
|
|
14
|
+
* column-level SELECT grants. A follow-up SELECT retrieves the full
|
|
15
|
+
* rows using only the returned PKs, ensuring the query runs through
|
|
16
|
+
* the normal grant-aware pipeline.
|
|
17
|
+
*
|
|
18
|
+
* When `bulkRelational` is enabled, nested child records are detected
|
|
19
|
+
* in the input and inserted in a layered fashion: parent rows first,
|
|
20
|
+
* then child rows with FK values auto-populated from the parent PKs.
|
|
21
|
+
*
|
|
22
|
+
* Pattern from: https://github.com/pyramation/graphile-column-privileges-mutations
|
|
23
|
+
*/
|
|
24
|
+
export const BulkInsertPlugin = {
|
|
25
|
+
name: 'BulkInsertPlugin',
|
|
26
|
+
version,
|
|
27
|
+
description: 'Adds bulk insert (bulkCreateX) mutations',
|
|
28
|
+
after: ['BulkTypesPlugin'],
|
|
29
|
+
schema: {
|
|
30
|
+
hooks: {
|
|
31
|
+
GraphQLObjectType_fields(fields, build, context) {
|
|
32
|
+
const { scope: { isRootMutation } } = context;
|
|
33
|
+
if (!isRootMutation)
|
|
34
|
+
return fields;
|
|
35
|
+
const { inflection, sql, graphql: { GraphQLNonNull }, options: { bulkInsert: enableInsert = true, bulkRelational: enableRelational = false, bulkMaxRows = 1000 } } = build;
|
|
36
|
+
if (!enableInsert)
|
|
37
|
+
return fields;
|
|
38
|
+
const pgRegistry = build.input.pgRegistry;
|
|
39
|
+
for (const [resourceName, resource] of Object.entries(pgRegistry.pgResources)) {
|
|
40
|
+
if (resource.parameters)
|
|
41
|
+
continue;
|
|
42
|
+
if (!resource.codec.attributes)
|
|
43
|
+
continue;
|
|
44
|
+
if (resource.codec.polymorphism)
|
|
45
|
+
continue;
|
|
46
|
+
if (resource.codec.isAnonymous)
|
|
47
|
+
continue;
|
|
48
|
+
if (!resource.uniques?.length)
|
|
49
|
+
continue;
|
|
50
|
+
if (!build.behavior.pgResourceMatches(resource, 'bulkInsert'))
|
|
51
|
+
continue;
|
|
52
|
+
const typeName = inflection.tableType(resource.codec);
|
|
53
|
+
const fieldName = inflection.bulkInsertField(resourceName);
|
|
54
|
+
const inputTypeName = inflection.bulkInsertInputType(typeName);
|
|
55
|
+
const payloadTypeName = inflection.bulkInsertPayloadType(typeName);
|
|
56
|
+
const inputType = build.getTypeByName(inputTypeName);
|
|
57
|
+
const payloadType = build.getTypeByName(payloadTypeName);
|
|
58
|
+
if (!inputType || !payloadType)
|
|
59
|
+
continue;
|
|
60
|
+
// Pre-compute column specs for SQL generation
|
|
61
|
+
const columnSpecs = [];
|
|
62
|
+
const fieldToAttr = {};
|
|
63
|
+
for (const [attrName, attr] of Object.entries(resource.codec.attributes)) {
|
|
64
|
+
if (attr.extensions?.isInsertable === false)
|
|
65
|
+
continue;
|
|
66
|
+
const gqlFieldName = inflection.attribute({
|
|
67
|
+
attributeName: attrName,
|
|
68
|
+
codec: resource.codec
|
|
69
|
+
});
|
|
70
|
+
fieldToAttr[gqlFieldName] = attrName;
|
|
71
|
+
columnSpecs.push({
|
|
72
|
+
name: attrName,
|
|
73
|
+
sqlType: sql.compile(attr.codec.sqlType).text
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
// Extract primary key columns for RETURNING clause
|
|
77
|
+
const primaryUnique = resource.uniques.find((u) => u.isPrimary) ?? resource.uniques[0];
|
|
78
|
+
const pkColumns = primaryUnique.attributes;
|
|
79
|
+
const compiledFrom = sql.compile(resource.from).text;
|
|
80
|
+
// Discover nested relations at schema build time
|
|
81
|
+
const nestedRelations = enableRelational
|
|
82
|
+
? discoverNestedRelations(resource, pgRegistry, inflection, sql)
|
|
83
|
+
: [];
|
|
84
|
+
const nestedFieldNames = new Set(nestedRelations.map((r) => r.fieldName));
|
|
85
|
+
// Capture executor for plan closure
|
|
86
|
+
const executor = resource.executor;
|
|
87
|
+
const maxRows = bulkMaxRows;
|
|
88
|
+
fields = build.extend(fields, {
|
|
89
|
+
[fieldName]: context.fieldWithHooks({ fieldName }, {
|
|
90
|
+
description: `Bulk insert rows into ${typeName}.`,
|
|
91
|
+
type: payloadType,
|
|
92
|
+
args: {
|
|
93
|
+
input: {
|
|
94
|
+
type: new GraphQLNonNull(inputType)
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
plan(_$root, args) {
|
|
98
|
+
const $input = args.getRaw('input');
|
|
99
|
+
const $result = sideEffectWithPgClient(executor, $input, async (pgClient, input) => {
|
|
100
|
+
const values = input.values;
|
|
101
|
+
if (!values || !Array.isArray(values) || values.length === 0) {
|
|
102
|
+
return { affectedCount: 0, returning: [] };
|
|
103
|
+
}
|
|
104
|
+
if (values.length > maxRows) {
|
|
105
|
+
throw new Error(`Bulk insert exceeds maximum of ${maxRows} rows (got ${values.length})`);
|
|
106
|
+
}
|
|
107
|
+
// Check for nested data
|
|
108
|
+
const hasNestedData = nestedRelations.length > 0 &&
|
|
109
|
+
values.some((row) => nestedRelations.some((rel) => Array.isArray(row[rel.fieldName]) &&
|
|
110
|
+
row[rel.fieldName].length > 0));
|
|
111
|
+
// Validate: ON CONFLICT not allowed with nested inserts
|
|
112
|
+
if (hasNestedData && input.onConflict) {
|
|
113
|
+
throw new Error('ON CONFLICT cannot be used with nested/relational inserts. ' +
|
|
114
|
+
'Remove the onConflict option or remove nested records from values.');
|
|
115
|
+
}
|
|
116
|
+
// Map GraphQL field names to SQL column names,
|
|
117
|
+
// separating parent data from nested data
|
|
118
|
+
const rows = values.map((row) => {
|
|
119
|
+
const mapped = {};
|
|
120
|
+
for (const [key, val] of Object.entries(row)) {
|
|
121
|
+
if (nestedFieldNames.has(key))
|
|
122
|
+
continue;
|
|
123
|
+
const attrName = fieldToAttr[key];
|
|
124
|
+
if (attrName) {
|
|
125
|
+
mapped[attrName] = val;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return mapped;
|
|
129
|
+
});
|
|
130
|
+
// Build ON CONFLICT clause
|
|
131
|
+
let onConflict;
|
|
132
|
+
if (input.onConflict) {
|
|
133
|
+
const conflictColumns = input.onConflict.constraint
|
|
134
|
+
? input.onConflict.constraint.split(',')
|
|
135
|
+
: undefined;
|
|
136
|
+
onConflict = {
|
|
137
|
+
conflictColumns,
|
|
138
|
+
action: input.onConflict.action
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const batches = buildBulkInsertSQL(compiledFrom, columnSpecs, rows, pkColumns, onConflict);
|
|
142
|
+
let totalAffected = 0;
|
|
143
|
+
const allPkRows = [];
|
|
144
|
+
for (const batch of batches) {
|
|
145
|
+
const result = await pgClient.query(batch.text, batch.values);
|
|
146
|
+
totalAffected += result.rowCount ?? 0;
|
|
147
|
+
if (result.rows) {
|
|
148
|
+
allPkRows.push(...result.rows);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Layered nested inserts: insert child rows with FK
|
|
152
|
+
// values auto-populated from parent PKs
|
|
153
|
+
if (hasNestedData) {
|
|
154
|
+
for (let i = 0; i < values.length && i < allPkRows.length; i++) {
|
|
155
|
+
const row = values[i];
|
|
156
|
+
const parentPk = allPkRows[i];
|
|
157
|
+
for (const rel of nestedRelations) {
|
|
158
|
+
const childInputRows = row[rel.fieldName];
|
|
159
|
+
if (!childInputRows?.length)
|
|
160
|
+
continue;
|
|
161
|
+
// Map child GraphQL fields to SQL columns
|
|
162
|
+
// and inject FK values from parent PK
|
|
163
|
+
const childRows = childInputRows.map((childRow) => {
|
|
164
|
+
const mapped = {};
|
|
165
|
+
for (const [key, val] of Object.entries(childRow)) {
|
|
166
|
+
const attrName = rel.childFieldToAttr[key];
|
|
167
|
+
if (attrName) {
|
|
168
|
+
mapped[attrName] = val;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Fill FK columns from parent PK
|
|
172
|
+
for (let j = 0; j < rel.localAttributes.length; j++) {
|
|
173
|
+
mapped[rel.remoteAttributes[j]] =
|
|
174
|
+
parentPk[rel.localAttributes[j]];
|
|
175
|
+
}
|
|
176
|
+
return mapped;
|
|
177
|
+
});
|
|
178
|
+
const childBatches = buildBulkInsertSQL(rel.childCompiledFrom, rel.childColumnSpecs, childRows, rel.childPkColumns);
|
|
179
|
+
for (const batch of childBatches) {
|
|
180
|
+
const result = await pgClient.query(batch.text, batch.values);
|
|
181
|
+
totalAffected += result.rowCount ?? 0;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Follow-up SELECT using PKs to respect column-level grants
|
|
187
|
+
let returning = [];
|
|
188
|
+
if (allPkRows.length > 0) {
|
|
189
|
+
const pkConditions = allPkRows.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 whereClause = pkConditions.map((c) => `(${c})`).join(' OR ');
|
|
196
|
+
const selectParams = allPkRows.flatMap((pkRow) => pkColumns.map((col) => pkRow[col]));
|
|
197
|
+
const selectResult = await pgClient.query(`SELECT * FROM ${compiledFrom} WHERE ${whereClause}`, selectParams);
|
|
198
|
+
returning = selectResult.rows || [];
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
affectedCount: totalAffected,
|
|
202
|
+
returning
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
return $result;
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
}, `Adding bulk insert field for ${typeName}`);
|
|
209
|
+
}
|
|
210
|
+
return fields;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import '../augmentations';
|
|
2
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
3
|
+
/**
|
|
4
|
+
* BulkRelationalPlugin
|
|
5
|
+
*
|
|
6
|
+
* Adds relational/nested insert support to bulk mutations. When enabled
|
|
7
|
+
* via `bulkRelational: true`, parent values item input types gain optional
|
|
8
|
+
* fields for each "has many" relation, allowing creation of parent and
|
|
9
|
+
* child rows in a single mutation.
|
|
10
|
+
*
|
|
11
|
+
* Example:
|
|
12
|
+
* mutation {
|
|
13
|
+
* bulkCreateCategories(input: {
|
|
14
|
+
* values: [{
|
|
15
|
+
* name: "Electronics"
|
|
16
|
+
* products: [
|
|
17
|
+
* { name: "Laptop", price: "999.99" }
|
|
18
|
+
* ]
|
|
19
|
+
* }]
|
|
20
|
+
* }) { affectedCount }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* The FK column on the child table (e.g. category_id) is automatically
|
|
24
|
+
* populated from the parent's returned primary key. Nested input types
|
|
25
|
+
* exclude FK columns so the user does not need to provide them.
|
|
26
|
+
*
|
|
27
|
+
* Limitations:
|
|
28
|
+
* - ON CONFLICT cannot be used with nested inserts (throws an error)
|
|
29
|
+
* - Only one level of nesting is supported in the initial implementation
|
|
30
|
+
*/
|
|
31
|
+
export declare const BulkRelationalPlugin: GraphileConfig.Plugin;
|