*/ protected array $parsedCache = []; public function __construct() { $this->language = new ExpressionLanguage(); } public function evaluate(string $expr, array $context = []) { $expr = trim($expr); if ($expr === '') { return null; } $names = array_keys($context); sort($names); $cacheKey = md5($expr . '|' . implode(',', $names)); if (!isset($this->parsedCache[$cacheKey])) { $this->parsedCache[$cacheKey] = $this->language->parse($expr, $names); } return $this->language->evaluate($this->parsedCache[$cacheKey], $context); } public function evaluateBoolean(?string $expr, array $context = []): bool { if ($expr === null || trim($expr) === '') { return true; } return (bool) $this->evaluate($expr, $context); } /** * Compile DSL expression to engine-compatible JSON structure. * * Supported DSL (canonical): * - if(condition; then-actions; else-actions) * - sex('F'|'M') -> patient["Sex"] == 'F' * - priority('R'|'S'|'U') -> order["Priority"] == 'S' * - age > 18 -> age > 18 * - requested('CODE') -> requested('CODE') (resolved at runtime) * - result_set(value) * - test_insert('CODE') * - test_delete('CODE') * - comment_insert('text') * - nothing * - Multi-action: result_set(0.5):test_insert('CODE'):comment_insert('text') * * Backward compatible aliases: * - set_result -> result_set * - insert -> test_insert * - add_comment -> comment_insert * * @param string $expr The raw DSL expression * @return array The compiled structure with actions * @throws \InvalidArgumentException If DSL is invalid */ public function compile(string $expr): array { $expr = trim($expr); if ($expr === '') { return []; } $inner = $this->extractIfInner($expr); if ($inner === null) { // Allow callers to pass without if(...) wrapper if (substr_count($expr, ';') >= 2) { $inner = $expr; } else { throw new \InvalidArgumentException('Invalid DSL: expected "if(condition; then; else)" format'); } } $parts = $this->splitTopLevel($inner, ';', 3); if (count($parts) !== 3) { // Fallback: legacy ternary syntax $ternary = $this->convertTopLevelTernaryToSemicolon($inner); if ($ternary !== null) { $parts = $this->splitTopLevel($ternary, ';', 3); } } if (count($parts) !== 3) { throw new \InvalidArgumentException('Invalid DSL: expected exactly 3 parts "condition; then; else"'); } $condition = trim($parts[0]); $thenAction = trim($parts[1]); $elseAction = trim($parts[2]); // Compile condition $compiledCondition = $this->compileCondition($condition); // Compile actions (supports multi-action with : separator) $thenActions = $this->compileMultiAction($thenAction); $elseActions = $this->compileMultiAction($elseAction); // Build valueExpr for backward compatibility $thenValueExpr = $this->buildValueExpr($thenActions); $elseValueExpr = $this->buildValueExpr($elseActions); $valueExpr = "({$compiledCondition}) ? {$thenValueExpr} : {$elseValueExpr}"; return [ 'conditionExpr' => $compiledCondition, 'valueExpr' => $valueExpr, 'then' => $thenActions, 'else' => $elseActions, ]; } /** * Compile DSL condition to ExpressionLanguage expression * Supports: sex(), priority(), age, requested(), &&, ||, (), comparison operators */ private function compileCondition(string $condition): string { $condition = trim($condition); if ($condition === '') { return 'true'; } $tokens = $this->tokenize($condition); $out = ''; $count = count($tokens); for ($i = 0; $i < $count; $i++) { $t = $tokens[$i]; $type = $t['type']; $val = $t['value']; if ($type === 'op' && $val === '&&') { $out .= ' and '; continue; } if ($type === 'op' && $val === '||') { $out .= ' or '; continue; } if ($type === 'ident') { $lower = strtolower($val); // sex('M') if ($lower === 'sex') { $arg = $this->parseSingleStringArg($tokens, $i + 1); if ($arg !== null) { $out .= 'patient["Sex"] == "' . addslashes($arg['value']) . '"'; $i = $arg['endIndex']; continue; } } // priority('S') if ($lower === 'priority') { $arg = $this->parseSingleStringArg($tokens, $i + 1); if ($arg !== null) { $out .= 'order["Priority"] == "' . addslashes($arg['value']) . '"'; $i = $arg['endIndex']; continue; } } // requested('CODE') if ($lower === 'requested') { $arg = $this->parseSingleStringArg($tokens, $i + 1); if ($arg !== null) { $out .= 'requested("' . addslashes($arg['value']) . '")'; $i = $arg['endIndex']; continue; } } // age >= 18 if ($lower === 'age') { $op = $tokens[$i + 1] ?? null; $num = $tokens[$i + 2] ?? null; if (($op['type'] ?? '') === 'op' && in_array($op['value'], ['<', '>', '<=', '>=', '==', '!='], true) && ($num['type'] ?? '') === 'number') { $out .= 'age ' . $op['value'] . ' ' . $num['value']; $i += 2; continue; } } } $out .= $val; } return trim($out); } /** * Compile multi-action string (separated by :) * Returns array of action objects */ private function compileMultiAction(string $actionStr): array { $actionStr = trim($actionStr); // Split by : separator (top-level only) $actions = []; $parts = $this->splitTopLevel($actionStr, ':'); foreach ($parts as $part) { $action = $this->compileSingleAction(trim($part)); if ($action !== null) { $actions[] = $action; } } return $actions; } /** * Compile a single action */ private function compileSingleAction(string $action): ?array { $action = trim($action); // nothing - no operation if (strcasecmp($action, 'nothing') === 0) { return [ 'type' => 'NO_OP', 'value' => null, ]; } // result_set(value) [aliases: set_result] if (preg_match('/^(result_set|set_result)\s*\(\s*(.+?)\s*\)$/is', $action, $m)) { $value = trim($m[2]); // Check if it's a number if (is_numeric($value)) { return [ 'type' => 'RESULT_SET', 'value' => strpos($value, '.') !== false ? (float) $value : (int) $value, 'valueExpr' => $value, ]; } // Check if it's a quoted string (single or double quotes) if (preg_match('/^["\'](.+)["\']$/s', $value, $vm)) { return [ 'type' => 'RESULT_SET', 'value' => $vm[1], 'valueExpr' => '"' . addslashes($vm[1]) . '"', ]; } // Complex expression return [ 'type' => 'RESULT_SET', 'valueExpr' => $value, ]; } // test_insert('CODE') [aliases: insert] if (preg_match('/^(test_insert|insert)\s*\(\s*["\']([^"\']+)["\']\s*\)$/i', $action, $m)) { return [ 'type' => 'TEST_INSERT', 'testCode' => $m[2], ]; } // test_delete('CODE') if (preg_match('/^test_delete\s*\(\s*["\']([^"\']+)["\']\s*\)$/i', $action, $m)) { return [ 'type' => 'TEST_DELETE', 'testCode' => $m[1], ]; } // comment_insert('text') [aliases: add_comment] if (preg_match('/^(comment_insert|add_comment)\s*\(\s*["\'](.+?)["\']\s*\)$/is', $action, $m)) { return [ 'type' => 'COMMENT_INSERT', 'comment' => $m[2], ]; } throw new \InvalidArgumentException('Unknown action: ' . $action); } /** * Build valueExpr string from array of actions (for backward compatibility) */ private function buildValueExpr(array $actions): string { if (empty($actions)) { return 'null'; } // Use first SET_RESULT action's value, or null foreach ($actions as $action) { if (($action['type'] ?? '') === 'RESULT_SET' || ($action['type'] ?? '') === 'SET_RESULT') { if (isset($action['valueExpr'])) { return $action['valueExpr']; } if (isset($action['value'])) { if (is_string($action['value'])) { return '"' . addslashes($action['value']) . '"'; } return json_encode($action['value']); } } } return 'null'; } /** * Parse and split multi-rule expressions (comma-separated) * Returns array of individual rule expressions */ public function parseMultiRule(string $expr): array { $expr = trim($expr); if ($expr === '') { return []; } $rules = []; $depth = 0; $current = ''; $len = strlen($expr); for ($i = 0; $i < $len; $i++) { $char = $expr[$i]; if ($char === '(') { $depth++; } elseif ($char === ')') { $depth--; } if ($char === ',' && $depth === 0) { $trimmed = trim($current); if ($trimmed !== '') { $rules[] = $trimmed; } $current = ''; } else { $current .= $char; } } // Add last rule $trimmed = trim($current); if ($trimmed !== '') { $rules[] = $trimmed; } return $rules; } private function extractIfInner(string $expr): ?string { $expr = trim($expr); if (!preg_match('/^if\s*\(/i', $expr)) { return null; } $pos = stripos($expr, '('); if ($pos === false) { return null; } $start = $pos + 1; $end = $this->findMatchingParen($expr, $pos); if ($end === null) { throw new \InvalidArgumentException('Invalid DSL: unbalanced parentheses in if(...)'); } // Only allow trailing whitespace after the closing paren if (trim(substr($expr, $end + 1)) !== '') { throw new \InvalidArgumentException('Invalid DSL: unexpected trailing characters after if(...)'); } return trim(substr($expr, $start, $end - $start)); } private function findMatchingParen(string $s, int $openIndex): ?int { $len = strlen($s); $depth = 0; $inSingle = false; $inDouble = false; for ($i = $openIndex; $i < $len; $i++) { $ch = $s[$i]; if ($ch === '\\') { $i++; continue; } if (!$inDouble && $ch === "'") { $inSingle = !$inSingle; continue; } if (!$inSingle && $ch === '"') { $inDouble = !$inDouble; continue; } if ($inSingle || $inDouble) { continue; } if ($ch === '(') { $depth++; continue; } if ($ch === ')') { $depth--; if ($depth === 0) { return $i; } } } return null; } /** * Split string by a delimiter only when not inside quotes/parentheses. * If $limit is provided, splits into at most $limit parts. */ private function splitTopLevel(string $s, string $delimiter, ?int $limit = null): array { $s = (string) $s; $len = strlen($s); $depth = 0; $inSingle = false; $inDouble = false; $parts = []; $buf = ''; for ($i = 0; $i < $len; $i++) { $ch = $s[$i]; if ($ch === '\\') { $buf .= $ch; if ($i + 1 < $len) { $buf .= $s[$i + 1]; $i++; } continue; } if (!$inDouble && $ch === "'") { $inSingle = !$inSingle; $buf .= $ch; continue; } if (!$inSingle && $ch === '"') { $inDouble = !$inDouble; $buf .= $ch; continue; } if (!$inSingle && !$inDouble) { if ($ch === '(') { $depth++; } elseif ($ch === ')') { $depth = max(0, $depth - 1); } if ($depth === 0 && $ch === $delimiter) { $parts[] = trim($buf); $buf = ''; if ($limit !== null && count($parts) >= ($limit - 1)) { $buf .= substr($s, $i + 1); $i = $len; } continue; } } $buf .= $ch; } $parts[] = trim($buf); $parts = array_values(array_filter($parts, static fn ($p) => $p !== '')); return $parts; } /** * Convert a top-level ternary (condition ? then : else) into semicolon form. */ private function convertTopLevelTernaryToSemicolon(string $s): ?string { $len = strlen($s); $depth = 0; $inSingle = false; $inDouble = false; $qPos = null; $cPos = null; for ($i = 0; $i < $len; $i++) { $ch = $s[$i]; if ($ch === '\\') { $i++; continue; } if (!$inDouble && $ch === "'") { $inSingle = !$inSingle; continue; } if (!$inSingle && $ch === '"') { $inDouble = !$inDouble; continue; } if ($inSingle || $inDouble) { continue; } if ($ch === '(') { $depth++; continue; } if ($ch === ')') { $depth = max(0, $depth - 1); continue; } if ($depth === 0 && $ch === '?' && $qPos === null) { $qPos = $i; continue; } if ($depth === 0 && $ch === ':' && $qPos !== null) { $cPos = $i; break; } } if ($qPos === null || $cPos === null) { return null; } $cond = trim(substr($s, 0, $qPos)); $then = trim(substr($s, $qPos + 1, $cPos - $qPos - 1)); $else = trim(substr($s, $cPos + 1)); if ($cond === '' || $then === '' || $else === '') { return null; } return $cond . '; ' . $then . '; ' . $else; } /** * Tokenize a condition for lightweight transforms. * Returns tokens with keys: type (ident|number|string|op|punct|other), value. */ private function tokenize(string $s): array { $len = strlen($s); $tokens = []; for ($i = 0; $i < $len; $i++) { $ch = $s[$i]; if (ctype_space($ch)) { continue; } $two = ($i + 1 < $len) ? $ch . $s[$i + 1] : ''; if (in_array($two, ['&&', '||', '>=', '<=', '==', '!='], true)) { $tokens[] = ['type' => 'op', 'value' => $two]; $i++; continue; } if (in_array($ch, ['>', '<', '(', ')', '[', ']', ',', '+', '-', '*', '/', '%', '!', '.', ':', '?'], true)) { $tokens[] = ['type' => ($ch === '>' || $ch === '<' || $ch === '!' ? 'op' : 'punct'), 'value' => $ch]; continue; } if ($ch === '"' || $ch === "'") { $quote = $ch; $buf = $quote; $i++; for (; $i < $len; $i++) { $c = $s[$i]; $buf .= $c; if ($c === '\\' && $i + 1 < $len) { $buf .= $s[$i + 1]; $i++; continue; } if ($c === $quote) { break; } } $tokens[] = ['type' => 'string', 'value' => $buf]; continue; } if (ctype_digit($ch)) { $buf = $ch; while ($i + 1 < $len && (ctype_digit($s[$i + 1]) || $s[$i + 1] === '.')) { $buf .= $s[$i + 1]; $i++; } $tokens[] = ['type' => 'number', 'value' => $buf]; continue; } if (ctype_alpha($ch) || $ch === '_') { $buf = $ch; while ($i + 1 < $len && (ctype_alnum($s[$i + 1]) || $s[$i + 1] === '_')) { $buf .= $s[$i + 1]; $i++; } $tokens[] = ['type' => 'ident', 'value' => $buf]; continue; } $tokens[] = ['type' => 'other', 'value' => $ch]; } return $tokens; } /** * Parse a single quoted string argument from tokens starting at $start. * Expects: '(' string ')'. Returns ['value' => string, 'endIndex' => int] or null. */ private function parseSingleStringArg(array $tokens, int $start): ?array { $t0 = $tokens[$start] ?? null; $t1 = $tokens[$start + 1] ?? null; $t2 = $tokens[$start + 2] ?? null; if (($t0['value'] ?? null) !== '(') { return null; } if (($t1['type'] ?? null) !== 'string') { return null; } if (($t2['value'] ?? null) !== ')') { return null; } $raw = $t1['value']; $quote = $raw[0] ?? ''; if ($quote !== '"' && $quote !== "'") { return null; } $val = substr($raw, 1, -1); return [ 'value' => $val, 'endIndex' => $start + 2, ]; } }