From d024a0a7af63a5adea68dd4508f4424242dcf411 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:04:28 +0000 Subject: [PATCH 01/31] Require evidence before departing from Laravel API compatibility Define compatibility by supported behavior and require investigation of compatible adaptations and reasonable alternatives before recommending a departure. Resolve uncertainty with focused tests or prototypes, identify concrete costs, and present supporting evidence for owner approval. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index aecf43eeb..b2a0e8f9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -794,7 +794,7 @@ When porting Laravel packages, whether first-party or third-party, keep them as - Not porting deprecated upstream code or backwards-compatibility shims for versions/features Hypervel does not support — Hypervel is a new framework without Laravel's backwards-compatibility burden, so deprecated APIs and compatibility code that exist only to support older versions should be omitted rather than ported. Before changing or removing a deprecated public Laravel API, reach consensus on the recommended approach through a second-opinion loop with your peer agent. Then STOP, explain the proposed difference and its rationale to the owner, and wait for approval. Here, "upstream" means the framework or package being ported, not one of its dependencies — a Symfony deprecation does not make a Laravel API deprecated while Laravel still retains it. If a deprecated upstream surface still contains behavior that Hypervel actively needs, keep the behavior but move it onto the correct non-deprecated Hypervel-owned surface instead of porting the deprecated alias/wrapper as-is. - General performance improvements — reach consensus on the expected benefit, correctness, and complexity tradeoffs through a second-opinion loop with your peer agent before implementing. -Hypervel has no obligation to preserve Hypervel-specific behavior from earlier versions, but supported Laravel APIs—including named arguments and protected extension points—must remain compatible unless the user approves a difference. If a Laravel API is unsuitable for Hypervel or preserving it would make the code worse, reach consensus on the cleanest design through a second-opinion loop with your peer agent. Then STOP, explain the proposed difference and its rationale to the owner, and wait for approval before changing it. +Hypervel has no obligation to preserve Hypervel-specific behavior from earlier versions, but supported Laravel APIs—including named arguments and protected extension points—must remain compatible unless the user approves a difference. Compatibility means preserving supported behavior, not merely matching names and signatures. Before recommending a departure, investigate the simplest compatible adaptation and reasonable alternatives, and establish the concrete obstacle. Resolve uncertainty through focused tests or prototypes; an assumed architectural conflict or peer agreement is not sufficient evidence. If preserving compatibility would compromise correctness, performance or scalability, or require disproportionate complexity, reach consensus on the cleanest design through a second-opinion loop with your peer agent. Then STOP, explain the proposed difference, supporting evidence and rationale to the owner, and wait for approval before changing it. When Laravel or a tracked upstream introduces an equivalent to a Hypervel-specific enhancement, compare their capabilities, APIs, coroutine safety and performance, then reach consensus on how to reconcile them through a second-opinion loop with your peer agent. Normally prefer the upstream API when it meets Hypervel’s requirements. Before deprecating or removing the Hypervel-specific API, STOP, explain the recommendation to the owner, and wait for approval. From 903695b9674cb77f605cf6ba05660572ab6b9fb8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:20:59 +0000 Subject: [PATCH 02/31] Fix touching related models through intermediate relations Port laravel/framework#61942 (3517d4dbb24ae7d49e9dadebe4d9820c3968bd72). Select the related IDs before touching their table, retaining intermediate soft-delete constraints and withoutTouching behavior. Add the upstream through-relation coverage while preserving the existing assertions. Both complete through-relation test files pass. --- .../Relations/HasOneOrManyThrough.php | 33 ++++++++++ ...eEloquentHasManyThroughIntegrationTest.php | 65 +++++++++++++++++++ ...seEloquentHasOneThroughIntegrationTest.php | 13 ++++ 3 files changed, 111 insertions(+) diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index 0f51aca97..f2dd86ac5 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -581,6 +581,39 @@ protected function shouldSelect(array $columns = ['*']): array return array_merge($columns, [$this->getQualifiedFirstKeyName() . ' as hypervel_through_key']); } + /** + * Touch all of the related models for the relationship. + */ + public function touch(): void + { + $model = $this->getRelated(); + + if ($model::isIgnoringTouch()) { + return; + } + + $columns = [ + $model->getUpdatedAtColumn() => $model->freshTimestampString(), + ]; + + if (count($ids = $this->allRelatedIds()) > 0) { + $this->getRelated()->newQueryWithoutRelationships()->whereIn( + $this->related->getQualifiedKeyName(), + $ids + )->update($columns); + } + } + + /** + * Get all of the IDs for the related models. + * + * @return BaseCollection + */ + public function allRelatedIds(): BaseCollection + { + return $this->pluck($this->related->getQualifiedKeyName()); + } + /** * Chunk the results of the query. */ diff --git a/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php b/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php index fb5115a27..2ea641152 100644 --- a/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php +++ b/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php @@ -9,6 +9,7 @@ use Hypervel\Database\Eloquent\Casts\Attribute; use Hypervel\Database\Eloquent\Model as Eloquent; use Hypervel\Database\Eloquent\ModelNotFoundException; +use Hypervel\Database\Eloquent\Relations\HasManyThrough; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Schema\Builder; use Hypervel\Support\Collection; @@ -578,6 +579,55 @@ public function testEagerLoadingLoadsRelatedModelsCorrectly(): void $this->assertCount(2, $country->posts); } + public function testTouch(): void + { + $this->seedData(); + $country = Country::first(); + + Post::query()->update(['updated_at' => '2000-01-01 00:00:00']); + $this->assertSame('2000-01-01 00:00:00', Post::first()->updated_at->format('Y-m-d H:i:s')); + + $country->posts()->touch(); + + $this->assertNotSame('2000-01-01 00:00:00', Post::first()->fresh()->updated_at->format('Y-m-d H:i:s')); + } + + public function testTouchRespectsIntermediateSoftDeletes(): void + { + $this->seedData(); + $user = SoftDeletesUser::first(); + $user->delete(); + + Post::query()->update(['updated_at' => '2000-01-01 00:00:00']); + + $country = CountrySoftDeletesIntermediate::first(); + $country->posts()->touch(); + + $this->assertSame('2000-01-01 00:00:00', Post::first()->fresh()->updated_at->format('Y-m-d H:i:s')); + } + + public function testTouchRespectsWithoutTouching(): void + { + $this->seedData(); + $country = Country::first(); + + Post::query()->update(['updated_at' => '2000-01-01 00:00:00']); + + Eloquent::withoutTouching(function () use ($country) { + $country->posts()->touch(); + }); + + $this->assertSame('2000-01-01 00:00:00', Post::first()->fresh()->updated_at->format('Y-m-d H:i:s')); + } + + public function testAllRelatedIds(): void + { + $this->seedData(); + $country = Country::first(); + + $this->assertEquals([1, 2], $country->posts()->allRelatedIds()->all()); + } + public function testEagerLoadingDoesNotReadTheEmptyFarParentKey(): void { $this->seedData(); @@ -854,3 +904,18 @@ public function users() return $this->hasMany(SoftDeletesUser::class, 'country_id'); } } + +class CountrySoftDeletesIntermediate extends Eloquent +{ + protected ?string $table = 'countries'; + + protected array $guarded = []; + + /** + * Get the posts through soft-deletable users. + */ + public function posts(): HasManyThrough + { + return $this->hasManyThrough(SoftDeletesPost::class, SoftDeletesUser::class, 'country_id', 'user_id'); + } +} diff --git a/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php b/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php index 0f193f2a8..892af66a8 100644 --- a/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php +++ b/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php @@ -302,6 +302,19 @@ public function testEagerLoadingLoadsRelatedModelsCorrectly(): void $this->assertSame('A title', $position->contract->title); } + public function testTouch(): void + { + $this->seedData(); + $position = Position::first(); + + Contract::query()->update(['updated_at' => '2000-01-01 00:00:00']); + $this->assertSame('2000-01-01 00:00:00', Contract::first()->updated_at->format('Y-m-d H:i:s')); + + $position->contract()->touch(); + + $this->assertNotSame('2000-01-01 00:00:00', Contract::first()->fresh()->updated_at->format('Y-m-d H:i:s')); + } + /** * Helpers... */ From a30aa911da374c675093c3210224cb0378051ce2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:20:59 +0000 Subject: [PATCH 03/31] Batch Redis bulk queue payloads and notifications together Port laravel/framework#61965 (3496cdf7008a6ff2657c5540ad38cddce26ae599). Flush bounded payload and notification buffers together, including before delayed writes. Preserve mixed-job ordering, exact completion counts and preceding successful writes when a later Redis operation fails. The Redis integration file and queue, outbox and Horizon consumers pass. Coverage includes empty, boundary and larger batches, mixed delayed jobs and partial failure. --- src/queue/src/LuaScripts.php | 28 ++++++-- .../Queue/Redis/RedisQueueTest.php | 65 +++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/queue/src/LuaScripts.php b/src/queue/src/LuaScripts.php index 95a65a79e..a282f6777 100644 --- a/src/queue/src/LuaScripts.php +++ b/src/queue/src/LuaScripts.php @@ -68,18 +68,38 @@ public static function bulk(): string { return <<<'LUA' local stored = 0 +local buffered = 0 +local payloads = {} +local notifications = {} + +local function flush() + if buffered > 0 then + redis.call('rpush', KEYS[1], unpack(payloads, 1, buffered)) + redis.call('rpush', KEYS[2], unpack(notifications, 1, buffered)) + stored = stored + buffered + buffered = 0 + end +end for i = 1, #ARGV, 2 do if ARGV[i] == 'i' then - redis.call('rpush', KEYS[1], ARGV[i + 1]) - redis.call('rpush', KEYS[2], 1) + buffered = buffered + 1 + payloads[buffered] = ARGV[i + 1] + notifications[buffered] = 1 + + if buffered == 100 then + flush() + end else + -- Preserve earlier jobs and their notifications if a later write fails. + flush() redis.call('zadd', KEYS[3], ARGV[i], ARGV[i + 1]) + stored = stored + 1 end - - stored = stored + 1 end +flush() + return stored LUA; } diff --git a/tests/Integration/Queue/Redis/RedisQueueTest.php b/tests/Integration/Queue/Redis/RedisQueueTest.php index a59549e93..ad004d5c6 100644 --- a/tests/Integration/Queue/Redis/RedisQueueTest.php +++ b/tests/Integration/Queue/Redis/RedisQueueTest.php @@ -15,6 +15,7 @@ use Hypervel\Queue\InvalidPayloadException; use Hypervel\Queue\Jobs\InspectedJob; use Hypervel\Queue\Jobs\RedisJob; +use Hypervel\Queue\LuaScripts; use Hypervel\Queue\RedisQueue; use Hypervel\Redis\Exceptions\LuaScriptException; use Hypervel\Redis\RedisConnection; @@ -598,6 +599,33 @@ public function testBulkScriptFailureLeavesEarlierWritesAndStopsLaterWrites(): v $this->assertSame(0, $this->redisConnection()->llen("{$redisKey}:notify")); } + public function testBulkDelayedFailurePreservesEarlierJobsAndNotifications(): void + { + $default = $this->defaultQueueName(); + $this->setQueue($default); + $redisKey = $this->getQueueRedisKey($default); + $this->redisConnection()->set("{$redisKey}:delayed", 'not-a-sorted-set'); + $caught = null; + + try { + $this->queue->bulk([ + new RedisQueueIntegrationTestJob(1), + new RedisQueueIntegrationTestJob(2), + new RedisQueueIntegrationDelayedJob(3, 60), + new RedisQueueIntegrationTestJob(4), + ]); + } catch (LuaScriptException $caught) { + } + + $this->assertInstanceOf(LuaScriptException::class, $caught); + $immediate = $this->redisConnection()->lrange($redisKey, 0, -1); + $this->assertSame([1, 2], array_map( + static fn (string $payload): int => unserialize(json_decode($payload)->data->command)->i, + $immediate, + )); + $this->assertSame(2, $this->redisConnection()->llen("{$redisKey}:notify")); + } + public function testDelayedJobsWorkWithPhpRedisSerializationEnabled(): void { $connection = Redis::connection('default'); @@ -828,6 +856,43 @@ public function testBulkPushesManyJobsOntoQueue(): void $this->assertSame(1050, $this->redisConnection()->llen("{$redisKey}:notify")); } + #[DataProvider('bulkPushNotificationCountProvider')] + public function testBulkPushScriptCreatesOneNotificationPerJob(int $count): void + { + $connection = $this->redisConnection(); + $key = "queues:{bulk-script-{$count}}"; + $payloads = []; + $arguments = []; + + for ($i = 0; $i < $count; ++$i) { + $payloads[] = "job-{$i}"; + $arguments[] = 'i'; + $arguments[] = "job-{$i}"; + } + + $stored = $connection->eval(LuaScripts::bulk(), 3, $key, "{$key}:notify", "{$key}:delayed", ...$arguments); + + $this->assertSame($count, $stored); + $this->assertSame($payloads, $connection->lrange($key, 0, -1)); + $this->assertSame(array_fill(0, $count, '1'), $connection->lrange("{$key}:notify", 0, -1)); + } + + /** + * Provide bulk sizes around the notification chunk boundary. + */ + public static function bulkPushNotificationCountProvider(): array + { + return [ + 'empty' => [0], + 'single' => [1], + 'before chunk boundary' => [99], + 'at chunk boundary' => [100], + 'after chunk boundary' => [101], + 'multiple chunks' => [250], + 'full bulk' => [1000], + ]; + } + public function testAllQueueNamesStripsClusterBraces(): void { $default = $this->defaultQueueName(); From d6434c7759ba4de89b1dd16de22a04138e992d4e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:00 +0000 Subject: [PATCH 04/31] Reconcile scheduled callback exception regression coverage Port the regression from laravel/framework#61973 (1adc68b20f647bd470f9e67b801c0e84268ad114). The existing coroutine-local invocation state already prevents an earlier callback exception from escaping a later successful run. Move overlapping coverage into the upstream integration test, retaining exact failure identity and the recovered return value. Both complete callback test files pass. --- .../Console/Scheduling/CallbackEventTest.php | 21 ------------ .../Console/Scheduling/CallbackEventTest.php | 33 +++++++++++++++++++ 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/tests/Console/Scheduling/CallbackEventTest.php b/tests/Console/Scheduling/CallbackEventTest.php index 368ae3ad8..22ca18fe4 100644 --- a/tests/Console/Scheduling/CallbackEventTest.php +++ b/tests/Console/Scheduling/CallbackEventTest.php @@ -222,27 +222,6 @@ public function testExecuteCapturesExceptionAndRethrowsOnRun(): void $event->run($this->app); } - public function testSuccessfulRunDoesNotRethrowExceptionFromPreviousRun(): void - { - $calls = 0; - $event = new CallbackEvent($this->mutex, function () use (&$calls): string { - if (++$calls === 1) { - throw new RuntimeException('first run failed'); - } - - return 'recovered'; - }); - - try { - $event->run($this->app); - $this->fail('The first callback exception was not thrown.'); - } catch (RuntimeException $exception) { - $this->assertSame('first run failed', $exception->getMessage()); - } - - $this->assertSame('recovered', $event->run($this->app)); - } - public function testCallbackFailureRemainsPrimaryWhenAfterCallbackFails(): void { $event = new CallbackEvent($this->mutex, fn () => throw new RuntimeException('callback failed')); diff --git a/tests/Integration/Console/Scheduling/CallbackEventTest.php b/tests/Integration/Console/Scheduling/CallbackEventTest.php index e18833941..ff32a99d0 100644 --- a/tests/Integration/Console/Scheduling/CallbackEventTest.php +++ b/tests/Integration/Console/Scheduling/CallbackEventTest.php @@ -89,6 +89,39 @@ public function testExceptionBubbles(): void $event->run($this->app); } + public function testExceptionIsNotRethrownOnSubsequentRuns(): void + { + $runs = 0; + $success = null; + $failure = new Exception('First run failed.'); + + $event = (new CallbackEvent($this->mutex, function () use (&$runs, $failure): string { + if (++$runs === 1) { + throw $failure; + } + + return 'recovered'; + }))->onSuccess(function () use (&$success): void { + $success = true; + })->onFailure(function () use (&$success): void { + $success = false; + }); + + try { + $event->run($this->app); + $this->fail('The first callback exception was not thrown.'); + } catch (Exception $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertFalse($success); + + $this->assertSame('recovered', $event->run($this->app)); + + $this->assertSame(2, $runs); + $this->assertTrue($success); + } + public function testOnSuccessCallbackCanReceiveEvent(): void { $callbackEvent = null; From 494692163c4f8e80fd9ea7a742d8db20d31a2082 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:00 +0000 Subject: [PATCH 05/31] Cover unique queue locks after rollback and failed dispatch Reconcile laravel/framework#61974 (88aadc10c5f9ad54aa3c8884572411a3eb5f9ce6), #61985 (3c775493cf679a6cddd813d4a6996ac15233deb4) and #62010 (8a58e4696cab87eb0be42bf58cb4d45bceafe8f8). Add the upstream rollback and failed-push regressions against the existing dispatch-lock ownership cleanup. Verify that a rolled-back queued listener can be dispatched again and a failed queue push releases its unique lock. The complete UniqueJobTest passes with the database queue. --- tests/Integration/Queue/UniqueJobTest.php | 69 +++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php index d730bb70d..c595a5637 100644 --- a/tests/Integration/Queue/UniqueJobTest.php +++ b/tests/Integration/Queue/UniqueJobTest.php @@ -14,14 +14,18 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Cache\Repository as Cache; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Contracts\Queue\ShouldBeUnique; use Hypervel\Contracts\Queue\ShouldBeUniqueUntilProcessing; use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Foundation\Auth\User; use Hypervel\Foundation\Bus\Dispatchable; +use Hypervel\Queue\Connectors\ConnectorInterface; use Hypervel\Queue\Events\UniqueJobSkipped; use Hypervel\Queue\InteractsWithQueue; +use Hypervel\Queue\NullQueue; use Hypervel\Queue\QueueManager; use Hypervel\Queue\SerializesModels; use Hypervel\Support\Facades\Bus; @@ -35,6 +39,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; +use UnitEnum; #[WithMigration] #[WithMigration('cache')] @@ -464,6 +469,60 @@ public function testRolledBackPushDoesNotReleaseAnotherDispatchesUniqueLock(): v $this->assertFalse($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); } + public function testRolledBackQueuedListenerReleasesItsUniqueLock(): void + { + $this->markTestSkippedWhenUsingSyncQueueDriver(); + + config()->set('cache.default', 'array'); + + Event::listen(UniqueTestEvent::class, UniqueTestAfterCommitListener::class); + + $size = Queue::size(); + + try { + DB::transaction(function (): never { + event(new UniqueTestEvent); + + throw new Exception('Rollback.'); + }); + } catch (Exception) { + } + + event(new UniqueTestEvent); + + $this->assertSame($size + 1, Queue::size()); + } + + public function testLockIsReleasedWhenPushingToTheQueueFails(): void + { + Queue::extend('failing', fn () => new class implements ConnectorInterface { + /** + * Establish a queue connection. + */ + public function connect(array $config): QueueContract + { + return new class extends NullQueue { + /** + * Push a new job onto the queue. + */ + public function push(object|string $job, mixed $data = '', UnitEnum|string|null $queue = null): never + { + throw new RuntimeException('Queue unavailable.'); + } + }; + } + }); + + config()->set('queue.connections.failing', ['driver' => 'failing']); + + try { + dispatch(new UniqueTestJob)->onConnection('failing'); + } catch (RuntimeException) { + } + + $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey(UniqueTestJob::class), 10)->get()); + } + /** * Get the unique lock key for the given job. */ @@ -719,6 +778,16 @@ class UniqueTestEvent { } +class UniqueTestAfterCommitListener implements ShouldQueueAfterCommit, ShouldBeUnique +{ + /** + * Handle the event. + */ + public function handle(UniqueTestEvent $event): void + { + } +} + class UniqueTestListener implements ShouldQueue, ShouldBeUnique { /** From a842bac1452d390f52e538686b55d47d34ca29b8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:00 +0000 Subject: [PATCH 06/31] Support broadcast uniqueness attributes and synchronous Mercure Port laravel/framework#61981 (14e4c05951fe99ffb0863d13885255de685df7c3) and #61977 (84744cdc2f91310259f659346a20fd88de84854d), plus laravel/docs#11407 (ff6cddfc8f771c98bd39c455bb4e15cdd5322cae). Read UniqueFor through the existing attribute resolver, preserving method precedence and zero values. Default Mercure delivery to synchronous operation and expose the async option in shipped configuration. Normalize integer and backed-enum connection names without losing the zero name. Keep the existing HTTP Mercure transport and omit the unsupported FrankenPHP integration. Update synchronous delivery and uniqueness documentation. Both complete broadcasting test files pass. --- skeletons/hypervel/config/broadcasting.php | 1 + .../react-starter-kit/config/broadcasting.php | 1 + src/broadcasting/src/BroadcastManager.php | 1 + .../src/Mercure/CreatesMercureDrivers.php | 25 ++++ src/broadcasting/src/UniqueBroadcastEvent.php | 5 +- src/docs/broadcasting.md | 13 +- src/foundation/config/broadcasting.php | 1 + tests/Broadcasting/BroadcastEventTest.php | 55 +++++++ .../Broadcasting/BroadcastManagerTest.php | 134 ++++++++++++++++++ 9 files changed, 233 insertions(+), 3 deletions(-) diff --git a/skeletons/hypervel/config/broadcasting.php b/skeletons/hypervel/config/broadcasting.php index 3fce66121..3b0c33555 100644 --- a/skeletons/hypervel/config/broadcasting.php +++ b/skeletons/hypervel/config/broadcasting.php @@ -87,6 +87,7 @@ ], 'cookie_name' => env('MERCURE_COOKIE_NAME'), 'subscribe_expiration' => (float) env('MERCURE_SUBSCRIBE_EXPIRATION', 5), + 'async' => (bool) env('MERCURE_ASYNC', false), ], 'redis' => [ diff --git a/skeletons/react-starter-kit/config/broadcasting.php b/skeletons/react-starter-kit/config/broadcasting.php index 3fce66121..3b0c33555 100644 --- a/skeletons/react-starter-kit/config/broadcasting.php +++ b/skeletons/react-starter-kit/config/broadcasting.php @@ -87,6 +87,7 @@ ], 'cookie_name' => env('MERCURE_COOKIE_NAME'), 'subscribe_expiration' => (float) env('MERCURE_SUBSCRIBE_EXPIRATION', 5), + 'async' => (bool) env('MERCURE_ASYNC', false), ], 'redis' => [ diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index 1fddab18a..ade6757b6 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -201,6 +201,7 @@ public function queue(mixed $event): void { if ($event instanceof ShouldBroadcastNow || (is_object($event) && method_exists($event, 'shouldBroadcastNow') && $event->shouldBroadcastNow()) + || $this->broadcastsToSynchronousMercureConnections($event) ) { $dispatch = fn () => $this->app->make(Dispatcher::class)->dispatchNow( new BroadcastEvent($event instanceof UnitEnum ? $event : clone $event) diff --git a/src/broadcasting/src/Mercure/CreatesMercureDrivers.php b/src/broadcasting/src/Mercure/CreatesMercureDrivers.php index 6bdc9fd49..9d0264eb0 100644 --- a/src/broadcasting/src/Mercure/CreatesMercureDrivers.php +++ b/src/broadcasting/src/Mercure/CreatesMercureDrivers.php @@ -18,6 +18,8 @@ use Symfony\Component\Mercure\Jwt\WebTokenFactory; use Symfony\Contracts\HttpClient\HttpClientInterface; +use function Hypervel\Support\enum_value; + trait CreatesMercureDrivers { protected const string DEFAULT_MERCURE_ALGORITHM = 'HS256'; @@ -105,6 +107,29 @@ public function mercure(array $config): HubInterface // REMOVED: frankenPhpMercure() calls FrankenPHP's in-process hub; Hypervel uses Swoole and publishes over HTTP. + /** + * Determine if the given event only targets Mercure connections publishing synchronously. + */ + protected function broadcastsToSynchronousMercureConnections(mixed $event): bool + { + $connections = method_exists($event, 'broadcastConnections') + ? $event->broadcastConnections() + : [null]; + + foreach ($connections as $connection) { + $connection = enum_value($connection); + $config = $this->getConfig($connection === null || $connection === '' + ? $this->getDefaultDriver() + : (string) $connection); + + if (($config['driver'] ?? null) !== 'mercure' || ! empty($config['async'])) { + return false; + } + } + + return $connections !== []; + } + /** * Identify the shared HTTP-client pool for construction and invalidation. */ diff --git a/src/broadcasting/src/UniqueBroadcastEvent.php b/src/broadcasting/src/UniqueBroadcastEvent.php index aeead47a8..c4f81799d 100644 --- a/src/broadcasting/src/UniqueBroadcastEvent.php +++ b/src/broadcasting/src/UniqueBroadcastEvent.php @@ -7,6 +7,7 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Cache\Repository; use Hypervel\Contracts\Queue\ShouldBeUnique; +use Hypervel\Queue\Attributes\UniqueFor; class UniqueBroadcastEvent extends BroadcastEvent implements ShouldBeUnique { @@ -33,8 +34,8 @@ public function __construct(mixed $event) if (method_exists($event, 'uniqueFor')) { $this->uniqueFor = $event->uniqueFor(); - } elseif (property_exists($event, 'uniqueFor')) { - $this->uniqueFor = $event->uniqueFor; + } else { + $this->uniqueFor = $this->getAttributeValue($event, UniqueFor::class, 'uniqueFor', $this->uniqueFor); } parent::__construct($event); diff --git a/src/docs/broadcasting.md b/src/docs/broadcasting.md index 01a430d1e..800518110 100644 --- a/src/docs/broadcasting.md +++ b/src/docs/broadcasting.md @@ -93,7 +93,7 @@ All of your application's event broadcasting configuration is stored in the `con Once you have enabled event broadcasting, you're ready to learn more about [defining broadcast events](#defining-broadcast-events) and [listening for events](#listening-for-events). If you're using Hypervel's React [starter kit](/docs/{{version}}/starter-kits), you may listen for events using Echo's [useEcho hook](#using-react-or-vue). > [!NOTE] -> Before broadcasting any events, you should first configure and run a [queue worker](/docs/{{version}}/queues). All event broadcasting is done via queued jobs so that the response time of your application is not seriously affected by events being broadcast. +> Before broadcasting any events, you should first configure and run a [queue worker](/docs/{{version}}/queues). All event broadcasting is done via queued jobs so that the response time of your application is not seriously affected by events being broadcast. The Mercure driver is an exception, as it [broadcasts events synchronously](#mercure-synchronous-broadcasting) by default. ## Server Side Installation @@ -281,6 +281,15 @@ Mercure publishing uses pooled HTTP clients so concurrent coroutines do not shar Finally, you are ready to install and configure [Laravel Echo](#client-side-installation), which will receive the broadcast events on the client-side. + +#### Synchronous Broadcasting + +Unlike other drivers, the Mercure driver broadcasts events immediately instead of queueing them, so it doesn't require a queue worker. Publishing an update is fast, since the hub dispatches it to subscribers asynchronously. To broadcast events using your [queue](#broadcast-queue) instead, set the `MERCURE_ASYNC` environment variable to `true`: + +```ini +MERCURE_ASYNC=true +``` + ## Client Side Installation @@ -946,6 +955,8 @@ public function broadcastQueue(): string If you would like all of your broadcast events to use the same queue without customizing each event class, you may [route the `ShouldBroadcast` contract to a queue](/docs/{{version}}/queues#queue-routing) instead. +Broadcast events implementing `Hypervel\Contracts\Queue\ShouldBeUnique` also support the `Hypervel\Queue\Attributes\UniqueFor` attribute to configure the lock's lifetime in seconds, just like [unique jobs](/docs/{{version}}/queues#unique-jobs). + If you would like to broadcast your event using the `sync` queue instead of the default queue driver, you can implement the `ShouldBroadcastNow` interface instead of `ShouldBroadcast`: ```php diff --git a/src/foundation/config/broadcasting.php b/src/foundation/config/broadcasting.php index 3fce66121..3b0c33555 100644 --- a/src/foundation/config/broadcasting.php +++ b/src/foundation/config/broadcasting.php @@ -87,6 +87,7 @@ ], 'cookie_name' => env('MERCURE_COOKIE_NAME'), 'subscribe_expiration' => (float) env('MERCURE_SUBSCRIBE_EXPIRATION', 5), + 'async' => (bool) env('MERCURE_ASYNC', false), ], 'redis' => [ diff --git a/tests/Broadcasting/BroadcastEventTest.php b/tests/Broadcasting/BroadcastEventTest.php index e41a773ec..d30274e23 100644 --- a/tests/Broadcasting/BroadcastEventTest.php +++ b/tests/Broadcasting/BroadcastEventTest.php @@ -7,11 +7,13 @@ use Exception; use Hypervel\Broadcasting\BroadcastEvent; use Hypervel\Broadcasting\InteractsWithBroadcasting; +use Hypervel\Broadcasting\UniqueBroadcastEvent; use Hypervel\Bus\Queueable; use Hypervel\Contracts\Broadcasting\Broadcaster; use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactory; use Hypervel\Contracts\Broadcasting\ShouldBroadcast; use Hypervel\Queue\Attributes\Backoff; +use Hypervel\Queue\Attributes\UniqueFor; use Hypervel\Support\Collection; use Hypervel\Tests\TestCase; use Mockery as m; @@ -19,6 +21,34 @@ class BroadcastEventTest extends TestCase { + public function testUniqueBroadcastEventReadsUniqueForAttribute(): void + { + $job = new UniqueBroadcastEvent(new TestBroadcastEventWithUniqueForAttribute); + + $this->assertSame(60, $job->uniqueFor); + } + + public function testUniqueBroadcastEventReadsInheritedUniqueForAttribute(): void + { + $job = new UniqueBroadcastEvent(new TestBroadcastEventWithInheritedUniqueFor); + + $this->assertSame(60, $job->uniqueFor); + } + + public function testUniqueBroadcastEventUniqueForMethodOverridesAttribute(): void + { + $job = new UniqueBroadcastEvent(new TestBroadcastEventWithUniqueForMethod); + + $this->assertSame(120, $job->uniqueFor); + } + + public function testUniqueBroadcastEventChildPropertyOverridesInheritedAttribute(): void + { + $job = new UniqueBroadcastEvent(new TestBroadcastEventWithUniqueForProperty); + + $this->assertSame(180, $job->uniqueFor); + } + public function testBasicEventBroadcastParameterFormatting(): void { $broadcaster = m::mock(Broadcaster::class); @@ -270,6 +300,31 @@ protected function assertEventBroadcastsAs(object $event, string $name): void } } +#[UniqueFor(60)] +class TestBroadcastEventWithUniqueForAttribute extends TestBroadcastEvent +{ +} + +class TestBroadcastEventWithInheritedUniqueFor extends TestBroadcastEventWithUniqueForAttribute +{ +} + +class TestBroadcastEventWithUniqueForMethod extends TestBroadcastEventWithUniqueForAttribute +{ + /** + * Get the uniqueness lifetime. + */ + public function uniqueFor(): int + { + return 120; + } +} + +class TestBroadcastEventWithUniqueForProperty extends TestBroadcastEventWithUniqueForAttribute +{ + public int $uniqueFor = 180; +} + class TestBroadcastEvent { public string $firstName = 'Taylor'; diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php index 37b0c3146..54e1824e7 100644 --- a/tests/Integration/Broadcasting/BroadcastManagerTest.php +++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php @@ -41,6 +41,7 @@ use Hypervel\Foundation\Http\Middleware\PreventRequestForgery; use Hypervel\Http\Request; use Hypervel\ObjectPool\PoolManager; +use Hypervel\Queue\Attributes\UniqueFor; use Hypervel\Redis\RedisProxy; use Hypervel\Routing\Route; use Hypervel\Routing\RouteCollection; @@ -296,6 +297,33 @@ public function testUniqueEventsCanBeBroadcastWithUniqueIdFromMethod(): void $this->assertFalse($this->app->get(Cache::class)->lock($lockKey, 10)->get()); } + public function testUniqueEventsUseInheritedUniqueForAttribute(): void + { + Queue::fake(); + + Broadcast::queue(new TestEventUniqueWithInheritedUniqueFor); + Broadcast::queue(new TestEventUniqueWithInheritedUniqueFor); + + Queue::assertPushed(UniqueBroadcastEvent::class, 1); + + $this->travel(60)->seconds(); + + Broadcast::queue(new TestEventUniqueWithInheritedUniqueFor); + + Queue::assertPushed(UniqueBroadcastEvent::class, 2); + } + + public function testUniqueForAttributeDoesNotMakeEventsUniqueWithoutTheContract(): void + { + Queue::fake(); + + Broadcast::queue(new TestEventWithUniqueForAttribute); + Broadcast::queue(new TestEventWithUniqueForAttribute); + + Queue::assertPushed(BroadcastEvent::class, 2); + Queue::assertNotPushed(UniqueBroadcastEvent::class); + } + public function testUniqueEventLockIsReleasedWhenQueueResolutionFails(): void { $cache = new CacheRepository(new ArrayStore); @@ -940,6 +968,7 @@ public function testPurgeAcceptsBackedEnum(): void $this->assertNotSame($instance1, $instance2); } + // REMOVED: FrankenPHP's in-process hub may resolve without a URL; Hypervel always publishes over HTTP. public function testMercureRequiresAUrl(): void { $this->expectException(InvalidArgumentException::class); @@ -948,6 +977,91 @@ public function testMercureRequiresAUrl(): void (new BroadcastManager($this->getApp([])))->mercure(['secret' => str_repeat('s', 32)]); } + public function testAsyncMercureRequiresAUrl(): void + { + $manager = new BroadcastManager($this->getApp([ + 'broadcasting' => ['connections' => ['mercure' => ['driver' => 'mercure', 'secret' => str_repeat('s', 32), 'async' => true]]], + ])); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageIsOrContains('"url"'); + + $manager->connection('mercure'); + } + + public function testMercureEventsAreBroadcastNow(): void + { + Bus::fake(); + Queue::fake(); + + config([ + 'broadcasting.default' => 'mercure', + 'broadcasting.connections.mercure' => $this->mercureConfig(['driver' => 'mercure']), + ]); + + Broadcast::queue(new TestEvent); + + Bus::assertDispatched(BroadcastEvent::class); + Queue::assertNotPushed(BroadcastEvent::class); + } + + public function testMercureConnectionSelectionPreservesZeroValuedEnums(): void + { + Bus::fake(); + Queue::fake(); + + config([ + 'broadcasting.default' => 'log', + 'broadcasting.connections.0' => $this->mercureConfig(['driver' => 'mercure']), + ]); + + Broadcast::queue(new class extends TestEvent { + /** + * Get the connections used to broadcast the event. + */ + public function broadcastConnections(): array + { + return [BroadcastIntegerIdentifier::Zero]; + } + }); + + Bus::assertDispatched(BroadcastEvent::class); + Queue::assertNotPushed(BroadcastEvent::class); + } + + public function testAsyncMercureEventsAreQueued(): void + { + Bus::fake(); + Queue::fake(); + + config([ + 'broadcasting.default' => 'mercure', + 'broadcasting.connections.mercure' => $this->mercureConfig(['driver' => 'mercure', 'async' => true]), + ]); + + Broadcast::queue(new TestEvent); + + Bus::assertNotDispatched(BroadcastEvent::class); + Queue::assertPushed(BroadcastEvent::class); + } + + public function testEventsBroadcastToMercureAndOtherConnectionsAreQueued(): void + { + Bus::fake(); + Queue::fake(); + + config([ + 'broadcasting.default' => 'mercure', + 'broadcasting.connections.mercure' => $this->mercureConfig(['driver' => 'mercure']), + 'broadcasting.connections.log' => ['driver' => 'log'], + ]); + + Broadcast::queue(new TestEventOnMercureAndLog); + + Bus::assertNotDispatched(BroadcastEvent::class); + Queue::assertPushed(BroadcastEvent::class); + } + public function testMercureRequiresASecret(): void { $this->expectException(InvalidArgumentException::class); @@ -1402,6 +1516,17 @@ public function broadcastOn(): array } } +class TestEventOnMercureAndLog extends TestEvent +{ + /** + * Get the connections used to broadcast the event. + */ + public function broadcastConnections(): array + { + return ['mercure', 'log']; + } +} + class TestEventNow implements ShouldBroadcastNow { /** @@ -1485,6 +1610,15 @@ public function uniqueId(): string } } +#[UniqueFor(60)] +class TestEventWithUniqueForAttribute extends TestEvent +{ +} + +class TestEventUniqueWithInheritedUniqueFor extends TestEventWithUniqueForAttribute implements ShouldBeUnique +{ +} + class TestEventUniqueWithNullableCache extends TestEventUnique { public function uniqueVia(): ?Cache From 1040ef6e0ecf6203a21d3e15040afe1caf95843a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:00 +0000 Subject: [PATCH 07/31] Preserve fractional CarbonInterval process timeouts Port laravel/framework#61979 (631f8a6e68e2c44c16d7e36387ddec89b9810e99). Keep fractional totalSeconds values for process and idle timeouts instead of truncating them to integers. Widen the stored timeout types accordingly and retain the upstream subsecond regressions. Update the concurrency assertion to the actual float supplied by CarbonInterval without weakening its strict comparison. Complete Process and Concurrency tests pass, with the established Swoole STDIN skip. --- src/process/src/PendingProcess.php | 8 +++--- tests/Concurrency/ConcurrencyTest.php | 2 +- tests/Process/ProcessTest.php | 36 +++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/process/src/PendingProcess.php b/src/process/src/PendingProcess.php index ae5e6e8cf..d65668e51 100644 --- a/src/process/src/PendingProcess.php +++ b/src/process/src/PendingProcess.php @@ -48,12 +48,12 @@ class PendingProcess /** * The maximum number of seconds the process may run. */ - public ?int $timeout = 60; + public int|float|null $timeout = 60; /** * The maximum number of seconds the process may go without returning output. */ - public ?int $idleTimeout = null; + public int|float|null $idleTimeout = null; /** * The additional environment variables for the process. @@ -126,7 +126,7 @@ public function path(string $path): static */ public function timeout(CarbonInterval|int $timeout): static { - $this->timeout = $timeout instanceof CarbonInterval ? (int) $timeout->totalSeconds : $timeout; + $this->timeout = $timeout instanceof CarbonInterval ? $timeout->totalSeconds : $timeout; return $this; } @@ -136,7 +136,7 @@ public function timeout(CarbonInterval|int $timeout): static */ public function idleTimeout(CarbonInterval|int $timeout): static { - $this->idleTimeout = $timeout instanceof CarbonInterval ? (int) $timeout->totalSeconds : $timeout; + $this->idleTimeout = $timeout instanceof CarbonInterval ? $timeout->totalSeconds : $timeout; return $this; } diff --git a/tests/Concurrency/ConcurrencyTest.php b/tests/Concurrency/ConcurrencyTest.php index fdb3b275c..f1de478dd 100644 --- a/tests/Concurrency/ConcurrencyTest.php +++ b/tests/Concurrency/ConcurrencyTest.php @@ -809,7 +809,7 @@ public function testProcessDriverAppliesCustomTimeouts(): void timeout: CarbonInterval::seconds(120), )); - $factory->assertRan(fn (PendingProcess $process): bool => $process->timeout === 120); + $factory->assertRan(fn (PendingProcess $process): bool => $process->timeout === 120.0); } public function testCoroutineAndSyncDriversAcceptProcessOnlyTimeouts(): void diff --git a/tests/Process/ProcessTest.php b/tests/Process/ProcessTest.php index d7b446167..9bcf12473 100644 --- a/tests/Process/ProcessTest.php +++ b/tests/Process/ProcessTest.php @@ -1265,6 +1265,42 @@ public function testATimeoutCanBeSetWithACarbonInterval(): void $result->throw(); } + #[RequiresOperatingSystem('Linux|Darwin')] + public function testASubSecondTimeoutCanBeSetWithACarbonInterval(): void + { + if (! env('RUN_BLOCKING_TESTS', false)) { + $this->markTestSkipped('Skip blocking tests'); + } + + $factory = new Factory; + + try { + $factory->timeout(CarbonInterval::milliseconds(500))->path(__DIR__)->run('sleep 2;'); + + $this->fail('The process did not time out.'); + } catch (ProcessTimedOutException $e) { + $this->assertSame(0.5, $e->exceededTimeout()); + } + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testASubSecondIdleTimeoutCanBeSetWithACarbonInterval(): void + { + if (! env('RUN_BLOCKING_TESTS', false)) { + $this->markTestSkipped('Skip blocking tests'); + } + + $factory = new Factory; + + try { + $factory->idleTimeout(CarbonInterval::milliseconds(500))->path(__DIR__)->run('sleep 2;'); + + $this->fail('The process did not time out.'); + } catch (ProcessIdleTimedOutException $e) { + $this->assertSame(0.5, $e->exceededTimeout()); + } + } + #[RequiresOperatingSystem('Linux|Darwin')] public function testGeneralTimeoutsThrowTheBaseException(): void { From 1e7d32f3401215416e2791127ce0ea95e4e008e5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:00 +0000 Subject: [PATCH 08/31] Keep reconnect recursion guards active for leased drivers Port laravel/framework#61995 (686f6bf4d1d62e509f9b419415c8c1c91922351c). Guard reconnect attempts on the logical connection and reset the guard in finally. Resolve and announce non-PDO replacement leases inside reconnect so event callbacks run under the same guard as PDO connections. Regression tests demonstrate the recursive reconnect before the fix and preserve the original failure. Complete PDO, driver-neutral connection and lease/pool/manager tests pass. --- src/database/src/Connection.php | 16 ++++++- src/database/src/Pool/ConnectionLease.php | 3 ++ .../DatabaseDriverConnectionLeaseTest.php | 42 +++++++++++++++++++ tests/Database/DatabasePdoConnectionTest.php | 31 ++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 9553d63a9..49febe5ca 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -82,6 +82,11 @@ abstract class Connection implements ConnectionInterface, NonCopyableContext */ protected mixed $reconnector = null; + /** + * Indicates if the connection is reconnecting after a lost connection. + */ + protected bool $reconnecting = false; + /** * The owner notified when the current driver resources are forgotten. */ @@ -910,8 +915,15 @@ protected function handleQueryException(QueryException $e, string $query, array */ protected function tryAgainIfCausedByLostConnection(QueryException $e, string $query, array $bindings, Closure $callback): mixed { - if ($this->causedByLostConnection($e->getPrevious())) { - $this->reconnect(); + // Queries run while reconnecting (e.g. from ConnectionEstablished listeners) must not reconnect again... + if (! $this->reconnecting && $this->causedByLostConnection($e->getPrevious())) { + $this->reconnecting = true; + + try { + $this->reconnect(); + } finally { + $this->reconnecting = false; + } return $this->runQueryCallback($query, $bindings, $callback); } diff --git a/src/database/src/Pool/ConnectionLease.php b/src/database/src/Pool/ConnectionLease.php index ebd957766..00a06e69b 100644 --- a/src/database/src/Pool/ConnectionLease.php +++ b/src/database/src/Pool/ConnectionLease.php @@ -158,6 +158,9 @@ public function reconnect(): Connection $connection = $this->connection; $connection->setPdo($this->pdoResolver)->setReadPdo($this->readPdoResolver); $connection->getPdo(); + } else { + // Announce the replacement while failed listener queries are still guarded against another reconnect. + $this->resolvePhysicalConnection(); } return $this->connection; diff --git a/tests/Database/DatabaseDriverConnectionLeaseTest.php b/tests/Database/DatabaseDriverConnectionLeaseTest.php index 101df7260..f76e7659c 100644 --- a/tests/Database/DatabaseDriverConnectionLeaseTest.php +++ b/tests/Database/DatabaseDriverConnectionLeaseTest.php @@ -6,18 +6,22 @@ use Closure; use Generator; +use Hypervel\Contracts\Database\LostConnectionDetector; use Hypervel\Contracts\Foundation\Application; use Hypervel\Database\Connection; use Hypervel\Database\Events\ConnectionEstablished; use Hypervel\Database\LeasableConnection; use Hypervel\Database\Pool\DatabasePool; use Hypervel\Database\Pool\PoolManager; +use Hypervel\Database\QueryException; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Event; use Hypervel\Testbench\TestCase; use LogicException; +use Mockery as m; use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; +use Throwable; use function Hypervel\Coroutine\parallel; @@ -183,6 +187,32 @@ public function testDisconnectClosesOnlyAHeldPhysicalConnectionAndEachGeneration $this->assertSame(3, $this->generations); } + public function testQueriesRunWhileReconnectingDoNotReconnectAgain(): void + { + $failure = new RuntimeException('The remote database connection was lost.'); + $detector = m::mock(LostConnectionDetector::class); + $detector->shouldReceive('causedByLostConnection')->with($failure)->andReturnTrue(); + $this->app->instance(LostConnectionDetector::class, $detector); + $connection = DB::connection('leased'); + $reconnects = 0; + + Event::listen(ConnectionEstablished::class, function (ConnectionEstablished $event) use ($failure, &$reconnects): void { + if (++$reconnects > 1) { + $this->fail('The leased connection reconnected while it was already reconnecting.'); + } + + $event->connection->runFailingQuery($failure); + }); + + try { + $connection->runFailingQuery($failure); + $this->fail('A QueryException was not thrown.'); + } catch (QueryException $exception) { + $this->assertSame(1, $reconnects); + $this->assertSame($failure, $exception->getPrevious()); + } + } + public function testAnEndedOwnerResetsItsWorkAndCannotBorrowAgain(): void { [$retained] = parallel([static function (): LeasedDriverConnection { @@ -279,6 +309,18 @@ public function statement(string $query, array $bindings = []): bool return true; } + /** + * Run a failing operation through the driver's normal retry boundary. + */ + public function runFailingQuery(Throwable $failure): void + { + $this->run('query', [], function () use ($failure): never { + $this->physical(); + + throw $failure; + }); + } + public function affectingStatement(string $query, array $bindings = []): int { return 0; diff --git a/tests/Database/DatabasePdoConnectionTest.php b/tests/Database/DatabasePdoConnectionTest.php index c1a1d7515..a0d0f84f5 100755 --- a/tests/Database/DatabasePdoConnectionTest.php +++ b/tests/Database/DatabasePdoConnectionTest.php @@ -873,6 +873,37 @@ public function testOnLostConnectionPDOIsSwappedOutsideTransaction(): void $this->assertTrue($called); } + public function testQueriesRunWhileReconnectingDoNotReconnectAgain(): void + { + $statement = m::mock(PDOStatement::class); + $statement->shouldReceive('execute')->andThrow( + new PDOException('SQLSTATE[08006] [7] connection to server at "127.0.0.1", port 5432 failed: Connection refused') + ); + + $pdo = m::mock(PDO::class); + $pdo->shouldReceive('prepare')->andReturn($statement); + + $connection = new PdoConnection($pdo); + + $reconnects = 0; + + $connection->setReconnector(function (Connection $connection) use (&$reconnects): void { + if (++$reconnects > 1) { + $this->fail('The connection reconnected while it was already reconnecting.'); + } + + $connection->statement('bar'); + }); + + try { + $connection->statement('foo'); + + $this->fail('A QueryException was not thrown.'); + } catch (QueryException $e) { + $this->assertSame(1, $reconnects); + } + } + public function testExplicitPhysicalCommitFailureLeavesTheTransactionCallerOwned(): void { $failure = new RuntimeException('commit failure'); From 6f5a729081f3a13dc9a1a613dfccd6b330b061e7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:00 +0000 Subject: [PATCH 09/31] Skip contextual binding lookup when no bindings exist Port laravel/framework#61993 (7b8a685ab02cfb1f35326f0eda19f853f1c5ec39). Return immediately from getContextualConcrete when no contextual bindings are registered. Retain the lower-level guard because recipe resolution also calls that lookup directly. The complete Container suite and source analysis pass. --- src/container/src/Container.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/container/src/Container.php b/src/container/src/Container.php index e7fb70eb1..fdfd34215 100755 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -1597,6 +1597,10 @@ protected function resolveConcreteFromAttributes(ReflectionClass $reflected): ?s */ protected function getContextualConcrete(string $abstract): mixed { + if ($this->contextual === []) { + return null; + } + if (! is_null($binding = $this->findInContextualBindings($abstract))) { return $binding; } From 3e47fd48aa6e126f3c406246d17fed25dbe15f44 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:29 +0000 Subject: [PATCH 10/31] Apply model casts to extra increment and decrement attributes Port laravel/framework#61991 (9bbacaf625b829929f615d5da00bc85701220610). Pass the cast storage values of extra attributes into both increment paths before updating events. Apply binary binding conversion only at the SQL boundary, preserving raw values for model refresh and original-state synchronization. The encrypted-attribute regression and binary increment/decrement cases fail before correction and pass afterward. Complete update, binary and model tests pass; binary extras also pass on MySQL. --- src/database/src/Eloquent/Model.php | 12 +++++++-- ...atabaseEloquentAsBinaryIntegrationTest.php | 26 +++++++++++++++++++ .../Database/EloquentUpdateTest.php | 26 +++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index 1a86c69d2..53648d048 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -1209,6 +1209,8 @@ protected function incrementOrDecrement(string $column, mixed $amount, array $ex $this->forceFill($extra); + $extra = array_merge($extra, array_intersect_key($this->getAttributes(), $extra)); + if ($this->fireModelEvent('updating') === false) { return false; } @@ -1217,7 +1219,11 @@ protected function incrementOrDecrement(string $column, mixed $amount, array $ex $amount = (clone $this)->setAttribute($column, $amount)->getAttributeFromArray($column); } - return tap($this->setKeysForSaveQuery($this->newQueryWithoutScopes())->{$method}($column, $amount, $extra), function () use ($column, $extra): void { + return tap($this->setKeysForSaveQuery($this->newQueryWithoutScopes())->{$method}( + $column, + $amount, + $this->prepareBinaryAttributesForDatabase($extra) + ), function () use ($column, $extra): void { if ($this->refreshes !== []) { // Increments skip the save path's cast merge, so keep unsaved cached cast changes. $this->mergeAttributesFromCachedCasts(); @@ -1376,6 +1382,8 @@ protected function incrementOrDecrementEach(array $columns, array $extra, string $this->forceFill($extra); + $extra = array_merge($extra, array_intersect_key($this->getAttributes(), $extra)); + if ($this->fireModelEvent('updating') === false) { return false; } @@ -1392,7 +1400,7 @@ protected function incrementOrDecrementEach(array $columns, array $extra, string return tap( $this->setKeysForSaveQuery($this->newQueryWithoutScopes()) - ->{$method}($dbColumns, $extra), + ->{$method}($dbColumns, $this->prepareBinaryAttributesForDatabase($extra)), function () use ($columns, $extra): void { if ($this->refreshes !== []) { // Increments skip the save path's cast merge, so keep unsaved cached cast changes. diff --git a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php index a782e6cf9..b97296b3a 100644 --- a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php +++ b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php @@ -31,6 +31,7 @@ protected function afterRefreshingDatabase(): void { Schema::create('binary_identifiers', function (Blueprint $table): void { $table->increments('id'); + $table->integer('counter')->default(0); $table->binary('uuid', length: 16, fixed: true)->unique(); $table->binary('ulid', length: 16, fixed: true); }); @@ -156,6 +157,31 @@ public function testBinaryIdentifiersRoundTripAcrossModelAndQueryBuilderWritePat $this->assertSame($upsertUlid, $upserted->ulid); } + #[TestWith(['increment'])] + #[TestWith(['decrement'])] + #[TestWith(['incrementEach'])] + #[TestWith(['decrementEach'])] + public function testIncrementAndDecrementBindBinaryExtraAttributes(string $method): void + { + $model = AsBinaryIdentifierModel::create([ + 'counter' => 10, + 'uuid' => '00000000-0000-0000-0000-000000000001', + 'ulid' => '01ARZ3NDEKTSV4RRFFQ69G5FAV', + ]); + $uuid = '00ff7f80-4048-43c2-b80b-40491d165946'; + $extra = ['uuid' => $uuid]; + + str_ends_with($method, 'Each') + ? $model->{$method}(['counter' => 1], $extra) + : $model->{$method}('counter', 1, $extra); + + $this->assertTrue(AsBinaryIdentifierModel::query() + ->where('uuid', new BinaryParameter(Uuid::fromString($uuid)->toBinary())) + ->exists()); + $this->assertFalse($model->isDirty('uuid')); + $this->assertSame($uuid, $model->fresh()->uuid); + } + #[DataProvider('fillAndInsertMethods')] public function testBinaryIdentifiersRoundTripThroughFillAndInsert(string $method): void { diff --git a/tests/Integration/Database/EloquentUpdateTest.php b/tests/Integration/Database/EloquentUpdateTest.php index 173fe21aa..2b5919b63 100644 --- a/tests/Integration/Database/EloquentUpdateTest.php +++ b/tests/Integration/Database/EloquentUpdateTest.php @@ -365,6 +365,23 @@ public function testIncrementEachSyncsPrevious(): void $this->assertSame(['views' => 11, 'likes' => 7], $post->getChanges()); $this->assertSame(['views' => 10, 'likes' => 5], $post->getPrevious()); } + + public function testIncrementCastsExtraAttributes(): void + { + config(['app.key' => str_repeat('a', 32)]); + + $post = TestUpdateModel5::create(['views' => 10]); + + $post->increment('views', 1, ['name' => 'secret']); + + $this->assertNotSame('secret', TestUpdateModel5::query()->toBase()->value('name')); + $this->assertSame('secret', $post->fresh()->name); + + $post->incrementEach(['views' => 1], ['name' => 'another secret']); + + $this->assertNotSame('another secret', TestUpdateModel5::query()->toBase()->value('name')); + $this->assertSame('another secret', $post->fresh()->name); + } } class TestUpdateModel1 extends Model @@ -407,6 +424,15 @@ class TestUpdateModel4 extends Model protected array $casts = ['deleted_at' => 'datetime']; } +class TestUpdateModel5 extends Model +{ + public ?string $table = 'test_model4'; + + protected array $fillable = ['views', 'likes', 'name']; + + protected array $casts = ['name' => 'encrypted']; +} + class TestSelectiveUpdateModel extends TestUpdateModel1 { /** From 6834834e467434c88c92b349b4a4082eaa93e0f1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:29 +0000 Subject: [PATCH 11/31] Observe scheduler overlap locks without acquiring them Port laravel/framework#62007 (0fd73881deb1d659d39e975c814fd35df2cea6f8). Use the lock contract isLocked method to check overlap without acquiring and releasing the lock, avoiding interference with onOneServer scheduling. The existing contract guarantees this method, so no optional-method fallback is needed. Port the observation regression and preserve existing active/inactive lock coverage. The complete mutex test file passes. --- .../src/Scheduling/CacheEventMutex.php | 4 +-- .../Scheduling/CacheEventMutexTest.php | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/console/src/Scheduling/CacheEventMutex.php b/src/console/src/Scheduling/CacheEventMutex.php index 26e6b6fbb..e3fb8bc54 100644 --- a/src/console/src/Scheduling/CacheEventMutex.php +++ b/src/console/src/Scheduling/CacheEventMutex.php @@ -55,9 +55,9 @@ public function exists(Event $event): bool $store = $repository->getStore(); if ($this->shouldUseLocks($store)) { - return ! $store + return $store ->lock($event->mutexName(), $event->expiresAt * 60) - ->get(fn () => true); + ->isLocked(); } return $repository->has($event->mutexName()); diff --git a/tests/Console/Scheduling/CacheEventMutexTest.php b/tests/Console/Scheduling/CacheEventMutexTest.php index a724af5e4..72df1e3f3 100644 --- a/tests/Console/Scheduling/CacheEventMutexTest.php +++ b/tests/Console/Scheduling/CacheEventMutexTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Console\Scheduling; +use Hypervel\Cache\ArrayLock; use Hypervel\Cache\ArrayStore; use Hypervel\Cache\Repository; use Hypervel\Cache\StorageStore; @@ -125,6 +126,38 @@ public function testOverlapsForRunningTaskWithLockProvider(): void $this->assertTrue($this->cacheMutex->exists($this->event)); } + public function testCheckingForOverlapDoesNotAcquireLockWithLockProvider(): void + { + $store = new class extends ArrayStore { + public int $acquisitions = 0; + + /** + * Get a lock instance. + */ + public function lock(string $name, int $seconds = 0, ?string $owner = null): ArrayLock + { + return new class($this, $name, $seconds, $owner) extends ArrayLock { + /** + * Attempt to acquire the lock. + */ + public function acquire(): bool + { + ++$this->store->acquisitions; + + return parent::acquire(); + } + }; + } + }; + + $this->cacheFactory->repository = new Repository($store); + + $this->assertFalse($this->cacheMutex->exists($this->event)); + $this->assertSame(0, $store->acquisitions); + } + + // REMOVED: Locks without isLocked() cannot implement Hypervel's Lock contract. + public function testResetOverlapWithLockProvider(): void { $this->cacheFactory->repository = new Repository(new ArrayStore); From 42d09273df831ce697e68b269057b4803ea2b2fc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:29 +0000 Subject: [PATCH 12/31] Honor the complete blocking deadline in locks and limiters Reconcile laravel/framework#62017 (6168f60d55ee89b4d337b8a920533a95e5354835). Use integer-microsecond deadlines with fake-aware time and cap the final sleep to the remaining wait. The previous look-ahead check could stop one interval early and miss a resource released within the requested timeout. Every sleep now leads to an acquisition attempt. Preserve immediate acquisition, block(0), callback handling and idle connection release. Port the upstream faked-Sleep regression and cover successful acquisition at the deadline in all four loops. Affected lock, limiter and consumer tests pass, including isolated Redis integration. --- src/cache/src/Limiters/ConcurrencyLimiter.php | 11 +++--- src/cache/src/Lock.php | 11 +++--- src/redis/src/Limiters/ConcurrencyLimiter.php | 11 +++--- src/redis/src/Limiters/DurationLimiter.php | 11 +++--- tests/Cache/CacheLockTest.php | 19 +++++++++ tests/Cache/ConcurrencyLimiterTest.php | 39 +++++++++++++++++++ tests/Redis/ConcurrencyLimiterTest.php | 24 ++++++++++++ tests/Redis/DurationLimiterTest.php | 24 ++++++++++++ 8 files changed, 126 insertions(+), 24 deletions(-) diff --git a/src/cache/src/Limiters/ConcurrencyLimiter.php b/src/cache/src/Limiters/ConcurrencyLimiter.php index 333677cd1..4072201e4 100644 --- a/src/cache/src/Limiters/ConcurrencyLimiter.php +++ b/src/cache/src/Limiters/ConcurrencyLimiter.php @@ -54,19 +54,18 @@ public function acquire(int $timeout, int $sleep = 250): Lease { $id = Str::random(20); - $starting = ((int) now()->format('Uu')) / 1000; - - $milliseconds = $timeout * 1000; + $deadline = (int) now()->format('Uu') + $timeout * 1_000_000; while (! $lock = $this->claimSlot($id)) { - $now = ((int) now()->format('Uu')) / 1000; + $remaining = $deadline - (int) now()->format('Uu'); - if (($now + $sleep - $milliseconds) >= $starting) { + if ($remaining <= 0) { throw new LimiterTimeoutException; } ConnectionResolver::releaseIdleConnections(); - Sleep::usleep($sleep * 1000); + // Allow a final attempt without sleeping beyond the remaining wait. + Sleep::usleep(min($sleep * 1000, $remaining)); } return $lock instanceof RefreshableLock diff --git a/src/cache/src/Lock.php b/src/cache/src/Lock.php index c29c3ced0..863b9221e 100644 --- a/src/cache/src/Lock.php +++ b/src/cache/src/Lock.php @@ -95,19 +95,18 @@ public function get(?callable $callback = null): mixed */ public function block(int $seconds, ?callable $callback = null): mixed { - $starting = ((int) CarbonImmutable::now()->format('Uu')) / 1000; - - $milliseconds = $seconds * 1000; + $deadline = (int) CarbonImmutable::now()->format('Uu') + $seconds * 1_000_000; while (! $this->acquire()) { - $now = ((int) CarbonImmutable::now()->format('Uu')) / 1000; + $remaining = $deadline - (int) CarbonImmutable::now()->format('Uu'); - if (($now + $this->sleepMilliseconds - $milliseconds) >= $starting) { + if ($remaining <= 0) { throw new LockTimeoutException; } ConnectionResolver::releaseIdleConnections(); - Sleep::usleep($this->sleepMilliseconds * 1000); + // Allow a final attempt without sleeping beyond the remaining wait. + Sleep::usleep(min($this->sleepMilliseconds * 1000, $remaining)); } if (is_callable($callback)) { diff --git a/src/redis/src/Limiters/ConcurrencyLimiter.php b/src/redis/src/Limiters/ConcurrencyLimiter.php index 3e8a3bc29..49b2faa28 100644 --- a/src/redis/src/Limiters/ConcurrencyLimiter.php +++ b/src/redis/src/Limiters/ConcurrencyLimiter.php @@ -63,19 +63,18 @@ public function acquire(int $timeout, int $sleep = 250): ConcurrencyLease { $id = Str::random(20); - $starting = ((int) now()->format('Uu')) / 1000; - - $milliseconds = $timeout * 1000; + $deadline = (int) now()->format('Uu') + $timeout * 1_000_000; while (! $slot = $this->claimSlot($id)) { - $now = ((int) now()->format('Uu')) / 1000; + $remaining = $deadline - (int) now()->format('Uu'); - if (($now + $sleep - $milliseconds) >= $starting) { + if ($remaining <= 0) { throw new LimiterTimeoutException; } ConnectionResolver::releaseIdleConnections(); - Sleep::usleep($sleep * 1000); + // Allow a final attempt without sleeping beyond the remaining wait. + Sleep::usleep(min($sleep * 1000, $remaining)); } return new ConcurrencyLease($this->redis, $slot, $id, $this->releaseAfter); diff --git a/src/redis/src/Limiters/DurationLimiter.php b/src/redis/src/Limiters/DurationLimiter.php index e1c6f71a3..5db938577 100644 --- a/src/redis/src/Limiters/DurationLimiter.php +++ b/src/redis/src/Limiters/DurationLimiter.php @@ -47,19 +47,18 @@ public function __construct( */ public function block(int $timeout, ?callable $callback = null, int $sleep = 750): mixed { - $starting = ((int) now()->format('Uu')) / 1000; - - $milliseconds = $timeout * 1000; + $deadline = (int) now()->format('Uu') + $timeout * 1_000_000; while (! $this->acquire()) { - $now = ((int) now()->format('Uu')) / 1000; + $remaining = $deadline - (int) now()->format('Uu'); - if (($now + $sleep - $milliseconds) >= $starting) { + if ($remaining <= 0) { throw new LimiterTimeoutException; } ConnectionResolver::releaseIdleConnections(); - Sleep::usleep($sleep * 1000); + // Allow a final attempt without sleeping beyond the remaining wait. + Sleep::usleep(min($sleep * 1000, $remaining)); } if (is_callable($callback)) { diff --git a/tests/Cache/CacheLockTest.php b/tests/Cache/CacheLockTest.php index 0d512b038..907f1ff64 100644 --- a/tests/Cache/CacheLockTest.php +++ b/tests/Cache/CacheLockTest.php @@ -4,7 +4,10 @@ namespace Hypervel\Tests\Cache; +use Hypervel\Cache\ArrayStore; use Hypervel\Cache\Lock; +use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Sleep; use Hypervel\Tests\TestCase; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -65,6 +68,22 @@ public function testGetReleaseCancellationSupersedesOrdinaryCallbackFailure(): v } } + public function testBlockCanAcquireAtTheDeadlineWithAShorterFinalSleep(): void + { + Sleep::fake(syncWithCarbon: true); + CarbonImmutable::setTestNow(CarbonImmutable::now()); + $store = new ArrayStore; + $store->lock('key', 2)->get(); + + $this->assertTrue($store->lock('key', 2)->betweenBlockedAttemptsSleepFor(750)->block(2)); + + Sleep::assertSequence([ + Sleep::for(750)->milliseconds(), + Sleep::for(750)->milliseconds(), + Sleep::for(500)->milliseconds(), + ]); + } + public function testBlockPreservesCallbackFailureWhenReleaseAlsoFails(): void { $lock = new FailingReleaseLock; diff --git a/tests/Cache/ConcurrencyLimiterTest.php b/tests/Cache/ConcurrencyLimiterTest.php index 739a68d07..d3cd735b4 100644 --- a/tests/Cache/ConcurrencyLimiterTest.php +++ b/tests/Cache/ConcurrencyLimiterTest.php @@ -17,6 +17,8 @@ use Hypervel\Contracts\Cache\Store; use Hypervel\Contracts\Limiters\LimiterTimeoutException; use Hypervel\Contracts\Limiters\RefreshableLease; +use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Sleep; use Hypervel\Tests\TestCase; use Mockery as m; use RuntimeException; @@ -145,6 +147,43 @@ public function testItFailsAfterRetryTimeout(): void $this->assertEquals([1], $store); } + public function testRetryTimeoutRespectsFakedSleep(): void + { + Sleep::fake(syncWithCarbon: true); + CarbonImmutable::setTestNow(CarbonImmutable::now()); + + $lock = new ConcurrencyLimiterMockThatDoesntRelease($this->repository->getStore(), 'key', 1, 10); + + $lock->block(2, fn () => null); + + try { + $lock->block(2, fn () => null, 500); + $this->fail('Expected LimiterTimeoutException.'); + } catch (LimiterTimeoutException) { + // 2s timeout / 500ms faked sleeps advancing Carbon + Sleep::assertSleptTimes(4); + } finally { + Sleep::fake(false); + CarbonImmutable::setTestNow(); + } + } + + public function testBlockCanAcquireAtTheDeadlineWithAShorterFinalSleep(): void + { + Sleep::fake(syncWithCarbon: true); + CarbonImmutable::setTestNow(CarbonImmutable::now()); + $limiter = new ConcurrencyLimiter($this->repository->getStore(), 'key', 1, 2); + $limiter->block(0); + + $this->assertTrue($limiter->block(2, sleep: 750)); + + Sleep::assertSequence([ + Sleep::for(750)->milliseconds(), + Sleep::for(750)->milliseconds(), + Sleep::for(500)->milliseconds(), + ]); + } + public function testItReleasesIfErrorIsThrown(): void { $store = []; diff --git a/tests/Redis/ConcurrencyLimiterTest.php b/tests/Redis/ConcurrencyLimiterTest.php index 8a52232a8..144c5e9d9 100644 --- a/tests/Redis/ConcurrencyLimiterTest.php +++ b/tests/Redis/ConcurrencyLimiterTest.php @@ -8,6 +8,8 @@ use Hypervel\Redis\Limiters\ConcurrencyLimiter; use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; +use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Sleep; use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; @@ -144,6 +146,28 @@ public function testBlockThrowsTimeoutExceptionWhenCannotAcquire(): void $limiter->block(0, null, 1); // 1ms sleep between retries } + public function testBlockCanAcquireAtTheDeadlineWithAShorterFinalSleep(): void + { + Sleep::fake(syncWithCarbon: true); + CarbonImmutable::setTestNow(CarbonImmutable::now()); + $redis = $this->mockRedis(); + $connection = m::mock(RedisConnection::class); + $redis->shouldReceive('withConnection') + ->andReturnUsing(fn (callable $callback): mixed => $callback($connection)); + $connection->shouldReceive('evalWithShaCache')->times(4) + ->andReturn(false, false, false, 'test-lock1'); + + $limiter = new ConcurrencyLimiter($redis, 'test-lock', 1, 10); + + $this->assertTrue($limiter->block(2, sleep: 750)); + + Sleep::assertSequence([ + Sleep::for(750)->milliseconds(), + Sleep::for(750)->milliseconds(), + Sleep::for(500)->milliseconds(), + ]); + } + public function testBlockWithZeroLimitDoesNotCallEvalAndTimesOut(): void { $redis = $this->mockRedis(); diff --git a/tests/Redis/DurationLimiterTest.php b/tests/Redis/DurationLimiterTest.php index 377ed79ff..10d79816d 100644 --- a/tests/Redis/DurationLimiterTest.php +++ b/tests/Redis/DurationLimiterTest.php @@ -8,6 +8,8 @@ use Hypervel\Redis\Limiters\DurationLimiter; use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; +use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Sleep; use Hypervel\Tests\TestCase; use Mockery as m; @@ -171,6 +173,28 @@ public function testBlockThrowsExceptionAfterTimeout(): void $limiter->block(0, null, 1); // 1ms sleep between retries } + public function testBlockCanAcquireAtTheDeadlineWithAShorterFinalSleep(): void + { + Sleep::fake(syncWithCarbon: true); + CarbonImmutable::setTestNow(CarbonImmutable::now()); + $redis = $this->mockRedis(); + $connection = m::mock(RedisConnection::class); + $redis->shouldReceive('withConnection') + ->andReturnUsing(fn (callable $callback): mixed => $callback($connection)); + $connection->shouldReceive('evalWithShaCache')->times(4) + ->andReturn([0, 0, '1'], [0, 0, '1'], [0, 0, '1'], [1, 10, '1']); + + $limiter = new DurationLimiter($redis, 'test-key', 1, 10); + + $this->assertTrue($limiter->block(2, sleep: 750)); + + Sleep::assertSequence([ + Sleep::for(750)->milliseconds(), + Sleep::for(750)->milliseconds(), + Sleep::for(500)->milliseconds(), + ]); + } + // REMOVED: testUsesSpecifiedConnectionName - Connection is now resolved before creating the limiter, // so DurationLimiter no longer has a connection name parameter. From 90c3e223b85f4132135bdb66966716cdac8f5843 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:29 +0000 Subject: [PATCH 13/31] Constrain both schema tables when inspecting MySQL foreign keys Port laravel/framework#62014 (f9aed736a89adce3dccccb0bd656d185d28d7437). Constrain both information_schema tables by schema and table to avoid scanning unrelated referential constraints on servers with many databases. MariaDB inherits the same query. Port every implicit and explicit schema assertion. Both grammar test files and MySQL simple/compound foreign-key inspection checks pass. --- src/database/src/Schema/Grammars/MySqlGrammar.php | 10 ++++++++-- tests/Database/DatabaseMariaDbSchemaGrammarTest.php | 13 +++++++++++++ tests/Database/DatabaseMySqlSchemaGrammarTest.php | 13 +++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/database/src/Schema/Grammars/MySqlGrammar.php b/src/database/src/Schema/Grammars/MySqlGrammar.php index a43931432..50cc3bfab 100755 --- a/src/database/src/Schema/Grammars/MySqlGrammar.php +++ b/src/database/src/Schema/Grammars/MySqlGrammar.php @@ -156,6 +156,9 @@ public function compileIndexes(?string $schema, string $table): string */ public function compileForeignKeys(?string $schema, string $table): string { + $schema = $schema ? $this->quoteString($schema) : 'schema()'; + $table = $this->quoteString($table); + return sprintf( 'select kc.constraint_name as `name`, ' . 'group_concat(kc.column_name order by kc.ordinal_position) as `columns`, ' @@ -167,9 +170,12 @@ public function compileForeignKeys(?string $schema, string $table): string . 'from information_schema.key_column_usage kc join information_schema.referential_constraints rc ' . 'on kc.constraint_schema = rc.constraint_schema and kc.constraint_name = rc.constraint_name ' . 'where kc.table_schema = %s and kc.table_name = %s and kc.referenced_table_name is not null ' + . 'and rc.constraint_schema = %s and rc.table_name = %s ' . 'group by kc.constraint_name, kc.referenced_table_schema, kc.referenced_table_name, rc.update_rule, rc.delete_rule', - $schema ? $this->quoteString($schema) : 'schema()', - $this->quoteString($table) + $schema, + $table, + $schema, + $table ); } diff --git a/tests/Database/DatabaseMariaDbSchemaGrammarTest.php b/tests/Database/DatabaseMariaDbSchemaGrammarTest.php index 0e26fb5bc..4915f6716 100755 --- a/tests/Database/DatabaseMariaDbSchemaGrammarTest.php +++ b/tests/Database/DatabaseMariaDbSchemaGrammarTest.php @@ -1619,6 +1619,19 @@ protected function getConnection( return $connection; } + public function testCompileForeignKeysConstrainsReferentialConstraints(): void + { + $grammar = $this->getGrammar(); + + $sql = $grammar->compileForeignKeys(null, 'users'); + $this->assertStringContainsString("where kc.table_schema = schema() and kc.table_name = 'users' and kc.referenced_table_name is not null", $sql); + $this->assertStringContainsString("and rc.constraint_schema = schema() and rc.table_name = 'users' ", $sql); + + $sql = $grammar->compileForeignKeys('my_schema', 'users'); + $this->assertStringContainsString("where kc.table_schema = 'my_schema' and kc.table_name = 'users' and kc.referenced_table_name is not null", $sql); + $this->assertStringContainsString("and rc.constraint_schema = 'my_schema' and rc.table_name = 'users' ", $sql); + } + /** * Get a schema grammar instance. */ diff --git a/tests/Database/DatabaseMySqlSchemaGrammarTest.php b/tests/Database/DatabaseMySqlSchemaGrammarTest.php index ae1f04005..b263e7566 100755 --- a/tests/Database/DatabaseMySqlSchemaGrammarTest.php +++ b/tests/Database/DatabaseMySqlSchemaGrammarTest.php @@ -1958,6 +1958,19 @@ public function testQuoteStringOnArray(): void $this->assertSame("'foo''bar', 'baz''qux'", $this->getGrammar()->quoteString(["foo'bar", "baz'qux"])); } + public function testCompileForeignKeysConstrainsReferentialConstraints(): void + { + $grammar = $this->getGrammar(); + + $sql = $grammar->compileForeignKeys(null, 'users'); + $this->assertStringContainsString("where kc.table_schema = schema() and kc.table_name = 'users' and kc.referenced_table_name is not null", $sql); + $this->assertStringContainsString("and rc.constraint_schema = schema() and rc.table_name = 'users' ", $sql); + + $sql = $grammar->compileForeignKeys('my_schema', 'users'); + $this->assertStringContainsString("where kc.table_schema = 'my_schema' and kc.table_name = 'users' and kc.referenced_table_name is not null", $sql); + $this->assertStringContainsString("and rc.constraint_schema = 'my_schema' and rc.table_name = 'users' ", $sql); + } + /** * Create the MySQL schema grammar. */ From 3b1fe4b51f9243ee12ec26849bcb90263593b2ce Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:29 +0000 Subject: [PATCH 14/31] Preserve self aliases in one-of-many existence queries Port laravel/framework#62009 (a0d6db3446a469e2619311f58425e0953b8e4c70). Establish the self-relation alias before merging one-of-many joins and qualify the foreign key against that alias. Apply the same correction to MorphOne, including its morph-type column, which has the same reproduced defect. Port the has, withCount and doesntHave regression and cover polymorphic type isolation. Both complete test files and related relationship consumers pass. --- .../src/Eloquent/Relations/HasOne.php | 6 ++- .../src/Eloquent/Relations/MorphOne.php | 9 ++-- .../DatabaseEloquentHasOneOfManyTest.php | 46 +++++++++++++++++++ .../DatabaseEloquentMorphOneOfManyTest.php | 46 +++++++++++++++++++ 4 files changed, 102 insertions(+), 5 deletions(-) diff --git a/src/database/src/Eloquent/Relations/HasOne.php b/src/database/src/Eloquent/Relations/HasOne.php index 3dc324290..a7d35dee9 100644 --- a/src/database/src/Eloquent/Relations/HasOne.php +++ b/src/database/src/Eloquent/Relations/HasOne.php @@ -50,11 +50,13 @@ public function match(array $models, EloquentCollection $results, string $relati public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder { + $query = parent::getRelationExistenceQuery($query, $parentQuery, $columns); + if ($this->isOneOfMany()) { $this->mergeOneOfManyJoinsTo($query); } - return parent::getRelationExistenceQuery($query, $parentQuery, $columns); + return $query; } /** @@ -80,7 +82,7 @@ public function getOneOfManySubQuerySelectColumns(): array|string */ public function addOneOfManyJoinSubQueryConstraints(JoinClause $join): void { - $join->on($this->qualifySubSelectColumn($this->foreignKey), '=', $this->qualifyRelatedColumn($this->foreignKey)); + $join->on($this->qualifySubSelectColumn($this->foreignKey), '=', $this->qualifyRelatedColumn($this->getForeignKeyName())); } /** diff --git a/src/database/src/Eloquent/Relations/MorphOne.php b/src/database/src/Eloquent/Relations/MorphOne.php index 16e3dfab2..d6c694ba3 100644 --- a/src/database/src/Eloquent/Relations/MorphOne.php +++ b/src/database/src/Eloquent/Relations/MorphOne.php @@ -50,11 +50,14 @@ public function match(array $models, EloquentCollection $results, string $relati public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder { + // Alias self-relations before adding joins that qualify the related columns. + $query = parent::getRelationExistenceQuery($query, $parentQuery, $columns); + if ($this->isOneOfMany()) { $this->mergeOneOfManyJoinsTo($query); } - return parent::getRelationExistenceQuery($query, $parentQuery, $columns); + return $query; } /** @@ -81,8 +84,8 @@ public function getOneOfManySubQuerySelectColumns(): array|string public function addOneOfManyJoinSubQueryConstraints(JoinClause $join): void { $join - ->on($this->qualifySubSelectColumn($this->morphType), '=', $this->qualifyRelatedColumn($this->morphType)) - ->on($this->qualifySubSelectColumn($this->foreignKey), '=', $this->qualifyRelatedColumn($this->foreignKey)); + ->on($this->qualifySubSelectColumn($this->morphType), '=', $this->qualifyRelatedColumn($this->getMorphType())) + ->on($this->qualifySubSelectColumn($this->foreignKey), '=', $this->qualifyRelatedColumn($this->getForeignKeyName())); } /** diff --git a/tests/Database/DatabaseEloquentHasOneOfManyTest.php b/tests/Database/DatabaseEloquentHasOneOfManyTest.php index a8ef664b6..2fd3e0444 100755 --- a/tests/Database/DatabaseEloquentHasOneOfManyTest.php +++ b/tests/Database/DatabaseEloquentHasOneOfManyTest.php @@ -7,7 +7,10 @@ use Hypervel\Database\Capsule\Manager as DB; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\Eloquent\Model as Eloquent; +use Hypervel\Database\Eloquent\Relations\HasMany; +use Hypervel\Database\Eloquent\Relations\HasOne; use Hypervel\Database\Eloquent\SoftDeletes; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Schema\Builder; use Hypervel\Tests\TestCase; use InvalidArgumentException; @@ -59,6 +62,11 @@ public function createSchema() $table->dateTime('published_at'); $table->foreignId('user_id'); }); + + $this->schema()->create('comments', function (Blueprint $table): void { + $table->increments('id'); + $table->foreignId('parent_id')->nullable(); + }); } /** @@ -70,6 +78,7 @@ protected function tearDown(): void $this->schema()->drop('logins'); $this->schema()->drop('states'); $this->schema()->drop('prices'); + $this->schema()->drop('comments'); parent::tearDown(); } @@ -298,6 +307,18 @@ public function testWithHasNested(): void $this->assertFalse($found); } + public function testExistenceQueriesOnSelfReferencingRelation(): void + { + $first = Comment::create(); + $second = Comment::create(); + $first->replies()->create(); + $second->replies()->create(); + + $this->assertEquals([$first->id, $second->id], Comment::has('latestReply')->pluck('id')->all()); + $this->assertEquals([1, 1, 0, 0], Comment::withCount('latestReply')->pluck('latest_reply_count')->all()); + $this->assertEquals([3, 4], Comment::doesntHave('latestReply')->pluck('id')->all()); + } + public function testHasCount(): void { $user = User::create(); @@ -714,3 +735,28 @@ class Price extends Eloquent protected array $casts = ['published_at' => 'datetime']; } + +class Comment extends Eloquent +{ + protected ?string $table = 'comments'; + + protected array $guarded = []; + + public bool $timestamps = false; + + /** + * Get the comment's replies. + */ + public function replies(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + /** + * Get the latest reply. + */ + public function latestReply(): HasOne + { + return $this->hasOne(self::class, 'parent_id')->latestOfMany(); + } +} diff --git a/tests/Database/DatabaseEloquentMorphOneOfManyTest.php b/tests/Database/DatabaseEloquentMorphOneOfManyTest.php index ce9472485..088feddd4 100644 --- a/tests/Database/DatabaseEloquentMorphOneOfManyTest.php +++ b/tests/Database/DatabaseEloquentMorphOneOfManyTest.php @@ -6,6 +6,9 @@ use Hypervel\Database\Capsule\Manager as DB; use Hypervel\Database\Eloquent\Model as Eloquent; +use Hypervel\Database\Eloquent\Relations\MorphMany; +use Hypervel\Database\Eloquent\Relations\MorphOne; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Tests\TestCase; class DatabaseEloquentMorphOneOfManyTest extends TestCase @@ -42,6 +45,11 @@ public function createSchema(): void $table->string('state'); $table->string('type')->nullable(); }); + + $this->schema()->create('comments', function (Blueprint $table): void { + $table->increments('id'); + $table->nullableMorphs('commentable'); + }); } /** @@ -51,6 +59,7 @@ protected function tearDown(): void { $this->schema()->drop('products'); $this->schema()->drop('states'); + $this->schema()->drop('comments'); parent::tearDown(); } @@ -107,6 +116,18 @@ public function testForceCreateMorphType(): void $this->assertSame(Product::class, $product->current_state->stateful_type); } + public function testExistenceQueriesOnSelfReferencingRelation(): void + { + $first = Comment::create(); + $second = Comment::create(); + $first->replies()->create(); + Comment::create(['commentable_id' => $second->id, 'commentable_type' => Product::class]); + + $this->assertEquals([$first->id], Comment::has('latestReply')->pluck('id')->all()); + $this->assertEquals([1, 0, 0, 0], Comment::withCount('latestReply')->pluck('latest_reply_count')->all()); + $this->assertEquals([2, 3, 4], Comment::doesntHave('latestReply')->pluck('id')->all()); + } + public function testExists(): void { $product = Product::create(); @@ -257,3 +278,28 @@ class State extends Eloquent protected array $fillable = ['state', 'type']; } + +class Comment extends Eloquent +{ + protected ?string $table = 'comments'; + + protected array $guarded = []; + + public bool $timestamps = false; + + /** + * Get the comment's replies. + */ + public function replies(): MorphMany + { + return $this->morphMany(self::class, 'commentable'); + } + + /** + * Get the latest reply. + */ + public function latestReply(): MorphOne + { + return $this->morphOne(self::class, 'commentable')->latestOfMany(); + } +} From 46f896e73544c8bd96e299c97ab4e31c17f54995 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:29 +0000 Subject: [PATCH 15/31] Include elapsed runtime in failed scheduled task events Port laravel/framework#61970 (3727a91780ac544845b4cd1abdcd6f5bf8d10f0d). Append a nullable runtime to ScheduledTaskFailed and supply elapsed seconds from the existing command start time. Preserve listener guards and cancellation handling. Document the units and unavailable value, and retain the upstream runtime assertion alongside existing event checks. The complete scheduling command test file passes. --- src/console/src/Commands/ScheduleRunCommand.php | 6 +++++- src/console/src/Events/ScheduledTaskFailed.php | 1 + src/docs/scheduling.md | 2 ++ .../Console/Scheduling/ScheduleRunCommandTest.php | 3 ++- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/console/src/Commands/ScheduleRunCommand.php b/src/console/src/Commands/ScheduleRunCommand.php index 539f0beb5..4a03ad7f7 100644 --- a/src/console/src/Commands/ScheduleRunCommand.php +++ b/src/console/src/Commands/ScheduleRunCommand.php @@ -524,7 +524,11 @@ protected function runEvent(Event $event): void CoroutineContext::set(self::TASK_FAILED_CONTEXT_KEY, true); if ($this->dispatcher->hasListeners(ScheduledTaskFailed::class)) { - $this->dispatcher->dispatch(new ScheduledTaskFailed($event, $e)); + $this->dispatcher->dispatch(new ScheduledTaskFailed( + $event, + $e, + round(microtime(true) - $start, 2) + )); } $this->handler->report($e); diff --git a/src/console/src/Events/ScheduledTaskFailed.php b/src/console/src/Events/ScheduledTaskFailed.php index 62d1b390b..fb77f9129 100644 --- a/src/console/src/Events/ScheduledTaskFailed.php +++ b/src/console/src/Events/ScheduledTaskFailed.php @@ -15,6 +15,7 @@ class ScheduledTaskFailed public function __construct( public Event $task, public Throwable $exception, + public ?float $runtime = null, ) { } } diff --git a/src/docs/scheduling.md b/src/docs/scheduling.md index 7c5f525e2..9fe0e775f 100644 --- a/src/docs/scheduling.md +++ b/src/docs/scheduling.md @@ -786,3 +786,5 @@ Hypervel dispatches a variety of [events](/docs/{{version}}/events) during the s | `Hypervel\Console\Events\ScheduledTaskFailed` | + +The `ScheduledTaskFailed` event's `runtime` property contains the elapsed execution time in seconds, or `null` when the runtime is unavailable. diff --git a/tests/Integration/Console/Scheduling/ScheduleRunCommandTest.php b/tests/Integration/Console/Scheduling/ScheduleRunCommandTest.php index 913d1de3c..1562c7cc2 100644 --- a/tests/Integration/Console/Scheduling/ScheduleRunCommandTest.php +++ b/tests/Integration/Console/Scheduling/ScheduleRunCommandTest.php @@ -52,7 +52,8 @@ public function testFailingCommandInForegroundTriggersEvent(): void Event::assertDispatched(ScheduledTaskFinished::class); Event::assertDispatched(ScheduledTaskFailed::class, function (ScheduledTaskFailed $event) use ($task): bool { return $event->task === $task - && $event->exception->getMessage() === 'Scheduled command [exit 1] failed with exit code [1].'; + && $event->exception->getMessage() === 'Scheduled command [exit 1] failed with exit code [1].' + && is_float($event->runtime); }); } From 2357596fb117a8260703af0fb12dd82184215b0b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:29 +0000 Subject: [PATCH 16/31] Preserve JSON:API loading customization with batched default includes Port laravel/framework#62000 (04c07c33d9bd49dd03567ffb5b78ab339d6f50dc), #62023 (6ea1b32090c0f39fb38675b4ecb066b7d1dd45d3) and #62152 (719bf5a318bbc82d25508b5a8c559f66aae6668d). Retain all upstream relationship-loading hooks and invoke custom implementations at their original call sites with the owning parent and arguments. Default loaders keep whole-level batching by model class, connection and relationship. Cache only immutable per-class override metadata and clear it through existing state cleanup. Filter conditional attributes before expanding integer keys, while preserving JsonSerializable conversion semantics. Reconcile nested and undeclared include coverage, and test custom constraints, parent identity, collection hook ordering and connection isolation. All JSON:API unit and integration tests pass with the upstream query-count assertions preserved. Formatting and full source/type analysis pass. --- .../JsonApi/AnonymousResourceCollection.php | 41 +++- .../Concerns/ResolvesJsonApiElements.php | 94 +++++++- .../src/Resources/JsonApi/JsonApiResource.php | 1 + .../Resources/JsonApi/JsonApiResourceTest.php | 28 +++ .../JsonApi/JsonApiCollectionTest.php | 20 ++ .../JsonApiRelationshipConnectionsTest.php | 23 +- .../JsonApiRelationshipLoadingTest.php | 211 ++++++++++++++++++ .../Resources/JsonApi/JsonApiResourceTest.php | 104 ++++++++- 8 files changed, 512 insertions(+), 10 deletions(-) create mode 100644 tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipLoadingTest.php diff --git a/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php b/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php index 7e1340d0a..0d9df2d31 100644 --- a/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php +++ b/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php @@ -6,6 +6,7 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Database\Eloquent\Model; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Http\Resources\Json\AnonymousResourceCollection as BaseAnonymousResourceCollection; @@ -28,7 +29,7 @@ class AnonymousResourceCollection extends BaseAnonymousResourceCollection public function with(Request $request): array { $request = $this->resolveJsonApiRequestFrom($request); - $included = $this->includedResources ??= JsonApiResource::resolveIncludedResources($this->collection, $request); + $included = $this->resolveIncludedResources($request); return [ ...($included !== [] || $request->has('include')) ? ['included' => $included] : [], @@ -44,13 +45,47 @@ public function with(Request $request): array #[Override] public function toAttributes(Request $request): array|Arrayable|JsonSerializable { - JsonApiResource::prepareResourceRelationships($this->collection, $this->resolveJsonApiRequestFrom($request)); + if ($this->includedResources === null) { + $this->loadMissingRequestedRelationships($request); + } return $this->collection ->map(fn ($resource) => $resource->resolveResourceData($request)) ->all(); } + /** + * Load the requested relationships for all of the resources at once. + */ + protected function loadMissingRequestedRelationships(Request $request): void + { + $request = $this->resolveJsonApiRequestFrom($request); + $resource = $this->collection->first(); + + // Honor custom loading before the default batch can populate those relationships. + if ($resource instanceof JsonApiResource && ! $resource->usesDefaultRelationshipLoading()) { + $resource->loadMissingRequestedRelationshipsOn( + $request, + $this->collection->pluck('resource')->whereInstanceOf(Model::class) + ); + } + + JsonApiResource::prepareResourceRelationships($this->collection, $request); + } + + /** + * Load the requested relationships and resolve the included resources. + */ + protected function resolveIncludedResources(JsonApiRequest $request): array + { + if ($this->includedResources === null) { + $this->loadMissingRequestedRelationships($request); + $this->includedResources = JsonApiResource::resolveIncludedResources($this->collection, $request); + } + + return $this->includedResources; + } + /** * Customize the outgoing response for the resource. */ @@ -69,7 +104,7 @@ public function toResponse(Request $request): JsonResponse $request = $this->resolveJsonApiRequestFrom($request); // Merge duplicate linkage before the primary collection is serialized. - $this->includedResources ??= JsonApiResource::resolveIncludedResources($this->collection, $request); + $this->resolveIncludedResources($request); return parent::toResponse($request); } diff --git a/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php b/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php index 7567f681b..094ec44c7 100644 --- a/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php +++ b/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php @@ -6,6 +6,7 @@ use Generator; use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsToMany; use Hypervel\Database\Eloquent\Relations\Concerns\AsPivot; @@ -22,6 +23,7 @@ use Hypervel\Support\Str; use Hypervel\Support\Stringable; use JsonSerializable; +use ReflectionMethod; use RuntimeException; use WeakMap; @@ -61,6 +63,13 @@ trait ResolvesJsonApiElements */ protected array $loadedRelationshipIdentifiers = []; + /** + * Whether each resource class uses the default relationship loader. + * + * @var array + */ + protected static array $loadsRelationshipsByDefault = []; + /** * The maximum relationship depth. */ @@ -164,14 +173,14 @@ protected function resolveResourceAttributes(JsonApiRequest $request, string $re if ($data instanceof Arrayable) { $data = $data->toArray(); } elseif ($data instanceof JsonSerializable) { - $data = $data->jsonSerialize(); + $data = (new Collection($data->jsonSerialize()))->all(); } $usesSparseFieldset = $this->usesRequestQueryString && $request->hasSparseFieldset($resourceType); $sparseFieldset = $usesSparseFieldset ? $request->sparseFields($resourceType) : []; - $data = (new Collection($data)) + $data = (new Collection($this->filter($data))) ->mapWithKeys(fn ($value, $key) => is_int($key) ? [$value => $this->resource->{$value}] : [$key => $value]) ->when($usesSparseFieldset, fn ($attributes) => $attributes->only($sparseFieldset)) ->transform(fn ($value) => value($value, $request)) @@ -255,6 +264,11 @@ protected function compileResourceRelationshipUsingResolver( return; } + // Default loading stays batched across parents in prepareResourceRelationships(). + if (! empty($requestedRelationships) && ! $this->usesDefaultRelationshipLoading()) { + $this->loadMissingRequestedRelationships($request, $relatedModels, $resourceClass, $requestedRelationships); + } + $relationship = $resource->{$relationName}(); $isUnique = ! $relationship instanceof BelongsToMany; @@ -320,6 +334,82 @@ function ($uniqueKey) use ($relatedResource) { )]; } + /** + * Load the requested relationships that are missing on the given models at once. + * + * @internal + */ + public function loadMissingRequestedRelationshipsOn(JsonApiRequest $request, Collection $models): void + { + $requestedRelationships = (new Collection($this->requestedResourceRelationships($request))) + ->flatMap(fn ($relationship) => [ + $relationship, + ...array_map(fn ($nested) => $relationship . '.' . $nested, $this->requestedResourceRelationships($request, $relationship)), + ]) + ->all(); + + if (! empty($requestedRelationships)) { + $this->loadMissingRequestedRelationships($request, $models, static::class, $requestedRelationships); + } + } + + /** + * Load the requested relationships that are missing on the given related models. + */ + protected function loadMissingRequestedRelationships( + JsonApiRequest $request, + Collection $relatedModels, + ?string $resourceClass, + array $requestedRelationships + ): void { + // Eager loading uses the first model's class and connection for the whole group. + $relatedModels->groupBy(fn ($relatedModel) => serialize([$relatedModel::class, $relatedModel->getConnectionName()]))->each(function ($relatedModels) use ($request, $resourceClass, $requestedRelationships) { + $relatedResource = rescue(fn () => $relatedModels->first()->toResource($resourceClass), report: false); + + if (! $relatedResource instanceof JsonApiResource) { + return; + } + + $relatedModels = new EloquentCollection($relatedModels->all()); + + $resourceRelationships = (new Collection($relatedResource->toRelationships($request))) + ->map(fn ($value, $key) => is_int($key) ? new RelationResolver($value) : new RelationResolver($key, $value)) + ->keyBy(fn ($relationResolver) => $relationResolver->relationName) + ->only(array_map(fn ($relationship) => explode('.', $relationship, 2)[0], $requestedRelationships)); + + $relatedModels->loadMissing($resourceRelationships->keys()->all()); + + foreach ($resourceRelationships as $relationName => $relationResolver) { + $nestedRelationships = (new Collection($requestedRelationships)) + ->filter(fn ($relationship) => str_starts_with($relationship, $relationName . '.')) + ->map(fn ($relationship) => substr($relationship, strlen($relationName) + 1)) + ->values() + ->all(); + + if (empty($nestedRelationships)) { + continue; + } + + $this->loadMissingRequestedRelationships( + $request, + $relatedModels->toBase()->map->getRelation($relationName)->flatten(1)->filter(), + $relationResolver->resourceClass(), + $nestedRelationships, + ); + } + }); + } + + /** + * Determine whether the resource uses the framework's default relationship loader. + * + * @internal + */ + public function usesDefaultRelationshipLoading(): bool + { + return static::$loadsRelationshipsByDefault[static::class] ??= (new ReflectionMethod(static::class, 'loadMissingRequestedRelationships'))->class === self::class; + } + /** * Get the requested relationships for this resource or one of its relationships. */ diff --git a/src/http/src/Resources/JsonApi/JsonApiResource.php b/src/http/src/Resources/JsonApi/JsonApiResource.php index 90f84d533..02b9de895 100644 --- a/src/http/src/Resources/JsonApi/JsonApiResource.php +++ b/src/http/src/Resources/JsonApi/JsonApiResource.php @@ -250,6 +250,7 @@ public static function flushState(): void parent::flushState(); static::$jsonApiInformation = []; + static::$loadsRelationshipsByDefault = []; static::$maxRelationshipDepth = static::DEFAULT_MAX_RELATIONSHIP_DEPTH; } } diff --git a/tests/Http/Resources/JsonApi/JsonApiResourceTest.php b/tests/Http/Resources/JsonApi/JsonApiResourceTest.php index 9bc8030de..f00d89607 100644 --- a/tests/Http/Resources/JsonApi/JsonApiResourceTest.php +++ b/tests/Http/Resources/JsonApi/JsonApiResourceTest.php @@ -12,7 +12,9 @@ use Hypervel\Http\Resources\JsonApi\Exceptions\ResourceIdentificationException; use Hypervel\Http\Resources\JsonApi\JsonApiRequest; use Hypervel\Http\Resources\JsonApi\JsonApiResource; +use Hypervel\Support\Collection; use Hypervel\Tests\TestCase; +use JsonSerializable; use Override; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; @@ -114,6 +116,32 @@ public function testEmptySparseFieldsetOmitsResourceAttributes(): void ], $resource->resolveResourceData($request)); } + public function testJsonSerializableAttributesCanReturnACollection(): void + { + $resource = new class([]) extends JsonApiResourceWithCustomIdentifier { + /** + * Transform the resource into serializable attributes. + */ + #[Override] + public function toAttributes(Request $request): JsonSerializable + { + return new class implements JsonSerializable { + /** + * Return the resource's attribute values. + */ + public function jsonSerialize(): Collection + { + return new Collection(['title' => 'Post title']); + } + }; + } + }; + + $data = $resource->resolveResourceData(new JsonApiRequest); + + $this->assertSame(['title' => 'Post title'], (array) $data['attributes']); + } + public function testDefaultAttributesExcludeReservedAndDeclaredRelationshipNames(): void { $child = new class extends JsonApiResourceTestModel { diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php index 9ecdb990c..a3bc5afe7 100644 --- a/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Integration\Http\Resources\JsonApi; use Hypervel\Http\Resources\JsonApi\JsonApiRequest; +use Hypervel\Support\Facades\DB; use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Comment; use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Post; use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Profile; @@ -212,6 +213,25 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl ); } + public function testItEagerLoadsIncludesForAllResourcesAtOnce(): void + { + $users = User::factory()->times(3)->create(); + + foreach ($users as $user) { + Profile::factory()->create(['user_id' => $user->getKey()]); + Post::factory()->create(['user_id' => $user->getKey()]); + } + + DB::enableQueryLog(); + + $this->getJson('/posts?' . http_build_query(['include' => 'author.profile'])) + ->assertOk() + ->assertJsonCount(6, 'included'); + + // One query for the count, one for the posts, one for the authors and one for their profiles... + $this->assertCount(4, DB::getQueryLog()); + } + #[TestWith([false])] #[TestWith([true])] public function testNestedRelationshipsAreBatchedAcrossThePageWithoutReorderingIncludedResources(bool $includeProfile): void diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipConnectionsTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipConnectionsTest.php index 949a5e71f..73beaf5a8 100644 --- a/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipConnectionsTest.php +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipConnectionsTest.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Filesystem\Filesystem; use Hypervel\Http\Resources\JsonApi\JsonApiRequest; +use Hypervel\Support\Collection; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Comment; @@ -14,6 +15,7 @@ use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\UserResource; use Override; use PDO; +use PHPUnit\Framework\Attributes\TestWith; class JsonApiRelationshipConnectionsTest extends TestCase { @@ -59,7 +61,9 @@ protected function tearDown(): void } } - public function testNestedRelationshipBatchesPreserveEachModelsConnection(): void + #[TestWith([false])] + #[TestWith([true])] + public function testNestedRelationshipBatchesPreserveEachModelsConnection(bool $customLoader): void { $first = (new Comment)->setConnection('first')->forceFill([ 'id' => 1, 'user_id' => 1, 'content' => 'First comment', @@ -71,7 +75,22 @@ public function testNestedRelationshipBatchesPreserveEachModelsConnection(): voi 'id' => 99, 'name' => 'Parent', 'email' => 'parent@example.com', ])->setRelation('comments', $first->newCollection([$first, $second])); - (new UserResource($user))->resolve(JsonApiRequest::create('/?include=comments.commenter')); + $resource = $customLoader ? new class($user) extends UserResource { + /** + * Delegate custom loading to the connection-aware default implementation. + */ + #[Override] + protected function loadMissingRequestedRelationships( + JsonApiRequest $request, + Collection $relatedModels, + ?string $resourceClass, + array $requestedRelationships + ): void { + parent::loadMissingRequestedRelationships($request, $relatedModels, $resourceClass, $requestedRelationships); + } + } : new UserResource($user); + + $resource->resolve(JsonApiRequest::create('/?include=comments.commenter')); $this->assertSame('first', $first->getRelation('commenter')->name); $this->assertSame('second', $second->getRelation('commenter')->name); diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipLoadingTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipLoadingTest.php new file mode 100644 index 000000000..79405463e --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipLoadingTest.php @@ -0,0 +1,211 @@ +count(2)->create(); + foreach ($users as $user) { + foreach (['published', 'draft'] as $content) { + $post = Post::factory()->create(['user_id' => $user->getKey()]); + Comment::factory()->create(['user_id' => $user->getKey(), 'post_id' => $post->getKey(), 'content' => $content]); + } + } + + $collection = new AnonymousResourceCollection(new BaseCollection([ + new ParentSpecificUserResource($users[0], 'published'), + new ParentSpecificUserResource($users[1], 'draft'), + ]), ParentSpecificUserResource::class); + + $response = $collection->toResponse(JsonApiRequest::create('/?include=posts.comments'))->getData(true); + $comments = array_values(array_filter($response['included'], fn ($entry) => $entry['type'] === 'comments')); + $this->assertSame(['published', 'draft'], array_column(array_column($comments, 'attributes'), 'content')); + } + + public function testParentResourceHookControlsItsNestedRelationshipLoading(): void + { + $user = User::factory()->create(); + $post = Post::factory()->create(['user_id' => $user->getKey()]); + Comment::factory()->create(['user_id' => $user->getKey(), 'post_id' => $post->getKey(), 'content' => 'published']); + $otherPost = Post::factory()->create(['user_id' => $user->getKey()]); + Comment::factory()->create(['user_id' => $user->getKey(), 'post_id' => $otherPost->getKey(), 'content' => 'draft']); + + $resource = new class($user) extends UserResource { + public array $calls = []; + + /** + * Constrain comments before the default loader runs. + */ + #[Override] + protected function loadMissingRequestedRelationships( + JsonApiRequest $request, + BaseCollection $relatedModels, + ?string $resourceClass, + array $requestedRelationships + ): void { + $this->calls[] = [$relatedModels->first()::class, $relatedModels->count(), $resourceClass, $requestedRelationships]; + + if ($relatedModels->first() instanceof Post) { + (new Collection($relatedModels->all()))->loadMissing([ + 'comments' => fn ($query) => $query->where('content', 'published'), + ]); + } + parent::loadMissingRequestedRelationships($request, $relatedModels, $resourceClass, $requestedRelationships); + } + }; + + $response = $resource->toResponse(JsonApiRequest::create('/?include=posts.comments'))->getData(true); + $comments = array_values(array_filter($response['included'], fn ($entry) => $entry['type'] === 'comments')); + $this->assertSame(['published'], array_column(array_column($comments, 'attributes'), 'content')); + $this->assertSame([[Post::class, 2, null, ['comments']]], $resource->calls); + } + + public function testCollectionHookConstrainsLoadingBeforeLinkageAndAttributes(): void + { + $users = User::factory()->count(2)->create(); + foreach ($users as $user) { + Comment::factory()->create(['user_id' => $user->getKey(), 'content' => 'public']); + Comment::factory()->create(['user_id' => $user->getKey(), 'content' => 'private']); + } + + $resources = new class($users, UserResource::class) extends AnonymousResourceCollection { + public int $calls = 0; + + /** + * Constrain comments for the whole collection before preparing resources. + */ + #[Override] + protected function loadMissingRequestedRelationships(Request $request): void + { + ++$this->calls; + (new Collection($this->collection->pluck('resource')->all())) + ->loadMissing(['comments' => fn ($query) => $query->where('content', 'public')]); + parent::loadMissingRequestedRelationships($request); + } + }; + + DB::enableQueryLog(); + $response = $resources->toResponse(JsonApiRequest::create('/?include=comments'))->getData(true); + + $this->assertCount(2, $response['included']); + $this->assertSame(['public', 'public'], array_column(array_column($response['included'], 'attributes'), 'content')); + $this->assertCount(1, $response['data'][0]['relationships']['comments']['data']); + $this->assertCount(1, $response['data'][1]['relationships']['comments']['data']); + $this->assertCount(1, DB::getQueryLog()); + $this->assertSame(1, $resources->calls); + } + + public function testSubclassCanLoadNestedRelationshipsWithoutInvokingUndeclaredMethods(): void + { + $user = User::factory()->create(); + $post = Post::factory()->create(['user_id' => $user->getKey()]); + Comment::factory()->create(['user_id' => $user->getKey(), 'post_id' => $post->getKey()]); + $resource = new class($user) extends UserResource { + /** + * Load requested relationships through the protected extension point. + */ + public function loadRequestedRelationships(BaseCollection $models): void + { + $this->loadMissingRequestedRelationships( + new JsonApiRequest, + $models, + UserResource::class, + ['posts', 'posts.comments', 'posts.truncate', 'truncate'], + ); + } + }; + + DB::enableQueryLog(); + $resource->loadRequestedRelationships(new Collection([$user])); + + $this->assertCount(2, DB::getQueryLog()); + $this->assertTrue($user->posts->first()->relationLoaded('comments')); + $this->assertSame(1, Comment::count()); + } + + public function testCollectionPreservesResourceLoaderCallOrder(): void + { + $users = User::factory()->count(2)->create(); + foreach ($users as $user) { + $post = Post::factory()->create(['user_id' => $user->getKey()]); + Comment::factory()->create(['user_id' => $user->getKey(), 'post_id' => $post->getKey()]); + } + + $first = new class($users[0]) extends UserResource { + public array $calls = []; + + /** + * Record the receiver and arguments while retaining default loading. + */ + #[Override] + protected function loadMissingRequestedRelationships( + JsonApiRequest $request, + BaseCollection $relatedModels, + ?string $resourceClass, + array $requestedRelationships + ): void { + $this->calls[] = [$relatedModels->first()::class, $relatedModels->count(), $resourceClass, $requestedRelationships]; + + parent::loadMissingRequestedRelationships($request, $relatedModels, $resourceClass, $requestedRelationships); + } + }; + $second = new ($first::class)($users[1]); + $collection = new AnonymousResourceCollection(new BaseCollection([$first, $second]), $first::class); + + DB::enableQueryLog(); + $collection->toResponse(JsonApiRequest::create('/?include=posts.comments')); + + $this->assertCount(2, DB::getQueryLog()); + $this->assertSame([ + [User::class, 2, $first::class, ['posts', 'posts.comments']], + [Post::class, 2, null, ['comments']], + [Post::class, 1, null, ['comments']], + ], $first->calls); + $this->assertSame([[Post::class, 1, null, ['comments']]], $second->calls); + } +} + +class ParentSpecificUserResource extends UserResource +{ + /** + * Create a resource with its own comment constraint. + */ + public function __construct(mixed $resource, private string $allowedContent) + { + parent::__construct($resource); + } + + /** + * Load comments using this parent's constraint. + */ + #[Override] + protected function loadMissingRequestedRelationships( + JsonApiRequest $request, + BaseCollection $relatedModels, + ?string $resourceClass, + array $requestedRelationships + ): void { + if ($relatedModels->first() instanceof Post) { + (new Collection($relatedModels->all()))->loadMissing([ + 'comments' => fn ($query) => $query->where('content', $this->allowedContent), + ]); + } + parent::loadMissingRequestedRelationships($request, $relatedModels, $resourceClass, $requestedRelationships); + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php index e64141f77..d592b897a 100644 --- a/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php @@ -9,6 +9,9 @@ use Hypervel\Http\Request; use Hypervel\Http\Resources\JsonApi\JsonApiRequest; use Hypervel\Http\Resources\JsonApi\JsonApiResource; +use Hypervel\Support\Facades\DB; +use Hypervel\Support\Facades\Route; +use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\ArrayBackedJsonApiResource; use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Comment; use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Post; use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\PostResource; @@ -467,9 +470,71 @@ public function testItCanResolveNestedRelationshipThroughClosureReturningResourc )); } - // Upstream testItIgnoresNestedIncludesThatAreNotResourceRelationships and - // testItIgnoresDeeplyNestedIncludesThatAreNotResourceRelationships are replaced by - // testRelativeRelationshipSelectionIsRestrictedToResourceDeclarations in the unit JsonApiResourceTest. + public function testItIgnoresNestedIncludesThatAreNotResourceRelationships(): void + { + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + + Comment::factory()->create([ + 'user_id' => $user->getKey(), + ]); + + Comment::factory()->create([ + 'user_id' => $otherUser->getKey(), + ]); + + $this->getJson("/users/{$user->getKey()}?" . http_build_query(['include' => 'comments.truncate'])) + ->assertOk() + ->assertJsonCount(1, 'included'); + + $this->assertSame(2, Comment::count()); + } + + public function testItIgnoresDeeplyNestedIncludesThatAreNotResourceRelationships(): void + { + $user = User::factory()->create(); + $post = Post::factory()->create([ + 'user_id' => $user->getKey(), + ]); + + Comment::factory()->create([ + 'post_id' => $post->getKey(), + 'user_id' => $user->getKey(), + ]); + + Comment::factory()->create(); + + $this->getJson("/users/{$user->getKey()}?" . http_build_query(['include' => 'posts.comments.truncate'])) + ->assertOk() + ->assertJsonCount(2, 'included'); + + $this->assertSame(2, Comment::count()); + } + + public function testItEagerLoadsNestedIncludesForAllRelatedModelsAtOnce(): void + { + $user = User::factory()->create(); + + $posts = Post::factory()->count(3)->create([ + 'user_id' => $user->getKey(), + ]); + + foreach ($posts as $post) { + Comment::factory()->create([ + 'post_id' => $post->getKey(), + 'user_id' => $user->getKey(), + ]); + } + + DB::enableQueryLog(); + + $this->getJson("/users/{$user->getKey()}?" . http_build_query(['include' => 'posts.comments'])) + ->assertOk() + ->assertJsonCount(6, 'included'); + + // One query for the user, one for the posts and one for the comments of all posts... + $this->assertCount(3, DB::getQueryLog()); + } #[TestWith(['posts.comments', 3])] #[TestWith(['posts.comments.commenter', 4])] @@ -767,6 +832,39 @@ public function testTopLevelArrayBackedCustomResourceCanGenerateJsonApiResponse( ->assertJsonMissingPath('included'); } + public function testAttributesCanBeConditionallyMerged(): void + { + Route::get('things-with-merged-attributes/{id}', function (string $id) { + return new class(['id' => (int) $id, 'name' => 'test', 'secret' => 'hidden', 'notes' => 'internal']) extends ArrayBackedJsonApiResource { + /** + * Transform the resource into attributes. + */ + #[Override] + public function toAttributes(Request $request): array + { + return [ + 'name' => $this->resource['name'], + $this->mergeWhen(false, ['secret' => $this->resource['secret']]), + $this->mergeWhen(true, ['notes' => $this->resource['notes']]), + ]; + } + }; + }); + + $this->getJson('/things-with-merged-attributes/99') + ->assertHeader('Content-type', 'application/vnd.api+json') + ->assertExactJson([ + 'data' => [ + 'id' => '99', + 'type' => 'things', + 'attributes' => [ + 'name' => 'test', + 'notes' => 'internal', + ], + ], + ]); + } + public function testSameModelWithTheSameResourceTypeIsDeduplicated(): void { $user = User::factory()->create(); From b31dcec528f638b57055fe57c6413e88a1ead144 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:29 +0000 Subject: [PATCH 17/31] Qualify the deleted column when restoring joined models Port laravel/framework#61998 (ce7a0de5aed01ed1e45791a78def2343e6a33abc). Use the existing join-aware deleted-column lookup for restore, matching delete and avoiding ambiguous columns when the joined model also uses soft deletes. Port the through-parent restore regression and fixtures. Complete through-relation and soft-delete scope tests pass. The regression reproduces the prior ambiguous-column failure on MySQL and passes with the fix. --- .../src/Eloquent/SoftDeletingScope.php | 2 +- .../Database/EloquentHasManyThroughTest.php | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/database/src/Eloquent/SoftDeletingScope.php b/src/database/src/Eloquent/SoftDeletingScope.php index 998947c71..13da16b9e 100644 --- a/src/database/src/Eloquent/SoftDeletingScope.php +++ b/src/database/src/Eloquent/SoftDeletingScope.php @@ -73,7 +73,7 @@ protected function addRestore(Builder $builder): void $builder->macro('restore', function (Builder $builder) { $builder->withTrashed(); - return $builder->update([$builder->getModel()->getDeletedAtColumn() => null]); + return $builder->update([$this->getDeletedAtColumn($builder) => null]); }); } diff --git a/tests/Integration/Database/EloquentHasManyThroughTest.php b/tests/Integration/Database/EloquentHasManyThroughTest.php index d757380db..8594fcae3 100644 --- a/tests/Integration/Database/EloquentHasManyThroughTest.php +++ b/tests/Integration/Database/EloquentHasManyThroughTest.php @@ -42,6 +42,7 @@ protected function afterRefreshingDatabase(): void Schema::create('products', function (Blueprint $table) { $table->increments('id'); $table->integer('category_id'); + $table->softDeletes(); }); Schema::create('articles', function (Blueprint $table) { @@ -493,6 +494,21 @@ public function testCanReplicateModelLoadedThroughHasManyThrough(): void $newArticle->save(); } + public function testRestoreWithSoftDeletingThroughParent(): void + { + $category = Category::create(); + $subCategory = Category::create(['parent_id' => $category->id]); + $product = Product::create(['category_id' => $subCategory->id]); + + $category->subProducts()->delete(); + + $this->assertTrue($product->fresh()->trashed()); + + $category->subProducts()->onlyTrashed()->restore(); + + $this->assertFalse($product->fresh()->trashed()); + } + public function testOne(): void { $team = Team::create(); @@ -679,6 +695,8 @@ public function latestSubProduct(): HasOneThrough class Product extends Model { + use SoftDeletes; + public bool $timestamps = false; protected array $guarded = []; From 8678ac58eb7d8bf64beacdc49b591aaafe50e4a7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:44 +0000 Subject: [PATCH 18/31] Support path-based MIME detection and Swoole FTP installations Port laravel/framework#61972 (c90a0c8d51764149c5b52b2bb8c3dc71fd3cf189). Pass detect_mime_type_using_path to both remote adapters, normalize the optional boolean at disk construction and retain it in pooled configuration. Add both adapters as development dependencies and remove obsolete missing-class suppressions. Declare the development root as providing ext-ftp because Swoole supplies the actual FTP functions without registering that extension. Published packages make no such assertion. Document the Swoole build option, native-extension conflict, capability check and application-root Composer declaration; remove the resolved installation todo. Gate FTP tests on ftp_connect and assert that capability in the CI image recipe. Both complete filesystem test files, normal Composer install/platform checks and source analysis pass. The capability assertion passes on both existing PHP CI images. --- .github/docker/ci/Dockerfile | 3 +++ composer.json | 5 +++++ docs/todo.md | 4 ---- src/docs/filesystem.md | 14 +++++++++++++- src/docs/installation.md | 2 +- src/docs/porting-from-laravel.md | 2 ++ src/filesystem/README.md | 2 ++ src/filesystem/composer.json | 1 - src/filesystem/src/FilesystemAdapter.php | 2 +- src/filesystem/src/FilesystemManager.php | 18 +++++++++++++----- tests/Filesystem/FilesystemAdapterTest.php | 8 ++------ tests/Filesystem/FilesystemManagerTest.php | 21 +++++++++++++++++++++ 12 files changed, 63 insertions(+), 19 deletions(-) diff --git a/.github/docker/ci/Dockerfile b/.github/docker/ci/Dockerfile index 2d205226b..aa06494e9 100644 --- a/.github/docker/ci/Dockerfile +++ b/.github/docker/ci/Dockerfile @@ -95,6 +95,9 @@ RUN set -eux; \ RUN printf '%s\n' 'swoole.use_shortname=Off' > /usr/local/etc/php/conf.d/99-swoole-shortname.ini +# The development Composer root declares the FTP capability supplied by Swoole. +RUN php -r 'if (! function_exists("ftp_connect")) { fwrite(STDERR, "Swoole FTP support is required for filesystem tests.\n"); exit(1); }' + RUN set -eux; \ test "$(php -r 'echo PHP_MAJOR_VERSION, ".", PHP_MINOR_VERSION;')" = "${PHP_VERSION}"; \ test "$(php -r 'echo phpversion("swoole");')" = "${SWOOLE_VERSION}"; \ diff --git a/composer.json b/composer.json index 1c8acdc95..780611a51 100644 --- a/composer.json +++ b/composer.json @@ -126,7 +126,9 @@ "fakerphp/faker": "^1.24", "friendsofphp/php-cs-fixer": "^3.57.2", "intervention/image": "^4.2", + "league/flysystem-ftp": "^3.36", "league/flysystem-google-cloud-storage": "^3.0", + "league/flysystem-sftp-v3": "^3.36", "meilisearch/meilisearch-php": "^1.16", "mockery/mockery": "^1.6.15", "opis/json-schema": "^2.6.0", @@ -145,6 +147,9 @@ "typesense/typesense-php": "^5.2", "web-token/jwt-library": "^4.2.3" }, + "provide": { + "ext-ftp": "*" + }, "replace": { "hypervel/ai": "self.version", "hypervel/api-client": "self.version", diff --git a/docs/todo.md b/docs/todo.md index f3cd08250..4c87f99b8 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -41,10 +41,6 @@ - Add native return types to the `test*` methods that lack them: about 5,600 methods in 490 files. `AGENTS.md` requires `: void` on test methods; a test that returns a value for `#[Depends]` takes its actual return type instead. As with method titles, untyped methods in existing files lead new tests to copy them, so make the change as one approved sweep and enforce it with the same automated check. - Integrate the resolution of [PHPUnit #7039](https://github.com/sebastianbergmann/phpunit/issues/7039) into `RunTestsInCoroutine` once PHPUnit 13.5 is released ([scheduled for December 4, 2026](https://github.com/sebastianbergmann/phpunit/blob/main/ChangeLog-13.5.md)); it adds `TestCase::suspendOutputBuffering()` and `resumeOutputBuffering()` ([PR #7040](https://github.com/sebastianbergmann/phpunit/pull/7040)). Follow the merged upstream design, including calling `parent::invokeTestMethod()` if capture is handled there. Add regression coverage for output expectations, flushed output and buffer cleanup; update the testing guidance and reassess output-related coroutine opt-outs. Lift the `~13.4.3` PHPUnit constraints in the root, Testbench and dogfood manifests, the testing package's suggestion and the `hypervel/hypervel` skeleton once Testbench's PHPUnit integration is verified on 13.5. Do not add a private-buffer adapter, maintained fork or ongoing Composer/vendor patches. Prefer supported subclass or method overrides; use AOP only as an absolute last resort. Never document a confirmed defect as an accepted limitation. -## Filesystem - -- Investigate FTP support with Swoole's built-in coroutine FTP implementation. It supplies `ftp_*` functions but is not discoverable as `ext-ftp`, so Composer rejects `league/flysystem-ftp` and `RequiresPhpExtension('ftp')` skips the driver test. Resolve normal development and production installation without bypassing dependency checks, then add the adapter to root `require-dev`, use a test requirement that accepts either FTP implementation, and update the installation guidance. Basic transfers through Hypervel and Flysystem have been verified inside a Swoole coroutine. - ## AI and MCP - Revisit conversation-history query plans when production evidence or newer database versions warrant it; this does not block the AI port. Reproduce with the stock migration, one oldest conversation containing 10,000 messages, then 999 other participants/conversations with 20 newer messages each. After updating statistics, inspect actual execution of `DatabaseConversationStore::latestConversationId()`, `getLatestConversationMessages(..., 100)` and the first/subsequent cursor pages. MySQL 9.5 and PostgreSQL chose reverse primary-key scans, examining 20,080 rows for 100 messages despite `(conversation_id, id)` being available; smaller targets at roughly 1% of the table used conversation indexes and did not scan other conversations. MariaDB 10.11 read history efficiently but filtered cursor IDs after seeking only the conversation, scanning preceding pages within it; SQLite 3.45 used both index bounds. The query shape matches Laravel's. Selecting IDs first, including through a bounded subquery joined back to messages, did not fix the MySQL/PostgreSQL plans. Include the similar latest-row queries in `pendingApprovalsFor()` and `pausedRowFor()` when investigating; their execution plans have not been measured. Test an index-derived scalar upper bound on message IDs with both oldest-only and interleaved conversations. A stored pointer needs maintenance across all supported writes; a stale low pointer would hide messages. Keep current queries and indexes unless a measured improvement preserves other workloads, configured/partitioned schemas and pagination behavior without unnecessary round trips or database-specific machinery. diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md index 66ab9f626..9960d16f8 100644 --- a/src/docs/filesystem.md +++ b/src/docs/filesystem.md @@ -133,7 +133,17 @@ You may also supply a callable `credentials` provider, which takes precedence ov #### FTP Driver Configuration -Before using the FTP driver, you will need to install the Flysystem FTP package via the Composer package manager: +The FTP driver uses Swoole's coroutine-aware FTP support. Build Swoole with `--enable-swoole-ftp`, which is disabled by default, and disable PHP's separate `ftp` extension to avoid a conflict. Verify that `function_exists('ftp_connect')` returns `true`. + +Swoole supplies the FTP functions without registering as `ext-ftp`. After enabling this support, declare it in your application's `composer.json` so Composer can resolve the adapter's requirement: + +```json +"provide": { + "ext-ftp": "*" +} +``` + +Then install the Flysystem FTP package: ```shell composer require league/flysystem-ftp "^3.0" @@ -726,6 +736,8 @@ The MIME type of a given file may be obtained via the `mimeType` method: $mime = Storage::mimeType('file.jpg'); ``` +For FTP and SFTP disks, you may set the `detect_mime_type_using_path` configuration option to `true` to determine MIME types from file names without downloading their contents. By default, these disks inspect the file contents. + The checksum of a given file may be obtained via the `checksum` method: ```php diff --git a/src/docs/installation.md b/src/docs/installation.md index fea24be7e..32bb5efa9 100644 --- a/src/docs/installation.md +++ b/src/docs/installation.md @@ -59,7 +59,7 @@ You may install Swoole using [PIE](https://github.com/php/pie): pie install "swoole/swoole:^6.3.0@RC" --enable-openssl --enable-swoole-curl --enable-swoole-sqlite --enable-swoole-pgsql ``` -Swoole must be built with OpenSSL and native cURL support. The SQLite and PostgreSQL options are required to use those databases. Each option needs the matching development library (OpenSSL, libcurl, SQLite, or libpq) when Swoole is compiled. +Swoole must be built with OpenSSL and native cURL support. The SQLite and PostgreSQL options are required to use those databases. Each option needs the matching development library (OpenSSL, libcurl, SQLite, or libpq) when Swoole is compiled. Applications using FTP disks also need `--enable-swoole-ftp`; see [FTP configuration](/docs/{{version}}/filesystem#ftp-driver-configuration). Once the Homebrew formula provides a Swoole version that meets the requirement above, macOS developers may install Swoole via Homebrew instead. The formula already includes the required build options. Replace `8.4` with your installed PHP version: diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index cae6f3a6a..b90ab4a35 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -776,6 +776,8 @@ Hypervel's `Filesystem::hash()` method uses `xxh128` by default. Pass `md5` expl Custom filesystem contract implementations must also provide `fileExists()` and `directoryExists()`. The existing `exists()` method continues to accept either a file or a directory. See [retrieving files](/docs/{{version}}/filesystem#retrieving-files). +FTP disks use Swoole's FTP support instead of PHP's separate `ftp` extension. Enable it when building Swoole and declare the supplied capability in your application's Composer configuration; see [FTP configuration](/docs/{{version}}/filesystem#ftp-driver-configuration). + S3, Google Cloud Storage, FTP, SFTP, and poolable custom disks are pooled, so they do not hand out objects that outlive an operation. Replace `getAdapter()` and `getDriver()` with `withAdapter()` and `withDriver()`, and an S3 or Google Cloud Storage disk's `getClient()` with `withClient()`; each callback receives the borrowed object. Methods a pooled disk does not support throw instead of forwarding to the driver. See [driver pools](/docs/{{version}}/filesystem#driver-pools). If you override `FilesystemManager::formatS3Config()`, named credential providers remain configuration data there. Customize their resolution through `createS3Client()` instead. diff --git a/src/filesystem/README.md b/src/filesystem/README.md index a4c868e15..8fbef30e5 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -19,6 +19,8 @@ Hypervel pools S3 and Google Cloud Storage SDK clients rather than complete disk On read-through disks, raw `listContents()` results from pooled, S3 and Google Cloud Storage sides are fully loaded instead of streamed. +FTP disks use Swoole's coroutine-aware FTP support instead of PHP's separate `ftp` extension. See the [build and Composer requirements](https://hypervel.org/docs/filesystem#ftp-driver-configuration). + Filesystem construction differs from Laravel in how it carries logical disk identity. `callCustomCreator()` accepts the logical disk name as an optional second parameter, so existing one-argument calls remain valid while overrides must adopt the parameter. The public `build()` method uses a logical-name-aware construction path rather than the protected `resolve()` method because anonymous builds must pass a null name to creators; `resolve()` remains the configured-disk seam. `createScopedDriver()` resolves its prepared descriptor directly rather than through `build()`, so override `createScopedDriver()` for scoped construction customization. Customize on-demand construction through `Storage::extend()` or the public driver creator methods. Creator callbacks may accept the nullable name as a third argument after the application and configuration, while existing two-argument callbacks remain valid. Hypervel carries the name through scoped reconstruction and whole-driver pool fingerprints. A matching explicit fingerprint declares that every construction detail is equivalent, including serving-route ownership. `formatS3Config()` leaves named credential providers as configuration data so they can define pool identity. Override `createS3Client()` to customize provider resolution; see the [porting guide](https://hypervel.org/docs/porting-from-laravel#filesystem). diff --git a/src/filesystem/composer.json b/src/filesystem/composer.json index b2d0b9b74..38bbdd035 100644 --- a/src/filesystem/composer.json +++ b/src/filesystem/composer.json @@ -49,7 +49,6 @@ }, "suggest": { "ext-fileinfo": "Required to use the Filesystem class.", - "ext-ftp": "Required to use the Flysystem FTP driver.", "hypervel/image": "Required to create images from stored files (^0.4).", "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", "league/flysystem-google-cloud-storage": "Required to use the Flysystem Google Cloud Storage driver (^3.25.1).", diff --git a/src/filesystem/src/FilesystemAdapter.php b/src/filesystem/src/FilesystemAdapter.php index 2d6bffd6a..ec8c3bdbf 100644 --- a/src/filesystem/src/FilesystemAdapter.php +++ b/src/filesystem/src/FilesystemAdapter.php @@ -834,7 +834,7 @@ public function url(string $path): string if (method_exists($this->driver, 'getUrl')) { return $this->driver->getUrl($path); } - if ($adapter instanceof FtpAdapter || $adapter instanceof SftpAdapter) { // @phpstan-ignore class.notFound, class.notFound + if ($adapter instanceof FtpAdapter || $adapter instanceof SftpAdapter) { return $this->getFtpUrl($path); } if ($adapter instanceof LocalAdapter) { diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php index 4cf2d2374..1dd164b86 100644 --- a/src/filesystem/src/FilesystemManager.php +++ b/src/filesystem/src/FilesystemManager.php @@ -412,9 +412,12 @@ public function createFtpDriver(array $config): Filesystem $config['root'] = ''; } - $adapter = new FtpAdapter(FtpConnectionOptions::fromArray($config)); // @phpstan-ignore class.notFound, class.notFound + $adapter = new FtpAdapter( + FtpConnectionOptions::fromArray($config), + detectMimeTypeUsingPath: (bool) ($config['detect_mime_type_using_path'] ?? false), + ); - return new FilesystemAdapter($this->createFlysystem($adapter, $config), $adapter, $config); // @phpstan-ignore argument.type, argument.type + return new FilesystemAdapter($this->createFlysystem($adapter, $config), $adapter, $config); } /** @@ -422,7 +425,7 @@ public function createFtpDriver(array $config): Filesystem */ public function createSftpDriver(array $config): Filesystem { - $provider = SftpConnectionProvider::fromArray($config); // @phpstan-ignore class.notFound + $provider = SftpConnectionProvider::fromArray($config); $root = $config['root'] ?? ''; @@ -430,9 +433,14 @@ public function createSftpDriver(array $config): Filesystem $config['permissions'] ?? [] ); - $adapter = new SftpAdapter($provider, $root, $visibility); // @phpstan-ignore class.notFound + $adapter = new SftpAdapter( + $provider, + $root, + $visibility, + detectMimeTypeUsingPath: (bool) ($config['detect_mime_type_using_path'] ?? false), + ); - return new FilesystemAdapter($this->createFlysystem($adapter, $config), $adapter, $config); // @phpstan-ignore argument.type, argument.type + return new FilesystemAdapter($this->createFlysystem($adapter, $config), $adapter, $config); } /** diff --git a/tests/Filesystem/FilesystemAdapterTest.php b/tests/Filesystem/FilesystemAdapterTest.php index add7e2e8f..cc6d8c8d3 100644 --- a/tests/Filesystem/FilesystemAdapterTest.php +++ b/tests/Filesystem/FilesystemAdapterTest.php @@ -40,7 +40,7 @@ use League\Flysystem\UnableToRetrieveMetadata; use League\Flysystem\UnableToWriteFile; use Mockery as m; -use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use PHPUnit\Framework\Attributes\RequiresFunction; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\ExpectationFailedException; use RuntimeException; @@ -1090,13 +1090,9 @@ public function testPutFileWithoutPath(): void $this->assertSame('normal file content', $filesystemAdapter->read($storagePath)); } - #[RequiresPhpExtension('ftp')] + #[RequiresFunction('ftp_connect')] public function testCreateFtpDriver(): void { - if (! class_exists(FtpAdapter::class)) { - $this->markTestSkipped('league/flysystem-ftp is not installed.'); - } - $filesystem = new FilesystemManager(m::mock(Container::class)); $driver = $filesystem->createFtpDriver([ diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php index 181398b21..4daeb1bf3 100644 --- a/tests/Filesystem/FilesystemManagerTest.php +++ b/tests/Filesystem/FilesystemManagerTest.php @@ -411,6 +411,27 @@ public function testConfiguredOndemandDiskNameIsReserved(): void $filesystem->disk('ondemand'); } + #[TestWith(['sftp'])] + #[TestWith(['ftp'])] + public function testCanBuildRemoteDisksThatDetectMimeTypesUsingThePath(string $driver): void + { + if ($driver === 'ftp' && ! function_exists('ftp_connect')) { + $this->markTestSkipped('Swoole FTP support is not enabled.'); + } + + $filesystem = new FilesystemManager($this->getContainer()); + + $disk = $filesystem->build([ + 'driver' => $driver, + 'host' => 'localhost', + 'username' => 'hypervel', + 'password' => 'secret', + 'detect_mime_type_using_path' => true, + ]); + + $this->assertSame('text/csv', $disk->mimeType('export.csv')); + } + public function testCanBuildReadOnlyDisks(): void { $filesystem = new FilesystemManager($this->getContainer()); From 84c44ffb31a4078834f84b5bc817f9f648b8ef53 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:44 +0000 Subject: [PATCH 19/31] Allow queue:forget to remove multiple failed jobs Port laravel/framework#61901 (c3e586825b0d459169f65702ddcc980b5b541e8a). Accept multiple IDs and report the result for each, continuing after missing IDs while returning failure if any could not be removed. Preserve the existing successful command return behavior. Port all command regressions, update the generated signature fixture and document multiple-ID usage. The command and complete signature test files pass. --- src/docs/queues.md | 6 ++ src/queue/src/Console/ForgetFailedCommand.php | 18 +++- tests/Console/Fixtures/command_signatures.php | 6 +- tests/Queue/QueueForgetFailedCommandTest.php | 93 +++++++++++++++++++ 4 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 tests/Queue/QueueForgetFailedCommandTest.php diff --git a/src/docs/queues.md b/src/docs/queues.md index 11160bd38..2ec357131 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -3466,6 +3466,12 @@ If you would like to delete a failed job, you may use the `queue:forget` command php artisan queue:forget 91401d2c-0784-4f43-824c-34f94a33c24d ``` +You may delete several failed jobs by passing multiple IDs: + +```shell +php artisan queue:forget 5 6 7 +``` + > [!NOTE] > When using [Horizon](/docs/{{version}}/horizon), you should use the `horizon:forget` command to delete a failed job instead of the `queue:forget` command. diff --git a/src/queue/src/Console/ForgetFailedCommand.php b/src/queue/src/Console/ForgetFailedCommand.php index 6536d1afd..24361e700 100644 --- a/src/queue/src/Console/ForgetFailedCommand.php +++ b/src/queue/src/Console/ForgetFailedCommand.php @@ -14,7 +14,7 @@ class ForgetFailedCommand extends Command /** * The console command signature. */ - protected ?string $signature = 'queue:forget {id : The ID of the failed job}'; + protected ?string $signature = 'queue:forget {id* : The IDs of the failed jobs}'; /** * The console command description. @@ -26,11 +26,19 @@ class ForgetFailedCommand extends Command */ public function handle(): ?int { - if ($this->hypervel->make(FailedJobProviderInterface::class)->forget($this->argument('id'))) { - $this->components->info('Failed job deleted successfully.'); - } else { - $this->components->error('No failed job matches the given ID.'); + $missing = false; + foreach ((array) $this->argument('id') as $id) { + if ($this->hypervel->make(FailedJobProviderInterface::class)->forget($id)) { + $this->components->info("Failed job [{$id}] deleted successfully."); + } else { + $this->components->error("No failed job matches the ID [{$id}]."); + + $missing = true; + } + } + + if ($missing) { return self::FAILURE; } diff --git a/tests/Console/Fixtures/command_signatures.php b/tests/Console/Fixtures/command_signatures.php index 6eff29c6a..83db31378 100644 --- a/tests/Console/Fixtures/command_signatures.php +++ b/tests/Console/Fixtures/command_signatures.php @@ -3494,9 +3494,9 @@ [ 'name' => 'id', 'mode' => 'required', - 'isArray' => false, - 'default' => null, - 'description' => 'The ID of the failed job', + 'isArray' => true, + 'default' => [], + 'description' => 'The IDs of the failed jobs', ], ], ], diff --git a/tests/Queue/QueueForgetFailedCommandTest.php b/tests/Queue/QueueForgetFailedCommandTest.php new file mode 100644 index 000000000..4c8c47f1f --- /dev/null +++ b/tests/Queue/QueueForgetFailedCommandTest.php @@ -0,0 +1,93 @@ +shouldReceive('forget')->with('5')->andReturnTrue(); + $container->instance(FailedJobProviderInterface::class, $failer); + + $command = new ForgetFailedCommand; + $command->setHypervel($container); + + $exitCode = $command->run(new ArrayInput(['id' => ['5']]), $output = new BufferedOutput); + + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('Failed job [5] deleted successfully.', $output->fetch()); + } + + public function testItForgetsMultipleJobs(): void + { + $container = new Application; + $failer = m::mock(FailedJobProviderInterface::class); + $failer->shouldReceive('forget')->with('5')->andReturnTrue(); + $failer->shouldReceive('forget')->with('6')->andReturnTrue(); + $container->instance(FailedJobProviderInterface::class, $failer); + + $command = new ForgetFailedCommand; + $command->setHypervel($container); + + $exitCode = $command->run(new ArrayInput(['id' => ['5', '6']]), $output = new BufferedOutput); + + $this->assertSame(Command::SUCCESS, $exitCode); + + $output = $output->fetch(); + + $this->assertStringContainsString('Failed job [5] deleted successfully.', $output); + $this->assertStringContainsString('Failed job [6] deleted successfully.', $output); + } + + public function testItFailsWhenAJobCannotBeFound(): void + { + $container = new Application; + $failer = m::mock(FailedJobProviderInterface::class); + $failer->shouldReceive('forget')->with('5')->andReturnFalse(); + $container->instance(FailedJobProviderInterface::class, $failer); + + $command = new ForgetFailedCommand; + $command->setHypervel($container); + + $exitCode = $command->run(new ArrayInput(['id' => ['5']]), $output = new BufferedOutput); + + $this->assertSame(Command::FAILURE, $exitCode); + $this->assertStringContainsString('No failed job matches the ID [5].', $output->fetch()); + } + + public function testItForgetsTheRemainingJobsWhenOneCannotBeFound(): void + { + $container = new Application; + $failer = m::mock(FailedJobProviderInterface::class); + $failer->shouldReceive('forget')->with('5')->andReturnTrue(); + $failer->shouldReceive('forget')->with('6')->andReturnFalse(); + $failer->shouldReceive('forget')->with('7')->andReturnTrue(); + $container->instance(FailedJobProviderInterface::class, $failer); + + $command = new ForgetFailedCommand; + $command->setHypervel($container); + + $exitCode = $command->run(new ArrayInput(['id' => ['5', '6', '7']]), $output = new BufferedOutput); + + $this->assertSame(Command::FAILURE, $exitCode); + + $output = $output->fetch(); + + $this->assertStringContainsString('Failed job [5] deleted successfully.', $output); + $this->assertStringContainsString('No failed job matches the ID [6].', $output); + $this->assertStringContainsString('Failed job [7] deleted successfully.', $output); + } +} From 2c8fac8dae93876bc25ed6d6cdaa49a71ba44fbf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:21:44 +0000 Subject: [PATCH 20/31] Qualify implicit timestamp ordering on joined Eloquent queries Port laravel/framework#62024 (c4340e646ac6fd7626e01c51cb74f3f13e20aa82) with #62149 (6956459735a4792b87776137003ce67962b347e5). Qualify the default latest/oldest timestamp for joined queries with a string source, using the existing alias-aware qualification. Preserve explicit columns, union output names and raw or subquery sources. Port both upstream pagination regressions with all ordering assertions. The complete cursor pagination file passes on SQLite and the affected join/subquery cases pass on MySQL. --- src/database/src/Eloquent/Builder.php | 8 ++++ .../Database/EloquentCursorPaginateTest.php | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index f20a82c8f..198c803ea 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -423,6 +423,10 @@ public function latest(Closure|self|QueryBuilder|Relation|Expression|string|null { if (is_null($column)) { $column = $this->model->getCreatedAtColumn() ?? 'created_at'; + + if (! empty($this->query->joins) && empty($this->query->unions) && is_string($this->query->from)) { + $column = $this->qualifyColumn($column); + } } $this->query->latest($column); @@ -437,6 +441,10 @@ public function oldest(Closure|self|QueryBuilder|Relation|Expression|string|null { if (is_null($column)) { $column = $this->model->getCreatedAtColumn() ?? 'created_at'; + + if (! empty($this->query->joins) && empty($this->query->unions) && is_string($this->query->from)) { + $column = $this->qualifyColumn($column); + } } $this->query->oldest($column); diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php index f064c902a..cb2206bd1 100644 --- a/tests/Integration/Database/EloquentCursorPaginateTest.php +++ b/tests/Integration/Database/EloquentCursorPaginateTest.php @@ -814,6 +814,48 @@ public function testPaginationWithDistinctColumnsAndSelectAndJoin(): void $this->assertCount(5, $query->cursorPaginate()->items()); } + public function testLatestAndOldestPaginationWithJoin(): void + { + $user = TestUser::create(['name' => 'Taylor']); + + for ($i = 1; $i <= 3; ++$i) { + TestPost::create(['title' => 'Title ' . $i, 'user_id' => $user->id, 'created_at' => now()->startOfSecond()->addMinutes($i)]); + } + + $query = fn () => TestPost::query() + ->join('test_users', 'test_users.id', '=', 'test_posts.user_id') + ->select('test_posts.*'); + + $firstPage = $query()->latest()->cursorPaginate(2); + $secondPage = $query()->latest()->cursorPaginate(2, ['*'], 'cursor', $firstPage->nextCursor()); + + $this->assertSame(['Title 3', 'Title 2'], $firstPage->pluck('title')->all()); + $this->assertSame(['Title 1'], $secondPage->pluck('title')->all()); + + $firstPage = $query()->oldest()->cursorPaginate(2); + $secondPage = $query()->oldest()->cursorPaginate(2, ['*'], 'cursor', $firstPage->nextCursor()); + + $this->assertSame(['Title 1', 'Title 2'], $firstPage->pluck('title')->all()); + $this->assertSame(['Title 3'], $secondPage->pluck('title')->all()); + } + + public function testLatestAndOldestWithJoinFromSubquery(): void + { + $user = TestUser::create(['name' => 'Taylor']); + + for ($i = 1; $i <= 3; ++$i) { + TestPost::create(['title' => 'Title ' . $i, 'user_id' => $user->id, 'created_at' => now()->startOfSecond()->addMinutes($i)]); + } + + $query = fn () => TestPost::query() + ->fromSub(TestPost::query(), 'posts') + ->joinSub(TestUser::query()->select('id', 'name'), 'users', 'users.id', '=', 'posts.user_id') + ->select('posts.*'); + + $this->assertSame(['Title 3', 'Title 2', 'Title 1'], $query()->latest()->pluck('title')->all()); + $this->assertSame(['Title 1', 'Title 2', 'Title 3'], $query()->oldest()->pluck('title')->all()); + } + public function testCursorPaginationPreservesFloatOrderValues(): void { TestPost::fillAndInsert([ From 54e1673845aa559434077ccb2b5f0935cec9635c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:25:09 +0000 Subject: [PATCH 21/31] Prefer upstream fixes when reconciling equivalent Hypervel code --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b2a0e8f9c..e1c712928 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -796,7 +796,7 @@ When porting Laravel packages, whether first-party or third-party, keep them as Hypervel has no obligation to preserve Hypervel-specific behavior from earlier versions, but supported Laravel APIs—including named arguments and protected extension points—must remain compatible unless the user approves a difference. Compatibility means preserving supported behavior, not merely matching names and signatures. Before recommending a departure, investigate the simplest compatible adaptation and reasonable alternatives, and establish the concrete obstacle. Resolve uncertainty through focused tests or prototypes; an assumed architectural conflict or peer agreement is not sufficient evidence. If preserving compatibility would compromise correctness, performance or scalability, or require disproportionate complexity, reach consensus on the cleanest design through a second-opinion loop with your peer agent. Then STOP, explain the proposed difference, supporting evidence and rationale to the owner, and wait for approval before changing it. -When Laravel or a tracked upstream introduces an equivalent to a Hypervel-specific enhancement, compare their capabilities, APIs, coroutine safety and performance, then reach consensus on how to reconcile them through a second-opinion loop with your peer agent. Normally prefer the upstream API when it meets Hypervel’s requirements. Before deprecating or removing the Hypervel-specific API, STOP, explain the recommendation to the owner, and wait for approval. +When Laravel or a tracked upstream introduces an equivalent to a Hypervel-specific enhancement or fix, compare their capabilities, APIs, coroutine safety and performance, then reach consensus on how to reconcile them through a second-opinion loop with your peer agent. Normally prefer upstream when it meets Hypervel’s requirements. Before deprecating or removing the Hypervel-specific API, STOP, explain the recommendation to the owner, and wait for approval. Approved adaptations take precedence over upstream fidelity. Preserve Laravel upstream naming, structure, and style everywhere else. From 667fd7932f80c0c7a4e553bcb964379ccb6f1cd1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:06:29 +0000 Subject: [PATCH 22/31] Preserve broadcast guarantees across synchronous and deferred dispatch Keep Mercure publishing worker-free while honoring unique locks and transaction commit or rollback. Explicit outbox delivery retains queue routing, and immediate broadcasts retain their existing priority. Direct publishing avoids serialization and model reloads; nested event objects are read when the deferred callback runs. Claim deferred dispatch locks before reacquiring a pooled queue so lease failures cannot strand ownership. Share this cleanup boundary with failover and rollback, and let BroadcastEvent apply ShouldRescue after the original manager call has returned. Preserve queue-owned payload capture, transaction selection and cancellation chains without adding transaction-manager state. Extend real dispatch coverage for queue and failover failures, cancellation, rollback cleanup, nonserializable Mercure events and uniqueness. Strengthen existing unique-job exception assertions and update broadcasting guidance. Affected queue, bus and broadcasting suites, targeted static analysis and formatting pass. --- src/broadcasting/src/BroadcastEvent.php | 16 + src/broadcasting/src/BroadcastManager.php | 71 ++++- src/docs/broadcasting.md | 12 +- src/queue/src/FailoverQueue.php | 14 +- src/queue/src/Queue.php | 68 +++-- tests/Bus/DispatchLockContextTest.php | 51 ++++ .../Broadcasting/BroadcastManagerTest.php | 283 ++++++++++++++++++ tests/Integration/Queue/UniqueJobTest.php | 34 ++- 8 files changed, 491 insertions(+), 58 deletions(-) diff --git a/src/broadcasting/src/BroadcastEvent.php b/src/broadcasting/src/BroadcastEvent.php index e434a3d0b..2c4f6bb33 100644 --- a/src/broadcasting/src/BroadcastEvent.php +++ b/src/broadcasting/src/BroadcastEvent.php @@ -6,6 +6,7 @@ use Hypervel\Bus\Queueable; use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactory; +use Hypervel\Contracts\Broadcasting\ShouldRescue; use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Contracts\Queue\ShouldQueueViaOutbox; use Hypervel\Contracts\Support\Arrayable; @@ -203,6 +204,21 @@ public function failed(?Throwable $e = null): void $this->event->failed($e); } + /** + * Handle a deferred dispatch or transaction cleanup failure. + * + * @internal + */ + public function deferredDispatchFailed(Throwable $exception): void + { + if (! $this->event instanceof ShouldRescue) { + throw $exception; + } + + // The manager's rescue callback has returned before deferred queue work runs. + rescue(static fn () => throw $exception); + } + /** * Get the display name for the queued job. */ diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index ade6757b6..1340780a2 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -31,7 +31,9 @@ use Hypervel\Contracts\ObjectPool\Factory as PoolFactory; use Hypervel\Contracts\ObjectPool\InvalidatesPool; use Hypervel\Contracts\Queue\Factory as Queue; +use Hypervel\Contracts\Queue\ShouldQueueViaOutbox; use Hypervel\Contracts\Redis\Factory as RedisFactory; +use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Foundation\Http\Middleware\PreventRequestForgery; use Hypervel\Http\Client\Guzzle\HandlerFactory; use Hypervel\Http\Request; @@ -199,10 +201,13 @@ public function event(mixed $event = null): PendingBroadcast */ public function queue(mixed $event): void { - if ($event instanceof ShouldBroadcastNow - || (is_object($event) && method_exists($event, 'shouldBroadcastNow') && $event->shouldBroadcastNow()) - || $this->broadcastsToSynchronousMercureConnections($event) - ) { + $immediate = $event instanceof ShouldBroadcastNow + || (is_object($event) && method_exists($event, 'shouldBroadcastNow') && $event->shouldBroadcastNow()); + $synchronousMercure = ! $immediate + && ! ($event->viaOutbox ?? $event instanceof ShouldQueueViaOutbox) + && $this->broadcastsToSynchronousMercureConnections($event); + + if ($immediate || ($synchronousMercure && ! $event instanceof ShouldBeUnique && empty($event->afterCommit))) { $dispatch = fn () => $this->app->make(Dispatcher::class)->dispatchNow( new BroadcastEvent($event instanceof UnitEnum ? $event : clone $event) ); @@ -234,13 +239,15 @@ public function queue(mixed $event): void return; } - $push = fn () => $this->app->make(Queue::class) - ->connection( - $event->connection - ?? $this->getAttributeValue($event, ConnectionAttribute::class, 'connection') - ?? $this->resolveConnectionFromQueueRoute($event, $queue) - ) - ->pushOn($queue, $broadcastEvent); + $push = $synchronousMercure + ? fn () => $this->dispatchSynchronousBroadcast($broadcastEvent) + : fn () => $this->app->make(Queue::class) + ->connection( + $event->connection + ?? $this->getAttributeValue($event, ConnectionAttribute::class, 'connection') + ?? $this->resolveConnectionFromQueueRoute($event, $queue) + ) + ->pushOn($queue, $broadcastEvent); if ($event instanceof ShouldBeUnique) { $dispatch = $push; @@ -258,6 +265,48 @@ public function queue(mixed $event): void : $push(); } + /** + * Publish synchronously while honoring commit timing and dispatch locks. + */ + protected function dispatchSynchronousBroadcast(BroadcastEvent $event): void + { + $dispatch = fn () => $this->app->make(Dispatcher::class)->dispatchNow($event); + + if ($event->afterCommit && $this->app->bound('db.transactions')) { + /** @var DatabaseTransactionsManager $transactions */ + $transactions = $this->app->make('db.transactions'); + + if ($transactions->hasApplicableTransaction()) { + // Direct publishing retains the event without queue serialization or model reloads. + $release = static function () use ($event): void { + DispatchLockContext::claim($event); + DispatchLockContext::release($event); + }; + $transactions->addCallbackForRollback(fn () => $event->event instanceof ShouldRescue + ? $this->rescue($release) + : $release()); + + $publish = static function () use ($event, $dispatch): void { + DispatchLockContext::claim($event); + + try { + $dispatch(); + } finally { + DispatchLockContext::release($event); + } + }; + $transactions->addCallback(fn () => $event->event instanceof ShouldRescue + ? $this->rescue($publish) + : $publish()); + DispatchLockContext::delegate($event); + + return; + } + } + + $dispatch(); + } + /** * Get the container that owns the queue routes. */ diff --git a/src/docs/broadcasting.md b/src/docs/broadcasting.md index 800518110..9b229a8e4 100644 --- a/src/docs/broadcasting.md +++ b/src/docs/broadcasting.md @@ -93,7 +93,7 @@ All of your application's event broadcasting configuration is stored in the `con Once you have enabled event broadcasting, you're ready to learn more about [defining broadcast events](#defining-broadcast-events) and [listening for events](#listening-for-events). If you're using Hypervel's React [starter kit](/docs/{{version}}/starter-kits), you may listen for events using Echo's [useEcho hook](#using-react-or-vue). > [!NOTE] -> Before broadcasting any events, you should first configure and run a [queue worker](/docs/{{version}}/queues). All event broadcasting is done via queued jobs so that the response time of your application is not seriously affected by events being broadcast. The Mercure driver is an exception, as it [broadcasts events synchronously](#mercure-synchronous-broadcasting) by default. +> Before broadcasting queued events, you should first configure and run a [queue worker](/docs/{{version}}/queues). Events targeting only Mercure connections may [broadcast synchronously](#mercure-synchronous-broadcasting) without a worker; mixed connections and events selecting outbox delivery still use the queue. ## Server Side Installation @@ -284,7 +284,11 @@ Finally, you are ready to install and configure [Laravel Echo](#client-side-inst #### Synchronous Broadcasting -Unlike other drivers, the Mercure driver broadcasts events immediately instead of queueing them, so it doesn't require a queue worker. Publishing an update is fast, since the hub dispatches it to subscribers asynchronously. To broadcast events using your [queue](#broadcast-queue) instead, set the `MERCURE_ASYNC` environment variable to `true`: +When every connection selected by an event uses Mercure with `async` disabled, the event broadcasts without a queue worker. Events implementing `Hypervel\Contracts\Broadcasting\ShouldBeUnique` retain their uniqueness lock until publishing finishes. Setting the event's `afterCommit` property to `true` delays publishing until the transaction commits and discards the broadcast on rollback. + +Events selecting outbox delivery through `ShouldQueueViaOutbox` or `viaOutbox` use your [queue](#broadcast-queue). An explicit `ShouldBroadcastNow` declaration continues to request immediate delivery. + +Publishing an update is fast, since the hub dispatches it to subscribers asynchronously. To queue ordinary Mercure events too, set the `MERCURE_ASYNC` environment variable to `true`: ```ini MERCURE_ASYNC=true @@ -955,7 +959,7 @@ public function broadcastQueue(): string If you would like all of your broadcast events to use the same queue without customizing each event class, you may [route the `ShouldBroadcast` contract to a queue](/docs/{{version}}/queues#queue-routing) instead. -Broadcast events implementing `Hypervel\Contracts\Queue\ShouldBeUnique` also support the `Hypervel\Queue\Attributes\UniqueFor` attribute to configure the lock's lifetime in seconds, just like [unique jobs](/docs/{{version}}/queues#unique-jobs). +Broadcast events implementing `Hypervel\Contracts\Broadcasting\ShouldBeUnique` also support the `Hypervel\Queue\Attributes\UniqueFor` attribute to configure the lock's lifetime in seconds, just like [unique jobs](/docs/{{version}}/queues#unique-jobs). If you would like to broadcast your event using the `sync` queue instead of the default queue driver, you can implement the `ShouldBroadcastNow` interface instead of `ShouldBroadcast`: @@ -1009,6 +1013,8 @@ class ServerCreated implements ShouldBroadcast, ShouldDispatchAfterCommit } ``` +The queue connection's `after_commit` option does not apply to synchronous Mercure publishing; use `ShouldDispatchAfterCommit` or set the event's `afterCommit` property to `true` to wait for commit without requiring a queue worker. + > [!NOTE] > To learn more about working around these issues, please review the documentation regarding [queued jobs and database transactions](/docs/{{version}}/queues#jobs-and-database-transactions). diff --git a/src/queue/src/FailoverQueue.php b/src/queue/src/FailoverQueue.php index 4d233abed..384f46e9d 100644 --- a/src/queue/src/FailoverQueue.php +++ b/src/queue/src/FailoverQueue.php @@ -382,20 +382,10 @@ function () use (&$pendingConnections, &$settled, $connection, $method, $argumen $settled = true; - if (is_object($job)) { - DispatchLockContext::claim($job); - } - // Child payload hooks run after commit but must see the dispatch-time log context. - try { - $this->withLogContext($context, fn () => $selectedConnection !== null + self::runDeferredEnqueue([$job], fn () => $this->withLogContext($context, fn () => $selectedConnection !== null ? $this->attemptOnConnections($method, $arguments, $job, true) - : $this->attemptOnAllConnections($method, $arguments, $job)); - } finally { - if (is_object($job)) { - DispatchLockContext::release($job); - } - } + : $this->attemptOnAllConnections($method, $arguments, $job))); }, $connection ); diff --git a/src/queue/src/Queue.php b/src/queue/src/Queue.php index d3363bab0..6a99b2385 100644 --- a/src/queue/src/Queue.php +++ b/src/queue/src/Queue.php @@ -513,34 +513,16 @@ protected function deferBatchEnqueueAfterCommit( $this->addJobRollbackCallback($transactions, $job, $connection); } - $callback = static function (Queue $owner) use ($jobs, $enqueue): mixed { - foreach ($jobs as $job) { - if (is_object($job)) { - DispatchLockContext::claim($job); - } - } - - try { - return $enqueue($owner); - } finally { - foreach ($jobs as $job) { - if (is_object($job)) { - DispatchLockContext::release($job); - } - } - } - }; - if ($this->afterCommitDispatcher !== null) { $dispatcher = $this->afterCommitDispatcher; $transactions->addCallback( - static fn () => $dispatcher($callback), + static fn () => self::runDeferredEnqueue($jobs, static fn () => $dispatcher($enqueue)), $connection, ); } else { $transactions->addCallback( - fn () => $callback($this), + fn () => self::runDeferredEnqueue($jobs, fn () => $enqueue($this)), $connection, ); } @@ -552,6 +534,45 @@ protected function deferBatchEnqueueAfterCommit( } } + /** + * Run deferred work with its dispatch locks and optional deferredDispatchFailed job hook. + * + * @param array $jobs + */ + protected static function runDeferredEnqueue(array $jobs, Closure $operation): mixed + { + try { + foreach ($jobs as $job) { + if (is_object($job)) { + DispatchLockContext::claim($job); + } + } + + // Reacquiring a pooled queue can fail before its enqueue callback runs. + try { + return $operation(); + } finally { + foreach ($jobs as $job) { + if (is_object($job)) { + DispatchLockContext::release($job); + } + } + } + } catch (Throwable $exception) { + $job = reset($jobs); + + if (Coroutine::causedByCancellation($exception) + || count($jobs) !== 1 + || ! is_object($job) + || ! method_exists($job, 'deferredDispatchFailed') + ) { + throw $exception; + } + + return $job->deferredDispatchFailed($exception); + } + } + /** * Enqueue a job immediately using the given callback. */ @@ -783,11 +804,8 @@ protected function createJobRollbackCallback(object|string $job): ?Closure return null; } - return static function () use ($job): void { - // Rollback revokes ownership delegated to the commit callback, which can no longer run. - DispatchLockContext::claim($job); - DispatchLockContext::release($job); - }; + // Rollback revokes ownership delegated to the commit callback, which can no longer run. + return static fn () => self::runDeferredEnqueue([$job], static fn () => null); } /** diff --git a/tests/Bus/DispatchLockContextTest.php b/tests/Bus/DispatchLockContextTest.php index 778ef531b..b59d2cb2c 100644 --- a/tests/Bus/DispatchLockContextTest.php +++ b/tests/Bus/DispatchLockContextTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Bus; +use Closure; use DateInterval; use DateTimeInterface; use Hypervel\Bus\DispatchLockContext; @@ -24,6 +25,10 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; use WeakReference; class DispatchLockContextTest extends TestCase @@ -225,6 +230,52 @@ public static function afterCommitPayloadBuilders(): array 'deferred later' => [DispatchLockContextDeferredQueue::class, true], ]; } + + #[TestWith([false])] + #[TestWith([true])] + public function testFailedQueueReacquisitionReleasesOwnedLocks(bool $cleanupThrows): void + { + $container = new Container; + $transactions = new DatabaseTransactionsManager; + $container->instance('db.transactions', $transactions); + $container->instance(EventDispatcher::class, new EventsDispatcher($container)); + Container::setInstance($container); + $queue = new SyncQueue(true); + $queue->setContainer($container); + $queue->setConnectionName('test'); + $failure = $cleanupThrows ? new CanceledException : new RuntimeException('Queue unavailable.'); + $cleanupFailure = new RuntimeException('Lock cleanup failed.'); + + if ($cleanupThrows) { + DispatchLockContext::captureReleaseContextUsing(static fn () => static function (Closure $release) use ($cleanupFailure): void { + $release(); + throw $cleanupFailure; + }); + } + + $cache = new Repository(new WorkerArrayStore); + $job = new DispatchLockContextDefaultCacheJob('failed-reacquisition'); + $this->assertTrue((new UniqueLock($cache))->acquireForDispatch($job)); + $lock = $cache->lock(UniqueLock::getKey($job)); + $queue->setAfterCommitDispatcher(static fn () => throw $failure); + + $transactions->begin('default', 1); + $queue->push($job); + $this->assertTrue($lock->isLocked()); + + try { + $transactions->commit('default', 1, 0); + $this->fail('Expected the deferred queue failure.'); + } catch (Throwable $exception) { + $this->assertSame($cleanupThrows ? $cleanupFailure : $failure, $exception); + if ($cleanupThrows) { + $this->assertSame($failure, $exception->getPrevious()); + } + } + + $this->assertFalse($lock->isLocked()); + $this->assertFalse(DispatchLockContext::has($job)); + } } class DispatchLockContextJob implements ShouldBeUnique diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php index 54e1824e7..4e72dde27 100644 --- a/tests/Integration/Broadcasting/BroadcastManagerTest.php +++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Integration\Broadcasting; use Ably\AblyRest; +use Closure; use Exception; use Hypervel\Broadcasting\Broadcasters\AblyBroadcaster; use Hypervel\Broadcasting\Broadcasters\Broadcaster as BaseBroadcaster; @@ -17,6 +18,7 @@ use Hypervel\Broadcasting\Channel; use Hypervel\Broadcasting\Mercure\Hub as MercureHub; use Hypervel\Broadcasting\UniqueBroadcastEvent; +use Hypervel\Bus\DispatchLockContext; use Hypervel\Cache\ArrayStore; use Hypervel\Cache\Repository as CacheRepository; use Hypervel\Config\Repository; @@ -33,21 +35,29 @@ use Hypervel\Contracts\Cache\Repository as Cache; use Hypervel\Contracts\Cache\Store as CacheStore; use Hypervel\Contracts\Container\Container as ContainerContract; +use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; use Hypervel\Contracts\Foundation\CachesRoutes; use Hypervel\Contracts\ObjectPool\Factory as PoolFactory; use Hypervel\Contracts\Queue\Factory as QueueFactory; +use Hypervel\Contracts\Queue\ShouldQueueViaOutbox; use Hypervel\Contracts\Redis\Factory as Redis; use Hypervel\Contracts\Routing\UrlGenerator as UrlGeneratorContract; +use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Foundation\Http\Middleware\PreventRequestForgery; use Hypervel\Http\Request; use Hypervel\ObjectPool\PoolManager; use Hypervel\Queue\Attributes\UniqueFor; +use Hypervel\Queue\FailoverQueue; +use Hypervel\Queue\QueueManager; +use Hypervel\Queue\SyncQueue; use Hypervel\Redis\RedisProxy; use Hypervel\Routing\Route; use Hypervel\Routing\RouteCollection; use Hypervel\Routing\UrlGenerator; use Hypervel\Support\Facades\Broadcast; use Hypervel\Support\Facades\Bus; +use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Queue; use Hypervel\Support\Testing\Fakes\QueueFake; use Hypervel\Testbench\TestCase; @@ -58,7 +68,9 @@ use Pusher\Pusher; use RuntimeException; use stdClass; +use Swoole\Coroutine\CanceledException; use Symfony\Component\Mercure\Exception\InvalidArgumentException as MercureInvalidArgumentException; +use Throwable; class BroadcastManagerTest extends TestCase { @@ -365,6 +377,67 @@ public function testRescuedUniqueEventPublicationFailureReleasesItsLock(): void $replacement->forceRelease(); } + #[TestWith(['event'])] + #[TestWith(['connection'])] + #[TestWith(['failover'])] + #[TestWith(['failover child'])] + #[TestWith(['cancellation'])] + #[TestWith(['rollback'])] + public function testDeferredQueueFailuresHonorBroadcastRescueAndReleaseLocks(string $mode): void + { + config(['cache.default' => 'array', 'broadcasting.default' => 'log']); + $failure = $mode === 'cancellation' ? new CanceledException : new RuntimeException('Queue unavailable.'); + $handler = m::mock(ExceptionHandler::class); + $handler->shouldReceive('report')->with($failure, [])->times($mode === 'cancellation' ? 0 : 1); + $this->app->instance(ExceptionHandler::class, $handler); + + if ($mode === 'rollback') { + DispatchLockContext::captureReleaseContextUsing(static fn () => static function (Closure $release) use ($failure): void { + $release(); + throw $failure; + }); + } + + $transactions = new DatabaseTransactionsManager; + $this->app->instance('db.transactions', $transactions); + $queue = new TestFailingDeferredBroadcastQueue($failure, $mode !== 'event'); + $queue->setContainer($this->app); + $queue->setConnectionName('deferred'); + $queue->setAfterCommitDispatcher(static fn () => throw $failure); + + if (str_starts_with($mode, 'failover')) { + $manager = m::mock(QueueManager::class); + $manager->shouldReceive('connection')->with('deferred')->andReturn($queue); + $queue = new FailoverQueue($manager, $this->app->make(EventDispatcher::class), ['deferred'], $mode === 'failover'); + $queue->setContainer($this->app); + $queue->setConnectionName('failover'); + } + + $factory = m::mock(QueueFactory::class); + $factory->shouldReceive('connection')->andReturn($queue); + $this->app->instance(QueueFactory::class, $factory); + $lock = $this->app->make(Cache::class)->lock('laravel_unique_job:' . hash('xxh128', TestDeferredRescuedBroadcast::class) . ':'); + + $transactions->begin('first', 1); + $transactions->begin('second', 1); + Broadcast::queue(new TestDeferredRescuedBroadcast($mode === 'event' ? true : null)); + $this->assertTrue($lock->isLocked()); + $transactions->commit('first', 1, 0); + $this->assertTrue($lock->isLocked()); + + $caught = null; + try { + $mode === 'rollback' + ? $transactions->rollback('second', 0) + : $transactions->commit('second', 1, 0); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($mode === 'cancellation' ? $failure : null, $caught); + $this->assertFalse($lock->isLocked()); + } + public function testUniqueEventWithNullableCacheResolverUsesDefaultCache(): void { Queue::fake(); @@ -1005,6 +1078,132 @@ public function testMercureEventsAreBroadcastNow(): void Queue::assertNotPushed(BroadcastEvent::class); } + #[DataProvider('mercureQueueSemantics')] + public function testMercurePreservesExplicitQueueSemantics(ShouldBroadcast $event, bool $queued): void + { + Bus::fake(); + Queue::fake(); + + config([ + 'broadcasting.default' => 'mercure', + 'broadcasting.connections.mercure' => $this->mercureConfig(['driver' => 'mercure']), + ]); + + Broadcast::queue($event); + + if ($queued) { + Bus::assertNotDispatched(BroadcastEvent::class); + Queue::assertPushed($event instanceof ShouldBeUnique ? UniqueBroadcastEvent::class : BroadcastEvent::class); + } else { + Bus::assertDispatched(BroadcastEvent::class); + Queue::assertNothingPushed(); + } + } + + public static function mercureQueueSemantics(): array + { + return [ + 'outbox contract' => [new class extends TestEvent implements ShouldQueueViaOutbox {}, true], + 'outbox property' => [new class extends TestEvent { + public bool $viaOutbox = true; + }, true], + 'before commit' => [new class extends TestEvent { + public bool $afterCommit = false; + }, false], + 'outbox explicitly disabled' => [new class extends TestEvent implements ShouldQueueViaOutbox { + public bool $viaOutbox = false; + }, false], + 'explicit immediate unique event' => [new class extends TestEventUnique implements ShouldBroadcastNow {}, false], + ]; + } + + public function testUniqueMercurePublishesWithoutAWorkerAndReleasesTheLock(): void + { + $published = 0; + $manager = $this->synchronousMercureManager(function () use (&$published, &$manager): void { + ++$published; + if ($published === 1) { + $manager->queue(new TestSynchronousMercureUniqueEvent); + } + }); + + $manager->queue(new TestSynchronousMercureUniqueEvent); + $this->assertSame(1, $published); + $manager->queue(new TestSynchronousMercureUniqueEvent); + $this->assertSame(2, $published); + } + + public function testMercureDefersDeduplicatesAndRollsBackWithoutAWorker(): void + { + $published = 0; + $manager = $this->synchronousMercureManager(function () use (&$published): void { + ++$published; + }); + + DB::beginTransaction(); + $manager->queue(new TestSynchronousMercureUniqueEvent(true)); + $manager->queue(new TestSynchronousMercureUniqueEvent(true)); + $this->assertSame(0, $published); + DB::rollBack(); + $this->assertSame(0, $published); + DB::beginTransaction(); + $manager->queue(new TestSynchronousMercureUniqueEvent(true)); + $manager->queue(new TestSynchronousMercureUniqueEvent(true)); + $this->assertSame(0, $published); + DB::commit(); + $this->assertSame(1, $published); + $manager->queue(new TestSynchronousMercureUniqueEvent(true)); + $this->assertSame(2, $published); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testSynchronousMercurePreservesNonSerializableEvents(bool $afterCommit): void + { + $published = 0; + $manager = $this->synchronousMercureManager(function () use (&$published): void { + ++$published; + }); + + DB::beginTransaction(); + $manager->queue(new TestNonSerializableMercureEvent($afterCommit)); + $this->assertSame($afterCommit ? 0 : 1, $published); + DB::commit(); + $this->assertSame(1, $published); + } + + #[TestWith(['publish'])] + #[TestWith(['commit cleanup'])] + #[TestWith(['rollback cleanup'])] + public function testDeferredMercureRescuesPublicationAndLockCleanupFailures(string $phase): void + { + $failure = new RuntimeException('Broadcast operation failed.'); + $handler = m::mock(ExceptionHandler::class); + $handler->expects('report')->with($failure, []); + $this->app->instance(ExceptionHandler::class, $handler); + $published = 0; + $manager = $this->synchronousMercureManager(function () use (&$published, $phase, $failure): void { + ++$published; + if ($phase === 'publish') { + throw $failure; + } + }); + if ($phase !== 'publish') { + DispatchLockContext::captureReleaseContextUsing(static fn () => static function (Closure $release) use ($failure): void { + $release(); + throw $failure; + }); + } + + DB::beginTransaction(); + $manager->queue(new TestRescuedMercureEvent(true)); + $lock = $this->app->make(Cache::class)->lock('laravel_unique_job:' . hash('xxh128', TestRescuedMercureEvent::class) . ':'); + $this->assertTrue($lock->isLocked()); + $phase === 'rollback cleanup' ? DB::rollBack() : DB::commit(); + $this->assertSame($phase === 'rollback cleanup' ? 0 : 1, $published); + $this->assertFalse($lock->isLocked()); + } + public function testMercureConnectionSelectionPreservesZeroValuedEnums(): void { Bus::fake(); @@ -1416,6 +1615,25 @@ public function testMercureKeepsPoolControlsAndCustomCreatorsDoNotReceiveThem(): $this->assertArrayNotHasKey('pool', $received); } + /** + * Create a Mercure manager that uses the real dispatch path without a queue worker. + */ + protected function synchronousMercureManager(Closure $publish): BroadcastManager + { + config([ + 'queue.default' => 'null', + 'cache.default' => 'array', + 'broadcasting.default' => 'mercure', + 'broadcasting.connections.mercure' => ['driver' => 'mercure', 'async' => false], + ]); + $broadcaster = m::mock(Broadcaster::class); + $broadcaster->shouldReceive('broadcast')->andReturnUsing($publish); + $manager = $this->app->make(BroadcastManager::class); + $manager->extend('mercure', static fn () => $broadcaster); + + return $manager; + } + /** * Build a remote Mercure connection configuration. */ @@ -1501,6 +1719,71 @@ public function broadcastOn(): array } } +class TestDeferredRescuedBroadcast extends TestEvent implements ShouldBeUnique, ShouldRescue +{ + /** + * Create an event with an optional commit preference. + */ + public function __construct(public ?bool $afterCommit = null) + { + } +} + +class TestFailingDeferredBroadcastQueue extends SyncQueue +{ + /** + * Create a queue that fails when it executes a payload. + */ + public function __construct(private Throwable $failure, bool $afterCommit) + { + parent::__construct($afterCommit); + } + + /** + * Fail before the queued broadcast can run. + */ + protected function executePayload(string $payload, ?string $queue = null): int + { + throw $this->failure; + } +} + +class TestSynchronousMercureUniqueEvent extends TestEvent implements ShouldBeUnique +{ + /** + * Create a unique event with the given commit preference. + */ + public function __construct(public bool $afterCommit = false) + { + } + + /** + * Get the channels the event should broadcast on. + */ + public function broadcastOn(): array + { + return ['test']; + } +} + +class TestNonSerializableMercureEvent extends TestSynchronousMercureUniqueEvent +{ + private Closure $callback; + + /** + * Create an event containing a nonserializable callback. + */ + public function __construct(bool $afterCommit) + { + parent::__construct($afterCommit); + $this->callback = static fn () => null; + } +} + +class TestRescuedMercureEvent extends TestSynchronousMercureUniqueEvent implements ShouldRescue +{ +} + class TestForwardedEvent implements ShouldBroadcast { public string $queue = 'broadcast-queue'; diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php index c595a5637..0a8a9a4ab 100644 --- a/tests/Integration/Queue/UniqueJobTest.php +++ b/tests/Integration/Queue/UniqueJobTest.php @@ -457,15 +457,20 @@ public function testRolledBackPushDoesNotReleaseAnotherDispatchesUniqueLock(): v $this->assertFalse($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + $failure = new Exception('Rollback.'); + $caught = null; + try { - DB::transaction(function (): never { + DB::transaction(function () use ($failure): never { Queue::push(new UniqueTestAfterCommitJob); - throw new Exception('Rollback.'); + throw $failure; }); - } catch (Exception) { + } catch (Exception $exception) { + $caught = $exception; } + $this->assertSame($failure, $caught); $this->assertFalse($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); } @@ -478,16 +483,26 @@ public function testRolledBackQueuedListenerReleasesItsUniqueLock(): void Event::listen(UniqueTestEvent::class, UniqueTestAfterCommitListener::class); $size = Queue::size(); + $lock = $this->app->get(Cache::class)->lock( + 'laravel_unique_job:' . hash('xxh128', UniqueTestAfterCommitListener::class) . ':' + ); + $failure = new Exception('Rollback.'); + $caught = null; try { - DB::transaction(function (): never { + DB::transaction(function () use ($lock, $failure): never { event(new UniqueTestEvent); - throw new Exception('Rollback.'); + $this->assertTrue($lock->isLocked()); + + throw $failure; }); - } catch (Exception) { + } catch (Exception $exception) { + $caught = $exception; } + $this->assertSame($failure, $caught); + event(new UniqueTestEvent); $this->assertSame($size + 1, Queue::size()); @@ -515,11 +530,16 @@ public function push(object|string $job, mixed $data = '', UnitEnum|string|null config()->set('queue.connections.failing', ['driver' => 'failing']); + $caught = null; + try { dispatch(new UniqueTestJob)->onConnection('failing'); - } catch (RuntimeException) { + } catch (RuntimeException $exception) { + $caught = $exception; } + $this->assertInstanceOf(RuntimeException::class, $caught); + $this->assertSame('Queue unavailable.', $caught->getMessage()); $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey(UniqueTestJob::class), 10)->get()); } From 751c9e2675fd1f6d8a4c52967df1d53f1f79f6c2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:06:38 +0000 Subject: [PATCH 23/31] Preserve relationship scopes and key bindings when touching related models Select through-related keys independently of caller projections and preserve the scopes that selected those rows. Pivot touches retain explicitly removed scopes. Encode binary keys at the binding boundary so updates use the same identity as the selection. Share related-key updates at Relation, applying scopes once and batching only unbounded PDO updates within the connection binding limit. Keep limited or offset updates as one statement so splitting cannot change the selected rows; non-PDO drivers retain their own statement behavior. Cover custom projections, soft-delete scope removal, binary keys, binding boundaries and scoped limit/offset updates. Affected relationship suites and native PostgreSQL and MySQL checks pass. --- .../src/Eloquent/Relations/BelongsToMany.php | 14 +++- .../Relations/HasOneOrManyThrough.php | 22 ++++-- .../src/Eloquent/Relations/Relation.php | 29 ++++++++ ...eEloquentHasManyThroughIntegrationTest.php | 33 ++++++++- .../Database/DatabaseEloquentRelationTest.php | 71 +++++++++++++++++++ ...atabaseEloquentAsBinaryIntegrationTest.php | 63 ++++++++++++++++ .../Database/EloquentBelongsToManyTest.php | 27 +++++++ 7 files changed, 247 insertions(+), 12 deletions(-) diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php index b3438e19f..6f83721f4 100644 --- a/src/database/src/Eloquent/Relations/BelongsToMany.php +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -1347,12 +1347,20 @@ public function touch(): void $this->related->getUpdatedAtColumn() => $this->related->freshTimestampString(), ]; + // Pivot queries return raw keys; retain their binary binding intent without decoding them. + $key = $this->relatedKey; + $ids = $this->allRelatedIds()->map(fn ($id) => $this->related->prepareBinaryAttributesForDatabase([$key => $id])[$key]); + // If we actually have IDs for the relation, we will run the query to update all // the related model's timestamps, to make sure these all reflect the changes // to the parent models. This will help us keep any caching synced up here. - if (count($ids = $this->allRelatedIds()) > 0) { - $this->getRelated()->newQueryWithoutRelationships()->whereIn($this->getQualifiedRelatedKeyName(), $ids)->update($columns); - } + // Pivot selection does not apply related-model scopes, but explicit removals still apply. + $this->updateRelatedKeys( + $this->getRelated()->newQueryWithoutRelationships()->withoutGlobalScopes($this->query->removedScopes()), + $this->getQualifiedRelatedKeyName(), + $ids, + $columns, + ); } /** diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index f2dd86ac5..195e74dc5 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -596,12 +596,18 @@ public function touch(): void $model->getUpdatedAtColumn() => $model->freshTimestampString(), ]; - if (count($ids = $this->allRelatedIds()) > 0) { - $this->getRelated()->newQueryWithoutRelationships()->whereIn( - $this->related->getQualifiedKeyName(), - $ids - )->update($columns); - } + $key = $model->getKeyName(); + + // Pluck applies casts; restore binary binding intent for keys selected by the framework. + $ids = $this->allRelatedIds()->map(fn ($id) => $model->encodeBinaryAttributeForDatabase($key, $id)); + + // The selection already applied the relation's scopes, including explicit removals. + $this->updateRelatedKeys( + $model->newQueryWithoutRelationships()->withoutGlobalScopes(), + $model->getQualifiedKeyName(), + $ids, + $columns, + ); } /** @@ -611,7 +617,9 @@ public function touch(): void */ public function allRelatedIds(): BaseCollection { - return $this->pluck($this->related->getQualifiedKeyName()); + $key = $this->related->getQualifiedKeyName(); + + return (clone $this->query)->select($key)->pluck($key); } /** diff --git a/src/database/src/Eloquent/Relations/Relation.php b/src/database/src/Eloquent/Relations/Relation.php index 690adb028..4e6753f93 100644 --- a/src/database/src/Eloquent/Relations/Relation.php +++ b/src/database/src/Eloquent/Relations/Relation.php @@ -12,6 +12,7 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Database\MultipleRecordsFoundException; +use Hypervel\Database\PdoConnection; use Hypervel\Database\Query\Builder as QueryBuilder; use Hypervel\Database\Query\Expression; use Hypervel\Support\Collection as BaseCollection; @@ -293,6 +294,34 @@ public function touch(): void } } + /** + * Update related keys, splitting unbounded queries at the statement binding limit. + * + * @param Builder $query + * @param BaseCollection $keys + * @param array $values + */ + protected function updateRelatedKeys(Builder $query, string $column, BaseCollection $keys, array $values): void + { + if ($keys->isEmpty()) { + return; + } + + // Prepare scopes once so counting bindings and splitting the update do not replay them. + $query = $query->applyScopes()->withoutGlobalScopes(); + $connection = $query->getConnection(); + $base = $query->getQuery(); + + // Limits and offsets select across the whole list and must not be reapplied per chunk. + $size = $connection instanceof PdoConnection && $base->limit === null && $base->offset === null + ? $connection->maxBindings() - count($values) - count($base->getBindings()) + : $keys->count(); + + foreach (array_chunk($keys->all(), $size) as $chunk) { + (clone $query)->whereIn($column, $chunk)->update($values); + } + } + /** * Run a raw update against the base query. */ diff --git a/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php b/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php index 2ea641152..3fc64506c 100644 --- a/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php +++ b/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php @@ -620,12 +620,36 @@ public function testTouchRespectsWithoutTouching(): void $this->assertSame('2000-01-01 00:00:00', Post::first()->fresh()->updated_at->format('Y-m-d H:i:s')); } - public function testAllRelatedIds(): void + public function testTouchPreservesRemovedRelatedScopes(): void + { + $this->schema()->table('posts', fn ($table) => $table->softDeletes()); + $this->seedData(); + TouchSoftDeletedPost::first()->delete(); + TouchSoftDeletedPost::withTrashed()->update(['updated_at' => '2000-01-01 00:00:00']); + + $relation = Country::first()->hasManyThrough(TouchSoftDeletedPost::class, User::class, 'country_id', 'user_id'); + $relation->touch(); + + $this->assertSame('2000-01-01 00:00:00', TouchSoftDeletedPost::onlyTrashed()->first()->updated_at->format('Y-m-d H:i:s')); + + $relation->withTrashed()->touch(); + + $this->assertNotSame('2000-01-01 00:00:00', TouchSoftDeletedPost::onlyTrashed()->first()->updated_at->format('Y-m-d H:i:s')); + } + + #[TestWith([null])] + #[TestWith(['posts.title'])] + public function testAllRelatedIds(?string $column): void { $this->seedData(); $country = Country::first(); + $relation = $country->posts(); + + if ($column !== null) { + $relation->select($column); + } - $this->assertEquals([1, 2], $country->posts()->allRelatedIds()->all()); + $this->assertEquals([1, 2], $relation->allRelatedIds()->all()); } public function testEagerLoadingDoesNotReadTheEmptyFarParentKey(): void @@ -767,6 +791,11 @@ public function owner() } } +class TouchSoftDeletedPost extends Post +{ + use SoftDeletes; +} + class Country extends Eloquent { protected ?string $table = 'countries'; diff --git a/tests/Database/DatabaseEloquentRelationTest.php b/tests/Database/DatabaseEloquentRelationTest.php index 54256ed7a..8fd0295ee 100755 --- a/tests/Database/DatabaseEloquentRelationTest.php +++ b/tests/Database/DatabaseEloquentRelationTest.php @@ -13,8 +13,10 @@ use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\SQLiteConnection; use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Collection as BaseCollection; use Hypervel\Tests\TestCase; use PDO; +use PHPUnit\Framework\Attributes\DataProvider; class DatabaseEloquentRelationTest extends TestCase { @@ -46,6 +48,75 @@ public function testTouchMethodUpdatesRelatedTimestamps(): void $this->assertSame('2023-01-01 00:00:00', $this->updatedAt($connection, 'table')); } + #[DataProvider('relatedKeyUpdateCases')] + public function testRelatedKeyUpdatesRespectBindingLimitsWithoutReapplyingScopes(int $keyCount, int $expectedUpdates, ?int $limit, ?int $offset, array $expectedKeys): void + { + $connection = new class(new PDO('sqlite::memory:')) extends SQLiteConnection { + /** + * Exercise the statement boundary with a small fixture. + */ + protected function resolveMaxBindings(): int + { + return 4; + } + }; + $connection->statement('create table "table" (id integer primary key, foreign_key integer, updated_at text)'); + $connection->statement('insert into "table" (id, foreign_key) values (1, 1), (2, 1), (3, 1), (4, 2)'); + $model = new NoTouchingModelStub; + $scopeCalls = 0; + $query = (new Builder($connection->query()))->setModel($model)->withGlobalScope('tenant', function (Builder $query) use (&$scopeCalls, $limit, $offset): void { + ++$scopeCalls; + $query->where('foreign_key', 1); + + if ($limit !== null) { + $query->orderByDesc('id')->limit($limit); + } + + if ($offset !== null) { + $query->offset($offset); + } + }); + $relation = new class($query, $model) extends RelationStub { + public array $keys = []; + + /** + * Touch the selected related models through the shared update path. + */ + public function touch(): void + { + $this->updateRelatedKeys($this->query, 'table.id', new BaseCollection($this->keys), [ + 'updated_at' => $this->related->freshTimestampString(), + ]); + } + }; + $relation->keys = range(1, $keyCount); + $connection->enableQueryLog(); + + $relation->touch(); + + $this->assertSame(1, $scopeCalls); + $this->assertCount($expectedUpdates, $connection->getQueryLog()); + if ($limit === null && $offset === null) { + foreach ($connection->getQueryLog() as $query) { + $this->assertLessThanOrEqual(4, count($query['bindings'])); + } + } + $this->assertSame($expectedKeys, $connection->table('table')->whereNotNull('updated_at')->orderBy('id')->pluck('id')->all()); + } + + /** + * Provide statement boundaries and scoped selections that cannot be split. + */ + public static function relatedKeyUpdateCases(): array + { + return [ + 'fits one statement' => [2, 1, null, null, [1, 2]], + 'splits unbounded selection' => [4, 2, null, null, [1, 2, 3]], + 'retains global limit' => [4, 1, 1, null, [3]], + 'retains global offset' => [4, 1, 1, 1, [2]], + ]; + } + public function testCanDisableParentTouchingForAllModels(): void { $connection = $this->newConnection(); diff --git a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php index b97296b3a..20187e53c 100644 --- a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php +++ b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php @@ -34,6 +34,8 @@ protected function afterRefreshingDatabase(): void $table->integer('counter')->default(0); $table->binary('uuid', length: 16, fixed: true)->unique(); $table->binary('ulid', length: 16, fixed: true); + $table->integer('parent_id')->nullable(); + $table->timestamp('updated_at')->nullable(); }); Schema::create('binary_primary_keys', function (Blueprint $table): void { @@ -41,6 +43,13 @@ protected function afterRefreshingDatabase(): void $table->string('name'); $table->integer('counter')->default(0); $table->timestamp('deleted_at')->nullable(); + $table->integer('identifier_id')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + + Schema::create('binary_touch_links', function (Blueprint $table): void { + $table->integer('parent_id'); + $table->binary('related_uuid', length: 16, fixed: true); }); } @@ -182,6 +191,46 @@ public function testIncrementAndDecrementBindBinaryExtraAttributes(string $metho $this->assertSame($uuid, $model->fresh()->uuid); } + #[TestWith(['through'])] + #[TestWith(['pivot'])] + public function testRelationTouchBindsFrameworkSelectedBinaryKeys(string $kind): void + { + $parent = AsBinaryIdentifierModel::create([ + 'uuid' => '00000000-0000-0000-0000-000000000001', + 'ulid' => '01ARZ3NDEKTSV4RRFFQ69G5FAV', + ]); + $intermediate = TouchingAsBinaryIdentifierModel::create([ + 'uuid' => '00ff7f80-4048-43c2-b80b-40491d165946', + 'ulid' => '2WBHE5RQ2WBHE5RQ2WBHE5RQ2W', + 'parent_id' => $parent->getKey(), + 'updated_at' => '2000-01-01 00:00:00', + ]); + + if ($kind === 'through') { + $related = TouchingAsBinaryPrimaryKeyModel::create([ + 'id' => $intermediate->uuid, + 'name' => 'related', + 'identifier_id' => $intermediate->getKey(), + 'updated_at' => '2000-01-01 00:00:00', + ]); + $relation = $parent->hasManyThrough(TouchingAsBinaryPrimaryKeyModel::class, AsBinaryIdentifierModel::class, 'parent_id', 'identifier_id'); + } else { + $related = $intermediate; + $parent->getConnection()->table('binary_touch_links')->insert([ + 'parent_id' => $parent->getKey(), + 'related_uuid' => new BinaryParameter($related->getRawOriginal('uuid')), + ]); + $relation = $parent->belongsToMany(TouchingAsBinaryIdentifierModel::class, 'binary_touch_links', 'parent_id', 'related_uuid', 'id', 'uuid'); + } + + $this->assertTrue($relation->sole()->is($related)); + + $relation->touch(); + + $this->assertNotSame('2000-01-01 00:00:00', $related->fresh()->getRawOriginal('updated_at')); + $this->assertNull($parent->fresh()->getRawOriginal('updated_at')); + } + #[DataProvider('fillAndInsertMethods')] public function testBinaryIdentifiersRoundTripThroughFillAndInsert(string $method): void { @@ -519,6 +568,20 @@ protected function casts(): array } } +class TouchingAsBinaryIdentifierModel extends AsBinaryIdentifierModel +{ + public const ?string CREATED_AT = null; + + public bool $timestamps = true; +} + +class TouchingAsBinaryPrimaryKeyModel extends AsBinaryPrimaryKeyModel +{ + public const ?string CREATED_AT = null; + + public bool $timestamps = true; +} + class ScopedAsBinaryPrimaryKeyModel extends AsBinaryPrimaryKeyModel { /** diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php index 2ed4991f2..4328b427a 100644 --- a/tests/Integration/Database/EloquentBelongsToManyTest.php +++ b/tests/Integration/Database/EloquentBelongsToManyTest.php @@ -12,6 +12,7 @@ use Hypervel\Database\Eloquent\Relations\BelongsTo; use Hypervel\Database\Eloquent\Relations\BelongsToMany; use Hypervel\Database\Eloquent\Relations\Pivot; +use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\RecordsNotFoundException; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\CarbonImmutable; @@ -1115,6 +1116,27 @@ public function testCanTouchRelatedModels(): void $this->assertNotSame('2017-10-10 10:10:10', Tag::find(2)->updated_at?->toDateTimeString()); } + public function testTouchPreservesRemovedRelatedScopes(): void + { + Schema::table('tags', fn (Blueprint $table) => $table->softDeletes()); + $post = Post::create(['title' => Str::random()]); + $tag = TouchSoftDeletedTag::create(['name' => 'deleted']); + $other = TouchSoftDeletedTag::create(['name' => 'active']); + $relation = $post->belongsToMany(TouchSoftDeletedTag::class, 'posts_tags', 'post_id', 'tag_id'); + $relation->attach([$tag->getKey(), $other->getKey()]); + $tag->delete(); + TouchSoftDeletedTag::withTrashed()->update(['updated_at' => '2000-01-01 00:00:00']); + + $relation->touch(); + + $this->assertSame('2000-01-01 00:00:00', $tag->fresh()->updated_at->format('Y-m-d H:i:s')); + $this->assertNotSame('2000-01-01 00:00:00', $other->fresh()->updated_at->format('Y-m-d H:i:s')); + + $relation->withTrashed()->touch(); + + $this->assertNotSame('2000-01-01 00:00:00', $tag->fresh()->updated_at->format('Y-m-d H:i:s')); + } + public function testCanTouchRelatedModelsUsingCustomRelatedKey(): void { $post = Post::create(['title' => Str::random()]); @@ -2067,6 +2089,11 @@ public function postsWithChaperone(): BelongsToMany } } +class TouchSoftDeletedTag extends Tag +{ + use SoftDeletes; +} + class UniqueTag extends Model { protected ?string $table = 'unique_tags'; From 441a7eb3a9643d2f65e7a55213a89d24b69e8a73 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:06:45 +0000 Subject: [PATCH 24/31] Keep one-of-many subqueries independent of outer model aliases Port the complete Laravel framework #62043 fix from f72822e5b8d88729a479deae0bef93561b444932, reconciled against the frozen 13.x target 5d7797528723107cd4f495326838a7d7d67fbb20. Build aggregate subqueries from a cloned model and retain the join model used for qualification. Self-referencing relations no longer inherit the outer query alias inside their aggregate subqueries. Preserve native types and the existing polymorphic type checks. Include the upstream score and soft-delete fixtures and all applicable relation assertions, retaining distinct polymorphic coverage. Complete affected files and native MySQL and PostgreSQL relationship cases pass. --- .../Relations/Concerns/CanBeOneOfMany.php | 18 +++-- .../src/Eloquent/Relations/MorphOne.php | 1 - .../DatabaseEloquentHasOneOfManyTest.php | 66 +++++++++++++++++++ .../DatabaseEloquentMorphOneOfManyTest.php | 36 ++++++---- 4 files changed, 104 insertions(+), 17 deletions(-) diff --git a/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php b/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php index 5945ea490..65baa2d5b 100644 --- a/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php +++ b/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Database\Eloquent\Builder; +use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Query\JoinClause; use Hypervel\Support\Arr; use Hypervel\Support\Collection; @@ -30,6 +31,11 @@ trait CanBeOneOfMany */ protected ?Builder $oneOfManySubQuery = null; + /** + * The model of the query the one of many subselect is currently being joined to. + */ + protected ?Model $oneOfManyJoinModel = null; + /** * Add constraints for inner join subselect for one of many relationships. * @@ -171,7 +177,7 @@ protected function getDefaultOneOfManyJoinAlias(string $relation): string */ protected function newOneOfManySubQuery(string|array $groupBy, ?array $columns = null, ?string $aggregate = null): Builder { - $subQuery = $this->query->getModel() + $subQuery = (clone $this->query->getModel()) ->newQuery() ->withoutGlobalScopes($this->removedScopes()); @@ -207,15 +213,19 @@ protected function newOneOfManySubQuery(string|array $groupBy, ?array $columns = */ protected function addOneOfManyJoinSubQuery(Builder $parent, Builder $subQuery, array $on): void { - $parent->beforeQuery(function ($parent) use ($subQuery, $on) { + $parent->beforeQuery(function ($query) use ($parent, $subQuery, $on) { $subQuery->applyBeforeQueryCallbacks(); - $parent->joinSub($subQuery, $this->relationName, function ($join) use ($on) { + $query->joinSub($subQuery, $this->relationName, function ($join) use ($parent, $on) { + $this->oneOfManyJoinModel = $parent->getModel(); + foreach ($on as $onColumn) { $join->on($this->qualifySubSelectColumn($onColumn . '_aggregate'), '=', $this->qualifyRelatedColumn($onColumn)); } $this->addOneOfManyJoinSubQueryConstraints($join); + + $this->oneOfManyJoinModel = null; }); }); } @@ -267,7 +277,7 @@ public function qualifySubSelectColumn(string $column): string */ protected function qualifyRelatedColumn(string $column): string { - return $this->query->getModel()->qualifyColumn($column); + return ($this->oneOfManyJoinModel ?? $this->query->getModel())->qualifyColumn($column); } /** diff --git a/src/database/src/Eloquent/Relations/MorphOne.php b/src/database/src/Eloquent/Relations/MorphOne.php index d6c694ba3..491018974 100644 --- a/src/database/src/Eloquent/Relations/MorphOne.php +++ b/src/database/src/Eloquent/Relations/MorphOne.php @@ -50,7 +50,6 @@ public function match(array $models, EloquentCollection $results, string $relati public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder { - // Alias self-relations before adding joins that qualify the related columns. $query = parent::getRelationExistenceQuery($query, $parentQuery, $columns); if ($this->isOneOfMany()) { diff --git a/tests/Database/DatabaseEloquentHasOneOfManyTest.php b/tests/Database/DatabaseEloquentHasOneOfManyTest.php index 2fd3e0444..6535c8e2f 100755 --- a/tests/Database/DatabaseEloquentHasOneOfManyTest.php +++ b/tests/Database/DatabaseEloquentHasOneOfManyTest.php @@ -66,6 +66,13 @@ public function createSchema() $this->schema()->create('comments', function (Blueprint $table): void { $table->increments('id'); $table->foreignId('parent_id')->nullable(); + $table->integer('score')->default(0); + }); + + $this->schema()->create('soft_deleting_comments', function (Blueprint $table): void { + $table->increments('id'); + $table->foreignId('parent_id')->nullable(); + $table->softDeletes(); }); } @@ -79,6 +86,7 @@ protected function tearDown(): void $this->schema()->drop('states'); $this->schema()->drop('prices'); $this->schema()->drop('comments'); + $this->schema()->drop('soft_deleting_comments'); parent::tearDown(); } @@ -319,6 +327,29 @@ public function testExistenceQueriesOnSelfReferencingRelation(): void $this->assertEquals([3, 4], Comment::doesntHave('latestReply')->pluck('id')->all()); } + public function testExistenceQueriesOnSelfReferencingRelationWithAggregateColumn(): void + { + $first = Comment::create(); + $second = Comment::create(); + $first->replies()->create(['score' => 5]); + $first->replies()->create(['score' => 3]); + $second->replies()->create(['score' => 1]); + + $this->assertEquals([$first->id], Comment::whereHas('topReply', fn ($query) => $query->where('score', 5))->pluck('id')->all()); + $this->assertEquals([1, 1, 0, 0, 0], Comment::withCount('topReply')->pluck('top_reply_count')->all()); + } + + public function testExistenceQueriesOnSelfReferencingRelationWithSoftDeletes(): void + { + $first = SoftDeletingComment::create(); + $second = SoftDeletingComment::create(); + $first->replies()->create(); + $second->replies()->create()->delete(); + + $this->assertEquals([$first->id], SoftDeletingComment::has('latestReply')->pluck('id')->all()); + $this->assertEquals([1, 0, 0], SoftDeletingComment::withCount('latestReply')->pluck('latest_reply_count')->all()); + } + public function testHasCount(): void { $user = User::create(); @@ -759,4 +790,39 @@ public function latestReply(): HasOne { return $this->hasOne(self::class, 'parent_id')->latestOfMany(); } + + /** + * Get the highest-scoring reply. + */ + public function topReply(): HasOne + { + return $this->hasOne(self::class, 'parent_id')->ofMany('score', 'max'); + } +} + +class SoftDeletingComment extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'soft_deleting_comments'; + + protected array $guarded = []; + + public bool $timestamps = false; + + /** + * Get the comment's replies. + */ + public function replies(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + /** + * Get the latest reply. + */ + public function latestReply(): HasOne + { + return $this->hasOne(self::class, 'parent_id')->latestOfMany(); + } } diff --git a/tests/Database/DatabaseEloquentMorphOneOfManyTest.php b/tests/Database/DatabaseEloquentMorphOneOfManyTest.php index 088feddd4..ab5d9dc41 100644 --- a/tests/Database/DatabaseEloquentMorphOneOfManyTest.php +++ b/tests/Database/DatabaseEloquentMorphOneOfManyTest.php @@ -49,6 +49,7 @@ public function createSchema(): void $this->schema()->create('comments', function (Blueprint $table): void { $table->increments('id'); $table->nullableMorphs('commentable'); + $table->integer('score')->default(0); }); } @@ -116,18 +117,6 @@ public function testForceCreateMorphType(): void $this->assertSame(Product::class, $product->current_state->stateful_type); } - public function testExistenceQueriesOnSelfReferencingRelation(): void - { - $first = Comment::create(); - $second = Comment::create(); - $first->replies()->create(); - Comment::create(['commentable_id' => $second->id, 'commentable_type' => Product::class]); - - $this->assertEquals([$first->id], Comment::has('latestReply')->pluck('id')->all()); - $this->assertEquals([1, 0, 0, 0], Comment::withCount('latestReply')->pluck('latest_reply_count')->all()); - $this->assertEquals([2, 3, 4], Comment::doesntHave('latestReply')->pluck('id')->all()); - } - public function testExists(): void { $product = Product::create(); @@ -219,6 +208,21 @@ public function testWithExistsWithConstraintsInJoinSubSelect(): void $this->assertTrue($product->current_foo_state_exists); } + public function testExistenceQueriesOnSelfReferencingRelation(): void + { + $first = Comment::create(); + $second = Comment::create(); + $firstReply = $first->replies()->create(['score' => 5]); + $first->replies()->create(['score' => 3]); + $second->replies()->create(['score' => 1]); + Comment::create(['commentable_id' => $firstReply->id, 'commentable_type' => Product::class, 'score' => 9]); + + $this->assertEquals([$first->id, $second->id], Comment::has('latestReply')->pluck('id')->all()); + $this->assertEquals([$first->id], Comment::whereHas('topReply', fn ($query) => $query->where('score', 5))->pluck('id')->all()); + $this->assertEquals([1, 1, 0, 0, 0, 0], Comment::withCount('latestReply')->pluck('latest_reply_count')->all()); + $this->assertEquals([3, 4, 5, 6], Comment::doesntHave('latestReply')->pluck('id')->all()); + } + /** * Get a database connection instance. */ @@ -302,4 +306,12 @@ public function latestReply(): MorphOne { return $this->morphOne(self::class, 'commentable')->latestOfMany(); } + + /** + * Get the highest-scoring reply. + */ + public function topReply(): MorphOne + { + return $this->morphOne(self::class, 'commentable')->ofMany('score', 'max'); + } } From 3503956959331bb7aac270bf28dd4f477b08d2bc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:06:54 +0000 Subject: [PATCH 25/31] Synchronize written increment values through model change hooks Capture extra-attribute changes before updating observers run so increment and incrementEach report the values actually written without rerunning nondeterministic cast setters. Preserve observer changes as pending edits, including mutations to cached cast objects on the refresh path. Restore calls to public syncChanges overrides during save and increment. Pass prepared changes through a temporary coroutine-local model/value pair, with identity checks and finally restoration for nested writes, clones and throwing hooks. Keep the approved refreshSavedAttributes argument and disabled-refresh fast path. Cover encrypted and binary extra values, observer dirty-state reversals, pending object edits and override restoration. The complete update file, affected model suites and targeted static analysis pass; native database checks cover the cast and binding paths. --- .../src/Eloquent/Concerns/HasAttributes.php | 18 +- src/database/src/Eloquent/Model.php | 40 +++- .../DatabaseEloquentIntegrationTest.php | 1 + .../Database/EloquentUpdateTest.php | 197 ++++++++++++++++++ 4 files changed, 246 insertions(+), 10 deletions(-) diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index 97a67be3d..d2bb2a9d3 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -2216,7 +2216,12 @@ public function syncOriginalAttributes(array|string $attributes): static */ public function syncChanges(): static { - return $this->syncChangesFrom($this->getDirty()); + /** @var null|array{model: static, changes: array} $pending */ + $pending = CoroutineContext::get(self::SYNC_CHANGES_CONTEXT_KEY); + $this->changes = ($pending['model'] ?? null) === $this ? $pending['changes'] : $this->getDirty(); + $this->previous = array_intersect_key($this->getRawOriginal(), $this->changes); + + return $this; } /** @@ -2226,10 +2231,15 @@ public function syncChanges(): static */ private function syncChangesFrom(array $changes): static { - $this->changes = $changes; - $this->previous = array_intersect_key($this->getRawOriginal(), $changes); + // Pass stored values through the zero-argument hook so overrides run without repeating cached setters. + $previous = CoroutineContext::get(self::SYNC_CHANGES_CONTEXT_KEY); + CoroutineContext::set(self::SYNC_CHANGES_CONTEXT_KEY, ['model' => $this, 'changes' => $changes]); - return $this; + try { + return $this->syncChanges(); + } finally { + CoroutineContext::set(self::SYNC_CHANGES_CONTEXT_KEY, $previous); + } } /** diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index 53648d048..d8ffe48e1 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -96,6 +96,11 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt */ protected const string MISSING_ATTRIBUTE_ACCESS_SUPPRESSED_CONTEXT_KEY = '__database.model.missing_attribute_access_suppressed'; + /** + * Context key for passing a write's changes through the synchronization hook. + */ + protected const string SYNC_CHANGES_CONTEXT_KEY = '__database.model.sync_changes'; + /** * Context key for storing whether mass assignment is unguarded. */ @@ -1211,6 +1216,9 @@ protected function incrementOrDecrement(string $column, mixed $amount, array $ex $extra = array_merge($extra, array_intersect_key($this->getAttributes(), $extra)); + // The statement stores these values even if an updating listener changes them, so compare them now. + $extraChanges = $this->getDirtyFromAttributes($extra); + if ($this->fireModelEvent('updating') === false) { return false; } @@ -1223,16 +1231,16 @@ protected function incrementOrDecrement(string $column, mixed $amount, array $ex $column, $amount, $this->prepareBinaryAttributesForDatabase($extra) - ), function () use ($column, $extra): void { + ), function () use ($column, $extra, $extraChanges): void { if ($this->refreshes !== []) { - // Increments skip the save path's cast merge, so keep unsaved cached cast changes. + // Refreshing drops the cast cache, so keep in-place edits made by updating listeners. $this->mergeAttributesFromCachedCasts(); // The statement applies the extra values after the increment, so they win. $this->refreshSavedAttributes($extra + [$column => $this->attributes[$column]]); } - $this->syncChanges(); + $this->syncIncrementedChanges($extra, $extraChanges); $this->fireModelEvent('updated', false); @@ -1384,6 +1392,9 @@ protected function incrementOrDecrementEach(array $columns, array $extra, string $extra = array_merge($extra, array_intersect_key($this->getAttributes(), $extra)); + // The statement stores these values even if an updating listener changes them, so compare them now. + $extraChanges = $this->getDirtyFromAttributes($extra); + if ($this->fireModelEvent('updating') === false) { return false; } @@ -1401,16 +1412,16 @@ protected function incrementOrDecrementEach(array $columns, array $extra, string return tap( $this->setKeysForSaveQuery($this->newQueryWithoutScopes()) ->{$method}($dbColumns, $this->prepareBinaryAttributesForDatabase($extra)), - function () use ($columns, $extra): void { + function () use ($columns, $extra, $extraChanges): void { if ($this->refreshes !== []) { - // Increments skip the save path's cast merge, so keep unsaved cached cast changes. + // Refreshing drops the cast cache, so keep in-place edits made by updating listeners. $this->mergeAttributesFromCachedCasts(); // The statement applies the extra values after the increments, so they win. $this->refreshSavedAttributes($extra + array_intersect_key($this->attributes, $columns)); } - $this->syncChanges(); + $this->syncIncrementedChanges($extra, $extraChanges); $this->fireModelEvent('updated', false); @@ -1419,6 +1430,23 @@ function () use ($columns, $extra): void { ); } + /** + * Sync changes using the extra values an increment stored. + * + * @param array $extra + * @param array $extraChanges + */ + private function syncIncrementedChanges(array $extra, array $extraChanges): void + { + $stored = array_diff_key($extra, array_flip($this->refreshes)); + + // Reuse the written values without running cached setters again; database-refreshed values win. + $this->syncChangesFrom( + $this->getDirtyFromAttributes(array_diff_key($this->attributes, $stored)) + + array_intersect_key($extraChanges, $stored) + ); + } + /** * Sync the original attributes with the values an increment stored. * diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index 7fda64c62..57ee279c0 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -445,6 +445,7 @@ public function testIncrementsTrackTheValuesTheyStore(): void $this->assertSame('RESTOCKED', $item->note); $this->assertFalse($item->isDirty('note')); $this->assertSame(60, $item->getOriginal('id')); + $this->assertSame(60, $item->getChanges()['id']); $this->assertSame(61, $item->id); $this->assertNull(GeneratedTotal::find($id)); diff --git a/tests/Integration/Database/EloquentUpdateTest.php b/tests/Integration/Database/EloquentUpdateTest.php index 2b5919b63..4ed3d0053 100644 --- a/tests/Integration/Database/EloquentUpdateTest.php +++ b/tests/Integration/Database/EloquentUpdateTest.php @@ -4,12 +4,18 @@ namespace Hypervel\Tests\Integration\Database; +use Closure; use Hypervel\Contracts\Database\Eloquent\CastsAttributes; +use Hypervel\Database\Eloquent\Casts\AsEncryptedCollection; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Schema\Blueprint; +use Hypervel\Support\Collection; +use Hypervel\Support\Facades\Crypt; use Hypervel\Support\Facades\Schema; use Hypervel\Support\Str; +use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; class EloquentUpdateTest extends DatabaseTestCase { @@ -187,6 +193,82 @@ public function testGetDirtyUsesOverriddenAttributesAccessor(): void $this->assertSame(1, TestOverriddenAttributesUpdateModel::$getAttributesCalls); } + #[DataProvider('writeSynchronizationMethods')] + public function testWritesCallSyncChangesOverridesWithoutLeakingTheirValues(string $method): void + { + $model = TestSyncChangesUpdateModel::create(['name' => 'initial']); + $copy = null; + $model->onSyncChanges = function (Model $updated) use (&$copy): void { + $copy = clone $updated; + }; + $model->name = 'written'; + + match ($method) { + 'save' => $model->save(), + 'increment' => $model->increment('views', 1, ['name' => 'written']), + 'incrementEach' => $model->incrementEach(['views' => 1], ['name' => 'written']), + }; + + $this->assertSame(1, $model->syncCalls); + $this->assertSame('written', $model->getChanges()['name']); + + $copy->name = 'copied'; + $copy->syncChanges(); + $this->assertSame('copied', $copy->getChanges()['name']); + + $model->name = 'manual'; + $model->syncChanges(); + $this->assertSame('manual', $model->getChanges()['name']); + } + + /** + * Provide write paths that call the change synchronization hook. + */ + public static function writeSynchronizationMethods(): array + { + return [ + 'save' => ['save'], + 'increment' => ['increment'], + 'incrementEach' => ['incrementEach'], + ]; + } + + public function testThrowingSyncChangesOverrideRestoresManualSynchronization(): void + { + $model = TestSyncChangesUpdateModel::create(['name' => 'initial']); + $failure = new RuntimeException('Synchronization failed.'); + $model->onSyncChanges = static fn () => throw $failure; + $caught = null; + + try { + $model->increment('views', 1, ['name' => 'written']); + } catch (RuntimeException $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $model->name = 'manual'; + $model->syncChanges(); + $this->assertSame('manual', $model->getChanges()['name']); + } + + public function testNestedWriteRestoresOuterSynchronizationValues(): void + { + $outer = TestSyncChangesUpdateModel::create(['name' => 'initial']); + $inner = TestSyncChangesUpdateModel::create(['name' => 'inner']); + $outer->onSyncChanges = static function (Model $model) use ($inner): void { + $model->name = 'pending'; + $inner->increment('views', 1, ['name' => 'inner-written']); + }; + + $outer->increment('views', 1, ['name' => 'outer-written']); + + $this->assertSame('outer-written', $outer->getChanges()['name']); + $this->assertSame('inner-written', $inner->getChanges()['name']); + $this->assertSame('pending', $outer->name); + $this->assertTrue($outer->isDirty('name')); + } + public function testSaveRetainsAllLogicalChangesWhenUpdateValuesAreFiltered(): void { $model = TestSelectiveUpdateModel::create([ @@ -382,6 +464,94 @@ public function testIncrementCastsExtraAttributes(): void $this->assertNotSame('another secret', TestUpdateModel5::query()->toBase()->value('name')); $this->assertSame('another secret', $post->fresh()->name); } + + #[DataProvider('encryptedIncrementCases')] + public function testIncrementReportsStoredCastValues(string $method, bool $refresh, bool $editInPlace): void + { + $modelClass = $refresh ? TestRefreshingUpdateModel::class : TestUpdateModel4::class; + $post = $modelClass::create(['name' => Crypt::encryptString('["initial"]')]); + $post->mergeCasts(['name' => AsEncryptedCollection::class]); + $updatedState = null; + $editPending = $editInPlace; + + $modelClass::updating(function (Model $model) use (&$editPending): void { + if ($editPending) { + $model->name->push('pending'); + $editPending = false; + } + }); + + $modelClass::updated(function (Model $model) use (&$updatedState): void { + $updatedState = [ + 'changed' => $model->getChanges()['name'], + 'stored' => $model->newQuery()->whereKey($model->getKey())->toBase()->value('name'), + ]; + }); + + $extra = ['name' => new Collection(['written'])]; + $arguments = $method === 'increment' ? ['views', 1, $extra] : [['views' => 1], $extra]; + $post->{$method}(...$arguments); + + $this->assertSame($updatedState['stored'], $updatedState['changed']); + $this->assertSame('["written"]', Crypt::decryptString($updatedState['stored'])); + $this->assertSame($editInPlace, $post->isDirty('name')); + + if ($editInPlace) { + $this->assertSame(['written', 'pending'], $post->name->all()); + $post->save(); + $this->assertSame(['written', 'pending'], $post->fresh()->name->all()); + } + } + + /** + * Provide cast writeback and refresh paths. + */ + public static function encryptedIncrementCases(): array + { + return [ + 'increment' => ['increment', false, false], + 'incrementEach' => ['incrementEach', false, false], + 'increment with refresh' => ['increment', true, false], + 'incrementEach with refresh' => ['incrementEach', true, false], + 'increment with refresh and pending object edit' => ['increment', true, true], + 'incrementEach with refresh and pending object edit' => ['incrementEach', true, true], + ]; + } + + #[DataProvider('incrementObserverCases')] + public function testIncrementExtraChangesCompareWrittenValues(string $method, string $written, string $pending): void + { + $post = TestUpdateModel4::create(['name' => 'initial']); + + TestUpdateModel4::updating(function (Model $model) use ($pending): void { + $model->name = $pending; + }); + + $arguments = $method === 'increment' ? ['views', 1, ['name' => $written]] : [['views' => 1], ['name' => $written]]; + $post->{$method}(...$arguments); + + $this->assertSame($written, $post->newQuery()->whereKey($post->getKey())->toBase()->value('name')); + $this->assertSame($written !== 'initial', $post->wasChanged('name')); + $this->assertSame($pending, $post->name); + $this->assertTrue($post->isDirty('name')); + + if ($written !== 'initial') { + $this->assertSame($written, $post->getChanges()['name']); + } + } + + /** + * Provide observer changes that reverse the stored value's dirty state. + */ + public static function incrementObserverCases(): array + { + return [ + 'increment observer restores original' => ['increment', 'changed', 'initial'], + 'incrementEach observer restores original' => ['incrementEach', 'changed', 'initial'], + 'increment observer changes unchanged extra' => ['increment', 'initial', 'changed'], + 'incrementEach observer changes unchanged extra' => ['incrementEach', 'initial', 'changed'], + ]; + } } class TestUpdateModel1 extends Model @@ -433,6 +603,33 @@ class TestUpdateModel5 extends Model protected array $casts = ['name' => 'encrypted']; } +class TestRefreshingUpdateModel extends TestUpdateModel4 +{ + protected array $refreshes = ['likes']; +} + +class TestSyncChangesUpdateModel extends TestUpdateModel4 +{ + public int $syncCalls = 0; + + public ?Closure $onSyncChanges = null; + + /** + * Observe synchronization and exercise a one-shot override callback. + */ + public function syncChanges(): static + { + ++$this->syncCalls; + + if ($callback = $this->onSyncChanges) { + $this->onSyncChanges = null; + $callback($this); + } + + return parent::syncChanges(); + } +} + class TestSelectiveUpdateModel extends TestUpdateModel1 { /** From 8cade85338cc84a8ac40da52f2cbdd471baef951 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:07:01 +0000 Subject: [PATCH 26/31] Keep lock inspection and release tied to the current owner Read database lock ownership and expiry from the write connection so replica lag cannot misreport live locks. Inspect file lock records without expiring or deleting a replacement owner during the read. Check file ownership under an exclusive lock before release. Detect an unlinked open handle after acquiring its lock, preventing both a waiting acquisition and a waiting release from acting on a replacement file. Reuse the existing lock file instead of adding sidecar locks or retry machinery. Add split-connection, replacement-owner, waiting-release and unlinked-handle regressions. A native acquisition interleaving, mutation check, affected files and lock consumers pass. --- src/cache/src/DatabaseLock.php | 2 + src/cache/src/FileLock.php | 16 ++++ src/cache/src/FileStore.php | 60 +++++++++++++++ src/filesystem/src/LockableFile.php | 14 ++++ tests/Cache/CacheDatabaseLockTest.php | 23 ++++++ tests/Cache/CacheFileStoreTest.php | 103 ++++++++++++++++++++++++++ tests/Filesystem/LockableFileTest.php | 21 ++++++ 7 files changed, 239 insertions(+) diff --git a/src/cache/src/DatabaseLock.php b/src/cache/src/DatabaseLock.php index 39d00acfb..c090c35b3 100644 --- a/src/cache/src/DatabaseLock.php +++ b/src/cache/src/DatabaseLock.php @@ -188,6 +188,7 @@ public function pruneExpiredLocks(): void protected function getCurrentOwner(): ?string { return $this->connection()->table($this->table) + ->useWritePdo() ->where('key', $this->name) ->where('expiration', '>', $this->currentTime()) ->first() @@ -266,6 +267,7 @@ public function getRemainingLifetime(): ?float $now = $this->currentTime(); $lock = $this->connection()->table($this->table) + ->useWritePdo() ->where('key', $this->name) ->where('expiration', '>', $now) ->first(); diff --git a/src/cache/src/FileLock.php b/src/cache/src/FileLock.php index bd0d41a85..02efd41c9 100644 --- a/src/cache/src/FileLock.php +++ b/src/cache/src/FileLock.php @@ -17,6 +17,22 @@ public function acquire(): bool return $this->fileStore()->add($this->name, $this->owner, $this->seconds); } + /** + * Release the lock without deleting another owner's replacement. + */ + public function release(): bool + { + return $this->fileStore()->forgetIfOwned($this->name, $this->owner); + } + + /** + * Return the current owner without deleting an expired lock record. + */ + protected function getCurrentOwner(): ?string + { + return $this->fileStore()->getLockOwner($this->name); + } + /** * Refresh the lock's TTL if still owned by this process. * diff --git a/src/cache/src/FileStore.php b/src/cache/src/FileStore.php index 8f4315084..cf1924e9c 100644 --- a/src/cache/src/FileStore.php +++ b/src/cache/src/FileStore.php @@ -105,6 +105,13 @@ public function add(string $key, mixed $value, int $seconds): bool return false; } + // A releasing owner can unlink this inode between opening it and acquiring its lock. + if ($file->wasDeleted()) { + $file->close(); + + return false; + } + $expire = $file->read(10); if (empty($expire) || $this->currentTime() >= $expire) { @@ -165,6 +172,59 @@ public function refreshIfOwned(string $key, string $expectedOwner, int $seconds) return true; } + /** + * Read the owner of a live lock without removing its file. + */ + public function getLockOwner(string $key): ?string + { + try { + $contents = $this->files->get($this->path($key), true); + + if ($this->currentTime() >= (int) substr($contents, 0, 10)) { + return null; + } + + return $this->unserialize(substr($contents, 10)); + } catch (CanceledException $exception) { + throw $exception; + } catch (Exception) { + return null; + } + } + + /** + * Remove a lock file only while its expected owner holds it. + */ + public function forgetIfOwned(string $key, string $expectedOwner): bool + { + if (! $this->files->exists($path = $this->path($key))) { + return false; + } + + $file = new LockableFile($path, 'c+'); + + try { + $file->getExclusiveLock(block: true); + + // A waiting release must never unlink the replacement for its old inode. + if ($file->wasDeleted()) { + return false; + } + + $contents = $file->read(); + $empty = strlen($contents) < 10; + $owned = ! $empty && $this->unserialize(substr($contents, 10)) === $expectedOwner; + $live = $owned && $this->currentTime() < (int) substr($contents, 0, 10); + + // Keep the lock through deletion; acquisition rejects any orphaned open handle. + $deleted = ($owned || $empty) && $this->files->delete($path); + + return $live && $deleted; + } finally { + $file->close(); + } + } + /** * Get the number of seconds until the given key expires. */ diff --git a/src/filesystem/src/LockableFile.php b/src/filesystem/src/LockableFile.php index ebf3b28b7..f1d637998 100644 --- a/src/filesystem/src/LockableFile.php +++ b/src/filesystem/src/LockableFile.php @@ -94,6 +94,20 @@ public function size(): int return $stat['size']; } + /** + * Determine whether the open file has been unlinked. + */ + public function wasDeleted(): bool + { + $stat = @fstat($this->handle); + + if ($stat === false) { + throw new RuntimeException("Unable to determine the link count of file at path [{$this->path}]."); + } + + return $stat['nlink'] === 0; + } + /** * Write to the file. */ diff --git a/tests/Cache/CacheDatabaseLockTest.php b/tests/Cache/CacheDatabaseLockTest.php index 2bfb6fda1..cdc2709d5 100644 --- a/tests/Cache/CacheDatabaseLockTest.php +++ b/tests/Cache/CacheDatabaseLockTest.php @@ -12,11 +12,13 @@ use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Query\Builder; use Hypervel\Database\QueryException; +use Hypervel\Database\SQLiteConnection; use Hypervel\Database\UniqueConstraintViolationException; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; +use PDO; use PDOException; use PHPUnit\Framework\Attributes\TestWith; @@ -36,6 +38,26 @@ public function testLockCanBeAcquired(): void $this->assertTrue($lock->acquire()); } + public function testOwnershipAndLifetimeReadTheWriterInsteadOfAStaleReplica(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestamp(1000)); + $writer = new PDO('sqlite::memory:'); + $reader = new PDO('sqlite::memory:'); + foreach ([$writer, $reader] as $pdo) { + $pdo->exec('create table cache_locks (key text primary key, owner text, expiration integer)'); + } + $writer->exec("insert into cache_locks values ('key', 'fresh', 1060)"); + $reader->exec("insert into cache_locks values ('key', 'stale', 1010)"); + $connection = new SQLiteConnection($writer, ':memory:'); + $connection->setReadPdo($reader); + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('connection')->andReturn($connection); + $lock = new DatabaseLock($resolver, null, 'key', 'cache_locks', 60, 'fresh', null); + + $this->assertTrue($lock->isOwnedByCurrentProcess()); + $this->assertSame(60.0, $lock->getRemainingLifetime()); + } + public function testFractionalSecondAcquireAndRefreshNeverExpireBeforeRequestedDeadline(): void { CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); @@ -464,6 +486,7 @@ protected function getLock(int $seconds = 10, ?array $lockLottery = [0, 1], ?str $resolver = m::mock(ConnectionResolverInterface::class); $connection = m::mock(ConnectionInterface::class); $table = m::mock(Builder::class); + $table->shouldReceive('useWritePdo')->andReturnSelf()->byDefault(); $resolver->shouldReceive('connection')->with($connectionName)->andReturn($connection); $connection->shouldReceive('getDriverName')->andReturn($driver); diff --git a/tests/Cache/CacheFileStoreTest.php b/tests/Cache/CacheFileStoreTest.php index 5fec88d6f..daec1826b 100644 --- a/tests/Cache/CacheFileStoreTest.php +++ b/tests/Cache/CacheFileStoreTest.php @@ -5,13 +5,17 @@ namespace Hypervel\Tests\Cache; use __PHP_Incomplete_Class; +use Closure; use Exception; use Hypervel\Cache\FileStore; use Hypervel\Cache\Repository; use Hypervel\Cache\SerializableClassPolicy; +use Hypervel\Coroutine\Coroutine; +use Hypervel\Engine\Channel; use Hypervel\Filesystem\Filesystem; use Hypervel\Filesystem\LockableFile; use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Sleep; use Hypervel\Support\Str; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; @@ -58,6 +62,105 @@ public function testNullIsReturnedIfFileDoesntExist(): void $this->assertNull($value); } + public function testLockInspectionDoesNotDeleteAReplacementOwner(): void + { + Sleep::fake(syncWithCarbon: true); + $files = new class extends Filesystem { + public ?Closure $afterRead = null; + + /** + * Allow another owner to acquire after the shared read completes. + */ + public function get(string $path, bool $lock = false): string + { + $contents = parent::get($path, $lock); + + if ($callback = $this->afterRead) { + $this->afterRead = null; + $callback(); + } + + return $contents; + } + }; + $store = new FileStore($files, $this->tempDir); + $first = $store->lock('key', 1, 'first'); + $second = $store->lock('key', 60, 'second'); + $this->assertTrue($first->acquire()); + Sleep::for(2)->seconds(); + $files->afterRead = function () use ($second): void { + $this->assertTrue($second->acquire()); + }; + + $this->assertFalse($first->isLocked()); + $this->assertTrue($second->isOwnedByCurrentProcess()); + } + + public function testLockReleaseKeepsOwnershipUntilDeletion(): void + { + Sleep::fake(syncWithCarbon: true); + $files = new class extends Filesystem { + public ?Closure $beforeDelete = null; + + /** + * Attempt takeover after ownership verification but before deletion. + */ + public function delete(array|string $paths): bool + { + if ($callback = $this->beforeDelete) { + $this->beforeDelete = null; + $callback(); + } + + return parent::delete($paths); + } + }; + $store = new FileStore($files, $this->tempDir); + $first = $store->lock('key', 1, 'first'); + $second = $store->lock('key', 60, 'second'); + $this->assertTrue($first->acquire()); + $files->beforeDelete = function () use ($second): void { + Sleep::for(2)->seconds(); + $this->assertFalse($second->acquire()); + }; + + $this->assertTrue($first->release()); + $this->assertSame([], $files->allFiles($this->tempDir)); + $this->assertTrue($second->acquire()); + $this->assertFalse($first->release()); + $this->assertTrue($second->isOwnedByCurrentProcess()); + } + + public function testWaitingLockReleaseDoesNotDeleteAReplacementOwner(): void + { + $store = new FileStore(new Filesystem, $this->tempDir); + $first = $store->lock('key', 60, 'first'); + $second = $store->lock('key', 60, 'second'); + $this->assertTrue($first->acquire()); + $path = $store->path('file-store-lock:key'); + $holder = new LockableFile($path, 'c+'); + $holder->getExclusiveLock(); + $released = new Channel(1); + + try { + Coroutine::create(static function () use ($first, $released): void { + $released->push([$first->release()]); + }); + + $this->assertFalse($released->pop(0.05)); + unlink($path); + $this->assertTrue($second->acquire()); + } finally { + $holder->close(); + $result = $released->pop(1.0); + $released->close(); + } + + // An array distinguishes a completed false result from a channel timeout. + $this->assertSame([false], $result); + $this->assertTrue($second->isOwnedByCurrentProcess()); + } + public function testGetPreservesCancellationFromFilesystemRead(): void { $cancellation = new CanceledException('read canceled'); diff --git a/tests/Filesystem/LockableFileTest.php b/tests/Filesystem/LockableFileTest.php index c6c82cf00..62e51713e 100644 --- a/tests/Filesystem/LockableFileTest.php +++ b/tests/Filesystem/LockableFileTest.php @@ -41,6 +41,27 @@ protected function tearDown(): void parent::tearDown(); } + public function testDeletedHandleIsDistinctFromAReplacementAtTheSamePath(): void + { + $path = $this->tempDir . '/deleted.lock'; + $file = new LockableFile($path, 'c+'); + + try { + $this->assertFalse($file->wasDeleted()); + unlink($path); + $replacement = new LockableFile($path, 'c+'); + + try { + $this->assertTrue($file->wasDeleted()); + $this->assertFalse($replacement->wasDeleted()); + } finally { + $replacement->close(); + } + } finally { + $file->close(); + } + } + public function testBlockingLocksSerializeAccess(): void { $path = $this->tempDir . '/blocking.lock'; From 3a103d81eedc9b6a03bd3ddceb066d2d951c5412 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:07:07 +0000 Subject: [PATCH 27/31] Require FTP adapters that support path-based MIME detection Reject Flysystem FTP and SFTP adapters below 3.13 in the split package and development root, since older constructors do not accept the MIME path option. Keep installation examples aligned with the existing recommended adapter version. Require the CI runtime to expose Swoole FTP functions without loading the conflicting native FTP extension. Composer resolution and validation pass, and both existing PHP CI images satisfy the capability check. --- .github/docker/ci/Dockerfile | 2 +- composer.json | 4 +++- src/docs/filesystem.md | 4 ++-- src/filesystem/composer.json | 4 ++++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/docker/ci/Dockerfile b/.github/docker/ci/Dockerfile index aa06494e9..2de26f488 100644 --- a/.github/docker/ci/Dockerfile +++ b/.github/docker/ci/Dockerfile @@ -96,7 +96,7 @@ RUN set -eux; \ RUN printf '%s\n' 'swoole.use_shortname=Off' > /usr/local/etc/php/conf.d/99-swoole-shortname.ini # The development Composer root declares the FTP capability supplied by Swoole. -RUN php -r 'if (! function_exists("ftp_connect")) { fwrite(STDERR, "Swoole FTP support is required for filesystem tests.\n"); exit(1); }' +RUN php -r 'if (! function_exists("ftp_connect") || extension_loaded("ftp")) { fwrite(STDERR, "Swoole FTP support without the separate FTP extension is required for filesystem tests.\n"); exit(1); }' RUN set -eux; \ test "$(php -r 'echo PHP_MAJOR_VERSION, ".", PHP_MINOR_VERSION;')" = "${PHP_VERSION}"; \ diff --git a/composer.json b/composer.json index 780611a51..0f1a21525 100644 --- a/composer.json +++ b/composer.json @@ -240,7 +240,9 @@ }, "conflict": { "aws/aws-sdk-php": "<3.369.1", - "intervention/image": "<4.0 || >=5.0" + "intervention/image": "<4.0 || >=5.0", + "league/flysystem-ftp": "<3.13", + "league/flysystem-sftp-v3": "<3.13" }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md index 9960d16f8..c7255f289 100644 --- a/src/docs/filesystem.md +++ b/src/docs/filesystem.md @@ -146,7 +146,7 @@ Swoole supplies the FTP functions without registering as `ext-ftp`. After enabli Then install the Flysystem FTP package: ```shell -composer require league/flysystem-ftp "^3.0" +composer require league/flysystem-ftp "^3.25.1" ``` Hypervel's Flysystem integrations work great with FTP; however, a sample configuration is not included with the framework's default `config/filesystems.php` configuration file. If you need to configure an FTP filesystem, you may use the configuration example below: @@ -173,7 +173,7 @@ Hypervel's Flysystem integrations work great with FTP; however, a sample configu Before using the SFTP driver, you will need to install the Flysystem SFTP package via the Composer package manager: ```shell -composer require league/flysystem-sftp-v3 "^3.0" +composer require league/flysystem-sftp-v3 "^3.25.1" ``` Hypervel's Flysystem integrations work great with SFTP; however, a sample configuration is not included with the framework's default `config/filesystems.php` configuration file. If you need to configure an SFTP filesystem, you may use the configuration example below: diff --git a/src/filesystem/composer.json b/src/filesystem/composer.json index 38bbdd035..d848a0c8d 100644 --- a/src/filesystem/composer.json +++ b/src/filesystem/composer.json @@ -47,6 +47,10 @@ "symfony/http-foundation": "^8.1", "symfony/http-kernel": "^8.1" }, + "conflict": { + "league/flysystem-ftp": "<3.13", + "league/flysystem-sftp-v3": "<3.13" + }, "suggest": { "ext-fileinfo": "Required to use the Filesystem class.", "hypervel/image": "Required to create images from stored files (^0.4).", From db07ca47544a731dff5b4ded988bcfd82044c58b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:07:12 +0000 Subject: [PATCH 28/31] Clarify JSON API collection loader ownership Document that root collection preloading runs on the first resource with the whole batch, while nested relationship loading retains its parent resource. Rename the constructor-dependent regression to describe the parent identity it protects. Preserve resource-aware batching instead of reintroducing one query per root. Existing JSON API unit and integration coverage passes; runtime behavior is unchanged. --- .../src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php | 3 +++ .../Http/Resources/JsonApi/JsonApiRelationshipLoadingTest.php | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php b/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php index 094ec44c7..8157366d1 100644 --- a/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php +++ b/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php @@ -355,6 +355,9 @@ public function loadMissingRequestedRelationshipsOn(JsonApiRequest $request, Col /** * Load the requested relationships that are missing on the given related models. + * + * Collection loading starts on the first resource with every root model. + * Overrides must apply constraints to the supplied models, which may belong to different parents. */ protected function loadMissingRequestedRelationships( JsonApiRequest $request, diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipLoadingTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipLoadingTest.php index 79405463e..04e5ba81e 100644 --- a/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipLoadingTest.php +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipLoadingTest.php @@ -18,7 +18,7 @@ class JsonApiRelationshipLoadingTest extends TestCase { - public function testEachParentKeepsItsOwnLoaderConstraint(): void + public function testNestedLoaderRetainsParentWhenResourceNeedsConstructorArguments(): void { $users = User::factory()->count(2)->create(); foreach ($users as $user) { @@ -28,6 +28,7 @@ public function testEachParentKeepsItsOwnLoaderConstraint(): void } } + // The root batch cannot reconstruct this resource; nested loading retains each existing parent. $collection = new AnonymousResourceCollection(new BaseCollection([ new ParentSpecificUserResource($users[0], 'published'), new ParentSpecificUserResource($users[1], 'draft'), From 4c2556b75b6d72c715a244f38cc866559d9116a5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:07:18 +0000 Subject: [PATCH 29/31] Avoid repeated array reindexing in string replacement Normalize replacement values once and consume them by index in Str::replaceArray. Repeated array_shift calls made formatting SQL errors with many bindings quadratic in the replacement count. Preserve associative input order, missing replacements, value conversion and cancellation behavior. The complete existing SupportStrTest suite passes without adding duplicate tests or timing benchmarks. --- src/support/src/Str.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/support/src/Str.php b/src/support/src/Str.php index 860d1188b..568f88ab0 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -1082,12 +1082,14 @@ public static function replaceArray(string $search, iterable $replace, string $s $replace = iterator_to_array($replace); } + // Indexing avoids repeatedly renumbering large binding lists while formatting query errors. + $replace = array_values($replace); $segments = explode($search, $subject); $result = array_shift($segments); - foreach ($segments as $segment) { - $result .= self::toStringOr(array_shift($replace) ?? $search, $search) . $segment; + foreach ($segments as $index => $segment) { + $result .= self::toStringOr($replace[$index] ?? $search, $search) . $segment; } return $result; From 7b72bd745b607ca11a55d705acd7d4086cb49703 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:07:22 +0000 Subject: [PATCH 30/31] Remove an unused exception binding from the reconnect regression Keep the QueryException catch and all reconnect assertions while dropping its unused variable. The complete PDO connection test file passes. --- tests/Database/DatabasePdoConnectionTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Database/DatabasePdoConnectionTest.php b/tests/Database/DatabasePdoConnectionTest.php index a0d0f84f5..2e70b8b00 100755 --- a/tests/Database/DatabasePdoConnectionTest.php +++ b/tests/Database/DatabasePdoConnectionTest.php @@ -899,7 +899,7 @@ public function testQueriesRunWhileReconnectingDoNotReconnectAgain(): void $connection->statement('foo'); $this->fail('A QueryException was not thrown.'); - } catch (QueryException $e) { + } catch (QueryException) { $this->assertSame(1, $reconnects); } } From 561748477612332279d3588538e9c4468b757e76 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:52:48 +0000 Subject: [PATCH 31/31] Reject refreshes through replaced file-lock handles Check the open file link count after acquiring the refresh lock. A normal release and reacquisition between opening and locking the file must not let the previous owner report a successful refresh of an orphaned inode. Use an isolated native-lock interception fixture to cover both acquisition and refresh at the replacement boundary. Removing the acquisition guard reproduces two reported owners; the full file-store and file-lock suites pass with the guards intact. Clarify that JSON API collection loaders recurse over nested batches on the first resource before per-parent compilation, and add the missing Mercure test-provider docblock. Targeted analysis, formatting and affected tests pass. --- src/cache/src/FileStore.php | 7 +++ .../Concerns/ResolvesJsonApiElements.php | 2 +- tests/Cache/CacheFileStoreTest.php | 23 ++++++++ tests/Cache/Fixtures/replaced_lock_file.php | 58 +++++++++++++++++++ .../Broadcasting/BroadcastManagerTest.php | 3 + 5 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 tests/Cache/Fixtures/replaced_lock_file.php diff --git a/src/cache/src/FileStore.php b/src/cache/src/FileStore.php index cf1924e9c..49d79caea 100644 --- a/src/cache/src/FileStore.php +++ b/src/cache/src/FileStore.php @@ -146,6 +146,13 @@ public function refreshIfOwned(string $key, string $expectedOwner, int $seconds) return false; } + // Release may replace the path after this refresh opens the old inode. + if ($file->wasDeleted()) { + $file->close(); + + return false; + } + $contents = $file->read(); if (strlen($contents) < 10) { diff --git a/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php b/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php index 8157366d1..69fc92c58 100644 --- a/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php +++ b/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php @@ -356,7 +356,7 @@ public function loadMissingRequestedRelationshipsOn(JsonApiRequest $request, Col /** * Load the requested relationships that are missing on the given related models. * - * Collection loading starts on the first resource with every root model. + * Collection loading starts on the first resource with every root model, then recurses with each nested batch. * Overrides must apply constraints to the supplied models, which may belong to different parents. */ protected function loadMissingRequestedRelationships( diff --git a/tests/Cache/CacheFileStoreTest.php b/tests/Cache/CacheFileStoreTest.php index daec1826b..0f52cec3d 100644 --- a/tests/Cache/CacheFileStoreTest.php +++ b/tests/Cache/CacheFileStoreTest.php @@ -20,11 +20,13 @@ use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\MockObject\MockObject; use ReflectionProperty; use RuntimeException; use stdClass; use Swoole\Coroutine\CanceledException; +use Symfony\Component\Process\Process; class CacheFileStoreTest extends TestCase { @@ -614,6 +616,27 @@ public function testRefreshReturnsFalseWhenFileLockCannotBeAcquired(): void } } + #[TestWith(['acquire'])] + #[TestWith(['refresh'])] + public function testLockOperationsRejectAFileReplacedAfterOpening(string $operation): void + { + // Isolate the native flock interception so it cannot affect another test. + $process = new Process([ + PHP_BINARY, + __DIR__ . '/Fixtures/replaced_lock_file.php', + dirname(__DIR__, 2) . '/vendor/autoload.php', + $this->tempDir, + $operation, + ], timeout: 10); + $process->run(); + + $this->assertTrue($process->isSuccessful(), $process->getErrorOutput()); + $this->assertSame([ + 'succeeded' => false, + 'replacementOwned' => true, + ], json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR)); + } + public function testRefreshPadsShortTimestampsToTenDigits(): void { CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC(990464400)); diff --git a/tests/Cache/Fixtures/replaced_lock_file.php b/tests/Cache/Fixtures/replaced_lock_file.php new file mode 100644 index 000000000..4c19530df --- /dev/null +++ b/tests/Cache/Fixtures/replaced_lock_file.php @@ -0,0 +1,58 @@ +lock('key', $operation === 'acquire' ? 1 : 60, 'original'); + $replacement = $store->lock('key', 60, 'replacement'); + + if (! $original->acquire()) { + throw new RuntimeException('Unable to acquire the original lock.'); + } + + if ($operation === 'acquire') { + Date::setTestNow(Date::now()->addSeconds(5)); + } + + $GLOBALS['replaceLockFile'] = static function () use ($original, $replacement): void { + $original->release(); + + if (! $replacement->acquire()) { + throw new RuntimeException('Unable to replace the lock.'); + } + }; + + echo json_encode([ + 'succeeded' => match ($operation) { + 'acquire' => $store->lock('key', 60, 'contender')->acquire(), + 'refresh' => $original->refresh(60), + }, + 'replacementOwned' => $replacement->isOwnedByCurrentProcess(), + ], JSON_THROW_ON_ERROR); +} diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php index 4e72dde27..790a7998d 100644 --- a/tests/Integration/Broadcasting/BroadcastManagerTest.php +++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php @@ -1100,6 +1100,9 @@ public function testMercurePreservesExplicitQueueSemantics(ShouldBroadcast $even } } + /** + * Provide Mercure events with their expected queue routing. + */ public static function mercureQueueSemantics(): array { return [