UNPKG

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
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; } }