mirror of
https://github.com/ToolJet/ToolJet
synced 2026-04-29 17:38:07 +00:00
804 lines
31 KiB
TypeScript
804 lines
31 KiB
TypeScript
'use strict';
|
||
|
||
// ─── Error ────────────────────────────────────────────────────────────────────
|
||
|
||
export class QueryBuilderError extends Error {
|
||
operation: string | null;
|
||
dialect: string | null;
|
||
|
||
constructor(
|
||
message: string,
|
||
{ operation = null, dialect = null }: { operation?: string | null; dialect?: string | null } = {}
|
||
) {
|
||
super(message);
|
||
this.name = 'QueryBuilderError';
|
||
this.operation = operation;
|
||
this.dialect = dialect;
|
||
}
|
||
}
|
||
|
||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||
|
||
const OPERATORS: Record<string, string> = {
|
||
eq: '=',
|
||
neq: '!=',
|
||
gt: '>',
|
||
gte: '>=',
|
||
lt: '<',
|
||
lte: '<=',
|
||
like: 'LIKE',
|
||
ilike: 'ILIKE', // PostgreSQL native; falls back to LIKE on other dialects
|
||
in: 'IN',
|
||
not_in: 'NOT IN',
|
||
between: 'BETWEEN',
|
||
};
|
||
|
||
const AGGREGATE_FNS = new Set(['sum', 'count', 'avg', 'min', 'max', 'count_distinct']);
|
||
|
||
const VALID_ORDER_DIRECTIONS = new Set(['ASC', 'DESC']);
|
||
|
||
// ─── Dialects ─────────────────────────────────────────────────────────────────
|
||
|
||
abstract class BaseDialect {
|
||
abstract quote(id: string): string;
|
||
limitOffset(_limit: number | string | null | undefined, _offset: number | string | null | undefined): string {
|
||
return '';
|
||
}
|
||
supportsIlike(): boolean {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
class PostgreSQLDialect extends BaseDialect {
|
||
get name() {
|
||
return 'postgresql';
|
||
}
|
||
quote(id: string): string {
|
||
return `"${String(id).replace(/"/g, '""')}"`;
|
||
}
|
||
supportsIlike(): boolean {
|
||
return true;
|
||
}
|
||
limitOffset(limit: number | string | null | undefined, offset: number | string | null | undefined): string {
|
||
let s = '';
|
||
if (limit != null) s += ` LIMIT ${toPositiveInt(limit)}`;
|
||
if (offset != null) s += ` OFFSET ${toPositiveInt(offset)}`;
|
||
return s;
|
||
}
|
||
}
|
||
|
||
class MySQLDialect extends BaseDialect {
|
||
get name() {
|
||
return 'mysql';
|
||
}
|
||
quote(id: string): string {
|
||
return `\`${String(id).replace(/`/g, '``')}\``;
|
||
}
|
||
limitOffset(limit: number | string | null | undefined, offset: number | string | null | undefined): string {
|
||
if (limit == null) return '';
|
||
let s = ` LIMIT ${toPositiveInt(limit)}`;
|
||
if (offset != null) s += ` OFFSET ${toPositiveInt(offset)}`;
|
||
return s;
|
||
}
|
||
}
|
||
|
||
class MSSQLDialect extends BaseDialect {
|
||
get name() {
|
||
return 'mssql';
|
||
}
|
||
quote(id: string): string {
|
||
return `[${String(id).replace(/\]/g, ']]')}]`;
|
||
}
|
||
/**
|
||
* MSSQL OFFSET/FETCH – requires ORDER BY in the surrounding query.
|
||
* Call only when offset is set; TOP handles the limit-only case.
|
||
*/
|
||
limitOffset(limit: number | string | null | undefined, offset: number | string | null | undefined): string {
|
||
if (offset == null) return '';
|
||
const offsetPart = ` OFFSET ${toPositiveInt(offset)} ROWS`;
|
||
if (limit != null) return `${offsetPart} FETCH NEXT ${toPositiveInt(limit)} ROWS ONLY`;
|
||
return offsetPart;
|
||
}
|
||
}
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function toPositiveInt(value: number | string): number {
|
||
const parsedNumber = parseInt(String(value), 10);
|
||
if (isNaN(parsedNumber) || parsedNumber < 0) {
|
||
throw new QueryBuilderError(`Expected a non-negative integer, got: "${value}"`);
|
||
}
|
||
return parsedNumber;
|
||
}
|
||
|
||
function getDialect(name: string): BaseDialect {
|
||
switch (String(name).toLowerCase()) {
|
||
case 'postgresql':
|
||
case 'postgres':
|
||
return new PostgreSQLDialect();
|
||
case 'mysql':
|
||
return new MySQLDialect();
|
||
case 'mssql':
|
||
case 'sqlserver':
|
||
return new MSSQLDialect();
|
||
default:
|
||
throw new QueryBuilderError(`Unsupported dialect: "${name}"`);
|
||
}
|
||
}
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
interface WhereFilter {
|
||
column: string;
|
||
operator: string;
|
||
value?: unknown;
|
||
}
|
||
|
||
interface OrderFilter {
|
||
column: string;
|
||
order?: string;
|
||
}
|
||
|
||
interface AggregateEntry {
|
||
aggFx: string;
|
||
column: string;
|
||
table_id?: string;
|
||
}
|
||
|
||
interface CreateRowEntry {
|
||
column: string;
|
||
value?: unknown;
|
||
}
|
||
|
||
interface UpdateRowEntry {
|
||
column: string;
|
||
value?: unknown;
|
||
}
|
||
|
||
interface ListRowsInput {
|
||
schema?: string;
|
||
aggregates?: Record<string, AggregateEntry>;
|
||
group_by?: Record<string, string[]>;
|
||
where_filters?: Record<string, WhereFilter>;
|
||
order_filters?: Record<string, OrderFilter>;
|
||
limit?: string | number;
|
||
offset?: string | number;
|
||
fields?: Array<{ name: string; table: string }>;
|
||
}
|
||
|
||
interface DeleteRowsInput {
|
||
schema?: string;
|
||
limit?: string | number;
|
||
where_filters?: Record<string, WhereFilter>;
|
||
}
|
||
|
||
interface UpdateRowsInput {
|
||
schema?: string;
|
||
columns: Record<string, UpdateRowEntry>;
|
||
where_filters?: Record<string, WhereFilter>;
|
||
}
|
||
|
||
interface UpsertRowsInput {
|
||
schema?: string;
|
||
primary_key_columns: string | string[];
|
||
columns: Record<string, UpdateRowEntry>;
|
||
}
|
||
|
||
interface BulkInsertInput {
|
||
schema?: string;
|
||
rows_insert: Record<string, unknown>[];
|
||
}
|
||
|
||
interface BulkUpdateWithPrimaryKeyInput {
|
||
schema?: string;
|
||
primary_key: string | string[];
|
||
rows_update: Record<string, unknown>[];
|
||
}
|
||
|
||
interface BulkUpsertWithPrimaryKeyInput {
|
||
schema?: string;
|
||
primary_key: string | string[];
|
||
row_upsert: Record<string, unknown>[];
|
||
}
|
||
|
||
interface QueryResult {
|
||
query: string;
|
||
params: unknown[];
|
||
}
|
||
|
||
interface BulkQueryResult {
|
||
queries: QueryResult[];
|
||
}
|
||
|
||
// ─── QueryBuilder ─────────────────────────────────────────────────────────────
|
||
|
||
export class QueryBuilder {
|
||
private _dialect: BaseDialect;
|
||
private _params: unknown[];
|
||
|
||
constructor(dialectName = 'postgresql') {
|
||
this._dialect = getDialect(dialectName);
|
||
this._params = [];
|
||
}
|
||
|
||
// ── Internal state ──────────────────────────────────────────────────────────
|
||
|
||
private _reset(): void {
|
||
this._params = [];
|
||
}
|
||
|
||
private _normalizePrimaryKey(primaryKey: string | string[] | undefined | null): string[] {
|
||
if (Array.isArray(primaryKey)) return primaryKey.filter(Boolean);
|
||
if (typeof primaryKey === 'string' && primaryKey.trim()) return [primaryKey.trim()];
|
||
return [];
|
||
}
|
||
|
||
private _addParam(value: unknown): string {
|
||
this._params.push(value);
|
||
return '?';
|
||
}
|
||
|
||
// Builds a fully-qualified table reference: "schema"."table" or just "table"
|
||
private _buildTableRef(tableName: string, schema?: string | null): string {
|
||
const trimmedSchema = schema && String(schema).trim();
|
||
if (trimmedSchema) {
|
||
return `${this._dialect.quote(trimmedSchema)}.${this._dialect.quote(tableName)}`;
|
||
}
|
||
return this._dialect.quote(tableName);
|
||
}
|
||
|
||
// ── WHERE builder ───────────────────────────────────────────────────────────
|
||
|
||
private _whereClause(whereFilters: Record<string, WhereFilter> | null | undefined): string {
|
||
if (!whereFilters || Object.keys(whereFilters).length === 0) return '';
|
||
const conditions = Object.values(whereFilters)
|
||
.map((f) => this._condition(f))
|
||
.filter((c): c is string => c !== null);
|
||
if (conditions.length === 0) return '';
|
||
return ` WHERE ${conditions.join(' AND ')}`;
|
||
}
|
||
|
||
private _condition({ column, operator, value }: WhereFilter): string | null {
|
||
const hasColumn = !!(column && String(column).trim());
|
||
const hasOperator = !!(operator && String(operator).trim());
|
||
const isValueEmpty = value === undefined || value === null || value === '';
|
||
|
||
// Fully empty row — silently skip
|
||
if (!hasColumn && !hasOperator && isValueEmpty) return null;
|
||
|
||
if (!hasColumn) throw new QueryBuilderError('A filter condition has a value or operator but no column specified');
|
||
if (!hasOperator) throw new QueryBuilderError(`Filter on column "${column}" is missing an operator`);
|
||
|
||
const col = this._dialect.quote(column);
|
||
|
||
// 'is' operator: the stored value ('null' | 'not_null') determines the SQL fragment
|
||
if (operator === 'is') {
|
||
if (value === 'null') return `${col} IS NULL`;
|
||
if (value === 'not_null') return `${col} IS NOT NULL`;
|
||
throw new QueryBuilderError(`Unknown value for "is" operator: "${value}". Expected "null" or "not_null".`);
|
||
}
|
||
|
||
const sqlOp = OPERATORS[operator];
|
||
if (!sqlOp) throw new QueryBuilderError(`Unknown operator: "${operator}"`);
|
||
|
||
const effectiveOp = operator === 'ilike' && !this._dialect.supportsIlike() ? 'LIKE' : sqlOp;
|
||
|
||
if (operator === 'in' || operator === 'not_in') {
|
||
const values: unknown[] = Array.isArray(value)
|
||
? value
|
||
: String(value)
|
||
.split(',')
|
||
.map((v) => v.trim());
|
||
if ((values as unknown[]).length === 0) {
|
||
throw new QueryBuilderError(`"${operator}" requires at least one value`);
|
||
}
|
||
const placeholders = (values as unknown[]).map((v) => this._addParam(v));
|
||
return `${col} ${effectiveOp} (${placeholders.join(', ')})`;
|
||
}
|
||
|
||
if (operator === 'between') {
|
||
if (!Array.isArray(value) || (value as unknown[]).length !== 2) {
|
||
throw new QueryBuilderError('"between" requires value to be a 2-element array [from, to]');
|
||
}
|
||
return `${col} ${effectiveOp} ${this._addParam((value as unknown[])[0])} AND ${this._addParam(
|
||
(value as unknown[])[1]
|
||
)}`;
|
||
}
|
||
|
||
return `${col} ${effectiveOp} ${this._addParam(value)}`;
|
||
}
|
||
|
||
// ── SELECT clause builder ───────────────────────────────────────────────────
|
||
|
||
private _selectClause(
|
||
fields: Array<{ name: string; table: string }> | null | undefined,
|
||
aggregates: Record<string, AggregateEntry> | null | undefined
|
||
): string {
|
||
const parts: string[] = [];
|
||
|
||
if (fields && fields.length > 0) {
|
||
parts.push(
|
||
...fields.map((f) => {
|
||
if (!f.name) throw new QueryBuilderError('fields entry is missing "name"');
|
||
return this._dialect.quote(f.name);
|
||
})
|
||
);
|
||
}
|
||
|
||
if (aggregates && Object.keys(aggregates).length > 0) {
|
||
for (const agg of Object.values(aggregates)) {
|
||
const hasAggFx = !!(agg.aggFx && String(agg.aggFx).trim());
|
||
const hasColumn = !!(agg.column && String(agg.column).trim());
|
||
if (!hasAggFx && !hasColumn) continue; // skip fully empty aggregate entry
|
||
if (!hasAggFx) throw new QueryBuilderError(`Aggregate on column "${agg.column}" is missing a function`);
|
||
if (!hasColumn) throw new QueryBuilderError(`Aggregate function "${agg.aggFx}" is missing a column`);
|
||
const fn = String(agg.aggFx).toLowerCase();
|
||
if (!AGGREGATE_FNS.has(fn)) {
|
||
throw new QueryBuilderError(`Unknown aggregate function: "${agg.aggFx}"`);
|
||
}
|
||
const col = this._dialect.quote(agg.column);
|
||
parts.push(fn === 'count_distinct' ? `COUNT(DISTINCT ${col})` : `${fn.toUpperCase()}(${col})`);
|
||
}
|
||
}
|
||
|
||
return parts.length > 0 ? parts.join(', ') : '*';
|
||
}
|
||
|
||
// ── Operations ──────────────────────────────────────────────────────────────
|
||
createRow(
|
||
tableName: string,
|
||
schema: string | undefined | null,
|
||
columns: Record<string, CreateRowEntry> | undefined | null
|
||
): QueryResult {
|
||
this._reset();
|
||
this._assertTableName(tableName, 'create_row');
|
||
|
||
const table = this._buildTableRef(tableName, schema);
|
||
|
||
const entries = Object.values(columns || {}).filter((entry) => {
|
||
const hasColumn = !!(entry.column && String(entry.column).trim());
|
||
const isValueEmpty = entry.value === undefined || entry.value === null || entry.value === '';
|
||
if (!hasColumn && isValueEmpty) return false; // skip fully empty
|
||
if (!hasColumn) throw new QueryBuilderError('A column entry has a value but no column name specified');
|
||
return true;
|
||
});
|
||
|
||
if (entries.length === 0) {
|
||
const query = `INSERT INTO ${table} DEFAULT VALUES`;
|
||
return { query, params: [] };
|
||
}
|
||
|
||
const cols = entries.map((e) => this._dialect.quote(e.column));
|
||
const placeholders = entries.map((e) => this._addParam(e.value));
|
||
|
||
const query = `INSERT INTO ${table} (${cols.join(', ')}) VALUES (${placeholders.join(', ')})`;
|
||
return { query, params: [...this._params] };
|
||
}
|
||
|
||
updateRows(tableName: string, updateRows: UpdateRowsInput): QueryResult {
|
||
this._reset();
|
||
this._assertTableName(tableName, 'update_rows');
|
||
|
||
const { schema, columns, where_filters } = updateRows;
|
||
|
||
const validColumnEntries = Object.values(columns || {}).filter((col) => {
|
||
const hasColumn = !!(col.column && String(col.column).trim());
|
||
const isValueEmpty = col.value === undefined || col.value === null || col.value === '';
|
||
if (!hasColumn && isValueEmpty) return false; // skip fully empty
|
||
if (!hasColumn) throw new QueryBuilderError('An update entry has a value but no column name specified');
|
||
return true;
|
||
});
|
||
|
||
if (validColumnEntries.length === 0) {
|
||
throw new QueryBuilderError('At least one column to update is required');
|
||
}
|
||
|
||
const setClauses = validColumnEntries.map(
|
||
(col) => `${this._dialect.quote(col.column)} = ${this._addParam(col.value)}`
|
||
);
|
||
|
||
const table = this._buildTableRef(tableName, schema);
|
||
let query = `UPDATE ${table} SET ${setClauses.join(', ')}`;
|
||
query += this._whereClause(where_filters);
|
||
|
||
return { query, params: [...this._params] };
|
||
}
|
||
|
||
upsertRows(tableName: string, upsertRowsData: UpsertRowsInput): QueryResult {
|
||
this._reset();
|
||
this._assertTableName(tableName, 'upsert_rows');
|
||
|
||
const { schema, primary_key_columns: rawPrimaryKeyColumns, columns } = upsertRowsData;
|
||
const primary_key_columns = this._normalizePrimaryKey(rawPrimaryKeyColumns);
|
||
|
||
if (primary_key_columns.length === 0) {
|
||
throw new QueryBuilderError('At least one primary key column is required for upsert');
|
||
}
|
||
|
||
const columnEntries = Object.values(columns || {}).filter((entry) => {
|
||
const hasColumn = !!(entry.column && String(entry.column).trim());
|
||
const isValueEmpty = entry.value === undefined || entry.value === null || entry.value === '';
|
||
if (!hasColumn && isValueEmpty) return false; // skip fully empty
|
||
if (!hasColumn) throw new QueryBuilderError('An upsert entry has a value but no column name specified');
|
||
return true;
|
||
});
|
||
|
||
if (columnEntries.length === 0) {
|
||
throw new QueryBuilderError('At least one column is required for upsert');
|
||
}
|
||
|
||
const primaryKeySet = new Set(primary_key_columns);
|
||
const allColumnNames = columnEntries.map((entry) => entry.column);
|
||
const updateColumnNames = allColumnNames.filter((col) => !primaryKeySet.has(col));
|
||
const row = Object.fromEntries(columnEntries.map((entry) => [entry.column, entry.value]));
|
||
const table = this._buildTableRef(tableName, schema);
|
||
|
||
if (this._dialect instanceof PostgreSQLDialect) {
|
||
return this._pgUpsert(table, primary_key_columns, allColumnNames, updateColumnNames, row);
|
||
} else if (this._dialect instanceof MySQLDialect) {
|
||
return this._mysqlUpsert(table, primary_key_columns, allColumnNames, updateColumnNames, row);
|
||
} else {
|
||
return this._mssqlUpsert(table, primary_key_columns, allColumnNames, updateColumnNames, row);
|
||
}
|
||
}
|
||
|
||
deleteRows(tableName: string, deleteRows: DeleteRowsInput): QueryResult {
|
||
this._reset();
|
||
this._assertTableName(tableName, 'delete_rows');
|
||
|
||
const { schema, limit: rawLimit, where_filters } = deleteRows;
|
||
const limitStr = rawLimit == null ? '' : String(rawLimit).trim();
|
||
const limit = limitStr === '' ? undefined : limitStr;
|
||
const table = this._buildTableRef(tableName, schema);
|
||
let query: string;
|
||
|
||
if (this._dialect instanceof MySQLDialect) {
|
||
query = `DELETE FROM ${table}`;
|
||
query += this._whereClause(where_filters);
|
||
if (limit != null) query += ` LIMIT ${toPositiveInt(limit)}`;
|
||
} else if (this._dialect instanceof MSSQLDialect) {
|
||
const top = limit != null ? `TOP(${toPositiveInt(limit)}) ` : '';
|
||
query = `DELETE ${top}FROM ${table}`;
|
||
query += this._whereClause(where_filters);
|
||
} else {
|
||
// PostgreSQL – use ctid subquery to honour a LIMIT
|
||
if (limit != null) {
|
||
const innerWhere = this._whereClause(where_filters);
|
||
query = `DELETE FROM ${table} WHERE ctid IN (SELECT ctid FROM ${table}${innerWhere} LIMIT ${toPositiveInt(
|
||
limit
|
||
)})`;
|
||
} else {
|
||
query = `DELETE FROM ${table}`;
|
||
query += this._whereClause(where_filters);
|
||
}
|
||
}
|
||
|
||
return { query, params: [...this._params] };
|
||
}
|
||
|
||
listRows(tableName: string, listRows: ListRowsInput = {}): QueryResult {
|
||
this._reset();
|
||
this._assertTableName(tableName, 'list_rows');
|
||
|
||
const {
|
||
schema,
|
||
aggregates,
|
||
group_by,
|
||
where_filters,
|
||
order_filters,
|
||
limit: rawLimit,
|
||
offset: rawOffset,
|
||
fields,
|
||
} = listRows;
|
||
const limitStr = rawLimit == null ? '' : String(rawLimit).trim();
|
||
const limit = limitStr === '' ? undefined : limitStr;
|
||
const offsetStr = rawOffset == null ? '' : String(rawOffset).trim();
|
||
const offset = offsetStr === '' ? undefined : offsetStr;
|
||
const table = this._buildTableRef(tableName, schema);
|
||
|
||
const selectExpr = this._selectClause(fields, aggregates);
|
||
|
||
let topClause = '';
|
||
if (this._dialect instanceof MSSQLDialect && limit != null && offset == null) {
|
||
topClause = `TOP ${toPositiveInt(limit)} `;
|
||
}
|
||
|
||
let query = `SELECT ${topClause}${selectExpr} FROM ${table}`;
|
||
|
||
query += this._whereClause(where_filters);
|
||
|
||
if (group_by && Object.keys(group_by).length > 0) {
|
||
const cols = Object.values(group_by)
|
||
.flat()
|
||
.map((c) => this._dialect.quote(c));
|
||
query += ` GROUP BY ${cols.join(', ')}`;
|
||
}
|
||
|
||
let hasOrderBy = false;
|
||
if (order_filters && Object.keys(order_filters).length > 0) {
|
||
const orders = Object.values(order_filters)
|
||
.filter((of) => !!(of.column && String(of.column).trim())) // skip empty column entries
|
||
.map((of) => {
|
||
const dir = String(of.order || 'ASC').toUpperCase();
|
||
if (!VALID_ORDER_DIRECTIONS.has(dir)) {
|
||
throw new QueryBuilderError(`Invalid sort direction: "${of.order}". Use "asc" or "desc".`);
|
||
}
|
||
return `${this._dialect.quote(of.column)} ${dir}`;
|
||
});
|
||
if (orders.length > 0) {
|
||
query += ` ORDER BY ${orders.join(', ')}`;
|
||
hasOrderBy = true;
|
||
}
|
||
}
|
||
|
||
if (this._dialect instanceof MSSQLDialect) {
|
||
if (offset != null) {
|
||
if (!hasOrderBy) query += ' ORDER BY (SELECT NULL)';
|
||
query += this._dialect.limitOffset(limit, offset);
|
||
}
|
||
} else {
|
||
query += this._dialect.limitOffset(limit, offset);
|
||
}
|
||
|
||
return { query, params: [...this._params] };
|
||
}
|
||
|
||
bulkInsert(tableName: string, bulkInsert: BulkInsertInput): QueryResult {
|
||
this._reset();
|
||
this._assertTableName(tableName, 'bulk_insert');
|
||
|
||
const { schema, rows_insert } = bulkInsert;
|
||
if (!rows_insert || rows_insert.length === 0) {
|
||
throw new QueryBuilderError('Bulk insert requires at least one row', { operation: 'bulk_insert' });
|
||
}
|
||
|
||
const allCols = [...new Set(rows_insert.flatMap((row) => Object.keys(row)))];
|
||
if (allCols.length === 0) {
|
||
throw new QueryBuilderError('Bulk insert rows must have at least one column', { operation: 'bulk_insert' });
|
||
}
|
||
|
||
const quotedCols = allCols.map((c) => this._dialect.quote(c));
|
||
const valueGroups = rows_insert.map((row) => {
|
||
const placeholders = allCols.map((col) => this._addParam(col in row ? row[col] : null));
|
||
return `(${placeholders.join(', ')})`;
|
||
});
|
||
|
||
const table = this._buildTableRef(tableName, schema);
|
||
const query = `INSERT INTO ${table} (${quotedCols.join(', ')}) VALUES ${valueGroups.join(', ')}`;
|
||
return { query, params: [...this._params] };
|
||
}
|
||
|
||
bulkUpdateWithPrimaryKey(tableName: string, bulkUpdate: BulkUpdateWithPrimaryKeyInput): BulkQueryResult {
|
||
this._assertTableName(tableName, 'bulk_update_with_primary_key');
|
||
|
||
const { schema, primary_key: rawPrimaryKey, rows_update } = bulkUpdate;
|
||
const primary_key = this._normalizePrimaryKey(rawPrimaryKey);
|
||
if (primary_key.length === 0) {
|
||
throw new QueryBuilderError('Bulk update requires at least one primary key column', {
|
||
operation: 'bulk_update_with_primary_key',
|
||
});
|
||
}
|
||
if (!rows_update || rows_update.length === 0) {
|
||
throw new QueryBuilderError('Bulk update requires at least one row', {
|
||
operation: 'bulk_update_with_primary_key',
|
||
});
|
||
}
|
||
|
||
const primaryKeySet = new Set(primary_key);
|
||
const table = this._buildTableRef(tableName, schema);
|
||
|
||
const queries = rows_update.map((row, rowIndex) => {
|
||
this._reset();
|
||
|
||
for (const primaryKey of primary_key) {
|
||
if (!(primaryKey in row)) {
|
||
throw new QueryBuilderError(`Row ${rowIndex + 1} is missing primary key column "${primaryKey}"`, {
|
||
operation: 'bulk_update_with_primary_key',
|
||
});
|
||
}
|
||
}
|
||
|
||
const updateCols = Object.keys(row).filter((col) => !primaryKeySet.has(col));
|
||
if (updateCols.length === 0) {
|
||
throw new QueryBuilderError(`Row ${rowIndex + 1} has no columns to update (only primary key columns present)`, {
|
||
operation: 'bulk_update_with_primary_key',
|
||
});
|
||
}
|
||
|
||
const setClauses = updateCols.map((col) => `${this._dialect.quote(col)} = ${this._addParam(row[col])}`);
|
||
const whereClauses = primary_key.map((pk) => `${this._dialect.quote(pk)} = ${this._addParam(row[pk])}`);
|
||
|
||
const query = `UPDATE ${table} SET ${setClauses.join(', ')} WHERE ${whereClauses.join(' AND ')}`;
|
||
return { query, params: [...this._params] };
|
||
});
|
||
|
||
return { queries };
|
||
}
|
||
|
||
bulkUpsertWithPrimaryKey(tableName: string, bulkUpsert: BulkUpsertWithPrimaryKeyInput): BulkQueryResult {
|
||
this._assertTableName(tableName, 'bulk_upsert_with_primary_key');
|
||
|
||
const { schema, primary_key: rawPrimaryKey, row_upsert } = bulkUpsert;
|
||
const primary_key = this._normalizePrimaryKey(rawPrimaryKey);
|
||
if (primary_key.length === 0) {
|
||
throw new QueryBuilderError('Bulk upsert requires at least one primary key column', {
|
||
operation: 'bulk_upsert_with_primary_key',
|
||
});
|
||
}
|
||
if (!row_upsert || row_upsert.length === 0) {
|
||
throw new QueryBuilderError('Bulk upsert requires at least one row', {
|
||
operation: 'bulk_upsert_with_primary_key',
|
||
});
|
||
}
|
||
|
||
const primaryKeySet = new Set(primary_key);
|
||
const table = this._buildTableRef(tableName, schema);
|
||
|
||
const queries = row_upsert.map((row) => {
|
||
this._reset();
|
||
|
||
const allCols = Object.keys(row);
|
||
const updateCols = allCols.filter((col) => !primaryKeySet.has(col));
|
||
|
||
if (this._dialect instanceof PostgreSQLDialect) {
|
||
return this._pgUpsert(table, primary_key, allCols, updateCols, row);
|
||
} else if (this._dialect instanceof MySQLDialect) {
|
||
return this._mysqlUpsert(table, primary_key, allCols, updateCols, row);
|
||
} else {
|
||
return this._mssqlUpsert(table, primary_key, allCols, updateCols, row);
|
||
}
|
||
});
|
||
|
||
return { queries };
|
||
}
|
||
|
||
// ── Dialect-specific upsert helpers ─────────────────────────────────────────
|
||
|
||
private _pgUpsert(
|
||
table: string,
|
||
primaryKey: string[],
|
||
allCols: string[],
|
||
updateCols: string[],
|
||
row: Record<string, unknown>
|
||
): QueryResult {
|
||
const primaryKeySet = new Set(primaryKey);
|
||
|
||
// Rule 2: All PK values absent/null → INSERT without PK columns (IDENTITY auto-gen)
|
||
// RETURNING * is embedded here because the upsert execution path does not append it.
|
||
const allPrimaryKeyValuesAbsentOrNull = primaryKey.every(
|
||
(primaryKeyColumn) => !(primaryKeyColumn in row) || row[primaryKeyColumn] == null
|
||
);
|
||
if (allPrimaryKeyValuesAbsentOrNull) {
|
||
const nonPrimaryKeyColumns = allCols.filter((col) => !primaryKeySet.has(col));
|
||
|
||
if (nonPrimaryKeyColumns.length === 0) {
|
||
return { query: `INSERT INTO ${table} DEFAULT VALUES RETURNING *`, params: [] };
|
||
}
|
||
|
||
const quotedColumns = nonPrimaryKeyColumns.map((col) => this._dialect.quote(col));
|
||
const placeholders = nonPrimaryKeyColumns.map((col) => this._addParam(row[col]));
|
||
const query = `INSERT INTO ${table} (${quotedColumns.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`; // prettier-ignore
|
||
return { query, params: [...this._params] };
|
||
}
|
||
|
||
// Rule 3: PK values provided AND columns to update exist.
|
||
// Rule 4: Only PK columns present, nothing to update → touch the row (no-op UPDATE) so INSERT is skipped.
|
||
//
|
||
// Two-CTE pattern (PostgreSQL equivalent of IF EXISTS → UPDATE ELSE INSERT):
|
||
// - "_upsert_update": attempts UPDATE first; RETURNING * surfaces the updated row.
|
||
// - "_upsert_insert": INSERT only when the UPDATE matched nothing (new record); RETURNING * surfaces inserted row.
|
||
// - Final SELECT unions both CTEs so ALL affected rows (updated OR inserted) are returned to the caller.
|
||
// RETURNING * is embedded — the upsert execution path must NOT append an additional RETURNING *.
|
||
const setClauses = updateCols.map((col) => `${this._dialect.quote(col)} = ${this._addParam(row[col])}`);
|
||
const updateWhereClauses = primaryKey.map((pk) => `${this._dialect.quote(pk)} = ${this._addParam(row[pk])}`);
|
||
|
||
const quotedAllCols = allCols.map((col) => this._dialect.quote(col));
|
||
const conflictTarget = primaryKey.map((pk) => this._dialect.quote(pk)).join(', ');
|
||
const insertPlaceholders = allCols.map((col) => this._addParam(row[col]));
|
||
|
||
// Rule 4: no updateCols → no-op UPDATE (SET pk = pk) just to detect existence.
|
||
const noOpSetClauses = primaryKey.map((pk) => `${this._dialect.quote(pk)} = ${this._dialect.quote(pk)}`).join(', ');
|
||
const updateSetClause = updateCols.length === 0 ? noOpSetClauses : setClauses.join(', ');
|
||
|
||
const query = [
|
||
`WITH "_upsert_update" AS (`,
|
||
`UPDATE ${table} SET ${updateSetClause} WHERE ${updateWhereClauses.join(' AND ')} RETURNING *`,
|
||
`), "_upsert_insert" AS (`,
|
||
`INSERT INTO ${table} (${quotedAllCols.join(', ')})`,
|
||
`SELECT ${insertPlaceholders.join(', ')} WHERE NOT EXISTS (SELECT 1 FROM "_upsert_update")`,
|
||
`ON CONFLICT (${conflictTarget}) DO NOTHING RETURNING *`,
|
||
`)`,
|
||
`SELECT * FROM "_upsert_update" UNION ALL SELECT * FROM "_upsert_insert"`,
|
||
].join(' ');
|
||
|
||
return { query, params: [...this._params] };
|
||
}
|
||
|
||
private _mysqlUpsert(
|
||
table: string,
|
||
primaryKey: string[],
|
||
allCols: string[],
|
||
updateCols: string[],
|
||
row: Record<string, unknown>
|
||
): QueryResult {
|
||
const quotedCols = allCols.map((c) => this._dialect.quote(c));
|
||
const placeholders = allCols.map((col) => this._addParam(row[col]));
|
||
|
||
let query = `INSERT INTO ${table} (${quotedCols.join(', ')}) VALUES (${placeholders.join(', ')})`;
|
||
|
||
const setCols = updateCols.length > 0 ? updateCols : [primaryKey[0]];
|
||
const setClauses = setCols.map((col) => `${this._dialect.quote(col)} = VALUES(${this._dialect.quote(col)})`);
|
||
query += ` ON DUPLICATE KEY UPDATE ${setClauses.join(', ')}`;
|
||
|
||
return { query, params: [...this._params] };
|
||
}
|
||
|
||
private _mssqlUpsert(
|
||
table: string,
|
||
primaryKey: string[],
|
||
allCols: string[],
|
||
updateCols: string[],
|
||
row: Record<string, unknown>
|
||
): QueryResult {
|
||
const quotedCols = allCols.map((c) => this._dialect.quote(c));
|
||
const placeholders = allCols.map((col) => this._addParam(row[col]));
|
||
const onClause = primaryKey
|
||
.map((pk) => `[_target].${this._dialect.quote(pk)} = [_source].${this._dialect.quote(pk)}`)
|
||
.join(' AND ');
|
||
|
||
let query = `MERGE INTO ${table} WITH (HOLDLOCK) AS [_target]`;
|
||
query += ` USING (VALUES (${placeholders.join(', ')})) AS [_source] (${quotedCols.join(', ')})`;
|
||
query += ` ON ${onClause}`;
|
||
|
||
if (updateCols.length > 0) {
|
||
const setClauses = updateCols.map(
|
||
(col) => `[_target].${this._dialect.quote(col)} = [_source].${this._dialect.quote(col)}`
|
||
);
|
||
query += ` WHEN MATCHED THEN UPDATE SET ${setClauses.join(', ')}`;
|
||
}
|
||
|
||
const insertValues = allCols.map((c) => `[_source].${this._dialect.quote(c)}`).join(', ');
|
||
query += ` WHEN NOT MATCHED THEN INSERT (${quotedCols.join(', ')}) VALUES (${insertValues});`;
|
||
|
||
return { query, params: [...this._params] };
|
||
}
|
||
|
||
// ── Unified entry point ─────────────────────────────────────────────────────
|
||
|
||
build(operation: string, tableName: string, data: Record<string, unknown>): QueryResult | BulkQueryResult {
|
||
switch (String(operation).toLowerCase()) {
|
||
case 'create_row':
|
||
return this.createRow(tableName, data.schema as string | undefined, data as Record<string, CreateRowEntry>);
|
||
case 'update_rows':
|
||
return this.updateRows(tableName, data as unknown as UpdateRowsInput);
|
||
case 'upsert_rows':
|
||
return this.upsertRows(tableName, data as unknown as UpsertRowsInput);
|
||
case 'delete_rows':
|
||
return this.deleteRows(tableName, data as DeleteRowsInput);
|
||
case 'list_rows':
|
||
return this.listRows(tableName, data as ListRowsInput);
|
||
case 'bulk_insert':
|
||
return this.bulkInsert(tableName, data as unknown as BulkInsertInput);
|
||
case 'bulk_update_with_primary_key':
|
||
return this.bulkUpdateWithPrimaryKey(tableName, data as unknown as BulkUpdateWithPrimaryKeyInput);
|
||
case 'bulk_upsert_with_primary_key':
|
||
return this.bulkUpsertWithPrimaryKey(tableName, data as unknown as BulkUpsertWithPrimaryKeyInput);
|
||
default:
|
||
throw new QueryBuilderError(`Unsupported operation: "${operation}"`, { operation });
|
||
}
|
||
}
|
||
|
||
// ── Validation ──────────────────────────────────────────────────────────────
|
||
|
||
private _assertTableName(tableName: string, operation: string): void {
|
||
if (!tableName || typeof tableName !== 'string' || !tableName.trim()) {
|
||
throw new QueryBuilderError('table_name is required and must be a non-empty string', { operation });
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Factory ──────────────────────────────────────────────────────────────────
|
||
|
||
export function createQueryBuilder(dialect = 'postgresql'): QueryBuilder {
|
||
return new QueryBuilder(dialect);
|
||
}
|