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