From 8990777b39721a3f2fd4e575f7fd63da58de09a5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:46:14 +0000 Subject: [PATCH 01/22] Clarify class imports for new and ported code Require imported short class names for fully and partially qualified references, with aliases for collisions and no redundant same-namespace imports. Retain the exception for clearer config-style identifier lists. List the class-import convention explicitly among approved porting adaptations so upstream style preservation does not override it. --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index c91c1205c..29e758ab8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,7 +175,7 @@ Build complete, long-term solutions, not MVPs or local workarounds. A broad chan - **Contract signature dependencies are lazy** — contract signatures may natively reference types from optional split packages without a reverse Composer dependency. Do not remove these types or add cyclic dependencies solely for split-package isolation. - **Newly written classes use dependency injection** — inject contracts (e.g. `Repository $config`, `CacheRepository $cache`) via constructor or method injection rather than helpers, facades, or `new` for framework services. Dependencies become explicit in signatures and tests swap them in directly, without facade-mocking machinery. Fall back to `Container::getInstance()->make(...)` only where injection isn't possible — static contexts and traits, like the testing package's Concerns. Helpers (`config()`, `cache()`) are fine in non-class contexts such as route and config files. - **Never convert ported code to dependency injection** — ported code keeps its upstream facade, helper, and instantiation style. Converting it restructures classes and breaks 1:1 upstream mergeability. -- **Import classes, don't use FQCNs** — always add a `use` statement and reference the short name. The only exceptions are places where FQCNs genuinely make more sense, such as middleware arrays and similar config-style identifier lists. +- **Use imported short class names** — applies to new and ported code. Replace fully or partially qualified class references with imported short names; use aliases for naming collisions. Classes in the current namespace need no import. Keep fully qualified names where genuinely clearer, such as middleware arrays and similar config-style identifier lists. - **Group traits in `Concerns/`** — follow the package's existing convention if it already has a `Concerns/` or `Traits/` directory; never mix both in one package. New Hypervel-original packages always use `Concerns/`; a newly ported package keeps its upstream directory name. - **Use Laravel observer conventions** — place Eloquent observers in a top-level `Observers/` directory. Register model-specific observers with `#[ObservedBy(...)]`; use `observe()` only for dynamic registration or observers supplied automatically by a reusable concern. - **Use attributed local scopes** — define local Eloquent query scopes as protected methods marked with `#[Scope]`, rather than legacy public `scopeFoo()` methods. Use separate scope classes only for genuine global scopes. @@ -756,6 +756,7 @@ Run full PHPStan checks with `composer analyse`. During implementation, use targ When porting Laravel packages, whether first-party or third-party, keep them as close to 1:1 with upstream as possible so future changes are easy to merge. The exceptions are: - Modernizing PHP types, including native parameter, return, property, and class-constant types, plus other appropriate PHP 8.4+ features, strict types, and strict comparisons +- Applying the class-import convention, including its exceptions - Converting mutable Laravel date construction to Hypervel's immutable date conventions, typing configurable factory output as `CarbonInterface`, and capturing date-modifier return values - Converting container array access (`$app['events']`) and dynamic service-property access (`$app->events`) in ported code to named container methods, and applying the Configuration rules to ported config and its consumers - Adding Laravel-style title docblocks to methods (not classes — see Development Conventions) From 29e238d95bbc7ce639e34702452daf9bec73a495 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:08:17 +0000 Subject: [PATCH 02/22] Document database query listener callback types and lifetime Port Laravel framework PR #57633 using the current 13.x implementation at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2: https://github.com/laravel/framework/pull/57633 Describe the QueryExecuted argument supplied to Connection::listen callbacks. Complete the upstream callable annotation with an explicit mixed return type, which PHPStan requires and which preserves arbitrary callback results, including false to stop event propagation. Document that registration persists on the worker-global dispatcher and belongs at boot. Use the existing QueryExecuted import as required by the porting convention. Native signatures and runtime behavior remain unchanged. Validation: scoped PHP-CS-Fixer, full source and type-fixture PHPStan analysis, and scoped ParaTest for DatabaseConnectionTest and DatabaseIntegrationTest passed (97 tests, 419 assertions). The upstream PR changes no tests. Independently reviewed and signed off by claude-laravel-parity. --- src/database/src/Connection.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 167c6c5ee..e4ca35ec4 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -1027,10 +1027,15 @@ public function getErrorCount(): int /** * Register a database query listener with the connection. + * + * Boot-only. Registers a listener on the worker-global event dispatcher; + * per-request registration persists and affects subsequent requests. + * + * @param Closure(QueryExecuted): mixed $callback */ public function listen(Closure $callback): void { - $this->events?->listen(Events\QueryExecuted::class, $callback); + $this->events?->listen(QueryExecuted::class, $callback); } /** From 732500a627a0f0c17f03d393eced101147bc1a8e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:59:14 +0000 Subject: [PATCH 03/22] Exempt test methods from required title docblocks Clarify the approved exception for test-prefixed methods in test classes. Fixture and helper methods remain covered by the existing method documentation rule. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 29e758ab8..6d02e28b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,7 +179,7 @@ Build complete, long-term solutions, not MVPs or local workarounds. A broad chan - **Group traits in `Concerns/`** — follow the package's existing convention if it already has a `Concerns/` or `Traits/` directory; never mix both in one package. New Hypervel-original packages always use `Concerns/`; a newly ported package keeps its upstream directory name. - **Use Laravel observer conventions** — place Eloquent observers in a top-level `Observers/` directory. Register model-specific observers with `#[ObservedBy(...)]`; use `observe()` only for dynamic registration or observers supplied automatically by a reusable concern. - **Use attributed local scopes** — define local Eloquent query scopes as protected methods marked with `#[Scope]`, rather than legacy public `scopeFoo()` methods. Use separate scope classes only for genuine global scopes. -- **No class docblocks unless warranted** — only add a class-level docblock if something unusual or complex needs explanation: purpose, architectural role, usage patterns. Never write one that inventories the class — trait lists, method summaries, "registers X, configures Y" — that duplicates the members' own docblocks and goes stale. Method docblocks (title only, Laravel-style, imperative mood: "Return", not "Returns") are always added. A body can accompany the title for complex methods that need further explanation. +- **No class docblocks unless warranted** — only add a class-level docblock if something unusual or complex needs explanation: purpose, architectural role, usage patterns. Never write one that inventories the class — trait lists, method summaries, "registers X, configures Y" — that duplicates the members' own docblocks and goes stale. Method docblocks (title only, Laravel-style, imperative mood: "Return", not "Returns") are always added, except on `test*` methods in test classes. A body can accompany the title for complex methods that need further explanation. - **Add comments only where they're genuinely useful** — a short WHY for logic that isn't obvious from reading the code, the reason behind a bug fix, or logic that's coupled to code in other files and hard to understand in isolation. Avoid unnecessary code comments; don't comment what the code does, and don't annotate framework divergences, routine casts, or type normalizations. - **Don't make classes final by default** — keep classes open; add `final` only when it protects a real invariant or avoids a concrete framework/API problem, e.g. immutability, coroutine-safety, or a security guarantee. - **Place methods logically, not at the end** — group new methods with related ones (getters with getters, setters with setters). Two exceptions: preserve upstream order when merging ported code (see Porting rules), and `flushState()` has its own placement rule (see Static state and test cleanup). From 77aff7f7287898e12306ac911a4419034d410ad8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:00:02 +0000 Subject: [PATCH 04/22] Complete queued notification routing test parity Hypervel already preserves notification connection and queue defaults when channel maps omit an entry, as required by Laravel #57625. Complete parity with current 13.x by requiring exactly two container accesses in each of its three two-channel cases, incorporating those later assertions from #61117 without claiming the rest of that PR is ported. Retain all six channel, connection, and queue payload predicates. Apply native SendQueuedNotifications callback types, bool returns, and precise fixture signatures with method-title documentation. Preserve Hypervel event-listener registration at provider boot. This changes tests only and adds no production runtime overhead. Porting source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Upstream: https://github.com/laravel/framework/pull/57625 Partially incorporated: https://github.com/laravel/framework/pull/61117 Validation: NotificationSenderTest passes with 26 tests and 76 assertions; scoped formatting and git diff --check pass. Full source analysis was already clean at the unchanged source revision. Reviewed and signed off by claude-laravel-parity. --- .../Notifications/NotificationSenderTest.php | 51 +++++++++++++------ 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/tests/Notifications/NotificationSenderTest.php b/tests/Notifications/NotificationSenderTest.php index 6fc4bc349..7676d99be 100644 --- a/tests/Notifications/NotificationSenderTest.php +++ b/tests/Notifications/NotificationSenderTest.php @@ -19,6 +19,7 @@ use Hypervel\Notifications\Notifiable; use Hypervel\Notifications\Notification; use Hypervel\Notifications\NotificationSender; +use Hypervel\Notifications\SendQueuedNotifications; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Attributes\Queue; use Hypervel\Tests\TestCase; @@ -230,16 +231,16 @@ public function testItCanSendQueuedNotificationsWithAnArrayVia(): void { $notifiable = m::mock(Notifiable::class); $manager = m::mock(ChannelManager::class); - $manager->shouldReceive('getContainer')->andReturn(app()); + $manager->shouldReceive('getContainer')->twice()->andReturn(app()); $bus = m::mock(BusDispatcherContract::class); $bus->shouldReceive('dispatch') ->once() - ->withArgs(function ($job) { + ->withArgs(function (SendQueuedNotifications $job): bool { return $job->queue === 'dummy' && $job->channels === ['database'] && $job->connection === 'redis'; }); $bus->shouldReceive('dispatch') ->once() - ->withArgs(function ($job) { + ->withArgs(function (SendQueuedNotifications $job): bool { return $job->queue === 'dummy' && $job->channels === ['mail'] && $job->connection === 'redis'; }); @@ -331,16 +332,16 @@ public function testItCanSendQueuedWithViaConnectionsNotifications(): void { $notifiable = new AnonymousNotifiable; $manager = m::mock(ChannelManager::class); - $manager->shouldReceive('getContainer')->andReturn(app()); + $manager->shouldReceive('getContainer')->twice()->andReturn(app()); $bus = m::mock(BusDispatcherContract::class); $bus->shouldReceive('dispatch') ->once() - ->withArgs(function ($job) { + ->withArgs(function (SendQueuedNotifications $job): bool { return $job->connection === 'sync' && $job->channels === ['database'] && $job->queue === 'dummy'; }); $bus->shouldReceive('dispatch') ->once() - ->withArgs(function ($job) { + ->withArgs(function (SendQueuedNotifications $job): bool { return $job->connection === 'redis' && $job->channels === ['mail'] && $job->queue === 'dummy'; }); @@ -355,16 +356,16 @@ public function testItCanSendQueuedWithViaQueuesNotifications(): void { $notifiable = new AnonymousNotifiable; $manager = m::mock(ChannelManager::class); - $manager->shouldReceive('getContainer')->andReturn(app()); + $manager->shouldReceive('getContainer')->twice()->andReturn(app()); $bus = m::mock(BusDispatcherContract::class); $bus->shouldReceive('dispatch') ->once() - ->withArgs(function ($job) { + ->withArgs(function (SendQueuedNotifications $job): bool { return $job->queue === 'dummy' && $job->channels === ['database'] && $job->connection === 'redis'; }); $bus->shouldReceive('dispatch') ->once() - ->withArgs(function ($job) { + ->withArgs(function (SendQueuedNotifications $job): bool { return $job->queue === 'admin_notifications' && $job->channels === ['mail'] && $job->connection === 'redis'; }); @@ -709,6 +710,9 @@ class DummyQueuedNotificationWithArrayVia extends Notification implements Should { use Queueable; + /** + * Create a new notification instance. + */ public function __construct() { $this->connection = 'redis'; @@ -717,9 +721,8 @@ public function __construct() /** * Get the notification channels. - * @param mixed $notifiable */ - public function via($notifiable) + public function via(mixed $notifiable): array { return ['mail', 'database']; } @@ -777,18 +780,27 @@ class DummyNotificationWithViaConnections extends Notification implements Should { use Queueable; + /** + * Create a new notification instance. + */ public function __construct() { $this->connection = 'redis'; $this->queue = 'dummy'; } - public function via($notifiable) + /** + * Get the notification channels. + */ + public function via(mixed $notifiable): array { return ['mail', 'database']; } - public function viaConnections() + /** + * Determine which connections should be used for each notification channel. + */ + public function viaConnections(): array { return [ 'database' => 'sync', @@ -800,18 +812,27 @@ class DummyNotificationWithViaQueues extends Notification implements ShouldQueue { use Queueable; + /** + * Create a new notification instance. + */ public function __construct() { $this->connection = 'redis'; $this->queue = 'dummy'; } - public function via($notifiable) + /** + * Get the notification channels. + */ + public function via(mixed $notifiable): array { return ['mail', 'database']; } - public function viaQueues() + /** + * Determine which queues should be used for each notification channel. + */ + public function viaQueues(): array { return [ 'mail' => 'admin_notifications', From 494aeff6a9b3885810ce8142767b37048942cfb1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:19:22 +0000 Subject: [PATCH 05/22] Run complete Queue and Cache integration suites against Redis Port the remaining CI coverage from Laravel framework PR #57641. The asynchronous chaining assertions and conditional Redis test lifecycle were already present, but four queue suites forced the database driver and the workflow selected only two driver-neutral files. Honor QUEUE_CONNECTION in the debounce, listener, unique-job, and worker test environments while retaining database for unconfigured local runs. Restore applicable current upstream debounce driver conditions and assert chain emptiness through the selected queue, including delayed and reserved Redis jobs, instead of inspecting the database jobs table. Run the entire Queue directory with Redis selected on Redis 8, Redis Cluster, and Valkey 9. Also adopt the current Laravel workflow's whole Cache directory selection, as requested, with CACHE_STORE=redis. Preserve Hypervel's capped standalone parallelism and serial Cluster isolation; the nested Redis test directories are discovered once. No production source or public API changes are needed. Upstream: https://github.com/laravel/framework/pull/57641 Current debounce test conditions: https://github.com/laravel/framework/pull/59507 Redis workflow pattern: https://github.com/laravel/framework/pull/57710 Porting source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Later PR references identify incorporated behavior, not full dispositions. Validation: each changed test file passes database, Redis, and applicable sync cases. Complete Queue suite passes standalone and Cluster with 277 tests and 1,254 assertions each; Cache passes with 569 tests and 2,603 / 2,614 assertions. Scoped formatting, YAML parsing, shell syntax, and diff checks pass. Local runs use PHP 8.4 and Redis 8.8; Valkey and PHP 8.5 remain covered by CI configuration rather than these local runs. Reviewed and signed off by claude-laravel-parity. --- .github/workflows/redis.yml | 18 ++++------ tests/Integration/Queue/DebouncedJobTest.php | 33 +++++++++++++++++-- .../Queue/DebouncedListenerTest.php | 5 ++- tests/Integration/Queue/UniqueJobTest.php | 5 ++- tests/Integration/Queue/WorkCommandTest.php | 5 ++- 5 files changed, 49 insertions(+), 17 deletions(-) diff --git a/.github/workflows/redis.yml b/.github/workflows/redis.yml index 067c60d0a..3f0f19060 100644 --- a/.github/workflows/redis.yml +++ b/.github/workflows/redis.yml @@ -69,13 +69,11 @@ jobs: run: | vendor/bin/paratest --max-processes=15 tests/Integration/Auth/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Broadcasting/Redis - vendor/bin/paratest --max-processes=15 tests/Integration/Cache/Redis + CACHE_STORE=redis vendor/bin/paratest --max-processes=15 tests/Integration/Cache vendor/bin/paratest --max-processes=15 tests/Integration/Horizon vendor/bin/paratest --max-processes=15 tests/Integration/Http/Redis vendor/bin/paratest --max-processes=15 tests/Integration/OpenTelemetry/Redis - vendor/bin/paratest --max-processes=15 tests/Integration/Queue/Redis - QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobChainingTest.php - QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobDispatchingTest.php + QUEUE_CONNECTION=redis vendor/bin/paratest --max-processes=15 tests/Integration/Queue vendor/bin/paratest --max-processes=15 tests/Integration/RateLimiter/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Session/Redis @@ -225,13 +223,11 @@ jobs: run: | vendor/bin/phpunit --no-progress tests/Integration/Auth/Redis vendor/bin/phpunit --no-progress tests/Integration/Broadcasting/Redis - vendor/bin/phpunit --no-progress tests/Integration/Cache/Redis + CACHE_STORE=redis vendor/bin/phpunit --no-progress tests/Integration/Cache vendor/bin/phpunit --no-progress tests/Integration/Horizon vendor/bin/phpunit --no-progress tests/Integration/Http/Redis vendor/bin/phpunit --no-progress tests/Integration/OpenTelemetry/Redis - vendor/bin/phpunit --no-progress tests/Integration/Queue/Redis - QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobChainingTest.php - QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobDispatchingTest.php + QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue vendor/bin/phpunit --no-progress tests/Integration/RateLimiter/Redis vendor/bin/phpunit --no-progress tests/Integration/Session/Redis # This explicit list contains the topology-neutral Redis tests; the remaining files require standalone-only behavior. @@ -306,13 +302,11 @@ jobs: run: | vendor/bin/paratest --max-processes=15 tests/Integration/Auth/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Broadcasting/Redis - vendor/bin/paratest --max-processes=15 tests/Integration/Cache/Redis + CACHE_STORE=redis vendor/bin/paratest --max-processes=15 tests/Integration/Cache vendor/bin/paratest --max-processes=15 tests/Integration/Horizon vendor/bin/paratest --max-processes=15 tests/Integration/Http/Redis vendor/bin/paratest --max-processes=15 tests/Integration/OpenTelemetry/Redis - vendor/bin/paratest --max-processes=15 tests/Integration/Queue/Redis - QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobChainingTest.php - QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobDispatchingTest.php + QUEUE_CONNECTION=redis vendor/bin/paratest --max-processes=15 tests/Integration/Queue vendor/bin/paratest --max-processes=15 tests/Integration/RateLimiter/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Redis vendor/bin/phpunit --no-progress tests/Integration/Reverb/ClearStateCommandTest.php diff --git a/tests/Integration/Queue/DebouncedJobTest.php b/tests/Integration/Queue/DebouncedJobTest.php index ecfa1b2a7..f5601f22a 100644 --- a/tests/Integration/Queue/DebouncedJobTest.php +++ b/tests/Integration/Queue/DebouncedJobTest.php @@ -32,17 +32,22 @@ #[WithMigration('queue')] class DebouncedJobTest extends QueueTestCase { + /** + * Define the test environment. + */ protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); $config = $app->make('config'); $config->set('cache.default', 'database'); - $config->set('queue.default', 'database'); + $config->set('queue.default', env('QUEUE_CONNECTION', 'database')); } public function testDebouncedJobDispatchesAndExecutes(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']); + DebouncedTestJob::resetState(); dispatch(new DebouncedTestJob('entity-1')); @@ -54,6 +59,8 @@ public function testDebouncedJobDispatchesAndExecutes(): void public function testSupersededDebouncedJobIsSkipped(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']); + DebouncedTestJob::resetState(); dispatch(new DebouncedTestJob('entity-1')); @@ -67,6 +74,8 @@ public function testSupersededDebouncedJobIsSkipped(): void public function testTokenPersistsAfterSuccessfulExecution(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']); + DebouncedTestJob::resetState(); dispatch($job = new DebouncedTestJob('entity-1')); @@ -92,6 +101,8 @@ public function testFailedDebouncedJobStillCallsHandler(): void public function testJobDebouncedEventFiresForSupersededJob(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']); + $firedCount = 0; Event::listen(JobDebounced::class, function () use (&$firedCount): void { @@ -148,6 +159,8 @@ public function testDebounceOwnerSurvivesSerialization(): void public function testDifferentDebounceIdsDoNotInterfere(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']); + DebouncedTestJob::resetState(); dispatch(new DebouncedTestJob('entity-1')); @@ -195,6 +208,8 @@ public function testBusFakeRetainsDebounceMaximumWaitState(): void public function testJobExecutesWhenCacheTokenIsEvicted(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']); + DebouncedTestJob::resetState(); dispatch($job = new DebouncedTestJob('entity-1')); @@ -246,6 +261,8 @@ public function testReleaseClearsMaxWaitTimestamp(): void public function testSupersededDebouncedJobDoesNotDispatchChain(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']); + DebouncedTestJob::resetState(); ChainReceiverJob::resetState(); @@ -257,11 +274,13 @@ public function testSupersededDebouncedJobDoesNotDispatchChain(): void $this->assertSame(1, DebouncedTestJob::$handleCount); $this->assertFalse(ChainReceiverJob::$handled); - $this->assertDatabaseCount('jobs', 0); + $this->assertSame(0, Queue::size()); } public function testDebounceViaUsesCustomCacheStore(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']); + DebouncedWithCustomCacheJob::resetState(); dispatch(new DebouncedWithCustomCacheJob('entity-1')); @@ -274,6 +293,8 @@ public function testDebounceViaUsesCustomCacheStore(): void public function testMaxDebounceWaitForcesImmediateExecution(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']); + DebouncedWithMaxWaitJob::resetState(); dispatch(new DebouncedWithMaxWaitJob('entity-1')); @@ -291,6 +312,8 @@ public function testMaxDebounceWaitForcesImmediateExecution(): void public function testMaxDebounceWaitStartsOverAfterTheDebouncedJobRuns(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']); + DebouncedWithMaxWaitJob::resetState(); dispatch(new DebouncedWithMaxWaitJob('entity-1')); @@ -311,6 +334,8 @@ public function testMaxDebounceWaitStartsOverAfterTheDebouncedJobRuns(): void public function testMaxDebounceWaitIsNotReleasedWhenMiddlewareReleasesTheJob(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']); + dispatch(new DebouncedWithReleasingMiddlewareJob('entity-1')); $this->travelDebounceTo(CarbonImmutable::now()->addSeconds(31)); @@ -327,6 +352,8 @@ public function testMaxDebounceWaitIsNotReleasedWhenMiddlewareReleasesTheJob(): public function testDebounceWithoutMaxWaitAllowsIndefiniteDelay(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']); + $job1 = new DebouncedTestJob('entity-1'); $pending = dispatch($job1); unset($pending); @@ -350,6 +377,8 @@ public function testDebounceLockReadsMaxWaitFromAttribute(): void public function testChildDebouncedJobInheritsFromParent(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']); + ChildOfDebouncedTestJob::resetState(); dispatch(new ChildOfDebouncedTestJob('entity-1')); diff --git a/tests/Integration/Queue/DebouncedListenerTest.php b/tests/Integration/Queue/DebouncedListenerTest.php index 3ba4ec013..96ed9a97e 100644 --- a/tests/Integration/Queue/DebouncedListenerTest.php +++ b/tests/Integration/Queue/DebouncedListenerTest.php @@ -17,13 +17,16 @@ #[WithMigration('queue')] class DebouncedListenerTest extends QueueTestCase { + /** + * Define the test environment. + */ protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); $config = $app->make('config'); $config->set('cache.default', 'database'); - $config->set('queue.default', 'database'); + $config->set('queue.default', env('QUEUE_CONNECTION', 'database')); } public function testSupersededDebouncedListenerIsSkipped(): void diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php index 8c3df090f..6e0e9931a 100644 --- a/tests/Integration/Queue/UniqueJobTest.php +++ b/tests/Integration/Queue/UniqueJobTest.php @@ -29,13 +29,16 @@ #[WithMigration('queue')] class UniqueJobTest extends QueueTestCase { + /** + * Define the test environment. + */ protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); $config = $app->make('config'); $config->set('cache.default', 'database'); - $config->set('queue.default', 'database'); + $config->set('queue.default', env('QUEUE_CONNECTION', 'database')); } public function testUniqueJobsAreNotDispatched() diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php index 4edbbbf5d..1135a54e3 100644 --- a/tests/Integration/Queue/WorkCommandTest.php +++ b/tests/Integration/Queue/WorkCommandTest.php @@ -28,11 +28,14 @@ class WorkCommandTest extends QueueTestCase { use DatabaseMigrations; + /** + * Define the test environment. + */ protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); - $app->make('config')->set('queue.default', 'database'); + $app->make('config')->set('queue.default', env('QUEUE_CONNECTION', 'database')); } protected function setUp(): void From e21a4104bbedb011830375d7e58e4f2376f3b002 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:22:36 +0000 Subject: [PATCH 06/22] Complete Laravel file validation parity for Symfony files and uploads Port the current Laravel 13.x behavior associated with: https://github.com/laravel/framework/pull/57656 Source revision: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 The custom File-rule message guard already existed, but its file-type classification recognized only Hypervel uploads. Symfony files and uploads therefore selected string-size messages or the generic File-rule error. Recognize Symfony File consistently across message selection, required checks, size calculation, and file validation. Complete the earlier Hyperf-to-HttpFoundation migration: Symfony uploads must receive the same failed-upload checks and PHP client-filename checks as Hypervel's subclass. Update both compiled and delegated validation while preserving the optimized size-comparison and presence-preflight paths. Restore upstream File fixtures and all intended SVG File/upload and MIME cases. Correct upstream SVG constructor arguments and ineffective MIME fixtures, retain existing assertions, and add focused coverage for the reproduced message, invalid-upload, and PHP-filename failures. Incorporate the current failed-upload test call counts from part of: https://github.com/laravel/framework/pull/61117 The remainder of that PR is still tracked for its own parity review. Correct the maximum-image-dimensions example from "at least" to "at most"; the same wording defect is present in Laravel docs at 2914ba0b06c6be40c2f1f992555853f6266707d6. Validation: all four edited test files pass individually. Validation unit ParaTest passes 1682 tests / 5812 assertions. Integration validation with SQLite passes 263 cases / 271 assertions, with 167 other-driver skips. Full source/type PHPStan, scoped PHP-CS-Fixer, and diff checks pass. Peer review: claude-laravel-parity signed off the complete nine-file diff. --- src/docs/validation.md | 2 +- .../src/Concerns/FormatsMessages.php | 8 +- .../src/Concerns/ValidatesAttributes.php | 9 +- src/validation/src/PlanExecutor.php | 6 +- src/validation/src/Validator.php | 2 +- .../Validation/Rules/FileValidationTest.php | 16 +- .../ValidationCompiledExecutionTest.php | 28 ++- .../Validation/ValidationPlanExecutorTest.php | 7 +- tests/Validation/ValidationValidatorTest.php | 200 ++++++++++++------ 9 files changed, 186 insertions(+), 92 deletions(-) diff --git a/src/docs/validation.md b/src/docs/validation.md index 34f293fa9..9addbe387 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -3156,7 +3156,7 @@ Validator::validate($input, [ #### Validating Image Dimensions -You may also validate the dimensions of an image. For example, to validate that an uploaded image is at least 1000 pixels wide and 500 pixels tall, you may use the `dimensions` rule: +You may also validate the dimensions of an image. For example, to validate that an uploaded image is at most 1000 pixels wide and 500 pixels tall, you may use the `dimensions` rule: ```php use Hypervel\Validation\Rule; diff --git a/src/validation/src/Concerns/FormatsMessages.php b/src/validation/src/Concerns/FormatsMessages.php index 5be34cb2e..b1926bfaf 100644 --- a/src/validation/src/Concerns/FormatsMessages.php +++ b/src/validation/src/Concerns/FormatsMessages.php @@ -6,10 +6,10 @@ use Closure; use Hypervel\Contracts\Validation\Validator; -use Hypervel\Http\UploadedFile; use Hypervel\Support\Arr; use Hypervel\Support\Number; use Hypervel\Support\Str; +use Symfony\Component\HttpFoundation\File\File; trait FormatsMessages { @@ -212,13 +212,11 @@ protected function getSizeMessage(string $attribute, string $rule): string */ protected function getAttributeType(string $attribute): string { - // We assume that the attributes present in the file array are files so that - // means that if the attribute does not have a numeric rule and the files - // list doesn't have it we'll just consider it a string by elimination. + // Keep file-message selection consistent with sizeOf(). return match (true) { $this->hasRule($attribute, $this->numericRules) => 'numeric', $this->hasRule($attribute, ['Array', 'List']) => 'array', - $this->getValue($attribute) instanceof UploadedFile => 'file', + $this->getValue($attribute) instanceof File => 'file', default => 'string', }; } diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php index 77a7b7fc4..e2b968e00 100644 --- a/src/validation/src/Concerns/ValidatesAttributes.php +++ b/src/validation/src/Concerns/ValidatesAttributes.php @@ -19,7 +19,6 @@ use Exception; use Hypervel\Container\Container; use Hypervel\Database\Eloquent\Model; -use Hypervel\Http\UploadedFile; use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\Exceptions\MathException; @@ -32,9 +31,9 @@ use Hypervel\Validation\ValidationData; use Hypervel\Validation\ValidationRuleParser; use InvalidArgumentException; -use SplFileInfo; use Stringable; use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\File\UploadedFile; use ValueError; use function with; @@ -2013,7 +2012,7 @@ public function validateRequired(string $attribute, mixed $value): bool if (is_countable($value) && count($value) < 1) { return false; } - if ($value instanceof SplFileInfo) { + if ($value instanceof File) { return (string) $value->getPath() !== ''; } @@ -2565,7 +2564,7 @@ protected function sizeOf(string $attribute, mixed $value, bool $numeric): float return count($value); } - if ($value instanceof SplFileInfo) { + if ($value instanceof File) { return $value->getSize() / 1024; } @@ -2581,7 +2580,7 @@ public function isValidFileInstance(mixed $value): bool return false; } - return $value instanceof SplFileInfo; + return $value instanceof File; } /** diff --git a/src/validation/src/PlanExecutor.php b/src/validation/src/PlanExecutor.php index 47d12e122..a635677f1 100644 --- a/src/validation/src/PlanExecutor.php +++ b/src/validation/src/PlanExecutor.php @@ -6,14 +6,14 @@ use Brick\Math\BigNumber; use Brick\Math\Exception\MathException as BrickMathException; -use Hypervel\Http\UploadedFile; use Hypervel\Support\Arr; use Hypervel\Support\Exceptions\MathException; use Hypervel\Support\Str; use Hypervel\Validation\Enums\CheckType; use InvalidArgumentException; -use SplFileInfo; use Stringable; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\File\UploadedFile; /** * Execute compiled AttributePlans against validation data. @@ -433,7 +433,7 @@ private function compareSizeBetween( */ private function usesNativeSizeComparison(mixed $value, bool $numeric): bool { - return ! ($numeric && is_numeric($value)) && ! $value instanceof SplFileInfo; + return ! ($numeric && is_numeric($value)) && ! $value instanceof File; } /** diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index a2ac37e98..85d168e38 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -14,7 +14,6 @@ use Hypervel\Contracts\Validation\Rule as RuleContract; use Hypervel\Contracts\Validation\Validator as ValidatorContract; use Hypervel\Contracts\Validation\ValidatorAwareRule; -use Hypervel\Http\UploadedFile; use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\Fluent; @@ -26,6 +25,7 @@ use LogicException; use RuntimeException; use stdClass; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Throwable; use ValueError; diff --git a/tests/Integration/Validation/Rules/FileValidationTest.php b/tests/Integration/Validation/Rules/FileValidationTest.php index 8092a82d0..a30ac6b88 100644 --- a/tests/Integration/Validation/Rules/FileValidationTest.php +++ b/tests/Integration/Validation/Rules/FileValidationTest.php @@ -10,6 +10,8 @@ use Hypervel\Validation\Rule; use Hypervel\Validation\Rules\File; use PHPUnit\Framework\Attributes\TestWith; +use Symfony\Component\HttpFoundation\File\File as SymfonyFile; +use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile; class FileValidationTest extends TestCase { @@ -55,11 +57,21 @@ public function testItCanValidateAttributeAsArrayWhenValidationShouldFails(strin ], $validator->messages()->all()); } - public function testFileCustomValidationMessages() + #[TestWith([UploadedFile::class])] + #[TestWith([SymfonyUploadedFile::class])] + #[TestWith([SymfonyFile::class])] + public function testFileCustomValidationMessages(string $fileClass): void { + $upload = UploadedFile::fake()->createWithContent('photo', str_repeat('x', 1000 * 1024)); + $file = match ($fileClass) { + UploadedFile::class => $upload, + SymfonyUploadedFile::class => new SymfonyUploadedFile($upload->getPathname(), 'photo', test: true), + SymfonyFile::class => new SymfonyFile($upload->getPathname()), + }; + $validator = Validator::make( [ - 'one' => UploadedFile::fake()->create('photo', 1000), + 'one' => $file, 'two' => 'not-a-file', ], [ diff --git a/tests/Validation/ValidationCompiledExecutionTest.php b/tests/Validation/ValidationCompiledExecutionTest.php index 7158ca991..7d27fc242 100644 --- a/tests/Validation/ValidationCompiledExecutionTest.php +++ b/tests/Validation/ValidationCompiledExecutionTest.php @@ -16,10 +16,12 @@ use Hypervel\Validation\Rule; use Hypervel\Validation\Validator; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionProperty; -use SplFileInfo; use stdClass; use Stringable; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile; class ValidationCompiledExecutionTest extends TestCase { @@ -73,7 +75,7 @@ public function testRuntimeDispatchedSizeRulesMatchCompiledAndDelegatedExecution [['value' => '2.00'], ['value' => 'max:3|decimal:2'], true], [['value' => '2.00'], ['value' => 'string|numeric|max:3'], true], [['value' => [1, 2, 3]], ['value' => 'between:2,3'], true], - [['value' => new SplFileInfo(__FILE__)], ['value' => 'file|min:0|max:1000'], true], + [['value' => new File(__FILE__)], ['value' => 'file|min:0|max:1000'], true], [['value' => 'abc'], ['value' => 'size:3.0000000000000000001'], false], ]; @@ -691,20 +693,28 @@ public function getMultiCount(string $collection, string $column, array $values, $this->assertTrue($v->passes()); } - public function testInvalidUploadedFileProducesUploadedError() + #[TestWith([UploadedFile::class, UPLOAD_ERR_INI_SIZE, ''])] + #[TestWith([SymfonyUploadedFile::class, UPLOAD_ERR_INI_SIZE, ''])] + #[TestWith([UploadedFile::class, UPLOAD_ERR_PARTIAL, __DIR__ . '/Fixtures/image.png'])] + #[TestWith([SymfonyUploadedFile::class, UPLOAD_ERR_PARTIAL, __DIR__ . '/Fixtures/image.png'])] + public function testInvalidUploadedFileProducesUploadedError(string $fileClass, int $error, string $path): void { - $file = new UploadedFile( - path: '', + $file = new $fileClass( + path: $path, originalName: 'test.jpg', mimeType: 'image/jpeg', - error: UPLOAD_ERR_INI_SIZE, + error: $error, test: true, ); - $v = $this->makeValidator(['file' => $file], ['file' => 'required|image']); - $v->passes(); + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + foreach (['required|image', 'max:1000'] as $rules) { + $v = $this->makeValidator(['file' => $file], ['file' => $rules], validatorClass: $validatorClass); - $this->assertTrue($v->errors()->has('file')); + $this->assertFalse($v->passes()); + $this->assertSame(['validation.uploaded'], $v->errors()->get('file')); + } + } } public function testExcludeAttributesResetAcrossValidatorReuse() diff --git a/tests/Validation/ValidationPlanExecutorTest.php b/tests/Validation/ValidationPlanExecutorTest.php index d3a72932d..6db1e6752 100644 --- a/tests/Validation/ValidationPlanExecutorTest.php +++ b/tests/Validation/ValidationPlanExecutorTest.php @@ -12,8 +12,8 @@ use Hypervel\Validation\InlineCheck; use Hypervel\Validation\RulePlan\ExposedExecutorValidator; use PHPUnit\Framework\Attributes\DataProvider; -use SplFileInfo; use Stringable; +use Symfony\Component\HttpFoundation\File\File; class ValidationPlanExecutorTest extends TestCase { @@ -182,9 +182,12 @@ public function __toString(): string return 'value'; } }; - $file = new class(__FILE__) extends SplFileInfo { + $file = new class(__FILE__, false) extends File { public int $reads = 0; + /** + * Get the file size and record the read. + */ public function getSize(): int|false { ++$this->reads; diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index af2ddf6ed..4ca8538a5 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -41,10 +41,13 @@ use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionProperty; use RuntimeException; use SplFileInfo; use stdClass; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile; use UnitEnum; class ValidationValidatorTest extends TestCase @@ -1104,7 +1107,7 @@ public function testCustomValidationLinesAreRespected() $this->assertSame('really required!', $v->messages()->first('name')); } - public function testCustomValidationLinesForSizeRules() + public function testCustomValidationLinesForSizeRules(): void { $trans = $this->getArrayTranslator(); $trans->getLoader()->addMessages('en', 'validation', [ @@ -1123,10 +1126,15 @@ public function testCustomValidationLinesForSizeRules() $this->assertFalse($v->passes()); $this->assertSame('Custom message for image filenames.', $v->messages()->first('image')); - $file = new UploadedFile(__FILE__, ''); - $v = new Validator($trans, ['image' => $file], ['image' => 'gte:50']); - $this->assertFalse($v->passes()); - $this->assertSame('Custom message for image files.', $v->messages()->first('image')); + foreach ([ + new UploadedFile(__FILE__, '', test: true), + new SymfonyUploadedFile(__FILE__, '', test: true), + new File(__FILE__), + ] as $file) { + $v = new Validator($trans, ['image' => $file], ['image' => 'gte:50']); + $this->assertFalse($v->passes()); + $this->assertSame('Custom message for image files.', $v->messages()->first('image')); + } } public function testCustomValidationLinesAreRespectedWithAsterisks() @@ -1204,7 +1212,7 @@ public function testValidationDotCustomDotAnythingCanBeTranslated() $this->assertSame('should be integer!', $v->messages()->first('validation.custom.1')); } - public function testInlineValidationMessagesAreRespected() + public function testInlineValidationMessagesAreRespected(): void { $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['name' => ''], ['name' => 'Required'], ['name.required' => 'require it please!']); @@ -1223,6 +1231,21 @@ public function testInlineValidationMessagesAreRespected() $this->assertFalse($v->passes()); $v->messages()->setFormat(':message'); $this->assertSame('name should be of length 9', $v->messages()->first('name')); + + foreach ([ + $this->uploadedFile(__FILE__, '', isValid: true, size: 4072), + new SymfonyUploadedFile(__FILE__, '', test: true), + new File(__FILE__), + ] as $file) { + $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:3'], [ + 'max' => [ + 'file' => ':attribute must not exceed :max kilobytes.', + 'string' => ':attribute must not exceed :max characters.', + ], + ]); + $this->assertFalse($v->passes()); + $this->assertSame('photo must not exceed 3 kilobytes.', $v->messages()->first('photo')); + } } #[DataProvider('integerMessageParameterCases')] @@ -1338,25 +1361,25 @@ public function testIfRulesAreSuccessfullyAdded() $this->assertFalse($v->hasRule('bar', 'Required')); } - public function testValidateArray() + public function testValidateArray(): void { $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['foo' => [1, 2, 3]], ['foo' => 'Array']); $this->assertTrue($v->passes()); - $v = new Validator($trans, ['foo' => new SplFileInfo('/tmp/foo')], ['foo' => 'Array']); + $v = new Validator($trans, ['foo' => new File('/tmp/foo', false)], ['foo' => 'Array']); $this->assertFalse($v->passes()); } - public function testValidateList() + public function testValidateList(): void { $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['foo' => [1, 2, 3]], ['foo' => 'list']); $this->assertTrue($v->passes()); - $v = new Validator($trans, ['foo' => new SplFileInfo('/tmp/foo')], ['foo' => 'list']); + $v = new Validator($trans, ['foo' => new File('/tmp/foo', false)], ['foo' => 'list']); $this->assertFalse($v->passes()); $v = new Validator($trans, ['foo' => [1 => 1, 2 => 2]], ['foo' => 'list']); @@ -1675,7 +1698,7 @@ public function testValidatePresentWithAll() $this->assertSame('The foo field must be present when bar / baz are present.', $v->errors()->first('foo')); } - public function testValidateRequired() + public function testValidateRequired(): void { $trans = $this->getArrayTranslator(); $v = new Validator($trans, [], ['name' => 'Required']); @@ -1687,16 +1710,16 @@ public function testValidateRequired() $v = new Validator($trans, ['name' => 'foo'], ['name' => 'Required']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(''); + $file = new File('', false); $v = new Validator($trans, ['name' => $file], ['name' => 'Required']); $this->assertFalse($v->passes()); - $file = new SplFileInfo(__FILE__); + $file = new File(__FILE__, false); $v = new Validator($trans, ['name' => $file], ['name' => 'Required']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(__FILE__); - $file2 = new SplFileInfo(__FILE__); + $file = new File(__FILE__, false); + $file2 = new File(__FILE__, false); $v = new Validator($trans, ['files' => [$file, $file2]], ['files.0' => 'Required', 'files.1' => 'Required']); $this->assertTrue($v->passes()); @@ -1704,7 +1727,7 @@ public function testValidateRequired() $this->assertTrue($v->passes()); } - public function testValidateRequiredWith() + public function testValidateRequiredWith(): void { $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['first' => 'Taylor'], ['last' => 'required_with:first']); @@ -1722,17 +1745,17 @@ public function testValidateRequiredWith() $v = new Validator($trans, ['first' => 'Taylor', 'last' => 'Otwell'], ['last' => 'required_with:first']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(''); + $file = new File('', false); $v = new Validator($trans, ['file' => $file, 'foo' => ''], ['foo' => 'required_with:file']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(__FILE__); - $foo = new SplFileInfo(__FILE__); + $file = new File(__FILE__, false); + $foo = new File(__FILE__, false); $v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_with:file']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(__FILE__); - $foo = new SplFileInfo(''); + $file = new File(__FILE__, false); + $foo = new File('', false); $v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_with:file']); $this->assertFalse($v->passes()); } @@ -1747,7 +1770,7 @@ public function testRequiredWithAll() $this->assertFalse($v->passes()); } - public function testValidateRequiredWithout() + public function testValidateRequiredWithout(): void { $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['first' => 'Taylor'], ['last' => 'required_without:first']); @@ -1768,35 +1791,35 @@ public function testValidateRequiredWithout() $v = new Validator($trans, ['last' => 'Otwell'], ['last' => 'required_without:first']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(''); + $file = new File('', false); $v = new Validator($trans, ['file' => $file], ['foo' => 'required_without:file']); $this->assertFalse($v->passes()); - $foo = new SplFileInfo(''); + $foo = new File('', false); $v = new Validator($trans, ['foo' => $foo], ['foo' => 'required_without:file']); $this->assertFalse($v->passes()); - $foo = new SplFileInfo(__FILE__); + $foo = new File(__FILE__, false); $v = new Validator($trans, ['foo' => $foo], ['foo' => 'required_without:file']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(__FILE__); - $foo = new SplFileInfo(__FILE__); + $file = new File(__FILE__, false); + $foo = new File(__FILE__, false); $v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_without:file']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(__FILE__); - $foo = new SplFileInfo(''); + $file = new File(__FILE__, false); + $foo = new File('', false); $v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_without:file']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(''); - $foo = new SplFileInfo(__FILE__); + $file = new File('', false); + $foo = new File(__FILE__, false); $v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_without:file']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(''); - $foo = new SplFileInfo(''); + $file = new File('', false); + $foo = new File('', false); $v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_without:file']); $this->assertFalse($v->passes()); } @@ -2072,7 +2095,7 @@ public function testRequiredUnless() $this->assertSame('The last field is required unless first is in taylor, sven.', $v->messages()->first('last')); } - public function testProhibited() + public function testProhibited(): void { $trans = $this->getArrayTranslator(); @@ -2085,16 +2108,16 @@ public function testProhibited() $v = new Validator($trans, ['name' => 'foo'], ['name' => 'prohibited']); $this->assertTrue($v->fails()); - $file = new SplFileInfo(''); + $file = new File('', false); $v = new Validator($trans, ['name' => $file], ['name' => 'prohibited']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(__FILE__); + $file = new File(__FILE__, false); $v = new Validator($trans, ['name' => $file], ['name' => 'prohibited']); $this->assertTrue($v->fails()); - $file = new SplFileInfo(__FILE__); - $file2 = new SplFileInfo(__FILE__); + $file = new File(__FILE__, false); + $file2 = new File(__FILE__, false); $v = new Validator($trans, ['files' => [$file, $file2]], ['files.0' => 'prohibited', 'files.1' => 'prohibited']); $this->assertTrue($v->fails()); @@ -2368,21 +2391,23 @@ public function count(): int ]; } - public function testFailedFileUploads() + #[TestWith([UploadedFile::class])] + #[TestWith([SymfonyUploadedFile::class])] + public function testFailedFileUploads(string $fileClass): void { $trans = $this->getArrayTranslator(); // If file is not successfully uploaded validation should fail with a // 'uploaded' error message instead of the original rule. - $file = m::mock(UploadedFile::class); - $file->shouldReceive('isValid')->andReturn(false); + $file = m::mock($fileClass); + $file->shouldReceive('isValid')->once()->andReturn(false); $file->shouldNotReceive('getSize'); $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:10']); $this->assertTrue($v->fails()); $this->assertEquals(['validation.uploaded'], $v->errors()->get('photo')); // Even "required" will not run if the file failed to upload. - $file = m::mock(UploadedFile::class); + $file = m::mock($fileClass); $file->shouldReceive('isValid')->once()->andReturn(false); $v = new Validator($trans, ['photo' => $file], ['photo' => 'required']); $this->assertTrue($v->fails()); @@ -2390,20 +2415,33 @@ public function testFailedFileUploads() // It should only fail with that rule if a validation rule implies it's // a file. Otherwise it should fail with the regular rule. - $file = m::mock(UploadedFile::class); - $file->shouldReceive('isValid')->andReturn(false); + $file = m::mock($fileClass); + $file->shouldReceive('isValid')->once()->andReturn(false); $v = new Validator($trans, ['photo' => $file], ['photo' => 'string']); $this->assertTrue($v->fails()); $this->assertEquals(['validation.string'], $v->errors()->get('photo')); // Validation shouldn't continue if a file failed to upload. - $file = m::mock(UploadedFile::class); + $file = m::mock($fileClass); $file->shouldReceive('isValid')->once()->andReturn(false); $v = new Validator($trans, ['photo' => $file], ['photo' => 'file|mimes:pdf|min:10']); $this->assertTrue($v->fails()); $this->assertEquals(['validation.uploaded'], $v->errors()->get('photo')); } + #[TestWith([UploadedFile::class])] + #[TestWith([SymfonyUploadedFile::class])] + public function testDirectFileRulesRejectFailedUploads(string $fileClass): void + { + $file = m::mock($fileClass); + $file->shouldReceive('isValid')->twice()->andReturn(false); + $file->shouldNotReceive('getSize'); + $validator = new Validator($this->getArrayTranslator(), [], []); + + $this->assertFalse($validator->isValidFileInstance($file)); + $this->assertFalse($validator->validateMax('photo', $file, [10])); + } + public function testValidateInArray() { $trans = $this->getArrayTranslator(); @@ -4153,7 +4191,7 @@ public static function multipleOfDataProvider() ]; } - public function testProperMessagesAreReturnedForSizes() + public function testProperMessagesAreReturnedForSizes(): void { $trans = $this->getArrayTranslator(); $trans->addLines(['validation.min.numeric' => 'numeric', 'validation.size.string' => 'string', 'validation.max.file' => 'file'], 'en'); @@ -4167,11 +4205,16 @@ public function testProperMessagesAreReturnedForSizes() $v->messages()->setFormat(':message'); $this->assertSame('string', $v->messages()->first('name')); - $file = $this->uploadedFile(__FILE__, '', isValid: true, size: 4072); - $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:3']); - $this->assertFalse($v->passes()); - $v->messages()->setFormat(':message'); - $this->assertSame('file', $v->messages()->first('photo')); + foreach ([ + $this->uploadedFile(__FILE__, '', isValid: true, size: 4072), + new SymfonyUploadedFile(__FILE__, '', test: true), + new File(__FILE__), + ] as $file) { + $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:3']); + $this->assertFalse($v->passes()); + $v->messages()->setFormat(':message'); + $this->assertSame('file', $v->messages()->first('photo')); + } } public function testValidateGtPlaceHolderIsReplacedProperly() @@ -5570,20 +5613,21 @@ public function testValidateImageDimensions(): void $v = new Validator($trans, ['x' => $svgXmlUploadedFile], ['x' => 'dimensions:max_width=1,max_height=1']); $this->assertTrue($v->passes()); - $svgXmlFile = new UploadedFile(__DIR__ . '/Fixtures/image.svg', '', 'image/svg+xml', null, true); + $svgXmlFile = new File(__DIR__ . '/Fixtures/image.svg'); $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['x' => $svgXmlFile], ['x' => 'dimensions:max_width=1,max_height=1']); $this->assertTrue($v->passes()); // Ensure svg images always pass as size is irrelevant (image/svg) - $svgUploadedFile = new UploadedFile(__DIR__ . '/Fixtures/image2.svg', '', 'image/svg', null, true); + $svgUploadedFile = $this->uploadedFile(__DIR__ . '/Fixtures/image2.svg', '', mimeType: 'image/svg'); $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['x' => $svgUploadedFile], ['x' => 'dimensions:max_width=1,max_height=1']); $this->assertTrue($v->passes()); - $svgFile = new UploadedFile(__DIR__ . '/Fixtures/image2.svg', '', 'image/svg', null, true); + $svgFile = m::mock(File::class, [__DIR__ . '/Fixtures/image2.svg'])->makePartial(); + $svgFile->shouldReceive('getMimeType')->once()->andReturn('image/svg'); $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['x' => $svgFile], ['x' => 'dimensions:max_width=1,max_height=1']); @@ -5674,7 +5718,7 @@ public function testValidateExtension() $this->assertFalse($v->passes()); } - public function testValidateMimeEnforcesPhpCheck() + public function testValidateMimeEnforcesPhpCheck(): void { $trans = $this->getArrayTranslator(); $file = $this->uploadedFile(__FILE__, '', guessedExtension: 'pdf', clientOriginalExtension: 'php'); @@ -5684,10 +5728,20 @@ public function testValidateMimeEnforcesPhpCheck() $file2 = $this->uploadedFile(__FILE__, '', guessedExtension: 'php', clientOriginalExtension: 'php'); $v = new Validator($trans, ['x' => $file2], ['x' => 'mimes:pdf,php']); $this->assertTrue($v->passes()); + + $file = new SymfonyUploadedFile(__DIR__ . '/Fixtures/image.png', 'image.php', test: true); + $v = new Validator($trans, ['x' => $file], ['x' => 'mimes:png']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['x' => $file], ['x' => 'mimetypes:image/png']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['x' => $file], ['x' => 'mimes:png,php']); + $this->assertTrue($v->passes()); } #[RequiresPhpExtension('fileinfo')] - public function testValidateFile() + public function testValidateFile(): void { $trans = $this->getArrayTranslator(); $file = new UploadedFile(__FILE__, '', null, null, true); @@ -5697,6 +5751,15 @@ public function testValidateFile() $v = new Validator($trans, ['x' => $file], ['x' => 'file']); $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['x' => new SymfonyUploadedFile(__FILE__, '', test: true)], ['x' => 'file']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['x' => new File(__FILE__)], ['x' => 'file']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['x' => new SplFileInfo(__FILE__)], ['x' => 'file']); + $this->assertTrue($v->fails()); } public function testEmptyRulesSkipped() @@ -8685,19 +8748,19 @@ public function testNestedInvalidMethod() ); } - public function testMultipleFileUploads() + public function testMultipleFileUploads(): void { $trans = $this->getArrayTranslator(); - $file = new SplFileInfo(__FILE__); - $file2 = new SplFileInfo(__FILE__); + $file = new File(__FILE__, false); + $file2 = new File(__FILE__, false); $v = new Validator($trans, ['file' => [$file, $file2]], ['file.*' => 'Required|mimes:xls']); $this->assertFalse($v->passes()); } - public function testFileUploads() + public function testFileUploads(): void { $trans = $this->getArrayTranslator(); - $file = new SplFileInfo(__FILE__); + $file = new File(__FILE__, false); $v = new Validator($trans, ['file' => $file], ['file' => 'Required|mimes:xls']); $this->assertFalse($v->passes()); } @@ -10504,9 +10567,12 @@ public function testWhenPasses() $this->assertSame('whenNotPasses', $result); } - protected function fileInfoWithSize(int $size): SplFileInfo + /** + * Create a file with the given size. + */ + protected function fileInfoWithSize(int $size): File { - return new SizedSplFileInfo(__FILE__, $size); + return new SizedFile(__FILE__, $size); } protected function uploadedFile( @@ -10594,13 +10660,19 @@ class NonEloquentModel { } -class SizedSplFileInfo extends SplFileInfo +class SizedFile extends File { + /** + * Create a file with the given size. + */ public function __construct(string $filename, private readonly int $size) { parent::__construct($filename); } + /** + * Get the file size. + */ public function getSize(): int { return $this->size; From fffbf3bf65853c222148e0abb8303bd70ed8874d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:42:11 +0000 Subject: [PATCH 07/22] Port queue worker stop output from Laravel Report why queue:work stops in both console and JSON output. Preserve all nine upstream reason descriptions, the stop status and exit code, nullable metrics, memory rounding, timestamps, and quiet/silent suppression. Port Laravel PR #61339 from the current 13.x implementation: https://github.com/laravel/framework/pull/61339 Source revision: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 Resolve the command through the stopping event's existing worker options. Graceful stopping runs outside the configured job coroutine context, and the once-registered static listener must not retain the first command. Reuse existing ownership without adding worker state or per-job work. Keep native types, imported names, and the immutable clock. Port both upstream integration tests and add focused coverage for distinct command instances, nullable and zero metrics, JSON formatting, suppression, and events without command-owned options. Document stop output and --json at the worker command's public documentation surface. Validation: integration file 18 tests / 58 assertions; new command file 6 / 29; Queue ParaTest suite 650 / 2724; affected Horizon, SQLite worker lifetime, and Sentry tests 16 / 76. Scoped formatting, full source/type PHPStan analysis, and diff checks pass. Database checks use SQLite. Self-reviewed and signed off by claude-laravel-parity. --- src/docs/queues.md | 6 + src/queue/src/Console/WorkCommand.php | 40 ++++++ src/queue/src/WorkerStopReason.php | 18 +++ tests/Integration/Queue/WorkCommandTest.php | 27 ++++ tests/Queue/WorkCommandTest.php | 142 ++++++++++++++++++++ 5 files changed, 233 insertions(+) create mode 100644 tests/Queue/WorkCommandTest.php diff --git a/src/docs/queues.md b/src/docs/queues.md index 25187b5fc..a7684e9a4 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -2607,6 +2607,12 @@ You may include the `-v` flag when invoking the `queue:work` command if you woul php artisan queue:work -v ``` +The command also reports worker stop reasons, such as reaching the memory limit. To output job updates and stop information as JSON, use the `--json` option: + +```shell +php artisan queue:work --json +``` + Remember, queue workers are long-lived processes and store the booted application state in memory. As a result, they will not notice changes in your code base after they have been started. So, during your deployment process, be sure to [restart your queue workers](#queue-workers-and-deployment). In addition, remember that any static state created or modified by your application will not be automatically reset between jobs. Request or job specific state should be stored in `CoroutineContext` instead of static properties or mutable singletons. Alternatively, you may run the `queue:listen` command. When using the `queue:listen` command, you don't have to manually restart the worker when you want to reload your updated code or reset the application state; however, this command is significantly less efficient than the `queue:work` command: diff --git a/src/queue/src/Console/WorkCommand.php b/src/queue/src/Console/WorkCommand.php index 034b42f89..2754b5835 100644 --- a/src/queue/src/Console/WorkCommand.php +++ b/src/queue/src/Console/WorkCommand.php @@ -14,6 +14,7 @@ use Hypervel\Queue\Events\JobProcessed; use Hypervel\Queue\Events\JobProcessing; use Hypervel\Queue\Events\JobReleasedAfterException; +use Hypervel\Queue\Events\WorkerStopping; use Hypervel\Queue\Failed\FailedJobProviderInterface; use Hypervel\Queue\InvalidPayloadException; use Hypervel\Queue\Worker; @@ -197,6 +198,15 @@ protected function listenForEvents(): void $command?->writeOutput($event->job, 'failed', $event->exception); }); + $events->listen(WorkerStopping::class, static function (WorkerStopping $event): void { + // Graceful stopping runs outside the configured job coroutine context. + $command = $event->workerOptions?->coroutineContext[self::CURRENT_COMMAND_CONTEXT_KEY] ?? null; + + if ($command instanceof self) { + $command->writeStopReason($event); + } + }); + static::$hasRegisteredListeners = true; } @@ -214,6 +224,36 @@ protected function writeOutput(Job $job, string $status, ?Throwable $exception = : $this->writeOutputForCli($job, $status); } + /** + * Write the status output for a queue worker that is stopping. + */ + protected function writeStopReason(WorkerStopping $event): void + { + if ($this->output->isQuiet() || $this->output->isSilent() || is_null($event->reason)) { + return; + } + + if ($this->outputUsingJson()) { + $this->output->writeln(json_encode([ + 'level' => $event->status === 0 ? 'info' : 'warning', + 'status' => 'stopped', + 'reason' => $event->reason->value, + 'exit_code' => $event->status, + 'jobs_processed' => $event->jobsProcessed, + 'memory' => is_null($event->memoryUsage) ? null : round($event->memoryUsage, 1), + 'timestamp' => $this->now()->format('Y-m-d\TH:i:s.uP'), + ])); + + return; + } + + $this->output->writeln(sprintf( + ' %s Worker STOPPED %s', + $this->now()->format('Y-m-d H:i:s'), + $event->reason->description(), + )); + } + /** * Format the status output for the queue worker. */ diff --git a/src/queue/src/WorkerStopReason.php b/src/queue/src/WorkerStopReason.php index 41e2ccc97..e33f57ced 100644 --- a/src/queue/src/WorkerStopReason.php +++ b/src/queue/src/WorkerStopReason.php @@ -15,4 +15,22 @@ enum WorkerStopReason: string case QueueEmptyFor = 'empty_for'; case ReceivedRestartSignal = 'restart_signal'; case TimedOut = 'timed_out'; + + /** + * Get the description of the worker stop reason. + */ + public function description(): string + { + return match ($this) { + self::Interrupted => 'Interrupted', + self::LostConnection => 'Lost connection', + self::MaxJobsExceeded => 'Maximum jobs exceeded', + self::MaxMemoryExceeded => 'Memory limit exceeded', + self::MaxTimeExceeded => 'Maximum run time exceeded', + self::QueueEmpty => 'Queue empty', + self::QueueEmptyFor => 'Queue empty for the configured duration', + self::ReceivedRestartSignal => 'Received restart signal', + self::TimedOut => 'Job timed out', + }; + } } diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php index 1135a54e3..70d2e27db 100644 --- a/tests/Integration/Queue/WorkCommandTest.php +++ b/tests/Integration/Queue/WorkCommandTest.php @@ -368,6 +368,33 @@ public function testFailedJobListenerOnlyRunsOnce() Exceptions::assertNotReported(UniqueConstraintViolationException::class); $this->assertSame(2, substr_count(Artisan::output(), JobWillFail::class)); } + + public function testStopReasonIsWritten(): void + { + Queue::push(new FirstJob); + Queue::push(new SecondJob); + + $this->artisan('queue:work', [ + '--daemon' => true, + '--stop-when-empty' => true, + '--memory' => 1024, + ])->expectsOutputToContain('Queue empty') + ->assertExitCode(0); + } + + public function testStopReasonIsWrittenAsJson(): void + { + Queue::push(new FirstJob); + Queue::push(new SecondJob); + + $this->artisan('queue:work', [ + '--daemon' => true, + '--stop-when-empty' => true, + '--memory' => 1, + '--json' => true, + ])->expectsOutputToContain('"status":"stopped","reason":"memory","exit_code":12') + ->assertExitCode(12); + } } class FirstJob implements ShouldQueue diff --git a/tests/Queue/WorkCommandTest.php b/tests/Queue/WorkCommandTest.php new file mode 100644 index 000000000..564511530 --- /dev/null +++ b/tests/Queue/WorkCommandTest.php @@ -0,0 +1,142 @@ +make('config'); + $config->set('queue.default', 'sync'); + $config->set('cache.default', 'array'); + } + + public function testStopOutputUsesTheCurrentCommand(): void + { + $this->travelTo(CarbonImmutable::create(2023, 1, 18, 10, 10, 11)); + + $firstOutput = new BufferedOutput; + $this->runWorkerCommand(new WorkerStopping(reason: WorkerStopReason::QueueEmpty), $firstOutput); + + $this->assertSame(" 2023-01-18 10:10:11 Worker STOPPED Queue empty\n", $firstOutput->fetch()); + + $secondOutput = new BufferedOutput; + $this->runWorkerCommand(new WorkerStopping( + status: Worker::EXIT_MEMORY_LIMIT, + reason: WorkerStopReason::MaxMemoryExceeded, + jobsProcessed: 0, + memoryUsage: 64.25, + ), $secondOutput, ['--json' => true]); + + $this->assertSame('', $firstOutput->fetch()); + $this->assertSame([ + 'level' => 'warning', + 'status' => 'stopped', + 'reason' => 'memory', + 'exit_code' => 12, + 'jobs_processed' => 0, + 'memory' => 64.3, + 'timestamp' => '2023-01-18T10:10:11.000000+00:00', + ], json_decode($secondOutput->fetch(), true, 512, JSON_THROW_ON_ERROR)); + } + + public function testStopOutputPreservesMissingMetrics(): void + { + $this->travelTo(CarbonImmutable::create(2023, 1, 18, 10, 10, 11)); + + $output = new BufferedOutput; + $this->runWorkerCommand(new WorkerStopping(reason: WorkerStopReason::QueueEmpty), $output, ['--json' => true]); + + $this->assertSame([ + 'level' => 'info', + 'status' => 'stopped', + 'reason' => 'empty', + 'exit_code' => 0, + 'jobs_processed' => null, + 'memory' => null, + 'timestamp' => '2023-01-18T10:10:11.000000+00:00', + ], json_decode($output->fetch(), true, 512, JSON_THROW_ON_ERROR)); + } + + #[DataProvider('suppressedStopOutputProvider')] + public function testStopOutputIsSuppressed(int $verbosity, ?WorkerStopReason $reason): void + { + $output = new BufferedOutput($verbosity); + $this->runWorkerCommand(new WorkerStopping(reason: $reason), $output, ['--json' => true]); + + $this->assertSame('', $output->fetch()); + } + + /** + * Provide stop events that should not produce output. + */ + public static function suppressedStopOutputProvider(): array + { + return [ + 'quiet' => [OutputInterface::VERBOSITY_QUIET, WorkerStopReason::QueueEmpty], + 'silent' => [OutputInterface::VERBOSITY_SILENT, WorkerStopReason::QueueEmpty], + 'no reason' => [OutputInterface::VERBOSITY_NORMAL, null], + ]; + } + + public function testStopEventsWithoutCommandOptionsDoNotWriteOutput(): void + { + $output = new BufferedOutput; + $this->runWorkerCommand(new WorkerStopping(reason: WorkerStopReason::QueueEmpty), $output, ['--json' => true]); + $output->fetch(); + + $this->app->make('events')->dispatch(new WorkerStopping(reason: WorkerStopReason::QueueEmpty)); + + $this->assertSame('', $output->fetch()); + } + + /** + * Run a distinct command instance that dispatches the given stop event. + * + * @param array $arguments + */ + private function runWorkerCommand(WorkerStopping $event, BufferedOutput $output, array $arguments = []): void + { + $worker = m::mock(Worker::class); + $worker->shouldReceive('setName')->once()->with('default')->andReturnSelf(); + $worker->shouldReceive('setCache')->once()->with(m::type(Repository::class))->andReturnSelf(); + $worker->shouldReceive('daemon') + ->once() + ->with('sync', 'default', m::type(WorkerOptions::class)) + ->andReturnUsing(function (string $connection, string $queue, WorkerOptions $options) use ($event): int { + $event->workerOptions = $options; + $this->app->make('events')->dispatch($event); + + return $event->status; + }); + + $command = new WorkCommand( + $this->app, + $this->app->make('config'), + $worker, + $this->app->make('cache'), + ); + $command->setHypervel($this->app); + $command->run(new ArrayInput($arguments), $output); + } +} From 38a901faeaf34182091b537479b84310d5d7c656 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:51:25 +0000 Subject: [PATCH 08/22] Port context propagation across explicit process boundaries Port Laravel framework PR #61419 and its receiving/scheduler dependency #57918 from the pinned 13.x source at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2: https://github.com/laravel/framework/pull/61419 https://github.com/laravel/framework/pull/57918 Propagate visible and hidden context to process-driver tasks, including deferred tasks, and to explicitly scheduled system commands. Preserve Hypervel's coroutine driver as the default and its native in-worker scheduling and background execution paths. Use a base64-encoded serialized dehydration payload because upstream's JSON encoding silently drops valid binary context. Receive the payload in ConsoleServiceProvider before command handling, where the repository belongs to the executing coroutine. Claim the startup payload before hydrating so callbacks can invoke nested commands without hydrating twice, and later worker-job coroutines cannot restore startup context. Skip receiver registration for empty payloads and non-console startup. Fix AI-020: the queue payload hook must run registered dehydration hooks in fresh coroutines even when no context repository exists yet. Retain the allocation-free path when neither context nor listeners exist and capture the worker-safe dispatcher once during provider boot. Declare concurrency's direct log dependency, port both upstream tests, add focused binary transport, receiver lifecycle, eligibility, scheduler, and fresh-coroutine regressions, and document the public context behavior. Update transport-hook comments that incorrectly restricted them to jobs. Validation: immediate changed-file PHPUnit runs; affected ParaTest suites passed with 1616 tests and 5700 assertions before review-only corrections. Final review checks passed: Concurrency 40/76, ConsoleServiceProvider 4/26, ContextQueue 17/64. Full source/type PHPStan, scoped PHP-CS-Fixer, concurrency Composer validation and git diff --check passed. Self-reviewed and signed off by claude-laravel-parity. --- src/concurrency/composer.json | 1 + src/concurrency/src/ProcessDriver.php | 5 + src/console/src/ConsoleServiceProvider.php | 24 +++++ src/console/src/Scheduling/Event.php | 6 +- src/docs/concurrency.md | 2 + src/docs/context.md | 2 +- src/docs/scheduling.md | 2 + .../src/Context/ContextServiceProvider.php | 9 +- src/log/src/Context/Repository.php | 6 +- tests/Concurrency/ConcurrencyTest.php | 45 +++++++++ tests/Console/ConsoleServiceProviderTest.php | 98 +++++++++++++++++++ .../ScheduleRunContextPropagationTest.php | 40 ++++++++ tests/Log/ContextQueueTest.php | 19 ++++ 13 files changed, 251 insertions(+), 8 deletions(-) diff --git a/src/concurrency/composer.json b/src/concurrency/composer.json index c092be10c..7aad3c708 100644 --- a/src/concurrency/composer.json +++ b/src/concurrency/composer.json @@ -37,6 +37,7 @@ "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", "hypervel/coroutine": "^0.4", + "hypervel/log": "^0.4", "hypervel/process": "^0.4", "hypervel/support": "^0.4", "nesbot/carbon": "^3.13.1", diff --git a/src/concurrency/src/ProcessDriver.php b/src/concurrency/src/ProcessDriver.php index 929bfe85a..7399dff06 100644 --- a/src/concurrency/src/ProcessDriver.php +++ b/src/concurrency/src/ProcessDriver.php @@ -13,6 +13,7 @@ use Hypervel\Process\Pool; use Hypervel\Support\Arr; use Hypervel\Support\Defer\DeferredCallback; +use Hypervel\Support\Facades\Context; use Laravel\SerializableClosure\SerializableClosure; use function Hypervel\Support\defer; @@ -37,6 +38,8 @@ public function run(Closure|array $tasks, CarbonInterval|int|null $timeout = nul $results = $this->processFactory->pool(function (Pool $pool) use ($tasks, $command, $timeout) { foreach (Arr::wrap($tasks) as $key => $task) { $process = $pool->as((string) $key)->path(base_path())->env([ + /* @phpstan-ignore staticMethod.notFound */ + '__HYPERVEL_CONTEXT' => base64_encode(serialize(Context::dehydrate())), 'HYPERVEL_INVOKABLE_CLOSURE' => base64_encode( serialize(new SerializableClosure($task)) ), @@ -67,6 +70,8 @@ public function defer(Closure|array $tasks): DeferredCallback return defer(function () use ($tasks, $command) { foreach (Arr::wrap($tasks) as $task) { $this->processFactory->path(base_path())->env([ + /* @phpstan-ignore staticMethod.notFound */ + '__HYPERVEL_CONTEXT' => base64_encode(serialize(Context::dehydrate())), 'HYPERVEL_INVOKABLE_CLOSURE' => base64_encode( serialize(new SerializableClosure($task)) ), diff --git a/src/console/src/ConsoleServiceProvider.php b/src/console/src/ConsoleServiceProvider.php index 1a1b26ffe..152954c22 100644 --- a/src/console/src/ConsoleServiceProvider.php +++ b/src/console/src/ConsoleServiceProvider.php @@ -11,6 +11,10 @@ use Hypervel\Console\Commands\ScheduleResumeCommand; use Hypervel\Console\Commands\ScheduleRunCommand; use Hypervel\Console\Commands\ScheduleTestCommand; +use Hypervel\Console\Events\BeforeHandle; +use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Log\Context\Repository; +use Hypervel\Support\Env; use Hypervel\Support\ServiceProvider; class ConsoleServiceProvider extends ServiceProvider @@ -30,4 +34,24 @@ public function register(): void ScheduleTestCommand::class, ]); } + + /** + * Bootstrap the service provider. + */ + public function boot(Dispatcher $events): void + { + if ($this->app->runningInConsole() + && is_string($encoded = Env::get('__HYPERVEL_CONTEXT')) + && is_array($context = unserialize(base64_decode($encoded, true), ['allowed_classes' => false]))) { + $events->listen(BeforeHandle::class, static function () use (&$context): void { + if ($context !== null) { + // Hydration callbacks may invoke another command, so claim the startup context first. + $payload = $context; + $context = null; + + Repository::getInstance()->hydrate($payload); + } + }); + } + } } diff --git a/src/console/src/Scheduling/Event.php b/src/console/src/Scheduling/Event.php index b1c5c49c0..20ba49f3f 100644 --- a/src/console/src/Scheduling/Event.php +++ b/src/console/src/Scheduling/Event.php @@ -20,6 +20,7 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Mail\Mailer; use Hypervel\Filesystem\Filesystem; +use Hypervel\Log\Context\Repository; use Hypervel\Support\Arr; use Hypervel\Support\Facades\Date; use Hypervel\Support\Stringable; @@ -265,10 +266,13 @@ protected function execute(Container $container): int */ protected function runProcess(Container $container): int { + $context = base64_encode(serialize(Repository::getInstance()->dehydrate())); + /** @var \Hypervel\Contracts\Foundation\Application $container */ $process = Process::fromShellCommandline( $this->command, - $container->basePath() + $container->basePath(), + ['__HYPERVEL_CONTEXT' => $context] ); CoroutineContext::set($this->processContextKey(), $process); diff --git a/src/docs/concurrency.md b/src/docs/concurrency.md index c503c5b0e..c301c6230 100644 --- a/src/docs/concurrency.md +++ b/src/docs/concurrency.md @@ -110,6 +110,8 @@ The `coroutine` driver is the correct choice for almost all application code. It The `process` driver should be reserved for tasks that need OS-level process isolation. This includes work that must run outside the current framework lifecycle, work that should not share long-lived worker memory, or work that calls extensions Swoole cannot hook. Since process tasks are serialized before being sent to the child process, the closure and any captured values must be serializable. +When using the `process` driver, each task receives the visible and hidden [context](/docs/{{version}}/context) captured when the process is started. This applies to both `run` and `defer`. + The `sync` driver executes tasks one after another. This is useful in tests and simple scripts where you want deterministic execution without concurrency. diff --git a/src/docs/context.md b/src/docs/context.md index dd01e72ec..6857a7f20 100644 --- a/src/docs/context.md +++ b/src/docs/context.md @@ -109,7 +109,7 @@ The resulting log entry would contain the information that was added to the cont Processing podcast. {"podcast_id":95} {"url":"https://example.com/login","trace_id":"e04e1a11-e75c-4db3-b5b5-cfef4ef56697"} ``` -Although we have focused on the built-in logging related features of Hypervel's context, the following documentation will illustrate how context allows you to share information across the HTTP request / queued job boundary and even how to add [hidden context data](#hidden-context) that is not written with log entries. +In addition to its logging features, context allows you to share information with queued jobs, [concurrent processes](/docs/{{version}}/concurrency), and [scheduled commands](/docs/{{version}}/scheduling). You may also add [hidden context data](#hidden-context) that is not written with log entries. ## Capturing Context diff --git a/src/docs/scheduling.md b/src/docs/scheduling.md index ec1718e21..72981e67a 100644 --- a/src/docs/scheduling.md +++ b/src/docs/scheduling.md @@ -145,6 +145,8 @@ use Hypervel\Support\Facades\Schedule; Schedule::exec('node /path/to/script.js')->daily(); ``` +If the shell command launches a Hypervel Artisan command, the child command receives the task's visible and hidden [context](/docs/{{version}}/context). + ### Schedule Frequency Options diff --git a/src/log/src/Context/ContextServiceProvider.php b/src/log/src/Context/ContextServiceProvider.php index cffa5b3d5..5a8f8f662 100644 --- a/src/log/src/Context/ContextServiceProvider.php +++ b/src/log/src/Context/ContextServiceProvider.php @@ -5,6 +5,7 @@ namespace Hypervel\Log\Context; use Hypervel\Contracts\Log\ContextLogProcessor as ContextLogProcessorContract; +use Hypervel\Log\Context\Events\ContextDehydrating; use Hypervel\Queue\Events\JobProcessing; use Hypervel\Queue\Queue; use Hypervel\Support\Facades\Context; @@ -25,8 +26,10 @@ public function register(): void */ public function boot(): void { - Queue::createPayloadUsing(function (string $connection, ?string $queue, array $payload): array { - if (! Repository::hasInstance()) { + $events = $this->app->make('events'); + + Queue::createPayloadUsing(function (string $connection, ?string $queue, array $payload) use ($events): array { + if (! Repository::hasInstance() && ! $events->hasListeners(ContextDehydrating::class)) { return []; } @@ -40,7 +43,7 @@ public function boot(): void }); // IMPORTANT: Uses Laravel's payload key for cross-framework queue interoperability. - $this->app->make('events')->listen(JobProcessing::class, function (JobProcessing $event): void { + $events->listen(JobProcessing::class, function (JobProcessing $event): void { $context = $event->job->payload()['illuminate:log:context'] ?? null; if ($context !== null || Repository::hasInstance()) { diff --git a/src/log/src/Context/Repository.php b/src/log/src/Context/Repository.php index cd1708d4f..09793e04d 100644 --- a/src/log/src/Context/Repository.php +++ b/src/log/src/Context/Repository.php @@ -546,7 +546,7 @@ public function replicate(): static // --- Transport hooks --- /** - * Register a callback to execute before context is dehydrated for a job. + * Register a callback to execute before context is dehydrated. * * Boot-only. Registers a listener on the worker-global event dispatcher; * per-request registration persists and affects subsequent requests. @@ -562,7 +562,7 @@ public function dehydrating(callable $callback): static } /** - * Register a callback to execute after context has been hydrated from a job. + * Register a callback to execute after context has been hydrated. * * Boot-only. Registers a listener on the worker-global event dispatcher; * per-request registration persists and affects subsequent requests. @@ -603,7 +603,7 @@ public static function flushState(): void static::flushMacros(); } - // --- Internal transport (called by queue infrastructure) --- + // --- Internal transport --- /** * Dehydrate the context into a serializable payload. diff --git a/tests/Concurrency/ConcurrencyTest.php b/tests/Concurrency/ConcurrencyTest.php index 2d71f0f4a..eb805e850 100644 --- a/tests/Concurrency/ConcurrencyTest.php +++ b/tests/Concurrency/ConcurrencyTest.php @@ -14,9 +14,12 @@ use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel; use Hypervel\Process\Factory as ProcessFactory; +use Hypervel\Process\PendingProcess; use Hypervel\Support\Defer\DeferredCallback; use Hypervel\Support\Defer\DeferredCallbackCollection; use Hypervel\Support\Facades\Concurrency as ConcurrencyFacade; +use Hypervel\Support\Facades\Context; +use Hypervel\Testbench\Attributes\UsesVendor; use Hypervel\Testbench\TestCase; use Hypervel\Tests\Concurrency\Fixtures\ConcurrentProcessExceptionFixtures; use Hypervel\Tests\Context\Fixtures\ThrowingReplicableContext; @@ -508,6 +511,48 @@ public function testProcessDriverReportsFailedChildProcessesBeforeDecoding(): vo $driver->run(static fn () => null); } + #[UsesVendor] + public function testContextIsPropagatedToConcurrentProcesses(): void + { + Context::add('task', 'concurrency'); + Context::addHidden('token', 'secret'); + + [$task, $token] = ConcurrencyFacade::driver('process')->run([ + static fn (): mixed => Context::get('task'), + static fn (): mixed => Context::getHidden('token'), + ]); + + $this->assertSame('concurrency', $task); + $this->assertSame('secret', $token); + } + + public function testContextIsPropagatedToDeferredConcurrentProcesses(): void + { + $this->withoutDefer(); + + Context::add('task', 'concurrency'); + + $factory = $this->app->make(ProcessFactory::class); + $factory->fake(); + + (new ProcessDriver($factory))->defer([static fn (): string => 'result']); + + $factory->assertRan(static fn (PendingProcess $process): bool => ($process->environment['__HYPERVEL_CONTEXT'] ?? null) === base64_encode(serialize(Context::dehydrate()))); + } + + #[UsesVendor] + public function testBinaryContextIsPropagatedToConcurrentProcesses(): void + { + Context::add('task', 'concurrency'); + Context::addHidden('token', "binary-\xFF\x00\x8B"); + + [$context] = ConcurrencyFacade::driver('process')->run([ + static fn (): array => [Context::get('task'), Context::getHidden('token')], + ]); + + $this->assertSame(['concurrency', "binary-\xFF\x00\x8B"], $context); + } + public function testProcessDriverAppliesCustomTimeouts(): void { $factory = $this->app->make(ProcessFactory::class); diff --git a/tests/Console/ConsoleServiceProviderTest.php b/tests/Console/ConsoleServiceProviderTest.php index 121c3ceac..01e30b031 100644 --- a/tests/Console/ConsoleServiceProviderTest.php +++ b/tests/Console/ConsoleServiceProviderTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Console; +use Hypervel\Console\Command; use Hypervel\Console\Commands\ScheduleClearCacheCommand; use Hypervel\Console\Commands\ScheduleInterruptCommand; use Hypervel\Console\Commands\ScheduleListCommand; @@ -11,8 +12,18 @@ use Hypervel\Console\Commands\ScheduleResumeCommand; use Hypervel\Console\Commands\ScheduleRunCommand; use Hypervel\Console\Commands\ScheduleTestCommand; +use Hypervel\Console\ConsoleServiceProvider; +use Hypervel\Console\Events\BeforeHandle; use Hypervel\Contracts\Console\Kernel as KernelContract; +use Hypervel\Coroutine\Coroutine; +use Hypervel\Engine\Channel; +use Hypervel\Log\Context\Events\ContextHydrated; +use Hypervel\Log\Context\Repository; +use Hypervel\Support\Facades\Context; +use Hypervel\Testbench\Attributes\WithEnv; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\Console\Input\ArrayInput; class ConsoleServiceProviderTest extends TestCase { @@ -37,4 +48,91 @@ public function testScheduleCommandsAreRegistered() $this->assertInstanceOf($class, $artisan->find($name)); } } + + public function testProcessContextIsHydratedOnlyForTheInitialCommand(): void + { + $payload = [ + 'data' => ['task' => serialize('concurrency')], + 'hidden' => ['token' => serialize('secret')], + ]; + $restoreEnvironment = (new WithEnv('__HYPERVEL_CONTEXT', base64_encode(serialize($payload))))($this->app); + + try { + $events = $this->app->make('events'); + (new ConsoleServiceProvider($this->app))->boot($events); + + $command = new BeforeHandle(new Command('context:test'), new ArrayInput([])); + $hydrations = 0; + $received = null; + + Context::hydrated(static function (Repository $context) use ($events, $command, &$hydrations, &$received): void { + ++$hydrations; + $received = [$context->get('task'), $context->getHidden('token')]; + $context->add('task', 'updated'); + + if ($hydrations === 1) { + $events->dispatch($command); + } + }); + + $events->dispatch($command); + + $this->assertSame(['concurrency', 'secret'], $received); + $this->assertSame(1, $hydrations); + $this->assertSame('updated', Context::get('task')); + + Context::add('task', 'later'); + $events->dispatch($command); + + $this->assertSame('later', Context::get('task')); + + $result = new Channel(1); + Coroutine::create(static function () use ($events, $command, $result): void { + $events->dispatch($command); + $result->push([Repository::hasInstance()]); + }); + + $this->assertSame([false], $result->pop(1)); + $this->assertSame(1, $hydrations); + } finally { + $restoreEnvironment(); + } + } + + #[DataProvider('contextsWithoutProcessHydration')] + public function testProcessContextIsNotHydratedWithoutAConsolePayload(bool $runningInConsole, ?array $payload): void + { + $restoreEnvironment = (new WithEnv('__HYPERVEL_CONTEXT', base64_encode(serialize($payload))))($this->app); + $previousRunningInConsole = $this->app->runningInConsole(); + + try { + $this->app->setRunningInConsole($runningInConsole); + $events = $this->app->make('events'); + (new ConsoleServiceProvider($this->app))->boot($events); + + $hydrations = 0; + $events->listen(ContextHydrated::class, static function () use (&$hydrations): void { + ++$hydrations; + }); + + $events->dispatch(new BeforeHandle(new Command('context:test'), new ArrayInput([]))); + + $this->assertSame(0, $hydrations); + $this->assertFalse(Repository::hasInstance()); + } finally { + $this->app->setRunningInConsole($previousRunningInConsole); + $restoreEnvironment(); + } + } + + /** + * Provide startup contexts that must not hydrate command context. + */ + public static function contextsWithoutProcessHydration(): array + { + return [ + 'empty context' => [true, null], + 'HTTP server' => [false, ['data' => ['task' => serialize('concurrency')]]], + ]; + } } diff --git a/tests/Console/Scheduling/ScheduleRunContextPropagationTest.php b/tests/Console/Scheduling/ScheduleRunContextPropagationTest.php index 8588adaee..81fa79648 100644 --- a/tests/Console/Scheduling/ScheduleRunContextPropagationTest.php +++ b/tests/Console/Scheduling/ScheduleRunContextPropagationTest.php @@ -15,9 +15,11 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Coroutine\Concurrent; use Hypervel\Engine\Channel; +use Hypervel\Filesystem\Filesystem; use Hypervel\Log\Context\Repository as ContextRepository; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; +use Hypervel\Support\Stringable; use Hypervel\Testbench\TestCase; use Mockery as m; use ReflectionMethod; @@ -30,6 +32,8 @@ class ScheduleRunContextPropagationTest extends TestCase protected ExceptionHandler $handler; + protected ?string $outputFile = null; + protected function setUp(): void { parent::setUp(); @@ -41,6 +45,20 @@ protected function setUp(): void $this->handler = m::mock(ExceptionHandler::class); } + /** + * Remove captured process output. + */ + protected function tearDown(): void + { + try { + if ($this->outputFile !== null) { + (new Filesystem)->delete($this->outputFile); + } + } finally { + parent::tearDown(); + } + } + public function testBackgroundTaskReceivesParentContext() { ContextRepository::getInstance()->add('trace_id', 'parent-trace-123'); @@ -141,6 +159,28 @@ public function testForegroundTaskReceivesIndependentCopyOfParentLogContext(): v $this->assertNull(ContextRepository::getInstance()->get('child_only')); } + public function testSystemTaskReceivesContextThroughItsEnvironment(): void + { + ContextRepository::getInstance() + ->add('trace_id', 'parent-trace-123') + ->addHidden('token', 'secret'); + + $context = null; + $event = new Event(m::mock(EventMutex::class), 'printf %s "$__HYPERVEL_CONTEXT"', isSystem: true); + $event->thenWithOutput(static function (Stringable $output) use (&$context): void { + $context = (string) $output; + }); + $this->outputFile = $event->output; + + $event->run($this->app); + + $this->assertSame(0, $event->exitCode()); + $this->assertSame([ + 'data' => ['trace_id' => serialize('parent-trace-123')], + 'hidden' => ['token' => serialize('secret')], + ], unserialize(base64_decode($context, true), ['allowed_classes' => false])); + } + /** * Create a background Event mock that executes a callback inside run(). */ diff --git a/tests/Log/ContextQueueTest.php b/tests/Log/ContextQueueTest.php index fd951c425..342442dde 100644 --- a/tests/Log/ContextQueueTest.php +++ b/tests/Log/ContextQueueTest.php @@ -11,6 +11,7 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Queue\ShouldBeUnique; use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel; use Hypervel\Foundation\Bus\Dispatchable; use Hypervel\Foundation\Queue\Queueable; @@ -255,6 +256,24 @@ public function testDehydratingHookFiresBeforeJobDispatch(): void $this->assertArrayHasKey('dehydrated_at', $payload['illuminate:log:context']['data']); } + public function testDehydratingHookContributesContextInAFreshCoroutine(): void + { + Repository::getInstance()->dehydrating(static function (Repository $context): void { + $context->addHidden('locale', 'en'); + }); + + $queue = $this->createSyncQueue(); + $result = new Channel(1); + + Coroutine::create(static function () use ($queue, $result): void { + $result->push($queue->testCreatePayload('SomeJob', null)); + }); + + $payload = $result->pop(1); + + $this->assertSame(serialize('en'), $payload['illuminate:log:context']['hidden']['locale']); + } + public function testHydratedHookFiresWhenJobProcesses(): void { $called = false; From d18725ff614cc0e2ce6b3f8bfe0cd6e9aeff816a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:24:19 +0000 Subject: [PATCH 09/22] Port Laravel collection filtering updates Use Collection::diff() to remove exception classes from both reporting exclusion lists and to remove exact provider names from the bootstrap file. This replaces callback-based membership scans while preserving class-name matching, reindexing, fluent returns and fuzzy provider removal. Complete Laravel PR #60945 from the pinned current 13.x source: https://github.com/laravel/framework/pull/60945 Upstream source: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 The PR's DefaultProviders::except() and JSON:API relationship-selection changes are already present. Preserve Hypervel's resolved paths, atomic provider-file replacement and permissions. Apply native callback types and document the shared exception configuration's boot-time lifetime on both the handler and its configuration wrapper. Validation: affected ParaTest suite passes 104 tests / 316 assertions; full source and type-fixture PHPStan, scoped formatting and diff checks pass. No tests were added upstream; existing coverage includes exact/fuzzy provider removal, file permissions and string/array exception inputs. Peer review: claude-laravel-parity signed off on the final three-file diff. --- src/foundation/src/Configuration/Exceptions.php | 3 +++ src/foundation/src/Exceptions/Handler.php | 7 +++++-- src/support/src/ServiceProvider.php | 6 +++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/foundation/src/Configuration/Exceptions.php b/src/foundation/src/Configuration/Exceptions.php index 71fffceec..fad89028c 100644 --- a/src/foundation/src/Configuration/Exceptions.php +++ b/src/foundation/src/Configuration/Exceptions.php @@ -201,6 +201,9 @@ public function shouldRenderJsonWhen(callable $callback): static /** * Indicate that the given exception class should not be ignored. * + * Boot-only. The exception lists persist on the shared handler and affect + * exception reporting for every subsequent request and job in the worker. + * * @param array>|class-string $class */ public function stopIgnoring(array|string $class): static diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index 3b8bddb1d..af0a06a4b 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -641,18 +641,21 @@ public function throttleUsing(callable $throttleUsing): static /** * Remove the given exception class from the list of exceptions that should be ignored. + * + * Boot-only. The exception lists persist on the shared handler and affect + * exception reporting for every subsequent request and job in the worker. */ public function stopIgnoring(array|string $exceptions): static { $exceptions = Arr::wrap($exceptions); $this->dontReport = (new Collection($this->dontReport)) - ->reject(fn ($ignored) => in_array($ignored, $exceptions)) + ->diff($exceptions) ->values() ->all(); $this->internalDontReport = (new Collection($this->internalDontReport)) - ->reject(fn ($ignored) => in_array($ignored, $exceptions)) + ->diff($exceptions) ->values() ->all(); diff --git a/src/support/src/ServiceProvider.php b/src/support/src/ServiceProvider.php index 41aea538a..895abf61a 100644 --- a/src/support/src/ServiceProvider.php +++ b/src/support/src/ServiceProvider.php @@ -505,10 +505,10 @@ public static function removeProviderFromBootstrapFile(string|array $providersTo ->values() ->when( $strict, - static fn (Collection $providerCollection) => $providerCollection->reject(fn (string $p) => in_array($p, $providersToRemove, true)), - static fn (Collection $providerCollection) => $providerCollection->reject(fn (string $p) => Str::contains($p, $providersToRemove)) + static fn (Collection $providerCollection): Collection => $providerCollection->diff($providersToRemove), + static fn (Collection $providerCollection): Collection => $providerCollection->reject(fn (string $p): bool => Str::contains($p, $providersToRemove)) ) - ->map(fn ($p) => ' ' . $p . '::class,') + ->map(fn (string $p): string => ' ' . $p . '::class,') ->implode(PHP_EOL); $content = ' Date: Sun, 6 Sep 2026 05:16:39 +0000 Subject: [PATCH 10/22] Fix Composer package uninstall event dispatch The pre-package-uninstall callback retained Laravel's container array access after Hypervel removed that API. Its child process failed before dispatching the package event, and the best-effort warning allowed removal to continue without running package cleanup such as Telescope's provider removal. Resolve the event dispatcher through the named container API and retain the current Laravel process isolation, development-mode gate and failure reporting. Preserve the dispatcher contract's mixed result. Narrow the Composer operation to the UninstallOperation supplied by this event, without adding runtime guards or changing public signatures. Complete the current-source disposition of the full uninstall history: - https://github.com/laravel/framework/pull/57144 introduces the callback and provider-removal API; the provider implementation and all three upstream test assertions were already present. - https://github.com/laravel/framework/pull/57222 and https://github.com/laravel/framework/pull/57226 are bootstrap fixes superseded by the process-based implementation, so no legacy branches are reintroduced. - https://github.com/laravel/framework/pull/58177 owns process isolation. - https://github.com/laravel/framework/pull/58338 owns the dev-mode gate. - https://github.com/laravel/framework/pull/58609 owns encompassing error reporting so cleanup failures do not prevent package removal. Porting source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Add Composer as a development dependency for real package-event tests and restore static analysis of ComposerScripts. Tests exercise successive package removals, non-dev mode, normal and verbose failure output, and suppression of the deliberate fixture exception through the bound handler. Use an isolated Testbench runtime and restore all owned files and cwd. Document the public script and package-listener APIs. The newly available Composer types also exposed an obsolete Testbench method-availability check. Keep its loaded-class check, remove the dead compatibility condition and ignore, and extend the existing serve test to verify timeout disabling while restoring Composer's timeout for every case. Validation: - Uninstall tests: 4 tests, 10 assertions. - Affected ParaTest suites: 120 tests, 353 assertions. - Serve command tests: 3 tests, 26 assertions. - Testbench contract suite: 543 tests, 1655 assertions, 3 skips. - Full source and type PHPStan checks, scoped formatting, Composer validation and git diff checks pass. Peer-reviewed and signed off by claude-laravel-parity. Resolves parity issues AI-019 and AI-022. --- composer.json | 1 + phpstan.neon.dist | 1 - src/docs/packages.md | 33 ++++ src/foundation/src/ComposerScripts.php | 9 +- .../src/Foundation/Console/ServeCommand.php | 8 +- .../ComposerScriptsUninstallTest.php | 157 ++++++++++++++++++ .../ComposerUninstallServiceProvider.php | 33 ++++ .../Foundation/Console/ServeCommandTest.php | 17 ++ 8 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 tests/Foundation/ComposerScriptsUninstallTest.php create mode 100644 tests/Foundation/Fixtures/ComposerUninstallServiceProvider.php diff --git a/composer.json b/composer.json index b3ec1853f..954253254 100644 --- a/composer.json +++ b/composer.json @@ -308,6 +308,7 @@ "ably/ably-php": "^1.0", "algolia/algoliasearch-client-php": "^4.0", "brianium/paratest": "^7.24", + "composer/composer": "^2.10.3", "composer/semver": "^3.4", "fakerphp/faker": "^1.24", "friendsofphp/php-cs-fixer": "^3.57.2", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 4a1cca4c8..eec673130 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -31,7 +31,6 @@ parameters: - %currentWorkingDirectory%/src/*/node_modules/* - %currentWorkingDirectory%/src/*/resources/views/* - %currentWorkingDirectory%/src/*/publish/* - - %currentWorkingDirectory%/src/foundation/src/ComposerScripts.php ignoreErrors: # CreatesApplication is an open trait consumed by both PHPUnit test cases and the # standalone Testbench application. PHPStan reports this shared capability guard diff --git a/src/docs/packages.md b/src/docs/packages.md index 0c576400b..a79959c95 100644 --- a/src/docs/packages.md +++ b/src/docs/packages.md @@ -6,6 +6,7 @@ - [Generating Facade Docblocks](#generating-facade-docblocks) - [Package Discovery](#package-discovery) - [Test State Cleanup](#test-state-cleanup) +- [Package Uninstallation](#package-uninstallation) - [Inspecting Installed Packages](#inspecting-installed-packages) - [Service Providers](#service-providers) - [Provider Priority](#provider-priority) @@ -179,6 +180,38 @@ Use your Composer package name as the callback name. Registrar classes are disco Test-state callbacks run after the test application has been destroyed. Use them for process-local state that can be reset directly, not cleanup that resolves container services. Use the appropriate testing trait to clean up external resources. + +## Package Uninstallation + +Packages may listen for an event before Composer removes them. To enable these events, add the following script to your application's `composer.json` file: + +```json +"scripts": { + "pre-package-uninstall": [ + "Hypervel\\Foundation\\ComposerScripts::prePackageUninstall" + ] +} +``` + +Register a listener in your package's service provider using the event name `composer_package.vendor/package:pre_uninstall`, replacing `vendor/package` with your Composer package name. For example, you may remove a manually registered provider from the application's `bootstrap/providers.php` file: + +```php +use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Support\ServiceProvider; + +/** + * Bootstrap any package services. + */ +public function boot(Dispatcher $events): void +{ + $events->listen('composer_package.vendor/courier:pre_uninstall', static function (): void { + ServiceProvider::removeProviderFromBootstrapFile(CourierServiceProvider::class); + }); +} +``` + +These events do not run when Composer's `--no-dev` option is used. If an event cannot be dispatched or a listener fails, Hypervel displays a warning and allows the package removal to continue. Run Composer with `--verbose` to see the exception message. + ## Inspecting Installed Packages diff --git a/src/foundation/src/ComposerScripts.php b/src/foundation/src/ComposerScripts.php index 6a58ba8f0..56d42ce0b 100644 --- a/src/foundation/src/ComposerScripts.php +++ b/src/foundation/src/ComposerScripts.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation; +use Composer\DependencyResolver\Operation\UninstallOperation; use Composer\Installer\PackageEvent; use Composer\IO\IOInterface; use Composer\Script\Event; @@ -70,16 +71,18 @@ public static function prePackageUninstall(PackageEvent $event): void // Ensure we can encrypt our serializable closure... (new EncryptionServiceProvider($hypervel))->register(); - $name = $event->getOperation()->getPackage()->getName(); + /** @var UninstallOperation $operation */ + $operation = $event->getOperation(); + $name = $operation->getPackage()->getName(); $eventName = "composer_package.{$name}:pre_uninstall"; $hypervel->make(ProcessDriver::class)->run( - static fn () => app()['events']->dispatch($eventName) + static fn (): mixed => app()->make('events')->dispatch($eventName) ); } catch (Throwable $e) { // Ignore any errors to allow the package removal to complete... $event->getIO()->write('There was an error dispatching or handling the [' . ($eventName ?? 'unknown') . '] event. Continuing with package removal...'); - $event->getIO()->writeError('Exception message: ' . $e->getMessage(), verbosity: IOInterface::VERBOSE); // @phpstan-ignore class.notFound (Composer exists if this is running) + $event->getIO()->writeError('Exception message: ' . $e->getMessage(), verbosity: IOInterface::VERBOSE); } } diff --git a/src/testbench/src/Foundation/Console/ServeCommand.php b/src/testbench/src/Foundation/Console/ServeCommand.php index 22f06a6a3..3686309f1 100644 --- a/src/testbench/src/Foundation/Console/ServeCommand.php +++ b/src/testbench/src/Foundation/Console/ServeCommand.php @@ -22,13 +22,13 @@ #[AsCommand(name: 'serve', description: 'Start Hypervel servers.')] class ServeCommand extends Command { + /** + * Execute the console command. + */ #[Override] protected function execute(InputInterface $input, OutputInterface $output): int { - if ( - class_exists(ComposerConfig::class, false) - && method_exists(ComposerConfig::class, 'disableProcessTimeout') // @phpstan-ignore function.impossibleType - ) { + if (class_exists(ComposerConfig::class, false)) { ComposerConfig::disableProcessTimeout(); } diff --git a/tests/Foundation/ComposerScriptsUninstallTest.php b/tests/Foundation/ComposerScriptsUninstallTest.php new file mode 100644 index 000000000..3dbc921d4 --- /dev/null +++ b/tests/Foundation/ComposerScriptsUninstallTest.php @@ -0,0 +1,157 @@ +files = new Filesystem; + $this->providersPath = $this->app->getBootstrapProvidersPath(); + $this->originalProviders = $this->files->get($this->providersPath); + $this->marker = $this->app->storagePath('composer-uninstall-events.log'); + $this->previousDirectory = getcwd(); + + $this->composer = new Composer; + $config = new Config(false); + $config->merge(['config' => ['vendor-dir' => $this->app->basePath('vendor')]]); + $this->composer->setConfig($config); + + // Only the child application boots this provider. + $this->files->replace($this->providersPath, 'app->basePath()); + } + + /** + * Restore the shared application files and working directory. + */ + protected function tearDown(): void + { + CleanupActions::run( + fn (): bool => chdir($this->previousDirectory), + function (): void { + $this->files->replace($this->providersPath, $this->originalProviders); + }, + fn (): bool => $this->files->delete($this->marker), + function (): void { + parent::tearDown(); + }, + ); + } + + public function testPrePackageUninstallDispatchesEachPackageEvent(): void + { + $io = new BufferIO; + + ComposerScripts::prePackageUninstall($this->createPackageEvent('example/first', $io)); + ComposerScripts::prePackageUninstall($this->createPackageEvent('example/second', $io)); + + $this->assertSame('', $io->getOutput()); + $this->assertSame( + 'composer_package.example/first:pre_uninstall' . PHP_EOL . 'composer_package.example/second:pre_uninstall' . PHP_EOL, + $this->files->get($this->marker), + ); + } + + public function testPrePackageUninstallDoesNotDispatchOutsideDevMode(): void + { + $io = new BufferIO; + + ComposerScripts::prePackageUninstall($this->createPackageEvent('example/first', $io, devMode: false)); + + $this->assertSame('', $io->getOutput()); + $this->assertFileDoesNotExist($this->marker); + } + + #[DataProvider('failureVerbosityProvider')] + public function testPrePackageUninstallContinuesAfterListenerFailure(int $verbosity, string $exceptionOutput): void + { + $io = new BufferIO(verbosity: $verbosity); + + ComposerScripts::prePackageUninstall($this->createPackageEvent('example/failing', $io)); + + $this->assertSame('composer_package.example/failing:pre_uninstall' . PHP_EOL, $this->files->get($this->marker)); + $this->assertSame( + 'There was an error dispatching or handling the [composer_package.example/failing:pre_uninstall] event. Continuing with package removal...' . PHP_EOL . $exceptionOutput, + $io->getOutput(), + ); + + $logPath = $this->app->storagePath('logs/hypervel.log'); + + $this->assertStringNotContainsString( + 'Package cleanup failed.', + $this->files->exists($logPath) ? $this->files->get($logPath) : '', + ); + } + + /** + * Provide the exception detail shown at each output level. + * + * @return array + */ + public static function failureVerbosityProvider(): array + { + return [ + 'normal' => [OutputInterface::VERBOSITY_NORMAL, ''], + 'verbose' => [OutputInterface::VERBOSITY_VERBOSE, 'Exception message: Package cleanup failed.' . PHP_EOL], + ]; + } + + /** + * Create the Composer event for a package removal. + */ + protected function createPackageEvent(string $package, BufferIO $io, bool $devMode = true): PackageEvent + { + $operation = new UninstallOperation(new Package($package, '1.0.0.0', '1.0.0')); + + return new PackageEvent( + PackageEvents::PRE_PACKAGE_UNINSTALL, + $this->composer, + $io, + $devMode, + new ArrayRepository, + [$operation], + $operation, + ); + } +} diff --git a/tests/Foundation/Fixtures/ComposerUninstallServiceProvider.php b/tests/Foundation/Fixtures/ComposerUninstallServiceProvider.php new file mode 100644 index 000000000..aac8a376e --- /dev/null +++ b/tests/Foundation/Fixtures/ComposerUninstallServiceProvider.php @@ -0,0 +1,33 @@ +dontReport(RuntimeException::class); + + $marker = $this->app->storagePath('composer-uninstall-events.log'); + $events->listen('composer_package.example/*:pre_uninstall', static function (string $event) use ($files, $marker): void { + $files->append($marker, $event . PHP_EOL); + + if ($event === 'composer_package.example/failing:pre_uninstall') { + throw new RuntimeException('Package cleanup failed.'); + } + }); + } +} diff --git a/tests/Testbench/Foundation/Console/ServeCommandTest.php b/tests/Testbench/Foundation/Console/ServeCommandTest.php index c7ade76e8..980ef773a 100644 --- a/tests/Testbench/Foundation/Console/ServeCommandTest.php +++ b/tests/Testbench/Foundation/Console/ServeCommandTest.php @@ -4,6 +4,8 @@ namespace Hypervel\Tests\Testbench\Foundation\Console; +use Composer\Config as ComposerConfig; +use Composer\Util\ProcessExecutor; use Hypervel\Console\OutputStyle; use Hypervel\Console\View\Components\Factory; use Hypervel\Contracts\Config\Repository; @@ -30,10 +32,16 @@ class ServeCommandTest extends TestCase /** @var array{process: false|string, environment_exists: bool, environment: mixed, server_exists: bool, server: mixed} */ private array $workingPathState; + private int $processTimeout; + + /** + * Capture the working environment and Composer timeout. + */ protected function setUp(): void { parent::setUp(); + $this->processTimeout = ProcessExecutor::getTimeout(); $this->workingPathState = [ 'process' => getenv(self::WORKING_PATH_ENV), 'environment_exists' => array_key_exists(self::WORKING_PATH_ENV, $_ENV), @@ -43,6 +51,9 @@ protected function setUp(): void ]; } + /** + * Restore the working environment and Composer timeout. + */ protected function tearDown(): void { try { @@ -63,6 +74,8 @@ protected function tearDown(): void unset($_SERVER[self::WORKING_PATH_ENV]); } } finally { + ProcessExecutor::setTimeout($this->processTimeout); + parent::tearDown(); } } @@ -100,9 +113,13 @@ public function itStartsTheUnderlyingServerCommandAndDispatchesLifecycleEvents() Application::getInstance()->setRunningInConsole(false); + class_exists(ComposerConfig::class); + ProcessExecutor::setTimeout(300); + $result = $command->run(new ArrayInput([]), new NullOutput); $this->assertSame(0, $result); + $this->assertSame(0, ProcessExecutor::getTimeout()); $this->assertCount(1, $startedEvents); $this->assertCount(1, $endedEvents); $this->assertSame(0, $endedEvents[0]->exitCode); From 25dc2ea9a3144ba72778522a4e2357a9cebc5cfc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:35:36 +0000 Subject: [PATCH 11/22] Clarify PHPUnit exception assertion cleanup Expand the existing testing follow-up with the combined exception-object assertion and explicit matching semantics. Link the PHPUnit soft-deprecation so the later cleanup has its upstream rationale. The parity session modernizes only test code it modifies; the suite-wide cleanup remains a follow-up. No source or tests changed. Validation: git diff --check. --- docs/todo.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/todo.md b/docs/todo.md index d4d23aa48..542bf0858 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -31,7 +31,7 @@ ## Testing -- Replace PHPUnit 13's deprecated `expectExceptionMessage()` calls across the test suite. Use `expectExceptionMessageIs()` for complete messages and `expectExceptionMessageIsOrContains()` for fragments, auditing each assertion's intent and running its owning test file as it is changed. +- Replace PHPUnit 13's [soft-deprecated `expectExceptionMessage()`](https://github.com/sebastianbergmann/phpunit/issues/6560) calls across the test suite. Preserve intended matching semantics: use `expectExceptionObject()` for combined class/message/code expectations, `expectExceptionMessageIs()` for exact messages, and `expectExceptionMessageIsOrContains()` for substring matching. Audit each assertion's intent and run its owning test file as it is changed. ## HTTP Server From f7f5dd9aee387c02ba462c9a1c5bd0eaa3102d3c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:46:26 +0000 Subject: [PATCH 12/22] Port queue totals and complete Redis bulk parity Add totalSize(), totalPendingSize(), totalDelayedSize() and totalReservedSize() across the supported queue drivers, QueueFake, QueuePoolProxy and the generated Queue facade. Database totals use the upstream state predicates. Redis totals retain virtual discovery and per-queue size methods while pinning one pooled connection per operation. With owner approval, Beanstalkd reads native server stats rather than returning zero; buried jobs remain excluded consistently with size(). Complete the QueueFake bulk delay-attribute fix using Hypervel's existing attribute resolver. Count its three disjoint inspection states without duplicating delayed jobs in the aggregate. Port all Redis Cluster bulk/discovery tests against Hypervel's existing unified Lua batch dispatch and streamed key discovery. The owner approved omitting scanQueueKeys(), bulkOnClusterConnection() and bulkPush(); record those specific extension-point differences in source and the queue README. Preserve numeric queue names as strings when deduplicating discovery. Fix confirmed scan-prefix failures exposed by the port. SafeScan honors the native prefix bit, including combined retry/prefix settings, without double-prefixing key patterns. Cache member scans temporarily disable prefix matching on their held connection and restore it in finally. Keep native member-filtering behavior elsewhere, preserve the retry bit, and exclude this connection-bound scope from proxy/facade forwarding. Port every applicable upstream test and add focused coverage for pooled extension-point dispatch, native Beanstalkd totals, queue-name identity, scan flag combinations, exception cleanup and multi-page cache scans. Document the public totals and scan behavior concisely. Upstream PRs: https://github.com/laravel/framework/pull/61231 https://github.com/laravel/framework/pull/61373 https://github.com/laravel/framework/pull/60916 https://github.com/laravel/framework/pull/61198 Ported from Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: Queue units 659/2799; QueueFake 63/204; Redis units 671/2377 (two existing skips); Redis cache units 479/2312. Redis queue integration 46/269 on both standalone and Cluster; permanent cache flush/prune integration 32/1279 on Cluster, with both files also passing standalone. SafeScan regressions pass on both topologies. Full source/type PHPStan, scoped project formatter, Queue/Redis facade lint and diff checks pass. Beanstalkd uses real ServerStats fixtures; no local server is available. Full combined diff independently reviewed and signed off by claude-laravel-parity in Codesonic message 2026-09-06-191511-claude-laravel-parity-to-codex-laravel-parity-pr-61231-61373-60916-61198-combined-diff-approved.md. --- .../Redis/Operations/AllTag/GetEntries.php | 6 +- .../src/Redis/Operations/AllTag/Prune.php | 6 +- .../Redis/Operations/AnyTag/GetTaggedKeys.php | 6 +- .../src/Redis/Operations/AnyTag/Prune.php | 12 +- src/docs/queues.md | 11 + src/docs/redis.md | 4 +- src/queue/README.md | 3 +- src/queue/src/BeanstalkdQueue.php | 36 ++++ src/queue/src/DatabaseQueue.php | 40 ++++ src/queue/src/FailoverQueue.php | 33 +++ src/queue/src/LuaScripts.php | 3 + src/queue/src/NullQueue.php | 32 +++ src/queue/src/QueuePoolProxy.php | 32 +++ src/queue/src/RedisQueue.php | 120 ++++++++--- src/queue/src/SqsQueue.php | 32 +++ src/queue/src/SyncQueue.php | 32 +++ src/redis/src/Operations/SafeScan.php | 20 +- src/redis/src/RedisConnection.php | 29 ++- src/redis/src/RedisProxy.php | 1 + src/support/src/Facades/Queue.php | 4 + src/support/src/Facades/Redis.php | 1 + src/support/src/Testing/Fakes/QueueFake.php | 47 ++++- tests/Cache/Redis/RedisCacheTestCase.php | 10 +- .../Redis/FlushOperationsIntegrationTest.php | 18 +- .../Cache/Redis/PruneIntegrationTest.php | 6 + .../Queue/Redis/RedisQueueTest.php | 195 ++++++++++++++++++ .../Redis/SafeScanIntegrationTest.php | 29 ++- tests/Queue/FailoverQueueTest.php | 10 +- tests/Queue/QueueBeanstalkdQueueTest.php | 69 +++++++ tests/Queue/QueueDatabaseQueueUnitTest.php | 68 +++++- tests/Queue/QueuePoolProxyTest.php | 8 + tests/Queue/QueueRedisQueueTest.php | 51 +++++ tests/Redis/RedisConnectionTest.php | 57 +++++ tests/Support/SupportTestingQueueFakeTest.php | 88 ++++++++ 34 files changed, 1058 insertions(+), 61 deletions(-) diff --git a/src/cache/src/Redis/Operations/AllTag/GetEntries.php b/src/cache/src/Redis/Operations/AllTag/GetEntries.php index 28834e4d8..677320216 100644 --- a/src/cache/src/Redis/Operations/AllTag/GetEntries.php +++ b/src/cache/src/Redis/Operations/AllTag/GetEntries.php @@ -38,8 +38,10 @@ public function execute(array $tagIds): LazyCollection do { $entries = $context->withConnection( - function (RedisConnection $connection) use ($prefix, $tagId, &$cursor) { - return $connection->zscan($prefix . $tagId, $cursor, '*', 1000); + function (RedisConnection $connection) use ($prefix, $tagId, &$cursor): mixed { + return $connection->withoutScanPrefix(function () use ($connection, $prefix, $tagId, &$cursor): mixed { + return $connection->zscan($prefix . $tagId, $cursor, '*', 1000); + }); } ); diff --git a/src/cache/src/Redis/Operations/AllTag/Prune.php b/src/cache/src/Redis/Operations/AllTag/Prune.php index 6bdba4555..b433af34c 100644 --- a/src/cache/src/Redis/Operations/AllTag/Prune.php +++ b/src/cache/src/Redis/Operations/AllTag/Prune.php @@ -100,8 +100,10 @@ private function removeOrphanedEntries( $iterator = PhpRedis::initialScanCursor(); do { - // ZSCAN returns [member => score, ...] array - $members = $connection->zScan($tagKey, $iterator, '*', $scanCount); + // Tag members omit OPT_PREFIX, so scan every member without prefixing the pattern. + $members = $connection->withoutScanPrefix(function () use ($connection, $tagKey, &$iterator, $scanCount): mixed { + return $connection->zScan($tagKey, $iterator, '*', $scanCount); + }); if ($members === false || ! is_array($members)) { break; diff --git a/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php b/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php index 897b4d7d2..e61333647 100644 --- a/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php +++ b/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php @@ -90,8 +90,10 @@ private function hscanGenerator(string $tagKey, int $count): Generator do { // Acquire connection just for this HSCAN batch $fields = $this->context->withConnection( - function (RedisConnection $connection) use ($tagKey, &$iterator, $count) { - return $connection->hscan($tagKey, $iterator, null, $count); + function (RedisConnection $connection) use ($tagKey, &$iterator, $count): mixed { + return $connection->withoutScanPrefix(function () use ($connection, $tagKey, &$iterator, $count): mixed { + return $connection->hscan($tagKey, $iterator, null, $count); + }); } ); diff --git a/src/cache/src/Redis/Operations/AnyTag/Prune.php b/src/cache/src/Redis/Operations/AnyTag/Prune.php index ab8417d68..5cd1c2eb5 100644 --- a/src/cache/src/Redis/Operations/AnyTag/Prune.php +++ b/src/cache/src/Redis/Operations/AnyTag/Prune.php @@ -169,8 +169,10 @@ private function cleanupTagHashUsingLua( $iterator = PhpRedis::initialScanCursor(); do { - // HSCAN returns [field => value, ...] array - $fields = $connection->hScan($tagHash, $iterator, '*', $scanCount); + // Tag fields omit OPT_PREFIX, so scan every field without prefixing the pattern. + $fields = $connection->withoutScanPrefix(function () use ($connection, $tagHash, &$iterator, $scanCount): mixed { + return $connection->hScan($tagHash, $iterator, '*', $scanCount); + }); if ($fields === false || ! is_array($fields)) { break; @@ -230,8 +232,10 @@ private function cleanupTagHashCluster( $iterator = PhpRedis::initialScanCursor(); do { - // HSCAN returns [field => value, ...] array - $fields = $connection->hScan($tagHash, $iterator, '*', $scanCount); + // Tag fields omit OPT_PREFIX, so scan every field without prefixing the pattern. + $fields = $connection->withoutScanPrefix(function () use ($connection, $tagHash, &$iterator, $scanCount): mixed { + return $connection->hScan($tagHash, $iterator, '*', $scanCount); + }); if ($fields === false || ! is_array($fields)) { break; diff --git a/src/docs/queues.md b/src/docs/queues.md index a7684e9a4..9ef390c1c 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -3304,6 +3304,17 @@ $reserved = $queue->allReservedJobs(); These methods load every matching job into memory. Avoid using them against very large backlogs in latency-sensitive code. +To count jobs across every queue on a database, Redis, or Beanstalkd connection, use the `totalSize`, `totalPendingSize`, `totalDelayedSize`, and `totalReservedSize` methods: + +```php +$total = $queue->totalSize(); +$pending = $queue->totalPendingSize(); +$delayed = $queue->totalDelayedSize(); +$reserved = $queue->totalReservedSize(); +``` + +The total includes pending, delayed, and reserved jobs. Beanstalkd's buried jobs are excluded. + ## Monitoring Your Queues diff --git a/src/docs/redis.md b/src/docs/redis.md index dc11900b6..3959ffcc5 100644 --- a/src/docs/redis.md +++ b/src/docs/redis.md @@ -608,7 +608,7 @@ PhpRedis does not support pipelining on Redis Cluster connections. Pipelining re ### Advanced Helpers -If you need to stream keys with Redis' `SCAN` command, use `safeScan` while holding a pooled connection. The `safeScan` method handles PhpRedis' `OPT_PREFIX` behavior by adding the prefix to the scan pattern and removing it from returned keys: +If you need to stream keys with Redis' `SCAN` command, use `safeScan` while holding a pooled connection. The `safeScan` method applies your configured prefix exactly once, including when PhpRedis' `SCAN_PREFIX` option is enabled, and removes it from returned keys: ```php use Hypervel\Redis\RedisConnection; @@ -619,6 +619,8 @@ $keys = Redis::withConnection(function (RedisConnection $connection): array { }, transform: false); ``` +The `SCAN_PREFIX` option also prefixes hash-field and set-member patterns. To scan these without prefixing the pattern, wrap the scan calls in `$connection->withoutScanPrefix($callback)` inside `withConnection`. Key prefixing is unchanged, and scan options are restored when the callback finishes. + The `evalWithShaCache` method executes a Lua script using `evalSha` and automatically falls back to `eval` when Redis has not cached the script yet: ```php diff --git a/src/queue/README.md b/src/queue/README.md index 552ba2baa..207046744 100644 --- a/src/queue/README.md +++ b/src/queue/README.md @@ -10,6 +10,7 @@ Documentation: https://hypervel.org/docs/queues - The protected `enqueueUsing()` callback receives the queue that owns the operation as its first argument. This lets deferred work borrow a fresh pooled queue connection after a database transaction commits instead of retaining a connection for the lifetime of the transaction. - Positive per-message delays on SQS FIFO queues throw `LogicException`. Laravel silently omits the delay and sends the job immediately. - `RateLimited::releaseAfter(0)` requests an immediate retry. Laravel treats zero as absent and uses the limiter's computed retry delay. -- Redis bulk dispatch uses one same-slot Lua call, keeping Cluster dispatch to one round trip and giving every Redis topology the same exact completion count. Laravel uses a nested transaction and pipeline. +- `RedisQueue::scanQueueKeys()` is intentionally omitted. Queue discovery streams keys on a held connection instead of materializing a physical-key array; override `allQueueNames()` to customize discovery. +- Redis bulk dispatch uses `enqueueBatch()` and `LuaScripts::bulk()` for both standalone Redis and Cluster, storing immediate and delayed jobs together with an exact completion count. Laravel's `RedisQueue::bulkOnClusterConnection()` and `LuaScripts::bulkPush()` helpers are intentionally omitted. Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Queue diff --git a/src/queue/src/BeanstalkdQueue.php b/src/queue/src/BeanstalkdQueue.php index 54d0ca5bb..bdde992b5 100644 --- a/src/queue/src/BeanstalkdQueue.php +++ b/src/queue/src/BeanstalkdQueue.php @@ -73,6 +73,42 @@ public function reservedSize(?string $queue = null): int return $this->pheanstalk->statsTube(new TubeName($this->getQueue($queue)))->currentJobsReserved; } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + $stats = $this->pheanstalk->stats(); + + return $stats->currentJobsReady + + $stats->currentJobsDelayed + + $stats->currentJobsReserved; + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return $this->pheanstalk->stats()->currentJobsReady; + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return $this->pheanstalk->stats()->currentJobsDelayed; + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return $this->pheanstalk->stats()->currentJobsReserved; + } + /** * Get the pending jobs for the given queue. */ diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index 77ceef0c7..946c7bb6f 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -93,6 +93,46 @@ public function reservedSize(?string $queue = null): int ->count(); } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + return $this->getDatabase()->table($this->table)->count(); + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return $this->getDatabase()->table($this->table) + ->whereNull('reserved_at') + ->where('available_at', '<=', $this->currentTime()) + ->count(); + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return $this->getDatabase()->table($this->table) + ->whereNull('reserved_at') + ->where('available_at', '>', $this->currentTime()) + ->count(); + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return $this->getDatabase()->table($this->table) + ->whereNotNull('reserved_at') + ->count(); + } + /** * Get the pending jobs for the given queue. * diff --git a/src/queue/src/FailoverQueue.php b/src/queue/src/FailoverQueue.php index 378e7d900..3f99aa415 100644 --- a/src/queue/src/FailoverQueue.php +++ b/src/queue/src/FailoverQueue.php @@ -77,6 +77,39 @@ public function reservedSize(?string $queue = null): int return $this->manager->connection($this->connections[0])->reservedSize($queue); } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + // Aggregate counts are an optional driver capability outside the core Queue contract. + return $this->manager->connection($this->connections[0])->totalSize(); // @phpstan-ignore method.notFound + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return $this->manager->connection($this->connections[0])->totalPendingSize(); // @phpstan-ignore method.notFound + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return $this->manager->connection($this->connections[0])->totalDelayedSize(); // @phpstan-ignore method.notFound + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return $this->manager->connection($this->connections[0])->totalReservedSize(); // @phpstan-ignore method.notFound + } + /** * Get the pending jobs for the given queue. */ diff --git a/src/queue/src/LuaScripts.php b/src/queue/src/LuaScripts.php index 016aed7f8..95a65a79e 100644 --- a/src/queue/src/LuaScripts.php +++ b/src/queue/src/LuaScripts.php @@ -37,6 +37,9 @@ public static function push(): string LUA; } + // REMOVED: Laravel's bulkPush(). bulk() stores immediate and delayed jobs + // together and returns the count required for dispatch confirmation. + /** * Get the Lua script for pushing delayed jobs onto the queue. * diff --git a/src/queue/src/NullQueue.php b/src/queue/src/NullQueue.php index deb105dfd..7bf849f45 100644 --- a/src/queue/src/NullQueue.php +++ b/src/queue/src/NullQueue.php @@ -44,6 +44,38 @@ public function reservedSize(?string $queue = null): int return 0; } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + return 0; + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return 0; + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return 0; + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return 0; + } + /** * Get the pending jobs for the given queue. */ diff --git a/src/queue/src/QueuePoolProxy.php b/src/queue/src/QueuePoolProxy.php index 364fc0e4b..14c5a391d 100644 --- a/src/queue/src/QueuePoolProxy.php +++ b/src/queue/src/QueuePoolProxy.php @@ -84,6 +84,38 @@ public function reservedSize(?string $queue = null): int return $this->invoke(__FUNCTION__, func_get_args()); } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + return $this->invoke(__FUNCTION__, func_get_args()); + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return $this->invoke(__FUNCTION__, func_get_args()); + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return $this->invoke(__FUNCTION__, func_get_args()); + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return $this->invoke(__FUNCTION__, func_get_args()); + } + /** * Get the pending jobs for the given queue. */ diff --git a/src/queue/src/RedisQueue.php b/src/queue/src/RedisQueue.php index 4371e25db..5a0ed1414 100644 --- a/src/queue/src/RedisQueue.php +++ b/src/queue/src/RedisQueue.php @@ -101,6 +101,46 @@ public function reservedSize(?string $queue = null): int return $this->getConnection()->zcard($this->getQueueRedisKey($queue) . ':reserved'); } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + return $this->getConnection()->withPinnedConnection( + fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->size($name)), + ); + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return $this->getConnection()->withPinnedConnection( + fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->pendingSize($name)), + ); + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return $this->getConnection()->withPinnedConnection( + fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->delayedSize($name)), + ); + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return $this->getConnection()->withPinnedConnection( + fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->reservedSize($name)), + ); + } + /** * Get the pending jobs for the given queue. * @@ -161,6 +201,55 @@ public function allReservedJobs(): Collection return $this->inspectAllQueues(':reserved'); } + /** + * Get the unique queue names. + * + * @return Collection + */ + protected function allQueueNames(): Collection + { + return $this->getConnection()->withConnection( + fn (RedisConnection $connection): Collection => $this->allQueueNamesUsing($connection), + transform: false, + ); + } + + // REMOVED: Laravel's scanQueueKeys(). allQueueNamesUsing() streams keys on + // a held connection instead of materializing a physical-key array. + + /** + * Get the unique queue names using an already-held raw connection. + * + * @return Collection + */ + protected function allQueueNamesUsing(RedisConnection $connection): Collection + { + $this->isCluster ??= $connection->isCluster(); + $names = []; + + foreach ($connection->safeScan('queues:*') as $key) { + $name = substr($key, strlen('queues:')); + + foreach ([':delayed', ':reserved', ':notify'] as $storageSuffix) { + if (str_ends_with($name, $storageSuffix)) { + $name = substr($name, 0, -strlen($storageSuffix)); + break; + } + } + + // Cluster hash tags are routing syntax, so discovery reports the + // canonical queue name. On standalone Redis, braces remain identity. + if ($this->isCluster && preg_match('/^\{([^{}]+)\}$/', $name, $matches) === 1) { + $name = $matches[1]; + } + + // Keep the string value because PHP converts numeric array keys to integers. + $names[$name] = $name; + } + + return Collection::make(array_values($names)); + } + /** * Inspect jobs from one queue while holding one Redis connection. * @@ -188,32 +277,8 @@ function (RedisConnection $connection) use ($name, $suffix): Collection { protected function inspectAllQueues(string $suffix = ''): Collection { return $this->getConnection()->withConnection( - function (RedisConnection $connection) use ($suffix): Collection { - $this->isCluster ??= $connection->isCluster(); - $names = []; - - foreach ($connection->safeScan('queues:*') as $key) { - $name = substr($key, strlen('queues:')); - - foreach ([':delayed', ':reserved', ':notify'] as $storageSuffix) { - if (str_ends_with($name, $storageSuffix)) { - $name = substr($name, 0, -strlen($storageSuffix)); - break; - } - } - - // Cluster hash tags are routing syntax, so discovery reports the - // canonical queue name. On standalone Redis, braces remain identity. - if ($this->isCluster && preg_match('/^\{([^{}]+)\}$/', $name, $matches) === 1) { - $name = $matches[1]; - } - - $names[$name] = true; - } - - return Collection::make(array_keys($names)) - ->flatMap(fn (string $name): Collection => $this->inspectJobsUsing($connection, $name, $suffix)); - }, + fn (RedisConnection $connection): Collection => $this->allQueueNamesUsing($connection) + ->flatMap(fn (string $name): Collection => $this->inspectJobsUsing($connection, $name, $suffix)), transform: false, ); } @@ -292,6 +357,9 @@ static function (Queue $owner) use ($preparedJobs, $queue): void { return null; } + // REMOVED: Laravel's bulkOnClusterConnection(). enqueueBatch() owns Lua + // bulk dispatch for both standalone Redis and Cluster. + /** * Prepare the payload and delay for each of the given jobs. * diff --git a/src/queue/src/SqsQueue.php b/src/queue/src/SqsQueue.php index 7fd5af91d..a7c4392c9 100644 --- a/src/queue/src/SqsQueue.php +++ b/src/queue/src/SqsQueue.php @@ -129,6 +129,38 @@ public function reservedSize(?string $queue = null): int return (int) ($response['Attributes']['ApproximateNumberOfMessagesNotVisible'] ?? 0); } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + return 0; + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return 0; + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return 0; + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return 0; + } + /** * Get the pending jobs for the given queue. */ diff --git a/src/queue/src/SyncQueue.php b/src/queue/src/SyncQueue.php index 7edbca6b6..a216aeefb 100644 --- a/src/queue/src/SyncQueue.php +++ b/src/queue/src/SyncQueue.php @@ -66,6 +66,38 @@ public function reservedSize(?string $queue = null): int return 0; } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + return 0; + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return 0; + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return 0; + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return 0; + } + /** * Get the pending jobs for the given queue. */ diff --git a/src/redis/src/Operations/SafeScan.php b/src/redis/src/Operations/SafeScan.php index fdab04131..907beff47 100644 --- a/src/redis/src/Operations/SafeScan.php +++ b/src/redis/src/Operations/SafeScan.php @@ -9,6 +9,7 @@ use Hypervel\Redis\PhpRedis; use Hypervel\Redis\PhpRedisClusterConnection; use Hypervel\Redis\RedisConnection; +use Redis; /** * Safely scan the Redis keyspace for keys matching a pattern. @@ -21,8 +22,8 @@ * phpredis has an OPT_PREFIX option that automatically prepends a prefix to keys * for most commands (GET, SET, DEL, etc.). However, this creates complexity: * - * 1. **SCAN does NOT auto-prefix the pattern** - You must manually include OPT_PREFIX - * in your SCAN pattern to match keys that were stored with auto-prefixing. + * 1. **SCAN prefixing is configurable** - SCAN_PREFIX adds OPT_PREFIX to the pattern. + * Without it, the pattern must include OPT_PREFIX to match auto-prefixed keys. * * 2. **SCAN returns full keys** - Keys returned include the OPT_PREFIX as stored in Redis. * @@ -31,6 +32,8 @@ * * ## Example of the Bug This Class Prevents * + * With SCAN_PREFIX disabled: + * * ``` * OPT_PREFIX = "myapp:" * Stored key in Redis = "myapp:cache:user:1" @@ -102,11 +105,16 @@ public function execute(string $pattern, int $count = 1000): Generator { $prefixLen = strlen($this->optPrefix); - // SCAN does not automatically apply OPT_PREFIX to the pattern, - // so we must prepend it manually to match keys stored with auto-prefixing. + // Apply OPT_PREFIX exactly once, whether phpredis adds it or not. $scanPattern = $pattern; - if ($prefixLen > 0 && ! str_starts_with($pattern, $this->optPrefix)) { - $scanPattern = $this->optPrefix . $pattern; + if ($prefixLen > 0) { + if (str_starts_with($scanPattern, $this->optPrefix)) { + $scanPattern = substr($scanPattern, $prefixLen); + } + + if (($this->connection->getOption(Redis::OPT_SCAN) & Redis::SCAN_PREFIX) === 0) { + $scanPattern = $this->optPrefix . $scanPattern; + } } // Route to cluster or standard implementation diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index cb4ed75fc..3f9569568 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -1651,6 +1651,33 @@ public function compressed(): bool return $this->connection->getOption(Redis::OPT_COMPRESSION) !== Redis::COMPRESSION_NONE; } + /** + * Execute the given callback without prefixing scan patterns. + * + * Key prefixing remains unchanged. Hold this connection until the callback + * finishes so its scan options are restored before returning it to the pool. + * + * @template TReturn + * + * @param callable(): TReturn $callback + * @return TReturn + */ + public function withoutScanPrefix(callable $callback): mixed + { + if (($this->connection->getOption(Redis::OPT_SCAN) & Redis::SCAN_PREFIX) === 0) { + return $callback(); + } + + $this->connection->setOption(Redis::OPT_SCAN, Redis::SCAN_NOPREFIX); + + try { + return $callback(); + } finally { + // OPT_SCAN setters toggle individual flags; passing the saved bitmask can disable prefixing. + $this->connection->setOption(Redis::OPT_SCAN, Redis::SCAN_PREFIX); + } + } + /** * Execute the given callback without serialization or compression. * @@ -1799,7 +1826,7 @@ protected function normalizeNullReplies(mixed $result): mixed * Safely scan the Redis keyspace for keys matching a pattern. * * This method handles the phpredis OPT_PREFIX complexity correctly: - * - Automatically prepends OPT_PREFIX to the scan pattern + * - Applies OPT_PREFIX to the scan pattern exactly once, respecting SCAN_PREFIX * - Strips OPT_PREFIX from returned keys so they work with other commands * * The connection must be held with transform disabled so SCAN retains its diff --git a/src/redis/src/RedisProxy.php b/src/redis/src/RedisProxy.php index a7b5be86a..10ab18ca2 100644 --- a/src/redis/src/RedisProxy.php +++ b/src/redis/src/RedisProxy.php @@ -80,6 +80,7 @@ class RedisProxy implements ConnectionContract 'safescan', 'setoption', 'shouldtransform', + 'withoutscanprefix', ]; /** diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php index 7b1c9e0e3..3bcfd1b4f 100644 --- a/src/support/src/Facades/Queue.php +++ b/src/support/src/Facades/Queue.php @@ -96,6 +96,10 @@ * @method static \Hypervel\Support\Collection reservedJobs(\UnitEnum|string|null $queue = null) * @method static \Hypervel\Support\Testing\Fakes\QueueFake serializeAndRestore(bool $serializeAndRestore = true) * @method static bool shouldFakeJob(object|string $job) + * @method static int totalDelayedSize() + * @method static int totalPendingSize() + * @method static int totalReservedSize() + * @method static int totalSize() * * @see \Hypervel\Queue\QueueManager * @see \Hypervel\Queue\Queue diff --git a/src/support/src/Facades/Redis.php b/src/support/src/Facades/Redis.php index 9eda33cb1..7edc2d2a9 100644 --- a/src/support/src/Facades/Redis.php +++ b/src/support/src/Facades/Redis.php @@ -344,6 +344,7 @@ protected static function ignoredFacadeDocumenterMethods(): array 'setOption', 'shouldTransform', 'ssubscribe', + 'withoutScanPrefix', ]; } diff --git a/src/support/src/Testing/Fakes/QueueFake.php b/src/support/src/Testing/Fakes/QueueFake.php index fe818a783..9f61cad4f 100644 --- a/src/support/src/Testing/Fakes/QueueFake.php +++ b/src/support/src/Testing/Fakes/QueueFake.php @@ -17,6 +17,8 @@ use Hypervel\Contracts\Queue\Queue; use Hypervel\Contracts\Queue\ShouldBeUnique; use Hypervel\Events\CallQueuedListener; +use Hypervel\Queue\Attributes\Delay; +use Hypervel\Queue\Attributes\ReadsQueueAttributes; use Hypervel\Queue\CallQueuedClosure; use Hypervel\Queue\Jobs\InspectedJob; use Hypervel\Queue\QueueManager; @@ -34,6 +36,7 @@ */ class QueueFake extends QueueManager implements Fake, Queue { + use ReadsQueueAttributes; use ReflectsClosures; /** @@ -414,6 +417,40 @@ public function reservedSize(UnitEnum|string|null $queue = null): int return $this->reservedJobs($queue)->count(); } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + return $this->totalPendingSize() + + $this->totalDelayedSize() + + $this->totalReservedSize(); + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return $this->allPendingJobs()->count(); + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return $this->allDelayedJobs()->count(); + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return $this->allReservedJobs()->count(); + } + /** * Get the pending jobs for the given queue. */ @@ -685,9 +722,13 @@ public function pop(UnitEnum|string|null $queue = null): ?Job public function bulk(array $jobs, mixed $data = '', UnitEnum|string|null $queue = null): mixed { foreach ($jobs as $job) { - is_object($job) && isset($job->delay) - ? $this->later($job->delay, $job, $data, $queue) - : $this->push($job, $data, $queue); + $delay = is_object($job) ? $this->getAttributeValue($job, Delay::class, 'delay') : null; + + if ($delay !== null) { + $this->later($delay, $job, $data, $queue); + } else { + $this->push($job, $data, $queue); + } } return null; diff --git a/tests/Cache/Redis/RedisCacheTestCase.php b/tests/Cache/Redis/RedisCacheTestCase.php index 7d1bf5ea4..8bf52b5f3 100644 --- a/tests/Cache/Redis/RedisCacheTestCase.php +++ b/tests/Cache/Redis/RedisCacheTestCase.php @@ -89,6 +89,9 @@ protected function mockConnection(): m\MockInterface|PhpRedisConnection $connection = m::mock(PhpRedisConnection::class); $connection->shouldReceive('release')->zeroOrMoreTimes(); + $connection->shouldReceive('withoutScanPrefix') + ->andReturnUsing(fn (callable $callback): mixed => $callback()) + ->byDefault(); $connection->shouldReceive('serialized')->andReturn(false)->byDefault(); $connection->shouldReceive('client')->andReturn($client)->byDefault(); $connection->shouldReceive('getOption') @@ -104,7 +107,7 @@ protected function mockConnection(): m\MockInterface|PhpRedisConnection $connection->shouldReceive('pipeline')->andReturn($connection)->byDefault(); $connection->shouldReceive('exec')->andReturn([])->byDefault(); - // Store client reference for backward compatibility during migration + // Expose the client for expectation setup. $connection->_mockClient = $client; return $connection; @@ -134,6 +137,9 @@ protected function mockClusterConnection(): m\MockInterface|PhpRedisClusterConne $connection = m::mock(PhpRedisClusterConnection::class); $connection->shouldReceive('release')->zeroOrMoreTimes(); + $connection->shouldReceive('withoutScanPrefix') + ->andReturnUsing(fn (callable $callback): mixed => $callback()) + ->byDefault(); $connection->shouldReceive('serialized')->andReturn(false)->byDefault(); $connection->shouldReceive('client')->andReturn($client)->byDefault(); $connection->shouldReceive('getOption') @@ -145,7 +151,7 @@ protected function mockClusterConnection(): m\MockInterface|PhpRedisClusterConne ->andReturn('') ->byDefault(); - // Store client reference for backward compatibility during migration + // Expose the client for expectation setup. $connection->_mockClient = $client; return $connection; diff --git a/tests/Integration/Cache/Redis/FlushOperationsIntegrationTest.php b/tests/Integration/Cache/Redis/FlushOperationsIntegrationTest.php index 59080da32..f723b470d 100644 --- a/tests/Integration/Cache/Redis/FlushOperationsIntegrationTest.php +++ b/tests/Integration/Cache/Redis/FlushOperationsIntegrationTest.php @@ -6,6 +6,8 @@ use Hypervel\Cache\TagMode; use Hypervel\Support\Facades\Cache; +use Hypervel\Testbench\Attributes\WithConfig; +use Redis; use Throwable; /** @@ -248,6 +250,8 @@ public function testFlushNonExistentTagGracefullyInAnyMode(): void $this->assertSame('value', Cache::get('item')); } + #[WithConfig('database.redis.options.prefix', 'scan-prefix:')] + #[WithConfig('database.redis.options.scan', Redis::SCAN_PREFIX)] public function testFlushLargeTagSetInAllMode(): void { $this->setTagMode(TagMode::All); @@ -271,25 +275,29 @@ public function testFlushLargeTagSetInAllMode(): void } } + #[WithConfig('database.redis.options.prefix', 'scan-prefix:')] + #[WithConfig('database.redis.options.scan', Redis::SCAN_PREFIX)] public function testFlushLargeTagSetInAnyMode(): void { $this->setTagMode(TagMode::Any); - // Create many items with the same tag - for ($i = 0; $i < 100; ++$i) { - Cache::tags(['bulk'])->put("item.{$i}", "value.{$i}", 60); + // Exceed the HSCAN threshold to exercise paged tag enumeration. + $values = []; + for ($i = 0; $i < 1001; ++$i) { + $values["item.{$i}"] = "value.{$i}"; } + Cache::tags(['bulk'])->putMany($values, 60); // Verify some items exist $this->assertSame('value.0', Cache::get('item.0')); $this->assertSame('value.50', Cache::get('item.50')); - $this->assertSame('value.99', Cache::get('item.99')); + $this->assertSame('value.1000', Cache::get('item.1000')); // Flush all at once Cache::tags(['bulk'])->flush(); // Verify all items are gone - for ($i = 0; $i < 100; ++$i) { + for ($i = 0; $i < 1001; ++$i) { $this->assertNull(Cache::get("item.{$i}")); } } diff --git a/tests/Integration/Cache/Redis/PruneIntegrationTest.php b/tests/Integration/Cache/Redis/PruneIntegrationTest.php index c26da5b67..347ed4d96 100644 --- a/tests/Integration/Cache/Redis/PruneIntegrationTest.php +++ b/tests/Integration/Cache/Redis/PruneIntegrationTest.php @@ -6,6 +6,8 @@ use Hypervel\Cache\TagMode; use Hypervel\Support\Facades\Cache; +use Hypervel\Testbench\Attributes\WithConfig; +use Redis; /** * Integration tests for prune (cleanup) operations. @@ -80,6 +82,8 @@ public function testAnyModeForgetRemovesTagMembership(): void // ANY MODE - PRUNE COMMAND // ========================================================================= + #[WithConfig('database.redis.options.prefix', 'scan-prefix:')] + #[WithConfig('database.redis.options.scan', Redis::SCAN_PREFIX)] public function testAnyModePruneRemovesOrphanedFields(): void { $this->setTagMode(TagMode::Any); @@ -287,6 +291,8 @@ public function testAllModeFlushLeavesOrphanedEntriesInOtherTags(): void // ALL MODE - PRUNE COMMAND // ========================================================================= + #[WithConfig('database.redis.options.prefix', 'scan-prefix:')] + #[WithConfig('database.redis.options.scan', Redis::SCAN_PREFIX)] public function testAllModePruneRemovesOrphanedEntries(): void { $this->setTagMode(TagMode::All); diff --git a/tests/Integration/Queue/Redis/RedisQueueTest.php b/tests/Integration/Queue/Redis/RedisQueueTest.php index 2e7a0cc90..8322ffa80 100644 --- a/tests/Integration/Queue/Redis/RedisQueueTest.php +++ b/tests/Integration/Queue/Redis/RedisQueueTest.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Redis\Factory as RedisFactory; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; +use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Events\JobPayloadFinalizing; use Hypervel\Queue\Events\JobQueued; use Hypervel\Queue\Events\JobQueueing; @@ -26,6 +27,7 @@ use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use Redis as PhpRedis; use RedisCluster; use ReflectionMethod; @@ -670,6 +672,8 @@ public function testAllPendingJobsReportExplicitHashTaggedNamesByTopology(): voi $this->usingRedisCluster() ? 'orders' : '{orders}', 0, ); + $this->assertSame(1, $this->queue->totalSize()); + $this->assertSame(1, $this->queue->totalPendingSize()); } public function testAllDelayedJobs(): void @@ -702,6 +706,178 @@ public function testAllReservedJobs(): void $jobs->each(fn (InspectedJob $job) => $this->assertInspectedJob($job, $job->queue, 1)); } + public function testTotalSize(): void + { + $this->setQueue($this->defaultQueueName()); + + $this->queue->push(new RedisQueueIntegrationTestJob(1)); + $this->queue->pushOn('emails', new RedisQueueIntegrationTestJob(2)); + $this->queue->later(60, new RedisQueueIntegrationTestJob(3)); + + $this->assertSame(3, $this->queue->totalSize()); + } + + public function testTotalPendingSize(): void + { + $this->setQueue($this->defaultQueueName()); + + $this->queue->push(new RedisQueueIntegrationTestJob(1)); + $this->queue->pushOn('emails', new RedisQueueIntegrationTestJob(2)); + + $this->assertSame(2, $this->queue->totalPendingSize()); + } + + public function testTotalDelayedSize(): void + { + $this->setQueue($this->defaultQueueName()); + + $this->queue->later(60, new RedisQueueIntegrationTestJob(1)); + $this->queue->laterOn('emails', 60, new RedisQueueIntegrationTestJob(2)); + + $this->assertSame(2, $this->queue->totalDelayedSize()); + } + + public function testTotalReservedSize(): void + { + $this->setQueue($this->defaultQueueName()); + + $this->queue->push(new RedisQueueIntegrationTestJob(1)); + $this->queue->pushOn('emails', new RedisQueueIntegrationTestJob(2)); + $this->queue->pop(); + $this->queue->pop('emails'); + + $this->assertSame(2, $this->queue->totalReservedSize()); + } + + public function testBulkPushesAllJobsOntoQueue(): void + { + $this->setQueue('default'); + + $this->queue->bulk([ + new RedisQueueIntegrationTestJob(1), + new RedisQueueIntegrationTestJob(2), + new RedisQueueIntegrationTestJob(3), + ], '', 'bulk-test'); + + $this->assertSame(3, $this->queue->size('bulk-test')); + + $seen = []; + + for ($i = 0; $i < 3; ++$i) { + $seen[] = unserialize(json_decode($this->queue->pop('bulk-test')->getRawBody())->data->command)->i; + } + + sort($seen); + + $this->assertSame([1, 2, 3], $seen); + $this->assertNull($this->queue->pop('bulk-test')); + } + + public function testBulkPushesDelayedJobsOntoDelayedQueue(): void + { + $this->setQueue('default'); + + $this->queue->bulk([ + new RedisQueueIntegrationTestJob(1), + new RedisQueueIntegrationTestDelayedJob(2), + ], '', 'bulk-delay'); + + $redisKey = $this->getQueueRedisKey('bulk-delay'); + + $this->assertSame(1, $this->redisConnection()->llen($redisKey)); + $this->assertSame(1, $this->redisConnection()->zcard("{$redisKey}:delayed")); + } + + public function testBulkPushesManyJobsOntoQueue(): void + { + $this->setQueue('default'); + + $jobs = []; + + for ($i = 0; $i < 1050; ++$i) { + $jobs[] = new RedisQueueIntegrationTestJob($i); + } + + $this->queue->bulk($jobs, '', 'bulk-many'); + + $redisKey = $this->getQueueRedisKey('bulk-many'); + + $this->assertSame(1050, $this->queue->size('bulk-many')); + $this->assertSame(1050, $this->redisConnection()->llen("{$redisKey}:notify")); + } + + public function testAllQueueNamesReturnsQueuesAcrossMultipleQueues(): void + { + $default = $this->defaultQueueName(); + $this->setQueue($default); + + $this->queue->push(new RedisQueueIntegrationTestJob(1)); + $this->queue->pushOn('emails', new RedisQueueIntegrationTestJob(2)); + $this->queue->pushOn('notifications', new RedisQueueIntegrationTestJob(3)); + + $names = (new ReflectionMethod($this->queue, 'allQueueNames')) + ->invoke($this->queue) + ->sort() + ->values() + ->all(); + + $this->assertSame([$default, 'emails', 'notifications'], $names); + } + + #[DataProvider('scanRetryOptions')] + public function testScanningQueueNamesDoesNotDoublePrefixTheMatchPattern(bool $retryScan): void + { + $connectionName = $this->createRedisConnectionWithOptions('queue-scan-prefix', [ + 'prefix' => 'test_', + 'scan' => PhpRedis::SCAN_PREFIX, + ]); + $this->setQueue('default', $connectionName); + $redis = Redis::connection($connectionName); + + $redis->withPinnedConnection(function () use ($redis, $retryScan): void { + if ($retryScan) { + $redis->withConnection(function (RedisConnection $connection): void { + $connection->setOption(PhpRedis::OPT_SCAN, PhpRedis::SCAN_RETRY); + }); + } + + $this->queue->push(new RedisQueueIntegrationTestJob(1)); + + $this->assertSame( + ['default'], + (new ReflectionMethod($this->queue, 'allQueueNames'))->invoke($this->queue)->all(), + ); + }); + } + + /** + * Provide the retry settings used alongside scan prefixing. + */ + public static function scanRetryOptions(): array + { + return [ + 'prefix only' => [false], + 'prefix and retry' => [true], + ]; + } + + public function testTotalSizesPreserveQueueNamesAcrossEveryState(): void + { + $this->setQueue(); + + foreach (['reports:high', '0'] as $name) { + $this->queue->pushOn($name, new RedisQueueIntegrationTestJob(1)); + $this->queue->pop($name); + $this->queue->pushOn($name, new RedisQueueIntegrationTestJob(2)); + $this->queue->laterOn($name, 60, new RedisQueueIntegrationTestJob(3)); + } + + $this->assertSame(6, $this->queue->totalSize()); + $this->assertSame(2, $this->queue->totalPendingSize()); + $this->assertSame(2, $this->queue->totalDelayedSize()); + $this->assertSame(2, $this->queue->totalReservedSize()); + } + public function testInvalidInspectedPayloadRetainsItsRedisRemovalMember(): void { $this->setQueue('poison'); @@ -772,6 +948,25 @@ public function handle(): void } } +#[Delay(60)] +class RedisQueueIntegrationTestDelayedJob +{ + /** + * Create a delayed test job. + */ + public function __construct( + public int $i, + ) { + } + + /** + * Handle the job. + */ + public function handle(): void + { + } +} + class RedisQueueIntegrationDelayedJob extends RedisQueueIntegrationTestJob { public function __construct(int $i, public int $delay) diff --git a/tests/Integration/Redis/SafeScanIntegrationTest.php b/tests/Integration/Redis/SafeScanIntegrationTest.php index 5bc1b4f45..6e9051b70 100644 --- a/tests/Integration/Redis/SafeScanIntegrationTest.php +++ b/tests/Integration/Redis/SafeScanIntegrationTest.php @@ -8,6 +8,8 @@ use Hypervel\Redis\RedisConnection; use Hypervel\Support\Facades\Redis; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use Redis as PhpRedis; /** * Integration tests for SafeScan and FlushByPattern operations. @@ -20,7 +22,8 @@ class SafeScanIntegrationTest extends TestCase { use InteractsWithRedis; - public function testSafeScanYieldsKeysWithoutPrefix() + #[DataProvider('scanPrefixOptions')] + public function testSafeScanYieldsKeysWithoutPrefix(bool $prefixScan, bool $retryScan): void { $prefix = 'safescan_test:'; $connectionName = $this->createRedisConnectionWithPrefix($prefix); @@ -33,8 +36,16 @@ public function testSafeScanYieldsKeysWithoutPrefix() $redis->set('key3', 'val3'); // safeScan should yield keys WITHOUT the prefix - $keys = $redis->withConnection(function (RedisConnection $connection) { - return iterator_to_array($connection->safeScan('key*')); + $keys = $redis->withConnection(function (RedisConnection $connection) use ($prefix, $prefixScan, $retryScan): array { + $connection->setOption(PhpRedis::OPT_SCAN, $retryScan ? PhpRedis::SCAN_RETRY : PhpRedis::SCAN_NORETRY); + $connection->setOption(PhpRedis::OPT_SCAN, $prefixScan ? PhpRedis::SCAN_PREFIX : PhpRedis::SCAN_NOPREFIX); + $options = $connection->getOption(PhpRedis::OPT_SCAN); + $keys = iterator_to_array($connection->safeScan('key*')); + + $this->assertEqualsCanonicalizing($keys, iterator_to_array($connection->safeScan($prefix . 'key*'))); + $this->assertSame($options, $connection->getOption(PhpRedis::OPT_SCAN)); + + return $keys; }, transform: false); sort($keys); @@ -45,6 +56,18 @@ public function testSafeScanYieldsKeysWithoutPrefix() $this->assertSame('val1', $redis->get('key1')); } + /** + * Provide native scan prefix and retry settings. + */ + public static function scanPrefixOptions(): array + { + return [ + 'no prefixing' => [false, false], + 'prefixing' => [true, false], + 'prefixing and retry' => [true, true], + ]; + } + public function testSafeScanWithoutPrefix() { $connectionName = $this->createRedisConnectionWithPrefix(''); diff --git a/tests/Queue/FailoverQueueTest.php b/tests/Queue/FailoverQueueTest.php index 11b057790..4bfc87fdb 100644 --- a/tests/Queue/FailoverQueueTest.php +++ b/tests/Queue/FailoverQueueTest.php @@ -537,7 +537,11 @@ public function testInspectionDelegatesToTheFirstConnection(): void $redis = m::mock(RedisQueue::class); $queue = new FailoverQueue($manager, $events, ['redis', 'sync']); - $manager->shouldReceive('connection')->times(6)->with('redis')->andReturn($redis); + $manager->shouldReceive('connection')->times(10)->with('redis')->andReturn($redis); + $redis->shouldReceive('totalSize')->once()->withNoArgs()->andReturn(9); + $redis->shouldReceive('totalPendingSize')->once()->withNoArgs()->andReturn(2); + $redis->shouldReceive('totalDelayedSize')->once()->withNoArgs()->andReturn(3); + $redis->shouldReceive('totalReservedSize')->once()->withNoArgs()->andReturn(4); $redis->shouldReceive('pendingJobs')->once()->with('emails')->andReturn($pending = new Collection(['pending'])); $redis->shouldReceive('delayedJobs')->once()->with('emails')->andReturn($delayed = new Collection(['delayed'])); $redis->shouldReceive('reservedJobs')->once()->with('emails')->andReturn($reserved = new Collection(['reserved'])); @@ -545,6 +549,10 @@ public function testInspectionDelegatesToTheFirstConnection(): void $redis->shouldReceive('allDelayedJobs')->once()->andReturn($allDelayed = new Collection(['all-delayed'])); $redis->shouldReceive('allReservedJobs')->once()->andReturn($allReserved = new Collection(['all-reserved'])); + $this->assertSame(9, $queue->totalSize()); + $this->assertSame(2, $queue->totalPendingSize()); + $this->assertSame(3, $queue->totalDelayedSize()); + $this->assertSame(4, $queue->totalReservedSize()); $this->assertSame($pending, $queue->pendingJobs('emails')); $this->assertSame($delayed, $queue->delayedJobs('emails')); $this->assertSame($reserved, $queue->reservedJobs('emails')); diff --git a/tests/Queue/QueueBeanstalkdQueueTest.php b/tests/Queue/QueueBeanstalkdQueueTest.php index 49a763bb5..122b4b940 100644 --- a/tests/Queue/QueueBeanstalkdQueueTest.php +++ b/tests/Queue/QueueBeanstalkdQueueTest.php @@ -20,6 +20,7 @@ use Pheanstalk\Contract\PheanstalkSubscriberInterface; use Pheanstalk\Pheanstalk; use Pheanstalk\Values\Job; +use Pheanstalk\Values\ServerStats; use Pheanstalk\Values\TubeList; use Pheanstalk\Values\TubeName; use Pheanstalk\Values\TubeStats; @@ -73,6 +74,74 @@ public function testSizeIncludesPendingDelayedAndReservedJobsWithOneStatsRequest $this->assertSame(12, $this->queue->size('stack')); } + public function testTotalSizesUseOneServerStatsRequestPerCount(): void + { + $this->setQueue('default', 60); + + $this->queue->getPheanstalk() + ->shouldReceive('stats') + ->times(4) + ->withNoArgs() + ->andReturn(new ServerStats( + currentJobsUrgent: 1, + currentJobsReady: 3, + currentJobsReserved: 5, + currentJobsDelayed: 4, + currentJobsBuried: 6, + cmdPut: 0, + cmdPeek: 0, + cmdPeekReady: 0, + cmdPeekDelayed: 0, + cmdReserveWithTimeout: 0, + cmdPeekBuried: 0, + cmdReserve: 0, + cmdUse: 0, + cmdWatch: 0, + cmdIgnore: 0, + cmdDelete: 0, + cmdRelease: 0, + cmdBury: 0, + cmdKick: 0, + cmdStats: 0, + cmdStatsJob: 0, + cmdStatsTube: 0, + cmdListTubes: 0, + cmdListTubeUsed: 0, + cmdListTubesWatched: 0, + cmdPauseTube: 0, + jobTimeouts: 0, + totalJobs: 18, + maxJobSize: 65535, + currentTubes: 2, + currentConnections: 1, + currentProducers: 0, + currentWorkers: 0, + currentWaiting: 0, + totalConnections: 1, + pid: 1, + version: '1.13', + rusageUtime: 0.0, + rusageStime: 0.0, + binlogOldestIndex: 0, + binlogCurrentIndex: 0, + binlogMaxSize: 0, + binlogRecordsWritten: 0, + draining: false, + id: 'test-server', + hostname: 'localhost', + os: 'Linux', + platform: 'x86_64', + cmdTouch: 0, + uptime: 0, + binlogRecordsMigrated: 0, + )); + + $this->assertSame(12, $this->queue->totalSize()); + $this->assertSame(3, $this->queue->totalPendingSize()); + $this->assertSame(4, $this->queue->totalDelayedSize()); + $this->assertSame(5, $this->queue->totalReservedSize()); + } + public function testInspectionReturnsEmptyCollections(): void { $this->setQueue('default', 60); diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index 1ef287161..85cd25777 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -365,8 +365,7 @@ public function testBulkTreatsAnExplicitFalseInsertAsFailure(): void $connection->shouldReceive('table')->once()->with('table')->andReturn($query = m::mock(Builder::class)); $query->shouldReceive('insert')->once()->andReturnFalse(); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to insert queued jobs into the database.'); + $this->expectExceptionObject(new RuntimeException('Unable to insert queued jobs into the database.')); $queue->bulk(['job']); } @@ -867,6 +866,71 @@ public function testAllReservedJobs(): void $this->assertInspectedJob($jobs->last(), 'SecondReservedJob', 'emails', 2, 42); } + public function testTotalSize(): void + { + $resolver = m::mock(ConnectionResolverInterface::class); + $database = m::mock(ConnectionInterface::class); + $resolver->expects('connection')->with(null)->andReturn($database); + $queue = new TestDatabaseQueue($resolver, null, 'table', 'default', 1732502704); + $queue->setContainer(m::spy(Container::class)); + + $query = m::mock(Builder::class); + $database->expects('table')->with('table')->andReturn($query); + $query->expects('count')->andReturn(9); + + $this->assertSame(9, $queue->totalSize()); + } + + public function testTotalPendingSize(): void + { + $resolver = m::mock(ConnectionResolverInterface::class); + $database = m::mock(ConnectionInterface::class); + $resolver->expects('connection')->with(null)->andReturn($database); + $queue = new TestDatabaseQueue($resolver, null, 'table', 'default', 1732502704); + $queue->setContainer(m::spy(Container::class)); + + $query = m::mock(Builder::class); + $database->expects('table')->with('table')->andReturn($query); + $query->expects('whereNull')->with('reserved_at')->andReturnSelf(); + $query->expects('where')->with('available_at', '<=', 1732502704)->andReturnSelf(); + $query->expects('count')->andReturn(2); + + $this->assertSame(2, $queue->totalPendingSize()); + } + + public function testTotalDelayedSize(): void + { + $resolver = m::mock(ConnectionResolverInterface::class); + $database = m::mock(ConnectionInterface::class); + $resolver->expects('connection')->with(null)->andReturn($database); + $queue = new TestDatabaseQueue($resolver, null, 'table', 'default', 1732502704); + $queue->setContainer(m::spy(Container::class)); + + $query = m::mock(Builder::class); + $database->expects('table')->with('table')->andReturn($query); + $query->expects('whereNull')->with('reserved_at')->andReturnSelf(); + $query->expects('where')->with('available_at', '>', 1732502704)->andReturnSelf(); + $query->expects('count')->andReturn(3); + + $this->assertSame(3, $queue->totalDelayedSize()); + } + + public function testTotalReservedSize(): void + { + $resolver = m::mock(ConnectionResolverInterface::class); + $database = m::mock(ConnectionInterface::class); + $resolver->expects('connection')->with(null)->andReturn($database); + $queue = new TestDatabaseQueue($resolver, null, 'table', 'default', 1732502704); + $queue->setContainer(m::spy(Container::class)); + + $query = m::mock(Builder::class); + $database->expects('table')->with('table')->andReturn($query); + $query->expects('whereNotNull')->with('reserved_at')->andReturnSelf(); + $query->expects('count')->andReturn(4); + + $this->assertSame(4, $queue->totalReservedSize()); + } + public function testInvalidInspectedPayloadIdentifiesItsQueueAndRecord(): void { [$queue, $query] = $this->createInspectionQueue(); diff --git a/tests/Queue/QueuePoolProxyTest.php b/tests/Queue/QueuePoolProxyTest.php index 1419d1edc..4328d4ba0 100644 --- a/tests/Queue/QueuePoolProxyTest.php +++ b/tests/Queue/QueuePoolProxyTest.php @@ -59,6 +59,10 @@ public function testEnumeratedSynchronousSurfaceUsesBorrowScopedInvocation(): vo $queue->shouldReceive('pendingSize')->once()->with('queue')->andReturn(2); $queue->shouldReceive('delayedSize')->once()->with('queue')->andReturn(3); $queue->shouldReceive('reservedSize')->once()->with('queue')->andReturn(4); + $queue->shouldReceive('totalSize')->once()->withNoArgs()->andReturn(18); + $queue->shouldReceive('totalPendingSize')->once()->withNoArgs()->andReturn(5); + $queue->shouldReceive('totalDelayedSize')->once()->withNoArgs()->andReturn(6); + $queue->shouldReceive('totalReservedSize')->once()->withNoArgs()->andReturn(7); $queue->shouldReceive('pendingJobs')->once()->with('queue')->andReturn($pending = new Collection(['pending'])); $queue->shouldReceive('delayedJobs')->once()->with('queue')->andReturn($delayed = new Collection(['delayed'])); $queue->shouldReceive('reservedJobs')->once()->with('queue')->andReturn($reserved = new Collection(['reserved'])); @@ -78,6 +82,10 @@ public function testEnumeratedSynchronousSurfaceUsesBorrowScopedInvocation(): vo $this->assertSame(2, $proxy->pendingSize('queue')); $this->assertSame(3, $proxy->delayedSize('queue')); $this->assertSame(4, $proxy->reservedSize('queue')); + $this->assertSame(18, $proxy->totalSize()); + $this->assertSame(5, $proxy->totalPendingSize()); + $this->assertSame(6, $proxy->totalDelayedSize()); + $this->assertSame(7, $proxy->totalReservedSize()); $this->assertSame($pending, $proxy->pendingJobs('queue')); $this->assertSame($delayed, $proxy->delayedJobs('queue')); $this->assertSame($reserved, $proxy->reservedJobs('queue')); diff --git a/tests/Queue/QueueRedisQueueTest.php b/tests/Queue/QueueRedisQueueTest.php index f6c0eb615..5df64a76b 100644 --- a/tests/Queue/QueueRedisQueueTest.php +++ b/tests/Queue/QueueRedisQueueTest.php @@ -24,14 +24,65 @@ use Hypervel\Queue\RedisQueue; use Hypervel\Redis\RedisProxy; use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Collection; use Hypervel\Support\Str; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use Symfony\Component\Uid\Uuid; class QueueRedisQueueTest extends TestCase { + #[DataProvider('totalSizeMethods')] + public function testTotalsUseQueueSizeOverridesInsidePinnedConnection(string $totalMethod, string $sizeMethod): void + { + $pinned = false; + $connection = m::mock(RedisProxy::class); + $connection->expects('withPinnedConnection')->andReturnUsing(function (callable $callback) use (&$pinned): int { + $pinned = true; + + try { + return $callback(); + } finally { + $pinned = false; + } + }); + $redis = m::mock(Redis::class); + $redis->expects('connection')->with(null)->andReturn($connection); + $queue = m::mock(RedisQueue::class, [$redis, 'default']) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $queue->expects('allQueueNames')->andReturnUsing(function () use (&$pinned): Collection { + $this->assertTrue($pinned); + + return new Collection(['emails', 'reports:high']); + }); + $queue->shouldReceive($sizeMethod)->twice()->andReturnUsing(function (string $name) use (&$pinned): int { + $this->assertTrue($pinned); + + return match ($name) { + 'emails' => 5, + 'reports:high' => 7, + }; + }); + + $this->assertSame(12, $queue->{$totalMethod}()); + } + + /** + * Provide aggregate methods and their per-queue extension points. + */ + public static function totalSizeMethods(): array + { + return [ + 'all jobs' => ['totalSize', 'size'], + 'pending jobs' => ['totalPendingSize', 'pendingSize'], + 'delayed jobs' => ['totalDelayedSize', 'delayedSize'], + 'reserved jobs' => ['totalReservedSize', 'reservedSize'], + ]; + } + public function testBulkUsesOneLuaCallAndHonorsJobDelays(): void { CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index cc1f59218..25d088862 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -2355,6 +2355,63 @@ public function testCompressedReturnsFalseWhenNoCompression(): void $this->assertFalse($connection->compressed()); } + #[DataProvider('scanPrefixOptions')] + public function testWithoutScanPrefixPreservesOtherOptionsAndRestores(bool $retry, bool $prefix): void + { + $redis = new Redis; + $redis->setOption(Redis::OPT_PREFIX, 'app:'); + $redis->setOption(Redis::OPT_SCAN, $retry ? Redis::SCAN_RETRY : Redis::SCAN_NORETRY); + $redis->setOption(Redis::OPT_SCAN, $prefix ? Redis::SCAN_PREFIX : Redis::SCAN_NOPREFIX); + $originalOptions = $redis->getOption(Redis::OPT_SCAN); + $connection = (new PhpRedisConnectionStub)->setActiveConnection($redis); + + $result = $connection->withoutScanPrefix(function () use ($redis, $retry): string { + $this->assertSame($retry ? Redis::SCAN_RETRY : Redis::SCAN_NORETRY, $redis->getOption(Redis::OPT_SCAN)); + $this->assertSame('app:', $redis->getOption(Redis::OPT_PREFIX)); + + return 'callback-result'; + }); + + $this->assertSame('callback-result', $result); + $this->assertSame($originalOptions, $redis->getOption(Redis::OPT_SCAN)); + } + + /** + * Provide independent retry and prefix settings. + */ + public static function scanPrefixOptions(): array + { + return [ + 'neither' => [false, false], + 'retry' => [true, false], + 'prefix' => [false, true], + 'retry and prefix' => [true, true], + ]; + } + + public function testWithoutScanPrefixRestoresOptionsWhenCallbackThrows(): void + { + $redis = new Redis; + $redis->setOption(Redis::OPT_SCAN, Redis::SCAN_RETRY); + $redis->setOption(Redis::OPT_SCAN, Redis::SCAN_PREFIX); + $originalOptions = $redis->getOption(Redis::OPT_SCAN); + $connection = (new PhpRedisConnectionStub)->setActiveConnection($redis); + $failure = new RuntimeException('Callback failed'); + + try { + $connection->withoutScanPrefix(function () use ($redis, $failure): never { + $this->assertSame(Redis::SCAN_RETRY, $redis->getOption(Redis::OPT_SCAN)); + + throw $failure; + }); + $this->fail('Expected the callback exception.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame($originalOptions, $redis->getOption(Redis::OPT_SCAN)); + } + public function testWithoutSerializationOrCompressionDisablesSerializerAndRestores(): void { $connection = $this->mockRedisConnection(); diff --git a/tests/Support/SupportTestingQueueFakeTest.php b/tests/Support/SupportTestingQueueFakeTest.php index 4c51f1e5a..2feb4f866 100644 --- a/tests/Support/SupportTestingQueueFakeTest.php +++ b/tests/Support/SupportTestingQueueFakeTest.php @@ -14,6 +14,7 @@ use Hypervel\Contracts\Queue\Queue; use Hypervel\Contracts\Queue\ShouldBeUnique; use Hypervel\Foundation\Application; +use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\CallQueuedClosure; use Hypervel\Queue\Jobs\InspectedJob; use Hypervel\Queue\QueueManager; @@ -235,6 +236,31 @@ public function testAssertPushedUsingBulk(): void $this->fake->assertPushed(JobStub::class, 2); } + public function testBulkRespectsDelayAttribute(): void + { + $this->fake->bulk([ + new JobWithDelayAttributeStub, + new JobStub, + ], ['foo' => 'bar'], 'redis'); + + $this->assertSame(1, $this->fake->delayedSize('redis')); + $this->fake->assertPushedOn('redis', JobWithDelayAttributeStub::class); + $this->fake->assertPushed(JobWithDelayAttributeStub::class, function (JobWithDelayAttributeStub $job, ?string $queue, mixed $data): bool { + return $queue === 'redis' && $data === ['foo' => 'bar']; + }); + $this->fake->assertPushedOn('redis', JobStub::class); + } + + public function testBulkRespectsRuntimeDelay(): void + { + $job = (new JobWithRuntimeDelayStub)->delay(30); + + $this->fake->bulk([$job], '', 'redis'); + + $this->assertSame(1, $this->fake->delayedSize('redis')); + $this->fake->assertPushedOn('redis', JobWithRuntimeDelayStub::class); + } + public function testPushOnAndLaterOnAcceptUnitEnums(): void { $this->fake->pushOn(QueueNameEnumStub::Foo, $this->job); @@ -641,6 +667,23 @@ public function testAllPendingJobs(): void $this->assertTrue($pending->contains(fn ($job) => $job->name === JobToFakeStub::class)); } + public function testTotalSize(): void + { + $this->fake->push($this->job, '', 'foo'); + $this->fake->later(10, new JobToFakeStub, '', 'bar'); + $this->fake->reserve(new JobToFakeStub, 'baz'); + + $this->assertSame(3, $this->fake->totalSize()); + } + + public function testTotalPendingSize(): void + { + $this->fake->push($this->job, '', 'foo'); + $this->fake->push(new JobToFakeStub, '', 'bar'); + + $this->assertSame(2, $this->fake->totalPendingSize()); + } + public function testDelayedJobs(): void { $this->fake->later(10, $this->job, '', 'foo'); @@ -668,6 +711,14 @@ public function testAllDelayedJobs(): void $this->assertTrue($delayed->contains(fn ($job) => $job->name === JobToFakeStub::class)); } + public function testTotalDelayedSize(): void + { + $this->fake->later(10, $this->job, '', 'foo'); + $this->fake->later(10, new JobToFakeStub, '', 'bar'); + + $this->assertSame(2, $this->fake->totalDelayedSize()); + } + public function testDelayedSize(): void { $this->fake->later(10, $this->job, '', 'foo'); @@ -695,6 +746,10 @@ public function testPendingDelayedAndReservedJobsAreDisjoint(): void $this->assertSame(1, $this->fake->delayedSize('foo')); $this->assertSame(1, $this->fake->reservedSize('foo')); $this->assertSame(3, $this->fake->size('foo')); + $this->assertSame(1, $this->fake->totalPendingSize()); + $this->assertSame(1, $this->fake->totalDelayedSize()); + $this->assertSame(1, $this->fake->totalReservedSize()); + $this->assertSame(3, $this->fake->totalSize()); $this->fake->assertCount(2); } @@ -756,6 +811,14 @@ public function testAllReservedJobs(): void $this->assertTrue($reserved->contains(fn ($job) => $job->name === JobToFakeStub::class)); } + public function testTotalReservedSize(): void + { + $this->fake->reserve($this->job, 'foo'); + $this->fake->reserve(new JobToFakeStub, 'bar'); + + $this->assertSame(2, $this->fake->totalReservedSize()); + } + public function testReservedSize(): void { $this->fake->reserve($this->job, 'foo'); @@ -964,6 +1027,31 @@ public function handle(): void } } +#[Delay(15)] +class JobWithDelayAttributeStub +{ + use Queueable; + + /** + * Handle the job. + */ + public function handle(): void + { + } +} + +class JobWithRuntimeDelayStub +{ + use Queueable; + + /** + * Handle the job. + */ + public function handle(): void + { + } +} + class JobWithConnectionStub { use Queueable; From ada8ba2d49c3d1bb3b458976de1c6c8c88d885db Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:17:34 +0000 Subject: [PATCH 13/22] Port application configuration cache memoization Application::configurationIsCached() now honors the existing config_loaded_from_cache binding and stores the first filesystem result. This reflects the configuration actually loaded by bootstrap and avoids repeated disk checks without adding another cache or invalidation mechanism. Port Laravel PR https://github.com/laravel/framework/pull/57665 using the current 13.x implementation at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Preserve Hypervel's named container API and native boolean return type. Verify bound true/false state and both filesystem memoization outcomes with real applications and isolated files. Extend cached-state integration coverage to assert the public helper agrees with the bootstrap flag. Use the existing parallel temp-directory pattern and replace deprecated exception expectations in the touched application tests. WithCachedConfig test boots now take the environment bootstrap's cached configuration early return and skip .env loading, matching Laravel and the existing configuration documentation. Global test cleanup still clears dotenv state between test methods; no environment-persistence assumption or workaround is introduced. Validation: Foundation suite (1412 tests), service providers, Testbench cached state, config cache/clear commands, both changed test files, full composer analyse, scoped composer lint:fix and git diff --check pass. Reviewed and approved by claude-laravel-parity. --- src/foundation/src/Application.php | 6 +- .../Foundation/FoundationApplicationTest.php | 100 ++++++++++++------ .../Testing/WithCachedStateTest.php | 1 + 3 files changed, 73 insertions(+), 34 deletions(-) diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index 5b4e89098..65455cf3c 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -659,7 +659,11 @@ public function environmentFilePath(): string */ public function configurationIsCached(): bool { - return is_file($this->getCachedConfigPath()); + if ($this->bound('config_loaded_from_cache')) { + return (bool) $this->make('config_loaded_from_cache'); + } + + return $this->instance('config_loaded_from_cache', is_file($this->getCachedConfigPath())); } /** diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index 008ef69a0..01c01137f 100644 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -32,12 +32,18 @@ class FoundationApplicationTest extends TestCase { protected ?string $namespaceApplicationPath = null; + protected ?string $configurationApplicationPath = null; + protected function tearDown(): void { try { if ($this->namespaceApplicationPath !== null) { (new Filesystem)->deleteDirectory($this->namespaceApplicationPath); } + + if ($this->configurationApplicationPath !== null) { + (new Filesystem)->deleteDirectory($this->configurationApplicationPath); + } } finally { parent::tearDown(); } @@ -523,8 +529,7 @@ public function testGetNamespaceRejectsMissingComposerFile(): void { $app = $this->makeNamespaceApplication(null); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to detect application namespace.'); + $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.')); $app->getNamespace(); } @@ -535,8 +540,7 @@ public function testGetNamespaceRejectsUnreadableComposerPath(): void unlink($this->namespaceApplicationPath . '/composer.json'); mkdir($this->namespaceApplicationPath . '/composer.json'); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to detect application namespace.'); + $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.')); $app->getNamespace(); } @@ -559,8 +563,7 @@ public function testGetNamespaceRejectsNonArrayComposerJson(): void { $app = $this->makeNamespaceApplication('null'); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to detect application namespace.'); + $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.')); $app->getNamespace(); } @@ -571,8 +574,7 @@ public function testGetNamespaceRejectsInvalidPsrFourMap(): void 'autoload' => ['psr-4' => 'app/'], ], JSON_THROW_ON_ERROR)); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to detect application namespace.'); + $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.')); $app->getNamespace(); } @@ -583,8 +585,7 @@ public function testGetNamespaceRejectsInvalidPsrFourPath(): void 'autoload' => ['psr-4' => ['App\\' => [123]]], ], JSON_THROW_ON_ERROR)); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to detect application namespace.'); + $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.')); $app->getNamespace(); } @@ -595,8 +596,7 @@ public function testGetNamespaceDoesNotMatchTwoMissingPaths(): void 'autoload' => ['psr-4' => ['App\\' => 'missing/']], ], JSON_THROW_ON_ERROR), createAppPath: false); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to detect application namespace.'); + $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.')); $app->getNamespace(); } @@ -817,19 +817,17 @@ protected function assertExpectationCount(int $times): void $this->assertSame($times, m::getContainer()->mockery_getExpectationCount()); } - public function testAbortThrowsNotFoundHttpException() + public function testAbortThrowsNotFoundHttpException(): void { - $this->expectException(NotFoundHttpException::class); - $this->expectExceptionMessage('Page was not found'); + $this->expectExceptionObject(new NotFoundHttpException('Page was not found')); $app = new Application; $app->abort(404, 'Page was not found'); } - public function testAbortThrowsHttpException() + public function testAbortThrowsHttpException(): void { - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Request is bad'); + $this->expectExceptionObject(new HttpException(400, 'Request is bad')); $app = new Application; $app->abort(400, 'Request is bad'); @@ -859,30 +857,52 @@ public function testMethodAfterLoadingEnvironmentAddsClosure(): void $this->assertArrayHasKey(0, $listeners); } - public function testConfigurationIsCachedReturnsFalseWhenNoCacheFile() + public function testConfigurationIsCachedReturnsFalseWhenNoCacheFile(): void { - $app = new Application(sys_get_temp_dir() . '/hypervel-test-app-' . uniqid()); + $app = $this->makeConfigurationApplication(); $this->assertFalse($app->configurationIsCached()); } - public function testConfigurationIsCachedReturnsTrueWhenCacheFileExists() + public function testConfigurationIsCachedReturnsTrueWhenCacheFileExists(): void { - $basePath = sys_get_temp_dir() . '/hypervel-test-app-' . uniqid(); - $cachePath = $basePath . '/bootstrap/cache/config.php'; + $app = $this->makeConfigurationApplication(); + file_put_contents($app->getCachedConfigPath(), 'assertTrue($app->configurationIsCached()); + } + + public function testConfigurationIsCachedUsesBoundState(): void + { + $app = $this->makeConfigurationApplication(); + $app->instance('config_loaded_from_cache', true); + + $this->assertTrue($app->configurationIsCached()); + + file_put_contents($app->getCachedConfigPath(), 'instance('config_loaded_from_cache', false); + + $this->assertFalse($app->configurationIsCached()); + } + + public function testConfigurationIsCachedMemoizesFilesystemResult(): void + { + $app = $this->makeConfigurationApplication(); + $cachePath = $app->getCachedConfigPath(); + + $this->assertFalse($app->configurationIsCached()); - mkdir(dirname($cachePath), 0755, true); file_put_contents($cachePath, 'assertTrue($app->configurationIsCached()); - } finally { - unlink($cachePath); - rmdir(dirname($cachePath)); - rmdir(dirname($cachePath, 2)); - rmdir($basePath); - } + $this->assertFalse($app->configurationIsCached()); + + $freshApp = new Application($this->configurationApplicationPath); + + $this->assertTrue($freshApp->configurationIsCached()); + + unlink($cachePath); + + $this->assertTrue($freshApp->configurationIsCached()); } public function testRoutesAreCachedReturnsFalseWhenNoCacheFile() @@ -956,6 +976,20 @@ public function testAddAbsoluteCachePathPrefixReturnsSelf() $this->assertSame($app, $app->addAbsoluteCachePathPrefix('s3:')); } + /** + * Create an application with an isolated configuration cache directory. + */ + private function makeConfigurationApplication(): Application + { + $this->configurationApplicationPath = ParallelTesting::tempDir('FoundationApplicationConfigTest'); + + $files = new Filesystem; + $files->deleteDirectory($this->configurationApplicationPath); + $files->makeDirectory($this->configurationApplicationPath . '/bootstrap/cache', 0755, true); + + return new Application($this->configurationApplicationPath); + } + private function makeNamespaceApplication(?string $composerContents, bool $createAppPath = true): Application { $this->namespaceApplicationPath = ParallelTesting::tempDir('FoundationApplicationNamespaceTest'); diff --git a/tests/Foundation/Testing/WithCachedStateTest.php b/tests/Foundation/Testing/WithCachedStateTest.php index 1ec7b5c55..e6923f6ae 100644 --- a/tests/Foundation/Testing/WithCachedStateTest.php +++ b/tests/Foundation/Testing/WithCachedStateTest.php @@ -115,6 +115,7 @@ public function testCachedStateIsRearmedBeforeSuccessiveFoundationApplicationBoo $second->runSetUp(); $this->assertTrue($second->configLoadedFromCache()); + $this->assertTrue($second->application()->configurationIsCached()); $this->assertTrue($second->application()->routesAreCached()); $this->assertInstanceOf(CompiledRouteCollection::class, $second->routeCollection()); $this->assertNotNull($second->routeCollection()->getByName('cached-state')); From 1732de045ae452d9386086f039dfc7694366fcf0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:34:39 +0000 Subject: [PATCH 14/22] Honor configured application paths in app_path app_path() always appended app to the base directory, ignoring the application's useAppPath() configuration. Model suggestions, generator namespace selection, pruning and provider publish destinations could therefore use the wrong directory. Delegate to Application::path() when the application is available, matching Laravel's helper. Preserve Hypervel's pre-bootstrap BASE_PATH fallback and its existing failure message using the same pattern as sibling path helpers. No new state, cache or consumer-specific workaround is introduced. Discovered while revalidating the complete model-discovery port from https://github.com/laravel/framework/pull/57671 against Laravel 13.x source 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. All six source changes in that PR were already present; this fixes the shared Hypervel helper they consume. Add real-application coverage for default/custom paths and suffixes, plus the pre-bootstrap fallback with exception-safe container restoration. Correct the pruning discovery test's false positive: its invalid class names previously yielded no models and satisfied only negative assertions. Exclude its database-dependent soft-delete fixture, which has dedicated tests, and assert a real model and pruning count alongside every upstream negative assertion. This also removes reliance on earlier tests' database state. Modernize the touched file's deprecated exception expectation. Validation: changed helper tests 41/75; pruning tests 10/27; affected Foundation, Console and generator tests via ParaTest 2030/6350 (one skip); SQLite model inspection 2/192; full composer analyse, scoped formatting, missing-BASE_PATH failure probe and git diff --check pass. Reviewed and approved by claude-laravel-parity. Tracks AI-026. --- src/foundation/src/helpers.php | 8 ++++++- tests/Database/PruneCommandTest.php | 20 ++++++++++++----- tests/Foundation/FoundationHelpersTest.php | 25 ++++++++++++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php index 4db3a3afc..a1e2fcb57 100644 --- a/src/foundation/src/helpers.php +++ b/src/foundation/src/helpers.php @@ -177,7 +177,13 @@ function app_id(): string */ function app_path(string $path = ''): string { - return join_paths(base_path('app'), $path); + if (! Container::getInstance()->has(Application::class)) { + return defined('BASE_PATH') + ? join_paths(BASE_PATH, 'app', $path) + : throw new RuntimeException('BASE_PATH constant is not defined.'); + } + + return app()->path($path); } } diff --git a/tests/Database/PruneCommandTest.php b/tests/Database/PruneCommandTest.php index 07d033445..7c8c027b1 100644 --- a/tests/Database/PruneCommandTest.php +++ b/tests/Database/PruneCommandTest.php @@ -49,10 +49,9 @@ protected function setUp(): void $container->instance('env', 'development'); } - public function testPrunableModelAndExceptWithEachOther() + public function testPrunableModelAndExceptWithEachOther(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The --model and --except options cannot be combined.'); + $this->expectExceptionObject(new InvalidArgumentException('The --model and --except options cannot be combined.')); $this->artisan([ '--model' => Pruning\Models\PrunableTestModelWithPrunableRecords::class, @@ -159,12 +158,23 @@ public function testNonPrunableTestWithATrait() ); } - public function testNonModelFilesAreIgnoredTest() + public function testNonModelFilesAreIgnoredTest(): void { - $output = $this->artisan(['--path' => 'Models']); + $output = $this->artisan([ + '--path' => 'Models', + // The soft-delete fixture needs a database; its dedicated tests set one up. + '--except' => [Pruning\Models\PrunableTestSoftDeletedModelWithPrunableRecords::class], + ]); $output = $output->fetch(); + $this->assertStringContainsString( + 'Hypervel\Tests\Database\Pruning\Models\PrunableTestModelWithPrunableRecords', + $output, + ); + + $this->assertStringContainsString('20 records', $output); + $this->assertStringNotContainsString( 'No prunable [Hypervel\Tests\Database\Pruning\Models\AbstractPrunableModel] records found.', $output, diff --git a/tests/Foundation/FoundationHelpersTest.php b/tests/Foundation/FoundationHelpersTest.php index c421ccd86..4174f9074 100644 --- a/tests/Foundation/FoundationHelpersTest.php +++ b/tests/Foundation/FoundationHelpersTest.php @@ -8,6 +8,7 @@ use Hypervel\Broadcasting\FakePendingBroadcast; use Hypervel\Broadcasting\PendingBroadcast; use Hypervel\Cache\CacheManager; +use Hypervel\Container\Container; use Hypervel\Context\RequestContext; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Support\Responsable; @@ -45,6 +46,30 @@ enum UnitEnum class FoundationHelpersTest extends TestCase { + public function testAppPathUsesTheConfiguredApplicationDirectory(): void + { + $this->assertSame($this->app->basePath('app'), app_path()); + $this->assertSame($this->app->basePath('app/Models'), app_path('Models')); + + $path = $this->app->basePath('custom-app'); + $this->app->useAppPath($path); + + $this->assertSame($path, app_path()); + $this->assertSame($path . '/Models', app_path('Models')); + } + + public function testAppPathUsesBasePathBeforeApplicationBootstrap(): void + { + Container::setInstance(new Container); + + try { + $this->assertSame(BASE_PATH . '/app', app_path()); + $this->assertSame(BASE_PATH . '/app/Models', app_path('Models')); + } finally { + Container::setInstance($this->app); + } + } + public function testNowReturnsCarbonImmutableByDefault(): void { $result = now(); From 5fc76654702651774e09d8d155aa3efde95892ca Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:17:38 +0000 Subject: [PATCH 15/22] Port factory insertion fixes and preserve binary bulk bindings Complete the current Laravel factory insertion changes, including the zero-count early return and test, generic model annotations, and current hidden-attribute and array-cast assertions. Preserve the existing factory relationship, callback, connection, and custom Eloquent builder behavior. Fix two defects in the upstream insertion path: serializing made models can omit visible-filtered attributes, and filling those values again applies mutators twice. Prepare the made models directly, generate their unique IDs, and merge one batch of timestamp defaults underneath their raw attributes. Supplied timestamps, including null and values equal to model defaults, remain intact. Retain virtual Eloquent insert dispatch for custom builders. Share Hypervel's existing binary binding preparation with factories and all three fill-and-insert methods. Without PDO LOB binding, PostgreSQL truncates binary UUID values and SQLite binary lookups miss inserted rows. Expose the existing Hypervel-owned helper without changing Laravel protected APIs or the explicit BinaryParameter contract of raw query builder operations. Add targeted regressions for setter and visibility corruption, timestamps, unique IDs, custom builder dispatch and one-query insertion. Verify bulk UUID/ULID writes and generated/supplied binary primary keys on SQLite and PostgreSQL. Document the public factory insert behavior and binary support. Upstream PRs: https://github.com/laravel/framework/pull/57670 https://github.com/laravel/framework/pull/57600 https://github.com/laravel/framework/pull/57722 https://github.com/laravel/framework/pull/57794 https://github.com/laravel/framework/pull/59780 https://github.com/laravel/framework/pull/60911 Port source: Laravel 13.x 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Additional issue records: AI-027 and AI-028. Validation: Database ParaTest 3507 tests; final factory file 78 tests; binary integration 6 tests each on SQLite and PostgreSQL; full composer analyse, scoped formatting, and git diff --check pass. Independently reviewed and approved by claude-laravel-parity. --- src/database/src/Eloquent/Builder.php | 8 +- .../src/Eloquent/Concerns/HasAttributes.php | 2 +- .../src/Eloquent/Factories/Factory.php | 33 +++- src/docs/eloquent-factories.md | 8 + src/docs/eloquent-mutators.md | 2 +- .../Database/DatabaseEloquentFactoryTest.php | 159 +++++++++++++++++- ...atabaseEloquentAsBinaryIntegrationTest.php | 76 +++++++++ 7 files changed, 267 insertions(+), 21 deletions(-) diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 695973352..567f6c612 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -482,10 +482,10 @@ public function fillForInsert(array $values): array $this->model->unguarded(function () use (&$values) { foreach ($values as $key => $rowValues) { - $values[$key] = tap( - $this->newModelInstance($rowValues), - fn ($model) => $model->setUniqueIds() - )->getAttributes(); + $model = $this->newModelInstance($rowValues); + $model->setUniqueIds(); + + $values[$key] = $model->prepareBinaryAttributesForDatabase($model->getAttributes()); } }); diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index 9bf4a6587..fac63cc9d 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -1920,7 +1920,7 @@ private function isBinaryCast(?string $cast): bool * @param array $attributes * @return array */ - protected function prepareBinaryAttributesForDatabase(array $attributes): array + public function prepareBinaryAttributesForDatabase(array $attributes): array { $casts = $this->getCasts(); diff --git a/src/database/src/Eloquent/Factories/Factory.php b/src/database/src/Eloquent/Factories/Factory.php index 22fb5deb2..c17583d4d 100644 --- a/src/database/src/Eloquent/Factories/Factory.php +++ b/src/database/src/Eloquent/Factories/Factory.php @@ -323,7 +323,7 @@ public function lazy(array $attributes = [], ?Model $parent = null): Closure /** * Set the connection name on the results and store them. * - * @param \Hypervel\Support\Collection $results + * @param \Hypervel\Support\Collection $results */ protected function store(Collection $results): void { @@ -346,6 +346,8 @@ protected function store(Collection $results): void /** * Create the children for the given model. + * + * @param TModel $model */ protected function createChildren(Model $model): void { @@ -444,6 +446,10 @@ public function insert(array $attributes = [], ?Model $parent = null): void ? $made : $this->newModel()->newCollection([$made]); + if ($madeCollection->isEmpty()) { + return; + } + $model = $madeCollection->first(); if (isset($this->connection)) { @@ -452,12 +458,25 @@ public function insert(array $attributes = [], ?Model $parent = null): void $query = $model->newQueryWithoutScopes(); - $query->fillAndInsert( - $madeCollection->withoutAppends() - ->setHidden([]) - ->map(static fn (Model $model) => $model->attributesToArray()) - ->all() - ); + $timestamps = []; + + if ($model->usesTimestamps()) { + $timestamp = $model->freshTimestampString(); + + foreach (array_filter([$model->getCreatedAtColumn(), $model->getUpdatedAtColumn()]) as $column) { + $timestamps[$column] = $timestamp; + } + } + + // These models have already applied their casts and mutators. Serializing or + // filling them again can drop attributes or transform stored values twice. + $values = $madeCollection->map(static function (Model $model) use ($timestamps): array { + $model->setUniqueIds(); + + return array_merge($timestamps, $model->prepareBinaryAttributesForDatabase($model->getAttributes())); + })->all(); + + $query->insert($values); } /** diff --git a/src/docs/eloquent-factories.md b/src/docs/eloquent-factories.md index 611d6030c..d5cd8e8ce 100644 --- a/src/docs/eloquent-factories.md +++ b/src/docs/eloquent-factories.md @@ -302,6 +302,14 @@ $user = User::factory()->create([ ]); ``` +To insert multiple models in a single query, you may use the `insert` method: + +```php +User::factory()->count(100)->insert(); +``` + +The `insert` method applies attribute casts, generates unique IDs, and adds timestamps. It does not return model instances, dispatch model events, run `afterCreating` callbacks, or create child relationships. + ### Sequences diff --git a/src/docs/eloquent-mutators.md b/src/docs/eloquent-mutators.md index ce49dc2ac..d49afced7 100644 --- a/src/docs/eloquent-mutators.md +++ b/src/docs/eloquent-mutators.md @@ -626,7 +626,7 @@ return $user->uuid; // "6e8cdeed-2f32-40bd-b109-1e4405be2140" ``` -Normal model saves apply the cast automatically. Direct query builder operations, including `where`, bulk `update`, and `upsert` calls, do not apply model casts. When those operations receive already-encoded binary strings, wrap them in a [binary parameter](/docs/{{version}}/database#binding-binary-values). +Model saves, `fillAndInsert`, `fillAndInsertOrIgnore`, `fillAndInsertGetId`, and [factory inserts](/docs/{{version}}/eloquent-factories#persisting-models) apply the cast automatically. Direct query builder operations, including `where`, bulk `update`, and `upsert` calls, do not apply model casts. When those operations receive already-encoded binary strings, wrap them in a [binary parameter](/docs/{{version}}/database#binding-binary-values). A non-incrementing primary key may also use `AsBinary` for model-instance writes and reloads. Lookups from already-encoded key bytes must use a binary parameter with `find`, `whereKey`, or `whereKeyNot`. Canonical UUID / ULID text passed directly to `find`, route or queue model binding, and relationship queries is not encoded automatically. diff --git a/tests/Database/DatabaseEloquentFactoryTest.php b/tests/Database/DatabaseEloquentFactoryTest.php index 18b2991d5..6e3a793fd 100644 --- a/tests/Database/DatabaseEloquentFactoryTest.php +++ b/tests/Database/DatabaseEloquentFactoryTest.php @@ -5,13 +5,17 @@ namespace Hypervel\Tests\Database\DatabaseEloquentFactoryTest; use BadMethodCallException; +use Faker\Factory as FakerFactory; use Faker\Generator; use Hypervel\Container\Container; use Hypervel\Contracts\Foundation\Application; use Hypervel\Database\Capsule\Manager as DB; +use Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder; use Hypervel\Database\Eloquent\Attributes\UseFactory; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Casts\Attribute; use Hypervel\Database\Eloquent\Collection; +use Hypervel\Database\Eloquent\Concerns\HasUuids; use Hypervel\Database\Eloquent\Factories\Attributes\UseModel; use Hypervel\Database\Eloquent\Factories\CrossJoinSequence; use Hypervel\Database\Eloquent\Factories\Factory; @@ -20,6 +24,7 @@ use Hypervel\Database\Eloquent\Model as Eloquent; use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Database\Eloquent\SoftDeletes; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Str; use Hypervel\Tests\Database\Fixtures\Models\Money\Price; @@ -31,9 +36,11 @@ class DatabaseEloquentFactoryTest extends TestCase { protected function setUp(): void { + parent::setUp(); + $container = Container::getInstance(); $container->singleton(Generator::class, function ($app, $parameters) { - return \Faker\Factory::create('en_US'); + return FakerFactory::create('en_US'); }); $container->instance(Application::class, $app = m::mock(Application::class)); $app->shouldReceive('getNamespace')->andReturn('App\\'); @@ -1155,7 +1162,7 @@ public function testFactoryModelMorphManyRelationshipHasPendingAttributesOverrid $this->assertEquals('other body', Comment::first()->body); } - public function testFactoryCanInsert() + public function testFactoryCanInsert(): void { (new PostFactory) ->count(5) @@ -1167,24 +1174,31 @@ public function testFactoryCanInsert() ->insert(); $this->assertCount(5, $posts = Post::query()->where('title', 'hello')->get()); $this->assertEquals(strtoupper($posts[0]->user->name), $posts[0]->upper_case_name); - $this->assertEquals( + $this->assertCount( 2, - ($users = User::query()->get())->count() + $users = User::query()->get() ); $this->assertCount(1, $users->where('name', 'totwell')); $this->assertCount(1, $users->where('name', 'shaedrich')); } - public function testFactoryCanInsertWithHidden() + public function testFactoryCanInsertZeroModels(): void + { + (new PostFactory)->count(0)->insert(); + + $this->assertCount(0, Post::all()); + } + + public function testFactoryCanInsertWithHidden(): void { (new UserFactory)->forEachSequence(['name' => Name::Taylor, 'options' => 'abc'])->insert(); $user = DB::table('users')->sole(); - $this->assertEquals('abc', $user->options); + $this->assertSame('abc', $user->options); $userModel = User::query()->sole(); - $this->assertEquals('abc', $userModel->options); + $this->assertSame('abc', $userModel->options); } - public function testFactoryCanInsertWithArrayCasts() + public function testFactoryCanInsertWithArrayCasts(): void { (new UserWithArrayFactory)->count(2)->insert(); $users = DB::table('users')->get(); @@ -1196,6 +1210,80 @@ public function testFactoryCanInsertWithArrayCasts() } } + public function testFactoryInsertDoesNotApplyMutatorsTwice(): void + { + (new UserFactory)->useModel(UserWithMutator::class)->insert(['name' => 'Taylor']); + + $user = DB::table('users')->sole(); + + $this->assertSame('prefix:Taylor', $user->name); + $this->assertNull($user->created_at); + $this->assertNull($user->updated_at); + } + + public function testFactoryInsertPreservesAttributesExcludedFromSerialization(): void + { + (new UserFactory) + ->afterMaking(fn (User $user) => $user->setVisible(['options'])) + ->insert(['name' => 'Taylor', 'options' => 'abc']); + + $user = DB::table('users')->sole(); + + $this->assertSame('Taylor', $user->name); + $this->assertSame('abc', $user->options); + } + + public function testFactoryInsertGeneratesUniqueIdsAndPreservesTimestampValues(): void + { + $this->schema()->table('users', function (Blueprint $table): void { + $table->timestamp('joined_at')->nullable(); + $table->timestamp('changed_at')->nullable(); + }); + + CarbonImmutable::setTestNow('2026-09-06 12:00:00'); + $suppliedId = '11111111-0000-7000-8000-000000000000'; + $factory = (new UserFactory)->useModel(UserWithUniqueIds::class); + + $factory->forEachSequence( + ['name' => 'generated'], + ['name' => 'supplied', 'options' => $suppliedId, 'joined_at' => '2020-01-01 00:00:00', 'changed_at' => null], + ['name' => 'another generated'], + )->insert(); + + $users = DB::table('users')->get()->keyBy('name'); + + $this->assertCount(3, $users); + foreach (['generated', 'another generated'] as $name) { + $this->assertTrue(Str::isUuid($users[$name]->options)); + $this->assertSame('2026-09-06 12:00:00', $users[$name]->joined_at); + $this->assertSame($users[$name]->joined_at, $users[$name]->changed_at); + } + $this->assertNotSame($users['generated']->options, $users['another generated']->options); + $this->assertSame($suppliedId, $users['supplied']->options); + $this->assertSame('2020-01-01 00:00:00', $users['supplied']->joined_at); + $this->assertNull($users['supplied']->changed_at); + + // Values equal to model defaults are clean, but bulk insertion must retain them. + $factory->useModel(UserWithTimestampDefaults::class)->insert([ + 'name' => 'defaults', 'joined_at' => null, 'changed_at' => '2020-01-01 00:00:00', + ]); + + $defaults = DB::table('users')->where('name', 'defaults')->sole(); + + $this->assertNull($defaults->joined_at); + $this->assertSame('2020-01-01 00:00:00', $defaults->changed_at); + } + + public function testFactoryInsertUsesTheCustomEloquentBuilder(): void + { + DB::enableQueryLog(); + + (new UserFactory)->useModel(UserWithInsertBuilder::class)->count(2)->insert(); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertSame(['custom builder', 'custom builder'], DB::table('users')->pluck('options')->all()); + } + /** * Get a database connection instance. * @@ -1503,6 +1591,61 @@ public function definition(): array } } +class UserWithMutator extends User +{ + public bool $timestamps = false; + + /** + * Prefix the stored name. + */ + protected function name(): Attribute + { + return Attribute::set(fn (string $value): string => 'prefix:' . $value); + } +} + +class UserWithUniqueIds extends User +{ + use HasUuids; + + public const ?string CREATED_AT = 'joined_at'; + + public const ?string UPDATED_AT = 'changed_at'; + + /** + * Get the columns that should receive a unique identifier. + */ + public function uniqueIds(): array + { + return ['options']; + } +} + +class UserWithTimestampDefaults extends UserWithUniqueIds +{ + protected array $attributes = ['joined_at' => null, 'changed_at' => '2020-01-01 00:00:00']; +} + +#[UseEloquentBuilder(FactoryInsertBuilder::class)] +class UserWithInsertBuilder extends User +{ +} + +class FactoryInsertBuilder extends Builder +{ + /** + * Apply custom attributes to the inserted rows. + */ + public function insert(array $values): bool + { + foreach ($values as &$row) { + $row['options'] = 'custom builder'; + } + + return $this->toBase()->insert($values); + } +} + class UserWithCallbacksFactory extends Factory { protected ?string $model = User::class; diff --git a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php index 870bd9180..64b2cb1b2 100644 --- a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php +++ b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php @@ -6,12 +6,16 @@ use Hypervel\Database\BinaryParameter; use Hypervel\Database\Eloquent\Casts\AsBinary; +use Hypervel\Database\Eloquent\Concerns\HasUuids; +use Hypervel\Database\Eloquent\Factories\Factory; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Schema\Blueprint; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Support\Facades\Schema; +use Hypervel\Support\Str; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Uid\Ulid; use Symfony\Component\Uid\Uuid; @@ -148,6 +152,60 @@ public function testBinaryIdentifiersRoundTripAcrossModelAndQueryBuilderWritePat $this->assertSame($upsertUlid, $upserted->ulid); } + #[DataProvider('fillAndInsertMethods')] + public function testBinaryIdentifiersRoundTripThroughFillAndInsert(string $method): void + { + $uuid = '00ff7f80-4048-43c2-b80b-40491d165946'; + $ulid = '2WBHE5RQ2WBHE5RQ2WBHE5RQ2W'; + $attributes = ['uuid' => $uuid, 'ulid' => $ulid]; + + AsBinaryIdentifierModel::query()->{$method}( + $method === 'fillAndInsertGetId' ? $attributes : [$attributes] + ); + + $model = AsBinaryIdentifierModel::query() + ->where('uuid', new BinaryParameter(Uuid::fromString($uuid)->toBinary())) + ->where('ulid', new BinaryParameter(Ulid::fromString($ulid)->toBinary())) + ->sole(); + + $this->assertSame($uuid, $model->uuid); + $this->assertSame($ulid, $model->ulid); + } + + /** + * Provide the insert methods that prepare model attributes. + */ + public static function fillAndInsertMethods(): array + { + return [ + 'insert' => ['fillAndInsert'], + 'insert or ignore' => ['fillAndInsertOrIgnore'], + 'insert and get ID' => ['fillAndInsertGetId'], + ]; + } + + public function testFactoryInsertPreparesGeneratedAndSuppliedBinaryPrimaryKeys(): void + { + $suppliedId = '00ff7f80-4048-43c2-b80b-40491d165946'; + + (new AsBinaryPrimaryKeyFactory)->forEachSequence( + ['name' => 'generated'], + ['name' => 'supplied', 'id' => $suppliedId], + )->insert(); + + $models = AsBinaryFactoryPrimaryKeyModel::query()->get()->keyBy('name'); + + $this->assertCount(2, $models); + $this->assertTrue(Str::isUuid($models['generated']->id)); + $this->assertSame($suppliedId, $models['supplied']->id); + + foreach ($models as $name => $model) { + $key = new BinaryParameter(Uuid::fromString($model->id)->toBinary()); + + $this->assertSame($name, AsBinaryFactoryPrimaryKeyModel::query()->whereKey($key)->sole()->name); + } + } + public function testBinaryPrimaryKeysPreserveBindingIntentAcrossModelOperations(): void { $primaryId = '21107c1e-6448-43c2-b80b-40491d165946'; @@ -297,6 +355,24 @@ protected function casts(): array } } +class AsBinaryFactoryPrimaryKeyModel extends AsBinaryPrimaryKeyModel +{ + use HasUuids; +} + +class AsBinaryPrimaryKeyFactory extends Factory +{ + protected ?string $model = AsBinaryFactoryPrimaryKeyModel::class; + + /** + * Define the model's default state. + */ + public function definition(): array + { + return []; + } +} + class SoftDeletingAsBinaryPrimaryKeyModel extends AsBinaryPrimaryKeyModel { use SoftDeletes; From 3454fa0faeed8624c0735754dd226b9271dc690e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:38:44 +0000 Subject: [PATCH 16/22] Port cached-route detection memoization and test parity Remember the route-cache filesystem result on the application so repeated bootstrap consumers use the same cached state. Preserve explicit true and false bindings and Hypervel's native is_file() check, subprocess compilation, and cached-route loading lifecycle. Port the upstream bound-state test and cover both memoized outcomes across filesystem changes and fresh application instances. Retain existing real-file and path coverage, and share the isolated cache-directory fixture between configuration and route tests with exception-safe cleanup. Upstream: https://github.com/laravel/framework/pull/57623 https://github.com/laravel/framework/pull/57687 Porting source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Related cached-config, nullable-state, cleanup, and parallel-database follow-ups (#57663, #57708, #57785, #57826) were fully investigated and are already covered by Hypervel's existing implementations and tests. Validation: Application PHPUnit 60 tests / 197 assertions; affected cached-state, route compilation/loading/reload, and parallel-database ParaTest coverage 78 tests / 295 assertions. Full composer analyse passes for source and type fixtures; scoped formatting and git diff --check pass. Self-reviewed and approved by claude-laravel-parity. --- src/foundation/src/Application.php | 2 +- .../Foundation/FoundationApplicationTest.php | 80 ++++++++++++------- 2 files changed, 52 insertions(+), 30 deletions(-) diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index 65455cf3c..18a8119bf 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -691,7 +691,7 @@ public function routesAreCached(): bool return (bool) $this->make('routes.cached'); } - return is_file($this->getCachedRoutesPath()); + return $this->instance('routes.cached', is_file($this->getCachedRoutesPath())); } /** diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index 01c01137f..a21293d58 100644 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -32,7 +32,7 @@ class FoundationApplicationTest extends TestCase { protected ?string $namespaceApplicationPath = null; - protected ?string $configurationApplicationPath = null; + protected ?string $cacheApplicationPath = null; protected function tearDown(): void { @@ -41,8 +41,8 @@ protected function tearDown(): void (new Filesystem)->deleteDirectory($this->namespaceApplicationPath); } - if ($this->configurationApplicationPath !== null) { - (new Filesystem)->deleteDirectory($this->configurationApplicationPath); + if ($this->cacheApplicationPath !== null) { + (new Filesystem)->deleteDirectory($this->cacheApplicationPath); } } finally { parent::tearDown(); @@ -859,14 +859,14 @@ public function testMethodAfterLoadingEnvironmentAddsClosure(): void public function testConfigurationIsCachedReturnsFalseWhenNoCacheFile(): void { - $app = $this->makeConfigurationApplication(); + $app = $this->makeCacheApplication(); $this->assertFalse($app->configurationIsCached()); } public function testConfigurationIsCachedReturnsTrueWhenCacheFileExists(): void { - $app = $this->makeConfigurationApplication(); + $app = $this->makeCacheApplication(); file_put_contents($app->getCachedConfigPath(), 'assertTrue($app->configurationIsCached()); @@ -874,7 +874,7 @@ public function testConfigurationIsCachedReturnsTrueWhenCacheFileExists(): void public function testConfigurationIsCachedUsesBoundState(): void { - $app = $this->makeConfigurationApplication(); + $app = $this->makeCacheApplication(); $app->instance('config_loaded_from_cache', true); $this->assertTrue($app->configurationIsCached()); @@ -887,7 +887,7 @@ public function testConfigurationIsCachedUsesBoundState(): void public function testConfigurationIsCachedMemoizesFilesystemResult(): void { - $app = $this->makeConfigurationApplication(); + $app = $this->makeCacheApplication(); $cachePath = $app->getCachedConfigPath(); $this->assertFalse($app->configurationIsCached()); @@ -896,7 +896,7 @@ public function testConfigurationIsCachedMemoizesFilesystemResult(): void $this->assertFalse($app->configurationIsCached()); - $freshApp = new Application($this->configurationApplicationPath); + $freshApp = new Application($this->cacheApplicationPath); $this->assertTrue($freshApp->configurationIsCached()); @@ -905,30 +905,52 @@ public function testConfigurationIsCachedMemoizesFilesystemResult(): void $this->assertTrue($freshApp->configurationIsCached()); } - public function testRoutesAreCachedReturnsFalseWhenNoCacheFile() + public function testRoutesAreCachedReturnsFalseWhenNoCacheFile(): void { - $app = new Application(sys_get_temp_dir() . '/hypervel-test-app-' . uniqid()); + $app = $this->makeCacheApplication(); $this->assertFalse($app->routesAreCached()); } - public function testRoutesAreCachedReturnsTrueWhenCacheFileExists() + public function testRoutesAreCachedReturnsTrueWhenCacheFileExists(): void { - $basePath = sys_get_temp_dir() . '/hypervel-test-app-' . uniqid(); - $cachePath = $basePath . '/bootstrap/cache/routes-v7.php'; + $app = $this->makeCacheApplication(); + file_put_contents($this->cacheApplicationPath . '/bootstrap/cache/routes-v7.php', 'assertTrue($app->routesAreCached()); + } + + public function testRoutesAreCachedUsesBoundState(): void + { + $app = $this->makeCacheApplication(); + $app->instance('routes.cached', true); + + $this->assertTrue($app->routesAreCached()); + + file_put_contents($app->getCachedRoutesPath(), 'instance('routes.cached', false); + + $this->assertFalse($app->routesAreCached()); + } + + public function testRoutesAreCachedMemoizesFilesystemResult(): void + { + $app = $this->makeCacheApplication(); + $cachePath = $app->getCachedRoutesPath(); + + $this->assertFalse($app->routesAreCached()); - mkdir(dirname($cachePath), 0755, true); file_put_contents($cachePath, 'assertTrue($app->routesAreCached()); - } finally { - unlink($cachePath); - rmdir(dirname($cachePath)); - rmdir(dirname($cachePath, 2)); - rmdir($basePath); - } + $this->assertFalse($app->routesAreCached()); + + $freshApp = new Application($this->cacheApplicationPath); + + $this->assertTrue($freshApp->routesAreCached()); + + unlink($cachePath); + + $this->assertTrue($freshApp->routesAreCached()); } public function testEventsAreCachedReturnsFalseWhenNoCacheFile() @@ -977,17 +999,17 @@ public function testAddAbsoluteCachePathPrefixReturnsSelf() } /** - * Create an application with an isolated configuration cache directory. + * Create an application with an isolated cache directory. */ - private function makeConfigurationApplication(): Application + private function makeCacheApplication(): Application { - $this->configurationApplicationPath = ParallelTesting::tempDir('FoundationApplicationConfigTest'); + $this->cacheApplicationPath = ParallelTesting::tempDir('FoundationApplicationCacheTest'); $files = new Filesystem; - $files->deleteDirectory($this->configurationApplicationPath); - $files->makeDirectory($this->configurationApplicationPath . '/bootstrap/cache', 0755, true); + $files->deleteDirectory($this->cacheApplicationPath); + $files->makeDirectory($this->cacheApplicationPath . '/bootstrap/cache', 0755, true); - return new Application($this->configurationApplicationPath); + return new Application($this->cacheApplicationPath); } private function makeNamespaceApplication(?string $composerContents, bool $createAppPath = true): Application From 37a3576e96a12718a65686d8b103ff5523559dba Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:56:59 +0000 Subject: [PATCH 17/22] Complete ucwords separator parity and handle empty separators Reconcile Laravel's original Unicode ucwords helper and its fluent separator argument against current 13.x source. Both APIs and all six upstream assertions per API are already present in Hypervel. Correct the shared upstream defect where an empty separator string builds an invalid regex character class. Delegate this supported no-delimiter case to the existing UTF-8 ucfirst helper, capitalizing only the first character without adding state or changing the normal separator path. Extend the existing static and fluent tests with Unicode-sentence and empty-input regressions. Add concise custom-separator examples to both public documentation sections after checking the current Laravel docs. Upstream PRs: https://github.com/laravel/framework/pull/57581 https://github.com/laravel/framework/pull/57688 Source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: both changed PHPUnit classes pass (354 tests, 1951 assertions); Support ParaTest passes (2630 tests, 8568 assertions); full PHPStan source and type checks, scoped formatting and git diff checks pass. Self-reviewed and approved by claude-laravel-parity. Tracks parity issue AI-029. --- src/docs/strings.md | 16 ++++++++++++++++ src/support/src/Str.php | 4 ++++ tests/Support/SupportStrTest.php | 2 ++ tests/Support/SupportStringableTest.php | 4 +++- 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/docs/strings.md b/src/docs/strings.md index 618ca9735..30483766e 100644 --- a/src/docs/strings.md +++ b/src/docs/strings.md @@ -1943,6 +1943,14 @@ $string = Str::ucwords('hypervel framework'); // Hypervel Framework ``` +You may pass custom word separators as the second argument: + +```php +$string = Str::ucwords('hypervel-framework', '-'); + +// Hypervel-Framework +``` + #### `Str::upper()` {.collection-method} @@ -4087,6 +4095,14 @@ $string = Str::of('hypervel framework')->ucwords(); // Hypervel Framework ``` +You may pass custom word separators to the method: + +```php +$string = Str::of('hypervel-framework')->ucwords('-'); + +// Hypervel-Framework +``` + #### `unwrap` {.collection-method} diff --git a/src/support/src/Str.php b/src/support/src/Str.php index a0b10ccbf..d4a5be695 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -1608,6 +1608,10 @@ public static function ucfirst(string $string): string */ public static function ucwords(string $string, string $separators = " \t\r\n\f\v"): string { + if ($separators === '') { + return static::ucfirst($string); + } + $pattern = '/(^|[' . preg_quote($separators, '/') . '])(\p{Ll})/u'; return preg_replace_callback($pattern, function ($matches) { diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index 9d28fbc1b..e8f702906 100644 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -1643,6 +1643,8 @@ public function testUcwords(): void $this->assertSame('Мама', Str::ucwords('мама')); $this->assertSame('Мама Мыла Раму', Str::ucwords('мама мыла раму')); $this->assertSame('JJ Watt', Str::ucwords('JJ watt')); + $this->assertSame('Мама мыла раму', Str::ucwords('мама мыла раму', '')); + $this->assertSame('', Str::ucwords('', '')); } public function testUcsplit(): void diff --git a/tests/Support/SupportStringableTest.php b/tests/Support/SupportStringableTest.php index 63d56d781..4ee600a3e 100644 --- a/tests/Support/SupportStringableTest.php +++ b/tests/Support/SupportStringableTest.php @@ -218,7 +218,7 @@ public function testCanBeLimitedByWords() $this->assertSame('Taylor Otwell', (string) $this->stringable('Taylor Otwell')->words(3)); } - public function testUcwords() + public function testUcwords(): void { $this->assertSame('Hypervel', (string) $this->stringable('hypervel')->ucwords()); $this->assertSame('Hypervel Framework', (string) $this->stringable('hypervel framework')->ucwords()); @@ -226,6 +226,8 @@ public function testUcwords() $this->assertSame('Мама', (string) $this->stringable('мама')->ucwords()); $this->assertSame('Мама Мыла Раму', (string) $this->stringable('мама мыла раму')->ucwords()); $this->assertSame('JJ Watt', (string) $this->stringable('JJ watt')->ucwords()); + $this->assertSame('Мама мыла раму', (string) $this->stringable('мама мыла раму')->ucwords('')); + $this->assertSame('', (string) $this->stringable('')->ucwords('')); } public function testUnless() From 01101809a9ea51c9b698f95cd812b7702e0c0958 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:42:37 +0000 Subject: [PATCH 18/22] Complete string helper parity and correct Unicode wrapping Reconcile the APA and word-splitting helpers with Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2, including the complete current Str and Stringable test additions discovered through that history. Port Unicode-aware word wrapping, native PHP 8.4 first-letter casing, and the public resetFactoryState API. Preserve Hypervel's native types, Symfony UID factories, separate finite-input StrCache, and authoritative test cleanup: flushState delegates to the narrow reset before flushing macros. Document the reset's worker-wide effect and its testing usage. Correct two defects shared by current Laravel: - Hyphenated title and subtitle openings must receive the same APA capitalization exception as nonhyphenated opening words. - Unicode word wrapping must encode existing separators before wrapping, and escape literal NUL/SUB bytes so the native break token cannot be confused with input. Keep the ASCII fast path and native wrapping primitive; remove the variable-length token search. Merge all sixteen upstream test methods, the missing headline/studly symbol assertions, and focused regressions. Restore the previous dump handler in finally and use valid typed Symfony UUID/ULID fixtures. Correct the missing semicolon in the documented word-wrap example. Upstream PRs: https://github.com/laravel/framework/pull/49572 https://github.com/laravel/framework/pull/56338 https://github.com/laravel/framework/pull/60012 https://github.com/laravel/framework/pull/60814 https://github.com/laravel/framework/pull/60864 https://github.com/laravel/framework/pull/57296 https://github.com/laravel/framework/pull/61260 Verified existing follow-up source and test coverage: https://github.com/laravel/framework/pull/50114 https://github.com/laravel/framework/pull/50335 https://github.com/laravel/framework/pull/51428 https://github.com/laravel/framework/pull/56796 https://github.com/laravel/framework/pull/57254 https://github.com/laravel/framework/pull/60056 Validation: changed test files pass 370 tests / 2,015 assertions; Support, Translation, and Validation ParaTest pass 4,499 tests / 14,900 assertions. Full source and type-fixture PHPStan, scoped formatting, and git diff --check pass. Independently reviewed and signed off by claude-laravel-parity. --- src/docs/strings.md | 8 ++- src/support/src/Str.php | 57 +++++++++++++--- tests/Support/SupportStrTest.php | 76 ++++++++++++++++++++- tests/Support/SupportStringableTest.php | 88 +++++++++++++++++++++++++ 4 files changed, 216 insertions(+), 13 deletions(-) diff --git a/src/docs/strings.md b/src/docs/strings.md index 30483766e..70dabfcf2 100644 --- a/src/docs/strings.md +++ b/src/docs/strings.md @@ -1453,6 +1453,12 @@ To instruct the `random` method to return to generating random strings normally, Str::createRandomStringsNormally(); ``` +You may reset the random string, UUID, and ULID factories together using the `resetFactoryState` method: + +```php +Str::resetFactoryState(); +``` + #### `Str::remove()` {.collection-method} @@ -2118,7 +2124,7 @@ The `Str::wordWrap` method wraps a string to a given number of characters: ```php use Hypervel\Support\Str; -$text = "The quick brown fox jumped over the lazy dog." +$text = "The quick brown fox jumped over the lazy dog."; Str::wordWrap($text, characters: 20, break: "
\n"); diff --git a/src/support/src/Str.php b/src/support/src/Str.php index d4a5be695..47906ae55 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -1248,16 +1248,20 @@ public static function apa(string $value): string $hyphenatedWords = explode('-', $lowercaseWord); $hyphenatedWords = array_map(function ($part) use ($minorWords) { - return (in_array($part, $minorWords) && mb_strlen($part) <= 3) + return (in_array($part, $minorWords, true) && mb_strlen($part) <= 3) ? $part : mb_strtoupper(mb_substr($part, 0, 1)) . mb_substr($part, 1); }, $hyphenatedWords); $words[$i] = implode('-', $hyphenatedWords); + + if ($i === 0 || in_array(mb_substr($words[$i - 1], -1), $endPunctuation, true)) { + $words[$i] = static::ucfirst($words[$i]); + } } else { - if (in_array($lowercaseWord, $minorWords) + if (in_array($lowercaseWord, $minorWords, true) && mb_strlen($lowercaseWord) <= 3 - && ! ($i === 0 || in_array(mb_substr($words[$i - 1], -1), $endPunctuation))) { + && ! ($i === 0 || in_array(mb_substr($words[$i - 1], -1), $endPunctuation, true))) { $words[$i] = $lowercaseWord; } else { $words[$i] = mb_strtoupper(mb_substr($lowercaseWord, 0, 1)) . mb_substr($lowercaseWord, 1); @@ -1588,7 +1592,7 @@ public static function fromBase64(string $string, bool $strict = false): string| */ public static function lcfirst(string $string): string { - return static::lower(static::substr($string, 0, 1)) . static::substr($string, 1); + return mb_lcfirst($string, 'UTF-8'); } /** @@ -1598,7 +1602,7 @@ public static function lcfirst(string $string): string */ public static function ucfirst(string $string): string { - return static::upper(static::substr($string, 0, 1)) . static::substr($string, 1); + return mb_ucfirst($string, 'UTF-8'); } /** @@ -1644,7 +1648,33 @@ public static function wordCount(string $string, ?string $characters = null): in */ public static function wordWrap(string $string, int $characters = 75, string $break = "\n", bool $cutLongWords = false): string { - return wordwrap($string, $characters, $break, $cutLongWords); + if (static::isAscii($string)) { + return wordwrap($string, $characters, $break, $cutLongWords); + } + + if ($break === '') { + return wordwrap($string, $characters, $break, $cutLongWords); + } + + $replaced = []; + $breakToken = "\0"; + + // Encode existing breaks so the native wrapper resets its line width. + $skeleton = implode($breakToken, array_map(function ($segment) use (&$replaced) { + return preg_replace_callback('/[\x80-\xFF][\x80-\xBF]*|[\x00\x1A]/', function ($match) use (&$replaced) { + $replaced[] = $match[0]; + + return "\x1A"; + }, $segment); + }, explode($break, $string))); + + $index = 0; + + return implode($break, array_map(function ($segment) use (&$replaced, &$index) { + return preg_replace_callback('/\x1A/', function () use (&$replaced, &$index) { + return $replaced[$index++]; + }, $segment); + }, explode($breakToken, wordwrap($skeleton, $characters, $breakToken, $cutLongWords)))); } /** @@ -1890,15 +1920,24 @@ public static function flushCache(): void } /** - * Flush all static state. + * Return all factory functions to their default state. + * + * Tests only. Clears the worker-wide random string, UUID, and ULID + * factories, affecting generation in every coroutine. */ - public static function flushState(): void + public static function resetFactoryState(): void { - // Return all factory functions to their default state. static::createRandomStringsNormally(); static::createUlidsNormally(); static::createUuidsNormally(); + } + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::resetFactoryState(); static::flushMacros(); } } diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index e8f702906..a238edbb6 100644 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -113,6 +113,8 @@ public function testStringHeadline(): void $this->assertSame('Orwell 1984', Str::headline('-orwell-1984 -')); $this->assertSame('Orwell 1984', Str::headline(' orwell_- 1984 ')); + $this->assertSame('❤ Multi Byte ☆', Str::headline('❤_multiByte-☆')); + $nbsp = chr(0xC2) . chr(0xA0); $this->assertSame('Hypervel Rocks!', Str::headline('hypervel' . $nbsp . 'rocks!')); @@ -162,6 +164,10 @@ public function testStringApa(): void $this->assertSame('Self-Report', Str::apa('Self-report')); $this->assertSame('Self-Report', Str::apa('SELF-REPORT')); + $this->assertSame('On-Call Work', Str::apa('on-call work')); + $this->assertSame('A Guide: On-Call Work', Str::apa('a guide: on-call work')); + $this->assertSame('A Guide to on-Call Work', Str::apa('a guide to on-call work')); + $this->assertSame('As the World Turns, So Are the Days of Our Lives', Str::apa('as the world turns, so are the days of our lives')); $this->assertSame('As the World Turns, So Are the Days of Our Lives', Str::apa('AS THE WORLD TURNS, SO ARE THE DAYS OF OUR LIVES')); $this->assertSame('As the World Turns, So Are the Days of Our Lives', Str::apa('As The World Turns, So Are The Days Of Our Lives')); @@ -899,6 +905,17 @@ public static function uuidVersionList(): array ]; } + public function testIsUlid(): void + { + $this->assertTrue(Str::isUlid((string) Str::ulid())); + $this->assertTrue(Str::isUlid('01ARZ3NDEKTSV4RRFFQ69G5FAV')); + + $this->assertFalse(Str::isUlid('not-a-ulid')); + $this->assertFalse(Str::isUlid('01ARZ3NDEKTSV4RRFFQ69G5FA')); + $this->assertFalse(Str::isUlid(null)); + $this->assertFalse(Str::isUlid(['not', 'a', 'ulid'])); + } + public function testIsJson(): void { $this->assertTrue(Str::isJson('1')); @@ -1381,6 +1398,8 @@ public function testStudly(): void $this->assertSame('ÖffentlicheÜberraschungen', Str::studly('öffentliche-überraschungen')); + $this->assertSame('❤MultiByte☆', Str::studly('❤ multi-byte☆')); + $nbsp = chr(0xC2) . chr(0xA0); $this->assertSame('HypervelRocks!', Str::studly('hypervel' . $nbsp . 'rocks!')); @@ -1633,6 +1652,8 @@ public function testUcfirst(): void $this->assertSame('Hypervel framework', Str::ucfirst('hypervel framework')); $this->assertSame('Мама', Str::ucfirst('мама')); $this->assertSame('Мама мыла раму', Str::ucfirst('мама мыла раму')); + $this->assertSame('Džungla', Str::ucfirst('džungla')); + $this->assertSame('Sseta', Str::ucfirst('ßeta')); } public function testUcwords(): void @@ -1761,10 +1782,22 @@ public function testWordCount(): void public function testWordWrap(): void { - $this->assertEquals('Hello
World', Str::wordWrap('Hello World', 3, '
')); - $this->assertEquals('Hel
lo
Wor
ld', Str::wordWrap('Hello World', 3, '
', true)); + $this->assertSame('Hello
World', Str::wordWrap('Hello World', 3, '
')); + $this->assertSame('Hel
lo
Wor
ld', Str::wordWrap('Hello World', 3, '
', true)); + + $this->assertSame('❤Multi
Byte☆❤☆❤☆❤', Str::wordWrap('❤Multi Byte☆❤☆❤☆❤', 3, '
')); + + $this->assertSame('žltý kôň', Str::wordWrap('žltý kôň', 8, "\n")); + $this->assertSame("žltý\nkôň", Str::wordWrap('žltý kôň', 4, "\n", true)); + $this->assertSame("žl\ntý", Str::wordWrap('žltý', 2, "\n", true)); + $this->assertSame("😀😀\n😀😀", Str::wordWrap('😀😀😀😀', 2, "\n", true)); + $this->assertSame("éA\x1ABé", Str::wordWrap('é é', 1, "A\x1AB")); + $this->assertSame('❤Mu
lti
Byt
e☆❤
☆❤☆
❤', Str::wordWrap('❤Multi Byte☆❤☆❤☆❤', 3, '
', true)); - $this->assertEquals('❤Multi
Byte☆❤☆❤☆❤', Str::wordWrap('❤Multi Byte☆❤☆❤☆❤', 3, '
')); + $this->assertSame("éé\néé éé", Str::wordWrap("éé\néé éé", 5, "\n")); + $this->assertSame('éé
éé éé', Str::wordWrap('éé
éé éé', 5, '
', true)); + $this->assertSame('éé☆éé éé', Str::wordWrap('éé☆éé éé', 5, '☆')); + $this->assertSame("é\0\n\x1Aé", Str::wordWrap("é\0\x1Aé", 2, "\n", true)); } public function testMarkdown(): void @@ -2090,6 +2123,28 @@ public function testUlidSequenceIsRestoredWhenNormalGenerationThrows(): void ThrowingSequenceStr::createUlidsNormally(); } + public function testResetFactoryState(): void + { + $uuid = Uuid::fromString('00000000-0000-0000-0000-000000000000'); + $ulid = new Ulid('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + Str::macro('factoryResetMacro', fn (): bool => true); + Str::createRandomStringsUsing(fn (int $length): string => 'random:' . $length); + Str::createUuidsUsing(fn (): Uuid => $uuid); + Str::createUlidsUsing(fn (): Ulid => $ulid); + + $this->assertSame('random:7', Str::random(7)); + $this->assertSame((string) $uuid, (string) Str::uuid()); + $this->assertSame((string) $ulid, (string) Str::ulid()); + + Str::resetFactoryState(); + + $this->assertNotSame('random:7', Str::random(7)); + $this->assertNotSame((string) $uuid, (string) Str::uuid()); + $this->assertNotSame((string) $ulid, (string) Str::ulid()); + $this->assertTrue(Str::hasMacro('factoryResetMacro')); + } + public function testPasswordCreation(): void { $this->assertTrue(strlen(Str::password()) === 32); @@ -2265,6 +2320,21 @@ public function count(): int $this->assertSame('UserGroups', Str::pluralPascal('UserGroup', $countable)); } + + public function testPluralStudly(): void + { + $this->assertSame('VerifiedHumans', Str::pluralStudly('VerifiedHuman')); + $this->assertSame('UserFeedback', Str::pluralStudly('UserFeedback')); + $this->assertSame('VerifiedHuman', Str::pluralStudly('VerifiedHuman', 1)); + $this->assertSame('VerifiedHumans', Str::pluralStudly('VerifiedHuman', 2)); + } + + public function testSingular(): void + { + $this->assertSame('child', Str::singular('children')); + $this->assertSame('mouse', Str::singular('mice')); + $this->assertSame('Laracon', Str::singular('Laracons')); + } } class ThrowingSequenceStr extends Str diff --git a/tests/Support/SupportStringableTest.php b/tests/Support/SupportStringableTest.php index 4ee600a3e..ea86fdf2c 100644 --- a/tests/Support/SupportStringableTest.php +++ b/tests/Support/SupportStringableTest.php @@ -18,6 +18,7 @@ use Hypervel\Tests\TestCase; use League\CommonMark\Environment\EnvironmentBuilderInterface; use League\CommonMark\Extension\ExtensionInterface; +use Symfony\Component\VarDumper\VarDumper; class SupportStringableTest extends TestCase { @@ -301,6 +302,13 @@ public function testDirname() $this->assertSame(DIRECTORY_SEPARATOR, (string) $this->stringable('/')->dirname()); } + public function testBasename(): void + { + $this->assertSame('Support', (string) $this->stringable('/framework/tests/Support')->basename()); + $this->assertSame('Str.php', (string) $this->stringable('/framework/src/Str.php')->basename()); + $this->assertSame('Str', (string) $this->stringable('/framework/src/Str.php')->basename('.php')); + } + public function testUcsplitOnStringable() { $this->assertSame(['Taylor', 'Otwell'], $this->stringable('TaylorOtwell')->ucsplit()->toArray()); @@ -648,6 +656,55 @@ public function testTitle() $this->assertSame('Jefferson Costella', (string) $this->stringable('jefFErson coSTella')->title()); } + public function testHeadline(): void + { + $this->assertSame('Jefferson Costella', (string) $this->stringable('jefferson costella')->headline()); + $this->assertSame('Hypervel Php Framework', (string) $this->stringable('hypervel_php_framework')->headline()); + $this->assertSame('Foo Bar Baz', (string) $this->stringable('foo-barBaz')->headline()); + } + + public function testApa(): void + { + $this->assertSame('Back to the Future', (string) $this->stringable('back to the future')->apa()); + $this->assertSame('Self-Report', (string) $this->stringable('self-report')->apa()); + } + + public function testLcfirst(): void + { + $this->assertSame('hypervel', (string) $this->stringable('Hypervel')->lcfirst()); + $this->assertSame('hypervel framework', (string) $this->stringable('Hypervel framework')->lcfirst()); + } + + public function testUcfirst(): void + { + $this->assertSame('Hypervel', (string) $this->stringable('hypervel')->ucfirst()); + $this->assertSame('Hypervel framework', (string) $this->stringable('hypervel framework')->ucfirst()); + } + + public function testConvertCase(): void + { + $this->assertSame('HELLO', (string) $this->stringable('hello')->convertCase(MB_CASE_UPPER)); + $this->assertSame('hello', (string) $this->stringable('HELLO')->convertCase(MB_CASE_LOWER)); + } + + public function testWordWrap(): void + { + $this->assertSame('Hello
World', (string) $this->stringable('Hello World')->wordWrap(3, '
')); + $this->assertSame('Hel
lo
Wor
ld', (string) $this->stringable('Hello World')->wordWrap(3, '
', true)); + } + + public function testPlural(): void + { + $this->assertSame('Laracons', (string) $this->stringable('Laracon')->plural(3)); + $this->assertSame('Laracon', (string) $this->stringable('Laracon')->plural(1)); + } + + public function testSingular(): void + { + $this->assertSame('child', (string) $this->stringable('children')->singular()); + $this->assertSame('mouse', (string) $this->stringable('mice')->singular()); + } + public function testWithoutWordsDoesntProduceError() { $nbsp = chr(0xC2) . chr(0xA0); @@ -1051,6 +1108,20 @@ public function testKebab() $this->assertSame('hypervel-php-framework', (string) $this->stringable('HypervelPhpFramework')->kebab()); } + public function testChopStart(): void + { + $this->assertSame('hypervel.com', (string) $this->stringable('http://hypervel.com')->chopStart('http://')); + $this->assertSame('http://hypervel.com', (string) $this->stringable('http://hypervel.com')->chopStart('https://')); + $this->assertSame('hypervel.com', (string) $this->stringable('http://hypervel.com')->chopStart(['https://', 'http://'])); + } + + public function testChopEnd(): void + { + $this->assertSame('path/to/file', (string) $this->stringable('path/to/file.php')->chopEnd('.php')); + $this->assertSame('path/to/file.php', (string) $this->stringable('path/to/file.php')->chopEnd('.html')); + $this->assertSame('path/to/file', (string) $this->stringable('path/to/file.php')->chopEnd(['.html', '.php'])); + } + public function testLower() { $this->assertSame('foo bar baz', (string) $this->stringable('FOO BAR BAZ')->lower()); @@ -1665,4 +1736,21 @@ public function testEncryptAndDecrypt() $this->assertNotSame('foo', $encrypted->value()); $this->assertSame('foo', $encrypted->decrypt()->value()); } + + public function testDump(): void + { + $log = new Collection; + + $previousHandler = VarDumper::setHandler(function (mixed $value) use ($log): void { + $log->add($value); + }); + + try { + $this->stringable('foo')->dump('one', 'two'); + + $this->assertSame(['foo', 'one', 'two'], $log->all()); + } finally { + VarDumper::setHandler($previousHandler); + } + } } From 513669f659e4a281f92e7420c43545bf0d64b41f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:52:47 +0000 Subject: [PATCH 19/22] Complete null-safe query comparison parity across supported databases Port the current Laravel 13.x implementation and tests identified by: https://github.com/laravel/framework/pull/57698 https://github.com/laravel/framework/pull/58962 Source revision: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Route the <=> operator through each driver's null-safe equality compiler and remove SQLite's obsolete basic-clause override. Preserve all supported morph relationship cases and merge the seven applicable upstream query builder tests with existing null and raw-expression coverage. Correct two upstream gaps during adaptation: RHS subqueries bypassed the null-safe compiler, and JSON booleans either lost their driver-specific casts or compared against an incorrectly bound scalar. Reuse the existing dialect compilers with prepared expressions, preserving Hypervel's child grammar, embedded timeout checks and binding order. Normalize SQLite's literal booleans to 1/0 so IS compares equality instead of truthiness. Keep Laravel public/protected APIs and Hypervel's pooled architecture; no new worker state or database round trips. SQL Server remains unsupported. Validation: Database ParaTest 3518 tests / 12464 assertions; both changed files pass individually; full source/type PHPStan, scoped formatter and diff check pass. Real SQLite execution also passes (1 test / 8 assertions). The owner deferred committing that additional integration test to the next checkpoint to keep this PR at 100 files; its source is preserved in /tmp/hypervel-laravel-parity-57698/NullSafeEqualityTest.php. Reviewed and approved by claude-laravel-parity. --- src/database/src/Query/Builder.php | 9 ++ src/database/src/Query/Grammars/Grammar.php | 14 ++ .../src/Query/Grammars/SQLiteGrammar.php | 40 +++--- .../Database/DatabaseEloquentBuilderTest.php | 2 + tests/Database/DatabaseQueryBuilderTest.php | 127 +++++++++++++++++- 5 files changed, 168 insertions(+), 24 deletions(-) diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index d4f5bc737..fe99ae563 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -858,6 +858,11 @@ public function where(Closure|self|EloquentBuilder|Relation|ExpressionContract|a $type = 'Bitwise'; } + // JSON booleans need their driver's casts before applying null-safe equality. + if ($operator === '<=>' && $type !== 'JsonBoolean') { + $type = 'NullSafeEquals'; + } + // Now that we are working with just a simple query we can put the elements // in our array and add the query binding to our array of bindings that // will be bound to each SQL statements when it is finally executed. @@ -1143,6 +1148,10 @@ public function orWhereNotLike(ExpressionContract|string $column, string $value, */ public function whereNullSafeEquals(ExpressionContract|string $column, mixed $value, string $boolean = 'and'): static { + if (is_bool($value) && is_string($column) && str_contains($column, '->')) { + return $this->where($column, '<=>', $value, $boolean); + } + $type = 'NullSafeEquals'; $this->wheres[] = compact('type', 'column', 'value', 'boolean'); diff --git a/src/database/src/Query/Grammars/Grammar.php b/src/database/src/Query/Grammars/Grammar.php index 39602b8e0..94776b4e1 100755 --- a/src/database/src/Query/Grammars/Grammar.php +++ b/src/database/src/Query/Grammars/Grammar.php @@ -9,6 +9,7 @@ use Hypervel\Database\Concerns\CompilesJsonPaths; use Hypervel\Database\Grammar as BaseGrammar; use Hypervel\Database\Query\Builder; +use Hypervel\Database\Query\Expression as QueryExpression; use Hypervel\Database\Query\JoinClause; use Hypervel\Database\Query\JoinLateralClause; use Hypervel\Support\Arr; @@ -512,6 +513,12 @@ protected function whereSub(Builder $query, array $where): string $subquery = $where['query']; $select = $subquery->getGrammar()->compileSelectQuery($subquery); + if ($where['operator'] === '<=>') { + $where['value'] = new QueryExpression("({$select})"); + + return $this->whereNullSafeEquals($query, $where); + } + return $this->wrap($where['column']) . ' ' . $where['operator'] . " ({$select})"; } @@ -558,6 +565,13 @@ protected function whereJsonBoolean(Builder $query, array $where): string $this->parameter($where['value']) ); + if ($where['operator'] === '<=>') { + $where['column'] = new QueryExpression($column); + $where['value'] = new QueryExpression($value); + + return $this->whereNullSafeEquals($query, $where); + } + return $column . ' ' . $where['operator'] . ' ' . $value; } diff --git a/src/database/src/Query/Grammars/SQLiteGrammar.php b/src/database/src/Query/Grammars/SQLiteGrammar.php index f42acf39f..af075f253 100755 --- a/src/database/src/Query/Grammars/SQLiteGrammar.php +++ b/src/database/src/Query/Grammars/SQLiteGrammar.php @@ -41,29 +41,6 @@ protected function wrapUnion(string $sql): string return 'select * from (' . $sql . ')'; } - /** - * Compile a basic where clause. - */ - protected function whereBasic(Builder $query, array $where): string - { - if ($where['operator'] === '<=>') { - $column = $this->wrap($where['column']); - $value = $this->parameter($where['value']); - - return "{$column} IS {$value}"; - } - - return parent::whereBasic($query, $where); - } - - /** - * Compile a "where null safe equals" clause. - */ - protected function whereNullSafeEquals(Builder $query, array $where): string - { - return $this->wrap($where['column']) . ' is ' . $this->parameter($where['value']); - } - /** * Compile a "where like" clause. */ @@ -89,6 +66,23 @@ public function prepareWhereLikeBinding(string $value, bool $caseSensitive): str ); } + /** + * Compile a "where null safe equals" clause. + */ + protected function whereNullSafeEquals(Builder $query, array $where): string + { + $value = $this->parameter($where['value']); + + // SQLite's IS TRUE and IS FALSE test truthiness instead of equality. + $value = match ($value) { + 'true' => '1', + 'false' => '0', + default => $value, + }; + + return $this->wrap($where['column']) . ' is ' . $value; + } + /** * Compile a "where date" clause. */ diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index 22de23af5..a08104d15 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -2567,6 +2567,8 @@ public function testWhereNotMorphedToClassUsesPostgresNullSafeEquality(): void $this->assertSame([ModelCloseRelatedStub::class], $builder->getBindings()); } + // REMOVED: SQL Server whereNotMorphedTo tests; SQL Server is not supported. + public function testWhereMorphedToAlias(): void { $model = new ModelParentStub; diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 4decaa72c..14a98b641 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -932,11 +932,29 @@ public function testWhereTimeOperatorOptionalSqlite() public function testWhereNullSafeEquals(): void { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar'); + $this->assertSame('select * from "users" where "foo" is not distinct from ?', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar')->whereNullSafeEquals('baz', 'qux'); + $this->assertSame('select * from "users" where "foo" is not distinct from ? and "baz" is not distinct from ?', $builder->toSql()); + $this->assertSame(['bar', 'qux'], $builder->getBindings()); + $builder = $this->getBuilder(); $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar')->whereNullSafeEquals('baz', null); $this->assertSame('select * from "users" where "foo" is not distinct from ? and "baz" is not distinct from ?', $builder->toSql()); $this->assertSame(['bar', null], $builder->getBindings()); + } + + public function testOrWhereNullSafeEquals(): void + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('foo', 'bar')->orWhereNullSafeEquals('baz', 'qux'); + $this->assertSame('select * from "users" where "foo" = ? or "baz" is not distinct from ?', $builder->toSql()); + $this->assertSame(['bar', 'qux'], $builder->getBindings()); $builder = $this->getBuilder(); $builder->select('*')->from('users')->where('foo', 'bar')->orWhereNullSafeEquals('baz', new Raw('qux')); @@ -945,19 +963,126 @@ public function testWhereNullSafeEquals(): void $this->assertSame(['bar'], $builder->getBindings()); } - public function testWhereNullSafeEqualsUsesSupportedDriverSyntax(): void + public function testWhereNullSafeEqualsViaNullSafeOperator(): void + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('foo', '<=>', 'bar'); + $this->assertSame('select * from "users" where "foo" is not distinct from ?', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + } + + public function testWhereNullSafeEqualsWithNullViaOperator(): void + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('foo', '<=>', null); + $this->assertSame('select * from "users" where "foo" is null', $builder->toSql()); + } + + public function testWhereNullSafeEqualsMySql(): void { $builder = $this->getMySqlBuilder(); $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar'); $this->assertSame('select * from `users` where `foo` <=> ?', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('foo', '<=>', 'bar'); + $this->assertSame('select * from `users` where `foo` <=> ?', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + } + public function testWhereNullSafeEqualsSQLite(): void + { $builder = $this->getSQLiteBuilder(); $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar'); $this->assertSame('select * from "users" where "foo" is ?', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->where('foo', '<=>', 'bar'); + $this->assertSame('select * from "users" where "foo" is ?', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + } + + public function testWhereNullSafeEqualsPostgres(): void + { $builder = $this->getPostgresBuilder(); $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar'); $this->assertSame('select * from "users" where "foo" is not distinct from ?', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('foo', '<=>', 'bar'); + $this->assertSame('select * from "users" where "foo" is not distinct from ?', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + } + + // REMOVED: SQL Server null-safe equality tests; SQL Server is not supported. + + public function testWhereNullSafeEqualsWithJsonBooleansMySql(): void + { + $builder = $this->getMySqlBuilder(); + $builder->from('users')->where('id', 1)->whereNullSafeEquals('options->enabled', true)->orWhereNullSafeEquals('options->archived', false); + $this->assertSame('select * from `users` where `id` = ? and json_extract(`options`, \'$."enabled"\') <=> true or json_extract(`options`, \'$."archived"\') <=> false', $builder->toSql()); + $this->assertSame([1], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->from('users')->where('id', 1)->where('options->enabled', '<=>', true)->orWhere('options->archived', '<=>', false); + $this->assertSame('select * from `users` where `id` = ? and json_extract(`options`, \'$."enabled"\') <=> true or json_extract(`options`, \'$."archived"\') <=> false', $builder->toSql()); + $this->assertSame([1], $builder->getBindings()); + } + + public function testWhereNullSafeEqualsWithJsonBooleansSQLite(): void + { + $builder = $this->getSQLiteBuilder(); + $builder->from('users')->where('id', 1)->whereNullSafeEquals('options->enabled', true)->orWhereNullSafeEquals('options->archived', false); + $this->assertSame('select * from "users" where "id" = ? and json_extract("options", \'$."enabled"\') is 1 or json_extract("options", \'$."archived"\') is 0', $builder->toSql()); + $this->assertSame([1], $builder->getBindings()); + + $builder = $this->getSQLiteBuilder(); + $builder->from('users')->where('id', 1)->where('options->enabled', '<=>', true)->orWhere('options->archived', '<=>', false); + $this->assertSame('select * from "users" where "id" = ? and json_extract("options", \'$."enabled"\') is 1 or json_extract("options", \'$."archived"\') is 0', $builder->toSql()); + $this->assertSame([1], $builder->getBindings()); + } + + public function testWhereNullSafeEqualsWithJsonBooleansPostgres(): void + { + $builder = $this->getPostgresBuilder(); + $builder->from('users')->where('id', 1)->whereNullSafeEquals('options->enabled', true)->orWhereNullSafeEquals('options->archived', false); + $this->assertSame('select * from "users" where "id" = ? and ("options"->\'enabled\')::jsonb is not distinct from \'true\'::jsonb or ("options"->\'archived\')::jsonb is not distinct from \'false\'::jsonb', $builder->toSql()); + $this->assertSame([1], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->from('users')->where('id', 1)->where('options->enabled', '<=>', true)->orWhere('options->archived', '<=>', false); + $this->assertSame('select * from "users" where "id" = ? and ("options"->\'enabled\')::jsonb is not distinct from \'true\'::jsonb or ("options"->\'archived\')::jsonb is not distinct from \'false\'::jsonb', $builder->toSql()); + $this->assertSame([1], $builder->getBindings()); + } + + public function testWhereNullSafeEqualsWithSubqueryMySql(): void + { + $builder = $this->getMySqlBuilder(); + $builder->from('users')->where('id', 1)->orWhere('foo', '<=>', fn (Builder $query) => $query->selectRaw('?', ['bar'])); + + $this->assertSame('select * from `users` where `id` = ? or `foo` <=> (select ?)', $builder->toSql()); + $this->assertSame([1, 'bar'], $builder->getBindings()); + } + + public function testWhereNullSafeEqualsWithSubquerySQLite(): void + { + $builder = $this->getSQLiteBuilder(); + $builder->from('users')->where('id', 1)->orWhere('foo', '<=>', fn (Builder $query) => $query->selectRaw('?', ['bar'])); + + $this->assertSame('select * from "users" where "id" = ? or "foo" is (select ?)', $builder->toSql()); + $this->assertSame([1, 'bar'], $builder->getBindings()); + } + + public function testWhereNullSafeEqualsWithSubqueryPostgres(): void + { + $builder = $this->getPostgresBuilder(); + $builder->from('users')->where('id', 1)->orWhere('foo', '<=>', fn (Builder $query) => $query->selectRaw('?', ['bar'])); + + $this->assertSame('select * from "users" where "id" = ? or "foo" is not distinct from (select ?)', $builder->toSql()); + $this->assertSame([1, 'bar'], $builder->getBindings()); } public function testWhereBetweens() From 79c0351bf0591c320275674ec5f29d202b0fe9b8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:48:03 +0000 Subject: [PATCH 20/22] Fix Redis scan patterns and consume startup context transport Preserve logical SafeScan patterns even when their first segment matches OPT_PREFIX. Only prepend the connection prefix when phpredis does not do so; continue stripping physical result prefixes for subsequent commands. The old input guess could scan and delete unrelated keys. Correct its unit expectation and cover overlapping prefixes, option preservation and wrong-key deletion on standalone Redis and Redis Cluster. Consume __HYPERVEL_CONTEXT during console boot so descendants cannot inherit a stale startup payload. Clear both configured environment adapters and the native environment, including when PutenvAdapter is disabled. Preserve initial-command hydration, callback reentry handling and coroutine isolation; verify inheritance using a real subprocess. Clarify logical scan patterns and the topology-specific Redis queue-name storage suffixes. Document the reproduced PhpRedis 6.3.0 Cluster tcp_keepalive crash without adding a Hypervel workaround. Its upstream fix is 997d564521b3b95866c7747a971c4e357d8046e4. Restore the real SQLite null-safe JSON comparison regression previously held outside the checkpoint for the initial review's file limit. It checks both APIs, selector forms, boolean/integer equality and null or missing values. The owner lifted the limit for incremental review fixes. Follow-up to these Laravel ports: https://github.com/laravel/framework/pull/61198 https://github.com/laravel/framework/pull/57918 https://github.com/laravel/framework/pull/61419 https://github.com/laravel/framework/pull/57698 https://github.com/laravel/framework/pull/58962 Reviewed by claude-laravel-parity after both external bot reviews. Validation: full source/type PHPStan and formatting; 1456 focused unit tests (2 skips), 238 standalone Redis consumer tests (8 skips), 90 Cluster consumer tests, and the restored SQLite regression. No committed tests were skipped or weakened for the native extension failure found when a standalone-only connector test was initially selected on Cluster. --- src/console/src/ConsoleServiceProvider.php | 13 +++- src/docs/queues.md | 2 + src/docs/redis.md | 5 +- src/redis/src/Operations/SafeScan.php | 14 ++-- tests/Console/ConsoleServiceProviderTest.php | 35 +++++++++- .../Database/Sqlite/NullSafeEqualityTest.php | 66 +++++++++++++++++++ .../Redis/SafeScanIntegrationTest.php | 16 +++-- tests/Redis/Operations/SafeScanTest.php | 13 ++-- 8 files changed, 137 insertions(+), 27 deletions(-) create mode 100644 tests/Integration/Database/Sqlite/NullSafeEqualityTest.php diff --git a/src/console/src/ConsoleServiceProvider.php b/src/console/src/ConsoleServiceProvider.php index 152954c22..23e76b59c 100644 --- a/src/console/src/ConsoleServiceProvider.php +++ b/src/console/src/ConsoleServiceProvider.php @@ -40,8 +40,17 @@ public function register(): void */ public function boot(Dispatcher $events): void { - if ($this->app->runningInConsole() - && is_string($encoded = Env::get('__HYPERVEL_CONTEXT')) + if (! $this->app->runningInConsole() + || ($encoded = Env::get('__HYPERVEL_CONTEXT')) === null) { + return; + } + + // Consume startup transport at boot so child processes cannot inherit stale context. + Env::deleteMany(['__HYPERVEL_CONTEXT']); + // The native environment also needs clearing when Env's putenv adapter is disabled. + putenv('__HYPERVEL_CONTEXT'); + + if (is_string($encoded) && is_array($context = unserialize(base64_decode($encoded, true), ['allowed_classes' => false]))) { $events->listen(BeforeHandle::class, static function () use (&$context): void { if ($context !== null) { diff --git a/src/docs/queues.md b/src/docs/queues.md index 9ef390c1c..bba6ea549 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -204,6 +204,8 @@ If your Redis queue connection uses a [Redis Cluster](https://redis.io/docs/late You may still include your own hash tag, such as `{mail}:high`, when you need several queue names to share a specific Redis Cluster slot. Hypervel leaves explicit hash tags unchanged. +On standalone Redis, or when supplying an explicit Cluster hash tag, avoid queue names ending in `:delayed`, `:reserved`, or `:notify`, which overlap with the queue driver's storage keys. Automatically tagged Cluster queue names do not have this restriction. + ##### Blocking diff --git a/src/docs/redis.md b/src/docs/redis.md index 3959ffcc5..06961e831 100644 --- a/src/docs/redis.md +++ b/src/docs/redis.md @@ -120,6 +120,9 @@ A non-zero `read_timeout` is applied both when the Redis socket is opened and as The `context` option accepts stream options directly or nested under an `ssl` or `stream` key. If you need to configure PhpRedis options such as `prefix`, `scan`, `serializer`, `compression`, `compression_level`, `tcp_keepalive`, or `pack_ignore_numbers`, add them to the `options` array. The `pack_ignore_numbers` option requires PhpRedis 6.2 or later and applies to standalone and Cluster connections. Connection options override shared options, while a non-null top-level connection `prefix` takes final precedence. +> [!WARNING] +> PhpRedis 6.3.0 crashes when `tcp_keepalive` is applied to a Redis Cluster connection; leave this option unset for Cluster connections when using that version. + #### Retry and Backoff Configuration @@ -608,7 +611,7 @@ PhpRedis does not support pipelining on Redis Cluster connections. Pipelining re ### Advanced Helpers -If you need to stream keys with Redis' `SCAN` command, use `safeScan` while holding a pooled connection. The `safeScan` method applies your configured prefix exactly once, including when PhpRedis' `SCAN_PREFIX` option is enabled, and removes it from returned keys: +If you need to stream keys with Redis' `SCAN` command, use `safeScan` while holding a pooled connection. Pass a logical key pattern without adding the connection prefix, just as you would pass a key to `get`. The `safeScan` method adds the connection prefix, including when PhpRedis' `SCAN_PREFIX` option is enabled, and removes it from returned keys: ```php use Hypervel\Redis\RedisConnection; diff --git a/src/redis/src/Operations/SafeScan.php b/src/redis/src/Operations/SafeScan.php index 907beff47..1f2d40bc1 100644 --- a/src/redis/src/Operations/SafeScan.php +++ b/src/redis/src/Operations/SafeScan.php @@ -105,16 +105,12 @@ public function execute(string $pattern, int $count = 1000): Generator { $prefixLen = strlen($this->optPrefix); - // Apply OPT_PREFIX exactly once, whether phpredis adds it or not. + // Patterns are logical keys, even when they start with OPT_PREFIX. + // Prepend the connection prefix only when phpredis will not add it. $scanPattern = $pattern; - if ($prefixLen > 0) { - if (str_starts_with($scanPattern, $this->optPrefix)) { - $scanPattern = substr($scanPattern, $prefixLen); - } - - if (($this->connection->getOption(Redis::OPT_SCAN) & Redis::SCAN_PREFIX) === 0) { - $scanPattern = $this->optPrefix . $scanPattern; - } + if ($prefixLen > 0 + && ($this->connection->getOption(Redis::OPT_SCAN) & Redis::SCAN_PREFIX) === 0) { + $scanPattern = $this->optPrefix . $pattern; } // Route to cluster or standard implementation diff --git a/tests/Console/ConsoleServiceProviderTest.php b/tests/Console/ConsoleServiceProviderTest.php index 01e30b031..f351bd928 100644 --- a/tests/Console/ConsoleServiceProviderTest.php +++ b/tests/Console/ConsoleServiceProviderTest.php @@ -19,11 +19,13 @@ use Hypervel\Engine\Channel; use Hypervel\Log\Context\Events\ContextHydrated; use Hypervel\Log\Context\Repository; +use Hypervel\Support\Env; use Hypervel\Support\Facades\Context; use Hypervel\Testbench\Attributes\WithEnv; use Hypervel\Testbench\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Process\Process; class ConsoleServiceProviderTest extends TestCase { @@ -49,7 +51,8 @@ public function testScheduleCommandsAreRegistered() } } - public function testProcessContextIsHydratedOnlyForTheInitialCommand(): void + #[DataProvider('putenvAdapters')] + public function testProcessContextIsHydratedOnlyForTheInitialCommand(bool $putenvEnabled): void { $payload = [ 'data' => ['task' => serialize('concurrency')], @@ -58,9 +61,22 @@ public function testProcessContextIsHydratedOnlyForTheInitialCommand(): void $restoreEnvironment = (new WithEnv('__HYPERVEL_CONTEXT', base64_encode(serialize($payload))))($this->app); try { + if (! $putenvEnabled) { + Env::disablePutenv(); + } + $events = $this->app->make('events'); (new ConsoleServiceProvider($this->app))->boot($events); + $this->assertNull(Env::get('__HYPERVEL_CONTEXT')); + $this->assertFalse(getenv('__HYPERVEL_CONTEXT')); + $this->assertArrayNotHasKey('__HYPERVEL_CONTEXT', $_ENV); + $this->assertArrayNotHasKey('__HYPERVEL_CONTEXT', $_SERVER); + + $process = new Process([PHP_BINARY, '-r', 'echo json_encode(getenv("__HYPERVEL_CONTEXT"));']); + $process->mustRun(); + $this->assertSame('false', $process->getOutput()); + $command = new BeforeHandle(new Command('context:test'), new ArrayInput([])); $hydrations = 0; $received = null; @@ -95,10 +111,22 @@ public function testProcessContextIsHydratedOnlyForTheInitialCommand(): void $this->assertSame([false], $result->pop(1)); $this->assertSame(1, $hydrations); } finally { + Env::enablePutenv(); $restoreEnvironment(); } } + /** + * Provide environment adapter configurations. + */ + public static function putenvAdapters(): array + { + return [ + 'putenv enabled' => [true], + 'putenv disabled' => [false], + ]; + } + #[DataProvider('contextsWithoutProcessHydration')] public function testProcessContextIsNotHydratedWithoutAConsolePayload(bool $runningInConsole, ?array $payload): void { @@ -110,6 +138,11 @@ public function testProcessContextIsNotHydratedWithoutAConsolePayload(bool $runn $events = $this->app->make('events'); (new ConsoleServiceProvider($this->app))->boot($events); + if ($runningInConsole) { + $this->assertNull(Env::get('__HYPERVEL_CONTEXT')); + $this->assertFalse(getenv('__HYPERVEL_CONTEXT')); + } + $hydrations = 0; $events->listen(ContextHydrated::class, static function () use (&$hydrations): void { ++$hydrations; diff --git a/tests/Integration/Database/Sqlite/NullSafeEqualityTest.php b/tests/Integration/Database/Sqlite/NullSafeEqualityTest.php new file mode 100644 index 000000000..8f821a5df --- /dev/null +++ b/tests/Integration/Database/Sqlite/NullSafeEqualityTest.php @@ -0,0 +1,66 @@ +make('config'); + $config->set('database.default', 'null_safe'); + $config->set('database.connections.null_safe', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + public function testJsonBooleansUseNullSafeEqualityInsteadOfTruthiness(): void + { + Schema::create('null_safe_values', function (Blueprint $table): void { + $table->integer('id')->primary(); + $table->jsonb('options'); + }); + + DB::table('null_safe_values')->insert([ + ['id' => 1, 'options' => '{"enabled":true}'], + ['id' => 2, 'options' => '{"enabled":false}'], + ['id' => 3, 'options' => '{"enabled":1}'], + ['id' => 4, 'options' => '{"enabled":0}'], + ['id' => 5, 'options' => '{"enabled":2}'], + ['id' => 6, 'options' => '{"enabled":"hello"}'], + ['id' => 7, 'options' => '{"enabled":null}'], + ['id' => 8, 'options' => '{}'], + ]); + + foreach ([true, false] as $value) { + // SQLite exposes JSON booleans as the integers 1 and 0. + $expected = $value ? [1, 3] : [2, 4]; + + foreach (['options->enabled', new Expression("options->>'enabled'")] as $column) { + $this->assertSame($expected, DB::table('null_safe_values') + ->whereNullSafeEquals($column, $value)->orderBy('id')->pluck('id')->all()); + + $this->assertSame($expected, DB::table('null_safe_values') + ->where($column, '<=>', $value)->orderBy('id')->pluck('id')->all()); + } + } + } +} diff --git a/tests/Integration/Redis/SafeScanIntegrationTest.php b/tests/Integration/Redis/SafeScanIntegrationTest.php index 6e9051b70..4ad0a3b65 100644 --- a/tests/Integration/Redis/SafeScanIntegrationTest.php +++ b/tests/Integration/Redis/SafeScanIntegrationTest.php @@ -34,6 +34,7 @@ public function testSafeScanYieldsKeysWithoutPrefix(bool $prefixScan, bool $retr $redis->set('key1', 'val1'); $redis->set('key2', 'val2'); $redis->set('key3', 'val3'); + $redis->set($prefix . 'key4', 'val4'); // safeScan should yield keys WITHOUT the prefix $keys = $redis->withConnection(function (RedisConnection $connection) use ($prefix, $prefixScan, $retryScan): array { @@ -42,7 +43,7 @@ public function testSafeScanYieldsKeysWithoutPrefix(bool $prefixScan, bool $retr $options = $connection->getOption(PhpRedis::OPT_SCAN); $keys = iterator_to_array($connection->safeScan('key*')); - $this->assertEqualsCanonicalizing($keys, iterator_to_array($connection->safeScan($prefix . 'key*'))); + $this->assertSame([$prefix . 'key4'], iterator_to_array($connection->safeScan($prefix . 'key*'))); $this->assertSame($options, $connection->getOption(PhpRedis::OPT_SCAN)); return $keys; @@ -167,20 +168,23 @@ public function testFlushByPatternReturnsZeroWhenNoKeysMatch() $this->assertSame(0, $deleted); } - public function testFlushByPatternWithPrefixHandlesDoublePrefix() + #[DataProvider('scanPrefixOptions')] + public function testFlushByPatternPreservesOverlappingLogicalPrefixes(bool $prefixScan, bool $retryScan): void { - $prefix = 'flushprefix:'; + $prefix = 'cache:'; $connectionName = $this->createRedisConnectionWithPrefix($prefix); $redis = Redis::connection($connectionName); $redis->flushdb(); - // Create keys via prefixed connection (stored as "flushprefix:cache:1" in Redis) + // The logical cache prefix and the connection prefix both belong in the stored keys. $redis->set('cache:1', 'a'); $redis->set('cache:2', 'b'); $redis->set('other:1', 'c'); - // flushByPattern should handle OPT_PREFIX correctly — no double prefix - $deleted = $redis->withConnection(function (RedisConnection $connection) { + $deleted = $redis->withConnection(function (RedisConnection $connection) use ($prefixScan, $retryScan): int { + $connection->setOption(PhpRedis::OPT_SCAN, $retryScan ? PhpRedis::SCAN_RETRY : PhpRedis::SCAN_NORETRY); + $connection->setOption(PhpRedis::OPT_SCAN, $prefixScan ? PhpRedis::SCAN_PREFIX : PhpRedis::SCAN_NOPREFIX); + return $connection->flushByPattern('cache:*'); }, transform: false); diff --git a/tests/Redis/Operations/SafeScanTest.php b/tests/Redis/Operations/SafeScanTest.php index 1f257d8af..8e2c93747 100644 --- a/tests/Redis/Operations/SafeScanTest.php +++ b/tests/Redis/Operations/SafeScanTest.php @@ -35,7 +35,7 @@ public function testScanRejectsTransformedConnections(): void $connection->shouldTransform(); $this->expectException(InvalidRedisConnectionException::class); - $this->expectExceptionMessage('SafeScan requires a raw Redis connection.'); + $this->expectExceptionMessageIsOrContains('SafeScan requires a raw Redis connection.'); new SafeScan($connection, ''); } @@ -136,25 +136,22 @@ public function testScanIteratesMultipleBatches(): void $this->assertSame(2, $client->getScanCallCount()); } - public function testScanDoesNotDoublePrefixWhenPatternAlreadyHasPrefix(): void + public function testScanPreservesLogicalPatternsStartingWithTheConnectionPrefix(): void { $client = new FakeRedisClient( scanResults: [ - ['keys' => ['myapp:cache:key1'], 'iterator' => 0], + ['keys' => ['myapp:myapp:cache:key1'], 'iterator' => 0], ], optPrefix: 'myapp:', ); $safeScan = new SafeScan($this->createConnection($client), 'myapp:'); - // Pattern already has prefix - should NOT add it again $keys = iterator_to_array($safeScan->execute('myapp:cache:*')); - // Should strip prefix from result - $this->assertSame(['cache:key1'], $keys); + $this->assertSame(['myapp:cache:key1'], $keys); - // Pattern should NOT be double-prefixed - $this->assertSame('myapp:cache:*', $client->getScanCalls()[0]['pattern']); + $this->assertSame('myapp:myapp:cache:*', $client->getScanCalls()[0]['pattern']); } public function testScanReturnsKeyAsIsWhenItDoesNotHavePrefix(): void From b56af4bf5a224028b894fb0399eb83d6adea4760 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:13:35 +0000 Subject: [PATCH 21/22] Test gRPC bootstrap against a real cached HTTP route The route-cache memoization port correctly retains the application boot decision, but the gRPC provider test created a placeholder cache file only after Testbench had booted without cached routes. This caused deterministic failures in both PHP 8.4 and PHP 8.5 CI. Use Testbench defineCacheRoutes() to compile HTTP routes and reload the application before registering the gRPC provider. Preserve all assertions for bootstrap-owned isolated gRPC routes and verify the cached HTTP route still dispatches afterward. Let the existing helper own cache files and cleanup; remove the manual environment mutation and scratch directory. Follow-up to the route-cache detection ports: https://github.com/laravel/framework/pull/57623 and https://github.com/laravel/framework/pull/57687. No framework source behavior changes. Validation: changed class passes 26 tests / 78 assertions. Full composer test:parallel passes 34,814 tests / 125,524 assertions with 2,238 service/platform skips on PHP 8.4. Formatting and git diff --check pass. Self-reviewed and approved by claude-laravel-parity. --- tests/Grpc/GrpcServiceProviderTest.php | 57 +++++++++++--------------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/tests/Grpc/GrpcServiceProviderTest.php b/tests/Grpc/GrpcServiceProviderTest.php index 597d194c4..a3a27ef51 100644 --- a/tests/Grpc/GrpcServiceProviderTest.php +++ b/tests/Grpc/GrpcServiceProviderTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Grpc; -use Hypervel\Filesystem\Filesystem; use Hypervel\Grpc\GrpcServiceProvider; use Hypervel\Grpc\Health\HealthStatusProvider; use Hypervel\Grpc\Health\ServingHealthStatusProvider; @@ -23,7 +22,6 @@ use Hypervel\Server\ServerInterface; use Hypervel\Support\ServiceProvider; use Hypervel\Testbench\TestCase; -use Hypervel\Testing\ParallelTesting; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; @@ -206,36 +204,31 @@ public function testPublishesConfigurationAndCanonicalRoutes(): void public function testLoadsIsolatedRoutesDuringServerBootstrapEvenWhenApplicationRoutesAreCached(): void { - $cacheDirectory = ParallelTesting::tempDir('GrpcServiceProviderTest-route-cache'); - $cachePath = $cacheDirectory . '/routes.php'; - $files = new Filesystem; - $files->deleteDirectory($cacheDirectory); - $files->ensureDirectoryExists($cacheDirectory); - $files->put($cachePath, 'registerEnabledProvider(); - - $this->assertTrue($this->app->routesAreCached()); - - $provider->boot(); - - $this->assertCount(0, $this->app->make(GrpcRouter::class)->getRoutes()->getRoutes()); - - $this->app->make(Server::class)->bootstrapForServer('grpc'); - - $routes = $this->app->make(GrpcRouter::class)->getRoutes()->getRoutes(); - $this->assertCount(3, $routes); - $this->assertSame([ - 'grpc.health.v1.Health/Check', - 'grpc.health.v1.Health/List', - 'grpc.health.v1.Health/Watch', - ], array_map(static fn ($route): string => $route->uri(), $routes)); - } finally { - unset($_SERVER['APP_ROUTES_CACHE']); - $files->deleteDirectory($cacheDirectory); - } + $this->defineCacheRoutes(<<<'PHP' + 'cached HTTP'); +PHP); + + $provider = $this->registerEnabledProvider(); + + $this->assertTrue($this->app->routesAreCached()); + + $provider->boot(); + + $this->assertCount(0, $this->app->make(GrpcRouter::class)->getRoutes()->getRoutes()); + + $this->app->make(Server::class)->bootstrapForServer('grpc'); + + $routes = $this->app->make(GrpcRouter::class)->getRoutes()->getRoutes(); + $this->assertCount(3, $routes); + $this->assertSame([ + 'grpc.health.v1.Health/Check', + 'grpc.health.v1.Health/List', + 'grpc.health.v1.Health/Watch', + ], array_map(static fn ($route): string => $route->uri(), $routes)); + + $this->get('/cached-http')->assertOk()->assertContent('cached HTTP'); } public function testFinalServerConfigurationRejectsAListenerNameAddedByAnotherProvider(): void From da0579d8a449301ebc517217e79c72f45b914b52 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:13:35 +0000 Subject: [PATCH 22/22] Clarify the SafeScan logical pattern parameter contract Describe execute() input as a logical key pattern whose bytes are preserved, including when they match OPT_PREFIX. The connection prefix is added separately. Replace ambiguous wording that could be read as forbidding supported overlapping logical prefixes. Addresses the CodeRabbit outside-diff follow-up on backup PR #35. Documentation only; checked against execute() and the existing overlapping-prefix regression test. Scoped formatting and git diff --check pass. --- src/redis/src/Operations/SafeScan.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/redis/src/Operations/SafeScan.php b/src/redis/src/Operations/SafeScan.php index 1f2d40bc1..9f35b7089 100644 --- a/src/redis/src/Operations/SafeScan.php +++ b/src/redis/src/Operations/SafeScan.php @@ -95,8 +95,8 @@ public function __construct( /** * Execute the scan operation. * - * @param string $pattern The pattern to match (e.g., "cache:users:*"). - * Should NOT include OPT_PREFIX - it will be added automatically. + * @param string $pattern The logical key pattern (e.g., "cache:users:*"). OPT_PREFIX is added automatically; + * the pattern is preserved even when it starts with the same bytes as OPT_PREFIX. * @param int $count The COUNT hint for SCAN (not a limit, just a hint to Redis) * @return Generator yields keys with OPT_PREFIX stripped, safe for use with * other phpredis commands that auto-add the prefix