chore: repo-wide normalization + rules test coverage

Normalize formatting/line endings across configs, controllers, models, tests, and OpenAPI specs.

Update rule expression/rule engine implementation and remove obsolete RuleAction controller/model.

Add unit tests for rule expression syntax and multi-action behavior, and include docs updates.
This commit is contained in:
root 2026-03-16 07:24:50 +07:00
parent c01786bb93
commit 2bcdf09b55
291 changed files with 35119 additions and 33957 deletions

View File

@ -357,11 +357,6 @@ $routes->group('api', function ($routes) {
$routes->delete('(:num)', 'Rule\RuleController::delete/$1');
$routes->post('validate', 'Rule\RuleController::validateExpr');
$routes->post('compile', 'Rule\RuleController::compile');
$routes->get('(:num)/actions', 'Rule\RuleActionController::index/$1');
$routes->post('(:num)/actions', 'Rule\RuleActionController::create/$1');
$routes->patch('(:num)/actions/(:num)', 'Rule\RuleActionController::update/$1/$2');
$routes->delete('(:num)/actions/(:num)', 'Rule\RuleActionController::delete/$1/$2');
});
// Demo/Test Routes (No Auth)

View File

@ -7,7 +7,6 @@ use App\Libraries\ValueSet;
use App\Models\OrderTest\OrderTestModel;
use App\Models\Patient\PatientModel;
use App\Models\PatVisit\PatVisitModel;
use App\Services\RuleEngineService;
class OrderTestController extends Controller {
use ResponseTrait;
@ -180,20 +179,7 @@ class OrderTestController extends Controller {
$order['Specimens'] = $this->getOrderSpecimens($order['InternalOID']);
$order['Tests'] = $this->getOrderTests($order['InternalOID']);
// Run common rules for ORDER_CREATED (non-blocking)
try {
$ruleEngine = new RuleEngineService();
$ruleEngine->run('ORDER_CREATED', [
'order' => $order,
'tests' => $order['Tests'],
'input' => $input,
]);
// Refresh tests in case rules updated results
$order['Tests'] = $this->getOrderTests($order['InternalOID']);
} catch (\Throwable $e) {
log_message('error', 'OrderTestController::create rule engine error: ' . $e->getMessage());
}
// Rule engine triggers are fired at the test/result level (test_created, result_updated)
return $this->respondCreated([
'status' => 'success',

View File

@ -1,227 +0,0 @@
<?php
namespace App\Controllers\Rule;
use App\Controllers\BaseController;
use App\Models\Rule\RuleActionModel;
use App\Models\Rule\RuleDefModel;
use App\Models\Test\TestDefSiteModel;
use App\Traits\ResponseTrait;
class RuleActionController extends BaseController
{
use ResponseTrait;
protected RuleDefModel $ruleDefModel;
protected RuleActionModel $ruleActionModel;
public function __construct()
{
$this->ruleDefModel = new RuleDefModel();
$this->ruleActionModel = new RuleActionModel();
}
public function index($ruleID = null)
{
try {
if (!$ruleID || !is_numeric($ruleID)) {
return $this->failValidationErrors('RuleID is required');
}
$rule = $this->ruleDefModel->where('EndDate', null)->find((int) $ruleID);
if (!$rule) {
return $this->respond([
'status' => 'failed',
'message' => 'Rule not found',
'data' => [],
], 404);
}
$rows = $this->ruleActionModel
->where('RuleID', (int) $ruleID)
->where('EndDate', null)
->orderBy('RuleActionID', 'ASC')
->findAll();
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $rows,
], 200);
} catch (\Throwable $e) {
log_message('error', 'RuleActionController::index error: ' . $e->getMessage());
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
public function create($ruleID = null)
{
$input = $this->request->getJSON(true) ?? [];
if (!$ruleID || !is_numeric($ruleID)) {
$ruleID = $input['RuleID'] ?? null;
}
if (!$ruleID || !is_numeric($ruleID)) {
return $this->failValidationErrors('RuleID is required');
}
$rule = $this->ruleDefModel->where('EndDate', null)->find((int) $ruleID);
if (!$rule) {
return $this->respond([
'status' => 'failed',
'message' => 'Rule not found',
'data' => [],
], 404);
}
$validation = service('validation');
$validation->setRules([
'ActionType' => 'required|max_length[50]',
]);
if (!$validation->run($input)) {
return $this->failValidationErrors($validation->getErrors());
}
// Light validation for SET_RESULT params
$actionType = strtoupper((string) $input['ActionType']);
$params = $input['ActionParams'] ?? null;
if ($actionType === 'SET_RESULT') {
$decoded = is_array($params) ? $params : (is_string($params) ? json_decode($params, true) : null);
if (!is_array($decoded)) {
return $this->failValidationErrors(['ActionParams' => 'ActionParams must be JSON object for SET_RESULT']);
}
if (empty($decoded['testSiteID']) && empty($decoded['testSiteCode'])) {
return $this->failValidationErrors(['ActionParams' => 'SET_RESULT requires testSiteID or testSiteCode']);
}
if (!array_key_exists('value', $decoded) && !array_key_exists('valueExpr', $decoded)) {
return $this->failValidationErrors(['ActionParams' => 'SET_RESULT requires value or valueExpr']);
}
if (!empty($decoded['testSiteID']) && is_numeric($decoded['testSiteID'])) {
$testDef = new TestDefSiteModel();
$exists = $testDef->where('EndDate', null)->find((int) $decoded['testSiteID']);
if (!$exists) {
return $this->failValidationErrors(['ActionParams' => 'testSiteID not found']);
}
}
}
try {
if (is_array($params)) {
$params = json_encode($params);
}
$id = $this->ruleActionModel->insert([
'RuleID' => (int) $ruleID,
'ActionType' => $input['ActionType'],
'ActionParams' => is_string($params) ? $params : null,
], true);
return $this->respondCreated([
'status' => 'success',
'message' => 'Action created successfully',
'data' => ['RuleActionID' => $id],
], 201);
} catch (\Throwable $e) {
log_message('error', 'RuleActionController::create error: ' . $e->getMessage());
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
public function update($ruleID = null, $actionID = null)
{
$input = $this->request->getJSON(true) ?? [];
if (!$ruleID || !is_numeric($ruleID)) {
$ruleID = $input['RuleID'] ?? null;
}
if (!$actionID || !is_numeric($actionID)) {
$actionID = $input['RuleActionID'] ?? null;
}
if (!$ruleID || !is_numeric($ruleID) || !$actionID || !is_numeric($actionID)) {
return $this->failValidationErrors('RuleID and RuleActionID are required');
}
$rule = $this->ruleDefModel->where('EndDate', null)->find((int) $ruleID);
if (!$rule) {
return $this->respond([
'status' => 'failed',
'message' => 'Rule not found',
'data' => [],
], 404);
}
$existing = $this->ruleActionModel->where('EndDate', null)->find((int) $actionID);
if (!$existing || (int) ($existing['RuleID'] ?? 0) !== (int) $ruleID) {
return $this->respond([
'status' => 'failed',
'message' => 'Action not found',
'data' => [],
], 404);
}
$validation = service('validation');
$validation->setRules([
'ActionType' => 'permit_empty|max_length[50]',
]);
if (!$validation->run($input)) {
return $this->failValidationErrors($validation->getErrors());
}
try {
$updateData = [];
foreach (['ActionType', 'ActionParams'] as $field) {
if (array_key_exists($field, $input)) {
$updateData[$field] = $input[$field];
}
}
if (isset($updateData['ActionParams']) && is_array($updateData['ActionParams'])) {
$updateData['ActionParams'] = json_encode($updateData['ActionParams']);
}
if (!empty($updateData)) {
$this->ruleActionModel->update((int) $actionID, $updateData);
}
return $this->respond([
'status' => 'success',
'message' => 'Action updated successfully',
'data' => ['RuleActionID' => (int) $actionID],
], 200);
} catch (\Throwable $e) {
log_message('error', 'RuleActionController::update error: ' . $e->getMessage());
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
public function delete($ruleID = null, $actionID = null)
{
try {
if (!$ruleID || !is_numeric($ruleID) || !$actionID || !is_numeric($actionID)) {
return $this->failValidationErrors('RuleID and RuleActionID are required');
}
$existing = $this->ruleActionModel->where('EndDate', null)->find((int) $actionID);
if (!$existing || (int) ($existing['RuleID'] ?? 0) !== (int) $ruleID) {
return $this->respond([
'status' => 'failed',
'message' => 'Action not found',
'data' => [],
], 404);
}
$this->ruleActionModel->delete((int) $actionID);
return $this->respondDeleted([
'status' => 'success',
'message' => 'Action deleted successfully',
'data' => ['RuleActionID' => (int) $actionID],
]);
} catch (\Throwable $e) {
log_message('error', 'RuleActionController::delete error: ' . $e->getMessage());
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
}

View File

@ -3,7 +3,6 @@
namespace App\Controllers\Rule;
use App\Controllers\BaseController;
use App\Models\Rule\RuleActionModel;
use App\Models\Rule\RuleDefModel;
use App\Models\Test\TestDefSiteModel;
use App\Services\RuleExpressionService;
@ -14,12 +13,10 @@ class RuleController extends BaseController
use ResponseTrait;
protected RuleDefModel $ruleDefModel;
protected RuleActionModel $ruleActionModel;
public function __construct()
{
$this->ruleDefModel = new RuleDefModel();
$this->ruleActionModel = new RuleActionModel();
}
public function index()
@ -80,15 +77,8 @@ class RuleController extends BaseController
], 404);
}
$actions = $this->ruleActionModel
->where('RuleID', (int) $id)
->where('EndDate', null)
->orderBy('RuleActionID', 'ASC')
->findAll();
$linkedTests = $this->ruleDefModel->getLinkedTests((int) $id);
$rule['actions'] = $actions;
$rule['linkedTests'] = $linkedTests;
return $this->respond([
@ -161,31 +151,6 @@ class RuleController extends BaseController
$this->ruleDefModel->linkTest($ruleID, (int) $testSiteID);
}
// Create actions if provided
if (isset($input['actions']) && is_array($input['actions'])) {
foreach ($input['actions'] as $action) {
if (!is_array($action)) {
continue;
}
$actionType = $action['ActionType'] ?? null;
if (!$actionType) {
continue;
}
$params = $action['ActionParams'] ?? null;
if (is_array($params)) {
$params = json_encode($params);
}
$this->ruleActionModel->insert([
'RuleID' => $ruleID,
'ActionType' => $actionType,
'ActionParams' => is_string($params) ? $params : null,
]);
}
}
$db->transComplete();
if ($db->transStatus() === false) {
throw new \Exception('Transaction failed');

View File

@ -5,8 +5,9 @@ namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
/**
* Replace ruledef/ruleaction with testrule schema
* Rules can now be linked to multiple tests via testrule_testsite mapping table
* Rule engine tables: ruledef + testrule.
*
* ruleaction is deprecated; actions are embedded in ruledef.ConditionExprCompiled.
*/
class CreateTestRules extends Migration
{
@ -46,26 +47,10 @@ class CreateTestRules extends Migration
// Foreign keys for mapping table
$this->db->query('ALTER TABLE `testrule` ADD CONSTRAINT `fk_testrule_ruledef` FOREIGN KEY (`RuleID`) REFERENCES `ruledef`(`RuleID`) ON DELETE CASCADE ON UPDATE CASCADE');
$this->db->query('ALTER TABLE `testrule` ADD CONSTRAINT `fk_testrule_testsite` FOREIGN KEY (`TestSiteID`) REFERENCES `testdefsite`(`TestSiteID`) ON DELETE CASCADE ON UPDATE CASCADE');
// ruleaction - actions for rules
$this->forge->addField([
'RuleActionID' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'RuleID' => ['type' => 'INT', 'unsigned' => true, 'null' => false],
'ActionType' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false],
'ActionParams' => ['type' => 'TEXT', 'null' => true],
'CreateDate' => ['type' => 'DATETIME', 'null' => true],
'EndDate' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('RuleActionID', true);
$this->forge->addKey('RuleID');
$this->forge->createTable('ruleaction');
$this->db->query('ALTER TABLE `ruleaction` ADD CONSTRAINT `fk_ruleaction_ruledef` FOREIGN KEY (`RuleID`) REFERENCES `ruledef`(`RuleID`) ON DELETE CASCADE ON UPDATE CASCADE');
}
public function down()
{
$this->forge->dropTable('ruleaction', true);
$this->forge->dropTable('testrule', true);
$this->forge->dropTable('ruledef', true);
}

View File

@ -164,6 +164,20 @@ class OrderTestModel extends BaseModel {
// Insert unique tests into patres with specimen links
if (!empty($testToOrder)) {
$resModel = new \App\Models\PatResultModel();
$patientModel = new \App\Models\Patient\PatientModel();
$patient = $patientModel->find((int) $data['InternalPID']);
$age = null;
if (is_array($patient) && !empty($patient['Birthdate'])) {
try {
$birthdate = new \DateTime((string) $patient['Birthdate']);
$age = (new \DateTime())->diff($birthdate)->y;
} catch (\Throwable $e) {
$age = null;
}
}
$ruleEngine = new \App\Services\RuleEngineService();
foreach ($testToOrder as $tid => $tinfo) {
$specimenInfo = $specimenConDefMap[$tid] ?? null;
@ -182,6 +196,24 @@ class OrderTestModel extends BaseModel {
}
$resModel->insert($patResData);
// Fire test_created rules after the test row is persisted
try {
$ruleEngine->run('test_created', [
'order' => [
'InternalOID' => $internalOID,
'Priority' => $orderData['Priority'] ?? 'R',
'TestSiteID' => $tid,
],
'patient' => [
'Sex' => is_array($patient) ? ($patient['Sex'] ?? null) : null,
],
'age' => $age,
'testSiteID' => $tid,
]);
} catch (\Throwable $e) {
log_message('error', 'OrderTestModel::createOrder rule engine error: ' . $e->getMessage());
}
}
}
}

View File

@ -214,6 +214,47 @@ class PatResultModel extends BaseModel {
$this->db->transComplete();
// Fire result_updated rules (non-blocking)
try {
$fresh = $this->find($resultID);
if (is_array($fresh) && !empty($fresh['OrderID']) && !empty($fresh['TestSiteID'])) {
$orderModel = new \App\Models\OrderTest\OrderTestModel();
$order = $orderModel->find((int) $fresh['OrderID']);
$patient = null;
$age = null;
if (is_array($order) && !empty($order['InternalPID'])) {
$patientModel = new \App\Models\Patient\PatientModel();
$patient = $patientModel->find((int) $order['InternalPID']);
if (is_array($patient) && !empty($patient['Birthdate'])) {
try {
$birthdate = new \DateTime((string) $patient['Birthdate']);
$age = (new \DateTime())->diff($birthdate)->y;
} catch (\Throwable $e) {
$age = null;
}
}
}
$engine = new \App\Services\RuleEngineService();
$engine->run('result_updated', [
'order' => [
'InternalOID' => (int) ($fresh['OrderID'] ?? 0),
'Priority' => is_array($order) ? ($order['Priority'] ?? null) : null,
'TestSiteID' => (int) ($fresh['TestSiteID'] ?? 0),
],
'patient' => [
'Sex' => is_array($patient) ? ($patient['Sex'] ?? null) : null,
],
'age' => $age,
'testSiteID' => (int) ($fresh['TestSiteID'] ?? 0),
'result' => $fresh,
]);
}
} catch (\Throwable $e) {
log_message('error', 'PatResultModel::updateWithValidation rule engine error: ' . $e->getMessage());
}
return [
'success' => true,
'flag' => $flag,

View File

@ -1,49 +0,0 @@
<?php
namespace App\Models\Rule;
use App\Models\BaseModel;
/**
* RuleAction Model
*
* Actions that can be executed when a rule matches.
*/
class RuleActionModel extends BaseModel
{
protected $table = 'ruleaction';
protected $primaryKey = 'RuleActionID';
protected $allowedFields = [
'RuleID',
'ActionType',
'ActionParams',
'CreateDate',
'EndDate',
];
protected $useTimestamps = true;
protected $createdField = 'CreateDate';
protected $updatedField = '';
protected $useSoftDeletes = true;
protected $deletedField = 'EndDate';
/**
* Get active actions by rule IDs
*
* @param array $ruleIDs Array of RuleID values
* @return array Array of actions
*/
public function getActiveByRuleIDs(array $ruleIDs): array
{
if (empty($ruleIDs)) {
return [];
}
return $this->whereIn('RuleID', $ruleIDs)
->where('EndDate IS NULL')
->orderBy('RuleID', 'ASC')
->orderBy('RuleActionID', 'ASC')
->findAll();
}
}

View File

@ -137,4 +137,144 @@ class RuleDefModel extends BaseModel
->where('EndDate IS NULL')
->update(['EndDate' => date('Y-m-d H:i:s')]);
}
/**
* Validate that a DSL expression compiles successfully
*
* @param string $expr The DSL expression to validate
* @return array ['valid' => bool, 'error' => string|null, 'compiled' => array|null]
*/
public function validateExpression(string $expr): array
{
$service = new \App\Services\RuleExpressionService();
try {
$compiled = $service->compile($expr);
return [
'valid' => true,
'error' => null,
'compiled' => $compiled,
];
} catch (\Throwable $e) {
return [
'valid' => false,
'error' => $e->getMessage(),
'compiled' => null,
];
}
}
/**
* Compile a DSL expression to JSON
*
* @param string $expr The DSL expression to compile
* @return array The compiled structure
* @throws \InvalidArgumentException If compilation fails
*/
public function compileExpression(string $expr): array
{
$service = new \App\Services\RuleExpressionService();
return $service->compile($expr);
}
/**
* Check if a rule's expression needs recompilation
* Returns true if the raw expression differs from the compiled version
*
* @param int $ruleID The rule ID to check
* @return bool True if recompilation is needed
*/
public function needsRecompilation(int $ruleID): bool
{
$rule = $this->find($ruleID);
if (!$rule) {
return false;
}
$rawExpr = $rule['ConditionExpr'] ?? '';
$compiledJson = $rule['ConditionExprCompiled'] ?? '';
if (empty($rawExpr)) {
return false;
}
if (empty($compiledJson)) {
return true;
}
// Try to compile current expression and compare with stored version
try {
$compiled = $this->compileExpression($rawExpr);
$stored = json_decode($compiledJson, true);
return json_encode($compiled) !== json_encode($stored);
} catch (\Throwable $e) {
return true;
}
}
/**
* Automatically compile expression if needed and update the rule
*
* @param int $ruleID The rule ID
* @return bool Success status
*/
public function autoCompile(int $ruleID): bool
{
if (!$this->needsRecompilation($ruleID)) {
return true;
}
$rule = $this->find($ruleID);
if (!$rule) {
return false;
}
$rawExpr = $rule['ConditionExpr'] ?? '';
if (empty($rawExpr)) {
return false;
}
try {
$compiled = $this->compileExpression($rawExpr);
return $this->update($ruleID, [
'ConditionExprCompiled' => json_encode($compiled),
]);
} catch (\Throwable $e) {
log_message('error', 'Auto-compile failed for RuleID=' . $ruleID . ': ' . $e->getMessage());
return false;
}
}
/**
* Compile all rules that need recompilation
*
* @return array ['success' => int, 'failed' => int]
*/
public function compileAllPending(): array
{
$rules = $this->where('EndDate IS NULL')->findAll();
$success = 0;
$failed = 0;
foreach ($rules as $rule) {
$ruleID = (int) ($rule['RuleID'] ?? 0);
if ($ruleID === 0) {
continue;
}
if ($this->needsRecompilation($ruleID)) {
if ($this->autoCompile($ruleID)) {
$success++;
} else {
$failed++;
}
}
}
return [
'success' => $success,
'failed' => $failed,
];
}
}

View File

@ -2,29 +2,31 @@
namespace App\Services;
use App\Models\Rule\RuleActionModel;
use App\Models\Rule\RuleDefModel;
use App\Models\Test\TestDefSiteModel;
class RuleEngineService
{
protected RuleDefModel $ruleDefModel;
protected RuleActionModel $ruleActionModel;
protected RuleExpressionService $expr;
public function __construct()
{
$this->ruleDefModel = new RuleDefModel();
$this->ruleActionModel = new RuleActionModel();
$this->expr = new RuleExpressionService();
}
/**
* Run rules for an event.
*
* Expected context keys for ORDER_CREATED:
* Expected context keys:
* - order: array (must include InternalOID)
* - tests: array (patres rows, optional)
* - patient: array (optional, includes Sex, Age, etc.)
* - tests: array (optional, patres rows)
*
* @param string $eventCode The event that triggered rule execution
* @param array $context Context data for rule evaluation
* @return void
*/
public function run(string $eventCode, array $context = []): void
{
@ -40,18 +42,6 @@ class RuleEngineService
return;
}
$ruleIDs = array_values(array_filter(array_map(static fn ($r) => $r['RuleID'] ?? null, $rules)));
$actions = $this->ruleActionModel->getActiveByRuleIDs($ruleIDs);
$actionsByRule = [];
foreach ($actions as $action) {
$rid = $action['RuleID'] ?? null;
if (!$rid) {
continue;
}
$actionsByRule[$rid][] = $action;
}
foreach ($rules as $rule) {
$rid = (int) ($rule['RuleID'] ?? 0);
if ($rid === 0) {
@ -59,35 +49,33 @@ class RuleEngineService
}
try {
// Check for compiled expression first
// Rules must have compiled expressions
$compiled = null;
if (!empty($rule['ConditionExprCompiled'])) {
$compiled = json_decode($rule['ConditionExprCompiled'], true);
}
if (!empty($compiled) && is_array($compiled)) {
// Compiled rule: evaluate condition from compiled structure
if (empty($compiled) || !is_array($compiled)) {
log_message('warning', 'Rule ' . $rid . ' has no compiled expression, skipping');
continue;
}
// Evaluate condition
$conditionExpr = $compiled['conditionExpr'] ?? 'true';
$matches = $this->expr->evaluateBoolean($conditionExpr, $context);
if (!$matches) {
continue;
}
$matches = $this->evaluateCondition($conditionExpr, $context);
// Use compiled valueExpr for SET_RESULT action
if (!empty($compiled['valueExpr'])) {
$this->executeCompiledSetResult($rid, $compiled['valueExpr'], $context);
}
if (!$matches) {
// Execute else actions
$actions = $compiled['else'] ?? [];
} else {
// Legacy rule: evaluate raw ConditionExpr and execute stored actions
$matches = $this->expr->evaluateBoolean($rule['ConditionExpr'] ?? null, $context);
if (!$matches) {
continue;
// Execute then actions
$actions = $compiled['then'] ?? [];
}
foreach ($actionsByRule[$rid] ?? [] as $action) {
// Execute all actions
foreach ($actions as $action) {
$this->executeAction($action, $context);
}
}
} catch (\Throwable $e) {
log_message('error', 'Rule engine error (RuleID=' . $rid . '): ' . $e->getMessage());
continue;
@ -96,10 +84,90 @@ class RuleEngineService
}
/**
* Execute SET_RESULT action using compiled valueExpr.
* Automatically creates the test result if it doesn't exist.
* Evaluate a condition expression
* Handles special functions like requested() by querying the database
*/
protected function executeCompiledSetResult(int $ruleID, string $valueExpr, array $context): void
private function evaluateCondition(string $conditionExpr, array $context): bool
{
// Handle requested() function(s) by querying database
$conditionExpr = preg_replace_callback(
'/requested\s*\(\s*["\']([^"\']+)["\']\s*\)/i',
function (array $m) use ($context) {
$testCode = $m[1] ?? '';
if ($testCode === '') {
return 'false';
}
return $this->isTestRequested($testCode, $context) ? 'true' : 'false';
},
$conditionExpr
);
return $this->expr->evaluateBoolean($conditionExpr, $context);
}
/**
* Check if a test was requested for the current order
*/
private function isTestRequested(string $testCode, array $context): bool
{
$order = $context['order'] ?? null;
if (!is_array($order) || empty($order['InternalOID'])) {
return false;
}
$internalOID = (int) $order['InternalOID'];
$db = \Config\Database::connect();
// Query patres to check if test with given code exists
$result = $db->table('patres')
->select('patres.*, testdefsite.TestSiteCode')
->join('testdefsite', 'testdefsite.TestSiteID = patres.TestSiteID', 'inner')
->where('patres.OrderID', $internalOID)
->where('testdefsite.TestSiteCode', $testCode)
->where('patres.DelDate', null)
->where('testdefsite.EndDate', null)
->get()
->getRow();
return $result !== null;
}
/**
* Execute an action based on its type
*/
protected function executeAction(array $action, array $context): void
{
$type = strtoupper((string) ($action['type'] ?? ''));
switch ($type) {
case 'RESULT_SET':
case 'SET_RESULT': // legacy
$this->executeSetResult($action, $context);
break;
case 'TEST_INSERT':
case 'INSERT_TEST': // legacy
$this->executeInsertTest($action, $context);
break;
case 'TEST_DELETE':
$this->executeDeleteTest($action, $context);
break;
case 'COMMENT_INSERT':
case 'ADD_COMMENT': // legacy
$this->executeAddComment($action, $context);
break;
case 'NO_OP':
// Do nothing
break;
default:
log_message('warning', 'Unknown action type: ' . $type);
break;
}
}
/**
* Execute SET_RESULT action
*/
protected function executeSetResult(array $action, array $context): void
{
$order = $context['order'] ?? null;
if (!is_array($order) || empty($order['InternalOID'])) {
@ -113,20 +181,16 @@ class RuleEngineService
$testSiteID = is_numeric($order['TestSiteID']) ? (int) $order['TestSiteID'] : null;
}
if ($testSiteID === null) {
// Try to get testSiteID from context tests
$tests = $context['tests'] ?? [];
if (!empty($tests) && is_array($tests)) {
$testSiteID = (int) ($tests[0]['TestSiteID'] ?? null);
}
}
if ($testSiteID === null) {
throw new \Exception('SET_RESULT requires testSiteID');
}
// Evaluate the value expression
$value = $this->expr->evaluate($valueExpr, $context);
// Get the value
if (isset($action['valueExpr']) && is_string($action['valueExpr'])) {
$value = $this->expr->evaluate($action['valueExpr'], $context);
} else {
$value = $action['value'] ?? null;
}
$db = \Config\Database::connect();
@ -150,6 +214,7 @@ class RuleEngineService
$ok = $db->table('patres')->insert([
'OrderID' => $internalOID,
'TestSiteID' => $testSiteID,
'TestSiteCode' => $this->resolveTestSiteCode($testSiteID),
'Result' => $value,
'CreateDate' => date('Y-m-d H:i:s'),
]);
@ -160,72 +225,139 @@ class RuleEngineService
}
}
protected function executeAction(array $action, array $context): void
{
$type = strtoupper((string) ($action['ActionType'] ?? ''));
if ($type === 'SET_RESULT') {
$this->executeSetResult($action, $context);
return;
}
// Unknown action type: ignore
}
/**
* SET_RESULT action params (JSON):
* - testSiteID (int) OR testSiteCode (string)
* - value (scalar) OR valueExpr (ExpressionLanguage string)
* Execute INSERT_TEST action - Insert a new test into patres
*/
protected function executeSetResult(array $action, array $context): void
protected function executeInsertTest(array $action, array $context): void
{
$paramsRaw = (string) ($action['ActionParams'] ?? '');
$params = [];
if (trim($paramsRaw) !== '') {
$decoded = json_decode($paramsRaw, true);
if (is_array($decoded)) {
$params = $decoded;
}
}
$order = $context['order'] ?? null;
if (!is_array($order) || empty($order['InternalOID'])) {
throw new \Exception('SET_RESULT requires context.order.InternalOID');
throw new \Exception('INSERT_TEST requires context.order.InternalOID');
}
$internalOID = (int) $order['InternalOID'];
$testCode = $action['testCode'] ?? null;
$testSiteID = isset($params['testSiteID']) && is_numeric($params['testSiteID'])
? (int) $params['testSiteID']
: null;
if (empty($testCode)) {
throw new \Exception('INSERT_TEST requires testCode');
}
if ($testSiteID === null && !empty($params['testSiteCode'])) {
$testSiteCode = (string) $params['testSiteCode'];
// Look up TestSiteID from TestSiteCode
$testDefSiteModel = new TestDefSiteModel();
$row = $testDefSiteModel->where('TestSiteCode', $testSiteCode)->where('EndDate', null)->first();
$testSiteID = isset($row['TestSiteID']) ? (int) $row['TestSiteID'] : null;
$testSite = $testDefSiteModel->where('TestSiteCode', $testCode)
->where('EndDate', null)
->first();
if (!$testSite || empty($testSite['TestSiteID'])) {
throw new \Exception('INSERT_TEST: Test not found with code: ' . $testCode);
}
if ($testSiteID === null) {
throw new \Exception('SET_RESULT requires testSiteID or testSiteCode');
}
if (array_key_exists('valueExpr', $params) && is_string($params['valueExpr'])) {
$value = $this->expr->evaluate($params['valueExpr'], $context);
} else {
$value = $params['value'] ?? null;
}
$testSiteID = (int) $testSite['TestSiteID'];
$db = \Config\Database::connect();
$ok = $db->table('patres')
// Check if test already exists (avoid duplicates)
$existing = $db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $testSiteID)
->where('DelDate', null)
->update(['Result' => $value]);
->get()
->getRow();
if ($existing) {
// Test already exists, skip
return;
}
// Insert new test row
$ok = $db->table('patres')->insert([
'OrderID' => $internalOID,
'TestSiteID' => $testSiteID,
'TestSiteCode' => $testCode,
'CreateDate' => date('Y-m-d H:i:s'),
]);
if ($ok === false) {
throw new \Exception('SET_RESULT update failed');
throw new \Exception('INSERT_TEST insert failed');
}
}
/**
* Execute ADD_COMMENT action - Add a comment to the order
*/
protected function executeAddComment(array $action, array $context): void
{
$order = $context['order'] ?? null;
if (!is_array($order) || empty($order['InternalOID'])) {
throw new \Exception('ADD_COMMENT requires context.order.InternalOID');
}
$internalOID = (int) $order['InternalOID'];
$comment = $action['comment'] ?? null;
if (empty($comment)) {
throw new \Exception('ADD_COMMENT requires comment');
}
$db = \Config\Database::connect();
// Insert comment into ordercom table
$ok = $db->table('ordercom')->insert([
'InternalOID' => $internalOID,
'Comment' => $comment,
'CreateDate' => date('Y-m-d H:i:s'),
]);
if ($ok === false) {
throw new \Exception('ADD_COMMENT insert failed');
}
}
/**
* Execute TEST_DELETE action - soft delete a test from patres
*/
protected function executeDeleteTest(array $action, array $context): void
{
$order = $context['order'] ?? null;
if (!is_array($order) || empty($order['InternalOID'])) {
throw new \Exception('TEST_DELETE requires context.order.InternalOID');
}
$internalOID = (int) $order['InternalOID'];
$testCode = $action['testCode'] ?? null;
if (empty($testCode)) {
throw new \Exception('TEST_DELETE requires testCode');
}
$testDefSiteModel = new TestDefSiteModel();
$testSite = $testDefSiteModel->where('TestSiteCode', $testCode)
->where('EndDate', null)
->first();
if (!$testSite || empty($testSite['TestSiteID'])) {
// Unknown test code: no-op
return;
}
$testSiteID = (int) $testSite['TestSiteID'];
$db = \Config\Database::connect();
// Soft delete matching patres row(s)
$db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $testSiteID)
->where('DelDate', null)
->update(['DelDate' => date('Y-m-d H:i:s')]);
}
private function resolveTestSiteCode(int $testSiteID): ?string
{
try {
$testDefSiteModel = new TestDefSiteModel();
$row = $testDefSiteModel->where('TestSiteID', $testSiteID)->where('EndDate', null)->first();
return $row['TestSiteCode'] ?? null;
} catch (\Throwable $e) {
return null;
}
}
}

View File

@ -47,13 +47,26 @@ class RuleExpressionService
/**
* Compile DSL expression to engine-compatible JSON structure.
*
* Supported DSL:
* - if(condition ? action : action)
* - sex('F'|'M') -> order["Sex"] == 'F'
* - set_result(value) -> {"value": value} or {"valueExpr": "value"}
* 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 valueExpr
* @return array The compiled structure with actions
* @throws \InvalidArgumentException If DSL is invalid
*/
public function compile(string $expr): array
@ -63,109 +76,189 @@ class RuleExpressionService
return [];
}
// Remove outer parentheses from if(...)
if (preg_match('/^if\s*\(\s*(.+?)\s*\)$/s', $expr, $m)) {
$expr = trim($m[1]);
$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');
}
}
// Parse: condition ? thenAction : elseAction
if (!preg_match('/^(.+?)\s*\?\s*(.+?)\s*:\s*(.+)$/s', $expr, $parts)) {
throw new \InvalidArgumentException('Invalid DSL: expected "if(condition ? action : action)" 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);
}
}
$condition = trim($parts[1]);
$thenAction = trim($parts[2]);
$elseAction = trim($parts[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
$thenCompiled = $this->compileAction($thenAction);
$elseCompiled = $this->compileAction($elseAction);
// Compile actions (supports multi-action with : separator)
$thenActions = $this->compileMultiAction($thenAction);
$elseActions = $this->compileMultiAction($elseAction);
// Build valueExpr combining condition and actions
$thenValue = $thenCompiled['valueExpr'] ?? json_encode($thenCompiled['value'] ?? null);
$elseValue = $elseCompiled['valueExpr'] ?? json_encode($elseCompiled['value'] ?? null);
// Build valueExpr for backward compatibility
$thenValueExpr = $this->buildValueExpr($thenActions);
$elseValueExpr = $this->buildValueExpr($elseActions);
// Handle string vs numeric values
if (is_string($thenCompiled['value'] ?? null)) {
$thenValue = '"' . addslashes($thenCompiled['value']) . '"';
}
if (is_string($elseCompiled['value'] ?? null)) {
$elseValue = '"' . addslashes($elseCompiled['value']) . '"';
}
$valueExpr = "({$compiledCondition}) ? {$thenValue} : {$elseValue}";
$valueExpr = "({$compiledCondition}) ? {$thenValueExpr} : {$elseValueExpr}";
return [
'conditionExpr' => $compiledCondition,
'valueExpr' => $valueExpr,
'then' => $thenCompiled,
'else' => $elseCompiled,
'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);
// sex('F') -> order["Sex"] == 'F'
if (preg_match("/^sex\s*\(\s*['\"]([MF])['\"]\s*\)$/i", $condition, $m)) {
return 'order["Sex"] == "' . $m[1] . '"';
if ($condition === '') {
return 'true';
}
// sex == 'F' (alternative syntax)
if (preg_match('/^\s*sex\s*==\s*[\'"]([MF])[\'"]\s*$/i', $condition, $m)) {
return 'order["Sex"] == "' . $m[1] . '"';
$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;
}
// priority('S') -> order["Priority"] == 'S'
if (preg_match("/^priority\s*\(\s*['\"]([SR])['\"]\s*\)$/i", $condition, $m)) {
return 'order["Priority"] == "' . $m[1] . '"';
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' (alternative syntax)
if (preg_match('/^\s*priority\s*==\s*[\'"]([SR])[\'"]\s*$/i', $condition, $m)) {
return 'order["Priority"] == "' . $m[1] . '"';
// priority('S')
if ($lower === 'priority') {
$arg = $this->parseSingleStringArg($tokens, $i + 1);
if ($arg !== null) {
$out .= 'order["Priority"] == "' . addslashes($arg['value']) . '"';
$i = $arg['endIndex'];
continue;
}
}
// age > 18 -> patient["Age"] > 18 (if available) or order["Age"] > 18
if (preg_match('/^\s*age\s*([<>]=?)\s*(\d+)\s*$/i', $condition, $m)) {
return 'order["Age"] ' . $m[1] . ' ' . $m[2];
// requested('CODE')
if ($lower === 'requested') {
$arg = $this->parseSingleStringArg($tokens, $i + 1);
if ($arg !== null) {
$out .= 'requested("' . addslashes($arg['value']) . '")';
$i = $arg['endIndex'];
continue;
}
}
// If already valid ExpressionLanguage, return as-is
return $condition;
// 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 DSL action to action params
* Compile multi-action string (separated by :)
* Returns array of action objects
*/
private function compileAction(string $action): array
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);
// set_result(value) -> SET_RESULT action
if (preg_match('/^set_result\s*\(\s*(.+?)\s*\)$/i', $action, $m)) {
$value = trim($m[1]);
// 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' => 'SET_RESULT',
'type' => 'RESULT_SET',
'value' => strpos($value, '.') !== false ? (float) $value : (int) $value,
'valueExpr' => $value,
];
}
// Check if it's a quoted string
// Check if it's a quoted string (single or double quotes)
if (preg_match('/^["\'](.+)["\']$/s', $value, $vm)) {
return [
'type' => 'SET_RESULT',
'type' => 'RESULT_SET',
'value' => $vm[1],
'valueExpr' => '"' . addslashes($vm[1]) . '"',
];
@ -173,11 +266,404 @@ class RuleExpressionService
// Complex expression
return [
'type' => 'SET_RESULT',
'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,
];
}
}

280
docs/test-rule-engine.md Normal file
View File

@ -0,0 +1,280 @@
# Test Rule Engine Documentation
## Overview
The CLQMS Rule Engine evaluates business rules that inspect orders, patients, and tests, then executes actions when the compiled condition matches.
Rules are authored using a domain specific language stored in `ruledef.ConditionExpr`. Before the platform executes any rule, the DSL must be compiled into JSON and stored in `ConditionExprCompiled`, and each rule must be linked to the tests it should influence via `testrule`.
### Execution Flow
1. Write or edit the DSL in `ConditionExpr`.
2. POST the expression to `POST /api/rules/compile` to validate syntax and produce compiled JSON.
3. Save the compiled payload into `ConditionExprCompiled` and persist the rule in `ruledef`.
4. Link the rule to one or more tests through `testrule.TestSiteID` (rules only run for linked tests).
5. When the configured event fires (`test_created` or `result_updated`), the engine evaluates `ConditionExprCompiled` and runs the resulting `then` or `else` actions.
> **Note:** The rule engine currently fires only for `test_created` and `result_updated`. Other event codes can exist in the database but are not triggered by the application unless additional `RuleEngineService::run(...)` calls are added.
## Event Triggers
| Event Code | Status | Trigger Point |
|------------|--------|----------------|
| `test_created` | Active | Fired after a new test row is persisted; the handler calls `RuleEngineService::run('test_created', ...)` to evaluate test-scoped rules |
| `result_updated` | Active | Fired whenever a test result is saved or updated so result-dependent rules run immediately |
Other event codes remain in the database for future workflows, but only `test_created` and `result_updated` are executed by the current application flow.
## Rule Structure
```
Rule
├── Event Trigger (when to run)
├── Conditions (when to match)
└── Actions (what to do)
```
The DSL expression lives in `ConditionExpr`. The compile endpoint (`/api/rules/compile`) renders the lifeblood of execution, producing `conditionExpr`, `valueExpr`, `then`, and `else` nodes that the engine consumes at runtime.
## Syntax Guide
### Basic Format
```
if(condition; then-action; else-action)
```
### Logical Operators
- Use `&&` for AND (all sub-conditions must match).
- Use `||` for OR (any matching branch satisfies the rule).
- Surround mixed logic with parentheses for clarity and precedence.
### Multi-Action Syntax
Actions within any branch are separated by `:` and evaluated in order. Every `then` and `else` branch must end with an action; use `nothing` when no further work is required.
```
if(sex('M'); result_set(0.5):test_insert('HBA1C'); nothing)
```
### Multiple Rules
Create each rule as its own `ruledef` row; do not chain expressions with commas. The `testrule` table manages rule-to-test mappings, so multiple rules can attach to the same test. Example:
1. Insert `RULE_MALE_RESULT` and `RULE_SENIOR_COMMENT` in `ruledef`.
2. Add two `testrule` rows linking each rule to the appropriate `TestSiteID`.
Each rule compiles and runs independently when its trigger fires and the test is linked.
## Available Functions
### Conditions
| Function | Description | Example |
|----------|-------------|---------|
| `sex('M'|'F')` | Match patient sex | `sex('M')` |
| `priority('R'|'S'|'U')` | Match order priority | `priority('S')` |
| `age > 18` | Numeric age comparisons (`>`, `<`, `>=`, `<=`) | `age >= 18 && age <= 65` |
| `requested('CODE')` | Check whether the order already requested a test (queries `patres`) | `requested('GLU')` |
### Logical Operators
| Operator | Meaning | Example |
|----------|---------|---------|
| `&&` | AND (all truthy) | `sex('M') && age > 40` |
| `||` | OR (any truthy) | `sex('M') || age > 65` |
| `()` | Group expressions | `(sex('M') && age > 40) || priority('S')` |
## Actions
| Action | Description | Example |
|--------|-------------|---------|
| `result_set(value)` | Write to `patres.Result` for the current order/test using the provided value | `result_set(0.5)` |
| `test_insert('CODE')` | Insert a test row by `TestSiteCode` if it doesnt already exist for the order | `test_insert('HBA1C')` |
| `test_delete('CODE')` | Remove a previously requested test from the current order when the rule deems it unnecessary | `test_delete('INS')` |
| `comment_insert('text')` | Insert an order comment (`ordercom`) describing priority or clinical guidance | `comment_insert('Male patient - review')` |
| `nothing` | Explicit no-op to terminate an action chain | `nothing` |
> **Note:** `set_priority()` was removed. Use `comment_insert()` for priority notes without altering billing.
## Runtime Requirements
1. **Compiled expression required:** Rules without `ConditionExprCompiled` are ignored (see `RuleEngineService::run`).
2. **Order context:** `context.order.InternalOID` must exist for any action that writes to `patres` or `ordercom`.
3. **TestSiteID:** `result_set()` needs `testSiteID` (either provided in context or from `order.TestSiteID`). `test_insert()` resolves a `TestSiteID` via the `TestSiteCode` in `TestDefSiteModel`, and `test_delete()` removes the matching `TestSiteID` rows when needed.
4. **Requested check:** `requested('CODE')` inspects `patres` rows for the same `OrderID` and `TestSiteCode`.
## Examples
```
if(sex('M'); result_set(0.5); result_set(0.6))
```
Returns `0.5` for males, `0.6` otherwise.
```
if(requested('GLU'); test_insert('HBA1C'):test_insert('INS'); nothing)
```
Adds new tests when glucose is already requested.
```
if(sex('M') && age > 40; result_set(1.2); result_set(1.0))
```
```
if((sex('M') && age > 40) || (sex('F') && age > 50); result_set(1.5); result_set(1.0))
```
```
if(priority('S'); result_set('URGENT'):test_insert('STAT_TEST'); result_set('NORMAL'))
```
```
if(sex('M') && age > 40; result_set(1.5):test_insert('EXTRA_TEST'):comment_insert('Male over 40'); nothing)
```
```
if(sex('F') && (age >= 18 && age <= 50) && priority('S'); result_set('HIGH_PRIO'):comment_insert('Female stat 18-50'); result_set('NORMAL'))
```
```
if(requested('GLU'); test_delete('INS'):comment_insert('Duplicate insulin request removed'); nothing)
```
## API Usage
### Compile DSL
Validates the DSL and returns a compiled JSON structure that should be persisted in `ConditionExprCompiled`.
```http
POST /api/rules/compile
Content-Type: application/json
{
"expr": "if(sex('M'); result_set(0.5); result_set(0.6))"
}
```
The response contains `raw`, `compiled`, and `conditionExprCompiled` fields; store the JSON payload in `ConditionExprCompiled` before saving the rule.
### Evaluate Expression (Validation)
This endpoint simply evaluates an expression against a runtime context. It does not compile DSL or persist the result.
```http
POST /api/rules/validate
Content-Type: application/json
{
"expr": "order[\"Age\"] > 18",
"context": {
"order": {
"Age": 25
}
}
}
```
### Create Rule (example)
```http
POST /api/rules
Content-Type: application/json
{
"RuleCode": "RULE_001",
"RuleName": "Sex-based result",
"EventCode": "test_created",
"ConditionExpr": "if(sex('M'); result_set(0.5); result_set(0.6))",
"ConditionExprCompiled": "<compiled JSON here>",
"TestSiteIDs": [1, 2]
}
```
## Database Schema
### Tables
- **ruledef** stores rule metadata, raw DSL, and compiled JSON.
- **testrule** mapping table that links rules to tests via `TestSiteID`.
- **ruleaction** deprecated. Actions are now embedded in `ConditionExprCompiled`.
### Key Columns
| Column | Table | Description |
|--------|-------|-------------|
| `EventCode` | ruledef | The trigger event (typically `test_created` or `result_updated`). |
| `ConditionExpr` | ruledef | Raw DSL expression (semicolon syntax). |
| `ConditionExprCompiled` | ruledef | JSON payload consumed at runtime (`then`, `else`, etc.). |
| `ActionType` / `ActionParams` | ruleaction | Deprecated; actions live in compiled JSON now. |
## Best Practices
1. Always run `POST /api/rules/compile` before persisting a rule so `ConditionExprCompiled` exists.
2. Link each rule to the relevant tests via `testrule.TestSiteID`—rules are scoped to linked tests.
3. Use multi-action (`:`) to bundle several actions in a single branch; finish the branch with `nothing` if no further work is needed.
4. Prefer `comment_insert()` over the removed `set_priority()` action when documenting priority decisions.
5. Group complex boolean logic with parentheses for clarity when mixing `&&` and `||`.
6. Use `requested('CODE')` responsibly; it performs a database lookup on `patres` so avoid invoking it in high-frequency loops without reason.
## Migration Guide
### Syntax Changes (v2.0)
The DSL moved from ternary (`condition ? action : action`) to semicolon syntax. Existing rules must be migrated via the provided script.
| Old Syntax | New Syntax |
|------------|------------|
| `if(condition ? action : action)` | `if(condition; action; action)` |
#### Migration Examples
```
# BEFORE
if(sex('M') ? result_set(0.5) : result_set(0.6))
# AFTER
if(sex('M'); result_set(0.5); result_set(0.6))
```
```
# BEFORE
if(sex('F') ? set_priority('S') : nothing)
# AFTER
if(sex('F'); comment_insert('Female patient - review priority'); nothing)
```
#### Migration Process
Run the migration which:
1. Converts ternary syntax to semicolon syntax.
2. Recompiles every expression into JSON so the engine consumes `ConditionExprCompiled` directly.
3. Eliminates reliance on the `ruleaction` table.
```bash
php spark migrate
```
## Troubleshooting
### Rule Not Executing
1. Ensure the rule has a compiled payload (`ConditionExprCompiled`).
2. Confirm the rule is linked to the relevant `TestSiteID` in `testrule`.
3. Verify the `EventCode` matches the currently triggered event (`test_created` or `result_updated`).
4. Check that `EndDate IS NULL` for both `ruledef` and `testrule` (soft deletes disable execution).
5. Use `/api/rules/compile` to validate the DSL and view errors.
### Invalid Expression
1. POST the expression to `/api/rules/compile` to get a detailed compilation error.
2. If using `/api/rules/validate`, supply the expected `context` payload; the endpoint simply evaluates the expression without saving it.
### Runtime Errors
- `RESULT_SET requires context.order.InternalOID` or `testSiteID`: include those fields in the context passed to `RuleEngineService::run()`.
- `TEST_INSERT` failures mean the provided `TestSiteCode` does not exist or the rule attempted to insert a duplicate test; check `testdefsite` and existing `patres` rows.
- `COMMENT_INSERT requires comment`: ensure the action provides text.

View File

@ -3156,7 +3156,7 @@ paths:
type: string
EventCode:
type: string
example: ORDER_CREATED
example: test_created
TestSiteIDs:
type: array
items:
@ -3169,19 +3169,12 @@ paths:
ConditionExpr:
type: string
nullable: true
example: order["Priority"] == "S"
actions:
type: array
items:
type: object
properties:
ActionType:
description: Raw DSL expression. Compile it first and persist the compiled JSON in ConditionExprCompiled.
example: if(sex('M'); result_set(0.5); result_set(0.6))
ConditionExprCompiled:
type: string
example: SET_RESULT
ActionParams:
oneOf:
- type: string
- type: object
nullable: true
description: Compiled JSON payload from POST /api/rules/compile
required:
- RuleCode
- RuleName
@ -3194,7 +3187,7 @@ paths:
get:
tags:
- Rules
summary: Get rule with actions and linked tests
summary: Get rule with linked tests
security:
- bearerAuth: []
parameters:
@ -3206,7 +3199,7 @@ paths:
description: RuleID
responses:
'200':
description: Rule details with actions and linked test sites
description: Rule details with linked test sites
content:
application/json:
schema:
@ -3328,7 +3321,7 @@ paths:
expr:
type: string
description: Raw DSL expression
example: 'if(sex(''F'') ? set_result(0.7) : set_result(1))'
example: if(sex('M'); result_set(0.5); result_set(0.6))
required:
- expr
responses:
@ -3356,126 +3349,6 @@ paths:
description: JSON string to save to ConditionExprCompiled field
'400':
description: Compilation failed (invalid syntax)
/api/rules/{id}/actions:
get:
tags:
- Rules
summary: List actions for a rule
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: RuleID
responses:
'200':
description: Actions list
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '#/components/schemas/RuleAction'
post:
tags:
- Rules
summary: Create action for a rule
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: RuleID
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ActionType:
type: string
example: SET_RESULT
ActionParams:
oneOf:
- type: string
- type: object
required:
- ActionType
responses:
'201':
description: Action created
/api/rules/{id}/actions/{actionId}:
patch:
tags:
- Rules
summary: Update action
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: RuleID
- name: actionId
in: path
required: true
schema:
type: integer
description: RuleActionID
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ActionType:
type: string
ActionParams:
oneOf:
- type: string
- type: object
responses:
'200':
description: Action updated
delete:
tags:
- Rules
summary: Soft delete action
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: RuleID
- name: actionId
in: path
required: true
schema:
type: integer
description: RuleActionID
responses:
'200':
description: Action deleted
/api/specimen:
get:
tags:
@ -7385,12 +7258,12 @@ components:
type: string
nullable: true
description: Raw DSL expression (editable)
example: 'if(sex(''F'') ? set_result(0.7) : set_result(1))'
example: if(sex('M'); result_set(0.5); result_set(0.6))
ConditionExprCompiled:
type: string
nullable: true
description: Compiled JSON structure (auto-generated from ConditionExpr)
example: '{"conditionExpr":"order["Sex"] == \"F\"","valueExpr":"(order["Sex"] == \"F\") ? 0.7 : 1","then":{"type":"SET_RESULT","value":0.7,"valueExpr":"0.7"},"else":{"type":"SET_RESULT","value":1,"valueExpr":"1"}}'
example: '{"conditionExpr":"patient[\"Sex\"] == \"M\"","valueExpr":"(patient[\"Sex\"] == \"M\") ? 0.5 : 0.6","then":[{"type":"RESULT_SET","value":0.5,"valueExpr":"0.5"}],"else":[{"type":"RESULT_SET","value":0.6,"valueExpr":"0.6"}]}'
CreateDate:
type: string
format: date-time
@ -7403,38 +7276,11 @@ components:
type: string
format: date-time
nullable: true
RuleAction:
type: object
properties:
RuleActionID:
type: integer
RuleID:
type: integer
ActionType:
type: string
example: SET_RESULT
ActionParams:
type: string
description: JSON string parameters
nullable: true
example: '{"testSiteID": 1, "value": "Normal"}'
CreateDate:
type: string
format: date-time
nullable: true
EndDate:
type: string
format: date-time
nullable: true
RuleWithDetails:
allOf:
- $ref: '#/components/schemas/RuleDef'
- type: object
properties:
actions:
type: array
items:
$ref: '#/components/schemas/RuleAction'
linkedTests:
type: array
items:

View File

@ -191,8 +191,6 @@ components:
# Rules schemas
RuleDef:
$ref: './components/schemas/rules.yaml#/RuleDef'
RuleAction:
$ref: './components/schemas/rules.yaml#/RuleAction'
RuleWithDetails:
$ref: './components/schemas/rules.yaml#/RuleWithDetails'
TestRule:

View File

@ -69,10 +69,14 @@ console.log(`\n[SUCCESS] Merged paths into: ${tempFile}`);
// Now use Redocly CLI to resolve all $ref references
console.log('\nResolving $ref references with Redocly CLI...');
try {
execSync(`redocly bundle "${tempFile}" -o "${outputFile}"`, {
cwd: publicDir,
stdio: 'inherit'
});
const runBundle = (cmd) => execSync(cmd, { cwd: publicDir, stdio: 'inherit' });
try {
runBundle(`redocly bundle "${tempFile}" -o "${outputFile}"`);
} catch (e) {
// Fallback: use npx if redocly isn't installed globally
runBundle(`npx --yes @redocly/cli bundle "${tempFile}" -o "${outputFile}"`);
}
console.log(`\n[SUCCESS] Resolved all $ref references to: ${outputFile}`);
@ -91,6 +95,6 @@ try {
} catch (e) {
console.error(`\n[ERROR] Failed to run Redocly CLI: ${e.message}`);
console.error('Make sure Redocly CLI is installed: npm install -g @redocly/cli');
console.error('Make sure Redocly CLI is installed (global or via npx): npm install -g @redocly/cli');
process.exit(1);
}

View File

@ -19,12 +19,12 @@ RuleDef:
type: string
nullable: true
description: Raw DSL expression (editable)
example: "if(sex('F') ? set_result(0.7) : set_result(1))"
example: "if(sex('M'); result_set(0.5); result_set(0.6))"
ConditionExprCompiled:
type: string
nullable: true
description: Compiled JSON structure (auto-generated from ConditionExpr)
example: '{"conditionExpr":"order["Sex"] == \"F\"","valueExpr":"(order["Sex"] == \"F\") ? 0.7 : 1","then":{"type":"SET_RESULT","value":0.7,"valueExpr":"0.7"},"else":{"type":"SET_RESULT","value":1,"valueExpr":"1"}}'
example: '{"conditionExpr":"patient[\"Sex\"] == \"M\"","valueExpr":"(patient[\"Sex\"] == \"M\") ? 0.5 : 0.6","then":[{"type":"RESULT_SET","value":0.5,"valueExpr":"0.5"}],"else":[{"type":"RESULT_SET","value":0.6,"valueExpr":"0.6"}]}'
CreateDate:
type: string
format: date-time
@ -38,39 +38,11 @@ RuleDef:
format: date-time
nullable: true
RuleAction:
type: object
properties:
RuleActionID:
type: integer
RuleID:
type: integer
ActionType:
type: string
example: SET_RESULT
ActionParams:
type: string
description: JSON string parameters
nullable: true
example: '{"testSiteID": 1, "value": "Normal"}'
CreateDate:
type: string
format: date-time
nullable: true
EndDate:
type: string
format: date-time
nullable: true
RuleWithDetails:
allOf:
- $ref: './rules.yaml#/RuleDef'
- type: object
properties:
actions:
type: array
items:
$ref: './rules.yaml#/RuleAction'
linkedTests:
type: array
items:

View File

@ -62,7 +62,7 @@
type: string
EventCode:
type: string
example: ORDER_CREATED
example: test_created
TestSiteIDs:
type: array
items:
@ -72,19 +72,12 @@
ConditionExpr:
type: string
nullable: true
example: 'order["Priority"] == "S"'
actions:
type: array
items:
type: object
properties:
ActionType:
description: Raw DSL expression. Compile it first and persist the compiled JSON in ConditionExprCompiled.
example: "if(sex('M'); result_set(0.5); result_set(0.6))"
ConditionExprCompiled:
type: string
example: SET_RESULT
ActionParams:
oneOf:
- type: string
- type: object
nullable: true
description: Compiled JSON payload from POST /api/rules/compile
required: [RuleCode, RuleName, EventCode, TestSiteIDs]
responses:
'201':
@ -93,7 +86,7 @@
/api/rules/{id}:
get:
tags: [Rules]
summary: Get rule with actions and linked tests
summary: Get rule with linked tests
security:
- bearerAuth: []
parameters:
@ -105,7 +98,7 @@
description: RuleID
responses:
'200':
description: Rule details with actions and linked test sites
description: Rule details with linked test sites
content:
application/json:
schema:
@ -220,7 +213,7 @@
expr:
type: string
description: Raw DSL expression
example: "if(sex('F') ? set_result(0.7) : set_result(1))"
example: "if(sex('M'); result_set(0.5); result_set(0.6))"
required: [expr]
responses:
'200':
@ -247,121 +240,3 @@
description: JSON string to save to ConditionExprCompiled field
'400':
description: Compilation failed (invalid syntax)
/api/rules/{id}/actions:
get:
tags: [Rules]
summary: List actions for a rule
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: RuleID
responses:
'200':
description: Actions list
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '../components/schemas/rules.yaml#/RuleAction'
post:
tags: [Rules]
summary: Create action for a rule
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: RuleID
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ActionType:
type: string
example: SET_RESULT
ActionParams:
oneOf:
- type: string
- type: object
required: [ActionType]
responses:
'201':
description: Action created
/api/rules/{id}/actions/{actionId}:
patch:
tags: [Rules]
summary: Update action
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: RuleID
- name: actionId
in: path
required: true
schema:
type: integer
description: RuleActionID
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ActionType: { type: string }
ActionParams:
oneOf:
- type: string
- type: object
responses:
'200':
description: Action updated
delete:
tags: [Rules]
summary: Soft delete action
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: RuleID
- name: actionId
in: path
required: true
schema:
type: integer
description: RuleActionID
responses:
'200':
description: Action deleted

View File

@ -25,7 +25,7 @@ class RuleDefModelTest extends CIUnitTestCase
*/
public function testGetActiveByEventReturnsEmptyWithoutTestSiteID(): void
{
$rules = $this->model->getActiveByEvent('ORDER_CREATED', null);
$rules = $this->model->getActiveByEvent('test_created', null);
$this->assertIsArray($rules);
$this->assertEmpty($rules);
@ -56,7 +56,7 @@ class RuleDefModelTest extends CIUnitTestCase
$ruleData = [
'RuleCode' => 'MULTI_TEST_RULE',
'RuleName' => 'Multi Test Rule',
'EventCode' => 'ORDER_CREATED',
'EventCode' => 'test_created',
'ConditionExpr' => 'order["InternalOID"] > 0',
'CreateDate' => date('Y-m-d H:i:s'),
];
@ -69,12 +69,12 @@ class RuleDefModelTest extends CIUnitTestCase
$this->model->linkTest($ruleID, $testSiteID2);
// Verify rule is returned for both test sites
$rules1 = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID1);
$rules1 = $this->model->getActiveByEvent('test_created', $testSiteID1);
$this->assertNotEmpty($rules1);
$this->assertCount(1, $rules1);
$this->assertEquals($ruleID, $rules1[0]['RuleID']);
$rules2 = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID2);
$rules2 = $this->model->getActiveByEvent('test_created', $testSiteID2);
$this->assertNotEmpty($rules2);
$this->assertCount(1, $rules2);
$this->assertEquals($ruleID, $rules2[0]['RuleID']);
@ -107,7 +107,7 @@ class RuleDefModelTest extends CIUnitTestCase
$ruleData = [
'RuleCode' => 'UNLINKED_RULE',
'RuleName' => 'Unlinked Test Rule',
'EventCode' => 'ORDER_CREATED',
'EventCode' => 'test_created',
'ConditionExpr' => 'true',
'CreateDate' => date('Y-m-d H:i:s'),
];
@ -116,14 +116,14 @@ class RuleDefModelTest extends CIUnitTestCase
$this->assertNotFalse($ruleID);
// Verify rule is NOT returned when not linked
$rules = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID);
$rules = $this->model->getActiveByEvent('test_created', $testSiteID);
$this->assertEmpty($rules);
// Now link the rule
$this->model->linkTest($ruleID, $testSiteID);
// Verify rule is now returned
$rules = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID);
$rules = $this->model->getActiveByEvent('test_created', $testSiteID);
$this->assertNotEmpty($rules);
$this->assertCount(1, $rules);
@ -156,7 +156,7 @@ class RuleDefModelTest extends CIUnitTestCase
$ruleData = [
'RuleCode' => 'UNLINK_TEST',
'RuleName' => 'Unlink Test Rule',
'EventCode' => 'ORDER_CREATED',
'EventCode' => 'test_created',
'ConditionExpr' => 'true',
'CreateDate' => date('Y-m-d H:i:s'),
];
@ -166,15 +166,15 @@ class RuleDefModelTest extends CIUnitTestCase
$this->model->linkTest($ruleID, $testSiteID2);
// Verify rule is returned for both
$this->assertNotEmpty($this->model->getActiveByEvent('ORDER_CREATED', $testSiteID1));
$this->assertNotEmpty($this->model->getActiveByEvent('ORDER_CREATED', $testSiteID2));
$this->assertNotEmpty($this->model->getActiveByEvent('test_created', $testSiteID1));
$this->assertNotEmpty($this->model->getActiveByEvent('test_created', $testSiteID2));
// Unlink from first test
$this->model->unlinkTest($ruleID, $testSiteID1);
// Verify rule is NOT returned for first test but still for second
$this->assertEmpty($this->model->getActiveByEvent('ORDER_CREATED', $testSiteID1));
$this->assertNotEmpty($this->model->getActiveByEvent('ORDER_CREATED', $testSiteID2));
$this->assertEmpty($this->model->getActiveByEvent('test_created', $testSiteID1));
$this->assertNotEmpty($this->model->getActiveByEvent('test_created', $testSiteID2));
// Cleanup
$this->model->delete($ruleID);
@ -203,7 +203,7 @@ class RuleDefModelTest extends CIUnitTestCase
$ruleData = [
'RuleCode' => 'DELETED_RULE',
'RuleName' => 'Deleted Test Rule',
'EventCode' => 'ORDER_CREATED',
'EventCode' => 'test_created',
'ConditionExpr' => 'true',
'CreateDate' => date('Y-m-d H:i:s'),
];
@ -213,14 +213,14 @@ class RuleDefModelTest extends CIUnitTestCase
$this->model->linkTest($ruleID, $testSiteID);
// Verify rule is returned
$rules = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID);
$rules = $this->model->getActiveByEvent('test_created', $testSiteID);
$this->assertNotEmpty($rules);
// Soft delete the rule
$this->model->delete($ruleID);
// Verify deleted rule is NOT returned
$rules = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID);
$rules = $this->model->getActiveByEvent('test_created', $testSiteID);
$this->assertEmpty($rules);
}
@ -249,7 +249,7 @@ class RuleDefModelTest extends CIUnitTestCase
$ruleData = [
'RuleCode' => 'LINKED_TESTS',
'RuleName' => 'Linked Tests Rule',
'EventCode' => 'ORDER_CREATED',
'EventCode' => 'test_created',
'ConditionExpr' => 'true',
'CreateDate' => date('Y-m-d H:i:s'),
];

View File

@ -0,0 +1,355 @@
<?php
namespace Tests\Unit\Rules;
use App\Services\RuleEngineService;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
/**
* Integration tests for Rule Engine with multi-action support
*/
class RuleEngineMultiActionTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected RuleEngineService $engine;
protected $seed = [];
public function setUp(): void
{
parent::setUp();
$this->engine = new RuleEngineService();
// Seed test data
$this->seedTestData();
}
private function seedTestData(): void
{
$db = \Config\Database::connect();
// Insert test testdefsite
$db->table('testdefsite')->insert([
'TestSiteCode' => 'GLU',
'TestSiteName' => 'Glucose',
'CreateDate' => date('Y-m-d H:i:s'),
]);
$db->table('testdefsite')->insert([
'TestSiteCode' => 'HBA1C',
'TestSiteName' => 'HbA1c',
'CreateDate' => date('Y-m-d H:i:s'),
]);
// Insert test order
$db->table('orders')->insert([
'OrderID' => 'ORD001',
'Sex' => 'M',
'Age' => 45,
'Priority' => 'R',
'CreateDate' => date('Y-m-d H:i:s'),
]);
}
public function testExecuteSetResult()
{
$db = \Config\Database::connect();
// Get order ID
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
$internalOID = $order['InternalOID'] ?? null;
// Get test site ID
$testSite = $db->table('testdefsite')->where('TestSiteCode', 'GLU')->get()->getRowArray();
$testSiteID = $testSite['TestSiteID'] ?? null;
$this->assertNotNull($internalOID);
$this->assertNotNull($testSiteID);
// Insert initial patres row
$db->table('patres')->insert([
'OrderID' => $internalOID,
'TestSiteID' => $testSiteID,
'CreateDate' => date('Y-m-d H:i:s'),
]);
// Execute RESULT_SET action
$action = [
'type' => 'RESULT_SET',
'value' => 5.5,
];
$context = [
'order' => [
'InternalOID' => $internalOID,
'Sex' => 'M',
'Age' => 45,
],
'testSiteID' => $testSiteID,
];
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
// Verify result was set
$patres = $db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $testSiteID)
->where('DelDate', null)
->get()->getRowArray();
$this->assertNotNull($patres);
$this->assertEquals(5.5, $patres['Result']);
}
public function testExecuteInsertTest()
{
$db = \Config\Database::connect();
// Get order ID
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
$internalOID = $order['InternalOID'] ?? null;
$this->assertNotNull($internalOID);
// Execute TEST_INSERT action
$action = [
'type' => 'TEST_INSERT',
'testCode' => 'HBA1C',
];
$context = [
'order' => [
'InternalOID' => $internalOID,
],
];
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
// Verify test was inserted
$testSite = $db->table('testdefsite')->where('TestSiteCode', 'HBA1C')->get()->getRowArray();
$testSiteID = $testSite['TestSiteID'] ?? null;
$patres = $db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $testSiteID)
->where('DelDate', null)
->get()->getRowArray();
$this->assertNotNull($patres);
}
public function testExecuteAddComment()
{
$db = \Config\Database::connect();
// Get order ID
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
$internalOID = $order['InternalOID'] ?? null;
$this->assertNotNull($internalOID);
// Execute COMMENT_INSERT action
$action = [
'type' => 'COMMENT_INSERT',
'comment' => 'Test comment from rule engine',
];
$context = [
'order' => [
'InternalOID' => $internalOID,
],
];
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
// Verify comment was added
$comment = $db->table('ordercom')
->where('InternalOID', $internalOID)
->where('Comment', 'Test comment from rule engine')
->get()->getRowArray();
$this->assertNotNull($comment);
}
public function testExecuteNoOp()
{
// Execute NO_OP action - should not throw or do anything
$action = [
'type' => 'NO_OP',
];
$context = [
'order' => [
'InternalOID' => 1,
],
];
// Should not throw exception
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
// Test passes if no exception is thrown
$this->assertTrue(true);
}
public function testMultiActionExecution()
{
$db = \Config\Database::connect();
// Get order ID
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
$internalOID = $order['InternalOID'] ?? null;
// Get test site ID
$testSite = $db->table('testdefsite')->where('TestSiteCode', 'GLU')->get()->getRowArray();
$testSiteID = $testSite['TestSiteID'] ?? null;
$this->assertNotNull($internalOID);
$this->assertNotNull($testSiteID);
// Insert initial patres row
$db->table('patres')->insert([
'OrderID' => $internalOID,
'TestSiteID' => $testSiteID,
'CreateDate' => date('Y-m-d H:i:s'),
]);
// Execute multiple actions
$actions = [
[
'type' => 'RESULT_SET',
'value' => 7.2,
],
[
'type' => 'COMMENT_INSERT',
'comment' => 'Multi-action test',
],
[
'type' => 'TEST_INSERT',
'testCode' => 'HBA1C',
],
];
$context = [
'order' => [
'InternalOID' => $internalOID,
],
'testSiteID' => $testSiteID,
];
foreach ($actions as $action) {
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
}
// Verify SET_RESULT
$patres = $db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $testSiteID)
->where('DelDate', null)
->get()->getRowArray();
$this->assertEquals(7.2, $patres['Result']);
// Verify ADD_COMMENT
$comment = $db->table('ordercom')
->where('InternalOID', $internalOID)
->where('Comment', 'Multi-action test')
->get()->getRowArray();
$this->assertNotNull($comment);
// Verify INSERT_TEST
$hba1cSite = $db->table('testdefsite')->where('TestSiteCode', 'HBA1C')->get()->getRowArray();
$hba1cPatres = $db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $hba1cSite['TestSiteID'])
->where('DelDate', null)
->get()->getRowArray();
$this->assertNotNull($hba1cPatres);
}
public function testIsTestRequested()
{
$db = \Config\Database::connect();
// Get order ID
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
$internalOID = $order['InternalOID'] ?? null;
// Get test site ID
$testSite = $db->table('testdefsite')->where('TestSiteCode', 'GLU')->get()->getRowArray();
$testSiteID = $testSite['TestSiteID'] ?? null;
// Insert patres row for GLU test
$db->table('patres')->insert([
'OrderID' => $internalOID,
'TestSiteID' => $testSiteID,
'CreateDate' => date('Y-m-d H:i:s'),
]);
// Test isTestRequested method
$result = $this->invokeMethod($this->engine, 'isTestRequested', ['GLU', [
'order' => ['InternalOID' => $internalOID],
]]);
$this->assertTrue($result);
// Test for non-existent test
$result = $this->invokeMethod($this->engine, 'isTestRequested', ['NONEXISTENT', [
'order' => ['InternalOID' => $internalOID],
]]);
$this->assertFalse($result);
}
public function testExecuteDeleteTest(): void
{
$db = \Config\Database::connect();
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
$internalOID = $order['InternalOID'] ?? null;
$this->assertNotNull($internalOID);
$testSite = $db->table('testdefsite')->where('TestSiteCode', 'HBA1C')->get()->getRowArray();
$testSiteID = $testSite['TestSiteID'] ?? null;
$this->assertNotNull($testSiteID);
// Insert a patres row to delete
$db->table('patres')->insert([
'OrderID' => $internalOID,
'TestSiteID' => $testSiteID,
'CreateDate' => date('Y-m-d H:i:s'),
]);
$action = [
'type' => 'TEST_DELETE',
'testCode' => 'HBA1C',
];
$context = [
'order' => [
'InternalOID' => $internalOID,
],
];
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
$deleted = $db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $testSiteID)
->get()
->getRowArray();
$this->assertNotNull($deleted);
$this->assertNotEmpty($deleted['DelDate'] ?? null);
}
/**
* Helper to invoke protected/private methods
*/
private function invokeMethod($object, $methodName, array $parameters = [])
{
$reflection = new \ReflectionClass(get_class($object));
$method = $reflection->getMethod($methodName);
$method->setAccessible(true);
return $method->invokeArgs($object, $parameters);
}
}

View File

@ -19,25 +19,25 @@ class RuleExpressionCompileTest extends CIUnitTestCase
{
$svc = new RuleExpressionService();
$compiled = $svc->compile("if(sex('F') ? set_result(0.7) : set_result(1))");
$compiled = $svc->compile("if(sex('F') ? result_set(0.7) : result_set(1))");
$this->assertIsArray($compiled);
$this->assertEquals('order["Sex"] == "F"', $compiled['conditionExpr']);
$this->assertEquals(0.7, $compiled['then']['value']);
$this->assertEquals(1, $compiled['else']['value']);
$this->assertStringContainsString('order["Sex"] == "F"', $compiled['valueExpr']);
$this->assertEquals('patient["Sex"] == "F"', $compiled['conditionExpr']);
$this->assertEquals(0.7, $compiled['then'][0]['value']);
$this->assertEquals(1, $compiled['else'][0]['value']);
$this->assertStringContainsString('patient["Sex"] == "F"', $compiled['valueExpr']);
}
public function testCompilePriorityCondition(): void
{
$svc = new RuleExpressionService();
$compiled = $svc->compile("if(priority('S') ? set_result('urgent') : set_result('normal'))");
$compiled = $svc->compile("if(priority('S') ? result_set('urgent') : result_set('normal'))");
$this->assertIsArray($compiled);
$this->assertEquals('order["Priority"] == "S"', $compiled['conditionExpr']);
$this->assertEquals('urgent', $compiled['then']['value']);
$this->assertEquals('normal', $compiled['else']['value']);
$this->assertEquals('urgent', $compiled['then'][0]['value']);
$this->assertEquals('normal', $compiled['else'][0]['value']);
}
public function testCompileInvalidSyntax(): void

View File

@ -0,0 +1,346 @@
<?php
namespace Tests\Unit\Rules;
use App\Services\RuleExpressionService;
use CodeIgniter\Test\CIUnitTestCase;
/**
* Tests for Rule DSL syntax - semicolon syntax, multi-actions, operators
*/
class RuleExpressionSyntaxTest extends CIUnitTestCase
{
protected RuleExpressionService $service;
public function setUp(): void
{
parent::setUp();
$this->service = new RuleExpressionService();
}
// ============================================
// SYNTAX TESTS
// ============================================
public function testSemicolonSyntaxBasic()
{
$result = $this->service->compile('if(sex("M"); result_set(0.5); result_set(0.6))');
$this->assertArrayHasKey('conditionExpr', $result);
$this->assertArrayHasKey('valueExpr', $result);
$this->assertArrayHasKey('then', $result);
$this->assertArrayHasKey('else', $result);
$this->assertEquals('patient["Sex"] == "M"', $result['conditionExpr']);
$this->assertEquals('0.5', $result['then'][0]['valueExpr']);
$this->assertEquals('0.6', $result['else'][0]['valueExpr']);
}
public function testTernarySyntaxFallback()
{
// Legacy ternary syntax should still work (fallback)
$result = $this->service->compile('if(sex("M") ? result_set(0.5) : result_set(0.6))');
$this->assertArrayHasKey('conditionExpr', $result);
$this->assertEquals('patient["Sex"] == "M"', $result['conditionExpr']);
}
// ============================================
// MULTI-ACTION TESTS
// ============================================
public function testMultiActionWithColon()
{
$result = $this->service->compile('if(sex("M"); result_set(0.5):test_insert("HBA1C"); nothing)');
$this->assertCount(2, $result['then']);
$this->assertEquals('RESULT_SET', $result['then'][0]['type']);
$this->assertEquals(0.5, $result['then'][0]['value']);
$this->assertEquals('TEST_INSERT', $result['then'][1]['type']);
$this->assertEquals('HBA1C', $result['then'][1]['testCode']);
$this->assertEquals('NO_OP', $result['else'][0]['type']);
}
public function testThreeActions()
{
$result = $this->service->compile('if(priority("S"); result_set("URGENT"):test_insert("STAT_TEST"):comment_insert("Stat order"); nothing)');
$this->assertCount(3, $result['then']);
$this->assertEquals('RESULT_SET', $result['then'][0]['type']);
$this->assertEquals('URGENT', $result['then'][0]['value']);
$this->assertEquals('TEST_INSERT', $result['then'][1]['type']);
$this->assertEquals('STAT_TEST', $result['then'][1]['testCode']);
$this->assertEquals('COMMENT_INSERT', $result['then'][2]['type']);
$this->assertEquals('Stat order', $result['then'][2]['comment']);
}
public function testMultiActionInElse()
{
$result = $this->service->compile('if(sex("M"); result_set(1.0); result_set(0.8):comment_insert("Default"))');
$this->assertCount(1, $result['then']);
$this->assertCount(2, $result['else']);
$this->assertEquals('RESULT_SET', $result['else'][0]['type']);
$this->assertEquals(0.8, $result['else'][0]['value']);
$this->assertEquals('COMMENT_INSERT', $result['else'][1]['type']);
}
// ============================================
// LOGICAL OPERATOR TESTS
// ============================================
public function testAndOperator()
{
$result = $this->service->compile('if(sex("M") && age > 40; result_set(1.2); result_set(1.0))');
$this->assertStringContainsString('and', strtolower($result['conditionExpr']));
$this->assertStringContainsString('patient["Sex"] == "M"', $result['conditionExpr']);
$this->assertStringContainsString('age > 40', $result['conditionExpr']);
}
public function testOrOperator()
{
$result = $this->service->compile('if(sex("M") || age > 65; result_set(1.0); result_set(0.8))');
$this->assertStringContainsString('or', strtolower($result['conditionExpr']));
$this->assertStringContainsString('patient["Sex"] == "M"', $result['conditionExpr']);
$this->assertStringContainsString('age > 65', $result['conditionExpr']);
}
public function testCombinedAndOr()
{
$result = $this->service->compile('if((sex("M") && age > 40) || (sex("F") && age > 50); result_set(1.5); result_set(1.0))');
$this->assertStringContainsString('or', strtolower($result['conditionExpr']));
$this->assertStringContainsString('and', strtolower($result['conditionExpr']));
}
public function testComplexNestedCondition()
{
$result = $this->service->compile('if(sex("M") && (age > 40 || priority("S")); result_set(1.2); nothing)');
$this->assertStringContainsString('patient["Sex"] == "M"', $result['conditionExpr']);
$this->assertStringContainsString('or', strtolower($result['conditionExpr']));
}
// ============================================
// ACTION TESTS
// ============================================
public function testNothingAction()
{
$result = $this->service->compile('if(sex("M"); result_set(0.5); nothing)');
$this->assertEquals('NO_OP', $result['else'][0]['type']);
}
public function testNothingActionInThen()
{
$result = $this->service->compile('if(sex("M"); nothing; result_set(0.6))');
$this->assertEquals('NO_OP', $result['then'][0]['type']);
}
public function testInsertAction()
{
$result = $this->service->compile('if(requested("GLU"); test_insert("HBA1C"); nothing)');
$this->assertEquals('TEST_INSERT', $result['then'][0]['type']);
$this->assertEquals('HBA1C', $result['then'][0]['testCode']);
}
public function testAddCommentAction()
{
$result = $this->service->compile('if(sex("M"); comment_insert("Male patient"); nothing)');
$this->assertEquals('COMMENT_INSERT', $result['then'][0]['type']);
$this->assertEquals('Male patient', $result['then'][0]['comment']);
}
// ============================================
// CONDITION TESTS
// ============================================
public function testSexCondition()
{
$result = $this->service->compile('if(sex("F"); result_set(0.7); result_set(1.0))');
$this->assertEquals('patient["Sex"] == "F"', $result['conditionExpr']);
}
public function testPriorityCondition()
{
$result = $this->service->compile('if(priority("S"); result_set("URGENT"); result_set("NORMAL"))');
$this->assertEquals('order["Priority"] == "S"', $result['conditionExpr']);
}
public function testPriorityUrgent()
{
$result = $this->service->compile('if(priority("U"); result_set("CRITICAL"); nothing)');
$this->assertEquals('order["Priority"] == "U"', $result['conditionExpr']);
}
public function testAgeCondition()
{
$result = $this->service->compile('if(age > 18; result_set(1.0); result_set(0.5))');
$this->assertEquals('age > 18', $result['conditionExpr']);
}
public function testAgeGreaterThanEqual()
{
$result = $this->service->compile('if(age >= 18; result_set(1.0); nothing)');
$this->assertEquals('age >= 18', $result['conditionExpr']);
}
public function testAgeLessThan()
{
$result = $this->service->compile('if(age < 65; result_set(1.0); nothing)');
$this->assertEquals('age < 65', $result['conditionExpr']);
}
public function testAgeRange()
{
$result = $this->service->compile('if(age >= 18 && age <= 65; result_set(1.0); nothing)');
$this->assertStringContainsString('age >= 18', $result['conditionExpr']);
$this->assertStringContainsString('age <= 65', $result['conditionExpr']);
}
public function testRequestedCondition()
{
$result = $this->service->compile('if(requested("GLU"); test_insert("HBA1C"); nothing)');
$this->assertStringContainsString('requested', $result['conditionExpr']);
$this->assertStringContainsString('GLU', $result['conditionExpr']);
}
// ============================================
// MULTI-RULE TESTS
// ============================================
public function testParseMultiRule()
{
$expr = 'if(sex("M"); result_set(0.5); nothing), if(age > 65; result_set(1.0); nothing)';
$rules = $this->service->parseMultiRule($expr);
$this->assertCount(2, $rules);
$this->assertStringContainsString('sex("M")', $rules[0]);
$this->assertStringContainsString('age > 65', $rules[1]);
}
public function testParseMultiRuleThreeRules()
{
$expr = 'if(sex("M"); result_set(0.5); nothing), if(age > 65; result_set(1.0); nothing), if(priority("S"); result_set(2.0); nothing)';
$rules = $this->service->parseMultiRule($expr);
$this->assertCount(3, $rules);
}
// ============================================
// ERROR HANDLING TESTS
// ============================================
public function testInvalidSyntaxThrowsException()
{
$this->expectException(\InvalidArgumentException::class);
$this->service->compile('invalid syntax here');
}
public function testUnknownActionThrowsException()
{
$this->expectException(\InvalidArgumentException::class);
$this->service->compile('if(sex("M"); unknown_action(); nothing)');
}
public function testEmptyExpressionReturnsEmptyArray()
{
$result = $this->service->compile('');
$this->assertEmpty($result);
}
// ============================================
// DOCUMENTATION EXAMPLES
// ============================================
public function testExample1SexBasedResult()
{
// Example 1 from docs
$result = $this->service->compile('if(sex("M"); result_set(0.5); result_set(0.6))');
$this->assertEquals('RESULT_SET', $result['then'][0]['type']);
$this->assertEquals(0.5, $result['then'][0]['value']);
$this->assertEquals('RESULT_SET', $result['else'][0]['type']);
$this->assertEquals(0.6, $result['else'][0]['value']);
}
public function testExample2ConditionalTestInsertion()
{
// Example 2 from docs
$result = $this->service->compile("if(requested('GLU'); test_insert('HBA1C'):test_insert('INS'); nothing)");
$this->assertCount(2, $result['then']);
$this->assertEquals('TEST_INSERT', $result['then'][0]['type']);
$this->assertEquals('HBA1C', $result['then'][0]['testCode']);
$this->assertEquals('INS', $result['then'][1]['testCode']);
}
public function testExample3MultipleConditionsAnd()
{
// Example 3 from docs
$result = $this->service->compile("if(sex('M') && age > 40; result_set(1.2); result_set(1.0))");
$this->assertStringContainsString('and', strtolower($result['conditionExpr']));
}
public function testExample4OrCondition()
{
// Example 4 from docs
$result = $this->service->compile("if(sex('M') || age > 65; result_set(1.0); result_set(0.8))");
$this->assertStringContainsString('or', strtolower($result['conditionExpr']));
}
public function testExample5CombinedAndOr()
{
// Example 5 from docs
$result = $this->service->compile("if((sex('M') && age > 40) || (sex('F') && age > 50); result_set(1.5); result_set(1.0))");
$this->assertStringContainsString('or', strtolower($result['conditionExpr']));
}
public function testExample6MultipleActions()
{
// Example 6 from docs
$result = $this->service->compile("if(priority('S'); result_set('URGENT'):test_insert('STAT_TEST'); result_set('NORMAL'))");
$this->assertCount(2, $result['then']);
$this->assertEquals('RESULT_SET', $result['then'][0]['type']);
$this->assertEquals('TEST_INSERT', $result['then'][1]['type']);
}
public function testExample7ThreeActions()
{
// Example 7 from docs
$result = $this->service->compile("if(sex('M') && age > 40; result_set(1.5):test_insert('EXTRA_TEST'):comment_insert('Male over 40'); nothing)");
$this->assertCount(3, $result['then']);
$this->assertEquals('RESULT_SET', $result['then'][0]['type']);
$this->assertEquals('TEST_INSERT', $result['then'][1]['type']);
$this->assertEquals('COMMENT_INSERT', $result['then'][2]['type']);
}
public function testExample8ComplexRule()
{
// Example 8 from docs
$result = $this->service->compile("if(sex('F') && (age >= 18 && age <= 50) && priority('S'); result_set('HIGH_PRIO'):comment_insert('Female stat 18-50'); result_set('NORMAL'))");
$this->assertCount(2, $result['then']);
$this->assertStringContainsString('and', strtolower($result['conditionExpr']));
}
}