diff --git a/.github/docker/ci/Dockerfile b/.github/docker/ci/Dockerfile index 2d205226b4..2de26f4885 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") || 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}"; \ test "$(php -r 'echo phpversion("swoole");')" = "${SWOOLE_VERSION}"; \ diff --git a/AGENTS.md b/AGENTS.md index aecf43eeb5..e1c712928b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -794,9 +794,9 @@ 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. +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. diff --git a/composer.json b/composer.json index 1c8acdc95e..0f1a215259 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", @@ -235,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/docs/todo.md b/docs/todo.md index f3cd082504..4c87f99b86 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/skeletons/hypervel/config/broadcasting.php b/skeletons/hypervel/config/broadcasting.php index 3fce661211..3b0c335554 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 3fce661211..3b0c335554 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/BroadcastEvent.php b/src/broadcasting/src/BroadcastEvent.php index e434a3d0b4..2c4f6bb336 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 1fddab18a2..1340780a20 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,9 +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()) - ) { + $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) ); @@ -233,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; @@ -257,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/broadcasting/src/Mercure/CreatesMercureDrivers.php b/src/broadcasting/src/Mercure/CreatesMercureDrivers.php index 6bdc9fd493..9d0264eb02 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 aeead47a85..c4f81799de 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/cache/src/DatabaseLock.php b/src/cache/src/DatabaseLock.php index 39d00acfb8..c090c35b34 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 bd0d41a85d..02efd41c95 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 8f43150843..49d79caeac 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) { @@ -139,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) { @@ -165,6 +179,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/cache/src/Limiters/ConcurrencyLimiter.php b/src/cache/src/Limiters/ConcurrencyLimiter.php index 333677cd1e..4072201e42 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 c29c3ced04..863b9221e7 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/console/src/Commands/ScheduleRunCommand.php b/src/console/src/Commands/ScheduleRunCommand.php index 539f0beb58..4a03ad7f7a 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 62d1b390b5..fb77f9129b 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/console/src/Scheduling/CacheEventMutex.php b/src/console/src/Scheduling/CacheEventMutex.php index 26e6b6fbb0..e3fb8bc54c 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/src/container/src/Container.php b/src/container/src/Container.php index e7fb70eb1b..fdfd342156 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; } diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 9553d63a9a..49febe5ca9 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/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index f20a82c8fd..198c803ea0 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/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index 97a67be3de..d2bb2a9d36 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 1a86c69d28..d8ffe48e12 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. */ @@ -1209,6 +1214,11 @@ protected function incrementOrDecrement(string $column, mixed $amount, array $ex $this->forceFill($extra); + $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; } @@ -1217,16 +1227,20 @@ 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, $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); @@ -1376,6 +1390,11 @@ protected function incrementOrDecrementEach(array $columns, array $extra, string $this->forceFill($extra); + $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; } @@ -1392,17 +1411,17 @@ protected function incrementOrDecrementEach(array $columns, array $extra, string return tap( $this->setKeysForSaveQuery($this->newQueryWithoutScopes()) - ->{$method}($dbColumns, $extra), - function () use ($columns, $extra): void { + ->{$method}($dbColumns, $this->prepareBinaryAttributesForDatabase($extra)), + 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); @@ -1411,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/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php index b3438e19fc..6f83721f4b 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/Concerns/CanBeOneOfMany.php b/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php index 5945ea4907..65baa2d5b2 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/HasOne.php b/src/database/src/Eloquent/Relations/HasOne.php index 3dc324290d..a7d35dee96 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/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index 0f51aca971..195e74dc5f 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -581,6 +581,47 @@ 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(), + ]; + + $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, + ); + } + + /** + * Get all of the IDs for the related models. + * + * @return BaseCollection + */ + public function allRelatedIds(): BaseCollection + { + $key = $this->related->getQualifiedKeyName(); + + return (clone $this->query)->select($key)->pluck($key); + } + /** * Chunk the results of the query. */ diff --git a/src/database/src/Eloquent/Relations/MorphOne.php b/src/database/src/Eloquent/Relations/MorphOne.php index 16e3dfab23..4910189744 100644 --- a/src/database/src/Eloquent/Relations/MorphOne.php +++ b/src/database/src/Eloquent/Relations/MorphOne.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; } /** @@ -81,8 +83,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/src/database/src/Eloquent/Relations/Relation.php b/src/database/src/Eloquent/Relations/Relation.php index 690adb0283..4e6753f938 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/src/database/src/Eloquent/SoftDeletingScope.php b/src/database/src/Eloquent/SoftDeletingScope.php index 998947c710..13da16b9e3 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/src/database/src/Pool/ConnectionLease.php b/src/database/src/Pool/ConnectionLease.php index ebd957766f..00a06e69b7 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/src/database/src/Schema/Grammars/MySqlGrammar.php b/src/database/src/Schema/Grammars/MySqlGrammar.php index a439314323..50cc3bfaba 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/src/docs/broadcasting.md b/src/docs/broadcasting.md index 01a430d1e5..9b229a8e4b 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 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 @@ -281,6 +281,19 @@ 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 + +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 +``` + ## Client Side Installation @@ -946,6 +959,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\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`: ```php @@ -998,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/docs/filesystem.md b/src/docs/filesystem.md index 66ab9f626a..c7255f2898 100644 --- a/src/docs/filesystem.md +++ b/src/docs/filesystem.md @@ -133,10 +133,20 @@ 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" +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: @@ -163,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: @@ -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 fea24be7ed..32bb5efa98 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 cae6f3a6ae..b90ab4a35f 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/docs/queues.md b/src/docs/queues.md index 11160bd38d..2ec3571318 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/docs/scheduling.md b/src/docs/scheduling.md index 7c5f525e27..9fe0e775f2 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/src/filesystem/README.md b/src/filesystem/README.md index a4c868e15b..8fbef30e51 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 b2d0b9b74d..d848a0c8d4 100644 --- a/src/filesystem/composer.json +++ b/src/filesystem/composer.json @@ -47,9 +47,12 @@ "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.", - "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 2d6bffd6ac..ec8c3bdbf3 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 4cf2d2374a..1dd164b868 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/src/filesystem/src/LockableFile.php b/src/filesystem/src/LockableFile.php index ebf3b28b75..f1d637998b 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/src/foundation/config/broadcasting.php b/src/foundation/config/broadcasting.php index 3fce661211..3b0c335554 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/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php b/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php index 7e1340d0a2..0d9df2d311 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 7567f681be..69fc92c582 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,85 @@ 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. + * + * 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( + 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 90f84d5334..02b9de895d 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/src/process/src/PendingProcess.php b/src/process/src/PendingProcess.php index ae5e6e8cf3..d65668e515 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/src/queue/src/Console/ForgetFailedCommand.php b/src/queue/src/Console/ForgetFailedCommand.php index 6536d1afdc..24361e7000 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/src/queue/src/FailoverQueue.php b/src/queue/src/FailoverQueue.php index 4d233abedf..384f46e9d2 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/LuaScripts.php b/src/queue/src/LuaScripts.php index 95a65a79e3..a282f67770 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/src/queue/src/Queue.php b/src/queue/src/Queue.php index d3363bab01..6a99b23852 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/src/redis/src/Limiters/ConcurrencyLimiter.php b/src/redis/src/Limiters/ConcurrencyLimiter.php index 3e8a3bc29c..49b2faa28b 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 e1c6f71a3b..5db9385777 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/src/support/src/Str.php b/src/support/src/Str.php index 860d1188b6..568f88ab04 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; diff --git a/tests/Broadcasting/BroadcastEventTest.php b/tests/Broadcasting/BroadcastEventTest.php index e41a773ecd..d30274e23c 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/Bus/DispatchLockContextTest.php b/tests/Bus/DispatchLockContextTest.php index 778ef531bf..b59d2cb2cc 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/Cache/CacheDatabaseLockTest.php b/tests/Cache/CacheDatabaseLockTest.php index 2bfb6fda1a..cdc2709d5b 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 5fec88d6f3..0f52cec3d6 100644 --- a/tests/Cache/CacheFileStoreTest.php +++ b/tests/Cache/CacheFileStoreTest.php @@ -5,22 +5,28 @@ 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; 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 { @@ -58,6 +64,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'); @@ -511,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/CacheLockTest.php b/tests/Cache/CacheLockTest.php index 0d512b038d..907f1ff64b 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 739a68d07b..d3cd735b4b 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/Cache/Fixtures/replaced_lock_file.php b/tests/Cache/Fixtures/replaced_lock_file.php new file mode 100644 index 0000000000..4c19530df9 --- /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/Concurrency/ConcurrencyTest.php b/tests/Concurrency/ConcurrencyTest.php index fdb3b275c1..f1de478ddf 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/Console/Fixtures/command_signatures.php b/tests/Console/Fixtures/command_signatures.php index 6eff29c6a3..83db31378d 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/Console/Scheduling/CacheEventMutexTest.php b/tests/Console/Scheduling/CacheEventMutexTest.php index a724af5e49..72df1e3f35 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); diff --git a/tests/Console/Scheduling/CallbackEventTest.php b/tests/Console/Scheduling/CallbackEventTest.php index 368ae3ad83..22ca18fe4c 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/Database/DatabaseDriverConnectionLeaseTest.php b/tests/Database/DatabaseDriverConnectionLeaseTest.php index 101df72607..f76e7659c7 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/DatabaseEloquentHasManyThroughIntegrationTest.php b/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php index fb5115a27b..3fc64506ca 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,79 @@ 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 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], $relation->allRelatedIds()->all()); + } + public function testEagerLoadingDoesNotReadTheEmptyFarParentKey(): void { $this->seedData(); @@ -717,6 +791,11 @@ public function owner() } } +class TouchSoftDeletedPost extends Post +{ + use SoftDeletes; +} + class Country extends Eloquent { protected ?string $table = 'countries'; @@ -854,3 +933,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/DatabaseEloquentHasOneOfManyTest.php b/tests/Database/DatabaseEloquentHasOneOfManyTest.php index a8ef664b6a..6535c8e2f1 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,18 @@ 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(); + $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(); + }); } /** @@ -70,6 +85,8 @@ protected function tearDown(): void $this->schema()->drop('logins'); $this->schema()->drop('states'); $this->schema()->drop('prices'); + $this->schema()->drop('comments'); + $this->schema()->drop('soft_deleting_comments'); parent::tearDown(); } @@ -298,6 +315,41 @@ 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 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(); @@ -714,3 +766,63 @@ 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(); + } + + /** + * 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/DatabaseEloquentHasOneThroughIntegrationTest.php b/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php index 0f193f2a88..892af66a88 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... */ diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index 7fda64c621..57ee279c03 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/Database/DatabaseEloquentMorphOneOfManyTest.php b/tests/Database/DatabaseEloquentMorphOneOfManyTest.php index ce9472485b..ab5d9dc418 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,12 @@ 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'); + $table->integer('score')->default(0); + }); } /** @@ -51,6 +60,7 @@ protected function tearDown(): void { $this->schema()->drop('products'); $this->schema()->drop('states'); + $this->schema()->drop('comments'); parent::tearDown(); } @@ -198,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. */ @@ -257,3 +282,36 @@ 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(); + } + + /** + * Get the highest-scoring reply. + */ + public function topReply(): MorphOne + { + return $this->morphOne(self::class, 'commentable')->ofMany('score', 'max'); + } +} diff --git a/tests/Database/DatabaseEloquentRelationTest.php b/tests/Database/DatabaseEloquentRelationTest.php index 54256ed7a5..8fd0295eea 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/Database/DatabaseMariaDbSchemaGrammarTest.php b/tests/Database/DatabaseMariaDbSchemaGrammarTest.php index 0e26fb5bc0..4915f67163 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 ae1f04005f..b263e75665 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. */ diff --git a/tests/Database/DatabasePdoConnectionTest.php b/tests/Database/DatabasePdoConnectionTest.php index c1a1d7515e..2e70b8b00c 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) { + $this->assertSame(1, $reconnects); + } + } + public function testExplicitPhysicalCommitFailureLeavesTheTransactionCallerOwned(): void { $failure = new RuntimeException('commit failure'); diff --git a/tests/Filesystem/FilesystemAdapterTest.php b/tests/Filesystem/FilesystemAdapterTest.php index add7e2e8ff..cc6d8c8d3e 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 181398b211..4daeb1bf30 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()); diff --git a/tests/Filesystem/LockableFileTest.php b/tests/Filesystem/LockableFileTest.php index c6c82cf00a..62e51713e2 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'; diff --git a/tests/Http/Resources/JsonApi/JsonApiResourceTest.php b/tests/Http/Resources/JsonApi/JsonApiResourceTest.php index 9bc8030ded..f00d896070 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/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php index 37b0c3146d..790a7998d9 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,20 +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; @@ -57,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 { @@ -296,6 +309,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); @@ -337,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(); @@ -940,6 +1041,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 +1050,220 @@ 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); + } + + #[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(); + } + } + + /** + * Provide Mercure events with their expected queue routing. + */ + 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(); + 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); @@ -1302,6 +1618,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. */ @@ -1387,6 +1722,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'; @@ -1402,6 +1802,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 +1896,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 diff --git a/tests/Integration/Console/Scheduling/CallbackEventTest.php b/tests/Integration/Console/Scheduling/CallbackEventTest.php index e18833941b..ff32a99d05 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; diff --git a/tests/Integration/Console/Scheduling/ScheduleRunCommandTest.php b/tests/Integration/Console/Scheduling/ScheduleRunCommandTest.php index 913d1de3c2..1562c7cc22 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); }); } diff --git a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php index a782e6cf90..20187e53ca 100644 --- a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php +++ b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php @@ -31,8 +31,11 @@ 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); + $table->integer('parent_id')->nullable(); + $table->timestamp('updated_at')->nullable(); }); Schema::create('binary_primary_keys', function (Blueprint $table): void { @@ -40,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); }); } @@ -156,6 +166,71 @@ 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); + } + + #[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 { @@ -493,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 2ed4991f2b..4328b427ac 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'; diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php index f064c902a5..cb2206bd11 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([ diff --git a/tests/Integration/Database/EloquentHasManyThroughTest.php b/tests/Integration/Database/EloquentHasManyThroughTest.php index d757380dbd..8594fcae33 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 = []; diff --git a/tests/Integration/Database/EloquentUpdateTest.php b/tests/Integration/Database/EloquentUpdateTest.php index 173fe21aa7..4ed3d00531 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([ @@ -365,6 +447,111 @@ 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); + } + + #[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 @@ -407,6 +594,42 @@ 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 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 { /** diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php index 9ecdb990ce..a3bc5afe73 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 949a5e71f1..73beaf5a86 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 0000000000..04e5ba81ee --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipLoadingTest.php @@ -0,0 +1,212 @@ +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]); + } + } + + // 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'), + ]), 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 e64141f771..d592b897a6 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(); diff --git a/tests/Integration/Queue/Redis/RedisQueueTest.php b/tests/Integration/Queue/Redis/RedisQueueTest.php index a59549e935..ad004d5c6f 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(); diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php index d730bb70d8..0a8a9a4abb 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')] @@ -452,18 +457,92 @@ 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()); } + public function testRolledBackQueuedListenerReleasesItsUniqueLock(): void + { + $this->markTestSkippedWhenUsingSyncQueueDriver(); + + config()->set('cache.default', 'array'); + + 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 () use ($lock, $failure): never { + event(new UniqueTestEvent); + + $this->assertTrue($lock->isLocked()); + + throw $failure; + }); + } catch (Exception $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + + 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']); + + $caught = null; + + try { + dispatch(new UniqueTestJob)->onConnection('failing'); + } 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()); + } + /** * Get the unique lock key for the given job. */ @@ -719,6 +798,16 @@ class UniqueTestEvent { } +class UniqueTestAfterCommitListener implements ShouldQueueAfterCommit, ShouldBeUnique +{ + /** + * Handle the event. + */ + public function handle(UniqueTestEvent $event): void + { + } +} + class UniqueTestListener implements ShouldQueue, ShouldBeUnique { /** diff --git a/tests/Process/ProcessTest.php b/tests/Process/ProcessTest.php index d7b4461670..9bcf124731 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 { diff --git a/tests/Queue/QueueForgetFailedCommandTest.php b/tests/Queue/QueueForgetFailedCommandTest.php new file mode 100644 index 0000000000..4c8c47f1f4 --- /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); + } +} diff --git a/tests/Redis/ConcurrencyLimiterTest.php b/tests/Redis/ConcurrencyLimiterTest.php index 8a52232a85..144c5e9d97 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 377ed79ffe..10d79816d7 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.