From 899aec4fde0743d08b6e7d4dd3df4ab6d7fe86fa Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:26:41 +0000 Subject: [PATCH 1/7] Preserve query ownership and bindings during database pagination Integrate Laravel framework PRs #61841 and #62131 from 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20, retaining the complete upstream binding and grouped-union count regressions. Prepare SQL and parameters from the same query copy. Preserve the clauses that select rows inside bounded union members, apply page comparisons to their output, and map member columns by position. Reject ambiguous mappings instead of silently filtering the wrong column. Keep normal scope timing, root alias overrides, current binding values and reusable queries intact. Retain first-SELECT bounds in grouped union counts. Complete callbacks when embedding queries, keep logical binding categories separate from SQL order, and compile group limits without consuming caller state. Track parameters owned by column orders so replacing an order removes only its own values. Nested unions use a derived table only on older MySQL versions that need it. Verification includes full source and type analysis, the Database suite, SQLite and PostgreSQL integration, MySQL 5.7 integration, and cursor cases on MySQL 8.0.30 and MariaDB 10.11. --- src/database/src/Concerns/BuildsQueries.php | 93 +-- src/database/src/Eloquent/Builder.php | 10 +- src/database/src/Query/Builder.php | 690 ++++++++++++++++-- src/database/src/Query/Grammars/Grammar.php | 26 +- .../src/Query/Grammars/MySqlGrammar.php | 30 + .../DatabaseMySqlQueryGrammarTest.php | 49 ++ .../DatabaseQueryBuilderEmbeddingTest.php | 27 +- .../DatabaseQueryBuilderPaginationTest.php | 206 ++++++ tests/Database/DatabaseQueryBuilderTest.php | 265 ++++++- .../Database/EloquentCursorPaginateTest.php | 140 ++++ .../Integration/Database/QueryBuilderTest.php | 9 + 11 files changed, 1409 insertions(+), 136 deletions(-) diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php index 639c8b35ee..93b7dd699e 100644 --- a/src/database/src/Concerns/BuildsQueries.php +++ b/src/database/src/Concerns/BuildsQueries.php @@ -17,6 +17,7 @@ use Hypervel\Pagination\CursorPaginator; use Hypervel\Pagination\LengthAwarePaginator; use Hypervel\Pagination\Paginator; +use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; use Hypervel\Support\Str; @@ -509,75 +510,35 @@ protected function paginateUsingCursor( $orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->pointsToPreviousItems()); if (! is_null($cursor)) { - // Reset the union bindings so we can add the cursor where in the correct position... - $this->setBindings([], 'union'); - - $addCursorConditions = function (QueryBuilder|Builder $builder, $previousColumn, $originalColumn, $i) use (&$addCursorConditions, $cursor, $orders) { - // Unions are base queries; the other builder family only appears in nested constraints without unions. - $unionBuilders = $builder instanceof self ? $builder->getUnionBuilders() : new Collection; - - if (! is_null($previousColumn)) { - $originalColumn ??= $this->getOriginalColumnNameForCursorPagination($this, $previousColumn); - - $builder->where( - $originalColumn !== $previousColumn && Str::contains($originalColumn, ['(', ')']) ? new Expression($originalColumn) : $originalColumn, - '=', - $this->getCursorParameterBinding($cursor, $previousColumn) - ); - - $unionBuilders->each(function ($unionBuilder) use ($previousColumn, $cursor) { - $unionBuilder->where( - $this->getOriginalColumnNameForCursorPagination($unionBuilder, $previousColumn), - '=', - $this->getCursorParameterBinding($cursor, $previousColumn) - ); - - $this->addBinding($unionBuilder->getRawBindings()['where'], 'union'); - }); + $query = $this instanceof Builder ? $this->getQuery() : $this; + $originalColumns = $query->columns; + $originalColumnNames = []; + + // 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); + try { + foreach ($orders as $order) { + $originalColumnNames[$order['column']] = $this->getOriginalColumnNameForCursorPagination($this, $order['column']); } + } finally { + $query->columns = $originalColumns; + } - $builder->where(function (QueryBuilder|Builder $secondBuilder) use ($addCursorConditions, $cursor, $orders, $i, $unionBuilders) { - ['column' => $column, 'direction' => $direction] = $orders[$i]; - - $originalColumn = $this->getOriginalColumnNameForCursorPagination($this, $column); - - $secondBuilder->where( - $originalColumn !== $column && Str::contains($originalColumn, ['(', ')']) ? new Expression($originalColumn) : $originalColumn, - $direction === 'asc' ? '>' : '<', - $this->getCursorParameterBinding($cursor, $column) - ); - - if ($i < $orders->count() - 1) { - $secondBuilder->orWhere(function (QueryBuilder|Builder $thirdBuilder) use ($addCursorConditions, $column, $originalColumn, $i) { - $addCursorConditions($thirdBuilder, $column, $originalColumn, $i + 1); - }); - } - - $unionBuilders->each(function ($unionBuilder) use ($column, $direction, $cursor, $i, $orders, $addCursorConditions) { - $unionWheres = $unionBuilder->getRawBindings()['where']; - - $originalColumn = $this->getOriginalColumnNameForCursorPagination($unionBuilder, $column); - $unionBuilder->where(function ($unionBuilder) use ($column, $direction, $cursor, $i, $orders, $addCursorConditions, $originalColumn, $unionWheres) { - $unionBuilder->where( - $originalColumn, - $direction === 'asc' ? '>' : '<', - $this->getCursorParameterBinding($cursor, $column) - ); - - if ($i < $orders->count() - 1) { - $unionBuilder->orWhere(function (QueryBuilder|Builder $fourthBuilder) use ($addCursorConditions, $column, $originalColumn, $i) { - $addCursorConditions($fourthBuilder, $column, $originalColumn, $i + 1); - }); - } - - $this->addBinding($unionWheres, 'union'); - $this->addBinding($unionBuilder->getRawBindings()['where'], 'union'); - }); - }); - }); - }; + $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] = $this->getCursorParameterBinding($cursor, $column); + } - $addCursorConditions($this, null, null, 0); + // 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); } $this->limit($perPage + 1); diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 52821cc823..fccb71ecea 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -1164,6 +1164,8 @@ protected function ensureOrderForCursorPagination(bool $shouldReverse = false): $this->enforceOrderBy(); } + $this->query->prepareCursorOrdering(); + $reverseDirection = function ($order) { if (! isset($order['direction'])) { return $order; @@ -1175,8 +1177,12 @@ protected function ensureOrderForCursorPagination(bool $shouldReverse = false): }; if ($shouldReverse) { - $this->query->orders = (new BaseCollection($this->query->orders))->map($reverseDirection)->toArray(); - $this->query->unionOrders = (new BaseCollection($this->query->unionOrders))->map($reverseDirection)->toArray(); + if (! empty($this->query->unionOrders)) { + // Reversing the page must not change a bounded first SELECT's rows. + $this->query->unionOrders = (new BaseCollection($this->query->unionOrders))->map($reverseDirection)->toArray(); + } else { + $this->query->orders = (new BaseCollection($this->query->orders))->map($reverseDirection)->toArray(); + } } $orders = ! empty($this->query->unionOrders) ? $this->query->unionOrders : $this->query->orders; diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 398db7d6a6..06e3c57f8f 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -32,6 +32,7 @@ use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; +use Hypervel\Support\Str; use Hypervel\Support\StrCache; use Hypervel\Support\Stringable; use Hypervel\Support\Traits\ForwardsCalls; @@ -40,6 +41,7 @@ use JsonException; use LogicException; use PDO; +use ReflectionProperty; use RuntimeException; use SortDirection; use stdClass; @@ -141,6 +143,23 @@ class Builder implements BuilderContract */ public array $wheres = []; + /** + * Whether paging predicates belong to this SELECT or its union result. + */ + protected bool $hasSelectKeysetConditions = false; + + protected bool $hasUnionKeysetConditions = false; + + /** + * Whether a cursor compares a selected expression. + */ + protected bool $hasKeysetExpressions = false; + + /** + * The SELECT orders removed by paging, in removal order. + */ + protected array $removedKeysetOrders = []; + /** * The groupings for the query. */ @@ -395,7 +414,7 @@ protected function parseSub(mixed $query): array } if ($query instanceof self) { - $this->ensureCanEmbedQuery($query); + $query = $this->prepareQueryForEmbedding($query); $query = $this->prependDatabaseNameIfCrossDatabaseQuery($query); @@ -1849,7 +1868,7 @@ protected function whereSub(ExpressionContract|string $column, string $operator, $query = $callback instanceof self ? $callback : $callback->toBase(); } - $this->ensureCanEmbedQuery($query); + $query = $this->prepareQueryForEmbedding($query); $this->wheres[] = [ 'type' => $type, 'column' => $column, 'operator' => $operator, 'query' => $query, 'boolean' => $boolean, @@ -1926,7 +1945,7 @@ public function orWhereNotExists(Closure|self|EloquentBuilder $callback): static */ public function addWhereExistsQuery(self $query, string $boolean = 'and', bool $not = false): static { - $this->ensureCanEmbedQuery($query); + $query = $this->prepareQueryForEmbedding($query); $type = $not ? 'NotExists' : 'Exists'; @@ -2666,10 +2685,7 @@ public function inOrderOf(ExpressionContract|string $column, Arrayable|array $va 'values' => $values, ]; - $this->addBinding( - $this->cleanBindings($values), - $hasUnions ? 'unionOrder' : 'order' - ); + $this->addOrderBindings($this->cleanBindings($values)); return $this; } @@ -2688,6 +2704,26 @@ public function orderByRaw(string $sql, mixed $bindings = []): static return $this; } + /** + * Attach bindings to the last column order so removing it also removes its parameters. + */ + protected function addOrderBindings(array $bindings): void + { + if ($bindings === []) { + return; + } + + $property = $this->unions ? 'unionOrders' : 'orders'; + $slot = $this->unions ? 'unionOrder' : 'order'; + $index = array_key_last($this->{$property}); + $offset = count($this->bindings[$slot]); + + $this->addBinding($bindings, $slot); + + $this->{$property}[$index]['bindings'] ??= ['offset' => $offset, 'count' => 0]; + $this->{$property}[$index]['bindings']['count'] += count($this->bindings[$slot]) - $offset; + } + /** * Alias to set the "offset" value of the query. */ @@ -2761,7 +2797,13 @@ public function forPageBeforeId(int $perPage = 15, mixed $lastId = 0, string $co $lastId = BinaryParameter::fromStream($lastId); } - $this->orders = $this->removeExistingOrdersFor($column); + $orderColumn = $this->unions ? $this->getKeysetOutputColumn($column) : $column; + if ($this->unions) { + $this->unionOrders = $this->removeOrdersFor($this->unionOrders ?? [], $this->bindings['unionOrder'], $orderColumn); + } else { + $this->orders = $this->removeExistingOrdersFor($column); + } + $bindingOffset = count($this->bindings['where']); if (is_null($lastId)) { $this->whereNotNull($column); @@ -2769,8 +2811,13 @@ public function forPageBeforeId(int $perPage = 15, mixed $lastId = 0, string $co $this->where($column, '<', $lastId); } - return $this->orderBy($column, SortDirection::Descending) - ->limit($perPage); + $this->markKeysetCondition($bindingOffset, $column); + $this->orderBy($orderColumn, SortDirection::Descending)->limit($perPage); + if (! $this->unions) { + $this->orders[array_key_last($this->orders)]['keyset'] = true; + } + + return $this; } /** @@ -2784,7 +2831,13 @@ public function forPageAfterId(int $perPage = 15, mixed $lastId = 0, string $col $lastId = BinaryParameter::fromStream($lastId); } - $this->orders = $this->removeExistingOrdersFor($column); + $orderColumn = $this->unions ? $this->getKeysetOutputColumn($column) : $column; + if ($this->unions) { + $this->unionOrders = $this->removeOrdersFor($this->unionOrders ?? [], $this->bindings['unionOrder'], $orderColumn); + } else { + $this->orders = $this->removeExistingOrdersFor($column); + } + $bindingOffset = count($this->bindings['where']); if (is_null($lastId)) { $this->whereNotNull($column); @@ -2792,8 +2845,13 @@ public function forPageAfterId(int $perPage = 15, mixed $lastId = 0, string $col $this->where($column, '>', $lastId); } - return $this->orderBy($column, SortDirection::Ascending) - ->limit($perPage); + $this->markKeysetCondition($bindingOffset, $column); + $this->orderBy($orderColumn, SortDirection::Ascending)->limit($perPage); + if (! $this->unions) { + $this->orders[array_key_last($this->orders)]['keyset'] = true; + } + + return $this; } /** @@ -2803,6 +2861,7 @@ public function forPageAfterId(int $perPage = 15, mixed $lastId = 0, string $col */ public function reorder(Closure|self|EloquentBuilder|Relation|ExpressionContract|string|null $column = null, SortDirection|string $direction = SortDirection::Ascending): static { + $this->removedKeysetOrders = []; $this->orders = null; $this->unionOrders = null; $this->bindings['order'] = []; @@ -2828,10 +2887,48 @@ public function reorderDesc(Closure|self|EloquentBuilder|Relation|ExpressionCont */ protected function removeExistingOrdersFor(string $column): array { - return (new Collection($this->orders)) - ->reject(fn ($order) => isset($order['column']) && $order['column'] === $column) - ->values() - ->all(); + return $this->removeOrdersFor($this->orders ?? [], $this->bindings['order'], $column); + } + + /** + * Remove matching orders and only their owned parameters. + */ + protected function removeOrdersFor(array $orders, array &$bindings, string $column): array + { + $remaining = []; + $removed = 0; + + foreach ($orders as $order) { + if (isset($order['bindings'])) { + $order['bindings']['offset'] -= $removed; + } + + if (isset($order['column']) && $order['column'] === $column) { + if (! $this->unions) { + // Retained bindings stay live; copy only values actually removed. + $this->removedKeysetOrders[] = [ + 'index' => count($remaining), + 'order' => $order, + 'bindingOffset' => $order['bindings']['offset'] ?? count($bindings), + 'bindings' => isset($order['bindings']) + ? array_slice($bindings, $order['bindings']['offset'], $order['bindings']['count']) + : [], + ]; + } + // Column orders can own parameters (inOrderOf and driver extensions). + // Paging must not leave them bound after removing their SQL clause. + if (isset($order['bindings'])) { + array_splice($bindings, $order['bindings']['offset'], $order['bindings']['count']); + $removed += $order['bindings']['count']; + } + + continue; + } + + $remaining[] = $order; + } + + return $remaining; } /** @@ -2851,7 +2948,7 @@ public function union(Closure|self|EloquentBuilder $query, bool $all = false): s $query = $query->toBase(); } - $this->ensureCanEmbedQuery($query); + $query = $this->prepareQueryForEmbedding($query); $this->unions[] = ['query' => $query, 'all' => $all]; @@ -2971,7 +3068,7 @@ public function toSql(): string { $this->applyBeforeQueryCallbacks(); - return $this->grammar->compileSelect($this); + return $this->grammar->compileSelect($this->prepareForSelect()); } /** @@ -3071,7 +3168,10 @@ public function soleValue(ExpressionContract|string $column): mixed public function get(ExpressionContract|array|string $columns = ['*']): Collection { $items = new Collection($this->onceWithColumns(Arr::wrap($columns), function () { - return $this->processor->processSelect($this, $this->runSelect()); + $this->applyBeforeQueryCallbacks(); + $query = $this->prepareForSelect(); + + return $this->processor->processSelect($query, $query->runSelect()); })); return $this->applyAfterQueryCallbacks( @@ -3086,11 +3186,14 @@ public function get(ExpressionContract|array|string $columns = ['*']): Collectio */ protected function runSelect(): array { - return $this->connection->select( - $this->toSql(), - $this->getBindings(), - ! $this->useWritePdo, - $this->fetchUsingOverride ?? $this->fetchUsing + $this->applyBeforeQueryCallbacks(); + $query = $this->prepareForSelect(); + + return $query->connection->select( + $query->toSql(), + $query->getBindings(), + ! $query->useWritePdo, + $query->fetchUsingOverride ?? $query->fetchUsing ); } @@ -3218,6 +3321,8 @@ protected function ensureOrderForCursorPagination(bool $shouldReverse = false): $this->enforceOrderBy(); } + $this->prepareCursorOrdering(); + $reverseDirection = function ($order) { if (! isset($order['direction'])) { return $order; @@ -3229,8 +3334,12 @@ protected function ensureOrderForCursorPagination(bool $shouldReverse = false): }; if ($shouldReverse) { - $this->orders = (new Collection($this->orders))->map($reverseDirection)->toArray(); - $this->unionOrders = (new Collection($this->unionOrders))->map($reverseDirection)->toArray(); + if (! empty($this->unionOrders)) { + // Reversing the page must not change a bounded first SELECT's rows. + $this->unionOrders = (new Collection($this->unionOrders))->map($reverseDirection)->toArray(); + } else { + $this->orders = (new Collection($this->orders))->map($reverseDirection)->toArray(); + } } $orders = ! empty($this->unionOrders) ? $this->unionOrders : $this->orders; @@ -3240,6 +3349,436 @@ protected function ensureOrderForCursorPagination(bool $shouldReverse = false): ->values(); } + /** + * Record the paging predicate and the bindings it owns. + */ + protected function markKeysetCondition(int $offset, ?string $column = null): void + { + $index = array_key_last($this->wheres); + $union = (bool) $this->unions; + // Like limit() and orderBy(), a page retains the owner it had when added. + $this->wheres[$index]['keyset'] = [ + 'offset' => $offset, + 'count' => count($this->bindings['where']) - $offset, + 'union' => $union, + ]; + if ($column !== null) { + $this->wheres[$index]['keysetColumn'] = $column; + } + if ($union) { + $this->hasUnionKeysetConditions = true; + } else { + $this->hasSelectKeysetConditions = true; + } + } + + /** + * Retain membership ordering before cursor traversal reverses it. + */ + public function prepareCursorOrdering(): void + { + if ($this->unions) { + return; + } + + foreach ($this->orders ?? [] as $index => $order) { + $this->orders[$index]['keysetCursor'] = true; + if (isset($order['direction'])) { + $this->orders[$index]['keysetDirection'] ??= $order['direction']; + } + } + } + + /** + * Add cursor comparisons resolved by the calling query or Eloquent builder. + * + * @param list $orders + * @param array $bindings + */ + public function addCursorPaginationConditions(array $orders, array $bindings): static + { + $offset = count($this->bindings['where']); + $this->addCursorComparisons($orders, $bindings, 0); + $this->markKeysetCondition($offset); + $this->hasKeysetExpressions = $this->hasKeysetExpressions + || array_any($orders, static fn (array $order): bool => $order['original'] instanceof ExpressionContract); + + return $this; + } + + /** + * Add the ordered comparisons for a compound cursor. + */ + protected function addCursorComparisons(array $orders, array $bindings, int $index): void + { + ['column' => $parameter, 'original' => $column, 'direction' => $direction] = $orders[$index]; + $this->where(function (self $query) use ($orders, $bindings, $index, $parameter, $column, $direction): void { + $query->where($column, $direction === 'asc' ? '>' : '<', $bindings[$parameter]); + $query->wheres[array_key_last($query->wheres)]['keysetColumn'] = $parameter; + if ($index < count($orders) - 1) { + $query->orWhere(function (self $query) use ($orders, $bindings, $index, $parameter, $column): void { + $query->where($column, '=', $bindings[$parameter]); + $query->wheres[array_key_last($query->wheres)]['keysetColumn'] = $parameter; + $query->addCursorComparisons($orders, $bindings, $index + 1); + }); + } + }); + } + + /** + * Determine whether paging must filter the result of this SELECT. + */ + protected function requiresKeysetIsolation(bool $limitSelectsRows): bool + { + return ($limitSelectsRows && ($this->limit !== null || $this->offset !== null)) + || ($this->hasKeysetExpressions && ($this->groups || $this->havings)); + } + + /** + * Prepare a SELECT without changing the logical query retained by its caller. + */ + protected function prepareForSelect(): static + { + $isolateSelect = $this->hasSelectKeysetConditions && $this->requiresKeysetIsolation(false); + if (! $isolateSelect && ! $this->hasUnionKeysetConditions) { + return $this; + } + + $query = $this->clone(); + $query->hasSelectKeysetConditions = $query->hasUnionKeysetConditions = false; + $conditions = []; + $query->wheres = $query->collectKeysetConditions($query->wheres, $conditions, $isolateSelect); + $ranges = array_column($conditions, 'range'); + usort($ranges, static fn (array $left, array $right): int => $right['offset'] <=> $left['offset']); + foreach ($ranges as $range) { + array_splice($query->bindings['where'], $range['offset'], $range['count']); + } + + $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']); + } + } + $query->isolateKeysetSelect(false); + $query->appendKeysetConditions($selectConditions, [], true); + $query->orders = $pageOrders; + $query->bindings['order'] = $pageBindings; + } + + if ($unionConditions !== []) { + $parameters = []; + foreach ($unionConditions as $condition) { + $this->collectKeysetParameters($condition['where'], $parameters); + } + // UNION columns correspond by position, not by each member's aliases. + $this->mapUnionKeysetColumns($this, array_keys($parameters)); + $isolateUnionSelect = $query->requiresKeysetIsolation(true); + if ($isolateUnionSelect) { + $query->isolateKeysetSelect(true); + } + $query->appendKeysetConditions($unionConditions, [], $isolateSelect || $isolateUnionSelect); + $query->bindings['union'] = []; + foreach ($query->unions ?? [] as $index => $union) { + $member = clone $union['query']; + $member = $member->applyUnionKeysetConditions($this, $unionConditions, array_keys($parameters)); + $query->unions[$index]['query'] = $member; + $query->addBinding($member->getBindings(), 'union'); + } + } + + $query->hasKeysetExpressions = false; + + return $query; + } + + /** + * Collect paging constraints without rewriting nested groups shared with another query. + */ + protected function collectKeysetConditions(array $wheres, array &$conditions, bool $includeSelect): array + { + foreach ($wheres as $index => $where) { + if (isset($where['keyset']) && ($includeSelect || $where['keyset']['union'])) { + $range = $where['keyset']; + unset($where['keyset']); + $conditions[] = [ + 'where' => $this->normalizeKeysetColumns($where), + 'bindings' => array_slice($this->bindings['where'], $range['offset'], $range['count']), + 'range' => $range, + ]; + unset($wheres[$index]); + } elseif ($where['type'] === 'Nested') { + $nested = $this->collectKeysetConditions($where['query']->wheres, $conditions, $includeSelect); + if ($nested === []) { + unset($wheres[$index]); + } elseif ($nested !== $where['query']->wheres) { + $where['query'] = clone $where['query']; + $where['query']->wheres = $nested; + $wheres[$index] = $where; + } + } + } + + return array_values($wheres); + } + + /** + * Normalize only framework-owned comparison columns to selected output names. + */ + protected function normalizeKeysetColumns(array $where): array + { + if (isset($where['keysetColumn'])) { + $where['keysetColumn'] = $this->getKeysetOutputColumn($where['keysetColumn']); + } + if ($where['type'] === 'Nested') { + $where['query'] = clone $where['query']; + $where['query']->wheres = array_map($this->normalizeKeysetColumns(...), $where['query']->wheres); + } + + return $where; + } + + /** + * Get the source column and output name of a selected entry. + * + * @return array{string, string} + */ + protected function getKeysetSelection(string $column): array + { + if (($position = strripos($column, ' as ')) === false) { + return [$column, Str::afterLast($column, '.')]; + } + + $original = substr($column, 0, $position); + $alias = substr($column, $position + 4); + $quotes = $this->grammar->wrap(''); + if (str_starts_with($alias, $quotes[0]) && str_ends_with($alias, $quotes[-1])) { + $alias = str_replace($quotes[-1] . $quotes[-1], $quotes[-1], substr($alias, 1, -1)); + } + + return [$original, $alias]; + } + + /** + * Get the selected output name for a paging column. + */ + protected function getKeysetOutputColumn(string $parameter): string + { + foreach ($this->getColumns() as $column) { + [$original, $alias] = $this->getKeysetSelection($column); + if ($parameter === $original || $parameter === $alias) { + return $alias; + } + } + + return Str::afterLast($parameter, '.'); + } + + /** + * Collect the output columns used by a compound paging constraint. + */ + protected function collectKeysetParameters(array $where, array &$parameters): void + { + if (isset($where['keysetColumn'])) { + $parameters[$where['keysetColumn']] = true; + } + if ($where['type'] === 'Nested') { + foreach ($where['query']->wheres as $nested) { + $this->collectKeysetParameters($nested, $parameters); + } + } + } + + /** + * Map union output columns only when their correspondence can be proven. + * + * @return array + */ + protected function mapUnionKeysetColumns(self $member, array $parameters): array + { + $mapping = []; + $sameLayout = is_string($this->from) && is_string($member->from) + && $this->selectsAllSourceColumns() && $member->selectsAllSourceColumns() + && $this->connection === $member->connection + && preg_split('/\s+as\s+/i', $this->from)[0] === preg_split('/\s+as\s+/i', $member->from)[0]; + + foreach ($parameters as $parameter) { + $parameter = (string) $parameter; + if ($sameLayout) { + $mapping[$parameter] = [$member->getFromAlias() . '.' . $parameter, $parameter]; + continue; + } + + $position = null; + foreach ($this->columns ?? [] as $index => $column) { + if (! is_string($column) || $column === '*' || str_ends_with($column, '.*')) { + break; + } + [$original, $alias] = $this->getKeysetSelection($column); + if ($parameter === $alias || $parameter === $original) { + $position = $index; + break; + } + } + if ($position !== null) { + foreach (array_slice($member->columns ?? [], 0, $position + 1) as $index => $column) { + if (! is_string($column) || $column === '*' || str_ends_with($column, '.*')) { + break; + } + if ($index === $position) { + $mapping[$parameter] = $member->getKeysetSelection($column); + } + } + } + if (! isset($mapping[$parameter])) { + // Raw SQL can select several columns. Guessing its width can skip rows. + throw new InvalidArgumentException('Cannot automatically paginate this union. Select explicit plain columns in corresponding positions, or wrap the union with fromSub() and paginate its output.'); + } + } + + return $mapping; + } + + /** + * Determine whether the projection selects only the source's wildcard columns. + */ + protected function selectsAllSourceColumns(): bool + { + return ! $this->joins + && ($this->columns === null || $this->columns === ['*'] || $this->columns === [$this->getFromAlias() . '.*']); + } + + /** + * Apply inherited paging outside member bounds and through unbounded nested unions. + */ + protected function applyUnionKeysetConditions(self $root, array $conditions, array $parameters): static + { + $mapping = $root->mapUnionKeysetColumns($this, $parameters); + if ($this->unions && ($this->unionLimit !== null || $this->unionOffset !== null)) { + return $this->newQuery()->fromSub($this, 'cursor_table')->appendKeysetConditions($conditions, $mapping, true); + } + + $isolate = $this->requiresKeysetIsolation(true); + if ($isolate) { + $this->isolateKeysetSelect(true); + } + $this->appendKeysetConditions($conditions, $mapping, $isolate); + if ($this->unions) { + $this->bindings['union'] = []; + foreach ($this->unions as $index => $union) { + $member = clone $union['query']; + $member = $member->applyUnionKeysetConditions($root, $conditions, $parameters); + $this->unions[$index]['query'] = $member; + $this->addBinding($member->getBindings(), 'union'); + } + } + + return $this; + } + + /** + * Append comparisons using the member's source columns or isolated output names. + */ + protected function appendKeysetConditions(array $conditions, array $mapping = [], bool $output = false): static + { + foreach ($conditions as $condition) { + $this->wheres[] = $this->rebindKeysetColumns($condition['where'], $mapping, $output); + $this->addBinding($condition['bindings'], 'where'); + } + + return $this; + } + + /** + * Rebind only owned comparisons, preserving user predicates and shared nested groups. + */ + protected function rebindKeysetColumns(array $where, array $mapping, bool $output): array + { + if (isset($where['keysetColumn'])) { + $parameter = $where['keysetColumn']; + if (isset($mapping[$parameter])) { + $where['column'] = $mapping[$parameter][$output ? 1 : 0]; + } elseif ($output) { + $where['column'] = $parameter; + } + unset($where['keysetColumn']); + } + if ($where['type'] === 'Nested') { + $where['query'] = clone $where['query']; + foreach ($where['query']->wheres as $index => $nested) { + $where['query']->wheres[$index] = $this->rebindKeysetColumns($nested, $mapping, $output); + } + } + + return $where; + } + + /** + * Restore membership ordering from live entries and the orders paging removed. + */ + protected function restoreKeysetOrdering(): void + { + $orders = $this->orders ?? []; + 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']; + } + $this->orders[] = $order; + } + $this->removedKeysetOrders = []; + } + + /** + * Move row selection inside a derived table while retaining the page outside it. + */ + protected function isolateKeysetSelect(bool $limitSelectsRows): void + { + $aggregate = $this->aggregate; + $inner = $this->cloneWithout(['aggregate', 'unions', 'unionOrders', 'unionLimit', 'unionOffset', 'timeout']) + ->cloneWithoutBindings(['union', 'unionOrder']); + $inner->columns ??= ['*']; + $limit = $this->limit; + $offset = $this->offset; + if (! $limitSelectsRows) { + $inner->limit = $inner->offset = null; + // Grouped rows need only the outer page order; sorting them again adds no selection guarantee. + $inner->orders = null; + $inner->bindings['order'] = []; + } + $this->resetProperties([ + 'aggregate', 'columns', 'distinct', 'from', 'indexHint', 'joins', 'wheres', 'groups', 'havings', + 'orders', 'limit', 'groupLimit', 'offset', 'lock', + ]); + foreach (['select', 'from', 'join', 'where', 'groupBy', 'having', 'order'] as $binding) { + $this->bindings[$binding] = []; + } + $this->columns = ['*']; + $this->aggregate = $aggregate; + $this->fromSub($inner, 'cursor_table'); + if (! $limitSelectsRows) { + $this->limit = $limit; + $this->offset = $offset; + } + } + /** * Get the count of the total records for the paginator. * @@ -3257,7 +3796,7 @@ public function getCountForPagination(array $columns = ['*']): int $query->applyBeforeQueryCallbacks(); } - return $query->runPaginationCountQuery($columns); + return $query->prepareForSelect()->runPaginationCountQuery($columns); }); // Once we have run the pagination count query, we will get the resulting count and @@ -3316,8 +3855,12 @@ protected function runPaginationCountQuery(array $columns = ['*']): array */ protected function cloneForPaginationCount(): static { - return $this->cloneWithout(['orders', 'limit', 'offset']) - ->cloneWithoutBindings(['order']); + // A union's page bounds belong to the union, not its first SELECT. + // Retain that SELECT's limits and ordering so the count sees the same rows. + return $this->cloneWithout($this->unions + ? ['unionOrders', 'unionLimit', 'unionOffset'] + : ['orders', 'limit', 'offset']) + ->cloneWithoutBindings([$this->unions ? 'unionOrder' : 'order']); } /** @@ -3347,12 +3890,14 @@ public function cursor(): LazyCollection } return new LazyCollection(function () { + $this->applyBeforeQueryCallbacks(); + $query = $this->prepareForSelect(); // Deferred execution must read the public query mode, not a scoped terminal override. - foreach ($this->connection->cursor( - $this->toSql(), - $this->getBindings(), - ! $this->useWritePdo, - $this->fetchUsing + foreach ($query->connection->cursor( + $query->toSql(), + $query->getBindings(), + ! $query->useWritePdo, + $query->fetchUsing ) as $key => $item) { $items = $this->applyAfterQueryCallbacks(new Collection([$item])); @@ -3516,11 +4061,12 @@ public function implode(string $column, string $glue = ''): string public function exists(): bool { $this->applyBeforeQueryCallbacks(); + $query = $this->prepareForSelect(); - $results = $this->connection->select( - $this->grammar->compileExists($this), - $this->getBindings(), - ! $this->useWritePdo + $results = $query->connection->select( + $query->grammar->compileExists($query), + $query->getBindings(), + ! $query->useWritePdo ); // If the results have rows, we will get the row and see if the exists column is a @@ -3617,8 +4163,17 @@ public function average(ExpressionContract|string $column): mixed public function aggregate(string $function, array $columns = ['*']): mixed { return $this->withoutFetchUsing(function () use ($function, $columns) { - $results = $this->cloneWithout($this->unions || $this->havings ? [] : ['columns']) - ->cloneWithoutBindings($this->unions || $this->havings ? [] : ['select']) + $query = $this; + // Callbacks may add projections, unions or HAVING. Decide what to retain + // after those clauses exist, without consuming the original query's callbacks. + if ($this->beforeQueryCallbacks !== []) { + $query = $this->clone(); + $query->applyBeforeQueryCallbacks(); + } + + $query = $query->prepareForSelect(); + $results = $query->cloneWithout($query->unions || $query->havings ? [] : ['columns']) + ->cloneWithoutBindings($query->unions || $query->havings ? [] : ['select']) ->setAggregate($function, $columns) ->get($columns); @@ -3665,7 +4220,7 @@ protected function setAggregate(string $function, array $columns): static { $this->aggregate = ['function' => $function, 'columns' => $columns]; - if (empty($this->groups)) { + if (! $this->shouldPreserveAggregateOrdering()) { $this->orders = null; $this->bindings['order'] = []; @@ -3674,6 +4229,15 @@ protected function setAggregate(string $function, array $columns): static return $this; } + /** + * Determine whether ordering selects rows retained by the aggregate's inner query. + */ + protected function shouldPreserveAggregateOrdering(): bool + { + // Bounded union members need their original order to select the same rows. + return (bool) $this->groups || ($this->unions && ($this->limit !== null || $this->offset !== null)); + } + /** * Execute the given callback while selecting the given columns. * @@ -4199,7 +4763,15 @@ public function getOffset(): ?int */ public function getBindings(): array { - return Arr::flatten($this->bindings); + return Arr::flatten($this->getBindingsForSelect()); + } + + /** + * Get the binding groups in the order used by this select statement. + */ + protected function getBindingsForSelect(): array + { + return $this->grammar->prepareBindingsForSelect($this->prepareForSelect()); } /** @@ -4273,7 +4845,8 @@ public function castBinding(mixed $value): mixed */ public function mergeBindings(self $query): static { - $this->bindings = array_merge_recursive($this->bindings, $query->bindings); + $query->applyBeforeQueryCallbacks(); + $this->bindings = array_merge_recursive($this->bindings, $query->getBindingsForSelect()); return $this; } @@ -4385,17 +4958,23 @@ protected function isQueryable(mixed $value): bool } /** - * Ensure the query can be embedded in another statement. + * Prepare the query for embedding in another statement. * * @throws InvalidArgumentException */ - protected function ensureCanEmbedQuery(self $query): void + protected function prepareQueryForEmbedding(self $query): self { + // Callbacks can change both bindings and statement options. Complete them + // before validation and before the parent captures the embedded query. + $query->applyBeforeQueryCallbacks(); + if ($query->timeout !== null) { throw new InvalidArgumentException( 'An embedded query cannot define its own timeout. Apply the timeout to the outer query instead.' ); } + + return $query->prepareForSelect(); } /** @@ -4411,11 +4990,20 @@ public function clone(): static */ public function cloneWithout(array $properties): static { - return tap($this->clone(), function ($clone) use ($properties) { - foreach ($properties as $property) { - $clone->{$property} = is_array($clone->{$property}) ? [] : null; - } - }); + return tap($this->clone(), static fn (self $clone) => $clone->resetProperties($properties)); + } + + /** + * Clear query properties while retaining defaults required by non-nullable types. + */ + protected function resetProperties(array $properties): void + { + foreach ($properties as $property) { + $reflection = new ReflectionProperty($this, $property); + $this->{$property} = ($reflection->getType()?->allowsNull() ?? true) + ? null + : $reflection->getDefaultValue(); + } } /** diff --git a/src/database/src/Query/Grammars/Grammar.php b/src/database/src/Query/Grammars/Grammar.php index bcbcca3fbf..8de91e5f9a 100755 --- a/src/database/src/Query/Grammars/Grammar.php +++ b/src/database/src/Query/Grammars/Grammar.php @@ -67,6 +67,23 @@ public function compileSelect(Builder $query): string ); } + /** + * Arrange binding groups in the order used by a select statement. + */ + public function prepareBindingsForSelect(Builder $query): array + { + $bindings = $query->getRawBindings(); + + if (isset($query->groupLimit)) { + // Window ordering is emitted in the projection, before WHERE bindings. + // Keep the logical groups intact for callers that copy individual clauses. + $bindings['select'] = array_merge($bindings['select'], $bindings['order']); + $bindings['order'] = []; + } + + return $bindings; + } + /** * Compile a select query without statement-level decoration. * @@ -84,6 +101,10 @@ protected function compileSelectQuery(Builder $query): string // different syntax. This primarily supports limits on eager loads using // Eloquent. We'll also set the columns if they have not been defined. if (isset($query->groupLimit)) { + // Group-limit compilers adjust columns, offset and ordering. Compiling + // SQL must not consume those values on a reusable or embedded builder. + $query = clone $query; + if (is_null($query->columns)) { $query->columns = ['*']; } @@ -919,11 +940,6 @@ protected function compileLimit(Builder $query, int $limit): string */ protected function compileGroupLimit(Builder $query): string { - $selectBindings = array_merge($query->getRawBindings()['select'], $query->getRawBindings()['order']); - - $query->setBindings($selectBindings, 'select'); - $query->setBindings([], 'order'); - $limit = (int) $query->groupLimit['value']; $offset = $query->offset; diff --git a/src/database/src/Query/Grammars/MySqlGrammar.php b/src/database/src/Query/Grammars/MySqlGrammar.php index a58f1de33f..5a7ac94112 100755 --- a/src/database/src/Query/Grammars/MySqlGrammar.php +++ b/src/database/src/Query/Grammars/MySqlGrammar.php @@ -163,6 +163,17 @@ protected function compileGroupLimit(Builder $query): string : parent::compileGroupLimit($query); } + /** + * Arrange select bindings while retaining legacy group-limit ordering. + */ + public function prepareBindingsForSelect(Builder $query): array + { + // The legacy form keeps ORDER BY after WHERE instead of in a window projection. + return isset($query->groupLimit) && $this->useLegacyGroupLimit($query) + ? $query->getRawBindings() + : parent::prepareBindingsForSelect($query); + } + /** * Determine whether to use a legacy group limit clause for MySQL < 8.0. */ @@ -312,6 +323,25 @@ protected function compileOffset(Builder $query, int $offset): string . parent::compileOffset($query, $offset); } + /** + * Compile nested union members using the syntax supported by the server. + */ + protected function compileUnion(array $union): string + { + $query = $union['query']; + + if (! $query->unions || $this->connection->isMaria() + || version_compare($this->connection->getServerVersion(), '8.0.31', '>=')) { + return parent::compileUnion($union); + } + + // Older MySQL servers cannot parse a union as a union member. Supported + // MariaDB and newer MySQL servers retain their native nesting and plans. + return ($union['all'] ? ' union all ' : ' union ') + . '(select * from (' . $query->getGrammar()->compileSelectQuery($query) + . ') as ' . $this->wrap('union_table') . ')'; + } + /** * Compile a union offset independently of the first select's limit. */ diff --git a/tests/Database/DatabaseMySqlQueryGrammarTest.php b/tests/Database/DatabaseMySqlQueryGrammarTest.php index 787ef7d232..ab86a3753f 100755 --- a/tests/Database/DatabaseMySqlQueryGrammarTest.php +++ b/tests/Database/DatabaseMySqlQueryGrammarTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Database; use Hypervel\Database\Connection; +use Hypervel\Database\MariaDbConnection; use Hypervel\Database\MySqlConnection; use Hypervel\Database\PdoConnection; use Hypervel\Database\Query\Builder; @@ -151,6 +152,54 @@ public function testTimeoutDecoratesExistsAndLockingStatementsAtTheRoot(): void ); } + #[DataProvider('nestedUnionVersions')] + public function testNestedUnionMembersUseTheSupportedServerSyntax(string $connectionClass, string $version, bool $wrapped): void + { + $pdo = m::mock(PDO::class); + $pdo->shouldReceive('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->andReturn($version); + $connection = new $connectionClass($pdo, 'database'); + $member = $connection->table('posts')->select('id')->where('id', '<=', 3) + ->unionAll($connection->table('posts')->select('id')->whereBetween('id', [4, 6])) + ->orderByDesc('id')->offset(1)->limit(3); + $query = $connection->table('posts')->select('id')->where('id', 7) + ->unionAll($member)->orderByDesc('id')->limit(2); + $memberSql = '(select `id` from `posts` where `id` <= ?) union all (select `id` from `posts` where `id` between ? and ?) order by `id` desc limit 3 offset 1'; + + $this->assertSame( + '(select `id` from `posts` where `id` = ?) union all (' + . ($wrapped ? 'select * from (' . $memberSql . ') as `union_table`' : $memberSql) + . ') order by `id` desc limit 2', + $query->toSql() + ); + $this->assertSame([7, 3, 4, 6], $query->getBindings()); + $this->assertSame(3, $member->unionLimit); + $this->assertSame(1, $member->unionOffset); + } + + /** + * Cover native nesting and older syntax through both MySQL-compatible drivers. + */ + public static function nestedUnionVersions(): iterable + { + yield 'MySQL 5.7' => [MySqlConnection::class, '5.7.44', true]; + yield 'MySQL 8.0.30' => [MySqlConnection::class, '8.0.30', true]; + yield 'MySQL 8.0.31' => [MySqlConnection::class, '8.0.31', false]; + yield 'MariaDB 10.11 through mysql' => [MySqlConnection::class, '10.11.19-MariaDB', false]; + yield 'MariaDB 10.11' => [MariaDbConnection::class, '10.11.19-MariaDB', false]; + } + + public function testOrdinaryUnionMembersDoNotInspectTheServerVersion(): void + { + $connection = m::mock(MySqlConnection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldNotReceive('getServerVersion'); + $connection->shouldNotReceive('isMaria'); + $query = new Builder($connection, new MySqlGrammar($connection), new Processor); + $query->from('posts')->select('id')->unionAll($query->newQuery()->from('archives')->select('id')); + + $this->assertSame('(select `id` from `posts`) union all (select `id` from `archives`)', $query->toSql()); + } + public function testOuterTimeoutLeavesAnUntimedSubqueryUndecorated(): void { $subquery = $this->getBuilder()->select('user_id')->from('memberships')->where('active', true); diff --git a/tests/Database/DatabaseQueryBuilderEmbeddingTest.php b/tests/Database/DatabaseQueryBuilderEmbeddingTest.php index c346e2355f..f7d4ad83b4 100644 --- a/tests/Database/DatabaseQueryBuilderEmbeddingTest.php +++ b/tests/Database/DatabaseQueryBuilderEmbeddingTest.php @@ -45,6 +45,11 @@ public function testEmbeddingValidationRunsOnlyWhenTheChildIsAttached(Closure $a { $outer = $this->builder()->from('users'); $child = $this->builder()->select('id')->from('memberships')->where('enabled', true); + $callbackCalls = 0; + $child->beforeQuery(static function (Builder $query) use (&$callbackCalls): void { + ++$callbackCalls; + $query->where('team_id', 2); + }); $attach($outer, $child); $sql = $outer->toSql(); $bindings = $outer->getBindings(); @@ -56,6 +61,22 @@ public function testEmbeddingValidationRunsOnlyWhenTheChildIsAttached(Closure $a $this->assertSame($bindings, $outer->getBindings()); $this->assertSame([$child], $outer->validatedQueries); $this->assertSame(5, $child->timeout); + $this->assertSame([true, 2], $bindings); + $this->assertStringContainsString('"enabled" = ? and "team_id" = ?', $sql); + $this->assertSame(1, $callbackCalls); + } + + #[DataProvider('attachmentPaths')] + public function testEmbeddingRejectsStatementOptionsAddedByCallbacks(Closure $attach): void + { + $outer = $this->builder()->from('users'); + $child = $this->builder()->select('id')->from('memberships'); + $child->beforeQuery(static fn (Builder $query): Builder => $query->timeout(5)); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('An embedded query cannot define its own timeout.'); + + $attach($outer, $child); } /** @@ -94,14 +115,16 @@ class EmbeddingAwareQueryBuilder extends Builder /** * Extend embedding validation while retaining the framework timeout rule. */ - protected function ensureCanEmbedQuery(Builder $query): void + protected function prepareQueryForEmbedding(Builder $query): Builder { - parent::ensureCanEmbedQuery($query); + $query = parent::prepareQueryForEmbedding($query); $this->validatedQueries[] = $query; if ($this->embeddingFailure !== null) { throw $this->embeddingFailure; } + + return $query; } } diff --git a/tests/Database/DatabaseQueryBuilderPaginationTest.php b/tests/Database/DatabaseQueryBuilderPaginationTest.php index cab6c59c89..37dcaa4596 100644 --- a/tests/Database/DatabaseQueryBuilderPaginationTest.php +++ b/tests/Database/DatabaseQueryBuilderPaginationTest.php @@ -6,20 +6,201 @@ use Hypervel\Database\BinaryParameter; use Hypervel\Database\Connection; +use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\Eloquent\Builder as EloquentBuilder; +use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Query\Builder; use Hypervel\Database\Query\Grammars\Grammar; use Hypervel\Database\Query\Grammars\MySqlGrammar; use Hypervel\Database\Query\Processors\Processor; use Hypervel\Database\SQLiteConnection; +use Hypervel\Pagination\Cursor; use Hypervel\Support\Collection; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use Mockery as m; use PDO; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; class DatabaseQueryBuilderPaginationTest extends TestCase { + #[DataProvider('keysetIterators')] + public function testKeysetIterationVisitsEveryUnionRowOnce(string $method, bool $lazy, bool $descending): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table posts (id integer)'); + $connection->unprepared('create table archives (archive_id integer)'); + $connection->unprepared('insert into posts values (1), (2), (3)'); + $connection->unprepared('insert into archives values (4), (5), (6)'); + $member = $connection->table('archives')->select('archive_id as archived_value'); + $query = $connection->table('posts')->select('id as value')->unionAll($member); + $actual = []; + + if ($lazy) { + $actual = $query->{$method}(2, 'id', 'value')->take(8)->pluck('value')->all(); + } else { + $this->assertTrue($query->{$method}(2, function (Collection $rows) use (&$actual): bool { + array_push($actual, ...$rows->pluck('value')->all()); + + return count($actual) < 8; + }, 'id', 'value')); + } + + $this->assertSame($descending ? [6, 5, 4, 3, 2, 1] : [1, 2, 3, 4, 5, 6], $actual); + $this->assertSame([], $member->wheres); + $this->assertSame([], $query->getBindings()); + } + + /** + * Exercise both shared iteration paths and traversal directions. + */ + public static function keysetIterators(): iterable + { + yield 'chunks ascending' => ['chunkById', false, false]; + yield 'chunks descending' => ['chunkByIdDesc', false, true]; + yield 'lazy ascending' => ['lazyById', true, false]; + yield 'lazy descending' => ['lazyByIdDesc', true, true]; + } + + public function testDirectKeysetPagesPreserveScopesLimitsAndCurrentBindings(): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table posts (id integer)'); + $connection->unprepared('create table archives (id integer)'); + $connection->unprepared('insert into posts values (1), (2), (3)'); + $connection->unprepared('insert into archives values (4), (5), (6)'); + $model = new class extends Model { + protected ?string $table = 'posts'; + }; + $model::setConnectionResolver(m::mock(ConnectionResolverInterface::class, ['connection' => $connection])); + $query = (new EloquentBuilder($connection->query()))->setModel($model) + ->withGlobalScope('visible', static fn (EloquentBuilder $query): EloquentBuilder => $query->where('posts.id', '<', 3)) + ->select('id')->orderByDesc('id')->limit(2) + ->unionAll($connection->table('archives')->select('id'))->orderBy('id'); + + $scoped = (clone $query)->forPageAfterId(2, 1)->limit(1); + $this->assertSame([2], $scoped->get()->modelKeys()); + $unscoped = (clone $query)->forPageAfterId(2, 2)->withoutGlobalScope('visible')->limit(1); + $this->assertSame([3], $unscoped->get()->modelKeys()); + $this->assertSame(2, (clone $query)->withoutGlobalScopes()->forPageAfterId(2, 2)->count()); + $this->assertSame(4, (clone $query)->withoutGlobalScopes()->forPageAfterId(10, 2)->count()); + $this->assertSame(4, (clone $query)->withoutGlobalScopes()->forPageAfterId(2, 2) + ->getQuery()->groupBy('id')->getCountForPagination()); + + $base = (clone $query)->withoutGlobalScopes()->getQuery(); + $this->assertSame($base, $base->forPageAfterId(3, 1)); + $this->assertSame($base, $base->forPageBeforeId(3, 5)); + $base->setBindings([2, 5], 'where'); + $this->assertSame([4, 3], $base->get()->pluck('id')->all()); + $sql = $base->toSql(); + $bindings = $base->getBindings(); + $this->assertSame($sql, $base->toSql()); + $this->assertSame($bindings, $base->getBindings()); + $base->forPageAfterId(2, 3); + $this->assertSame([4], $base->get()->pluck('id')->all()); + $this->assertSame([2, 5, 3, 2, 5, 3], $base->getBindings()); + $this->assertSame([], $base->getRawBindings()['union']); + + foreach ([false, true] as $readFirst) { + $reused = $connection->table('posts')->forPageAfterId(1, 2); + if ($readFirst) { + $this->assertSame([3], $reused->get()->pluck('id')->all()); + } + $reused->unionAll($connection->table('posts')->where('id', 1))->forPageAfterId(2, 0); + $this->assertSame([1, 3], $reused->get()->pluck('id')->all()); + } + } + + #[TestWith(['wildcard'])] + #[TestWith(['raw root'])] + #[TestWith(['raw member'])] + public function testUnprovenUnionProjectionsRequireExplicitWrapping(string $shape): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table posts (id integer, title text)'); + $connection->unprepared('create table archives (archive_id integer, title text)'); + $connection->unprepared("insert into posts values (1, 'first'), (2, 'second')"); + $connection->unprepared("insert into archives values (3, 'third')"); + $query = $connection->table('posts'); + $member = $connection->table('archives'); + if ($shape === 'raw root') { + $query->selectRaw('id, title'); + $member->select('archive_id', 'title'); + } elseif ($shape === 'raw member') { + $query->select('id', 'title'); + $member->selectRaw('archive_id, title'); + } + $query->unionAll($member); + $page = (clone $query)->forPageAfterId(2, 1); + try { + $page->get(); + $this->fail('The unknown projection width must not be guessed.'); + } catch (InvalidArgumentException $exception) { + $this->assertStringContainsString('fromSub()', $exception->getMessage()); + } + + $this->assertSame([2, 3], $connection->query()->fromSub($query, 'entries')->forPageAfterId(2, 1)->pluck('id')->all()); + } + + public function testPreparedPagesKeepBindingsAndEmbeddingConsistent(): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table posts (id integer)'); + $connection->unprepared('create table archives (id integer)'); + $connection->unprepared('insert into posts values (1), (2), (3)'); + $connection->unprepared('insert into archives values (4), (5), (6)'); + $page = $connection->table('posts')->select('id')->orderByDesc('id')->limit(2) + ->unionAll($connection->table('archives')->select('id')->where('id', '>', 4)) + ->forPageAfterId(3, 1); + $rawBindings = $page->getRawBindings(); + + $this->assertSame([2, 3, 5], $page->get()->pluck('id')->all()); + $this->assertSame([2, 3, 5], $page->cursor()->pluck('id')->all()); + $this->assertTrue($page->exists()); + $this->assertSame(4, $page->getCountForPagination()); + $this->assertSame(3, $connection->query()->fromSub($page, 'entries')->count()); + $this->assertSame(3, $connection->table('posts')->whereExists($page)->count()); + $this->assertSame([2, 3], $connection->table('posts')->whereIn('id', $page)->orderBy('id')->pluck('id')->all()); + $this->assertSame([2], $connection->table('posts')->where('id', '=', (clone $page)->limit(1))->pluck('id')->all()); + + $embedded = clone $page; + $callbackCalls = 0; + $embedded->beforeQuery(function (Builder $query) use (&$callbackCalls): void { + ++$callbackCalls; + $query->where('id', '>', 2); + }); + $merged = $connection->query()->mergeBindings($embedded)->fromRaw('(' . $embedded->toSql() . ') as entries'); + $this->assertSame([3, 5, 6], $merged->get()->pluck('id')->all()); + $this->assertSame(1, $callbackCalls); + $this->assertSame($rawBindings, $page->getRawBindings()); + $this->assertSame('posts', $page->from); + $this->assertSame(2, $page->limit); + $this->assertSame(3, $page->unionLimit); + } + + public function testCursorComparisonsFilterGroupedExpressionResults(): 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')->select('tenant_id')->selectRaw('sum(amount) as total') + ->groupBy('tenant_id')->havingRaw('sum(amount) >= ?', [5]) + ->orderBy('total')->orderBy('tenant_id') + ->beforeQuery(static fn (Builder $query) => $query->orderByRaw('tenant_id + ?', [100])); + + $first = (clone $query)->cursorPaginate(1); + $second = (clone $query)->cursorPaginate(1, cursor: $first->nextCursor()); + $third = (clone $query)->cursorPaginate(1, cursor: $second->nextCursor()); + + $this->assertSame([1], $first->pluck('tenant_id')->all()); + $this->assertSame([2], $second->pluck('tenant_id')->all()); + $this->assertSame([3], $third->pluck('tenant_id')->all()); + $this->assertSame([1], (clone $query)->cursorPaginate(1, cursor: $second->previousCursor())->pluck('tenant_id')->all()); + $this->assertSame([], (clone $query)->cursorPaginate(1, cursor: new Cursor(['total' => 7, 'tenant_id' => 3]))->items()); + } + public function testBinaryStringsCanBePagedUsingAnExplicitCursorAlias(): void { $connection = new SQLiteConnection(new PDO('sqlite::memory:')); @@ -126,6 +307,31 @@ public function testPlainCountRemovesCallbackSuppliedProjectionOrderingAndPagina $this->assertCount(1, $query->beforeQueryCallbacks); } + #[TestWith([false])] + #[TestWith([true])] + public function testAggregatePreparesCallbacksBeforeSelectingItsBindings(bool $grouped): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table events (tenant_id integer)'); + $connection->unprepared('insert into events values (1), (2), (2)'); + $connection->enableQueryLog(); + $query = $connection->table('events')->where('tenant_id', '>', 0) + ->beforeQuery(function (Builder $query) use ($grouped): void { + $query->selectRaw('tenant_id + ? as bucket', [10])->orderByRaw('tenant_id + ?', [20]); + + if ($grouped) { + $query->groupBy('tenant_id')->havingRaw('count(*) > ?', [0]); + } + }); + + $this->assertSame($grouped ? 2 : 3, $query->count()); + $this->assertSame($grouped ? [10, 0, 0, 20] : [0], $connection->getQueryLog()[0]['bindings']); + $this->assertNull($query->columns); + $this->assertNull($query->orders); + $this->assertCount(1, $query->beforeQueryCallbacks); + $this->assertSame($grouped ? [11, 12] : [11, 12, 12], $query->get()->pluck('bucket')->all()); + } + public function testGroupedCountTransfersCallbackTimeoutAndRoutingToTheOuterStatement(): void { $connection = m::mock(Connection::class); diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index a3f9717f7a..34be2523c8 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -3222,6 +3222,32 @@ public function testForPageAfterId(): void $this->assertSame(['019abc'], $builder->getBindings()); } + public function testIdPagingRemovesOnlyTheReplacedOrdersBindings(): void + { + $builder = $this->getBuilder()->from('users') + ->orderByRaw('score + ?', [9]) + ->inOrderOf('id', [5, 2]) + ->inOrderOf('status', ['active', 'pending']) + ->orderBy($this->getBuilder()->select('rank')->from('ranks')->where('team', 7)->limit(1)) + ->orderByRaw('age + ?', [8]); + + $builder->forPageAfterId(15, 1); + + $this->assertSame( + 'select * from "users" where "id" > ? order by score + ?, case when "status" = ? then 0 when "status" = ? then 1 else 2 end, (select "rank" from "ranks" where "team" = ? limit 1) asc, age + ?, "id" asc limit 15', + $builder->toSql() + ); + $this->assertSame([1, 9, 'active', 'pending', 7, 8], $builder->getBindings()); + + $builder->setBindings([11, 'new', 'old', 17, 18], 'order')->forPageBeforeId(15, 'z', 'status'); + + $this->assertSame( + 'select * from "users" where "id" > ? and "status" < ? order by score + ?, (select "rank" from "ranks" where "team" = ? limit 1) asc, age + ?, "id" asc, "status" desc limit 15', + $builder->toSql() + ); + $this->assertSame([1, 'z', 11, 17, 18], $builder->getBindings()); + } + public function testGetCountForPaginationWithBindings(): void { $builder = $this->getBuilder(); @@ -3296,6 +3322,35 @@ public function testGetCountForPaginationWithUnionLimitAndOffset(): void $this->assertEquals(1, $count); } + public function testGetCountForPaginationWithGroupedUnionLimitAndOffset(): void + { + $builder = $this->getBuilder(); + $builder->from('posts')->select('category')->groupBy('category')->unionAll($this->getBuilder()->from('videos')->select('category'))->limit(15)->offset(1); + + $builder->getConnection()->expects('select')->with('select count(*) as "aggregate" from ((select "category" from "posts" group by "category") union all (select "category" from "videos")) as "aggregate_table"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getProcessor()->expects('processSelect')->andReturnUsing(function ($builder, $results) { + return $results; + }); + + $count = $builder->getCountForPagination(); + $this->assertEquals(1, $count); + } + + public function testGetCountForPaginationWithGroupedUnionOrders(): void + { + $builder = $this->getBuilder(); + $builder->from('posts')->select('category')->groupBy('category')->unionAll($this->getBuilder()->from('videos')->select('category'))->orderBy('category')->orderByRaw('field(category, ?)', ['news']); + + $builder->getConnection()->expects('select')->with('select count(*) as "aggregate" from ((select "category" from "posts" group by "category") union all (select "category" from "videos")) as "aggregate_table"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getProcessor()->expects('processSelect')->andReturnUsing(function ($builder, $results) { + return $results; + }); + + $count = $builder->getCountForPagination(); + $this->assertEquals(1, $count); + $this->assertSame(['news'], $builder->getRawBindings()['unionOrder']); + } + public function testGroupLimitCompilationWithAndWithoutOffset(): void { $builder = $this->getSQLiteBuilder() @@ -3319,11 +3374,97 @@ public function testGroupLimitCompilationWithAndWithoutOffset(): void ->offset(3) ->groupLimit(2, 'user_id'); + $sql = $offsetBuilder->toSql(); $this->assertSame( 'select * from (select "id", row_number() over (partition by "user_id" order by "created_at" desc) as "hypervel_row" from "posts" where "active" = ?) as "hypervel_table" where "hypervel_row" <= 5 and "hypervel_row" > 3 order by "hypervel_row"', - $offsetBuilder->toSql() + $sql ); + $this->assertSame($sql, $offsetBuilder->toSql()); + $this->assertSame(3, $offsetBuilder->offset); + $this->assertSame(['id'], $offsetBuilder->columns); $this->assertSame([true], $offsetBuilder->getBindings()); + + $implicitColumns = $this->getSQLiteBuilder()->from('posts')->groupLimit(1, 'user_id'); + $implicitColumns->toSql(); + $this->assertNull($implicitColumns->columns); + $implicitColumns->getConnection()->expects('select')->with( + 'select * from (select "id", row_number() over (partition by "user_id") as "hypervel_row" from "posts") as "hypervel_table" where "hypervel_row" <= 1 order by "hypervel_row"', + [], + true, + [] + )->andReturn([]); + $implicitColumns->getProcessor()->expects('processSelect')->andReturn([]); + $implicitColumns->get(['id']); + } + + #[DataProvider('groupLimitEmbeddingCases')] + public function testGroupLimitBindingsStayInSqlOrderWhenEmbedded(string $driver, string $attachment): void + { + $inner = $driver === 'sqlite' ? $this->getSQLiteBuilder() : $this->getMySqlBuilder(); + $legacy = $driver === 'mysql-legacy'; + if ($driver !== 'sqlite') { + $inner->getConnection()->shouldReceive('getServerVersion')->andReturn($legacy ? '5.7.44' : '8.4.0'); + $inner->getConnection()->shouldReceive('isMaria')->andReturn(false); + } + + $inner->select('id')->from('posts')->where('enabled', true) + ->orderByRaw('case when status = ? then 0 else 1 end', ['priority']) + ->offset(1)->groupLimit(2, 'user_id'); + $rawBindings = $inner->getRawBindings(); + $orders = $inner->orders; + $innerBindings = $legacy ? [true, 'priority'] : ['priority', true]; + $outer = $inner->newQuery()->select('id')->from('users')->where('id', '>', 2); + $expectedBindings = [2, ...$innerBindings]; + + switch ($attachment) { + case 'exists': + $outer->whereExists($inner); + break; + case 'scalar': + // A group-limit SELECT includes its row number. A scalar subquery + // explicitly selects one column and one row from that result. + $scalar = $inner->newQuery()->fromSub($inner, 'ranked')->select('id')->limit(1); + $outer->where('id', '=', $scalar); + break; + case 'union': + $outer->selectRaw('0 as hypervel_row')->union($inner); + break; + case 'from': + $outer->fromSub($inner, 'ranked'); + $expectedBindings = [...$innerBindings, 2]; + break; + case 'merge': + $outer = $inner->newQuery()->mergeBindings($inner); + $outer->fromRaw('(' . $inner->toSql() . ') as ranked'); + $expectedBindings = $innerBindings; + break; + } + + $this->assertSame($expectedBindings, $outer->getBindings()); + $sql = $outer->toSql(); + $this->assertSame($sql, $outer->toSql()); + $this->assertSame($expectedBindings, $outer->getBindings()); + $this->assertSame($rawBindings, $inner->getRawBindings()); + $this->assertSame($orders, $inner->orders); + $this->assertSame(1, $inner->offset); + + if ($legacy) { + $this->assertStringContainsString('where `enabled` = ? order by `user_id` asc, case when status = ? then 0 else 1 end', $sql); + } else { + $this->assertStringContainsString('row_number() over (partition by ' . $inner->getGrammar()->wrap('user_id') . ' order by case when status = ? then 0 else 1 end)', $sql); + } + } + + /** + * Exercise attachment boundaries with window and legacy group-limit ordering. + */ + public static function groupLimitEmbeddingCases(): iterable + { + foreach (['sqlite', 'mysql', 'mysql-legacy'] as $driver) { + foreach (['exists', 'scalar', 'union', 'from', 'merge'] as $attachment) { + yield $driver . '-' . $attachment => [$driver, $attachment]; + } + } } public function testGroupedPaginationAppliesTimeoutToTheOuterCountStatement(): void @@ -6822,7 +6963,7 @@ public function testCustomBindingSlotsSurviveFactoriesAndPaginationCloning(): vo $clone = (new ReflectionMethod($builder, 'cloneForPaginationCount'))->invoke($builder); $this->assertInstanceOf(DatabaseQueryBuilderWithCustomBindings::class, $clone); $this->assertSame(['tenant', true], $clone->getBindings()); - $this->assertSame([], $clone->orders); + $this->assertNull($clone->orders); $this->assertNull($clone->limit); $this->assertNull($clone->offset); @@ -7525,6 +7666,34 @@ public function testCursorPaginateWrapsOrderColumnsContainingParentheses(): void $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); } + public function testKeysetIsolationDiscardsUnneededInnerOrdering(): void + { + $connection = $this->getConnection(); + $builder = new class($connection, new Grammar($connection), new Processor) extends Builder { + /** + * Require paging outside a custom SELECT clause. + */ + protected function requiresKeysetIsolation(bool $limitSelectsRows): bool + { + return true; + } + }; + $builder->from('events')->orderBy('id')->orderByRaw('id + ?', [7])->orderBy('sequence') + ->forPageBeforeId(2, 5)->setBindings([8], 'order'); + + $this->assertSame( + 'select * from (select * from "events") as "cursor_table" where "id" < ? order by "id" desc limit 2', + $builder->toSql() + ); + $this->assertSame([5], $builder->getBindings()); + $builder->forPageBeforeId(2, 10, 'sequence'); + $this->assertSame( + 'select * from (select * from "events") as "cursor_table" where "id" < ? and "sequence" < ? order by "id" desc, "sequence" desc limit 2', + $builder->toSql() + ); + $this->assertSame([5, 10], $builder->getBindings()); + } + public function testCursorPaginateWithDefaultArguments(): void { $perPage = 15; @@ -7807,7 +7976,8 @@ public function testCursorPaginateWithUnionWheres(): void $builder->toSql() ); $this->assertEquals([$ts], $builder->bindings['where']); - $this->assertEquals([$ts], $builder->bindings['union']); + $this->assertEquals([$ts, $ts], $builder->getBindings()); + $this->assertSame([], $builder->bindings['union']); return $results; }); @@ -7825,6 +7995,63 @@ public function testCursorPaginateWithUnionWheres(): void ]), $result); } + public function testCursorPaginateFiltersNestedUnionsAndRebuildsTheirBindings(): void + { + $builder = $this->getMockQueryBuilder(['get']); + $builder->select('id')->from('videos')->where('active', 1) + ->unionAll($this->getBuilder()->select('id')->from('news')->where('active', 2) + ->unionAll($this->getBuilder()->select('id')->from('archives')->where('active', 3))) + ->orderBy('id'); + $builder->expects('get')->andReturnUsing(function () use ($builder): Collection { + $this->assertSame( + '(select "id" from "videos" where "active" = ? and ("id" > ?)) union all ((select "id" from "news" where "active" = ? and ("id" > ?)) union all (select "id" from "archives" where "active" = ? and ("id" > ?))) order by "id" asc limit 11', + $builder->toSql() + ); + $this->assertSame([1, 2, 2, 2, 3, 2], $builder->getBindings()); + + return new Collection; + }); + $builder->cursorPaginate(10, cursor: new Cursor(['id' => 2])); + } + + public function testCursorPaginateWithUnionSelectAndJoinBindings(): void + { + $timestamp = now()->toDateTimeString(); + $perPage = 16; + $columns = ['test']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['created_at' => $timestamp]); + $builder = $this->getMockQueryBuilder(['get']); + $builder->select('id', 'created_at')->selectRaw('? as type', ['video'])->from('videos'); + $builder->union($this->getBuilder()->select('id', 'created_at')->selectRaw('? as type', ['news'])->from('news')->join('authors', function (JoinClause $join): void { + $join->on('authors.id', '=', 'news.author_id')->where('authors.active', true); + })); + $builder->orderBy('created_at'); + $path = 'http://foo.bar?cursor=' . $cursor->encode(); + $results = collect([ + ['id' => 1, 'created_at' => now(), 'type' => 'video'], + ['id' => 2, 'created_at' => now(), 'type' => 'news'], + ]); + $builder->expects('get')->andReturnUsing(function () use ($builder, $results, $timestamp): Collection { + $this->assertSame( + '(select "id", "created_at", ? as type from "videos" where ("created_at" > ?)) union (select "id", "created_at", ? as type from "news" inner join "authors" on "authors"."id" = "news"."author_id" and "authors"."active" = ? where ("created_at" > ?)) order by "created_at" asc limit 17', + $builder->toSql() + ); + $this->assertEquals(['video', $timestamp, 'news', true, $timestamp], $builder->getBindings()); + + return $results; + }); + Paginator::currentPathResolver(function () use ($path): string { + return $path; + }); + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['created_at'], + ]), $result); + } + public function testCursorPaginateWithMultipleUnionsAndMultipleWheres(): void { $ts = now()->toDateTimeString(); @@ -7853,7 +8080,7 @@ public function testCursorPaginateWithMultipleUnionsAndMultipleWheres(): void $builder->toSql() ); $this->assertEquals([$ts], $builder->bindings['where']); - $this->assertEquals(['first', $ts, 'second', $ts], $builder->bindings['union']); + $this->assertEquals([$ts, 'first', $ts, 'second', $ts], $builder->getBindings()); return $results; }); @@ -7896,11 +8123,11 @@ public function testCursorPaginateWithUnionMultipleWheresMultipleOrders(): void $builder->expects('get')->andReturnUsing(function () use ($builder, $results, $ts) { $this->assertSame( - '(select "id", "start_time" as "created_at", "type" from "videos" where "extra" = ? and ("id" > ? or ("id" = ? and ("start_time" < ? or ("start_time" = ? and ("type" > ?)))))) union (select "id", "created_at", "type" from "news" where "extra" = ? and ("id" > ? or ("id" = ? and ("start_time" < ? or ("start_time" = ? and ("type" > ?)))))) union (select "id", "created_at", "type" from "podcasts" where "extra" = ? and ("id" > ? or ("id" = ? and ("start_time" < ? or ("start_time" = ? and ("type" > ?)))))) order by "id" asc, "created_at" desc, "type" asc limit 17', + '(select "id", "start_time" as "created_at", "type" from "videos" where "extra" = ? and ("id" > ? or ("id" = ? and ("start_time" < ? or ("start_time" = ? and ("type" > ?)))))) union (select "id", "created_at", "type" from "news" where "extra" = ? and ("id" > ? or ("id" = ? and ("created_at" < ? or ("created_at" = ? and ("type" > ?)))))) union (select "id", "created_at", "type" from "podcasts" where "extra" = ? and ("id" > ? or ("id" = ? and ("created_at" < ? or ("created_at" = ? and ("type" > ?)))))) order by "id" asc, "created_at" desc, "type" asc limit 17', $builder->toSql() ); $this->assertEquals(['first', 1, 1, $ts, $ts, 'news'], $builder->bindings['where']); - $this->assertEquals(['second', 1, 1, $ts, $ts, 'news', 'third', 1, 1, $ts, $ts, 'news'], $builder->bindings['union']); + $this->assertEquals(['first', 1, 1, $ts, $ts, 'news', 'second', 1, 1, $ts, $ts, 'news', 'third', 1, 1, $ts, $ts, 'news'], $builder->getBindings()); return $results; }); @@ -7944,7 +8171,7 @@ public function testCursorPaginateWithUnionWheresWithRawOrderExpression(): void $builder->toSql() ); $this->assertEquals([true, $ts], $builder->bindings['where']); - $this->assertEquals([true, $ts], $builder->bindings['union']); + $this->assertEquals([true, $ts, true, $ts], $builder->getBindings()); return $results; }); @@ -7988,7 +8215,7 @@ public function testCursorPaginateWithUnionWheresReverseOrder(): void $builder->toSql() ); $this->assertEquals([$ts], $builder->bindings['where']); - $this->assertEquals([$ts], $builder->bindings['union']); + $this->assertEquals([$ts, $ts], $builder->getBindings()); return $results; }); @@ -8032,7 +8259,7 @@ public function testCursorPaginateWithUnionWheresMultipleOrders(): void $builder->toSql() ); $this->assertEquals([$ts, $ts, 1], $builder->bindings['where']); - $this->assertEquals([$ts, $ts, 1], $builder->bindings['union']); + $this->assertEquals([$ts, $ts, 1, $ts, $ts, 1], $builder->getBindings()); return $results; }); @@ -8078,7 +8305,7 @@ public function testCursorPaginateWithUnionWheresAndAliassedOrderColumns(): void $builder->toSql() ); $this->assertEquals([$ts], $builder->bindings['where']); - $this->assertEquals([$ts, $ts], $builder->bindings['union']); + $this->assertEquals([$ts, $ts, $ts], $builder->getBindings()); return $results; }); @@ -8666,6 +8893,24 @@ public function testCloneWithout(): void $this->assertSame('select * from "users" where "email" = ? order by "email" asc', $builder->toSql()); $this->assertSame('select * from "users" where "email" = ?', $clone->toSql()); + + $builder->select('id')->distinct()->useWritePdo(); + $clone = $builder->cloneWithout(['columns', 'distinct', 'useWritePdo']); + + $this->assertSame('select * from "users" where "email" = ? order by "email" asc', $clone->toSql()); + $this->assertNull($clone->columns); + $this->assertFalse($clone->distinct); + $this->assertFalse($clone->useWritePdo); + $this->assertSame('select distinct "id" from "users" where "email" = ? order by "email" asc', $builder->toSql()); + $this->assertTrue($builder->useWritePdo); + + $connection = $this->getConnection(); + $builder = new class($connection, new Grammar($connection), new Processor) extends Builder { + public ?int $limit = 10; + }; + + $this->assertNull($builder->cloneWithout(['limit'])->limit); + $this->assertSame(10, $builder->limit); } public function testCloneWithoutBindings(): void diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php index b884195aaf..040bb522dc 100644 --- a/tests/Integration/Database/EloquentCursorPaginateTest.php +++ b/tests/Integration/Database/EloquentCursorPaginateTest.php @@ -5,16 +5,20 @@ namespace Hypervel\Tests\Integration\Database; use Hypervel\Database\BinaryParameter; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Casts\AsBinary; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsToMany; use Hypervel\Database\Eloquent\Relations\HasManyThrough; use Hypervel\Database\Eloquent\Relations\Pivot; +use Hypervel\Database\Query\Builder as QueryBuilder; use Hypervel\Database\Query\Expression; use Hypervel\Database\Schema\Blueprint; use Hypervel\Pagination\Cursor; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; class EloquentCursorPaginateTest extends DatabaseTestCase { @@ -58,6 +62,39 @@ public function testCursorPaginationOnTopOfColumns(): void $this->assertCount(15, TestPost::cursorPaginate(15, ['id', 'title'])); } + #[TestWith([false])] + #[TestWith([true])] + public function testCursorPaginationResolvesAliasesInTheColumnsArgument(bool $eloquent): void + { + TestPost::fillAndInsert([[], [], []]); + $scopeCalls = 0; + $callbackCalls = 0; + $query = $eloquent ? TestPost::query() : DB::table('test_posts'); + + if ($eloquent) { + $query->withGlobalScope('visible', function (Builder $builder) use (&$scopeCalls): void { + ++$scopeCalls; + $this->assertNull($builder->getQuery()->columns); + $builder->where('id', '>', 1); + }); + } + + $query->beforeQuery(function (QueryBuilder $builder) use (&$callbackCalls): void { + ++$callbackCalls; + $this->assertSame(['id as cursor_id'], $builder->columns); + }); + $page = $query->orderBy('cursor_id')->cursorPaginate( + 2, + ['id as cursor_id'], + cursor: new Cursor(['cursor_id' => 2]), + ); + + $this->assertSame([3], $page->pluck('cursor_id')->all()); + $this->assertSame($eloquent ? 1 : 0, $scopeCalls); + $this->assertSame(1, $callbackCalls); + $this->assertNull(($eloquent ? $query->getQuery() : $query)->columns); + } + public function testCursorPaginationWithNumericExpressionProjection(): void { TestPost::fillAndInsert([['title' => 'First'], ['title' => 'Second']]); @@ -88,6 +125,109 @@ public function testPaginationWithUnion(): void $this->assertSame(['user_id'], $result->getOptions()['parameters']); } + #[DataProvider('boundedUnionProvider')] + public function testPaginationPreservesBoundedUnionMembers(string $shape, array $firstIds, array $secondIds): void + { + for ($id = 1; $id <= 7; ++$id) { + TestPost::create(['id' => $id]); + } + + $calls = 0; + $query = function () use ($shape, &$calls): QueryBuilder { + $bounded = DB::table('test_posts')->where('id', '<', 7)->orderByDesc('id')->limit(3); + $ordinary = DB::table('test_posts')->select('id as identifier')->where('id', 7); + $query = match ($shape) { + 'root' => $bounded->unionAll($ordinary), + 'member' => $ordinary->unionAll($bounded->select('id as identifier')), + 'group' => $ordinary->unionAll( + DB::table('test_posts')->select('id as identifier')->where('id', '<=', 3) + ->unionAll(DB::table('test_posts')->select('id as identifier')->whereBetween('id', [4, 6])) + ->orderByDesc('identifier')->offset(1)->limit(3) + ), + }; + + return $query->orderByDesc('identifier')->beforeQuery(function () use (&$calls): void { + ++$calls; + }); + }; + + $first = $query()->cursorPaginate(2, ['id as identifier']); + $this->assertSame($firstIds, $first->pluck('identifier')->all()); + $this->assertSame(1, $calls); + $second = $query()->cursorPaginate(2, ['id as identifier'], cursor: $first->nextCursor()); + $this->assertSame($secondIds, $second->pluck('identifier')->all()); + $this->assertSame(2, $calls); + $previous = $query()->cursorPaginate(2, ['id as identifier'], cursor: $second->previousCursor()); + $this->assertSame($firstIds, $previous->pluck('identifier')->all()); + $this->assertSame(3, $calls); + $after = $query()->cursorPaginate(2, ['id as identifier'], cursor: new Cursor(['identifier' => $secondIds[1]])); + $this->assertSame([], $after->items()); + $this->assertSame(4, $calls); + } + + /** + * Provide bounded primary selects, members and complete union groups. + */ + public static function boundedUnionProvider(): array + { + return [ + ['root', [7, 6], [5, 4]], + ['member', [7, 6], [5, 4]], + ['group', [7, 5], [4, 3]], + ]; + } + + public function testBoundedEloquentUnionKeepsScopesAndEagerLoads(): void + { + TestUser::fillAndInsert([['id' => 1], ['id' => 2], ['id' => 3], ['id' => 4]]); + TestPost::fillAndInsert([['user_id' => 1], ['user_id' => 3], ['user_id' => 4]]); + $scopeCalls = 0; + $query = function () use (&$scopeCalls): Builder { + return TestUser::with('posts') + ->withGlobalScope('visible', function (Builder $query) use (&$scopeCalls): void { + ++$scopeCalls; + $query->where('test_users.id', '>=', 2); + }) + ->orderByDesc('id')->limit(2) + ->unionAll(TestUser::where('id', 1)) + ->orderByDesc('id'); + }; + + $first = $query()->cursorPaginate(2); + $this->assertSame([4, 3], $first->getCollection()->modelKeys()); + $this->assertSame(1, $scopeCalls); + $second = $query()->cursorPaginate(2, cursor: $first->nextCursor()); + $this->assertSame([1], $second->getCollection()->modelKeys()); + $this->assertSame(2, $scopeCalls); + $this->assertTrue($second->items()[0]->relationLoaded('posts')); + $this->assertSame([1], $second->items()[0]->posts->pluck('user_id')->all()); + $previous = $query()->cursorPaginate(2, cursor: $second->previousCursor()); + $this->assertSame([4, 3], $previous->getCollection()->modelKeys()); + $this->assertSame(3, $scopeCalls); + } + + public function testBoundedUnionMemberKeepsItsImplicitProjection(): void + { + TestPost::fillAndInsert([['id' => 1], ['id' => 2], ['id' => 3], ['id' => 4]]); + $query = fn (): QueryBuilder => DB::table('test_posts')->where('id', '<=', 2)->orderByDesc('id')->limit(1) + ->unionAll(DB::table('test_posts', 'archive')->where('id', '>', 2)->orderByDesc('id')->limit(1)) + ->orderByDesc('id'); + + $first = $query()->cursorPaginate(1, ['test_posts.*']); + $this->assertSame([4], $first->pluck('id')->all()); + $second = $query()->cursorPaginate(1, ['test_posts.*'], cursor: $first->nextCursor()); + $this->assertSame([2], $second->pluck('id')->all()); + } + + public function testOrdinaryCursorPaginationStillOwnsItsLimit(): void + { + TestPost::fillAndInsert([['id' => 1], ['id' => 2], ['id' => 3], ['id' => 4]]); + $page = DB::table('test_posts')->orderBy('id')->limit(1) + ->cursorPaginate(3, cursor: new Cursor(['id' => 1])); + + $this->assertSame([2, 3, 4], $page->pluck('id')->all()); + } + public function testPaginationWithDistinct(): void { for ($i = 1; $i <= 3; ++$i) { diff --git a/tests/Integration/Database/QueryBuilderTest.php b/tests/Integration/Database/QueryBuilderTest.php index a7c1be6307..1e6c7e0d50 100644 --- a/tests/Integration/Database/QueryBuilderTest.php +++ b/tests/Integration/Database/QueryBuilderTest.php @@ -658,6 +658,15 @@ public function testPaginateWithSpecificColumns(): void ]); } + public function testGetCountForPaginationWithGroupedUnionAndLimit(): void + { + $query = DB::table('posts')->select('title')->groupBy('title') + ->unionAll(DB::table('posts')->select('title')->where('title', 'Foo Post')) + ->limit(1); + + $this->assertSame(3, $query->getCountForPagination()); + } + public function testChunkMap(): void { DB::enableQueryLog(); From be5b84148658ca40f761ff1c5d62cebfdee62733 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:26:56 +0000 Subject: [PATCH 2/7] Require MariaDB 10.11 and preserve MySQL rename defaults Use MariaDB 10.11 as the minimum supported server and pin the database CI service to that series. Native UUID columns, column renaming and SKIP LOCKED are available throughout the supported range, so remove obsolete server branches and align their capability and schema tests. Client-tool checks remain independent of the server minimum. Keep the legacy MySQL column-renaming path for supported older MySQL. Its metadata exposes string defaults without quotes: treat CURRENT_TIMESTAMP as an expression only for temporal columns, and preserve the literal string NULL. Renaming now retains literal, nullable, numeric and fractional temporal defaults without changing their meaning. Verified schema and capability tests, full static analysis, MariaDB 10.11 integration and the expanded rename regression on MySQL 5.7, PostgreSQL and SQLite. User documentation is updated in the accompanying documentation commit. --- .github/workflows/databases.yml | 6 ++-- src/database/src/MySqlConnection.php | 4 +-- .../src/Schema/Grammars/MariaDbGrammar.php | 15 ---------- .../src/Schema/Grammars/MySqlGrammar.php | 20 ++++++++----- .../DatabaseMariaDbSchemaGrammarTest.php | 25 ++++------------ tests/Database/DatabasePdoConnectionTest.php | 7 ++--- .../Database/DatabaseSchemaBlueprintTest.php | 27 +++++++---------- .../Database/SchemaBuilderTest.php | 30 +++++++++++++++++-- 8 files changed, 62 insertions(+), 72 deletions(-) diff --git a/.github/workflows/databases.yml b/.github/workflows/databases.yml index dc6a0cf8f6..eff51e4f45 100644 --- a/.github/workflows/databases.yml +++ b/.github/workflows/databases.yml @@ -154,13 +154,13 @@ jobs: DB_PASSWORD: password run: bin/run-database-tests.sh mysql - mariadb_10: + mariadb_1011: runs-on: ubuntu-latest timeout-minutes: 10 services: mariadb: - image: mariadb:10 + image: mariadb:10.11 env: MARIADB_ROOT_PASSWORD: password MARIADB_DATABASE: testing @@ -178,7 +178,7 @@ jobs: matrix: php: ["8.4", "8.5"] - name: MariaDB 10 (PHP ${{ matrix.php }}) + name: MariaDB 10.11 (PHP ${{ matrix.php }}) steps: - name: Checkout code diff --git a/src/database/src/MySqlConnection.php b/src/database/src/MySqlConnection.php index a02d958c10..b423778155 100755 --- a/src/database/src/MySqlConnection.php +++ b/src/database/src/MySqlConnection.php @@ -199,9 +199,7 @@ protected function resolveLockForPopping(): bool|string : true; } - $minimumVersion = $isMaria ? '10.6.0' : '8.0.1'; - - return version_compare($version, $minimumVersion, '>=') + return $isMaria || version_compare($version, '8.0.1', '>=') ? 'FOR UPDATE SKIP LOCKED' : true; } diff --git a/src/database/src/Schema/Grammars/MariaDbGrammar.php b/src/database/src/Schema/Grammars/MariaDbGrammar.php index 5878021a35..6f1840168a 100755 --- a/src/database/src/Schema/Grammars/MariaDbGrammar.php +++ b/src/database/src/Schema/Grammars/MariaDbGrammar.php @@ -6,29 +6,14 @@ use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Fluent; -use Override; class MariaDbGrammar extends MySqlGrammar { - #[Override] - public function compileRenameColumn(Blueprint $blueprint, Fluent $command): array|string - { - if (version_compare($this->connection->getServerVersion(), '10.5.2', '<')) { - return $this->compileLegacyRenameColumn($blueprint, $command); - } - - return parent::compileRenameColumn($blueprint, $command); - } - /** * Create the column definition for a uuid type. */ protected function typeUuid(Fluent $column): string { - if (version_compare($this->connection->getServerVersion(), '10.7.0', '<')) { - return 'char(36)'; - } - return 'uuid'; } diff --git a/src/database/src/Schema/Grammars/MySqlGrammar.php b/src/database/src/Schema/Grammars/MySqlGrammar.php index 9cd8148974..a439314323 100755 --- a/src/database/src/Schema/Grammars/MySqlGrammar.php +++ b/src/database/src/Schema/Grammars/MySqlGrammar.php @@ -297,11 +297,9 @@ public function compileAutoIncrementStartingValues(Blueprint $blueprint, Fluent #[Override] public function compileRenameColumn(Blueprint $blueprint, Fluent $command): array|string { - $isMaria = $this->connection->isMaria(); - $version = $this->connection->getServerVersion(); - - if (($isMaria && version_compare($version, '10.5.2', '<')) - || (! $isMaria && version_compare($version, '8.0.3', '<'))) { + // All supported MariaDB versions have native column renaming. + if (! $this->connection->isMaria() + && version_compare($this->connection->getServerVersion(), '8.0.3', '<')) { return $this->compileLegacyRenameColumn($blueprint, $command); } @@ -316,6 +314,14 @@ protected function compileLegacyRenameColumn(Blueprint $blueprint, Fluent $comma $column = (new Collection($this->connection->getSchemaBuilder()->getColumns($blueprint->getTable()))) ->firstWhere('name', $command->from); + // Metadata leaves string defaults unquoted. Only temporal columns can + // use CURRENT_TIMESTAMP as an expression; "NULL" remains a string. + $default = $column['default']; + if (in_array($column['type_name'], ['timestamp', 'datetime'], true) + && $default !== null && str_starts_with(strtolower($default), 'current_timestamp')) { + $default = new Expression($default); + } + $modifiers = $this->addModifiers($column['type'], $blueprint, new ColumnDefinition([ 'change' => true, 'type' => match ($column['type_name']) { @@ -327,9 +333,7 @@ protected function compileLegacyRenameColumn(Blueprint $blueprint, Fluent $comma default => $column['type_name'], }, 'nullable' => $column['nullable'], - 'default' => $column['default'] && (str_starts_with(strtolower($column['default']), 'current_timestamp') || $column['default'] === 'NULL') - ? new Expression($column['default']) - : $column['default'], + 'default' => $default, 'autoIncrement' => $column['auto_increment'], 'collation' => $column['collation'], 'comment' => $column['comment'], diff --git a/tests/Database/DatabaseMariaDbSchemaGrammarTest.php b/tests/Database/DatabaseMariaDbSchemaGrammarTest.php index 009f4549b6..0e26fb5bc0 100755 --- a/tests/Database/DatabaseMariaDbSchemaGrammarTest.php +++ b/tests/Database/DatabaseMariaDbSchemaGrammarTest.php @@ -52,7 +52,6 @@ public function testBasicCreateTable(): void $conn = $this->getConnection(); $conn->shouldReceive('getConfig')->andReturn(null); - $conn->shouldReceive('getServerVersion')->andReturn('10.7.0'); $blueprint = new Blueprint($conn, 'users'); $blueprint->create(); @@ -934,7 +933,7 @@ public function testAddingDate(): void { $conn = $this->getConnection(); $conn->expects('isMaria')->andReturn(true); - $conn->expects('getServerVersion')->andReturn('10.3.0'); + $conn->expects('getServerVersion')->andReturn('10.11.0'); $blueprint = new Blueprint($conn, 'users'); $blueprint->date('foo'); @@ -948,7 +947,7 @@ public function testAddingDateWithDefaultCurrent(): void { $conn = $this->getConnection(); $conn->expects('isMaria')->andReturn(true); - $conn->expects('getServerVersion')->andReturn('10.3.0'); + $conn->expects('getServerVersion')->andReturn('10.11.0'); $blueprint = new Blueprint($conn, 'users'); $blueprint->date('foo')->useCurrent(); @@ -962,7 +961,7 @@ public function testAddingYear(): void { $conn = $this->getConnection(); $conn->expects('isMaria')->andReturn(true); - $conn->expects('getServerVersion')->andReturn('10.3.0'); + $conn->expects('getServerVersion')->andReturn('10.11.0'); $blueprint = new Blueprint($conn, 'users'); $blueprint->year('birth_year'); @@ -975,7 +974,7 @@ public function testAddingYearWithDefaultCurrent(): void { $conn = $this->getConnection(); $conn->expects('isMaria')->andReturn(true); - $conn->expects('getServerVersion')->andReturn('10.3.0'); + $conn->expects('getServerVersion')->andReturn('10.11.0'); $blueprint = new Blueprint($conn, 'users'); $blueprint->year('birth_year')->useCurrent(); @@ -1215,7 +1214,6 @@ public function testAddingBinary(): void public function testAddingUuid(): void { $conn = $this->getConnection(); - $conn->shouldReceive('getServerVersion')->andReturn('10.7.0'); $blueprint = new Blueprint($conn, 'users'); $blueprint->uuid('foo'); @@ -1225,23 +1223,11 @@ public function testAddingUuid(): void $this->assertSame('alter table `users` add `foo` uuid not null', $statements[0]); } - public function testAddingUuidOn106(): void - { - $conn = $this->getConnection(); - $conn->expects('getServerVersion')->andReturn('10.6.21'); - - $blueprint = new Blueprint($conn, 'users'); - $blueprint->uuid('foo'); - $statements = $blueprint->toSql(); - - $this->assertCount(1, $statements); - $this->assertSame('alter table `users` add `foo` char(36) not null', $statements[0]); - } + // REMOVED: UUID fallback coverage for MariaDB below the 10.11 minimum. public function testAddingUuidDefaultsColumnName(): void { $conn = $this->getConnection(); - $conn->shouldReceive('getServerVersion')->andReturn('10.7.0'); $blueprint = new Blueprint($conn, 'users'); $blueprint->uuid(); @@ -1254,7 +1240,6 @@ public function testAddingUuidDefaultsColumnName(): void public function testAddingForeignUuid(): void { $conn = $this->getConnection(); - $conn->shouldReceive('getServerVersion')->andReturn('10.7.0'); $blueprint = new Blueprint($conn, 'users'); $foreignUuid = $blueprint->foreignUuid('foo'); diff --git a/tests/Database/DatabasePdoConnectionTest.php b/tests/Database/DatabasePdoConnectionTest.php index 79c68e17b8..c1a1d7515e 100755 --- a/tests/Database/DatabasePdoConnectionTest.php +++ b/tests/Database/DatabasePdoConnectionTest.php @@ -503,11 +503,10 @@ public static function databaseCapabilityProvider(): array 'unknown PDO connection' => [PdoConnection::class, '1.0', null, true, 999], 'MySQL before SKIP LOCKED' => [MySqlConnection::class, '8.0.0', null, true, 65_535], 'MySQL with SKIP LOCKED' => [MySqlConnection::class, '8.0.1', null, 'FOR UPDATE SKIP LOCKED', 65_535], - 'MariaDB through MySQL before SKIP LOCKED' => [MySqlConnection::class, '5.5.5-10.5.99-MariaDB', null, true, 65_535], - 'MariaDB through MySQL with SKIP LOCKED' => [MySqlConnection::class, '5.5.5-10.6.0-MariaDB', null, 'FOR UPDATE SKIP LOCKED', 65_535], - 'MariaDB connection' => [MariaDbConnection::class, '5.5.5-10.6.0-MariaDB', null, 'FOR UPDATE SKIP LOCKED', 65_535], + 'MariaDB through MySQL' => [MySqlConnection::class, '5.5.5-10.11.0-MariaDB', null, 'FOR UPDATE SKIP LOCKED', 65_535], + 'MariaDB connection' => [MariaDbConnection::class, '10.11.0-MariaDB', null, 'FOR UPDATE SKIP LOCKED', 65_535], 'configured MySQL override' => [MySqlConnection::class, '8.0.0', '8.0.1', 'FOR UPDATE SKIP LOCKED', 65_535], - 'configured MariaDB marker' => [MySqlConnection::class, '8.0.0', '5.5.5-10.6.0-MariaDB', 'FOR UPDATE SKIP LOCKED', 65_535], + 'configured MariaDB marker' => [MySqlConnection::class, '8.0.0', '5.5.5-10.11.0-MariaDB', 'FOR UPDATE SKIP LOCKED', 65_535], 'Vitess before SKIP LOCKED' => [MySqlConnection::class, '8.0.0', '18.0.0-vitess', true, 65_535], 'Vitess with SKIP LOCKED' => [MySqlConnection::class, '8.0.0', '19.0.0-vitess', 'FOR UPDATE SKIP LOCKED', 65_535], 'PlanetScale with SKIP LOCKED' => [MySqlConnection::class, '8.0.0', '19.0.0-PlanetScale', 'FOR UPDATE SKIP LOCKED', 65_535], diff --git a/tests/Database/DatabaseSchemaBlueprintTest.php b/tests/Database/DatabaseSchemaBlueprintTest.php index 79f86a6b8e..34d6cd76f3 100755 --- a/tests/Database/DatabaseSchemaBlueprintTest.php +++ b/tests/Database/DatabaseSchemaBlueprintTest.php @@ -27,6 +27,7 @@ use Mockery as m; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; class DatabaseSchemaBlueprintTest extends TestCase { @@ -533,30 +534,22 @@ public function testNativeRenameColumnOnMysql57(): void ], $blueprint->toSql()); } - public function testNativeRenameColumnOnLegacyMariaDB(): void + // REMOVED: legacy MariaDB CHANGE COLUMN reconstruction below the 10.11 minimum. + #[TestWith(['MariaDb'])] + #[TestWith(['MySql'])] + public function testNativeRenameColumnOnMariaDb(string $driver): void { - $connection = $this->getConnection('MariaDb'); + $connection = $this->getConnection($driver); $connection->shouldReceive('isMaria')->andReturn(true); - $connection->shouldReceive('getServerVersion')->andReturn('10.1.35'); - $connection->getSchemaBuilder()->shouldReceive('getColumns')->andReturn([ - ['name' => 'name', 'type' => 'varchar(255)', 'type_name' => 'varchar', 'nullable' => true, 'collation' => 'utf8mb4_unicode_ci', 'default' => 'foo', 'comment' => null, 'auto_increment' => false, 'generation' => null], - ['name' => 'id', 'type' => 'bigint unsigned', 'type_name' => 'bigint', 'nullable' => false, 'collation' => null, 'default' => null, 'comment' => 'lorem ipsum', 'auto_increment' => true, 'generation' => null], - ['name' => 'generated', 'type' => 'int', 'type_name' => 'int', 'nullable' => false, 'collation' => null, 'default' => null, 'comment' => null, 'auto_increment' => false, 'generation' => ['type' => 'stored', 'expression' => 'expression']], - ['name' => 'foo', 'type' => 'int', 'type_name' => 'int', 'nullable' => true, 'collation' => null, 'default' => 'NULL', 'comment' => null, 'auto_increment' => false, 'generation' => null], - ]); + $connection->shouldNotReceive('getServerVersion'); + $connection->getSchemaBuilder()->shouldNotReceive('getColumns'); $blueprint = new Blueprint($connection, 'users', function ($table) { $table->renameColumn('name', 'title'); - $table->renameColumn('id', 'key'); - $table->renameColumn('generated', 'new_generated'); - $table->renameColumn('foo', 'bar'); }); $this->assertEquals([ - "alter table `users` change `name` `title` varchar(255) collate 'utf8mb4_unicode_ci' null default 'foo'", - "alter table `users` change `id` `key` bigint unsigned not null auto_increment comment 'lorem ipsum'", - 'alter table `users` change `generated` `new_generated` int as (expression) stored not null', - 'alter table `users` change `foo` `bar` int null default NULL', + 'alter table `users` rename column `name` to `title`', ], $blueprint->toSql()); } @@ -1025,7 +1018,7 @@ protected function getConnection(?string $grammar = null, string $prefix = ''): } if ($grammar === 'MySql') { - $connection->shouldReceive('isMaria')->andReturn(false); + $connection->shouldReceive('isMaria')->andReturn(false)->byDefault(); } if ($connection instanceof MySqlConnection) { diff --git a/tests/Integration/Database/SchemaBuilderTest.php b/tests/Integration/Database/SchemaBuilderTest.php index 13052d6bdd..1bad7ec546 100644 --- a/tests/Integration/Database/SchemaBuilderTest.php +++ b/tests/Integration/Database/SchemaBuilderTest.php @@ -187,19 +187,45 @@ public function testRenameColumnWithDefault(): void Schema::create('test', static function (Blueprint $table) { $table->timestamp('foo')->useCurrent(); $table->string('bar')->default('value'); + $table->string('literal_null')->default('NULL'); + $table->string('literal_timestamp')->default('CURRENT_TIMESTAMP'); + $table->string('nullable_value')->nullable()->default(null); + $table->integer('zero')->default(0); + $table->dateTime('fractional_timestamp', 3)->useCurrent(); }); $columns = Schema::getColumns('test'); $defaultFoo = collect($columns)->firstWhere('name', 'foo')['default']; $defaultBar = collect($columns)->firstWhere('name', 'bar')['default']; + $defaults = collect($columns)->pluck('default', 'name'); Schema::table('test', static function (Blueprint $table) { $table->renameColumn('foo', 'new_foo'); $table->renameColumn('bar', 'new_bar'); + $table->renameColumn('literal_null', 'new_literal_null'); + $table->renameColumn('literal_timestamp', 'new_literal_timestamp'); + $table->renameColumn('nullable_value', 'new_nullable_value'); + $table->renameColumn('zero', 'new_zero'); + $table->renameColumn('fractional_timestamp', 'new_fractional_timestamp'); }); - $this->assertEquals(collect(Schema::getColumns('test'))->firstWhere('name', 'new_foo')['default'], $defaultFoo); - $this->assertEquals(collect(Schema::getColumns('test'))->firstWhere('name', 'new_bar')['default'], $defaultBar); + $renamed = collect(Schema::getColumns('test'))->pluck('default', 'name'); + + $this->assertEquals($defaultFoo, $renamed['new_foo']); + $this->assertEquals($defaultBar, $renamed['new_bar']); + foreach (['literal_null', 'literal_timestamp', 'nullable_value', 'zero', 'fractional_timestamp'] as $column) { + $this->assertSame($defaults[$column], $renamed['new_' . $column]); + } + + DB::table('test')->insert(['new_bar' => 'inserted']); + $row = DB::table('test')->first(); + + $this->assertSame('NULL', $row->new_literal_null); + $this->assertSame('CURRENT_TIMESTAMP', $row->new_literal_timestamp); + $this->assertNull($row->new_nullable_value); + $this->assertEquals(0, $row->new_zero); + $this->assertNotNull($row->new_foo); + $this->assertNotNull($row->new_fractional_timestamp); } #[RequiresDatabase('sqlite')] From 60aed46d42a7603461f71f6095392ce1e5a2d58e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:27:07 +0000 Subject: [PATCH 3/7] Make validation database fixtures portable across drivers Create validation fixtures in the coroutine-aware database refresh hook. PHPUnit setup runs outside the test coroutine, so direct database work there fails with the Swoole-backed PostgreSQL transport. Keep the custom-key negative assertion, but compare its UUID with another text column rather than integer id. The original test depended on implicit coercion and failed on PostgreSQL before it could check uniqueness. Remove the unused server-variable setup and cleanup. The complete validation integration file passes on PostgreSQL 14, SQLite and MySQL 5.7. --- tests/Integration/Validation/ValidatorTest.php | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/Integration/Validation/ValidatorTest.php b/tests/Integration/Validation/ValidatorTest.php index d009feddfb..269487cc37 100644 --- a/tests/Integration/Validation/ValidatorTest.php +++ b/tests/Integration/Validation/ValidatorTest.php @@ -16,10 +16,11 @@ class ValidatorTest extends DatabaseTestCase { - protected function setUp(): void + /** + * Create the validation fixtures after the database has been refreshed. + */ + protected function afterRefreshingDatabase(): void { - parent::setUp(); - Schema::create('users', function (Blueprint $table) { $table->increments('id'); $table->string('uuid'); @@ -50,8 +51,6 @@ public function testUnique(): void public function testUniqueWithCustomModelKey(): void { - $_SERVER['CUSTOM_MODEL_KEY_NAME'] = 'uuid'; - $validator = $this->getValidator(['first_name' => 'John'], ['first_name' => 'unique:' . UserWithUuid::class]); $this->assertFalse($validator->passes()); @@ -63,13 +62,12 @@ public function testUniqueWithCustomModelKey(): void $validator = $this->getValidator(['first_name' => 'John'], ['first_name' => 'unique:users,first_name,' . $user->uuid . ',uuid']); $this->assertTrue($validator->passes()); - $validator = $this->getValidator(['first_name' => 'John'], ['first_name' => 'unique:users,first_name,' . $user->uuid . ',id']); + // Keep the wrong-column check independent of database-specific UUID-to-integer coercion. + $validator = $this->getValidator(['first_name' => 'John'], ['first_name' => 'unique:users,first_name,' . $user->uuid . ',first_name']); $this->assertFalse($validator->passes()); $validator = $this->getValidator(['first_name' => 'Taylor'], ['first_name' => 'unique:' . UserWithUuid::class]); $this->assertTrue($validator->passes()); - - unset($_SERVER['CUSTOM_MODEL_KEY_NAME']); } public function testImplicitAttributeFormatting(): void From 74dcf8029630c199fc7ad3def3bee265a725fc50 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:27:19 +0000 Subject: [PATCH 4/7] Document union pagination and supported database requirements Explain how cursor and ID-based pagination maps union columns by position. Show explicit matching projections and the fromSub alternative for computed or ambiguous results. Document the protected member-resolver difference and retain normal root alias resolution. Describe the preparation boundary for custom database drivers: use one prepared builder for SQL and bindings, retain row-selection clauses inside, and keep request options outside. Drivers whose selection depends on order restore that order before applying the outer page. Update the canonical MariaDB minimum to 10.11, align older version mentions, and link the requirement from the package differences and Laravel porting guide. Documentation matches the verified framework behavior. --- src/database/README.md | 2 ++ src/docs/database.md | 6 ++++-- src/docs/porting-from-laravel.md | 4 ++++ src/docs/queries.md | 32 ++++++++++++++++++++++++++++++-- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/database/README.md b/src/database/README.md index f59b05e497..8626e1f6c4 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -12,6 +12,8 @@ Documentation: https://hypervel.org/docs/database - `DB::transaction()` throws if its closure manually ends the transaction it owns. Let `transaction()` manage completion, or use the manual transaction API separately. Balanced nested transactions remain supported; see [database transactions](https://hypervel.org/docs/database#database-transactions). - 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). - 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/database.md b/src/docs/database.md index 973204b0bd..bd7560b0b8 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -34,7 +34,7 @@ Almost every modern web application interacts with a database. Hypervel makes in
-- MariaDB 10.3+ ([Version Policy](https://mariadb.org/about/#maintenance-policy)) +- MariaDB 10.11+ ([Version Policy](https://mariadb.org/about/#maintenance-policy)) - MySQL 5.7+ ([Version Policy](https://en.wikipedia.org/wiki/MySQL#Release_history)) - PostgreSQL 10.0+ ([Version Policy](https://www.postgresql.org/support/versioning/)) - SQLite 3.32.0+ @@ -528,7 +528,9 @@ Both execution methods construct database errors through the protected `newQuery Custom drivers used with database cache locks must identify unique-constraint violations through `isUniqueConstraintError` so lock contention can be handled without hiding other database failures. -Query builders with statement-level options may override the protected `Query\Builder::ensureCanEmbedQuery` method to reject options that belong on the outer statement. It runs when attaching a subquery, scalar or exists predicate, or union member; call the parent to preserve the built-in rejection of embedded timeouts. +Query builders with statement-level options may override the protected `Query\Builder::prepareQueryForEmbedding` method to reject options that belong on the outer statement. It runs when attaching a subquery, scalar or exists predicate, or union member. Call the parent first, validate the returned query, and return it so callback preparation and the built-in rejection of embedded timeouts are preserved. + +Custom query execution methods should run `applyBeforeQueryCallbacks()`, then call the protected `prepareForSelect()` method and use the returned builder for both SQL and bindings. It preserves the caller's query while preparing pagination around bounded union members. Drivers with additional row-selection clauses can override `requiresKeysetIsolation()` and `isolateKeysetSelect()` to retain those clauses inside the selected page and request options outside it. When those clauses depend on ordering, call `restoreKeysetOrdering()` on the inner query to preserve which rows they select. `getRawBindings()` describes the caller's clauses; `getBindings()` returns parameters in the order required by the prepared SQL. Custom Eloquent builders may override the public `ensureCanCreateOrFirst(): void` method to reject helpers that depend on unique-constraint recovery. Both `firstOrCreate` and `createOrFirst` call it before reading or writing, including their relationship forms; `updateOrCreate` and `incrementOrCreate` reach it through those helpers. Helpers delegate to each other, so validation may run more than once per operation; keep overrides side-effect-free. Relationships use the related model's builder, even when the parent uses another driver. The default method imposes no restriction, and ordinary `create`, `save`, and `firstOrNew` are unchanged. diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index c50b4515a9..4ed2261f01 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -789,6 +789,8 @@ Hypervel's drivers are designed around its Swoole runtime and do not mirror ever Hypervel supports MySQL, MariaDB, PostgreSQL, and SQLite database connections. SQL Server, MongoDB, and DynamoDB database integrations are not supported. +Hypervel requires a newer MariaDB version than Laravel. Check the [supported database versions](/docs/{{version}}/database#introduction) and upgrade older servers before porting. + Remove any warning or silent `PDO::ATTR_ERRMODE` setting; PDO connections require exception mode. Custom connectors and supplied PDO handles must also use it. See [PDO error mode](/docs/{{version}}/database#pdo-error-mode). Custom non-PDO drivers that relied on generic exception messages for recovery must provide an explicit detector policy. Extend the default detectors and bind them to the existing contracts to preserve recovery for built-in drivers; see [detecting database failures](/docs/{{version}}/database#detecting-database-failures). @@ -799,6 +801,8 @@ 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). + Database connections are persistent, pooled worker resources. Define every connection in `config/database.php` before the application boots. Dynamic connection creation through `DB::build()` and `DB::connectUsing()` is not supported. Review pool sizing and any database session state against the [database documentation](/docs/{{version}}/database#connection-pooling). To use a custom query grammar on every connection, register a connection subclass with `Connection::resolverFor()` that overrides `getDefaultQueryGrammar()`. On connections that support early release, `setQueryGrammar()` changes only the current coroutine's connection, and `ConnectionEstablished` runs only when a new database session opens, so neither applies a grammar to every caller. See [extending database connections](/docs/{{version}}/database#extending-database-connections). diff --git a/src/docs/queries.md b/src/docs/queries.md index d6aa2a407a..d84c840b52 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -10,6 +10,7 @@ - [Raw Expressions](#raw-expressions) - [Joins](#joins) - [Unions](#unions) + - [Paginating Unions](#paginating-unions) - [Basic Where Clauses](#basic-where-clauses) - [Where Clauses](#where-clauses) - [Or Where Clauses](#or-where-clauses) @@ -636,6 +637,33 @@ $users = DB::table('users') In addition to the `union` method, the query builder provides a `unionAll` method. Queries that are combined using the `unionAll` method will not have their duplicate results removed. The `unionAll` method has the same method signature as the `union` method. + +### Paginating Unions + +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 +$entries = DB::table('posts')->select('id', 'title') + ->unionAll(DB::table('archives')->select('post_id', 'title')) + ->orderBy('id') + ->cursorPaginate(20); +``` + +Each selected entry up to and including a paging column must be a plain column, optionally with an alias. Raw expressions after those columns are allowed. Wildcards are also supported when every query selects only the columns of the same table on the same connection, without joins. + +For other projections, Hypervel throws an exception instead of guessing which columns correspond. Wrap the union in `fromSub` and paginate its output when using computed paging columns or wildcards from different tables: + +```php +$combined = DB::table('posts')->selectRaw('id + 1000 as entry_id, title') + ->unionAll(DB::table('archives')->selectRaw('post_id as entry_id, title')); + +$entries = DB::query()->fromSub($combined, 'entries') + ->orderBy('entry_id') + ->cursorPaginate(20); +``` + +The ordering columns must uniquely identify each result, as with other cursor queries. The descending chunk and lazy methods and the `forPageAfterId` / `forPageBeforeId` methods follow the same union rules. + ## Basic Where Clauses @@ -827,7 +855,7 @@ WHERE published = true AND NOT ( ### JSON Where Clauses -Hypervel also supports querying JSON column types on databases that provide support for JSON column types. Currently, this includes MariaDB 10.3+, MySQL 8.0+, PostgreSQL 12.0+, and SQLite 3.39.0+. To query a JSON column, use the `->` operator: +Hypervel also supports querying JSON column types on databases that provide support for JSON column types. Currently, this includes all [supported MariaDB versions](/docs/{{version}}/database#introduction), MySQL 8.0+, PostgreSQL 12.0+, and SQLite 3.39.0+. To query a JSON column, use the `->` operator: ```php $users = DB::table('users') @@ -1765,7 +1793,7 @@ DB::table('users')->updateOrInsert( ### Updating JSON Columns -When updating a JSON column, you should use `->` syntax to update the appropriate key in the JSON object. This operation is supported on MariaDB 10.3+, MySQL 5.7+, PostgreSQL 9.5+, and SQLite 3.39.0+: +When updating a JSON column, you should use `->` syntax to update the appropriate key in the JSON object. This operation is supported on all [supported MariaDB versions](/docs/{{version}}/database#introduction), MySQL 5.7+, PostgreSQL 9.5+, and SQLite 3.39.0+: ```php $affected = DB::table('users') From b0a730819ef54cc9a065b5add06bc7f9850eabf8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:08:56 +0000 Subject: [PATCH 5/7] Distinguish paging source columns from output aliases Cursor parameters identify selected output names, while ID-based paging identifies source columns. An alias that reused another source column's name could therefore filter one value and order or map another, skipping or repeating rows in union, grouped and ordinary ID-based queries. Keep source and output lookup separate, map normalized union outputs by position, and resolve ID ordering only after scopes and the requested projection are complete. Preserve logical order names on the original builder so repeated paging calls still replace the intended order. Qualified references to different tables remain distinct. When a single source is known, qualify an unselected source hidden by an alias. Reject the ambiguous joined or raw-source form with guidance to qualify or select the paging column explicitly. Verified with the Database suite, full static analysis and integration checks on SQLite, PostgreSQL, MySQL and MariaDB. Regressions cover forward and backward traversal, qualified sources, aliases and repeated calls. --- src/database/src/Query/Builder.php | 104 ++++++++++-- .../DatabaseQueryBuilderPaginationTest.php | 149 ++++++++++++++++-- 2 files changed, 232 insertions(+), 21 deletions(-) diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 06e3c57f8f..e98d5643c5 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -2797,7 +2797,7 @@ public function forPageBeforeId(int $perPage = 15, mixed $lastId = 0, string $co $lastId = BinaryParameter::fromStream($lastId); } - $orderColumn = $this->unions ? $this->getKeysetOutputColumn($column) : $column; + $orderColumn = $this->unions ? $this->getKeysetOutputColumn($column, source: true) : $column; if ($this->unions) { $this->unionOrders = $this->removeOrdersFor($this->unionOrders ?? [], $this->bindings['unionOrder'], $orderColumn); } else { @@ -2831,7 +2831,7 @@ public function forPageAfterId(int $perPage = 15, mixed $lastId = 0, string $col $lastId = BinaryParameter::fromStream($lastId); } - $orderColumn = $this->unions ? $this->getKeysetOutputColumn($column) : $column; + $orderColumn = $this->unions ? $this->getKeysetOutputColumn($column, source: true) : $column; if ($this->unions) { $this->unionOrders = $this->removeOrdersFor($this->unionOrders ?? [], $this->bindings['unionOrder'], $orderColumn); } else { @@ -3363,7 +3363,7 @@ protected function markKeysetCondition(int $offset, ?string $column = null): voi 'union' => $union, ]; if ($column !== null) { - $this->wheres[$index]['keysetColumn'] = $column; + $this->wheres[$index]['keysetSourceColumn'] = $column; } if ($union) { $this->hasUnionKeysetConditions = true; @@ -3440,11 +3440,25 @@ protected function requiresKeysetIsolation(bool $limitSelectsRows): bool protected function prepareForSelect(): static { $isolateSelect = $this->hasSelectKeysetConditions && $this->requiresKeysetIsolation(false); - if (! $isolateSelect && ! $this->hasUnionKeysetConditions) { + $orders = $this->orders; + // 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 !== ['*']) { + foreach ($orders ?? [] as $index => $order) { + if (isset($order['keyset']) && is_string($order['column'])) { + $column = $this->resolveKeysetOrderColumn($order['column']); + if ($column !== $order['column']) { + $orders[$index]['column'] = $column; + } + } + } + } + if (! $isolateSelect && ! $this->hasUnionKeysetConditions && $orders === $this->orders) { return $this; } $query = $this->clone(); + $query->orders = $orders; $query->hasSelectKeysetConditions = $query->hasUnionKeysetConditions = false; $conditions = []; $query->wheres = $query->collectKeysetConditions($query->wheres, $conditions, $isolateSelect); @@ -3467,7 +3481,7 @@ protected function prepareForSelect(): static 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']); + $pageOrders[$index]['column'] = $this->getKeysetOutputColumn($order['column'], source: isset($order['keyset'])); } } $query->isolateKeysetSelect(false); @@ -3537,7 +3551,10 @@ protected function collectKeysetConditions(array $wheres, array &$conditions, bo */ protected function normalizeKeysetColumns(array $where): array { - if (isset($where['keysetColumn'])) { + if (isset($where['keysetSourceColumn'])) { + $where['keysetColumn'] = $this->getKeysetOutputColumn($where['keysetSourceColumn'], source: true); + unset($where['keysetSourceColumn']); + } elseif (isset($where['keysetColumn'])) { $where['keysetColumn'] = $this->getKeysetOutputColumn($where['keysetColumn']); } if ($where['type'] === 'Nested') { @@ -3572,16 +3589,79 @@ protected function getKeysetSelection(string $column): array /** * Get the selected output name for a paging column. */ - protected function getKeysetOutputColumn(string $parameter): string + protected 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. + if ($source && ($output = $this->getKeysetSourceOutputColumn($parameter)) !== null) { + return $output; + } + + $outputName = $source ? Str::afterLast($parameter, '.') : $parameter; foreach ($this->getColumns() as $column) { - [$original, $alias] = $this->getKeysetSelection($column); - if ($parameter === $original || $parameter === $alias) { + [, $alias] = $this->getKeysetSelection($column); + if ($alias === $outputName) { + if ($source) { + throw new InvalidArgumentException("Cannot paginate the unselected source column [{$parameter}] through a shadowing alias. Select the paging column under its own output name."); + } + + return $alias; + } + } + + return ($source ? null : $this->getKeysetSourceOutputColumn($parameter)) ?? Str::afterLast($parameter, '.'); + } + + /** + * Find the selected output that represents a paging source column. + */ + protected function getKeysetSourceOutputColumn(string $column): ?string + { + foreach ($this->getColumns() as $selection) { + [$original, $alias] = $this->getKeysetSelection($selection); + if ($this->isSameKeysetSourceColumn($original, $column)) { return $alias; } } - return Str::afterLast($parameter, '.'); + return null; + } + + /** + * Compare source references without conflating two explicitly qualified columns. + */ + protected function isSameKeysetSourceColumn(string $left, string $right): bool + { + return $left === $right || ((! str_contains($left, '.') || ! str_contains($right, '.')) + && Str::afterLast($left, '.') === Str::afterLast($right, '.')); + } + + /** + * Keep an ID page ordered by its WHERE source when an output alias shadows it. + */ + protected function resolveKeysetOrderColumn(string $column): string + { + if (str_contains($column, '.')) { + return $column; + } + + foreach ($this->getColumns() as $selection) { + [$original, $alias] = $this->getKeysetSelection($selection); + if ($alias !== $column || $this->isSameKeysetSourceColumn($original, $column)) { + continue; + } + + if (($output = $this->getKeysetSourceOutputColumn($column)) !== null) { + return $output; + } + if (! $this->joins && ($source = $this->getFromAlias()) !== null) { + return $source . '.' . $column; + } + + throw new InvalidArgumentException("Qualify the paging column [{$column}] or select it under its own output name; an alias shadows its unresolved source."); + } + + return $column; } /** @@ -3624,8 +3704,8 @@ protected function mapUnionKeysetColumns(self $member, array $parameters): array if (! is_string($column) || $column === '*' || str_ends_with($column, '.*')) { break; } - [$original, $alias] = $this->getKeysetSelection($column); - if ($parameter === $alias || $parameter === $original) { + [, $alias] = $this->getKeysetSelection($column); + if ($parameter === $alias) { $position = $index; break; } diff --git a/tests/Database/DatabaseQueryBuilderPaginationTest.php b/tests/Database/DatabaseQueryBuilderPaginationTest.php index 37dcaa4596..2fb6c2be11 100644 --- a/tests/Database/DatabaseQueryBuilderPaginationTest.php +++ b/tests/Database/DatabaseQueryBuilderPaginationTest.php @@ -64,6 +64,133 @@ public static function keysetIterators(): iterable yield 'lazy descending' => ['lazyByIdDesc', true, true]; } + #[DataProvider('keysetIterators')] + public function testKeysetIterationUsesTheSourceColumnWhenAnAliasShadowsIt(string $method, bool $lazy, bool $descending): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table entries (id integer, code integer)'); + $connection->unprepared('insert into entries values (1, 40), (2, 30), (3, 20), (4, 10)'); + + foreach ([false, true] as $union) { + $query = $connection->table('entries')->select('code as id', 'id as original_id'); + if ($union) { + $query->where('id', '<', 3)->unionAll( + $connection->table('entries')->select('code as id', 'id as original_id')->where('id', '>=', 3) + ); + } + $actual = []; + if ($lazy) { + $actual = $query->{$method}(2, 'id', 'original_id')->take(8)->pluck('original_id')->all(); + } else { + $this->assertTrue($query->{$method}(2, function (Collection $rows) use (&$actual): bool { + array_push($actual, ...$rows->pluck('original_id')->all()); + + return count($actual) < 8; + }, 'id', 'original_id')); + } + + $this->assertSame($descending ? [4, 3, 2, 1] : [1, 2, 3, 4], $actual); + } + } + + public function testUnionCursorPrefersOutputAliasesOverOtherSourceColumns(): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table entries (id integer, name integer)'); + $connection->unprepared('insert into entries values (1, 101), (2, 102), (3, 103), (4, 104)'); + $query = $connection->table('entries')->select('name as label', 'id as name')->where('id', '<', 3) + ->unionAll($connection->table('entries')->select('name as label', 'id as name')->where('id', '>=', 3)) + ->orderBy('name'); + $cursor = null; + foreach ([1, 2, 3, 4] as $expected) { + $page = (clone $query)->cursorPaginate(1, cursor: $cursor); + $this->assertSame([$expected], $page->pluck('name')->all()); + $cursor = $page->nextCursor(); + } + $this->assertNull($cursor); + foreach ([3, 2, 1] as $expected) { + $page = (clone $query)->cursorPaginate(1, cursor: $page->previousCursor()); + $this->assertSame([$expected], $page->pluck('name')->all()); + } + $this->assertNull($page->previousCursor()); + } + + #[TestWith(['id', 'entries.id'])] + #[TestWith(['entries.id', 'id'])] + public function testUnionPagingMatchesQualifiedAndUnqualifiedSourceColumns(string $selected, string $column): 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)'); + $query = $connection->table('entries')->select($selected . ' as cursor_id')->where('id', '<', 3) + ->unionAll($connection->table('entries')->select('id as member_id')->where('id', '>=', 3)); + + $this->assertSame([2, 3], (clone $query)->forPageAfterId(2, 1, $column)->pluck('cursor_id')->all()); + $this->assertSame([3, 2], (clone $query)->forPageBeforeId(2, 4, $column)->pluck('cursor_id')->all()); + } + + public function testDirectIdPagesResolveShadowedOrdersAfterTheProjectionIsComplete(): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table entries (id integer, code integer)'); + $connection->unprepared('insert into entries values (1, 40), (2, 30), (3, 20), (4, 10)'); + $query = $connection->table('entries')->forPageAfterId(2, 1, 'id'); + + $this->assertSame([30, 20], $query->get(['code as id'])->pluck('id')->all()); + $query->forPageBeforeId(2, 4, 'id'); + $this->assertSame([3, 2], $query->get(['code as id', 'id as original_id'])->pluck('original_id')->all()); + $this->assertSame([20, 30], $connection->table('entries', 'source')->select('code as id') + ->forPageBeforeId(2, 4, 'id')->pluck('id')->all()); + } + + public function testUnionIdPagingRejectsAnUnselectedSourceHiddenByAnAlias(): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Select the paging column under its own output name'); + $connection->table('entries')->select('code as id')->where('id', '<', 3) + ->unionAll($connection->table('entries')->select('code as id')->where('id', '>=', 3)) + ->forPageAfterId(2, 1, 'id')->get(); + } + + public function testUnionPagingDistinguishesQualifiedSourcesInJoinedProjections(): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table entries (id integer)'); + $connection->unprepared('create table owners (id integer)'); + $connection->unprepared('insert into entries values (1), (2), (3), (4)'); + $connection->unprepared('insert into owners values (101)'); + $query = $connection->table('entries')->crossJoin('owners') + ->select('owners.id as owner_id', 'entries.id as cursor_id')->where('entries.id', '<', 3) + ->unionAll($connection->table('entries', 'archive')->crossJoin('owners as other') + ->select('other.id as owner_id', 'archive.id as cursor_id')->where('archive.id', '>=', 3)); + + $this->assertSame([2, 3], $query->forPageAfterId(2, 1, 'entries.id')->pluck('cursor_id')->all()); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testUnselectedShadowedIdColumnsRequireQualificationWhenTheirSourceIsUnknown(bool $raw): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table entries (id integer, code integer)'); + $connection->unprepared('create table owners (owner_id integer)'); + $connection->unprepared('insert into entries values (1, 40), (2, 30), (3, 20), (4, 10)'); + $connection->unprepared('insert into owners values (1)'); + $query = $connection->query()->select('code as id'); + if ($raw) { + $query->fromRaw('entries'); + } else { + $query->from('entries')->crossJoin('owners'); + } + + $this->assertSame([30, 20], (clone $query)->forPageAfterId(2, 1, 'entries.id')->pluck('id')->all()); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Qualify the paging column'); + $query->forPageAfterId(2, 1, 'id')->get(); + } + public function testDirectKeysetPagesPreserveScopesLimitsAndCurrentBindings(): void { $connection = new SQLiteConnection(new PDO('sqlite::memory:')); @@ -180,25 +307,29 @@ public function testPreparedPagesKeepBindingsAndEmbeddingConsistent(): void $this->assertSame(3, $page->unionLimit); } - public function testCursorComparisonsFilterGroupedExpressionResults(): void + #[TestWith([false])] + #[TestWith([true])] + public function testCursorComparisonsFilterGroupedExpressionResults(bool $shadowed): 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')->select('tenant_id')->selectRaw('sum(amount) as total') - ->groupBy('tenant_id')->havingRaw('sum(amount) >= ?', [5]) - ->orderBy('total')->orderBy('tenant_id') + $groupColumn = $shadowed ? 'total' : 'tenant_id'; + $aggregateColumn = $shadowed ? 'tenant_id' : 'total'; + $query = $connection->table('events')->select('tenant_id as ' . $groupColumn)->selectRaw('sum(amount) as ' . $aggregateColumn) + ->groupBy('events.tenant_id')->havingRaw('sum(amount) >= ?', [5]) + ->orderBy($aggregateColumn)->orderBy($groupColumn) ->beforeQuery(static fn (Builder $query) => $query->orderByRaw('tenant_id + ?', [100])); $first = (clone $query)->cursorPaginate(1); $second = (clone $query)->cursorPaginate(1, cursor: $first->nextCursor()); $third = (clone $query)->cursorPaginate(1, cursor: $second->nextCursor()); - $this->assertSame([1], $first->pluck('tenant_id')->all()); - $this->assertSame([2], $second->pluck('tenant_id')->all()); - $this->assertSame([3], $third->pluck('tenant_id')->all()); - $this->assertSame([1], (clone $query)->cursorPaginate(1, cursor: $second->previousCursor())->pluck('tenant_id')->all()); - $this->assertSame([], (clone $query)->cursorPaginate(1, cursor: new Cursor(['total' => 7, 'tenant_id' => 3]))->items()); + $this->assertSame([1], $first->pluck($groupColumn)->all()); + $this->assertSame([2], $second->pluck($groupColumn)->all()); + $this->assertSame([3], $third->pluck($groupColumn)->all()); + $this->assertSame([1], (clone $query)->cursorPaginate(1, cursor: $second->previousCursor())->pluck($groupColumn)->all()); + $this->assertSame([], (clone $query)->cursorPaginate(1, cursor: new Cursor([$aggregateColumn => 7, $groupColumn => 3]))->items()); } public function testBinaryStringsCanBePagedUsingAnExplicitCursorAlias(): void From 2e532a6683d195affa51688d3b2bbe46986f88c2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:09:08 +0000 Subject: [PATCH 6/7] Explain ID paging when output aliases hide source columns Document how to select the source under a distinct output name and pass that name to the chunkById or lazyById alias argument. Explain the narrow rejection for an unselected shadowed source in joined or raw-source queries, where the builder cannot identify its table safely. Keep the package differences and Laravel porting guide concise and link them to the query documentation. Qualified source references retain the same paging capability without relying on ambiguous SQL name resolution. --- src/database/README.md | 1 + src/docs/porting-from-laravel.md | 2 ++ src/docs/queries.md | 2 ++ 3 files changed, 5 insertions(+) diff --git a/src/database/README.md b/src/database/README.md index 8626e1f6c4..3c4bf576f7 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -14,6 +14,7 @@ Documentation: https://hypervel.org/docs/database - 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). +- 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). - 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 4ed2261f01..8740e3f7eb 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -803,6 +803,8 @@ 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. 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 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). + Database connections are persistent, pooled worker resources. Define every connection in `config/database.php` before the application boots. Dynamic connection creation through `DB::build()` and `DB::connectUsing()` is not supported. Review pool sizing and any database session state against the [database documentation](/docs/{{version}}/database#connection-pooling). To use a custom query grammar on every connection, register a connection subclass with `Connection::resolverFor()` that overrides `getDefaultQueryGrammar()`. On connections that support early release, `setQueryGrammar()` changes only the current coroutine's connection, and `ConnectionEstablished` runs only when a new database session opens, so neither applies a grammar to every caller. See [extending database connections](/docs/{{version}}/database#extending-database-connections). diff --git a/src/docs/queries.md b/src/docs/queries.md index d84c840b52..529a09d71c 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -201,6 +201,8 @@ DB::table('users')->where('active', false) }); ``` +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`. 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 also applies to the descending methods and `forPageAfterId` / `forPageBeforeId`. + Since the `chunkById` and `lazyById` methods add their own "where" conditions to the query being executed, you should typically [logically group](#logical-grouping) your own conditions within a closure: ```php From 8b234d6969dacb340cfa3815b522b86ce03e714e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:25:38 +0000 Subject: [PATCH 7/7] Clarify alias arguments for ID paging methods Separate chunkById and lazyById alias guidance from source qualification. Their descending variants accept the same alias argument, while the direct forPageAfterId and forPageBeforeId methods accept only a paging column. State that distinction explicitly so the shared qualification guidance does not suggest an argument the direct methods do not support. Verified against the builder and relationship signatures; no runtime code changes. --- src/docs/queries.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/docs/queries.md b/src/docs/queries.md index 529a09d71c..59080aa843 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -201,7 +201,9 @@ DB::table('users')->where('active', false) }); ``` -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`. 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 also applies to the descending methods and `forPageAfterId` / `forPageBeforeId`. +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. Since the `chunkById` and `lazyById` methods add their own "where" conditions to the query being executed, you should typically [logically group](#logical-grouping) your own conditions within a closure: