Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions ProcessMaker/Models/FormalExpression.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use ProcessMaker\Exception\SyntaxErrorException;
use ProcessMaker\Nayra\Bpmn\BaseTrait;
use ProcessMaker\Nayra\Contracts\Bpmn\FormalExpressionInterface;
use ProcessMaker\Support\PmFunctionRegistry;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\ExpressionLanguage\SyntaxError;
use Throwable;
Expand All @@ -31,8 +32,6 @@ class FormalExpression implements FormalExpressionInterface
'FEEL' => ['feelExpression', 'feelEncode'],
];

private static $pmFunctions = [];

/**
* FEEL expression object to be used to evaluate
* @var ExpressionLanguage
Expand All @@ -55,7 +54,29 @@ protected function initFormalExpression()
*/
public function registerPMFunction($name, callable $callable)
{
static::$pmFunctions[$name] = $callable;
PmFunctionRegistry::register($name, $callable);

// Also register into the current ExpressionLanguage instance if initialized
if ($this->feelExpression instanceof ExpressionLanguage) {
$this->feelExpression->register(
$name,
function () {
},
$callable
);
}
}

/**
* Reset runtime-registered PM functions.
*
* Called by Octane's RequestTerminated listener to clear request-scoped
* functions and prevent accumulation across requests. Boot-time functions
* are preserved in the registry and re-registered on the next request.
*/
public static function resetPmFunctions(): void
{
PmFunctionRegistry::reset();
}

/**
Expand Down Expand Up @@ -274,7 +295,7 @@ function ($arguments, $array, $key, $default) {
);

// Register global PM functions from packages
foreach (static::$pmFunctions as $name => $callable) {
foreach (PmFunctionRegistry::all() as $name => $callable) {
$this->feelExpression->register(
$name,
function () {
Expand Down
2 changes: 2 additions & 0 deletions ProcessMaker/Octane/ResetRequestState.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace ProcessMaker\Octane;

use ProcessMaker\Listeners\HandleRedirectListener;
use ProcessMaker\Models\FormalExpression;
use ProcessMaker\Providers\ProcessMakerServiceProvider;

final class ResetRequestState
Expand All @@ -13,5 +14,6 @@ public function handle(): void
{
ProcessMakerServiceProvider::beginRequestTiming();
HandleRedirectListener::reset();
FormalExpression::resetPmFunctions();
}
}
50 changes: 50 additions & 0 deletions ProcessMaker/Support/PmFunctionRegistry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace ProcessMaker\Support;

/**
* Bounded registry for PM functions.
*
* Stores callables by name and supports resetting to boot-time state.
* Designed as a standalone utility to avoid static state in FormalExpression.
*/
class PmFunctionRegistry
{
private static array $functions = [];

private static array $bootFunctions = [];

private static bool $bootTracking = true;

public static function register(string $name, callable $callable): void
{
self::$functions[$name] = $callable;

if (self::$bootTracking && !isset(self::$bootFunctions[$name])) {
self::$bootFunctions[$name] = $callable;
}
}

public static function all(): array
{
return self::$functions;
}

public static function reset(): void
{
self::$bootTracking = false;
self::$functions = self::$bootFunctions;
}

/**
* Clear all registered functions.
*
* Intended for test isolation or complete teardown.
*/
public static function clear(): void
{
self::$functions = [];
self::$bootFunctions = [];
self::$bootTracking = true;
}
}
180 changes: 180 additions & 0 deletions tests/unit/ProcessMaker/Models/FormalExpressionOctaneTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<?php

declare(strict_types=1);

namespace Tests\Unit\ProcessMaker\Models;

use ProcessMaker\Models\FormalExpression;
use ProcessMaker\Octane\ResetRequestState;
use ProcessMaker\Support\PmFunctionRegistry;
use Tests\TestCase;

