From d3c2a9b7e819f5b6b843d24914eda2f9bce9a43e Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Fri, 28 Aug 2026 10:22:03 -0400 Subject: [PATCH] Format against the shared Pint config pint.json had 24 variants across 54 files. The config now lives once in laranail/package-tools and is consumed with --config from vendor/, so there is no local copy left to drift. Mechanical reformat, no behavioural change; the suite was run before and after. Where lint was defined as a bare pint invocation it is repaired too: that FIXES files rather than checking them, so it exited 0 unconditionally and the package believed it was linted when it was not. --- benchmark.php | 213 ++++--- composer.json | 9 +- config/laranail-validation.php | 6 +- pint.json | 58 -- rector-migrate-1.0.php | 2 +- rector.php | 20 +- resources/lang/en/validation.php | 196 +++--- src/Actions/CachedDnsResolver.php | 10 +- src/BatchDatabaseChecker.php | 205 +++--- src/Builder/Concerns/HasEmbeddedRules.php | 8 +- src/Builder/Concerns/HasFieldModifiers.php | 250 ++++---- src/Builder/Concerns/SelfValidates.php | 254 ++++---- src/Builder/Nodes/AcceptedRule.php | 12 +- src/Builder/Nodes/ArrayRule.php | 103 +-- src/Builder/Nodes/BooleanRule.php | 12 +- src/Builder/Nodes/DateRule.php | 12 +- src/Builder/Nodes/DeclinedRule.php | 12 +- src/Builder/Nodes/EmailRule.php | 34 +- src/Builder/Nodes/FieldRule.php | 124 ++-- src/Builder/Nodes/FileRule.php | 38 +- src/Builder/Nodes/ImageRule.php | 14 +- src/Builder/Nodes/IpAddressRule.php | 48 +- src/Builder/Nodes/MacAddressRule.php | 18 +- src/Builder/Nodes/NumericRule.php | 12 +- src/Builder/Nodes/PasswordRule.php | 16 +- src/Builder/Nodes/PhoneRule.php | 58 +- src/Builder/Nodes/StringRule.php | 42 +- src/Builder/Nodes/UrlRule.php | 24 +- src/Builder/Nodes/UsernameRule.php | 18 +- src/Check.php | 72 ++- src/Commands/BenchmarkCommand.php | 6 +- src/Commands/DoctorCommand.php | 16 +- src/Commands/RulesCommand.php | 28 +- src/Concerns/SkipsPrecognition.php | 6 +- src/Contracts/ClientCheckable.php | 4 +- src/Contracts/Email/DisposableDomainList.php | 4 +- src/Contracts/Email/DnsResolver.php | 4 +- src/Contracts/Email/RoleAccountList.php | 4 +- src/Contracts/FluentRuleContract.php | 30 +- src/Contracts/I18n/CountryDataset.php | 4 +- src/Contracts/I18n/CurrencyDataset.php | 4 +- src/Contracts/I18n/LanguageDataset.php | 4 +- src/Contracts/Payment/CardBrandCatalogue.php | 4 +- src/Contracts/PrecognitionSkippable.php | 4 +- src/Contracts/ReservedUsernameList.php | 4 +- src/Contracts/TermList.php | 4 +- src/Events/RuleSetCompiling.php | 12 +- src/Events/ValidationCompleted.php | 8 +- src/Events/ValidationFailed.php | 6 +- src/Events/ValidationStarting.php | 4 +- .../BatchLimitExceededException.php | 4 +- src/Exceptions/CannotExtendListShapedEach.php | 4 +- src/Exceptions/TypedBuilderHint.php | 49 +- src/Exceptions/UnknownFluentRuleMethod.php | 4 +- src/FastCheck/CoreValueCompiler.php | 4 +- src/FastCheck/ItemContextCompiler.php | 63 +- src/FastCheck/PresenceConditionalCompiler.php | 18 +- src/FastCheck/ProhibitedCompiler.php | 4 +- src/FastCheck/RuleConfigBuilder.php | 127 ++-- .../Shared/ItemAwareBranchBuilder.php | 4 +- src/FastCheck/Shared/LaravelEmptiness.php | 4 +- src/FastCheckCompiler.php | 24 +- src/FluentFormRequest.php | 4 +- src/FluentRule.php | 64 +- src/FluentRules.php | 4 +- src/FluentSchema.php | 56 +- src/FluentValidator.php | 14 +- src/HasFluentRules.php | 170 ++--- src/HasFluentValidation.php | 112 ++-- src/HasFluentValidationForFilament.php | 4 +- src/Internal/AbstractConditionalReducer.php | 28 +- src/Internal/BatchLimitRemap.php | 6 +- src/Internal/BatchPresenceQuery.php | 16 +- src/Internal/BatchRuleShape.php | 6 +- src/Internal/ConditionalEvaluationPhase.php | 75 +-- src/Internal/ConditionalValueMatcher.php | 77 +-- src/Internal/ConditionalVerdict.php | 4 +- src/Internal/DatabaseClaimScanner.php | 7 +- src/Internal/ExcludeConditionExtractor.php | 11 +- src/Internal/ItemErrorCollector.php | 8 +- src/Internal/ItemRuleCompiler.php | 193 +++--- src/Internal/ItemValidator.php | 29 +- src/Internal/PreparesOptimizedRules.php | 43 +- src/Internal/PresenceMatchFidelity.php | 8 +- src/Internal/RuleCacheKey.php | 4 +- src/Internal/ValidatorStateCopier.php | 6 +- src/Internal/ValueTypePredicates.php | 25 +- src/Internal/VanillaAfterRoute.php | 17 +- src/MemoizingValidator.php | 11 +- src/OptimizedValidator.php | 143 +++-- src/PrecomputedPresenceVerifier.php | 16 +- src/PreparedRules.php | 12 +- src/PresenceConditionalReducer.php | 34 +- src/Providers/ValidationServiceProvider.php | 123 ++-- src/Regex.php | 98 +-- src/RuleSet.php | 600 +++++++++--------- src/Rules/AntiSpam/Honeypot.php | 22 +- src/Rules/AntiSpam/SubmissionTiming.php | 58 +- src/Rules/Banking/Bic.php | 4 +- src/Rules/Banking/BsbNumber.php | 4 +- src/Rules/Banking/Iban.php | 18 +- src/Rules/Banking/Isin.php | 18 +- src/Rules/Banking/Luhn.php | 20 +- src/Rules/Chrono/DateInterval.php | 4 +- src/Rules/Chrono/MaxDateDifference.php | 8 +- src/Rules/Chrono/MinimumAge.php | 8 +- src/Rules/Chrono/MinuteIn.php | 10 +- src/Rules/Chrono/Rfc3339.php | 18 +- src/Rules/Chrono/TimeOfDay.php | 4 +- src/Rules/Chrono/TimezoneAbbreviation.php | 4 +- src/Rules/Chrono/UnixTimestamp.php | 4 +- src/Rules/Codes/Asin.php | 18 +- src/Rules/Codes/Ean.php | 4 +- src/Rules/Codes/Gtin.php | 24 +- src/Rules/Codes/Isbn.php | 28 +- src/Rules/Codes/Ismn.php | 18 +- src/Rules/Codes/Issn.php | 20 +- src/Rules/Codes/UpcE.php | 24 +- src/Rules/Colour/CssColor.php | 94 +-- src/Rules/Colour/Support/Names.php | 10 +- src/Rules/Crypto/BitcoinAddress.php | 20 +- src/Rules/Crypto/EthereumAddress.php | 4 +- src/Rules/Crypto/Support/Base58Check.php | 6 +- src/Rules/Crypto/Support/Bech32.php | 6 +- src/Rules/Database/Authorized.php | 16 +- src/Rules/Database/CompareToColumn.php | 8 +- src/Rules/Database/Comparison.php | 14 +- src/Rules/Database/ModelsExist.php | 14 +- src/Rules/Email/EmailDomainIs.php | 30 +- src/Rules/Email/EmailDomainIsNot.php | 4 +- src/Rules/Email/NoSubaddressing.php | 18 +- src/Rules/Email/NotDisposableEmail.php | 16 +- src/Rules/Email/NotRoleEmail.php | 16 +- src/Rules/Email/Support/Address.php | 6 +- src/Rules/Encoding/Base64.php | 18 +- src/Rules/Encoding/Base64Image.php | 62 +- src/Rules/Encoding/DataUri.php | 6 +- src/Rules/Fiscal/NationalIdentifier.php | 26 +- src/Rules/Fiscal/VatNumber.php | 30 +- src/Rules/Geo/CaProvince.php | 18 +- src/Rules/Geo/Coordinate.php | 4 +- src/Rules/Geo/LatLng.php | 18 +- src/Rules/Geo/Latitude.php | 5 +- src/Rules/Geo/Longitude.php | 5 +- src/Rules/Geo/Subdivisions.php | 6 +- src/Rules/Geo/UsState.php | 18 +- src/Rules/I18n/CountryCode.php | 4 +- src/Rules/I18n/CurrencyCode.php | 14 +- src/Rules/I18n/LanguageCode.php | 4 +- src/Rules/Identifiers/HashDigest.php | 58 +- src/Rules/Identifiers/Imei.php | 18 +- src/Rules/Identifiers/Jwt.php | 18 +- src/Rules/Identifiers/SemVer.php | 4 +- src/Rules/Identifiers/Vin.php | 18 +- src/Rules/Markup/Xml.php | 8 +- src/Rules/Net/Cidr.php | 18 +- src/Rules/Net/DomainName.php | 20 +- src/Rules/Net/InCidrRange.php | 22 +- src/Rules/Net/MacAddress.php | 150 ++--- src/Rules/Net/PrivateIp.php | 4 +- src/Rules/Net/PublicIp.php | 4 +- src/Rules/Net/Subdomain.php | 18 +- src/Rules/Net/Support/HostPattern.php | 6 +- src/Rules/Net/Support/IpClassifier.php | 8 +- src/Rules/Net/Url.php | 20 +- src/Rules/Network/DeliverableEmail.php | 6 +- src/Rules/Network/HasGravatar.php | 10 +- src/Rules/Network/ImageUrl.php | 12 +- src/Rules/Numbers/MonetaryAmount.php | 20 +- src/Rules/Numbers/Parity.php | 30 +- src/Rules/Payment/CardCvc.php | 8 +- src/Rules/Payment/CardExpiry.php | 4 +- src/Rules/Payment/CardNumber.php | 60 +- src/Rules/Postal/Patterns.php | 20 +- src/Rules/Postal/PostalCode.php | 102 +-- src/Rules/Profanity/NoProfanity.php | 87 +-- src/Rules/Storage/FileExistsOnDisk.php | 6 +- src/Rules/Structure/Delimited.php | 20 +- src/Rules/Telecom/Phone.php | 16 +- src/Rules/Telecom/UniquePhone.php | 20 +- src/Rules/Text/CaseStyle.php | 24 +- src/Rules/Text/HtmlClean.php | 22 +- src/Rules/Text/MaxWords.php | 20 +- src/Rules/Text/MinWords.php | 6 +- src/Rules/Text/PersonName.php | 36 +- src/Rules/Text/Salutation.php | 10 +- src/Rules/Text/Slug.php | 4 +- src/Rules/Text/Username.php | 110 ++-- src/Rules/Text/WithoutSpaces.php | 4 +- src/Rules/Vendor/VendorIdentifier.php | 48 +- src/Schemas/PersonNamePresence.php | 4 +- src/Schemas/PersonNameSchema.php | 20 +- .../Email/BundledDisposableDomainList.php | 4 +- src/Support/Email/BundledRoleAccountList.php | 4 +- src/Support/Email/DomainFile.php | 4 +- src/Support/Encoding/Base64File.php | 6 +- src/Support/I18n/BundledCountryDataset.php | 4 +- src/Support/I18n/BundledCurrencyDataset.php | 4 +- src/Support/I18n/BundledLanguageDataset.php | 4 +- src/Support/I18n/CodeFile.php | 4 +- src/Support/InlineTermList.php | 10 +- .../Payment/BundledCardBrandCatalogue.php | 4 +- src/Support/Payment/CardBrand.php | 12 +- src/Support/RuleAliases.php | 282 ++++---- src/Support/RuleRegistrar.php | 16 +- .../Text/DefaultReservedUsernameList.php | 6 +- .../Text/ExtendedReservedUsernameList.php | 6 +- src/Testing/Arch/BansFieldRuleTypeMethods.php | 41 +- src/Testing/FluentRulesTester.php | 82 +-- src/Testing/PestExpectations.php | 16 +- src/Testing/ValidationFake.php | 10 +- src/Validated.php | 8 +- src/Validation.php | 6 +- src/ValueConditionalReducer.php | 28 +- src/WildcardExpander.php | 14 +- static | 1 + tests/AddRuleMessageParamTest.php | 11 +- tests/ArchTest.php | 6 +- tests/ArrayRuleExtendHelpersTest.php | 8 +- tests/BatchCollationParityTest.php | 16 +- tests/BatchDatabaseValidationTest.php | 49 +- .../BatchExistsUniqueSameFieldParityTest.php | 16 +- tests/BatchSharedColumnParityTest.php | 19 +- tests/BatchValidationGuardsTest.php | 89 +-- tests/Builder/EmailNodeRulesTest.php | 16 +- tests/CheckAndFakeTest.php | 14 +- tests/ConditionalEvaluationPhaseTest.php | 38 +- tests/ConditionalOptimizationTest.php | 46 +- tests/ConsoleCommandsTest.php | 4 +- tests/ConsumerExtensionTest.php | 8 +- tests/DateRuleTest.php | 12 +- .../DependentBooleanRuleRemovalParityTest.php | 22 +- tests/DocCountsTest.php | 4 +- tests/EmailRuleTest.php | 11 +- .../UnknownFluentRuleMethodTest.php | 9 +- tests/ExcludeBooleanDependentParityTest.php | 30 +- tests/ExcludeCoercionParityTest.php | 39 +- tests/ExcludeStringFormPruningTest.php | 26 +- tests/ExpandsWildcardsTest.php | 10 +- tests/FactoryMessageSeedingTest.php | 44 +- tests/FastCheck/FamilyCompilerTest.php | 6 +- tests/FastCheckArrayValueParityTest.php | 8 +- tests/FastCheckCompilerCacheTest.php | 16 +- tests/FastCheckParityTest.php | 82 +-- tests/FieldRuleExtendHelpersTest.php | 8 +- tests/FileRuleTest.php | 6 +- tests/FluentFormRequestTest.php | 73 ++- tests/FluentRuleContractTest.php | 20 +- tests/FluentRuleTest.php | 386 +++++------ tests/FluentSchemaTest.php | 85 +-- tests/FluentValidatorBatchDatabaseTest.php | 10 +- tests/FluentValidatorBenchTest.php | 74 ++- .../FluentValidatorConditionalParityTest.php | 56 +- tests/HasFluentValidationForFilamentTest.php | 27 +- tests/HasFluentValidationTest.php | 55 +- tests/Helpers.php | 14 +- tests/ImageRuleTest.php | 4 +- tests/ImplicitAttributesCanaryTest.php | 6 +- tests/ImportBenchTest.php | 154 ++--- tests/InlineMessageParamPhase3aTest.php | 38 +- tests/InlineMessageParamPhase3bTest.php | 48 +- tests/InlineMessageParamPhase3cTest.php | 46 +- tests/InlineMessageParamPhase3dTest.php | 28 +- tests/InlineMessageParamPhase3eTest.php | 30 +- tests/IntegrationTest.php | 54 +- tests/Internal/ItemRuleCompilerTest.php | 28 +- tests/Internal/ItemValidatorTest.php | 14 +- tests/Internal/ValidatorStateCopierTest.php | 12 +- tests/ItemExclusionLeakParityTest.php | 9 +- tests/ItemValidatorCustomResolverTest.php | 22 +- tests/ItemValidatorFastCheckCacheTest.php | 9 +- tests/MemoizingValidatorParityTest.php | 21 +- tests/NamingConventionTest.php | 6 +- tests/NumericRuleTest.php | 12 +- tests/PasswordRuleTest.php | 14 +- tests/Pest.php | 29 +- tests/PhoneRuleTest.php | 20 +- tests/PrecognitionSkippableTest.php | 4 +- tests/PresenceConditionalParityTest.php | 72 ++- tests/ProhibitedConditionalParityTest.php | 6 +- tests/ProviderLayoutTest.php | 4 +- tests/PublicApiBoundaryTest.php | 4 +- tests/RegexBuilderTest.php | 6 +- tests/RegexWithPipeTest.php | 26 +- tests/RequiredConditionalNullableTest.php | 117 ++-- tests/RuleAliasesTest.php | 32 +- tests/RuleConfigBuilderTest.php | 6 +- tests/RuleMessagesResolveTest.php | 34 +- tests/RuleRegistrarTest.php | 19 +- tests/RuleSetEventsTest.php | 14 +- tests/RuleSetModifyEachTest.php | 8 +- tests/RuleSetParityHarnessTest.php | 8 +- tests/RuleSetTest.php | 281 ++++---- tests/RuleSetWithBagTest.php | 10 +- tests/Rules/AntiSpam/AntiSpamRulesTest.php | 12 +- tests/Rules/Banking/BankingRulesTest.php | 38 +- tests/Rules/Chrono/ChronoRulesTest.php | 36 +- tests/Rules/ClientCheckableTest.php | 31 +- tests/Rules/Codes/CodesRulesTest.php | 30 +- tests/Rules/Codes/NewCodesRulesTest.php | 16 +- tests/Rules/Colour/CssColorTest.php | 4 +- tests/Rules/Crypto/CryptoRulesTest.php | 68 +- tests/Rules/Database/CompareToColumnTest.php | 16 +- tests/Rules/Database/DatabaseRulesTest.php | 16 +- tests/Rules/Email/EmailRulesTest.php | 23 +- tests/Rules/Encoding/EncodingRulesTest.php | 24 +- tests/Rules/Fiscal/NationalIdentifierTest.php | 60 +- tests/Rules/Fiscal/VatNumberTest.php | 14 +- tests/Rules/Geo/GeoRulesTest.php | 66 +- tests/Rules/I18n/I18nRulesTest.php | 47 +- .../Identifiers/HashDigestAndBsbTest.php | 8 +- .../Identifiers/IdentifiersRulesTest.php | 40 +- tests/Rules/Markup/XmlTest.php | 16 +- tests/Rules/Net/NetRulesTest.php | 56 +- tests/Rules/Net/NetworkFieldsTest.php | 8 +- tests/Rules/Net/UrlTest.php | 6 +- tests/Rules/Network/DeliverableEmailTest.php | 22 +- tests/Rules/Network/ProbeRulesTest.php | 42 +- tests/Rules/Numbers/NumbersRulesTest.php | 52 +- tests/Rules/Payment/PaymentRulesTest.php | 35 +- tests/Rules/Postal/PostalCodeTest.php | 46 +- tests/Rules/Profanity/InlineTermListTest.php | 6 +- tests/Rules/Profanity/NoProfanityTest.php | 13 +- tests/Rules/RuleTierTest.php | 6 +- tests/Rules/Storage/FileExistsOnDiskTest.php | 4 +- tests/Rules/Structure/DelimitedTest.php | 18 +- tests/Rules/Text/ReservedUsernameListTest.php | 16 +- tests/Rules/Text/TextRulesTest.php | 64 +- tests/Rules/Text/WordsAndSalutationTest.php | 14 +- tests/Rules/TrailingNewlineTest.php | 64 +- tests/Rules/Vendor/VendorIdentifierTest.php | 34 +- tests/SchemaRulesMergeTest.php | 61 +- tests/Schemas/PersonNameSchemaTest.php | 74 +-- tests/SeparateRulesWildcardParsingTest.php | 4 +- tests/SlowPathBenchTest.php | 124 ++-- tests/StringRuleTest.php | 45 +- tests/Support/ThrowsOnEveryCacheCall.php | 6 +- tests/TestCase.php | 13 +- tests/TestCaseEnvTest.php | 4 +- .../Arch/BansFieldRuleTypeMethodsTest.php | 4 +- .../FluentRulesTesterClassTargetsTest.php | 18 +- .../Testing/FluentRulesTesterLivewireTest.php | 6 +- tests/Testing/FluentRulesTesterTest.php | 24 +- tests/Testing/PestExpectationsTest.php | 10 +- tests/ValidationServiceProviderTest.php | 9 +- tests/ValueConditionalParityTest.php | 46 +- tests/WildcardDepPathParityTest.php | 34 +- tests/WildcardExpanderTest.php | 8 +- workbench/config/package-boost.php | 4 +- 349 files changed, 6017 insertions(+), 5232 deletions(-) delete mode 100644 pint.json create mode 100644 static diff --git a/benchmark.php b/benchmark.php index cd78be8..236fd1e 100644 --- a/benchmark.php +++ b/benchmark.php @@ -1,4 +1,6 @@ -createApplication(); +$app = (new Application)->createApplication(); // ========================================================================= // Data generators @@ -35,13 +37,13 @@ function makeProducts(int $count): array { return array_map(fn (int $i): array => [ - 'sku' => "SKU-{$i}", - 'name' => "Product {$i}", - 'price' => round(9.99 + ($i % 100) * 0.5, 2), + 'sku' => "SKU-{$i}", + 'name' => "Product {$i}", + 'price' => round(9.99 + ($i % 100) * 0.5, 2), 'quantity' => $i % 1000, 'category' => ['electronics', 'clothing', 'food', 'books', 'toys'][$i % 5], - 'active' => $i % 2 === 0, - 'tags' => 'tag-' . ($i % 20), + 'active' => $i % 2 === 0, + 'tags' => 'tag-' . ($i % 20), ], range(1, $count)); } @@ -49,11 +51,11 @@ function makeOrders(int $orders, int $lineItemsPerOrder): array { return array_map(fn (int $i): array => [ 'order_number' => "ORD-{$i}", - 'status' => ['pending', 'processing', 'shipped'][$i % 3], - 'line_items' => array_map(fn (int $j): array => [ + 'status' => ['pending', 'processing', 'shipped'][$i % 3], + 'line_items' => array_map(fn (int $j): array => [ 'product_id' => ($i * 10 + $j) % 9999 + 1, - 'quantity' => $j + 1, - 'price' => round(10.00 + $j * 2.5, 2), + 'quantity' => $j + 1, + 'price' => round(10.00 + $j * 2.5, 2), ], range(1, $lineItemsPerOrder)), ], range(1, $orders)); } @@ -61,9 +63,9 @@ function makeOrders(int $orders, int $lineItemsPerOrder): array function makeEvents(int $count): array { return array_map(fn (int $i): array => [ - 'name' => "Event {$i}", - 'start_date' => '2025-06-' . str_pad((string) ($i % 28 + 1), 2, '0', STR_PAD_LEFT), - 'end_date' => '2025-07-' . str_pad((string) ($i % 28 + 1), 2, '0', STR_PAD_LEFT), + 'name' => "Event {$i}", + 'start_date' => '2025-06-' . str_pad((string) ($i % 28 + 1), 2, '0', STR_PAD_LEFT), + 'end_date' => '2025-07-' . str_pad((string) ($i % 28 + 1), 2, '0', STR_PAD_LEFT), 'registration_deadline' => '2025-05-' . str_pad((string) ($i % 28 + 1), 2, '0', STR_PAD_LEFT), ], range(1, $count)); } @@ -71,9 +73,9 @@ function makeEvents(int $count): array function makeArticles(int $count): array { return array_map(fn (int $i): array => [ - 'title' => "Article {$i}", - 'slug' => "article-{$i}", - 'content' => str_repeat('Word ', 120), + 'title' => "Article {$i}", + 'slug' => "article-{$i}", + 'content' => str_repeat('Word ', 120), 'category' => ['tech', 'science', 'art'][$i % 3], 'priority' => $i % 10, ], range(1, $count)); @@ -133,7 +135,8 @@ function benchNativeLaravel(array $rules, array $data): float $events100 = makeEvents(100); $articles50 = makeArticles(50); -$minWordCount = new class implements ValidationRule { +$minWordCount = new class implements ValidationRule +{ public function validate(string $attribute, mixed $value, Closure $fail): void { if (! is_string($value) || str_word_count($value) < 100) { @@ -142,7 +145,8 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } }; -$validCategory = new class implements ValidationRule { +$validCategory = new class implements ValidationRule +{ public function validate(string $attribute, mixed $value, Closure $fail): void { if (! in_array($value, ['tech', 'science', 'art', 'music'], true)) { @@ -151,7 +155,8 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } }; -$validPriority = new class implements ValidationRule { +$validPriority = new class implements ValidationRule +{ public function validate(string $attribute, mixed $value, Closure $fail): void { if (! is_int($value) || $value < 0 || $value > 10) { @@ -165,7 +170,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // --- 1. Product import — 500 items, all fast-checkable --- $scenarios['Product import — 500 items, simple rules'] = [ 'optimizations' => 'Wildcard, fast-check', - 'native' => [ + 'native' => [ ['products' => 'required|array', 'products.*.sku' => 'required|string|max:50|regex:/^SKU-/', 'products.*.name' => 'required|string|min:2|max:255', 'products.*.price' => 'required|numeric|min:0', 'products.*.quantity' => 'required|numeric|integer|min:0', 'products.*.category' => ['required', 'string', Rule::in(['electronics', 'clothing', 'food', 'books', 'toys'])], 'products.*.active' => 'required|boolean', 'products.*.tags' => 'nullable|string|max:50'], ['products' => $products500], ], @@ -178,7 +183,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // --- 2. Nested order lines — 1000 orders × 5 line items --- $scenarios['Nested order lines — 1000 orders × 5 line items'] = [ 'optimizations' => 'Wildcard, fast-check (nested)', - 'native' => [ + 'native' => [ ['orders' => 'required|array', 'orders.*.order_number' => 'required|string|alpha_dash|min:5', 'orders.*.status' => ['required', 'string', Rule::in(['pending', 'processing', 'shipped'])], 'orders.*.line_items' => 'required|array|min:1', 'orders.*.line_items.*.product_id' => 'required|integer|min:1', 'orders.*.line_items.*.quantity' => 'required|integer|min:1', 'orders.*.line_items.*.price' => 'required|numeric|min:0.01'], ['orders' => $orders1000], ], @@ -191,7 +196,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // --- 3. Event scheduling — field-reference date comparisons (partial fast-check) --- $scenarios['Event scheduling — 100 items, field-ref dates'] = [ 'optimizations' => 'Wildcard, partial fast-check', - 'native' => [ + 'native' => [ ['events' => 'required|array', 'events.*.name' => 'required|string|min:3|max:255', 'events.*.start_date' => 'required|date|after:2025-01-01', 'events.*.end_date' => 'required|date|after:events.*.start_date', 'events.*.registration_deadline' => 'required|date|before:events.*.start_date'], ['events' => $events100], ], @@ -204,7 +209,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // --- 4. Article submission — custom Rule objects (wildcard expansion only) --- $scenarios['Article submission — 50 items, custom Rule objects'] = [ 'optimizations' => 'Wildcard only', - 'native' => [ + 'native' => [ ['articles' => 'required|array', 'articles.*.title' => 'required|string|min:3|max:255', 'articles.*.slug' => 'required|string|alpha_dash|max:255', 'articles.*.content' => ['required', 'string', $minWordCount], 'articles.*.category' => ['required', $validCategory], 'articles.*.priority' => ['required', $validPriority]], ['articles' => $articles50], ], @@ -217,95 +222,95 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // --- 5. Conditional import — 100 items, ~47 wildcard patterns with exclude_unless --- $interactionTypes = ['button', 'hotspot', 'scroll_area', 'image', 'chapter', 'menu', 'frame', 'text', 'iframe']; $interactions100 = array_map(fn (int $i): array => [ - 'type' => $interactionTypes[$i % count($interactionTypes)], - 'title' => "Interaction {$i}", - 'start_time' => $i * 10, - 'end_time' => $i * 10 + 5, - 'position' => 'bottom', - 'should_start_collapsed' => false, + 'type' => $interactionTypes[$i % count($interactionTypes)], + 'title' => "Interaction {$i}", + 'start_time' => $i * 10, + 'end_time' => $i * 10 + 5, + 'position' => 'bottom', + 'should_start_collapsed' => false, 'should_collapse_after_menu_item_click' => true, - 'should_pause_when_shown' => false, - 'should_not_use_time' => false, - 'should_use_menu_layout' => false, - 'text' => '

Sample text

', - 'text_stroke' => null, - 'should_fade_volume' => false, - 'sound_url' => null, - 'should_enable_sound' => false, - 'should_fade_in' => true, - 'should_fade_out' => true, - 'image_url' => $interactionTypes[$i % count($interactionTypes)] === 'image' ? 'https://example.com/image.png' : null, - 'style' => [ - 'top' => '10%', 'left' => '20%', 'height' => '30%', 'width' => '40%', + 'should_pause_when_shown' => false, + 'should_not_use_time' => false, + 'should_use_menu_layout' => false, + 'text' => '

Sample text

', + 'text_stroke' => null, + 'should_fade_volume' => false, + 'sound_url' => null, + 'should_enable_sound' => false, + 'should_fade_in' => true, + 'should_fade_out' => true, + 'image_url' => $interactionTypes[$i % count($interactionTypes)] === 'image' ? 'https://example.com/image.png' : null, + 'style' => [ + 'top' => '10%', 'left' => '20%', 'height' => '30%', 'width' => '40%', 'background_color' => '#ff0000', 'border_radius' => 5, - 'padding_top' => 10, 'padding_bottom' => 10, - 'border' => ['width' => 1, 'style' => 'solid', 'color' => '#000000'], + 'padding_top' => 10, 'padding_bottom' => 10, + 'border' => ['width' => 1, 'style' => 'solid', 'color' => '#000000'], ], - 'action' => ['type' => 'link', 'link' => 'https://example.com', 'time' => 0], + 'action' => ['type' => 'link', 'link' => 'https://example.com', 'time' => 0], 'attributes' => [ 'show_indicator' => true, 'indicator_color' => '#00ff00', 'blinking_speed' => 'normal', - 'options' => ['menu_button_location' => 'top', 'menu_button_name' => 'Menu'], + 'options' => ['menu_button_location' => 'top', 'menu_button_name' => 'Menu'], ], 'chapters' => $i % count($interactionTypes) === 4 ? array_map(fn (int $j): array => [ - 'title' => "Chapter {$j}", 'title_short' => null, + 'title' => "Chapter {$j}", 'title_short' => null, 'start_time' => $j * 5, 'end_time' => $j * 5 + 4, 'sort_order' => $j, ], range(1, 4)) : [], ], range(1, 100)); $conditionalRules = [ - 'interactions' => 'required|array|min:1', - 'interactions.*.type' => ['required', 'string', Rule::in($interactionTypes)], - 'interactions.*.title' => ['nullable', 'string'], - 'interactions.*.start_time' => ['required', 'numeric', 'min:0'], - 'interactions.*.end_time' => ['required', 'numeric', 'gte:interactions.*.start_time'], - 'interactions.*.position' => ['bail', ['exclude_unless', 'interactions.*.type', 'chapter', 'menu'], 'string'], - 'interactions.*.should_start_collapsed' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'boolean'], - 'interactions.*.should_collapse_after_menu_item_click' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'boolean'], - 'interactions.*.should_pause_when_shown' => [['exclude_unless', 'interactions.*.type', 'chapter', 'menu'], 'nullable', 'boolean'], - 'interactions.*.should_not_use_time' => [['exclude_unless', 'interactions.*.type', 'menu'], 'nullable', 'boolean'], - 'interactions.*.should_use_menu_layout' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'nullable', 'boolean'], - 'interactions.*.text' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'string'], - 'interactions.*.text_stroke' => [['exclude_unless', 'interactions.*.type', 'text'], 'nullable', 'string'], - 'interactions.*.should_fade_volume' => ['boolean'], - 'interactions.*.sound_url' => ['bail', ['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image'], 'nullable', 'string'], - 'interactions.*.should_enable_sound' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image'], 'boolean'], - 'interactions.*.should_fade_in' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'boolean'], - 'interactions.*.should_fade_out' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'boolean'], - 'interactions.*.image_url' => ['bail', ['exclude_unless', 'interactions.*.type', 'image', 'hotspot'], ['required_if', 'interactions.*.type', 'image'], 'nullable', 'string'], - 'interactions.*.style.top' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'], - 'interactions.*.style.left' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'], - 'interactions.*.style.height' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'], - 'interactions.*.style.width' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'], - 'interactions.*.style.background_color' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'string'], - 'interactions.*.style.border_radius' => ['bail', ['exclude_unless', 'interactions.*.type', 'button', 'hotspot'], 'nullable', 'numeric', 'integer', 'between:0,360'], - 'interactions.*.style.padding_top' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'numeric', 'integer'], - 'interactions.*.style.padding_bottom' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'numeric', 'integer'], - 'interactions.*.style.border' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'array'], - 'interactions.*.style.border.width' => ['nullable', 'numeric', 'integer', 'min:0'], - 'interactions.*.style.border.style' => ['nullable', 'string', Rule::in(['solid', 'dashed', 'dotted', 'none'])], - 'interactions.*.style.border.color' => ['nullable', 'string'], - 'interactions.*.action' => ['nullable', 'array'], - 'interactions.*.action.type' => ['nullable', 'string', Rule::in(['link', 'time', 'video', 'none'])], - 'interactions.*.action.link' => ['nullable', 'string'], - 'interactions.*.action.time' => ['nullable', 'numeric'], - 'interactions.*.attributes' => ['nullable', 'array'], - 'interactions.*.attributes.show_indicator' => ['nullable', 'boolean'], - 'interactions.*.attributes.indicator_color' => ['nullable', 'string'], - 'interactions.*.attributes.blinking_speed' => ['nullable', 'string', Rule::in(['slow', 'normal', 'fast', 'none'])], - 'interactions.*.attributes.options' => ['nullable', 'array'], + 'interactions' => 'required|array|min:1', + 'interactions.*.type' => ['required', 'string', Rule::in($interactionTypes)], + 'interactions.*.title' => ['nullable', 'string'], + 'interactions.*.start_time' => ['required', 'numeric', 'min:0'], + 'interactions.*.end_time' => ['required', 'numeric', 'gte:interactions.*.start_time'], + 'interactions.*.position' => ['bail', ['exclude_unless', 'interactions.*.type', 'chapter', 'menu'], 'string'], + 'interactions.*.should_start_collapsed' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'boolean'], + 'interactions.*.should_collapse_after_menu_item_click' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'boolean'], + 'interactions.*.should_pause_when_shown' => [['exclude_unless', 'interactions.*.type', 'chapter', 'menu'], 'nullable', 'boolean'], + 'interactions.*.should_not_use_time' => [['exclude_unless', 'interactions.*.type', 'menu'], 'nullable', 'boolean'], + 'interactions.*.should_use_menu_layout' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'nullable', 'boolean'], + 'interactions.*.text' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'string'], + 'interactions.*.text_stroke' => [['exclude_unless', 'interactions.*.type', 'text'], 'nullable', 'string'], + 'interactions.*.should_fade_volume' => ['boolean'], + 'interactions.*.sound_url' => ['bail', ['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image'], 'nullable', 'string'], + 'interactions.*.should_enable_sound' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image'], 'boolean'], + 'interactions.*.should_fade_in' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'boolean'], + 'interactions.*.should_fade_out' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'boolean'], + 'interactions.*.image_url' => ['bail', ['exclude_unless', 'interactions.*.type', 'image', 'hotspot'], ['required_if', 'interactions.*.type', 'image'], 'nullable', 'string'], + 'interactions.*.style.top' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'], + 'interactions.*.style.left' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'], + 'interactions.*.style.height' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'], + 'interactions.*.style.width' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'], + 'interactions.*.style.background_color' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'string'], + 'interactions.*.style.border_radius' => ['bail', ['exclude_unless', 'interactions.*.type', 'button', 'hotspot'], 'nullable', 'numeric', 'integer', 'between:0,360'], + 'interactions.*.style.padding_top' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'numeric', 'integer'], + 'interactions.*.style.padding_bottom' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'numeric', 'integer'], + 'interactions.*.style.border' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'array'], + 'interactions.*.style.border.width' => ['nullable', 'numeric', 'integer', 'min:0'], + 'interactions.*.style.border.style' => ['nullable', 'string', Rule::in(['solid', 'dashed', 'dotted', 'none'])], + 'interactions.*.style.border.color' => ['nullable', 'string'], + 'interactions.*.action' => ['nullable', 'array'], + 'interactions.*.action.type' => ['nullable', 'string', Rule::in(['link', 'time', 'video', 'none'])], + 'interactions.*.action.link' => ['nullable', 'string'], + 'interactions.*.action.time' => ['nullable', 'numeric'], + 'interactions.*.attributes' => ['nullable', 'array'], + 'interactions.*.attributes.show_indicator' => ['nullable', 'boolean'], + 'interactions.*.attributes.indicator_color' => ['nullable', 'string'], + 'interactions.*.attributes.blinking_speed' => ['nullable', 'string', Rule::in(['slow', 'normal', 'fast', 'none'])], + 'interactions.*.attributes.options' => ['nullable', 'array'], 'interactions.*.attributes.options.menu_button_location' => ['nullable', 'string'], - 'interactions.*.attributes.options.menu_button_name' => ['nullable', 'string'], - 'interactions.*.chapters' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'array', 'min:1', 'max:16'], - 'interactions.*.chapters.*.title' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'string'], - 'interactions.*.chapters.*.title_short' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'nullable', 'string'], - 'interactions.*.chapters.*.start_time' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'numeric', 'min:0'], - 'interactions.*.chapters.*.end_time' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'numeric'], - 'interactions.*.chapters.*.sort_order' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'integer', 'min:0'], + 'interactions.*.attributes.options.menu_button_name' => ['nullable', 'string'], + 'interactions.*.chapters' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'array', 'min:1', 'max:16'], + 'interactions.*.chapters.*.title' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'string'], + 'interactions.*.chapters.*.title_short' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'nullable', 'string'], + 'interactions.*.chapters.*.start_time' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'numeric', 'min:0'], + 'interactions.*.chapters.*.end_time' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'numeric'], + 'interactions.*.chapters.*.sort_order' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'integer', 'min:0'], ]; $scenarios['Conditional import — 100 items, 47 conditional fields'] = [ 'optimizations' => 'Wildcard, pre-evaluation', - 'native' => [ + 'native' => [ $conditionalRules, ['interactions' => $interactions100], ], @@ -318,7 +323,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // --- 6. Login form — no wildcards, minimal rules --- $scenarios['Login form — 3 fields, no wildcards'] = [ 'optimizations' => 'Fast-check (flat)', - 'native' => [ + 'native' => [ ['email' => 'required|string|email|max:255', 'password' => 'required|string|min:8', 'remember' => 'nullable|boolean'], ['email' => 'user@example.com', 'password' => 'secure-password-123', 'remember' => true], ], @@ -345,9 +350,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $results[$label] = [ 'optimizations' => $config['optimizations'], - 'native_ms' => round($native, 1), - 'optimized_ms' => round($ruleSet, 1), - 'speedup' => round($speedup, 1), + 'native_ms' => round($native, 1), + 'optimized_ms' => round($ruleSet, 1), + 'speedup' => round($speedup, 1), ]; } diff --git a/composer.json b/composer.json index 8b04b62..60a5926 100644 --- a/composer.json +++ b/composer.json @@ -130,7 +130,7 @@ "phpstan-simplified": "vendor/bin/phpstan analyse --memory-limit=2G --error-format symplify", "phpstan-clear-cache": "vendor/bin/phpstan clear-result-cache", "tag-currency": "bash scripts/verify-tag-currency.sh", - "format": "vendor/bin/pint", + "format": "@pint-fix", "rector": "vendor/bin/rector process", "test": "vendor/bin/pest", "test-coverage": "vendor/bin/pest --coverage", @@ -139,6 +139,13 @@ "@rector", "@format", "@phpstan-simplified" + ], + "pint": "vendor/bin/pint --test --config vendor/laranail/package-tools/pint.json", + "pint-fix": "vendor/bin/pint --parallel --config vendor/laranail/package-tools/pint.json", + "lint": [ + "@pint", + "@phpstan", + "@rector" ] }, "extra": { diff --git a/config/laranail-validation.php b/config/laranail-validation.php index ab53497..5d92d6a 100644 --- a/config/laranail-validation.php +++ b/config/laranail-validation.php @@ -1,4 +1,6 @@ - [ 'enabled' => env('LARANAIL_VALIDATION_ALIASES', false), - 'prefix' => 'laranail_', + 'prefix' => 'laranail_', ], /* diff --git a/pint.json b/pint.json deleted file mode 100644 index e2409f2..0000000 --- a/pint.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "preset": "laravel", - "cache-file": ".cache/pint/pint.json", - "rules": { - "backtick_to_shell_exec": true, - "blank_line_after_opening_tag": false, - "class_attributes_separation": { - "elements": { - "const": "one", - "method": "one" - } - }, - "class_definition": { - "inline_constructor_arguments": false, - "multi_line_extends_each_single_line": false, - "single_item_single_line": false, - "single_line": false, - "space_before_parenthesis": true - }, - "concat_space": { - "spacing": "one" - }, - "constant_case": false, - "curly_braces_position": { - "control_structures_opening_brace": "same_line", - "functions_opening_brace": "next_line_unless_newline_at_signature_end", - "anonymous_functions_opening_brace": "same_line", - "classes_opening_brace": "next_line_unless_newline_at_signature_end", - "anonymous_classes_opening_brace": "same_line", - "allow_single_line_empty_anonymous_classes": false, - "allow_single_line_anonymous_functions": false - }, - "declare_strict_types": true, - "function_typehint_space": false, - "increment_style": false, - "linebreak_after_opening_tag": false, - "new_with_parentheses": { - "anonymous_class": false, - "named_class": true - }, - "nullable_type_declaration": false, - "nullable_type_declaration_for_default_null_value": false, - "phpdoc_separation": false, - "phpdoc_align": false, - "single_class_element_per_statement": { - "elements": {} - }, - "single_space_around_construct": false, - "type_declaration_spaces": false, - "unary_operator_spaces": false, - "whitespace_after_comma_in_array": false - }, - "exclude": [ - ".cache", - ".github", - "vendor" - ] -} diff --git a/rector-migrate-1.0.php b/rector-migrate-1.0.php index 16edec2..6703c8e 100644 --- a/rector-migrate-1.0.php +++ b/rector-migrate-1.0.php @@ -3,9 +3,9 @@ declare(strict_types=1); use Rector\Config\RectorConfig; -use Rector\Renaming\Rector\MethodCall\RenameMethodRector; use Rector\Renaming\ValueObject\MethodCallRename; use Simtabi\Laranail\Validation\Builder\Nodes\ArrayRule; +use Rector\Renaming\Rector\MethodCall\RenameMethodRector; /** * The 0.x → 1.0 migration set (UPGRADING.md, v1.0.0). Run it against an diff --git a/rector.php b/rector.php index 1833846..1fb4731 100644 --- a/rector.php +++ b/rector.php @@ -1,20 +1,22 @@ -withCache( diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index 94c5218..0fb18f6 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -1,4 +1,6 @@ - 'The :attribute must be a valid BIC/SWIFT code.', + 'bic' => 'The :attribute must be a valid BIC/SWIFT code.', 'bsb_number' => 'The :attribute must be a valid BSB number.', - 'iban' => 'The :attribute must be a valid IBAN.', - 'isin' => 'The :attribute must be a valid ISIN.', - 'luhn' => 'The :attribute must pass the Luhn checksum.', + 'iban' => 'The :attribute must be a valid IBAN.', + 'isin' => 'The :attribute must be a valid ISIN.', + 'luhn' => 'The :attribute must pass the Luhn checksum.', // Codes - 'asin' => 'The :attribute must be a valid ASIN.', - 'ean' => 'The :attribute must be a valid EAN barcode.', - 'gtin' => 'The :attribute must be a valid GTIN.', - 'isbn' => 'The :attribute must be a valid ISBN.', - 'ismn' => 'The :attribute must be a valid ISMN.', + 'asin' => 'The :attribute must be a valid ASIN.', + 'ean' => 'The :attribute must be a valid EAN barcode.', + 'gtin' => 'The :attribute must be a valid GTIN.', + 'isbn' => 'The :attribute must be a valid ISBN.', + 'ismn' => 'The :attribute must be a valid ISMN.', 'upc_e' => 'The :attribute must be a valid UPC-E barcode.', - 'issn' => 'The :attribute must be a valid ISSN.', + 'issn' => 'The :attribute must be a valid ISSN.', // Identifiers 'hash_digest' => 'The :attribute must be a :length-character hex digest.', - 'imei' => 'The :attribute must be a valid IMEI.', - 'jwt' => 'The :attribute must be a valid JSON Web Token.', - 'semver' => 'The :attribute must be a valid semantic version.', - 'vin' => 'The :attribute must be a valid vehicle identification number.', + 'imei' => 'The :attribute must be a valid IMEI.', + 'jwt' => 'The :attribute must be a valid JSON Web Token.', + 'semver' => 'The :attribute must be a valid semantic version.', + 'vin' => 'The :attribute must be a valid vehicle identification number.', // Telecom // @@ -43,21 +45,21 @@ // nothing they can act on; "must be a mobile number" and "must be a number from KE" each name // the thing to change. None of the three upstream Filament phone packages ships any message at // all — each tells you to add one to your own validation.php. - 'phone' => 'The :attribute must be a valid phone number.', - 'phone_possible' => 'The :attribute is not a possible phone number.', - 'phone_country' => 'The :attribute must be a phone number from :country.', - 'phone_type' => 'The :attribute must be a :type number.', - 'phone_extension' => 'The :attribute must not include an extension.', + 'phone' => 'The :attribute must be a valid phone number.', + 'phone_possible' => 'The :attribute is not a possible phone number.', + 'phone_country' => 'The :attribute must be a phone number from :country.', + 'phone_type' => 'The :attribute must be a :type number.', + 'phone_extension' => 'The :attribute must not include an extension.', 'phone_short_code' => 'The :attribute must be a full phone number, not a short code.', - 'phone_emergency' => 'The :attribute must not be an emergency number.', - 'phone_unique' => 'The :attribute has already been taken.', + 'phone_emergency' => 'The :attribute must not be an emergency number.', + 'phone_unique' => 'The :attribute has already been taken.', // Net - 'cidr' => 'The :attribute must be a valid CIDR network.', + 'cidr' => 'The :attribute must be a valid CIDR network.', 'domain_name' => 'The :attribute must be a valid domain name.', - 'private_ip' => 'The :attribute must be a private or reserved IP address.', - 'public_ip' => 'The :attribute must be a publicly routable IP address.', - 'subdomain' => 'The :attribute must be a valid subdomain.', + 'private_ip' => 'The :attribute must be a private or reserved IP address.', + 'public_ip' => 'The :attribute must be a publicly routable IP address.', + 'subdomain' => 'The :attribute must be a valid subdomain.', // // Several keys rather than one, because "that is not a URL" and "that @@ -65,48 +67,48 @@ // single message for both makes a working link look broken for no // stated reason. 'url' => [ - 'credentials' => 'The :attribute must not contain a username or password.', - 'fragment' => 'The :attribute must not contain a fragment.', - 'host' => 'The :attribute must have a valid host.', - 'host_is' => 'The :attribute must be at one of: :hosts.', - 'host_is_not' => 'The :attribute must not be at any of: :hosts.', - 'malformed' => 'The :attribute must be a valid URL.', - 'port' => 'The :attribute must use one of these ports: :ports.', + 'credentials' => 'The :attribute must not contain a username or password.', + 'fragment' => 'The :attribute must not contain a fragment.', + 'host' => 'The :attribute must have a valid host.', + 'host_is' => 'The :attribute must be at one of: :hosts.', + 'host_is_not' => 'The :attribute must not be at any of: :hosts.', + 'malformed' => 'The :attribute must be a valid URL.', + 'port' => 'The :attribute must use one of these ports: :ports.', 'private_host' => 'The :attribute must point at a public address.', - 'query' => 'The :attribute must not contain a query string.', - 'scheme' => 'The :attribute must use one of these schemes: :schemes.', + 'query' => 'The :attribute must not contain a query string.', + 'scheme' => 'The :attribute must use one of these schemes: :schemes.', ], 'mac_address' => [ 'broadcast' => 'The :attribute must not be the broadcast address.', - 'format' => 'The :attribute must be written in one of these notations: :formats.', - 'length' => 'The :attribute must be :bytes bytes.', - 'local' => 'The :attribute must be a manufacturer-assigned address, not a randomised one.', + 'format' => 'The :attribute must be written in one of these notations: :formats.', + 'length' => 'The :attribute must be :bytes bytes.', + 'local' => 'The :attribute must be a manufacturer-assigned address, not a randomised one.', 'malformed' => 'The :attribute must be a valid MAC address.', 'multicast' => 'The :attribute must be a unicast address.', - 'null' => 'The :attribute must not be the null address.', - 'oui' => 'The :attribute must begin with one of: :ouis.', + 'null' => 'The :attribute must not be the null address.', + 'oui' => 'The :attribute must begin with one of: :ouis.', ], 'in_cidr_range' => 'The :attribute must be within one of these networks: :networks.', 'slug' => 'The :attribute must be a URL slug: lowercase letters, digits and single hyphens, with no hyphen at the start or end.', - 'username' => 'The :attribute may contain letters, digits, and single dots, hyphens or underscores between them — not at the start, at the end, or doubled.', + 'username' => 'The :attribute may contain letters, digits, and single dots, hyphens or underscores between them — not at the start, at the end, or doubled.', 'username_reserved' => 'The :attribute is reserved. Please choose another.', // Three keys rather than one. A field whose characters were fine and whose count was not gets // sent looking for a bad character that is not there. - 'person_name' => 'The :attribute must be a name: letters, marks, spaces, apostrophes and hyphens.', - 'person_name_min' => 'The :attribute must contain at least :min names.', - 'person_name_max' => 'The :attribute must not contain more than :max names.', + 'person_name' => 'The :attribute must be a name: letters, marks, spaces, apostrophes and hyphens.', + 'person_name_min' => 'The :attribute must contain at least :min names.', + 'person_name_max' => 'The :attribute must not contain more than :max names.', 'person_name_required' => 'Please provide at least one of :values.', 'contains_html' => 'The :attribute must contain HTML markup.', - 'html_clean' => 'The :attribute must not contain HTML tags.', - 'max_words' => 'The :attribute must not contain more than :max words.', - 'min_words' => 'The :attribute must contain at least :min words.', - 'salutation' => 'The :attribute must be a recognised salutation.', + 'html_clean' => 'The :attribute must not contain HTML tags.', + 'max_words' => 'The :attribute must not contain more than :max words.', + 'min_words' => 'The :attribute must contain at least :min words.', + 'salutation' => 'The :attribute must be a recognised salutation.', 'without_spaces' => 'The :attribute must not contain any spaces, including non-breaking and zero-width ones.', @@ -115,63 +117,63 @@ // Networking probes 'has_gravatar' => 'The :attribute must have a Gravatar.', - 'image_url' => 'The :attribute must be a URL serving an image.', + 'image_url' => 'The :attribute must be a URL serving an image.', // Payment - 'card_cvc' => 'The :attribute must be a valid security code.', - 'card_expiry' => 'The :attribute must be a valid expiry date that has not passed.', - 'card_number' => 'The :attribute is not a recognised card number.', - 'card_number_brand' => 'The :attribute must be one of these card brands: :brands.', + 'card_cvc' => 'The :attribute must be a valid security code.', + 'card_expiry' => 'The :attribute must be a valid expiry date that has not passed.', + 'card_number' => 'The :attribute is not a recognised card number.', + 'card_number_brand' => 'The :attribute must be one of these card brands: :brands.', 'card_number_checksum' => 'The :attribute fails the card number check digit.', - 'card_number_length' => 'The :attribute must be :lengths digits for :brand.', + 'card_number_length' => 'The :attribute must be :lengths digits for :brand.', // Chrono - 'date_interval' => 'The :attribute must be an ISO 8601 duration.', + 'date_interval' => 'The :attribute must be an ISO 8601 duration.', 'date_interval_positive' => 'The :attribute must be a non-zero ISO 8601 duration.', - 'max_date_difference' => 'The :attribute must be within :hours hours of the reference date.', - 'minimum_age' => 'The :attribute must be a date of birth at least :years years ago.', - 'minute_in' => 'The :attribute must be a time whose minutes are one of: :minutes.', - 'rfc3339' => 'The :attribute must be an RFC 3339 timestamp.', - 'time_of_day' => 'The :attribute must be a valid time of day.', - 'timezone_abbreviation' => 'The :attribute must be a timezone abbreviation.', - 'unix_timestamp' => 'The :attribute must be a Unix timestamp.', + 'max_date_difference' => 'The :attribute must be within :hours hours of the reference date.', + 'minimum_age' => 'The :attribute must be a date of birth at least :years years ago.', + 'minute_in' => 'The :attribute must be a time whose minutes are one of: :minutes.', + 'rfc3339' => 'The :attribute must be an RFC 3339 timestamp.', + 'time_of_day' => 'The :attribute must be a valid time of day.', + 'timezone_abbreviation' => 'The :attribute must be a timezone abbreviation.', + 'unix_timestamp' => 'The :attribute must be a Unix timestamp.', // Encoding - 'base64' => 'The :attribute must be a base64-encoded value.', - 'base64_image' => 'The :attribute must be a base64-encoded image of an accepted type.', + 'base64' => 'The :attribute must be a base64-encoded value.', + 'base64_image' => 'The :attribute must be a base64-encoded image of an accepted type.', 'base64_image_size' => 'The :attribute must be an image no larger than :max.', - 'data_uri' => 'The :attribute must be a valid data URI.', + 'data_uri' => 'The :attribute must be a valid data URI.', // I18n - 'country_code' => 'The :attribute must be a valid ISO 3166-1 country code.', - 'currency_code' => 'The :attribute must be a valid ISO 4217 currency code.', + 'country_code' => 'The :attribute must be a valid ISO 3166-1 country code.', + 'currency_code' => 'The :attribute must be a valid ISO 4217 currency code.', 'currency_code_numeric' => 'The :attribute must be a valid ISO 4217 numeric currency code.', - 'currency_symbol' => 'The :attribute must be a recognised currency symbol.', - 'language_code' => 'The :attribute must be a valid ISO 639-1 language code.', + 'currency_symbol' => 'The :attribute must be a recognised currency symbol.', + 'language_code' => 'The :attribute must be a valid ISO 639-1 language code.', // Geo 'ca_province' => 'The :attribute must be a Canadian province or territory.', - 'lat_lng' => 'The :attribute must be a latitude,longitude pair.', - 'latitude' => 'The :attribute must be a latitude between -90 and 90.', - 'longitude' => 'The :attribute must be a longitude between -180 and 180.', - 'us_state' => 'The :attribute must be a US state.', + 'lat_lng' => 'The :attribute must be a latitude,longitude pair.', + 'latitude' => 'The :attribute must be a latitude between -90 and 90.', + 'longitude' => 'The :attribute must be a longitude between -180 and 180.', + 'us_state' => 'The :attribute must be a US state.', // Email 'email' => [ - 'disposable' => 'The :attribute must not be a disposable email address.', - 'domain_is' => 'The :attribute must be at one of: :domains.', + 'disposable' => 'The :attribute must not be a disposable email address.', + 'domain_is' => 'The :attribute must be at one of: :domains.', 'domain_is_not' => 'The :attribute must not be at any of: :domains.', - 'malformed' => 'The :attribute must be a valid email address.', - 'role' => 'The :attribute must belong to a person, not a shared mailbox.', - 'subaddress' => 'The :attribute must not contain a plus tag.', + 'malformed' => 'The :attribute must be a valid email address.', + 'role' => 'The :attribute must belong to a person, not a shared mailbox.', + 'subaddress' => 'The :attribute must not contain a plus tag.', 'undeliverable' => 'The :attribute is at a domain that cannot receive mail.', ], // Database - 'authorized' => 'The selected :attribute is invalid.', + 'authorized' => 'The selected :attribute is invalid.', 'compare_to_column' => 'The :attribute is outside the allowed bound.', - 'models_exist' => [ - 'array' => 'The :attribute must be a list of identifiers.', + 'models_exist' => [ + 'array' => 'The :attribute must be a list of identifiers.', 'missing' => 'The :attribute contains values that do not exist: :values.', ], @@ -181,15 +183,15 @@ // the field holds a dozen values. Each key names what to change. 'delimited' => [ 'distinct' => 'The :attribute must not repeat an entry.', - 'empty' => 'Entry :position of :attribute is empty — check for a stray separator.', - 'invalid' => 'The :attribute must be a delimited list.', - 'item' => 'Entry :position of :attribute is not valid.', - 'max' => 'The :attribute must not have more than :max entries.', - 'min' => 'The :attribute must have at least :min entries.', + 'empty' => 'Entry :position of :attribute is empty — check for a stray separator.', + 'invalid' => 'The :attribute must be a delimited list.', + 'item' => 'Entry :position of :attribute is not valid.', + 'max' => 'The :attribute must not have more than :max entries.', + 'min' => 'The :attribute must have at least :min entries.', ], // Crypto - 'bitcoin_address' => 'The :attribute must be a valid Bitcoin address.', + 'bitcoin_address' => 'The :attribute must be a valid Bitcoin address.', 'ethereum_address' => 'The :attribute must be a valid Ethereum address.', // Postal @@ -198,7 +200,7 @@ // Numbers 'parity' => [ 'even' => 'The :attribute must be an even number.', - 'odd' => 'The :attribute must be an odd number.', + 'odd' => 'The :attribute must be an odd number.', ], 'monetary_amount' => 'The :attribute must be an amount with at most :decimals decimal places.', @@ -206,17 +208,17 @@ 'css_color' => 'The :attribute must be a colour in one of these notations: :notations.', // Anti-spam - 'honeypot' => 'The :attribute could not be submitted.', + 'honeypot' => 'The :attribute could not be submitted.', 'submission_timing' => [ 'too_fast' => 'The form was submitted too quickly. Please try again.', - 'expired' => 'The form has expired. Please reload and try again.', + 'expired' => 'The form has expired. Please reload and try again.', ], // Vendor identifiers 'vendor_identifier' => 'The :attribute must be a valid :vendor identifier.', // Fiscal - 'vat_number' => 'The :attribute must be a valid VAT number.', + 'vat_number' => 'The :attribute must be a valid VAT number.', 'national_identifier' => 'The :attribute must be a valid :country national identification number.', // Profanity @@ -224,8 +226,8 @@ // Markup 'xml' => [ - 'malformed' => 'The :attribute must be well-formed XML.', - 'schema' => 'The :attribute does not match the required schema.', + 'malformed' => 'The :attribute must be well-formed XML.', + 'schema' => 'The :attribute does not match the required schema.', 'schema_missing' => 'The :attribute could not be checked: the schema is unavailable.', ], @@ -234,11 +236,11 @@ | parameter rather than five classes differing by a pattern and a message. */ 'case_style' => [ - 'camel' => 'The :attribute must be camelCase.', - 'kebab' => 'The :attribute must be kebab-case.', + 'camel' => 'The :attribute must be camelCase.', + 'kebab' => 'The :attribute must be kebab-case.', 'pascal' => 'The :attribute must be PascalCase.', - 'snake' => 'The :attribute must be snake_case.', - 'title' => 'The :attribute must be Title Case.', + 'snake' => 'The :attribute must be snake_case.', + 'title' => 'The :attribute must be Title Case.', ], ]; diff --git a/src/Actions/CachedDnsResolver.php b/src/Actions/CachedDnsResolver.php index de08893..a150859 100644 --- a/src/Actions/CachedDnsResolver.php +++ b/src/Actions/CachedDnsResolver.php @@ -1,11 +1,13 @@ - $values Deduplicated, non-null values to check - * @return array Values that exist in the DB + * @param array $values Deduplicated, non-null values to check + * + * @return array Values that exist in the DB */ public static function fetchExisting(array $values, Exists $rule): array { @@ -99,8 +102,9 @@ public static function fetchExisting(array $values, Exists $rule): array /** * Batch-query which values are already taken for a Unique rule. * - * @param array $values Deduplicated, non-null values to check - * @return array Values that already exist (taken) + * @param array $values Deduplicated, non-null values to check + * + * @return array Values that already exist (taken) */ public static function fetchTaken(array $values, Unique $rule): array { @@ -164,8 +168,9 @@ public static function batchableSignature(Exists|Unique $rule, string $table, st /** * Find batchable Exists/Unique rules in a set of compiled rules. * - * @param array $rules - * @return array Field name → rule object + * @param array $rules + * + * @return array Field name → rule object */ public static function findBatchableRules(array $rules): array { @@ -190,9 +195,10 @@ public static function findBatchableRules(array $rules): array /** * Collect values from items for batchable rules and group by table:column. * - * @param array $batchableFields - * @param array $items - * @param array $fieldRules Per-field rule array used to derive type predicates. + * @param array $batchableFields + * @param array $items + * @param array $fieldRules Per-field rule array used to derive type predicates. + * * @return array}> */ public static function collectValues(array $batchableFields, array $items, bool $isScalar, array $fieldRules = []): array @@ -261,8 +267,9 @@ public static function collectValues(array $batchableFields, array $items, bool * Unlike collectValues() which iterates items, this iterates expanded * rules like 'items.0.email', 'items.1.email' and uses data_get(). * - * @param array $preparedRules Expanded rules with concrete paths - * @param array $data + * @param array $preparedRules Expanded rules with concrete paths + * @param array $data + * * @return array}> */ public static function collectExpandedValues(array $preparedRules, array $data): array @@ -319,18 +326,6 @@ public static function collectExpandedValues(array $preparedRules, array $data): return $groups; } - /** - * Build the grouping key for a batched rule. Disambiguates exists / unique - * against the same (table, column) so a validator carrying both rules - * does not conflate them. - */ - private static function groupKey(string $table, string $column, Exists|Unique $rule): string - { - return $table . ':' . $column . ':' - . ($rule instanceof Unique ? 'unique' : 'exists') . ':' - . BatchRuleShape::of(self::extractMeta($rule)); - } - /** * Filter values against per-item string-form type rules (integer, numeric, * uuid, ulid, string). Values that would never pass per-item validation @@ -341,8 +336,9 @@ private static function groupKey(string $table, string $column, Exists|Unique $r * Object-form rules fall through unchanged; if no known type rule is * present, values are returned as-is (matches previous behaviour). * - * @param array $values - * @param array|string $itemRules Either a pipe-delimited string or an array of rules. + * @param array $values + * @param array|string $itemRules Either a pipe-delimited string or an array of rules. + * * @return array */ public static function filterValuesByType(array $values, array|string $itemRules): array @@ -356,9 +352,9 @@ public static function filterValuesByType(array $values, array|string $itemRules * Groups arrive already filtered (Phase 1) and deduplicated (collectors * own the dedup). Canonical order is filter → dedup → cap check → query. * - * @param array}> $groups - * @param array $poisonedTableColumns Extra table:columns that must not - * register a lookup ({@see findPoisonedTableColumns}) + * @param array}> $groups + * @param array $poisonedTableColumns Extra table:columns that must not + * register a lookup ({@see findPoisonedTableColumns}) * * @throws BatchLimitExceededException When any group's value count exceeds `$maxValuesPerGroup`. */ @@ -388,35 +384,6 @@ public static function buildVerifier(array $groups, array $poisonedTableColumns return $verifier->hasLookups() ? $verifier : null; } - /** - * Throw `BatchLimitExceededException` if any group exceeds the hard cap. - * Called after filter/dedup so the cap sees the same normalised set the - * query will. - * - * @param array}> $groups - */ - private static function assertWithinCap(array $groups): void - { - foreach ($groups as $key => $group) { - $count = count($group['values']); - - if ($count <= self::$maxValuesPerGroup) { - continue; - } - - [$table, $column, $ruleType] = self::parseGroupKey($key); - - throw new BatchLimitExceededException( - table: $table, - column: $column, - ruleType: $ruleType, - reason: BatchLimitExceededException::REASON_HARD_CAP, - valueCount: $count, - limit: self::$maxValuesPerGroup, - ); - } - } - /** * Batch-query and register lookups on a verifier for a set of grouped rules. * @@ -432,13 +399,14 @@ private static function assertWithinCap(array $groups): void * handles each rule correctly via per-item queries. Rare case, small * perf hit — exists and unique on the same (table, column) is unusual. * - * @param array}> $groups Keyed by "table:column:ruleType" - * @param array $poisonedTableColumns Table:columns claimed by rules the - * collectors never saw (a field's second DB rule, - * non-batchable or string-form rules) — treated - * exactly like a detected group conflict. - * @return bool False if any group was skipped, so the caller knows a - * fallback verifier is required to answer it. + * @param array}> $groups Keyed by "table:column:ruleType" + * @param array $poisonedTableColumns Table:columns claimed by rules the + * collectors never saw (a field's second DB rule, + * non-batchable or string-form rules) — treated + * exactly like a detected group conflict. + * + * @return bool False if any group was skipped, so the caller knows a + * fallback verifier is required to answer it. */ public static function registerLookups(PrecomputedPresenceVerifier $verifier, array $groups, array $poisonedTableColumns = []): bool { @@ -476,12 +444,72 @@ public static function registerLookups(PrecomputedPresenceVerifier $verifier, ar return $complete; } + /** + * Deduplicate and cast values to strings for batch queries. + * + * @param array $values + * + * @return list + */ + public static function uniqueStringValues(array $values): array + { + return array_values(array_unique( + array_map( + static fn (mixed $v): string => is_scalar($v) || $v instanceof Stringable ? (string) $v : '', + $values, + ), + SORT_STRING, + )); + } + + /** + * Build the grouping key for a batched rule. Disambiguates exists / unique + * against the same (table, column) so a validator carrying both rules + * does not conflate them. + */ + private static function groupKey(string $table, string $column, Exists|Unique $rule): string + { + return $table . ':' . $column . ':' + . ($rule instanceof Unique ? 'unique' : 'exists') . ':' + . BatchRuleShape::of(self::extractMeta($rule)); + } + + /** + * Throw `BatchLimitExceededException` if any group exceeds the hard cap. + * Called after filter/dedup so the cap sees the same normalised set the + * query will. + * + * @param array}> $groups + */ + private static function assertWithinCap(array $groups): void + { + foreach ($groups as $key => $group) { + $count = count($group['values']); + + if ($count <= self::$maxValuesPerGroup) { + continue; + } + + [$table, $column, $ruleType] = self::parseGroupKey($key); + + throw new BatchLimitExceededException( + table: $table, + column: $column, + ruleType: $ruleType, + reason: BatchLimitExceededException::REASON_HARD_CAP, + valueCount: $count, + limit: self::$maxValuesPerGroup, + ); + } + } + /** * Return the set of "table:column" strings that appear under more than * one rule type in the grouped rules (i.e. both an exists and a unique * group exist for the same physical column). * - * @param array}> $groups + * @param array}> $groups + * * @return array */ private static function findConflictingTableColumns(array $groups): array @@ -565,34 +593,17 @@ private static function extractMeta(Exists|Unique $rule): ?array return [ 'connection' => $connection, - 'table' => $table, - 'column' => $column === 'NULL' ? '' : $column, - 'wheres' => $typedWheres, - 'ignore' => $ignore, - 'idColumn' => $idColumn, + 'table' => $table, + 'column' => $column === 'NULL' ? '' : $column, + 'wheres' => $typedWheres, + 'ignore' => $ignore, + 'idColumn' => $idColumn, ]; } catch (ReflectionException) { return null; } } - /** - * Deduplicate and cast values to strings for batch queries. - * - * @param array $values - * @return list - */ - public static function uniqueStringValues(array $values): array - { - return array_values(array_unique( - array_map( - static fn (mixed $v): string => is_scalar($v) || $v instanceof Stringable ? (string) $v : '', - $values, - ), - SORT_STRING, - )); - } - private static function readProperty(object $object, string $property): mixed { return new ReflectionProperty($object, $property)->getValue($object); diff --git a/src/Builder/Concerns/HasEmbeddedRules.php b/src/Builder/Concerns/HasEmbeddedRules.php index 218fa1e..2485cb7 100644 --- a/src/Builder/Concerns/HasEmbeddedRules.php +++ b/src/Builder/Concerns/HasEmbeddedRules.php @@ -1,11 +1,13 @@ - */ private array $customMessages = []; - /** - * Seed $lastConstraint for rule classes whose defining constraint - * is property-initialised (e.g. `protected array $constraints = ['string']`) - * rather than added via addRule(). Called from factory constructors so - * `FluentRule::string()->message('...')` binds to 'string' without the - * caller knowing the initialisation internals. - */ - protected function seedLastConstraint(string $name): void - { - $this->lastConstraint = $name; - } - /** * Set the human-readable label for this field. * Used as the :attribute replacement in error messages. @@ -110,68 +100,6 @@ public function getCustomMessages(): array return $this->customMessages; } - /** - * Strings are appended to $constraints. Objects are appended to $rules. - * - * When $message is non-null, writes it to customMessages under the - * resolved $lastConstraint key. Throws LogicException if $message is - * set but no rule was added (e.g. an early-return branch in a caller). - * - * @param array|string|object $rules - */ - protected function addRule(array|string|object $rules, ?string $message = null): static - { - $this->compiledCache = null; - - if (is_object($rules)) { - $this->rules[] = $rules; - - $this->lastConstraint = match (true) { - $rules instanceof RequiredIf => 'required', - $rules instanceof RequiredUnless => 'required', - $rules instanceof ProhibitedIf => 'prohibited', - $rules instanceof ProhibitedUnless => 'prohibited', - $rules instanceof ExcludeIf => 'exclude', - $rules instanceof ExcludeUnless => 'exclude', - $rules instanceof In => 'in', - $rules instanceof NotIn => 'not_in', - $rules instanceof Unique => 'unique', - $rules instanceof Exists => 'exists', - $rules instanceof Contains => 'contains', - $rules instanceof DoesntContain => 'doesnt_contain', - default => lcfirst(class_basename($rules)), - }; - } else { - $this->constraints = array_merge($this->constraints, Arr::wrap($rules)); - // Track last constraint name: 'min:2' → 'min', 'required' → 'required' - $last = is_array($rules) ? end($rules) : $rules; - $this->lastConstraint = is_string($last) ? explode(':', $last, 2)[0] : null; - } - - if ($message !== null) { - if ($this->lastConstraint === null) { - throw new LogicException('message parameter has no rule to bind to'); - } - - $this->customMessages[$this->lastConstraint] = $message; - } - - return $this; - } - - /** @param array $values */ - private static function serializeValues(array $values): string - { - return implode(',', array_map( - static fn (string|int|bool|BackedEnum $v): string|int => match (true) { - $v instanceof BackedEnum => $v->value, - is_bool($v) => $v ? '1' : '0', - default => $v, - }, - $values, - )); - } - public function bail(): static { return $this->addRule('bail'); @@ -388,39 +316,6 @@ public function prohibitedIfDeclined(string $field, ?string $message = null): st return $this->addRule('prohibited_if_declined:' . $field, $message); } - /** - * Reorder constraints so that presence modifiers (required, nullable, bail, etc.) - * appear before type and size constraints ("required" must come - * before "must be a string"). - * - * @param list $constraints - * @return list - */ - protected function reorderConstraints(array $constraints): array - { - $presence = []; - $rest = []; - - foreach ($constraints as $constraint) { - if ($this->isPresenceConstraint($constraint)) { - $presence[] = $constraint; - } else { - $rest[] = $constraint; - } - } - - return [...$presence, ...$rest]; - } - - private function isPresenceConstraint(string $constraint): bool - { - return in_array($constraint, ['required', 'nullable', 'sometimes', 'filled', 'present', 'missing', 'bail', 'exclude', 'prohibited'], true) - || str_starts_with($constraint, 'required_') - || str_starts_with($constraint, 'missing_') - || str_starts_with($constraint, 'exclude_') - || str_starts_with($constraint, 'prohibited_'); - } - /** * Add any Laravel validation rule — string, object, or array tuple. * @@ -433,7 +328,7 @@ private function isPresenceConstraint(string $constraint): bool * instance — there is no defensive copy. Clone first if you need * isolation: `(clone $ruleSet->get($field))->rule($extra)`. * - * @param object|string|array $rule + * @param object|string|array $rule */ public function rule(object|string|array $rule, ?string $message = null): static { @@ -458,9 +353,9 @@ public function rule(object|string|array $rule, ?string $message = null): static * fn ($r) => $r->sometimes()->max(100), * ) * - * @param Closure(Fluent): bool $condition - * @param Closure(static): static|string|list $rules - * @param Closure(static): static|string|list $defaultRules + * @param Closure(Fluent): bool $condition + * @param Closure(static): static|string|list $rules + * @param Closure(static): static|string|list $defaultRules */ public function whenInput(Closure $condition, Closure|string|array $rules, Closure|string|array $defaultRules = []): static { @@ -472,7 +367,116 @@ public function whenInput(Closure $condition, Closure|string|array $rules, Closu } /** - * @param Closure(static): static|string|list $rules + * Seed $lastConstraint for rule classes whose defining constraint + * is property-initialised (e.g. `protected array $constraints = ['string']`) + * rather than added via addRule(). Called from factory constructors so + * `FluentRule::string()->message('...')` binds to 'string' without the + * caller knowing the initialisation internals. + */ + protected function seedLastConstraint(string $name): void + { + $this->lastConstraint = $name; + } + + /** + * Strings are appended to $constraints. Objects are appended to $rules. + * + * When $message is non-null, writes it to customMessages under the + * resolved $lastConstraint key. Throws LogicException if $message is + * set but no rule was added (e.g. an early-return branch in a caller). + * + * @param array|string|object $rules + */ + protected function addRule(array|string|object $rules, ?string $message = null): static + { + $this->compiledCache = null; + + if (is_object($rules)) { + $this->rules[] = $rules; + + $this->lastConstraint = match (true) { + $rules instanceof RequiredIf => 'required', + $rules instanceof RequiredUnless => 'required', + $rules instanceof ProhibitedIf => 'prohibited', + $rules instanceof ProhibitedUnless => 'prohibited', + $rules instanceof ExcludeIf => 'exclude', + $rules instanceof ExcludeUnless => 'exclude', + $rules instanceof In => 'in', + $rules instanceof NotIn => 'not_in', + $rules instanceof Unique => 'unique', + $rules instanceof Exists => 'exists', + $rules instanceof Contains => 'contains', + $rules instanceof DoesntContain => 'doesnt_contain', + default => lcfirst(class_basename($rules)), + }; + } else { + $this->constraints = array_merge($this->constraints, Arr::wrap($rules)); + // Track last constraint name: 'min:2' → 'min', 'required' → 'required' + $last = is_array($rules) ? end($rules) : $rules; + $this->lastConstraint = is_string($last) ? explode(':', $last, 2)[0] : null; + } + + if ($message !== null) { + if ($this->lastConstraint === null) { + throw new LogicException('message parameter has no rule to bind to'); + } + + $this->customMessages[$this->lastConstraint] = $message; + } + + return $this; + } + + /** + * Reorder constraints so that presence modifiers (required, nullable, bail, etc.) + * appear before type and size constraints ("required" must come + * before "must be a string"). + * + * @param list $constraints + * + * @return list + */ + protected function reorderConstraints(array $constraints): array + { + $presence = []; + $rest = []; + + foreach ($constraints as $constraint) { + if ($this->isPresenceConstraint($constraint)) { + $presence[] = $constraint; + } else { + $rest[] = $constraint; + } + } + + return [...$presence, ...$rest]; + } + + /** @param array $values */ + private static function serializeValues(array $values): string + { + return implode(',', array_map( + static fn (string|int|bool|BackedEnum $v): string|int => match (true) { + $v instanceof BackedEnum => $v->value, + is_bool($v) => $v ? '1' : '0', + default => $v, + }, + $values, + )); + } + + private function isPresenceConstraint(string $constraint): bool + { + return in_array($constraint, ['required', 'nullable', 'sometimes', 'filled', 'present', 'missing', 'bail', 'exclude', 'prohibited'], true) + || str_starts_with($constraint, 'required_') + || str_starts_with($constraint, 'missing_') + || str_starts_with($constraint, 'exclude_') + || str_starts_with($constraint, 'prohibited_'); + } + + /** + * @param Closure(static): static|string|list $rules + * * @return string|list */ private function compileConditionalBranch(Closure|string|array $rules): string|array diff --git a/src/Builder/Concerns/SelfValidates.php b/src/Builder/Concerns/SelfValidates.php index 85a683a..4183987 100644 --- a/src/Builder/Concerns/SelfValidates.php +++ b/src/Builder/Concerns/SelfValidates.php @@ -1,23 +1,42 @@ - */ protected array $data = []; + /** @var string|list|null */ + protected string|array|null $compiledCache = null; + + public function __clone(): void + { + $this->compiledCache = null; + } + public function validate(string $attribute, mixed $value, Closure $fail): void { if (! $this->hasPresenceModifier() && ! Arr::has($this->data, $attribute)) { @@ -63,6 +90,98 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $this->forwardErrors($innerValidator, $attribute, $nestedRules !== [], $fail); } + public function canCompile(): bool + { + return $this->rules === []; + } + + /** + * Compile to native Laravel format. Returns a pipe-joined string + * when possible (faster for Laravel to parse), or an array when + * non-stringable object rules are present. Memoized since rule + * objects are immutable after construction. + * + * @return string|list + */ + public function compiledRules(): string|array + { + return $this->compiledCache ??= $this->buildCompiledRules(); + } + + /** + * Get the compiled rules as an array. Useful for debugging and testing. + * + * @return list + */ + public function toArray(): array + { + $compiled = $this->compiledRules(); + + if (is_array($compiled)) { + return $compiled; + } + + return $compiled === '' ? [] : explode('|', $compiled); + } + + /** + * Dump the compiled rules and terminate execution. + */ + public function dd(mixed ...$args): never + { + dd($this->toArray(), ...$args); + } + + /** + * Dump the compiled rules. + */ + public function dump(mixed ...$args): static + { + dump($this->toArray(), ...$args); + + return $this; + } + + /** @return array */ + public function buildNestedRules(string $attribute): array + { + return []; + } + + public function setValidator(\Illuminate\Contracts\Validation\Validator $validator): static + { + $this->validator = $validator; + + return $this; + } + + /** @param array $data */ + public function setData(array $data): static + { + $this->data = $data; + + return $this; + } + + protected function hasPresenceModifier(): bool + { + if (array_intersect($this->constraints, ['required', 'sometimes', 'nullable', 'present']) !== []) { + return true; + } + + if ($this->hasPresenceConstraint()) { + return true; + } + + return $this->hasPresenceRule(); + } + + /** @return list */ + protected function buildValidationRules(): array + { + return [...$this->rules, ...$this->constraints]; + } + /** @return array */ private function buildMessages(string $attribute): array { @@ -146,23 +265,6 @@ private function isNullable(mixed $value): bool && in_array('nullable', $this->constraints, true); } - /** - * Exact rule names (the part before any `:`) whose rules must be evaluated - * against a null/absent value rather than skipped by `nullable`. Matched - * exactly — not by prefix — so a non-presence rule that merely shares a - * stem (most notably `required_array_keys`) never counts, and no entry is - * silently subsumed by another (`required_with` vs `required_without`). - */ - private const PRESENCE_FORCING_RULES = [ - 'required', 'filled', 'present', 'missing', - 'required_if', 'required_unless', - 'required_with', 'required_with_all', - 'required_without', 'required_without_all', - 'required_if_accepted', 'required_if_declined', - 'present_if', 'present_unless', 'present_with', 'present_with_all', - 'missing_if', 'missing_unless', 'missing_with', 'missing_with_all', - ]; - /** * Whether the chain carries a modifier that forces evaluation of a * null/absent value, in which case `nullable` must NOT short-circuit @@ -208,19 +310,6 @@ private function hasPresenceForcingModifier(): bool return false; } - protected function hasPresenceModifier(): bool - { - if (array_intersect($this->constraints, ['required', 'sometimes', 'nullable', 'present']) !== []) { - return true; - } - - if ($this->hasPresenceConstraint()) { - return true; - } - - return $this->hasPresenceRule(); - } - private function hasPresenceConstraint(): bool { foreach ($this->constraints as $constraint) { @@ -281,66 +370,6 @@ private function shouldExclude(string $attribute): bool return false; } - public function canCompile(): bool - { - return $this->rules === []; - } - - /** @var string|list|null */ - protected string|array|null $compiledCache = null; - - public function __clone(): void - { - $this->compiledCache = null; - } - - /** - * Compile to native Laravel format. Returns a pipe-joined string - * when possible (faster for Laravel to parse), or an array when - * non-stringable object rules are present. Memoized since rule - * objects are immutable after construction. - * - * @return string|list - */ - public function compiledRules(): string|array - { - return $this->compiledCache ??= $this->buildCompiledRules(); - } - - /** - * Get the compiled rules as an array. Useful for debugging and testing. - * - * @return list - */ - public function toArray(): array - { - $compiled = $this->compiledRules(); - - if (is_array($compiled)) { - return $compiled; - } - - return $compiled === '' ? [] : explode('|', $compiled); - } - - /** - * Dump the compiled rules and terminate execution. - */ - public function dd(mixed ...$args): never - { - dd($this->toArray(), ...$args); - } - - /** - * Dump the compiled rules. - */ - public function dump(mixed ...$args): static - { - dump($this->toArray(), ...$args); - - return $this; - } - /** @return string|list */ private function buildCompiledRules(): string|array { @@ -398,7 +427,7 @@ private function buildCompiledRules(): string|array * `regex:/^(a|b)$/` pattern) cannot be safely pipe-joined — Laravel's parser * would split it. Rules with one fall back to array form instead. * - * @param list $rules + * @param list $rules */ private function anyContainsPipe(array $rules): bool { @@ -422,31 +451,4 @@ private function isPresenceModifier(object $rule): bool || $rule instanceof RequiredIf || $rule instanceof RequiredUnless || $rule instanceof ProhibitedIf || $rule instanceof ProhibitedUnless; } - - /** @return list */ - protected function buildValidationRules(): array - { - return [...$this->rules, ...$this->constraints]; - } - - /** @return array */ - public function buildNestedRules(string $attribute): array - { - return []; - } - - public function setValidator(\Illuminate\Contracts\Validation\Validator $validator): static - { - $this->validator = $validator; - - return $this; - } - - /** @param array $data */ - public function setData(array $data): static - { - $this->data = $data; - - return $this; - } } diff --git a/src/Builder/Nodes/AcceptedRule.php b/src/Builder/Nodes/AcceptedRule.php index 0434996..3b98c5a 100644 --- a/src/Builder/Nodes/AcceptedRule.php +++ b/src/Builder/Nodes/AcceptedRule.php @@ -1,14 +1,16 @@ - $rules + * @param array $rules * * @throws CannotExtendListShapedEach when `eachRules` is list-shaped. */ @@ -169,7 +171,7 @@ public function mergeEachRules(array $rules): static if (array_key_exists('', $rules)) { throw new InvalidArgumentException( - 'mergeEachRules() requires non-empty keys — empty keys expand to malformed wildcard paths (items.*.).' + 'mergeEachRules() requires non-empty keys — empty keys expand to malformed wildcard paths (items.*.).', ); } @@ -185,7 +187,7 @@ public function mergeEachRules(array $rules): static * Unlike each() which produces wildcard paths (items.*.name), * children() produces fixed paths (search.value, search.regex). * - * @param array $rules + * @param array $rules */ public function children(array $rules): static { @@ -264,6 +266,37 @@ public function doesntContain(Arrayable|UnitEnum|array|string|int ...$values): s return $this->addRule(new DoesntContain($this->flattenContainsValues($values))); } + /** @return array */ + public function buildNestedRules(string $attribute): array + { + $rules = $this->buildEachNestedRules($attribute); + + return array_merge($rules, $this->buildChildNestedRules($attribute)); + } + + protected function buildArrayRule(): string + { + if ($this->keys === []) { + return 'array'; + } + + $keys = array_map( + static fn (string|BackedEnum $key): string|int => $key instanceof BackedEnum ? $key->value : $key, + $this->keys, + ); + + return 'array:' . implode(',', $keys); + } + + /** @return list */ + protected function buildValidationRules(): array + { + return [ + ...$this->rules, + ...$this->reorderConstraints([$this->buildArrayRule(), ...$this->constraints]), + ]; + } + /** * Unwrap a single `Arrayable` or `array` varargs entry so `->contains([...])` * and `->contains(...$iter)` behave identically. Matches Laravel's @@ -273,7 +306,8 @@ public function doesntContain(Arrayable|UnitEnum|array|string|int ...$values): s * Laravel's Rule::contains silently ignores extras, and leaving nested * arrays/Arrayables in the value list would crash Contains::__toString. * - * @param array $values + * @param array $values + * * @return array */ private function flattenContainsValues(array $values): array @@ -294,7 +328,7 @@ private function flattenContainsValues(array $values): array if (is_array($value) || $value instanceof Arrayable) { throw new InvalidArgumentException( 'contains()/doesntContain() does not accept multiple array or Arrayable arguments. ' - . 'Pass either a single iterable (->contains($values)) or variadic scalars (->contains($a, $b, $c)).' + . 'Pass either a single iterable (->contains($values)) or variadic scalars (->contains($a, $b, $c)).', ); } } @@ -302,28 +336,6 @@ private function flattenContainsValues(array $values): array return array_values($values); } - protected function buildArrayRule(): string - { - if ($this->keys === []) { - return 'array'; - } - - $keys = array_map( - static fn (string|BackedEnum $key): string|int => $key instanceof BackedEnum ? $key->value : $key, - $this->keys, - ); - - return 'array:' . implode(',', $keys); - } - - /** @return array */ - public function buildNestedRules(string $attribute): array - { - $rules = $this->buildEachNestedRules($attribute); - - return array_merge($rules, $this->buildChildNestedRules($attribute)); - } - /** @return array */ private function buildEachNestedRules(string $attribute): array { @@ -374,13 +386,4 @@ private function buildChildNestedRules(string $attribute): array return $nested === [] ? $rules : array_merge($rules, ...$nested); } - - /** @return list */ - protected function buildValidationRules(): array - { - return [ - ...$this->rules, - ...$this->reorderConstraints([$this->buildArrayRule(), ...$this->constraints]), - ]; - } } diff --git a/src/Builder/Nodes/BooleanRule.php b/src/Builder/Nodes/BooleanRule.php index 67d3174..cae9fd0 100644 --- a/src/Builder/Nodes/BooleanRule.php +++ b/src/Builder/Nodes/BooleanRule.php @@ -1,14 +1,16 @@ -rule(new NotDisposableEmail(), $message); + return $this->rule(new NotDisposableEmail, $message); } /** @@ -88,7 +90,7 @@ public function notDisposable(?string $message = null): static */ public function notRole(?string $message = null): static { - return $this->rule(new NotRoleEmail(), $message); + return $this->rule(new NotRoleEmail, $message); } /** @@ -97,7 +99,7 @@ public function notRole(?string $message = null): static * `*.example.com` matches any subdomain and NOT the bare domain; list * both when both are wanted. See EmailDomainIs for why that is strict. * - * @param list|string $domains + * @param list|string $domains */ public function domainIs(array|string $domains, ?string $message = null): static { @@ -107,7 +109,7 @@ public function domainIs(array|string $domains, ?string $message = null): static /** * Bar the address from the given domains, same pattern syntax. * - * @param list|string $domains + * @param list|string $domains */ public function domainIsNot(array|string $domains, ?string $message = null): static { @@ -133,7 +135,7 @@ public function domainIsNot(array|string $domains, ?string $message = null): sta */ public function deliverable(?string $message = null): static { - return $this->rule(new DeliverableEmail(), $message); + return $this->rule(new DeliverableEmail, $message); } /** @@ -151,7 +153,7 @@ public function deliverable(?string $message = null): static */ public function withoutSubaddressing(?string $message = null): static { - return $this->rule(new NoSubaddressing(), $message); + return $this->rule(new NoSubaddressing, $message); } /** diff --git a/src/Builder/Nodes/FieldRule.php b/src/Builder/Nodes/FieldRule.php index 11b7281..921e605 100644 --- a/src/Builder/Nodes/FieldRule.php +++ b/src/Builder/Nodes/FieldRule.php @@ -1,19 +1,21 @@ -|null */ protected ?array $childRules = null; + /** + * Mirrors `Macroable::__call` dispatch exactly except for the + * "no macro registered" branch, which throws a typed exception + * pointing at the correct typed builder instead of a bare + * `BadMethodCallException`. See `UnknownFluentRuleMethod`. + * + * @param array $parameters + */ + public function __call(string $method, array $parameters): mixed + { + if (! static::hasMacro($method)) { + throw UnknownFluentRuleMethod::on($method); + } + + $macro = static::$macros[$method]; + + if ($macro instanceof Closure) { + $macro = $macro->bindTo($this, static::class); + } + + if (! is_callable($macro)) { + throw UnknownFluentRuleMethod::on($method); + } + + return $macro(...$parameters); + } + + /** @param array $parameters */ + public static function __callStatic(string $method, array $parameters): mixed + { + if (! static::hasMacro($method)) { + throw UnknownFluentRuleMethod::on($method); + } + + $macro = static::$macros[$method]; + + if ($macro instanceof Closure) { + $macro = $macro->bindTo(null, static::class); + } + + if (! is_callable($macro)) { + throw UnknownFluentRuleMethod::on($method); + } + + return $macro(...$parameters); + } + /** * Define rules for fixed-key children of this field. * * Produces fixed paths (answer.email_address) without wildcards. * - * @param array $rules + * @param array $rules */ public function children(array $rules): static { @@ -71,15 +120,15 @@ public function getChildRules(): ?array * survive untouched. * * @throws LogicException when $key already exists — silent override - * would hide the "parent already defines this" - * mistake. Use mergeChildRules() for - * intentional replacement. + * would hide the "parent already defines this" + * mistake. Use mergeChildRules() for + * intentional replacement. */ public function addChildRule(string $key, ValidationRule $rule): static { if ($key === '') { throw new InvalidArgumentException( - 'addChildRule() requires a non-empty key — empty keys expand to malformed dotted paths (parent.).' + 'addChildRule() requires a non-empty key — empty keys expand to malformed dotted paths (parent.).', ); } @@ -103,13 +152,13 @@ public function addChildRule(string $key, ValidationRule $rule): static /** * Merge multiple keyed child rules, later-wins on collision. * - * @param array $rules + * @param array $rules */ public function mergeChildRules(array $rules): static { if (array_key_exists('', $rules)) { throw new InvalidArgumentException( - 'mergeChildRules() requires non-empty keys — empty keys expand to malformed dotted paths (parent.).' + 'mergeChildRules() requires non-empty keys — empty keys expand to malformed dotted paths (parent.).', ); } @@ -159,51 +208,4 @@ protected function buildValidationRules(): array { return [...$this->reorderConstraints($this->constraints), ...$this->rules]; } - - /** - * Mirrors `Macroable::__call` dispatch exactly except for the - * "no macro registered" branch, which throws a typed exception - * pointing at the correct typed builder instead of a bare - * `BadMethodCallException`. See `UnknownFluentRuleMethod`. - * - * @param array $parameters - */ - public function __call(string $method, array $parameters): mixed - { - if (! static::hasMacro($method)) { - throw UnknownFluentRuleMethod::on($method); - } - - $macro = static::$macros[$method]; - - if ($macro instanceof Closure) { - $macro = $macro->bindTo($this, static::class); - } - - if (! is_callable($macro)) { - throw UnknownFluentRuleMethod::on($method); - } - - return $macro(...$parameters); - } - - /** @param array $parameters */ - public static function __callStatic(string $method, array $parameters): mixed - { - if (! static::hasMacro($method)) { - throw UnknownFluentRuleMethod::on($method); - } - - $macro = static::$macros[$method]; - - if ($macro instanceof Closure) { - $macro = $macro->bindTo(null, static::class); - } - - if (! is_callable($macro)) { - throw UnknownFluentRuleMethod::on($method); - } - - return $macro(...$parameters); - } } diff --git a/src/Builder/Nodes/FileRule.php b/src/Builder/Nodes/FileRule.php index 596c919..c29b764 100644 --- a/src/Builder/Nodes/FileRule.php +++ b/src/Builder/Nodes/FileRule.php @@ -1,14 +1,16 @@ -seedLastConstraint($this->defaultConstraintName()); } - /** - * Hook for subclasses (ImageRule) to override which constraint name - * seeds $lastConstraint without re-implementing __construct. - */ - protected function defaultConstraintName(): string - { - return 'file'; - } - public function min(int|string $size, ?string $message = null): static { return $this->addRule('min:' . $this->toKilobytes($size), $message); @@ -69,6 +62,15 @@ public function mimetypes(string ...$mimetypes): static return $this->addRule('mimetypes:' . implode(',', $mimetypes)); } + /** + * Hook for subclasses (ImageRule) to override which constraint name + * seeds $lastConstraint without re-implementing __construct. + */ + protected function defaultConstraintName(): string + { + return 'file'; + } + protected function toKilobytes(int|string $size): int { if (is_int($size)) { @@ -80,10 +82,10 @@ protected function toKilobytes(int|string $size): int $unit = strtolower($matches[2]); return (int) round(match ($unit) { - 'kb' => $value, - 'mb' => $value * 1_000, - 'gb' => $value * 1_000_000, - 'tb' => $value * 1_000_000_000, + 'kb' => $value, + 'mb' => $value * 1_000, + 'gb' => $value * 1_000_000, + 'tb' => $value * 1_000_000_000, default => $value, }); } diff --git a/src/Builder/Nodes/ImageRule.php b/src/Builder/Nodes/ImageRule.php index 449a960..b90d72a 100644 --- a/src/Builder/Nodes/ImageRule.php +++ b/src/Builder/Nodes/ImageRule.php @@ -1,4 +1,6 @@ - */ protected array $constraints = ['image']; - protected function defaultConstraintName(): string - { - return 'image'; - } - public function allowSvg(): static { $this->constraints = array_values(array_map( @@ -63,4 +60,9 @@ public function ratio(float|string $value, ?string $message = null): static { return $this->dimensions(new Dimensions(['ratio' => $value]), $message); } + + protected function defaultConstraintName(): string + { + return 'image'; + } } diff --git a/src/Builder/Nodes/IpAddressRule.php b/src/Builder/Nodes/IpAddressRule.php index 2164392..81b1eb0 100644 --- a/src/Builder/Nodes/IpAddressRule.php +++ b/src/Builder/Nodes/IpAddressRule.php @@ -1,18 +1,20 @@ -rule(new PublicIp(), $message); + return $this->rule(new PublicIp, $message); } /** @@ -89,7 +91,7 @@ public function public(?string $message = null): static */ public function private(?string $message = null): static { - return $this->rule(new PrivateIp(), $message); + return $this->rule(new PrivateIp, $message); } /** @@ -100,13 +102,25 @@ public function private(?string $message = null): static * FluentRule::ip()->inRange(['203.0.113.0/24', '2001:db8::/32']) * ``` * - * @param list|string $networks + * @param list|string $networks */ public function inRange(array|string $networks, ?string $message = null): static { return $this->rule(new InCidrRange(array_values((array) $networks)), $message); } + /** + * @return list + */ + protected function buildValidationRules(): array + { + return [ + ...$this->reorderConstraints($this->constraints), + $this->family, + ...$this->rules, + ]; + } + private function family(string $rule, ?string $message): static { $this->family = $rule; @@ -120,16 +134,4 @@ private function family(string $rule, ?string $message): static return $this; } - - /** - * @return list - */ - protected function buildValidationRules(): array - { - return [ - ...$this->reorderConstraints($this->constraints), - $this->family, - ...$this->rules, - ]; - } } diff --git a/src/Builder/Nodes/MacAddressRule.php b/src/Builder/Nodes/MacAddressRule.php index f646988..59b9286 100644 --- a/src/Builder/Nodes/MacAddressRule.php +++ b/src/Builder/Nodes/MacAddressRule.php @@ -1,16 +1,18 @@ -|string $formats {@see MacAddress::COLON} and friends. + * @param list|string $formats {@see MacAddress::COLON} and friends. */ public function format(array|string $formats): static { @@ -139,7 +141,7 @@ public function universal(): static * Written in any notation and any length — a 24-bit OUI, or a longer * MA-M/MA-S assignment. * - * @param list|string $ouis + * @param list|string $ouis */ public function oui(array|string $ouis): static { diff --git a/src/Builder/Nodes/NumericRule.php b/src/Builder/Nodes/NumericRule.php index ec37807..1332407 100644 --- a/src/Builder/Nodes/NumericRule.php +++ b/src/Builder/Nodes/NumericRule.php @@ -1,15 +1,17 @@ - - */ - protected function buildValidationRules(): array - { - return [ - ...$this->reorderConstraints($this->constraints), - new Phone( - countries: $this->countries, - countryField: $this->countryField, - types: $this->types, - possibleOnly: $this->possibleOnly, - allowExtension: $this->allowExtension, - rejectShortNumbers: $this->rejectShortNumbers, - rejectEmergency: $this->rejectEmergency, - ), - ...$this->rules, - ]; - } - /** * Uniqueness compared in E.164 rather than as typed. * @@ -237,4 +219,24 @@ public function unique(string $table, ?string $column = null, ?Closure $callback return $this->addRule($rule, $message); } + + /** + * @return list + */ + protected function buildValidationRules(): array + { + return [ + ...$this->reorderConstraints($this->constraints), + new Phone( + countries: $this->countries, + countryField: $this->countryField, + types: $this->types, + possibleOnly: $this->possibleOnly, + allowExtension: $this->allowExtension, + rejectShortNumbers: $this->rejectShortNumbers, + rejectEmergency: $this->rejectEmergency, + ), + ...$this->rules, + ]; + } } diff --git a/src/Builder/Nodes/StringRule.php b/src/Builder/Nodes/StringRule.php index 8cfaadd..669cd75 100644 --- a/src/Builder/Nodes/StringRule.php +++ b/src/Builder/Nodes/StringRule.php @@ -1,17 +1,19 @@ -addRule('regex:' . $compiled, $message); } - /** @param Regex|Closure(Regex): Regex $pattern */ - private function compileRegex(Regex|Closure $pattern): string - { - if ($pattern instanceof Closure) { - $built = $pattern(Regex::build()); - - return $built->compile(); - } - - return $pattern->compile(); - } - public function notRegex(string $pattern, ?string $message = null): static { return $this->addRule('not_regex:' . $pattern, $message); @@ -267,4 +257,16 @@ protected function buildValidationRules(): array { return [...$this->reorderConstraints($this->constraints), ...$this->rules]; } + + /** @param Regex|Closure(Regex): Regex $pattern */ + private function compileRegex(Regex|Closure $pattern): string + { + if ($pattern instanceof Closure) { + $built = $pattern(Regex::build()); + + return $built->compile(); + } + + return $pattern->compile(); + } } diff --git a/src/Builder/Nodes/UrlRule.php b/src/Builder/Nodes/UrlRule.php index 441617f..7bb6ac4 100644 --- a/src/Builder/Nodes/UrlRule.php +++ b/src/Builder/Nodes/UrlRule.php @@ -1,17 +1,19 @@ -|string $schemes + * @param list|string $schemes */ public function scheme(array|string $schemes): static { @@ -95,7 +97,7 @@ public function secure(): static * bare domain. List both when both are wanted — see the note on * {@see HostPattern}. * - * @param list|string $hosts + * @param list|string $hosts */ public function hostIs(array|string $hosts): static { @@ -107,7 +109,7 @@ public function hostIs(array|string $hosts): static /** * Bar these hosts, same syntax. Applied after the allow-list. * - * @param list|string $hosts + * @param list|string $hosts */ public function hostIsNot(array|string $hosts): static { @@ -120,7 +122,7 @@ public function hostIsNot(array|string $hosts): static * Accept only these ports. A URL with no port is accepted only if the * default for its scheme is in the list — state 80 or 443 explicitly. * - * @param list|int $ports + * @param list|int $ports */ public function port(array|int $ports): static { diff --git a/src/Builder/Nodes/UsernameRule.php b/src/Builder/Nodes/UsernameRule.php index cadf1fe..4bcf78d 100644 --- a/src/Builder/Nodes/UsernameRule.php +++ b/src/Builder/Nodes/UsernameRule.php @@ -1,16 +1,18 @@ -reserved([...Username::DEFAULT_RESERVED, 'acme', 'enterprise']) * ``` * - * @param list $names + * @param list $names */ public function reserved(array $names): static { @@ -130,7 +132,7 @@ public function reserved(array $names): static /** * Add to the shipped list rather than replacing it. * - * @param list|string $names + * @param list|string $names */ public function alsoReserved(array|string $names): static { diff --git a/src/Check.php b/src/Check.php index 86e494c..564d30f 100644 --- a/src/Check.php +++ b/src/Check.php @@ -1,34 +1,36 @@ - ['OK', resolve(RoleAccountList::class)::class]], - ['dns resolver', fn (): array => ['OK', resolve(DnsResolver::class)::class]], + ['dns resolver', fn (): array => ['OK', resolve(DnsResolver::class)::class]], ['batch query cap', function (): array { $limit = BatchDatabaseChecker::$maxValuesPerGroup; diff --git a/src/Commands/RulesCommand.php b/src/Commands/RulesCommand.php index 9502a23..abd442c 100644 --- a/src/Commands/RulesCommand.php +++ b/src/Commands/RulesCommand.php @@ -1,13 +1,15 @@ - [User::class], - 'authorized' => ['view', User::class], - 'parity' => ['even'], - 'vendor_identifier' => ['aws_region'], + 'models_exist' => [User::class], + 'authorized' => ['view', User::class], + 'parity' => ['even'], + 'vendor_identifier' => ['aws_region'], 'national_identifier' => ['nl'], - 'minute_in' => ['0', '15', '30', '45'], + 'minute_in' => ['0', '15', '30', '45'], 'max_date_difference' => ['48', '2026-08-24 00:00:00'], - 'minimum_age' => ['18'], - 'compare_to_column' => ['posts', 'owner_id', 'gte', 'id', '1'], + 'minimum_age' => ['18'], + 'compare_to_column' => ['posts', 'owner_id', 'gte', 'id', '1'], 'file_exists_on_disk' => ['local'], - 'hash_digest' => ['sha256'], - 'max_words' => ['200'], - 'min_words' => ['2'], + 'hash_digest' => ['sha256'], + 'max_words' => ['200'], + 'min_words' => ['2'], ]; $index = []; diff --git a/src/Concerns/SkipsPrecognition.php b/src/Concerns/SkipsPrecognition.php index b3dafea..23be9fb 100644 --- a/src/Concerns/SkipsPrecognition.php +++ b/src/Concerns/SkipsPrecognition.php @@ -1,9 +1,11 @@ - $rule + * @param object|string|array $rule */ public function rule(object|string|array $rule): static; @@ -149,25 +151,27 @@ public function rule(object|string|array $rule): static; * return is `$this|\Illuminate\Conditionable\HigherOrderWhenProxy` * depending on whether a callback is supplied. * - * @param mixed $value - * @param (callable(static, mixed): mixed)|null $callback - * @param (callable(static, mixed): mixed)|null $default + * @param mixed $value + * @param (callable(static, mixed): mixed)|null $callback + * @param (callable(static, mixed): mixed)|null $default + * * @return static|HigherOrderWhenProxy */ public function when($value = null, ?callable $callback = null, ?callable $default = null); /** - * @param mixed $value - * @param (callable(static, mixed): mixed)|null $callback - * @param (callable(static, mixed): mixed)|null $default + * @param mixed $value + * @param (callable(static, mixed): mixed)|null $callback + * @param (callable(static, mixed): mixed)|null $default + * * @return static|HigherOrderWhenProxy */ public function unless($value = null, ?callable $callback = null, ?callable $default = null); /** - * @param Closure(Fluent): bool $condition - * @param Closure(static): static|string|list $rules - * @param Closure(static): static|string|list $defaultRules + * @param Closure(Fluent): bool $condition + * @param Closure(static): static|string|list $rules + * @param Closure(static): static|string|list $defaultRules */ public function whenInput(Closure $condition, Closure|string|array $rules, Closure|string|array $defaultRules = []): static; diff --git a/src/Contracts/I18n/CountryDataset.php b/src/Contracts/I18n/CountryDataset.php index 99987b4..e9a5063 100644 --- a/src/Contracts/I18n/CountryDataset.php +++ b/src/Contracts/I18n/CountryDataset.php @@ -1,4 +1,6 @@ - $rules Field → rule input, as the rule set holds it. Mutable. - * @param array $messages Custom messages. Mutable. - * @param array $attributes Human attribute names. Mutable. - * @param array $data The data about to be validated. Mutable. + * @param array $rules Field → rule input, as the rule set holds it. Mutable. + * @param array $messages Custom messages. Mutable. + * @param array $attributes Human attribute names. Mutable. + * @param array $data The data about to be validated. Mutable. */ public function __construct( public readonly RuleSet $ruleSet, diff --git a/src/Events/ValidationCompleted.php b/src/Events/ValidationCompleted.php index bdbaf6d..2f54934 100644 --- a/src/Events/ValidationCompleted.php +++ b/src/Events/ValidationCompleted.php @@ -1,4 +1,6 @@ - $data - * @param array $validated + * @param array $data + * @param array $validated */ public function __construct( public RuleSet $ruleSet, diff --git a/src/Events/ValidationFailed.php b/src/Events/ValidationFailed.php index 3068425..43b8557 100644 --- a/src/Events/ValidationFailed.php +++ b/src/Events/ValidationFailed.php @@ -1,10 +1,12 @@ - 'string', - NumericRule::class => 'numeric', - DateRule::class => 'date', - ArrayRule::class => 'array', - FileRule::class => 'file', - ImageRule::class => 'image', - BooleanRule::class => 'boolean', + StringRule::class => 'string', + NumericRule::class => 'numeric', + DateRule::class => 'date', + ArrayRule::class => 'array', + FileRule::class => 'file', + ImageRule::class => 'image', + BooleanRule::class => 'boolean', AcceptedRule::class => 'accepted', PasswordRule::class => 'password', - EmailRule::class => 'email', + EmailRule::class => 'email', ]; /** @@ -111,14 +113,14 @@ public static function for(string $method): ?string private static function specialCase(string $method): ?string { return match ($method) { - 'accepted' => 'Use `FluentRule::accepted()` for permissive accepted values (`\'yes\'`, `\'on\'`, `\'1\'`, `true`). Avoid `FluentRule::boolean()->accepted()` — the `boolean` base rule rejects `\'yes\'`/`\'on\'`.', - 'declined' => "Use `FluentRule::boolean()->declined(...)` for strict boolean-only input. If HTML form values (`'no'`, `'off'`) need to pass, use `->rule('declined')` on the untyped builder instead.", - 'size' => 'No `size()` method — use `->exactly(...)` on a typed builder. (This package renames Laravel\'s `size:` rule to `exactly()` for clarity.)', + 'accepted' => 'Use `FluentRule::accepted()` for permissive accepted values (`\'yes\'`, `\'on\'`, `\'1\'`, `true`). Avoid `FluentRule::boolean()->accepted()` — the `boolean` base rule rejects `\'yes\'`/`\'on\'`.', + 'declined' => "Use `FluentRule::boolean()->declined(...)` for strict boolean-only input. If HTML form values (`'no'`, `'off'`) need to pass, use `->rule('declined')` on the untyped builder instead.", + 'size' => 'No `size()` method — use `->exactly(...)` on a typed builder. (This package renames Laravel\'s `size:` rule to `exactly()` for clarity.)', 'gt', 'gte', 'lt', 'lte' => 'No `gt()`/`lt()` methods — use `FluentRule::numeric()->greaterThan(FIELD)`, `->greaterThanOrEqualTo(FIELD)`, `->lessThan(FIELD)`, `->lessThanOrEqualTo(FIELD)`.', - 'alphaNum' => 'No `alphaNum()` method — use `FluentRule::string()->alphaNumeric(...)`.', - 'contains' => 'Use `FluentRule::array()->contains(...)` (this package exposes `contains` on `array()`, not `string()`).', - 'format' => 'Use `FluentRule::date()->format(...)` for date format; for string-format regex use `FluentRule::string()->regex(...)`.', - default => null, + 'alphaNum' => 'No `alphaNum()` method — use `FluentRule::string()->alphaNumeric(...)`.', + 'contains' => 'Use `FluentRule::array()->contains(...)` (this package exposes `contains` on `array()`, not `string()`).', + 'format' => 'Use `FluentRule::date()->format(...)` for date format; for string-format regex use `FluentRule::string()->regex(...)`.', + default => null, }; } @@ -155,7 +157,8 @@ private static function methodMap(): array } /** - * @param class-string $class + * @param class-string $class + * * @return array */ private static function publicInstanceMethods(string $class): array @@ -184,7 +187,7 @@ private static function publicInstanceMethods(string $class): array } /** - * @param list $factories + * @param list $factories */ private static function formatFactoryList(array $factories): string { diff --git a/src/Exceptions/UnknownFluentRuleMethod.php b/src/Exceptions/UnknownFluentRuleMethod.php index 276ed75..c2486d4 100644 --- a/src/Exceptions/UnknownFluentRuleMethod.php +++ b/src/Exceptions/UnknownFluentRuleMethod.php @@ -1,4 +1,6 @@ - $config + * @param array $config + * * @return array|null */ private static function parsePartWithItemContext(string $part, array $config): ?array @@ -149,18 +152,18 @@ private static function parsePartWithItemContext(string $part, array $config): ? // For date rules, use the field-ref-aware parser. For everything else, // delegate to the value-only parser in RuleConfigBuilder. return match (true) { - str_starts_with($part, 'date_equals:') => self::parseDateParamWithFieldRef($config, 'dateEquals', substr($part, 12)), - str_starts_with($part, 'after_or_equal:') => self::parseDateParamWithFieldRef($config, 'afterOrEqual', substr($part, 15)), + str_starts_with($part, 'date_equals:') => self::parseDateParamWithFieldRef($config, 'dateEquals', substr($part, 12)), + str_starts_with($part, 'after_or_equal:') => self::parseDateParamWithFieldRef($config, 'afterOrEqual', substr($part, 15)), str_starts_with($part, 'before_or_equal:') => self::parseDateParamWithFieldRef($config, 'beforeOrEqual', substr($part, 16)), - str_starts_with($part, 'after:') => self::parseDateParamWithFieldRef($config, 'after', substr($part, 6)), - str_starts_with($part, 'before:') => self::parseDateParamWithFieldRef($config, 'before', substr($part, 7)), - str_starts_with($part, 'same:') => self::parseFieldOnlyRef($config, 'sameField', substr($part, 5)), - str_starts_with($part, 'different:') => self::parseFieldOnlyRef($config, 'differentField', substr($part, 10)), - str_starts_with($part, 'gte:') => self::parseFieldOnlyRef($config, 'gteField', substr($part, 4)), - str_starts_with($part, 'lte:') => self::parseFieldOnlyRef($config, 'lteField', substr($part, 4)), - str_starts_with($part, 'gt:') => self::parseFieldOnlyRef($config, 'gtField', substr($part, 3)), - str_starts_with($part, 'lt:') => self::parseFieldOnlyRef($config, 'ltField', substr($part, 3)), - default => RuleConfigBuilder::parseValuePart($part, $config), + str_starts_with($part, 'after:') => self::parseDateParamWithFieldRef($config, 'after', substr($part, 6)), + str_starts_with($part, 'before:') => self::parseDateParamWithFieldRef($config, 'before', substr($part, 7)), + str_starts_with($part, 'same:') => self::parseFieldOnlyRef($config, 'sameField', substr($part, 5)), + str_starts_with($part, 'different:') => self::parseFieldOnlyRef($config, 'differentField', substr($part, 10)), + str_starts_with($part, 'gte:') => self::parseFieldOnlyRef($config, 'gteField', substr($part, 4)), + str_starts_with($part, 'lte:') => self::parseFieldOnlyRef($config, 'lteField', substr($part, 4)), + str_starts_with($part, 'gt:') => self::parseFieldOnlyRef($config, 'gtField', substr($part, 3)), + str_starts_with($part, 'lt:') => self::parseFieldOnlyRef($config, 'ltField', substr($part, 3)), + default => RuleConfigBuilder::parseValuePart($part, $config), }; } @@ -169,7 +172,8 @@ private static function parsePartWithItemContext(string $part, array $config): ? * is a date literal, behaves like a literal parse. Otherwise, if it's a * plausible field name, stores it under `{$key}Field` for item-time resolution. * - * @param array $config + * @param array $config + * * @return array|null */ private static function parseDateParamWithFieldRef(array $config, string $key, string $param): ?array @@ -193,7 +197,8 @@ private static function parseDateParamWithFieldRef(array $config, string $key, s * parameter isn't a single plausible identifier — so multi-param forms * like `different:a,b` fall through to Laravel. * - * @param array $config + * @param array $config + * * @return array|null */ private static function parseFieldOnlyRef(array $config, string $key, string $param): ?array @@ -210,7 +215,8 @@ private static function parseFieldOnlyRef(array $config, string $key, string $pa * against the item array. Delegates most checks to CoreValueCompiler's * value-only closure. * - * @param array $c + * @param array $c + * * @return Closure(mixed, array): bool */ private static function buildItemAwareClosure(array $c): Closure @@ -261,13 +267,24 @@ private static function buildItemAwareClosure(array $c): Closure return static function (mixed $value, array $item) use ( $valueClosure, - $afterField, $beforeField, - $afterOrEqualField, $beforeOrEqualField, + $afterField, + $beforeField, + $afterOrEqualField, + $beforeOrEqualField, $dateEqualsField, - $sameField, $differentField, - $gtField, $gteField, $ltField, $lteField, - $isNumeric, $isInteger, $isArray, $nullable, $hasImplicit, - $hasDateFieldRef, $hasSizeComparison + $sameField, + $differentField, + $gtField, + $gteField, + $ltField, + $lteField, + $isNumeric, + $isInteger, + $isArray, + $nullable, + $hasImplicit, + $hasDateFieldRef, + $hasSizeComparison ): bool { if (! $valueClosure($value)) { return false; @@ -421,7 +438,7 @@ private static function sizePair(mixed $value, mixed $ref, bool $isNumeric, bool * missing, empty, non-string, unparseable) coerce to 0 — matches Laravel's * loose-comparison behavior when a referenced field doesn't parse. * - * @param array $item + * @param array $item */ private static function resolveRefTimestamp(array $item, string $field): int { diff --git a/src/FastCheck/PresenceConditionalCompiler.php b/src/FastCheck/PresenceConditionalCompiler.php index 7154730..48077f2 100644 --- a/src/FastCheck/PresenceConditionalCompiler.php +++ b/src/FastCheck/PresenceConditionalCompiler.php @@ -1,10 +1,12 @@ - $fields - * @param array $item + * @param list $fields + * @param array $item */ private static function presenceConditionActive(string $type, array $fields, array $item): bool { @@ -104,11 +106,11 @@ private static function presenceConditionActive(string $type, array $fields, arr } return match ($type) { - 'required_with' => in_array(true, $present, true), - 'required_without' => in_array(false, $present, true), - 'required_with_all' => ! in_array(false, $present, true), + 'required_with' => in_array(true, $present, true), + 'required_without' => in_array(false, $present, true), + 'required_with_all' => ! in_array(false, $present, true), 'required_without_all' => ! in_array(true, $present, true), - default => false, + default => false, }; } } diff --git a/src/FastCheck/ProhibitedCompiler.php b/src/FastCheck/ProhibitedCompiler.php index 2a96580..ecdff96 100644 --- a/src/FastCheck/ProhibitedCompiler.php +++ b/src/FastCheck/ProhibitedCompiler.php @@ -1,4 +1,6 @@ - false, 'filled' => false, - 'nullable' => false, 'sometimes' => false, - 'string' => false, 'numeric' => false, 'integer' => false, 'integer.strict' => false, - 'boolean' => false, 'array' => false, 'email' => false, 'date' => false, - 'url' => false, 'ip' => false, 'uuid' => false, 'ulid' => false, - 'accepted' => false, 'declined' => false, - 'alpha' => false, 'alphaDash' => false, 'alphaNum' => false, - 'min' => null, 'max' => null, - 'digits' => null, 'digitsMin' => null, 'digitsMax' => null, - 'in' => null, 'notIn' => null, - 'regex' => null, 'notRegex' => null, - 'dateFormat' => null, - 'after' => null, 'before' => null, + 'required' => false, 'filled' => false, + 'nullable' => false, 'sometimes' => false, + 'string' => false, 'numeric' => false, 'integer' => false, 'integer.strict' => false, + 'boolean' => false, 'array' => false, 'email' => false, 'date' => false, + 'url' => false, 'ip' => false, 'uuid' => false, 'ulid' => false, + 'accepted' => false, 'declined' => false, + 'alpha' => false, 'alphaDash' => false, 'alphaNum' => false, + 'min' => null, 'max' => null, + 'digits' => null, 'digitsMin' => null, 'digitsMax' => null, + 'in' => null, 'notIn' => null, + 'regex' => null, 'notRegex' => null, + 'dateFormat' => null, + 'after' => null, 'before' => null, 'afterOrEqual' => null, 'beforeOrEqual' => null, - 'dateEquals' => null, + 'dateEquals' => null, // Item-context field-reference keys — populated by ItemContextCompiler. - 'afterField' => null, 'beforeField' => null, + 'afterField' => null, 'beforeField' => null, 'afterOrEqualField' => null, 'beforeOrEqualField' => null, - 'dateEqualsField' => null, - 'sameField' => null, 'differentField' => null, - 'gtField' => null, 'gteField' => null, - 'ltField' => null, 'lteField' => null, + 'dateEqualsField' => null, + 'sameField' => null, 'differentField' => null, + 'gtField' => null, 'gteField' => null, + 'ltField' => null, 'lteField' => null, ]; } @@ -63,7 +65,8 @@ public static function initialConfig(): array * rules (`same:FIELD`, `gt:FIELD`, etc.) are NOT handled here — see * `ItemContextCompiler::parsePartWithItemContext` for that path. * - * @param array $config + * @param array $config + * * @return array|null */ public static function parseValuePart(string $part, array $config): ?array @@ -83,34 +86,34 @@ public static function parseValuePart(string $part, array $config): ?array } return match (true) { - $part === 'integer' => [...$config, 'integer' => true], - $part === 'integer:strict' => [...$config, 'integer' => true, 'integer.strict' => true], - $part === 'alpha', $part === 'alpha:ascii' => [...$config, 'alpha' => true], + $part === 'integer' => [...$config, 'integer' => true], + $part === 'integer:strict' => [...$config, 'integer' => true, 'integer.strict' => true], + $part === 'alpha', $part === 'alpha:ascii' => [...$config, 'alpha' => true], $part === 'alpha_dash', $part === 'alpha_dash:ascii' => [...$config, 'alphaDash' => true], - $part === 'alpha_num', $part === 'alpha_num:ascii' => [...$config, 'alphaNum' => true], - $part === 'nullable' => [...$config, 'nullable' => true], + $part === 'alpha_num', $part === 'alpha_num:ascii' => [...$config, 'alphaNum' => true], + $part === 'nullable' => [...$config, 'nullable' => true], // 'sometimes' not fast-checkable: distinguishing absent from // present-null requires presence info the closure doesn't have. $part === 'sometimes' => null, - $part === 'bail' => $config, + $part === 'bail' => $config, // Not (int): NumericRule::min() accepts int|float, and Laravel // compares with BigNumber against the raw parameter. Casting // `min:2.5` to 2 lets 2.2 through. - str_starts_with($part, 'min:') => [...$config, 'min' => self::parseSize(substr($part, 4))], - str_starts_with($part, 'max:') => [...$config, 'max' => self::parseSize(substr($part, 4))], - str_starts_with($part, 'digits:') => [...$config, 'digits' => (int) substr($part, 7)], - str_starts_with($part, 'digits_between:') => self::parseDigitsBetween($config, substr($part, 15)), - str_starts_with($part, 'in:') => [...$config, 'in' => self::parseInValues(substr($part, 3))], - str_starts_with($part, 'not_in:') => [...$config, 'notIn' => self::parseInValues(substr($part, 7))], - str_starts_with($part, 'regex:') => [...$config, 'regex' => substr($part, 6)], - str_starts_with($part, 'not_regex:') => [...$config, 'notRegex' => substr($part, 10)], - str_starts_with($part, 'date_format:') => [...$config, 'dateFormat' => substr($part, 12)], - str_starts_with($part, 'date_equals:') => self::parseDateLiteral($config, 'dateEquals', substr($part, 12)), - str_starts_with($part, 'after_or_equal:') => self::parseDateLiteral($config, 'afterOrEqual', substr($part, 15)), + str_starts_with($part, 'min:') => [...$config, 'min' => self::parseSize(substr($part, 4))], + str_starts_with($part, 'max:') => [...$config, 'max' => self::parseSize(substr($part, 4))], + str_starts_with($part, 'digits:') => [...$config, 'digits' => (int) substr($part, 7)], + str_starts_with($part, 'digits_between:') => self::parseDigitsBetween($config, substr($part, 15)), + str_starts_with($part, 'in:') => [...$config, 'in' => self::parseInValues(substr($part, 3))], + str_starts_with($part, 'not_in:') => [...$config, 'notIn' => self::parseInValues(substr($part, 7))], + str_starts_with($part, 'regex:') => [...$config, 'regex' => substr($part, 6)], + str_starts_with($part, 'not_regex:') => [...$config, 'notRegex' => substr($part, 10)], + str_starts_with($part, 'date_format:') => [...$config, 'dateFormat' => substr($part, 12)], + str_starts_with($part, 'date_equals:') => self::parseDateLiteral($config, 'dateEquals', substr($part, 12)), + str_starts_with($part, 'after_or_equal:') => self::parseDateLiteral($config, 'afterOrEqual', substr($part, 15)), str_starts_with($part, 'before_or_equal:') => self::parseDateLiteral($config, 'beforeOrEqual', substr($part, 16)), - str_starts_with($part, 'after:') => self::parseDateLiteral($config, 'after', substr($part, 6)), - str_starts_with($part, 'before:') => self::parseDateLiteral($config, 'before', substr($part, 7)), - default => null, + str_starts_with($part, 'after:') => self::parseDateLiteral($config, 'after', substr($part, 6)), + str_starts_with($part, 'before:') => self::parseDateLiteral($config, 'before', substr($part, 7)), + default => null, }; } @@ -119,7 +122,7 @@ public static function parseValuePart(string $part, array $config): ?array * measure: string length, array count, or numeric value. Without one, * Laravel infers from runtime type — not fast-checkable. * - * @param array $config + * @param array $config */ public static function validateSizeRuleHasType(array $config): bool { @@ -144,7 +147,7 @@ public static function validateSizeRuleHasType(array $config): bool * loose subset semantics (array_diff), which the scalar closure * cannot reproduce. * - * @param array $config + * @param array $config */ public static function validateCompilableCombinations(array $config): bool { @@ -167,7 +170,8 @@ public static function validateCompilableCombinations(array $config): bool * Build the value-only closure from a parsed config. ItemContextCompiler * wraps the result in an item-aware closure for cross-field comparisons. * - * @param array $c + * @param array $c + * * @return Closure(mixed): bool */ public static function buildValueClosure(array $c): Closure @@ -200,10 +204,21 @@ public static function buildValueClosure(array $c): Closure $hasInRegex = $in !== null || $notIn !== null || $regex !== null || $notRegex !== null; return static function (mixed $value) use ( - $required, $nullable, $hasImplicit, - $isString, $isNumeric, $isInteger, $isArray, - $min, $max, $hasSize, - $in, $notIn, $regex, $notRegex, $hasInRegex, + $required, + $nullable, + $hasImplicit, + $isString, + $isNumeric, + $isInteger, + $isArray, + $min, + $max, + $hasSize, + $in, + $notIn, + $regex, + $notRegex, + $hasInRegex, $checks ): bool { // Presence gate: Laravel's validateRequired() also rejects @@ -300,7 +315,8 @@ public static function buildValueClosure(array $c): Closure } /** - * @param array $config + * @param array $config + * * @return array */ private static function parseDigitsBetween(array $config, string $value): array @@ -365,7 +381,7 @@ private static function parseSize(string $value): int|float * through a strict-comparison-friendly operator. Used to detect the * loose/strict DISAGREEMENT zone the fast path must not decide. * - * @param list $haystack + * @param list $haystack */ private static function matchesLoosely(string $needle, array $haystack): bool { @@ -394,7 +410,8 @@ private static function parseInValues(string $values): array * Parse a date comparison rule. Only compiles when the parameter is a * date literal (resolvable by strtotime), not a field reference. * - * @param array $config + * @param array $config + * * @return array|null */ private static function parseDateLiteral(array $config, string $key, string $param): ?array @@ -415,7 +432,7 @@ private static function parseDateLiteral(array $config, string $key, string $par * Mutates `$checks` in place; closures capture the type literals they * compare against. * - * @param array $c + * @param array $c * @param list $checks */ private static function addTypeChecks(array $c, array &$checks): void @@ -454,7 +471,7 @@ private static function addTypeChecks(array $c, array &$checks): void /** * Append format-check closures (email, url, ip, uuid, ulid, alpha-family). * - * @param array $c + * @param array $c * @param list $checks */ private static function addFormatChecks(array $c, array &$checks): void @@ -500,7 +517,7 @@ private static function addFormatChecks(array $c, array &$checks): void /** * Append date-check closure when any date-literal rule is set. * - * @param array $c + * @param array $c * @param list $checks */ private static function addDateChecks(array $c, array &$checks): void @@ -577,7 +594,7 @@ private static function timestampWithinBounds( /** * Append digit-check closure(s) when `digits:` or `digits_between:` is set. * - * @param array $c + * @param array $c * @param list $checks */ private static function addDigitChecks(array $c, array &$checks): void diff --git a/src/FastCheck/Shared/ItemAwareBranchBuilder.php b/src/FastCheck/Shared/ItemAwareBranchBuilder.php index 51d9763..b92e491 100644 --- a/src/FastCheck/Shared/ItemAwareBranchBuilder.php +++ b/src/FastCheck/Shared/ItemAwareBranchBuilder.php @@ -1,4 +1,6 @@ -label($label); } @@ -48,7 +50,7 @@ public static function string(?string $label = null, ?string $message = null): S public static function numeric(?string $label = null, ?string $message = null): NumericRule { - $numericRule = new NumericRule(); + $numericRule = new NumericRule; if ($label !== null) { $numericRule->label($label); } @@ -90,7 +92,7 @@ public static function dateTime(?string $label = null, ?string $message = null): public static function boolean(?string $label = null, ?string $message = null): BooleanRule { - $booleanRule = new BooleanRule(); + $booleanRule = new BooleanRule; if ($label !== null) { $booleanRule->label($label); } @@ -104,7 +106,7 @@ public static function boolean(?string $label = null, ?string $message = null): public static function accepted(?string $label = null, ?string $message = null): AcceptedRule { - $acceptedRule = new AcceptedRule(); + $acceptedRule = new AcceptedRule; if ($label !== null) { $acceptedRule->label($label); } @@ -118,7 +120,7 @@ public static function accepted(?string $label = null, ?string $message = null): public static function declined(?string $label = null, ?string $message = null): DeclinedRule { - $declinedRule = new DeclinedRule(); + $declinedRule = new DeclinedRule; if ($label !== null) { $declinedRule->label($label); } @@ -147,7 +149,7 @@ public static function array(Arrayable|array|null $keys = null, ?string $label = public static function file(?string $label = null, ?string $message = null): FileRule { - $fileRule = new FileRule(); + $fileRule = new FileRule; if ($label !== null) { $fileRule->label($label); } @@ -182,7 +184,7 @@ public static function email(?string $label = null, bool $defaults = true, ?stri */ public static function phone(?string $label = null, ?string $message = null): PhoneRule { - $phoneRule = new PhoneRule(); + $phoneRule = new PhoneRule; if ($label !== null) { $phoneRule->label($label); } @@ -196,7 +198,7 @@ public static function phone(?string $label = null, ?string $message = null): Ph public static function image(?string $label = null, ?string $message = null): ImageRule { - $imageRule = new ImageRule(); + $imageRule = new ImageRule; if ($label !== null) { $imageRule->label($label); } @@ -236,7 +238,7 @@ public static function password(?int $min = null, ?string $label = null, bool $d */ public static function url(?string $label = null, ?string $message = null): UrlRule { - $urlRule = new UrlRule(); + $urlRule = new UrlRule; if ($label !== null) { $urlRule->label($label); } @@ -269,7 +271,7 @@ public static function ulid(?string $label = null, ?string $message = null): Str */ public static function ip(?string $label = null, ?string $message = null): IpAddressRule { - $ipRule = new IpAddressRule(); + $ipRule = new IpAddressRule; if ($label !== null) { $ipRule->label($label); } @@ -299,7 +301,7 @@ public static function ipv6(?string $label = null, ?string $message = null): IpA */ public static function macAddress(?string $label = null, ?string $message = null): MacAddressRule { - $macRule = new MacAddressRule(); + $macRule = new MacAddressRule; if ($label !== null) { $macRule->label($label); } @@ -340,7 +342,7 @@ public static function username(int $min = 3, int $max = 32, ?string $label = nu */ public static function subdomain(?string $label = null, ?string $message = null): StringRule { - return self::string($label)->rule(new Subdomain(), $message); + return self::string($label)->rule(new Subdomain, $message); } /** A fully-qualified domain name, internationalised names included. */ @@ -387,13 +389,13 @@ public static function enum(string $type, ?Closure $callback = null, ?string $la public static function field(?string $label = null): FieldRule { - $fieldRule = new FieldRule(); + $fieldRule = new FieldRule; return $label !== null ? $fieldRule->label($label) : $fieldRule; } /** - * @param array $rules + * @param array $rules */ public static function anyOf(array $rules): AnyOf { diff --git a/src/FluentRules.php b/src/FluentRules.php index a6f3d41..c5cd6ac 100644 --- a/src/FluentRules.php +++ b/src/FluentRules.php @@ -1,4 +1,6 @@ - $arguments + */ + public function __call(string $name, array $arguments): mixed + { + // Only reached for names not declared above — i.e. macros, which + // Macroable resolves through FluentRule's __callStatic. + return FluentRule::__callStatic($name, $arguments); + } + public function string(?string $label = null, ?string $message = null): StringRule { return FluentRule::string($label, $message); @@ -208,23 +223,10 @@ public function field(?string $label = null): FieldRule } /** - * @param array $rules + * @param array $rules */ public function anyOf(array $rules): AnyOf { return FluentRule::anyOf($rules); } - - /** - * Forward anything not declared above to {@see FluentRule}, so macros - * registered on the static factory are also reachable on the instance. - * - * @param array $arguments - */ - public function __call(string $name, array $arguments): mixed - { - // Only reached for names not declared above — i.e. macros, which - // Macroable resolves through FluentRule's __callStatic. - return FluentRule::__callStatic($name, $arguments); - } } diff --git a/src/FluentValidator.php b/src/FluentValidator.php index 3bc6958..b114e2a 100644 --- a/src/FluentValidator.php +++ b/src/FluentValidator.php @@ -1,10 +1,12 @@ - $data - * @param array $rules - * @param array $messages - * @param array $attributes + * @param array $data + * @param array $rules + * @param array $messages + * @param array $attributes */ public function __construct( array $data, diff --git a/src/HasFluentRules.php b/src/HasFluentRules.php index 2f08ce8..110bc14 100644 --- a/src/HasFluentRules.php +++ b/src/HasFluentRules.php @@ -1,15 +1,17 @@ -schemaExpectsFluentSchema(); + $hasRules = $this->definesOwnRules(); + + /** @var array|RuleSet $rules */ + $rules = match (true) { + $hasSchema && $hasRules => $this->mergeSchemaAndRules( + $this->container->call([$this, 'schema']), + $this->container->call([$this, 'rules']), + ), + $hasSchema => $this->container->call([$this, 'schema']), + $hasRules => $this->container->call([$this, 'rules']), + default => [], + }; + + /** @var array $data */ + $data = $this->validationData(); + + // Auto-unwrap: rules() may return either a plain array or a RuleSet + // (the latter pattern lets callers chain ->only/->except/->merge + // before returning, eliminating a terminal ->toArray() call). + $ruleSet = $rules instanceof RuleSet ? $rules : RuleSet::from($rules); + $prepared = $ruleSet->prepare($data); + + // Pre-exclude rules whose exclude_unless/exclude_if conditions + // don't match the actual data. This happens BEFORE the validator + // constructor, so excluded rules are never parsed. + $preparedRules = $this->preExcludeRules($prepared->rules, $data); + + [$fastChecks, $attributePatternMap] = $this->buildFastCheckMaps($prepared, $preparedRules); + + $messages = $this->messages() + $prepared->messages; + $attributes = $this->attributes() + $prepared->attributes; + + // Only use OptimizedValidator when there are fast-checkable wildcard + // rules or conditional rules were pre-excluded. + if ($fastChecks !== [] || count($preparedRules) < count($prepared->rules)) { + $validator = $this->makeOptimizedValidator($factory, $data, $preparedRules, $messages, $attributes); + $validator->withFastChecks($fastChecks, $attributePatternMap); + } else { + /** @var Validator $validator */ + $validator = $factory->make($data, $preparedRules, $messages, $attributes); + } + + if ($prepared->implicitAttributes !== []) { + new ReflectionProperty(Validator::class, 'implicitAttributes') + ->setValue($validator, $prepared->implicitAttributes); + } + + $validator->stopOnFirstFailure($this->stopOnFirstFailure); + + $this->applyBatchPresenceVerifier($validator, $prepared, $preparedRules, $data); + + if ($this->isPrecognitive()) { + $validator->setRules( + $this->filterPrecognitiveRules($validator->getRulesWithoutPlaceholders()), + ); + } + + return $validator; + } + /** * Whether the request's `schema()` method is the FluentSchema builder hook * (its first parameter is typed FluentSchema) rather than a coincidental, @@ -171,89 +247,15 @@ private function definesOwnRules(): bool return new ReflectionMethod($this, 'rules')->getFileName() !== __FILE__; } - protected function createDefaultValidator(ValidationFactory $factory): Validator - { - // schema(FluentSchema $rules) — the builder shape — and rules() are both - // rule sources. schema() detection keys off the FluentSchema-typed first - // parameter (not just the method name), so an unrelated schema() method - // (e.g. one returning a JSON/DB schema) is never hijacked. rules() counts - // only when the consumer declared their own (definesOwnRules()); the - // trait's fallback rules() just re-exposes schema() and must not trigger - // a merge. When both are present they are merged rather than one shadowing - // the other — the more specific declaration wins each shared field (see - // mergeSchemaAndRules()), so an abstract base or trait can supply one - // method and a concrete request the other. Each source is resolved by the - // container. - $hasSchema = method_exists($this, 'schema') && $this->schemaExpectsFluentSchema(); - $hasRules = $this->definesOwnRules(); - - /** @var array|RuleSet $rules */ - $rules = match (true) { - $hasSchema && $hasRules => $this->mergeSchemaAndRules( - $this->container->call([$this, 'schema']), - $this->container->call([$this, 'rules']), - ), - $hasSchema => $this->container->call([$this, 'schema']), - $hasRules => $this->container->call([$this, 'rules']), - default => [], - }; - - /** @var array $data */ - $data = $this->validationData(); - - // Auto-unwrap: rules() may return either a plain array or a RuleSet - // (the latter pattern lets callers chain ->only/->except/->merge - // before returning, eliminating a terminal ->toArray() call). - $ruleSet = $rules instanceof RuleSet ? $rules : RuleSet::from($rules); - $prepared = $ruleSet->prepare($data); - - // Pre-exclude rules whose exclude_unless/exclude_if conditions - // don't match the actual data. This happens BEFORE the validator - // constructor, so excluded rules are never parsed. - $preparedRules = $this->preExcludeRules($prepared->rules, $data); - - [$fastChecks, $attributePatternMap] = $this->buildFastCheckMaps($prepared, $preparedRules); - - $messages = $this->messages() + $prepared->messages; - $attributes = $this->attributes() + $prepared->attributes; - - // Only use OptimizedValidator when there are fast-checkable wildcard - // rules or conditional rules were pre-excluded. - if ($fastChecks !== [] || count($preparedRules) < count($prepared->rules)) { - $validator = $this->makeOptimizedValidator($factory, $data, $preparedRules, $messages, $attributes); - $validator->withFastChecks($fastChecks, $attributePatternMap); - } else { - /** @var Validator $validator */ - $validator = $factory->make($data, $preparedRules, $messages, $attributes); - } - - if ($prepared->implicitAttributes !== []) { - new ReflectionProperty(Validator::class, 'implicitAttributes') - ->setValue($validator, $prepared->implicitAttributes); - } - - $validator->stopOnFirstFailure($this->stopOnFirstFailure); - - $this->applyBatchPresenceVerifier($validator, $prepared, $preparedRules, $data); - - if ($this->isPrecognitive()) { - $validator->setRules( - $this->filterPrecognitiveRules($validator->getRulesWithoutPlaceholders()) - ); - } - - return $validator; - } - /** * Create an OptimizedValidator with the same setup the factory provides * (extensions, container, presence verifier, excludeUnvalidatedArrayKeys) * without mutating the shared factory's resolver. Octane-safe. * - * @param array $data - * @param array $rules - * @param array $messages - * @param array $attributes + * @param array $data + * @param array $rules + * @param array $messages + * @param array $attributes */ private function makeOptimizedValidator( ValidationFactory $factory, diff --git a/src/HasFluentValidation.php b/src/HasFluentValidation.php index 361a47e..9bf9cf5 100644 --- a/src/HasFluentValidation.php +++ b/src/HasFluentValidation.php @@ -1,4 +1,6 @@ -|null $rules + * @param array $messages + * @param array $attributes + * + * @return array{0: array|null, 1: array, 2: array} + */ + protected function compileFluentRules(?array $rules, array $messages, array $attributes): array + { + // If no rules passed (null), resolve from rules() method or $rules + // property. An explicitly empty array `[]` means "caller wants no + // validation" — it must NOT fall back to the component's default + // rules, matching Livewire's $this->validate([]) behavior. + $ruleSet = $rules !== null ? RuleSet::from($rules) : $this->resolveFluentRuleSource(); + + if ($ruleSet->isEmpty()) { + return [$rules, $messages, $attributes]; + } + + $hasFluentRules = $ruleSet->hasObjectRules(); + + if (! $hasFluentRules) { + return [$rules, $messages, $attributes]; + } + + $flatRules = $ruleSet->flattenRules(); + + // Use Livewire's data resolution when available — it correctly + // handles model-bound properties and nested data for wildcard expansion. + // For array source, preserve the exact shape callers passed previously. + $rawData = method_exists($this, 'getDataForValidation') // @phpstan-ignore function.alreadyNarrowedType + ? $this->getDataForValidation($flatRules) + : (method_exists($this, 'all') ? $this->all() : []); // @phpstan-ignore function.alreadyNarrowedType + + // Unwrap Eloquent models to arrays so WildcardExpander can traverse them. + if (method_exists($this, 'unwrapDataForValidation')) { // @phpstan-ignore function.alreadyNarrowedType + $rawData = $this->unwrapDataForValidation($rawData); + } + + $data = $this->toNullableArray($rawData) ?? []; + + $prepared = $ruleSet->prepare($data, $flatRules); + + return [ + $prepared->rules, + array_merge($prepared->messages, $messages), + array_merge($prepared->attributes, $attributes), + ]; + } + /** * Merge with Livewire's rulesFromOutside (same as parent::getRules()). * - * @param array $rules + * @param array $rules + * * @return array */ private function mergeRulesFromOutside(array $rules): array @@ -246,56 +302,4 @@ private function toStringMap(mixed $value): array return $result; } - - /** - * Compile FluentRule objects to native format, expand wildcards - * against actual data, and extract labels/messages. - * - * @param array|null $rules - * @param array $messages - * @param array $attributes - * @return array{0: array|null, 1: array, 2: array} - */ - protected function compileFluentRules(?array $rules, array $messages, array $attributes): array - { - // If no rules passed (null), resolve from rules() method or $rules - // property. An explicitly empty array `[]` means "caller wants no - // validation" — it must NOT fall back to the component's default - // rules, matching Livewire's $this->validate([]) behavior. - $ruleSet = $rules !== null ? RuleSet::from($rules) : $this->resolveFluentRuleSource(); - - if ($ruleSet->isEmpty()) { - return [$rules, $messages, $attributes]; - } - - $hasFluentRules = $ruleSet->hasObjectRules(); - - if (! $hasFluentRules) { - return [$rules, $messages, $attributes]; - } - - $flatRules = $ruleSet->flattenRules(); - - // Use Livewire's data resolution when available — it correctly - // handles model-bound properties and nested data for wildcard expansion. - // For array source, preserve the exact shape callers passed previously. - $rawData = method_exists($this, 'getDataForValidation') // @phpstan-ignore function.alreadyNarrowedType - ? $this->getDataForValidation($flatRules) - : (method_exists($this, 'all') ? $this->all() : []); // @phpstan-ignore function.alreadyNarrowedType - - // Unwrap Eloquent models to arrays so WildcardExpander can traverse them. - if (method_exists($this, 'unwrapDataForValidation')) { // @phpstan-ignore function.alreadyNarrowedType - $rawData = $this->unwrapDataForValidation($rawData); - } - - $data = $this->toNullableArray($rawData) ?? []; - - $prepared = $ruleSet->prepare($data, $flatRules); - - return [ - $prepared->rules, - array_merge($prepared->messages, $messages), - array_merge($prepared->attributes, $attributes), - ]; - } } diff --git a/src/HasFluentValidationForFilament.php b/src/HasFluentValidationForFilament.php index 44a3eca..e6770d0 100644 --- a/src/HasFluentValidationForFilament.php +++ b/src/HasFluentValidationForFilament.php @@ -1,4 +1,6 @@ - $itemRules + * @param array $itemRules */ public static function hasAny(array $itemRules): bool { @@ -64,7 +57,7 @@ public static function hasAny(array $itemRules): bool * Subclasses' `apply()` methods bind their per-call context (field, * itemData, etc.) into a closure and hand it here. * - * @param Closure(string): ?string $rewriteOne + * @param Closure(string): ?string $rewriteOne */ protected static function applyTemplate(mixed $rule, Closure $rewriteOne): mixed { @@ -112,7 +105,7 @@ protected static function applyTemplate(mixed $rule, Closure $rewriteOne): mixed * rewrite path doesn't bypass a `{field}.{rule}`-style override at * message-formatting time. Identical across all conditional reducers. * - * @param array $itemMessages + * @param array $itemMessages */ protected static function hasCustomMessage(string $field, string $ruleName, array $itemMessages): bool { @@ -169,4 +162,13 @@ protected static function stringContainsRule(string $rule): bool return static::parse($rule) !== null; } + + /** + * Parse a single rule string into `[ruleName, rawParam]` when it matches + * one of the family's recognised conditionals. Subclasses define the + * recognised set; longest-prefix order is each subclass's responsibility. + * + * @return array{0: string, 1: string}|null + */ + abstract protected static function parse(string $rule): ?array; } diff --git a/src/Internal/BatchLimitRemap.php b/src/Internal/BatchLimitRemap.php index ee0a207..653633a 100644 --- a/src/Internal/BatchLimitRemap.php +++ b/src/Internal/BatchLimitRemap.php @@ -1,11 +1,13 @@ - $values - * @param array $wheres + * @param array $values + * @param array $wheres + * * @return array */ public static function run( @@ -34,7 +37,8 @@ public static function run( $chunks = []; foreach (array_chunk($values, self::CHUNK_SIZE) as $chunk) { - $query = ($connection !== null + $query = ( + $connection !== null ? DB::connection($connection)->table($table) : DB::table($table) )->useWritePdo(); @@ -59,7 +63,7 @@ public static function run( /** * Apply a single Laravel-style where condition to the query. * - * @param array{column: string, value: string} $where + * @param array{column: string, value: string} $where */ private static function applyWhere(Builder $query, array $where): void { diff --git a/src/Internal/BatchRuleShape.php b/src/Internal/BatchRuleShape.php index 85adf93..feb86aa 100644 --- a/src/Internal/BatchRuleShape.php +++ b/src/Internal/BatchRuleShape.php @@ -1,4 +1,6 @@ -, ignore: mixed, idColumn: string}|null $meta + * @param array{connection: string|null, table: string, column: string, wheres: array, ignore: mixed, idColumn: string}|null $meta */ public static function of(?array $meta): string { diff --git a/src/Internal/ConditionalEvaluationPhase.php b/src/Internal/ConditionalEvaluationPhase.php index a0002e5..088eccc 100644 --- a/src/Internal/ConditionalEvaluationPhase.php +++ b/src/Internal/ConditionalEvaluationPhase.php @@ -1,4 +1,6 @@ - */ private array $valueCache = []; + /** + * Replace wildcards in a condition field reference with the concrete key + * at the SAME dot-position in the attribute path — mirroring how Laravel + * resolves a dependent wildcard reference against the attribute under + * validation. E.g. "interactions.*.type" against "interactions.5.style.top" + * → "interactions.5.type", and "items.*.type" against "items.foo.rows.0.x" + * → "items.foo.type" (associative keys handled, not just numeric indices). + * + * A `*` whose position has no corresponding attribute segment is left in + * place, so callers can detect the unresolved wildcard and defer. + * + * Pure — exposed `static` so tests can pin it without instantiating. + */ + public static function resolveWildcard(string $attribute, string $conditionField): string + { + if (! str_contains($conditionField, '*')) { + return $conditionField; + } + + $attributeSegments = explode('.', $attribute); + $fieldSegments = explode('.', $conditionField); + + foreach ($fieldSegments as $i => $segment) { + if ($segment === '*') { + $fieldSegments[$i] = $attributeSegments[$i] ?? '*'; + } + } + + return implode('.', $fieldSegments); + } + /** * Build a flat map of attributes that carry exclude_unless / exclude_if * tuples, paired with the parsed tuple data. Pure — depends only on the * rule shape. Accepts the Validator's untyped `$rules` array directly; * non-string keys are filtered inside. * - * @param array $rules + * @param array $rules + * * @return array}>> */ public function indexConditionalAttrs(array $rules): array @@ -64,9 +98,9 @@ public function indexConditionalAttrs(array $rules): array * because `Validator::getValue()` is protected — the closure must be * constructed inside the validator subclass to capture scope. * - * @param list}> $tuples - * @param Closure(string): mixed $getValue - * @param array $rules The validator's parsed rules, read + * @param list}> $tuples + * @param Closure(string): mixed $getValue + * @param array $rules The validator's parsed rules, read * only to learn whether a dependent * is declared `boolean`. Defaults to * none, which simply skips the @@ -133,35 +167,4 @@ public function evaluate(string $attribute, array $tuples, Closure $getValue, ar return $deferred ? ConditionalVerdict::Defer : ConditionalVerdict::NotExcluded; } - - /** - * Replace wildcards in a condition field reference with the concrete key - * at the SAME dot-position in the attribute path — mirroring how Laravel - * resolves a dependent wildcard reference against the attribute under - * validation. E.g. "interactions.*.type" against "interactions.5.style.top" - * → "interactions.5.type", and "items.*.type" against "items.foo.rows.0.x" - * → "items.foo.type" (associative keys handled, not just numeric indices). - * - * A `*` whose position has no corresponding attribute segment is left in - * place, so callers can detect the unresolved wildcard and defer. - * - * Pure — exposed `static` so tests can pin it without instantiating. - */ - public static function resolveWildcard(string $attribute, string $conditionField): string - { - if (! str_contains($conditionField, '*')) { - return $conditionField; - } - - $attributeSegments = explode('.', $attribute); - $fieldSegments = explode('.', $conditionField); - - foreach ($fieldSegments as $i => $segment) { - if ($segment === '*') { - $fieldSegments[$i] = $attributeSegments[$i] ?? '*'; - } - } - - return implode('.', $fieldSegments); - } } diff --git a/src/Internal/ConditionalValueMatcher.php b/src/Internal/ConditionalValueMatcher.php index abbd83f..45e179b 100644 --- a/src/Internal/ConditionalValueMatcher.php +++ b/src/Internal/ConditionalValueMatcher.php @@ -1,4 +1,6 @@ - $rawValues The rule's value parameters. - * @param array $itemData - * @param array $itemRules Item-scoped rules, for the `boolean` declaration check. + * @param list $rawValues The rule's value parameters. + * @param array $itemData + * @param array $itemRules Item-scoped rules, for the `boolean` declaration check. */ public static function matches(string $depPath, array $rawValues, array $itemData, array $itemRules): bool { @@ -42,7 +44,7 @@ public static function matches(string $depPath, array $rawValues, array $itemDat * through a validator's protected `getValue()`), so they don't re-resolve * via `data_get`. * - * @param list $rawValues + * @param list $rawValues */ public static function matchesValue(mixed $other, array $rawValues, bool $dependentHasBooleanRule = false): bool { @@ -60,6 +62,35 @@ public static function matchesValue(mixed $other, array $rawValues, bool $depend : self::scalarLooseIn($other, $values); } + /** + * Does the dependent field's rule set contain a `boolean` marker? + * + * Public because both the item-scoped path ({@see matches()}) and the + * top-level path ({@see ConditionalEvaluationPhase::evaluate()}) need the + * same answer, and Laravel applies the conversion in both. + * Best-effort check against the item-scoped rule set — mirrors Laravel's + * `shouldConvertToBoolean` which reads `$this->rules[$parameter]`. + * + * `array-key`, not `string`: the top-level caller hands over the + * validator's own `$rules`, which Laravel types as a bare array. + * + * @param array $itemRules + */ + public static function dependentHasBooleanRule(string $depPath, array $itemRules): bool + { + $rules = $itemRules[$depPath] ?? null; + + if (is_string($rules)) { + return in_array('boolean', explode('|', $rules), true); + } + + if (! is_array($rules)) { + return false; + } + + return in_array('boolean', $rules, true); + } + /** * Stand-in for PHP's loose `in_array($other, $values, false)` with `$other` * a non-bool/non-null scalar — phpstan-strict-rules forbids loose `in_array`, @@ -72,7 +103,7 @@ public static function matchesValue(mixed $other, array $rawValues, bool $depend * - any other scalar compares by string coercion, covering numeric-string ↔ * numeric (`'1'` ↔ `1`). * - * @param list $values + * @param list $values */ private static function scalarLooseIn(int|float|string $other, array $values): bool { @@ -97,14 +128,15 @@ private static function scalarLooseIn(int|float|string $other, array $values): b * value is already a bool; null conversion when the resolved value is * null. Order matters — bool first, then null. * - * @param list $values + * @param list $values + * * @return list */ private static function convertValues(array $values, mixed $other, bool $dependentHasBooleanRule): array { if (is_bool($other) || $dependentHasBooleanRule) { $values = array_map(static fn (mixed $v): mixed => match ($v) { - 'true' => true, + 'true' => true, 'false' => false, default => $v, }, $values); @@ -119,33 +151,4 @@ private static function convertValues(array $values, mixed $other, bool $depende return $values; } - - /** - * Does the dependent field's rule set contain a `boolean` marker? - * - * Public because both the item-scoped path ({@see matches()}) and the - * top-level path ({@see ConditionalEvaluationPhase::evaluate()}) need the - * same answer, and Laravel applies the conversion in both. - * Best-effort check against the item-scoped rule set — mirrors Laravel's - * `shouldConvertToBoolean` which reads `$this->rules[$parameter]`. - * - * `array-key`, not `string`: the top-level caller hands over the - * validator's own `$rules`, which Laravel types as a bare array. - * - * @param array $itemRules - */ - public static function dependentHasBooleanRule(string $depPath, array $itemRules): bool - { - $rules = $itemRules[$depPath] ?? null; - - if (is_string($rules)) { - return in_array('boolean', explode('|', $rules), true); - } - - if (! is_array($rules)) { - return false; - } - - return in_array('boolean', $rules, true); - } } diff --git a/src/Internal/ConditionalVerdict.php b/src/Internal/ConditionalVerdict.php index 5903dfb..ec96e8d 100644 --- a/src/Internal/ConditionalVerdict.php +++ b/src/Internal/ConditionalVerdict.php @@ -1,4 +1,6 @@ - $rules Field (or expanded attribute) → rules + * @param array $rules Field (or expanded attribute) → rules + * * @return array */ public static function findPoisonedTableColumns(array $rules): array diff --git a/src/Internal/ExcludeConditionExtractor.php b/src/Internal/ExcludeConditionExtractor.php index fd7b019..6f1b3d9 100644 --- a/src/Internal/ExcludeConditionExtractor.php +++ b/src/Internal/ExcludeConditionExtractor.php @@ -1,4 +1,6 @@ - $segment + * @param array $segment + * * @return array{action: string, field: string, values: list}|null */ private static function parseTuple(array $segment): ?array @@ -113,7 +116,7 @@ private static function parseTuple(array $segment): ?array return [ 'action' => $action, - 'field' => $field, + 'field' => $field, 'values' => array_map( static fn (mixed $v): string => is_scalar($v) ? (string) $v : '', array_values(array_slice($segment, 2)), @@ -144,7 +147,7 @@ private static function parseString(string $segment): ?array return [ 'action' => $action, - 'field' => $params[0], + 'field' => $params[0], 'values' => array_map(static fn (?string $v): string => (string) $v, array_slice($params, 1)), ]; } diff --git a/src/Internal/ItemErrorCollector.php b/src/Internal/ItemErrorCollector.php index f920621..c7546e3 100644 --- a/src/Internal/ItemErrorCollector.php +++ b/src/Internal/ItemErrorCollector.php @@ -1,4 +1,6 @@ -): bool> $fastChecks - * @param array $itemData + * @param array $itemData */ public function passesAllFastChecks(iterable $fastChecks, array $itemData): bool { @@ -33,7 +35,7 @@ public function passesAllFastChecks(iterable $fastChecks, array $itemData): bool * Append errors from a failed per-item validator into the accumulator, * keyed by full dotted path (`{parent}.{index}.{field}` or `{parent}.{index}` for scalar each). * - * @param array> $errors + * @param array> $errors */ public function collectErrors(Validator $validator, string $parent, int|string $index, bool $isScalar, array &$errors): void { diff --git a/src/Internal/ItemRuleCompiler.php b/src/Internal/ItemRuleCompiler.php index d4d0f92..0beeb2a 100644 --- a/src/Internal/ItemRuleCompiler.php +++ b/src/Internal/ItemRuleCompiler.php @@ -1,15 +1,17 @@ - $itemRules + * @param array $itemRules + * * @return array}>> */ public function analyzeConditionals(array $itemRules): array @@ -52,10 +55,11 @@ public function analyzeConditionals(array $itemRules): array /** * Reduce item rules by evaluating conditional exclusions against the item data. * - * @param array $itemRules - * @param array $itemData - * @param array}>> $conditionalFields - * @param array $itemMessages + * @param array $itemRules + * @param array $itemData + * @param array}>> $conditionalFields + * @param array $itemMessages + * * @return array */ public function reduceRulesForItem(array $itemRules, array $itemData, array $conditionalFields, array $itemMessages = []): array @@ -78,91 +82,12 @@ public function reduceRulesForItem(array $itemRules, array $itemData, array $con return $itemRules; } - /** - * Is the field excluded by ANY of its exclude_* conditions? Mirrors native - * Laravel, which evaluates every exclude rule on a field. - * - * @param list}> $conditions - * @param array $itemData - * @param array $itemRules - */ - private function fieldIsExcluded(array $conditions, array $itemData, array $itemRules): bool - { - foreach ($conditions as $condition) { - // exclude_if is inactive when the dependent field is absent (mirrors - // Laravel's Arr::has short-circuit); short-circuiting also skips the - // matcher's data_get there. Otherwise match via the shared matcher, - // which reproduces Laravel's null/bool/loose-scalar coercion. - $present = $condition['action'] !== 'exclude_if' || Arr::has($itemData, $condition['field']); - $match = $present - && ConditionalValueMatcher::matches($condition['field'], $condition['values'], $itemData, $itemRules); - - if ($condition['action'] === 'exclude_unless' ? ! $match : $match) { - return true; - } - } - - return false; - } - - /** - * Strip exclude_unless/exclude_if tuples from a rule array, leaving the - * actual validation rules. Joins remaining strings into a pipe-delimited - * string when possible. - * - * Only the exclude tuples are removed — required_if/required_unless tuples - * must survive so the field's presence conditional still validates (the - * value-conditional reducer rewrites string forms; native Laravel handles - * the surviving tuple form). - */ - private function stripConditionalTuples(mixed $rules): mixed - { - if (! is_array($rules)) { - return $rules; - } - - $stripped = []; - - foreach ($rules as $rule) { - if (is_array($rule) && isset($rule[0]) && is_string($rule[0]) - && in_array($rule[0], ['exclude_unless', 'exclude_if'], true)) { - continue; - } - - // Stringify Stringable objects (Rule::in, Rule::notIn) so the - // result can be fast-checked as a pipe-joined string. - $stripped[] = $rule instanceof Stringable ? (string) $rule : $rule; - } - - // If all remaining rules are strings, join them for faster parsing — - // unless a token contains a literal `|` (e.g. `regex:/^(a|b)$/`), which - // Laravel's parser would split. Keep the array form in that case. - $allStrings = true; - $anyContainsPipe = false; - foreach ($stripped as $rule) { - if (! is_string($rule)) { - $allStrings = false; - break; - } - - if (str_contains($rule, '|')) { - $anyContainsPipe = true; - } - } - - if ($allStrings && ! $anyContainsPipe && $stripped !== []) { - return implode('|', array_map(static fn (mixed $v): string => is_scalar($v) ? (string) $v : '', $stripped)); - } - - return $stripped; - } - /** * Find a common dispatch field if EVERY exclude condition references the * same field. Returns the field name (e.g., "type") or null if conditions * reference different fields or there are no conditionals. * - * @param array}>> $conditionalFields + * @param array}>> $conditionalFields */ public function findCommonDispatchField(array $conditionalFields): ?string { @@ -189,7 +114,7 @@ public function findCommonDispatchField(array $conditionalFields): ?string * Delegate to `RuleCacheKey::for` — see that class for the rationale on * why field names alone are insufficient after per-item reducers engage. * - * @param array $rules + * @param array $rules */ public function ruleCacheKey(array $rules): string { @@ -200,7 +125,8 @@ public function ruleCacheKey(array $rules): string * Build fast-check closures for eligible fields. * Returns fast checks for compilable fields and the remaining slow rules. * - * @param array $compiledRules + * @param array $compiledRules + * * @return array{0: list): bool>, 1: array} */ public function buildFastChecks(array $compiledRules): array @@ -310,8 +236,8 @@ public function buildFastChecks(array $compiledRules): array * Build a PrecomputedPresenceVerifier by batching all exists/unique values * from slow rules across all items in a single whereIn query. * - * @param array $slowRules - * @param array $items + * @param array $slowRules + * @param array $items */ public function buildBatchVerifier(array $slowRules, array $items, bool $isScalar): ?PrecomputedPresenceVerifier { @@ -332,4 +258,83 @@ public function buildBatchVerifier(array $slowRules, array $items, bool $isScala DatabaseClaimScanner::findPoisonedTableColumns($slowRules), ); } + + /** + * Is the field excluded by ANY of its exclude_* conditions? Mirrors native + * Laravel, which evaluates every exclude rule on a field. + * + * @param list}> $conditions + * @param array $itemData + * @param array $itemRules + */ + private function fieldIsExcluded(array $conditions, array $itemData, array $itemRules): bool + { + foreach ($conditions as $condition) { + // exclude_if is inactive when the dependent field is absent (mirrors + // Laravel's Arr::has short-circuit); short-circuiting also skips the + // matcher's data_get there. Otherwise match via the shared matcher, + // which reproduces Laravel's null/bool/loose-scalar coercion. + $present = $condition['action'] !== 'exclude_if' || Arr::has($itemData, $condition['field']); + $match = $present + && ConditionalValueMatcher::matches($condition['field'], $condition['values'], $itemData, $itemRules); + + if ($condition['action'] === 'exclude_unless' ? ! $match : $match) { + return true; + } + } + + return false; + } + + /** + * Strip exclude_unless/exclude_if tuples from a rule array, leaving the + * actual validation rules. Joins remaining strings into a pipe-delimited + * string when possible. + * + * Only the exclude tuples are removed — required_if/required_unless tuples + * must survive so the field's presence conditional still validates (the + * value-conditional reducer rewrites string forms; native Laravel handles + * the surviving tuple form). + */ + private function stripConditionalTuples(mixed $rules): mixed + { + if (! is_array($rules)) { + return $rules; + } + + $stripped = []; + + foreach ($rules as $rule) { + if (is_array($rule) && isset($rule[0]) && is_string($rule[0]) + && in_array($rule[0], ['exclude_unless', 'exclude_if'], true)) { + continue; + } + + // Stringify Stringable objects (Rule::in, Rule::notIn) so the + // result can be fast-checked as a pipe-joined string. + $stripped[] = $rule instanceof Stringable ? (string) $rule : $rule; + } + + // If all remaining rules are strings, join them for faster parsing — + // unless a token contains a literal `|` (e.g. `regex:/^(a|b)$/`), which + // Laravel's parser would split. Keep the array form in that case. + $allStrings = true; + $anyContainsPipe = false; + foreach ($stripped as $rule) { + if (! is_string($rule)) { + $allStrings = false; + break; + } + + if (str_contains($rule, '|')) { + $anyContainsPipe = true; + } + } + + if ($allStrings && ! $anyContainsPipe && $stripped !== []) { + return implode('|', array_map(static fn (mixed $v): string => is_scalar($v) ? (string) $v : '', $stripped)); + } + + return $stripped; + } } diff --git a/src/Internal/ItemValidator.php b/src/Internal/ItemValidator.php index af48bf8..ab3c1d1 100644 --- a/src/Internal/ItemValidator.php +++ b/src/Internal/ItemValidator.php @@ -1,16 +1,18 @@ - $items - * @param array $itemRules - * @param array $itemMessages - * @param array $itemAttributes + * @param array $items + * @param array $itemRules + * @param array $itemMessages + * @param array $itemAttributes + * * @return array> */ public function validate(array $items, array $itemRules, array $itemMessages, array $itemAttributes, string $parent, bool $isScalar): array @@ -185,10 +188,10 @@ public function validate(array $items, array $itemRules, array $itemMessages, ar * its own memoization, a consumer's overrides keep firing. Only the plain * default is safe to optimize. * - * @param array $itemData - * @param array $rules - * @param array $messages - * @param array $attributes + * @param array $itemData + * @param array $rules + * @param array $messages + * @param array $attributes */ /** * Clear exclusions carried over from the previous item. diff --git a/src/Internal/PreparesOptimizedRules.php b/src/Internal/PreparesOptimizedRules.php index 517ee75..bb65527 100644 --- a/src/Internal/PreparesOptimizedRules.php +++ b/src/Internal/PreparesOptimizedRules.php @@ -1,17 +1,19 @@ - $rules - * @param array $data + * @param array $rules + * @param array $data + * * @return array */ private function preExcludeRules(array $rules, array $data): array @@ -80,7 +83,8 @@ private function preExcludeRules(array $rules, array $data): array /** * Build fast-check closures and the attribute-to-pattern lookup map. * - * @param array $preparedRules Rules after pre-exclusion + * @param array $preparedRules Rules after pre-exclusion + * * @return array{0: array, 1: array} */ private function buildFastCheckMaps(PreparedRules $prepared, array $preparedRules): array @@ -99,8 +103,9 @@ private function buildFastCheckMaps(PreparedRules $prepared, array $preparedRule /** * @param array $fastChecks - * @param array> $implicitAttributes - * @param array $preparedRules + * @param array> $implicitAttributes + * @param array $preparedRules + * * @return array */ private function buildAttributePatternMap(array $fastChecks, array $implicitAttributes, array $preparedRules): array @@ -127,10 +132,10 @@ private function buildAttributePatternMap(array $fastChecks, array $implicitAttr * exists/unique checks on single fields. No-op when there are no wildcard * attributes or the batch checker isn't available (no DB bound). * - * @param array $preparedRules - * @param array $data + * @param array $preparedRules + * @param array $data * - * @throws ValidationException when a parent array exceeds its max:N budget + * @throws ValidationException when a parent array exceeds its max:N budget */ private function applyBatchPresenceVerifier(Validator $validator, PreparedRules $prepared, array $preparedRules, array $data): void { @@ -171,9 +176,9 @@ private function applyBatchPresenceVerifier(Validator $validator, PreparedRules * happens before the validator runs, `failedValidation()` is NOT invoked. * - Hard cap (Phase 2): enforced inside `BatchDatabaseChecker::buildVerifier`. * - * @param array $preparedRules Full prepared rules (NOT pre-intersected). - * @param array $data - * @param list $wildcardAttributes Concrete expanded wildcard paths (e.g. `items.0.id`). + * @param array $preparedRules Full prepared rules (NOT pre-intersected). + * @param array $data + * @param list $wildcardAttributes Concrete expanded wildcard paths (e.g. `items.0.id`). * * @throws BatchLimitExceededException */ @@ -206,9 +211,9 @@ private function buildWildcardBatchVerifier(array $preparedRules, array $data, a * Exists / Unique rule — no point refusing to query when we weren't * going to query anyway. * - * @param array $wildcardRules Rules keyed by concrete wildcard path. - * @param array $preparedRules Full rules, needed to find parent rule strings. - * @param array $data + * @param array $wildcardRules Rules keyed by concrete wildcard path. + * @param array $preparedRules Full rules, needed to find parent rule strings. + * @param array $data * * @throws BatchLimitExceededException */ diff --git a/src/Internal/PresenceMatchFidelity.php b/src/Internal/PresenceMatchFidelity.php index 080950a..fd585d0 100644 --- a/src/Internal/PresenceMatchFidelity.php +++ b/src/Internal/PresenceMatchFidelity.php @@ -1,4 +1,6 @@ - $values - * @param array $fetched + * @param list $values + * @param array $fetched */ public static function isFaithful(array $values, array $fetched): bool { diff --git a/src/Internal/RuleCacheKey.php b/src/Internal/RuleCacheKey.php index e27029a..e3d35d6 100644 --- a/src/Internal/RuleCacheKey.php +++ b/src/Internal/RuleCacheKey.php @@ -1,4 +1,6 @@ - $values - * @param array|string $itemRules + * @param array $values + * @param array|string $itemRules + * * @return array */ public static function filter(array $values, array|string $itemRules): array @@ -34,7 +37,8 @@ public static function filter(array $values, array|string $itemRules): array } /** - * @param array|string $itemRules + * @param array|string $itemRules + * * @return list */ private static function derive(array|string $itemRules): array @@ -53,7 +57,8 @@ private static function derive(array|string $itemRules): array } /** - * @param array|string $itemRules + * @param array|string $itemRules + * * @return list */ private static function stringRules(array|string $itemRules): array @@ -88,11 +93,11 @@ private static function predicateFor(string $rule): ?Closure return match ($token) { 'integer', 'int' => static fn (mixed $v): bool => filter_var($v, FILTER_VALIDATE_INT) !== false, - 'numeric' => is_numeric(...), - 'uuid' => static fn (mixed $v): bool => is_string($v) && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $v) === 1, - 'ulid' => static fn (mixed $v): bool => is_string($v) && preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $v) === 1, - 'string' => static fn (mixed $v): bool => is_scalar($v) || $v instanceof Stringable, - default => null, + 'numeric' => is_numeric(...), + 'uuid' => static fn (mixed $v): bool => is_string($v) && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $v) === 1, + 'ulid' => static fn (mixed $v): bool => is_string($v) && preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $v) === 1, + 'string' => static fn (mixed $v): bool => is_scalar($v) || $v instanceof Stringable, + default => null, }; } } diff --git a/src/Internal/VanillaAfterRoute.php b/src/Internal/VanillaAfterRoute.php index 82c33cb..047ce15 100644 --- a/src/Internal/VanillaAfterRoute.php +++ b/src/Internal/VanillaAfterRoute.php @@ -1,11 +1,13 @@ - $compiledRules Output of {@see RuleSet::compile()} over the flattened fields. - * @param array $data - * @param array $messages - * @param array $attributes - * @param list $afterCallbacks + * @param array $compiledRules Output of {@see RuleSet::compile()} over the flattened fields. + * @param array $data + * @param array $messages + * @param array $attributes + * @param list $afterCallbacks + * * @return array * * @throws ValidationException diff --git a/src/MemoizingValidator.php b/src/MemoizingValidator.php index 851662f..f7bcbb0 100644 --- a/src/MemoizingValidator.php +++ b/src/MemoizingValidator.php @@ -1,9 +1,11 @@ - $rules + * @param string $attribute + * @param string|array $rules + * * @return array|null */ protected function getRule($attribute, $rules): ?array diff --git a/src/OptimizedValidator.php b/src/OptimizedValidator.php index 349fd61..54037c7 100644 --- a/src/OptimizedValidator.php +++ b/src/OptimizedValidator.php @@ -1,13 +1,15 @@ -> Pattern → expanded attributes (pre-grouped) */ private array $fastCheckGroups = []; - /** - * @param array $fastChecks - * @param array $attributePatternMap - */ - public function withFastChecks(array $fastChecks, array $attributePatternMap): static - { - $this->fastChecks = $fastChecks; - - // Pre-group attributes by pattern for the fast-check loop - foreach ($attributePatternMap as $attribute => $pattern) { - if (isset($fastChecks[$pattern])) { - $this->fastCheckGroups[$pattern][] = $attribute; - } - } - - return $this; - } - /** * The rules as they stood before the fast-check and conditional phases * removed any, so questions Laravel asks about OTHER fields still get the @@ -58,27 +42,51 @@ public function withFastChecks(array $fastChecks, array $attributePatternMap): s private array $rulesBeforeOptimization = []; /** - * Answer from the pre-optimization rules, not the pruned ones. + * Build fast-check closures from compiled rule strings. + * Returns closures keyed by wildcard pattern that accept a single value. * - * Laravel's dependent rules — required_if, exclude_if, prohibited_if and - * the rest — convert their `true`/`false` parameters to real booleans only - * when the DEPENDENT field is declared `boolean`, which it learns by - * reading `$this->rules[$parameter]`. The fast-check phase removes a - * satisfied attribute from `$this->rules` before Laravel validates, so a - * dependent that passed its own `boolean` rule vanished from that lookup, - * the conversion was skipped, and `required_if:items.*.notify,true` no - * longer matched a notify of 1 — leaving a required field unenforced - * rather than reporting an error. + * Only string-only rules are eligible. Object rules, date comparisons, + * cross-field references, distinct, size/between are skipped. * - * @param string $parameter + * @param array $compiledRules Compiled rules keyed by wildcard pattern + * + * @return array */ - protected function shouldConvertToBoolean($parameter): bool + public static function buildFastChecks(array $compiledRules): array { - $rules = $this->rules[$parameter] - ?? $this->rulesBeforeOptimization[$parameter] - ?? []; + $checks = []; - return in_array('boolean', (array) $rules, true); + foreach ($compiledRules as $pattern => $rule) { + if (! is_string($rule)) { + continue; + } + + $check = FastCheckCompiler::compile($rule); + + if ($check instanceof Closure) { + $checks[$pattern] = $check; + } + } + + return $checks; + } + + /** + * @param array $fastChecks + * @param array $attributePatternMap + */ + public function withFastChecks(array $fastChecks, array $attributePatternMap): static + { + $this->fastChecks = $fastChecks; + + // Pre-group attributes by pattern for the fast-check loop + foreach ($attributePatternMap as $attribute => $pattern) { + if (isset($fastChecks[$pattern])) { + $this->fastCheckGroups[$pattern][] = $attribute; + } + } + + return $this; } /** @@ -112,6 +120,30 @@ public function passes(): bool return $result; } + /** + * Answer from the pre-optimization rules, not the pruned ones. + * + * Laravel's dependent rules — required_if, exclude_if, prohibited_if and + * the rest — convert their `true`/`false` parameters to real booleans only + * when the DEPENDENT field is declared `boolean`, which it learns by + * reading `$this->rules[$parameter]`. The fast-check phase removes a + * satisfied attribute from `$this->rules` before Laravel validates, so a + * dependent that passed its own `boolean` rule vanished from that lookup, + * the conversion was skipped, and `required_if:items.*.notify,true` no + * longer matched a notify of 1 — leaving a required field unenforced + * rather than reporting an error. + * + * @param string $parameter + */ + protected function shouldConvertToBoolean($parameter): bool + { + $rules = $this->rules[$parameter] + ?? $this->rulesBeforeOptimization[$parameter] + ?? []; + + return in_array('boolean', (array) $rules, true); + } + /** * Phase 1: Fast-check wildcard attributes by pattern. Iterates per-pattern * with all values for that pattern, improving cache locality and reducing @@ -170,7 +202,7 @@ private function runFastCheckPhase(): array */ private function runConditionalPhase(array &$removedRules): void { - $phase = new ConditionalEvaluationPhase(); + $phase = new ConditionalEvaluationPhase; // Closure (not [$this, 'getValue']) because parent::getValue() is // protected — only invokable from inside this class scope. $getValue = fn (string $field): mixed => $this->getValue($field); @@ -210,7 +242,7 @@ private function runConditionalPhase(array &$removedRules): void * portion of an attribute's rules. Returns true when the closure * compiled and the value passed — caller may then drop the attribute. * - * @param list $rules + * @param list $rules */ private function tryFastCheckRemaining(string $attribute, array $rules): bool { @@ -236,7 +268,7 @@ private function tryFastCheckRemaining(string $attribute, array $rules): bool */ private function finalizeWithoutParent(): bool { - $this->messages = new MessageBag(); + $this->messages = new MessageBag; $this->failedRules = []; foreach ($this->after as $after) { @@ -252,7 +284,7 @@ private function finalizeWithoutParent(): bool * Extract the non-conditional string rules from an attribute's rule array * and join them into a pipe-delimited string for fast-check compilation. * - * @param list $rules + * @param list $rules */ private function extractNonConditionalRule(array $rules): ?string { @@ -281,33 +313,4 @@ private function extractNonConditionalRule(array $rules): ?string return $stringParts !== [] ? implode('|', $stringParts) : null; } - - /** - * Build fast-check closures from compiled rule strings. - * Returns closures keyed by wildcard pattern that accept a single value. - * - * Only string-only rules are eligible. Object rules, date comparisons, - * cross-field references, distinct, size/between are skipped. - * - * @param array $compiledRules Compiled rules keyed by wildcard pattern - * @return array - */ - public static function buildFastChecks(array $compiledRules): array - { - $checks = []; - - foreach ($compiledRules as $pattern => $rule) { - if (! is_string($rule)) { - continue; - } - - $check = FastCheckCompiler::compile($rule); - - if ($check instanceof Closure) { - $checks[$pattern] = $check; - } - } - - return $checks; - } } diff --git a/src/PrecomputedPresenceVerifier.php b/src/PrecomputedPresenceVerifier.php index 898c3fa..46f0135 100644 --- a/src/PrecomputedPresenceVerifier.php +++ b/src/PrecomputedPresenceVerifier.php @@ -1,10 +1,12 @@ - $values Values that exist in the database + * @param array $values Values that exist in the database */ public function addLookup(string $table, string $column, array $values): void { @@ -72,8 +74,8 @@ public function getCount(mixed $collection, mixed $column, mixed $value, mixed $ } /** - * @param array $values - * @param array $extra + * @param array $values + * @param array $extra */ public function getMultiCount(mixed $collection, mixed $column, array $values, array $extra = []): int { @@ -92,7 +94,7 @@ public function getMultiCount(mixed $collection, mixed $column, array $values, a foreach ($values as $val) { if (is_scalar($val) && isset($lookup[(string) $val])) { - ++$count; + $count++; } } diff --git a/src/PreparedRules.php b/src/PreparedRules.php index bce108a..2273935 100644 --- a/src/PreparedRules.php +++ b/src/PreparedRules.php @@ -1,4 +1,6 @@ - $rules Compiled rules in native Laravel format - * @param array $messages Extracted per-rule messages (from ->message() / ->fieldMessage()) - * @param array $attributes Extracted labels (from ->label() / factory argument) - * @param array> $implicitAttributes Wildcard-to-concrete path mapping + * @param array $rules Compiled rules in native Laravel format + * @param array $messages Extracted per-rule messages (from ->message() / ->fieldMessage()) + * @param array $attributes Extracted labels (from ->label() / factory argument) + * @param array> $implicitAttributes Wildcard-to-concrete path mapping */ public function __construct( public array $rules, diff --git a/src/PresenceConditionalReducer.php b/src/PresenceConditionalReducer.php index a78ae56..4aaed90 100644 --- a/src/PresenceConditionalReducer.php +++ b/src/PresenceConditionalReducer.php @@ -1,10 +1,12 @@ - $itemData - * @param array $itemMessages + * @param array $itemData + * @param array $itemMessages */ public static function apply(mixed $rule, string $field, array $itemData, array $itemMessages): mixed { @@ -73,8 +75,8 @@ protected static function parse(string $rule): ?array * * Covers both single- and multi-param forms of all four rules. * - * @param array $itemData - * @param array $itemMessages + * @param array $itemData + * @param array $itemMessages */ private static function rewriteOne(string $rule, string $field, array $itemData, array $itemMessages): ?string { @@ -118,10 +120,10 @@ private static function rewriteOne(string $rule, string $field, array $itemData, * | `required_with_all` | ALL of `$params` present | * | `required_without_all` | ALL of `$params` absent | * - * @param list $params May contain `null` entries when `str_getcsv` - * resolved an empty slot (matches Laravel's - * `Arr::get($data, null)` → full-item semantics). - * @param array $itemData + * @param list $params May contain `null` entries when `str_getcsv` + * resolved an empty slot (matches Laravel's + * `Arr::get($data, null)` → full-item semantics). + * @param array $itemData */ private static function ruleActivates(string $ruleName, array $params, array $itemData): bool { @@ -137,11 +139,11 @@ private static function ruleActivates(string $ruleName, array $params, array $it } return match ($ruleName) { - 'required_with' => $anyPresent, - 'required_without' => ! $allPresent, - 'required_with_all' => $allPresent, + 'required_with' => $anyPresent, + 'required_without' => ! $allPresent, + 'required_with_all' => $allPresent, 'required_without_all' => ! $anyPresent, - default => false, + default => false, }; } @@ -152,11 +154,11 @@ private static function ruleActivates(string $ruleName, array $params, array $it * (`required_without:profile.birthdate`), so resolve via `data_get` * instead of direct array-key lookup. * - * @param array $itemData + * @param array $itemData */ private static function fieldPresent(array $itemData, ?string $field): bool { - $marker = new stdClass(); + $marker = new stdClass; // A `null` key mirrors `Arr::get($data, null)` and resolves to the // full item (that's how Laravel treats a null parameter slot). $value = $field === null ? $itemData : data_get($itemData, $field, $marker); diff --git a/src/Providers/ValidationServiceProvider.php b/src/Providers/ValidationServiceProvider.php index 7f93901..ea8107b 100644 --- a/src/Providers/ValidationServiceProvider.php +++ b/src/Providers/ValidationServiceProvider.php @@ -1,36 +1,38 @@ -app->singletonIf(RuleRegistrar::class); } + public function bootingPackage(): void + { + $this->applyBatchLimit(); + $this->registerRuleAliases(); + + if ($this->app->runningInConsole()) { + $this->publishes( + [$this->configPath() => config_path('laranail-validation.php')], + $this->package->getNamespacedPublishTag('config'), + ); + + $this->commands([ + RulesCommand::class, + DoctorCommand::class, + BenchmarkCommand::class, + ]); + } + } + + /** + * Narrow the validator's loosely-typed parameter array to a string list. + * + * `Validator::extend` types the third argument as a bare `array`, so the + * factories cannot assume anything about it. Non-scalars are dropped + * rather than coerced: `(string) []` is a fatal, and a rule string cannot + * carry an array parameter in the first place. + * + * @param array $parameters + * + * @return list + */ + private static function stringParameters(array $parameters): array + { + $strings = []; + + foreach ($parameters as $parameter) { + if (is_scalar($parameter) || $parameter instanceof Stringable) { + $strings[] = (string) $parameter; + } + } + + return $strings; + } + /** * Bind the bundled email lists, but only if nothing else has. * @@ -112,25 +158,6 @@ private function bindI18nDatasetFallbacks(): void $this->app->singletonIf(ReservedUsernameList::class, DefaultReservedUsernameList::class); } - public function bootingPackage(): void - { - $this->applyBatchLimit(); - $this->registerRuleAliases(); - - if ($this->app->runningInConsole()) { - $this->publishes( - [$this->configPath() => config_path('laranail-validation.php')], - $this->package->getNamespacedPublishTag('config'), - ); - - $this->commands([ - RulesCommand::class, - DoctorCommand::class, - BenchmarkCommand::class, - ]); - } - } - /** * Boot-time only. BatchDatabaseChecker caps how many values a single * exists/unique group may carry; mutating it per-request is unsafe under @@ -225,30 +252,6 @@ static function (string $attribute, mixed $value, array $parameters, ValidationV } } - /** - * Narrow the validator's loosely-typed parameter array to a string list. - * - * `Validator::extend` types the third argument as a bare `array`, so the - * factories cannot assume anything about it. Non-scalars are dropped - * rather than coerced: `(string) []` is a fatal, and a rule string cannot - * carry an array parameter in the first place. - * - * @param array $parameters - * @return list - */ - private static function stringParameters(array $parameters): array - { - $strings = []; - - foreach ($parameters as $parameter) { - if (is_scalar($parameter) || $parameter instanceof Stringable) { - $strings[] = (string) $parameter; - } - } - - return $strings; - } - private function configPath(): string { return $this->packagePath('config/laranail-validation.php'); diff --git a/src/Regex.php b/src/Regex.php index 92a916e..584f1e1 100644 --- a/src/Regex.php +++ b/src/Regex.php @@ -1,11 +1,13 @@ -compile(); + } + /** * Wrap a complete raw pattern. Undelimited input gains delimiters and * the `D` modifier (the end-of-string safety that prevents the @@ -58,7 +65,7 @@ private function __construct() {} */ public static function of(string $pattern): self { - $regex = new self(); + $regex = new self; $regex->raw = self::normalizeRaw($pattern); return $regex; @@ -67,7 +74,7 @@ public static function of(string $pattern): self /** Start a fluent build. Anchored by default; see {@see unanchored()}. */ public static function build(): self { - return new self(); + return new self; } public function digits(?int $count = null): self @@ -202,9 +209,44 @@ public function compile(): string return $pattern; } - public function __toString(): string + /** Wrap an undelimited body with the first delimiter it does not contain. */ + private static function wrap(string $body): string { - return $this->compile(); + foreach (self::DELIMITERS as $delimiter) { + if (! str_contains($body, $delimiter)) { + return $delimiter . $body . $delimiter; + } + } + + throw new InvalidArgumentException( + 'The pattern contains every supported delimiter (/ # ~ %) — pass it pre-delimited instead.', + ); + } + + /** Detect an already-delimited raw pattern: same shape the JS runner reads. */ + private static function normalizeRaw(string $pattern): string + { + $delimited = preg_match('/^([\/#~%])(.*)\1([a-zA-Z]*)$/s', $pattern) === 1; + + $normalized = $delimited ? $pattern : self::wrap($pattern) . 'D'; + + self::assertCompiles($normalized); + + return $normalized; + } + + /** + * The compile-time probe: a pattern that PCRE refuses must fail at + * DEFINITION time with the author present, not at validation time with + * a user in front of the form. + */ + private static function assertCompiles(string $pattern): void + { + if (@preg_match($pattern, '') === false) { + throw new InvalidArgumentException( + 'The pattern does not compile: ' . $pattern . ' (' . preg_last_error_msg() . ')', + ); + } } private function quantified(string $atom, ?int $count): self @@ -240,7 +282,7 @@ private function innerPattern(Closure|string $part): string return preg_quote($part, '/'); } - $sub = new self(); + $sub = new self; $sub->allowUnbounded = $this->allowUnbounded; $built = $part($sub); @@ -257,44 +299,4 @@ private function looksUnbounded(string $fragment): bool { return preg_match('/(? @@ -63,19 +65,19 @@ final class RuleSet implements Arrayable, IteratorAggregate public function __construct() { - $this->ruleCompiler = new ItemRuleCompiler(); - $this->errorCollector = new ItemErrorCollector(); + $this->ruleCompiler = new ItemRuleCompiler; + $this->errorCollector = new ItemErrorCollector; } public static function make(): self { - return new self(); + return new self; } /** @param array $rules */ public static function from(array $rules): self { - $ruleSet = new self(); + $ruleSet = new self; $ruleSet->fields = $rules; return $ruleSet; @@ -91,11 +93,107 @@ public static function from(array $rules): self * 'email' => $rules->email()->required(), * ])->validate($data); * - * @param Closure(FluentSchema): array $callback + * @param Closure(FluentSchema): array $callback */ public static function define(Closure $callback): self { - return self::from($callback(new FluentSchema())); + return self::from($callback(new FluentSchema)); + } + + /** + * @param array $rules + * + * @return array + */ + public static function compile(array $rules): array + { + foreach ($rules as $field => $rule) { + if (is_object($rule) && method_exists($rule, 'compiledRules')) { + $rules[$field] = $rule->compiledRules(); + } + } + + return $rules; + } + + /** + * Compile rules to array format, guaranteed to return arrays per field. + * Useful when passing rules to APIs that expect array> + * (e.g., Livewire's $this->validate()). + * + * @param array $rules + * + * @return array> + */ + /** + * Compile rules and extract labels/messages for use with Livewire's validate(). + * Returns [rules, messages, attributes] matching validate()'s parameter order. + * + * Usage in Filament components: + * [$rules, $messages, $attributes] = RuleSet::compileWithMetadata($this->rules()); + * $this->validate($rules, $messages, $attributes); + * + * @param array $rules + * + * @return array{0: array, 1: array, 2: array} + */ + public static function compileWithMetadata(array $rules): array + { + $ruleSet = self::from($rules); + $flattened = $ruleSet->flattenRules(); + [$messages, $attributes] = self::extractMetadata($flattened); + + return [self::compile($flattened), $messages, $attributes]; + } + + /** + * @param array $rules + * + * @return array> + */ + public static function compileToArrays(array $rules): array + { + $compiled = self::compile($rules); + + /** @var array> $result */ + $result = []; + + foreach ($compiled as $field => $rule) { + if (is_string($rule)) { + $result[$field] = explode('|', $rule); + } elseif (is_array($rule)) { + $result[$field] = $rule; + } else { + $result[$field] = [$rule]; + } + } + + return $result; + } + + /** + * Extract labels and per-rule messages from rule objects before compilation. + * + * @param array $rules + * + * @return array{0: array, 1: array} + */ + public static function extractMetadata(array $rules): array + { + $messages = []; + $attributes = []; + + foreach ($rules as $field => $rule) { + // For mixed arrays like ['exclude', FluentRule::string('ID')], + // look inside for rule objects with metadata. + $objects = is_object($rule) ? [$rule] : (is_array($rule) ? array_filter($rule, is_object(...)) : []); + + foreach ($objects as $object) { + self::extractObjectMetadata($object, $field, $messages, $attributes); + } + } + + return [$messages, $attributes]; } public function field(string $name, mixed $rule): self @@ -117,10 +215,10 @@ public function merge(self|array $rules): self } /** - * @param string|list ...$fields Pass either as variadic strings - * (`->only('a', 'b')`) or as a single - * array (`->only(['a', 'b'])`) — matches - * Collection::only / Arr::only semantics. + * @param string|list ...$fields Pass either as variadic strings + * (`->only('a', 'b')`) or as a single + * array (`->only(['a', 'b'])`) — matches + * Collection::only / Arr::only semantics. */ public function only(string|array ...$fields): self { @@ -217,13 +315,13 @@ public function modify(string $field, Closure $callback): self * If strict add-only behaviour is required (collision throws), use the * primitive `modify($field, fn ($r) => $r->addEachRule($key, $rule))`. * - * @param array $rules + * @param array $rules * * @throws LogicException When `$field` is not in the rule set, when the - * stored rule is not an `ArrayRule`, or when the - * stored rule's `each()` is list-shaped (a - * `CannotExtendListShapedEach` bubbles out of - * `mergeEachRules`). + * stored rule is not an `ArrayRule`, or when the + * stored rule's `each()` is list-shaped (a + * `CannotExtendListShapedEach` bubbles out of + * `mergeEachRules`). */ public function modifyEach(string $field, array $rules): self { @@ -247,17 +345,17 @@ public function modifyEach(string $field, array $rules): self * consumer demand surfaced). Falls through to `modify()` with the * primitive if you need to extend an `ArrayRule`'s `children()`. * - * @param array $rules + * @param array $rules * * @throws LogicException When `$field` is not in the rule set or the - * stored rule is not a `FieldRule`. + * stored rule is not a `FieldRule`. */ public function modifyChildren(string $field, array $rules): self { return $this->modify($field, static function (mixed $rule) use ($field, $rules): ValidationRule { if (! $rule instanceof FieldRule) { throw new LogicException( - "Field [{$field}] is not a FieldRule — modifyChildren() only applies to FluentRule::field() rules. For ArrayRule::children() extension, use modify(\$field, fn (\$r) => \$r->children([...]))." + "Field [{$field}] is not a FieldRule — modifyChildren() only applies to FluentRule::field() rules. For ArrayRule::children() extension, use modify(\$field, fn (\$r) => \$r->children([...])).", ); } @@ -313,7 +411,7 @@ public function stopOnFirstFailure(): self * Callbacks run in registration order, each seeing the previous one's * output — trimming, casting, or normalising input belongs here. * - * @param Closure(array): (array|null) $callback + * @param Closure(array): (array|null) $callback */ public function before(Closure $callback): self { @@ -335,7 +433,7 @@ public function before(Closure $callback): self * shortcuts. Hooks trade speed for the full Laravel feature; a rule set * without them keeps the fast engine. * - * @param Closure(\Illuminate\Validation\Validator): void $callback + * @param Closure(\Illuminate\Validation\Validator): void $callback */ public function after(Closure $callback): self { @@ -411,8 +509,8 @@ public function dump(): array [$messages, $attributes] = self::extractMetadata($flat); return [ - 'rules' => self::compileToArrays($flat), - 'messages' => $messages, + 'rules' => self::compileToArrays($flat), + 'messages' => $messages, 'attributes' => $attributes, ]; } @@ -426,8 +524,8 @@ public function dump(): array * $p = RuleSet::from($rules)->prepare($data); * parent::__construct($translator, $data, $p->rules, $p->messages, $p->attributes); * - * @param array $data - * @param array|null $flatRules Pre-computed flatten() result + * @param array $data + * @param array|null $flatRules Pre-computed flatten() result */ public function prepare(array $data, ?array $flatRules = null): PreparedRules { @@ -460,9 +558,9 @@ public function prepare(array $data, ?array $flatRules = null): PreparedRules * calls `$request->all()` internally, keeping the unsafe read scoped * to the library boundary for static-analysis purposes. * - * @param array|Request $data - * @param array $messages - * @param array $attributes + * @param array|Request $data + * @param array $messages + * @param array $attributes */ public function check(array|Request $data, array $messages = [], array $attributes = []): Validated { @@ -474,7 +572,7 @@ public function check(array|Request $data, array $messages = [], array $attribut return new Validated( passes: true, validated: $validated, - errors: new MessageBag(), + errors: new MessageBag, validator: Validator::make($data, []), ); } catch (ValidationException $validationException) { @@ -492,7 +590,8 @@ public function check(array|Request $data, array $messages = [], array $attribut } /** - * @param array $data + * @param array $data + * * @return array */ public function expandWildcards(array $data): array @@ -512,8 +611,9 @@ public function expandWildcards(array $data): array * fast at the call site with the install command rather than returning * an empty schema a browser would silently treat as "nothing to check". * - * @param array $messages - * @param array $attributes + * @param array $messages + * @param array $attributes + * * @return array{version: int, fields: array}>, server: list}>, messages: array, messageVariants: array>} * * @throws LogicException When laranail/validation-js is not installed. @@ -544,9 +644,10 @@ public function toSchema(array $messages = [], array $attributes = []): array * calls `$request->all()` internally, keeping the unsafe read scoped * to the library boundary for static-analysis purposes. * - * @param array|Request $data - * @param array $messages - * @param array $attributes + * @param array|Request $data + * @param array $messages + * @param array $attributes + * * @return array * * @throws ValidationException @@ -571,13 +672,124 @@ public function validate(array|Request $data, array $messages = [], array $attri return $this->runValidateInternal($data, $messages, $attributes); } + /** + * Expand wildcard rules against `$data` and return the rules paired with + * implicit-attributes metadata. Returns the awkward tuple shape + * `[$rules, $implicitAttributes]`; user code should reach for + * `expandWildcards()` (rules only) or `prepare()` (full PreparedRules + * payload) instead. + * + * @internal Used by {@see prepare()}, {@see validate()}, and the + * compile pipeline. Not covered by the package's BC promise. + * + * @param array $data + * @param array|null $flatRules Pre-computed flatten() result + * + * @return array{0: array, 1: array>} + */ + public function expand(array $data, ?array $flatRules = null): array + { + $flatRules ??= $this->flatten(); + $rules = []; + $implicitAttributes = []; + + foreach ($flatRules as $field => $rule) { + if (! str_contains($field, '*')) { + $rules[$field] = $rule; + + continue; + } + + $paths = WildcardExpander::expand($field, $data); + + if ($paths !== []) { + $implicitAttributes[$field] = $paths; + } + + foreach ($paths as $path) { + $rules[$path] = $rule; + } + } + + return [$rules, $implicitAttributes]; + } + + /** + * Flatten rules with wildcard keys preserved (e.g. items.*.name). + * Unlike prepare(), this does NOT expand wildcards against data. + * + * @return array + */ + public function flattenRules(): array + { + return $this->flatten(); + } + + /** + * @param array $messages + * @param array $attributes + */ + private static function extractObjectMetadata(object $object, string $field, array &$messages, array &$attributes): void + { + if (method_exists($object, 'getLabel')) { + $label = $object->getLabel(); + + if (is_string($label)) { + $attributes[$field] = $label; + } + } + + if (method_exists($object, 'getCustomMessages')) { + /** @var array $customMessages */ + $customMessages = $object->getCustomMessages(); + foreach ($customMessages as $ruleName => $msg) { + $messages[$ruleName === '' ? $field : $field . '.' . $ruleName] = $msg; + } + } + } + + /** @param array $rules */ + private static function flattenRule(string $prefix, mixed $rule, array &$rules): void + { + // Get nested rule definitions if the rule supports them. + $eachListRule = $rule instanceof ArrayRule ? $rule->getEachListRule() : null; + $eachKeyedRules = $rule instanceof ArrayRule ? $rule->getEachKeyedRules() : null; + + /** @var array|null $childRules */ + $childRules = is_object($rule) && method_exists($rule, 'getChildRules') ? $rule->getChildRules() : null; + + if (! $eachListRule instanceof ValidationRule && $eachKeyedRules === null && $childRules === null) { + $rules[$prefix] = $rule; + + return; + } + + // Store the parent rule, stripped of nested definitions to prevent double-validation. + $rules[$prefix] = $rule instanceof ArrayRule ? $rule->withoutEachRules() : $rule; + + // each() → wildcard paths: items.*.name + if ($eachListRule instanceof ValidationRule) { + self::flattenRule($prefix . '.*', $eachListRule, $rules); + } elseif ($eachKeyedRules !== null) { + foreach ($eachKeyedRules as $field => $fieldRule) { + self::flattenRule($prefix . '.*.' . $field, $fieldRule, $rules); + } + } + + // children() → fixed paths: search.value, answer.email_address + foreach ($childRules ?? [] as $field => $fieldRule) { + self::flattenRule($prefix . '.' . $field, $fieldRule, $rules); + } + } + /** * Normalize a public entry-point's `$data` argument. Routing `Request` * through `->all()` here keeps the unsafe-input read scoped to the * package boundary, so callers can use `RuleSet::validate($request)` * without tripping static-analysis rules against `$request->all()`. * - * @param array|Request $data + * @param array|Request $data + * * @return array */ private function normalizeInput(array|Request $data): array @@ -595,9 +807,10 @@ private function normalizeInput(array|Request $data): array } /** - * @param array $data - * @param array $messages - * @param array $attributes + * @param array $data + * @param array $messages + * @param array $attributes + * * @return array * * @throws ValidationException @@ -659,9 +872,10 @@ private function runValidateInternal(array $data, array $messages, array $attrib } /** - * @param array $data - * @param array $messages - * @param array $attributes + * @param array $data + * @param array $messages + * @param array $attributes + * * @return array * * @throws ValidationException @@ -683,9 +897,10 @@ private function validateWithAfterCallbacks(array $data, array $messages, array } /** - * @param array $data - * @param array $messages - * @param array $attributes + * @param array $data + * @param array $messages + * @param array $attributes + * * @return array * * @throws ValidationException @@ -750,7 +965,8 @@ private function validateInternal(array $data, array $messages, array $attribute /** * Split flattened rules into top-level rules and wildcard groups. * - * @param array|null $flatRules Pre-computed flatten() result to avoid re-processing + * @param array|null $flatRules Pre-computed flatten() result to avoid re-processing + * * @return array{0: array, 1: array>} */ private function separateRules(?array $flatRules = null): array @@ -776,7 +992,7 @@ private function separateRules(?array $flatRules = null): array if (! str_contains($field, '.*')) { throw new InvalidArgumentException( "Malformed wildcard rule key [{$field}]: a wildcard segment must be written as '.*' " - . "(e.g. 'items.*.name'). Did you mean '" . str_replace('*', '.*', $field) . "'?" + . "(e.g. 'items.*.name'). Did you mean '" . str_replace('*', '.*', $field) . "'?", ); } @@ -793,24 +1009,24 @@ private function separateRules(?array $flatRules = null): array /** * Validate all wildcard groups per-item with fast-check optimization. * - * @param array> $wildcardGroups - /** - * @param array> $wildcardGroups - * @param array $data - * @param array $messages - * @param array $attributes - * @param array|null $fallbackResult Set when full expansion fallback is used + * @param array> $wildcardGroups + * @param array> $wildcardGroups + * @param array $data + * @param array $messages + * @param array $attributes + * @param array|null $fallbackResult Set when full expansion fallback is used + * * @return array> * * @throws ValidationException */ private function validateWildcardGroups( - array $wildcardGroups, - array $data, - array $messages, - array $attributes, + array $wildcardGroups, + array $data, + array $messages, + array $attributes, ?array &$fallbackResult = null, - bool $stopOnFirstFailure = false, + bool $stopOnFirstFailure = false, ): array { /** @var array> $allErrors */ $allErrors = []; @@ -865,10 +1081,11 @@ private function validateWildcardGroups( /** * Validate individual items in a wildcard group. * - * @param array $items - * @param array $itemRules - * @param array $itemMessages - * @param array $itemAttributes + * @param array $items + * @param array $itemRules + * @param array $itemMessages + * @param array $itemAttributes + * * @return array> */ private function validateItems(array $items, array $itemRules, array $itemMessages, array $itemAttributes, string $parent, bool $isScalar, bool $stopOnFirstFailure = false): array @@ -880,11 +1097,11 @@ private function validateItems(array $items, array $itemRules, array $itemMessag /** * Reject input keys not covered by any rule in the set. * - * @param array $data - * @param array $topRules - * @param array> $wildcardGroups - * @param array $messages - * @param array $attributes + * @param array $data + * @param array $topRules + * @param array> $wildcardGroups + * @param array $messages + * @param array $attributes * * @throws ValidationException */ @@ -918,7 +1135,7 @@ private function rejectUnknownFields(array $data, array $topRules, array $wildca /** * Check if an input key matches any allowed rule key, including wildcard patterns. * - * @param list $allowedKeys + * @param list $allowedKeys */ private function isKnownField(string $inputKey, array $allowedKeys): bool { @@ -958,7 +1175,8 @@ private function throwValidationErrors(array $errors): never * ['exclude_unless', 'items.*.type', 'chapter'] → ['exclude_unless', 'type', 'chapter'] * 'gte:items.*.start_time' → 'gte:start_time' * - * @param array $groupRules + * @param array $groupRules + * * @return array */ private function rewriteRulesForPerItem(array $groupRules, string $parent): array @@ -1000,7 +1218,8 @@ private function rewriteRulesForPerItem(array $groupRules, string $parent): arra * rule's constraint list so per-item validation resolves the dep against * the relative item. * - * @param array $rule + * @param array $rule + * * @return list */ private function stripPrefixFromConstraints(array $rule, string $prefix): array @@ -1045,9 +1264,10 @@ private function requiresFullExpansion(array $compiledRules): bool } /** - * @param array $data - * @param array $messages - * @param array $attributes + * @param array $data + * @param array $messages + * @param array $attributes + * * @return array * * @throws ValidationException @@ -1073,149 +1293,6 @@ private function validateStandard(array $data, array $messages, array $attribute return $validator->validate(); } - /** - * Expand wildcard rules against `$data` and return the rules paired with - * implicit-attributes metadata. Returns the awkward tuple shape - * `[$rules, $implicitAttributes]`; user code should reach for - * `expandWildcards()` (rules only) or `prepare()` (full PreparedRules - * payload) instead. - * - * @internal Used by {@see prepare()}, {@see validate()}, and the - * compile pipeline. Not covered by the package's BC promise. - * - * @param array $data - * @param array|null $flatRules Pre-computed flatten() result - * @return array{0: array, 1: array>} - */ - public function expand(array $data, ?array $flatRules = null): array - { - $flatRules ??= $this->flatten(); - $rules = []; - $implicitAttributes = []; - - foreach ($flatRules as $field => $rule) { - if (! str_contains($field, '*')) { - $rules[$field] = $rule; - - continue; - } - - $paths = WildcardExpander::expand($field, $data); - - if ($paths !== []) { - $implicitAttributes[$field] = $paths; - } - - foreach ($paths as $path) { - $rules[$path] = $rule; - } - } - - return [$rules, $implicitAttributes]; - } - - /** - * @param array $rules - * @return array - */ - public static function compile(array $rules): array - { - foreach ($rules as $field => $rule) { - if (is_object($rule) && method_exists($rule, 'compiledRules')) { - $rules[$field] = $rule->compiledRules(); - } - } - - return $rules; - } - - /** - * Compile rules to array format, guaranteed to return arrays per field. - * Useful when passing rules to APIs that expect array> - * (e.g., Livewire's $this->validate()). - * - * @param array $rules - * @return array> - */ - /** - * Compile rules and extract labels/messages for use with Livewire's validate(). - * Returns [rules, messages, attributes] matching validate()'s parameter order. - * - * Usage in Filament components: - * [$rules, $messages, $attributes] = RuleSet::compileWithMetadata($this->rules()); - * $this->validate($rules, $messages, $attributes); - * - * @param array $rules - * @return array{0: array, 1: array, 2: array} - */ - public static function compileWithMetadata(array $rules): array - { - $ruleSet = self::from($rules); - $flattened = $ruleSet->flattenRules(); - [$messages, $attributes] = self::extractMetadata($flattened); - - return [self::compile($flattened), $messages, $attributes]; - } - - /** - * @param array $rules - * @return array> - */ - public static function compileToArrays(array $rules): array - { - $compiled = self::compile($rules); - - /** @var array> $result */ - $result = []; - - foreach ($compiled as $field => $rule) { - if (is_string($rule)) { - $result[$field] = explode('|', $rule); - } elseif (is_array($rule)) { - $result[$field] = $rule; - } else { - $result[$field] = [$rule]; - } - } - - return $result; - } - - /** - * Extract labels and per-rule messages from rule objects before compilation. - * - * @param array $rules - * @return array{0: array, 1: array} - */ - public static function extractMetadata(array $rules): array - { - $messages = []; - $attributes = []; - - foreach ($rules as $field => $rule) { - // For mixed arrays like ['exclude', FluentRule::string('ID')], - // look inside for rule objects with metadata. - $objects = is_object($rule) ? [$rule] : (is_array($rule) ? array_filter($rule, is_object(...)) : []); - - foreach ($objects as $object) { - self::extractObjectMetadata($object, $field, $messages, $attributes); - } - } - - return [$messages, $attributes]; - } - - /** - * Flatten rules with wildcard keys preserved (e.g. items.*.name). - * Unlike prepare(), this does NOT expand wildcards against data. - * - * @return array - */ - public function flattenRules(): array - { - return $this->flatten(); - } - /** @return array */ private function flatten(): array { @@ -1227,61 +1304,4 @@ private function flatten(): array return $rules; } - - /** - * @param array $messages - * @param array $attributes - */ - private static function extractObjectMetadata(object $object, string $field, array &$messages, array &$attributes): void - { - if (method_exists($object, 'getLabel')) { - $label = $object->getLabel(); - - if (is_string($label)) { - $attributes[$field] = $label; - } - } - - if (method_exists($object, 'getCustomMessages')) { - /** @var array $customMessages */ - $customMessages = $object->getCustomMessages(); - foreach ($customMessages as $ruleName => $msg) { - $messages[$ruleName === '' ? $field : $field . '.' . $ruleName] = $msg; - } - } - } - - /** @param array $rules */ - private static function flattenRule(string $prefix, mixed $rule, array &$rules): void - { - // Get nested rule definitions if the rule supports them. - $eachListRule = $rule instanceof ArrayRule ? $rule->getEachListRule() : null; - $eachKeyedRules = $rule instanceof ArrayRule ? $rule->getEachKeyedRules() : null; - - /** @var array|null $childRules */ - $childRules = is_object($rule) && method_exists($rule, 'getChildRules') ? $rule->getChildRules() : null; - - if (! $eachListRule instanceof ValidationRule && $eachKeyedRules === null && $childRules === null) { - $rules[$prefix] = $rule; - - return; - } - - // Store the parent rule, stripped of nested definitions to prevent double-validation. - $rules[$prefix] = $rule instanceof ArrayRule ? $rule->withoutEachRules() : $rule; - - // each() → wildcard paths: items.*.name - if ($eachListRule instanceof ValidationRule) { - self::flattenRule($prefix . '.*', $eachListRule, $rules); - } elseif ($eachKeyedRules !== null) { - foreach ($eachKeyedRules as $field => $fieldRule) { - self::flattenRule($prefix . '.*.' . $field, $fieldRule, $rules); - } - } - - // children() → fixed paths: search.value, answer.email_address - foreach ($childRules ?? [] as $field => $fieldRule) { - self::flattenRule($prefix . '.' . $field, $fieldRule, $rules); - } - } } diff --git a/src/Rules/AntiSpam/Honeypot.php b/src/Rules/AntiSpam/Honeypot.php index ce34006..044e8ad 100644 --- a/src/Rules/AntiSpam/Honeypot.php +++ b/src/Rules/AntiSpam/Honeypot.php @@ -1,4 +1,6 @@ -translate(); - } - } - public static function passes(mixed $value): bool { if ($value === null) { @@ -59,4 +52,13 @@ public static function passes(mixed $value): bool // never a person. return false; } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value)) { + // Deliberately vague. Telling the sender which field gave them + // away is telling the bot's author how to pass next time. + $fail('laranail/validation::validation.honeypot')->translate(); + } + } } diff --git a/src/Rules/AntiSpam/SubmissionTiming.php b/src/Rules/AntiSpam/SubmissionTiming.php index 24f756b..d1731ac 100644 --- a/src/Rules/AntiSpam/SubmissionTiming.php +++ b/src/Rules/AntiSpam/SubmissionTiming.php @@ -1,12 +1,14 @@ -getTimestamp()); } - public function validate(string $attribute, mixed $value, Closure $fail): void - { - $elapsed = self::elapsed($value); - - if ($elapsed === null) { - // Undecryptable means tampered or from another key. Same message - // as too-fast: distinguishing them tells an attacker which lever - // they pulled. - $fail('laranail/validation::validation.submission_timing.too_fast')->translate(); - - return; - } - - if ($elapsed < $this->minimumSeconds) { - $fail('laranail/validation::validation.submission_timing.too_fast')->translate(); - - return; - } - - if ($elapsed > $this->maximumSeconds) { - $fail('laranail/validation::validation.submission_timing.expired')->translate(); - } - } - /** Seconds since the token was issued, or null if it is not a token we made. */ public static function elapsed(mixed $value): ?int { @@ -96,4 +74,28 @@ public static function elapsed(mixed $value): ?int // is not a measurement. return $elapsed < 0 ? null : $elapsed; } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + $elapsed = self::elapsed($value); + + if ($elapsed === null) { + // Undecryptable means tampered or from another key. Same message + // as too-fast: distinguishing them tells an attacker which lever + // they pulled. + $fail('laranail/validation::validation.submission_timing.too_fast')->translate(); + + return; + } + + if ($elapsed < $this->minimumSeconds) { + $fail('laranail/validation::validation.submission_timing.too_fast')->translate(); + + return; + } + + if ($elapsed > $this->maximumSeconds) { + $fail('laranail/validation::validation.submission_timing.expired')->translate(); + } + } } diff --git a/src/Rules/Banking/Bic.php b/src/Rules/Banking/Bic.php index c7e280b..dc27ab0 100644 --- a/src/Rules/Banking/Bic.php +++ b/src/Rules/Banking/Bic.php @@ -1,4 +1,6 @@ -translate(); - } - } - public static function passes(mixed $value): bool { if (! is_string($value)) { @@ -80,6 +75,13 @@ public static function passes(mixed $value): bool return self::checksumIsValid($iban); } + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value)) { + $fail('laranail/validation::validation.iban')->translate(); + } + } + /** * ISO 7064 MOD-97-10: move the first four characters to the end, map each * letter to two digits (A=10 … Z=35), and require a remainder of 1. diff --git a/src/Rules/Banking/Isin.php b/src/Rules/Banking/Isin.php index aa27ff9..a332231 100644 --- a/src/Rules/Banking/Isin.php +++ b/src/Rules/Banking/Isin.php @@ -1,4 +1,6 @@ -translate(); - } - } - public static function passes(mixed $value): bool { if (! is_string($value)) { @@ -50,4 +45,11 @@ public static function passes(mixed $value): bool return Luhn::passes($expanded); } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value)) { + $fail('laranail/validation::validation.isin')->translate(); + } + } } diff --git a/src/Rules/Banking/Luhn.php b/src/Rules/Banking/Luhn.php index 36e066b..56aa839 100644 --- a/src/Rules/Banking/Luhn.php +++ b/src/Rules/Banking/Luhn.php @@ -1,4 +1,6 @@ -translate(); - } - } - public static function passes(mixed $value): bool { if (! is_string($value) && ! is_int($value)) { @@ -46,7 +41,7 @@ public static function passes(mixed $value): bool // Right to left: every second digit is doubled, and a doubled value // above 9 has its digits summed — equivalent to subtracting 9. - for ($i = strlen($digits) - 1; $i >= 0; --$i) { + for ($i = strlen($digits) - 1; $i >= 0; $i--) { $digit = (int) $digits[$i]; if ($double) { @@ -63,4 +58,11 @@ public static function passes(mixed $value): bool return $sum % 10 === 0; } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value)) { + $fail('laranail/validation::validation.luhn')->translate(); + } + } } diff --git a/src/Rules/Chrono/DateInterval.php b/src/Rules/Chrono/DateInterval.php index 74116b8..98c1116 100644 --- a/src/Rules/Chrono/DateInterval.php +++ b/src/Rules/Chrono/DateInterval.php @@ -1,4 +1,6 @@ - $minutes Allowed minute values, each 0–59. + * @param list $minutes Allowed minute values, each 0–59. */ public function __construct(private array $minutes) { diff --git a/src/Rules/Chrono/Rfc3339.php b/src/Rules/Chrono/Rfc3339.php index 69deeb2..e841123 100644 --- a/src/Rules/Chrono/Rfc3339.php +++ b/src/Rules/Chrono/Rfc3339.php @@ -1,4 +1,6 @@ -[01]\d|2[0-3]):(?[0-5]\d):(?[0-5]\d|60)(?:\.\d+)?' . '(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/D'; - public function validate(string $attribute, mixed $value, Closure $fail): void - { - if (! is_string($value) || ! self::passes($value)) { - $fail('laranail/validation::validation.rfc3339')->translate(); - } - } - public static function passes(string $value): bool { if (preg_match(self::GRAMMAR, $value, $parts) !== 1) { @@ -40,4 +35,11 @@ public static function passes(string $value): bool return checkdate((int) $parts['m'], (int) $parts['d'], (int) $parts['y']); } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || ! self::passes($value)) { + $fail('laranail/validation::validation.rfc3339')->translate(); + } + } } diff --git a/src/Rules/Chrono/TimeOfDay.php b/src/Rules/Chrono/TimeOfDay.php index ab73c48..71ba8d9 100644 --- a/src/Rules/Chrono/TimeOfDay.php +++ b/src/Rules/Chrono/TimeOfDay.php @@ -1,4 +1,6 @@ -translate(); - } - } - public static function passes(string $value): bool { if (preg_match('/^B[0-9A-Z]{9}$/D', $value) === 1) { @@ -36,4 +31,11 @@ public static function passes(string $value): bool return Isbn::passes($value, [10]); } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || ! self::passes($value)) { + $fail('laranail/validation::validation.asin')->translate(); + } + } } diff --git a/src/Rules/Codes/Ean.php b/src/Rules/Codes/Ean.php index d8eb250..3c92927 100644 --- a/src/Rules/Codes/Ean.php +++ b/src/Rules/Codes/Ean.php @@ -1,4 +1,6 @@ - $lengths Widths to accept; defaults to all four. + * @param list $lengths Widths to accept; defaults to all four. */ public function __construct(array $lengths = self::VALID_LENGTHS) { $this->lengths = $lengths === [] ? self::VALID_LENGTHS : $lengths; } - public function validate(string $attribute, mixed $value, Closure $fail): void - { - if (! self::passes($value, $this->lengths)) { - $fail('laranail/validation::validation.gtin')->translate(); - } - } - /** - * @param list $lengths + * @param list $lengths */ public static function passes(mixed $value, array $lengths = self::VALID_LENGTHS): bool { @@ -75,11 +70,18 @@ public static function checkDigit(string $body): int $sum = 0; $weight = 3; - for ($i = strlen($body) - 1; $i >= 0; --$i) { + for ($i = strlen($body) - 1; $i >= 0; $i--) { $sum += ((int) $body[$i]) * $weight; $weight = $weight === 3 ? 1 : 3; } return (10 - ($sum % 10)) % 10; } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value, $this->lengths)) { + $fail('laranail/validation::validation.gtin')->translate(); + } + } } diff --git a/src/Rules/Codes/Isbn.php b/src/Rules/Codes/Isbn.php index c82e283..5697196 100644 --- a/src/Rules/Codes/Isbn.php +++ b/src/Rules/Codes/Isbn.php @@ -1,4 +1,6 @@ - $editions 10, 13, or both (the default). + * @param list $editions 10, 13, or both (the default). */ public function __construct(array $editions = [self::EDITION_10, self::EDITION_13]) { $this->editions = $editions === [] ? [self::EDITION_10, self::EDITION_13] : $editions; } - public function validate(string $attribute, mixed $value, Closure $fail): void - { - if (! self::passes($value, $this->editions)) { - $fail('laranail/validation::validation.isbn')->translate(); - } - } - /** - * @param list $editions + * @param list $editions */ public static function passes(mixed $value, array $editions = [self::EDITION_10, self::EDITION_13]): bool { @@ -56,12 +51,19 @@ public static function passes(mixed $value, array $editions = [self::EDITION_10, $isbn = strtoupper(str_replace([' ', '-'], '', (string) $value)); return match (strlen($isbn)) { - 10 => in_array(self::EDITION_10, $editions, true) && self::isbn10IsValid($isbn), - 13 => in_array(self::EDITION_13, $editions, true) && self::isbn13IsValid($isbn), + 10 => in_array(self::EDITION_10, $editions, true) && self::isbn10IsValid($isbn), + 13 => in_array(self::EDITION_13, $editions, true) && self::isbn13IsValid($isbn), default => false, }; } + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value, $this->editions)) { + $fail('laranail/validation::validation.isbn')->translate(); + } + } + /** * Mod-11 with weights 10 down to 1. Only the final character may be `X`, * standing for a check value of 10. @@ -73,7 +75,7 @@ private static function isbn10IsValid(string $isbn): bool } $sum = 0; - for ($i = 0; $i < 9; ++$i) { + for ($i = 0; $i < 9; $i++) { $sum += ((int) $isbn[$i]) * (10 - $i); } diff --git a/src/Rules/Codes/Ismn.php b/src/Rules/Codes/Ismn.php index b86db48..a10c226 100644 --- a/src/Rules/Codes/Ismn.php +++ b/src/Rules/Codes/Ismn.php @@ -1,4 +1,6 @@ -translate(); - } - } - public static function passes(string $value): bool { $digits = str_replace(['-', ' '], '', $value); return str_starts_with($digits, '9790') && Gtin::passes($digits, [13]); } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || ! self::passes($value)) { + $fail('laranail/validation::validation.ismn')->translate(); + } + } } diff --git a/src/Rules/Codes/Issn.php b/src/Rules/Codes/Issn.php index ce1e32e..edd1107 100644 --- a/src/Rules/Codes/Issn.php +++ b/src/Rules/Codes/Issn.php @@ -1,4 +1,6 @@ -translate(); - } - } - public static function passes(mixed $value): bool { if (! is_string($value)) { @@ -42,7 +37,7 @@ public static function passes(mixed $value): bool // Weights run 8 down to 2 across the first seven characters. $sum = 0; - for ($i = 0; $i < 7; ++$i) { + for ($i = 0; $i < 7; $i++) { $sum += ((int) $digits[$i]) * (8 - $i); } @@ -51,4 +46,11 @@ public static function passes(mixed $value): bool return ($check === '10' ? 'X' : $check) === $digits[7]; } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value)) { + $fail('laranail/validation::validation.issn')->translate(); + } + } } diff --git a/src/Rules/Codes/UpcE.php b/src/Rules/Codes/UpcE.php index d280008..b7b8ceb 100644 --- a/src/Rules/Codes/UpcE.php +++ b/src/Rules/Codes/UpcE.php @@ -1,4 +1,6 @@ -translate(); - } - } - public static function passes(string $value): bool { if (preg_match('/^[01]\d{7}$/D', $value) !== 1) { @@ -42,11 +37,18 @@ public static function passes(string $value): bool // GS1's zero-suppression patterns, keyed by the sixth data digit. $body = match (true) { in_array($selector, ['0', '1', '2'], true) => $numberSystem . substr($data, 0, 2) . $selector . '0000' . substr($data, 2, 3), - $selector === '3' => $numberSystem . substr($data, 0, 3) . '00000' . substr($data, 3, 2), - $selector === '4' => $numberSystem . substr($data, 0, 4) . '00000' . $data[4], - default => $numberSystem . substr($data, 0, 5) . '0000' . $selector, + $selector === '3' => $numberSystem . substr($data, 0, 3) . '00000' . substr($data, 3, 2), + $selector === '4' => $numberSystem . substr($data, 0, 4) . '00000' . $data[4], + default => $numberSystem . substr($data, 0, 5) . '0000' . $selector, }; return Gtin::passes($body . $value[7], [12]); } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || ! self::passes($value)) { + $fail('laranail/validation::validation.upc_e')->translate(); + } + } } diff --git a/src/Rules/Colour/CssColor.php b/src/Rules/Colour/CssColor.php index 8f08009..cfb66ec 100644 --- a/src/Rules/Colour/CssColor.php +++ b/src/Rules/Colour/CssColor.php @@ -1,4 +1,6 @@ -notations = $normalised === [] ? self::CSS_NOTATIONS : $normalised; } - public function validate(string $attribute, mixed $value, Closure $fail): void - { - if (! self::passes($value, $this->notations)) { - $fail('laranail/validation::validation.css_color') - ->translate(['notations' => implode(', ', $this->notations)]); - } - } - /** @param list $notations */ public static function passes(mixed $value, array $notations = self::CSS_NOTATIONS): bool { @@ -83,40 +77,12 @@ public static function passes(mixed $value, array $notations = self::CSS_NOTATIO return array_any($notations, fn (string $notation) => self::matches($value, $notation)); } - private static function matches(string $value, string $notation): bool - { - return match ($notation) { - // 3, 4, 6 and 8 digits: the 4- and 8-digit forms carry alpha. - // Anything else — 5 digits, 7 digits — is not a colour, and a - // pattern of {3,8} would accept them. - self::HEX => preg_match('/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/iD', $value) === 1, - self::RGB => self::functional($value, 'rgba?', 3), - self::HSL => self::functional($value, 'hsla?', 3), - self::HSV => self::functional($value, 'hsva?', 3), - self::NAME => Names::has($value), - default => false, - }; - } - - /** - * A CSS colour function: `rgb(1, 2, 3)`, `rgb(1 2 3 / 40%)`, `hsl(120deg 50% 50%)`. - * - * Deliberately shape-only. Component ranges are not enforced because CSS - * clamps rather than rejects — `rgb(300, 0, 0)` renders as red — so a rule - * that rejected it would be stricter than every browser. - */ - private static function functional(string $value, string $function, int $components): bool + public function validate(string $attribute, mixed $value, Closure $fail): void { - // A component: number, percentage, or angle. The separator is a comma - // (legacy syntax) or whitespace (CSS Color 4), and alpha follows a - // slash in the modern form or a fourth comma in the legacy one. - $component = '[+-]?(?:\d+\.?\d*|\.\d+)(?:%|deg|grad|rad|turn)?'; - $separator = '(?:\s*,\s*|\s+)'; - - $body = $component . str_repeat($separator . $component, $components - 1); - $alpha = '(?:\s*(?:,|\/)\s*' . $component . ')?'; - - return preg_match('/^' . $function . '\(\s*' . $body . $alpha . '\s*\)$/iD', $value) === 1; + if (! self::passes($value, $this->notations)) { + $fail('laranail/validation::validation.css_color') + ->translate(['notations' => implode(', ', $this->notations)]); + } } /** @@ -155,6 +121,42 @@ public function clientRules(): array return [['rule' => 'regex', 'params' => ['pattern' => '/^(?:' . implode('|', $branches) . ')$/iD']]]; } + private static function matches(string $value, string $notation): bool + { + return match ($notation) { + // 3, 4, 6 and 8 digits: the 4- and 8-digit forms carry alpha. + // Anything else — 5 digits, 7 digits — is not a colour, and a + // pattern of {3,8} would accept them. + self::HEX => preg_match('/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/iD', $value) === 1, + self::RGB => self::functional($value, 'rgba?', 3), + self::HSL => self::functional($value, 'hsla?', 3), + self::HSV => self::functional($value, 'hsva?', 3), + self::NAME => Names::has($value), + default => false, + }; + } + + /** + * A CSS colour function: `rgb(1, 2, 3)`, `rgb(1 2 3 / 40%)`, `hsl(120deg 50% 50%)`. + * + * Deliberately shape-only. Component ranges are not enforced because CSS + * clamps rather than rejects — `rgb(300, 0, 0)` renders as red — so a rule + * that rejected it would be stricter than every browser. + */ + private static function functional(string $value, string $function, int $components): bool + { + // A component: number, percentage, or angle. The separator is a comma + // (legacy syntax) or whitespace (CSS Color 4), and alpha follows a + // slash in the modern form or a fourth comma in the legacy one. + $component = '[+-]?(?:\d+\.?\d*|\.\d+)(?:%|deg|grad|rad|turn)?'; + $separator = '(?:\s*,\s*|\s+)'; + + $body = $component . str_repeat($separator . $component, $components - 1); + $alpha = '(?:\s*(?:,|\/)\s*' . $component . ')?'; + + return preg_match('/^' . $function . '\(\s*' . $body . $alpha . '\s*\)$/iD', $value) === 1; + } + /** The unanchored body for one notation, or null if there is no such notation. */ private function branch(string $notation): ?string { @@ -165,10 +167,10 @@ private function branch(string $notation): ?string . '(?:\s*(?:,|\/)\s*' . $component . ')?\s*\)'; return match ($notation) { - self::HEX => '#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})', - self::RGB => $body('rgba?'), - self::HSL => $body('hsla?'), - self::HSV => $body('hsva?'), + self::HEX => '#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})', + self::RGB => $body('rgba?'), + self::HSL => $body('hsla?'), + self::HSV => $body('hsva?'), self::NAME => implode('|', array_map( static fn (string $name): string => preg_quote($name, '/'), Names::all(), diff --git a/src/Rules/Colour/Support/Names.php b/src/Rules/Colour/Support/Names.php index 890fdb6..d16170d 100644 --- a/src/Rules/Colour/Support/Names.php +++ b/src/Rules/Colour/Support/Names.php @@ -1,4 +1,6 @@ -|null */ - private static ?array $lookup = null; - /** @var list */ private const array NAMES = [ 'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', @@ -48,6 +47,9 @@ final class Names 'whitesmoke', 'yellow', 'yellowgreen', ]; + /** @var array|null */ + private static ?array $lookup = null; + public static function has(string $value): bool { self::$lookup ??= array_fill_keys([...self::NAMES, 'transparent', 'currentcolor'], true); diff --git a/src/Rules/Crypto/BitcoinAddress.php b/src/Rules/Crypto/BitcoinAddress.php index 03a83e4..641c0ce 100644 --- a/src/Rules/Crypto/BitcoinAddress.php +++ b/src/Rules/Crypto/BitcoinAddress.php @@ -1,11 +1,13 @@ -testnet)) { - $fail('laranail/validation::validation.bitcoin_address')->translate(); - } - } - public static function passes(mixed $value, bool $testnet = false): bool { if (! is_string($value) || $value === '') { @@ -71,4 +66,11 @@ public static function passes(mixed $value, bool $testnet = false): bool true, ); } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value, $this->testnet)) { + $fail('laranail/validation::validation.bitcoin_address')->translate(); + } + } } diff --git a/src/Rules/Crypto/EthereumAddress.php b/src/Rules/Crypto/EthereumAddress.php index 99de5b5..9d6fc7c 100644 --- a/src/Rules/Crypto/EthereumAddress.php +++ b/src/Rules/Crypto/EthereumAddress.php @@ -1,4 +1,6 @@ -= 0; --$i) { + for ($i = count($bytes) - 1; $i >= 0; $i--) { $carry += $bytes[$i] * 58; $bytes[$i] = $carry & 0xFF; $carry >>= 8; diff --git a/src/Rules/Crypto/Support/Bech32.php b/src/Rules/Crypto/Support/Bech32.php index fa6b048..5439f68 100644 --- a/src/Rules/Crypto/Support/Bech32.php +++ b/src/Rules/Crypto/Support/Bech32.php @@ -1,4 +1,6 @@ -> 25; $checksum = (($checksum & 0x1FFFFFF) << 5) ^ $value; - for ($i = 0; $i < 5; ++$i) { + for ($i = 0; $i < 5; $i++) { if ((($top >> $i) & 1) === 1) { $checksum ^= self::GENERATOR[$i]; } diff --git a/src/Rules/Database/Authorized.php b/src/Rules/Database/Authorized.php index 9b990e8..389d0cd 100644 --- a/src/Rules/Database/Authorized.php +++ b/src/Rules/Database/Authorized.php @@ -1,11 +1,13 @@ - $model Model to resolve the value against. - * @param string|null $guard Auth guard, or null for the default. - * @param list $arguments Extra arguments passed after the model. + * @param string $ability Ability name, as the policy declares it. + * @param class-string $model Model to resolve the value against. + * @param string|null $guard Auth guard, or null for the default. + * @param list $arguments Extra arguments passed after the model. */ public function __construct( private string $ability, @@ -50,7 +52,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - $model = new $this->model(); + $model = new $this->model; $record = $model->newQuery() ->where($model->getRouteKeyName(), $value) diff --git a/src/Rules/Database/CompareToColumn.php b/src/Rules/Database/CompareToColumn.php index 3e45122..57a82ed 100644 --- a/src/Rules/Database/CompareToColumn.php +++ b/src/Rules/Database/CompareToColumn.php @@ -1,12 +1,14 @@ - $left < $right, - self::LessThanOrEqual => $left <= $right, - self::GreaterThan => $left > $right, + self::LessThan => $left < $right, + self::LessThanOrEqual => $left <= $right, + self::GreaterThan => $left > $right, self::GreaterThanOrEqual => $left >= $right, - self::Equal => ($left <=> $right) === 0, - self::NotEqual => ($left <=> $right) !== 0, + self::Equal => ($left <=> $right) === 0, + self::NotEqual => ($left <=> $right) !== 0, }; } } diff --git a/src/Rules/Database/ModelsExist.php b/src/Rules/Database/ModelsExist.php index 0c0db43..b72d47f 100644 --- a/src/Rules/Database/ModelsExist.php +++ b/src/Rules/Database/ModelsExist.php @@ -1,11 +1,13 @@ - $model Model to look the values up against. - * @param string|null $column Defaults to the model's route key. + * @param class-string $model Model to look the values up against. + * @param string|null $column Defaults to the model's route key. */ public function __construct( private string $model, @@ -69,7 +71,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void /** @var list $identifiers */ $identifiers = array_keys($wanted); - $model = new $this->model(); + $model = new $this->model; $column = $this->column ?? $model->getRouteKeyName(); // Query builder rather than the Eloquent one, matching diff --git a/src/Rules/Email/EmailDomainIs.php b/src/Rules/Email/EmailDomainIs.php index c1c0934..3d25c5a 100644 --- a/src/Rules/Email/EmailDomainIs.php +++ b/src/Rules/Email/EmailDomainIs.php @@ -1,4 +1,6 @@ - $domains */ public function __construct(private array $domains) {} + /** + * The matching itself lives in {@see HostPattern}, because the URL rules + * need exactly these semantics for their host lists. Two copies of an + * allow-list matcher is two chances to leave a gap, and a gap in one would + * be a bypass in the other. + * + * @param list $patterns + */ + public static function matches(string $domain, array $patterns): bool + { + return HostPattern::matches($domain, $patterns); + } + public function validate(string $attribute, mixed $value, Closure $fail): void { $address = Address::split($value); @@ -48,17 +63,4 @@ public function validate(string $attribute, mixed $value, Closure $fail): void ->translate(['domains' => implode(', ', $this->domains)]); } } - - /** - * The matching itself lives in {@see HostPattern}, because the URL rules - * need exactly these semantics for their host lists. Two copies of an - * allow-list matcher is two chances to leave a gap, and a gap in one would - * be a bypass in the other. - * - * @param list $patterns - */ - public static function matches(string $domain, array $patterns): bool - { - return HostPattern::matches($domain, $patterns); - } } diff --git a/src/Rules/Email/EmailDomainIsNot.php b/src/Rules/Email/EmailDomainIsNot.php index 81ce02b..2b0fd86 100644 --- a/src/Rules/Email/EmailDomainIsNot.php +++ b/src/Rules/Email/EmailDomainIsNot.php @@ -1,4 +1,6 @@ -translate(); } } - - public static function passes(mixed $value): bool - { - $address = Address::split($value); - - return $address !== null && ! str_contains($address[0], '+'); - } } diff --git a/src/Rules/Email/NotDisposableEmail.php b/src/Rules/Email/NotDisposableEmail.php index 7c2d27e..1fd869a 100644 --- a/src/Rules/Email/NotDisposableEmail.php +++ b/src/Rules/Email/NotDisposableEmail.php @@ -1,11 +1,13 @@ -domains ??= resolve(DisposableDomainList::class); - } - public function validate(string $attribute, mixed $value, Closure $fail): void { $address = Address::split($value); @@ -53,4 +50,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $fail('laranail/validation::validation.email.disposable')->translate(); } } + + private function domains(): DisposableDomainList + { + return $this->domains ??= resolve(DisposableDomainList::class); + } } diff --git a/src/Rules/Email/NotRoleEmail.php b/src/Rules/Email/NotRoleEmail.php index abc0a25..5de175c 100644 --- a/src/Rules/Email/NotRoleEmail.php +++ b/src/Rules/Email/NotRoleEmail.php @@ -1,11 +1,13 @@ -localParts ??= resolve(RoleAccountList::class); - } - public function validate(string $attribute, mixed $value, Closure $fail): void { $address = Address::split($value); @@ -60,4 +57,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $fail('laranail/validation::validation.email.role')->translate(); } } + + private function localParts(): RoleAccountList + { + return $this->localParts ??= resolve(RoleAccountList::class); + } } diff --git a/src/Rules/Email/Support/Address.php b/src/Rules/Email/Support/Address.php index 70f7dde..b2eae18 100644 --- a/src/Rules/Email/Support/Address.php +++ b/src/Rules/Email/Support/Address.php @@ -1,4 +1,6 @@ -translate(); - } - } - public static function passes(string $value): bool { $decoded = base64_decode($value, true); return $decoded !== false && base64_encode($decoded) === $value; } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || ! self::passes($value)) { + $fail('laranail/validation::validation.base64')->translate(); + } + } } diff --git a/src/Rules/Encoding/Base64Image.php b/src/Rules/Encoding/Base64Image.php index 0587c41..62cc327 100644 --- a/src/Rules/Encoding/Base64Image.php +++ b/src/Rules/Encoding/Base64Image.php @@ -1,10 +1,12 @@ - $mimes Accepted `image/*` subtypes. + * @param list $mimes Accepted `image/*` subtypes. */ public function __construct( private array $mimes = ['jpeg', 'png', 'gif', 'webp', 'bmp'], private ?int $maxBytes = null, ) {} + /** + * The decoded bytes, from the bare or data-URI form — null when the + * value is not canonical base64. + */ + public static function decode(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + if (str_starts_with($value, 'data:')) { + $comma = strpos($value, ','); + + if ($comma === false || ! str_contains(substr($value, 0, $comma), ';base64')) { + return null; + } + + $value = substr($value, $comma + 1); + } + + if (! Base64::passes($value)) { + return null; + } + + return (string) base64_decode($value, true); + } + public function validate(string $attribute, mixed $value, Closure $fail): void { $bytes = self::decode($value); @@ -63,31 +92,4 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $fail('laranail/validation::validation.base64_image')->translate(); } } - - /** - * The decoded bytes, from the bare or data-URI form — null when the - * value is not canonical base64. - */ - public static function decode(mixed $value): ?string - { - if (! is_string($value)) { - return null; - } - - if (str_starts_with($value, 'data:')) { - $comma = strpos($value, ','); - - if ($comma === false || ! str_contains(substr($value, 0, $comma), ';base64')) { - return null; - } - - $value = substr($value, $comma + 1); - } - - if (! Base64::passes($value)) { - return null; - } - - return (string) base64_decode($value, true); - } } diff --git a/src/Rules/Encoding/DataUri.php b/src/Rules/Encoding/DataUri.php index baa9f54..6292cb9 100644 --- a/src/Rules/Encoding/DataUri.php +++ b/src/Rules/Encoding/DataUri.php @@ -1,4 +1,6 @@ - $mediaTypes Accepted declared types, exact or `family/*`. + * @param list $mediaTypes Accepted declared types, exact or `family/*`. */ public function __construct(private array $mediaTypes = []) {} diff --git a/src/Rules/Fiscal/NationalIdentifier.php b/src/Rules/Fiscal/NationalIdentifier.php index d2806fc..eeac3a8 100644 --- a/src/Rules/Fiscal/NationalIdentifier.php +++ b/src/Rules/Fiscal/NationalIdentifier.php @@ -1,4 +1,6 @@ -country)) { - $fail('laranail/validation::validation.national_identifier') - ->translate(['country' => mb_strtoupper(trim($this->country))]); - } - } - public static function passes(mixed $value, string $country): bool { if (! is_string($value)) { @@ -70,10 +64,18 @@ public static function passes(mixed $value, string $country): bool self::GB => self::britishNino($value), self::FR => self::frenchNir($value), self::VN => self::vietnameseCccd($value), - default => false, + default => false, }; } + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value, $this->country)) { + $fail('laranail/validation::validation.national_identifier') + ->translate(['country' => mb_strtoupper(trim($this->country))]); + } + } + /** * The 11-proef: digits weighted 9..2, the last weighted -1, and the total * divisible by 11. A BSN is 8 or 9 digits; the 8-digit form is the same @@ -93,7 +95,7 @@ private static function dutchBsn(string $value): bool $sum = 0; - for ($i = 0; $i < 9; ++$i) { + for ($i = 0; $i < 9; $i++) { // The final digit carries weight -1, which is what makes this a // check rather than a plain weighted sum. $sum += (int) $digits[$i] * ($i === 8 ? -1 : 9 - $i); @@ -123,7 +125,7 @@ private static function brazilianCpf(string $value): bool foreach ([9, 10] as $length) { $sum = 0; - for ($i = 0; $i < $length; ++$i) { + for ($i = 0; $i < $length; $i++) { $sum += (int) $digits[$i] * ($length + 1 - $i); } diff --git a/src/Rules/Fiscal/VatNumber.php b/src/Rules/Fiscal/VatNumber.php index 5878e97..b13cb01 100644 --- a/src/Rules/Fiscal/VatNumber.php +++ b/src/Rules/Fiscal/VatNumber.php @@ -1,4 +1,6 @@ -|null $countries Accepted prefixes; null accepts every known one. + * @param list|null $countries Accepted prefixes; null accepts every known one. */ public function __construct(private ?array $countries = null) {} @@ -90,14 +92,14 @@ private function passes(string $value): bool } return match ($country) { - 'NL' => $this->dutch($normalised, $tail), - 'BE' => (int) substr($tail, 8, 2) === 97 - ((int) substr($tail, 0, 8) % 97), - 'DE' => $this->germanMod11x10($tail), - 'IT' => $this->italianLuhn($tail), - 'SE' => str_ends_with($tail, '01') && $this->luhnPasses(substr($tail, 0, 10)), - 'EL' => $this->greekPowerSum($tail), - 'LU' => (int) substr($tail, 6, 2) === (int) substr($tail, 0, 6) % 89, - 'FR' => $this->frenchKey($tail), + 'NL' => $this->dutch($normalised, $tail), + 'BE' => (int) substr($tail, 8, 2) === 97 - ((int) substr($tail, 0, 8) % 97), + 'DE' => $this->germanMod11x10($tail), + 'IT' => $this->italianLuhn($tail), + 'SE' => str_ends_with($tail, '01') && $this->luhnPasses(substr($tail, 0, 10)), + 'EL' => $this->greekPowerSum($tail), + 'LU' => (int) substr($tail, 6, 2) === (int) substr($tail, 0, 6) % 89, + 'FR' => $this->frenchKey($tail), default => true, }; } @@ -107,7 +109,7 @@ private function dutch(string $full, string $tail): bool { $sum = 0; - for ($i = 0; $i < 8; ++$i) { + for ($i = 0; $i < 8; $i++) { $sum += ((int) $tail[$i]) * (9 - $i); } @@ -136,7 +138,7 @@ private function germanMod11x10(string $digits): bool { $product = 10; - for ($i = 0; $i < 8; ++$i) { + for ($i = 0; $i < 8; $i++) { $sum = (((int) $digits[$i]) + $product) % 10; if ($sum === 0) { @@ -177,7 +179,7 @@ private function greekPowerSum(string $digits): bool { $sum = 0; - for ($i = 0; $i < 8; ++$i) { + for ($i = 0; $i < 8; $i++) { $sum += ((int) $digits[$i]) * (2 ** (8 - $i)); } @@ -201,7 +203,7 @@ private function luhnPasses(string $digits): bool $sum = 0; $double = false; - for ($i = strlen($digits) - 1; $i >= 0; --$i) { + for ($i = strlen($digits) - 1; $i >= 0; $i--) { $digit = (int) $digits[$i]; if ($double) { diff --git a/src/Rules/Geo/CaProvince.php b/src/Rules/Geo/CaProvince.php index 3f84432..8c45992 100644 --- a/src/Rules/Geo/CaProvince.php +++ b/src/Rules/Geo/CaProvince.php @@ -1,4 +1,6 @@ - 'Newfoundland & Labrador', ]; - public function validate(string $attribute, mixed $value, Closure $fail): void - { - if (! self::passes($value)) { - $fail('laranail/validation::validation.ca_province')->translate(); - } - } - public static function passes(mixed $value): bool { if (! is_string($value)) { @@ -60,4 +55,11 @@ public static function passes(mixed $value): bool return Subdivisions::contains($value, self::PROVINCES) || Subdivisions::contains($value, self::ALIASES); } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value)) { + $fail('laranail/validation::validation.ca_province')->translate(); + } + } } diff --git a/src/Rules/Geo/Coordinate.php b/src/Rules/Geo/Coordinate.php index efe914b..c3cf9c9 100644 --- a/src/Rules/Geo/Coordinate.php +++ b/src/Rules/Geo/Coordinate.php @@ -1,4 +1,6 @@ -translate(); - } - } - public static function passes(mixed $value): bool { if (! is_string($value) || substr_count($value, ',') !== 1) { @@ -41,4 +36,11 @@ public static function passes(mixed $value): bool return Coordinate::isWithin($latitude, 90.0) && Coordinate::isWithin($longitude, 180.0); } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value)) { + $fail('laranail/validation::validation.lat_lng')->translate(); + } + } } diff --git a/src/Rules/Geo/Latitude.php b/src/Rules/Geo/Latitude.php index 871774c..c8d2741 100644 --- a/src/Rules/Geo/Latitude.php +++ b/src/Rules/Geo/Latitude.php @@ -1,4 +1,6 @@ -|array>> */ /** diff --git a/src/Rules/Geo/Longitude.php b/src/Rules/Geo/Longitude.php index 50637d8..f63b212 100644 --- a/src/Rules/Geo/Longitude.php +++ b/src/Rules/Geo/Longitude.php @@ -1,4 +1,6 @@ -|array>> */ /** diff --git a/src/Rules/Geo/Subdivisions.php b/src/Rules/Geo/Subdivisions.php index ff8da3d..5f45b95 100644 --- a/src/Rules/Geo/Subdivisions.php +++ b/src/Rules/Geo/Subdivisions.php @@ -1,4 +1,6 @@ - $subdivisions code => name + * @param array $subdivisions code => name */ public static function contains(string $value, array $subdivisions): bool { diff --git a/src/Rules/Geo/UsState.php b/src/Rules/Geo/UsState.php index 6a25728..a74a7da 100644 --- a/src/Rules/Geo/UsState.php +++ b/src/Rules/Geo/UsState.php @@ -1,4 +1,6 @@ -includeTerritories)) { - $fail('laranail/validation::validation.us_state')->translate(); - } - } - public static function passes(mixed $value, bool $includeTerritories = false): bool { if (! is_string($value)) { @@ -68,4 +63,11 @@ public static function passes(mixed $value, bool $includeTerritories = false): b $includeTerritories ? self::STATES + self::TERRITORIES : self::STATES, ); } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value, $this->includeTerritories)) { + $fail('laranail/validation::validation.us_state')->translate(); + } + } } diff --git a/src/Rules/I18n/CountryCode.php b/src/Rules/I18n/CountryCode.php index c2d1310..64476be 100644 --- a/src/Rules/I18n/CountryCode.php +++ b/src/Rules/I18n/CountryCode.php @@ -1,4 +1,6 @@ -numeric => $this->dataset()->isNumericCode($value), - $this->symbol => $this->dataset()->isSymbol($value), - default => $this->dataset()->isCode($this->caseInsensitive ? strtoupper($value) : $value), + $this->symbol => $this->dataset()->isSymbol($value), + default => $this->dataset()->isCode($this->caseInsensitive ? strtoupper($value) : $value), }; if (! $passes) { @@ -62,8 +64,8 @@ private function fail(Closure $fail): void { $key = match (true) { $this->numeric => 'laranail/validation::validation.currency_code_numeric', - $this->symbol => 'laranail/validation::validation.currency_symbol', - default => 'laranail/validation::validation.currency_code', + $this->symbol => 'laranail/validation::validation.currency_symbol', + default => 'laranail/validation::validation.currency_code', }; $fail($key)->translate(); diff --git a/src/Rules/I18n/LanguageCode.php b/src/Rules/I18n/LanguageCode.php index dc66250..2d001d4 100644 --- a/src/Rules/I18n/LanguageCode.php +++ b/src/Rules/I18n/LanguageCode.php @@ -1,4 +1,6 @@ - 8, - 'crc32' => 8, - 'crc32b' => 8, - 'crc32c' => 8, - 'md4' => 32, - 'md5' => 32, - 'ripemd128' => 32, - 'ripemd160' => 40, - 'ripemd256' => 64, - 'ripemd320' => 80, - 'sha1' => 40, - 'sha224' => 56, - 'sha256' => 64, - 'sha384' => 96, - 'sha512' => 128, + 'adler32' => 8, + 'crc32' => 8, + 'crc32b' => 8, + 'crc32c' => 8, + 'md4' => 32, + 'md5' => 32, + 'ripemd128' => 32, + 'ripemd160' => 40, + 'ripemd256' => 64, + 'ripemd320' => 80, + 'sha1' => 40, + 'sha224' => 56, + 'sha256' => 64, + 'sha384' => 96, + 'sha512' => 128, 'sha512/224' => 56, 'sha512/256' => 64, - 'sha3-224' => 56, - 'sha3-256' => 64, - 'sha3-384' => 96, - 'sha3-512' => 128, - 'tiger128' => 32, - 'tiger160' => 40, - 'tiger192' => 48, - 'whirlpool' => 128, - 'xxh32' => 8, - 'xxh64' => 16, - 'xxh128' => 32, + 'sha3-224' => 56, + 'sha3-256' => 64, + 'sha3-384' => 96, + 'sha3-512' => 128, + 'tiger128' => 32, + 'tiger160' => 40, + 'tiger192' => 48, + 'whirlpool' => 128, + 'xxh32' => 8, + 'xxh64' => 16, + 'xxh128' => 32, ]; private int $length; diff --git a/src/Rules/Identifiers/Imei.php b/src/Rules/Identifiers/Imei.php index 9552f7d..bc063b6 100644 --- a/src/Rules/Identifiers/Imei.php +++ b/src/Rules/Identifiers/Imei.php @@ -1,4 +1,6 @@ -translate(); - } - } - public static function passes(mixed $value): bool { if (! is_string($value) && ! is_int($value)) { @@ -42,4 +37,11 @@ public static function passes(mixed $value): bool return Luhn::passes($imei); } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value)) { + $fail('laranail/validation::validation.imei')->translate(); + } + } } diff --git a/src/Rules/Identifiers/Jwt.php b/src/Rules/Identifiers/Jwt.php index cf57892..4b3c521 100644 --- a/src/Rules/Identifiers/Jwt.php +++ b/src/Rules/Identifiers/Jwt.php @@ -1,4 +1,6 @@ -translate(); - } - } - public static function passes(mixed $value): bool { if (! is_string($value) || preg_match(self::PATTERN, $value) !== 1) { @@ -45,6 +40,13 @@ public static function passes(mixed $value): bool return is_array($header) && isset($header['alg']); } + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value)) { + $fail('laranail/validation::validation.jwt')->translate(); + } + } + /** * base64url differs from base64 in two ways: `-`/`_` replace `+`/`/`, and * the `=` padding is dropped. Both have to be undone before decoding. diff --git a/src/Rules/Identifiers/SemVer.php b/src/Rules/Identifiers/SemVer.php index f66deb5..f05b1af 100644 --- a/src/Rules/Identifiers/SemVer.php +++ b/src/Rules/Identifiers/SemVer.php @@ -1,4 +1,6 @@ -checkDigit)) { - $fail('laranail/validation::validation.vin')->translate(); - } - } - public static function passes(mixed $value, bool $checkDigit = false): bool { if (! is_string($value)) { @@ -64,6 +59,13 @@ public static function passes(mixed $value, bool $checkDigit = false): bool return ! $checkDigit || self::checkDigitIsValid($vin); } + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value, $this->checkDigit)) { + $fail('laranail/validation::validation.vin')->translate(); + } + } + private static function checkDigitIsValid(string $vin): bool { $sum = 0; diff --git a/src/Rules/Markup/Xml.php b/src/Rules/Markup/Xml.php index 9ba0124..55d0b78 100644 --- a/src/Rules/Markup/Xml.php +++ b/src/Rules/Markup/Xml.php @@ -1,11 +1,13 @@ -translate(); - } - } - public static function passes(mixed $value): bool { if (! is_string($value) || substr_count($value, '/') !== 1) { @@ -49,4 +44,11 @@ public static function passes(mixed $value): bool return (int) $prefix <= $max; } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value)) { + $fail('laranail/validation::validation.cidr')->translate(); + } + } } diff --git a/src/Rules/Net/DomainName.php b/src/Rules/Net/DomainName.php index ef10c81..ea7b151 100644 --- a/src/Rules/Net/DomainName.php +++ b/src/Rules/Net/DomainName.php @@ -1,4 +1,6 @@ -requireTld)) { - $fail('laranail/validation::validation.domain_name')->translate(); - } - } - public static function passes(mixed $value, bool $requireTld = true): bool { if (! is_string($value) || $value === '') { @@ -86,6 +81,13 @@ public static function supportsInternationalNames(): bool return function_exists('idn_to_ascii'); } + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value, $this->requireTld)) { + $fail('laranail/validation::validation.domain_name')->translate(); + } + } + /** * Returns null when the value cannot be represented as ASCII at all. */ diff --git a/src/Rules/Net/InCidrRange.php b/src/Rules/Net/InCidrRange.php index 4944ec4..ed52375 100644 --- a/src/Rules/Net/InCidrRange.php +++ b/src/Rules/Net/InCidrRange.php @@ -1,4 +1,6 @@ - $networks CIDR notation; an invalid entry never matches. */ public function __construct(private array $networks) {} - public function validate(string $attribute, mixed $value, Closure $fail): void - { - if (! self::passes($value, $this->networks)) { - $fail('laranail/validation::validation.in_cidr_range') - ->translate(['networks' => implode(', ', $this->networks)]); - } - } - /** - * @param list $networks + * @param list $networks */ public static function passes(mixed $value, array $networks): bool { @@ -86,6 +80,14 @@ public static function contains(string $network, string $address): bool return self::sharesPrefix($packedAddress, $packedBase, (int) $prefix); } + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value, $this->networks)) { + $fail('laranail/validation::validation.in_cidr_range') + ->translate(['networks' => implode(', ', $this->networks)]); + } + } + /** `::ffff:10.0.0.1` is 10.0.0.1; anything else is returned unchanged. */ private static function unwrap(string $address): string { diff --git a/src/Rules/Net/MacAddress.php b/src/Rules/Net/MacAddress.php index c8b5a74..1202921 100644 --- a/src/Rules/Net/MacAddress.php +++ b/src/Rules/Net/MacAddress.php @@ -1,4 +1,6 @@ - $formats Accepted notations; empty accepts all four. - * @param int|null $bytes 6 for EUI-48, 8 for EUI-64; null accepts either. - * @param bool $requireUnicast Reject multicast addresses (I/G bit set). - * @param bool $requireUniversal Reject locally-administered addresses (U/L bit set). - * @param list $ouis Accepted OUI prefixes, in any notation; empty accepts any. + * @param list $formats Accepted notations; empty accepts all four. + * @param int|null $bytes 6 for EUI-48, 8 for EUI-64; null accepts either. + * @param bool $requireUnicast Reject multicast addresses (I/G bit set). + * @param bool $requireUniversal Reject locally-administered addresses (U/L bit set). + * @param list $ouis Accepted OUI prefixes, in any notation; empty accepts any. */ public function __construct( private array $formats = [], @@ -62,72 +64,6 @@ public function __construct( private array $ouis = [], ) {} - public function validate(string $attribute, mixed $value, Closure $fail): void - { - if (! is_string($value)) { - $fail('laranail/validation::validation.mac_address.malformed')->translate(); - - return; - } - - $format = self::formatOf($value); - - if ($format === null) { - $fail('laranail/validation::validation.mac_address.malformed')->translate(); - - return; - } - - if ($this->formats !== [] && ! in_array($format, $this->formats, true)) { - $fail('laranail/validation::validation.mac_address.format') - ->translate(['formats' => implode(', ', $this->formats)]); - - return; - } - - $octets = self::octets($value); - - if ($this->bytes !== null && count($octets) !== $this->bytes) { - $fail('laranail/validation::validation.mac_address.length') - ->translate(['bytes' => $this->bytes]); - - return; - } - - // Before the bit checks, because both of these have the bits of a - // perfectly ordinary address and neither names a device — reporting - // "must be unicast" for the broadcast address would be true and - // useless. - if ($this->isBroadcast($octets)) { - $fail('laranail/validation::validation.mac_address.broadcast')->translate(); - - return; - } - - if ($this->isNull($octets)) { - $fail('laranail/validation::validation.mac_address.null')->translate(); - - return; - } - - if ($this->requireUnicast && (($octets[0] & 0b1) !== 0)) { - $fail('laranail/validation::validation.mac_address.multicast')->translate(); - - return; - } - - if ($this->requireUniversal && (($octets[0] & 0b10) !== 0)) { - $fail('laranail/validation::validation.mac_address.local')->translate(); - - return; - } - - if ($this->ouis !== [] && ! $this->matchesAnyOui($octets, $this->ouis)) { - $fail('laranail/validation::validation.mac_address.oui') - ->translate(['ouis' => implode(', ', $this->ouis)]); - } - } - public static function passes(mixed $value): bool { return is_string($value) && self::formatOf($value) !== null; @@ -199,6 +135,72 @@ public static function normalise(string $value): ?string )); } + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value)) { + $fail('laranail/validation::validation.mac_address.malformed')->translate(); + + return; + } + + $format = self::formatOf($value); + + if ($format === null) { + $fail('laranail/validation::validation.mac_address.malformed')->translate(); + + return; + } + + if ($this->formats !== [] && ! in_array($format, $this->formats, true)) { + $fail('laranail/validation::validation.mac_address.format') + ->translate(['formats' => implode(', ', $this->formats)]); + + return; + } + + $octets = self::octets($value); + + if ($this->bytes !== null && count($octets) !== $this->bytes) { + $fail('laranail/validation::validation.mac_address.length') + ->translate(['bytes' => $this->bytes]); + + return; + } + + // Before the bit checks, because both of these have the bits of a + // perfectly ordinary address and neither names a device — reporting + // "must be unicast" for the broadcast address would be true and + // useless. + if ($this->isBroadcast($octets)) { + $fail('laranail/validation::validation.mac_address.broadcast')->translate(); + + return; + } + + if ($this->isNull($octets)) { + $fail('laranail/validation::validation.mac_address.null')->translate(); + + return; + } + + if ($this->requireUnicast && (($octets[0] & 0b1) !== 0)) { + $fail('laranail/validation::validation.mac_address.multicast')->translate(); + + return; + } + + if ($this->requireUniversal && (($octets[0] & 0b10) !== 0)) { + $fail('laranail/validation::validation.mac_address.local')->translate(); + + return; + } + + if ($this->ouis !== [] && ! $this->matchesAnyOui($octets, $this->ouis)) { + $fail('laranail/validation::validation.mac_address.oui') + ->translate(['ouis' => implode(', ', $this->ouis)]); + } + } + /** @param list $octets */ private function isBroadcast(array $octets): bool { @@ -218,8 +220,8 @@ private function isNull(array $octets): bool } /** - * @param list $octets - * @param list $ouis + * @param list $octets + * @param list $ouis */ private function matchesAnyOui(array $octets, array $ouis): bool { diff --git a/src/Rules/Net/PrivateIp.php b/src/Rules/Net/PrivateIp.php index 9ee573c..a48bb82 100644 --- a/src/Rules/Net/PrivateIp.php +++ b/src/Rules/Net/PrivateIp.php @@ -1,4 +1,6 @@ -translate(); - } - } - public static function passes(mixed $value): bool { if (! is_string($value) || preg_match(self::PATTERN, $value) !== 1) { @@ -41,6 +36,13 @@ public static function passes(mixed $value): bool return ! str_starts_with(strtolower($value), 'xn--'); } + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value)) { + $fail('laranail/validation::validation.subdomain')->translate(); + } + } + /** * The whole check is this pattern, so the browser can run the same one * rather than a hand-written twin that would drift from it. diff --git a/src/Rules/Net/Support/HostPattern.php b/src/Rules/Net/Support/HostPattern.php index ffd24bf..1a132d2 100644 --- a/src/Rules/Net/Support/HostPattern.php +++ b/src/Rules/Net/Support/HostPattern.php @@ -1,4 +1,6 @@ - $patterns + * @param list $patterns */ public static function matches(string $host, array $patterns): bool { diff --git a/src/Rules/Net/Support/IpClassifier.php b/src/Rules/Net/Support/IpClassifier.php index 50deb3d..cbe206b 100644 --- a/src/Rules/Net/Support/IpClassifier.php +++ b/src/Rules/Net/Support/IpClassifier.php @@ -1,9 +1,11 @@ - $ranges + * @param list $ranges */ private static function matchesAny(string $ip, array $ranges): bool { diff --git a/src/Rules/Net/Url.php b/src/Rules/Net/Url.php index fb13a44..ccf5e20 100644 --- a/src/Rules/Net/Url.php +++ b/src/Rules/Net/Url.php @@ -1,4 +1,6 @@ - 80, 'https' => 443, 'ftp' => 21, 'ftps' => 990, 'ws' => 80, 'wss' => 443]; /** - * @param list $schemes Accepted schemes, lowercase, without `://`. - * @param list $hosts Allow-list; `*.example.com` matches subdomains only. - * @param list $blockedHosts Deny-list, same syntax, applied after the allow-list. - * @param list $ports Accepted ports; empty accepts any, including none. - * @param bool $allowCredentials Permit `user:pass@` in the authority. - * @param bool $allowIpHost Permit an IP literal instead of a name. - * @param bool $publicHostOnly Reject reserved IP literals and loopback names. - * @param bool $requireTld Reject single-label hosts such as `intranet`. + * @param list $schemes Accepted schemes, lowercase, without `://`. + * @param list $hosts Allow-list; `*.example.com` matches subdomains only. + * @param list $blockedHosts Deny-list, same syntax, applied after the allow-list. + * @param list $ports Accepted ports; empty accepts any, including none. + * @param bool $allowCredentials Permit `user:pass@` in the authority. + * @param bool $allowIpHost Permit an IP literal instead of a name. + * @param bool $publicHostOnly Reject reserved IP literals and loopback names. + * @param bool $requireTld Reject single-label hosts such as `intranet`. */ public function __construct( private array $schemes = ['http', 'https'], diff --git a/src/Rules/Network/DeliverableEmail.php b/src/Rules/Network/DeliverableEmail.php index 960055e..cdfce13 100644 --- a/src/Rules/Network/DeliverableEmail.php +++ b/src/Rules/Network/DeliverableEmail.php @@ -1,4 +1,6 @@ - $mimes Accepted `image/*` subtypes; empty accepts any image. + * @param list $mimes Accepted `image/*` subtypes; empty accepts any image. */ public function __construct( private array $mimes = [], diff --git a/src/Rules/Numbers/MonetaryAmount.php b/src/Rules/Numbers/MonetaryAmount.php index aa57ade..db3dca7 100644 --- a/src/Rules/Numbers/MonetaryAmount.php +++ b/src/Rules/Numbers/MonetaryAmount.php @@ -1,4 +1,6 @@ -decimals, $this->allowNegative)) { - $fail('laranail/validation::validation.monetary_amount') - ->translate(['decimals' => $this->decimals]); - } - } - public static function passes(mixed $value, int $decimals = 2, bool $allowNegative = false): bool { if (is_int($value) || is_float($value)) { @@ -80,6 +74,14 @@ public static function pattern(int $decimals = 2, bool $allowNegative = false): return '/^' . $sign . '\\d+' . $fraction . '$/D'; } + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value, $this->decimals, $this->allowNegative)) { + $fail('laranail/validation::validation.monetary_amount') + ->translate(['decimals' => $this->decimals]); + } + } + /** * @return list}> */ diff --git a/src/Rules/Numbers/Parity.php b/src/Rules/Numbers/Parity.php index 4d78b2f..a243b24 100644 --- a/src/Rules/Numbers/Parity.php +++ b/src/Rules/Numbers/Parity.php @@ -1,4 +1,6 @@ -parity)) { - $fail('laranail/validation::validation.parity.' . $this->normalised())->translate(); - } - } - public static function passes(mixed $value, string $parity): bool { $integer = self::asInteger($value); @@ -53,16 +48,16 @@ public static function passes(mixed $value, string $parity): bool return match (mb_strtolower(trim($parity))) { self::EVEN => $remainder === 0, - self::ODD => $remainder === 1, - default => false, + self::ODD => $remainder === 1, + default => false, }; } - private function normalised(): string + public function validate(string $attribute, mixed $value, Closure $fail): void { - $parity = mb_strtolower(trim($this->parity)); - - return $parity === self::ODD ? self::ODD : self::EVEN; + if (! self::passes($value, $this->parity)) { + $fail('laranail/validation::validation.parity.' . $this->normalised())->translate(); + } } private static function asInteger(mixed $value): ?int @@ -87,4 +82,11 @@ private static function asInteger(mixed $value): ?int // says exactly what is meant. return preg_match('/^[+-]?\d+$/D', $trimmed) === 1 ? (int) $trimmed : null; } + + private function normalised(): string + { + $parity = mb_strtolower(trim($this->parity)); + + return $parity === self::ODD ? self::ODD : self::EVEN; + } } diff --git a/src/Rules/Payment/CardCvc.php b/src/Rules/Payment/CardCvc.php index 23a73ec..6232723 100644 --- a/src/Rules/Payment/CardCvc.php +++ b/src/Rules/Payment/CardCvc.php @@ -1,13 +1,15 @@ -|null $brands Accepted brand slugs; null accepts the whole catalogue. + * @param list|null $brands Accepted brand slugs; null accepts the whole catalogue. */ public function __construct( private readonly ?array $brands = null, private ?CardBrandCatalogue $catalogue = null, ) {} + public static function luhnPasses(string $digits): bool + { + $sum = 0; + $double = false; + + for ($i = strlen($digits) - 1; $i >= 0; $i--) { + $digit = (int) $digits[$i]; + + if ($double) { + $digit *= 2; + + if ($digit > 9) { + $digit -= 9; + } + } + + $sum += $digit; + $double = ! $double; + } + + return $sum % 10 === 0; + } + public function validate(string $attribute, mixed $value, Closure $fail): void { if (! is_string($value)) { @@ -74,7 +99,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void if (! in_array(strlen($digits), $brand->lengths, true)) { $this->fail($fail, 'card_number_length', [ 'lengths' => implode(' or ', $brand->lengths), - 'brand' => $brand->displayName, + 'brand' => $brand->displayName, ]); return; @@ -85,37 +110,14 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } } - public static function luhnPasses(string $digits): bool - { - $sum = 0; - $double = false; - - for ($i = strlen($digits) - 1; $i >= 0; --$i) { - $digit = (int) $digits[$i]; - - if ($double) { - $digit *= 2; - - if ($digit > 9) { - $digit -= 9; - } - } - - $sum += $digit; - $double = ! $double; - } - - return $sum % 10 === 0; - } - private function catalogue(): CardBrandCatalogue { return $this->catalogue ??= resolve(CardBrandCatalogue::class); } /** - * @param Closure(string): PotentiallyTranslatedString $fail - * @param array $parameters + * @param Closure(string): PotentiallyTranslatedString $fail + * @param array $parameters */ private function fail(Closure $fail, string $key, array $parameters = []): void { diff --git a/src/Rules/Postal/Patterns.php b/src/Rules/Postal/Patterns.php index 3dd9f8b..c278b28 100644 --- a/src/Rules/Postal/Patterns.php +++ b/src/Rules/Postal/Patterns.php @@ -1,4 +1,6 @@ - '/^[0-9]{5}$/D', 'AM, AR, AT, AU, BD, BE, BG, CH, CY, DK, GE, GL, HU, LI, LU, LV, MD, MK, NO, NZ,' . - 'PH, SI, TN, ZA' => '/^[0-9]{4}$/D', + 'PH, SI, TN, ZA' => '/^[0-9]{4}$/D', 'BY, CN, EC, IN, KG, KZ, RO, RU, SG, TJ' => '/^[0-9]{6}$/D', - 'CZ, GR, SE, SK' => '/^[0-9]{3} [0-9]{2}$/D', - 'FO, IS, MG, PG' => '/^[0-9]{3}$/D', - 'GG, JE' => '/^[a-z]{2}[0-9][0-9]? [0-9][a-z]{2}$/iD', - 'MV, MX' => '/^[0-9]{4}[0-9]?$/D', - 'AZ' => '/^[0-9]{4}([0-9]{2})?$/D', - 'BN' => '/^[a-z]{2}[0-9]{4}$/iD', - 'BR' => '/^[0-9]{5}(-?[0-9]{3})?$/D', + 'CZ, GR, SE, SK' => '/^[0-9]{3} [0-9]{2}$/D', + 'FO, IS, MG, PG' => '/^[0-9]{3}$/D', + 'GG, JE' => '/^[a-z]{2}[0-9][0-9]? [0-9][a-z]{2}$/iD', + 'MV, MX' => '/^[0-9]{4}[0-9]?$/D', + 'AZ' => '/^[0-9]{4}([0-9]{2})?$/D', + 'BN' => '/^[a-z]{2}[0-9]{4}$/iD', + 'BR' => '/^[0-9]{5}(-?[0-9]{3})?$/D', // Corrected from the source table, which made the final digit optional // and so accepted the five-character 'K1A 0B'. A Canadian postal code is // always A1A 1A1. Canada Post also excludes D, F, I, O, Q and U in every diff --git a/src/Rules/Postal/PostalCode.php b/src/Rules/Postal/PostalCode.php index 663f823..6c0a536 100644 --- a/src/Rules/Postal/PostalCode.php +++ b/src/Rules/Postal/PostalCode.php @@ -1,11 +1,13 @@ - $countries + * @param string|list $countries */ public function __construct(string|array $countries = [], private readonly ?string $countryField = null) { @@ -106,53 +108,6 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $fail('laranail/validation::validation.postal_code')->translate(); } - /** @return list */ - private function resolveCountries(string $attribute): array - { - if ($this->countryField === null) { - return $this->countries; - } - - $country = Arr::get($this->data, $this->siblingPath($attribute)); - - return is_string($country) && $country !== '' ? [strtoupper(trim($country))] : []; - } - - /** - * Rewrite a wildcard reference to point at the row currently being - * validated. Without this, `addresses.*.country` inside - * `addresses.2.postcode` would read `addresses.0.country` — every row - * validated against the first row's country, which is wrong in a way that - * only shows up when two rows differ. - */ - private function siblingPath(string $attribute): string - { - if (! str_contains((string) $this->countryField, '*')) { - return (string) $this->countryField; - } - - $keys = []; - foreach (explode('.', $attribute) as $segment) { - if (is_numeric($segment)) { - $keys[] = $segment; - } - } - - $path = (string) $this->countryField; - - foreach ($keys as $key) { - $position = strpos($path, '*'); - - if ($position === false) { - break; - } - - $path = substr_replace($path, $key, $position, 1); - } - - return $path; - } - /** * The named countries' patterns, or null when the country is not known * until validation time. @@ -218,4 +173,51 @@ private static function inlineFlags(string $pattern): string return $flags === '' ? $body : '(?' . $flags . ':' . $body . ')'; } + + /** @return list */ + private function resolveCountries(string $attribute): array + { + if ($this->countryField === null) { + return $this->countries; + } + + $country = Arr::get($this->data, $this->siblingPath($attribute)); + + return is_string($country) && $country !== '' ? [strtoupper(trim($country))] : []; + } + + /** + * Rewrite a wildcard reference to point at the row currently being + * validated. Without this, `addresses.*.country` inside + * `addresses.2.postcode` would read `addresses.0.country` — every row + * validated against the first row's country, which is wrong in a way that + * only shows up when two rows differ. + */ + private function siblingPath(string $attribute): string + { + if (! str_contains((string) $this->countryField, '*')) { + return (string) $this->countryField; + } + + $keys = []; + foreach (explode('.', $attribute) as $segment) { + if (is_numeric($segment)) { + $keys[] = $segment; + } + } + + $path = (string) $this->countryField; + + foreach ($keys as $key) { + $position = strpos($path, '*'); + + if ($position === false) { + break; + } + + $path = substr_replace($path, $key, $position, 1); + } + + return $path; + } } diff --git a/src/Rules/Profanity/NoProfanity.php b/src/Rules/Profanity/NoProfanity.php index d0795e2..dcb2046 100644 --- a/src/Rules/Profanity/NoProfanity.php +++ b/src/Rules/Profanity/NoProfanity.php @@ -1,10 +1,12 @@ - $terms - * @param list $allowed Ignored when $terms is a TermList, which carries its own. + * @param TermList|list $terms + * @param list $allowed Ignored when $terms is a TermList, which carries its own. */ public function __construct( private readonly TermList|array $terms, private readonly array $allowed = [], ) {} + /** + * Fold a value to the form the terms are compared against. + * + * Lowercase, decomposed to strip accents, and common character + * substitutions undone. Separators and repeated characters are left + * ALONE — {@see pattern()} absorbs those, and rewriting them here would + * destroy the word boundaries that stop `ass` matching inside `assess`. + */ + public static function normalise(string $value): string + { + $value = mb_strtolower(trim($value)); + + // Full-width and other compatibility forms first: shit is not + // otherwise the same string as shit. + if (class_exists(Normalizer::class)) { + $decomposed = Normalizer::normalize($value, Normalizer::FORM_KD); + + if (is_string($decomposed) && $decomposed !== '') { + $value = $decomposed; + } + } + + // Strip combining marks left by the decomposition, so shít folds to shit. + $stripped = preg_replace('/\p{M}+/u', '', $value); + + if (is_string($stripped)) { + $value = $stripped; + } + + $value = strtr($value, [ + '4' => 'a', '@' => 'a', '3' => 'e', '1' => 'i', '!' => 'i', + '0' => 'o', '5' => 's', '$' => 's', '7' => 't', '+' => 't', + ]); + + return trim($value); + } + public function validate(string $attribute, mixed $value, Closure $fail): void { if (! is_string($value)) { @@ -145,43 +184,6 @@ private function pattern(string $term): string return '/\b' . $body . '\b/u'; } - /** - * Fold a value to the form the terms are compared against. - * - * Lowercase, decomposed to strip accents, and common character - * substitutions undone. Separators and repeated characters are left - * ALONE — {@see pattern()} absorbs those, and rewriting them here would - * destroy the word boundaries that stop `ass` matching inside `assess`. - */ - public static function normalise(string $value): string - { - $value = mb_strtolower(trim($value)); - - // Full-width and other compatibility forms first: shit is not - // otherwise the same string as shit. - if (class_exists(Normalizer::class)) { - $decomposed = Normalizer::normalize($value, Normalizer::FORM_KD); - - if (is_string($decomposed) && $decomposed !== '') { - $value = $decomposed; - } - } - - // Strip combining marks left by the decomposition, so shít folds to shit. - $stripped = preg_replace('/\p{M}+/u', '', $value); - - if (is_string($stripped)) { - $value = $stripped; - } - - $value = strtr($value, [ - '4' => 'a', '@' => 'a', '3' => 'e', '1' => 'i', '!' => 'i', - '0' => 'o', '5' => 's', '$' => 's', '7' => 't', '+' => 't', - ]); - - return trim($value); - } - /** @return list */ private function terms(): array { @@ -195,7 +197,8 @@ private function allowed(): array } /** - * @param list $values + * @param list $values + * * @return list */ private function normaliseAll(array $values): array diff --git a/src/Rules/Storage/FileExistsOnDisk.php b/src/Rules/Storage/FileExistsOnDisk.php index 4c4b9a5..70be442 100644 --- a/src/Rules/Storage/FileExistsOnDisk.php +++ b/src/Rules/Storage/FileExistsOnDisk.php @@ -1,10 +1,12 @@ - $rules Applied to each item. - * @param string $separator Splits the string; not trimmed itself. - * @param int|null $min Fewest items required. - * @param int|null $max Most items allowed. - * @param bool $distinct Reject repeated items. - * @param bool $trim Trim whitespace around each item. + * @param list $rules Applied to each item. + * @param string $separator Splits the string; not trimmed itself. + * @param int|null $min Fewest items required. + * @param int|null $max Most items allowed. + * @param bool $distinct Reject repeated items. + * @param bool $trim Trim whitespace around each item. * * @throws InvalidArgumentException If the separator is empty. */ diff --git a/src/Rules/Telecom/Phone.php b/src/Rules/Telecom/Phone.php index 67fac89..93ea54e 100644 --- a/src/Rules/Telecom/Phone.php +++ b/src/Rules/Telecom/Phone.php @@ -1,15 +1,17 @@ - $countries ISO 3166-1 alpha-2 codes; empty means any country - * @param list $types Acceptable line types; empty means any + * @param list $countries ISO 3166-1 alpha-2 codes; empty means any country + * @param list $types Acceptable line types; empty means any */ public function __construct( private readonly array $countries = [], diff --git a/src/Rules/Telecom/UniquePhone.php b/src/Rules/Telecom/UniquePhone.php index 11b332a..773e434 100644 --- a/src/Rules/Telecom/UniquePhone.php +++ b/src/Rules/Telecom/UniquePhone.php @@ -1,15 +1,17 @@ - '/^[a-z][a-zA-Z0-9]*$/D', + self::CAMEL => '/^[a-z][a-zA-Z0-9]*$/D', self::PASCAL => '/^[A-Z][a-zA-Z0-9]*$/D', - self::SNAKE => '/^[a-z0-9]+(?:_[a-z0-9]+)*$/D', - self::KEBAB => '/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', + self::SNAKE => '/^[a-z0-9]+(?:_[a-z0-9]+)*$/D', + self::KEBAB => '/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', // Each space-separated word starts with a capital. Digits may follow. self::TITLE => '/^[A-Z][a-z0-9]*(?: [A-Z][a-z0-9]*)*$/D', ]; public function __construct(private string $style) {} - public function validate(string $attribute, mixed $value, Closure $fail): void - { - if (! self::passes($value, $this->style)) { - $fail('laranail/validation::validation.case_style.' . $this->style)->translate(); - } - } - public static function passes(mixed $value, string $style): bool { if (! is_string($value) || ! isset(self::PATTERNS[$style])) { @@ -65,6 +60,13 @@ public static function passes(mixed $value, string $style): bool return preg_match(self::PATTERNS[$style], $value) === 1; } + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::passes($value, $this->style)) { + $fail('laranail/validation::validation.case_style.' . $this->style)->translate(); + } + } + /** * The configured style's pattern, which is the whole check. * diff --git a/src/Rules/Text/HtmlClean.php b/src/Rules/Text/HtmlClean.php index f826a8f..ca6474f 100644 --- a/src/Rules/Text/HtmlClean.php +++ b/src/Rules/Text/HtmlClean.php @@ -1,4 +1,6 @@ -mustContainHtml) { @@ -55,13 +66,4 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $fail('laranail/validation::validation.html_clean')->translate(); } } - - public static function passes(mixed $value): bool - { - if (! is_string($value)) { - return false; - } - - return strip_tags($value) === $value; - } } diff --git a/src/Rules/Text/MaxWords.php b/src/Rules/Text/MaxWords.php index 05efa5e..ade6e58 100644 --- a/src/Rules/Text/MaxWords.php +++ b/src/Rules/Text/MaxWords.php @@ -1,10 +1,12 @@ - $this->max) { - $fail('laranail/validation::validation.max_words')->translate(['max' => $this->max]); - } - } - /** How many words the value contains, shared with {@see MinWords}. */ public static function count(string $value): int { @@ -40,4 +35,11 @@ public static function count(string $value): int return $words === false ? 0 : count($words); } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || self::count($value) > $this->max) { + $fail('laranail/validation::validation.max_words')->translate(['max' => $this->max]); + } + } } diff --git a/src/Rules/Text/MinWords.php b/src/Rules/Text/MinWords.php index 5833a2a..ad7824a 100644 --- a/src/Rules/Text/MinWords.php +++ b/src/Rules/Text/MinWords.php @@ -1,10 +1,12 @@ -= $minNames && ($maxNames === null || $count <= $maxNames); + } + public function validate(string $attribute, mixed $value, Closure $fail): void { if (! is_string($value) || ! self::hasNameCharacters($value, $this->allowDigits)) { @@ -93,17 +109,6 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } } - public static function passes(mixed $value, bool $allowDigits = false, int $minNames = 1, ?int $maxNames = null): bool - { - if (! is_string($value) || ! self::hasNameCharacters($value, $allowDigits)) { - return false; - } - - $count = self::countNames($value); - - return $count >= $minNames && ($maxNames === null || $count <= $maxNames); - } - /** * The rule's own patterns, so the browser runs the same expressions rather * than a hand-written twin that would drift from them. @@ -132,9 +137,6 @@ public function clientRules(): array return $rules; } - /** At least one actual letter: `'`, `-` and `.` are punctuation, not a name. */ - private const string LETTER = '/\p{L}/u'; - private static function hasNameCharacters(string $value, bool $allowDigits): bool { if (trim($value) === '') { diff --git a/src/Rules/Text/Salutation.php b/src/Rules/Text/Salutation.php index d4f9480..c7c5bdb 100644 --- a/src/Rules/Text/Salutation.php +++ b/src/Rules/Text/Salutation.php @@ -1,4 +1,6 @@ -|null */ - private readonly ?array $accepted; + private static ?array $bundled = null; /** @var array|null */ - private static ?array $bundled = null; + private readonly ?array $accepted; /** - * @param list|null $accepted Replacement list, lowercase without dots. + * @param list|null $accepted Replacement list, lowercase without dots. */ public function __construct(?array $accepted = null) { diff --git a/src/Rules/Text/Slug.php b/src/Rules/Text/Slug.php index c286943..8eab325 100644 --- a/src/Rules/Text/Slug.php +++ b/src/Rules/Text/Slug.php @@ -1,4 +1,6 @@ -|null $reserved Replaces the default list; `[]` disables the check. + * @param int $min Fewest characters, counting separators. + * @param int $max Most characters, counting separators. + * @param string $separators Which characters may appear between others. + * @param bool $lowercase Reject uppercase rather than accepting and folding it. + * @param list|null $reserved Replaces the default list; `[]` disables the check. */ public function __construct( private int $min = 3, @@ -69,23 +71,6 @@ public function __construct( private ?array $reserved = null, ) {} - public function validate(string $attribute, mixed $value, Closure $fail): void - { - if (! is_string($value) || preg_match(self::pattern($this->min, $this->max, $this->separators, $this->lowercase), $value) !== 1) { - // One message for the shape, because the shape is one idea and - // splitting it would produce "must not start with a separator", - // "must not contain two separators in a row" and three more, each - // reachable only by fixing the previous one. - $fail('laranail/validation::validation.username')->translate(); - - return; - } - - if (self::isReserved($value, $this->reserved ?? $this->boundReserved(), $this->separators)) { - $fail('laranail/validation::validation.username_reserved')->translate(); - } - } - /** @param list|null $reserved */ public static function passes( mixed $value, @@ -106,23 +91,6 @@ public static function passes( return ! self::isReserved($value, $reserved ?? self::DEFAULT_RESERVED, $separators); } - /** - * The application-bound list, or the floor when no container is booted — - * a rule set built in a queued job or plain unit test still validates. - * Uncached on purpose: this class is readonly, and the container binding - * may legitimately change between validations in long-lived workers. - * - * @return list - */ - private function boundReserved(): array - { - if (function_exists('app') && app()->bound(ReservedUsernameList::class)) { - return resolve(ReservedUsernameList::class)->names(); - } - - return self::DEFAULT_RESERVED; - } - /** * Shape and length in one pattern, so the rule and the form advertised to * a browser cannot disagree. @@ -133,7 +101,7 @@ private function boundReserved(): array * per character. That is what makes this rule expressible as a pattern at * all, and why a rule with a Unicode class could not do the same. * - * @param string $separators Escaped for a character class before use. + * @param string $separators Escaped for a character class before use. */ public static function pattern( int $min = 3, @@ -165,7 +133,7 @@ public static function pattern( * compared the literal value would let `a.d.m.i.n` and `ad-min` through, * and those are the same claim to anyone reading a profile page. * - * @param list $reserved + * @param list $reserved */ public static function isReserved(string $value, array $reserved, string $separators = self::DEFAULT_SEPARATORS): bool { @@ -195,6 +163,41 @@ public static function isReserved(string $value, array $reserved, string $separa return false; } + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || preg_match(self::pattern($this->min, $this->max, $this->separators, $this->lowercase), $value) !== 1) { + // One message for the shape, because the shape is one idea and + // splitting it would produce "must not start with a separator", + // "must not contain two separators in a row" and three more, each + // reachable only by fixing the previous one. + $fail('laranail/validation::validation.username')->translate(); + + return; + } + + if (self::isReserved($value, $this->reserved ?? $this->boundReserved(), $this->separators)) { + $fail('laranail/validation::validation.username_reserved')->translate(); + } + } + + /** + * The shape travels; the reserved list does not. + * + * A browser could check a name against a list, and it would be exporting + * the list — which is a small disclosure with no upside, since the server + * checks it anyway and the field is already undetermined the moment a + * `unique` sits beside it. + * + * @return list}> + */ + public function clientRules(): array + { + return [[ + 'rule' => 'regex', + 'params' => ['pattern' => self::pattern($this->min, $this->max, $this->separators, $this->lowercase)], + ]]; + } + /** * `preg_quote` is not enough inside a character class: it leaves `-` * alone, and a stray `-` between two characters there is a RANGE. A @@ -211,20 +214,19 @@ private static function escapeForCharacterClass(string $characters): string } /** - * The shape travels; the reserved list does not. - * - * A browser could check a name against a list, and it would be exporting - * the list — which is a small disclosure with no upside, since the server - * checks it anyway and the field is already undetermined the moment a - * `unique` sits beside it. + * The application-bound list, or the floor when no container is booted — + * a rule set built in a queued job or plain unit test still validates. + * Uncached on purpose: this class is readonly, and the container binding + * may legitimately change between validations in long-lived workers. * - * @return list}> + * @return list */ - public function clientRules(): array + private function boundReserved(): array { - return [[ - 'rule' => 'regex', - 'params' => ['pattern' => self::pattern($this->min, $this->max, $this->separators, $this->lowercase)], - ]]; + if (function_exists('app') && app()->bound(ReservedUsernameList::class)) { + return resolve(ReservedUsernameList::class)->names(); + } + + return self::DEFAULT_RESERVED; } } diff --git a/src/Rules/Text/WithoutSpaces.php b/src/Rules/Text/WithoutSpaces.php index 53455d3..5464138 100644 --- a/src/Rules/Text/WithoutSpaces.php +++ b/src/Rules/Text/WithoutSpaces.php @@ -1,4 +1,6 @@ - */ private const array PATTERNS = [ - self::GOOGLE_ANALYTICS => '/^G-[A-Z0-9]{10}$/D', + self::GOOGLE_ANALYTICS => '/^G-[A-Z0-9]{10}$/D', self::GOOGLE_TAG_MANAGER => '/^GTM-[A-Z0-9]{6,8}$/D', - self::FACEBOOK_PIXEL => '/^\d{15,16}$/D', - self::AWS_REGION => '/^[a-z]{2}(?:-gov)?-[a-z]{4,9}-\d$/D', + self::FACEBOOK_PIXEL => '/^\d{15,16}$/D', + self::AWS_REGION => '/^[a-z]{2}(?:-gov)?-[a-z]{4,9}-\d$/D', // No consecutive periods, and cannot start or end with one. self::DISCORD_USERNAME => '/^(?!.*\.\.)[a-z0-9_.]{2,32}$/D', ]; public function __construct(private string $vendor) {} - public function validate(string $attribute, mixed $value, Closure $fail): void - { - if (! self::passes($value, $this->vendor)) { - $fail('laranail/validation::validation.vendor_identifier') - ->translate(['vendor' => str_replace('_', ' ', mb_strtolower(trim($this->vendor)))]); - } - } - public static function passes(mixed $value, string $vendor): bool { if (! is_string($value)) { @@ -94,24 +88,18 @@ public static function passes(mixed $value, string $vendor): bool // either way would admit a value Discord itself refuses. $candidate = match ($vendor) { self::GOOGLE_ANALYTICS, self::GOOGLE_TAG_MANAGER => mb_strtoupper($value), - default => $value, + default => $value, }; return preg_match(self::PATTERNS[$vendor], $candidate) === 1; } - /** - * A tenant id is a UUID, but Microsoft also accepts three well-known - * aliases wherever one is expected, and a settings field that rejected - * `common` would reject a working configuration. - */ - private static function microsoftTenant(string $value): bool + public function validate(string $attribute, mixed $value, Closure $fail): void { - if (in_array(mb_strtolower($value), ['common', 'organizations', 'consumers'], true)) { - return true; + if (! self::passes($value, $this->vendor)) { + $fail('laranail/validation::validation.vendor_identifier') + ->translate(['vendor' => str_replace('_', ' ', mb_strtolower(trim($this->vendor)))]); } - - return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD', $value) === 1; } /** @@ -146,4 +134,18 @@ public function clientRules(): array return [['rule' => 'regex', 'params' => ['pattern' => $foldsCase ? $pattern . 'i' : $pattern]]]; } + + /** + * A tenant id is a UUID, but Microsoft also accepts three well-known + * aliases wherever one is expected, and a settings field that rejected + * `common` would reject a working configuration. + */ + private static function microsoftTenant(string $value): bool + { + if (in_array(mb_strtolower($value), ['common', 'organizations', 'consumers'], true)) { + return true; + } + + return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD', $value) === 1; + } } diff --git a/src/Schemas/PersonNamePresence.php b/src/Schemas/PersonNamePresence.php index ac89186..3d3e4f7 100644 --- a/src/Schemas/PersonNamePresence.php +++ b/src/Schemas/PersonNamePresence.php @@ -1,4 +1,6 @@ - $labels + * @param array $labels */ public function labels(array $labels): self { @@ -247,7 +249,8 @@ public function rules(): array * omitted it, so the caller writes a complete row rather than a partial one. * Keys the caller did not declare are dropped. * - * @param array $input + * @param array $input + * * @return array */ public function normalise(array $input): array @@ -271,7 +274,8 @@ public function normalise(array $input): array /** * The list form of {@see normalise()} — trimmed, blanks dropped, reindexed. * - * @param array $names + * @param array $names + * * @return list */ public function normaliseList(array $names): array @@ -409,7 +413,7 @@ private function labelFor(string $field): string * by one of them. The builders it produces are freshly constructed per * call, so nothing downstream shares state either. * - * @param callable(self): void $mutate + * @param callable(self): void $mutate */ private function with(callable $mutate): self { diff --git a/src/Support/Email/BundledDisposableDomainList.php b/src/Support/Email/BundledDisposableDomainList.php index b820f1c..5262820 100644 --- a/src/Support/Email/BundledDisposableDomainList.php +++ b/src/Support/Email/BundledDisposableDomainList.php @@ -1,4 +1,6 @@ - $terms Lowercase terms to match. - * @param list $allowed Words containing a term that are not one - * — the Scunthorpe problem. + * @param list $terms Lowercase terms to match. + * @param list $allowed Words containing a term that are not one + * — the Scunthorpe problem. */ public function __construct( private array $terms, diff --git a/src/Support/Payment/BundledCardBrandCatalogue.php b/src/Support/Payment/BundledCardBrandCatalogue.php index 4b744ae..0bca3af 100644 --- a/src/Support/Payment/BundledCardBrandCatalogue.php +++ b/src/Support/Payment/BundledCardBrandCatalogue.php @@ -1,4 +1,6 @@ - $ranges Inclusive prefix intervals, equal-width bounds. - * @param list $lengths - * @param list $cvcLengths + * @param string $name Machine slug (`visa`), used in `brands:` restrictions. + * @param list $ranges Inclusive prefix intervals, equal-width bounds. + * @param list $lengths + * @param list $cvcLengths */ public function __construct( public string $name, diff --git a/src/Support/RuleAliases.php b/src/Support/RuleAliases.php index 36c1100..839a9c4 100644 --- a/src/Support/RuleAliases.php +++ b/src/Support/RuleAliases.php @@ -1,96 +1,98 @@ - static fn (): ValidationRule => new Iban(), - 'bic' => static fn (): ValidationRule => new Bic(), - 'isin' => static fn (): ValidationRule => new Isin(), - 'luhn' => static fn (): ValidationRule => new Luhn(), - 'bsb_number' => static fn (): ValidationRule => new BsbNumber(), + 'iban' => static fn (): ValidationRule => new Iban, + 'bic' => static fn (): ValidationRule => new Bic, + 'isin' => static fn (): ValidationRule => new Isin, + 'luhn' => static fn (): ValidationRule => new Luhn, + 'bsb_number' => static fn (): ValidationRule => new BsbNumber, // Codes — the parameters narrow which lengths/editions are accepted. - 'ean' => static fn (): ValidationRule => new Ean(), - 'issn' => static fn (): ValidationRule => new Issn(), - 'isbn' => static fn (array $p): ValidationRule => new Isbn(self::ints($p)), - 'gtin' => static fn (array $p): ValidationRule => new Gtin(self::ints($p)), - 'asin' => static fn (): ValidationRule => new Asin(), - 'ismn' => static fn (): ValidationRule => new Ismn(), - 'upc_e' => static fn (): ValidationRule => new UpcE(), + 'ean' => static fn (): ValidationRule => new Ean, + 'issn' => static fn (): ValidationRule => new Issn, + 'isbn' => static fn (array $p): ValidationRule => new Isbn(self::ints($p)), + 'gtin' => static fn (array $p): ValidationRule => new Gtin(self::ints($p)), + 'asin' => static fn (): ValidationRule => new Asin, + 'ismn' => static fn (): ValidationRule => new Ismn, + 'upc_e' => static fn (): ValidationRule => new UpcE, // Identifiers - 'imei' => static fn (): ValidationRule => new Imei(), - 'jwt' => static fn (): ValidationRule => new Jwt(), - 'semver' => static fn (): ValidationRule => new SemVer(), - 'vin' => static fn (array $p): ValidationRule => new Vin(...self::flags($p)), + 'imei' => static fn (): ValidationRule => new Imei, + 'jwt' => static fn (): ValidationRule => new Jwt, + 'semver' => static fn (): ValidationRule => new SemVer, + 'vin' => static fn (array $p): ValidationRule => new Vin(...self::flags($p)), 'hash_digest' => static fn (array $p): ValidationRule => new HashDigest(self::str($p, 0)), // Geo - 'latitude' => static fn (): ValidationRule => new Latitude(), - 'longitude' => static fn (): ValidationRule => new Longitude(), - 'lat_lng' => static fn (): ValidationRule => new LatLng(), - 'ca_province' => static fn (): ValidationRule => new CaProvince(), - 'us_state' => static fn (array $p): ValidationRule => new UsState(...self::flags($p)), + 'latitude' => static fn (): ValidationRule => new Latitude, + 'longitude' => static fn (): ValidationRule => new Longitude, + 'lat_lng' => static fn (): ValidationRule => new LatLng, + 'ca_province' => static fn (): ValidationRule => new CaProvince, + 'us_state' => static fn (array $p): ValidationRule => new UsState(...self::flags($p)), // Postal — `laranail_postal_code:US`, `:US,CA`, or `:@country` // to read the country from a sibling field. @@ -198,78 +200,80 @@ public static function map(): array // Database — `laranail_compare_to_column:products,max_quantity,lte,id,@product_id` 'compare_to_column' => static fn (array $p): ValidationRule => new CompareToColumn( - self::str($p, 0), self::str($p, 1), + self::str($p, 0), + self::str($p, 1), Comparison::from(self::str($p, 2, 'eq')), - self::str($p, 3), self::str($p, 4), + self::str($p, 3), + self::str($p, 4), ), // Storage — `laranail_file_exists_on_disk:uploads,avatars` 'file_exists_on_disk' => static fn (array $p): ValidationRule => new FileExistsOnDisk(self::str($p, 0), self::str($p, 1)), // Networking probes - 'image_url' => static fn (array $p): ValidationRule => new ImageUrl(self::strings($p)), - 'has_gravatar' => static fn (): ValidationRule => new HasGravatar(), + 'image_url' => static fn (array $p): ValidationRule => new ImageUrl(self::strings($p)), + 'has_gravatar' => static fn (): ValidationRule => new HasGravatar, // Payment — `laranail_card_number:visa,mastercard` restricts // brands; `laranail_card_cvc:card_number` names the sibling // number field; `laranail_card_expiry:Europe/Berlin` the zone. 'card_number' => static fn (array $p): ValidationRule => new CardNumber(self::strings($p) === [] ? null : self::strings($p)), - 'card_cvc' => static fn (array $p): ValidationRule => new CardCvc(self::nullableStr($p, 0)), + 'card_cvc' => static fn (array $p): ValidationRule => new CardCvc(self::nullableStr($p, 0)), 'card_expiry' => static fn (array $p): ValidationRule => new CardExpiry(self::nullableStr($p, 0)), // Chrono - 'rfc3339' => static fn (): ValidationRule => new Rfc3339(), + 'rfc3339' => static fn (): ValidationRule => new Rfc3339, // `laranail_time_of_day:true` = 12-hour; second parameter is the // separator (`laranail_time_of_day:false,.` for `23.59`). - 'time_of_day' => static fn (array $p): ValidationRule => new TimeOfDay(self::flags($p)[0] ?? false, self::str($p, 1, ':')), + 'time_of_day' => static fn (array $p): ValidationRule => new TimeOfDay(self::flags($p)[0] ?? false, self::str($p, 1, ':')), 'unix_timestamp' => static fn (array $p): ValidationRule => new UnixTimestamp(...self::flags($p)), - 'date_interval' => static fn (array $p): ValidationRule => new DateInterval(...self::flags($p)), - 'minute_in' => static fn (array $p): ValidationRule => new MinuteIn(self::ints($p)), + 'date_interval' => static fn (array $p): ValidationRule => new DateInterval(...self::flags($p)), + 'minute_in' => static fn (array $p): ValidationRule => new MinuteIn(self::ints($p)), // `laranail_max_date_difference:48,@start_at` — hours, then the // reference (a date, or @field for a sibling). - 'max_date_difference' => static fn (array $p): ValidationRule => new MaxDateDifference((int) self::str($p, 0, '0'), self::str($p, 1)), - 'timezone_abbreviation' => static fn (): ValidationRule => new TimezoneAbbreviation(), - 'minimum_age' => static fn (array $p): ValidationRule => new MinimumAge((int) self::str($p, 0, '0'), self::nullableStr($p, 1)), + 'max_date_difference' => static fn (array $p): ValidationRule => new MaxDateDifference((int) self::str($p, 0, '0'), self::str($p, 1)), + 'timezone_abbreviation' => static fn (): ValidationRule => new TimezoneAbbreviation, + 'minimum_age' => static fn (array $p): ValidationRule => new MinimumAge((int) self::str($p, 0, '0'), self::nullableStr($p, 1)), // Encoding — Base64Image and DataUri take lists and a byte cap, // which a flat rule string cannot spell honestly (see UNALIASED). - 'base64' => static fn (): ValidationRule => new Base64(), + 'base64' => static fn (): ValidationRule => new Base64, // I18n — positional flags mirror the constructors: // `laranail_country_code:true` is alpha-3 mode, // `laranail_currency_code:true` numeric, `:false,true` symbol. - 'country_code' => static fn (array $p): ValidationRule => new CountryCode(...self::flags($p)), + 'country_code' => static fn (array $p): ValidationRule => new CountryCode(...self::flags($p)), 'currency_code' => static fn (array $p): ValidationRule => new CurrencyCode(...self::flags($p)), 'language_code' => static fn (array $p): ValidationRule => new LanguageCode(...self::flags($p)), // Net - 'cidr' => static fn (): ValidationRule => new Cidr(), - 'subdomain' => static fn (): ValidationRule => new Subdomain(), - 'public_ip' => static fn (): ValidationRule => new PublicIp(), - 'private_ip' => static fn (): ValidationRule => new PrivateIp(), + 'cidr' => static fn (): ValidationRule => new Cidr, + 'subdomain' => static fn (): ValidationRule => new Subdomain, + 'public_ip' => static fn (): ValidationRule => new PublicIp, + 'private_ip' => static fn (): ValidationRule => new PrivateIp, 'domain_name' => static fn (array $p): ValidationRule => new DomainName(...self::flags($p)), - 'mac_address' => static fn (): ValidationRule => new MacAddress(), + 'mac_address' => static fn (): ValidationRule => new MacAddress, // The networks are the whole rule, so the parameters are the list: // `laranail_in_cidr_range:10.0.0.0/8,192.168.0.0/16`. - 'in_cidr_range' => static fn (array $p): ValidationRule => new InCidrRange(self::strings($p)), - 'no_subaddressing' => static fn (): ValidationRule => new NoSubaddressing(), + 'in_cidr_range' => static fn (array $p): ValidationRule => new InCidrRange(self::strings($p)), + 'no_subaddressing' => static fn (): ValidationRule => new NoSubaddressing, // Text - 'slug' => static fn (): ValidationRule => new Slug(), + 'slug' => static fn (): ValidationRule => new Slug, // `laranail_html_clean:true` inverts the rule: markup required. - 'html_clean' => static fn (array $p): ValidationRule => new HtmlClean(...self::flags($p)), - 'max_words' => static fn (array $p): ValidationRule => new MaxWords(...self::ints($p)), - 'min_words' => static fn (array $p): ValidationRule => new MinWords(...self::ints($p)), - 'salutation' => static fn (): ValidationRule => new Salutation(), - 'without_spaces' => static fn (): ValidationRule => new WithoutSpaces(), - 'case_style' => static fn (array $p): ValidationRule => new CaseStyle(self::str($p, 0)), - 'person_name' => static fn (array $p): ValidationRule => new PersonName(...self::flags($p)), + 'html_clean' => static fn (array $p): ValidationRule => new HtmlClean(...self::flags($p)), + 'max_words' => static fn (array $p): ValidationRule => new MaxWords(...self::ints($p)), + 'min_words' => static fn (array $p): ValidationRule => new MinWords(...self::ints($p)), + 'salutation' => static fn (): ValidationRule => new Salutation, + 'without_spaces' => static fn (): ValidationRule => new WithoutSpaces, + 'case_style' => static fn (array $p): ValidationRule => new CaseStyle(self::str($p, 0)), + 'person_name' => static fn (array $p): ValidationRule => new PersonName(...self::flags($p)), // Spread rather than restate the defaults: hardcoding them here // let `laranail_username` drift from `new Username()` once already. 'username' => static fn (array $p): ValidationRule => new Username(...self::ints($p)), // Anti-spam - 'honeypot' => static fn (): ValidationRule => new Honeypot(), + 'honeypot' => static fn (): ValidationRule => new Honeypot, // Fiscal 'national_identifier' => static fn (array $p): ValidationRule => new NationalIdentifier(self::str($p, 0)), @@ -283,30 +287,30 @@ public static function map(): array 'vendor_identifier' => static fn (array $p): ValidationRule => new VendorIdentifier(self::str($p, 0)), // Numbers - 'parity' => static fn (array $p): ValidationRule => new Parity(self::str($p, 0)), + 'parity' => static fn (array $p): ValidationRule => new Parity(self::str($p, 0)), 'monetary_amount' => self::monetary(...), // Colour 'css_color' => static fn (array $p): ValidationRule => new CssColor(self::strings($p)), // Crypto - 'ethereum_address' => static fn (): ValidationRule => new EthereumAddress(), - 'bitcoin_address' => static fn (array $p): ValidationRule => new BitcoinAddress(...self::flags($p)), + 'ethereum_address' => static fn (): ValidationRule => new EthereumAddress, + 'bitcoin_address' => static fn (array $p): ValidationRule => new BitcoinAddress(...self::flags($p)), // Email — the two list-backed rules resolve their list from the // container, so they need no parameter at all. - 'not_disposable_email' => static fn (): ValidationRule => new NotDisposableEmail(), - 'not_role_email' => static fn (): ValidationRule => new NotRoleEmail(), - 'email_domain_is' => static fn (array $p): ValidationRule => new EmailDomainIs(self::strings($p)), - 'email_domain_is_not' => static fn (array $p): ValidationRule => new EmailDomainIsNot(self::strings($p)), + 'not_disposable_email' => static fn (): ValidationRule => new NotDisposableEmail, + 'not_role_email' => static fn (): ValidationRule => new NotRoleEmail, + 'email_domain_is' => static fn (array $p): ValidationRule => new EmailDomainIs(self::strings($p)), + 'email_domain_is_not' => static fn (array $p): ValidationRule => new EmailDomainIsNot(self::strings($p)), // Network — the only tier that performs IO. Skipped entirely // during a precognitive request. - 'deliverable_email' => static fn (): ValidationRule => new DeliverableEmail(), + 'deliverable_email' => static fn (): ValidationRule => new DeliverableEmail, // Database 'models_exist' => static fn (array $p): ValidationRule => new ModelsExist(self::model($p, 0), self::nullableStr($p, 1)), - 'authorized' => static fn (array $p): ValidationRule => new Authorized(self::str($p, 0), self::model($p, 1), self::nullableStr($p, 2)), + 'authorized' => static fn (array $p): ValidationRule => new Authorized(self::str($p, 0), self::model($p, 1), self::nullableStr($p, 2)), ]; } @@ -319,7 +323,7 @@ public static function map(): array * country and a field called `country`. The sigil resolves it explicitly * rather than guessing from the shape of the string. * - * @param array $parameters + * @param array $parameters */ private static function postalCode(array $parameters): PostalCode { @@ -349,7 +353,8 @@ private static function postalCode(array $parameters): PostalCode * the name itself and the user gets a bare "class not found" that never * mentions the alias or the parameter that produced it. * - * @param array $parameters + * @param array $parameters + * * @return class-string */ private static function model(array $parameters, int $index): string @@ -372,7 +377,8 @@ private static function model(array $parameters, int $index): string * promise their shape. Non-scalars are dropped rather than coerced — * `(string) []` is a fatal, and a rule string cannot carry an array. * - * @param array $parameters + * @param array $parameters + * * @return list */ private static function strings(array $parameters): array @@ -407,14 +413,14 @@ private static function nullableStr(array $parameters, int $index): ?string * Spread rather than defaulted, so an omitted parameter leaves the rule's * own default in force. See {@see flags()} for why that matters. * - * @param array $parameters + * @param array $parameters */ private static function monetary(array $parameters): MonetaryAmount { $given = self::strings($parameters); if ($given === []) { - return new MonetaryAmount(); + return new MonetaryAmount; } $decimals = is_numeric($given[0]) ? (int) $given[0] : 2; @@ -438,7 +444,8 @@ private static function monetary(array $parameters): MonetaryAmount * rules: `laranail_username` capped at 30 against Username's 32, and * `laranail_vin` demanded a check digit that `new Vin()` does not. * - * @param array $parameters + * @param array $parameters + * * @return list */ private static function flags(array $parameters): array @@ -453,7 +460,8 @@ private static function flags(array $parameters): array } /** - * @param array $parameters + * @param array $parameters + * * @return list */ private static function ints(array $parameters): array diff --git a/src/Support/RuleRegistrar.php b/src/Support/RuleRegistrar.php index ceb11ea..3d5e9bf 100644 --- a/src/Support/RuleRegistrar.php +++ b/src/Support/RuleRegistrar.php @@ -1,14 +1,16 @@ - $rule - * @param Closure(list): ValidationRule|null $factory Builds the rule from - * string-rule parameters; required for an alias. + * @param class-string $rule + * @param Closure(list): ValidationRule|null $factory Builds the rule from + * string-rule parameters; required for an alias. */ public function register(string $rule, ?string $alias = null, ?Closure $factory = null): self { diff --git a/src/Support/Text/DefaultReservedUsernameList.php b/src/Support/Text/DefaultReservedUsernameList.php index f3f346f..fbbc577 100644 --- a/src/Support/Text/DefaultReservedUsernameList.php +++ b/src/Support/Text/DefaultReservedUsernameList.php @@ -1,9 +1,11 @@ - $ast - * @param array $banned + * @param array $ast + * @param array $banned */ private static function containsViolation(array $ast, array $banned): bool { - $visitor = new class ($banned) extends NodeVisitorAbstract { + $visitor = new class($banned) extends NodeVisitorAbstract + { public bool $found = false; /** @param array $banned */ @@ -197,12 +200,12 @@ private function chainRootsAtFluentRuleField(MethodCall $call): bool // before NameResolver's `enterNode` had a chance to resolve that // child's class Name. Running name resolution as a separate first // pass guarantees the whole AST is resolved before we walk it. - $resolve = new NodeTraverser(); - $resolve->addVisitor(new NameResolver()); + $resolve = new NodeTraverser; + $resolve->addVisitor(new NameResolver); $ast = $resolve->traverse($ast); - $traverser = new NodeTraverser(); + $traverser = new NodeTraverser; $traverser->addVisitor($visitor); $traverser->traverse($ast); diff --git a/src/Testing/FluentRulesTester.php b/src/Testing/FluentRulesTester.php index 2b89957..28583d0 100644 --- a/src/Testing/FluentRulesTester.php +++ b/src/Testing/FluentRulesTester.php @@ -1,33 +1,36 @@ -|class-string|class-string|RuleSet|ValidationRule|array $target - * @param list $constructorArgs + * @param class-string|class-string|class-string|RuleSet|ValidationRule|array $target + * @param list $constructorArgs */ private function __construct( private readonly mixed $target, @@ -80,8 +83,8 @@ private function __construct( /** * @api * - * @param class-string|class-string|class-string|RuleSet|ValidationRule|array $target - * @param mixed ...$constructorArgs Forwarded to FluentValidator subclass constructors after `$data`. Ignored for non-class targets. + * @param class-string|class-string|class-string|RuleSet|ValidationRule|array $target + * @param mixed ...$constructorArgs Forwarded to FluentValidator subclass constructors after `$data`. Ignored for non-class targets. */ public static function for(mixed $target, mixed ...$constructorArgs): self { @@ -91,7 +94,7 @@ public static function for(mixed $target, mixed ...$constructorArgs): self /** * @api * - * @param array $data + * @param array $data */ public function with(array $data): self { @@ -116,7 +119,7 @@ public function with(array $data): self * * @api * - * @param array $parameters + * @param array $parameters */ public function withRoute(array $parameters): self { @@ -156,7 +159,7 @@ public function actingAs(Authenticatable $user, ?string $guard = null): self * * @api * - * @param string|array $key + * @param string|array $key */ public function set(string|array $key, mixed $value = null): self { @@ -222,7 +225,7 @@ public function andCall(string $method, mixed ...$args): self * * @api * - * @param array $parameters + * @param array $parameters */ public function mount(array $parameters): self { @@ -451,7 +454,7 @@ public function failsWithAny(string $prefix): self * * @api * - * @param array $replacements + * @param array $replacements */ public function failsWithMessage(string $field, string $translationKey, array $replacements = []): self { @@ -565,8 +568,8 @@ private function isLivewireTarget(): bool * set container + redirector + user resolver, call validateResolved(). * Internals shift subtly across Laravel majors; CI matrix exercises it. * - * @param class-string $class - * @param array $data + * @param class-string $class + * @param array $data */ private function runFormRequest(string $class, array $data): Validated { @@ -608,11 +611,12 @@ private function runFormRequest(string $class, array $data): Validated * exposes the same surface (`parameter()` + `parameters()`) so FormRequests * that introspect routes during `authorize()` or `rules()` Just Work. * - * @param array $parameters + * @param array $parameters */ private function makeRouteShim(array $parameters): object { - return new readonly class ($parameters) { + return new readonly class($parameters) + { /** @param array $parameters */ public function __construct(private array $parameters) {} @@ -644,8 +648,8 @@ private function extractValidator(FormRequest $request): ValidatorContract } /** - * @param class-string $class - * @param array $data + * @param class-string $class + * @param array $data */ private function runFluentValidator(string $class, array $data): Validated { @@ -670,7 +674,7 @@ private function runFluentValidator(string $class, array $data): Validated * `$this->target` is a `class-string<\Livewire\Component>` at this point. * Asserted at runtime to satisfy PHPStan without inline `@var` overrides. * - * @param array $data + * @param array $data */ private function runLivewire(array $data): Validated { @@ -710,7 +714,7 @@ private function runLivewire(array $data): Validated $this->callQueue = []; $rawErrors = $component->errors(); - $errors = $rawErrors instanceof MessageBag ? $rawErrors : new MessageBag(); + $errors = $rawErrors instanceof MessageBag ? $rawErrors : new MessageBag; $validator = $this->extractLivewireValidator($component); @@ -735,7 +739,7 @@ private function runLivewire(array $data): Validated * — in that case the caller falls back to a synthetic Validator so the * `Validated` DTO contract still holds. * - * @param Testable $component + * @param Testable $component */ private function extractLivewireValidator(Testable $component): ?ValidatorContract { @@ -829,7 +833,7 @@ private function wrap(ValidatorContract $validator): Validated return new Validated( passes: true, validated: $validated, - errors: new MessageBag(), + errors: new MessageBag, validator: $validator, ); } diff --git a/src/Testing/PestExpectations.php b/src/Testing/PestExpectations.php index 84483db..347b251 100644 --- a/src/Testing/PestExpectations.php +++ b/src/Testing/PestExpectations.php @@ -1,4 +1,6 @@ - $array + * @param array $array + * * @return array */ $assertStringKeyed = static function (array $array, string $context): array { @@ -75,7 +78,8 @@ }; /** - * @param array $data + * @param array $data + * * @return array */ $narrowData = static fn (array $data): array => $assertStringKeyed($data, 'Validation data'); diff --git a/src/Testing/ValidationFake.php b/src/Testing/ValidationFake.php index 973c65b..17e578f 100644 --- a/src/Testing/ValidationFake.php +++ b/src/Testing/ValidationFake.php @@ -1,13 +1,15 @@ - $validated + * @param array $validated */ public function __construct( private bool $passes, diff --git a/src/Validation.php b/src/Validation.php index 02ee9e2..3b5e432 100644 --- a/src/Validation.php +++ b/src/Validation.php @@ -1,4 +1,6 @@ - */ private const array REWRITE_TARGET = [ - 'required_if' => 'required', - 'required_unless' => 'required', - 'prohibited_if' => 'prohibited', + 'required_if' => 'required', + 'required_unless' => 'required', + 'prohibited_if' => 'prohibited', 'prohibited_unless' => 'prohibited', ]; @@ -56,9 +58,9 @@ final class ValueConditionalReducer extends AbstractConditionalReducer * can detect a `boolean` declaration on the dependent path and mirror * Laravel's `shouldConvertToBoolean`. * - * @param array $itemData - * @param array $itemMessages - * @param array $itemRules + * @param array $itemData + * @param array $itemMessages + * @param array $itemRules */ public static function apply(mixed $rule, string $field, array $itemData, array $itemMessages, array $itemRules): mixed { @@ -91,9 +93,9 @@ protected static function parse(string $rule): ?array * collapse to plain `required` / `prohibited` when active and no custom * message overrides the original rule name, otherwise return unchanged. * - * @param array $itemData - * @param array $itemMessages - * @param array $itemRules + * @param array $itemData + * @param array $itemMessages + * @param array $itemRules */ private static function rewriteOne(string $rule, string $field, array $itemData, array $itemMessages, array $itemRules): ?string { @@ -131,9 +133,9 @@ private static function rewriteOne(string $rule, string $field, array $itemData, $match = ConditionalValueMatcher::matches($depPath, $rawValues, $itemData, $itemRules); $active = match ($ruleName) { - 'required_if', 'prohibited_if' => $match, + 'required_if', 'prohibited_if' => $match, 'required_unless', 'prohibited_unless' => ! $match, - default => false, + default => false, }; if (! $active) { diff --git a/src/WildcardExpander.php b/src/WildcardExpander.php index f272599..cfe23eb 100644 --- a/src/WildcardExpander.php +++ b/src/WildcardExpander.php @@ -1,4 +1,6 @@ - $data + * @param array $data + * * @return list */ public static function expand(string $pattern, array $data): array @@ -27,9 +30,10 @@ public static function expand(string $pattern, array $data): array } /** - * @param list $segments - * @param list $path - * @param bool $afterWildcard Whether we've passed through a * segment + * @param list $segments + * @param list $path + * @param bool $afterWildcard Whether we've passed through a * segment + * * @return list */ private static function resolve(array $segments, mixed $current, array $path, bool $afterWildcard = false): array diff --git a/static b/static new file mode 100644 index 0000000..573541a --- /dev/null +++ b/static @@ -0,0 +1 @@ +0 diff --git a/tests/AddRuleMessageParamTest.php b/tests/AddRuleMessageParamTest.php index 00909f1..3428ca8 100644 --- a/tests/AddRuleMessageParamTest.php +++ b/tests/AddRuleMessageParamTest.php @@ -1,4 +1,6 @@ - */ @@ -21,7 +24,7 @@ function exposeAddRule(): object protected ?string $compiledCache = null; /** - * @param array|string|object $rules + * @param array|string|object $rules */ public function callAddRule(array|string|object $rules, ?string $message = null): static { @@ -76,6 +79,6 @@ public function compiledRules(): string expect($rule->getCustomMessages())->toBe([ 'required' => 'Name required.', - 'min' => 'Too short.', + 'min' => 'Too short.', ]); }); diff --git a/tests/ArchTest.php b/tests/ArchTest.php index 557cbef..007f7af 100644 --- a/tests/ArchTest.php +++ b/tests/ArchTest.php @@ -1,6 +1,8 @@ -expect(['dd', 'dump', 'ray']) diff --git a/tests/ArrayRuleExtendHelpersTest.php b/tests/ArrayRuleExtendHelpersTest.php index df8aa1a..3399394 100644 --- a/tests/ArrayRuleExtendHelpersTest.php +++ b/tests/ArrayRuleExtendHelpersTest.php @@ -1,10 +1,12 @@ - 'alice@example.com', 'upper' => 'ALICE@EXAMPLE.COM', 'mixed' => 'Alice@Example.com', - 'free' => 'dave@example.com', + 'free' => 'dave@example.com', ]); it('agrees with the database on exists, for the same reason inverted', function (string $email): void { @@ -104,7 +106,7 @@ function vanillaUniquePasses(string $email): bool expect($batched)->toBe($vanilla); })->with([ - 'exact' => 'alice@example.com', - 'upper' => 'ALICE@EXAMPLE.COM', + 'exact' => 'alice@example.com', + 'upper' => 'ALICE@EXAMPLE.COM', 'absent' => 'nobody@example.com', ]); diff --git a/tests/BatchDatabaseValidationTest.php b/tests/BatchDatabaseValidationTest.php index 52dd69f..c947f5f 100644 --- a/tests/BatchDatabaseValidationTest.php +++ b/tests/BatchDatabaseValidationTest.php @@ -1,21 +1,23 @@ - 'testing']); config(['database.connections.testing' => [ - 'driver' => 'sqlite', + 'driver' => 'sqlite', 'database' => ':memory:', ]]); @@ -47,7 +49,7 @@ function setupTestDatabase(): void // ========================================================================= it('precomputed verifier returns 1 for existing values and 0 for missing', function (): void { - $verifier = new PrecomputedPresenceVerifier(); + $verifier = new PrecomputedPresenceVerifier; $verifier->addLookup('t', 'c', ['a', 'b', 'c']); expect($verifier->getCount('t', 'c', 'a'))->toBe(1) @@ -55,7 +57,7 @@ function setupTestDatabase(): void }); it('precomputed verifier counts multi values correctly', function (): void { - $verifier = new PrecomputedPresenceVerifier(); + $verifier = new PrecomputedPresenceVerifier; $verifier->addLookup('t', 'c', ['a', 'b', 'c']); expect($verifier->getMultiCount('t', 'c', ['a', 'b', 'z']))->toBe(2) @@ -63,7 +65,7 @@ function setupTestDatabase(): void }); it('precomputed verifier scopes lookups by table and column', function (): void { - $verifier = new PrecomputedPresenceVerifier(); + $verifier = new PrecomputedPresenceVerifier; $verifier->addLookup('users', 'email', ['alice@example.com']); $verifier->addLookup('users', 'username', ['bob']); @@ -79,12 +81,12 @@ function setupTestDatabase(): void // ========================================================================= it('precomputed verifier preserves custom messages keyed by rule name', function (): void { - $verifier = new PrecomputedPresenceVerifier(); + $verifier = new PrecomputedPresenceVerifier; $verifier->addLookup('users', 'email', ['known@example.com']); $validator = Validator::make( ['email' => 'unknown@example.com'], - ['email' => ['required', Rule::exists('users', 'email')]], + ['email' => ['required', Rule::exists('users', 'email')]], ['email.exists' => 'Custom exists message'], ); $validator->setPresenceVerifier($verifier); @@ -94,7 +96,7 @@ function setupTestDatabase(): void }); it('precomputed verifier produces standard exists message', function (): void { - $verifier = new PrecomputedPresenceVerifier(); + $verifier = new PrecomputedPresenceVerifier; $verifier->addLookup('users', 'email', ['known@example.com']); $validator = Validator::make( @@ -108,7 +110,7 @@ function setupTestDatabase(): void }); it('precomputed verifier passes for values in the lookup set', function (): void { - $verifier = new PrecomputedPresenceVerifier(); + $verifier = new PrecomputedPresenceVerifier; $verifier->addLookup('users', 'email', ['known@example.com']); $validator = Validator::make( @@ -121,7 +123,7 @@ function setupTestDatabase(): void }); it('precomputed verifier preserves custom attributes', function (): void { - $verifier = new PrecomputedPresenceVerifier(); + $verifier = new PrecomputedPresenceVerifier; $verifier->addLookup('users', 'email', ['known@example.com']); $validator = Validator::make( @@ -611,14 +613,14 @@ function setupTestDatabase(): void // ========================================================================= it('verifier returns 0 for unknown lookup with no fallback', function (): void { - $verifier = new PrecomputedPresenceVerifier(); + $verifier = new PrecomputedPresenceVerifier; expect($verifier->getCount('unknown_table', 'col', 'val'))->toBe(0) ->and($verifier->getMultiCount('unknown_table', 'col', ['a', 'b']))->toBe(0); }); it('verifier hasLookups returns false when empty', function (): void { - $verifier = new PrecomputedPresenceVerifier(); + $verifier = new PrecomputedPresenceVerifier; expect($verifier->hasLookups())->toBeFalse(); }); @@ -781,12 +783,13 @@ function setupTestDatabase(): void // ========================================================================= /** - * @param array $rules - * @param array $data + * @param array $rules + * @param array $data */ function createBatchFormRequest(array $rules, array $data): FormRequest { - $formRequest = new class extends FormRequest { + $formRequest = new class extends FormRequest + { use HasFluentRules; /** @var array */ diff --git a/tests/BatchExistsUniqueSameFieldParityTest.php b/tests/BatchExistsUniqueSameFieldParityTest.php index eed91a1..32fc64d 100644 --- a/tests/BatchExistsUniqueSameFieldParityTest.php +++ b/tests/BatchExistsUniqueSameFieldParityTest.php @@ -1,10 +1,12 @@ - $rules - * @param array $data + * @param array $rules + * @param array $data */ function existsUniqueRuleSetVerdict(array $rules, array $data): bool { @@ -56,8 +58,8 @@ function existsUniqueRuleSetVerdict(array $rules, array $data): bool } /** - * @param array $rules - * @param array $data + * @param array $rules + * @param array $data */ function existsUniqueVanillaVerdict(array $rules, array $data): bool { diff --git a/tests/BatchSharedColumnParityTest.php b/tests/BatchSharedColumnParityTest.php index 72a7387..91884ba 100644 --- a/tests/BatchSharedColumnParityTest.php +++ b/tests/BatchSharedColumnParityTest.php @@ -1,10 +1,12 @@ - $data + * @param array $data + * * @return array */ function vanillaSharedColumnErrors(array $data): array { return Validator::make($data, [ 'items.*.primary' => ['required', 'string', Rule::unique('testing.users', 'email')], - 'items.*.backup' => ['required', 'string', Rule::unique('testing.users', 'email')], + 'items.*.backup' => ['required', 'string', Rule::unique('testing.users', 'email')], ])->errors()->keys(); } @@ -68,7 +71,7 @@ function vanillaSharedColumnErrors(array $data): array expect(RuleSet::from($rules)->check($data)->fails())->toBeTrue(); })->with([ 'primary declared first' => true, - 'backup declared first' => false, + 'backup declared first' => false, ]); it('flags every taken value when both fields carry one', function (): void { @@ -78,7 +81,7 @@ function vanillaSharedColumnErrors(array $data): array $result = RuleSet::from(['items' => FluentRule::array()->required()->each([ 'primary' => FluentRule::string()->required()->unique('testing.users', 'email'), - 'backup' => FluentRule::string()->required()->unique('testing.users', 'email'), + 'backup' => FluentRule::string()->required()->unique('testing.users', 'email'), ])])->check($data); expect($result->errors()->keys())->toHaveSameSize(vanillaSharedColumnErrors($data)); @@ -90,7 +93,7 @@ function vanillaSharedColumnErrors(array $data): array $result = RuleSet::from(['items' => FluentRule::array()->required()->each([ 'primary' => FluentRule::string()->required()->unique('testing.users', 'email'), - 'backup' => FluentRule::string()->required()->unique('testing.users', 'email'), + 'backup' => FluentRule::string()->required()->unique('testing.users', 'email'), ])])->check(['items' => [['primary' => 'free@example.com', 'backup' => 'spare@example.com']]]); expect($result->passes())->toBeTrue(); diff --git a/tests/BatchValidationGuardsTest.php b/tests/BatchValidationGuardsTest.php index bae86e2..6d502f0 100644 --- a/tests/BatchValidationGuardsTest.php +++ b/tests/BatchValidationGuardsTest.php @@ -1,20 +1,22 @@ - 'testing']); config(['database.connections.testing' => [ - 'driver' => 'sqlite', + 'driver' => 'sqlite', 'database' => ':memory:', ]]); @@ -42,12 +44,13 @@ function setupGuardsDatabase(): void } /** - * @param array $rules - * @param array $data + * @param array $rules + * @param array $data */ function createGuardsFormRequest(array $rules, array $data): FormRequest { - $formRequest = new class extends FormRequest { + $formRequest = new class extends FormRequest + { use HasFluentRules; /** @var array */ @@ -136,7 +139,8 @@ public function authorize(): bool }); it('filterValuesByType preserves scalar and Stringable for string rule', function (): void { - $stringable = new class implements Stringable { + $stringable = new class implements Stringable + { public function __toString(): string { return 'x'; @@ -368,11 +372,11 @@ public function __toString(): string $groups = [ 'widgets:uuid:exists' => [ - 'rule' => $existsRule, + 'rule' => $existsRule, 'values' => ['11111111-2222-3333-4444-555555555555'], ], 'widgets:uuid:unique' => [ - 'rule' => $uniqueRule, + 'rule' => $uniqueRule, 'values' => ['99999999-0000-0000-0000-000000000000'], ], ]; @@ -392,11 +396,11 @@ public function __toString(): string $groups = [ 'widgets:uuid:exists' => [ - 'rule' => $existsRule, + 'rule' => $existsRule, 'values' => ['11111111-2222-3333-4444-555555555555'], ], 'widgets:id:unique' => [ - 'rule' => $uniqueRule, + 'rule' => $uniqueRule, 'values' => ['1'], ], ]; @@ -446,7 +450,7 @@ public function __toString(): string $rule = Rule::exists('widgets', 'id'); $values = []; - for ($i = 1; $i <= 11; ++$i) { + for ($i = 1; $i <= 11; $i++) { $values[] = (string) $i; } @@ -518,7 +522,7 @@ public function __toString(): string // Build 11 distinct valid ids; cap 10 so we trip the hard cap. $items = []; - for ($i = 1; $i <= 11; ++$i) { + for ($i = 1; $i <= 11; $i++) { $items[] = ['id' => $i]; } @@ -555,7 +559,7 @@ public function __toString(): string setupGuardsDatabase(); $items = []; - for ($i = 1; $i <= 11; ++$i) { + for ($i = 1; $i <= 11; $i++) { $items[] = ['id' => $i]; } @@ -594,7 +598,7 @@ public function __toString(): string setupGuardsDatabase(); $items = []; - for ($i = 1; $i <= 11; ++$i) { + for ($i = 1; $i <= 11; $i++) { $items[] = ['id' => $i]; } @@ -619,7 +623,7 @@ public function __toString(): string setupGuardsDatabase(); $items = []; - for ($i = 1; $i <= 11; ++$i) { + for ($i = 1; $i <= 11; $i++) { $items[] = ['id' => $i]; } @@ -649,7 +653,7 @@ public function __toString(): string // 10_001 identical ids — after dedup this is 1 value, well under any cap. $items = []; - for ($i = 0; $i < 10_001; ++$i) { + for ($i = 0; $i < 10_001; $i++) { $items[] = ['id' => 1]; } @@ -715,7 +719,7 @@ public function __toString(): string // Parent says max:5, user sends 100 items. $items = []; - for ($i = 1; $i <= 100; ++$i) { + for ($i = 1; $i <= 100; $i++) { $items[] = ['id' => $i]; } @@ -794,7 +798,7 @@ public function __toString(): string // orders.*.items has max:3; the first order sends 10 items. $overflow = []; - for ($i = 1; $i <= 10; ++$i) { + for ($i = 1; $i <= 10; $i++) { $overflow[] = ['id' => $i]; } @@ -879,13 +883,13 @@ public function __toString(): string // Use plain array rules (not FluentRule) so the parent ends up as a // pipe-delimited string — forces the string branch of extractParentMax. $items = []; - for ($i = 1; $i <= 10; ++$i) { + for ($i = 1; $i <= 10; $i++) { $items[] = ['id' => $i]; } $formRequest = createGuardsFormRequest( rules: [ - 'items' => 'required|array|max:5', + 'items' => 'required|array|max:5', 'items.*.id' => ['required', 'integer', Rule::exists('testing.widgets', 'id')], ], data: ['items' => $items], @@ -918,7 +922,7 @@ public function __toString(): string // Bypass the trait remap by calling the private method directly. $items = []; - for ($i = 1; $i <= 10; ++$i) { + for ($i = 1; $i <= 10; $i++) { $items[] = ['id' => $i]; } @@ -926,7 +930,7 @@ public function __toString(): string 'items' => ['required', 'array', 'max:5'], ]; $wildcardAttributes = []; - for ($i = 0; $i < 10; ++$i) { + for ($i = 0; $i < 10; $i++) { $key = "items.{$i}.id"; $preparedRules[$key] = ['required', 'integer', Rule::exists('testing.widgets', 'id')]; $wildcardAttributes[] = $key; @@ -957,11 +961,12 @@ public function __toString(): string setupGuardsDatabase(); $items = []; - for ($i = 1; $i <= 10; ++$i) { + for ($i = 1; $i <= 10; $i++) { $items[] = ['id' => $i]; } - $formRequest = new class extends FormRequest { + $formRequest = new class extends FormRequest + { use HasFluentRules; public static bool $failedValidationWasCalled = false; @@ -1018,7 +1023,7 @@ protected function failedValidation(Validator $validator): void setupGuardsDatabase(); $items = []; - for ($i = 1; $i <= 10; ++$i) { + for ($i = 1; $i <= 10; $i++) { $items[] = ['id' => $i]; } @@ -1056,7 +1061,7 @@ protected function failedValidation(Validator $validator): void // path, the top validator catches `max` before per-item wildcard // validation runs (and before batching). $items = []; - for ($i = 1; $i <= 20; ++$i) { + for ($i = 1; $i <= 20; $i++) { $items[] = ['id' => $i]; } @@ -1094,9 +1099,9 @@ protected function failedValidation(Validator $validator): void // `.*.` field names — so nested batching is a no-op. Pin this so any // future change to enable nested batching MUST also add the guards. $orders = []; - for ($o = 0; $o < 2; ++$o) { + for ($o = 0; $o < 2; $o++) { $items = []; - for ($i = 1; $i <= 2; ++$i) { + for ($i = 1; $i <= 2; $i++) { $items[] = ['id' => $i]; } @@ -1169,7 +1174,7 @@ protected function failedValidation(Validator $validator): void // Parent max is 50, so Phase 3 (parent-max) does NOT fire — but Phase 2 // (hard cap, override to 5) does, applied to filter+dedup'd values. $items = []; - for ($i = 1; $i <= 10; ++$i) { + for ($i = 1; $i <= 10; $i++) { $items[] = ['id' => $i]; } @@ -1214,7 +1219,7 @@ protected function failedValidation(Validator $validator): void setupGuardsDatabase(); $items = []; - for ($i = 1; $i <= 10; ++$i) { + for ($i = 1; $i <= 10; $i++) { $items[] = ['id' => $i]; } @@ -1242,7 +1247,7 @@ protected function failedValidation(Validator $validator): void setupGuardsDatabase(); $items = []; - for ($i = 1; $i <= 10; ++$i) { + for ($i = 1; $i <= 10; $i++) { $items[] = ['uuid' => sprintf('%08d-0000-0000-0000-000000000000', $i)]; } @@ -1251,7 +1256,7 @@ protected function failedValidation(Validator $validator): void 'items' => ['required', 'array', 'max:3'], ]; $wildcardAttributes = []; - for ($i = 0; $i < 10; ++$i) { + for ($i = 0; $i < 10; $i++) { $key = "items.{$i}.uuid"; $preparedRules[$key] = ['required', 'uuid', Rule::unique('testing.widgets', 'uuid')]; $wildcardAttributes[] = $key; @@ -1282,7 +1287,7 @@ protected function failedValidation(Validator $validator): void setupGuardsDatabase(); $items = []; - for ($i = 1; $i <= 11; ++$i) { + for ($i = 1; $i <= 11; $i++) { $items[] = ['id' => $i]; } @@ -1369,7 +1374,7 @@ protected function failedValidation(Validator $validator): void // fires, and it must not exhaust memory or blow past a sensible wall-clock // budget. $items = []; - for ($i = 1; $i <= 50_000; ++$i) { + for ($i = 1; $i <= 50_000; $i++) { $items[] = ['id' => $i]; } diff --git a/tests/Builder/EmailNodeRulesTest.php b/tests/Builder/EmailNodeRulesTest.php index 48edc0a..09a5909 100644 --- a/tests/Builder/EmailNodeRulesTest.php +++ b/tests/Builder/EmailNodeRulesTest.php @@ -1,12 +1,14 @@ -toHaveCount(1); })->with([ 'notDisposable' => [fn (EmailRule $e): EmailRule => $e->notDisposable(), NotDisposableEmail::class], - 'notRole' => [fn (EmailRule $e): EmailRule => $e->notRole(), NotRoleEmail::class], - 'domainIs' => [fn (EmailRule $e): EmailRule => $e->domainIs(['example.com']), EmailDomainIs::class], - 'domainIsNot' => [fn (EmailRule $e): EmailRule => $e->domainIsNot(['spam.test']), EmailDomainIsNot::class], + 'notRole' => [fn (EmailRule $e): EmailRule => $e->notRole(), NotRoleEmail::class], + 'domainIs' => [fn (EmailRule $e): EmailRule => $e->domainIs(['example.com']), EmailDomainIs::class], + 'domainIsNot' => [fn (EmailRule $e): EmailRule => $e->domainIsNot(['spam.test']), EmailDomainIsNot::class], ]); it('accepts a single domain as a string', function (): void { @@ -59,7 +61,7 @@ it('resolves the list lazily, so a rule set can be built before the container has one', function (): void { // The constructor must not touch the container: a rule set built in a // queued job or a data provider is constructed long before validation. - $rule = new NotDisposableEmail(); + $rule = new NotDisposableEmail; expect((fn (): mixed => $this->domains)->call($rule))->toBeNull() ->and(ruleAccepts($rule, 'alice@mailinator.com')) diff --git a/tests/CheckAndFakeTest.php b/tests/CheckAndFakeTest.php index 02da567..922ca6c 100644 --- a/tests/CheckAndFakeTest.php +++ b/tests/CheckAndFakeTest.php @@ -1,11 +1,13 @@ -toBeTrue() - ->and(Check::rule(new Iban(), 'nope'))->toBeFalse(); + expect(Check::rule(new Iban, 'DE89370400440532013000'))->toBeTrue() + ->and(Check::rule(new Iban, 'nope'))->toBeFalse(); }); // --------------------------------------------------------------------------- @@ -80,7 +82,7 @@ it('toSchema() exports the wire schema in one call', function (): void { $schema = RuleSet::from([ 'email' => 'required|email|unique:users', - 'age' => 'nullable|integer|min:18', + 'age' => 'nullable|integer|min:18', ])->toSchema(attributes: ['email' => 'work email']); expect($schema['version'])->toBe(1) diff --git a/tests/ConditionalEvaluationPhaseTest.php b/tests/ConditionalEvaluationPhaseTest.php index 068ea66..f650f32 100644 --- a/tests/ConditionalEvaluationPhaseTest.php +++ b/tests/ConditionalEvaluationPhaseTest.php @@ -1,7 +1,9 @@ - [['exclude_unless', 'type', 'A', 'B'], 'string'], - 'age' => ['integer'], + 'age' => ['integer'], ]; expect($phase->indexConditionalAttrs($rules))->toBe([ @@ -24,7 +26,7 @@ }); it('indexes exclude_if tuples', function (): void { - $phase = new ConditionalEvaluationPhase(); + $phase = new ConditionalEvaluationPhase; $rules = [ 'name' => [['exclude_if', 'type', 'X']], @@ -38,11 +40,11 @@ }); it('skips non-conditional tuples', function (): void { - $phase = new ConditionalEvaluationPhase(); + $phase = new ConditionalEvaluationPhase; $rules = [ 'name' => [['required_if', 'other', 'Y']], - 'age' => 'integer', + 'age' => 'integer', ]; expect($phase->indexConditionalAttrs($rules)) @@ -50,7 +52,7 @@ }); it('skips malformed tuples (under 3 elements)', function (): void { - $phase = new ConditionalEvaluationPhase(); + $phase = new ConditionalEvaluationPhase; $rules = [ 'name' => [['exclude_unless', 'type']], @@ -61,7 +63,7 @@ }); it('skips tuples with non-string field', function (): void { - $phase = new ConditionalEvaluationPhase(); + $phase = new ConditionalEvaluationPhase; $rules = [ 'name' => [['exclude_unless', 123, 'A']], @@ -72,7 +74,7 @@ }); it('exclude_unless excludes when value not in list', function (): void { - $phase = new ConditionalEvaluationPhase(); + $phase = new ConditionalEvaluationPhase; $tuples = [ ['action' => 'exclude_unless', 'field' => 'type', 'values' => ['A', 'B']], ]; @@ -83,7 +85,7 @@ }); it('exclude_unless does not exclude when value in list', function (): void { - $phase = new ConditionalEvaluationPhase(); + $phase = new ConditionalEvaluationPhase; $tuples = [ ['action' => 'exclude_unless', 'field' => 'type', 'values' => ['A', 'B']], ]; @@ -94,7 +96,7 @@ }); it('exclude_if excludes when value in list', function (): void { - $phase = new ConditionalEvaluationPhase(); + $phase = new ConditionalEvaluationPhase; $tuples = [ ['action' => 'exclude_if', 'field' => 'type', 'values' => ['X']], ]; @@ -105,7 +107,7 @@ }); it('exclude_if does not exclude when value not in list', function (): void { - $phase = new ConditionalEvaluationPhase(); + $phase = new ConditionalEvaluationPhase; $tuples = [ ['action' => 'exclude_if', 'field' => 'type', 'values' => ['X']], ]; @@ -116,10 +118,10 @@ }); it('caches getValue lookups across tuples in one evaluate call', function (): void { - $phase = new ConditionalEvaluationPhase(); + $phase = new ConditionalEvaluationPhase; $callCount = 0; $getValue = function () use (&$callCount): string { - ++$callCount; + $callCount++; return 'A'; }; @@ -140,7 +142,7 @@ // exclude_if does not fire → NotExcluded (not Defer). The matcher decides // null/bool/non-scalar correctly now; Defer is reserved for an unresolved // wildcard whose position has no matching attribute segment. - $phase = new ConditionalEvaluationPhase(); + $phase = new ConditionalEvaluationPhase; $tuples = [ ['action' => 'exclude_if', 'field' => 'type', 'values' => ['']], ]; @@ -153,7 +155,7 @@ it('defers only when a wildcard position has no matching attribute segment', function (): void { // attribute 'name' has one segment (index 0); the dep 'a.*.type' wildcard // sits at index 1 with no attribute segment to map to → stays a wildcard → Defer. - $phase = new ConditionalEvaluationPhase(); + $phase = new ConditionalEvaluationPhase; $tuples = [ ['action' => 'exclude_unless', 'field' => 'a.*.type', 'values' => ['a']], ]; @@ -203,7 +205,7 @@ }); it('evaluate resolves wildcard before lookup', function (): void { - $phase = new ConditionalEvaluationPhase(); + $phase = new ConditionalEvaluationPhase; $captured = null; $getValue = function (string $field) use (&$captured): string { $captured = $field; diff --git a/tests/ConditionalOptimizationTest.php b/tests/ConditionalOptimizationTest.php index a3f87c9..f49bcdb 100644 --- a/tests/ConditionalOptimizationTest.php +++ b/tests/ConditionalOptimizationTest.php @@ -3,8 +3,8 @@ declare(strict_types=1); use Illuminate\Validation\Rule; -use Illuminate\Validation\ValidationException; use Simtabi\Laranail\Validation\RuleSet; +use Illuminate\Validation\ValidationException; /** * Tests for conditional pre-evaluation optimizations in RuleSet::validate(). @@ -23,7 +23,7 @@ it('does not error on excluded conditional fields in RuleSet::validate()', function (): void { // isbn is required, but excluded for dvd. Should pass without error. $validated = RuleSet::from([ - 'items' => 'required|array', + 'items' => 'required|array', 'items.*.type' => ['required', 'string', Rule::in(['book', 'dvd'])], 'items.*.isbn' => [['exclude_unless', 'items.*.type', 'book'], 'required', 'string', 'min:10'], ])->validate([ @@ -38,7 +38,7 @@ it('errors on surviving conditional fields that fail validation', function (): void { expect(fn () => RuleSet::from([ - 'items' => 'required|array', + 'items' => 'required|array', 'items.*.type' => ['required', 'string', Rule::in(['book', 'dvd'])], 'items.*.isbn' => [['exclude_unless', 'items.*.type', 'book'], 'required', 'string', 'min:10'], ])->validate([ @@ -51,8 +51,8 @@ it('handles exclude_if conditions correctly', function (): void { // price is excluded when type=free. Should pass. $validated = RuleSet::from([ - 'items' => 'required|array', - 'items.*.type' => ['required', 'string'], + 'items' => 'required|array', + 'items.*.type' => ['required', 'string'], 'items.*.price' => [['exclude_if', 'items.*.type', 'free'], 'required', 'numeric', 'min:1'], ])->validate([ 'items' => [ @@ -68,8 +68,8 @@ // style_top excluded unless type is button, text, or image. // frame type should pass even with invalid style_top. $validated = RuleSet::from([ - 'items' => 'required|array', - 'items.*.type' => ['required', 'string', Rule::in(['button', 'text', 'image', 'frame'])], + 'items' => 'required|array', + 'items.*.type' => ['required', 'string', Rule::in(['button', 'text', 'image', 'frame'])], 'items.*.style_top' => [['exclude_unless', 'items.*.type', 'button', 'text', 'image'], 'required', 'string', 'min:3'], ])->validate([ 'items' => [ @@ -89,13 +89,13 @@ it('validates 20 items with 2 types using dispatch table', function (): void { $items = array_map(fn (int $i): array => [ - 'type' => $i % 2 === 0 ? 'chapter' : 'button', + 'type' => $i % 2 === 0 ? 'chapter' : 'button', 'title' => "Item {$i}", ], range(1, 20)); $validated = RuleSet::from([ - 'items' => 'required|array', - 'items.*.type' => ['required', 'string', Rule::in(['chapter', 'button'])], + 'items' => 'required|array', + 'items.*.type' => ['required', 'string', Rule::in(['chapter', 'button'])], 'items.*.title' => [['exclude_unless', 'items.*.type', 'chapter'], 'required', 'string'], ])->validate(['items' => $items]); @@ -110,10 +110,10 @@ it('validates conditional fields with fast-checkable rules after stripping', function (): void { // After stripping exclude_unless, "boolean" and "nullable|string" are fast-checkable. $validated = RuleSet::from([ - 'items' => 'required|array', - 'items.*.type' => ['required', 'string', Rule::in(['chapter', 'button'])], + 'items' => 'required|array', + 'items.*.type' => ['required', 'string', Rule::in(['chapter', 'button'])], 'items.*.collapsed' => [['exclude_unless', 'items.*.type', 'chapter'], 'boolean'], - 'items.*.text' => [['exclude_unless', 'items.*.type', 'button'], 'nullable', 'string'], + 'items.*.text' => [['exclude_unless', 'items.*.type', 'button'], 'nullable', 'string'], ])->validate([ 'items' => [ ['type' => 'chapter', 'collapsed' => false, 'text' => null], @@ -128,8 +128,8 @@ it('fast-checks stringified In/NotIn objects', function (): void { $validated = RuleSet::from([ - 'items' => 'required|array', - 'items.*.type' => ['required', 'string', Rule::in(['chapter', 'button'])], + 'items' => 'required|array', + 'items.*.type' => ['required', 'string', Rule::in(['chapter', 'button'])], 'items.*.speed' => [['exclude_unless', 'items.*.type', 'button'], 'string', Rule::in(['slow', 'normal', 'fast'])], ])->validate([ 'items' => [ @@ -143,8 +143,8 @@ it('errors when stringified In rule fails on non-excluded item', function (): void { expect(fn () => RuleSet::from([ - 'items' => 'required|array', - 'items.*.type' => ['required', 'string', Rule::in(['chapter', 'button'])], + 'items' => 'required|array', + 'items.*.type' => ['required', 'string', Rule::in(['chapter', 'button'])], 'items.*.speed' => [['exclude_unless', 'items.*.type', 'button'], 'string', Rule::in(['slow', 'normal', 'fast'])], ])->validate([ 'items' => [ @@ -159,9 +159,9 @@ it('handles mix of conditional and unconditional rules', function (): void { $validated = RuleSet::from([ - 'items' => 'required|array', - 'items.*.type' => ['required', 'string'], - 'items.*.name' => 'required|string|max:255', // unconditional + 'items' => 'required|array', + 'items.*.type' => ['required', 'string'], + 'items.*.name' => 'required|string|max:255', // unconditional 'items.*.chapters' => [['exclude_unless', 'items.*.type', 'chapter'], 'array'], ])->validate([ 'items' => [ @@ -176,9 +176,9 @@ it('handles items with no conditional rules', function (): void { $validated = RuleSet::from([ - 'items' => 'required|array', + 'items' => 'required|array', 'items.*.name' => 'required|string|max:255', - 'items.*.age' => 'required|numeric|min:0', + 'items.*.age' => 'required|numeric|min:0', ])->validate([ 'items' => [ ['name' => 'Alice', 'age' => 30], @@ -193,7 +193,7 @@ it('all items excluded for a conditional field still pass validation', function (): void { // All items are "dvd" — isbn excluded for all. required doesn't trigger. $validated = RuleSet::from([ - 'items' => 'required|array', + 'items' => 'required|array', 'items.*.type' => ['required', 'string'], 'items.*.isbn' => [['exclude_unless', 'items.*.type', 'book'], 'required', 'string'], ])->validate([ diff --git a/tests/ConsoleCommandsTest.php b/tests/ConsoleCommandsTest.php index 1ef43f7..bc9302e 100644 --- a/tests/ConsoleCommandsTest.php +++ b/tests/ConsoleCommandsTest.php @@ -1,4 +1,6 @@ - '2099-01-01'], - ['date' => FluentRule::date()->after('today')] + ['date' => FluentRule::date()->after('today')], ); expect($validator->passes())->toBeTrue(); @@ -31,14 +33,14 @@ it('validates date with format', function (): void { $v = makeValidator( ['date' => '01/15/2025'], - ['date' => FluentRule::date()->format('m/d/Y')] + ['date' => FluentRule::date()->format('m/d/Y')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['date' => '2025-01-15'], - ['date' => FluentRule::date()->format('m/d/Y')] + ['date' => FluentRule::date()->format('m/d/Y')], ); expect($v->passes())->toBeFalse(); @@ -47,7 +49,7 @@ it('validates datetime shortcut', function (): void { $validator = makeValidator( ['timestamp' => '2025-01-15 14:30:00'], - ['timestamp' => FluentRule::dateTime()] + ['timestamp' => FluentRule::dateTime()], ); expect($validator->passes())->toBeTrue(); diff --git a/tests/DependentBooleanRuleRemovalParityTest.php b/tests/DependentBooleanRuleRemovalParityTest.php index 919eb53..73e7b34 100644 --- a/tests/DependentBooleanRuleRemovalParityTest.php +++ b/tests/DependentBooleanRuleRemovalParityTest.php @@ -1,8 +1,10 @@ - FluentRule::boolean(), - 'items.*.email' => FluentRule::string()->requiredIf('items.*.notify', 'true'), + 'items.*.email' => FluentRule::string()->requiredIf('items.*.notify', 'true'), ]); } } @@ -41,7 +43,7 @@ function vanillaWildcardFails(mixed $notify): bool ['items' => [['notify' => $notify]]], [ 'items.*.notify' => ['boolean'], - 'items.*.email' => ['required_if:items.*.notify,true', 'string'], + 'items.*.email' => ['required_if:items.*.notify,true', 'string'], ], )->fails(); } @@ -62,12 +64,12 @@ function optimizedWildcardFails(mixed $notify): bool // the fast-check phase, and not booleans, so the conversion mattered. expect(optimizedWildcardFails($notify))->toBe(vanillaWildcardFails($notify)); })->with([ - 'int one' => 1, - 'string one' => '1', - 'real true' => true, - 'int zero' => 0, + 'int one' => 1, + 'string one' => '1', + 'real true' => true, + 'int zero' => 0, 'string zero' => '0', - 'real false' => false, + 'real false' => false, ]); it('does not start requiring the field when the condition is genuinely unmet', function (): void { @@ -83,7 +85,7 @@ function optimizedWildcardFails(mixed $notify): bool $vanilla = Validator::make($data, [ 'items.*.notify' => ['string'], - 'items.*.email' => ['required_if:items.*.notify,true', 'string'], + 'items.*.email' => ['required_if:items.*.notify,true', 'string'], ]); expect($vanilla->fails())->toBeFalse(); diff --git a/tests/DocCountsTest.php b/tests/DocCountsTest.php index 259ab59..131a224 100644 --- a/tests/DocCountsTest.php +++ b/tests/DocCountsTest.php @@ -1,4 +1,6 @@ - FluentRule::boolean(), - 'email' => FluentRule::string()->excludeUnless('notify', 'true')->required(), + 'email' => FluentRule::string()->excludeUnless('notify', 'true')->required(), ]); } } @@ -40,7 +42,7 @@ public function __construct(array $data) { parent::__construct($data, [ 'notify' => FluentRule::boolean(), - 'email' => FluentRule::string()->excludeIf('notify', 'true')->required(), + 'email' => FluentRule::string()->excludeIf('notify', 'true')->required(), ]); } } @@ -61,12 +63,12 @@ function vanillaExcluding(string $rule, mixed $notify): array expect(array_key_exists('email', $optimized))->toBe(array_key_exists('email', $vanilla)); })->with([ - 'int one' => 1, - 'string one' => '1', - 'real true' => true, - 'int zero' => 0, + 'int one' => 1, + 'string one' => '1', + 'real true' => true, + 'int zero' => 0, 'string zero' => '0', - 'real false' => false, + 'real false' => false, ]); it('keeps exclude_if in step with Laravel for the same values', function (mixed $notify): void { @@ -75,12 +77,12 @@ function vanillaExcluding(string $rule, mixed $notify): array expect(array_key_exists('email', $optimized))->toBe(array_key_exists('email', $vanilla)); })->with([ - 'int one' => 1, - 'string one' => '1', - 'real true' => true, - 'int zero' => 0, + 'int one' => 1, + 'string one' => '1', + 'real true' => true, + 'int zero' => 0, 'string zero' => '0', - 'real false' => false, + 'real false' => false, ]); it('still excludes when the dependent is not declared boolean', function (): void { @@ -91,7 +93,7 @@ function vanillaExcluding(string $rule, mixed $notify): array $vanilla = Validator::make($data, [ 'notify' => ['string'], - 'email' => ['exclude_unless:notify,true', 'required', 'string'], + 'email' => ['exclude_unless:notify,true', 'required', 'string'], ])->validate(); expect($vanilla)->not->toHaveKey('email'); diff --git a/tests/ExcludeCoercionParityTest.php b/tests/ExcludeCoercionParityTest.php index 1fd3003..b28c455 100644 --- a/tests/ExcludeCoercionParityTest.php +++ b/tests/ExcludeCoercionParityTest.php @@ -1,8 +1,10 @@ - $native - * @param array $fluent - * @param array $data + * @param array $native + * @param array $fluent + * @param array $data */ function assertExcludeParity(array $native, array $fluent, array $data): void { @@ -32,7 +34,7 @@ function assertExcludeParity(array $native, array $fluent, array $data): void assertExcludeParity( ['items.*.detail' => ['exclude_unless:items.*.state,null', 'required', 'string']], ['items.*.detail' => [['exclude_unless', 'items.*.state', 'null'], 'required', 'string']], - ['items' => [['state' => null]]], // state IS null → not excluded → required fires + ['items' => [['state' => null]]], // state IS null → not excluded → required fires ); }); @@ -40,7 +42,7 @@ function assertExcludeParity(array $native, array $fluent, array $data): void assertExcludeParity( ['items.*.detail' => ['exclude_if:items.*.state,null', 'required', 'string']], ['items.*.detail' => [['exclude_if', 'items.*.state', 'null'], 'required', 'string']], - ['items' => [['state' => null]]], // state IS null → excluded → no error + ['items' => [['state' => null]]], // state IS null → excluded → no error ); }); @@ -76,7 +78,7 @@ function assertExcludeParity(array $native, array $fluent, array $data): void assertExcludeParity( ['items.*.x' => ['exclude_unless:items.*.n,1', 'required', 'string']], ['items.*.x' => [['exclude_unless', 'items.*.n', '1'], 'required', 'string']], - ['items' => [['n' => 1]]], // int 1 ↔ '1' loose match → not excluded → required fires + ['items' => [['n' => 1]]], // int 1 ↔ '1' loose match → not excluded → required fires ); }); @@ -143,7 +145,7 @@ function assertExcludeParity(array $native, array $fluent, array $data): void assertExcludeParity( ['items.*.name' => ['exclude_if:items.*.flag,null', 'required', 'string']], ['items.*.name' => [['exclude_if', 'items.*.flag', 'null'], 'required', 'string']], - ['items' => [['other' => 'x']]], // flag absent + ['items' => [['other' => 'x']]], // flag absent ); }); @@ -151,7 +153,7 @@ function assertExcludeParity(array $native, array $fluent, array $data): void assertExcludeParity( ['items.*.name' => ['exclude_if:items.*.flag,null', 'required', 'string']], ['items.*.name' => [['exclude_if', 'items.*.flag', 'null'], 'required', 'string']], - ['items' => [['flag' => null]]], // flag present and null → excluded → no error + ['items' => [['flag' => null]]], // flag present and null → excluded → no error ); }); @@ -164,9 +166,7 @@ function assertExcludeParity(array $native, array $fluent, array $data): void 'items.*.name' => ['exclude_if:items.*.flag,null', 'string'], ])->validate(); - $validator = new class ($data, [ - 'items.*.name' => [['exclude_if', 'items.*.flag', 'null'], 'string'], - ]) extends FluentValidator {}; + $validator = new class($data, ['items.*.name' => [['exclude_if', 'items.*.flag', 'null'], 'string']]) extends FluentValidator {}; expect($validator->validate())->toBe($native) ->and($native)->toBe(['items' => [['name' => 'present']]]); // name kept, not dropped @@ -182,10 +182,7 @@ function assertExcludeParity(array $native, array $fluent, array $data): void 'items.*.name' => ['exclude_if:items.*.flag,true', 'string'], ])->validate(); - $validator = new class ($data, [ - 'items.*.flag' => ['boolean'], - 'items.*.name' => [['exclude_if', 'items.*.flag', 'true'], 'string'], - ]) extends FluentValidator {}; + $validator = new class($data, ['items.*.flag' => ['boolean'], 'items.*.name' => [['exclude_if', 'items.*.flag', 'true'], 'string']]) extends FluentValidator {}; expect($validator->validate())->toBe($native); // name excluded in both }); @@ -198,7 +195,7 @@ function assertExcludeParity(array $native, array $fluent, array $data): void assertExcludeParity( ['items.*.x' => ['exclude_unless:items.*.type,a', 'exclude_if:items.*.other,z', 'required', 'string']], ['items.*.x' => [['exclude_unless', 'items.*.type', 'a'], ['exclude_if', 'items.*.other', 'z'], 'required', 'string']], - ['items' => [['type' => 'a', 'other' => 'z']]], + ['items' => [['type' => 'a', 'other' => 'z']]], ); }); @@ -206,7 +203,7 @@ function assertExcludeParity(array $native, array $fluent, array $data): void assertExcludeParity( ['items.*.x' => ['exclude_unless:items.*.type,a', 'exclude_if:items.*.other,z', 'required', 'string']], ['items.*.x' => [['exclude_unless', 'items.*.type', 'a'], ['exclude_if', 'items.*.other', 'z'], 'required', 'string']], - ['items' => [['type' => 'a', 'other' => 'keep']]], // type=a (unless ok), other≠z → kept → required fires + ['items' => [['type' => 'a', 'other' => 'keep']]], // type=a (unless ok), other≠z → kept → required fires ); }); @@ -219,7 +216,7 @@ function assertExcludeParity(array $native, array $fluent, array $data): void assertExcludeParity( ['items.*.x' => ['exclude_unless:items.*.type,a', 'required_if:items.*.other,y', 'string']], ['items.*.x' => [['exclude_unless', 'items.*.type', 'a'], ['required_if', 'items.*.other', 'y'], 'string']], - ['items' => [['type' => 'a', 'other' => 'y']]], // x missing + ['items' => [['type' => 'a', 'other' => 'y']]], // x missing ); }); @@ -227,7 +224,7 @@ function assertExcludeParity(array $native, array $fluent, array $data): void assertExcludeParity( ['items.*.x' => ['exclude_unless:items.*.type,a', 'required_unless:items.*.other,skip', 'string']], ['items.*.x' => [['exclude_unless', 'items.*.type', 'a'], ['required_unless', 'items.*.other', 'skip'], 'string']], - ['items' => [['type' => 'a', 'other' => 'go']]], // other≠skip → x required, missing → error + ['items' => [['type' => 'a', 'other' => 'go']]], // other≠skip → x required, missing → error ); }); diff --git a/tests/ExcludeStringFormPruningTest.php b/tests/ExcludeStringFormPruningTest.php index 0729a94..22e411b 100644 --- a/tests/ExcludeStringFormPruningTest.php +++ b/tests/ExcludeStringFormPruningTest.php @@ -1,4 +1,6 @@ - $rules - * @param array $data + * @param array $rules + * @param array $data */ function makePruneValidator(array $rules, array $data): FluentValidator { - return new class ($data, $rules) extends FluentValidator {}; + return new class($data, $rules) extends FluentValidator {}; } /** @return array */ @@ -41,14 +43,14 @@ function pauseItems(int $n): array $tuple = makePruneValidator([ 'items.*.type' => ['required', 'string'], - 'items.*.a' => [['exclude_unless', 'items.*.type', 'chapter'], 'required', 'string'], - 'items.*.b' => [['exclude_unless', 'items.*.type', 'chapter'], 'required', 'string'], + 'items.*.a' => [['exclude_unless', 'items.*.type', 'chapter'], 'required', 'string'], + 'items.*.b' => [['exclude_unless', 'items.*.type', 'chapter'], 'required', 'string'], ], $data); $fluent = makePruneValidator([ 'items.*.type' => ['required', 'string'], - 'items.*.a' => FluentRule::field()->excludeUnless('items.*.type', 'chapter')->required()->rule('string'), - 'items.*.b' => FluentRule::field()->excludeUnless('items.*.type', 'chapter')->required()->rule('string'), + 'items.*.a' => FluentRule::field()->excludeUnless('items.*.type', 'chapter')->required()->rule('string'), + 'items.*.b' => FluentRule::field()->excludeUnless('items.*.type', 'chapter')->required()->rule('string'), ], $data); // All 'pause' items exclude a + b → only the 10 `type` rules remain. @@ -60,9 +62,9 @@ function pauseItems(int $n): array $data = pauseItems(10); $validator = makePruneValidator([ - 'items.*.type' => ['required', 'string'], + 'items.*.type' => ['required', 'string'], 'items.*.style' => FluentRule::array()->children([ - 'top' => FluentRule::string()->excludeUnless('items.*.type', 'chapter')->required(), + 'top' => FluentRule::string()->excludeUnless('items.*.type', 'chapter')->required(), 'left' => FluentRule::string()->excludeUnless('items.*.type', 'chapter')->required(), ]), ], $data); @@ -83,7 +85,7 @@ function pauseItems(int $n): array $validator = makePruneValidator([ 'items.*.type' => ['required', 'string'], - 'items.*.a' => FluentRule::field()->excludeUnless('items.*.type', PruneType::CHAPTER, PruneType::MENU)->required()->rule('string'), + 'items.*.a' => FluentRule::field()->excludeUnless('items.*.type', PruneType::CHAPTER, PruneType::MENU)->required()->rule('string'), ], $data); // enum cases serialize to their ->value ('chapter','menu'); 'pause' is in @@ -96,7 +98,7 @@ function pauseItems(int $n): array $data = ['items' => array_map(static fn (int $i): array => ['type' => 'pause', 'start_time' => 1.0], range(1, $n))]; $rules = ['items.*.type' => ['required', 'string']]; // 20 fluent excludeUnless fields per item, all excluded for 'pause'. - for ($f = 0; $f < 20; ++$f) { + for ($f = 0; $f < 20; $f++) { $rules["items.*.f{$f}"] = FluentRule::field()->excludeUnless('items.*.type', 'chapter')->required()->rule('string'); } diff --git a/tests/ExpandsWildcardsTest.php b/tests/ExpandsWildcardsTest.php index b5e6297..ebd15fc 100644 --- a/tests/ExpandsWildcardsTest.php +++ b/tests/ExpandsWildcardsTest.php @@ -1,10 +1,12 @@ - FluentRule::array()->required()->each([ - 'name' => FluentRule::string()->required()->min(2), + 'name' => FluentRule::string()->required()->min(2), 'email' => FluentRule::string()->required()->rule('email'), ]), ], diff --git a/tests/FactoryMessageSeedingTest.php b/tests/FactoryMessageSeedingTest.php index e28f9c8..2fb8715 100644 --- a/tests/FactoryMessageSeedingTest.php +++ b/tests/FactoryMessageSeedingTest.php @@ -1,4 +1,6 @@ - 123], - ['name' => FluentRule::string()->messageFor('string', 'Must be text.')] + ['name' => FluentRule::string()->messageFor('string', 'Must be text.')], ); expect($v->passes())->toBeFalse() @@ -24,7 +26,7 @@ it('NumericRule: messageFor("numeric") surfaces on failing numeric rule', function (): void { $v = makeValidator( ['age' => 'abc'], - ['age' => FluentRule::numeric()->messageFor('numeric', 'Must be a number.')] + ['age' => FluentRule::numeric()->messageFor('numeric', 'Must be a number.')], ); expect($v->passes())->toBeFalse() @@ -34,7 +36,7 @@ it('BooleanRule: messageFor("boolean") surfaces on failing boolean rule', function (): void { $v = makeValidator( ['active' => 'notabool'], - ['active' => FluentRule::boolean()->messageFor('boolean', 'Must be a boolean.')] + ['active' => FluentRule::boolean()->messageFor('boolean', 'Must be a boolean.')], ); expect($v->passes())->toBeFalse() @@ -44,7 +46,7 @@ it('AcceptedRule: messageFor("accepted") surfaces on failing accepted rule', function (): void { $v = makeValidator( ['tos' => 'no'], - ['tos' => FluentRule::accepted()->messageFor('accepted', 'You must accept.')] + ['tos' => FluentRule::accepted()->messageFor('accepted', 'You must accept.')], ); expect($v->passes())->toBeFalse() @@ -54,7 +56,7 @@ it('DeclinedRule: messageFor("declined") surfaces on failing declined rule', function (): void { $v = makeValidator( ['opt_out' => 'yes'], - ['opt_out' => FluentRule::declined()->messageFor('declined', 'You must decline.')] + ['opt_out' => FluentRule::declined()->messageFor('declined', 'You must decline.')], ); expect($v->passes())->toBeFalse() @@ -64,7 +66,7 @@ it('FileRule: messageFor("file") surfaces on failing file rule', function (): void { $v = makeValidator( ['avatar' => 'notafile'], - ['avatar' => FluentRule::file()->messageFor('file', 'Must be a file.')] + ['avatar' => FluentRule::file()->messageFor('file', 'Must be a file.')], ); expect($v->passes())->toBeFalse() @@ -74,7 +76,7 @@ it('ImageRule: messageFor("image") surfaces on failing image rule', function (): void { $v = makeValidator( ['picture' => 'notanimage'], - ['picture' => FluentRule::image()->messageFor('image', 'Must be an image.')] + ['picture' => FluentRule::image()->messageFor('image', 'Must be an image.')], ); expect($v->passes())->toBeFalse() @@ -84,7 +86,7 @@ it('ArrayRule: messageFor("array") surfaces on failing array rule', function (): void { $v = makeValidator( ['items' => 'notarray'], - ['items' => FluentRule::array()->messageFor('array', 'Must be an array.')] + ['items' => FluentRule::array()->messageFor('array', 'Must be an array.')], ); expect($v->passes())->toBeFalse() @@ -94,7 +96,7 @@ it('EmailRule: messageFor("email") surfaces on failing email rule (plain branch)', function (): void { $v = makeValidator( ['contact' => 'notanemail'], - ['contact' => FluentRule::email(defaults: false)->messageFor('email', 'Must be valid email.')] + ['contact' => FluentRule::email(defaults: false)->messageFor('email', 'Must be valid email.')], ); expect($v->passes())->toBeFalse() @@ -104,7 +106,7 @@ it('EmailRule: messageFor("email") surfaces on failing email:modes branch', function (): void { $v = makeValidator( ['contact' => 'notanemail'], - ['contact' => FluentRule::email()->rfcCompliant()->messageFor('email', 'Must be valid email.')] + ['contact' => FluentRule::email()->rfcCompliant()->messageFor('email', 'Must be valid email.')], ); expect($v->passes())->toBeFalse() @@ -121,7 +123,7 @@ try { $v = makeValidator( ['contact' => 'notanemail'], - ['contact' => FluentRule::email()->messageFor('email', 'Must be valid email.')] + ['contact' => FluentRule::email()->messageFor('email', 'Must be valid email.')], ); expect($v->passes())->toBeFalse() @@ -145,7 +147,7 @@ it('StringRule: ->message() after factory binds to the implicit string rule', function (): void { $v = makeValidator( ['name' => 123], - ['name' => FluentRule::string()->message('Must be text.')] + ['name' => FluentRule::string()->message('Must be text.')], ); expect($v->passes())->toBeFalse() @@ -155,7 +157,7 @@ it('NumericRule: ->message() after factory binds to the implicit numeric rule', function (): void { $v = makeValidator( ['age' => 'abc'], - ['age' => FluentRule::numeric()->message('Must be a number.')] + ['age' => FluentRule::numeric()->message('Must be a number.')], ); expect($v->passes())->toBeFalse() @@ -165,7 +167,7 @@ it('BooleanRule: ->message() after factory binds to the implicit boolean rule', function (): void { $v = makeValidator( ['active' => 'notabool'], - ['active' => FluentRule::boolean()->message('Must be a boolean.')] + ['active' => FluentRule::boolean()->message('Must be a boolean.')], ); expect($v->passes())->toBeFalse() @@ -175,7 +177,7 @@ it('AcceptedRule: ->message() after factory binds to the implicit accepted rule', function (): void { $v = makeValidator( ['tos' => 'no'], - ['tos' => FluentRule::accepted()->message('You must accept.')] + ['tos' => FluentRule::accepted()->message('You must accept.')], ); expect($v->passes())->toBeFalse() @@ -185,7 +187,7 @@ it('DeclinedRule: ->message() after factory binds to the implicit declined rule', function (): void { $v = makeValidator( ['opt_out' => 'yes'], - ['opt_out' => FluentRule::declined()->message('You must decline.')] + ['opt_out' => FluentRule::declined()->message('You must decline.')], ); expect($v->passes())->toBeFalse() @@ -195,7 +197,7 @@ it('FileRule: ->message() after factory binds to the implicit file rule', function (): void { $v = makeValidator( ['avatar' => 'notafile'], - ['avatar' => FluentRule::file()->message('Must be a file.')] + ['avatar' => FluentRule::file()->message('Must be a file.')], ); expect($v->passes())->toBeFalse() @@ -205,7 +207,7 @@ it('ImageRule: ->message() after factory binds to the implicit image rule', function (): void { $v = makeValidator( ['picture' => 'notanimage'], - ['picture' => FluentRule::image()->message('Must be an image.')] + ['picture' => FluentRule::image()->message('Must be an image.')], ); expect($v->passes())->toBeFalse() @@ -215,7 +217,7 @@ it('ArrayRule: ->message() after factory binds to the implicit array rule', function (): void { $v = makeValidator( ['items' => 'notarray'], - ['items' => FluentRule::array()->message('Must be an array.')] + ['items' => FluentRule::array()->message('Must be an array.')], ); expect($v->passes())->toBeFalse() @@ -225,7 +227,7 @@ it('EmailRule: ->message() after factory binds to the implicit email rule', function (): void { $v = makeValidator( ['contact' => 'notanemail'], - ['contact' => FluentRule::email(defaults: false)->message('Must be valid email.')] + ['contact' => FluentRule::email(defaults: false)->message('Must be valid email.')], ); expect($v->passes())->toBeFalse() diff --git a/tests/FastCheck/FamilyCompilerTest.php b/tests/FastCheck/FamilyCompilerTest.php index 7461808..da51430 100644 --- a/tests/FastCheck/FamilyCompilerTest.php +++ b/tests/FastCheck/FamilyCompilerTest.php @@ -1,12 +1,14 @@ -toBeNull() diff --git a/tests/FastCheckArrayValueParityTest.php b/tests/FastCheckArrayValueParityTest.php index a557c56..2ccc397 100644 --- a/tests/FastCheckArrayValueParityTest.php +++ b/tests/FastCheckArrayValueParityTest.php @@ -1,4 +1,6 @@ - $data - * @param array $rules + * @param array $data + * @param array $rules */ public function __construct(array $data, array $rules) { diff --git a/tests/FastCheckCompilerCacheTest.php b/tests/FastCheckCompilerCacheTest.php index a93905f..c5d81ef 100644 --- a/tests/FastCheckCompilerCacheTest.php +++ b/tests/FastCheckCompilerCacheTest.php @@ -1,4 +1,6 @@ -and($second)->toBeInstanceOf(Closure::class) ->and($second)->not->toBe($first); })->with([ - 'after:today' => ['required|date|after:today'], - 'before:now' => ['required|date|before:now'], + 'after:today' => ['required|date|after:today'], + 'before:now' => ['required|date|before:now'], 'after_or_equal:tomorrow' => ['required|date|after_or_equal:tomorrow'], 'before_or_equal:+1 week' => ['required|date|before_or_equal:+1 week'], - 'date_equals:today' => ['required|date|date_equals:today'], - 'absolute date literal' => ['required|date|after:2030-01-01'], + 'date_equals:today' => ['required|date|date_equals:today'], + 'absolute date literal' => ['required|date|after:2030-01-01'], ]); /** @@ -55,7 +57,7 @@ // Compile 1024 distinct stable rules. None hit the cap-reset since // count >= MAX is checked BEFORE insert: at the 1024th call, // pre-insert count = 1023 (no reset), post-insert count = 1024. - for ($i = 1; $i <= 1024; ++$i) { + for ($i = 1; $i <= 1024; $i++) { FastCheckCompiler::compile('required|string|max:' . $i); } @@ -71,7 +73,7 @@ $original = FastCheckCompiler::compile('required|string|max:1'); // Fill to 1024 (rule #1 already counts as one of those). - for ($i = 2; $i <= 1024; ++$i) { + for ($i = 2; $i <= 1024; $i++) { FastCheckCompiler::compile('required|string|max:' . $i); } diff --git a/tests/FastCheckParityTest.php b/tests/FastCheckParityTest.php index c912711..d99f823 100644 --- a/tests/FastCheckParityTest.php +++ b/tests/FastCheckParityTest.php @@ -1,4 +1,6 @@ - ['value' => '2030-06-05', 'start_date' => '2030-06-01'], - 'both-valid-before' => ['value' => '2030-05-15', 'start_date' => '2030-06-01'], - 'both-valid-equal' => ['value' => '2030-06-01', 'start_date' => '2030-06-01'], + 'both-valid-after' => ['value' => '2030-06-05', 'start_date' => '2030-06-01'], + 'both-valid-before' => ['value' => '2030-05-15', 'start_date' => '2030-06-01'], + 'both-valid-equal' => ['value' => '2030-06-01', 'start_date' => '2030-06-01'], 'value-invalid-date' => ['value' => 'not-a-date', 'start_date' => '2030-06-01'], - 'ref-invalid-date' => ['value' => '2030-06-01', 'start_date' => 'not-a-date'], - 'value-null' => ['value' => null, 'start_date' => '2030-06-01'], - 'value-empty' => ['value' => '', 'start_date' => '2030-06-01'], - 'ref-null' => ['value' => '2030-06-01', 'start_date' => null], - 'ref-missing' => ['value' => '2030-06-01'], + 'ref-invalid-date' => ['value' => '2030-06-01', 'start_date' => 'not-a-date'], + 'value-null' => ['value' => null, 'start_date' => '2030-06-01'], + 'value-empty' => ['value' => '', 'start_date' => '2030-06-01'], + 'ref-null' => ['value' => '2030-06-01', 'start_date' => null], + 'ref-missing' => ['value' => '2030-06-01'], ]; foreach ($rules as $rule) { @@ -288,17 +290,17 @@ function itemAwareSameDifferentParityGrid(): iterable ]; $items = [ - 'equal-strings' => ['value' => 'foo', 'other' => 'foo'], - 'different-strings' => ['value' => 'foo', 'other' => 'bar'], - 'equal-ints' => ['value' => 7, 'other' => 7], - 'int-vs-string' => ['value' => 1, 'other' => '1'], - 'string-vs-int' => ['value' => '1', 'other' => 1], - 'both-null' => ['value' => null, 'other' => null], + 'equal-strings' => ['value' => 'foo', 'other' => 'foo'], + 'different-strings' => ['value' => 'foo', 'other' => 'bar'], + 'equal-ints' => ['value' => 7, 'other' => 7], + 'int-vs-string' => ['value' => 1, 'other' => '1'], + 'string-vs-int' => ['value' => '1', 'other' => 1], + 'both-null' => ['value' => null, 'other' => null], 'value-null-other-string' => ['value' => null, 'other' => 'foo'], 'value-string-other-null' => ['value' => 'foo', 'other' => null], - 'value-empty' => ['value' => '', 'other' => 'foo'], - 'other-missing' => ['value' => 'foo'], - 'value-and-other-empty' => ['value' => '', 'other' => ''], + 'value-empty' => ['value' => '', 'other' => 'foo'], + 'other-missing' => ['value' => 'foo'], + 'value-and-other-empty' => ['value' => '', 'other' => ''], ]; foreach ($rules as $rule) { @@ -359,10 +361,10 @@ function itemAwareSameDifferentParityGrid(): iterable expect(FastCheckCompiler::compileWithItemContext($rule))->toBeNull(); })->with([ 'plain string rule' => ['required|string|max:255'], - 'numeric rule' => ['required|numeric|min:0'], - 'email rule' => ['nullable|email'], - 'in-list rule' => ['required|in:a,b,c'], - 'regex rule' => ['required|regex:/^[a-z]+$/'], + 'numeric rule' => ['required|numeric|min:0'], + 'email rule' => ['nullable|email'], + 'in-list rule' => ['required|in:a,b,c'], + 'regex rule' => ['required|regex:/^[a-z]+$/'], 'integer with size' => ['required|integer|min:1|max:100'], ]); @@ -487,21 +489,21 @@ function itemAwarePresenceConditionalParityGrid(): iterable $items = [ // Basic presence shapes. - 'a-present-target-ok' => ['value' => 'label', 'a' => 'x'], + 'a-present-target-ok' => ['value' => 'label', 'a' => 'x'], 'a-present-target-missing' => ['value' => null, 'a' => 'x', 'b' => 'y'], - 'a-absent-target-missing' => ['value' => null], + 'a-absent-target-missing' => ['value' => null], // Whitespace-only sibling (Laravel treats as absent). - 'a-whitespace-target-ok' => ['value' => 'label', 'a' => ' '], + 'a-whitespace-target-ok' => ['value' => 'label', 'a' => ' '], 'a-whitespace-target-missing' => ['value' => null, 'a' => ' '], // Whitespace-only target with sibling present. 'a-present-target-whitespace' => ['value' => ' ', 'a' => 'x'], // Multi-param for _all variants. - 'ab-present-target-ok' => ['value' => 'label', 'a' => 'x', 'b' => 'y'], + 'ab-present-target-ok' => ['value' => 'label', 'a' => 'x', 'b' => 'y'], 'ab-one-missing-target-missing' => ['value' => null, 'a' => 'x'], - 'ab-whitespace-target-missing' => ['value' => null, 'a' => ' ', 'b' => ' '], + 'ab-whitespace-target-missing' => ['value' => null, 'a' => ' ', 'b' => ' '], // Empty array sibling (Laravel treats as absent). 'a-empty-array-target-missing' => ['value' => null, 'a' => []], @@ -690,26 +692,26 @@ function itemAwareSizeComparisonParityGrid(): iterable $items = [ // Numeric shapes - 'num:5-vs-3' => ['value' => 5, 'other' => 3], - 'num:3-vs-5' => ['value' => 3, 'other' => 5], - 'num:5-vs-5' => ['value' => 5, 'other' => 5], - 'num:5-vs-str5' => ['value' => 5, 'other' => '5'], - 'num:5-vs-abc' => ['value' => 5, 'other' => 'abc'], // ref falls through to mb_strlen - 'num:5-vs-null' => ['value' => 5, 'other' => null], + 'num:5-vs-3' => ['value' => 5, 'other' => 3], + 'num:3-vs-5' => ['value' => 3, 'other' => 5], + 'num:5-vs-5' => ['value' => 5, 'other' => 5], + 'num:5-vs-str5' => ['value' => 5, 'other' => '5'], + 'num:5-vs-abc' => ['value' => 5, 'other' => 'abc'], // ref falls through to mb_strlen + 'num:5-vs-null' => ['value' => 5, 'other' => null], 'num:5-vs-missing' => ['value' => 5], // String shapes (lengths) - 'str:hello-vs-hi' => ['value' => 'hello', 'other' => 'hi'], // 5 vs 2 - 'str:hi-vs-hello' => ['value' => 'hi', 'other' => 'hello'], // 2 vs 5 + 'str:hello-vs-hi' => ['value' => 'hello', 'other' => 'hi'], // 5 vs 2 + 'str:hi-vs-hello' => ['value' => 'hi', 'other' => 'hello'], // 2 vs 5 'str:same-vs-same' => ['value' => 'same', 'other' => 'same'], // 4 vs 4 - 'str:hi-vs-null' => ['value' => 'hi', 'other' => null], // 2 vs 0 - 'str:hi-vs-array' => ['value' => 'hi', 'other' => ['a', 'b']], // 2 vs count=2 + 'str:hi-vs-null' => ['value' => 'hi', 'other' => null], // 2 vs 0 + 'str:hi-vs-array' => ['value' => 'hi', 'other' => ['a', 'b']], // 2 vs count=2 // Array shapes (counts) - 'arr:3-vs-1' => ['value' => [1, 2, 3], 'other' => [1]], + 'arr:3-vs-1' => ['value' => [1, 2, 3], 'other' => [1]], 'arr:empty-vs-1' => ['value' => [], 'other' => [1]], - 'arr:1-vs-1' => ['value' => [1], 'other' => [1]], - 'arr:2-vs-str3' => ['value' => [1, 2], 'other' => 'abc'], // count 2 vs mb_strlen 3 + 'arr:1-vs-1' => ['value' => [1], 'other' => [1]], + 'arr:2-vs-str3' => ['value' => [1, 2], 'other' => 'abc'], // count 2 vs mb_strlen 3 // Nullable-value-null (should skip on nullable + non-implicit). 'null-value' => ['value' => null, 'other' => 3], diff --git a/tests/FieldRuleExtendHelpersTest.php b/tests/FieldRuleExtendHelpersTest.php index 54c22b3..45a6450 100644 --- a/tests/FieldRuleExtendHelpersTest.php +++ b/tests/FieldRuleExtendHelpersTest.php @@ -1,9 +1,11 @@ - 'not-a-file'], - ['doc' => FluentRule::file()->bail()->max(2048)] + ['doc' => FluentRule::file()->bail()->max(2048)], ); expect($validator->passes())->toBeFalse() ->and($validator->errors()->get('doc'))->toHaveCount(1); diff --git a/tests/FluentFormRequestTest.php b/tests/FluentFormRequestTest.php index a34c9b9..e27b425 100644 --- a/tests/FluentFormRequestTest.php +++ b/tests/FluentFormRequestTest.php @@ -1,19 +1,21 @@ - 'string|required|max:255', - 'items.*.qty' => 'numeric|required|min:1', + 'items.*.name' => 'string|required|max:255', + 'items.*.qty' => 'numeric|required|min:1', 'items.*.email' => 'required|string|email', // not fast-checkable ]); @@ -218,7 +220,7 @@ rules: [ 'items' => FluentRule::array()->required()->each([ 'name' => FluentRule::string('Name')->required()->max(255), - 'qty' => FluentRule::numeric('Quantity')->required()->min(1), + 'qty' => FluentRule::numeric('Quantity')->required()->min(1), ]), ], data: [ @@ -331,7 +333,7 @@ rules: [ 'items' => FluentRule::array()->required()->each([ 'name' => FluentRule::string()->required()->max(255), - 'qty' => FluentRule::numeric()->required()->min(1), + 'qty' => FluentRule::numeric()->required()->min(1), ]), ], data: [ @@ -411,7 +413,7 @@ rules: [ 'items' => FluentRule::array()->required()->each([ 'start' => FluentRule::numeric()->required(), - 'end' => FluentRule::numeric()->required()->greaterThanOrEqualTo('items.*.start'), + 'end' => FluentRule::numeric()->required()->greaterThanOrEqualTo('items.*.start'), ]), ], data: [ @@ -435,11 +437,11 @@ it('handles rules without wildcards', function (): void { $formRequest = createFluentFormRequest( rules: [ - 'name' => FluentRule::string('Name')->required()->max(255), + 'name' => FluentRule::string('Name')->required()->max(255), 'email' => FluentRule::email('Email')->required(), ], data: [ - 'name' => 'John', + 'name' => 'John', 'email' => 'john@example.com', ], ); @@ -634,7 +636,7 @@ $formRequest = createFluentFormRequest( rules: [ 'items' => FluentRule::array()->required()->each([ - 'name' => FluentRule::string()->required()->max(255), + 'name' => FluentRule::string()->required()->max(255), 'user_id' => FluentRule::numeric()->bail()->required()->integer() ->rule(function (string $attribute, mixed $value, Closure $fail) use (&$closureCalled): void { $closureCalled = true; @@ -667,7 +669,7 @@ $formRequest = createFluentFormRequest( rules: [ 'items' => FluentRule::array()->required()->each([ - 'name' => FluentRule::string()->required()->max(255), + 'name' => FluentRule::string()->required()->max(255), 'user_id' => FluentRule::numeric()->bail()->required()->integer() ->rule(function (string $attribute, mixed $value, Closure $fail) use (&$closureCalled, &$closureValue): void { $closureCalled = true; @@ -697,7 +699,7 @@ $formRequest = createFluentFormRequest( rules: [ 'items' => FluentRule::array()->required()->each([ - 'name' => FluentRule::string()->required()->max(255), // fast-checkable + 'name' => FluentRule::string()->required()->max(255), // fast-checkable 'score' => FluentRule::numeric()->required()->rule(function (string $attribute, mixed $value, Closure $fail) use (&$closureCalled): void { $closureCalled = true; if ($value > 100) { @@ -863,7 +865,7 @@ $formRequest = createFluentFormRequest( rules: [ 'items' => FluentRule::array()->required()->each([ - 'name' => FluentRule::string()->required()->max(255), // fast-checkable + 'name' => FluentRule::string()->required()->max(255), // fast-checkable 'email' => FluentRule::email()->required(), // NOT fast-checkable (email rule) ]), ], @@ -893,7 +895,7 @@ rules: [ 'items' => FluentRule::array()->required()->each([ 'name' => FluentRule::string()->required()->max(255), - 'qty' => FluentRule::numeric()->required()->min(1), + 'qty' => FluentRule::numeric()->required()->min(1), ]), ], data: [ @@ -922,13 +924,13 @@ rules: [ 'address' => FluentRule::field()->children([ 'street' => FluentRule::string()->required()->max(255), - 'city' => FluentRule::string()->required()->max(100), + 'city' => FluentRule::string()->required()->max(100), ]), ], data: [ 'address' => [ 'street' => '123 Main St', - 'city' => 'Springfield', + 'city' => 'Springfield', ], ], ); @@ -945,13 +947,13 @@ rules: [ 'address' => FluentRule::field()->children([ 'street' => FluentRule::string()->required()->max(255), - 'city' => FluentRule::string()->required()->max(100), + 'city' => FluentRule::string()->required()->max(100), ]), ], data: [ 'address' => [ 'street' => '', - 'city' => 'Springfield', + 'city' => 'Springfield', ], ], ); @@ -1045,7 +1047,7 @@ it('correctly identifies the specific invalid item among many valid ones', function (): void { $items = []; - for ($i = 0; $i < 50; ++$i) { + for ($i = 0; $i < 50; $i++) { $items[] = ['name' => 'Valid Name ' . $i, 'qty' => $i + 1]; } @@ -1056,7 +1058,7 @@ rules: [ 'items' => FluentRule::array()->required()->each([ 'name' => FluentRule::string()->required()->max(255), - 'qty' => FluentRule::numeric()->required()->min(1), + 'qty' => FluentRule::numeric()->required()->min(1), ]), ], data: ['items' => $items], @@ -1155,7 +1157,8 @@ // Wrap the flag in a tiny class so PHPStan tracks the mutation through // the closure — raw `use (&$bool)` loses the write in static analysis, // and stdClass + @var narrowing hits "property not writable". - $state = new class { + $state = new class + { public bool $customCalled = false; }; $factory->resolver(function (Translator $translator, array $data, array $rules, array $messages, array $attributes) use ($state): Validator { @@ -1257,14 +1260,15 @@ // ========================================================================= it('respects stopOnFirstFailure on the FormRequest', function (): void { - $formRequest = new class extends FluentFormRequest { + $formRequest = new class extends FluentFormRequest + { protected $stopOnFirstFailure = true; /** @return array */ public function rules(): array { return [ - 'name' => FluentRule::string()->required()->max(255), + 'name' => FluentRule::string()->required()->max(255), 'email' => FluentRule::email()->required(), ]; } @@ -1291,7 +1295,7 @@ public function authorize(): bool it('does not stop on first failure by default', function (): void { $formRequest = createFluentFormRequest( rules: [ - 'name' => FluentRule::string()->required()->max(255), + 'name' => FluentRule::string()->required()->max(255), 'email' => FluentRule::email()->required(), ], data: [], @@ -1314,7 +1318,8 @@ public function authorize(): bool // ========================================================================= it('auto-unwraps a RuleSet returned from rules()', function (): void { - $formRequest = new class extends FluentFormRequest { + $formRequest = new class extends FluentFormRequest + { public static RuleSet $testRuleSet; public function rules(): RuleSet @@ -1357,7 +1362,8 @@ public function authorize(): bool }); it('auto-unwraps a RuleSet built via fluent chain in rules()', function (): void { - $formRequest = new class extends FluentFormRequest { + $formRequest = new class extends FluentFormRequest + { public function rules(): RuleSet { return RuleSet::make() @@ -1388,7 +1394,8 @@ public function authorize(): bool */ function createFluentFormRequest(array $rules, array $data): FormRequest { - $formRequest = new class extends FluentFormRequest { + $formRequest = new class extends FluentFormRequest + { /** @var array */ public static array $testRules = []; diff --git a/tests/FluentRuleContractTest.php b/tests/FluentRuleContractTest.php index 4c9155f..4f82ef8 100644 --- a/tests/FluentRuleContractTest.php +++ b/tests/FluentRuleContractTest.php @@ -1,19 +1,21 @@ -` instead of a concrete-type union. /** @var array $rules */ $rules = [ - 'name' => FluentRule::string()->required()->min(2), - 'age' => FluentRule::numeric()->nullable()->integer()->min(0), + 'name' => FluentRule::string()->required()->min(2), + 'age' => FluentRule::numeric()->nullable()->integer()->min(0), 'email' => FluentRule::email()->required(), 'agree' => FluentRule::accepted(), ]; diff --git a/tests/FluentRuleTest.php b/tests/FluentRuleTest.php index ccfe81e..56b662a 100644 --- a/tests/FluentRuleTest.php +++ b/tests/FluentRuleTest.php @@ -1,24 +1,26 @@ - ['php', 'laravel']], - ['tags' => FluentRule::array()->required()->min(1)->max(10)] + ['tags' => FluentRule::array()->required()->min(1)->max(10)], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['tags' => []], - ['tags' => FluentRule::array()->required()->min(1)] + ['tags' => FluentRule::array()->required()->min(1)], ); expect($v->passes())->toBeFalse(); @@ -76,14 +78,14 @@ it('validates array with list', function (): void { $v = makeValidator( ['ids' => [1, 2, 3]], - ['ids' => FluentRule::array()->required()->list()->min(1)] + ['ids' => FluentRule::array()->required()->list()->min(1)], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['ids' => ['a' => 1, 'b' => 2]], - ['ids' => FluentRule::array()->required()->list()] + ['ids' => FluentRule::array()->required()->list()], ); expect($v->passes())->toBeFalse(); @@ -92,7 +94,7 @@ it('validates array with keys', function (): void { $validator = makeValidator( ['data' => ['name' => 'John', 'email' => 'john@example.com']], - ['data' => FluentRule::array(['name', 'email'])->required()] + ['data' => FluentRule::array(['name', 'email'])->required()], ); expect($validator->passes())->toBeTrue(); @@ -106,7 +108,7 @@ it('validates array with between', function (): void { $validator = makeValidator( ['items' => ['a', 'b', 'c']], - ['items' => FluentRule::array()->between(1, 5)] + ['items' => FluentRule::array()->between(1, 5)], ); expect($validator->passes())->toBeTrue(); @@ -115,14 +117,14 @@ it('validates array with exactly', function (): void { $v = makeValidator( ['items' => ['a', 'b']], - ['items' => FluentRule::array()->exactly(2)] + ['items' => FluentRule::array()->exactly(2)], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['items' => ['a', 'b', 'c']], - ['items' => FluentRule::array()->exactly(2)] + ['items' => FluentRule::array()->exactly(2)], ); expect($v->passes())->toBeFalse(); @@ -131,14 +133,14 @@ it('validates array each() with scalar rule standalone', function (): void { $v = makeValidator( ['tags' => ['php', 'laravel']], - ['tags' => FluentRule::array()->required()->each(FluentRule::string()->max(50))] + ['tags' => FluentRule::array()->required()->each(FluentRule::string()->max(50))], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['tags' => ['php', 123]], - ['tags' => FluentRule::array()->required()->each(FluentRule::string()->max(50))] + ['tags' => FluentRule::array()->required()->each(FluentRule::string()->max(50))], ); expect($v->passes())->toBeFalse() @@ -150,8 +152,8 @@ ['items' => [['name' => 'John', 'age' => 25], ['name' => 'Jane', 'age' => 30]]], ['items' => FluentRule::array()->required()->each([ 'name' => FluentRule::string()->required()->min(2), - 'age' => FluentRule::numeric()->required()->min(0), - ])] + 'age' => FluentRule::numeric()->required()->min(0), + ])], ); expect($v->passes())->toBeTrue(); @@ -160,8 +162,8 @@ ['items' => [['name' => 'J', 'age' => 25]]], ['items' => FluentRule::array()->required()->each([ 'name' => FluentRule::string()->required()->min(2), - 'age' => FluentRule::numeric()->required()->min(0), - ])] + 'age' => FluentRule::numeric()->required()->min(0), + ])], ); expect($v->passes())->toBeFalse() @@ -171,7 +173,7 @@ it('produces indexed error keys for standalone each() scalar', function (): void { $validator = makeValidator( ['tags' => ['valid', 123]], - ['tags' => FluentRule::array()->required()->each(FluentRule::string())] + ['tags' => FluentRule::array()->required()->each(FluentRule::string())], ); expect($validator->passes())->toBeFalse() @@ -183,7 +185,7 @@ ['items' => [['name' => 'Ok'], ['name' => '']]], ['items' => FluentRule::array()->required()->each([ 'name' => FluentRule::string()->required()->min(2), - ])] + ])], ); expect($validator->passes())->toBeFalse() @@ -194,8 +196,8 @@ $v = makeValidator( ['matrix' => [['a', 'b'], ['c', 'd']]], ['matrix' => FluentRule::array()->required()->each( - FluentRule::array()->each(FluentRule::string()->max(10)) - )] + FluentRule::array()->each(FluentRule::string()->max(10)), + )], ); expect($v->passes())->toBeTrue(); @@ -203,8 +205,8 @@ $v = makeValidator( ['matrix' => [['a', 123], ['c', 'd']]], ['matrix' => FluentRule::array()->required()->each( - FluentRule::array()->each(FluentRule::string()->max(10)) - )] + FluentRule::array()->each(FluentRule::string()->max(10)), + )], ); expect($v->passes())->toBeFalse(); @@ -216,7 +218,7 @@ ['search' => FluentRule::array()->required()->children([ 'value' => FluentRule::string()->required(), 'regex' => FluentRule::boolean()->required(), - ])] + ])], ); expect($v->passes())->toBeTrue(); @@ -226,7 +228,7 @@ ['search' => FluentRule::array()->required()->children([ 'value' => FluentRule::string()->required(), 'regex' => FluentRule::boolean()->required(), - ])] + ])], ); expect($v->passes())->toBeFalse() @@ -244,7 +246,7 @@ 'host' => FluentRule::string()->required(), 'port' => FluentRule::numeric()->required()->integer(), ]), - ])] + ])], ); expect($v->passes())->toBeTrue(); @@ -256,7 +258,7 @@ 'host' => FluentRule::string()->required(), 'port' => FluentRule::numeric()->required()->integer(), ]), - ])] + ])], ); expect($v->passes())->toBeFalse() @@ -271,10 +273,10 @@ ]], ['items' => FluentRule::array()->required()->each([ 'action' => FluentRule::array()->required()->children([ - 'type' => FluentRule::string()->required(), + 'type' => FluentRule::string()->required(), 'target' => FluentRule::string()->required(), ]), - ])] + ])], ); expect($v->passes())->toBeTrue(); @@ -286,10 +288,10 @@ ]], ['items' => FluentRule::array()->required()->each([ 'action' => FluentRule::array()->required()->children([ - 'type' => FluentRule::string()->required(), + 'type' => FluentRule::string()->required(), 'target' => FluentRule::string()->required(), ]), - ])] + ])], ); expect($v->passes())->toBeFalse() @@ -312,13 +314,13 @@ it('validates anyOf passes when any rule matches', function (): void { $v = makeValidator( ['contact' => 'user@example.com'], - ['contact' => FluentRule::anyOf([FluentRule::string()->email(), FluentRule::string()->url()])] + ['contact' => FluentRule::anyOf([FluentRule::string()->email(), FluentRule::string()->url()])], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['contact' => 'https://example.com'], - ['contact' => FluentRule::anyOf([FluentRule::string()->email(), FluentRule::string()->url()])] + ['contact' => FluentRule::anyOf([FluentRule::string()->email(), FluentRule::string()->url()])], ); expect($v->passes())->toBeTrue(); }); @@ -326,7 +328,7 @@ it('validates anyOf fails when no rule matches', function (): void { $validator = makeValidator( ['contact' => 'not-email-or-url'], - ['contact' => FluentRule::anyOf([FluentRule::string()->email(), FluentRule::string()->url()])] + ['contact' => FluentRule::anyOf([FluentRule::string()->email(), FluentRule::string()->url()])], ); expect($validator->passes())->toBeFalse(); }); @@ -417,13 +419,13 @@ // required_if:published,1 — matches when published is "1" or 1 (form input) $v = makeValidator( ['published' => '0'], - ['title' => FluentRule::string()->requiredIf('published', true)] + ['title' => FluentRule::string()->requiredIf('published', true)], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['published' => '1'], - ['title' => FluentRule::string()->requiredIf('published', true)] + ['title' => FluentRule::string()->requiredIf('published', true)], ); expect($v->passes())->toBeFalse(); }); @@ -432,13 +434,13 @@ // required_if:active,0 — matches when active is "0" or 0 $v = makeValidator( ['active' => '0', 'reason' => 'testing'], - ['reason' => FluentRule::string()->requiredIf('active', false)] + ['reason' => FluentRule::string()->requiredIf('active', false)], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['active' => '1'], - ['reason' => FluentRule::string()->requiredIf('active', false)] + ['reason' => FluentRule::string()->requiredIf('active', false)], ); expect($v->passes())->toBeTrue(); // active is 1, not 0 → reason not required }); @@ -449,7 +451,7 @@ it('requiredIf accepts BackedEnum values', function (): void { $v = makeValidator( ['type' => 'active', 'reason' => 'needs review'], - ['reason' => FluentRule::string()->requiredIf('type', TestStringEnum::Active)] + ['reason' => FluentRule::string()->requiredIf('type', TestStringEnum::Active)], ); expect($v->passes())->toBeTrue(); }); @@ -543,7 +545,8 @@ // ========================================================================= it('message works on custom ValidationRule via class name fallback', function (): void { - $customRule = new class implements ValidationRule { + $customRule = new class implements ValidationRule + { public function validate(string $attribute, mixed $value, Closure $fail): void { if ($value !== 'valid') { @@ -606,7 +609,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates string with notIn rule passes', function (): void { $validator = makeValidator( ['role' => 'editor'], - ['role' => FluentRule::string()->required()->notIn(['admin', 'root'])] + ['role' => FluentRule::string()->required()->notIn(['admin', 'root'])], ); expect($validator->passes())->toBeTrue(); @@ -615,7 +618,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates string with notIn rule fails', function (): void { $validator = makeValidator( ['role' => 'admin'], - ['role' => FluentRule::string()->required()->notIn(['admin', 'root'])] + ['role' => FluentRule::string()->required()->notIn(['admin', 'root'])], ); expect($validator->passes())->toBeFalse(); @@ -628,14 +631,14 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates numeric with int enum', function (): void { $v = makeValidator( ['priority' => 1], - ['priority' => FluentRule::numeric()->required()->enum(TestIntEnum::class)] + ['priority' => FluentRule::numeric()->required()->enum(TestIntEnum::class)], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['priority' => 99], - ['priority' => FluentRule::numeric()->required()->enum(TestIntEnum::class)] + ['priority' => FluentRule::numeric()->required()->enum(TestIntEnum::class)], ); expect($v->passes())->toBeFalse(); @@ -648,7 +651,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('propagates error messages from sub-validator', function (): void { $validator = makeValidator( ['name' => ''], - ['name' => FluentRule::string()->required()->min(2)] + ['name' => FluentRule::string()->required()->min(2)], ); expect($validator->passes())->toBeFalse() @@ -658,7 +661,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('has no errors when validation passes', function (): void { $validator = makeValidator( ['name' => 'John'], - ['name' => FluentRule::string()->required()->min(2)] + ['name' => FluentRule::string()->required()->min(2)], ); expect($validator->passes())->toBeTrue() @@ -672,7 +675,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('uses label in error messages via SelfValidates', function (): void { $validator = makeValidator( ['name' => ''], - ['name' => FluentRule::string('Full Name')->required()] + ['name' => FluentRule::string('Full Name')->required()], ); expect($validator->passes())->toBeFalse() @@ -682,7 +685,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('uses per-rule message via SelfValidates', function (): void { $validator = makeValidator( ['name' => ''], - ['name' => FluentRule::string()->required()->message('We need your name!')] + ['name' => FluentRule::string()->required()->message('We need your name!')], ); expect($validator->passes())->toBeFalse() @@ -692,7 +695,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('uses label on numeric rule', function (): void { $validator = makeValidator( ['age' => 'not-a-number'], - ['age' => FluentRule::numeric('Your age')->required()] + ['age' => FluentRule::numeric('Your age')->required()], ); expect($validator->passes())->toBeFalse() @@ -702,7 +705,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('uses label on date rule', function (): void { $validator = makeValidator( ['starts_at' => 'not-a-date'], - ['starts_at' => FluentRule::date('Start Date')->required()] + ['starts_at' => FluentRule::date('Start Date')->required()], ); expect($validator->passes())->toBeFalse() @@ -712,7 +715,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('uses label on boolean rule', function (): void { $validator = makeValidator( ['agree' => 'not-a-bool'], - ['agree' => FluentRule::boolean('Terms Agreement')->required()] + ['agree' => FluentRule::boolean('Terms Agreement')->required()], ); expect($validator->passes())->toBeFalse() @@ -722,7 +725,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('uses message after in() rule', function (): void { $validator = makeValidator( ['role' => 'hacker'], - ['role' => FluentRule::string()->required()->in(['admin', 'user'])->message('Pick a valid role.')] + ['role' => FluentRule::string()->required()->in(['admin', 'user'])->message('Pick a valid role.')], ); expect($validator->passes())->toBeFalse() @@ -732,7 +735,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('uses message after requiredIf with closure', function (): void { $validator = makeValidator( [], - ['name' => FluentRule::string()->requiredIf(fn (): true => true)->message('Conditionally required!')] + ['name' => FluentRule::string()->requiredIf(fn (): true => true)->message('Conditionally required!')], ); expect($validator->passes())->toBeFalse() @@ -748,7 +751,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void ['name' => ''], ['name' => FluentRule::string() ->required()->message('Name is required.') - ->min(2)->message('Name too short.')] + ->min(2)->message('Name too short.')], ); expect($validator->passes())->toBeFalse() @@ -758,7 +761,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('uses message inside when() conditional', function (): void { $validator = makeValidator( ['password' => 'short'], - ['password' => FluentRule::string()->required()->when(true, fn (StringRule $r): StringRule => $r->min(12)->message('Admin passwords need 12+ chars.'))] + ['password' => FluentRule::string()->required()->when(true, fn (StringRule $r): StringRule => $r->min(12)->message('Admin passwords need 12+ chars.'))], ); expect($validator->passes())->toBeFalse() @@ -772,13 +775,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates boolean with acceptedIf', function (): void { $v = makeValidator( ['tos' => true, 'country' => 'US'], - ['tos' => FluentRule::boolean()->acceptedIf('country', 'US')] + ['tos' => FluentRule::boolean()->acceptedIf('country', 'US')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['tos' => false, 'country' => 'US'], - ['tos' => FluentRule::boolean()->acceptedIf('country', 'US')] + ['tos' => FluentRule::boolean()->acceptedIf('country', 'US')], ); expect($v->passes())->toBeFalse(); }); @@ -786,13 +789,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates boolean with declinedIf', function (): void { $v = makeValidator( ['opt_in' => false, 'type' => 'minor'], - ['opt_in' => FluentRule::boolean()->declinedIf('type', 'minor')] + ['opt_in' => FluentRule::boolean()->declinedIf('type', 'minor')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['opt_in' => true, 'type' => 'minor'], - ['opt_in' => FluentRule::boolean()->declinedIf('type', 'minor')] + ['opt_in' => FluentRule::boolean()->declinedIf('type', 'minor')], ); expect($v->passes())->toBeFalse(); }); @@ -830,7 +833,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('FluentRule::accepted() supports label', function (): void { $v = makeValidator( ['agree' => false], - ['agree' => FluentRule::accepted('Terms Agreement')->required()] + ['agree' => FluentRule::accepted('Terms Agreement')->required()], ); expect($v->passes())->toBeFalse() ->and($v->errors()->first('agree'))->toContain('Terms Agreement'); @@ -839,19 +842,19 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('FluentRule::accepted()->acceptedIf() replaces unconditional accepted', function (): void { $v = makeValidator( ['tos' => false, 'country' => 'DE'], - ['tos' => FluentRule::accepted()->acceptedIf('country', 'US')] + ['tos' => FluentRule::accepted()->acceptedIf('country', 'US')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['tos' => false, 'country' => 'US'], - ['tos' => FluentRule::accepted()->acceptedIf('country', 'US')] + ['tos' => FluentRule::accepted()->acceptedIf('country', 'US')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( ['tos' => true, 'country' => 'US'], - ['tos' => FluentRule::accepted()->acceptedIf('country', 'US')] + ['tos' => FluentRule::accepted()->acceptedIf('country', 'US')], ); expect($v->passes())->toBeTrue(); }); @@ -862,7 +865,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // $this->constraints via addRule()). $v = makeValidator( [], - ['tos' => FluentRule::accepted()->required()->acceptedIf('country', 'US')] + ['tos' => FluentRule::accepted()->required()->acceptedIf('country', 'US')], ); expect($v->passes())->toBeFalse() ->and($v->errors()->first('tos'))->toContain('required'); @@ -871,7 +874,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('FluentRule::accepted()->nullable() allows null without accepted check', function (): void { $v = makeValidator( ['tos' => null], - ['tos' => FluentRule::accepted()->nullable()] + ['tos' => FluentRule::accepted()->nullable()], ); expect($v->passes())->toBeTrue(); }); @@ -890,13 +893,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates array with requiredArrayKeys', function (): void { $v = makeValidator( ['data' => ['name' => 'John', 'email' => 'john@test.com']], - ['data' => FluentRule::array()->requiredArrayKeys('name', 'email')] + ['data' => FluentRule::array()->requiredArrayKeys('name', 'email')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['data' => ['name' => 'John']], - ['data' => FluentRule::array()->requiredArrayKeys('name', 'email')] + ['data' => FluentRule::array()->requiredArrayKeys('name', 'email')], ); expect($v->passes())->toBeFalse(); }); @@ -904,13 +907,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates array with BackedEnum keys', function (): void { $v = makeValidator( ['data' => ['low' => 1, 'medium' => 2]], - ['data' => FluentRule::array([TestArrayKeyEnum::Low, TestArrayKeyEnum::Medium])] + ['data' => FluentRule::array([TestArrayKeyEnum::Low, TestArrayKeyEnum::Medium])], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['data' => ['low' => 1, 'medium' => 2, 'unknown' => 3]], - ['data' => FluentRule::array([TestArrayKeyEnum::Low, TestArrayKeyEnum::Medium])] + ['data' => FluentRule::array([TestArrayKeyEnum::Low, TestArrayKeyEnum::Medium])], ); expect($v->passes())->toBeFalse(); }); @@ -946,13 +949,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates missingIf', function (): void { $v = makeValidator( ['type' => 'free'], - ['price' => FluentRule::string()->missingIf('type', 'free')] + ['price' => FluentRule::string()->missingIf('type', 'free')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['type' => 'free', 'price' => '100'], - ['price' => FluentRule::string()->missingIf('type', 'free')] + ['price' => FluentRule::string()->missingIf('type', 'free')], ); expect($v->passes())->toBeFalse(); }); @@ -960,13 +963,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates missingUnless', function (): void { $v = makeValidator( ['type' => 'free'], - ['price' => FluentRule::string()->missingUnless('type', 'paid')] + ['price' => FluentRule::string()->missingUnless('type', 'paid')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['type' => 'free', 'price' => '100'], - ['price' => FluentRule::string()->missingUnless('type', 'paid')] + ['price' => FluentRule::string()->missingUnless('type', 'paid')], ); expect($v->passes())->toBeFalse(); }); @@ -974,13 +977,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates missingWith', function (): void { $v = makeValidator( ['coupon' => 'ABC'], - ['discount' => FluentRule::string()->missingWith('coupon')] + ['discount' => FluentRule::string()->missingWith('coupon')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['coupon' => 'ABC', 'discount' => '10'], - ['discount' => FluentRule::string()->missingWith('coupon')] + ['discount' => FluentRule::string()->missingWith('coupon')], ); expect($v->passes())->toBeFalse(); }); @@ -988,13 +991,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates missingWithAll', function (): void { $v = makeValidator( ['a' => '1', 'b' => '2'], - ['c' => FluentRule::string()->missingWithAll('a', 'b')] + ['c' => FluentRule::string()->missingWithAll('a', 'b')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['a' => '1', 'b' => '2', 'c' => '3'], - ['c' => FluentRule::string()->missingWithAll('a', 'b')] + ['c' => FluentRule::string()->missingWithAll('a', 'b')], ); expect($v->passes())->toBeFalse(); }); @@ -1006,13 +1009,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates requiredUnless with field and value', function (): void { $v = makeValidator( ['role' => 'guest'], - ['name' => FluentRule::string()->requiredUnless('role', 'guest')] + ['name' => FluentRule::string()->requiredUnless('role', 'guest')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['role' => 'admin'], - ['name' => FluentRule::string()->requiredUnless('role', 'guest')] + ['name' => FluentRule::string()->requiredUnless('role', 'guest')], ); expect($v->passes())->toBeFalse(); }); @@ -1039,13 +1042,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates requiredWith', function (): void { $v = makeValidator( ['email' => 'test@test.com'], - ['name' => FluentRule::string()->requiredWith('email')] + ['name' => FluentRule::string()->requiredWith('email')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( [], - ['name' => FluentRule::string()->requiredWith('email')] + ['name' => FluentRule::string()->requiredWith('email')], ); expect($v->passes())->toBeTrue(); }); @@ -1053,13 +1056,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates requiredWithAll', function (): void { $v = makeValidator( ['first' => 'a', 'last' => 'b'], - ['full' => FluentRule::string()->requiredWithAll('first', 'last')] + ['full' => FluentRule::string()->requiredWithAll('first', 'last')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( ['first' => 'a'], - ['full' => FluentRule::string()->requiredWithAll('first', 'last')] + ['full' => FluentRule::string()->requiredWithAll('first', 'last')], ); expect($v->passes())->toBeTrue(); }); @@ -1067,13 +1070,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates requiredWithout', function (): void { $v = makeValidator( [], - ['name' => FluentRule::string()->requiredWithout('nickname')] + ['name' => FluentRule::string()->requiredWithout('nickname')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( ['nickname' => 'Johnny'], - ['name' => FluentRule::string()->requiredWithout('nickname')] + ['name' => FluentRule::string()->requiredWithout('nickname')], ); expect($v->passes())->toBeTrue(); }); @@ -1081,13 +1084,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates requiredWithoutAll', function (): void { $v = makeValidator( [], - ['name' => FluentRule::string()->requiredWithoutAll('first_name', 'last_name')] + ['name' => FluentRule::string()->requiredWithoutAll('first_name', 'last_name')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( ['first_name' => 'John'], - ['name' => FluentRule::string()->requiredWithoutAll('first_name', 'last_name')] + ['name' => FluentRule::string()->requiredWithoutAll('first_name', 'last_name')], ); expect($v->passes())->toBeTrue(); }); @@ -1099,7 +1102,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates excludeIf', function (): void { $validator = makeValidator( ['type' => 'free', 'price' => '100'], - ['price' => FluentRule::string()->excludeIf('type', 'free')] + ['price' => FluentRule::string()->excludeIf('type', 'free')], ); expect($validator->passes())->toBeTrue(); }); @@ -1107,7 +1110,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates excludeUnless', function (): void { $validator = makeValidator( ['type' => 'free', 'price' => '100'], - ['price' => FluentRule::string()->excludeUnless('type', 'paid')] + ['price' => FluentRule::string()->excludeUnless('type', 'paid')], ); expect($validator->passes())->toBeTrue(); }); @@ -1160,7 +1163,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates excludeWith', function (): void { $validator = makeValidator( ['other' => 'val', 'field' => 'val'], - ['field' => FluentRule::string()->excludeWith('other')] + ['field' => FluentRule::string()->excludeWith('other')], ); expect($validator->passes())->toBeTrue(); }); @@ -1168,7 +1171,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates excludeWithout', function (): void { $validator = makeValidator( ['field' => 'val'], - ['field' => FluentRule::string()->excludeWithout('other')] + ['field' => FluentRule::string()->excludeWithout('other')], ); expect($validator->passes())->toBeTrue(); }); @@ -1180,13 +1183,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates prohibitedIf with field and value', function (): void { $v = makeValidator( ['type' => 'free', 'price' => '100'], - ['price' => FluentRule::string()->prohibitedIf('type', 'free')] + ['price' => FluentRule::string()->prohibitedIf('type', 'free')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( ['type' => 'paid', 'price' => '100'], - ['price' => FluentRule::string()->prohibitedIf('type', 'free')] + ['price' => FluentRule::string()->prohibitedIf('type', 'free')], ); expect($v->passes())->toBeTrue(); }); @@ -1207,13 +1210,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates prohibitedUnless with field and value', function (): void { $v = makeValidator( ['type' => 'free', 'price' => '100'], - ['price' => FluentRule::string()->prohibitedUnless('type', 'paid')] + ['price' => FluentRule::string()->prohibitedUnless('type', 'paid')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( ['type' => 'paid', 'price' => '100'], - ['price' => FluentRule::string()->prohibitedUnless('type', 'paid')] + ['price' => FluentRule::string()->prohibitedUnless('type', 'paid')], ); expect($v->passes())->toBeTrue(); }); @@ -1229,13 +1232,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates prohibits', function (): void { $v = makeValidator( ['field' => 'val', 'other' => 'val'], - ['field' => FluentRule::string()->prohibits('other')] + ['field' => FluentRule::string()->prohibits('other')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( ['field' => 'val'], - ['field' => FluentRule::string()->prohibits('other')] + ['field' => FluentRule::string()->prohibits('other')], ); expect($v->passes())->toBeTrue(); }); @@ -1247,13 +1250,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates enum with callback modifier', function (): void { $v = makeValidator( ['status' => 'active'], - ['status' => FluentRule::string()->required()->enum(TestStringEnum::class, fn (Enum $rule): Enum => $rule->only(TestStringEnum::Active))] + ['status' => FluentRule::string()->required()->enum(TestStringEnum::class, fn (Enum $rule): Enum => $rule->only(TestStringEnum::Active))], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['status' => 'inactive'], - ['status' => FluentRule::string()->required()->enum(TestStringEnum::class, fn (Enum $rule): Enum => $rule->only(TestStringEnum::Active))] + ['status' => FluentRule::string()->required()->enum(TestStringEnum::class, fn (Enum $rule): Enum => $rule->only(TestStringEnum::Active))], ); expect($v->passes())->toBeFalse(); }); @@ -1497,7 +1500,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('compiles fluent rules to native format', function (): void { $compiled = RuleSet::compile([ 'name' => FluentRule::string()->required()->min(2), - 'age' => FluentRule::numeric()->integer(), + 'age' => FluentRule::numeric()->integer(), ]); expect($compiled)->toMatchArray(['name' => 'required|string|min:2', 'age' => 'numeric|integer']); }); @@ -1517,14 +1520,14 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('compileToArrays returns arrays for fluent rules', function (): void { $compiled = RuleSet::compileToArrays([ 'name' => FluentRule::string()->required()->min(2), - 'age' => FluentRule::numeric()->integer(), + 'age' => FluentRule::numeric()->integer(), ]); expect($compiled)->toMatchArray(['name' => ['required', 'string', 'min:2'], 'age' => ['numeric', 'integer']]); }); it('compileToArrays explodes string rules into arrays', function (): void { $compiled = RuleSet::compileToArrays([ - 'name' => 'required|string|max:255', + 'name' => 'required|string|max:255', 'email' => 'required|email', ]); expect($compiled)->toMatchArray(['name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email']]); @@ -1539,7 +1542,8 @@ public function validate(string $attribute, mixed $value, Closure $fail): void }); it('compileToArrays wraps standalone objects in arrays', function (): void { - $rule = new class implements ValidationRule { + $rule = new class implements ValidationRule + { public function validate(string $attribute, mixed $value, Closure $fail): void {} }; @@ -1552,9 +1556,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('compileToArrays handles mixed fluent and string rules', function (): void { $compiled = RuleSet::compileToArrays([ - 'name' => FluentRule::string()->required(), + 'name' => FluentRule::string()->required(), 'email' => 'required|email', - 'tags' => ['required', 'array'], + 'tags' => ['required', 'array'], ]); expect($compiled)->toMatchArray(['name' => ['required', 'string'], 'email' => ['required', 'email'], 'tags' => ['required', 'array']]); }); @@ -1606,14 +1610,14 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { $v = makeValidator( ['slug' => 'hello'], - ['slug' => FluentRule::string()->slug()] + ['slug' => FluentRule::string()->slug()], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['slug' => 'Hello World'], - ['slug' => FluentRule::string()->slug()] + ['slug' => FluentRule::string()->slug()], ); expect($v->passes())->toBeFalse(); @@ -1624,7 +1628,8 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { // ========================================================================= it('compiledRules returns array when rule contains non-stringable object', function (): void { - $nonStringable = new class implements ValidationRule { + $nonStringable = new class implements ValidationRule + { public function validate(string $attribute, mixed $value, Closure $fail): void {} }; @@ -1680,13 +1685,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('validates requiredIf with multiple values', function (): void { $v = makeValidator( ['role' => 'editor'], - ['name' => FluentRule::string()->requiredIf('role', 'admin', 'editor')] + ['name' => FluentRule::string()->requiredIf('role', 'admin', 'editor')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( ['role' => 'viewer'], - ['name' => FluentRule::string()->requiredIf('role', 'admin', 'editor')] + ['name' => FluentRule::string()->requiredIf('role', 'admin', 'editor')], ); expect($v->passes())->toBeTrue(); }); @@ -1694,13 +1699,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('validates requiredWith with multiple fields', function (): void { $v = makeValidator( ['first' => 'John'], - ['name' => FluentRule::string()->requiredWith('first', 'last')] + ['name' => FluentRule::string()->requiredWith('first', 'last')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( [], - ['name' => FluentRule::string()->requiredWith('first', 'last')] + ['name' => FluentRule::string()->requiredWith('first', 'last')], ); expect($v->passes())->toBeTrue(); }); @@ -1708,7 +1713,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('validates prohibits with multiple fields', function (): void { $validator = makeValidator( ['role' => 'admin', 'other' => 'x', 'another' => 'y'], - ['role' => FluentRule::string()->prohibits('other', 'another')] + ['role' => FluentRule::string()->prohibits('other', 'another')], ); expect($validator->passes())->toBeFalse(); }); @@ -1740,7 +1745,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { if ($value !== 'valid') { $fail("The {$attribute} must be valid."); } - })] + })], ); expect($v->passes())->toBeFalse(); @@ -1750,7 +1755,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { if ($value !== 'valid') { $fail("The {$attribute} must be valid."); } - })] + })], ); expect($v->passes())->toBeTrue(); }); @@ -1810,20 +1815,20 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('validates presentIf', function (): void { $v = makeValidator( ['type' => 'admin', 'role' => 'editor'], - ['role' => FluentRule::string()->presentIf('type', 'admin')] + ['role' => FluentRule::string()->presentIf('type', 'admin')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['type' => 'admin'], - ['role' => FluentRule::string()->presentIf('type', 'admin')] + ['role' => FluentRule::string()->presentIf('type', 'admin')], ); expect($v->passes())->toBeFalse(); // When condition doesn't match, field doesn't need to be present $v = makeValidator( ['type' => 'guest'], - ['role' => FluentRule::string()->presentIf('type', 'admin')] + ['role' => FluentRule::string()->presentIf('type', 'admin')], ); expect($v->passes())->toBeTrue(); }); @@ -1831,19 +1836,19 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('validates presentUnless', function (): void { $v = makeValidator( ['type' => 'admin'], - ['role' => FluentRule::string()->presentUnless('type', 'guest')] + ['role' => FluentRule::string()->presentUnless('type', 'guest')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( ['type' => 'admin', 'role' => 'editor'], - ['role' => FluentRule::string()->presentUnless('type', 'guest')] + ['role' => FluentRule::string()->presentUnless('type', 'guest')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['type' => 'guest'], - ['role' => FluentRule::string()->presentUnless('type', 'guest')] + ['role' => FluentRule::string()->presentUnless('type', 'guest')], ); expect($v->passes())->toBeTrue(); }); @@ -1851,19 +1856,19 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('validates presentWith', function (): void { $v = makeValidator( ['email' => 'test@test.com'], - ['name' => FluentRule::string()->presentWith('email')] + ['name' => FluentRule::string()->presentWith('email')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( ['email' => 'test@test.com', 'name' => 'John'], - ['name' => FluentRule::string()->presentWith('email')] + ['name' => FluentRule::string()->presentWith('email')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( [], - ['name' => FluentRule::string()->presentWith('email')] + ['name' => FluentRule::string()->presentWith('email')], ); expect($v->passes())->toBeTrue(); }); @@ -1871,20 +1876,20 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('validates presentWithAll', function (): void { $v = makeValidator( ['first' => 'a', 'last' => 'b'], - ['full' => FluentRule::string()->presentWithAll('first', 'last')] + ['full' => FluentRule::string()->presentWithAll('first', 'last')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( ['first' => 'a', 'last' => 'b', 'full' => 'a b'], - ['full' => FluentRule::string()->presentWithAll('first', 'last')] + ['full' => FluentRule::string()->presentWithAll('first', 'last')], ); expect($v->passes())->toBeTrue(); // Only one field present — not required $v = makeValidator( ['first' => 'a'], - ['full' => FluentRule::string()->presentWithAll('first', 'last')] + ['full' => FluentRule::string()->presentWithAll('first', 'last')], ); expect($v->passes())->toBeTrue(); }); @@ -1896,19 +1901,19 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('validates requiredIfAccepted', function (): void { $v = makeValidator( ['terms' => 'yes'], - ['signature' => FluentRule::string()->requiredIfAccepted('terms')] + ['signature' => FluentRule::string()->requiredIfAccepted('terms')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( ['terms' => 'yes', 'signature' => 'John Doe'], - ['signature' => FluentRule::string()->requiredIfAccepted('terms')] + ['signature' => FluentRule::string()->requiredIfAccepted('terms')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['terms' => 'no'], - ['signature' => FluentRule::string()->requiredIfAccepted('terms')] + ['signature' => FluentRule::string()->requiredIfAccepted('terms')], ); expect($v->passes())->toBeTrue(); }); @@ -1916,19 +1921,19 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('validates requiredIfDeclined', function (): void { $v = makeValidator( ['terms' => 'no'], - ['reason' => FluentRule::string()->requiredIfDeclined('terms')] + ['reason' => FluentRule::string()->requiredIfDeclined('terms')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( ['terms' => 'no', 'reason' => 'I disagree'], - ['reason' => FluentRule::string()->requiredIfDeclined('terms')] + ['reason' => FluentRule::string()->requiredIfDeclined('terms')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['terms' => 'yes'], - ['reason' => FluentRule::string()->requiredIfDeclined('terms')] + ['reason' => FluentRule::string()->requiredIfDeclined('terms')], ); expect($v->passes())->toBeTrue(); }); @@ -1940,13 +1945,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('validates contains on array', function (): void { $v = makeValidator( ['tags' => ['php', 'laravel']], - ['tags' => FluentRule::array()->contains('php')] + ['tags' => FluentRule::array()->contains('php')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['tags' => ['php', 'laravel']], - ['tags' => FluentRule::array()->contains('python')] + ['tags' => FluentRule::array()->contains('python')], ); expect($v->passes())->toBeFalse(); }); @@ -1954,13 +1959,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('validates doesntContain on array', function (): void { $v = makeValidator( ['tags' => ['php', 'laravel']], - ['tags' => FluentRule::array()->doesntContain('python')] + ['tags' => FluentRule::array()->doesntContain('python')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['tags' => ['php', 'laravel']], - ['tags' => FluentRule::array()->doesntContain('php')] + ['tags' => FluentRule::array()->doesntContain('php')], ); expect($v->passes())->toBeFalse(); }); @@ -1972,7 +1977,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('contains preserves value containing a comma (CSV-quoted)', function (): void { $v = makeValidator( ['tags' => ['a,b', 'other']], - ['tags' => FluentRule::array()->contains('a,b')] + ['tags' => FluentRule::array()->contains('a,b')], ); expect($v->passes())->toBeTrue(); @@ -1980,7 +1985,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { // and the validator would require both 'a' AND 'b' to be present. $v = makeValidator( ['tags' => ['a', 'b']], - ['tags' => FluentRule::array()->contains('a,b')] + ['tags' => FluentRule::array()->contains('a,b')], ); expect($v->passes())->toBeFalse(); }); @@ -1988,7 +1993,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('contains preserves value containing double quotes (escaped)', function (): void { $v = makeValidator( ['tags' => ['he said "hi"', 'other']], - ['tags' => FluentRule::array()->contains('he said "hi"')] + ['tags' => FluentRule::array()->contains('he said "hi"')], ); expect($v->passes())->toBeTrue(); }); @@ -1996,7 +2001,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('contains accepts BackedEnum by its value', function (): void { $v = makeValidator( ['statuses' => ['active', 'other']], - ['statuses' => FluentRule::array()->contains(TestStringEnum::Active)] + ['statuses' => FluentRule::array()->contains(TestStringEnum::Active)], ); expect($v->passes())->toBeTrue(); }); @@ -2004,13 +2009,14 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('contains accepts UnitEnum by its name', function (): void { $v = makeValidator( ['flags' => ['Foo']], - ['flags' => FluentRule::array()->contains(TestUnitEnum::Foo)] + ['flags' => FluentRule::array()->contains(TestUnitEnum::Foo)], ); expect($v->passes())->toBeTrue(); }); it('contains accepts Arrayable input (single arg expanded)', function (): void { - $arrayable = new class implements Arrayable { + $arrayable = new class implements Arrayable + { /** @return array */ public function toArray(): array { @@ -2020,7 +2026,7 @@ public function toArray(): array $v = makeValidator( ['tags' => ['php', 'laravel']], - ['tags' => FluentRule::array()->contains($arrayable)] + ['tags' => FluentRule::array()->contains($arrayable)], ); expect($v->passes())->toBeTrue(); }); @@ -2038,7 +2044,7 @@ public function toArray(): array it('contains variadic passthrough still works', function (): void { $v = makeValidator( ['tags' => ['a', 'b', 'c']], - ['tags' => FluentRule::array()->contains('a', 'b')] + ['tags' => FluentRule::array()->contains('a', 'b')], ); expect($v->passes())->toBeTrue(); }); @@ -2050,7 +2056,7 @@ public function toArray(): array $v = makeValidator( ['tags' => ['laravel']], - ['tags' => FluentRule::array()->contains('php')->message('Must contain PHP.')] + ['tags' => FluentRule::array()->contains('php')->message('Must contain PHP.')], ); expect($v->passes())->toBeFalse() ->and($v->errors()->first('tags'))->toBe('Must contain PHP.'); @@ -2065,13 +2071,13 @@ public function toArray(): array it('doesntContain preserves value containing a comma (CSV-quoted)', function (): void { $v = makeValidator( ['tags' => ['other']], - ['tags' => FluentRule::array()->doesntContain('a,b')] + ['tags' => FluentRule::array()->doesntContain('a,b')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['tags' => ['a,b', 'other']], - ['tags' => FluentRule::array()->doesntContain('a,b')] + ['tags' => FluentRule::array()->doesntContain('a,b')], ); expect($v->passes())->toBeFalse(); }); @@ -2079,19 +2085,20 @@ public function toArray(): array it('doesntContain accepts BackedEnum by its value', function (): void { $v = makeValidator( ['statuses' => ['inactive']], - ['statuses' => FluentRule::array()->doesntContain(TestStringEnum::Active)] + ['statuses' => FluentRule::array()->doesntContain(TestStringEnum::Active)], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['statuses' => ['active']], - ['statuses' => FluentRule::array()->doesntContain(TestStringEnum::Active)] + ['statuses' => FluentRule::array()->doesntContain(TestStringEnum::Active)], ); expect($v->passes())->toBeFalse(); }); it('doesntContain accepts Arrayable + single-array input', function (): void { - $arrayable = new class implements Arrayable { + $arrayable = new class implements Arrayable + { /** @return array */ public function toArray(): array { @@ -2101,14 +2108,14 @@ public function toArray(): array $v = makeValidator( ['tags' => ['php']], - ['tags' => FluentRule::array()->doesntContain($arrayable)] + ['tags' => FluentRule::array()->doesntContain($arrayable)], ); expect($v->passes())->toBeTrue(); // Single-array — equivalent to varargs $v = makeValidator( ['tags' => ['php', 'laravel']], - ['tags' => FluentRule::array()->doesntContain(['banned', 'evil'])] + ['tags' => FluentRule::array()->doesntContain(['banned', 'evil'])], ); expect($v->passes())->toBeTrue(); }); @@ -2119,7 +2126,7 @@ public function toArray(): array ['statuses' => FluentRule::array()->contains( TestStringEnum::Active, TestStringEnum::Inactive, - )] + )], ); expect($v->passes())->toBeTrue(); @@ -2128,7 +2135,7 @@ public function toArray(): array ['statuses' => FluentRule::array()->contains( TestStringEnum::Active, TestStringEnum::Inactive, - )] + )], ); expect($v->passes())->toBeFalse(); }); @@ -2147,7 +2154,7 @@ public function toArray(): array // customMessages without going through addRule's class-basename match. $v = makeValidator( ['tags' => ['laravel']], - ['tags' => FluentRule::array()->contains('php')->messageFor('contains', 'Must include PHP.')] + ['tags' => FluentRule::array()->contains('php')->messageFor('contains', 'Must include PHP.')], ); expect($v->passes())->toBeFalse() ->and($v->errors()->first('tags'))->toBe('Must include PHP.'); @@ -2174,14 +2181,14 @@ public function toArray(): array expect((string) $fluentContains)->toBe((string) $directRule); })->with([ - 'single string' => 'php', - 'single int' => 42, + 'single string' => 'php', + 'single int' => 42, 'single array unwrapped' => [['a', 'b', 'c']], 'Arrayable (Collection)' => fn () => collect(['a', 'b']), - 'BackedEnum single' => fn () => TestStringEnum::Active, - 'UnitEnum single' => fn () => TestUnitEnum::Foo, - 'embedded comma' => 'a,b', - 'embedded quote' => 'he said "hi"', + 'BackedEnum single' => fn () => TestStringEnum::Active, + 'UnitEnum single' => fn () => TestUnitEnum::Foo, + 'embedded comma' => 'a,b', + 'embedded quote' => 'he said "hi"', ]); // ========================================================================= @@ -2191,13 +2198,13 @@ public function toArray(): array it('validates url shortcut', function (): void { $v = makeValidator( ['website' => 'https://example.com'], - ['website' => FluentRule::url()->required()] + ['website' => FluentRule::url()->required()], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['website' => 'not-a-url'], - ['website' => FluentRule::url()->required()] + ['website' => FluentRule::url()->required()], ); expect($v->passes())->toBeFalse(); }); @@ -2205,13 +2212,13 @@ public function toArray(): array it('validates uuid shortcut', function (): void { $v = makeValidator( ['id' => '550e8400-e29b-41d4-a716-446655440000'], - ['id' => FluentRule::uuid()->required()] + ['id' => FluentRule::uuid()->required()], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['id' => 'not-a-uuid'], - ['id' => FluentRule::uuid()->required()] + ['id' => FluentRule::uuid()->required()], ); expect($v->passes())->toBeFalse(); }); @@ -2219,13 +2226,13 @@ public function toArray(): array it('validates ulid shortcut', function (): void { $v = makeValidator( ['id' => '01ARZ3NDEKTSV4RRFFQ69G5FAV'], - ['id' => FluentRule::ulid()->required()] + ['id' => FluentRule::ulid()->required()], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['id' => 'not-a-ulid'], - ['id' => FluentRule::ulid()->required()] + ['id' => FluentRule::ulid()->required()], ); expect($v->passes())->toBeFalse(); }); @@ -2233,13 +2240,13 @@ public function toArray(): array it('validates ip shortcut', function (): void { $v = makeValidator( ['address' => '192.168.1.1'], - ['address' => FluentRule::ip()->required()] + ['address' => FluentRule::ip()->required()], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['address' => 'not-an-ip'], - ['address' => FluentRule::ip()->required()] + ['address' => FluentRule::ip()->required()], ); expect($v->passes())->toBeFalse(); }); @@ -2355,13 +2362,13 @@ public function toArray(): array it('validates declinedIf', function (): void { $v = makeValidator( ['under_18' => 'yes', 'has_consent' => 'no'], - ['has_consent' => FluentRule::declined()->declinedIf('under_18', 'yes')] + ['has_consent' => FluentRule::declined()->declinedIf('under_18', 'yes')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['under_18' => 'yes', 'has_consent' => 'yes'], - ['has_consent' => FluentRule::declined()->declinedIf('under_18', 'yes')] + ['has_consent' => FluentRule::declined()->declinedIf('under_18', 'yes')], ); expect($v->passes())->toBeFalse(); }); @@ -2373,19 +2380,19 @@ public function toArray(): array it('validates prohibitedIfAccepted', function (): void { $v = makeValidator( ['terms' => 'yes', 'waiver' => 'some value'], - ['waiver' => FluentRule::string()->prohibitedIfAccepted('terms')] + ['waiver' => FluentRule::string()->prohibitedIfAccepted('terms')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( ['terms' => 'yes'], - ['waiver' => FluentRule::string()->prohibitedIfAccepted('terms')] + ['waiver' => FluentRule::string()->prohibitedIfAccepted('terms')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['terms' => 'no', 'waiver' => 'some value'], - ['waiver' => FluentRule::string()->prohibitedIfAccepted('terms')] + ['waiver' => FluentRule::string()->prohibitedIfAccepted('terms')], ); expect($v->passes())->toBeTrue(); }); @@ -2393,19 +2400,19 @@ public function toArray(): array it('validates prohibitedIfDeclined', function (): void { $v = makeValidator( ['terms' => 'no', 'reason' => 'I disagree'], - ['reason' => FluentRule::string()->prohibitedIfDeclined('terms')] + ['reason' => FluentRule::string()->prohibitedIfDeclined('terms')], ); expect($v->passes())->toBeFalse(); $v = makeValidator( ['terms' => 'no'], - ['reason' => FluentRule::string()->prohibitedIfDeclined('terms')] + ['reason' => FluentRule::string()->prohibitedIfDeclined('terms')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['terms' => 'yes', 'reason' => 'whatever'], - ['reason' => FluentRule::string()->prohibitedIfDeclined('terms')] + ['reason' => FluentRule::string()->prohibitedIfDeclined('terms')], ); expect($v->passes())->toBeTrue(); }); @@ -2417,7 +2424,7 @@ public function toArray(): array it('validates encoding', function (): void { $v = makeValidator( ['name' => 'hello'], - ['name' => FluentRule::string()->encoding('UTF-8')] + ['name' => FluentRule::string()->encoding('UTF-8')], ); expect($v->passes())->toBeTrue(); }); @@ -2441,7 +2448,8 @@ public function toArray(): array }); it('toArray returns array from object-compiled rules', function (): void { - $rule = FluentRule::string()->required()->rule(new class implements ValidationRule { + $rule = FluentRule::string()->required()->rule(new class implements ValidationRule + { public function validate(string $attribute, mixed $value, Closure $fail): void {} }); expect($rule->toArray())->toMatchArray([0 => 'required', 1 => 'string']); @@ -2449,7 +2457,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { it('RuleSet dump returns rules messages and attributes', function (): void { $dump = RuleSet::from([ - 'name' => FluentRule::string('Full Name')->required()->max(255), + 'name' => FluentRule::string('Full Name')->required()->max(255), 'email' => FluentRule::email()->required(), ])->dump(); diff --git a/tests/FluentSchemaTest.php b/tests/FluentSchemaTest.php index 858bd40..4948424 100644 --- a/tests/FluentSchemaTest.php +++ b/tests/FluentSchemaTest.php @@ -1,14 +1,16 @@ - $rule])->prepare(['field' => 'value']); return [ - 'rules' => $prepared->rules, - 'messages' => $prepared->messages, + 'rules' => $prepared->rules, + 'messages' => $prepared->messages, 'attributes' => $prepared->attributes, ]; } @@ -34,11 +36,12 @@ function preparedSnapshot(mixed $rule): array * A FormRequest that defines BOTH schema() and rules() — used to pin the * precedence contract (schema() wins). * - * @param array $data + * @param array $data */ function bootDualFormRequest(array $data): FormRequest { - $formRequest = new class extends FormRequest { + $formRequest = new class extends FormRequest + { use HasFluentRules; /** @return array */ @@ -145,7 +148,7 @@ function fluentRuleFactoryMethods(): array // ========================================================================= it('builds the same rule object as the static factory', function (): void { - $schema = new FluentSchema(); + $schema = new FluentSchema; expect($schema->string('Name')->required()->max(255)->compiledRules()) ->toBe(FluentRule::string('Name')->required()->max(255)->compiledRules()); @@ -156,7 +159,7 @@ function fluentRuleFactoryMethods(): array }); it('forwards every non-default argument identically to FluentRule', function (Closure $viaSchema, Closure $viaStatic): void { - expect(preparedSnapshot($viaSchema(new FluentSchema()))) + expect(preparedSnapshot($viaSchema(new FluentSchema))) ->toEqual(preparedSnapshot($viaStatic())); })->with([ 'string label+message' => [ @@ -195,26 +198,26 @@ function fluentRuleFactoryMethods(): array // each one proves it forwards to the RIGHT FluentRule factory — a wrong // target (e.g. url() calling uuid()) survives the signature parity check but // produces a different compiled rule here. - 'numeric' => [fn (FluentSchema $s): mixed => $s->numeric('L', 'm')->required(), fn (): mixed => FluentRule::numeric('L', 'm')->required()], - 'date' => [fn (FluentSchema $s): mixed => $s->date('L', 'm')->required(), fn (): mixed => FluentRule::date('L', 'm')->required()], - 'boolean' => [fn (FluentSchema $s): mixed => $s->boolean('L', 'm')->required(), fn (): mixed => FluentRule::boolean('L', 'm')->required()], - 'accepted' => [fn (FluentSchema $s): mixed => $s->accepted('L', 'm')->required(), fn (): mixed => FluentRule::accepted('L', 'm')->required()], - 'declined' => [fn (FluentSchema $s): mixed => $s->declined('L', 'm')->required(), fn (): mixed => FluentRule::declined('L', 'm')->required()], - 'file' => [fn (FluentSchema $s): mixed => $s->file('L', 'm')->required(), fn (): mixed => FluentRule::file('L', 'm')->required()], - 'image' => [fn (FluentSchema $s): mixed => $s->image('L', 'm')->required(), fn (): mixed => FluentRule::image('L', 'm')->required()], - 'url' => [fn (FluentSchema $s): mixed => $s->url('L', 'm')->required(), fn (): mixed => FluentRule::url('L', 'm')->required()], - 'uuid' => [fn (FluentSchema $s): mixed => $s->uuid('L', 'm')->required(), fn (): mixed => FluentRule::uuid('L', 'm')->required()], - 'ulid' => [fn (FluentSchema $s): mixed => $s->ulid('L', 'm')->required(), fn (): mixed => FluentRule::ulid('L', 'm')->required()], - 'ip' => [fn (FluentSchema $s): mixed => $s->ip('L', 'm')->required(), fn (): mixed => FluentRule::ip('L', 'm')->required()], - 'ipv4' => [fn (FluentSchema $s): mixed => $s->ipv4('L', 'm')->required(), fn (): mixed => FluentRule::ipv4('L', 'm')->required()], - 'ipv6' => [fn (FluentSchema $s): mixed => $s->ipv6('L', 'm')->required(), fn (): mixed => FluentRule::ipv6('L', 'm')->required()], + 'numeric' => [fn (FluentSchema $s): mixed => $s->numeric('L', 'm')->required(), fn (): mixed => FluentRule::numeric('L', 'm')->required()], + 'date' => [fn (FluentSchema $s): mixed => $s->date('L', 'm')->required(), fn (): mixed => FluentRule::date('L', 'm')->required()], + 'boolean' => [fn (FluentSchema $s): mixed => $s->boolean('L', 'm')->required(), fn (): mixed => FluentRule::boolean('L', 'm')->required()], + 'accepted' => [fn (FluentSchema $s): mixed => $s->accepted('L', 'm')->required(), fn (): mixed => FluentRule::accepted('L', 'm')->required()], + 'declined' => [fn (FluentSchema $s): mixed => $s->declined('L', 'm')->required(), fn (): mixed => FluentRule::declined('L', 'm')->required()], + 'file' => [fn (FluentSchema $s): mixed => $s->file('L', 'm')->required(), fn (): mixed => FluentRule::file('L', 'm')->required()], + 'image' => [fn (FluentSchema $s): mixed => $s->image('L', 'm')->required(), fn (): mixed => FluentRule::image('L', 'm')->required()], + 'url' => [fn (FluentSchema $s): mixed => $s->url('L', 'm')->required(), fn (): mixed => FluentRule::url('L', 'm')->required()], + 'uuid' => [fn (FluentSchema $s): mixed => $s->uuid('L', 'm')->required(), fn (): mixed => FluentRule::uuid('L', 'm')->required()], + 'ulid' => [fn (FluentSchema $s): mixed => $s->ulid('L', 'm')->required(), fn (): mixed => FluentRule::ulid('L', 'm')->required()], + 'ip' => [fn (FluentSchema $s): mixed => $s->ip('L', 'm')->required(), fn (): mixed => FluentRule::ip('L', 'm')->required()], + 'ipv4' => [fn (FluentSchema $s): mixed => $s->ipv4('L', 'm')->required(), fn (): mixed => FluentRule::ipv4('L', 'm')->required()], + 'ipv6' => [fn (FluentSchema $s): mixed => $s->ipv6('L', 'm')->required(), fn (): mixed => FluentRule::ipv6('L', 'm')->required()], 'macAddress' => [fn (FluentSchema $s): mixed => $s->macAddress('L', 'm')->required(), fn (): mixed => FluentRule::macAddress('L', 'm')->required()], - 'json' => [fn (FluentSchema $s): mixed => $s->json('L', 'm')->required(), fn (): mixed => FluentRule::json('L', 'm')->required()], - 'timezone' => [fn (FluentSchema $s): mixed => $s->timezone('L', 'm')->required(), fn (): mixed => FluentRule::timezone('L', 'm')->required()], - 'hexColor' => [fn (FluentSchema $s): mixed => $s->hexColor('L', 'm')->required(), fn (): mixed => FluentRule::hexColor('L', 'm')->required()], - 'activeUrl' => [fn (FluentSchema $s): mixed => $s->activeUrl('L', 'm')->required(), fn (): mixed => FluentRule::activeUrl('L', 'm')->required()], - 'list' => [fn (FluentSchema $s): mixed => $s->list('L', 'm')->required(), fn (): mixed => FluentRule::list('L', 'm')->required()], - 'field' => [fn (FluentSchema $s): mixed => $s->field('L')->required(), fn (): mixed => FluentRule::field('L')->required()], + 'json' => [fn (FluentSchema $s): mixed => $s->json('L', 'm')->required(), fn (): mixed => FluentRule::json('L', 'm')->required()], + 'timezone' => [fn (FluentSchema $s): mixed => $s->timezone('L', 'm')->required(), fn (): mixed => FluentRule::timezone('L', 'm')->required()], + 'hexColor' => [fn (FluentSchema $s): mixed => $s->hexColor('L', 'm')->required(), fn (): mixed => FluentRule::hexColor('L', 'm')->required()], + 'activeUrl' => [fn (FluentSchema $s): mixed => $s->activeUrl('L', 'm')->required(), fn (): mixed => FluentRule::activeUrl('L', 'm')->required()], + 'list' => [fn (FluentSchema $s): mixed => $s->list('L', 'm')->required(), fn (): mixed => FluentRule::list('L', 'm')->required()], + 'field' => [fn (FluentSchema $s): mixed => $s->field('L')->required(), fn (): mixed => FluentRule::field('L')->required()], ]); it('forwards anyOf to FluentRule when AnyOf is available', function (): void { @@ -228,14 +231,14 @@ function fluentRuleFactoryMethods(): array it('validates through RuleSet::define', function (): void { $validated = RuleSet::define(fn (FluentSchema $rules): array => [ - 'name' => $rules->string('Full Name')->required()->min(2)->max(255), + 'name' => $rules->string('Full Name')->required()->min(2)->max(255), 'email' => $rules->email()->required(), 'items' => $rules->array()->required()->each([ - 'id' => $rules->integer()->required(), + 'id' => $rules->integer()->required(), 'name' => $rules->string()->required(), ]), ])->validate([ - 'name' => 'Amara', + 'name' => 'Amara', 'email' => 'amara@example.com', 'items' => [['id' => 1, 'name' => 'Widget']], ]); @@ -295,7 +298,8 @@ function fluentRuleFactoryMethods(): array })->throws(ValidationException::class); it('does not hijack an unrelated schema() method that lacks a FluentSchema parameter', function (): void { - $formRequest = new class extends FormRequest { + $formRequest = new class extends FormRequest + { use HasFluentRules; /** A coincidental, non-validation schema() — must be left untouched. */ @@ -324,14 +328,15 @@ public function authorize(): bool }); it('answers rules() with the schema() output when a request defines only schema()', function (): void { - $request = new class extends FormRequest { + $request = new class extends FormRequest + { use HasFluentRules; /** @return array */ public function schema(FluentSchema $rules): array { return [ - 'title' => $rules->string()->required()->max(10), + 'title' => $rules->string()->required()->max(10), 'author' => $rules->string()->required(), ]; } @@ -347,7 +352,8 @@ public function authorize(): bool }); it('returns a RuleSet from rules() when schema() returns one', function (): void { - $request = new class extends FormRequest { + $request = new class extends FormRequest + { use HasFluentRules; public function schema(FluentSchema $rules): RuleSet @@ -367,7 +373,8 @@ public function authorize(): bool it('answers rules() on a directly-instantiated request before it is resolved', function (): void { // No bootFormRequest: rules() is called straight after `new`, so the // request's container is unset and the fallback must use the global one. - $request = new class extends FormRequest { + $request = new class extends FormRequest + { use HasFluentRules; /** @return array */ diff --git a/tests/FluentValidatorBatchDatabaseTest.php b/tests/FluentValidatorBatchDatabaseTest.php index fc1a77c..10eee3d 100644 --- a/tests/FluentValidatorBatchDatabaseTest.php +++ b/tests/FluentValidatorBatchDatabaseTest.php @@ -1,10 +1,12 @@ - 'testing']); config(['database.connections.testing' => [ - 'driver' => 'sqlite', + 'driver' => 'sqlite', 'database' => ':memory:', ]]); diff --git a/tests/FluentValidatorBenchTest.php b/tests/FluentValidatorBenchTest.php index 48e6b70..0f2bd18 100644 --- a/tests/FluentValidatorBenchTest.php +++ b/tests/FluentValidatorBenchTest.php @@ -1,9 +1,11 @@ - 'required|array|min:1', - 'interactions.*.type' => ['required', 'string', LaravelRule::in($types)], - 'interactions.*.title' => ['nullable', 'string'], - 'interactions.*.start_time' => ['required', 'numeric', 'min:0'], - 'interactions.*.end_time' => [['required_unless', 'interactions.*.type', 'pause'], 'numeric', 'gte:interactions.*.start_time'], - 'interactions.*.position' => [['exclude_unless', 'interactions.*.type', 'chapter', 'menu'], 'string'], - 'interactions.*.text' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'string'], - 'interactions.*.image_url' => [['exclude_unless', 'interactions.*.type', 'image', 'hotspot'], ['required_if', 'interactions.*.type', 'image'], 'nullable', 'string'], + 'interactions' => 'required|array|min:1', + 'interactions.*.type' => ['required', 'string', LaravelRule::in($types)], + 'interactions.*.title' => ['nullable', 'string'], + 'interactions.*.start_time' => ['required', 'numeric', 'min:0'], + 'interactions.*.end_time' => [['required_unless', 'interactions.*.type', 'pause'], 'numeric', 'gte:interactions.*.start_time'], + 'interactions.*.position' => [['exclude_unless', 'interactions.*.type', 'chapter', 'menu'], 'string'], + 'interactions.*.text' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'string'], + 'interactions.*.image_url' => [['exclude_unless', 'interactions.*.type', 'image', 'hotspot'], ['required_if', 'interactions.*.type', 'image'], 'nullable', 'string'], 'interactions.*.should_fade_in' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'boolean'], - 'interactions.*.style.top' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'string'], - 'interactions.*.style.left' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'string'], + 'interactions.*.style.top' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'string'], + 'interactions.*.style.left' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'string'], ]); } } @@ -41,15 +43,15 @@ function benchItems(int $n): array $type = $types[$i % count($types)]; return [ - 'type' => $type, - 'title' => 'Item ' . $i, - 'start_time' => 1.0, - 'end_time' => 2.0, - 'position' => 'top', - 'text' => 'hello', - 'image_url' => $type === 'image' ? 'https://example.com/x.png' : null, + 'type' => $type, + 'title' => 'Item ' . $i, + 'start_time' => 1.0, + 'end_time' => 2.0, + 'position' => 'top', + 'text' => 'hello', + 'image_url' => $type === 'image' ? 'https://example.com/x.png' : null, 'should_fade_in' => true, - 'style' => ['top' => '0px', 'left' => '0px'], + 'style' => ['top' => '0px', 'left' => '0px'], ]; }, range(0, $n - 1))]; } @@ -60,17 +62,17 @@ function benchNativeRules(): array $types = ['button', 'hotspot', 'image', 'text', 'chapter', 'menu', 'pause']; return [ - 'interactions' => 'required|array|min:1', - 'interactions.*.type' => ['required', 'string', LaravelRule::in($types)], - 'interactions.*.title' => ['nullable', 'string'], - 'interactions.*.start_time' => ['required', 'numeric', 'min:0'], - 'interactions.*.end_time' => ['required_unless:interactions.*.type,pause', 'numeric', 'gte:interactions.*.start_time'], - 'interactions.*.position' => ['exclude_unless:interactions.*.type,chapter,menu', 'string'], - 'interactions.*.text' => ['exclude_unless:interactions.*.type,button,hotspot,text', 'nullable', 'string'], - 'interactions.*.image_url' => ['exclude_unless:interactions.*.type,image,hotspot', 'required_if:interactions.*.type,image', 'nullable', 'string'], + 'interactions' => 'required|array|min:1', + 'interactions.*.type' => ['required', 'string', LaravelRule::in($types)], + 'interactions.*.title' => ['nullable', 'string'], + 'interactions.*.start_time' => ['required', 'numeric', 'min:0'], + 'interactions.*.end_time' => ['required_unless:interactions.*.type,pause', 'numeric', 'gte:interactions.*.start_time'], + 'interactions.*.position' => ['exclude_unless:interactions.*.type,chapter,menu', 'string'], + 'interactions.*.text' => ['exclude_unless:interactions.*.type,button,hotspot,text', 'nullable', 'string'], + 'interactions.*.image_url' => ['exclude_unless:interactions.*.type,image,hotspot', 'required_if:interactions.*.type,image', 'nullable', 'string'], 'interactions.*.should_fade_in' => ['exclude_unless:interactions.*.type,button,hotspot,image,text', 'boolean'], - 'interactions.*.style.top' => ['exclude_unless:interactions.*.type,button,hotspot,image,text', 'string'], - 'interactions.*.style.left' => ['exclude_unless:interactions.*.type,button,hotspot,image,text', 'string'], + 'interactions.*.style.top' => ['exclude_unless:interactions.*.type,button,hotspot,image,text', 'string'], + 'interactions.*.style.left' => ['exclude_unless:interactions.*.type,button,hotspot,image,text', 'string'], ]; } @@ -92,8 +94,16 @@ function benchNativeRules(): array $fluent = benchmarkMedian(fn () => new BenchImportValidator($data)->validate(), 3); $ruleSet = benchmarkMedian(fn () => RuleSet::from($nativeRules)->validate($data), 3); - fprintf(STDERR, " %-6d %10.0fms %12.0fms (%4.1fx) %10.0fms (%4.1fx)\n", - $n, $native, $fluent, $native / $fluent, $ruleSet, $native / $ruleSet); + fprintf( + STDERR, + " %-6d %10.0fms %12.0fms (%4.1fx) %10.0fms (%4.1fx)\n", + $n, + $native, + $fluent, + $native / $fluent, + $ruleSet, + $native / $ruleSet, + ); } fprintf(STDERR, "\n"); diff --git a/tests/FluentValidatorConditionalParityTest.php b/tests/FluentValidatorConditionalParityTest.php index 08b4763..e31e614 100644 --- a/tests/FluentValidatorConditionalParityTest.php +++ b/tests/FluentValidatorConditionalParityTest.php @@ -1,4 +1,6 @@ - ['required', 'string'], + 'interactions.*.type' => ['required', 'string'], 'interactions.*.start_time' => ['required', 'numeric', 'min:0'], // requiredUnless against the wildcard sibling path, multi-value. 'interactions.*.end_time' => [['required_unless', 'interactions.*.type', 'pause', 'stop'], 'numeric'], @@ -32,17 +34,18 @@ public function __construct(array $data) } /** - * @param array $data - * @return list sorted failing attribute keys + * @param array $data + * + * @return list sorted failing attribute keys */ function nativeConditionalFailures(array $data): array { $errors = validator($data, [ - 'interactions.*.type' => ['required', 'string'], + 'interactions.*.type' => ['required', 'string'], 'interactions.*.start_time' => ['required', 'numeric', 'min:0'], - 'interactions.*.end_time' => ['required_unless:interactions.*.type,pause,stop', 'numeric'], - 'interactions.*.position' => ['exclude_unless:interactions.*.type,chapter,menu', 'required', 'string'], - 'interactions.*.image_url' => ['required_if:interactions.*.type,image', 'nullable', 'string'], + 'interactions.*.end_time' => ['required_unless:interactions.*.type,pause,stop', 'numeric'], + 'interactions.*.position' => ['exclude_unless:interactions.*.type,chapter,menu', 'required', 'string'], + 'interactions.*.image_url' => ['required_if:interactions.*.type,image', 'nullable', 'string'], ])->errors()->keys(); sort($errors); @@ -51,8 +54,9 @@ function nativeConditionalFailures(array $data): array } /** - * @param array $data - * @return list sorted failing attribute keys + * @param array $data + * + * @return list sorted failing attribute keys */ function fluentConditionalFailures(array $data): array { @@ -76,18 +80,18 @@ function fluentConditionalFailures(array $data): array })->with([ // requiredUnless: type in (pause,stop) → end_time not required 'pause, no end_time' => [['type' => 'pause', 'start_time' => 1.0]], - 'stop, no end_time' => [['type' => 'stop', 'start_time' => 1.0]], + 'stop, no end_time' => [['type' => 'stop', 'start_time' => 1.0]], // requiredUnless: type not in list → end_time required 'play, no end_time → error' => [['type' => 'play', 'start_time' => 1.0]], - 'play, with end_time' => [['type' => 'play', 'start_time' => 1.0, 'end_time' => 2.0]], + 'play, with end_time' => [['type' => 'play', 'start_time' => 1.0, 'end_time' => 2.0]], // exclude_unless: position only required for chapter/menu 'chapter, no position → error' => [['type' => 'chapter', 'start_time' => 1.0, 'end_time' => 2.0]], - 'chapter, with position' => [['type' => 'chapter', 'start_time' => 1.0, 'end_time' => 2.0, 'position' => 'top']], - 'button, no position ok' => [['type' => 'button', 'start_time' => 1.0, 'end_time' => 2.0]], - 'menu, no position → error' => [['type' => 'menu', 'start_time' => 1.0, 'end_time' => 2.0]], + 'chapter, with position' => [['type' => 'chapter', 'start_time' => 1.0, 'end_time' => 2.0, 'position' => 'top']], + 'button, no position ok' => [['type' => 'button', 'start_time' => 1.0, 'end_time' => 2.0]], + 'menu, no position → error' => [['type' => 'menu', 'start_time' => 1.0, 'end_time' => 2.0]], // required_if: image_url required when type=image 'image, no url → error' => [['type' => 'image', 'start_time' => 1.0, 'end_time' => 2.0]], - 'image, with url' => [['type' => 'image', 'start_time' => 1.0, 'end_time' => 2.0, 'image_url' => 'x.png']], + 'image, with url' => [['type' => 'image', 'start_time' => 1.0, 'end_time' => 2.0, 'image_url' => 'x.png']], // missing start_time always fails 'missing start_time' => [['type' => 'pause']], ]); @@ -101,16 +105,16 @@ function fluentConditionalFailures(array $data): array // ========================================================================= /** - * @param array $rules native rule strings keyed by wildcard path - * @param array $fluentRules - * @param array $data + * @param array $rules native rule strings keyed by wildcard path + * @param array $fluentRules + * @param array $data */ function assertFvExcludeParity(array $rules, array $fluentRules, array $data): void { $native = validator($data, $rules)->errors()->keys(); sort($native); - $validator = new class ($data, $fluentRules) extends FluentValidator {}; + $validator = new class($data, $fluentRules) extends FluentValidator {}; try { $validator->validate(); @@ -128,7 +132,7 @@ function assertFvExcludeParity(array $rules, array $fluentRules, array $data): v assertFvExcludeParity( ['items.*.detail' => ['exclude_unless:items.*.state,null', 'required', 'string']], ['items.*.detail' => [['exclude_unless', 'items.*.state', 'null'], 'required', 'string']], - ['items' => [['state' => null]]], // state IS null → not excluded → required fires + ['items' => [['state' => null]]], // state IS null → not excluded → required fires ); }); @@ -150,7 +154,7 @@ function assertFvExcludeParity(array $rules, array $fluentRules, array $data): v assertFvExcludeParity( ['items.*.extra' => ['exclude_unless:items.*.type,a', 'required', 'string']], ['items.*.extra' => [['exclude_unless', 'items.*.type', 'a'], 'required', 'string']], - ['items' => ['foo' => ['type' => 'a']]], // type matches → not excluded → required fires + ['items' => ['foo' => ['type' => 'a']]], // type matches → not excluded → required fires ); }); @@ -161,7 +165,7 @@ function assertFvExcludeParity(array $rules, array $fluentRules, array $data): v assertFvExcludeParity( ['items.*.rows.*.detail' => ['exclude_unless:items.*.type,keep', 'required', 'string']], ['items.*.rows.*.detail' => [['exclude_unless', 'items.*.type', 'keep'], 'required', 'string']], - ['items' => ['foo' => ['type' => 'drop', 'rows' => [['x' => 1]]]]], + ['items' => ['foo' => ['type' => 'drop', 'rows' => [['x' => 1]]]]], ); }); @@ -175,14 +179,14 @@ function assertFvExcludeParity(array $rules, array $fluentRules, array $data): v $data = ['items' => [['flag' => true, 'name' => 'present', 'label' => 'ok']]]; $rules = static fn (mixed $excludeIf): array => [ - 'items.*.flag' => ['boolean'], + 'items.*.flag' => ['boolean'], 'items.*.label' => ['string'], - 'items.*.name' => [$excludeIf, 'string'], + 'items.*.name' => [$excludeIf, 'string'], ]; $native = validator($data, $rules('exclude_if:items.*.flag,true'))->validate(); - $validator = new class ($data, $rules(['exclude_if', 'items.*.flag', 'true'])) extends FluentValidator {}; + $validator = new class($data, $rules(['exclude_if', 'items.*.flag', 'true'])) extends FluentValidator {}; expect($validator->validate())->toBe($native) ->and($native)->toBe(['items' => [['flag' => true, 'label' => 'ok']]]); // name excluded, not leaked diff --git a/tests/HasFluentValidationForFilamentTest.php b/tests/HasFluentValidationForFilamentTest.php index 5903fa6..3680fb1 100644 --- a/tests/HasFluentValidationForFilamentTest.php +++ b/tests/HasFluentValidationForFilamentTest.php @@ -1,4 +1,6 @@ -lastValidateCall = [ - 'rules' => $rules, - 'messages' => is_array($messages) ? $messages : [], + 'rules' => $rules, + 'messages' => is_array($messages) ? $messages : [], 'attributes' => is_array($attributes) ? $attributes : [], ]; @@ -33,9 +35,9 @@ public function validate(mixed $rules = null, mixed $messages = [], mixed $attri public function validateOnly(mixed $field, mixed $rules = null, mixed $messages = [], mixed $attributes = [], mixed $dataOverrides = []): mixed { $this->lastValidateOnlyCall = [ - 'field' => is_string($field) ? $field : '', - 'rules' => $rules, - 'messages' => is_array($messages) ? $messages : [], + 'field' => is_string($field) ? $field : '', + 'rules' => $rules, + 'messages' => is_array($messages) ? $messages : [], 'attributes' => is_array($attributes) ? $attributes : [], ]; @@ -61,8 +63,8 @@ public function getValidationAttributes(): array class FakeFilamentForm { /** - * @param array $validationRules - * @param array $validationAttributes + * @param array $validationRules + * @param array $validationAttributes */ public function __construct( private readonly array $validationRules = [], @@ -87,9 +89,9 @@ class TestableFilamentComponent extends FakeFilamentBase use HasFluentValidationForFilament; /** - * @param array $fluentRules - * @param array $data - * @param list $forms + * @param array $fluentRules + * @param array $data + * @param list $forms */ public function __construct( private array $fluentRules = [], @@ -104,7 +106,8 @@ public function rules(): array } /** - * @param array $rules + * @param array $rules + * * @return array */ public function getDataForValidation(array $rules): array diff --git a/tests/HasFluentValidationTest.php b/tests/HasFluentValidationTest.php index 0cb6712..0d361b4 100644 --- a/tests/HasFluentValidationTest.php +++ b/tests/HasFluentValidationTest.php @@ -1,8 +1,10 @@ - $data + * @param array $data * @param array $fluentRules */ public function __construct(public array $data = [], private array $fluentRules = []) {} @@ -67,7 +69,8 @@ public function rules(): array /** * Simulates Livewire's data resolution. * - * @param array $rules + * @param array $rules + * * @return array */ public function getDataForValidation(array $rules): array @@ -78,9 +81,10 @@ public function getDataForValidation(array $rules): array /** * Expose the protected compilation step for testing. * - * @param array|null $rules - * @param array $messages - * @param array $attributes + * @param array|null $rules + * @param array $messages + * @param array $attributes + * * @return array{0: array|null, 1: array, 2: array} */ public function compile(?array $rules = null, array $messages = [], array $attributes = []): array @@ -107,9 +111,10 @@ public function all(): array } /** - * @param array|null $rules - * @param array $messages - * @param array $attributes + * @param array|null $rules + * @param array $messages + * @param array $attributes + * * @return array{0: array|null, 1: array, 2: array} */ public function compile(?array $rules = null, array $messages = [], array $attributes = []): array @@ -127,9 +132,9 @@ class TestableComponentWithUnwrap extends FakeLivewireBase use HasFluentValidation; /** - * @param array $rawData - * @param array $unwrappedData - * @param array|RuleSet $rules + * @param array $rawData + * @param array $unwrappedData + * @param array|RuleSet $rules */ public function __construct(public array $rawData, public array $unwrappedData, private array|RuleSet $rules = []) {} @@ -140,7 +145,8 @@ public function rules(): array|RuleSet } /** - * @param array $rules + * @param array $rules + * * @return array */ public function getDataForValidation(array $rules): array @@ -155,9 +161,10 @@ public function unwrapDataForValidation(mixed $data): array } /** - * @param array|null $rules - * @param array $messages - * @param array $attributes + * @param array|null $rules + * @param array $messages + * @param array $attributes + * * @return array{0: array|null, 1: array, 2: array} */ public function compile(?array $rules = null, array $messages = [], array $attributes = []): array @@ -337,8 +344,8 @@ public function compile(?array $rules = null, array $messages = [], array $attri $component = new TestableComponent( data: ['items' => [['name' => 'Foo'], ['name' => 'Bar']]], fluentRules: [ - 'items' => FluentRule::array()->required(), - 'items.*' => FluentRule::array()->required(), + 'items' => FluentRule::array()->required(), + 'items.*' => FluentRule::array()->required(), 'items.*.name' => FluentRule::string()->required()->max(255), ], ); @@ -372,8 +379,8 @@ public function compile(?array $rules = null, array $messages = [], array $attri rawData: ['items' => 'model-object'], unwrappedData: ['items' => [['name' => 'Foo'], ['name' => 'Bar']]], rules: [ - 'items' => FluentRule::array()->required(), - 'items.*' => FluentRule::array()->required(), + 'items' => FluentRule::array()->required(), + 'items.*' => FluentRule::array()->required(), 'items.*.name' => FluentRule::string()->required(), ], ); @@ -433,7 +440,7 @@ public function compile(?array $rules = null, array $messages = [], array $attri $component = new TestableComponent( data: ['items' => [['name' => 'Foo']]], fluentRules: [ - 'items' => FluentRule::array()->required(), + 'items' => FluentRule::array()->required(), 'items.*.name' => FluentRule::string()->required(), ], ); @@ -528,7 +535,7 @@ public function compile(?array $rules = null, array $messages = [], array $attri data: ['credentials' => ['base_uri' => 'https://example.com', 'client_id' => '123']], fluentRules: [ 'credentials' => FluentRule::array()->children([ - 'base_uri' => FluentRule::string()->nullable()->url(), + 'base_uri' => FluentRule::string()->nullable()->url(), 'client_id' => FluentRule::string()->required()->uuid(), ]), ], @@ -587,7 +594,7 @@ public function compile(?array $rules = null, array $messages = [], array $attri $component = new TestableComponent( data: ['name' => 'John', 'email' => 'john@example.com'], fluentRules: [ - 'name' => FluentRule::string('Full Name')->required(), + 'name' => FluentRule::string('Full Name')->required(), 'email' => FluentRule::email()->required(), ], ); diff --git a/tests/Helpers.php b/tests/Helpers.php index ab9bc27..eb26067 100644 --- a/tests/Helpers.php +++ b/tests/Helpers.php @@ -1,4 +1,6 @@ -getParameters()) >= 3; @@ -28,8 +30,9 @@ function laravelSupportsIntegerStrict(): bool * be stringified during compilation — __toString() silently drops them. Use * this to assert the object itself survived rather than its lossy string form. * - * @param list $rules - * @param class-string $type + * @param list $rules + * @param class-string $type + * * @return list */ function rulesOfType(array $rules, string $type): array @@ -81,7 +84,8 @@ function compiledArray(mixed $compiled): array * failed outright. Every Windows CI cell failed that way while every Linux one * passed. * - * @param class-string|null $implementing Restrict to classes of this type. + * @param class-string|null $implementing Restrict to classes of this type. + * * @return list */ function ruleClassesUnder(?string $implementing = null): array diff --git a/tests/ImageRuleTest.php b/tests/ImageRuleTest.php index f9d4150..9dc0e74 100644 --- a/tests/ImageRuleTest.php +++ b/tests/ImageRuleTest.php @@ -1,4 +1,6 @@ - [ - 'type' => $interactionTypes[$i % count($interactionTypes)], - 'title' => "Interaction {$i}", - 'start_time' => $i * 10, - 'end_time' => $i * 10 + 5, - 'position' => 'bottom', - 'should_start_collapsed' => false, + 'type' => $interactionTypes[$i % count($interactionTypes)], + 'title' => "Interaction {$i}", + 'start_time' => $i * 10, + 'end_time' => $i * 10 + 5, + 'position' => 'bottom', + 'should_start_collapsed' => false, 'should_collapse_after_menu_item_click' => true, - 'should_pause_when_shown' => false, - 'should_not_use_time' => false, - 'should_use_menu_layout' => false, - 'text' => '

Sample text

', - 'text_stroke' => null, - 'should_fade_volume' => false, - 'sound_url' => null, - 'should_enable_sound' => false, - 'should_fade_in' => true, - 'should_fade_out' => true, - 'image_url' => $interactionTypes[$i % count($interactionTypes)] === 'image' ? 'https://example.com/image.png' : null, - 'style' => [ - 'top' => '10%', - 'left' => '20%', - 'height' => '30%', - 'width' => '40%', + 'should_pause_when_shown' => false, + 'should_not_use_time' => false, + 'should_use_menu_layout' => false, + 'text' => '

Sample text

', + 'text_stroke' => null, + 'should_fade_volume' => false, + 'sound_url' => null, + 'should_enable_sound' => false, + 'should_fade_in' => true, + 'should_fade_out' => true, + 'image_url' => $interactionTypes[$i % count($interactionTypes)] === 'image' ? 'https://example.com/image.png' : null, + 'style' => [ + 'top' => '10%', + 'left' => '20%', + 'height' => '30%', + 'width' => '40%', 'background_color' => '#ff0000', - 'border_radius' => 5, - 'padding_top' => 10, - 'padding_bottom' => 10, - 'border' => ['width' => 1, 'style' => 'solid', 'color' => '#000000'], + 'border_radius' => 5, + 'padding_top' => 10, + 'padding_bottom' => 10, + 'border' => ['width' => 1, 'style' => 'solid', 'color' => '#000000'], ], 'action' => [ 'type' => 'link', @@ -52,17 +54,17 @@ 'time' => 0, ], 'attributes' => [ - 'show_indicator' => true, + 'show_indicator' => true, 'indicator_color' => '#00ff00', - 'blinking_speed' => 'normal', - 'options' => ['menu_button_location' => 'top', 'menu_button_name' => 'Menu'], + 'blinking_speed' => 'normal', + 'options' => ['menu_button_location' => 'top', 'menu_button_name' => 'Menu'], ], 'chapters' => $i % count($interactionTypes) === 4 ? array_map(fn (int $j): array => [ - 'title' => "Chapter {$j}", + 'title' => "Chapter {$j}", 'title_short' => null, - 'start_time' => $j * 5, - 'end_time' => $j * 5 + 4, - 'sort_order' => $j, + 'start_time' => $j * 5, + 'end_time' => $j * 5 + 4, + 'sort_order' => $j, ], range(1, 4)) : [], ], range(1, 100)); @@ -71,58 +73,58 @@ // Build rules that match hihaho's complexity: ~30 wildcard patterns, // conditional rules, nested wildcards, custom rule objects $rules = [ - 'interactions' => 'required|array|min:1', - 'interactions.*.type' => ['required', 'string', LaravelRule::in($interactionTypes)], - 'interactions.*.title' => ['nullable', 'string'], - 'interactions.*.start_time' => ['required', 'numeric', 'min:0'], - 'interactions.*.end_time' => ['required', 'numeric', 'gte:interactions.*.start_time'], - 'interactions.*.position' => ['bail', ['exclude_unless', 'interactions.*.type', 'chapter', 'menu'], 'string'], - 'interactions.*.should_start_collapsed' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'boolean'], + 'interactions' => 'required|array|min:1', + 'interactions.*.type' => ['required', 'string', LaravelRule::in($interactionTypes)], + 'interactions.*.title' => ['nullable', 'string'], + 'interactions.*.start_time' => ['required', 'numeric', 'min:0'], + 'interactions.*.end_time' => ['required', 'numeric', 'gte:interactions.*.start_time'], + 'interactions.*.position' => ['bail', ['exclude_unless', 'interactions.*.type', 'chapter', 'menu'], 'string'], + 'interactions.*.should_start_collapsed' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'boolean'], 'interactions.*.should_collapse_after_menu_item_click' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'boolean'], - 'interactions.*.should_pause_when_shown' => [['exclude_unless', 'interactions.*.type', 'chapter', 'menu'], 'nullable', 'boolean'], - 'interactions.*.should_not_use_time' => [['exclude_unless', 'interactions.*.type', 'menu'], 'nullable', 'boolean'], - 'interactions.*.should_use_menu_layout' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'nullable', 'boolean'], - 'interactions.*.text' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'string'], - 'interactions.*.text_stroke' => [['exclude_unless', 'interactions.*.type', 'text'], 'nullable', 'string'], - 'interactions.*.should_fade_volume' => ['boolean'], - 'interactions.*.sound_url' => ['bail', ['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image'], 'nullable', 'string'], - 'interactions.*.should_enable_sound' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image'], 'boolean'], - 'interactions.*.should_fade_in' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'boolean'], - 'interactions.*.should_fade_out' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'boolean'], - 'interactions.*.image_url' => ['bail', ['exclude_unless', 'interactions.*.type', 'image', 'hotspot'], ['required_if', 'interactions.*.type', 'image'], 'nullable', 'string'], + 'interactions.*.should_pause_when_shown' => [['exclude_unless', 'interactions.*.type', 'chapter', 'menu'], 'nullable', 'boolean'], + 'interactions.*.should_not_use_time' => [['exclude_unless', 'interactions.*.type', 'menu'], 'nullable', 'boolean'], + 'interactions.*.should_use_menu_layout' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'nullable', 'boolean'], + 'interactions.*.text' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'string'], + 'interactions.*.text_stroke' => [['exclude_unless', 'interactions.*.type', 'text'], 'nullable', 'string'], + 'interactions.*.should_fade_volume' => ['boolean'], + 'interactions.*.sound_url' => ['bail', ['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image'], 'nullable', 'string'], + 'interactions.*.should_enable_sound' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image'], 'boolean'], + 'interactions.*.should_fade_in' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'boolean'], + 'interactions.*.should_fade_out' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'boolean'], + 'interactions.*.image_url' => ['bail', ['exclude_unless', 'interactions.*.type', 'image', 'hotspot'], ['required_if', 'interactions.*.type', 'image'], 'nullable', 'string'], // Style rules - 'interactions.*.style.top' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'], - 'interactions.*.style.left' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'], - 'interactions.*.style.height' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'], - 'interactions.*.style.width' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'], + 'interactions.*.style.top' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'], + 'interactions.*.style.left' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'], + 'interactions.*.style.height' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'], + 'interactions.*.style.width' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'], 'interactions.*.style.background_color' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'string'], - 'interactions.*.style.border_radius' => ['bail', ['exclude_unless', 'interactions.*.type', 'button', 'hotspot'], 'nullable', 'numeric', 'integer', 'between:0,360'], - 'interactions.*.style.padding_top' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'numeric', 'integer'], - 'interactions.*.style.padding_bottom' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'numeric', 'integer'], - 'interactions.*.style.border' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'array'], - 'interactions.*.style.border.width' => ['nullable', 'numeric', 'integer', 'min:0'], - 'interactions.*.style.border.style' => ['nullable', 'string', LaravelRule::in(['solid', 'dashed', 'dotted', 'none'])], - 'interactions.*.style.border.color' => ['nullable', 'string'], + 'interactions.*.style.border_radius' => ['bail', ['exclude_unless', 'interactions.*.type', 'button', 'hotspot'], 'nullable', 'numeric', 'integer', 'between:0,360'], + 'interactions.*.style.padding_top' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'numeric', 'integer'], + 'interactions.*.style.padding_bottom' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'numeric', 'integer'], + 'interactions.*.style.border' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'array'], + 'interactions.*.style.border.width' => ['nullable', 'numeric', 'integer', 'min:0'], + 'interactions.*.style.border.style' => ['nullable', 'string', LaravelRule::in(['solid', 'dashed', 'dotted', 'none'])], + 'interactions.*.style.border.color' => ['nullable', 'string'], // Action rules - 'interactions.*.action' => ['nullable', 'array'], + 'interactions.*.action' => ['nullable', 'array'], 'interactions.*.action.type' => ['nullable', 'string', LaravelRule::in(['link', 'time', 'video', 'none'])], 'interactions.*.action.link' => ['nullable', 'string'], 'interactions.*.action.time' => ['nullable', 'numeric'], // Attribute rules - 'interactions.*.attributes' => ['nullable', 'array'], - 'interactions.*.attributes.show_indicator' => ['nullable', 'boolean'], - 'interactions.*.attributes.indicator_color' => ['nullable', 'string'], - 'interactions.*.attributes.blinking_speed' => ['nullable', 'string', LaravelRule::in(['slow', 'normal', 'fast', 'none'])], - 'interactions.*.attributes.options' => ['nullable', 'array'], + 'interactions.*.attributes' => ['nullable', 'array'], + 'interactions.*.attributes.show_indicator' => ['nullable', 'boolean'], + 'interactions.*.attributes.indicator_color' => ['nullable', 'string'], + 'interactions.*.attributes.blinking_speed' => ['nullable', 'string', LaravelRule::in(['slow', 'normal', 'fast', 'none'])], + 'interactions.*.attributes.options' => ['nullable', 'array'], 'interactions.*.attributes.options.menu_button_location' => ['nullable', 'string'], - 'interactions.*.attributes.options.menu_button_name' => ['nullable', 'string'], + 'interactions.*.attributes.options.menu_button_name' => ['nullable', 'string'], // Chapters (nested wildcard) - 'interactions.*.chapters' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'array', 'min:1', 'max:16'], - 'interactions.*.chapters.*.title' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'string'], + 'interactions.*.chapters' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'array', 'min:1', 'max:16'], + 'interactions.*.chapters.*.title' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'string'], 'interactions.*.chapters.*.title_short' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'nullable', 'string'], - 'interactions.*.chapters.*.start_time' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'numeric', 'min:0'], - 'interactions.*.chapters.*.end_time' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'numeric'], - 'interactions.*.chapters.*.sort_order' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'integer', 'min:0'], + 'interactions.*.chapters.*.start_time' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'numeric', 'min:0'], + 'interactions.*.chapters.*.end_time' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'numeric'], + 'interactions.*.chapters.*.sort_order' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'integer', 'min:0'], ]; // ── Warmup ── diff --git a/tests/InlineMessageParamPhase3aTest.php b/tests/InlineMessageParamPhase3aTest.php index 410ca17..a99a489 100644 --- a/tests/InlineMessageParamPhase3aTest.php +++ b/tests/InlineMessageParamPhase3aTest.php @@ -1,4 +1,6 @@ - [ fn () => FluentRule::string()->required(message: 'x'), - fn () => FluentRule::string()->required()->message('x'), + fn () => FluentRule::string()->required()->message('x'), ['required' => 'x'], ], 'sometimes' => [ fn () => FluentRule::string()->sometimes(message: 'x'), - fn () => FluentRule::string()->sometimes()->message('x'), + fn () => FluentRule::string()->sometimes()->message('x'), ['sometimes' => 'x'], ], 'filled' => [ fn () => FluentRule::string()->filled(message: 'x'), - fn () => FluentRule::string()->filled()->message('x'), + fn () => FluentRule::string()->filled()->message('x'), ['filled' => 'x'], ], 'present' => [ fn () => FluentRule::field()->present(message: 'x'), - fn () => FluentRule::field()->present()->message('x'), + fn () => FluentRule::field()->present()->message('x'), ['present' => 'x'], ], 'prohibited' => [ fn () => FluentRule::string()->prohibited(message: 'x'), - fn () => FluentRule::string()->prohibited()->message('x'), + fn () => FluentRule::string()->prohibited()->message('x'), ['prohibited' => 'x'], ], 'missing' => [ fn () => FluentRule::string()->missing(message: 'x'), - fn () => FluentRule::string()->missing()->message('x'), + fn () => FluentRule::string()->missing()->message('x'), ['missing' => 'x'], ], 'requiredIfAccepted' => [ fn () => FluentRule::string()->requiredIfAccepted('tos', message: 'x'), - fn () => FluentRule::string()->requiredIfAccepted('tos')->message('x'), + fn () => FluentRule::string()->requiredIfAccepted('tos')->message('x'), ['required_if_accepted' => 'x'], ], 'requiredIfDeclined' => [ fn () => FluentRule::string()->requiredIfDeclined('tos', message: 'x'), - fn () => FluentRule::string()->requiredIfDeclined('tos')->message('x'), + fn () => FluentRule::string()->requiredIfDeclined('tos')->message('x'), ['required_if_declined' => 'x'], ], 'prohibitedIfAccepted' => [ fn () => FluentRule::string()->prohibitedIfAccepted('tos', message: 'x'), - fn () => FluentRule::string()->prohibitedIfAccepted('tos')->message('x'), + fn () => FluentRule::string()->prohibitedIfAccepted('tos')->message('x'), ['prohibited_if_accepted' => 'x'], ], 'prohibitedIfDeclined' => [ fn () => FluentRule::string()->prohibitedIfDeclined('tos', message: 'x'), - fn () => FluentRule::string()->prohibitedIfDeclined('tos')->message('x'), + fn () => FluentRule::string()->prohibitedIfDeclined('tos')->message('x'), ['prohibited_if_declined' => 'x'], ], 'rule-string' => [ fn () => FluentRule::string()->rule('max:100', message: 'x'), - fn () => FluentRule::string()->rule('max:100')->message('x'), + fn () => FluentRule::string()->rule('max:100')->message('x'), ['max' => 'x'], ], 'unique' => [ fn () => FluentRule::string()->unique('users', 'email', message: 'x'), - fn () => FluentRule::string()->unique('users', 'email')->message('x'), + fn () => FluentRule::string()->unique('users', 'email')->message('x'), ['unique' => 'x'], ], 'exists' => [ fn () => FluentRule::string()->exists('users', 'email', message: 'x'), - fn () => FluentRule::string()->exists('users', 'email')->message('x'), + fn () => FluentRule::string()->exists('users', 'email')->message('x'), ['exists' => 'x'], ], 'enum' => [ fn () => FluentRule::string()->enum(TestStringEnum::class, message: 'x'), - fn () => FluentRule::string()->enum(TestStringEnum::class)->message('x'), + fn () => FluentRule::string()->enum(TestStringEnum::class)->message('x'), ['enum' => 'x'], ], 'in' => [ @@ -90,7 +92,7 @@ ], 'notIn' => [ fn () => FluentRule::string()->notIn(['banned'], message: 'x'), - fn () => FluentRule::string()->notIn(['banned'])->message('x'), + fn () => FluentRule::string()->notIn(['banned'])->message('x'), ['not_in' => 'x'], ], ]); @@ -114,7 +116,7 @@ it('required(message: ...) surfaces in live validation', function (): void { $v = makeValidator( ['name' => ''], - ['name' => FluentRule::string()->required(message: 'Name is required!')] + ['name' => FluentRule::string()->required(message: 'Name is required!')], ); expect($v->passes())->toBeFalse() @@ -124,7 +126,7 @@ it('rule(..., message: ...) surfaces in live validation', function (): void { $v = makeValidator( ['name' => str_repeat('a', 300)], - ['name' => FluentRule::string()->rule('max:100', message: 'Too long!')] + ['name' => FluentRule::string()->rule('max:100', message: 'Too long!')], ); expect($v->passes())->toBeFalse() diff --git a/tests/InlineMessageParamPhase3bTest.php b/tests/InlineMessageParamPhase3bTest.php index b1d9e72..bf2339d 100644 --- a/tests/InlineMessageParamPhase3bTest.php +++ b/tests/InlineMessageParamPhase3bTest.php @@ -1,4 +1,6 @@ - [ fn () => FluentRule::string()->alpha(message: 'x'), - fn () => FluentRule::string()->alpha()->message('x'), + fn () => FluentRule::string()->alpha()->message('x'), ['alpha' => 'x'], ], 'ascii' => [ fn () => FluentRule::string()->ascii(message: 'x'), - fn () => FluentRule::string()->ascii()->message('x'), + fn () => FluentRule::string()->ascii()->message('x'), ['ascii' => 'x'], ], 'between' => [ fn () => FluentRule::string()->between(2, 10, message: 'x'), - fn () => FluentRule::string()->between(2, 10)->message('x'), + fn () => FluentRule::string()->between(2, 10)->message('x'), ['between' => 'x'], ], 'min' => [ fn () => FluentRule::string()->min(2, message: 'x'), - fn () => FluentRule::string()->min(2)->message('x'), + fn () => FluentRule::string()->min(2)->message('x'), ['min' => 'x'], ], 'max' => [ fn () => FluentRule::string()->max(100, message: 'x'), - fn () => FluentRule::string()->max(100)->message('x'), + fn () => FluentRule::string()->max(100)->message('x'), ['max' => 'x'], ], 'exactly' => [ fn () => FluentRule::string()->exactly(5, message: 'x'), - fn () => FluentRule::string()->exactly(5)->message('x'), + fn () => FluentRule::string()->exactly(5)->message('x'), ['size' => 'x'], ], 'url' => [ fn () => FluentRule::string()->url(message: 'x'), - fn () => FluentRule::string()->url()->message('x'), + fn () => FluentRule::string()->url()->message('x'), ['url' => 'x'], ], 'uuid' => [ fn () => FluentRule::string()->uuid(message: 'x'), - fn () => FluentRule::string()->uuid()->message('x'), + fn () => FluentRule::string()->uuid()->message('x'), ['uuid' => 'x'], ], 'regex' => [ fn () => FluentRule::string()->regex('/^[a-z]+$/', message: 'x'), - fn () => FluentRule::string()->regex('/^[a-z]+$/')->message('x'), + fn () => FluentRule::string()->regex('/^[a-z]+$/')->message('x'), ['regex' => 'x'], ], 'same' => [ fn () => FluentRule::string()->same('other', message: 'x'), - fn () => FluentRule::string()->same('other')->message('x'), + fn () => FluentRule::string()->same('other')->message('x'), ['same' => 'x'], ], 'dateFormat' => [ fn () => FluentRule::string()->dateFormat('Y-m-d', message: 'x'), - fn () => FluentRule::string()->dateFormat('Y-m-d')->message('x'), + fn () => FluentRule::string()->dateFormat('Y-m-d')->message('x'), ['date_format' => 'x'], ], 'confirmed' => [ fn () => FluentRule::string()->confirmed(message: 'x'), - fn () => FluentRule::string()->confirmed()->message('x'), + fn () => FluentRule::string()->confirmed()->message('x'), ['confirmed' => 'x'], ], 'currentPassword' => [ fn () => FluentRule::string()->currentPassword(message: 'x'), - fn () => FluentRule::string()->currentPassword()->message('x'), + fn () => FluentRule::string()->currentPassword()->message('x'), ['current_password' => 'x'], ], ]); @@ -79,27 +81,27 @@ dataset('phase3b_numeric', [ 'min' => [ fn () => FluentRule::numeric()->min(0, message: 'x'), - fn () => FluentRule::numeric()->min(0)->message('x'), + fn () => FluentRule::numeric()->min(0)->message('x'), ['min' => 'x'], ], 'max' => [ fn () => FluentRule::numeric()->max(100, message: 'x'), - fn () => FluentRule::numeric()->max(100)->message('x'), + fn () => FluentRule::numeric()->max(100)->message('x'), ['max' => 'x'], ], 'between' => [ fn () => FluentRule::numeric()->between(0, 100, message: 'x'), - fn () => FluentRule::numeric()->between(0, 100)->message('x'), + fn () => FluentRule::numeric()->between(0, 100)->message('x'), ['between' => 'x'], ], 'integer' => [ fn () => FluentRule::numeric()->integer(message: 'x'), - fn () => FluentRule::numeric()->integer()->message('x'), + fn () => FluentRule::numeric()->integer()->message('x'), ['integer' => 'x'], ], 'decimal' => [ fn () => FluentRule::numeric()->decimal(2, message: 'x'), - fn () => FluentRule::numeric()->decimal(2)->message('x'), + fn () => FluentRule::numeric()->decimal(2)->message('x'), ['decimal' => 'x'], ], 'positive' => [ @@ -114,7 +116,7 @@ ], 'multipleOf' => [ fn () => FluentRule::numeric()->multipleOf(5, message: 'x'), - fn () => FluentRule::numeric()->multipleOf(5)->message('x'), + fn () => FluentRule::numeric()->multipleOf(5)->message('x'), ['multiple_of' => 'x'], ], ]); @@ -159,7 +161,7 @@ ->messageFor('integer', 'Must be a whole number.'); expect($rule->getCustomMessages())->toBe([ - 'digits' => 'Must be 5 digits.', + 'digits' => 'Must be 5 digits.', 'integer' => 'Must be a whole number.', ]); }); @@ -183,7 +185,7 @@ it('StringRule::min(message: ...) surfaces in validation', function (): void { $v = makeValidator( ['name' => 'a'], - ['name' => FluentRule::string()->min(2, message: 'Too short!')] + ['name' => FluentRule::string()->min(2, message: 'Too short!')], ); expect($v->passes())->toBeFalse() @@ -193,7 +195,7 @@ it('NumericRule::between(message: ...) surfaces in validation', function (): void { $v = makeValidator( ['age' => 200], - ['age' => FluentRule::numeric()->between(0, 150, message: 'Out of range.')] + ['age' => FluentRule::numeric()->between(0, 150, message: 'Out of range.')], ); expect($v->passes())->toBeFalse() diff --git a/tests/InlineMessageParamPhase3cTest.php b/tests/InlineMessageParamPhase3cTest.php index 4d2942f..33d231f 100644 --- a/tests/InlineMessageParamPhase3cTest.php +++ b/tests/InlineMessageParamPhase3cTest.php @@ -1,4 +1,6 @@ - [ fn () => FluentRule::array()->min(1, message: 'x'), - fn () => FluentRule::array()->min(1)->message('x'), + fn () => FluentRule::array()->min(1)->message('x'), ['min' => 'x'], ], 'max' => [ fn () => FluentRule::array()->max(10, message: 'x'), - fn () => FluentRule::array()->max(10)->message('x'), + fn () => FluentRule::array()->max(10)->message('x'), ['max' => 'x'], ], 'between' => [ fn () => FluentRule::array()->between(1, 5, message: 'x'), - fn () => FluentRule::array()->between(1, 5)->message('x'), + fn () => FluentRule::array()->between(1, 5)->message('x'), ['between' => 'x'], ], 'exactly' => [ fn () => FluentRule::array()->exactly(3, message: 'x'), - fn () => FluentRule::array()->exactly(3)->message('x'), + fn () => FluentRule::array()->exactly(3)->message('x'), ['size' => 'x'], ], 'list' => [ fn () => FluentRule::array()->list(message: 'x'), - fn () => FluentRule::array()->list()->message('x'), + fn () => FluentRule::array()->list()->message('x'), ['list' => 'x'], ], 'distinct' => [ fn () => FluentRule::array()->distinct(message: 'x'), - fn () => FluentRule::array()->distinct()->message('x'), + fn () => FluentRule::array()->distinct()->message('x'), ['distinct' => 'x'], ], ]); @@ -43,42 +45,42 @@ dataset('phase3c_date', [ 'before' => [ fn () => FluentRule::date()->before('2026-12-31', message: 'x'), - fn () => FluentRule::date()->before('2026-12-31')->message('x'), + fn () => FluentRule::date()->before('2026-12-31')->message('x'), ['before' => 'x'], ], 'after' => [ fn () => FluentRule::date()->after('2020-01-01', message: 'x'), - fn () => FluentRule::date()->after('2020-01-01')->message('x'), + fn () => FluentRule::date()->after('2020-01-01')->message('x'), ['after' => 'x'], ], 'beforeOrEqual' => [ fn () => FluentRule::date()->beforeOrEqual('2026-12-31', message: 'x'), - fn () => FluentRule::date()->beforeOrEqual('2026-12-31')->message('x'), + fn () => FluentRule::date()->beforeOrEqual('2026-12-31')->message('x'), ['before_or_equal' => 'x'], ], 'afterOrEqual' => [ fn () => FluentRule::date()->afterOrEqual('2020-01-01', message: 'x'), - fn () => FluentRule::date()->afterOrEqual('2020-01-01')->message('x'), + fn () => FluentRule::date()->afterOrEqual('2020-01-01')->message('x'), ['after_or_equal' => 'x'], ], 'beforeToday' => [ fn () => FluentRule::date()->beforeToday(message: 'x'), - fn () => FluentRule::date()->beforeToday()->message('x'), + fn () => FluentRule::date()->beforeToday()->message('x'), ['before' => 'x'], ], 'past' => [ fn () => FluentRule::date()->past(message: 'x'), - fn () => FluentRule::date()->past()->message('x'), + fn () => FluentRule::date()->past()->message('x'), ['before' => 'x'], ], 'dateEquals' => [ fn () => FluentRule::date()->dateEquals('2026-04-22', message: 'x'), - fn () => FluentRule::date()->dateEquals('2026-04-22')->message('x'), + fn () => FluentRule::date()->dateEquals('2026-04-22')->message('x'), ['date_equals' => 'x'], ], 'same' => [ fn () => FluentRule::date()->same('other', message: 'x'), - fn () => FluentRule::date()->same('other')->message('x'), + fn () => FluentRule::date()->same('other')->message('x'), ['same' => 'x'], ], ]); @@ -86,22 +88,22 @@ dataset('phase3c_file', [ 'min' => [ fn () => FluentRule::file()->min(100, message: 'x'), - fn () => FluentRule::file()->min(100)->message('x'), + fn () => FluentRule::file()->min(100)->message('x'), ['min' => 'x'], ], 'max' => [ fn () => FluentRule::file()->max('2mb', message: 'x'), - fn () => FluentRule::file()->max('2mb')->message('x'), + fn () => FluentRule::file()->max('2mb')->message('x'), ['max' => 'x'], ], 'between' => [ fn () => FluentRule::file()->between(1, '2mb', message: 'x'), - fn () => FluentRule::file()->between(1, '2mb')->message('x'), + fn () => FluentRule::file()->between(1, '2mb')->message('x'), ['between' => 'x'], ], 'exactly' => [ fn () => FluentRule::file()->exactly(500, message: 'x'), - fn () => FluentRule::file()->exactly(500)->message('x'), + fn () => FluentRule::file()->exactly(500)->message('x'), ['size' => 'x'], ], ]); @@ -159,7 +161,7 @@ expect($rule->getCustomMessages())->toBe([ 'before' => 'Must be before end.', - 'after' => 'Must be after start.', + 'after' => 'Must be after start.', ]); }); @@ -201,7 +203,7 @@ it('ArrayRule::max(message: ...) surfaces in validation', function (): void { $v = makeValidator( ['tags' => ['a', 'b', 'c']], - ['tags' => FluentRule::array()->max(2, message: 'Too many.')] + ['tags' => FluentRule::array()->max(2, message: 'Too many.')], ); expect($v->passes())->toBeFalse() @@ -211,7 +213,7 @@ it('DateRule::before(message: ...) surfaces in validation', function (): void { $v = makeValidator( ['start' => '2030-01-01'], - ['start' => FluentRule::date()->before('2026-12-31', message: 'Must be before 2027.')] + ['start' => FluentRule::date()->before('2026-12-31', message: 'Must be before 2027.')], ); expect($v->passes())->toBeFalse() diff --git a/tests/InlineMessageParamPhase3dTest.php b/tests/InlineMessageParamPhase3dTest.php index 6263af0..6385fd7 100644 --- a/tests/InlineMessageParamPhase3dTest.php +++ b/tests/InlineMessageParamPhase3dTest.php @@ -1,4 +1,6 @@ - [ fn () => FluentRule::email()->max(255, message: 'x'), - fn () => FluentRule::email()->max(255)->message('x'), + fn () => FluentRule::email()->max(255)->message('x'), ['max' => 'x'], ], 'confirmed' => [ fn () => FluentRule::email()->confirmed(message: 'x'), - fn () => FluentRule::email()->confirmed()->message('x'), + fn () => FluentRule::email()->confirmed()->message('x'), ['confirmed' => 'x'], ], 'same' => [ fn () => FluentRule::email()->same('other', message: 'x'), - fn () => FluentRule::email()->same('other')->message('x'), + fn () => FluentRule::email()->same('other')->message('x'), ['same' => 'x'], ], 'different' => [ fn () => FluentRule::email()->different('other', message: 'x'), - fn () => FluentRule::email()->different('other')->message('x'), + fn () => FluentRule::email()->different('other')->message('x'), ['different' => 'x'], ], ]); @@ -33,7 +35,7 @@ dataset('phase3d_password', [ 'confirmed' => [ fn () => FluentRule::password()->confirmed(message: 'x'), - fn () => FluentRule::password()->confirmed()->message('x'), + fn () => FluentRule::password()->confirmed()->message('x'), ['confirmed' => 'x'], ], ]); @@ -41,12 +43,12 @@ dataset('phase3d_boolean', [ 'accepted' => [ fn () => FluentRule::boolean()->accepted(message: 'x'), - fn () => FluentRule::boolean()->accepted()->message('x'), + fn () => FluentRule::boolean()->accepted()->message('x'), ['accepted' => 'x'], ], 'declined' => [ fn () => FluentRule::boolean()->declined(message: 'x'), - fn () => FluentRule::boolean()->declined()->message('x'), + fn () => FluentRule::boolean()->declined()->message('x'), ['declined' => 'x'], ], ]); @@ -54,17 +56,17 @@ dataset('phase3d_field', [ 'same' => [ fn () => FluentRule::field()->same('other', message: 'x'), - fn () => FluentRule::field()->same('other')->message('x'), + fn () => FluentRule::field()->same('other')->message('x'), ['same' => 'x'], ], 'different' => [ fn () => FluentRule::field()->different('other', message: 'x'), - fn () => FluentRule::field()->different('other')->message('x'), + fn () => FluentRule::field()->different('other')->message('x'), ['different' => 'x'], ], 'confirmed' => [ fn () => FluentRule::field()->confirmed(message: 'x'), - fn () => FluentRule::field()->confirmed()->message('x'), + fn () => FluentRule::field()->confirmed()->message('x'), ['confirmed' => 'x'], ], ]); @@ -141,7 +143,7 @@ it('EmailRule::max(message: ...) surfaces in validation', function (): void { $v = makeValidator( ['email' => 'a@' . str_repeat('b', 300) . '.com'], - ['email' => FluentRule::email()->max(50, message: 'Too long!')] + ['email' => FluentRule::email()->max(50, message: 'Too long!')], ); expect($v->passes())->toBeFalse() @@ -151,7 +153,7 @@ it('BooleanRule::accepted(message: ...) surfaces in validation', function (): void { $v = makeValidator( ['tos' => false], - ['tos' => FluentRule::boolean()->accepted(message: 'Must accept.')] + ['tos' => FluentRule::boolean()->accepted(message: 'Must accept.')], ); expect($v->passes())->toBeFalse() diff --git a/tests/InlineMessageParamPhase3eTest.php b/tests/InlineMessageParamPhase3eTest.php index a265f86..b02ba5b 100644 --- a/tests/InlineMessageParamPhase3eTest.php +++ b/tests/InlineMessageParamPhase3eTest.php @@ -1,4 +1,6 @@ -max(255, message: 'Too long.'); expect($rule->getCustomMessages())->toBe([ - 'email' => 'Invalid email.', + 'email' => 'Invalid email.', 'required' => 'Email is required.', - 'max' => 'Too long.', + 'max' => 'Too long.', ]); }); @@ -190,7 +192,7 @@ it('FluentRule::email(message: ...) surfaces in live validation', function (): void { $v = makeValidator( ['contact' => 'notanemail'], - ['contact' => FluentRule::email(defaults: false, message: 'Must be valid email.')] + ['contact' => FluentRule::email(defaults: false, message: 'Must be valid email.')], ); expect($v->passes())->toBeFalse() @@ -200,7 +202,7 @@ it('FluentRule::uuid(message: ...) surfaces in live validation', function (): void { $v = makeValidator( ['token' => 'notuuid'], - ['token' => FluentRule::uuid(message: 'Must be a UUID.')] + ['token' => FluentRule::uuid(message: 'Must be a UUID.')], ); expect($v->passes())->toBeFalse() @@ -211,7 +213,7 @@ // 1.5 passes `numeric` but fails `integer`, so the integer-bound message fires. $v = makeValidator( ['age' => 1.5], - ['age' => FluentRule::integer(message: 'Must be whole number.')] + ['age' => FluentRule::integer(message: 'Must be whole number.')], ); expect($v->passes())->toBeFalse() @@ -221,7 +223,7 @@ it('FluentRule::string(message: ...) surfaces in live validation', function (): void { $v = makeValidator( ['name' => ['array-not-string']], - ['name' => FluentRule::string(message: 'Must be text.')] + ['name' => FluentRule::string(message: 'Must be text.')], ); expect($v->passes())->toBeFalse() @@ -231,7 +233,7 @@ it('FluentRule::numeric(message: ...) surfaces in live validation', function (): void { $v = makeValidator( ['score' => 'abc'], - ['score' => FluentRule::numeric(message: 'Must be a number.')] + ['score' => FluentRule::numeric(message: 'Must be a number.')], ); expect($v->passes())->toBeFalse() @@ -241,7 +243,7 @@ it('FluentRule::array(message: ...) surfaces in live validation', function (): void { $v = makeValidator( ['tags' => 'not-an-array'], - ['tags' => FluentRule::array(message: 'Must be a list.')] + ['tags' => FluentRule::array(message: 'Must be a list.')], ); expect($v->passes())->toBeFalse() @@ -251,7 +253,7 @@ it('FluentRule::file(message: ...) surfaces in live validation', function (): void { $v = makeValidator( ['upload' => 'not-a-file'], - ['upload' => FluentRule::file(message: 'Must be a file.')] + ['upload' => FluentRule::file(message: 'Must be a file.')], ); expect($v->passes())->toBeFalse() @@ -261,7 +263,7 @@ it('FluentRule::image(message: ...) surfaces in live validation', function (): void { $v = makeValidator( ['avatar' => 'not-an-image'], - ['avatar' => FluentRule::image(message: 'Must be an image.')] + ['avatar' => FluentRule::image(message: 'Must be an image.')], ); expect($v->passes())->toBeFalse() @@ -271,7 +273,7 @@ it('FluentRule::boolean(message: ...) surfaces in live validation', function (): void { $v = makeValidator( ['agree' => 'maybe'], - ['agree' => FluentRule::boolean(message: 'Must be true or false.')] + ['agree' => FluentRule::boolean(message: 'Must be true or false.')], ); expect($v->passes())->toBeFalse() @@ -281,7 +283,7 @@ it('FluentRule::date(message: ...) surfaces in live validation', function (): void { $v = makeValidator( ['dob' => 'not-a-date'], - ['dob' => FluentRule::date(message: 'Bad date.')] + ['dob' => FluentRule::date(message: 'Bad date.')], ); expect($v->passes())->toBeFalse() @@ -350,7 +352,7 @@ it('FluentRule::dateTime(message: ...) surfaces in live validation', function (): void { $v = makeValidator( ['ts' => 'not-a-datetime'], - ['ts' => FluentRule::dateTime(message: 'Bad timestamp.')] + ['ts' => FluentRule::dateTime(message: 'Bad timestamp.')], ); expect($v->passes())->toBeFalse() diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index b8fea37..cef62d7 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -1,11 +1,13 @@ - 'John'], - ['name' => ['sometimes', FluentRule::string()->min(2)->max(255)]] + ['name' => ['sometimes', FluentRule::string()->min(2)->max(255)]], ); expect($validator->passes())->toBeTrue(); @@ -23,7 +25,7 @@ it('works alongside string rules in an array when absent', function (): void { $validator = makeValidator( [], - ['name' => ['sometimes', FluentRule::string()->min(2)->max(255)]] + ['name' => ['sometimes', FluentRule::string()->min(2)->max(255)]], ); // 'sometimes' is a native rule, our rule is an additional custom rule @@ -34,7 +36,7 @@ it('works alongside Laravel Rule objects', function (): void { $validator = makeValidator( ['status' => 'active'], - ['status' => [FluentRule::string()->required(), LaravelRule::in(['active', 'inactive'])]] + ['status' => [FluentRule::string()->required(), LaravelRule::in(['active', 'inactive'])]], ); expect($validator->passes())->toBeTrue(); @@ -43,7 +45,7 @@ it('works alongside Laravel Rule objects when value is invalid', function (): void { $validator = makeValidator( ['status' => 'deleted'], - ['status' => [FluentRule::string()->required(), LaravelRule::in(['active', 'inactive'])]] + ['status' => [FluentRule::string()->required(), LaravelRule::in(['active', 'inactive'])]], ); expect($validator->passes())->toBeFalse(); @@ -56,7 +58,7 @@ it('works inside Rule::forEach', function (): void { $validator = makeValidator( ['items' => ['hello', 'world']], - ['items.*' => LaravelRule::forEach(fn (): StringRule => FluentRule::string()->required()->max(255))] + ['items.*' => LaravelRule::forEach(fn (): StringRule => FluentRule::string()->required()->max(255))], ); expect($validator->passes())->toBeTrue(); @@ -65,7 +67,7 @@ it('fails inside Rule::forEach for invalid items', function (): void { $validator = makeValidator( ['items' => ['hello', 123]], - ['items.*' => LaravelRule::forEach(fn (): StringRule => FluentRule::string()->required()->max(255))] + ['items.*' => LaravelRule::forEach(fn (): StringRule => FluentRule::string()->required()->max(255))], ); expect($validator->passes())->toBeFalse(); @@ -78,7 +80,7 @@ it('works with wildcard attributes directly', function (): void { $validator = makeValidator( ['items' => ['hello', 'world']], - ['items' => FluentRule::array()->required()->min(1), 'items.*' => FluentRule::string()->max(10)] + ['items' => FluentRule::array()->required()->min(1), 'items.*' => FluentRule::string()->max(10)], ); expect($validator->passes())->toBeTrue(); @@ -87,7 +89,7 @@ it('fails with wildcard attributes for invalid items', function (): void { $validator = makeValidator( ['items' => ['hello', 123]], - ['items' => FluentRule::array()->required(), 'items.*' => FluentRule::string()] + ['items' => FluentRule::array()->required(), 'items.*' => FluentRule::string()], ); expect($validator->passes())->toBeFalse(); @@ -99,10 +101,10 @@ it('uses custom error messages from the validator', function (): void { $v = new Validator( - new Translator(new ArrayLoader(), 'en'), - ['name' => ''], - ['name' => FluentRule::string()->required()], - ['name.required' => 'Please enter your name.'] + new Translator(new ArrayLoader, 'en'), + ['name' => ''], + ['name' => FluentRule::string()->required()], + ['name.required' => 'Please enter your name.'], ); expect($v->passes())->toBeFalse() @@ -111,11 +113,11 @@ it('uses custom attribute names', function (): void { $v = new Validator( - new Translator(new ArrayLoader(), 'en'), + new Translator(new ArrayLoader, 'en'), ['email_address' => 'not-enough'], ['email_address' => FluentRule::string()->required()->min(20)], [], - ['email_address' => 'email address'] + ['email_address' => 'email address'], ); expect($v->passes())->toBeFalse(); @@ -130,7 +132,7 @@ it('returns validated data correctly', function (): void { $validator = makeValidator( ['name' => 'John', 'age' => 25, 'extra' => 'ignored'], - ['name' => FluentRule::string()->required(), 'age' => FluentRule::numeric()->required()] + ['name' => FluentRule::string()->required(), 'age' => FluentRule::numeric()->required()], ); expect($validator->passes())->toBeTrue(); @@ -141,7 +143,7 @@ it('excludes absent optional fields from validated data', function (): void { $validator = makeValidator( ['name' => 'John'], - ['name' => FluentRule::string()->required(), 'nickname' => FluentRule::string()->sometimes()->min(2)] + ['name' => FluentRule::string()->required(), 'nickname' => FluentRule::string()->sometimes()->min(2)], ); expect($validator->passes())->toBeTrue(); @@ -156,7 +158,7 @@ it('handles multiple fluent rules in an array for one field', function (): void { $validator = makeValidator( ['age' => 25], - ['age' => [FluentRule::numeric()->required()->min(0), 'max:120']] + ['age' => [FluentRule::numeric()->required()->min(0), 'max:120']], ); expect($validator->passes())->toBeTrue(); @@ -173,7 +175,7 @@ // Wrap the fluent rule in an array. $validator = makeValidator( ['secret' => 'short'], - ['secret' => LaravelRule::when($isAdmin, [FluentRule::string()->required()->min(12)])] + ['secret' => LaravelRule::when($isAdmin, [FluentRule::string()->required()->min(12)])], ); expect($validator->passes())->toBeFalse(); @@ -184,7 +186,7 @@ $validator = makeValidator( ['secret' => 'short'], - ['secret' => LaravelRule::when($isAdmin, [FluentRule::string()->required()->min(12)])] + ['secret' => LaravelRule::when($isAdmin, [FluentRule::string()->required()->min(12)])], ); expect($validator->passes())->toBeTrue(); @@ -197,7 +199,7 @@ it('works for nested dot-notation fields', function (): void { $validator = makeValidator( ['user' => ['name' => 'John', 'email' => 'john@example.com']], - ['user.name' => FluentRule::string()->required()->min(2), 'user.email' => FluentRule::string()->required()] + ['user.name' => FluentRule::string()->required()->min(2), 'user.email' => FluentRule::string()->required()], ); expect($validator->passes())->toBeTrue(); @@ -206,7 +208,7 @@ it('fails for nested dot-notation fields', function (): void { $validator = makeValidator( ['user' => ['name' => 'J']], - ['user.name' => FluentRule::string()->required()->min(2)] + ['user.name' => FluentRule::string()->required()->min(2)], ); expect($validator->passes())->toBeFalse(); @@ -222,7 +224,7 @@ // object. Use them as separate native rules alongside the fluent rule. $validator = makeValidator( ['name' => 'John', 'internal_id' => '123'], - ['name' => FluentRule::string()->required(), 'internal_id' => ['exclude', FluentRule::string()]] + ['name' => FluentRule::string()->required(), 'internal_id' => ['exclude', FluentRule::string()]], ); expect($validator->passes())->toBeTrue(); diff --git a/tests/Internal/ItemRuleCompilerTest.php b/tests/Internal/ItemRuleCompilerTest.php index 8603b2b..eced0b0 100644 --- a/tests/Internal/ItemRuleCompilerTest.php +++ b/tests/Internal/ItemRuleCompilerTest.php @@ -1,4 +1,6 @@ -analyzeConditionals([ 'price' => [['exclude_unless', 'type', 'product'], 'required', 'numeric'], @@ -22,7 +24,7 @@ }); it('analyzeConditionals extracts ALL exclude conditions on a field', function (): void { - $compiler = new ItemRuleCompiler(); + $compiler = new ItemRuleCompiler; $result = $compiler->analyzeConditionals([ 'x' => [['exclude_unless', 'type', 'a'], ['exclude_if', 'other', 'z'], 'string'], @@ -35,7 +37,7 @@ }); it('analyzeConditionals extracts exclude_if with multiple values', function (): void { - $compiler = new ItemRuleCompiler(); + $compiler = new ItemRuleCompiler; $result = $compiler->analyzeConditionals([ 'sku' => [['exclude_if', 'type', 'draft', 'template'], 'required'], @@ -46,14 +48,14 @@ }); it('analyzeConditionals skips rules that are not arrays or have no conditional tuple', function (): void { - $compiler = new ItemRuleCompiler(); + $compiler = new ItemRuleCompiler; expect($compiler->analyzeConditionals(['name' => 'required|string']))->toBeEmpty() ->and($compiler->analyzeConditionals(['name' => ['required', 'string']]))->toBeEmpty(); }); it('findCommonDispatchField returns shared field when all conditionals reference it', function (): void { - $compiler = new ItemRuleCompiler(); + $compiler = new ItemRuleCompiler; $field = $compiler->findCommonDispatchField([ 'a' => [['action' => 'exclude_unless', 'field' => 'type', 'values' => ['x']]], @@ -64,7 +66,7 @@ }); it('findCommonDispatchField returns null when fields differ', function (): void { - $compiler = new ItemRuleCompiler(); + $compiler = new ItemRuleCompiler; $field = $compiler->findCommonDispatchField([ 'a' => [['action' => 'exclude_unless', 'field' => 'type', 'values' => ['x']]], @@ -79,7 +81,7 @@ }); it('reduceRulesForItem strips excluded fields when action matches', function (): void { - $compiler = new ItemRuleCompiler(); + $compiler = new ItemRuleCompiler; $conditionals = [ 'price' => [['action' => 'exclude_unless', 'field' => 'type', 'values' => ['product']]], ]; @@ -95,7 +97,7 @@ }); it('reduceRulesForItem keeps field and strips conditional tuple when active', function (): void { - $compiler = new ItemRuleCompiler(); + $compiler = new ItemRuleCompiler; $conditionals = [ 'price' => [['action' => 'exclude_unless', 'field' => 'type', 'values' => ['product']]], ]; @@ -110,7 +112,7 @@ }); it('ruleCacheKey encodes field name + rule content to distinguish items with varying reduced rules', function (): void { - $compiler = new ItemRuleCompiler(); + $compiler = new ItemRuleCompiler; // Keys with different string content must produce different cache keys — // this is what prevents a cached validator from being reused across @@ -126,12 +128,12 @@ }); it('buildFastChecks separates fast-checkable fields from slow rules', function (): void { - $compiler = new ItemRuleCompiler(); + $compiler = new ItemRuleCompiler; [$checks, $slowRules] = $compiler->buildFastChecks([ - 'name' => 'required|string', + 'name' => 'required|string', 'email' => 'required|email', - 'meta' => ['required', new In(['a', 'b'])], + 'meta' => ['required', new In(['a', 'b'])], ]); expect($checks)->toHaveCount(2) diff --git a/tests/Internal/ItemValidatorTest.php b/tests/Internal/ItemValidatorTest.php index acb35be..934ac6e 100644 --- a/tests/Internal/ItemValidatorTest.php +++ b/tests/Internal/ItemValidatorTest.php @@ -1,17 +1,19 @@ - 'draft', 'price' => null], ], itemRules: [ - 'type' => 'required|string', + 'type' => 'required|string', 'price' => [['exclude_unless', 'type', 'product'], 'required', 'numeric'], ], itemMessages: [], diff --git a/tests/Internal/ValidatorStateCopierTest.php b/tests/Internal/ValidatorStateCopierTest.php index 72ef9fb..0c1aaf9 100644 --- a/tests/Internal/ValidatorStateCopierTest.php +++ b/tests/Internal/ValidatorStateCopierTest.php @@ -1,17 +1,19 @@ - $data - * @param array $rules + * @param array $data + * @param array $rules */ function stateCopierValidator(array $data, array $rules): BaseValidator { - return new BaseValidator(new Translator(new ArrayLoader(), 'en'), $data, $rules); + return new BaseValidator(new Translator(new ArrayLoader, 'en'), $data, $rules); } function readValidatorProperty(BaseValidator $validator, string $property): mixed diff --git a/tests/ItemExclusionLeakParityTest.php b/tests/ItemExclusionLeakParityTest.php index 46601dc..a578ea7 100644 --- a/tests/ItemExclusionLeakParityTest.php +++ b/tests/ItemExclusionLeakParityTest.php @@ -1,8 +1,10 @@ - $data + * @param array $data + * * @return array */ function vanillaExclusionErrors(array $data): array diff --git a/tests/ItemValidatorCustomResolverTest.php b/tests/ItemValidatorCustomResolverTest.php index c018b0f..fc2b3e8 100644 --- a/tests/ItemValidatorCustomResolverTest.php +++ b/tests/ItemValidatorCustomResolverTest.php @@ -1,10 +1,12 @@ - $data - * @param array $rules - * @param array $messages - * @param array $attributes + * @param array $data + * @param array $rules + * @param array $messages + * @param array $attributes */ function makeCustomResolverValidator(Translator $translator, array $data, array $rules, array $messages = [], array $attributes = []): BaseValidator { @@ -51,8 +53,8 @@ function makeCustomResolverValidator(Translator $translator, array $data, array // The custom Rule object makes `rows.*.a` non-fast-checkable, forcing the // per-item slow path (makeItemValidator) rather than a fast-check closure. $rules = [ - 'rows' => 'required|array', - 'rows.*.a' => ['required', 'string', new CustomResolverAlwaysPassesRule()], + 'rows' => 'required|array', + 'rows.*.a' => ['required', 'string', new CustomResolverAlwaysPassesRule], ]; RuleSet::from($rules)->validate(['rows' => [['a' => 'x'], ['a' => 'y']]]); diff --git a/tests/ItemValidatorFastCheckCacheTest.php b/tests/ItemValidatorFastCheckCacheTest.php index 8edecc5..a1a14a0 100644 --- a/tests/ItemValidatorFastCheckCacheTest.php +++ b/tests/ItemValidatorFastCheckCacheTest.php @@ -1,7 +1,9 @@ -> $items + * @param list> $items + * * @return array> */ function runMemoConditionalItems(array $items): array diff --git a/tests/MemoizingValidatorParityTest.php b/tests/MemoizingValidatorParityTest.php index 0015d26..1b407e6 100644 --- a/tests/MemoizingValidatorParityTest.php +++ b/tests/MemoizingValidatorParityTest.php @@ -1,11 +1,13 @@ - $data - * @param array $rules + * @param array $data + * @param array $rules + * * @return array{0: BaseValidator, 1: MemoizingValidator} */ function memoParityPair(array $data, array $rules): array @@ -36,7 +39,7 @@ function memoParityPair(array $data, array $rules): array // A shared translator with no messages loaded: both validators emit the // same raw `validation.*` keys, so message parity is about structure and // ordering, not human strings. - $translator = new Translator(new ArrayLoader(), 'en'); + $translator = new Translator(new ArrayLoader, 'en'); return [ new BaseValidator($translator, $data, $rules), @@ -184,7 +187,7 @@ function memoParityScenarios(): iterable * regexes) relies on the drop to avoid unbounded growth on long-lived workers. */ it('caches up to the cap before resetting', function (): void { - for ($i = 1; $i <= 1024; ++$i) { + for ($i = 1; $i <= 1024; $i++) { [, $validator] = memoParityPair(['f' => 'x'], ['f' => 'max:' . $i]); $validator->passes(); } @@ -195,7 +198,7 @@ function memoParityScenarios(): iterable }); it('drops the cache after the cap is exceeded', function (): void { - for ($i = 1; $i <= 1024; ++$i) { + for ($i = 1; $i <= 1024; $i++) { [, $validator] = memoParityPair(['f' => 'x'], ['f' => 'max:' . $i]); $validator->passes(); } diff --git a/tests/NamingConventionTest.php b/tests/NamingConventionTest.php index 750d296..1e8245d 100644 --- a/tests/NamingConventionTest.php +++ b/tests/NamingConventionTest.php @@ -1,7 +1,9 @@ - 100, 'amount_confirmation' => 100], - ['amount' => FluentRule::numeric()->confirmed()] + ['amount' => FluentRule::numeric()->confirmed()], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['amount' => 100, 'amount_confirmation' => 200], - ['amount' => FluentRule::numeric()->confirmed()] + ['amount' => FluentRule::numeric()->confirmed()], ); expect($v->passes())->toBeFalse(); }); @@ -189,13 +191,13 @@ it('validates numeric with inArray', function (): void { $v = makeValidator( ['val' => 2, 'allowed' => [1, 2, 3]], - ['val' => FluentRule::numeric()->inArray('allowed.*')] + ['val' => FluentRule::numeric()->inArray('allowed.*')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['val' => 9, 'allowed' => [1, 2, 3]], - ['val' => FluentRule::numeric()->inArray('allowed.*')] + ['val' => FluentRule::numeric()->inArray('allowed.*')], ); expect($v->passes())->toBeFalse(); }); diff --git a/tests/PasswordRuleTest.php b/tests/PasswordRuleTest.php index af0c91b..c736f74 100644 --- a/tests/PasswordRuleTest.php +++ b/tests/PasswordRuleTest.php @@ -1,4 +1,6 @@ - 'SecureP@ss1'], - ['password' => FluentRule::password()->required()->letters()->mixedCase()->numbers()->symbols()] + ['password' => FluentRule::password()->required()->letters()->mixedCase()->numbers()->symbols()], ); expect($validator->passes())->toBeTrue(); }); @@ -18,7 +20,7 @@ it('rejects weak password', function (): void { $validator = makeValidator( ['password' => 'weak'], - ['password' => FluentRule::password(8)->required()->letters()->numbers()] + ['password' => FluentRule::password(8)->required()->letters()->numbers()], ); expect($validator->passes())->toBeFalse(); }); @@ -26,13 +28,13 @@ it('validates password min length', function (): void { $v = makeValidator( ['password' => 'abcdefgh'], - ['password' => FluentRule::password(8)->required()] + ['password' => FluentRule::password(8)->required()], ); expect($v->passes())->toBeTrue(); $v = makeValidator( ['password' => 'short'], - ['password' => FluentRule::password(8)->required()] + ['password' => FluentRule::password(8)->required()], ); expect($v->passes())->toBeFalse(); }); @@ -40,7 +42,7 @@ it('validates password max length', function (): void { $validator = makeValidator( ['password' => str_repeat('a', 256)], - ['password' => FluentRule::password()->required()->max(255)] + ['password' => FluentRule::password()->required()->max(255)], ); expect($validator->passes())->toBeFalse(); }); diff --git a/tests/Pest.php b/tests/Pest.php index 8018a83..920749c 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,11 +1,13 @@ - */ @@ -60,12 +63,13 @@ public function authorize(): bool * Build a FormRequest that defines its rules through the FluentSchema * builder via a schema() method, mirroring createFormRequest(). * - * @param Closure(FluentSchema): array $schema - * @param array $data + * @param Closure(FluentSchema): array $schema + * @param array $data */ function createSchemaFormRequest(Closure $schema, array $data): FormRequest { - $formRequest = new class extends FormRequest { + $formRequest = new class extends FormRequest + { use HasFluentRules; /** @var Closure(FluentSchema): array */ @@ -94,8 +98,9 @@ public function authorize(): bool * * @template T of FormRequest * - * @param T $formRequest - * @param array $data + * @param T $formRequest + * @param array $data + * * @return T */ function bootFormRequest(FormRequest $formRequest, array $data): FormRequest diff --git a/tests/PhoneRuleTest.php b/tests/PhoneRuleTest.php index ff51699..4bbcea8 100644 --- a/tests/PhoneRuleTest.php +++ b/tests/PhoneRuleTest.php @@ -1,10 +1,12 @@ - $number], ['phone' => FluentRule::phone()])->passes())->toBeTrue(); })->with([ - 'Kenya' => ['+254712123456'], - 'Türkiye' => ['+905301111111'], + 'Kenya' => ['+254712123456'], + 'Türkiye' => ['+905301111111'], 'United Kingdom' => ['+447400123456'], - 'Brazil' => ['+5511961234567'], + 'Brazil' => ['+5511961234567'], ]); // ========================================================================= @@ -143,10 +145,10 @@ it('normalises what the user actually pasted', function (string $input): void { expect(makeValidator(['phone' => $input], ['phone' => FluentRule::phone()->country('KE')])->passes())->toBeTrue(); })->with([ - 'international' => ['+254712123456'], - 'IDD prefix' => ['00254712123456'], + 'international' => ['+254712123456'], + 'IDD prefix' => ['00254712123456'], 'spaced national' => ['0712 123 456'], - 'punctuated' => ['(0712) 123-456'], + 'punctuated' => ['(0712) 123-456'], // Typed on an Arabic keyboard. None of the surveyed packages handles this. 'Arabic-Indic digits' => ['٠٠٢٥٤٧١٢١٢٣٤٥٦'], ]); diff --git a/tests/PrecognitionSkippableTest.php b/tests/PrecognitionSkippableTest.php index 7a0d625..863ec5f 100644 --- a/tests/PrecognitionSkippableTest.php +++ b/tests/PrecognitionSkippableTest.php @@ -1,4 +1,6 @@ -> $items - * @param array $messages + * @param array> $items + * @param array $messages + * * @return array> */ function runPresenceItems(array $items, array $messages = []): array @@ -78,12 +81,12 @@ function runPresenceItems(array $items, array $messages = []): array it('required_without: verdicts match native Laravel for every shape', function (): void { $shapes = [ ['postcode' => '1234AB', 'birthdate' => '1990-01-01'], - ['postcode' => '1234AB'], + ['postcode' => '1234AB'], ['birthdate' => '1990-01-01'], [], - ['postcode' => null], - ['postcode' => ''], - ['postcode' => []], + ['postcode' => null], + ['postcode' => ''], + ['postcode' => []], ['birthdate' => null], ['birthdate' => ''], ]; @@ -157,17 +160,18 @@ function fieldWithPresenceRule(string $ruleName): FieldRule $field = FluentRule::field(); return match ($ruleName) { - 'required_with' => $field->requiredWith('birthdate'), - 'required_without' => $field->requiredWithout('birthdate'), - 'required_with_all' => $field->requiredWithAll('birthdate'), + 'required_with' => $field->requiredWith('birthdate'), + 'required_without' => $field->requiredWithout('birthdate'), + 'required_with_all' => $field->requiredWithAll('birthdate'), 'required_without_all' => $field->requiredWithoutAll('birthdate'), - default => throw new LogicException('Unknown rule ' . $ruleName), + default => throw new LogicException('Unknown rule ' . $ruleName), }; } /** - * @param array> $items - * @param array $messages + * @param array> $items + * @param array $messages + * * @return array> */ function runPresenceItemsForRule(string $ruleName, array $items, array $messages = []): array @@ -218,12 +222,12 @@ function runPresenceItemsForRule(string $ruleName, array $items, array $messages it('phase 2: verdicts match native Laravel across all four rules × shape grid', function (): void { $shapes = [ ['postcode' => '1234AB', 'birthdate' => '1990-01-01'], - ['postcode' => '1234AB'], + ['postcode' => '1234AB'], ['birthdate' => '1990-01-01'], [], - ['postcode' => null], - ['postcode' => ''], - ['postcode' => []], + ['postcode' => null], + ['postcode' => ''], + ['postcode' => []], ['birthdate' => null], ['birthdate' => ' '], ]; @@ -254,7 +258,7 @@ function runPresenceItemsForRule(string $ruleName, array $items, array $messages // Build a shape that definitely activates each rule → target missing → error must mention the preserved rule name. $shape = match ($ruleName) { - 'required_with', 'required_with_all' => ['birthdate' => '1990-01-01'], + 'required_with', 'required_with_all' => ['birthdate' => '1990-01-01'], 'required_without', 'required_without_all' => [], }; @@ -274,18 +278,18 @@ function runPresenceItemsForRule(string $ruleName, array $items, array $messages /** * Build a FieldRule with a multi-param presence conditional. * - * @param list $params + * @param list $params */ function fieldWithMultiParamPresenceRule(string $ruleName, array $params): FieldRule { $field = FluentRule::field(); return match ($ruleName) { - 'required_with' => $field->requiredWith(...$params), - 'required_without' => $field->requiredWithout(...$params), - 'required_with_all' => $field->requiredWithAll(...$params), + 'required_with' => $field->requiredWith(...$params), + 'required_without' => $field->requiredWithout(...$params), + 'required_with_all' => $field->requiredWithAll(...$params), 'required_without_all' => $field->requiredWithoutAll(...$params), - default => throw new LogicException('Unknown rule ' . $ruleName), + default => throw new LogicException('Unknown rule ' . $ruleName), }; } @@ -299,9 +303,9 @@ function fieldWithMultiParamPresenceRule(string $ruleName, array $params): Field ['b' => 'B'], // one sibling present, target absent [], // none present ['postcode' => 'X'], // only target present - ['a' => '', 'b' => 'B'], // one sibling empty-string - ['a' => null, 'b' => 'B'], // one sibling null - ['a' => ' ', 'b' => 'B'], // one sibling whitespace-only + ['a' => '', 'b' => 'B'], // one sibling empty-string + ['a' => null, 'b' => 'B'], // one sibling null + ['a' => ' ', 'b' => 'B'], // one sibling whitespace-only ]; $ruleNames = ['required_with', 'required_without', 'required_with_all', 'required_without_all']; @@ -384,7 +388,8 @@ function fieldWithMultiParamPresenceRule(string $ruleName, array $params): Field * Uses the raw-rule form (not FluentRule) so exotic parameter shapes reach * the parser verbatim. * - * @param array> $items + * @param array> $items + * * @return array{native: array, fluent: array} */ function parityForRawPresenceRule(string $ruleString, array $items): array @@ -426,7 +431,7 @@ static function (array $item) use ($ruleString): bool { $items = [ ['postcode' => 'X', 'birthdate' => '1990-01-01'], // birthdate present → rule active ['birthdate' => '1990-01-01'], // target absent, rule active → fail - ['postcode' => 'X'], // birthdate absent, but null slot matches full item → present + ['postcode' => 'X'], // birthdate absent, but null slot matches full item → present [], // fully empty item ]; @@ -438,7 +443,7 @@ static function (array $item) use ($ruleString): bool { $items = [ ['postcode' => 'X', 'birthdate' => '1990-01-01'], ['birthdate' => '1990-01-01'], - ['postcode' => 'X'], + ['postcode' => 'X'], [], ]; @@ -453,7 +458,7 @@ static function (array $item) use ($ruleString): bool { $items = [ ['postcode' => 'X', 'a,b' => 1, 'c' => 1], ['a,b' => 1], - ['c' => 1], + ['c' => 1], [], ]; @@ -491,7 +496,8 @@ static function (array $item) use ($ruleString): bool { it('required_without treats empty Countable sibling as absent', function (): void { // Sibling = empty Countable → rule activates. - $emptyCountable = new class implements Countable { + $emptyCountable = new class implements Countable + { public function count(): int { return 0; diff --git a/tests/ProhibitedConditionalParityTest.php b/tests/ProhibitedConditionalParityTest.php index 1e037c5..a7fa3e0 100644 --- a/tests/ProhibitedConditionalParityTest.php +++ b/tests/ProhibitedConditionalParityTest.php @@ -1,7 +1,9 @@ - $files */ $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS) + new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS), ); foreach ($files as $file) { @@ -56,7 +56,7 @@ function providerFiles(string $src): array preg_match('/^namespace\s+([^;]+);/m', $source === false ? '' : $source, $matches); expect($matches[1] ?? '')->toEndWith('\\Providers'); - ++$checked; + $checked++; } expect($checked)->toBeGreaterThan(0); diff --git a/tests/PublicApiBoundaryTest.php b/tests/PublicApiBoundaryTest.php index 28ac7d0..ee24ff8 100644 --- a/tests/PublicApiBoundaryTest.php +++ b/tests/PublicApiBoundaryTest.php @@ -1,4 +1,6 @@ - FluentRule::string(), - 'numeric' => FluentRule::numeric(), - 'integer' => FluentRule::integer(), - 'date' => FluentRule::date(), - 'boolean' => FluentRule::boolean(), - 'array' => FluentRule::array(), - 'file' => FluentRule::file(), - 'image' => FluentRule::image(), + 'string' => FluentRule::string(), + 'numeric' => FluentRule::numeric(), + 'integer' => FluentRule::integer(), + 'date' => FluentRule::date(), + 'boolean' => FluentRule::boolean(), + 'array' => FluentRule::array(), + 'file' => FluentRule::file(), + 'image' => FluentRule::image(), 'password' => FluentRule::password(), - 'email' => FluentRule::email(), + 'email' => FluentRule::email(), 'accepted' => FluentRule::accepted(), - default => FluentRule::field(), + default => FluentRule::field(), }; expect($rule->rule('regex:/^(foo|bar)$/')->compiledRules())->toBeArray(); diff --git a/tests/RequiredConditionalNullableTest.php b/tests/RequiredConditionalNullableTest.php index 995135d..27e8130 100644 --- a/tests/RequiredConditionalNullableTest.php +++ b/tests/RequiredConditionalNullableTest.php @@ -1,9 +1,11 @@ - $r->requiredIf('role', 'admin'), - 'required_if:role,admin', [['active', ['role' => 'admin']], ['inactive', ['role' => 'guest']]]], + 'required_if:role,admin', [['active', ['role' => 'admin']], ['inactive', ['role' => 'guest']]]], ['requiredUnless(role,admin)', static fn (FluentRuleContract $r): FluentRuleContract => $r->requiredUnless('role', 'admin'), - 'required_unless:role,admin', [['active', ['role' => 'guest']], ['inactive', ['role' => 'admin']]]], + 'required_unless:role,admin', [['active', ['role' => 'guest']], ['inactive', ['role' => 'admin']]]], ['requiredWith(other)', static fn (FluentRuleContract $r): FluentRuleContract => $r->requiredWith('other'), - 'required_with:other', [['active', ['other' => 'y']], ['inactive', []]]], + 'required_with:other', [['active', ['other' => 'y']], ['inactive', []]]], ['requiredWithAll(a,b)', static fn (FluentRuleContract $r): FluentRuleContract => $r->requiredWithAll('a', 'b'), - 'required_with_all:a,b', [['active', ['a' => 1, 'b' => 2]], ['inactive', ['a' => 1]]]], + 'required_with_all:a,b', [['active', ['a' => 1, 'b' => 2]], ['inactive', ['a' => 1]]]], ['requiredWithout(other)', static fn (FluentRuleContract $r): FluentRuleContract => $r->requiredWithout('other'), - 'required_without:other', [['active', []], ['inactive', ['other' => 'y']]]], + 'required_without:other', [['active', []], ['inactive', ['other' => 'y']]]], ['requiredWithoutAll(a,b)', static fn (FluentRuleContract $r): FluentRuleContract => $r->requiredWithoutAll('a', 'b'), - 'required_without_all:a,b', [['active', []], ['inactive', ['a' => 1]]]], + 'required_without_all:a,b', [['active', []], ['inactive', ['a' => 1]]]], ['requiredIfAccepted(terms)', static fn (FluentRuleContract $r): FluentRuleContract => $r->requiredIfAccepted('terms'), - 'required_if_accepted:terms', [['active', ['terms' => 'yes']], ['inactive', ['terms' => 'no']]]], + 'required_if_accepted:terms', [['active', ['terms' => 'yes']], ['inactive', ['terms' => 'no']]]], ['requiredIfDeclined(terms)', static fn (FluentRuleContract $r): FluentRuleContract => $r->requiredIfDeclined('terms'), - 'required_if_declined:terms', [['active', ['terms' => 'no']], ['inactive', ['terms' => 'yes']]]], + 'required_if_declined:terms', [['active', ['terms' => 'no']], ['inactive', ['terms' => 'yes']]]], ]; } @@ -97,10 +99,10 @@ function conditionalRequiredFamilies(): array function conditionalFieldValueStates(): array { return [ - 'absent' => FIELD_ABSENT, - 'null' => null, + 'absent' => FIELD_ABSENT, + 'null' => null, 'empty-string' => '', - 'valid' => 'x', + 'valid' => 'x', ]; } @@ -121,23 +123,23 @@ function presenceForcingFamilies(): array ['present', static fn (FluentRuleContract $r): FluentRuleContract => $r->present(), 'present', [['always', []]]], ['presentIf(role,admin)', static fn (FluentRuleContract $r): FluentRuleContract => $r->presentIf('role', 'admin'), - 'present_if:role,admin', [['trigger', ['role' => 'admin']], ['no-trigger', ['role' => 'guest']]]], + 'present_if:role,admin', [['trigger', ['role' => 'admin']], ['no-trigger', ['role' => 'guest']]]], ['presentUnless(role,admin)', static fn (FluentRuleContract $r): FluentRuleContract => $r->presentUnless('role', 'admin'), - 'present_unless:role,admin', [['trigger', ['role' => 'guest']], ['no-trigger', ['role' => 'admin']]]], + 'present_unless:role,admin', [['trigger', ['role' => 'guest']], ['no-trigger', ['role' => 'admin']]]], ['presentWith(other)', static fn (FluentRuleContract $r): FluentRuleContract => $r->presentWith('other'), - 'present_with:other', [['trigger', ['other' => 'y']], ['no-trigger', []]]], + 'present_with:other', [['trigger', ['other' => 'y']], ['no-trigger', []]]], ['presentWithAll(a,b)', static fn (FluentRuleContract $r): FluentRuleContract => $r->presentWithAll('a', 'b'), - 'present_with_all:a,b', [['trigger', ['a' => 1, 'b' => 2]], ['no-trigger', ['a' => 1]]]], + 'present_with_all:a,b', [['trigger', ['a' => 1, 'b' => 2]], ['no-trigger', ['a' => 1]]]], ['missing', static fn (FluentRuleContract $r): FluentRuleContract => $r->missing(), 'missing', [['always', []]]], ['missingIf(role,admin)', static fn (FluentRuleContract $r): FluentRuleContract => $r->missingIf('role', 'admin'), - 'missing_if:role,admin', [['trigger', ['role' => 'admin']], ['no-trigger', ['role' => 'guest']]]], + 'missing_if:role,admin', [['trigger', ['role' => 'admin']], ['no-trigger', ['role' => 'guest']]]], ['missingUnless(role,admin)', static fn (FluentRuleContract $r): FluentRuleContract => $r->missingUnless('role', 'admin'), - 'missing_unless:role,admin', [['trigger', ['role' => 'guest']], ['no-trigger', ['role' => 'admin']]]], + 'missing_unless:role,admin', [['trigger', ['role' => 'guest']], ['no-trigger', ['role' => 'admin']]]], ['missingWith(other)', static fn (FluentRuleContract $r): FluentRuleContract => $r->missingWith('other'), - 'missing_with:other', [['trigger', ['other' => 'y']], ['no-trigger', []]]], + 'missing_with:other', [['trigger', ['other' => 'y']], ['no-trigger', []]]], ['missingWithAll(a,b)', static fn (FluentRuleContract $r): FluentRuleContract => $r->missingWithAll('a', 'b'), - 'missing_with_all:a,b', [['trigger', ['a' => 1, 'b' => 2]], ['no-trigger', ['a' => 1]]]], + 'missing_with_all:a,b', [['trigger', ['a' => 1, 'b' => 2]], ['no-trigger', ['a' => 1]]]], // Prohibited family — excluded from the presence-forcing set on purpose: // it is satisfied by an empty/null value, so the nullable short-circuit @@ -155,6 +157,7 @@ function presenceForcingFamilies(): array * Expand a family list into a flat, labelled case map. * * @param list}>}> $families + * * @return array, modifier: Closure}> */ function expandNullableCases(array $families): array @@ -176,9 +179,9 @@ function expandNullableCases(array $families): array $label = "{$familyLabel} | {$nullableLabel} | {$contextLabel} | {$valueLabel}"; $cases[$label] = [ - 'native' => $native, + 'native' => $native, 'nullable' => $withNullable, - 'data' => $data, + 'data' => $data, 'modifier' => $modifier, ]; } @@ -274,11 +277,11 @@ function assertNullableParityAcrossPaths(array $case): void expect($validator->fails())->toBe($shouldFail); })->with([ - 'missing' => [null, false, true], - 'null' => [null, true, true], - 'empty string' => ['', true, true], + 'missing' => [null, false, true], + 'null' => [null, true, true], + 'empty string' => ['', true, true], 'invalid email' => ['nope', true, true], - 'valid email' => ['a@b.com', true, false], + 'valid email' => ['a@b.com', true, false], ]); it('requiredIf(false)->nullable() stays optional', function (mixed $value, bool $present, bool $shouldFail): void { @@ -288,11 +291,11 @@ function assertNullableParityAcrossPaths(array $case): void expect($validator->fails())->toBe($shouldFail); })->with([ - 'missing' => [null, false, false], - 'null' => [null, true, false], - 'empty string' => ['', true, false], + 'missing' => [null, false, false], + 'null' => [null, true, false], + 'empty string' => ['', true, false], 'invalid email still fails type' => ['nope', true, true], - 'valid email' => ['a@b.com', true, false], + 'valid email' => ['a@b.com', true, false], ]); it('string-form requiredWith->nullable() enforces when the trigger is present', function (array $data, bool $shouldFail): void { @@ -302,11 +305,11 @@ function assertNullableParityAcrossPaths(array $case): void expect($validator->fails())->toBe($shouldFail); })->with([ 'trigger present, field missing' => [['other' => 'y'], true], - 'trigger present, field null' => [['other' => 'y', 'field' => null], true], - 'trigger present, field empty' => [['other' => 'y', 'field' => ''], true], - 'trigger present, field set' => [['other' => 'y', 'field' => 'x'], false], - 'trigger absent, field missing' => [[], false], - 'trigger absent, field null' => [['field' => null], false], + 'trigger present, field null' => [['other' => 'y', 'field' => null], true], + 'trigger present, field empty' => [['other' => 'y', 'field' => ''], true], + 'trigger present, field set' => [['other' => 'y', 'field' => 'x'], false], + 'trigger absent, field missing' => [[], false], + 'trigger absent, field null' => [['field' => null], false], ]); it('string-form requiredIfAccepted->nullable() enforces when accepted', function (array $data, bool $shouldFail): void { @@ -315,11 +318,11 @@ function assertNullableParityAcrossPaths(array $case): void expect($validator->fails())->toBe($shouldFail); })->with([ - 'accepted, sig missing' => [['terms' => 'yes'], true], - 'accepted, sig null' => [['terms' => 'yes', 'sig' => null], true], - 'accepted, sig set' => [['terms' => 'yes', 'sig' => 'signed'], false], + 'accepted, sig missing' => [['terms' => 'yes'], true], + 'accepted, sig null' => [['terms' => 'yes', 'sig' => null], true], + 'accepted, sig set' => [['terms' => 'yes', 'sig' => 'signed'], false], 'not accepted, sig missing' => [['terms' => 'no'], false], - 'not accepted, sig null' => [['terms' => 'no', 'sig' => null], false], + 'not accepted, sig null' => [['terms' => 'no', 'sig' => null], false], ]); // ------------------------------------------------------------------------- @@ -355,8 +358,8 @@ function assertNullableParityAcrossPaths(array $case): void expect($validator->fails())->toBe($shouldFail); })->with([ - 'key absent' => [[], true], - 'key present, null' => [['field' => null], false], + 'key absent' => [[], true], + 'key present, null' => [['field' => null], false], 'key present, value' => [['field' => 'x'], false], ]); @@ -366,8 +369,8 @@ function assertNullableParityAcrossPaths(array $case): void expect($validator->fails())->toBe($shouldFail); })->with([ - 'key absent' => [[], false], - 'key present, null' => [['field' => null], true], + 'key absent' => [[], false], + 'key present, null' => [['field' => null], true], 'key present, empty' => [['field' => ''], true], 'key present, value' => [['field' => 'x'], false], ]); @@ -378,8 +381,8 @@ function assertNullableParityAcrossPaths(array $case): void expect($validator->fails())->toBe($shouldFail); })->with([ - 'key absent' => [[], false], - 'key present, null' => [['field' => null], true], + 'key absent' => [[], false], + 'key present, null' => [['field' => null], true], 'key present, value' => [['field' => 'x'], true], ]); @@ -395,9 +398,9 @@ function assertNullableParityAcrossPaths(array $case): void ->and(makeValidator($data, ['field' => $rule])->fails())->toBe($shouldFail) ->and(RuleSet::from(['field' => $rule])->check($data)->fails())->toBe($shouldFail); })->with([ - 'null value' => [['field' => null], false], - 'missing field' => [[], false], - 'valid array' => [['field' => ['name' => 'x']], false], + 'null value' => [['field' => null], false], + 'missing field' => [[], false], + 'valid array' => [['field' => ['name' => 'x']], false], 'array missing required key' => [['field' => ['other' => 'x']], true], ]); @@ -421,11 +424,11 @@ function nestedNullableCases(): array $eachRequiredNative = ['obj' => ['nullable', 'array'], 'obj.*.id' => ['integer', 'required']]; return [ - 'children required, parent null' => [$childrenRequired, $childrenRequiredNative, ['obj' => null]], - 'children required, parent absent' => [$childrenRequired, $childrenRequiredNative, []], - 'children required, parent valid' => [$childrenRequired, $childrenRequiredNative, ['obj' => ['id' => 1]]], + 'children required, parent null' => [$childrenRequired, $childrenRequiredNative, ['obj' => null]], + 'children required, parent absent' => [$childrenRequired, $childrenRequiredNative, []], + 'children required, parent valid' => [$childrenRequired, $childrenRequiredNative, ['obj' => ['id' => 1]]], 'children required, parent missing key' => [$childrenRequired, $childrenRequiredNative, ['obj' => ['other' => 1]]], - 'children nullable child, parent null' => [ + 'children nullable child, parent null' => [ static fn (): FluentRuleContract => FluentRule::array()->nullable()->children(['id' => FluentRule::integer()->nullable()]), ['obj' => ['nullable', 'array'], 'obj.id' => ['integer', 'nullable']], ['obj' => null], @@ -435,9 +438,9 @@ function nestedNullableCases(): array ['obj' => ['present', 'nullable', 'array'], 'obj.id' => ['integer', 'required']], ['obj' => null], ], - 'each required, parent null' => [$eachRequired, $eachRequiredNative, ['obj' => null]], - 'each required, parent empty array' => [$eachRequired, $eachRequiredNative, ['obj' => []]], - 'each required, parent valid' => [$eachRequired, $eachRequiredNative, ['obj' => [['id' => 1]]]], + 'each required, parent null' => [$eachRequired, $eachRequiredNative, ['obj' => null]], + 'each required, parent empty array' => [$eachRequired, $eachRequiredNative, ['obj' => []]], + 'each required, parent valid' => [$eachRequired, $eachRequiredNative, ['obj' => [['id' => 1]]]], 'each required, element missing key' => [$eachRequired, $eachRequiredNative, ['obj' => [['other' => 1]]]], ]; } diff --git a/tests/RuleAliasesTest.php b/tests/RuleAliasesTest.php index 516eaf3..57e4d66 100644 --- a/tests/RuleAliasesTest.php +++ b/tests/RuleAliasesTest.php @@ -1,11 +1,13 @@ - [User::class], - 'authorized' => ['do-something', User::class], - 'parity' => ['even'], - 'vendor_identifier' => ['aws_region'], + 'models_exist' => [User::class], + 'authorized' => ['do-something', User::class], + 'parity' => ['even'], + 'vendor_identifier' => ['aws_region'], 'national_identifier' => ['nl'], - 'minute_in' => ['0', '15', '30', '45'], + 'minute_in' => ['0', '15', '30', '45'], 'max_date_difference' => ['48', '2026-08-24 00:00:00'], - 'minimum_age' => ['18'], - 'compare_to_column' => ['posts', 'owner_id', 'gte', 'id', '1'], + 'minimum_age' => ['18'], + 'compare_to_column' => ['posts', 'owner_id', 'gte', 'id', '1'], 'file_exists_on_disk' => ['local'], - 'hash_digest' => ['sha256'], - 'max_words' => ['200'], - 'min_words' => ['2'], + 'hash_digest' => ['sha256'], + 'max_words' => ['200'], + 'min_words' => ['2'], ]; } @@ -182,7 +184,7 @@ function ruleClasses(): array continue; } - $direct = new $class(); + $direct = new $class; if (print_r($viaAlias, true) !== print_r($direct, true)) { $drifted[] = $suffix; diff --git a/tests/RuleConfigBuilderTest.php b/tests/RuleConfigBuilderTest.php index 6e2bf21..39c5359 100644 --- a/tests/RuleConfigBuilderTest.php +++ b/tests/RuleConfigBuilderTest.php @@ -1,8 +1,10 @@ -keysReferencedInSource() as $key) { diff --git a/tests/RuleRegistrarTest.php b/tests/RuleRegistrarTest.php index 65e85f8..4bdf50b 100644 --- a/tests/RuleRegistrarTest.php +++ b/tests/RuleRegistrarTest.php @@ -1,15 +1,17 @@ -register( EvenNumber::class, alias: 'acme_even', - factory: fn (array $parameters): EvenNumber => new EvenNumber(), + factory: fn (array $parameters): EvenNumber => new EvenNumber, ); expect(resolve(RuleRegistrar::class)->classes())->toContain(EvenNumber::class); @@ -68,7 +70,8 @@ it('implementing ClientCheckable is the whole browser opt-in for a custom rule', function (): void { // No registry call needed for the wire: the exporter reads the // interface off the rule object itself. - $rule = new class implements ClientCheckable, ValidationRule { + $rule = new class implements ClientCheckable, ValidationRule + { public function validate(string $attribute, mixed $value, Closure $fail): void {} public function clientRules(): array diff --git a/tests/RuleSetEventsTest.php b/tests/RuleSetEventsTest.php index aabd9b4..16d3da9 100644 --- a/tests/RuleSetEventsTest.php +++ b/tests/RuleSetEventsTest.php @@ -1,14 +1,16 @@ -rules['once_only'] = 'required'; diff --git a/tests/RuleSetModifyEachTest.php b/tests/RuleSetModifyEachTest.php index 512027d..45c0b8d 100644 --- a/tests/RuleSetModifyEachTest.php +++ b/tests/RuleSetModifyEachTest.php @@ -1,11 +1,13 @@ - [['f' => $rule], ['f' => $value]], - 'wildcard' => [['items.*.f' => $rule], ['items' => [['f' => $value], ['f' => $value]]]], + 'wildcard' => [['items.*.f' => $rule], ['items' => [['f' => $value], ['f' => $value]]]], ]; foreach ($shapes as $shape => [$rules, $data]) { diff --git a/tests/RuleSetTest.php b/tests/RuleSetTest.php index f35a0ae..526b905 100644 --- a/tests/RuleSetTest.php +++ b/tests/RuleSetTest.php @@ -1,20 +1,22 @@ - FluentRule::string()->required(), - 'age' => FluentRule::numeric()->nullable(), + 'age' => FluentRule::numeric()->nullable(), ])->toArray(); expect($rules)->toHaveKeys(['name', 'age']); @@ -42,8 +44,8 @@ it('handles mixed rule types via from()', function (): void { $rules = RuleSet::from([ - 'name' => 'required|string|max:255', - 'age' => ['required', 'integer'], + 'name' => 'required|string|max:255', + 'age' => ['required', 'integer'], 'email' => FluentRule::string()->required()->rule('email'), ])->toArray(); expect($rules)->toMatchArray(['name' => 'required|string|max:255', 'age' => ['required', 'integer']]) @@ -68,7 +70,7 @@ $rules = RuleSet::from([ 'items' => FluentRule::array()->required()->each([ 'name' => FluentRule::string()->required(), - 'qty' => FluentRule::numeric()->required()->integer(), + 'qty' => FluentRule::numeric()->required()->integer(), ]), ])->toArray(); @@ -107,7 +109,7 @@ it('expands wildcard fields against data', function (): void { $rules = RuleSet::from([ - 'items' => FluentRule::array()->required(), + 'items' => FluentRule::array()->required(), 'items.*.name' => FluentRule::string()->required(), ])->expandWildcards([ 'items' => [ @@ -177,7 +179,7 @@ it('validates simple rules without wildcards', function (): void { $validated = RuleSet::from([ 'name' => FluentRule::string()->required()->min(2), - 'age' => FluentRule::numeric()->nullable()->integer()->min(0), + 'age' => FluentRule::numeric()->nullable()->integer()->min(0), ])->validate(['name' => 'John', 'age' => 25]); expect($validated)->toBe(['name' => 'John', 'age' => 25]); @@ -241,7 +243,7 @@ 'name' => FluentRule::string()->required(), ])->validate( ['name' => ''], - ['name.required' => 'Please provide your name.'] + ['name.required' => 'Please provide your name.'], ); } catch (ValidationException $validationException) { expect($validationException->errors()['name'][0])->toBe('Please provide your name.'); @@ -259,7 +261,7 @@ ])->validate( ['email_address' => 'test@example.com'], [], - ['email_address' => 'email address'] + ['email_address' => 'email address'], ); } catch (ValidationException $validationException) { expect($validationException->errors()['email_address'][0])->toContain('email address'); @@ -276,12 +278,12 @@ it('throws when top-level field fails alongside wildcard rules', function (): void { RuleSet::from([ - 'name' => FluentRule::string()->required()->min(5), + 'name' => FluentRule::string()->required()->min(5), 'items' => FluentRule::array()->required()->each([ 'title' => FluentRule::string()->required(), ]), ])->validate([ - 'name' => 'Jo', + 'name' => 'Jo', 'items' => [['title' => 'OK']], ]); })->throws(ValidationException::class); @@ -423,7 +425,8 @@ }); it('fast-checks skip non-compilable rules and use slow path', function (): void { - $customRule = new class implements ValidationRule { + $customRule = new class implements ValidationRule + { public function validate(string $attribute, mixed $value, Closure $fail): void { if ($value !== 'valid') { @@ -795,7 +798,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void try { RuleSet::from([ 'tags' => FluentRule::array(label: 'Tag List')->required()->each( - FluentRule::string('Tag')->required()->distinct() + FluentRule::string('Tag')->required()->distinct(), ), ])->validate([ 'tags' => ['php', 'php'], @@ -914,8 +917,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('adds fields conditionally with when()', function (): void { $rules = RuleSet::make() ->field('name', FluentRule::string()->required()) - ->when(true, fn (RuleSet $ruleSet) => $ruleSet - ->field('role', FluentRule::string()->required()) + ->when( + true, + fn (RuleSet $ruleSet) => $ruleSet + ->field('role', FluentRule::string()->required()), ) ->toArray(); @@ -925,8 +930,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('skips fields when condition is false', function (): void { $rules = RuleSet::make() ->field('name', FluentRule::string()->required()) - ->when(false, fn (RuleSet $ruleSet) => $ruleSet - ->field('role', FluentRule::string()->required()) + ->when( + false, + fn (RuleSet $ruleSet) => $ruleSet + ->field('role', FluentRule::string()->required()), ) ->toArray(); @@ -936,8 +943,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('supports unless()', function (): void { $rules = RuleSet::make() ->field('name', FluentRule::string()->required()) - ->unless(true, fn (RuleSet $ruleSet) => $ruleSet - ->field('role', FluentRule::string()->required()) + ->unless( + true, + fn (RuleSet $ruleSet) => $ruleSet + ->field('role', FluentRule::string()->required()), ) ->toArray(); @@ -950,7 +959,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('supports array spread via IteratorAggregate', function (): void { $ruleSet = RuleSet::from([ - 'name' => FluentRule::string()->required(), + 'name' => FluentRule::string()->required(), 'email' => FluentRule::email()->required(), ]); @@ -965,7 +974,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('IteratorAggregate yields the same keys as toArray()', function (): void { $ruleSet = RuleSet::from([ - 'name' => FluentRule::string()->required(), + 'name' => FluentRule::string()->required(), 'items' => FluentRule::array()->each([ 'qty' => FluentRule::numeric()->required(), ]), @@ -984,7 +993,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('all() returns the same value as toArray()', function (): void { $ruleSet = RuleSet::from([ - 'name' => FluentRule::string()->required(), + 'name' => FluentRule::string()->required(), 'email' => FluentRule::email()->required(), ]); @@ -1039,9 +1048,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('keeps only the named fields via only()', function (): void { $rules = RuleSet::from([ - 'name' => FluentRule::string()->required(), + 'name' => FluentRule::string()->required(), 'email' => FluentRule::email()->required(), - 'age' => FluentRule::numeric()->nullable(), + 'age' => FluentRule::numeric()->nullable(), ])->only('name', 'email')->toArray(); expect($rules)->toHaveKeys(['name', 'email']) @@ -1064,7 +1073,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('only() with no arguments empties the RuleSet', function (): void { $rules = RuleSet::from([ - 'name' => FluentRule::string()->required(), + 'name' => FluentRule::string()->required(), 'email' => FluentRule::email()->required(), ])->only()->toArray(); @@ -1073,9 +1082,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('only() accepts an array argument (Collection-style)', function (): void { $rules = RuleSet::from([ - 'name' => FluentRule::string()->required(), + 'name' => FluentRule::string()->required(), 'email' => FluentRule::email()->required(), - 'age' => FluentRule::numeric()->nullable(), + 'age' => FluentRule::numeric()->nullable(), ])->only(['name', 'email'])->toArray(); expect($rules)->toHaveKeys(['name', 'email']) @@ -1084,9 +1093,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('except() accepts an array argument (Collection-style)', function (): void { $rules = RuleSet::from([ - 'name' => FluentRule::string()->required(), + 'name' => FluentRule::string()->required(), 'email' => FluentRule::email()->required(), - 'age' => FluentRule::numeric()->nullable(), + 'age' => FluentRule::numeric()->nullable(), ])->except(['email', 'age'])->toArray(); expect($rules)->toHaveKey('name') @@ -1108,9 +1117,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('drops the named fields via except()', function (): void { $rules = RuleSet::from([ - 'name' => FluentRule::string()->required(), + 'name' => FluentRule::string()->required(), 'email' => FluentRule::email()->required(), - 'age' => FluentRule::numeric()->nullable(), + 'age' => FluentRule::numeric()->nullable(), ])->except('age')->toArray(); expect($rules)->toHaveKeys(['name', 'email']) @@ -1264,7 +1273,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('creates an untyped field rule', function (): void { $validator = makeValidator( ['answer' => 'yes'], - ['answer' => FluentRule::field()->present()] + ['answer' => FluentRule::field()->present()], ); expect($validator->passes())->toBeTrue(); @@ -1273,7 +1282,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('field rule fails when not present', function (): void { $validator = makeValidator( [], - ['answer' => FluentRule::field()->present()] + ['answer' => FluentRule::field()->present()], ); expect($validator->passes())->toBeFalse(); @@ -1282,7 +1291,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('field rule with label', function (): void { $validator = makeValidator( [], - ['answer' => FluentRule::field('Your Answer')->required()] + ['answer' => FluentRule::field('Your Answer')->required()], ); expect($validator->passes())->toBeFalse() @@ -1292,7 +1301,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('field rule with in() and no type constraint', function (): void { $validator = makeValidator( ['answer' => 'yes'], - ['answer' => FluentRule::field()->required()->in(['yes', 'no'])] + ['answer' => FluentRule::field()->required()->in(['yes', 'no'])], ); expect($validator->passes())->toBeTrue(); @@ -1341,7 +1350,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('accepts array tuple in rule()', function (): void { $validator = makeValidator( ['role' => 'admin'], - ['role' => FluentRule::string()->required()->rule(['in', 'admin', 'user'])] + ['role' => FluentRule::string()->required()->rule(['in', 'admin', 'user'])], ); expect($validator->passes())->toBeTrue(); @@ -1350,7 +1359,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('rejects invalid value with array tuple in rule()', function (): void { $validator = makeValidator( ['role' => 'hacker'], - ['role' => FluentRule::string()->required()->rule(['in', 'admin', 'user'])] + ['role' => FluentRule::string()->required()->rule(['in', 'admin', 'user'])], ); expect($validator->passes())->toBeFalse(); @@ -1359,7 +1368,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('handles parameterless array tuple in rule()', function (): void { $validator = makeValidator( [], - ['name' => FluentRule::string()->rule(['required'])] + ['name' => FluentRule::string()->rule(['required'])], ); expect($validator->passes())->toBeFalse(); @@ -1389,7 +1398,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('prepare returns compiled rules with metadata', function (): void { $preparedRules = RuleSet::from([ - 'name' => FluentRule::string('Full Name')->required()->message('Name is required.')->min(2), + 'name' => FluentRule::string('Full Name')->required()->message('Name is required.')->min(2), 'items' => FluentRule::array()->required()->each([ 'qty' => FluentRule::numeric('Quantity')->required()->integer()->min(1), ]), @@ -1467,7 +1476,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('prepare returns empty implicitAttributes for non-wildcard rules', function (): void { $prepared = RuleSet::from([ - 'name' => FluentRule::string()->required(), + 'name' => FluentRule::string()->required(), 'email' => FluentRule::email()->required(), ])->prepare([]); @@ -1488,43 +1497,27 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // ========================================================================= it('FluentValidator validates with compiled rules and labels', function (): void { - $validator = new class (['name' => 'Jo'], [ - 'name' => FluentRule::string('Full Name')->required()->min(5), - ]) extends FluentValidator {}; + $validator = new class(['name' => 'Jo'], ['name' => FluentRule::string('Full Name')->required()->min(5)]) extends FluentValidator {}; expect($validator->passes())->toBeFalse() ->and($validator->errors()->first('name'))->toContain('Full Name'); }); it('FluentValidator passes valid data', function (): void { - $validator = new class (['name' => 'John Doe', 'age' => 25], [ - 'name' => FluentRule::string()->required()->min(2), - 'age' => FluentRule::numeric()->required()->integer()->min(0), - ]) extends FluentValidator {}; + $validator = new class(['name' => 'John Doe', 'age' => 25], ['name' => FluentRule::string()->required()->min(2), 'age' => FluentRule::numeric()->required()->integer()->min(0)]) extends FluentValidator {}; expect($validator->passes())->toBeTrue(); }); it('FluentValidator expands each() rules', function (): void { - $validator = new class ( - ['items' => [['name' => 'a'], ['name' => 'b']]], - [ - 'items' => FluentRule::array()->required()->each([ - 'name' => FluentRule::string()->required()->min(2), - ]), - ] - ) extends FluentValidator {}; + $validator = new class(['items' => [['name' => 'a'], ['name' => 'b']]], ['items' => FluentRule::array()->required()->each(['name' => FluentRule::string()->required()->min(2)])]) extends FluentValidator {}; expect($validator->passes())->toBeFalse() ->and($validator->errors()->keys())->toContain('items.0.name'); }); it('FluentValidator merges custom messages with rule messages', function (): void { - $validator = new class ( - ['name' => ''], - ['name' => FluentRule::string()->required()->message('Fluent message.')], - ['name.required' => 'Custom override.'], - ) extends FluentValidator {}; + $validator = new class(['name' => ''], ['name' => FluentRule::string()->required()->message('Fluent message.')], ['name.required' => 'Custom override.']) extends FluentValidator {}; expect($validator->passes())->toBeFalse() ->and($validator->errors()->first('name'))->toBe('Custom override.'); @@ -1533,39 +1526,15 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('FluentValidator handles cross-field wildcard references', function (): void { // Simulates hihaho's JsonInteractionImportValidator pattern: // requiredUnless('*.type', ...) references a sibling field via wildcard - $validator = new class ( - ['items' => [ - ['type' => 'chapter', 'title' => 'Hello', 'end_time' => 10], - ['type' => 'menu'], // menu doesn't require end_time - ]], - [ - 'items' => 'required|array', - 'items.*.type' => ['required', 'string', Rule::in(['chapter', 'menu', 'button'])], - 'items.*.title' => ['nullable', 'string'], - 'items.*.end_time' => [ - ['required_unless', 'items.*.type', 'menu'], - 'numeric', - ], - ] - ) extends FluentValidator {}; + $validator = new class(['items' => [['type' => 'chapter', 'title' => 'Hello', 'end_time' => 10], ['type' => 'menu'], // menu doesn't require end_time + ]], ['items' => 'required|array', 'items.*.type' => ['required', 'string', Rule::in(['chapter', 'menu', 'button'])], 'items.*.title' => ['nullable', 'string'], 'items.*.end_time' => [['required_unless', 'items.*.type', 'menu'], 'numeric']]) extends FluentValidator {}; expect($validator->passes())->toBeTrue(); }); it('FluentValidator fails cross-field wildcard references correctly', function (): void { - $validator = new class ( - ['items' => [ - ['type' => 'chapter'], // chapter requires end_time but it's missing - ]], - [ - 'items' => 'required|array', - 'items.*.type' => ['required', 'string'], - 'items.*.end_time' => [ - ['required_unless', 'items.*.type', 'menu'], - 'numeric', - ], - ] - ) extends FluentValidator {}; + $validator = new class(['items' => [['type' => 'chapter'], // chapter requires end_time but it's missing + ]], ['items' => 'required|array', 'items.*.type' => ['required', 'string'], 'items.*.end_time' => [['required_unless', 'items.*.type', 'menu'], 'numeric']]) extends FluentValidator {}; expect($validator->passes())->toBeFalse() ->and($validator->errors()->keys())->toContain('items.0.end_time'); @@ -1575,7 +1544,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $formRequest = createFormRequest( rules: [ 'items' => FluentRule::array()->required()->each([ - 'type' => FluentRule::string()->required()->in(['chapter', 'menu']), + 'type' => FluentRule::string()->required()->in(['chapter', 'menu']), 'end_time' => FluentRule::numeric() ->requiredUnless('items.*.type', 'menu'), ]), @@ -1601,20 +1570,20 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // Simulates a real-world import validator with deeply nested objects // and many wildcard patterns (similar to JsonInteractionImportValidator). $items = array_map(fn (int $i): array => [ - 'type' => 'button', - 'title' => "Item {$i}", + 'type' => 'button', + 'title' => "Item {$i}", 'start_time' => $i * 10, - 'end_time' => $i * 10 + 5, - 'style' => [ - 'top' => '10%', - 'left' => '20%', - 'height' => '30%', - 'width' => '40%', + 'end_time' => $i * 10 + 5, + 'style' => [ + 'top' => '10%', + 'left' => '20%', + 'height' => '30%', + 'width' => '40%', 'background_color' => '#ff0000', - 'border_radius' => 5, - 'padding_top' => 10, - 'padding_bottom' => 10, - 'border' => ['width' => 1, 'style' => 'solid', 'color' => '#000000'], + 'border_radius' => 5, + 'padding_top' => 10, + 'padding_bottom' => 10, + 'border' => ['width' => 1, 'style' => 'solid', 'color' => '#000000'], ], 'action' => [ 'type' => 'link', @@ -1622,14 +1591,14 @@ public function validate(string $attribute, mixed $value, Closure $fail): void 'time' => 0, ], 'attributes' => [ - 'show_indicator' => true, + 'show_indicator' => true, 'indicator_color' => '#00ff00', - 'options' => ['menu_button_location' => 'top', 'menu_button_name' => 'Menu'], + 'options' => ['menu_button_location' => 'top', 'menu_button_name' => 'Menu'], ], 'chapters' => array_map(fn (int $j): array => [ - 'title' => "Chapter {$j}", + 'title' => "Chapter {$j}", 'start_time' => $j * 5, - 'end_time' => $j * 5 + 4, + 'end_time' => $j * 5 + 4, 'sort_order' => $j, ], range(1, 4)), ], range(1, 200)); @@ -1676,21 +1645,21 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('RuleSet::validate() with compiled rules matches native Laravel performance', function (): void { $items = array_map(fn (int $i): array => [ - 'name' => "Item {$i}", + 'name' => "Item {$i}", 'email' => "user{$i}@example.com", - 'age' => $i % 80 + 18, - 'role' => ['admin', 'editor', 'viewer'][$i % 3], + 'age' => $i % 80 + 18, + 'role' => ['admin', 'editor', 'viewer'][$i % 3], ], range(1, 500)); $data = ['items' => $items]; // Native Laravel with string rules $nativeStart = microtime(true); Validator::make($data, [ - 'items' => ['required', 'array'], - 'items.*.name' => ['required', 'string', 'min:2', 'max:255'], + 'items' => ['required', 'array'], + 'items.*.name' => ['required', 'string', 'min:2', 'max:255'], 'items.*.email' => ['required', 'string', 'max:255'], - 'items.*.age' => ['required', 'numeric', 'integer', 'min:0', 'max:150'], - 'items.*.role' => ['required', 'string', Rule::in(['admin', 'editor', 'viewer'])], + 'items.*.age' => ['required', 'numeric', 'integer', 'min:0', 'max:150'], + 'items.*.role' => ['required', 'string', Rule::in(['admin', 'editor', 'viewer'])], ])->validate(); $nativeElapsed = microtime(true) - $nativeStart; @@ -1698,10 +1667,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $ruleSetStart = microtime(true); RuleSet::from([ 'items' => FluentRule::array()->required()->each([ - 'name' => FluentRule::string()->required()->min(2)->max(255), + 'name' => FluentRule::string()->required()->min(2)->max(255), 'email' => FluentRule::string()->required()->max(255), - 'age' => FluentRule::numeric()->required()->integer()->min(0)->max(150), - 'role' => FluentRule::string()->required()->in(['admin', 'editor', 'viewer']), + 'age' => FluentRule::numeric()->required()->integer()->min(0)->max(150), + 'role' => FluentRule::string()->required()->in(['admin', 'editor', 'viewer']), ]), ])->validate($data); $ruleSetElapsed = microtime(true) - $ruleSetStart; @@ -1837,7 +1806,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates mixed fast+slow rules via partial fast-check', function (): void { $validated = RuleSet::from([ 'items' => FluentRule::array()->required()->each([ - 'name' => FluentRule::string()->required()->min(2)->max(255), + 'name' => FluentRule::string()->required()->min(2)->max(255), 'starts_at' => FluentRule::date()->required()->after('2025-01-01'), ]), ])->validate(['items' => [ @@ -1852,7 +1821,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void try { RuleSet::from([ 'items' => FluentRule::array()->required()->each([ - 'name' => FluentRule::string()->required()->min(2)->max(255), + 'name' => FluentRule::string()->required()->min(2)->max(255), 'starts_at' => FluentRule::date()->required()->after('2025-01-01'), ]), ])->validate(['items' => [ @@ -1868,7 +1837,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void try { RuleSet::from([ 'items' => FluentRule::array()->required()->each([ - 'name' => FluentRule::string()->required()->min(2)->max(255), + 'name' => FluentRule::string()->required()->min(2)->max(255), 'starts_at' => FluentRule::date()->required()->after('2025-01-01'), ]), ])->validate(['items' => [ @@ -1884,7 +1853,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $validated = RuleSet::from([ 'items' => FluentRule::array()->required()->each([ 'name' => FluentRule::string()->required()->min(2), - 'age' => FluentRule::numeric()->required()->integer()->min(0), + 'age' => FluentRule::numeric()->required()->integer()->min(0), ]), ])->validate(['items' => [['name' => 'Alice', 'age' => 30]]]); @@ -1893,7 +1862,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('partial fast-check catches single invalid item among many valid', function (): void { $items = array_map(fn (int $i): array => [ - 'name' => 'User ' . $i, + 'name' => 'User ' . $i, 'starts_at' => '2025-06-' . str_pad((string) ($i % 28 + 1), 2, '0', STR_PAD_LEFT), ], range(1, 50)); @@ -1902,7 +1871,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void try { RuleSet::from([ 'items' => FluentRule::array()->required()->each([ - 'name' => FluentRule::string()->required()->min(2), + 'name' => FluentRule::string()->required()->min(2), 'starts_at' => FluentRule::date()->required()->after('2025-01-01'), ]), ])->validate(['items' => $items]); @@ -1979,10 +1948,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('failOnUnknownFields passes when all input keys are known', function (): void { $validated = RuleSet::from([ - 'name' => FluentRule::string()->required(), + 'name' => FluentRule::string()->required(), 'email' => FluentRule::email()->required(), ])->failOnUnknownFields()->validate([ - 'name' => 'John', + 'name' => 'John', 'email' => 'john@example.com', ]); @@ -2007,7 +1976,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void RuleSet::from([ 'name' => FluentRule::string()->required(), ])->failOnUnknownFields()->validate([ - 'name' => 'John', + 'name' => 'John', 'unknown_field' => 'value', ]); } catch (ValidationException $validationException) { @@ -2051,7 +2020,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $validated = RuleSet::from([ 'name' => FluentRule::string()->required(), ])->validate([ - 'name' => 'John', + 'name' => 'John', 'extra' => 'ignored', ]); @@ -2067,7 +2036,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void try { RuleSet::from([ - 'name' => FluentRule::string()->required(), + 'name' => FluentRule::string()->required(), 'email' => FluentRule::email()->required(), ])->stopOnFirstFailure()->validate([]); } catch (ValidationException $validationException) { @@ -2080,10 +2049,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('stopOnFirstFailure still passes when all fields are valid', function (): void { $validated = RuleSet::from([ - 'name' => FluentRule::string()->required(), + 'name' => FluentRule::string()->required(), 'email' => FluentRule::email()->required(), ])->stopOnFirstFailure()->validate([ - 'name' => 'John', + 'name' => 'John', 'email' => 'john@example.com', ]); @@ -2205,7 +2174,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $request = Request::create('/test', 'POST', ['name' => 'John', 'email' => 'john@example.com']); $validated = RuleSet::from([ - 'name' => FluentRule::string()->required(), + 'name' => FluentRule::string()->required(), 'email' => FluentRule::email()->required(), ])->validate($request); @@ -2268,7 +2237,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('dropUnknownFields strips unknown nested keys declared via dotted rule keys', function (): void { $validated = RuleSet::from([ - 'meta' => FluentRule::array()->required(), + 'meta' => FluentRule::array()->required(), 'meta.type' => FluentRule::string()->required(), ])->dropUnknownFields()->validate([ 'meta' => ['type' => 'admin', 'secret' => 'leak'], @@ -2338,7 +2307,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $validated = RuleSet::from([ 'name' => FluentRule::string()->required(), ])->dropUnknownFields()->validate([ - 'name' => 'John', + 'name' => 'John', 'extra' => 'noise', ]); @@ -2403,7 +2372,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('compileWithMetadata returns compiled rules with extracted messages and labels', function (): void { $rules = [ - 'name' => FluentRule::string('Full Name')->required()->message('Name is required!'), + 'name' => FluentRule::string('Full Name')->required()->message('Name is required!'), 'items' => FluentRule::array()->required()->each([ 'qty' => FluentRule::numeric('Quantity')->required()->integer()->min(1), ]), @@ -2508,7 +2477,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('check() safe() returns ValidatedInput on success', function (): void { $result = RuleSet::from([ - 'name' => FluentRule::string()->required(), + 'name' => FluentRule::string()->required(), 'email' => FluentRule::email()->required(), ])->check(['name' => 'John', 'email' => 'john@example.com']); @@ -2551,7 +2520,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $rules = [ 'events' => FluentRule::array()->required()->each([ 'start_date' => FluentRule::date()->required(), - 'end_date' => FluentRule::date()->required()->after('start_date'), + 'end_date' => FluentRule::date()->required()->after('start_date'), ]), ]; @@ -2568,7 +2537,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $rules = [ 'events' => FluentRule::array()->required()->each([ 'start_date' => FluentRule::date()->required(), - 'end_date' => FluentRule::date()->required()->after('start_date'), + 'end_date' => FluentRule::date()->required()->after('start_date'), ]), ]; @@ -2583,7 +2552,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('wildcard fast path rejects field-ref date violation (before:sibling)', function (): void { $rules = [ 'events' => FluentRule::array()->required()->each([ - 'start_date' => FluentRule::date()->required(), + 'start_date' => FluentRule::date()->required(), 'registration_deadline' => FluentRule::date()->required()->before('start_date'), ]), ]; @@ -2600,7 +2569,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('wildcard fast path enforces same:FIELD equality against sibling', function (): void { $rules = [ 'users' => FluentRule::array()->required()->each([ - 'password' => FluentRule::string()->required(), + 'password' => FluentRule::string()->required(), 'password_confirmation' => FluentRule::string()->required()->rule('same:password'), ]), ]; @@ -2621,7 +2590,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $rules = [ 'slots' => FluentRule::array()->required()->each([ 'stock' => FluentRule::integer()->required(), - 'sold' => FluentRule::integer()->required()->rule('lte:stock'), + 'sold' => FluentRule::integer()->required()->rule('lte:stock'), ]), ]; @@ -2643,7 +2612,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void 'offers' => FluentRule::array()->required()->each([ 'min_price' => FluentRule::numeric()->required(), 'max_price' => FluentRule::numeric()->required(), - 'price' => FluentRule::numeric()->required()->rule('gt:min_price')->rule('lt:max_price'), + 'price' => FluentRule::numeric()->required()->rule('gt:min_price')->rule('lt:max_price'), ]), ]; @@ -2673,7 +2642,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $rules = [ 'entries' => FluentRule::array()->required()->each([ 'short' => FluentRule::string()->required(), - 'long' => FluentRule::string()->required()->rule('gt:short'), + 'long' => FluentRule::string()->required()->rule('gt:short'), ]), ]; @@ -2757,7 +2726,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $rules = [ 'events' => FluentRule::array()->required()->each([ 'start' => FluentRule::date()->required(), - 'end' => FluentRule::date()->required(), + 'end' => FluentRule::date()->required(), // checkpoint must sit strictly between start and end. 'checkpoint' => FluentRule::date()->required()->after('start')->before('end'), ]), @@ -2782,7 +2751,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('wildcard fast path enforces required_with (sibling present → required)', function (): void { $rules = [ 'contacts' => FluentRule::array()->required()->each([ - 'phone' => FluentRule::string()->nullable(), + 'phone' => FluentRule::string()->nullable(), 'phone_type' => FluentRule::string()->requiredWith('phone'), ]), ]; @@ -2806,8 +2775,8 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('wildcard fast path enforces required_with with multi-param (any sibling present)', function (): void { $rules = [ 'rows' => FluentRule::array()->required()->each([ - 'a' => FluentRule::string()->nullable(), - 'b' => FluentRule::string()->nullable(), + 'a' => FluentRule::string()->nullable(), + 'b' => FluentRule::string()->nullable(), 'label' => FluentRule::string()->rule('required_with:a,b')->max(50), ]), ]; @@ -2836,7 +2805,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // withoutRequired is the always-pass closure. $rules = [ 'items' => FluentRule::array()->required()->each([ - 'trigger' => FluentRule::string()->nullable(), + 'trigger' => FluentRule::string()->nullable(), 'conditional' => FluentRule::field()->rule('required_with:trigger'), ]), ]; @@ -2878,7 +2847,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void 'cards' => FluentRule::array()->required()->each([ 'number' => FluentRule::string()->nullable(), 'expiry' => FluentRule::string()->nullable(), - 'cvc' => FluentRule::string()->rule('required_with_all:number,expiry'), + 'cvc' => FluentRule::string()->rule('required_with_all:number,expiry'), ]), ]; @@ -2899,8 +2868,8 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('wildcard fast path enforces required_without_all (all siblings absent → required)', function (): void { $rules = [ 'contacts' => FluentRule::array()->required()->each([ - 'email' => FluentRule::string()->nullable(), - 'phone' => FluentRule::string()->nullable(), + 'email' => FluentRule::string()->nullable(), + 'phone' => FluentRule::string()->nullable(), 'fallback' => FluentRule::string()->rule('required_without_all:email,phone'), ]), ]; diff --git a/tests/RuleSetWithBagTest.php b/tests/RuleSetWithBagTest.php index 2cbb99a..2311df2 100644 --- a/tests/RuleSetWithBagTest.php +++ b/tests/RuleSetWithBagTest.php @@ -1,8 +1,10 @@ - FluentRule::string()->required()->min(8), - 'new_password' => FluentRule::string()->required()->min(12), + 'new_password' => FluentRule::string()->required()->min(12), ])->withBag('updatePassword'); try { diff --git a/tests/Rules/AntiSpam/AntiSpamRulesTest.php b/tests/Rules/AntiSpam/AntiSpamRulesTest.php index 013e81d..f928de6 100644 --- a/tests/Rules/AntiSpam/AntiSpamRulesTest.php +++ b/tests/Rules/AntiSpam/AntiSpamRulesTest.php @@ -1,7 +1,9 @@ -toBeFalse(); + expect(ruleAccepts(new SubmissionTiming, $value))->toBeFalse(); })->with([ 'plain timestamp' => '1700000000', - 'garbage' => 'not-a-token', - 'non-string' => 12345, + 'garbage' => 'not-a-token', + 'non-string' => 12345, ]); it('rejects a token from the future rather than treating it as instant', function (): void { diff --git a/tests/Rules/Banking/BankingRulesTest.php b/tests/Rules/Banking/BankingRulesTest.php index ad38432..83da547 100644 --- a/tests/Rules/Banking/BankingRulesTest.php +++ b/tests/Rules/Banking/BankingRulesTest.php @@ -1,4 +1,6 @@ -toBeTrue(); + expect(ruleAccepts(new Luhn, $value))->toBeTrue(); })->with([ '4111111111111111', // Visa test number '5500005555555559', // Mastercard test number @@ -21,7 +23,7 @@ ]); it('rejects Luhn-invalid numbers', function (mixed $value): void { - expect(ruleAccepts(new Luhn(), $value))->toBeFalse(); + expect(ruleAccepts(new Luhn, $value))->toBeFalse(); })->with([ '4111111111111112', // last digit off by one '79927398710', @@ -34,23 +36,23 @@ ]); it('rejects non-string, non-integer values', function (string $ruleClass, mixed $value): void { - expect(ruleAccepts(new $ruleClass(), $value))->toBeFalse(); + expect(ruleAccepts(new $ruleClass, $value))->toBeFalse(); })->with([ 'luhn array' => [Luhn::class, ['x']], - 'luhn bool' => [Luhn::class, true], + 'luhn bool' => [Luhn::class, true], 'iban array' => [Iban::class, ['x']], - 'iban int' => [Iban::class, 12345], + 'iban int' => [Iban::class, 12345], 'isin array' => [Isin::class, ['x']], - 'bic array' => [Bic::class, ['x']], + 'bic array' => [Bic::class, ['x']], ]); it('leaves an empty value to required, as Laravel does', function (): void { // Format rules are not implicit: Laravel skips them for '' and null, so a // blank field is `required`'s business. Asserting otherwise here would // encode a behaviour that differs from every core rule. - expect(ruleAccepts(new Iban(), ''))->toBeTrue() - ->and(ruleAccepts(new Bic(), ''))->toBeTrue() - ->and(Validator::make(['f' => ''], ['f' => ['required', new Iban()]])->passes())->toBeFalse(); + expect(ruleAccepts(new Iban, ''))->toBeTrue() + ->and(ruleAccepts(new Bic, ''))->toBeTrue() + ->and(Validator::make(['f' => ''], ['f' => ['required', new Iban]])->passes())->toBeFalse(); }); it('exposes Luhn as a static so other rules can compose it', function (): void { @@ -63,7 +65,7 @@ // ========================================================================= it('accepts valid IBANs', function (string $value): void { - expect(ruleAccepts(new Iban(), $value))->toBeTrue(); + expect(ruleAccepts(new Iban, $value))->toBeTrue(); })->with([ 'GB82WEST12345698765432', // the ISO 13616 example 'DE89370400440532013000', @@ -74,7 +76,7 @@ ]); it('rejects invalid IBANs', function (mixed $value): void { - expect(ruleAccepts(new Iban(), $value))->toBeFalse(); + expect(ruleAccepts(new Iban, $value))->toBeFalse(); })->with([ 'GB82WEST12345698765431', // checksum broken 'GB81WEST12345698765432', // check digits altered @@ -89,7 +91,7 @@ it('rejects an unknown country even when the checksum would pass', function (): void { // The mod-97 step alone cannot catch a wrong-length or bogus country, so // the length table has to be authoritative rather than advisory. - expect(ruleAccepts(new Iban(), 'ZZ82WEST12345698765432'))->toBeFalse(); + expect(ruleAccepts(new Iban, 'ZZ82WEST12345698765432'))->toBeFalse(); }); // ========================================================================= @@ -97,7 +99,7 @@ // ========================================================================= it('accepts valid ISINs', function (string $value): void { - expect(ruleAccepts(new Isin(), $value))->toBeTrue(); + expect(ruleAccepts(new Isin, $value))->toBeTrue(); })->with([ 'US0378331005', // Apple 'AU0000XVGZA3', @@ -107,7 +109,7 @@ ]); it('rejects invalid ISINs', function (mixed $value): void { - expect(ruleAccepts(new Isin(), $value))->toBeFalse(); + expect(ruleAccepts(new Isin, $value))->toBeFalse(); })->with([ 'US0378331006', // check digit off by one 'US037833100', // too short @@ -121,7 +123,7 @@ // verdict for every input and wrongly accepts about one in ten. Pin the // difference so a "simplification" cannot reintroduce it. expect(Luhn::passes('US0378331005'))->toBeFalse() - ->and(ruleAccepts(new Isin(), 'US0378331005'))->toBeTrue(); + ->and(ruleAccepts(new Isin, 'US0378331005'))->toBeTrue(); }); // ========================================================================= @@ -129,7 +131,7 @@ // ========================================================================= it('accepts valid BICs', function (string $value): void { - expect(ruleAccepts(new Bic(), $value))->toBeTrue(); + expect(ruleAccepts(new Bic, $value))->toBeTrue(); })->with([ 'DEUTDEFF', // 8-character head-office form 'DEUTDEFF500', // with branch @@ -140,7 +142,7 @@ ]); it('rejects invalid BICs', function (mixed $value): void { - expect(ruleAccepts(new Bic(), $value))->toBeFalse(); + expect(ruleAccepts(new Bic, $value))->toBeFalse(); })->with([ 'DEUTDEFF5', // 9 chars: neither 8 nor 11 'DEUTDEFF50', // 10 chars diff --git a/tests/Rules/Chrono/ChronoRulesTest.php b/tests/Rules/Chrono/ChronoRulesTest.php index a929295..68ad49d 100644 --- a/tests/Rules/Chrono/ChronoRulesTest.php +++ b/tests/Rules/Chrono/ChronoRulesTest.php @@ -1,15 +1,17 @@ -toBeTrue(); + expect(ruleAccepts(new Rfc3339, $value))->toBeTrue(); })->with([ '2020-12-21T23:59:59+00:00', '2020-12-21T23:59:59Z', @@ -34,7 +36,7 @@ ]); it('rejects timestamps that only look like RFC 3339', function (mixed $value): void { - expect(ruleAccepts(new Rfc3339(), $value))->toBeFalse(); + expect(ruleAccepts(new Rfc3339, $value))->toBeFalse(); })->with([ '2023-02-29T00:00:00Z', // not a leap year '2020-02-30T00:00:00Z', // no such date @@ -53,11 +55,11 @@ // ========================================================================= it('accepts 24-hour times, with and without seconds', function (string $value): void { - expect(ruleAccepts(new TimeOfDay(), $value))->toBeTrue(); + expect(ruleAccepts(new TimeOfDay, $value))->toBeTrue(); })->with(['00:00', '9:30', '23:59', '23:59:59']); it('rejects out-of-range 24-hour times and meridiems', function (mixed $value): void { - expect(ruleAccepts(new TimeOfDay(), $value))->toBeFalse(); + expect(ruleAccepts(new TimeOfDay, $value))->toBeFalse(); })->with(['24:00', '23:60', '23:59:60', '9:05 PM', '930', 930, null]); it('accepts 12-hour times only with a meridiem', function (): void { @@ -82,11 +84,11 @@ // ========================================================================= it('accepts canonical unix timestamps', function (mixed $value): void { - expect(ruleAccepts(new UnixTimestamp(), $value))->toBeTrue(); + expect(ruleAccepts(new UnixTimestamp, $value))->toBeTrue(); })->with(['1724457600', [1724457600], '0', [0]]); it('rejects floats, leading zeros and pre-epoch values by default', function (mixed $value): void { - expect(ruleAccepts(new UnixTimestamp(), $value))->toBeFalse(); + expect(ruleAccepts(new UnixTimestamp, $value))->toBeFalse(); })->with(['1.5', [1.5], '01', '-1', [-1], '12a', '+5', null]); it('accepts pre-epoch timestamps only when asked to', function (): void { @@ -100,11 +102,11 @@ // ========================================================================= it('accepts ISO 8601 durations', function (string $value): void { - expect(ruleAccepts(new DateInterval(), $value))->toBeTrue(); + expect(ruleAccepts(new DateInterval, $value))->toBeTrue(); })->with(['P1Y', 'PT30M', 'P1DT12H', 'P0Y']); it('rejects strings DateInterval cannot parse', function (mixed $value): void { - expect(ruleAccepts(new DateInterval(), $value))->toBeFalse(); + expect(ruleAccepts(new DateInterval, $value))->toBeFalse(); })->with(['1 day', 'p1y', 'P', 'PT', 'P1H', 12, null]); it('treats zero as non-positive when positivity is required', function (): void { @@ -152,7 +154,7 @@ it('reads an @-prefixed reference from a sibling field', function (): void { $rules = [ 'start_at' => 'required', - 'end_at' => [new MaxDateDifference(48, '@start_at')], + 'end_at' => [new MaxDateDifference(48, '@start_at')], ]; expect(Validator::make( @@ -171,13 +173,13 @@ // ========================================================================= it('accepts timezone abbreviations in any case', function (string $value): void { - expect(ruleAccepts(new TimezoneAbbreviation(), $value))->toBeTrue(); + expect(ruleAccepts(new TimezoneAbbreviation, $value))->toBeTrue(); })->with(['EST', 'cet', 'EAT', 'utc']); it('rejects identifiers and unknown abbreviations', function (mixed $value): void { // 'Europe/Berlin' is a timezone IDENTIFIER — Laravel's own `timezone` // rule covers those; this rule is the abbreviation set that rule rejects. - expect(ruleAccepts(new TimezoneAbbreviation(), $value))->toBeFalse(); + expect(ruleAccepts(new TimezoneAbbreviation, $value))->toBeFalse(); })->with(['XYZ', 'Europe/Berlin', 'GMT+2', 123, null]); // ========================================================================= diff --git a/tests/Rules/ClientCheckableTest.php b/tests/Rules/ClientCheckableTest.php index 48465e0..17cb02c 100644 --- a/tests/Rules/ClientCheckableTest.php +++ b/tests/Rules/ClientCheckableTest.php @@ -1,13 +1,15 @@ - [CaseStyle::KEBAB], + CaseStyle::class => [CaseStyle::KEBAB], VendorIdentifier::class => [VendorIdentifier::AWS_REGION], - HashDigest::class => ['sha256'], + HashDigest::class => ['sha256'], ]; } @@ -72,7 +74,8 @@ function clientCheckableRules(): array /** * The advertised rules as a native Laravel rule string. * - * @param list}> $advertised + * @param list}> $advertised + * * @return list */ function asLaravelRules(array $advertised): array @@ -136,10 +139,14 @@ function asLaravelRules(array $advertised): array $advertisedSaysPass = Validator::make(['v' => $value], ['v' => $asRules])->passes(); if ($ruleSaysPass !== $advertisedSaysPass) { - $mismatches[] = sprintf('%s on %s: rule=%s advertised(%s)=%s', - class_basename($class), var_export($value, true), - var_export($ruleSaysPass, true), implode('|', $asRules), - var_export($advertisedSaysPass, true)); + $mismatches[] = sprintf( + '%s on %s: rule=%s advertised(%s)=%s', + class_basename($class), + var_export($value, true), + var_export($ruleSaysPass, true), + implode('|', $asRules), + var_export($advertisedSaysPass, true), + ); } } } diff --git a/tests/Rules/Codes/CodesRulesTest.php b/tests/Rules/Codes/CodesRulesTest.php index 31159eb..a4e73bd 100644 --- a/tests/Rules/Codes/CodesRulesTest.php +++ b/tests/Rules/Codes/CodesRulesTest.php @@ -1,4 +1,6 @@ -toBeTrue(); + expect(ruleAccepts(new Gtin, $value))->toBeTrue(); })->with([ '96385074', // GTIN-8 '036000291452', // GTIN-12 / UPC-A @@ -21,7 +23,7 @@ ]); it('rejects GTINs with a bad check digit', function (string $value): void { - expect(ruleAccepts(new Gtin(), $value))->toBeFalse(); + expect(ruleAccepts(new Gtin, $value))->toBeFalse(); })->with([ '96385075', '036000291453', @@ -31,7 +33,7 @@ it('rejects GTINs of an impossible width', function (string $value): void { // 9, 10 and 11 digits are not GTIN widths at all, however the digits add up. - expect(ruleAccepts(new Gtin(), $value))->toBeFalse(); + expect(ruleAccepts(new Gtin, $value))->toBeFalse(); })->with(['9638507', '963850749', '0360002914', '03600029145']); it('can be narrowed to specific widths', function (): void { @@ -39,7 +41,7 @@ expect(ruleAccepts($thirteenOnly, '4006381333931'))->toBeTrue() ->and(ruleAccepts($thirteenOnly, '96385074'))->toBeFalse() - ->and(ruleAccepts(new Gtin(), '96385074'))->toBeTrue(); + ->and(ruleAccepts(new Gtin, '96385074'))->toBeTrue(); }); it('weights the checksum from the right, not the left', function (): void { @@ -57,14 +59,14 @@ // ========================================================================= it('accepts EAN-8 and EAN-13', function (string $value): void { - expect(ruleAccepts(new Ean(), $value))->toBeTrue(); + expect(ruleAccepts(new Ean, $value))->toBeTrue(); })->with(['96385074', '4006381333931']); it('rejects GTIN widths that are not retail EANs', function (string $value): void { // Valid GTINs, but a UPC-A and a shipping carton are not EANs. Accepting // them would let a carton code through where a retail code is meant. - expect(ruleAccepts(new Gtin(), $value))->toBeTrue() - ->and(ruleAccepts(new Ean(), $value))->toBeFalse(); + expect(ruleAccepts(new Gtin, $value))->toBeTrue() + ->and(ruleAccepts(new Ean, $value))->toBeFalse(); })->with(['036000291452', '00012345600012']); // ========================================================================= @@ -72,7 +74,7 @@ // ========================================================================= it('accepts valid ISBN-10s', function (string $value): void { - expect(ruleAccepts(new Isbn(), $value))->toBeTrue(); + expect(ruleAccepts(new Isbn, $value))->toBeTrue(); })->with([ '0306406152', '0-306-40615-2', // hyphenated @@ -82,7 +84,7 @@ ]); it('accepts valid ISBN-13s', function (string $value): void { - expect(ruleAccepts(new Isbn(), $value))->toBeTrue(); + expect(ruleAccepts(new Isbn, $value))->toBeTrue(); })->with([ '9780306406157', '978-0-306-40615-7', @@ -90,7 +92,7 @@ ]); it('rejects invalid ISBNs', function (string $value): void { - expect(ruleAccepts(new Isbn(), $value))->toBeFalse(); + expect(ruleAccepts(new Isbn, $value))->toBeFalse(); })->with([ '0306406153', // ISBN-10 check off by one '9780306406158', // ISBN-13 check off by one @@ -111,7 +113,7 @@ // 977 is the serials prefix. The GS1 checksum passes, so only the prefix // check distinguishes an ISSN-derived barcode from an ISBN. expect(Gtin::passes('9770306406158', [13]))->toBeTrue() - ->and(ruleAccepts(new Isbn(), '9770306406158'))->toBeFalse(); + ->and(ruleAccepts(new Isbn, '9770306406158'))->toBeFalse(); }); // ========================================================================= @@ -119,7 +121,7 @@ // ========================================================================= it('accepts valid ISSNs', function (string $value): void { - expect(ruleAccepts(new Issn(), $value))->toBeTrue(); + expect(ruleAccepts(new Issn, $value))->toBeTrue(); })->with([ '0378-5955', '03785955', // hyphen is optional @@ -130,7 +132,7 @@ ]); it('rejects invalid ISSNs', function (string $value): void { - expect(ruleAccepts(new Issn(), $value))->toBeFalse(); + expect(ruleAccepts(new Issn, $value))->toBeFalse(); })->with([ '0378-5954', // check off by one '0378595', // too short diff --git a/tests/Rules/Codes/NewCodesRulesTest.php b/tests/Rules/Codes/NewCodesRulesTest.php index 21d01cd..cdc15a4 100644 --- a/tests/Rules/Codes/NewCodesRulesTest.php +++ b/tests/Rules/Codes/NewCodesRulesTest.php @@ -1,4 +1,6 @@ -toBeTrue(); + expect(ruleAccepts(new Ismn, $value))->toBeTrue(); })->with([ '9790060115615', // a real published ISMN '979-0-060-11561-5', @@ -18,7 +20,7 @@ ]); it('rejects ISMNs with the wrong prefix, length or check digit', function (mixed $value): void { - expect(ruleAccepts(new Ismn(), $value))->toBeFalse(); + expect(ruleAccepts(new Ismn, $value))->toBeFalse(); })->with([ '9790060115614', // bad check digit '9780060115616', // 978 is ISBN territory, not ISMN @@ -34,7 +36,7 @@ // ========================================================================= it('accepts modern B-prefixed ASINs and ISBN-10 ASINs', function (string $value): void { - expect(ruleAccepts(new Asin(), $value))->toBeTrue(); + expect(ruleAccepts(new Asin, $value))->toBeTrue(); })->with([ 'B01LYCLS24', 'B000002L5R', @@ -45,7 +47,7 @@ it('rejects lowercase, wrong-length and checksum-failing ASINs', function (mixed $value): void { // The legacy rule accepted any 10 alphanumerics; the ISBN-10 branch has a // checksum and the B branch is uppercase — both are enforced now. - expect(ruleAccepts(new Asin(), $value))->toBeFalse(); + expect(ruleAccepts(new Asin, $value))->toBeFalse(); })->with([ 'b01lycls24', // lowercase 'B01LYCLS2', // 9 chars @@ -62,7 +64,7 @@ // ========================================================================= it('accepts valid UPC-E codes across the expansion patterns', function (string $value): void { - expect(ruleAccepts(new UpcE(), $value))->toBeTrue(); + expect(ruleAccepts(new UpcE, $value))->toBeTrue(); })->with([ '04252614', // the textbook pair: expands to UPC-A 042100005264 '01234531', // last-digit-3 expansion pattern @@ -74,7 +76,7 @@ // The check digit belongs to the EXPANDED UPC-A, not to the 8 compressed // digits — checksumming the compressed form (as the legacy rule did) both // accepts invalid codes and rejects valid ones. - expect(ruleAccepts(new UpcE(), $value))->toBeFalse(); + expect(ruleAccepts(new UpcE, $value))->toBeFalse(); })->with([ '04252615', // bad check digit '24252614', // number system 2 does not exist in UPC-E diff --git a/tests/Rules/Colour/CssColorTest.php b/tests/Rules/Colour/CssColorTest.php index 19310b6..f587d65 100644 --- a/tests/Rules/Colour/CssColorTest.php +++ b/tests/Rules/Colour/CssColorTest.php @@ -1,4 +1,6 @@ -toBeTrue(); + expect(ruleAccepts(new BitcoinAddress, $address))->toBeTrue(); })->with([ - 'P2PKH (genesis)' => '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', - 'P2PKH' => '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', - 'P2SH' => '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy', + 'P2PKH (genesis)' => '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + 'P2PKH' => '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', + 'P2SH' => '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy', 'P2WPKH (bech32 v0)' => 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', - 'P2WSH (bech32 v0)' => 'bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3', - 'P2TR (bech32m v1)' => 'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr', - 'uppercase bech32' => 'BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4', + 'P2WSH (bech32 v0)' => 'bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3', + 'P2TR (bech32m v1)' => 'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr', + 'uppercase bech32' => 'BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4', ]); it('rejects a single mistyped character, which a regex would accept', function (string $address): void { // The whole reason for verifying checksums rather than matching a shape. // Each of these differs from a real address by one character and is the // right length and alphabet throughout. - expect(ruleAccepts(new BitcoinAddress(), $address))->toBeFalse(); + expect(ruleAccepts(new BitcoinAddress, $address))->toBeFalse(); })->with([ - 'P2PKH last char' => '1A1zP1eP5QGefi2DMPTfTL5SLmv7Divfna', - 'P2SH last char' => '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLx', - 'bech32 last char' => 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5', + 'P2PKH last char' => '1A1zP1eP5QGefi2DMPTfTL5SLmv7Divfna', + 'P2SH last char' => '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLx', + 'bech32 last char' => 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5', 'bech32m last char' => 'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcq', ]); it('rejects malformed Bitcoin addresses', function (string $address): void { - expect(ruleAccepts(new BitcoinAddress(), $address))->toBeFalse(); + expect(ruleAccepts(new BitcoinAddress, $address))->toBeFalse(); })->with([ - 'truncated' => '1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf', + 'truncated' => '1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf', 'chars outside base58' => '0OIl1A1zP1eP5QGefi2DMPTfTL5SLmv7', - 'mixed-case bech32' => 'bc1Qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', - 'wrong hrp' => 'ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', - 'not an address' => 'not-an-address', - 'ethereum address' => '0x52908400098527886E0F7030069857D2E4169EE7', + 'mixed-case bech32' => 'bc1Qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + 'wrong hrp' => 'ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + 'not an address' => 'not-an-address', + 'ethereum address' => '0x52908400098527886E0F7030069857D2E4169EE7', ]); it('rejects testnet addresses on mainnet and the reverse', function (): void { $testnetBech32 = 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx'; $mainnetBech32 = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; - expect(ruleAccepts(new BitcoinAddress(), $testnetBech32))->toBeFalse() + expect(ruleAccepts(new BitcoinAddress, $testnetBech32))->toBeFalse() ->and(ruleAccepts(new BitcoinAddress(testnet: true), $testnetBech32))->toBeTrue() ->and(ruleAccepts(new BitcoinAddress(testnet: true), $mainnetBech32))->toBeFalse(); }); @@ -58,8 +60,8 @@ $v0 = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; $v1 = 'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr'; - expect(ruleAccepts(new BitcoinAddress(), $v0))->toBeTrue() - ->and(ruleAccepts(new BitcoinAddress(), $v1))->toBeTrue(); + expect(ruleAccepts(new BitcoinAddress, $v0))->toBeTrue() + ->and(ruleAccepts(new BitcoinAddress, $v1))->toBeTrue(); }); // ========================================================================= @@ -67,23 +69,23 @@ // ========================================================================= it('accepts well-formed Ethereum addresses', function (string $address): void { - expect(ruleAccepts(new EthereumAddress(), $address))->toBeTrue(); + expect(ruleAccepts(new EthereumAddress, $address))->toBeTrue(); })->with([ - 'all lowercase' => '0x52908400098527886e0f7030069857d2e4169ee7', - 'all uppercase' => '0x52908400098527886E0F7030069857D2E4169EE7', + 'all lowercase' => '0x52908400098527886e0f7030069857d2e4169ee7', + 'all uppercase' => '0x52908400098527886E0F7030069857D2E4169EE7', 'EIP-55 mixed case' => '0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed', - 'zero address' => '0x0000000000000000000000000000000000000000', + 'zero address' => '0x0000000000000000000000000000000000000000', ]); it('rejects malformed Ethereum addresses', function (string $address): void { - expect(ruleAccepts(new EthereumAddress(), $address))->toBeFalse(); + expect(ruleAccepts(new EthereumAddress, $address))->toBeFalse(); })->with([ - 'no prefix' => '52908400098527886E0F7030069857D2E4169EE7', - 'too short' => '0x52908400098527886E0F7030069857D2E4169EE', - 'too long' => '0x52908400098527886E0F7030069857D2E4169EE77', - 'non-hex' => '0x52908400098527886E0F7030069857D2E4169EEZ', + 'no prefix' => '52908400098527886E0F7030069857D2E4169EE7', + 'too short' => '0x52908400098527886E0F7030069857D2E4169EE', + 'too long' => '0x52908400098527886E0F7030069857D2E4169EE77', + 'non-hex' => '0x52908400098527886E0F7030069857D2E4169EEZ', 'uppercase prefix' => '0X52908400098527886E0F7030069857D2E4169EE7', - 'bitcoin address' => '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + 'bitcoin address' => '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', ]); it('does not verify the EIP-55 checksum, and a typo therefore passes', function (): void { @@ -94,7 +96,7 @@ $valid = '0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed'; $checksumBroken = '0x5AAeb6053F3E94C9b9A09f33669435E7Ef1BeAed'; // one case flipped - expect(ruleAccepts(new EthereumAddress(), $valid))->toBeTrue() - ->and(ruleAccepts(new EthereumAddress(), $checksumBroken))->toBeTrue() + expect(ruleAccepts(new EthereumAddress, $valid))->toBeTrue() + ->and(ruleAccepts(new EthereumAddress, $checksumBroken))->toBeTrue() ->and(in_array('keccak256', hash_algos(), true))->toBeFalse(); }); diff --git a/tests/Rules/Database/CompareToColumnTest.php b/tests/Rules/Database/CompareToColumnTest.php index ad21988..01ee9ac 100644 --- a/tests/Rules/Database/CompareToColumnTest.php +++ b/tests/Rules/Database/CompareToColumnTest.php @@ -1,11 +1,13 @@ - 'testing']); @@ -25,7 +27,11 @@ it('compares the value against the looked-up column across the operators', function (): void { $rule = static fn (Comparison $op): CompareToColumn => new CompareToColumn( - 'products', 'max_quantity', $op, 'id', 1, + 'products', + 'max_quantity', + $op, + 'id', + 1, ); expect(ruleAccepts($rule(Comparison::LessThanOrEqual), '10'))->toBeTrue() @@ -50,7 +56,7 @@ it('reads an @-prefixed key from a sibling field', function (): void { $rules = [ 'product_id' => 'required', - 'quantity' => [new CompareToColumn('products', 'max_quantity', Comparison::LessThanOrEqual, 'id', '@product_id')], + 'quantity' => [new CompareToColumn('products', 'max_quantity', Comparison::LessThanOrEqual, 'id', '@product_id')], ]; expect(Validator::make(['product_id' => 1, 'quantity' => '10'], $rules)->passes())->toBeTrue() diff --git a/tests/Rules/Database/DatabaseRulesTest.php b/tests/Rules/Database/DatabaseRulesTest.php index 7abc688..2732d48 100644 --- a/tests/Rules/Database/DatabaseRulesTest.php +++ b/tests/Rules/Database/DatabaseRulesTest.php @@ -1,29 +1,31 @@ - $entries + * @param list $entries */ function fakeList(array $entries): DisposableDomainList&RoleAccountList { - return new readonly class ($entries) implements DisposableDomainList, RoleAccountList { + return new readonly class($entries) implements DisposableDomainList, RoleAccountList + { /** @param list $entries */ public function __construct(private array $entries) {} @@ -139,12 +142,12 @@ public function contains(string $domain): bool }); it('recognises real disposable domains through the bundled snapshot', function (): void { - $rule = new NotDisposableEmail(new BundledDisposableDomainList()); + $rule = new NotDisposableEmail(new BundledDisposableDomainList); expect(ruleAccepts($rule, 'alice@mailinator.com'))->toBeFalse() ->and(ruleAccepts($rule, 'alice@gmail.com'))->toBeTrue(); }); it('recognises RFC 2142 role mailboxes through the bundled snapshot', function (string $local): void { - expect(ruleAccepts(new NotRoleEmail(new BundledRoleAccountList()), "{$local}@example.com"))->toBeFalse(); + expect(ruleAccepts(new NotRoleEmail(new BundledRoleAccountList), "{$local}@example.com"))->toBeFalse(); })->with(['postmaster', 'abuse', 'hostmaster', 'webmaster', 'info', 'sales', 'support']); diff --git a/tests/Rules/Encoding/EncodingRulesTest.php b/tests/Rules/Encoding/EncodingRulesTest.php index 8ed1b38..0abf330 100644 --- a/tests/Rules/Encoding/EncodingRulesTest.php +++ b/tests/Rules/Encoding/EncodingRulesTest.php @@ -1,9 +1,11 @@ -toBeTrue(); + expect(ruleAccepts(new Base64, $value))->toBeTrue(); })->with(['aGVsbG8=', 'aGVsbG8gd29ybGQ=', PNG_B64]); it('rejects non-canonical and malformed base64', function (mixed $value): void { // Round-tripping catches what a charset regex cannot: missing padding, // embedded whitespace, and non-zero discarded bits ('aGVsbG9=' decodes, // but nothing ever encodes TO it). - expect(ruleAccepts(new Base64(), $value))->toBeFalse(); + expect(ruleAccepts(new Base64, $value))->toBeFalse(); })->with(['aGVsbG9', "aGVsbG8=\n", 'aGVsbG 8=', '!!!!', 'aGVsbG9=', 12345, null]); // ========================================================================= @@ -29,16 +31,16 @@ // ========================================================================= it('accepts a real image, bare and as a data URI', function (): void { - expect(ruleAccepts(new Base64Image(), PNG_B64))->toBeTrue() - ->and(ruleAccepts(new Base64Image(), 'data:image/png;base64,' . PNG_B64))->toBeTrue(); + expect(ruleAccepts(new Base64Image, PNG_B64))->toBeTrue() + ->and(ruleAccepts(new Base64Image, 'data:image/png;base64,' . PNG_B64))->toBeTrue(); }); it('rejects non-images and images outside the allowed types', function (): void { // 'aGVsbG8=' is valid base64 of text — the MIME sniff is what rejects it. - expect(ruleAccepts(new Base64Image(), 'aGVsbG8='))->toBeFalse() + expect(ruleAccepts(new Base64Image, 'aGVsbG8='))->toBeFalse() ->and(ruleAccepts(new Base64Image(mimes: ['jpeg']), PNG_B64))->toBeFalse() - ->and(ruleAccepts(new Base64Image(), 'not base64 at all'))->toBeFalse() - ->and(ruleAccepts(new Base64Image(), 12345))->toBeFalse(); + ->and(ruleAccepts(new Base64Image, 'not base64 at all'))->toBeFalse() + ->and(ruleAccepts(new Base64Image, 12345))->toBeFalse(); }); it('enforces the decoded size cap with a human-readable message', function (): void { @@ -72,7 +74,7 @@ // ========================================================================= it('accepts well-formed data URIs', function (string $value): void { - expect(ruleAccepts(new DataUri(), $value))->toBeTrue(); + expect(ruleAccepts(new DataUri, $value))->toBeTrue(); })->with([ 'data:image/png;base64,' . PNG_B64, 'data:text/plain,hello%20world', @@ -81,7 +83,7 @@ ]); it('rejects malformed data URIs', function (mixed $value): void { - expect(ruleAccepts(new DataUri(), $value))->toBeFalse(); + expect(ruleAccepts(new DataUri, $value))->toBeFalse(); })->with([ 'data:image/png;base64,!!!!', // base64 flag with non-base64 payload 'data:image/png;base64', // no comma, no data diff --git a/tests/Rules/Fiscal/NationalIdentifierTest.php b/tests/Rules/Fiscal/NationalIdentifierTest.php index b2de205..3bad328 100644 --- a/tests/Rules/Fiscal/NationalIdentifierTest.php +++ b/tests/Rules/Fiscal/NationalIdentifierTest.php @@ -1,4 +1,6 @@ -toBeFalse(); })->with([ 'wrong check' => '111222334', - 'all zeroes' => '000000000', - 'too short' => '1234567', - 'too long' => '1112223334', - 'not digits' => 'abcdefghi', + 'all zeroes' => '000000000', + 'too short' => '1234567', + 'too long' => '1112223334', + 'not digits' => 'abcdefghi', ]); it('weights the final BSN digit negatively', function (): void { @@ -53,10 +55,10 @@ expect(Id::passes($value, Id::BR))->toBeFalse(); })->with([ 'bad second digit' => '11144477736', - 'bad first digit' => '11144477745', - 'all ones' => '11111111111', - 'all zeroes' => '00000000000', - 'too short' => '1114447773', + 'bad first digit' => '11144477745', + 'all ones' => '11111111111', + 'all zeroes' => '00000000000', + 'too short' => '1114447773', ]); // ========================================================================= @@ -70,13 +72,13 @@ it('rejects the ranges the SSA has never issued', function (string $value): void { expect(Id::passes($value, Id::US))->toBeFalse(); })->with([ - 'area 000' => '000-12-3456', - 'area 666' => '666-12-3456', - 'area 900+' => '900-12-3456', - 'area 999' => '999-12-3456', - 'group 00' => '078-00-1120', + 'area 000' => '000-12-3456', + 'area 666' => '666-12-3456', + 'area 900+' => '900-12-3456', + 'area 999' => '999-12-3456', + 'group 00' => '078-00-1120', 'serial 0000' => '078-05-0000', - 'too short' => '078-05-112', + 'too short' => '078-05-112', ]); // ========================================================================= @@ -90,18 +92,18 @@ it('rejects reserved prefixes and letters the scheme never uses', function (string $value): void { expect(Id::passes($value, Id::GB))->toBeFalse(); })->with([ - 'D first' => 'DA123456C', - 'F first' => 'FA123456C', - 'Q first' => 'QQ123456C', - 'U first' => 'UA123456C', - 'V first' => 'VA123456C', - 'O second' => 'AO123456C', + 'D first' => 'DA123456C', + 'F first' => 'FA123456C', + 'Q first' => 'QQ123456C', + 'U first' => 'UA123456C', + 'V first' => 'VA123456C', + 'O second' => 'AO123456C', 'reserved BG' => 'BG123456C', 'reserved GB' => 'GB123456C', 'reserved NK' => 'NK123456C', 'reserved TN' => 'TN123456C', 'reserved ZZ' => 'ZZ123456C', - 'suffix E' => 'AB123456E', + 'suffix E' => 'AB123456E', ]); // ========================================================================= @@ -113,9 +115,9 @@ // substitutes 19 and 18 before the modulo. expect(Id::passes($value, Id::FR))->toBeTrue(); })->with([ - 'mainland' => '269054958815780', - 'key of 97' => '180126745108997', - 'another' => '184017512345658', + 'mainland' => '269054958815780', + 'key of 97' => '180126745108997', + 'another' => '184017512345658', 'Corsica 2A' => '199122A12345641', 'Corsica 2B' => '199122B12345668', ]); @@ -123,11 +125,11 @@ it('rejects a NIR whose key does not match', function (string $value): void { expect(Id::passes($value, Id::FR))->toBeFalse(); })->with([ - 'wrong key' => '269054958815781', + 'wrong key' => '269054958815781', 'Corsica wrong key' => '199122A12345642', - 'bad sex digit' => '369054958815780', - 'bad month' => '269134958815780', - 'too short' => '26905495881578', + 'bad sex digit' => '369054958815780', + 'bad month' => '269134958815780', + 'too short' => '26905495881578', ]); // ========================================================================= diff --git a/tests/Rules/Fiscal/VatNumberTest.php b/tests/Rules/Fiscal/VatNumberTest.php index d4772d5..ace93fb 100644 --- a/tests/Rules/Fiscal/VatNumberTest.php +++ b/tests/Rules/Fiscal/VatNumberTest.php @@ -1,7 +1,9 @@ -toBeTrue(); + expect(ruleAccepts(new VatNumber, $value))->toBeTrue(); })->with([ 'NL123456782B01', 'BE0423456765', @@ -25,7 +27,7 @@ it('rejects the same numbers with one digit off', function (string $value): void { // A pattern-only port would pass every one of these. - expect(ruleAccepts(new VatNumber(), $value))->toBeFalse(); + expect(ruleAccepts(new VatNumber, $value))->toBeFalse(); })->with([ 'NL123456783B01', 'BE0423456766', @@ -38,7 +40,7 @@ ]); it('accepts pattern-only countries and punctuation people actually type', function (string $value): void { - expect(ruleAccepts(new VatNumber(), $value))->toBeTrue(); + expect(ruleAccepts(new VatNumber, $value))->toBeTrue(); })->with([ 'ATU12345678', 'PL1234567890', @@ -48,7 +50,7 @@ ]); it('rejects unknown prefixes and malformed tails', function (mixed $value): void { - expect(ruleAccepts(new VatNumber(), $value))->toBeFalse(); + expect(ruleAccepts(new VatNumber, $value))->toBeFalse(); })->with([ 'XX123456789', 'NL123456782', // missing the B-suffix diff --git a/tests/Rules/Geo/GeoRulesTest.php b/tests/Rules/Geo/GeoRulesTest.php index 3041aeb..7f1e0a8 100644 --- a/tests/Rules/Geo/GeoRulesTest.php +++ b/tests/Rules/Geo/GeoRulesTest.php @@ -1,37 +1,39 @@ -toBeTrue(); + expect(ruleAccepts(new Latitude, $value))->toBeTrue(); })->with([ '0', '45.5', '-45.5', '90', '-90', '89.999999', '1e1', '48.8584', ]); it('rejects latitudes out of range', function (mixed $value): void { // `numeric` alone accepts every one of these. The range is the rule. - expect(ruleAccepts(new Latitude(), $value))->toBeFalse(); + expect(ruleAccepts(new Latitude, $value))->toBeFalse(); })->with(['90.0001', '-90.0001', '91', '180', '1000', '-1000']); it('accepts longitudes in range', function (mixed $value): void { - expect(ruleAccepts(new Longitude(), $value))->toBeTrue(); + expect(ruleAccepts(new Longitude, $value))->toBeTrue(); })->with(['0', '180', '-180', '179.999999', '2.2945']); it('rejects longitudes out of range', function (mixed $value): void { - expect(ruleAccepts(new Longitude(), $value))->toBeFalse(); + expect(ruleAccepts(new Longitude, $value))->toBeFalse(); })->with(['180.0001', '-180.0001', '181', '360']); it('rejects values that are not real numbers', function (mixed $value): void { - expect(ruleAccepts(new Latitude(), $value))->toBeFalse() - ->and(ruleAccepts(new Longitude(), $value))->toBeFalse(); + expect(ruleAccepts(new Latitude, $value))->toBeFalse() + ->and(ruleAccepts(new Longitude, $value))->toBeFalse(); })->with([ '12.34.56', // the shape a hand-rolled regex usually lets through '1,5', @@ -43,14 +45,14 @@ it('rejects NAN and INF, which are numeric but not positions', function (): void { // NAN is especially nasty: it fails every comparison silently, so a // range check written as `$v > -90 && $v < 90` passes it straight through. - expect(ruleAccepts(new Latitude(), NAN))->toBeFalse() - ->and(ruleAccepts(new Latitude(), INF))->toBeFalse() - ->and(ruleAccepts(new Longitude(), -INF))->toBeFalse(); + expect(ruleAccepts(new Latitude, NAN))->toBeFalse() + ->and(ruleAccepts(new Latitude, INF))->toBeFalse() + ->and(ruleAccepts(new Longitude, -INF))->toBeFalse(); }); it('rejects booleans, which PHP would otherwise cast to 0 and 1', function (): void { - expect(ruleAccepts(new Latitude(), true))->toBeFalse() - ->and(ruleAccepts(new Latitude(), false))->toBeFalse(); + expect(ruleAccepts(new Latitude, true))->toBeFalse() + ->and(ruleAccepts(new Latitude, false))->toBeFalse(); }); // ========================================================================= @@ -58,7 +60,7 @@ // ========================================================================= it('accepts coordinate pairs', function (string $value): void { - expect(ruleAccepts(new LatLng(), $value))->toBeTrue(); + expect(ruleAccepts(new LatLng, $value))->toBeTrue(); })->with([ '48.8584,2.2945', '48.8584, 2.2945', // as copied out of a map @@ -69,7 +71,7 @@ ]); it('rejects malformed coordinate pairs', function (string $value): void { - expect(ruleAccepts(new LatLng(), $value))->toBeFalse(); + expect(ruleAccepts(new LatLng, $value))->toBeFalse(); })->with([ '48.8584', // no pair '48.8584,2.2945,3', // three parts @@ -82,8 +84,8 @@ // A swapped pair still validates as two numbers, so nothing downstream // notices — the point just plots somewhere else. Differing ranges are the // only defence available, and they catch the common case. - expect(ruleAccepts(new LatLng(), '2.2945,48.8584'))->toBeTrue() // both in range: undetectable - ->and(ruleAccepts(new LatLng(), '120.5,45.0'))->toBeFalse(); // 120 is no latitude + expect(ruleAccepts(new LatLng, '2.2945,48.8584'))->toBeTrue() // both in range: undetectable + ->and(ruleAccepts(new LatLng, '120.5,45.0'))->toBeFalse(); // 120 is no latitude }); // ========================================================================= @@ -91,22 +93,22 @@ // ========================================================================= it('accepts states by code and by name', function (string $value): void { - expect(ruleAccepts(new UsState(), $value))->toBeTrue(); + expect(ruleAccepts(new UsState, $value))->toBeTrue(); })->with(['CA', 'ca', 'California', 'california', 'NY', 'New York', 'new york', ' Texas ']); it('includes DC by default', function (): void { // Not a state, but every form asking for one expects it. - expect(ruleAccepts(new UsState(), 'DC'))->toBeTrue() - ->and(ruleAccepts(new UsState(), 'District of Columbia'))->toBeTrue(); + expect(ruleAccepts(new UsState, 'DC'))->toBeTrue() + ->and(ruleAccepts(new UsState, 'District of Columbia'))->toBeTrue(); }); it('excludes territories unless asked', function (string $value): void { - expect(ruleAccepts(new UsState(), $value))->toBeFalse() + expect(ruleAccepts(new UsState, $value))->toBeFalse() ->and(ruleAccepts(new UsState(includeTerritories: true), $value))->toBeTrue(); })->with(['PR', 'Puerto Rico', 'GU', 'VI', 'AS', 'MP']); it('rejects values that are not states', function (string $value): void { - expect(ruleAccepts(new UsState(), $value))->toBeFalse(); + expect(ruleAccepts(new UsState, $value))->toBeFalse(); })->with(['XX', 'Ontario', 'Californa', 'C', 'United States']); it('covers all fifty states plus DC', function (): void { @@ -120,7 +122,7 @@ expect($codes)->toHaveCount(51); foreach ($codes as $code) { - expect(ruleAccepts(new UsState(), $code))->toBeTrue("missing state code {$code}"); + expect(ruleAccepts(new UsState, $code))->toBeTrue("missing state code {$code}"); } }); @@ -129,23 +131,23 @@ // ========================================================================= it('accepts provinces by code and by name', function (string $value): void { - expect(ruleAccepts(new CaProvince(), $value))->toBeTrue(); + expect(ruleAccepts(new CaProvince, $value))->toBeTrue(); })->with(['ON', 'on', 'Ontario', 'BC', 'British Columbia', 'Newfoundland and Labrador']); it('includes the territories by default', function (string $value): void { // Canada's three are ordinary places people live and post to; the US // territory distinction does not carry over. - expect(ruleAccepts(new CaProvince(), $value))->toBeTrue(); + expect(ruleAccepts(new CaProvince, $value))->toBeTrue(); })->with(['NT', 'Northwest Territories', 'NU', 'Nunavut', 'YT', 'Yukon']); it('accepts both spellings of Quebec', function (): void { - expect(ruleAccepts(new CaProvince(), 'Quebec'))->toBeTrue() - ->and(ruleAccepts(new CaProvince(), 'Québec'))->toBeTrue() - ->and(ruleAccepts(new CaProvince(), 'QC'))->toBeTrue(); + expect(ruleAccepts(new CaProvince, 'Quebec'))->toBeTrue() + ->and(ruleAccepts(new CaProvince, 'Québec'))->toBeTrue() + ->and(ruleAccepts(new CaProvince, 'QC'))->toBeTrue(); }); it('rejects values that are not provinces', function (string $value): void { - expect(ruleAccepts(new CaProvince(), $value))->toBeFalse(); + expect(ruleAccepts(new CaProvince, $value))->toBeFalse(); })->with(['XX', 'California', 'Ontari', 'Canada']); it('covers all thirteen provinces and territories', function (): void { @@ -154,6 +156,6 @@ expect($codes)->toHaveCount(13); foreach ($codes as $code) { - expect(ruleAccepts(new CaProvince(), $code))->toBeTrue("missing province code {$code}"); + expect(ruleAccepts(new CaProvince, $code))->toBeTrue("missing province code {$code}"); } }); diff --git a/tests/Rules/I18n/I18nRulesTest.php b/tests/Rules/I18n/I18nRulesTest.php index e56dfd5..95a0e38 100644 --- a/tests/Rules/I18n/I18nRulesTest.php +++ b/tests/Rules/I18n/I18nRulesTest.php @@ -1,26 +1,28 @@ -toBeTrue(); + expect(ruleAccepts(new CountryCode, $value))->toBeTrue(); })->with(['US', 'KE', 'DE', 'XK']); it('rejects unassigned, lowercase and alpha-3 values in alpha-2 mode', function (mixed $value): void { // Empty strings are absent from this list on purpose: Laravel skips // non-implicit rules on '' — presence is `required`'s job, not this rule's. - expect(ruleAccepts(new CountryCode(), $value))->toBeFalse(); + expect(ruleAccepts(new CountryCode, $value))->toBeFalse(); })->with(['AA', 'us', 'USA', 'U', 12, null, [['US']]]); it('accepts assigned alpha-3 codes only in alpha-3 mode', function (): void { @@ -42,7 +44,8 @@ }); it('honours a bound CountryDataset', function (): void { - $this->app->instance(CountryDataset::class, new class implements CountryDataset { + $this->app->instance(CountryDataset::class, new class implements CountryDataset + { public function isAlpha2(string $code): bool { return $code === 'ZZ'; @@ -54,8 +57,8 @@ public function isAlpha3(string $code): bool } }); - expect(ruleAccepts(new CountryCode(), 'ZZ'))->toBeTrue() - ->and(ruleAccepts(new CountryCode(), 'US'))->toBeFalse(); + expect(ruleAccepts(new CountryCode, 'ZZ'))->toBeTrue() + ->and(ruleAccepts(new CountryCode, 'US'))->toBeFalse(); }); // ========================================================================= @@ -63,11 +66,11 @@ public function isAlpha3(string $code): bool // ========================================================================= it('accepts assigned ISO 639-1 codes', function (string $value): void { - expect(ruleAccepts(new LanguageCode(), $value))->toBeTrue(); + expect(ruleAccepts(new LanguageCode, $value))->toBeTrue(); })->with(['en', 'sw', 'de', 'zh']); it('rejects unassigned, uppercase and three-letter values', function (mixed $value): void { - expect(ruleAccepts(new LanguageCode(), $value))->toBeFalse(); + expect(ruleAccepts(new LanguageCode, $value))->toBeFalse(); })->with(['EN', 'eng', 'xx', 3, null]); it('folds language case only when asked to', function (): void { @@ -75,15 +78,16 @@ public function isAlpha3(string $code): bool }); it('honours a bound LanguageDataset', function (): void { - $this->app->instance(LanguageDataset::class, new class implements LanguageDataset { + $this->app->instance(LanguageDataset::class, new class implements LanguageDataset + { public function isAlpha2(string $code): bool { return $code === 'zz'; } }); - expect(ruleAccepts(new LanguageCode(), 'zz'))->toBeTrue() - ->and(ruleAccepts(new LanguageCode(), 'en'))->toBeFalse(); + expect(ruleAccepts(new LanguageCode, 'zz'))->toBeTrue() + ->and(ruleAccepts(new LanguageCode, 'en'))->toBeFalse(); }); // ========================================================================= @@ -91,13 +95,13 @@ public function isAlpha2(string $code): bool // ========================================================================= it('accepts current alpha currency codes, including the 2016-2024 replacements', function (string $value): void { - expect(ruleAccepts(new CurrencyCode(), $value))->toBeTrue(); + expect(ruleAccepts(new CurrencyCode, $value))->toBeTrue(); })->with(['USD', 'EUR', 'KES', 'BYN', 'MRU', 'STN', 'VES', 'SLE', 'ZWG', 'XAU', 'XXX']); it('rejects retired and unassigned alpha codes', function (mixed $value): void { // HRK (Croatia -> euro, 2023), SLL (redenominated, withdrawn 2023) and // ZWL (replaced by ZWG, 2024) are exactly what a stale list would accept. - expect(ruleAccepts(new CurrencyCode(), $value))->toBeFalse(); + expect(ruleAccepts(new CurrencyCode, $value))->toBeFalse(); })->with(['HRK', 'SLL', 'ZWL', 'usd', 'ABC', '840', 840, null]); it('validates numeric codes in numeric mode', function (): void { @@ -122,7 +126,8 @@ public function isAlpha2(string $code): bool }); it('honours a bound CurrencyDataset', function (): void { - $this->app->instance(CurrencyDataset::class, new class implements CurrencyDataset { + $this->app->instance(CurrencyDataset::class, new class implements CurrencyDataset + { public function isCode(string $code): bool { return $code === 'ZZZ'; @@ -139,8 +144,8 @@ public function isSymbol(string $symbol): bool } }); - expect(ruleAccepts(new CurrencyCode(), 'ZZZ'))->toBeTrue() - ->and(ruleAccepts(new CurrencyCode(), 'USD'))->toBeFalse(); + expect(ruleAccepts(new CurrencyCode, 'ZZZ'))->toBeTrue() + ->and(ruleAccepts(new CurrencyCode, 'USD'))->toBeFalse(); }); // ========================================================================= diff --git a/tests/Rules/Identifiers/HashDigestAndBsbTest.php b/tests/Rules/Identifiers/HashDigestAndBsbTest.php index c3aa319..d672007 100644 --- a/tests/Rules/Identifiers/HashDigestAndBsbTest.php +++ b/tests/Rules/Identifiers/HashDigestAndBsbTest.php @@ -1,4 +1,6 @@ -toBeTrue(); + expect(ruleAccepts(new BsbNumber, $value))->toBeTrue(); })->with(['062-000', '062000', '733100']); it('rejects malformed BSB numbers', function (mixed $value): void { - expect(ruleAccepts(new BsbNumber(), $value))->toBeFalse(); + expect(ruleAccepts(new BsbNumber, $value))->toBeFalse(); })->with([ '062-00', // five digits '0620000', // seven digits diff --git a/tests/Rules/Identifiers/IdentifiersRulesTest.php b/tests/Rules/Identifiers/IdentifiersRulesTest.php index c292f46..17f1ef1 100644 --- a/tests/Rules/Identifiers/IdentifiersRulesTest.php +++ b/tests/Rules/Identifiers/IdentifiersRulesTest.php @@ -1,16 +1,18 @@ -toBeTrue(); + expect(ruleAccepts(new Imei, $value))->toBeTrue(); })->with([ '490154203237518', '356938035643809', @@ -18,7 +20,7 @@ ]); it('rejects invalid IMEIs', function (string $value): void { - expect(ruleAccepts(new Imei(), $value))->toBeFalse(); + expect(ruleAccepts(new Imei, $value))->toBeFalse(); })->with([ '490154203237519', // Luhn check off by one '35693803564380', // 14 digits @@ -31,7 +33,7 @@ // Swapping the check digit for a two-digit software version removes the // only integrity check the format has. Accepting both widths would mean // silently validating nothing for the 16-digit case. - expect(ruleAccepts(new Imei(), '3569380356438099'))->toBeFalse(); + expect(ruleAccepts(new Imei, '3569380356438099'))->toBeFalse(); }); // ========================================================================= @@ -39,7 +41,7 @@ // ========================================================================= it('accepts structurally valid VINs', function (string $value): void { - expect(ruleAccepts(new Vin(), $value))->toBeTrue(); + expect(ruleAccepts(new Vin, $value))->toBeTrue(); })->with([ '1M8GDM9AXKP042788', '1HGCM82633A004352', @@ -50,7 +52,7 @@ it('rejects VINs containing I, O or Q', function (string $value): void { // Barred throughout the format, being too easily confused with 1 and 0. - expect(ruleAccepts(new Vin(), $value))->toBeFalse(); + expect(ruleAccepts(new Vin, $value))->toBeFalse(); })->with([ '1M8GDM9AXKP04278I', '1M8GDM9AXKP04278O', @@ -58,7 +60,7 @@ ]); it('rejects VINs of the wrong length', function (string $value): void { - expect(ruleAccepts(new Vin(), $value))->toBeFalse(); + expect(ruleAccepts(new Vin, $value))->toBeFalse(); })->with(['1M8GDM9AXKP04278', '1M8GDM9AXKP0427888']); it('verifies the check digit only when asked', function (): void { @@ -67,7 +69,7 @@ // Enforcing it by default would reject real data. $broken = '1M8GDM9A0KP042788'; // valid shape, check digit forced to 0 - expect(ruleAccepts(new Vin(), $broken))->toBeTrue() + expect(ruleAccepts(new Vin, $broken))->toBeTrue() ->and(ruleAccepts(new Vin(checkDigit: true), $broken))->toBeFalse() ->and(ruleAccepts(new Vin(checkDigit: true), '1M8GDM9AXKP042788'))->toBeTrue(); }); @@ -83,7 +85,7 @@ // ========================================================================= it('accepts valid semantic versions', function (string $value): void { - expect(ruleAccepts(new SemVer(), $value))->toBeTrue(); + expect(ruleAccepts(new SemVer, $value))->toBeTrue(); })->with([ '0.0.4', '1.2.3', @@ -99,7 +101,7 @@ ]); it('rejects invalid semantic versions', function (string $value): void { - expect(ruleAccepts(new SemVer(), $value))->toBeFalse(); + expect(ruleAccepts(new SemVer, $value))->toBeFalse(); })->with([ '1', '1.2', @@ -119,7 +121,7 @@ $pathological = '1.0.0-' . str_repeat('a.', 5000) . '!'; $start = hrtime(true); - $result = ruleAccepts(new SemVer(), $pathological); + $result = ruleAccepts(new SemVer, $pathological); $elapsedMs = (hrtime(true) - $start) / 1_000_000; expect($result)->toBeFalse() @@ -134,19 +136,19 @@ $header = rtrim(strtr(base64_encode((string) json_encode(['alg' => 'HS256', 'typ' => 'JWT'])), '+/', '-_'), '='); $payload = rtrim(strtr(base64_encode((string) json_encode(['sub' => '123'])), '+/', '-_'), '='); - expect(ruleAccepts(new Jwt(), "{$header}.{$payload}.signature"))->toBeTrue() + expect(ruleAccepts(new Jwt, "{$header}.{$payload}.signature"))->toBeTrue() // An unsecured token has an empty signature but keeps both dots. - ->and(ruleAccepts(new Jwt(), "{$header}.{$payload}."))->toBeTrue(); + ->and(ruleAccepts(new Jwt, "{$header}.{$payload}."))->toBeTrue(); }); it('rejects strings that merely look like JWTs', function (): void { // The classic bare-regex failure: three dot-separated base64url runs is a // shape, not a token. Decoding the header rejects it for one base64 pass. - expect(ruleAccepts(new Jwt(), 'aaa.bbb.ccc'))->toBeFalse(); + expect(ruleAccepts(new Jwt, 'aaa.bbb.ccc'))->toBeFalse(); }); it('rejects malformed JWTs', function (string $value): void { - expect(ruleAccepts(new Jwt(), $value))->toBeFalse(); + expect(ruleAccepts(new Jwt, $value))->toBeFalse(); })->with([ 'onlyonesegment', 'two.segments', @@ -157,7 +159,7 @@ it('rejects a header that decodes but carries no alg', function (): void { $header = rtrim(strtr(base64_encode((string) json_encode(['typ' => 'JWT'])), '+/', '-_'), '='); - expect(ruleAccepts(new Jwt(), "{$header}.eyJzdWIiOiIxMjMifQ.sig"))->toBeFalse(); + expect(ruleAccepts(new Jwt, "{$header}.eyJzdWIiOiIxMjMifQ.sig"))->toBeFalse(); }); it('accepts alg none, which is well-formed and worthless', function (): void { @@ -166,5 +168,5 @@ // library before any claim in it is believed. $header = rtrim(strtr(base64_encode((string) json_encode(['alg' => 'none'])), '+/', '-_'), '='); - expect(ruleAccepts(new Jwt(), "{$header}.eyJzdWIiOiIxMjMifQ."))->toBeTrue(); + expect(ruleAccepts(new Jwt, "{$header}.eyJzdWIiOiIxMjMifQ."))->toBeTrue(); }); diff --git a/tests/Rules/Markup/XmlTest.php b/tests/Rules/Markup/XmlTest.php index 0fc6196..b7cacbd 100644 --- a/tests/Rules/Markup/XmlTest.php +++ b/tests/Rules/Markup/XmlTest.php @@ -1,13 +1,15 @@ -x'))->toBeTrue() - ->and(ruleAccepts(new Xml(), ''))->toBeFalse() - ->and(ruleAccepts(new Xml(), 'not xml at all'))->toBeFalse(); + expect(ruleAccepts(new Xml, 'x'))->toBeTrue() + ->and(ruleAccepts(new Xml, ''))->toBeFalse() + ->and(ruleAccepts(new Xml, 'not xml at all'))->toBeFalse(); }); it('guards blank and non-string input when called directly', function (mixed $value): void { @@ -34,7 +36,7 @@ &xxe; XML; - $document = new DOMDocument(); + $document = new DOMDocument; $previous = libxml_use_internal_errors(true); $document->loadXML($xxe, LIBXML_NONET); libxml_use_internal_errors($previous); @@ -83,7 +85,7 @@ $before = libxml_use_internal_errors(false); libxml_use_internal_errors($before); - ruleAccepts(new Xml(), ''); + ruleAccepts(new Xml, ''); $after = libxml_use_internal_errors(false); libxml_use_internal_errors($after); diff --git a/tests/Rules/Net/NetRulesTest.php b/tests/Rules/Net/NetRulesTest.php index 228c21f..b68ac2e 100644 --- a/tests/Rules/Net/NetRulesTest.php +++ b/tests/Rules/Net/NetRulesTest.php @@ -1,17 +1,19 @@ -toBeTrue(); + expect(ruleAccepts(new DomainName, $value))->toBeTrue(); })->with([ 'example.com', 'sub.example.com', @@ -24,7 +26,7 @@ ]); it('rejects invalid domain names', function (string $value): void { - expect(ruleAccepts(new DomainName(), $value))->toBeFalse(); + expect(ruleAccepts(new DomainName, $value))->toBeFalse(); })->with([ 'localhost', // single label, no TLD '-example.com', // label may not lead with a hyphen @@ -39,8 +41,8 @@ ]); it('rejects a label longer than 63 characters', function (): void { - expect(ruleAccepts(new DomainName(), str_repeat('a', 63) . '.com'))->toBeTrue() - ->and(ruleAccepts(new DomainName(), str_repeat('a', 64) . '.com'))->toBeFalse(); + expect(ruleAccepts(new DomainName, str_repeat('a', 63) . '.com'))->toBeTrue() + ->and(ruleAccepts(new DomainName, str_repeat('a', 64) . '.com'))->toBeFalse(); }); it('rejects a name longer than 253 characters', function (): void { @@ -55,20 +57,20 @@ // strlen($x)->toBe(n), Rector's Pest set rewrites it and silently changed // the expected value while dropping two of the four assertions. expect($atLimit)->toHaveLength(253) - ->and(ruleAccepts(new DomainName(), $atLimit))->toBeTrue() + ->and(ruleAccepts(new DomainName, $atLimit))->toBeTrue() ->and($overLimit)->toHaveLength(254) - ->and(ruleAccepts(new DomainName(), $overLimit))->toBeFalse(); + ->and(ruleAccepts(new DomainName, $overLimit))->toBeFalse(); }); it('treats a unicode name and its A-label form alike', function (): void { // The whole point of normalising to ASCII first: these are the same name. expect(DomainName::supportsInternationalNames())->toBeTrue() - ->and(ruleAccepts(new DomainName(), 'münchen.de'))->toBeTrue() - ->and(ruleAccepts(new DomainName(), 'xn--mnchen-3ya.de'))->toBeTrue(); + ->and(ruleAccepts(new DomainName, 'münchen.de'))->toBeTrue() + ->and(ruleAccepts(new DomainName, 'xn--mnchen-3ya.de'))->toBeTrue(); })->skip(fn (): bool => ! DomainName::supportsInternationalNames(), 'ext-intl not installed'); it('can allow single-label names', function (): void { - expect(ruleAccepts(new DomainName(), 'localhost'))->toBeFalse() + expect(ruleAccepts(new DomainName, 'localhost'))->toBeFalse() ->and(ruleAccepts(new DomainName(requireTld: false), 'localhost'))->toBeTrue(); }); @@ -77,11 +79,11 @@ // ========================================================================= it('accepts valid subdomains', function (string $value): void { - expect(ruleAccepts(new Subdomain(), $value))->toBeTrue(); + expect(ruleAccepts(new Subdomain, $value))->toBeTrue(); })->with(['blog', 'my-app', 'a', 'a1', '123', str_repeat('a', 63)]); it('rejects invalid subdomains', function (string $value): void { - expect(ruleAccepts(new Subdomain(), $value))->toBeFalse(); + expect(ruleAccepts(new Subdomain, $value))->toBeFalse(); })->with([ 'blog.example.com', // one label only '-blog', @@ -95,8 +97,8 @@ // xn--pple-43d renders as `аpple` with a Cyrillic а. Accepting Punycode // from user input is a homograph attack waiting to happen, and a // self-chosen subdomain is exactly where it would land. - expect(ruleAccepts(new Subdomain(), 'xn--pple-43d'))->toBeFalse() - ->and(ruleAccepts(new Subdomain(), 'XN--pple-43d'))->toBeFalse(); + expect(ruleAccepts(new Subdomain, 'xn--pple-43d'))->toBeFalse() + ->and(ruleAccepts(new Subdomain, 'XN--pple-43d'))->toBeFalse(); }); // ========================================================================= @@ -104,8 +106,8 @@ // ========================================================================= it('accepts publicly routable addresses', function (string $ip): void { - expect(ruleAccepts(new PublicIp(), $ip))->toBeTrue() - ->and(ruleAccepts(new PrivateIp(), $ip))->toBeFalse(); + expect(ruleAccepts(new PublicIp, $ip))->toBeTrue() + ->and(ruleAccepts(new PrivateIp, $ip))->toBeFalse(); })->with([ '8.8.8.8', '1.1.1.1', @@ -117,8 +119,8 @@ ]); it('rejects addresses that are not publicly routable', function (string $ip): void { - expect(ruleAccepts(new PublicIp(), $ip))->toBeFalse() - ->and(ruleAccepts(new PrivateIp(), $ip))->toBeTrue(); + expect(ruleAccepts(new PublicIp, $ip))->toBeFalse() + ->and(ruleAccepts(new PrivateIp, $ip))->toBeTrue(); })->with([ '10.0.0.1', // RFC 1918 '172.16.0.1', @@ -143,7 +145,7 @@ // The classic SSRF filter bypass: ::ffff:127.0.0.1 is loopback written as // v6, and filter_var's NO_PRIV_RANGE|NO_RES_RANGE flags read it as an // ordinary global v6 address. - expect(ruleAccepts(new PublicIp(), $ip))->toBeFalse(); + expect(ruleAccepts(new PublicIp, $ip))->toBeFalse(); })->with([ '::ffff:127.0.0.1', '::ffff:10.0.0.1', @@ -152,16 +154,16 @@ ]); it('rejects values that are not IP addresses at all', function (string $value): void { - expect(ruleAccepts(new PublicIp(), $value))->toBeFalse() - ->and(ruleAccepts(new PrivateIp(), $value))->toBeFalse(); + expect(ruleAccepts(new PublicIp, $value))->toBeFalse() + ->and(ruleAccepts(new PrivateIp, $value))->toBeFalse(); })->with(['not-an-ip', '999.999.999.999', '10.0.0', 'example.com', '10.0.0.1/8']); it('is the exact complement of PrivateIp over valid addresses', function (): void { // Both rules delegate to one classifier precisely so a range cannot be // private to one and public to the other. foreach (['8.8.8.8', '10.0.0.1', '::1', '2001:4860:4860::8888', '::ffff:127.0.0.1'] as $ip) { - expect(ruleAccepts(new PublicIp(), $ip)) - ->not->toBe(ruleAccepts(new PrivateIp(), $ip), "both agreed on {$ip}"); + expect(ruleAccepts(new PublicIp, $ip)) + ->not->toBe(ruleAccepts(new PrivateIp, $ip), "both agreed on {$ip}"); } }); @@ -170,7 +172,7 @@ // ========================================================================= it('accepts valid CIDR notation', function (string $value): void { - expect(ruleAccepts(new Cidr(), $value))->toBeTrue(); + expect(ruleAccepts(new Cidr, $value))->toBeTrue(); })->with([ '10.0.0.0/8', '192.168.1.0/24', @@ -183,7 +185,7 @@ ]); it('rejects invalid CIDR notation', function (string $value): void { - expect(ruleAccepts(new Cidr(), $value))->toBeFalse(); + expect(ruleAccepts(new Cidr, $value))->toBeFalse(); })->with([ '10.0.0.0/33', // v4 prefix out of range '10.0.0.0/64', // a v6 prefix on a v4 address diff --git a/tests/Rules/Net/NetworkFieldsTest.php b/tests/Rules/Net/NetworkFieldsTest.php index 08778a6..d966e43 100644 --- a/tests/Rules/Net/NetworkFieldsTest.php +++ b/tests/Rules/Net/NetworkFieldsTest.php @@ -1,9 +1,11 @@ - $deliverable + * @param list $deliverable */ function fakeResolver(array $deliverable, ?Closure $onCall = null): DnsResolver { - return new readonly class ($deliverable, $onCall) implements DnsResolver { + return new readonly class($deliverable, $onCall) implements DnsResolver + { /** @param list $deliverable */ public function __construct(private array $deliverable, private ?Closure $onCall) {} @@ -88,8 +91,8 @@ public function hasMailExchanger(string $domain): bool it('resolves the resolver from the container when none is given', function (): void { app()->instance(DnsResolver::class, fakeResolver(['example.com'])); - expect(Validator::make(['e' => 'a@example.com'], ['e' => [new DeliverableEmail()]])->passes())->toBeTrue() - ->and(Validator::make(['e' => 'a@other.test'], ['e' => [new DeliverableEmail()]])->passes())->toBeFalse(); + expect(Validator::make(['e' => 'a@example.com'], ['e' => [new DeliverableEmail]])->passes())->toBeTrue() + ->and(Validator::make(['e' => 'a@other.test'], ['e' => [new DeliverableEmail]])->passes())->toBeFalse(); }); it('binds the bundled resolver so the rule works with nothing configured', function (): void { @@ -114,7 +117,8 @@ public function hasMailExchanger(string $domain): bool // store whose table was never migrated is the canonical case — crashed // the lookup mid-validation. A caching layer is an optimization; its // infrastructure failing must cost speed, never a verdict. - $broken = new class implements Repository { + $broken = new class implements Repository + { use ThrowsOnEveryCacheCall; }; @@ -128,7 +132,7 @@ public function hasMailExchanger(string $domain): bool // not the same as an undeliverable domain, and rejecting every signup for // the duration of a DNS outage is the worse error. Exercised against the // real resolver with a name that cannot resolve. - $resolver = new CachedDnsResolver(); + $resolver = new CachedDnsResolver; // .invalid is reserved by RFC 2606 and must never resolve, so a false here // is a genuine negative answer rather than an outage. diff --git a/tests/Rules/Network/ProbeRulesTest.php b/tests/Rules/Network/ProbeRulesTest.php index 393a797..46eb653 100644 --- a/tests/Rules/Network/ProbeRulesTest.php +++ b/tests/Rules/Network/ProbeRulesTest.php @@ -1,11 +1,13 @@ - Http::response('', 200, ['Content-Type' => 'image/png'])]); - expect(ruleAccepts(new ImageUrl(), 'https://example.com/logo.png'))->toBeTrue(); + expect(ruleAccepts(new ImageUrl, 'https://example.com/logo.png'))->toBeTrue(); }); it('rejects non-image content, error statuses and unreachable hosts', function (): void { @@ -25,12 +27,12 @@ 'down.example.com/*' => static fn () => throw new ConnectionException('refused'), ]); - expect(ruleAccepts(new ImageUrl(), 'https://text.example.com/page'))->toBeFalse() - ->and(ruleAccepts(new ImageUrl(), 'https://gone.example.com/x.png'))->toBeFalse() + expect(ruleAccepts(new ImageUrl, 'https://text.example.com/page'))->toBeFalse() + ->and(ruleAccepts(new ImageUrl, 'https://gone.example.com/x.png'))->toBeFalse() // The probe IS the rule: unreachable fails, unlike DeliverableEmail's // fail-open DNS posture, because "serves an image right now" is // exactly what was asked. - ->and(ruleAccepts(new ImageUrl(), 'https://down.example.com/x.png'))->toBeFalse(); + ->and(ruleAccepts(new ImageUrl, 'https://down.example.com/x.png'))->toBeFalse(); }); it('can restrict the image subtypes', function (): void { @@ -43,7 +45,7 @@ it('refuses non-http schemes, loopback names and reserved-range IP literals without any request', function (mixed $value): void { Http::fake(); - expect(ruleAccepts(new ImageUrl(), $value))->toBeFalse(); + expect(ruleAccepts(new ImageUrl, $value))->toBeFalse(); Http::assertNothingSent(); })->with([ @@ -60,14 +62,14 @@ it('probes with HEAD and follows no redirects', function (): void { Http::fake(['example.com/*' => Http::response('', 302, ['Location' => 'http://127.0.0.1/'])]); - expect(ruleAccepts(new ImageUrl(), 'https://example.com/logo.png'))->toBeFalse(); + expect(ruleAccepts(new ImageUrl, 'https://example.com/logo.png'))->toBeFalse(); Http::assertSent(static fn (Request $request): bool => $request->method() === 'HEAD'); }); it('is skipped during precognition', function (): void { - expect(new ImageUrl())->toBeInstanceOf(PrecognitionSkippable::class) - ->and(new HasGravatar())->toBeInstanceOf(PrecognitionSkippable::class); + expect(new ImageUrl)->toBeInstanceOf(PrecognitionSkippable::class) + ->and(new HasGravatar)->toBeInstanceOf(PrecognitionSkippable::class); }); // ========================================================================= @@ -77,20 +79,20 @@ it('reports whether the address has a gravatar via the sha256 endpoint', function (): void { Http::fake([ 'gravatar.com/avatar/' . hash('sha256', 'alice@example.com') . '*' => Http::response('', 200), - 'gravatar.com/*' => Http::response('', 404), + 'gravatar.com/*' => Http::response('', 404), ]); - expect(ruleAccepts(new HasGravatar(), 'alice@example.com'))->toBeTrue() - ->and(ruleAccepts(new HasGravatar(), 'nobody@example.com'))->toBeFalse(); + expect(ruleAccepts(new HasGravatar, 'alice@example.com'))->toBeTrue() + ->and(ruleAccepts(new HasGravatar, 'nobody@example.com'))->toBeFalse(); }); it('normalises the address before hashing, as gravatar does', function (): void { Http::fake([ 'gravatar.com/avatar/' . hash('sha256', 'alice@example.com') . '*' => Http::response('', 200), - 'gravatar.com/*' => Http::response('', 404), + 'gravatar.com/*' => Http::response('', 404), ]); - expect(ruleAccepts(new HasGravatar(), ' Alice@Example.COM '))->toBeTrue(); + expect(ruleAccepts(new HasGravatar, ' Alice@Example.COM '))->toBeTrue(); }); it('passes rather than blocks when gravatar is unreachable', function (): void { @@ -98,14 +100,14 @@ // away real users. Treat a pass as "not shown to be missing". Http::fake(['gravatar.com/*' => static fn () => throw new ConnectionException('down')]); - expect(ruleAccepts(new HasGravatar(), 'alice@example.com'))->toBeTrue(); + expect(ruleAccepts(new HasGravatar, 'alice@example.com'))->toBeTrue(); }); it('rejects a malformed address without asking gravatar', function (): void { Http::fake(); - expect(ruleAccepts(new HasGravatar(), 'not-an-email'))->toBeFalse() - ->and(ruleAccepts(new HasGravatar(), 42))->toBeFalse(); + expect(ruleAccepts(new HasGravatar, 'not-an-email'))->toBeFalse() + ->and(ruleAccepts(new HasGravatar, 42))->toBeFalse(); Http::assertNothingSent(); }); diff --git a/tests/Rules/Numbers/NumbersRulesTest.php b/tests/Rules/Numbers/NumbersRulesTest.php index b3b582d..64c7480 100644 --- a/tests/Rules/Numbers/NumbersRulesTest.php +++ b/tests/Rules/Numbers/NumbersRulesTest.php @@ -1,7 +1,9 @@ -toBe($even) ->and(Parity::passes($value, Parity::ODD))->toBe(! $even); })->with([ - 'zero' => [0, true], - 'positive even' => [4, true], - 'positive odd' => [7, false], - 'negative even' => [-4, true], - 'negative odd' => [-3, false], + 'zero' => [0, true], + 'positive even' => [4, true], + 'positive odd' => [7, false], + 'negative even' => [-4, true], + 'negative odd' => [-3, false], 'numeric string' => ['4', true], - 'signed string' => ['-3', false], - 'padded string' => [' 6 ', true], + 'signed string' => ['-3', false], + 'padded string' => [' 6 ', true], ]); it('rejects values that have no parity', function (mixed $value): void { @@ -29,14 +31,14 @@ expect(Parity::passes($value, Parity::EVEN))->toBeFalse() ->and(Parity::passes($value, Parity::ODD))->toBeFalse(); })->with([ - 'fractional' => 2.5, + 'fractional' => 2.5, 'not a number' => 'four', - 'empty' => '', - 'hex-ish' => '0x4', - 'exponent' => '1e2', - 'null' => null, - 'array' => [[2]], - 'bool' => true, + 'empty' => '', + 'hex-ish' => '0x4', + 'exponent' => '1e2', + 'null' => null, + 'array' => [[2]], + 'bool' => true, ]); it('accepts a whole float but not one beyond int range', function (): void { @@ -66,17 +68,17 @@ // these is numeric to PHP. expect(MonetaryAmount::passes($value))->toBeFalse(); })->with([ - 'exponent' => '1e3', - 'hex' => '0x1A', - 'infinity' => INF, - 'nan' => NAN, + 'exponent' => '1e3', + 'hex' => '0x1A', + 'infinity' => INF, + 'nan' => NAN, 'thousands separator' => '1,234.50', - 'currency symbol' => '$12.00', - 'trailing dot' => '12.', - 'too many decimals' => '12.345', + 'currency symbol' => '$12.00', + 'trailing dot' => '12.', + 'too many decimals' => '12.345', 'negative by default' => '-12.00', - 'empty' => '', - 'spaces only' => ' ', + 'empty' => '', + 'spaces only' => ' ', ]); it('takes the decimal count as a parameter, because currencies differ', function (): void { diff --git a/tests/Rules/Payment/PaymentRulesTest.php b/tests/Rules/Payment/PaymentRulesTest.php index beb1e40..d62c38e 100644 --- a/tests/Rules/Payment/PaymentRulesTest.php +++ b/tests/Rules/Payment/PaymentRulesTest.php @@ -1,12 +1,14 @@ -toBeTrue() + expect(ruleAccepts(new CardNumber, $number))->toBeTrue() ->and(resolve(CardBrandCatalogue::class)->identify(preg_replace('/\D/', '', $number) ?? '')?->name) ->toBe($brand); })->with([ @@ -43,7 +45,7 @@ ]); it('rejects failing checksums, wrong lengths and unknown ranges', function (mixed $value): void { - expect(ruleAccepts(new CardNumber(), $value))->toBeFalse(); + expect(ruleAccepts(new CardNumber, $value))->toBeFalse(); })->with([ '4242424242424241', // Luhn fails '424242424242424', // 15 digits is not a Visa length @@ -67,7 +69,7 @@ // different corrections. $messages = static fn (string $number): string => Validator::make( ['card' => $number], - ['card' => new CardNumber()], + ['card' => new CardNumber], )->errors()->first('card'); expect($messages('424242424242424'))->toContain('digits') @@ -76,7 +78,8 @@ }); it('honours a bound catalogue', function (): void { - $this->app->instance(CardBrandCatalogue::class, new class implements CardBrandCatalogue { + $this->app->instance(CardBrandCatalogue::class, new class implements CardBrandCatalogue + { /** @return list */ public function brands(): array { @@ -94,8 +97,8 @@ private function store(): CardBrand } }); - expect(ruleAccepts(new CardNumber(), '9990000000'))->toBeTrue() - ->and(ruleAccepts(new CardNumber(), '4242424242424242'))->toBeFalse(); + expect(ruleAccepts(new CardNumber, '9990000000'))->toBeTrue() + ->and(ruleAccepts(new CardNumber, '4242424242424242'))->toBeFalse(); }); // ========================================================================= @@ -103,12 +106,12 @@ private function store(): CardBrand // ========================================================================= it('accepts 3 or 4 digits when no brand context is given', function (): void { - expect(ruleAccepts(new CardCvc(), '123'))->toBeTrue() - ->and(ruleAccepts(new CardCvc(), '1234'))->toBeTrue() - ->and(ruleAccepts(new CardCvc(), '12'))->toBeFalse() - ->and(ruleAccepts(new CardCvc(), '12345'))->toBeFalse() - ->and(ruleAccepts(new CardCvc(), '12a'))->toBeFalse() - ->and(ruleAccepts(new CardCvc(), 123))->toBeFalse(); + expect(ruleAccepts(new CardCvc, '123'))->toBeTrue() + ->and(ruleAccepts(new CardCvc, '1234'))->toBeTrue() + ->and(ruleAccepts(new CardCvc, '12'))->toBeFalse() + ->and(ruleAccepts(new CardCvc, '12345'))->toBeFalse() + ->and(ruleAccepts(new CardCvc, '12a'))->toBeFalse() + ->and(ruleAccepts(new CardCvc, 123))->toBeFalse(); }); it('narrows to the brand of a sibling card number', function (): void { @@ -133,13 +136,13 @@ private function store(): CardBrand it('accepts current and future expiries in the common spellings', function (string $value): void { Date::setTestNow('2026-08-24 12:00:00'); - expect(ruleAccepts(new CardExpiry(), $value))->toBeTrue(); + expect(ruleAccepts(new CardExpiry, $value))->toBeTrue(); })->with(['08/26', '09/26', '12/2027', '08-26', '11-2030', '2027-03', '8/27']); it('rejects past, malformed and implausibly distant expiries', function (mixed $value): void { Date::setTestNow('2026-08-24 12:00:00'); - expect(ruleAccepts(new CardExpiry(), $value))->toBeFalse(); + expect(ruleAccepts(new CardExpiry, $value))->toBeFalse(); })->with([ '07/26', // last month '12/25', // last year diff --git a/tests/Rules/Postal/PostalCodeTest.php b/tests/Rules/Postal/PostalCodeTest.php index 00d27cd..e586da9 100644 --- a/tests/Rules/Postal/PostalCodeTest.php +++ b/tests/Rules/Postal/PostalCodeTest.php @@ -1,4 +1,6 @@ -toBeTrue(); })->with([ - 'US 5-digit' => ['US', '90210'], + 'US 5-digit' => ['US', '90210'], 'GB outward+inward' => ['GB', 'SW1A 1AA'], - 'GB short' => ['GB', 'M1 1AE'], - 'NL digits only' => ['NL', '1012'], - 'NL with letters' => ['NL', '1012 AB'], - 'CA' => ['CA', 'K1A 0B1'], - 'CA no space' => ['CA', 'K1A0B1'], - 'JP' => ['JP', '100-0001'], - 'DE' => ['DE', '10115'], - 'BR' => ['BR', '01310-100'], - 'lowercase input' => ['GB', 'sw1a 1aa'], + 'GB short' => ['GB', 'M1 1AE'], + 'NL digits only' => ['NL', '1012'], + 'NL with letters' => ['NL', '1012 AB'], + 'CA' => ['CA', 'K1A 0B1'], + 'CA no space' => ['CA', 'K1A0B1'], + 'JP' => ['JP', '100-0001'], + 'DE' => ['DE', '10115'], + 'BR' => ['BR', '01310-100'], + 'lowercase input' => ['GB', 'sw1a 1aa'], 'surrounding space' => ['US', ' 90210 '], ]); it('rejects postcodes that do not match the given country', function (string $country, string $value): void { expect(ruleAccepts(new PostalCode($country), $value))->toBeFalse(); })->with([ - 'US too short' => ['US', '9021'], - 'US too long' => ['US', '902101'], - 'US letters' => ['US', 'ABCDE'], - 'GB malformed' => ['GB', '12345'], - 'CA missing final digit' => ['CA', 'K1A 0B'], - 'CA barred letter D' => ['CA', 'D1A 0B1'], + 'US too short' => ['US', '9021'], + 'US too long' => ['US', '902101'], + 'US letters' => ['US', 'ABCDE'], + 'GB malformed' => ['GB', '12345'], + 'CA missing final digit' => ['CA', 'K1A 0B'], + 'CA barred letter D' => ['CA', 'D1A 0B1'], 'CA barred letter as second' => ['CA', 'K1I 0B1'], - 'JP without hyphen' => ['JP', '1000001'], - 'NL too many letters' => ['NL', '1012 ABC'], + 'JP without hyphen' => ['JP', '1000001'], + 'NL too many letters' => ['NL', '1012 ABC'], ]); it('accepts a value valid for any of several countries', function (): void { @@ -51,7 +53,7 @@ // Silently accepting anything for a country not in the table is how a // postcode column fills with junk. The failure is visible; the pass is not. expect(ruleAccepts(new PostalCode('ZW'), '12345'))->toBeFalse() - ->and(ruleAccepts(new PostalCode(), '12345'))->toBeFalse(); + ->and(ruleAccepts(new PostalCode, '12345'))->toBeFalse(); }); // ========================================================================= @@ -60,7 +62,7 @@ it('reads the country from another field', function (): void { $rules = [ - 'country' => ['required', 'string'], + 'country' => ['required', 'string'], 'postcode' => PostalCode::reference('country'), ]; @@ -80,7 +82,7 @@ // Without per-row resolution, every row would be validated against the // FIRST row's country — wrong in a way that only shows when rows differ. $rules = [ - 'addresses.*.country' => ['required', 'string'], + 'addresses.*.country' => ['required', 'string'], 'addresses.*.postcode' => PostalCode::reference('addresses.*.country'), ]; diff --git a/tests/Rules/Profanity/InlineTermListTest.php b/tests/Rules/Profanity/InlineTermListTest.php index d843218..e68d4a2 100644 --- a/tests/Rules/Profanity/InlineTermListTest.php +++ b/tests/Rules/Profanity/InlineTermListTest.php @@ -1,7 +1,9 @@ - $terms - * @param list $allowed + * @param list $terms + * @param list $allowed */ function termList(array $terms, array $allowed = []): NoProfanity { @@ -86,7 +88,8 @@ function termList(array $terms, array $allowed = []): NoProfanity }); it('accepts a TermList implementation', function (): void { - $list = new class implements TermList { + $list = new class implements TermList + { public function terms(): array { return ['badger']; diff --git a/tests/Rules/RuleTierTest.php b/tests/Rules/RuleTierTest.php index a1dbb8d..a3779e2 100644 --- a/tests/Rules/RuleTierTest.php +++ b/tests/Rules/RuleTierTest.php @@ -1,10 +1,12 @@ -toBeTrue(); })->with([ - 'single' => 'alice@example.com', - 'two' => 'alice@example.com,bob@example.com', - 'spaced' => 'alice@example.com, bob@example.com', + 'single' => 'alice@example.com', + 'two' => 'alice@example.com,bob@example.com', + 'spaced' => 'alice@example.com, bob@example.com', 'lots of space' => ' alice@example.com , bob@example.com ', ]); it('rejects the list when any single item fails', function (string $value): void { expect(ruleAccepts(new Delimited(['email']), $value))->toBeFalse(); })->with([ - 'first bad' => 'nope,bob@example.com', - 'last bad' => 'alice@example.com,nope', + 'first bad' => 'nope,bob@example.com', + 'last bad' => 'alice@example.com,nope', 'middle bad' => 'alice@example.com,nope,bob@example.com', ]); @@ -78,7 +80,7 @@ function delimitedError(Delimited $rule, mixed $value): string it('accepts rule objects as well as strings', function (): void { // The sub-rules are whatever Laravel accepts, so the rest of this library // composes into it without special-casing. - $rule = new Delimited([new Isbn()]); + $rule = new Delimited([new Isbn]); expect(ruleAccepts($rule, '0306406152,9780306406157'))->toBeTrue() ->and(ruleAccepts($rule, '0306406152,not-an-isbn'))->toBeFalse(); @@ -88,7 +90,7 @@ function delimitedError(Delimited $rule, mixed $value): string expect(ruleAccepts(new Delimited(['string']), $value))->toBeFalse(); })->with([ 'array' => [['a', 'b']], - 'int' => [42], + 'int' => [42], ]); it('refuses an empty separator at construction', function (): void { diff --git a/tests/Rules/Text/ReservedUsernameListTest.php b/tests/Rules/Text/ReservedUsernameListTest.php index 2ff70ac..afe0cbf 100644 --- a/tests/Rules/Text/ReservedUsernameListTest.php +++ b/tests/Rules/Text/ReservedUsernameListTest.php @@ -1,23 +1,25 @@ -toBeTrue() - ->and(ruleAccepts(new Username(), 'admin'))->toBeFalse(); + expect(ruleAccepts(new Username, 'chat'))->toBeTrue() + ->and(ruleAccepts(new Username, 'admin'))->toBeFalse(); }); it('honours a bound list, including separator-stripped claims', function (): void { $this->app->singleton(ReservedUsernameList::class, ExtendedReservedUsernameList::class); - expect(ruleAccepts(new Username(), 'gitlab'))->toBeFalse() - ->and(ruleAccepts(new Username(), 'git.lab'))->toBeFalse() - ->and(ruleAccepts(new Username(), 'imani'))->toBeTrue(); + expect(ruleAccepts(new Username, 'gitlab'))->toBeFalse() + ->and(ruleAccepts(new Username, 'git.lab'))->toBeFalse() + ->and(ruleAccepts(new Username, 'imani'))->toBeTrue(); }); it('lets an explicit reserved: list win over the binding', function (): void { diff --git a/tests/Rules/Text/TextRulesTest.php b/tests/Rules/Text/TextRulesTest.php index a4391d1..b034ad0 100644 --- a/tests/Rules/Text/TextRulesTest.php +++ b/tests/Rules/Text/TextRulesTest.php @@ -1,12 +1,14 @@ -toBeTrue(); + expect(ruleAccepts(new Slug, $value))->toBeTrue(); })->with(['hello', 'hello-world', 'a', '2026-in-review', 'a1-b2-c3']); it('rejects invalid slugs', function (string $value): void { - expect(ruleAccepts(new Slug(), $value))->toBeFalse(); + expect(ruleAccepts(new Slug, $value))->toBeFalse(); })->with([ 'Hello-World', // uppercase '-hello', // leading hyphen @@ -32,7 +34,7 @@ it('accepts exactly what Str::slug produces', function (string $input): void { // A slug that passes must survive a round trip, otherwise the rule and the // generator disagree about what a slug is. - expect(ruleAccepts(new Slug(), Str::slug($input)))->toBeTrue(); + expect(ruleAccepts(new Slug, Str::slug($input)))->toBeTrue(); })->with(['Hello World', 'Ünïcødé Títlè', 'lots of spaces', '2026 in review']); // ========================================================================= @@ -40,13 +42,13 @@ // ========================================================================= it('accepts values with no whitespace', function (string $value): void { - expect(ruleAccepts(new WithoutSpaces(), $value))->toBeTrue(); + expect(ruleAccepts(new WithoutSpaces, $value))->toBeTrue(); })->with(['abc', 'a-b_c', '12345', 'héllo']); it('rejects every kind of whitespace, not just ASCII', function (string $value): void { // \s alone misses these, and they are exactly what gets pasted in from a // word processor or used to slip past a naive check. - expect(ruleAccepts(new WithoutSpaces(), $value))->toBeFalse(); + expect(ruleAccepts(new WithoutSpaces, $value))->toBeFalse(); })->with([ 'a b', "a\tb", @@ -62,7 +64,7 @@ // ========================================================================= it('accepts plain text', function (string $value): void { - expect(ruleAccepts(new HtmlClean(), $value))->toBeTrue(); + expect(ruleAccepts(new HtmlClean, $value))->toBeTrue(); })->with([ 'Just a name', 'Ünïcødé', @@ -70,7 +72,7 @@ ]); it('rejects values containing markup', function (string $value): void { - expect(ruleAccepts(new HtmlClean(), $value))->toBeFalse(); + expect(ruleAccepts(new HtmlClean, $value))->toBeFalse(); })->with([ 'bold', 'hello ', @@ -82,7 +84,7 @@ // Both are ordinary text. `<script>` renders as the literal // characters and is not a tag; `5 < 10` is prose. Rejecting either would // be wrong far more often than useful. - expect(ruleAccepts(new HtmlClean(), $value))->toBeTrue(); + expect(ruleAccepts(new HtmlClean, $value))->toBeTrue(); })->with(['<script>', '5 < 10', 'a -> b', 'x > y']); it('is a data-shape rule and not an XSS defence', function (): void { @@ -90,7 +92,7 @@ // NOT safe to render unescaped — output escaping is the defence. $passes = '<img src=x onerror=alert(1)>'; - expect(ruleAccepts(new HtmlClean(), $passes))->toBeTrue() + expect(ruleAccepts(new HtmlClean, $passes))->toBeTrue() ->and(e($passes))->not->toBe($passes); }); @@ -99,18 +101,18 @@ // ========================================================================= it('accepts valid usernames', function (string $value): void { - expect(ruleAccepts(new Username(), $value))->toBeTrue(); + expect(ruleAccepts(new Username, $value))->toBeTrue(); })->with(['alice', 'alice_b', 'alice-b', 'alice.b', 'a1b2', 'Alice', str_repeat('a', 32)]); it('rejects usernames with edge or doubled separators', function (string $value): void { // `admin.`, `_admin` and `admin..b` all read as `admin` at a glance, and a // doubled separator is invisible in most fonts — impersonation shapes. - expect(ruleAccepts(new Username(), $value))->toBeFalse(); + expect(ruleAccepts(new Username, $value))->toBeFalse(); })->with(['_alice', 'alice_', '.alice', 'alice.', '-alice', 'alice-', 'alice..b', 'alice__b']); it('rejects usernames outside the length bounds', function (): void { - expect(ruleAccepts(new Username(), 'ab'))->toBeFalse() - ->and(ruleAccepts(new Username(), str_repeat('a', 33)))->toBeFalse() + expect(ruleAccepts(new Username, 'ab'))->toBeFalse() + ->and(ruleAccepts(new Username, str_repeat('a', 33)))->toBeFalse() ->and(ruleAccepts(new Username(min: 2), 'ab'))->toBeTrue() ->and(ruleAccepts(new Username(max: 33), str_repeat('a', 33)))->toBeTrue(); }); @@ -119,14 +121,14 @@ // `аlice` with a Cyrillic а is visually identical to `alice`. The ASCII // control is deliberately not `admin` — that one is now refused for a // different reason and would prove nothing about the alphabet. - expect(ruleAccepts(new Username(), "\u{0430}lice"))->toBeFalse() - ->and(ruleAccepts(new Username(), 'alice'))->toBeTrue(); + expect(ruleAccepts(new Username, "\u{0430}lice"))->toBeFalse() + ->and(ruleAccepts(new Username, 'alice'))->toBeTrue(); }); it('rejects a reserved name however it is punctuated', function (string $value): void { // Comparing the literal value would let every one of these through, and // they are the same claim to anyone reading a profile page. - expect(ruleAccepts(new Username(), $value))->toBeFalse(); + expect(ruleAccepts(new Username, $value))->toBeFalse(); })->with(['admin', 'ADMIN', 'Admin', 'a.d.m.i.n', 'ad-min', 'ad_min', 'support', 'api', 'root']); it('rejects a username with a trailing newline — the reserved-list bypass', function (string $value): void { @@ -134,7 +136,7 @@ // so "admin\n" passed the shape check AND slipped past the reserved // comparison (the raw value "admin\n" !== "admin") — and can dodge a // `unique` index already holding "admin". Renders as "admin". - expect(ruleAccepts(new Username(), $value))->toBeFalse() + expect(ruleAccepts(new Username, $value))->toBeFalse() ->and(Username::passes($value))->toBeFalse(); })->with(["admin\n", "alice\n", "support\n", "a.d.m.i.n\n"]); @@ -156,7 +158,7 @@ it('can require a lowercase handle', function (): void { expect(ruleAccepts(new Username(lowercase: true), 'alice'))->toBeTrue() ->and(ruleAccepts(new Username(lowercase: true), 'Alice'))->toBeFalse() - ->and(ruleAccepts(new Username(), 'Alice'))->toBeTrue(); + ->and(ruleAccepts(new Username, 'Alice'))->toBeTrue(); }); it('takes a narrowed separator set', function (): void { @@ -182,7 +184,7 @@ it('accepts names from many scripts', function (string $value): void { // A validator that assumes ASCII, or two parts, or no punctuation, is // wrong about a large share of the world's population. - expect(ruleAccepts(new PersonName(), $value))->toBeTrue(); + expect(ruleAccepts(new PersonName, $value))->toBeTrue(); })->with([ 'Alice', "O'Neill", @@ -196,7 +198,7 @@ ]); it('rejects values that are not names', function (string $value): void { - expect(ruleAccepts(new PersonName(), $value))->toBeFalse(); + expect(ruleAccepts(new PersonName, $value))->toBeFalse(); })->with([ 'Alice2', // digits, by default 'Alice 😀', // emoji: \p{S} @@ -209,12 +211,12 @@ // Validator::presentOrRuleIsImplicit() treats a string that trims to '' // as absent, so a non-implicit rule never sees it. That is `required`'s // job, and it applies to every core format rule too. - expect(ruleAccepts(new PersonName(), ' '))->toBeTrue() - ->and(Validator::make(['f' => ' '], ['f' => ['required', new PersonName()]])->passes())->toBeFalse(); + expect(ruleAccepts(new PersonName, ' '))->toBeTrue() + ->and(Validator::make(['f' => ' '], ['f' => ['required', new PersonName]])->passes())->toBeFalse(); }); it('can allow digits when the domain genuinely needs them', function (): void { - expect(ruleAccepts(new PersonName(), 'Henry 8'))->toBeFalse() + expect(ruleAccepts(new PersonName, 'Henry 8'))->toBeFalse() ->and(ruleAccepts(new PersonName(allowDigits: true), 'Henry 8'))->toBeTrue(); }); @@ -226,11 +228,11 @@ expect(ruleAccepts(new CaseStyle($style), $valid))->toBeTrue() ->and(ruleAccepts(new CaseStyle($style), $invalid))->toBeFalse(); })->with([ - 'camel' => [CaseStyle::CAMEL, 'helloWorld', 'HelloWorld'], + 'camel' => [CaseStyle::CAMEL, 'helloWorld', 'HelloWorld'], 'pascal' => [CaseStyle::PASCAL, 'HelloWorld', 'helloWorld'], - 'snake' => [CaseStyle::SNAKE, 'hello_world', 'hello__world'], - 'kebab' => [CaseStyle::KEBAB, 'hello-world', '-hello'], - 'title' => [CaseStyle::TITLE, 'Hello World', 'hello world'], + 'snake' => [CaseStyle::SNAKE, 'hello_world', 'hello__world'], + 'kebab' => [CaseStyle::KEBAB, 'hello-world', '-hello'], + 'title' => [CaseStyle::TITLE, 'Hello World', 'hello world'], ]); it('rejects leading, trailing and doubled separators', function (string $style, string $value): void { diff --git a/tests/Rules/Text/WordsAndSalutationTest.php b/tests/Rules/Text/WordsAndSalutationTest.php index 249c1b4..e11fd64 100644 --- a/tests/Rules/Text/WordsAndSalutationTest.php +++ b/tests/Rules/Text/WordsAndSalutationTest.php @@ -1,8 +1,10 @@ -toBeTrue() - ->and(ruleAccepts(new HtmlClean(), 'bold'))->toBeFalse(); + expect(ruleAccepts(new HtmlClean, 'plain text'))->toBeTrue() + ->and(ruleAccepts(new HtmlClean, 'bold'))->toBeFalse(); }); // ========================================================================= @@ -65,11 +67,11 @@ // ========================================================================= it('accepts common salutations in any case, with or without the dot', function (string $value): void { - expect(ruleAccepts(new Salutation(), $value))->toBeTrue(); + expect(ruleAccepts(new Salutation, $value))->toBeTrue(); })->with(['Mr', 'mr', 'Mr.', 'MRS', 'Prof.', 'Mx', 'Dr.', 'Madame']); it('rejects values that are not salutations', function (mixed $value): void { - expect(ruleAccepts(new Salutation(), $value))->toBeFalse(); + expect(ruleAccepts(new Salutation, $value))->toBeFalse(); })->with(['xyz', 'Mister John', 'M r', 12, null]); it('validates against a custom salutation list when given one', function (): void { diff --git a/tests/Rules/TrailingNewlineTest.php b/tests/Rules/TrailingNewlineTest.php index f028170..75a40b7 100644 --- a/tests/Rules/TrailingNewlineTest.php +++ b/tests/Rules/TrailingNewlineTest.php @@ -1,25 +1,27 @@ -toBeTrue() ->and(ruleAccepts($rule, $valid . "\n"))->toBeFalse(); })->with([ - 'Slug' => [fn (): object => new Slug(), 'my-slug'], - 'Jwt' => [fn (): object => new Jwt(), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.c2ln'], - 'DomainName' => [fn (): object => new DomainName(), 'example.com'], - 'PersonName' => [fn (): object => new PersonName(), 'Ada Lovelace'], - 'EthereumAddress' => [fn (): object => new EthereumAddress(), '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'], - 'SemVer' => [fn (): object => new SemVer(), '1.2.3'], - 'Subdomain' => [fn (): object => new Subdomain(), 'blog'], - 'MacAddress' => [fn (): object => new MacAddress(), '00:1a:2b:3c:4d:5e'], - 'Isbn' => [fn (): object => new Isbn(), '0306406152'], - 'Issn' => [fn (): object => new Issn(), '2049-3630'], - 'Vin' => [fn (): object => new Vin(), '11111111111111111'], - 'Iban' => [fn (): object => new Iban(), 'DE89370400440532013000'], - 'Bic' => [fn (): object => new Bic(), 'DEUTDEFF'], - 'Isin' => [fn (): object => new Isin(), 'US0378331005'], + 'Slug' => [fn (): object => new Slug, 'my-slug'], + 'Jwt' => [fn (): object => new Jwt, 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.c2ln'], + 'DomainName' => [fn (): object => new DomainName, 'example.com'], + 'PersonName' => [fn (): object => new PersonName, 'Ada Lovelace'], + 'EthereumAddress' => [fn (): object => new EthereumAddress, '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'], + 'SemVer' => [fn (): object => new SemVer, '1.2.3'], + 'Subdomain' => [fn (): object => new Subdomain, 'blog'], + 'MacAddress' => [fn (): object => new MacAddress, '00:1a:2b:3c:4d:5e'], + 'Isbn' => [fn (): object => new Isbn, '0306406152'], + 'Issn' => [fn (): object => new Issn, '2049-3630'], + 'Vin' => [fn (): object => new Vin, '11111111111111111'], + 'Iban' => [fn (): object => new Iban, 'DE89370400440532013000'], + 'Bic' => [fn (): object => new Bic, 'DEUTDEFF'], + 'Isin' => [fn (): object => new Isin, 'US0378331005'], 'CaseStyle (kebab)' => [fn (): object => new CaseStyle('kebab'), 'my-var'], ]); @@ -68,9 +70,9 @@ ->and(ruleAccepts($rule, $valid . "\n"))->toBeTrue() ->and(ruleAccepts($rule, ' ' . $valid . ' '))->toBeTrue(); })->with([ - 'PostalCode (US)' => [fn (): object => new PostalCode('US'), '12345'], - 'CssColor' => [fn (): object => new CssColor(), '#fff'], - 'MonetaryAmount' => [fn (): object => new MonetaryAmount(), '10.99'], - 'Parity (even)' => [fn (): object => new Parity('even'), '12'], + 'PostalCode (US)' => [fn (): object => new PostalCode('US'), '12345'], + 'CssColor' => [fn (): object => new CssColor, '#fff'], + 'MonetaryAmount' => [fn (): object => new MonetaryAmount, '10.99'], + 'Parity (even)' => [fn (): object => new Parity('even'), '12'], 'NationalIdentifier (US SSN)' => [fn (): object => new NationalIdentifier('US'), '212-09-4553'], ]); diff --git a/tests/Rules/Vendor/VendorIdentifierTest.php b/tests/Rules/Vendor/VendorIdentifierTest.php index 7c74e65..3deaf2c 100644 --- a/tests/Rules/Vendor/VendorIdentifierTest.php +++ b/tests/Rules/Vendor/VendorIdentifierTest.php @@ -1,35 +1,37 @@ -toBeTrue(); })->with([ - 'ga4' => [VendorIdentifier::GOOGLE_ANALYTICS, 'G-ABCDE12345'], - 'gtm' => [VendorIdentifier::GOOGLE_TAG_MANAGER, 'GTM-ABC1234'], - 'pixel 15' => [VendorIdentifier::FACEBOOK_PIXEL, '123456789012345'], - 'pixel 16' => [VendorIdentifier::FACEBOOK_PIXEL, '1234567890123456'], - 'tenant uuid' => [VendorIdentifier::MICROSOFT_TENANT, '72f988bf-86f1-41af-91ab-2d7cd011db47'], + 'ga4' => [VendorIdentifier::GOOGLE_ANALYTICS, 'G-ABCDE12345'], + 'gtm' => [VendorIdentifier::GOOGLE_TAG_MANAGER, 'GTM-ABC1234'], + 'pixel 15' => [VendorIdentifier::FACEBOOK_PIXEL, '123456789012345'], + 'pixel 16' => [VendorIdentifier::FACEBOOK_PIXEL, '1234567890123456'], + 'tenant uuid' => [VendorIdentifier::MICROSOFT_TENANT, '72f988bf-86f1-41af-91ab-2d7cd011db47'], 'tenant alias' => [VendorIdentifier::MICROSOFT_TENANT, 'common'], - 'aws' => [VendorIdentifier::AWS_REGION, 'us-east-1'], - 'aws long' => [VendorIdentifier::AWS_REGION, 'ap-southeast-4'], + 'aws' => [VendorIdentifier::AWS_REGION, 'us-east-1'], + 'aws long' => [VendorIdentifier::AWS_REGION, 'ap-southeast-4'], 'aws govcloud' => [VendorIdentifier::AWS_REGION, 'us-gov-west-1'], - 'discord' => [VendorIdentifier::DISCORD_USERNAME, 'alice.smith'], + 'discord' => [VendorIdentifier::DISCORD_USERNAME, 'alice.smith'], ]); it('rejects a plausible but wrong identifier', function (string $vendor, string $value): void { expect(VendorIdentifier::passes($value, $vendor))->toBeFalse(); })->with([ - 'ga4 too short' => [VendorIdentifier::GOOGLE_ANALYTICS, 'G-ABCDE1234'], - 'ga4 old UA form' => [VendorIdentifier::GOOGLE_ANALYTICS, 'UA-12345-1'], - 'gtm no prefix' => [VendorIdentifier::GOOGLE_TAG_MANAGER, 'ABC1234'], - 'pixel too short' => [VendorIdentifier::FACEBOOK_PIXEL, '12345'], + 'ga4 too short' => [VendorIdentifier::GOOGLE_ANALYTICS, 'G-ABCDE1234'], + 'ga4 old UA form' => [VendorIdentifier::GOOGLE_ANALYTICS, 'UA-12345-1'], + 'gtm no prefix' => [VendorIdentifier::GOOGLE_TAG_MANAGER, 'ABC1234'], + 'pixel too short' => [VendorIdentifier::FACEBOOK_PIXEL, '12345'], 'pixel non-numeric' => [VendorIdentifier::FACEBOOK_PIXEL, '12345678901234a'], 'tenant not a uuid' => [VendorIdentifier::MICROSOFT_TENANT, '72f988bf86f141af91ab2d7cd011db47'], - 'aws no digit' => [VendorIdentifier::AWS_REGION, 'us-east'], - 'aws uppercase' => [VendorIdentifier::AWS_REGION, 'US-EAST-1'], + 'aws no digit' => [VendorIdentifier::AWS_REGION, 'us-east'], + 'aws uppercase' => [VendorIdentifier::AWS_REGION, 'US-EAST-1'], 'discord uppercase' => [VendorIdentifier::DISCORD_USERNAME, 'Alice'], - 'discord dots' => [VendorIdentifier::DISCORD_USERNAME, 'alice..smith'], + 'discord dots' => [VendorIdentifier::DISCORD_USERNAME, 'alice..smith'], 'discord too short' => [VendorIdentifier::DISCORD_USERNAME, 'a'], ]); diff --git a/tests/SchemaRulesMergeTest.php b/tests/SchemaRulesMergeTest.php index b64ac09..3fe0bda 100644 --- a/tests/SchemaRulesMergeTest.php +++ b/tests/SchemaRulesMergeTest.php @@ -1,11 +1,13 @@ - */ public function rules(): array { return [ - 'shared' => FluentRule::string()->required()->in(['child']), + 'shared' => FluentRule::string()->required()->in(['child']), 'child_only' => FluentRule::string()->required(), ]; } @@ -35,12 +38,13 @@ public function rules(): array function rulesBaseWithChildSchema(): MergeRulesBaseRequest { - return new class extends MergeRulesBaseRequest { + return new class extends MergeRulesBaseRequest + { /** @return array */ public function schema(FluentSchema $rules): array { return [ - 'shared' => $rules->string()->required()->in(['child']), + 'shared' => $rules->string()->required()->in(['child']), 'child_only' => $rules->string()->required(), ]; } @@ -49,7 +53,8 @@ public function schema(FluentSchema $rules): array function traitSchemaWithBodyRules(): FormRequest { - return new class extends FormRequest { + return new class extends FormRequest + { use HasFluentRules; use ProvidesMergeSchemaViaTrait; @@ -57,7 +62,7 @@ function traitSchemaWithBodyRules(): FormRequest public function rules(): array { return [ - 'shared' => FluentRule::string()->required()->in(['body']), + 'shared' => FluentRule::string()->required()->in(['body']), 'body_only' => FluentRule::string()->required(), ]; } @@ -73,8 +78,8 @@ public function authorize(): bool it('merges base schema() and child rules(), applying both layers', function (): void { $request = bootFormRequest(schemaBaseWithChildRules(), [ - 'shared' => 'child', - 'base_only' => 'x', + 'shared' => 'child', + 'base_only' => 'x', 'child_only' => 'y', ]); @@ -89,8 +94,8 @@ public function authorize(): bool // 'base' satisfies the base schema() (in:base) but not the child rules() // (in:child); the more-derived child wins, so this must fail. bootFormRequest(schemaBaseWithChildRules(), [ - 'shared' => 'base', - 'base_only' => 'x', + 'shared' => 'base', + 'base_only' => 'x', 'child_only' => 'y', ])->validateResolved(); })->throws(ValidationException::class); @@ -99,7 +104,7 @@ public function authorize(): bool // base_only exists only on the base schema(); omitting it must fail, // proving the base layer is merged in rather than shadowed. bootFormRequest(schemaBaseWithChildRules(), [ - 'shared' => 'child', + 'shared' => 'child', 'child_only' => 'y', ])->validateResolved(); })->throws(ValidationException::class); @@ -108,8 +113,8 @@ public function authorize(): bool it('merges base rules() and child schema(), applying both layers', function (): void { $request = bootFormRequest(rulesBaseWithChildSchema(), [ - 'shared' => 'child', - 'base_only' => 'x', + 'shared' => 'child', + 'base_only' => 'x', 'child_only' => 'y', ]); @@ -122,8 +127,8 @@ public function authorize(): bool it('lets the child schema() win the shared key over the base rules()', function (): void { bootFormRequest(rulesBaseWithChildSchema(), [ - 'shared' => 'base', - 'base_only' => 'x', + 'shared' => 'base', + 'base_only' => 'x', 'child_only' => 'y', ])->validateResolved(); })->throws(ValidationException::class); @@ -132,9 +137,9 @@ public function authorize(): bool it('lets a body rules() win over a trait-provided schema()', function (): void { $request = bootFormRequest(traitSchemaWithBodyRules(), [ - 'shared' => 'body', + 'shared' => 'body', 'trait_only' => 'x', - 'body_only' => 'y', + 'body_only' => 'y', ]); $request->validateResolved(); @@ -148,16 +153,17 @@ public function authorize(): bool // 'trait' satisfies the trait schema() but not the body rules(); the body // definition is more specific than the trait import, so it wins → fail. bootFormRequest(traitSchemaWithBodyRules(), [ - 'shared' => 'trait', + 'shared' => 'trait', 'trait_only' => 'x', - 'body_only' => 'y', + 'body_only' => 'y', ])->validateResolved(); })->throws(ValidationException::class); // --- RuleSet returns from either source merge as a union ---------------------- it('merges when both sources return a RuleSet', function (): void { - $request = new class extends FormRequest { + $request = new class extends FormRequest + { use HasFluentRules; public function schema(FluentSchema $rules): RuleSet @@ -190,7 +196,8 @@ public function authorize(): bool // then combines that with the child schema(). This pins the boundary: a // runtime parent:: call is fine — only renaming the base's rules() away // (a migration step) would break it. - $request = new class extends MergeRulesBaseRequest { + $request = new class extends MergeRulesBaseRequest + { /** @return array */ public function rules(): array { @@ -208,9 +215,9 @@ public function schema(FluentSchema $rules): array }; $booted = bootFormRequest($request, [ - 'shared' => 'base', - 'base_only' => 'x', - 'child_only' => 'y', + 'shared' => 'base', + 'base_only' => 'x', + 'child_only' => 'y', 'schema_only' => 'z', ]); $booted->validateResolved(); diff --git a/tests/Schemas/PersonNameSchemaTest.php b/tests/Schemas/PersonNameSchemaTest.php index 5151ef2..2e41c22 100644 --- a/tests/Schemas/PersonNameSchemaTest.php +++ b/tests/Schemas/PersonNameSchemaTest.php @@ -1,9 +1,11 @@ - [], - 'all empty strings' => ['first_name' => '', 'middle_name' => '', 'last_name' => ''], - 'all null' => ['first_name' => null, 'middle_name' => null, 'last_name' => null], - 'whitespace only' => ['first_name' => ' ', 'middle_name' => "\t", 'last_name' => ' '], + 'no keys at all' => [], + 'all empty strings' => ['first_name' => '', 'middle_name' => '', 'last_name' => ''], + 'all null' => ['first_name' => null, 'middle_name' => null, 'last_name' => null], + 'whitespace only' => ['first_name' => ' ', 'middle_name' => "\t", 'last_name' => ' '], 'only unrelated keys' => ['email' => 'a@b.test'], ]; @@ -42,13 +44,13 @@ // path the application actually uses. The validator has to agree with the // schema exactly: anything holding one of the three must validate. $combinations = [ - 'first only' => ['first_name' => 'Ada'], - 'middle only' => ['middle_name' => 'Prince'], - 'last only' => ['last_name' => 'Bono'], + 'first only' => ['first_name' => 'Ada'], + 'middle only' => ['middle_name' => 'Prince'], + 'last only' => ['last_name' => 'Bono'], 'first and middle' => ['first_name' => 'Ada', 'middle_name' => 'Byron'], - 'first and last' => ['first_name' => 'Ada', 'last_name' => 'Lovelace'], - 'middle and last' => ['middle_name' => 'Byron', 'last_name' => 'Lovelace'], - 'all three' => ['first_name' => 'Ada', 'middle_name' => 'Byron', 'last_name' => 'Lovelace'], + 'first and last' => ['first_name' => 'Ada', 'last_name' => 'Lovelace'], + 'middle and last' => ['middle_name' => 'Byron', 'last_name' => 'Lovelace'], + 'all three' => ['first_name' => 'Ada', 'middle_name' => 'Byron', 'last_name' => 'Lovelace'], ]; $schema = PersonNameSchema::make(); @@ -117,7 +119,7 @@ // given names, or a patronymic, or one name, is not an edge case — the // three-column split is a guess about a naming culture. $shapes = [ - 'two fields' => [['given_name', 'family_name'], ['family_name' => 'Mokoena']], + 'two fields' => [['given_name', 'family_name'], ['family_name' => 'Mokoena']], 'four fields' => [ ['first_name', 'second_name', 'third_name', 'last_name'], ['second_name' => 'Byron', 'last_name' => 'Lovelace'], @@ -148,11 +150,11 @@ it('normalises exactly the fields it was declared with', function (): void { expect(PersonNameSchema::make('given_name', 'family_name')->normalise([ - 'given_name' => ' Ada ', + 'given_name' => ' Ada ', 'family_name' => ' ', - 'email' => 'ignored@example.test', + 'email' => 'ignored@example.test', ]))->toBe([ - 'given_name' => 'Ada', + 'given_name' => 'Ada', 'family_name' => null, ]); }); @@ -161,9 +163,9 @@ // A partial row is how an untouched optional input becomes '' in the // database: present, not null, sorting before every real value. expect(PersonNameSchema::make()->normalise([]))->toBe([ - 'first_name' => null, + 'first_name' => null, 'middle_name' => null, - 'last_name' => null, + 'last_name' => null, ]); }); @@ -181,13 +183,13 @@ expect(PersonNameSchema::make()->toRuleSet()->check(['first_name' => $value])->passes()) ->toBeTrue(); })->with([ - 'two' => ['Ada Byron'], - 'three' => ['Ada Byron Gordon'], - 'doubled space' => ['Ada Byron'], + 'two' => ['Ada Byron'], + 'three' => ['Ada Byron Gordon'], + 'doubled space' => ['Ada Byron'], 'compound family name' => ['Ait Ben Haddou'], - 'hyphenated' => ['Jean-Luc'], - 'apostrophe' => ["O'Neill"], - 'non-latin' => ['李 明'], + 'hyphenated' => ['Jean-Luc'], + 'apostrophe' => ["O'Neill"], + 'non-latin' => ['李 明'], ]); it('can insist on exactly one name per field', function (): void { @@ -248,9 +250,9 @@ expect(PersonNameSchema::make()->toRuleSet()->check(['first_name' => $value])->passes()) ->toBeFalse(); })->with([ - 'digits' => ['Henry 8'], - 'markup' => ['