Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ jobs:
env: {}
- package: graphile/graphile-ltree
env: {}
- package: graphile/graphile-bulk-mutations
env: {}
- package: graphql/orm-test
env: {}
- package: graphql/test
Expand Down
139 changes: 139 additions & 0 deletions graphile/graphile-bulk-mutations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# graphile-bulk-mutations

<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>

<p align="center" width="100%">
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
</a>
<a href="https://github.com/constructive-io/constructive/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
<a href="https://www.npmjs.com/package/graphile-bulk-mutations"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=graphile%2Fgraphile-bulk-mutations%2Fpackage.json"/></a>
</p>

PostGraphile v5 plugin for bulk mutations in the Constructive monorepo.

Adds bulk mutation support to PostGraphile:

- **Bulk insert** with optional `ON CONFLICT DO NOTHING` (ignore duplicates)
- **Bulk upsert** with `ON CONFLICT DO UPDATE SET` (selective column updates)
- **Bulk update** with condition-based WHERE clauses
- **Bulk delete** with condition-based WHERE clauses
- Column-level SELECT grant safety (`RETURNING <pk>` + follow-up SELECT)
- PG parameter batching (auto-split at 32K limit)
- Opt-in via smart tags (`@behavior +bulkInsert +bulkUpsert +bulkUpdate +bulkDelete`)

## Usage

```typescript
import { BulkMutationPreset } from 'graphile-bulk-mutations';

const preset: GraphileConfig.Preset = {
extends: [
BulkMutationPreset(),
],
};
```

## Per-Table Opt-In

Tables must opt in via smart tags — no bulk mutations are generated by default:

```sql
COMMENT ON TABLE users IS E'@behavior +bulkInsert +bulkUpsert +bulkUpdate +bulkDelete';
```

You can enable specific operations per table:

```sql
COMMENT ON TABLE orders IS E'@behavior +bulkInsert +bulkUpdate';
COMMENT ON TABLE categories IS E'@behavior +bulkInsert +bulkUpsert';
```

## Example Queries

### Bulk Insert

```graphql
mutation {
bulkCreateUsers(input: {
values: [
{ name: "Alice", email: "alice@example.com" }
{ name: "Bob", email: "bob@example.com" }
]
}) {
affectedCount
}
}
```

### Bulk Insert with ON CONFLICT DO NOTHING

```graphql
mutation {
bulkCreateUsers(input: {
values: [
{ name: "Alice", email: "alice@example.com" }
]
onConflict: {
constraint: USERS_EMAIL_KEY
action: IGNORE
}
}) {
affectedCount
}
}
```

### Bulk Upsert

```graphql
mutation {
bulkUpsertUsers(input: {
values: [
{ name: "Alice Updated", email: "alice@example.com" }
]
onConflict: {
constraint: USERS_EMAIL_KEY
}
}) {
affectedCount
}
}
```

### Bulk Update

```graphql
mutation {
bulkUpdateUsers(input: {
where: { name: "Alice" }
patch: { name: "Alice Updated" }
}) {
affectedCount
}
}
```

### Bulk Delete

```graphql
mutation {
bulkDeleteUsers(input: {
where: { name: "Bob" }
}) {
affectedCount
}
}
```

## Configuration

```typescript
BulkMutationPreset({
bulkMaxRows: 1000, // Max rows per insert/upsert (default: 1000)
bulkRequireWhere: true, // Require WHERE on update/delete (default: true)
bulkNaming: 'bulk', // Naming strategy: 'bulk' | 'pluralized' | 'many'
})
```
227 changes: 227 additions & 0 deletions graphile/graphile-bulk-mutations/__tests__/bulk-mutations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { join } from 'path';
import { getConnectionsObject, seed } from 'graphile-test';
import type { GraphQLQueryFnObj } from 'graphile-test';
import { BulkMutationPreset } from '../src';

const SCHEMA = 'bulk_test';
const sqlFile = (f: string) => join(__dirname, '../sql', f);

type QueryFn = GraphQLQueryFnObj;

// Shared setup for all test groups — single schema build is expensive
let teardown: () => Promise<void>;
let query: QueryFn;

beforeAll(async () => {
const testPreset = {
extends: [BulkMutationPreset()],
};

const connections = await getConnectionsObject(
{
schemas: [SCHEMA],
preset: testPreset,
useRoot: true,
},
[seed.sqlfile([sqlFile('test-seed.sql')])]
);

teardown = connections.teardown;
query = connections.query;
}, 30_000);

afterAll(async () => {
if (teardown) await teardown();
});

