Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/databases.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/database/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ 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).
- 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.
Expand Down
93 changes: 27 additions & 66 deletions src/database/src/Concerns/BuildsQueries.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 8 additions & 2 deletions src/database/src/Eloquent/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,8 @@ protected function ensureOrderForCursorPagination(bool $shouldReverse = false):
$this->enforceOrderBy();
}

$this->query->prepareCursorOrdering();

$reverseDirection = function ($order) {
if (! isset($order['direction'])) {
return $order;
Expand All @@ -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;
Expand Down
4 changes: 1 addition & 3 deletions src/database/src/MySqlConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading