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,83 @@
1
+ /**
2
+ * Relation discovery utilities for bulk relational/nested inserts.
3
+ *
4
+ * Inspects the PostGraphile registry to find "has many" (isReferencee)
5
+ * relations for a given resource. For each qualifying relation, computes
6
+ * column metadata needed to build child INSERT statements.
7
+ */
8
+ /**
9
+ * Discover "has many" relations for a resource that qualify for nested
10
+ * inserts. A relation qualifies when:
11
+ *
12
+ * 1. isReferencee is true (the remote table references the local table)
13
+ * 2. isUnique is false (one-to-many, not one-to-one)
14
+ * 3. The remote resource is a regular table (has attributes, uniques, etc.)
15
+ */
16
+ export function discoverNestedRelations(resource, pgRegistry, inflection, sql) {
17
+ const codecName = resource.codec.name;
18
+ const relations = pgRegistry.pgRelations[codecName];
19
+ if (!relations)
20
+ return [];
21
+ const result = [];
22
+ for (const [relationName, relation] of Object.entries(relations)) {
23
+ // Only reverse relations (child references parent)
24
+ if (!relation.isReferencee)
25
+ continue;
26
+ // Only one-to-many (skip one-to-one)
27
+ if (relation.isUnique)
28
+ continue;
29
+ const remoteResource = relation.remoteResource;
30
+ if (!remoteResource?.codec?.attributes)
31
+ continue;
32
+ if (remoteResource.codec.polymorphism)
33
+ continue;
34
+ if (remoteResource.codec.isAnonymous)
35
+ continue;
36
+ if (!remoteResource.uniques?.length)
37
+ continue;
38
+ // Build child column metadata (all insertable columns)
39
+ const childFieldToAttr = {};
40
+ const childColumnSpecs = [];
41
+ for (const [attrName, attr] of Object.entries(remoteResource.codec.attributes)) {
42
+ if (attr.extensions?.isInsertable === false)
43
+ continue;
44
+ const gqlFieldName = inflection.attribute({
45
+ attributeName: attrName,
46
+ codec: remoteResource.codec
47
+ });
48
+ childFieldToAttr[gqlFieldName] = attrName;
49
+ childColumnSpecs.push({
50
+ name: attrName,
51
+ sqlType: sql.compile(attr.codec.sqlType).text
52
+ });
53
+ }
54
+ // Find PK columns for child
55
+ const childPrimaryUnique = remoteResource.uniques.find((u) => u.isPrimary) ??
56
+ remoteResource.uniques[0];
57
+ const childPkColumns = [...childPrimaryUnique.attributes];
58
+ const childCompiledFrom = sql.compile(remoteResource.from).text;
59
+ // Find the registry key of the remote resource
60
+ let remoteResourceName = '';
61
+ for (const [name, res] of Object.entries(pgRegistry.pgResources)) {
62
+ if (res === remoteResource) {
63
+ remoteResourceName = name;
64
+ break;
65
+ }
66
+ }
67
+ // Derive a clean GraphQL field name from the remote resource name
68
+ const fieldName = inflection.camelCase(remoteResourceName);
69
+ result.push({
70
+ relationName,
71
+ fieldName,
72
+ remoteResource,
73
+ remoteResourceName,
74
+ localAttributes: [...relation.localAttributes],
75
+ remoteAttributes: [...relation.remoteAttributes],
76
+ childFieldToAttr,
77
+ childColumnSpecs,
78
+ childCompiledFrom,
79
+ childPkColumns
80
+ });
81
+ }
82
+ return result;
83
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * SQL builder utilities for bulk mutations.
3
+ *
4
+ * Provides parameterized SQL generation for VALUES clauses, ON CONFLICT
5
+ * clauses, and SET patches. Handles auto-batching when the parameter
6
+ * count would exceed PostgreSQL's 32K limit.
7
+ *
8
+ * IMPORTANT: All mutation queries use `RETURNING <pk_columns>` instead of
9
+ * `RETURNING *` to respect column-level SELECT grants. The caller is
10
+ * responsible for doing a follow-up SELECT using the returned PKs to
11
+ * resolve the full row through PostGraphile's grant-aware query pipeline.
12
+ * See: https://github.com/pyramation/graphile-column-privileges-mutations
13
+ */
14
+ export interface ColumnSpec {
15
+ name: string;
16
+ sqlType: string;
17
+ }
18
+ export interface InsertBatch {
19
+ text: string;
20
+ values: unknown[];
21
+ }
22
+ /**
23
+ * Builds parameterized INSERT ... VALUES SQL with optional ON CONFLICT clause.
24
+ * Auto-splits into multiple batches when parameter count exceeds PG_MAX_PARAMS.
25
+ *
26
+ * Uses `RETURNING <returningColumns>` (typically primary key columns) instead
27
+ * of `RETURNING *` to avoid permission errors with column-level SELECT grants.
28
+ */
29
+ export declare function buildBulkInsertSQL(tableName: string, columns: ColumnSpec[], rows: Record<string, unknown>[], returningColumns: string[], onConflict?: {
30
+ conflictColumns?: string[];
31
+ action: 'IGNORE';
32
+ } | {
33
+ conflictColumns: string[];
34
+ action: 'UPDATE';
35
+ updateColumns?: string[];
36
+ }): InsertBatch[];
37
+ /**
38
+ * Builds a parameterized UPDATE ... SET ... WHERE SQL statement.
39
+ *
40
+ * Uses `RETURNING <returningColumns>` (typically primary key columns) instead
41
+ * of `RETURNING *` to avoid permission errors with column-level SELECT grants.
42
+ */
43
+ export declare function buildBulkUpdateSQL(tableName: string, patch: Record<string, unknown>, columns: ColumnSpec[], returningColumns: string[], whereClause: string, whereParams: unknown[]): {
44
+ text: string;
45
+ values: unknown[];
46
+ };
47
+ /**
48
+ * Builds a parameterized DELETE ... WHERE SQL statement.
49
+ *
50
+ * Uses `RETURNING <returningColumns>` (typically primary key columns) instead
51
+ * of `RETURNING *` to avoid permission errors with column-level SELECT grants.
52
+ */
53
+ export declare function buildBulkDeleteSQL(tableName: string, returningColumns: string[], whereClause: string, whereParams: unknown[]): {
54
+ text: string;
55
+ values: unknown[];
56
+ };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * SQL builder utilities for bulk mutations.
3
+ *
4
+ * Provides parameterized SQL generation for VALUES clauses, ON CONFLICT
5
+ * clauses, and SET patches. Handles auto-batching when the parameter
6
+ * count would exceed PostgreSQL's 32K limit.
7
+ *
8
+ * IMPORTANT: All mutation queries use `RETURNING <pk_columns>` instead of
9
+ * `RETURNING *` to respect column-level SELECT grants. The caller is
10
+ * responsible for doing a follow-up SELECT using the returned PKs to
11
+ * resolve the full row through PostGraphile's grant-aware query pipeline.
12
+ * See: https://github.com/pyramation/graphile-column-privileges-mutations
13
+ */
14
+ import { PG_MAX_PARAMS } from '../types';
15
+ /**
16
+ * Builds parameterized INSERT ... VALUES SQL with optional ON CONFLICT clause.
17
+ * Auto-splits into multiple batches when parameter count exceeds PG_MAX_PARAMS.
18
+ *
19
+ * Uses `RETURNING <returningColumns>` (typically primary key columns) instead
20
+ * of `RETURNING *` to avoid permission errors with column-level SELECT grants.
21
+ */
22
+ export function buildBulkInsertSQL(tableName, columns, rows, returningColumns, onConflict) {
23
+ const colNames = columns.map((c) => `"${c.name}"`);
24
+ const colsPerRow = columns.length;
25
+ const maxRowsPerBatch = Math.floor(PG_MAX_PARAMS / colsPerRow);
26
+ const returningClause = returningColumns.length > 0
27
+ ? returningColumns.map((c) => `"${c}"`).join(', ')
28
+ : '*';
29
+ const batches = [];
30
+ for (let offset = 0; offset < rows.length; offset += maxRowsPerBatch) {
31
+ const batchRows = rows.slice(offset, offset + maxRowsPerBatch);
32
+ const values = [];
33
+ const valuePlaceholders = [];
34
+ for (const row of batchRows) {
35
+ const placeholders = [];
36
+ for (const col of columns) {
37
+ const val = row[col.name];
38
+ if (val === undefined) {
39
+ placeholders.push('DEFAULT');
40
+ }
41
+ else if (val === null) {
42
+ placeholders.push('NULL');
43
+ }
44
+ else {
45
+ values.push(val);
46
+ placeholders.push(`$${values.length}::${col.sqlType}`);
47
+ }
48
+ }
49
+ valuePlaceholders.push(`(${placeholders.join(', ')})`);
50
+ }
51
+ let text = `INSERT INTO ${tableName} (${colNames.join(', ')})\nVALUES ${valuePlaceholders.join(',\n ')}`;
52
+ if (onConflict) {
53
+ if (onConflict.conflictColumns && onConflict.conflictColumns.length > 0) {
54
+ const colList = onConflict.conflictColumns.map((c) => `"${c}"`).join(', ');
55
+ text += `\nON CONFLICT (${colList})`;
56
+ }
57
+ else {
58
+ text += '\nON CONFLICT';
59
+ }
60
+ if (onConflict.action === 'IGNORE') {
61
+ text += ' DO NOTHING';
62
+ }
63
+ else if (onConflict.action === 'UPDATE') {
64
+ const setCols = onConflict.updateColumns && onConflict.updateColumns.length > 0
65
+ ? onConflict.updateColumns
66
+ : columns.map((c) => c.name);
67
+ const setClause = setCols
68
+ .map((c) => `"${c}" = EXCLUDED."${c}"`)
69
+ .join(', ');
70
+ text += ` DO UPDATE SET ${setClause}`;
71
+ }
72
+ }
73
+ text += `\nRETURNING ${returningClause}`;
74
+ batches.push({ text, values });
75
+ }
76
+ return batches;
77
+ }
78
+ /**
79
+ * Builds a parameterized UPDATE ... SET ... WHERE SQL statement.
80
+ *
81
+ * Uses `RETURNING <returningColumns>` (typically primary key columns) instead
82
+ * of `RETURNING *` to avoid permission errors with column-level SELECT grants.
83
+ */
84
+ export function buildBulkUpdateSQL(tableName, patch, columns, returningColumns, whereClause, whereParams) {
85
+ const values = [];
86
+ const setClauses = [];
87
+ for (const [fieldName, value] of Object.entries(patch)) {
88
+ const col = columns.find((c) => c.name === fieldName);
89
+ if (!col)
90
+ continue;
91
+ if (value === undefined)
92
+ continue;
93
+ values.push(value);
94
+ setClauses.push(`"${col.name}" = $${values.length}::${col.sqlType}`);
95
+ }
96
+ if (setClauses.length === 0) {
97
+ throw new Error('No columns to update in patch');
98
+ }
99
+ // Renumber where params to follow set params
100
+ const offset = values.length;
101
+ const renumberedWhere = whereClause.replace(/\$(\d+)/g, (_, n) => `$${parseInt(n) + offset}`);
102
+ values.push(...whereParams);
103
+ const returningClause = returningColumns.length > 0
104
+ ? returningColumns.map((c) => `"${c}"`).join(', ')
105
+ : '*';
106
+ const text = `UPDATE ${tableName}\nSET ${setClauses.join(', ')}\nWHERE ${renumberedWhere}\nRETURNING ${returningClause}`;
107
+ return { text, values };
108
+ }
109
+ /**
110
+ * Builds a parameterized DELETE ... WHERE SQL statement.
111
+ *
112
+ * Uses `RETURNING <returningColumns>` (typically primary key columns) instead
113
+ * of `RETURNING *` to avoid permission errors with column-level SELECT grants.
114
+ */
115
+ export function buildBulkDeleteSQL(tableName, returningColumns, whereClause, whereParams) {
116
+ const returningClause = returningColumns.length > 0
117
+ ? returningColumns.map((c) => `"${c}"`).join(', ')
118
+ : '*';
119
+ const text = `DELETE FROM ${tableName}\nWHERE ${whereClause}\nRETURNING ${returningClause}`;
120
+ return { text, values: [...whereParams] };
121
+ }
package/index.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * graphile-bulk-mutations
3
+ *
4
+ * A PostGraphile v5 plugin for bulk insert, upsert, update, and delete
5
+ * mutations with ON CONFLICT handling.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { BulkMutationPreset } from 'graphile-bulk-mutations';
10
+ *
11
+ * const preset = {
12
+ * extends: [
13
+ * BulkMutationPreset(),
14
+ * ],
15
+ * };
16
+ * ```
17
+ *
18
+ * Tables must opt in via smart tags:
19
+ * ```sql
20
+ * COMMENT ON TABLE users IS E'@behavior +bulkInsert +bulkUpsert';
21
+ * COMMENT ON TABLE orders IS E'@behavior +bulkInsert +bulkUpdate +bulkDelete';
22
+ * ```
23
+ */
24
+ import './augmentations';
25
+ export { BulkMutationPreset } from './preset';
26
+ export { BulkDeletePlugin, BulkInflectionPlugin, BulkInsertPlugin, BulkRelationalPlugin, BulkTypesPlugin, BulkUpdatePlugin, BulkUpsertPlugin } from './plugins';
27
+ export type { BulkMutationOptions, BulkNamingStrategy } from './types';
28
+ export { PG_MAX_PARAMS } from './types';
29
+ export type { ColumnSpec, InsertBatch } from './utils';
30
+ export { buildBulkDeleteSQL, buildBulkInsertSQL, buildBulkUpdateSQL } from './utils';
package/index.js ADDED
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ /**
3
+ * graphile-bulk-mutations
4
+ *
5
+ * A PostGraphile v5 plugin for bulk insert, upsert, update, and delete
6
+ * mutations with ON CONFLICT handling.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { BulkMutationPreset } from 'graphile-bulk-mutations';
11
+ *
12
+ * const preset = {
13
+ * extends: [
14
+ * BulkMutationPreset(),
15
+ * ],
16
+ * };
17
+ * ```
18
+ *
19
+ * Tables must opt in via smart tags:
20
+ * ```sql
21
+ * COMMENT ON TABLE users IS E'@behavior +bulkInsert +bulkUpsert';
22
+ * COMMENT ON TABLE orders IS E'@behavior +bulkInsert +bulkUpdate +bulkDelete';
23
+ * ```
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.buildBulkUpdateSQL = exports.buildBulkInsertSQL = exports.buildBulkDeleteSQL = exports.PG_MAX_PARAMS = exports.BulkUpsertPlugin = exports.BulkUpdatePlugin = exports.BulkTypesPlugin = exports.BulkRelationalPlugin = exports.BulkInsertPlugin = exports.BulkInflectionPlugin = exports.BulkDeletePlugin = exports.BulkMutationPreset = void 0;
27
+ require("./augmentations");
28
+ var preset_1 = require("./preset");
29
+ Object.defineProperty(exports, "BulkMutationPreset", { enumerable: true, get: function () { return preset_1.BulkMutationPreset; } });
30
+ // Re-export all plugins for granular use
31
+ var plugins_1 = require("./plugins");
32
+ Object.defineProperty(exports, "BulkDeletePlugin", { enumerable: true, get: function () { return plugins_1.BulkDeletePlugin; } });
33
+ Object.defineProperty(exports, "BulkInflectionPlugin", { enumerable: true, get: function () { return plugins_1.BulkInflectionPlugin; } });
34
+ Object.defineProperty(exports, "BulkInsertPlugin", { enumerable: true, get: function () { return plugins_1.BulkInsertPlugin; } });
35
+ Object.defineProperty(exports, "BulkRelationalPlugin", { enumerable: true, get: function () { return plugins_1.BulkRelationalPlugin; } });
36
+ Object.defineProperty(exports, "BulkTypesPlugin", { enumerable: true, get: function () { return plugins_1.BulkTypesPlugin; } });
37
+ Object.defineProperty(exports, "BulkUpdatePlugin", { enumerable: true, get: function () { return plugins_1.BulkUpdatePlugin; } });
38
+ Object.defineProperty(exports, "BulkUpsertPlugin", { enumerable: true, get: function () { return plugins_1.BulkUpsertPlugin; } });
39
+ var types_1 = require("./types");
40
+ Object.defineProperty(exports, "PG_MAX_PARAMS", { enumerable: true, get: function () { return types_1.PG_MAX_PARAMS; } });
41
+ var utils_1 = require("./utils");
42
+ Object.defineProperty(exports, "buildBulkDeleteSQL", { enumerable: true, get: function () { return utils_1.buildBulkDeleteSQL; } });
43
+ Object.defineProperty(exports, "buildBulkInsertSQL", { enumerable: true, get: function () { return utils_1.buildBulkInsertSQL; } });
44
+ Object.defineProperty(exports, "buildBulkUpdateSQL", { enumerable: true, get: function () { return utils_1.buildBulkUpdateSQL; } });
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "graphile-bulk-mutations",
3
+ "version": "0.2.0",
4
+ "description": "PostGraphile v5 plugin for bulk insert, upsert, update, and delete mutations with ON CONFLICT handling",
5
+ "author": "Constructive <developers@constructive.io>",
6
+ "homepage": "https://github.com/constructive-io/constructive",
7
+ "license": "MIT",
8
+ "main": "index.js",
9
+ "module": "esm/index.js",
10
+ "types": "index.d.ts",
11
+ "scripts": {
12
+ "clean": "makage clean",
13
+ "prepack": "npm run build",
14
+ "build": "makage build",
15
+ "build:dev": "makage build --dev",
16
+ "lint": "eslint . --fix",
17
+ "test": "jest",
18
+ "test:watch": "jest --watch"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "directory": "dist"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/constructive-io/constructive"
27
+ },
28
+ "keywords": [
29
+ "postgraphile",
30
+ "graphile",
31
+ "constructive",
32
+ "plugin",
33
+ "postgres",
34
+ "graphql",
35
+ "bulk",
36
+ "mutations",
37
+ "upsert",
38
+ "on-conflict",
39
+ "v5"
40
+ ],
41
+ "bugs": {
42
+ "url": "https://github.com/constructive-io/constructive/issues"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^22.19.11",
46
+ "graphile-test": "^4.14.1",
47
+ "makage": "^0.3.0",
48
+ "pgsql-test": "^4.13.1"
49
+ },
50
+ "peerDependencies": {
51
+ "@dataplan/pg": "1.0.0",
52
+ "grafast": "1.0.0",
53
+ "graphile-build": "5.0.0",
54
+ "graphile-build-pg": "5.0.0",
55
+ "graphile-config": "1.0.0",
56
+ "graphql": "16.13.0",
57
+ "pg-sql2": "5.0.0",
58
+ "postgraphile": "5.0.0"
59
+ },
60
+ "gitHead": "a8badcda8c3aa551194622b866fc61d4ab3e4eda"
61
+ }
@@ -0,0 +1,13 @@
1
+ import '../augmentations';
2
+ import type { GraphileConfig } from 'graphile-config';
3
+ /**
4
+ * BulkDeletePlugin
5
+ *
6
+ * Registers `bulkDeleteX` mutation fields for each resource with the
7
+ * `bulkDelete` behavior. Uses DELETE ... WHERE with conditions from
8
+ * 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 BulkDeletePlugin: GraphileConfig.Plugin;
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BulkDeletePlugin = void 0;
4
+ require("../augmentations");
5
+ const pg_1 = require("@dataplan/pg");
6
+ const version = '0.1.0';
7
+ /**
8
+ * BulkDeletePlugin
9
+ *
10
+ * Registers `bulkDeleteX` mutation fields for each resource with the
11
+ * `bulkDelete` behavior. Uses DELETE ... WHERE with conditions from
12
+ * 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.BulkDeletePlugin = {
18
+ name: 'BulkDeletePlugin',
19
+ version,
20
+ description: 'Adds bulk delete (bulkDeleteX) 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: { bulkDelete: enableDelete = true, bulkRequireWhere = true } } = build;
29
+ if (!enableDelete)
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, 'bulkDelete'))
44
+ continue;
45
+ const typeName = inflection.tableType(resource.codec);
46
+ const fieldName = inflection.bulkDeleteField(resourceName);
47
+ const inputTypeName = inflection.bulkDeleteInputType(typeName);
48
+ const payloadTypeName = inflection.bulkDeletePayloadType(typeName);
49
+ const inputType = build.getTypeByName(inputTypeName);
50
+ const payloadType = build.getTypeByName(payloadTypeName);
51
+ if (!inputType || !payloadType)
52
+ continue;
53
+ const fieldToAttr = {};
54
+ const attrToSqlType = {};
55
+ for (const [attrName, attr] of Object.entries(resource.codec.attributes)) {
56
+ const gqlFieldName = inflection.attribute({
57
+ attributeName: attrName,
58
+ codec: resource.codec
59
+ });
60
+ fieldToAttr[gqlFieldName] = attrName;
61
+ attrToSqlType[attrName] = sql.compile(attr.codec.sqlType).text;
62
+ }
63
+ // Extract primary key columns for RETURNING clause
64
+ const primaryUnique = resource.uniques.find((u) => u.isPrimary) ?? resource.uniques[0];
65
+ const pkColumns = primaryUnique.attributes;
66
+ const pkReturning = pkColumns.map((c) => `"${c}"`).join(', ');
67
+ const compiledFrom = sql.compile(resource.from).text;
68
+ // Capture for plan closure
69
+ const executor = resource.executor;
70
+ const requireWhere = bulkRequireWhere;
71
+ fields = build.extend(fields, {
72
+ [fieldName]: context.fieldWithHooks({ fieldName }, {
73
+ description: `Bulk delete ${typeName} rows matching the given condition.`,
74
+ type: payloadType,
75
+ args: {
76
+ input: {
77
+ type: new GraphQLNonNull(inputType)
78
+ }
79
+ },
80
+ plan(_$root, args) {
81
+ const $input = args.getRaw('input');
82
+ const $result = (0, pg_1.sideEffectWithPgClient)(executor, $input, async (pgClient, input) => {
83
+ if (requireWhere && (!input.where || Object.keys(input.where).length === 0)) {
84
+ throw new Error('Bulk delete requires a non-empty where condition. Set bulkRequireWhere: false to allow unrestricted deletes.');
85
+ }
86
+ // Build WHERE clause from condition
87
+ // Supports both simple equality (Condition type: { name: "X" })
88
+ // and operator-based (Filter type: { name: { equalTo: "X" } })
89
+ const values = [];
90
+ const whereClauses = [];
91
+ if (input.where) {
92
+ for (const [key, spec] of Object.entries(input.where)) {
93
+ const attrName = fieldToAttr[key];
94
+ if (!attrName)
95
+ continue;
96
+ const sqlType = attrToSqlType[attrName];
97
+ if (spec === null) {
98
+ whereClauses.push(`"${attrName}" IS NULL`);
99
+ }
100
+ else if (spec !== undefined && typeof spec !== 'object') {
101
+ // Simple equality (Condition type)
102
+ values.push(spec);
103
+ whereClauses.push(`"${attrName}" = $${values.length}::${sqlType}`);
104
+ }
105
+ else if (spec && typeof spec === 'object') {
106
+ // Operator-based (Filter type)
107
+ for (const [op, val] of Object.entries(spec)) {
108
+ values.push(val);
109
+ const paramRef = `$${values.length}::${sqlType}`;
110
+ switch (op) {
111
+ case 'equalTo':
112
+ whereClauses.push(`"${attrName}" = ${paramRef}`);
113
+ break;
114
+ case 'notEqualTo':
115
+ whereClauses.push(`"${attrName}" != ${paramRef}`);
116
+ break;
117
+ case 'greaterThan':
118
+ whereClauses.push(`"${attrName}" > ${paramRef}`);
119
+ break;
120
+ case 'greaterThanOrEqualTo':
121
+ whereClauses.push(`"${attrName}" >= ${paramRef}`);
122
+ break;
123
+ case 'lessThan':
124
+ whereClauses.push(`"${attrName}" < ${paramRef}`);
125
+ break;
126
+ case 'lessThanOrEqualTo':
127
+ whereClauses.push(`"${attrName}" <= ${paramRef}`);
128
+ break;
129
+ case 'in':
130
+ if (Array.isArray(val)) {
131
+ const placeholders = val.map((v) => {
132
+ values.push(v);
133
+ return `$${values.length}::${sqlType}`;
134
+ });
135
+ values.pop();
136
+ whereClauses.push(`"${attrName}" IN (${placeholders.join(', ')})`);
137
+ }
138
+ break;
139
+ case 'isNull':
140
+ values.pop();
141
+ if (val) {
142
+ whereClauses.push(`"${attrName}" IS NULL`);
143
+ }
144
+ else {
145
+ whereClauses.push(`"${attrName}" IS NOT NULL`);
146
+ }
147
+ break;
148
+ default:
149
+ values.pop();
150
+ break;
151
+ }
152
+ }
153
+ }
154
+ }
155
+ }
156
+ if (whereClauses.length === 0 && requireWhere) {
157
+ throw new Error('Bulk delete: no valid where conditions resolved. At least one condition is required.');
158
+ }
159
+ const whereStr = whereClauses.length > 0
160
+ ? whereClauses.join(' AND ')
161
+ : 'TRUE';
162
+ // Use RETURNING <pk_columns> instead of RETURNING *
163
+ // For delete, we capture PKs before rows are gone
164
+ const text = `DELETE FROM ${compiledFrom}\nWHERE ${whereStr}\nRETURNING ${pkReturning}`;
165
+ const mutationResult = await pgClient.query(text, values);
166
+ const affectedCount = mutationResult.rowCount ?? 0;
167
+ // For delete, rows no longer exist so we can't do a
168
+ // follow-up SELECT. Return the PK values directly.
169
+ const returning = mutationResult.rows || [];
170
+ return {
171
+ affectedCount,
172
+ returning
173
+ };
174
+ });
175
+ return $result;
176
+ }
177
+ })
178
+ }, `Adding bulk delete field for ${typeName}`);
179
+ }
180
+ return fields;
181
+ }
182
+ }
183
+ }
184
+ };
@@ -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;