Skip to content
Merged
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 67 additions & 1 deletion src/DecisionMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
*
* @internal
*
* @phpstan-type Entry array{questions: array<string, Question>, parameters: list<array{name: string, type: string, ask: Ask, scale: bool, of: class-string<\BackedEnum>|null}>}
* @phpstan-type Entry array{questions: array<string, Question>, parameters: list<array{name: string, type: string, ask: Ask, scale: bool, of: class-string<\BackedEnum>|null, nullable: bool}>}
*/
final class DecisionMapper
{
Expand Down Expand Up @@ -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<string, mixed>
*/
public static function toArray(string $class, object $decision): array
{
$data = [];
foreach (self::entry($class)['parameters'] as ['name' => $name]) {
$data[$name] = new \ReflectionProperty($class, $name)->getValue($decision); // also reads private promoted properties
}

return $data;
}

/**
* The inverse of toArray(), through the constructor.
*
* @template T of object
*
* @param class-string<T> $class
* @param array<array-key, mixed> $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
Expand Down Expand Up @@ -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(),
];
}

Expand Down
8 changes: 8 additions & 0 deletions src/Exceptions/InvalidDecisionCastException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

declare(strict_types=1);

namespace MarcReichel\Laya\Exceptions;

/** An Eloquent attribute cast with AsDecision was given, or read back, something that isn't its decision class. */
final class InvalidDecisionCastException extends \UnexpectedValueException implements LayaException {}
70 changes: 70 additions & 0 deletions src/Laravel/AsDecision.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace MarcReichel\Laya\Laravel;

use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use MarcReichel\Laya\DecisionMapper;
use MarcReichel\Laya\Exceptions\InvalidDecisionCastException;

/**
* Stores a decision object in a JSON column: `'triage' => 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<object|null, object|null>
*/
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);
if (! $data instanceof \stdClass) {
throw new \UnexpectedValueException('is not a JSON object');
}

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);
}
}

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);
}
};
}
}
167 changes: 167 additions & 0 deletions tests/AsDecisionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

declare(strict_types=1);

use App\Decisions\Area;
use App\Decisions\Areas;
use App\Decisions\Code;
use App\Decisions\Coded;
use App\Decisions\Department;
use App\Decisions\Prospect;
use App\Decisions\Triage;
use App\Decisions\Urgency;
use App\Decisions\Urgent;
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;

require_once __DIR__.'/Fixtures/Decisions.php';

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';

protected $guarded = [];

public $timestamps = false;

protected function casts(): array
{
return [
'triage' => 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),
'hidden' => AsDecision::of(Hidden::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', 'hidden'] 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('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);
});

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');
});
Loading