From 9ea0716ef5dff9505a91c74adae8b36a6ca2df19 Mon Sep 17 00:00:00 2001 From: Marc Reichel Date: Mon, 12 Oct 2026 22:51:33 +0200 Subject: [PATCH 1/2] Add AsDecision, an Eloquent cast that stores decision objects in a JSON column --- README.md | 20 +++ src/DecisionMapper.php | 68 ++++++++- .../InvalidDecisionCastException.php | 8 + src/Laravel/AsDecision.php | 70 +++++++++ tests/AsDecisionTest.php | 143 ++++++++++++++++++ 5 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 src/Exceptions/InvalidDecisionCastException.php create mode 100644 src/Laravel/AsDecision.php create mode 100644 tests/AsDecisionTest.php diff --git a/README.md b/README.md index a3e5832..eda2279 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,26 @@ final class ClassifyTicket implements ShouldQueue } ``` +To store a decision in a JSON column, cast the attribute with `AsDecision`: + +```php +use MarcReichel\Laya\Laravel\AsDecision; + +protected function casts(): array +{ + return ['triage' => AsDecision::of(Triage::class)]; // or AsDecision::class.':'.Triage::class +} +``` + +```php +$ticket->triage = $laya->decide($ticket->body, Triage::class); +$ticket->save(); + +$ticket->triage->department; // Department::Billing +``` + +It stores the constructor arguments by name, enums by backing value (`{"department":"billing","urgency":2,"churn":true}`), and reads them back through the constructor, so `readonly` classes work. Lists (`#[Of]`), floats and `null` (below `minConfidence`) round-trip; a `null` column is a `null` attribute. Assigning anything but an instance of the class, or reading JSON with a missing key or an unknown enum value, throws an `InvalidDecisionCastException` that names the model attribute. + To change the config file, publish it with `php artisan vendor:publish --tag=laya-config`. In tests, `Laya::fake([...])` also replaces the container's instance, so injected code gets the fake (see [Testing your code](#testing-your-code)): ```php diff --git a/src/DecisionMapper.php b/src/DecisionMapper.php index 02e64ca..6f9d160 100644 --- a/src/DecisionMapper.php +++ b/src/DecisionMapper.php @@ -26,7 +26,7 @@ * * @internal * - * @phpstan-type Entry array{questions: array, parameters: list|null}>} + * @phpstan-type Entry array{questions: array, parameters: list|null, nullable: bool}>} */ final class DecisionMapper { @@ -99,6 +99,71 @@ public static function values(string $class, Result $result): array return $arguments; } + /** + * The decision's constructor arguments by parameter name; json_encode() turns enums, and lists of them, into backing values. + * + * @param class-string $class + * @return array + */ + public static function toArray(string $class, object $decision): array + { + $data = []; + foreach (self::entry($class)['parameters'] as ['name' => $name]) { + $data[$name] = $decision->$name; + } + + return $data; + } + + /** + * The inverse of toArray(), through the constructor. + * + * @template T of object + * + * @param class-string $class + * @param array $data + * @return T + * + * @throws \UnexpectedValueException when $data has no value, or one of the wrong type, for a parameter + */ + public static function fromArray(string $class, array $data): object + { + $arguments = []; + foreach (self::entry($class)['parameters'] as ['name' => $name, 'type' => $type, 'of' => $of, 'nullable' => $nullable]) { + if (! array_key_exists($name, $data)) { + throw new \UnexpectedValueException(sprintf('has no value for $%s', $name)); + } + $value = $data[$name]; + if ($value === null && $nullable) { + $arguments[$name] = null; + } elseif ($of !== null) { + is_array($value) || throw new \UnexpectedValueException(sprintf('has no list for $%s', $name)); + $arguments[$name] = array_map(static fn (mixed $case): \BackedEnum => self::stored($name, $of, $case), $value); + } elseif (is_subclass_of($type, \BackedEnum::class)) { + $arguments[$name] = self::stored($name, $type, $value); + } elseif (get_debug_type($value) === $type || ($type === 'float' && is_int($value))) { // int widens to float in the constructor call + $arguments[$name] = $value; + } else { + throw new \UnexpectedValueException(sprintf('has %s for $%s, not %s', get_debug_type($value), $name, $type)); + } + } + + return new $class(...$arguments); + } + + /** @param class-string<\BackedEnum> $enum */ + private static function stored(string $name, string $enum, mixed $value): \BackedEnum + { + try { + $case = is_int($value) || is_string($value) ? $enum::tryFrom($value) : null; + } catch (\TypeError) { // strict_types: an int for a string enum, or a string for an int enum + $case = null; + } + + return $case + ?? throw new \UnexpectedValueException(sprintf('has %s for $%s, which isn\'t a %s case', json_encode($value), $name, new \ReflectionClass($enum)->getShortName())); + } + /** * @param class-string $class * @return Entry @@ -128,6 +193,7 @@ private static function entry(string $class): array 'ask' => $ask, 'scale' => self::isScale($type), 'of' => $type === 'array' ? self::of($class, $parameter) : null, + 'nullable' => $parameter->allowsNull(), ]; } diff --git a/src/Exceptions/InvalidDecisionCastException.php b/src/Exceptions/InvalidDecisionCastException.php new file mode 100644 index 0000000..0c80729 --- /dev/null +++ b/src/Exceptions/InvalidDecisionCastException.php @@ -0,0 +1,8 @@ + AsDecision::of(Triage::class)`. + * Enums are stored by backing value; reading goes through the constructor, so readonly classes work. + */ +final class AsDecision implements Castable +{ + /** @param class-string $class */ + public static function of(string $class): string + { + return self::class.':'.$class; + } + + /** + * @param array{0?: class-string} $arguments + * @return CastsAttributes + */ + public static function castUsing(array $arguments): CastsAttributes + { + $class = $arguments[0] ?? throw new \InvalidArgumentException('AsDecision needs a decision class: AsDecision::of(Triage::class).'); + + return new readonly class($class) implements CastsAttributes + { + /** @param class-string $class */ + public function __construct(private string $class) {} + + /** @param string|null $value */ + public function get(Model $model, string $key, mixed $value, array $attributes): ?object + { + if ($value === null) { + return null; + } + + try { + $data = json_decode($value, true); + if (! is_array($data)) { + throw new \UnexpectedValueException('is not a JSON object'); + } + + return DecisionMapper::fromArray($this->class, $data); + } catch (\UnexpectedValueException $e) { + throw new InvalidDecisionCastException(sprintf('%s::$%s holds a value that doesn\'t fit %s: it %s.', $model::class, $key, $this->class, $e->getMessage()), previous: $e); + } + } + + public function set(Model $model, string $key, mixed $value, array $attributes): ?string + { + if ($value === null) { + return null; + } + if (! $value instanceof $this->class) { + throw new InvalidDecisionCastException(sprintf('%s::$%s takes a %s, got %s.', $model::class, $key, $this->class, get_debug_type($value))); + } + + return json_encode(DecisionMapper::toArray($this->class, $value), JSON_THROW_ON_ERROR); + } + }; + } +} diff --git a/tests/AsDecisionTest.php b/tests/AsDecisionTest.php new file mode 100644 index 0000000..8af6623 --- /dev/null +++ b/tests/AsDecisionTest.php @@ -0,0 +1,143 @@ + AsDecision::of(Triage::class), + 'string_form' => AsDecision::class.':'.Triage::class, + 'areas' => AsDecision::of(Areas::class), + 'coded' => AsDecision::of(Coded::class), + 'urgent' => AsDecision::of(Urgent::class), + 'prospect' => AsDecision::of(Prospect::class), + ]; + } +} + +beforeEach(function () { + config(['database.default' => 'testing', 'database.connections.testing' => ['driver' => 'sqlite', 'database' => ':memory:']]); + Schema::create('tickets', function ($table) { + $table->id(); + foreach (['triage', 'string_form', 'areas', 'coded', 'urgent', 'prospect'] as $column) { + $table->json($column)->nullable(); + } + }); +}); + +it('round-trips a decision through the database', function () { + $ticket = CastTicket::create(['triage' => new Triage(Department::Billing, 2, true)]); + + expect(DB::table('tickets')->value('triage'))->toBe('{"department":"billing","urgency":2,"churn":true}'); + + $triage = CastTicket::find($ticket->id)->triage; + expect($triage)->toEqual(new Triage(Department::Billing, 2, true)) + ->and($triage->department)->toBe(Department::Billing); +}); + +it('accepts the string form', function () { + $ticket = CastTicket::create(['string_form' => new Triage(Department::Other, 0, false)]); + + expect($ticket->fresh()->string_form)->toEqual(new Triage(Department::Other, 0, false)); +}); + +it('maps null to null in both directions, and keeps null parameters', function () { + $ticket = CastTicket::create(['triage' => null]); + expect($ticket->fresh()->triage)->toBeNull(); + + $ticket->triage = new Triage(Department::Technical, 1, null); + $ticket->save(); + expect($ticket->fresh()->triage->churn)->toBeNull(); +}); + +it('round-trips enum lists, int enums, scale enums and floats', function () { + $ticket = CastTicket::create([ + 'areas' => new Areas([Area::Docs, Area::Billing], true), + 'coded' => new Coded(Code::One), + 'urgent' => new Urgent(Urgency::High), + 'prospect' => new Prospect(1.5, 1), + ]); + + expect(DB::table('tickets')->value('areas'))->toBe('{"areas":["docs.api","billing"],"churn":true}') + ->and(DB::table('tickets')->value('coded'))->toBe('{"code":1}'); + + $fresh = $ticket->fresh(); + expect($fresh->areas)->toEqual(new Areas([Area::Docs, Area::Billing], true)) + ->and($fresh->coded)->toEqual(new Coded(Code::One)) + ->and($fresh->urgent)->toEqual(new Urgent(Urgency::High)) + ->and($fresh->prospect->budget)->toBe(1.0); +}); + +it('round-trips a null list', function () { + $ticket = CastTicket::create(['areas' => new Areas(null, false)]); + + expect($ticket->fresh()->areas->areas)->toBeNull(); +}); + +it('rejects a value that is not the decision class', function (mixed $value, string $given) { + expect(fn () => new CastTicket(['triage' => $value()])) + ->toThrow(InvalidDecisionCastException::class, sprintf('CastTicket::$triage takes a %s, got %s.', Triage::class, $given)); +})->with([ + 'other object' => [fn () => new Urgent(Urgency::Low), Urgent::class], + 'array' => [fn () => ['department' => 'billing'], 'array'], +]); + +it('names the attribute when the stored JSON does not fit', function (string $json, string $problem) { + DB::table('tickets')->insert(['triage' => $json]); + + expect(fn () => CastTicket::first()->triage) + ->toThrow(InvalidDecisionCastException::class, sprintf('CastTicket::$triage holds a value that doesn\'t fit %s: it %s.', Triage::class, $problem)); +})->with([ + 'missing key' => ['{"department":"billing","urgency":2}', 'has no value for $churn'], + 'unknown enum value' => ['{"department":"sales","urgency":2,"churn":true}', 'has "sales" for $department, which isn\'t a Department case'], + 'array enum value' => ['{"department":["billing"],"urgency":2,"churn":true}', 'has ["billing"] for $department, which isn\'t a Department case'], + 'wrong type' => ['{"department":"billing","urgency":"2","churn":true}', 'has string for $urgency, not int'], + 'int for bool' => ['{"department":"billing","urgency":2,"churn":1}', 'has int for $churn, not bool'], + 'null in non-null' => ['{"department":null,"urgency":2,"churn":true}', 'has null for $department, which isn\'t a Department case'], + 'not an object' => ['5', 'is not a JSON object'], + 'invalid json' => ['{', 'is not a JSON object'], +]); + +it('rejects a list that is not a list', function () { + DB::table('tickets')->insert(['areas' => '{"areas":"billing","churn":true}']); + + expect(fn () => CastTicket::first()->areas)->toThrow(InvalidDecisionCastException::class, 'it has no list for $areas'); +}); + +it('needs a decision class', function () { + expect(fn () => AsDecision::castUsing([]))->toThrow(InvalidArgumentException::class); +}); + +it('rejects a string for an int-backed enum', function () { + DB::table('tickets')->insert(['coded' => '{"code":"1"}']); + + expect(fn () => CastTicket::first()->coded)->toThrow(InvalidDecisionCastException::class, 'it has "1" for $code, which isn\'t a Code case'); +}); From 98be6c7c4ed61043a700d7ad562b0a8f3910f378 Mon Sep 17 00:00:00 2001 From: Marc Reichel Date: Mon, 12 Oct 2026 23:02:35 +0200 Subject: [PATCH 2/2] Support private promoted properties and keep JSON objects distinct from enum lists in AsDecision --- src/DecisionMapper.php | 2 +- src/Laravel/AsDecision.php | 6 +++--- tests/AsDecisionTest.php | 26 +++++++++++++++++++++++++- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/DecisionMapper.php b/src/DecisionMapper.php index 6f9d160..52bf028 100644 --- a/src/DecisionMapper.php +++ b/src/DecisionMapper.php @@ -109,7 +109,7 @@ public static function toArray(string $class, object $decision): array { $data = []; foreach (self::entry($class)['parameters'] as ['name' => $name]) { - $data[$name] = $decision->$name; + $data[$name] = new \ReflectionProperty($class, $name)->getValue($decision); // also reads private promoted properties } return $data; diff --git a/src/Laravel/AsDecision.php b/src/Laravel/AsDecision.php index 1557b88..882dcb7 100644 --- a/src/Laravel/AsDecision.php +++ b/src/Laravel/AsDecision.php @@ -43,12 +43,12 @@ public function get(Model $model, string $key, mixed $value, array $attributes): } try { - $data = json_decode($value, true); - if (! is_array($data)) { + $data = json_decode($value); + if (! $data instanceof \stdClass) { throw new \UnexpectedValueException('is not a JSON object'); } - return DecisionMapper::fromArray($this->class, $data); + return DecisionMapper::fromArray($this->class, (array) $data); } catch (\UnexpectedValueException $e) { throw new InvalidDecisionCastException(sprintf('%s::$%s holds a value that doesn\'t fit %s: it %s.', $model::class, $key, $this->class, $e->getMessage()), previous: $e); } diff --git a/tests/AsDecisionTest.php b/tests/AsDecisionTest.php index 8af6623..d4efcb4 100644 --- a/tests/AsDecisionTest.php +++ b/tests/AsDecisionTest.php @@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use MarcReichel\Laya\Attributes\Ask; use MarcReichel\Laya\Exceptions\InvalidDecisionCastException; use MarcReichel\Laya\Laravel\AsDecision; use Orchestra\Testbench\TestCase; @@ -22,6 +23,16 @@ uses(TestCase::class); +final readonly class Hidden +{ + public function __construct(#[Ask('Which department?')] private Department $department) {} + + public function department(): Department + { + return $this->department; + } +} + final class CastTicket extends Model { protected $table = 'tickets'; @@ -39,6 +50,7 @@ protected function casts(): array 'coded' => AsDecision::of(Coded::class), 'urgent' => AsDecision::of(Urgent::class), 'prospect' => AsDecision::of(Prospect::class), + 'hidden' => AsDecision::of(Hidden::class), ]; } } @@ -47,7 +59,7 @@ protected function casts(): array config(['database.default' => 'testing', 'database.connections.testing' => ['driver' => 'sqlite', 'database' => ':memory:']]); Schema::create('tickets', function ($table) { $table->id(); - foreach (['triage', 'string_form', 'areas', 'coded', 'urgent', 'prospect'] as $column) { + foreach (['triage', 'string_form', 'areas', 'coded', 'urgent', 'prospect', 'hidden'] as $column) { $table->json($column)->nullable(); } }); @@ -132,6 +144,18 @@ protected function casts(): array expect(fn () => CastTicket::first()->areas)->toThrow(InvalidDecisionCastException::class, 'it has no list for $areas'); }); +it('rejects a JSON object where a list belongs', function () { + DB::table('tickets')->insert(['areas' => '{"areas":{"extra":"docs.api"},"churn":true}']); + + expect(fn () => CastTicket::first()->areas)->toThrow(InvalidDecisionCastException::class, 'it has no list for $areas'); +}); + +it('stores a decision with private promoted properties', function () { + $ticket = CastTicket::create(['hidden' => new Hidden(Department::Other)]); + + expect($ticket->fresh()->hidden->department())->toBe(Department::Other); +}); + it('needs a decision class', function () { expect(fn () => AsDecision::castUsing([]))->toThrow(InvalidArgumentException::class); });