From 7d8ade275086f14d159751cec39af3020fc8815b Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Sun, 3 Sep 2023 18:22:07 +0100 Subject: [PATCH 01/10] initial work on updating fields for validation --- composer.json | 3 +- src/Fields/Boolean.php | 17 +- src/Fields/Concerns/IsReadOnly.php | 13 +- src/Fields/DateTime.php | 15 +- src/Fields/ID.php | 40 +- src/Fields/Integer.php | 105 +++++ src/Fields/Number.php | 19 +- src/Fields/SoftDelete.php | 16 +- src/Fields/Str.php | 14 +- .../lib/Integration/Fields/ArrayHashTest.php | 3 +- tests/lib/Integration/Fields/BooleanTest.php | 11 + tests/lib/Integration/Fields/DateTimeTest.php | 11 + tests/lib/Integration/Fields/IdTest.php | 28 ++ tests/lib/Integration/Fields/IntegerTest.php | 381 ++++++++++++++++++ tests/lib/Integration/Fields/NumberTest.php | 20 + .../lib/Integration/Fields/SoftDeleteTest.php | 21 + tests/lib/Integration/Fields/StrTest.php | 10 + 17 files changed, 706 insertions(+), 21 deletions(-) create mode 100644 src/Fields/Integer.php create mode 100644 tests/lib/Integration/Fields/IntegerTest.php diff --git a/composer.json b/composer.json index 56f131b..3484176 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,8 @@ "ext-json": "*", "illuminate/database": "^10.0", "illuminate/support": "^10.0", - "laravel-json-api/core": "^4.0" + "laravel-json-api/core": "^4.0", + "laravel-json-api/validation": "^4.0" }, "require-dev": { "orchestra/testbench": "^8.0", diff --git a/src/Fields/Boolean.php b/src/Fields/Boolean.php index 445f8d8..98d4ee1 100644 --- a/src/Fields/Boolean.php +++ b/src/Fields/Boolean.php @@ -19,8 +19,13 @@ namespace LaravelJsonApi\Eloquent\Fields; -class Boolean extends Attribute +use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Fields\ValidatedWithListOfRules; +use LaravelJsonApi\Validation\Rules\JsonBoolean; + +class Boolean extends Attribute implements IsValidated { + use ValidatedWithListOfRules; /** * Create a boolean attribute. @@ -34,12 +39,20 @@ public static function make(string $fieldName, string $column = null): self return new self($fieldName, $column); } + /** + * @return array + */ + protected function defaultRules(): array + { + return [new JsonBoolean()]; + } + /** * @inheritDoc */ protected function assertValue($value): void { - if (!is_null($value) && !is_bool($value)) { + if ($value !== null && !is_bool($value)) { throw new \UnexpectedValueException(sprintf( 'Expecting the value of attribute %s to be a boolean.', $this->name() diff --git a/src/Fields/Concerns/IsReadOnly.php b/src/Fields/Concerns/IsReadOnly.php index 2b1d64c..9b7f395 100644 --- a/src/Fields/Concerns/IsReadOnly.php +++ b/src/Fields/Concerns/IsReadOnly.php @@ -25,13 +25,12 @@ trait IsReadOnly { - /** * Whether the field is read-only. * * @var Closure|bool */ - private $readOnly = false; + private Closure|bool $readOnly = false; /** * Mark the field as read-only. @@ -39,12 +38,8 @@ trait IsReadOnly * @param Closure|bool $callback * @return $this */ - public function readOnly($callback = true): self + public function readOnly(Closure|bool $callback = true): static { - if (!is_bool($callback) && !$callback instanceof Closure) { - throw new InvalidArgumentException('Expecting a boolean or closure.'); - } - $this->readOnly = $callback; return $this; @@ -55,7 +50,7 @@ public function readOnly($callback = true): self * * @return $this */ - public function readOnlyOnCreate(): self + public function readOnlyOnCreate(): static { $this->readOnly(static fn($request) => $request && $request->isMethod('POST')); @@ -67,7 +62,7 @@ public function readOnlyOnCreate(): self * * @return $this */ - public function readOnlyOnUpdate(): self + public function readOnlyOnUpdate(): static { $this->readOnly(static fn($request) => $request && $request->isMethod('PATCH')); diff --git a/src/Fields/DateTime.php b/src/Fields/DateTime.php index 6459261..9a0095b 100644 --- a/src/Fields/DateTime.php +++ b/src/Fields/DateTime.php @@ -20,11 +20,16 @@ namespace LaravelJsonApi\Eloquent\Fields; use Carbon\CarbonInterface; +use Closure; use Illuminate\Support\Facades\Date; +use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Fields\ValidatedWithListOfRules; +use LaravelJsonApi\Validation\Rules\DateTimeIso8601; use function config; -class DateTime extends Attribute +class DateTime extends Attribute implements IsValidated { + use ValidatedWithListOfRules; /** * Should dates be converted to the defined time zone? @@ -121,6 +126,14 @@ protected function parse($value): ?CarbonInterface return $value; } + /** + * @return DateTimeIso8601[] + */ + protected function defaultRules(): array + { + return [new DateTimeIso8601()]; + } + /** * @inheritDoc */ diff --git a/src/Fields/ID.php b/src/Fields/ID.php index eb823a8..50b3a9b 100644 --- a/src/Fields/ID.php +++ b/src/Fields/ID.php @@ -20,15 +20,16 @@ namespace LaravelJsonApi\Eloquent\Fields; use Illuminate\Database\Eloquent\Model; +use Illuminate\Http\Request; use LaravelJsonApi\Contracts\Schema\ID as IDContract; use LaravelJsonApi\Core\Schema\Concerns\ClientIds; use LaravelJsonApi\Core\Schema\Concerns\MatchesIds; use LaravelJsonApi\Core\Schema\Concerns\Sortable; use LaravelJsonApi\Eloquent\Contracts\Fillable; +use LaravelJsonApi\Validation\Fields\IsValidated; -class ID implements IDContract, Fillable +class ID implements IDContract, Fillable, IsValidated { - use ClientIds; use MatchesIds; use Sortable; @@ -38,6 +39,11 @@ class ID implements IDContract, Fillable */ private ?string $column; + /** + * @var string + */ + private string $validationModifier = 'required'; + /** * Create an id field. * @@ -112,6 +118,36 @@ public function isNotReadOnly($request): bool return !$this->isReadOnly($request); } + /** + * @return $this + */ + public function nullable(): self + { + $this->validationModifier = 'nullable'; + + return $this; + } + + /** + * @inheritDoc + */ + public function rulesForCreation(?Request $request): array|null + { + if ($this->acceptsClientIds()) { + return [$this->validationModifier, "regex:/^{$this->pattern}$/{$this->flags}"]; + } + + return null; + } + + /** + * @inheritDoc + */ + public function rulesForUpdate(?Request $request, object $model): ?array + { + return null; + } + /** * @inheritDoc */ diff --git a/src/Fields/Integer.php b/src/Fields/Integer.php new file mode 100644 index 0000000..b6c3abd --- /dev/null +++ b/src/Fields/Integer.php @@ -0,0 +1,105 @@ +acceptStrings = true; + + return $this; + } + + /** + * @return array + */ + protected function defaultRules(): array + { + if ($this->acceptStrings) { + return ['numeric', 'integer']; + } + + return [(new JsonNumber())->onlyIntegers()]; + } + + /** + * @inheritDoc + */ + protected function assertValue($value): void + { + if (!$this->isInt($value)) { + $expected = $this->acceptStrings ? + 'an integer or a numeric string that is an integer.' : + 'an integer.'; + + throw new UnexpectedValueException(sprintf( + 'Expecting the value of attribute %s to be ' . $expected, + $this->name(), + )); + } + } + + /** + * Is the value a numeric value that this field accepts? + * + * @param mixed $value + * @return bool + */ + private function isInt(mixed $value): bool + { + if ($this->acceptStrings && is_string($value) && is_numeric($value)) { + $value = filter_var($value, FILTER_VALIDATE_INT); + } + + if ($value === null || is_int($value)) { + return true; + } + + return false; + } +} diff --git a/src/Fields/Number.php b/src/Fields/Number.php index 932f3ee..136fae8 100644 --- a/src/Fields/Number.php +++ b/src/Fields/Number.php @@ -19,10 +19,15 @@ namespace LaravelJsonApi\Eloquent\Fields; +use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Fields\ValidatedWithListOfRules; +use LaravelJsonApi\Validation\Rules\JsonNumber; use UnexpectedValueException; -class Number extends Attribute +class Number extends Attribute implements IsValidated { + use ValidatedWithListOfRules; + /** * @var bool */ @@ -50,6 +55,18 @@ public function acceptStrings(): self return $this; } + /** + * @return array + */ + protected function defaultRules(): array + { + if ($this->acceptStrings) { + return ['numeric']; + } + + return [new JsonNumber()]; + } + /** * @inheritDoc */ diff --git a/src/Fields/SoftDelete.php b/src/Fields/SoftDelete.php index 4aa33e6..dea9085 100644 --- a/src/Fields/SoftDelete.php +++ b/src/Fields/SoftDelete.php @@ -20,6 +20,8 @@ namespace LaravelJsonApi\Eloquent\Fields; use Illuminate\Support\Facades\Date; +use LaravelJsonApi\Validation\Rules\DateTimeIso8601; +use LaravelJsonApi\Validation\Rules\JsonBoolean; use UnexpectedValueException; use function boolval; use function is_bool; @@ -28,7 +30,6 @@ class SoftDelete extends DateTime { - /** * @var bool */ @@ -68,12 +69,23 @@ public function serialize(object $model) return parent::serialize($model); } + /** + * @return array + */ + protected function defaultRules(): array + { + return [ + 'nullable', + $this->boolean ? new JsonBoolean() : new DateTimeIso8601(), + ]; + } + /** * @inheritDoc */ protected function deserialize($value) { - if (true === $this->boolean && (is_bool($value) || is_null($value))) { + if (true === $this->boolean && (is_bool($value) || $value === null)) { return $this->parse($value ? Date::now() : null); } diff --git a/src/Fields/Str.php b/src/Fields/Str.php index 322adc7..ec0747e 100644 --- a/src/Fields/Str.php +++ b/src/Fields/Str.php @@ -19,8 +19,12 @@ namespace LaravelJsonApi\Eloquent\Fields; -class Str extends Attribute +use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Fields\ValidatedWithListOfRules; + +class Str extends Attribute implements IsValidated { + use ValidatedWithListOfRules; /** * Create a string attribute. @@ -34,6 +38,14 @@ public static function make(string $fieldName, string $column = null): self return new self($fieldName, $column); } + /** + * @return array + */ + protected function defaultRules(): array + { + return ['string']; + } + /** * @inheritDoc */ diff --git a/tests/lib/Integration/Fields/ArrayHashTest.php b/tests/lib/Integration/Fields/ArrayHashTest.php index 9d8fd5e..5308c78 100644 --- a/tests/lib/Integration/Fields/ArrayHashTest.php +++ b/tests/lib/Integration/Fields/ArrayHashTest.php @@ -96,9 +96,8 @@ public function testFill($value): void $model = new Role(); $attr = ArrayHash::make('permissions'); - $result = $attr->fill($model, $value, []); + $attr->fill($model, $value, []); - $this->assertNull($result); $this->assertSame($value, $model->permissions); } diff --git a/tests/lib/Integration/Fields/BooleanTest.php b/tests/lib/Integration/Fields/BooleanTest.php index 1f5e513..6b88b0d 100644 --- a/tests/lib/Integration/Fields/BooleanTest.php +++ b/tests/lib/Integration/Fields/BooleanTest.php @@ -24,6 +24,8 @@ use Illuminate\Http\Request; use LaravelJsonApi\Eloquent\Fields\Boolean; use LaravelJsonApi\Eloquent\Tests\Integration\TestCase; +use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Rules\JsonBoolean; class BooleanTest extends TestCase { @@ -54,6 +56,15 @@ public function testColumn(): void $this->assertSame(['is_active'], $attr->columnsForField()); } + public function testIsValidated(): void + { + $attr = Boolean::make('active'); + + $this->assertInstanceOf(IsValidated::class, $attr); + $this->assertEquals([new JsonBoolean()], $attr->rulesForCreation(null)); + $this->assertEquals([new JsonBoolean()], $attr->rulesForUpdate(null, new \stdClass())); + } + public function testNotSparseField(): void { $attr = Boolean::make('active')->notSparseField(); diff --git a/tests/lib/Integration/Fields/DateTimeTest.php b/tests/lib/Integration/Fields/DateTimeTest.php index c2bfc04..0c960a1 100644 --- a/tests/lib/Integration/Fields/DateTimeTest.php +++ b/tests/lib/Integration/Fields/DateTimeTest.php @@ -25,6 +25,8 @@ use Illuminate\Http\Request; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Tests\Integration\TestCase; +use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Rules\DateTimeIso8601; class DateTimeTest extends TestCase { @@ -66,6 +68,15 @@ public function testColumn(): void $this->assertSame(['published_at'], $attr->columnsForField()); } + public function testIsValidated(): void + { + $attr = DateTime::make('publishedAt'); + + $this->assertInstanceOf(IsValidated::class, $attr); + $this->assertEquals([new DateTimeIso8601()], $attr->rulesForCreation(null)); + $this->assertEquals([new DateTimeIso8601()], $attr->rulesForUpdate(null, new \stdClass())); + } + public function testNotSparseField(): void { $attr = DateTime::make('publishedAt')->notSparseField(); diff --git a/tests/lib/Integration/Fields/IdTest.php b/tests/lib/Integration/Fields/IdTest.php index feecd82..aff63e5 100644 --- a/tests/lib/Integration/Fields/IdTest.php +++ b/tests/lib/Integration/Fields/IdTest.php @@ -24,6 +24,7 @@ use Illuminate\Http\Request; use LaravelJsonApi\Eloquent\Fields\ID; use LaravelJsonApi\Eloquent\Tests\Integration\TestCase; +use LaravelJsonApi\Validation\Fields\IsValidated; class IdTest extends TestCase { @@ -54,6 +55,33 @@ public function testClientIds(): void $this->assertTrue($id->acceptsClientIds()); } + public function testIsValidatedWhenNotClientId(): void + { + $id = ID::make(); + + $this->assertInstanceOf(IsValidated::class, $id); + $this->assertNull($id->rulesForCreation(null)); + $this->assertNull($id->rulesForUpdate(null, new \stdClass())); + } + + public function testIsValidatedWhenClientId(): void + { + $id = ID::make()->clientIds(); + + $this->assertInstanceOf(IsValidated::class, $id); + $this->assertSame(['required', 'regex:/^[0-9]+$/iD'], $id->rulesForCreation(null)); + $this->assertNull($id->rulesForUpdate(null, new \stdClass())); + } + + public function testIsValidatedWhenNullableClientId(): void + { + $id = ID::make()->clientIds()->nullable(); + + $this->assertInstanceOf(IsValidated::class, $id); + $this->assertSame(['nullable', 'regex:/^[0-9]+$/iD'], $id->rulesForCreation(null)); + $this->assertNull($id->rulesForUpdate(null, new \stdClass())); + } + public function testFillUsesRouteKeyName(): void { $model = $this->getMockBuilder(Model::class)->onlyMethods(['getRouteKeyName'])->getMock(); diff --git a/tests/lib/Integration/Fields/IntegerTest.php b/tests/lib/Integration/Fields/IntegerTest.php new file mode 100644 index 0000000..8feb168 --- /dev/null +++ b/tests/lib/Integration/Fields/IntegerTest.php @@ -0,0 +1,381 @@ +createMock(Request::class); + $attr = Integer::make('failureCount'); + + $this->assertSame('failureCount', $attr->name()); + $this->assertSame('failureCount', $attr->serializedFieldName()); + $this->assertSame('failure_count', $attr->column()); + $this->assertTrue($attr->isSparseField()); + $this->assertSame(['failure_count'], $attr->columnsForField()); + $this->assertFalse($attr->isSortable()); + $this->assertFalse($attr->isReadOnly($request)); + $this->assertTrue($attr->isNotReadOnly($request)); + $this->assertFalse($attr->isHidden($request)); + $this->assertTrue($attr->isNotHidden($request)); + } + + public function testColumn(): void + { + $attr = Integer::make('failures', 'failure_count'); + + $this->assertSame('failures', $attr->name()); + $this->assertSame('failure_count', $attr->column()); + $this->assertSame(['failure_count'], $attr->columnsForField()); + } + + public function testNotSparseField(): void + { + $attr = Integer::make('failures')->notSparseField(); + + $this->assertFalse($attr->isSparseField()); + } + + public function testSortable(): void + { + $query = Post::query(); + + $attr = Integer::make('failures')->sortable(); + + $this->assertTrue($attr->isSortable()); + $attr->sort($query, 'desc'); + + $this->assertSame( + [['column' => 'posts.failures', 'direction' => 'desc']], + $query->toBase()->orders + ); + } + + public function testItIsValidatedAsNumber(): void + { + $rule = (new JsonNumber())->onlyIntegers(); + $attr = Integer::make('views'); + + $this->assertInstanceOf(IsValidated::class, $attr); + $this->assertEquals([$rule], $attr->rulesForCreation(null)); + $this->assertEquals([$rule], $attr->rulesForUpdate(null, new \stdClass())); + } + + public function testItIsValidatedAsNumberAllowingStrings(): void + { + $attr = Integer::make('views')->acceptStrings(); + + $this->assertInstanceOf(IsValidated::class, $attr); + $this->assertEquals(['numeric', 'integer'], $attr->rulesForCreation(null)); + $this->assertEquals(['numeric', 'integer'], $attr->rulesForUpdate(null, new \stdClass())); + } + + /** + * @return array + */ + public function validProvider(): array + { + return [ + 'int' => [1], + 'null' => [null], + 'zero' => [0], + ]; + } + + /** + * @param $value + * @dataProvider validProvider + */ + public function testFill($value): void + { + $model = new Post(); + $attr = Integer::make('title'); + + $attr->fill($model, $value, []); + + $this->assertSame($value, $model->title); + } + + /** + * @return array + */ + public function validWithStringProvider(): array + { + return array_merge($this->validProvider(), [ + 'int as string' => ['1'], + 'zero as string' => ['0'], + ]); + } + + /** + * @param $value + * @dataProvider validWithStringProvider + */ + public function testFillAcceptsStrings($value): void + { + $model = new Post(); + $attr = Integer::make('title')->acceptStrings(); + + $attr->fill($model, $value, []); + + $this->assertSame($value, $model->title); + } + + /** + * @return array + */ + public function invalidProvider(): array + { + return [ + [0.0], + [1.1], + [true], + ['foo'], + ['0'], + [''], + [[]], + [new \DateTime()], + ]; + } + + /** + * @param $value + * @dataProvider invalidProvider + */ + public function testFillWithInvalid($value): void + { + $model = new Post(); + $attr = Integer::make('count'); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Expecting the value of attribute count to be an integer.'); + + $attr->fill($model, $value, []); + } + + /** + * @return array + */ + public function invalidWhenAcceptingStringsProvider(): array + { + return [ + ['0.0'], + ['1.1'], + [true], + ['foo'], + [''], + [[]], + [new \DateTime()], + ]; + } + + /** + * @param $value + * @dataProvider invalidWhenAcceptingStringsProvider + */ + public function testFillWithInvalidWhenAcceptingStrings($value): void + { + $model = new Post(); + $attr = Integer::make('title')->acceptStrings(); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage( + 'Expecting the value of attribute title to be an integer or a numeric string that is an integer.' + ); + + $attr->fill($model, $value, []); + } + + public function testFillRespectsMassAssignment(): void + { + $model = new Post(); + $attr = Integer::make('views'); + + $attr->fill($model, 200, []); + $this->assertArrayNotHasKey('views', $model->getAttributes()); + } + + public function testUnguarded(): void + { + $model = new Post(); + $attr = Integer::make('views')->unguarded(); + + $attr->fill($model, 200, []); + $this->assertSame(200, $model->views); + } + + public function testDeserializeUsing(): void + { + $model = new Post(); + $attr = Integer::make('title')->deserializeUsing( + fn($value) => $value + 200 + ); + + $attr->fill($model, 100, []); + $this->assertSame(300, $model->title); + } + + public function testFillUsing(): void + { + $post = new Post(); + $attr = Integer::make('views')->fillUsing(function ($model, $column, $value) use ($post) { + $this->assertSame($post, $model); + $this->assertSame('views', $column); + $this->assertSame(200, $value); + $model->views = 300; + }); + + $attr->fill($post, 200, []); + $this->assertSame(300, $post->views); + } + + public function testFillRelated(): void + { + $user = new User(); + + $attr = Integer::make('views')->on('profile')->unguarded(); + + $attr->fill($user, 99, []); + + $this->assertSame(99, $user->profile->views); + $this->assertSame('profile', $attr->with()); + } + + public function testReadOnly(): void + { + $request = $this->createMock(Request::class); + $request->expects($this->never())->method($this->anything()); + + $attr = Integer::make('views')->readOnly(); + + $this->assertTrue($attr->isReadOnly($request)); + $this->assertFalse($attr->isNotReadOnly($request)); + } + + public function testReadOnlyWithClosure(): void + { + $request = $this->createMock(Request::class); + $request->expects($this->exactly(2)) + ->method('wantsJson') + ->willReturnOnConsecutiveCalls(true, false); + + $attr = Integer::make('views')->readOnly( + fn($request) => $request->wantsJson() + ); + + $this->assertTrue($attr->isReadOnly($request)); + $this->assertFalse($attr->isReadOnly($request)); + } + + public function testReadOnlyOnCreate(): void + { + $request = $this->createMock(Request::class); + $request->expects($this->exactly(2)) + ->method('isMethod') + ->with('POST') + ->willReturnOnConsecutiveCalls(true, false); + + $attr = Integer::make('views')->readOnlyOnCreate(); + + $this->assertTrue($attr->isReadOnly($request)); + $this->assertFalse($attr->isReadOnly($request)); + } + + public function testReadOnlyOnUpdate(): void + { + $request = $this->createMock(Request::class); + $request->expects($this->exactly(2)) + ->method('isMethod') + ->with('PATCH') + ->willReturnOnConsecutiveCalls(true, false); + + $attr = Integer::make('views')->readOnlyOnUpdate(); + + $this->assertTrue($attr->isReadOnly($request)); + $this->assertFalse($attr->isReadOnly($request)); + } + + public function testSerialize(): void + { + $post = new Post(); + $attr = Integer::make('views'); + + $this->assertNull($attr->serialize($post)); + $post->views = 101; + $this->assertSame(101, $attr->serialize($post)); + } + + public function testSerializeUsing(): void + { + $post = new Post(); + $post->views = 100; + + $attr = Integer::make('views')->serializeUsing( + fn($value) => $value * 2 + ); + + $this->assertSame(200, $attr->serialize($post)); + } + + public function testSerializeRelated(): void + { + $user = new User(); + + $attr = Integer::make('views')->on('profile'); + + $this->assertNull($attr->serialize($user)); + + $user->profile->views = 99; + + $this->assertSame(99, $attr->serialize($user)); + } + + public function testHidden(): void + { + $request = $this->createMock(Request::class); + $request->expects($this->never())->method($this->anything()); + + $attr = Integer::make('views')->hidden(); + + $this->assertTrue($attr->isHidden($request)); + } + + public function testHiddenCallback(): void + { + $mock = $this->createMock(Request::class); + $mock->expects($this->once())->method('isMethod')->with('POST')->willReturn(true); + + $attr = Integer::make('views')->hidden( + fn($request) => $request->isMethod('POST') + ); + + $this->assertTrue($attr->isHidden($mock)); + } +} diff --git a/tests/lib/Integration/Fields/NumberTest.php b/tests/lib/Integration/Fields/NumberTest.php index 07cdbd0..7f1cf35 100644 --- a/tests/lib/Integration/Fields/NumberTest.php +++ b/tests/lib/Integration/Fields/NumberTest.php @@ -24,6 +24,8 @@ use Illuminate\Http\Request; use LaravelJsonApi\Eloquent\Fields\Number; use LaravelJsonApi\Eloquent\Tests\Integration\TestCase; +use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Rules\JsonNumber; class NumberTest extends TestCase { @@ -76,6 +78,24 @@ public function testSortable(): void ); } + public function testItIsValidatedAsNumber(): void + { + $attr = Number::make('views'); + + $this->assertInstanceOf(IsValidated::class, $attr); + $this->assertEquals([new JsonNumber()], $attr->rulesForCreation(null)); + $this->assertEquals([new JsonNumber()], $attr->rulesForUpdate(null, new \stdClass())); + } + + public function testItIsValidatedAsNumberAllowingStrings(): void + { + $attr = Number::make('views')->acceptStrings(); + + $this->assertInstanceOf(IsValidated::class, $attr); + $this->assertEquals(['numeric'], $attr->rulesForCreation(null)); + $this->assertEquals(['numeric'], $attr->rulesForUpdate(null, new \stdClass())); + } + /** * @return array */ diff --git a/tests/lib/Integration/Fields/SoftDeleteTest.php b/tests/lib/Integration/Fields/SoftDeleteTest.php index 3731a32..4444c57 100644 --- a/tests/lib/Integration/Fields/SoftDeleteTest.php +++ b/tests/lib/Integration/Fields/SoftDeleteTest.php @@ -25,6 +25,9 @@ use Illuminate\Http\Request; use LaravelJsonApi\Eloquent\Fields\SoftDelete; use LaravelJsonApi\Eloquent\Tests\Integration\TestCase; +use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Rules\DateTimeIso8601; +use LaravelJsonApi\Validation\Rules\JsonBoolean; class SoftDeleteTest extends TestCase { @@ -78,6 +81,24 @@ public function testColumn(): void $this->assertSame(['deleted_at'], $attr->columnsForField()); } + public function testItIsValidated(): void + { + $attr = SoftDelete::make('deletedAt'); + + $this->assertInstanceOf(IsValidated::class, $attr); + $this->assertEquals(['nullable', new DateTimeIso8601()], $attr->rulesForCreation(null)); + $this->assertEquals(['nullable', new DateTimeIso8601()], $attr->rulesForUpdate(null, new \stdClass())); + } + + public function testItIsValidatedAsBoolean(): void + { + $attr = SoftDelete::make('deletedAt')->asBoolean(); + + $this->assertInstanceOf(IsValidated::class, $attr); + $this->assertEquals(['nullable', new JsonBoolean()], $attr->rulesForCreation(null)); + $this->assertEquals(['nullable', new JsonBoolean()], $attr->rulesForUpdate(null, new \stdClass())); + } + public function testNotSparseField(): void { $attr = SoftDelete::make('deletedAt')->notSparseField(); diff --git a/tests/lib/Integration/Fields/StrTest.php b/tests/lib/Integration/Fields/StrTest.php index 9424921..f0c342e 100644 --- a/tests/lib/Integration/Fields/StrTest.php +++ b/tests/lib/Integration/Fields/StrTest.php @@ -24,6 +24,7 @@ use Illuminate\Http\Request; use LaravelJsonApi\Eloquent\Fields\Str; use LaravelJsonApi\Eloquent\Tests\Integration\TestCase; +use LaravelJsonApi\Validation\Fields\IsValidated; class StrTest extends TestCase { @@ -54,6 +55,15 @@ public function testColumn(): void $this->assertSame(['display_name'], $attr->columnsForField()); } + public function testItIsValidated(): void + { + $attr = Str::make('name'); + + $this->assertInstanceOf(IsValidated::class, $attr); + $this->assertSame(['string'], $attr->rulesForCreation(null)); + $this->assertSame(['string'], $attr->rulesForUpdate(null, new \stdClass())); + } + public function testNotSparseField(): void { $attr = Str::make('name')->notSparseField(); From 5989b170e6610627bd692ca4770447f39b1d377d Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Tue, 12 Mar 2024 21:17:16 +0000 Subject: [PATCH 02/10] fix: formating in composer json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3204f8f..1e1bd0a 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "illuminate/database": "^11.0", "illuminate/support": "^11.0", "laravel-json-api/core": "^5.0", - "laravel-json-api/validation": "^5.0" + "laravel-json-api/validation": "^5.0" }, "require-dev": { "orchestra/testbench": "^9.0", From 74403d6feb537f499e812f2c748b6b3526cea606 Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Sat, 23 Mar 2024 11:49:40 +0000 Subject: [PATCH 03/10] feat: add validation to array hash and list attributes --- src/Fields/ArrayHash.php | 36 ++++++++++++++++++- src/Fields/ArrayList.php | 14 +++++++- .../lib/Integration/Fields/ArrayHashTest.php | 21 +++++++++++ .../lib/Integration/Fields/ArrayListTest.php | 10 ++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/Fields/ArrayHash.php b/src/Fields/ArrayHash.php index 170eb4a..52de6b2 100644 --- a/src/Fields/ArrayHash.php +++ b/src/Fields/ArrayHash.php @@ -14,10 +14,17 @@ use Closure; use LaravelJsonApi\Core\Json\Hash; use LaravelJsonApi\Core\Support\Arr; +use LaravelJsonApi\Validation\Fields\IsValidated; + +use LaravelJsonApi\Validation\Fields\ValidatedWithKeyedSetOfRules; + +use LaravelJsonApi\Validation\Rules\JsonObject; + use function is_null; -class ArrayHash extends Attribute +class ArrayHash extends Attribute implements IsValidated { + use ValidatedWithKeyedSetOfRules; /** * @var Closure|null @@ -48,6 +55,13 @@ class ArrayHash extends Attribute */ private ?string $keyCase = null; + /** + * Whether an empty array is allowed as the value. + * + * @var bool + */ + private bool $allowEmpty = false; + /** * Create an array attribute. * @@ -184,6 +198,19 @@ public function dasherizeKeys(): self return $this; } + /** + * Whether an empty array is allowed as the value. + * + * @param bool $allowEmpty + * @return self + */ + public function allowEmpty(bool $allowEmpty = true): self + { + $this->allowEmpty = $allowEmpty; + + return $this; + } + /** * @inheritDoc */ @@ -232,4 +259,11 @@ protected function assertValue($value): void } } + /** + * @return array + */ + protected function defaultRules(): array + { + return ['.' => (new JsonObject())->allowEmpty($this->allowEmpty)]; + } } diff --git a/src/Fields/ArrayList.php b/src/Fields/ArrayList.php index a1faa3a..8c4b737 100644 --- a/src/Fields/ArrayList.php +++ b/src/Fields/ArrayList.php @@ -12,11 +12,16 @@ namespace LaravelJsonApi\Eloquent\Fields; use Illuminate\Support\Arr; +use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Fields\ValidatedWithKeyedSetOfRules; +use LaravelJsonApi\Validation\Rules\JsonArray; + use function is_null; use function sort; -class ArrayList extends Attribute +class ArrayList extends Attribute implements IsValidated { + use ValidatedWithKeyedSetOfRules; /** * @var bool @@ -88,4 +93,11 @@ protected function assertValue($value): void } } + /** + * @return array + */ + protected function defaultRules(): array + { + return ['.' => new JsonArray()]; + } } diff --git a/tests/lib/Integration/Fields/ArrayHashTest.php b/tests/lib/Integration/Fields/ArrayHashTest.php index 3ce44ca..d7bd425 100644 --- a/tests/lib/Integration/Fields/ArrayHashTest.php +++ b/tests/lib/Integration/Fields/ArrayHashTest.php @@ -16,6 +16,8 @@ use Illuminate\Http\Request; use LaravelJsonApi\Eloquent\Fields\ArrayHash; use LaravelJsonApi\Eloquent\Tests\Integration\TestCase; +use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Rules\JsonObject; class ArrayHashTest extends TestCase { @@ -572,4 +574,23 @@ public function testHiddenCallback(): void $this->assertTrue($attr->isHidden($mock)); } + public function testIsValidated(): void + { + $attr = ArrayHash::make('permissions'); + + $this->assertInstanceOf(IsValidated::class, $attr); + $this->assertEquals($expected = ['.' => new JsonObject()], $attr->rulesForCreation(null)); + $this->assertEquals($expected, $attr->rulesForUpdate(null, new \stdClass())); + } + + public function testIsValidatedAndAllowsEmpty(): void + { + $attr = ArrayHash::make('permissions')->allowEmpty(); + + $this->assertEquals( + $expected = ['.' => (new JsonObject())->allowEmpty()], + $attr->rulesForCreation(null) + ); + $this->assertEquals($expected, $attr->rulesForUpdate(null, new \stdClass())); + } } diff --git a/tests/lib/Integration/Fields/ArrayListTest.php b/tests/lib/Integration/Fields/ArrayListTest.php index fb558d6..ce5c06c 100644 --- a/tests/lib/Integration/Fields/ArrayListTest.php +++ b/tests/lib/Integration/Fields/ArrayListTest.php @@ -16,6 +16,8 @@ use Illuminate\Http\Request; use LaravelJsonApi\Eloquent\Fields\ArrayList; use LaravelJsonApi\Eloquent\Tests\Integration\TestCase; +use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Rules\JsonArray; class ArrayListTest extends TestCase { @@ -327,4 +329,12 @@ public function testHiddenCallback(): void $this->assertTrue($attr->isHidden($mock)); } + public function testItIsValidated(): void + { + $attr = ArrayList::make('permissions'); + + $this->assertInstanceOf(IsValidated::class, $attr); + $this->assertEquals($expected = ['.' => new JsonArray()], $attr->rulesForCreation(null)); + $this->assertEquals($expected, $attr->rulesForUpdate(null, new \stdClass())); + } } From 91b092ca5c591f42c62f148d1130baa76cae8ffa Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Sat, 23 Mar 2024 12:13:33 +0000 Subject: [PATCH 04/10] feat: add validation contract to map field --- src/Fields/Map.php | 50 +++++++++++++++++++++++- tests/lib/Integration/Fields/MapTest.php | 43 ++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/Fields/Map.php b/src/Fields/Map.php index 030ad82..cb33423 100644 --- a/src/Fields/Map.php +++ b/src/Fields/Map.php @@ -12,6 +12,7 @@ namespace LaravelJsonApi\Eloquent\Fields; use Illuminate\Database\Eloquent\Model; +use Illuminate\Http\Request; use Illuminate\Support\Arr; use InvalidArgumentException; use LaravelJsonApi\Contracts\Resources\Serializer\Attribute as SerializableContract; @@ -23,6 +24,7 @@ use LaravelJsonApi\Eloquent\Fields\Concerns\Hideable; use LaravelJsonApi\Eloquent\Fields\Concerns\IsReadOnly; use LaravelJsonApi\Eloquent\Fields\Concerns\OnRelated; +use LaravelJsonApi\Validation\Fields\IsValidated; use LogicException; class Map implements @@ -30,9 +32,9 @@ class Map implements EagerLoadableField, Fillable, Selectable, - SerializableContract + SerializableContract, + IsValidated { - use Hideable; use OnRelated; use IsReadOnly; @@ -215,6 +217,50 @@ public function serialize(object $model) return $values ?: null; } + /** + * @inheritDoc + */ + public function rulesForCreation(?Request $request): ?array + { + $fields = []; + $rules = []; + + /** @var AttributeContract $attr */ + foreach ($this->map as $attr) { + if ($attr instanceof IsValidated) { + $fields[] = $name = $attr->name(); + $rules[$name] = $attr->rulesForCreation($request); + } + } + + return !empty($fields) ? [ + '.' => 'array:' . implode(',', $fields), + ...$rules, + ] : null; + } + + /** + * @inheritDoc + */ + public function rulesForUpdate(?Request $request, object $model): ?array + { + $fields = []; + $rules = []; + + /** @var AttributeContract $attr */ + foreach ($this->map as $attr) { + if ($attr instanceof IsValidated) { + $fields[] = $name = $attr->name(); + $rules[$name] = $attr->rulesForUpdate($request, $model); + } + } + + return !empty($fields) ? [ + '.' => 'array:' . implode(',', $fields), + ...$rules, + ] : null; + } + /** * Set all values to null. * diff --git a/tests/lib/Integration/Fields/MapTest.php b/tests/lib/Integration/Fields/MapTest.php index a3fc2e2..fd118ac 100644 --- a/tests/lib/Integration/Fields/MapTest.php +++ b/tests/lib/Integration/Fields/MapTest.php @@ -19,6 +19,9 @@ use LaravelJsonApi\Eloquent\Fields\Number; use LaravelJsonApi\Eloquent\Fields\Str; use LaravelJsonApi\Eloquent\Tests\Integration\TestCase; +use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Rules\JsonNumber; +use LaravelJsonApi\Validation\Rules\JsonObject; class MapTest extends TestCase { @@ -283,4 +286,44 @@ public function testHiddenCallback(): void $this->assertTrue($map->isHidden($mock)); } + public function testItIsValidatedOnCreate(): void + { + $request = $this->createMock(Request::class); + $model = new \stdClass(); + + $map = Map::make('options', [ + Str::make('foo', 'option_foo') + ->creationRules(function ($r) use ($request) { + $this->assertSame($request, $r); + return ['foo1']; + }) + ->updateRules(function ($r, $m) use ($request, $model) { + $this->assertSame($request, $r); + $this->assertSame($model, $m); + return ['foo2']; + }), + Number::make('bar', 'option_bar') + ->creationRules(function ($r) use ($request) { + $this->assertSame($request, $r); + return ['bar1']; + }) + ->updateRules(function ($r, $m) use ($request, $model) { + $this->assertSame($request, $r); + $this->assertSame($model, $m); + return ['bar2']; + }), + ]); + + $this->assertInstanceOf(IsValidated::class, $map); + $this->assertEquals([ + '.' => 'array:bar,foo', + 'foo' => ['string', 'foo1'], + 'bar' => [new JsonNumber(), 'bar1'], + ], $map->rulesForCreation($request)); + $this->assertEquals([ + '.' => 'array:bar,foo', + 'foo' => ['string', 'foo2'], + 'bar' => [new JsonNumber(), 'bar2'], + ], $map->rulesForUpdate($request, $model)); + } } From c8b5433389d97e49c6e696525dc0682fdc28f39e Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Sun, 24 Mar 2024 18:05:49 +0000 Subject: [PATCH 05/10] feat: update implementation for static schemas --- src/Schema.php | 10 ++- tests/app/Schemas/CarOwnerSchema.php | 10 +-- tests/app/Schemas/CarSchema.php | 10 +-- tests/app/Schemas/CommentSchema.php | 10 +-- tests/app/Schemas/CountrySchema.php | 10 +-- tests/app/Schemas/ImageSchema.php | 10 +-- tests/app/Schemas/MechanicSchema.php | 10 +-- tests/app/Schemas/PhoneSchema.php | 10 +-- tests/app/Schemas/PostSchema.php | 9 +-- tests/app/Schemas/RoleSchema.php | 10 +-- tests/app/Schemas/TagSchema.php | 10 +-- tests/app/Schemas/UserAccountSchema.php | 10 +-- tests/app/Schemas/UserSchema.php | 10 +-- tests/app/Schemas/VideoSchema.php | 11 +-- tests/lib/Acceptance/FilterTest.php | 4 +- .../Pagination/PagePaginationTest.php | 7 +- tests/lib/Acceptance/SortTest.php | 4 +- tests/lib/Acceptance/TestCase.php | 75 +++++++++++++++---- 18 files changed, 104 insertions(+), 126 deletions(-) diff --git a/src/Schema.php b/src/Schema.php index 4c01301..92a43da 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -33,7 +33,6 @@ abstract class Schema extends BaseSchema implements CountableSchema { - /** * The relationships that should always be eager loaded. * @@ -86,6 +85,14 @@ public function repository(): Repository ); } + /** + * @return class-string + */ + public function model(): string + { + return $this->static->getModel(); + } + /** * @return Model */ @@ -354,5 +361,4 @@ protected function driver(): Driver { return new StandardDriver($this->newInstance()); } - } diff --git a/tests/app/Schemas/CarOwnerSchema.php b/tests/app/Schemas/CarOwnerSchema.php index 9059a38..a7323b4 100644 --- a/tests/app/Schemas/CarOwnerSchema.php +++ b/tests/app/Schemas/CarOwnerSchema.php @@ -12,6 +12,7 @@ namespace App\Schemas; use App\Models\CarOwner; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Contracts\Paginator; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; @@ -21,16 +22,9 @@ use LaravelJsonApi\Eloquent\Filters\WhereIdIn; use LaravelJsonApi\Eloquent\Schema; +#[Model(CarOwner::class)] class CarOwnerSchema extends Schema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = CarOwner::class; - /** * @inheritDoc */ diff --git a/tests/app/Schemas/CarSchema.php b/tests/app/Schemas/CarSchema.php index 012eeb9..86a319b 100644 --- a/tests/app/Schemas/CarSchema.php +++ b/tests/app/Schemas/CarSchema.php @@ -12,6 +12,7 @@ namespace App\Schemas; use App\Models\Car; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Contracts\Paginator; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; @@ -20,16 +21,9 @@ use LaravelJsonApi\Eloquent\Filters\WhereIdIn; use LaravelJsonApi\Eloquent\Schema; +#[Model(Car::class)] class CarSchema extends Schema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = Car::class; - /** * @inheritDoc */ diff --git a/tests/app/Schemas/CommentSchema.php b/tests/app/Schemas/CommentSchema.php index 47e61d0..0868e71 100644 --- a/tests/app/Schemas/CommentSchema.php +++ b/tests/app/Schemas/CommentSchema.php @@ -13,6 +13,7 @@ use App\Models\Comment; use LaravelJsonApi\Contracts\Pagination\Paginator; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; use LaravelJsonApi\Eloquent\Fields\Relations\BelongsTo; @@ -22,16 +23,9 @@ use LaravelJsonApi\Eloquent\Pagination\PagePagination; use LaravelJsonApi\Eloquent\Schema; +#[Model(Comment::class)] class CommentSchema extends Schema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = Comment::class; - /** * @inheritDoc */ diff --git a/tests/app/Schemas/CountrySchema.php b/tests/app/Schemas/CountrySchema.php index 34bf9cc..df3a0fd 100644 --- a/tests/app/Schemas/CountrySchema.php +++ b/tests/app/Schemas/CountrySchema.php @@ -12,6 +12,7 @@ namespace App\Schemas; use App\Models\Country; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Contracts\Paginator; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; @@ -20,16 +21,9 @@ use LaravelJsonApi\Eloquent\Filters\WhereIdIn; use LaravelJsonApi\Eloquent\Schema; +#[Model(Country::class)] class CountrySchema extends Schema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = Country::class; - /** * @inheritDoc */ diff --git a/tests/app/Schemas/ImageSchema.php b/tests/app/Schemas/ImageSchema.php index 940e381..5e7ff2c 100644 --- a/tests/app/Schemas/ImageSchema.php +++ b/tests/app/Schemas/ImageSchema.php @@ -12,6 +12,7 @@ namespace App\Schemas; use App\Models\Image; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Contracts\Paginator; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; @@ -20,16 +21,9 @@ use LaravelJsonApi\Eloquent\Filters\WhereIdIn; use LaravelJsonApi\Eloquent\Schema; +#[Model(Image::class)] class ImageSchema extends Schema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = Image::class; - /** * @inheritDoc */ diff --git a/tests/app/Schemas/MechanicSchema.php b/tests/app/Schemas/MechanicSchema.php index 052a1de..46c6c15 100644 --- a/tests/app/Schemas/MechanicSchema.php +++ b/tests/app/Schemas/MechanicSchema.php @@ -12,6 +12,7 @@ namespace App\Schemas; use App\Models\Mechanic; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Contracts\Paginator; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; @@ -20,16 +21,9 @@ use LaravelJsonApi\Eloquent\Filters\WhereIdIn; use LaravelJsonApi\Eloquent\Schema; +#[Model(Mechanic::class)] class MechanicSchema extends Schema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = Mechanic::class; - /** * @inheritDoc */ diff --git a/tests/app/Schemas/PhoneSchema.php b/tests/app/Schemas/PhoneSchema.php index 082c4fe..38fc5c1 100644 --- a/tests/app/Schemas/PhoneSchema.php +++ b/tests/app/Schemas/PhoneSchema.php @@ -13,6 +13,7 @@ use App\Models\Phone; use LaravelJsonApi\Contracts\Pagination\Paginator; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; use LaravelJsonApi\Eloquent\Fields\Relations\BelongsTo; @@ -21,16 +22,9 @@ use LaravelJsonApi\Eloquent\Filters\WhereIdIn; use LaravelJsonApi\Eloquent\Schema; +#[Model(Phone::class)] class PhoneSchema extends Schema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = Phone::class; - /** * @inheritDoc */ diff --git a/tests/app/Schemas/PostSchema.php b/tests/app/Schemas/PostSchema.php index 6fbb229..1d7247b 100644 --- a/tests/app/Schemas/PostSchema.php +++ b/tests/app/Schemas/PostSchema.php @@ -13,6 +13,7 @@ use App\Models\Post; use LaravelJsonApi\Contracts\Pagination\Paginator; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; use LaravelJsonApi\Eloquent\Fields\Relations\BelongsTo; @@ -36,17 +37,11 @@ use LaravelJsonApi\Eloquent\SoftDeletes; use LaravelJsonApi\Eloquent\Sorting\SortCountable; +#[Model(Post::class)] class PostSchema extends Schema { use SoftDeletes; - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = Post::class; - /** * @var array|null */ diff --git a/tests/app/Schemas/RoleSchema.php b/tests/app/Schemas/RoleSchema.php index 6e353cb..b3d69e1 100644 --- a/tests/app/Schemas/RoleSchema.php +++ b/tests/app/Schemas/RoleSchema.php @@ -12,6 +12,7 @@ namespace App\Schemas; use App\Models\Role; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Contracts\Paginator; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; @@ -20,16 +21,9 @@ use LaravelJsonApi\Eloquent\Filters\WhereIdIn; use LaravelJsonApi\Eloquent\Schema; +#[Model(Role::class)] class RoleSchema extends Schema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = Role::class; - /** * @inheritDoc */ diff --git a/tests/app/Schemas/TagSchema.php b/tests/app/Schemas/TagSchema.php index b1f89c7..2b9bc94 100644 --- a/tests/app/Schemas/TagSchema.php +++ b/tests/app/Schemas/TagSchema.php @@ -13,6 +13,7 @@ use App\Models\Tag; use LaravelJsonApi\Contracts\Pagination\Paginator; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; use LaravelJsonApi\Eloquent\Fields\Relations\BelongsToMany; @@ -21,16 +22,9 @@ use LaravelJsonApi\Eloquent\Filters\WhereIdIn; use LaravelJsonApi\Eloquent\Schema; +#[Model(Tag::class)] class TagSchema extends Schema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = Tag::class; - /** * @inheritDoc */ diff --git a/tests/app/Schemas/UserAccountSchema.php b/tests/app/Schemas/UserAccountSchema.php index dbaaf1a..1935a1e 100644 --- a/tests/app/Schemas/UserAccountSchema.php +++ b/tests/app/Schemas/UserAccountSchema.php @@ -12,6 +12,7 @@ namespace App\Schemas; use App\Models\UserAccount; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Contracts\Paginator; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; @@ -23,16 +24,9 @@ use LaravelJsonApi\Eloquent\Pagination\PagePagination; use LaravelJsonApi\Eloquent\ProxySchema; +#[Model(UserAccount::class)] class UserAccountSchema extends ProxySchema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = UserAccount::class; - /** * @inheritDoc */ diff --git a/tests/app/Schemas/UserSchema.php b/tests/app/Schemas/UserSchema.php index f309507..558af26 100644 --- a/tests/app/Schemas/UserSchema.php +++ b/tests/app/Schemas/UserSchema.php @@ -12,6 +12,7 @@ namespace App\Schemas; use App\Models\User; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Contracts\Paginator; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; @@ -25,16 +26,9 @@ use LaravelJsonApi\Eloquent\Filters\WhereIdIn; use LaravelJsonApi\Eloquent\Schema; +#[Model(User::class)] class UserSchema extends Schema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = User::class; - /** * @inheritDoc */ diff --git a/tests/app/Schemas/VideoSchema.php b/tests/app/Schemas/VideoSchema.php index 197cbec..b830a6d 100644 --- a/tests/app/Schemas/VideoSchema.php +++ b/tests/app/Schemas/VideoSchema.php @@ -13,6 +13,7 @@ use App\Models\Video; use LaravelJsonApi\Contracts\Pagination\Paginator; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; use LaravelJsonApi\Eloquent\Fields\Relations\BelongsToMany; @@ -22,16 +23,9 @@ use LaravelJsonApi\Eloquent\Filters\WhereIdIn; use LaravelJsonApi\Eloquent\Schema; +#[Model(Video::class)] class VideoSchema extends Schema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = Video::class; - /** * @inheritDoc */ @@ -69,5 +63,4 @@ public function pagination(): ?Paginator { return null; } - } diff --git a/tests/lib/Acceptance/FilterTest.php b/tests/lib/Acceptance/FilterTest.php index c033791..50903fd 100644 --- a/tests/lib/Acceptance/FilterTest.php +++ b/tests/lib/Acceptance/FilterTest.php @@ -33,10 +33,12 @@ protected function setUp(): void { parent::setUp(); + $static = self::$staticSchemas->schemaFor(PostSchema::class); + $this->posts = $this ->getMockBuilder(PostSchema::class) ->onlyMethods(['isSingular']) - ->setConstructorArgs(['server' => $this->server()]) + ->setConstructorArgs(['server' => $this->server(), 'static' => $static]) ->getMock(); } diff --git a/tests/lib/Acceptance/Pagination/PagePaginationTest.php b/tests/lib/Acceptance/Pagination/PagePaginationTest.php index dfc7581..8d4aac4 100644 --- a/tests/lib/Acceptance/Pagination/PagePaginationTest.php +++ b/tests/lib/Acceptance/Pagination/PagePaginationTest.php @@ -53,16 +53,19 @@ protected function setUp(): void $this->paginator = PagePagination::make(); + $posts = self::$staticSchemas->schemaFor(PostSchema::class); + $videos = self::$staticSchemas->schemaFor(VideoSchema::class); + $this->posts = $this ->getMockBuilder(PostSchema::class) ->onlyMethods(['pagination', 'defaultPagination']) - ->setConstructorArgs(['server' => $this->server()]) + ->setConstructorArgs(['server' => $this->server(), 'static' => $posts]) ->getMock(); $this->videos = $this ->getMockBuilder(VideoSchema::class) ->onlyMethods(['pagination', 'defaultPagination']) - ->setConstructorArgs(['server' => $this->server()]) + ->setConstructorArgs(['server' => $this->server(), 'static' => $videos]) ->getMock(); $this->posts->method('pagination')->willReturn($this->paginator); diff --git a/tests/lib/Acceptance/SortTest.php b/tests/lib/Acceptance/SortTest.php index e840a53..1efbb1c 100644 --- a/tests/lib/Acceptance/SortTest.php +++ b/tests/lib/Acceptance/SortTest.php @@ -31,10 +31,12 @@ protected function setUp(): void { parent::setUp(); + $static = self::$staticSchemas->schemaFor(PostSchema::class); + $this->posts = $this ->getMockBuilder(PostSchema::class) ->onlyMethods(['defaultSort']) - ->setConstructorArgs(['server' => $this->server()]) + ->setConstructorArgs(['server' => $this->server(), 'static' => $static]) ->getMock(); } diff --git a/tests/lib/Acceptance/TestCase.php b/tests/lib/Acceptance/TestCase.php index a3e6139..fcebedb 100644 --- a/tests/lib/Acceptance/TestCase.php +++ b/tests/lib/Acceptance/TestCase.php @@ -15,8 +15,11 @@ use Illuminate\Foundation\Testing\Concerns\InteractsWithDeprecationHandling; use Illuminate\Support\Arr; use LaravelJsonApi\Contracts\Schema\Container as SchemaContainerContract; +use LaravelJsonApi\Contracts\Schema\StaticSchema\StaticSchema; use LaravelJsonApi\Contracts\Server\Server; use LaravelJsonApi\Core\Schema\Container as SchemaContainer; +use LaravelJsonApi\Core\Schema\StaticSchema\StaticContainer; +use LaravelJsonApi\Core\Schema\StaticSchema\StaticSchemaFactory; use LaravelJsonApi\Core\Schema\TypeResolver; use LaravelJsonApi\Core\Support\ContainerResolver; use Orchestra\Testbench\TestCase as BaseTestCase; @@ -25,6 +28,46 @@ class TestCase extends BaseTestCase { use InteractsWithDeprecationHandling; + /** + * @var StaticContainer|null + */ + protected static ?StaticContainer $staticSchemas = null; + + /** + * @return void + */ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + $factory = new StaticSchemaFactory(); + + self::$staticSchemas = new StaticContainer($factory->make([ + Schemas\CarOwnerSchema::class, + Schemas\CarSchema::class, + Schemas\CommentSchema::class, + Schemas\CountrySchema::class, + Schemas\ImageSchema::class, + Schemas\MechanicSchema::class, + Schemas\PhoneSchema::class, + Schemas\PostSchema::class, + Schemas\RoleSchema::class, + Schemas\TagSchema::class, + Schemas\UserAccountSchema::class, + Schemas\UserSchema::class, + Schemas\VideoSchema::class, + ])); + } + + /** + * @return void + */ + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + self::$staticSchemas = null; + } + /** * @inheritDoc */ @@ -38,23 +81,21 @@ protected function setUp(): void $this->app->singleton(SchemaContainerContract::class, function ($container) { $resolver = new ContainerResolver(static fn() => $container); - return new SchemaContainer($resolver, $container->make(Server::class), [ - Schemas\CarOwnerSchema::class, - Schemas\CarSchema::class, - Schemas\CommentSchema::class, - Schemas\CountrySchema::class, - Schemas\ImageSchema::class, - Schemas\MechanicSchema::class, - Schemas\PhoneSchema::class, - Schemas\PostSchema::class, - Schemas\RoleSchema::class, - Schemas\TagSchema::class, - Schemas\UserAccountSchema::class, - Schemas\UserSchema::class, - Schemas\VideoSchema::class, - ]); + return new SchemaContainer( + $resolver, + $container->make(Server::class), + self::$staticSchemas, + ); }); + foreach (self::$staticSchemas as $schema) { + $class = $schema->getSchemaClass(); + $this->app + ->when($class) + ->needs(StaticSchema::class) + ->give(fn () => self::$staticSchemas->schemaFor($class)); + } + $this->app->singleton(Server::class, function () { $server = $this->createMock(Server::class); $server->method('schemas')->willReturnCallback(fn() => $this->schemas()); @@ -85,9 +126,11 @@ protected function server(): Server */ protected function createSchemaWithDefaultEagerLoading(string $class, $paths): void { + $static = self::$staticSchemas->schemaFor($class); + $mock = $this ->getMockBuilder($class) - ->setConstructorArgs(['server' => $this->server()]) + ->setConstructorArgs(['server' => $this->server(), 'static' => $static]) ->onlyMethods(['with']) ->getMock(); From b2f6dcca1e053c780ee993a4138fc417df0d398e Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 28 Mar 2024 21:04:20 +0000 Subject: [PATCH 06/10] feat: update validation of all fields including relations --- src/Contracts/FillableToMany.php | 4 ++-- src/Contracts/FillableToOne.php | 4 ++-- src/Fields/ArrayHash.php | 12 ++++------ src/Fields/ArrayList.php | 8 +++---- src/Fields/Boolean.php | 4 ++-- src/Fields/DateTime.php | 5 ++-- src/Fields/Integer.php | 4 ++-- src/Fields/Number.php | 4 ++-- src/Fields/Relations/BelongsTo.php | 11 +++++++++ src/Fields/Relations/BelongsToMany.php | 17 +++++++++++++- src/Fields/Relations/HasMany.php | 15 ++++++++++++ src/Fields/Relations/HasOne.php | 12 ++++++++++ src/Fields/Relations/MorphToMany.php | 15 ++++++++++++ src/Fields/Relations/Relation.php | 1 - src/Fields/Str.php | 4 ++-- .../lib/Integration/Fields/ArrayHashTest.php | 17 ++++++++++---- .../lib/Integration/Fields/ArrayListTest.php | 2 +- .../Fields/Relations/BelongsToManyTest.php | 22 ++++++++++++++++++ .../Fields/Relations/BelongsToTest.php | 17 ++++++++++++++ .../Fields/Relations/HasManyTest.php | 23 +++++++++++++++++++ .../Fields/Relations/HasOneTest.php | 17 ++++++++++++++ 21 files changed, 183 insertions(+), 35 deletions(-) diff --git a/src/Contracts/FillableToMany.php b/src/Contracts/FillableToMany.php index 3d0a27b..bdc7208 100644 --- a/src/Contracts/FillableToMany.php +++ b/src/Contracts/FillableToMany.php @@ -14,10 +14,10 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; use LaravelJsonApi\Eloquent\Polymorphism\MorphMany; +use LaravelJsonApi\Validation\Fields\IsValidated; -interface FillableToMany extends IsReadOnly +interface FillableToMany extends IsReadOnly, IsValidated { - /** * Fill the model with the value of the JSON:API to-many relation. * diff --git a/src/Contracts/FillableToOne.php b/src/Contracts/FillableToOne.php index c11c065..85b7c5b 100644 --- a/src/Contracts/FillableToOne.php +++ b/src/Contracts/FillableToOne.php @@ -12,10 +12,10 @@ namespace LaravelJsonApi\Eloquent\Contracts; use Illuminate\Database\Eloquent\Model; +use LaravelJsonApi\Validation\Fields\IsValidated; -interface FillableToOne extends IsReadOnly +interface FillableToOne extends IsReadOnly, IsValidated { - /** * Does the model need to exist in the database before the relation is filled? * diff --git a/src/Fields/ArrayHash.php b/src/Fields/ArrayHash.php index 52de6b2..e4f4518 100644 --- a/src/Fields/ArrayHash.php +++ b/src/Fields/ArrayHash.php @@ -15,16 +15,12 @@ use LaravelJsonApi\Core\Json\Hash; use LaravelJsonApi\Core\Support\Arr; use LaravelJsonApi\Validation\Fields\IsValidated; - -use LaravelJsonApi\Validation\Fields\ValidatedWithKeyedSetOfRules; - +use LaravelJsonApi\Validation\Fields\ValidatedWithArrayKeys; use LaravelJsonApi\Validation\Rules\JsonObject; -use function is_null; - class ArrayHash extends Attribute implements IsValidated { - use ValidatedWithKeyedSetOfRules; + use ValidatedWithArrayKeys; /** * @var Closure|null @@ -235,7 +231,7 @@ protected function deserialize($value) $value = ($this->keys)($value); } - if (is_null($value)) { + if ($value === null) { return null; } @@ -251,7 +247,7 @@ protected function deserialize($value) */ protected function assertValue($value): void { - if ((!is_null($value) && !is_array($value)) || (!empty($value) && !Arr::isAssoc($value))) { + if (($value !== null && !is_array($value)) || (!empty($value) && !Arr::isAssoc($value))) { throw new \UnexpectedValueException(sprintf( 'Expecting the value of attribute %s to be an associative array.', $this->name() diff --git a/src/Fields/ArrayList.php b/src/Fields/ArrayList.php index 8c4b737..0c9d747 100644 --- a/src/Fields/ArrayList.php +++ b/src/Fields/ArrayList.php @@ -13,15 +13,13 @@ use Illuminate\Support\Arr; use LaravelJsonApi\Validation\Fields\IsValidated; -use LaravelJsonApi\Validation\Fields\ValidatedWithKeyedSetOfRules; +use LaravelJsonApi\Validation\Fields\ValidatedWithArrayKeys; use LaravelJsonApi\Validation\Rules\JsonArray; - -use function is_null; use function sort; class ArrayList extends Attribute implements IsValidated { - use ValidatedWithKeyedSetOfRules; + use ValidatedWithArrayKeys; /** * @var bool @@ -85,7 +83,7 @@ protected function deserialize($value) */ protected function assertValue($value): void { - if ((!is_null($value) && !is_array($value)) || (!empty($value) && Arr::isAssoc($value))) { + if (($value !== null && !is_array($value)) || (!empty($value) && Arr::isAssoc($value))) { throw new \UnexpectedValueException(sprintf( 'Expecting the value of attribute %s to be an array list.', $this->name() diff --git a/src/Fields/Boolean.php b/src/Fields/Boolean.php index 4d76d76..38bffae 100644 --- a/src/Fields/Boolean.php +++ b/src/Fields/Boolean.php @@ -12,12 +12,12 @@ namespace LaravelJsonApi\Eloquent\Fields; use LaravelJsonApi\Validation\Fields\IsValidated; -use LaravelJsonApi\Validation\Fields\ValidatedWithListOfRules; +use LaravelJsonApi\Validation\Fields\ValidatedWithRules; use LaravelJsonApi\Validation\Rules\JsonBoolean; class Boolean extends Attribute implements IsValidated { - use ValidatedWithListOfRules; + use ValidatedWithRules; /** * Create a boolean attribute. diff --git a/src/Fields/DateTime.php b/src/Fields/DateTime.php index 81e829b..fb5a748 100644 --- a/src/Fields/DateTime.php +++ b/src/Fields/DateTime.php @@ -12,16 +12,15 @@ namespace LaravelJsonApi\Eloquent\Fields; use Carbon\CarbonInterface; -use Closure; use Illuminate\Support\Facades\Date; use LaravelJsonApi\Validation\Fields\IsValidated; -use LaravelJsonApi\Validation\Fields\ValidatedWithListOfRules; +use LaravelJsonApi\Validation\Fields\ValidatedWithRules; use LaravelJsonApi\Validation\Rules\DateTimeIso8601; use function config; class DateTime extends Attribute implements IsValidated { - use ValidatedWithListOfRules; + use ValidatedWithRules; /** * Should dates be converted to the defined time zone? diff --git a/src/Fields/Integer.php b/src/Fields/Integer.php index 43a7bd4..60dcc5b 100644 --- a/src/Fields/Integer.php +++ b/src/Fields/Integer.php @@ -12,13 +12,13 @@ namespace LaravelJsonApi\Eloquent\Fields; use LaravelJsonApi\Validation\Fields\IsValidated; -use LaravelJsonApi\Validation\Fields\ValidatedWithListOfRules; +use LaravelJsonApi\Validation\Fields\ValidatedWithRules; use LaravelJsonApi\Validation\Rules\JsonNumber; use UnexpectedValueException; class Integer extends Attribute implements IsValidated { - use ValidatedWithListOfRules; + use ValidatedWithRules; /** * @var bool diff --git a/src/Fields/Number.php b/src/Fields/Number.php index 16a84f1..3283b13 100644 --- a/src/Fields/Number.php +++ b/src/Fields/Number.php @@ -12,13 +12,13 @@ namespace LaravelJsonApi\Eloquent\Fields; use LaravelJsonApi\Validation\Fields\IsValidated; -use LaravelJsonApi\Validation\Fields\ValidatedWithListOfRules; +use LaravelJsonApi\Validation\Fields\ValidatedWithRules; use LaravelJsonApi\Validation\Rules\JsonNumber; use UnexpectedValueException; class Number extends Attribute implements IsValidated { - use ValidatedWithListOfRules; + use ValidatedWithRules; /** * @var bool diff --git a/src/Fields/Relations/BelongsTo.php b/src/Fields/Relations/BelongsTo.php index c8f97a8..c9d7fa3 100644 --- a/src/Fields/Relations/BelongsTo.php +++ b/src/Fields/Relations/BelongsTo.php @@ -14,10 +14,13 @@ use Illuminate\Database\Eloquent\Model; use LaravelJsonApi\Eloquent\Contracts\FillableToOne; use LaravelJsonApi\Eloquent\Fields\Concerns\IsReadOnly; +use LaravelJsonApi\Validation\Fields\ValidatedWithArrayKeys; +use LaravelJsonApi\Validation\Rules\HasOne as HasOneRule; class BelongsTo extends ToOne implements FillableToOne { use IsReadOnly; + use ValidatedWithArrayKeys; /** * Create a belongs-to relation. @@ -93,4 +96,12 @@ public function associate(Model $model, ?array $identifier): ?Model return $model->getRelation($this->relationName()); } + + /** + * @return array + */ + protected function defaultRules(): array + { + return ['.' => ['array:type,id', new HasOneRule($this)]]; + } } diff --git a/src/Fields/Relations/BelongsToMany.php b/src/Fields/Relations/BelongsToMany.php index 541a3b2..d66140e 100644 --- a/src/Fields/Relations/BelongsToMany.php +++ b/src/Fields/Relations/BelongsToMany.php @@ -11,18 +11,22 @@ namespace LaravelJsonApi\Eloquent\Fields\Relations; +use Closure; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany as EloquentBelongsToMany; use InvalidArgumentException; use LaravelJsonApi\Eloquent\Contracts\FillableToMany; use LaravelJsonApi\Eloquent\Fields\Concerns\IsReadOnly; +use LaravelJsonApi\Validation\Fields\ValidatedWithArrayKeys; +use LaravelJsonApi\Validation\Rules\HasMany as HasManyRule; +use LaravelJsonApi\Validation\Rules\JsonArray; use function sprintf; class BelongsToMany extends ToMany implements FillableToMany { - use IsReadOnly; + use ValidatedWithArrayKeys; /** * Create a belongs-to-many relation. @@ -142,6 +146,17 @@ public function detach(Model $model, array $identifiers): iterable return $related; } + /** + * @return array + */ + protected function defaultRules(): array + { + return [ + '.' => [new JsonArray(), new HasManyRule($this)], + '*' => ['array:type,id'], + ]; + } + /** * @param Model $model * @return EloquentBelongsToMany diff --git a/src/Fields/Relations/HasMany.php b/src/Fields/Relations/HasMany.php index 56b04b9..814683d 100644 --- a/src/Fields/Relations/HasMany.php +++ b/src/Fields/Relations/HasMany.php @@ -17,6 +17,9 @@ use Illuminate\Database\Eloquent\Relations\MorphMany as EloquentMorphMany; use LaravelJsonApi\Eloquent\Contracts\FillableToMany; use LaravelJsonApi\Eloquent\Fields\Concerns\IsReadOnly; +use LaravelJsonApi\Validation\Fields\ValidatedWithArrayKeys; +use LaravelJsonApi\Validation\Rules\HasMany as HasManyRule; +use LaravelJsonApi\Validation\Rules\JsonArray; use function sprintf; class HasMany extends ToMany implements FillableToMany @@ -31,6 +34,7 @@ class HasMany extends ToMany implements FillableToMany private const FORCE_DELETE_DETACHED_MODELS = 2; use IsReadOnly; + use ValidatedWithArrayKeys; /** * Flag for how to detach models from the relationship. @@ -134,6 +138,17 @@ public function detach(Model $model, array $identifiers): iterable return $models; } + /** + * @return array + */ + protected function defaultRules(): array + { + return [ + '.' => [new JsonArray(), new HasManyRule($this)], + '*' => ['array:type,id'], + ]; + } + /** * @param Model $model * @param EloquentCollection $new diff --git a/src/Fields/Relations/HasOne.php b/src/Fields/Relations/HasOne.php index f4c9968..df2a5b6 100644 --- a/src/Fields/Relations/HasOne.php +++ b/src/Fields/Relations/HasOne.php @@ -16,9 +16,13 @@ use Illuminate\Database\Eloquent\Relations\MorphOne as EloquentMorphOne; use LaravelJsonApi\Eloquent\Contracts\FillableToOne; use LaravelJsonApi\Eloquent\Fields\Concerns\IsReadOnly; +use LaravelJsonApi\Validation\Fields\ValidatedWithArrayKeys; +use LaravelJsonApi\Validation\Rules\HasOne as HasOneRule; class HasOne extends ToOne implements FillableToOne { + use ValidatedWithArrayKeys; + /** @var int */ private const KEEP_DETACHED_MODEL = 0; @@ -135,6 +139,14 @@ public function associate(Model $model, ?array $identifier): ?Model return $model->getRelation($this->relationName()); } + /** + * @return array + */ + protected function defaultRules(): array + { + return ['.' => ['array:type,id', new HasOneRule($this)]]; + } + /** * @param Model|null $current * @param Model|null $new diff --git a/src/Fields/Relations/MorphToMany.php b/src/Fields/Relations/MorphToMany.php index b03af01..f5faa4c 100644 --- a/src/Fields/Relations/MorphToMany.php +++ b/src/Fields/Relations/MorphToMany.php @@ -23,6 +23,9 @@ use LaravelJsonApi\Eloquent\Fields\Concerns\IsReadOnly; use LaravelJsonApi\Eloquent\Polymorphism\MorphMany; use LaravelJsonApi\Eloquent\Polymorphism\MorphValue; +use LaravelJsonApi\Validation\Fields\ValidatedWithArrayKeys; +use LaravelJsonApi\Validation\Rules\HasMany as HasManyRule; +use LaravelJsonApi\Validation\Rules\JsonArray; use LogicException; use Traversable; use UnexpectedValueException; @@ -31,6 +34,7 @@ class MorphToMany extends ToMany implements PolymorphicRelation, IteratorAggrega { use Polymorphic; use IsReadOnly; + use ValidatedWithArrayKeys; /** * @var array @@ -240,6 +244,17 @@ public function parse($models): iterable throw new LogicException('Expecting model value to already be a morph many value.'); } + /** + * @return array + */ + protected function defaultRules(): array + { + return [ + '.' => [new JsonArray(), new HasManyRule($this)], + '*' => ['array:type,id'], + ]; + } + /** * Get the identifiers that are valid for the supplied relation. * diff --git a/src/Fields/Relations/Relation.php b/src/Fields/Relations/Relation.php index 87a1a06..259612e 100644 --- a/src/Fields/Relations/Relation.php +++ b/src/Fields/Relations/Relation.php @@ -35,7 +35,6 @@ abstract class Relation implements RelationContract, SchemaAwareContract, SerializableContract { - use EagerLoadable; use Filterable; use Hideable; diff --git a/src/Fields/Str.php b/src/Fields/Str.php index 16bbd2c..85eb4b8 100644 --- a/src/Fields/Str.php +++ b/src/Fields/Str.php @@ -12,11 +12,11 @@ namespace LaravelJsonApi\Eloquent\Fields; use LaravelJsonApi\Validation\Fields\IsValidated; -use LaravelJsonApi\Validation\Fields\ValidatedWithListOfRules; +use LaravelJsonApi\Validation\Fields\ValidatedWithRules; class Str extends Attribute implements IsValidated { - use ValidatedWithListOfRules; + use ValidatedWithRules; /** * Create a string attribute. diff --git a/tests/lib/Integration/Fields/ArrayHashTest.php b/tests/lib/Integration/Fields/ArrayHashTest.php index d7bd425..c0f4a41 100644 --- a/tests/lib/Integration/Fields/ArrayHashTest.php +++ b/tests/lib/Integration/Fields/ArrayHashTest.php @@ -576,19 +576,28 @@ public function testHiddenCallback(): void public function testIsValidated(): void { - $attr = ArrayHash::make('permissions'); + $attr = ArrayHash::make('permissions') + ->creationRules(['.' => 'array:foo,bar']) + ->updateRules(['.' => 'array:foo,bar']); + + $expected = [ + '.' => [new JsonObject(), 'array:foo,bar'], + ]; $this->assertInstanceOf(IsValidated::class, $attr); - $this->assertEquals($expected = ['.' => new JsonObject()], $attr->rulesForCreation(null)); + $this->assertEquals($expected, $attr->rulesForCreation(null)); $this->assertEquals($expected, $attr->rulesForUpdate(null, new \stdClass())); } public function testIsValidatedAndAllowsEmpty(): void { - $attr = ArrayHash::make('permissions')->allowEmpty(); + $attr = ArrayHash::make('permissions') + ->allowEmpty() + ->creationRules(['.' => 'array:foo,bar']) + ->updateRules(['.' => 'array:foo,bar']); $this->assertEquals( - $expected = ['.' => (new JsonObject())->allowEmpty()], + $expected = ['.' => [(new JsonObject())->allowEmpty(), 'array:foo,bar']], $attr->rulesForCreation(null) ); $this->assertEquals($expected, $attr->rulesForUpdate(null, new \stdClass())); diff --git a/tests/lib/Integration/Fields/ArrayListTest.php b/tests/lib/Integration/Fields/ArrayListTest.php index ce5c06c..13b8bc4 100644 --- a/tests/lib/Integration/Fields/ArrayListTest.php +++ b/tests/lib/Integration/Fields/ArrayListTest.php @@ -334,7 +334,7 @@ public function testItIsValidated(): void $attr = ArrayList::make('permissions'); $this->assertInstanceOf(IsValidated::class, $attr); - $this->assertEquals($expected = ['.' => new JsonArray()], $attr->rulesForCreation(null)); + $this->assertEquals($expected = ['.' => [new JsonArray()]], $attr->rulesForCreation(null)); $this->assertEquals($expected, $attr->rulesForUpdate(null, new \stdClass())); } } diff --git a/tests/lib/Integration/Fields/Relations/BelongsToManyTest.php b/tests/lib/Integration/Fields/Relations/BelongsToManyTest.php index a6e6490..296f5ff 100644 --- a/tests/lib/Integration/Fields/Relations/BelongsToManyTest.php +++ b/tests/lib/Integration/Fields/Relations/BelongsToManyTest.php @@ -16,6 +16,9 @@ use LaravelJsonApi\Contracts\Schema\Filter; use LaravelJsonApi\Eloquent\Fields\Relations\BelongsToMany; use LaravelJsonApi\Eloquent\Tests\Integration\TestCase; +use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Rules\HasMany; +use LaravelJsonApi\Validation\Rules\JsonArray; class BelongsToManyTest extends TestCase { @@ -69,6 +72,25 @@ public function testItIsNotValidatedByDefault(): void $this->assertFalse($relation->isValidated()); } + public function testValidationRules(): void + { + $relation = BelongsToMany::make('tags') + ->creationRules(['*.type' => 'foo']) + ->updateRules(['*.type' => 'bar']); + + $this->assertInstanceOf(IsValidated::class, $relation); + $this->assertEquals([ + '.' => [new JsonArray(), new HasMany($relation)], + '*' => ['array:type,id'], + '*.type' => ['foo'], + ], $relation->rulesForCreation(null)); + $this->assertEquals([ + '.' => [new JsonArray(), new HasMany($relation)], + '*' => ['array:type,id'], + '*.type' => ['bar'], + ], $relation->rulesForUpdate(null, new \stdClass())); + } + public function testUriName(): void { $relation = BelongsToMany::make('blogTags'); diff --git a/tests/lib/Integration/Fields/Relations/BelongsToTest.php b/tests/lib/Integration/Fields/Relations/BelongsToTest.php index fbc4247..d72756a 100644 --- a/tests/lib/Integration/Fields/Relations/BelongsToTest.php +++ b/tests/lib/Integration/Fields/Relations/BelongsToTest.php @@ -15,6 +15,8 @@ use LaravelJsonApi\Contracts\Schema\Filter; use LaravelJsonApi\Eloquent\Fields\Relations\BelongsTo; use LaravelJsonApi\Eloquent\Tests\Integration\TestCase; +use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Rules\HasOne; class BelongsToTest extends TestCase { @@ -68,6 +70,21 @@ public function testItIsValidatedByDefault(): void $this->assertTrue($relation->isValidated()); } + public function testValidationRules(): void + { + $relation = BelongsTo::make('author') + ->creationRules(['.' => 'foo']) + ->updateRules(['.' => 'bar']); + + $this->assertInstanceOf(IsValidated::class, $relation); + $this->assertEquals([ + '.' => ['array:type,id', new HasOne($relation), 'foo'], + ], $relation->rulesForCreation(null)); + $this->assertEquals([ + '.' => ['array:type,id', new HasOne($relation), 'bar'], + ], $relation->rulesForUpdate(null, new \stdClass())); + } + public function testItDoesNotNeedToExist(): void { $relation = BelongsTo::make('author'); diff --git a/tests/lib/Integration/Fields/Relations/HasManyTest.php b/tests/lib/Integration/Fields/Relations/HasManyTest.php index b4d05a7..e7030d0 100644 --- a/tests/lib/Integration/Fields/Relations/HasManyTest.php +++ b/tests/lib/Integration/Fields/Relations/HasManyTest.php @@ -15,6 +15,9 @@ use LaravelJsonApi\Contracts\Schema\Filter; use LaravelJsonApi\Eloquent\Fields\Relations\HasMany; use LaravelJsonApi\Eloquent\Tests\Integration\TestCase; +use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Rules\HasMany as HasManyRule; +use LaravelJsonApi\Validation\Rules\JsonArray; class HasManyTest extends TestCase { @@ -68,6 +71,26 @@ public function testItIsNotValidatedByDefault(): void $this->assertFalse($relation->isValidated()); } + + public function testValidationRules(): void + { + $relation = HasMany::make('tags') + ->creationRules(['*.type' => 'foo']) + ->updateRules(['*.type' => 'bar']); + + $this->assertInstanceOf(IsValidated::class, $relation); + $this->assertEquals([ + '.' => [new JsonArray(), new HasManyRule($relation)], + '*' => ['array:type,id'], + '*.type' => ['foo'], + ], $relation->rulesForCreation(null)); + $this->assertEquals([ + '.' => [new JsonArray(), new HasManyRule($relation)], + '*' => ['array:type,id'], + '*.type' => ['bar'], + ], $relation->rulesForUpdate(null, new \stdClass())); + } + public function testUriName(): void { $relation = HasMany::make('blogTags'); diff --git a/tests/lib/Integration/Fields/Relations/HasOneTest.php b/tests/lib/Integration/Fields/Relations/HasOneTest.php index b24f840..2b40d46 100644 --- a/tests/lib/Integration/Fields/Relations/HasOneTest.php +++ b/tests/lib/Integration/Fields/Relations/HasOneTest.php @@ -15,6 +15,8 @@ use LaravelJsonApi\Contracts\Schema\Filter; use LaravelJsonApi\Eloquent\Fields\Relations\HasOne; use LaravelJsonApi\Eloquent\Tests\Integration\TestCase; +use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Rules\HasOne as HasOneRule; class HasOneTest extends TestCase { @@ -68,6 +70,21 @@ public function testItIsNotValidatedByDefault(): void $this->assertFalse($relation->isValidated()); } + public function testValidationRules(): void + { + $relation = HasOne::make('author') + ->creationRules(['.' => 'foo']) + ->updateRules(['.' => 'bar']); + + $this->assertInstanceOf(IsValidated::class, $relation); + $this->assertEquals([ + '.' => ['array:type,id', new HasOneRule($relation), 'foo'], + ], $relation->rulesForCreation(null)); + $this->assertEquals([ + '.' => ['array:type,id', new HasOneRule($relation), 'bar'], + ], $relation->rulesForUpdate(null, new \stdClass())); + } + public function testItDoesNotNeedToExist(): void { $relation = HasOne::make('author'); From c91e4488ea5045d10cc301c8bc3fa91d853f184a Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 28 Mar 2024 22:47:42 +0000 Subject: [PATCH 07/10] refactor: update map field to use new rules utility class --- src/Fields/Map.php | 35 ++++-------------------- tests/lib/Integration/Fields/MapTest.php | 4 +-- 2 files changed, 7 insertions(+), 32 deletions(-) diff --git a/src/Fields/Map.php b/src/Fields/Map.php index cb33423..64e8ec5 100644 --- a/src/Fields/Map.php +++ b/src/Fields/Map.php @@ -24,6 +24,7 @@ use LaravelJsonApi\Eloquent\Fields\Concerns\Hideable; use LaravelJsonApi\Eloquent\Fields\Concerns\IsReadOnly; use LaravelJsonApi\Eloquent\Fields\Concerns\OnRelated; +use LaravelJsonApi\Validation\Fields\FieldRuleMap; use LaravelJsonApi\Validation\Fields\IsValidated; use LogicException; @@ -222,21 +223,8 @@ public function serialize(object $model) */ public function rulesForCreation(?Request $request): ?array { - $fields = []; - $rules = []; - - /** @var AttributeContract $attr */ - foreach ($this->map as $attr) { - if ($attr instanceof IsValidated) { - $fields[] = $name = $attr->name(); - $rules[$name] = $attr->rulesForCreation($request); - } - } - - return !empty($fields) ? [ - '.' => 'array:' . implode(',', $fields), - ...$rules, - ] : null; + return FieldRuleMap::make($this->map) + ->creation($request); } /** @@ -244,21 +232,8 @@ public function rulesForCreation(?Request $request): ?array */ public function rulesForUpdate(?Request $request, object $model): ?array { - $fields = []; - $rules = []; - - /** @var AttributeContract $attr */ - foreach ($this->map as $attr) { - if ($attr instanceof IsValidated) { - $fields[] = $name = $attr->name(); - $rules[$name] = $attr->rulesForUpdate($request, $model); - } - } - - return !empty($fields) ? [ - '.' => 'array:' . implode(',', $fields), - ...$rules, - ] : null; + return FieldRuleMap::make($this->map) + ->update($request, $model); } /** diff --git a/tests/lib/Integration/Fields/MapTest.php b/tests/lib/Integration/Fields/MapTest.php index fd118ac..2b65f80 100644 --- a/tests/lib/Integration/Fields/MapTest.php +++ b/tests/lib/Integration/Fields/MapTest.php @@ -316,12 +316,12 @@ public function testItIsValidatedOnCreate(): void $this->assertInstanceOf(IsValidated::class, $map); $this->assertEquals([ - '.' => 'array:bar,foo', + '.' => ['array:bar,foo'], 'foo' => ['string', 'foo1'], 'bar' => [new JsonNumber(), 'bar1'], ], $map->rulesForCreation($request)); $this->assertEquals([ - '.' => 'array:bar,foo', + '.' => ['array:bar,foo'], 'foo' => ['string', 'foo2'], 'bar' => [new JsonNumber(), 'bar2'], ], $map->rulesForUpdate($request, $model)); From d6c108706b281e91894bc48d1b3148001799378a Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 28 Mar 2024 22:48:39 +0000 Subject: [PATCH 08/10] feat: add validation to filters and page paginator --- src/Contracts/Filter.php | 4 ++-- src/Filters/Concerns/DeserializesValue.php | 21 ++++++++++------ src/Filters/Concerns/HasDelimiter.php | 13 ++++------ src/Filters/Has.php | 19 ++++++++++++--- src/Filters/OnlyTrashed.php | 2 -- src/Filters/Scope.php | 9 ++++--- src/Filters/Where.php | 28 ++++++++++++++++++---- src/Filters/WhereHas.php | 16 ++++++++++++- src/Filters/WhereIdIn.php | 15 ++++++++++-- src/Filters/WhereIdNotIn.php | 1 - src/Filters/WhereIn.php | 15 +++++++----- src/Filters/WhereNotIn.php | 1 - src/Filters/WhereNull.php | 21 +++++++++++++--- src/Filters/WherePivot.php | 2 -- src/Filters/WherePivotIn.php | 1 - src/Filters/WherePivotNotIn.php | 1 - src/Filters/WithTrashed.php | 19 +++++++++++---- src/Pagination/PagePagination.php | 24 +++++++++++++++++-- 18 files changed, 158 insertions(+), 54 deletions(-) diff --git a/src/Contracts/Filter.php b/src/Contracts/Filter.php index f6aaa47..a63cb15 100644 --- a/src/Contracts/Filter.php +++ b/src/Contracts/Filter.php @@ -13,10 +13,10 @@ use Illuminate\Database\Eloquent\Builder; use LaravelJsonApi\Contracts\Schema\Filter as BaseFilter; +use LaravelJsonApi\Validation\Filters\IsValidated; -interface Filter extends BaseFilter +interface Filter extends BaseFilter, IsValidated { - /** * Does the filter return a singular resource? * diff --git a/src/Filters/Concerns/DeserializesValue.php b/src/Filters/Concerns/DeserializesValue.php index 21804b1..185ad05 100644 --- a/src/Filters/Concerns/DeserializesValue.php +++ b/src/Filters/Concerns/DeserializesValue.php @@ -16,21 +16,26 @@ trait DeserializesValue { - /** * @var Closure|null */ private ?Closure $deserializer = null; + /** + * @var bool + */ + private bool $asBool = false; + /** * Use the supplied callback to deserialize the value. * * @param Closure $deserializer * @return $this */ - public function deserializeUsing(Closure $deserializer): self + public function deserializeUsing(Closure $deserializer): static { $this->deserializer = $deserializer; + $this->asBool = false; return $this; } @@ -40,11 +45,9 @@ public function deserializeUsing(Closure $deserializer): self * * @return $this */ - public function asBoolean(): self + public function asBoolean(): static { - $this->deserializeUsing( - static fn($value) => filter_var($value, FILTER_VALIDATE_BOOL) - ); + $this->asBool = true; return $this; } @@ -55,8 +58,12 @@ public function asBoolean(): self * @param mixed $value * @return mixed */ - protected function deserialize($value) + protected function deserialize(mixed $value): mixed { + if (true === $this->asBool) { + return filter_var($value, FILTER_VALIDATE_BOOL); + } + if ($this->deserializer) { return ($this->deserializer)($value); } diff --git a/src/Filters/Concerns/HasDelimiter.php b/src/Filters/Concerns/HasDelimiter.php index 347196d..43e77fc 100644 --- a/src/Filters/Concerns/HasDelimiter.php +++ b/src/Filters/Concerns/HasDelimiter.php @@ -19,7 +19,6 @@ trait HasDelimiter { - /** * @var string|null */ @@ -28,14 +27,12 @@ trait HasDelimiter /** * If the filter accepts a string value, the delimiter to use to extract values. * - * @param string $delimiter + * @param non-empty-string $delimiter * @return $this */ - public function delimiter(string $delimiter): self + public function delimiter(string $delimiter): static { - if (empty($delimiter)) { - throw new InvalidArgumentException('Expecting a non-empty string delimiter.'); - } + assert(!empty($delimiter), 'Expecting a non-empty string delimiter.'); $this->delimiter = $delimiter; @@ -45,10 +42,10 @@ public function delimiter(string $delimiter): self /** * Convert the provided value to an array. * - * @param string|array|null $value + * @param mixed $value * @return array */ - protected function toArray($value): array + protected function toArray(mixed $value): array { if ($this->delimiter && is_string($value)) { return ('' !== $value) ? explode($this->delimiter, $value) : []; diff --git a/src/Filters/Has.php b/src/Filters/Has.php index 2fb357e..6c82697 100644 --- a/src/Filters/Has.php +++ b/src/Filters/Has.php @@ -11,16 +11,21 @@ namespace LaravelJsonApi\Eloquent\Filters; +use Illuminate\Http\Request; +use LaravelJsonApi\Core\Query\Input\Query; use LaravelJsonApi\Eloquent\Contracts\Filter; use LaravelJsonApi\Eloquent\Filters\Concerns\HasRelation; use LaravelJsonApi\Eloquent\Filters\Concerns\IsSingular; use LaravelJsonApi\Eloquent\Schema; +use LaravelJsonApi\Validation\Filters\Validated; +use LaravelJsonApi\Validation\Rules\JsonBoolean; use function filter_var; class Has implements Filter { use HasRelation; use IsSingular; + use Validated; /** * Create a new filter. @@ -28,9 +33,9 @@ class Has implements Filter * @param Schema $schema * @param string $fieldName * @param string|null $key - * @return static + * @return self */ - public static function make(Schema $schema, string $fieldName, string $key = null) + public static function make(Schema $schema, string $fieldName, string $key = null): self { return new static($schema, $fieldName, $key); } @@ -64,13 +69,21 @@ public function apply($query, $value) return $query->doesntHave($relationName); } + /** + * @inheritDoc + */ + public function validationRules(?Request $request, Query $query): array + { + return [(new JsonBoolean())->asString()]; + } + /** * Deserialize the value. * * @param mixed $value * @return bool */ - protected function deserialize($value): bool + protected function deserialize(mixed $value): bool { return filter_var($value, FILTER_VALIDATE_BOOL); } diff --git a/src/Filters/OnlyTrashed.php b/src/Filters/OnlyTrashed.php index a014689..465c400 100644 --- a/src/Filters/OnlyTrashed.php +++ b/src/Filters/OnlyTrashed.php @@ -15,7 +15,6 @@ class OnlyTrashed extends WithTrashed { - /** * @inheritDoc */ @@ -31,5 +30,4 @@ public function apply($query, $value) throw new LogicException("Filter {$this->key()} expects query builder to have a `withTrashed` method."); } - } diff --git a/src/Filters/Scope.php b/src/Filters/Scope.php index 9e21653..6fccbe8 100644 --- a/src/Filters/Scope.php +++ b/src/Filters/Scope.php @@ -13,12 +13,15 @@ use LaravelJsonApi\Core\Support\Str; use LaravelJsonApi\Eloquent\Contracts\Filter; +use LaravelJsonApi\Eloquent\Filters\Concerns\DeserializesValue; +use LaravelJsonApi\Eloquent\Filters\Concerns\IsSingular; +use LaravelJsonApi\Validation\Filters\ValidatedWithRules; class Scope implements Filter { - - use Concerns\DeserializesValue; - use Concerns\IsSingular; + use DeserializesValue; + use IsSingular; + use ValidatedWithRules; /** * @var string diff --git a/src/Filters/Where.php b/src/Filters/Where.php index d1672e2..5e02eea 100644 --- a/src/Filters/Where.php +++ b/src/Filters/Where.php @@ -13,14 +13,20 @@ use LaravelJsonApi\Core\Support\Str; use LaravelJsonApi\Eloquent\Contracts\Filter; +use LaravelJsonApi\Eloquent\Filters\Concerns\DeserializesValue; +use LaravelJsonApi\Eloquent\Filters\Concerns\HasColumn; +use LaravelJsonApi\Eloquent\Filters\Concerns\HasOperator; +use LaravelJsonApi\Eloquent\Filters\Concerns\IsSingular; +use LaravelJsonApi\Validation\Filters\ValidatedWithRules; +use LaravelJsonApi\Validation\Rules\JsonBoolean; class Where implements Filter { - - use Concerns\DeserializesValue; - use Concerns\HasColumn; - use Concerns\HasOperator; - use Concerns\IsSingular; + use DeserializesValue; + use HasColumn; + use HasOperator; + use IsSingular; + use ValidatedWithRules; /** * @var string @@ -72,6 +78,18 @@ public function apply($query, $value) ); } + /** + * @return array + */ + protected function defaultRules(): array + { + if ($this->asBool) { + return [(new JsonBoolean())->asString()]; + } + + return []; + } + /** * @return string */ diff --git a/src/Filters/WhereHas.php b/src/Filters/WhereHas.php index 2241f55..9bef446 100644 --- a/src/Filters/WhereHas.php +++ b/src/Filters/WhereHas.php @@ -12,18 +12,23 @@ namespace LaravelJsonApi\Eloquent\Filters; use Closure; +use Illuminate\Http\Request; +use LaravelJsonApi\Core\Query\Input\Query; use LaravelJsonApi\Eloquent\Contracts\Filter; use LaravelJsonApi\Eloquent\Filters\Concerns\DeserializesToArray; use LaravelJsonApi\Eloquent\Filters\Concerns\HasRelation; use LaravelJsonApi\Eloquent\Filters\Concerns\IsSingular; use LaravelJsonApi\Eloquent\QueryBuilder\Applicators\FilterApplicator; use LaravelJsonApi\Eloquent\Schema; +use LaravelJsonApi\Validation\Filters\FilterRuleMap; +use LaravelJsonApi\Validation\Filters\Validated; class WhereHas implements Filter { use DeserializesToArray; use HasRelation; use IsSingular; + use Validated; /** * Create a new filter. @@ -63,13 +68,22 @@ public function apply($query, $value) ); } + /** + * @inheritDoc + */ + public function validationRules(?Request $request, Query $query): array + { + return FilterRuleMap::make($this->schema->filters()) + ->rules($request, $query); + } + /** * Get the relation query callback. * * @param mixed $value * @return Closure */ - protected function callback($value): Closure + protected function callback(mixed $value): Closure { return function($query) use ($value) { $relation = $this->relation(); diff --git a/src/Filters/WhereIdIn.php b/src/Filters/WhereIdIn.php index c823208..aa61b25 100644 --- a/src/Filters/WhereIdIn.php +++ b/src/Filters/WhereIdIn.php @@ -11,17 +11,21 @@ namespace LaravelJsonApi\Eloquent\Filters; +use Closure; use Illuminate\Database\Eloquent\Model; use LaravelJsonApi\Contracts\Schema\ID; use LaravelJsonApi\Contracts\Schema\Schema; use LaravelJsonApi\Core\Schema\IdParser; use LaravelJsonApi\Eloquent\Contracts\Filter; +use LaravelJsonApi\Eloquent\Filters\Concerns\HasDelimiter; use LaravelJsonApi\Eloquent\Schema as EloquentSchema; +use LaravelJsonApi\Validation\Filters\ValidatedWithRules; +use LaravelJsonApi\Validation\Rules\ListOfIds; class WhereIdIn implements Filter { - - use Concerns\HasDelimiter; + use HasDelimiter; + use ValidatedWithRules; /** * @var ID @@ -143,4 +147,11 @@ protected function deserialize($value): array ); } + /** + * @return array + */ + protected function defaultRules(): array + { + return [new ListOfIds($this->field, $this->delimiter)]; + } } diff --git a/src/Filters/WhereIdNotIn.php b/src/Filters/WhereIdNotIn.php index cb56c15..b6a26fb 100644 --- a/src/Filters/WhereIdNotIn.php +++ b/src/Filters/WhereIdNotIn.php @@ -13,7 +13,6 @@ class WhereIdNotIn extends WhereIdIn { - /** * @inheritDoc */ diff --git a/src/Filters/WhereIn.php b/src/Filters/WhereIn.php index 0b4f230..0ca2a01 100644 --- a/src/Filters/WhereIn.php +++ b/src/Filters/WhereIn.php @@ -13,13 +13,17 @@ use LaravelJsonApi\Core\Support\Str; use LaravelJsonApi\Eloquent\Contracts\Filter; +use LaravelJsonApi\Eloquent\Filters\Concerns\DeserializesValue; +use LaravelJsonApi\Eloquent\Filters\Concerns\HasColumn; +use LaravelJsonApi\Eloquent\Filters\Concerns\HasDelimiter; +use LaravelJsonApi\Validation\Filters\ValidatedWithRules; class WhereIn implements Filter { - - use Concerns\DeserializesValue; - use Concerns\HasColumn; - use Concerns\HasDelimiter; + use DeserializesValue; + use HasColumn; + use HasDelimiter; + use ValidatedWithRules; /** * @var string @@ -78,7 +82,7 @@ public function apply($query, $value) } /** - * Deserialize the fitler value. + * Deserialize the filter value. * * @param string|array $value * @return array @@ -101,5 +105,4 @@ private function guessColumn(): string Str::singular($this->name) ); } - } diff --git a/src/Filters/WhereNotIn.php b/src/Filters/WhereNotIn.php index c7c64c6..8401f2c 100644 --- a/src/Filters/WhereNotIn.php +++ b/src/Filters/WhereNotIn.php @@ -13,7 +13,6 @@ class WhereNotIn extends WhereIn { - /** * @inheritDoc */ diff --git a/src/Filters/WhereNull.php b/src/Filters/WhereNull.php index f6cbd79..f441deb 100644 --- a/src/Filters/WhereNull.php +++ b/src/Filters/WhereNull.php @@ -11,13 +11,20 @@ namespace LaravelJsonApi\Eloquent\Filters; +use Illuminate\Http\Request; +use LaravelJsonApi\Core\Query\Input\Query; use LaravelJsonApi\Core\Support\Str; use LaravelJsonApi\Eloquent\Contracts\Filter; +use LaravelJsonApi\Eloquent\Filters\Concerns\HasColumn; +use LaravelJsonApi\Eloquent\Filters\Concerns\IsSingular; +use LaravelJsonApi\Validation\Filters\Validated; +use LaravelJsonApi\Validation\Rules\JsonBoolean; class WhereNull implements Filter { - use Concerns\HasColumn; - use Concerns\IsSingular; + use HasColumn; + use IsSingular; + use Validated; /** * @var string @@ -71,6 +78,14 @@ public function apply($query, $value) return $query->whereNotNull($column); } + /** + * @inheritDoc + */ + public function validationRules(?Request $request, Query $query): array + { + return [(new JsonBoolean())->asString()]; + } + /** * Should a "where null" query be used? * @@ -88,7 +103,7 @@ protected function isWhereNull(bool $value): bool * @param mixed $value * @return bool */ - private function deserialize($value): bool + private function deserialize(mixed $value): bool { return filter_var($value, FILTER_VALIDATE_BOOL); } diff --git a/src/Filters/WherePivot.php b/src/Filters/WherePivot.php index 1927ed4..631ac51 100644 --- a/src/Filters/WherePivot.php +++ b/src/Filters/WherePivot.php @@ -15,7 +15,6 @@ class WherePivot extends Where { - /** * @inheritDoc */ @@ -40,5 +39,4 @@ public function apply($query, $value) $this->deserialize($value) ); } - } diff --git a/src/Filters/WherePivotIn.php b/src/Filters/WherePivotIn.php index 1527354..c82dfeb 100644 --- a/src/Filters/WherePivotIn.php +++ b/src/Filters/WherePivotIn.php @@ -15,7 +15,6 @@ class WherePivotIn extends WhereIn { - /** * @inheritDoc */ diff --git a/src/Filters/WherePivotNotIn.php b/src/Filters/WherePivotNotIn.php index 479d9ea..ada5dbe 100644 --- a/src/Filters/WherePivotNotIn.php +++ b/src/Filters/WherePivotNotIn.php @@ -15,7 +15,6 @@ class WherePivotNotIn extends WhereIn { - /** * @inheritDoc */ diff --git a/src/Filters/WithTrashed.php b/src/Filters/WithTrashed.php index d395586..4051a11 100644 --- a/src/Filters/WithTrashed.php +++ b/src/Filters/WithTrashed.php @@ -11,12 +11,17 @@ namespace LaravelJsonApi\Eloquent\Filters; +use Illuminate\Http\Request; +use LaravelJsonApi\Core\Query\Input\Query; use LaravelJsonApi\Eloquent\Contracts\Filter; +use LaravelJsonApi\Validation\Filters\Validated; +use LaravelJsonApi\Validation\Rules\JsonBoolean; use LogicException; use function filter_var; class WithTrashed implements Filter { + use Validated; /** * @var string @@ -75,13 +80,19 @@ public function key(): string } /** - * @param $value + * @inheritDoc + */ + public function validationRules(?Request $request, Query $query): array + { + return [(new JsonBoolean())->asString()]; + } + + /** + * @param mixed $value * @return bool */ - protected function deserialize($value): bool + protected function deserialize(mixed $value): bool { return filter_var($value, FILTER_VALIDATE_BOOL); } - - } diff --git a/src/Pagination/PagePagination.php b/src/Pagination/PagePagination.php index f6a2126..48ffb5d 100644 --- a/src/Pagination/PagePagination.php +++ b/src/Pagination/PagePagination.php @@ -20,12 +20,14 @@ use LaravelJsonApi\Core\Pagination\Concerns\HasPageNumbers; use LaravelJsonApi\Core\Pagination\Page; use LaravelJsonApi\Eloquent\Contracts\Paginator; +use LaravelJsonApi\Validation\Pagination\IsValidated; +use LaravelJsonApi\Validation\Pagination\Validated; -class PagePagination implements Paginator +class PagePagination implements Paginator, IsValidated { - use HasPageMeta; use HasPageNumbers; + use Validated; /** * @var array|null @@ -171,6 +173,24 @@ protected function isSimplePagination(): bool return (bool) $this->simplePagination; } + /** + * @return array + */ + protected function defaultRules(): array + { + return [ + $this->pageKey => array_filter([ + $this->required ? 'required' : null, + 'integer', + 'min:1', + ]), + $this->perPageKey => [ + 'integer', + $this->maxPerPage > 0 ? 'between:1,' . $this->maxPerPage : 'min:1', + ], + ]; + } + /** * @param Builder|Relation $query * @return bool From aeedead2b242718b09e0c91721e3efe57f1fa4f1 Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Fri, 29 Mar 2024 12:19:28 +0000 Subject: [PATCH 09/10] refactor: use client id rule in id field --- src/Fields/ID.php | 3 ++- tests/lib/Integration/Fields/IdTest.php | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Fields/ID.php b/src/Fields/ID.php index 1c43677..dcc1fef 100644 --- a/src/Fields/ID.php +++ b/src/Fields/ID.php @@ -19,6 +19,7 @@ use LaravelJsonApi\Core\Schema\Concerns\Sortable; use LaravelJsonApi\Eloquent\Contracts\Fillable; use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Rules\ClientId; class ID implements IDContract, Fillable, IsValidated { @@ -126,7 +127,7 @@ public function nullable(): self public function rulesForCreation(?Request $request): array|null { if ($this->acceptsClientIds()) { - return [$this->validationModifier, "regex:/^{$this->pattern}$/{$this->flags}"]; + return [$this->validationModifier, new ClientId($this)]; } return null; diff --git a/tests/lib/Integration/Fields/IdTest.php b/tests/lib/Integration/Fields/IdTest.php index 7eed8a7..b9dedfd 100644 --- a/tests/lib/Integration/Fields/IdTest.php +++ b/tests/lib/Integration/Fields/IdTest.php @@ -17,6 +17,7 @@ use LaravelJsonApi\Eloquent\Fields\ID; use LaravelJsonApi\Eloquent\Tests\Integration\TestCase; use LaravelJsonApi\Validation\Fields\IsValidated; +use LaravelJsonApi\Validation\Rules\ClientId; class IdTest extends TestCase { @@ -61,7 +62,7 @@ public function testIsValidatedWhenClientId(): void $id = ID::make()->clientIds(); $this->assertInstanceOf(IsValidated::class, $id); - $this->assertSame(['required', 'regex:/^[0-9]+$/iD'], $id->rulesForCreation(null)); + $this->assertEquals(['required', new ClientId($id)], $id->rulesForCreation(null)); $this->assertNull($id->rulesForUpdate(null, new \stdClass())); } @@ -70,7 +71,7 @@ public function testIsValidatedWhenNullableClientId(): void $id = ID::make()->clientIds()->nullable(); $this->assertInstanceOf(IsValidated::class, $id); - $this->assertSame(['nullable', 'regex:/^[0-9]+$/iD'], $id->rulesForCreation(null)); + $this->assertEquals(['nullable', new ClientId($id)], $id->rulesForCreation(null)); $this->assertNull($id->rulesForUpdate(null, new \stdClass())); } From 423c24f38cad4dc6be58421529cdcddc41055b09 Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Wed, 26 Jun 2024 19:39:22 +0100 Subject: [PATCH 10/10] feat: add default boolean rule to scope filter --- src/Filters/Scope.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Filters/Scope.php b/src/Filters/Scope.php index 6fccbe8..0e59974 100644 --- a/src/Filters/Scope.php +++ b/src/Filters/Scope.php @@ -16,6 +16,7 @@ use LaravelJsonApi\Eloquent\Filters\Concerns\DeserializesValue; use LaravelJsonApi\Eloquent\Filters\Concerns\IsSingular; use LaravelJsonApi\Validation\Filters\ValidatedWithRules; +use LaravelJsonApi\Validation\Rules\JsonBoolean; class Scope implements Filter { @@ -75,6 +76,18 @@ public function apply($query, $value) ); } + /** + * @return array + */ + protected function defaultRules(): array + { + if ($this->asBool) { + return [(new JsonBoolean())->asString()]; + } + + return []; + } + /** * @return string */