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
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})`
|
|
)
|
|
}
|
|
} |