// ============================================================================
// SCHEMA GENERATION
// ============================================================================
describe('Schema generation', () => {
let fieldNames: string[];

beforeAll(async () => {
const result = await query<{ __type: { fields: { name: string }[] } }>({
query: `
query {
__type(name: "Mutation") {
fields { name }
}
}
`,
});
fieldNames = result.data?.__type?.fields?.map((f) => f.name) ?? [];
});

it('generates bulk insert/upsert/update/delete for items (all behaviors)', () => {
expect(fieldNames).toContain('bulkCreateItems');
expect(fieldNames).toContain('bulkUpsertItems');
expect(fieldNames).toContain('bulkUpdateItems');
expect(fieldNames).toContain('bulkDeleteItems');
});

it('generates only insert + upsert for categories', () => {
expect(fieldNames).toContain('bulkCreateCategories');
expect(fieldNames).toContain('bulkUpsertCategories');
expect(fieldNames).not.toContain('bulkUpdateCategories');
expect(fieldNames).not.toContain('bulkDeleteCategories');
});

it('generates insert + update + delete for products (no upsert)', () => {
expect(fieldNames).toContain('bulkCreateProducts');
expect(fieldNames).not.toContain('bulkUpsertProducts');
expect(fieldNames).toContain('bulkUpdateProducts');
expect(fieldNames).toContain('bulkDeleteProducts');
});

it('does NOT generate bulk mutations for non-tagged tables', () => {
const noBulk = fieldNames.filter(
(n) => n.includes('NoBulk') && n.startsWith('bulk')
);
expect(noBulk).toHaveLength(0);
});
});

// ============================================================================
// BULK INSERT
// ============================================================================
describe('Bulk insert', () => {
it('inserts multiple rows', async () => {
const result = await query<{
bulkCreateItems: { affectedCount: number };
}>({
query: `
mutation {
bulkCreateItems(input: {
values: [
{ name: "New A", price: "1.00", quantity: 1 }
{ name: "New B", price: "2.00", quantity: 2 }
]
}) {
affectedCount
}
}
`,
});
expect(result.errors).toBeUndefined();
expect(result.data?.bulkCreateItems.affectedCount).toBe(2);
});

it('returns zero when values is empty', async () => {
const result = await query<{
bulkCreateItems: { affectedCount: number };
}>({
query: `
mutation {
bulkCreateItems(input: { values: [] }) {
affectedCount
}
}
`,
});
expect(result.errors).toBeUndefined();
expect(result.data?.bulkCreateItems.affectedCount).toBe(0);
});

it('supports ON CONFLICT DO NOTHING (ignore duplicates)', async () => {
const result = await query<{
bulkCreateItems: { affectedCount: number };
}>({
query: `
mutation {
bulkCreateItems(input: {
values: [
{ name: "Widget A", price: "1.00", quantity: 1 }
{ name: "Brand New", price: "5.00", quantity: 5 }
]
onConflict: {
constraint: ITEMS_NAME_KEY
action: IGNORE
}
}) {
affectedCount
}
}
`,
});
expect(result.errors).toBeUndefined();
// Widget A already exists, should be ignored; Brand New is new
expect(result.data?.bulkCreateItems.affectedCount).toBe(1);
});
});

// ============================================================================
// BULK UPSERT
// ============================================================================
describe('Bulk upsert', () => {
it('upserts rows (inserts new, updates existing)', async () => {
const result = await query<{
bulkUpsertItems: { affectedCount: number };
}>({
query: `
mutation {
bulkUpsertItems(input: {
values: [
{ name: "Widget A", price: "99.99", quantity: 999 }
{ name: "Upserted New", price: "3.00", quantity: 3 }
]
onConflict: {
constraint: ITEMS_NAME_KEY
}
}) {
affectedCount
}
}
`,
});
expect(result.errors).toBeUndefined();
expect(result.data?.bulkUpsertItems.affectedCount).toBe(2);
});
});

// ============================================================================
// BULK UPDATE
// ============================================================================
describe('Bulk update', () => {
it('updates rows matching a condition', async () => {
const result = await query<{
bulkUpdateItems: { affectedCount: number };
}>({
query: `
mutation {
bulkUpdateItems(input: {
where: { name: "Gadget X" }
patch: { quantity: 0 }
}) {
affectedCount
}
}
`,
});
expect(result.errors).toBeUndefined();
expect(result.data?.bulkUpdateItems.affectedCount).toBe(1);
});
});

// ============================================================================
// BULK DELETE
// ============================================================================
describe('Bulk delete', () => {
it('deletes rows matching a condition', async () => {
const result = await query<{
bulkDeleteItems: { affectedCount: number };
}>({
query: `
mutation {
bulkDeleteItems(input: {
where: { name: "Gadget X" }
}) {
affectedCount
}
}
`,
});
expect(result.errors).toBeUndefined();
expect(result.data?.bulkDeleteItems.affectedCount).toBe(1);
});
});
18 changes: 18 additions & 0 deletions graphile/graphile-bulk-mutations/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
babelConfig: false,
tsconfig: 'tsconfig.json'
}
]
},
transformIgnorePatterns: [`/node_modules/*`],
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*']
};
Loading
Loading