Appearance
polyspec/fsm-php
The PHP consumer of fsm (docs/plans/fsm-v1.md, C-2). The package is private and is a test bed: it is not published. It shows how the generated PHP code of a fsm definition is used, and it checks that code against the conformance vectors. The test bench generates the namespace Polyspec\FsmTest; your project chooses its own namespace with --php-namespace, and each machine is a sub-namespace <ns>\<Pascal machine key>, such as Polyspec\FsmTest\Order.
Generate into your project
Generate into the source directory of your package, with the namespace of your code. composer.json must autoload that directory with PSR-4, and the loader src/functions.php through autoload.files. The loader requires the functions.php of every machine, so the one files entry loads the functions of all machines:
sh
fsm-gen gen --project /path/to/order-machines --kind php --php-namespace 'Acme\Fsm' --out ./srctext
[fsm-gen] gen: okThe autoload block of packages/php/composer.json is:
json
{
"autoload": {
"psr-4": {
"Polyspec\\FsmTest\\": "src/"
},
"files": [
"src/functions.php"
]
}
}The test bed's namespace is Polyspec\FsmTest. For the namespace of this README (Acme\Fsm), your composer.json has "psr-4": { "Acme\\Fsm\\": "src/" } and the same files entry, src/functions.php. Run composer dump-autoload once after you write composer.json; the generated files need no new dump.
Run the command again after every change of the definition. The vectors come from fsm-gen vectors --kind php --php-namespace 'Acme\Fsm' --out <file>, written into a test file of your own (the test bed writes tests/ConformanceTest.php).
Generated files
| File | Content |
|---|---|
src/Fsm/Code.php | the enum Code (backed by string): Conflict = 'E_CONFLICT', InvalidTransition, UnknownCode, ReservedCode |
src/Fsm/Error.php | the exception Error of the shared namespace |
src/Order/State.php, src/Order/Event.php | the backed enums State: int (the value is the code) and Event: int (the position, from 1) |
src/Order/Step.php | the final class Step with the readonly properties to and changed |
src/Order/functions.php | the functions of the machine, in the namespace <ns>\Order; the loader requires it |
src/functions.php | the loader: it requires the functions.php of every machine (src/InReview/functions.php, src/Order/functions.php and so on); composer.json names this file in autoload.files |
Each file starts with // Code generated by fsm-gen from <machine>; DO NOT EDIT. and declares strict_types.
API
The functions of the namespace Polyspec\FsmTest\Order (src/Order/functions.php) take the enum cases:
| Function | Result |
|---|---|
initial(): State | the initial state |
code(State $state): int | the code of a state |
key(State $state): string | the key of a state, such as draft |
fromCode(mixed $value): State | the state of a code; a value that is not an int, or that is no state, throws E_UNKNOWN_CODE |
isFinal(State $state): bool | whether a state is final |
eventsFrom(State $state): array | the events that have an edge from a state, in declaration order |
target(State $expected, Event $event): State | the state that the event leads to; throws E_INVALID_TRANSITION when it has no edge |
apply(State $expected, Event $event, State $actual): Step | the result of one attempt (see the root README, Transitions) |
The persistence example examples/persist.php holds the complete apply use inside a transaction; it is the pattern to copy (see Persistence). The pure call for a value that is not in a database:
php
<?php
use Acme\Fsm\Order\State;
use Acme\Fsm\Order\Event;
use Acme\Fsm\Order;
use Acme\Fsm\Fsm\Error;
$step = Order\apply(State::Draft, Event::Next, State::Draft);
echo $step->to->name, ' ', var_export($step->changed, true), "\n";
try {
Order\apply(State::Draft, Event::Next, State::Shipped);
} catch (Error $error) {
echo $error->getCode(), ' ', $error->expected, ' ', $error->actual, ' ', $error->event, "\n";
}This program was run in a temporary project with composer dump-autoload and the generated files of this checkout. Its output:
text
Paid true
E_CONFLICT 1 3 1Errors
The functions throw Polyspec\FsmTest\Fsm\Error (here Acme\Fsm\Fsm\Error), an \Exception:
| Member | Value |
|---|---|
getCode() | the code string: E_CONFLICT, E_INVALID_TRANSITION, E_UNKNOWN_CODE or E_RESERVED_CODE. It is the value of the enum Code, because PHP keeps the protected $code of Exception as an int |
getMessage() | the message, which names the machine |
expected | the expected state code (?int), or null when the error has none |
actual | the stored state code (?int), or null |
event | the event position (?int, from 1), or null |
Catch Error and read getCode() and the three properties.
Which properties a code sets is the rule of spec B-6 (docs/spec/fsm.md), and the vectors of tests/ConformanceTest.php assert it:
| Code | expected | actual | event |
|---|---|---|---|
E_CONFLICT | set | set | set |
E_INVALID_TRANSITION | set | null | set |
E_UNKNOWN_CODE, E_RESERVED_CODE | null | null | null |
Persistence
examples/persist.php is the pattern of docs/spec/fsm.md, B-4 with polyspec orm on SQLite, PostgreSQL and MySQL (the DSN given to open selects the dialect). The stored state is a code in a row. A transition runs in one transaction: the row is read under its lock (forUpdate()), the event is applied to the state that the caller observed, and the row is updated only when the step is changed:
php
// packages/php/examples/persist.php, transition
function transition(Db $db, string $module, int $id, \UnitEnum $expected, \UnitEnum $event): object
{
return $db->transaction(static function () use ($db, $module, $id, $expected, $event): object {
$row = (new Item())->connect($db)->id($id)->forUpdate()->get();
$apply = $module . '\apply';
$fromCode = $module . '\fromCode';
$code = $module . '\code';
$step = $apply($expected, $event, $fromCode($row->getStatus()));
if ($step->changed) {
$row->setStatus($code($step->to))->update();
}
return $step;
});
}$module is the namespace of the machine (such as Polyspec\FsmTest\Order), so the same function serves every machine. An error of apply is thrown inside the transaction and rolls it back. A retry with the same $expected returns an unchanged step and writes nothing. open, seed and statusOf in the same file create the tables and read a row. The model Item is generated by orm-gen from contracts/schema/persist.dbs into examples/model/, and examples/model/bootstrap.php is loaded when a database is opened.
Tests
From the checkout:
sh
make -C packages/php install # composer install: the pinned orm release and PHPUnit 13.4.2 (composer.lock)
make -C packages/php test # PHPUnit through kit's run-tests
make test-php # the same from the root
make php-persist-test-postgres # the persist cases on PostgreSQL (FSM_TEST_PG_DSN; a missing DSN exits 2)
make php-persist-test-mysql # the persist cases on MySQL (FSM_TEST_MYSQL_DSN)The run passes 89 of 89 cases. tests/ConformanceTest.php, generated by fsm-gen vectors --kind php, runs its one PHPUnit test testConformance on every vector case, a data set named <feature>__<id>, and each runs the check of the hand-written tests/ConformanceCase.php; the persist cases run examples/persist.php on SQLite, and make php-persist-test-postgres and make php-persist-test-mysql run them on PostgreSQL and MySQL. The root make gen-check checks that the generated files are the ones fsm-gen writes today. The PHP release of the checkout is 8.5 (config/toolchain.json); config.platform.php in composer.json is 8.5.0, the PHP pin of kit at the precision of composerPlatforms (config/dependency-policy.json), and require.php is >=8.5.