From f09df670a0a6d098c20f7c4db4570074609f4bfc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:00:32 +0000 Subject: [PATCH 01/31] Roll back every test transaction level and add WithoutMigrationEvents Port laravel/framework#62194 and #62201 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. They share RefreshDatabase, so they land together. RefreshDatabase now rolls back with rollBack(0), so a transaction the test leaves open no longer survives into the next test. DatabaseTransactions had the same defect in a sharper form: Laravel disconnects after its one-level rollback, but Hypervel keeps the pooled connection, so the open level stayed on the wrapper until pool release. It now rolls back every level too. The new WithoutMigrationEvents attribute, on a test class or method, runs RefreshDatabase migrations with the connection event dispatchers removed. Hypervel restores the dispatchers on the resolved coroutine-owned connection objects instead of re-resolving them by name. The attribute is documented in database-testing.md. Regression tests cover open transactions under both traits with real SQLite, and the attribute at method and class level. The affected Foundation testing suites, static analysis and formatting pass. --- src/docs/database-testing.md | 15 +++++ .../Attributes/WithoutMigrationEvents.php | 18 ++++++ .../src/Testing/DatabaseTransactions.php | 4 +- .../src/Testing/RefreshDatabase.php | 36 ++++++++++- .../Traits/CanConfigureMigrationCommands.php | 22 +++++++ .../Testing/DatabaseTransactionsTest.php | 18 ++++++ .../Testing/LazilyRefreshDatabaseTest.php | 35 +++++++++++ ...reshDatabaseWithoutMigrationEventsTest.php | 62 +++++++++++++++++++ .../Testing/RefreshDatabaseTest.php | 17 +++++ 9 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 src/foundation/src/Testing/Attributes/WithoutMigrationEvents.php create mode 100644 tests/Foundation/Testing/LazilyRefreshDatabaseWithoutMigrationEventsTest.php diff --git a/src/docs/database-testing.md b/src/docs/database-testing.md index 269001c7eb..978fc9948e 100644 --- a/src/docs/database-testing.md +++ b/src/docs/database-testing.md @@ -127,6 +127,21 @@ class ExampleTest extends TestCase The trait monitors every connection returned by your test's `connectionsToTransact` method. The first query or transaction on any of these connections refreshes the database once and starts the test transactions. +Since the database may be migrated in the middle of a test, your query listeners could receive the migration's queries. To migrate without dispatching database events on these connections, add the `WithoutMigrationEvents` attribute to the test class or test method. The attribute also works with the `RefreshDatabase` trait: + +```php +use Hypervel\Foundation\Testing\Attributes\WithoutMigrationEvents; +use Hypervel\Foundation\Testing\LazilyRefreshDatabase; + +#[WithoutMigrationEvents] +class ExampleTest extends TestCase +{ + use LazilyRefreshDatabase; + + // ... +} +``` + ### Refresh Hooks diff --git a/src/foundation/src/Testing/Attributes/WithoutMigrationEvents.php b/src/foundation/src/Testing/Attributes/WithoutMigrationEvents.php new file mode 100644 index 0000000000..22b15f96b8 --- /dev/null +++ b/src/foundation/src/Testing/Attributes/WithoutMigrationEvents.php @@ -0,0 +1,18 @@ +forgetRecordModificationState(); } - $connection->rollBack(); + // Laravel disconnects after rolling back one level. Hypervel keeps the connection + // open, so roll back every level, including transactions the test left open. + $connection->rollBack(0); } finally { if ($dispatcher !== null) { $connection->setEventDispatcher($dispatcher); diff --git a/src/foundation/src/Testing/RefreshDatabase.php b/src/foundation/src/Testing/RefreshDatabase.php index a4ea2dc42d..573bbeb61a 100644 --- a/src/foundation/src/Testing/RefreshDatabase.php +++ b/src/foundation/src/Testing/RefreshDatabase.php @@ -134,7 +134,11 @@ protected function refreshTestDatabase(): void ) { RefreshDatabaseState::$migrated = false; - $this->migrateDatabases(); + if ($this->shouldDispatchMigrationEvents()) { + $this->migrateDatabases(); + } else { + $this->migrateDatabasesWithoutEvents(); + } $this->app->make(Kernel::class)->setArtisan(null); @@ -211,6 +215,34 @@ protected function migrateDatabases(): void $this->artisan('migrate:fresh', $this->migrateFreshUsing()); } + /** + * Migrate the database without dispatching database events. + */ + protected function migrateDatabasesWithoutEvents(): void + { + $database = $this->app->make('db'); + + $dispatchers = []; + + foreach ($this->connectionsToTransact() as $name) { + $connection = $database->connection($name); + + $dispatchers[] = [$connection, $connection->getEventDispatcher()]; + + $connection->unsetEventDispatcher(); + } + + try { + $this->migrateDatabases(); + } finally { + foreach ($dispatchers as [$connection, $dispatcher]) { + if ($dispatcher !== null) { + $connection->setEventDispatcher($dispatcher); + } + } + } + } + /** * Start database transaction in the test coroutine. * @@ -276,7 +308,7 @@ protected function rollbackDatabaseTransactionWork(): void $connection->forgetRecordModificationState(); } - $connection->rollBack(); + $connection->rollBack(0); } finally { if ($dispatcher !== null) { $connection->setEventDispatcher($dispatcher); diff --git a/src/foundation/src/Testing/Traits/CanConfigureMigrationCommands.php b/src/foundation/src/Testing/Traits/CanConfigureMigrationCommands.php index 608eeba28d..886fd9fbfe 100644 --- a/src/foundation/src/Testing/Traits/CanConfigureMigrationCommands.php +++ b/src/foundation/src/Testing/Traits/CanConfigureMigrationCommands.php @@ -6,7 +6,9 @@ use Hypervel\Foundation\Testing\Attributes\Seed; use Hypervel\Foundation\Testing\Attributes\Seeder; +use Hypervel\Foundation\Testing\Attributes\WithoutMigrationEvents; use ReflectionClass; +use ReflectionMethod; trait CanConfigureMigrationCommands { @@ -58,6 +60,26 @@ protected function shouldSeed(): bool return property_exists($this, 'seed') ? $this->seed : false; } + /** + * Determine if database events should be dispatched while migrating the database. + */ + protected function shouldDispatchMigrationEvents(): bool + { + if ((new ReflectionMethod(static::class, $this->name()))->getAttributes(WithoutMigrationEvents::class) !== []) { + return false; + } + + $class = new ReflectionClass($this); + + do { + if ($class->getAttributes(WithoutMigrationEvents::class) !== []) { + return false; + } + } while ($class = $class->getParentClass()); + + return true; + } + /** * Determine the specific seeder class that should be used when refreshing the database. */ diff --git a/tests/Foundation/Testing/DatabaseTransactionsTest.php b/tests/Foundation/Testing/DatabaseTransactionsTest.php index 4a57e76be4..0b41a680f9 100644 --- a/tests/Foundation/Testing/DatabaseTransactionsTest.php +++ b/tests/Foundation/Testing/DatabaseTransactionsTest.php @@ -9,9 +9,11 @@ use Hypervel\Database\Connection; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\DatabaseManager; +use Hypervel\Database\SQLiteConnection; use Hypervel\Foundation\Testing\DatabaseTransactions; use Hypervel\Tests\TestCase; use Mockery as m; +use PDO; use RuntimeException; class DatabaseTransactionsTest extends TestCase @@ -36,6 +38,22 @@ public function testBeginTransactionRestoresTheDispatcherWhenItFails(): void } } + public function testTestTransactionIsRolledBackWhenTheTestLeavesATransactionOpen(): void + { + $pdo = new PDO('sqlite::memory:'); + $connection = new SQLiteConnection($pdo, ':memory:'); + $testCase = $this->createTestCase($connection); + + // The wrapper transaction, followed by one the test leaves open. + $connection->beginTransaction(); + $connection->beginTransaction(); + + $testCase->rollbackDatabaseTransactionWork(); + + $this->assertFalse($pdo->inTransaction()); + $this->assertSame(0, $connection->transactionLevel()); + } + public function testRollbackRestoresTheDispatcherWhenItFails(): void { $failure = new RuntimeException('Transaction rollback failed.'); diff --git a/tests/Foundation/Testing/LazilyRefreshDatabaseTest.php b/tests/Foundation/Testing/LazilyRefreshDatabaseTest.php index 0a2ad80cab..9c4fe8c6ed 100644 --- a/tests/Foundation/Testing/LazilyRefreshDatabaseTest.php +++ b/tests/Foundation/Testing/LazilyRefreshDatabaseTest.php @@ -6,6 +6,8 @@ use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Database\DatabaseManager; +use Hypervel\Foundation\Testing\Attributes\WithoutMigrationEvents; use Hypervel\Foundation\Testing\LazilyRefreshDatabase; use Hypervel\Foundation\Testing\RefreshDatabaseState; use Hypervel\Testbench\Attributes\ResetRefreshDatabaseState; @@ -14,6 +16,7 @@ use Hypervel\Tests\Foundation\Testing\Fixtures\ConsoleKernelSpy; use Mockery as m; use RuntimeException; +use Symfony\Component\Console\Output\OutputInterface; use Throwable; #[ResetRefreshDatabaseState] @@ -155,6 +158,38 @@ public function testLazyTeardownRunsOnlyAfterSuccessfulRefresh(): void RefreshDatabaseState::$lazilyRefreshed = false; } + #[WithoutMigrationEvents] + public function testMigrationQueriesAreNotDispatchedToTheTestMethod(): void + { + $database = $this->app->make('db'); + + $this->app->instance(KernelContract::class, $kernel = new class($database) extends ConsoleKernelSpy { + public function __construct(protected DatabaseManager $database) + { + } + + public function call(string $command, array $parameters = [], ?OutputInterface $outputBuffer = null): int + { + $this->database->connection('testing')->select('select 2'); + + return parent::call($command, $parameters, $outputBuffer); + } + }); + + $queries = []; + + $database->listen(function ($query) use (&$queries) { + $queries[] = $query->sql; + }); + + $this->refreshDatabase(); + $database->connection('testing')->select('select 1'); + + $this->assertSame(['select 1'], $queries); + // The refresh state is shared across tests, so prove the migration ran here. + $this->assertCount(1, $kernel->calls); + } + protected function refreshTestDatabase(): void { ++$this->refreshCount; diff --git a/tests/Foundation/Testing/LazilyRefreshDatabaseWithoutMigrationEventsTest.php b/tests/Foundation/Testing/LazilyRefreshDatabaseWithoutMigrationEventsTest.php new file mode 100644 index 0000000000..d5894dbd74 --- /dev/null +++ b/tests/Foundation/Testing/LazilyRefreshDatabaseWithoutMigrationEventsTest.php @@ -0,0 +1,62 @@ +make('config')->set('database.default', 'testing'); + } + + public function testMigrationQueriesAreNotDispatchedToTheTest(): void + { + $database = $this->app->make('db'); + + $this->app->instance(KernelContract::class, $kernel = new class($database) extends ConsoleKernelSpy { + public function __construct(protected DatabaseManager $database) + { + } + + public function call(string $command, array $parameters = [], ?OutputInterface $outputBuffer = null): int + { + $this->database->connection('testing')->select('select 2'); + + return parent::call($command, $parameters, $outputBuffer); + } + }); + + $queries = []; + + $database->listen(function ($query) use (&$queries) { + $queries[] = $query->sql; + }); + + $this->refreshDatabase(); + $database->connection('testing')->select('select 1'); + + $this->assertSame(['select 1'], $queries); + // The refresh state is shared across tests, so prove the migration ran here. + $this->assertCount(1, $kernel->calls); + } +} diff --git a/tests/Foundation/Testing/RefreshDatabaseTest.php b/tests/Foundation/Testing/RefreshDatabaseTest.php index 08c668ed61..ee86e81e5e 100644 --- a/tests/Foundation/Testing/RefreshDatabaseTest.php +++ b/tests/Foundation/Testing/RefreshDatabaseTest.php @@ -197,6 +197,23 @@ public function testRefreshTestDatabaseWithSeederOption(): void $this->assertSame([null], $kernel->setArtisanCalls); } + public function testTestTransactionIsRolledBackWhenTheTestLeavesATransactionOpen(): void + { + config()->set('database.default', 'testing'); + config()->set('database.connections.testing', ['driver' => 'sqlite', 'database' => ':memory:']); + $this->app->instance(KernelContract::class, new ConsoleKernelSpy); + + $this->refreshTestDatabase(); + + $pdo = $this->app->make('db')->connection()->getPdo(); + + $this->app->make('db')->connection()->beginTransaction(); + + $this->callBeforeApplicationDestroyedCallbacks(); + + $this->assertFalse($pdo->inTransaction()); + } + public function testRefreshTestDatabaseRestoresMockConsoleOutputAfterMigrationFailure(): void { $this->mockConsoleOutput = true; From 8deb1d0900abf46c23fad8176ff904c397ca8640 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:00:37 +0000 Subject: [PATCH 02/31] Remove dead guards around test method reflection PHPUnit only constructs test cases for methods that exist, so reflecting on the current test method cannot fail. TestCase::withoutBootingFramework() caught any Throwable and defaulted to booting the framework; the catch is gone and the check is memoized directly. Its only test built a test case for a missing method, which PHPUnit never does, so it is removed. Testbench's database truncation hook resolved the method name through a helper that returns null only outside PHPUnit, which cannot happen in a PHPUnit test case. It now reads the name directly. Found alongside the reflection check added for laravel/framework#62201. The Foundation testing suites, Testbench database tests, the Testbench package:test contract suite and static analysis pass. --- src/foundation/src/Testing/TestCase.php | 10 +--------- src/testbench/src/TestCase.php | 5 +---- tests/Foundation/Testing/UnitTestTest.php | 7 ------- 3 files changed, 2 insertions(+), 20 deletions(-) diff --git a/src/foundation/src/Testing/TestCase.php b/src/foundation/src/Testing/TestCase.php index 0e1d084cac..bffd593416 100644 --- a/src/foundation/src/Testing/TestCase.php +++ b/src/foundation/src/Testing/TestCase.php @@ -164,15 +164,7 @@ protected function shouldBootFrameworkForTest(): bool */ protected function withoutBootingFramework(): bool { - if ($this->withoutBootingFramework !== null) { - return $this->withoutBootingFramework; - } - - try { - return $this->withoutBootingFramework = (new ReflectionMethod(static::class, $this->name()))->getAttributes(UnitTest::class) !== []; - } catch (Throwable) { - return $this->withoutBootingFramework = false; - } + return $this->withoutBootingFramework ??= (new ReflectionMethod(static::class, $this->name()))->getAttributes(UnitTest::class) !== []; } /** diff --git a/src/testbench/src/TestCase.php b/src/testbench/src/TestCase.php index 6aace84042..33607175b2 100644 --- a/src/testbench/src/TestCase.php +++ b/src/testbench/src/TestCase.php @@ -199,10 +199,7 @@ protected function prepareDatabaseTruncationForMethod(array $uses): void return; } - $method = $this->resolvePhpUnitTestMethodName(); - - if ($method === null - || (new ReflectionMethod(static::class, $method))->getAttributes(WithMigration::class) === []) { + if ((new ReflectionMethod(static::class, $this->name()))->getAttributes(WithMigration::class) === []) { return; } diff --git a/tests/Foundation/Testing/UnitTestTest.php b/tests/Foundation/Testing/UnitTestTest.php index 765a18cf71..689a3bc310 100644 --- a/tests/Foundation/Testing/UnitTestTest.php +++ b/tests/Foundation/Testing/UnitTestTest.php @@ -38,13 +38,6 @@ public function testUnannotatedTestShouldBootFramework(): void $this->assertTrue($testCase->shouldBootFramework()); } - public function testMissingMethodDefaultsToBootingFramework(): void - { - $testCase = new UnitTestFoundationTestCase('missingMethod'); - - $this->assertTrue($testCase->shouldBootFramework()); - } - public function testUnitTestStillRunsInsideCoroutine(): void { $testCase = new UnitTestFoundationTestCase('unitMethod'); From aa6cb148bfab4e0144c33d272ac5d9611e72da26 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:00:42 +0000 Subject: [PATCH 03/31] Cover GMT conversion of the down --retry datetime Port the test from laravel/framework#62190 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. DownCommand already formats the retry time with toRfc7231String(), which converts to GMT, so no source change is needed. The upstream cases (a non-UTC default timezone with naive and offset datetimes) are merged into the existing named-timezone test. The test file passes. --- .../Foundation/MaintenanceModeTest.php | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/Integration/Foundation/MaintenanceModeTest.php b/tests/Integration/Foundation/MaintenanceModeTest.php index 90f6d59d84..8b5b60a44c 100644 --- a/tests/Integration/Foundation/MaintenanceModeTest.php +++ b/tests/Integration/Foundation/MaintenanceModeTest.php @@ -538,11 +538,31 @@ public static function retryAfterDatetimeProvider(): array public function testMaintenanceModeRetryDatetimeIsConvertedToGmt(): void { - $this->artisan(DownCommand::class, ['--retry' => '2023-01-08 12:00:00 Australia/Sydney']); + $timezone = date_default_timezone_get(); - $data = json_decode(file_get_contents(storage_path('framework/down')), true); + date_default_timezone_set('Europe/Berlin'); + + try { + $this->artisan(DownCommand::class, ['--retry' => '2026-12-31 15:30:00']); + + $data = json_decode(file_get_contents(storage_path('framework/down')), true); + + $this->assertSame('Thu, 31 Dec 2026 14:30:00 GMT', $data['retry']); + + $this->artisan(DownCommand::class, ['--retry' => '2026-12-31T15:30:00+02:00']); - $this->assertSame('Sun, 08 Jan 2023 01:00:00 GMT', $data['retry']); + $data = json_decode(file_get_contents(storage_path('framework/down')), true); + + $this->assertSame('Thu, 31 Dec 2026 13:30:00 GMT', $data['retry']); + + $this->artisan(DownCommand::class, ['--retry' => '2023-01-08 12:00:00 Australia/Sydney']); + + $data = json_decode(file_get_contents(storage_path('framework/down')), true); + + $this->assertSame('Sun, 08 Jan 2023 01:00:00 GMT', $data['retry']); + } finally { + date_default_timezone_set($timezone); + } } public function testMaintenanceModeRetryWithHttpDateHeader(): void From 43845490575fe10131cda357b0c1d0096f7a7bea Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:00:42 +0000 Subject: [PATCH 04/31] Read bus job attributes through ReadsClassAttributes directly Port laravel/framework#62182 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. The bus Dispatcher, DebounceLock and UniqueLock now use the support ReadsClassAttributes trait directly instead of going through the queue package. Hypervel's bus package requires the queue package, so the upstream fatal error never occurred here; this keeps the classes aligned with upstream. Upstream changed no tests. The bus test suite passes. --- src/bus/src/DebounceLock.php | 4 ++-- src/bus/src/Dispatcher.php | 4 ++-- src/bus/src/UniqueLock.php | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/bus/src/DebounceLock.php b/src/bus/src/DebounceLock.php index 70591dc113..97b468cd20 100644 --- a/src/bus/src/DebounceLock.php +++ b/src/bus/src/DebounceLock.php @@ -7,14 +7,14 @@ use Hypervel\Contracts\Cache\Repository as Cache; use Hypervel\Coroutine\Coroutine; use Hypervel\Queue\Attributes\DebounceFor; -use Hypervel\Queue\Attributes\ReadsQueueAttributes; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Str; +use Hypervel\Support\Traits\ReadsClassAttributes; use Throwable; class DebounceLock { - use ReadsQueueAttributes; + use ReadsClassAttributes; /** * Create a new debounce lock manager instance. diff --git a/src/bus/src/Dispatcher.php b/src/bus/src/Dispatcher.php index 304b5e154b..c3d43c45a3 100644 --- a/src/bus/src/Dispatcher.php +++ b/src/bus/src/Dispatcher.php @@ -15,16 +15,16 @@ use Hypervel\Queue\Attributes\Connection; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Attributes\Queue as QueueAttribute; -use Hypervel\Queue\Attributes\ReadsQueueAttributes; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\Jobs\SyncJob; use Hypervel\Support\Collection; use Hypervel\Support\Queue\Concerns\ResolvesQueueRoutes; +use Hypervel\Support\Traits\ReadsClassAttributes; use RuntimeException; class Dispatcher implements QueueingDispatcher { - use ReadsQueueAttributes; + use ReadsClassAttributes; use ResolvesQueueRoutes; /** diff --git a/src/bus/src/UniqueLock.php b/src/bus/src/UniqueLock.php index 03fcbd5c31..e0f2326b2a 100644 --- a/src/bus/src/UniqueLock.php +++ b/src/bus/src/UniqueLock.php @@ -7,12 +7,12 @@ use Hypervel\Cache\Repository; use Hypervel\Contracts\Cache\LockProvider; use Hypervel\Contracts\Cache\Repository as Cache; -use Hypervel\Queue\Attributes\ReadsQueueAttributes; use Hypervel\Queue\Attributes\UniqueFor; +use Hypervel\Support\Traits\ReadsClassAttributes; class UniqueLock { - use ReadsQueueAttributes; + use ReadsClassAttributes; /** * Create a new unique lock manager instance. From f84cc3ff465f99a7030a7ba4b2467dc01f483be3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:00:52 +0000 Subject: [PATCH 05/31] Add size methods to the array rule builder Port laravel/framework#62179 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. Rule::array() gains between(), exactly(), max() and min(), which append size constraints after the array rule. Upstream's parser splits the builder's string on pipes, which breaks quoted keys that contain one. Like Hypervel's other composite rules, ArrayRule now provides toArray() and the parser uses it, so keys such as 'a|b' survive. The builder is documented in validation.md, which had no Rule::array() coverage upstream. The upstream ArrayRule and rule parser tests are ported, with keyed cases adapted to Hypervel's quoted keys, plus a pipe-in-key case that fails with upstream's split. Both test files pass. --- src/docs/validation.md | 16 +++++ src/validation/src/Rules/ArrayRule.php | 71 +++++++++++++++++++ src/validation/src/ValidationRuleParser.php | 5 +- tests/Validation/ValidationArrayRuleTest.php | 44 ++++++++++++ tests/Validation/ValidationRuleParserTest.php | 42 +++++++++++ 5 files changed, 176 insertions(+), 2 deletions(-) diff --git a/src/docs/validation.md b/src/docs/validation.md index d92f7465c6..adc5ff2a26 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -1740,6 +1740,22 @@ Validator::make($input, [ In general, you should always specify the array keys that are allowed to be present within your array. +For convenience, array rules may also be constructed using the fluent `Rule::array()` rule builder. You may pass the allowed keys to the `array` method, and use the `min`, `max`, `between`, and `exactly` methods to limit the number of items in the array: + +```php +use Hypervel\Validation\Rule; + +'user' => [ + 'required', + Rule::array(['name', 'username', 'email'])->min(2), +], + +'tags' => [ + 'required', + Rule::array()->between(1, 5), +], +``` + #### array_keys:_foo_,_bar_,... diff --git a/src/validation/src/Rules/ArrayRule.php b/src/validation/src/Rules/ArrayRule.php index 2c74cce973..c464fad497 100644 --- a/src/validation/src/Rules/ArrayRule.php +++ b/src/validation/src/Rules/ArrayRule.php @@ -5,6 +5,7 @@ namespace Hypervel\Validation\Rules; use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Support\Arr; use Stringable; use function Hypervel\Support\enum_value; @@ -16,6 +17,11 @@ class ArrayRule implements Stringable */ protected array $keys = []; + /** + * The additional constraints for the array rule. + */ + protected array $constraints = []; + /** * Create a new array rule instance. */ @@ -28,10 +34,65 @@ public function __construct(mixed $keys = null) $this->keys = is_array($keys) ? $keys : func_get_args(); } + /** + * The field under validation must have between the given min and max number of items (inclusive). + * + * @param non-negative-int $min + * @param non-negative-int $max + */ + public function between(int $min, int $max): static + { + return $this->addRule('between:' . $min . ',' . $max); + } + + /** + * The field under validation must have exactly the given number of items. + */ + public function exactly(int $value): static + { + return $this->addRule('size:' . $value); + } + + /** + * The field under validation must not have more than the given number of items. + * + * @param non-negative-int $value + */ + public function max(int $value): static + { + return $this->addRule('max:' . $value); + } + + /** + * The field under validation must have at least the given number of items. + */ + public function min(int $value): static + { + return $this->addRule('min:' . $value); + } + /** * Convert the rule to a validation string. */ public function __toString(): string + { + return implode('|', $this->toArray()); + } + + /** + * Convert the rule to an array of validation rules. + * + * @return list + */ + public function toArray(): array + { + return array_values(array_unique([$this->arrayRule(), ...$this->constraints])); + } + + /** + * Get the array rule with its accepted keys. + */ + protected function arrayRule(): string { if (empty($this->keys)) { return 'array'; @@ -44,4 +105,14 @@ public function __toString(): string return 'array:' . implode(',', $keys); } + + /** + * Add custom rules to the validation rules array. + */ + protected function addRule(array|string $rules): static + { + $this->constraints = array_merge($this->constraints, Arr::wrap($rules)); + + return $this; + } } diff --git a/src/validation/src/ValidationRuleParser.php b/src/validation/src/ValidationRuleParser.php index ce88056849..d7ea0937cf 100644 --- a/src/validation/src/ValidationRuleParser.php +++ b/src/validation/src/ValidationRuleParser.php @@ -14,6 +14,7 @@ use Hypervel\Support\Collection; use Hypervel\Support\Str; use Hypervel\Support\StrCache; +use Hypervel\Validation\Rules\ArrayRule; use Hypervel\Validation\Rules\Date; use Hypervel\Validation\Rules\Exists; use Hypervel\Validation\Rules\Numeric; @@ -88,7 +89,7 @@ protected function explodeExplicitRule(mixed $rule, string $attribute): array } if (is_object($rule)) { - if ($rule instanceof Date || $rule instanceof Numeric || $rule instanceof StringRule) { + if ($rule instanceof ArrayRule || $rule instanceof Date || $rule instanceof Numeric || $rule instanceof StringRule) { // Composite rules already separate constraints; literal parameters may contain pipes. return $rule->toArray(); } @@ -99,7 +100,7 @@ protected function explodeExplicitRule(mixed $rule, string $attribute): array $rules = []; foreach ($rule as $value) { - if ($value instanceof Date || $value instanceof Numeric || $value instanceof StringRule) { + if ($value instanceof ArrayRule || $value instanceof Date || $value instanceof Numeric || $value instanceof StringRule) { $rules = array_merge($rules, $value->toArray()); } else { $rules[] = $this->prepareRule($value, $attribute); diff --git a/tests/Validation/ValidationArrayRuleTest.php b/tests/Validation/ValidationArrayRuleTest.php index e2cbd83bc0..e66832f59f 100644 --- a/tests/Validation/ValidationArrayRuleTest.php +++ b/tests/Validation/ValidationArrayRuleTest.php @@ -51,10 +51,54 @@ public function testItCorrectlyFormatsAStringVersionOfTheRule(): void $this->assertSame('array:"1","2","3"', (string) $rule); } + public function testItCorrectlyFormatsTheSizeConstraints(): void + { + $this->assertSame('array|min:1', (string) Rule::array()->min(1)); + $this->assertSame('array|max:5', (string) Rule::array()->max(5)); + $this->assertSame('array|between:1,5', (string) Rule::array()->between(1, 5)); + $this->assertSame('array|size:3', (string) Rule::array()->exactly(3)); + $this->assertSame('array|min:1|max:5', (string) Rule::array()->min(1)->max(5)); + $this->assertSame('array|min:1', (string) Rule::array()->min(1)->min(1)); + $this->assertSame('array:"key_1","key_2"|min:1', (string) Rule::array(['key_1', 'key_2'])->min(1)); + } + + public function testSizeValidation(): void + { + $trans = new Translator(new ArrayLoader, 'en'); + + $v = new Validator($trans, ['foo' => []], ['foo' => Rule::array()->min(1)]); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => ['bar']], ['foo' => Rule::array()->min(1)]); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => ['bar', 'baz']], ['foo' => Rule::array()->max(1)]); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => ['bar', 'baz']], ['foo' => Rule::array()->between(1, 2)]); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => ['bar', 'baz', 'qux']], ['foo' => Rule::array()->between(1, 2)]); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => ['bar', 'baz']], ['foo' => Rule::array()->exactly(2)]); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => ['bar']], ['foo' => Rule::array()->exactly(2)]); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => ['key_1' => 'bar', 'key_2' => 'baz']], ['foo' => Rule::array(['key_1'])->min(1)]); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => ['key_1' => 'bar']], ['foo' => ['required', Rule::array(['key_1'])->max(1)]]); + $this->assertTrue($v->passes()); + } + #[TestWith(['a,b'])] #[TestWith(['a"b'])] #[TestWith(['a\\'])] #[TestWith(['a\"b'])] + #[TestWith(['a|b'])] public function testArrayRulePreservesLiteralKeys(string $key): void { $validator = new Validator( diff --git a/tests/Validation/ValidationRuleParserTest.php b/tests/Validation/ValidationRuleParserTest.php index 214ec85241..726b34118f 100644 --- a/tests/Validation/ValidationRuleParserTest.php +++ b/tests/Validation/ValidationRuleParserTest.php @@ -588,6 +588,48 @@ public function testExplodeHandlesNumericRuleWithAdditionalRules(): void ], $results->rules); } + public function testExplodeHandlesArrayRuleWithAdditionalRules(): void + { + $parser = (new ValidationRuleParser([ + 'items' => ['foo'], + ])); + + $rules = [ + 'items' => Rule::array()->min(1)->max(5), + ]; + + $results = $parser->explode($rules); + + $this->assertEquals([ + 'items' => [ + 'array', + 'min:1', + 'max:5', + ], + ], $results->rules); + } + + public function testExplodeHandlesArrayRuleWithAdditionalRulesInAnArray(): void + { + $parser = (new ValidationRuleParser([ + 'items' => ['key_1' => 'foo'], + ])); + + $rules = [ + 'items' => ['required', Rule::array(['key_1', 'key_2'])->between(1, 2)], + ]; + + $results = $parser->explode($rules); + + $this->assertEquals([ + 'items' => [ + 'required', + 'array:"key_1","key_2"', + 'between:1,2', + ], + ], $results->rules); + } + public function testExplodeHandlesStringRule(): void { $parser = (new ValidationRuleParser([ From 058e9a50ecfe86e8c6cd6829672d0f67c7c39800 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:00:52 +0000 Subject: [PATCH 06/31] Fix distinct:ignore_case with malformed UTF-8 and trailing newlines Port laravel/framework#62178 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. A value that is not valid UTF-8 now uses the case-sensitive comparison instead of throwing from the /u pattern. Two related defects are fixed in the same check. preg_grep() stops at the first malformed subject, so a malformed sibling hid any later case-insensitive duplicate; malformed siblings are now skipped before matching. The pattern also ended in $, which matches before a trailing newline, so 'hello' and "HELLO\n" were reported as duplicates; it now ends in \z. Both upstream cases are ported, with regressions for the hidden duplicate and the trailing newline. The validator test file passes. --- .../src/Concerns/ValidatesAttributes.php | 15 +++++++++++---- tests/Validation/ValidationValidatorTest.php | 13 +++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php index 9cb1f7588c..6a100aa9f5 100644 --- a/src/validation/src/Concerns/ValidatesAttributes.php +++ b/src/validation/src/Concerns/ValidatesAttributes.php @@ -886,7 +886,11 @@ public function validateDistinct(string $attribute, mixed $value, mixed $paramet return true; } - if (! is_string($value) || ! in_array('ignore_case', $parameters, true)) { + $ignoreCase = in_array('ignore_case', $parameters, true) + && is_string($value) + && mb_check_encoding($value, 'UTF-8'); + + if (! $ignoreCase) { $primary = $this->getPrimaryAttribute($attribute); if (! array_key_exists($primary, $this->distinctValueCounts)) { @@ -921,14 +925,17 @@ public function validateDistinct(string $attribute, mixed $value, mixed $paramet $data = Arr::except($values, $attribute); - if (in_array('ignore_case', $parameters, true) && is_string($value)) { + if ($ignoreCase) { foreach ($data as $key => $other) { - if (! is_scalar($other) && $other !== null && ! $other instanceof Stringable) { + // preg_grep() stops at the first malformed UTF-8 subject, hiding any later duplicates. + if ((! is_scalar($other) && $other !== null && ! $other instanceof Stringable) + || ! mb_check_encoding((string) $other, 'UTF-8')) { unset($data[$key]); } } - return empty(preg_grep('/^' . preg_quote($value, '/') . '$/iu', $data)); + // \z rather than $, which also matches before a trailing newline. + return empty(preg_grep('/^' . preg_quote($value, '/') . '\z/iu', $data)); } return ! in_array($value, array_values($data), in_array('strict', $parameters, true)); diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index 9e00666d0d..984caa65e9 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -4709,6 +4709,9 @@ public function testValidateDistinct(): void $v = new Validator($trans, ['foo' => ['1', '11']], ['foo.*' => 'distinct:ignore_case']); $this->assertTrue($v->passes()); + $v = new Validator($trans, ['foo' => ['hello', "HELLO\n"]], ['foo.*' => 'distinct:ignore_case']); + $this->assertTrue($v->passes()); + $v = new Validator($trans, ['foo' => ['foo', 'bar']], ['foo.*' => 'distinct']); $this->assertTrue($v->passes()); @@ -4777,6 +4780,16 @@ public function testValidateDistinct(): void $v = new Validator($trans, ['users' => [['name' => ['John']], ['name' => ['john']]]], ['users.*.name' => 'distinct:ignore_case']); $this->assertTrue($v->passes()); + $v = new Validator($trans, ['foo' => ["caf\xE9", 'bar']], ['foo.*' => 'distinct:ignore_case']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => ["caf\xE9", "caf\xE9"]], ['foo.*' => 'distinct:ignore_case']); + $this->assertFalse($v->passes()); + + // A malformed sibling must not stop the case-insensitive search for later duplicates. + $v = new Validator($trans, ['foo' => ["caf\xE9", 'BAR', 'bar']], ['foo.*' => 'distinct:ignore_case']); + $this->assertSame(['foo.1', 'foo.2'], $v->errors()->keys()); + $v = new Validator($trans, ['values' => ['hello', []]], ['values.*' => 'distinct:ignore_case']); $this->assertTrue($v->passes()); From 724f440896de3d7521944a88c943477ca9309dd2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:01:04 +0000 Subject: [PATCH 07/31] Report reconnect driver failures as query exceptions Port the test from laravel/framework#62176 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. ApiAuthenticationWithEloquentTest had never been ported; it now lives at its upstream path, uses the CI MySQL port, and runs in the MySQL database jobs. The test exposed a Hypervel defect. Laravel reconnects lazily, so a failed connect surfaces from query execution as a QueryException. Hypervel reconnects pooled connections eagerly, so the driver's PDOException escaped raw from the missing-connection reconnect and the lost-connection retry. Both paths in run() and runStreaming() now go through reconnectForQuery(), which reports driver errors as a QueryException for the query while leaving query exceptions, cancellation and non-driver errors unchanged. Connection::isDriverError() decides what counts as a driver error. PdoConnection recognizes PDOException and native PDO and mysqlnd warnings; custom drivers can override it, as database.md now explains. Regressions cover buffered and streaming missing-connection failures, a failed lost-connection reconnect, query exception identity, cancellation and the PDO classification. The auth test passes against MySQL. The database and SQLite integration suites, the MySQL, MariaDB and PostgreSQL suites, and static analysis pass. --- .github/workflows/databases.yml | 12 +- src/database/src/Connection.php | 48 ++++- src/database/src/PdoConnection.php | 19 ++ src/docs/database.md | 2 + tests/Database/DatabaseConnectionTest.php | 200 ++++++++++++++++++ .../ApiAuthenticationWithEloquentTest.php | 64 ++++++ 6 files changed, 337 insertions(+), 8 deletions(-) create mode 100644 tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php diff --git a/.github/workflows/databases.yml b/.github/workflows/databases.yml index eff51e4f45..6883abded3 100644 --- a/.github/workflows/databases.yml +++ b/.github/workflows/databases.yml @@ -56,7 +56,9 @@ jobs: DB_DATABASE: testing DB_USERNAME: root DB_PASSWORD: password - run: bin/run-database-tests.sh mysql + run: | + bin/run-database-tests.sh mysql + vendor/bin/phpunit --no-progress tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php mysql_84: runs-on: ubuntu-latest @@ -104,7 +106,9 @@ jobs: DB_DATABASE: testing DB_USERNAME: root DB_PASSWORD: password - run: bin/run-database-tests.sh mysql + run: | + bin/run-database-tests.sh mysql + vendor/bin/phpunit --no-progress tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php mysql_97: runs-on: ubuntu-latest @@ -152,7 +156,9 @@ jobs: DB_DATABASE: testing DB_USERNAME: root DB_PASSWORD: password - run: bin/run-database-tests.sh mysql + run: | + bin/run-database-tests.sh mysql + vendor/bin/phpunit --no-progress tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php mariadb_1011: runs-on: ubuntu-latest diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 45562c7ba7..afd6e80d4c 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -572,14 +572,16 @@ protected function run(string $query, array $bindings, Closure $callback): mixed $beforeExecutingCallback($query, $bindings, $this); } - $this->reconnectIfMissingConnection(); - $start = hrtime(true) / 1e9; // Here we will run this query. If an exception occurs we'll determine if it was // caused by a connection that has been lost. If that is the cause, we'll try // to re-establish connection and re-run the query with a fresh connection. try { + if (! $this->hasDriverResources()) { + $this->reconnectForQuery($query, $bindings); + } + try { $result = $this->runQueryCallback($query, $bindings, $callback); } catch (QueryException $e) { @@ -648,8 +650,6 @@ protected function runStreaming(string $query, array $bindings, Closure $callbac $beforeExecutingCallback($query, $bindings, $this); } - $this->reconnectIfMissingConnection(); - $start = hrtime(true) / 1e9; $hasYielded = false; @@ -680,6 +680,10 @@ protected function runStreaming(string $query, array $bindings, Closure $callbac }; try { + if (! $this->hasDriverResources()) { + $this->reconnectForQuery($query, $bindings); + } + try { yield from $execute($query, $bindings); } catch (QueryException $exception) { @@ -795,6 +799,14 @@ protected function isDataTypeError(Exception $exception): bool return false; } + /** + * Determine if the given exception was thrown by the database driver. + */ + protected function isDriverError(Exception $exception): bool + { + return false; + } + /** * Extract the index and columns that caused a unique constraint violation. * @@ -933,7 +945,7 @@ protected function tryAgainIfCausedByLostConnection(QueryException $e, string $q $this->reconnecting = true; try { - $this->reconnect(); + $this->reconnectForQuery($query, $bindings); } finally { $this->reconnecting = false; } @@ -968,6 +980,32 @@ public function reconnectIfMissingConnection(): void } } + /** + * Reconnect for the given query, reporting driver failures as query failures. + * + * Laravel reconnects lazily, so a failed connection surfaces from the query callback + * as a QueryException. Driver resources are established eagerly here instead. + * + * @throws QueryException + */ + protected function reconnectForQuery(string $query, array $bindings): void + { + try { + $this->reconnect(); + } catch (Exception $exception) { + // QueryException extends PDOException, so check it before the driver classification. + if ($exception instanceof QueryException + || Coroutine::causedByCancellation($exception) + || ! $this->isDriverError($exception)) { + throw $exception; + } + + ++$this->errorCount; + + throw $this->newQueryException($query, $bindings, $exception); + } + } + /** * Refresh the driver resources from a fresh connection. * diff --git a/src/database/src/PdoConnection.php b/src/database/src/PdoConnection.php index 0ef405c8de..4d884c4dcb 100755 --- a/src/database/src/PdoConnection.php +++ b/src/database/src/PdoConnection.php @@ -5,9 +5,13 @@ namespace Hypervel\Database; use Closure; +use ErrorException; +use Exception; use Generator; use Hypervel\Database\Events\StatementPrepared; +use Hypervel\Support\Str; use PDO; +use PDOException; use PDOStatement; use RuntimeException; use stdClass; @@ -333,6 +337,21 @@ protected function escapeString(string $value): string return $escaped; } + /** + * Determine if the given exception was thrown by the database driver. + */ + protected function isDriverError(Exception $exception): bool + { + // Error handlers convert native PDO and mysqlnd warnings into ErrorException. + return $exception instanceof PDOException + || ($exception instanceof ErrorException && Str::startsWith($exception->getMessage(), [ + 'PDO::', + 'PDOStatement::', + 'Packets out of order. Expected', + 'Error while sending', + ])); + } + /** * Get the last inserted ID. */ diff --git a/src/docs/database.md b/src/docs/database.md index c9d4b4194d..a560e7c0b9 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -572,6 +572,8 @@ If an active stream is explicitly closed, its next advancement must throw `Hyper Both execution methods construct database errors through the protected `newQueryException` method. Drivers whose parameter types need custom error formatting may override this method to return a `QueryException` subclass. Preserve the original query, bindings, and cause; exceptions that are not database failures may be rethrown unchanged. The base implementation continues to prepare bindings and enrich unique-constraint errors with the reported index or columns. It returns an `InvalidValueException` when the protected `isDataTypeError` method identifies an invalid or out-of-range value; override that method to classify your driver's errors. +When a query has to re-establish a missing or lost connection first, a failure to reconnect is reported through `newQueryException` only if the protected `isDriverError` method recognizes it as your driver's exception. Override that method so connection failures reach callers as query exceptions, just like failures from the query itself. Other reconnect failures are thrown unchanged. + Custom drivers used with database cache locks must identify unique-constraint violations through `isUniqueConstraintError` so lock contention can be handled without hiding other database failures. Query builders with statement-level options may override the protected `Query\Builder::prepareQueryForEmbedding` method to reject options that belong on the outer statement. It runs when attaching a subquery, scalar or exists predicate, or union member. Call the parent first, validate the returned query, and return it so callback preparation and the built-in rejection of embedded timeouts are preserved. diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index 2cc42a1eb9..223a6724bb 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -918,6 +918,167 @@ static function (QueryFailed $dispatched) use (&$event): bool { $this->assertSame($exception, $event->exception); } + public function testRunReportsAMissingConnectionDriverFailureAsTheQueryFailure(): void + { + $method = (new ReflectionClass(Connection::class))->getMethod('run'); + $connection = $this->getMockConnection(); + $connection->setPdo(null); + $driverFailure = new PDOException('SQLSTATE[HY000] [14] unable to open database file'); + $connection->setReconnector(static function () use ($driverFailure): never { + throw $driverFailure; + }); + $connection->setEventDispatcher($events = m::mock(Dispatcher::class)); + $event = null; + $events->expects('hasListeners')->with(QueryFailed::class)->andReturnTrue(); + $events->expects('dispatch')->withArgs( + static function (QueryFailed $dispatched) use (&$event): bool { + $event = $dispatched; + + return true; + }, + ); + $exception = null; + + try { + $method->invokeArgs($connection, [ + 'select ?', + [1], + fn (): never => $this->fail('The query must not run without a connection.'), + ]); + } catch (QueryException $thrown) { + $exception = $thrown; + } + + $this->assertInstanceOf(QueryException::class, $exception); + $this->assertSame($driverFailure, $exception->getPrevious()); + $this->assertSame('select ?', $exception->getSql()); + $this->assertSame([1], $exception->getBindings()); + $this->assertInstanceOf(QueryFailed::class, $event); + $this->assertSame($exception, $event->exception); + $this->assertSame(1, $connection->getErrorCount()); + } + + public function testRunReportsAFailedLostConnectionReconnectAsTheQueryFailure(): void + { + $method = (new ReflectionClass(Connection::class))->getMethod('run'); + $connection = $this->getMockConnection(); + $driverFailure = new PDOException('SQLSTATE[HY000] [2002] Connection refused'); + $connection->setReconnector(static function () use ($driverFailure): never { + throw $driverFailure; + }); + $connection->setEventDispatcher($events = m::mock(Dispatcher::class)); + $event = null; + $events->expects('hasListeners')->with(QueryFailed::class)->andReturnTrue(); + $events->expects('dispatch')->withArgs( + static function (QueryFailed $dispatched) use (&$event): bool { + $event = $dispatched; + + return true; + }, + ); + $exception = null; + + try { + $method->invokeArgs($connection, [ + 'select 1', + [], + static fn (): never => throw new PDOException('server has gone away'), + ]); + } catch (QueryException $thrown) { + $exception = $thrown; + } + + $this->assertInstanceOf(QueryException::class, $exception); + $this->assertSame($driverFailure, $exception->getPrevious()); + $this->assertInstanceOf(QueryFailed::class, $event); + $this->assertSame($exception, $event->exception); + $this->assertSame(2, $connection->getErrorCount()); + } + + public function testRunRethrowsAReconnectQueryExceptionWithoutCountingItAgain(): void + { + $method = (new ReflectionClass(Connection::class))->getMethod('run'); + $connection = $this->getMockConnection(); + $connection->setPdo(null); + // A ConnectionEstablished listener's query has already reported its own failure. + $listenerFailure = new QueryException('test', 'select 2', [], new PDOException('Listener query failed.')); + $connection->setReconnector(static function () use ($listenerFailure): never { + throw $listenerFailure; + }); + + try { + $method->invokeArgs($connection, [ + 'select 1', + [], + fn (): never => $this->fail('The query must not run without a connection.'), + ]); + $this->fail('Expected the listener query failure to be rethrown.'); + } catch (QueryException $exception) { + $this->assertSame($listenerFailure, $exception); + } + + $this->assertSame(0, $connection->getErrorCount()); + } + + #[DataProvider('driverCancellations')] + public function testRunPreservesCancellationDuringAQueryReconnect(Throwable $cancellation): void + { + $method = (new ReflectionClass(Connection::class))->getMethod('run'); + $connection = $this->getMockConnection(); + $connection->setPdo(null); + $connection->setReconnector(static function () use ($cancellation): never { + throw $cancellation; + }); + $connection->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldNotReceive('hasListeners')->with(QueryFailed::class); + $events->shouldNotReceive('dispatch')->with(m::type(QueryFailed::class)); + + try { + $method->invokeArgs($connection, [ + 'select 1', + [], + fn (): never => $this->fail('The query must not run without a connection.'), + ]); + $this->fail('Expected the reconnect cancellation to be thrown.'); + } catch (Throwable $exception) { + $this->assertSame($cancellation, $exception); + } + + $this->assertSame(0, $connection->getErrorCount()); + } + + /** + * Supply direct cancellation and a driver failure carrying it. + */ + public static function driverCancellations(): iterable + { + yield 'direct' => [new CanceledException('Operation canceled.')]; + yield 'chained driver failure' => [new PDOException('Connect failed.', previous: new CanceledException('Operation canceled.'))]; + } + + #[DataProvider('pdoDriverErrors')] + public function testPdoConnectionClassifiesNativeDriverErrors(Exception $exception, bool $expected): void + { + $connection = $this->getMockConnection(); + + $this->assertSame( + $expected, + (new ReflectionClass(PdoConnection::class))->getMethod('isDriverError')->invoke($connection, $exception), + ); + } + + /** + * Supply PDO driver failures and unrelated exceptions. + */ + public static function pdoDriverErrors(): iterable + { + yield 'pdo exception' => [new PDOException('SQLSTATE[HY000] [1045] Access denied for user'), true]; + yield 'pdo warning' => [new ErrorException('PDO::__construct(): SSL operation failed with code 1.'), true]; + yield 'mysqlnd warning' => [new ErrorException('Packets out of order. Expected 1 received 0. Packet size=23'), true]; + yield 'unrelated warning' => [new ErrorException('Undefined array key "host"'), false]; + yield 'reconnector failure' => [new RuntimeException('Unable to reconnect.'), false]; + } + public function testRunEmitsOnlyQueryExecutedWhenLostConnectionRetrySucceeds(): void { $method = (new ReflectionClass(Connection::class))->getMethod('run'); @@ -1046,6 +1207,45 @@ public function testStreamingReconnectsMissingResourcesOnlyWhenAdvanced(): void $this->assertSame(1, $reconnects); } + public function testStreamingReportsAMissingConnectionDriverFailureAsTheQueryFailure(): void + { + $connection = $this->getMockConnection(); + $connection->setPdo(null); + $reconnects = 0; + $driverFailure = new PDOException('SQLSTATE[HY000] [14] unable to open database file'); + $connection->setReconnector(static function () use (&$reconnects, $driverFailure): never { + ++$reconnects; + + throw $driverFailure; + }); + $event = null; + $events = m::mock(Dispatcher::class); + $events->expects('hasListeners')->with(QueryFailed::class)->andReturnTrue(); + $events->expects('dispatch')->andReturnUsing( + static function (QueryFailed $dispatched) use (&$event): void { + $event = $dispatched; + }, + ); + $connection->setEventDispatcher($events); + + $stream = $this->runStreamingQuery($connection, fn (): never => $this->fail('The query must not run without a connection.')); + + $this->assertSame(0, $reconnects); + + try { + iterator_to_array($stream); + $this->fail('Expected the reconnect failure to be reported.'); + } catch (QueryException $exception) { + $this->assertSame($driverFailure, $exception->getPrevious()); + $this->assertSame('select ?', $exception->getSql()); + $this->assertInstanceOf(QueryFailed::class, $event); + $this->assertSame($exception, $event->exception); + } + + $this->assertSame(1, $reconnects); + $this->assertSame(1, $connection->getErrorCount()); + } + public function testStreamingEmptyAndPretendResultsStillLogCompletion(): void { $connection = new NeutralConnectionForTest; diff --git a/tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php b/tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php new file mode 100644 index 0000000000..5edcaa177b --- /dev/null +++ b/tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php @@ -0,0 +1,64 @@ +make('config'); + + // Auth configuration + $config->set('auth.defaults.guard', 'api'); + + $config->set('auth.guards.api', [ + 'driver' => 'token', + 'provider' => 'users', + 'hash' => false, + ]); + + // Database configuration + $config->set('database.default', 'testbench'); + + $config->set('database.connections.testbench', [ + 'driver' => 'mysql', + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => (int) env('DB_PORT', 3306), + 'username' => 'root', + 'password' => 'invalid-credentials', + 'database' => 'forge', + 'prefix' => '', + ]); + } + + public function testAuthenticationViaApiWithEloquentUsingWrongDatabaseCredentialsShouldNotCauseInfiniteLoop(): void + { + Route::get('/auth', function () { + return 'success'; + })->middleware('auth:api'); + + $this->expectException(QueryException::class); + + $this->expectExceptionMessage("Access denied for user 'root'@"); + + try { + $this->withoutExceptionHandling()->get('/auth', ['Authorization' => 'Bearer whatever']); + } catch (QueryException $e) { + if (Str::startsWith($e->getMessage(), 'SQLSTATE[HY000] [2002]')) { + $this->markTestSkipped('MySQL instance required.'); + } + + throw $e; + } + } +} From f8af9882364b04e51fa23a7179160e5379b2449b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:01:04 +0000 Subject: [PATCH 08/31] Cover unencodable JSON in JSON paths and object casts Port the tests from laravel/framework#62175 and #62203 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. They share DatabaseEloquentModelTest, so they land together. Hypervel already throws JsonEncodingException in both places. fillJsonAttribute() encodes through castAttributeAsJson(), and the AsArrayObject, AsCollection, AsEncryptedArrayObject, AsEncryptedCollection and AsFluent casts check the encoder result. No source change is needed. Both upstream tests are ported, including all five object cast cases. The test file passes. --- tests/Database/DatabaseEloquentModelTest.php | 29 ++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 0edcfde7b3..1e9e3cbc68 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -3706,6 +3706,22 @@ public function testModelJsonCastingFailsOnUnencodableData(): void $model->getAttributes(); } + public function testModelJsonPathCastingFailsOnUnencodableData(): void + { + $model = new CastingStub; + $model->jsonAttribute = ['theme' => 'dark']; + + try { + $model->{'jsonAttribute->name'} = "b\xF8r"; + + $this->fail('JsonEncodingException was not thrown.'); + } catch (JsonEncodingException $e) { + $this->assertSame('Unable to encode attribute [jsonAttribute] for model [Hypervel\Tests\Database\DatabaseEloquentModelTest\CastingStub] to JSON: Malformed UTF-8 characters, possibly incorrectly encoded.', $e->getMessage()); + } + + $this->assertSame(['theme' => 'dark'], $model->jsonAttribute); + } + public function testModelAttributeCastingFailsOnUnencodableDataWithUnicode(): void { $this->expectExceptionObject(new JsonEncodingException('Unable to encode attribute [jsonAttributeWithUnicode] for model [Hypervel\Tests\Database\DatabaseEloquentModelTest\CastingStub] to JSON: Malformed UTF-8 characters, possibly incorrectly encoded.')); @@ -3716,6 +3732,19 @@ public function testModelAttributeCastingFailsOnUnencodableDataWithUnicode(): vo $model->getAttributes(); } + #[TestWith(['asarrayobjectAttribute'])] + #[TestWith(['ascollectionAttribute'])] + #[TestWith(['asFluentAttribute'])] + #[TestWith(['asEncryptedArrayObjectAttribute'])] + #[TestWith(['asEncryptedCollectionAttribute'])] + public function testObjectCastsFailOnUnencodableData(string $attribute): void + { + $this->expectExceptionObject(new JsonEncodingException("Unable to encode attribute [{$attribute}] for model [Hypervel\\Tests\\Database\\DatabaseEloquentModelTest\\CastingStub] to JSON: Malformed UTF-8 characters, possibly incorrectly encoded.")); + + $model = new CastingStub; + $model->{$attribute} = ['foo' => "b\xF8r"]; + } + public function testJsonCastingRespectsUnicodeOption(): void { $data = ['こんにちは' => '世界']; From 15cd12aa16b24d252b63105b753628c0301c3be7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:01:04 +0000 Subject: [PATCH 09/31] Correct the AsFluent cast's write type AsFluent declared its write type as string, which only covers a write the cast cannot read back: a string is encoded again and decodes to a non-array, so the attribute reads as null. Supported writes are arrays and objects, such as Fluent instances, so the cast is now CastsAttributes, matching Fluent's constructor. Runtime behavior is unchanged. Found while porting laravel/framework#62203 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. The Castable type fixture now asserts the cast type and accepts array and Fluent writes; type analysis passes. --- src/database/src/Eloquent/Casts/AsFluent.php | 2 +- types/Database/Eloquent/Casts/Castable.php | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/database/src/Eloquent/Casts/AsFluent.php b/src/database/src/Eloquent/Casts/AsFluent.php index f24803da43..a8a4d3b350 100644 --- a/src/database/src/Eloquent/Casts/AsFluent.php +++ b/src/database/src/Eloquent/Casts/AsFluent.php @@ -15,7 +15,7 @@ class AsFluent implements Castable /** * Get the caster class to use when casting from / to this cast target. * - * @return CastsAttributes + * @return CastsAttributes */ public static function castUsing(array $arguments): CastsAttributes { diff --git a/types/Database/Eloquent/Casts/Castable.php b/types/Database/Eloquent/Casts/Castable.php index d264d4774a..9cd0cbc625 100644 --- a/types/Database/Eloquent/Casts/Castable.php +++ b/types/Database/Eloquent/Casts/Castable.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use Hypervel\Database\Eloquent\Casts\AsFluent; +use Hypervel\Support\Fluent; + use function PHPStan\Testing\assertType; assertType( @@ -34,6 +37,14 @@ \Hypervel\Database\Eloquent\Casts\AsEnumCollection::castUsing([\UserType::class]), ); +/** @var User $user */ +$fluentCast = AsFluent::castUsing([]); + +assertType('Hypervel\Contracts\Database\Eloquent\CastsAttributes', $fluentCast); + +$fluentCast->set($user, 'options', ['theme' => 'dark'], $user->getAttributes()); +$fluentCast->set($user, 'options', new Fluent(['theme' => 'dark']), $user->getAttributes()); + assertType( 'Hypervel\Contracts\Database\Eloquent\CastsAttributes', \Hypervel\Database\Eloquent\Casts\AsStringable::castUsing([]), From de1daaf421865044898a406e0ccd0b01e12950cf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:01:13 +0000 Subject: [PATCH 10/31] Fail schedule:interrupt when interrupting is disabled Port laravel/framework#62183 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. When Schedule::$interruptible is false, schedule:interrupt now fails with "Schedule interrupting is currently disabled." instead of setting a cache key that nothing polls, matching schedule:pause. The upstream test is ported with Hypervel's cache key prefix. The test file passes. --- src/console/src/Commands/ScheduleInterruptCommand.php | 7 +++++++ .../Scheduling/ScheduleInterruptCommandTest.php | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/console/src/Commands/ScheduleInterruptCommand.php b/src/console/src/Commands/ScheduleInterruptCommand.php index bf43927c59..d5240b8ed0 100644 --- a/src/console/src/Commands/ScheduleInterruptCommand.php +++ b/src/console/src/Commands/ScheduleInterruptCommand.php @@ -5,6 +5,7 @@ namespace Hypervel\Console\Commands; use Hypervel\Console\Command; +use Hypervel\Console\Scheduling\Schedule; use Hypervel\Contracts\Cache\Repository as Cache; use Hypervel\Support\Facades\Date; use Symfony\Component\Console\Attribute\AsCommand; @@ -36,6 +37,12 @@ public function __construct( */ public function handle(): int { + if (! Schedule::$interruptible) { + $this->components->error('Schedule interrupting is currently disabled.'); + + return self::FAILURE; + } + $this->cache->forever('hypervel:schedule:interrupt', Date::now()->getTimestampMs()); $this->components->info('Broadcasting schedule interrupt signal.'); diff --git a/tests/Integration/Console/Scheduling/ScheduleInterruptCommandTest.php b/tests/Integration/Console/Scheduling/ScheduleInterruptCommandTest.php index cac00c30db..84c7ece811 100644 --- a/tests/Integration/Console/Scheduling/ScheduleInterruptCommandTest.php +++ b/tests/Integration/Console/Scheduling/ScheduleInterruptCommandTest.php @@ -43,6 +43,17 @@ public function testDoesNotInterruptWhenInterruptingIsDisabled(): void $this->assertFalse($this->app->make(Schedule::class)->hasBeenInterruptedSince(CarbonImmutable::now()->subMinute())); } + public function testFailsWhenInterruptingIsDisabled(): void + { + Schedule::$interruptible = false; + + $this->artisan('schedule:interrupt') + ->expectsOutputToContain('Schedule interrupting is currently disabled.') + ->assertFailed(); + + $this->assertNull(Cache::get('hypervel:schedule:interrupt')); + } + public function testLaterScheduleRunsPreserveEarlierInterruptionsAfterMoreThanAMinute(): void { $schedule = $this->app->make(Schedule::class); From 840b1078777c294dd160f8f443a4c64a60b6a2a8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:01:13 +0000 Subject: [PATCH 11/31] Cast non-integer failed job ids to strings Port laravel/framework#62208 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. DatabaseFailedJobProvider::ids() now returns integer ids unchanged and casts any other driver value to a string, so queue:retry receives scalar ids. The failed job provider interface and the database, UUID, file and null providers document list ids and list records. Hypervel's RetryCommand already avoided upstream's array-key crash by iterating with a generator; the cast keeps the provider contract correct. The upstream test is ported and the test file passes. --- .../src/Failed/DatabaseFailedJobProvider.php | 5 +++++ .../Failed/DatabaseUuidFailedJobProvider.php | 4 ++++ .../src/Failed/FailedJobProviderInterface.php | 4 ++++ src/queue/src/Failed/FileFailedJobProvider.php | 4 ++++ src/queue/src/Failed/NullFailedJobProvider.php | 4 ++++ tests/Queue/DatabaseFailedJobProviderTest.php | 17 +++++++++++++++++ 6 files changed, 38 insertions(+) diff --git a/src/queue/src/Failed/DatabaseFailedJobProvider.php b/src/queue/src/Failed/DatabaseFailedJobProvider.php index 397db5d732..c35c0ff540 100644 --- a/src/queue/src/Failed/DatabaseFailedJobProvider.php +++ b/src/queue/src/Failed/DatabaseFailedJobProvider.php @@ -38,6 +38,8 @@ public function log(string $connection, string $queue, string $payload, Throwabl /** * Get the IDs of all of the failed jobs. + * + * @return list */ public function ids(?string $queue = null): array { @@ -45,11 +47,14 @@ public function ids(?string $queue = null): array ->when(! is_null($queue), fn ($query) => $query->where('queue', $queue)) ->orderBy('id', 'desc') ->pluck('id') + ->map(fn ($id) => is_int($id) ? $id : (string) $id) ->all(); } /** * Get a list of all of the failed jobs. + * + * @return list */ public function all(): array { diff --git a/src/queue/src/Failed/DatabaseUuidFailedJobProvider.php b/src/queue/src/Failed/DatabaseUuidFailedJobProvider.php index 04561e3bd8..416d9a3bf6 100644 --- a/src/queue/src/Failed/DatabaseUuidFailedJobProvider.php +++ b/src/queue/src/Failed/DatabaseUuidFailedJobProvider.php @@ -50,6 +50,8 @@ public function log(string $connection, string $queue, string $payload, Throwabl /** * Get the IDs of all of the failed jobs. + * + * @return list */ public function ids(?string $queue = null): array { @@ -62,6 +64,8 @@ public function ids(?string $queue = null): array /** * Get a list of all of the failed jobs. + * + * @return list */ public function all(): array { diff --git a/src/queue/src/Failed/FailedJobProviderInterface.php b/src/queue/src/Failed/FailedJobProviderInterface.php index 9557007eb5..715d750740 100644 --- a/src/queue/src/Failed/FailedJobProviderInterface.php +++ b/src/queue/src/Failed/FailedJobProviderInterface.php @@ -15,11 +15,15 @@ public function log(string $connection, string $queue, string $payload, Throwabl /** * Get the IDs of all of the failed jobs. + * + * @return list */ public function ids(?string $queue = null): array; /** * Get a list of all of the failed jobs. + * + * @return list */ public function all(): array; diff --git a/src/queue/src/Failed/FileFailedJobProvider.php b/src/queue/src/Failed/FileFailedJobProvider.php index eee8a302b8..7aea597879 100644 --- a/src/queue/src/Failed/FileFailedJobProvider.php +++ b/src/queue/src/Failed/FileFailedJobProvider.php @@ -57,6 +57,8 @@ public function log(string $connection, string $queue, string $payload, Throwabl /** * Get the IDs of all of the failed jobs. + * + * @return list */ public function ids(?string $queue = null): array { @@ -68,6 +70,8 @@ public function ids(?string $queue = null): array /** * Get a list of all of the failed jobs. + * + * @return list */ public function all(): array { diff --git a/src/queue/src/Failed/NullFailedJobProvider.php b/src/queue/src/Failed/NullFailedJobProvider.php index fd7e2696b1..2cc47131d9 100644 --- a/src/queue/src/Failed/NullFailedJobProvider.php +++ b/src/queue/src/Failed/NullFailedJobProvider.php @@ -18,6 +18,8 @@ public function log(string $connection, string $queue, string $payload, Throwabl /** * Get the IDs of all of the failed jobs. + * + * @return list */ public function ids(?string $queue = null): array { @@ -26,6 +28,8 @@ public function ids(?string $queue = null): array /** * Get a list of all of the failed jobs. + * + * @return list */ public function all(): array { diff --git a/tests/Queue/DatabaseFailedJobProviderTest.php b/tests/Queue/DatabaseFailedJobProviderTest.php index 1b0c9d68e2..ca82aed243 100644 --- a/tests/Queue/DatabaseFailedJobProviderTest.php +++ b/tests/Queue/DatabaseFailedJobProviderTest.php @@ -10,8 +10,11 @@ use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Queue\Failed\DatabaseFailedJobProvider; use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Collection; use Hypervel\Support\Str; +use Hypervel\Support\Stringable; use Hypervel\Testbench\TestCase; +use Mockery as m; use RuntimeException; class DatabaseFailedJobProviderTest extends TestCase @@ -61,6 +64,20 @@ public function testCanGetAllFailedJobIds(): void $this->assertSame([4, 3, 2, 1], $this->provider->ids()); } + public function testNonIntegerFailedJobIdsAreCastToStrings(): void + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('when')->andReturnSelf(); + $builder->shouldReceive('orderBy')->andReturnSelf(); + $builder->shouldReceive('pluck')->with('id')->andReturn(new Collection([new Stringable('4'), 3])); + + $provider = m::mock(DatabaseFailedJobProvider::class, [$this->resolver, null, 'failed_jobs']) + ->makePartial(); + $provider->shouldReceive('getTable')->andReturn($builder); + + $this->assertSame(['4', 3], $provider->ids()); + } + public function testCanGetAllFailedJobs(): void { $this->assertEmpty($this->provider->all()); From 9e16d1196e22816c7eabdd0a5ef9aeb5391efe47 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:01:13 +0000 Subject: [PATCH 12/31] Cover transactions manager rollback after a failed commit Port the test from laravel/framework#62206 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. Hypervel already rolls back the transactions manager when a commit fails, through rollBack(0) or the lost-connection reset, so no source change is needed. The test's tearDown uses dropIfExists: Hypervel replaces a session whose commit failed unexpectedly, which discards the in-memory SQLite database before cleanup runs. The test file passes. --- tests/Database/DatabaseTransactionsTest.php | 36 ++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/tests/Database/DatabaseTransactionsTest.php b/tests/Database/DatabaseTransactionsTest.php index 97172eb663..68839626d9 100644 --- a/tests/Database/DatabaseTransactionsTest.php +++ b/tests/Database/DatabaseTransactionsTest.php @@ -11,6 +11,7 @@ use Hypervel\Foundation\Testing\DatabaseTransactionsManager as TestingTransactionsManager; use Hypervel\Tests\TestCase; use Mockery as m; +use PDOException; use RuntimeException; use Throwable; @@ -57,7 +58,8 @@ protected function createSchema(): void protected function tearDown(): void { foreach (['default', 'second_connection'] as $connection) { - $this->schema($connection)->drop('users'); + // A failed commit replaces the untrusted session, discarding the in-memory database. + $this->schema($connection)->dropIfExists('users'); } parent::tearDown(); @@ -189,6 +191,38 @@ public function testTransactionIsRolledBack(): void } } + public function testFailedCommitRollsBackTheTransactionsManager(): void + { + $manager = new DatabaseTransactionsManager; + $this->connection()->setTransactionManager($manager); + + $afterCommitRan = false; + $rolledBack = false; + + try { + $this->connection()->transaction(function () use (&$afterCommitRan, &$rolledBack) { + $this->connection()->afterCommit(function () use (&$afterCommitRan) { + $afterCommitRan = true; + }); + + $this->connection()->afterRollBack(function () use (&$rolledBack) { + $rolledBack = true; + }); + + // End the transaction behind the connection's back so COMMIT fails. + $this->connection()->getPdo()->exec('ROLLBACK'); + }); + + $this->fail('The failed commit should have thrown.'); + } catch (PDOException) { + } + + $this->assertSame(0, $this->connection()->transactionLevel()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertFalse($afterCommitRan); + $this->assertTrue($rolledBack); + } + public function testTransactionIsRolledBackUsingSeparateMethods(): void { $transactionManager = m::mock(new DatabaseTransactionsManager); From 258c604ce44c8d3f9dbc586b5dee4a0135bbe6f9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:01:13 +0000 Subject: [PATCH 13/31] Cover models with a disabled primary key Port the test from laravel/framework#62205 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. Hypervel's Model::getIncrementing() already returns false whenever the key name is null, which covers #[Table(key: false)] as well as properties, setters and overridden getKeyName(), so upstream's source change is not needed. The upstream test is ported with Hypervel naming. The test file passes. --- tests/Database/DatabaseEloquentModelAttributesTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Database/DatabaseEloquentModelAttributesTest.php b/tests/Database/DatabaseEloquentModelAttributesTest.php index 0304ddd199..6f1b750e57 100644 --- a/tests/Database/DatabaseEloquentModelAttributesTest.php +++ b/tests/Database/DatabaseEloquentModelAttributesTest.php @@ -138,6 +138,13 @@ public function testPrimaryKeyAttributeCanDisablePrimaryKey(): void $this->assertNull($model->getKeyName()); } + public function testDisabledPrimaryKeyIsNotIncrementing(): void + { + $model = new ModelWithoutPrimaryKeyAttribute; + + $this->assertFalse($model->getIncrementing()); + } + public function testPrimaryKeyCanBeDisabledByPropertyOrSetter(): void { $model = new class extends Model { From a498ac4f0413043042fdf060cf4731f44f57771f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:01:30 +0000 Subject: [PATCH 14/31] Add restoreOrCreate() and createOrRestore() to through relations Port laravel/framework#62204 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. HasOneOrManyThrough gains both methods, matching HasOneOrMany: restoreOrCreate() restores a soft-deleted match or creates one, and createOrRestore() creates first and restores a conflicting soft-deleted record. The upstream countries and comments schema, fixtures and both through-relation tests are ported. The existing relationship docs already describe the methods generally, and upstream changed no docs. The test file passes. --- .../Relations/HasOneOrManyThrough.php | 32 +++++++ .../EloquentRelationRestoreOrCreateTest.php | 95 +++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index c3395b9c94..0cb47c967d 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -276,6 +276,38 @@ public function createOrFirst(array $attributes = [], Closure|array $values = [] } } + /** + * Get the first record matching the attributes, including trashed records, and restore it. If the record is not found, create it. + * + * @param array|(Closure(): array) $values + * @return TRelatedModel + */ + public function restoreOrCreate(array $attributes = [], Closure|array $values = []): Model + { + $this->withTrashed(); + + return tap($this->firstOrCreate($attributes, $values), function ($instance) { + $instance->restore(); + }); + } + + /** + * Attempt to create the record. If a unique constraint violation occurs, find the matching record, including trashed records, and restore it. + * + * @param array|(Closure(): array) $values + * @return TRelatedModel + * + * @throws UniqueConstraintViolationException + */ + public function createOrRestore(array $attributes = [], Closure|array $values = []): Model + { + $this->withTrashed(); + + return tap($this->createOrFirst($attributes, $values), function ($instance) { + $instance->restore(); + }); + } + /** * Create or update a related record matching the attributes, and fill it with values. * diff --git a/tests/Integration/Database/EloquentRelationRestoreOrCreateTest.php b/tests/Integration/Database/EloquentRelationRestoreOrCreateTest.php index 80c78fcccd..89ea917009 100644 --- a/tests/Integration/Database/EloquentRelationRestoreOrCreateTest.php +++ b/tests/Integration/Database/EloquentRelationRestoreOrCreateTest.php @@ -7,6 +7,7 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsToMany; use Hypervel\Database\Eloquent\Relations\HasMany; +use Hypervel\Database\Eloquent\Relations\HasManyThrough; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; @@ -19,8 +20,13 @@ class EloquentRelationRestoreOrCreateTest extends DatabaseTestCase */ protected function afterRefreshingDatabase(): void { + Schema::create('countries', function (Blueprint $table) { + $table->increments('id'); + }); + Schema::create('users', function (Blueprint $table) { $table->increments('id'); + $table->unsignedInteger('country_id')->nullable(); }); Schema::create('posts', function (Blueprint $table) { @@ -30,6 +36,13 @@ protected function afterRefreshingDatabase(): void $table->softDeletes(); }); + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('user_id')->nullable(); + $table->string('body')->unique(); + $table->softDeletes(); + }); + Schema::create('roles', function (Blueprint $table) { $table->increments('id'); $table->string('name'); @@ -80,6 +93,42 @@ public function testCreateOrRestoreOnHasMany(): void $this->assertFalse($restored->trashed()); } + public function testRestoreOrCreateOnHasManyThrough(): void + { + $country = Country::create(); + $otherUser = User::create(['country_id' => Country::create()->id]); + $user = User::create(['country_id' => $country->id]); + + $otherUser->posts()->create(['title' => 'Other'])->delete(); + $otherUser->posts()->create(['title' => 'Other'])->delete(); + $post = $user->posts()->create(['title' => 'Draft']); + $post->delete(); + + $restored = $country->posts()->restoreOrCreate(['title' => 'Draft']); + + $this->assertTrue($restored->is($post)); + $this->assertFalse($post->fresh()->trashed()); + $this->assertSame(2, Post::onlyTrashed()->where('title', 'Other')->count()); + } + + public function testCreateOrRestoreOnHasManyThrough(): void + { + $country = Country::create(); + $otherUser = User::create(['country_id' => Country::create()->id]); + $user = User::create(['country_id' => $country->id]); + + $otherUser->comments()->create(['body' => 'First'])->delete(); + $otherUser->comments()->create(['body' => 'Second'])->delete(); + $comment = $user->comments()->create(['body' => 'Draft']); + $comment->delete(); + + $restored = $country->comments()->createOrRestore(['body' => 'Draft']); + + $this->assertTrue($restored->is($comment)); + $this->assertFalse($comment->fresh()->trashed()); + $this->assertSame(2, Comment::onlyTrashed()->count()); + } + public function testRestoreOrCreateOnBelongsToMany(): void { $user = User::create(); @@ -141,10 +190,37 @@ public function testCreateOrRestoreOnBelongsToMany(): void } } +class Country extends Model +{ + public bool $timestamps = false; + + /** + * Get the country's posts. + * + * @return HasManyThrough + */ + public function posts(): HasManyThrough + { + return $this->hasManyThrough(Post::class, User::class); + } + + /** + * Get the country's comments. + * + * @return HasManyThrough + */ + public function comments(): HasManyThrough + { + return $this->hasManyThrough(Comment::class, User::class); + } +} + class User extends Model { public bool $timestamps = false; + protected array $guarded = []; + /** * Get the user's posts. * @@ -155,6 +231,16 @@ public function posts(): HasMany return $this->hasMany(Post::class); } + /** + * Get the user's comments. + * + * @return HasMany + */ + public function comments(): HasMany + { + return $this->hasMany(Comment::class); + } + /** * Get the user's roles. * @@ -175,6 +261,15 @@ class Post extends Model protected array $guarded = []; } +class Comment extends Model +{ + use SoftDeletes; + + public bool $timestamps = false; + + protected array $guarded = []; +} + class Role extends Model { use SoftDeletes; From fd53bfaf7daf3844874a923678e56d4bfe6068f2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:01:30 +0000 Subject: [PATCH 15/31] Support exit code and exception fakes when starting processes Port laravel/framework#62200 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. Process fakes for start() now accept an exit code shorthand and rethrow exception fakes, as run() already did. Both upstream tests are ported. The process test suite passes. --- src/process/src/PendingProcess.php | 8 ++++++++ tests/Process/ProcessTest.php | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/process/src/PendingProcess.php b/src/process/src/PendingProcess.php index e28e031813..f9aa57bd9b 100644 --- a/src/process/src/PendingProcess.php +++ b/src/process/src/PendingProcess.php @@ -386,11 +386,16 @@ protected function resolveSynchronousFake(string $command, Closure $fake): Proce * Resolve the given fake handler for an asynchronous process. * * @throws LogicException + * @throws Throwable */ protected function resolveAsynchronousFake(string $command, ?callable $output, Closure $fake): InvokedProcessContract { $result = $fake($this); + if (is_int($result)) { + $result = new FakeProcessResult(exitCode: $result); + } + if (is_string($result) || is_array($result)) { $result = new FakeProcessResult(output: $result); } @@ -421,6 +426,9 @@ protected function resolveAsynchronousFake(string $command, ?callable $output, C if ($result instanceof FakeProcessSequence) { return $this->resolveAsynchronousFake($command, $output, fn () => $result()); } + if ($result instanceof Throwable) { + throw $result; + } throw new LogicException('Unsupported asynchronous process fake result provided.'); } diff --git a/tests/Process/ProcessTest.php b/tests/Process/ProcessTest.php index 2b232dd0a6..3a8eac4dd3 100644 --- a/tests/Process/ProcessTest.php +++ b/tests/Process/ProcessTest.php @@ -860,6 +860,16 @@ public function testProcessFakeExitCodeShorthand(): void $this->assertFalse($result->successful()); } + public function testProcessFakeExitCodeShorthandWhenStartingProcesses(): void + { + $factory = new Factory; + $factory->fake(['ls -la' => 1]); + + $result = $factory->start('ls -la')->wait(); + $this->assertSame(1, $result->exitCode()); + $this->assertFalse($result->successful()); + } + public function testBasicProcessFakeWithCustomOutput(): void { $factory = new Factory; @@ -1103,6 +1113,17 @@ public function testProcessFakeThrowShorthand(): void $factory->run('cat me'); } + public function testProcessFakeThrowShorthandWhenStartingProcesses(): void + { + $this->expectExceptionObject(new RuntimeException('fake exception message')); + + $factory = new Factory; + + $factory->fake(['cat me' => new RuntimeException('fake exception message')]); + + $factory->start('cat me'); + } + public function testFakeSymfonyTimeoutsAreConvertedToFrameworkTimeouts(): void { $factory = new Factory; From 9a6a11ee3d75c16de27dfdd1d4ba23c9e96d1da8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:01:30 +0000 Subject: [PATCH 16/31] Cover install:broadcasting with each React entry file Port the test from laravel/framework#62202 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. Hypervel already picks the first existing entry file, so app.jsx and app.js are not skipped and no source change is needed. Hypervel's starter kit is React-only, so the Vue entry-file cases are removed with a REMOVED comment. The Vue omission was previously recorded only in history; the command now has a source comment and the foundation README lists the difference. The existing integration test keeps its full-command coverage. The new test file passes. --- src/foundation/README.md | 2 + .../Console/BroadcastingInstallCommand.php | 3 + .../BroadcastingInstallCommandTest.php | 81 +++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 tests/Foundation/Console/BroadcastingInstallCommandTest.php diff --git a/src/foundation/README.md b/src/foundation/README.md index 6805c6d856..3228c924ed 100644 --- a/src/foundation/README.md +++ b/src/foundation/README.md @@ -28,6 +28,8 @@ Laravel's `Application::handleRequest()` is omitted. Hypervel's HTTP server hand The `php artisan serve` command starts Hypervel's configured Swoole servers directly instead of PHP's built-in development server. +`php artisan install:broadcasting` adds Echo configuration to the application file only for React apps. Vue apps receive the standard `echo.js` setup; see the [broadcasting documentation](https://hypervel.org/docs/broadcasting#client-side-installation) to use `@laravel/echo-vue` instead. + Environment detection honors startup `--env` arguments even when `runningInConsole()` is false, so server commands can select their environment while booting with HTTP semantics. The default `dev` server process runs `php artisan watch` so the Watcher package can own and restart the long-running Swoole server. Official Hypervel skeletons and starter kits include `hypervel/watcher` as a development dependency. diff --git a/src/foundation/src/Console/BroadcastingInstallCommand.php b/src/foundation/src/Console/BroadcastingInstallCommand.php index d32a61f07c..30aa911740 100644 --- a/src/foundation/src/Console/BroadcastingInstallCommand.php +++ b/src/foundation/src/Console/BroadcastingInstallCommand.php @@ -369,6 +369,9 @@ protected function collectMercureConfig(): void protected function injectFrameworkSpecificConfiguration(): void { $files = $this->hypervel->make(Filesystem::class); + + // Laravel also configures @laravel/echo-vue in app.ts or app.js. Hypervel only ships + // a React starter kit, so Vue apps use the standard echo.js setup instead. $filePaths = [ $this->hypervel->resourcePath('js/app.tsx'), $this->hypervel->resourcePath('js/app.jsx'), diff --git a/tests/Foundation/Console/BroadcastingInstallCommandTest.php b/tests/Foundation/Console/BroadcastingInstallCommandTest.php new file mode 100644 index 0000000000..32631c1087 --- /dev/null +++ b/tests/Foundation/Console/BroadcastingInstallCommandTest.php @@ -0,0 +1,81 @@ +basePath = ParallelTesting::tempDir('BroadcastingInstallCommandTest'); + + (new Filesystem)->deleteDirectory($this->basePath); + (new Filesystem)->ensureDirectoryExists($this->basePath . '/resources/js'); + } + + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->basePath); + + parent::tearDown(); + } + + #[DataProvider('frameworkFiles')] + public function testEchoConfigurationIsInjectedIntoTheApplicationFile(string $package, string $file): void + { + file_put_contents($this->basePath . '/package.json', json_encode(['devDependencies' => [$package => '*']])); + file_put_contents($this->basePath . '/resources/js/' . $file, "import './bootstrap';\n"); + + $app = new Application($this->basePath); + $app->instance('files', new Filesystem); + + $command = new BroadcastingInstallCommandTestStub; + $command->setHypervel($app); + $command->injectEchoConfiguration('reverb'); + + $this->assertStringContainsString( + "import { configureEcho } from '@laravel/echo-{$package}';", + file_get_contents($this->basePath . '/resources/js/' . $file) + ); + } + + public static function frameworkFiles(): array + { + // REMOVED: Vue's app.ts and app.js cases. Hypervel's command configures Echo + // inside the React starter kit only; Vue apps use the standard echo.js setup. + return [ + ['react', 'app.tsx'], + ['react', 'app.jsx'], + ]; + } +} + +class BroadcastingInstallCommandTestStub extends BroadcastingInstallCommand +{ + /** + * Inject the Echo configuration for the given driver. + */ + public function injectEchoConfiguration(string $driver): void + { + $this->driver = $driver; + $this->components = new Factory(new OutputStyle(new ArrayInput([]), new BufferedOutput)); + + $this->injectFrameworkSpecificConfiguration(); + } +} From 75e87976b91bba4c0724d2fba5309812d970f815 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:01:30 +0000 Subject: [PATCH 17/31] Assert the Paginator::links() return type Port the type assertion from laravel/framework#62226 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. Hypervel's Paginator::links() already declares a native Htmlable return type, so no source change is needed. Type analysis passes. --- types/Pagination/Paginator.php | 1 + 1 file changed, 1 insertion(+) diff --git a/types/Pagination/Paginator.php b/types/Pagination/Paginator.php index 1f96401ed1..914fe165c9 100644 --- a/types/Pagination/Paginator.php +++ b/types/Pagination/Paginator.php @@ -20,6 +20,7 @@ assertType('string|null', $paginator->fragment()); assertType('Hypervel\Pagination\Paginator', $paginator->fragment('foo')); +assertType('Hypervel\Contracts\Support\Htmlable', $paginator->links()); assertType('Hypervel\Pagination\Paginator', $paginator->loadMorph('parentable', [Post::class => ['comments' => function ($relation) { assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); }]])); From ded74f2ac76c17ada5ffe36c163c9ec1c8107190 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:01:30 +0000 Subject: [PATCH 18/31] Fix updating JSON paths with collections Port laravel/framework#62225 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. Query\Builder::update() converts Collection values to arrays before compiling, so a JSON path update with a collection is stored as JSON like an array. Hypervel's MySQL grammar already wraps array JSON path values with compileJsonValueCast(). The upstream MariaDB grammar test and the array and collection integration tests are ported. The unit tests and the SQLite, MySQL, MariaDB and PostgreSQL runs pass. --- src/database/src/Query/Builder.php | 5 ++- tests/Database/DatabaseQueryBuilderTest.php | 19 +++++++++++ .../Database/QueryBuilderUpdateTest.php | 34 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 1705f1b24b..44c6f58cd6 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -4667,11 +4667,14 @@ public function update(array $values): int $this->applyBeforeQueryCallbacks(); $values = (new Collection($values))->map(function ($value) { + if ($value instanceof Collection) { + $value = $value->all(); + } + if (! $value instanceof self && ! $value instanceof EloquentBuilder && ! $value instanceof Relation) { return ['value' => $value, 'bindings' => match (true) { - $value instanceof Collection => $value->all(), $value instanceof UnitEnum => enum_value($value), default => $value, }]; diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 8ef047517b..7edd3b77aa 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -6396,6 +6396,25 @@ public function testMySqlUpdateWrappingJsonArray(): void ]); } + public function testMariaDbUpdateWrappingJsonArray(): void + { + $connection = $this->createMock(Connection::class); + $grammar = new MariaDbGrammar($connection); + $processor = new Processor; + + $connection->expects($this->once()) + ->method('update') + ->with( + 'update `users` set `meta` = json_set(`meta`, \'$."tags"\', json_query(?, \'$\')) where `active` = ?', + [json_encode(['white', 'large']), 1] + ); + + $builder = new Builder($connection, $grammar, $processor); + $builder->from('users')->where('active', 1)->update([ + 'meta->tags' => ['white', 'large'], + ]); + } + public function testMySqlUpdateWrappingJsonPathArrayIndex(): void { $connection = $this->createMock(Connection::class); diff --git a/tests/Integration/Database/QueryBuilderUpdateTest.php b/tests/Integration/Database/QueryBuilderUpdateTest.php index 27c614166b..6965966c7b 100644 --- a/tests/Integration/Database/QueryBuilderUpdateTest.php +++ b/tests/Integration/Database/QueryBuilderUpdateTest.php @@ -80,6 +80,40 @@ public static function jsonValuesDataProvider() yield ['status', StringStatus::Draft, 'draft']; } + #[RequiresDatabase(['sqlite', 'mysql', 'mariadb', 'pgsql'])] + public function testUpdateJsonPathWithArray(): void + { + DB::table('example')->insert([ + ['name' => 'Taylor Otwell', 'payload' => json_encode(['tags' => [], 'active' => true])], + ]); + + DB::table('example')->update([ + 'payload->tags' => ['Hypervel', 'Founder'], + ]); + + $this->assertSame( + ['tags' => ['Hypervel', 'Founder'], 'active' => true], + json_decode(DB::table('example')->value('payload'), true) + ); + } + + #[RequiresDatabase(['sqlite', 'mysql', 'mariadb', 'pgsql'])] + public function testUpdateJsonPathWithCollection(): void + { + DB::table('example')->insert([ + ['name' => 'Taylor Otwell', 'payload' => json_encode(['tags' => [], 'active' => true])], + ]); + + DB::table('example')->update([ + 'payload->tags' => collect(['Hypervel', 'Founder']), + ]); + + $this->assertSame( + ['tags' => ['Hypervel', 'Founder'], 'active' => true], + json_decode(DB::table('example')->value('payload'), true) + ); + } + #[RequiresDatabase(['sqlite', 'mysql', 'mariadb'])] public function testSubqueryUpdate(): void { From 2ee00ce6c55752a1a259d2dbf79d7c4d40096c3b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:57:43 +0000 Subject: [PATCH 19/31] Add strict typed getters to InteractsWithData Port laravel/framework#62217, laravel/framework#62254 and direct commit c40be6abbd at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. array() now converts object values such as collections with Arr::from(), so ComponentAttributeBag's array() override is removed. str(), string(), integer(), float() and boolean() accept a strict flag that returns the default for values that are not valid for the type. integer(), float() and boolean() also accept null and closure defaults; their PHPDoc return types keep non-strict calls typed as int, float and bool. The Request facade is regenerated, and requests.md documents strict mode because upstream has no documentation for it. Two upstream defects are fixed. Strict mode resolved a closure default twice when the value was missing or invalid; it now resolves the default only after rejecting the value. Strict integer() and float() validated a numeric Stringable and then cast the object, which gave a warning and a wrong result; they now return the filtered value. Upstream's shared InteractsWithData contract tests are ported and run for Request, Fluent, ValidatedInput, ComponentAttributeBag, UriQueryString, CommandInput and the trait itself. Hypervel's trait test is merged into SupportInteractsWithDataTest, keeping coverage upstream lacks, with regression tests for both fixes. Static analysis, FacadeDocblocksTest and the contract test files pass. --- src/docs/requests.md | 26 +- src/support/src/Facades/Request.php | 10 +- src/support/src/Traits/InteractsWithData.php | 78 +- src/view/src/ComponentAttributeBag.php | 8 - tests/Console/CommandInputTest.php | 171 +++ tests/Http/Fixtures/TestEnum.php | 10 - tests/Http/Fixtures/TestEnumBacked.php | 11 - tests/Http/HttpRequestTest.php | 638 +--------- .../TestsInteractsWithDataContract.php | 1075 +++++++++++++++++ .../Fixtures/TestEmptyStringBackedEnum.php | 10 + .../Fixtures/TestIntegerBackedEnum.php} | 4 +- tests/Support/SupportFluentTest.php | 360 ++---- .../Support/SupportInteractsWithDataTest.php | 427 +++++++ tests/Support/SupportUriTest.php | 151 +++ .../Support/Traits/InteractsWithDataTest.php | 373 ------ tests/Support/ValidatedInputTest.php | 494 ++------ tests/View/ViewComponentAttributeBagTest.php | 277 ++--- types/Http/Request.php | 9 + 18 files changed, 2311 insertions(+), 1821 deletions(-) create mode 100644 tests/Console/CommandInputTest.php delete mode 100644 tests/Http/Fixtures/TestEnum.php delete mode 100644 tests/Http/Fixtures/TestEnumBacked.php create mode 100644 tests/Support/Concerns/TestsInteractsWithDataContract.php create mode 100644 tests/Support/Fixtures/TestEmptyStringBackedEnum.php rename tests/{Http/Fixtures/TestIntegerEnumBacked.php => Support/Fixtures/TestIntegerBackedEnum.php} (56%) create mode 100644 tests/Support/SupportInteractsWithDataTest.php delete mode 100644 tests/Support/Traits/InteractsWithDataTest.php diff --git a/src/docs/requests.md b/src/docs/requests.md index d7e19fc678..8c3738eaa3 100644 --- a/src/docs/requests.md +++ b/src/docs/requests.md @@ -415,6 +415,12 @@ Instead of retrieving the request's input data as a primitive `string`, you may $name = $request->string('name')->trim(); ``` +If the input is not a string, number, or `Stringable` instance, such as an array, you may pass `strict: true` to receive the default value instead: + +```php +$name = $request->string('name', 'Guest', strict: true); +``` + #### Retrieving Integer Input Values @@ -424,6 +430,12 @@ To retrieve input values as integers, you may use the `integer` method. This met $perPage = $request->integer('per_page'); ``` +By default, the `integer` method casts whatever it receives, so a value like `"12abc"` becomes `12`. To reject values that are not valid integers, pass `strict: true`. Invalid values will then return the default value, which may be `null`. The default may also be a closure, which is only called when the default is needed: + +```php +$perPage = $request->integer('per_page', default: null, strict: true); +``` + #### Retrieving Float Input Values @@ -433,6 +445,12 @@ Similarly, the `float` method retrieves an input value as a float. If the input $price = $request->float('price'); ``` +The `float` method also accepts `strict: true`, which returns the default value for anything that is not a valid number, such as `"1.5abc"` or `"1,000"`: + +```php +$price = $request->float('price', default: null, strict: true); +``` + #### Retrieving Clamped Input Values @@ -445,12 +463,18 @@ $perPage = $request->clamp('per_page', min: 1, max: 100, default: 15); #### Retrieving Boolean Input Values -When dealing with HTML elements like checkboxes, your application may receive "truthy" values that are actually strings. For example, "true" or "on". For convenience, you may use the `boolean` method to retrieve these values as booleans. The `boolean` method returns `true` for 1, "1", true, "true", "on", and "yes". All other values will return `false`: +When dealing with HTML elements like checkboxes, your application may receive "truthy" values that are actually strings. For example, "true" or "on". For convenience, you may use the `boolean` method to retrieve these values as booleans. The `boolean` method returns `true` for 1, "1", true, "true", "on", and "yes", ignoring case and surrounding whitespace. All other values will return `false`: ```php $archived = $request->boolean('archived'); ``` +If you need to tell a false value apart from an invalid one, pass `strict: true`. Values such as 0, "0", false, "false", "off", and "no" will still return `false`, but unrecognized or empty input will return the default value instead: + +```php +$archived = $request->boolean('archived', default: null, strict: true); +``` + #### Retrieving Array Input Values diff --git a/src/support/src/Facades/Request.php b/src/support/src/Facades/Request.php index 1db94b552e..103ec1f78e 100644 --- a/src/support/src/Facades/Request.php +++ b/src/support/src/Facades/Request.php @@ -16,7 +16,7 @@ * @method static bool anyFilled(array|string $keys) * @method static array array(array|string|null $key = null) * @method static string|null bearerToken() - * @method static bool boolean(string|null $key = null, bool $default = false) + * @method static ($strict is true ? bool|null : bool) boolean(string|null $key = null, \Closure|bool|null $default = false, bool $strict = false) * @method static int|float clamp(string $key, int|float $min, int|float $max, int|float $default = 0) * @method static \Hypervel\Support\Collection collect(array|string|null $key = null) * @method static array|string|null cookie(string|null $key = null, array|string|null $default = null) @@ -42,7 +42,7 @@ * @method static void flash() * @method static void flashExcept(mixed $keys) * @method static void flashOnly(mixed $keys) - * @method static float float(string $key, float $default = 0.0) + * @method static ($strict is true ? float|null : float) float(string $key, \Closure|float|null $default = 0.0, bool $strict = false) * @method static \Hypervel\Support\Fluent fluent(array|string|null $key = null, array $default = []) * @method static void flush() * @method static void flushMacros() @@ -118,7 +118,7 @@ * @method static void initialize(array $query = [], array $request = [], array $attributes = [], array $cookies = [], array $files = [], array $server = [], string|resource|null $content = null) * @method static mixed input(string|null $key = null, mixed $default = null) * @method static \Hypervel\Http\Request instance() - * @method static int integer(string $key, int $default = 0) + * @method static ($strict is true ? int|null : int) integer(string $key, \Closure|int|null $default = 0, bool $strict = false) * @method static \Carbon\CarbonInterval|null interval(string $key, \Carbon\Unit|string|null $unit = null) * @method static string|null ip() * @method static array ips() @@ -183,8 +183,8 @@ * @method static void setTrustedProxies(array $proxies, int $trustedHeaderSet) * @method static \Hypervel\Http\Request setUserResolver(\Closure $callback) * @method static \Hypervel\Support\CarbonImmutable startedAt() - * @method static \Hypervel\Support\Stringable str(string $key, mixed $default = null) - * @method static \Hypervel\Support\Stringable string(string $key, mixed $default = null) + * @method static \Hypervel\Support\Stringable str(string $key, mixed $default = null, bool $strict = false) + * @method static \Hypervel\Support\Stringable string(string $key, mixed $default = null, bool $strict = false) * @method static array toArray() * @method static mixed unless(mixed $value = null, null|callable $callback = null, null|callable $default = null) * @method static \Hypervel\Support\Uri uri() diff --git a/src/support/src/Traits/InteractsWithData.php b/src/support/src/Traits/InteractsWithData.php index 03ac6f5afc..9b32d2c8b3 100644 --- a/src/support/src/Traits/InteractsWithData.php +++ b/src/support/src/Traits/InteractsWithData.php @@ -9,6 +9,7 @@ use Carbon\CarbonInterval; use Carbon\Exceptions\InvalidFormatException; use Carbon\Unit; +use Closure; use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Date; @@ -242,42 +243,101 @@ protected function isEmptyString(string $key): bool /** * Retrieve data from the instance as a Stringable instance. */ - public function str(string $key, mixed $default = null): Stringable + public function str(string $key, mixed $default = null, bool $strict = false): Stringable { - return $this->string($key, $default); + return $this->string($key, $default, $strict); } /** * Retrieve data from the instance as a Stringable instance. + * + * When strict, values that are not strings, numbers, or Stringable objects return the default. */ - public function string(string $key, mixed $default = null): Stringable + public function string(string $key, mixed $default = null, bool $strict = false): Stringable { - return new Stringable($this->data($key, $default)); + // Strict mode resolves the default only after rejecting the value, so a lazy default runs once. + $value = $this->data($key, $strict ? null : $default); + + if ($strict && ! is_string($value) && ! is_int($value) && ! is_float($value) && ! $value instanceof BaseStringable) { + $value = value($default); + } + + return new Stringable($value); } /** * Retrieve data as a boolean value. * * Returns true when value is "1", "true", "on", and "yes". Otherwise, returns false. + * When strict, unrecognized or empty values return the default. + * + * @template TDefault of bool|null + * + * @param (Closure(): TDefault)|TDefault $default + * @return ($strict is true ? bool|TDefault : bool) */ - public function boolean(?string $key = null, bool $default = false): bool + public function boolean(?string $key = null, Closure|bool|null $default = false, bool $strict = false): ?bool { + if ($strict) { + // A null key reads all of the data, which is never a valid boolean. + return $key === null || $this->isNotFilled($key) + ? value($default) + : filter_var($this->data($key), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? value($default); + } + return filter_var($this->data($key, $default), FILTER_VALIDATE_BOOLEAN); } /** * Retrieve data as an integer value. + * + * When strict, values that are not valid integers return the default. + * + * @template TDefault of int|null + * + * @param (Closure(): TDefault)|TDefault $default + * @return ($strict is true ? int|TDefault : int) */ - public function integer(string $key, int $default = 0): int + public function integer(string $key, Closure|int|null $default = 0, bool $strict = false): ?int { + if ($strict) { + $value = $this->data($key); + + // FILTER_VALIDATE_INT returns 1 for true, but false for false, so we need to check for a bool first + if (is_bool($value)) { + return value($default); + } + + // Return the filtered value: a numeric Stringable passes the filter, but casting it to int fails. + return filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE) ?? value($default); + } + return (int) $this->data($key, $default); } /** * Retrieve data as a float value. + * + * When strict, values that are not valid numbers return the default. + * + * @template TDefault of float|null + * + * @param (Closure(): TDefault)|TDefault $default + * @return ($strict is true ? float|TDefault : float) */ - public function float(string $key, float $default = 0.0): float + public function float(string $key, Closure|float|null $default = 0.0, bool $strict = false): ?float { + if ($strict) { + $value = $this->data($key); + + if (is_bool($value)) { + return value($default); + } + + // Return the filtered value: a numeric Stringable passes the filter, but casting it to float fails. + return filter_var($value, FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE) ?? value($default); + } + return (float) $this->data($key, $default); } @@ -381,7 +441,9 @@ public function enums(string $key, string $enumClass): array */ public function array(array|string|null $key = null): array { - return (array) (is_array($key) ? $this->only($key) : $this->data($key)); + $value = is_array($key) ? $this->only($key) : $this->data($key); + + return is_object($value) ? Arr::from($value) : (array) $value; } /** diff --git a/src/view/src/ComponentAttributeBag.php b/src/view/src/ComponentAttributeBag.php index da846473c5..2f12a1f188 100644 --- a/src/view/src/ComponentAttributeBag.php +++ b/src/view/src/ComponentAttributeBag.php @@ -80,14 +80,6 @@ protected function data(?string $key = null, mixed $default = null): mixed return $this->get($key, $default); } - /** - * Retrieve data from the instance as an array. - */ - public function array(array|string|null $key = null): array - { - return is_array($key) ? $this->only($key)->toArray() : (array) $this->data($key); - } - /** * Only include the given attribute from the attribute array. */ diff --git a/tests/Console/CommandInputTest.php b/tests/Console/CommandInputTest.php new file mode 100644 index 0000000000..27cf142791 --- /dev/null +++ b/tests/Console/CommandInputTest.php @@ -0,0 +1,171 @@ +assertDataExistsContract(); + $this->assertDataExistsNestedContract(); + } + + public function testHasMethod(): void + { + $this->assertDataHasContract(); + $this->assertDataHasNestedContract(); + } + + public function testHasAnyMethod(): void + { + $this->assertDataHasAnyContract(); + $this->assertDataHasAnyNestedContract(); + } + + public function testWhenHasMethod(): void + { + $this->assertDataWhenHasContract(); + $this->assertDataWhenHasNestedContract(); + } + + public function testFilledMethod(): void + { + $this->assertDataFilledContract(); + $this->assertDataFilledNestedContract(); + } + + public function testIsNotFilledMethod(): void + { + $this->assertDataIsNotFilledContract(); + $this->assertDataIsNotFilledNestedContract(); + } + + public function testAnyFilledMethod(): void + { + $this->assertDataAnyFilledContract(); + $this->assertDataAnyFilledNestedContract(); + } + + public function testWhenFilledMethod(): void + { + $this->assertDataWhenFilledContract(); + $this->assertDataWhenFilledNestedContract(); + } + + public function testWhenEnumMethod(): void + { + $this->assertDataWhenEnumContract(); + } + + public function testMissingMethod(): void + { + $this->assertDataMissingContract(); + $this->assertDataMissingNestedContract(); + } + + public function testWhenMissingMethod(): void + { + $this->assertDataWhenMissingContract(); + $this->assertDataWhenMissingNestedContract(); + } + + public function testStrMethod(): void + { + $this->assertDataStrContract(); + } + + public function testStringMethod(): void + { + $this->assertDataStringContract(); + $this->assertDataStringStrictContract(); + } + + public function testBooleanMethod(): void + { + $this->assertDataBooleanContract(); + $this->assertDataBooleanStrictContract(); + $this->assertDataBooleanStrictNativeValuesContract(); + } + + public function testIntegerMethod(): void + { + $this->assertDataIntegerContract(); + $this->assertDataIntegerStrictContract(); + } + + public function testFloatMethod(): void + { + $this->assertDataFloatContract(); + $this->assertDataFloatStrictContract(); + } + + public function testClampMethod(): void + { + $this->assertDataClampContract(); + } + + public function testDateMethod(): void + { + $this->assertDataDateContract(); + } + + public function testIntervalMethod(): void + { + $this->assertDataIntervalContract(); + } + + public function testEnumMethod(): void + { + $this->assertDataEnumContract(); + $this->assertDataEnumNestedContract(); + } + + public function testEnumsMethod(): void + { + $this->assertDataEnumsContract(); + } + + public function testArrayMethod(): void + { + $this->assertDataArrayContract(); + $this->assertDataArrayArrayableContract(); + } + + public function testCollectMethod(): void + { + $this->assertDataCollectContract(); + $this->assertDataCollectNestedContract(); + } + + public function testOnlyMethod(): void + { + $this->assertDataOnlyContract(); + $this->assertDataOnlyNestedContract(); + } + + public function testExceptMethod(): void + { + $this->assertDataExceptContract(); + $this->assertDataExceptNestedContract(); + } + + public function testNullValues(): void + { + $this->assertDataNullValuesContract(); + $this->assertDataNullValuesIgnoreDefaultsContract(); + $this->assertDataNestedNullValuesContract(); + } + + protected static function arrayToDataFactory(array $data): object + { + return new CommandInput($data); + } +} diff --git a/tests/Http/Fixtures/TestEnum.php b/tests/Http/Fixtures/TestEnum.php deleted file mode 100644 index 132fc81724..0000000000 --- a/tests/Http/Fixtures/TestEnum.php +++ /dev/null @@ -1,10 +0,0 @@ - 'Taylor', 'age' => '', 'city' => null]); - $this->assertTrue($request->has('name')); - $this->assertTrue($request->has('age')); - $this->assertTrue($request->has('city')); - $this->assertFalse($request->has('foo')); - $this->assertFalse($request->has('name', 'email')); - - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'email' => 'foo']); - $this->assertTrue($request->has('name')); - $this->assertTrue($request->has('name', 'email')); - $this->assertTrue($request->has(['name', 'email'])); - - $request = Request::create('/', 'GET', ['foo' => ['bar', 'bar']]); - $this->assertTrue($request->has('foo')); - - $request = Request::create('/', 'GET', ['foo' => '', 'bar' => null]); - $this->assertTrue($request->has('foo')); - $this->assertTrue($request->has('bar')); - - $request = Request::create('/', 'GET', ['foo' => ['bar' => null, 'baz' => '']]); - $this->assertTrue($request->has('foo.bar')); - $this->assertTrue($request->has('foo.baz')); + $this->assertDataHasContract(); + $this->assertDataHasNestedContract(); } public function testWhenHasMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); - - $name = $age = $city = $foo = $bar = false; - - $request->whenHas('name', function ($value) use (&$name) { - $name = $value; - }); - - $request->whenHas('age', function ($value) use (&$age) { - $age = $value; - }); - - $request->whenHas('city', function ($value) use (&$city) { - $city = $value; - }); - - $request->whenHas('foo', function () use (&$foo) { - $foo = 'test'; - }); - - $request->whenHas('bar', function () use (&$bar) { - $bar = 'test'; - }, function () use (&$bar) { - $bar = true; - }); - - $this->assertSame('Taylor', $name); - $this->assertSame('', $age); - $this->assertNull($city); - $this->assertFalse($foo); - $this->assertTrue($bar); + $this->assertDataWhenHasContract(); + $this->assertDataWhenHasNestedContract(); } public function testWhenFilledMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); - - $name = $age = $city = $foo = $bar = false; - - $request->whenFilled('name', function ($value) use (&$name) { - $name = $value; - }); - - $request->whenFilled('age', function ($value) use (&$age) { - $age = 'test'; - }); - - $request->whenFilled('city', function ($value) use (&$city) { - $city = 'test'; - }); - - $request->whenFilled('foo', function () use (&$foo) { - $foo = 'test'; - }); - - $request->whenFilled('bar', function () use (&$bar) { - $bar = 'test'; - }, function () use (&$bar) { - $bar = true; - }); - - $this->assertSame('Taylor', $name); - $this->assertFalse($age); - $this->assertFalse($city); - $this->assertFalse($foo); - $this->assertTrue($bar); + $this->assertDataWhenFilledContract(); + $this->assertDataWhenFilledNestedContract(); } public function testWhenEnumMethod(): void { - $request = Request::create('/', 'GET', ['status' => 'test', 'invalid' => 'invalid', 'empty' => '']); - - $status = $invalid = $empty = $missing = $default = false; - - $request->whenEnum('status', TestEnumBacked::class, function ($value) use (&$status) { - $status = $value; - }); - - $request->whenEnum('invalid', TestEnumBacked::class, function ($value) use (&$invalid) { - $invalid = $value; - }); - - $request->whenEnum('empty', TestEnumBacked::class, function ($value) use (&$empty) { - $empty = $value; - }); - - $request->whenEnum('missing', TestEnumBacked::class, function ($value) use (&$missing) { - $missing = $value; - }, function () use (&$default) { - $default = true; - }); - - $this->assertSame(TestEnumBacked::Test, $status); - $this->assertFalse($invalid); - $this->assertFalse($empty); - $this->assertFalse($missing); - $this->assertTrue($default); + $this->assertDataWhenEnumContract(); } public function testMissingMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); - $this->assertFalse($request->missing('name')); - $this->assertFalse($request->missing('age')); - $this->assertFalse($request->missing('city')); - $this->assertTrue($request->missing('foo')); - $this->assertTrue($request->missing('name', 'email')); - $this->assertTrue($request->missing(['name', 'email'])); - - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'email' => 'foo']); - $this->assertFalse($request->missing('name')); - $this->assertFalse($request->missing('name', 'email')); - - $request = Request::create('/', 'GET', ['foo' => ['bar', 'bar']]); - $this->assertFalse($request->missing('foo')); - - $request = Request::create('/', 'GET', ['foo' => '', 'bar' => null]); - $this->assertFalse($request->missing('foo')); - $this->assertFalse($request->missing('bar')); - - $request = Request::create('/', 'GET', ['foo' => ['bar' => null, 'baz' => '']]); - $this->assertFalse($request->missing('foo.bar')); - $this->assertFalse($request->missing('foo.baz')); + $this->assertDataMissingContract(); + $this->assertDataMissingNestedContract(); } public function testWhenMissingMethod(): void { - $request = Request::create('/', 'GET', ['bar' => null]); - - $name = $age = $city = $foo = $bar = true; - - $request->whenMissing('name', function ($value) use (&$name) { - $name = 'Taylor'; - }); - - $request->whenMissing('age', function ($value) use (&$age) { - $age = ''; - }); - - $request->whenMissing('city', function ($value) use (&$city) { - $city = null; - }); - - $request->whenMissing('foo', function () use (&$foo) { - $foo = false; - }); - - $request->whenMissing('bar', function () use (&$bar) { - $bar = 'test'; - }, function () use (&$bar) { - $bar = true; - }); - - $this->assertSame('Taylor', $name); - $this->assertSame('', $age); - $this->assertNull($city); - $this->assertFalse($foo); - $this->assertTrue($bar); + $this->assertDataWhenMissingContract(); + $this->assertDataWhenMissingNestedContract(); } public function testHasAnyMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); - $this->assertTrue($request->hasAny('name')); - $this->assertTrue($request->hasAny('age')); - $this->assertTrue($request->hasAny('city')); - $this->assertFalse($request->hasAny('foo')); - $this->assertTrue($request->hasAny('name', 'email')); - $this->assertTrue($request->hasAny(['name', 'email'])); - - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'email' => 'foo']); - $this->assertTrue($request->hasAny('name', 'email')); - $this->assertFalse($request->hasAny('surname', 'password')); - $this->assertFalse($request->hasAny(['surname', 'password'])); - - $request = Request::create('/', 'GET', ['foo' => ['bar' => null, 'baz' => '']]); - $this->assertTrue($request->hasAny('foo.bar')); - $this->assertTrue($request->hasAny('foo.baz')); - $this->assertFalse($request->hasAny('foo.bax')); - $this->assertTrue($request->hasAny(['foo.bax', 'foo.baz'])); + $this->assertDataHasAnyContract(); + $this->assertDataHasAnyNestedContract(); } public function testFilledMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); - $this->assertTrue($request->filled('name')); - $this->assertFalse($request->filled('age')); - $this->assertFalse($request->filled('city')); - $this->assertFalse($request->filled('foo')); - $this->assertFalse($request->filled('name', 'email')); - - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'email' => 'foo']); - $this->assertTrue($request->filled('name')); - $this->assertTrue($request->filled('name', 'email')); - $this->assertTrue($request->filled(['name', 'email'])); - - // test arrays within query string - $request = Request::create('/', 'GET', ['foo' => ['bar', 'baz']]); - $this->assertTrue($request->filled('foo')); - - $request = Request::create('/', 'GET', ['foo' => ['bar' => 'baz']]); - $this->assertTrue($request->filled('foo.bar')); + $this->assertDataFilledContract(); + $this->assertDataFilledNestedContract(); } public function testIsNotFilledMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); - $this->assertFalse($request->isNotFilled('name')); - $this->assertTrue($request->isNotFilled('age')); - $this->assertTrue($request->isNotFilled('city')); - $this->assertTrue($request->isNotFilled('foo')); - $this->assertFalse($request->isNotFilled(['name', 'email'])); - $this->assertTrue($request->isNotFilled(['foo', 'age'])); - $this->assertTrue($request->isNotFilled(['age', 'city'])); - - $request = Request::create('/', 'GET', ['foo' => ['bar', 'baz' => '0']]); - $this->assertFalse($request->isNotFilled('foo')); - $this->assertTrue($request->isNotFilled('foo.bar')); - $this->assertFalse($request->isNotFilled('foo.baz')); + $this->assertDataIsNotFilledContract(); + $this->assertDataIsNotFilledNestedContract(); } public function testFilledAnyMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); - - $this->assertTrue($request->anyFilled(['name'])); - $this->assertTrue($request->anyFilled('name')); - - $this->assertFalse($request->anyFilled(['age'])); - $this->assertFalse($request->anyFilled('age')); - - $this->assertFalse($request->anyFilled(['foo'])); - $this->assertFalse($request->anyFilled('foo')); - - $this->assertTrue($request->anyFilled(['age', 'name'])); - $this->assertTrue($request->anyFilled('age', 'name')); - - $this->assertTrue($request->anyFilled(['foo', 'name'])); - $this->assertTrue($request->anyFilled('foo', 'name')); - - $this->assertFalse($request->anyFilled(['age', 'city'])); - $this->assertFalse($request->anyFilled('age', 'city')); + $this->assertDataAnyFilledContract(); + $this->assertDataAnyFilledNestedContract(); + } - $this->assertFalse($request->anyFilled(['foo', 'bar'])); - $this->assertFalse($request->anyFilled('foo', 'bar')); + public function testExistsMethod(): void + { + $this->assertDataExistsContract(); + $this->assertDataExistsNestedContract(); } public function testInputMethod(): void @@ -794,346 +578,67 @@ public function testFluentMethod(): void $this->assertSame(['name' => 'Guest'], $request->fluent('not_found', ['name' => 'Guest'])->toArray()); } + public function testStrMethod(): void + { + $this->assertDataStrContract(); + } + public function testStringMethod(): void { - $request = Request::create('/', 'GET', [ - 'int' => 123, - 'int_str' => '456', - 'float' => 123.456, - 'float_str' => '123.456', - 'float_zero' => 0.000, - 'float_str_zero' => '0.000', - 'str' => 'abc', - 'empty_str' => '', - 'null' => null, - ]); - $this->assertInstanceOf(Stringable::class, $request->string('int')); - $this->assertInstanceOf(Stringable::class, $request->string('unknown_key')); - $this->assertSame('123', $request->string('int')->value()); - $this->assertSame('456', $request->string('int_str')->value()); - $this->assertSame('123.456', $request->string('float')->value()); - $this->assertSame('123.456', $request->string('float_str')->value()); - $this->assertSame('0', $request->string('float_zero')->value()); - $this->assertSame('0.000', $request->string('float_str_zero')->value()); - $this->assertSame('', $request->string('empty_str')->value()); - $this->assertSame('', $request->string('null')->value()); - $this->assertSame('', $request->string('unknown_key')->value()); + $this->assertDataStringContract(); + $this->assertDataStringStrictContract(); } public function testBooleanMethod(): void { - $request = Request::create('/', 'GET', ['with_trashed' => 'false', 'download' => true, 'checked' => 1, 'unchecked' => '0', 'with_on' => 'on', 'with_yes' => 'yes']); - $this->assertTrue($request->boolean('checked')); - $this->assertTrue($request->boolean('download')); - $this->assertFalse($request->boolean('unchecked')); - $this->assertFalse($request->boolean('with_trashed')); - $this->assertFalse($request->boolean('some_undefined_key')); - $this->assertTrue($request->boolean('with_on')); - $this->assertTrue($request->boolean('with_yes')); + $this->assertDataBooleanContract(); + $this->assertDataBooleanStrictContract(); + $this->assertDataBooleanStrictNativeValuesContract(); } public function testIntegerMethod(): void { - $request = Request::create('/', 'GET', [ - 'int' => '123', - 'raw_int' => 456, - 'zero_padded' => '078', - 'space_padded' => ' 901', - 'nan' => 'nan', - 'mixed' => '1ab', - 'underscore_notation' => '2_000', - 'null' => null, - ]); - $this->assertSame(123, $request->integer('int')); - $this->assertSame(456, $request->integer('raw_int')); - $this->assertSame(78, $request->integer('zero_padded')); - $this->assertSame(901, $request->integer('space_padded')); - $this->assertSame(0, $request->integer('nan')); - $this->assertSame(1, $request->integer('mixed')); - $this->assertSame(2, $request->integer('underscore_notation')); - $this->assertSame(123456, $request->integer('unknown_key', 123456)); - $this->assertSame(0, $request->integer('null')); - $this->assertSame(0, $request->integer('null', 123456)); + $this->assertDataIntegerContract(); + $this->assertDataIntegerStrictContract(); } public function testFloatMethod(): void { - $request = Request::create('/', 'GET', [ - 'float' => '1.23', - 'raw_float' => 45.6, - 'decimal_only' => '.6', - 'zero_padded' => '0.78', - 'space_padded' => ' 90.1', - 'nan' => 'nan', - 'mixed' => '1.ab', - 'scientific_notation' => '1e3', - 'null' => null, - ]); - $this->assertSame(1.23, $request->float('float')); - $this->assertSame(45.6, $request->float('raw_float')); - $this->assertSame(.6, $request->float('decimal_only')); - $this->assertSame(0.78, $request->float('zero_padded')); - $this->assertSame(90.1, $request->float('space_padded')); - $this->assertSame(0.0, $request->float('nan')); - $this->assertSame(1.0, $request->float('mixed')); - $this->assertSame(1e3, $request->float('scientific_notation')); - $this->assertSame(123.456, $request->float('unknown_key', 123.456)); - $this->assertSame(0.0, $request->float('null')); - $this->assertSame(0.0, $request->float('null', 123.456)); + $this->assertDataFloatContract(); + $this->assertDataFloatStrictContract(); } public function testArrayMethod(): void { - $request = Request::create('/', 'GET', []); - $this->assertIsArray($request->array()); - $this->assertEmpty($request->array()); - - $request = Request::create('/', 'GET', [ - 'users' => [1, 2, 3], - 'roles' => [4, 5, 6], - 'email' => 'test@example.com', - ]); - - $this->assertEmpty($request->array('missing')); - $this->assertEmpty($request->array(['missing'])); - $this->assertEquals([1, 2, 3], $request->array('users')); - $this->assertEquals(['users' => [1, 2, 3]], $request->array(['users'])); - $this->assertEquals(['users' => [1, 2, 3], 'email' => 'test@example.com'], $request->array(['users', 'email'])); - - $this->assertEquals([ - 'users' => [1, 2, 3], - 'roles' => [4, 5, 6], - 'email' => 'test@example.com', - ], $request->array()); + $this->assertDataArrayContract(); + $this->assertDataArrayArrayableContract(); } public function testCollectMethod(): void { - $request = Request::create('/', 'GET', ['users' => [1, 2, 3]]); - - $this->assertInstanceOf(Collection::class, $request->collect('users')); - $this->assertTrue($request->collect('developers')->isEmpty()); - $this->assertEquals([1, 2, 3], $request->collect('users')->all()); - $this->assertEquals(['users' => [1, 2, 3]], $request->collect()->all()); - - $request = Request::create('/', 'GET', ['text-payload']); - $this->assertEquals(['text-payload'], $request->collect()->all()); - - $request = Request::create('/', 'GET', ['email' => 'test@example.com']); - $this->assertEquals(['test@example.com'], $request->collect('email')->all()); - - $request = Request::create('/', 'GET', []); - $this->assertInstanceOf(Collection::class, $request->collect()); - $this->assertTrue($request->collect()->isEmpty()); - - $request = Request::create('/', 'GET', ['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com']); - $this->assertInstanceOf(Collection::class, $request->collect(['users'])); - $this->assertTrue($request->collect(['developers'])->isEmpty()); - $this->assertTrue($request->collect(['roles'])->isNotEmpty()); - $this->assertEquals(['roles' => [4, 5, 6]], $request->collect(['roles'])->all()); - $this->assertEquals(['users' => [1, 2, 3], 'email' => 'test@example.com'], $request->collect(['users', 'email'])->all()); - $this->assertEquals(collect(['roles' => [4, 5, 6], 'foo' => ['bar', 'baz']]), $request->collect(['roles', 'foo'])); - $this->assertEquals(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com'], $request->collect()->all()); + $this->assertDataCollectContract(); + $this->assertDataCollectNestedContract(); } public function testDateMethod(): void { - $request = Request::create('/', 'GET', [ - 'as_null' => null, - 'as_invalid' => 'invalid', - - 'as_datetime' => '20-01-01 16:30:25', - 'as_format' => '1577896225', - 'as_timezone' => '20-01-01 13:30:25', - - 'as_date' => '2020-01-01', - 'as_time' => '16:30:25', - ]); - - $current = Carbon::create(2020, 1, 1, 16, 30, 25); - - $this->assertNull($request->date('as_null')); - $this->assertNull($request->date('doesnt_exists')); - - $this->assertEquals($current, $request->date('as_datetime')); - $this->assertEquals($current->format('Y-m-d H:i:s P'), $request->date('as_format', 'U')->format('Y-m-d H:i:s P')); - $this->assertEquals($current, $request->date('as_timezone', null, 'America/Santiago')); - - $this->assertTrue($request->date('as_date')->isSameDay($current)); - $this->assertTrue($request->date('as_time')->isSameSecond('16:30:25')); - } - - public function testDateMethodExceptionWhenValueInvalid(): void - { - $this->expectException(InvalidArgumentException::class); - - $request = Request::create('/', 'GET', [ - 'date' => 'invalid', - ]); - - $request->date('date'); - } - - public function testDateMethodExceptionWhenFormatInvalid(): void - { - $this->expectException(InvalidArgumentException::class); - - $request = Request::create('/', 'GET', [ - 'date' => '20-01-01 16:30:25', - ]); - - $request->date('date', 'invalid_format'); + $this->assertDataDateContract(); } public function testIntervalMethod(): void { - $request = Request::create('/', 'GET', [ - 'as_null' => null, - 'as_empty' => '', - 'as_iso' => 'P1Y2M3DT4H5M6S', - 'as_human' => '2 hours 30 minutes', - 'as_seconds' => '90', - 'as_minutes' => '45', - ]); - - $this->assertNull($request->interval('as_null')); - $this->assertNull($request->interval('as_empty')); - $this->assertNull($request->interval('doesnt_exist')); - - $interval = $request->interval('as_iso'); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(1, $interval->years); - $this->assertSame(2, $interval->months); - $this->assertSame(3, $interval->dayz); - $this->assertSame(4, $interval->hours); - $this->assertSame(5, $interval->minutes); - $this->assertSame(6, $interval->seconds); - - $interval = $request->interval('as_human'); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(2, $interval->hours); - $this->assertSame(30, $interval->minutes); - - $interval = $request->interval('as_seconds', 'second'); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(90, $interval->seconds); - - $this->assertSame(90, $request->interval('as_seconds', 'minute')->minutes); - $this->assertSame(90, $request->interval('as_seconds', 'hour')->hours); - $this->assertSame(90, $request->interval('as_seconds', 'day')->dayz); - - $this->assertSame(45, $request->interval('as_minutes', Unit::Minute)->minutes); - $this->assertSame(45, $request->interval('as_minutes', Unit::Second)->seconds); - } - - public function testIntervalMethodWithScientificNotationFloats(): void - { - $request = Request::create('/', 'GET', [ - 'small_float' => '0.000053', - 'very_small' => '0.000001', - 'scientific' => '5.3E-5', - 'normal_float' => '1.5', - 'integer' => '90', - ]); - - // Small floats that PHP would render in scientific notation (e.g. 5.3E-5) - $interval = $request->interval('small_float', Unit::Millisecond); - $this->assertInstanceOf(CarbonInterval::class, $interval); - - $interval = $request->interval('very_small', Unit::Second); - $this->assertInstanceOf(CarbonInterval::class, $interval); - - // Scientific notation string passed directly - $interval = $request->interval('scientific', Unit::Millisecond); - $this->assertInstanceOf(CarbonInterval::class, $interval); - - // Normal values should still work - $interval = $request->interval('normal_float', Unit::Hour); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(1, $interval->hours); - - $interval = $request->interval('integer', Unit::Minute); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(90, $interval->minutes); + $this->assertDataIntervalContract(); } public function testEnumMethod(): void { - $request = Request::create('/', 'GET', [ - 'valid_enum_value' => 'test', - 'invalid_enum_value' => 'invalid', - 'empty_value_request' => '', - 'string' => [ - 'minus_1' => '-1', - '0' => '0', - 'plus_1' => '1', - 'doesnt_exist' => '-1024', - ], - 'int' => [ - 'minus_1' => -1, - '0' => 0, - 'plus_1' => 1, - 'doesnt_exist' => 1024, - ], - ]); - - $this->assertNull($request->enum('doesnt_exist', TestEnumBacked::class)); - - $this->assertEquals(TestEnumBacked::Test, $request->enum('invalid_enum_value', TestEnumBacked::class, TestEnumBacked::Test)); - $this->assertEquals(TestEnumBacked::Test, $request->enum('missing_key', TestEnumBacked::class, TestEnumBacked::Test)); - - $this->assertEquals(TestEnumBacked::Test, $request->enum('valid_enum_value', TestEnumBacked::class)); - - $this->assertNull($request->enum('invalid_enum_value', TestEnumBacked::class)); - $this->assertNull($request->enum('empty_value_request', TestEnumBacked::class)); - $this->assertNull($request->enum('valid_enum_value', TestEnum::class)); - - $this->assertEquals(TestIntegerEnumBacked::Minus1, $request->enum('string.minus_1', TestIntegerEnumBacked::class)); - $this->assertEquals(TestIntegerEnumBacked::Zero, $request->enum('string.0', TestIntegerEnumBacked::class)); - $this->assertEquals(TestIntegerEnumBacked::Plus1, $request->enum('string.plus_1', TestIntegerEnumBacked::class)); - $this->assertNull($request->enum('string.doesnt_exist', TestIntegerEnumBacked::class)); - $this->assertEquals(TestIntegerEnumBacked::Minus1, $request->enum('int.minus_1', TestIntegerEnumBacked::class)); - $this->assertEquals(TestIntegerEnumBacked::Zero, $request->enum('int.0', TestIntegerEnumBacked::class)); - $this->assertEquals(TestIntegerEnumBacked::Plus1, $request->enum('int.plus_1', TestIntegerEnumBacked::class)); - $this->assertNull($request->enum('int.doesnt_exist', TestIntegerEnumBacked::class)); + $this->assertDataEnumContract(); + $this->assertDataEnumNestedContract(); } public function testEnumsMethod(): void { - $request = Request::create('/', 'GET', [ - 'valid_enum_values' => ['test', 'test'], - 'invalid_enum_values' => ['invalid', 'invalid'], - 'empty_value_request' => [], - 'string' => [ - 'minus_1' => ['-1', '0'], - '0' => '0', - 'plus_1' => '1', - 'doesnt_exist' => '-1024', - ], - 'int' => [ - 'minus_1' => -1, - '0' => 0, - 'plus_1' => 1, - 'doesnt_exist' => 1024, - ], - ]); - - $this->assertEmpty($request->enums('doesnt_exist', TestEnumBacked::class)); - - $this->assertEquals([TestEnumBacked::Test, TestEnumBacked::Test], $request->enums('valid_enum_values', TestEnumBacked::class)); - - $this->assertEmpty($request->enums('invalid_enum_values', TestEnumBacked::class)); - $this->assertEmpty($request->enums('empty_value_request', TestEnumBacked::class)); - $this->assertEmpty($request->enums('valid_enum_values', TestEnum::class)); - - $this->assertEquals([TestIntegerEnumBacked::Minus1, TestIntegerEnumBacked::Zero], $request->enums('string.minus_1', TestIntegerEnumBacked::class)); - $this->assertEquals([TestIntegerEnumBacked::Zero], $request->enums('string.0', TestIntegerEnumBacked::class)); - $this->assertEquals([TestIntegerEnumBacked::Plus1], $request->enums('string.plus_1', TestIntegerEnumBacked::class)); - $this->assertEmpty($request->enums('string.doesnt_exist', TestIntegerEnumBacked::class)); - $this->assertEquals([TestIntegerEnumBacked::Minus1], $request->enums('int.minus_1', TestIntegerEnumBacked::class)); - $this->assertEquals([TestIntegerEnumBacked::Zero], $request->enums('int.0', TestIntegerEnumBacked::class)); - $this->assertEquals([TestIntegerEnumBacked::Plus1], $request->enums('int.plus_1', TestIntegerEnumBacked::class)); - $this->assertEmpty($request->enums('int.doesnt_exist', TestIntegerEnumBacked::class)); + $this->assertDataEnumsContract(); } public function testArrayAccess(): void @@ -1222,21 +727,21 @@ public function testKeysMethod(): void public function testOnlyMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => null]); - $this->assertEquals(['name' => 'Taylor', 'age' => null], $request->only('name', 'age', 'email')); - - $request = Request::create('/', 'GET', ['developer' => ['name' => 'Taylor', 'age' => null]]); - $this->assertEquals(['developer' => ['name' => 'Taylor']], $request->only('developer.name', 'developer.skills')); - $this->assertEquals(['developer' => ['age' => null]], $request->only('developer.age')); - $this->assertSame([], $request->only('developer.skills')); + $this->assertDataOnlyContract(); + $this->assertDataOnlyNestedContract(); } public function testExceptMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => 25]); - $this->assertEquals(['name' => 'Taylor'], $request->except('age')); - $this->assertSame([], $request->except('age', 'name')); - $this->assertSame([], $request->except(['age', 'name'])); + $this->assertDataExceptContract(); + $this->assertDataExceptNestedContract(); + } + + public function testNullValues(): void + { + $this->assertDataNullValuesContract(); + $this->assertDataNullValuesIgnoreDefaultsContract(); + $this->assertDataNestedNullValuesContract(); } public function testQueryMethod(): void @@ -2269,22 +1774,11 @@ public function testItDoesNotGenerateJsonErrorsForEmptyContent(): void public function testItClampsValues(): void { - $request = Request::create('/', 'GET', [ - 'per_page' => 100, - 'float' => 9.24, - 'empty' => '', - 'null' => null, - 'string' => 'string', - ]); + $this->assertDataClampContract(); + } - $this->assertSame(100, $request->clamp('per_page', 100, 101)); - $this->assertSame(10, $request->clamp('per_page', -10, 10)); - $this->assertSame(25, $request->clamp('per_page_2', 25, 100, 1)); - $this->assertSame(100, $request->clamp('per_page', 1, 250, 99)); - $this->assertSame(22.4, $request->clamp('per_page', 1.11, 22.4, 2)); - $this->assertSame(9.24, $request->clamp('float', 1, 10)); - $this->assertSame(15, $request->clamp('empty', 10, 100, 15)); - $this->assertSame(15, $request->clamp('string', 10, 100, 15)); - $this->assertSame(15, $request->clamp('null', 10, 100, 15)); + protected static function arrayToDataFactory(array $data): object + { + return Request::create('/', 'GET', $data); } } diff --git a/tests/Support/Concerns/TestsInteractsWithDataContract.php b/tests/Support/Concerns/TestsInteractsWithDataContract.php new file mode 100644 index 0000000000..c910ff0882 --- /dev/null +++ b/tests/Support/Concerns/TestsInteractsWithDataContract.php @@ -0,0 +1,1075 @@ + 'Taylor', 'surname' => 'Otwell', 'age' => '']); + + $this->assertTrue($data->exists('name')); + $this->assertTrue($data->exists('age')); + $this->assertTrue($data->exists(['name', 'surname'])); + $this->assertFalse($data->exists('votes')); + $this->assertFalse($data->exists(['name', 'votes'])); + } + + protected function assertDataExistsNestedContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'foo' => ['bar' => 'bar', 'baz' => '']]); + + $this->assertTrue($data->exists('foo')); + $this->assertTrue($data->exists('foo.bar')); + $this->assertTrue($data->exists(['name', 'foo.baz'])); + $this->assertFalse($data->exists(['name', 'foo.bax'])); + } + + protected function assertDataHasContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); + $this->assertTrue($data->has('name')); + $this->assertTrue($data->has('age')); + $this->assertTrue($data->has('city')); + $this->assertTrue($data->has(['name'])); + $this->assertFalse($data->has('foo')); + $this->assertFalse($data->has(['foo'])); + $this->assertFalse($data->has('name', 'email')); + $this->assertFalse($data->has(['name', 'email'])); + + $data = static::arrayToDataFactory(['name' => 'Taylor', 'email' => 'foo']); + $this->assertTrue($data->has('name', 'email')); + $this->assertTrue($data->has(['name', 'email'])); + + $data = static::arrayToDataFactory(['foo' => ['bar', 'bar']]); + $this->assertTrue($data->has('foo')); + } + + protected function assertDataHasNestedContract(): void + { + $data = static::arrayToDataFactory(['foo' => ['bar' => 'bar', 'baz' => '']]); + + $this->assertTrue($data->has('foo')); + $this->assertTrue($data->has('foo.bar')); + $this->assertTrue($data->has('foo.baz')); + $this->assertFalse($data->has('foo.bax')); + $this->assertFalse($data->has(['foo.bar', 'foo.bax'])); + } + + protected function assertDataHasAnyContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); + $this->assertTrue($data->hasAny('name')); + $this->assertTrue($data->hasAny('age')); + $this->assertTrue($data->hasAny('city')); + $this->assertFalse($data->hasAny('foo')); + $this->assertTrue($data->hasAny('name', 'email')); + $this->assertTrue($data->hasAny(['name', 'email'])); + $this->assertFalse($data->hasAny('surname', 'password')); + $this->assertFalse($data->hasAny(['surname', 'password'])); + } + + protected function assertDataHasAnyNestedContract(): void + { + $data = static::arrayToDataFactory(['foo' => ['bar' => 'bar', 'baz' => '']]); + + $this->assertTrue($data->hasAny('foo.bar')); + $this->assertTrue($data->hasAny('foo.baz')); + $this->assertFalse($data->hasAny('foo.bax')); + $this->assertTrue($data->hasAny(['foo.bax', 'foo.baz'])); + $this->assertTrue($data->hasAny(['votes', 'foo'])); + } + + protected function assertDataWhenHasContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); + + $name = $age = $city = $foo = $bar = false; + + $data->whenHas('name', function ($value) use (&$name) { + $name = $value; + }); + + $data->whenHas('age', function ($value) use (&$age) { + $age = $value; + }); + + $data->whenHas('city', function ($value) use (&$city) { + $city = $value; + }); + + $data->whenHas('foo', function () use (&$foo) { + $foo = 'test'; + }); + + $data->whenHas('bar', function () use (&$bar) { + $bar = 'test'; + }, function () use (&$bar) { + $bar = true; + }); + + $this->assertSame('Taylor', $name); + $this->assertSame('', $age); + $this->assertSame(' ', $city); + $this->assertFalse($foo); + $this->assertTrue($bar); + + $this->assertSame('callback-Taylor', $data->whenHas('name', fn ($value) => 'callback-' . $value)); + $this->assertSame($data, $data->whenHas('name', fn () => null)); + $this->assertSame($data, $data->whenHas('missing', fn ($value) => 'callback-' . $value)); + $this->assertSame('default', $data->whenHas('missing', fn ($value) => 'callback-' . $value, fn () => 'default')); + } + + protected function assertDataWhenHasNestedContract(): void + { + $data = static::arrayToDataFactory(['foo' => ['bar' => '']]); + + $foo = $bar = $baz = false; + + $data->whenHas('foo', function ($value) use (&$foo) { + $foo = $value; + }); + + $data->whenHas('foo.bar', function ($value) use (&$bar) { + $bar = $value; + }); + + $data->whenHas('foo.baz', function () use (&$baz) { + $baz = 'test'; + }); + + $this->assertEquals(['bar' => ''], $foo); + $this->assertSame('', $bar); + $this->assertFalse($baz); + } + + protected function assertDataFilledContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); + $this->assertTrue($data->filled('name')); + $this->assertFalse($data->filled('age')); + $this->assertFalse($data->filled('city')); + $this->assertFalse($data->filled('foo')); + $this->assertFalse($data->filled('name', 'email')); + $this->assertFalse($data->filled(['name', 'email'])); + + $data = static::arrayToDataFactory(['name' => 'Taylor', 'email' => 'foo']); + $this->assertTrue($data->filled('name', 'email')); + $this->assertTrue($data->filled(['name', 'email'])); + + $data = static::arrayToDataFactory(['zero' => '0', 'false' => false, 'whitespace' => ' ']); + $this->assertTrue($data->filled('zero')); + $this->assertTrue($data->filled('false')); + $this->assertFalse($data->filled('whitespace')); + + $data = static::arrayToDataFactory(['foo' => ['bar', 'baz']]); + $this->assertTrue($data->filled('foo')); + } + + protected function assertDataFilledNestedContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'foo' => ['bar' => ' ', 'baz' => '', 'bax' => 'bax']]); + + $this->assertTrue($data->filled(['name', 'foo'])); + $this->assertTrue($data->filled('foo.bax')); + $this->assertFalse($data->filled('foo.bar')); + $this->assertFalse($data->filled(['name', 'foo.baz'])); + } + + protected function assertDataIsNotFilledContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => '0', 'whitespace' => ' ']); + $this->assertFalse($data->isNotFilled('name')); + $this->assertTrue($data->isNotFilled('age')); + $this->assertFalse($data->isNotFilled('city')); + $this->assertTrue($data->isNotFilled('whitespace')); + $this->assertTrue($data->isNotFilled('foo')); + $this->assertFalse($data->isNotFilled(['name', 'email'])); + $this->assertFalse($data->isNotFilled('name', 'email')); + $this->assertTrue($data->isNotFilled(['foo', 'age'])); + $this->assertTrue($data->isNotFilled(['age', 'whitespace'])); + $this->assertTrue($data->isNotFilled('age', 'whitespace')); + $this->assertFalse($data->isNotFilled('age', 'city')); + } + + protected function assertDataIsNotFilledNestedContract(): void + { + $data = static::arrayToDataFactory(['foo' => ['bar', 'baz' => '0', 'bax' => '']]); + + $this->assertFalse($data->isNotFilled('foo')); + $this->assertFalse($data->isNotFilled('foo.baz')); + $this->assertTrue($data->isNotFilled('foo.bar')); + $this->assertTrue($data->isNotFilled('foo.bax')); + } + + protected function assertDataAnyFilledContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); + + $this->assertTrue($data->anyFilled(['name'])); + $this->assertTrue($data->anyFilled('name')); + + $this->assertFalse($data->anyFilled(['age'])); + $this->assertFalse($data->anyFilled('age')); + + $this->assertFalse($data->anyFilled(['foo'])); + $this->assertFalse($data->anyFilled('foo')); + + $this->assertTrue($data->anyFilled(['age', 'name'])); + $this->assertTrue($data->anyFilled('age', 'name')); + + $this->assertTrue($data->anyFilled(['foo', 'name'])); + $this->assertTrue($data->anyFilled('foo', 'name')); + + $this->assertFalse($data->anyFilled(['age', 'city'])); + $this->assertFalse($data->anyFilled('age', 'city')); + + $this->assertFalse($data->anyFilled(['foo', 'bar'])); + $this->assertFalse($data->anyFilled('foo', 'bar')); + } + + protected function assertDataAnyFilledNestedContract(): void + { + $data = static::arrayToDataFactory(['foo' => ['bar' => '', 'baz' => 'baz']]); + + $this->assertTrue($data->anyFilled('foo')); + $this->assertTrue($data->anyFilled(['foo.bar', 'foo.baz'])); + $this->assertFalse($data->anyFilled('foo.bar')); + } + + protected function assertDataWhenFilledContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); + + $name = $age = $city = $foo = $bar = false; + + $data->whenFilled('name', function ($value) use (&$name) { + $name = $value; + }); + + $data->whenFilled('age', function () use (&$age) { + $age = 'test'; + }); + + $data->whenFilled('city', function () use (&$city) { + $city = 'test'; + }); + + $data->whenFilled('foo', function () use (&$foo) { + $foo = 'test'; + }); + + $data->whenFilled('bar', function () use (&$bar) { + $bar = 'test'; + }, function () use (&$bar) { + $bar = true; + }); + + $this->assertSame('Taylor', $name); + $this->assertFalse($age); + $this->assertFalse($city); + $this->assertFalse($foo); + $this->assertTrue($bar); + + $this->assertSame('callback-Taylor', $data->whenFilled('name', fn ($value) => 'callback-' . $value)); + $this->assertSame($data, $data->whenFilled('name', fn () => null)); + $this->assertSame($data, $data->whenFilled('age', fn ($value) => 'callback-' . $value)); + $this->assertSame('default', $data->whenFilled('age', fn ($value) => 'callback-' . $value, fn () => 'default')); + } + + protected function assertDataWhenFilledNestedContract(): void + { + $data = static::arrayToDataFactory(['foo' => ['bar' => '', 'baz' => 'baz']]); + + $foo = $bar = $baz = false; + + $data->whenFilled('foo', function ($value) use (&$foo) { + $foo = $value; + }); + + $data->whenFilled('foo.bar', function () use (&$bar) { + $bar = 'test'; + }); + + $data->whenFilled('foo.baz', function ($value) use (&$baz) { + $baz = $value; + }); + + $this->assertEquals(['bar' => '', 'baz' => 'baz'], $foo); + $this->assertFalse($bar); + $this->assertSame('baz', $baz); + } + + protected function assertDataWhenEnumContract(): void + { + $data = static::arrayToDataFactory(['status' => 'A', 'invalid' => 'invalid', 'empty' => '']); + + $status = $invalid = $empty = $emptyCase = $unit = $missing = $default = false; + + $data->whenEnum('status', TestStringBackedEnum::class, function ($value) use (&$status) { + $status = $value; + }); + + $data->whenEnum('invalid', TestStringBackedEnum::class, function ($value) use (&$invalid) { + $invalid = $value; + }); + + $data->whenEnum('empty', TestStringBackedEnum::class, function ($value) use (&$empty) { + $empty = $value; + }); + + $data->whenEnum('empty', TestEmptyStringBackedEnum::class, function ($value) use (&$emptyCase) { + $emptyCase = $value; + }); + + $data->whenEnum('status', TestEnum::class, function ($value) use (&$unit) { + $unit = $value; + }); + + $data->whenEnum('missing', TestStringBackedEnum::class, function ($value) use (&$missing) { + $missing = $value; + }, function () use (&$default) { + $default = true; + }); + + $this->assertSame(TestStringBackedEnum::A, $status); + $this->assertFalse($invalid); + $this->assertFalse($empty); + $this->assertFalse($emptyCase); + $this->assertFalse($unit); + $this->assertFalse($missing); + $this->assertTrue($default); + + $this->assertSame('callback-A', $data->whenEnum('status', TestStringBackedEnum::class, fn ($value) => 'callback-' . $value->value)); + $this->assertSame($data, $data->whenEnum('status', TestStringBackedEnum::class, fn () => null)); + $this->assertSame($data, $data->whenEnum('missing', TestStringBackedEnum::class, fn () => 'callback')); + $this->assertSame('default', $data->whenEnum('missing', TestStringBackedEnum::class, fn () => 'callback', fn () => 'default')); + } + + protected function assertDataMissingContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); + $this->assertFalse($data->missing('name')); + $this->assertFalse($data->missing('age')); + $this->assertFalse($data->missing('city')); + $this->assertTrue($data->missing('foo')); + $this->assertTrue($data->missing(['foo'])); + $this->assertTrue($data->missing('name', 'email')); + $this->assertTrue($data->missing(['name', 'email'])); + + $data = static::arrayToDataFactory(['name' => 'Taylor', 'email' => 'foo']); + $this->assertFalse($data->missing('name', 'email')); + $this->assertFalse($data->missing(['name', 'email'])); + } + + protected function assertDataMissingNestedContract(): void + { + $data = static::arrayToDataFactory(['foo' => ['bar' => 'bar', 'baz' => '']]); + + $this->assertFalse($data->missing('foo')); + $this->assertFalse($data->missing('foo.bar')); + $this->assertFalse($data->missing('foo.baz')); + $this->assertTrue($data->missing('foo.bax')); + $this->assertTrue($data->missing(['foo.bar', 'foo.bax'])); + } + + protected function assertDataWhenMissingContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'bar' => '']); + + $age = $city = $name = $bar = false; + + $data->whenMissing('age', function () use (&$age) { + $age = ''; + }); + + $data->whenMissing('city', function ($value) use (&$city) { + $city = $value; + }); + + $data->whenMissing('name', function () use (&$name) { + $name = 'test'; + }); + + $data->whenMissing('bar', function () use (&$bar) { + $bar = 'test'; + }, function () use (&$bar) { + $bar = true; + }); + + $this->assertSame('', $age); + $this->assertNull($city); + $this->assertFalse($name); + $this->assertTrue($bar); + + $this->assertSame('callback', $data->whenMissing('missing', fn () => 'callback')); + $this->assertSame($data, $data->whenMissing('missing', fn () => null)); + $this->assertSame($data, $data->whenMissing('name', fn () => 'callback')); + $this->assertSame('default', $data->whenMissing('name', fn () => 'callback', fn () => 'default')); + } + + protected function assertDataWhenMissingNestedContract(): void + { + $data = static::arrayToDataFactory(['foo' => ['bar' => '']]); + + $foo = $bar = $baz = false; + + $data->whenMissing('foo', function () use (&$foo) { + $foo = 'test'; + }); + + $data->whenMissing('foo.bar', function () use (&$bar) { + $bar = 'test'; + }); + + $data->whenMissing('foo.baz', function () use (&$baz) { + $baz = true; + }); + + $this->assertFalse($foo); + $this->assertFalse($bar); + $this->assertTrue($baz); + } + + protected function assertDataStrContract(): void + { + $data = static::arrayToDataFactory([ + 'int' => 123, + 'str' => 'abc', + 'empty_str' => '', + ]); + + $this->assertInstanceOf(Stringable::class, $data->str('int')); + $this->assertInstanceOf(Stringable::class, $data->str('unknown_key')); + $this->assertSame('123', $data->str('int')->value()); + $this->assertSame('abc', $data->str('str')->value()); + $this->assertSame('', $data->str('empty_str')->value()); + $this->assertSame('', $data->str('unknown_key')->value()); + $this->assertSame('default', $data->str('unknown_key', 'default')->value()); + } + + protected function assertDataStringContract(): void + { + $data = static::arrayToDataFactory([ + 'int' => 123, + 'int_str' => '456', + 'float' => 123.456, + 'float_str' => '123.456', + 'float_str_zero' => '0.000', + 'str' => 'abc', + 'empty_str' => '', + ]); + + $this->assertInstanceOf(Stringable::class, $data->string('int')); + $this->assertInstanceOf(Stringable::class, $data->string('unknown_key')); + $this->assertSame('123', $data->string('int')->value()); + $this->assertSame('456', $data->string('int_str')->value()); + $this->assertSame('123.456', $data->string('float')->value()); + $this->assertSame('123.456', $data->string('float_str')->value()); + $this->assertSame('0.000', $data->string('float_str_zero')->value()); + $this->assertSame('abc', $data->string('str')->value()); + $this->assertSame('', $data->string('empty_str')->value()); + $this->assertSame('', $data->string('unknown_key')->value()); + $this->assertSame('default', $data->string('unknown_key', 'default')->value()); + } + + protected function assertDataStringStrictContract(): void + { + $data = static::arrayToDataFactory([ + 'int' => 123, + 'float' => 1.5, + 'str' => 'abc', + 'empty_str' => '', + 'array' => ['a', 'b'], + ]); + + $this->assertSame('123', $data->string('int', 'default', true)->value()); + $this->assertSame('1.5', $data->string('float', 'default', true)->value()); + $this->assertSame('abc', $data->string('str', 'default', true)->value()); + $this->assertSame('', $data->string('empty_str', 'default', true)->value()); + $this->assertSame('default', $data->string('array', 'default', true)->value()); + $this->assertSame('default', $data->string('unknown_key', 'default', true)->value()); + $this->assertSame('', $data->string('array', strict: true)->value()); + + $this->assertInstanceOf(Stringable::class, $data->str('array', 'default', true)); + $this->assertSame('abc', $data->str('str', 'default', true)->value()); + $this->assertSame('default', $data->str('array', 'default', true)->value()); + + $this->assertSame('default', $data->string('array', fn () => 'default', true)->value()); + } + + protected function assertDataBooleanContract(): void + { + $data = static::arrayToDataFactory([ + 'with_trashed' => 'false', + 'download' => true, + 'checked' => 1, + 'unchecked' => '0', + 'with_on' => 'on', + 'with_off' => 'off', + 'with_yes' => 'yes', + 'with_no' => 'no', + 'with_true' => 'true', + ]); + + $this->assertTrue($data->boolean('checked')); + $this->assertTrue($data->boolean('download')); + $this->assertFalse($data->boolean('unchecked')); + $this->assertFalse($data->boolean('with_trashed')); + $this->assertTrue($data->boolean('with_on')); + $this->assertFalse($data->boolean('with_off')); + $this->assertTrue($data->boolean('with_yes')); + $this->assertFalse($data->boolean('with_no')); + $this->assertTrue($data->boolean('with_true')); + $this->assertFalse($data->boolean('some_undefined_key')); + $this->assertTrue($data->boolean('some_undefined_key', true)); + } + + protected function assertDataBooleanStrictContract(): void + { + $data = static::arrayToDataFactory([ + 'one' => '1', + 'true' => 'true', + 'on' => 'on', + 'yes' => 'yes', + 'raw_one' => 1, + 'zero' => '0', + 'false' => 'false', + 'off' => 'off', + 'no' => 'no', + 'raw_zero' => 0, + 'two' => '2', + 'maybe' => 'maybe', + 'empty_str' => '', + 'whitespace' => ' ', + 'array' => ['1'], + ]); + + $this->assertTrue($data->boolean('one', null, true)); + $this->assertTrue($data->boolean('true', null, true)); + $this->assertTrue($data->boolean('on', null, true)); + $this->assertTrue($data->boolean('yes', null, true)); + $this->assertTrue($data->boolean('raw_one', null, true)); + $this->assertFalse($data->boolean('zero', null, true)); + $this->assertFalse($data->boolean('false', null, true)); + $this->assertFalse($data->boolean('off', null, true)); + $this->assertFalse($data->boolean('no', null, true)); + $this->assertFalse($data->boolean('raw_zero', null, true)); + $this->assertFalse($data->boolean('zero', true, true)); + + $this->assertNull($data->boolean('two', null, true)); + $this->assertNull($data->boolean('maybe', null, true)); + $this->assertNull($data->boolean('empty_str', null, true)); + $this->assertNull($data->boolean('whitespace', null, true)); + $this->assertNull($data->boolean('array', null, true)); + $this->assertNull($data->boolean('unknown_key', null, true)); + $this->assertTrue($data->boolean('maybe', true, true)); + $this->assertFalse($data->boolean('maybe', strict: true)); + + $this->assertTrue($data->boolean('maybe', fn () => true, true)); + $this->assertTrue($data->boolean('empty_str', fn () => true, true)); + } + + protected function assertDataIntegerContract(): void + { + $data = static::arrayToDataFactory([ + 'int' => '123', + 'raw_int' => 456, + 'float' => '123.45', + 'zero_padded' => '078', + 'space_padded' => ' 901', + 'nan' => 'nan', + 'mixed' => '1ab', + 'underscore_notation' => '2_000', + ]); + + $this->assertSame(123, $data->integer('int')); + $this->assertSame(456, $data->integer('raw_int')); + $this->assertSame(123, $data->integer('float')); + $this->assertSame(78, $data->integer('zero_padded')); + $this->assertSame(901, $data->integer('space_padded')); + $this->assertSame(0, $data->integer('nan')); + $this->assertSame(1, $data->integer('mixed')); + $this->assertSame(2, $data->integer('underscore_notation')); + $this->assertSame(0, $data->integer('unknown_key')); + $this->assertSame(123456, $data->integer('unknown_key', 123456)); + } + + protected function assertDataIntegerStrictContract(): void + { + $data = static::arrayToDataFactory([ + 'int' => '123', + 'raw_int' => 456, + 'zero' => '0', + 'negative' => '-5', + 'signed' => '+5', + 'space_padded' => ' 901 ', + 'float' => '123.45', + 'whole_float' => '1.0', + 'zero_padded' => '078', + 'mixed' => '1ab', + 'underscore_notation' => '2_000', + 'scientific_notation' => '1e3', + 'nan' => 'nan', + 'empty_str' => '', + 'array' => ['1'], + ]); + + $this->assertSame(123, $data->integer('int', null, true)); + $this->assertSame(456, $data->integer('raw_int', null, true)); + $this->assertSame(0, $data->integer('zero', 7, true)); + $this->assertSame(-5, $data->integer('negative', null, true)); + $this->assertSame(5, $data->integer('signed', null, true)); + $this->assertSame(901, $data->integer('space_padded', null, true)); + + $this->assertNull($data->integer('float', null, true)); + $this->assertNull($data->integer('whole_float', null, true)); + $this->assertNull($data->integer('zero_padded', null, true)); + $this->assertNull($data->integer('mixed', null, true)); + $this->assertNull($data->integer('underscore_notation', null, true)); + $this->assertNull($data->integer('scientific_notation', null, true)); + $this->assertNull($data->integer('nan', null, true)); + $this->assertNull($data->integer('empty_str', null, true)); + $this->assertNull($data->integer('array', null, true)); + $this->assertNull($data->integer('unknown_key', null, true)); + $this->assertSame(7, $data->integer('mixed', 7, true)); + $this->assertSame(0, $data->integer('mixed', strict: true)); + + $this->assertSame(7, $data->integer('mixed', fn () => 7, true)); + } + + protected function assertDataFloatContract(): void + { + $data = static::arrayToDataFactory([ + 'float' => '1.23', + 'raw_float' => 45.6, + 'int' => '123', + 'decimal_only' => '.6', + 'zero_padded' => '0.78', + 'space_padded' => ' 90.1', + 'nan' => 'nan', + 'mixed' => '1.ab', + 'scientific_notation' => '1e3', + ]); + + $this->assertSame(1.23, $data->float('float')); + $this->assertSame(45.6, $data->float('raw_float')); + $this->assertSame(123.0, $data->float('int')); + $this->assertSame(.6, $data->float('decimal_only')); + $this->assertSame(0.78, $data->float('zero_padded')); + $this->assertSame(90.1, $data->float('space_padded')); + $this->assertSame(0.0, $data->float('nan')); + $this->assertSame(1.0, $data->float('mixed')); + $this->assertSame(1e3, $data->float('scientific_notation')); + $this->assertSame(0.0, $data->float('unknown_key')); + $this->assertSame(123.456, $data->float('unknown_key', 123.456)); + } + + protected function assertDataFloatStrictContract(): void + { + $data = static::arrayToDataFactory([ + 'float' => '1.23', + 'raw_float' => 45.6, + 'int' => '123', + 'raw_int' => 5, + 'zero' => '0', + 'negative' => '-1.5', + 'decimal_only' => '.6', + 'space_padded' => ' 90.1 ', + 'scientific_notation' => '1e3', + 'nan' => 'nan', + 'infinity' => 'INF', + 'mixed' => '1.ab', + 'thousands' => '1,000', + 'empty_str' => '', + 'array' => ['1.5'], + ]); + + $this->assertSame(1.23, $data->float('float', null, true)); + $this->assertSame(45.6, $data->float('raw_float', null, true)); + $this->assertSame(123.0, $data->float('int', null, true)); + $this->assertSame(5.0, $data->float('raw_int', null, true)); + $this->assertSame(0.0, $data->float('zero', 7.0, true)); + $this->assertSame(-1.5, $data->float('negative', null, true)); + $this->assertSame(.6, $data->float('decimal_only', null, true)); + $this->assertSame(90.1, $data->float('space_padded', null, true)); + $this->assertSame(1e3, $data->float('scientific_notation', null, true)); + + $this->assertNull($data->float('nan', null, true)); + $this->assertNull($data->float('infinity', null, true)); + $this->assertNull($data->float('mixed', null, true)); + $this->assertNull($data->float('thousands', null, true)); + $this->assertNull($data->float('empty_str', null, true)); + $this->assertNull($data->float('array', null, true)); + $this->assertNull($data->float('unknown_key', null, true)); + $this->assertSame(7.5, $data->float('mixed', 7.5, true)); + $this->assertSame(0.0, $data->float('mixed', strict: true)); + + $this->assertSame(7.5, $data->float('mixed', fn () => 7.5, true)); + } + + protected function assertDataBooleanStrictNativeValuesContract(): void + { + $data = static::arrayToDataFactory(['true' => true, 'false' => false]); + + $this->assertSame('default', $data->string('true', 'default', true)->value()); + $this->assertSame('default', $data->string('false', 'default', true)->value()); + $this->assertNull($data->integer('true', null, true)); + $this->assertNull($data->integer('false', null, true)); + $this->assertNull($data->float('true', null, true)); + $this->assertNull($data->float('false', null, true)); + $this->assertTrue($data->boolean('true', null, true)); + $this->assertFalse($data->boolean('false', null, true)); + } + + protected function assertDataClampContract(): void + { + $data = static::arrayToDataFactory([ + 'per_page' => 100, + 'float' => 9.24, + 'numeric_string' => '50', + 'empty' => '', + 'string' => 'string', + ]); + + $this->assertSame(100, $data->clamp('per_page', 100, 101)); + $this->assertSame(10, $data->clamp('per_page', -10, 10)); + $this->assertSame(25, $data->clamp('per_page_2', 25, 100, 1)); + $this->assertSame(100, $data->clamp('per_page', 1, 250, 99)); + $this->assertSame(22.4, $data->clamp('per_page', 1.11, 22.4, 2)); + $this->assertSame(9.24, $data->clamp('float', 1, 10)); + $this->assertSame(50, $data->clamp('numeric_string', 1, 100)); + $this->assertSame(15, $data->clamp('empty', 10, 100, 15)); + $this->assertSame(15, $data->clamp('string', 10, 100, 15)); + } + + protected function assertDataDateContract(): void + { + $data = static::arrayToDataFactory([ + 'as_empty' => '', + 'as_datetime' => '20-01-01 16:30:25', + 'as_format' => '1577896225', + 'as_timezone' => '20-01-01 13:30:25', + 'as_date' => '2020-01-01', + 'as_time' => '16:30:25', + 'invalid' => 'invalid', + ]); + + $current = Carbon::create(2020, 1, 1, 16, 30, 25); + + $this->assertNull($data->date('as_empty')); + $this->assertNull($data->date('doesnt_exists')); + + $this->assertEquals($current, $data->date('as_datetime')); + $this->assertEquals($current->format('Y-m-d H:i:s P'), $data->date('as_format', 'U')->format('Y-m-d H:i:s P')); + $this->assertEquals($current, $data->date('as_timezone', null, 'America/Santiago')); + + $this->assertTrue($data->date('as_date')->isSameDay($current)); + $this->assertTrue($data->date('as_time')->isSameSecond('16:30:25')); + + try { + $data->date('invalid'); + + $this->fail('An invalid date value should throw an exception.'); + } catch (InvalidArgumentException) { + } + + try { + $data->date('as_datetime', 'invalid_format'); + + $this->fail('An invalid date format should throw an exception.'); + } catch (InvalidArgumentException) { + } + } + + protected function assertDataIntervalContract(): void + { + $data = static::arrayToDataFactory([ + 'as_empty' => '', + 'as_iso' => 'P1Y2M3DT4H5M6S', + 'as_human' => '2 hours 30 minutes', + 'as_seconds' => '90', + 'as_minutes' => '45', + 'small_float' => '0.000053', + 'very_small' => '0.000001', + 'scientific' => '5.3E-5', + 'normal_float' => '1.5', + ]); + + $this->assertNull($data->interval('as_empty')); + $this->assertNull($data->interval('doesnt_exist')); + + $interval = $data->interval('as_iso'); + $this->assertInstanceOf(CarbonInterval::class, $interval); + $this->assertSame(1, $interval->years); + $this->assertSame(2, $interval->months); + $this->assertSame(3, $interval->dayz); + $this->assertSame(4, $interval->hours); + $this->assertSame(5, $interval->minutes); + $this->assertSame(6, $interval->seconds); + + $interval = $data->interval('as_human'); + $this->assertInstanceOf(CarbonInterval::class, $interval); + $this->assertSame(2, $interval->hours); + $this->assertSame(30, $interval->minutes); + + $interval = $data->interval('as_seconds', 'second'); + $this->assertInstanceOf(CarbonInterval::class, $interval); + $this->assertSame(90, $interval->seconds); + + $this->assertSame(90, $data->interval('as_seconds', 'minute')->minutes); + $this->assertSame(90, $data->interval('as_seconds', 'hour')->hours); + $this->assertSame(90, $data->interval('as_seconds', 'day')->dayz); + + $this->assertSame(45, $data->interval('as_minutes', Unit::Minute)->minutes); + $this->assertSame(45, $data->interval('as_minutes', Unit::Second)->seconds); + + // Small floats that PHP would render in scientific notation (e.g. 5.3E-5) + $this->assertInstanceOf(CarbonInterval::class, $data->interval('small_float', Unit::Millisecond)); + $this->assertInstanceOf(CarbonInterval::class, $data->interval('very_small', Unit::Second)); + $this->assertInstanceOf(CarbonInterval::class, $data->interval('scientific', Unit::Millisecond)); + $this->assertSame(1, $data->interval('normal_float', Unit::Hour)->hours); + } + + protected function assertDataEnumContract(): void + { + $data = static::arrayToDataFactory([ + 'valid_enum_value' => 'A', + 'invalid_enum_value' => 'invalid', + 'empty_value' => '', + 'minus_1' => '-1', + 'zero' => '0', + 'plus_1' => 1, + 'doesnt_exist' => '-1024', + ]); + + $this->assertNull($data->enum('missing_key', TestStringBackedEnum::class)); + $this->assertNull($data->enum('missing_key', TestEnum::class)); + + $this->assertSame(TestStringBackedEnum::A, $data->enum('valid_enum_value', TestStringBackedEnum::class)); + + $this->assertNull($data->enum('invalid_enum_value', TestStringBackedEnum::class)); + $this->assertNull($data->enum('empty_value', TestStringBackedEnum::class)); + $this->assertNull($data->enum('empty_value', TestEmptyStringBackedEnum::class)); + $this->assertNull($data->enum('valid_enum_value', TestEnum::class)); + + $this->assertSame(TestStringBackedEnum::B, $data->enum('invalid_enum_value', TestStringBackedEnum::class, TestStringBackedEnum::B)); + $this->assertSame(TestStringBackedEnum::B, $data->enum('missing_key', TestStringBackedEnum::class, TestStringBackedEnum::B)); + $this->assertSame(TestStringBackedEnum::B, $data->enum('missing_key', TestStringBackedEnum::class, fn () => TestStringBackedEnum::B)); + + $this->assertSame(TestIntegerBackedEnum::Minus1, $data->enum('minus_1', TestIntegerBackedEnum::class)); + $this->assertSame(TestIntegerBackedEnum::Zero, $data->enum('zero', TestIntegerBackedEnum::class)); + $this->assertSame(TestIntegerBackedEnum::Plus1, $data->enum('plus_1', TestIntegerBackedEnum::class)); + $this->assertNull($data->enum('doesnt_exist', TestIntegerBackedEnum::class)); + } + + protected function assertDataEnumNestedContract(): void + { + $data = static::arrayToDataFactory([ + 'string' => ['a' => 'A', 'zero' => '0'], + ]); + + $this->assertSame(TestStringBackedEnum::A, $data->enum('string.a', TestStringBackedEnum::class)); + $this->assertSame(TestIntegerBackedEnum::Zero, $data->enum('string.zero', TestIntegerBackedEnum::class)); + $this->assertNull($data->enum('string.missing', TestStringBackedEnum::class)); + } + + protected function assertDataEnumsContract(): void + { + $data = static::arrayToDataFactory([ + 'valid_enum_values' => ['A', 'B'], + 'mixed_enum_values' => ['A', 'invalid'], + 'invalid_enum_values' => ['invalid', 'invalid'], + 'single_value' => 'B', + 'integer_values' => ['-1', '0', '1'], + 'empty_value' => '', + ]); + + $this->assertSame([], $data->enums('missing_key', TestStringBackedEnum::class)); + $this->assertSame([], $data->enums('valid_enum_values', TestEnum::class)); + + $this->assertSame([TestStringBackedEnum::A, TestStringBackedEnum::B], $data->enums('valid_enum_values', TestStringBackedEnum::class)); + $this->assertSame([TestStringBackedEnum::A], $data->enums('mixed_enum_values', TestStringBackedEnum::class)); + $this->assertSame([], $data->enums('invalid_enum_values', TestStringBackedEnum::class)); + $this->assertSame([TestStringBackedEnum::B], $data->enums('single_value', TestStringBackedEnum::class)); + $this->assertSame([], $data->enums('empty_value', TestEmptyStringBackedEnum::class)); + + $this->assertSame([TestIntegerBackedEnum::Minus1, TestIntegerBackedEnum::Zero, TestIntegerBackedEnum::Plus1], $data->enums('integer_values', TestIntegerBackedEnum::class)); + } + + protected function assertDataArrayContract(): void + { + $data = static::arrayToDataFactory(['users' => ['1', '2', '3'], 'roles' => ['4', '5', '6'], 'email' => 'test@example.com']); + + $this->assertSame(['1', '2', '3'], $data->array('users')); + $this->assertSame(['test@example.com'], $data->array('email')); + $this->assertSame([], $data->array('missing')); + $this->assertSame([], $data->array(['missing'])); + $this->assertEquals(['roles' => ['4', '5', '6']], $data->array(['roles'])); + $this->assertEquals(['users' => ['1', '2', '3'], 'email' => 'test@example.com'], $data->array(['users', 'email'])); + $this->assertEquals(['users' => ['1', '2', '3'], 'roles' => ['4', '5', '6'], 'email' => 'test@example.com'], $data->array()); + + $this->assertSame([], static::arrayToDataFactory([])->array()); + } + + protected function assertDataArrayArrayableContract(): void + { + $this->assertSame([1, 2], static::arrayToDataFactory(['users' => new Collection([1, 2])])->array('users')); + } + + protected function assertDataCollectContract(): void + { + $data = static::arrayToDataFactory(['users' => ['1', '2', '3'], 'roles' => ['4', '5', '6'], 'email' => 'test@example.com']); + + $this->assertInstanceOf(Collection::class, $data->collect('users')); + $this->assertSame(['1', '2', '3'], $data->collect('users')->all()); + $this->assertSame(['test@example.com'], $data->collect('email')->all()); + $this->assertTrue($data->collect('missing')->isEmpty()); + $this->assertInstanceOf(Collection::class, $data->collect(['users'])); + $this->assertTrue($data->collect(['missing'])->isEmpty()); + $this->assertEquals(['roles' => ['4', '5', '6']], $data->collect(['roles'])->all()); + $this->assertEquals(['users' => ['1', '2', '3'], 'email' => 'test@example.com'], $data->collect(['users', 'email'])->all()); + $this->assertEquals(['users' => ['1', '2', '3'], 'roles' => ['4', '5', '6'], 'email' => 'test@example.com'], $data->collect()->all()); + + $this->assertInstanceOf(Collection::class, static::arrayToDataFactory([])->collect()); + $this->assertTrue(static::arrayToDataFactory([])->collect()->isEmpty()); + } + + protected function assertDataCollectNestedContract(): void + { + $data = static::arrayToDataFactory(['authors' => ['taylor' => ['products' => ['forge', 'vapor', 'spark']]]]); + + $this->assertSame(['forge', 'vapor', 'spark'], $data->collect('authors.taylor.products')->all()); + } + + protected function assertDataOnlyContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'email' => 'taylor@laravel.com']); + + $this->assertEquals(['name' => 'Taylor'], $data->only('name')); + $this->assertEquals(['name' => 'Taylor', 'age' => ''], $data->only('name', 'age', 'missing')); + $this->assertEquals(['name' => 'Taylor', 'age' => ''], $data->only(['name', 'age', 'missing'])); + $this->assertSame([], $data->only('missing')); + } + + protected function assertDataOnlyNestedContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'developer' => ['name' => 'Taylor', 'age' => '']]); + + $this->assertEquals(['developer' => ['name' => 'Taylor']], $data->only('developer.name', 'developer.skills')); + $this->assertEquals(['developer' => ['age' => '']], $data->only('developer.age')); + $this->assertEquals(['name' => 'Taylor', 'developer' => ['name' => 'Taylor', 'age' => '']], $data->only('name', 'developer')); + $this->assertSame([], $data->only('developer.skills')); + } + + protected function assertDataExceptContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '25']); + + $this->assertEquals(['name' => 'Taylor'], $data->except('age')); + $this->assertEquals(['name' => 'Taylor', 'age' => '25'], $data->except('missing')); + $this->assertSame([], $data->except('age', 'name')); + $this->assertSame([], $data->except(['age', 'name'])); + } + + protected function assertDataExceptNestedContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'developer' => ['name' => 'Taylor', 'age' => '']]); + + $this->assertEquals(['name' => 'Taylor', 'developer' => ['name' => 'Taylor']], $data->except('developer.age')); + $this->assertEquals(['developer' => ['age' => '']], $data->except('name', 'developer.name')); + } + + protected function assertDataNullValuesContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'city' => null]); + + $this->assertTrue($data->exists('city')); + $this->assertTrue($data->has('city')); + $this->assertTrue($data->has('name', 'city')); + $this->assertTrue($data->hasAny('city')); + $this->assertFalse($data->missing('city')); + + $this->assertFalse($data->filled('city')); + $this->assertTrue($data->isNotFilled('city')); + $this->assertFalse($data->anyFilled('city')); + $this->assertTrue($data->anyFilled('city', 'name')); + + $city = $missing = $filled = false; + + $data->whenHas('city', function ($value) use (&$city) { + $city = $value; + }); + + $data->whenMissing('city', function () use (&$missing) { + $missing = true; + }); + + $data->whenFilled('city', function () use (&$filled) { + $filled = true; + }); + + $this->assertNull($city); + $this->assertFalse($missing); + $this->assertFalse($filled); + + $this->assertSame('', $data->string('city')->value()); + $this->assertFalse($data->boolean('city')); + $this->assertSame(0, $data->integer('city')); + $this->assertSame(0.0, $data->float('city')); + $this->assertSame(15, $data->clamp('city', 10, 100, 15)); + $this->assertNull($data->date('city')); + $this->assertNull($data->interval('city')); + $this->assertNull($data->enum('city', TestStringBackedEnum::class)); + $this->assertSame([], $data->enums('city', TestStringBackedEnum::class)); + } + + protected function assertDataNullValuesIgnoreDefaultsContract(): void + { + $data = static::arrayToDataFactory(['city' => null]); + + $this->assertSame('', $data->string('city', 'default')->value()); + $this->assertFalse($data->boolean('city', true)); + $this->assertSame(0, $data->integer('city', 123456)); + $this->assertSame(0.0, $data->float('city', 123.456)); + } + + protected function assertDataNestedNullValuesContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'foo' => ['bar' => null]]); + + $this->assertTrue($data->exists('foo.bar')); + $this->assertTrue($data->has('foo.bar')); + $this->assertTrue($data->hasAny('foo.bar')); + $this->assertFalse($data->missing('foo.bar')); + $this->assertTrue($data->filled('foo')); + $this->assertFalse($data->filled('foo.bar')); + $this->assertTrue($data->isNotFilled('foo.bar')); + + $bar = false; + + $data->whenHas('foo.bar', function ($value) use (&$bar) { + $bar = $value; + }); + + $this->assertNull($bar); + $this->assertSame(['foo' => ['bar' => null]], $data->only('foo.bar')); + $this->assertSame(['name' => 'Taylor', 'foo' => ['bar' => null]], $data->only('name', 'foo.bar', 'foo.baz')); + $this->assertSame(['foo' => ['bar' => null]], $data->except('name')); + } +} diff --git a/tests/Support/Fixtures/TestEmptyStringBackedEnum.php b/tests/Support/Fixtures/TestEmptyStringBackedEnum.php new file mode 100644 index 0000000000..2a6b7b3d8c --- /dev/null +++ b/tests/Support/Fixtures/TestEmptyStringBackedEnum.php @@ -0,0 +1,10 @@ + 'Taylor', 'age' => 25]; @@ -215,267 +211,55 @@ public function testToCollection(): void public function testStringMethod(): void { - $fluent = new Fluent([ - 'int' => 123, - 'int_str' => '456', - 'float' => 123.456, - 'float_str' => '123.456', - 'float_zero' => 0.000, - 'float_str_zero' => '0.000', - 'str' => 'abc', - 'empty_str' => '', - 'null' => null, - ]); - $this->assertInstanceOf(Stringable::class, $fluent->string('int')); - $this->assertInstanceOf(Stringable::class, $fluent->string('unknown_key')); - $this->assertSame('123', $fluent->string('int')->value()); - $this->assertSame('456', $fluent->string('int_str')->value()); - $this->assertSame('123.456', $fluent->string('float')->value()); - $this->assertSame('123.456', $fluent->string('float_str')->value()); - $this->assertSame('0', $fluent->string('float_zero')->value()); - $this->assertSame('0.000', $fluent->string('float_str_zero')->value()); - $this->assertSame('', $fluent->string('empty_str')->value()); - $this->assertSame('', $fluent->string('null')->value()); - $this->assertSame('', $fluent->string('unknown_key')->value()); + $this->assertDataStringContract(); + $this->assertDataStringStrictContract(); } public function testBooleanMethod(): void { - $fluent = new Fluent(['with_trashed' => 'false', 'download' => true, 'checked' => 1, 'unchecked' => '0', 'with_on' => 'on', 'with_yes' => 'yes']); - $this->assertTrue($fluent->boolean('checked')); - $this->assertTrue($fluent->boolean('download')); - $this->assertFalse($fluent->boolean('unchecked')); - $this->assertFalse($fluent->boolean('with_trashed')); - $this->assertFalse($fluent->boolean('some_undefined_key')); - $this->assertTrue($fluent->boolean('with_on')); - $this->assertTrue($fluent->boolean('with_yes')); + $this->assertDataBooleanContract(); + $this->assertDataBooleanStrictContract(); + $this->assertDataBooleanStrictNativeValuesContract(); } public function testIntegerMethod(): void { - $fluent = new Fluent([ - 'int' => '123', - 'raw_int' => 456, - 'zero_padded' => '078', - 'space_padded' => ' 901', - 'nan' => 'nan', - 'mixed' => '1ab', - 'underscore_notation' => '2_000', - 'null' => null, - ]); - $this->assertSame(123, $fluent->integer('int')); - $this->assertSame(456, $fluent->integer('raw_int')); - $this->assertSame(78, $fluent->integer('zero_padded')); - $this->assertSame(901, $fluent->integer('space_padded')); - $this->assertSame(0, $fluent->integer('nan')); - $this->assertSame(1, $fluent->integer('mixed')); - $this->assertSame(2, $fluent->integer('underscore_notation')); - $this->assertSame(123456, $fluent->integer('unknown_key', 123456)); - $this->assertSame(0, $fluent->integer('null')); - $this->assertSame(0, $fluent->integer('null', 123456)); + $this->assertDataIntegerContract(); + $this->assertDataIntegerStrictContract(); } public function testFloatMethod(): void { - $fluent = new Fluent([ - 'float' => '1.23', - 'raw_float' => 45.6, - 'decimal_only' => '.6', - 'zero_padded' => '0.78', - 'space_padded' => ' 90.1', - 'nan' => 'nan', - 'mixed' => '1.ab', - 'scientific_notation' => '1e3', - 'null' => null, - ]); - $this->assertSame(1.23, $fluent->float('float')); - $this->assertSame(45.6, $fluent->float('raw_float')); - $this->assertSame(.6, $fluent->float('decimal_only')); - $this->assertSame(0.78, $fluent->float('zero_padded')); - $this->assertSame(90.1, $fluent->float('space_padded')); - $this->assertSame(0.0, $fluent->float('nan')); - $this->assertSame(1.0, $fluent->float('mixed')); - $this->assertSame(1e3, $fluent->float('scientific_notation')); - $this->assertSame(123.456, $fluent->float('unknown_key', 123.456)); - $this->assertSame(0.0, $fluent->float('null')); - $this->assertSame(0.0, $fluent->float('null', 123.456)); + $this->assertDataFloatContract(); + $this->assertDataFloatStrictContract(); } public function testArrayMethod(): void { - $fluent = new Fluent(['users' => [1, 2, 3]]); - - $this->assertIsArray($fluent->array('users')); - $this->assertEquals([1, 2, 3], $fluent->array('users')); - $this->assertEquals(['users' => [1, 2, 3]], $fluent->array()); - - $fluent = new Fluent(['text-payload']); - $this->assertEquals(['text-payload'], $fluent->array()); - - $fluent = new Fluent(['email' => 'test@example.com']); - $this->assertEquals(['test@example.com'], $fluent->array('email')); - - $fluent = new Fluent([]); - $this->assertIsArray($fluent->array()); - $this->assertEmpty($fluent->array()); - - $fluent = new Fluent(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com']); - $this->assertEmpty($fluent->array(['developers'])); - $this->assertNotEmpty($fluent->array(['roles'])); - $this->assertEquals(['roles' => [4, 5, 6]], $fluent->array(['roles'])); - $this->assertEquals(['users' => [1, 2, 3], 'email' => 'test@example.com'], $fluent->array(['users', 'email'])); - $this->assertEquals(['roles' => [4, 5, 6], 'foo' => ['bar', 'baz']], $fluent->array(['roles', 'foo'])); - $this->assertEquals(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com'], $fluent->array()); + $this->assertDataArrayContract(); + $this->assertDataArrayArrayableContract(); } public function testCollectMethod(): void { - $fluent = new Fluent(['users' => [1, 2, 3]]); - - $this->assertInstanceOf(Collection::class, $fluent->collect('users')); - $this->assertTrue($fluent->collect('developers')->isEmpty()); - $this->assertEquals([1, 2, 3], $fluent->collect('users')->all()); - $this->assertEquals(['users' => [1, 2, 3]], $fluent->collect()->all()); - - $fluent = new Fluent(['text-payload']); - $this->assertEquals(['text-payload'], $fluent->collect()->all()); - - $fluent = new Fluent(['email' => 'test@example.com']); - $this->assertEquals(['test@example.com'], $fluent->collect('email')->all()); - - $fluent = new Fluent([]); - $this->assertInstanceOf(Collection::class, $fluent->collect()); - $this->assertTrue($fluent->collect()->isEmpty()); - - $fluent = new Fluent(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com']); - $this->assertInstanceOf(Collection::class, $fluent->collect(['users'])); - $this->assertTrue($fluent->collect(['developers'])->isEmpty()); - $this->assertTrue($fluent->collect(['roles'])->isNotEmpty()); - $this->assertEquals(['roles' => [4, 5, 6]], $fluent->collect(['roles'])->all()); - $this->assertEquals(['users' => [1, 2, 3], 'email' => 'test@example.com'], $fluent->collect(['users', 'email'])->all()); - $this->assertEquals(collect(['roles' => [4, 5, 6], 'foo' => ['bar', 'baz']]), $fluent->collect(['roles', 'foo'])); - $this->assertEquals(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com'], $fluent->collect()->all()); + $this->assertDataCollectContract(); + $this->assertDataCollectNestedContract(); } public function testDateMethod(): void { - $fluent = new Fluent([ - 'as_null' => null, - 'as_invalid' => 'invalid', - - 'as_datetime' => '20-01-01 16:30:25', - 'as_format' => '1577896225', - 'as_timezone' => '20-01-01 13:30:25', - - 'as_date' => '2020-01-01', - 'as_time' => '16:30:25', - ]); - - $current = CarbonImmutable::create(2020, 1, 1, 16, 30, 25); - - $this->assertNull($fluent->date('as_null')); - $this->assertNull($fluent->date('doesnt_exists')); - - $dateTime = $fluent->date('as_datetime'); - - $this->assertSame(CarbonImmutable::class, $dateTime::class); - $this->assertEquals($current, $dateTime); - $this->assertEquals($current->format('Y-m-d H:i:s P'), $fluent->date('as_format', 'U')->format('Y-m-d H:i:s P')); - $this->assertEquals($current, $fluent->date('as_timezone', null, 'America/Santiago')); - - $this->assertTrue($fluent->date('as_date')->isSameDay($current)); - $this->assertTrue($fluent->date('as_time')->isSameSecond('16:30:25')); - } - - public function testDateMethodExceptionWhenValueInvalid(): void - { - $this->expectException(InvalidArgumentException::class); - - $fluent = new Fluent([ - 'date' => 'invalid', - ]); - - $fluent->date('date'); - } - - public function testDateMethodExceptionWhenFormatInvalid(): void - { - $this->expectException(InvalidArgumentException::class); - - $fluent = new Fluent([ - 'date' => '20-01-01 16:30:25', - ]); - - $fluent->date('date', 'invalid_format'); + $this->assertDataDateContract(); } public function testEnumMethod(): void { - $fluent = new Fluent([ - 'valid_enum_value' => 'A', - 'invalid_enum_value' => 'invalid', - 'empty_value_request' => '', - 'string' => [ - 'a' => '1', - 'b' => '2', - 'doesnt_exist' => '-1024', - ], - 'int' => [ - 'a' => 1, - 'b' => 2, - 'doesnt_exist' => 1024, - ], - ]); - - $this->assertNull($fluent->enum('doesnt_exist', TestEnum::class)); - - $this->assertEquals(TestStringBackedEnum::A, $fluent->enum('valid_enum_value', TestStringBackedEnum::class)); - - $this->assertNull($fluent->enum('invalid_enum_value', TestStringBackedEnum::class)); - $this->assertNull($fluent->enum('empty_value_request', TestStringBackedEnum::class)); - $this->assertNull($fluent->enum('valid_enum_value', TestEnum::class)); - - $this->assertEquals(TestBackedEnum::A, $fluent->enum('string.a', TestBackedEnum::class)); - $this->assertEquals(TestBackedEnum::B, $fluent->enum('string.b', TestBackedEnum::class)); - $this->assertNull($fluent->enum('string.doesnt_exist', TestBackedEnum::class)); - $this->assertEquals(TestBackedEnum::A, $fluent->enum('int.a', TestBackedEnum::class)); - $this->assertEquals(TestBackedEnum::B, $fluent->enum('int.b', TestBackedEnum::class)); - $this->assertNull($fluent->enum('int.doesnt_exist', TestBackedEnum::class)); + $this->assertDataEnumContract(); + $this->assertDataEnumNestedContract(); } public function testEnumsMethod(): void { - $fluent = new Fluent([ - 'valid_enum_values' => ['A', 'B'], - 'invalid_enum_values' => ['invalid', 'invalid'], - 'empty_value_request' => [], - 'string' => [ - 'a' => ['1', '2'], - 'b' => '2', - 'doesnt_exist' => '-1024', - ], - 'int' => [ - 'a' => [1, 2], - 'b' => 2, - 'doesnt_exist' => 1024, - ], - ]); - - $this->assertEmpty($fluent->enums('doesnt_exist', TestEnum::class)); - - $this->assertEquals([TestStringBackedEnum::A, TestStringBackedEnum::B], $fluent->enums('valid_enum_values', TestStringBackedEnum::class)); - - $this->assertEmpty($fluent->enums('invalid_enum_values', TestStringBackedEnum::class)); - $this->assertEmpty($fluent->enums('empty_value_request', TestStringBackedEnum::class)); - $this->assertEmpty($fluent->enums('valid_enum_values', TestEnum::class)); - - $this->assertEquals([TestBackedEnum::A, TestBackedEnum::B], $fluent->enums('string.a', TestBackedEnum::class)); - $this->assertEquals([TestBackedEnum::B], $fluent->enums('string.b', TestBackedEnum::class)); - $this->assertEmpty($fluent->enums('string.doesnt_exist', TestBackedEnum::class)); - - $this->assertEquals([TestBackedEnum::A, TestBackedEnum::B], $fluent->enums('int.a', TestBackedEnum::class)); - $this->assertEquals([TestBackedEnum::B], $fluent->enums('int.b', TestBackedEnum::class)); - $this->assertEmpty($fluent->enums('int.doesnt_exist', TestBackedEnum::class)); + $this->assertDataEnumsContract(); } public function testFill(): void @@ -551,6 +335,110 @@ public function testFluentIsNotEmpty(): void $this->assertTrue($fluent->isNotEmpty()); $this->assertFalse($fluent->isEmpty()); } + + public function testExistsMethod(): void + { + $this->assertDataExistsContract(); + $this->assertDataExistsNestedContract(); + } + + public function testHasMethod(): void + { + $this->assertDataHasContract(); + $this->assertDataHasNestedContract(); + } + + public function testHasAnyMethod(): void + { + $this->assertDataHasAnyContract(); + $this->assertDataHasAnyNestedContract(); + } + + public function testWhenHasMethod(): void + { + $this->assertDataWhenHasContract(); + $this->assertDataWhenHasNestedContract(); + } + + public function testFilledMethod(): void + { + $this->assertDataFilledContract(); + $this->assertDataFilledNestedContract(); + } + + public function testIsNotFilledMethod(): void + { + $this->assertDataIsNotFilledContract(); + $this->assertDataIsNotFilledNestedContract(); + } + + public function testAnyFilledMethod(): void + { + $this->assertDataAnyFilledContract(); + $this->assertDataAnyFilledNestedContract(); + } + + public function testWhenFilledMethod(): void + { + $this->assertDataWhenFilledContract(); + $this->assertDataWhenFilledNestedContract(); + } + + public function testWhenEnumMethod(): void + { + $this->assertDataWhenEnumContract(); + } + + public function testMissingMethod(): void + { + $this->assertDataMissingContract(); + $this->assertDataMissingNestedContract(); + } + + public function testWhenMissingMethod(): void + { + $this->assertDataWhenMissingContract(); + $this->assertDataWhenMissingNestedContract(); + } + + public function testStrMethod(): void + { + $this->assertDataStrContract(); + } + + public function testClampMethod(): void + { + $this->assertDataClampContract(); + } + + public function testIntervalMethod(): void + { + $this->assertDataIntervalContract(); + } + + public function testOnlyMethod(): void + { + $this->assertDataOnlyContract(); + $this->assertDataOnlyNestedContract(); + } + + public function testExceptMethod(): void + { + $this->assertDataExceptContract(); + $this->assertDataExceptNestedContract(); + } + + public function testNullValues(): void + { + $this->assertDataNullValuesContract(); + $this->assertDataNullValuesIgnoreDefaultsContract(); + $this->assertDataNestedNullValuesContract(); + } + + protected static function arrayToDataFactory(array $data): object + { + return new Fluent($data); + } } class FluentArrayIteratorStub implements IteratorAggregate diff --git a/tests/Support/SupportInteractsWithDataTest.php b/tests/Support/SupportInteractsWithDataTest.php new file mode 100644 index 0000000000..6f4a2d5381 --- /dev/null +++ b/tests/Support/SupportInteractsWithDataTest.php @@ -0,0 +1,427 @@ +assertDataExistsContract(); + $this->assertDataExistsNestedContract(); + } + + public function testHasMethod(): void + { + $this->assertDataHasContract(); + $this->assertDataHasNestedContract(); + } + + public function testHasAnyMethod(): void + { + $this->assertDataHasAnyContract(); + $this->assertDataHasAnyNestedContract(); + } + + public function testWhenHasMethod(): void + { + $this->assertDataWhenHasContract(); + $this->assertDataWhenHasNestedContract(); + } + + public function testFilledMethod(): void + { + $this->assertDataFilledContract(); + $this->assertDataFilledNestedContract(); + } + + public function testFilledHandlesTypedValuesWithoutStringifyingObjects(): void + { + $instance = static::arrayToDataFactory([ + 'null' => null, + 'blank' => ' ', + 'blank_stringable' => new HypervelStringable(' '), + 'boolean' => false, + 'array' => [], + 'integer' => 0, + 'enum' => InteractsWithDataTestIntEnum::One, + 'object' => new stdClass, + ]); + + $this->assertFalse($instance->filled('null')); + $this->assertFalse($instance->filled('blank')); + $this->assertFalse($instance->filled('blank_stringable')); + $this->assertTrue($instance->filled('boolean')); + $this->assertTrue($instance->filled('array')); + $this->assertTrue($instance->filled('integer')); + $this->assertTrue($instance->filled('enum')); + $this->assertTrue($instance->filled('object')); + } + + public function testIsNotFilledMethod(): void + { + $this->assertDataIsNotFilledContract(); + $this->assertDataIsNotFilledNestedContract(); + } + + public function testAnyFilledMethod(): void + { + $this->assertDataAnyFilledContract(); + $this->assertDataAnyFilledNestedContract(); + } + + public function testWhenFilledMethod(): void + { + $this->assertDataWhenFilledContract(); + $this->assertDataWhenFilledNestedContract(); + } + + public function testWhenEnumMethod(): void + { + $this->assertDataWhenEnumContract(); + + $number = false; + + static::arrayToDataFactory(['number' => '1'])->whenEnum('number', InteractsWithDataTestIntEnum::class, function (InteractsWithDataTestIntEnum $value) use (&$number): void { + $number = $value; + }); + + $this->assertSame(InteractsWithDataTestIntEnum::One, $number); + } + + public function testMissingMethod(): void + { + $this->assertDataMissingContract(); + $this->assertDataMissingNestedContract(); + } + + public function testWhenMissingMethod(): void + { + $this->assertDataWhenMissingContract(); + $this->assertDataWhenMissingNestedContract(); + } + + public function testStrMethod(): void + { + $this->assertDataStrContract(); + } + + public function testStringMethod(): void + { + $this->assertDataStringContract(); + $this->assertDataStringStrictContract(); + } + + public function testBooleanMethod(): void + { + $this->assertDataBooleanContract(); + $this->assertDataBooleanStrictContract(); + $this->assertDataBooleanStrictNativeValuesContract(); + } + + public function testIntegerMethod(): void + { + $this->assertDataIntegerContract(); + $this->assertDataIntegerStrictContract(); + } + + public function testFloatMethod(): void + { + $this->assertDataFloatContract(); + $this->assertDataFloatStrictContract(); + } + + public function testStrictNumbersAcceptNumericStringables(): void + { + $data = static::arrayToDataFactory([ + 'integer' => new HypervelStringable('12'), + 'float' => new HypervelStringable('12.5'), + ]); + + $this->assertSame(12, $data->integer('integer', null, true)); + $this->assertSame(12.5, $data->float('float', null, true)); + } + + public function testStrictDefaultsAreResolvedOnce(): void + { + $data = static::arrayToDataFactory([]); + $calls = 0; + $default = function () use (&$calls): null { + ++$calls; + + return null; + }; + + $this->assertSame('', $data->string('missing', $default, true)->value()); + $this->assertSame(1, $calls); + + $this->assertNull($data->integer('missing', $default, true)); + $this->assertSame(2, $calls); + + $this->assertNull($data->float('missing', $default, true)); + $this->assertSame(3, $calls); + + $this->assertNull($data->boolean('missing', $default, true)); + $this->assertSame(4, $calls); + } + + public function testClampMethod(): void + { + $this->assertDataClampContract(); + + $instance = static::arrayToDataFactory([ + 'per_page' => '100', + 'float' => '9.24', + ]); + + $this->assertSame(100, $instance->clamp('per_page', 100, 101)); + $this->assertSame(10, $instance->clamp('per_page', -10, 10)); + $this->assertSame(100, $instance->clamp('per_page', 1, 250, 99)); + $this->assertSame(9.24, $instance->clamp('float', 1, 10)); + } + + public function testDateMethod(): void + { + $this->assertDataDateContract(); + } + + public function testDateParsesWithoutFormat(): void + { + $instance = static::arrayToDataFactory(['date' => '2024-01-15 10:30:00']); + + $result = $instance->date('date'); + + $this->assertSame(CarbonImmutable::class, $result::class); + $this->assertEquals('2024-01-15 10:30:00', $result->format('Y-m-d H:i:s')); + } + + public function testDateHonorsTheMutableDateFactoryOptOut(): void + { + Date::use(Carbon::class); + $instance = static::arrayToDataFactory(['date' => '2024-01-15 10:30:00']); + + $result = $instance->date('date'); + + $this->assertSame(Carbon::class, $result::class); + } + + public function testDateParsesWithFormat(): void + { + $instance = static::arrayToDataFactory(['date' => '15/01/2024']); + + $result = $instance->date('date', 'd/m/Y'); + + $this->assertSame(CarbonImmutable::class, $result::class); + $this->assertEquals('2024-01-15', $result->format('Y-m-d')); + } + + public function testDateWithStringTimezone(): void + { + $instance = static::arrayToDataFactory(['date' => '2024-01-15 10:30:00']); + + $result = $instance->date('date', null, 'America/New_York'); + + $this->assertSame(CarbonImmutable::class, $result::class); + $this->assertEquals('America/New_York', $result->timezone->getName()); + } + + public function testDateWithStringBackedEnumTimezone(): void + { + $instance = static::arrayToDataFactory(['date' => '2024-01-15 10:30:00']); + + $result = $instance->date('date', null, InteractsWithDataTestStringEnum::NewYork); + + $this->assertSame(CarbonImmutable::class, $result::class); + $this->assertEquals('America/New_York', $result->timezone->getName()); + } + + public function testDateWithUnitEnumTimezone(): void + { + $instance = static::arrayToDataFactory(['date' => '2024-01-15 10:30:00']); + + // UnitEnum uses ->name, so 'UTC' will be the timezone + $result = $instance->date('date', null, InteractsWithDataTestUnitEnum::UTC); + + $this->assertSame(CarbonImmutable::class, $result::class); + $this->assertEquals('UTC', $result->timezone->getName()); + } + + public function testDateWithIntBackedEnumTimezoneUsesEnumValue(): void + { + $instance = static::arrayToDataFactory(['date' => '2024-01-15 10:30:00']); + + // Int-backed enum will return int (1), which Carbon interprets as a UTC offset + // This tests that enum_value() is called and passes the value to Carbon + $result = $instance->date('date', null, InteractsWithDataTestIntEnum::One); + + $this->assertSame(CarbonImmutable::class, $result::class); + // Carbon interprets int as UTC offset, so timezone offset will be +01:00 + $this->assertEquals('+01:00', $result->timezone->getName()); + } + + public function testDateWithNullTimezone(): void + { + $instance = static::arrayToDataFactory(['date' => '2024-01-15 10:30:00']); + + $result = $instance->date('date', null, null); + + $this->assertSame(CarbonImmutable::class, $result::class); + } + + public function testIntervalMethod(): void + { + $this->assertDataIntervalContract(); + } + + public function testEnumMethod(): void + { + $this->assertDataEnumContract(); + $this->assertDataEnumNestedContract(); + } + + public function testStringBackedEnumAcceptsAnyStringableValue(): void + { + $instance = static::arrayToDataFactory([ + 'timezone' => new class implements Stringable { + public function __toString(): string + { + return 'UTC'; + } + }, + ]); + + $this->assertSame( + InteractsWithDataTestStringEnum::Utc, + $instance->enum('timezone', InteractsWithDataTestStringEnum::class), + ); + } + + public function testEnumMethodsAcceptNumericStringsAndExistingCases(): void + { + $instance = static::arrayToDataFactory([ + 'numeric' => '1', + 'case' => InteractsWithDataTestIntEnum::Two, + 'cases' => ['1', InteractsWithDataTestIntEnum::Two, 'invalid'], + ]); + + $this->assertSame( + InteractsWithDataTestIntEnum::One, + $instance->enum('numeric', InteractsWithDataTestIntEnum::class), + ); + $this->assertSame( + InteractsWithDataTestIntEnum::Two, + $instance->enum('case', InteractsWithDataTestIntEnum::class), + ); + $this->assertSame( + [InteractsWithDataTestIntEnum::One, InteractsWithDataTestIntEnum::Two], + $instance->enums('cases', InteractsWithDataTestIntEnum::class), + ); + } + + public function testEnumsMethod(): void + { + $this->assertDataEnumsContract(); + } + + public function testArrayMethod(): void + { + $this->assertDataArrayContract(); + $this->assertDataArrayArrayableContract(); + } + + public function testCollectMethod(): void + { + $this->assertDataCollectContract(); + $this->assertDataCollectNestedContract(); + } + + public function testOnlyMethod(): void + { + $this->assertDataOnlyContract(); + $this->assertDataOnlyNestedContract(); + } + + public function testExceptMethod(): void + { + $this->assertDataExceptContract(); + $this->assertDataExceptNestedContract(); + } + + public function testNullValues(): void + { + $this->assertDataNullValuesContract(); + $this->assertDataNullValuesIgnoreDefaultsContract(); + $this->assertDataNestedNullValuesContract(); + } + + protected static function arrayToDataFactory(array $data): object + { + return new class($data) { + use InteractsWithData; + + public function __construct(protected array $attributes) + { + } + + public function all(mixed $keys = null): array + { + if (! $keys) { + return $this->attributes; + } + + $results = []; + + foreach (is_array($keys) ? $keys : func_get_args() as $key) { + data_set($results, $key, data_get($this->attributes, $key)); + } + + return $results; + } + + protected function data(?string $key = null, mixed $default = null): mixed + { + return data_get($this->attributes, $key, $default); + } + }; + } +} diff --git a/tests/Support/SupportUriTest.php b/tests/Support/SupportUriTest.php index 5dc9364742..babe495e7d 100644 --- a/tests/Support/SupportUriTest.php +++ b/tests/Support/SupportUriTest.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\Routing\UrlRoutable; use Hypervel\Support\Stringable as HypervelStringable; use Hypervel\Support\Uri; +use Hypervel\Tests\Support\Concerns\TestsInteractsWithDataContract; use Hypervel\Tests\TestCase; use League\Uri\Uri as LeagueUri; use Mockery as m; @@ -20,6 +21,8 @@ class SupportUriTest extends TestCase { + use TestsInteractsWithDataContract; + public function testCanBuildSpecialUrls(): void { Uri::setUrlGeneratorResolver(fn () => new CustomUrlGeneratorResolver); @@ -420,6 +423,154 @@ public function testMacroable(): void $this->assertSame('https://hypervel.org/foobar', (string) $uri->myMacro()); } + + public function testExistsMethod(): void + { + $this->assertDataExistsContract(); + $this->assertDataExistsNestedContract(); + } + + public function testHasMethod(): void + { + $this->assertDataHasContract(); + $this->assertDataHasNestedContract(); + } + + public function testHasAnyMethod(): void + { + $this->assertDataHasAnyContract(); + $this->assertDataHasAnyNestedContract(); + } + + public function testWhenHasMethod(): void + { + $this->assertDataWhenHasContract(); + $this->assertDataWhenHasNestedContract(); + } + + public function testFilledMethod(): void + { + $this->assertDataFilledContract(); + $this->assertDataFilledNestedContract(); + } + + public function testIsNotFilledMethod(): void + { + $this->assertDataIsNotFilledContract(); + $this->assertDataIsNotFilledNestedContract(); + } + + public function testAnyFilledMethod(): void + { + $this->assertDataAnyFilledContract(); + $this->assertDataAnyFilledNestedContract(); + } + + public function testWhenFilledMethod(): void + { + $this->assertDataWhenFilledContract(); + $this->assertDataWhenFilledNestedContract(); + } + + public function testWhenEnumMethod(): void + { + $this->assertDataWhenEnumContract(); + } + + public function testMissingMethod(): void + { + $this->assertDataMissingContract(); + $this->assertDataMissingNestedContract(); + } + + public function testWhenMissingMethod(): void + { + $this->assertDataWhenMissingContract(); + $this->assertDataWhenMissingNestedContract(); + } + + public function testStrMethod(): void + { + $this->assertDataStrContract(); + } + + public function testStringMethod(): void + { + $this->assertDataStringContract(); + $this->assertDataStringStrictContract(); + } + + public function testBooleanMethod(): void + { + $this->assertDataBooleanContract(); + $this->assertDataBooleanStrictContract(); + } + + public function testIntegerMethod(): void + { + $this->assertDataIntegerContract(); + $this->assertDataIntegerStrictContract(); + } + + public function testFloatMethod(): void + { + $this->assertDataFloatContract(); + $this->assertDataFloatStrictContract(); + } + + public function testClampMethod(): void + { + $this->assertDataClampContract(); + } + + public function testDateMethod(): void + { + $this->assertDataDateContract(); + } + + public function testIntervalMethod(): void + { + $this->assertDataIntervalContract(); + } + + public function testEnumMethod(): void + { + $this->assertDataEnumContract(); + $this->assertDataEnumNestedContract(); + } + + public function testEnumsMethod(): void + { + $this->assertDataEnumsContract(); + } + + public function testArrayMethod(): void + { + $this->assertDataArrayContract(); + } + + public function testCollectMethod(): void + { + $this->assertDataCollectContract(); + $this->assertDataCollectNestedContract(); + } + + public function testOnlyMethod(): void + { + $this->assertDataOnlyContract(); + $this->assertDataOnlyNestedContract(); + } + + public function testExceptMethod(): void + { + $this->assertDataExceptContract(); + $this->assertDataExceptNestedContract(); + } + + protected static function arrayToDataFactory(array $data): object + { + return Uri::of('https://hypervel.org')->withQuery($data)->query(); + } } class CustomUrlGeneratorResolver implements UrlGenerator diff --git a/tests/Support/Traits/InteractsWithDataTest.php b/tests/Support/Traits/InteractsWithDataTest.php deleted file mode 100644 index f57bd8e530..0000000000 --- a/tests/Support/Traits/InteractsWithDataTest.php +++ /dev/null @@ -1,373 +0,0 @@ -getReturnType(); - - $this->assertSame(HypervelStringable::class, $returnType?->getName()); - } - } - - public function testStringBackedEnumAcceptsAnyStringableValue(): void - { - $instance = new TestInteractsWithDataClass([ - 'timezone' => new class implements Stringable { - public function __toString(): string - { - return 'UTC'; - } - }, - ]); - - $this->assertSame( - InteractsWithDataTestStringEnum::Utc, - $instance->enum('timezone', InteractsWithDataTestStringEnum::class), - ); - } - - public function testFilledHandlesTypedValuesWithoutStringifyingObjects(): void - { - $instance = new TestInteractsWithDataClass([ - 'null' => null, - 'blank' => ' ', - 'blank_stringable' => new HypervelStringable(' '), - 'boolean' => false, - 'array' => [], - 'integer' => 0, - 'enum' => InteractsWithDataTestIntEnum::One, - 'object' => new stdClass, - ]); - - $this->assertFalse($instance->filled('null')); - $this->assertFalse($instance->filled('blank')); - $this->assertFalse($instance->filled('blank_stringable')); - $this->assertTrue($instance->filled('boolean')); - $this->assertTrue($instance->filled('array')); - $this->assertTrue($instance->filled('integer')); - $this->assertTrue($instance->filled('enum')); - $this->assertTrue($instance->filled('object')); - } - - public function testEnumMethodsAcceptNumericStringsAndExistingCases(): void - { - $instance = new TestInteractsWithDataClass([ - 'numeric' => '1', - 'case' => InteractsWithDataTestIntEnum::Two, - 'cases' => ['1', InteractsWithDataTestIntEnum::Two, 'invalid'], - ]); - - $this->assertSame( - InteractsWithDataTestIntEnum::One, - $instance->enum('numeric', InteractsWithDataTestIntEnum::class), - ); - $this->assertSame( - InteractsWithDataTestIntEnum::Two, - $instance->enum('case', InteractsWithDataTestIntEnum::class), - ); - $this->assertSame( - [InteractsWithDataTestIntEnum::One, InteractsWithDataTestIntEnum::Two], - $instance->enums('cases', InteractsWithDataTestIntEnum::class), - ); - } - - public function testDateReturnsNullWhenKeyIsNotFilled(): void - { - $instance = new TestInteractsWithDataClass(['date' => '']); - - $this->assertNull($instance->date('date')); - } - - public function testDateParsesWithoutFormat(): void - { - $instance = new TestInteractsWithDataClass(['date' => '2024-01-15 10:30:00']); - - $result = $instance->date('date'); - - $this->assertSame(CarbonImmutable::class, $result::class); - $this->assertEquals('2024-01-15 10:30:00', $result->format('Y-m-d H:i:s')); - } - - public function testDateHonorsTheMutableDateFactoryOptOut(): void - { - Date::use(Carbon::class); - $instance = new TestInteractsWithDataClass(['date' => '2024-01-15 10:30:00']); - - $result = $instance->date('date'); - - $this->assertSame(Carbon::class, $result::class); - } - - public function testDateParsesWithFormat(): void - { - $instance = new TestInteractsWithDataClass(['date' => '15/01/2024']); - - $result = $instance->date('date', 'd/m/Y'); - - $this->assertSame(CarbonImmutable::class, $result::class); - $this->assertEquals('2024-01-15', $result->format('Y-m-d')); - } - - public function testDateWithStringTimezone(): void - { - $instance = new TestInteractsWithDataClass(['date' => '2024-01-15 10:30:00']); - - $result = $instance->date('date', null, 'America/New_York'); - - $this->assertSame(CarbonImmutable::class, $result::class); - $this->assertEquals('America/New_York', $result->timezone->getName()); - } - - public function testDateWithStringBackedEnumTimezone(): void - { - $instance = new TestInteractsWithDataClass(['date' => '2024-01-15 10:30:00']); - - $result = $instance->date('date', null, InteractsWithDataTestStringEnum::NewYork); - - $this->assertSame(CarbonImmutable::class, $result::class); - $this->assertEquals('America/New_York', $result->timezone->getName()); - } - - public function testDateWithUnitEnumTimezone(): void - { - $instance = new TestInteractsWithDataClass(['date' => '2024-01-15 10:30:00']); - - // UnitEnum uses ->name, so 'UTC' will be the timezone - $result = $instance->date('date', null, InteractsWithDataTestUnitEnum::UTC); - - $this->assertSame(CarbonImmutable::class, $result::class); - $this->assertEquals('UTC', $result->timezone->getName()); - } - - public function testDateWithIntBackedEnumTimezoneUsesEnumValue(): void - { - $instance = new TestInteractsWithDataClass(['date' => '2024-01-15 10:30:00']); - - // Int-backed enum will return int (1), which Carbon interprets as a UTC offset - // This tests that enum_value() is called and passes the value to Carbon - $result = $instance->date('date', null, InteractsWithDataTestIntEnum::One); - - $this->assertSame(CarbonImmutable::class, $result::class); - // Carbon interprets int as UTC offset, so timezone offset will be +01:00 - $this->assertEquals('+01:00', $result->timezone->getName()); - } - - public function testDateWithNullTimezone(): void - { - $instance = new TestInteractsWithDataClass(['date' => '2024-01-15 10:30:00']); - - $result = $instance->date('date', null, null); - - $this->assertSame(CarbonImmutable::class, $result::class); - } - - public function testIntervalMethod(): void - { - $instance = new TestInteractsWithDataClass([ - 'as_null' => null, - 'as_empty' => '', - 'as_iso' => 'P1Y2M3DT4H5M6S', - 'as_human' => '2 hours 30 minutes', - 'as_seconds' => '90', - 'as_minutes' => '45', - ]); - - $this->assertNull($instance->interval('as_null')); - $this->assertNull($instance->interval('as_empty')); - $this->assertNull($instance->interval('doesnt_exist')); - - $interval = $instance->interval('as_iso'); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(1, $interval->years); - $this->assertSame(2, $interval->months); - $this->assertSame(3, $interval->dayz); - $this->assertSame(4, $interval->hours); - $this->assertSame(5, $interval->minutes); - $this->assertSame(6, $interval->seconds); - - $interval = $instance->interval('as_human'); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(2, $interval->hours); - $this->assertSame(30, $interval->minutes); - - $interval = $instance->interval('as_seconds', 'second'); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(90, $interval->seconds); - - $this->assertSame(90, $instance->interval('as_seconds', 'minute')->minutes); - $this->assertSame(90, $instance->interval('as_seconds', 'hour')->hours); - $this->assertSame(90, $instance->interval('as_seconds', 'day')->dayz); - - $this->assertSame(45, $instance->interval('as_minutes', Unit::Minute)->minutes); - $this->assertSame(45, $instance->interval('as_minutes', Unit::Second)->seconds); - } - - public function testIntervalMethodWithScientificNotationFloats(): void - { - $instance = new TestInteractsWithDataClass([ - 'small_float' => '0.000053', - 'very_small' => '0.000001', - 'scientific' => '5.3E-5', - 'normal_float' => '1.5', - 'integer' => '90', - ]); - - // Small floats that PHP would render in scientific notation (e.g. 5.3E-5) - $interval = $instance->interval('small_float', Unit::Millisecond); - $this->assertInstanceOf(CarbonInterval::class, $interval); - - $interval = $instance->interval('very_small', Unit::Second); - $this->assertInstanceOf(CarbonInterval::class, $interval); - - // Scientific notation string passed directly - $interval = $instance->interval('scientific', Unit::Millisecond); - $this->assertInstanceOf(CarbonInterval::class, $interval); - - // Normal values should still work - $interval = $instance->interval('normal_float', Unit::Hour); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(1, $interval->hours); - - $interval = $instance->interval('integer', Unit::Minute); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(90, $interval->minutes); - } - - public function testWhenEnumMethod(): void - { - $instance = new TestInteractsWithDataClass([ - 'timezone' => 'America/New_York', - 'number' => '1', - 'invalid' => 'abc', - 'empty' => '', - ]); - - $timezone = $number = $invalid = $empty = $missing = $default = false; - - $instance->whenEnum('timezone', InteractsWithDataTestStringEnum::class, function (InteractsWithDataTestStringEnum $value) use (&$timezone): void { - $timezone = $value; - }); - - $instance->whenEnum('number', InteractsWithDataTestIntEnum::class, function (InteractsWithDataTestIntEnum $value) use (&$number): void { - $number = $value; - }); - - $instance->whenEnum('invalid', InteractsWithDataTestIntEnum::class, function (InteractsWithDataTestIntEnum $value) use (&$invalid): void { - $invalid = $value; - }); - - $instance->whenEnum('empty', InteractsWithDataTestIntEnum::class, function (InteractsWithDataTestIntEnum $value) use (&$empty): void { - $empty = $value; - }); - - $instance->whenEnum('missing', InteractsWithDataTestIntEnum::class, function (InteractsWithDataTestIntEnum $value) use (&$missing): void { - $missing = $value; - }, function () use (&$default): void { - $default = true; - }); - - $this->assertSame(InteractsWithDataTestStringEnum::NewYork, $timezone); - $this->assertSame(InteractsWithDataTestIntEnum::One, $number); - $this->assertFalse($invalid); - $this->assertFalse($empty); - $this->assertFalse($missing); - $this->assertTrue($default); - } - - public function testClampMethod(): void - { - $instance = new TestInteractsWithDataClass([ - 'per_page' => '100', - 'float' => '9.24', - ]); - - $this->assertSame(100, $instance->clamp('per_page', 100, 101)); - $this->assertSame(10, $instance->clamp('per_page', -10, 10)); - $this->assertSame(25, $instance->clamp('per_page_2', 25, 100, 1)); - $this->assertSame(100, $instance->clamp('per_page', 1, 250, 99)); - $this->assertSame(22.4, $instance->clamp('per_page', 1.11, 22.4, 2)); - $this->assertSame(9.24, $instance->clamp('float', 1, 10)); - } - - public function testClampMethodUsesDefaultForNonNumericValues(): void - { - $instance = new TestInteractsWithDataClass(['per_page' => 'abc']); - - $this->assertSame(1, $instance->clamp('per_page', 1, 100)); - } -} - -class TestInteractsWithDataClass -{ - use InteractsWithData; - - public function __construct( - protected array $data = [] - ) { - } - - public function all(mixed $keys = null): array - { - return $this->data; - } - - protected function data(?string $key = null, mixed $default = null): mixed - { - if (is_null($key)) { - return $this->data; - } - - return $this->data[$key] ?? $default; - } - - public function collect(array|string|null $key = null): Collection - { - return new Collection(is_array($key) ? $this->only($key) : $this->data($key)); - } -} diff --git a/tests/Support/ValidatedInputTest.php b/tests/Support/ValidatedInputTest.php index 3e5e77f354..81038a8b03 100644 --- a/tests/Support/ValidatedInputTest.php +++ b/tests/Support/ValidatedInputTest.php @@ -6,17 +6,16 @@ use Generator; use Hypervel\Http\UploadedFile; -use Hypervel\Support\CarbonImmutable; -use Hypervel\Support\Collection; -use Hypervel\Support\Stringable; use Hypervel\Support\ValidatedInput; -use Hypervel\Tests\Support\Fixtures\StringBackedEnum; +use Hypervel\Tests\Support\Concerns\TestsInteractsWithDataContract; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\VarDumper\VarDumper; class ValidatedInputTest extends TestCase { + use TestsInteractsWithDataContract; + public function testCanAccessInput(): void { $input = new ValidatedInput(['name' => 'Taylor', 'votes' => 100]); @@ -53,286 +52,69 @@ public function testCanMergeItems(): void $this->assertEquals(['name' => 'Taylor', 'votes' => 100], $input->all()); } - public function testInputExistence(): void - { - $inputA = new ValidatedInput(['name' => 'Taylor']); - - $this->assertTrue($inputA->has('name')); - $this->assertTrue($inputA->missing('votes')); - $this->assertTrue($inputA->missing(['votes'])); - $this->assertFalse($inputA->missing('name')); - - $inputB = new ValidatedInput(['name' => 'Taylor', 'votes' => 100]); - - $this->assertTrue($inputB->has(['name', 'votes'])); - } - public function testExistsMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); - - $this->assertTrue($input->exists('name')); - $this->assertTrue($input->exists('surname')); - $this->assertTrue($input->exists(['name', 'surname'])); - $this->assertTrue($input->exists('foo.bar')); - $this->assertTrue($input->exists(['name', 'foo.baz'])); - $this->assertTrue($input->exists(['name', 'foo'])); - $this->assertTrue($input->exists('foo')); - - $this->assertFalse($input->exists('votes')); - $this->assertFalse($input->exists(['name', 'votes'])); - $this->assertFalse($input->exists(['votes', 'foo.bar'])); + $this->assertDataExistsContract(); + $this->assertDataExistsNestedContract(); } public function testHasMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); - - $this->assertTrue($input->has('name')); - $this->assertTrue($input->has('surname')); - $this->assertTrue($input->has(['name', 'surname'])); - $this->assertTrue($input->has('foo.bar')); - $this->assertTrue($input->has(['name', 'foo.baz'])); - $this->assertTrue($input->has(['name', 'foo'])); - $this->assertTrue($input->has('foo')); - - $this->assertFalse($input->has('votes')); - $this->assertFalse($input->has(['name', 'votes'])); - $this->assertFalse($input->has(['votes', 'foo.bar'])); + $this->assertDataHasContract(); + $this->assertDataHasNestedContract(); } public function testHasAnyMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); - - $this->assertTrue($input->hasAny('name')); - $this->assertTrue($input->hasAny('surname')); - $this->assertTrue($input->hasAny('foo.bar')); - $this->assertTrue($input->hasAny(['name', 'surname'])); - $this->assertTrue($input->hasAny(['name', 'foo.bat'])); - $this->assertTrue($input->hasAny(['votes', 'foo'])); - - $this->assertFalse($input->hasAny('votes')); - $this->assertFalse($input->hasAny(['votes', 'foo.bat'])); + $this->assertDataHasAnyContract(); + $this->assertDataHasAnyNestedContract(); } public function testWhenHasMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'age' => '', 'foo' => ['bar' => null]]); - - $name = $age = $city = $foo = $bar = $baz = false; - - $input->whenHas('name', function ($value) use (&$name) { - $name = $value; - }); - - $input->whenHas('age', function ($value) use (&$age) { - $age = $value; - }); - - $input->whenHas('city', function ($value) use (&$city) { - $city = $value; - }); - - $input->whenHas('foo', function ($value) use (&$foo) { - $foo = $value; - }); - - $input->whenHas('foo.bar', function ($value) use (&$bar) { - $bar = $value; - }); - - $input->whenHas('foo.baz', function () use (&$baz) { - $baz = 'test'; - }, function () use (&$baz) { - $baz = true; - }); - - $this->assertSame('Fatih', $name); - $this->assertSame('', $age); - $this->assertFalse($city); - $this->assertEquals(['bar' => null], $foo); - $this->assertTrue($baz); - $this->assertNull($bar); + $this->assertDataWhenHasContract(); + $this->assertDataWhenHasNestedContract(); } public function testFilledMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); - - $this->assertTrue($input->filled('name')); - $this->assertTrue($input->filled('surname')); - $this->assertTrue($input->filled(['name', 'surname'])); - $this->assertTrue($input->filled(['name', 'foo'])); - $this->assertTrue($input->filled('foo')); - - $this->assertFalse($input->filled('foo.bar')); - $this->assertFalse($input->filled(['name', 'foo.baz'])); - $this->assertFalse($input->filled('votes')); - $this->assertFalse($input->filled(['name', 'votes'])); - $this->assertFalse($input->filled(['votes', 'foo.bar'])); + $this->assertDataFilledContract(); + $this->assertDataFilledNestedContract(); } public function testIsNotFilledMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); - - $this->assertFalse($input->isNotFilled('name')); - $this->assertFalse($input->isNotFilled('surname')); - $this->assertFalse($input->isNotFilled(['name', 'surname'])); - $this->assertFalse($input->isNotFilled(['name', 'foo'])); - $this->assertFalse($input->isNotFilled('foo')); - $this->assertFalse($input->isNotFilled(['name', 'foo.baz'])); - $this->assertFalse($input->isNotFilled(['name', 'votes'])); - - $this->assertTrue($input->isNotFilled('foo.bar')); - $this->assertTrue($input->isNotFilled('votes')); - $this->assertTrue($input->isNotFilled(['votes', 'foo.bar'])); + $this->assertDataIsNotFilledContract(); + $this->assertDataIsNotFilledNestedContract(); } public function testAnyFilledMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); - - $this->assertTrue($input->anyFilled('name')); - $this->assertTrue($input->anyFilled('surname')); - $this->assertTrue($input->anyFilled(['name', 'surname'])); - $this->assertTrue($input->anyFilled(['name', 'foo'])); - $this->assertTrue($input->anyFilled('foo')); - $this->assertTrue($input->anyFilled(['name', 'foo.baz'])); - $this->assertTrue($input->anyFilled(['name', 'votes'])); - - $this->assertFalse($input->anyFilled('foo.bar')); - $this->assertFalse($input->anyFilled('votes')); - $this->assertFalse($input->anyFilled(['votes', 'foo.bar'])); + $this->assertDataAnyFilledContract(); + $this->assertDataAnyFilledNestedContract(); } public function testWhenFilledMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'age' => '', 'foo' => ['bar' => null]]); - - $name = $age = $city = $foo = $bar = $baz = false; - - $input->whenFilled('name', function ($value) use (&$name) { - $name = $value; - }); - - $input->whenFilled('age', function ($value) use (&$age) { - $age = $value; - }); - - $input->whenFilled('city', function ($value) use (&$city) { - $city = $value; - }); - - $input->whenFilled('foo', function ($value) use (&$foo) { - $foo = $value; - }); - - $input->whenFilled('foo.bar', function ($value) use (&$bar) { - $bar = $value; - }); - - $input->whenFilled('foo.baz', function () use (&$baz) { - $baz = 'test'; - }, function () use (&$baz) { - $baz = true; - }); - - $this->assertSame('Fatih', $name); - $this->assertEquals(['bar' => null], $foo); - $this->assertTrue($baz); - $this->assertFalse($age); - $this->assertFalse($city); - $this->assertFalse($bar); + $this->assertDataWhenFilledContract(); + $this->assertDataWhenFilledNestedContract(); } public function testWhenEnumMethod(): void { - $input = new ValidatedInput(['status' => 'Hello world', 'invalid' => 'invalid', 'age' => '']); - - $status = $invalid = $age = $missing = $default = false; - - $input->whenEnum('status', StringBackedEnum::class, function (StringBackedEnum $value) use (&$status): void { - $status = $value; - }); - - $input->whenEnum('invalid', StringBackedEnum::class, function (StringBackedEnum $value) use (&$invalid): void { - $invalid = $value; - }); - - $input->whenEnum('age', StringBackedEnum::class, function (StringBackedEnum $value) use (&$age): void { - $age = $value; - }); - - $input->whenEnum('missing', StringBackedEnum::class, function (StringBackedEnum $value) use (&$missing): void { - $missing = $value; - }, function () use (&$default): void { - $default = true; - }); - - $this->assertSame(StringBackedEnum::HelloWorld, $status); - $this->assertFalse($invalid); - $this->assertFalse($age); - $this->assertFalse($missing); - $this->assertTrue($default); + $this->assertDataWhenEnumContract(); } public function testMissingMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); - - $this->assertFalse($input->missing('name')); - $this->assertFalse($input->missing('surname')); - $this->assertFalse($input->missing(['name', 'surname'])); - $this->assertFalse($input->missing('foo.bar')); - $this->assertFalse($input->missing(['name', 'foo.baz'])); - $this->assertFalse($input->missing(['name', 'foo'])); - $this->assertFalse($input->missing('foo')); - - $this->assertTrue($input->missing('votes')); - $this->assertTrue($input->missing(['name', 'votes'])); - $this->assertTrue($input->missing(['votes', 'foo.bar'])); + $this->assertDataMissingContract(); + $this->assertDataMissingNestedContract(); } public function testWhenMissingMethod(): void { - $input = new ValidatedInput(['foo' => ['bar' => null]]); - - $name = $age = $city = $foo = $bar = $baz = false; - - $input->whenMissing('name', function () use (&$name) { - $name = 'Fatih'; - }); - - $input->whenMissing('age', function () use (&$age) { - $age = ''; - }); - - $input->whenMissing('city', function () use (&$city) { - $city = null; - }); - - $input->whenMissing('foo', function ($value) use (&$foo) { - $foo = $value; - }); - - $input->whenMissing('foo.baz', function () use (&$baz) { - $baz = true; - }); - - $input->whenMissing('foo.bar', function () use (&$bar) { - $bar = 'test'; - }, function () use (&$bar) { - $bar = true; - }); - - $this->assertSame('Fatih', $name); - $this->assertSame('', $age); - $this->assertNull($city); - $this->assertFalse($foo); - $this->assertTrue($baz); - $this->assertTrue($bar); + $this->assertDataWhenMissingContract(); + $this->assertDataWhenMissingNestedContract(); } public function testKeysMethod(): void @@ -360,186 +142,64 @@ public function testInputMethod(): void public function testStrMethod(): void { - $input = new ValidatedInput([ - 'int' => 123, - 'int_str' => '456', - 'float' => 123.456, - 'float_str' => '123.456', - 'float_zero' => 0.000, - 'float_str_zero' => '0.000', - 'str' => 'abc', - 'empty_str' => '', - 'null' => null, - ]); - - $this->assertInstanceOf(Stringable::class, $input->str('int')); - $this->assertInstanceOf(Stringable::class, $input->str('unknown_key')); - $this->assertSame('123', $input->str('int')->value()); - $this->assertSame('456', $input->str('int_str')->value()); - $this->assertSame('123.456', $input->str('float')->value()); - $this->assertSame('123.456', $input->str('float_str')->value()); - $this->assertSame('0', $input->str('float_zero')->value()); - $this->assertSame('0.000', $input->str('float_str_zero')->value()); - $this->assertSame('', $input->str('empty_str')->value()); - $this->assertSame('', $input->str('null')->value()); - $this->assertSame('', $input->str('unknown_key')->value()); + $this->assertDataStrContract(); } public function testStringMethod(): void { - $input = new ValidatedInput([ - 'int' => 123, - 'int_str' => '456', - 'float' => 123.456, - 'float_str' => '123.456', - 'float_zero' => 0.000, - 'float_str_zero' => '0.000', - 'str' => 'abc', - 'empty_str' => '', - 'null' => null, - ]); - - $this->assertInstanceOf(Stringable::class, $input->string('int')); - $this->assertInstanceOf(Stringable::class, $input->string('unknown_key')); - $this->assertSame('123', $input->string('int')->value()); - $this->assertSame('456', $input->string('int_str')->value()); - $this->assertSame('123.456', $input->string('float')->value()); - $this->assertSame('123.456', $input->string('float_str')->value()); - $this->assertSame('0', $input->string('float_zero')->value()); - $this->assertSame('0.000', $input->string('float_str_zero')->value()); - $this->assertSame('', $input->string('empty_str')->value()); - $this->assertSame('', $input->string('null')->value()); - $this->assertSame('', $input->string('unknown_key')->value()); + $this->assertDataStringContract(); + $this->assertDataStringStrictContract(); } public function testBooleanMethod(): void { - $input = new ValidatedInput([ - 'with_trashed' => 'false', - 'download' => true, - 'checked' => 1, - 'unchecked' => '0', - 'with_on' => 'on', - 'with_yes' => 'yes', - ]); - - $this->assertTrue($input->boolean('checked')); - $this->assertTrue($input->boolean('download')); - $this->assertFalse($input->boolean('unchecked')); - $this->assertFalse($input->boolean('with_trashed')); - $this->assertFalse($input->boolean('some_undefined_key')); - $this->assertTrue($input->boolean('with_on')); - $this->assertTrue($input->boolean('with_yes')); + $this->assertDataBooleanContract(); + $this->assertDataBooleanStrictContract(); + $this->assertDataBooleanStrictNativeValuesContract(); } public function testIntegerMethod(): void { - $input = new ValidatedInput([ - 'int' => '123', - 'raw_int' => 456, - 'zero_padded' => '078', - 'space_padded' => ' 901', - 'nan' => 'nan', - 'mixed' => '1ab', - 'underscore_notation' => '2_000', - 'null' => null, - ]); - - $this->assertSame(123, $input->integer('int')); - $this->assertSame(456, $input->integer('raw_int')); - $this->assertSame(78, $input->integer('zero_padded')); - $this->assertSame(901, $input->integer('space_padded')); - $this->assertSame(0, $input->integer('nan')); - $this->assertSame(1, $input->integer('mixed')); - $this->assertSame(2, $input->integer('underscore_notation')); - $this->assertSame(123456, $input->integer('unknown_key', 123456)); - $this->assertSame(0, $input->integer('null')); - $this->assertSame(0, $input->integer('null', 123456)); + $this->assertDataIntegerContract(); + $this->assertDataIntegerStrictContract(); } public function testFloatMethod(): void { - $input = new ValidatedInput([ - 'float' => '1.23', - 'raw_float' => 45.6, - 'decimal_only' => '.6', - 'zero_padded' => '0.78', - 'space_padded' => ' 90.1', - 'nan' => 'nan', - 'mixed' => '1.ab', - 'scientific_notation' => '1e3', - 'null' => null, - ]); + $this->assertDataFloatContract(); + $this->assertDataFloatStrictContract(); + } - $this->assertSame(1.23, $input->float('float')); - $this->assertSame(45.6, $input->float('raw_float')); - $this->assertSame(.6, $input->float('decimal_only')); - $this->assertSame(0.78, $input->float('zero_padded')); - $this->assertSame(90.1, $input->float('space_padded')); - $this->assertSame(0.0, $input->float('nan')); - $this->assertSame(1.0, $input->float('mixed')); - $this->assertSame(1e3, $input->float('scientific_notation')); - $this->assertSame(123.456, $input->float('unknown_key', 123.456)); - $this->assertSame(0.0, $input->float('null')); - $this->assertSame(0.0, $input->float('null', 123.456)); + public function testClampMethod(): void + { + $this->assertDataClampContract(); } public function testDateMethod(): void { - $input = new ValidatedInput([ - 'as_null' => null, - 'as_invalid' => 'invalid', - - 'as_datetime' => '24-01-01 16:30:25', - 'as_format' => '1704126625', - 'as_timezone' => '24-01-01 13:30:25', - - 'as_date' => '2024-01-01', - 'as_time' => '16:30:25', - ]); - - $current = CarbonImmutable::create(2024, 1, 1, 16, 30, 25); - - $this->assertNull($input->date('as_null')); - $this->assertNull($input->date('doesnt_exists')); - - $dateTime = $input->date('as_datetime'); - - $this->assertSame(CarbonImmutable::class, $dateTime::class); - $this->assertEquals($current, $dateTime); - $this->assertEquals($current->format('Y-m-d H:i:s P'), $input->date('as_format', 'U')->format('Y-m-d H:i:s P')); - $this->assertEquals($current, $input->date('as_timezone', null, 'America/Santiago')); + $this->assertDataDateContract(); + } - $this->assertTrue($input->date('as_date')->isSameDay($current)); - $this->assertTrue($input->date('as_time')->isSameSecond('16:30:25')); + public function testIntervalMethod(): void + { + $this->assertDataIntervalContract(); } public function testEnumMethod(): void { - $input = new ValidatedInput([ - 'valid_enum_value' => 'Hello world', - 'invalid_enum_value' => 'invalid', - ]); - - $this->assertNull($input->enum('doesnt_exists', StringBackedEnum::class)); - - $this->assertEquals(StringBackedEnum::HelloWorld, $input->enum('valid_enum_value', StringBackedEnum::class)); - - $this->assertNull($input->enum('invalid_enum_value', StringBackedEnum::class)); + $this->assertDataEnumContract(); + $this->assertDataEnumNestedContract(); } public function testEnumsMethod(): void { - $input = new ValidatedInput([ - 'valid_enum_value' => 'Hello world', - 'invalid_enum_value' => 'invalid', - ]); - - $this->assertEmpty($input->enums('doesnt_exists', StringBackedEnum::class)); - - $this->assertEquals([StringBackedEnum::HelloWorld], $input->enums('valid_enum_value', StringBackedEnum::class)); + $this->assertDataEnumsContract(); + } - $this->assertEmpty($input->enums('invalid_enum_value', StringBackedEnum::class)); + public function testArrayMethod(): void + { + $this->assertDataArrayContract(); + $this->assertDataArrayArrayableContract(); } public function testFileMethod(): void @@ -591,49 +251,31 @@ public static function dumpArguments(): Generator public function testCollectMethod(): void { - $input = new ValidatedInput(['users' => [1, 2, 3]]); - - $this->assertInstanceOf(Collection::class, $input->collect('users')); - $this->assertTrue($input->collect('developers')->isEmpty()); - $this->assertEquals([1, 2, 3], $input->collect('users')->all()); - $this->assertEquals(['users' => [1, 2, 3]], $input->collect()->all()); - - $input = new ValidatedInput(['text-payload']); - $this->assertEquals(['text-payload'], $input->collect()->all()); - - $input = new ValidatedInput(['email' => 'test@example.com']); - $this->assertEquals(['test@example.com'], $input->collect('email')->all()); - - $input = new ValidatedInput([]); - $this->assertInstanceOf(Collection::class, $input->collect()); - $this->assertTrue($input->collect()->isEmpty()); - - $input = new ValidatedInput(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com']); - $this->assertInstanceOf(Collection::class, $input->collect(['users'])); - $this->assertTrue($input->collect(['developers'])->isEmpty()); - $this->assertTrue($input->collect(['roles'])->isNotEmpty()); - $this->assertEquals(['roles' => [4, 5, 6]], $input->collect(['roles'])->all()); - $this->assertEquals(['users' => [1, 2, 3], 'email' => 'test@example.com'], $input->collect(['users', 'email'])->all()); - $this->assertEquals(collect(['roles' => [4, 5, 6], 'foo' => ['bar', 'baz']]), $input->collect(['roles', 'foo'])); - $this->assertEquals(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com'], $input->collect()->all()); + $this->assertDataCollectContract(); + $this->assertDataCollectNestedContract(); } public function testOnlyMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); - - $this->assertEquals(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null]], $input->only('name', 'surname', 'foo.bar')); - $this->assertEquals(['name' => 'Fatih', 'foo' => ['bar' => null, 'baz' => '']], $input->only('name', 'foo')); - $this->assertEquals(['foo' => ['baz' => '']], $input->only('foo.baz')); - $this->assertEquals(['name' => 'Fatih'], $input->only('name')); + $this->assertDataOnlyContract(); + $this->assertDataOnlyNestedContract(); } public function testExceptMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); + $this->assertDataExceptContract(); + $this->assertDataExceptNestedContract(); + } - $this->assertEquals(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null]], $input->except('foo.baz')); - $this->assertEquals(['surname' => 'AYDIN'], $input->except('name', 'foo')); - $this->assertSame([], $input->except('name', 'surname', 'foo')); + public function testNullValues(): void + { + $this->assertDataNullValuesContract(); + $this->assertDataNullValuesIgnoreDefaultsContract(); + $this->assertDataNestedNullValuesContract(); + } + + protected static function arrayToDataFactory(array $data): object + { + return new ValidatedInput($data); } } diff --git a/tests/View/ViewComponentAttributeBagTest.php b/tests/View/ViewComponentAttributeBagTest.php index 1bd9f184a9..bb211b16a6 100644 --- a/tests/View/ViewComponentAttributeBagTest.php +++ b/tests/View/ViewComponentAttributeBagTest.php @@ -4,12 +4,14 @@ namespace Hypervel\Tests\View; -use Hypervel\Support\Stringable as HypervelStringable; +use Hypervel\Tests\Support\Concerns\TestsInteractsWithDataContract; use Hypervel\Tests\TestCase; use Hypervel\View\ComponentAttributeBag; class ViewComponentAttributeBagTest extends TestCase { + use TestsInteractsWithDataContract; + public function testAttributeRetrieval(): void { $bag = new ComponentAttributeBag(['class' => 'font-bold', 'name' => 'test']); @@ -104,19 +106,104 @@ public function testAttributeRetrieval(): void 'test-extract-1', 'test-extract-2' => 'defaultValue', ])); + } + public function testOnly(): void + { $bag = new ComponentAttributeBag(['class' => 'font-bold', 'name' => 'test', 'id' => 'my-id']); + $this->assertInstanceOf(ComponentAttributeBag::class, $bag->only('class')); $this->assertSame('class="font-bold"', (string) $bag->only('class')); $this->assertSame('class="font-bold" name="test"', (string) $bag->only(['class', 'name'])); $this->assertSame('', (string) $bag->only('missing')); $this->assertSame('name="test"', (string) $bag->only(['name', 'missing'])); + $this->assertSame('class="font-bold" name="test" id="my-id"', (string) $bag->only(null)); + $this->assertSame(['class' => 'font-bold'], $bag->all('class')); + } + + public function testExcept(): void + { + $bag = new ComponentAttributeBag(['class' => 'font-bold', 'name' => 'test', 'id' => 'my-id']); $this->assertInstanceOf(ComponentAttributeBag::class, $bag->except('class')); $this->assertSame('name="test" id="my-id"', (string) $bag->except('class')); $this->assertSame('id="my-id"', (string) $bag->except(['class', 'name'])); $this->assertSame('class="font-bold" name="test" id="my-id"', (string) $bag->except('missing')); $this->assertSame('class="font-bold" id="my-id"', (string) $bag->except(['name', 'missing'])); + $this->assertSame('class="font-bold" name="test" id="my-id"', (string) $bag->except(null)); + } + + public function testHas(): void + { + $this->assertDataHasContract(); + } + + public function testHasAny(): void + { + $this->assertDataHasAnyContract(); + } + + public function testWhenEnum(): void + { + $this->assertDataWhenEnumContract(); + } + + public function testMissing(): void + { + $this->assertDataMissingContract(); + } + + public function testStr(): void + { + $this->assertDataStrContract(); + } + + public function testClamp(): void + { + $this->assertDataClampContract(); + } + + public function testDate(): void + { + $this->assertDataDateContract(); + } + + public function testInterval(): void + { + $this->assertDataIntervalContract(); + } + + public function testEnum(): void + { + $this->assertDataEnumContract(); + } + + public function testEnums(): void + { + $this->assertDataEnumsContract(); + } + + public function testArray(): void + { + $this->assertDataArrayContract(); + $this->assertDataArrayArrayableContract(); + } + + public function testCollect(): void + { + $this->assertDataCollectContract(); + } + + public function testNullValues(): void + { + $this->assertDataNullValuesContract(); + + $bag = new ComponentAttributeBag(['city' => null]); + + $this->assertSame('default', $bag->string('city', 'default')->value()); + $this->assertTrue($bag->boolean('city', true)); + $this->assertSame(42, $bag->integer('city', 42)); + $this->assertSame(4.2, $bag->float('city', 4.2)); } public function testMergeTerminatesDefaultStyleWithSemicolon(): void @@ -185,24 +272,6 @@ public function testItMakesAnExceptionForLivewireWireAttributes(): void $this->assertSame('wire:loading="" wire:loading.remove="" wire:poll=""', (string) $bag); } - public function testAttributeExistence(): void - { - $bag = new ComponentAttributeBag(['name' => 'test', 'href' => '', 'src' => null]); - - $this->assertTrue($bag->has('src')); - $this->assertTrue($bag->has('href')); - $this->assertTrue($bag->has('name')); - $this->assertTrue($bag->has(['name'])); - $this->assertTrue($bag->hasAny(['class', 'name'])); - $this->assertTrue($bag->hasAny('class', 'name')); - $this->assertFalse($bag->missing('name')); - $this->assertFalse($bag->has('class')); - $this->assertFalse($bag->has(['class'])); - $this->assertFalse($bag->has(['name', 'class'])); - $this->assertFalse($bag->has('name', 'class')); - $this->assertTrue($bag->missing('class')); - } - public function testAttributeIsEmpty(): void { $bag = new ComponentAttributeBag([]); @@ -245,196 +314,66 @@ public function testAttributeArrayAndCollectSubsets(): void public function testFilled(): void { - $bag = new ComponentAttributeBag([ - 'name' => 'test', - 'class' => 'font-bold', - 'empty' => '', - 'whitespace' => ' ', - 'zero' => '0', - 'false' => false, - 'null' => null, - ]); - - $this->assertTrue($bag->filled('name')); - $this->assertTrue($bag->filled('class')); - $this->assertTrue($bag->filled('zero')); - $this->assertTrue($bag->filled('false')); - $this->assertFalse($bag->filled('null')); - $this->assertFalse($bag->filled('empty')); - $this->assertFalse($bag->filled('whitespace')); - $this->assertFalse($bag->filled('nonexistent')); - $this->assertTrue($bag->filled(['name', 'class'])); - $this->assertFalse($bag->filled(['name', 'empty'])); - $this->assertTrue($bag->filled('name', 'class')); - $this->assertFalse($bag->filled('name', 'empty')); + $this->assertDataFilledContract(); } public function testIsNotFilled(): void { - $bag = new ComponentAttributeBag([ - 'name' => 'test', - 'empty' => '', - 'whitespace' => ' ', - ]); - - $this->assertFalse($bag->isNotFilled('name')); - $this->assertTrue($bag->isNotFilled('empty')); - $this->assertTrue($bag->isNotFilled('whitespace')); - $this->assertTrue($bag->isNotFilled('nonexistent')); - $this->assertTrue($bag->isNotFilled(['empty', 'whitespace'])); - $this->assertFalse($bag->isNotFilled(['name', 'empty'])); + $this->assertDataIsNotFilledContract(); } public function testAnyFilled(): void { - $bag = new ComponentAttributeBag([ - 'name' => 'test', - 'empty' => '', - 'whitespace' => ' ', - ]); - - $this->assertTrue($bag->anyFilled(['name', 'empty'])); - $this->assertTrue($bag->anyFilled(['empty', 'name'])); - $this->assertFalse($bag->anyFilled(['empty', 'whitespace'])); - $this->assertTrue($bag->anyFilled('name', 'empty')); - $this->assertFalse($bag->anyFilled('empty', 'whitespace')); + $this->assertDataAnyFilledContract(); } public function testWhenFilled(): void { - $bag = new ComponentAttributeBag([ - 'name' => 'test', - 'empty' => '', - ]); - - $this->assertSame('callback-test', $bag->whenFilled('name', function ($value) { - return 'callback-' . $value; - })); - - $this->assertSame($bag, $bag->whenFilled('empty', function ($value) { - return 'callback-' . $value; - })); - - $this->assertSame('default-callback', $bag->whenFilled('empty', function ($value) { - return 'callback-' . $value; - }, function () { - return 'default-callback'; - })); + $this->assertDataWhenFilledContract(); } public function testWhenHas(): void { - $bag = new ComponentAttributeBag(['name' => 'test']); - - $this->assertSame('callback-test', $bag->whenHas('name', function ($value) { - return 'callback-' . $value; - })); - - $this->assertSame($bag, $bag->whenHas('missing', function ($value) { - return 'callback-' . $value; - })); - - $this->assertSame('default-callback', $bag->whenHas('missing', function ($value) { - return 'callback-' . $value; - }, function () { - return 'default-callback'; - })); + $this->assertDataWhenHasContract(); } public function testWhenMissing(): void { - $bag = new ComponentAttributeBag(['name' => 'test']); - - $this->assertSame($bag, $bag->whenMissing('name', function () { - return 'callback'; - })); - - $this->assertSame('callback', $bag->whenMissing('missing', function () { - return 'callback'; - })); - - $this->assertSame('default-callback', $bag->whenMissing('name', function () { - return 'callback'; - }, function () { - return 'default-callback'; - })); + $this->assertDataWhenMissingContract(); } public function testString(): void { - $bag = new ComponentAttributeBag([ - 'name' => 'test', - 'empty' => '', - 'number' => 123, - ]); - - $this->assertInstanceOf(HypervelStringable::class, $bag->string('name')); - $this->assertSame('test', (string) $bag->string('name')); - $this->assertSame('', (string) $bag->string('empty')); - $this->assertSame('123', (string) $bag->string('number')); - $this->assertSame('default', (string) $bag->string('missing', 'default')); + $this->assertDataStringContract(); + $this->assertDataStringStrictContract(); } public function testBoolean(): void { - $bag = new ComponentAttributeBag([ - 'true_string' => 'true', - 'false_string' => 'false', - 'one' => '1', - 'zero' => '0', - 'yes' => 'yes', - 'no' => 'no', - 'on' => 'on', - 'off' => 'off', - ]); - - $this->assertTrue($bag->boolean('true_string')); - $this->assertFalse($bag->boolean('false_string')); - $this->assertTrue($bag->boolean('one')); - $this->assertFalse($bag->boolean('zero')); - $this->assertTrue($bag->boolean('yes')); - $this->assertFalse($bag->boolean('no')); - $this->assertTrue($bag->boolean('on')); - $this->assertFalse($bag->boolean('off')); - $this->assertTrue($bag->boolean('missing', true)); - $this->assertFalse($bag->boolean('missing', false)); + $this->assertDataBooleanContract(); + $this->assertDataBooleanStrictContract(); + $this->assertDataBooleanStrictNativeValuesContract(); } public function testInteger(): void { - $bag = new ComponentAttributeBag([ - 'number' => '123', - 'float' => '123.45', - 'string' => 'abc', - ]); - - $this->assertSame(123, $bag->integer('number')); - $this->assertSame(123, $bag->integer('float')); - $this->assertSame(0, $bag->integer('string')); - $this->assertSame(42, $bag->integer('missing', 42)); + $this->assertDataIntegerContract(); + $this->assertDataIntegerStrictContract(); } public function testFloat(): void { - $bag = new ComponentAttributeBag([ - 'number' => '123', - 'float' => '123.45', - 'string' => 'abc', - ]); - - $this->assertSame(123.0, $bag->float('number')); - $this->assertSame(123.45, $bag->float('float')); - $this->assertSame(0.0, $bag->float('string')); - $this->assertSame(42.5, $bag->float('missing', 42.5)); + $this->assertDataFloatContract(); + $this->assertDataFloatStrictContract(); } public function testExists(): void { - $bag = new ComponentAttributeBag(['name' => 'test']); + $this->assertDataExistsContract(); + } - $this->assertTrue($bag->exists('name')); - $this->assertFalse($bag->exists('missing')); - $this->assertTrue($bag->exists(['name'])); - $this->assertFalse($bag->exists(['missing'])); + protected static function arrayToDataFactory(array $data): object + { + return new ComponentAttributeBag($data); } } diff --git a/types/Http/Request.php b/types/Http/Request.php index 777011ce20..3f1c4a9e0a 100644 --- a/types/Http/Request.php +++ b/types/Http/Request.php @@ -18,6 +18,15 @@ enum TestEnum: string assertType('TestEnum|null', $request->enum('key', TestEnum::class)); assertType('TestEnum|TestEnum::Foo', $request->enum('key', TestEnum::class, TestEnum::Foo)); +assertType('int', $request->integer('key')); +assertType('int', $request->integer('key', 5, true)); +assertType('int', $request->integer('key', fn () => 5, true)); +assertType('int|null', $request->integer('key', null, true)); +assertType('float', $request->float('key')); +assertType('float|null', $request->float('key', null, true)); +assertType('bool', $request->boolean('key')); +assertType('bool|null', $request->boolean('key', null, true)); + assertType('Hypervel\Routing\Route|null', $request->route()); assertType('object|string|null', $request->route('key')); From a08fb535b6ea448cc06821e1091beb64ab09df90 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:57:48 +0000 Subject: [PATCH 20/31] Return filtered numbers from cache getters and the page resolver Cache\Repository::integer() and float() and the PaginationState page resolver validated a value with filter_var() and then cast the original value. A numeric Stringable passes the filter but cannot be cast to a number, so a cached Str::of('12') was read back as 1 with a warning, and a Stringable page merged into request input threw. They now return the filtered value. Failed validation behaves as before, and the cache getters keep their native int and float fast paths. Found while fixing the same defect in InteractsWithData's strict getters. Regression tests are added to CacheRepositoryTest and PaginationResolverTest. Static analysis and the Cache and Pagination suites pass. --- src/cache/src/Repository.php | 10 ++++++---- src/pagination/src/PaginationState.php | 7 +++---- tests/Cache/CacheRepositoryTest.php | 17 +++++++++++++++++ tests/Pagination/PaginationResolverTest.php | 12 ++++++++++++ 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php index 2c467f2df8..7bad2ff81d 100644 --- a/src/cache/src/Repository.php +++ b/src/cache/src/Repository.php @@ -234,8 +234,9 @@ public function integer(UnitEnum|string $key, callable|int|null $default = null) return $value; } - if (filter_var($value, FILTER_VALIDATE_INT) !== false) { - return (int) $value; + // Return the filtered value: a numeric Stringable passes the filter, but casting it to int fails. + if (($integer = filter_var($value, FILTER_VALIDATE_INT)) !== false) { + return $integer; } throw new InvalidArgumentException( @@ -259,8 +260,9 @@ public function float(UnitEnum|string $key, callable|float|null $default = null) return $value; } - if (filter_var($value, FILTER_VALIDATE_FLOAT) !== false) { - return (float) $value; + // Return the filtered value: a numeric Stringable passes the filter, but casting it to float fails. + if (($float = filter_var($value, FILTER_VALIDATE_FLOAT)) !== false) { + return $float; } throw new InvalidArgumentException( diff --git a/src/pagination/src/PaginationState.php b/src/pagination/src/PaginationState.php index 73e7b1dade..5fae1697f5 100644 --- a/src/pagination/src/PaginationState.php +++ b/src/pagination/src/PaginationState.php @@ -33,11 +33,10 @@ public static function resolveUsing(Container $app): void return 1; } - $page = $request->input($pageName); + // Use the filtered value: a numeric Stringable passes the filter, but casting it to int fails. + $page = filter_var($request->input($pageName), FILTER_VALIDATE_INT); - return filter_var($page, FILTER_VALIDATE_INT) !== false && (int) $page >= 1 - ? (int) $page - : 1; + return $page !== false && $page >= 1 ? $page : 1; }); Paginator::queryStringResolver(static function (): array { diff --git a/tests/Cache/CacheRepositoryTest.php b/tests/Cache/CacheRepositoryTest.php index 77d84f52dc..09412c26d3 100644 --- a/tests/Cache/CacheRepositoryTest.php +++ b/tests/Cache/CacheRepositoryTest.php @@ -39,6 +39,7 @@ use Hypervel\Events\Dispatcher as EventDispatcher; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Stringable; use Hypervel\Support\Testing\Fakes\EventFake; use Hypervel\Tests\TestCase; use InvalidArgumentException; @@ -1435,6 +1436,14 @@ public function testItGetsAsIntegerFromNumericString(): void $this->assertSame(123, $repo->integer('foo')); } + public function testItGetsAsIntegerFromNumericStringable(): void + { + $repo = $this->getRepository(); + $repo->getStore()->expects('get')->with('foo')->andReturn(new Stringable('123')); + + $this->assertSame(123, $repo->integer('foo')); + } + public function testIntegerTypedGetterThrowsExceptionForNonInteger(): void { $this->expectException(InvalidArgumentException::class); @@ -1488,6 +1497,14 @@ public function testItGetsAsFloatFromNumericString(): void $this->assertSame(1.5, $repo->float('foo')); } + public function testItGetsAsFloatFromNumericStringable(): void + { + $repo = $this->getRepository(); + $repo->getStore()->expects('get')->with('foo')->andReturn(new Stringable('1.5')); + + $this->assertSame(1.5, $repo->float('foo')); + } + public function testFloatTypedGetterThrowsExceptionForNonFloat(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/Pagination/PaginationResolverTest.php b/tests/Pagination/PaginationResolverTest.php index 98397589d7..89d83bbc86 100644 --- a/tests/Pagination/PaginationResolverTest.php +++ b/tests/Pagination/PaginationResolverTest.php @@ -13,6 +13,7 @@ use Hypervel\Pagination\CursorPaginator; use Hypervel\Pagination\PaginationState; use Hypervel\Pagination\Paginator; +use Hypervel\Support\Stringable; use Hypervel\Testbench\TestCase; use Mockery as m; use ReflectionProperty; @@ -64,6 +65,17 @@ public function testCurrentPageResolverReturnsOneForNegativePage(): void $this->assertSame(1, Paginator::resolveCurrentPage()); } + public function testCurrentPageResolverAcceptsMergedNumericStringable(): void + { + $request = Request::create('https://example.com'); + $request->merge(['page' => new Stringable('2')]); + RequestContext::set($request); + + PaginationState::resolveUsing($this->app); + + $this->assertSame(2, Paginator::resolveCurrentPage()); + } + public function testCurrentCursorResolverReadsFromRequest(): void { $cursor = new Cursor(['id' => 10], true); From 08ffcbf15fa62b50f5805e7889c38ac49755c012 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:42:16 +0000 Subject: [PATCH 21/31] Flush Redis cache tags one tag at a time Port laravel/framework#62215 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. Flushing several tags removed every scanned chunk from every requested tag, so the script work grew with the tag count and could block Redis when many tags were flushed together. Both tag modes now flush each tag separately: each chunk is deleted and removed only from the tag it was scanned from. In "any" mode the chunk script also deletes the keys' reverse indexes, and empty tags are still removed from the registry after all tags are flushed. The Cluster paths keep removing memberships before values, so a concurrent rewrite never leaves a live value unindexed. A key shared by overlapping tags is now scanned once per tag; this is accepted because tag flushes are not a hot path. The unreachable empty-chunk guard is removed because LazyCollection::chunk() never yields an empty chunk. Upstream's two RedisStoreTest cases are ported, with the rewrite injected between tag passes because Hypervel's flush scripts dispatch no command events. ConcurrencyIntegrationTest adds the "any" mode rewrite case, and unit expectations are updated for per-tag scripts. Static analysis, the Cache unit suite and the Redis cache integration suite pass. --- .../src/Redis/Operations/AllTag/Flush.php | 57 ++++++-------- .../src/Redis/Operations/AnyTag/Flush.php | 45 +++++------ tests/Cache/Redis/AllTaggedCacheTest.php | 4 +- tests/Cache/Redis/AnyTagSetTest.php | 9 ++- tests/Cache/Redis/AnyTaggedCacheTest.php | 4 +- .../Redis/Operations/AllTag/FlushTest.php | 76 ++++++++++--------- .../Redis/Operations/AnyTag/FlushTest.php | 66 +++++----------- .../Redis/ConcurrencyIntegrationTest.php | 35 +++++++++ .../Cache/Redis/RedisStoreTest.php | 75 ++++++++++++++++++ 9 files changed, 223 insertions(+), 148 deletions(-) diff --git a/src/cache/src/Redis/Operations/AllTag/Flush.php b/src/cache/src/Redis/Operations/AllTag/Flush.php index ce28c3bc6a..e2440d1643 100644 --- a/src/cache/src/Redis/Operations/AllTag/Flush.php +++ b/src/cache/src/Redis/Operations/AllTag/Flush.php @@ -30,51 +30,38 @@ public function execute(array $tagIds): void $prefix = $this->context->prefix(); $isCluster = $this->context->isCluster(); - $tagKeys = array_map(fn (string $tagId): string => $prefix . $tagId, $tagIds); - $entries = $this->getEntries->execute($tagIds); - - foreach ($entries->chunk(self::CHUNK_SIZE) as $chunk) { - $members = array_values($chunk->all()); - - if ($members === []) { - continue; - } - - $keys = array_map(fn (string $key): string => $prefix . $key, $members); - - $this->context->withConnection(function (RedisConnection $connection) use ($keys, $members, $tagKeys, $isCluster): void { - if ($isCluster) { - // Remove memberships first so a racing writer can leave only - // orphaned metadata for Prune, never an unindexed live value. - foreach ($tagKeys as $tagKey) { + // Flush one tag at a time so each chunk is only removed from the tag it was scanned + // from. Removing every chunk from every requested tag multiplies the work by the tag + // count and blocks Redis when many tags are flushed together. + foreach ($tagIds as $tagId) { + $tagKey = $prefix . $tagId; + + foreach ($this->getEntries->execute([$tagId])->chunk(self::CHUNK_SIZE) as $chunk) { + $members = array_values($chunk->all()); + $keys = array_map(fn (string $key): string => $prefix . $key, $members); + + $this->context->withConnection(function (RedisConnection $connection) use ($keys, $members, $tagKey, $isCluster): void { + if ($isCluster) { + // Remove memberships first so a racing writer can leave only + // orphaned metadata for Prune, never an unindexed live value. $connection->zrem($tagKey, ...$members); + $connection->del(...$keys); + } else { + $connection->evalWithShaCache($this->flushChunkScript(), [$tagKey, ...$keys], $members); } - - $connection->del(...$keys); - } else { - $connection->evalWithShaCache( - $this->flushChunkScript(), - [...$tagKeys, ...$keys], - [count($tagKeys), ...$members], - ); - } - }); + }); + } } } /** - * Atomically delete scanned values and memberships, preserving concurrent additions. + * Atomically delete scanned values and their memberships, preserving concurrent additions. */ protected function flushChunkScript(): string { return <<<'LUA' - local tagCount = tonumber(ARGV[1]) - - redis.call('DEL', unpack(KEYS, tagCount + 1, #KEYS)) - - for i = 1, tagCount do - redis.call('ZREM', KEYS[i], unpack(ARGV, 2, #ARGV)) - end + redis.call('DEL', unpack(KEYS, 2, #KEYS)) + redis.call('ZREM', KEYS[1], unpack(ARGV)) return 1 LUA; diff --git a/src/cache/src/Redis/Operations/AnyTag/Flush.php b/src/cache/src/Redis/Operations/AnyTag/Flush.php index fcb59e5a05..7c6a2fc3d8 100644 --- a/src/cache/src/Redis/Operations/AnyTag/Flush.php +++ b/src/cache/src/Redis/Operations/AnyTag/Flush.php @@ -40,24 +40,27 @@ public function execute(array $tags): bool } $tags = array_map(strval(...), $tags); - $tagKeys = array_map($this->context->tagHashKey(...), $tags); $isCluster = $this->context->isCluster(); - $buffer = []; - + // Flush one tag at a time so each chunk is only removed from the tag it was scanned + // from. Removing every chunk from every requested tag multiplies the work by the tag + // count and blocks Redis when many tags are flushed together. foreach ($tags as $tag) { + $tagKey = $this->context->tagHashKey($tag); + $buffer = []; + foreach ($this->getTaggedKeys->execute($tag) as $key) { - $buffer[$key] = true; + $buffer[] = $key; if (count($buffer) === self::CHUNK_SIZE) { - $this->flushChunk(array_keys($buffer), $tagKeys, $isCluster); + $this->flushChunk($buffer, $tagKey, $isCluster); $buffer = []; } } - } - if ($buffer !== []) { - $this->flushChunk(array_keys($buffer), $tagKeys, $isCluster); + if ($buffer !== []) { + $this->flushChunk($buffer, $tagKey, $isCluster); + } } $this->context->withConnection( @@ -71,23 +74,19 @@ public function execute(array $tags): bool * Delete a bounded chunk of values and their scanned memberships. * * @param array $keys Cache keys without the store prefix - * @param array $tagKeys */ - private function flushChunk(array $keys, array $tagKeys, bool $isCluster): void + private function flushChunk(array $keys, string $tagKey, bool $isCluster): void { $keys = array_map(strval(...), $keys); $prefix = $this->context->prefix(); $reverseIndexKeys = array_map($this->context->reverseIndexKey(...), $keys); $valueKeys = array_map(fn (string $key): string => $prefix . $key, $keys); - $this->context->withConnection(function (RedisConnection $connection) use ($keys, $tagKeys, $reverseIndexKeys, $valueKeys, $isCluster): void { + $this->context->withConnection(function (RedisConnection $connection) use ($keys, $tagKey, $reverseIndexKeys, $valueKeys, $isCluster): void { if ($isCluster) { // Remove memberships before values so racing writers leave only // orphaned metadata for Prune, never unindexed live values. - foreach ($tagKeys as $tagKey) { - $connection->hdel($tagKey, ...$keys); - } - + $connection->hdel($tagKey, ...$keys); $connection->del(...$reverseIndexKeys); $connection->unlink(...$valueKeys); @@ -96,8 +95,8 @@ private function flushChunk(array $keys, array $tagKeys, bool $isCluster): void $connection->evalWithShaCache( $this->flushChunkScript(), - [...$tagKeys, ...$reverseIndexKeys, ...$valueKeys], - [count($tagKeys), count($keys), ...$keys], + [$tagKey, ...$reverseIndexKeys, ...$valueKeys], + [count($keys), ...$keys], ); }); } @@ -108,15 +107,11 @@ private function flushChunk(array $keys, array $tagKeys, bool $isCluster): void protected function flushChunkScript(): string { return <<<'LUA' - local tagCount = tonumber(ARGV[1]) - local keyCount = tonumber(ARGV[2]) - - for i = 1, tagCount do - redis.call('HDEL', KEYS[i], unpack(ARGV, 3, #ARGV)) - end + local keyCount = tonumber(ARGV[1]) - redis.call('DEL', unpack(KEYS, tagCount + 1, tagCount + keyCount)) - redis.call('UNLINK', unpack(KEYS, tagCount + keyCount + 1, #KEYS)) + redis.call('HDEL', KEYS[1], unpack(ARGV, 2, #ARGV)) + redis.call('DEL', unpack(KEYS, 2, keyCount + 1)) + redis.call('UNLINK', unpack(KEYS, keyCount + 2, #KEYS)) return 1 LUA; diff --git a/tests/Cache/Redis/AllTaggedCacheTest.php b/tests/Cache/Redis/AllTaggedCacheTest.php index ae2b696d11..b473319fc2 100644 --- a/tests/Cache/Redis/AllTaggedCacheTest.php +++ b/tests/Cache/Redis/AllTaggedCacheTest.php @@ -307,7 +307,7 @@ public function testFlush(): void return ['key1' => 0, 'key2' => 0]; }); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_all:tag:people:entries', 'prefix:key1', 'prefix:key2'], [1, 'key1', 'key2']) + ->with(m::type('string'), ['prefix:_all:tag:people:entries', 'prefix:key1', 'prefix:key2'], ['key1', 'key2']) ->andReturn(1); $store = $this->createStore($connection); @@ -329,7 +329,7 @@ public function testClearFlushesTaggedItems(): void return ['key1' => 0, 'key2' => 0]; }); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_all:tag:people:entries', 'prefix:key1', 'prefix:key2'], [1, 'key1', 'key2']) + ->with(m::type('string'), ['prefix:_all:tag:people:entries', 'prefix:key1', 'prefix:key2'], ['key1', 'key2']) ->andReturn(1); $store = $this->createStore($connection); diff --git a/tests/Cache/Redis/AnyTagSetTest.php b/tests/Cache/Redis/AnyTagSetTest.php index 15966e15e2..9898f4b451 100644 --- a/tests/Cache/Redis/AnyTagSetTest.php +++ b/tests/Cache/Redis/AnyTagSetTest.php @@ -149,7 +149,7 @@ public function testFlushDeletesKeysAndScannedMemberships(): void ->andReturn(['key1', 'key2']); $this->connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key2:_any:tags', 'prefix:key1', 'prefix:key2'], [1, 2, 'key1', 'key2']) + ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key2:_any:tags', 'prefix:key1', 'prefix:key2'], [2, 'key1', 'key2']) ->andReturn(1); $this->connection->expects('evalWithShaCache') ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries'], ['users']) @@ -173,7 +173,7 @@ public function testFlushTagDeletesSingleTag(): void ->andReturn(['key1']); $this->connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key1'], [1, 1, 'key1']) + ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key1'], [1, 'key1']) ->andReturn(1); $this->connection->expects('evalWithShaCache') ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries'], ['users']) @@ -208,7 +208,10 @@ public function testResetCallsFlush(): void ->andReturn(['key2']); $this->connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:_any:tag:posts:entries', 'prefix:key1:_any:tags', 'prefix:key2:_any:tags', 'prefix:key1', 'prefix:key2'], [2, 2, 'key1', 'key2']) + ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key1'], [1, 'key1']) + ->andReturn(1); + $this->connection->expects('evalWithShaCache') + ->with(m::type('string'), ['prefix:_any:tag:posts:entries', 'prefix:key2:_any:tags', 'prefix:key2'], [1, 'key2']) ->andReturn(1); $this->connection->expects('evalWithShaCache') ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries', 'prefix:_any:tag:posts:entries'], ['users', 'posts']) diff --git a/tests/Cache/Redis/AnyTaggedCacheTest.php b/tests/Cache/Redis/AnyTaggedCacheTest.php index d594ac0674..4325be7e62 100644 --- a/tests/Cache/Redis/AnyTaggedCacheTest.php +++ b/tests/Cache/Redis/AnyTaggedCacheTest.php @@ -508,7 +508,7 @@ public function testFlushDeletesAllTaggedItems(): void ->andReturn(['key1', 'key2']); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key2:_any:tags', 'prefix:key1', 'prefix:key2'], [1, 2, 'key1', 'key2']) + ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key2:_any:tags', 'prefix:key1', 'prefix:key2'], [2, 'key1', 'key2']) ->andReturn(1); $connection->expects('evalWithShaCache') ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries'], ['users']) @@ -530,7 +530,7 @@ public function testClearFlushesTaggedItems(): void ->once() ->andReturn(['key1', 'key2']); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key2:_any:tags', 'prefix:key1', 'prefix:key2'], [1, 2, 'key1', 'key2']) + ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key2:_any:tags', 'prefix:key1', 'prefix:key2'], [2, 'key1', 'key2']) ->andReturn(1); $connection->expects('evalWithShaCache') ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries'], ['users']) diff --git a/tests/Cache/Redis/Operations/AllTag/FlushTest.php b/tests/Cache/Redis/Operations/AllTag/FlushTest.php index 06fa1b5e54..c4bfa812d6 100644 --- a/tests/Cache/Redis/Operations/AllTag/FlushTest.php +++ b/tests/Cache/Redis/Operations/AllTag/FlushTest.php @@ -30,7 +30,7 @@ public function testFlushDeletesCacheEntriesAndScannedMemberships(): void ->andReturn(new LazyCollection(['key1', 'key2'])); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_all:tag:users:entries', 'prefix:key1', 'prefix:key2'], [1, 'key1', 'key2']) + ->with(m::type('string'), ['prefix:_all:tag:users:entries', 'prefix:key1', 'prefix:key2'], ['key1', 'key2']) ->andReturn(1); $store = $this->createStore($connection); @@ -39,29 +39,37 @@ public function testFlushDeletesCacheEntriesAndScannedMemberships(): void $operation->execute(['_all:tag:users:entries']); } - public function testFlushWithMultipleTagsDeletesAllScannedEntries(): void + public function testFlushWithMultipleTagsOnlyRemovesEntriesFromTheirOwnTag(): void { $connection = $this->mockConnection(); - // Mock GetEntries to return cache keys from multiple tags $getEntries = m::mock(GetEntries::class); $getEntries->shouldReceive('execute') ->once() - ->with(['_all:tag:users:entries', '_all:tag:posts:entries']) - ->andReturn(new LazyCollection(['user_key1', 'user_key2', 'post_key1'])); + ->with(['_all:tag:people:entries']) + ->andReturn(new LazyCollection(['person-1', 'person-2'])); + $getEntries->shouldReceive('execute') + ->once() + ->with(['_all:tag:artist:entries']) + ->andReturn(new LazyCollection(['person-2', 'person-3'])); + $getEntries->shouldReceive('execute') + ->once() + ->with(['_all:tag:empty:entries']) + ->andReturn(new LazyCollection([])); $connection->expects('evalWithShaCache') - ->with( - m::type('string'), - ['prefix:_all:tag:users:entries', 'prefix:_all:tag:posts:entries', 'prefix:user_key1', 'prefix:user_key2', 'prefix:post_key1'], - [2, 'user_key1', 'user_key2', 'post_key1'], - ) - ->andReturn(1); + ->with(m::type('string'), ['prefix:_all:tag:people:entries', 'prefix:person-1', 'prefix:person-2'], ['person-1', 'person-2']) + ->andReturn(1) + ->ordered(); + $connection->expects('evalWithShaCache') + ->with(m::type('string'), ['prefix:_all:tag:artist:entries', 'prefix:person-2', 'prefix:person-3'], ['person-2', 'person-3']) + ->andReturn(1) + ->ordered(); $store = $this->createStore($connection); $operation = new Flush($store->getContext(), $getEntries); - $operation->execute(['_all:tag:users:entries', '_all:tag:posts:entries']); + $operation->execute(['_all:tag:people:entries', '_all:tag:artist:entries', '_all:tag:empty:entries']); } public function testFlushWithNoEntriesDoesNotDeleteTagSets(): void @@ -107,7 +115,7 @@ public function testFlushChunksLargeEntrySets(): void $firstChunkArgs[] = "prefix:key{$i}"; } $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_all:tag:users:entries', ...$firstChunkArgs], [1, ...array_slice($entries, 0, 1000)]) + ->with(m::type('string'), ['prefix:_all:tag:users:entries', ...$firstChunkArgs], array_slice($entries, 0, 1000)) ->andReturn(1); // Second chunk: 500 entries. @@ -116,7 +124,7 @@ public function testFlushChunksLargeEntrySets(): void $secondChunkArgs[] = "prefix:key{$i}"; } $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_all:tag:users:entries', ...$secondChunkArgs], [1, ...array_slice($entries, 1000)]) + ->with(m::type('string'), ['prefix:_all:tag:users:entries', ...$secondChunkArgs], array_slice($entries, 1000)) ->andReturn(1); $store = $this->createStore($connection); @@ -149,10 +157,10 @@ public function testFlushReleasesScanConnectionsBeforeCheckingOutEachDeletionChu } $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_all:tag:users:entries', ...$firstChunk], [1, ...array_slice(array_keys($entries), 0, 1000)]) + ->with(m::type('string'), ['prefix:_all:tag:users:entries', ...$firstChunk], array_slice(array_keys($entries), 0, 1000)) ->andReturn(1); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_all:tag:users:entries', 'prefix:key1001'], [1, 'key1001']) + ->with(m::type('string'), ['prefix:_all:tag:users:entries', 'prefix:key1001'], ['key1001']) ->andReturn(1); $active = false; @@ -197,7 +205,7 @@ public function testFlushUsesCorrectPrefix(): void ->andReturn(new LazyCollection(['mykey'])); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['custom_prefix:_all:tag:users:entries', 'custom_prefix:mykey'], [1, 'mykey']) + ->with(m::type('string'), ['custom_prefix:_all:tag:users:entries', 'custom_prefix:mykey'], ['mykey']) ->andReturn(1); $store = $this->createStore($connection, 'custom_prefix:'); @@ -210,15 +218,11 @@ public function testFlushWithEmptyTagIds(): void { $connection = $this->mockConnection(); - // Mock GetEntries - will be called with empty array $getEntries = m::mock(GetEntries::class); - $getEntries->shouldReceive('execute') - ->once() - ->with([]) - ->andReturn(new LazyCollection([])); + $getEntries->shouldNotReceive('execute'); - // No del calls should be made for entries or tags $connection->shouldNotReceive('del'); + $connection->shouldNotReceive('evalWithShaCache'); $store = $this->createStore($connection); $operation = new Flush($store->getContext(), $getEntries); @@ -237,7 +241,7 @@ public function testFlushTagKeyFormat(): void ->andReturn(new LazyCollection(['key'])); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_all:tag:my-special-tag:entries', 'prefix:key'], [1, 'key']) + ->with(m::type('string'), ['prefix:_all:tag:my-special-tag:entries', 'prefix:key'], ['key']) ->andReturn(1); $store = $this->createStore($connection); @@ -327,28 +331,32 @@ public function testFlushInClusterModeWithMultipleTags(): void { [$store, , $connection] = $this->createClusterStore(); - // Mock GetEntries to return cache keys from multiple tags $getEntries = m::mock(GetEntries::class); $getEntries->shouldReceive('execute') ->once() - ->with(['_all:tag:users:entries', '_all:tag:posts:entries']) - ->andReturn(new LazyCollection(['user_key1', 'user_key2', 'post_key1'])); + ->with(['_all:tag:users:entries']) + ->andReturn(new LazyCollection(['user_key1', 'shared_key'])); + $getEntries->shouldReceive('execute') + ->once() + ->with(['_all:tag:posts:entries']) + ->andReturn(new LazyCollection(['shared_key', 'post_key1'])); // Cluster mode should NOT use pipeline $connection->shouldNotReceive('pipeline'); $connection->expects('zrem') - ->with('prefix:_all:tag:users:entries', 'user_key1', 'user_key2', 'post_key1') + ->with('prefix:_all:tag:users:entries', 'user_key1', 'shared_key') + ->andReturn(2)->ordered(); + $connection->expects('del') + ->with('prefix:user_key1', 'prefix:shared_key') ->andReturn(2)->ordered(); $connection->expects('zrem') - ->with('prefix:_all:tag:posts:entries', 'user_key1', 'user_key2', 'post_key1') + ->with('prefix:_all:tag:posts:entries', 'shared_key', 'post_key1') + ->andReturn(2)->ordered(); + $connection->expects('del') + ->with('prefix:shared_key', 'prefix:post_key1') ->andReturn(1)->ordered(); - $connection->shouldReceive('del') - ->once() - ->with('prefix:user_key1', 'prefix:user_key2', 'prefix:post_key1') - ->andReturn(3)->ordered(); - $operation = new Flush($store->getContext(), $getEntries); $operation->execute(['_all:tag:users:entries', '_all:tag:posts:entries']); } diff --git a/tests/Cache/Redis/Operations/AnyTag/FlushTest.php b/tests/Cache/Redis/Operations/AnyTag/FlushTest.php index 426389b26d..b426997569 100644 --- a/tests/Cache/Redis/Operations/AnyTag/FlushTest.php +++ b/tests/Cache/Redis/Operations/AnyTag/FlushTest.php @@ -32,7 +32,7 @@ public function testFlushDeletesCacheEntriesReverseIndexesAndScannedFields(): vo ->with( m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key2:_any:tags', 'prefix:key1', 'prefix:key2'], - [1, 2, 'key1', 'key2'], + [2, 'key1', 'key2'], )->andReturn(1)->ordered(); $connection->expects('evalWithShaCache') ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries'], ['users']) @@ -46,26 +46,31 @@ public function testFlushDeletesCacheEntriesReverseIndexesAndScannedFields(): vo $this->assertTrue($result); } - public function testFlushWithMultipleTags(): void + public function testFlushWithMultipleTagsOnlyRemovesKeysFromTheirOwnTag(): void { $connection = $this->mockConnection(); - // Mock GetTaggedKeys to return keys from multiple tags $getTaggedKeys = m::mock(GetTaggedKeys::class); $getTaggedKeys->shouldReceive('execute') ->once() ->with('users') - ->andReturn($this->arrayToGenerator(['user_key1'])); + ->andReturn($this->arrayToGenerator(['shared_key', 'user_only'])); $getTaggedKeys->shouldReceive('execute') ->once() ->with('posts') - ->andReturn($this->arrayToGenerator(['post_key1'])); + ->andReturn($this->arrayToGenerator(['shared_key', 'post_only'])); $connection->expects('evalWithShaCache') ->with( m::type('string'), - ['prefix:_any:tag:users:entries', 'prefix:_any:tag:posts:entries', 'prefix:user_key1:_any:tags', 'prefix:post_key1:_any:tags', 'prefix:user_key1', 'prefix:post_key1'], - [2, 2, 'user_key1', 'post_key1'], + ['prefix:_any:tag:users:entries', 'prefix:shared_key:_any:tags', 'prefix:user_only:_any:tags', 'prefix:shared_key', 'prefix:user_only'], + [2, 'shared_key', 'user_only'], + )->andReturn(1)->ordered(); + $connection->expects('evalWithShaCache') + ->with( + m::type('string'), + ['prefix:_any:tag:posts:entries', 'prefix:shared_key:_any:tags', 'prefix:post_only:_any:tags', 'prefix:shared_key', 'prefix:post_only'], + [2, 'shared_key', 'post_only'], )->andReturn(1)->ordered(); $connection->expects('evalWithShaCache') ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries', 'prefix:_any:tag:posts:entries'], ['users', 'posts']) @@ -115,39 +120,6 @@ public function testFlushWithNoTagsDoesNotSendCommands(): void $this->assertTrue($operation->execute([])); } - public function testFlushDeduplicatesKeysAcrossTags(): void - { - $connection = $this->mockConnection(); - - // Mock GetTaggedKeys - both tags have 'shared_key' - $getTaggedKeys = m::mock(GetTaggedKeys::class); - $getTaggedKeys->shouldReceive('execute') - ->once() - ->with('users') - ->andReturn($this->arrayToGenerator(['shared_key', 'user_only'])); - $getTaggedKeys->shouldReceive('execute') - ->once() - ->with('posts') - ->andReturn($this->arrayToGenerator(['shared_key', 'post_only'])); - - $connection->expects('evalWithShaCache') - ->with( - m::type('string'), - ['prefix:_any:tag:users:entries', 'prefix:_any:tag:posts:entries', 'prefix:shared_key:_any:tags', 'prefix:user_only:_any:tags', 'prefix:post_only:_any:tags', 'prefix:shared_key', 'prefix:user_only', 'prefix:post_only'], - [2, 3, 'shared_key', 'user_only', 'post_only'], - )->andReturn(1)->ordered(); - $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries', 'prefix:_any:tag:posts:entries'], ['users', 'posts']) - ->andReturn([2, 2])->ordered(); - - $store = $this->createStore($connection); - $store->setTagMode('any'); - $operation = new Flush($store->getContext(), $getTaggedKeys, new RemoveEmptyTags($store->getContext())); - - $result = $operation->execute(['users', 'posts']); - $this->assertTrue($result); - } - public function testFlushUsesCorrectPrefix(): void { $connection = $this->mockConnection(); @@ -159,7 +131,7 @@ public function testFlushUsesCorrectPrefix(): void ->andReturn($this->arrayToGenerator(['mykey'])); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['custom_prefix:_any:tag:users:entries', 'custom_prefix:mykey:_any:tags', 'custom_prefix:mykey'], [1, 1, 'mykey']) + ->with(m::type('string'), ['custom_prefix:_any:tag:users:entries', 'custom_prefix:mykey:_any:tags', 'custom_prefix:mykey'], [1, 'mykey']) ->andReturn(1)->ordered(); $connection->expects('evalWithShaCache') ->with(m::type('string'), ['custom_prefix:_any:tag:registry', 'custom_prefix:_any:tag:users:entries'], ['users']) @@ -224,15 +196,15 @@ public function testFlushClusterModeWithMultipleTags(): void $getTaggedKeys->shouldReceive('execute') ->once() ->with('users') - ->andReturn($this->arrayToGenerator(['user_key'])); + ->andReturn($this->arrayToGenerator(['user_key', 'shared_key'])); $getTaggedKeys->shouldReceive('execute') ->once() ->with('posts') - ->andReturn($this->arrayToGenerator(['post_key'])); + ->andReturn($this->arrayToGenerator(['shared_key', 'post_key'])); - // Sequential commands for chunks - $connection->expects('hdel')->with('prefix:_any:tag:users:entries', 'user_key', 'post_key')->andReturn(1); - $connection->expects('hdel')->with('prefix:_any:tag:posts:entries', 'user_key', 'post_key')->andReturn(1); + // Each tag's chunk only removes fields from that tag's hash. + $connection->expects('hdel')->with('prefix:_any:tag:users:entries', 'user_key', 'shared_key')->andReturn(2); + $connection->expects('hdel')->with('prefix:_any:tag:posts:entries', 'shared_key', 'post_key')->andReturn(2); $connection->expects('hlen')->with('prefix:_any:tag:users:entries')->twice()->andReturn(0); $connection->expects('hlen')->with('prefix:_any:tag:posts:entries')->twice()->andReturn(0); $connection->shouldReceive('del')->andReturn(1); @@ -257,7 +229,7 @@ public function testFlushViaRedisStoreMethod(): void ->andReturn(['mykey']); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:mykey:_any:tags', 'prefix:mykey'], [1, 1, 'mykey']) + ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:mykey:_any:tags', 'prefix:mykey'], [1, 'mykey']) ->andReturn(1); $connection->expects('evalWithShaCache') ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries'], ['users']) diff --git a/tests/Integration/Cache/Redis/ConcurrencyIntegrationTest.php b/tests/Integration/Cache/Redis/ConcurrencyIntegrationTest.php index e1000fde51..03bb54cde7 100644 --- a/tests/Integration/Cache/Redis/ConcurrencyIntegrationTest.php +++ b/tests/Integration/Cache/Redis/ConcurrencyIntegrationTest.php @@ -281,6 +281,41 @@ public function testInterruptedAnyModeFlushKeepsRemainingMembersDiscoverableByPr $this->assertFalse($this->anyModeRegistryHasTag('interrupted')); } + public function testAnyModeFlushingTagsWhileASharedKeyIsRewrittenLeavesNoLiveValueUnindexed(): void + { + $this->setTagMode(TagMode::Any); + $cache = Cache::tags(['people', 'artist']); + $cache->forever('person', 'Sally'); + + $context = $this->store()->getContext(); + $scanner = new GetTaggedKeys($context); + $reader = m::mock(GetTaggedKeys::class); + $reader->expects('execute')->with('people')->andReturnUsing(fn (): Generator => $scanner->execute('people')); + $reader->expects('execute')->with('artist')->andReturnUsing(function () use ($cache, $scanner): Generator { + // Rewrite the shared key after the first tag's pass, before the second tag is scanned. + $cache->forever('person', 'John'); + + yield from $scanner->execute('artist'); + }); + + (new AnyTagFlush($context, $reader, new RemoveEmptyTags($context)))->execute(['people', 'artist']); + + // The second tag's pass deletes the rewritten value with its reverse index. Only the + // rewrite's membership in the already-flushed first tag remains until it is flushed again. + $this->assertNull(Cache::get('person')); + $this->assertSame([], $this->getAnyModeReverseIndex('person')); + $this->assertTrue($this->anyModeTagHasEntry('people', 'person')); + + Cache::tags(['people'])->flush(); + Cache::tags(['artist'])->flush(); + + $this->assertNull(Cache::get('person')); + $this->assertSame([], $this->getAnyModeTagEntries('people')); + $this->assertSame([], $this->getAnyModeTagEntries('artist')); + $this->assertFalse($this->anyModeRegistryHasTag('people')); + $this->assertFalse($this->anyModeRegistryHasTag('artist')); + } + // ========================================================================= // OPERATIONS ON DIFFERENT TAGS - BOTH MODES // ========================================================================= diff --git a/tests/Integration/Cache/Redis/RedisStoreTest.php b/tests/Integration/Cache/Redis/RedisStoreTest.php index 5e1f27c684..b11a2eb42a 100644 --- a/tests/Integration/Cache/Redis/RedisStoreTest.php +++ b/tests/Integration/Cache/Redis/RedisStoreTest.php @@ -6,13 +6,17 @@ use BadMethodCallException; use DateTime; +use Hypervel\Cache\Redis\Operations\AllTag\Flush; +use Hypervel\Cache\Redis\Operations\AllTag\GetEntries; use Hypervel\Cache\RedisStore; use Hypervel\Cache\Repository; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Support\Facades\Cache; +use Hypervel\Support\LazyCollection; use Hypervel\Support\Sleep; use Hypervel\Support\Str; use Hypervel\Testbench\TestCase; +use Mockery as m; use PHPUnit\Framework\Attributes\TestWith; use Redis; use RuntimeException; @@ -321,6 +325,77 @@ public function testTagsCanBeFlushedWithLargeNumberOfKeys(): void $this->assertCount(0, $keyCount); } + public function testFlushingManyTagsOnlyPassesEachEntrysOwnTagToTheScript(): void + { + // Hypervel runs flush scripts on the raw connection, which dispatches no command events, + // so the per-script arguments are asserted in Cache\Redis\Operations\AllTag\FlushTest. + $store = Cache::store('redis'); + $connection = $store->connection(); + + $store->clear(); + + $prefix = $store->getPrefix(); + + $store->tags(['people'])->put('person-1', 'Sally', 300); + $store->tags(['people', 'artist'])->put('person-2', 'John', 300); + $store->tags(['artist'])->forever('person-3', 'Taylor'); + $store->tags(['other'])->put('person-4', 'Abigail', 300); + + $store->tags(['people', 'artist', 'empty-1', 'empty-2'])->flush(); + + $this->assertNull($store->tags(['people'])->get('person-1')); + $this->assertNull($store->tags(['people', 'artist'])->get('person-2')); + $this->assertNull($store->tags(['artist'])->get('person-3')); + $this->assertSame('Abigail', $store->tags(['other'])->get('person-4')); + $this->assertSame(0, $connection->zcard($prefix . '_all:tag:people:entries')); + $this->assertSame(0, $connection->zcard($prefix . '_all:tag:artist:entries')); + $this->assertSame(1, $connection->zcard($prefix . '_all:tag:other:entries')); + } + + public function testFlushingTagsWhileASharedKeyIsRewrittenLeavesNoLiveValueUnindexed(): void + { + $store = Cache::store('redis'); + $connection = $store->connection(); + + $store->clear(); + + $prefix = $store->getPrefix(); + + $store->tags(['people', 'artist'])->forever('person', 'Sally'); + + // Hypervel's flush scripts dispatch no command events, so the rewrite happens when the + // second tag's entries are requested, after the first tag's script has run. + $context = $store->getStore()->getContext(); + $scanner = new GetEntries($context); + $requests = 0; + $entries = m::mock(GetEntries::class); + $entries->shouldReceive('execute') + ->twice() + ->andReturnUsing(function (array $tagIds) use ($scanner, $store, &$requests): LazyCollection { + if (++$requests === 2) { + $store->tags(['people', 'artist'])->forever('person', 'John'); + } + + return $scanner->execute($tagIds); + }); + + (new Flush($context, $entries))->execute($store->tags(['people', 'artist'])->getTags()->tagIds()); + + $itemKey = $store->tags(['people', 'artist'])->taggedItemKey('person'); + + if ($store->tags(['people', 'artist'])->get('person') !== null) { + $this->assertContains($itemKey, $connection->zrange($prefix . '_all:tag:people:entries', 0, -1)); + $this->assertContains($itemKey, $connection->zrange($prefix . '_all:tag:artist:entries', 0, -1)); + } + + $store->tags(['people'])->flush(); + $store->tags(['artist'])->flush(); + + $this->assertNull($store->tags(['people', 'artist'])->get('person')); + $this->assertSame(0, $connection->zcard($prefix . '_all:tag:people:entries')); + $this->assertSame(0, $connection->zcard($prefix . '_all:tag:artist:entries')); + } + public function testLocksCanBeFlushed(): void { $store = $this->getStoreWithSeparateLockStorage(); From 4a31de9a63a315505bdab5a0b95a0bc495170071 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:18:22 +0000 Subject: [PATCH 22/31] Name the query-count test cases after their method FoundationInteractsWithDatabaseTest::testExpectsDatabaseQueryCount, ported from Laravel, builds four anonymous test cases with the placeholder name "foo" and calls setUp() directly. Since the reflection guard in TestCase::withoutBootingFramework() was removed, setUp() reflects on the named method, so the placeholder threw a ReflectionException. The cases now use the name of the method they define; every query-count assertion is unchanged. The guard stays removed: PHPUnit always names a test case after an existing method. The test file and the Foundation suite pass. --- tests/Foundation/FoundationInteractsWithDatabaseTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Foundation/FoundationInteractsWithDatabaseTest.php b/tests/Foundation/FoundationInteractsWithDatabaseTest.php index 782b2f5c0d..0c1c7eddb6 100644 --- a/tests/Foundation/FoundationInteractsWithDatabaseTest.php +++ b/tests/Foundation/FoundationInteractsWithDatabaseTest.php @@ -503,7 +503,7 @@ public function testGetTableCustomizedDeletedAtColumnName(): void public function testExpectsDatabaseQueryCount(): void { - $case = new class('foo') extends TestingTestCase { + $case = new class('testExpectsDatabaseQueryCount') extends TestingTestCase { use CreatesApplication; public function testExpectsDatabaseQueryCount(): void @@ -516,7 +516,7 @@ public function testExpectsDatabaseQueryCount(): void $case->testExpectsDatabaseQueryCount(); $case->tearDown(); - $case = new class('foo') extends TestingTestCase { + $case = new class('testExpectsDatabaseQueryCount') extends TestingTestCase { use CreatesApplication; public function testExpectsDatabaseQueryCount(): void @@ -535,7 +535,7 @@ public function testExpectsDatabaseQueryCount(): void $this->assertSame("Expected 3 database queries on the [testing] connection. 0 occurred.\nFailed asserting that 0 is identical to 3.", $e->getMessage()); } - $case = new class('foo') extends TestingTestCase { + $case = new class('testExpectsDatabaseQueryCount') extends TestingTestCase { use CreatesApplication; public function testExpectsDatabaseQueryCount(): void @@ -561,7 +561,7 @@ public function testExpectsDatabaseQueryCount(): void $this->assertSame("Expected 3 database queries on the [testing] connection. 4 occurred.\nFailed asserting that 4 is identical to 3.", $e->getMessage()); } - $case = new class('foo') extends TestingTestCase { + $case = new class('testExpectsDatabaseQueryCount') extends TestingTestCase { use CreatesApplication; public function testExpectsDatabaseQueryCount(): void From 4d7982dadd273d298cfe8105ae1e849787bd5137 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:18:26 +0000 Subject: [PATCH 23/31] Create the SQLite test database file only for SQLite runs SqliteTestCase::setUp() creates the configured DB_DATABASE file before the class-level RequiresDatabase('sqlite') skip runs, because that skip needs the booted application. MySQL, MariaDB and PostgreSQL runs therefore left an empty file named after the server database in the working directory. The method now returns early unless DB_CONNECTION (default sqlite) is sqlite. The PostgreSQL, MySQL, MariaDB and SQLite database suites pass without leaving files behind. --- tests/Integration/Database/Sqlite/SqliteTestCase.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Integration/Database/Sqlite/SqliteTestCase.php b/tests/Integration/Database/Sqlite/SqliteTestCase.php index 7c18ad9704..d06d8d3fb5 100644 --- a/tests/Integration/Database/Sqlite/SqliteTestCase.php +++ b/tests/Integration/Database/Sqlite/SqliteTestCase.php @@ -49,6 +49,12 @@ protected function getConfiguredDatabasePath(): string */ protected function ensureSqliteDatabaseFileExists(): void { + // This runs before the class-level RequiresDatabase skip, so another driver's + // database name must not be created as a file in the working directory. + if (env('DB_CONNECTION', 'sqlite') !== 'sqlite') { + return; + } + $path = $this->getConfiguredDatabasePath(); // URI names pass to SQLite unchanged and are not literal filesystem paths. From 5b65f6d856b5c8fdc0ca403bad14c555601681d2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:18:40 +0000 Subject: [PATCH 24/31] Qualify PostgreSQL index drops and renames with the table schema Port laravel/framework#62213 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. PostgreSQL index names are schema-scoped, but dropping or renaming an index on a schema-qualified table used the bare index name, so it resolved through the search path and could act on a same-named index in another schema. PostgresGrammar::wrapIndex() now qualifies the index with the table's schema unless the name is already qualified. The fulltext, spatial and vector drops delegate to compileDropIndex(). Related corrections found during review: - A schema named "0" was treated as absent by truthiness checks, so the code acted on the current schema instead. Explicit null checks now cover wrapIndex(), the PostgreSQL and MySQL metadata queries, the scalar schema filters for PostgreSQL, MySQL and SQLite, SQLite index creation, drops and truncation, DatabaseTruncation's exclusion matching and db:show's schema labels. - PostgresBuilder::getCurrentSchemaListing() now follows the connector's search_path ?? schema ?? 'public' precedence. Previously a "0" search path listed the fallback schema, and an empty search path listed public, while the connection used a different path. - An explicitly empty search path selects no schemas, but getTables([]) selects every schema, so the table, view and type drops and DatabaseTruncation now do nothing for an empty listing. getTables([]) itself is unchanged. The truncation test helper now defaults to the schema-less null listing. - PostgresBuilder::hasView() returns false when there is no current schema, instead of searching every schema for an unqualified view. Upstream's grammar and integration tests are ported, and the corrections have focused regression cases. Static analysis, the Database and Foundation suites and the database suites for all four drivers pass. --- src/database/src/Console/ShowCommand.php | 6 +- .../src/Query/Grammars/SQLiteGrammar.php | 2 +- .../src/Schema/Grammars/MySqlGrammar.php | 10 ++-- .../src/Schema/Grammars/PostgresGrammar.php | 28 ++++++--- .../src/Schema/Grammars/SQLiteGrammar.php | 8 +-- src/database/src/Schema/PostgresBuilder.php | 59 +++++++++++++++++-- .../src/Testing/DatabaseTruncation.php | 6 +- tests/Database/DatabaseConsoleOutputTest.php | 24 ++++++++ .../DatabaseMySqlSchemaGrammarTest.php | 11 ++++ .../Database/DatabasePostgresBuilderTest.php | 53 ++++++++++++++++- .../DatabasePostgresSchemaGrammarTest.php | 50 +++++++++++++++- .../DatabaseSQLiteSchemaGrammarTest.php | 20 +++++++ .../Testing/DatabaseTruncationTest.php | 49 +++++++++++++-- .../Postgres/PostgresSchemaBuilderTest.php | 34 +++++++++++ 14 files changed, 323 insertions(+), 37 deletions(-) diff --git a/src/database/src/Console/ShowCommand.php b/src/database/src/Console/ShowCommand.php index d666d8cfff..ff41217f09 100644 --- a/src/database/src/Console/ShowCommand.php +++ b/src/database/src/Console/ShowCommand.php @@ -167,7 +167,7 @@ protected function displayForCli(array $data): void $tableSize = is_null($table['size']) ? null : Number::fileSize($table['size'], 2); $this->components->twoColumnDetail( - ($table['schema'] ? $table['schema'] . ' / ' : '') . $table['table'] . ($this->output->isVerbose() ? ' ' . $table['engine'] . '' : null), + ($table['schema'] !== null ? $table['schema'] . ' / ' : '') . $table['table'] . ($this->output->isVerbose() ? ' ' . $table['engine'] . '' : null), ($tableSize ?? '—') . ($this->option('counts') ? ' / ' . Number::format($table['rows']) . '' : '') ); @@ -192,7 +192,7 @@ protected function displayForCli(array $data): void ); $views->each(fn ($view) => $this->components->twoColumnDetail( - ($view['schema'] ? $view['schema'] . ' / ' : '') . $view['view'], + ($view['schema'] !== null ? $view['schema'] . ' / ' : '') . $view['view'], Number::format($view['rows']) )); @@ -208,7 +208,7 @@ protected function displayForCli(array $data): void ); $types->each(fn ($type) => $this->components->twoColumnDetail( - ($type['schema'] ? $type['schema'] . ' / ' : '') . $type['name'], + ($type['schema'] !== null ? $type['schema'] . ' / ' : '') . $type['name'], $type['type'] . ' / ' . $type['category'] )); diff --git a/src/database/src/Query/Grammars/SQLiteGrammar.php b/src/database/src/Query/Grammars/SQLiteGrammar.php index 14de09b82f..3f5341f4f5 100755 --- a/src/database/src/Query/Grammars/SQLiteGrammar.php +++ b/src/database/src/Query/Grammars/SQLiteGrammar.php @@ -398,7 +398,7 @@ public function compileTruncate(Builder $query): array { [$schema, $table] = $query->getConnection()->getSchemaBuilder()->parseSchemaAndTable($query->from); - $schema = $schema ? $this->wrapValue($schema) . '.' : ''; + $schema = $schema !== null ? $this->wrapValue($schema) . '.' : ''; return [ 'delete from ' . $schema . 'sqlite_sequence where name = ?' => [$query->getConnection()->getTablePrefix() . $table], diff --git a/src/database/src/Schema/Grammars/MySqlGrammar.php b/src/database/src/Schema/Grammars/MySqlGrammar.php index 50cc3bfaba..669e508d80 100755 --- a/src/database/src/Schema/Grammars/MySqlGrammar.php +++ b/src/database/src/Schema/Grammars/MySqlGrammar.php @@ -79,7 +79,7 @@ public function compileTableExists(?string $schema, string $table): string return sprintf( 'select exists (select 1 from information_schema.tables where ' . "table_schema = %s and table_name = %s and table_type in ('BASE TABLE', 'SYSTEM VERSIONED')) as `exists`", - $schema ? $this->quoteString($schema) : 'schema()', + $schema !== null ? $this->quoteString($schema) : 'schema()', $this->quoteString($table) ); } @@ -114,7 +114,7 @@ protected function compileSchemaWhereClause(string|array|null $schema, string $c { return $column . (match (true) { ! empty($schema) && is_array($schema) => ' in (' . $this->quoteString($schema) . ')', - ! empty($schema) => ' = ' . $this->quoteString($schema), + is_string($schema) => ' = ' . $this->quoteString($schema), default => " not in ('information_schema', 'mysql', 'ndbinfo', 'performance_schema', 'sys')", }); } @@ -131,7 +131,7 @@ public function compileColumns(?string $schema, string $table): string . 'generation_expression as `expression`, extra as `extra` ' . 'from information_schema.columns where table_schema = %s and table_name = %s ' . 'order by ordinal_position asc', - $schema ? $this->quoteString($schema) : 'schema()', + $schema !== null ? $this->quoteString($schema) : 'schema()', $this->quoteString($table) ); } @@ -146,7 +146,7 @@ public function compileIndexes(?string $schema, string $table): string . 'index_type as `type`, not non_unique as `unique` ' . 'from information_schema.statistics where table_schema = %s and table_name = %s ' . 'group by index_name, index_type, non_unique', - $schema ? $this->quoteString($schema) : 'schema()', + $schema !== null ? $this->quoteString($schema) : 'schema()', $this->quoteString($table) ); } @@ -156,7 +156,7 @@ public function compileIndexes(?string $schema, string $table): string */ public function compileForeignKeys(?string $schema, string $table): string { - $schema = $schema ? $this->quoteString($schema) : 'schema()'; + $schema = $schema !== null ? $this->quoteString($schema) : 'schema()'; $table = $this->quoteString($table); return sprintf( diff --git a/src/database/src/Schema/Grammars/PostgresGrammar.php b/src/database/src/Schema/Grammars/PostgresGrammar.php index d164284fc3..d7dde61b59 100755 --- a/src/database/src/Schema/Grammars/PostgresGrammar.php +++ b/src/database/src/Schema/Grammars/PostgresGrammar.php @@ -70,7 +70,7 @@ public function compileTableExists(?string $schema, string $table): ?string return sprintf( 'select exists (select 1 from pg_class c, pg_namespace n where ' . "n.nspname = %s and c.relname = %s and c.relkind in ('r', 'p') and n.oid = c.relnamespace)", - $schema ? $this->quoteString($schema) : 'current_schema()', + $schema !== null ? $this->quoteString($schema) : 'current_schema()', $this->quoteString($table) ); } @@ -125,7 +125,7 @@ protected function compileSchemaWhereClause(string|array|null $schema, string $c { return $column . (match (true) { ! empty($schema) && is_array($schema) => ' in (' . $this->quoteString($schema) . ')', - ! empty($schema) => ' = ' . $this->quoteString($schema), + is_string($schema) => ' = ' . $this->quoteString($schema), default => " <> 'information_schema' and {$column} not like 'pg\\_%'", }); } @@ -150,7 +150,7 @@ public function compileColumns(?string $schema, string $table): string . 'where c.relname = %s and n.nspname = %s and a.attnum > 0 and a.attrelid = c.oid and a.atttypid = t.oid and n.oid = c.relnamespace ' . 'order by a.attnum', $this->quoteString($table), - $schema ? $this->quoteString($schema) : 'current_schema()' + $schema !== null ? $this->quoteString($schema) : 'current_schema()' ); } @@ -173,7 +173,7 @@ public function compileIndexes(?string $schema, string $table): string . 'where tc.relname = %s and tn.nspname = %s ' . 'group by ic.relname, am.amname, i.indisunique, i.indisprimary, i.indpred is not null', $this->quoteString($table), - $schema ? $this->quoteString($schema) : 'current_schema()' + $schema !== null ? $this->quoteString($schema) : 'current_schema()' ); } @@ -199,7 +199,7 @@ public function compileForeignKeys(?string $schema, string $table): string . "where c.contype = 'f' and tc.relname = %s and tn.nspname = %s " . 'group by c.conname, fn.nspname, fc.relname, c.confupdtype, c.confdeltype', $this->quoteString($table), - $schema ? $this->quoteString($schema) : 'current_schema()' + $schema !== null ? $this->quoteString($schema) : 'current_schema()' ); } @@ -218,7 +218,7 @@ public function compilePartitions(?string $schema, string $table): string . 'where c.relispartition and pc.relname = %s and pn.nspname = %s ' . 'order by n.nspname, c.relname', $this->quoteString($table), - $schema ? $this->quoteString($schema) : 'current_schema()' + $schema !== null ? $this->quoteString($schema) : 'current_schema()' ); } @@ -606,7 +606,7 @@ public function compileDropUnique(Blueprint $blueprint, Fluent $command): string */ public function compileDropIndex(Blueprint $blueprint, Fluent $command): string { - return "drop index {$this->wrap($command->index)}"; + return "drop index {$this->wrapIndex($blueprint, $command->index)}"; } /** @@ -660,11 +660,23 @@ public function compileRenameIndex(Blueprint $blueprint, Fluent $command): strin { return sprintf( 'alter index %s rename to %s', - $this->wrap($command->from), + $this->wrapIndex($blueprint, $command->from), $this->wrap($command->to) ); } + /** + * Wrap an index name, qualifying it with the schema of the blueprint's table. + */ + protected function wrapIndex(Blueprint $blueprint, string $index): string + { + [$schema] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($blueprint->getTable()); + + return $schema !== null && ! str_contains($index, '.') + ? $this->wrapValue($schema) . '.' . $this->wrap($index) + : $this->wrap($index); + } + /** * Compile the command to enable foreign key constraints. */ diff --git a/src/database/src/Schema/Grammars/SQLiteGrammar.php b/src/database/src/Schema/Grammars/SQLiteGrammar.php index 60d578929a..28bcc0b040 100644 --- a/src/database/src/Schema/Grammars/SQLiteGrammar.php +++ b/src/database/src/Schema/Grammars/SQLiteGrammar.php @@ -99,7 +99,7 @@ public function compileTables(string|array|null $schema, bool $withSize = false) . ' from pragma_table_list as tl where' . (match (true) { ! empty($schema) && is_array($schema) => ' tl.schema in (' . $this->quoteString($schema) . ') and', - ! empty($schema) => ' tl.schema = ' . $this->quoteString($schema) . ' and', + is_string($schema) => ' tl.schema = ' . $this->quoteString($schema) . ' and', default => '', }) . " tl.type in ('table', 'virtual') and tl.name not like 'sqlite\\_%' escape '\\' " @@ -562,7 +562,7 @@ public function compileUnique(Blueprint $blueprint, Fluent $command): string return sprintf( 'create unique index %s%s on %s (%s)', - $schema ? $this->wrapValue($schema) . '.' : '', + $schema !== null ? $this->wrapValue($schema) . '.' : '', $this->wrap($command->index), $this->wrapTable($table), $this->columnizeIndexedColumns($command) @@ -578,7 +578,7 @@ public function compileIndex(Blueprint $blueprint, Fluent $command): string return sprintf( 'create index %s%s on %s (%s)%s', - $schema ? $this->wrapValue($schema) . '.' : '', + $schema !== null ? $this->wrapValue($schema) . '.' : '', $this->wrap($command->index), $this->wrapTable($table), $this->columnizeIndexedColumns($command), @@ -700,7 +700,7 @@ public function compileDropIndex(Blueprint $blueprint, Fluent $command): string return sprintf( 'drop index %s%s', - $schema ? $this->wrapValue($schema) . '.' : '', + $schema !== null ? $this->wrapValue($schema) . '.' : '', $this->wrap($command->index) ); } diff --git a/src/database/src/Schema/PostgresBuilder.php b/src/database/src/Schema/PostgresBuilder.php index 6f18aae004..00f853ceac 100755 --- a/src/database/src/Schema/PostgresBuilder.php +++ b/src/database/src/Schema/PostgresBuilder.php @@ -54,17 +54,48 @@ protected function hasOnlineCommand(Blueprint $blueprint): bool return false; } + /** + * Determine if the given view exists. + */ + #[Override] + public function hasView(string $view): bool + { + [$schema, $view] = $this->parseSchemaAndTable($view, withDefaultSchema: true); + + // Without a current schema an unqualified view resolves to nothing, but getViews(null) would search every schema. + if ($schema === null) { + return false; + } + + $view = $this->connection->getTablePrefix() . $view; + + foreach ($this->getViews($schema) as $value) { + if (strtolower($view) === strtolower($value['name'])) { + return true; + } + } + + return false; + } + /** * Drop all tables from the database. */ #[Override] public function dropAllTables(): void { + $schemas = $this->getCurrentSchemaListing(); + + // An empty search path selects no schemas, but getTables([]) would select them all. + if ($schemas === []) { + return; + } + $tables = []; $excludedTables = $this->connection->getConfig('dont_drop') ?? ['spatial_ref_sys']; - foreach ($this->withoutCoveredPartitions($this->getTables($this->getCurrentSchemaListing()), $excludedTables) as $table) { + foreach ($this->withoutCoveredPartitions($this->getTables($schemas), $excludedTables) as $table) { if (empty(array_intersect([$table['name'], $table['schema_qualified_name']], $excludedTables))) { $tables[] = $table['schema_qualified_name']; } @@ -85,7 +116,14 @@ public function dropAllTables(): void #[Override] public function dropAllViews(): void { - $views = array_column($this->getViews($this->getCurrentSchemaListing()), 'schema_qualified_name'); + $schemas = $this->getCurrentSchemaListing(); + + // An empty search path selects no schemas, but getViews([]) would select them all. + if ($schemas === []) { + return; + } + + $views = array_column($this->getViews($schemas), 'schema_qualified_name'); if (empty($views)) { return; @@ -102,10 +140,17 @@ public function dropAllViews(): void #[Override] public function dropAllTypes(): void { + $schemas = $this->getCurrentSchemaListing(); + + // An empty search path selects no schemas, but getTypes([]) would select them all. + if ($schemas === []) { + return; + } + $types = []; $domains = []; - foreach ($this->getTypes($this->getCurrentSchemaListing()) as $type) { + foreach ($this->getTypes($schemas) as $type) { if (! $type['implicit']) { if ($type['type'] === 'domain') { $domains[] = $type['schema_qualified_name']; @@ -131,6 +176,8 @@ public function dropAllTypes(): void /** * Get the current schemas for the connection. * + * An explicitly empty search path returns an empty list. + * * @return string[] */ #[Override] @@ -138,10 +185,12 @@ public function getCurrentSchemaListing(): array { return array_map( fn ($schema) => $schema === '$user' ? $this->connection->getConfig('username') : $schema, + // Match the connector's setting precedence; a truthiness check would ignore a schema + // named "0" and replace an explicitly empty search path. $this->parseSearchPath( $this->connection->getConfig('search_path') - ?: $this->connection->getConfig('schema') - ?: 'public' + ?? $this->connection->getConfig('schema') + ?? 'public' ) ); } diff --git a/src/foundation/src/Testing/DatabaseTruncation.php b/src/foundation/src/Testing/DatabaseTruncation.php index 60e59a2349..a5c5272646 100644 --- a/src/foundation/src/Testing/DatabaseTruncation.php +++ b/src/foundation/src/Testing/DatabaseTruncation.php @@ -266,8 +266,10 @@ protected function getAllTablesForConnection(ConnectionInterface $connection, ?s } $schema = $connection->getSchemaBuilder(); + $schemas = $schema->getCurrentSchemaListing(); - return static::$allTables[$name] = Arr::from($schema->getTables($schema->getCurrentSchemaListing())); + // An empty PostgreSQL search path selects no schemas, but getTables([]) would select them all. + return static::$allTables[$name] = $schemas === [] ? [] : Arr::from($schema->getTables($schemas)); } /** @@ -275,7 +277,7 @@ protected function getAllTablesForConnection(ConnectionInterface $connection, ?s */ protected function tableExistsIn(array $table, array $tables): bool { - return $table['schema'] + return $table['schema'] !== null ? ! empty(array_intersect([$table['name'], $table['schema_qualified_name']], $tables)) : in_array($table['name'], $tables); } diff --git a/tests/Database/DatabaseConsoleOutputTest.php b/tests/Database/DatabaseConsoleOutputTest.php index 7e055bacdf..7c204a82e0 100644 --- a/tests/Database/DatabaseConsoleOutputTest.php +++ b/tests/Database/DatabaseConsoleOutputTest.php @@ -103,6 +103,30 @@ public function testShowCommandPreservesTableCommentsInText(): void $this->assertStringContainsString('Pending a\>b', $output->fetch()); } + public function testShowCommandLabelsObjectsInSchemaNamedZero(): void + { + [$command, $output] = $this->showCommand(); + + $command->renderText([ + 'platform' => ['name' => 'PostgreSQL', 'version' => '17.0', 'connection' => 'pgsql', 'config' => [], 'open_connections' => null], + 'tables' => collect([ + ['schema' => '0', 'table' => 'orders', 'size' => null, 'engine' => null, 'comment' => null, 'rows' => null], + ]), + 'views' => collect([ + ['schema' => '0', 'view' => 'active_orders', 'rows' => 0], + ]), + 'types' => collect([ + ['schema' => '0', 'name' => 'order_status', 'type' => 'enum', 'category' => 'enum'], + ]), + ]); + + $text = $output->fetch(); + + $this->assertStringContainsString('0 / orders', $text); + $this->assertStringContainsString('0 / active_orders', $text); + $this->assertStringContainsString('0 / order_status', $text); + } + /** * Create a table command probe that writes to a buffer. * diff --git a/tests/Database/DatabaseMySqlSchemaGrammarTest.php b/tests/Database/DatabaseMySqlSchemaGrammarTest.php index b263e75665..fc8f053303 100755 --- a/tests/Database/DatabaseMySqlSchemaGrammarTest.php +++ b/tests/Database/DatabaseMySqlSchemaGrammarTest.php @@ -1971,6 +1971,17 @@ public function testCompileForeignKeysConstrainsReferentialConstraints(): void $this->assertStringContainsString("and rc.constraint_schema = 'my_schema' and rc.table_name = 'users' ", $sql); } + public function testCompileMetadataQueriesForSchemaNamedZero(): void + { + $grammar = $this->getGrammar(); + + $this->assertStringContainsString("table_schema = '0' and", $grammar->compileTableExists('0', 'users')); + $this->assertStringEndsWith("table_schema = '0' order by table_schema, table_name", $grammar->compileTables('0')); + $this->assertStringContainsString("table_schema = '0' and", $grammar->compileColumns('0', 'users')); + $this->assertStringContainsString("table_schema = '0' and", $grammar->compileIndexes('0', 'users')); + $this->assertStringContainsString("where kc.table_schema = '0' and", $grammar->compileForeignKeys('0', 'users')); + } + /** * Create the MySQL schema grammar. */ diff --git a/tests/Database/DatabasePostgresBuilderTest.php b/tests/Database/DatabasePostgresBuilderTest.php index 5b38ef3ddf..f4f4d6dd6c 100644 --- a/tests/Database/DatabasePostgresBuilderTest.php +++ b/tests/Database/DatabasePostgresBuilderTest.php @@ -15,6 +15,7 @@ use InvalidArgumentException; use Mockery as m; use Override; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; class DatabasePostgresBuilderTest extends TestCase @@ -352,6 +353,40 @@ public function testCurrentSchemaNameQueriesTheConnectionRatherThanConfiguredSea $this->assertSame('public', $builder->getCurrentSchemaName()); } + #[DataProvider('currentSchemaListingConfigurationProvider')] + public function testCurrentSchemaListingFollowsTheConnectorSettingPrecedence(?string $searchPath, ?string $schema, array $expected): void + { + $connection = $this->getConnection(); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(m::mock(PostgresGrammar::class)); + $connection->shouldReceive('getConfig')->with('search_path')->andReturn($searchPath); + $connection->shouldReceive('getConfig')->with('schema')->andReturn($schema); + + $this->assertSame($expected, $this->getBuilder($connection)->getCurrentSchemaListing()); + } + + public static function currentSchemaListingConfigurationProvider(): array + { + return [ + 'search path named zero' => ['0', 'tenant', ['0']], + 'schema fallback' => [null, 'tenant', ['tenant']], + 'public default' => [null, null, ['public']], + 'empty search path' => ['', 'tenant', []], + ]; + } + + public function testHasViewIsFalseWithoutACurrentSchema(): void + { + $connection = $this->getConnection(); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(m::mock(PostgresGrammar::class)); + $connection->shouldReceive('scalar') + ->once() + ->with('select current_schema()', [], false) + ->andReturnNull(); + $connection->shouldNotReceive('selectFromWriteConnection'); + + $this->assertFalse($this->getBuilder($connection)->hasView('active_users')); + } + public function testHasViewUsesTheActualCurrentSchema(): void { $connection = $this->getConnection(); @@ -363,8 +398,8 @@ public function testHasViewUsesTheActualCurrentSchema(): void $connection->shouldReceive('scalar') ->once() ->with('select current_schema()', [], false) - ->andReturn('public'); - $grammar->shouldReceive('compileViews')->once()->with('public')->andReturn('sql'); + ->andReturn('tenant.example'); + $grammar->shouldReceive('compileViews')->once()->with('tenant.example')->andReturn('sql'); $connection->shouldReceive('selectFromWriteConnection')->once()->with('sql')->andReturn([ ['name' => 'active_users'], ]); @@ -442,6 +477,20 @@ public function testDropAllTablesWhenSearchPathIsArrayOfMany(): void $builder->dropAllTables(); } + public function testDropAllObjectsWithAnEmptySearchPathLeavesEverySchemaUntouched(): void + { + $connection = $this->getConnection(); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(m::mock(PostgresGrammar::class)); + $connection->shouldReceive('getConfig')->times(3)->with('search_path')->andReturn(''); + $connection->shouldNotReceive('selectFromWriteConnection'); + $connection->shouldNotReceive('statement'); + $builder = $this->getBuilder($connection); + + $builder->dropAllTables(); + $builder->dropAllViews(); + $builder->dropAllTypes(); + } + public function testDropAllTablesPropagatesAFalseStatementResult(): void { $connection = $this->getConnection(); diff --git a/tests/Database/DatabasePostgresSchemaGrammarTest.php b/tests/Database/DatabasePostgresSchemaGrammarTest.php index f0b5dbe75e..f5a5fccfa3 100755 --- a/tests/Database/DatabasePostgresSchemaGrammarTest.php +++ b/tests/Database/DatabasePostgresSchemaGrammarTest.php @@ -281,6 +281,25 @@ public function testDropIndex(): void $this->assertSame('drop index "foo"', $statements[0]); } + public function testDropIndexOnSchemaQualifiedTable(): void + { + $blueprint = new Blueprint($this->getConnection(), 'tenant.users'); + $blueprint->dropIndex(['email']); + $blueprint->dropIndex('foo'); + $blueprint->dropIndex('other.bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(3, $statements); + $this->assertSame('drop index "tenant"."tenant_users_email_index"', $statements[0]); + $this->assertSame('drop index "tenant"."foo"', $statements[1]); + $this->assertSame('drop index "other"."bar"', $statements[2]); + + $blueprint = new Blueprint($this->getConnection(), '0.users'); + $blueprint->dropIndex('foo'); + + $this->assertSame(['drop index "0"."foo"'], $blueprint->toSql()); + } + public function testDropSpatialIndex(): void { $blueprint = new Blueprint($this->getConnection(), 'geo'); @@ -372,6 +391,21 @@ public function testRenameIndex(): void $this->assertSame('alter index "foo" rename to "bar"', $statements[0]); } + public function testRenameIndexOnSchemaQualifiedTable(): void + { + $blueprint = new Blueprint($this->getConnection(), 'tenant.users'); + $blueprint->renameIndex('foo', 'bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter index "tenant"."foo" rename to "bar"', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), '0.users'); + $blueprint->renameIndex('foo', 'bar'); + + $this->assertSame(['alter index "0"."foo" rename to "bar"'], $blueprint->toSql()); + } + public function testAddingPrimaryKey(): void { $blueprint = new Blueprint($this->getConnection(), 'users'); @@ -1601,6 +1635,20 @@ public function testCompileColumnsOnLegacyServer(): void $this->assertStringNotContainsString('a.attgenerated', $statement); } + public function testCompileMetadataQueriesForSchemaNamedZero(): void + { + $connection = $this->getConnection(); + $connection->expects('getServerVersion')->andReturn('12.0.0'); + $grammar = $connection->getSchemaGrammar(); + + $this->assertStringContainsString("n.nspname = '0' and", $grammar->compileTableExists('0', 'users')); + $this->assertStringEndsWith("n.nspname = '0' order by n.nspname, c.relname", $grammar->compileTables('0')); + $this->assertStringContainsString("n.nspname = '0' and", $grammar->compileColumns('0', 'users')); + $this->assertStringContainsString("tn.nspname = '0' group", $grammar->compileIndexes('0', 'users')); + $this->assertStringContainsString("tn.nspname = '0' group", $grammar->compileForeignKeys('0', 'users')); + $this->assertStringContainsString("pn.nspname = '0' order", $grammar->compilePartitions('0', 'users')); + } + public function testAddUsingKeywordToColumnOnChange(): void { $blueprint = new Blueprint($this->getConnection(), 'currency_rates'); @@ -1660,7 +1708,7 @@ public function getGrammar(?Connection $connection = null): PostgresGrammar */ public function getBuilder(): PostgresBuilder { - return mock(PostgresBuilder::class); + return m::mock(PostgresBuilder::class)->makePartial(); } public function testGrammarsAreMacroable(): void diff --git a/tests/Database/DatabaseSQLiteSchemaGrammarTest.php b/tests/Database/DatabaseSQLiteSchemaGrammarTest.php index a4428c189b..a961ed6146 100755 --- a/tests/Database/DatabaseSQLiteSchemaGrammarTest.php +++ b/tests/Database/DatabaseSQLiteSchemaGrammarTest.php @@ -118,6 +118,11 @@ public function testDropIndexWithSchema(): void $this->assertCount(1, $statements); $this->assertSame('drop index "my_schema"."foo"', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), '0.users'); + $blueprint->dropIndex('foo'); + + $this->assertSame(['drop index "0"."foo"'], $blueprint->toSql()); } public function testDropForeignWithColumnsAndName(): void @@ -303,6 +308,11 @@ public function testAddingUniqueKeyWithSchema(): void $blueprint->unique('foo', 'bar'); $this->assertSame(['create unique index "foo"."bar" on "users" ("foo")'], $blueprint->toSql()); + + $blueprint = new Blueprint($this->getConnection(), '0.users'); + $blueprint->unique('foo', 'bar'); + + $this->assertSame(['create unique index "0"."bar" on "users" ("foo")'], $blueprint->toSql()); } public function testAddingIndexWithSchema(): void @@ -311,6 +321,11 @@ public function testAddingIndexWithSchema(): void $blueprint->index(['foo', 'bar'], 'baz'); $this->assertSame(['create index "foo"."baz" on "users" ("foo", "bar")'], $blueprint->toSql()); + + $blueprint = new Blueprint($this->getConnection(), '0.users'); + $blueprint->index(['foo', 'bar'], 'baz'); + + $this->assertSame(['create index "0"."baz" on "users" ("foo", "bar")'], $blueprint->toSql()); } public function testAddingPartialIndexWithSchema(): void @@ -382,6 +397,11 @@ public function testCompileIndexesIncludesPartialMetadata(): void ); } + public function testCompileTablesForSchemaNamedZero(): void + { + $this->assertStringContainsString(" where tl.schema = '0' and tl.type in", $this->getGrammar()->compileTables('0')); + } + public function testAddingIncrementingID(): void { $blueprint = new Blueprint($this->getConnection(), 'users'); diff --git a/tests/Foundation/Testing/DatabaseTruncationTest.php b/tests/Foundation/Testing/DatabaseTruncationTest.php index ff648fb6a0..e131e234af 100644 --- a/tests/Foundation/Testing/DatabaseTruncationTest.php +++ b/tests/Foundation/Testing/DatabaseTruncationTest.php @@ -183,6 +183,34 @@ public function testTruncateTablesWithSchemaAndExceptTablesProperty(): void $this->assertEquals(['public.baz', 'private.bar'], $truncatedTables); } + public function testTruncateTablesWithSchemaNamedZeroAndExceptTablesProperty(): void + { + $this->exceptTables = ['0.bar']; + + $connection = $this->arrangeConnection($truncatedTables, [ + ['schema' => 'public', 'name' => 'migrations', 'schema_qualified_name' => 'public.migrations'], + ['schema' => 'public', 'name' => 'bar', 'schema_qualified_name' => 'public.bar'], + ['schema' => '0', 'name' => 'bar', 'schema_qualified_name' => '0.bar'], + ['schema' => '0', 'name' => 'baz', 'schema_qualified_name' => '0.baz'], + ]); + + $this->truncateTablesForConnection($connection, 'test'); + + $this->assertSame(['public.bar', '0.baz'], $truncatedTables); + } + + public function testTruncateTablesWithAnEmptySearchPathTruncatesNothing(): void + { + $connection = $this->arrangeConnection($truncatedTables, [ + ['schema' => 'public', 'name' => 'foo', 'schema_qualified_name' => 'public.foo'], + ['schema' => 'private', 'name' => 'bar', 'schema_qualified_name' => 'private.bar'], + ], schemas: []); + + $this->truncateTablesForConnection($connection, 'test'); + + $this->assertSame([], $truncatedTables); + } + public function testTruncateTablesWithConnectionPrefix(): void { $connection = $this->arrangeConnection($truncatedTables, [ @@ -514,6 +542,9 @@ protected function truncateTablesForAllConnections(): void /** * Arrange a connection that records truncated tables. * + * A null schema listing selects every table, as on drivers without schemas. An empty + * listing is an empty PostgreSQL search path, which selects no tables. + * * @param null|class-string $builder */ private function arrangeConnection( @@ -521,18 +552,24 @@ private function arrangeConnection( array $allTables, string $prefix = '', ?string $builder = null, - ?array $schemas = [], + ?array $schemas = null, ?RuntimeException $failure = null ): Connection { $actual = []; $allTables = array_map(fn (array $table): array => $table + ['partition_of' => null], $allTables); $schema = m::mock($builder ?? Builder::class)->makePartial(); - $schema->expects('getTables')->with($schemas)->andReturn( - empty($schemas) - ? $allTables - : array_filter($allTables, fn (array $table): bool => in_array($table['schema'], $schemas, true)) - ); + + if ($schemas === []) { + $schema->shouldNotReceive('getTables'); + } else { + $schema->expects('getTables')->with($schemas)->andReturn( + $schemas === null + ? $allTables + : array_filter($allTables, fn (array $table): bool => in_array($table['schema'], $schemas, true)) + ); + } + $schema->expects('getCurrentSchemaListing')->andReturn($schemas); $withoutPrefix = false; $schema->expects('truncateTables')->andReturnUsing( diff --git a/tests/Integration/Database/Postgres/PostgresSchemaBuilderTest.php b/tests/Integration/Database/Postgres/PostgresSchemaBuilderTest.php index b42d95c8d4..818686cd5b 100644 --- a/tests/Integration/Database/Postgres/PostgresSchemaBuilderTest.php +++ b/tests/Integration/Database/Postgres/PostgresSchemaBuilderTest.php @@ -158,6 +158,40 @@ public function testDropAllViewsOnAllSchemas(): void $this->assertFalse(Schema::hasView('private.foo')); } + public function testDropIndexOnSchemaQualifiedTable(): void + { + foreach (['public.table', 'private.table'] as $name) { + Schema::create($name, function (Blueprint $table) { + $table->string('name'); + $table->index('name', 'table_name_index'); + }); + } + + Schema::table('private.table', function (Blueprint $table) { + $table->dropIndex('table_name_index'); + }); + + $this->assertTrue(Schema::hasIndex('public.table', 'table_name_index')); + $this->assertFalse(Schema::hasIndex('private.table', 'table_name_index')); + } + + public function testRenameIndexOnSchemaQualifiedTable(): void + { + foreach (['public.table', 'private.table'] as $name) { + Schema::create($name, function (Blueprint $table) { + $table->string('name'); + $table->index('name', 'table_name_index'); + }); + } + + Schema::table('private.table', function (Blueprint $table) { + $table->renameIndex('table_name_index', 'renamed_index'); + }); + + $this->assertTrue(Schema::hasIndex('public.table', 'table_name_index')); + $this->assertTrue(Schema::hasIndex('private.table', 'renamed_index')); + } + public function testAddTableCommentOnNewTable(): void { Schema::create('public.posts', function (Blueprint $table) { From ed570c726816714b171491434674d0ba174148e9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:18:47 +0000 Subject: [PATCH 25/31] Cover reconnecting after a connection lost during rollback Port laravel/framework#62211 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. Upstream clears the PDO instances when a rollback fails because the connection was lost, so the next query reconnects. Hypervel already does this: handleRollBackException() forgets the lost connection, which detaches the driver resources, releases them and resets the transaction state. No source change is needed. Upstream's testConnectionIsReestablishedAfterTheConnectionIsLostDuringRollBack is ported. The test file passes. --- tests/Database/DatabaseConnectionTest.php | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index 223a6724bb..f25869cafd 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -541,6 +541,35 @@ public function testBeginTransactionMethodReconnectsMissingConnection(): void $this->assertEquals(1, $connection->transactionLevel()); } + public function testConnectionIsReestablishedAfterTheConnectionIsLostDuringRollBack(): void + { + $pdo = $this->createMock(PDOStub::class); + $pdo->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->once())->method('rollBack')->willThrowException(new PDOException('server has gone away')); + + $newPdo = $this->createMock(PDOStub::class); + $newPdo->expects($this->once())->method('beginTransaction'); + + $connection = $this->getMockConnection([], $pdo); + $connection->setReconnector(fn ($connection) => $connection->setPdo($newPdo)); + + $connection->beginTransaction(); + + try { + $connection->rollBack(); + + $this->fail('A PDOException was not thrown.'); + } catch (PDOException) { + $this->assertSame(0, $connection->transactionLevel()); + } + + $connection->beginTransaction(); + + $this->assertSame(1, $connection->transactionLevel()); + $this->assertSame($newPdo, $connection->getPdo()); + } + public function testBeginTransactionMethodNeverRetriesIfWithinTransaction(): void { $pdo = $this->createMock(PDOStub::class); From e1e080ea3e40111e16f9558da90869c22d13a541 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:18:47 +0000 Subject: [PATCH 26/31] Add missing PostgreSQL full text languages Port laravel/framework#62210 at 13.x revision b20bf7f442beabdb70e45cf9d29cd6c64675f856. PostgresGrammar::validFullTextLanguages() now includes armenian, basque, catalan, greek, hindi, serbian and yiddish, which PostgreSQL provides as text search configurations. Upstream's greek case is added to testWhereFulltextPostgres. DatabaseQueryBuilderTest also carries the SQLite truncate case for a schema named "0", the regression test for the previous schema-qualification correction. The test file passes. --- src/database/src/Query/Grammars/PostgresGrammar.php | 7 +++++++ tests/Database/DatabaseQueryBuilderTest.php | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/database/src/Query/Grammars/PostgresGrammar.php b/src/database/src/Query/Grammars/PostgresGrammar.php index 6120e656eb..89e5086923 100755 --- a/src/database/src/Query/Grammars/PostgresGrammar.php +++ b/src/database/src/Query/Grammars/PostgresGrammar.php @@ -176,12 +176,17 @@ protected function validFullTextLanguages(): array return [ 'simple', 'arabic', + 'armenian', + 'basque', + 'catalan', 'danish', 'dutch', 'english', 'finnish', 'french', 'german', + 'greek', + 'hindi', 'hungarian', 'indonesian', 'irish', @@ -192,10 +197,12 @@ protected function validFullTextLanguages(): array 'portuguese', 'romanian', 'russian', + 'serbian', 'spanish', 'swedish', 'tamil', 'turkish', + 'yiddish', ]; } diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 7edd3b77aa..1af1eab5b9 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -2015,6 +2015,11 @@ public function testWhereFulltextPostgres(): void $this->assertSame('select * from "users" where (to_tsvector(\'simple\', "body")) @@ plainto_tsquery(\'simple\', ?)', $builder->toSql()); $this->assertEquals(['Hello World'], $builder->getBindings()); + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText('body', 'Hello World', ['language' => 'greek']); + $this->assertSame('select * from "users" where (to_tsvector(\'greek\', "body")) @@ plainto_tsquery(\'greek\', ?)', $builder->toSql()); + $this->assertEquals(['Hello World'], $builder->getBindings()); + $builder = $this->getPostgresBuilderWithProcessor(); $builder->select('*')->from('users')->whereFullText('body', 'Hello World', ['mode' => 'plain']); $this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ plainto_tsquery(\'english\', ?)', $builder->toSql()); @@ -6198,6 +6203,14 @@ public function testTruncateMethodWithPrefixAndSchema(): void 'delete from "my_schema".sqlite_sequence where name = ?' => ['prefix_users'], 'delete from "my_schema"."prefix_users"' => [], ], $builder->getGrammar()->compileTruncate($builder)); + + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->expects('getSchemaBuilder->parseSchemaAndTable')->andReturn(['0', 'users']); + $builder->from('0.users'); + $this->assertSame([ + 'delete from "0".sqlite_sequence where name = ?' => ['users'], + 'delete from "0"."users"' => [], + ], $builder->getGrammar()->compileTruncate($builder)); } public function testPreserveAddsClosureToArray(): void From fbc87662887a856b89f486d61c0debd2c3d2912e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:22:05 +0000 Subject: [PATCH 27/31] Move the SQLite test database file fix to the next batch This reverts commit 4d7982dad to keep this pull request within its file limit. The fix will be reapplied in the next batch. --- tests/Integration/Database/Sqlite/SqliteTestCase.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/Integration/Database/Sqlite/SqliteTestCase.php b/tests/Integration/Database/Sqlite/SqliteTestCase.php index d06d8d3fb5..7c18ad9704 100644 --- a/tests/Integration/Database/Sqlite/SqliteTestCase.php +++ b/tests/Integration/Database/Sqlite/SqliteTestCase.php @@ -49,12 +49,6 @@ protected function getConfiguredDatabasePath(): string */ protected function ensureSqliteDatabaseFileExists(): void { - // This runs before the class-level RequiresDatabase skip, so another driver's - // database name must not be created as a file in the working directory. - if (env('DB_CONNECTION', 'sqlite') !== 'sqlite') { - return; - } - $path = $this->getConfiguredDatabasePath(); // URI names pass to SQLite unchanged and are not literal filesystem paths. From c4c3331f23b5613346cba899c93beff3c59e715e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:18:26 +0000 Subject: [PATCH 28/31] Create the SQLite test database file only for SQLite runs SqliteTestCase::setUp() creates the configured DB_DATABASE file before the class-level RequiresDatabase('sqlite') skip runs, because that skip needs the booted application. MySQL, MariaDB and PostgreSQL runs therefore left an empty file named after the server database in the working directory. The method now returns early unless DB_CONNECTION (default sqlite) is sqlite. The PostgreSQL, MySQL, MariaDB and SQLite database suites pass without leaving files behind. --- tests/Integration/Database/Sqlite/SqliteTestCase.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Integration/Database/Sqlite/SqliteTestCase.php b/tests/Integration/Database/Sqlite/SqliteTestCase.php index 7c18ad9704..d06d8d3fb5 100644 --- a/tests/Integration/Database/Sqlite/SqliteTestCase.php +++ b/tests/Integration/Database/Sqlite/SqliteTestCase.php @@ -49,6 +49,12 @@ protected function getConfiguredDatabasePath(): string */ protected function ensureSqliteDatabaseFileExists(): void { + // This runs before the class-level RequiresDatabase skip, so another driver's + // database name must not be created as a file in the working directory. + if (env('DB_CONNECTION', 'sqlite') !== 'sqlite') { + return; + } + $path = $this->getConfiguredDatabasePath(); // URI names pass to SQLite unchanged and are not literal filesystem paths. From da1b0e1a2798224f51c1fce4cd54da960a493e69 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:28:46 +0000 Subject: [PATCH 29/31] Add title docblocks to new test helper methods The shared input contract, its consumers' data factories, test hooks, data providers and anonymous-class methods added in this branch had no title docblocks, which the repository requires on every method other than tests. Each edited test file passes. --- tests/Console/CommandInputTest.php | 3 + .../Database/DatabasePostgresBuilderTest.php | 3 + .../BroadcastingInstallCommandTest.php | 9 ++ .../Testing/LazilyRefreshDatabaseTest.php | 6 + ...reshDatabaseWithoutMigrationEventsTest.php | 9 ++ tests/Http/HttpRequestTest.php | 3 + .../ApiAuthenticationWithEloquentTest.php | 3 + .../TestsInteractsWithDataContract.php | 147 ++++++++++++++++++ tests/Support/SupportFluentTest.php | 3 + .../Support/SupportInteractsWithDataTest.php | 18 +++ tests/Support/SupportUriTest.php | 3 + tests/Support/ValidatedInputTest.php | 3 + tests/View/ViewComponentAttributeBagTest.php | 3 + 13 files changed, 213 insertions(+) diff --git a/tests/Console/CommandInputTest.php b/tests/Console/CommandInputTest.php index 27cf142791..2a323a84e6 100644 --- a/tests/Console/CommandInputTest.php +++ b/tests/Console/CommandInputTest.php @@ -164,6 +164,9 @@ public function testNullValues(): void $this->assertDataNestedNullValuesContract(); } + /** + * Create the data instance under test from the given array. + */ protected static function arrayToDataFactory(array $data): object { return new CommandInput($data); diff --git a/tests/Database/DatabasePostgresBuilderTest.php b/tests/Database/DatabasePostgresBuilderTest.php index f4f4d6dd6c..bd1ba4ba1d 100644 --- a/tests/Database/DatabasePostgresBuilderTest.php +++ b/tests/Database/DatabasePostgresBuilderTest.php @@ -364,6 +364,9 @@ public function testCurrentSchemaListingFollowsTheConnectorSettingPrecedence(?st $this->assertSame($expected, $this->getBuilder($connection)->getCurrentSchemaListing()); } + /** + * Get the search path and schema settings with their expected schema listings. + */ public static function currentSchemaListingConfigurationProvider(): array { return [ diff --git a/tests/Foundation/Console/BroadcastingInstallCommandTest.php b/tests/Foundation/Console/BroadcastingInstallCommandTest.php index 32631c1087..cc7e17025a 100644 --- a/tests/Foundation/Console/BroadcastingInstallCommandTest.php +++ b/tests/Foundation/Console/BroadcastingInstallCommandTest.php @@ -19,6 +19,9 @@ class BroadcastingInstallCommandTest extends TestCase { protected string $basePath; + /** + * Set up the test environment. + */ protected function setUp(): void { parent::setUp(); @@ -29,6 +32,9 @@ protected function setUp(): void (new Filesystem)->ensureDirectoryExists($this->basePath . '/resources/js'); } + /** + * Clean up the test environment. + */ protected function tearDown(): void { (new Filesystem)->deleteDirectory($this->basePath); @@ -55,6 +61,9 @@ public function testEchoConfigurationIsInjectedIntoTheApplicationFile(string $pa ); } + /** + * Get the framework packages with their application files. + */ public static function frameworkFiles(): array { // REMOVED: Vue's app.ts and app.js cases. Hypervel's command configures Echo diff --git a/tests/Foundation/Testing/LazilyRefreshDatabaseTest.php b/tests/Foundation/Testing/LazilyRefreshDatabaseTest.php index 9c4fe8c6ed..077354f52c 100644 --- a/tests/Foundation/Testing/LazilyRefreshDatabaseTest.php +++ b/tests/Foundation/Testing/LazilyRefreshDatabaseTest.php @@ -164,10 +164,16 @@ public function testMigrationQueriesAreNotDispatchedToTheTestMethod(): void $database = $this->app->make('db'); $this->app->instance(KernelContract::class, $kernel = new class($database) extends ConsoleKernelSpy { + /** + * Create a new console kernel spy. + */ public function __construct(protected DatabaseManager $database) { } + /** + * Run a query before calling the command. + */ public function call(string $command, array $parameters = [], ?OutputInterface $outputBuffer = null): int { $this->database->connection('testing')->select('select 2'); diff --git a/tests/Foundation/Testing/LazilyRefreshDatabaseWithoutMigrationEventsTest.php b/tests/Foundation/Testing/LazilyRefreshDatabaseWithoutMigrationEventsTest.php index d5894dbd74..8882902431 100644 --- a/tests/Foundation/Testing/LazilyRefreshDatabaseWithoutMigrationEventsTest.php +++ b/tests/Foundation/Testing/LazilyRefreshDatabaseWithoutMigrationEventsTest.php @@ -22,6 +22,9 @@ class LazilyRefreshDatabaseWithoutMigrationEventsTest extends TestCase protected array $connectionsToTransact = ['testing']; + /** + * Define the test environment. + */ protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); @@ -34,10 +37,16 @@ public function testMigrationQueriesAreNotDispatchedToTheTest(): void $database = $this->app->make('db'); $this->app->instance(KernelContract::class, $kernel = new class($database) extends ConsoleKernelSpy { + /** + * Create a new console kernel spy. + */ public function __construct(protected DatabaseManager $database) { } + /** + * Run a query before calling the command. + */ public function call(string $command, array $parameters = [], ?OutputInterface $outputBuffer = null): int { $this->database->connection('testing')->select('select 2'); diff --git a/tests/Http/HttpRequestTest.php b/tests/Http/HttpRequestTest.php index 4dd1fe41e2..aadffbcaf8 100644 --- a/tests/Http/HttpRequestTest.php +++ b/tests/Http/HttpRequestTest.php @@ -1777,6 +1777,9 @@ public function testItClampsValues(): void $this->assertDataClampContract(); } + /** + * Create the data instance under test from the given array. + */ protected static function arrayToDataFactory(array $data): object { return Request::create('/', 'GET', $data); diff --git a/tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php b/tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php index 5edcaa177b..00aeb9f8a3 100644 --- a/tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php +++ b/tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php @@ -14,6 +14,9 @@ #[RequiresPhpExtension('pdo_mysql')] class ApiAuthenticationWithEloquentTest extends TestCase { + /** + * Define the test environment. + */ protected function defineEnvironment(ApplicationContract $app): void { $config = $app->make('config'); diff --git a/tests/Support/Concerns/TestsInteractsWithDataContract.php b/tests/Support/Concerns/TestsInteractsWithDataContract.php index c910ff0882..d72bb7c1e9 100644 --- a/tests/Support/Concerns/TestsInteractsWithDataContract.php +++ b/tests/Support/Concerns/TestsInteractsWithDataContract.php @@ -17,8 +17,14 @@ trait TestsInteractsWithDataContract { + /** + * Create the data instance under test from the given array. + */ abstract protected static function arrayToDataFactory(array $data): object; + /** + * Assert the exists contract. + */ protected function assertDataExistsContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'surname' => 'Otwell', 'age' => '']); @@ -30,6 +36,9 @@ protected function assertDataExistsContract(): void $this->assertFalse($data->exists(['name', 'votes'])); } + /** + * Assert the exists contract for nested keys. + */ protected function assertDataExistsNestedContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'foo' => ['bar' => 'bar', 'baz' => '']]); @@ -40,6 +49,9 @@ protected function assertDataExistsNestedContract(): void $this->assertFalse($data->exists(['name', 'foo.bax'])); } + /** + * Assert the has contract. + */ protected function assertDataHasContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); @@ -60,6 +72,9 @@ protected function assertDataHasContract(): void $this->assertTrue($data->has('foo')); } + /** + * Assert the has contract for nested keys. + */ protected function assertDataHasNestedContract(): void { $data = static::arrayToDataFactory(['foo' => ['bar' => 'bar', 'baz' => '']]); @@ -71,6 +86,9 @@ protected function assertDataHasNestedContract(): void $this->assertFalse($data->has(['foo.bar', 'foo.bax'])); } + /** + * Assert the hasAny contract. + */ protected function assertDataHasAnyContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); @@ -84,6 +102,9 @@ protected function assertDataHasAnyContract(): void $this->assertFalse($data->hasAny(['surname', 'password'])); } + /** + * Assert the hasAny contract for nested keys. + */ protected function assertDataHasAnyNestedContract(): void { $data = static::arrayToDataFactory(['foo' => ['bar' => 'bar', 'baz' => '']]); @@ -95,6 +116,9 @@ protected function assertDataHasAnyNestedContract(): void $this->assertTrue($data->hasAny(['votes', 'foo'])); } + /** + * Assert the whenHas contract. + */ protected function assertDataWhenHasContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); @@ -135,6 +159,9 @@ protected function assertDataWhenHasContract(): void $this->assertSame('default', $data->whenHas('missing', fn ($value) => 'callback-' . $value, fn () => 'default')); } + /** + * Assert the whenHas contract for nested keys. + */ protected function assertDataWhenHasNestedContract(): void { $data = static::arrayToDataFactory(['foo' => ['bar' => '']]); @@ -158,6 +185,9 @@ protected function assertDataWhenHasNestedContract(): void $this->assertFalse($baz); } + /** + * Assert the filled contract. + */ protected function assertDataFilledContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); @@ -181,6 +211,9 @@ protected function assertDataFilledContract(): void $this->assertTrue($data->filled('foo')); } + /** + * Assert the filled contract for nested keys. + */ protected function assertDataFilledNestedContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'foo' => ['bar' => ' ', 'baz' => '', 'bax' => 'bax']]); @@ -191,6 +224,9 @@ protected function assertDataFilledNestedContract(): void $this->assertFalse($data->filled(['name', 'foo.baz'])); } + /** + * Assert the isNotFilled contract. + */ protected function assertDataIsNotFilledContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => '0', 'whitespace' => ' ']); @@ -207,6 +243,9 @@ protected function assertDataIsNotFilledContract(): void $this->assertFalse($data->isNotFilled('age', 'city')); } + /** + * Assert the isNotFilled contract for nested keys. + */ protected function assertDataIsNotFilledNestedContract(): void { $data = static::arrayToDataFactory(['foo' => ['bar', 'baz' => '0', 'bax' => '']]); @@ -217,6 +256,9 @@ protected function assertDataIsNotFilledNestedContract(): void $this->assertTrue($data->isNotFilled('foo.bax')); } + /** + * Assert the anyFilled contract. + */ protected function assertDataAnyFilledContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); @@ -243,6 +285,9 @@ protected function assertDataAnyFilledContract(): void $this->assertFalse($data->anyFilled('foo', 'bar')); } + /** + * Assert the anyFilled contract for nested keys. + */ protected function assertDataAnyFilledNestedContract(): void { $data = static::arrayToDataFactory(['foo' => ['bar' => '', 'baz' => 'baz']]); @@ -252,6 +297,9 @@ protected function assertDataAnyFilledNestedContract(): void $this->assertFalse($data->anyFilled('foo.bar')); } + /** + * Assert the whenFilled contract. + */ protected function assertDataWhenFilledContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); @@ -292,6 +340,9 @@ protected function assertDataWhenFilledContract(): void $this->assertSame('default', $data->whenFilled('age', fn ($value) => 'callback-' . $value, fn () => 'default')); } + /** + * Assert the whenFilled contract for nested keys. + */ protected function assertDataWhenFilledNestedContract(): void { $data = static::arrayToDataFactory(['foo' => ['bar' => '', 'baz' => 'baz']]); @@ -315,6 +366,9 @@ protected function assertDataWhenFilledNestedContract(): void $this->assertSame('baz', $baz); } + /** + * Assert the whenEnum contract. + */ protected function assertDataWhenEnumContract(): void { $data = static::arrayToDataFactory(['status' => 'A', 'invalid' => 'invalid', 'empty' => '']); @@ -361,6 +415,9 @@ protected function assertDataWhenEnumContract(): void $this->assertSame('default', $data->whenEnum('missing', TestStringBackedEnum::class, fn () => 'callback', fn () => 'default')); } + /** + * Assert the missing contract. + */ protected function assertDataMissingContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); @@ -377,6 +434,9 @@ protected function assertDataMissingContract(): void $this->assertFalse($data->missing(['name', 'email'])); } + /** + * Assert the missing contract for nested keys. + */ protected function assertDataMissingNestedContract(): void { $data = static::arrayToDataFactory(['foo' => ['bar' => 'bar', 'baz' => '']]); @@ -388,6 +448,9 @@ protected function assertDataMissingNestedContract(): void $this->assertTrue($data->missing(['foo.bar', 'foo.bax'])); } + /** + * Assert the whenMissing contract. + */ protected function assertDataWhenMissingContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'bar' => '']); @@ -423,6 +486,9 @@ protected function assertDataWhenMissingContract(): void $this->assertSame('default', $data->whenMissing('name', fn () => 'callback', fn () => 'default')); } + /** + * Assert the whenMissing contract for nested keys. + */ protected function assertDataWhenMissingNestedContract(): void { $data = static::arrayToDataFactory(['foo' => ['bar' => '']]); @@ -446,6 +512,9 @@ protected function assertDataWhenMissingNestedContract(): void $this->assertTrue($baz); } + /** + * Assert the str contract. + */ protected function assertDataStrContract(): void { $data = static::arrayToDataFactory([ @@ -463,6 +532,9 @@ protected function assertDataStrContract(): void $this->assertSame('default', $data->str('unknown_key', 'default')->value()); } + /** + * Assert the string contract. + */ protected function assertDataStringContract(): void { $data = static::arrayToDataFactory([ @@ -488,6 +560,9 @@ protected function assertDataStringContract(): void $this->assertSame('default', $data->string('unknown_key', 'default')->value()); } + /** + * Assert the strict string contract. + */ protected function assertDataStringStrictContract(): void { $data = static::arrayToDataFactory([ @@ -513,6 +588,9 @@ protected function assertDataStringStrictContract(): void $this->assertSame('default', $data->string('array', fn () => 'default', true)->value()); } + /** + * Assert the boolean contract. + */ protected function assertDataBooleanContract(): void { $data = static::arrayToDataFactory([ @@ -540,6 +618,9 @@ protected function assertDataBooleanContract(): void $this->assertTrue($data->boolean('some_undefined_key', true)); } + /** + * Assert the strict boolean contract. + */ protected function assertDataBooleanStrictContract(): void { $data = static::arrayToDataFactory([ @@ -585,6 +666,9 @@ protected function assertDataBooleanStrictContract(): void $this->assertTrue($data->boolean('empty_str', fn () => true, true)); } + /** + * Assert the integer contract. + */ protected function assertDataIntegerContract(): void { $data = static::arrayToDataFactory([ @@ -610,6 +694,9 @@ protected function assertDataIntegerContract(): void $this->assertSame(123456, $data->integer('unknown_key', 123456)); } + /** + * Assert the strict integer contract. + */ protected function assertDataIntegerStrictContract(): void { $data = static::arrayToDataFactory([ @@ -653,6 +740,9 @@ protected function assertDataIntegerStrictContract(): void $this->assertSame(7, $data->integer('mixed', fn () => 7, true)); } + /** + * Assert the float contract. + */ protected function assertDataFloatContract(): void { $data = static::arrayToDataFactory([ @@ -680,6 +770,9 @@ protected function assertDataFloatContract(): void $this->assertSame(123.456, $data->float('unknown_key', 123.456)); } + /** + * Assert the strict float contract. + */ protected function assertDataFloatStrictContract(): void { $data = static::arrayToDataFactory([ @@ -723,6 +816,9 @@ protected function assertDataFloatStrictContract(): void $this->assertSame(7.5, $data->float('mixed', fn () => 7.5, true)); } + /** + * Assert the strict boolean contract for native values. + */ protected function assertDataBooleanStrictNativeValuesContract(): void { $data = static::arrayToDataFactory(['true' => true, 'false' => false]); @@ -737,6 +833,9 @@ protected function assertDataBooleanStrictNativeValuesContract(): void $this->assertFalse($data->boolean('false', null, true)); } + /** + * Assert the clamp contract. + */ protected function assertDataClampContract(): void { $data = static::arrayToDataFactory([ @@ -758,6 +857,9 @@ protected function assertDataClampContract(): void $this->assertSame(15, $data->clamp('string', 10, 100, 15)); } + /** + * Assert the date contract. + */ protected function assertDataDateContract(): void { $data = static::arrayToDataFactory([ @@ -797,6 +899,9 @@ protected function assertDataDateContract(): void } } + /** + * Assert the interval contract. + */ protected function assertDataIntervalContract(): void { $data = static::arrayToDataFactory([ @@ -846,6 +951,9 @@ protected function assertDataIntervalContract(): void $this->assertSame(1, $data->interval('normal_float', Unit::Hour)->hours); } + /** + * Assert the enum contract. + */ protected function assertDataEnumContract(): void { $data = static::arrayToDataFactory([ @@ -878,6 +986,9 @@ protected function assertDataEnumContract(): void $this->assertNull($data->enum('doesnt_exist', TestIntegerBackedEnum::class)); } + /** + * Assert the enum contract for nested keys. + */ protected function assertDataEnumNestedContract(): void { $data = static::arrayToDataFactory([ @@ -889,6 +1000,9 @@ protected function assertDataEnumNestedContract(): void $this->assertNull($data->enum('string.missing', TestStringBackedEnum::class)); } + /** + * Assert the enums contract. + */ protected function assertDataEnumsContract(): void { $data = static::arrayToDataFactory([ @@ -912,6 +1026,9 @@ protected function assertDataEnumsContract(): void $this->assertSame([TestIntegerBackedEnum::Minus1, TestIntegerBackedEnum::Zero, TestIntegerBackedEnum::Plus1], $data->enums('integer_values', TestIntegerBackedEnum::class)); } + /** + * Assert the array contract. + */ protected function assertDataArrayContract(): void { $data = static::arrayToDataFactory(['users' => ['1', '2', '3'], 'roles' => ['4', '5', '6'], 'email' => 'test@example.com']); @@ -927,11 +1044,17 @@ protected function assertDataArrayContract(): void $this->assertSame([], static::arrayToDataFactory([])->array()); } + /** + * Assert the array contract for Arrayable values. + */ protected function assertDataArrayArrayableContract(): void { $this->assertSame([1, 2], static::arrayToDataFactory(['users' => new Collection([1, 2])])->array('users')); } + /** + * Assert the collect contract. + */ protected function assertDataCollectContract(): void { $data = static::arrayToDataFactory(['users' => ['1', '2', '3'], 'roles' => ['4', '5', '6'], 'email' => 'test@example.com']); @@ -950,6 +1073,9 @@ protected function assertDataCollectContract(): void $this->assertTrue(static::arrayToDataFactory([])->collect()->isEmpty()); } + /** + * Assert the collect contract for nested keys. + */ protected function assertDataCollectNestedContract(): void { $data = static::arrayToDataFactory(['authors' => ['taylor' => ['products' => ['forge', 'vapor', 'spark']]]]); @@ -957,6 +1083,9 @@ protected function assertDataCollectNestedContract(): void $this->assertSame(['forge', 'vapor', 'spark'], $data->collect('authors.taylor.products')->all()); } + /** + * Assert the only contract. + */ protected function assertDataOnlyContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'email' => 'taylor@laravel.com']); @@ -967,6 +1096,9 @@ protected function assertDataOnlyContract(): void $this->assertSame([], $data->only('missing')); } + /** + * Assert the only contract for nested keys. + */ protected function assertDataOnlyNestedContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'developer' => ['name' => 'Taylor', 'age' => '']]); @@ -977,6 +1109,9 @@ protected function assertDataOnlyNestedContract(): void $this->assertSame([], $data->only('developer.skills')); } + /** + * Assert the except contract. + */ protected function assertDataExceptContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '25']); @@ -987,6 +1122,9 @@ protected function assertDataExceptContract(): void $this->assertSame([], $data->except(['age', 'name'])); } + /** + * Assert the except contract for nested keys. + */ protected function assertDataExceptNestedContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'developer' => ['name' => 'Taylor', 'age' => '']]); @@ -995,6 +1133,9 @@ protected function assertDataExceptNestedContract(): void $this->assertEquals(['developer' => ['age' => '']], $data->except('name', 'developer.name')); } + /** + * Assert the contract for null values. + */ protected function assertDataNullValuesContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'city' => null]); @@ -1039,6 +1180,9 @@ protected function assertDataNullValuesContract(): void $this->assertSame([], $data->enums('city', TestStringBackedEnum::class)); } + /** + * Assert that null values do not use the given defaults. + */ protected function assertDataNullValuesIgnoreDefaultsContract(): void { $data = static::arrayToDataFactory(['city' => null]); @@ -1049,6 +1193,9 @@ protected function assertDataNullValuesIgnoreDefaultsContract(): void $this->assertSame(0.0, $data->float('city', 123.456)); } + /** + * Assert the contract for nested null values. + */ protected function assertDataNestedNullValuesContract(): void { $data = static::arrayToDataFactory(['name' => 'Taylor', 'foo' => ['bar' => null]]); diff --git a/tests/Support/SupportFluentTest.php b/tests/Support/SupportFluentTest.php index 88a0d52cdb..bb501f9a96 100644 --- a/tests/Support/SupportFluentTest.php +++ b/tests/Support/SupportFluentTest.php @@ -435,6 +435,9 @@ public function testNullValues(): void $this->assertDataNestedNullValuesContract(); } + /** + * Create the data instance under test from the given array. + */ protected static function arrayToDataFactory(array $data): object { return new Fluent($data); diff --git a/tests/Support/SupportInteractsWithDataTest.php b/tests/Support/SupportInteractsWithDataTest.php index 6f4a2d5381..62505a7194 100644 --- a/tests/Support/SupportInteractsWithDataTest.php +++ b/tests/Support/SupportInteractsWithDataTest.php @@ -38,6 +38,9 @@ class SupportInteractsWithDataTest extends TestCase { use TestsInteractsWithDataContract; + /** + * Set up the test environment. + */ protected function setUp(): void { parent::setUp(); @@ -323,6 +326,9 @@ public function testStringBackedEnumAcceptsAnyStringableValue(): void { $instance = static::arrayToDataFactory([ 'timezone' => new class implements Stringable { + /** + * Get the timezone name. + */ public function __toString(): string { return 'UTC'; @@ -394,15 +400,24 @@ public function testNullValues(): void $this->assertDataNestedNullValuesContract(); } + /** + * Create the data instance under test from the given array. + */ protected static function arrayToDataFactory(array $data): object { return new class($data) { use InteractsWithData; + /** + * Create a new data instance. + */ public function __construct(protected array $attributes) { } + /** + * Get all of the data, or only the given keys. + */ public function all(mixed $keys = null): array { if (! $keys) { @@ -418,6 +433,9 @@ public function all(mixed $keys = null): array return $results; } + /** + * Retrieve data from the instance. + */ protected function data(?string $key = null, mixed $default = null): mixed { return data_get($this->attributes, $key, $default); diff --git a/tests/Support/SupportUriTest.php b/tests/Support/SupportUriTest.php index babe495e7d..f5b25f2fcf 100644 --- a/tests/Support/SupportUriTest.php +++ b/tests/Support/SupportUriTest.php @@ -567,6 +567,9 @@ public function testExceptMethod(): void $this->assertDataExceptNestedContract(); } + /** + * Create the data instance under test from the given array. + */ protected static function arrayToDataFactory(array $data): object { return Uri::of('https://hypervel.org')->withQuery($data)->query(); diff --git a/tests/Support/ValidatedInputTest.php b/tests/Support/ValidatedInputTest.php index 81038a8b03..ddfb6fe7d6 100644 --- a/tests/Support/ValidatedInputTest.php +++ b/tests/Support/ValidatedInputTest.php @@ -274,6 +274,9 @@ public function testNullValues(): void $this->assertDataNestedNullValuesContract(); } + /** + * Create the data instance under test from the given array. + */ protected static function arrayToDataFactory(array $data): object { return new ValidatedInput($data); diff --git a/tests/View/ViewComponentAttributeBagTest.php b/tests/View/ViewComponentAttributeBagTest.php index bb211b16a6..aa7e70459f 100644 --- a/tests/View/ViewComponentAttributeBagTest.php +++ b/tests/View/ViewComponentAttributeBagTest.php @@ -372,6 +372,9 @@ public function testExists(): void $this->assertDataExistsContract(); } + /** + * Create the data instance under test from the given array. + */ protected static function arrayToDataFactory(array $data): object { return new ComponentAttributeBag($data); From ba502e5f317f15ddede5b4a82405450f420e8a86 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:28:46 +0000 Subject: [PATCH 30/31] Describe install:broadcasting's Echo setup for non-React apps The README said Vue apps receive the standard echo.js setup. For apps other than React, the command imports echo.js only from resources/js/bootstrap.js or resources/js/app.js, so a Vue app with only app.ts gets an echo.js file nothing imports. The difference now says so and points Vue apps to the @laravel/echo-vue instructions. --- src/foundation/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/foundation/README.md b/src/foundation/README.md index 3228c924ed..7b16af322e 100644 --- a/src/foundation/README.md +++ b/src/foundation/README.md @@ -28,7 +28,7 @@ Laravel's `Application::handleRequest()` is omitted. Hypervel's HTTP server hand The `php artisan serve` command starts Hypervel's configured Swoole servers directly instead of PHP's built-in development server. -`php artisan install:broadcasting` adds Echo configuration to the application file only for React apps. Vue apps receive the standard `echo.js` setup; see the [broadcasting documentation](https://hypervel.org/docs/broadcasting#client-side-installation) to use `@laravel/echo-vue` instead. +`php artisan install:broadcasting` adds Echo configuration to the application file only for React apps. Other apps receive a standard `echo.js` file, imported from `resources/js/bootstrap.js` or `resources/js/app.js` when either exists. Vue apps, including those with only an `app.ts` file, can configure `@laravel/echo-vue` as described in the [broadcasting documentation](https://hypervel.org/docs/broadcasting#client-side-installation). Environment detection honors startup `--env` arguments even when `runningInConsole()` is false, so server commands can select their environment while booting with HTTP semantics. From 7431126376f6b507447acac562f4128e51f39f5d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:28:56 +0000 Subject: [PATCH 31/31] Read assigned cast objects back in the cast's own form Eloquent caches an object assigned to a class-cast attribute and returns it on later reads. That keeps identity and in-place edits for objects already in the cast's form, but other supported inputs read back wrongly: - a Collection assigned to AsArrayObject read back as a Collection, and toArray() threw; - a LazyCollection assigned to AsCollection read back as a LazyCollection; - a base Collection assigned to a custom collection class, or arrays assigned to AsCollection::of(), read back unconverted; - a generator assigned to AsEnumCollection threw on writeback, and backing values read back as strings; - a Collection assigned to AsEnumArrayObject, a plain object assigned to AsFluent, another Stringable assigned to AsStringable and a Uuid assigned to AsBinary all read back as the assigned object. Casters may now define shouldCacheObject(). setClassCastableAttribute() checks it after the existing inbound, object and withoutObjectCaching checks, and a declined object is rebuilt through get() from the stored attributes on the next read. The built-in casts accept only objects already in their read form, including mapped and enum items; a callable map declines and is applied on the next read. AsBinary disables object caching, since its reads are always strings. The check is stateless, so the worker-wide caster cache is unaffected, and the read path is unchanged. AsCollection, AsArrayObject and their encrypted variants also stored a generator as {} because JSON encoding ignores its items. They now encode a Traversable that is not JsonSerializable through iterator_to_array(), as Collection does. AsFluent's write type returns to array|object, and the enum casts' write types include the string and integer values they already accept. The custom cast documentation describes the new method. Regression cases in DatabaseEloquentModelTest fail against the previous code. The identity, in-place edit and callable-map cases pass. DatabaseEloquentModelTest, the Database unit and SQLite integration suites, analysis and formatting pass. --- .../src/Eloquent/Casts/AsArrayObject.php | 15 ++- src/database/src/Eloquent/Casts/AsBinary.php | 3 + .../src/Eloquent/Casts/AsCollection.php | 28 ++++- .../Eloquent/Casts/AsEncryptedArrayObject.php | 24 +++- .../Eloquent/Casts/AsEncryptedCollection.php | 28 ++++- .../src/Eloquent/Casts/AsEnumArrayObject.php | 37 ++++++- .../src/Eloquent/Casts/AsEnumCollection.php | 27 ++++- src/database/src/Eloquent/Casts/AsFluent.php | 14 +++ .../src/Eloquent/Casts/AsStringable.php | 8 ++ .../src/Eloquent/Concerns/HasAttributes.php | 5 +- src/docs/eloquent-mutators.md | 12 ++ tests/Database/DatabaseEloquentModelTest.php | 103 ++++++++++++++++++ types/Database/Eloquent/Casts/Castable.php | 4 +- 13 files changed, 299 insertions(+), 9 deletions(-) diff --git a/src/database/src/Eloquent/Casts/AsArrayObject.php b/src/database/src/Eloquent/Casts/AsArrayObject.php index 2ee1f56328..4f03637979 100644 --- a/src/database/src/Eloquent/Casts/AsArrayObject.php +++ b/src/database/src/Eloquent/Casts/AsArrayObject.php @@ -8,6 +8,8 @@ use Hypervel\Contracts\Database\Eloquent\CastsAttributes; use Hypervel\Database\Eloquent\JsonEncodingException; use Hypervel\Database\Eloquent\Model; +use JsonSerializable; +use Traversable; class AsArrayObject implements Castable { @@ -49,7 +51,10 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return [$key => null]; } - $encoded = Json::encode($value); + // JSON encoding a plain Traversable such as a generator would discard its items. + $encoded = Json::encode( + $value instanceof Traversable && ! $value instanceof JsonSerializable ? iterator_to_array($value) : $value + ); if ($encoded === false) { throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); @@ -58,6 +63,14 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return [$key => $encoded]; } + /** + * Determine if the assigned object can be cached as the attribute value. + */ + public function shouldCacheObject(Model $model, string $key, object $value): bool + { + return $value instanceof ArrayObject; + } + /** * Serialize the attribute for an array or JSON response. */ diff --git a/src/database/src/Eloquent/Casts/AsBinary.php b/src/database/src/Eloquent/Casts/AsBinary.php index c827dad7d0..bd267d51c1 100644 --- a/src/database/src/Eloquent/Casts/AsBinary.php +++ b/src/database/src/Eloquent/Casts/AsBinary.php @@ -22,6 +22,9 @@ class AsBinary implements Castable public static function castUsing(array $arguments): CastsAttributes { return new class($arguments) implements CastsAttributes { + // Eloquent otherwise caches an assigned UUID or ULID object and returns it instead of a string. + public bool $withoutObjectCaching = true; + protected string $format; /** diff --git a/src/database/src/Eloquent/Casts/AsCollection.php b/src/database/src/Eloquent/Casts/AsCollection.php index 589d819fef..c327238ead 100644 --- a/src/database/src/Eloquent/Casts/AsCollection.php +++ b/src/database/src/Eloquent/Casts/AsCollection.php @@ -11,6 +11,8 @@ use Hypervel\Support\Collection; use Hypervel\Support\Str; use InvalidArgumentException; +use JsonSerializable; +use Traversable; class AsCollection implements Castable { @@ -77,7 +79,10 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return [$key => null]; } - $encoded = Json::encode($value); + // JSON encoding a plain Traversable such as a generator would discard its items. + $encoded = Json::encode( + $value instanceof Traversable && ! $value instanceof JsonSerializable ? iterator_to_array($value) : $value + ); if ($encoded === false) { throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); @@ -85,6 +90,27 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return [$key => $encoded]; } + + /** + * Determine if the assigned object can be cached as the attribute value. + */ + public function shouldCacheObject(Model $model, string $key, object $value): bool + { + $collectionClass = empty($this->arguments[0]) ? Collection::class : $this->arguments[0]; + + if (! $value instanceof Collection || ! $value instanceof $collectionClass) { + return false; + } + + if (! $this->arguments[1]) { + return true; + } + + $map = is_string($this->arguments[1]) ? Str::parseCallback($this->arguments[1]) : $this->arguments[1]; + + // A callable map's results cannot be checked, so the next read applies it instead. + return ! is_callable($map) && $value->every(fn ($item) => $item instanceof $map[0]); + } }; } diff --git a/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php b/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php index 0214cd6734..c5b966b64c 100644 --- a/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php +++ b/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php @@ -9,6 +9,8 @@ use Hypervel\Database\Eloquent\JsonEncodingException; use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Facades\Crypt; +use JsonSerializable; +use Traversable; class AsEncryptedArrayObject implements Castable { @@ -20,6 +22,9 @@ class AsEncryptedArrayObject implements Castable public static function castUsing(array $arguments): CastsAttributes { return new class implements CastsAttributes { + /** + * Transform the attribute from the underlying model values. + */ public function get(Model $model, string $key, mixed $value, array $attributes): ?ArrayObject { if (isset($attributes[$key])) { @@ -31,10 +36,16 @@ public function get(Model $model, string $key, mixed $value, array $attributes): return null; } + /** + * Transform the attribute to its underlying model values. + */ public function set(Model $model, string $key, mixed $value, array $attributes): ?array { if (! is_null($value)) { - $encoded = Json::encode($value); + // JSON encoding a plain Traversable such as a generator would discard its items. + $encoded = Json::encode( + $value instanceof Traversable && ! $value instanceof JsonSerializable ? iterator_to_array($value) : $value + ); if ($encoded === false) { throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); @@ -46,6 +57,17 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return null; } + /** + * Determine if the assigned object can be cached as the attribute value. + */ + public function shouldCacheObject(Model $model, string $key, object $value): bool + { + return $value instanceof ArrayObject; + } + + /** + * Serialize the attribute for an array or JSON response. + */ public function serialize(Model $model, string $key, mixed $value, array $attributes): ?array { return ! is_null($value) ? $value->getArrayCopy() : null; diff --git a/src/database/src/Eloquent/Casts/AsEncryptedCollection.php b/src/database/src/Eloquent/Casts/AsEncryptedCollection.php index d377c28ed6..82c29733ad 100644 --- a/src/database/src/Eloquent/Casts/AsEncryptedCollection.php +++ b/src/database/src/Eloquent/Casts/AsEncryptedCollection.php @@ -12,6 +12,8 @@ use Hypervel\Support\Facades\Crypt; use Hypervel\Support\Str; use InvalidArgumentException; +use JsonSerializable; +use Traversable; class AsEncryptedCollection implements Castable { @@ -75,7 +77,10 @@ public function get(Model $model, string $key, mixed $value, array $attributes): public function set(Model $model, string $key, mixed $value, array $attributes): ?array { if (! is_null($value)) { - $encoded = Json::encode($value); + // JSON encoding a plain Traversable such as a generator would discard its items. + $encoded = Json::encode( + $value instanceof Traversable && ! $value instanceof JsonSerializable ? iterator_to_array($value) : $value + ); if ($encoded === false) { throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); @@ -86,6 +91,27 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return null; } + + /** + * Determine if the assigned object can be cached as the attribute value. + */ + public function shouldCacheObject(Model $model, string $key, object $value): bool + { + $collectionClass = empty($this->arguments[0]) ? Collection::class : $this->arguments[0]; + + if (! $value instanceof Collection || ! $value instanceof $collectionClass) { + return false; + } + + if (! $this->arguments[1]) { + return true; + } + + $map = is_string($this->arguments[1]) ? Str::parseCallback($this->arguments[1]) : $this->arguments[1]; + + // A callable map's results cannot be checked, so the next read applies it instead. + return ! is_callable($map) && $value->every(fn ($item) => $item instanceof $map[0]); + } }; } diff --git a/src/database/src/Eloquent/Casts/AsEnumArrayObject.php b/src/database/src/Eloquent/Casts/AsEnumArrayObject.php index dcfbdaaf71..b4a4063da2 100644 --- a/src/database/src/Eloquent/Casts/AsEnumArrayObject.php +++ b/src/database/src/Eloquent/Casts/AsEnumArrayObject.php @@ -23,18 +23,24 @@ class AsEnumArrayObject implements Castable * @template TEnum of UnitEnum * * @param array{class-string} $arguments - * @return CastsAttributes, iterable> + * @return CastsAttributes, iterable> */ public static function castUsing(array $arguments): CastsAttributes { return new class($arguments) implements CastsAttributes { protected array $arguments; + /** + * Create a new enum array object cast instance. + */ public function __construct(array $arguments) { $this->arguments = $arguments; } + /** + * Transform the attribute from the underlying model values. + */ public function get(Model $model, string $key, mixed $value, array $attributes): ?ArrayObject { if (! isset($attributes[$key])) { @@ -56,6 +62,9 @@ public function get(Model $model, string $key, mixed $value, array $attributes): })->toArray()); } + /** + * Transform the attribute to its underlying model values. + */ public function set(Model $model, string $key, mixed $value, array $attributes): array { if ($value === null) { @@ -77,6 +86,29 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return [$key => $encoded]; } + /** + * Determine if the assigned object can be cached as the attribute value. + */ + public function shouldCacheObject(Model $model, string $key, object $value): bool + { + if (! $value instanceof ArrayObject) { + return false; + } + + $enumClass = $this->arguments[0]; + + foreach ($value as $item) { + if (! $item instanceof $enumClass) { + return false; + } + } + + return true; + } + + /** + * Serialize the attribute for an array or JSON response. + */ public function serialize(Model $model, string $key, mixed $value, array $attributes): array { return (new Collection($value->getArrayCopy())) @@ -84,6 +116,9 @@ public function serialize(Model $model, string $key, mixed $value, array $attrib ->toArray(); } + /** + * Get the storable value of the given enum. + */ protected function getStorableEnumValue(mixed $enum): string|int { if (is_string($enum) || is_int($enum)) { diff --git a/src/database/src/Eloquent/Casts/AsEnumCollection.php b/src/database/src/Eloquent/Casts/AsEnumCollection.php index 23c74813ed..e20d5ebc13 100644 --- a/src/database/src/Eloquent/Casts/AsEnumCollection.php +++ b/src/database/src/Eloquent/Casts/AsEnumCollection.php @@ -23,18 +23,24 @@ class AsEnumCollection implements Castable * @template TEnum of UnitEnum * * @param array{class-string} $arguments - * @return CastsAttributes, iterable> + * @return CastsAttributes, iterable> */ public static function castUsing(array $arguments): CastsAttributes { return new class($arguments) implements CastsAttributes { protected array $arguments; + /** + * Create a new enum collection cast instance. + */ public function __construct(array $arguments) { $this->arguments = $arguments; } + /** + * Transform the attribute from the underlying model values. + */ public function get(Model $model, string $key, mixed $value, array $attributes): ?Collection { if (! isset($attributes[$key])) { @@ -56,6 +62,9 @@ public function get(Model $model, string $key, mixed $value, array $attributes): }); } + /** + * Transform the attribute to its underlying model values. + */ public function set(Model $model, string $key, mixed $value, array $attributes): array { $encoded = $value !== null @@ -71,6 +80,19 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return [$key => $encoded]; } + /** + * Determine if the assigned object can be cached as the attribute value. + */ + public function shouldCacheObject(Model $model, string $key, object $value): bool + { + $enumClass = $this->arguments[0]; + + return $value instanceof Collection && $value->every(fn ($item) => $item instanceof $enumClass); + } + + /** + * Serialize the attribute for an array or JSON response. + */ public function serialize(Model $model, string $key, mixed $value, array $attributes): array { return (new Collection($value)) @@ -78,6 +100,9 @@ public function serialize(Model $model, string $key, mixed $value, array $attrib ->toArray(); } + /** + * Get the storable value of the given enum. + */ protected function getStorableEnumValue(mixed $enum): string|int { if (is_string($enum) || is_int($enum)) { diff --git a/src/database/src/Eloquent/Casts/AsFluent.php b/src/database/src/Eloquent/Casts/AsFluent.php index a8a4d3b350..a4f481949d 100644 --- a/src/database/src/Eloquent/Casts/AsFluent.php +++ b/src/database/src/Eloquent/Casts/AsFluent.php @@ -20,6 +20,9 @@ class AsFluent implements Castable public static function castUsing(array $arguments): CastsAttributes { return new class implements CastsAttributes { + /** + * Transform the attribute from the underlying model values. + */ public function get(Model $model, string $key, mixed $value, array $attributes): ?Fluent { if (! isset($value)) { @@ -32,6 +35,9 @@ public function get(Model $model, string $key, mixed $value, array $attributes): return is_array($data) || is_object($data) ? new Fluent($data) : null; } + /** + * Transform the attribute to its underlying model values. + */ public function set(Model $model, string $key, mixed $value, array $attributes): ?array { if (! isset($value)) { @@ -46,6 +52,14 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return [$key => $encoded]; } + + /** + * Determine if the assigned object can be cached as the attribute value. + */ + public function shouldCacheObject(Model $model, string $key, object $value): bool + { + return $value instanceof Fluent; + } }; } } diff --git a/src/database/src/Eloquent/Casts/AsStringable.php b/src/database/src/Eloquent/Casts/AsStringable.php index 1bac8bdce8..d5b7b9443d 100644 --- a/src/database/src/Eloquent/Casts/AsStringable.php +++ b/src/database/src/Eloquent/Casts/AsStringable.php @@ -35,6 +35,14 @@ public function set(Model $model, string $key, mixed $value, array $attributes): { return isset($value) ? (string) $value : null; } + + /** + * Determine if the assigned object can be cached as the attribute value. + */ + public function shouldCacheObject(Model $model, string $key, object $value): bool + { + return $value instanceof Stringable; + } }; } } diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index fd6da400d1..f9cde313ad 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -1261,9 +1261,12 @@ protected function setClassCastableAttribute(string $key, mixed $value): void // A custom caster may read the accessor while preparing the new stored value. unset($this->attributeCastCache[$key], $this->attributeCastStoredValues[$key]); + // A caster may decline an assigned object that differs from what it reads back, + // so the next read builds the value from the stored attributes instead. if ($caster instanceof CastsInboundAttributes || ! is_object($value) - || ($caster->withoutObjectCaching ?? false)) { + || ($caster->withoutObjectCaching ?? false) + || (method_exists($caster, 'shouldCacheObject') && ! $caster->shouldCacheObject($this, $key, $value))) { unset($this->classCastCache[$key], $this->classCastStoredValues[$key]); } else { $this->classCastCache[$key] = $value; diff --git a/src/docs/eloquent-mutators.md b/src/docs/eloquent-mutators.md index 984b94c33f..c58a399676 100644 --- a/src/docs/eloquent-mutators.md +++ b/src/docs/eloquent-mutators.md @@ -997,6 +997,18 @@ class AsAddress implements CastsAttributes } ``` +Objects you assign to the attribute are cached too, so the next read returns the same instance. If your cast accepts objects that it does not return, define a `shouldCacheObject` method on the cast class. When this method returns `false`, Eloquent builds the attribute from its stored value the next time it is read: + +```php +/** + * Determine if the assigned object can be cached as the attribute value. + */ +public function shouldCacheObject(Model $model, string $key, object $value): bool +{ + return $value instanceof Address; +} +``` + ### Array / JSON Serialization diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 1e9e3cbc68..a6b3d5e674 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -7,6 +7,7 @@ use ArgumentCountError; use ArrayObject as NativeArrayObject; use Carbon\CarbonInterface; +use Closure; use DateTime; use DateTimeImmutable; use DateTimeInterface; @@ -32,6 +33,7 @@ use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Casts\ArrayObject; use Hypervel\Database\Eloquent\Casts\AsArrayObject; +use Hypervel\Database\Eloquent\Casts\AsBinary; use Hypervel\Database\Eloquent\Casts\AsCollection; use Hypervel\Database\Eloquent\Casts\AsEncryptedArrayObject; use Hypervel\Database\Eloquent\Casts\AsEncryptedCollection; @@ -70,6 +72,7 @@ use Hypervel\Support\Fluent; use Hypervel\Support\HtmlString; use Hypervel\Support\InteractsWithTime; +use Hypervel\Support\LazyCollection; use Hypervel\Support\Stringable; use Hypervel\Support\Uri; use Hypervel\Testbench\TestCase; @@ -81,11 +84,13 @@ use LogicException; use Mockery as m; use PDO; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; use RuntimeException; use stdClass; use Stringable as NativeStringable; +use Symfony\Component\Uid\Uuid; use UnitEnum; use WeakMap; @@ -2289,6 +2294,104 @@ protected function settings(): Attribute $this->assertSame(['settings' => '{"theme":"dark","locale":"fr"}'], $model->getDirty()); } + #[DataProvider('assignedObjectsOutsideTheCastFormProvider')] + public function testAssignedObjectsOutsideTheCastFormAreReadInTheCastForm(string $cast, Closure $value, string $type, ?string $itemType): void + { + Crypt::swap(new ConcreteEncrypter(str_repeat('a', 32), 'AES-256-CBC')); + + $model = (new ModelStub)->mergeCasts(['settings' => $cast]); + $model->settings = $value(); + $model->getAttributes(); + + $this->assertSame($type, get_debug_type($model->settings)); + + if ($itemType !== null) { + foreach ($model->settings as $item) { + $this->assertSame($itemType, get_debug_type($item)); + } + } + + $this->assertArrayHasKey('settings', $model->toArray()); + } + + /** + * Get the casts with assigned objects they do not read back as. + */ + public static function assignedObjectsOutsideTheCastFormProvider(): array + { + return [ + 'array object from a collection' => [AsArrayObject::class, fn () => new BaseCollection(['theme' => 'dark']), ArrayObject::class, null], + 'encrypted array object from a collection' => [AsEncryptedArrayObject::class, fn () => new BaseCollection(['theme' => 'dark']), ArrayObject::class, null], + 'collection from a lazy collection' => [AsCollection::class, fn () => LazyCollection::make(['dark']), BaseCollection::class, null], + 'encrypted collection from a lazy collection' => [AsEncryptedCollection::class, fn () => LazyCollection::make(['dark']), BaseCollection::class, null], + 'custom collection from a base collection' => [AsCollection::using(CustomCollection::class), fn () => new BaseCollection(['dark']), CustomCollection::class, null], + 'mapped collection from arrays' => [AsCollection::of(Fluent::class), fn () => new BaseCollection([['theme' => 'dark']]), BaseCollection::class, Fluent::class], + 'enum collection from a generator' => [AsEnumCollection::of(StringStatus::class), fn () => (fn () => yield StringStatus::Draft)(), BaseCollection::class, StringStatus::class], + 'enum collection from backing values' => [AsEnumCollection::of(StringStatus::class), fn () => new BaseCollection(['draft']), BaseCollection::class, StringStatus::class], + 'enum array object from a collection' => [AsEnumArrayObject::of(StringStatus::class), fn () => new BaseCollection([StringStatus::Draft]), ArrayObject::class, StringStatus::class], + 'fluent from a plain object' => [AsFluent::class, fn () => (object) ['theme' => 'dark'], Fluent::class, null], + 'stringable from another stringable' => [AsStringable::class, fn () => new HtmlString('Taylor'), Stringable::class, null], + 'binary uuid from a uuid object' => [AsBinary::uuid(), fn () => Uuid::v4(), 'string', null], + ]; + } + + #[DataProvider('assignedObjectsInTheCastFormProvider')] + public function testAssignedObjectsInTheCastFormKeepTheirIdentity(string $cast, Closure $value, Closure $edit, string $stored): void + { + $model = (new ModelStub)->mergeCasts(['settings' => $cast]); + $model->settings = $object = $value(); + + $this->assertSame($object, $model->settings); + + $edit($object); + + $this->assertSame($stored, $model->getAttributes()['settings']); + } + + /** + * Get the casts with assigned objects they read back unchanged, and an edit to each object. + */ + public static function assignedObjectsInTheCastFormProvider(): array + { + return [ + 'array object' => [AsArrayObject::class, fn () => new ArrayObject(['theme' => 'dark']), fn (ArrayObject $object) => $object['theme'] = 'light', '{"theme":"light"}'], + 'collection' => [AsCollection::class, fn () => new BaseCollection(['dark']), fn (BaseCollection $collection) => $collection->push('light'), '["dark","light"]'], + 'mapped collection' => [AsCollection::of(Fluent::class), fn () => new BaseCollection([new Fluent(['theme' => 'dark'])]), fn (BaseCollection $collection) => $collection->first()->theme = 'light', '[{"theme":"light"}]'], + 'enum collection' => [AsEnumCollection::of(StringStatus::class), fn () => new BaseCollection([StringStatus::Draft]), fn (BaseCollection $collection) => $collection->push(StringStatus::Done), '["draft","done"]'], + 'fluent' => [AsFluent::class, fn () => new Fluent(['theme' => 'dark']), fn (Fluent $fluent) => $fluent->theme = 'light', '{"theme":"light"}'], + ]; + } + + public function testCallbackMappedCollectionIsCachedFromItsFirstRead(): void + { + $model = (new ModelStub)->mergeCasts(['settings' => AsCollection::of([Fluent::class, 'make'])]); + $model->settings = new BaseCollection([new Fluent(['theme' => 'dark'])]); + + $settings = $model->settings; + + $this->assertInstanceOf(Fluent::class, $settings->first()); + $this->assertSame($settings, $model->settings); + + $settings->first()->theme = 'light'; + + $this->assertSame('[{"theme":"light"}]', $model->getAttributes()['settings']); + } + + #[TestWith([AsArrayObject::class])] + #[TestWith([AsEncryptedArrayObject::class])] + #[TestWith([AsCollection::class])] + #[TestWith([AsEncryptedCollection::class])] + public function testGeneratorsAreStoredWithTheirItems(string $cast): void + { + Crypt::swap(new ConcreteEncrypter(str_repeat('a', 32), 'AES-256-CBC')); + + $model = (new ModelStub)->mergeCasts(['settings' => $cast]); + $model->settings = (fn () => yield 'theme' => 'dark')(); + $model->setRawAttributes($model->getAttributes()); + + $this->assertSame(['theme' => 'dark'], iterator_to_array($model->settings)); + } + public function testUnguardAllowsAnythingToBeSet(): void { $model = new ModelStub; diff --git a/types/Database/Eloquent/Casts/Castable.php b/types/Database/Eloquent/Casts/Castable.php index 9cd0cbc625..fe89266e3a 100644 --- a/types/Database/Eloquent/Casts/Castable.php +++ b/types/Database/Eloquent/Casts/Castable.php @@ -28,12 +28,12 @@ ); assertType( - 'Hypervel\Contracts\Database\Eloquent\CastsAttributes, iterable>', + 'Hypervel\Contracts\Database\Eloquent\CastsAttributes, iterable>', \Hypervel\Database\Eloquent\Casts\AsEnumArrayObject::castUsing([\UserType::class]), ); assertType( - 'Hypervel\Contracts\Database\Eloquent\CastsAttributes, iterable>', + 'Hypervel\Contracts\Database\Eloquent\CastsAttributes, iterable>', \Hypervel\Database\Eloquent\Casts\AsEnumCollection::castUsing([\UserType::class]), );