Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2e12a71
Isolate query casts on cloned Eloquent builders
binaryfire Oct 30, 2026
1431fbe
Preserve scoped facade lifetimes and document queue state isolation
binaryfire Oct 30, 2026
20f1595
Isolate Redis allocation tests from runner database reservations
binaryfire Oct 30, 2026
4056b4f
Run formatting at review and commit checkpoints
binaryfire Oct 30, 2026
ea9524f
Cover unreachable Redis Cluster connection replacement
binaryfire Oct 30, 2026
fb0beed
Consume notification locale overrides for each send
binaryfire Oct 30, 2026
101f2f8
Resolve morph aliases in relationship loading constraints
binaryfire Oct 30, 2026
bd22992
Keep discovered and closure commands on their console kernel
binaryfire Oct 30, 2026
b0e13d9
Keep escaped user text inside secured Markdown links
binaryfire Oct 30, 2026
4a0d19f
Add pooled Vitess analytical workload scopes
binaryfire Oct 30, 2026
ff769f8
Describe supported aggregate relationship constraints precisely
binaryfire Oct 30, 2026
e20539c
Preserve scoped casts and cursor query behavior
binaryfire Oct 30, 2026
5a34968
Keep delegated scoped services out of facade caches
binaryfire Oct 30, 2026
f2460e2
Isolate notification fake locale overrides by coroutine
binaryfire Oct 30, 2026
01ccb0a
Preserve literal content in Markdown mail encoding
binaryfire Oct 30, 2026
ebc223d
Clarify facade lifetimes and notification boot defaults
binaryfire Oct 30, 2026
369e1d1
Preserve query ordering and alias semantics during paging
binaryfire Oct 31, 2026
ee2a41c
Use the query builder's ID ordering for cached model mutations
binaryfire Oct 31, 2026
8c5223a
Document paging order requirements and facade lifetimes
binaryfire Oct 31, 2026
785aec3
Preserve scoped selections and model keys during paging
binaryfire Oct 31, 2026
7bd3c84
Explain scope ordering in limited ID iteration
binaryfire Oct 31, 2026
d9ecff3
Validate scoped union ordering against the paging source
binaryfire Oct 31, 2026
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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ Anything found follows When to Stop and Report — "the task didn't ask me to fi

Do not routinely run formatting or PHPStan during edits. Run targeted PHPStan earlier only when investigating a typing issue or when its feedback is needed to guide implementation.

**At the end of a coherent implementation slice**, run `composer lint:fix`, `composer analyse`, and the affected tests, in that order. Use PHPUnit for one test file or `composer test:parallel -- --processes=4 tests/PackageName` for multiple files. Use `--filter` when needed to narrow the selection.
**At the end of a coherent implementation slice**, run `composer analyse` and the affected tests, in that order. Use PHPUnit for one test file or `composer test:parallel -- --processes=4 tests/PackageName` for multiple files. Use `--filter` when needed to narrow the selection.

Run `composer lint:fix` before a scheduled code review or commit checkpoint, not after individual changes within a batch.

Run the full test suite only when the change has framework-wide impact. In that case, use `composer fix`, which includes formatting, analysis and all test suites; do not run those checks separately first. For focused changes, include separate Testbench or dogfood checks when the changed behavior affects those workflows.

