From 84b5e056a09090dcdc1fa30d89ed63df4daee156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Milhoran=C3=A7a?= Date: Mon, 1 Dec 2025 17:48:55 -0300 Subject: [PATCH] feat: add required if check --- .github/workflows/ci.yml | 2 +- check.sh | 34 ++++ src/Model/Model.php | 22 ++- src/Model/Schema/SchemaAttribute.php | 18 ++ tests/Model/ComprehensiveModelTest.php | 222 ++++++++++++++++++++++++- 5 files changed, 290 insertions(+), 8 deletions(-) create mode 100755 check.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4dcd8b8..6525ba0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - php-version: [8.1, 8.2, 8.3] # opcional, se quiser testar várias versões + php-version: [8.1, 8.2, 8.3] steps: - name: Checkout repository diff --git a/check.sh b/check.sh new file mode 100755 index 0000000..cf53117 --- /dev/null +++ b/check.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +offer_run() { + read -p "For more output, run \"$1\". Run it now (Y/n)? " run + + case ${run:0:1} in + n|N ) + exit 1 + ;; + * ) + eval "$1" + ;; + esac + + exit 1 +} + +if (./vendor/bin/phpstan analyse --memory-limit=1G > /dev/null 2>/dev/null); then + echo '✅ PHPStan OK' +else + echo '❌ PHPStan FAILED' + offer_run "./vendor/bin/phpstan analyse --memory-limit=1G" +fi + +if (./vendor/bin/phpunit --colors=always > /dev/null 2>/dev/null); then + echo '✅ PHPUnit OK' +else + echo '❌ PHPUnit FAILED' + offer_run "./vendor/bin/phpunit --colors=always" +fi + +echo '==============================' +echo '✅ All checks passed (local CI)' diff --git a/src/Model/Model.php b/src/Model/Model.php index a3626b2..c94c3dd 100644 --- a/src/Model/Model.php +++ b/src/Model/Model.php @@ -101,19 +101,35 @@ public function setSchemaName(string $name): self */ public function fill(array $data): static { + $snapshot = $this->copy(); + $snapshot->mutators = []; + foreach ($data as $key => $value) { - $this->set($key, $value); + $snapshot->set($key, $value); } - foreach ($this->schema->getAttributes() as $attr) { - if ($attr->isRequired() && !array_key_exists($attr->getName(), $data)) { + foreach ($snapshot->schema->getAttributes() as $attr) { + /** @var SchemaAttribute $attr */ + if ( + ($attr->isRequired() || $attr->isRequiredIf($snapshot->get($attr->getName()), $snapshot)) + && !array_key_exists($attr->getName(), $data) + ) { throw new SchemaAttributeParseException($attr, "Missing required attribute"); } } + $this->data = $snapshot->data; + $this->relations = $snapshot->relations; + $this->mutators = $snapshot->mutators; + return $this; } + public function copy(): static + { + return clone $this; + } + public function set(string $attribute, mixed $value): self { $attributeSchema = $this->schema->query($attribute); diff --git a/src/Model/Schema/SchemaAttribute.php b/src/Model/Schema/SchemaAttribute.php index d16a9f3..659e7dc 100644 --- a/src/Model/Schema/SchemaAttribute.php +++ b/src/Model/Schema/SchemaAttribute.php @@ -17,6 +17,7 @@ class SchemaAttribute protected mixed $default; protected bool $hasDefault; protected bool $required; + protected ?Closure $requiredCheck; public function __construct(Schema $schema, string $name) { @@ -31,6 +32,7 @@ public function __construct(Schema $schema, string $name) $this->hasDefault = false; $this->required = false; + $this->requiredCheck = null; } public function getName(): string @@ -84,6 +86,11 @@ public function isHiddenIf(mixed $value, Model $model): bool return $this->hiddenCheck?->__invoke($value, $model) ?? false; } + public function isRequiredIf(mixed $value, Model $model): bool + { + return $this->requiredCheck?->__invoke($value, $model) ?? false; + } + public function hasDefault(): bool { return $this->hasDefault; @@ -133,6 +140,17 @@ public function required(bool $default = true): self return $this; } + public function requiredIf(callable $check): self + { + $this->requiredCheck = $check instanceof Closure ? $check : Closure::fromCallable($check); + return $this; + } + + public function requiredWhen(string $field, mixed $expectedValue): self + { + return $this->requiredIf(static fn($value, Model $model) => $model->get($field) === $expectedValue); + } + public function array(): SchemaArrayAttribute { return $this->schema->array($this->getName(), $this); diff --git a/tests/Model/ComprehensiveModelTest.php b/tests/Model/ComprehensiveModelTest.php index a74a195..ec493e5 100644 --- a/tests/Model/ComprehensiveModelTest.php +++ b/tests/Model/ComprehensiveModelTest.php @@ -2,7 +2,9 @@ namespace IpagDevs\Tests; +use Exception; use DateTimeInterface; +use ReflectionProperty; use IpagDevs\Model\Model; use IpagDevs\Model\Schema\Schema; use IpagDevs\Model\Schema\Mutator; @@ -89,6 +91,34 @@ public function slug(): Mutator } } +class UserRegistration extends Model +{ + protected function schema(SchemaBuilder $schema): Schema + { + $schema->string('name')->required(); + $schema->string('email')->required(); + $schema->string('user_type')->required(); + $schema->string('contact_preference')->nullable(); + + $schema->string('company_name') + ->requiredWhen('user_type', 'business'); + + $schema->string('phone') + ->requiredWhen('contact_preference', 'phone'); + + $schema->string('supervisor_approval') + ->requiredIf(function ($value, Model $model) { + $budget = $model->get('budget'); + $userType = $model->get('user_type'); + return $budget > 10000 && $userType !== 'enterprise'; + }); + + $schema->float('budget')->nullable(); + + return $schema->build(); + } +} + class ComprehensiveModelTest extends BaseTestCase { /** @@ -125,12 +155,188 @@ protected function setUp(): void protected function tearDown(): void { - $reflection = new \ReflectionProperty(Model::class, 'globalSchema'); - $reflection->setAccessible(true); + $reflection = new ReflectionProperty(Model::class, 'globalSchema'); $reflection->setValue(null, []); parent::tearDown(); } + public function testRequiredWhenConditionIsTriggeredAndFieldMissing(): void + { + $this->expectException(SchemaAttributeParseException::class); + $this->expectExceptionMessage("Missing required attribute"); + + $data = [ + 'name' => 'João', + 'email' => 'joao@test.com', + 'user_type' => 'business', + ]; + + UserRegistration::parse($data); + } + + public function testRequiredWhenConditionNotTriggeredAllowsMissingField(): void + { + $data = [ + 'name' => 'Maria', + 'email' => 'maria@test.com', + 'user_type' => 'personal', + ]; + + $user = UserRegistration::parse($data); + + $this->assertNull($user->get('company_name')); + } + + public function testPhoneIsRequiredWhenContactPreferenceIsPhone(): void + { + $data = [ + 'name' => 'Ana', + 'email' => 'ana@test.com', + 'user_type' => 'personal', + 'contact_preference' => 'phone', + 'phone' => '11999999999' + ]; + + $user = UserRegistration::parse($data); + + $this->assertSame('11999999999', $user->get('phone')); + } + + public function testMissingPhoneThrowsWhenConditionIsTriggered(): void + { + $this->expectException(SchemaAttributeParseException::class); + $this->expectExceptionMessage("Missing required attribute"); + + $data = [ + 'name' => 'Ana', + 'email' => 'ana@test.com', + 'user_type' => 'personal', + 'contact_preference' => 'phone', + ]; + + UserRegistration::parse($data); + } + + public function testPhoneNotRequiredWhenConditionIsNotMet(): void + { + $data = [ + 'name' => 'Ana', + 'email' => 'ana@test.com', + 'user_type' => 'personal', + 'contact_preference' => 'email', + ]; + + $user = UserRegistration::parse($data); + $this->assertNull($user->get('phone')); + } + + public function testSupervisorApprovalNotRequiredWhenBudgetLow(): void + { + $data = [ + 'name' => 'Carlos', + 'email' => 'carlos@test.com', + 'user_type' => 'business', + 'budget' => 5000, + 'company_name' => 'Negócios SA' + ]; + + $user = UserRegistration::parse($data); + + $this->assertNull($user->get('supervisor_approval')); + } + public function testSupervisorApprovalNotRequiredForEnterpriseUsers(): void + { + $data = [ + 'name' => 'CEO', + 'email' => 'ceo@test.com', + 'user_type' => 'enterprise', + 'budget' => 30000 + ]; + + $user = UserRegistration::parse($data); + + $this->assertNull($user->get('supervisor_approval')); + } + + public function testSupervisorApprovalRequiredWhenBudgetHighAndUserNotEnterprise(): void + { + $this->expectException(SchemaAttributeParseException::class); + $this->expectExceptionMessage("Missing required attribute"); + + $data = [ + 'name' => 'Gerente', + 'email' => 'gerente@test.com', + 'user_type' => 'business', + 'budget' => 20000, + ]; + + UserRegistration::parse($data); + } + public function testSupervisorApprovalIsAcceptedWhenProvided(): void + { + $data = [ + 'name' => 'Gerente', + 'email' => 'gerente@test.com', + 'user_type' => 'business', + 'budget' => 20000, + 'supervisor_approval' => 'Aprovado por João', + 'company_name' => 'Negócios SA' + ]; + + $user = UserRegistration::parse($data); + + $this->assertSame('Aprovado por João', $user->get('supervisor_approval')); + } + public function testRequiredIfFailsWhenValueIsExplicitNull(): void + { + $this->expectException(SchemaAttributeParseException::class); + + $data = [ + 'name' => 'Pedro', + 'email' => 'pedro@test.com', + 'user_type' => 'business', + 'budget' => 15000, + 'supervisor_approval' => null + ]; + + UserRegistration::parse($data); + } + + public function testFillUsesSnapshotAndDoesNotModifyOriginalOnFailure(): void + { + $instance = new class extends Model { + protected function schema(SchemaBuilder $schema): Schema + { + $schema->int('id')->required(); + $schema->string('name')->between(3, 10)->required(); + return $schema->build(); + } + }; + + $class = $instance::class; + + $model = $class::parse([ + 'id' => 1, + 'name' => 'ValidName' + ]); + + $this->assertSame(1, $model->get('id')); + $this->assertSame('ValidName', $model->get('name')); + + $this->expectException(SchemaAttributeParseException::class); + + try { + $model->fill([ + 'id' => 2, + ]); + } catch (Exception $e) { + $this->assertSame(1, $model->get('id')); + $this->assertSame('ValidName', $model->get('name')); + + throw $e; + } + } + public function testModelHiddenIf(): void { $review = Review::parse([ @@ -289,12 +495,20 @@ public function testHiddenIfWithComplexCondition(): void { $highPriceData = $this->fullProductData; $product1 = Product::parse($highPriceData); - $this->assertArrayNotHasKey('promo_code', $product1->jsonSerialize(), "Promo code should be hidden for high-priced items."); + $this->assertArrayNotHasKey( + 'promo_code', + $product1->jsonSerialize(), + "Promo code should be hidden for high-priced items." + ); $lowPriceData = $this->fullProductData; $lowPriceData['price'] = 50.00; $product2 = Product::parse($lowPriceData); - $this->assertArrayHasKey('promo_code', $product2->jsonSerialize(), "Promo code should be visible for low-priced items."); + $this->assertArrayHasKey( + 'promo_code', + $product2->jsonSerialize(), + "Promo code should be visible for low-priced items." + ); $this->assertSame('SAVE10', $product2->jsonSerialize()['promo_code']); }