Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
buildBulkDeleteSQL,
buildBulkInsertSQL,
buildBulkUpdateSQL,
} from '../src/utils/sql-builder';

describe('bulk mutation catalog identifier quoting', () => {
const hostile = 'value" RETURNING secret --';
const quoted = '"value"" RETURNING secret --"';

it('escapes insert, conflict, update, and returning identifiers', () => {
const [query] = buildBulkInsertSQL(
'tenant_a.items',
[{ name: hostile, sqlType: 'text' }],
[{ [hostile]: 'safe-value' }],
[hostile],
{ conflictColumns: [hostile], action: 'UPDATE', updateColumns: [hostile] }
);

expect(query.text).toContain(`(${quoted})`);
expect(query.text).toContain(`ON CONFLICT (${quoted})`);
expect(query.text).toContain(`${quoted} = EXCLUDED.${quoted}`);
expect(query.text).toContain(`RETURNING ${quoted}`);
expect(query.values).toEqual(['safe-value']);
});

it('escapes update and delete identifiers', () => {
const update = buildBulkUpdateSQL(
'tenant_a.items',
{ [hostile]: 'safe-value' },
[{ name: hostile, sqlType: 'text' }],
[hostile],
'TRUE',
[]
);
const deletion = buildBulkDeleteSQL(
'tenant_a.items',
[hostile],
'TRUE',
[]
);

expect(update.text).toContain(`${quoted} = $1::text`);
expect(update.text).toContain(`RETURNING ${quoted}`);
expect(deletion.text).toContain(`RETURNING ${quoted}`);
});
});
3 changes: 3 additions & 0 deletions graphile/graphile-bulk-mutations/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
"bugs": {
"url": "https://github.com/constructive-io/constructive/issues"
},
"dependencies": {
"@pgsql/quotes": "^18.2.4"
},
"devDependencies": {
"@types/node": "^22.19.11",
"graphile-test": "workspace:^",
Expand Down
31 changes: 19 additions & 12 deletions graphile/graphile-bulk-mutations/src/plugins/BulkDeletePlugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import '../augmentations';

import { sideEffectWithPgClient } from '@dataplan/pg';
import { QuoteUtils } from '@pgsql/quotes';
import type { GraphileConfig } from 'graphile-config';
import type { GraphQLInputType,GraphQLOutputType } from 'graphql';

Expand Down Expand Up @@ -79,7 +80,9 @@ export const BulkDeletePlugin: GraphileConfig.Plugin = {
// Extract primary key columns for RETURNING clause
const primaryUnique = resource.uniques.find((u: any) => u.isPrimary) ?? resource.uniques[0];
const pkColumns: string[] = primaryUnique.attributes;
const pkReturning = pkColumns.map((c) => `"${c}"`).join(', ');
const pkReturning = pkColumns
.map((c) => QuoteUtils.quoteIdentifier(c))
.join(', ');

const compiledFrom = sql.compile(resource.from).text;

Expand Down Expand Up @@ -125,34 +128,36 @@ export const BulkDeletePlugin: GraphileConfig.Plugin = {
const sqlType = attrToSqlType[attrName];

if (spec === null) {
whereClauses.push(`"${attrName}" IS NULL`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} IS NULL`);
} else if (spec !== undefined && typeof spec !== 'object') {
// Simple equality (Condition type)
values.push(spec);
whereClauses.push(`"${attrName}" = $${values.length}::${sqlType}`);
whereClauses.push(
`${QuoteUtils.quoteIdentifier(attrName)} = $${values.length}::${sqlType}`
);
} else if (spec && typeof spec === 'object') {
// Operator-based (Filter type)
for (const [op, val] of Object.entries(spec) as [string, any][]) {
values.push(val);
const paramRef = `$${values.length}::${sqlType}`;
switch (op) {
case 'equalTo':
whereClauses.push(`"${attrName}" = ${paramRef}`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} = ${paramRef}`);
break;
case 'notEqualTo':
whereClauses.push(`"${attrName}" != ${paramRef}`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} != ${paramRef}`);
break;
case 'greaterThan':
whereClauses.push(`"${attrName}" > ${paramRef}`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} > ${paramRef}`);
break;
case 'greaterThanOrEqualTo':
whereClauses.push(`"${attrName}" >= ${paramRef}`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} >= ${paramRef}`);
break;
case 'lessThan':
whereClauses.push(`"${attrName}" < ${paramRef}`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} < ${paramRef}`);
break;
case 'lessThanOrEqualTo':
whereClauses.push(`"${attrName}" <= ${paramRef}`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} <= ${paramRef}`);
break;
case 'in':
if (Array.isArray(val)) {
Expand All @@ -161,15 +166,17 @@ export const BulkDeletePlugin: GraphileConfig.Plugin = {
return `$${values.length}::${sqlType}`;
});
values.pop();
whereClauses.push(`"${attrName}" IN (${placeholders.join(', ')})`);
whereClauses.push(
`${QuoteUtils.quoteIdentifier(attrName)} IN (${placeholders.join(', ')})`
);
}
break;
case 'isNull':
values.pop();
if (val) {
whereClauses.push(`"${attrName}" IS NULL`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} IS NULL`);
} else {
whereClauses.push(`"${attrName}" IS NOT NULL`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} IS NOT NULL`);
}
break;
default:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import '../augmentations';

import { sideEffectWithPgClient } from '@dataplan/pg';
import { QuoteUtils } from '@pgsql/quotes';
import type { GraphileConfig } from 'graphile-config';
import type { GraphQLInputType, GraphQLOutputType } from 'graphql';

Expand Down Expand Up @@ -275,7 +276,7 @@ export const BulkInsertPlugin: GraphileConfig.Plugin = {
const pkConditions = allPkRows.map((pkRow, rowIdx) => {
return pkColumns.map((col, colIdx) => {
const paramIdx = rowIdx * pkColumns.length + colIdx + 1;
return `"${col}" = $${paramIdx}`;
return `${QuoteUtils.quoteIdentifier(col)} = $${paramIdx}`;
}).join(' AND ');
});
const whereClause = pkConditions.map((c) => `(${c})`).join(' OR ');
Expand Down
37 changes: 23 additions & 14 deletions graphile/graphile-bulk-mutations/src/plugins/BulkUpdatePlugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import '../augmentations';

import { sideEffectWithPgClient } from '@dataplan/pg';
import { QuoteUtils } from '@pgsql/quotes';
import type { GraphileConfig } from 'graphile-config';
import type { GraphQLInputType, GraphQLOutputType } from 'graphql';

Expand Down Expand Up @@ -80,7 +81,9 @@ export const BulkUpdatePlugin: GraphileConfig.Plugin = {
// Extract primary key columns for RETURNING clause
const primaryUnique = resource.uniques.find((u: any) => u.isPrimary) ?? resource.uniques[0];
const pkColumns: string[] = primaryUnique.attributes;
const pkReturning = pkColumns.map((c) => `"${c}"`).join(', ');
const pkReturning = pkColumns
.map((c) => QuoteUtils.quoteIdentifier(c))
.join(', ');

const compiledFrom = sql.compile(resource.from).text;

Expand Down Expand Up @@ -126,7 +129,9 @@ export const BulkUpdatePlugin: GraphileConfig.Plugin = {
if (!attrName) continue;
const sqlType = attrToSqlType[attrName];
values.push(val);
setClauses.push(`"${attrName}" = $${values.length}::${sqlType}`);
setClauses.push(
`${QuoteUtils.quoteIdentifier(attrName)} = $${values.length}::${sqlType}`
);
}

if (setClauses.length === 0) {
Expand All @@ -144,34 +149,36 @@ export const BulkUpdatePlugin: GraphileConfig.Plugin = {
const sqlType = attrToSqlType[attrName];

if (spec === null) {
whereClauses.push(`"${attrName}" IS NULL`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} IS NULL`);
} else if (spec !== undefined && typeof spec !== 'object') {
// Simple equality (Condition type)
values.push(spec);
whereClauses.push(`"${attrName}" = $${values.length}::${sqlType}`);
whereClauses.push(
`${QuoteUtils.quoteIdentifier(attrName)} = $${values.length}::${sqlType}`
);
} else if (spec && typeof spec === 'object') {
// Operator-based (Filter type)
for (const [op, val] of Object.entries(spec) as [string, any][]) {
values.push(val);
const paramRef = `$${values.length}::${sqlType}`;
switch (op) {
case 'equalTo':
whereClauses.push(`"${attrName}" = ${paramRef}`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} = ${paramRef}`);
break;
case 'notEqualTo':
whereClauses.push(`"${attrName}" != ${paramRef}`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} != ${paramRef}`);
break;
case 'greaterThan':
whereClauses.push(`"${attrName}" > ${paramRef}`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} > ${paramRef}`);
break;
case 'greaterThanOrEqualTo':
whereClauses.push(`"${attrName}" >= ${paramRef}`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} >= ${paramRef}`);
break;
case 'lessThan':
whereClauses.push(`"${attrName}" < ${paramRef}`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} < ${paramRef}`);
break;
case 'lessThanOrEqualTo':
whereClauses.push(`"${attrName}" <= ${paramRef}`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} <= ${paramRef}`);
break;
case 'in':
if (Array.isArray(val)) {
Expand All @@ -180,15 +187,17 @@ export const BulkUpdatePlugin: GraphileConfig.Plugin = {
return `$${values.length}::${sqlType}`;
});
values.pop();
whereClauses.push(`"${attrName}" IN (${placeholders.join(', ')})`);
whereClauses.push(
`${QuoteUtils.quoteIdentifier(attrName)} IN (${placeholders.join(', ')})`
);
}
break;
case 'isNull':
values.pop();
if (val) {
whereClauses.push(`"${attrName}" IS NULL`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} IS NULL`);
} else {
whereClauses.push(`"${attrName}" IS NOT NULL`);
whereClauses.push(`${QuoteUtils.quoteIdentifier(attrName)} IS NOT NULL`);
}
break;
default:
Expand Down Expand Up @@ -222,7 +231,7 @@ export const BulkUpdatePlugin: GraphileConfig.Plugin = {
const pkConditions = pkRows.map((pkRow, rowIdx) => {
return pkColumns.map((col, colIdx) => {
const paramIdx = rowIdx * pkColumns.length + colIdx + 1;
return `"${col}" = $${paramIdx}`;
return `${QuoteUtils.quoteIdentifier(col)} = $${paramIdx}`;
}).join(' AND ');
});
const selectWhere = pkConditions.map((c) => `(${c})`).join(' OR ');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import '../augmentations';

import { sideEffectWithPgClient } from '@dataplan/pg';
import { QuoteUtils } from '@pgsql/quotes';
import type { GraphileConfig } from 'graphile-config';
import type { GraphQLInputType, GraphQLOutputType } from 'graphql';

Expand Down Expand Up @@ -193,7 +194,7 @@ export const BulkUpsertPlugin: GraphileConfig.Plugin = {
const pkConditions = allPkRows.map((pkRow, rowIdx) => {
return pkColumns.map((col, colIdx) => {
const paramIdx = rowIdx * pkColumns.length + colIdx + 1;
return `"${col}" = $${paramIdx}`;
return `${QuoteUtils.quoteIdentifier(col)} = $${paramIdx}`;
}).join(' AND ');
});
const whereClause = pkConditions.map((c) => `(${c})`).join(' OR ');
Expand Down
23 changes: 16 additions & 7 deletions graphile/graphile-bulk-mutations/src/utils/sql-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
* See: https://github.com/pyramation/graphile-column-privileges-mutations
*/

import { QuoteUtils } from '@pgsql/quotes';

import { PG_MAX_PARAMS } from '../types';

export interface ColumnSpec {
Expand Down Expand Up @@ -45,12 +47,12 @@ export function buildBulkInsertSQL(
updateColumns?: string[];
}
): InsertBatch[] {
const colNames = columns.map((c) => `"${c.name}"`);
const colNames = columns.map((c) => QuoteUtils.quoteIdentifier(c.name));
const colsPerRow = columns.length;
const maxRowsPerBatch = Math.floor(PG_MAX_PARAMS / colsPerRow);

const returningClause = returningColumns.length > 0
? returningColumns.map((c) => `"${c}"`).join(', ')
? returningColumns.map((c) => QuoteUtils.quoteIdentifier(c)).join(', ')
: '*';

const batches: InsertBatch[] = [];
Expand Down Expand Up @@ -79,7 +81,9 @@ export function buildBulkInsertSQL(

if (onConflict) {
if (onConflict.conflictColumns && onConflict.conflictColumns.length > 0) {
const colList = onConflict.conflictColumns.map((c) => `"${c}"`).join(', ');
const colList = onConflict.conflictColumns
.map((c) => QuoteUtils.quoteIdentifier(c))
.join(', ');
text += `\nON CONFLICT (${colList})`;
} else {
text += '\nON CONFLICT';
Expand All @@ -93,7 +97,10 @@ export function buildBulkInsertSQL(
? onConflict.updateColumns
: columns.map((c) => c.name);
const setClause = setCols
.map((c) => `"${c}" = EXCLUDED."${c}"`)
.map((c) => {
const identifier = QuoteUtils.quoteIdentifier(c);
return `${identifier} = EXCLUDED.${identifier}`;
})
.join(', ');
text += ` DO UPDATE SET ${setClause}`;
}
Expand Down Expand Up @@ -130,7 +137,9 @@ export function buildBulkUpdateSQL(
if (value === undefined) continue;

values.push(value);
setClauses.push(`"${col.name}" = $${values.length}::${col.sqlType}`);
setClauses.push(
`${QuoteUtils.quoteIdentifier(col.name)} = $${values.length}::${col.sqlType}`
);
}

if (setClauses.length === 0) {
Expand All @@ -146,7 +155,7 @@ export function buildBulkUpdateSQL(
values.push(...whereParams);

const returningClause = returningColumns.length > 0
? returningColumns.map((c) => `"${c}"`).join(', ')
? returningColumns.map((c) => QuoteUtils.quoteIdentifier(c)).join(', ')
: '*';

const text = `UPDATE ${tableName}\nSET ${setClauses.join(', ')}\nWHERE ${renumberedWhere}\nRETURNING ${returningClause}`;
Expand All @@ -167,7 +176,7 @@ export function buildBulkDeleteSQL(
whereParams: unknown[]
): { text: string; values: unknown[] } {
const returningClause = returningColumns.length > 0
? returningColumns.map((c) => `"${c}"`).join(', ')
? returningColumns.map((c) => QuoteUtils.quoteIdentifier(c)).join(', ')
: '*';

const text = `DELETE FROM ${tableName}\nWHERE ${whereClause}\nRETURNING ${returningClause}`;
Expand Down
1 change: 1 addition & 0 deletions graphile/graphile-i18n/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"url": "https://github.com/constructive-io/constructive/issues"
},
"dependencies": {
"@pgsql/quotes": "^18.2.4",
"accept-language-parser": "^1.5.0"
},
"peerDependencies": {
Expand Down
Loading