iagate-querykit
Version:
QueryKit: lightweight TypeScript query toolkit with models, views, triggers, events, scheduler and adapters (better-sqlite3).
312 lines (311 loc) • 12.5 kB
JavaScript
import { QueryKitConfig } from './config';
import { eventManager } from './event-manager';
const ALL_ACTIONS = ['INSERT', 'UPDATE', 'DELETE', 'READ'];
export class TriggerManager {
static semantic = new Map();
static eventNameFor(when, action, table) {
return `querykit:trigger:${when}:${action}:${table}`;
}
static async runBody(body, ctx) {
if (Array.isArray(body)) {
for (const b of body) {
await TriggerManager.runBody(b, ctx);
}
return;
}
if (typeof body === 'object' && body && 'parallel' in body && Array.isArray(body.parallel)) {
const p = body.parallel;
await Promise.all(p.map((b) => TriggerManager.runBody(b, ctx)));
return;
}
if (typeof body === 'string') {
const exec = QueryKitConfig.defaultExecutor;
if (!exec || !exec.runSync)
throw new Error('No executor configured for QueryKit');
exec.runSync(body, []);
return;
}
if (typeof body === 'function') {
const out = await Promise.resolve(body(ctx));
if (out !== undefined && out !== null) {
await TriggerManager.runBody(out, ctx);
}
return;
}
}
static attachListenerFor(when, action, table, body) {
const handler = async (ctx) => {
await TriggerManager.runBody(body, ctx);
};
return eventManager.on(TriggerManager.eventNameFor(when, action, table), handler);
}
static serializeBodyToSql(body) {
const flatten = (b) => {
if (Array.isArray(b))
return b.flatMap(x => flatten(x));
if (typeof b === 'object' && b && 'parallel' in b && Array.isArray(b.parallel)) {
return b.parallel.flatMap(x => flatten(x));
}
if (typeof b === 'string')
return [b];
// functions cannot be represented in SQL trigger bodies
return [null];
};
const parts = flatten(body);
if (parts.every(p => p === null))
return null;
const sqls = parts.filter((p) => typeof p === 'string');
if (sqls.length === 0)
return null;
const joined = sqls.map(s => s.trim().replace(/;\s*$/, '')).join('; ');
return joined.length ? joined + ';' : '';
}
static extractSqlStrings(body) {
const out = [];
const walk = (b) => {
if (Array.isArray(b)) {
b.forEach(walk);
return;
}
if (typeof b === 'object' && b && 'parallel' in b && Array.isArray(b.parallel)) {
b.parallel.forEach(walk);
return;
}
if (typeof b === 'string')
out.push(b);
};
walk(body);
return out;
}
static extractNonSql(body) {
const toNonSql = (b) => {
if (Array.isArray(b)) {
const sequential = [];
for (const item of b) {
const cleaned = toNonSql(item);
if (!cleaned)
continue;
if (typeof cleaned === 'function') {
sequential.push(cleaned);
}
else if (typeof cleaned === 'object' && 'parallel' in cleaned) {
// flatten parallel functions into the sequence to preserve type correctness
const par = cleaned.parallel.filter((x) => typeof x === 'function');
sequential.push(...par);
}
// strings are excluded (SQL handled by DB trigger)
}
return sequential.length ? sequential : null;
}
if (typeof b === 'object' && b && 'parallel' in b && Array.isArray(b.parallel)) {
const onlyFns = b.parallel.filter((x) => typeof x === 'function');
return onlyFns.length ? { parallel: onlyFns } : null;
}
if (typeof b === 'string')
return null;
if (typeof b === 'function')
return b;
return null;
};
return toNonSql(body);
}
create(name, opts) {
this.drop(name);
const offs = [];
const createdSql = [];
const when = opts.when;
const actionsArr = Array.isArray(opts.action) ? opts.action : [opts.action];
const exceptArr = Array.isArray(opts.except) ? opts.except : [];
const tablesArr = Array.isArray(opts.table) ? opts.table : [opts.table];
const expandedActions = actionsArr.flatMap(a => a === '*' ? ALL_ACTIONS : [a]);
const excluded = new Set(exceptArr.flatMap(a => a === '*' ? ALL_ACTIONS : [a]));
const finalActions = expandedActions.filter(a => !excluded.has(a));
const targetState = opts.state || 'bank';
const sqlParts = TriggerManager.extractSqlStrings(opts.body);
const sqlBody = sqlParts.length ? sqlParts.map(s => s.trim().replace(/;\s*$/, '')).join('; ') + ';' : null;
const nonSql = TriggerManager.extractNonSql(opts.body);
for (const action of finalActions) {
for (const table of tablesArr) {
const canUseSql = targetState === 'bank' && sqlBody && action !== 'READ';
if (canUseSql) {
const sqlTriggerName = `${name}__${when}__${action}__${table}`;
this.createTrigger(sqlTriggerName, table, when, action, sqlBody);
createdSql.push(sqlTriggerName);
}
const bodyForSemantic = targetState === 'state' ? opts.body : nonSql;
if (bodyForSemantic) {
const off = TriggerManager.attachListenerFor(when, action, table, bodyForSemantic);
offs.push(off);
}
if (!canUseSql && !bodyForSemantic) {
// nothing to attach; fallback to original body in memory
const off = TriggerManager.attachListenerFor(when, action, table, opts.body);
offs.push(off);
}
}
}
TriggerManager.semantic.set(name, { opts, offs, sqlNames: createdSql });
}
drop(name) {
const entry = TriggerManager.semantic.get(name);
if (entry) {
for (const off of entry.offs) {
try {
off();
}
catch { }
}
for (const sqlName of entry.sqlNames || []) {
try {
this.dropTrigger(sqlName);
}
catch { }
}
TriggerManager.semantic.delete(name);
}
}
async dropAsync(name) {
const entry = TriggerManager.semantic.get(name);
if (entry) {
for (const off of entry.offs) {
try {
off();
}
catch { }
}
for (const sqlName of entry.sqlNames || []) {
try {
await Promise.resolve(this.dropTrigger(sqlName));
}
catch { }
}
TriggerManager.semantic.delete(name);
}
}
dropAll() {
for (const [name] of TriggerManager.semantic) {
try {
this.drop(name);
}
catch { }
}
}
list() {
return Array.from(TriggerManager.semantic.keys());
}
listDetailed() {
const bank_triggers = (() => { try {
return this.listTriggers();
}
catch {
return [];
} })();
const state_triggers = Array.from(TriggerManager.semantic.entries())
.filter(([_, v]) => (v.offs && v.offs.length > 0))
.map(([k]) => k);
return { bank_triggers, state_triggers };
}
async listDetailedAsync() {
const bank_triggers = await (async () => { try {
return await this.listTriggersAsync();
}
catch {
return [];
} })();
const state_triggers = Array.from(TriggerManager.semantic.entries())
.filter(([_, v]) => (v.offs && v.offs.length > 0))
.map(([k]) => k);
return { bank_triggers, state_triggers };
}
// SQL-level triggers (multi-vendor)
createTrigger(name, tableName, timing, event, body) {
const createTriggerSql = `
CREATE TRIGGER IF NOT EXISTS ${name}
${timing} ${event} ON ${tableName}
FOR EACH ROW
BEGIN
${body}
END;
`;
const exec = QueryKitConfig.defaultExecutor;
if (!exec)
throw new Error('No executor configured for QueryKit');
if (exec.runSync)
exec.runSync(createTriggerSql, []);
else
exec.executeQuery(createTriggerSql, []);
}
dropTrigger(name) {
const dropTriggerSql = `DROP TRIGGER IF EXISTS ${name}`;
const exec = QueryKitConfig.defaultExecutor;
if (!exec)
throw new Error('No executor configured for QueryKit');
if (exec.runSync)
exec.runSync(dropTriggerSql, []);
else
exec.executeQuery(dropTriggerSql, []);
}
triggerQueriesByDialect(dialect) {
switch (dialect) {
case 'sqlite': return [{ sql: "SELECT name FROM sqlite_master WHERE type='trigger'", map: (r) => r.name }];
case 'mysql': return [{ sql: "SELECT TRIGGER_NAME AS name FROM INFORMATION_SCHEMA.TRIGGERS", map: (r) => r.name }];
case 'postgres': return [{ sql: "SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal", map: (r) => r.tgname || r.name }];
case 'mssql': return [{ sql: "SELECT name FROM sys.triggers", map: (r) => r.name }];
case 'oracle': return [{ sql: "SELECT TRIGGER_NAME AS name FROM USER_TRIGGERS", map: (r) => r.name }];
default:
return [
{ sql: "SELECT name FROM sqlite_master WHERE type='trigger'", map: (r) => r.name },
{ sql: "SELECT TRIGGER_NAME AS name FROM INFORMATION_SCHEMA.TRIGGERS", map: (r) => r.name },
{ sql: "SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal", map: (r) => r.tgname || r.name },
{ sql: "SELECT name FROM sys.triggers", map: (r) => r.name },
{ sql: "SELECT TRIGGER_NAME AS name FROM USER_TRIGGERS", map: (r) => r.name },
];
}
}
listTriggers() {
const exec = QueryKitConfig.defaultExecutor;
if (!exec)
throw new Error('No executor configured for QueryKit');
if (!exec.executeQuerySync)
return [];
const candidates = this.triggerQueriesByDialect(exec.dialect || QueryKitConfig.defaultDialect);
for (const c of candidates) {
try {
const res = exec.executeQuerySync(c.sql, []);
const rows = res?.data || [];
const names = rows.map(c.map).filter(Boolean);
if (names.length || rows.length >= 0)
return names;
}
catch { /* try next */ }
}
return [];
}
triggerExists(name) {
const names = this.listTriggers();
return names.includes(name);
}
async listTriggersAsync() {
const exec = QueryKitConfig.defaultExecutor;
if (!exec)
throw new Error('No executor configured for QueryKit');
if (exec.executeQuerySync)
return this.listTriggers();
const candidates = this.triggerQueriesByDialect(exec.dialect || QueryKitConfig.defaultDialect);
for (const c of candidates) {
try {
const res = await exec.executeQuery(c.sql, []);
const rows = res?.data || [];
const names = rows.map(c.map).filter(Boolean);
if (names.length || rows.length >= 0)
return names;
}
catch { /* try next */ }
}
return [];
}
async triggerExistsAsync(name) {
const names = await this.listTriggersAsync();
return names.includes(name);
}
}