Expand Down
3 changes: 1 addition & 2 deletions src/cache/src/ModelCacheCoordinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,7 @@ public function mutate(Builder $query, Closure $write, Closure $invalidate): int
$writeBatch($batch);
}
} else {
// Other sort columns would make chunkById skip records between batches.
$selection->reorder()->chunkById(
$selection->chunkById(
self::MUTATION_BATCH_SIZE,
static fn (Collection $rows) => $writeBatch($rows->pluck($key)->all()),
$qualifiedKey,
Expand Down
23 changes: 23 additions & 0 deletions src/container/src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,11 @@ protected function isExplicitlyShared(string $abstract): bool
*/
public function isScoped(string $abstract): bool
{
// Internal resolution usually supplies an already canonical name.
if (isset($this->aliases[$abstract])) {
$abstract = $this->getAlias($abstract);
}

if (isset($this->scopedInstances[$abstract])) {
return true;
}
Expand All @@ -384,6 +389,24 @@ public function isScoped(string $abstract): bool
return false;
}

/**
* Determine if the object is a resolved scoped instance in this coroutine.
*/
public function isScopedInstance(mixed $instance): bool
{
if (! is_object($instance)) {
return false;
}

foreach ($this->scopedInstances as $abstract => $_) {
if (CoroutineContext::get(self::SCOPED_CONTEXT_PREFIX . $abstract) === $instance) {
return true;
}
}

return false;
}

/**
* Determine if a scoped binding has already been resolved in this coroutine.
*
Expand Down
4 changes: 4 additions & 0 deletions src/contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ Contracts for Hypervel

Documentation: https://hypervel.org/docs/contracts

## Differences From Laravel

The `Container` contract also requires `isScoped()`, `isScopedInstance()`, `forgetInstance()`, `makeTransient()`, `build()`, `buildWith()`, `resolveContextualParameters()`, and `rebinding()`. Custom implementations must provide these methods; extending `Hypervel\Container\Container` supplies them. Scope inspection lets consumers preserve coroutine-local lifetimes instead of caching scoped services across requests.

Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Contracts
10 changes: 10 additions & 0 deletions src/contracts/src/Container/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ public function scoped(Closure|string $abstract, Closure|string|null $concrete =
*/
public function scopedIf(Closure|string $abstract, Closure|string|null $concrete = null): void;

/**
* Determine if a given abstract is a scoped binding.
*/
public function isScoped(string $abstract): bool;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/**
* Determine if the object is a resolved scoped instance in this coroutine.
*/
public function isScopedInstance(mixed $instance): bool;

/**
* "Extend" an abstract type in the container.
*
Expand Down
3 changes: 2 additions & 1 deletion src/database/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ Documentation: https://hypervel.org/docs/database
- Transaction records use the resolved connection's `getNameWithReadWriteType()`, not its base `getName()`, so distinct base, read and write sessions own separate callbacks. Extensions inspecting transaction records must compare against the resolved owner's full name.
- SQLite requires 3.32.0 or later rather than Laravel's 3.26.0 minimum, so framework code assumes the newer default limit of 32,766 bindings per statement.
- MariaDB has a higher [minimum version](https://hypervel.org/docs/database#introduction) than Laravel. Supported servers provide native column and index renaming and UUID columns without legacy compatibility paths.
- Union cursor and ID-based pagination requires provable column correspondence. Use explicit columns in matching positions, or wrap computed and ambiguous projections with `fromSub()`. Union members are mapped by position rather than through the protected `getOriginalColumnNameForCursorPagination()` override; the root query still uses that override. See [paginating unions](https://hypervel.org/docs/queries#paginating-unions).
- Union cursor and ID-based pagination requires provable column correspondence. Use explicit columns in matching positions, or wrap computed and ambiguous projections with `fromSub()`. Query-builder `chunk`, `lazy`, and `cursorPaginate` calls also require `orderBy` after the union to sort the combined result. Union members are mapped by position rather than through the protected `getOriginalColumnNameForCursorPagination()` override; the root query still uses that override. See [paginating unions](https://hypervel.org/docs/queries#paginating-unions).
- ID-based paging rejects an unselected column hidden by an output alias when joins or a raw source prevent resolving its table. Qualify the paging column, such as `users.id`, or select it under a separate name; see [chunking results](https://hypervel.org/docs/queries#chunking-results).
- `chunkById` and `lazyById` prioritize the paging column, including when stopping early. Limited or offset calls led by another order throw instead of selecting a different set, including when a global scope uses `reorder`; choose ranked subsets with `fromSub` first. The descending variants follow the same rule. See [chunking results](https://hypervel.org/docs/queries#chunking-results).
- Outgoing framework HTTP requests return idle database sessions to their pools. Code using temporary tables, session locks or retained raw PDOs across these calls must use `withPinnedSession()`; ordinary queries and active transactions keep their behavior. See [releasing and pinning connections](https://hypervel.org/docs/database#releasing-and-pinning-connections).
- MySQL and MariaDB connections require `strict` or `modes` in their configuration instead of implicitly inheriting the server's SQL mode, so SQL literals can be escaped correctly.
- Laravel's external database pooler support uses a `::direct` connection suffix. Hypervel instead uses normal named connections for each endpoint and `migrations_connection` for schema and migration paths. This keeps direct and pooled endpoints as normal configured connections with their own pool settings, so Hypervel does not support Laravel's `::direct` suffix.
Expand Down
103 changes: 74 additions & 29 deletions src/database/src/Concerns/BuildsQueries.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Generator;
use Hypervel\Container\Container;
use Hypervel\Contracts\Database\Query\Expression as ExpressionContract;
use Hypervel\Database\Eloquent\Builder;
use Hypervel\Database\Eloquent\Model;
use Hypervel\Database\MultipleRecordsFoundException;
Expand Down Expand Up @@ -162,9 +163,11 @@ public function orderedChunkById(int $count, callable $callback, ?string $column

$column ??= $this->defaultKeyName();
$alias ??= $column;
$this->ensureLimitedIterationOrdersById($column, $descending === true || $descending === SortDirection::Descending);
$lastId = null;
$skip = $this->getOffset();
$remaining = $this->getLimit();
$limited = $remaining !== null || $skip > 0;

$page = 1;

Expand All @@ -185,8 +188,8 @@ public function orderedChunkById(int $count, callable $callback, ?string $column
// no results we can just break and return from here. When there are results
// we will call the callback with the current chunk of these results here.
$results = match ($descending) {
SortDirection::Ascending, false => $clone->forPageAfterId($limit, $lastId, $column)->get(),
SortDirection::Descending, true => $clone->forPageBeforeId($limit, $lastId, $column)->get(),
SortDirection::Ascending, false => $clone->forPageAfterId($limit, $lastId, $column)->prioritizeKeysetOrders($limited)->get(),
SortDirection::Descending, true => $clone->forPageBeforeId($limit, $lastId, $column)->prioritizeKeysetOrders($limited)->get(),
};

$countResults = $results->count();
Expand Down Expand Up @@ -330,10 +333,12 @@ protected function orderedLazyById(int $chunkSize = 1000, ?string $column = null

$alias ??= $column;

$this->ensureLimitedIterationOrdersById($column, $descending === true || $descending === SortDirection::Descending);
$skip = $this->getOffset();
$remaining = $this->getLimit();
$limited = $remaining !== null || $skip > 0;

return new LazyCollection(function () use ($chunkSize, $column, $alias, $descending, $skip, $remaining): Generator {
return new LazyCollection(function () use ($chunkSize, $column, $alias, $descending, $skip, $remaining, $limited): Generator {
$lastId = null;

$page = 1;
Expand All @@ -352,8 +357,8 @@ protected function orderedLazyById(int $chunkSize = 1000, ?string $column = null
}

$results = match ($descending) {
SortDirection::Ascending, false => $clone->forPageAfterId($limit, $lastId, $column)->get(),
SortDirection::Descending, true => $clone->forPageBeforeId($limit, $lastId, $column)->get(),
SortDirection::Ascending, false => $clone->forPageAfterId($limit, $lastId, $column)->prioritizeKeysetOrders($limited)->get(),
SortDirection::Descending, true => $clone->forPageBeforeId($limit, $lastId, $column)->prioritizeKeysetOrders($limited)->get(),
};

foreach ($results as $result) {
Expand Down Expand Up @@ -507,49 +512,61 @@ protected function paginateUsingCursor(
: CursorPaginator::resolveCurrentCursor($cursorName, $cursor);
}

// The caller must provide the complete unique order. Adding scope keys here
// would require extra selected values and can replace an index range with a compound filter.
$orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->pointsToPreviousItems());
$this->limit($perPage + 1);
$builder = $this;

if (! is_null($cursor)) {
$query = $this instanceof Builder ? $this->getQuery() : $this;
$originalColumns = $query->columns;
$originalColumnNames = [];
$builder = $this->getBuilderForCursorPage();
$pageQuery = $builder instanceof Builder ? $builder->getQuery() : $builder;
$originalColumns = $pageQuery->columns;
$comparisons = [];
$bindings = [];

// Resolve argument-only aliases before get() installs its projection,
// then restore it so Eloquent scopes retain their normal column semantics.
$query->columns ??= Arr::wrap($columns);
// Scopes may add computed aliases. Resolve against the page's scoped projection,
// installing argument-only columns afterward so scopes still see their normal input.
$pageQuery->columns ??= Arr::wrap($columns);
try {
foreach ($orders as $order) {
$originalColumnNames[$order['column']] = $this->getOriginalColumnNameForCursorPagination($this, $order['column']);
$column = $order['column'];
$original = $this->getOriginalColumnNameForCursorPagination($builder, $column);
$order['original'] = $this->isCursorExpression($pageQuery, $column, $original)
? new Expression($original) : $original;
$comparisons[] = $order;
$bindings[$column] = $builder->getCursorParameterBinding($cursor, $column);
}
} finally {
$query->columns = $originalColumns;
}

$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);
$pageQuery->columns = $originalColumns;
}

// Members are resolved by their output positions during SELECT preparation;
// keep their select/join bindings and never rewrite a shared union builder.
$query->addCursorPaginationConditions($comparisons, $bindings);
}

$this->limit($perPage + 1);
if ($pageQuery !== $query) {
// Keep the caller's page conditions as well as the scoped execution copy's.
$pageQuery->addCursorPaginationConditions($comparisons, $bindings);
}
}

return $this->cursorPaginator($this->get($columns), $perPage, $cursor, [
return $this->cursorPaginator($builder->get($columns), $perPage, $cursor, [
'path' => Paginator::resolveCurrentPath(),
'cursorName' => $cursorName,
'parameters' => $orders->pluck('column')->toArray(),
]);
}

/**
* Get the builder that owns cursor binding and page execution.
*/
protected function getBuilderForCursorPage(): static
{
return $this;
}

/**
* Get a cursor parameter for database binding.
*/
Expand All @@ -565,15 +582,18 @@ protected function getCursorParameterBinding(Cursor $cursor, string $parameter):
*/
protected function getOriginalColumnNameForCursorPagination(QueryBuilder|Builder $builder, string $parameter): string
{
$columns = $builder instanceof Builder ? $builder->getQuery()->getColumns() : $builder->getColumns();
$query = $builder instanceof Builder ? $builder->getQuery() : $builder;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

foreach ($columns as $column) {
// Ordered expressions require a separate select entry with an explicit AS alias.
// Implicit aliases and multiple fields in one raw entry remain unsupported: parsing
// arbitrary SQL is brittle, while automatic wrapping can change row-lock behavior.
foreach ($query->getColumns() as $column) {
if (($position = strripos($column, ' as ')) !== false) {
$original = substr($column, 0, $position);

$alias = substr($column, $position + 4);

if ($parameter === $alias || $builder->getGrammar()->wrap($parameter) === $alias) {
if ($parameter === $alias || $query->getGrammar()->wrap($parameter) === $alias) {
return $original;
}
}
Expand All @@ -582,6 +602,31 @@ protected function getOriginalColumnNameForCursorPagination(QueryBuilder|Builder
return $parameter;
}

/**
* Determine whether the resolved cursor column is a declared SQL expression.
*/
protected function isCursorExpression(QueryBuilder $query, string $parameter, string $original): bool
{
$grammar = $query->getGrammar();

// Arithmetic need not contain parentheses, and literal identifiers may contain them.
// Match the alias as well as its source so another selection cannot change its type.
foreach ($query->columns ?? [] as $column) {
$value = (string) $grammar->getValue($column);
if (($position = strripos($value, ' as ')) !== false) {
$alias = substr($value, $position + 4);

if (substr($value, 0, $position) === $original
&& ($parameter === $alias || $grammar->wrap($parameter) === $alias)) {
return $column instanceof ExpressionContract;
}
}
}

// Preserve custom alias-lookup overrides that return an unselected expression.
return $original !== $parameter && Str::contains($original, ['(', ')']);
}

/**
* Create a new length-aware paginator instance.
*/
Expand Down
Loading
Loading