iagate-querykit
Version:
QueryKit: lightweight TypeScript query toolkit with models, views, triggers, events, scheduler and adapters (better-sqlite3).
487 lines (486 loc) • 31.9 kB
JavaScript
import { QueryKitConfig, getExecutorForTable } from './config';
import { raw } from './raw';
import { simulationManager } from './simulation-manager';
import { eventManager } from './event-manager';
export class QueryBuilder {
tableName;
whereClauses = [];
orWhereClauses = [];
joins = [];
selectColumns = ['*'];
orderClauses = [];
limitValue;
offsetValue;
groupByColumns = [];
havingClauses = [];
isDistinct = false;
pendingAction;
aggregates = [];
tableAlias;
unionParts = [];
targetBanks;
isTracking = false;
isSeeding = false;
trackingLogs = [];
virtualTable = [];
includeAllRelations = false;
constructor(tableName) { this.tableName = tableName; }
bank(bankOrBanks) {
this.targetBanks = Array.isArray(bankOrBanks) ? bankOrBanks : [bankOrBanks];
return this;
}
hasPendingWrite() {
return !!this.pendingAction && ['insert', 'update', 'delete', 'updateOrInsert', 'increment', 'decrement'].includes(this.pendingAction.type);
}
track(step, details = {}) {
if (this.isTracking || simulationManager.isActive())
this.trackingLogs.push({ step, details, timestamp: new Date() });
}
async initial(data) {
this.isTracking = true;
this.trackingLogs = [];
if (data) {
this.virtualTable = JSON.parse(JSON.stringify(data));
this.track('tracking.initialized', { source: 'manual', count: data.length });
}
else {
this.track('tracking.seeding_from_db', { query: this.toSql() });
this.isSeeding = true;
try {
const results = await this.all();
this.virtualTable = results;
this.track('tracking.initialized', { source: 'database', table: this.tableName, count: results.length });
}
finally {
this.isSeeding = false;
}
}
return this;
}
tracking() {
if (!this.isTracking)
return [{ step: 'error', details: 'Tracking was not enabled. Call .initial() before .tracking().', timestamp: new Date() }];
if (this.pendingAction) {
this.track('virtual_execution.start', this.pendingAction);
this.executeVirtualAction();
this.track('virtual_execution.end', { finalVirtualTableState: this.virtualTable });
this.pendingAction = undefined;
}
else {
this.track('dry_run_select.summary', this.toSql());
}
return this.trackingLogs;
}
applyWhereClausesToVirtual(data) {
if (this.whereClauses.length === 0)
return data;
return data.filter(row => this.whereClauses.every(clause => {
if (clause.type === 'basic' && clause.operator === '=')
return row[clause.column] === clause.value;
return true;
}));
}
executeVirtualAction() {
if (!this.pendingAction)
return;
const { type, data } = this.pendingAction;
switch (type) {
case 'insert':
this.virtualTable.push(...data);
break;
case 'update': {
const rowsToUpdate = this.applyWhereClausesToVirtual(this.virtualTable);
rowsToUpdate.forEach(row => Object.assign(row, data));
break;
}
case 'delete': {
const rowsToDelete = this.applyWhereClausesToVirtual(this.virtualTable);
const idsToDelete = new Set(rowsToDelete.map(r => r.id));
this.virtualTable = this.virtualTable.filter(row => !idsToDelete.has(row.id));
break;
}
}
if (simulationManager.isActive())
simulationManager.updateStateFor(this.tableName, this.virtualTable);
}
select(columns = ['*']) { this.track('select', { columns }); this.selectColumns = columns.map(c => String(c)); return this; }
selectRaw(sql) { this.track('selectRaw', { sql }); this.selectColumns.push(raw(sql)); return this; }
aggregatesSelect(columns) { this.track('aggregatesSelect', { columns }); columns.forEach(c => this.selectColumns.push(c)); return this; }
distinct() { this.track('distinct'); this.isDistinct = true; return this; }
where(column, operator, value) { this.track('where', { column, operator, value }); this.whereClauses.push({ type: 'basic', column, operator, value, logical: 'AND' }); return this; }
orWhere(column, operator, value) { this.track('orWhere', { column, operator, value }); this.orWhereClauses.push({ type: 'basic', column, operator, value, logical: 'OR' }); return this; }
whereIf(condition, column, operator, value) { if (condition !== null && condition !== undefined && condition !== '')
this.where(column, operator, value); return this; }
whereAll(conditions) { for (const key in conditions) {
const value = conditions[key];
this.whereIf(value, key, '=', value);
} return this; }
insert(data) { this.track('insert', { data }); const dataAsArray = Array.isArray(data) ? data : [data]; this.pendingAction = { type: 'insert', data: dataAsArray }; return this; }
update(data) { this.track('update', { data }); this.pendingAction = { type: 'update', data }; return this; }
delete() { this.track('delete'); this.pendingAction = { type: 'delete' }; return this; }
updateOrInsert(attributes, values = {}) { this.pendingAction = { type: 'updateOrInsert', data: { attributes, values } }; return this; }
increment(column, amount = 1) { this.pendingAction = { type: 'increment', data: { column, amount } }; return this; }
decrement(column, amount = 1) { this.pendingAction = { type: 'decrement', data: { column, amount } }; return this; }
whereIn(column, values, logical = 'AND') { this.whereClauses.push({ type: 'in', column, value: values, logical, not: false }); return this; }
orWhereIn(column, values) { return this.whereIn(column, values, 'OR'); }
whereNotIn(column, values) { this.whereClauses.push({ type: 'in', column, value: values, logical: 'AND', not: true }); return this; }
orWhereNotIn(column, values) { this.orWhereClauses.push({ type: 'in', column, value: values, logical: 'OR', not: true }); return this; }
whereNull(column) { this.whereClauses.push({ type: 'null', column, logical: 'AND', not: false, value: undefined }); return this; }
orWhereNull(column) { this.orWhereClauses.push({ type: 'null', column, logical: 'OR', not: false, value: undefined }); return this; }
whereNotNull(column) { this.whereClauses.push({ type: 'null', column, logical: 'AND', not: true, value: undefined }); return this; }
orWhereNotNull(column) { this.orWhereClauses.push({ type: 'null', column, logical: 'OR', not: true, value: undefined }); return this; }
whereBetween(column, values) { this.whereClauses.push({ type: 'between', column, value: values, logical: 'AND', not: false }); return this; }
whereNotBetween(column, values) { this.whereClauses.push({ type: 'between', column, value: values, logical: 'AND', not: true }); return this; }
whereColumn(firstColumn, operator, secondColumn, logical = 'AND') { this.whereClauses.push({ type: 'column', column: firstColumn, operator, value: secondColumn, logical }); return this; }
whereRaw(sql, bindings = [], logical = 'AND') { this.havingClauses.push({ type: 'raw', sql, logical }); return this; }
whereRawSearch(searchTerm, columns) { if (!searchTerm)
return this; const searchConditions = columns.map(col => `${String(col)} LIKE ?`).join(' OR '); const bindings = columns.map(() => `%${searchTerm}%`); return this.whereRaw(`(${searchConditions})`, bindings); }
whereExists(query) { this.whereClauses.push({ type: 'exists', query, logical: 'AND', not: false, value: undefined }); return this; }
whereNotExists(query) { this.whereClauses.push({ type: 'exists', query, logical: 'AND', not: true, value: undefined }); return this; }
when(condition, callback) { if (condition)
callback(this, condition); return this; }
unless(condition, callback) { if (!condition)
callback(this, condition); return this; }
clone() { const newQuery = new this.constructor(this.tableName); Object.assign(newQuery, { ...this, selectColumns: [...this.selectColumns], whereClauses: [...this.whereClauses], orWhereClauses: [...this.orWhereClauses], joins: [...this.joins], orderClauses: [...this.orderClauses], groupByColumns: [...this.groupByColumns], havingClauses: [...this.havingClauses], aggregates: [...this.aggregates], }); return newQuery; }
orderBy(column, direction = 'ASC') { this.orderClauses.push({ column: String(column), direction }); return this; }
orderByMany(orders) { orders.forEach(o => this.orderBy(o.column, o.direction || 'ASC')); return this; }
limit(count) { this.limitValue = count; return this; }
offset(count) { this.offsetValue = count; return this; }
innerJoin(targetTable, on) { this.joins.push({ type: 'INNER', table: targetTable, on }); return this; }
leftJoin(targetTable, on) { this.joins.push({ type: 'LEFT', table: targetTable, on }); return this; }
rightJoin(targetTable, on) { this.joins.push({ type: 'RIGHT', table: targetTable, on }); return this; }
innerJoinOn(targetTable, left, right) { return this.innerJoin(targetTable, `${left} = ${right}`); }
leftJoinOn(targetTable, left, right) { return this.leftJoin(targetTable, `${left} = ${right}`); }
rightJoinOn(targetTable, left, right) { return this.rightJoin(targetTable, `${left} = ${right}`); }
groupBy(columns) { this.groupByColumns = columns.map(c => String(c)); return this; }
having(column, op, value) { this.havingClauses.push({ type: 'basic', column, operator: op, value, logical: 'AND' }); return this; }
havingRaw(sql, bindings = [], logical = 'AND') { this.havingClauses.push({ type: 'raw', sql, logical }); return this; }
havingIf(condition, column, op, value) { if (condition !== null && condition !== undefined && condition !== '')
return this.having(column, op, value); return this; }
toSql() {
if (this.aggregates.length > 0) {
const agg = this.aggregates[0];
this.selectColumns = [raw(`${agg.func}(${agg.column}) as ${agg.alias || 'aggregate'}`)];
}
let baseSelect = `SELECT ${this.isDistinct ? 'DISTINCT' : ''} ${this.selectColumns.map(c => (c && typeof c === 'object' && 'toSQL' in c) ? c.toSQL() : String(c)).join(', ')} FROM ${this.tableName}${this.tableAlias ? ' ' + this.tableAlias : ''}`;
const params = [];
if (this.joins.length > 0)
baseSelect += ' ' + this.joins.map(j => `${j.type} JOIN ${j.table} ON ${j.on}`).join(' ');
const whereClause = this.buildWhereClause(this.whereClauses, params, 'AND');
if (whereClause)
baseSelect += ` WHERE ${whereClause}`;
if (this.groupByColumns.length > 0)
baseSelect += ` GROUP BY ${this.groupByColumns.join(', ')}`;
if (this.havingClauses.length > 0) {
const havingClause = this.buildWhereClause(this.havingClauses, params, 'AND');
if (havingClause)
baseSelect += ` HAVING ${havingClause}`;
}
if (this.unionParts.length === 0) {
if (this.orderClauses.length > 0)
baseSelect += ` ORDER BY ${this.orderClauses.map(o => `${o.column} ${o.direction}`).join(', ')}`;
if (typeof this.limitValue === 'number') {
baseSelect += ` LIMIT ?`;
params.push(this.limitValue);
}
if (typeof this.offsetValue === 'number') {
baseSelect += ` OFFSET ?`;
params.push(this.offsetValue);
}
}
if (this.unionParts.length > 0) {
let sql = `${baseSelect}`;
for (const part of this.unionParts) {
const { sql: subSql, bindings: subBindings } = part.query.toSql();
sql += ` ${part.type} ${subSql}`;
params.push(...subBindings);
}
if (this.orderClauses.length > 0)
sql += ` ORDER BY ${this.orderClauses.map(o => `${o.column} ${o.direction}`).join(', ')}`;
if (typeof this.limitValue === 'number') {
sql += ` LIMIT ?`;
params.push(this.limitValue);
}
if (typeof this.offsetValue === 'number') {
sql += ` OFFSET ?`;
params.push(this.offsetValue);
}
return { sql, bindings: params };
}
return { sql: baseSelect, bindings: params };
}
buildWhereClause(clauses, params, def) {
if (clauses.length === 0)
return '';
return clauses.map((clause, index) => {
let conditionStr;
switch (clause.type) {
case 'basic':
params.push(clause.value);
conditionStr = `${String(clause.column)} ${clause.operator} ?`;
break;
case 'column':
conditionStr = `${String(clause.column)} ${clause.operator} ${String(clause.value)}`;
break;
case 'raw':
conditionStr = clause.sql;
break;
case 'in':
if (!Array.isArray(clause.value) || clause.value.length === 0) {
conditionStr = clause.not ? '1=1' : '1=0';
}
else {
params.push(...clause.value);
const placeholders = clause.value.map(() => '?').join(',');
conditionStr = `${String(clause.column)} ${clause.not ? 'NOT IN' : 'IN'} (${placeholders})`;
}
break;
case 'null':
conditionStr = `${String(clause.column)} IS ${clause.not ? 'NOT ' : ''}NULL`;
break;
case 'between':
params.push(...clause.value);
conditionStr = `${String(clause.column)} ${clause.not ? 'NOT BETWEEN' : 'BETWEEN'} ? AND ?`;
break;
case 'exists':
const { sql, bindings } = clause.query.toSql();
params.push(...bindings);
conditionStr = `${clause.not ? 'NOT ' : ''}EXISTS (${sql})`;
break;
default: throw new Error('Unsupported where clause type');
}
const logical = index > 0 ? clause.logical || def : '';
return `${logical} ${conditionStr}`;
}).join(' ').trim();
}
get() { this.limit(1); const rows = this.allSync(); return rows[0]; }
first() { return this.get(); }
find(id) { return this.where('id', '=', id).get(); }
async exists() { const q = this.selectRaw('1').limit(1); const row = await q.all(); return !!(row && row.length); }
async pluck(column) { const results = await this.select([column]).all(); return results.map(r => r[column]); }
async all() {
this.track('all');
if (simulationManager.isActive()) {
const virtualData = simulationManager.getStateFor(this.tableName);
if (virtualData) {
this.virtualTable = JSON.parse(JSON.stringify(virtualData));
let results = this.applyWhereClausesToVirtual(this.virtualTable);
const offset = this.offsetValue || 0;
const limit = this.limitValue === undefined ? results.length : this.limitValue;
results = results.slice(offset, offset + limit);
if (!this.includeAllRelations)
return results;
const { attachRelations } = await import('./relations-resolver').catch(() => ({ attachRelations: async (x) => x }));
const selector = typeof this.includeAllRelations === 'function' ? this.includeAllRelations : undefined;
return (await attachRelations(this.tableName, results, selector));
}
return [];
}
const exec = getExecutorForTable(this.tableName, this.targetBanks);
if (!exec)
throw new Error('No executor configured for QueryKit');
const { sql, bindings } = this.toSql();
const qbHelper = (t) => new QueryBuilder(t || this.tableName);
eventManager.emit(`querykit:trigger:BEFORE:READ:${this.tableName}`, { table: this.tableName, action: 'READ', timing: 'BEFORE', where: undefined, qb: qbHelper });
const res = await exec.executeQuery(sql, bindings);
let rows = res.data;
eventManager.emit(`querykit:trigger:AFTER:READ:${this.tableName}`, { table: this.tableName, action: 'READ', timing: 'AFTER', rows, qb: qbHelper });
if (!this.includeAllRelations)
return rows;
const { attachRelations } = await import('./relations-resolver').catch(() => ({ attachRelations: async (x) => x }));
const selector = typeof this.includeAllRelations === 'function' ? this.includeAllRelations : undefined;
return (await attachRelations(this.tableName, rows, selector));
}
run() { const exec = QueryKitConfig.defaultExecutor; if (!exec || !exec.runSync)
throw new Error('No executor configured for QueryKit'); const { sql, bindings } = this.toSql(); return exec.runSync(sql, bindings); }
allSync() {
const exec = getExecutorForTable(this.tableName, this.targetBanks);
if (!exec || !exec.executeQuerySync)
throw new Error('No executor configured for QueryKit');
const { sql, bindings } = this.toSql();
const qbHelper = (t) => new QueryBuilder(t || this.tableName);
eventManager.emit(`querykit:trigger:BEFORE:READ:${this.tableName}`, { table: this.tableName, action: 'READ', timing: 'BEFORE', where: undefined, qb: qbHelper });
const out = exec.executeQuerySync(sql, bindings).data;
eventManager.emit(`querykit:trigger:AFTER:READ:${this.tableName}`, { table: this.tableName, action: 'READ', timing: 'AFTER', rows: out, qb: qbHelper });
return out;
}
getSync() { this.limit(1); return this.allSync()[0]; }
firstSync() { this.limit(1); return this.getSync(); }
pluckSync(column) { const rows = this.select([String(column)]).allSync(); return rows.map(r => r[String(column)]); }
scalarSync(alias) { const row = this.getSync(); if (!row)
return undefined; if (alias && row[alias] !== undefined)
return row[alias]; const k = Object.keys(row)[0]; return row[k]; }
count(column = '*', alias) { return this.addAggregate('count', column, alias); }
sum(column, alias) { return this.addAggregate('sum', column, alias); }
avg(column, alias) { return this.addAggregate('avg', column, alias); }
min(column, alias) { return this.addAggregate('min', column, alias); }
max(column, alias) { return this.addAggregate('max', column, alias); }
addAggregate(func, column, alias) { this.aggregates.push({ func, column, alias: alias || `${func}_${column}` }); return this; }
selectExpression(expression, alias) { const expr = alias ? `${expression} AS ${alias}` : expression; this.selectColumns.push(raw(expr)); return this; }
selectCount(column = '*', alias = 'count') { return this.selectExpression(`COUNT(${column})`, alias); }
selectSum(column, alias = 'sum') { return this.selectExpression(`SUM(${column})`, alias); }
selectAvg(column, alias = 'avg') { return this.selectExpression(`AVG(${column})`, alias); }
selectMin(column, alias = 'min') { return this.selectExpression(`MIN(${column})`, alias); }
selectMax(column, alias = 'max') { return this.selectExpression(`MAX(${column})`, alias); }
selectCaseSum(conditionSql, alias) { return this.selectExpression(`SUM(CASE WHEN ${conditionSql} THEN 1 ELSE 0 END)`, alias); }
groupByOne(column) { return this.groupBy([String(column)]); }
paginate(page = 1, perPage = 25) { const safePage = Math.max(1, page || 1); const safePerPage = Math.max(1, perPage || 25); this.limit(safePerPage); this.offset((safePage - 1) * safePerPage); return this; }
range(field, start, end) { if (start)
this.whereRaw(`${String(field)} >= ?`, [start.toISOString()]); if (end)
this.whereRaw(`${String(field)} <= ?`, [end.toISOString()]); return this; }
period(field, periodKey) { if (!periodKey)
return this; const now = new Date(); let startDate; switch (periodKey) {
case '24h':
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
break;
case '7d':
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case '30d':
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
default: startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
} return this.whereRaw(`${String(field)} >= ?`, [startDate.toISOString()]); }
whereLike(column, pattern) { return this.where(column, 'LIKE', pattern); }
orWhereLike(column, pattern) { return this.orWhere(column, 'LIKE', pattern); }
whereContains(column, term) { return this.whereLike(column, `%${term}%`); }
whereStartsWith(column, prefix) { return this.whereLike(column, `${prefix}%`); }
whereEndsWith(column, suffix) { return this.whereLike(column, `%${suffix}`); }
whereILike(column, pattern) { return this.whereRaw(`${String(column)} LIKE ? COLLATE NOCASE`, [pattern]); }
whereContainsCI(column, term) { return this.whereILike(column, `%${term}%`); }
whereStartsWithCI(column, prefix) { return this.whereILike(column, `${prefix}%`); }
whereEndsWithCI(column, suffix) { return this.whereILike(column, `%${suffix}`); }
whereSearch(searchTerm, columns) { return this.whereRawSearch(searchTerm, columns); }
union(query) { this.unionParts.push({ type: 'UNION', query }); return this; }
unionAll(query) { this.unionParts.push({ type: 'UNION ALL', query }); return this; }
async make() {
const exec = getExecutorForTable(this.tableName, this.targetBanks);
if (!exec)
throw new Error('No executor configured for QueryKit');
if (!this.pendingAction)
throw new Error('No pending write action to execute. Call insert(), update(), or delete() before .make()');
const { type, data } = this.pendingAction;
const mapAsyncResult = (raw) => {
if (Array.isArray(raw)) {
const info = raw[1] || {};
const changes = info.affectedRows ?? info.changes ?? 0;
const lastId = info.insertId ?? info.lastInsertId ?? info.lastInsertRowid ?? 0;
return { changes, lastInsertRowid: lastId };
}
const changes = raw?.affectedRows ?? raw?.changes ?? 0;
const lastId = raw?.lastInsertId ?? raw?.lastInsertRowid ?? 0;
return { changes, lastInsertRowid: lastId };
};
const qbHelper = (t) => new QueryBuilder(t || this.tableName);
switch (type) {
case 'insert': {
const obj = Array.isArray(data) ? data[0] : data;
const columns = Object.keys(obj);
const values = Object.values(obj);
const placeholders = columns.map(() => '?').join(', ');
const sql = `INSERT INTO ${this.tableName} (${columns.join(', ')}) VALUES (${placeholders})`;
eventManager.emit(`querykit:trigger:BEFORE:INSERT:${this.tableName}`, { table: this.tableName, action: 'INSERT', timing: 'BEFORE', data: obj, where: undefined, qb: qbHelper });
const res = exec.runSync ? exec.runSync(sql, values) : await exec.executeQuery(sql, values);
const mapped = exec.runSync ? res : mapAsyncResult(res);
eventManager.emit(`querykit:trigger:AFTER:INSERT:${this.tableName}`, { table: this.tableName, action: 'INSERT', timing: 'AFTER', data: obj, result: mapped, qb: qbHelper });
this.pendingAction = undefined;
return mapped;
}
case 'update': {
if (this.whereClauses.length === 0)
throw new Error('Update operations must have a WHERE clause.');
const setClauses = Object.keys(data).map(k => `${k} = ?`).join(', ');
const params = Object.values(data);
const where = this.buildWhereClause(this.whereClauses, params, 'AND');
const sql = `UPDATE ${this.tableName} SET ${setClauses} WHERE ${where}`;
eventManager.emit(`querykit:trigger:BEFORE:UPDATE:${this.tableName}`, { table: this.tableName, action: 'UPDATE', timing: 'BEFORE', data, where: { sql: where, bindings: params }, qb: qbHelper });
const res = exec.runSync ? exec.runSync(sql, params) : await exec.executeQuery(sql, params);
const mapped = exec.runSync ? res : mapAsyncResult(res);
eventManager.emit(`querykit:trigger:AFTER:UPDATE:${this.tableName}`, { table: this.tableName, action: 'UPDATE', timing: 'AFTER', data, where: { sql: where, bindings: params }, result: mapped, qb: qbHelper });
this.pendingAction = undefined;
return mapped;
}
case 'delete': {
if (this.whereClauses.length === 0)
throw new Error('Delete operations must have a WHERE clause.');
const params = [];
const where = this.buildWhereClause(this.whereClauses, params, 'AND');
const sql = `DELETE FROM ${this.tableName} WHERE ${where}`;
eventManager.emit(`querykit:trigger:BEFORE:DELETE:${this.tableName}`, { table: this.tableName, action: 'DELETE', timing: 'BEFORE', where: { sql: where, bindings: params }, qb: qbHelper });
const res = exec.runSync ? exec.runSync(sql, params) : await exec.executeQuery(sql, params);
const mapped = exec.runSync ? res : mapAsyncResult(res);
eventManager.emit(`querykit:trigger:AFTER:DELETE:${this.tableName}`, { table: this.tableName, action: 'DELETE', timing: 'AFTER', where: { sql: where, bindings: params }, result: mapped, qb: qbHelper });
this.pendingAction = undefined;
return mapped;
}
case 'increment': {
if (this.whereClauses.length === 0)
throw new Error('Update operations must have a WHERE clause.');
const { column, amount } = data;
const params = [amount ?? 1];
const where = this.buildWhereClause(this.whereClauses, params, 'AND');
const sql = `UPDATE ${this.tableName} SET ${column} = ${column} + ? WHERE ${where}`;
eventManager.emit(`querykit:trigger:BEFORE:UPDATE:${this.tableName}`, { table: this.tableName, action: 'UPDATE', timing: 'BEFORE', data: { column, amount }, where: { sql: where, bindings: params }, qb: qbHelper });
const res = exec.runSync ? exec.runSync(sql, params) : await exec.executeQuery(sql, params);
const mapped = exec.runSync ? res : mapAsyncResult(res);
eventManager.emit(`querykit:trigger:AFTER:UPDATE:${this.tableName}`, { table: this.tableName, action: 'UPDATE', timing: 'AFTER', data: { column, amount }, where: { sql: where, bindings: params }, result: mapped, qb: qbHelper });
this.pendingAction = undefined;
return mapped;
}
case 'decrement': {
if (this.whereClauses.length === 0)
throw new Error('Update operations must have a WHERE clause.');
const { column, amount } = data;
const params = [amount ?? 1];
const where = this.buildWhereClause(this.whereClauses, params, 'AND');
const sql = `UPDATE ${this.tableName} SET ${column} = ${column} - ? WHERE ${where}`;
eventManager.emit(`querykit:trigger:BEFORE:UPDATE:${this.tableName}`, { table: this.tableName, action: 'UPDATE', timing: 'BEFORE', data: { column, amount }, where: { sql: where, bindings: params }, qb: qbHelper });
const res = exec.runSync ? exec.runSync(sql, params) : await exec.executeQuery(sql, params);
const mapped = exec.runSync ? res : mapAsyncResult(res);
eventManager.emit(`querykit:trigger:AFTER:UPDATE:${this.tableName}`, { table: this.tableName, action: 'UPDATE', timing: 'AFTER', data: { column, amount }, where: { sql: where, bindings: params }, result: mapped, qb: qbHelper });
this.pendingAction = undefined;
return mapped;
}
case 'updateOrInsert': {
const { attributes, values } = data;
// Attempt update
const setClauses = Object.keys(values).map(k => `${k} = ?`).join(', ');
const params = Object.values(values);
// Build where from attributes, ensuring params appended for where after values
const whereClausesBackup = [...this.whereClauses];
this.whereClauses = [];
Object.entries(attributes).forEach(([k, v]) => this.where(k, '=', v));
const where = this.buildWhereClause(this.whereClauses, params, 'AND');
const sqlUpd = `UPDATE ${this.tableName} SET ${setClauses} WHERE ${where}`;
eventManager.emit(`querykit:trigger:BEFORE:UPDATE:${this.tableName}`, { table: this.tableName, action: 'UPDATE', timing: 'BEFORE', data: values, where: { sql: where, bindings: params }, qb: qbHelper });
const resUpd = exec.runSync ? exec.runSync(sqlUpd, params) : await exec.executeQuery(sqlUpd, params);
const mappedUpd = exec.runSync ? resUpd : mapAsyncResult(resUpd);
eventManager.emit(`querykit:trigger:AFTER:UPDATE:${this.tableName}`, { table: this.tableName, action: 'UPDATE', timing: 'AFTER', data: values, where: { sql: where, bindings: params }, result: mappedUpd, qb: qbHelper });
let result = mappedUpd;
if (!mappedUpd.changes) {
// Perform insert with merged attributes+values
const insertObj = { ...attributes, ...values };
const columns = Object.keys(insertObj);
const vals = Object.values(insertObj);
const placeholders = columns.map(() => '?').join(', ');
const sqlIns = `INSERT INTO ${this.tableName} (${columns.join(', ')}) VALUES (${placeholders})`;
eventManager.emit(`querykit:trigger:BEFORE:INSERT:${this.tableName}`, { table: this.tableName, action: 'INSERT', timing: 'BEFORE', data: insertObj, where: undefined, qb: qbHelper });
const resIns = exec.runSync ? exec.runSync(sqlIns, vals) : await exec.executeQuery(sqlIns, vals);
const mappedIns = exec.runSync ? resIns : mapAsyncResult(resIns);
eventManager.emit(`querykit:trigger:AFTER:INSERT:${this.tableName}`, { table: this.tableName, action: 'INSERT', timing: 'AFTER', data: insertObj, result: mappedIns, qb: qbHelper });
result = mappedIns;
}
// restore whereClauses
this.whereClauses = whereClausesBackup;
this.pendingAction = undefined;
return result;
}
default:
throw new Error(`Unsupported pending action: ${type}`);
}
}
relationship(selector) {
this.includeAllRelations = selector || true;
return this;
}
}