clqms-be/app/Services/RuleExpressionService.php
root 30c4e47304 chore(repo): normalize EOL and harden contact patch flow
- handle contact PATCH failures by checking model save result and returning HTTP 400 with the model error message
- update ContactDetailModel nested updates to enforce active-detail checks and use model update() with explicit failure propagation
- extend contact patch assertions and align test-create variants expectations to status=success for POST responses
- refresh composer lock metadata/dependency constraints and include generated docs/data/test files updated during normalization
- impact: API contract unchanged except clearer 400 error responses on invalid contact detail updates
2026-04-17 05:38:11 +07:00

707 lines
22 KiB
PHP
Executable File

<?php
namespace App\Services;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\ExpressionLanguage\ParsedExpression;
class RuleExpressionService
{
protected ExpressionLanguage $language;
/** @var array<string, ParsedExpression> */
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('TestSiteCode', value)
* - result_set(value) (deprecated, uses current context TestSiteID)
* - 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) or result_set('CODE', value) [aliases: set_result]
if (preg_match('/^(result_set|set_result)\s*\(\s*(.+)\s*\)$/is', $action, $m)) {
$args = $this->splitTopLevel($m[2], ',', 2);
if (empty($args)) {
throw new \InvalidArgumentException('result_set requires a value expression');
}
$testCode = null;
if (count($args) === 2) {
$testCode = $this->unquoteStringArgument($args[0]);
if ($testCode === null) {
throw new \InvalidArgumentException('result_set test code must be a quoted string');
}
}
$value = trim($args[count($args) - 1]);
// Check if it's a number
if (is_numeric($value)) {
return [
'type' => 'RESULT_SET',
'value' => strpos($value, '.') !== false ? (float) $value : (int) $value,
'valueExpr' => $value,
'testCode' => $testCode,
];
}
// 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]) . '"',
'testCode' => $testCode,
];
}
// Complex expression
return [
'type' => 'RESULT_SET',
'valueExpr' => $value,
'testCode' => $testCode,
];
}
// 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,
];
}
private function unquoteStringArgument(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$quote = $value[0];
if ($quote !== '"' && $quote !== "'") {
return null;
}
if (substr($value, -1) !== $quote) {
return null;
}
$content = substr($value, 1, -1);
return stripcslashes($content);
}
}