Visit <span>https://hypervel.org/docs</span> to browse the documentation
', ]; + yield [new EncodedHtmlString('\[Hypervel](https://hypervel.org)'), '\[Hypervel](https://hypervel.org)
']; + + yield [ + new EncodedHtmlString('!\[Welcome to Hypervel](https://hypervel.org/assets/img/welcome/background.svg)'), + '!\[Welcome to Hypervel](https://hypervel.org/assets/img/welcome/background.svg)
', + ]; + yield [ new EncodedHtmlString(new HtmlString('Visit https://hypervel.org/docs to browse the documentation')), 'Visit https://hypervel.org/docs to browse the documentation
', From 4a0d19f322c571e80a9eb53120c7b054e3941b57 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:30:59 +0000 Subject: [PATCH 10/22] Add pooled Vitess analytical workload scopes Port Laravel framework #61932 at 68e78fe401c8c2d71da8b8c06bcd585b6da4339c. Add isVitess and olap to MySqlConnection, applying the analytical workload to both configured endpoints and restoring OLTP after the callback. Other MySQL servers execute the callback unchanged. Use the existing session pin to prevent early release during external waits. Nested calls share the outer scope. Reconnects reapply the workload before continuing, and a failed reconnect leaves lazy resolvers that preserve both workload and read routing on the next access. Partial setup or failed restoration marks the physical sessions unusable through the existing pool state. Normal query execution gains no hooks or session synchronization machinery. Port the upstream tests and documentation, then cover nesting, sticky reads, successful retry, failed reconnects, raw handle access, partial setup and cancellation cleanup. Connection tests, affected parallel tests, formatting and full static analysis pass. Vitess switching semantics were checked against its official source tests; this change has not been exercised against a live Vitess server. --- src/database/src/MySqlConnection.php | 116 +++++++++++++ src/docs/database.md | 26 +++ tests/Database/DatabaseConnectionTest.php | 195 ++++++++++++++++++++++ 3 files changed, 337 insertions(+) diff --git a/src/database/src/MySqlConnection.php b/src/database/src/MySqlConnection.php index b423778155..4e621dcb5d 100755 --- a/src/database/src/MySqlConnection.php +++ b/src/database/src/MySqlConnection.php @@ -17,6 +17,7 @@ use PDO; use PDOException; use RuntimeException; +use Throwable; class MySqlConnection extends PdoConnection { @@ -25,6 +26,11 @@ class MySqlConnection extends PdoConnection */ protected string|int|null $lastInsertId = null; + /** + * Whether an OLAP workload scope owns the current session. + */ + protected bool $usingOlapWorkload = false; + /** * Get a human-readable name for the given connection driver. */ @@ -67,6 +73,108 @@ public function insert(string $query, array $bindings = [], ?string $sequence = }); } + /** + * Execute the given callback using OLAP workload. + * + * @template TReturn + * + * @param Closure($this): TReturn $callback + * @return TReturn + */ + public function olap(Closure $callback): mixed + { + if ($this->usingOlapWorkload || ! $this->isVitess()) { + return $callback($this); + } + + // External waits must not return the OLAP session to another borrower. + return $this->withPinnedSession(function () use ($callback) { + $this->setWorkload('olap'); + $this->usingOlapWorkload = true; + + try { + return $callback($this); + } finally { + $this->usingOlapWorkload = false; + $this->setWorkload('oltp'); + } + }); + } + + /** + * Set the Vitess workload for the connection. + * + * @param 'olap'|'oltp' $workload + */ + protected function setWorkload(string $workload): void + { + try { + $this->run("set workload = '{$workload}'", [], function ($query) { + if ($this->pretending()) { + return true; + } + + $this->getPdo()->exec($query); + + if ($this->getRawReadPdo() !== null) { + // Configure the read endpoint even when sticky reads use the writer. + $this->resolveReadPdo()->exec($query); + } + + return true; + }); + } catch (Throwable $exception) { + // Partial setup or restoration must never return an OLAP session to the pool. + foreach ([$this->getRawPdo(), $this->getRawReadPdo()] as $pdo) { + if ($pdo instanceof PDO) { + $this->markSessionStateUnknown($pdo); + } + } + + throw $exception; + } + } + + /** + * Reconnect to the database, preserving an active OLAP workload. + */ + public function reconnect(): mixed + { + if (! $this->usingOlapWorkload) { + return parent::reconnect(); + } + + $hasReadEndpoint = $this->getRawReadPdo() !== null; + + // A failed SET may itself retry; it must not recursively reapply the workload. + $this->usingOlapWorkload = false; + + try { + $result = parent::reconnect(); + $this->setWorkload('olap'); + + return $result; + } catch (Throwable $exception) { + // Retain lazy recovery without letting the lease reopen an OLTP session. + $this->setPdo(fn (): PDO => $this->recoverOlapSession(false)) + ->setReadPdo($hasReadEndpoint ? fn (): PDO => $this->recoverOlapSession(true) : null); + + throw $exception; + } finally { + $this->usingOlapWorkload = true; + } + } + + /** + * Recover a session after a failed reconnect within an OLAP scope. + */ + private function recoverOlapSession(bool $read): PDO + { + $this->reconnect(); + + return $this->resolveRawPdo($read); + } + /** * Escape a binary value for safe SQL embedding. */ @@ -176,6 +284,14 @@ public function isMaria(): bool return str_contains($this->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION), 'MariaDB'); } + /** + * Determine if the connected database is a Vitess database. + */ + public function isVitess(): bool + { + return Str::contains($this->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION), ['Vitess', 'PlanetScale'], ignoreCase: true); + } + /** * Get the server version for the connection. */ diff --git a/src/docs/database.md b/src/docs/database.md index 70fb82e3fc..10ca985ec0 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -18,6 +18,7 @@ - [Static Analysis](#static-analysis) - [Running SQL Queries](#running-queries) - [Using Multiple Database Connections](#using-multiple-database-connections) + - [Running Analytical Queries on Vitess](#running-analytical-queries-on-vitess) - [Listening for Query Events](#listening-for-query-events) - [Monitoring Cumulative Query Time](#monitoring-cumulative-query-time) - [Database Transactions](#database-transactions) @@ -870,6 +871,31 @@ The driver-neutral `Connection` class does not expose PDO methods because native If you are building a low-level PDO extension, you may use `getRawPdo` to access the connection parameter without resolving or configuring it. This value may be a PDO, a lazy connection closure, or `null`. Applications should use `getPdo` or Hypervel's query APIs instead. + +### Running Analytical Queries on Vitess + +When using a Vitess database, such as PlanetScale, you may use the `olap` method to execute analytical queries using the OLAP workload: + +```php +use Hypervel\Support\Facades\DB; + +$totals = DB::connection('mysql')->olap(fn ($connection) => $connection->table('orders') + ->selectRaw('customer_id, sum(total) as total') + ->groupBy('customer_id') + ->get() +); +``` + +Hypervel sets the workload on both the read and write connections, then resets it to OLTP when the callback finishes, even if an exception is thrown. The `olap` method returns the callback's result. On MySQL connections that are not using Vitess, the callback is executed without changing the workload. + +The connection remains [pinned](#releasing-and-pinning-connections) until the callback finishes, including while waiting for external services. Complete any lazy queries inside the callback. Nested `olap` calls share the outer scope, and a recovered connection receives the OLAP workload before queries resume. + +You may determine whether a MySQL connection is using Vitess via the `isVitess` method: + +```php +$isVitess = DB::connection('mysql')->isVitess(); +``` + ### Listening for Query Events diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index 3c3eea5b03..a833dffc26 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -10,6 +10,7 @@ use Exception; use Generator; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Connection; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Database\DeadlockException; @@ -41,6 +42,7 @@ use Mockery as m; use PDO; use PDOException; +use PDOStatement; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; @@ -2359,6 +2361,199 @@ public function testPretendRestoresQueryLoggingWhenTheCallbackFails(): void $this->assertFalse($connection->pretending()); } + public function testOlapRunsCallbackUsingOlapWorkloadOnVitess(): void + { + $pdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['getAttribute'])->getMock(); + $pdo->expects($this->once())->method('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->willReturn('8.0.34-PlanetScale'); + + $queries = (new MySqlConnection($pdo))->pretend(function ($connection) { + $connection->olap(fn ($connection) => $connection->select('foo bar')); + }); + + $this->assertSame([ + "set workload = 'olap'", + 'foo bar', + "set workload = 'oltp'", + ], array_column($queries, 'query')); + } + + public function testOlapSetsWorkloadOnReadAndWriteConnections(): void + { + $writePdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['getAttribute', 'exec'])->getMock(); + $writePdo->expects($this->once())->method('getAttribute')->willReturn('8.0.34-PlanetScale'); + $writePdo->expects($this->exactly(2))->method('exec'); + + $readPdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['exec'])->getMock(); + $readPdo->expects($this->exactly(2))->method('exec'); + + $connection = (new MySqlConnection($writePdo, config: ['sticky' => true]))->setReadPdo($readPdo); + $connection->recordsHaveBeenModified(); + + $this->assertSame('foo', $connection->olap(function () use ($connection): string { + $this->assertTrue($connection->hasPinnedSession()); + + return 'foo'; + })); + } + + public function testOlapLeavesOtherMySqlServersUnchanged(): void + { + $pdo = m::mock(PDO::class); + $pdo->expects('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->andReturn('8.0.34'); + $pdo->shouldNotReceive('exec'); + $connection = new MySqlConnection($pdo); + + $this->assertSame($connection, $connection->olap(static fn ($connection) => $connection)); + } + + public function testNestedOlapScopesRestoreTheWorkloadOnlyAfterTheOuterCallback(): void + { + $pdo = m::mock(PDO::class); + $pdo->expects('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->andReturn('8.0.34-vitess'); + $connection = new MySqlConnection($pdo); + + $queries = $connection->pretend(function () use ($connection): void { + $connection->olap(function () use ($connection): void { + $connection->olap(fn () => $connection->select('inner query')); + $connection->select('outer query'); + }); + }); + + $this->assertSame([ + "set workload = 'olap'", + 'inner query', + 'outer query', + "set workload = 'oltp'", + ], array_column($queries, 'query')); + } + + public function testOlapReappliesTheWorkloadBeforeRetryingALostConnection(): void + { + $original = m::mock(PDO::class); + $original->expects('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->andReturn('8.0.34-vitess'); + $original->expects('exec')->with("set workload = 'olap'"); + $original->expects('prepare')->with('select total')->andThrow(new PDOException('server has gone away')); + $replacement = m::mock(PDO::class); + $replacement->expects('exec')->with("set workload = 'olap'")->ordered(); + $statement = m::mock(PDOStatement::class); + $statement->expects('setFetchMode')->with(PDO::FETCH_OBJ); + $statement->expects('execute'); + $statement->expects('fetchAll')->andReturn([42]); + $replacement->expects('prepare')->with('select total')->andReturn($statement)->ordered(); + $replacement->expects('exec')->with("set workload = 'oltp'")->ordered(); + $connection = new MySqlConnection($original); + $reconnects = 0; + $connection->setReconnector(static function (MySqlConnection $connection) use ($replacement, &$reconnects): void { + ++$reconnects; + $connection->setPdo($replacement); + }); + + $this->assertSame([42], $connection->olap(fn () => $connection->select('select total'))); + $this->assertSame(1, $reconnects); + } + + #[TestWith([false], 'raw writer')] + #[TestWith([true], 'read query')] + public function testOlapRecoversItsWorkloadAndEndpointAfterAFailedReconnect(bool $read): void + { + $original = m::mock(PDO::class); + $original->expects('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->andReturn('8.0.34-vitess'); + $original->expects('exec')->with("set workload = 'olap'"); + $originalRead = m::mock(PDO::class); + $originalRead->expects('exec')->with("set workload = 'olap'"); + $replacement = m::mock(PDO::class); + $replacement->expects('exec')->with("set workload = 'olap'")->globally()->ordered(); + $replacementRead = m::mock(PDO::class); + $replacementRead->expects('exec')->with("set workload = 'olap'")->globally()->ordered(); + + if ($read) { + $statement = m::mock(PDOStatement::class); + $statement->expects('setFetchMode')->with(PDO::FETCH_OBJ); + $statement->expects('execute'); + $statement->expects('fetchAll')->andReturn([42]); + $replacementRead->expects('prepare')->with('select total')->andReturn($statement)->globally()->ordered(); + $replacement->shouldNotReceive('prepare'); + } + + $replacement->expects('exec')->with("set workload = 'oltp'")->globally()->ordered(); + $replacementRead->expects('exec')->with("set workload = 'oltp'")->globally()->ordered(); + $connection = (new MySqlConnection($original))->setReadPdo($originalRead); + $failure = new RuntimeException('Database unavailable.'); + $reconnects = 0; + $connection->setReconnector(static function (MySqlConnection $connection) use ($replacement, $replacementRead, $failure, &$reconnects): void { + $connection->setPdo(static fn (): PDO => $replacement) + ->setReadPdo(static fn (): PDO => $replacementRead); + + if (++$reconnects === 1) { + throw $failure; + } + }); + + $connection->olap(function () use ($connection, $failure, $replacement, $read): void { + try { + $connection->reconnect(); + $this->fail('Expected the reconnect to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + if ($read) { + $this->assertSame([42], $connection->select('select total')); + } else { + $this->assertSame($replacement, $connection->getPdo()); + } + }); + + $this->assertSame(2, $reconnects); + $this->assertSame($replacementRead, $connection->getReadPdo()); + } + + public function testPartialOlapSetupPreventsReusingEitherSession(): void + { + $writePdo = m::mock(PDO::class); + $writePdo->expects('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->andReturn('8.0.34-vitess'); + $writePdo->expects('exec')->with("set workload = 'olap'"); + $readPdo = m::mock(PDO::class); + $failure = new PDOException('Workload change refused.'); + $readPdo->expects('exec')->with("set workload = 'olap'")->andThrow($failure); + $connection = (new MySqlConnection($writePdo))->setReadPdo($readPdo); + + try { + $connection->olap(fn () => $this->fail('A partially configured callback must not run.')); + $this->fail('Expected the workload change to fail.'); + } catch (QueryException $exception) { + $this->assertSame($failure, $exception->getPrevious()); + } + + $this->assertFalse((new MySqlConnection($writePdo))->isReusable()); + $this->assertFalse((new MySqlConnection($readPdo))->isReusable()); + } + + public function testOlapRestorationFailurePreservesCancellationAndDiscardsTheSession(): void + { + $pdo = m::mock(PDO::class); + $pdo->expects('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->andReturn('8.0.34-vitess'); + $pdo->expects('exec')->with("set workload = 'olap'"); + $connection = new MySqlConnection($pdo); + $cancellation = new CanceledException('Analytics canceled.'); + $failure = new RuntimeException('Restore hook failed.'); + $connection->beforeExecuting(static function (string $query) use ($failure): void { + if ($query === "set workload = 'oltp'") { + throw $failure; + } + }); + + try { + $connection->olap(static fn (): never => throw $cancellation); + $this->fail('Expected the restoration failure.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + $this->assertTrue(Coroutine::causedByCancellation($exception)); + } + + $this->assertFalse($connection->isReusable()); + } + public function testSchemaBuilderCanBeCreated(): void { $connection = $this->getMockConnection(); From ff769f87e963c6d4420a1e5b0da104cd5e9289c2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:36:28 +0000 Subject: [PATCH 11/22] Describe supported aggregate relationship constraints precisely Narrow aggregate-loading annotations to relation names, builder callbacks and empty constraint arrays. Nonempty nested relationship arrays flatten into dotted method names that aggregate loading cannot execute; empty arrays remain valid and compile to unconstrained relation aggregates. Apply the same type to the Model and Collection aggregate methods. Replace the existing invalid nested type examples with empty-constraint examples, preserving coverage for accepted input without changing runtime behavior. The old nested examples fail static analysis under the corrected types. The updated type fixtures, full source/type/skeleton analysis, formatting and diff checks pass. --- src/database/src/Eloquent/Collection.php | 14 +++++++------- src/database/src/Eloquent/Model.php | 14 +++++++------- types/Database/Eloquent/Collection.php | 14 +++++++------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php index 7a032db5bf..09a2d1b308 100644 --- a/src/database/src/Eloquent/Collection.php +++ b/src/database/src/Eloquent/Collection.php @@ -127,7 +127,7 @@ public function load(array|string $relations): static /** * Load a set of aggregations over relationship's column onto the collection. * - * @param array!\[Welcome to Hypervel](https://hypervel.org/assets/img/welcome/background.svg)
', ]; + yield 'literal punctuation in code' => [ + new EncodedHtmlString('`[x] \\\server `'), + '[x] \\\server <b>
42
']; + yield [ new EncodedHtmlString(new HtmlString('Visit https://hypervel.org/docs to browse the documentation')), 'Visit https://hypervel.org/docs to browse the documentation
', diff --git a/tests/Mail/MarkdownCoroutineSafetyTest.php b/tests/Mail/MarkdownCoroutineSafetyTest.php index 2ca7fdbba4..4cf457de2d 100644 --- a/tests/Mail/MarkdownCoroutineSafetyTest.php +++ b/tests/Mail/MarkdownCoroutineSafetyTest.php @@ -245,7 +245,7 @@ protected function renderEncodingProbe(): string protected function encodingMarker(): string { - return (new EncodedHtmlString('['))->toHtml() === '\[' + return (new EncodedHtmlString('['))->toHtml() !== '[' ? 'scoped' : 'default'; } From ebc223d8c1b92213707f4f7584cdf33fc4659407 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:45:02 +0000 Subject: [PATCH 16/22] Clarify facade lifetimes and notification boot defaults Explain that facade caching excludes bindings and factories that return an existing scoped service, and that custom containers must implement Hypervel's complete contract. List the added scoped-instance operation alongside the existing contract differences. Clarify that notification defaults are configured during initial application boot. Calls from providers registered after boot are execution-local, and locale overrides then apply to one send. The porting guide distinguishes that persistent boot default from Laravel's first-send consumption and links to the notification documentation. Checked the wording against container resolution and notification-manager behavior. No runtime behavior changes in this commit. --- src/contracts/README.md | 2 +- src/docs/facades.md | 2 +- src/docs/notifications.md | 4 ++-- src/docs/porting-from-laravel.md | 8 ++++++++ src/notifications/README.md | 2 +- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/contracts/README.md b/src/contracts/README.md index d3274f89fe..cb1d1fca72 100644 --- a/src/contracts/README.md +++ b/src/contracts/README.md @@ -7,6 +7,6 @@ Documentation: https://hypervel.org/docs/contracts ## Differences From Laravel -The `Container` contract also requires `isScoped()`, `forgetInstance()`, `makeTransient()`, `build()`, `buildWith()`, `resolveContextualParameters()`, and `rebinding()`. Custom implementations must provide these methods; extending `Hypervel\Container\Container` supplies them. Scope inspection lets consumers preserve coroutine-local lifetimes instead of caching scoped services across requests. +The `Container` contract also requires `isScoped()`, `isScopedInstance()`, `forgetInstance()`, `makeTransient()`, `build()`, `buildWith()`, `resolveContextualParameters()`, and `rebinding()`. Custom implementations must provide these methods; extending `Hypervel\Container\Container` supplies them. Scope inspection lets consumers preserve coroutine-local lifetimes instead of caching scoped services across requests. Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Contracts diff --git a/src/docs/facades.md b/src/docs/facades.md index 0e7e73c020..eafcb0269e 100644 --- a/src/docs/facades.md +++ b/src/docs/facades.md @@ -193,7 +193,7 @@ class Cache extends Facade Instead, the `Cache` facade extends the base `Facade` class and defines the method `getFacadeAccessor()`. This method's job is to return the name of a service container binding. When a user references any static method on the `Cache` facade, Hypervel resolves the `cache` binding from the [service container](/docs/{{version}}/container) and runs the requested method (in this case, `get`) against that object. -By default, a facade keeps the first instance it resolves for the lifetime of the worker, even when the service uses a `bind()` binding. Scoped bindings are the exception: register a service with `scoped()` to share one instance within each request or job coroutine. To resolve a fresh instance from a `bind()` binding on every facade call, set `protected static bool $cached = false` on the facade. +By default, a facade keeps the first instance it resolves for the lifetime of the worker, even when the service uses a `bind()` binding. Scoped services are the exception, including when a `bind()` binding or factory returns a scoped instance. Register a service with `scoped()` to share one instance within each request or job coroutine. To resolve a fresh instance from a `bind()` binding on every facade call, set `protected static bool $cached = false` on the facade. ## Facade Class Reference diff --git a/src/docs/notifications.md b/src/docs/notifications.md index 7ae82eba78..4f55f904df 100644 --- a/src/docs/notifications.md +++ b/src/docs/notifications.md @@ -136,7 +136,7 @@ public function via(object $notifiable): array } ``` -You may configure the notification manager's default channel by calling `Notification::deliverVia` from a service provider's `register` or `boot` method. Calls made after the application has booted change the default only for the current execution. Application `booted` callbacks run after this default-setting window, so they are also limited to their current execution; the same timing applies to the default locale described below. +You may configure the notification manager's default channel by calling `Notification::deliverVia` from a service provider's `register` or `boot` method during initial application boot. Calls made after the application has booted change the default only for the current execution, including calls from providers registered later. Application `booted` callbacks run after this default-setting window, so they are also limited to their current execution; the same timing applies to the default locale described below. ### Queueing Notifications @@ -1731,7 +1731,7 @@ Notification::locale('es')->send( ); ``` -Calling `Notification::locale` from a service provider's `register` or `boot` method configures the default locale for later executions. Calls made after the application has booted apply only to the next `send` or `sendNow` call in the current execution. Later sends return to the boot default. Both the boot default and a per-send override take precedence over a notifiable's preferred locale. +Calling `Notification::locale` from a service provider's `register` or `boot` method during initial application boot configures the default locale for later executions. Calls made after the application has booted apply only to the next `send` or `sendNow` call in the current execution, including calls from providers registered later. Later sends return to the boot default. Both the boot default and a per-send override take precedence over a notifiable's preferred locale. #### User Preferred Locales diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 1fb1db8da1..5b564cbd62 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -32,6 +32,7 @@ - [MCP](#mcp) - [Saloon](#saloon) - [Broadcasting](#broadcasting) + - [Notifications](#notifications) - [JSON:API Resources](#jsonapi-resources) - [CSRF Protection](#csrf-protection) - [Fortify](#fortify) @@ -416,6 +417,8 @@ Hypervel follows Laravel's named container APIs, but intentionally does not supp Use `get()` and `has()` instead when working through the PSR-11 container interface. Hypervel does not expose arbitrary binding removal; `forgetInstance()` clears a temporary instance so the original binding can resolve again. +Custom containers must implement Hypervel's full `Container` contract or extend `Hypervel\Container\Container`; Laravel's container contract alone does not provide the lifetime operations Hypervel uses. + Container lifecycles are adapted for Swoole: | Need | Method | @@ -597,6 +600,11 @@ For Pusher and Reverb, replace Guzzle's `max_host_connections` and `max_total_co Mercure applications must configure a standalone HTTP hub. Hypervel runs on Swoole and does not use FrankenPHP's in-process `mercure_publish()` integration. See [Mercure broadcasting](/docs/{{version}}/broadcasting#mercure). + +### Notifications + +`Notification::locale()` called during initial application boot sets a worker default that persists across sends. Laravel consumes that locale on the first send. For a one-send override, call it when sending the notification, after application boot. See [notification locales](/docs/{{version}}/notifications#localizing-notifications). + ### JSON:API Resources diff --git a/src/notifications/README.md b/src/notifications/README.md index 61ed955cb0..a222229c90 100644 --- a/src/notifications/README.md +++ b/src/notifications/README.md @@ -5,7 +5,7 @@ Documentation: https://hypervel.org/docs/notifications ## Differences From Laravel -- Calling `Notification::locale()` during provider registration or boot sets a worker default. Calls after boot apply only to the next send in the current execution. See [notification locales](https://hypervel.org/docs/notifications#localizing-notifications). +- Calling `Notification::locale()` during initial application boot sets a worker default. Calls after boot, including from providers registered later, apply only to the next send in the current execution. See [notification locales](https://hypervel.org/docs/notifications#localizing-notifications). - The manager creates a sender for each send so concurrent sends cannot change one another's locale. Laravel's protected `$notificationSender` cache is omitted; override `resolveNotificationSender()` to customize sender creation. Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Notifications From 369e1d162328887e5a44ced37df362d80b7fb1cb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:45:18 +0000 Subject: [PATCH 17/22] Preserve query ordering and alias semantics during paging Resolve cursor aliases against the scoped execution projection and classify expressions from their declared selection type. Arithmetic aliases no longer become quoted column names, while identifiers containing parentheses remain identifiers. Preserve the existing alias lookup override and accept the lone expression arguments advertised by find() and cursorPaginate(). Keep ID iteration ordered by its paging column, including when a global scope replaces the order. Restore the complete operation ordering and its bindings together, preserving bounded union members and driver-owned row selection. Require an outer order for query-builder union paging and supply the model key for Eloquent unions that have none. Reject limited or offset ID iteration when another leading order would select different rows. Compare names through the existing driver resolution rules, preserve empty and unbounded calls, and direct ranked selections to fromSub(). Remove redundant trailing orders and bindings from ordinary executed ID pages so MySQL uses its primary-key range without a filesort. Keep analytical clauses inside isolated results and construct plain outer paging orders. Preserve the direct grouped-query path for raw selections that are plain column references. Validated with database unit tests, cursor and binary-paging integration tests on SQLite, PostgreSQL and MySQL, full static analysis, and native MySQL EXPLAIN on the SQL generated by the builder. Regression tests cover caller and scope orders, union selection, aliases, bound ordering and limited-call behavior. --- src/database/src/Concerns/BuildsQueries.php | 76 +++++--- src/database/src/Eloquent/Builder.php | 14 +- src/database/src/Query/Builder.php | 144 +++++++++++---- .../DatabaseQueryBuilderPaginationTest.php | 160 ++++++++++++++++ tests/Database/DatabaseQueryBuilderTest.php | 9 + .../Database/EloquentCursorPaginateTest.php | 174 ++++++++++++++++++ 6 files changed, 515 insertions(+), 62 deletions(-) diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php index 148bc5879a..21ca28aac9 100644 --- a/src/database/src/Concerns/BuildsQueries.php +++ b/src/database/src/Concerns/BuildsQueries.php @@ -6,6 +6,7 @@ use Generator; use Hypervel\Container\Container; +use Hypervel\Contracts\Database\Query\Expression as ExpressionContract; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\MultipleRecordsFoundException; @@ -162,6 +163,7 @@ public function orderedChunkById(int $count, callable $callback, ?string $column $column ??= $this->defaultKeyName(); $alias ??= $column; + $this->ensureLimitedIterationOrdersById($column, $descending === true || $descending === SortDirection::Descending); $lastId = null; $skip = $this->getOffset(); $remaining = $this->getLimit(); @@ -185,8 +187,8 @@ public function orderedChunkById(int $count, callable $callback, ?string $column // no results we can just break and return from here. When there are results // we will call the callback with the current chunk of these results here. $results = match ($descending) { - SortDirection::Ascending, false => $clone->forPageAfterId($limit, $lastId, $column)->get(), - SortDirection::Descending, true => $clone->forPageBeforeId($limit, $lastId, $column)->get(), + SortDirection::Ascending, false => $clone->forPageAfterId($limit, $lastId, $column)->prioritizeKeysetOrders()->get(), + SortDirection::Descending, true => $clone->forPageBeforeId($limit, $lastId, $column)->prioritizeKeysetOrders()->get(), }; $countResults = $results->count(); @@ -330,6 +332,7 @@ protected function orderedLazyById(int $chunkSize = 1000, ?string $column = null $alias ??= $column; + $this->ensureLimitedIterationOrdersById($column, $descending === true || $descending === SortDirection::Descending); $skip = $this->getOffset(); $remaining = $this->getLimit(); @@ -352,8 +355,8 @@ protected function orderedLazyById(int $chunkSize = 1000, ?string $column = null } $results = match ($descending) { - SortDirection::Ascending, false => $clone->forPageAfterId($limit, $lastId, $column)->get(), - SortDirection::Descending, true => $clone->forPageBeforeId($limit, $lastId, $column)->get(), + SortDirection::Ascending, false => $clone->forPageAfterId($limit, $lastId, $column)->prioritizeKeysetOrders()->get(), + SortDirection::Descending, true => $clone->forPageBeforeId($limit, $lastId, $column)->prioritizeKeysetOrders()->get(), }; foreach ($results as $result) { @@ -507,43 +510,40 @@ protected function paginateUsingCursor( : CursorPaginator::resolveCurrentCursor($cursorName, $cursor); } + // The caller must provide the complete unique order. Adding scope keys here + // would require extra selected values and can replace an index range with a compound filter. $orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->pointsToPreviousItems()); $this->limit($perPage + 1); $builder = $this; if (! is_null($cursor)) { $query = $this instanceof Builder ? $this->getQuery() : $this; - $originalColumns = $query->columns; - $originalColumnNames = []; + $builder = $this->getBuilderForCursorPage(); + $pageQuery = $builder instanceof Builder ? $builder->getQuery() : $builder; + $originalColumns = $pageQuery->columns; + $comparisons = []; + $bindings = []; - // Resolve argument-only aliases before get() installs its projection, - // then restore it so Eloquent scopes retain their normal column semantics. - $query->columns ??= Arr::wrap($columns); + // Scopes may add computed aliases. Resolve against the page's scoped projection, + // installing argument-only columns afterward so scopes still see their normal input. + $pageQuery->columns ??= Arr::wrap($columns); try { foreach ($orders as $order) { - $originalColumnNames[$order['column']] = $this->getOriginalColumnNameForCursorPagination($this, $order['column']); + $column = $order['column']; + $original = $this->getOriginalColumnNameForCursorPagination($builder, $column); + $order['original'] = $this->isCursorExpression($pageQuery, $column, $original) + ? new Expression($original) : $original; + $comparisons[] = $order; + $bindings[$column] = $builder->getCursorParameterBinding($cursor, $column); } } finally { - $query->columns = $originalColumns; - } - - $builder = $this->getBuilderForCursorPage(); - $comparisons = []; - $bindings = []; - foreach ($orders as $order) { - $column = $order['column']; - $original = $originalColumnNames[$column]; - $order['original'] = $original !== $column && Str::contains($original, ['(', ')']) - ? new Expression($original) : $original; - $comparisons[] = $order; - $bindings[$column] = $builder->getCursorParameterBinding($cursor, $column); + $pageQuery->columns = $originalColumns; } // Members are resolved by their output positions during SELECT preparation; // keep their select/join bindings and never rewrite a shared union builder. $query->addCursorPaginationConditions($comparisons, $bindings); - $pageQuery = $builder instanceof Builder ? $builder->getQuery() : $builder; if ($pageQuery !== $query) { // Keep the caller's page conditions as well as the scoped execution copy's. $pageQuery->addCursorPaginationConditions($comparisons, $bindings); @@ -582,6 +582,9 @@ protected function getOriginalColumnNameForCursorPagination(QueryBuilder|Builder { $query = $builder instanceof Builder ? $builder->getQuery() : $builder; + // Ordered expressions require a separate select entry with an explicit AS alias. + // Implicit aliases and multiple fields in one raw entry remain unsupported: parsing + // arbitrary SQL is brittle, while automatic wrapping can change row-lock behavior. foreach ($query->getColumns() as $column) { if (($position = strripos($column, ' as ')) !== false) { $original = substr($column, 0, $position); @@ -597,6 +600,31 @@ protected function getOriginalColumnNameForCursorPagination(QueryBuilder|Builder return $parameter; } + /** + * Determine whether the resolved cursor column is a declared SQL expression. + */ + protected function isCursorExpression(QueryBuilder $query, string $parameter, string $original): bool + { + $grammar = $query->getGrammar(); + + // Arithmetic need not contain parentheses, and literal identifiers may contain them. + // Match the alias as well as its source so another selection cannot change its type. + foreach ($query->columns ?? [] as $column) { + $value = (string) $grammar->getValue($column); + if (($position = strripos($value, ' as ')) !== false) { + $alias = substr($value, $position + 4); + + if (substr($value, 0, $position) === $original + && ($parameter === $alias || $grammar->wrap($parameter) === $alias)) { + return $column instanceof ExpressionContract; + } + } + } + + // Preserve custom alias-lookup overrides that return an unselected expression. + return $original !== $parameter && Str::contains($original, ['(', ')']); + } + /** * Create a new length-aware paginator instance. */ diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 63297666e1..5c03b7e737 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -1054,8 +1054,9 @@ public function cursor(): LazyCollection */ protected function enforceOrderBy(): void { - if (empty($this->query->orders) && empty($this->query->unionOrders)) { - $this->orderBy($this->model->getQualifiedKeyName(), SortDirection::Ascending); + // A union's outer ordering cannot refer to a member table's qualified key. + if (empty($this->query->unions ? $this->query->unionOrders : $this->query->orders)) { + $this->orderBy($this->query->unions ? $this->model->getKeyName() : $this->model->getQualifiedKeyName(), SortDirection::Ascending); } } @@ -1169,9 +1170,7 @@ public function cursorPaginate(?int $perPage = null, array|string $columns = ['* */ protected function ensureOrderForCursorPagination(bool $shouldReverse = false): BaseCollection { - if (empty($this->query->orders) && empty($this->query->unionOrders)) { - $this->enforceOrderBy(); - } + $this->enforceOrderBy(); $this->query->prepareCursorOrdering(); @@ -1194,7 +1193,7 @@ protected function ensureOrderForCursorPagination(bool $shouldReverse = false): } } - $orders = ! empty($this->query->unionOrders) ? $this->query->unionOrders : $this->query->orders; + $orders = $this->query->unions ? $this->query->unionOrders : $this->query->orders; return (new BaseCollection($orders)) ->filter(fn ($order) => Arr::has($order, 'direction')) @@ -1601,6 +1600,9 @@ public function applyScopes(): static }); } + // A replacement sort cannot change the ordering a cursor or ID page compares against. + $builder->query->restoreKeysetOrdersFrom($this->query); + return $builder; } diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 3b08ea8b38..63ac1c4c4f 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -2813,9 +2813,8 @@ public function forPageBeforeId(int $perPage = 15, mixed $lastId = 0, string $co $this->markKeysetCondition($bindingOffset, $column); $this->orderBy($orderColumn, SortDirection::Descending)->limit($perPage); - if (! $this->unions) { - $this->orders[array_key_last($this->orders)]['keyset'] = true; - } + $property = $this->unions ? 'unionOrders' : 'orders'; + $this->{$property}[array_key_last($this->{$property})]['keyset'] = true; return $this; } @@ -2847,9 +2846,8 @@ public function forPageAfterId(int $perPage = 15, mixed $lastId = 0, string $col $this->markKeysetCondition($bindingOffset, $column); $this->orderBy($orderColumn, SortDirection::Ascending)->limit($perPage); - if (! $this->unions) { - $this->orders[array_key_last($this->orders)]['keyset'] = true; - } + $property = $this->unions ? 'unionOrders' : 'orders'; + $this->{$property}[array_key_last($this->{$property})]['keyset'] = true; return $this; } @@ -2897,6 +2895,7 @@ protected function removeOrdersFor(array $orders, array &$bindings, string $colu { $remaining = []; $removed = 0; + $membershipOrders = 0; foreach ($orders as $order) { if (isset($order['bindings'])) { @@ -2904,10 +2903,11 @@ protected function removeOrdersFor(array $orders, array &$bindings, string $colu } if (isset($order['column']) && $order['column'] === $column) { - if (! $this->unions) { + if (! $this->unions && ! isset($order['keyset'])) { + // Paging orders may move first, but only original orders select membership. // Retained bindings stay live; copy only values actually removed. $this->removedKeysetOrders[] = [ - 'index' => count($remaining), + 'index' => $membershipOrders, 'order' => $order, 'bindingOffset' => $order['bindings']['offset'] ?? count($bindings), 'bindings' => isset($order['bindings']) @@ -2925,6 +2925,7 @@ protected function removeOrdersFor(array $orders, array &$bindings, string $colu continue; } + $membershipOrders += isset($order['keyset']) ? 0 : 1; $remaining[] = $order; } @@ -3090,7 +3091,7 @@ public function toRawSql(): string */ public function find(int|string $id, ExpressionContract|array|string $columns = ['*']): mixed { - return $this->where('id', '=', $id)->first($columns); + return $this->where('id', '=', $id)->first($columns instanceof ExpressionContract ? [$columns] : $columns); } /** @@ -3309,7 +3310,12 @@ public function cursorPaginate( string $cursorName = 'cursor', Cursor|string|null $cursor = null, ): CursorPaginatorContract { - return $this->paginateUsingCursor($perPage, $columns, $cursorName, $cursor); + return $this->paginateUsingCursor( + $perPage, + $columns instanceof ExpressionContract ? [$columns] : $columns, + $cursorName, + $cursor + ); } /** @@ -3317,9 +3323,7 @@ public function cursorPaginate( */ protected function ensureOrderForCursorPagination(bool $shouldReverse = false): Collection { - if (empty($this->orders) && empty($this->unionOrders)) { - $this->enforceOrderBy(); - } + $this->enforceOrderBy(); $this->prepareCursorOrdering(); @@ -3342,7 +3346,7 @@ protected function ensureOrderForCursorPagination(bool $shouldReverse = false): } } - $orders = ! empty($this->unionOrders) ? $this->unionOrders : $this->orders; + $orders = $this->unions ? $this->unionOrders : $this->orders; return (new Collection($orders)) ->filter(fn ($order) => Arr::has($order, 'direction')) @@ -3378,6 +3382,10 @@ protected function markKeysetCondition(int $offset, ?string $column = null): voi public function prepareCursorOrdering(): void { if ($this->unions) { + foreach ($this->unionOrders ?? [] as $index => $order) { + $this->unionOrders[$index]['keysetCursor'] = true; + } + return; } @@ -3389,6 +3397,70 @@ public function prepareCursorOrdering(): void } } + /** + * Restore the operation's ordering when a global scope replaces its paging orders. + */ + public function restoreKeysetOrdersFrom(self $source): bool + { + $isKeyset = static fn (array $order): bool => isset($order['keyset']) || isset($order['keysetCursor']); + $property = $source->unions ? 'unionOrders' : 'orders'; + + if (! array_any($source->{$property} ?? [], $isKeyset) || array_any($this->{$property} ?? [], $isKeyset)) { + return false; + } + + // Pages compare against the operation's order. Restore its bindings and inner + // ordering together so a scope cannot also change which rows belong to it. + $this->orders = $source->orders; + $this->unionOrders = $source->unionOrders; + $this->bindings['order'] = $source->bindings['order']; + $this->bindings['unionOrder'] = $source->bindings['unionOrder']; + $this->removedKeysetOrders = $source->removedKeysetOrders; + + return true; + } + + /** + * Sort an ID iteration by its paging column before any other order. + */ + public function prioritizeKeysetOrders(): static + { + // The next page compares IDs alone; another leading sort can skip unseen IDs. + $property = $this->unions ? 'unionOrders' : 'orders'; + $orders = $this->{$property} ?? []; + $keyset = array_filter($orders, static fn (array $order): bool => isset($order['keyset'])); + + if ($keyset !== []) { + // These orders own no bindings. Retain other orders for driver-owned row selection. + $this->{$property} = [...$keyset, ...array_diff_key($orders, $keyset)]; + } + + return $this; + } + + /** + * Reject limited ID iteration when another order would choose different rows. + */ + public function ensureLimitedIterationOrdersById(string $column, bool $descending): void + { + $first = ($this->unions ? $this->unionOrders : $this->orders)[0] ?? null; + + if ($first === null || $this->getLimit() === 0 || ($this->getLimit() === null && ! $this->getOffset())) { + return; + } + + $source = fn (string $name): string => Str::chopStart( + $this->getOriginalColumnNameForCursorPagination($this, $name), + $this->getFromAlias() . '.' + ); + + // Compare source names without conflating equally named columns from different joins. + if (! is_string($first['column'] ?? null) || $source($first['column']) !== $source($this->resolveKeysetOrderColumn($column)) + || ($first['direction'] ?? null) !== ($descending ? 'desc' : 'asc')) { + throw new InvalidArgumentException('Cannot limit an ID-based iteration by another order. Select the rows with fromSub() first, then iterate them.'); + } + } + /** * Add cursor comparisons resolved by the calling query or Eloquent builder. * @@ -3401,8 +3473,11 @@ public function addCursorPaginationConditions(array $orders, array $bindings): s $offset = count($this->bindings['where']); $this->addCursorComparisons($orders, $bindings, 0); $this->markKeysetCondition($offset); + // Bare references cannot be aggregates; keep their grouped pages on the direct + // index path. Other raw expressions retain isolation without parsing their SQL. $this->hasKeysetExpressions = $this->hasKeysetExpressions - || array_any($orders, static fn (array $order): bool => $order['original'] instanceof ExpressionContract); + || array_any($orders, fn (array $order): bool => $order['original'] instanceof ExpressionContract + && preg_match('/\A[\w.]+\z/', (string) $this->grammar->getValue($order['original'])) !== 1); return $this; } @@ -3454,6 +3529,11 @@ protected function prepareForSelect(): static { $isolateSelect = $this->hasSelectKeysetConditions && $this->requiresKeysetIsolation(false); $orders = $this->orders; + // The unique paging key settles the sort; later keys make MySQL sort every remaining row. + $sortsByPageOnly = ! $isolateSelect && isset($orders[0]['keyset']); + if ($sortsByPageOnly) { + $orders = [$orders[0]]; + } // Keep logical source names for repeated ID calls; resolve aliases only // after scopes and get() have completed the executable projection. if (! $isolateSelect && $this->hasSelectKeysetConditions && $this->columns !== null && $this->columns !== ['*']) { @@ -3472,6 +3552,9 @@ protected function prepareForSelect(): static $query = $this->clone(); $query->orders = $orders; + if ($sortsByPageOnly) { + $query->bindings['order'] = []; + } $query->hasSelectKeysetConditions = $query->hasUnionKeysetConditions = false; $conditions = []; $query->wheres = $query->collectKeysetConditions($query->wheres, $conditions, $isolateSelect); @@ -3484,23 +3567,20 @@ protected function prepareForSelect(): static $selectConditions = array_values(array_filter($conditions, static fn (array $condition): bool => ! $condition['range']['union'])); $unionConditions = array_values(array_filter($conditions, static fn (array $condition): bool => $condition['range']['union'])); if ($isolateSelect) { - $pageOrders = array_values(array_filter($query->orders ?? [], static fn (array $order): bool => isset($order['direction']) - && (isset($order['keyset']) || isset($order['keysetCursor'])))); - $pageBindings = []; - foreach ($pageOrders as $index => $order) { - // Late scope and callback orders must not contribute parameters to the outer page. - if (isset($order['bindings'])) { - $pageOrders[$index]['bindings']['offset'] = count($pageBindings); - array_push($pageBindings, ...array_slice($query->bindings['order'], $order['bindings']['offset'], $order['bindings']['count'])); - } - if (isset($order['column']) && is_string($order['column'])) { - $pageOrders[$index]['column'] = $this->getKeysetOutputColumn($order['column'], source: isset($order['keyset'])); + // Only the sort belongs outside; driver clauses and their parameters stay in the result. + $pageOrders = []; + foreach ($query->orders ?? [] as $order) { + if (isset($order['direction']) && (isset($order['keyset']) || isset($order['keysetCursor']))) { + $pageOrders[] = [ + 'column' => is_string($order['column']) + ? $this->getKeysetOutputColumn($order['column'], source: isset($order['keyset'])) : $order['column'], + 'direction' => $order['direction'], + ]; } } $query->isolateKeysetSelect(false); $query->appendKeysetConditions($selectConditions, [], true); $query->orders = $pageOrders; - $query->bindings['order'] = $pageBindings; } if ($unionConditions !== []) { @@ -3820,19 +3900,18 @@ protected function rebindKeysetColumns(array $where, array $mapping, bool $outpu /** * Restore membership ordering from live entries and the orders paging removed. + * + * Drivers use this when their clauses select rows according to the original order. */ protected function restoreKeysetOrdering(): void { - $orders = $this->orders ?? []; + $orders = array_values(array_filter($this->orders ?? [], static fn (array $order): bool => ! isset($order['keyset']))); foreach (array_reverse($this->removedKeysetOrders) as $removed) { array_splice($orders, $removed['index'], 0, [$removed['order']]); array_splice($this->bindings['order'], $removed['bindingOffset'], 0, $removed['bindings']); } $this->orders = []; foreach ($orders as $order) { - if (isset($order['keyset'])) { - continue; - } if (isset($order['keysetDirection'])) { $order['direction'] = $order['keysetDirection']; } @@ -4010,7 +4089,8 @@ public function cursor(): LazyCollection */ protected function enforceOrderBy(): void { - if (empty($this->orders) && empty($this->unionOrders)) { + // A union's inner orders select member rows; only its outer order sorts the result. + if (empty($this->unions ? $this->unionOrders : $this->orders)) { throw new RuntimeException('You must specify an orderBy clause when using this function.'); } } diff --git a/tests/Database/DatabaseQueryBuilderPaginationTest.php b/tests/Database/DatabaseQueryBuilderPaginationTest.php index 2fb6c2be11..007026c411 100644 --- a/tests/Database/DatabaseQueryBuilderPaginationTest.php +++ b/tests/Database/DatabaseQueryBuilderPaginationTest.php @@ -64,6 +64,126 @@ public static function keysetIterators(): iterable yield 'lazy descending' => ['lazyByIdDesc', true, true]; } + #[DataProvider('keysetIterators')] + public function testIdIterationPrioritizesItsColumnOverCallerOrders(string $method, bool $lazy, bool $descending): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table entries (id integer, score integer)'); + $connection->unprepared('insert into entries values (1, 30), (2, 30), (3, 20), (4, 20), (5, 10), (6, 10)'); + $connection->enableQueryLog(); + + foreach ([false, true] as $union) { + $connection->flushQueryLog(); + $query = $connection->table('entries')->select('id', 'score'); + if ($union) { + $query->where('id', '<=', 3)->unionAll( + $connection->table('entries')->select('id', 'score')->where('id', '>', 3) + )->orderBy('score', $descending ? 'desc' : 'asc'); + } else { + $query->orderByRaw('score + ? ' . ($descending ? 'desc' : 'asc'), [7]); + } + $ids = []; + if ($lazy) { + $ids = $query->{$method}(2)->take(8)->pluck('id')->all(); + } else { + $this->assertTrue($query->{$method}(2, function (Collection $rows) use (&$ids): bool { + array_push($ids, ...$rows->pluck('id')->all()); + + return count($ids) < 8; + })); + } + + $this->assertSame($descending ? [6, 5, 4, 3, 2, 1] : [1, 2, 3, 4, 5, 6], $ids); + if (! $union) { + foreach ($connection->getQueryLog() as $executed) { + $this->assertStringEndsWith('order by "id" ' . ($descending ? 'desc' : 'asc') . ' limit 2', $executed['query']); + } + } + } + } + + #[DataProvider('limitedIdOrders')] + public function testLimitedIdIterationChecksItsLeadingOrder(string $firstColumn, string $column, string $direction, ?int $offset, bool $valid, int $limit = 3): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table entries (id integer, score_id integer)'); + $connection->unprepared('create table scores (id integer)'); + $connection->unprepared('insert into entries values (1, 4), (2, 3), (3, 2), (4, 1)'); + $connection->unprepared('insert into scores values (1), (2), (3), (4)'); + + foreach (['chunkById', 'lazyById'] as $method) { + $query = $connection->table('entries as e')->select('e.id as event_id')->orderBy($firstColumn, $direction); + if ($firstColumn === 's.id') { + $query->join('scores as s', 's.id', '=', 'e.score_id'); + } + $offset !== null ? $query->offset($offset) : $query->limit($limit); + $ids = []; + try { + if ($method === 'lazyById') { + $ids = $query->lazyById(2, $column, 'event_id')->all(); + $ids = array_column($ids, 'event_id'); + } else { + $query->chunkById(2, function (Collection $rows) use (&$ids): void { + array_push($ids, ...$rows->pluck('event_id')->all()); + }, $column, 'event_id'); + } + $this->assertTrue($valid, 'A conflicting limited order must fail before processing rows.'); + $this->assertSame(array_slice([1, 2, 3, 4], $offset ?? 0, $offset === null ? $limit : null), $ids); + } catch (InvalidArgumentException $exception) { + $this->assertFalse($valid); + $this->assertStringContainsString('fromSub()', $exception->getMessage()); + $this->assertSame([], $ids); + } + } + } + + /** + * Provide valid aliases and conflicting limited iteration orders. + */ + public static function limitedIdOrders(): iterable + { + yield 'own table qualification' => ['e.id', 'id', 'asc', null, true]; + yield 'selected alias' => ['event_id', 'e.id', 'asc', null, true]; + yield 'offset with ID order' => ['e.id', 'e.id', 'asc', 1, true]; + yield 'other joined table' => ['s.id', 'e.id', 'asc', null, false]; + yield 'opposite direction' => ['e.id', 'e.id', 'desc', null, false]; + yield 'offset chosen by another column' => ['e.score_id', 'e.id', 'asc', 1, false]; + yield 'zero offset does not choose a subset' => ['e.score_id', 'e.id', 'asc', 0, true]; + yield 'zero limit processes no rows' => ['e.score_id', 'e.id', 'asc', null, true, 0]; + } + + public function testLimitedIdIterationCanSelectItsRankedSetInASubquery(): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table entries (id integer, score integer)'); + $connection->unprepared('insert into entries values (1, 100), (2, 101), (3, 102), (4, 1), (5, 2), (6, 3)'); + $ranked = $connection->table('entries')->orderBy('score')->limit(3); + + $this->assertSame([4, 5, 6], $connection->query()->fromSub($ranked, 'ranked')->lazyById(2)->pluck('id')->all()); + } + + public function testRepeatedIdPagingRestoresMembershipOrdersAndCurrentBindings(): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $query = new class($connection) extends Builder { + /** + * Restore ordering used by a driver's row-selecting clauses. + */ + public function restoreMembershipOrder(): void + { + $this->restoreKeysetOrdering(); + } + }; + $query->from('entries')->orderByRaw('ts + ?', [1])->orderByDesc('id')->orderByRaw('bucket + ?', [2]) + ->forPageAfterId(2, 1)->prioritizeKeysetOrders(); + $query->setBindings([3, 4], 'order')->orderByRaw('other + ?', [5]) + ->forPageAfterId(2, 2)->prioritizeKeysetOrders(); + $query->restoreMembershipOrder(); + + $this->assertSame('select * from "entries" where "id" > ? and "id" > ? order by ts + ?, "id" desc, bucket + ?, other + ? limit 2', $query->toSql()); + $this->assertSame([1, 2, 3, 4, 5], $query->getBindings()); + } + #[DataProvider('keysetIterators')] public function testKeysetIterationUsesTheSourceColumnWhenAnAliasShadowsIt(string $method, bool $lazy, bool $descending): void { @@ -115,6 +235,27 @@ public function testUnionCursorPrefersOutputAliasesOverOtherSourceColumns(): voi $this->assertNull($page->previousCursor()); } + public function testCursorPaginationHonorsTheRootAliasLookupOverride(): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table entries (id integer)'); + $connection->unprepared('insert into entries values (1), (2), (3), (4), (5), (6)'); + $query = new class($connection) extends Builder { + /** + * Shift the cursor boundary independently of the selected alias. + */ + protected function getOriginalColumnNameForCursorPagination(Builder|EloquentBuilder $builder, string $parameter): string + { + return '(id - 1)'; + } + }; + + $page = $query->from('entries')->select('id as v')->orderBy('v') + ->cursorPaginate(2, cursor: new Cursor(['v' => 2])); + + $this->assertSame([4, 5], $page->pluck('v')->all()); + } + #[TestWith(['id', 'entries.id'])] #[TestWith(['entries.id', 'id'])] public function testUnionPagingMatchesQualifiedAndUnqualifiedSourceColumns(string $selected, string $column): void @@ -307,6 +448,25 @@ public function testPreparedPagesKeepBindingsAndEmbeddingConsistent(): void $this->assertSame(3, $page->unionLimit); } + #[TestWith(['events.tenant_id', false], 'bare reference')] + #[TestWith(['tenant_id + 1', true], 'arithmetic expression')] + public function testGroupedCursorPagesOnlyIsolateComputedSources(string $source, bool $isolated): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table events (tenant_id integer, amount integer)'); + $connection->unprepared('insert into events values (1, 2), (1, 3), (2, 5), (3, 7)'); + $query = $connection->table('events')->selectRaw($source . ' as tenant')->selectRaw('sum(amount) as total') + ->orderBy('tenant')->beforeQuery(static fn (Builder $query) => $query + ->groupBy('events.tenant_id')->havingRaw('sum(amount) >= ?', [5])); + $connection->enableQueryLog(); + + $page = $query->cursorPaginate(1, cursor: new Cursor(['tenant' => $isolated ? 2 : 1])); + + $this->assertSame([$isolated ? 3 : 2], $page->pluck('tenant')->all()); + $this->assertSame([5], $page->pluck('total')->all()); + $this->assertSame($isolated, str_contains($connection->getQueryLog()[0]['query'], 'cursor_table')); + } + #[TestWith([false])] #[TestWith([true])] public function testCursorComparisonsFilterGroupedExpressionResults(bool $shadowed): void diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 7968983056..388b8d5c57 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -4923,6 +4923,15 @@ public function testFindReturnsFirstResultByID(): void $this->assertEquals(['foo' => 'bar'], $results); } + public function testFindAcceptsALoneExpression(): void + { + $connection = new PdoConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table users (id integer)'); + $connection->unprepared('insert into users values (1)'); + + $this->assertSame(2, $connection->table('users')->find(1, new Raw('id + 1 as value'))->value); + } + public function testFindOrReturnsFirstResultByID(): void { $builder = $this->getMockQueryBuilder(); diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php index 9dd165101a..c386766da9 100644 --- a/tests/Integration/Database/EloquentCursorPaginateTest.php +++ b/tests/Integration/Database/EloquentCursorPaginateTest.php @@ -19,6 +19,7 @@ use Hypervel\Support\Facades\Schema; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; class EloquentCursorPaginateTest extends DatabaseTestCase { @@ -106,6 +107,179 @@ public function testCursorPaginationWithNumericExpressionProjection(): void $this->assertEquals(1.5, array_first($page->items()[0]->getAttributes())); } + #[TestWith(['query'], 'query expression')] + #[TestWith(['eloquent'], 'model expression')] + #[TestWith(['scope'], 'scope-added expression')] + #[TestWith(['argument'], 'lone expression argument')] + public function testCursorPaginationUsesTheSelectedArithmeticExpression(string $selection): void + { + TestPost::fillAndInsert([[], [], [], []]); + $scopeCalls = 0; + $query = function () use ($selection, &$scopeCalls): Builder|QueryBuilder { + $query = in_array($selection, ['eloquent', 'scope'], true) + ? TestPost::query() : DB::table('test_posts'); + + if ($selection === 'scope') { + $query->withGlobalScope('computed-id', function (Builder $builder) use (&$scopeCalls): void { + ++$scopeCalls; + $this->assertNull($builder->getQuery()->columns); + $builder->selectRaw('id + 1 as id'); + }); + } elseif ($selection !== 'argument') { + $query->selectRaw('id + 1 as id'); + } + + return $query->orderBy('id'); + }; + $columns = $selection === 'argument' ? new Expression('id + 1 as id') : ['*']; + + $first = $query()->cursorPaginate(2, $columns); + $second = $query()->cursorPaginate(2, $columns, cursor: $first->nextCursor()); + $previous = $query()->cursorPaginate(2, $columns, cursor: $second->previousCursor()); + + $this->assertSame([2, 3], $first->pluck('id')->all()); + $this->assertSame([4, 5], $second->pluck('id')->all()); + $this->assertSame([2, 3], $previous->pluck('id')->all()); + $this->assertNull($second->nextCursor()); + $this->assertSame($selection === 'scope' ? 3 : 0, $scopeCalls); + } + + public function testCursorPaginationQuotesAliasedIdentifiersContainingParentheses(): void + { + Schema::table('test_posts', function (Blueprint $table): void { + $table->integer('weight (kg)')->nullable(); + }); + TestPost::fillAndInsert([['weight (kg)' => 10], ['weight (kg)' => 20]]); + $query = fn () => TestPost::query()->select('weight (kg) as weight')->orderBy('weight'); + + $first = $query()->cursorPaginate(1); + $second = $query()->cursorPaginate(1, cursor: $first->nextCursor()); + + $this->assertSame([10], $first->pluck('weight')->all()); + $this->assertSame([20], $second->pluck('weight')->all()); + $this->assertNull($second->nextCursor()); + } + + #[DataProvider('idIteratorScopes')] + public function testIdIterationKeepsPagingOrderThroughGlobalScopes(string $method, bool $lazy, bool $descending, bool $replace): void + { + TestPost::fillAndInsert(array_map(fn (int $id): array => ['id' => $id, 'user_id' => 4 - (int) ceil($id / 2)], range(1, 6))); + $scopeCalls = 0; + $query = TestPost::query()->withGlobalScope('sort', function (Builder $builder) use ($descending, $replace, &$scopeCalls): void { + ++$scopeCalls; + $builder->{$replace ? 'reorder' : 'orderBy'}('user_id', $descending ? 'desc' : 'asc'); + }); + $ids = []; + if ($lazy) { + $ids = $query->{$method}(2)->take(8)->pluck('id')->all(); + } else { + $this->assertTrue($query->{$method}(2, function ($rows) use (&$ids): bool { + array_push($ids, ...$rows->modelKeys()); + + return count($ids) < 8; + })); + } + + $this->assertSame($descending ? [6, 5, 4, 3, 2, 1] : [1, 2, 3, 4, 5, 6], $ids); + $this->assertSame(4, $scopeCalls); + $this->assertSame($descending ? [3, 3, 2, 2, 1, 1] : [1, 1, 2, 2, 3, 3], $query->get()->pluck('user_id')->all()); + } + + /** + * Cover both directions and iterator families with appended and replaced scope orders. + */ + public static function idIteratorScopes(): iterable + { + foreach (['chunkById', 'chunkByIdDesc', 'lazyById', 'lazyByIdDesc'] as $method) { + foreach ([false, true] as $replace) { + yield $method . ($replace ? ' replaced' : ' appended') => [$method, str_starts_with($method, 'lazy'), str_ends_with($method, 'Desc'), $replace]; + } + } + } + + public function testCursorPaginationKeepsItsCompoundOrderWhenAScopeReplacesIt(): void + { + TestPost::fillAndInsert(array_map(fn (int $id): array => ['id' => $id, 'user_id' => 4 - (int) ceil($id / 2)], range(1, 6))); + $query = fn () => TestPost::query()->orderBy('user_id')->orderBy('id') + ->withGlobalScope('reverse', fn (Builder $builder) => $builder->reorder('id', 'desc')); + + $first = $query()->cursorPaginate(3); + $second = $query()->cursorPaginate(3, cursor: $first->nextCursor()); + $previous = $query()->cursorPaginate(3, cursor: $second->previousCursor()); + + $this->assertSame([5, 6, 3], $first->getCollection()->modelKeys()); + $this->assertSame([4, 1, 2], $second->getCollection()->modelKeys()); + $this->assertSame([5, 6, 3], $previous->getCollection()->modelKeys()); + } + + #[TestWith(['cursorPaginate'])] + #[TestWith(['chunkById'])] + #[TestWith(['lazyById'])] + public function testUnionPagingRestoresItsOuterOrderAndBoundedMemberBindings(string $method): void + { + TestPost::fillAndInsert(array_map(fn (int $id): array => ['id' => $id], range(1, 7))); + $query = fn () => TestPost::where('id', '>=', 4)->orderByRaw('id * ? desc', [1])->limit(2) + ->unionAll(TestPost::where('id', '<=', 3))->orderBy('id') + ->withGlobalScope('reverse', fn (Builder $builder) => $builder->reorder()->inOrderOf('id', [7, 6, 5, 4, 3, 2, 1])); + $ids = []; + if ($method === 'cursorPaginate') { + $cursor = null; + do { + $page = $query()->cursorPaginate(2, cursor: $cursor); + array_push($ids, ...$page->getCollection()->modelKeys()); + $cursor = $page->nextCursor(); + } while ($cursor !== null && count($ids) < 8); + $this->assertSame([3, 6], $query()->cursorPaginate(2, cursor: $page->previousCursor())->getCollection()->modelKeys()); + } elseif ($method === 'lazyById') { + $ids = $query()->lazyById(2)->take(8)->pluck('id')->all(); + } else { + $this->assertTrue($query()->chunkById(2, function ($rows) use (&$ids): bool { + array_push($ids, ...$rows->modelKeys()); + + return count($ids) < 8; + })); + } + + $this->assertSame([1, 2, 3, 6, 7], $ids); + } + + #[TestWith(['cursorPaginate'])] + #[TestWith(['chunk'])] + #[TestWith(['lazy'])] + public function testUnionPaginationAddsAnOuterDefaultOrder(string $method): void + { + TestPost::fillAndInsert(array_map(fn (int $id): array => ['id' => $id], range(1, 6))); + $query = fn () => TestPost::where('id', '>=', 4)->orderByDesc('id')->limit(2) + ->unionAll(TestPost::where('id', '<=', 3)); + $ids = []; + if ($method === 'cursorPaginate') { + $cursor = null; + do { + $page = $query()->cursorPaginate(2, cursor: $cursor); + array_push($ids, ...$page->getCollection()->modelKeys()); + $cursor = $page->nextCursor(); + } while ($cursor !== null && count($ids) < 8); + } elseif ($method === 'lazy') { + $ids = $query()->lazy(2)->take(8)->pluck('id')->all(); + } else { + $this->assertTrue($query()->chunk(2, function ($rows) use (&$ids): bool { + array_push($ids, ...$rows->modelKeys()); + + return count($ids) < 8; + })); + } + + $this->assertSame([1, 2, 3, 5, 6], $ids); + $raw = $query()->getQuery(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('You must specify an orderBy clause'); + match ($method) { + 'chunk' => $raw->chunk(2, fn () => true), + 'lazy' => $raw->lazy(2)->first(), + default => $raw->cursorPaginate(2), + }; + } + #[TestWith([false], 'single select')] #[TestWith([true], 'union members')] public function testCursorBoundaryAppliesToAllOrWhereBranches(bool $union): void From ee2a41cd72dd2652e412cf926ae7c9a1ca24bab9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:45:23 +0000 Subject: [PATCH 18/22] Use the query builder's ID ordering for cached model mutations Remove the explicit reorder before unbounded mutation batches and its stale comment. ID iteration now owns its paging order and strips unnecessary sorts when executing the page, so the cache coordinator no longer needs to do this itself. Limited selections still capture their complete ranked set first. Validated the coordinator tests and the Sanctum and Passport cache suites, including multi-batch writes, ranked selections and transactional failures. --- src/cache/src/ModelCacheCoordinator.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cache/src/ModelCacheCoordinator.php b/src/cache/src/ModelCacheCoordinator.php index cfe6f41bc0..94decfc062 100644 --- a/src/cache/src/ModelCacheCoordinator.php +++ b/src/cache/src/ModelCacheCoordinator.php @@ -187,8 +187,7 @@ public function mutate(Builder $query, Closure $write, Closure $invalidate): int $writeBatch($batch); } } else { - // Other sort columns would make chunkById skip records between batches. - $selection->reorder()->chunkById( + $selection->chunkById( self::MUTATION_BATCH_SIZE, static fn (Collection $rows) => $writeBatch($rows->pluck($key)->all()), $qualifiedKey, From 8c5223a5997deac3c11a29e07e4ca2e61a2c6de0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:45:32 +0000 Subject: [PATCH 19/22] Document paging order requirements and facade lifetimes Explain that ID iterators follow their paging column even when callers stop early, and show how to select a ranked subset with fromSub before iterating it. Record the conflicting limited-call restriction and the outer ordering needed for query-builder union paging in the query guide, package differences and Laravel porting guidance. Clarify that cursor queries must declare their complete unique order and select each ordered expression separately with an explicit alias. Scope-added order keys, implicit aliases and multi-column raw selections do not establish a supported cursor order. Correct the facade lifetime guidance: disabling its cache resolves on every call but does not turn singleton or auto-singleton bindings into fresh objects. The guidance now distinguishes repeated resolution from object lifetime. --- src/database/README.md | 3 ++- src/docs/pagination.md | 3 ++- src/docs/porting-from-laravel.md | 6 ++++-- src/docs/queries.md | 14 ++++++++++++++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/database/README.md b/src/database/README.md index 3c4bf576f7..6b46d10501 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -13,8 +13,9 @@ Documentation: https://hypervel.org/docs/database - Transaction records use the resolved connection's `getNameWithReadWriteType()`, not its base `getName()`, so distinct base, read and write sessions own separate callbacks. Extensions inspecting transaction records must compare against the resolved owner's full name. - SQLite requires 3.32.0 or later rather than Laravel's 3.26.0 minimum, so framework code assumes the newer default limit of 32,766 bindings per statement. - MariaDB has a higher [minimum version](https://hypervel.org/docs/database#introduction) than Laravel. Supported servers provide native column and index renaming and UUID columns without legacy compatibility paths. -- Union cursor and ID-based pagination requires provable column correspondence. Use explicit columns in matching positions, or wrap computed and ambiguous projections with `fromSub()`. Union members are mapped by position rather than through the protected `getOriginalColumnNameForCursorPagination()` override; the root query still uses that override. See [paginating unions](https://hypervel.org/docs/queries#paginating-unions). +- Union cursor and ID-based pagination requires provable column correspondence. Use explicit columns in matching positions, or wrap computed and ambiguous projections with `fromSub()`. Query-builder `chunk`, `lazy`, and `cursorPaginate` calls also require `orderBy` after the union to sort the combined result. Union members are mapped by position rather than through the protected `getOriginalColumnNameForCursorPagination()` override; the root query still uses that override. See [paginating unions](https://hypervel.org/docs/queries#paginating-unions). - ID-based paging rejects an unselected column hidden by an output alias when joins or a raw source prevent resolving its table. Qualify the paging column, such as `users.id`, or select it under a separate name; see [chunking results](https://hypervel.org/docs/queries#chunking-results). +- `chunkById` and `lazyById` prioritize the paging column, including when stopping early. Limited or offset calls led by another order throw instead of selecting a different set; choose ranked subsets with `fromSub` first. The descending variants follow the same rule. See [chunking results](https://hypervel.org/docs/queries#chunking-results). - Outgoing framework HTTP requests return idle database sessions to their pools. Code using temporary tables, session locks or retained raw PDOs across these calls must use `withPinnedSession()`; ordinary queries and active transactions keep their behavior. See [releasing and pinning connections](https://hypervel.org/docs/database#releasing-and-pinning-connections). - MySQL and MariaDB connections require `strict` or `modes` in their configuration instead of implicitly inheriting the server's SQL mode, so SQL literals can be escaped correctly. - Laravel's external database pooler support uses a `::direct` connection suffix. Hypervel instead uses normal named connections for each endpoint and `migrations_connection` for schema and migration paths. This keeps direct and pooled endpoints as normal configured connections with their own pool settings, so Hypervel does not support Laravel's `::direct` suffix. diff --git a/src/docs/pagination.md b/src/docs/pagination.md index 3932c7b4cd..b6dcb48f61 100644 --- a/src/docs/pagination.md +++ b/src/docs/pagination.md @@ -160,7 +160,8 @@ However, cursor pagination has the following limitations: - Like `simplePaginate`, cursor pagination can only be used to display "Next" and "Previous" links and does not support generating links with page numbers. - It requires that the ordering is based on at least one unique column or a combination of columns that are unique. Columns with `null` values are not supported. -- Query expressions in "order by" clauses are supported only if they are aliased and added to the "select" clause as well. +- Ordering added by a global scope is not part of the cursor, so the ordering set on the query itself must be unique. +- Query expressions in "order by" clauses are supported only if each expression is selected in its own entry with an explicit `as` alias. For example, use `select('id')->selectRaw('id + 1 as next_id')` instead of `selectRaw('id, id + 1 as next_id')`. Aliases without `as` and ordered expressions within multi-column raw selections are not supported and may fail or skip rows. - Query expressions with parameters are not supported. diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 5b564cbd62..c381b14913 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -434,7 +434,7 @@ Container lifecycles are adapted for Swoole: > [!WARNING] > Unbound concrete classes are automatically cached for the worker lifetime after their first resolution. If an unbound class captures the current user, tenant, request, or other mutable per-request data in its constructor, ordinary tests may pass while concurrent requests receive another request's state. Register the class with `bind()` for a fresh instance, use `scoped()` for one instance per request or job coroutine, call `makeTransient()` for one normal resolution without implicit auto-singletoning or constructor-derived execution scope, construct a guaranteed fresh instance with `build()`, or implement `Transient` when every subclass must always be fresh. Hypervel's intrinsically fresh model, value, builder, pending-request, pipeline, and response families already implement `Transient`; see [Transient Classes](/docs/{{version}}/container#transient-classes) for the current list and binding behavior. -Facades cache non-scoped services for the worker lifetime, including `bind()` bindings; use `scoped()` for request or job state, or disable the facade's cache for a fresh resolution on every call. See [how facades work](/docs/{{version}}/facades#how-facades-work). +Facades cache non-scoped services for the worker lifetime, including `bind()` bindings; use `scoped()` for request or job state. Disabling the facade's cache resolves the service on every call, returning fresh instances only for `bind()` bindings and `Transient` classes; singletons and auto-singletons still return the same instance. See [how facades work](/docs/{{version}}/facades#how-facades-work). Hypervel treats a contextual attribute's resolved value as authoritative, including `null`. Laravel constructor injection may fall through from `null` to class or primitive resolution, a contextual binding, or a declared default. Move that fallback into the attribute resolver when porting code that relies on this behavior. @@ -811,7 +811,9 @@ SQLite JSON-path updates replace assigned objects and retain JSON null. Review a Review pagination on update and delete queries. SQLite and PostgreSQL honor offsets, including without a limit. MySQL and MariaDB reject write offsets and limits on joined updates, and PostgreSQL rejects limits or offsets on `updateFrom`, instead of silently ignoring them. For these unsupported forms, select the intended primary keys first and write those rows explicitly. -For cursor or ID-based pagination across unions, select plain columns in corresponding positions, or use `fromSub` to paginate the combined output. Hypervel rejects projections whose correspondence cannot be established, including computed paging columns. Union members use positional mapping instead of the protected `getOriginalColumnNameForCursorPagination` override; root queries retain that override. See [paginating unions](/docs/{{version}}/queries#paginating-unions). +For cursor or ID-based pagination across unions, select plain columns in corresponding positions, or use `fromSub` to paginate the combined output. Hypervel rejects projections whose correspondence cannot be established, including computed paging columns. For query-builder `chunk`, `lazy`, and `cursorPaginate` calls, add `orderBy` after the union; an order on the first query alone throws. Union members use positional mapping instead of the protected `getOriginalColumnNameForCursorPagination` override; root queries retain that override. See [paginating unions](/docs/{{version}}/queries#paginating-unions). + +`chunkById`, `lazyById`, and their descending variants prioritize the paging column over other orders. Review calls that stop early, including `lazyById()->take(...)`, because they take the first IDs rather than the first rows under another sort. Limited or offset calls whose leading order conflicts with ID iteration throw; use `fromSub` to select a ranked subset before iterating it. See [chunking results](/docs/{{version}}/queries#chunking-results). For ID-based paging with joins or raw sources, qualify an unselected paging column if another output uses its name. Hypervel rejects that ambiguous form instead of sorting by the wrong column. Alternatively, select the source under a separate name and pass that name as the `alias` argument to `chunkById` or `lazyById`; see [chunking results](/docs/{{version}}/queries#chunking-results). diff --git a/src/docs/queries.md b/src/docs/queries.md index 59080aa843..285baa837a 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -201,6 +201,18 @@ DB::table('users')->where('active', false) }); ``` +The `chunkById` and `lazyById` methods sort by the paging column first, even when another order is present or a global scope replaces the ordering. Use their descending variants to iterate in descending ID order. Other sort orders do not choose the first rows, including when a callback stops early or a lazy collection uses `take()`. + +If a query has a `limit` or `offset`, its leading order must match the paging column and iteration direction. Otherwise, Hypervel throws an exception instead of silently selecting a different set. To iterate a limited set chosen by another order, select it in a subquery first: + +```php +$ranked = DB::table('users')->orderByDesc('score')->limit(100); + +DB::query()->fromSub($ranked, 'ranked')->chunkById(20, function (Collection $users) { + // Process the highest-scoring users in ID order... +}); +``` + When a selected alias uses your paging column's name, keep the actual column selected under a separate name and pass that name as the `alias` argument to `chunkById` or `lazyById`. Their descending methods accept the same argument. For queries with joins or raw sources, qualify an unselected paging column, such as `users.id`; Hypervel throws if another selected alias hides its name and the source cannot be identified. This rule also applies to `forPageAfterId` and `forPageBeforeId`, which do not accept an `alias` argument. @@ -644,6 +656,8 @@ In addition to the `union` method, the query builder provides a `unionAll` metho ### Paginating Unions +For query-builder `cursorPaginate`, `chunk`, and `lazy` calls, place `orderBy` after the union so it sorts the combined rows. An order before `union` belongs only to the first query and is not enough. Eloquent supplies its primary key as the default outer order when none is given. + When using `cursorPaginate`, `chunkById`, or `lazyById` on a union, select the paging columns in corresponding positions in every query. Column names and aliases may differ between queries. Hypervel uses their positions to apply each comparison to the correct source column: ```php From 785aec3f3fb36460686535100c72b328c7cdb1d9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:57:11 +0000 Subject: [PATCH 20/22] Preserve scoped selections and model keys during paging A global scope that replaces an ID iterator's order could bypass the limited-selection guard. Restoring the ID order then selected a different set of rows. Carry the caller's limit or offset through the existing order metadata and validate replacement orders before restoring paging state. Appended scope orders remain valid, and lazy queries still apply scopes when consumed rather than when the collection is constructed. Resolve an Eloquent union's implicit order through the selected output of its qualified model key. This handles selected aliases and source aliases without matching a joined table's ID first. Reuse qualifyColumn and the existing output lookup; retain the ordinary non-union default unchanged. Clarify that raw union paging requires an order after the union. Extend the existing cursor integration coverage for bounded scope replacement, lazy scope timing, selected aliases and joined keys. Database unit tests, cursor and binary integration checks on SQLite, PostgreSQL and MySQL, formatting and source/type analysis pass. The final qualification change also passes the complete cursor test file on all three databases. --- src/database/src/Concerns/BuildsQueries.php | 12 +- src/database/src/Eloquent/Builder.php | 7 +- src/database/src/Query/Builder.php | 22 ++- .../Database/EloquentCursorPaginateTest.php | 139 ++++++++++++++++-- 4 files changed, 157 insertions(+), 23 deletions(-) diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php index 21ca28aac9..6227ff96a6 100644 --- a/src/database/src/Concerns/BuildsQueries.php +++ b/src/database/src/Concerns/BuildsQueries.php @@ -167,6 +167,7 @@ public function orderedChunkById(int $count, callable $callback, ?string $column $lastId = null; $skip = $this->getOffset(); $remaining = $this->getLimit(); + $limited = $remaining !== null || $skip > 0; $page = 1; @@ -187,8 +188,8 @@ public function orderedChunkById(int $count, callable $callback, ?string $column // no results we can just break and return from here. When there are results // we will call the callback with the current chunk of these results here. $results = match ($descending) { - SortDirection::Ascending, false => $clone->forPageAfterId($limit, $lastId, $column)->prioritizeKeysetOrders()->get(), - SortDirection::Descending, true => $clone->forPageBeforeId($limit, $lastId, $column)->prioritizeKeysetOrders()->get(), + SortDirection::Ascending, false => $clone->forPageAfterId($limit, $lastId, $column)->prioritizeKeysetOrders($limited)->get(), + SortDirection::Descending, true => $clone->forPageBeforeId($limit, $lastId, $column)->prioritizeKeysetOrders($limited)->get(), }; $countResults = $results->count(); @@ -335,8 +336,9 @@ protected function orderedLazyById(int $chunkSize = 1000, ?string $column = null $this->ensureLimitedIterationOrdersById($column, $descending === true || $descending === SortDirection::Descending); $skip = $this->getOffset(); $remaining = $this->getLimit(); + $limited = $remaining !== null || $skip > 0; - return new LazyCollection(function () use ($chunkSize, $column, $alias, $descending, $skip, $remaining): Generator { + return new LazyCollection(function () use ($chunkSize, $column, $alias, $descending, $skip, $remaining, $limited): Generator { $lastId = null; $page = 1; @@ -355,8 +357,8 @@ protected function orderedLazyById(int $chunkSize = 1000, ?string $column = null } $results = match ($descending) { - SortDirection::Ascending, false => $clone->forPageAfterId($limit, $lastId, $column)->prioritizeKeysetOrders()->get(), - SortDirection::Descending, true => $clone->forPageBeforeId($limit, $lastId, $column)->prioritizeKeysetOrders()->get(), + SortDirection::Ascending, false => $clone->forPageAfterId($limit, $lastId, $column)->prioritizeKeysetOrders($limited)->get(), + SortDirection::Descending, true => $clone->forPageBeforeId($limit, $lastId, $column)->prioritizeKeysetOrders($limited)->get(), }; foreach ($results as $result) { diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 5c03b7e737..f20a82c8fd 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -1054,9 +1054,12 @@ public function cursor(): LazyCollection */ protected function enforceOrderBy(): void { - // A union's outer ordering cannot refer to a member table's qualified key. + // A union's outer order uses the selected key output. Qualify its source + // so a joined table's ID cannot match first. if (empty($this->query->unions ? $this->query->unionOrders : $this->query->orders)) { - $this->orderBy($this->query->unions ? $this->model->getKeyName() : $this->model->getQualifiedKeyName(), SortDirection::Ascending); + $this->orderBy($this->query->unions + ? $this->query->getKeysetOutputColumn($this->qualifyColumn($this->model->getKeyName())) + : $this->model->getQualifiedKeyName(), SortDirection::Ascending); } } diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 63ac1c4c4f..4eec3004bc 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -3409,6 +3409,13 @@ public function restoreKeysetOrdersFrom(self $source): bool return false; } + // A bounded iteration cannot silently replace the set a scope's new order chooses. + foreach ($source->{$property} as $order) { + if (isset($order['keysetLimited'])) { + $this->ensureLimitedIterationOrdersById($order['column'], $order['direction'] === 'desc'); + } + } + // Pages compare against the operation's order. Restore its bindings and inner // ordering together so a scope cannot also change which rows belong to it. $this->orders = $source->orders; @@ -3423,7 +3430,7 @@ public function restoreKeysetOrdersFrom(self $source): bool /** * Sort an ID iteration by its paging column before any other order. */ - public function prioritizeKeysetOrders(): static + public function prioritizeKeysetOrders(bool $limited = false): static { // The next page compares IDs alone; another leading sort can skip unseen IDs. $property = $this->unions ? 'unionOrders' : 'orders'; @@ -3431,6 +3438,13 @@ public function prioritizeKeysetOrders(): static $keyset = array_filter($orders, static fn (array $order): bool => isset($order['keyset'])); if ($keyset !== []) { + // Remember the caller's bound; even unlimited iteration gives each page a limit. + if ($limited) { + foreach ($keyset as $index => $order) { + $keyset[$index]['keysetLimited'] = true; + } + } + // These orders own no bindings. Retain other orders for driver-owned row selection. $this->{$property} = [...$keyset, ...array_diff_key($orders, $keyset)]; } @@ -3682,7 +3696,7 @@ protected function getKeysetSelection(string $column): array /** * Get the selected output name for a paging column. */ - protected function getKeysetOutputColumn(string $parameter, bool $source = false): string + public function getKeysetOutputColumn(string $parameter, bool $source = false): string { // ID methods name a WHERE source; cursors name ORDER BY outputs. An alias // can shadow another source, so those two lookups need different precedence. @@ -4091,7 +4105,9 @@ protected function enforceOrderBy(): void { // A union's inner orders select member rows; only its outer order sorts the result. if (empty($this->unions ? $this->unionOrders : $this->orders)) { - throw new RuntimeException('You must specify an orderBy clause when using this function.'); + throw new RuntimeException($this->unions + ? 'You must specify an orderBy clause after the union when using this function.' + : 'You must specify an orderBy clause when using this function.'); } } diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php index c386766da9..9b63c31047 100644 --- a/tests/Integration/Database/EloquentCursorPaginateTest.php +++ b/tests/Integration/Database/EloquentCursorPaginateTest.php @@ -17,6 +17,7 @@ use Hypervel\Pagination\Cursor; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; +use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; @@ -197,6 +198,73 @@ public static function idIteratorScopes(): iterable } } + #[DataProvider('limitedIdScopeOrders')] + public function testLimitedIdIterationValidatesReplacingScopeOrders(string $scopeOrder, bool $offsetOnly, bool $callerOrder, bool $throws, bool $descending = false): void + { + TestPost::fillAndInsert(array_map(fn (int $id): array => ['id' => $id, 'user_id' => (4 - $id) * 10], range(1, 4))); + + foreach ($descending ? ['chunkByIdDesc', 'lazyByIdDesc'] : ['chunkById', 'lazyById'] as $method) { + $scopeCalls = 0; + $query = TestPost::query()->withGlobalScope('sort', function (Builder $builder) use ($scopeOrder, &$scopeCalls): void { + ++$scopeCalls; + match ($scopeOrder) { + 'replace-other' => $builder->reorder('user_id'), + 'replace-id' => $builder->reorder('id'), + 'replace-descending' => $builder->reorder('id', 'desc'), + 'append-other' => $builder->orderBy('user_id'), + 'clear' => $builder->reorder(), + 'filter' => $builder->where('id', '>', 0), + }; + }); + $offsetOnly ? $query->offset(1) : $query->limit(2); + if ($callerOrder) { + $query->orderBy('id', $descending ? 'desc' : 'asc'); + } + $ids = []; + + try { + if (str_starts_with($method, 'lazy')) { + $rows = $query->{$method}(1); + $this->assertSame(0, $scopeCalls); + $rows->each(function (TestPost $post) use (&$ids): void { + $ids[] = $post->id; + }); + } else { + $query->{$method}(1, function ($rows) use (&$ids): void { + array_push($ids, ...$rows->modelKeys()); + }); + } + $this->assertFalse($throws, 'A conflicting replacement must fail before processing rows.'); + $expected = $descending ? [4, 3, 2, 1] : [1, 2, 3, 4]; + $this->assertSame($offsetOnly ? array_slice($expected, 1) : array_slice($expected, 0, 2), $ids); + $this->assertSame($offsetOnly ? 4 : 2, $scopeCalls); + } catch (InvalidArgumentException $exception) { + $this->assertTrue($throws); + $this->assertStringContainsString('fromSub()', $exception->getMessage()); + $this->assertSame([], $ids); + $this->assertSame(1, $scopeCalls); + } + } + } + + /** + * Distinguish scope replacements from appended orders and ordinary filtering. + */ + public static function limitedIdScopeOrders(): iterable + { + yield 'replacement with a limit' => ['replace-other', false, false, true]; + yield 'replacement with an offset' => ['replace-other', true, false, true]; + yield 'replacement of an explicit ID order' => ['replace-other', false, true, true]; + yield 'replacement with the opposite direction' => ['replace-descending', false, false, true]; + yield 'replacement with the paging key' => ['replace-id', false, false, false]; + yield 'replacement with the descending paging key' => ['replace-descending', false, false, false, true]; + yield 'cleared ordering' => ['clear', false, false, false]; + yield 'appended order with a limit' => ['append-other', false, false, false]; + yield 'appended order with an offset' => ['append-other', true, false, false]; + yield 'appended order after an explicit ID order' => ['append-other', false, true, false]; + yield 'filtering only' => ['filter', false, false, false]; + } + public function testCursorPaginationKeepsItsCompoundOrderWhenAScopeReplacesIt(): void { TestPost::fillAndInsert(array_map(fn (int $id): array => ['id' => $id, 'user_id' => 4 - (int) ceil($id / 2)], range(1, 6))); @@ -243,36 +311,45 @@ public function testUnionPagingRestoresItsOuterOrderAndBoundedMemberBindings(str $this->assertSame([1, 2, 3, 6, 7], $ids); } - #[TestWith(['cursorPaginate'])] - #[TestWith(['chunk'])] - #[TestWith(['lazy'])] - public function testUnionPaginationAddsAnOuterDefaultOrder(string $method): void + #[DataProvider('unionDefaultOrders')] + public function testUnionPaginationAddsAnOuterDefaultOrder(string $method, string $columns, bool $bounded): void { - TestPost::fillAndInsert(array_map(fn (int $id): array => ['id' => $id], range(1, 6))); - $query = fn () => TestPost::where('id', '>=', 4)->orderByDesc('id')->limit(2) - ->unionAll(TestPost::where('id', '<=', 3)); + TestPost::fillAndInsert(array_map(fn (int $id): array => ['id' => $id, 'user_id' => $id * 10], range(1, 6))); + $query = function () use ($columns, $bounded): Builder { + $first = TestPost::query()->select($columns)->where('id', '>=', 4); + if ($bounded) { + $first->orderByDesc('id')->limit(2); + } + + return $first->unionAll(TestPost::query()->select($columns)->where('id', '<=', 3)); + }; + $key = str_contains($columns, 'record_id') ? 'record_id' : 'id'; $ids = []; if ($method === 'cursorPaginate') { $cursor = null; do { $page = $query()->cursorPaginate(2, cursor: $cursor); - array_push($ids, ...$page->getCollection()->modelKeys()); + array_push($ids, ...$page->pluck($key)->all()); $cursor = $page->nextCursor(); } while ($cursor !== null && count($ids) < 8); } elseif ($method === 'lazy') { - $ids = $query()->lazy(2)->take(8)->pluck('id')->all(); + $ids = $query()->lazy(2)->take(8)->pluck($key)->all(); } else { - $this->assertTrue($query()->chunk(2, function ($rows) use (&$ids): bool { - array_push($ids, ...$rows->modelKeys()); + $this->assertTrue($query()->chunk(2, function ($rows) use (&$ids, $key): bool { + array_push($ids, ...$rows->pluck($key)->all()); return count($ids) < 8; })); } - $this->assertSame([1, 2, 3, 5, 6], $ids); + $expected = $bounded ? [1, 2, 3, 5, 6] : range(1, 6); + if ($columns === 'user_id as id') { + $expected = array_map(static fn (int $id): int => $id * 10, $expected); + } + $this->assertSame($expected, $ids); $raw = $query()->getQuery(); $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('You must specify an orderBy clause'); + $this->expectExceptionMessage('You must specify an orderBy clause after the union'); match ($method) { 'chunk' => $raw->chunk(2, fn () => true), 'lazy' => $raw->lazy(2)->first(), @@ -280,6 +357,42 @@ public function testUnionPaginationAddsAnOuterDefaultOrder(string $method): void }; } + /** + * Resolve the default union order through its actual output names. + */ + public static function unionDefaultOrders(): iterable + { + foreach (['cursorPaginate', 'chunk', 'lazy'] as $method) { + yield $method . ' plain key' => [$method, '*', true]; + yield $method . ' aliased key' => [$method, 'id as record_id', true]; + yield $method . ' qualified aliased key without an inner order' => [$method, 'test_posts.id as record_id', false]; + yield $method . ' shadowing output' => [$method, 'user_id as id', true]; + } + } + + #[TestWith([false], 'table name')] + #[TestWith([true], 'table alias')] + public function testUnionDefaultOrderUsesTheModelKeyInsteadOfAJoinedKey(bool $aliased): void + { + TestUser::fillAndInsert([['id' => 10], ['id' => 20]]); + TestPost::fillAndInsert(array_map(fn (int $id): array => ['id' => $id, 'user_id' => $id % 2 ? 10 : 20], range(1, 6))); + $source = $aliased ? 'p' : 'test_posts'; + $member = fn (string $operator): Builder => TestPost::query()->from('test_posts', $aliased ? 'p' : null) + ->join('test_users', 'test_users.id', '=', $source . '.user_id') + ->select($aliased ? 'test_users.id' : 'test_users.id as owner_id', $source . '.id as post_id') + ->where($source . '.id', $operator, 3); + $ids = []; + $cursor = null; + + do { + $page = $member('<=')->unionAll($member('>'))->cursorPaginate(2, cursor: $cursor); + array_push($ids, ...$page->pluck('post_id')->all()); + $cursor = $page->nextCursor(); + } while ($cursor !== null && count($ids) < 8); + + $this->assertSame([1, 2, 3, 4, 5, 6], $ids); + } + #[TestWith([false], 'single select')] #[TestWith([true], 'union members')] public function testCursorBoundaryAppliesToAllOrWhereBranches(bool $union): void From 7bd3c84c97ff40479b0f494ec3d880e8521dbf6b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:57:20 +0000 Subject: [PATCH 21/22] Explain scope ordering in limited ID iteration Document that the limit and offset guard also applies when a global scope replaces ordering with reorder. Such calls must select their ranked subset with fromSub before iterating by ID, rather than silently selecting another set of rows. Distinguish appended scope orders: orderBy stays behind the paging key and does not change which IDs a limited iteration selects. Keep the query guide, database differences and Laravel porting guidance consistent with the implemented behavior and regression coverage. --- src/database/README.md | 2 +- src/docs/porting-from-laravel.md | 2 +- src/docs/queries.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/database/README.md b/src/database/README.md index 6b46d10501..3391fe76fd 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -15,7 +15,7 @@ Documentation: https://hypervel.org/docs/database - MariaDB has a higher [minimum version](https://hypervel.org/docs/database#introduction) than Laravel. Supported servers provide native column and index renaming and UUID columns without legacy compatibility paths. - Union cursor and ID-based pagination requires provable column correspondence. Use explicit columns in matching positions, or wrap computed and ambiguous projections with `fromSub()`. Query-builder `chunk`, `lazy`, and `cursorPaginate` calls also require `orderBy` after the union to sort the combined result. Union members are mapped by position rather than through the protected `getOriginalColumnNameForCursorPagination()` override; the root query still uses that override. See [paginating unions](https://hypervel.org/docs/queries#paginating-unions). - ID-based paging rejects an unselected column hidden by an output alias when joins or a raw source prevent resolving its table. Qualify the paging column, such as `users.id`, or select it under a separate name; see [chunking results](https://hypervel.org/docs/queries#chunking-results). -- `chunkById` and `lazyById` prioritize the paging column, including when stopping early. Limited or offset calls led by another order throw instead of selecting a different set; choose ranked subsets with `fromSub` first. The descending variants follow the same rule. See [chunking results](https://hypervel.org/docs/queries#chunking-results). +- `chunkById` and `lazyById` prioritize the paging column, including when stopping early. Limited or offset calls led by another order throw instead of selecting a different set, including when a global scope uses `reorder`; choose ranked subsets with `fromSub` first. The descending variants follow the same rule. See [chunking results](https://hypervel.org/docs/queries#chunking-results). - Outgoing framework HTTP requests return idle database sessions to their pools. Code using temporary tables, session locks or retained raw PDOs across these calls must use `withPinnedSession()`; ordinary queries and active transactions keep their behavior. See [releasing and pinning connections](https://hypervel.org/docs/database#releasing-and-pinning-connections). - MySQL and MariaDB connections require `strict` or `modes` in their configuration instead of implicitly inheriting the server's SQL mode, so SQL literals can be escaped correctly. - Laravel's external database pooler support uses a `::direct` connection suffix. Hypervel instead uses normal named connections for each endpoint and `migrations_connection` for schema and migration paths. This keeps direct and pooled endpoints as normal configured connections with their own pool settings, so Hypervel does not support Laravel's `::direct` suffix. diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index c381b14913..cae6f3a6ae 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -813,7 +813,7 @@ Review pagination on update and delete queries. SQLite and PostgreSQL honor offs For cursor or ID-based pagination across unions, select plain columns in corresponding positions, or use `fromSub` to paginate the combined output. Hypervel rejects projections whose correspondence cannot be established, including computed paging columns. For query-builder `chunk`, `lazy`, and `cursorPaginate` calls, add `orderBy` after the union; an order on the first query alone throws. Union members use positional mapping instead of the protected `getOriginalColumnNameForCursorPagination` override; root queries retain that override. See [paginating unions](/docs/{{version}}/queries#paginating-unions). -`chunkById`, `lazyById`, and their descending variants prioritize the paging column over other orders. Review calls that stop early, including `lazyById()->take(...)`, because they take the first IDs rather than the first rows under another sort. Limited or offset calls whose leading order conflicts with ID iteration throw; use `fromSub` to select a ranked subset before iterating it. See [chunking results](/docs/{{version}}/queries#chunking-results). +`chunkById`, `lazyById`, and their descending variants prioritize the paging column over other orders. Review calls that stop early, including `lazyById()->take(...)`, because they take the first IDs rather than the first rows under another sort. Limited or offset calls whose leading order conflicts with ID iteration throw, including when a global scope uses `reorder`; use `fromSub` to select a ranked subset before iterating it. See [chunking results](/docs/{{version}}/queries#chunking-results). For ID-based paging with joins or raw sources, qualify an unselected paging column if another output uses its name. Hypervel rejects that ambiguous form instead of sorting by the wrong column. Alternatively, select the source under a separate name and pass that name as the `alias` argument to `chunkById` or `lazyById`; see [chunking results](/docs/{{version}}/queries#chunking-results). diff --git a/src/docs/queries.md b/src/docs/queries.md index 285baa837a..a2d973428f 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -203,7 +203,7 @@ DB::table('users')->where('active', false) The `chunkById` and `lazyById` methods sort by the paging column first, even when another order is present or a global scope replaces the ordering. Use their descending variants to iterate in descending ID order. Other sort orders do not choose the first rows, including when a callback stops early or a lazy collection uses `take()`. -If a query has a `limit` or `offset`, its leading order must match the paging column and iteration direction. Otherwise, Hypervel throws an exception instead of silently selecting a different set. To iterate a limited set chosen by another order, select it in a subquery first: +If a query has a `limit` or `offset`, its leading order must match the paging column and iteration direction, including a replacement from a global scope's `reorder`. Otherwise, Hypervel throws an exception instead of silently selecting a different set. A global scope's `orderBy` stays behind the paging column, so limited iteration still takes the first IDs. To iterate a limited set chosen by another order, select it in a subquery first: ```php $ranked = DB::table('users')->orderByDesc('score')->limit(100); From d9ecff3cf08e143a1e6efe88f5c6444da8a05b36 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:15:22 +0000 Subject: [PATCH 22/22] Validate scoped union ordering against the paging source A limited ID iteration over a union could reject a global scope that reordered by the correct selected key alias. The saved union order contains an output name, but the limited-selection guard expects the original source column. Treating that alias as a source incorrectly qualified it against the member table. Retain the original source in the existing paging-order marker and pass it to the guard when restoring scope ordering. Other marker consumers only check its presence, so this needs no additional metadata or reverse alias lookup. Ordinary queries retain the same source and behavior. Add regression coverage for matching ascending and descending union aliases and rejection of the opposite direction, exercising both chunked and lazy iteration. The complete cursor integration file passes on SQLite, PostgreSQL and MySQL. Query-builder pagination tests, targeted formatting, static analysis and diff checks also pass. --- src/database/src/Query/Builder.php | 7 ++-- .../Database/EloquentCursorPaginateTest.php | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 4eec3004bc..bd9261c078 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -2814,7 +2814,7 @@ public function forPageBeforeId(int $perPage = 15, mixed $lastId = 0, string $co $this->markKeysetCondition($bindingOffset, $column); $this->orderBy($orderColumn, SortDirection::Descending)->limit($perPage); $property = $this->unions ? 'unionOrders' : 'orders'; - $this->{$property}[array_key_last($this->{$property})]['keyset'] = true; + $this->{$property}[array_key_last($this->{$property})]['keyset'] = $column; return $this; } @@ -2847,7 +2847,7 @@ public function forPageAfterId(int $perPage = 15, mixed $lastId = 0, string $col $this->markKeysetCondition($bindingOffset, $column); $this->orderBy($orderColumn, SortDirection::Ascending)->limit($perPage); $property = $this->unions ? 'unionOrders' : 'orders'; - $this->{$property}[array_key_last($this->{$property})]['keyset'] = true; + $this->{$property}[array_key_last($this->{$property})]['keyset'] = $column; return $this; } @@ -3412,7 +3412,8 @@ public function restoreKeysetOrdersFrom(self $source): bool // A bounded iteration cannot silently replace the set a scope's new order chooses. foreach ($source->{$property} as $order) { if (isset($order['keysetLimited'])) { - $this->ensureLimitedIterationOrdersById($order['column'], $order['direction'] === 'desc'); + // The marker retains the WHERE source; a union's order names its output. + $this->ensureLimitedIterationOrdersById($order['keyset'], $order['direction'] === 'desc'); } } diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php index 9b63c31047..f064c902a5 100644 --- a/tests/Integration/Database/EloquentCursorPaginateTest.php +++ b/tests/Integration/Database/EloquentCursorPaginateTest.php @@ -265,6 +265,40 @@ public static function limitedIdScopeOrders(): iterable yield 'filtering only' => ['filter', false, false, false]; } + #[TestWith([false, false], 'ascending alias')] + #[TestWith([true, false], 'descending alias')] + #[TestWith([false, true], 'opposite direction')] + public function testLimitedUnionIdIterationValidatesTheSourceOfAScopedAlias(bool $descending, bool $conflicting): void + { + TestPost::fillAndInsert(array_map(fn (int $id): array => ['id' => $id], range(1, 6))); + + foreach ($descending ? ['chunkByIdDesc', 'lazyByIdDesc'] : ['chunkById', 'lazyById'] as $method) { + $query = TestPost::query()->select('id as record_id')->where('id', '<=', 3) + ->unionAll(TestPost::query()->select('id as record_id')->where('id', '>', 3)) + ->limit(3)->withGlobalScope('sort', fn (Builder $builder) => $builder->reorder('record_id', $descending || $conflicting ? 'desc' : 'asc')); + $ids = []; + + try { + if (str_starts_with($method, 'lazy')) { + $query->{$method}(1, 'id', 'record_id')->each(function (TestPost $post) use (&$ids): void { + $ids[] = $post->record_id; + }); + } else { + $query->{$method}(1, function ($rows) use (&$ids): void { + array_push($ids, ...$rows->pluck('record_id')->all()); + }, 'id', 'record_id'); + } + + $this->assertFalse($conflicting, 'A conflicting scope order must fail before processing rows.'); + $this->assertSame($descending ? [6, 5, 4] : [1, 2, 3], $ids); + } catch (InvalidArgumentException $exception) { + $this->assertTrue($conflicting); + $this->assertStringContainsString('fromSub()', $exception->getMessage()); + $this->assertSame([], $ids); + } + } + } + public function testCursorPaginationKeepsItsCompoundOrderWhenAScopeReplacesIt(): void { TestPost::fillAndInsert(array_map(fn (int $id): array => ['id' => $id, 'user_id' => 4 - (int) ceil($id / 2)], range(1, 6)));