/**
* Test that pmFunctions registry does not leak state between Octane requests.
*/
class FormalExpressionOctaneTest extends TestCase
{
private function registry(): PmFunctionRegistry
{
// Fresh registry instance for test isolation
return app()->make(PmFunctionRegistry::class);
}

public function test_pm_functions_do_not_accumulate_across_requests(): void
{
PmFunctionRegistry::clear();
FormalExpression::resetPmFunctions();

// Request 1: register a custom PM function (runtime registration)
$formalExp1 = new FormalExpression();
$formalExp1->setLanguage('FEEL');
$formalExp1->setBody('customFn("test") == "test"');

$reflection = new \ReflectionClass($formalExp1);
$method = $reflection->getMethod('registerPMFunction');
$method->setAccessible(true);
$method->invoke($formalExp1, 'customFn', function ($arguments, $arg) {
return (string) $arg;
});

$this->assertCount(1, $this->registry()->all());

// Simulate Octane RequestTerminated reset
$resetState = new ResetRequestState();
$resetState->handle();

// After reset, runtime-registered functions should be cleared
$this->assertCount(0, $this->registry()->all());

// Request 2: register another custom function after reset
$formalExp2 = new FormalExpression();
$formalExp2->setLanguage('FEEL');

$reflection2 = new \ReflectionClass($formalExp2);
$method2 = $reflection2->getMethod('registerPMFunction');
$method2->setAccessible(true);
$method2->invoke($formalExp2, 'anotherFn', function ($arguments, $arg) {
return strtoupper((string) $arg);
});

// Only the new function should exist after reset
$this->assertCount(1, $this->registry()->all());
$this->assertArrayHasKey('anotherFn', $this->registry()->all());
}

public function test_built_in_functions_are_always_available(): void
{
$formalExp = new FormalExpression();
$formalExp->setLanguage('FEEL');
$formalExp->setBody('date("Y") > 1900');
$this->assertTrue($formalExp([]));
}

public function test_custom_pm_function_is_evaluable(): void
{
$formalExp = new FormalExpression();
$formalExp->setLanguage('FEEL');
$formalExp->setBody('greet("World") == "Hello, World!"');

$reflection = new \ReflectionClass($formalExp);
$method = $reflection->getMethod('registerPMFunction');
$method->setAccessible(true);
$method->invoke($formalExp, 'greet', function ($arguments, $name) {
return 'Hello, ' . $name . '!';
});

$this->assertTrue($formalExp([]));
}

public function test_registering_same_function_twice_overwrites(): void
{
$formalExp = new FormalExpression();
$formalExp->setLanguage('FEEL');

$reflection = new \ReflectionClass($formalExp);
$method = $reflection->getMethod('registerPMFunction');
$method->setAccessible(true);

$method->invoke($formalExp, 'double', function ($arguments, $x) {
return $x * 2;
});

$method->invoke($formalExp, 'double', function ($arguments, $x) {
return $x * 3;
});

$formalExp->setBody('double(2) == 6');
$this->assertTrue($formalExp([]));
}

public function test_pm_functions_static_array_does_not_grow_with_instances(): void
{
PmFunctionRegistry::clear();
FormalExpression::resetPmFunctions();

$initialCount = count($this->registry()->all());

for ($i = 0; $i < 10; $i++) {
$exp = new FormalExpression();
$exp->setLanguage('FEEL');
}

$this->assertCount($initialCount, $this->registry()->all());
}

public function test_reset_pm_functions_restores_boot_time_state(): void
{
PmFunctionRegistry::clear();

// Register a boot-time function
$formalExp = new FormalExpression();
$reflection = new \ReflectionClass($formalExp);
$method = $reflection->getMethod('registerPMFunction');
$method->setAccessible(true);
$method->invoke($formalExp, 'bootFn', function () {
});

// Mark current state as boot baseline
FormalExpression::resetPmFunctions();

// Register a runtime function after the boot baseline
$method->invoke($formalExp, 'runtimeFn', function () {
});

$this->assertCount(2, $this->registry()->all());

// Reset should clear runtime functions, keeping only boot-time
FormalExpression::resetPmFunctions();

$this->assertCount(1, $this->registry()->all());
$this->assertArrayHasKey('bootFn', $this->registry()->all());
$this->assertArrayNotHasKey('runtimeFn', $this->registry()->all());
}

public function test_boot_time_functions_survive_reset(): void
{
PmFunctionRegistry::clear();

// Register a boot-time function
$formalExp = new FormalExpression();
$reflection = new \ReflectionClass($formalExp);
$method = $reflection->getMethod('registerPMFunction');
$method->setAccessible(true);
$bootFn = function () {
};
$method->invoke($formalExp, 'bootFn', $bootFn);

// Mark current state as boot baseline
FormalExpression::resetPmFunctions();

// Register a runtime function after the boot baseline
$method->invoke($formalExp, 'runtimeFn', function () {
});

// Reset should preserve bootFn
FormalExpression::resetPmFunctions();

$this->assertCount(1, $this->registry()->all());
$this->assertSame($bootFn, $this->registry()->all()['bootFn']);
}
}
Loading