UNPKG

iagate-querykit

Version:

QueryKit: lightweight TypeScript query toolkit with models, views, triggers, events, scheduler and adapters (better-sqlite3).

478 lines (477 loc) 17.6 kB
import { QueryKitConfig } from './config'; import { eventManager } from './event-manager'; /** * Array com todas as ações semânticas possíveis. */ const ALL_ACTIONS = ['INSERT', 'UPDATE', 'DELETE', 'READ']; /** * Gerenciador de triggers para o QueryKit. * Suporta triggers SQL nativos e triggers semânticos de aplicação. * Permite execução síncrona e assíncrona com suporte a múltiplos dialetos. * * @example * ```typescript * // Dados iniciais * const triggerManager = new TriggerManager(); * * // Como usar * triggerManager.create('user_audit', { * when: 'AFTER', * action: 'INSERT', * table: 'users', * body: (ctx) => console.log('Usuário inserido:', ctx.data) * }); * * // Output: Trigger semântico criado para auditar inserções de usuários * ``` */ export class TriggerManager { static semantic = new Map(); /** * Gera nome único para evento baseado no timing, ação e tabela. * * @param when - Momento de execução * @param action - Ação que dispara o trigger * @param table - Tabela afetada * @returns Nome único do evento */ static eventNameFor(when, action, table) { return `querykit:trigger:${when}:${action}:${table}`; } /** * Executa o corpo de um trigger semântico. * Suporta strings SQL, funções, arrays e execução paralela. * * @param body - Corpo do trigger a ser executado * @param ctx - Contexto da operação * @returns Promise que resolve quando a execução terminar */ 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; } } /** * Anexa listener para um evento específico de trigger. * * @param when - Momento de execução * @param action - Ação que dispara o trigger * @param table - Tabela afetada * @param body - Corpo do trigger * @returns Função para cancelar o listener */ static attachListenerFor(when, action, table, body) { const handler = async (ctx) => { await TriggerManager.runBody(body, ctx); }; return eventManager.on(TriggerManager.eventNameFor(when, action, table), handler); } /** * Serializa corpo do trigger para SQL quando possível. * Funções não podem ser representadas em SQL, retornando null. * * @param body - Corpo do trigger para serializar * @returns SQL serializado ou null se não for possível */ 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 + ';' : ''; } /** * Extrai todas as strings SQL de um corpo de trigger. * Útil para separar SQL de funções JavaScript. * * @param body - Corpo do trigger para extrair SQL * @returns Array com todas as strings SQL encontradas */ 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; } /** * Extrai partes não-SQL de um corpo de trigger. * Retorna apenas funções JavaScript, excluindo strings SQL. * * @param body - Corpo do trigger para extrair partes não-SQL * @returns Corpo do trigger sem strings SQL ou null se não houver funções */ 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); } /** * Cria um trigger semântico com as opções especificadas. * Remove trigger existente com o mesmo nome antes de criar. * Suporta execução em banco de dados (SQL) e/ou aplicação (JavaScript). * * @param name - Nome único do trigger * @param opts - Opções de configuração do trigger * * @example * ```typescript * // Dados iniciais * const triggerManager = new TriggerManager(); * * // Como usar * triggerManager.create('user_validation', { * when: 'BEFORE', * action: 'INSERT', * table: 'users', * body: (ctx) => { * if (!ctx.data.email.includes('@')) { * throw new Error('Email inválido'); * } * } * }); * * // Output: Trigger 'user_validation' criado para validar emails antes da inserção * ``` */ 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 }); } /** * Remove um trigger semântico pelo nome. * Cancela listeners e remove triggers SQL associados. * * @param name - Nome do trigger a ser removido * * @example * ```typescript * // Dados iniciais * triggerManager.create('temp_trigger', { ... }); * * // Como usar * triggerManager.drop('temp_trigger'); * * // Output: Trigger 'temp_trigger' removido e recursos liberados * ``` */ 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); } } /** * Remove um trigger semântico de forma assíncrona. * Versão assíncrona do método drop(). * * @param name - Nome do trigger a ser removido * @returns Promise que resolve quando o trigger for removido * * @example * ```typescript * // Dados iniciais * await triggerManager.create('async_trigger', { ... }); * * // Como usar * await triggerManager.dropAsync('async_trigger'); * * // Output: Promise resolve quando trigger 'async_trigger' for removido * ``` */ 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); } } /** * Remove todos os triggers semânticos ativos. * Limpa todos os listeners e triggers SQL criados. * * @example * ```typescript * // Dados iniciais * triggerManager.create('trigger1', { ... }); * triggerManager.create('trigger2', { ... *}); * * // Como usar * triggerManager.dropAll(); * * // Output: Todos os triggers removidos e recursos liberados * ``` */ dropAll() { for (const [name] of TriggerManager.semantic) { try { this.drop(name); } catch { } } } /** * Lista nomes de todos os triggers semânticos ativos. * * @returns Array com nomes dos triggers ativos * * @example * ```typescript * // Dados iniciais * triggerManager.create('user_audit', { ... }); * triggerManager.create('product_log', { ... }); * * // Como usar * const triggers = triggerManager.list(); * * // Output: ['user_audit', 'product_log'] * ``` */ 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); } }