From 033b9ee5d7635146329bf68c7751a7c9713bc8a7 Mon Sep 17 00:00:00 2001 From: Ark4ne Date: Fri, 28 Nov 2025 11:00:42 +0100 Subject: [PATCH] feat: add typed array support with new ValueArray class and related tests --- CHANGELOG-1.6.md | 13 + readme.md | 186 +++- src/Descriptors/Values.php | 36 +- src/Descriptors/Values/ValueArray.php | 30 +- tests/Unit/Descriptors/TypedArrayTest.php | 119 +++ .../Unit/Resources/TypedArrayResourceTest.php | 959 ++++++++++++++++++ 6 files changed, 1316 insertions(+), 27 deletions(-) create mode 100644 CHANGELOG-1.6.md create mode 100644 tests/Unit/Descriptors/TypedArrayTest.php create mode 100644 tests/Unit/Resources/TypedArrayResourceTest.php diff --git a/CHANGELOG-1.6.md b/CHANGELOG-1.6.md new file mode 100644 index 0000000..e34251c --- /dev/null +++ b/CHANGELOG-1.6.md @@ -0,0 +1,13 @@ +# Changelog - Version 1.6 + +## [1.6.0] - 2025-01-28 + +### Added + +- **Typed Arrays Support**: Added support for typed arrays in resource attributes, allowing type-safe array elements + - New `of()` method on `array()` descriptor to specify element type + - Support for class references (e.g., `ValueString::class`) in addition to descriptor instances + - Alternative `arrayOf()` helper method for more concise syntax + - Full support for nested typed arrays (multi-dimensional arrays) + - Compatible with all conditional methods (`when()`, `whenNotNull()`, `whenFilled()`, etc.) + - Type casting applied to all array elements (strings, integers, floats, booleans, etc.) \ No newline at end of file diff --git a/readme.md b/readme.md index 438ddb7..93a7d5c 100644 --- a/readme.md +++ b/readme.md @@ -209,7 +209,7 @@ protected function toAttributes(Request $request): array ``` #### Described attributes -_**@see** [described notation](##described-notation)_ +_**@see** [described notation](#described-notation)_ ```php protected function toAttributes(Request $request): array @@ -283,7 +283,7 @@ protected function toRelationships(Request $request): array ``` #### Described attributes -_**@see** [described notation](##described-notation)_ +_**@see** [described notation](#described-notation)_ ```php protected function toRelationships(Request $request): array @@ -391,17 +391,18 @@ UserResource::collection(User::all()); // => JsonApiCollection ## Described notation ### Value methods -| Method | Description | -|-----------|------------------------------------------| -| `bool` | Cast to boolean | -| `integer` | Cast to integer | -| `float` | Cast to float | -| `string` | Cast to string | -| `date` | Cast to date, allow to use custom format | -| `array` | Cast to array | -| `mixed` | Don't cast, return as is | -| `enum` | Get enum value | -| `struct` | Custom struct. Accept an array of values | +| Method | Description | +|-----------|-----------------------------------------------------------------| +| `bool` | Cast to boolean | +| `integer` | Cast to integer | +| `float` | Cast to float | +| `string` | Cast to string | +| `date` | Cast to date, allow to use custom format | +| `array` | Cast to array, supports typed arrays with `->of()` | +| `arrayOf` | Helper method for typed arrays (alternative to `array()->of()`) | +| `mixed` | Don't cast, return as is | +| `enum` | Get enum value | +| `struct` | Custom struct. Accept an array of values | ### Relation methods | Method | Description | @@ -454,3 +455,162 @@ Will return: "role": "ADMIN" ] ``` + +### Typed Arrays + +The `array` descriptor supports typed arrays to ensure all elements are cast to a specific type. This is useful when you need to guarantee type consistency across array elements. + +#### Basic Usage + +```php +// UserResource.php +protected function toAttributes(Request $request): array +{ + return [ + // Array of strings - all values will be cast to string + 'tags' => $this->array('tags')->of($this->string()), + + // Array of integers - all values will be cast to integer + 'scores' => $this->array('scores')->of($this->integer()), + + // Array of floats + 'prices' => $this->array('prices')->of($this->float()), + + // Array of booleans + 'flags' => $this->array('flags')->of($this->bool()), + ]; +} +``` + +#### Using Class References + +You can also use class references instead of descriptor instances: + +```php +use Ark4ne\JsonApi\Descriptors\Values\ValueString; +use Ark4ne\JsonApi\Descriptors\Values\ValueInteger; + +protected function toAttributes(Request $request): array +{ + return [ + 'tags' => $this->array('tags')->of(ValueString::class), + 'scores' => $this->array('scores')->of(ValueInteger::class), + ]; +} +``` + +#### Alternative Syntax + +You can also use the `arrayOf()` helper method: + +```php +protected function toAttributes(Request $request): array +{ + return [ + 'tags' => $this->arrayOf($this->string(), 'tags'), + 'scores' => $this->arrayOf($this->integer(), 'scores'), + ]; +} +``` + +#### Nested Typed Arrays + +For multi-dimensional arrays, you can nest `array()->of()` calls: + +```php +protected function toAttributes(Request $request): array +{ + return [ + // 2D array (matrix) of integers + 'matrix' => $this->array('matrix')->of( + $this->array()->of($this->integer()) + ), + ]; +} +``` + +#### With Closures and Transformations + +Combine typed arrays with closures for data transformation: + +```php +protected function toAttributes(Request $request): array +{ + return [ + // Transform and type cast + 'doubled' => $this->array(fn() => array_map(fn($n) => $n * 2, $this->numbers)) + ->of($this->integer()), + + // Access nested properties + 'user_ids' => $this->array(fn() => $this->users->pluck('id')) + ->of($this->integer()), + ]; +} +``` + +#### With Conditions + +Typed arrays support all conditional methods: + +```php +protected function toAttributes(Request $request): array +{ + return [ + // Only include if not null + 'tags' => $this->array('tags')->of($this->string())->whenNotNull(), + + // Only include if array is not empty + 'scores' => $this->array('scores')->of($this->integer())->whenFilled(), + + // Conditional based on closure + 'admin_notes' => $this->array('notes')->of($this->string()) + ->when(fn() => $request->user()->isAdmin()), + ]; +} +``` + +> **⚠️ Important Note:** Conditions applied to the item type (inside `of()`) are **not evaluated per-item**. They apply to the entire array descriptor, not individual elements. +> +> ```php +> // ❌ This will NOT filter individual items +> 'even-numbers' => $this->array('numbers')->of( +> $this->integer()->when(fn($request, $model, $attr) => $attr % 2 === 0) +> ) +> // All items will be included, the when() doesn't filter per item +> +> // ✅ To filter items, do it before passing to the array +> 'even-numbers' => $this->array( +> fn() => array_filter($this->numbers, fn($n) => $n % 2 === 0) +> )->of($this->integer()) +> ``` + +#### Example + +Given a model with mixed-type arrays: + +```php +$user = new User([ + 'tags' => ['php', 'laravel', 123, true], + 'scores' => [95.5, '87', 92, '78.9'], +]); +``` + +The resource will ensure type consistency: + +```php +protected function toAttributes(Request $request): array +{ + return [ + 'tags' => $this->array('tags')->of($this->string()), + 'scores' => $this->array('scores')->of($this->integer()), + ]; +} +``` + +Output: +```json +{ + "tags": ["php", "laravel", "123", "1"], + "scores": [95, 87, 92, 78] +} +``` diff --git a/src/Descriptors/Values.php b/src/Descriptors/Values.php index 051fe82..5f3bb31 100644 --- a/src/Descriptors/Values.php +++ b/src/Descriptors/Values.php @@ -2,7 +2,7 @@ namespace Ark4ne\JsonApi\Descriptors; -use Ark4ne\JsonApi\Descriptors\Values\{ +use Ark4ne\JsonApi\Descriptors\Values\{Value, ValueArray, ValueBool, ValueDate, @@ -11,8 +11,7 @@ ValueInteger, ValueMixed, ValueString, - ValueStruct -}; + ValueStruct}; use Closure; /** @@ -23,7 +22,7 @@ trait Values /** * @param null|string|Closure(T):mixed $attribute * - * @return \Ark4ne\JsonApi\Descriptors\Values\ValueBool + * @return ValueBool */ protected function bool(null|string|Closure $attribute = null): ValueBool { @@ -33,7 +32,7 @@ protected function bool(null|string|Closure $attribute = null): ValueBool /** * @param null|string|Closure(T):mixed $attribute * - * @return \Ark4ne\JsonApi\Descriptors\Values\ValueInteger + * @return ValueInteger */ protected function integer(null|string|Closure $attribute = null): ValueInteger { @@ -43,7 +42,7 @@ protected function integer(null|string|Closure $attribute = null): ValueInteger /** * @param null|string|Closure(T):mixed $attribute * - * @return \Ark4ne\JsonApi\Descriptors\Values\ValueFloat + * @return ValueFloat */ public function float(null|string|Closure $attribute = null): ValueFloat { @@ -53,7 +52,7 @@ public function float(null|string|Closure $attribute = null): ValueFloat /** * @param null|string|Closure(T):mixed $attribute * - * @return \Ark4ne\JsonApi\Descriptors\Values\ValueString + * @return ValueString */ protected function string(null|string|Closure $attribute = null): ValueString { @@ -63,7 +62,7 @@ protected function string(null|string|Closure $attribute = null): ValueString /** * @param null|string|Closure(T):(\DateTimeInterface|string|int|null) $attribute * - * @return \Ark4ne\JsonApi\Descriptors\Values\ValueDate + * @return ValueDate */ protected function date(null|string|Closure $attribute = null): ValueDate { @@ -73,7 +72,7 @@ protected function date(null|string|Closure $attribute = null): ValueDate /** * @param null|string|Closure(T):(array|null) $attribute * - * @return \Ark4ne\JsonApi\Descriptors\Values\ValueArray + * @return ValueArray */ protected function array(null|string|Closure $attribute = null): ValueArray { @@ -83,7 +82,7 @@ protected function array(null|string|Closure $attribute = null): ValueArray /** * @param null|string|Closure(T):mixed $attribute * - * @return \Ark4ne\JsonApi\Descriptors\Values\ValueMixed + * @return ValueMixed */ protected function mixed(null|string|Closure $attribute = null): ValueMixed { @@ -93,7 +92,7 @@ protected function mixed(null|string|Closure $attribute = null): ValueMixed /** * @param null|string|Closure(T):mixed $attribute * - * @return \Ark4ne\JsonApi\Descriptors\Values\ValueEnum + * @return ValueEnum */ protected function enum(null|string|Closure $attribute = null): ValueEnum { @@ -101,12 +100,23 @@ protected function enum(null|string|Closure $attribute = null): ValueEnum } /** - * @param Closure(T):iterable $attribute + * @param Closure(T):iterable $attribute * - * @return \Ark4ne\JsonApi\Descriptors\Values\ValueStruct + * @return ValueStruct */ protected function struct(Closure $attribute): ValueStruct { return new ValueStruct($attribute); } + + /** + * @template U + * @param Value $type + * @param null|string|Closure(T):(array|null) $attribute + * @return ValueArray + */ + protected function arrayOf(Value $type, null|string|Closure $attribute = null): ValueArray + { + return (new ValueArray($attribute))->of($type); + } } diff --git a/src/Descriptors/Values/ValueArray.php b/src/Descriptors/Values/ValueArray.php index b1aa5a7..eba2a83 100644 --- a/src/Descriptors/Values/ValueArray.php +++ b/src/Descriptors/Values/ValueArray.php @@ -7,10 +7,28 @@ /** * @template T + * @template U * @extends Value */ class ValueArray extends Value { + /** + * @var class-string>|Value|null + */ + protected null|string|Value $type = null; + + /** + * Define the type of elements in the array + * + * @param class-string>|Value $type + * @return $this + */ + public function of(string|Value $type): static + { + $this->type = $type; + return $this; + } + /** * @param mixed $of * @param Request $request @@ -18,6 +36,16 @@ class ValueArray extends Value */ public function value(mixed $of, Request $request): array { - return (new Collection($of))->toArray(); + if (!$this->type) { + return (new Collection($of))->toArray(); + } + + $type = is_string($this->type) + ? new ($this->type)(null) + : $this->type; + + return (new Collection($of)) + ->map(fn($item) => $type->value($item, $request)) + ->toArray(); } } diff --git a/tests/Unit/Descriptors/TypedArrayTest.php b/tests/Unit/Descriptors/TypedArrayTest.php new file mode 100644 index 0000000..f21a3f4 --- /dev/null +++ b/tests/Unit/Descriptors/TypedArrayTest.php @@ -0,0 +1,119 @@ +request = new Request(); + } + + public function testArrayOfStrings() + { + $array = new ValueArray('tags'); + $array->of(new ValueString(null)); + + $input = ['php', 'laravel', 'json-api', true, 1.0]; + $result = $array->value($input, $this->request); + + $this->assertIsArray($result); + $this->assertCount(5, $result); + $this->assertSame(['php', 'laravel', 'json-api', '1', '1'], $result); + } + + public function testArrayOfIntegers() + { + $array = new ValueArray('scores'); + $array->of(new ValueInteger(null)); + + $input = [1.5, '2', 3, '4.9']; + $result = $array->value($input, $this->request); + + $this->assertIsArray($result); + $this->assertCount(4, $result); + $this->assertSame([1, 2, 3, 4], $result); + } + + public function testArrayOfEnums() + { + $array = new ValueArray('statuses'); + $array->of(new ValueEnum(null)); + + $input = [ + Status::ACTIVE, + Status::INACTIVE, + Status::PENDING + ]; + $result = $array->value($input, $this->request); + + $this->assertIsArray($result); + $this->assertCount(3, $result); + $this->assertSame([1, 0, 2], $result); // Assuming backed enum values + } + + public function testNestedArrays() + { + $innerArray = new ValueArray(null); + $innerArray->of(new ValueInteger(null)); + + $outerArray = new ValueArray('matrix'); + $outerArray->of($innerArray); + + $input = [ + ['1', 2, 3], + [4, '5', 6], + [7, 8, '9'] + ]; + $result = $outerArray->value($input, $this->request); + + $this->assertIsArray($result); + $this->assertCount(3, $result); + $this->assertSame([ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9] + ], $result); + } + + public function testEmptyArray() + { + $array = new ValueArray('tags'); + $array->of(new ValueString(null)); + + $result = $array->value([], $this->request); + + $this->assertIsArray($result); + $this->assertCount(0, $result); + } + + public function testArrayWithoutTypeSpecification() + { + $array = new ValueArray('mixed'); + + $input = [1, 'string', true, 3.14]; + $result = $array->value($input, $this->request); + + $this->assertIsArray($result); + $this->assertCount(4, $result); + $this->assertSame([1, 'string', true, 3.14], $result); + } +} + +// Example enum for testing +enum Status: int +{ + case INACTIVE = 0; + case ACTIVE = 1; + case PENDING = 2; +} \ No newline at end of file diff --git a/tests/Unit/Resources/TypedArrayResourceTest.php b/tests/Unit/Resources/TypedArrayResourceTest.php new file mode 100644 index 0000000..e072421 --- /dev/null +++ b/tests/Unit/Resources/TypedArrayResourceTest.php @@ -0,0 +1,959 @@ + 1, + 'tags' => ['php', 'laravel', 'json-api', true, 1.0] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'article'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'tags' => $this->arrayOf($this->string(), 'tags') + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('article', $result['type']); + $this->assertEquals('1', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('tags', $result['attributes']); + $this->assertIsArray($result['attributes']['tags']); + $this->assertSame(['php', 'laravel', 'json-api', '1', '1'], $result['attributes']['tags']); + } + + public function testResourceWithArrayOfIntegers() + { + $model = Stub::model([ + 'id' => 2, + 'scores' => [1.5, '2', 3, '4.9'] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'game'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'scores' => $this->arrayOf($this->integer(), 'scores') + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('game', $result['type']); + $this->assertEquals('2', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('scores', $result['attributes']); + $this->assertIsArray($result['attributes']['scores']); + $this->assertSame([1, 2, 3, 4], $result['attributes']['scores']); + } + + public function testResourceWithArrayOfFloats() + { + $model = Stub::model([ + 'id' => 3, + 'prices' => [10, '20.5', 30.75, '40'] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'product'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'prices' => $this->arrayOf($this->float(), 'prices') + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('product', $result['type']); + $this->assertEquals('3', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('prices', $result['attributes']); + $this->assertIsArray($result['attributes']['prices']); + $this->assertEquals([10.0, 20.5, 30.75, 40.0], $result['attributes']['prices']); + } + + public function testResourceWithNestedTypedArrays() + { + $model = Stub::model([ + 'id' => 4, + 'matrix' => [ + ['1', 2, 3], + [4, '5', 6], + [7, 8, '9'] + ] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'matrix'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'matrix' => $this->arrayOf( + $this->arrayOf($this->integer()), + 'matrix' + ) + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('matrix', $result['type']); + $this->assertEquals('4', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('matrix', $result['attributes']); + $this->assertIsArray($result['attributes']['matrix']); + $this->assertSame([ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9] + ], $result['attributes']['matrix']); + } + + public function testResourceWithEmptyArray() + { + $model = Stub::model([ + 'id' => 5, + 'tags' => [] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'article'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'tags' => $this->arrayOf($this->string(), 'tags') + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('article', $result['type']); + $this->assertEquals('5', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('tags', $result['attributes']); + $this->assertIsArray($result['attributes']['tags']); + $this->assertEmpty($result['attributes']['tags']); + } + + public function testResourceWithUntypedArray() + { + $model = Stub::model([ + 'id' => 6, + 'mixed' => [1, 'string', true, 3.14] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'mixed-data'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'mixed' => $this->array('mixed') + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('mixed-data', $result['type']); + $this->assertEquals('6', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('mixed', $result['attributes']); + $this->assertIsArray($result['attributes']['mixed']); + $this->assertSame([1, 'string', true, 3.14], $result['attributes']['mixed']); + } + + public function testResourceWithMultipleTypedArrays() + { + $model = Stub::model([ + 'id' => 7, + 'tags' => ['php', 'laravel'], + 'scores' => [95, 87, 92], + 'prices' => [10.5, 20.75] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'complex'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'tags' => $this->arrayOf($this->string(), 'tags'), + 'scores' => $this->arrayOf($this->integer(), 'scores'), + 'prices' => $this->arrayOf($this->float(), 'prices') + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('complex', $result['type']); + $this->assertEquals('7', $result['id']); + $this->assertArrayHasKey('attributes', $result); + + $this->assertArrayHasKey('tags', $result['attributes']); + $this->assertSame(['php', 'laravel'], $result['attributes']['tags']); + + $this->assertArrayHasKey('scores', $result['attributes']); + $this->assertSame([95, 87, 92], $result['attributes']['scores']); + + $this->assertArrayHasKey('prices', $result['attributes']); + $this->assertEquals([10.5, 20.75], $result['attributes']['prices']); + } + + public function testResourceWithArrayOfBooleans() + { + $model = Stub::model([ + 'id' => 8, + 'flags' => [true, false, 1, 0, 'true', ''] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'flags'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'flags' => $this->arrayOf($this->bool(), 'flags') + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('flags', $result['type']); + $this->assertEquals('8', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('flags', $result['attributes']); + $this->assertIsArray($result['attributes']['flags']); + $this->assertSame([true, false, true, false, true, false], $result['attributes']['flags']); + } + + public function testResourceWithArrayAndClosure() + { + $model = Stub::model([ + 'id' => 9, + 'numbers' => [1, 2, 3, 4, 5] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'calculated'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'doubled' => $this->arrayOf( + $this->integer(), + fn() => array_map(fn($n) => $n * 2, $this->numbers) + ) + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('calculated', $result['type']); + $this->assertEquals('9', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('doubled', $result['attributes']); + $this->assertIsArray($result['attributes']['doubled']); + $this->assertSame([2, 4, 6, 8, 10], $result['attributes']['doubled']); + } + + // Edge cases with conditions + + /** + * Note: when() conditions on item types are not evaluated per-item in the current implementation. + * The when() only affects whether the entire array attribute is included, not individual items. + * This test documents the actual behavior. + */ + public function testArrayWithItemTypeConditionsAreNotEvaluated(): void + { + $model = Stub::model([ + 'id' => 10, + 'flags' => [true, false, 1, 0] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'conditional-item'; + } + + public function toAttributes(Request $request): iterable + { + return [ + // The when(false) on the item type is not evaluated per-item + // It only affects the array descriptor itself + 'flags' => $this->arrayOf($this->bool()->when(false), 'flags') + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('conditional-item', $result['type']); + $this->assertEquals('10', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('flags', $result['attributes']); + // Items are still included because when() on item type doesn't filter items + $this->assertSame([true, false, true, false], $result['attributes']['flags']); + } + + public function testArrayWithArrayTypeWhenFalse(): void + { + $model = Stub::model([ + 'id' => 12, + 'flags' => [true, false, 1, 0] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'conditional-array'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'flags' => $this->arrayOf($this->bool(), 'flags')->when(false) + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + // When the array itself has when(false), the entire attribute should not appear + $this->assertEquals('conditional-array', $result['type']); + $this->assertEquals('12', $result['id']); + $this->assertArrayNotHasKey('attributes', $result); + } + + public function testArrayWithArrayTypeWhenTrue(): void + { + $model = Stub::model([ + 'id' => 13, + 'flags' => [true, false, 1, 0] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'conditional-array-true'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'flags' => $this->arrayOf($this->bool(), 'flags')->when(true) + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('conditional-array-true', $result['type']); + $this->assertEquals('13', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('flags', $result['attributes']); + $this->assertSame([true, false, true, false], $result['attributes']['flags']); + } + + public function testArrayWithWhenNotNull(): void + { + $model = Stub::model([ + 'id' => 14, + 'present' => [1, 2, 3], + 'absent' => null + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'when-not-null'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'present' => $this->arrayOf($this->integer(), 'present')->whenNotNull(), + 'absent' => $this->arrayOf($this->integer(), 'absent')->whenNotNull() + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('when-not-null', $result['type']); + $this->assertEquals('14', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('present', $result['attributes']); + $this->assertArrayNotHasKey('absent', $result['attributes']); + $this->assertSame([1, 2, 3], $result['attributes']['present']); + } + + public function testArrayWithWhenFilled(): void + { + $model = Stub::model([ + 'id' => 15, + 'filled' => [1, 2, 3], + 'empty' => [] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'when-filled'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'filled' => $this->arrayOf($this->integer(), 'filled')->whenFilled(), + 'empty' => $this->arrayOf($this->integer(), 'empty')->whenFilled() + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('when-filled', $result['type']); + $this->assertEquals('15', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('filled', $result['attributes']); + // Empty array is not "filled" so should not appear + $this->assertArrayNotHasKey('empty', $result['attributes']); + $this->assertSame([1, 2, 3], $result['attributes']['filled']); + } + + public function testArrayWithWhenHas(): void + { + $model = Stub::model([ + 'id' => 16, + 'existing' => [1, 2, 3] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'when-has'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'existing' => $this->arrayOf($this->integer(), 'existing')->whenHas(), + 'missing' => $this->arrayOf($this->integer(), 'missing')->whenHas() + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('when-has', $result['type']); + $this->assertEquals('16', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('existing', $result['attributes']); + $this->assertArrayNotHasKey('missing', $result['attributes']); + $this->assertSame([1, 2, 3], $result['attributes']['existing']); + } + + public function testArrayWithUnless(): void + { + $model = Stub::model([ + 'id' => 17, + 'tags' => ['php', 'laravel'] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'unless'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'visible' => $this->arrayOf($this->string(), 'tags')->unless(false), + 'hidden' => $this->arrayOf($this->string(), 'tags')->unless(true) + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('unless', $result['type']); + $this->assertEquals('17', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('visible', $result['attributes']); + $this->assertArrayNotHasKey('hidden', $result['attributes']); + $this->assertSame(['php', 'laravel'], $result['attributes']['visible']); + } + + public function testArrayWithClosureCondition(): void + { + $model = Stub::model([ + 'id' => 18, + 'admin_tags' => ['admin', 'super'], + 'user_tags' => ['user', 'basic'], + 'is_admin' => true + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'closure-condition'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'admin_tags' => $this->arrayOf( + $this->string(), + 'admin_tags' + )->when(fn() => $this->is_admin), + 'user_tags' => $this->arrayOf( + $this->string(), + 'user_tags' + )->unless(fn() => $this->is_admin) + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('closure-condition', $result['type']); + $this->assertEquals('18', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('admin_tags', $result['attributes']); + $this->assertArrayNotHasKey('user_tags', $result['attributes']); + $this->assertSame(['admin', 'super'], $result['attributes']['admin_tags']); + } + + public function testArrayWithMultipleConditions(): void + { + $model = Stub::model([ + 'id' => 19, + 'tags' => ['php', 'laravel'], + 'is_visible' => true, + 'has_permission' => true + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'multiple-conditions'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'tags' => $this->arrayOf($this->string(), 'tags') + ->when(fn() => $this->is_visible) + ->when(fn() => $this->has_permission) + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('multiple-conditions', $result['type']); + $this->assertEquals('19', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('tags', $result['attributes']); + $this->assertSame(['php', 'laravel'], $result['attributes']['tags']); + } + + public function testArrayWithMultipleConditionsOneFalse(): void + { + $model = Stub::model([ + 'id' => 20, + 'tags' => ['php', 'laravel'], + 'is_visible' => true, + 'has_permission' => false + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'multiple-conditions-false'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'tags' => $this->arrayOf($this->string(), 'tags') + ->when(fn() => $this->is_visible) + ->when(fn() => $this->has_permission) + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('multiple-conditions-false', $result['type']); + $this->assertEquals('20', $result['id']); + $this->assertArrayNotHasKey('attributes', $result); + } + + // Alternative syntax: array()->of() + + public function testArrayWithOfSyntaxNoAttribute(): void + { + $model = Stub::model([ + 'id' => 21, + 'flags' => [true, false, 1, 0] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'of-syntax-no-attr'; + } + + public function toAttributes(Request $request): iterable + { + return [ + // Using array()->of() syntax without specifying attribute + // Should use the key 'flags' from the return array + 'flags' => $this->array()->of($this->bool()) + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + // Since no attribute is specified and array() returns null by default, + // this should result in an empty array or the behavior depends on implementation + $this->assertEquals('of-syntax-no-attr', $result['type']); + $this->assertEquals('21', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('flags', $result['attributes']); + } + + public function testArrayWithOfSyntaxWithAttribute(): void + { + $model = Stub::model([ + 'id' => 22, + 'abc' => ['php', 'laravel', 'json-api', 1, true] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'of-syntax-with-attr'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'abc' => $this->array('abc')->of($this->string()) + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('of-syntax-with-attr', $result['type']); + $this->assertEquals('22', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('abc', $result['attributes']); + $this->assertIsArray($result['attributes']['abc']); + $this->assertSame(['php', 'laravel', 'json-api', '1', '1'], $result['attributes']['abc']); + } + + public function testArrayWithOfSyntaxWithClosure(): void + { + $model = Stub::model([ + 'id' => 23, + 'def' => [1, 2, 3, 4, 5] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'of-syntax-with-closure'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'def' => $this->array(fn() => $this->def)->of($this->integer()) + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('of-syntax-with-closure', $result['type']); + $this->assertEquals('23', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('def', $result['attributes']); + $this->assertIsArray($result['attributes']['def']); + $this->assertSame([1, 2, 3, 4, 5], $result['attributes']['def']); + } + + public function testArrayWithOfSyntaxAndTransformation(): void + { + $model = Stub::model([ + 'id' => 24, + 'numbers' => [1, 2, 3] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'of-syntax-transformation'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'doubled' => $this->array(fn() => array_map(fn($n) => $n * 2, $this->numbers)) + ->of($this->integer()) + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('of-syntax-transformation', $result['type']); + $this->assertEquals('24', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('doubled', $result['attributes']); + $this->assertIsArray($result['attributes']['doubled']); + $this->assertSame([2, 4, 6], $result['attributes']['doubled']); + } + + public function testArrayWithOfSyntaxWithConditions(): void + { + $model = Stub::model([ + 'id' => 25, + 'scores' => [95.5, '87', 92.3, '78'], + 'show_scores' => true + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'of-syntax-conditions'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'scores' => $this->array('scores') + ->of($this->integer()) + ->when(fn() => $this->show_scores) + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('of-syntax-conditions', $result['type']); + $this->assertEquals('25', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('scores', $result['attributes']); + $this->assertIsArray($result['attributes']['scores']); + $this->assertSame([95, 87, 92, 78], $result['attributes']['scores']); + } + + public function testArrayWithOfSyntaxNested(): void + { + $model = Stub::model([ + 'id' => 26, + 'matrix' => [ + [1.5, '2', 3], + ['4', 5.7, 6], + ] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'of-syntax-nested'; + } + + public function toAttributes(Request $request): iterable + { + return [ + 'matrix' => $this->array('matrix')->of( + $this->array()->of($this->integer()) + ) + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('of-syntax-nested', $result['type']); + $this->assertEquals('26', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('matrix', $result['attributes']); + $this->assertIsArray($result['attributes']['matrix']); + $this->assertSame([ + [1, 2, 3], + [4, 5, 6], + ], $result['attributes']['matrix']); + } + + public function testArrayWithOfSyntaxMixedWithArrayOf(): void + { + $model = Stub::model([ + 'id' => 27, + 'tags' => ['php', 'laravel'], + 'scores' => [95, 87, 92] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'mixed-syntax'; + } + + public function toAttributes(Request $request): iterable + { + return [ + // Using arrayOf() syntax + 'tags' => $this->arrayOf($this->string(), 'tags'), + // Using array()->of() syntax + 'scores' => $this->array('scores')->of($this->integer()) + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('mixed-syntax', $result['type']); + $this->assertEquals('27', $result['id']); + $this->assertArrayHasKey('attributes', $result); + + $this->assertArrayHasKey('tags', $result['attributes']); + $this->assertSame(['php', 'laravel'], $result['attributes']['tags']); + + $this->assertArrayHasKey('scores', $result['attributes']); + $this->assertSame([95, 87, 92], $result['attributes']['scores']); + } + + /** + * Test demonstrating that when() conditions on item types within array()->of() + * are not evaluated per-item. The condition affects the entire array descriptor, + * not individual items. + */ + public function testArrayWithOfSyntaxItemTypeCondition(): void + { + $model = Stub::model([ + 'id' => 28, + 'numbers' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + ]); + + $resource = new class($model) extends JsonApiResource { + public function toType(Request $request): string + { + return 'conditional-items'; + } + + public function toAttributes(Request $request): iterable + { + return [ + // Note: when() on the item type doesn't filter individual items + // It would apply to the entire descriptor, not per-item + 'even-numbers' => $this->array('numbers')->of( + $this->integer()->when(fn($request, $model, $attr) => $attr % 2 === 0) + ) + ]; + } + }; + + $request = new Request(); + $result = $resource->toArray($request); + + $this->assertEquals('conditional-items', $result['type']); + $this->assertEquals('28', $result['id']); + $this->assertArrayHasKey('attributes', $result); + $this->assertArrayHasKey('even-numbers', $result['attributes']); + + // All items are included, even though we specified a condition on the item type + // The when() doesn't filter individual items, it's applied at the descriptor level + $this->assertSame([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], $result['attributes']['even-numbers']); + } +}