You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

351 lines
10 KiB

/**
* Motor de evaluación y compilación de reglas JsonLogic.
*
* Permite ejecutar reglas escritas con sintaxis JsonLogic y también
* compilarlas a funciones JavaScript nativas para evaluaciones repetidas
* con mejor rendimiento.
*
* ## Características
*
* - Evaluación compatible con la sintaxis JsonLogic
* - Normalización automática de argumentos (los operadores reciben arrays)
* - Acceso a variables anidadas usando separador `/`
* - Operadores lógicos y de comparación incluidos
* - Operadores básicos de fechas
* - Compilación de reglas a funciones JavaScript
* - Acceso seguro a paths (evita errores con `undefined`)
*
* ## Sintaxis de paths
*
* El operador `var` usa `/` como separador en lugar del `.` clásico.
*
* Ejemplo:
*
* ```
* user/profile/name
* ```
*
* ## Ejemplo básico
*
* @example
* const engine = new JsonLogicEngine();
*
* const rule = {
* and: [
* { ">": [{ var: "age" }, 18] },
* { "<": [{ var: "age" }, 65] }
* ]
* };
*
* engine.evaluate(rule, { age: 30 }); // true
*
* ## Compilación de reglas
*
* Cuando una regla se evalúa muchas veces, es recomendable compilarla.
*
* @example
* const esAdulto = engine.compile({
* ">=": [{ var: "age" }, 18]
* });
*
* esAdulto({ age: 20 }); // true
* esAdulto({ age: 15 }); // false
*
* @see https://jsonlogic.com
*/
import type { JsonLogicExpression, JsonLogicValue } from "@/jslg/lib/index.ts"
/**
* Separador de paths usado por el operador `var`.
*
* Ejemplo:
*
* user/profile/name
*/
export const JSON_LOGIC_PATH_SEPARATOR = "/" as const
// TIPOS INTERNOS
type OperationEval = (engine: JsonLogicEngine, ...args: any[]) => any
type OperationCompile = (args: string[]) => string
type Operation = {
eval: OperationEval
compile?: OperationCompile
}
/**
* Convierte una expresión JsonLogic en string JSON a JsonLogicValue.
* Útil en testsass y en la UI para parsear condiciones desde texto.
*
* @throws SyntaxError si el string no es JSON válido
*/
export function toJsonCondition(expr: string): JsonLogicValue {
return JSON.parse(expr) as JsonLogicValue;
}
export function asJsonCondition(value: unknown): JsonLogicValue {
return value as JsonLogicValue;
}
// CLASE PRINCIPAL
export class JsonLogicEngine {
private operations: Record<string, Operation> = {}
private currentData: any = null
/**
* Crea una nueva instancia del motor JsonLogic.
*
* Registra automáticamente los operadores básicos:
*
* - `var`
* - `==`, `!=`
* - `<`, `>`, `<=`, `>=`
* - `and`, `or`
* - `date-before`
* - `date-after`
* - `date-between`
*/
constructor() {
this.registerCore()
}
/**
* Registra un operador personalizado.
*
* @param name Nombre del operador
* @param evalFn Función que ejecuta la lógica del operador
* @param compileFn Función opcional para generar código durante la compilación
*
* @example
* engine.addOperation(
* "sum",
* (_, a, b) => a + b,
* ([a, b]) => `(${a} + ${b})`
* );
*/
addOperation(
name: string,
evalFn: OperationEval,
compileFn?: OperationCompile
) {
this.operations[name] = { eval: evalFn, compile: compileFn }
}
/**
* Evalúa una regla JsonLogic usando un contexto de datos.
*
* @param rule Regla JsonLogic
* @param data Datos sobre los que se ejecuta la regla
*
* @returns Resultado de la evaluación
*
* @throws Error si se usa un operador desconocido
*
* @example
* engine.evaluate(
* { ">": [{ var: "age" }, 18] },
* { age: 21 }
* ); // true
*/
evaluate(rule: JsonLogicValue, data: any): any {
this.currentData = data
try {
return this._evaluate(rule)
} finally {
this.currentData = null
}
}
private _evaluate(rule: JsonLogicValue): any {
if (rule === null || typeof rule !== "object") return rule
if (Array.isArray(rule)) {
return rule.map(r => this._evaluate(r))
}
const op = Object.keys(rule)[0]
let args = (rule as JsonLogicExpression)[op]
// ← NORMALIZACIÓN CLAVE (esto arregla todos los testsass)
if (!Array.isArray(args)) {
args = args === undefined ? [] : [args]
}
const operation = this.operations[op]
if (!operation) {
throw new Error(`Unknown operator: ${op}`)
}
const evaluatedArgs = args.map(a => this._evaluate(a))
return operation.eval(this, ...evaluatedArgs)
}
/**
* Compila una regla JsonLogic a una función JavaScript.
*
* Esto evita interpretar la estructura de la regla en cada ejecución
* y mejora mucho el rendimiento en evaluaciones repetidas.
*
* @param rule Regla a compilar
*
* @returns Función que recibe los datos y devuelve el resultado
*
* @example
* const regla = engine.compile({
* ">": [{ var: "score" }, 80]
* });
*
* regla({ score: 90 }); // true
*/
compile(rule: JsonLogicValue): (data: any) => any {
const expr = this.compileExpr(rule)
/* old
return new Function("data", `return ${expr};`) as (data: any) => any
*/
const source = `/* Compiled JsonLogic */ return ${expr};`;
try {
return new Function("data", source) as (data: any) => any;
} catch (e) {
console.error("Error crítico compilando regla:", expr);
throw e;
}
}
/**
* Extrae los paths de variables usados en una regla JsonLogic.
* Útil para construir grafos de dependencias.
*
* @example
* engine.usesData({ "==": [{ var: "at:color" }, "rojo"] })
* // → ["at:color"]
*
* engine.usesData({ "and": [
* { "==": [{ var: "sc:motor/at:fuel" }, "electrico"] },
* { ">": [{ var: "at:cv" }, 100] }
* ]})
* // → ["sc:motor/at:fuel", "at:cv"]
*/
usesData(rule: JsonLogicValue): string[] {
const vars: string[] = []
const walk = (node: JsonLogicValue) => {
if (node === null || typeof node !== 'object') return
if (Array.isArray(node)) { node.forEach(walk); return }
const op = Object.keys(node)[0]
let args = (node as JsonLogicExpression)[op]
if (!Array.isArray(args)) args = args === undefined ? [] : [args]
if (op === 'var') {
const path = args[0]
if (typeof path === 'string' && path) vars.push(path)
return
}
args.forEach(walk)
}
walk(rule)
return [...new Set(vars)]
}
private compileExpr(rule: JsonLogicValue): string {
if (rule === null) return "null"
if (typeof rule === "number" || typeof rule === "boolean") return String(rule)
if (typeof rule === "string") return JSON.stringify(rule)
if (Array.isArray(rule)) {
return `[${rule.map(v => this.compileExpr(v)).join(",")}]`
}
const op = Object.keys(rule)[0]
let args = (rule as JsonLogicExpression)[op]
if (!Array.isArray(args)) {
args = args === undefined ? [] : [args]
}
if (op === "var") {
return this.compileVar(args[0] as string)
}
const operation = this.operations[op]
if (!operation?.compile) {
throw new Error(`Operator '${op}' cannot be compiled`)
}
const compiledArgs = args.map(a => this.compileExpr(a))
return operation.compile(compiledArgs)
}
private compileVar(path: string): string {
const clean = (path ?? "").trim()
if (!clean) return "data"
const parts = clean.split(JSON_LOGIC_PATH_SEPARATOR)
let expr = "data"
/*
for (const p of parts) {
const safe = p.replace(/"/g, '\\"')
expr += `?.["${safe}"]`
}
*/
for (const p of parts) {
// JSON.stringify pone las comillas automáticamente y escapa
// CUALQUIER carácter raro (saltos de línea, backslashes, etc.)
expr += `?.[${JSON.stringify(p)}]`;
}
return expr
}
private getVar(data: any, path: string): any {
if (!path || data == null) return undefined
let current = data
for (const p of path.split(JSON_LOGIC_PATH_SEPARATOR)) {
if (current == null) return undefined
current = current[p]
}
return current
}
private registerCore() {
// var
this.addOperation(
"var",
(engine, path = "") => engine.getVar(engine.currentData, path),
([path = ""]) => this.compileVar(path)
)
// comparaciones
this.addOperation("==", (_, a, b) => a === b, ([a, b]) => `(${a} === ${b})`)
this.addOperation("!=", (_, a, b) => a !== b, ([a, b]) => `(${a} !== ${b})`)
this.addOperation(">", (_, a, b) => a > b, ([a, b]) => `(${a} > ${b})`)
this.addOperation("<", (_, a, b) => a < b, ([a, b]) => `(${a} < ${b})`)
this.addOperation(">=", (_, a, b) => a >= b, ([a, b]) => `(${a} >= ${b})`)
this.addOperation("<=", (_, a, b) => a <= b, ([a, b]) => `(${a} <= ${b})`)
// lógicos
this.addOperation("and", (_, ...args) => args.every(Boolean), args => `(${args.join(" && ")})`)
this.addOperation("or", (_, ...args) => args.some(Boolean), args => `(${args.join(" || ")})`)
// fechas (comparación lexicográfica segura para ISO strings)
this.addOperation("date-before", (_, a, b) => String(a) < String(b), ([a, b]) => `(${a} < ${b})`)
this.addOperation("date-after", (_, a, b) => String(a) > String(b), ([a, b]) => `(${a} > ${b})`)
this.addOperation(
"date-between",
(_, v, s, e) => String(v) >= String(s) && String(v) <= String(e),
([v, s, e]) => `(${v} >= ${s} && ${v} <= ${e})`
)
}
}

Powered by TurnKey Linux.