From 756b2b0277443d31035cbb479a987ae54ab2765a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:44:09 +0000 Subject: [PATCH 01/26] docs(audit): plan database persistence lifecycle work Record the complete, reviewed implementation plan for the Database audit and its required Redis, Core, Server, Pool, Support, and documentation boundaries.\n\nCapture exact task and fork ownership, pooled Redis transaction state, SQLite classification and single-owner pooling, truthful transaction cleanup, coroutine-safe Eloquent boot publication, current Laravel parity, performance corrections, metadata, documentation, regression coverage, and full validation criteria.\n\nDocument the evidence behind each mechanism, every owner-approved hot-path cost, and the rejected alternatives so implementation can remain complete without accumulating speculative abstractions or compatibility debris. --- ...e-lifecycles-and-current-laravel-parity.md | 2945 +++++++++++++++++ 1 file changed, 2945 insertions(+) create mode 100644 docs/plans/2026-07-24-1013-database-persistence-lifecycles-and-current-laravel-parity.md diff --git a/docs/plans/2026-07-24-1013-database-persistence-lifecycles-and-current-laravel-parity.md b/docs/plans/2026-07-24-1013-database-persistence-lifecycles-and-current-laravel-parity.md new file mode 100644 index 000000000..b1f557517 --- /dev/null +++ b/docs/plans/2026-07-24-1013-database-persistence-lifecycles-and-current-laravel-parity.md @@ -0,0 +1,2945 @@ +# Complete Database Persistence Lifecycles and Current Laravel Parity + +## Status + +Plan drafted after the Database audit and pre-implementation second-opinion consensus. Owner approval has been received for every additive capability and performance-sensitive change in that consensus. A fresh source, test, documentation, metadata, lifecycle, performance, and overengineering review is complete; it added `redis-06` through `redis-08`, whose minimal Redis command/release hot-path work must be included in the final owner review before implementation. Independent plan review is pending. Implementation has not started. + +## Scope + +Complete the `database` package audit as one coherent persistence work unit. The work includes the lowest owning Core, Server, Redis, Pool-consumer, documentation, and metadata boundaries required to fix the verified Database defects completely. + +The final implementation must: + +- release exact Database and Redis leases at the end of non-coroutine Swoole tasks; +- prevent parent-created Database and Redis resources from crossing Swoole process forks; +- make Redis command-event cleanup failure-safe and preserve exact connection ownership for optimistic transactions; +- make Redis release safe for abandoned native transaction, pipeline, and watch state; +- give one physical in-memory SQLite PDO exactly one concurrent owner; +- classify all supported SQLite memory and URI forms consistently; +- keep logical transactions, physical PDO transactions, transaction-manager records, callbacks, events, and pooled reuse truthful on every success and failure path; +- serialize first Eloquent model boot only while publication is incomplete, without rejecting legitimate sibling coroutines; +- port the complete supported current Laravel Database surface discovered through the relevant originating changes; +- apply the two approved current Laravel Database performance corrections; +- make split-package metadata, provenance, intentional omissions, and user-facing documentation complete; +- preserve Laravel-facing call shapes and extension points unless a verified Swoole/coroutine requirement demands an adaptation; +- remove the reflective task-worker cleanup and every stale helper, comment, test assumption, or dependency it leaves behind. + +This is not a redesign of Database around a new transaction engine, cleanup registry, lease proxy, shared SQLite cache, or generic lifecycle framework. Each accepted mechanism has a demonstrated owner and a concrete production failure. + +## Desired final architecture + +| Surface | Final owner and lifetime | +|---|---| +| Swoole task completion | `TaskCallback` owns a final `TaskTerminated` phase after action dispatch and result finishing | +| Non-coroutine Database task leases | `ConnectionResolver` retains the exact borrowed `PooledConnection` wrappers until `TaskTerminated` | +| Non-coroutine Redis task leases | Each `RedisProxy` owns its context identity; `RedisManager` exhausts already-created proxies at `TaskTerminated` | +| Final parent pre-fork boundary | `Server::start()` dispatches `BeforeServerFork` immediately before native `start()` | +| Database process cleanup | One Database lifecycle listener discards exact resolver leases and independently flushes already-resolved pools | +| Redis process cleanup | One Redis lifecycle listener discards exact proxy leases and independently flushes already-resolved pools | +| Redis pooled release | `RedisConnection` verifies native queue mode before any database restore or requeue | +| SQLite classification | Internal `SQLiteDatabase` is the single URI and in-memory classifier | +| In-memory SQLite ownership | Existing pool channel serializes one wrapper around one shared PDO | +| Transaction state | `Connection` owns physical/logical state; `DatabaseTransactionsManager` owns detached callback records | +| Eloquent first boot | Per-model owner ID plus the existing class-keyed `Mutex` until boot publication and post-publication hooks complete | +| Current Laravel parity | Current local Laravel `13.x` source/tests/docs, adapted only for supported drivers and Hypervel runtime rules | +| Package provenance and omissions | Database README, split manifests, source `REMOVED:` markers, metadata test, and Boost guides | + +## Finding summary + +| ID | Category | Severity | Verified failure | Final boundary | +|---|---|---:|---|---| +| `database-05` | Defect | Major | Default non-coroutine task workers never release pooled Database wrappers; raw context, transaction, and session state persist into later tasks | Add `TaskTerminated`; retain exact non-coroutine wrappers in `ConnectionResolver`; release them terminally | +| `database-06` | Defect | Major | Database pools, PDOs, timers, channels, and pinned fallback context can be created before Swoole forks and inherited by every process | Add the final `BeforeServerFork` boundary; discard resolved leases and flush resolved pools before fork and at child start | +| `database-07` | Defect | Major | The in-memory SQLite pool advertises multiple independent wrappers for one physical PDO | Normalize valid in-memory pool capacity to one and use the existing pool channel as the only serialization primitive | +| `database-08` | Defect and parity defect | Major | Four inconsistent SQLite checks miss valid URI memory forms, reject valid file URIs, and can truncate a literal URI filename instead of the attached database | Add `SQLiteDatabase`, accept current SQLite URI forms, derive the canonical attached path, and check truncation | +| `database-09` | Defect, parity defect, and upstream defect | Major | Transaction listeners, physical failures, manager callbacks, disconnects, and nested rollbacks can publish false state, strand callback records, or requeue corrupt pooled sessions | Separate phases, detach manager records before callbacks, clean up before retry, exhaust rollback cleanup, and preserve the earliest throwable | +| `database-10` | Defect, parity defect, and upstream defect | Major | Eloquent publishes `$booted` before boot finishes; recursion sees incomplete state, exceptions permanently poison boot state, and sibling coroutines race | Track the exact boot owner and lock only first publication per model class | +| `database-11` | Supported current Laravel parity | Major/Minor | Current Query, Eloquent, Schema, migration, connector, provider, exception, and metadata behavior is absent or stale | Port the complete current supported upstream surface discovered through originating changes | +| `database-12` | Performance improvement | Improvement | Ordinary attribute reads merge every cached cast; raw SQL substitution removes bindings quadratically | Port current Laravel's per-key cast merge and indexed binding substitution | +| `database-13` | Metadata and documentation defect | Major | Split dependencies, provenance, deliberate deprecated omissions, and accepted public APIs are incomplete or undiscoverable | Correct manifests, add metadata coverage, record omissions, and update task-first guides | +| `redis-03` | Cross-package defect | Major | Non-coroutine same-connection commands call coroutine defer outside a coroutine and pin the lease across tasks | Gate defer registration and release exact proxy-owned context at `TaskTerminated` | +| `redis-04` | Cross-package defect | Major | Abandoned native MULTI/PIPELINE clients are requeued and silently queue commands for the next borrower | Detect native mode at release and discard terminally | +| `redis-05` | Cross-package defect | Major | Redis sockets, heartbeat timers, pools, and pinned fallback context share Database's pre-fork inheritance defect | Consume `BeforeServerFork` and `BeforeWorkerStart` through exact discard plus resolved pool flush | +| `redis-06` | Cross-package defect | Major | A throwing `CommandExecuted` listener exits the proxy's `finally` block before release or context handoff, leaking the borrowed pool slot | Capture event failure, complete the same ownership handoff or cleanup, then preserve existing Laravel event-failure precedence | +| `redis-07` | Cross-package defect | Major | `WATCH` returns its wrapper to the pool immediately, so the later `MULTI`/`EXEC` can use another client and a watched client can leak into the next borrower; callback-form transaction completion bypasses wrapper state updates | Pin successful `WATCH`, track its native state on the owning wrapper, clear it on successful terminal commands including callback-form transactions, and discard an abandoned watch | +| `redis-08` | Cross-package parity and type defect | Major | `Redis::discard()` resolves the inherited pool-lifecycle `discard(): void` instead of phpredis `DISCARD`, destroying the wrapper and returning null while the transaction context still points at it | Give the wrapper a distinct internal native-discard method and route only the Laravel-facing proxy command through it | + +## Backing research and fixed assumptions + +### Current upstream workflow + +The originating Laravel changes below are discovery history: they identify the complete source, test, fixture, facade, contract, and documentation surface introduced with each feature or correction. The implementation reference is the current local Laravel `13.x` checkout at `examples/laravel/framework`, currently `23e9e71f38`. + +Do not copy historical diffs as the final source. Re-open each current upstream file when implementing it, preserve current relative member order, and merge current tests with Hypervel-specific coverage. + +The documentation reference is the current local Laravel Docs `13.x` checkout at `examples/laravel/docs`, currently `ce4a1bf093`. It documents `foreignUuidFor()`, `whereNullSafeEquals()` / `orWhereNullSafeEquals()`, `withoutRelation()`, and `#[RouteKey]`. The RouteKey documentation has a dedicated current-history change (`559641a399`); the other three entered the local `13.x` history through its bulk section creation (`0d61029ee4`), so that checkout exposes no narrower documentation change to use for discovery. It does not currently document the other accepted public additions in this plan. Port current documented wording as the reference where it exists and add proportionate Hypervel documentation for the remaining accepted APIs. + +#### Transaction corrections + +| Commit | Discovery surface | +|---|---| +| `f344339df5` | Physical rollback before retrying commit deadlocks; `ManagesTransactions`, `DatabaseConnectionTest` | +| `b5b0d6b52d` | Transaction-manager cleanup on disconnect; `Connection`, `DatabaseTransactionsManager`, `DatabaseConnectionTest` | +| `1ef91c00b2` | Rollback callbacks for committed nested records; manager source and tests | + +#### Query corrections and additions + +| Commits | Current behavior | +|---|---| +| `7c9e306c3d` | MySQL straight joins | +| `8d8465a26e` | Null-safe equality and relationship use on supported grammars | +| `8365873987` | `inOrderOf()` | +| `039787fe2f` | MySQL query timeout hint | +| `95ef41ad58`, `fdbdddf6ed`, `842bbaa105`, `8ed1300f1e` | Current `insertOrIgnoreReturning()` signature, validation, modified-record behavior, and multi-column conflicts | +| `e8f4b59f10` | `DatePeriod` between bounds | +| `4075266d62` | `whereColumn()` question-mark escaping | +| `4d83904b41`, `fe2afc066c` | PostgreSQL date/time expressions | +| `df19aecd0d` | Delimited aggregate aliases | +| `f9ac81f5ab` | PostgreSQL precomputed `tsvector` full-text queries | +| `57eb459c65` | Eloquent builders and relations as update subqueries | +| `2a60ae7150` | Relative-date integration coverage; current Hypervel source already supports the behavior | + +#### Eloquent corrections and additions + +| Commits | Current behavior | +|---|---| +| `8c6960875d`, `56a36ae2e2`, `34da7b5d27` | Instance-scoped increment/decrement-each and quiet forwarding | +| `6f27470d15` | `saveOrIgnore()` | +| `a06041f10a` | `withoutRelation()`; source and existing docs revalidation | +| `9baf8d82ea` | `#[RouteKey]` | +| `c1e239dc34` | Related key use in `BelongsToMany::touch()` | +| `eb473d3c37` | `restored` only after a successful soft-delete restore save | +| `26f92f2e4a` | Private attributed scopes | +| `65d16321e4` | `MorphTo` eager matching with null owner key and non-primitive result keys | +| `6ce926d755`, `10539d466c` | Closure values across create-or-first, first-or-new, and update-or-create paths | +| `4ca4a16772` | Nested scope removal | +| `651ead2721` | Multiple-column touching | +| `4ce9f70950`, `c0dc1d5ff9` | Enum model IDs in `ModelNotFoundException` | +| `8843a5e3f9` | `casts()` Stringable PHPDoc | +| `6822896080` | `AsEncryptedArrayObject::ARRAY_AS_PROPS` | +| `23e9e71f38` | Trait-carried class attributes | +| `871351c009` | Full model path in resource naming | + +#### Schema, migration, connector, and provider corrections + +| Commits | Current behavior | +|---|---| +| `7e5320693d` | `foreignUuidFor()` | +| `979601b173` | `hasForeignKey()` and facade metadata | +| `277daca775` | PostgreSQL `tsvector` columns | +| `fbb3f5344f` | MariaDB vector indexes | +| `2d8e8f4015` | Unique-constraint index and column metadata | +| `fefc53a93f` | Migration event names | +| `c0f75327bc` | Schema dumps without migration data | +| `566f2c4d9c`, `39cb3f2fb4` | SQLite URI support and standalone `base_path()` guard | +| `cf6681c426` | PostgreSQL pre-9.1 collation handling | +| `278fcd781e` | `model:prune` option validation typo | +| `dcf70c4b19`, `c0567a68aa` | Current lost-connection messages | +| `6575242feb` | `migrate:fresh` missing-database behavior | +| `b2dcd15f34` | MariaDB client detection | +| `fbc03bac3e` | PostgreSQL custom-schema sequence starts | + +#### Approved performance corrections + +| Commit | Current behavior | +|---|---| +| `391d540182` | Merge only the requested cached cast on ordinary attribute access; retain full merges where mutators can inspect siblings | +| `e4223623a0` | Use a binding index rather than `array_shift()` per raw SQL placeholder | + +### Verified Hypervel runtime facts + +- `server.settings.task_enable_coroutine` defaults to `false`. +- Swoole executes default non-coroutine task-worker callbacks sequentially within one task worker. +- `ConnectionResolver` currently installs deferred release only in a coroutine. The bare `Connection` retains the wrapper incidentally through the bound reconnector closure, but nothing owns release in non-coroutine tasks. +- `RedisProxy` currently calls `Coroutine::defer()` after successful `multi`, `pipeline`, or `select` even outside a coroutine; native Swoole rejects that call. +- `RedisProxy` dispatches `CommandExecuted` before its release/context-handoff branch inside one `finally`; a throwing listener therefore skips ownership cleanup. `CommandFailed` listener failure already replaces the command failure as current Laravel does, while the wrapper is still released. +- `RedisProxy::shouldUseSameConnection()` omits `watch`, although Redis optimistic transactions require `WATCH`, the intervening reads, `MULTI`, and `EXEC` to use one native client. Native phpredis `getMode()` remains `Redis::ATOMIC` while only watched, so queue-mode detection cannot discover this state. +- `RedisConnection` inherits the pool lifecycle method `discard(): void`. Consequently, `RedisProxy::__call('discard')` never reaches phpredis `DISCARD`; it removes the borrowed wrapper from pool ownership, returns `null`, and leaves the transaction context referring to a destroyed wrapper. Callback-form transactions call `discard()` on the native phpredis object and are unaffected. +- Callback-form Redis transaction/pipeline operations release a newly pinned connection in `MultiExec::executeMultiExec()` and therefore need no terminal double-release. A callback-form transaction that reuses a wrapper pinned by `WATCH` calls native `exec()` directly, so `MultiExec` must clear the wrapper's watch flag after that native call returns successfully. +- `RedisConnection::release()` can send `SELECT` while restoring the configured database. Fork cleanup must discard, not release, inherited sockets. +- `PhpRedisConnection` and `PhpRedisClusterConnection` expose the authoritative native mode through `getMode()`; this is local extension state, not a Redis network command. +- `Pool::discard()` validates exact borrowed ownership. `destroyConnection()` contains native close failure and always removes managed/borrowed bookkeeping and signals capacity. +- `BeforeMainServerStart` runs during first-port setup. `BeforeServerStart` runs per port after its configured callback. Neither is a final post-listener pre-fork boundary. +- `PoolFactory` is an auto-singletoned resolvable concrete for both Database and Redis and can own pools even when the canonical manager key is unresolved. +- SQLite `pragma_database_list` returns an empty main path for in-memory databases and a canonical filesystem path for file-backed URI connections. +- Native SQLite probes confirm lowercase `file:` handling, percent-decoded paths/query values, case-sensitive `mode`, and last-value-wins duplicate `mode` behavior. +- `Filesystem::put()` returns `int|false`; it does not guarantee an exception on write failure. +- In-memory `DbPool` deliberately shares one PDO among wrappers. Capacity greater than one therefore advertises owners that are not physically independent. +- `Mutex` is a class-keyed worker-static `Channel(1)` map. Test cleanup closes Mutex channels before resetting `Model`. +- Hypervel's class-attribute cache stores the attribute object and selects the requested property after lookup, so Laravel change `#60815` does not apply. + +### Approved owner gates + +The owner approved: + +1. additive Core `TaskTerminated`; +2. additive Core/Server `BeforeServerFork`; +3. the per-model Eloquent boot-owner map, one extra static `isset()` per normal model construction, and one retained Mutex channel per model class first booted in a coroutine; +4. one local phpredis `getMode()` call at every successful pooled release; +5. current Laravel's per-key cached-cast merge; +6. current Laravel's indexed raw SQL binding substitution. + +No other planned change adds successful request/query hot-path work. + +The fresh self-review additionally found `redis-06` through `redis-08` in `RedisProxy::__call()`, which this work already modifies. The direct event-cleanup slots, successful-command state update, and release-time boolean read are source-proven hot-path work. They remove a pool-slot leak and restore Redis `WATCH` / `DISCARD` correctness without a registry, wrapper, network check, or transaction abstraction, but still require explicit owner approval under the audit's performance gate before implementation. + +## Implementation order + +Implement in this order so every higher-level consumer lands only after its owner exists: + +1. Core task terminal event and failure precedence. +2. Server final pre-fork event. +3. Database exact task ownership and lifecycle listener. +4. Redis proxy/manager task ownership, command-event cleanup, optimistic-transaction ownership, fork listener, purge identity, and terminal release. +5. Shared SQLite classification and connector/schema routing. +6. In-memory SQLite single-owner pool capacity. +7. Transaction connection state and retry cleanup. +8. Transaction-manager detachment and callback exhaustion. +9. Eloquent boot publication and trait class attributes. +10. Current Query parity. +11. Current Eloquent parity. +12. Current Schema/migration/provider parity. +13. Approved Database performance ports. +14. Split metadata, provenance, deprecated omission markers, and docs. +15. Focused tests, full `composer fix`, fresh self-review, and post-implementation code review. + +### Failure-precedence rule + +Use direct `try/catch` blocks for each fixed set of independent phases: + +```php +$exception = null; + +try { + $firstPhase(); +} catch (Throwable $throwable) { + $exception = $throwable; +} + +try { + $secondPhase(); +} catch (Throwable $throwable) { + $exception ??= $throwable; +} + +if ($exception !== null) { + throw $exception; +} +``` + +Do not allocate closure arrays merely to reuse this shape. Do not extract a generic finalizer or callback executor. + +### Touched-file typing and comments + +- Preserve current Laravel member order in ported files. +- Use native PHP 8.4 types where the inherited public API allows them. +- Retain behavioral upstream comments and adapt only incorrect framework names or runtime assumptions. +- Add short WHY comments only for non-obvious ownership rules: detach-before-I/O, discard-at-fork, native queue-mode terminal discard, inode-preserving SQLite truncation, and the boot-owner publication window. +- Do not annotate ordinary Hypervel adaptations or add architecture prose to user-facing docs. + +## 1. Publish a terminal task lifecycle event + +### Files + +- Add `src/core/src/Events/TaskTerminated.php`. +- Modify `src/core/src/Bootstrap/TaskCallback.php`. +- Modify `tests/Core/Bootstrap/TaskCallbackTest.php`. + +### Event contract + +`TaskTerminated` carries the exact native server and task objects. It is an observational terminal event, not a result carrier: + +```php +class TaskTerminated +{ + /** + * Create a new task terminated event instance. + */ + public function __construct( + public readonly Server $server, + public readonly Task $task, + ) { + } +} +``` + +### Callback choreography + +After constructing the `Task` exactly as today, `TaskCallback::onTask()` has two phases: + +1. dispatch `OnTask`, then finish a non-null result only when that dispatch succeeded; +2. attempt guarded `TaskTerminated` dispatch regardless of action or finish failure. + +The earliest throwable remains primary: + +```php +$exception = null; + +try { + $event = new OnTask($server, $task); + $this->dispatcher->dispatch($event); + + if ($event->result !== null) { + $this->taskUsesObject + ? $task->finish($event->result) + : $server->finish($event->result); + } +} catch (Throwable $throwable) { + $exception = $throwable; +} + +try { + if ($this->dispatcher->hasListeners(TaskTerminated::class)) { + $this->dispatcher->dispatch(new TaskTerminated($server, $task)); + } +} catch (Throwable $throwable) { + $exception ??= $throwable; +} + +if ($exception !== null) { + throw $exception; +} +``` + +Do not attempt `finish()` after `OnTask` failed because no valid task result was published. Do attempt terminal cleanup after `finish()` failed because ownership still ends at this callback. + +### Tests + +Extend `TaskCallbackTest` to prove: + +- legacy and object task signatures carry the same exact `Task` into `TaskTerminated`; +- no terminal event is constructed or dispatched when there are no listeners; +- a normal result is finished before terminal dispatch; +- `OnTask` failure skips finish but not terminal dispatch; +- finish failure does not skip terminal dispatch; +- terminal failure propagates after a successful action/finish; +- action or finish failure remains primary when terminal dispatch also fails. + +Use dispatcher mocks and native task/server doubles; do not add a production testing hook. + +### Cost + +With no consumer registered, every Swoole task adds one cached-false `hasListeners()` lookup. HTTP requests, jobs that do not use Swoole task workers, queries, and model construction are unchanged. + +## 2. Publish one final pre-fork boundary + +### Files + +- Add `src/core/src/Events/BeforeServerFork.php`. +- Modify `src/server/src/Server.php`. +- Modify `tests/Server/ServerTest.php`. + +### Event contract + +The event carries the exact native server and documents the hard boundary: + +```php +class BeforeServerFork +{ + /** + * Create a new before server fork event instance. + * + * Listeners must release parent-only runtime resources and must not open + * sockets, timers, pools, or other resources that child processes could + * inherit. + */ + public function __construct( + public readonly SwooleServer $server, + ) { + } +} +``` + +Dispatch it inside `Server::start()` immediately before the native call: + +```php +public function start(): void +{ + $server = $this->getServer(); + $this->eventDispatcher->dispatch(new BeforeServerFork($server)); + + if ($server->start() === false) { + throw new ServerException('Failed to start the Swoole server.'); + } +} +``` + +This is deliberately one final event. Do not add per-port resets, listener priorities, `OnManagerStart`, `OnStart`, or a cleanup registry. + +### Tests + +Prove: + +- `BeforeServerFork` dispatch precedes native `start()`; +- the event carries the exact native server; +- a listener failure prevents the fork/start attempt; +- native `false` still produces the existing `ServerException` after listeners succeed. + +### Cost + +One event dispatch occurs once per server start. It is outside request, query, Redis command, and worker hot paths. + +## 3. Give non-coroutine Database task leases an exact owner + +### Files + +- Modify `src/database/src/ConnectionResolver.php`. +- Add `src/database/src/Listeners/DatabaseConnectionLifecycleListener.php`. +- Modify `src/database/src/DatabaseServiceProvider.php`. +- Delete `src/database/src/Listeners/UnsetContextInTaskWorkerListener.php`. +- Rename `tests/Database/ConnectionResolverSetDefaultConnectionTest.php` to `tests/Database/ConnectionResolverTest.php` and extend it with pooled lifecycle coverage. +- Add `tests/Database/DatabaseServiceProviderTest.php`. +- Add focused task/fork coverage under `tests/Database/`. + +### Resolver-owned wrapper map + +Add one instance property on the concrete pooled resolver: + +```php +/** + * Pooled wrappers retained by non-coroutine task execution. + * + * @var array + */ +protected array $nonCoroutineConnections = []; +``` + +The key is `ConnectionName::requested`, including `::read` and `::write`; never reconstruct wrappers later from configured base names. + +`connection()` publishes the bare connection and exactly one terminal owner transactionally: + +```php +$pooledConnection = $pool->get(); + +try { + $connection = $pooledConnection->getConnection(); + + if ($connectionName->isWrite() && $connection instanceof Connection) { + $connection->useWriteConnectionWhenReading(); + } + + CoroutineContext::set($contextKey, $connection); + + if (Coroutine::inCoroutine()) { + Coroutine::defer(function () use ($pooledConnection, $contextKey): void { + CoroutineContext::forget($contextKey); + $pooledConnection->release(); + }); + } else { + $this->nonCoroutineConnections[$connectionName->requested] = $pooledConnection; + } +} catch (Throwable $exception) { + CoroutineContext::forget($contextKey); + unset($this->nonCoroutineConnections[$connectionName->requested]); + + try { + $pooledConnection->discard(); + } catch (Throwable) { + // Preserve the connection-creation or publication failure. + } + + throw $exception; +} +``` + +The final implementation must retain these invariants: + +- raw connection creation, role configuration, context publication, and defer/map ownership either all commit or the exact wrapper is discarded; +- coroutine cleanup uses `forget()`, not a persistent null slot; +- only non-coroutine borrows enter the instance map; +- no lock or second context map is added because non-coroutine tasks execute sequentially in one task worker. + +### Terminal helpers + +Add concrete `@internal` methods only to `ConnectionResolver`; do not change `ConnectionResolverInterface` or `FlushableConnectionResolver`. + +```php +public function releaseConnections(): void +{ + $this->terminateConnections( + static fn (PooledConnection $connection): void => $connection->release(), + ); +} + +public function discardConnections(): void +{ + $this->terminateConnections( + static fn (PooledConnection $connection): void => $connection->discard(), + ); +} +``` + +The shared protected helper is justified because both public lifecycle operations must perform the same non-trivial detach-before-I/O transaction. It must: + +1. snapshot `$nonCoroutineConnections`; +2. clear the property; +3. forget every exact requested-name connection key; +4. forget `DEFAULT_CONNECTION_CONTEXT_KEY`; +5. only then release or discard every wrapper; +6. continue after each failure and throw the earliest one. + +Do not use `Closure::call()`, reflection, configured connection lists, a public raw context key, or a generic cleanup registry. + +### Database lifecycle listener + +Use one stateless listener resolved from the container and inject `Hypervel\Contracts\Container\Container` as `ContainerContract`: + +```php +class DatabaseConnectionLifecycleListener +{ + public function __construct( + protected ContainerContract $container, + ) { + } + + public function releaseTaskConnections(): void + { + if (! $this->container->resolved('db.resolver')) { + return; + } + + $resolver = $this->container->make('db.resolver'); + + if ($resolver instanceof ConnectionResolver) { + $resolver->releaseConnections(); + } + } + + public function discardProcessConnections(): void + { + // Resolve neither owner merely to clean it. Treat resolver discard and + // pool-factory flush as independent phases and preserve the first failure. + } +} +``` + +`discardProcessConnections()` independently checks: + +- canonical `'db.resolver'`: if resolved and concrete pooled resolver, call `discardConnections()`; +- Database `PoolFactory::class`: if resolved, call `flushAll()`. + +The resolver discard and factory flush both run when the first fails. A custom resolver is left alone because this internal lifecycle belongs to the pooled concrete. `SimpleConnectionResolver`, Capsule, and `DatabaseManager::$connections` remain unchanged. + +### Provider registration + +In `DatabaseServiceProvider::boot()`: + +- keep installing Model's resolver and dispatcher; +- register the `TaskTerminated` listener only when `server.settings.task_enable_coroutine` is false; +- register `BeforeServerFork` and `BeforeWorkerStart` unconditionally; +- resolve `DatabaseConnectionLifecycleListener` from the container inside each event closure. + +There is no runtime `Coroutine::inCoroutine()` check in the task listener. Registration already proves non-coroutine task mode, and the resolver map cannot contain coroutine-owned wrappers. + +### Tests + +Cover: + +- one non-coroutine borrow retained until terminal release; +- multiple requested names and read/write role suffixes; +- context/default override detached before any release callback can re-enter; +- every wrapper released despite one failure, with earliest failure preserved; +- discard path uses exact wrappers and exhausts failures; +- setup failure at connection retrieval, role configuration, context publication, and defer registration discards exactly once and preserves the primary failure; +- coroutine borrows remain defer-owned and never enter terminal task cleanup; +- provider task registration only when task coroutines are disabled; +- unresolved resolver/factory stay unresolved at lifecycle boundaries; +- resolved custom resolver is not forced through pooled internals; +- process cleanup still flushes an independently resolved pool factory after resolver discard failure. + +Delete every reflective-listener test and stale configured-base-key assertion rather than preserving the superseded model. + +### Rejected adjacent concern + +A custom non-coroutine daemon process can hold one resolver connection for its loop lifetime, but it has no task boundary and that lifetime matches ordinary daemon connection reuse. Do not invent a process-operation cleanup hook. + +## 4. Give Redis task and process leases exact proxy-owned cleanup + +### Files + +- Modify `src/redis/src/RedisProxy.php`. +- Modify `src/redis/src/RedisManager.php`. +- Modify `src/redis/src/RedisConnection.php`. +- Modify `src/redis/src/Traits/MultiExec.php`. +- Add `src/redis/src/Listeners/RedisConnectionLifecycleListener.php`. +- Modify `src/redis/src/RedisServiceProvider.php`. +- Modify `tests/Redis/RedisProxyTest.php`. +- Modify `tests/Redis/RedisManagerTest.php`. +- Modify `tests/Redis/RedisConnectionTest.php`. +- Modify `tests/Redis/RedisServiceProviderTest.php`. +- Add or extend external Redis integration tests using `InteractsWithRedis`. + +### Gate defer only where same-connection state is published + +After a successful `multi`, `pipeline`, `select`, or `watch`, retain the connection in context exactly as today, but install defer only in a coroutine: + +```php +CoroutineContext::set($this->getContextKey(), $connection); + +if (Coroutine::inCoroutine()) { + Coroutine::defer(function (): void { + $this->releaseContextConnection(); + }); +} +``` + +This branch runs only after successful same-connection commands. Ordinary Redis commands gain no coroutine check. + +### Complete command events before ownership cleanup + +Keep command events before release or context handoff so a listener cannot observe a wrapper that another coroutine has already borrowed. Contain a throwing listener long enough to complete ownership cleanup, then preserve the existing Laravel-facing failure contract: + +- a throwing `CommandExecuted` listener remains the primary failure after a successful command; +- a throwing `CommandFailed` listener continues to replace the command failure, matching current Laravel and existing Hypervel behavior; +- without a listener failure, the command failure remains primary over later cleanup failure; +- cleanup failure propagates when command and event handling succeeded. + +Use direct local throwable slots around the existing fixed phases. Do not extract an event executor or generic finalizer: + +```php +$commandException = null; +$eventException = null; +$cleanupException = null; + +try { + /** @var RedisConnection $connection */ + $connection = $connection->getConnection(); + $result = $connection->{$name}(...$arguments); +} catch (Throwable $throwable) { + $commandException = $throwable; + + try { + // Dispatch CommandFailed when it has listeners. + } catch (Throwable $throwable) { + $eventException = $throwable; + } +} + +if ($commandException === null) { + try { + // Dispatch CommandExecuted when it has listeners. + } catch (Throwable $throwable) { + $eventException = $throwable; + } +} + +try { + // Preserve the existing release or same-connection handoff decision. +} catch (Throwable $throwable) { + $cleanupException = $throwable; +} + +if ($eventException !== null) { + throw $eventException; +} + +if ($commandException !== null) { + throw $commandException; +} + +if ($cleanupException !== null) { + throw $cleanupException; +} + +return $result; +``` + +Successful commands still determine handoff from their command name even when the observational event fails; an event failure must not turn a successful `MULTI`, `PIPELINE`, `SELECT`, or `WATCH` into an ordinary release. + +### Preserve optimistic-transaction ownership + +Add `watch` to the same-connection command set. Track the native watch state on `RedisConnection`, which owns the exact client generation: + +```php +protected bool $watching = false; +``` + +After either the first native attempt or a retry succeeds, update only the states phpredis does not expose directly in `RedisConnection::__call()`: + +```php +if ($name === 'watch' && $result !== false) { + $this->watching = true; +} elseif ( + in_array($name, ['exec', 'discard', 'reset'], true) + || ($name === 'unwatch' && $result !== false) +) { + $this->watching = false; +} +``` + +Reset the flag when a new native generation is connected and when the connection is closed. Do not extract this one-caller branch into a helper. Do not infer state from failed `WATCH`/`UNWATCH` results, and do not attempt to model arbitrary `executeRaw()` or direct-native-client escape hatches. + +This one boolean is required because phpredis exposes MULTI/PIPELINE through `getMode()` but exposes no watched-state getter. It is not a second queue-mode model and adds no Redis command. Do not add a transaction object, context payload wrapper, command registry, or always-issued `UNWATCH`. + +### Settle callback-form transaction watch state at its owner + +`MultiExec::executeMultiExec()` calls native phpredis `exec()` directly after the callback. That bypasses `RedisConnection::__call()`, so a wrapper pinned by `WATCH` would otherwise retain a stale `watching=true` flag after the server consumed the watch. + +Add one concrete cross-owner method to `RedisConnection`: + +```php +/** + * Clear the tracked watch state after callback-form transaction completion. + * + * @internal + */ +public function clearWatchState(): void +{ + $this->watching = false; +} +``` + +Add `clearWatchState` to `RedisProxy::CONNECTION_BOUND_METHODS` so it cannot be invoked as a Laravel-facing Redis command. The trait calls it only on the concrete wrapper already retained in context. + +After callback-form `transaction()` reaches a non-throwing native `exec()` return, including `false` from an optimistic-lock conflict, clear the existing pinned wrapper: + +```php +$result = tap($instance, $callback)->exec(); + +if ($command === 'multi' && $hasExistingConnection) { + $connection = CoroutineContext::get($this->getContextKey()); + + if ($connection instanceof RedisConnection) { + $connection->clearWatchState(); + } +} + +return $result; +``` + +Limit this settlement to `multi`: callback-form pipeline execution does not consume a prior watch. Do not clear when native `exec()` throws; its terminal state is unknown, so release must conservatively discard the wrapper. A callback that issues native `discard()` is covered when the trait's final `exec()` returns without throwing. Do not route callback-form `exec()` through `RedisProxy::__call()`, because that would add command events where none are dispatched today. + +### Route native DISCARD around the pool lifecycle collision + +Keep Pool's public `RedisConnection::discard(): void` ownership method unchanged. Add a distinct concrete `@internal` wrapper method for the native Redis command: + +```php +public function discardTransaction(): bool|Redis +{ + $result = $this->connection->discard(); + + if ($result !== false) { + $this->watching = false; + } + + return $result; +} +``` + +Add `discardTransaction` to `RedisProxy::CONNECTION_BOUND_METHODS` so callers cannot accidentally proxy the internal name. In the existing command invocation branch, route only `$name === 'discard'` to `discardTransaction()`; every other Laravel command retains normal magic dispatch: + +```php +$result = $name === 'discard' + ? $connection->discardTransaction() + : $connection->{$name}(...$arguments); +``` + +This restores the documented Laravel-facing `Redis::discard()` command without renaming Pool's lifecycle API or changing its contract. Do not route it through `executeRaw()`, which would add another dynamic transformation path while already inside native queueing mode. Do not add a compatibility alias or change callback-form transaction behavior; callback transactions already receive the native client and call its native `discard()` directly. + +### Proxy-owned terminal methods + +Promote the existing release method and add its symmetric discard method as public `@internal` framework APIs: + +```php +public function releaseContextConnection(): void +{ + $contextKey = $this->getContextKey(); + $connection = CoroutineContext::get($contextKey); + + CoroutineContext::forget($contextKey); + + if ($connection instanceof RedisConnection) { + $connection->release(); + } +} + +public function discardContextConnection(): void +{ + $contextKey = $this->getContextKey(); + $connection = CoroutineContext::get($contextKey); + + CoroutineContext::forget($contextKey); + + if ($connection instanceof RedisConnection) { + $connection->discard(); + } +} +``` + +The proxy is the only owner of its actual pool/context identity. Do not expose the key or pooled connection and do not add these internal methods to Laravel-facing Redis contracts. + +Update `withPinnedConnection()` and any sibling terminal path touched by this work to forget, rather than store null, when ownership ends. Keep callback-form `MultiExec` release ownership unchanged; only settle the watched-state flag at its direct native-`exec()` boundary. + +### Manager aggregation + +Add concrete `@internal` `releaseConnections()` and `discardConnections()` methods that iterate already-created proxies, continue after each failure, and throw the earliest failure. They do not create proxies, flush pools, or alter the Redis contracts. + +Correct `purge($managerName)`: + +```php +$proxy = $this->connections[$managerName] ?? null; +unset($this->connections[$managerName]); + +$poolName = $proxy?->getName() ?? $managerName; +$exception = null; + +if ($proxy !== null) { + try { + $proxy->discardContextConnection(); + } catch (Throwable $throwable) { + $exception = $throwable; + } +} + +try { + $this->factory->flushPool($poolName); +} catch (Throwable $throwable) { + $exception ??= $throwable; +} + +if ($exception !== null) { + throw $exception; +} +``` + +Discard and pool flush are independent cleanup phases with earliest failure. When a custom creator returns a proxy with a different pool name, both context cleanup and pool destruction use the proxy's real identity. With no cached proxy, the manager name remains the correct pool fallback because `extend()` and `forgetExtension()` can remove only the proxy cache. + +### Redis lifecycle listener + +One stateless listener mirrors Database: + +- task termination: if canonical `'redis'` is resolved and is `RedisManager`, call `releaseConnections()`; do not flush pools; +- pre-fork and child start: independently discard resolved manager proxies and flush resolved Redis `PoolFactory`; +- never resolve either owner merely to clean it; +- preserve the earliest failure across manager and factory phases. + +Register the task listener only when task coroutines are disabled. Register `BeforeServerFork` and `BeforeWorkerStart` unconditionally. + +Add direct `hypervel/core` to the Redis split manifest because the provider now imports Core lifecycle events. + +### Tests + +Cover: + +- successful non-coroutine `select`, `multi`, and `pipeline` return their native result rather than throwing from defer registration; +- successful non-coroutine `watch` pins the exact wrapper without attempting coroutine defer; +- `Redis::discard()` invokes native DISCARD, returns its native result, clears native transaction/watch state, and does not discard the pool wrapper; +- raw same-connection commands remain pinned through one non-coroutine task and release at termination; +- callback-form transaction/pipeline releases a newly pinned wrapper in its own `finally` and terminal cleanup is a no-op; +- `WATCH` followed by callback-form `transaction()` reuses the pinned wrapper, clears tracked watch state after any non-throwing native `exec()` result, requeues the healthy wrapper, and emits no false WATCH-state critical log; +- both a successful result and `exec() === false` consume the tracked watch, while a thrown `exec()` leaves it set so terminal release discards the unknown generation; +- callback-form pipeline execution does not clear a prior watch; +- multiple proxies are exhausted and the earliest release/discard failure wins; +- a divergent manager name/proxy pool name proves task cleanup uses proxy context identity; +- the same divergent proxy proves `purge()` discards its context and flushes its real pool; +- purge discards rather than releases before pool destruction; +- unresolved canonical manager and independently unresolved PoolFactory remain unresolved; +- manager cleanup failure does not skip factory flush; +- coroutine cleanup remains defer-owned; +- selected database is restored before normal task reuse; +- parent pre-fork and child-start cleanup both discard pinned wrappers and flush already-resolved pools. +- a throwing success listener cannot leak an ordinary borrowed wrapper or skip successful same-connection handoff; +- a throwing failure listener cannot skip release and retains the existing Laravel failure precedence; +- cleanup failure remains observable without replacing the command failure when no event listener failed; +- `WATCH`, intervening reads, `MULTI`, and `EXEC` use the same native client; +- successful `UNWATCH`, `EXEC`, and `DISCARD` clear the tracked watch state, while a new generation begins unwatched. + +External Redis behavior tests must use the existing per-ParaTest-worker Redis isolation trait. + +## 5. Never requeue a native Redis client in queueing or watched state + +### Files + +- Modify `src/redis/src/RedisConnection.php`. +- Modify `tests/Redis/RedisConnectionTest.php`. +- Extend real phpredis integration coverage. + +### Release order + +Queue-mode detection and the locally tracked watch state are checked before database restoration. `SELECT` issued inside MULTI would itself be queued, while a watched client must not become visible to another borrower: + +```php +public function release(): void +{ + $this->shouldTransform = false; + + try { + $queueing = $this->isQueueingMode(); + } catch (Throwable $exception) { + $this->markInvalid(); + + try { + $this->log('Release connection failed, caused by ' . $exception, LogLevel::CRITICAL); + } catch (Throwable) { + // Reporting must not prevent terminal ownership cleanup. + } + + $this->database = null; + $this->availableForReuse = true; + parent::release(); + + return; + } + + if ($queueing || $this->watching) { + try { + $this->log( + $queueing + ? 'Discarding Redis connection left in MULTI or PIPELINE mode.' + : 'Discarding Redis connection left in WATCH state.', + LogLevel::CRITICAL + ); + } catch (Throwable) { + // Reporting must not prevent terminal ownership cleanup. + } + + $this->database = null; + $this->watching = false; + $this->availableForReuse = false; + $this->discard(); + + return; + } + + try { + $defaultDatabase = (int) ($this->config['database'] ?? 0); + + if ($this->database !== null && $this->database !== $defaultDatabase) { + $this->select($defaultDatabase); + } + } catch (Throwable $exception) { + $this->markInvalid(); + + try { + $this->log('Release connection failed, caused by ' . $exception, LogLevel::CRITICAL); + } catch (Throwable) { + // Reporting must not prevent terminal ownership cleanup. + } + } finally { + $this->database = null; + $this->watching = false; + $this->availableForReuse = true; + parent::release(); + } +} +``` + +The final code must avoid calling `parent::release()` after queue-mode discard. Best-effort reporting must be contained so logger failure cannot prevent discard. Mode-detection or ordinary database-restore failure retains the existing invalidate-and-parent-release path, allowing Pool reuse to replace the invalid generation. Keep this flow inline: extracting a single-use reporting or finalization helper would add indirection without sharing any real policy. + +Do not add a PHP-side queue-mode mirror. The native client remains authoritative for MULTI/PIPELINE state. The one watch boolean covers only the missing phpredis observation and is cleared by successful terminal commands, reconnect, close, and release. + +Do not add a mark-invalid fallback around a failed `discard()`. An escaping discard failure is an ownership invariant violation; Pool already contains native close failure and repairs bookkeeping. A flag cannot repair a bad ownership assertion. + +### Tests + +Prove with real phpredis behavior: + +- abandoned raw MULTI via coroutine defer discards the generation; +- abandoned raw MULTI via non-coroutine task termination discards the generation; +- abandoned pipeline follows the same path; +- `WATCH` remains on the same wrapper through intervening reads and `MULTI`/`EXEC`; +- successful `UNWATCH`, `EXEC`, and `DISCARD` permit ordinary reuse; +- an abandoned `WATCH` discards the generation without issuing cleanup commands on behalf of the next borrower; +- the next borrower is a fresh ATOMIC-mode client and commands execute normally; +- mode detection occurs before any selected-database restore; +- mode-check failure and database-restore failure retain invalidation behavior; +- reporting failure cannot prevent queue-mode discard; +- discard ownership failure propagates rather than being masked or double-released. + +### Cost + +Every successful phpredis pooled release gains one native local `getMode()` call and one boolean read. Neither performs Redis I/O. The base class remains a constant false queue-mode check for non-phpredis implementations. Watch-state writes occur only around optimistic transactions. These small unavoidable successful-hot-path costs are the minimum required to keep native client state private to one borrower. + +## 6. Normalize SQLite URI and in-memory classification + +### Files + +- Add `src/database/src/SQLiteDatabase.php`. +- Modify `src/database/src/Pool/DbPool.php`. +- Modify `src/database/src/Connectors/SQLiteConnector.php`. +- Modify `src/database/src/Schema/SqliteSchemaState.php`. +- Modify `src/database/src/Schema/SQLiteBuilder.php`. +- Add `tests/Database/SQLiteDatabaseTest.php`. +- Modify SQLite connector, pool, builder, and schema-state tests. + +### Internal classifier + +Use one small internal class rather than a trait or connector-owned helper: + +```php +class SQLiteDatabase +{ + /** + * Determine if the database name is a SQLite URI. + */ + public static function isUri(string $database): bool + { + return str_starts_with($database, 'file:'); + } + + /** + * Determine if the database name resolves to an in-memory database. + */ + public static function isInMemory(string $database): bool + { + if ($database === ':memory:') { + return true; + } + + if (! static::isUri($database)) { + return false; + } + + [$path, $query] = array_pad( + explode('?', substr($database, strlen('file:')), 2), + 2, + null, + ); + + if (rawurldecode($path) === ':memory:') { + return true; + } + + parse_str($query ?? '', $parameters); + + return ($parameters['mode'] ?? null) === 'memory'; + } +} +``` + +Before implementation, rerun the classifier matrix against the exact PHP/SQLite versions in the development environment. If native behavior has changed, stop and resolve the discrepancy rather than adding guesses. + +The required matrix includes: + +- literal `:memory:`; +- `file::memory:` and `file::memory:?cache=shared`; +- `file:?mode=memory`; +- named `file:name?mode=memory`; +- percent-encoded `:memory:` paths and `mode=memory` values; +- ordinary `file:/abs`, `file:///abs`, and query-bearing file paths; +- uppercase URI prefixes, uppercase mode keys, mixed-case mode values, and duplicate mode parameters. + +Lowercase exactness and last duplicate value must match the native implementation. + +### Connector and schema state + +`SQLiteConnector::parseDatabasePath()`: + +1. returns every `SQLiteDatabase::isInMemory()` form unchanged; +2. returns every lowercase `file:` URI unchanged; +3. otherwise resolves a direct path; +4. only calls `base_path()` when the helper exists; +5. throws the existing named exception when no path resolves. + +`SqliteSchemaState::load()` uses the classifier for the in-process PDO path and passes any file URI unchanged to the sqlite CLI environment. + +### Canonical file refresh + +`SQLiteBuilder::dropAllTables()` derives the active schema's canonical path from `getSchemas()` / `pragma_database_list`, not `Connection::getDatabaseName()`. An empty path is the in-memory signal: + +```php +$databases = array_column($this->getSchemas(), 'path', 'name'); +$database = $databases[$schema] ?? null; + +if (is_string($database) && $database !== '') { + $this->refreshDatabaseFile($database); +} else { + // Existing writable_schema/rebuild path for memory. +} +``` + +Keep inode-preserving truncation because the live PDO remains attached to that inode: + +```php +public function refreshDatabaseFile(?string $path = null): void +{ + $path ??= $this->connection->getDatabaseName(); + + if (File::put($path, '') === false) { + throw new RuntimeException("Unable to refresh SQLite database file [{$path}]."); + } +} +``` + +Do not atomically replace this file; that would leave the live PDO connected to the old unlinked inode. + +### Tests + +Cover the full classifier matrix, connector standalone use without `base_path()`, valid file URI acceptance, nonexistent plain paths, CLI routing, canonical URI path refresh, memory rebuild, and a checked `Filesystem::put()` false result. + +Use `ParallelTesting::tempDir()` for every database file. Do not write fixtures into the committed test tree. + +## 7. Give one in-memory SQLite PDO one pool owner + +### Files + +- Modify `src/database/src/Pool/DbPool.php`. +- Modify `tests/Integration/Database/Sqlite/InMemorySqliteSharedPdoTest.php`. +- Modify `tests/Integration/Database/Sqlite/PoolConnectionManagementTest.php`. +- Revalidate every SQLite pool test that borrows more than one wrapper. + +### Capacity normalization + +After extracting pool options and classifying through `SQLiteDatabase`, normalize only configurations that are already valid integer count pairs: + +```php +$minimum = $poolOptions['min_connections'] ?? 1; +$maximum = $poolOptions['max_connections'] ?? 10; + +if ($this->isInMemorySqlite() + && is_int($minimum) + && is_int($maximum) + && $minimum >= 0 + && $maximum >= 1 + && $minimum <= $maximum +) { + $poolOptions['min_connections'] = min($minimum, 1); + $poolOptions['max_connections'] = 1; +} +``` + +Invalid types, negative minimums, zero maximums, and invalid minimum/maximum relationships pass through unchanged so `PoolOption` remains the authoritative validator and produces the existing failure. + +Use `SQLiteDatabase::isInMemory()` in: + +- pool capacity normalization; +- shared PDO construction; +- derived-read in-memory rejection. + +Delete the duplicated substring checks. + +### Ownership behavior + +The existing pool channel supplies all required serialization: + +```text +one shared PDO + └── one managed PooledConnection + └── one borrower at a time +``` + +Do not add another Mutex, shared-cache URI, wrapper coordinator, connection registry, or driver-specific wait path. + +### Tests + +Replace any test that treats multiple wrappers as independent owners with deterministic one-owner behavior: + +- first borrow holds the only slot; +- a second coroutine waits through the existing pool wait boundary; +- release or discard transfers capacity; +- state is visible through the same shared PDO after reuse; +- pool close and persistence tests release their first wrapper before another borrow; +- every valid URI memory form receives maximum one; +- ordinary file-backed SQLite retains configured capacity; +- invalid configurations still fail through `PoolOption`. + +Tests that intentionally exercise waiting must set a bounded pool `wait_timeout` and use a deterministic channel/barrier. Never leave a test waiting indefinitely. + +### Cost + +Normalization occurs once at pool construction. Borrow, release, and query execution gain no extra branch or synchronization; the existing channel simply has capacity one. + +## 8. Make physical and logical transaction state truthful + +### Files + +- Modify `src/database/src/Concerns/ManagesTransactions.php`. +- Modify `src/database/src/Connection.php`. +- Modify `tests/Database/DatabaseConnectionTest.php`. +- Modify `tests/Database/DatabaseTransactionsTest.php`. +- Modify supported-driver integration transaction tests. + +### Transaction phase model + +Keep the current public APIs. The fixed phases are: + +```text +begin: + before callbacks + physical begin/savepoint + logical increment + manager record + began event + +commit through transaction(): + user callback + committing event + physical commit + logical decrement + manager commit/callbacks + committed event + +rollback: + physical rollback/savepoint rollback + logical level change + manager detach/callbacks + rolled-back event + +disconnect: + physical rollback when still possible + logical terminal reset + manager detach/callbacks + PDO references cleared +``` + +Do not introduce a transaction state enum, state machine, finalizer object, retry framework, ambiguity object, or callback registry. + +### Begin is atomic after physical creation + +`beforeStartingTransaction` callbacks remain before any physical transaction. Once `createTransaction()` succeeds: + +```php +$previousLevel = $this->transactions; +$this->transactions++; + +try { + $this->transactionsManager?->begin($this->getName(), $this->transactions); + $this->fireConnectionEvent('beganTransaction'); +} catch (Throwable $exception) { + try { + $this->rollBack($previousLevel); + } catch (Throwable) { + // Preserve the publication failure. + } + + throw $exception; +} +``` + +This prevents a manager/event failure from leaving an unowned physical transaction. Pre-publication cleanup failure cannot replace the original failure. + +### Separate pre-commit and physical-commit failures + +Inside `transaction()`: + +- user callback and `committing` listener failures are pre-commit failures and use the normal rollback/retry path; +- only `performCommit()` failures enter `handleCommitTransactionException()`; +- decrement logical state only after physical commit succeeds. + +The intended skeleton is: + +```php +try { + $callbackResult = $callback($this); + + if ($this->transactions === 1) { + $this->fireConnectionEvent('committing'); + } +} catch (Throwable $exception) { + $this->handleTransactionException($exception, $currentAttempt, $attempts); + + continue; +} + +$levelBeingCommitted = $this->transactions; + +if ($this->transactions === 1) { + try { + $this->performCommit(); + } catch (Throwable $exception) { + $this->handleCommitTransactionException( + $exception, + $currentAttempt, + $attempts, + ); + + continue; + } +} + +$this->transactions = max(0, $this->transactions - 1); +``` + +`handleCommitTransactionException()` runs while the logical level still describes the active transaction: + +1. a lost connection terminally detaches logical, manager, and PDO state without attempting another physical rollback; +2. another commit failure attempts `rollBack(0)`; +3. cleanup failure is contained so the commit failure remains primary; +4. a concurrency retry is allowed only when cleanup completed successfully and attempts remain; +5. every other path rethrows the original commit failure. + +This includes current Laravel's physical cleanup before deadlock retry and improves it by never retrying after failed cleanup. + +### Explicit `commit()` remains caller-owned before physical success + +Do not route explicit `commit()` through the retry handler. If its `committing` listener or physical commit throws: + +- do not decrement the logical level; +- do not detach manager records; +- let the caller or pool terminal cleanup decide what to do. + +After a successful root physical commit, decrement before manager callbacks. If manager after-commit callbacks throw, the transaction is already committed and detached; still attempt `TransactionCommitted`, preserve callback failure, and never pretend the physical commit can be rolled back. + +Nested explicit commits retain existing event behavior and member order. + +### Rollback publishes only after physical success + +`rollBack()` must: + +1. validate the requested target; +2. attempt physical rollback/savepoint rollback; +3. on success, invalidate memoized session configuration and publish the new logical level; +4. independently run manager rollback callbacks and `TransactionRolledBack`; +5. preserve the earliest manager/event failure. + +If physical rollback fails: + +- non-lost failure keeps the old logical level and manager records, marks the session unknown, and rethrows; +- lost failure sets level zero, detaches manager state, clears PDO/read PDO without retrying physical rollback, and rethrows the physical failure; +- do not fire the rolled-back event because no successful physical rollback was observed. + +After physical success, manager and event are independent terminal phases: + +```php +$this->transactions = $toLevel; +$exception = null; + +try { + $this->transactionsManager?->rollback($this->getName(), $toLevel); +} catch (Throwable $throwable) { + $exception = $throwable; +} + +try { + $this->fireConnectionEvent('rollingBack'); +} catch (Throwable $throwable) { + $exception ??= $throwable; +} + +if ($exception !== null) { + throw $exception; +} +``` + +### Disconnect is terminal and exhaustive + +`Connection::disconnect()` must independently: + +- attempt physical rollback when a PDO still reports an active transaction; +- publish logical level zero; +- ask the transaction manager to detach all records even when the logical counter was already zero; +- clear write and read PDO references unconditionally; +- preserve the earliest physical or manager/callback failure. + +The PDO references are cleared in a terminal `finally`-equivalent boundary. Physical rollback failure marks the old physical session unknown before dropping it. Do not retry physical rollback after a lost-connection failure. + +Use a small protected helper only if the commit-lost and rollback-lost paths otherwise duplicate the same non-trivial manager/PDO terminal detach. It may not hide physical I/O or become a generic finalizer. + +### Restore query logging in `finally` + +`withFreshQueryLog()` restores the exact prior flag even when its callback throws: + +```php +$loggingQueries = $this->loggingQueries; +$this->enableQueryLog(); +$this->queryLog = []; + +try { + return $callback(); +} finally { + $this->loggingQueries = $loggingQueries; +} +``` + +### Tests + +Build focused failure injection for: + +- before-start callback failure before physical begin; +- manager begin and began-event failure after physical begin; +- rollback cleanup failure preserving begin failure; +- user callback failure; +- throwing `TransactionCommitting` listener, including the real old pooled-corruption reproduction; +- physical commit concurrency failure with successful cleanup and retry; +- physical commit cleanup failure preventing retry while preserving the commit failure; +- lost commit terminal detachment and PDO clearing; +- explicit committing-listener and physical-commit failures retaining active caller-owned state; +- after-commit callback failure still attempting `TransactionCommitted`; +- root and savepoint physical rollback failure; +- lost rollback terminal detachment without a second physical rollback; +- manager rollback failure still attempting the event; +- event failure after successful manager cleanup; +- disconnect physical failure, callback failure, PDO clearing, and earliest throwable; +- `withFreshQueryLog()` success and exception restoration; +- pooled release after every failure class, proving no false reusable state. + +Use real SQLite transactions for physical-state assertions and mocks/subclasses only at existing protected seams for deterministic failure injection. + +## 9. Detach transaction-manager records before rollback callbacks + +### Files + +- Modify `src/database/src/DatabaseTransactionsManager.php`. +- Modify `src/database/src/DatabaseTransactionRecord.php`. +- Modify `tests/Database/DatabaseTransactionsManagerTest.php`. +- Revalidate integration after-commit/rollback tests. + +### Full rollback + +For a connection rolling back to level zero: + +1. collect every committed record for that connection; +2. collect the current parent chain; +3. clear current, pending, and committed manager state for that connection; +4. sort the detached records deepest-first; +5. execute all rollback callbacks, continuing after failure; +6. throw the earliest callback failure. + +The essential order is: + +```php +$transactions = $committedForConnection->concat($currentChain) + ->uniqueStrict() + ->sortByDesc(static fn (DatabaseTransactionRecord $record): int => $record->level) + ->values(); + +// Detach all manager state before invoking user code. +$this->setCurrentTransactionForConnection($connection, null); +$this->setPendingTransactions($pendingForOtherConnections); +$this->setCommittedTransactions($committedForOtherConnections); + +$this->executeRollbackCallbacks($transactions); +``` + +Normal commit choreography leaves the committed set and current pending ancestry disjoint. However, the public and directly tested `stageTransactions()` method can be invoked independently: it moves a record from pending to committed without advancing the current pointer. A later full rollback or disconnect then gathers that same record from both sets. Retain `uniqueStrict()` so its rollback callbacks execute once by object identity; this is a concrete upstream-reachable state, not defensive deduplication. + +### Partial rollback + +For a target above zero: + +- detach pending/current records strictly above the target; +- recursively detach committed descendants of those records; +- do not detach outer committed siblings; +- publish the surviving current record before callbacks; +- sort all detached records deepest-first and exhaust their rollback callbacks. + +A protected recursive helper that removes and returns committed descendants is justified because this is a real tree operation. It must not invoke callbacks during recursion. + +### Callback behavior + +`DatabaseTransactionRecord::executeCallbacksForRollback()` exhausts its fixed callback list: + +```php +$exception = null; + +foreach ($this->callbacksForRollback as $callback) { + try { + $callback(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } +} + +if ($exception !== null) { + throw $exception; +} +``` + +The manager similarly continues across every detached record. + +Keep after-commit callback stop-on-first behavior. Current Laravel explicitly tests that contract; rollback callbacks are cleanup and need exhaustive semantics. Do not unify both through a generic executor. + +### Tests + +Cover: + +- full rollback current chain plus committed descendants; +- partial rollback above target; +- deeply nested committed descendants; +- outer committed siblings retained; +- manager collections and current pointer already detached when a callback re-enters; +- failure of the first callback does not skip later callbacks in the record or deeper/shallower records; +- deepest-first order; +- earliest callback failure; +- commit callbacks remain stop-on-first; +- multiple connection names remain isolated in coroutine context. + +## 10. Serialize first Eloquent model boot until publication completes + +### Files + +- Modify `src/database/src/Eloquent/Model.php`. +- Modify `tests/Database/DatabaseEloquentModelTest.php`. +- Modify or add focused coroutine boot tests under `tests/Database/Eloquent/`. +- Modify `tests/Database/DatabaseEloquentModelAttributesTest.php`. + +### Owner state + +Add the current owner per model class: + +```php +/** + * The coroutine currently booting each model. + * + * @var array, int> + */ +protected static array $booting = []; +``` + +The integer is `Coroutine::id()`, where `-1` is the existing non-coroutine sentinel. + +### Fast path and recursive ownership + +The normal already-booted path avoids a coroutine-ID lookup: + +```php +$class = static::class; + +if (isset(static::$booted[$class]) && ! isset(static::$booting[$class])) { + return; +} + +$coroutineId = Coroutine::id(); + +if ((static::$booting[$class] ?? null) === $coroutineId) { + if (isset(static::$booted[$class])) { + return; + } + + throw new LogicException( + 'The [' . __METHOD__ . '] method may not be called on model [' . $class . '] while it is being booted.', + ); +} +``` + +Before publication, same-owner recursion throws current Laravel's named error. After `$booted` is published, same-owner construction from `booted()` or `whenBooted()` callbacks returns normally. + +### First-boot lock and publication + +Only coroutine first boot acquires the existing Mutex: + +```php +$mutexKey = 'database.model.booting.' . $class; +$locked = false; + +if ($coroutineId >= 0) { + if (! Mutex::lock($mutexKey)) { + throw new RuntimeException("Unable to acquire the model boot lock for [{$class}]."); + } + + $locked = true; +} + +try { + // Recheck booted and owner state after waiting. + // Publish this coroutine as owner. + // Run booting event, booting(), and boot(). + // Set $booted[$class] = true. + // Retain ownership through booted(), whenBooted callbacks, and booted event. +} finally { + if ((static::$booting[$class] ?? null) === $coroutineId) { + unset(static::$booting[$class]); + } + + if ($locked) { + Mutex::unlock($mutexKey); + } +} +``` + +The recheck after lock acquisition must return for a sibling whose predecessor completed. + +Publication order: + +1. booting event; +2. `booting()`; +3. `boot()`; +4. set `$booted[$class] = true`; +5. `booted()`; +6. registered `whenBooted()` callbacks; +7. booted event; +8. clear owner and unlock. + +Pre-publication failure clears the owner and leaves the model unbooted so a later attempt retries. Post-publication failure leaves `$booted=true`, matching Laravel, while still clearing owner/unlocking. Do not attempt to reverse arbitrary user hook side effects. + +### Reset behavior + +`clearBootedModels()` and `flushState()` clear `$booting`. They do not close live Mutex channels. `AfterEachTestSubscriber` already flushes Mutex before Model. + +Retain one Mutex channel for each model class whose first boot occurs in a coroutine. A model first booted outside a coroutine never creates a Mutex channel. Removing a created channel after boot would race waiters and require more synchronization. + +### Trait-carried class attributes + +Port current Laravel `#60566` by checking the traits of each reflected model/parent class before ascending: + +```php +foreach ($reflection->getTraits() as $trait) { + $attributes = $trait->getAttributes($attributeClass); + + if ($attributes !== []) { + $instance = $attributes[0]->newInstance(); + break 2; + } +} +``` + +Keep Hypervel's cache key as `class@attributeClass` and continue selecting `$property` after retrieving the cached attribute object. Do not port Laravel `#60815`; its property-key collision cannot occur in this cache shape. + +### Tests + +Cover: + +- same-owner recursion before publication throws; +- same-owner construction in `booted()` and `whenBooted()` succeeds; +- two sibling coroutines deterministically interleave and the second waits instead of observing incomplete state or throwing; +- pre-publication hook failure allows a later retry; +- post-publication hook/callback/event failure leaves the model published but clears owner/lock; +- boot state is isolated by model class; +- non-coroutine first boot uses no Mutex; +- `clearBootedModels()` and `flushState()` clear owner state; +- class attributes declared directly, on a trait, on a parent, and on a parent trait resolve with current precedence; +- attribute object cache/property selection remains collision-free. + +Use explicit channels for boot interleaving; do not rely on timing sleeps. + +### Cost + +Every normal model construction gains one additional static owner-map `isset()`. `Coroutine::id()` and Mutex operations occur only while a class is not stably booted or while its first boot is still owned. One channel is retained only for a model class first booted in a coroutine. This owner-approved cost is the minimum needed to distinguish stable publication from an in-progress sibling boot. + +## 11. Port the complete current supported Query surface + +### Files + +- Modify `src/database/src/Query/Builder.php`. +- Modify `src/database/src/Query/Grammars/Grammar.php`. +- Modify `src/database/src/Query/Grammars/MySqlGrammar.php`. +- Modify `src/database/src/Query/Grammars/PostgresGrammar.php`. +- Modify `src/database/src/Query/Grammars/SQLiteGrammar.php`. +- Modify `src/database/src/Eloquent/Concerns/QueriesRelationships.php`. +- Merge the originating tests into: + - `tests/Database/DatabaseQueryBuilderTest.php`; + - `tests/Database/DatabaseMySqlQueryGrammarTest.php`; + - `tests/Database/DatabaseEloquentBuilderTest.php`; + - the matching supported integration test files under `tests/Integration/Database`. + +Use current Laravel `13.x` member order and behavior. Do not port SQL Server grammar code or tests. + +### Straight joins + +Add the three current builder entry points in the same relative position as Laravel: + +```php +public function straightJoin( + ExpressionContract|string $table, + Closure|string $first, + ?string $operator = null, + ExpressionContract|string|null $second = null +): static { + return $this->join($table, $first, $operator, $second, 'straight_join'); +} + +public function straightJoinWhere( + ExpressionContract|string $table, + Closure|ExpressionContract|string $first, + string $operator, + ExpressionContract|string $second +): static { + return $this->joinWhere($table, $first, $operator, $second, 'straight_join'); +} + +public function straightJoinSub( + Closure|self|EloquentBuilder|string $query, + string $as, + Closure|ExpressionContract|string $first, + ?string $operator = null, + ExpressionContract|string|null $second = null +): static { + return $this->joinSub($query, $as, $first, $operator, $second, 'straight_join'); +} +``` + +Use the actual existing Hypervel union and generic conventions when implementing; the snippet shows the call shape, not permission to widen a more precise current signature. + +Compile the special join word only on a grammar that explicitly supports it: + +```php +$joinWord = $join->type === 'straight_join' && $this->supportsStraightJoins() + ? '' + : ' join'; + +return trim("{$join->type}{$joinWord} {$tableAndNestedJoins} {$this->compileWheres($join)}"); +``` + +The base grammar keeps current Laravel's unsupported-operation exception; MySQL returns `true`. Do not silently compile `straight_join join` or claim support on PostgreSQL or SQLite. + +### Null-safe equality + +Add the public builder methods and binding behavior: + +```php +public function whereNullSafeEquals( + ExpressionContract|string $column, + mixed $value, + string $boolean = 'and' +): static { + $this->wheres[] = [ + 'type' => 'NullSafeEquals', + 'column' => $column, + 'value' => $value, + 'boolean' => $boolean, + ]; + + if (! $value instanceof ExpressionContract) { + $this->addBinding($this->flattenValue($value), 'where'); + } + + return $this; +} + +public function orWhereNullSafeEquals(ExpressionContract|string $column, mixed $value): static +{ + return $this->whereNullSafeEquals($column, $value, 'or'); +} +``` + +Compile the supported dialects exactly: + +```php +// Base / PostgreSQL-compatible. +return $this->wrap($where['column']) + . ' is not distinct from ' + . $this->parameter($where['value']); + +// MySQL / MariaDB. +return $this->wrap($where['column']) + . ' <=> ' + . $this->parameter($where['value']); + +// SQLite. +return $this->wrap($where['column']) + . ' is ' + . $this->parameter($where['value']); +``` + +Port the two current `whereNotMorphedTo()` uses in `QueriesRelationships` so nullable morph types use the null-safe builder primitive. Do not add the unsupported SQL Server override. + +### Explicit value ordering + +Port `inOrderOf()` with Arrayable normalization, empty-input no-op behavior, union binding selection, and the CASE expression compiler: + +```php +public function inOrderOf(ExpressionContract|string $column, Arrayable|array $values): static +{ + if ($values instanceof Arrayable) { + $values = $values->toArray(); + } + + $values = array_values($values); + + if ($values === []) { + return $this; + } + + $hasUnions = $this->unions !== null && $this->unions !== []; + + $this->{$hasUnions ? 'unionOrders' : 'orders'}[] = [ + 'type' => 'InOrderOf', + 'column' => $column, + 'values' => $values, + ]; + + $this->addBinding( + $this->cleanBindings($values), + $hasUnions ? 'unionOrder' : 'order' + ); + + return $this; +} +``` + +```php +$column = $this->wrap($order['column']); +$cases = []; + +foreach (array_values($order['values']) as $index => $value) { + $cases[] = 'when ' . $column . ' = ' . $this->parameter($value) . ' then ' . $index; +} + +return 'case ' . implode(' ', $cases) . ' else ' . count($order['values']) . ' end'; +``` + +Do not invent driver-specific ordering functions; the current portable CASE implementation is the public contract. + +### MySQL query timeout + +Add the nullable timeout property and current fluent validation: + +```php +public ?int $timeout = null; + +public function timeout(?int $seconds): static +{ + if ($seconds !== null && $seconds <= 0) { + throw new InvalidArgumentException('Timeout must be greater than zero.'); + } + + $this->timeout = $seconds; + + return $this; +} +``` + +MySQL prepends the optimizer hint after compiling the normal select: + +```php +$sql = parent::compileSelect($query); + +if ($query->timeout === null) { + return $sql; +} + +$milliseconds = $query->timeout * 1000; + +return preg_replace( + '/^select\b/i', + 'select /*+ MAX_EXECUTION_TIME(' . $milliseconds . ') */', + $sql, + 1 +); +``` + +No other supported grammar claims timeout support. + +### Insert while ignoring conflicts and return rows + +Add current Laravel's final public signature and validation: + +```php +public function insertOrIgnoreReturning( + array $values, + array $returning = ['*'], + array|string|null $uniqueBy = null +): Collection { + if ($values === []) { + return new Collection; + } + + if ($uniqueBy === [] || $uniqueBy === '') { + throw new InvalidArgumentException('The unique columns must not be empty.'); + } + + if ($returning === []) { + throw new InvalidArgumentException('The returning columns must not be empty.'); + } + + // Normalize one or many rows exactly as insertOrIgnore() does. + // Apply before-query callbacks, compile, execute on the write connection, + // and mark records modified only when at least one row was returned. +} +``` + +The final compilation contract is: + +```php +public function compileInsertOrIgnoreReturning( + Builder $query, + array $values, + array $returning, + ?array $uniqueBy +): string +``` + +The base grammar throws `RuntimeException`. PostgreSQL and SQLite compile: + +```php +$insert = $this->compileInsert($query, $values); + +return match ($uniqueBy) { + null => "{$insert} on conflict do nothing returning {$this->columnize($returning)}", + default => "{$insert} on conflict ({$this->columnize($uniqueBy)}) do nothing returning {$this->columnize($returning)}", +}; +``` + +Preserve current handling for a single row, multiple rows, no inserted rows, explicit returning columns, one or several unique columns, before-query callbacks, binding order, and modified-record state. Do not add MySQL emulation; the grammar does not provide equivalent row-return semantics. + +### Current Query corrections + +Port the remaining current changes at their existing lowest methods: + +- Normalize `DatePeriod` in both `whereBetween()` and `havingBetween()` through one protected `resolveDatePeriodBounds()` helper. When no explicit end exists, clone the start and apply the interval for the declared recurrences. +- Escape question marks in `whereColumn()` operators before SQL compilation: + + ```php + $operator = str_replace('?', '??', $where['operator']); + ``` + +- In PostgreSQL `whereDate()` and `whereTime()`, call `wrap()` on the original column/Expression and parenthesize JSON selectors; never force an Expression through string-only selector logic. +- Wrap the aggregate alias through the grammar: + + ```php + return 'select ' . $aggregate['function'] . '(' . $column . ') as ' . $this->wrap('aggregate'); + ``` + +- Honor the PostgreSQL full-text `vector` option by wrapping a precomputed `tsvector` column directly instead of wrapping it in `to_tsvector(...)`. +- Accept Query builders, Eloquent builders, and relations as update values. Compile each supported value as a parenthesized subquery and merge its bindings in the same order as the update columns. +- Port current relative-date tests because the existing source already implements the behavior; do not add duplicate source logic. + +### Tests + +Port and adapt every test changed by the originating commits. At minimum prove: + +- all three straight-join entry points, nested joins, bindings, MySQL SQL, and unsupported-grammar failure; +- null-safe equality with normal values, null, expressions, OR form, bindings, MySQL, SQLite, PostgreSQL/base SQL, and morph relationship queries; +- `inOrderOf()` values, Arrayable values, union orders, bindings, duplicates, and empty no-op; +- timeout null/reset, positive values, invalid zero/negative values, hint placement, and unchanged non-MySQL SQL; +- `insertOrIgnoreReturning()` single/multiple rows, conflict/no-conflict, modified state, returning subsets, before-query callbacks, empty validation, multi-column uniqueness, and unsupported grammar; +- DatePeriod with explicit end and recurrence-derived end; +- literal question marks in column operators; +- PostgreSQL Expression date/time compilation; +- aggregate alias delimiting; +- PostgreSQL precomputed vector full-text SQL; +- Query/Eloquent/relation update subqueries and binding order; +- the current relative-date integration matrix on every configured supported driver. + +Run each changed test file immediately. Use the existing external-database isolation traits for MySQL, MariaDB, and PostgreSQL integration coverage. + +## 12. Port the complete current supported Eloquent surface + +### Files + +- Add `src/database/src/Eloquent/Attributes/RouteKey.php`. +- Modify: + - `src/database/src/Eloquent/Builder.php`; + - `src/database/src/Eloquent/Model.php`; + - `src/database/src/Eloquent/ModelNotFoundException.php`; + - `src/database/src/Eloquent/SoftDeletes.php`; + - `src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php`; + - `src/database/src/Eloquent/Concerns/HasAttributes.php`; + - `src/database/src/Eloquent/Concerns/HasRelationships.php`; + - `src/database/src/Eloquent/Concerns/HasTimestamps.php`; + - `src/database/src/Eloquent/Concerns/TransformsToResource.php`; + - `src/database/src/Eloquent/Relations/BelongsToMany.php`; + - `src/database/src/Eloquent/Relations/HasOneOrMany.php`; + - `src/database/src/Eloquent/Relations/HasOneOrManyThrough.php`; + - `src/database/src/Eloquent/Relations/MorphTo.php`. +- Merge the current originating tests into the matching `tests/Database/DatabaseEloquent*Test.php` and `tests/Integration/Database/Eloquent*Test.php` files, preserving Hypervel coroutine-specific cases. + +### Model-scoped increment and decrement operations + +Port the current builder and model behavior together. The public Query/Eloquent builder methods already express the SQL operation; the model methods must update one model instance, preserve events, class-deviable casts, dirty/original state, keys, timestamps, and quiet variants. + +```php +protected function incrementEach(array $columns, array $extra = []): int|false +{ + return $this->incrementOrDecrementEach($columns, $extra, 'incrementEach'); +} + +protected function decrementEach(array $columns, array $extra = []): int|false +{ + return $this->incrementOrDecrementEach($columns, $extra, 'decrementEach'); +} + +protected function incrementEachQuietly(array $columns, array $extra = []): int|false +{ + return static::withoutEvents( + fn () => $this->incrementOrDecrementEach($columns, $extra, 'incrementEach') + ); +} + +protected function decrementEachQuietly(array $columns, array $extra = []): int|false +{ + return static::withoutEvents( + fn () => $this->incrementOrDecrementEach($columns, $extra, 'decrementEach') + ); +} +``` + +`incrementOrDecrementEach()` must: + +1. delegate directly to a relationship-free builder when the model does not exist; +2. update every in-memory attribute, including class-deviable casts; +3. force-fill `$extra`; +4. honor an `updating` veto; +5. translate deviable cast deltas to database values; +6. constrain the update through `setKeysForSaveQuery()`; +7. sync changes, fire `updated`, and sync originals for the changed columns. + +Correct the existing sibling methods at the same owner: + +```php +protected function increment( + string $column, + mixed $amount = 1, + array $extra = [] +): int|false { + return $this->incrementOrDecrement($column, $amount, $extra, 'increment'); +} + +protected function decrement( + string $column, + mixed $amount = 1, + array $extra = [] +): int|false { + return $this->incrementOrDecrement($column, $amount, $extra, 'decrement'); +} +``` + +The shared implementation already returns `false` when an `updating` listener vetoes the operation. The current `int` declarations therefore turn a supported event veto into a `TypeError`; preserve the real Laravel behavior with the precise `int|false` contract instead of carrying the upstream PHPDoc error into native types. + +Route all four dynamic method names through `Model::__call()`. Do not add duplicate public wrappers solely to expose protected dynamic methods. + +### Conflict-safe model insert + +Add `saveOrIgnore()` and its protected insert phase: + +```php +public function saveOrIgnore( + array $options = [], + array|string|null $uniqueBy = null +): bool { + if ($this->exists) { + throw new LogicException('Cannot use saveOrIgnore on an existing model.'); + } + + $this->mergeAttributesFromCachedCasts(); + $query = $this->newModelQuery(); + + if ($this->fireModelEvent('saving') === false) { + return false; + } + + $saved = $this->performInsertOrIgnore($query, $uniqueBy); + + if ($this->getConnectionName() === null) { + $this->setConnection($query->getConnection()->getName()); + } + + if ($saved) { + $this->finishSave($options); + } + + return $saved; +} +``` + +`performInsertOrIgnore()` follows normal insert preparation: + +- generate unique IDs; +- honor `creating`; +- update timestamps; +- obtain insertion attributes; +- return success for an empty insertion; +- call `insertOrIgnoreReturning($attributes, ['*'], $uniqueBy)`; +- return `false` without publishing model state when the result is empty; +- set an incrementing key from the returned row; +- publish `exists`, `wasRecentlyCreated`, and `created` only for an inserted row. + +Do not emulate this API for MySQL, because the accepted Query primitive is supported only where the grammar can return the inserted row. Tests and docs must state the supported-driver boundary. + +### Closure-valued defaults + +Change the current first/create/update-or-create APIs to accept `Closure|array` wherever Laravel now does and resolve the closure only when the values are actually needed: + +```php +return $this->newModelInstance(array_merge($attributes, value($values))); + +return $this->withSavepointIfNeeded( + fn () => $this->create(array_merge($attributes, value($values))) +); + +return tap($this->firstOrCreate($attributes, $values), function ($instance) use ($values): void { + if (! $instance->wasRecentlyCreated) { + $instance->fill(value($values))->save(); + } +}); +``` + +Apply the current signature and `value()` use consistently to Eloquent Builder, `BelongsToMany`, `HasOneOrMany`, and the applicable `HasOneOrManyThrough` methods. Do not invoke a closure on an already-found path. + +### Route key class attribute + +Add the current class-only attribute: + +```php +#[Attribute(Attribute::TARGET_CLASS)] +class RouteKey +{ + public function __construct( + public string $key + ) { + } +} +``` + +Resolve it from `Model::getRouteKeyName()`: + +```php +return static::resolveClassAttribute(RouteKey::class, 'key') + ?? $this->getKeyName(); +``` + +This consumes the trait-aware class-attribute resolution implemented in Section 10. Preserve explicit method overrides and the ordinary primary-key fallback. + +### Relationship and scope corrections + +Port the current corrections at their existing owners: + +- `BelongsToMany::touch()` updates through `getQualifiedRelatedKeyName()`, not an incorrectly assumed related primary key. +- `MorphTo::matchToMorphParents()` normalizes both the configured owner-key value and the model-key fallback through `getDictionaryKey()`: + + ```php + $ownerKey = $this->getDictionaryKey( + $this->ownerKey !== null + ? $result->{$this->ownerKey} + : $result->getKey() + ); + ``` + +- Nested Eloquent closure where clauses propagate the nested builder's removed scopes before adding the nested base query: + + ```php + $this->withoutGlobalScopes($query->removedScopes()); + ``` + +- A private method carrying `#[Scope]` is not a callable named scope: + + ```php + if (method_exists(static::class, $method)) { + $reflection = new ReflectionMethod(static::class, $method); + + return ! $reflection->isPrivate() + && $reflection->getAttributes(Scope::class) !== []; + } + + return false; + ``` + +- Builder and model timestamp touching accept an array of columns, apply the same fresh timestamp to each, and retain the existing default updated-at behavior. +- `withoutRelation()` remains present and covered; do not duplicate its already-correct source or documentation merely because it appeared in the discovery history. + +### Event, exception, cast, and resource corrections + +- Fire `restored` only when the underlying soft-delete restore save succeeds: + + ```php + $result = $this->save(); + + if ($result) { + $this->fireModelEvent('restored', false); + } + + return $result; + ``` + +- Normalize backed and unit enum IDs at the `ModelNotFoundException` string boundary: + + ```php + $this->ids = array_map(enum_value(...), Arr::wrap($ids)); + ``` + + This is an identifier/message boundary covered by `support-02`; do not normalize enum values earlier in query/model data paths. + +- Add `ArrayObject::ARRAY_AS_PROPS` when constructing `AsEncryptedArrayObject`. +- Correct the `casts()` PHPDoc so custom cast methods may return `Stringable` objects where current Laravel allows them; do not widen unrelated runtime types. +- In `TransformsToResource::guessResourceName()`, use the full namespace segment before the final model class with `Str::beforeLast()`. Preserve Hypervel namespaces and the current resource guessing order. + +### Tests + +Port the exact originating source and integration tests, including: + +- existing/non-existing model increment/decrement-each, extra values, events, event veto, quiet variants, timestamps, dirty/original state, class-deviable casts, and the dynamic `__call()` route; +- existing single-column increment/decrement event vetoes return `false` rather than violating their native return type; +- `saveOrIgnore()` inserted/conflicted rows, generated IDs, timestamps, events/vetoes, incrementing/non-incrementing keys, connection naming, invalid existing models, unique-column selection, and supported drivers; +- lazy closure invocation and non-invocation across builders and relations; +- direct and inherited `#[RouteKey]`, trait-carried attributes, explicit method override, and route binding; +- `BelongsToMany::touch()` with a non-default related key; +- failed soft-delete restore without a false `restored` event; +- private attributed scope rejection without recursion; +- MorphTo null owner-key/non-primitive matching; +- nested removed-scope propagation; +- multiple timestamp columns; +- backed/unit enum missing-model messages; +- encrypted ArrayObject property access; +- Stringable `casts()` metadata; +- nested resource namespace guessing. + +Keep test-specific helper namespaces for generic model names and use the configured external-database traits for integration cases. Do not add placeholder tests for directly deprecated omissions. + +## 13. Port current Schema, migration, connection, and provider parity + +### Files + +- Modify: + - `src/database/src/Connection.php`; + - `src/database/src/ConnectionInterface.php`; + - `src/database/src/DatabaseManager.php`; + - `src/database/src/MySqlConnection.php`; + - `src/database/src/PostgresConnection.php`; + - `src/database/src/SQLiteConnection.php`; + - `src/database/src/UniqueConstraintViolationException.php`; + - `src/database/src/DatabaseServiceProvider.php`; + - `src/database/src/LostConnectionDetector.php`; + - `src/database/src/Events/MigrationEvent.php`; + - `src/database/src/Migrations/Migrator.php`; + - `src/database/src/Console/DumpCommand.php`; + - `src/database/src/Console/Migrations/FreshCommand.php`; + - `src/database/src/Console/PruneCommand.php`; + - `src/database/src/Schema/Blueprint.php`; + - `src/database/src/Schema/Builder.php`; + - `src/database/src/Schema/SchemaState.php`; + - `src/database/src/Schema/MariaDbSchemaState.php`; + - `src/database/src/Schema/Grammars/Grammar.php`; + - `src/database/src/Schema/Grammars/MariaDbGrammar.php`; + - `src/database/src/Schema/Grammars/PostgresGrammar.php`. +- Modify `src/database/src/Eloquent/Concerns/HasEvents.php`. +- Modify `src/support/src/Facades/Schema.php`. +- SQLite connector, builder, and schema-state changes are already specified in Section 6 and must be merged from the same current upstream files rather than implemented twice. +- Merge current tests into the matching Database unit and integration files, including new supported-driver coverage where Hypervel has no equivalent file yet. +Do not port SQL Server source or tests. + +### Schema inspection and definition APIs + +Add `Blueprint::foreignUuidFor()`: + +```php +public function foreignUuidFor(Model|string $model, ?string $column = null): ForeignIdColumnDefinition +{ + if (is_string($model)) { + $model = new $model; + } + + $column = $column === null || $column === '' + ? $model->getForeignKey() + : $column; + + return $this->foreignUuid($column) + ->table($model->getTable()) + ->referencesModelColumn($model->getKeyName()); +} +``` + +Add `Builder::hasForeignKey()` using the same name-or-column matching contract as `hasIndex()`: + +```php +public function hasForeignKey(string $table, array|string $foreignKey): bool +{ + foreach ($this->getForeignKeys($table) as $value) { + if ($value['name'] === $foreignKey || $value['columns'] === $foreignKey) { + return true; + } + } + + return false; +} +``` + +Add the matching annotation to Hypervel's hand-maintained Schema facade: + +```php +@method static bool hasForeignKey(string $table, array|string $foreignKey) +``` + +Add the PostgreSQL column definition: + +```php +public function tsvector(string $column): ColumnDefinition +{ + return $this->addColumn('tsvector', $column); +} + +protected function typeTsvector(Fluent $column): string +{ + return 'tsvector'; +} +``` + +Add MariaDB vector index compilation while retaining PostgreSQL's existing vector defaults: + +```php +public function vectorIndex(string $column, ?string $name = null): Fluent +{ + [$algorithm, $operatorClass] = $this->grammar instanceof MariaDbGrammar + ? [null, 'M=6 DISTANCE=cosine'] + : ['hnsw', 'vector_cosine_ops']; + + return $this->indexCommand( + 'vectorIndex', + $column, + $name, + $algorithm, + $operatorClass + ); +} +``` + +MariaDB's grammar compiles the native `vector index` clause, including the operator-class and optional lock text. Do not claim vector-index support on a grammar that does not compile it. + +### Unique-constraint metadata + +Give `UniqueConstraintViolationException` current public metadata: + +```php +public ?string $index = null; + +/** @var list */ +public array $columns = []; + +public function setIndex(?string $index): self +{ + $this->index = $index; + + return $this; +} + +/** @param list $columns */ +public function setColumns(array $columns): self +{ + $this->columns = $columns; + + return $this; +} +``` + +In the `Connection::runQueryCallback()` catch, preserve the existing error counter, parse the original driver exception, and publish its metadata on the wrapper: + +```php +catch (Exception $driverException) { + ++$this->errorCount; + + $exceptionType = ($isUniqueConstraintError = $this->isUniqueConstraintError($driverException)) + ? UniqueConstraintViolationException::class + : QueryException::class; + + $queryException = new $exceptionType( + $this->getName(), + $query, + $this->prepareBindings($bindings), + $driverException, + $this->getConnectionDetails(), + $this->latestReadWriteTypeUsed(), + ); + + if ($isUniqueConstraintError) { + ['index' => $index, 'columns' => $columns] + = $this->parseUniqueConstraintViolation($driverException); + + $queryException->setIndex($index)->setColumns($columns); + } + + throw $queryException; +} +``` + +Use the current driver parsers: + +- MySQL/MariaDB extracts the offending index and returns no columns when the native message does not expose them reliably. +- PostgreSQL extracts the quoted constraint name and the `Key (...)` column list. +- SQLite extracts and de-qualifies the columns from `UNIQUE constraint failed`. + +Keep a precise base return shape and add no speculative parser for unsupported or unrecognized message formats. An unparseable valid unique violation still throws the correct exception with `null`/empty metadata. + +### Migration names and schema-dump data + +Add nullable migration name state to `MigrationEvent` and thread the current migration name through both start and end events: + +```php +public function __construct( + public Migration $migration, + public string $method, + public ?string $name = null +) { +} +``` + +Preserve the existing public event property shape if Hypervel's current strict typing requires explicit declarations rather than promotion. + +Add `--without-migration-data` to `schema:dump`. When selected, pass `null` as the migration table: + +```php +$migrationTable = $this->option('without-migration-data') + ? null + : $migrationTable; + +return $connection->getSchemaState() + ->withMigrationTable($migrationTable) + ->handleOutputUsing(/* existing output callback */); +``` + +`SchemaState::withMigrationTable()` must accept `?string`. Do not add another dump mode or configuration key. + +### Remaining current connection and command corrections + +Port these current corrections in place: + +- PostgreSQL `compileColumns()` emits `null as collation` on servers before 9.1 rather than querying `pg_collation`, which does not exist there. +- PostgreSQL auto-increment starting values quote the fully wrapped table and exact column passed to `pg_get_serial_sequence()`, preserving custom schemas/connections. +- `model:prune` reports the actual singular `--model` option when combined with `--except`. +- `LostConnectionDetector` includes the two current additional native error strings from commits `dcf70c4b19` and `c0567a68aa`; preserve existing case-insensitive matching and do not generalize it into a regex engine. +- `migrate:fresh` treats a throwable `repositoryExists()` check as "repository absent", skips wipe, and lets the existing `migrate` path own database creation/prompting: + + ```php + try { + $repositoryExists = $this->migrator->repositoryExists(); + } catch (Throwable) { + $repositoryExists = false; + } + ``` + +- `MariaDbSchemaState::detectClientVersion()` probes `mariadb --version`, falls back to the minimum MariaDB CLI version `10.5.2` when that process fails, and reports `isMariaDb => true`. Use the current upstream result, not the originating commit subject as implementation truth. + +### Active PostgreSQL read/write prepare mode + +Correct Hypervel's existing `isUsingEmulatedPrepares()` adaptation to inspect the PDO configuration selected for the query: + +```php +protected function isUsingEmulatedPrepares(): bool +{ + $config = $this->latestReadWriteTypeUsed() === 'read' + && $this->readPdoConfig !== [] + ? $this->readPdoConfig + : $this->config; + + return (bool) ($config['options'][PDO::ATTR_EMULATE_PREPARES] ?? false); +} +``` + +Keep Hypervel's method name and boolean-binding adaptation. Do not port Laravel's unsupported direct/external connection branch. The bool cast accepts the native PDO option representations that configure the same behavior. + +### Queue entity resolution + +Bind the existing Database implementation to the existing Queue contract: + +```php +protected function registerQueueableEntityResolver(): void +{ + $this->app->singleton( + EntityResolver::class, + QueueEntityResolver::class + ); +} +``` + +Call this from `DatabaseServiceProvider::register()` in current Laravel order. The contract represents behavior every queue entity resolver must provide, so this is the correct contract boundary; no new API is added. + +### PHPDoc-only current parity + +Apply current, evidence-based generic/callback PHPDoc to: + +- `Connection::withoutPretending()`; +- `Connection::withoutTablePrefix()`; +- `DatabaseManager::usingConnection()`; +- `Migrator::usingConnection()`; +- `Eloquent\Concerns\HasEvents::withoutEvents()`; +- `Eloquent\Concerns\HasTimestamps::withoutTimestamps()` and `withoutTimestampsOn()`; +- `Eloquent\Model::withoutBroadcasting()`; +- `ConnectionInterface::transaction()`. + +These changes must improve static return/callback inference without changing runtime code, widening contracts, adding casts, or introducing PHPStan-only branches. + +### Tests + +Port every supported current originating test: + +- `foreignUuidFor()` infers model table, key, and default/custom column; +- `hasForeignKey()` matches names and column lists and returns false for absent definitions; +- PostgreSQL `tsvector` type and full-text vector use; +- MariaDB vector index SQL; +- MySQL, PostgreSQL, and SQLite unique index/column metadata with graceful unparseable messages; +- migration start/end event names for up/down and anonymous/named migrations; +- schema dump with and without migration data; +- SQLite URI coverage from Section 6; +- PostgreSQL pre-9.1 collation SQL and custom-schema sequence SQL; +- corrected prune validation message; +- new lost-connection strings; +- missing-database `migrate:fresh`; +- MariaDB CLI version success/fallback; +- active PostgreSQL read versus write emulated-prepare options; +- container resolution of `EntityResolver` to `QueueEntityResolver`; +- PHPDoc changes through PHPStan, not runtime-only placeholder tests. + +External MySQL, MariaDB, PostgreSQL, and SQLite tests stay under `tests/Integration/Database` and use the repository's existing isolation/configuration conventions. + +## 14. Apply the two approved Database performance corrections + +### Files + +- Modify `src/database/src/Eloquent/Concerns/HasAttributes.php`. +- Modify `src/database/src/Query/Grammars/Grammar.php`. +- Merge the originating cast tests into: + - `tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php`; + - `tests/Integration/Database/EloquentModelEncryptedCastingTest.php`. +- Extend `tests/Database/DatabaseQueryGrammarTest.php` for raw-SQL substitution. + +### Merge only the requested cached cast on ordinary reads + +Change `getAttributeFromArray()` from a full cached-cast merge to one key: + +```php +protected function getAttributeFromArray(string $key): mixed +{ + $this->mergeAttributeFromCachedCasts($key); + + return $this->attributes[$key] ?? null; +} +``` + +Add the per-key composition: + +```php +protected function mergeAttributeFromCachedCasts(string $key): void +{ + $this->mergeAttributeFromClassCasts($key); + $this->mergeAttributeFromAttributeCasts($key); +} +``` + +Refactor the two existing full loops to delegate to their corresponding per-key methods: + +```php +protected function mergeAttributesFromClassCasts(): void +{ + foreach ($this->classCastCache as $key => $value) { + $this->mergeAttributeFromClassCasts($key); + } +} + +protected function mergeAttributeFromClassCasts(string $key): void +{ + if (! isset($this->classCastCache[$key])) { + return; + } + + $value = $this->classCastCache[$key]; + $caster = $this->resolveCasterClass($key); + + $this->attributes = array_merge( + $this->attributes, + $caster instanceof CastsInboundAttributes + ? [$key => $value] + : $this->normalizeCastClassResponse( + $key, + $caster->set($this, $key, $value, $this->attributes) + ) + ); +} +``` + +Apply the same shape to `$attributeCastCache`, preserving getter-only attributes and the existing fallback setter callback. + +Retain a full `mergeAttributesFromCachedCasts()` immediately before: + +- legacy get mutators; +- `Attribute` get mutators; +- legacy set mutators; +- `Attribute` set mutators. + +Those mutators may inspect sibling attributes, so removing their full merge would be a behavior regression. Do not add dependency tracking between casts or mutators. + +This change reduces work on ordinary attribute reads. It adds one constant-time cache check for the requested key while removing iteration and serialization of unrelated cached casts. + +### Use indexed raw SQL binding substitution + +Replace destructive front removal: + +```php +$query = ''; +$bindingIndex = 0; + +// Existing SQL literal scan... +if ($char === '?' && ! $isStringLiteral) { + $query .= $bindings[$bindingIndex++] ?? '?'; +} +``` + +Keep the current literal/escaped-question-mark parser and binding escaping unchanged. This removes repeated array reindexing and makes substitution linear in the number of bindings. + +### Tests + +Port the originating behavior/performance regressions: + +- reading one cached cast does not serialize unrelated mutable custom casts; +- legacy and `Attribute` get/set mutators still see sibling cached-cast state; +- encrypted unrelated casts are not decrypted/serialized on another attribute read; +- mutable custom and date-like casts still synchronize back to raw attributes correctly; +- raw SQL substitution preserves quoted question marks, escaped question marks, resources, missing bindings, and binding order; +- a long binding list produces identical SQL without asserting implementation timing. + +No benchmark is required unless implementation uncovers uncertainty. Both changes remove source-proven work without adding machinery or changing results. + +## 15. Complete package metadata, provenance, and intentional omissions + +### Files + +- Modify `src/database/composer.json`. +- Modify `src/redis/composer.json`. +- Modify `src/database/README.md`. +- Add `tests/Database/PackageMetadataTest.php`. +- Add `tests/Redis/PackageMetadataTest.php`. +- Add `REMOVED:` comments to the current natural upstream positions in: + - `src/database/src/Eloquent/Factories/Factory.php`; + - `src/database/src/Schema/Blueprint.php`; + - `src/database/src/Grammar.php`; + - `src/database/src/Query/Processors/MySqlProcessor.php`; + - `src/database/src/Query/Grammars/PostgresGrammar.php`. + +### Split dependencies + +Add every direct runtime dependency proven by Database source: + +```json +{ + "ext-pdo": "*", + "ext-swoole": "^6.2", + "hypervel/coordinator": "^0.4", + "hypervel/engine": "^0.4", + "hypervel/prompts": "^0.4", + "psr/log": "^3.0", + "symfony/finder": "^8.1" +} +``` + +These constraints match the root manifest and sibling split-package conventions. Keep `hypervel/collections`, `hypervel/conditionable`, and `hypervel/macroable`; their symbols are directly consumed through Hypervel Support traits/types. Remove `hypervel/config` only after deleting `UnsetContextInTaskWorkerListener`, because that listener is the package's only direct concrete Config use. + +Add these direct Redis runtime requirements: + +```json +{ + "ext-swoole": "^6.2", + "hypervel/core": "^0.4", + "psr/log": "^3.0" +} +``` + +The provider and lifecycle listener consume Core lifecycle events, while existing Redis source directly imports `Swoole\Coroutine\CanceledException` and `Psr\Log\LogLevel`. Keep phpredis in `suggest` because the package retains its existing optional connector contract. + +Do not add transitive dependencies that no package source imports. Root `composer.json` already supplies the monorepo development graph; edit it only if the final direct dependency set is not already available there. + +### Metadata test + +Add the standard split-manifest regression: + +```php +public function testDirectRuntimeDependenciesAreDeclared(): void +{ + $composer = json_decode( + file_get_contents(__DIR__ . '/../../src/database/composer.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + + foreach ([ + 'ext-pdo', + 'ext-swoole', + 'hypervel/coordinator', + 'hypervel/engine', + 'hypervel/prompts', + 'psr/log', + 'symfony/finder', + // Retain the complete existing direct list. + ] as $dependency) { + $this->assertArrayHasKey($dependency, $composer['require']); + $this->assertIsString($composer['require'][$dependency]); + $this->assertNotSame('', trim($composer['require'][$dependency])); + } + + $this->assertArrayNotHasKey('hypervel/config', $composer['require']); +} +``` + +The final test lists all direct runtime dependencies, not only the newly added ones. + +Add the same standard regression for the Redis split manifest. Its final list includes every direct runtime dependency and specifically proves `ext-swoole`, `hypervel/core`, and `psr/log` are declared without moving optional `ext-redis` out of `suggest`. + +### Provenance and deprecated omissions + +Add the standard README provenance line: + +```md +Ported from: https://github.com/laravel/framework +``` + +Keep all three existing `Differences From Laravel` bullets. Add one concise grouped bullet explaining that directly deprecated Laravel compatibility forwarding is intentionally omitted and callers should use the current non-deprecated owner APIs. + +At each natural source position, add a concise marker naming the omitted upstream surface and replacement: + +```php +// REMOVED: Laravel's deprecated singular model-name resolver compatibility +// is omitted; use the class-keyed modelNameResolvers map. +``` + +The complete directly deprecated omitted set is: + +- Factory's singular `$modelNameResolver`; +- Blueprint's `commandsNamed()`, `getPrefix()`, and `getChangedColumns()`; +- Database Grammar's `getTablePrefix()` and `setTablePrefix()` forwarding; +- MySQL Processor's `processColumnListing()` forwarding; +- PostgreSQL Grammar's misspelled `cascadeOnTrucate()`. + +Use the exact current non-deprecated replacement in each final marker. Do not implement shims, compatibility aliases, dead wrappers, or placeholder tests. These are current Laravel deprecations, not arbitrary API divergence. + +## 16. Update task-first user documentation + +### Files and placement + +- Modify `src/boost/docs/migrations.md`. +- Modify `src/boost/docs/queries.md`. +- Modify `src/boost/docs/eloquent.md`. +- Modify `src/boost/docs/routing.md`. + +Match the surrounding Laravel-style headings, anchors, examples, language, and table of contents. Keep the content concise and oriented around building applications and packages; do not document lifecycle internals. + +### Migrations + +At the existing model-aware foreign-ID section, add `foreignUuidFor()` with a UUID-key model example and state that it derives the column, table, and referenced model key. + +At the existing schema inspection section, add `hasForeignKey()` and show both name and column-list checks. + +At the available column types/indexes sections: + +- document PostgreSQL `tsvector()` for stored full-text vectors; +- document MariaDB `vectorIndex()` and its supported-driver boundary; +- keep the existing PostgreSQL `vector`/pgvector documentation distinct. + +### Query builder + +Add brief sections at the natural existing headings: + +- **Joins:** `straightJoin()`, `straightJoinWhere()`, and `straightJoinSub()`, explicitly MySQL/MariaDB only. +- **Where clauses:** port current Laravel's `whereNullSafeEquals()` and `orWhereNullSafeEquals()` section, explaining that two null values compare equal. +- **Ordering:** `inOrderOf()` with a preferred value sequence. +- **Select/query execution:** `timeout()` as a MySQL/MariaDB per-query seconds limit. +- **Insert statements:** `insertOrIgnoreReturning()` with returned rows, optional conflict columns, supported PostgreSQL/SQLite boundary, and the distinction from `insertOrIgnore()`. + +Examples must use the real final signatures and avoid internal grammar terminology. + +### Eloquent + +At the existing insert/update sections: + +- document `saveOrIgnore()` for a new model and state that it returns `false` on a matching conflict; +- document model-instance `incrementEach()` and `decrementEach()`; +- mention the quiet variants beside their event behavior; +- retain the already-correct `withoutRelation()` section and Query-builder increment/decrement-each material without duplicating it. + +State the same supported-driver boundary for `saveOrIgnore()` that the underlying returning API enforces. + +### Routing + +Beside the current `getRouteKeyName()` override example, add the class attribute alternative: + +```php +use Hypervel\Database\Eloquent\Attributes\RouteKey; + +#[RouteKey('slug')] +class Post extends Model +{ + // ... +} +``` + +Explain that the attribute applies consistently to implicit route model binding while an explicit method override remains available. + +### Documentation verification + +After editing: + +- compare each addition with at least the neighboring section and the corresponding current Laravel docs where present; +- search every documented method against final source signatures; +- ensure every linked anchor/table-of-contents entry resolves; +- ensure no unsupported SQL Server/direct-connection/deprecated surface appears; +- ensure internal task, fork, pool, transaction, mutex, and URI-classifier mechanics stay out of user docs. + +## 17. Remove every superseded path + +The implementation is incomplete until the old ownership model is gone. + +Delete: + +- `src/database/src/Listeners/UnsetContextInTaskWorkerListener.php`; +- its reflective listener tests and configured-base-name cleanup assumptions; +- duplicate SQLite memory/URI substring checks replaced by `SQLiteDatabase`; +- tests that expect multiple independently borrowed wrappers around one shared in-memory PDO; +- transaction-manager callback-before-detach paths; +- any rollback retry path that can run before physical/logical cleanup succeeds; +- stale README or Boost wording contradicted by the final public APIs; +- stale `hypervel/config` split dependency after its final direct use disappears. + +Replace rather than retain: + +- `CoroutineContext::set($key, null)` terminal cleanup with `forget($key)`; +- manager-name-derived Redis context cleanup with proxy-owned key derivation; +- release-before-pool-destruction purge/fork cleanup with exact discard; +- raw SQLite filename reuse with canonical attached-database lookup; +- full cached-cast merge on ordinary single-attribute reads with per-key merge; +- `array_shift()` raw SQL substitution with indexed access. + +Do not leave: + +- compatibility shims for omitted deprecated Laravel APIs; +- a generic task cleanup registry; +- a lifecycle listener priority mechanism; +- a generic lease proxy; +- a transaction state machine/finalizer/callback executor; +- a second SQLite lock, shared-cache URI rewrite, or wrapper coordinator; +- a PHP mirror of native Redis queue mode; +- a mark-invalid fallback that masks `Pool::discard()` ownership failure; +- comments documenting abandoned designs; +- placeholder or implementation-detail-only tests. + +Before validation, run repository-wide searches for every removed class, helper, property, context-key reconstruction, stale dependency, and deprecated symbol. Every remaining hit must be an intentional README/source `REMOVED:` record or a current test assertion. + +## 18. Regression and validation plan + +### Focused lifecycle matrix + +| Boundary | Success coverage | Failure coverage | +|---|---|---| +| `TaskCallback` | OnTask result finishing followed by terminal event | OnTask failure, finish failure, terminal failure, and all combinations preserve the earliest throwable while still attempting terminal cleanup | +| Database task cleanup | Exact base/read/write wrappers release once at task end | Partial acquisition/publication/defer failure discards exact wrapper; one release failure does not skip siblings | +| Database fork cleanup | Resolved wrapper discard plus resolved pool flush | Unresolved owners stay unresolved; resolver failure does not skip factory flush; earliest failure wins | +| Redis task cleanup | Raw same-connection state remains pinned through one task then releases; callback-form transaction completion settles a consumed watch | Callback-form newly pinned release/no-op, divergent proxy name, release failure exhaustion, no double release, no false WATCH discard/log after successful callback transaction | +| Redis fork/purge | Exact proxy discard and actual proxy pool flush | Manager/factory independently unresolved or failing; invariant failure propagates | +| Redis command events | Listener observation precedes ownership handoff | Success/failure listener exceptions cannot skip release or same-connection handoff; existing event precedence and cleanup failures remain truthful | +| Redis transaction state | ATOMIC, unwatched clients restore selected database and requeue; WATCH through EXEC stays on one native client | MULTI/PIPELINE/abandoned WATCH discards; log failure cannot prevent discard; mode/restore failure invalidates; discard ownership failure cannot fall through to requeue | +| SQLite classifier | Literal memory, encoded memory, named memory, file URIs, canonical attached path | Invalid/mixed-case modes follow native behavior; duplicate `mode` uses final value; write false throws descriptively | +| In-memory pool | One owner serializes through existing channel | Invalid option types/counts still fail in `PoolOption`; no normalization masks configuration errors | +| Transaction begin | Physical begin and logical/manager/event publication agree | Manager/event publication failure rolls back to prior level and preserves the primary error | +| Managed `transaction()` commit | Listener, physical commit, manager callbacks, and event publish in order | Pre-commit failure rolls back; deadlock retry only after cleanup; lost commit detaches terminally; cleanup failure prevents retry and never replaces primary | +| Explicit `commit()` | Physical commit before logical decrement | Committing listener or physical failure leaves active caller-owned state | +| Rollback | Logical level updates after physical success | Non-lost failure keeps truthful active state; lost failure detaches terminally; manager/event failures run independently after physical success | +| Disconnect | Physical rollback, manager detach, and PDO nulling all complete | Every phase is attempted, both PDOs become null, and earliest failure wins even from logical level zero | +| Transaction records | Full/partial rollback detach the exact record set before callbacks | Re-entry cannot see detached records; rollback callbacks exhaust deepest-first; commit callbacks retain upstream stop-on-first | +| Eloquent boot | First owner publishes once; sibling waits; post-publication recursion returns | Pre-publication recursion throws; pre-publication failure retries; post-publication failure remains booted while clearing owner/lock | + +### Current upstream test porting + +For every originating commit listed in this plan: + +1. inspect its full changed-file list again; +2. reopen the corresponding current Laravel source and tests; +3. merge every supported current test into the matching Hypervel file; +4. retain Hypervel-specific coroutine, pool, immutable-date, strict-type, and external-service coverage; +5. remove only the explicitly approved SQL Server, dynamic/direct connection, and directly deprecated cases; +6. run each changed test file before moving to the next. + +Historical tests are discovery aids. If a current file contains follow-up cases not present in the originating diff, port the current cases. + +### Focused commands + +Run the changed files individually while implementing: + +```bash +./vendor/bin/phpunit --no-progress tests/Core/Bootstrap/TaskCallbackTest.php +./vendor/bin/phpunit --no-progress tests/Server/ServerTest.php +./vendor/bin/phpunit --no-progress tests/Database/ConnectionResolverTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseConnectionTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseTransactionsManagerTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseEloquentModelTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseQueryBuilderTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseSchemaBlueprintTest.php +./vendor/bin/phpunit --no-progress tests/Redis/RedisConnectionTest.php +./vendor/bin/phpunit --no-progress tests/Redis/RedisProxyTest.php +./vendor/bin/phpunit --no-progress tests/Redis/RedisManagerTest.php +``` + +Then run focused package groups: + +```bash +./vendor/bin/phpunit --no-progress tests/Core tests/Server +./vendor/bin/phpunit --no-progress tests/Database +./vendor/bin/phpunit --no-progress tests/Redis +``` + +Run configured integration groups: + +```bash +./vendor/bin/phpunit --no-progress tests/Integration/Database +./vendor/bin/phpunit --no-progress tests/Integration/Redis +``` + +The Redis and database integration tests must use the repository's worker-isolation traits and assigned `TEST_TOKEN`. Do not hardcode worker tokens, database numbers, ports, or shared temporary paths. + +### Static analysis and full gate + +After focused tests are green: + +```bash +./vendor/bin/php-cs-fixer fix +./vendor/bin/phpstan +composer fix +``` + +`composer fix` is authoritative and already runs formatting, both PHPStan configurations, the full ParaTest suite, the Testbench contract suite, and Testbench dogfood. Do not substitute a package-only run for it. + +Inspect all failures and skips normally. Fix source defects at their real boundary; do not weaken tests, add PHPStan-driven runtime branches, or suppress correct types. If implementation exposes an unexpected bug, edge case, lower-level contradiction, or same-family omission, stop that code path and use the required focused second-opinion loop before continuing. + +## 19. Fresh post-implementation self-review + +After `composer fix` passes, review the entire diff without trusting this plan: + +1. Re-read every changed source file in full or consecutive chunks. +2. Trace every new event from producer through all listeners and every throwing phase. +3. Trace each Database and Redis wrapper from borrow through publication, use, release/discard, pool bookkeeping, and close failure. +4. Re-run the parent-before-fork and child-start source trace and confirm no listener resolves a service only to clean it. +5. Trace SQLite classification through pool construction, connector creation, schema CLI routing, canonical path discovery, and refresh failure. +6. Trace physical PDO state, logical transaction count, manager records, callbacks, events, retry decisions, pooled reset, and disconnect through every exception edge. +7. Trace Eloquent first boot through same-owner recursion, sibling waiting, publication, post-publication hooks, failure, reset, and test cleanup order. +8. Compare every ported method, signature, member position, test, and doc example with current Laravel `13.x`, not historical diffs. +9. Search the whole repository for every changed contract, event, method, context key, deprecated omission, and documentation claim. +10. Re-check package dependency ownership from imports after all deletions/additions. +11. Check hot paths for new container resolution, locks, context calls, allocations, logging, yields, retries, and retained worker memory. +12. Check for dead helpers, duplicate cleanup, defensive guards that mask invariants, stale comments, and mechanisms with only hypothetical consumers. + +Fix straightforward omissions immediately and rerun proportionate tests. Any newly discovered design issue goes through the required second-opinion workflow. + +Then request a code review of the complete implementation, tests, docs, metadata, ledger updates, and validation. Continue until sign-off. + +## 20. Performance and overengineering assessment + +### Successful hot-path effects + +| Change | Frequency | Effect | +|---|---|---| +| Eloquent boot owner `isset()` | Every normal model construction | One static array lookup; owner-approved correctness cost | +| Redis native `getMode()` | Every successful phpredis pooled release | One local extension-state read, no network I/O; owner-approved correctness cost | +| Redis event cleanup slots | Every Redis proxy command | Three local throwable slots and direct branches around work already performed; fresh-review owner gate | +| Redis transaction-state routing | Every Redis wrapper/proxy command and release | Exact string comparisons for WATCH/DISCARD state and one boolean release read; callback-form multi-exec adds one string/boolean branch, while only an already-pinned successful transaction performs one context lookup and boolean clear; no lock or network I/O; fresh-review owner gate | +| Per-key cached-cast merge | Ordinary Eloquent attribute reads | Removes unrelated cached-cast iteration/serialization; performance improvement | +| Indexed raw SQL substitution | Query SQL formatting/logging | Removes repeated array reindexing; performance improvement | + +The Eloquent coroutine-ID lookup and Mutex acquisition occur only during incomplete first publication. Task and fork events are cold lifecycle boundaries. SQLite classification/capacity normalization happens at connection/pool/schema setup. Transaction additions are direct fixed-phase exception handling; successful query execution gains no registry, state machine, lock, retry, logging, container lookup, or yield. + +### Retained worker memory + +- One bounded `PooledConnection` entry per requested Database connection name only while a non-coroutine task owns it. +- Existing Redis proxies own their already-required pinned context; no parallel registry is added. +- One boolean belongs to each already-existing Redis connection wrapper and describes only that native generation's unobservable watch state. +- One integer boot owner only while a model class is publishing. +- One existing Mutex channel per model class first booted in a coroutine remains for the worker lifetime; non-coroutine first boot creates none. +- No shared SQLite cache, transaction registry, cleanup registry, or PHP Redis mode mirror is added. + +### Explicit anti-overengineering decisions + +- Two additive lifecycle events each have multiple verified consumers and replace incomplete local workarounds. +- The SQLite classifier has four real consumers and deletes duplicated incompatible checks. +- The transaction implementation uses direct phase-specific control flow; no generic abstraction is introduced. +- The Redis proxy owns its real context identity; the manager only aggregates existing proxies. +- Redis command-event cleanup uses direct local throwable slots, watch state is one wrapper boolean because phpredis exposes no equivalent getter, and native DISCARD gets one explicit route around the existing Pool method collision. +- Public contracts are unchanged except for supported current Laravel API parity. Concrete internal lifecycle methods remain off contracts. +- Directly deprecated Laravel compatibility surfaces remain omitted rather than reintroduced. +- Tests target supported behavior and reproduced failures, not impossible internal states. +- No benchmark, configuration option, event priority, generic extension point, or speculative retry policy is added. + +If the final implementation needs more machinery than this section describes, treat that as a design warning and re-establish the evidence and lowest owner before proceeding. + +## 21. Completion criteria + +This work unit is complete only when: + +- every accepted `database-05` through `database-13` and `redis-03` through `redis-08` finding is implemented at the specified owner; +- both lifecycle events have Database and Redis consumers, deterministic failure precedence, and focused tests; +- no pooled Database or Redis resource can cross the supported terminal task or process boundary unowned; +- no Redis client in native MULTI/PIPELINE or abandoned WATCH state can be requeued, callback-form optimistic transactions clear consumed watch state without false discard/logging, native `Redis::discard()` reaches phpredis rather than Pool lifecycle teardown, and command-listener failure cannot leak its borrowed wrapper; +- every supported SQLite URI/memory form is classified consistently and one in-memory PDO has one owner; +- physical transaction state, logical state, manager records, callbacks, events, retry decisions, and pooled reuse remain truthful on every covered edge; +- Eloquent first boot is recursive-safe, coroutine-safe, retryable before publication, and Laravel-compatible after publication; +- every supported current Laravel Query, Eloquent, Schema, migration, connector, provider, exception, and PHPDoc item listed here is present with current tests; +- both approved performance corrections are present with behavior regressions; +- manifests, provenance, omission markers, and user docs are complete and accurate; +- every superseded class, path, dependency, comment, and test assumption is removed; +- changed tests, focused package tests, configured integration tests, and `composer fix` pass; +- fresh self-review finds no unowned resource, stale state, unsupported divergence, hot-path regression beyond the approved noise-level costs, or unjustified mechanism; +- post-implementation code review is signed off; +- the companion ledger and main audit routing/checklist are updated only after the implementation is complete and validated; +- the owner receives the final self-contained summary and explicitly approves commits under the main audit workflow. From db77f55a480ae1302089fda977624b900808ede5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:07:51 +0000 Subject: [PATCH 02/26] feat(core): publish terminal task lifecycle events Add a TaskTerminated event that carries the exact native server and task after task action and result handling completes. Dispatch terminal cleanup through a listener-aware boundary on every success and failure path while preserving the earliest action, finish, or cleanup throwable. Cover both native task signatures, dispatch ordering, no-listener behavior, and combined failure precedence. --- src/core/src/Bootstrap/TaskCallback.php | 32 +++- src/core/src/Events/TaskTerminated.php | 20 +++ tests/Core/Bootstrap/TaskCallbackTest.php | 207 ++++++++++++++++++++++ 3 files changed, 252 insertions(+), 7 deletions(-) create mode 100644 src/core/src/Events/TaskTerminated.php diff --git a/src/core/src/Bootstrap/TaskCallback.php b/src/core/src/Bootstrap/TaskCallback.php index 5f57513c8..4110c3a43 100644 --- a/src/core/src/Bootstrap/TaskCallback.php +++ b/src/core/src/Bootstrap/TaskCallback.php @@ -7,9 +7,11 @@ use Hypervel\Contracts\Config\Repository; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Core\Events\OnTask; +use Hypervel\Core\Events\TaskTerminated; use Swoole\Constant; use Swoole\Server; use Swoole\Server\Task; +use Throwable; class TaskCallback { @@ -42,15 +44,31 @@ public function onTask(Server $server, mixed ...$arguments): void $task->data = $data; } - $event = new OnTask($server, $task); - $this->dispatcher->dispatch($event); + $exception = null; - if (! is_null($event->result)) { - if ($this->taskUsesObject) { - $task->finish($event->result); - } else { - $server->finish($event->result); + try { + $event = new OnTask($server, $task); + $this->dispatcher->dispatch($event); + + if ($event->result !== null) { + $this->taskUsesObject + ? $task->finish($event->result) + : $server->finish($event->result); + } + } catch (Throwable $throwable) { + $exception = $throwable; + } + + try { + if ($this->dispatcher->hasListeners(TaskTerminated::class)) { + $this->dispatcher->dispatch(new TaskTerminated($server, $task)); } + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + if ($exception !== null) { + throw $exception; } } } diff --git a/src/core/src/Events/TaskTerminated.php b/src/core/src/Events/TaskTerminated.php new file mode 100644 index 000000000..820d02502 --- /dev/null +++ b/src/core/src/Events/TaskTerminated.php @@ -0,0 +1,20 @@ +shouldReceive('hasListeners') + ->once() + ->with(TaskTerminated::class) + ->andReturnFalse(); $this->makeCallback($dispatcher)->onTask($server, 12, 3, ['payload' => true]); } @@ -51,6 +57,18 @@ public function testDedicatedTaskSettingsUseNativeObjectSignature(array $setting $this->assertSame($server, $event->server); $this->assertSame($task, $event->task); + return true; + })); + $dispatcher->shouldReceive('hasListeners') + ->once() + ->with(TaskTerminated::class) + ->andReturnTrue(); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::on(function (TaskTerminated $event) use ($server, $task): bool { + $this->assertSame($server, $event->server); + $this->assertSame($task, $event->task); + return true; })); @@ -78,6 +96,10 @@ public function testLegacyAliasPresenceTakesPrecedenceOverTaskObject(): void return true; })); + $dispatcher->shouldReceive('hasListeners') + ->once() + ->with(TaskTerminated::class) + ->andReturnFalse(); $this->makeCallback($dispatcher, [ Constant::OPTION_TASK_USE_OBJECT => false, @@ -97,10 +119,195 @@ public function testLegacyResultIsFinishedThroughServer(): void ->andReturnUsing(function (OnTask $event): void { $event->setResult('completed'); }); + $dispatcher->shouldReceive('hasListeners') + ->once() + ->with(TaskTerminated::class) + ->andReturnFalse(); $this->makeCallback($dispatcher)->onTask($server, 41, 6, 'payload'); } + public function testLegacyResultIsFinishedBeforeTerminalDispatch(): void + { + $order = []; + $server = m::mock(Server::class); + $server->shouldReceive('finish') + ->once() + ->with('completed') + ->andReturnUsing(function () use (&$order): bool { + $order[] = 'finish'; + + return true; + }); + + $task = null; + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(OnTask::class)) + ->andReturnUsing(function (OnTask $event) use (&$order, &$task): void { + $task = $event->task; + $event->setResult('completed'); + $order[] = 'task'; + }); + $dispatcher->shouldReceive('hasListeners') + ->once() + ->with(TaskTerminated::class) + ->andReturnTrue(); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::on(function (TaskTerminated $event) use ($server, &$order, &$task): bool { + $this->assertSame($server, $event->server); + $this->assertSame($task, $event->task); + $order[] = 'terminated'; + + return true; + })); + + $this->makeCallback($dispatcher)->onTask($server, 42, 7, 'payload'); + + $this->assertSame(['task', 'finish', 'terminated'], $order); + } + + public function testTaskFailureSkipsFinishButStillDispatchesTerminalEvent(): void + { + $exception = new RuntimeException('Task failed.'); + $server = m::mock(Server::class); + $server->shouldNotReceive('finish'); + + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(OnTask::class)) + ->andThrow($exception); + $dispatcher->shouldReceive('hasListeners') + ->once() + ->with(TaskTerminated::class) + ->andReturnTrue(); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(TaskTerminated::class)); + + try { + $this->makeCallback($dispatcher)->onTask($server, 43, 8, 'payload'); + $this->fail('Expected the task failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($exception, $throwable); + } + } + + public function testFinishFailureStillDispatchesTerminalEvent(): void + { + $exception = new RuntimeException('Finish failed.'); + $server = m::mock(Server::class); + $server->shouldReceive('finish')->once()->andThrow($exception); + + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(OnTask::class)) + ->andReturnUsing(function (OnTask $event): void { + $event->setResult('completed'); + }); + $dispatcher->shouldReceive('hasListeners') + ->once() + ->with(TaskTerminated::class) + ->andReturnTrue(); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(TaskTerminated::class)); + + try { + $this->makeCallback($dispatcher)->onTask($server, 44, 9, 'payload'); + $this->fail('Expected the finish failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($exception, $throwable); + } + } + + public function testTerminalFailurePropagatesAfterSuccessfulTask(): void + { + $exception = new RuntimeException('Terminal dispatch failed.'); + $server = m::mock(Server::class); + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(OnTask::class)); + $dispatcher->shouldReceive('hasListeners') + ->once() + ->with(TaskTerminated::class) + ->andReturnTrue(); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(TaskTerminated::class)) + ->andThrow($exception); + + try { + $this->makeCallback($dispatcher)->onTask($server, 45, 10, 'payload'); + $this->fail('Expected the terminal failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($exception, $throwable); + } + } + + public function testTaskFailureRemainsPrimaryWhenTerminalDispatchAlsoFails(): void + { + $taskException = new RuntimeException('Task failed.'); + $terminalException = new RuntimeException('Terminal dispatch failed.'); + $server = m::mock(Server::class); + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(OnTask::class)) + ->andThrow($taskException); + $dispatcher->shouldReceive('hasListeners') + ->once() + ->with(TaskTerminated::class) + ->andReturnTrue(); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(TaskTerminated::class)) + ->andThrow($terminalException); + + try { + $this->makeCallback($dispatcher)->onTask($server, 46, 11, 'payload'); + $this->fail('Expected the task failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($taskException, $throwable); + } + } + + public function testFinishFailureRemainsPrimaryWhenTerminalDispatchAlsoFails(): void + { + $finishException = new RuntimeException('Finish failed.'); + $terminalException = new RuntimeException('Terminal dispatch failed.'); + $server = m::mock(Server::class); + $server->shouldReceive('finish')->once()->andThrow($finishException); + + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(OnTask::class)) + ->andReturnUsing(function (OnTask $event): void { + $event->setResult('completed'); + }); + $dispatcher->shouldReceive('hasListeners') + ->once() + ->with(TaskTerminated::class) + ->andReturnTrue(); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(TaskTerminated::class)) + ->andThrow($terminalException); + + try { + $this->makeCallback($dispatcher)->onTask($server, 47, 12, 'payload'); + $this->fail('Expected the finish failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($finishException, $throwable); + } + } + /** * Create a task callback with the given server settings. */ From a4c84bbb60324520f3100ce32ff2a78363645c5c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:08:03 +0000 Subject: [PATCH 03/26] feat(server): publish a final pre-fork lifecycle event Add BeforeServerFork as the final parent-process boundary immediately before the native Swoole server starts and forks its child processes. Allow lifecycle consumers to discard parent-owned sockets, pools, timers, and context without relying on per-port events. Preserve native start failure behavior and cover exact event payload, ordering, listener failure, and false start results. --- src/core/src/Events/BeforeServerFork.php | 22 +++++++++ src/server/src/Server.php | 6 ++- tests/Server/ServerTest.php | 57 ++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 src/core/src/Events/BeforeServerFork.php diff --git a/src/core/src/Events/BeforeServerFork.php b/src/core/src/Events/BeforeServerFork.php new file mode 100644 index 000000000..0adb737f3 --- /dev/null +++ b/src/core/src/Events/BeforeServerFork.php @@ -0,0 +1,22 @@ +server->start() === false) { + $server = $this->getServer(); + $this->eventDispatcher->dispatch(new BeforeServerFork($server)); + + if ($server->start() === false) { throw new ServerException('Failed to start the Swoole server.'); } } diff --git a/tests/Server/ServerTest.php b/tests/Server/ServerTest.php index 0e5cfd377..ca0763b06 100644 --- a/tests/Server/ServerTest.php +++ b/tests/Server/ServerTest.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Core\Events\BeforeServerFork; use Hypervel\Filesystem\Filesystem; use Hypervel\Server\Event; use Hypervel\Server\Exceptions\InvalidArgumentException as ServerInvalidArgumentException; @@ -243,6 +244,62 @@ public function testStartFailureIsReported(): void $server->start(); } + public function testBeforeServerForkIsDispatchedBeforeNativeStart(): void + { + $order = []; + $nativeServer = m::mock(SwooleServer::class); + $nativeServer->expects('start')->andReturnUsing(function () use (&$order): bool { + $order[] = 'start'; + + return true; + }); + + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->expects('dispatch') + ->with(m::on(function (BeforeServerFork $event) use ($nativeServer, &$order): bool { + $this->assertSame($nativeServer, $event->server); + $order[] = 'before-fork'; + + return true; + })); + + $server = new ServerTestServer( + m::mock(Container::class), + m::mock(LoggerInterface::class), + $dispatcher, + ); + $server->useNativeServer($nativeServer); + $server->start(); + + $this->assertSame(['before-fork', 'start'], $order); + } + + public function testBeforeServerForkFailurePreventsNativeStart(): void + { + $exception = new RuntimeException('Pre-fork cleanup failed.'); + $nativeServer = m::mock(SwooleServer::class); + $nativeServer->shouldNotReceive('start'); + + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->expects('dispatch') + ->with(m::type(BeforeServerFork::class)) + ->andThrow($exception); + + $server = new ServerTestServer( + m::mock(Container::class), + m::mock(LoggerInterface::class), + $dispatcher, + ); + $server->useNativeServer($nativeServer); + + try { + $server->start(); + $this->fail('Expected the pre-fork failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($exception, $throwable); + } + } + public function testServerTypesArePrioritizedWithoutReorderingEqualTypes(): void { $server = new ServerTestServer( From e0c4444da7beccdd282deb550e3cdb5fa8a17a1f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:08:15 +0000 Subject: [PATCH 04/26] fix(database): own pooled connections across task lifecycles Retain exact non-coroutine pooled wrappers in ConnectionResolver and detach their context before terminal release or process-safe discard. Replace reflective configured-name cleanup with a concrete lifecycle listener that never resolves unused owners and exhausts independent cleanup phases. Register task, pre-fork, and worker-start boundaries through DatabaseServiceProvider, bind the queue entity resolver at its canonical contract, and cover aliases, read and write roles, publication failures, reentrant cleanup, custom resolvers, unresolved services, and failure precedence. --- src/database/src/ConnectionResolver.php | 113 ++++- src/database/src/DatabaseServiceProvider.php | 41 +- .../DatabaseConnectionLifecycleListener.php | 66 +++ .../UnsetContextInTaskWorkerListener.php | 44 -- ...ectionResolverSetDefaultConnectionTest.php | 126 ------ tests/Database/ConnectionResolverTest.php | 400 ++++++++++++++++++ ...atabaseConnectionLifecycleListenerTest.php | 115 +++++ .../Database/DatabaseServiceProviderTest.php | 62 +++ 8 files changed, 781 insertions(+), 186 deletions(-) create mode 100644 src/database/src/Listeners/DatabaseConnectionLifecycleListener.php delete mode 100644 src/database/src/Listeners/UnsetContextInTaskWorkerListener.php delete mode 100644 tests/Database/ConnectionResolverSetDefaultConnectionTest.php create mode 100644 tests/Database/ConnectionResolverTest.php create mode 100644 tests/Database/DatabaseConnectionLifecycleListenerTest.php create mode 100644 tests/Database/DatabaseServiceProviderTest.php diff --git a/src/database/src/ConnectionResolver.php b/src/database/src/ConnectionResolver.php index 17104016b..5b7a04fe5 100755 --- a/src/database/src/ConnectionResolver.php +++ b/src/database/src/ConnectionResolver.php @@ -4,11 +4,13 @@ namespace Hypervel\Database; +use Closure; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Container\Container; use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Pool\PooledConnection; use Hypervel\Database\Pool\PoolFactory; +use Throwable; use UnitEnum; use function Hypervel\Support\enum_value; @@ -16,9 +18,7 @@ /** * Resolves database connections from a connection pool. * - * Uses Context to store connections per-coroutine and defer() - * to automatically release connections back to the pool when the - * coroutine ends. + * Retains pooled wrappers until their owning coroutine or task ends. */ class ConnectionResolver implements ConnectionResolverInterface { @@ -41,6 +41,13 @@ class ConnectionResolver implements ConnectionResolverInterface protected PoolFactory $factory; + /** + * Pooled wrappers retained by non-coroutine task execution. + * + * @var array + */ + protected array $nonCoroutineConnections = []; + public function __construct( protected Container $container ) { @@ -66,7 +73,8 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa : $name; $connectionName = ConnectionName::parse($name); - $contextKey = $this->getContextKey($connectionName->requested); + $connectionOwnerName = $connectionName->requested; + $contextKey = $this->getContextKey($connectionOwnerName); // Check if this coroutine already has a connection if (CoroutineContext::has($contextKey)) { @@ -79,32 +87,88 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa // Get a pooled connection wrapper from the pool $pool = $this->factory->getPool($connectionName->requested); + // Role aliases of one shared in-memory PDO must share its sole wrapper owner. + if ($pool->getSharedInMemorySqlitePdo() !== null) { + $connectionOwnerName = $pool->getName(); + $contextKey = $this->getContextKey($connectionOwnerName); + + if (CoroutineContext::has($contextKey)) { + $connection = CoroutineContext::get($contextKey); + + if ($connection instanceof ConnectionInterface) { + if ($connectionName->isWrite() && $connection instanceof Connection) { + $connection->useWriteConnectionWhenReading(); + } + + return $connection; + } + } + } + /** @var PooledConnection $pooledConnection */ $pooledConnection = $pool->get(); try { - // Get the actual database connection from the wrapper $connection = $pooledConnection->getConnection(); if ($connectionName->isWrite() && $connection instanceof Connection) { $connection->useWriteConnectionWhenReading(); } - // Store in context for this coroutine CoroutineContext::set($contextKey, $connection); - } finally { - // Schedule cleanup when coroutine ends + if (Coroutine::inCoroutine()) { - Coroutine::defer(function () use ($pooledConnection, $contextKey) { - CoroutineContext::set($contextKey, null); + Coroutine::defer(function () use ($pooledConnection, $contextKey): void { + CoroutineContext::forget($contextKey); $pooledConnection->release(); }); + } else { + $this->nonCoroutineConnections[$connectionOwnerName] = $pooledConnection; + } + } catch (Throwable $exception) { + CoroutineContext::forget($contextKey); + unset($this->nonCoroutineConnections[$connectionOwnerName]); + + try { + $pooledConnection->discard(); + } catch (Throwable) { + // Preserve the connection-creation or publication failure. } + + throw $exception; } return $connection; } + /** + * Release connections retained by non-coroutine task execution. + * + * @internal + */ + public function releaseConnections(): void + { + $this->terminateConnections( + static function (PooledConnection $connection): void { + $connection->release(); + }, + ); + } + + /** + * Discard connections retained by non-coroutine task execution. + * + * @internal + */ + public function discardConnections(): void + { + $this->terminateConnections( + static function (PooledConnection $connection): void { + $connection->discard(); + }, + ); + } + /** * Get the default connection name. * @@ -140,4 +204,33 @@ protected function getContextKey(string $name): string { return sprintf('__database.connection.%s', $name); } + + /** + * Detach and terminate retained non-coroutine connections. + */ + protected function terminateConnections(Closure $terminate): void + { + $connections = $this->nonCoroutineConnections; + $this->nonCoroutineConnections = []; + + foreach (array_keys($connections) as $name) { + CoroutineContext::forget($this->getContextKey($name)); + } + + CoroutineContext::forget(self::DEFAULT_CONNECTION_CONTEXT_KEY); + + $exception = null; + + foreach ($connections as $connection) { + try { + $terminate($connection); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; + } + } } diff --git a/src/database/src/DatabaseServiceProvider.php b/src/database/src/DatabaseServiceProvider.php index b61d79da6..288343f3e 100644 --- a/src/database/src/DatabaseServiceProvider.php +++ b/src/database/src/DatabaseServiceProvider.php @@ -6,7 +6,10 @@ use Faker\Factory as FakerFactory; use Faker\Generator as FakerGenerator; +use Hypervel\Contracts\Queue\EntityResolver; +use Hypervel\Core\Events\BeforeServerFork; use Hypervel\Core\Events\BeforeWorkerStart; +use Hypervel\Core\Events\TaskTerminated; use Hypervel\Database\Connectors\ConnectionFactory; use Hypervel\Database\Console\DbCommand; use Hypervel\Database\Console\DumpCommand; @@ -28,12 +31,14 @@ use Hypervel\Database\Console\TableCommand; use Hypervel\Database\Console\WipeCommand; use Hypervel\Database\Eloquent\Model; -use Hypervel\Database\Listeners\UnsetContextInTaskWorkerListener; +use Hypervel\Database\Eloquent\QueueEntityResolver; +use Hypervel\Database\Listeners\DatabaseConnectionLifecycleListener; use Hypervel\Database\Migrations\DatabaseMigrationRepository; use Hypervel\Database\Migrations\MigrationCreator; use Hypervel\Database\Migrations\Migrator; use Hypervel\Database\Schema\SchemaProxy; use Hypervel\Support\ServiceProvider; +use Swoole\Constant; class DatabaseServiceProvider extends ServiceProvider { @@ -44,6 +49,7 @@ public function register(): void { $this->registerConnectionServices(); $this->registerFakerGenerator(); + $this->registerQueueableEntityResolver(); $this->app->singleton('db.resolver', fn ($app) => $app->make(ConnectionResolver::class)); @@ -149,18 +155,41 @@ protected function registerFakerGenerator(): void }); } + /** + * Register the queueable entity resolver implementation. + */ + protected function registerQueueableEntityResolver(): void + { + $this->app->singleton(EntityResolver::class, QueueEntityResolver::class); + } + /** * Bootstrap the service provider. */ public function boot(): void { - Model::setConnectionResolver($this->app['db']); - Model::setEventDispatcher($this->app['events']); + Model::setConnectionResolver($this->app->make('db')); + Model::setEventDispatcher($this->app->make('events')); + + $events = $this->app->make('events'); + $listener = fn (): DatabaseConnectionLifecycleListener => $this->app->make( + DatabaseConnectionLifecycleListener::class + ); + + if (! $this->app->make('config')->boolean( + 'server.settings.' . Constant::OPTION_TASK_ENABLE_COROUTINE + )) { + $events->listen(TaskTerminated::class, function () use ($listener): void { + $listener()->releaseTaskConnections(); + }); + } - $events = $this->app['events']; + $events->listen(BeforeServerFork::class, function () use ($listener): void { + $listener()->discardProcessConnections(); + }); - $events->listen(BeforeWorkerStart::class, function (BeforeWorkerStart $event) { - $this->app->make(UnsetContextInTaskWorkerListener::class)->handle($event); + $events->listen(BeforeWorkerStart::class, function () use ($listener): void { + $listener()->discardProcessConnections(); }); } } diff --git a/src/database/src/Listeners/DatabaseConnectionLifecycleListener.php b/src/database/src/Listeners/DatabaseConnectionLifecycleListener.php new file mode 100644 index 000000000..4ea00480d --- /dev/null +++ b/src/database/src/Listeners/DatabaseConnectionLifecycleListener.php @@ -0,0 +1,66 @@ +container->resolved('db.resolver')) { + return; + } + + $resolver = $this->container->make('db.resolver'); + + if ($resolver instanceof ConnectionResolver) { + $resolver->releaseConnections(); + } + } + + /** + * Discard inherited connections and close resolved pools. + */ + public function discardProcessConnections(): void + { + $exception = null; + + if ($this->container->resolved('db.resolver')) { + try { + $resolver = $this->container->make('db.resolver'); + + if ($resolver instanceof ConnectionResolver) { + $resolver->discardConnections(); + } + } catch (Throwable $throwable) { + $exception = $throwable; + } + } + + if ($this->container->resolved(PoolFactory::class)) { + try { + $this->container->make(PoolFactory::class)->flushAll(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; + } + } +} diff --git a/src/database/src/Listeners/UnsetContextInTaskWorkerListener.php b/src/database/src/Listeners/UnsetContextInTaskWorkerListener.php deleted file mode 100644 index 244bf7889..000000000 --- a/src/database/src/Listeners/UnsetContextInTaskWorkerListener.php +++ /dev/null @@ -1,44 +0,0 @@ -server->taskworker) { - return; - } - - $connectionResolver = $this->container->make('db.resolver'); - $connections = $this->config->array('database.connections', []); - - foreach (array_keys($connections) as $name) { - $contextKey = (fn () => $this->getContextKey($name))->call($connectionResolver); // @phpstan-ignore method.notFound (Closure::call() binds to concrete ConnectionResolver which has protected getContextKey()) - CoroutineContext::forget($contextKey); - } - } -} diff --git a/tests/Database/ConnectionResolverSetDefaultConnectionTest.php b/tests/Database/ConnectionResolverSetDefaultConnectionTest.php deleted file mode 100644 index fb5ae91fa..000000000 --- a/tests/Database/ConnectionResolverSetDefaultConnectionTest.php +++ /dev/null @@ -1,126 +0,0 @@ -makeResolver('pgsql'); - - $resolver->setDefaultConnection('reporting'); - - $this->assertSame( - 'reporting', - CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY), - ); - } - - public function testSetDefaultConnectionWithNullClearsContextOverride() - { - $resolver = $this->makeResolver('pgsql'); - - CoroutineContext::set(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY, 'reporting'); - - $resolver->setDefaultConnection(null); - - $this->assertNull( - CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY), - ); - } - - public function testGetDefaultConnectionFallsBackToConfigCapturedAtConstruction() - { - $resolver = $this->makeResolver('pgsql'); - - // No override set — should fall back to the config-captured default - $this->assertSame('pgsql', $resolver->getDefaultConnection()); - - // Override, then clear — should fall back again - $resolver->setDefaultConnection('reporting'); - $this->assertSame('reporting', $resolver->getDefaultConnection()); - - $resolver->setDefaultConnection(null); - $this->assertSame('pgsql', $resolver->getDefaultConnection()); - } - - public function testOverrideInOneCoroutineIsNotVisibleInSibling() - { - $resolver = $this->makeResolver('pgsql'); - - $observations = []; - - Coroutine::create(function () use ($resolver, &$observations) { - $resolver->setDefaultConnection('reporting'); - $observations['parent'] = $resolver->getDefaultConnection(); - - Coroutine::create(function () use ($resolver, &$observations) { - $observations['sibling'] = $resolver->getDefaultConnection(); - }); - }); - - $this->assertSame('reporting', $observations['parent']); - $this->assertSame( - 'pgsql', - $observations['sibling'], - 'Sibling coroutine must see config-derived default, not the parent\'s override', - ); - } - - public function testNestedOverrideRestoresExactPriorValue() - { - $resolver = $this->makeResolver('pgsql'); - - $resolver->setDefaultConnection('outer'); - $this->assertSame('outer', $resolver->getDefaultConnection()); - - $previous = CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY); - try { - $resolver->setDefaultConnection('inner'); - $this->assertSame('inner', $resolver->getDefaultConnection()); - } finally { - if ($previous === null) { - CoroutineContext::forget(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY); - } else { - CoroutineContext::set(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY, $previous); - } - } - - $this->assertSame('outer', $resolver->getDefaultConnection()); - } - - protected function makeResolver(string $configuredDefault): ConnectionResolver - { - $app = Container::getInstance(); - $app->instance('config', new Repository([ - 'database' => ['default' => $configuredDefault], - ])); - $app->instance(PoolFactory::class, m::mock(PoolFactory::class)); - - return new ConnectionResolver($app); - } -} diff --git a/tests/Database/ConnectionResolverTest.php b/tests/Database/ConnectionResolverTest.php new file mode 100644 index 000000000..3200b0bac --- /dev/null +++ b/tests/Database/ConnectionResolverTest.php @@ -0,0 +1,400 @@ +makeResolver('pgsql'); + + $resolver->setDefaultConnection('reporting'); + + $this->assertSame( + 'reporting', + CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY), + ); + } + + public function testSetDefaultConnectionWithNullClearsContextOverride(): void + { + $resolver = $this->makeResolver('pgsql'); + + CoroutineContext::set(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY, 'reporting'); + + $resolver->setDefaultConnection(null); + + $this->assertNull( + CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY), + ); + } + + public function testGetDefaultConnectionFallsBackToConfigCapturedAtConstruction(): void + { + $resolver = $this->makeResolver('pgsql'); + + // No override set — should fall back to the config-captured default + $this->assertSame('pgsql', $resolver->getDefaultConnection()); + + // Override, then clear — should fall back again + $resolver->setDefaultConnection('reporting'); + $this->assertSame('reporting', $resolver->getDefaultConnection()); + + $resolver->setDefaultConnection(null); + $this->assertSame('pgsql', $resolver->getDefaultConnection()); + } + + public function testOverrideInOneCoroutineIsNotVisibleInSibling(): void + { + $resolver = $this->makeResolver('pgsql'); + + $observations = []; + + run(function () use ($resolver, &$observations): void { + Coroutine::create(function () use ($resolver, &$observations) { + $resolver->setDefaultConnection('reporting'); + $observations['parent'] = $resolver->getDefaultConnection(); + + Coroutine::create(function () use ($resolver, &$observations) { + $observations['sibling'] = $resolver->getDefaultConnection(); + }); + }); + }); + + $this->assertSame('reporting', $observations['parent']); + $this->assertSame( + 'pgsql', + $observations['sibling'], + 'Sibling coroutine must see config-derived default, not the parent\'s override', + ); + } + + public function testNestedOverrideRestoresExactPriorValue(): void + { + $resolver = $this->makeResolver('pgsql'); + + $resolver->setDefaultConnection('outer'); + $this->assertSame('outer', $resolver->getDefaultConnection()); + + $previous = CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY); + try { + $resolver->setDefaultConnection('inner'); + $this->assertSame('inner', $resolver->getDefaultConnection()); + } finally { + if ($previous === null) { + CoroutineContext::forget(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY); + } else { + CoroutineContext::set(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY, $previous); + } + } + + $this->assertSame('outer', $resolver->getDefaultConnection()); + } + + public function testNonCoroutineConnectionIsRetainedUntilTerminalRelease(): void + { + $factory = m::mock(PoolFactory::class); + $pool = m::mock(DbPool::class); + $firstWrapper = m::mock(PooledConnection::class); + $secondWrapper = m::mock(PooledConnection::class); + $firstConnection = m::mock(Connection::class); + $secondConnection = m::mock(Connection::class); + + $factory->expects('getPool')->twice()->with('mysql')->andReturn($pool); + $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); + $pool->expects('get')->twice()->andReturn($firstWrapper, $secondWrapper); + $firstWrapper->expects('getConnection')->andReturn($firstConnection); + $firstWrapper->expects('release'); + $secondWrapper->expects('getConnection')->andReturn($secondConnection); + $secondWrapper->expects('release'); + + $resolver = $this->makeResolver('mysql', $factory); + + $this->assertSame($firstConnection, $resolver->connection()); + $this->assertSame($firstConnection, $resolver->connection()); + + $resolver->releaseConnections(); + + $this->assertSame($secondConnection, $resolver->connection()); + + $resolver->releaseConnections(); + } + + public function testTerminalReleaseOwnsEveryRequestedConnectionRole(): void + { + $factory = m::mock(PoolFactory::class); + $resolver = $this->makeResolver('mysql', $factory); + + foreach (['mysql', 'mysql::read', 'mysql::write'] as $name) { + $pool = m::mock(DbPool::class); + $wrapper = m::mock(PooledConnection::class); + $connection = m::mock(Connection::class); + + $factory->expects('getPool')->once()->with($name)->andReturn($pool); + $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); + $pool->expects('get')->once()->andReturn($wrapper); + $wrapper->expects('getConnection')->andReturn($connection); + $wrapper->expects('release'); + + if ($name === 'mysql::write') { + $connection->expects('useWriteConnectionWhenReading'); + } + + $this->assertSame($connection, $resolver->connection($name)); + } + + $resolver->releaseConnections(); + } + + public function testSharedInMemorySqliteAliasesReuseOneConnectionOwner(): void + { + $factory = m::mock(PoolFactory::class); + $pool = m::mock(DbPool::class); + $wrapper = m::mock(PooledConnection::class); + $connection = m::mock(Connection::class); + + $factory->expects('getPool')->once()->with('sqlite')->andReturn($pool); + $factory->expects('getPool')->once()->with('sqlite::read')->andReturn($pool); + $factory->expects('getPool')->once()->with('sqlite::write')->andReturn($pool); + $pool->expects('getSharedInMemorySqlitePdo')->times(3)->andReturn(m::mock(PDO::class)); + $pool->expects('getName')->times(3)->andReturn('sqlite'); + $pool->expects('get')->once()->andReturn($wrapper); + $wrapper->expects('getConnection')->once()->andReturn($connection); + $connection->expects('useWriteConnectionWhenReading')->once(); + $wrapper->expects('release')->once(); + + $resolver = $this->makeResolver('sqlite', $factory); + + $this->assertSame($connection, $resolver->connection('sqlite')); + $this->assertSame($connection, $resolver->connection('sqlite::read')); + $this->assertSame($connection, $resolver->connection('sqlite::write')); + + $resolver->releaseConnections(); + } + + public function testTerminalStateIsDetachedBeforeReleaseCanReenterTheResolver(): void + { + $factory = m::mock(PoolFactory::class); + $pool = m::mock(DbPool::class); + $firstWrapper = m::mock(PooledConnection::class); + $secondWrapper = m::mock(PooledConnection::class); + $firstConnection = m::mock(Connection::class); + $secondConnection = m::mock(Connection::class); + + $factory->expects('getPool')->twice()->with('mysql')->andReturn($pool); + $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); + $pool->expects('get')->twice()->andReturn($firstWrapper, $secondWrapper); + $firstWrapper->expects('getConnection')->andReturn($firstConnection); + $secondWrapper->expects('getConnection')->andReturn($secondConnection); + $secondWrapper->expects('release'); + + $resolver = $this->makeResolver('mysql', $factory); + $resolver->setDefaultConnection('reporting'); + + $firstWrapper->expects('release')->andReturnUsing(function () use ($resolver, $secondConnection): void { + $this->assertSame('mysql', $resolver->getDefaultConnection()); + $this->assertSame($secondConnection, $resolver->connection('mysql')); + }); + + $this->assertSame($firstConnection, $resolver->connection('mysql')); + + $resolver->releaseConnections(); + $resolver->releaseConnections(); + } + + public function testTerminalReleaseExhaustsConnectionsAndPreservesTheFirstFailure(): void + { + $firstException = new RuntimeException('First release failed.'); + $secondException = new RuntimeException('Second release failed.'); + $factory = m::mock(PoolFactory::class); + $resolver = $this->makeResolver('first', $factory); + + foreach ([ + ['first', $firstException], + ['second', $secondException], + ] as [$name, $exception]) { + $pool = m::mock(DbPool::class); + $wrapper = m::mock(PooledConnection::class); + + $factory->expects('getPool')->once()->with($name)->andReturn($pool); + $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); + $pool->expects('get')->once()->andReturn($wrapper); + $wrapper->expects('getConnection')->andReturn(m::mock(Connection::class)); + $wrapper->expects('release')->andThrow($exception); + + $resolver->connection($name); + } + + try { + $resolver->releaseConnections(); + $this->fail('Expected the first release failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($firstException, $throwable); + } + + $resolver->releaseConnections(); + } + + public function testTerminalDiscardExhaustsExactConnections(): void + { + $factory = m::mock(PoolFactory::class); + $resolver = $this->makeResolver('first', $factory); + + foreach (['first', 'second'] as $name) { + $pool = m::mock(DbPool::class); + $wrapper = m::mock(PooledConnection::class); + + $factory->expects('getPool')->once()->with($name)->andReturn($pool); + $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); + $pool->expects('get')->once()->andReturn($wrapper); + $wrapper->expects('getConnection')->andReturn(m::mock(Connection::class)); + $wrapper->expects('discard'); + + $resolver->connection($name); + } + + $resolver->discardConnections(); + $resolver->discardConnections(); + } + + public function testConnectionRetrievalFailureDiscardsTheExactWrapper(): void + { + $exception = new RuntimeException('Connection retrieval failed.'); + $factory = m::mock(PoolFactory::class); + $pool = m::mock(DbPool::class); + $wrapper = m::mock(PooledConnection::class); + + $factory->expects('getPool')->once()->with('mysql')->andReturn($pool); + $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); + $pool->expects('get')->once()->andReturn($wrapper); + $wrapper->expects('getConnection')->andThrow($exception); + $wrapper->expects('discard'); + + $resolver = $this->makeResolver('mysql', $factory); + + try { + $resolver->connection(); + $this->fail('Expected the connection retrieval failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($exception, $throwable); + } + } + + public function testWriteRoleConfigurationFailureDiscardsTheExactWrapper(): void + { + $exception = new RuntimeException('Write role configuration failed.'); + $factory = m::mock(PoolFactory::class); + $pool = m::mock(DbPool::class); + $wrapper = m::mock(PooledConnection::class); + $connection = m::mock(Connection::class); + + $factory->expects('getPool')->once()->with('mysql::write')->andReturn($pool); + $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); + $pool->expects('get')->once()->andReturn($wrapper); + $wrapper->expects('getConnection')->andReturn($connection); + $connection->expects('useWriteConnectionWhenReading')->andThrow($exception); + $wrapper->expects('discard'); + + $resolver = $this->makeResolver('mysql', $factory); + + try { + $resolver->connection('mysql::write'); + $this->fail('Expected the write role configuration failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($exception, $throwable); + } + } + + public function testDiscardFailureDoesNotReplaceTheSetupFailure(): void + { + $setupException = new RuntimeException('Connection retrieval failed.'); + $discardException = new RuntimeException('Discard failed.'); + $factory = m::mock(PoolFactory::class); + $pool = m::mock(DbPool::class); + $wrapper = m::mock(PooledConnection::class); + + $factory->expects('getPool')->once()->with('mysql')->andReturn($pool); + $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); + $pool->expects('get')->once()->andReturn($wrapper); + $wrapper->expects('getConnection')->andThrow($setupException); + $wrapper->expects('discard')->andThrow($discardException); + + $resolver = $this->makeResolver('mysql', $factory); + + try { + $resolver->connection(); + $this->fail('Expected the connection retrieval failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($setupException, $throwable); + } + } + + public function testCoroutineConnectionRemainsDeferOwned(): void + { + $factory = m::mock(PoolFactory::class); + $pool = m::mock(DbPool::class); + $wrapper = m::mock(PooledConnection::class); + $connection = m::mock(Connection::class); + + $factory->expects('getPool')->once()->with('mysql')->andReturn($pool); + $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); + $pool->expects('get')->once()->andReturn($wrapper); + $wrapper->expects('getConnection')->andReturn($connection); + $wrapper->expects('release'); + + $resolver = $this->makeResolver('mysql', $factory); + + run(function () use ($resolver, $connection): void { + $this->assertSame($connection, $resolver->connection()); + $resolver->releaseConnections(); + }); + + $resolver->releaseConnections(); + } + + protected function makeResolver( + string $configuredDefault, + ?PoolFactory $factory = null, + ): ConnectionResolver { + $app = Container::getInstance(); + $app->instance('config', new Repository([ + 'database' => ['default' => $configuredDefault], + ])); + $app->instance(PoolFactory::class, $factory ?? m::mock(PoolFactory::class)); + + return new ConnectionResolver($app); + } +} diff --git a/tests/Database/DatabaseConnectionLifecycleListenerTest.php b/tests/Database/DatabaseConnectionLifecycleListenerTest.php new file mode 100644 index 000000000..4c9a60d97 --- /dev/null +++ b/tests/Database/DatabaseConnectionLifecycleListenerTest.php @@ -0,0 +1,115 @@ +expects('resolved')->with('db.resolver')->andReturnFalse(); + $container->shouldNotReceive('make'); + + (new DatabaseConnectionLifecycleListener($container))->releaseTaskConnections(); + } + + public function testTaskCleanupReleasesTheConcretePooledResolver(): void + { + $resolver = m::mock(ConnectionResolver::class); + $resolver->expects('releaseConnections'); + $container = m::mock(Container::class); + $container->expects('resolved')->with('db.resolver')->andReturnTrue(); + $container->expects('make')->with('db.resolver')->andReturn($resolver); + + (new DatabaseConnectionLifecycleListener($container))->releaseTaskConnections(); + } + + public function testTaskCleanupLeavesCustomResolversAlone(): void + { + $resolver = m::mock(SimpleConnectionResolver::class); + $container = m::mock(Container::class); + $container->expects('resolved')->with('db.resolver')->andReturnTrue(); + $container->expects('make')->with('db.resolver')->andReturn($resolver); + + (new DatabaseConnectionLifecycleListener($container))->releaseTaskConnections(); + } + + public function testProcessCleanupDoesNotResolveUnusedOwners(): void + { + $container = m::mock(Container::class); + $container->expects('resolved')->with('db.resolver')->andReturnFalse(); + $container->expects('resolved')->with(PoolFactory::class)->andReturnFalse(); + $container->shouldNotReceive('make'); + + (new DatabaseConnectionLifecycleListener($container))->discardProcessConnections(); + } + + public function testProcessCleanupDiscardsResolverAndFlushesPoolFactory(): void + { + $resolver = m::mock(ConnectionResolver::class); + $resolver->expects('discardConnections'); + $factory = m::mock(PoolFactory::class); + $factory->expects('flushAll'); + $container = m::mock(Container::class); + $container->expects('resolved')->with('db.resolver')->andReturnTrue(); + $container->expects('make')->with('db.resolver')->andReturn($resolver); + $container->expects('resolved')->with(PoolFactory::class)->andReturnTrue(); + $container->expects('make')->with(PoolFactory::class)->andReturn($factory); + + (new DatabaseConnectionLifecycleListener($container))->discardProcessConnections(); + } + + public function testResolverFailureDoesNotSkipPoolFlushAndRemainsPrimary(): void + { + $resolverException = new RuntimeException('Resolver discard failed.'); + $factoryException = new RuntimeException('Pool flush failed.'); + $resolver = m::mock(ConnectionResolver::class); + $resolver->expects('discardConnections')->andThrow($resolverException); + $factory = m::mock(PoolFactory::class); + $factory->expects('flushAll')->andThrow($factoryException); + $container = m::mock(Container::class); + $container->expects('resolved')->with('db.resolver')->andReturnTrue(); + $container->expects('make')->with('db.resolver')->andReturn($resolver); + $container->expects('resolved')->with(PoolFactory::class)->andReturnTrue(); + $container->expects('make')->with(PoolFactory::class)->andReturn($factory); + + try { + (new DatabaseConnectionLifecycleListener($container))->discardProcessConnections(); + $this->fail('Expected the resolver failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($resolverException, $throwable); + } + } + + public function testPoolFactoryFailurePropagatesAfterResolverCleanup(): void + { + $exception = new RuntimeException('Pool flush failed.'); + $resolver = m::mock(ConnectionResolver::class); + $resolver->expects('discardConnections'); + $factory = m::mock(PoolFactory::class); + $factory->expects('flushAll')->andThrow($exception); + $container = m::mock(Container::class); + $container->expects('resolved')->with('db.resolver')->andReturnTrue(); + $container->expects('make')->with('db.resolver')->andReturn($resolver); + $container->expects('resolved')->with(PoolFactory::class)->andReturnTrue(); + $container->expects('make')->with(PoolFactory::class)->andReturn($factory); + + try { + (new DatabaseConnectionLifecycleListener($container))->discardProcessConnections(); + $this->fail('Expected the pool factory failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($exception, $throwable); + } + } +} diff --git a/tests/Database/DatabaseServiceProviderTest.php b/tests/Database/DatabaseServiceProviderTest.php new file mode 100644 index 000000000..6399b3cc6 --- /dev/null +++ b/tests/Database/DatabaseServiceProviderTest.php @@ -0,0 +1,62 @@ +assertInstanceOf( + QueueEntityResolver::class, + $this->app->make(EntityResolver::class), + ); + } + + public function testNonCoroutineTaskLifecycleListenersAreRegistered(): void + { + $events = $this->bootProviderWithTaskCoroutines(false); + + $this->assertTrue($events->hasListeners(TaskTerminated::class)); + $this->assertTrue($events->hasListeners(BeforeServerFork::class)); + $this->assertTrue($events->hasListeners(BeforeWorkerStart::class)); + } + + public function testCoroutineTasksDoNotRegisterTerminalTaskCleanup(): void + { + $events = $this->bootProviderWithTaskCoroutines(true); + + $this->assertFalse($events->hasListeners(TaskTerminated::class)); + $this->assertTrue($events->hasListeners(BeforeServerFork::class)); + $this->assertTrue($events->hasListeners(BeforeWorkerStart::class)); + } + + /** + * Boot a fresh provider against an isolated event dispatcher. + */ + private function bootProviderWithTaskCoroutines(bool $enabled): Dispatcher + { + $this->app->make('config')->set( + 'server.settings.' . Constant::OPTION_TASK_ENABLE_COROUTINE, + $enabled, + ); + + $events = new Dispatcher($this->app); + $this->app->instance('events', $events); + + (new DatabaseServiceProvider($this->app))->boot(); + + return $events; + } +} From 79915d8b82bb4251168831f8bbe57c0083e7953b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:08:28 +0000 Subject: [PATCH 05/26] fix(redis): preserve same-connection command ownership Register at most one terminal defer per pool and owning coroutine while keeping callback-form transactions and pipelines on their immediate-release path. Use coroutine owner IDs so copied context cannot inherit a defer that the child does not own. Keep WATCH, MULTI, PIPELINE, EXEC, UNWATCH, RESET, and native DISCARD state on the exact wrapper generation. Complete command event handling before ownership handoff, discard abandoned native queue or watch state, and preserve established failure precedence without network checks or a parallel transaction model. Add unit and live Redis coverage for copied contexts, bounded defer retention, callback release, optimistic conflicts, listener failures, selected databases, native mode recovery, and pool generation replacement. --- src/redis/src/RedisConnection.php | 102 +++++- src/redis/src/RedisProxy.php | 133 ++++++-- src/redis/src/Traits/MultiExec.php | 13 +- .../Redis/RedisProxyIntegrationTest.php | 169 ++++++++++ tests/Redis/MultiExecTest.php | 1 + tests/Redis/RedisConnectionTest.php | 262 +++++++++++++++ tests/Redis/RedisProxyTest.php | 309 ++++++++++++++++++ 7 files changed, 952 insertions(+), 37 deletions(-) diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index be90346ec..1a352642a 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -383,6 +383,11 @@ abstract class RedisConnection extends BaseConnection */ protected ?int $database = null; + /** + * Determine if the native connection is watching keys. + */ + protected bool $watching = false; + /** * Determine if the connection calls should be transformed to Laravel style. */ @@ -402,10 +407,21 @@ public function __construct(Container $container, PoolInterface $pool, array $co public function __call($name, $arguments) { try { - return $this->executeCommand($name, $arguments); + $result = $this->executeCommand($name, $arguments); } catch (RedisException $exception) { - return $this->retry($name, $arguments, $exception); + $result = $this->retry($name, $arguments, $exception); + } + + if ($name === 'watch' && $result !== false) { + $this->watching = true; + } elseif ( + in_array($name, ['exec', 'reset'], true) + || ($name === 'unwatch' && $result !== false) + ) { + $this->watching = false; } + + return $result; } /** @@ -526,6 +542,7 @@ protected function markReconnected(): void $this->pool->getOption()->getMaxLifetime() ); $this->availableForReuse = false; + $this->watching = false; $this->markValid(); } @@ -637,6 +654,7 @@ public function close(): bool } $this->connection = null; + $this->watching = false; return true; } @@ -682,20 +700,92 @@ public function release(): void $this->shouldTransform = false; try { - $defaultDb = (int) ($this->config['database'] ?? 0); - if ($this->database !== null && $this->database !== $defaultDb) { - $this->select($defaultDb); + $queueing = $this->isQueueingMode(); + } catch (Throwable $exception) { + $this->markInvalid(); + + try { + $this->log('Release connection failed, caused by ' . $exception, LogLevel::CRITICAL); + } catch (Throwable) { + // Reporting must not prevent terminal ownership cleanup. + } + + $this->database = null; + $this->watching = false; + $this->availableForReuse = true; + parent::release(); + + return; + } + + if ($queueing || $this->watching) { + try { + $this->log( + $queueing + ? 'Discarding Redis connection left in MULTI or PIPELINE mode.' + : 'Discarding Redis connection left in WATCH state.', + LogLevel::CRITICAL + ); + } catch (Throwable) { + // Reporting must not prevent terminal ownership cleanup. + } + + $this->database = null; + $this->watching = false; + $this->availableForReuse = false; + $this->discard(); + + return; + } + + try { + $defaultDatabase = (int) ($this->config['database'] ?? 0); + + if ($this->database !== null && $this->database !== $defaultDatabase) { + $this->select($defaultDatabase); } } catch (Throwable $exception) { - $this->log('Release connection failed, caused by ' . $exception, LogLevel::CRITICAL); $this->markInvalid(); + + try { + $this->log('Release connection failed, caused by ' . $exception, LogLevel::CRITICAL); + } catch (Throwable) { + // Reporting must not prevent terminal ownership cleanup. + } } finally { $this->database = null; + $this->watching = false; $this->availableForReuse = true; parent::release(); } } + /** + * Execute the native Redis DISCARD command. + * + * @internal + */ + public function discardTransaction(): bool|Redis + { + $result = $this->connection->discard(); + + if ($result !== false) { + $this->watching = false; + } + + return $result; + } + + /** + * Clear the tracked watch state after callback-form transaction completion. + * + * @internal + */ + public function clearWatchState(): void + { + $this->watching = false; + } + /** * Set current redis database. */ diff --git a/src/redis/src/RedisProxy.php b/src/redis/src/RedisProxy.php index 58a6c14d3..90df46b74 100644 --- a/src/redis/src/RedisProxy.php +++ b/src/redis/src/RedisProxy.php @@ -26,8 +26,8 @@ * * Each proxy represents a named Redis connection. Commands are executed by * checking out a connection from the pool, running the command, and releasing - * the connection back. Context management ensures that multi/pipeline/select - * commands reuse the same connection within a coroutine. + * the connection back. Context management ensures that stateful commands reuse + * the same connection within a coroutine. * * @mixin \Hypervel\Redis\RedisConnection */ @@ -40,6 +40,11 @@ class RedisProxy implements ConnectionContract */ public const CONNECTION_CONTEXT_PREFIX = '__redis.connection.'; + /** + * Context key prefix for the coroutine owning deferred pool cleanup. + */ + private const DEFERRED_RELEASE_OWNER_CONTEXT_KEY_PREFIX = '__redis.deferred_release_owner.'; + /** * Methods that must be called while explicitly holding a pool connection. * @@ -49,8 +54,10 @@ class RedisProxy implements ConnectionContract 'auth', 'check', 'client', + 'clearWatchState', 'close', 'connect', + 'discardTransaction', 'getActiveConnection', 'getConnection', 'getCreatedAt', @@ -162,48 +169,86 @@ public function __call($name, $arguments) $start = hrtime(true) / 1e9; $result = null; - $exception = null; + $commandException = null; + $eventException = null; + $cleanupException = null; try { /** @var RedisConnection $connection */ $connection = $connection->getConnection(); - $result = $connection->{$name}(...$arguments); - } catch (Throwable $e) { - $exception = $e; - $time = round((hrtime(true) / 1e9 - $start) * 1000, 2); - - if ($connection->getEventDispatcher()?->hasListeners(CommandFailed::class)) { - $connection->getEventDispatcher()->dispatch( - new CommandFailed($name, $arguments, $e, $connection, $time) - ); + $result = $name === 'discard' + ? $connection->discardTransaction() + : $connection->{$name}(...$arguments); + } catch (Throwable $throwable) { + $commandException = $throwable; + + try { + if ($connection->getEventDispatcher()?->hasListeners(CommandFailed::class)) { + $time = round((hrtime(true) / 1e9 - $start) * 1000, 2); + $connection->getEventDispatcher()->dispatch( + new CommandFailed($name, $arguments, $throwable, $connection, $time) + ); + } + } catch (Throwable $throwable) { + $eventException = $throwable; } - } finally { - if ($exception === null && $connection->getEventDispatcher()?->hasListeners(CommandExecuted::class)) { - $time = round((hrtime(true) / 1e9 - $start) * 1000, 2); - $connection->getEventDispatcher()->dispatch( - new CommandExecuted($name, $arguments, $time, $connection) - ); + } + + if ($commandException === null) { + try { + if ($connection->getEventDispatcher()?->hasListeners(CommandExecuted::class)) { + $time = round((hrtime(true) / 1e9 - $start) * 1000, 2); + $connection->getEventDispatcher()->dispatch( + new CommandExecuted($name, $arguments, $time, $connection) + ); + } + } catch (Throwable $throwable) { + $eventException = $throwable; } + } + try { if ($hasContextConnection) { // Connection is already in context, don't release - } elseif ($exception === null && $this->shouldUseSameConnection($name)) { + } elseif ($commandException === null && $this->shouldUseSameConnection($name)) { // On success with same-connection command: store in context for reuse if ($name === 'select' && array_key_exists(0, $arguments)) { $connection->setDatabase((int) $arguments[0]); } + CoroutineContext::set($this->getContextKey(), $connection); - Coroutine::defer(function () { - $this->releaseContextConnection(); - }); + + $coroutineId = Coroutine::id(); + + if ($coroutineId > 0) { + $deferredReleaseOwnerContextKey = $this->getDeferredReleaseOwnerContextKey(); + + if (CoroutineContext::get($deferredReleaseOwnerContextKey) !== $coroutineId) { + Coroutine::defer(function (): void { + $this->releaseContextConnection(); + }); + + CoroutineContext::set($deferredReleaseOwnerContextKey, $coroutineId); + } + } } else { // Release the connection $connection->release(); } + } catch (Throwable $throwable) { + $cleanupException = $throwable; + } + + if ($eventException !== null) { + throw $eventException; + } + + if ($commandException !== null) { + throw $commandException; } - if ($exception !== null) { - throw $exception; + if ($cleanupException !== null) { + throw $cleanupException; } return $result; @@ -211,18 +256,38 @@ public function __call($name, $arguments) /** * Release the connection stored in coroutine context. + * + * @internal */ - protected function releaseContextConnection(): void + public function releaseContextConnection(): void { $contextKey = $this->getContextKey(); $connection = CoroutineContext::get($contextKey); - if ($connection) { - CoroutineContext::set($contextKey, null); + CoroutineContext::forget($contextKey); + + if ($connection instanceof RedisConnection) { $connection->release(); } } + /** + * Discard the connection stored in coroutine context. + * + * @internal + */ + public function discardContextConnection(): void + { + $contextKey = $this->getContextKey(); + $connection = CoroutineContext::get($contextKey); + + CoroutineContext::forget($contextKey); + + if ($connection instanceof RedisConnection) { + $connection->discard(); + } + } + /** * Handle subscribe/psubscribe using the coroutine-native subscriber. * @@ -255,8 +320,7 @@ protected function handleSubscribe(string $name, array $arguments): void } /** - * Define the commands that need same connection to execute. - * When these commands executed, the connection will storage to coroutine context. + * Determine which commands must reuse the same connection. */ protected function shouldUseSameConnection(string $methodName): bool { @@ -264,6 +328,7 @@ protected function shouldUseSameConnection(string $methodName): bool 'multi', 'pipeline', 'select', + 'watch', ], true); } @@ -297,6 +362,14 @@ protected function getContextKey(): string return self::CONNECTION_CONTEXT_PREFIX . $this->poolName; } + /** + * Get the context key for this coroutine's deferred release owner. + */ + private function getDeferredReleaseOwnerContextKey(): string + { + return self::DEFERRED_RELEASE_OWNER_CONTEXT_KEY_PREFIX . $this->poolName; + } + /** * Execute callback with a pinned connection from the pool. * @@ -351,7 +424,7 @@ public function withPinnedConnection(callable $callback): mixed return $callback(); } finally { if (! $hadContextConnection) { - CoroutineContext::set($contextKey, null); + CoroutineContext::forget($contextKey); $connection->release(); } } diff --git a/src/redis/src/Traits/MultiExec.php b/src/redis/src/Traits/MultiExec.php index b777db759..578c98588 100644 --- a/src/redis/src/Traits/MultiExec.php +++ b/src/redis/src/Traits/MultiExec.php @@ -5,6 +5,7 @@ namespace Hypervel\Redis\Traits; use Hypervel\Context\CoroutineContext; +use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; use Redis; use RedisCluster; @@ -53,7 +54,17 @@ private function executeMultiExec(string $command, ?callable $callback = null) $instance = $this->__call($command, []); try { - return tap($instance, $callback)->exec(); + $result = tap($instance, $callback)->exec(); + + if ($command === 'multi' && $hasExistingConnection) { + $connection = CoroutineContext::get($this->getContextKey()); + + if ($connection instanceof RedisConnection) { + $connection->clearWatchState(); + } + } + + return $result; } finally { if (! $hasExistingConnection) { $this->releaseContextConnection(); diff --git a/tests/Integration/Redis/RedisProxyIntegrationTest.php b/tests/Integration/Redis/RedisProxyIntegrationTest.php index 0545c5158..1ac363c9e 100644 --- a/tests/Integration/Redis/RedisProxyIntegrationTest.php +++ b/tests/Integration/Redis/RedisProxyIntegrationTest.php @@ -7,6 +7,7 @@ use Hypervel\Engine\Channel; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Redis\RedisConnection; +use Hypervel\Redis\RedisProxy; use Hypervel\Support\Facades\Redis; use Hypervel\Testbench\TestCase; use Redis as PhpRedis; @@ -444,6 +445,161 @@ public function testTransactionCallbackRunsCommands(): void $this->assertSame('3', $redis->get($key)); } + public function testAbandonedMultiDiscardsTheNativeGeneration(): void + { + $redis = Redis::connection($this->createRedisConnectionWithOptions( + 'test_abandoned_multi', + ['prefix' => ''], + )); + $redis->flushdb(); + + $abandoned = $redis->multi(); + $redis->releaseContextConnection(); + $replacement = $this->nativeClient($redis); + + $this->assertNotSame($abandoned, $replacement); + $this->assertSame(PhpRedis::ATOMIC, $replacement->getMode()); + $this->assertTrue($redis->set('after:multi', 'healthy')); + $this->assertSame('healthy', $redis->get('after:multi')); + } + + public function testAbandonedPipelineDiscardsTheNativeGeneration(): void + { + $redis = Redis::connection($this->createRedisConnectionWithOptions( + 'test_abandoned_pipeline', + ['prefix' => ''], + )); + $redis->flushdb(); + + $abandoned = $redis->pipeline(); + $redis->releaseContextConnection(); + $replacement = $this->nativeClient($redis); + + $this->assertNotSame($abandoned, $replacement); + $this->assertSame(PhpRedis::ATOMIC, $replacement->getMode()); + $this->assertTrue($redis->set('after:pipeline', 'healthy')); + $this->assertSame('healthy', $redis->get('after:pipeline')); + } + + public function testAbandonedWatchDiscardsTheNativeGeneration(): void + { + $redis = Redis::connection($this->createRedisConnectionWithOptions( + 'test_abandoned_watch', + ['prefix' => ''], + )); + $redis->flushdb(); + + $this->assertTrue($redis->watch('watched:key')); + $abandoned = $this->nativeClient($redis); + $redis->releaseContextConnection(); + $replacement = $this->nativeClient($redis); + + $this->assertNotSame($abandoned, $replacement); + $this->assertSame(PhpRedis::ATOMIC, $replacement->getMode()); + $this->assertTrue($redis->set('after:watch', 'healthy')); + $this->assertSame('healthy', $redis->get('after:watch')); + } + + public function testWatchAndCallbackTransactionUseOneHealthyNativeClient(): void + { + $redis = Redis::connection($this->createRedisConnectionWithOptions( + 'test_watch_transaction', + ['prefix' => ''], + )); + $redis->flushdb(); + $key = 'watch:transaction'; + $redis->set($key, 'before'); + + $this->assertTrue($redis->watch($key)); + $watched = $this->nativeClient($redis); + $result = $redis->transaction(function (PhpRedis $transaction) use ($key, $watched): void { + $this->assertSame($watched, $transaction); + $transaction->get($key); + $transaction->set($key, 'after'); + }); + + $this->assertSame(['before', true], $result); + + $redis->releaseContextConnection(); + + $this->assertSame($watched, $this->nativeClient($redis)); + $this->assertSame('after', $redis->get($key)); + } + + public function testWatchConflictClearsConsumedStateAndReusesNativeClient(): void + { + $redis = Redis::connection($this->createRedisConnectionWithOptions( + 'test_watch_conflict', + ['prefix' => ''], + )); + $other = Redis::connection($this->createRedisConnectionWithOptions( + 'test_watch_conflict_other', + ['prefix' => ''], + )); + $redis->flushdb(); + $key = 'watch:conflict'; + $redis->set($key, 'before'); + + $this->assertTrue($redis->watch($key)); + $watched = $this->nativeClient($redis); + $other->set($key, 'changed'); + $result = $redis->transaction(static function (PhpRedis $transaction) use ($key): void { + $transaction->set($key, 'after'); + }); + + $this->assertFalse($result); + + $redis->releaseContextConnection(); + + $this->assertSame($watched, $this->nativeClient($redis)); + $this->assertSame('changed', $redis->get($key)); + } + + public function testCallbackPipelineDoesNotConsumeWatchState(): void + { + $redis = Redis::connection($this->createRedisConnectionWithOptions( + 'test_watch_pipeline', + ['prefix' => ''], + )); + $redis->flushdb(); + $redis->set('watch:pipeline', 'value'); + + $this->assertTrue($redis->watch('watch:pipeline')); + $watched = $this->nativeClient($redis); + $this->assertSame( + ['value'], + $redis->pipeline(static function (PhpRedis $pipeline): void { + $pipeline->get('watch:pipeline'); + }) + ); + + $redis->releaseContextConnection(); + + $this->assertNotSame($watched, $this->nativeClient($redis)); + } + + public function testNativeDiscardKeepsTheHealthyWrapperReusable(): void + { + $redis = Redis::connection($this->createRedisConnectionWithOptions( + 'test_native_discard', + ['prefix' => ''], + )); + $redis->flushdb(); + $key = 'discard:key'; + + $this->assertTrue($redis->watch($key)); + $native = $this->nativeClient($redis); + $redis->multi(); + $redis->set($key, 'discarded'); + + $this->assertTrue($redis->discard()); + + $redis->releaseContextConnection(); + + $this->assertSame($native, $this->nativeClient($redis)); + $this->assertNull($redis->get($key)); + } + public function testWithConnectionTransformFalseSupportsPipelineCallbacks(): void { $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); @@ -613,4 +769,17 @@ public function testConcurrentTransactionCallbacksWithLimitedConnectionPool(): v $redis->del("concurrent_transaction_test_{$i}_counter"); } } + + /** + * Get the exact native client held by a Redis proxy. + */ + private function nativeClient(RedisProxy $redis): PhpRedis + { + return $redis->withConnection(function (RedisConnection $connection): PhpRedis { + $client = $connection->client(); + $this->assertInstanceOf(PhpRedis::class, $client); + + return $client; + }); + } } diff --git a/tests/Redis/MultiExecTest.php b/tests/Redis/MultiExecTest.php index ca53c39b2..fd73eaf0a 100644 --- a/tests/Redis/MultiExecTest.php +++ b/tests/Redis/MultiExecTest.php @@ -169,6 +169,7 @@ public function testTransactionWithCallbackDoesNotReleaseExistingContextConnecti // Connection is NOT released during the test (it already existed in context), // but allow release() call for test cleanup $connection->shouldReceive('release')->zeroOrMoreTimes(); + $connection->shouldReceive('clearWatchState')->once(); $redis = $this->createRedis($connection); diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index 854970211..1f2df184d 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -46,6 +46,9 @@ public function testRelease(): void $pool->shouldReceive('release')->once(); $connection = $this->mockRedisConnection(pool: $pool); + $redis = m::mock(Redis::class); + $redis->expects('getMode')->andReturn(Redis::ATOMIC); + $connection->setActiveConnection($redis); $connection->shouldTransform(true); $connection->release(); @@ -61,6 +64,7 @@ public function testReleaseResetsDatabaseToConfiguredDefault(): void $redis = m::mock(Redis::class); $redis->shouldReceive('select')->once()->with(1)->andReturn(true); $redis->shouldReceive('select')->once()->with(1)->andReturn(true); + $redis->shouldReceive('getMode')->once()->andReturn(Redis::ATOMIC); $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'database' => 1], $redis) extends PhpRedisConnection { public function __construct( @@ -89,6 +93,7 @@ public function testReleaseDefaultsToDatabaseZeroWhenDbConfigIsMissing(): void $redis = m::mock(Redis::class); $redis->shouldReceive('select')->once()->with(0)->andReturn(true); + $redis->shouldReceive('getMode')->once()->andReturn(Redis::ATOMIC); $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends PhpRedisConnection { public function __construct( @@ -110,6 +115,263 @@ protected function createRedis(array $config): Redis $connection->release(); } + public function testReleaseDiscardsAConnectionInMultiMode(): void + { + $pool = $this->getMockedPool(); + $pool->expects('discard')->with(m::type(RedisConnection::class)); + $redis = m::mock(Redis::class); + $redis->expects('getMode')->andReturn(Redis::MULTI); + $connection = $this->mockRedisConnection(pool: $pool); + $connection->setActiveConnection($redis); + + $connection->release(); + } + + public function testReleaseDiscardsAConnectionInPipelineMode(): void + { + $pool = $this->getMockedPool(); + $pool->expects('discard')->with(m::type(RedisConnection::class)); + $redis = m::mock(Redis::class); + $redis->expects('getMode')->andReturn(Redis::PIPELINE); + $connection = $this->mockRedisConnection(pool: $pool); + $connection->setActiveConnection($redis); + + $connection->release(); + } + + public function testReleaseDiscardsAConnectionWithAnActiveWatch(): void + { + $pool = $this->getMockedPool(); + $pool->expects('discard')->with(m::type(RedisConnection::class)); + $redis = m::mock(Redis::class); + $redis->expects('watch')->with('key')->andReturnTrue(); + $redis->expects('getMode')->andReturn(Redis::ATOMIC); + $connection = $this->mockRedisConnection(pool: $pool); + $connection->setActiveConnection($redis); + + $this->assertTrue($connection->__call('watch', ['key'])); + + $connection->release(); + } + + #[DataProvider('watchTerminalCommandProvider')] + public function testSuccessfulTerminalCommandsClearTrackedWatchState( + string $command, + mixed $result, + ): void { + $pool = $this->getMockedPool(); + $pool->expects('release')->with(m::type(RedisConnection::class)); + $redis = m::mock(Redis::class); + $redis->expects('watch')->with('key')->andReturnTrue(); + $redis->expects($command)->andReturn($result); + $redis->expects('getMode')->andReturn(Redis::ATOMIC); + $connection = $this->mockRedisConnection(pool: $pool); + $connection->setActiveConnection($redis); + + $connection->__call('watch', ['key']); + $this->assertSame($result, $connection->__call($command, [])); + $connection->release(); + } + + public static function watchTerminalCommandProvider(): array + { + return [ + 'unwatch' => ['unwatch', true], + 'exec conflict' => ['exec', false], + 'reset' => ['reset', true], + ]; + } + + public function testFailedUnwatchRetainsTrackedWatchState(): void + { + $pool = $this->getMockedPool(); + $pool->expects('discard')->with(m::type(RedisConnection::class)); + $redis = m::mock(Redis::class); + $redis->expects('watch')->with('key')->andReturnTrue(); + $redis->expects('unwatch')->andReturnFalse(); + $redis->expects('getMode')->andReturn(Redis::ATOMIC); + $connection = $this->mockRedisConnection(pool: $pool); + $connection->setActiveConnection($redis); + + $connection->__call('watch', ['key']); + $this->assertFalse($connection->__call('unwatch', [])); + $connection->release(); + } + + public function testNativeDiscardClearsTrackedWatchState(): void + { + $pool = $this->getMockedPool(); + $pool->expects('release')->with(m::type(RedisConnection::class)); + $redis = m::mock(Redis::class); + $redis->expects('watch')->with('key')->andReturnTrue(); + $redis->expects('discard')->andReturnTrue(); + $redis->expects('getMode')->andReturn(Redis::ATOMIC); + $connection = $this->mockRedisConnection(pool: $pool); + $connection->setActiveConnection($redis); + + $connection->__call('watch', ['key']); + $this->assertTrue($connection->discardTransaction()); + $connection->release(); + } + + public function testClearWatchStateAllowsOrdinaryRelease(): void + { + $pool = $this->getMockedPool(); + $pool->expects('release')->with(m::type(RedisConnection::class)); + $redis = m::mock(Redis::class); + $redis->expects('watch')->with('key')->andReturnTrue(); + $redis->expects('getMode')->andReturn(Redis::ATOMIC); + $connection = $this->mockRedisConnection(pool: $pool); + $connection->setActiveConnection($redis); + + $connection->__call('watch', ['key']); + $connection->clearWatchState(); + $connection->release(); + } + + public function testReconnectBeginsWithNoTrackedWatchState(): void + { + $pool = $this->getMockedPool(); + $pool->expects('release')->with(m::type(RedisConnection::class)); + $redis = m::mock(Redis::class); + $redis->expects('watch')->with('key')->andReturnTrue(); + $redis->expects('getMode')->andReturn(Redis::ATOMIC); + $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends PhpRedisConnection { + public function __construct( + ContainerContract $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis, + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return $this->fakeRedis; + } + }; + + $connection->__call('watch', ['key']); + $connection->reconnect(); + $connection->release(); + } + + public function testCloseClearsTrackedWatchState(): void + { + $pool = $this->getMockedPool(); + $pool->expects('release')->with(m::type(RedisConnection::class)); + $redis = m::mock(Redis::class); + $redis->expects('watch')->with('key')->andReturnTrue(); + $redis->expects('close')->andReturnTrue(); + $connection = $this->mockRedisConnection(pool: $pool); + $connection->setActiveConnection($redis); + + $connection->__call('watch', ['key']); + $connection->close(); + $connection->release(); + } + + public function testReleaseChecksNativeModeBeforeRestoringDatabase(): void + { + $pool = $this->getMockedPool(); + $pool->expects('release')->with(m::type(RedisConnection::class)); + $redis = m::mock(Redis::class); + $redis->expects('getMode') + ->globally() + ->ordered() + ->andReturn(Redis::ATOMIC); + $redis->expects('select') + ->with(0) + ->globally() + ->ordered() + ->andReturnTrue(); + $connection = $this->mockRedisConnection(pool: $pool, options: ['database' => 0]); + $connection->setActiveConnection($redis); + $connection->setDatabase(2); + + $connection->release(); + } + + public function testModeDetectionFailureInvalidatesAndReleasesConnection(): void + { + $pool = $this->getMockedPool(); + $pool->expects('release')->with(m::type(RedisConnection::class)); + $redis = m::mock(Redis::class); + $redis->expects('getMode')->andThrow(new RuntimeException('Mode failed.')); + $connection = new class($this->getContainer(), $pool, []) extends PhpRedisConnectionStub { + public function isInvalidForTest(): bool + { + return $this->invalid; + } + }; + $connection->setActiveConnection($redis); + + $connection->release(); + + $this->assertTrue($connection->isInvalidForTest()); + } + + public function testDatabaseRestoreFailureInvalidatesAndReleasesConnection(): void + { + $pool = $this->getMockedPool(); + $pool->expects('release')->with(m::type(RedisConnection::class)); + $redis = m::mock(Redis::class); + $redis->expects('getMode')->andReturn(Redis::ATOMIC); + $redis->expects('select')->with(0)->andThrow(new RuntimeException('Select failed.')); + $connection = new class($this->getContainer(), $pool, ['database' => 0]) extends PhpRedisConnectionStub { + public function isInvalidForTest(): bool + { + return $this->invalid; + } + }; + $connection->setActiveConnection($redis); + $connection->setDatabase(2); + + $connection->release(); + + $this->assertTrue($connection->isInvalidForTest()); + } + + public function testReportingFailureCannotPreventQueueingModeDiscard(): void + { + $pool = $this->getMockedPool(); + $pool->expects('discard')->with(m::type(RedisConnection::class)); + $logger = m::mock(StdoutLoggerInterface::class); + $logger->expects('log') + ->with( + LogLevel::CRITICAL, + 'Discarding Redis connection left in MULTI or PIPELINE mode.' + ) + ->andThrow(new RuntimeException('Logging failed.')); + $container = $this->getContainer(); + $container->instance(StdoutLoggerInterface::class, $logger); + $redis = m::mock(Redis::class); + $redis->expects('getMode')->andReturn(Redis::MULTI); + $connection = $this->mockRedisConnection(container: $container, pool: $pool); + $connection->setActiveConnection($redis); + + $connection->release(); + } + + public function testDiscardOwnershipFailurePropagates(): void + { + $exception = new RuntimeException('Discard ownership failed.'); + $pool = $this->getMockedPool(); + $pool->expects('discard')->andThrow($exception); + $redis = m::mock(Redis::class); + $redis->expects('getMode')->andReturn(Redis::MULTI); + $connection = $this->mockRedisConnection(pool: $pool); + $connection->setActiveConnection($redis); + + try { + $connection->release(); + $this->fail('Expected the discard ownership failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($exception, $throwable); + } + } + public function testReconnectUsesCurrentDatabaseWhenSet(): void { $pool = $this->getMockedPool(); diff --git a/tests/Redis/RedisProxyTest.php b/tests/Redis/RedisProxyTest.php index 83c7276a5..4f5b6c699 100644 --- a/tests/Redis/RedisProxyTest.php +++ b/tests/Redis/RedisProxyTest.php @@ -8,6 +8,7 @@ use Exception; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel; use Hypervel\Pool\PoolOption; use Hypervel\Redis\Events\CommandExecuted; @@ -73,8 +74,10 @@ public function testConnectionBoundMethodsCannotBeCalledThroughProxy(): void 'auth', 'check', 'client', + 'clearWatchState', 'close', 'connect', + 'discardTransaction', 'getActiveConnection', 'getConnection', 'getCreatedAt', @@ -171,6 +174,124 @@ public function testConnectionIsStoredInContextForSelectZeroDatabase(): void $this->assertTrue(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default')); } + public function testConnectionIsStoredInContextForWatch(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('watch')->once()->with('key')->andReturn(true); + $connection->shouldReceive('release')->once(); + + $redis = $this->createRedis($connection); + + $this->assertTrue($redis->watch('key')); + $this->assertSame( + $connection, + CoroutineContext::get(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default') + ); + } + + public function testNativeDiscardDoesNotInvokePoolDiscard(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('discardTransaction')->once()->andReturn(true); + $connection->shouldReceive('discard')->never(); + $connection->shouldReceive('release')->never(); + CoroutineContext::set(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default', $connection); + + $redis = $this->createRedis($connection); + + $this->assertTrue($redis->discard()); + $this->assertSame( + $connection, + CoroutineContext::get(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default') + ); + } + + public function testRepeatedCallbackTransactionsRegisterOneTerminalRelease(): void + { + $firstTransaction = m::mock(PhpRedis::class); + $firstTransaction->expects('exec')->andReturn([]); + $secondTransaction = m::mock(PhpRedis::class); + $secondTransaction->expects('exec')->andReturn([]); + + $connection = $this->mockConnection(); + $connection->expects('multi')->twice()->andReturn($firstTransaction, $secondTransaction); + $connection->expects('release')->twice(); + + $redis = $this->createCountingRedis($connection); + $completed = new Channel(1); + go(static function () use ($redis, $completed): void { + Coroutine::defer(static function () use ($completed): void { + $completed->push(true); + }); + + $redis->transaction(static function (): void { + }); + $redis->transaction(static function (): void { + }); + }); + + $this->assertTrue($completed->pop(1.0)); + $this->assertSame(1, $redis->contextAbsentReleaseCalls); + } + + public function testExistingTerminalReleaseOwnsALaterRawPin(): void + { + $transaction = m::mock(PhpRedis::class); + $transaction->expects('exec')->andReturn([]); + $rawTransaction = m::mock(PhpRedis::class); + + $callbackConnection = $this->mockConnection(); + $callbackConnection->expects('multi')->andReturn($transaction); + $callbackConnection->expects('release'); + $rawConnection = $this->mockConnection(); + $rawConnection->expects('multi')->andReturn($rawTransaction); + $rawConnection->expects('release'); + + $redis = $this->createCountingRedis($callbackConnection, $rawConnection); + $completed = new Channel(1); + go(static function () use ($redis, $completed): void { + Coroutine::defer(static function () use ($completed): void { + $completed->push(true); + }); + + $redis->transaction(static function (): void { + }); + $redis->multi(); + }); + + $this->assertTrue($completed->pop(1.0)); + $this->assertSame(0, $redis->contextAbsentReleaseCalls); + } + + public function testCopiedContextRegistersAChildOwnedTerminalRelease(): void + { + $transaction = m::mock(PhpRedis::class); + $transaction->expects('exec')->andReturn([]); + $rawTransaction = m::mock(PhpRedis::class); + + $parentConnection = $this->mockConnection(); + $parentConnection->expects('multi')->andReturn($transaction); + $parentConnection->expects('release'); + $childConnection = $this->mockConnection(); + $childConnection->expects('multi')->andReturn($rawTransaction); + $childConnection->expects('release'); + + $redis = $this->createCountingRedis($parentConnection, $childConnection); + $redis->transaction(static function (): void { + }); + + $completed = new Channel(1); + go(static function () use ($redis, $completed): void { + Coroutine::defer(static function () use ($completed): void { + $completed->push(true); + }); + + $redis->multi(); + }, copyContext: true); + + $this->assertTrue($completed->pop(1.0)); + } + public function testSelectPinnedConnectionDoesNotLeakAcrossCoroutines(): void { $setConnection = $this->mockConnection(); @@ -404,6 +525,160 @@ public function testEventDispatchedOnErrorWithExceptionInfo(): void } } + public function testThrowingSuccessListenerStillReleasesOrdinaryConnection(): void + { + $eventException = new RuntimeException('Success listener failed.'); + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->expects('hasListeners')->with(CommandExecuted::class)->andReturnTrue(); + $dispatcher->expects('dispatch')->andThrow($eventException); + $connection = $this->createMockRedisConnection('get', 'value', null, $dispatcher); + $connection->expects('release'); + + try { + $this->createRedis($connection)->get('key'); + $this->fail('Expected the event listener failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($eventException, $throwable); + } + } + + public function testThrowingSuccessListenerDoesNotSkipSameConnectionHandoff(): void + { + $eventException = new RuntimeException('Success listener failed.'); + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->expects('hasListeners')->with(CommandExecuted::class)->andReturnTrue(); + $dispatcher->expects('dispatch')->andThrow($eventException); + $transaction = m::mock(PhpRedis::class); + $connection = $this->createMockRedisConnection('multi', $transaction, null, $dispatcher); + $connection->expects('release'); + $redis = $this->createRedis($connection); + + try { + $redis->multi(); + $this->fail('Expected the event listener failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($eventException, $throwable); + } + + $this->assertSame( + $connection, + CoroutineContext::get(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default') + ); + } + + public function testThrowingFailureListenerStillReleasesAndReplacesCommandFailure(): void + { + $commandException = new RuntimeException('Command failed.'); + $eventException = new RuntimeException('Failure listener failed.'); + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->expects('hasListeners')->with(CommandFailed::class)->andReturnTrue(); + $dispatcher->expects('dispatch')->andThrow($eventException); + $connection = $this->createMockRedisConnection('get', null, $commandException, $dispatcher); + $connection->expects('release'); + + try { + $this->createRedis($connection)->get('key'); + $this->fail('Expected the event listener failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($eventException, $throwable); + } + } + + public function testCommandFailureRemainsPrimaryOverCleanupFailure(): void + { + $commandException = new RuntimeException('Command failed.'); + $connection = $this->createMockRedisConnection('get', null, $commandException); + $connection->expects('release')->andThrow(new RuntimeException('Cleanup failed.')); + + try { + $this->createRedis($connection)->get('key'); + $this->fail('Expected the command failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($commandException, $throwable); + } + } + + public function testCleanupFailurePropagatesAfterSuccessfulCommand(): void + { + $cleanupException = new RuntimeException('Cleanup failed.'); + $connection = $this->createMockRedisConnection(); + $connection->expects('release')->andThrow($cleanupException); + + try { + $this->createRedis($connection)->get('key'); + $this->fail('Expected the cleanup failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($cleanupException, $throwable); + } + } + + public function testCallbackTransactionClearsWatchStateAfterSuccessfulExec(): void + { + $transaction = m::mock(PhpRedis::class); + $transaction->expects('exec')->andReturn([]); + $connection = $this->mockConnection(); + $connection->expects('multi')->andReturn($transaction); + $connection->expects('clearWatchState'); + $connection->shouldNotReceive('release'); + CoroutineContext::set(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default', $connection); + + $this->assertSame( + [], + $this->createRedis($connection)->transaction(static function (): void { + }) + ); + } + + public function testCallbackTransactionClearsWatchStateAfterOptimisticLockConflict(): void + { + $transaction = m::mock(PhpRedis::class); + $transaction->expects('exec')->andReturnFalse(); + $connection = $this->mockConnection(); + $connection->expects('multi')->andReturn($transaction); + $connection->expects('clearWatchState'); + $connection->shouldNotReceive('release'); + CoroutineContext::set(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default', $connection); + + $this->assertFalse( + $this->createRedis($connection)->transaction(static function (): void { + }) + ); + } + + public function testCallbackTransactionDoesNotClearWatchStateWhenExecThrows(): void + { + $transaction = m::mock(PhpRedis::class); + $transaction->expects('exec')->andThrow(new RuntimeException('Exec failed.')); + $connection = $this->mockConnection(); + $connection->expects('multi')->andReturn($transaction); + $connection->shouldNotReceive('clearWatchState'); + $connection->shouldNotReceive('release'); + CoroutineContext::set(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default', $connection); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Exec failed.'); + + $this->createRedis($connection)->transaction(static function (): void { + }); + } + + public function testCallbackPipelineDoesNotClearWatchState(): void + { + $pipeline = m::mock(PhpRedis::class); + $pipeline->expects('exec')->andReturn([]); + $connection = $this->mockConnection(); + $connection->expects('pipeline')->andReturn($pipeline); + $connection->shouldNotReceive('clearWatchState'); + $connection->shouldNotReceive('release'); + CoroutineContext::set(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default', $connection); + + $this->assertSame( + [], + $this->createRedis($connection)->pipeline(static function (): void { + }) + ); + } + public function testRegularCommandDoesNotStoreConnectionInContext(): void { $mockRedisConnection = $this->createMockRedisConnection(); @@ -874,6 +1149,22 @@ private function createRedis(m\MockInterface|RedisConnection $connection): Redis return new RedisProxy($poolFactory, 'default'); } + /** + * Create a release-counting Redis proxy with the given mock connections. + */ + private function createCountingRedis( + m\MockInterface|RedisConnection ...$connections + ): RedisProxyReleaseCountingStub { + $pool = m::mock(RedisPool::class); + $pool->shouldReceive('get')->andReturn(...$connections); + $pool->shouldReceive('getOption')->andReturn(new PoolOption); + + $poolFactory = m::mock(PoolFactory::class); + $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); + + return new RedisProxyReleaseCountingStub($poolFactory, 'default'); + } + /** * Create a mock Redis connection with configurable behavior. */ @@ -908,3 +1199,21 @@ private function createMockRedisConnection( return $mockRedisConnection; } } + +class RedisProxyReleaseCountingStub extends RedisProxy +{ + public int $contextAbsentReleaseCalls = 0; + + public function releaseContextConnection(): void + { + $hasContextConnection = CoroutineContext::has( + self::CONNECTION_CONTEXT_PREFIX . $this->getName() + ); + + parent::releaseContextConnection(); + + if (! $hasContextConnection) { + ++$this->contextAbsentReleaseCalls; + } + } +} From ddd632eb3effbbac2b795dc6feb64963c5347b98 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:08:41 +0000 Subject: [PATCH 06/26] fix(redis): terminate exact leases at lifecycle boundaries Aggregate already-created proxies through RedisManager so task completion releases their exact pinned wrappers and process boundaries discard them before flushing resolved pools. Make purge use the proxy real pool identity, detach context before pool destruction, continue independent manager and factory cleanup after failures, and avoid resolving unused services. Register the lifecycle only for the applicable task mode and at both pre-fork and worker-start boundaries. Cover non-coroutine MULTI, PIPELINE, SELECT, and WATCH ownership, abandoned transactions, selected-database restoration, divergent manager and pool names, unresolved services, and cleanup failure precedence. --- .../RedisConnectionLifecycleListener.php | 66 ++++++++++ src/redis/src/RedisManager.php | 86 ++++++++++--- src/redis/src/RedisServiceProvider.php | 34 +++++- .../RedisProxyNonCoroutineIntegrationTest.php | 78 ++++++++++++ .../RedisConnectionLifecycleListenerTest.php | 114 ++++++++++++++++++ tests/Redis/RedisManagerTest.php | 90 ++++++++++++-- tests/Redis/RedisProxyNonCoroutineTest.php | 85 +++++++++++++ tests/Redis/RedisServiceProviderTest.php | 42 +++++++ 8 files changed, 572 insertions(+), 23 deletions(-) create mode 100644 src/redis/src/Listeners/RedisConnectionLifecycleListener.php create mode 100644 tests/Integration/Redis/RedisProxyNonCoroutineIntegrationTest.php create mode 100644 tests/Redis/RedisConnectionLifecycleListenerTest.php create mode 100644 tests/Redis/RedisProxyNonCoroutineTest.php diff --git a/src/redis/src/Listeners/RedisConnectionLifecycleListener.php b/src/redis/src/Listeners/RedisConnectionLifecycleListener.php new file mode 100644 index 000000000..914826dd1 --- /dev/null +++ b/src/redis/src/Listeners/RedisConnectionLifecycleListener.php @@ -0,0 +1,66 @@ +container->resolved('redis')) { + return; + } + + $manager = $this->container->make('redis'); + + if ($manager instanceof RedisManager) { + $manager->releaseConnections(); + } + } + + /** + * Discard inherited connections and close resolved pools. + */ + public function discardProcessConnections(): void + { + $exception = null; + + if ($this->container->resolved('redis')) { + try { + $manager = $this->container->make('redis'); + + if ($manager instanceof RedisManager) { + $manager->discardConnections(); + } + } catch (Throwable $throwable) { + $exception = $throwable; + } + } + + if ($this->container->resolved(PoolFactory::class)) { + try { + $this->container->make(PoolFactory::class)->flushAll(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; + } + } +} diff --git a/src/redis/src/RedisManager.php b/src/redis/src/RedisManager.php index c5f82b81f..63a88d19c 100644 --- a/src/redis/src/RedisManager.php +++ b/src/redis/src/RedisManager.php @@ -5,7 +5,6 @@ namespace Hypervel\Redis; use Closure; -use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Redis\Connection as ConnectionContract; use Hypervel\Contracts\Redis\Factory as FactoryContract; @@ -15,6 +14,7 @@ use Hypervel\Redis\Limiters\DurationLimiterBuilder; use Hypervel\Redis\Pool\PoolFactory; use InvalidArgumentException; +use Throwable; use UnitEnum; use function Hypervel\Support\enum_value; @@ -81,9 +81,8 @@ public function connection(UnitEnum|string|null $name = null): RedisProxy /** * Disconnect the given connection and remove from local cache. * - * Releases any context-pinned connection, clears the coroutine context - * entry, and flushes the underlying pool so all connections are closed - * and re-created on next use. + * Discards any context-pinned connection and flushes the underlying pool + * so all connections are closed and re-created on next use. * * Boot or tests only. Flushes the shared pool; concurrent coroutines * checked out before this call may complete against the destroyed pool @@ -97,20 +96,29 @@ public function purge(UnitEnum|string|null $name = null): void $name = $name === null || $name === '' ? 'default' : $name; + $proxy = $this->connections[$name] ?? null; unset($this->connections[$name]); - // Release any context-pinned connection before clearing context. - // The coroutine defer in RedisProxy::__call() looks up the connection - // from context to release it — if we forget the key first, the defer - // finds null and the checked-out pool slot is leaked. - $contextKey = RedisProxy::CONNECTION_CONTEXT_PREFIX . $name; - $connection = CoroutineContext::get($contextKey); - if ($connection instanceof RedisConnection) { - $connection->release(); + $poolName = $proxy?->getName() ?? $name; + $exception = null; + + if ($proxy !== null) { + try { + $proxy->discardContextConnection(); + } catch (Throwable $throwable) { + $exception = $throwable; + } + } + + try { + $this->factory->flushPool($poolName); + } catch (Throwable $throwable) { + $exception ??= $throwable; } - CoroutineContext::forget($contextKey); - $this->factory->flushPool($name); + if ($exception !== null) { + throw $exception; + } } /** @@ -123,6 +131,56 @@ public function connections(): array return $this->connections; } + /** + * Release connections retained by non-coroutine task execution. + * + * @internal + */ + public function releaseConnections(): void + { + $this->terminateConnections( + static function (RedisProxy $connection): void { + $connection->releaseContextConnection(); + }, + ); + } + + /** + * Discard connections retained by this manager. + * + * @internal + */ + public function discardConnections(): void + { + $this->terminateConnections( + static function (RedisProxy $connection): void { + $connection->discardContextConnection(); + }, + ); + } + + /** + * Terminate every created connection proxy. + * + * @param Closure(RedisProxy): void $terminate + */ + protected function terminateConnections(Closure $terminate): void + { + $exception = null; + + foreach ($this->connections as $connection) { + try { + $terminate($connection); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; + } + } + /** * Register a custom connection creator. * diff --git a/src/redis/src/RedisServiceProvider.php b/src/redis/src/RedisServiceProvider.php index 237965520..0481cad24 100644 --- a/src/redis/src/RedisServiceProvider.php +++ b/src/redis/src/RedisServiceProvider.php @@ -4,8 +4,13 @@ namespace Hypervel\Redis; +use Hypervel\Core\Events\BeforeServerFork; +use Hypervel\Core\Events\BeforeWorkerStart; +use Hypervel\Core\Events\TaskTerminated; +use Hypervel\Redis\Listeners\RedisConnectionLifecycleListener; use Hypervel\Redis\Pool\PoolFactory; use Hypervel\Support\ServiceProvider; +use Swoole\Constant; class RedisServiceProvider extends ServiceProvider { @@ -20,6 +25,33 @@ public function register(): void $app->make(RedisConfig::class) )); - $this->app->bind('redis.connection', fn ($app) => $app['redis']->connection()); + $this->app->bind('redis.connection', fn ($app) => $app->make('redis')->connection()); + } + + /** + * Bootstrap the service provider. + */ + public function boot(): void + { + $events = $this->app->make('events'); + $listener = fn (): RedisConnectionLifecycleListener => $this->app->make( + RedisConnectionLifecycleListener::class + ); + + if (! $this->app->make('config')->boolean( + 'server.settings.' . Constant::OPTION_TASK_ENABLE_COROUTINE + )) { + $events->listen(TaskTerminated::class, function () use ($listener): void { + $listener()->releaseTaskConnections(); + }); + } + + $events->listen(BeforeServerFork::class, function () use ($listener): void { + $listener()->discardProcessConnections(); + }); + + $events->listen(BeforeWorkerStart::class, function () use ($listener): void { + $listener()->discardProcessConnections(); + }); } } diff --git a/tests/Integration/Redis/RedisProxyNonCoroutineIntegrationTest.php b/tests/Integration/Redis/RedisProxyNonCoroutineIntegrationTest.php new file mode 100644 index 000000000..9658e25c8 --- /dev/null +++ b/tests/Integration/Redis/RedisProxyNonCoroutineIntegrationTest.php @@ -0,0 +1,78 @@ +createRedisConnectionWithOptions( + 'test_non_coroutine_multi', + ['prefix' => ''], + )); + $redis->flushdb(); + + $abandoned = $redis->multi(); + $this->app->make(RedisManager::class)->releaseConnections(); + $replacement = $this->nativeClient($redis); + + $this->assertNotSame($abandoned, $replacement); + $this->assertSame(PhpRedis::ATOMIC, $replacement->getMode()); + $this->assertTrue($redis->set('after:task', 'healthy')); + $this->assertSame('healthy', $redis->get('after:task')); + } + + public function testTaskCleanupRestoresSelectedDatabaseBeforeReuse(): void + { + $redis = Redis::connection($this->createRedisConnectionWithOptions( + 'test_non_coroutine_select', + ['prefix' => ''], + )); + $redis->flushdb(); + $key = 'task:select:' . uniqid(); + + $redis->select($this->getSecondaryRedisDb()); + $selected = $this->nativeClient($redis); + $redis->set($key, 'secondary'); + + $this->app->make(RedisManager::class)->releaseConnections(); + + $this->assertSame($selected, $this->nativeClient($redis)); + $this->assertNull($redis->get($key)); + + try { + $redis->select($this->getSecondaryRedisDb()); + $this->assertSame('secondary', $redis->get($key)); + $redis->del($key); + } finally { + $this->app->make(RedisManager::class)->releaseConnections(); + } + } + + /** + * Get the exact native client held by a Redis proxy. + */ + private function nativeClient(RedisProxy $redis): PhpRedis + { + return $redis->withConnection(function (RedisConnection $connection): PhpRedis { + $client = $connection->client(); + $this->assertInstanceOf(PhpRedis::class, $client); + + return $client; + }); + } +} diff --git a/tests/Redis/RedisConnectionLifecycleListenerTest.php b/tests/Redis/RedisConnectionLifecycleListenerTest.php new file mode 100644 index 000000000..43ca9776e --- /dev/null +++ b/tests/Redis/RedisConnectionLifecycleListenerTest.php @@ -0,0 +1,114 @@ +expects('resolved')->with('redis')->andReturnFalse(); + $container->shouldNotReceive('make'); + + (new RedisConnectionLifecycleListener($container))->releaseTaskConnections(); + } + + public function testTaskCleanupReleasesTheConcreteManager(): void + { + $manager = m::mock(RedisManager::class); + $manager->expects('releaseConnections'); + $container = m::mock(Container::class); + $container->expects('resolved')->with('redis')->andReturnTrue(); + $container->expects('make')->with('redis')->andReturn($manager); + + (new RedisConnectionLifecycleListener($container))->releaseTaskConnections(); + } + + public function testTaskCleanupLeavesCustomManagersAlone(): void + { + $manager = new stdClass; + $container = m::mock(Container::class); + $container->expects('resolved')->with('redis')->andReturnTrue(); + $container->expects('make')->with('redis')->andReturn($manager); + + (new RedisConnectionLifecycleListener($container))->releaseTaskConnections(); + } + + public function testProcessCleanupDoesNotResolveUnusedOwners(): void + { + $container = m::mock(Container::class); + $container->expects('resolved')->with('redis')->andReturnFalse(); + $container->expects('resolved')->with(PoolFactory::class)->andReturnFalse(); + $container->shouldNotReceive('make'); + + (new RedisConnectionLifecycleListener($container))->discardProcessConnections(); + } + + public function testProcessCleanupDiscardsManagerAndFlushesPoolFactory(): void + { + $manager = m::mock(RedisManager::class); + $manager->expects('discardConnections'); + $factory = m::mock(PoolFactory::class); + $factory->expects('flushAll'); + $container = m::mock(Container::class); + $container->expects('resolved')->with('redis')->andReturnTrue(); + $container->expects('make')->with('redis')->andReturn($manager); + $container->expects('resolved')->with(PoolFactory::class)->andReturnTrue(); + $container->expects('make')->with(PoolFactory::class)->andReturn($factory); + + (new RedisConnectionLifecycleListener($container))->discardProcessConnections(); + } + + public function testManagerFailureDoesNotSkipPoolFlushAndRemainsPrimary(): void + { + $managerException = new RuntimeException('Manager discard failed.'); + $manager = m::mock(RedisManager::class); + $manager->expects('discardConnections')->andThrow($managerException); + $factory = m::mock(PoolFactory::class); + $factory->expects('flushAll')->andThrow(new RuntimeException('Pool flush failed.')); + $container = m::mock(Container::class); + $container->expects('resolved')->with('redis')->andReturnTrue(); + $container->expects('make')->with('redis')->andReturn($manager); + $container->expects('resolved')->with(PoolFactory::class)->andReturnTrue(); + $container->expects('make')->with(PoolFactory::class)->andReturn($factory); + + try { + (new RedisConnectionLifecycleListener($container))->discardProcessConnections(); + $this->fail('Expected the manager failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($managerException, $throwable); + } + } + + public function testPoolFactoryFailurePropagatesAfterManagerCleanup(): void + { + $exception = new RuntimeException('Pool flush failed.'); + $manager = m::mock(RedisManager::class); + $manager->expects('discardConnections'); + $factory = m::mock(PoolFactory::class); + $factory->expects('flushAll')->andThrow($exception); + $container = m::mock(Container::class); + $container->expects('resolved')->with('redis')->andReturnTrue(); + $container->expects('make')->with('redis')->andReturn($manager); + $container->expects('resolved')->with(PoolFactory::class)->andReturnTrue(); + $container->expects('make')->with(PoolFactory::class)->andReturn($factory); + + try { + (new RedisConnectionLifecycleListener($container))->discardProcessConnections(); + $this->fail('Expected the pool factory failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($exception, $throwable); + } + } +} diff --git a/tests/Redis/RedisManagerTest.php b/tests/Redis/RedisManagerTest.php index fa98e9c0e..e5299a790 100644 --- a/tests/Redis/RedisManagerTest.php +++ b/tests/Redis/RedisManagerTest.php @@ -18,6 +18,7 @@ use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; +use RuntimeException; /** * Tests for RedisManager — the named connection manager. @@ -107,25 +108,98 @@ public function testPurgeClearsProxyContextAndPool() $this->assertNotSame($first, $second); } - public function testPurgeReleasesContextPinnedConnection() + public function testPurgeDiscardsContextPinnedConnection(): void { $pinnedConnection = m::mock(PhpRedisConnection::class); - $pinnedConnection->shouldReceive('release')->once(); - - // Pin a connection in context (simulating an active multi/pipeline) - CoroutineContext::set(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default', $pinnedConnection); + $pinnedConnection->expects('discard'); $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldReceive('flushPool')->with('default'); - + $poolFactory->expects('flushPool')->with('default'); $manager = $this->createManager(['default'], poolFactory: $poolFactory); + $manager->connection('default'); + CoroutineContext::set(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default', $pinnedConnection); $manager->purge('default'); - // Context should be cleared after release $this->assertFalse(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default')); } + public function testReleaseConnectionsExhaustsProxiesAndPreservesFirstFailure(): void + { + $firstException = new RuntimeException('First release failed.'); + $first = m::mock(RedisProxy::class); + $first->expects('releaseContextConnection')->andThrow($firstException); + $second = m::mock(RedisProxy::class); + $second->expects('releaseContextConnection'); + $manager = $this->createManager(['first', 'second']); + $manager->extend('first', static fn () => $first); + $manager->extend('second', static fn () => $second); + $manager->connection('first'); + $manager->connection('second'); + + try { + $manager->releaseConnections(); + $this->fail('Expected the first release failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($firstException, $throwable); + } + } + + public function testDiscardConnectionsExhaustsEveryCreatedProxy(): void + { + $first = m::mock(RedisProxy::class); + $first->expects('discardContextConnection'); + $second = m::mock(RedisProxy::class); + $second->expects('discardContextConnection'); + $manager = $this->createManager(['first', 'second']); + $manager->extend('first', static fn () => $first); + $manager->extend('second', static fn () => $second); + $manager->connection('first'); + $manager->connection('second'); + + $manager->discardConnections(); + } + + public function testPurgeUsesTheResolvedProxysActualPoolIdentity(): void + { + $proxy = m::mock(RedisProxy::class); + $proxy->expects('getName')->andReturn('physical'); + $proxy->expects('discardContextConnection'); + $poolFactory = m::mock(PoolFactory::class); + $poolFactory->expects('flushPool')->with('physical'); + $manager = $this->createManager(['alias'], poolFactory: $poolFactory); + $manager->extend('alias', static fn () => $proxy); + $manager->connection('alias'); + + $manager->purge('alias'); + + $this->assertSame([], $manager->connections()); + } + + public function testPurgeFlushesPoolAfterDiscardFailureAndPreservesFirstFailure(): void + { + $discardException = new RuntimeException('Discard failed.'); + $proxy = m::mock(RedisProxy::class); + $proxy->expects('getName')->andReturn('physical'); + $proxy->expects('discardContextConnection')->andThrow($discardException); + $poolFactory = m::mock(PoolFactory::class); + $poolFactory->expects('flushPool') + ->with('physical') + ->andThrow(new RuntimeException('Flush failed.')); + $manager = $this->createManager(['alias'], poolFactory: $poolFactory); + $manager->extend('alias', static fn () => $proxy); + $manager->connection('alias'); + + try { + $manager->purge('alias'); + $this->fail('Expected the discard failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($discardException, $throwable); + } + + $this->assertSame([], $manager->connections()); + } + public function testExtendOverridesConnectionResolution() { $manager = $this->createManager(['default']); diff --git a/tests/Redis/RedisProxyNonCoroutineTest.php b/tests/Redis/RedisProxyNonCoroutineTest.php new file mode 100644 index 000000000..2b837f5ee --- /dev/null +++ b/tests/Redis/RedisProxyNonCoroutineTest.php @@ -0,0 +1,85 @@ +assertCommandPinsConnection('multi', [], new stdClass); + } + + public function testPipelinePinsConnectionUntilTerminalCleanup(): void + { + $this->assertCommandPinsConnection('pipeline', [], new stdClass); + } + + public function testSelectPinsConnectionUntilTerminalCleanup(): void + { + $this->assertCommandPinsConnection('select', [2], true, 2); + } + + public function testWatchPinsConnectionUntilTerminalCleanup(): void + { + $this->assertCommandPinsConnection('watch', ['key'], true); + } + + /** + * Assert a successful stateful command pins and terminally releases its wrapper. + */ + private function assertCommandPinsConnection( + string $command, + array $arguments, + mixed $result, + ?int $selectedDatabase = null, + ): void { + $connection = m::mock(PhpRedisConnection::class); + $connection->shouldReceive('shouldTransform')->andReturnSelf(); + $connection->shouldReceive('getConnection')->andReturnSelf(); + $connection->shouldReceive('getEventDispatcher')->andReturnNull(); + $connection->expects($command)->with(...$arguments)->andReturn($result); + $connection->expects('release'); + + if ($selectedDatabase !== null) { + $connection->expects('setDatabase')->with($selectedDatabase); + } + + $pool = m::mock(RedisPool::class); + $pool->expects('get')->andReturn($connection); + $factory = m::mock(PoolFactory::class); + $factory->expects('getPool')->with('default')->andReturn($pool); + $redis = new RedisProxy($factory, 'default'); + + $this->assertSame($result, $redis->{$command}(...$arguments)); + $this->assertSame( + $connection, + CoroutineContext::get(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default') + ); + + $redis->releaseContextConnection(); + + $this->assertFalse( + CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default') + ); + } +} diff --git a/tests/Redis/RedisServiceProviderTest.php b/tests/Redis/RedisServiceProviderTest.php index 28147c23e..78f4a4706 100644 --- a/tests/Redis/RedisServiceProviderTest.php +++ b/tests/Redis/RedisServiceProviderTest.php @@ -6,10 +6,16 @@ use Hypervel\Contracts\Redis\Connection as ConnectionContract; use Hypervel\Contracts\Redis\Factory as FactoryContract; +use Hypervel\Core\Events\BeforeServerFork; +use Hypervel\Core\Events\BeforeWorkerStart; +use Hypervel\Core\Events\TaskTerminated; +use Hypervel\Events\Dispatcher; use Hypervel\Redis\RedisManager; use Hypervel\Redis\RedisProxy; +use Hypervel\Redis\RedisServiceProvider; use Hypervel\Testbench\TestCase; use Redis; +use Swoole\Constant; /** * Tests for RedisServiceProvider container bindings and alias resolution. @@ -88,4 +94,40 @@ public function testRedisConnectionAliasesAreRegistered() $this->assertTrue($this->app->isAlias(ConnectionContract::class)); $this->assertFalse($this->app->isAlias(RedisProxy::class)); } + + public function testNonCoroutineTaskLifecycleListenersAreRegistered(): void + { + $events = $this->bootProviderWithTaskCoroutines(false); + + $this->assertTrue($events->hasListeners(TaskTerminated::class)); + $this->assertTrue($events->hasListeners(BeforeServerFork::class)); + $this->assertTrue($events->hasListeners(BeforeWorkerStart::class)); + } + + public function testCoroutineTasksDoNotRegisterTerminalTaskCleanup(): void + { + $events = $this->bootProviderWithTaskCoroutines(true); + + $this->assertFalse($events->hasListeners(TaskTerminated::class)); + $this->assertTrue($events->hasListeners(BeforeServerFork::class)); + $this->assertTrue($events->hasListeners(BeforeWorkerStart::class)); + } + + /** + * Boot a fresh provider against an isolated event dispatcher. + */ + private function bootProviderWithTaskCoroutines(bool $enabled): Dispatcher + { + $this->app->make('config')->set( + 'server.settings.' . Constant::OPTION_TASK_ENABLE_COROUTINE, + $enabled, + ); + + $events = new Dispatcher($this->app); + $this->app->instance('events', $events); + + (new RedisServiceProvider($this->app))->boot(); + + return $events; + } } From 47cc83aa824be7af22b408ac913244e71e9e36dd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:08:55 +0000 Subject: [PATCH 07/26] fix(database): classify SQLite database names consistently Add a native-faithful SQLiteDatabase classifier for literal memory databases, URI memory forms, and file URIs, then route connection creation, schema loading, integration fixtures, and filesystem management through that single owner. Resolve the canonical attached file before refreshing a live database, preserve its inode, reject URI names at plain-path management boundaries, and surface failed truncation instead of reporting false success. Cover the complete URI matrix, standalone connector behavior, CLI routing, canonical path refresh, invalid filesystem operations, and WAL fixture handling without introducing URI rewriting or another database-name abstraction. --- .../src/Connectors/SQLiteConnector.php | 9 +-- src/database/src/SQLiteDatabase.php | 44 +++++++++++ src/database/src/Schema/SQLiteBuilder.php | 38 ++++++--- src/database/src/Schema/SqliteSchemaState.php | 6 +- tests/Database/DatabaseConnectorTest.php | 14 ++++ tests/Database/DatabaseSQLiteBuilderTest.php | 77 +++++++++++++++++++ .../DatabaseSqliteSchemaStateTest.php | 36 +++++++-- tests/Database/SQLiteDatabaseTest.php | 64 +++++++++++++++ ...rateFreshCommandWithJournalModeWalTest.php | 9 ++- .../DatabaseSqliteSchemaBuilderTest.php | 28 +++++++ .../Database/Sqlite/SqliteTestCase.php | 17 ++-- 11 files changed, 306 insertions(+), 36 deletions(-) create mode 100644 src/database/src/SQLiteDatabase.php create mode 100644 tests/Database/SQLiteDatabaseTest.php diff --git a/src/database/src/Connectors/SQLiteConnector.php b/src/database/src/Connectors/SQLiteConnector.php index f1fde8c2b..eb95e2b14 100755 --- a/src/database/src/Connectors/SQLiteConnector.php +++ b/src/database/src/Connectors/SQLiteConnector.php @@ -4,6 +4,7 @@ namespace Hypervel\Database\Connectors; +use Hypervel\Database\SQLiteDatabase; use Hypervel\Database\SQLiteDatabaseDoesNotExistException; use PDO; @@ -41,14 +42,12 @@ protected function parseDatabasePath(string $path): string // SQLite supports "in-memory" databases that only last as long as the owning // connection does. These are useful for tests or for short lifetime store // querying. In-memory databases shall be anonymous (:memory:) or named. - if ($path === ':memory:' - || str_contains($path, '?mode=memory') - || str_contains($path, '&mode=memory') - ) { + if (SQLiteDatabase::isInMemory($path) || SQLiteDatabase::isUri($path)) { return $path; } - $path = realpath($path) ?: realpath(base_path($path)); + $path = realpath($path) + ?: (function_exists('base_path') ? realpath(base_path($path)) : false); // Here we'll verify that the SQLite database exists before going any further // as the developer probably wants to know if the database exists and this diff --git a/src/database/src/SQLiteDatabase.php b/src/database/src/SQLiteDatabase.php new file mode 100644 index 000000000..46775c9ef --- /dev/null +++ b/src/database/src/SQLiteDatabase.php @@ -0,0 +1,44 @@ +validateDatabasePath($name); + return File::put($name, '') !== false; } @@ -29,9 +34,23 @@ public function createDatabase(string $name): bool #[Override] public function dropDatabaseIfExists(string $name): bool { + $this->validateDatabasePath($name); + return ! File::exists($name) || File::delete($name); } + /** + * Validate the database name for filesystem management. + */ + protected function validateDatabasePath(string $name): void + { + if (SQLiteDatabase::isInMemory($name) || SQLiteDatabase::isUri($name)) { + throw new InvalidArgumentException( + "SQLite database management requires a plain filesystem path; [{$name}] is not supported." + ); + } + } + #[Override] public function getTables(array|string|null $schema = null): array { @@ -97,15 +116,12 @@ public function getColumns(string $table): array #[Override] public function dropAllTables(): void { + $databases = array_column($this->getSchemas(), 'path', 'name'); + foreach ($this->getCurrentSchemaListing() as $schema) { - $database = $schema === 'main' - ? $this->connection->getDatabaseName() - : (array_column($this->getSchemas(), 'path', 'name')[$schema] ?: ':memory:'); - - if ($database !== ':memory:' - && ! str_contains($database, '?mode=memory') - && ! str_contains($database, '&mode=memory') - ) { + $database = $databases[$schema] ?? null; + + if (is_string($database) && $database !== '') { $this->refreshDatabaseFile($database); } else { $this->pragma('writable_schema', 1); @@ -151,7 +167,11 @@ public function pragma(string $key, mixed $value = null): mixed */ public function refreshDatabaseFile(?string $path = null): void { - file_put_contents($path ?? $this->connection->getDatabaseName(), ''); + $path ??= $this->connection->getDatabaseName(); + + if (File::put($path, '') === false) { + throw new RuntimeException("Unable to refresh SQLite database file [{$path}]."); + } } /** diff --git a/src/database/src/Schema/SqliteSchemaState.php b/src/database/src/Schema/SqliteSchemaState.php index 5db84eb80..fe404743a 100644 --- a/src/database/src/Schema/SqliteSchemaState.php +++ b/src/database/src/Schema/SqliteSchemaState.php @@ -5,6 +5,7 @@ namespace Hypervel\Database\Schema; use Hypervel\Database\Connection; +use Hypervel\Database\SQLiteDatabase; use Hypervel\Support\Collection; use Override; @@ -53,10 +54,7 @@ public function load(string $path): void { $database = $this->connection->getDatabaseName(); - if ($database === ':memory:' - || str_contains($database, '?mode=memory') - || str_contains($database, '&mode=memory') - ) { + if (SQLiteDatabase::isInMemory($database)) { $this->connection->getPdo()->exec($this->files->get($path)); return; diff --git a/tests/Database/DatabaseConnectorTest.php b/tests/Database/DatabaseConnectorTest.php index 8cc690ce3..8448fd4f5 100755 --- a/tests/Database/DatabaseConnectorTest.php +++ b/tests/Database/DatabaseConnectorTest.php @@ -346,6 +346,20 @@ public function testSQLiteNamedMemoryDatabasesMayBeConnectedTo() $this->assertSame($result, $connection); } + public function testSQLiteFileUriDatabasesMayBeConnectedTo(): void + { + $dsn = 'sqlite:file:/tmp/database.sqlite?mode=rw'; + $config = ['database' => 'file:/tmp/database.sqlite?mode=rw']; + $connector = $this->getMockBuilder(SQLiteConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); + $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); + + $result = $connector->connect($config); + + $this->assertSame($result, $connection); + } + public function testSQLiteFileDatabasesMayBeConnectedTo() { $dsn = 'sqlite:' . __DIR__; diff --git a/tests/Database/DatabaseSQLiteBuilderTest.php b/tests/Database/DatabaseSQLiteBuilderTest.php index 4838238ea..68a7088e9 100644 --- a/tests/Database/DatabaseSQLiteBuilderTest.php +++ b/tests/Database/DatabaseSQLiteBuilderTest.php @@ -9,7 +9,10 @@ use Hypervel\Database\Schema\SQLiteBuilder; use Hypervel\Support\Facades\File; use Hypervel\Testbench\TestCase; +use InvalidArgumentException; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; class DatabaseSQLiteBuilderTest extends TestCase { @@ -70,4 +73,78 @@ public function testDropDatabaseIfExists() $this->assertFalse($builder->dropDatabaseIfExists('my_temporary_database_c')); } + + #[DataProvider('nonFileDatabaseNames')] + public function testCreateDatabaseRejectsNonFileDatabaseNames(string $name): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(new SQLiteGrammar($connection)); + + File::shouldReceive('put')->never(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "SQLite database management requires a plain filesystem path; [{$name}] is not supported." + ); + + (new SQLiteBuilder($connection))->createDatabase($name); + } + + #[DataProvider('nonFileDatabaseNames')] + public function testDropDatabaseRejectsNonFileDatabaseNames(string $name): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(new SQLiteGrammar($connection)); + + File::shouldReceive('exists')->never(); + File::shouldReceive('delete')->never(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "SQLite database management requires a plain filesystem path; [{$name}] is not supported." + ); + + (new SQLiteBuilder($connection))->dropDatabaseIfExists($name); + } + + public static function nonFileDatabaseNames(): array + { + return [ + 'literal memory database' => [':memory:'], + 'memory URI database' => ['file::memory:'], + 'file URI database' => ['file:/tmp/database.sqlite?mode=rwc'], + ]; + } + + public function testDropAllTablesRefreshesTheCanonicalAttachedDatabasePath(): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(new SQLiteGrammar($connection)); + $connection->shouldNotReceive('getDatabaseName'); + + $builder = m::mock(SQLiteBuilder::class, [$connection])->makePartial(); + $builder->shouldReceive('getSchemas')->once()->andReturn([ + ['name' => 'main', 'path' => '/canonical/database.sqlite'], + ]); + $builder->shouldReceive('getCurrentSchemaListing')->once()->andReturn(['main']); + $builder->shouldReceive('refreshDatabaseFile')->once()->with('/canonical/database.sqlite'); + + $builder->dropAllTables(); + } + + public function testRefreshDatabaseFileThrowsWhenTheFileCannotBeWritten(): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(new SQLiteGrammar($connection)); + + File::shouldReceive('put') + ->once() + ->with('/database.sqlite', '') + ->andReturn(false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to refresh SQLite database file [/database.sqlite].'); + + (new SQLiteBuilder($connection))->refreshDatabaseFile('/database.sqlite'); + } } diff --git a/tests/Database/DatabaseSqliteSchemaStateTest.php b/tests/Database/DatabaseSqliteSchemaStateTest.php index b6ae88c0b..21273ffb5 100644 --- a/tests/Database/DatabaseSqliteSchemaStateTest.php +++ b/tests/Database/DatabaseSqliteSchemaStateTest.php @@ -10,13 +10,15 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PDO; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Process\Process; class DatabaseSqliteSchemaStateTest extends TestCase { - public function testLoadSchemaToDatabase(): void + #[DataProvider('fileDatabaseProvider')] + public function testLoadSchemaToDatabase(string $database): void { - $config = ['driver' => 'sqlite', 'database' => 'database/database.sqlite', 'prefix' => '', 'foreign_key_constraints' => true, 'name' => 'sqlite']; + $config = ['driver' => 'sqlite', 'database' => $database, 'prefix' => '', 'foreign_key_constraints' => true, 'name' => 'sqlite']; $connection = m::mock(SQLiteConnection::class); $connection->shouldReceive('getConfig')->andReturn($config); $connection->shouldReceive('getDatabaseName')->andReturn($config['database']); @@ -34,14 +36,26 @@ public function testLoadSchemaToDatabase(): void $this->assertSame('sqlite3 "${:HYPERVEL_LOAD_DATABASE}" < "${:HYPERVEL_LOAD_PATH}"', $factoryCalledWith[0]); $process->shouldHaveReceived('mustRun')->with(null, [ - 'HYPERVEL_LOAD_DATABASE' => 'database/database.sqlite', + 'HYPERVEL_LOAD_DATABASE' => $database, 'HYPERVEL_LOAD_PATH' => 'database/schema/sqlite-schema.dump', ]); } - public function testLoadSchemaToInMemory(): void + /** + * @return array + */ + public static function fileDatabaseProvider(): array { - $config = ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'foreign_key_constraints' => true, 'name' => 'sqlite']; + return [ + 'plain path' => ['database/database.sqlite'], + 'file URI' => ['file:/tmp/database.sqlite?mode=rw'], + ]; + } + + #[DataProvider('inMemoryDatabaseProvider')] + public function testLoadSchemaToInMemory(string $database): void + { + $config = ['driver' => 'sqlite', 'database' => $database, 'prefix' => '', 'foreign_key_constraints' => true, 'name' => 'sqlite']; $connection = m::mock(SQLiteConnection::class); $connection->shouldReceive('getConfig')->andReturn($config); $connection->shouldReceive('getDatabaseName')->andReturn($config['database']); @@ -55,4 +69,16 @@ public function testLoadSchemaToInMemory(): void $pdo->shouldHaveReceived('exec')->with('CREATE TABLE IF NOT EXISTS "migrations" ("id" integer not null primary key autoincrement, "migration" varchar not null, "batch" integer not null);'); } + + /** + * @return array + */ + public static function inMemoryDatabaseProvider(): array + { + return [ + 'literal memory' => [':memory:'], + 'memory URI path' => ['file::memory:'], + 'named memory URI' => ['file:database?mode=memory'], + ]; + } } diff --git a/tests/Database/SQLiteDatabaseTest.php b/tests/Database/SQLiteDatabaseTest.php new file mode 100644 index 000000000..8fb740fe9 --- /dev/null +++ b/tests/Database/SQLiteDatabaseTest.php @@ -0,0 +1,64 @@ +assertSame($expected, SQLiteDatabase::isUri($database)); + } + + /** + * @return array + */ + public static function uriProvider(): array + { + return [ + 'literal memory' => [':memory:', false], + 'relative file URI' => ['file:database.sqlite', true], + 'absolute file URI' => ['file:/tmp/database.sqlite', true], + 'triple-slash file URI' => ['file:///tmp/database.sqlite', true], + 'query-bearing file URI' => ['file:database.sqlite?mode=rwc', true], + 'uppercase prefix is a filename' => ['FILE:database.sqlite', false], + ]; + } + + #[DataProvider('memoryProvider')] + public function testItClassifiesInMemoryDatabases(string $database, bool $expected): void + { + $this->assertSame($expected, SQLiteDatabase::isInMemory($database)); + } + + /** + * @return array + */ + public static function memoryProvider(): array + { + return [ + 'literal memory' => [':memory:', true], + 'memory URI path' => ['file::memory:', true], + 'memory URI path with shared cache' => ['file::memory:?cache=shared', true], + 'encoded memory URI path' => ['file:%3Amemory%3A', true], + 'empty URI path in memory mode' => ['file:?mode=memory', true], + 'named URI in memory mode' => ['file:database?mode=memory', true], + 'encoded memory mode' => ['file:database?mode=%6demory', true], + 'memory mode after another parameter' => ['file:database?cache=shared&mode=memory', true], + 'last duplicate mode wins for memory' => ['file:database?mode=rwc&mode=memory', true], + 'last duplicate mode wins for file' => ['file:database?mode=memory&mode=rwc', false], + 'ordinary path' => ['/tmp/database.sqlite', false], + 'ordinary file URI' => ['file:/tmp/database.sqlite', false], + 'query-bearing file URI' => ['file:/tmp/database.sqlite?mode=rwc', false], + 'uppercase URI prefix is a filename' => ['FILE:database?mode=memory', false], + 'uppercase mode key is ignored' => ['file:database?MODE=memory', false], + 'mixed-case mode value is not memory' => ['file:database?mode=MEMORY', false], + ]; + } +} diff --git a/tests/Integration/Database/Sqlite/Console/MigrateFreshCommandWithJournalModeWalTest.php b/tests/Integration/Database/Sqlite/Console/MigrateFreshCommandWithJournalModeWalTest.php index e76aa5db2..e193cab20 100644 --- a/tests/Integration/Database/Sqlite/Console/MigrateFreshCommandWithJournalModeWalTest.php +++ b/tests/Integration/Database/Sqlite/Console/MigrateFreshCommandWithJournalModeWalTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Integration\Database\Sqlite\Console; use Hypervel\Contracts\Foundation\Application; +use Hypervel\Database\SQLiteDatabase; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; use Hypervel\Testbench\Attributes\WithMigration; @@ -37,16 +38,16 @@ protected function defineEnvironment(Application $app): void #[Override] protected function setUp(): void { - // WAL journal mode doesn't work with :memory: databases - if ($this->isConfiguredForInMemoryDatabase()) { + $databasePath = $this->getConfiguredDatabasePath(); + + if ($this->isConfiguredForInMemoryDatabase() || SQLiteDatabase::isUri($databasePath)) { parent::setUp(); - $this->markTestSkipped('WAL journal mode requires a file-based database, not :memory:'); + $this->markTestSkipped('The WAL migration test requires a plain SQLite filesystem path.'); } // Delete any existing database file to start fresh, then create an // empty file. The connector will set WAL mode via the journal_mode // config when the connection is established. - $databasePath = $this->getConfiguredDatabasePath(); $this->deleteSqliteDatabaseFile($databasePath); touch($databasePath); diff --git a/tests/Integration/Database/Sqlite/DatabaseSqliteSchemaBuilderTest.php b/tests/Integration/Database/Sqlite/DatabaseSqliteSchemaBuilderTest.php index f7040185e..3485c3813 100644 --- a/tests/Integration/Database/Sqlite/DatabaseSqliteSchemaBuilderTest.php +++ b/tests/Integration/Database/Sqlite/DatabaseSqliteSchemaBuilderTest.php @@ -5,8 +5,12 @@ namespace Hypervel\Tests\Integration\Database\Sqlite; use Hypervel\Database\Schema\Blueprint; +use Hypervel\Database\SQLiteConnection; +use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; +use Hypervel\Testing\ParallelTesting; +use PDO; class DatabaseSqliteSchemaBuilderTest extends SqliteTestCase { @@ -92,4 +96,28 @@ public function testGetRawIndex() $this->assertSame([], collect($indexes)->firstWhere('name', 'table_raw_index')['columns']); } + + public function testDropAllTablesRefreshesTheCanonicalPathForAFileUri(): void + { + $directory = ParallelTesting::tempDir('DatabaseSqliteSchemaBuilderTest'); + $files = new Filesystem; + $files->ensureDirectoryExists($directory); + $path = $directory . '/database.sqlite'; + $files->put($path, ''); + $uri = 'file:' . $path . '?mode=rwc'; + $pdo = new PDO('sqlite:' . $uri); + $connection = new SQLiteConnection($pdo, $uri); + + try { + $connection->statement('create table canonical_path_test (id integer primary key)'); + + $connection->getSchemaBuilder()->dropAllTables(); + + $this->assertSame([], $connection->getSchemaBuilder()->getTables()); + $this->assertSame($pdo, $connection->getPdo()); + } finally { + $connection->disconnect(); + $files->deleteDirectory($directory); + } + } } diff --git a/tests/Integration/Database/Sqlite/SqliteTestCase.php b/tests/Integration/Database/Sqlite/SqliteTestCase.php index 8463d7f8a..7c18ad970 100644 --- a/tests/Integration/Database/Sqlite/SqliteTestCase.php +++ b/tests/Integration/Database/Sqlite/SqliteTestCase.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Integration\Database\Sqlite; +use Hypervel\Database\SQLiteDatabase; use Hypervel\Filesystem\Filesystem; use Hypervel\Testbench\Attributes\RequiresDatabase; use Hypervel\Tests\Integration\Database\DatabaseTestCase; @@ -27,11 +28,7 @@ protected function setUp(): void */ protected function isConfiguredForInMemoryDatabase(): bool { - $path = env('DB_DATABASE', ':memory:'); - - return $path === ':memory:' - || str_contains($path, '?mode=memory') - || str_contains($path, '&mode=memory'); + return SQLiteDatabase::isInMemory($this->getConfiguredDatabasePath()); } /** @@ -52,12 +49,13 @@ protected function getConfiguredDatabasePath(): string */ protected function ensureSqliteDatabaseFileExists(): void { - if ($this->isConfiguredForInMemoryDatabase()) { + $path = $this->getConfiguredDatabasePath(); + + // URI names pass to SQLite unchanged and are not literal filesystem paths. + if (SQLiteDatabase::isInMemory($path) || SQLiteDatabase::isUri($path)) { return; } - $path = $this->getConfiguredDatabasePath(); - if (! file_exists($path)) { touch($path); } @@ -74,7 +72,8 @@ protected function deleteSqliteDatabaseFile(?string $path = null): void { $path ??= $this->app->make('config')->get('database.connections.sqlite.database'); - if ($path === ':memory:' || str_contains($path, 'mode=memory')) { + // URI names pass to SQLite unchanged and are not literal filesystem paths. + if (SQLiteDatabase::isInMemory($path) || SQLiteDatabase::isUri($path)) { return; } From 2cbb307136c936cb13ec90e8bfe69786ffac35ad Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:09:10 +0000 Subject: [PATCH 08/26] fix(database): serialize shared in-memory SQLite ownership Normalize valid in-memory SQLite pools to one managed wrapper because every wrapper shares the same physical PDO. Reuse the existing pool channel as the only synchronization primitive and preserve PoolOption as the validator for invalid configurations. Canonicalize unsplit base, read, and write aliases around the shared owner in both runtime and testing resolvers. Update concurrency, session-configuration, persistence, query-log, alias-flush, and pool-capacity tests to prove deterministic transfer without adding another lock or coordinator. --- src/database/src/Pool/DbPool.php | 33 ++-- .../Testing/DatabaseConnectionResolver.php | 46 ++++- .../DatabaseConnectionResolverTest.php | 16 +- .../ConnectionCoroutineSafetyTest.php | 90 ++++----- .../Database/DatabaseConnectionsTest.php | 1 + .../Database/PooledConnectionTest.php | 12 +- .../Sqlite/InMemorySqliteSharedPdoTest.php | 181 ++++++++++++++---- 7 files changed, 259 insertions(+), 120 deletions(-) diff --git a/src/database/src/Pool/DbPool.php b/src/database/src/Pool/DbPool.php index fb5956d8e..811719a1d 100644 --- a/src/database/src/Pool/DbPool.php +++ b/src/database/src/Pool/DbPool.php @@ -9,6 +9,7 @@ use Hypervel\Coordinator\Timer; use Hypervel\Database\ConnectionName; use Hypervel\Database\Connectors\ConnectionFactory; +use Hypervel\Database\SQLiteDatabase; use Hypervel\Pool\Frequency; use Hypervel\Pool\Pool; use Hypervel\Support\Arr; @@ -22,9 +23,9 @@ * Extends the base Pool to create PooledConnection instances that wrap * our Laravel-ported Connection class. * - * For in-memory SQLite, manages a shared PDO so all pool slots see the same - * data. Non-pooled paths (Capsule, SimpleConnectionResolver) bypass this - * entirely and get isolated connections as expected. + * For in-memory SQLite, manages a shared PDO behind a single pooled owner. + * Non-pooled paths (Capsule, SimpleConnectionResolver) bypass this entirely + * and get isolated connections as expected. */ class DbPool extends Pool { @@ -35,8 +36,7 @@ class DbPool extends Pool protected ?int $heartbeatTimerId = null; /** - * Shared PDO for in-memory SQLite. All pool slots must share the same PDO - * instance, otherwise each would get its own empty database. + * Shared PDO for in-memory SQLite. */ protected ?PDO $sharedInMemorySqlitePdo = null; @@ -70,6 +70,20 @@ public function __construct(Container $container, string $name) ['testing_enabled'], ); + $minimum = $poolOptions['min_connections'] ?? 1; + $maximum = $poolOptions['max_connections'] ?? 10; + + if ($this->isInMemorySqlite() + && is_int($minimum) + && is_int($maximum) + && $minimum >= 0 + && $maximum >= 1 + && $minimum <= $maximum + ) { + $poolOptions['min_connections'] = min($minimum, 1); + $poolOptions['max_connections'] = 1; + } + $this->frequency = new Frequency; parent::__construct($container, $name, $poolOptions); @@ -77,8 +91,7 @@ public function __construct(Container $container, string $name) $this->heartbeatTimer = new Timer($this->getLogger()); - // For in-memory SQLite, pre-create a shared PDO so all pool slots - // see the same database. This must happen after parent::__construct. + // The sole managed wrapper must retain one PDO for the database lifetime. if ($this->isInMemorySqlite()) { $this->sharedInMemorySqlitePdo = $this->createSharedInMemorySqlitePdo(); } @@ -149,9 +162,7 @@ protected function isInMemorySqlite(): bool $database = $this->config['database'] ?? ''; - return $database === ':memory:' - || str_contains($database, '?mode=memory') - || str_contains($database, '&mode=memory'); + return SQLiteDatabase::isInMemory($database); } /** @@ -165,7 +176,7 @@ protected function ensureNotDerivedInMemorySqlitePool(ConnectionName $name, arra $database = $config['database'] ?? ''; - if ($database === ':memory:' || str_contains($database, '?mode=memory') || str_contains($database, '&mode=memory')) { + if (SQLiteDatabase::isInMemory($database)) { throw new InvalidArgumentException( "Database connection [{$name->requested}] cannot use a derived read pool for in-memory SQLite." ); diff --git a/src/foundation/src/Testing/DatabaseConnectionResolver.php b/src/foundation/src/Testing/DatabaseConnectionResolver.php index 45fc2dbb1..ad44f94e8 100644 --- a/src/foundation/src/Testing/DatabaseConnectionResolver.php +++ b/src/foundation/src/Testing/DatabaseConnectionResolver.php @@ -14,6 +14,7 @@ use Hypervel\Database\ConnectionName; use Hypervel\Database\ConnectionResolver; use Hypervel\Database\FlushableConnectionResolver; +use Hypervel\Database\Pool\DbPool; use LogicException; use Throwable; use UnitEnum; @@ -128,6 +129,24 @@ protected static function registerDispatcherRebinding(ContainerContract $contain static::$rebindingRegistered = true; } + /** + * Resolve the cache key that owns the pooled wrapper. + */ + protected function connectionCacheKey(string $name, ?DbPool $pool = null): string + { + if ($pool === null) { + if (! $this->factory->hasPool($name)) { + return $name; + } + + $pool = $this->factory->getPool($name); + } + + return $pool->getSharedInMemorySqlitePdo() !== null + ? $pool->getName() + : $name; + } + /** * Flush a cached connection. * @@ -135,12 +154,14 @@ protected static function registerDispatcherRebinding(ContainerContract $contain */ public function flush(string $name): void { + $cacheKey = $this->connectionCacheKey($name); + try { - if (isset(static::$pooledConnections[$name])) { - static::$pooledConnections[$name]->discard(); + if (isset(static::$pooledConnections[$cacheKey])) { + static::$pooledConnections[$cacheKey]->discard(); } } finally { - unset(static::$pooledConnections[$name], static::$connections[$name]); + unset(static::$pooledConnections[$cacheKey], static::$connections[$cacheKey]); } } @@ -179,7 +200,20 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa return $connection; } - $pooled = $this->factory->getPool($connectionName->requested)->get(); + $pool = $this->factory->getPool($connectionName->requested); + $cacheKey = $this->connectionCacheKey($connectionName->requested, $pool); + + if ($cacheKey !== $connectionName->requested + && $connection = static::$connections[$cacheKey] ?? null + ) { + if ($connectionName->isWrite() && $connection instanceof Connection) { + $connection->useWriteConnectionWhenReading(); + } + + return $connection; + } + + $pooled = $pool->get(); try { $connection = $pooled->getConnection(); @@ -197,9 +231,9 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa $connection->useWriteConnectionWhenReading(); } - static::$pooledConnections[$connectionName->requested] = $pooled; + static::$pooledConnections[$cacheKey] = $pooled; - return static::$connections[$connectionName->requested] = $connection; + return static::$connections[$cacheKey] = $connection; } /** diff --git a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php index 5a5240043..76eaedbb8 100644 --- a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php +++ b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php @@ -13,7 +13,7 @@ class DatabaseConnectionResolverTest extends TestCase { - public function testFlushDisconnectsCachedConnection() + public function testFlushDisconnectsCachedConnection(): void { $resolver = $this->app->make(DatabaseConnectionResolver::class); @@ -83,6 +83,20 @@ public function testCachedWriteConnectionReappliesWriteReadRoutingAfterReset(): $this->assertSame('Taylor', $cachedConnection->selectOne('select name from users')->name); } + public function testSharedInMemorySqliteAliasesReuseAndFlushOneCachedOwner(): void + { + $resolver = $this->app->make(DatabaseConnectionResolver::class); + $connection = $resolver->connection('testing'); + + $this->assertSame($connection, $resolver->connection('testing::read')); + $this->assertSame($connection, $resolver->connection('testing::write')); + + $resolver->flush('testing::read'); + + $this->assertNull($connection->getRawPdo()); + $this->assertNotSame($connection, $resolver->connection('testing')); + } + public function testIntegerBackedEnumConnectionNamesAreNormalizedInTestingAndPooledModes(): void { $config = $this->app->make('config'); diff --git a/tests/Integration/Database/ConnectionCoroutineSafetyTest.php b/tests/Integration/Database/ConnectionCoroutineSafetyTest.php index ba88989f8..0cbd5883a 100644 --- a/tests/Integration/Database/ConnectionCoroutineSafetyTest.php +++ b/tests/Integration/Database/ConnectionCoroutineSafetyTest.php @@ -96,17 +96,6 @@ protected function defineEnvironment($app): void 'heartbeat' => -1, ], ]); - - $app->make('config')->set('database.connections.session_shared_pool', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'pool' => [ - 'testing_enabled' => true, - 'min_connections' => 2, - 'max_connections' => 2, - 'heartbeat' => -1, - ], - ]); } protected function afterRefreshingDatabase(): void @@ -489,61 +478,54 @@ function () use ($pool, $firstFinished): int { public function testOverlappingConfigurationOfSharedPdoFailsClosedForBothCallers(): void { - $configurator = new CoroutineSessionConfigurator('session_shared_pool'); + $connectionName = 'session_shared_connection'; + $configurator = new CoroutineSessionConfigurator($connectionName); Connection::configureSessionUsing($configurator); CoroutineContext::set(CoroutineSessionConfigurator::CONTEXT_KEY, '0'); - $pool = new DbPool($this->app, 'session_shared_pool'); + $pdo = new PDO('sqlite::memory:'); + $config = ['name' => $connectionName]; + $firstConnection = new Connection($pdo, ':memory:', '', $config); + $secondConnection = new Connection($pdo, ':memory:', '', $config); $configurationStarted = new Channel(1); $resumeConfiguration = new Channel(1); $configurator->blockedState = '101'; $configurator->configurationStarted = $configurationStarted; $configurator->resumeConfiguration = $resumeConfiguration; - try { - [$firstFailure, $secondFailure] = parallel([ - function () use ($pool): string { - CoroutineContext::set(CoroutineSessionConfigurator::CONTEXT_KEY, '101'); - - /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $firstConnection->getPdo(); - try { - $pooledConnection->getConnection()->getPdo(); + [$firstFailure, $secondFailure] = parallel([ + function () use ($firstConnection): string { + CoroutineContext::set(CoroutineSessionConfigurator::CONTEXT_KEY, '101'); - return 'no failure'; - } catch (RuntimeException $exception) { - return $exception->getMessage(); - } finally { - $pooledConnection->release(); - } - }, - function () use ($pool, $configurationStarted, $resumeConfiguration): string { - $configurationStarted->pop(); - CoroutineContext::set(CoroutineSessionConfigurator::CONTEXT_KEY, '202'); + try { + $firstConnection->getPdo(); - /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); - - try { - $pooledConnection->getConnection()->getPdo(); - - return 'no failure'; - } catch (RuntimeException $exception) { - return $exception->getMessage(); - } finally { - $resumeConfiguration->push(true); - $pooledConnection->release(); - } - }, - ]); + return 'no failure'; + } catch (RuntimeException $exception) { + return $exception->getMessage(); + } + }, + function () use ($secondConnection, $configurationStarted, $resumeConfiguration): string { + $configurationStarted->pop(); + CoroutineContext::set(CoroutineSessionConfigurator::CONTEXT_KEY, '202'); + + try { + $secondConnection->getPdo(); + + return 'no failure'; + } catch (RuntimeException $exception) { + return $exception->getMessage(); + } finally { + $resumeConfiguration->push(true); + } + }, + ]); - $this->assertSame('Database session state became unknown during configuration.', $firstFailure); - $this->assertSame('Reentrant database session configuration is not allowed.', $secondFailure); - $this->assertSame(['0', '101'], $configurator->appliedStates); - $this->assertSame(2, $configurator->applyCalls); - } finally { - $pool->close(); - } + $this->assertSame('Database session state became unknown during configuration.', $firstFailure); + $this->assertSame('Reentrant database session configuration is not allowed.', $secondFailure); + $this->assertSame(['0', '101'], $configurator->appliedStates); + $this->assertSame(2, $configurator->applyCalls); } public function testWriteSuffixDoesNotForcePlainConnectionReadsInAnotherCoroutine(): void diff --git a/tests/Integration/Database/DatabaseConnectionsTest.php b/tests/Integration/Database/DatabaseConnectionsTest.php index 64e80bcfe..7e35a69f6 100644 --- a/tests/Integration/Database/DatabaseConnectionsTest.php +++ b/tests/Integration/Database/DatabaseConnectionsTest.php @@ -125,6 +125,7 @@ protected function assertQueryReadWriteTypes(string $connectionName, array $expe $events = collect(); $connection->listen($events->push(...)); + $connection->flushQueryLog(); $connection->enableQueryLog(); $connection->statement('select 1'); diff --git a/tests/Integration/Database/PooledConnectionTest.php b/tests/Integration/Database/PooledConnectionTest.php index b8b23aa81..c6f7a7e0c 100644 --- a/tests/Integration/Database/PooledConnectionTest.php +++ b/tests/Integration/Database/PooledConnectionTest.php @@ -978,24 +978,22 @@ public function testReleaseResetsErrorCountForNextBorrowWindow(): void $nextPooledConnection->release(); } - public function testSharedPdoForInMemorySqlite(): void + public function testSharedPdoPersistsAcrossInMemorySqliteBorrows(): void { $pool = new DbPool($this->app, 'pool_test'); $this->assertNotNull($pool->getSharedInMemorySqlitePdo()); - // Two connections from the same pool should share the same PDO /** @var PooledConnection $conn1 */ $conn1 = $pool->get(); + $pdo1 = $conn1->getConnection()->getPdo(); + $conn1->release(); + /** @var PooledConnection $conn2 */ $conn2 = $pool->get(); - - $pdo1 = $conn1->getConnection()->getPdo(); $pdo2 = $conn2->getConnection()->getPdo(); - $this->assertSame($pdo1, $pdo2, 'In-memory SQLite connections should share the same PDO'); - - $conn1->release(); + $this->assertSame($pdo1, $pdo2, 'In-memory SQLite borrows should share the same PDO'); $conn2->release(); } diff --git a/tests/Integration/Database/Sqlite/InMemorySqliteSharedPdoTest.php b/tests/Integration/Database/Sqlite/InMemorySqliteSharedPdoTest.php index 64bfc15c4..1db65a718 100644 --- a/tests/Integration/Database/Sqlite/InMemorySqliteSharedPdoTest.php +++ b/tests/Integration/Database/Sqlite/InMemorySqliteSharedPdoTest.php @@ -7,16 +7,18 @@ use Hypervel\Database\Connection; use Hypervel\Database\Connectors\ConnectionFactory; use Hypervel\Database\Connectors\SQLiteConnector; -use Hypervel\Database\Pool\DbPool; -use Hypervel\Database\Pool\PooledConnection; use Hypervel\Database\Pool\PoolFactory; +use Hypervel\Engine\Channel; use Hypervel\Filesystem\Filesystem; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; +use InvalidArgumentException; use PDO; use PHPUnit\Framework\Attributes\DataProvider; -use ReflectionMethod; +use Throwable; +use TypeError; +use function Hypervel\Coroutine\parallel; use function Hypervel\Coroutine\run; /** @@ -64,12 +66,8 @@ protected function getPoolFactory(): PoolFactory return $this->app->make(PoolFactory::class); } - // ========================================================================= - // DbPool::isInMemorySqlite() detection tests - // ========================================================================= - #[DataProvider('inMemoryDatabaseProvider')] - public function testIsInMemorySqliteDetection(string $database, bool $expected): void + public function testPoolCapacityFollowsSQLiteClassification(string $database, bool $inMemory): void { $config = $this->app->make('config'); @@ -89,27 +87,27 @@ public function testIsInMemorySqliteDetection(string $database, bool $expected): $factory = $this->getPoolFactory(); $pool = $factory->getPool($configKey); - // Use reflection to test the protected method - $method = new ReflectionMethod(DbPool::class, 'isInMemorySqlite'); - - $this->assertSame($expected, $method->invoke($pool)); - - // Cleanup + $this->assertSame($inMemory ? 1 : 2, $pool->getOption()->getMaxConnections()); + $this->assertSame($inMemory, $pool->getSharedInMemorySqlitePdo() instanceof PDO); $factory->flushPool($configKey); } + /** + * @return array + */ public static function inMemoryDatabaseProvider(): array { return [ 'standard :memory:' => [':memory:', true], - 'query string mode=memory' => ['file:test?mode=memory', true], - 'ampersand mode=memory' => ['file:test?cache=shared&mode=memory', true], - 'mode=memory at end' => ['file:test?other=value&mode=memory', true], + 'memory URI path' => ['file::memory:', true], + 'encoded memory URI path' => ['file:%3Amemory%3A', true], + 'empty URI path in memory mode' => ['file:?mode=memory', true], + 'named URI in memory mode' => ['file:test?mode=memory', true], + 'encoded memory mode' => ['file:test?mode=%6demory', true], 'regular file path' => ['/tmp/database.sqlite', false], - 'relative path' => ['database.sqlite', false], - 'empty string' => ['', false], - 'memory in path name' => ['/tmp/memory.sqlite', false], - 'mode_memory without equals' => ['file:test?mode_memory', false], + 'file URI' => ['file:/tmp/database.sqlite', false], + 'uppercase mode key' => ['file:test?MODE=memory', false], + 'last duplicate mode wins' => ['file:test?mode=memory&mode=rwc', false], ]; } @@ -133,13 +131,99 @@ public function testNonSqliteDriverIsNotInMemorySqlite(): void $factory = $this->getPoolFactory(); $pool = $factory->getPool('mysql_memory_test'); - $method = new ReflectionMethod(DbPool::class, 'isInMemorySqlite'); - - $this->assertFalse($method->invoke($pool)); + $this->assertSame(2, $pool->getOption()->getMaxConnections()); + $this->assertNull($pool->getSharedInMemorySqlitePdo()); $factory->flushPool('mysql_memory_test'); } + public function testDerivedReadPoolRejectsUriInMemoryDatabase(): void + { + $config = $this->app->make('config'); + $config->set('database.connections.uri_read_memory_test', [ + 'driver' => 'sqlite', + 'database' => '/tmp/database.sqlite', + 'prefix' => '', + 'read' => [ + 'database' => 'file::memory:', + ], + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Database connection [uri_read_memory_test::read] cannot use a derived read pool for in-memory SQLite.' + ); + + $this->getPoolFactory()->getPool('uri_read_memory_test::read'); + } + + public function testInMemoryPoolPreservesAZeroManagedConnectionFloor(): void + { + $config = $this->app->make('config'); + $config->set('database.connections.zero_floor_memory_test', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'pool' => [ + 'min_connections' => 0, + 'max_connections' => 5, + ], + ]); + + $pool = $this->getPoolFactory()->getPool('zero_floor_memory_test'); + + $this->assertSame(0, $pool->getOption()->getMinConnections()); + $this->assertSame(1, $pool->getOption()->getMaxConnections()); + } + + /** + * @param array $poolOptions + * @param class-string $exception + */ + #[DataProvider('invalidPoolOptionProvider')] + public function testInMemoryPoolDoesNotMaskInvalidConnectionCounts( + array $poolOptions, + string $exception + ): void { + $config = $this->app->make('config'); + $connection = 'invalid_memory_test_' . hash('xxh128', serialize($poolOptions)); + $config->set("database.connections.{$connection}", [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'pool' => $poolOptions, + ]); + + $this->expectException($exception); + + $this->getPoolFactory()->getPool($connection); + } + + /** + * @return array, class-string}> + */ + public static function invalidPoolOptionProvider(): array + { + return [ + 'negative minimum' => [ + ['min_connections' => -1, 'max_connections' => 5], + InvalidArgumentException::class, + ], + 'zero maximum' => [ + ['min_connections' => 0, 'max_connections' => 0], + InvalidArgumentException::class, + ], + 'minimum exceeds maximum' => [ + ['min_connections' => 2, 'max_connections' => 1], + InvalidArgumentException::class, + ], + 'non-integer minimum' => [ + ['min_connections' => '1', 'max_connections' => 5], + TypeError::class, + ], + ]; + } + // ========================================================================= // Shared PDO tests // ========================================================================= @@ -187,27 +271,41 @@ public function testFileSqlitePoolDoesNotHaveSharedPdo(): void } } - public function testAllPoolSlotsShareSamePdoForInMemorySqlite(): void + public function testInMemorySqlitePoolSerializesOneSharedPdoOwner(): void { $factory = $this->getPoolFactory(); $pool = $factory->getPool('memory_test'); - run(function () use ($pool) { - // Get multiple pooled connections - $pooled1 = $pool->get(); - $pooled2 = $pool->get(); - - $connection1 = $pooled1->getConnection(); - $connection2 = $pooled2->getConnection(); - - // Both connections should have the same underlying PDO - $pdo1 = $connection1->getPdo(); - $pdo2 = $connection2->getPdo(); - - $this->assertSame($pdo1, $pdo2, 'All pool slots should share the same PDO for in-memory SQLite'); - - $pooled1->release(); - $pooled2->release(); + run(function () use ($pool): void { + $secondAttempted = new Channel(1); + $secondAcquired = new Channel(1); + + [$firstPdo, $secondPdo] = parallel([ + function () use ($pool, $secondAttempted, $secondAcquired): PDO { + $pooledConnection = $pool->get(); + $pdo = $pooledConnection->getConnection()->getPdo(); + + $secondAttempted->pop(); + $this->assertFalse($secondAcquired->pop(0.01)); + $pooledConnection->release(); + + return $pdo; + }, + function () use ($pool, $secondAttempted, $secondAcquired): PDO { + $secondAttempted->push(true); + $pooledConnection = $pool->get(); + $secondAcquired->push(true); + + try { + return $pooledConnection->getConnection()->getPdo(); + } finally { + $pooledConnection->release(); + } + }, + ]); + + $this->assertSame(1, $pool->getOption()->getMaxConnections()); + $this->assertSame($firstPdo, $secondPdo); }); } @@ -328,6 +426,7 @@ public function testPooledConnectionCloseDoesNotDisconnectSharedPdo(): void // Close the pooled connection (should NOT disconnect the shared PDO) $pooled->close(); + $pooled->release(); // The shared PDO should still be functional // Get another pooled connection and verify data still exists From f7febdb15f8aea57ba034ce70466acedaa241472 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:09:23 +0000 Subject: [PATCH 09/26] fix(testing): manage SQLite databases through one classifier Use SQLiteDatabase across RefreshDatabase, application parallel testing, package testing, and Testbench so every supported memory and URI form receives the same lifecycle decision. Cache only physical memory PDOs in mixed transaction sets, keep the live refresh connection override, avoid suffixing memory databases, and fail fast when automatic file management receives a non-memory URI. Preserve assigned ParaTest identity and the existing without-databases escape hatch. Add focused coverage for mixed connections, both parallel systems, Testbench setup and cleanup, plain-path suffixing, URI rejection, and documented test configuration behavior. --- src/boost/docs/testing.md | 2 + .../InteractsWithParallelDatabase.php | 47 +++++- .../src/Testing/RefreshDatabase.php | 29 +++- .../InteractsWithSqliteDatabaseFile.php | 3 +- .../src/Concerns/HandlesDatabases.php | 5 +- src/testing/src/Concerns/TestDatabases.php | 18 +- .../InteractsWithParallelDatabaseTest.php | 154 ++++++++++++++++++ .../Testing/RefreshDatabaseTest.php | 140 +++++++++++++++- tests/Testbench/DefaultConfigurationTest.php | 17 ++ tests/Testing/Concerns/TestDatabasesTest.php | 78 +++++++++ 10 files changed, 472 insertions(+), 21 deletions(-) diff --git a/src/boost/docs/testing.md b/src/boost/docs/testing.md index 8bf92fc8e..56c3a1d91 100644 --- a/src/boost/docs/testing.md +++ b/src/boost/docs/testing.md @@ -276,6 +276,8 @@ php artisan test --parallel --processes=4 As long as you have configured a primary database connection, Hypervel automatically handles creating and migrating a test database for each parallel process that is running your tests. The test databases will be suffixed with a process token which is unique per process. For example, if you have two parallel test processes, Hypervel will create and use `your_db_test_1` and `your_db_test_2` test databases. +SQLite URI databases cannot be automatically isolated for parallel testing. Configure a plain filesystem path, or use the `--without-databases` option. + By default, test databases persist between calls to the `test` Artisan command so that they can be used again by subsequent `test` invocations. However, you may re-create them using the `--recreate-databases` option: ```shell diff --git a/src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php b/src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php index e9c0cfe16..c89ec0447 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php @@ -5,8 +5,10 @@ namespace Hypervel\Foundation\Testing\Concerns; use Hypervel\Database\QueryException; +use Hypervel\Database\SQLiteDatabase; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; +use InvalidArgumentException; /** * Provides per-worker database isolation for parallel testing. @@ -42,6 +44,10 @@ trait InteractsWithParallelDatabase */ protected function configureParallelDatabaseName($app): void { + if (! empty($_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_DATABASES'])) { + return; + } + $token = env('TEST_TOKEN'); if ($token === null) { @@ -56,9 +62,10 @@ protected function configureParallelDatabaseName($app): void return; } - $database = $config->get("database.connections.{$connection}.database", ''); + $driver = $config->get("database.connections.{$connection}.driver"); + $database = $config->get("database.connections.{$connection}.database"); - if ($database === ':memory:' || $database === '') { + if (! is_string($database) || ! $this->shouldManageParallelDatabase($driver, $database)) { return; } @@ -78,6 +85,10 @@ protected function configureParallelDatabaseName($app): void */ protected function ensureParallelDatabaseExists(): void { + if (! empty($_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_DATABASES'])) { + return; + } + $token = env('TEST_TOKEN'); if ($token === null) { @@ -91,9 +102,10 @@ protected function ensureParallelDatabaseExists(): void return; } - $database = $config->get("database.connections.{$connection}.database", ''); + $driver = $config->get("database.connections.{$connection}.driver"); + $database = $config->get("database.connections.{$connection}.database"); - if ($database === ':memory:' || $database === '') { + if (! is_string($database) || ! $this->shouldManageParallelDatabase($driver, $database)) { return; } @@ -114,6 +126,33 @@ protected function ensureParallelDatabaseExists(): void } } + /** + * Determine if the database should be managed for parallel testing. + */ + protected function shouldManageParallelDatabase(mixed $driver, string $database): bool + { + if ($database === '') { + return false; + } + + if ($driver !== 'sqlite') { + return true; + } + + if (SQLiteDatabase::isInMemory($database)) { + return false; + } + + if (SQLiteDatabase::isUri($database)) { + throw new InvalidArgumentException( + 'SQLite URI databases cannot be automatically managed during parallel testing. ' + . 'Configure a plain filesystem path or run with --without-databases.' + ); + } + + return true; + } + /** * Get the per-worker test database name. */ diff --git a/src/foundation/src/Testing/RefreshDatabase.php b/src/foundation/src/Testing/RefreshDatabase.php index 5da5822ca..eab653d2b 100644 --- a/src/foundation/src/Testing/RefreshDatabase.php +++ b/src/foundation/src/Testing/RefreshDatabase.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Database\Connection as DatabaseConnection; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\SQLiteDatabase; use Hypervel\Foundation\Testing\Concerns\InteractsWithParallelDatabase; use Hypervel\Foundation\Testing\Traits\CanConfigureMigrationCommands; @@ -31,7 +32,7 @@ public function refreshDatabase(): void // Restore in-memory database BEFORE migrations for all tests. // This ensures the correct ordering: restore cached PDO → run migrations → begin transaction. // For in-memory SQLite, this avoids overwriting a freshly migrated schema later. - if ($this->usingInMemoryDatabase()) { + if ($this->usingInMemoryDatabases()) { $this->restoreInMemoryDatabase(); } @@ -70,13 +71,31 @@ protected function restoreInMemoryDatabase(): void } /** - * Determine if an in-memory database is being used. + * Determine if any of the connections transacting is using in-memory databases. */ - protected function usingInMemoryDatabase(): bool + protected function usingInMemoryDatabases(): bool + { + foreach ($this->connectionsToTransact() as $name) { + if ($this->usingInMemoryDatabase($name)) { + return true; + } + } + + return false; + } + + /** + * Determine if a given database connection is an in-memory database. + */ + protected function usingInMemoryDatabase(?string $name = null): bool { $config = $this->app->make('config'); + $name ??= $this->getRefreshConnection(); - return $config->get("database.connections.{$this->getRefreshConnection()}.database") === ':memory:'; + // All supported SQLite memory URI forms need the same refresh lifecycle. + return SQLiteDatabase::isInMemory( + $config->string("database.connections.{$name}.database") + ); } /** @@ -166,7 +185,7 @@ protected function beginDatabaseTransactionWork(): void // Set the testing transaction manager on the connection $connection->setTransactionManager($transactionsManager); - if ($this->usingInMemoryDatabase()) { + if ($this->usingInMemoryDatabase($name)) { RefreshDatabaseState::$inMemoryConnections[$name] ??= $connection->getPdo(); } diff --git a/src/testbench/src/Concerns/Database/InteractsWithSqliteDatabaseFile.php b/src/testbench/src/Concerns/Database/InteractsWithSqliteDatabaseFile.php index 39f9894ac..1a8dba123 100644 --- a/src/testbench/src/Concerns/Database/InteractsWithSqliteDatabaseFile.php +++ b/src/testbench/src/Concerns/Database/InteractsWithSqliteDatabaseFile.php @@ -4,6 +4,7 @@ namespace Hypervel\Testbench\Concerns\Database; +use Hypervel\Database\SQLiteDatabase; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Collection; use Hypervel\Support\Facades\DB; @@ -66,7 +67,7 @@ protected function activeSqliteDatabasePath(): string $database = $this->baseSqliteDatabasePath(); $token = env('TEST_TOKEN'); - if ($token === null || $database === '' || $database === ':memory:') { + if ($token === null || $database === '' || SQLiteDatabase::isInMemory($database)) { return $database; } diff --git a/src/testbench/src/Concerns/HandlesDatabases.php b/src/testbench/src/Concerns/HandlesDatabases.php index 3ae88b1bd..163136730 100644 --- a/src/testbench/src/Concerns/HandlesDatabases.php +++ b/src/testbench/src/Concerns/HandlesDatabases.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Database\Events\DatabaseRefreshed; +use Hypervel\Database\SQLiteDatabase; use Hypervel\Testbench\Attributes\DefineDatabase; use Hypervel\Testbench\Attributes\RequiresDatabase; use Hypervel\Testbench\Attributes\WithMigration; @@ -88,9 +89,7 @@ protected function usesSqliteInMemoryDatabaseConnection(?string $connection = nu return false; } - return $database['database'] === ':memory:' - || str_contains($database['database'], '?mode=memory') - || str_contains($database['database'], '&mode=memory'); + return SQLiteDatabase::isInMemory($database['database']); } /** diff --git a/src/testing/src/Concerns/TestDatabases.php b/src/testing/src/Concerns/TestDatabases.php index 5d96fe985..5d57c9203 100644 --- a/src/testing/src/Concerns/TestDatabases.php +++ b/src/testing/src/Concerns/TestDatabases.php @@ -5,12 +5,14 @@ namespace Hypervel\Testing\Concerns; use Hypervel\Database\QueryException; +use Hypervel\Database\SQLiteDatabase; use Hypervel\Foundation\Testing; use Hypervel\Support\Arr; use Hypervel\Support\Facades\Artisan; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\ParallelTesting; use Hypervel\Support\Facades\Schema; +use InvalidArgumentException; trait TestDatabases { @@ -141,11 +143,23 @@ protected function whenNotUsingInMemoryDatabase(callable $callback): void return; } + /** @var string $database */ $database = DB::getConfig('database'); - if ($database !== ':memory:') { - $callback($database); + if (DB::getConfig('driver') === 'sqlite') { + if (SQLiteDatabase::isInMemory($database)) { + return; + } + + if (SQLiteDatabase::isUri($database)) { + throw new InvalidArgumentException( + 'SQLite URI databases cannot be automatically managed during parallel testing. ' + . 'Configure a plain filesystem path or run with --without-databases.' + ); + } } + + $callback($database); } /** diff --git a/tests/Foundation/Testing/Concerns/InteractsWithParallelDatabaseTest.php b/tests/Foundation/Testing/Concerns/InteractsWithParallelDatabaseTest.php index 567cfe2c5..9f3736e68 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithParallelDatabaseTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithParallelDatabaseTest.php @@ -4,8 +4,10 @@ namespace Hypervel\Tests\Foundation\Testing\Concerns; +use Closure; use Hypervel\Foundation\Testing\Concerns\InteractsWithParallelDatabase; use Hypervel\Testbench\TestCase; +use InvalidArgumentException; class InteractsWithParallelDatabaseTest extends TestCase { @@ -64,6 +66,57 @@ public function testConfigureParallelDatabaseNameSkipsInMemorySqlite() $this->assertSame(':memory:', $config->get("database.connections.{$connection}.database")); } + public function testConfigureParallelDatabaseNameSkipsSqliteMemoryUri(): void + { + $config = $this->app->make('config'); + $connection = $config->get('database.default'); + $config->set("database.connections.{$connection}.database", 'file::memory:'); + + $this->withParallelEnvironment('7', false, function () use ($config, $connection): void { + $this->configureParallelDatabaseName($this->app); + + $this->assertSame( + 'file::memory:', + $config->get("database.connections.{$connection}.database") + ); + }); + } + + public function testConfigureParallelDatabaseNameRejectsSqliteFileUri(): void + { + $config = $this->app->make('config'); + $connection = $config->get('database.default'); + $config->set("database.connections.{$connection}.database", 'file:/tmp/database.sqlite?mode=rwc'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'SQLite URI databases cannot be automatically managed during parallel testing. ' + . 'Configure a plain filesystem path or run with --without-databases.' + ); + + $this->withParallelEnvironment( + '7', + false, + fn () => $this->configureParallelDatabaseName($this->app) + ); + } + + public function testConfigureParallelDatabaseNameHonorsWithoutDatabasesOption(): void + { + $config = $this->app->make('config'); + $connection = $config->get('database.default'); + $database = $config->get("database.connections.{$connection}.database"); + + $this->withParallelEnvironment('7', true, function () use ($config, $connection, $database): void { + $this->configureParallelDatabaseName($this->app); + + $this->assertSame( + $database, + $config->get("database.connections.{$connection}.database") + ); + }); + } + public function testConfigureParallelDatabaseNameSkipsEmptyDatabase() { $config = $this->app->make('config'); @@ -91,4 +144,105 @@ public function testEnsureParallelDatabaseExistsIsNoOpWithoutTestToken() $this->assertTrue(true); } + + public function testEnsureParallelDatabaseExistsSkipsLateConfiguredSqliteMemoryUri(): void + { + $config = $this->app->make('config'); + $connection = $config->get('database.default'); + $config->set("database.connections.{$connection}.database", 'file::memory:'); + + $this->withParallelEnvironment('7', false, function (): void { + $this->ensureParallelDatabaseExists(); + + $this->assertTrue(true); + }); + } + + public function testEnsureParallelDatabaseExistsRejectsLateConfiguredSqliteFileUri(): void + { + $config = $this->app->make('config'); + $connection = $config->get('database.default'); + $config->set("database.connections.{$connection}.database", 'file:/tmp/database.sqlite?mode=rwc'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'SQLite URI databases cannot be automatically managed during parallel testing. ' + . 'Configure a plain filesystem path or run with --without-databases.' + ); + + $this->withParallelEnvironment( + '7', + false, + fn () => $this->ensureParallelDatabaseExists() + ); + } + + public function testEnsureParallelDatabaseExistsHonorsWithoutDatabasesOption(): void + { + $config = $this->app->make('config'); + $connection = $config->get('database.default'); + $config->set("database.connections.{$connection}.database", 'file:/tmp/database.sqlite?mode=rwc'); + + $this->withParallelEnvironment('7', true, function (): void { + $this->ensureParallelDatabaseExists(); + + $this->assertTrue(true); + }); + } + + /** + * Run a callback with an isolated parallel-testing environment. + */ + private function withParallelEnvironment( + string $token, + bool $withoutDatabases, + Closure $callback + ): void { + $previousProcessToken = getenv('TEST_TOKEN'); + $previousServerTokenExists = array_key_exists('TEST_TOKEN', $_SERVER); + $previousServerToken = $_SERVER['TEST_TOKEN'] ?? null; + $previousEnvironmentTokenExists = array_key_exists('TEST_TOKEN', $_ENV); + $previousEnvironmentToken = $_ENV['TEST_TOKEN'] ?? null; + $previousWithoutDatabasesExists = array_key_exists( + 'HYPERVEL_PARALLEL_TESTING_WITHOUT_DATABASES', + $_SERVER + ); + $previousWithoutDatabases = $_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_DATABASES'] ?? null; + + putenv("TEST_TOKEN={$token}"); + $_SERVER['TEST_TOKEN'] = $token; + $_ENV['TEST_TOKEN'] = $token; + + if ($withoutDatabases) { + $_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_DATABASES'] = 1; + } else { + unset($_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_DATABASES']); + } + + try { + $callback(); + } finally { + $previousProcessToken === false + ? putenv('TEST_TOKEN') + : putenv("TEST_TOKEN={$previousProcessToken}"); + + if ($previousServerTokenExists) { + $_SERVER['TEST_TOKEN'] = $previousServerToken; + } else { + unset($_SERVER['TEST_TOKEN']); + } + + if ($previousEnvironmentTokenExists) { + $_ENV['TEST_TOKEN'] = $previousEnvironmentToken; + } else { + unset($_ENV['TEST_TOKEN']); + } + + if ($previousWithoutDatabasesExists) { + $_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_DATABASES'] = $previousWithoutDatabases; + } else { + unset($_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_DATABASES']); + } + } + } } diff --git a/tests/Foundation/Testing/RefreshDatabaseTest.php b/tests/Foundation/Testing/RefreshDatabaseTest.php index b13cda861..ffc23b422 100644 --- a/tests/Foundation/Testing/RefreshDatabaseTest.php +++ b/tests/Foundation/Testing/RefreshDatabaseTest.php @@ -15,6 +15,7 @@ use Hypervel\Foundation\Testing\RefreshDatabaseState; use Hypervel\Testbench\Attributes\ResetRefreshDatabaseState; use Hypervel\Testbench\TestCase; +use Hypervel\Testing\ParallelTesting; use Mockery as m; use PDO; use RuntimeException; @@ -36,12 +37,18 @@ class RefreshDatabaseTest extends TestCase protected bool $migrateRefresh = true; + /** + * @var list + */ + protected array $connectionsToTransact = [null]; + public function tearDown(): void { $this->dropViews = false; $this->dropTypes = false; $this->seed = false; $this->seeder = null; + $this->connectionsToTransact = [null]; ResetRefreshDatabaseState::run(); @@ -65,7 +72,14 @@ public function testRefreshTestDatabaseDefault() ])->andReturn(0); $this->app = new Application; - $this->app->singleton('config', fn () => new Repository(['database' => ['default' => 'default']])); + $this->app->singleton('config', fn () => new Repository([ + 'database' => [ + 'default' => 'default', + 'connections' => [ + 'default' => ['database' => 'database.sqlite'], + ], + ], + ])); $this->app->singleton(KernelContract::class, fn () => $kernel); $this->app->singleton('db', fn () => $this->getMockedDatabase()); @@ -85,7 +99,14 @@ public function testRefreshTestDatabaseWithDropViewsOption() '--seed' => false, ])->andReturn(0); $this->app = new Application; - $this->app->singleton('config', fn () => new Repository(['database' => ['default' => 'default']])); + $this->app->singleton('config', fn () => new Repository([ + 'database' => [ + 'default' => 'default', + 'connections' => [ + 'default' => ['database' => 'database.sqlite'], + ], + ], + ])); $this->app->singleton(KernelContract::class, fn () => $kernel); $this->app->singleton('db', fn () => $this->getMockedDatabase()); @@ -105,7 +126,14 @@ public function testRefreshTestDatabaseWithDropTypesOption() '--seed' => false, ])->andReturn(0); $this->app = new Application; - $this->app->singleton('config', fn () => new Repository(['database' => ['default' => 'default']])); + $this->app->singleton('config', fn () => new Repository([ + 'database' => [ + 'default' => 'default', + 'connections' => [ + 'default' => ['database' => 'database.sqlite'], + ], + ], + ])); $this->app->singleton(KernelContract::class, fn () => $kernel); $this->app->singleton('db', fn () => $this->getMockedDatabase()); @@ -125,7 +153,14 @@ public function testRefreshTestDatabaseWithSeedOption() '--seed' => true, ])->andReturn(0); $this->app = new Application; - $this->app->singleton('config', fn () => new Repository(['database' => ['default' => 'default']])); + $this->app->singleton('config', fn () => new Repository([ + 'database' => [ + 'default' => 'default', + 'connections' => [ + 'default' => ['database' => 'database.sqlite'], + ], + ], + ])); $this->app->singleton(KernelContract::class, fn () => $kernel); $this->app->singleton('db', fn () => $this->getMockedDatabase()); @@ -145,7 +180,14 @@ public function testRefreshTestDatabaseWithSeederOption() '--seeder' => 'seeder', ])->andReturn(0); $this->app = new Application; - $this->app->singleton('config', fn () => new Repository(['database' => ['default' => 'default']])); + $this->app->singleton('config', fn () => new Repository([ + 'database' => [ + 'default' => 'default', + 'connections' => [ + 'default' => ['database' => 'database.sqlite'], + ], + ], + ])); $this->app->singleton(KernelContract::class, fn () => $kernel); $this->app->singleton('db', fn () => $this->getMockedDatabase()); @@ -162,7 +204,14 @@ public function testRefreshTestDatabaseRestoresMockConsoleOutputAfterMigrationFa ->andThrow(new RuntimeException('Migration failed.')); $this->app = new Application; - $this->app->singleton('config', fn () => new Repository(['database' => ['default' => 'default']])); + $this->app->singleton('config', fn () => new Repository([ + 'database' => [ + 'default' => 'default', + 'connections' => [ + 'default' => ['database' => 'database.sqlite'], + ], + ], + ])); $this->app->singleton(KernelContract::class, fn () => $kernel); try { @@ -228,6 +277,85 @@ public function testBeginDatabaseTransactionWorkSetsMigratedAndCachesPdoTogether ); } + public function testInMemoryClassificationUsesTheNamedConnectionAndLiveDefault(): void + { + $this->app = new Application; + $this->app->singleton('config', fn () => new Repository([ + 'database' => [ + 'default' => 'file', + 'connections' => [ + 'file' => [ + 'database' => ParallelTesting::tempDir('RefreshDatabaseTest') + . '/database.sqlite', + ], + 'memory' => ['database' => 'file::memory:?cache=shared'], + ], + ], + ])); + + $this->assertFalse($this->usingInMemoryDatabase()); + $this->assertFalse($this->usingInMemoryDatabase('file')); + $this->assertTrue($this->usingInMemoryDatabase('memory')); + + $this->connectionsToTransact = ['file', 'memory']; + + $this->assertTrue($this->usingInMemoryDatabases()); + + $this->connectionsToTransact = ['file']; + + $this->assertFalse($this->usingInMemoryDatabases()); + } + + public function testBeginDatabaseTransactionWorkCachesOnlyNamedInMemoryConnections(): void + { + RefreshDatabaseState::$migrated = false; + RefreshDatabaseState::$inMemoryConnections = []; + + $memoryPdo = m::mock(PDO::class); + $eventDispatcher = m::mock(Dispatcher::class); + $fileConnection = m::mock(ConnectionInterface::class); + $memoryConnection = m::mock(ConnectionInterface::class); + + foreach ([$fileConnection, $memoryConnection] as $connection) { + $connection->shouldReceive('setTransactionManager')->once(); + $connection->shouldReceive('getEventDispatcher')->once()->andReturn($eventDispatcher); + $connection->shouldReceive('unsetEventDispatcher')->once(); + $connection->shouldReceive('beginTransaction')->once(); + $connection->shouldReceive('setEventDispatcher')->once()->with($eventDispatcher); + } + + $fileConnection->shouldNotReceive('getPdo'); + $memoryConnection->shouldReceive('getPdo')->once()->andReturn($memoryPdo); + + $database = m::mock(DatabaseManager::class); + $database->shouldReceive('connection')->once()->with('file')->andReturn($fileConnection); + $database->shouldReceive('connection')->once()->with('memory')->andReturn($memoryConnection); + + $this->connectionsToTransact = ['file', 'memory']; + $this->app = new Application; + $this->app->singleton('config', fn () => new Repository([ + 'database' => [ + 'default' => 'file', + 'connections' => [ + 'file' => [ + 'database' => ParallelTesting::tempDir('RefreshDatabaseTest') + . '/database.sqlite', + ], + 'memory' => ['database' => 'file::memory:?cache=shared'], + ], + ], + ])); + $this->app->singleton('db', fn () => $database); + + $this->beginDatabaseTransactionWork(); + + $this->assertTrue(RefreshDatabaseState::$migrated); + $this->assertSame( + ['memory' => $memoryPdo], + RefreshDatabaseState::$inMemoryConnections, + ); + } + public function testRefreshTestDatabaseLeavesMigratedFalseWhenTransactionWorkNotYetRun(): void { // Regression test for the skip-window scenario: a RunTestsInCoroutine diff --git a/tests/Testbench/DefaultConfigurationTest.php b/tests/Testbench/DefaultConfigurationTest.php index 18f678dd3..d617a2007 100644 --- a/tests/Testbench/DefaultConfigurationTest.php +++ b/tests/Testbench/DefaultConfigurationTest.php @@ -47,6 +47,23 @@ public function itPopulatesExpectedTestingConfig(): void $this->assertFalse($this->usesSqliteInMemoryDatabaseConnection('sqlite')); } + #[Test] + public function itUsesTheCanonicalSqliteMemoryClassification(): void + { + $config = $this->app->make('config'); + $config->set('database.connections.uri_memory', [ + 'driver' => 'sqlite', + 'database' => 'file::memory:', + ]); + $config->set('database.connections.uri_file', [ + 'driver' => 'sqlite', + 'database' => 'file:database?mode=memory&mode=rwc', + ]); + + $this->assertTrue($this->usesSqliteInMemoryDatabaseConnection('uri_memory')); + $this->assertFalse($this->usesSqliteInMemoryDatabaseConnection('uri_file')); + } + #[Test] public function itFallsBackToTheTestingConnectionWhenRuntimeSqliteIsMissing(): void { diff --git a/tests/Testing/Concerns/TestDatabasesTest.php b/tests/Testing/Concerns/TestDatabasesTest.php index 496f2ee45..6f4117b29 100644 --- a/tests/Testing/Concerns/TestDatabasesTest.php +++ b/tests/Testing/Concerns/TestDatabasesTest.php @@ -11,6 +11,7 @@ use Hypervel\Testing\Concerns\TestDatabases; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; @@ -134,6 +135,55 @@ public function testDatabaseNameDoesNotDoubleAppendToken() $this->assertSame('my_database_test_1', $this->testDatabase('my_database_test_1')); } + public function testSqliteMemoryUriIsNotManaged(): void + { + $callbackCalled = false; + + $this->whenNotUsingInMemoryDatabase( + 'sqlite', + 'file::memory:', + false, + function () use (&$callbackCalled): void { + $callbackCalled = true; + } + ); + + $this->assertFalse($callbackCalled); + } + + public function testSqliteFileUriIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'SQLite URI databases cannot be automatically managed during parallel testing. ' + . 'Configure a plain filesystem path or run with --without-databases.' + ); + + $this->whenNotUsingInMemoryDatabase( + 'sqlite', + 'file:/tmp/database.sqlite?mode=rwc', + false, + static function (): void { + } + ); + } + + public function testSqliteFileUriIsIgnoredWhenDatabaseManagementIsDisabled(): void + { + $callbackCalled = false; + + $this->whenNotUsingInMemoryDatabase( + 'sqlite', + 'file:/tmp/database.sqlite?mode=rwc', + true, + function () use (&$callbackCalled): void { + $callbackCalled = true; + } + ); + + $this->assertFalse($callbackCalled); + } + protected function switchToDatabase(string $database): void { $instance = new class { @@ -154,4 +204,32 @@ protected function testDatabase(string $database): string return $method->invoke($instance, $database); } + + protected function whenNotUsingInMemoryDatabase( + string $driver, + string $database, + bool $withoutDatabases, + callable $callback + ): void { + $db = m::mock(DatabaseManager::class); + $db->shouldReceive('getConfig')->with('database')->andReturn($database); + + if (! $withoutDatabases) { + $db->shouldReceive('getConfig')->with('driver')->andReturn($driver); + } + + Container::getInstance()->instance('db', $db); + Container::getInstance() + ->make(ParallelTesting::class) + ->resolveOptionsUsing( + fn (string $option): bool => $option === 'without_databases' && $withoutDatabases + ); + + $instance = new class { + use TestDatabases; + }; + + $method = new ReflectionMethod($instance, 'whenNotUsingInMemoryDatabase'); + $method->invoke($instance, $callback); + } } From 77b99544a7f361e0a6b7f5362d412425e59a8f22 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:09:44 +0000 Subject: [PATCH 10/26] fix(database): keep transaction state truthful Separate callback, committing event, physical commit, logical publication, manager callback, rollback, and disconnect phases so each state changes only after its owning operation succeeds. Rollback before concurrency retries, suppress retries after failed cleanup, preserve the earliest operation failure, detach lost sessions terminally, clear PDO references exhaustively, and invalidate session configuration at every physical rollback boundary. Restore query logging in finally and publish unique-violation metadata from the connection boundary. Detach transaction records before rollback callbacks, exhaust cleanup deepest first, retain commit callback ordering, and cover begin, commit, rollback, savepoint, retry, listener, manager, disconnect, pooled reuse, and failure-precedence edges with deterministic regressions. --- .../src/Concerns/ManagesTransactions.php | 205 ++++-- src/database/src/Connection.php | 73 ++- src/database/src/ConnectionInterface.php | 5 + .../src/DatabaseTransactionRecord.php | 14 +- .../src/DatabaseTransactionsManager.php | 120 +++- tests/Database/DatabaseConnectionLostTest.php | 30 + tests/Database/DatabaseConnectionTest.php | 598 +++++++++++++++++- .../DatabaseSessionConfiguratorTest.php | 21 + .../DatabaseTransactionsManagerTest.php | 185 ++++++ 9 files changed, 1144 insertions(+), 107 deletions(-) create mode 100755 tests/Database/DatabaseConnectionLostTest.php diff --git a/src/database/src/Concerns/ManagesTransactions.php b/src/database/src/Concerns/ManagesTransactions.php index ef4033302..e37dfac9a 100644 --- a/src/database/src/Concerns/ManagesTransactions.php +++ b/src/database/src/Concerns/ManagesTransactions.php @@ -36,6 +36,10 @@ public function transaction(Closure $callback, int $attempts = 1): mixed // gets actually persisted to a database or stored in a permanent fashion. try { $callbackResult = $callback($this); + + if ($this->transactions === 1) { + $this->fireConnectionEvent('committing'); + } } // If we catch an exception we'll rollback this transaction and try again if we @@ -53,30 +57,42 @@ public function transaction(Closure $callback, int $attempts = 1): mixed $levelBeingCommitted = $this->transactions; - try { - if ($this->transactions === 1) { - $this->fireConnectionEvent('committing'); + if ($this->transactions === 1) { + try { $this->performCommit(); + } catch (Throwable $e) { + $this->handleCommitTransactionException( + $e, + $currentAttempt, + $attempts + ); + + continue; } + } - $this->transactions = max(0, $this->transactions - 1); - } catch (Throwable $e) { - $this->handleCommitTransactionException( - $e, - $currentAttempt, - $attempts - ); + $this->transactions = max(0, $this->transactions - 1); + $exception = null; - continue; + try { + $this->transactionsManager?->commit( + $this->getName(), + $levelBeingCommitted, + $this->transactions + ); + } catch (Throwable $throwable) { + $exception = $throwable; } - $this->transactionsManager?->commit( - $this->getName(), - $levelBeingCommitted, - $this->transactions - ); + try { + $this->fireConnectionEvent('committed'); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } - $this->fireConnectionEvent('committed'); + if ($exception !== null) { + throw $exception; + } return $callbackResult; } @@ -101,20 +117,37 @@ protected function handleTransactionException(Throwable $e, int $currentAttempt, --$this->transactions; - $this->transactionsManager?->rollback( - $this->getName(), - $this->transactions + $exception = new DeadlockException( + $e->getMessage(), + is_int($e->getCode()) ? $e->getCode() : 0, + $e ); - throw new DeadlockException($e->getMessage(), is_int($e->getCode()) ? $e->getCode() : 0, $e); + try { + $this->transactionsManager?->rollback( + $this->getName(), + $this->transactions + ); + } catch (Throwable) { + // Preserve the transaction failure. + } + + throw $exception; } // If there was an exception we will rollback this transaction and then we // can check if we have exceeded the maximum attempt count for this and // if we haven't we will return and try this query again in our loop. - $this->rollBack(); + $cleanedUp = true; - if ($this->causedByConcurrencyError($e) + try { + $this->rollBack(); + } catch (Throwable) { + $cleanedUp = false; + } + + if ($cleanedUp + && $this->causedByConcurrencyError($e) && $currentAttempt < $maxAttempts) { return; } @@ -135,14 +168,25 @@ public function beginTransaction(): void $this->createTransaction(); + $previousLevel = $this->transactions; ++$this->transactions; - $this->transactionsManager?->begin( - $this->getName(), - $this->transactions - ); + try { + $this->transactionsManager?->begin( + $this->getName(), + $this->transactions + ); - $this->fireConnectionEvent('beganTransaction'); + $this->fireConnectionEvent('beganTransaction'); + } catch (Throwable $exception) { + try { + $this->rollBack($previousLevel); + } catch (Throwable) { + // Preserve the transaction publication failure. + } + + throw $exception; + } } /** @@ -210,13 +254,27 @@ public function commit(): void max(0, $this->transactions - 1), ]; - $this->transactionsManager?->commit( - $this->getName(), - $levelBeingCommitted, - $this->transactions - ); + $exception = null; + + try { + $this->transactionsManager?->commit( + $this->getName(), + $levelBeingCommitted, + $this->transactions + ); + } catch (Throwable $throwable) { + $exception = $throwable; + } + + try { + $this->fireConnectionEvent('committed'); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } - $this->fireConnectionEvent('committed'); + if ($exception !== null) { + throw $exception; + } } /** @@ -247,14 +305,28 @@ protected function performCommit(): void */ protected function handleCommitTransactionException(Throwable $e, int $currentAttempt, int $maxAttempts): void { - $this->transactions = max(0, $this->transactions - 1); + if ($this->causedByLostConnection($e)) { + try { + $this->terminateTransactionState(); + } catch (Throwable) { + // Preserve the physical commit failure. + } - if ($this->causedByConcurrencyError($e) && $currentAttempt < $maxAttempts) { - return; + throw $e; } - if ($this->causedByLostConnection($e)) { - $this->transactions = 0; + $cleanedUp = true; + + try { + $this->rollBack(0); + } catch (Throwable) { + $cleanedUp = false; + } + + if ($cleanedUp + && $this->causedByConcurrencyError($e) + && $currentAttempt < $maxAttempts) { + return; } throw $e; @@ -296,13 +368,26 @@ public function rollBack(?int $toLevel = null): void } $this->transactions = $toLevel; + $exception = null; - $this->transactionsManager?->rollback( - $this->getName(), - $this->transactions - ); + try { + $this->transactionsManager?->rollback( + $this->getName(), + $this->transactions + ); + } catch (Throwable $throwable) { + $exception = $throwable; + } - $this->fireConnectionEvent('rollingBack'); + try { + $this->fireConnectionEvent('rollingBack'); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + if ($exception !== null) { + throw $exception; + } } /** @@ -331,17 +416,37 @@ protected function performRollBack(int $toLevel, PDO $pdo): void protected function handleRollBackException(Throwable $e): void { if ($this->causedByLostConnection($e)) { - $this->transactions = 0; - - $this->transactionsManager?->rollback( - $this->getName(), - $this->transactions - ); + try { + $this->terminateTransactionState(); + } catch (Throwable) { + // Preserve the physical rollback failure. + } } throw $e; } + /** + * Detach transaction records and physical connection references. + */ + protected function terminateTransactionState(): void + { + $this->transactions = 0; + $exception = null; + + try { + $this->transactionsManager?->rollback($this->getName(), 0); + } catch (Throwable $throwable) { + $exception = $throwable; + } + + $this->setPdo(null)->setReadPdo(null); + + if ($exception !== null) { + throw $exception; + } + } + /** * Get the number of active transactions. */ diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 249ace5ad..55c2450d0 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -610,6 +610,11 @@ public function pretend(Closure $callback): array /** * Execute the given callback without "pretending". + * + * @template TReturn + * + * @param Closure(): TReturn $callback + * @return TReturn */ public function withoutPretending(Closure $callback): mixed { @@ -645,11 +650,11 @@ protected function withFreshQueryLog(Closure $callback): array // Now we'll execute this callback and capture the result. Once it has been // executed we will restore the value of query logging and give back the // value of the callback so the original callers can have the results. - $result = $callback(); - - $this->loggingQueries = $loggingQueries; - - return $result; + try { + return $callback(); + } finally { + $this->loggingQueries = $loggingQueries; + } } /** @@ -752,11 +757,11 @@ protected function runQueryCallback(string $query, array $bindings, Closure $cal catch (Exception $e) { ++$this->errorCount; - $exceptionType = $this->isUniqueConstraintError($e) + $exceptionType = ($isUniqueConstraintError = $this->isUniqueConstraintError($e)) ? UniqueConstraintViolationException::class : QueryException::class; - throw new $exceptionType( + $queryException = new $exceptionType( $this->getName(), $query, $this->prepareBindings($bindings), @@ -764,6 +769,14 @@ protected function runQueryCallback(string $query, array $bindings, Closure $cal $this->getConnectionDetails(), $this->latestReadWriteTypeUsed(), ); + + if ($isUniqueConstraintError && $queryException instanceof UniqueConstraintViolationException) { + ['index' => $index, 'columns' => $columns] = $this->parseUniqueConstraintViolation($e); + + $queryException->setIndex($index)->setColumns($columns); + } + + throw $queryException; } } @@ -775,6 +788,16 @@ protected function isUniqueConstraintError(Exception $exception): bool return false; } + /** + * Extract the index and columns that caused a unique constraint violation. + * + * @return array{index: null|string, columns: list} + */ + protected function parseUniqueConstraintViolation(Exception $exception): array + { + return ['index' => null, 'columns' => []]; + } + /** * Log a query in the connection's query log. */ @@ -935,27 +958,26 @@ public function reconnectIfMissingConnection(): void public function disconnect(): void { $pdo = $this->getRawPdo(); + $exception = null; try { - if ($this->transactions > 0) { - $this->transactions = 0; - - if ($pdo instanceof PDO && $pdo->inTransaction()) { - try { - $pdo->rollBack(); - } finally { - $this->invalidateSessionState($pdo); - } - } - } - } catch (Throwable $exception) { - if ($pdo instanceof PDO) { - $this->markSessionStateUnknown($pdo); + if ($pdo instanceof PDO && $pdo->inTransaction()) { + $pdo->rollBack(); + $this->invalidateSessionState($pdo); } + } catch (Throwable $throwable) { + $this->markSessionStateUnknown($pdo); + $exception = $throwable; + } + + try { + $this->terminateTransactionState(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + if ($exception !== null) { throw $exception; - } finally { - $this->setPdo(null)->setReadPdo(null); } } @@ -1751,6 +1773,11 @@ public function setTablePrefix(string $prefix): static /** * Execute the given callback without table prefix. + * + * @template TReturn + * + * @param Closure($this): TReturn $callback + * @return TReturn */ public function withoutTablePrefix(Closure $callback): mixed { diff --git a/src/database/src/ConnectionInterface.php b/src/database/src/ConnectionInterface.php index 1a442d233..31ed96188 100644 --- a/src/database/src/ConnectionInterface.php +++ b/src/database/src/ConnectionInterface.php @@ -87,6 +87,11 @@ public function prepareBindings(array $bindings): array; /** * Execute a Closure within a transaction. * + * @template TReturn + * + * @param Closure(static): TReturn $callback + * @return TReturn + * * @throws Throwable */ public function transaction(Closure $callback, int $attempts = 1): mixed; diff --git a/src/database/src/DatabaseTransactionRecord.php b/src/database/src/DatabaseTransactionRecord.php index b125600ee..ad23c5b1d 100755 --- a/src/database/src/DatabaseTransactionRecord.php +++ b/src/database/src/DatabaseTransactionRecord.php @@ -4,6 +4,8 @@ namespace Hypervel\Database; +use Throwable; + class DatabaseTransactionRecord { /** @@ -76,8 +78,18 @@ public function executeCallbacks(): void */ public function executeCallbacksForRollback(): void { + $exception = null; + foreach ($this->callbacksForRollback as $callback) { - $callback(); + try { + $callback(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; } } diff --git a/src/database/src/DatabaseTransactionsManager.php b/src/database/src/DatabaseTransactionsManager.php index 80a7231b0..4aeef2d26 100755 --- a/src/database/src/DatabaseTransactionsManager.php +++ b/src/database/src/DatabaseTransactionsManager.php @@ -6,6 +6,7 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Support\Collection; +use Throwable; /** * Manages database transaction callbacks in a coroutine-safe manner. @@ -175,26 +176,45 @@ public function rollback(string $connection, int $newTransactionLevel): void { if ($newTransactionLevel === 0) { $this->removeAllTransactionsForConnection($connection); - } else { - $pending = $this->getPendingTransactionsInternal()->reject( + + return; + } + + $this->setPendingTransactions( + $this->getPendingTransactionsInternal()->reject( fn ($transaction) => $transaction->connection === $connection && $transaction->level > $newTransactionLevel - )->values(); - $this->setPendingTransactions($pending); - - $currentForConnection = $this->getCurrentTransactionForConnection($connection); - if ($currentForConnection !== null) { - do { - $this->removeCommittedTransactionsThatAreChildrenOf($currentForConnection); - $currentForConnection->executeCallbacksForRollback(); - $currentForConnection = $currentForConnection->parent; - $this->setCurrentTransactionForConnection($connection, $currentForConnection); - } while ( - $currentForConnection !== null - && $currentForConnection->level > $newTransactionLevel - ); - } + )->values() + ); + + $transactions = new Collection; + $currentForConnection = $this->getCurrentTransactionForConnection($connection); + + while ($currentForConnection !== null + && $currentForConnection->level > $newTransactionLevel) { + $transactions->push($currentForConnection); + $transactions = $transactions->concat( + $this->removeCommittedTransactionsThatAreChildrenOf($currentForConnection) + ); + $currentForConnection = $currentForConnection->parent; } + + $this->setCurrentTransactionForConnection($connection, $currentForConnection); + + [$stagedTransactions, $remainingCommitted] = $this->getCommittedTransactionsInternal()->partition( + fn (DatabaseTransactionRecord $committed): bool => $transactions->contains( + fn (DatabaseTransactionRecord $transaction): bool => $transaction === $committed + ) + ); + $this->setCommittedTransactions($remainingCommitted->values()); + + $this->executeRollbackCallbacks( + $transactions + ->concat($stagedTransactions) + ->uniqueStrict() + ->sortByDesc(static fn (DatabaseTransactionRecord $transaction): int => $transaction->level) + ->values() + ); } /** @@ -202,12 +222,26 @@ public function rollback(string $connection, int $newTransactionLevel): void */ protected function removeAllTransactionsForConnection(string $connection): void { + [$committedForConnection, $committedForOtherConnections] = $this + ->getCommittedTransactionsInternal() + ->partition( + fn (DatabaseTransactionRecord $transaction): bool => $transaction->connection === $connection + ); + + $currentTransactions = new Collection; $currentForConnection = $this->getCurrentTransactionForConnection($connection); for ($current = $currentForConnection; $current !== null; $current = $current->parent) { - $current->executeCallbacksForRollback(); + $currentTransactions->push($current); } + $transactions = $committedForConnection + ->concat($currentTransactions) + ->uniqueStrict() + ->sortByDesc(static fn (DatabaseTransactionRecord $transaction): int => $transaction->level) + ->values(); + + // User callbacks must observe the transaction records as already detached. $this->setCurrentTransactionForConnection($connection, null); $this->setPendingTransactions( @@ -216,18 +250,19 @@ protected function removeAllTransactionsForConnection(string $connection): void )->values() ); - $this->setCommittedTransactions( - $this->getCommittedTransactionsInternal()->reject( - fn ($transaction) => $transaction->connection === $connection - )->values() - ); + $this->setCommittedTransactions($committedForOtherConnections->values()); + + $this->executeRollbackCallbacks($transactions); } /** - * Remove all transactions that are children of the given transaction. + * Remove and return all committed descendants of the given transaction. + * + * @return Collection */ - protected function removeCommittedTransactionsThatAreChildrenOf(DatabaseTransactionRecord $transaction): void - { + protected function removeCommittedTransactionsThatAreChildrenOf( + DatabaseTransactionRecord $transaction + ): Collection { $committed = $this->getCommittedTransactionsInternal(); [$removedTransactions, $remaining] = $committed->partition( @@ -237,10 +272,35 @@ protected function removeCommittedTransactionsThatAreChildrenOf(DatabaseTransact $this->setCommittedTransactions($remaining); - // Recurse down children - $removedTransactions->each( - fn ($removed) => $this->removeCommittedTransactionsThatAreChildrenOf($removed) - ); + foreach ($removedTransactions as $removedTransaction) { + $removedTransactions = $removedTransactions->concat( + $this->removeCommittedTransactionsThatAreChildrenOf($removedTransaction) + ); + } + + return $removedTransactions; + } + + /** + * Execute rollback callbacks for every detached transaction. + * + * @param Collection $transactions + */ + protected function executeRollbackCallbacks(Collection $transactions): void + { + $exception = null; + + foreach ($transactions as $transaction) { + try { + $transaction->executeCallbacksForRollback(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; + } } /** diff --git a/tests/Database/DatabaseConnectionLostTest.php b/tests/Database/DatabaseConnectionLostTest.php new file mode 100755 index 000000000..94e8a10ce --- /dev/null +++ b/tests/Database/DatabaseConnectionLostTest.php @@ -0,0 +1,30 @@ +assertTrue($detector->causedByLostConnection(new PDOException($message))); + } + + public static function shouldBeLostProvider(): array + { + return [ + 'DNS failure' => ['SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo failed: Try again'], + 'PostgreSQL connection refused' => ["SQLSTATE[08006] [7] connection to server at \"example.database.com\" (10.0.1.7), port 5432 failed: Connection refused\nIs the server running on that host and accepting TCP/IP connections? (Connection: pgsql, Host: example.database.com, Port: 5432, Database: forge, SQL: select * from \"cache\" where \"key\" in (hypervel:queue:restart))"], + 'MySQL server gone away' => ['SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'], + 'SSL/TLS alert' => ['SSL error: ssl/tls alert unexpected message'], + ]; + } +} diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index 4b49ff3d8..f6bee8bf2 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -9,6 +9,8 @@ use Exception; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Database\Connection; +use Hypervel\Database\DatabaseTransactionsManager; +use Hypervel\Database\DeadlockException; use Hypervel\Database\Events\QueryExecuted; use Hypervel\Database\Events\TransactionBeginning; use Hypervel\Database\Events\TransactionCommitted; @@ -29,6 +31,7 @@ use PDOException; use PDOStatement; use ReflectionClass; +use RuntimeException; class DatabaseConnectionTest extends TestCase { @@ -337,7 +340,7 @@ public function testBeginTransactionMethodRetriesOnFailure() public function testBeginTransactionMethodReconnectsMissingConnection() { - $connection = $this->getMockConnection(); + $connection = $this->getMockConnection([], new PDO('sqlite::memory:')); $connection->setReconnector(function ($connection) { $connection->setPdo($this->createStub(PDOStub::class)); }); @@ -376,6 +379,25 @@ public function testSwapPDOWithOpenTransactionResetsTransactionLevel() $this->assertEquals(0, $connection->transactionLevel()); } + public function testDisconnectClearsTransactionManagerStateEvenWhenTheLogicalLevelIsZero(): void + { + $connection = $this->getSqliteTransactionConnection(); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $manager->begin('default', 1); + $manager->begin('default', 2); + $manager->stageTransactions('default', 2); + + $this->assertCount(1, $manager->getCommittedTransactions()); + $this->assertCount(1, $manager->getPendingTransactions()); + + $connection->disconnect(); + + $this->assertCount(0, $manager->getCommittedTransactions()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertNull($connection->getRawPdo()); + } + public function testBeganTransactionFiresEventsIfSet() { $pdo = $this->createStub(PDOStub::class); @@ -451,16 +473,34 @@ public function testTransactionMethodRunsSuccessfully() $this->assertEquals($mock, $result); } + public function testTransactionRetriesOnCommitDeadlockAfterPhysicalRollback(): void + { + $pdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['inTransaction', 'beginTransaction', 'commit', 'rollBack'])->getMock(); + $connection = $this->getMockConnection([], $pdo); + $pdo->expects($this->exactly(2))->method('beginTransaction'); + $pdo->expects($this->exactly(2))->method('commit')->willReturnOnConsecutiveCalls( + $this->throwException(new PDOExceptionStub('Serialization failure', '40001')), + true + ); + $pdo->expects($this->once())->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('rollBack'); + + $result = $connection->transaction(static fn (): string => 'success', 2); + + $this->assertSame('success', $result); + } + public function testTransactionRetriesOnSerializationFailure() { $this->expectException(PDOException::class); $this->expectExceptionMessage('Serialization failure'); - $pdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['beginTransaction', 'commit', 'rollBack'])->getMock(); + $pdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['inTransaction', 'beginTransaction', 'commit', 'rollBack'])->getMock(); $mock = $this->getMockConnection([], $pdo); + $pdo->expects($this->exactly(3))->method('inTransaction')->willReturn(true); $pdo->expects($this->exactly(3))->method('commit')->will($this->throwException(new PDOExceptionStub('Serialization failure', '40001'))); $pdo->expects($this->exactly(3))->method('beginTransaction'); - $pdo->expects($this->never())->method('rollBack'); + $pdo->expects($this->exactly(3))->method('rollBack'); $mock->transaction(function () { }, 3); } @@ -646,6 +686,512 @@ public function testBeforeStartingTransactionHooksCanBeRegistered() $connection->beginTransaction(); } + public function testBeginPublicationFailureRollsBackAndPreservesTheOriginalFailure(): void + { + $connection = $this->getSqliteTransactionConnection(); + $publicationFailure = new RuntimeException('manager begin failure'); + $cleanupFailure = new RuntimeException('manager rollback failure'); + $manager = m::mock(DatabaseTransactionsManager::class); + $manager->shouldReceive('begin')->once()->with('default', 1)->andThrow($publicationFailure); + $manager->shouldReceive('rollback')->once()->with('default', 0)->andThrow($cleanupFailure); + $connection->setTransactionManager($manager); + + try { + $connection->beginTransaction(); + $this->fail('Expected transaction publication to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($publicationFailure, $exception); + } + + $this->assertSame(0, $connection->transactionLevel()); + $this->assertFalse($connection->getPdo()->inTransaction()); + } + + public function testBeganEventFailureRollsBackPublishedTransactionState(): void + { + $connection = $this->getSqliteTransactionConnection(); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $failure = new RuntimeException('began listener failure'); + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->once()->with(TransactionBeginning::class)->andReturn(true); + $events->shouldReceive('dispatch')->once()->with(m::type(TransactionBeginning::class))->andThrow($failure); + $events->shouldReceive('hasListeners')->once()->with(TransactionRolledBack::class)->andReturn(false); + $connection->setEventDispatcher($events); + + try { + $connection->beginTransaction(); + $this->fail('Expected the transaction event to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame(0, $connection->transactionLevel()); + $this->assertFalse($connection->getPdo()->inTransaction()); + $this->assertCount(0, $manager->getPendingTransactions()); + } + + public function testCommittingEventFailureRollsBackManagedTransaction(): void + { + $connection = $this->getSqliteTransactionConnection(); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $failure = new RuntimeException('committing listener failure'); + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->once()->with(TransactionBeginning::class)->andReturn(false); + $events->shouldReceive('hasListeners')->once()->with(TransactionCommitting::class)->andReturn(true); + $events->shouldReceive('dispatch')->once()->with(m::type(TransactionCommitting::class))->andThrow($failure); + $events->shouldReceive('hasListeners')->once()->with(TransactionRolledBack::class)->andReturn(false); + $connection->setEventDispatcher($events); + + try { + $connection->transaction(static fn (): null => null); + $this->fail('Expected the committing event to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame(0, $connection->transactionLevel()); + $this->assertFalse($connection->getPdo()->inTransaction()); + $this->assertCount(0, $manager->getPendingTransactions()); + } + + public function testManagedTransactionFailureRemainsPrimaryWhenRollbackCleanupFails(): void + { + $connection = $this->getSqliteTransactionConnection(); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $failure = new RuntimeException('transaction callback failure'); + $cleanupFailure = new RuntimeException('rollback callback failure'); + $cleanupCalls = 0; + + try { + $connection->transaction(function (Connection $connection) use ( + $failure, + $cleanupFailure, + &$cleanupCalls + ): never { + $connection->afterRollBack(function () use ($cleanupFailure, &$cleanupCalls): never { + ++$cleanupCalls; + + throw $cleanupFailure; + }); + + throw $failure; + }); + $this->fail('Expected the transaction callback to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame(1, $cleanupCalls); + $this->assertSame(0, $connection->transactionLevel()); + $this->assertFalse($connection->getPdo()->inTransaction()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertCount(0, $manager->getCommittedTransactions()); + } + + public function testManagedTransactionDoesNotRetryWhenRollbackCleanupFails(): void + { + $connection = $this->getSqliteTransactionConnection(); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $failure = new QueryException( + 'default', + '', + [], + new RuntimeException('Deadlock found when trying to get lock') + ); + $cleanupFailure = new RuntimeException('rollback callback failure'); + $callbackCalls = 0; + + try { + $connection->transaction(function (Connection $connection) use ( + $failure, + $cleanupFailure, + &$callbackCalls + ): never { + ++$callbackCalls; + + $connection->afterRollBack(static function () use ($cleanupFailure): never { + throw $cleanupFailure; + }); + + throw $failure; + }, 2); + $this->fail('Expected the transaction callback to fail.'); + } catch (QueryException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame(1, $callbackCalls); + $this->assertSame(0, $connection->transactionLevel()); + $this->assertFalse($connection->getPdo()->inTransaction()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertCount(0, $manager->getCommittedTransactions()); + } + + public function testNestedDeadlockFailureRemainsPrimaryWhenRollbackCleanupFails(): void + { + $connection = $this->getSqliteTransactionConnection(); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $connection->beginTransaction(); + $failure = new QueryException( + 'default', + '', + [], + new RuntimeException('Deadlock found when trying to get lock') + ); + $cleanupFailure = new RuntimeException('rollback callback failure'); + $cleanupCalls = 0; + + try { + $connection->transaction(function (Connection $connection) use ( + $failure, + $cleanupFailure, + &$cleanupCalls + ): never { + $connection->afterRollBack(function () use ($cleanupFailure, &$cleanupCalls): never { + ++$cleanupCalls; + + throw $cleanupFailure; + }); + + throw $failure; + }); + $this->fail('Expected the nested transaction to deadlock.'); + } catch (DeadlockException $exception) { + $this->assertSame($failure, $exception->getPrevious()); + } + + $this->assertSame(1, $cleanupCalls); + $this->assertSame(1, $connection->transactionLevel()); + $this->assertTrue($connection->getPdo()->inTransaction()); + $this->assertCount(1, $manager->getPendingTransactions()); + $this->assertCount(0, $manager->getCommittedTransactions()); + + $connection->rollBack(); + } + + public function testExplicitCommittingEventFailureLeavesTheTransactionCallerOwned(): void + { + $connection = $this->getSqliteTransactionConnection(); + $connection->beginTransaction(); + $failure = new RuntimeException('committing listener failure'); + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->once()->with(TransactionCommitting::class)->andReturn(true); + $events->shouldReceive('dispatch')->once()->with(m::type(TransactionCommitting::class))->andThrow($failure); + $connection->setEventDispatcher($events); + + try { + $connection->commit(); + $this->fail('Expected the committing event to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame(1, $connection->transactionLevel()); + $this->assertTrue($connection->getPdo()->inTransaction()); + + $connection->unsetEventDispatcher(); + $connection->rollBack(); + } + + public function testManagerCommitFailureStillDispatchesCommittedEventAfterPhysicalCommit(): void + { + $connection = $this->getSqliteTransactionConnection(); + $failure = new RuntimeException('after commit callback failure'); + $manager = m::mock(DatabaseTransactionsManager::class); + $manager->shouldReceive('begin')->once()->with('default', 1); + $manager->shouldReceive('commit')->once()->with('default', 1, 0)->andThrow($failure); + $connection->setTransactionManager($manager); + $connection->beginTransaction(); + + $eventDispatched = false; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->once()->with(TransactionCommitting::class)->andReturn(false); + $events->shouldReceive('hasListeners')->once()->with(TransactionCommitted::class)->andReturn(true); + $events->shouldReceive('dispatch') + ->once() + ->with(m::type(TransactionCommitted::class)) + ->andReturnUsing(function () use (&$eventDispatched): void { + $eventDispatched = true; + }); + $connection->setEventDispatcher($events); + + try { + $connection->commit(); + $this->fail('Expected transaction-manager commit to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertTrue($eventDispatched); + $this->assertSame(0, $connection->transactionLevel()); + $this->assertFalse($connection->getPdo()->inTransaction()); + } + + public function testCommitFailureDoesNotRetryWhenRollbackCleanupFails(): void + { + $commitFailure = new PDOExceptionStub('Serialization failure', '40001'); + $cleanupFailure = new RuntimeException('rollback callback failure'); + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['beginTransaction', 'commit', 'inTransaction', 'rollBack']) + ->getMock(); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->once())->method('commit')->willThrowException($commitFailure); + $pdo->expects($this->once())->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('rollBack'); + + $connection = $this->getMockConnection([], $pdo); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $cleanupCalls = 0; + + try { + $connection->transaction(function (Connection $connection) use (&$cleanupCalls, $cleanupFailure): void { + $connection->afterRollBack(function () use (&$cleanupCalls, $cleanupFailure): never { + ++$cleanupCalls; + + throw $cleanupFailure; + }); + }, 2); + $this->fail('Expected the physical commit to fail.'); + } catch (PDOException $exception) { + $this->assertSame($commitFailure, $exception); + } + + $this->assertSame(1, $cleanupCalls); + $this->assertSame(0, $connection->transactionLevel()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertCount(0, $manager->getCommittedTransactions()); + } + + public function testExplicitPhysicalCommitFailureLeavesTheTransactionCallerOwned(): void + { + $failure = new RuntimeException('commit failure'); + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['beginTransaction', 'commit', 'inTransaction', 'rollBack']) + ->getMock(); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->once())->method('commit')->willThrowException($failure); + $pdo->expects($this->once())->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('rollBack'); + + $connection = $this->getMockConnection([], $pdo); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $connection->beginTransaction(); + + try { + $connection->commit(); + $this->fail('Expected the physical commit to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame(1, $connection->transactionLevel()); + $this->assertCount(1, $manager->getPendingTransactions()); + $this->assertSame($pdo, $connection->getRawPdo()); + + $connection->rollBack(); + } + + public function testLostManagedCommitTerminallyDetachesTransactionState(): void + { + $failure = new PDOException('server has gone away'); + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['beginTransaction', 'commit', 'inTransaction', 'rollBack']) + ->getMock(); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->once())->method('commit')->willThrowException($failure); + $pdo->expects($this->never())->method('inTransaction'); + $pdo->expects($this->never())->method('rollBack'); + + $connection = $this->getMockConnection([], $pdo); + $connection->setReadPdo(new PDOStub); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $rollbackCallbackCalled = false; + + try { + $connection->transaction(function (Connection $connection) use (&$rollbackCallbackCalled): void { + $connection->afterRollBack(function () use (&$rollbackCallbackCalled): void { + $rollbackCallbackCalled = true; + }); + }); + $this->fail('Expected the lost commit to fail.'); + } catch (PDOException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertTrue($rollbackCallbackCalled); + $this->assertSame(0, $connection->transactionLevel()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertCount(0, $manager->getCommittedTransactions()); + $this->assertNull($connection->getRawPdo()); + $this->assertNull($connection->getRawReadPdo()); + } + + public function testNonLostPhysicalRollbackFailureKeepsActiveStateAndMarksTheSessionUnknown(): void + { + Connection::configureSessionUsing(new StatementPathSessionConfigurator); + + $failure = new RuntimeException('rollback failure'); + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['beginTransaction', 'inTransaction', 'rollBack']) + ->getMock(); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->once())->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('rollBack')->willThrowException($failure); + + $connection = $this->getMockConnection([], $pdo); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $connection->beginTransaction(); + + try { + $connection->rollBack(); + $this->fail('Expected the physical rollback to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame(1, $connection->transactionLevel()); + $this->assertCount(1, $manager->getPendingTransactions()); + $this->assertTrue($connection->hasUnknownSessionState()); + $this->assertSame($pdo, $connection->getRawPdo()); + } + + public function testLostPhysicalRollbackTerminallyDetachesTransactionState(): void + { + $failure = new PDOException('server has gone away'); + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['beginTransaction', 'inTransaction', 'rollBack']) + ->getMock(); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->once())->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('rollBack')->willThrowException($failure); + + $connection = $this->getMockConnection([], $pdo); + $connection->setReadPdo(new PDOStub); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $connection->beginTransaction(); + $rollbackCallbackCalled = false; + $connection->afterRollBack(function () use (&$rollbackCallbackCalled): void { + $rollbackCallbackCalled = true; + }); + + try { + $connection->rollBack(); + $this->fail('Expected the lost rollback to fail.'); + } catch (PDOException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertTrue($rollbackCallbackCalled); + $this->assertSame(0, $connection->transactionLevel()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertNull($connection->getRawPdo()); + $this->assertNull($connection->getRawReadPdo()); + } + + public function testManagerRollbackFailureStillDispatchesRolledBackEvent(): void + { + $connection = $this->getSqliteTransactionConnection(); + $failure = new RuntimeException('manager rollback failure'); + $manager = m::mock(DatabaseTransactionsManager::class); + $manager->shouldReceive('begin')->once()->with('default', 1); + $manager->shouldReceive('rollback')->once()->with('default', 0)->andThrow($failure); + $connection->setTransactionManager($manager); + $connection->beginTransaction(); + + $eventDispatched = false; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->once()->with(TransactionRolledBack::class)->andReturn(true); + $events->shouldReceive('dispatch') + ->once() + ->with(m::type(TransactionRolledBack::class)) + ->andReturnUsing(function () use (&$eventDispatched): void { + $eventDispatched = true; + }); + $connection->setEventDispatcher($events); + + try { + $connection->rollBack(); + $this->fail('Expected transaction-manager rollback to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertTrue($eventDispatched); + $this->assertSame(0, $connection->transactionLevel()); + $this->assertFalse($connection->getPdo()->inTransaction()); + } + + public function testRolledBackEventFailureOccursAfterManagerCleanup(): void + { + $connection = $this->getSqliteTransactionConnection(); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $connection->beginTransaction(); + $failure = new RuntimeException('rolled back listener failure'); + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->once()->with(TransactionRolledBack::class)->andReturn(true); + $events->shouldReceive('dispatch')->once()->with(m::type(TransactionRolledBack::class))->andThrow($failure); + $connection->setEventDispatcher($events); + + try { + $connection->rollBack(); + $this->fail('Expected the rolled back event to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame(0, $connection->transactionLevel()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertFalse($connection->getPdo()->inTransaction()); + } + + public function testDisconnectExhaustsCleanupAndPreservesThePhysicalFailure(): void + { + $physicalFailure = new RuntimeException('physical rollback failure'); + $callbackFailure = new RuntimeException('rollback callback failure'); + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['beginTransaction', 'inTransaction', 'rollBack']) + ->getMock(); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->once())->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('rollBack')->willThrowException($physicalFailure); + + $connection = $this->getMockConnection([], $pdo); + $connection->setReadPdo(new PDOStub); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $connection->beginTransaction(); + $rollbackCallbackCalled = false; + $connection->afterRollBack(function () use (&$rollbackCallbackCalled, $callbackFailure): never { + $rollbackCallbackCalled = true; + + throw $callbackFailure; + }); + + try { + $connection->disconnect(); + $this->fail('Expected disconnect cleanup to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($physicalFailure, $exception); + } + + $this->assertTrue($rollbackCallbackCalled); + $this->assertSame(0, $connection->transactionLevel()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertNull($connection->getRawPdo()); + $this->assertNull($connection->getRawReadPdo()); + } + public function testPretendOnlyLogsQueries() { $connection = $this->getMockConnection(); @@ -659,6 +1205,42 @@ public function testPretendOnlyLogsQueries() $this->assertEquals(['baz'], $queries[0]['bindings']); } + public function testPretendRestoresThePreviousQueryLoggingState(): void + { + $connection = $this->getMockConnection(); + $connection->disableQueryLog(); + + $connection->pretend(static function (): void { + }); + + $this->assertFalse($connection->logging()); + + $connection->enableQueryLog(); + $connection->pretend(static function (): void { + }); + + $this->assertTrue($connection->logging()); + } + + public function testPretendRestoresQueryLoggingWhenTheCallbackFails(): void + { + $connection = $this->getMockConnection(); + $connection->disableQueryLog(); + $failure = new RuntimeException('pretend failure'); + + try { + $connection->pretend(static function () use ($failure): never { + throw $failure; + }); + $this->fail('Expected the pretend callback to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertFalse($connection->logging()); + $this->assertFalse($connection->pretending()); + } + public function testSchemaBuilderCanBeCreated() { $connection = $this->getMockConnection(); @@ -974,6 +1556,16 @@ public function testQueryExceptionContainsWriteConnectionDetailsWhenWritePdoConn } } + protected function getSqliteTransactionConnection(): Connection + { + return new Connection( + new PDO('sqlite::memory:'), + ':memory:', + '', + ['name' => 'default', 'driver' => 'sqlite'] + ); + } + /** * Create a read / write connection for sticky routing assertions. * diff --git a/tests/Database/DatabaseSessionConfiguratorTest.php b/tests/Database/DatabaseSessionConfiguratorTest.php index 3471578e7..a9410d5b8 100644 --- a/tests/Database/DatabaseSessionConfiguratorTest.php +++ b/tests/Database/DatabaseSessionConfiguratorTest.php @@ -688,6 +688,7 @@ public function testConcurrencyCommitFailureInvalidatesWithoutTaintingBeforeRetr $this->assertSame(2, $pdo->beginCalls); $this->assertSame(2, $pdo->commitCalls); + $this->assertSame(1, $pdo->rollbackCalls); $this->assertSame(2, $configurator->applyCalls); $this->assertFalse(TestSessionConnection::sessionStateIsUnknownForTest($pdo)); } @@ -970,6 +971,10 @@ class CommitRetryPdo extends PDO public int $commitCalls = 0; + public int $rollbackCalls = 0; + + private bool $transactionActive = false; + public function __construct() { } @@ -977,6 +982,7 @@ public function __construct() public function beginTransaction(): bool { ++$this->beginCalls; + $this->transactionActive = true; return true; } @@ -989,6 +995,21 @@ public function commit(): bool throw new StringCodePdoException('Serialization failure', '40001'); } + $this->transactionActive = false; + + return true; + } + + public function inTransaction(): bool + { + return $this->transactionActive; + } + + public function rollBack(): bool + { + ++$this->rollbackCalls; + $this->transactionActive = false; + return true; } } diff --git a/tests/Database/DatabaseTransactionsManagerTest.php b/tests/Database/DatabaseTransactionsManagerTest.php index 141acda10..e91f28b06 100755 --- a/tests/Database/DatabaseTransactionsManagerTest.php +++ b/tests/Database/DatabaseTransactionsManagerTest.php @@ -4,8 +4,10 @@ namespace Hypervel\Tests\Database; +use Hypervel\Database\DatabaseTransactionRecord; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Tests\TestCase; +use RuntimeException; class DatabaseTransactionsManagerTest extends TestCase { @@ -281,6 +283,181 @@ public function testRollbackExecutesOnlyCallbacksOfTheConnection() $this->assertEquals(['default', 1], $callbacks[0]); } + public function testPartialRollbackDetachesOnlyTheCurrentBranchAndItsCommittedDescendants(): void + { + $manager = new InspectableDatabaseTransactionsManager; + $callbacks = []; + + $manager->begin('default', 1); + $manager->addCallbackForRollback(function () use (&$callbacks): void { + $callbacks[] = 'outer'; + }); + + $manager->begin('default', 2); + $manager->addCallbackForRollback(function () use (&$callbacks): void { + $callbacks[] = 'committed sibling'; + }); + $manager->commit('default', 2, 1); + + $manager->begin('default', 2); + $manager->addCallbackForRollback(function () use (&$callbacks): void { + $callbacks[] = 'current'; + }); + $manager->begin('default', 3); + $manager->addCallbackForRollback(function () use (&$callbacks): void { + $callbacks[] = 'committed descendant'; + }); + $manager->commit('default', 3, 2); + + $manager->rollback('default', 1); + + $this->assertSame(['committed descendant', 'current'], $callbacks); + $this->assertSame(1, $manager->currentTransaction('default')?->level); + $this->assertSame([1], $manager->getPendingTransactions()->pluck('level')->all()); + $this->assertSame([2], $manager->getCommittedTransactions()->pluck('level')->all()); + + $manager->rollback('default', 0); + + $this->assertSame( + ['committed descendant', 'current', 'committed sibling', 'outer'], + $callbacks + ); + } + + public function testFullRollbackDetachesStateBeforeCallbacksCanReenter(): void + { + $manager = new InspectableDatabaseTransactionsManager; + $manager->begin('default', 1); + $manager->addCallbackForRollback(function () use ($manager): void { + $this->assertNull($manager->currentTransaction('default')); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertCount(0, $manager->getCommittedTransactions()); + + $manager->begin('default', 1); + }); + + $manager->rollback('default', 0); + + $this->assertSame(1, $manager->currentTransaction('default')?->level); + $this->assertCount(1, $manager->getPendingTransactions()); + } + + public function testFullRollbackExecutesEachStagedCurrentRecordOnce(): void + { + $manager = new DatabaseTransactionsManager; + $callbacks = 0; + $manager->begin('default', 1); + $manager->addCallbackForRollback(function () use (&$callbacks): void { + ++$callbacks; + }); + $manager->stageTransactions('default', 1); + + $manager->rollback('default', 0); + + $this->assertSame(1, $callbacks); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertCount(0, $manager->getCommittedTransactions()); + } + + public function testRollbackCallbacksExhaustDeepestFirstAndPreserveTheEarliestFailure(): void + { + $manager = new DatabaseTransactionsManager; + $callbacks = []; + $earliest = new RuntimeException('deepest failure'); + $later = new RuntimeException('outer failure'); + + $manager->begin('default', 1); + $manager->addCallbackForRollback(function () use (&$callbacks, $later): void { + $callbacks[] = 'outer first'; + throw $later; + }); + $manager->addCallbackForRollback(function () use (&$callbacks): void { + $callbacks[] = 'outer second'; + }); + + $manager->begin('default', 2); + $manager->addCallbackForRollback(function () use (&$callbacks, $earliest): void { + $callbacks[] = 'deepest first'; + throw $earliest; + }); + $manager->addCallbackForRollback(function () use (&$callbacks): void { + $callbacks[] = 'deepest second'; + }); + $manager->commit('default', 2, 1); + + try { + $manager->rollback('default', 0); + $this->fail('Expected rollback callbacks to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($earliest, $exception); + } + + $this->assertSame([ + 'deepest first', + 'deepest second', + 'outer first', + 'outer second', + ], $callbacks); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertCount(0, $manager->getCommittedTransactions()); + } + + public function testRollbackExecutesCallbacksInDeepestFirstOrderAcrossCommittedBranches(): void + { + $manager = new DatabaseTransactionsManager; + $callbacks = []; + + $manager->begin('default', 1); + $manager->begin('default', 2); + $manager->addCallbackForRollback(function () use (&$callbacks): void { + $callbacks[] = ['first', 2]; + }); + $manager->commit('default', 2, 1); + + $manager->begin('default', 2); + $manager->addCallbackForRollback(function () use (&$callbacks): void { + $callbacks[] = ['second', 2]; + }); + $manager->begin('default', 3); + $manager->addCallbackForRollback(function () use (&$callbacks): void { + $callbacks[] = ['second', 3]; + }); + $manager->commit('default', 3, 2); + $manager->commit('default', 2, 1); + + $manager->rollback('default', 0); + + $this->assertSame([ + ['second', 3], + ['first', 2], + ['second', 2], + ], $callbacks); + } + + public function testCommitCallbacksRemainStopOnFirst(): void + { + $manager = new DatabaseTransactionsManager; + $callbacks = []; + $failure = new RuntimeException('commit callback failure'); + $manager->begin('default', 1); + $manager->addCallback(function () use (&$callbacks, $failure): void { + $callbacks[] = 'first'; + throw $failure; + }); + $manager->addCallback(function () use (&$callbacks): void { + $callbacks[] = 'second'; + }); + + try { + $manager->commit('default', 1, 0); + $this->fail('Expected the commit callback to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame(['first'], $callbacks); + } + public function testCallbackForRollbackIsNotExecutedIfNoTransactions() { $callbacks = []; @@ -336,3 +513,11 @@ public function testStageTransactionsOnlyStagesTheTransactionsAtOrAboveTheGivenL $this->assertCount(2, $manager->getCommittedTransactions()); } } + +class InspectableDatabaseTransactionsManager extends DatabaseTransactionsManager +{ + public function currentTransaction(string $connection): ?DatabaseTransactionRecord + { + return $this->getCurrentTransactionForConnection($connection); + } +} From 1e970542b6a804c28ecdc555e097dae4af1cd62a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:09:58 +0000 Subject: [PATCH 11/26] feat(database): complete current query builder parity Port the supported current Laravel query surface: MySQL straight joins and timeouts, null-safe equality, explicit value ordering, conflict-safe returning inserts, DatePeriod bounds, escaped column operators, PostgreSQL date and full-text behavior, delimited aggregates, and builder-backed update subqueries. Keep unsupported grammars explicit, preserve binding order and modified-record state, and use indexed placeholder substitution so raw SQL rendering is linear. Render live and closed resources through their stable identity string without consuming stream contents. Merge current builder, grammar, relationship, and Telescope coverage for call shapes, SQL dialects, bindings, validation, expressions, unions, conflicts, and resource handling. --- .../Concerns/QueriesRelationships.php | 9 +- src/database/src/Query/Builder.php | 189 ++++++++- src/database/src/Query/Grammars/Grammar.php | 55 ++- .../src/Query/Grammars/MySqlGrammar.php | 29 ++ .../src/Query/Grammars/PostgresGrammar.php | 26 +- .../src/Query/Grammars/SQLiteGrammar.php | 21 + .../Database/DatabaseEloquentMorphToTest.php | 41 ++ .../DatabaseMySqlQueryGrammarTest.php | 65 +++ tests/Database/DatabaseQueryBuilderTest.php | 390 +++++++++++++++++- tests/Database/DatabaseQueryGrammarTest.php | 65 +++ tests/Telescope/Watchers/QueryWatcherTest.php | 2 +- 11 files changed, 854 insertions(+), 38 deletions(-) diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index 5264a2ca6..83c1bc57e 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -615,8 +615,11 @@ public function whereNotMorphedTo(MorphTo|string $relation, mixed $model, string $model = array_search($model, $morphMap, true); } - // @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation) - return $this->whereNot($relation->qualifyColumn($relation->getMorphType()), '<=>', $model, $boolean); + return $this->whereNot(fn ($query) => $query->whereNullSafeEquals( + // @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation) + $relation->qualifyColumn($relation->getMorphType()), + $model + ), null, null, $boolean); } $models = BaseCollection::wrap($model); @@ -629,7 +632,7 @@ public function whereNotMorphedTo(MorphTo|string $relation, mixed $model, string $models->groupBy(fn ($model) => $model->getMorphClass())->each(function ($models) use ($query, $relation) { $query->orWhere(function ($query) use ($relation, $models) { // @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation) - $query->where($relation->qualifyColumn($relation->getMorphType()), '<=>', $models->first()->getMorphClass()) + $query->whereNullSafeEquals($relation->qualifyColumn($relation->getMorphType()), $models->first()->getMorphClass()) // @phpstan-ignore method.notFound (getForeignKeyName exists on MorphTo, not base Relation) ->whereIn($relation->qualifyColumn($relation->getForeignKeyName()), $models->map->getKey()); }); diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index ff74df989..8a9e72035 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -6,8 +6,8 @@ use BackedEnum; use BadMethodCallException; -use Carbon\CarbonPeriod; use Closure; +use DatePeriod; use DateTimeInterface; use Hypervel\Contracts\Database\Query\Builder as BuilderContract; use Hypervel\Contracts\Database\Query\ConditionExpression; @@ -191,6 +191,11 @@ class Builder implements BuilderContract */ public string|bool|null $lock = null; + /** + * The query execution timeout in seconds. + */ + public ?int $timeout = null; + /** * The callbacks that should be invoked before the query is executed. */ @@ -687,6 +692,32 @@ public function crossJoinSub(Closure|self|EloquentBuilder|string $query, string return $this; } + /** + * Add a straight join to the query. + */ + public function straightJoin(ExpressionContract|string $table, Closure|string $first, ?string $operator = null, ExpressionContract|string|null $second = null): static + { + return $this->join($table, $first, $operator, $second, 'straight_join'); + } + + /** + * Add a straight join where clause to the query. + */ + public function straightJoinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, ExpressionContract|string $second): static + { + return $this->joinWhere($table, $first, $operator, $second, 'straight_join'); + } + + /** + * Add a subquery straight join to the query. + * + * @param Closure|self|EloquentBuilder<*>|string $query + */ + public function straightJoinSub(Closure|self|EloquentBuilder|string $query, string $as, Closure|ExpressionContract|string $first, ?string $operator = null, ExpressionContract|string|null $second = null): static + { + return $this->joinSub($query, $as, $first, $operator, $second, 'straight_join'); + } + /** * Get a new "join" clause. */ @@ -1085,6 +1116,30 @@ public function orWhereNotLike(ExpressionContract|string $column, string $value, return $this->whereNotLike($column, $value, $caseSensitive, 'or'); } + /** + * Add a null-safe equality clause to the query. + */ + public function whereNullSafeEquals(ExpressionContract|string $column, mixed $value, string $boolean = 'and'): static + { + $type = 'NullSafeEquals'; + + $this->wheres[] = compact('type', 'column', 'value', 'boolean'); + + if (! $value instanceof ExpressionContract) { + $this->addBinding($this->flattenValue($value), 'where'); + } + + return $this; + } + + /** + * Add an "or" null-safe equality clause to the query. + */ + public function orWhereNullSafeEquals(ExpressionContract|string $column, mixed $value): static + { + return $this->whereNullSafeEquals($column, $value, 'or'); + } + /** * Add a "where in" clause to the query. */ @@ -1240,8 +1295,8 @@ public function whereBetween(self|EloquentBuilder|ExpressionContract|string $col ->whereBetween(new Expression('(' . $sub . ')'), $values, $boolean, $not); } - if ($values instanceof CarbonPeriod) { // @phpstan-ignore instanceof.alwaysFalse (Carbon v3 lazy-loaded class hierarchy not statically resolvable) - $values = [$values->getStartDate(), $values->getEndDate()]; + if ($values instanceof DatePeriod) { + $values = $this->resolveDatePeriodBounds($values); } $this->wheres[] = compact('type', 'column', 'values', 'boolean', 'not'); @@ -2260,8 +2315,8 @@ public function havingBetween(string $column, iterable $values, string $boolean { $type = 'between'; - if ($values instanceof CarbonPeriod) { // @phpstan-ignore instanceof.alwaysFalse (Carbon v3 lazy-loaded class hierarchy not statically resolvable) - $values = [$values->getStartDate(), $values->getEndDate()]; + if ($values instanceof DatePeriod) { + $values = $this->resolveDatePeriodBounds($values); } $this->havings[] = compact('type', 'column', 'values', 'boolean', 'not'); @@ -2295,6 +2350,27 @@ public function orHavingNotBetween(string $column, iterable $values): static return $this->havingBetween($column, $values, 'or', true); } + /** + * Resolve the start and end dates from a DatePeriod. + * + * @return array{DateTimeInterface, DateTimeInterface} + */ + protected function resolveDatePeriodBounds(DatePeriod $period): array + { + [$start, $end] = [$period->getStartDate(), $period->getEndDate()]; + + if ($end === null) { + $end = clone $start; + $recurrences = $period->getRecurrences(); + + for ($i = 0; $i < $recurrences; ++$i) { + $end = $end->add($period->getDateInterval()); + } + } + + return [$start, $end]; + } + /** * Add a raw "having" clause to the query. */ @@ -2418,6 +2494,37 @@ public function inRandomOrder(string|int $seed = ''): static return $this->orderByRaw($this->grammar->compileRandom($seed)); } + /** + * Add an order clause for a given sequence of values. + */ + public function inOrderOf(ExpressionContract|string $column, Arrayable|array $values): static + { + if ($values instanceof Arrayable) { + $values = $values->toArray(); + } + + $values = array_values($values); + + if ($values === []) { + return $this; + } + + $hasUnions = $this->unions !== null && $this->unions !== []; + + $this->{$hasUnions ? 'unionOrders' : 'orders'}[] = [ + 'type' => 'InOrderOf', + 'column' => $column, + 'values' => $values, + ]; + + $this->addBinding( + $this->cleanBindings($values), + $hasUnions ? 'unionOrder' : 'order' + ); + + return $this; + } + /** * Add a raw "order by" clause to the query. */ @@ -2621,6 +2728,22 @@ public function sharedLock(): static return $this->lock(false); } + /** + * Set a query execution timeout in seconds. + * + * @throws InvalidArgumentException + */ + public function timeout(?int $seconds): static + { + if ($seconds !== null && $seconds <= 0) { + throw new InvalidArgumentException('Timeout must be greater than zero.'); + } + + $this->timeout = $seconds; + + return $this; + } + /** * Register a closure to be invoked before the query is executed. */ @@ -3374,6 +3497,58 @@ public function insertOrIgnore(array $values): int ); } + /** + * Insert records while ignoring conflicts and return the inserted rows. + * + * @param non-empty-array $returning + * @param null|non-empty-array|non-empty-string $uniqueBy + * @return Collection + */ + public function insertOrIgnoreReturning(array $values, array $returning = ['*'], array|string|null $uniqueBy = null): Collection + { + if ($values === []) { + return new Collection; + } + + if ($uniqueBy === [] || $uniqueBy === '') { + throw new InvalidArgumentException('The unique columns must not be empty.'); + } + + if ($returning === []) { + throw new InvalidArgumentException('The returning columns must not be empty.'); + } + + if (! is_array(array_first($values))) { + $values = [$values]; + } else { + foreach ($values as $key => $value) { + ksort($value); + + $values[$key] = $value; + } + } + + $this->applyBeforeQueryCallbacks(); + + $sql = $this->grammar->compileInsertOrIgnoreReturning( + $this, + $values, + $returning, + $uniqueBy === null ? null : Arr::wrap($uniqueBy) + ); + + $result = new Collection( + $this->connection->selectFromWriteConnection( + $sql, + $this->cleanBindings(Arr::flatten($values, 1)) + ) + ); + + $this->connection->recordsHaveBeenModified($result->isNotEmpty()); + + return $result; + } + /** * Insert a new record and get the value of the primary key. */ @@ -3432,7 +3607,9 @@ public function update(array $values): int $this->applyBeforeQueryCallbacks(); $values = (new Collection($values))->map(function ($value) { - if (! $value instanceof Builder) { + if (! $value instanceof self + && ! $value instanceof EloquentBuilder + && ! $value instanceof Relation) { return ['value' => $value, 'bindings' => match (true) { $value instanceof Collection => $value->all(), $value instanceof UnitEnum => enum_value($value), diff --git a/src/database/src/Query/Grammars/Grammar.php b/src/database/src/Query/Grammars/Grammar.php index 1d63de213..cc9018af6 100755 --- a/src/database/src/Query/Grammars/Grammar.php +++ b/src/database/src/Query/Grammars/Grammar.php @@ -135,7 +135,7 @@ protected function compileAggregate(Builder $query, array $aggregate): string $column = 'distinct ' . $column; } - return 'select ' . $aggregate['function'] . '(' . $column . ') as aggregate'; + return 'select ' . $aggregate['function'] . '(' . $column . ') as ' . $this->wrap('aggregate'); } /** @@ -357,6 +357,14 @@ protected function whereNotNull(Builder $query, array $where): string return $this->wrap($where['column']) . ' is not null'; } + /** + * Compile a "where null safe equals" clause. + */ + protected function whereNullSafeEquals(Builder $query, array $where): string + { + return $this->wrap($where['column']) . ' is not distinct from ' . $this->parameter($where['value']); + } + /** * Compile a "between" where clause. */ @@ -454,7 +462,9 @@ protected function dateBasedWhere(string $type, Builder $query, array $where): s */ protected function whereColumn(Builder $query, array $where): string { - return $this->wrap($where['first']) . ' ' . $where['operator'] . ' ' . $this->wrap($where['second']); + $operator = str_replace('?', '??', $where['operator']); + + return $this->wrap($where['first']) . ' ' . $operator . ' ' . $this->wrap($where['second']); } /** @@ -780,10 +790,30 @@ protected function compileOrdersToArray(Builder $query, array $orders): array return $order['sql']->getValue($query->getGrammar()); } + if (isset($order['type']) && $order['type'] === 'InOrderOf') { + return $this->compileInOrderOf($order); + } + return $order['sql'] ?? $this->wrap($order['column']) . ' ' . $order['direction']; }, $orders); } + /** + * Compile an "in order of" clause. + */ + protected function compileInOrderOf(array $order): string + { + $column = $this->wrap($order['column']); + + $cases = []; + + foreach (array_values($order['values']) as $index => $value) { + $cases[] = 'when ' . $column . ' = ' . $this->parameter($value) . ' then ' . $index; + } + + return 'case ' . implode(' ', $cases) . ' else ' . count($order['values']) . ' end'; + } + /** * Compile the random statement into SQL. */ @@ -967,6 +997,16 @@ public function compileInsertOrIgnore(Builder $query, array $values): string throw new RuntimeException('This database engine does not support inserting while ignoring errors.'); } + /** + * Compile an insert or ignore statement with a returning clause into SQL. + * + * @throws RuntimeException + */ + public function compileInsertOrIgnoreReturning(Builder $query, array $values, array $returning, ?array $uniqueBy): string + { + throw new RuntimeException('This database engine does not support insert or ignore with returning.'); + } + /** * Compile an insert and get ID statement into SQL. */ @@ -1202,11 +1242,18 @@ protected function removeLeadingBoolean(string $value): string */ public function substituteBindingsIntoRawSql(string $sql, array $bindings): string { - $bindings = array_map(fn ($value) => $this->escape($value, is_resource($value) || gettype($value) === 'resource (closed)'), $bindings); + $bindings = array_map(function ($value): string { + if (is_resource($value) || gettype($value) === 'resource (closed)') { + $value = (string) $value; + } + + return $this->escape($value); + }, $bindings); $query = ''; $isStringLiteral = false; + $bindingIndex = 0; for ($i = 0; $i < strlen($sql); ++$i) { $char = $sql[$i]; @@ -1222,7 +1269,7 @@ public function substituteBindingsIntoRawSql(string $sql, array $bindings): stri $query .= $char; $isStringLiteral = ! $isStringLiteral; } elseif ($char === '?' && ! $isStringLiteral) { // Substitutable binding... - $query .= array_shift($bindings) ?? '?'; + $query .= $bindings[$bindingIndex++] ?? '?'; } else { // Normal character... $query .= $char; } diff --git a/src/database/src/Query/Grammars/MySqlGrammar.php b/src/database/src/Query/Grammars/MySqlGrammar.php index d00588027..8acf440e1 100755 --- a/src/database/src/Query/Grammars/MySqlGrammar.php +++ b/src/database/src/Query/Grammars/MySqlGrammar.php @@ -21,6 +21,27 @@ class MySqlGrammar extends Grammar */ protected array $operators = ['sounds like']; + /** + * Compile a select query into SQL. + */ + public function compileSelect(Builder $query): string + { + $sql = parent::compileSelect($query); + + if ($query->timeout === null) { + return $sql; + } + + $milliseconds = $query->timeout * 1000; + + return preg_replace( + '/^select\b/i', + 'select /*+ MAX_EXECUTION_TIME(' . $milliseconds . ') */', + $sql, + 1 + ); + } + /** * Compile a "where like" clause. */ @@ -33,6 +54,14 @@ protected function whereLike(Builder $query, array $where): string return $this->whereBasic($query, $where); } + /** + * Compile a "where null safe equals" clause. + */ + protected function whereNullSafeEquals(Builder $query, array $where): string + { + return $this->wrap($where['column']) . ' <=> ' . $this->parameter($where['value']); + } + /** * Add a "where null" clause to the query. */ diff --git a/src/database/src/Query/Grammars/PostgresGrammar.php b/src/database/src/Query/Grammars/PostgresGrammar.php index 129552f2f..bd4f0c0a5 100755 --- a/src/database/src/Query/Grammars/PostgresGrammar.php +++ b/src/database/src/Query/Grammars/PostgresGrammar.php @@ -96,7 +96,7 @@ protected function whereDate(Builder $query, array $where): string $column = $this->wrap($where['column']); $value = $this->parameter($where['value']); - if ($this->isJsonSelector($where['column'])) { + if ($this->isJsonSelector($column)) { $column = '(' . $column . ')'; } @@ -111,7 +111,7 @@ protected function whereTime(Builder $query, array $where): string $column = $this->wrap($where['column']); $value = $this->parameter($where['value']); - if ($this->isJsonSelector($where['column'])) { + if ($this->isJsonSelector($column)) { $column = '(' . $column . ')'; } @@ -139,8 +139,12 @@ public function whereFullText(Builder $query, array $where): string $language = 'english'; } + $isVector = $where['options']['vector'] ?? false; + $columns = (new Collection($where['columns'])) - ->map(fn ($column) => "to_tsvector('{$language}', {$this->wrap($column)})") + ->map(fn ($column) => $isVector + ? $this->wrap($column) + : "to_tsvector('{$language}', {$this->wrap($column)})") ->implode(' || '); $mode = 'plainto_tsquery'; @@ -311,6 +315,19 @@ public function compileInsertOrIgnore(Builder $query, array $values): string return $this->compileInsert($query, $values) . ' on conflict do nothing'; } + /** + * Compile an insert or ignore statement with a returning clause into SQL. + */ + public function compileInsertOrIgnoreReturning(Builder $query, array $values, array $returning, ?array $uniqueBy): string + { + $insert = $this->compileInsert($query, $values); + + return match ($uniqueBy) { + null => "{$insert} on conflict do nothing returning {$this->columnize($returning)}", + default => "{$insert} on conflict ({$this->columnize($uniqueBy)}) do nothing returning {$this->columnize($returning)}", + }; + } + /** * Compile an insert ignore statement using a subquery into SQL. */ @@ -705,6 +722,9 @@ public static function cascadeOnTruncate(bool $value = true): void static::$cascadeTruncate = $value; } + // REMOVED: Laravel's deprecated cascadeOnTrucate() typo is omitted; + // use cascadeOnTruncate(). + /** * Flush all static state. */ diff --git a/src/database/src/Query/Grammars/SQLiteGrammar.php b/src/database/src/Query/Grammars/SQLiteGrammar.php index 82d69272b..a306780eb 100755 --- a/src/database/src/Query/Grammars/SQLiteGrammar.php +++ b/src/database/src/Query/Grammars/SQLiteGrammar.php @@ -56,6 +56,14 @@ protected function whereBasic(Builder $query, array $where): string return parent::whereBasic($query, $where); } + /** + * Compile a "where null safe equals" clause. + */ + protected function whereNullSafeEquals(Builder $query, array $where): string + { + return $this->wrap($where['column']) . ' is ' . $this->parameter($where['value']); + } + /** * Compile a "where like" clause. */ @@ -225,6 +233,19 @@ public function compileInsertOrIgnore(Builder $query, array $values): string return Str::replaceFirst('insert', 'insert or ignore', $this->compileInsert($query, $values)); } + /** + * Compile an insert or ignore statement with a returning clause into SQL. + */ + public function compileInsertOrIgnoreReturning(Builder $query, array $values, array $returning, ?array $uniqueBy): string + { + $insert = $this->compileInsert($query, $values); + + return match ($uniqueBy) { + null => "{$insert} on conflict do nothing returning {$this->columnize($returning)}", + default => "{$insert} on conflict ({$this->columnize($uniqueBy)}) do nothing returning {$this->columnize($returning)}", + }; + } + /** * Compile an insert ignore statement using a subquery into SQL. */ diff --git a/tests/Database/DatabaseEloquentMorphToTest.php b/tests/Database/DatabaseEloquentMorphToTest.php index 08cd3e366..0d96b216a 100644 --- a/tests/Database/DatabaseEloquentMorphToTest.php +++ b/tests/Database/DatabaseEloquentMorphToTest.php @@ -7,8 +7,10 @@ use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Eloquent\Builder; +use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\MorphTo; +use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\Query\Builder as QueryBuilder; use Hypervel\Database\Query\Grammars\Grammar; use Hypervel\Database\Query\Processors\Processor; @@ -382,6 +384,37 @@ public function testIsNotModelWithAnotherConnection() $this->assertFalse($relation->is($model)); } + public function testMatchToMorphParentsNormalizesKeyWhenOwnerKeyIsNullAndResultKeyIsObject(): void + { + $uuidObject = new class { + public function __toString(): string + { + return 'uuid-value'; + } + }; + + $builder = m::mock(Builder::class); + $related = m::mock(Model::class); + $builder->shouldReceive('getModel')->andReturn($related); + + $parent = new ModelStub; + $parent->morph_type = 'type_1'; + $parent->foreign_key = 'uuid-value'; + + $relation = Relation::noConstraints(function () use ($builder, $parent) { + return new AccessibleMorphTo($builder, $parent, 'foreign_key', null, 'morph_type', 'relation'); + }); + + $relation->addEagerConstraints([$parent]); + + $result = m::mock(Model::class); + $result->shouldReceive('getKey')->once()->andReturn($uuidObject); + + $relation->callMatchToMorphParents('type_1', new EloquentCollection([$result])); + + $this->assertSame($result, $parent->getRelation('relation')); + } + protected function getRelationAssociate($parent) { $builder = m::mock(Builder::class); @@ -426,3 +459,11 @@ class RelatedStub extends Model { protected ?string $table = 'related_stubs'; } + +class AccessibleMorphTo extends MorphTo +{ + public function callMatchToMorphParents(string $type, EloquentCollection $results): void + { + $this->matchToMorphParents($type, $results); + } +} diff --git a/tests/Database/DatabaseMySqlQueryGrammarTest.php b/tests/Database/DatabaseMySqlQueryGrammarTest.php index 9c6236957..72e6fbb78 100755 --- a/tests/Database/DatabaseMySqlQueryGrammarTest.php +++ b/tests/Database/DatabaseMySqlQueryGrammarTest.php @@ -5,8 +5,11 @@ namespace Hypervel\Tests\Database; use Hypervel\Database\Connection; +use Hypervel\Database\Query\Builder; use Hypervel\Database\Query\Grammars\MySqlGrammar; +use Hypervel\Database\Query\Processors\Processor; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use Mockery as m; class DatabaseMySqlQueryGrammarTest extends TestCase @@ -24,4 +27,66 @@ public function testToRawSql() $this->assertSame('select * from "users" where \'Hello\\\'World?\' IS NOT NULL AND "email" = \'foo\'', $query); } + + public function testTimeout(): void + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('email', 'like', '%test%')->timeout(60); + + $this->assertSame( + 'select /*+ MAX_EXECUTION_TIME(60000) */ * from `users` where `email` like ?', + $builder->toSql() + ); + } + + public function testTimeoutWithDistinctAndAggregateQueries(): void + { + $builder = $this->getBuilder(); + $builder->distinct()->select('*')->from('users')->timeout(30); + $this->assertSame( + 'select /*+ MAX_EXECUTION_TIME(30000) */ distinct * from `users`', + $builder->toSql() + ); + + $builder = $this->getBuilder(); + $builder->from('users')->timeout(10); + $builder->aggregate = ['function' => 'count', 'columns' => ['*']]; + $this->assertSame( + 'select /*+ MAX_EXECUTION_TIME(10000) */ count(*) as `aggregate` from `users`', + $builder->toSql() + ); + } + + public function testTimeoutCanBeCleared(): void + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->timeout(60)->timeout(null); + + $this->assertSame('select * from `users`', $builder->toSql()); + } + + public function testTimeoutRejectsNonPositiveValues(): void + { + foreach ([0, -1] as $timeout) { + try { + $this->getBuilder()->timeout($timeout); + $this->fail('Expected an invalid timeout exception.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame('Timeout must be greater than zero.', $exception->getMessage()); + } + } + } + + protected function getBuilder(): Builder + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getDatabaseName')->andReturn('database'); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + + return new Builder( + $connection, + new MySqlGrammar($connection), + m::mock(Processor::class) + ); + } } diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 8ce382e6b..103d8688d 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -6,6 +6,8 @@ use BadMethodCallException; use Closure; +use DateInterval; +use DatePeriod; use DateTime; use Hypervel\Contracts\Database\Query\ConditionExpression; use Hypervel\Database\Connection; @@ -596,6 +598,10 @@ public function testWhereDatePostgres() $builder = $this->getPostgresBuilder(); $builder->select('*')->from('users')->whereDate('result->created_at', new Raw('NOW()')); $this->assertSame('select * from "users" where ("result"->>\'created_at\')::date = NOW()', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereDate(new Raw('COALESCE(created_at, updated_at)'), new Raw('NOW()')); + $this->assertSame('select * from "users" where COALESCE(created_at, updated_at)::date = NOW()', $builder->toSql()); } public function testWhereDayPostgres() @@ -633,6 +639,11 @@ public function testWhereTimePostgres() $builder->select('*')->from('users')->whereTime('result->created_at', '>=', '22:00'); $this->assertSame('select * from "users" where ("result"->>\'created_at\')::time >= ?', $builder->toSql()); $this->assertEquals([0 => '22:00'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereTime(new Raw('COALESCE(created_at, updated_at)'), '>=', '22:00'); + $this->assertSame('select * from "users" where COALESCE(created_at, updated_at)::time >= ?', $builder->toSql()); + $this->assertSame(['22:00'], $builder->getBindings()); } public function testWherePast() @@ -917,6 +928,36 @@ public function testWhereTimeOperatorOptionalSqlite() $this->assertEquals([0 => '22:00'], $builder->getBindings()); } + public function testWhereNullSafeEquals(): void + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar')->whereNullSafeEquals('baz', null); + + $this->assertSame('select * from "users" where "foo" is not distinct from ? and "baz" is not distinct from ?', $builder->toSql()); + $this->assertSame(['bar', null], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('foo', 'bar')->orWhereNullSafeEquals('baz', new Raw('qux')); + + $this->assertSame('select * from "users" where "foo" = ? or "baz" is not distinct from qux', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + } + + public function testWhereNullSafeEqualsUsesSupportedDriverSyntax(): void + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar'); + $this->assertSame('select * from `users` where `foo` <=> ?', $builder->toSql()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar'); + $this->assertSame('select * from "users" where "foo" is ?', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar'); + $this->assertSame('select * from "users" where "foo" is not distinct from ?', $builder->toSql()); + } + public function testWhereBetweens() { $builder = $this->getBuilder(); @@ -957,6 +998,20 @@ public function testWhereBetweens() $this->assertSame('select * from "users" where "created_at" between ? and ?', $builder->toSql()); $this->assertEquals([now()->startOfDay(), now()->addMonth()->startOfDay()], $builder->getBindings()); + $start = now()->startOfDay(); + + $builder = $this->getBuilder(); + $period = new DatePeriod($start, new DateInterval('P1D'), $start->addDays(5)); + $builder->select('*')->from('users')->whereBetween('created_at', $period); + $this->assertSame('select * from "users" where "created_at" between ? and ?', $builder->toSql()); + $this->assertEquals([$start, $start->addDays(5)], $builder->getBindings()); + + $builder = $this->getBuilder(); + $period = new DatePeriod($start, new DateInterval('P1D'), 5); + $builder->select('*')->from('users')->whereBetween('created_at', $period); + $this->assertSame('select * from "users" where "created_at" between ? and ?', $builder->toSql()); + $this->assertEquals([$start, $start->addDays(5)], $builder->getBindings()); + $builder = $this->getBuilder(); $builder->select('*')->from('users')->whereBetween('id', collect([1, 2])); $this->assertSame('select * from "users" where "id" between ? and ?', $builder->toSql()); @@ -1470,6 +1525,21 @@ public function testWhereFulltextPostgres() $builder->select('*')->from('users')->whereFullText(['body', 'title'], 'Air | Plan:* -Car', ['mode' => 'raw']); $this->assertSame('select * from "users" where (to_tsvector(\'english\', "body") || to_tsvector(\'english\', "title")) @@ to_tsquery(\'english\', ?)', $builder->toSql()); $this->assertEquals(['Air | Plan:* -Car'], $builder->getBindings()); + + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText('search_vector', 'Hello World', ['vector' => true]); + $this->assertSame('select * from "users" where ("search_vector") @@ plainto_tsquery(\'english\', ?)', $builder->toSql()); + $this->assertSame(['Hello World'], $builder->getBindings()); + + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText('search_vector_nl', 'Hello World', ['vector' => true, 'language' => 'dutch']); + $this->assertSame('select * from "users" where ("search_vector_nl") @@ plainto_tsquery(\'dutch\', ?)', $builder->toSql()); + $this->assertSame(['Hello World'], $builder->getBindings()); + + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText(['tsv_title', 'tsv_body'], 'Car Plane', ['vector' => true]); + $this->assertSame('select * from "users" where ("tsv_title" || "tsv_body") @@ plainto_tsquery(\'english\', ?)', $builder->toSql()); + $this->assertSame(['Car Plane'], $builder->getBindings()); } public function testWhereAll() @@ -1769,25 +1839,25 @@ public function testMySqlUnionLimitsAndOffsets() public function testUnionAggregate() { - $expected = 'select count(*) as aggregate from ((select * from `posts`) union (select * from `videos`)) as `temp_table`'; + $expected = 'select count(*) as `aggregate` from ((select * from `posts`) union (select * from `videos`)) as `temp_table`'; $builder = $this->getMySqlBuilder(); $builder->getConnection()->shouldReceive('select')->once()->with($expected, [], true, []); $builder->getProcessor()->shouldReceive('processSelect')->once(); $builder->from('posts')->union($this->getMySqlBuilder()->from('videos'))->count(); - $expected = 'select count(*) as aggregate from ((select `id` from `posts`) union (select `id` from `videos`)) as `temp_table`'; + $expected = 'select count(*) as `aggregate` from ((select `id` from `posts`) union (select `id` from `videos`)) as `temp_table`'; $builder = $this->getMySqlBuilder(); $builder->getConnection()->shouldReceive('select')->once()->with($expected, [], true, []); $builder->getProcessor()->shouldReceive('processSelect')->once(); $builder->from('posts')->select('id')->union($this->getMySqlBuilder()->from('videos')->select('id'))->count(); - $expected = 'select count(*) as aggregate from ((select * from "posts") union (select * from "videos")) as "temp_table"'; + $expected = 'select count(*) as "aggregate" from ((select * from "posts") union (select * from "videos")) as "temp_table"'; $builder = $this->getPostgresBuilder(); $builder->getConnection()->shouldReceive('select')->once()->with($expected, [], true, []); $builder->getProcessor()->shouldReceive('processSelect')->once(); $builder->from('posts')->union($this->getPostgresBuilder()->from('videos'))->count(); - $expected = 'select count(*) as aggregate from (select * from (select * from "posts") union select * from (select * from "videos")) as "temp_table"'; + $expected = 'select count(*) as "aggregate" from (select * from (select * from "posts") union select * from (select * from "videos")) as "temp_table"'; $builder = $this->getSQLiteBuilder(); $builder->getConnection()->shouldReceive('select')->once()->with($expected, [], true, []); $builder->getProcessor()->shouldReceive('processSelect')->once(); @@ -1796,7 +1866,7 @@ public function testUnionAggregate() public function testHavingAggregate() { - $expected = 'select count(*) as aggregate from (select (select `count(*)` from `videos` where `posts`.`id` = `videos`.`post_id`) as `videos_count` from `posts` having `videos_count` > ?) as `temp_table`'; + $expected = 'select count(*) as `aggregate` from (select (select `count(*)` from `videos` where `posts`.`id` = `videos`.`post_id`) as `videos_count` from `posts` having `videos_count` > ?) as `temp_table`'; $builder = $this->getMySqlBuilder(); $builder->getConnection()->shouldReceive('getDatabaseName'); $builder->getConnection()->shouldReceive('select')->once()->with($expected, [0 => 1], true, [])->andReturn([['aggregate' => 1]]); @@ -2025,6 +2095,44 @@ public function testInRandomOrderPostgres() $this->assertSame('select * from "users" order by RANDOM()', $builder->toSql()); } + public function testInOrderOf(): void + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->inOrderOf('status', ['active', 'pending', 'inactive']); + + $this->assertSame('select * from "users" order by case when "status" = ? then 0 when "status" = ? then 1 when "status" = ? then 2 else 3 end', $builder->toSql()); + $this->assertSame(['active', 'pending', 'inactive'], $builder->getBindings()); + } + + public function testInOrderOfAcceptsArrayableValuesAndPreservesDuplicates(): void + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->inOrderOf('status', collect(['active', 'active', 'pending'])); + + $this->assertSame('select * from "users" order by case when "status" = ? then 0 when "status" = ? then 1 when "status" = ? then 2 else 3 end', $builder->toSql()); + $this->assertSame(['active', 'active', 'pending'], $builder->getBindings()); + } + + public function testInOrderOfIsANoOpForEmptyValues(): void + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->inOrderOf('status', []); + + $this->assertSame('select * from "users"', $builder->toSql()); + $this->assertSame([], $builder->getBindings()); + } + + public function testInOrderOfAppliesToUnionOrders(): void + { + $builder = $this->getBuilder(); + $builder->select('*')->from('posts')->where('public', 1) + ->unionAll($this->getBuilder()->select('*')->from('videos')->where('public', 1)) + ->inOrderOf('category', ['news', 'opinion']); + + $this->assertSame('(select * from "posts" where "public" = ?) union all (select * from "videos" where "public" = ?) order by case when "category" = ? then 0 when "category" = ? then 1 else 2 end', $builder->toSql()); + $this->assertSame([1, 1, 'news', 'opinion'], $builder->getBindings()); + } + public function testReorder(): void { $builder = $this->getBuilder(); @@ -2155,6 +2263,24 @@ public function testHavingBetweens() $builder->select('*')->from('users')->havingBetween('id', [[1, 2], [3, 4]]); $this->assertSame('select * from "users" having "id" between ? and ?', $builder->toSql()); $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $start = now()->startOfDay(); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->havingBetween( + 'created_at', + new DatePeriod($start, new DateInterval('P1D'), $start->addDays(5)) + ); + $this->assertSame('select * from "users" having "created_at" between ? and ?', $builder->toSql()); + $this->assertEquals([$start, $start->addDays(5)], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->havingBetween( + 'created_at', + new DatePeriod($start, new DateInterval('P1D'), 5) + ); + $this->assertSame('select * from "users" having "created_at" between ? and ?', $builder->toSql()); + $this->assertEquals([$start, $start->addDays(5)], $builder->getBindings()); } public function testHavingNull() @@ -2379,7 +2505,7 @@ public function testGetCountForPaginationWithBindings() $q->select('body')->from('posts')->where('id', 4); }, 'post'); - $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as aggregate from "users"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as "aggregate" from "users"', [], true, [])->andReturn([['aggregate' => 1]]); $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { return $results; }); @@ -2395,7 +2521,7 @@ public function testGetCountForPaginationWithColumnAliases() $columns = ['body as post_body', 'teaser', 'posts.created as published']; $builder->from('posts')->select($columns); - $builder->getConnection()->shouldReceive('select')->once()->with('select count("body", "teaser", "posts"."created") as aggregate from "posts"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select count("body", "teaser", "posts"."created") as "aggregate" from "posts"', [], true, [])->andReturn([['aggregate' => 1]]); $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { return $results; }); @@ -2409,7 +2535,7 @@ public function testGetCountForPaginationWithUnion() $builder = $this->getBuilder(); $builder->from('posts')->select('id')->union($this->getBuilder()->from('videos')->select('id')); - $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as aggregate from ((select "id" from "posts") union (select "id" from "videos")) as "temp_table"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as "aggregate" from ((select "id" from "posts") union (select "id" from "videos")) as "temp_table"', [], true, [])->andReturn([['aggregate' => 1]]); $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { return $results; }); @@ -2423,7 +2549,7 @@ public function testGetCountForPaginationWithUnionOrders() $builder = $this->getBuilder(); $builder->from('posts')->select('id')->union($this->getBuilder()->from('videos')->select('id'))->latest(); - $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as aggregate from ((select "id" from "posts") union (select "id" from "videos")) as "temp_table"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as "aggregate" from ((select "id" from "posts") union (select "id" from "videos")) as "temp_table"', [], true, [])->andReturn([['aggregate' => 1]]); $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { return $results; }); @@ -2437,7 +2563,7 @@ public function testGetCountForPaginationWithUnionLimitAndOffset() $builder = $this->getBuilder(); $builder->from('posts')->select('id')->union($this->getBuilder()->from('videos')->select('id'))->limit(15)->offset(1); - $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as aggregate from ((select "id" from "posts") union (select "id" from "videos")) as "temp_table"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as "aggregate" from ((select "id" from "posts") union (select "id" from "videos")) as "temp_table"', [], true, [])->andReturn([['aggregate' => 1]]); $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { return $results; }); @@ -3259,6 +3385,53 @@ public function testRightJoinSub() $builder->from('users')->rightJoinSub(['foo'], 'sub', 'users.id', '=', 'sub.id'); } + public function testStraightJoin(): void + { + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->select('*')->from('users') + ->join('contacts', 'users.id', '=', 'contacts.id') + ->straightJoin('photos', 'users.id', '=', 'photos.id'); + + $this->assertSame('select * from `users` inner join `contacts` on `users`.`id` = `contacts`.`id` straight_join `photos` on `users`.`id` = `photos`.`id`', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->select('*')->from('users') + ->straightJoinWhere('photos', 'users.id', '=', 'bar') + ->joinWhere('photos', 'users.id', '=', 'foo'); + + $this->assertSame('select * from `users` straight_join `photos` on `users`.`id` = ? inner join `photos` on `users`.`id` = ?', $builder->toSql()); + $this->assertSame(['bar', 'foo'], $builder->getBindings()); + } + + public function testStraightJoinIsRejectedByUnsupportedGrammars(): void + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->straightJoin('contacts', 'users.id', '=', 'contacts.id'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not support straight joins'); + + $builder->toSql(); + } + + public function testStraightJoinSub(): void + { + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->from('users')->straightJoinSub( + $this->getBuilder()->from('contacts')->where('active', true), + 'sub', + 'users.id', + '=', + 'sub.id' + ); + + $this->assertSame('select * from `users` straight_join (select * from "contacts" where "active" = ?) as `sub` on `users`.`id` = `sub`.`id`', $builder->toSql()); + $this->assertSame([true], $builder->getBindings()); + } + public function testJoinLateral() { $builder = $this->getMySqlBuilder(); @@ -3496,7 +3669,7 @@ public function testRawValueMethodReturnsSingleColumn() public function testAggregateFunctions() { $builder = $this->getBuilder(); - $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as aggregate from "users"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as "aggregate" from "users"', [], true, [])->andReturn([['aggregate' => 1]]); $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { return $results; }); @@ -3514,7 +3687,7 @@ public function testAggregateFunctions() $this->assertTrue($results); $builder = $this->getBuilder(); - $builder->getConnection()->shouldReceive('select')->once()->with('select max("id") as aggregate from "users"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select max("id") as "aggregate" from "users"', [], true, [])->andReturn([['aggregate' => 1]]); $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { return $results; }); @@ -3522,7 +3695,7 @@ public function testAggregateFunctions() $this->assertEquals(1, $results); $builder = $this->getBuilder(); - $builder->getConnection()->shouldReceive('select')->once()->with('select min("id") as aggregate from "users"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select min("id") as "aggregate" from "users"', [], true, [])->andReturn([['aggregate' => 1]]); $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { return $results; }); @@ -3530,7 +3703,7 @@ public function testAggregateFunctions() $this->assertEquals(1, $results); $builder = $this->getBuilder(); - $builder->getConnection()->shouldReceive('select')->once()->with('select sum("id") as aggregate from "users"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select sum("id") as "aggregate" from "users"', [], true, [])->andReturn([['aggregate' => 1]]); $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { return $results; }); @@ -3538,7 +3711,7 @@ public function testAggregateFunctions() $this->assertEquals(1, $results); $builder = $this->getBuilder(); - $builder->getConnection()->shouldReceive('select')->once()->with('select avg("id") as aggregate from "users"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select avg("id") as "aggregate" from "users"', [], true, [])->andReturn([['aggregate' => 1]]); $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { return $results; }); @@ -3546,7 +3719,7 @@ public function testAggregateFunctions() $this->assertEquals(1, $results); $builder = $this->getBuilder(); - $builder->getConnection()->shouldReceive('select')->once()->with('select avg("id") as aggregate from "users"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select avg("id") as "aggregate" from "users"', [], true, [])->andReturn([['aggregate' => 1]]); $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { return $results; }); @@ -3589,8 +3762,8 @@ public function testDoesntExistsOr() public function testAggregateResetFollowedByGet() { $builder = $this->getBuilder(); - $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as aggregate from "users"', [], true, [])->andReturn([['aggregate' => 1]]); - $builder->getConnection()->shouldReceive('select')->once()->with('select sum("id") as aggregate from "users"', [], true, [])->andReturn([['aggregate' => 2]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as "aggregate" from "users"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select sum("id") as "aggregate" from "users"', [], true, [])->andReturn([['aggregate' => 2]]); $builder->getConnection()->shouldReceive('select')->once()->with('select "column1", "column2" from "users"', [], true, [])->andReturn([['column1' => 'foo', 'column2' => 'bar']]); $builder->getProcessor()->shouldReceive('processSelect')->andReturnUsing(function ($builder, $results) { return $results; @@ -3607,7 +3780,7 @@ public function testAggregateResetFollowedByGet() public function testAggregateResetFollowedBySelectGet() { $builder = $this->getBuilder(); - $builder->getConnection()->shouldReceive('select')->once()->with('select count("column1") as aggregate from "users"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select count("column1") as "aggregate" from "users"', [], true, [])->andReturn([['aggregate' => 1]]); $builder->getConnection()->shouldReceive('select')->once()->with('select "column2", "column3" from "users"', [], true, [])->andReturn([['column2' => 'foo', 'column3' => 'bar']]); $builder->getProcessor()->shouldReceive('processSelect')->andReturnUsing(function ($builder, $results) { return $results; @@ -3622,7 +3795,7 @@ public function testAggregateResetFollowedBySelectGet() public function testAggregateResetFollowedByGetWithColumns() { $builder = $this->getBuilder(); - $builder->getConnection()->shouldReceive('select')->once()->with('select count("column1") as aggregate from "users"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select count("column1") as "aggregate" from "users"', [], true, [])->andReturn([['aggregate' => 1]]); $builder->getConnection()->shouldReceive('select')->once()->with('select "column2", "column3" from "users"', [], true, [])->andReturn([['column2' => 'foo', 'column3' => 'bar']]); $builder->getProcessor()->shouldReceive('processSelect')->andReturnUsing(function ($builder, $results) { return $results; @@ -3637,7 +3810,7 @@ public function testAggregateResetFollowedByGetWithColumns() public function testAggregateWithSubSelect() { $builder = $this->getBuilder(); - $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as aggregate from "users"', [], true, [])->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as "aggregate" from "users"', [], true, [])->andReturn([['aggregate' => 1]]); $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { return $results; }); @@ -3744,6 +3917,153 @@ public function testSQLiteInsertOrIgnoreMethod() $this->assertEquals(1, $result); } + public function testInsertOrIgnoreReturningRejectsUnsupportedGrammars(): void + { + $builder = $this->getBuilder(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not support insert or ignore with returning'); + + $builder->from('users')->insertOrIgnoreReturning(['email' => 'foo']); + } + + public function testInsertOrIgnoreReturningWithEmptyValues(): void + { + $result = $this->getPostgresBuilder()->from('users')->insertOrIgnoreReturning([]); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertTrue($result->isEmpty()); + } + + public function testMySqlInsertOrIgnoreReturningIsUnsupported(): void + { + $builder = $this->getMySqlBuilder(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not support insert or ignore with returning'); + + $builder->from('users')->insertOrIgnoreReturning(['email' => 'foo']); + } + + public function testPostgresInsertOrIgnoreReturning(): void + { + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('recordsHaveBeenModified')->once()->with(true); + $builder->getConnection()->shouldReceive('selectFromWriteConnection')->once()->with( + 'insert into "users" ("email") values (?) on conflict do nothing returning "id"', + ['foo'] + )->andReturn([['id' => 1]]); + + $result = $builder->from('users')->insertOrIgnoreReturning(['email' => 'foo'], ['id']); + + $this->assertSame([['id' => 1]], $result->all()); + } + + public function testPostgresInsertOrIgnoreReturningWithConflictColumns(): void + { + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('recordsHaveBeenModified')->once()->with(true); + $builder->getConnection()->shouldReceive('selectFromWriteConnection')->once()->with( + 'insert into "users" ("email", "name") values (?, ?) on conflict ("email", "name") do nothing returning *', + ['foo', 'bar'] + )->andReturn([['id' => 1, 'email' => 'foo', 'name' => 'bar']]); + + $result = $builder->from('users')->insertOrIgnoreReturning( + ['email' => 'foo', 'name' => 'bar'], + ['*'], + ['email', 'name'] + ); + + $this->assertSame([['id' => 1, 'email' => 'foo', 'name' => 'bar']], $result->all()); + } + + public function testPostgresInsertOrIgnoreReturningMultipleRows(): void + { + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('recordsHaveBeenModified')->once()->with(true); + $builder->getConnection()->shouldReceive('selectFromWriteConnection')->once()->with( + 'insert into "users" ("email") values (?), (?) on conflict ("email") do nothing returning "id", "email"', + ['foo', 'bar'] + )->andReturn([['id' => 1, 'email' => 'foo']]); + + $result = $builder->from('users')->insertOrIgnoreReturning( + [['email' => 'foo'], ['email' => 'bar']], + ['id', 'email'], + 'email' + ); + + $this->assertSame([['id' => 1, 'email' => 'foo']], $result->all()); + } + + public function testSQLiteInsertOrIgnoreReturning(): void + { + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('recordsHaveBeenModified')->once()->with(true); + $builder->getConnection()->shouldReceive('selectFromWriteConnection')->once()->with( + 'insert into "users" ("email", "name") values (?, ?) on conflict ("email") do nothing returning "id"', + ['foo', 'bar'] + )->andReturn([['id' => 1]]); + + $result = $builder->from('users')->insertOrIgnoreReturning( + ['email' => 'foo', 'name' => 'bar'], + ['id'], + 'email' + ); + + $this->assertSame([['id' => 1]], $result->all()); + } + + public function testInsertOrIgnoreReturningRunsBeforeQueryCallbacks(): void + { + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('recordsHaveBeenModified')->once()->with(true); + $builder->getConnection()->shouldReceive('selectFromWriteConnection')->once()->with( + 'insert into "members" ("email") values (?) on conflict do nothing returning *', + ['foo'] + )->andReturn([['email' => 'foo']]); + + $result = $builder->from('users') + ->beforeQuery(fn (Builder $query) => $query->from('members')) + ->insertOrIgnoreReturning(['email' => 'foo']); + + $this->assertSame([['email' => 'foo']], $result->all()); + } + + public function testInsertOrIgnoreReturningValidatesConflictAndReturningColumns(): void + { + $builder = $this->getPostgresBuilder()->from('users'); + + foreach ( + [ + [[], 'The unique columns must not be empty.'], + ['', 'The unique columns must not be empty.'], + ] as [$uniqueBy, $message] + ) { + try { + $builder->insertOrIgnoreReturning(['email' => 'foo'], ['*'], $uniqueBy); + $this->fail('Expected an invalid conflict-column exception.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame($message, $exception->getMessage()); + } + } + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The returning columns must not be empty.'); + + $builder->insertOrIgnoreReturning(['email' => 'foo'], []); + } + + public function testInsertOrIgnoreReturningDoesNotMarkRecordsModifiedWhenNoRowsAreInserted(): void + { + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('selectFromWriteConnection')->once()->andReturn([]); + $builder->getConnection()->shouldReceive('recordsHaveBeenModified')->once()->with(false); + + $result = $builder->from('users')->insertOrIgnoreReturning(['email' => 'foo']); + + $this->assertTrue($result->isEmpty()); + } + public function testInsertOrIgnoreUsingMethod() { $this->expectException(RuntimeException::class); @@ -4124,6 +4444,19 @@ public function testUpdateMethodWorksWithQueryAsValue() $result = $builder->from('users')->where('id', '=', 1)->update(['credits' => $this->getBuilder()->from('transactions')->selectRaw('sum(credits)')->whereColumn('transactions.user_id', 'users.id')->where('type', 'foo')]); $this->assertEquals(1, $result); + + $builder = $this->getBuilder(); + $subquery = new EloquentBuilder( + $this->getBuilder() + ->from('transactions') + ->selectRaw('sum(credits)') + ->whereColumn('transactions.user_id', 'users.id') + ->where('type', 'foo') + ); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "credits" = (select sum(credits) from "transactions" where "transactions"."user_id" = "users"."id" and "type" = ?) where "id" = ?', ['foo', 1])->andReturn(1); + $result = $builder->from('users')->where('id', '=', 1)->update(['credits' => $subquery]); + + $this->assertSame(1, $result); } public function testUpdateOrInsertMethod() @@ -6639,6 +6972,21 @@ public function testFromQuestionMarkOperatorOnPostgres() $this->assertSame('select * from "users" where "roles" ??& ?', $builder->toSql()); } + public function testWhereColumnQuestionMarkOperatorOnPostgres(): void + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereColumn('foo', '?', '_foo'); + $this->assertSame('select * from "users" where "foo" ?? "_foo"', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereColumn('foo', '?|', '_foo'); + $this->assertSame('select * from "users" where "foo" ??| "_foo"', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereColumn('foo', '?&', '_foo'); + $this->assertSame('select * from "users" where "foo" ??& "_foo"', $builder->toSql()); + } + public function testUseIndexMySql() { $builder = $this->getMySqlBuilder(); diff --git a/tests/Database/DatabaseQueryGrammarTest.php b/tests/Database/DatabaseQueryGrammarTest.php index 49526b89a..490f3b73f 100644 --- a/tests/Database/DatabaseQueryGrammarTest.php +++ b/tests/Database/DatabaseQueryGrammarTest.php @@ -8,8 +8,10 @@ use Hypervel\Database\Query\Builder; use Hypervel\Database\Query\Expression; use Hypervel\Database\Query\Grammars\Grammar; +use Hypervel\Database\SQLiteConnection; use Hypervel\Tests\TestCase; use Mockery as m; +use PDO; use ReflectionClass; class DatabaseQueryGrammarTest extends TestCase @@ -75,4 +77,67 @@ public function testCompileOrdersAcceptsExpressionWithPlaceholders() $this->assertSame('order by field(status, ?, ?) asc', strtolower($sql)); } + + public function testRawSqlBindingSubstitutionPreservesParserSemantics(): void + { + $resource = fopen('php://memory', 'r'); + $resourceIdentity = (string) $resource; + + try { + $connection = m::mock(Connection::class); + $connection->shouldReceive('escape')->once()->with('first', false)->andReturn("'first'"); + $connection->shouldReceive('escape')->once()->with($resourceIdentity, false)->andReturn("'resource'"); + + $sql = (new Grammar($connection))->substituteBindingsIntoRawSql( + "select ?, '?', ??, ?, ?", + ['first', $resource] + ); + + $this->assertSame("select 'first', '?', ??, 'resource', ?", $sql); + } finally { + fclose($resource); + } + } + + public function testRawSqlBindingSubstitutionRendersLiveAndClosedResourceIdentities(): void + { + $liveResource = fopen('php://memory', 'r'); + $closedResource = fopen('php://memory', 'r'); + $liveResourceIdentity = (string) $liveResource; + $closedResourceIdentity = (string) $closedResource; + + fclose($closedResource); + + try { + $connection = new SQLiteConnection(new PDO('sqlite::memory:'), ':memory:'); + + $sql = $connection->getQueryGrammar()->substituteBindingsIntoRawSql( + 'select ?, ?', + [$liveResource, $closedResource] + ); + + $this->assertSame( + "select '{$liveResourceIdentity}', '{$closedResourceIdentity}'", + $sql + ); + } finally { + fclose($liveResource); + } + } + + public function testRawSqlBindingSubstitutionHandlesLongBindingLists(): void + { + $bindings = range(1, 250); + $connection = m::mock(Connection::class); + $connection->shouldReceive('escape') + ->times(count($bindings)) + ->andReturnUsing(static fn (int $value, bool $binary): string => (string) $value); + + $sql = (new Grammar($connection))->substituteBindingsIntoRawSql( + implode(', ', array_fill(0, count($bindings), '?')), + $bindings + ); + + $this->assertSame(implode(', ', $bindings), $sql); + } } diff --git a/tests/Telescope/Watchers/QueryWatcherTest.php b/tests/Telescope/Watchers/QueryWatcherTest.php index ab584f56a..d2d634529 100644 --- a/tests/Telescope/Watchers/QueryWatcherTest.php +++ b/tests/Telescope/Watchers/QueryWatcherTest.php @@ -33,7 +33,7 @@ public function testQueryWatcherRegistersDatabaseQueries() $entry = $this->loadTelescopeEntries()->first(); $this->assertSame(EntryType::QUERY, $entry->type); - $this->assertSame('select count(*) as aggregate from "telescope_entries"', $entry->content['sql']); + $this->assertSame('select count(*) as "aggregate" from "telescope_entries"', $entry->content['sql']); $this->assertSame('testing', $entry->content['connection']); $this->assertSame('sqlite', $entry->content['driver']); } From c11d039f145e9a6c06b14697c46af75fa0afab41 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:10:09 +0000 Subject: [PATCH 12/26] perf(database): merge only requested cached casts Synchronize only the requested class or Attribute cast during ordinary attribute reads instead of walking and serializing every cached cast on the model. Retain full cached-cast merges around legacy and Attribute get and set mutators because those callbacks may inspect sibling state. Preserve mutable and encrypted cast persistence, add ARRAY_AS_PROPS behavior for encrypted array objects, and cover unrelated-cast avoidance alongside sibling-mutator visibility. --- .../Eloquent/Casts/AsEncryptedArrayObject.php | 2 +- .../src/Eloquent/Concerns/HasAttributes.php | 91 ++++++++++++++----- ...DatabaseEloquentModelCustomCastingTest.php | 73 +++++++++++++++ .../EloquentModelEncryptedCastingTest.php | 8 +- 4 files changed, 146 insertions(+), 28 deletions(-) diff --git a/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php b/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php index d3b2b0a77..aa0815734 100644 --- a/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php +++ b/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php @@ -21,7 +21,7 @@ public static function castUsing(array $arguments): CastsAttributes public function get(mixed $model, string $key, mixed $value, array $attributes): ?ArrayObject { if (isset($attributes[$key])) { - return new ArrayObject(Json::decode(Crypt::decryptString($attributes[$key]))); + return new ArrayObject(Json::decode(Crypt::decryptString($attributes[$key])), ArrayObject::ARRAY_AS_PROPS); } return null; diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index 6745dc3fb..4e5844be9 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -535,7 +535,9 @@ public function getAttributeValue(string $key): mixed */ protected function getAttributeFromArray(string $key): mixed { - return $this->getAttributes()[$key] ?? null; + $this->mergeAttributeFromCachedCasts($key); + + return $this->attributes[$key] ?? null; } /** @@ -684,6 +686,8 @@ public function hasAnyGetMutator(string $key): bool */ protected function mutateAttribute(string $key, mixed $value): mixed { + $this->mergeAttributesFromCachedCasts(); + return $this->{'get' . StrCache::studly($key) . 'Attribute'}($value); } @@ -696,6 +700,8 @@ protected function mutateAttributeMarkedAttribute(string $key, mixed $value): mi return $this->attributeCastCache[$key]; } + $this->mergeAttributesFromCachedCasts(); + $attribute = $this->{StrCache::camel($key)}(); $value = call_user_func($attribute->get ?: function ($value) { @@ -1100,6 +1106,8 @@ public function hasAttributeSetMutator(string $key): bool */ protected function setMutatedAttributeValue(string $key, mixed $value): mixed { + $this->mergeAttributesFromCachedCasts(); + return $this->{'set' . StrCache::studly($key) . 'Attribute'}($value); } @@ -1108,6 +1116,8 @@ protected function setMutatedAttributeValue(string $key, mixed $value): mixed */ protected function setAttributeMarkedMutatedAttributeValue(string $key, mixed $value): mixed { + $this->mergeAttributesFromCachedCasts(); + $attribute = $this->{StrCache::camel($key)}(); $callback = $attribute->set ?: function ($value) use ($key) { @@ -1552,7 +1562,7 @@ public function getCasts(): array /** * Get the attributes that should be cast. * - * @return array + * @return array */ protected function casts(): array { @@ -1754,21 +1764,43 @@ protected function mergeAttributesFromCachedCasts(): void $this->mergeAttributesFromAttributeCasts(); } + /** + * Merge the cast class and attribute cast attribute back into the model. + */ + protected function mergeAttributeFromCachedCasts(string $key): void + { + $this->mergeAttributeFromClassCasts($key); + $this->mergeAttributeFromAttributeCasts($key); + } + /** * Merge the cast class attributes back into the model. */ protected function mergeAttributesFromClassCasts(): void { foreach ($this->classCastCache as $key => $value) { - $caster = $this->resolveCasterClass($key); + $this->mergeAttributeFromClassCasts($key); + } + } - $this->attributes = array_merge( - $this->attributes, - $caster instanceof CastsInboundAttributes - ? [$key => $value] - : $this->normalizeCastClassResponse($key, $caster->set($this, $key, $value, $this->attributes)) - ); + /** + * Merge the cast class attribute back into the model. + */ + protected function mergeAttributeFromClassCasts(string $key): void + { + if (! isset($this->classCastCache[$key])) { + return; } + + $value = $this->classCastCache[$key]; + $caster = $this->resolveCasterClass($key); + + $this->attributes = array_merge( + $this->attributes, + $caster instanceof CastsInboundAttributes + ? [$key => $value] + : $this->normalizeCastClassResponse($key, $caster->set($this, $key, $value, $this->attributes)) + ); } /** @@ -1777,24 +1809,37 @@ protected function mergeAttributesFromClassCasts(): void protected function mergeAttributesFromAttributeCasts(): void { foreach ($this->attributeCastCache as $key => $value) { - $attribute = $this->{StrCache::camel($key)}(); + $this->mergeAttributeFromAttributeCasts($key); + } + } - if ($attribute->get && ! $attribute->set) { - continue; - } + /** + * Merge the cast class attribute back into the model. + */ + protected function mergeAttributeFromAttributeCasts(string $key): void + { + if (! isset($this->attributeCastCache[$key])) { + return; + } - $callback = $attribute->set ?: function ($value) use ($key) { - $this->attributes[$key] = $value; - }; + $value = $this->attributeCastCache[$key]; + $attribute = $this->{StrCache::camel($key)}(); - $this->attributes = array_merge( - $this->attributes, - $this->normalizeCastClassResponse( - $key, - $callback($value, $this->attributes) - ) - ); + if ($attribute->get && ! $attribute->set) { + return; } + + $callback = $attribute->set ?: function ($value) use ($key) { + $this->attributes[$key] = $value; + }; + + $this->attributes = array_merge( + $this->attributes, + $this->normalizeCastClassResponse( + $key, + $callback($value, $this->attributes) + ) + ); } /** diff --git a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php index 146179c19..da80a6807 100644 --- a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php +++ b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php @@ -10,11 +10,13 @@ use Hypervel\Contracts\Database\Eloquent\CastsInboundAttributes; use Hypervel\Contracts\Database\Eloquent\DeviatesCastableAttributes; use Hypervel\Contracts\Database\Eloquent\SerializesCastableAttributes; +use Hypervel\Database\Eloquent\Casts\Attribute; use Hypervel\Database\Eloquent\InvalidCastException; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Carbon; use Hypervel\Support\Facades\Schema; +use RuntimeException; class DatabaseEloquentModelCustomCastingTest extends DatabaseTestCase { @@ -289,6 +291,29 @@ public function testSetToUndefinedCast() $model->undefined_cast_column = 'Glāžšķūņu rūķīši'; } + + public function testMutatorCanDependOnAnotherCastedAttribute(): void + { + $model = new TestEloquentModelWithCustomCast([ + 'address_line_one' => '110 Kingsbrook St.', + 'address_line_two' => 'My Childhood House', + ]); + $model->address->lineOne = 'Changed St.'; + + $this->assertSame('Changed St. (My Childhood House)', $model->address_string); + } + + public function testMutatorCanDependOnAnotherCastedCarbonAttribute(): void + { + $model = new TestEloquentModelWithCustomCast([ + 'dob' => '2000-11-11', + 'tob' => '2000-11-11 11:11:00', + ]); + + $model->dob->addDay(); + + $this->assertSame('2000-11-12 11:11:00', $model->tob); + } } class TestEloquentModelWithCustomCast extends Model @@ -296,6 +321,7 @@ class TestEloquentModelWithCustomCast extends Model protected array $guarded = []; protected array $casts = [ + 'dob' => DOBCaster::class, 'address' => AddressCaster::class, 'price' => DecimalCaster::class, 'password' => HashCaster::class, @@ -311,6 +337,53 @@ class TestEloquentModelWithCustomCast extends Model 'anniversary_on_with_object_caching' => DateTimezoneCasterWithObjectCaching::class . ':America/New_York', 'anniversary_on_without_object_caching' => DateTimezoneCasterWithoutObjectCaching::class . ':America/New_York', ]; + + protected function getTobAttribute(?string $value): ?string + { + if ($value === null) { + return null; + } + + if (isset($this->attributes['dob'])) { + return Carbon::parse($this->attributes['dob'])->toDateString() . ' ' + . Carbon::parse($value)->toTimeString(); + } + + return Carbon::parse($value)->toDateTimeString(); + } + + /** + * Get the address string attribute. + */ + protected function addressString(): Attribute + { + return Attribute::get(function (): string { + $address = $this->address; + + if (! $address instanceof Address) { + throw new RuntimeException('Address was not cast before mutator access.'); + } + + return "{$address->lineOne} ({$address->lineTwo})"; + }); + } +} + +class DOBCaster implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): ?Carbon + { + return $value === null ? null : Carbon::parse($value); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): array + { + if ($value instanceof Carbon) { + return [$key => $value->toDateString()]; + } + + return [$key => $value === null ? null : (string) $value]; + } } class HashCaster implements CastsInboundAttributes diff --git a/tests/Integration/Database/EloquentModelEncryptedCastingTest.php b/tests/Integration/Database/EloquentModelEncryptedCastingTest.php index 14d9add69..d1bf8586d 100644 --- a/tests/Integration/Database/EloquentModelEncryptedCastingTest.php +++ b/tests/Integration/Database/EloquentModelEncryptedCastingTest.php @@ -191,7 +191,7 @@ public function testAsEncryptedCollection() ->with('{"key1":"value1"}') ->andReturn('encrypted-secret-collection-string-1'); $this->encrypter->expects('encryptString') - ->times(10) + ->times(9) ->with('{"key1":"value1","key2":"value2"}') ->andReturn('encrypted-secret-collection-string-2'); $this->encrypter->expects('decryptString') @@ -241,7 +241,7 @@ public function testAsEncryptedCollectionMap() ->with('[{"key1":"value1"}]') ->andReturn('encrypted-secret-collection-string-1'); $this->encrypter->expects('encryptString') - ->times(12) + ->times(11) ->with('[{"key1":"value1"},{"key2":"value2"}]') ->andReturn('encrypted-secret-collection-string-2'); $this->encrypter->expects('decryptString') @@ -297,7 +297,7 @@ public function testAsEncryptedArrayObject() ->with('encrypted-secret-array-string-1') ->andReturn('{"key1":"value1"}'); $this->encrypter->expects('encryptString') - ->times(10) + ->times(9) ->with('{"key1":"value1","key2":"value2"}') ->andReturn('encrypted-secret-array-string-2'); $this->encrypter->expects('decryptString') @@ -325,7 +325,7 @@ public function testAsEncryptedArrayObject() $subject = $subject->fresh(); $this->assertInstanceOf(ArrayObject::class, $subject->secret_array); - $this->assertSame('value1', $subject->secret_array['key1']); + $this->assertSame('value1', $subject->secret_array->key1); $this->assertSame('value2', $subject->secret_array['key2']); $subject->secret_array = null; From bb4934f7b7370610dfbf992726154c100484e1a3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:10:26 +0000 Subject: [PATCH 13/26] feat(database): complete Eloquent model lifecycle and APIs Serialize only incomplete first model boot publication with an exact coroutine owner and the existing model-class mutex. Permit post-publication recursion, retry failed pre-publication boot, keep successful publication after later hook failure, and leave the stable construction path at one static owner lookup. Move Scout model-dependent setup after publication and remove NestedSet model construction from boot. Resolve class attributes carried by traits and add RouteKey support without changing explicit overrides. Port model increment and decrement collections, quiet variants, truthful event-veto returns, and saveOrIgnore with conflict-safe state publication. Cover recursive, concurrent, non-coroutine, failed, and post-publication boot together with trait attributes, route keys, model events, casts, IDs, timestamps, and conflict behavior. --- .../src/Eloquent/Attributes/RouteKey.php | 19 + src/database/src/Eloquent/Model.php | 280 ++++++++++- src/nested-set/src/HasNode.php | 9 +- src/scout/src/Searchable.php | 6 +- .../DatabaseEloquentModelAttributesTest.php | 51 ++ tests/Database/DatabaseEloquentModelTest.php | 451 ++++++++++++++++++ .../Eloquent/ModelBootNonCoroutineTest.php | 42 ++ tests/Database/Eloquent/ModelBootTest.php | 288 +++++++++++ tests/NestedSet/NestedSetTest.php | 18 + tests/Scout/Feature/SearchableModelTest.php | 16 + 10 files changed, 1166 insertions(+), 14 deletions(-) create mode 100644 src/database/src/Eloquent/Attributes/RouteKey.php create mode 100644 tests/Database/Eloquent/ModelBootNonCoroutineTest.php create mode 100644 tests/Database/Eloquent/ModelBootTest.php diff --git a/src/database/src/Eloquent/Attributes/RouteKey.php b/src/database/src/Eloquent/Attributes/RouteKey.php new file mode 100644 index 000000000..af0222ba9 --- /dev/null +++ b/src/database/src/Eloquent/Attributes/RouteKey.php @@ -0,0 +1,19 @@ +, int> + */ + protected static array $booting = []; + /** * The array of booted models. * @@ -313,22 +324,67 @@ public function __construct(array $attributes = []) */ protected function bootIfNotBooted(): void { - if (! isset(static::$booted[static::class])) { - static::$booted[static::class] = true; + $class = static::class; + + if (isset(static::$booted[$class]) && ! isset(static::$booting[$class])) { + return; + } + + $coroutineId = Coroutine::id(); + + if ((static::$booting[$class] ?? null) === $coroutineId) { + if (isset(static::$booted[$class])) { + return; + } + + throw new LogicException( + 'The [' . __METHOD__ . '] method may not be called on model [' . $class . '] while it is being booted.' + ); + } + + $mutexKey = 'database.model.booting.' . $class; + $locked = false; + + if ($coroutineId >= 0) { + if (! Mutex::lock($mutexKey)) { + throw new RuntimeException("Unable to acquire the model boot lock for [{$class}]."); + } + + $locked = true; + } + + try { + // A sibling may have completed publication while this coroutine waited. + if (isset(static::$booted[$class])) { + return; + } + + static::$booting[$class] = $coroutineId; $this->fireModelEvent('booting', false); static::booting(); static::boot(); + + static::$booted[$class] = true; + static::booted(); - static::$bootedCallbacks[static::class] ??= []; + static::$bootedCallbacks[$class] ??= []; - foreach (static::$bootedCallbacks[static::class] as $callback) { + foreach (static::$bootedCallbacks[$class] as $callback) { $callback(); } $this->fireModelEvent('booted', false); + } finally { + if ((static::$booting[$class] ?? null) === $coroutineId) { + unset(static::$booting[$class]); + } + + if ($locked) { + Mutex::unlock($mutexKey); + } } } @@ -454,6 +510,7 @@ protected static function whenBooted(Closure $callback): void */ public static function clearBootedModels(): void { + static::$booting = []; static::$booted = []; static::$bootedCallbacks = []; static::$classAttributes = []; @@ -619,6 +676,11 @@ public static function handleMissingAttributeViolationUsing(?callable $callback) /** * Execute a callback without broadcasting any model events for all model types. + * + * @template TReturn + * + * @param callable(): TReturn $callback + * @return TReturn */ public static function withoutBroadcasting(callable $callback): mixed { @@ -1016,7 +1078,7 @@ public function loadMorphAvg(string $relation, array $relations, string $column) * * @param array $extra */ - protected function increment(string $column, mixed $amount = 1, array $extra = []): int + protected function increment(string $column, mixed $amount = 1, array $extra = []): int|false { return $this->incrementOrDecrement($column, $amount, $extra, 'increment'); } @@ -1026,7 +1088,7 @@ protected function increment(string $column, mixed $amount = 1, array $extra = [ * * @param array $extra */ - protected function decrement(string $column, mixed $amount = 1, array $extra = []): int + protected function decrement(string $column, mixed $amount = 1, array $extra = []): int|false { return $this->incrementOrDecrement($column, $amount, $extra, 'decrement'); } @@ -1136,6 +1198,104 @@ protected function decrementQuietly(string $column, float|int $amount = 1, array ); } + /** + * Increment each given column's value by the given amounts. + * + * @param array $columns + * @param array $extra + */ + protected function incrementEach(array $columns, array $extra = []): int|false + { + return $this->incrementOrDecrementEach($columns, $extra, 'incrementEach'); + } + + /** + * Decrement each given column's value by the given amounts. + * + * @param array $columns + * @param array $extra + */ + protected function decrementEach(array $columns, array $extra = []): int|false + { + return $this->incrementOrDecrementEach($columns, $extra, 'decrementEach'); + } + + /** + * Increment each given column's value by the given amounts without raising any events. + * + * @param array $columns + * @param array $extra + */ + protected function incrementEachQuietly(array $columns, array $extra = []): int|false + { + return static::withoutEvents( + fn () => $this->incrementOrDecrementEach($columns, $extra, 'incrementEach') + ); + } + + /** + * Decrement each given column's value by the given amounts without raising any events. + * + * @param array $columns + * @param array $extra + */ + protected function decrementEachQuietly(array $columns, array $extra = []): int|false + { + return static::withoutEvents( + fn () => $this->incrementOrDecrementEach($columns, $extra, 'decrementEach') + ); + } + + /** + * Run the incrementEach or decrementEach method on the model. + * + * @param array $columns + * @param array $extra + */ + protected function incrementOrDecrementEach(array $columns, array $extra, string $method): int|false + { + if (! $this->exists) { + return $this->newQueryWithoutRelationships()->{$method}($columns, $extra); + } + + $isIncrement = $method === 'incrementEach'; + $singleMethod = $isIncrement ? 'increment' : 'decrement'; + + foreach ($columns as $column => $amount) { + $this->{$column} = $this->isClassDeviable($column) + ? $this->deviateClassCastableAttribute($singleMethod, $column, $amount) + : $this->{$column} + ($isIncrement ? $amount : $amount * -1); + } + + $this->forceFill($extra); + + if ($this->fireModelEvent('updating') === false) { + return false; + } + + $dbColumns = $columns; + + foreach ($dbColumns as $column => $amount) { + if ($this->isClassDeviable($column)) { + $dbColumns[$column] = (clone $this) + ->setAttribute($column, $amount) + ->getAttributeFromArray($column); + } + } + + return tap( + $this->setKeysForSaveQuery($this->newQueryWithoutScopes()) + ->{$method}($dbColumns, $extra), + function () use ($columns): void { + $this->syncChanges(); + + $this->fireModelEvent('updated', false); + + $this->syncOriginalAttributes(array_keys($columns)); + } + ); + } + /** * Save the model and all of its relationships. */ @@ -1230,6 +1390,38 @@ public function save(array $options = []): bool return $saved; } + /** + * Save the model to the database, ignoring specific unique constraint conflicts. + * + * @param array $options + */ + public function saveOrIgnore(array $options = [], array|string|null $uniqueBy = null): bool + { + if ($this->exists) { + throw new LogicException('Cannot use saveOrIgnore on an existing model.'); + } + + $this->mergeAttributesFromCachedCasts(); + + $query = $this->newModelQuery(); + + if ($this->fireModelEvent('saving') === false) { + return false; + } + + $saved = $this->performInsertOrIgnore($query, $uniqueBy); + + if ($this->getConnectionName() === null) { + $this->setConnection($query->getConnection()->getName()); + } + + if ($saved) { + $this->finishSave($options); + } + + return $saved; + } + /** * Save the model to the database within a transaction. * @@ -1391,6 +1583,56 @@ protected function performInsert(Builder $query): bool return true; } + /** + * Perform a model insert operation, ignoring specific unique constraint conflicts. + * + * @param Builder $query + */ + protected function performInsertOrIgnore(Builder $query, array|string|null $uniqueBy): bool + { + if ($this->usesUniqueIds()) { + $this->setUniqueIds(); + } + + if ($this->fireModelEvent('creating') === false) { + return false; + } + + if ($this->usesTimestamps()) { + $this->updateTimestamps(); + } + + $attributes = $this->getAttributesForInsert(); + + if ($attributes === []) { + return true; + } + + $result = $query->toBase()->insertOrIgnoreReturning( + $attributes, + ['*'], + $uniqueBy + ); + + if ($result->isEmpty()) { + return false; + } + + if ($this->getIncrementing()) { + $this->setAttribute( + $keyName = $this->getKeyName(), + $result->first()->{$keyName} + ); + } + + $this->exists = true; + $this->wasRecentlyCreated = true; + + $this->fireModelEvent('created', false); + + return true; + } + /** * Insert the given attributes and set the ID on the model. * @@ -1708,9 +1950,14 @@ public function callNamedScope(string $scope, array $parameters = []): mixed */ protected static function isScopeMethodWithAttribute(string $method): bool { - return method_exists(static::class, $method) - && (new ReflectionMethod(static::class, $method)) - ->getAttributes(LocalScope::class) !== []; + if (method_exists(static::class, $method)) { + $reflection = new ReflectionMethod(static::class, $method); + + return ! $reflection->isPrivate() + && $reflection->getAttributes(LocalScope::class) !== []; + } + + return false; } /** @@ -1951,6 +2198,15 @@ protected static function resolveClassAttribute(string $attributeClass, ?string $instance = $attributes[0]->newInstance(); break; } + + foreach ($reflection->getTraits() as $trait) { + $attributes = $trait->getAttributes($attributeClass); + + if ($attributes !== []) { + $instance = $attributes[0]->newInstance(); + break 2; + } + } } while ($reflection = $reflection->getParentClass()); static::$classAttributes[$cacheKey] = $instance; @@ -2116,7 +2372,8 @@ public function getRouteKey(): mixed */ public function getRouteKeyName(): string { - return $this->getKeyName(); + return static::resolveClassAttribute(RouteKey::class, 'key') + ?? $this->getKeyName(); } /** @@ -2304,6 +2561,7 @@ public static function flushState(): void { static::$resolver = null; static::$dispatcher = null; + static::$booting = []; static::$booted = []; static::$bootedCallbacks = []; static::$traitInitializers = []; @@ -2428,7 +2686,7 @@ public function __unset(string $key): void */ public function __call(string $method, array $parameters): mixed { - if (in_array($method, ['increment', 'decrement', 'incrementQuietly', 'decrementQuietly'])) { + if (in_array($method, ['increment', 'decrement', 'incrementQuietly', 'decrementQuietly', 'incrementEach', 'decrementEach', 'incrementEachQuietly', 'decrementEachQuietly'])) { return $this->{$method}(...$parameters); } diff --git a/src/nested-set/src/HasNode.php b/src/nested-set/src/HasNode.php index cd20dfc2d..b6eaa57af 100644 --- a/src/nested-set/src/HasNode.php +++ b/src/nested-set/src/HasNode.php @@ -9,6 +9,7 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsTo; use Hypervel\Database\Eloquent\Relations\HasMany; +use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Query\Builder as BaseQueryBuilder; use Hypervel\NestedSet\Eloquent\AncestorsRelation; use Hypervel\NestedSet\Eloquent\Collection; @@ -53,6 +54,8 @@ public function newEloquentBuilder(BaseQueryBuilder $query): QueryBuilder */ public static function bootHasNode(): void { + // Keep event registration in the boot phase; soft-delete detection avoids + // constructing a model before boot publication. static::saving(fn ($model) => $model->callPendingActions()); static::deleting(fn ($model) => $model->refreshNode()); @@ -106,7 +109,11 @@ public static function usesSoftDelete(): bool return static::$hasSoftDelete; } - return static::$hasSoftDelete = method_exists(new static, 'bootSoftDeletes'); + return static::$hasSoftDelete = in_array( + SoftDeletes::class, + class_uses_recursive(static::class), + true, + ); } protected function actionRaw(): bool diff --git a/src/scout/src/Searchable.php b/src/scout/src/Searchable.php index 38ae68647..156cec062 100644 --- a/src/scout/src/Searchable.php +++ b/src/scout/src/Searchable.php @@ -44,9 +44,11 @@ public static function bootSearchable(): void { static::addGlobalScope(new SearchableScope); - static::observe(ModelObserver::class); + static::whenBooted(function (): void { + static::observe(ModelObserver::class); - (new static)->registerSearchableMacros(); + (new static)->registerSearchableMacros(); + }); } /** diff --git a/tests/Database/DatabaseEloquentModelAttributesTest.php b/tests/Database/DatabaseEloquentModelAttributesTest.php index f0221b79d..3e15a81fd 100644 --- a/tests/Database/DatabaseEloquentModelAttributesTest.php +++ b/tests/Database/DatabaseEloquentModelAttributesTest.php @@ -75,6 +75,27 @@ public function testChildInheritsParentTableAttribute(): void $this->assertSame('parent_attr', $model->getTable()); } + public function testTableAttributeIsResolvedFromATrait(): void + { + $model = new ModelWithTableAttributeTrait; + + $this->assertSame('trait_table', $model->getTable()); + } + + public function testChildInheritsTableAttributeFromAParentTrait(): void + { + $model = new ChildModelWithParentTableAttributeTrait; + + $this->assertSame('parent_trait_table', $model->getTable()); + } + + public function testClassTableAttributeTakesPrecedenceOverTraitAttribute(): void + { + $model = new ModelWithClassAndTraitTableAttributes; + + $this->assertSame('class_table', $model->getTable()); + } + public function testChildTablePropertyOverridesParentTableAttribute(): void { $model = new ChildModelWithTableProperty; @@ -540,6 +561,36 @@ class ChildModelWithNoTable extends ParentModelWithTableAttribute { } +#[Table(name: 'trait_table')] +trait ModelTableAttributeTrait +{ +} + +class ModelWithTableAttributeTrait extends Model +{ + use ModelTableAttributeTrait; +} + +#[Table(name: 'parent_trait_table')] +trait ParentModelTableAttributeTrait +{ +} + +class ParentModelWithTableAttributeTrait extends Model +{ + use ParentModelTableAttributeTrait; +} + +class ChildModelWithParentTableAttributeTrait extends ParentModelWithTableAttributeTrait +{ +} + +#[Table(name: 'class_table')] +class ModelWithClassAndTraitTableAttributes extends Model +{ + use ModelTableAttributeTrait; +} + class ChildModelWithTableProperty extends ParentModelWithTableAttribute { protected ?string $table = 'child_prop'; diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index f85318a43..5f9349566 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -21,6 +21,7 @@ use Hypervel\Database\ConnectionResolverInterface as Resolver; use Hypervel\Database\Eloquent\Attributes\CollectedBy; use Hypervel\Database\Eloquent\Attributes\ObservedBy; +use Hypervel\Database\Eloquent\Attributes\RouteKey; use Hypervel\Database\Eloquent\Attributes\UseFactory; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Casts\ArrayObject; @@ -1128,6 +1129,142 @@ public function testInsertIsCanceledIfCreatingEventReturnsFalse() $this->assertFalse($model->exists); } + public function testInsertOrIgnoreProcessWithIncrementing(): void + { + $model = $this->getMockBuilder(ModelStub::class) + ->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh']) + ->getMock(); + $query = m::mock(Builder::class); + $baseQuery = m::mock(BaseBuilder::class); + $query->shouldReceive('toBase')->once()->andReturn($baseQuery); + $baseQuery->shouldReceive('insertOrIgnoreReturning') + ->once() + ->with(['name' => 'taylor'], ['*'], null) + ->andReturn(new BaseCollection([(object) ['id' => 1, 'name' => 'taylor']])); + $query->shouldReceive('getConnection') + ->once() + ->andReturn(m::mock(ConnectionInterface::class, ['getName' => 'default'])); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->expects($this->once())->method('updateTimestamps'); + + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until')->once()->with('eloquent.saving: ' . get_class($model), $model)->andReturn(true); + $events->shouldReceive('until')->once()->with('eloquent.creating: ' . get_class($model), $model)->andReturn(true); + $events->shouldReceive('dispatch')->once()->with('eloquent.created: ' . get_class($model), $model); + $events->shouldReceive('dispatch')->once()->with('eloquent.saved: ' . get_class($model), $model); + + $model->name = 'taylor'; + $model->exists = false; + + $this->assertTrue($model->saveOrIgnore()); + $this->assertSame(1, $model->id); + $this->assertTrue($model->exists); + $this->assertTrue($model->wasRecentlyCreated); + } + + public function testInsertOrIgnoreProcessWithConflict(): void + { + $model = $this->getMockBuilder(ModelStub::class) + ->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh']) + ->getMock(); + $query = m::mock(Builder::class); + $baseQuery = m::mock(BaseBuilder::class); + $query->shouldReceive('toBase')->once()->andReturn($baseQuery); + $baseQuery->shouldReceive('insertOrIgnoreReturning') + ->once() + ->with(['name' => 'taylor'], ['*'], null) + ->andReturn(new BaseCollection); + $query->shouldReceive('getConnection') + ->once() + ->andReturn(m::mock(ConnectionInterface::class, ['getName' => 'default'])); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->expects($this->once())->method('updateTimestamps'); + + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until')->once()->with('eloquent.saving: ' . get_class($model), $model)->andReturn(true); + $events->shouldReceive('until')->once()->with('eloquent.creating: ' . get_class($model), $model)->andReturn(true); + + $model->name = 'taylor'; + $model->exists = false; + + $this->assertFalse($model->saveOrIgnore()); + $this->assertFalse($model->exists); + $this->assertFalse($model->wasRecentlyCreated); + } + + public function testInsertOrIgnoreProcessWithNonIncrementing(): void + { + $model = $this->getMockBuilder(ModelStub::class) + ->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh']) + ->getMock(); + $query = m::mock(Builder::class); + $baseQuery = m::mock(BaseBuilder::class); + $query->shouldReceive('toBase')->once()->andReturn($baseQuery); + $baseQuery->shouldReceive('insertOrIgnoreReturning') + ->once() + ->with(['name' => 'taylor'], ['*'], null) + ->andReturn(new BaseCollection([(object) ['name' => 'taylor']])); + $query->shouldReceive('getConnection') + ->once() + ->andReturn(m::mock(ConnectionInterface::class, ['getName' => 'default'])); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->expects($this->once())->method('updateTimestamps'); + $model->setIncrementing(false); + + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until')->once()->with('eloquent.saving: ' . get_class($model), $model)->andReturn(true); + $events->shouldReceive('until')->once()->with('eloquent.creating: ' . get_class($model), $model)->andReturn(true); + $events->shouldReceive('dispatch')->once()->with('eloquent.created: ' . get_class($model), $model); + $events->shouldReceive('dispatch')->once()->with('eloquent.saved: ' . get_class($model), $model); + + $model->name = 'taylor'; + $model->exists = false; + + $this->assertTrue($model->saveOrIgnore()); + $this->assertNull($model->id); + $this->assertTrue($model->exists); + $this->assertTrue($model->wasRecentlyCreated); + } + + public function testInsertOrIgnoreProcessWithNamedUnique(): void + { + $model = $this->getMockBuilder(ModelStub::class) + ->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh']) + ->getMock(); + $query = m::mock(Builder::class); + $baseQuery = m::mock(BaseBuilder::class); + $query->shouldReceive('toBase')->once()->andReturn($baseQuery); + $baseQuery->shouldReceive('insertOrIgnoreReturning') + ->once() + ->with(['name' => 'taylor'], ['*'], ['name']) + ->andReturn(new BaseCollection); + $query->shouldReceive('getConnection') + ->once() + ->andReturn(m::mock(ConnectionInterface::class, ['getName' => 'default'])); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->expects($this->once())->method('updateTimestamps'); + + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until')->once()->with('eloquent.saving: ' . get_class($model), $model)->andReturn(true); + $events->shouldReceive('until')->once()->with('eloquent.creating: ' . get_class($model), $model)->andReturn(true); + + $model->name = 'taylor'; + $model->exists = false; + + $this->assertFalse($model->saveOrIgnore([], ['name'])); + $this->assertFalse($model->exists); + $this->assertFalse($model->wasRecentlyCreated); + } + + public function testInsertOrIgnoreThrowsOnExistingModel(): void + { + $this->expectException(LogicException::class); + + $model = new ModelStub; + $model->exists = true; + $model->saveOrIgnore(); + } + public function testDeleteProperlyDeletesModel() { $model = $this->getMockBuilder(Model::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'touchOwners'])->getMock(); @@ -2648,6 +2785,253 @@ public function testDecrementQuietlyOnExistingModelCallsQueryAndSetsAttributeAnd $this->assertTrue($model->isDirty('category')); } + public function testIncrementReturnsFalseWhenUpdatingEventIsCancelled(): void + { + $model = m::mock(ModelStub::class . '[newQueryWithoutScopes]'); + $model->exists = true; + $model->foo = 1; + $model->shouldNotReceive('newQueryWithoutScopes'); + + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until') + ->once() + ->with('eloquent.updating: ' . get_class($model), $model) + ->andReturn(false); + + $this->assertFalse($model->publicIncrement('foo')); + $this->assertSame(2, $model->foo); + } + + public function testIncrementEachOnExistingModelScopesQueryToModelKey(): void + { + $model = m::mock(ModelStub::class . '[newQueryWithoutScopes]'); + $model->exists = true; + $model->id = 1; + $model->syncOriginalAttribute('id'); + $model->foo = 2; + $model->bar = 5; + + $model->shouldReceive('newQueryWithoutScopes')->andReturn($query = m::mock(Builder::class)); + $query->shouldReceive('where')->once()->with('id', '=', 1)->andReturnSelf(); + $query->shouldReceive('incrementEach') + ->once() + ->with(['foo' => 1, 'bar' => 2], []) + ->andReturn(1); + + $result = $model->publicIncrementEach(['foo' => 1, 'bar' => 2]); + + $this->assertSame(1, $result); + $this->assertSame(3, $model->foo); + $this->assertSame(7, $model->bar); + } + + public function testDecrementEachOnExistingModelScopesQueryToModelKey(): void + { + $model = m::mock(ModelStub::class . '[newQueryWithoutScopes]'); + $model->exists = true; + $model->id = 1; + $model->syncOriginalAttribute('id'); + $model->foo = 10; + $model->bar = 5; + + $model->shouldReceive('newQueryWithoutScopes')->andReturn($query = m::mock(Builder::class)); + $query->shouldReceive('where')->once()->with('id', '=', 1)->andReturnSelf(); + $query->shouldReceive('decrementEach') + ->once() + ->with(['foo' => 3, 'bar' => 2], []) + ->andReturn(1); + + $result = $model->publicDecrementEach(['foo' => 3, 'bar' => 2]); + + $this->assertSame(1, $result); + $this->assertSame(7, $model->foo); + $this->assertSame(3, $model->bar); + } + + public function testIncrementEachQuietlyUpdatesTheModelWithoutEvents(): void + { + $model = m::mock(ModelStub::class . '[newQueryWithoutScopes]'); + $model->exists = true; + $model->id = 1; + $model->syncOriginalAttribute('id'); + $model->foo = 2; + $model->bar = 5; + + $model->shouldReceive('newQueryWithoutScopes')->andReturn($query = m::mock(Builder::class)); + $query->shouldReceive('where')->andReturnSelf(); + $query->shouldReceive('incrementEach')->twice()->andReturn(1); + + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldNotReceive('until'); + $events->shouldNotReceive('dispatch'); + + $model->publicIncrementEachQuietly(['foo' => 1, 'bar' => 2]); + + $this->assertSame(3, $model->foo); + $this->assertSame(7, $model->bar); + $this->assertFalse($model->isDirty()); + + $model->publicIncrementEachQuietly(['foo' => 1], ['category' => 1]); + + $this->assertSame(4, $model->foo); + $this->assertSame(1, $model->category); + $this->assertTrue($model->isDirty('category')); + } + + public function testDecrementEachQuietlyUpdatesTheModelWithoutEvents(): void + { + $model = m::mock(ModelStub::class . '[newQueryWithoutScopes]'); + $model->exists = true; + $model->id = 1; + $model->syncOriginalAttribute('id'); + $model->foo = 10; + $model->bar = 5; + + $model->shouldReceive('newQueryWithoutScopes')->andReturn($query = m::mock(Builder::class)); + $query->shouldReceive('where')->andReturnSelf(); + $query->shouldReceive('decrementEach')->twice()->andReturn(1); + + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldNotReceive('until'); + $events->shouldNotReceive('dispatch'); + + $model->publicDecrementEachQuietly(['foo' => 3, 'bar' => 2]); + + $this->assertSame(7, $model->foo); + $this->assertSame(3, $model->bar); + $this->assertFalse($model->isDirty()); + + $model->publicDecrementEachQuietly(['foo' => 1], ['category' => 1]); + + $this->assertSame(6, $model->foo); + $this->assertSame(1, $model->category); + $this->assertTrue($model->isDirty('category')); + } + + public function testIncrementEachQuietlyCanBeCalledDynamicallyOnModelInstance(): void + { + $query = m::mock(Builder::class); + $query->shouldReceive('where')->once()->with('id', '=', 1)->andReturnSelf(); + $query->shouldReceive('incrementEach') + ->once() + ->with(['foo' => 1, 'bar' => 2], []) + ->andReturn(1); + + $model = new DynamicIncrementEachStub($query, ['foo' => 2, 'bar' => 5]); + $model->exists = true; + $model->id = 1; + $model->syncOriginalAttribute('id'); + + $model->incrementEachQuietly(['foo' => 1, 'bar' => 2]); + + $this->assertSame(3, $model->foo); + $this->assertSame(7, $model->bar); + } + + public function testDecrementEachQuietlyCanBeCalledDynamicallyOnModelInstance(): void + { + $query = m::mock(Builder::class); + $query->shouldReceive('where')->once()->with('id', '=', 1)->andReturnSelf(); + $query->shouldReceive('decrementEach') + ->once() + ->with(['foo' => 1, 'bar' => 2], []) + ->andReturn(1); + + $model = new DynamicIncrementEachStub($query, ['foo' => 5, 'bar' => 10]); + $model->exists = true; + $model->id = 1; + $model->syncOriginalAttribute('id'); + + $model->decrementEachQuietly(['foo' => 1, 'bar' => 2]); + + $this->assertSame(4, $model->foo); + $this->assertSame(8, $model->bar); + } + + public function testIncrementEachWithExtraColumnsOnExistingModel(): void + { + $model = m::mock(ModelStub::class . '[newQueryWithoutScopes]'); + $model->exists = true; + $model->id = 1; + $model->syncOriginalAttribute('id'); + $model->foo = 2; + + $model->shouldReceive('newQueryWithoutScopes')->andReturn($query = m::mock(Builder::class)); + $query->shouldReceive('where')->once()->with('id', '=', 1)->andReturnSelf(); + $query->shouldReceive('incrementEach') + ->once() + ->with(['foo' => 5], ['category' => 'test']) + ->andReturn(1); + + $result = $model->publicIncrementEach(['foo' => 5], ['category' => 'test']); + + $this->assertSame(1, $result); + $this->assertSame(7, $model->foo); + $this->assertSame('test', $model->category); + } + + public function testIncrementEachFiresModelEvents(): void + { + $model = m::mock(ModelStub::class . '[newQueryWithoutScopes]'); + $model->exists = true; + $model->id = 1; + $model->syncOriginalAttribute('id'); + $model->foo = 1; + + $model->shouldReceive('newQueryWithoutScopes')->andReturn($query = m::mock(Builder::class)); + $query->shouldReceive('where')->andReturnSelf(); + $query->shouldReceive('incrementEach')->andReturn(1); + + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until') + ->once() + ->with('eloquent.updating: ' . get_class($model), $model) + ->andReturn(true); + $events->shouldReceive('dispatch') + ->once() + ->with('eloquent.updated: ' . get_class($model), $model); + + $model->publicIncrementEach(['foo' => 1]); + } + + public function testIncrementEachReturnsFalseWhenUpdatingEventIsCancelled(): void + { + $model = m::mock(ModelStub::class . '[newQueryWithoutScopes]'); + $model->exists = true; + $model->id = 1; + $model->syncOriginalAttribute('id'); + $model->foo = 1; + $model->shouldNotReceive('newQueryWithoutScopes'); + + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until') + ->once() + ->with('eloquent.updating: ' . get_class($model), $model) + ->andReturn(false); + + $result = $model->publicIncrementEach(['foo' => 1]); + + $this->assertFalse($result); + $this->assertSame(2, $model->foo); + } + + public function testIncrementEachOnNonExistingModelForwardsToQueryBuilder(): void + { + $model = m::mock(ModelStub::class . '[newQueryWithoutRelationships]'); + $model->exists = false; + + $model->shouldReceive('newQueryWithoutRelationships') + ->andReturn($query = m::mock(Builder::class)); + $query->shouldReceive('incrementEach') + ->once() + ->with(['foo' => 1], []) + ->andReturn(5); + + $result = $model->publicIncrementEach(['foo' => 1]); + + $this->assertSame(5, $result); + } + public function testRelationshipTouchOwnersIsPropagated() { $relation = $this->getMockBuilder(BelongsTo::class)->onlyMethods(['touch'])->disableOriginalConstructor()->getMock(); @@ -3447,6 +3831,7 @@ public function testFlushStateRestoresStaticState() Model::$snakeAttributes = false; Model::encryptUsing(m::mock(Encrypter::class)); + $reflection->setStaticPropertyValue('booting', [ModelStub::class => 1]); $reflection->setStaticPropertyValue('booted', [ModelStub::class => true]); $reflection->setStaticPropertyValue('bootedCallbacks', [ModelStub::class => [static function () { }]]); @@ -3473,6 +3858,7 @@ public function testFlushStateRestoresStaticState() $this->assertNull(Model::getConnectionResolver()); $this->assertNull($reflection->getStaticPropertyValue('dispatcher')); + $this->assertSame([], $reflection->getStaticPropertyValue('booting')); $this->assertSame([], $reflection->getStaticPropertyValue('booted')); $this->assertSame([], $reflection->getStaticPropertyValue('bootedCallbacks')); $this->assertSame([], $reflection->getStaticPropertyValue('traitInitializers')); @@ -4032,6 +4418,20 @@ public function testDefaultBuilderIsUsedWhenUseEloquentBuilderAttributeIsNotPres $this->assertNotInstanceOf(CustomBuilder::class, $eloquentBuilder); } + + public function testRouteKeyCanBeResolvedFromAttribute(): void + { + $model = new ModelWithRouteKeyAttributeStub; + + $this->assertSame('slug', $model->getRouteKeyName()); + } + + public function testRouteKeyAttributeIsInherited(): void + { + $model = new ModelInheritingRouteKeyAttributeStub; + + $this->assertSame('slug', $model->getRouteKeyName()); + } } class CustomBuilder extends Builder @@ -4047,6 +4447,15 @@ class ModelWithoutUseEloquentBuilderAttributeStub extends Model { } +#[RouteKey('slug')] +class ModelWithRouteKeyAttributeStub extends Model +{ +} + +class ModelInheritingRouteKeyAttributeStub extends ModelWithRouteKeyAttributeStub +{ +} + class TestObserverStub { public function creating() @@ -4127,6 +4536,26 @@ public function publicDecrementQuietly($column, $amount = 1, $extra = []) return $this->decrementQuietly($column, $amount, $extra); } + public function publicIncrementEach(array $columns, array $extra = []) + { + return $this->incrementEach($columns, $extra); + } + + public function publicDecrementEach(array $columns, array $extra = []) + { + return $this->decrementEach($columns, $extra); + } + + public function publicIncrementEachQuietly(array $columns, array $extra = []) + { + return $this->incrementEachQuietly($columns, $extra); + } + + public function publicDecrementEachQuietly(array $columns, array $extra = []) + { + return $this->decrementEachQuietly($columns, $extra); + } + public function belongsToStub() { return $this->belongsTo(SaveStub::class); @@ -4986,6 +5415,28 @@ class CastCacheInvalidClassStub extends Model protected array $casts = ['foo' => '\This\Class\Does\Not\Exist']; } +class DynamicIncrementEachStub extends Model +{ + protected array $guarded = []; + + public function __construct( + public Builder $queryStub, + array $attributes = [], + ) { + parent::__construct($attributes); + } + + public function newQueryWithoutScopes(): Builder + { + return $this->queryStub; + } + + public function newQuery(): Builder + { + return $this->queryStub; + } +} + enum ConnectionName { case Foo; diff --git a/tests/Database/Eloquent/ModelBootNonCoroutineTest.php b/tests/Database/Eloquent/ModelBootNonCoroutineTest.php new file mode 100644 index 000000000..cfbb464a9 --- /dev/null +++ b/tests/Database/Eloquent/ModelBootNonCoroutineTest.php @@ -0,0 +1,42 @@ +assertSame(-1, Coroutine::id()); + $this->assertSame(1, NonCoroutineBootModel::$bootCalls); + $this->assertSame( + [], + (new ReflectionProperty(Mutex::class, 'channels'))->getValue() + ); + } +} + +class NonCoroutineBootModel extends Model +{ + public static int $bootCalls = 0; + + protected static function booting(): void + { + ++static::$bootCalls; + } +} diff --git a/tests/Database/Eloquent/ModelBootTest.php b/tests/Database/Eloquent/ModelBootTest.php new file mode 100644 index 000000000..6a391fe6b --- /dev/null +++ b/tests/Database/Eloquent/ModelBootTest.php @@ -0,0 +1,288 @@ +fail('Expected recursive model boot to fail.'); + } catch (LogicException $exception) { + $this->assertSame( + 'The [Hypervel\Database\Eloquent\Model::bootIfNotBooted] method may not be called on model [' + . RecursiveBootModel::class . '] while it is being booted.', + $exception->getMessage() + ); + } + + RecursiveBootModel::$recurse = false; + + $this->assertInstanceOf(RecursiveBootModel::class, new RecursiveBootModel); + $this->assertSame(2, RecursiveBootModel::$bootCalls); + } + + public function testSameOwnerCanConstructModelsAfterPublication(): void + { + new PostPublicationConstructionModel; + + $this->assertTrue(PostPublicationConstructionModel::$constructedInBooted); + $this->assertTrue(PostPublicationConstructionModel::$constructedInCallback); + $this->assertSame(1, PostPublicationConstructionModel::$bootCalls); + } + + public function testSiblingCoroutineWaitsForCompleteBootPublication(): void + { + ConcurrentBootModel::$bootStarted = new Channel(1); + ConcurrentBootModel::$continueBoot = new Channel(1); + $firstCompleted = new Channel(1); + $secondEntered = new Channel(1); + $secondCompleted = new Channel(1); + + $firstCoroutine = go(static function () use ($firstCompleted): void { + try { + new ConcurrentBootModel; + $firstCompleted->push(true); + } catch (Throwable $throwable) { + $firstCompleted->push($throwable); + } + }); + + $this->assertTrue(ConcurrentBootModel::$bootStarted->pop(1.0)); + + $secondCoroutine = go(static function () use ($secondEntered, $secondCompleted): void { + $secondEntered->push(true); + + try { + new ConcurrentBootModel; + $secondCompleted->push(true); + } catch (Throwable $throwable) { + $secondCompleted->push($throwable); + } + }); + + $this->assertTrue($secondEntered->pop(1.0)); + $this->assertFalse($secondCompleted->pop(0.01)); + + ConcurrentBootModel::$continueBoot->push(true); + + $this->assertTrue($firstCompleted->pop(1.0)); + $this->assertTrue($secondCompleted->pop(1.0)); + $this->assertFalse(Coroutine::exists($firstCoroutine)); + $this->assertFalse(Coroutine::exists($secondCoroutine)); + $this->assertSame(1, ConcurrentBootModel::$bootCalls); + } + + public function testBootedHookFailureLeavesTheModelPublishedAndClearsOwnership(): void + { + $failure = new RuntimeException('booted hook failure'); + PostPublicationFailureModel::$failurePhase = 'hook'; + PostPublicationFailureModel::$failure = $failure; + + try { + new PostPublicationFailureModel; + $this->fail('Expected the booted hook to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + PostPublicationFailureModel::$failurePhase = null; + + $this->assertInstanceOf(PostPublicationFailureModel::class, new PostPublicationFailureModel); + $this->assertSame(1, PostPublicationFailureModel::$bootCalls); + } + + public function testBootedCallbackFailureLeavesTheModelPublishedAndClearsOwnership(): void + { + $failure = new RuntimeException('booted callback failure'); + PostPublicationFailureModel::$failurePhase = 'callback'; + PostPublicationFailureModel::$failure = $failure; + + try { + new PostPublicationFailureModel; + $this->fail('Expected the booted callback to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + PostPublicationFailureModel::$failurePhase = null; + + $this->assertInstanceOf(PostPublicationFailureModel::class, new PostPublicationFailureModel); + $this->assertSame(1, PostPublicationFailureModel::$bootCalls); + } + + public function testBootedEventFailureLeavesTheModelPublishedAndClearsOwnership(): void + { + $failure = new RuntimeException('booted event failure'); + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with('eloquent.booting: ' . PostPublicationFailureModel::class, m::type(PostPublicationFailureModel::class)) + ->andReturnNull(); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with('eloquent.booted: ' . PostPublicationFailureModel::class, m::type(PostPublicationFailureModel::class)) + ->andThrow($failure); + Model::setEventDispatcher($dispatcher); + + try { + new PostPublicationFailureModel; + $this->fail('Expected the booted event to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } finally { + Model::unsetEventDispatcher(); + } + + $this->assertInstanceOf(PostPublicationFailureModel::class, new PostPublicationFailureModel); + $this->assertSame(1, PostPublicationFailureModel::$bootCalls); + } + + public function testClearBootedModelsClearsBootOwnership(): void + { + $booting = new ReflectionProperty(Model::class, 'booting'); + $booting->setValue(null, [RecursiveBootModel::class => Coroutine::id()]); + + Model::clearBootedModels(); + + $this->assertSame([], $booting->getValue()); + } +} + +class RecursiveBootModel extends Model +{ + public static bool $recurse = true; + + public static int $bootCalls = 0; + + protected static function booting(): void + { + ++static::$bootCalls; + + if (static::$recurse) { + new static; + } + } + + public static function reset(): void + { + static::$recurse = true; + static::$bootCalls = 0; + } +} + +class PostPublicationConstructionModel extends Model +{ + public static bool $constructedInBooted = false; + + public static bool $constructedInCallback = false; + + public static int $bootCalls = 0; + + protected static function boot(): void + { + ++static::$bootCalls; + parent::boot(); + + static::whenBooted(function (): void { + static::$constructedInCallback = new static instanceof static; + }); + } + + protected static function booted(): void + { + static::$constructedInBooted = new static instanceof static; + } + + public static function reset(): void + { + static::$constructedInBooted = false; + static::$constructedInCallback = false; + static::$bootCalls = 0; + } +} + +class ConcurrentBootModel extends Model +{ + public static ?Channel $bootStarted = null; + + public static ?Channel $continueBoot = null; + + public static int $bootCalls = 0; + + protected static function booting(): void + { + ++static::$bootCalls; + static::$bootStarted?->push(true); + static::$continueBoot?->pop(1.0); + } + + public static function reset(): void + { + static::$bootStarted = null; + static::$continueBoot = null; + static::$bootCalls = 0; + } +} + +class PostPublicationFailureModel extends Model +{ + public static ?string $failurePhase = null; + + public static ?RuntimeException $failure = null; + + public static int $bootCalls = 0; + + protected static function boot(): void + { + ++static::$bootCalls; + parent::boot(); + + static::whenBooted(static function (): void { + if (static::$failurePhase === 'callback') { + throw static::$failure; + } + }); + } + + protected static function booted(): void + { + if (static::$failurePhase === 'hook') { + throw static::$failure; + } + } + + public static function reset(): void + { + static::$failurePhase = null; + static::$failure = null; + static::$bootCalls = 0; + } +} diff --git a/tests/NestedSet/NestedSetTest.php b/tests/NestedSet/NestedSetTest.php index 35423f8b6..415a8f5fb 100644 --- a/tests/NestedSet/NestedSetTest.php +++ b/tests/NestedSet/NestedSetTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\NestedSet; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\NestedSet\HasNode; use Hypervel\NestedSet\NestedSet; use Hypervel\Tests\TestCase; @@ -17,6 +18,15 @@ public function testIsNodeReturnsTrueForModelUsingHasNode(): void $this->assertTrue(NestedSet::isNode(new NestedSetTestNodeModel)); } + public function testNodeBootDetectsSoftDeletesWithoutNestedModelConstruction(): void + { + $node = new NestedSetTestNodeModel; + $softDeletingNode = new NestedSetTestSoftDeletingNodeModel; + + $this->assertFalse($node::usesSoftDelete()); + $this->assertTrue($softDeletingNode::usesSoftDelete()); + } + public function testIsNodeReturnsFalseForPlainEloquentModel(): void { $this->assertFalse(NestedSet::isNode(new NestedSetTestPlainModel)); @@ -47,3 +57,11 @@ class NestedSetTestPlainModel extends Model { protected ?string $table = 'nested_set_test_plain'; } + +class NestedSetTestSoftDeletingNodeModel extends Model +{ + use SoftDeletes; + use HasNode; + + protected ?string $table = 'nested_set_test_soft_deleting_nodes'; +} diff --git a/tests/Scout/Feature/SearchableModelTest.php b/tests/Scout/Feature/SearchableModelTest.php index 533cb7d4e..c331b38e0 100644 --- a/tests/Scout/Feature/SearchableModelTest.php +++ b/tests/Scout/Feature/SearchableModelTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Scout\Feature; +use Hypervel\Support\Collection as BaseCollection; use Hypervel\Tests\Scout\Models\SearchableModel; use Hypervel\Tests\Scout\Models\SoftDeletableSearchableModel; use Hypervel\Tests\Scout\ScoutTestCase; @@ -11,6 +12,21 @@ class SearchableModelTest extends ScoutTestCase { + public function testSearchableBootDefersModelInstanceWorkUntilAfterPublication(): void + { + $model = new SearchableModel; + + $this->assertSame('searchable_models', $model->getTable()); + $this->assertTrue(BaseCollection::hasMacro('searchable')); + + $dispatcher = SearchableModel::getEventDispatcher(); + + $this->assertNotNull($dispatcher); + $this->assertTrue($dispatcher->hasListeners( + 'eloquent.saved: ' . SearchableModel::class + )); + } + public function testSearchReturnsBuilder() { $builder = SearchableModel::search('test'); From e02d0ef1084158d066f6521494de5283ccbae911 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:10:41 +0000 Subject: [PATCH 14/26] feat(database): complete Eloquent builder and relation parity Port closure-valued create and update defaults across builders and relations without evaluating them on already-found paths. Propagate removed nested scopes, reject private attributed scopes, touch exact related keys and multiple timestamp columns, and normalize MorphTo dictionary keys. Publish restored events only after a successful save, normalize enum model IDs at the exception string boundary, preserve class-string and callback inference, and guess resources from the complete nested model namespace. Merge the current unit and integration matrix for create-or-first families, relation updates, scope visibility, eager morph matching, soft-delete events, enum IDs, timestamps, and nested resource discovery. --- src/database/src/Eloquent/Builder.php | 53 ++++- .../src/Eloquent/Concerns/HasEvents.php | 5 + .../Eloquent/Concerns/HasRelationships.php | 14 ++ .../src/Eloquent/Concerns/HasTimestamps.php | 20 +- .../Concerns/TransformsToResource.php | 2 +- .../src/Eloquent/ModelNotFoundException.php | 9 +- .../src/Eloquent/Relations/BelongsToMany.php | 16 +- .../src/Eloquent/Relations/HasOneOrMany.php | 15 +- .../Relations/HasOneOrManyThrough.php | 8 +- .../src/Eloquent/Relations/MorphTo.php | 4 +- src/database/src/Eloquent/SoftDeletes.php | 4 +- ...EloquentBelongsToManyCreateOrFirstTest.php | 123 +++++++++++- ...tabaseEloquentBuilderCreateOrFirstTest.php | 185 +++++++++++++++++ .../Database/DatabaseEloquentBuilderTest.php | 189 +++++++++++++++++- .../DatabaseEloquentGlobalScopesTest.php | 37 ++++ ...tabaseEloquentHasManyCreateOrFirstTest.php | 101 +++++++++- ...loquentHasManyThroughCreateOrFirstTest.php | 15 +- .../Database/DatabaseEloquentRelationTest.php | 35 ++++ ...baseEloquentSoftDeletesIntegrationTest.php | 37 ++++ .../Concerns/TransformsToResourceTest.php | 11 + .../TransformsToResourceTestNestedModel.php | 12 ++ .../Database/EloquentBelongsToManyTest.php | 19 ++ .../Database/EloquentModelScopeTest.php | 13 ++ .../Database/EloquentMorphToEagerLoadTest.php | 153 ++++++++++++++ 24 files changed, 1026 insertions(+), 54 deletions(-) create mode 100644 tests/Database/Fixtures/Models/Administration/Billing/TransformsToResourceTestNestedModel.php create mode 100644 tests/Integration/Database/EloquentMorphToEagerLoadTest.php diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 6ff2db9b1..56fd70f3e 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -319,6 +319,9 @@ public function where(array|Closure|Expression|string $column, mixed $operator = $this->eagerLoad = array_merge($this->eagerLoad, $query->getEagerLoads()); + $this->withoutGlobalScopes( + $query->removedScopes() + ); $this->query->addNestedWhereQuery($query->getQuery(), $boolean); } else { $this->query->where(...func_get_args()); @@ -616,13 +619,13 @@ public function findOr(mixed $id, Closure|array|string $columns = ['*'], ?Closur * * @return TModel */ - public function firstOrNew(array $attributes = [], array $values = []): Model + public function firstOrNew(array $attributes = [], Closure|array $values = []): Model { if (! is_null($instance = $this->where($attributes)->first())) { return $instance; } - return $this->newModelInstance(array_merge($attributes, $values)); + return $this->newModelInstance(array_merge($attributes, value($values))); } /** @@ -630,7 +633,7 @@ public function firstOrNew(array $attributes = [], array $values = []): Model * * @return TModel */ - public function firstOrCreate(array $attributes = [], array $values = []): Model + public function firstOrCreate(array $attributes = [], Closure|array $values = []): Model { if (! is_null($instance = (clone $this)->where($attributes)->first())) { return $instance; @@ -644,10 +647,10 @@ public function firstOrCreate(array $attributes = [], array $values = []): Model * * @return TModel */ - public function createOrFirst(array $attributes = [], array $values = []): Model + public function createOrFirst(array $attributes = [], Closure|array $values = []): Model { try { - return $this->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, $values))); + return $this->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values)))); } catch (UniqueConstraintViolationException $e) { // @phpstan-ignore return.type (first() returns hydrated TModel, not stdClass) return $this->useWritePdo()->where($attributes)->first() ?? throw $e; @@ -659,11 +662,11 @@ public function createOrFirst(array $attributes = [], array $values = []): Model * * @return TModel */ - public function updateOrCreate(array $attributes, array $values = []): Model + public function updateOrCreate(array $attributes, Closure|array $values = []): Model { return tap($this->firstOrCreate($attributes, $values), function ($instance) use ($values) { if (! $instance->wasRecentlyCreated) { - $instance->fill($values)->save(); + $instance->fill(value($values))->save(); } }); } @@ -1150,12 +1153,16 @@ public function upsert(array $values, array|string $uniqueBy, ?array $update = n /** * Update the column's update timestamp. */ - public function touch(?string $column = null): false|int + public function touch(array|string|null $column = null): false|int { $time = $this->model->freshTimestamp(); if ($column) { - return $this->toBase()->update([$column => $time]); + $columns = (new BaseCollection(Arr::wrap($column))) + ->mapWithKeys(fn ($column) => [$column => $time]) + ->all(); + + return $this->toBase()->update($columns); } $column = $this->model->getUpdatedAtColumn(); @@ -1191,6 +1198,34 @@ public function decrement(Expression|string $column, mixed $amount = 1, array $e ); } + /** + * Increment the given column's values by the given amounts. + * + * @param array $columns + * @param array $extra + */ + public function incrementEach(array $columns, array $extra = []): int + { + return $this->toBase()->incrementEach( + $columns, + $this->addUpdatedAtColumn($extra) + ); + } + + /** + * Decrement the given column's values by the given amounts. + * + * @param array $columns + * @param array $extra + */ + public function decrementEach(array $columns, array $extra = []): int + { + return $this->toBase()->decrementEach( + $columns, + $this->addUpdatedAtColumn($extra) + ); + } + /** * Add the "updated at" column to an array of values. */ diff --git a/src/database/src/Eloquent/Concerns/HasEvents.php b/src/database/src/Eloquent/Concerns/HasEvents.php index 1e8b30edf..f59eaac63 100644 --- a/src/database/src/Eloquent/Concerns/HasEvents.php +++ b/src/database/src/Eloquent/Concerns/HasEvents.php @@ -480,6 +480,11 @@ public static function unsetEventDispatcher(): void * * Uses Context for coroutine-safe event disabling, ensuring concurrent * requests don't interfere with each other's event handling. + * + * @template TReturn + * + * @param callable(): TReturn $callback + * @return TReturn */ public static function withoutEvents(callable $callback): mixed { diff --git a/src/database/src/Eloquent/Concerns/HasRelationships.php b/src/database/src/Eloquent/Concerns/HasRelationships.php index c0e756cf3..bc4e6e24b 100644 --- a/src/database/src/Eloquent/Concerns/HasRelationships.php +++ b/src/database/src/Eloquent/Concerns/HasRelationships.php @@ -1028,6 +1028,20 @@ public function withoutRelations(): static return $model->unsetRelations(); } + /** + * Duplicate the instance and unset the given loaded relations. + */ + public function withoutRelation(array|string $relations): static + { + $model = clone $this; + + foreach ((array) $relations as $relation) { + $model->unsetRelation($relation); + } + + return $model; + } + /** * Unset all the loaded relations for the instance. * diff --git a/src/database/src/Eloquent/Concerns/HasTimestamps.php b/src/database/src/Eloquent/Concerns/HasTimestamps.php index ad8f6bac4..50eb07d05 100644 --- a/src/database/src/Eloquent/Concerns/HasTimestamps.php +++ b/src/database/src/Eloquent/Concerns/HasTimestamps.php @@ -9,6 +9,7 @@ use Hypervel\Database\Eloquent\Attributes\Initialize; use Hypervel\Database\Eloquent\Attributes\Table; use Hypervel\Database\Eloquent\Attributes\WithoutTimestamps; +use Hypervel\Support\Arr; use Hypervel\Support\Facades\Date; trait HasTimestamps @@ -46,10 +47,14 @@ public function initializeHasTimestamps(): void /** * Update the model's update timestamp. */ - public function touch(?string $attribute = null): bool + public function touch(array|string|null $attribute = null): bool { if ($attribute) { - $this->{$attribute} = $this->freshTimestamp(); + $time = $this->freshTimestamp(); + + foreach (Arr::wrap($attribute) as $column) { + $this->{$column} = $time; + } return $this->save(); } @@ -66,7 +71,7 @@ public function touch(?string $attribute = null): bool /** * Update the model's update timestamp without raising any events. */ - public function touchQuietly(?string $attribute = null): bool + public function touchQuietly(array|string|null $attribute = null): bool { return static::withoutEvents(fn () => $this->touch($attribute)); } @@ -181,6 +186,11 @@ public function getQualifiedUpdatedAtColumn(): ?string /** * Disable timestamps for the current class during the given callback scope. + * + * @template TReturn + * + * @param (callable(): TReturn) $callback + * @return TReturn */ public static function withoutTimestamps(callable $callback): mixed { @@ -190,7 +200,11 @@ public static function withoutTimestamps(callable $callback): mixed /** * Disable timestamps for the given model classes during the given callback scope. * + * @template TReturn + * * @param array $models + * @param callable(): TReturn $callback + * @return TReturn */ public static function withoutTimestampsOn(array $models, callable $callback): mixed { diff --git a/src/database/src/Eloquent/Concerns/TransformsToResource.php b/src/database/src/Eloquent/Concerns/TransformsToResource.php index e90b3eed1..c490affab 100644 --- a/src/database/src/Eloquent/Concerns/TransformsToResource.php +++ b/src/database/src/Eloquent/Concerns/TransformsToResource.php @@ -66,7 +66,7 @@ public static function guessResourceName(): array $relativeNamespace = Str::after($modelClass, '\Models\\'); $relativeNamespace = Str::contains($relativeNamespace, '\\') - ? Str::before($relativeNamespace, '\\' . class_basename($modelClass)) + ? Str::beforeLast($relativeNamespace, '\\' . class_basename($modelClass)) : ''; $potentialResource = sprintf( diff --git a/src/database/src/Eloquent/ModelNotFoundException.php b/src/database/src/Eloquent/ModelNotFoundException.php index 2dc9af189..41b246c84 100755 --- a/src/database/src/Eloquent/ModelNotFoundException.php +++ b/src/database/src/Eloquent/ModelNotFoundException.php @@ -6,6 +6,9 @@ use Hypervel\Database\RecordsNotFoundException; use Hypervel\Support\Arr; +use UnitEnum; + +use function Hypervel\Support\enum_value; /** * @template TModel of \Hypervel\Database\Eloquent\Model @@ -30,12 +33,12 @@ class ModelNotFoundException extends RecordsNotFoundException * Set the affected Eloquent model and instance ids. * * @param class-string $model - * @param array|int|string $ids + * @param array|int|string|UnitEnum $ids */ - public function setModel(string $model, array|int|string $ids = []): static + public function setModel(string $model, array|int|string|UnitEnum $ids = []): static { $this->model = $model; - $this->ids = Arr::wrap($ids); + $this->ids = array_map(enum_value(...), Arr::wrap($ids)); $this->message = "No query results for model [{$model}]"; diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php index 42751e032..810de6222 100644 --- a/src/database/src/Eloquent/Relations/BelongsToMany.php +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -568,10 +568,10 @@ public function findOrNew(mixed $id, array $columns = ['*']): EloquentCollection * * @return object{pivot: TPivotModel}&TRelatedModel */ - public function firstOrNew(array $attributes = [], array $values = []): Model + public function firstOrNew(array $attributes = [], Closure|array $values = []): Model { if (is_null($instance = $this->related->where($attributes)->first())) { - $instance = $this->related->newInstance(array_merge($attributes, $values)); + $instance = $this->related->newInstance(array_merge($attributes, value($values))); } return $instance; @@ -582,7 +582,7 @@ public function firstOrNew(array $attributes = [], array $values = []): Model * * @return object{pivot: TPivotModel}&TRelatedModel */ - public function firstOrCreate(array $attributes = [], array $values = [], array $joining = [], bool $touch = true): Model + public function firstOrCreate(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model { if (is_null($instance = (clone $this)->where($attributes)->first())) { if (is_null($instance = $this->related->where($attributes)->first())) { @@ -604,10 +604,10 @@ public function firstOrCreate(array $attributes = [], array $values = [], array * * @return object{pivot: TPivotModel}&TRelatedModel */ - public function createOrFirst(array $attributes = [], array $values = [], array $joining = [], bool $touch = true): Model + public function createOrFirst(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model { try { - return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, $values), $joining, $touch)); + return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values)), $joining, $touch)); } catch (UniqueConstraintViolationException $e) { // ... } @@ -626,11 +626,11 @@ public function createOrFirst(array $attributes = [], array $values = [], array * * @return object{pivot: TPivotModel}&TRelatedModel */ - public function updateOrCreate(array $attributes, array $values = [], array $joining = [], bool $touch = true): Model + public function updateOrCreate(array $attributes, Closure|array $values = [], array $joining = [], bool $touch = true): Model { return tap($this->firstOrCreate($attributes, $values, $joining, $touch), function ($instance) use ($values) { if (! $instance->wasRecentlyCreated) { - $instance->fill($values); + $instance->fill(value($values)); $instance->save(['touch' => false]); } @@ -1167,7 +1167,7 @@ public function touch(): void // the related model's timestamps, to make sure these all reflect the changes // to the parent models. This will help us keep any caching synced up here. if (count($ids = $this->allRelatedIds()) > 0) { - $this->getRelated()->newQueryWithoutRelationships()->whereKey($ids)->update($columns); + $this->getRelated()->newQueryWithoutRelationships()->whereIn($this->getQualifiedRelatedKeyName(), $ids)->update($columns); } } diff --git a/src/database/src/Eloquent/Relations/HasOneOrMany.php b/src/database/src/Eloquent/Relations/HasOneOrMany.php index b7789a923..0ba0e33da 100755 --- a/src/database/src/Eloquent/Relations/HasOneOrMany.php +++ b/src/database/src/Eloquent/Relations/HasOneOrMany.php @@ -4,6 +4,7 @@ namespace Hypervel\Database\Eloquent\Relations; +use Closure; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; @@ -217,10 +218,10 @@ public function findOrNew(mixed $id, array $columns = ['*']): EloquentCollection * * @return TRelatedModel */ - public function firstOrNew(array $attributes = [], array $values = []): Model + public function firstOrNew(array $attributes = [], Closure|array $values = []): Model { if (is_null($instance = $this->where($attributes)->first())) { - $instance = $this->related->newInstance(array_merge($attributes, $values)); + $instance = $this->related->newInstance(array_merge($attributes, value($values))); $this->setForeignAttributesForCreate($instance); } @@ -233,7 +234,7 @@ public function firstOrNew(array $attributes = [], array $values = []): Model * * @return TRelatedModel */ - public function firstOrCreate(array $attributes = [], array $values = []): Model + public function firstOrCreate(array $attributes = [], Closure|array $values = []): Model { if (is_null($instance = (clone $this)->where($attributes)->first())) { $instance = $this->createOrFirst($attributes, $values); @@ -247,11 +248,11 @@ public function firstOrCreate(array $attributes = [], array $values = []): Model * * @return TRelatedModel */ - public function createOrFirst(array $attributes = [], array $values = []): Model + public function createOrFirst(array $attributes = [], Closure|array $values = []): Model { try { // @phpstan-ignore return.type (generic type lost through withSavepointIfNeeded callback) - return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, $values))); + return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values)))); } catch (UniqueConstraintViolationException $e) { // @phpstan-ignore return.type (generic type lost through where()->first() chain) return $this->useWritePdo()->where($attributes)->first() ?? throw $e; @@ -263,11 +264,11 @@ public function createOrFirst(array $attributes = [], array $values = []): Model * * @return TRelatedModel */ - public function updateOrCreate(array $attributes, array $values = []): Model + public function updateOrCreate(array $attributes, Closure|array $values = []): Model { return tap($this->firstOrCreate($attributes, $values), function ($instance) use ($values) { if (! $instance->wasRecentlyCreated) { - $instance->fill($values)->save(); + $instance->fill(value($values))->save(); } }); } diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index 883239640..5e85692c1 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -203,13 +203,13 @@ public function firstOrNew(array $attributes = [], array $values = []): Model * * @return TRelatedModel */ - public function firstOrCreate(array $attributes = [], array $values = []): Model + public function firstOrCreate(array $attributes = [], Closure|array $values = []): Model { if (! is_null($instance = (clone $this)->where($attributes)->first())) { return $instance; } - return $this->createOrFirst(array_merge($attributes, $values)); + return $this->createOrFirst(array_merge($attributes, value($values))); } /** @@ -217,10 +217,10 @@ public function firstOrCreate(array $attributes = [], array $values = []): Model * * @return TRelatedModel */ - public function createOrFirst(array $attributes = [], array $values = []): Model + public function createOrFirst(array $attributes = [], Closure|array $values = []): Model { try { - return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, $values))); + return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values)))); } catch (UniqueConstraintViolationException $exception) { return $this->where($attributes)->first() ?? throw $exception; } diff --git a/src/database/src/Eloquent/Relations/MorphTo.php b/src/database/src/Eloquent/Relations/MorphTo.php index 1db354bf8..268adf874 100644 --- a/src/database/src/Eloquent/Relations/MorphTo.php +++ b/src/database/src/Eloquent/Relations/MorphTo.php @@ -198,9 +198,9 @@ public function match(array $models, EloquentCollection $results, string $relati protected function matchToMorphParents(string $type, EloquentCollection $results): void { foreach ($results as $result) { - $ownerKey = ! is_null($this->ownerKey) ? $this->getDictionaryKey($result->{$this->ownerKey}) : $result->getKey(); + $ownerKey = $this->getDictionaryKey(! is_null($this->ownerKey) ? $result->{$this->ownerKey} : $result->getKey()); - if (isset($this->dictionary[$type][$ownerKey])) { + if ($ownerKey !== null && isset($this->dictionary[$type][$ownerKey])) { foreach ($this->dictionary[$type][$ownerKey] as $model) { $model->setRelation($this->relationName, $result); } diff --git a/src/database/src/Eloquent/SoftDeletes.php b/src/database/src/Eloquent/SoftDeletes.php index e94b87812..a1939af77 100644 --- a/src/database/src/Eloquent/SoftDeletes.php +++ b/src/database/src/Eloquent/SoftDeletes.php @@ -167,7 +167,9 @@ public function restore(): bool $result = $this->save(); - $this->fireModelEvent('restored', false); + if ($result) { + $this->fireModelEvent('restored', false); + } return $result; } diff --git a/tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php b/tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php index eecf74e6c..99684d061 100644 --- a/tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php +++ b/tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Database\DatabaseEloquentBelongsToManyCreateOrFirstTest; +use Closure; use Exception; use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolverInterface; @@ -16,6 +17,7 @@ use Hypervel\Testbench\TestCase; use Mockery as m; use PDO; +use PHPUnit\Framework\Attributes\DataProvider; class DatabaseEloquentBelongsToManyCreateOrFirstTest extends TestCase { @@ -26,7 +28,8 @@ protected function setUp(): void CarbonImmutable::setTestNow('2023-01-01 00:00:00'); } - public function testCreateOrFirstMethodCreatesNewRelated(): void + #[DataProvider('createOrFirstValues')] + public function testCreateOrFirstMethodCreatesNewRelated(Closure|array $values): void { $source = new SourceModel; $source->id = 123; @@ -48,7 +51,7 @@ public function testCreateOrFirstMethodCreatesNewRelated(): void [456, 123], )->andReturnTrue(); - $result = $source->related()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $result = $source->related()->createOrFirst(['attr' => 'foo'], $values); $this->assertTrue($result->wasRecentlyCreated); $this->assertEquals([ 'id' => 456, @@ -59,6 +62,14 @@ public function testCreateOrFirstMethodCreatesNewRelated(): void ], $result->toArray()); } + public static function createOrFirstValues(): array + { + return [ + 'array' => [['val' => 'bar']], + 'closure' => [fn () => ['val' => 'bar']], + ]; + } + public function testCreateOrFirstMethodAssociatesExistingRelated(): void { $source = new SourceModel; @@ -445,6 +456,114 @@ protected function newBelongsToMany(Builder $query, Model $parent, $table, $fore ], $result->toArray()); } + public function testUpdateOrCreateMethodAcceptsClosureValuesAndCreates(): void + { + $source = new class extends SourceModel { + protected function newBelongsToMany(Builder $query, Model $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName = null): BelongsToMany + { + $relation = m::mock(BelongsToMany::class)->makePartial(); + $relation->__construct(...func_get_args()); + $instance = new RelatedModel([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]); + $instance->exists = true; + $instance->wasRecentlyCreated = true; + $instance->syncOriginal(); + $relation + ->expects('firstOrCreate') + ->withArgs(function ($attributes, $values, $joining, $touch) { + return $attributes === ['attr' => 'foo'] + && $values instanceof Closure + && $joining === [] + && $touch === true; + }) + ->andReturn($instance); + + return $relation; + } + }; + $source->id = 123; + $this->mockConnectionForModels( + [$source, new RelatedModel], + 'SQLite', + ); + + $callCount = 0; + $result = $source->related()->updateOrCreate(['attr' => 'foo'], function () use (&$callCount) { + ++$callCount; + + return ['val' => 'baz']; + }); + + // Closure is forwarded to firstOrCreate which would resolve it on the create path. + // Because we mocked firstOrCreate above, the closure was never invoked here. + $this->assertSame(0, $callCount); + $this->assertSame('bar', $result->val); + } + + public function testUpdateOrCreateMethodAcceptsClosureValuesAndUpdates(): void + { + $source = new class extends SourceModel { + protected function newBelongsToMany(Builder $query, Model $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName = null): BelongsToMany + { + $relation = m::mock(BelongsToMany::class)->makePartial(); + $relation->__construct(...func_get_args()); + $instance = new RelatedModel([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]); + $instance->exists = true; + $instance->wasRecentlyCreated = false; + $instance->syncOriginal(); + $relation + ->expects('firstOrCreate') + ->withArgs(function ($attributes, $values, $joining, $touch) { + return $attributes === ['attr' => 'foo'] + && $values instanceof Closure + && $joining === [] + && $touch === true; + }) + ->andReturn($instance); + + return $relation; + } + }; + $source->id = 123; + $this->mockConnectionForModels( + [$source, new RelatedModel], + 'SQLite', + ); + $source->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $source->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $source->getConnection() + ->expects('update') + ->with( + 'update "related_table" set "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 456], + ) + ->andReturn(1); + + $callCount = 0; + $result = $source->related()->updateOrCreate(['attr' => 'foo'], function () use (&$callCount) { + ++$callCount; + + return ['val' => 'baz']; + }); + + // On the update path firstOrCreate was mocked away, so the closure + // is only resolved once for the fill() call. + $this->assertSame(1, $callCount); + $this->assertSame('baz', $result->val); + } + protected function mockConnectionForModels(array $models, string $database, array $lastInsertIds = []): void { $grammarClass = 'Hypervel\Database\Query\Grammars\\' . $database . 'Grammar'; diff --git a/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php b/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php index b66c8463a..19e38ef01 100755 --- a/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php +++ b/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Database\DatabaseEloquentBuilderCreateOrFirstTest; +use Closure; use Exception; use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolverInterface; @@ -15,6 +16,7 @@ use Hypervel\Testbench\TestCase; use Mockery as m; use PDO; +use PHPUnit\Framework\Attributes\DataProvider; class DatabaseEloquentBuilderCreateOrFirstTest extends TestCase { @@ -468,6 +470,189 @@ public function testIncrementOrCreateMethodRetrievesRecordCreatedJustNow(): void ], $result->toArray()); } + #[DataProvider('createOrFirstValues')] + public function testUpdateOrCreateMethodAcceptsClosureValuesAndCreates(Closure|array $values): void + { + $model = new TestModel; + $this->mockConnectionForModel($model, 'SQLite', [123]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true, []) + ->andReturn([]); + + $model->getConnection()->expects('insert')->with( + 'insert into "table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $model->newQuery()->updateOrCreate(['attr' => 'foo'], $values); + + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodAcceptsClosureValuesAndUpdates(): void + { + $model = new TestModel; + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true, []) + ->andReturn([[ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $model->getConnection() + ->expects('update') + ->with( + 'update "table" set "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 123], + ) + ->andReturn(1); + + $result = $model->newQuery()->updateOrCreate(['attr' => 'foo'], fn () => ['val' => 'baz']); + + $this->assertFalse($result->wasRecentlyCreated); + $this->assertSame('baz', $result->val); + } + + public function testUpdateOrCreateInvokesClosureExactlyOnceWhenCreating(): void + { + $model = new TestModel; + $this->mockConnectionForModel($model, 'SQLite', [123]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true, []) + ->andReturn([]); + + $model->getConnection()->expects('insert')->with( + 'insert into "table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $callCount = 0; + $model->newQuery()->updateOrCreate(['attr' => 'foo'], function () use (&$callCount) { + ++$callCount; + + return ['val' => 'bar']; + }); + + $this->assertSame(1, $callCount); + } + + public function testUpdateOrCreateInvokesClosureExactlyOnceWhenUpdating(): void + { + $model = new TestModel; + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true, []) + ->andReturn([[ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $model->getConnection() + ->expects('update') + ->with( + 'update "table" set "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 123], + ) + ->andReturn(1); + + $callCount = 0; + $model->newQuery()->updateOrCreate(['attr' => 'foo'], function () use (&$callCount) { + ++$callCount; + + return ['val' => 'baz']; + }); + + $this->assertSame(1, $callCount); + } + + #[DataProvider('createOrFirstValues')] + public function testFirstOrNewMethodAcceptsClosureValuesAndInstantiates(Closure|array $values): void + { + $model = new TestModel; + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true, []) + ->andReturn([]); + + $result = $model->newQuery()->firstOrNew(['attr' => 'foo'], $values); + + $this->assertFalse($result->exists); + $this->assertSame('foo', $result->attr); + $this->assertSame('bar', $result->val); + } + + public static function createOrFirstValues(): array + { + return [ + 'array' => [['val' => 'bar']], + 'closure' => [fn () => ['val' => 'bar']], + ]; + } + + public function testFirstOrNewDoesNotInvokeClosureWhenRecordExists(): void + { + $model = new TestModel; + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true, []) + ->andReturn([[ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $callCount = 0; + $result = $model->newQuery()->firstOrNew(['attr' => 'foo'], function () use (&$callCount) { + ++$callCount; + + return ['val' => 'should-not-be-called']; + }); + + $this->assertSame(0, $callCount); + $this->assertTrue($result->exists); + $this->assertSame('bar', $result->val); + } + protected function mockConnectionForModel(Model $model, string $database, array $lastInsertIds = []): void { $grammarClass = 'Hypervel\Database\Query\Grammars\\' . $database . 'Grammar'; diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index 0178bf09a..6fb582edd 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -144,6 +144,24 @@ public function testFindOrFailMethodThrowsModelNotFoundException() $builder->findOrFail('bar', ['column']); } + public function testFindOrFailMethodThrowsModelNotFoundExceptionWithBackedEnum(): void + { + $exception = new ModelNotFoundException; + $exception->setModel('Foo', BuilderTestBackedEnum::Bar); + + $this->assertSame('No query results for model [Foo] bar', $exception->getMessage()); + $this->assertSame(['bar'], $exception->getIds()); + } + + public function testFindOrFailMethodThrowsModelNotFoundExceptionWithUnitEnum(): void + { + $exception = new ModelNotFoundException; + $exception->setModel('Foo', BuilderTestUnitEnum::Baz); + + $this->assertSame('No query results for model [Foo] Baz', $exception->getMessage()); + $this->assertSame(['Baz'], $exception->getIds()); + } + public function testFindOrFailMethodWithManyThrowsModelNotFoundException() { $this->expectException(ModelNotFoundException::class); @@ -1114,6 +1132,7 @@ public function testNestedWhere() $nestedRawQuery = $this->getMockQueryBuilder(); $nestedQuery->shouldReceive('getQuery')->once()->andReturn($nestedRawQuery); $nestedQuery->shouldReceive('getEagerLoads')->once()->andReturn([]); + $nestedQuery->shouldReceive('removedScopes')->once()->andReturn([]); $model = $this->getMockModel()->makePartial(); $model->shouldReceive('newQueryWithoutRelationships')->once()->andReturn($nestedQuery); $builder = $this->getBuilder(); @@ -1176,6 +1195,7 @@ public function testWhereNot() $nestedRawQuery = $this->getMockQueryBuilder(); $nestedQuery->shouldReceive('getQuery')->once()->andReturn($nestedRawQuery); $nestedQuery->shouldReceive('getEagerLoads')->once()->andReturn([]); + $nestedQuery->shouldReceive('removedScopes')->once()->andReturn([]); $model = $this->getMockModel()->makePartial(); $model->shouldReceive('newQueryWithoutRelationships')->once()->andReturn($nestedQuery); $builder = $this->getBuilder(); @@ -1205,6 +1225,7 @@ public function testOrWhereNot() $nestedRawQuery = $this->getMockQueryBuilder(); $nestedQuery->shouldReceive('getQuery')->once()->andReturn($nestedRawQuery); $nestedQuery->shouldReceive('getEagerLoads')->once()->andReturn([]); + $nestedQuery->shouldReceive('removedScopes')->once()->andReturn([]); $model = $this->getMockModel()->makePartial(); $model->shouldReceive('newQueryWithoutRelationships')->once()->andReturn($nestedQuery); $builder = $this->getBuilder(); @@ -2013,7 +2034,7 @@ public function testWhereNotMorphedTo() $builder = $model->whereNotMorphedTo('morph', $relatedModel); - $this->assertSame('select * from "model_parent_stubs" where not (("model_parent_stubs"."morph_type" <=> ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertSame('select * from "model_parent_stubs" where not (("model_parent_stubs"."morph_type" is not distinct from ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); $this->assertEquals([$relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); } @@ -2030,7 +2051,7 @@ public function testWhereNotMorphedToCollection() $builder = $model->whereNotMorphedTo('morph', new Collection([$firstRelatedModel, $secondRelatedModel])); - $this->assertSame('select * from "model_parent_stubs" where not (("model_parent_stubs"."morph_type" <=> ? and "model_parent_stubs"."morph_id" in (?, ?)))', $builder->toSql()); + $this->assertSame('select * from "model_parent_stubs" where not (("model_parent_stubs"."morph_type" is not distinct from ? and "model_parent_stubs"."morph_id" in (?, ?)))', $builder->toSql()); $this->assertEquals([$firstRelatedModel->getMorphClass(), $firstRelatedModel->getKey(), $secondRelatedModel->getKey()], $builder->getBindings()); } @@ -2050,7 +2071,7 @@ public function testWhereNotMorphedToCollectionWithDifferentModels() $builder = $model->whereNotMorphedTo('morph', [$firstRelatedModel, $secondRelatedModel, $thirdRelatedModel]); - $this->assertSame('select * from "model_parent_stubs" where not (("model_parent_stubs"."morph_type" <=> ? and "model_parent_stubs"."morph_id" in (?, ?)) or ("model_parent_stubs"."morph_type" <=> ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertSame('select * from "model_parent_stubs" where not (("model_parent_stubs"."morph_type" is not distinct from ? and "model_parent_stubs"."morph_id" in (?, ?)) or ("model_parent_stubs"."morph_type" is not distinct from ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); $this->assertEquals([$firstRelatedModel->getMorphClass(), $firstRelatedModel->getKey(), $thirdRelatedModel->getKey(), $secondRelatedModel->getMorphClass(), $secondRelatedModel->id], $builder->getBindings()); } @@ -2126,7 +2147,7 @@ public function testOrWhereNotMorphedTo() $builder = $model->where('bar', 'baz')->orWhereNotMorphedTo('morph', $relatedModel); - $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or not (("model_parent_stubs"."morph_type" <=> ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or not (("model_parent_stubs"."morph_type" is not distinct from ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); $this->assertEquals(['baz', $relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); } @@ -2143,7 +2164,7 @@ public function testOrWhereNotMorphedToCollection() $builder = $model->where('bar', 'baz')->orWhereNotMorphedTo('morph', new Collection([$firstRelatedModel, $secondRelatedModel])); - $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or not (("model_parent_stubs"."morph_type" <=> ? and "model_parent_stubs"."morph_id" in (?, ?)))', $builder->toSql()); + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or not (("model_parent_stubs"."morph_type" is not distinct from ? and "model_parent_stubs"."morph_id" in (?, ?)))', $builder->toSql()); $this->assertEquals(['baz', $firstRelatedModel->getMorphClass(), $firstRelatedModel->getKey(), $secondRelatedModel->getKey()], $builder->getBindings()); } @@ -2163,7 +2184,7 @@ public function testOrWhereNotMorphedToCollectionWithDifferentModels() $builder = $model->where('bar', 'baz')->orWhereNotMorphedTo('morph', [$firstRelatedModel, $secondRelatedModel, $thirdRelatedModel]); - $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or not (("model_parent_stubs"."morph_type" <=> ? and "model_parent_stubs"."morph_id" in (?, ?)) or ("model_parent_stubs"."morph_type" <=> ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or not (("model_parent_stubs"."morph_type" is not distinct from ? and "model_parent_stubs"."morph_id" in (?, ?)) or ("model_parent_stubs"."morph_type" is not distinct from ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); $this->assertEquals(['baz', $firstRelatedModel->getMorphClass(), $firstRelatedModel->getKey(), $thirdRelatedModel->getKey(), $secondRelatedModel->getMorphClass(), $secondRelatedModel->id], $builder->getBindings()); } @@ -2185,7 +2206,7 @@ public function testWhereNotMorphedToClass() $builder = $model->whereNotMorphedTo('morph', ModelCloseRelatedStub::class); - $this->assertSame('select * from "model_parent_stubs" where not "model_parent_stubs"."morph_type" <=> ?', $builder->toSql()); + $this->assertSame('select * from "model_parent_stubs" where not ("model_parent_stubs"."morph_type" is not distinct from ?)', $builder->toSql()); $this->assertEquals([ModelCloseRelatedStub::class], $builder->getBindings()); } @@ -2207,7 +2228,7 @@ public function testOrWhereNotMorphedToClass() $builder = $model->where('bar', 'baz')->orWhereNotMorphedTo('morph', ModelCloseRelatedStub::class); - $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or not "model_parent_stubs"."morph_type" <=> ?', $builder->toSql()); + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or not ("model_parent_stubs"."morph_type" is not distinct from ?)', $builder->toSql()); $this->assertEquals(['baz', ModelCloseRelatedStub::class], $builder->getBindings()); } @@ -2222,7 +2243,7 @@ public function testWhereNotMorphedToWithSQLite() $builder = $model->whereNotMorphedTo('morph', $relatedModel); $this->assertStringNotContainsString('<=>', $builder->toSql()); - $this->assertSame('select * from "model_parent_stubs" where not (("model_parent_stubs"."morph_type" IS ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertSame('select * from "model_parent_stubs" where not (("model_parent_stubs"."morph_type" is ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); $this->assertEquals([$relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); } @@ -2234,10 +2255,60 @@ public function testWhereNotMorphedToClassWithSQLite() $builder = $model->whereNotMorphedTo('morph', ModelCloseRelatedStub::class); $this->assertStringNotContainsString('<=>', $builder->toSql()); - $this->assertSame('select * from "model_parent_stubs" where not "model_parent_stubs"."morph_type" IS ?', $builder->toSql()); + $this->assertSame('select * from "model_parent_stubs" where not ("model_parent_stubs"."morph_type" is ?)', $builder->toSql()); $this->assertEquals([ModelCloseRelatedStub::class], $builder->getBindings()); } + public function testWhereNotMorphedToUsesMySqlNullSafeEquality(): void + { + $model = new ModelParentStub; + $this->mockConnectionForModel($model, 'MySql'); + + $relatedModel = new ModelCloseRelatedStub; + $relatedModel->id = 1; + + $builder = $model->whereNotMorphedTo('morph', $relatedModel); + + $this->assertSame('select * from `model_parent_stubs` where not ((`model_parent_stubs`.`morph_type` <=> ? and `model_parent_stubs`.`morph_id` in (?)))', $builder->toSql()); + $this->assertSame([$relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); + } + + public function testWhereNotMorphedToClassUsesMySqlNullSafeEquality(): void + { + $model = new ModelParentStub; + $this->mockConnectionForModel($model, 'MySql'); + + $builder = $model->whereNotMorphedTo('morph', ModelCloseRelatedStub::class); + + $this->assertSame('select * from `model_parent_stubs` where not (`model_parent_stubs`.`morph_type` <=> ?)', $builder->toSql()); + $this->assertSame([ModelCloseRelatedStub::class], $builder->getBindings()); + } + + public function testWhereNotMorphedToUsesPostgresNullSafeEquality(): void + { + $model = new ModelParentStub; + $this->mockConnectionForModel($model, 'Postgres'); + + $relatedModel = new ModelCloseRelatedStub; + $relatedModel->id = 1; + + $builder = $model->whereNotMorphedTo('morph', $relatedModel); + + $this->assertSame('select * from "model_parent_stubs" where not (("model_parent_stubs"."morph_type" is not distinct from ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertSame([$relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); + } + + public function testWhereNotMorphedToClassUsesPostgresNullSafeEquality(): void + { + $model = new ModelParentStub; + $this->mockConnectionForModel($model, 'Postgres'); + + $builder = $model->whereNotMorphedTo('morph', ModelCloseRelatedStub::class); + + $this->assertSame('select * from "model_parent_stubs" where not ("model_parent_stubs"."morph_type" is not distinct from ?)', $builder->toSql()); + $this->assertSame([ModelCloseRelatedStub::class], $builder->getBindings()); + } + public function testWhereMorphedToAlias() { $model = new ModelParentStub; @@ -2718,6 +2789,28 @@ public function testTouchWithCustomColumn() $this->assertEquals(2, $result); } + public function testTouchWithMultipleColumns(): void + { + CarbonImmutable::setTestNow($now = '2017-10-10 10:10:10'); + + $query = m::mock(BaseBuilder::class); + $query->shouldReceive('from')->with('foo_table')->andReturnSelf(); + $query->from = 'foo_table'; + + $builder = new Builder($query); + $model = new StubStringPrimaryKey; + $builder->setModel($model); + + $query->shouldReceive('update') + ->once() + ->with(['published_at' => $now, 'verified_at' => $now]) + ->andReturn(2); + + $result = $builder->touch(['published_at', 'verified_at']); + + $this->assertSame(2, $result); + } + public function testTouchWithoutUpdatedAtColumn() { $query = m::mock(BaseBuilder::class); @@ -2863,6 +2956,72 @@ public function testPipeCallback() $this->assertCount(1, $query->getQuery()->wheres); } + public function testIncrementEachCallsToBaseWithUpdatedAt(): void + { + $query = m::mock(BaseBuilder::class); + $query->shouldReceive('from')->with('foo_table'); + $query->from = 'foo_table'; + $query->shouldReceive('incrementEach')->once()->withArgs(function ($columns, $extra) { + return $columns === ['votes' => 5] + && array_key_exists('foo_table.updated_at', $extra); + })->andReturn(1); + + $builder = new Builder($query); + $model = $this->getMockModel(); + $model->shouldReceive('usesTimestamps')->andReturn(true); + $model->shouldReceive('getUpdatedAtColumn')->andReturn('updated_at'); + $model->shouldReceive('freshTimestampString')->andReturn('2026-03-26 00:00:00'); + $model->shouldReceive('hasSetMutator')->andReturn(false); + $model->shouldReceive('hasAttributeSetMutator')->andReturn(false); + $model->shouldReceive('hasCast')->andReturn(false); + $builder->setModel($model); + + $result = $builder->incrementEach(['votes' => 5]); + + $this->assertSame(1, $result); + } + + public function testDecrementEachCallsToBaseWithUpdatedAt(): void + { + $query = m::mock(BaseBuilder::class); + $query->shouldReceive('from')->with('foo_table'); + $query->from = 'foo_table'; + $query->shouldReceive('decrementEach')->once()->withArgs(function ($columns, $extra) { + return $columns === ['votes' => 3] + && array_key_exists('foo_table.updated_at', $extra); + })->andReturn(1); + + $builder = new Builder($query); + $model = $this->getMockModel(); + $model->shouldReceive('usesTimestamps')->andReturn(true); + $model->shouldReceive('getUpdatedAtColumn')->andReturn('updated_at'); + $model->shouldReceive('freshTimestampString')->andReturn('2026-03-26 00:00:00'); + $model->shouldReceive('hasSetMutator')->andReturn(false); + $model->shouldReceive('hasAttributeSetMutator')->andReturn(false); + $model->shouldReceive('hasCast')->andReturn(false); + $builder->setModel($model); + + $result = $builder->decrementEach(['votes' => 3]); + + $this->assertSame(1, $result); + } + + public function testIncrementEachWithoutTimestamps(): void + { + $query = m::mock(BaseBuilder::class); + $query->shouldReceive('from')->with('foo_table'); + $query->shouldReceive('incrementEach')->once()->with(['votes' => 1], [])->andReturn(1); + + $builder = new Builder($query); + $model = $this->getMockModel(); + $model->shouldReceive('usesTimestamps')->andReturn(false); + $builder->setModel($model); + + $result = $builder->incrementEach(['votes' => 1]); + + $this->assertSame(1, $result); + } + protected function mockConnectionForModel($model, $database) { $grammarClass = 'Hypervel\Database\Query\Grammars\\' . $database . 'Grammar'; @@ -3126,6 +3285,16 @@ class StubStringPrimaryKey extends Model protected string $keyType = 'string'; } +enum BuilderTestBackedEnum: string +{ + case Bar = 'bar'; +} + +enum BuilderTestUnitEnum +{ + case Baz; +} + class WhereBelongsToStub extends Model { protected array $fillable = [ diff --git a/tests/Database/DatabaseEloquentGlobalScopesTest.php b/tests/Database/DatabaseEloquentGlobalScopesTest.php index 8084fa363..b9385148a 100644 --- a/tests/Database/DatabaseEloquentGlobalScopesTest.php +++ b/tests/Database/DatabaseEloquentGlobalScopesTest.php @@ -170,6 +170,38 @@ public function testHasQueryWhereBothModelsHaveGlobalScopes() $this->assertEquals($mainQuery, $query->toSql()); $this->assertEquals(['bar', 1, 'baz', 1], $query->getBindings()); } + + public function testRegularScopesThatRemoveGlobalScopes(): void + { + $query = ClosureGlobalScopesModel::where('foo', 'foo')->approved()->notApproved(); + + $this->assertSame('select * from "table" where "foo" = ? and ("approved" = ? or "should_approve" = ?) and ("approved" = ? or "should_approve" = ?) order by "name" asc', $query->toSql()); + $this->assertEquals(['foo', 1, 0, 0, 1], $query->getBindings()); + } + + public function testRegularScopesThatRemoveGlobalScopesCalledInNestedWhereCondition(): void + { + $query = ClosureGlobalScopesModel::where('foo', 'foo')->where(function ($query) { + $query->approved(); + $query->orWhere(function ($query) { + $query->notApproved(); + }); + }); + + $this->assertSame('select * from "table" where "foo" = ? and (("approved" = ? or "should_approve" = ?) or (("approved" = ? or "should_approve" = ?))) order by "name" asc', $query->toSql()); + $this->assertEquals(['foo', 1, 0, 0, 1], $query->getBindings()); + } + + public function testRemovingGlobalScopeInNestedWhereCondition(): void + { + $query = ClosureGlobalScopesModel::where('foo', 'foo')->where(function ($query) { + $query->approved(); + $query->withoutGlobalScope('active_scope'); + }); + + $this->assertSame('select * from "table" where "foo" = ? and (("approved" = ? or "should_approve" = ?)) order by "name" asc', $query->toSql()); + $this->assertEquals(['foo', 1, 0], $query->getBindings()); + } } class ClosureGlobalScopesModel extends Model @@ -194,6 +226,11 @@ public function scopeApproved($query) return $query->where('approved', 1)->orWhere('should_approve', 0); } + public function scopeNotApproved($query) + { + return $query->where('approved', 0)->orWhere('should_approve', 1)->withoutGlobalScope('active_scope'); + } + public function scopeOrApproved($query) { return $query->orWhere('approved', 1)->orWhere('should_approve', 0); diff --git a/tests/Database/DatabaseEloquentHasManyCreateOrFirstTest.php b/tests/Database/DatabaseEloquentHasManyCreateOrFirstTest.php index c61990d86..451a5bfdb 100755 --- a/tests/Database/DatabaseEloquentHasManyCreateOrFirstTest.php +++ b/tests/Database/DatabaseEloquentHasManyCreateOrFirstTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Database\DatabaseEloquentHasManyCreateOrFirstTest; +use Closure; use Exception; use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolverInterface; @@ -15,6 +16,7 @@ use Hypervel\Testbench\TestCase; use Mockery as m; use PDO; +use PHPUnit\Framework\Attributes\DataProvider; class DatabaseEloquentHasManyCreateOrFirstTest extends TestCase { @@ -25,7 +27,8 @@ protected function setUp(): void CarbonImmutable::setTestNow('2023-01-01 00:00:00'); } - public function testCreateOrFirstMethodCreatesNewRecord(): void + #[DataProvider('createOrFirstValues')] + public function testCreateOrFirstMethodCreatesNewRecord(Closure|array $values): void { $model = new ParentModel; $model->id = 123; @@ -38,7 +41,7 @@ public function testCreateOrFirstMethodCreatesNewRecord(): void ['foo', 'bar', 123, '2023-01-01 00:00:00', '2023-01-01 00:00:00'], )->andReturnTrue(); - $result = $model->children()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $result = $model->children()->createOrFirst(['attr' => 'foo'], $values); $this->assertTrue($result->wasRecentlyCreated); $this->assertEquals([ 'id' => 456, @@ -314,6 +317,100 @@ public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(): void ], $result->toArray()); } + #[DataProvider('createOrFirstValues')] + public function testUpdateOrCreateMethodAcceptsClosureValuesAndCreates(Closure|array $values): void + { + $model = new ParentModel; + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite', [456]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], true, []) + ->andReturn([]); + + $model->getConnection()->expects('insert')->with( + 'insert into "child_table" ("attr", "val", "parent_id", "updated_at", "created_at") values (?, ?, ?, ?, ?)', + ['foo', 'bar', 123, '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $model->children()->updateOrCreate(['attr' => 'foo'], $values); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertSame('bar', $result->val); + } + + public static function createOrFirstValues(): array + { + return [ + 'array' => [['val' => 'bar']], + 'closure' => [fn () => ['val' => 'bar']], + ]; + } + + public function testUpdateOrCreateMethodAcceptsClosureValuesAndUpdates(): void + { + $model = new ParentModel; + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], true, []) + ->andReturn([[ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]]); + + $model->getConnection()->expects('update')->with( + 'update "child_table" set "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 456], + )->andReturn(1); + + $result = $model->children()->updateOrCreate(['attr' => 'foo'], fn () => ['val' => 'baz']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertSame('baz', $result->val); + } + + public function testFirstOrNewDoesNotInvokeClosureWhenRecordExists(): void + { + $model = new ParentModel; + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], true, []) + ->andReturn([[ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $callCount = 0; + $result = $model->children()->firstOrNew(['attr' => 'foo'], function () use (&$callCount) { + ++$callCount; + + return ['val' => 'should-not-be-called']; + }); + + $this->assertSame(0, $callCount); + $this->assertTrue($result->exists); + $this->assertSame('bar', $result->val); + } + protected function mockConnectionForModel(Model $model, string $database, array $lastInsertIds = []): void { $grammarClass = 'Hypervel\Database\Query\Grammars\\' . $database . 'Grammar'; diff --git a/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php b/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php index 9e892283f..9da98245f 100644 --- a/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php +++ b/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Database\DatabaseEloquentHasManyThroughCreateOrFirstTest; +use Closure; use Exception; use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolverInterface; @@ -15,6 +16,7 @@ use Hypervel\Testbench\TestCase; use Mockery as m; use PDO; +use PHPUnit\Framework\Attributes\DataProvider; class DatabaseEloquentHasManyThroughCreateOrFirstTest extends TestCase { @@ -32,7 +34,8 @@ protected function tearDown(): void parent::tearDown(); } - public function testCreateOrFirstMethodCreatesNewRecord(): void + #[DataProvider('createOrFirstValues')] + public function testCreateOrFirstMethodCreatesNewRecord(Closure|array $values): void { $parent = new ParentModel; $parent->id = 123; @@ -44,7 +47,7 @@ public function testCreateOrFirstMethodCreatesNewRecord(): void ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], )->andReturnTrue(); - $result = $parent->children()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $result = $parent->children()->createOrFirst(['attr' => 'foo'], $values); $this->assertTrue($result->wasRecentlyCreated); $this->assertEquals([ 'id' => 789, @@ -55,6 +58,14 @@ public function testCreateOrFirstMethodCreatesNewRecord(): void ], $result->toArray()); } + public static function createOrFirstValues(): array + { + return [ + 'array' => [['val' => 'bar']], + 'closure' => [fn () => ['val' => 'bar']], + ]; + } + public function testCreateOrFirstMethodRetrievesExistingRecord(): void { $parent = new ParentModel; diff --git a/tests/Database/DatabaseEloquentRelationTest.php b/tests/Database/DatabaseEloquentRelationTest.php index a3bf612ef..1c62727cd 100755 --- a/tests/Database/DatabaseEloquentRelationTest.php +++ b/tests/Database/DatabaseEloquentRelationTest.php @@ -266,6 +266,41 @@ public function testWithoutRelations() $this->assertFalse($model->relationLoaded('foo')); } + public function testWithoutRelation(): void + { + $original = new NoTouchingModelStub; + + $original->setRelation('foo', 'baz'); + $original->setRelation('bar', 'qux'); + + $model = $original->withoutRelation('foo'); + + $this->assertInstanceOf(NoTouchingModelStub::class, $model); + $this->assertNotSame($model, $original); + $this->assertTrue($original->relationLoaded('foo')); + $this->assertTrue($original->relationLoaded('bar')); + $this->assertFalse($model->relationLoaded('foo')); + $this->assertTrue($model->relationLoaded('bar')); + } + + public function testWithoutRelationWithArray(): void + { + $original = new NoTouchingModelStub; + + $original->setRelation('foo', 'baz'); + $original->setRelation('bar', 'qux'); + $original->setRelation('bam', 'zap'); + + $model = $original->withoutRelation(['foo', 'bar']); + + $this->assertTrue($original->relationLoaded('foo')); + $this->assertTrue($original->relationLoaded('bar')); + $this->assertTrue($original->relationLoaded('bam')); + $this->assertFalse($model->relationLoaded('foo')); + $this->assertFalse($model->relationLoaded('bar')); + $this->assertTrue($model->relationLoaded('bam')); + } + public function testMacroable() { Relation::macro('foo', function () { diff --git a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php index 4d2a04b49..0b4aebac0 100644 --- a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php +++ b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php @@ -11,6 +11,7 @@ use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Eloquent\SoftDeletingScope; use Hypervel\Database\Query\Builder; +use Hypervel\Events\Dispatcher; use Hypervel\Pagination\CursorPaginator; use Hypervel\Pagination\Paginator; use Hypervel\Support\CarbonImmutable; @@ -319,6 +320,42 @@ public function testRestoreRestoresRecords() $this->assertNull($users->find(2)->deleted_at); } + public function testRestoreDoesNotFireRestoredEventWhenSavingEventCancelsSave(): void + { + $previousDispatcher = Eloquent::getEventDispatcher(); + + Eloquent::setEventDispatcher(new Dispatcher); + + try { + $this->createUsers(); + + $restored = false; + + User::saving(function () { + return false; + }); + + User::restored(function () use (&$restored) { + $restored = true; + }); + + $user = User::withTrashed()->find(1); + + $this->assertFalse($user->restore()); + $this->assertFalse($restored); + $this->assertNotNull(User::withTrashed()->find(1)->deleted_at); + $this->assertNull(User::find(1)); + } finally { + User::flushEventListeners(); + + if ($previousDispatcher) { + Eloquent::setEventDispatcher($previousDispatcher); + } else { + Eloquent::unsetEventDispatcher(); + } + } + } + public function testOnlyTrashedOnlyReturnsTrashedRecords() { $this->createUsers(); diff --git a/tests/Database/Eloquent/Concerns/TransformsToResourceTest.php b/tests/Database/Eloquent/Concerns/TransformsToResourceTest.php index 61670fd52..fbbb20fa5 100644 --- a/tests/Database/Eloquent/Concerns/TransformsToResourceTest.php +++ b/tests/Database/Eloquent/Concerns/TransformsToResourceTest.php @@ -9,6 +9,7 @@ use Hypervel\Http\Resources\Json\JsonResource; use Hypervel\Testbench\TestCase; use Hypervel\Tests\Database\Eloquent\Models\TransformsToResourceTestModelInModelsNamespace; +use Hypervel\Tests\Database\Fixtures\Models\Administration\Billing\TransformsToResourceTestNestedModel; use LogicException; class TransformsToResourceTest extends TestCase @@ -59,6 +60,16 @@ public function testGuessResourceNameReturnsCorrectNamesForModelsNamespace(): vo ], $result); } + public function testGuessResourceNamePreservesTheFullNestedModelNamespace(): void + { + $result = TransformsToResourceTestNestedModel::guessResourceName(); + + $this->assertSame([ + 'Hypervel\Tests\Database\Fixtures\Http\Resources\Administration\Billing\TransformsToResourceTestNestedModelResource', + 'Hypervel\Tests\Database\Fixtures\Http\Resources\Administration\Billing\TransformsToResourceTestNestedModel', + ], $result); + } + public function testExplicitResourceTakesPrecedenceOverAttribute(): void { $model = new TransformsToResourceTestModelWithAttribute; diff --git a/tests/Database/Fixtures/Models/Administration/Billing/TransformsToResourceTestNestedModel.php b/tests/Database/Fixtures/Models/Administration/Billing/TransformsToResourceTestNestedModel.php new file mode 100644 index 000000000..118398e1e --- /dev/null +++ b/tests/Database/Fixtures/Models/Administration/Billing/TransformsToResourceTestNestedModel.php @@ -0,0 +1,12 @@ +assertNotSame('2017-10-10 10:10:10', Tag::find(2)->updated_at?->toDateTimeString()); } + public function testCanTouchRelatedModelsUsingCustomRelatedKey() + { + $post = Post::create(['title' => Str::random()]); + $tag = Tag::create(['name' => 'first']); + $untouchedTag = Tag::create(['name' => 'second']); + + DB::table('posts_tags')->insert([ + 'post_id' => $post->id, + 'tag_name' => $tag->name, + ]); + + CarbonImmutable::setTestNow('2017-10-10 10:10:10'); + + $post->tagsWithCustomRelatedKey()->touch(); + + $this->assertSame('2017-10-10 10:10:10', $tag->fresh()->updated_at?->toDateTimeString()); + $this->assertNotSame('2017-10-10 10:10:10', $untouchedTag->fresh()->updated_at?->toDateTimeString()); + } + public function testWherePivotOnString() { $tag = Tag::create(['name' => Str::random()])->fresh(); diff --git a/tests/Integration/Database/EloquentModelScopeTest.php b/tests/Integration/Database/EloquentModelScopeTest.php index 6c604ac59..abe50ceed 100644 --- a/tests/Integration/Database/EloquentModelScopeTest.php +++ b/tests/Integration/Database/EloquentModelScopeTest.php @@ -30,6 +30,13 @@ public function testModelHasAttributedScope() $this->assertTrue($model->hasNamedScope('existsAsWell')); } + + public function testModelDoesNotHaveScopeWhenPrivateVisibility(): void + { + $model = new TestScopeModel1; + + $this->assertFalse($model->hasNamedScope('existsAsPrivate')); + } } class TestScopeModel1 extends Model @@ -44,4 +51,10 @@ protected function existsAsWell(Builder $builder): Builder { return $builder; } + + #[Scope] + private function existsAsPrivate(Builder $builder): Builder + { + return $builder; + } } diff --git a/tests/Integration/Database/EloquentMorphToEagerLoadTest.php b/tests/Integration/Database/EloquentMorphToEagerLoadTest.php new file mode 100644 index 000000000..f1a7a68c6 --- /dev/null +++ b/tests/Integration/Database/EloquentMorphToEagerLoadTest.php @@ -0,0 +1,153 @@ +increments('id'); + }); + + Schema::create('articles', function (Blueprint $table) { + $table->string('slug')->primary(); + }); + + Schema::create('videos', function (Blueprint $table) { + $table->string('id')->primary(); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->string('commentable_id'); + }); + + $post = Post::create(); + $article = Article::create(['slug' => ArticleSlug::Review->value]); + $video = Video::create(['id' => '550e8400-e29b-41d4-a716-446655440000']); + + (new Comment)->commentable()->associate($post)->save(); + (new Comment)->commentable()->associate($article)->save(); + + $comment = new Comment; + $comment->commentable_type = Video::class; + $comment->commentable_id = (string) $video->id; + $comment->save(); + } + + public function testEagerLoadingResolvesRelationWithPrimitivePrimaryKey(): void + { + $comments = Comment::with('commentable') + ->where('commentable_type', Post::class) + ->get(); + + $this->assertNotNull($comments[0]->commentable); + $this->assertInstanceOf(Post::class, $comments[0]->commentable); + } + + public function testEagerLoadingResolvesRelationWithBackedEnumPrimaryKey(): void + { + $comments = Comment::with('commentable') + ->where('commentable_type', Article::class) + ->get(); + + $this->assertNotNull($comments[0]->commentable); + $this->assertInstanceOf(Article::class, $comments[0]->commentable); + $this->assertSame(ArticleSlug::Review, $comments[0]->commentable->slug); + } + + public function testEagerLoadingResolvesRelationWithUuidValueObjectPrimaryKey(): void + { + $comments = Comment::with('commentable') + ->where('commentable_type', Video::class) + ->get(); + + $this->assertNotNull($comments[0]->commentable); + $this->assertInstanceOf(Video::class, $comments[0]->commentable); + $this->assertSame('550e8400-e29b-41d4-a716-446655440000', (string) $comments[0]->commentable->id); + } +} + +enum ArticleSlug: string +{ + case Review = 'review'; +} + +class Post extends Model +{ + public bool $timestamps = false; +} + +class Article extends Model +{ + public bool $timestamps = false; + + protected string $primaryKey = 'slug'; + + protected string $keyType = 'string'; + + public bool $incrementing = false; + + protected array $casts = ['slug' => ArticleSlug::class]; + + protected array $fillable = ['slug']; +} + +class Comment extends Model +{ + public bool $timestamps = false; + + public function commentable(): MorphTo + { + return $this->morphTo(); + } +} + +class Uuid +{ + public function __construct(private readonly string $value) + { + } + + public function __toString(): string + { + return $this->value; + } +} + +class UuidCast implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): mixed + { + return new Uuid($value); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): mixed + { + return (string) $value; + } +} + +class Video extends Model +{ + public bool $timestamps = false; + + public bool $incrementing = false; + + protected array $fillable = ['id']; + + protected string $keyType = 'string'; + + protected array $casts = ['id' => UuidCast::class]; +} From 20365db897ee7ef7d9aa6f6a6c520c6a2c620365 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:10:58 +0000 Subject: [PATCH 15/26] feat(database): complete current schema builder parity Add model-aware foreign UUIDs, foreign-key inspection, PostgreSQL tsvector columns, and native MariaDB vector indexes while keeping driver support explicit. Correct PostgreSQL pre-9.1 collation inspection and sequence initialization for wrapped custom schemas, expose facade metadata, and retain concise markers for deliberately omitted deprecated forwarding. Port the supported blueprint, builder, MariaDB, PostgreSQL, and SQLite grammar regressions for inferred keys, names and column lists, vector SQL, server versions, and exact sequence expressions. --- src/database/src/Schema/Blueprint.php | 39 ++++++++++- src/database/src/Schema/Builder.php | 14 ++++ src/database/src/Schema/Grammars/Grammar.php | 8 +++ .../src/Schema/Grammars/MariaDbGrammar.php | 15 ++++ .../src/Schema/Grammars/PostgresGrammar.php | 27 +++++-- src/support/src/Facades/Schema.php | 1 + .../DatabaseMariaDbSchemaGrammarTest.php | 26 +++++++ .../DatabasePostgresSchemaGrammarTest.php | 70 ++++++++++++++++--- .../Database/DatabaseSchemaBlueprintTest.php | 27 +++++++ .../DatabaseSchemaBuilderIntegrationTest.php | 46 +++++++++++- .../Sqlite/DatabaseSchemaBlueprintTest.php | 4 +- 11 files changed, 258 insertions(+), 19 deletions(-) diff --git a/src/database/src/Schema/Blueprint.php b/src/database/src/Schema/Blueprint.php index dfd507b55..79f13e888 100755 --- a/src/database/src/Schema/Blueprint.php +++ b/src/database/src/Schema/Blueprint.php @@ -8,8 +8,10 @@ use Closure; use Hypervel\Database\Connection; use Hypervel\Database\Eloquent\Concerns\HasUlids; +use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Query\Expression; use Hypervel\Database\Schema\Grammars\Grammar; +use Hypervel\Database\Schema\Grammars\MariaDbGrammar; use Hypervel\Database\Schema\Grammars\MySqlGrammar; use Hypervel\Database\Schema\Grammars\SQLiteGrammar; use Hypervel\Support\Collection; @@ -149,6 +151,9 @@ protected function ensureCommandsAreValid(): void { } + // REMOVED: Laravel's deprecated commandsNamed() helper is omitted; + // filter getCommands() when command inspection is needed. + /** * Add the commands that are implied by the blueprint's state. */ @@ -567,7 +572,11 @@ public function spatialIndex(array|string $columns, ?string $name = null, ?strin */ public function vectorIndex(string $column, ?string $name = null): Fluent { - return $this->indexCommand('vectorIndex', $column, $name, 'hnsw', 'vector_cosine_ops'); + [$algorithm, $operatorClass] = $this->grammar instanceof MariaDbGrammar + ? [null, 'M=6 DISTANCE=cosine'] + : ['hnsw', 'vector_cosine_ops']; + + return $this->indexCommand('vectorIndex', $column, $name, $algorithm, $operatorClass); } /** @@ -828,6 +837,20 @@ public function foreignIdFor(object|string $model, ?string $column = null): Fore ->referencesModelColumn($model->getKeyName()); } + /** + * Create a foreign UUID column for the given model. + */ + public function foreignUuidFor(Model|string $model, ?string $column = null): ForeignIdColumnDefinition + { + if (is_string($model)) { + $model = new $model; + } + + return $this->foreignUuid($column ?: $model->getForeignKey()) + ->table($model->getTable()) + ->referencesModelColumn($model->getKeyName()); + } + /** * Create a new float column on the table. */ @@ -1154,6 +1177,14 @@ public function vector(string $column, ?int $dimensions = null): ColumnDefinitio return $this->addColumn('vector', $column, $options); } + /** + * Create a new tsvector column on the table. + */ + public function tsvector(string $column): ColumnDefinition + { + return $this->addColumn('tsvector', $column); + } + /** * Add the proper columns for a polymorphic table. */ @@ -1441,6 +1472,9 @@ public function getTable(): string return $this->table; } + // REMOVED: Laravel's deprecated getPrefix() forwarding is omitted; + // use the connection's getTablePrefix() method. + /** * Get the columns on the blueprint. * @@ -1489,6 +1523,9 @@ public function getAddedColumns(): array }); } + // REMOVED: Laravel's deprecated getChangedColumns() helper is omitted; + // filter getColumns() by each definition's change flag. + /** * Get the default time precision. */ diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php index 810003965..a24485b4f 100755 --- a/src/database/src/Schema/Builder.php +++ b/src/database/src/Schema/Builder.php @@ -412,6 +412,20 @@ public function hasIndex(string $table, array|string $index, ?string $type = nul return false; } + /** + * Determine if the table has a given foreign key. + */ + public function hasForeignKey(string $table, array|string $foreignKey): bool + { + foreach ($this->getForeignKeys($table) as $value) { + if ($value['name'] === $foreignKey || $value['columns'] === $foreignKey) { + return true; + } + } + + return false; + } + /** * Get the foreign keys for a given table. */ diff --git a/src/database/src/Schema/Grammars/Grammar.php b/src/database/src/Schema/Grammars/Grammar.php index 12316e1c0..2a6f4a9d8 100755 --- a/src/database/src/Schema/Grammars/Grammar.php +++ b/src/database/src/Schema/Grammars/Grammar.php @@ -295,6 +295,14 @@ protected function typeVector(Fluent $column): string throw new RuntimeException('This database driver does not support the vector type.'); } + /** + * Create the column definition for a tsvector type. + */ + protected function typeTsvector(Fluent $column): string + { + throw new RuntimeException('This database driver does not support the tsvector type.'); + } + /** * Create the column definition for a raw column type. */ diff --git a/src/database/src/Schema/Grammars/MariaDbGrammar.php b/src/database/src/Schema/Grammars/MariaDbGrammar.php index 0817a1a2b..d63f286d8 100755 --- a/src/database/src/Schema/Grammars/MariaDbGrammar.php +++ b/src/database/src/Schema/Grammars/MariaDbGrammar.php @@ -50,6 +50,21 @@ protected function typeGeometry(Fluent $column): string ); } + /** + * Compile a vector index key command. + */ + public function compileVectorIndex(Blueprint $blueprint, Fluent $command): string + { + return sprintf( + 'alter table %s add vector index %s(%s) %s%s', + $this->wrapTable($blueprint), + $this->wrap($command->index), + $this->columnize($command->columns), + $command->operatorClass ?? '', + $command->lock ? ', lock=' . $command->lock : '' + ); + } + /** * Wrap the given JSON selector. */ diff --git a/src/database/src/Schema/Grammars/PostgresGrammar.php b/src/database/src/Schema/Grammars/PostgresGrammar.php index 7478d748e..f1af24bd5 100755 --- a/src/database/src/Schema/Grammars/PostgresGrammar.php +++ b/src/database/src/Schema/Grammars/PostgresGrammar.php @@ -133,12 +133,16 @@ protected function compileSchemaWhereClause(string|array|null $schema, string $c */ public function compileColumns(?string $schema, string $table): string { + $serverVersion = $this->connection->getServerVersion(); + return sprintf( 'select a.attname as name, t.typname as type_name, format_type(a.atttypid, a.atttypmod) as type, ' - . '(select tc.collcollate from pg_catalog.pg_collation tc where tc.oid = a.attcollation) as collation, ' + . (version_compare($serverVersion, '9.1', '<') + ? 'null as collation, ' + : '(select tc.collcollate from pg_catalog.pg_collation tc where tc.oid = a.attcollation) as collation, ') . 'not a.attnotnull as nullable, ' . '(select pg_get_expr(adbin, adrelid) from pg_attrdef where c.oid = pg_attrdef.adrelid and pg_attrdef.adnum = a.attnum) as default, ' - . (version_compare($this->connection->getServerVersion(), '12.0', '<') ? "'' as generated, " : 'a.attgenerated as generated, ') + . (version_compare($serverVersion, '12.0', '<') ? "'' as generated, " : 'a.attgenerated as generated, ') . 'col_description(c.oid, a.attnum) as comment ' . 'from pg_attribute a, pg_class c, pg_type t, pg_namespace n ' . 'where c.relname = %s and n.nspname = %s and a.attnum > 0 and a.attrelid = c.oid and a.atttypid = t.oid and n.oid = c.relnamespace ' @@ -228,11 +232,12 @@ public function compileAutoIncrementStartingValues(Blueprint $blueprint, Fluent { if ($command->column->autoIncrement && $value = $command->column->get('startingValue', $command->column->get('from'))) { - [$schema, $table] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($blueprint->getTable()); - - $table = ($schema ? $schema . '.' : '') . $this->connection->getTablePrefix() . $table; - - return 'alter sequence ' . $table . '_' . $command->column->name . '_seq restart with ' . $value; + return sprintf( + 'select setval(pg_get_serial_sequence(%s, %s), %s, false)', + $this->quoteString($this->wrapTable($blueprint)), + $this->quoteString($command->column->name), + $value + ); } return null; @@ -947,6 +952,14 @@ protected function typeVector(Fluent $column): string : 'vector'; } + /** + * Create the column definition for a tsvector type. + */ + protected function typeTsvector(Fluent $column): string + { + return 'tsvector'; + } + /** * Get the SQL for a collation column modifier. */ diff --git a/src/support/src/Facades/Schema.php b/src/support/src/Facades/Schema.php index 31a24df4e..78806884f 100644 --- a/src/support/src/Facades/Schema.php +++ b/src/support/src/Facades/Schema.php @@ -32,6 +32,7 @@ * @method static array getIndexes(string $table) * @method static array getIndexListing(string $table) * @method static bool hasIndex(string $table, array|string $index, string|null $type = null) + * @method static bool hasForeignKey(string $table, array|string $foreignKey) * @method static array getForeignKeys(string $table) * @method static void table(string $table, \Closure $callback) * @method static void create(string $table, \Closure $callback) diff --git a/tests/Database/DatabaseMariaDbSchemaGrammarTest.php b/tests/Database/DatabaseMariaDbSchemaGrammarTest.php index 909e2a450..55f5ddc3a 100755 --- a/tests/Database/DatabaseMariaDbSchemaGrammarTest.php +++ b/tests/Database/DatabaseMariaDbSchemaGrammarTest.php @@ -419,6 +419,32 @@ public function testAddingFluentSpatialIndex() $this->assertSame('alter table `geo` add spatial index `geo_coordinates_spatialindex`(`coordinates`)', $statements[1]); } + public function testAddingVectorIndex(): void + { + $blueprint = new Blueprint($this->getConnection(), 'embeddings'); + $blueprint->vectorIndex('embedding'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame( + 'alter table `embeddings` add vector index `embeddings_embedding_vectorindex`(`embedding`) M=6 DISTANCE=cosine', + $statements[0] + ); + } + + public function testAddingVectorIndexWithNameAndLock(): void + { + $blueprint = new Blueprint($this->getConnection(), 'embeddings'); + $blueprint->vectorIndex('embedding', 'embedding_vector_index')->lock('none'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame( + 'alter table `embeddings` add vector index `embedding_vector_index`(`embedding`) M=6 DISTANCE=cosine, lock=none', + $statements[0] + ); + } + public function testAddingRawIndex() { $blueprint = new Blueprint($this->getConnection(), 'users'); diff --git a/tests/Database/DatabasePostgresSchemaGrammarTest.php b/tests/Database/DatabasePostgresSchemaGrammarTest.php index 297b767f9..da964256c 100755 --- a/tests/Database/DatabasePostgresSchemaGrammarTest.php +++ b/tests/Database/DatabasePostgresSchemaGrammarTest.php @@ -53,10 +53,39 @@ public function testAddingVector() $this->assertSame('alter table "embeddings" add column "embedding" vector(384) not null', $statements[0]); } + public function testAddingTsvectorColumn(): void + { + $blueprint = new Blueprint($this->getConnection(), 'test'); + $blueprint->tsvector('search_vector'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "test" add column "search_vector" tsvector not null', $statements[0]); + } + + public function testAddingNullableTsvectorColumn(): void + { + $blueprint = new Blueprint($this->getConnection(), 'test'); + $blueprint->tsvector('search_vector')->nullable(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "test" add column "search_vector" tsvector null', $statements[0]); + } + + public function testAddingTsvectorColumnWithStoredAs(): void + { + $blueprint = new Blueprint($this->getConnection(), 'test'); + $blueprint->tsvector('search_vector')->nullable()->storedAs("to_tsvector('english', coalesce(name, ''))"); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "test" add column "search_vector" tsvector null generated always as (to_tsvector(\'english\', coalesce(name, \'\'))) stored', $statements[0]); + } + public function testCreateTableWithAutoIncrementStartingValue() { $connection = $this->getConnection(); - $connection->getSchemaBuilder()->shouldReceive('parseSchemaAndTable')->andReturn([null, 'users']); $blueprint = new Blueprint($connection, 'users'); $blueprint->create(); @@ -67,15 +96,12 @@ public function testCreateTableWithAutoIncrementStartingValue() $this->assertCount(2, $statements); $this->assertSame('create table "users" ("id" serial not null primary key, "email" varchar(255) not null, "name" varchar(255) collate "nb_NO.utf8" not null)', $statements[0]); - $this->assertSame('alter sequence users_id_seq restart with 1000', $statements[1]); + $this->assertSame("select setval(pg_get_serial_sequence('\"users\"', 'id'), 1000, false)", $statements[1]); } public function testAddColumnsWithMultipleAutoIncrementStartingValue() { - $builder = $this->getBuilder(); - $builder->shouldReceive('parseSchemaAndTable')->andReturn([null, 'users']); - - $blueprint = new Blueprint($this->getConnection(builder: $builder), 'users'); + $blueprint = new Blueprint($this->getConnection(), 'users'); $blueprint->id()->from(100); $blueprint->increments('code')->from(200); $blueprint->string('name')->from(300); @@ -85,11 +111,23 @@ public function testAddColumnsWithMultipleAutoIncrementStartingValue() 'alter table "users" add column "id" bigserial not null primary key', 'alter table "users" add column "code" serial not null primary key', 'alter table "users" add column "name" varchar(255) not null', - 'alter sequence users_id_seq restart with 100', - 'alter sequence users_code_seq restart with 200', + "select setval(pg_get_serial_sequence('\"users\"', 'id'), 100, false)", + "select setval(pg_get_serial_sequence('\"users\"', 'code'), 200, false)", ], $statements); } + public function testAutoIncrementStartingValueUsesTheWrappedSchemaAndTable(): void + { + $blueprint = new Blueprint($this->getConnection(prefix: 'prefix_'), 'tenant.users'); + $blueprint->increments('id')->startingValue(1000); + $statements = $blueprint->toSql(); + + $this->assertSame( + "select setval(pg_get_serial_sequence('\"tenant\".\"prefix_users\"', 'id'), 1000, false)", + $statements[1] + ); + } + public function testCreateTableAndCommentColumn() { $blueprint = new Blueprint($this->getConnection(), 'users'); @@ -1358,6 +1396,22 @@ public function testCompileColumns() $statement = $connection->getSchemaGrammar()->compileColumns('public', 'table'); $this->assertStringContainsString("where c.relname = 'table' and n.nspname = 'public'", $statement); + $this->assertStringContainsString('pg_catalog.pg_collation', $statement); + $this->assertStringContainsString('a.attgenerated as generated', $statement); + } + + public function testCompileColumnsOnLegacyServer(): void + { + $connection = $this->getConnection(); + $connection->shouldReceive('getServerVersion')->once()->andReturn('8.0.2'); + + $statement = $connection->getSchemaGrammar()->compileColumns('public', 'table'); + + $this->assertStringContainsString("where c.relname = 'table' and n.nspname = 'public'", $statement); + $this->assertStringContainsString('null as collation', $statement); + $this->assertStringContainsString("'' as generated", $statement); + $this->assertStringNotContainsString('pg_catalog.pg_collation', $statement); + $this->assertStringNotContainsString('a.attgenerated', $statement); } protected function getConnection( diff --git a/tests/Database/DatabaseSchemaBlueprintTest.php b/tests/Database/DatabaseSchemaBlueprintTest.php index 374710df7..abaae35d9 100755 --- a/tests/Database/DatabaseSchemaBlueprintTest.php +++ b/tests/Database/DatabaseSchemaBlueprintTest.php @@ -462,6 +462,19 @@ public function testGenerateRelationshipColumnWithUuidModel() ], $getSql('MySql')); } + public function testGenerateUuidRelationshipColumnWithUuidModel(): void + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->foreignUuidFor(Fixtures\Models\EloquentModelUsingUuid::class); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `posts` add `model_using_uuid_id` char(36) not null', + ], $getSql('MySql')); + } + public function testGenerateRelationshipColumnWithUlidModel() { $getSql = function ($grammar) { @@ -493,6 +506,20 @@ public function testGenerateRelationshipConstrainedColumn() ], $getSql('MySql')); } + public function testGenerateUuidRelationshipConstrainedColumn(): void + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->foreignUuidFor(Fixtures\Models\EloquentModelUsingUuid::class)->constrained(); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `posts` add `model_using_uuid_id` char(36) not null', + 'alter table `posts` add constraint `posts_model_using_uuid_id_foreign` foreign key (`model_using_uuid_id`) references `model` (`id`)', + ], $getSql('MySql')); + } + public function testGenerateRelationshipForModelWithNonStandardPrimaryKeyName() { $getSql = function ($grammar) { diff --git a/tests/Database/DatabaseSchemaBuilderIntegrationTest.php b/tests/Database/DatabaseSchemaBuilderIntegrationTest.php index ead0c9f0c..0cd759edc 100644 --- a/tests/Database/DatabaseSchemaBuilderIntegrationTest.php +++ b/tests/Database/DatabaseSchemaBuilderIntegrationTest.php @@ -6,6 +6,7 @@ use Hypervel\Database\Capsule\Manager as DB; use Hypervel\Database\Schema\Blueprint; +use Hypervel\Database\Schema\Builder; use Hypervel\Tests\TestCase; class DatabaseSchemaBuilderIntegrationTest extends TestCase @@ -72,6 +73,37 @@ public function testHasColumnAndIndexWithPrefixIndexEnabled() $this->assertTrue($this->schemaBuilder()->hasIndex('table1', 'example_table1_name_index')); } + public function testHasForeignKeyWithSQLiteForeignKeys(): void + { + $this->schemaBuilder()->create('users', function (Blueprint $table) { + $table->id(); + }); + + $this->schemaBuilder()->create('posts', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id'); + $table->foreign('user_id', 'posts_user_id_foreign')->references('id')->on('users'); + }); + + $this->assertTrue($this->schemaBuilder()->hasForeignKey('posts', ['user_id'])); + $this->assertFalse($this->schemaBuilder()->hasForeignKey('posts', ['missing_id'])); + } + + public function testHasForeignKeyCanMatchForeignKeyNamesAndColumns(): void + { + $builder = new ForeignKeySchemaBuilderStub([ + [ + 'name' => 'posts_user_id_foreign', + 'columns' => ['user_id'], + ], + ]); + + $this->assertTrue($builder->hasForeignKey('posts', 'posts_user_id_foreign')); + $this->assertTrue($builder->hasForeignKey('posts', ['user_id'])); + $this->assertFalse($builder->hasForeignKey('posts', 'posts_missing_foreign')); + $this->assertFalse($builder->hasForeignKey('posts', ['missing_id'])); + } + public function testDropColumnWithTablePrefix() { $this->db->connection()->setTablePrefix('test_'); @@ -95,8 +127,20 @@ public function testDropColumnWithTablePrefix() $this->assertFalse($this->schemaBuilder()->hasColumn('pandemic_table', 'covid19')); } - private function schemaBuilder(): \Hypervel\Database\Schema\Builder + private function schemaBuilder(): Builder { return $this->db->connection()->getSchemaBuilder(); } } + +class ForeignKeySchemaBuilderStub extends Builder +{ + public function __construct(protected array $foreignKeys) + { + } + + public function getForeignKeys(string $table): array + { + return $this->foreignKeys; + } +} diff --git a/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php b/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php index f092458cf..75295a506 100644 --- a/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php +++ b/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php @@ -70,7 +70,7 @@ public function testRenamingColumnsWorks() $this->assertTrue($schema->hasColumns('test', ['bar', 'qux'])); } - public function testNativeColumnModifyingOnPostgreSql() + public function testNativeColumnModifyingOnPostgreSql(): void { $blueprint = $this->getBlueprint('Postgres', 'users', function ($table) { $table->integer('code')->autoIncrement()->from(10)->comment('my comment')->change(); @@ -80,7 +80,7 @@ public function testNativeColumnModifyingOnPostgreSql() 'alter table "users" ' . 'alter column "code" type integer, ' . 'alter column "code" set not null', - 'alter sequence users_code_seq restart with 10', + 'select setval(pg_get_serial_sequence(\'"users"\', \'code\'), 10, false)', 'comment on column "users"."code" is \'my comment\'', ], $blueprint->toSql()); From 061fd4cde80aac43f8af8d84f38f36a237acaf91 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:11:10 +0000 Subject: [PATCH 16/26] feat(database): expose unique constraint metadata Attach the offending constraint name and available columns to UniqueConstraintViolationException while preserving the original query, bindings, driver exception, connection details, and read or write classification. Parse native MySQL, MariaDB, PostgreSQL, and SQLite messages at each driver boundary and degrade to null or an empty list when the server omits usable metadata. Also honor the active PostgreSQL read or write PDO configuration when deciding emulated-prepare boolean handling. Cover parsed and unparseable unit messages, live supported-driver violations, multiple columns, named constraints, and read versus write prepare options. --- src/database/src/MySqlConnection.php | 16 +++ src/database/src/PostgresConnection.php | 27 +++- src/database/src/SQLiteConnection.php | 21 +++ .../UniqueConstraintViolationException.php | 33 +++++ .../DatabasePostgresConnectionTest.php | 40 +++++- .../DatabaseUniqueConstraintViolationTest.php | 61 +++++++++ .../UniqueConstraintViolationTest.php | 120 ++++++++++++++++++ 7 files changed, 313 insertions(+), 5 deletions(-) create mode 100644 tests/Database/DatabaseUniqueConstraintViolationTest.php create mode 100644 tests/Integration/Database/UniqueConstraintViolationTest.php diff --git a/src/database/src/MySqlConnection.php b/src/database/src/MySqlConnection.php index b7d5f5914..eb8dc2cbe 100755 --- a/src/database/src/MySqlConnection.php +++ b/src/database/src/MySqlConnection.php @@ -73,6 +73,22 @@ protected function isUniqueConstraintError(Exception $exception): bool return (bool) preg_match('#Integrity constraint violation: 1062#i', $exception->getMessage()); } + /** + * Extract the index that caused a unique constraint violation. + * + * @return array{index: null|string, columns: list} + */ + protected function parseUniqueConstraintViolation(Exception $exception): array + { + preg_match( + '#Duplicate entry \'.*?\' for key \'(?:.*?\.)?(.+?)\'#i', + $exception->getMessage(), + $matches + ); + + return ['columns' => [], 'index' => $matches[1] ?? null]; + } + /** * Get the connection's last insert ID. */ diff --git a/src/database/src/PostgresConnection.php b/src/database/src/PostgresConnection.php index 2d7833bb5..14cc95afe 100755 --- a/src/database/src/PostgresConnection.php +++ b/src/database/src/PostgresConnection.php @@ -81,7 +81,12 @@ public function prepareBindings(array $bindings): array */ protected function isUsingEmulatedPrepares(): bool { - return ($this->config['options'][PDO::ATTR_EMULATE_PREPARES] ?? false) === true; + $config = $this->latestReadWriteTypeUsed() === 'read' + && $this->readPdoConfig !== [] + ? $this->readPdoConfig + : $this->config; + + return (bool) ($config['options'][PDO::ATTR_EMULATE_PREPARES] ?? false); } /** @@ -92,6 +97,26 @@ protected function isUniqueConstraintError(Exception $exception): bool return $exception->getCode() === '23505'; } + /** + * Extract the index and columns that caused a unique constraint violation. + * + * @return array{index: null|string, columns: list} + */ + protected function parseUniqueConstraintViolation(Exception $exception): array + { + [$index, $columns] = [null, []]; + + if (preg_match('#unique constraint "([^"]+)"#i', $message = $exception->getMessage(), $matches)) { + $index = $matches[1]; + } + + if (preg_match('#Key \(([^)]+)\)=#i', $message, $matches)) { + $columns = array_map(trim(...), explode(',', $matches[1])); + } + + return ['columns' => $columns, 'index' => $index]; + } + /** * Get the default query grammar instance. */ diff --git a/src/database/src/SQLiteConnection.php b/src/database/src/SQLiteConnection.php index 94b0d0a8b..ce3a5a66f 100755 --- a/src/database/src/SQLiteConnection.php +++ b/src/database/src/SQLiteConnection.php @@ -57,6 +57,27 @@ protected function isUniqueConstraintError(Exception $exception): bool return (bool) preg_match('#(column(s)? .* (is|are) not unique|UNIQUE constraint failed: .*)#i', $exception->getMessage()); } + /** + * Extract the columns that caused a unique constraint violation. + * + * @return array{index: null, columns: list} + */ + protected function parseUniqueConstraintViolation(Exception $exception): array + { + preg_match('#UNIQUE constraint failed: (.+)#i', $exception->getMessage(), $matches); + + $columns = []; + + if (isset($matches[1])) { + $columns = array_map( + static fn (string $column): string => last(explode('.', trim($column))), + explode(',', $matches[1]) + ); + } + + return ['columns' => $columns, 'index' => null]; + } + /** * Get the default query grammar instance. */ diff --git a/src/database/src/UniqueConstraintViolationException.php b/src/database/src/UniqueConstraintViolationException.php index 181721efd..75112882d 100644 --- a/src/database/src/UniqueConstraintViolationException.php +++ b/src/database/src/UniqueConstraintViolationException.php @@ -6,4 +6,37 @@ class UniqueConstraintViolationException extends QueryException { + /** + * The unique index which prevented the query. + */ + public ?string $index = null; + + /** + * The columns which caused the violation. + * + * @var list + */ + public array $columns = []; + + /** + * Set the unique index which caused the violation. + */ + public function setIndex(?string $index): self + { + $this->index = $index; + + return $this; + } + + /** + * Set the columns that caused the violation. + * + * @param list $columns + */ + public function setColumns(array $columns): self + { + $this->columns = $columns; + + return $this; + } } diff --git a/tests/Database/DatabasePostgresConnectionTest.php b/tests/Database/DatabasePostgresConnectionTest.php index 864ccd8f2..ebef5e677 100644 --- a/tests/Database/DatabasePostgresConnectionTest.php +++ b/tests/Database/DatabasePostgresConnectionTest.php @@ -11,7 +11,7 @@ class DatabasePostgresConnectionTest extends TestCase { - public function testPrepareBindingsConvertsBooleansToPostgresLiteralsWhenEmulatedPreparesAreEnabled() + public function testPrepareBindingsConvertsBooleansToPostgresLiteralsWhenEmulatedPreparesAreEnabled(): void { $connection = $this->newConnection(emulatePrepares: true); @@ -28,7 +28,38 @@ public function testPrepareBindingsConvertsBooleansToPostgresLiteralsWhenEmulate ], $bindings); } - public function testPrepareBindingsFallsBackToDefaultBooleanCastingWhenEmulatedPreparesAreDisabled() + public function testPrepareBindingsConvertsBooleansForTruthyEmulatedPreparesConfiguration(): void + { + $connection = $this->newConnection(emulatePrepares: 1); + + $this->assertSame(['true', 'false'], $connection->prepareBindings([true, false])); + } + + public function testPrepareBindingsUsesActiveReadConnectionConfiguration(): void + { + $connection = $this->newConnection(emulatePrepares: false, readWriteType: 'read'); + $connection->setReadPdoConfig([ + 'options' => [ + PDO::ATTR_EMULATE_PREPARES => true, + ], + ]); + + $this->assertSame(['true', 'false'], $connection->prepareBindings([true, false])); + } + + public function testPrepareBindingsUsesWriteConnectionConfiguration(): void + { + $connection = $this->newConnection(emulatePrepares: true, readWriteType: 'write'); + $connection->setReadPdoConfig([ + 'options' => [ + PDO::ATTR_EMULATE_PREPARES => false, + ], + ]); + + $this->assertSame(['true', 'false'], $connection->prepareBindings([true, false])); + } + + public function testPrepareBindingsFallsBackToDefaultBooleanCastingWhenEmulatedPreparesAreDisabled(): void { $connection = $this->newConnection(emulatePrepares: false); @@ -45,7 +76,7 @@ public function testPrepareBindingsFallsBackToDefaultBooleanCastingWhenEmulatedP ], $bindings); } - public function testEscapeUsesPostgresBooleanLiterals() + public function testEscapeUsesPostgresBooleanLiterals(): void { $connection = $this->newConnection(emulatePrepares: true); @@ -53,7 +84,7 @@ public function testEscapeUsesPostgresBooleanLiterals() $this->assertSame('false', $connection->escape(false)); } - protected function newConnection(bool $emulatePrepares): PostgresConnection + protected function newConnection(bool|int $emulatePrepares, ?string $readWriteType = null): PostgresConnection { return new PostgresConnection( new DatabasePostgresConnectionPdoStub, @@ -62,6 +93,7 @@ protected function newConnection(bool $emulatePrepares): PostgresConnection [ 'name' => 'test', 'driver' => 'pgsql', + PostgresConnection::READ_WRITE_TYPE_CONFIG_KEY => $readWriteType, 'options' => [ PDO::ATTR_EMULATE_PREPARES => $emulatePrepares, ], diff --git a/tests/Database/DatabaseUniqueConstraintViolationTest.php b/tests/Database/DatabaseUniqueConstraintViolationTest.php new file mode 100644 index 000000000..5ffe563e0 --- /dev/null +++ b/tests/Database/DatabaseUniqueConstraintViolationTest.php @@ -0,0 +1,61 @@ +assertSame([ + 'columns' => [], + 'index' => null, + ], $method->invoke($connection, $exception)); + } + + /** + * @return Generator, Exception}> + */ + public static function unparseableConstraintProvider(): Generator + { + yield 'mysql' => [ + MySqlConnection::class, + new Exception('Integrity constraint violation: 1062 Duplicate entry'), + ]; + + yield 'postgres' => [ + PostgresConnection::class, + new Exception('duplicate key value violates unique constraint'), + ]; + + yield 'sqlite' => [ + SQLiteConnection::class, + new Exception('columns are not unique'), + ]; + } +} + +class DatabaseUniqueConstraintViolationPdoStub extends PDO +{ + public function __construct() + { + } +} diff --git a/tests/Integration/Database/UniqueConstraintViolationTest.php b/tests/Integration/Database/UniqueConstraintViolationTest.php new file mode 100644 index 000000000..fc5efa5f7 --- /dev/null +++ b/tests/Integration/Database/UniqueConstraintViolationTest.php @@ -0,0 +1,120 @@ +id(); + $table->string('name')->unique('single_unique_idx'); + }); + + Schema::create('test_unique_constraint_composite', function (Blueprint $table) { + $table->id(); + $table->string('first_name'); + $table->string('last_name'); + + $table->unique(['first_name', 'last_name'], 'unique_composite_idx'); + }); + } + + private function createUniqueModel(): UniqueConstraintViolationException + { + UniqueSingleModel::query()->create(['name' => 'test']); + try { + UniqueSingleModel::query()->create(['name' => 'test']); + } catch (UniqueConstraintViolationException $e) { + return $e; + } + + $this->fail('No exception was thrown'); + } + + private function createCompositeModel(): UniqueConstraintViolationException + { + UniqueCompositeModel::query()->create(['first_name' => 'Taylor', 'last_name' => 'Otwell']); + try { + UniqueCompositeModel::query()->create(['first_name' => 'Taylor', 'last_name' => 'Otwell']); + } catch (UniqueConstraintViolationException $e) { + return $e; + } + + $this->fail('No exception was thrown'); + } + + #[RequiresDatabase('sqlite')] + public function testSqliteUniqueConstraint(): void + { + $e = $this->createUniqueModel(); + $this->assertSame(['name'], $e->columns); + $this->assertNull($e->index); + } + + #[RequiresDatabase('sqlite')] + public function testSqliteUniqueCompositeConstraint(): void + { + $e = $this->createCompositeModel(); + $this->assertSame(['first_name', 'last_name'], $e->columns); + $this->assertNull($e->index); + } + + #[RequiresDatabase(['mysql', 'mariadb'])] + public function testMysqlUniqueConstraint(): void + { + $e = $this->createUniqueModel(); + $this->assertSame('single_unique_idx', $e->index); + $this->assertSame([], $e->columns); + } + + #[RequiresDatabase(['mysql', 'mariadb'])] + public function testMysqlUniqueCompositeConstraint(): void + { + $e = $this->createCompositeModel(); + $this->assertSame('unique_composite_idx', $e->index); + $this->assertSame([], $e->columns); + } + + #[RequiresDatabase('pgsql')] + public function testPostgresUniqueConstraint(): void + { + $e = $this->createUniqueModel(); + $this->assertSame('single_unique_idx', $e->index); + $this->assertSame(['name'], $e->columns); + } + + #[RequiresDatabase('pgsql')] + public function testPostgresUniqueCompositeConstraint(): void + { + $e = $this->createCompositeModel(); + $this->assertSame('unique_composite_idx', $e->index); + $this->assertSame(['first_name', 'last_name'], $e->columns); + } +} + +class UniqueSingleModel extends Model +{ + protected ?string $table = 'test_unique_constraint'; + + protected array $fillable = ['name']; + + public bool $timestamps = false; +} + +class UniqueCompositeModel extends Model +{ + protected ?string $table = 'test_unique_constraint_composite'; + + protected array $fillable = ['first_name', 'last_name']; + + public bool $timestamps = false; +} From e7326fbddd1b4d25d840e82c374bed3083e2c255 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:11:22 +0000 Subject: [PATCH 17/26] feat(database): complete migration and connection tooling parity Expose migration filenames on start and end events and allow schema dumps to retain table definitions while omitting migration rows. Keep nullable migration-table state explicit through every schema-state implementation. Let migrate fresh recover through the existing migration path when repository inspection cannot reach a missing database, report the correct prune option, recognize current lost-connection messages, and detect MariaDB client versions with the documented minimum fallback. Refresh callback generic metadata and cover migration names, dump modes, missing databases, command validation, client detection, and schema-state output on supported environments. --- src/database/src/Console/DumpCommand.php | 7 +- .../src/Console/Migrations/FreshCommand.php | 11 ++- src/database/src/Console/PruneCommand.php | 2 +- src/database/src/DatabaseManager.php | 5 ++ src/database/src/Events/MigrationEvent.php | 8 ++- src/database/src/LostConnectionDetector.php | 2 + src/database/src/Migrations/Migrator.php | 17 +++-- .../src/Schema/MariaDbSchemaState.php | 26 +++++++ src/database/src/Schema/SchemaState.php | 8 ++- .../DatabaseMariaDbSchemaStateTest.php | 71 +++++++++++++++++++ .../DatabaseMigrationFreshCommandTest.php | 26 +++++++ tests/Database/PruneCommandTest.php | 2 +- .../Database/MigratorEventsTest.php | 16 +++-- .../Database/Sqlite/SchemaStateTest.php | 36 +++++++++- 14 files changed, 217 insertions(+), 20 deletions(-) diff --git a/src/database/src/Console/DumpCommand.php b/src/database/src/Console/DumpCommand.php index 1a1c919c8..3393f0608 100644 --- a/src/database/src/Console/DumpCommand.php +++ b/src/database/src/Console/DumpCommand.php @@ -26,7 +26,8 @@ class DumpCommand extends Command protected ?string $signature = 'schema:dump {--database= : The database connection to use} {--path= : The path where the schema dump file should be stored} - {--prune : Delete all existing migration files}'; + {--prune : Delete all existing migration files} + {--without-migration-data : Dump the schema without the migration data}'; /** * The console command description. @@ -79,6 +80,10 @@ protected function schemaState(Connection $connection): mixed $migrationTable = is_array($migrations) ? ($migrations['table'] ?? 'migrations') : $migrations; + if ($this->option('without-migration-data')) { + $migrationTable = null; + } + return $connection->getSchemaState() ->withMigrationTable($migrationTable) ->handleOutputUsing(function ($type, $buffer) { diff --git a/src/database/src/Console/Migrations/FreshCommand.php b/src/database/src/Console/Migrations/FreshCommand.php index d549c2077..3c318a7ec 100644 --- a/src/database/src/Console/Migrations/FreshCommand.php +++ b/src/database/src/Console/Migrations/FreshCommand.php @@ -12,6 +12,7 @@ use Hypervel\Database\Migrations\Migrator; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; +use Throwable; #[AsCommand(name: 'migrate:fresh')] class FreshCommand extends Command @@ -57,7 +58,13 @@ public function handle(): int $database = $this->input->getOption('database'); $this->migrator->usingConnection($database, function () use ($database) { - if ($this->migrator->repositoryExists()) { + try { + $repositoryExists = $this->migrator->repositoryExists(); + } catch (Throwable) { + $repositoryExists = false; + } + + if ($repositoryExists) { $this->newLine(); $this->components->task('Dropping all tables', fn () => $this->callSilent('db:wipe', array_filter([ @@ -81,7 +88,7 @@ public function handle(): int ])); if ($this->hypervel->bound(Dispatcher::class)) { - $this->hypervel[Dispatcher::class]->dispatch( + $this->hypervel->make(Dispatcher::class)->dispatch( new DatabaseRefreshed($database, $this->needsSeeding()) ); } diff --git a/src/database/src/Console/PruneCommand.php b/src/database/src/Console/PruneCommand.php index f22509db2..708ccbf72 100644 --- a/src/database/src/Console/PruneCommand.php +++ b/src/database/src/Console/PruneCommand.php @@ -112,7 +112,7 @@ protected function models(): Collection $except = $this->option('except'); if ($models && $except) { - throw new InvalidArgumentException('The --models and --except options cannot be combined.'); + throw new InvalidArgumentException('The --model and --except options cannot be combined.'); } if ($models) { diff --git a/src/database/src/DatabaseManager.php b/src/database/src/DatabaseManager.php index 0c1b2dbce..9a3597cf2 100755 --- a/src/database/src/DatabaseManager.php +++ b/src/database/src/DatabaseManager.php @@ -372,6 +372,11 @@ public function reconnect(UnitEnum|string|null $name = null): Connection * * Uses Context for coroutine-safe state management, ensuring concurrent * requests don't interfere with each other's default connection. + * + * @template TReturn + * + * @param callable(): TReturn $callback + * @return TReturn */ public function usingConnection(UnitEnum|string $name, callable $callback): mixed { diff --git a/src/database/src/Events/MigrationEvent.php b/src/database/src/Events/MigrationEvent.php index 0d9fdec4b..dbf96c8b2 100644 --- a/src/database/src/Events/MigrationEvent.php +++ b/src/database/src/Events/MigrationEvent.php @@ -19,12 +19,18 @@ abstract class MigrationEvent implements MigrationEventContract */ public string $method; + /** + * The migration name. + */ + public ?string $name; + /** * Create a new event instance. */ - public function __construct(Migration $migration, string $method) + public function __construct(Migration $migration, string $method, ?string $name = null) { $this->method = $method; $this->migration = $migration; + $this->name = $name; } } diff --git a/src/database/src/LostConnectionDetector.php b/src/database/src/LostConnectionDetector.php index 3125ec22f..d09613fbe 100644 --- a/src/database/src/LostConnectionDetector.php +++ b/src/database/src/LostConnectionDetector.php @@ -65,6 +65,7 @@ public function causedByLostConnection(Throwable $e): bool 'Unknown $curl_error_code: 77', 'SSL: Handshake timed out', 'SSL error: sslv3 alert unexpected message', + 'SSL error: ssl/tls alert unexpected message', 'unrecognized SSL error code:', 'SQLSTATE[HY000] [1045] Access denied for user', 'SQLSTATE[HY000] [2002] No connection could be made because the target machine actively refused it', @@ -83,6 +84,7 @@ public function causedByLostConnection(Throwable $e): bool 'Connection lost', 'Broken pipe', 'SQLSTATE[25006]: Read only sql transaction: 7', + 'failed: Connection refused', 'vtgate connection error: no healthy endpoints', 'primary is not serving, there may be a reparent operation in progress', 'current keyspace is being resharded', diff --git a/src/database/src/Migrations/Migrator.php b/src/database/src/Migrations/Migrator.php index 5830e2c10..740d4b117 100755 --- a/src/database/src/Migrations/Migrator.php +++ b/src/database/src/Migrations/Migrator.php @@ -212,7 +212,7 @@ protected function runUp(string $file, int $batch, bool $pretend): void $this->write(Task::class, $name, fn () => MigrationResult::Skipped->value); } else { - $this->write(Task::class, $name, fn () => $this->runMigration($migration, 'up')); + $this->write(Task::class, $name, fn () => $this->runMigration($migration, 'up', $name)); // Once we have run a migrations class, we will log that it was run in this // repository so that we don't try to run it next time we do a migration @@ -370,7 +370,7 @@ protected function runDown(string $file, object $migration, bool $pretend): void return; } - $this->write(Task::class, $name, fn () => $this->runMigration($instance, 'down')); + $this->write(Task::class, $name, fn () => $this->runMigration($instance, 'down', $name)); // Once we have successfully run the migration "down" we will remove it from // the migration repository so it will be considered to have not been run @@ -381,19 +381,19 @@ protected function runDown(string $file, object $migration, bool $pretend): void /** * Run a migration inside a transaction if the database supports it. */ - protected function runMigration(object $migration, string $method): void + protected function runMigration(object $migration, string $method, ?string $name = null): void { $connection = $this->resolveConnection( $migration->getConnection() ); - $callback = function () use ($connection, $migration, $method) { + $callback = function () use ($connection, $migration, $method, $name) { if (method_exists($migration, $method)) { - $this->fireMigrationEvent(new MigrationStarted($migration, $method)); + $this->fireMigrationEvent(new MigrationStarted($migration, $method, $name)); $this->runMethod($connection, $migration, $method); - $this->fireMigrationEvent(new MigrationEnded($migration, $method)); + $this->fireMigrationEvent(new MigrationEnded($migration, $method, $name)); } }; @@ -635,6 +635,11 @@ public static function resolveMigrationConnectionName(?string $name): ?string * routing back through setConnection() — otherwise the restoration would * apply migrations_connection to the saved alias and leave the wrong * default in place. + * + * @template TReturn + * + * @param callable(): TReturn $callback + * @return TReturn */ public function usingConnection(?string $name, callable $callback): mixed { diff --git a/src/database/src/Schema/MariaDbSchemaState.php b/src/database/src/Schema/MariaDbSchemaState.php index b55ee1623..9f48a25ab 100644 --- a/src/database/src/Schema/MariaDbSchemaState.php +++ b/src/database/src/Schema/MariaDbSchemaState.php @@ -5,6 +5,7 @@ namespace Hypervel\Database\Schema; use Override; +use Symfony\Component\Process\Exception\ProcessFailedException; class MariaDbSchemaState extends MySqlSchemaState { @@ -37,4 +38,29 @@ protected function baseDumpCommand(): string return $command . ' "${:HYPERVEL_LOAD_DATABASE}"'; } + + /** + * Detect the MariaDB client version. + * + * @return array{version: string, isMariaDb: bool} + */ + protected function detectClientVersion(): array + { + // Minimum version of MariaDB that supports the mariadb command... + $version = '10.5.2'; + + try { + $versionOutput = $this->makeProcess('mariadb --version')->mustRun()->getOutput(); + + if (preg_match('/(\d+\.\d+\.\d+)/', $versionOutput, $matches)) { + $version = $matches[1]; + } + } catch (ProcessFailedException) { + } + + return [ + 'isMariaDb' => true, + 'version' => $version, + ]; + } } diff --git a/src/database/src/Schema/SchemaState.php b/src/database/src/Schema/SchemaState.php index 1250e9060..484a0d584 100644 --- a/src/database/src/Schema/SchemaState.php +++ b/src/database/src/Schema/SchemaState.php @@ -24,7 +24,7 @@ abstract class SchemaState /** * The name of the application's migration table. */ - protected string $migrationTable = 'migrations'; + protected ?string $migrationTable = 'migrations'; /** * The process factory callback. @@ -84,7 +84,9 @@ public function makeProcess(mixed ...$arguments): Process */ public function hasMigrationTable(): bool { - return $this->connection->getSchemaBuilder()->hasTable($this->migrationTable); + return $this->migrationTable !== null + && $this->migrationTable !== '' + && $this->connection->getSchemaBuilder()->hasTable($this->migrationTable); } /** @@ -98,7 +100,7 @@ protected function getMigrationTable(): string /** * Specify the name of the application's migration table. */ - public function withMigrationTable(string $table): static + public function withMigrationTable(?string $table): static { $this->migrationTable = $table; diff --git a/tests/Database/DatabaseMariaDbSchemaStateTest.php b/tests/Database/DatabaseMariaDbSchemaStateTest.php index 40fabb05f..98226e1f7 100644 --- a/tests/Database/DatabaseMariaDbSchemaStateTest.php +++ b/tests/Database/DatabaseMariaDbSchemaStateTest.php @@ -11,6 +11,8 @@ use PDO; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; +use Symfony\Component\Process\Exception\ProcessFailedException; +use Symfony\Component\Process\Process; class DatabaseMariaDbSchemaStateTest extends TestCase { @@ -137,4 +139,73 @@ public static function provider(): Generator ], ]; } + + public function testDetectClientVersionUsesMariaDbClientOutput(): void + { + $connection = $this->createStub(MariaDbConnection::class); + $process = $this->createMock(Process::class); + $process->expects($this->once())->method('mustRun')->willReturnSelf(); + $process->expects($this->once())->method('getOutput')->willReturn('mariadb from 11.8.3-MariaDB'); + + $schemaState = new class($connection, $process) extends MariaDbSchemaState { + public function __construct(MariaDbConnection $connection, private readonly Process $process) + { + parent::__construct($connection); + } + + public function makeProcess(mixed ...$arguments): Process + { + return $this->process; + } + + public function clientVersion(): array + { + return $this->detectClientVersion(); + } + }; + + $this->assertSame([ + 'isMariaDb' => true, + 'version' => '11.8.3', + ], $schemaState->clientVersion()); + } + + public function testDetectClientVersionFallsBackToMinimumSupportedVersion(): void + { + $connection = $this->createStub(MariaDbConnection::class); + $failedProcess = $this->createStub(Process::class); + $failedProcess->method('isSuccessful')->willReturn(false); + $failedProcess->method('getCommandLine')->willReturn("'mariadb' '--version'"); + $failedProcess->method('getExitCode')->willReturn(1); + $failedProcess->method('getExitCodeText')->willReturn('General error'); + $failedProcess->method('getWorkingDirectory')->willReturn(null); + $failedProcess->method('isOutputDisabled')->willReturn(true); + + $process = $this->createMock(Process::class); + $process->expects($this->once())->method('mustRun')->willThrowException( + new ProcessFailedException($failedProcess) + ); + + $schemaState = new class($connection, $process) extends MariaDbSchemaState { + public function __construct(MariaDbConnection $connection, private readonly Process $process) + { + parent::__construct($connection); + } + + public function makeProcess(mixed ...$arguments): Process + { + return $this->process; + } + + public function clientVersion(): array + { + return $this->detectClientVersion(); + } + }; + + $this->assertSame([ + 'isMariaDb' => true, + 'version' => '10.5.2', + ], $schemaState->clientVersion()); + } } diff --git a/tests/Database/DatabaseMigrationFreshCommandTest.php b/tests/Database/DatabaseMigrationFreshCommandTest.php index 77f20718f..617d76cce 100644 --- a/tests/Database/DatabaseMigrationFreshCommandTest.php +++ b/tests/Database/DatabaseMigrationFreshCommandTest.php @@ -12,6 +12,7 @@ use Hypervel\Foundation\Application; use Hypervel\Tests\TestCase; use Mockery as m; +use RuntimeException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\NullOutput; @@ -64,6 +65,31 @@ public function testFreshCommandDropsTablesMigratesAndSeeds() $this->runCommand($command, ['--database' => 'sqlite', '--drop-views' => true, '--seed' => true, '--seeder' => 'Database\Seeders\CustomSeeder']); } + public function testFreshCommandRunsMigrationsWhenRepositoryLookupFails(): void + { + $app = new ApplicationDatabaseFreshStub; + $dispatcher = $app->instance(Dispatcher::class, m::mock(Dispatcher::class)->shouldIgnoreMissing()); + $command = $this->getMockBuilder(FreshCommand::class) + ->onlyMethods(['call', 'callSilent']) + ->setConstructorArgs([$migrator = m::mock(Migrator::class)]) + ->getMock(); + $command->setHypervel($app); + + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing( + static fn ($name, $callback) => $callback() + ); + $migrator->shouldReceive('repositoryExists')->once()->andThrow(new RuntimeException('Database does not exist.')); + $dispatcher->shouldReceive('dispatch')->once()->with(m::type(DatabaseRefreshed::class)); + + $command->expects($this->never())->method('callSilent'); + $command->expects($this->once())->method('call')->with('migrate', [ + '--database' => 'missing', + '--force' => true, + ])->willReturn(0); + + $this->assertSame(0, $this->runCommand($command, ['--database' => 'missing'])); + } + protected function runCommand($command, array $input = []): int { return $command->run(new ArrayInput($input), new NullOutput); diff --git a/tests/Database/PruneCommandTest.php b/tests/Database/PruneCommandTest.php index 08e42aede..00ac65623 100644 --- a/tests/Database/PruneCommandTest.php +++ b/tests/Database/PruneCommandTest.php @@ -52,7 +52,7 @@ protected function setUp(): void public function testPrunableModelAndExceptWithEachOther() { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The --models and --except options cannot be combined.'); + $this->expectExceptionMessage('The --model and --except options cannot be combined.'); $this->artisan([ '--model' => Pruning\Models\PrunableTestModelWithPrunableRecords::class, diff --git a/tests/Integration/Database/MigratorEventsTest.php b/tests/Integration/Database/MigratorEventsTest.php index d961c5622..7107e1700 100644 --- a/tests/Integration/Database/MigratorEventsTest.php +++ b/tests/Integration/Database/MigratorEventsTest.php @@ -114,16 +114,24 @@ public function testMigrationEventsContainTheMigrationAndMethod() }); Event::assertDispatched(MigrationStarted::class, function ($event) { - return $event->method === 'up' && $event->migration instanceof Migration; + return $event->method === 'up' + && $event->migration instanceof Migration + && $event->name === '2014_10_12_000000_create_members_table'; }); Event::assertDispatched(MigrationStarted::class, function ($event) { - return $event->method === 'down' && $event->migration instanceof Migration; + return $event->method === 'down' + && $event->migration instanceof Migration + && $event->name === '2014_10_12_000000_create_members_table'; }); Event::assertDispatched(MigrationEnded::class, function ($event) { - return $event->method === 'up' && $event->migration instanceof Migration; + return $event->method === 'up' + && $event->migration instanceof Migration + && $event->name === '2014_10_12_000000_create_members_table'; }); Event::assertDispatched(MigrationEnded::class, function ($event) { - return $event->method === 'down' && $event->migration instanceof Migration; + return $event->method === 'down' + && $event->migration instanceof Migration + && $event->name === '2014_10_12_000000_create_members_table'; }); } diff --git a/tests/Integration/Database/Sqlite/SchemaStateTest.php b/tests/Integration/Database/Sqlite/SchemaStateTest.php index da6ea5038..2d30e2f48 100644 --- a/tests/Integration/Database/Sqlite/SchemaStateTest.php +++ b/tests/Integration/Database/Sqlite/SchemaStateTest.php @@ -55,7 +55,7 @@ public function testSchemaDumpOnSqlite() $this->assertTrue($connection->table('sqlite_sequence')->exists()); - $this->app['files']->ensureDirectoryExists(database_path('schema')); + $this->app->make('files')->ensureDirectoryExists(database_path('schema')); $connection->getSchemaState()->dump($connection, database_path('schema/sqlite-schema.sql')); @@ -67,4 +67,38 @@ public function testSchemaDumpOnSqlite() 'sqlite_sequence', ], 'database/schema/sqlite-schema.sql'); } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testSchemaDumpWithoutMigrationDataOnSqlite(): void + { + if (! is_executable('/usr/bin/sqlite3') && ! shell_exec('which sqlite3')) { + $this->markTestSkipped('sqlite3 CLI tool is not available'); + } + + if ($this->usesSqliteInMemoryDatabaseConnection()) { + $this->markTestSkipped('Test cannot be run using :in-memory: database connection'); + } + + $connection = DB::connection(); + $connection->getSchemaBuilder()->createDatabase($connection->getConfig('database')); + + $connection->statement('CREATE TABLE IF NOT EXISTS migrations (id integer primary key autoincrement not null, migration varchar not null, batch integer not null);'); + $connection->statement('CREATE TABLE users (id integer primary key autoincrement not null, email varchar not null, name varchar not null);'); + $connection->statement('INSERT INTO migrations (migration, batch) VALUES ("2014_10_12_000000_create_users_table", 1);'); + + $this->app->make('files')->ensureDirectoryExists(database_path('schema')); + + $connection->getSchemaState() + ->withMigrationTable(null) + ->dump($connection, database_path('schema/sqlite-schema.sql')); + + $this->assertFileContains([ + 'CREATE TABLE migrations', + 'CREATE TABLE users', + ], 'database/schema/sqlite-schema.sql'); + + $this->assertFileNotContains([ + 'INSERT INTO "migrations"', + ], 'database/schema/sqlite-schema.sql'); + } } From 495c8557814e546b362461b8dd950832a590af74 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:11:44 +0000 Subject: [PATCH 18/26] chore(database): complete split package metadata Declare every direct Database runtime dependency required by the split package and remove the stale concrete Config dependency after deleting reflective task cleanup. Record Laravel provenance and the deliberate omission of directly deprecated compatibility forwarding. Add source markers at natural upstream sync points and a manifest regression that verifies the complete direct dependency set without relying on the monorepo graph. --- src/database/README.md | 3 + src/database/composer.json | 8 ++- .../src/Eloquent/Factories/Factory.php | 3 + src/database/src/Grammar.php | 3 + .../src/Query/Processors/MySqlProcessor.php | 3 + tests/Database/PackageMetadataTest.php | 64 +++++++++++++++++++ 6 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 tests/Database/PackageMetadataTest.php diff --git a/src/database/README.md b/src/database/README.md index a28fe8afc..b742526d9 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -3,8 +3,11 @@ Database for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/database) +Ported from: https://github.com/laravel/framework + ## Differences From Laravel - 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. - Laravel's deprecated database-inspection forwarding helpers are intentionally not ported. Extensions can call `ConnectionInterface::getDriverTitle()` and `threadCount()` directly. +- Laravel's remaining directly deprecated Database compatibility forwarders are intentionally not ported. Use the current class-keyed factory resolver, schema blueprint and grammar APIs, and correctly named PostgreSQL truncation method instead. - `make:migration` omits Laravel's deprecated `--fullpath` option and obsolete Composer constructor dependency because migration creation no longer dumps autoload files. diff --git a/src/database/composer.json b/src/database/composer.json index 0490a121e..c2bd05b27 100644 --- a/src/database/composer.json +++ b/src/database/composer.json @@ -31,20 +31,25 @@ }, "require": { "php": "^8.4", + "ext-pdo": "*", + "ext-swoole": "^6.2", "brick/math": "^0.16", "nesbot/carbon": "^3.13.1", + "psr/log": "^3.0", "symfony/console": "^8.1", + "symfony/finder": "^8.1", "symfony/polyfill-php86": "^1.36", "symfony/process": "^8.1", "hypervel/broadcasting": "^0.4", "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", - "hypervel/config": "^0.4", "hypervel/console": "^0.4", "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/coordinator": "^0.4", "hypervel/coroutine": "^0.4", + "hypervel/engine": "^0.4", "hypervel/events": "^0.4", "hypervel/filesystem": "^0.4", "hypervel/core": "^0.4", @@ -52,6 +57,7 @@ "hypervel/macroable": "^0.4", "hypervel/pagination": "^0.4", "hypervel/pool": "^0.4", + "hypervel/prompts": "^0.4", "hypervel/queue": "^0.4", "hypervel/support": "^0.4" }, diff --git a/src/database/src/Eloquent/Factories/Factory.php b/src/database/src/Eloquent/Factories/Factory.php index 85e21b46f..f7752dd3f 100644 --- a/src/database/src/Eloquent/Factories/Factory.php +++ b/src/database/src/Eloquent/Factories/Factory.php @@ -103,6 +103,9 @@ abstract class Factory */ public static string $namespace = 'Database\Factories\\'; + // REMOVED: Laravel's deprecated singular model-name resolver is omitted; + // use the class-keyed modelNameResolvers map. + /** * The default model name resolvers. * diff --git a/src/database/src/Grammar.php b/src/database/src/Grammar.php index 7693754ba..5ad28619d 100755 --- a/src/database/src/Grammar.php +++ b/src/database/src/Grammar.php @@ -237,6 +237,9 @@ public function getDateFormat(): string return 'Y-m-d H:i:s'; } + // REMOVED: Laravel's deprecated grammar table-prefix forwarding is omitted; + // use the connection's getTablePrefix() and setTablePrefix() methods. + /** * Flush all static state. */ diff --git a/src/database/src/Query/Processors/MySqlProcessor.php b/src/database/src/Query/Processors/MySqlProcessor.php index a58b30ef6..18259e83f 100644 --- a/src/database/src/Query/Processors/MySqlProcessor.php +++ b/src/database/src/Query/Processors/MySqlProcessor.php @@ -9,6 +9,9 @@ class MySqlProcessor extends Processor { + // REMOVED: Laravel's deprecated processColumnListing() forwarding is omitted; + // use processColumns() for column metadata. + /** * Process an "insert get ID" query. */ diff --git a/tests/Database/PackageMetadataTest.php b/tests/Database/PackageMetadataTest.php new file mode 100644 index 000000000..79e1bccb7 --- /dev/null +++ b/tests/Database/PackageMetadataTest.php @@ -0,0 +1,64 @@ +assertArrayHasKey($dependency, $composer['require']); + $this->assertIsString($composer['require'][$dependency]); + $this->assertNotSame('', trim($composer['require'][$dependency])); + } + + $this->assertArrayNotHasKey('hypervel/config', $composer['require']); + } +} From a23cde61c765748b208494b7c3ce7d864e0179f5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:11:56 +0000 Subject: [PATCH 19/26] chore(redis): complete split package metadata Declare direct Swoole, Core lifecycle, and PSR logging dependencies used by Redis source while retaining phpredis as an optional connector capability. Add a split-manifest regression that checks the complete direct runtime dependency set and prevents monorepo-only transitive availability from hiding missing package requirements. --- src/redis/composer.json | 3 ++ tests/Redis/PackageMetadataTest.php | 49 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 tests/Redis/PackageMetadataTest.php diff --git a/src/redis/composer.json b/src/redis/composer.json index 7173aa0fb..f05309a1a 100644 --- a/src/redis/composer.json +++ b/src/redis/composer.json @@ -33,12 +33,15 @@ }, "require": { "php": "^8.4", + "ext-swoole": "^6.2", + "psr/log": "^3.0", "hypervel/collections": "^0.4", "hypervel/config": "^0.4", "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", "hypervel/coordinator": "^0.4", + "hypervel/core": "^0.4", "hypervel/coroutine": "^0.4", "hypervel/engine": "^0.4", "hypervel/pool": "^0.4", diff --git a/tests/Redis/PackageMetadataTest.php b/tests/Redis/PackageMetadataTest.php new file mode 100644 index 000000000..1ca9065bc --- /dev/null +++ b/tests/Redis/PackageMetadataTest.php @@ -0,0 +1,49 @@ +assertArrayHasKey($dependency, $composer['require']); + $this->assertIsString($composer['require'][$dependency]); + $this->assertNotSame('', trim($composer['require'][$dependency])); + } + + $this->assertArrayNotHasKey('ext-redis', $composer['require']); + $this->assertArrayHasKey('ext-redis', $composer['suggest']); + } +} From 873397cbe3a898080bcd4a469ba98dfac87d0702 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:12:06 +0000 Subject: [PATCH 20/26] docs(queries): document current query builder APIs Document MySQL and MariaDB straight joins, null-safe equality, explicit value ordering, per-query timeouts, and conflict-safe returning inserts at their existing task-oriented sections. Use the final public signatures, show realistic application examples, and state driver boundaries without exposing grammar or pooled-connection internals. --- src/boost/docs/queries.md | 62 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/boost/docs/queries.md b/src/boost/docs/queries.md index 8c94bec4c..55d4f461e 100644 --- a/src/boost/docs/queries.md +++ b/src/boost/docs/queries.md @@ -321,6 +321,17 @@ $query = DB::table('users')->select('name'); $users = $query->addSelect('age')->get(); ``` + +#### Query Timeouts + +When using MariaDB or MySQL, the `timeout` method may be used to limit a select query's execution time in seconds: + +```php +$users = DB::table('users') + ->timeout(2) + ->get(); +``` + #### Index Hints @@ -439,6 +450,19 @@ $users = DB::table('users') ->get(); ``` + +#### Straight Join Clauses + +When using MariaDB or MySQL, the `straightJoin` method may be used to force joined tables to be read in the order they appear in the query: + +```php +$users = DB::table('users') + ->straightJoin('contacts', 'users.id', '=', 'contacts.user_id') + ->get(); +``` + +The `straightJoinWhere` method compares the joined column against a value, while the `straightJoinSub` method joins a subquery. + #### Left Join / Right Join Clause @@ -972,6 +996,18 @@ $users = DB::table('users') ->get(); ``` +**whereNullSafeEquals / orWhereNullSafeEquals** + +The `whereNullSafeEquals` and `orWhereNullSafeEquals` methods may be used to compare a column's value against a given value while treating two `NULL` values as equal: + +```php +$lastLoginIp = $request->input('last_login_ip'); + +$users = DB::table('users') + ->whereNullSafeEquals('last_login_ip', $lastLoginIp) + ->get(); +``` + **whereDate / whereMonth / whereDay / whereYear / whereTime** The `whereDate` method may be used to compare a column's value against a date: @@ -1290,6 +1326,17 @@ $corporations = DB::table('corporations') ->get(); ``` + +#### Preferred Value Ordering + +The `inOrderOf` method orders results according to a preferred sequence of values. Values not included in the sequence are placed last: + +```php +$orders = DB::table('orders') + ->inOrderOf('status', ['pending', 'processing', 'completed']) + ->get(); +``` + #### The `latest` and `oldest` Methods @@ -1448,6 +1495,21 @@ DB::table('users')->insertOrIgnore([ ]); ``` +When using PostgreSQL or SQLite, the `insertOrIgnoreReturning` method inserts non-conflicting records and returns the inserted rows. You may select the returned columns and specify the column or columns that identify a conflict: + +```php +$users = DB::table('users')->insertOrIgnoreReturning( + [ + ['email' => 'sisko@example.com'], + ['email' => 'archer@example.com'], + ], + ['id', 'email'], + 'email', +); +``` + +Unlike `insertOrIgnore`, this method returns a collection containing only the rows that were inserted. + The `insertUsing` method will insert new records into the table while using a subquery to determine the data that should be inserted: ```php From 98f22d39e58c83a65ff0c9d7f7e7ea6c58fbf844 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:12:18 +0000 Subject: [PATCH 21/26] docs(eloquent): document current model persistence APIs Document saveOrIgnore and model-instance incrementEach and decrementEach operations beside the existing persistence and atomic update guidance. Describe event behavior, quiet variants, conflict return values, and supported PostgreSQL and SQLite boundaries without duplicating query-builder material or internal publication mechanics. --- src/boost/docs/eloquent.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/boost/docs/eloquent.md b/src/boost/docs/eloquent.md index 682f39de8..28ff6b3ea 100644 --- a/src/boost/docs/eloquent.md +++ b/src/boost/docs/eloquent.md @@ -819,6 +819,16 @@ If you would like to save the model within a database transaction, you may use t $flight->saveOrFail(); ``` +When using PostgreSQL or SQLite, the `saveOrIgnore` method may be used to insert a new model while ignoring a matching conflict. The method returns `false` when the model was not inserted: + +```php +$flight = new Flight; +$flight->flight_number = 'HV100'; +$flight->destination = 'Paris'; + +$inserted = $flight->saveOrIgnore(uniqueBy: 'flight_number'); +``` + Alternatively, you may use the `create` method to "save" a new model using a single PHP statement. The inserted model instance will be returned to you by the `create` method: ```php @@ -852,6 +862,22 @@ If you would like to update the model within a database transaction, you may use $flight->updateOrFail(['name' => 'Paris to London']); ``` +You may increment or decrement several attributes on a model instance in one query using the `incrementEach` and `decrementEach` methods: + +```php +$flight->incrementEach([ + 'seats_booked' => 1, + 'miles_flown' => 250, +]); + +$flight->decrementEach([ + 'seats_available' => 1, + 'reward_points' => 50, +]); +``` + +These methods dispatch the model's `updating` and `updated` events. To perform the same updates without dispatching model events, use `incrementEachQuietly` or `decrementEachQuietly`. + Occasionally, you may need to update an existing model or create a new model if no matching model exists. Like the `firstOrCreate` method, the `updateOrCreate` method persists the model, so there's no need to manually call the `save` method. In the example below, if a flight exists with a `departure` location of `Oakland` and a `destination` location of `San Diego`, its `price` and `discounted` columns will be updated. If no such flight exists, a new flight will be created which has the attributes resulting from merging the first argument array with the second argument array: From b9a64308a5e43c58b09c9b03595466b9bb6bd6de Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:12:26 +0000 Subject: [PATCH 22/26] docs(routing): document route key attributes Add the RouteKey class attribute beside the existing getRouteKeyName customization and show how it configures implicit model binding with a slug. Keep the explicit method override available and describe the public application-facing behavior without exposing class-attribute caching internals. --- src/boost/docs/routing.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/boost/docs/routing.md b/src/boost/docs/routing.md index 9982c6cbe..53e7dc6e0 100644 --- a/src/boost/docs/routing.md +++ b/src/boost/docs/routing.md @@ -653,6 +653,21 @@ public function getRouteKeyName(): string } ``` +Alternatively, you may apply the `RouteKey` attribute to the model: + +```php +use Hypervel\Database\Eloquent\Attributes\RouteKey; +use Hypervel\Database\Eloquent\Model; + +#[RouteKey('slug')] +class Post extends Model +{ + // ... +} +``` + +The attribute applies to implicit route model binding, while overriding `getRouteKeyName` remains available when the key must be determined by custom logic. + #### Custom Keys and Scoping From d15923b994317df1ec87e7dbfb0655c372711b46 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:12:37 +0000 Subject: [PATCH 23/26] docs(migrations): document current schema and migration APIs Document model-aware foreign UUIDs, foreign-key inspection, PostgreSQL tsvector columns, MariaDB vector indexes, schema dumps without migration rows, and migration event filenames. Place each addition beside the existing schema or event task, preserve distinct PostgreSQL vector guidance, and state supported-driver behavior using the final source signatures. --- src/boost/docs/migrations.md | 38 ++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/boost/docs/migrations.md b/src/boost/docs/migrations.md index ac37d3fa3..35c754958 100644 --- a/src/boost/docs/migrations.md +++ b/src/boost/docs/migrations.md @@ -59,6 +59,12 @@ php artisan schema:dump php artisan schema:dump --prune ``` +To dump the schema without including rows from the migrations table, use the `--without-migration-data` option: + +```shell +php artisan schema:dump --without-migration-data +``` + When you execute this command, Hypervel will write a "schema" file to your application's `database/schema` directory. The schema file's name will correspond to the database connection. Now, when you attempt to migrate your database and no other migrations have been executed, Hypervel will first execute the SQL statements in the schema file of the database connection you are using. After executing the schema file's SQL statements, Hypervel will execute any remaining migrations that were not part of the schema dump. If your application's tests use a different database connection than the one you typically use during local development, you should ensure you have dumped a schema file using that database connection so that your tests are able to build your database. You may wish to do this after dumping the database connection you typically use during local development: @@ -322,7 +328,7 @@ When creating the table, you may use any of the schema builder's [column methods #### Determining Table / Column Existence -You may determine the existence of a table, column, or index using the `hasTable`, `hasColumn`, and `hasIndex` methods: +You may determine the existence of a table, column, index, or foreign key using the `hasTable`, `hasColumn`, `hasIndex`, and `hasForeignKey` methods: ```php if (Schema::hasTable('users')) { @@ -336,8 +342,14 @@ if (Schema::hasColumn('users', 'email')) { if (Schema::hasIndex('users', ['email'], 'unique')) { // The "users" table exists and has a unique index on the "email" column... } + +if (Schema::hasForeignKey('posts', ['user_id'])) { + // The "posts" table has a foreign key on the "user_id" column... +} ``` +The `hasForeignKey` method accepts either the foreign key name or its column list. + #### Database Connection and Table Options @@ -592,6 +604,7 @@ The schema builder blueprint offers a variety of methods that correspond to the [foreignIdFor](#column-method-foreignIdFor) [foreignUlid](#column-method-foreignUlid) [foreignUuid](#column-method-foreignUuid) +[foreignUuidFor](#column-method-foreignUuidFor) [morphs](#column-method-morphs) [nullableMorphs](#column-method-nullableMorphs) @@ -608,6 +621,7 @@ The schema builder blueprint offers a variety of methods that correspond to the [ipAddress](#column-method-ipAddress) [rememberToken](#column-method-rememberToken) [vector](#column-method-vector) +[tsvector](#column-method-tsvector) @@ -771,6 +785,15 @@ The `foreignUuid` method creates a `UUID` equivalent column: $table->foreignUuid('user_id'); ``` + +#### `foreignUuidFor()` {.collection-method} + +The `foreignUuidFor` method adds a UUID column for the given model. By default, the column name, referenced table, and referenced model key are derived from the model: + +```php +$table->foreignUuidFor(User::class); +``` + #### `geography()` {.collection-method} @@ -1215,6 +1238,15 @@ When utilizing PostgreSQL, the `pgvector` extension must be loaded before `vecto Schema::ensureVectorExtensionExists(); ``` + +#### `tsvector()` {.collection-method} + +The `tsvector` method creates a PostgreSQL `tsvector` equivalent column for storing precomputed full-text search vectors: + +```php +$table->tsvector('search_vector'); +``` + #### `year()` {.collection-method} @@ -1470,7 +1502,7 @@ Hypervel's schema builder blueprint class provides methods for creating each typ | `$table->fullText('body')->language('english');` | Adds a full text index of the specified language (PostgreSQL). | | `$table->spatialIndex('location');` | Adds a spatial index (except SQLite). | | `$table->rawIndex('(lower(email))', 'users_email_lower_index');` | Adds an index from a raw expression. | -| `$table->vectorIndex('embedding');` | Adds a vector index (PostgreSQL). | +| `$table->vectorIndex('embedding');` | Adds a vector index (MariaDB / PostgreSQL). | @@ -1643,3 +1675,5 @@ For convenience, each migration operation will dispatch an [event](/docs/{{versi | `Hypervel\Database\Events\MigrationsPruned` | Existing migration files have been pruned. | + +The `MigrationStarted` and `MigrationEnded` events expose the migration filename through their `$name` property. From fdb64eff874c50876474450fb0630c8108e02211 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:12:50 +0000 Subject: [PATCH 24/26] docs(audit): close the database persistence work unit Finalize the signed implementation plan with every discovered ownership, transaction, Redis, SQLite, Eloquent, parity, metadata, documentation, performance, and anti-overengineering decision reflected in the delivered code. Record database-05 through database-13 and redis-03 through redis-08 in the audit ledger, update cross-package routing and revalidation status, capture the complete validation and review result, and mark the Database package checklist complete. --- ...amework-coroutine-state-lifecycle-audit.md | 36 ++- ...-coroutine-state-lifecycle-audit-ledger.md | 36 +++ ...e-lifecycles-and-current-laravel-parity.md | 248 +++++++++++++++--- 3 files changed, 269 insertions(+), 51 deletions(-) diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md index 399664e74..eb3988862 100644 --- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md @@ -991,7 +991,7 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md). - **Active package or work unit:** `database` -- **Ledger entries required for the active work:** `Release cleared coordinator timers deterministically`; `Bound pool resources and connection progress deterministically`; `Normalize framework enum identifiers at string boundaries`; `Complete Foundation runtime lifecycles and safe publication`; `Complete Console command, scheduling, and generator lifecycles`. +- **Ledger entries required for the active work:** `Release cleared coordinator timers deterministically`; `Bound pool resources and connection progress deterministically`; `Normalize framework enum identifiers at string boundaries`; `Complete Foundation runtime lifecycles and safe publication`; `Complete Console command, scheduling, and generator lifecycles`; `Complete Database persistence lifecycles and current Laravel parity`. - **Pending revalidation carried into the active work:** None. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -1020,7 +1020,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `container-09` | `auth`, `cache`, `log` | `container` (revalidation complete); later full `auth`, `cache`, and `log` audits | `Coordinate shared container construction and complete current contextual resolution`; finding `container-09` | | `container-10` | `log` | `container` (revalidation complete); later full `log` audit | `Coordinate shared container construction and complete current contextual resolution`; finding `container-10` | | `context-01` | `context` | `container` and `foundation` (revalidation complete) | `Correct explicit coroutine context targeting`; finding `context-01` | -| `context-04` | `context` | `foundation` (revalidation complete); later full `database` audit | `Correct explicit coroutine context targeting`; finding `context-04` | +| `context-04` | `context` | `foundation` and `database` (revalidation complete) | `Correct explicit coroutine context targeting`; finding `context-04` | | `coroutine-05` | `coroutine`, `filesystem` | `filesystem` (revalidation complete) | `Make coroutine creation and copied context failure-safe`; finding `coroutine-05` | | `coroutine-06` | `context`, `coroutine` | `concurrency` and `foundation` (revalidation complete) | `Make coroutine creation and copied context failure-safe`; finding `coroutine-06` | | `foundation-02` | `foundation` | `coroutine` and `foundation` (revalidation complete) | `Make coroutine creation and copied context failure-safe`; finding `foundation-02` | @@ -1030,13 +1030,13 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `concurrency-03` | `concurrency`, `foundation`, `testbench` | `foundation` (revalidation complete); later full `testbench` audit | `Make process concurrency transport lossless and reconstruct failures safely`; finding `concurrency-03` | | `pool-01` | `pool` | `coordinator` (revalidation complete); later full `pool` audit | `Release cleared coordinator timers deterministically`; finding `pool-01` | | `pool-02` | `pool` | later full `pool` audit | `Release cleared coordinator timers deterministically`; finding `pool-02` | -| `pool-04` | `pool`, `database`, `redis` | later full `database` and `redis` audits | `Bound pool resources and connection progress deterministically`; finding `pool-04` | -| `pool-05` | `pool` | `database`, `redis`; later full consumer audits | `Bound pool resources and connection progress deterministically`; finding `pool-05` | -| `database-02` | `database` | `pool`; later full `database` audit | `Bound pool resources and connection progress deterministically`; finding `database-02` | -| `redis-02` | `redis` | `pool`; later full `redis` audit | `Bound pool resources and connection progress deterministically`; finding `redis-02` | -| `pool-08` | `pool`, `redis` | later full `redis` audit | `Bound pool resources and connection progress deterministically`; finding `pool-08` | -| `database-01` | `database` | later full `database` audit | `Release cleared coordinator timers deterministically`; finding `database-01` | -| `redis-01` | `redis` | later full `redis` audit | `Release cleared coordinator timers deterministically`; finding `redis-01` | +| `pool-04` | `pool`, `database`, `redis` | `database` and `redis` (revalidation complete); later full `redis` audit | `Bound pool resources and connection progress deterministically`; finding `pool-04` | +| `pool-05` | `pool` | `database` and `redis` (revalidation complete); later full `redis` audit | `Bound pool resources and connection progress deterministically`; finding `pool-05` | +| `database-02` | `database` | `pool` and `database` (revalidation complete) | `Bound pool resources and connection progress deterministically`; finding `database-02` | +| `redis-02` | `redis` | `pool` and `redis` (revalidation complete); later full `redis` audit | `Bound pool resources and connection progress deterministically`; finding `redis-02` | +| `pool-08` | `pool`, `redis` | `redis` (revalidation complete); later full `redis` audit | `Bound pool resources and connection progress deterministically`; finding `pool-08` | +| `database-01` | `database` | `database` (revalidation complete) | `Release cleared coordinator timers deterministically`; finding `database-01` | +| `redis-01` | `redis` | `redis` (revalidation complete); later full `redis` audit | `Release cleared coordinator timers deterministically`; finding `redis-01` | | `di-02` | `di` | `foundation` (revalidation complete); later full `sentry` and `telescope` audits | `Correct AOP proxy generation and publication`; finding `di-02` | | `filesystem-02` | `filesystem` | `di` and `filesystem` (revalidation complete) | `Correct AOP proxy generation and publication`; finding `filesystem-02` | | `filesystem-03` | `filesystem` | `encryption`, `support`, and `filesystem` (revalidation complete) | `Harden encryption rotation, key publication, and global lifecycle state`; finding `filesystem-03` | @@ -1052,7 +1052,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `queue-11` | `queue` | `events` (revalidation complete), `broadcasting`; later full `queue` and `broadcasting` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-11` | | `queue-12` | `bus`, `queue` | `events` and `bus` (revalidation complete), `broadcasting`; later full `queue` and `broadcasting` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` | | `foundation-01` | `foundation` | `support` and `foundation` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `foundation-01` | -| `support-02` | `support` | `auth`, `broadcasting`, `bus` (revalidation complete), `cache`, `concurrency`, `console` (revalidation complete), `container`, `contracts`, `cookie`, `database`, `events`, `filesystem` (revalidation complete), `foundation` (revalidation complete), `hashing` (revalidation complete), `horizon`, `inertia`, `jwt`, `log`, `mail`, `notifications`, `permission`, `pipeline`, `queue`, `redis`, `reverb`, `routing`, `sanctum`, `scout`, `session`, `socialite`, `telescope`, `testbench`, `translation`; later full consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-0920-framework-enum-identifier-contracts.md` | +| `support-02` | `support` | `auth`, `broadcasting`, `bus` (revalidation complete), `cache`, `concurrency`, `console` (revalidation complete), `container`, `contracts`, `cookie`, `database` (revalidation complete), `events`, `filesystem` (revalidation complete), `foundation` (revalidation complete), `hashing` (revalidation complete), `horizon`, `inertia`, `jwt`, `log`, `mail`, `notifications`, `permission`, `pipeline`, `queue`, `redis` (revalidation complete), `reverb`, `routing`, `sanctum`, `scout`, `session`, `socialite`, `telescope`, `testbench`, `translation`; later full consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-0920-framework-enum-identifier-contracts.md` | | `auth-01` | `support`, `auth` | later full `auth` audit | `Correct Support utility boundaries and authentication timing isolation`; finding `auth-01` | | `encryption-03` | `encryption` | `contracts`, `support`, `filesystem`, and `foundation` (revalidation complete) | `Harden encryption rotation, key publication, and global lifecycle state`; finding `encryption-03` | | `sanctum-01` | `sanctum` | `encryption`; later full `sanctum` audit | `Harden encryption rotation, key publication, and global lifecycle state`; finding `sanctum-01` | @@ -1075,10 +1075,20 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `queue-14` | `foundation`, `queue` | `foundation` (revalidation complete); later full `queue` audit | `Complete Foundation runtime lifecycles and safe publication`; finding `queue-14` | | `http-03` | `http`, `foundation` | `contracts` and `foundation` (revalidation complete); later full `http` audit | `Complete Foundation runtime lifecycles and safe publication`; finding `http-03` | | `auth-02` | `auth` | `foundation` (revalidation complete); later full `auth` audit | `Complete Foundation runtime lifecycles and safe publication`; finding `auth-02` | -| `database-03` | `database` | `foundation` (revalidation complete); later full `database` and `testbench` audits | `Complete Foundation runtime lifecycles and safe publication`; finding `database-03` | -| `database-04` | `database` | `console` (revalidation complete); later full `database` audit | `Complete Console command, scheduling, and generator lifecycles`; finding `database-04` | +| `database-03` | `database` | `foundation` and `database` (revalidation complete); later full `testbench` audit | `Complete Foundation runtime lifecycles and safe publication`; finding `database-03` | +| `database-04` | `database` | `console` and `database` (revalidation complete) | `Complete Console command, scheduling, and generator lifecycles`; finding `database-04` | | `reverb-04` | `reverb` | later full `reverb` audit | `Complete Console command, scheduling, and generator lifecycles`; finding `reverb-04` | | `watcher-10` | `support` | `watcher`, `foundation`, and `horizon` (revalidation complete) | `Make Watcher drivers and managed processes lifecycle-safe`; finding `watcher-10` | +| `database-05` | `core`, `database` | `redis` (revalidation complete); later full `redis` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `database-05`; sibling finding `redis-03` | +| `database-06` | `core`, `server`, `database` | `server` and `redis` (revalidation complete); later full `redis` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `database-06`; sibling finding `redis-05` | +| `database-08` | `database` | `foundation`, `testing`, and `testbench` (revalidation complete); later full `testing` and `testbench` audits | `Complete Database persistence lifecycles and current Laravel parity`; finding `database-08` | +| `database-10` | `database` | `scout` and `nested-set` (revalidation complete); later full consumer audits | `Complete Database persistence lifecycles and current Laravel parity`; finding `database-10` | +| `redis-03` | `redis` | later full `redis` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-03` | +| `redis-04` | `redis` | later full `redis` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-04` | +| `redis-05` | `redis`, `core`, `server` | later full `redis` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-05` | +| `redis-06` | `redis` | later full `redis` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-06` | +| `redis-07` | `redis` | later full `redis` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-07` | +| `redis-08` | `redis`, `pool` | later full `redis` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-08` | ## Package checklist @@ -1147,7 +1157,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen ### Persistence, transport, and background execution -- [ ] `database` +- [x] `database` - [ ] `redis` - [ ] `cache` - [ ] `session` diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index b4cbd2f23..4e8905c86 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -1121,3 +1121,39 @@ Append package entries in checklist order. Keep each entry compact but complete - **Validation and review:** Every changed and new focused Watcher, Support, Foundation, and Horizon test passed. The final `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. Both changed split manifests validate, `git diff --check` is clean, package-checklist parity holds, and broad stale-reference scans are clean. Fresh self-review covered driver output and filtering, channel termination, child ownership and every state transition, dotenv callers, configuration, documentation, retained state, hot-path cost, and overengineering. Independent code review corrected post-reap PID unpublication and scan-warning diagnostics, then signed off with no remaining finding. - **Assessment:** All accepted defects and improvements are resolved at their lowest owners. The result deletes more machinery than it adds, improves idle CPU and burst memory use, launches fewer subprocesses, and adds only negligible target-existence checks beside polling scans. It introduces no request hot-path work, lock, retry loop, registry, timeout policy, compatibility shim, speculative extension point, or retained filename/process history. - **Laravel-facing result:** Watcher has no Laravel counterpart. Foundation's Laravel-facing APIs remain unchanged; the configuration addition makes the existing Swoole-only non-daemon requirement explicit. Horizon's public process behavior remains intact. The owner approved removing the unused Hyperf-derived event layer while preserving useful Watcher and Horizon extension surfaces. + +### Complete Database persistence lifecycles and current Laravel parity + +- **Architecture and inspected risk surfaces:** Database is a Laravel-derived query, schema, migration, Eloquent, pooled-connection, testing, and transaction package adapted to long-lived Swoole workers. The work also covers the Core task-terminal and Server pre-fork boundaries, Redis's corresponding pooled ownership, Foundation/Testing/Testbench SQLite consumers, and Scout/NestedSet model-boot consumers. The audit covered every Database source and test file; all repository consumers of connection resolution, transactions, SQLite classification, model boot, and the accepted APIs; current Laravel 13.x source, tests, documentation, and originating pull requests; Hyperf's Redis deferred-release correction; native SQLite, PDO, phpredis, and Swoole behavior; split metadata; and every carried Database/Redis dependency recorded by the routing index. The detailed implementation design is recorded in [`2026-07-24-1013-database-persistence-lifecycles-and-current-laravel-parity.md`](2026-07-24-1013-database-persistence-lifecycles-and-current-laravel-parity.md). + +| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision | +|---|---|---|---|---|---| +| `database-05` | Defect | Major | High | Default non-coroutine Swoole tasks retain exact pooled wrappers and their raw context, transaction, and session state across tasks | Publish `TaskTerminated`, retain exact wrappers in the pooled resolver, and release them only at that terminal task boundary | +| `database-06` | Defect | Major | High | Parent-created pools, PDOs, sockets, timers, channels, and fallback context can cross Swoole forks | Publish one final `BeforeServerFork` boundary and independently discard resolved exact leases and flush already-resolved Database and Redis pools before fork and at child start | +| `database-07` | Defect | Major | High | Multiple wrappers advertise independent ownership of one physical in-memory SQLite PDO | Normalize valid in-memory pool capacity to one and reuse the existing pool channel as the sole ownership primitive | +| `database-08` | Defect and parity defect | Major | High | Inconsistent literal checks miss valid SQLite URI-memory forms, reject valid file URIs, and can truncate a URI string instead of the attached file | Centralize native-faithful classification in `SQLiteDatabase`, route every live consumer through it, and refresh the canonical attached path | +| `database-09` | Defect, parity defect, and upstream defect | Major | High | Physical PDO state, logical levels, manager records, callbacks, events, retries, disconnects, and pooled reuse can disagree or replace the primary failure | Separate transaction phases, publish state only after physical success, detach records before callbacks, exhaust rollback cleanup, retry only after complete cleanup, and preserve the earliest operation failure | +| `database-10` | Defect, parity defect, and upstream defect | Major | High | Eloquent publishes `$booted` before boot finishes, so recursion observes incomplete state, exceptions poison publication, and siblings race | Track the exact boot owner and serialize only incomplete first publication with the existing model-class Mutex | +| `database-11` | Supported current Laravel parity | Major/Minor | High | Current supported Query, Eloquent, Schema, migration, connector, provider, exception, and metadata behavior is missing or stale | Port the complete supported current Laravel surface discovered through its originating source and documentation changes | +| `database-12` | Performance improvement | Improvement | High | Ordinary attribute reads merge every cached cast and raw SQL rendering repeatedly reindexes bindings | Port Laravel's per-key cast merge and indexed binding substitution, including truthful resource rendering | +| `database-13` | Package metadata and documentation defect | Major | High | Split dependencies, provenance, deliberate deprecated omissions, and accepted public APIs are incomplete or undiscoverable | Correct Database and Redis manifests, add metadata coverage and omission markers, and update concise task-first guides | +| `redis-03` | Cross-package defect | Major | High | Non-coroutine same-connection commands register an invalid defer, while repeated callback operations retain one no-op defer per call despite immediate wrapper release | Register no outside-coroutine defer and at most one owner-ID terminal defer per pool/coroutine while preserving immediate callback-form release | +| `redis-04` | Cross-package defect | Major | High | Abandoned native MULTI or PIPELINE clients can be requeued and silently queue commands for a later borrower | Inspect local phpredis mode before reuse and discard terminally when queueing | +| `redis-05` | Cross-package defect | Major | High | Redis sockets, heartbeat timers, pools, and pinned fallback state share Database's fork-inheritance defect | Consume the shared process lifecycle through exact proxy discard plus already-resolved pool flush | +| `redis-06` | Cross-package defect | Major | High | A throwing command-event listener can skip ownership handoff or cleanup and leak the borrowed pool slot | Complete command, event, and cleanup phases independently while retaining established failure precedence | +| `redis-07` | Cross-package defect | Major | High | `WATCH` returns its client before `MULTI`/`EXEC`, and callback-form completion bypasses wrapper watch-state settlement | Pin WATCH to its exact wrapper, track only unobservable native watch state, settle successful terminal commands, and discard abandoned state | +| `redis-08` | Cross-package parity and type defect | Major | High | Laravel-facing `Redis::discard()` collides with Pool's lifecycle `discard(): void`, destroys the wrapper, and never invokes native DISCARD | Route only the Redis command through a distinct internal native-discard method while retaining Pool's lifecycle contract | + +- **Task and process ownership:** `TaskCallback` now publishes a guarded terminal event after action/finish work and preserves the earliest failure. `Server::start()` publishes one final pre-fork event immediately before native start. Database's resolver and Redis's existing proxies own their exact borrowed identities; lifecycle listeners aggregate only already-resolved owners, detach context before I/O, continue independent cleanup after failure, and discard rather than release inherited resources. No generic lifecycle registry, priority mechanism, reflective context deletion, or reconstructed configured-name cleanup remains. +- **Redis ownership and native state:** Each pool/coroutine owns one terminal defer identified by the owning coroutine ID, so copied context cannot inherit false defer ownership and repeated callback-form operations cannot accumulate closures. Callback transactions still return newly pinned wrappers immediately, including in long-lived coroutines. The wrapper reads phpredis's local queue mode at release and tracks one boolean only for WATCH because phpredis exposes no corresponding getter. Native DISCARD has one explicit route around Pool's method-name collision. Ordinary Redis commands add no defer lookup or network command. +- **SQLite and Testbench ownership:** `SQLiteDatabase` is the sole lowercase URI and in-memory classifier across connectors, pools, schema state, RefreshDatabase, parallel testing, the shared integration harness, and Testbench. Automatic parallel database management and file create/drop reject non-memory URI names rather than treating them as PHP paths. File refresh uses the canonical attached database and preserves its inode. Mixed RefreshDatabase connection sets cache only physical memory PDOs, and unsplit base/read/write aliases share one canonical wrapper around one memory PDO. Tests retain assigned ParaTest worker identity and use bounded wait paths. +- **Transaction truthfulness:** Begin, callback/pre-commit, physical commit, logical publication, manager callbacks, events, rollback, disconnect, and retry are explicit fixed phases. Manager records detach before user rollback callbacks and rollback callbacks exhaust deepest-first; commit callbacks retain current Laravel's stop-on-first contract. Cleanup failure prevents retry and never replaces the primary callback, listener, commit, or synthesized nested-deadlock failure. Lost physical sessions detach terminally, while non-lost physical failures preserve truthful active state. No state machine, transaction object, ambiguity model, callback registry, generic finalizer, or retry framework is introduced. +- **Eloquent publication and current parity:** First model boot remains lock-free after stable publication, permits same-owner construction only after `$booted` is true, clears failed pre-publication ownership for retry, and retains publication through post-boot hooks. Scout and NestedSet no longer instantiate an unpublished model inside boot. Current supported Laravel Query, Eloquent, Schema, migration, connector, provider, exception, facade, and PHPDoc surfaces are ported from current source after originating-PR discovery. SQL Server, dynamic/direct connection, directly deprecated compatibility, and other approved unsupported surfaces remain omitted and explicitly recorded where future syncs look. +- **Important rejected concerns:** Do not add a generic task cleanup registry, event priorities, transaction state machine, shared SQLite cache or second lock, URI rewriting for automatic parallel isolation, Redis queue-mode mirror, eager-release marker, per-pin defer, `WeakMap` defer registry, transaction abstraction, boot registry, compatibility shims, benchmarks for source-proven performance improvements, or documentation that exposes internal lifecycle choreography. Deliberate Laravel-style escape hatches remain escape hatches. Hyperf parity is not a requirement; only the bounded Redis lifecycle lesson from its fix is retained in the cleaner owning shape. +- **Performance and compatibility:** Normal Database queries gain no container lookup, context operation, lock, retry, logging, yield, or registry. Stable model construction adds one static owner-map `isset()`, and first boot alone reads the coroutine ID and may use the existing Mutex. Redis release adds one local extension-state read and one boolean; same-connection publication adds one owner-ID comparison only when publishing a pin. Per-key cast merging and indexed raw-SQL substitution remove work. SQLite normalization occurs at setup, lifecycle events are cold task/start boundaries, and exhaustive cleanup is exceptional or terminal. Public Laravel APIs remain intact; supported current APIs are restored, while internal Swoole adaptations remain at their lowest owners. +- **Implementation:** Added exact task and pre-fork lifecycle events and Database/Redis listeners; replaced reflective task cleanup with exact resolver/proxy ownership; deduplicated Redis terminal defers without delaying callback release; made Redis event cleanup, WATCH, native DISCARD, mode checks, purge, and fork cleanup truthful; centralized SQLite classification and serialized shared-memory ownership; corrected RefreshDatabase and parallel/Testbench consumers; repaired transaction publication, retry, rollback, callback, disconnect, and query-log state; made first Eloquent boot coroutine-safe; ported the complete accepted current Laravel Database surface and both approved performance corrections; completed split metadata, provenance, omission markers, facade metadata, and concise user documentation; and removed every superseded listener, literal classifier, dependency, comment, and test assumption. +- **Regression tests:** Deterministic coverage spans task and fork failure precedence; exact Database and Redis wrapper ownership; copied-context and callback-immediate Redis release; real MULTI/PIPELINE/WATCH/DISCARD state; full SQLite URI classification, canonical refresh, mixed RefreshDatabase ownership, and one-owner concurrency; transaction begin/commit/rollback/disconnect and manager-callback failures; retry suppression and primary-failure preservation; recursive, concurrent, failed, and post-publication model boot; all supported current Query, Eloquent, Schema, migration, connector, provider, exception, metadata, facade, documentation-facing call shapes, and external MySQL, MariaDB, PostgreSQL, SQLite, Redis, and Valkey behavior. +- **Cross-package revalidation:** The work closes carried `database-01` through `database-04`, `redis-01` and `redis-02`, `pool-04`, `pool-05`, `pool-08`, `context-04`, `support-02`, `foundation-06`, and the Database side of `database-03`. Core and Server own the new lifecycle producers; Redis remains a later full-package audit with `redis-03` through `redis-08` already fixed and recorded; Foundation, Testing, and Testbench consume the SQLite and transaction-test boundaries; Scout and NestedSet consume the corrected model-boot publication; Telescope's aggregate SQL expectation follows the corrected grammar. Focused consumer coverage and the complete gate remain green. +- **Validation and review:** Every changed test file and affected package group passed during implementation. The final `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. Database and Redis split manifests validate, `git diff --check`, package-checklist parity, broad stale-reference/classifier/dependency scans, and a fresh full-diff caller/callee, lifecycle, transaction, API, documentation, hot-path, retained-state, and overengineering self-review are complete. Independent post-implementation code review is signed off with no remaining findings. +- **Laravel-facing result:** Current supported Laravel Database APIs, signatures, member ordering, tests, and task-first documentation are restored. Intentional differences are limited to Swoole/coroutine ownership, pooled-connection safety, truthful contract corrections, unsupported drivers/dynamic connections, and deliberate omission of directly deprecated forwarding. Redis's Hypervel/Hyperf-derived internals retain their public Laravel-shaped command surface while fixing lifecycle and native-state defects. +- **Assessment:** Every accepted Database finding and linked Redis ownership correction is implemented at its lowest owner. The result removes reflective and duplicated cleanup, repeated defer retention, inconsistent classifiers, false shared-PDO capacity, stale manager state, and quadratic or over-broad work while adding only the owner-approved noise-level correctness checks. It contains no workaround, speculative mechanism, compatibility shim, hot-path synchronization, unresolved accepted defect, or stale superseded path. diff --git a/docs/plans/2026-07-24-1013-database-persistence-lifecycles-and-current-laravel-parity.md b/docs/plans/2026-07-24-1013-database-persistence-lifecycles-and-current-laravel-parity.md index b1f557517..a6c3ba720 100644 --- a/docs/plans/2026-07-24-1013-database-persistence-lifecycles-and-current-laravel-parity.md +++ b/docs/plans/2026-07-24-1013-database-persistence-lifecycles-and-current-laravel-parity.md @@ -2,7 +2,7 @@ ## Status -Plan drafted after the Database audit and pre-implementation second-opinion consensus. Owner approval has been received for every additive capability and performance-sensitive change in that consensus. A fresh source, test, documentation, metadata, lifecycle, performance, and overengineering review is complete; it added `redis-06` through `redis-08`, whose minimal Redis command/release hot-path work must be included in the final owner review before implementation. Independent plan review is pending. Implementation has not started. +Plan drafted after the Database audit and pre-implementation second-opinion consensus. Owner approval has been received for every additive capability and performance-sensitive change in that consensus. A fresh source, test, documentation, metadata, lifecycle, performance, and overengineering review added `redis-06` through `redis-08`, and independent plan review signed off the complete design. After sign-off, Hyperf issue #7733 and pull request #7734 exposed one missed `redis-03` defer-retention path; focused source review and second-opinion consensus settled the bounded amendment below. Refreshed independent plan review signed off the amendment. During the full validation gate, Laravel 13's model boot guard exposed stale pre-publication model construction in the current Scout and NestedSet consumers; focused upstream review and second-opinion consensus added the bounded compatibility corrections in Section 10. Fresh post-implementation review added two concise migration-guide notes for the new schema-dump option and migration-event name while deliberately excluding low-level exception metadata and lazy-value overload inventory, and closed one missed transaction failure-precedence path already required by Section 8. Implementation, the full validation gate, fresh self-review, audit-record updates, and independent post-implementation code review are complete. ## Scope @@ -12,6 +12,7 @@ The final implementation must: - release exact Database and Redis leases at the end of non-coroutine Swoole tasks; - prevent parent-created Database and Redis resources from crossing Swoole process forks; +- bound Redis same-connection terminal cleanup to one defer per pool per coroutine while preserving immediate callback-form release; - make Redis command-event cleanup failure-safe and preserve exact connection ownership for optimistic transactions; - make Redis release safe for abandoned native transaction, pipeline, and watch state; - give one physical in-memory SQLite PDO exactly one concurrent owner; @@ -33,6 +34,7 @@ This is not a redesign of Database around a new transaction engine, cleanup regi | Swoole task completion | `TaskCallback` owns a final `TaskTerminated` phase after action dispatch and result finishing | | Non-coroutine Database task leases | `ConnectionResolver` retains the exact borrowed `PooledConnection` wrappers until `TaskTerminated` | | Non-coroutine Redis task leases | Each `RedisProxy` owns its context identity; `RedisManager` exhausts already-created proxies at `TaskTerminated` | +| Coroutine Redis same-connection leases | Each `RedisProxy` records the coroutine ID owning at most one terminal deferred release per pool; callback-form operations still release newly pinned wrappers immediately, and copied child context cannot inherit false ownership | | Final parent pre-fork boundary | `Server::start()` dispatches `BeforeServerFork` immediately before native `start()` | | Database process cleanup | One Database lifecycle listener discards exact resolver leases and independently flushes already-resolved pools | | Redis process cleanup | One Redis lifecycle listener discards exact proxy leases and independently flushes already-resolved pools | @@ -57,7 +59,7 @@ This is not a redesign of Database around a new transaction engine, cleanup regi | `database-11` | Supported current Laravel parity | Major/Minor | Current Query, Eloquent, Schema, migration, connector, provider, exception, and metadata behavior is absent or stale | Port the complete current supported upstream surface discovered through originating changes | | `database-12` | Performance improvement | Improvement | Ordinary attribute reads merge every cached cast; raw SQL substitution removes bindings quadratically | Port current Laravel's per-key cast merge and indexed binding substitution | | `database-13` | Metadata and documentation defect | Major | Split dependencies, provenance, deliberate deprecated omissions, and accepted public APIs are incomplete or undiscoverable | Correct manifests, add metadata coverage, record omissions, and update task-first guides | -| `redis-03` | Cross-package defect | Major | Non-coroutine same-connection commands call coroutine defer outside a coroutine and pin the lease across tasks | Gate defer registration and release exact proxy-owned context at `TaskTerminated` | +| `redis-03` | Cross-package defect | Major | Non-coroutine same-connection commands call coroutine defer outside a coroutine and pin the lease across tasks; repeated callback-form operations in one long-lived coroutine immediately release their wrappers but retain one redundant defer closure per call until coroutine exit | Gate non-coroutine defer registration, register at most one terminal defer per pool per coroutine, preserve immediate callback release, and release exact proxy-owned task context at `TaskTerminated` | | `redis-04` | Cross-package defect | Major | Abandoned native MULTI/PIPELINE clients are requeued and silently queue commands for the next borrower | Detect native mode at release and discard terminally | | `redis-05` | Cross-package defect | Major | Redis sockets, heartbeat timers, pools, and pinned fallback context share Database's pre-fork inheritance defect | Consume `BeforeServerFork` and `BeforeWorkerStart` through exact discard plus resolved pool flush | | `redis-06` | Cross-package defect | Major | A throwing `CommandExecuted` listener exits the proxy's `finally` block before release or context handoff, leaking the borrowed pool slot | Capture event failure, complete the same ownership handoff or cleanup, then preserve existing Laravel event-failure precedence | @@ -146,16 +148,27 @@ The documentation reference is the current local Laravel Docs `13.x` checkout at | `391d540182` | Merge only the requested cached cast on ordinary attribute access; retain full merges where mutators can inspect siblings | | `e4223623a0` | Use a binding index rather than `array_shift()` per raw SQL placeholder | +### Hyperf Redis lifecycle correction + +Hyperf issue #7733 and merged pull request #7734 are discovery evidence for the callback-form defer-retention defect. Hyperf's final correction uses an eager-release context marker to suppress defer registration while preserving immediate release from callback-form pipeline and transaction operations. Hypervel keeps that ownership contract but uses the lower registration boundary: one terminal deferred release per Redis pool per coroutine. + +The immediate-release behavior is non-negotiable. Deferring callback-form release until coroutine exit can exhaust a small Redis pool in long-running or highly concurrent coroutines. The single terminal defer no-ops when no wrapper remains pinned, but it also owns any later raw same-connection pin in the same coroutine. This removes per-call closure growth without a callback-specific marker or another release path. + +Swoole 6.2.2's `swoole_coroutine_defer` has no catchable failure after a valid callable is accepted inside a live coroutine. Register the terminal defer before publishing its owner ID. This establishes ownership by statement order without a rollback branch; a non-positive coroutine ID excludes the outside-coroutine defer failure. + ### Verified Hypervel runtime facts - `server.settings.task_enable_coroutine` defaults to `false`. - Swoole executes default non-coroutine task-worker callbacks sequentially within one task worker. - `ConnectionResolver` currently installs deferred release only in a coroutine. The bare `Connection` retains the wrapper incidentally through the bound reconnector closure, but nothing owns release in non-coroutine tasks. - `RedisProxy` currently calls `Coroutine::defer()` after successful `multi`, `pipeline`, or `select` even outside a coroutine; native Swoole rejects that call. +- Each callback-form `pipeline()` or `transaction()` with no existing pin currently registers a fresh terminal defer in `RedisProxy::__call()`, then `MultiExec::executeMultiExec()` immediately releases and forgets the wrapper. Repeating the operation in a long-lived coroutine therefore retains one additional no-op closure per call until coroutine exit. +- `Coroutine::fork()`, `go(..., true)`, and `parallel(..., copyContext: true)` copy scalar context values into a child but do not copy the native defer stack. A boolean deduplication flag would therefore create a false owner in the child. Storing the owning coroutine ID makes the inherited parent value stale and forces the child to register its own defer. +- `RedisProxy::withPinnedConnection()` is defer-free and releases its own newly pinned wrapper in `finally`; it does not share the accumulation defect. - `RedisProxy` dispatches `CommandExecuted` before its release/context-handoff branch inside one `finally`; a throwing listener therefore skips ownership cleanup. `CommandFailed` listener failure already replaces the command failure as current Laravel does, while the wrapper is still released. - `RedisProxy::shouldUseSameConnection()` omits `watch`, although Redis optimistic transactions require `WATCH`, the intervening reads, `MULTI`, and `EXEC` to use one native client. Native phpredis `getMode()` remains `Redis::ATOMIC` while only watched, so queue-mode detection cannot discover this state. - `RedisConnection` inherits the pool lifecycle method `discard(): void`. Consequently, `RedisProxy::__call('discard')` never reaches phpredis `DISCARD`; it removes the borrowed wrapper from pool ownership, returns `null`, and leaves the transaction context referring to a destroyed wrapper. Callback-form transactions call `discard()` on the native phpredis object and are unaffected. -- Callback-form Redis transaction/pipeline operations release a newly pinned connection in `MultiExec::executeMultiExec()` and therefore need no terminal double-release. A callback-form transaction that reuses a wrapper pinned by `WATCH` calls native `exec()` directly, so `MultiExec` must clear the wrapper's watch flag after that native call returns successfully. +- Callback-form Redis transaction/pipeline operations release a newly pinned connection immediately in `MultiExec::executeMultiExec()`. One pool-scoped terminal defer may later no-op or release a subsequent raw pin, but callback completion never waits for coroutine exit. A callback-form transaction that reuses a wrapper pinned by `WATCH` calls native `exec()` directly, so `MultiExec` must clear the wrapper's watch flag after that native call returns successfully. - `RedisConnection::release()` can send `SELECT` while restoring the configured database. Fork cleanup must discard, not release, inherited sockets. - `PhpRedisConnection` and `PhpRedisClusterConnection` expose the authoritative native mode through `getMode()`; this is local extension state, not a Redis network command. - `Pool::discard()` validates exact borrowed ownership. `destroyConnection()` contains native close failure and always removes managed/borrowed bookkeeping and signals capacity. @@ -163,6 +176,7 @@ The documentation reference is the current local Laravel Docs `13.x` checkout at - `PoolFactory` is an auto-singletoned resolvable concrete for both Database and Redis and can own pools even when the canonical manager key is unresolved. - SQLite `pragma_database_list` returns an empty main path for in-memory databases and a canonical filesystem path for file-backed URI connections. - Native SQLite probes confirm lowercase `file:` handling, percent-decoded paths/query values, case-sensitive `mode`, and last-value-wins duplicate `mode` behavior. +- Current Laravel splits RefreshDatabase's aggregate and named in-memory checks, but its named check still recognizes only literal `:memory:`. Hypervel must route that live refresh-connection hook and every named transaction connection through the settled SQLite classifier so mixed file/memory connection sets cache only their physical memory PDOs. - `Filesystem::put()` returns `int|false`; it does not guarantee an exception on write failure. - In-memory `DbPool` deliberately shares one PDO among wrappers. Capacity greater than one therefore advertises owners that are not physically independent. - `Mutex` is a class-keyed worker-static `Channel(1)` map. Test cleanup closes Mutex channels before resetting `Model`. @@ -181,7 +195,9 @@ The owner approved: No other planned change adds successful request/query hot-path work. -The fresh self-review additionally found `redis-06` through `redis-08` in `RedisProxy::__call()`, which this work already modifies. The direct event-cleanup slots, successful-command state update, and release-time boolean read are source-proven hot-path work. They remove a pool-slot leak and restore Redis `WATCH` / `DISCARD` correctness without a registry, wrapper, network check, or transaction abstraction, but still require explicit owner approval under the audit's performance gate before implementation. +The fresh self-review additionally found `redis-06` through `redis-08` in `RedisProxy::__call()`, which this work already modifies. The direct event-cleanup slots, successful-command state update, and release-time boolean read are source-proven hot-path work. They remove a pool-slot leak and restore Redis `WATCH` / `DISCARD` correctness without a registry, wrapper, network check, or transaction abstraction. The owner approved these bounded costs together with the other implementation gates. + +The owner separately required callback-form Redis connections to keep returning to the pool immediately after their closure finishes. The `redis-03` amendment preserves that behavior and replaces the already-planned coroutine-presence read with one coroutine-ID read plus one context owner comparison when a successful same-connection command publishes a new pin. ## Implementation order @@ -404,11 +420,22 @@ Add one instance property on the concrete pooled resolver: protected array $nonCoroutineConnections = []; ``` -The key is `ConnectionName::requested`, including `::read` and `::write`; never reconstruct wrappers later from configured base names. +The owner key is normally `ConnectionName::requested`, including `::read` and `::write`; never reconstruct wrappers later from configured base names. The one exception is aliases backed by the same shared in-memory SQLite PDO: base, read, and write requests must use the pool's canonical name because only one wrapper can own that PDO. Genuine read/write pools retain their exact requested-name owners. `connection()` publishes the bare connection and exactly one terminal owner transactionally: ```php +$connectionOwnerName = $connectionName->requested; +$contextKey = $this->getContextKey($connectionOwnerName); +$pool = $this->factory->getPool($connectionName->requested); + +if ($pool->getSharedInMemorySqlitePdo() !== null) { + $connectionOwnerName = $pool->getName(); + $contextKey = $this->getContextKey($connectionOwnerName); + + // Return an existing canonical owner before borrowing the sole wrapper. +} + $pooledConnection = $pool->get(); try { @@ -426,11 +453,11 @@ try { $pooledConnection->release(); }); } else { - $this->nonCoroutineConnections[$connectionName->requested] = $pooledConnection; + $this->nonCoroutineConnections[$connectionOwnerName] = $pooledConnection; } } catch (Throwable $exception) { CoroutineContext::forget($contextKey); - unset($this->nonCoroutineConnections[$connectionName->requested]); + unset($this->nonCoroutineConnections[$connectionOwnerName]); try { $pooledConnection->discard(); @@ -473,7 +500,7 @@ The shared protected helper is justified because both public lifecycle operation 1. snapshot `$nonCoroutineConnections`; 2. clear the property; -3. forget every exact requested-name connection key; +3. forget every exact owner-name connection key; 4. forget `DEFAULT_CONNECTION_CONTEXT_KEY`; 5. only then release or discard every wrapper; 6. continue after each failure and throw the earliest one. @@ -540,7 +567,7 @@ Cover: - context/default override detached before any release callback can re-enter; - every wrapper released despite one failure, with earliest failure preserved; - discard path uses exact wrappers and exhausts failures; -- setup failure at connection retrieval, role configuration, context publication, and defer registration discards exactly once and preserves the primary failure; +- setup failure at connection retrieval or write-role configuration discards exactly once and preserves the primary failure; successful context publication is terminally owned by either the coroutine defer or non-coroutine task map; - coroutine borrows remain defer-owned and never enter terminal task cleanup; - provider task registration only when task coroutines are disabled; - unresolved resolver/factory stay unresolved at lifecycle boundaries; @@ -569,21 +596,46 @@ A custom non-coroutine daemon process can hold one resolver connection for its l - Modify `tests/Redis/RedisServiceProviderTest.php`. - Add or extend external Redis integration tests using `InteractsWithRedis`. -### Gate defer only where same-connection state is published +### Register one terminal defer per pool and coroutine -After a successful `multi`, `pipeline`, `select`, or `watch`, retain the connection in context exactly as today, but install defer only in a coroutine: +After a successful `multi`, `pipeline`, `select`, or `watch`, retain the connection in context exactly as today. Outside a coroutine, register no defer. Inside a coroutine, register at most one terminal deferred release for that pool: ```php CoroutineContext::set($this->getContextKey(), $connection); -if (Coroutine::inCoroutine()) { - Coroutine::defer(function (): void { - $this->releaseContextConnection(); - }); +$coroutineId = Coroutine::id(); + +if ($coroutineId > 0) { + $deferredReleaseOwnerContextKey = $this->getDeferredReleaseOwnerContextKey(); + + if (CoroutineContext::get($deferredReleaseOwnerContextKey) !== $coroutineId) { + Coroutine::defer(function (): void { + $this->releaseContextConnection(); + }); + + CoroutineContext::set($deferredReleaseOwnerContextKey, $coroutineId); + } } ``` -This branch runs only after successful same-connection commands. Ordinary Redis commands gain no coroutine check. +Use one private key helper and the repository naming convention: + +```php +private const DEFERRED_RELEASE_OWNER_CONTEXT_KEY_PREFIX = '__redis.deferred_release_owner.'; + +private function getDeferredReleaseOwnerContextKey(): string +{ + return self::DEFERRED_RELEASE_OWNER_CONTEXT_KEY_PREFIX . $this->poolName; +} +``` + +The order is load-bearing: register the terminal owner before publishing its coroutine ID. Do not wrap the native defer call in rollback handling; the guarded in-coroutine Swoole path has no catchable registration failure. Keep the owner ID for the context lifetime, including after callback-form immediate release, manager purge, and later re-pinning. The one existing defer releases whichever wrapper remains pinned at coroutine exit. + +The ID is required by Hypervel's supported context-copy behavior. A copy-all child inherits the parent's scalar owner value but has a different coroutine ID and no inherited native defer, so it registers and publishes its own terminal owner. Sibling children and later generations behave the same. Do not use a boolean, a coroutine-ID key suffix that accumulates copied ancestor keys, a `ReplicableContext` sentinel object, or a proxy-owned `WeakMap`/registry. + +This branch runs only after successful same-connection commands. Ordinary Redis commands gain no coroutine or context check. Do not add Hyperf's callback-specific eager-release marker, marker helper trio, or marker cleanup choreography. + +Database has no deduplication analog: every Database coroutine borrow registers its own defer, so no copyable false-owner slot exists. Explicitly copying a context while a live Database or Redis connection object is pinned continues to share that object by the context API's documented reference semantics. This amendment neither creates that deliberate lifetime escape nor justifies a generic connection-cloning or context-filtering mechanism. ### Complete command events before ownership cleanup @@ -660,7 +712,7 @@ After either the first native attempt or a retry succeeds, update only the state if ($name === 'watch' && $result !== false) { $this->watching = true; } elseif ( - in_array($name, ['exec', 'discard', 'reset'], true) + in_array($name, ['exec', 'reset'], true) || ($name === 'unwatch' && $result !== false) ) { $this->watching = false; @@ -768,7 +820,7 @@ public function discardContextConnection(): void The proxy is the only owner of its actual pool/context identity. Do not expose the key or pooled connection and do not add these internal methods to Laravel-facing Redis contracts. -Update `withPinnedConnection()` and any sibling terminal path touched by this work to forget, rather than store null, when ownership ends. Keep callback-form `MultiExec` release ownership unchanged; only settle the watched-state flag at its direct native-`exec()` boundary. +Update `withPinnedConnection()` and any sibling terminal path touched by this work to forget, rather than store null, when ownership ends. `withPinnedConnection()` remains defer-free. Keep callback-form `MultiExec` immediate-release ownership unchanged; the one pool-scoped terminal defer later no-ops unless another raw pin remains, and `MultiExec` otherwise changes only to settle the watched-state flag at its direct native-`exec()` boundary. ### Manager aggregation @@ -825,7 +877,12 @@ Cover: - successful non-coroutine `watch` pins the exact wrapper without attempting coroutine defer; - `Redis::discard()` invokes native DISCARD, returns its native result, clears native transaction/watch state, and does not discard the pool wrapper; - raw same-connection commands remain pinned through one non-coroutine task and release at termination; -- callback-form transaction/pipeline releases a newly pinned wrapper in its own `finally` and terminal cleanup is a no-op; +- repeated callback-form transaction/pipeline calls in one child coroutine release each newly pinned wrapper in their own `finally` and produce exactly one context-absent terminal `releaseContextConnection()` call for the pool after the child exits; +- callback-form success and callback/native-exec failure both return a newly pinned wrapper immediately rather than retaining it until coroutine exit; +- the existing limited-pool concurrency integration still completes while each child remains alive after its callback, proving immediate release rather than coroutine-terminal release; +- a raw same-connection pin after one or more callback-form calls is released by the already-registered terminal defer; +- a pre-pinned raw connection and nested callback-form operations gain no duplicate terminal defer and retain their original owner; +- a parent that has completed a callback-form operation can copy all context into a child; the child's inherited parent owner ID is rejected, the child publishes its own ID, and its distinct raw same-connection wrapper releases exactly once at child exit; - `WATCH` followed by callback-form `transaction()` reuses the pinned wrapper, clears tracked watch state after any non-throwing native `exec()` result, requeues the healthy wrapper, and emits no false WATCH-state critical log; - both a successful result and `exec() === false` consume the tracked watch, while a thrown `exec()` leaves it set so terminal release discards the unknown generation; - callback-form pipeline execution does not clear a prior watch; @@ -844,6 +901,8 @@ Cover: - `WATCH`, intervening reads, `MULTI`, and `EXEC` use the same native client; - successful `UNWATCH`, `EXEC`, and `DISCARD` clear the tracked watch state, while a new generation begins unwatched. +Keep the defer-count and copied-context regressions in `tests/Redis/RedisProxyTest.php`. Use a test-only `RedisProxy` subclass at the already-planned public `@internal` `releaseContextConnection()` boundary to count context-absent terminal calls after joining an explicitly created child; do not add a production defer hook. Keep the real limited-pool pipeline/transaction cases in `tests/Integration/Redis/RedisProxyIntegrationTest.php`; they prove actual wrappers return to the pool while each caller coroutine remains alive. + External Redis behavior tests must use the existing per-ParaTest-worker Redis isolation trait. ## 5. Never requeue a native Redis client in queueing or watched state @@ -959,8 +1018,16 @@ Every successful phpredis pooled release gains one native local `getMode()` call - Modify `src/database/src/Connectors/SQLiteConnector.php`. - Modify `src/database/src/Schema/SqliteSchemaState.php`. - Modify `src/database/src/Schema/SQLiteBuilder.php`. +- Modify `src/foundation/src/Testing/RefreshDatabase.php`. +- Modify `src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php`. +- Modify `src/testing/src/Concerns/TestDatabases.php`. +- Modify `src/testbench/src/Concerns/HandlesDatabases.php`. +- Modify `src/testbench/src/Concerns/Database/InteractsWithSqliteDatabaseFile.php`. +- Modify `src/boost/docs/testing.md`. - Add `tests/Database/SQLiteDatabaseTest.php`. - Modify SQLite connector, pool, builder, and schema-state tests. +- Modify `tests/Foundation/Testing/RefreshDatabaseTest.php`. +- Modify focused Foundation, Testing, and Testbench SQLite/parallel-database tests. ### Internal classifier @@ -1063,9 +1130,50 @@ public function refreshDatabaseFile(?string $path = null): void Do not atomically replace this file; that would leave the live PDO connected to the old unlinked inode. +### Parallel testing and filesystem management boundaries + +Every Testbench and parallel-testing memory check delegates to `SQLiteDatabase::isInMemory()`. URI memory databases remain unchanged because each worker process already owns distinct memory. Automatic parallel database management requires a plain SQLite filesystem path: both the early Testbench configuration rewrite and its post-`defineEnvironment()` ensure phase, plus the application test runner's common database-management choke point, reject a non-memory `file:` URI with a message directing callers to configure a plain path or use `--without-databases`. Both parallel systems honor that option before classification. + +`SQLiteBuilder::createDatabase()` and `dropDatabaseIfExists()` similarly reject memory and URI names before calling PHP filesystem APIs. They operate on plain paths only; treating a URI as a PHP path either creates a stray literal filename or reports successful deletion without touching the database. + +Do not add URI suffixing, URI-to-path decoding, or another database-name abstraction. Supporting URI-backed automatic parallel isolation honestly would require coordinated rewriting and cleanup across both parallel systems, URL handling, schema creation/drop, Testbench file swapping, and query parameters. The plain-path boundary prevents silent worker sharing without that unused machinery. + +### Mixed RefreshDatabase connection ownership + +Match current Laravel's aggregate/named split while retaining Hypervel's live default-refresh hook: + +```php +protected function usingInMemoryDatabases(): bool +{ + foreach ($this->connectionsToTransact() as $name) { + if ($this->usingInMemoryDatabase($name)) { + return true; + } + } + + return false; +} + +protected function usingInMemoryDatabase(?string $name = null): bool +{ + $config = $this->app->make('config'); + $name ??= $this->getRefreshConnection(); + + return SQLiteDatabase::isInMemory( + $config->string("database.connections.{$name}.database") + ); +} +``` + +Use `usingInMemoryDatabases()` for the restore decision and pass the loop's `$name` when caching PDOs in `beginDatabaseTransactionWork()`. A file-backed default with a named memory connection must cache only the named memory PDO; otherwise the next test can skip migrations after receiving a fresh empty memory PDO. Keep `getRefreshConnection()` as the no-argument owner because package test suites override or consume that live refresh boundary. + +Do not alter restore ordering, coroutine setup, migration publication, transaction cleanup, or the existing `$migrated` / `$inMemoryConnections` lockstep. + ### Tests -Cover the full classifier matrix, connector standalone use without `base_path()`, valid file URI acceptance, nonexistent plain paths, CLI routing, canonical URI path refresh, memory rebuild, and a checked `Filesystem::put()` false result. +Cover the full classifier matrix, connector standalone use without `base_path()`, valid file URI acceptance, nonexistent plain paths, CLI routing, canonical URI path refresh, memory rebuild, and a checked `Filesystem::put()` false result. Prove both parallel systems skip `file::memory:`, reject a non-memory URI, honor `--without-databases`, and keep suffixing plain paths. Prove SQLite schema create/drop reject memory and URI names before filesystem access. Prove RefreshDatabase's no-argument check uses the live default-refresh connection, its aggregate check finds memory on any named transaction connection, and mixed file/memory transaction setup caches only the memory PDO. + +The shared SQLite integration harness must use `SQLiteDatabase` for configured in-memory detection and must not pass URI names to PHP file-management APIs. The WAL migration test similarly skips configurations that are not plain filesystem paths because its fixture deletes and recreates the database file directly. Use `ParallelTesting::tempDir()` for every database file. Do not write fixtures into the committed test tree. @@ -1120,6 +1228,8 @@ one shared PDO Do not add another Mutex, shared-cache URI, wrapper coordinator, connection registry, or driver-specific wait path. +Resolvers must also treat unsplit base, `::read`, and `::write` aliases as one owner when their pool exposes the shared PDO. Production context and Testbench wrapper caches use the pool's canonical name only for this case. Testbench named flush resolves that canonical key only through an already-created pool, so cleanup never creates a pool merely to destroy it. + ### Tests Replace any test that treats multiple wrappers as independent owners with deterministic one-owner behavior: @@ -1128,6 +1238,9 @@ Replace any test that treats multiple wrappers as independent owners with determ - a second coroutine waits through the existing pool wait boundary; - release or discard transfers capacity; - state is visible through the same shared PDO after reuse; +- unsplit base/read/write aliases reuse one bare connection and one wrapper without waiting on their own pool slot; +- Testbench alias flush discards the canonical wrapper and restores capacity; +- query-log assertions clear the existing log before opening each alias-specific observation window; - pool close and persistence tests release their first wrapper before another borrow; - every valid URI memory form receives maximum one; - ordinary file-backed SQLite retains configured capacity; @@ -1260,6 +1373,28 @@ $this->transactions = max(0, $this->transactions - 1); This includes current Laravel's physical cleanup before deadlock retry and improves it by never retrying after failed cleanup. +The same cleanup gate applies when the transaction callback or `committing` listener fails. Attempt rollback, preserve that earlier failure if physical rollback, manager rollback callbacks, or the rolled-back event fails, and retry a concurrency failure only after completely successful cleanup: + +```php +$cleanedUp = true; + +try { + $this->rollBack(); +} catch (Throwable) { + $cleanedUp = false; +} + +if ($cleanedUp + && $this->causedByConcurrencyError($e) + && $currentAttempt < $maxAttempts) { + return; +} + +throw $e; +``` + +The nested concurrency branch similarly preserves its intended `DeadlockException` if transaction-manager rollback callbacks fail. It still invalidates the exact session memo and decrements the logical level before manager cleanup; no additional event or retry path is introduced. + ### Explicit `commit()` remains caller-owned before physical success Do not route explicit `commit()` through the retry handler. If its `committing` listener or physical commit throws: @@ -1277,15 +1412,15 @@ Nested explicit commits retain existing event behavior and member order. `rollBack()` must: 1. validate the requested target; -2. attempt physical rollback/savepoint rollback; -3. on success, invalidate memoized session configuration and publish the new logical level; +2. attempt physical rollback/savepoint rollback while invalidating the exact PDO's memoized session configuration at the physical boundary on every outcome; +3. on success, publish the new logical level; 4. independently run manager rollback callbacks and `TransactionRolledBack`; 5. preserve the earliest manager/event failure. If physical rollback fails: -- non-lost failure keeps the old logical level and manager records, marks the session unknown, and rethrows; -- lost failure sets level zero, detaches manager state, clears PDO/read PDO without retrying physical rollback, and rethrows the physical failure; +- non-lost failure keeps the old logical level and manager records, marks the session unknown after invalidating its memo, and rethrows; +- lost failure invalidates the dead PDO's memo without marking it unknown, sets level zero, detaches manager state, clears PDO/read PDO without retrying physical rollback, and rethrows the physical failure; - do not fire the rolled-back event because no successful physical rollback was observed. After physical success, manager and event are independent terminal phases: @@ -1348,7 +1483,9 @@ Build focused failure injection for: - before-start callback failure before physical begin; - manager begin and began-event failure after physical begin; - rollback cleanup failure preserving begin failure; -- user callback failure; +- user callback failure, including rollback cleanup failure preserving the callback failure; +- retryable callback failure with rollback cleanup failure preventing retry; +- nested concurrency failure preserving its `DeadlockException` when manager rollback cleanup fails; - throwing `TransactionCommitting` listener, including the real old pooled-corruption reproduction; - physical commit concurrency failure with successful cleanup and retry; - physical commit cleanup failure preventing retry while preserving the commit failure; @@ -1462,6 +1599,8 @@ Cover: - Modify `tests/Database/DatabaseEloquentModelTest.php`. - Modify or add focused coroutine boot tests under `tests/Database/Eloquent/`. - Modify `tests/Database/DatabaseEloquentModelAttributesTest.php`. +- Modify `src/scout/src/Searchable.php` and focused Scout feature coverage. +- Modify `src/nested-set/src/HasNode.php` and `tests/NestedSet/NestedSetTest.php`. ### Owner state @@ -1575,6 +1714,15 @@ foreach ($reflection->getTraits() as $trait) { Keep Hypervel's cache key as `class@attributeClass` and continue selecting `$property` after retrieving the cached attribute object. Do not port Laravel `#60815`; its property-key collision cannot occur in this cache shape. +### Keep model-boot consumers out of the pre-publication recursion guard + +The full gate proved two current package consumers still construct their model while `bootIfNotBooted()` is deliberately incomplete: + +- Scout's `bootSearchable()` creates `(new static)` to register collection macros. Current Scout PR `#965` moved that instance work and observer registration into `whenBooted()`. Port that boundary without its obsolete `method_exists()` compatibility branch, while retaining Hypervel's class-string observer and collection behavior. +- NestedSet's `usesSoftDelete()` calls `method_exists(new static, ...)` from `bootHasNode()`. Match current upstream's no-instantiation trait check through `class_uses_recursive(static::class)` and the existing typed cache. Keep listener registration in the boot phase: it is not recursive in Hypervel, and moving the whole block to `whenBooted()` would change established listener order without fixing another verified failure. Record that ordering reason in one short source comment where a future upstream sync will compare the block. + +Do not relax the Model recursion guard or add another boot phase. The consumers must stop constructing an unpublished model at the exact operation that does so. + ### Tests Cover: @@ -1589,6 +1737,8 @@ Cover: - `clearBootedModels()` and `flushState()` clear owner state; - class attributes declared directly, on a trait, on a parent, and on a parent trait resolve with current precedence; - attribute object cache/property selection remains collision-free. +- first construction of a Searchable model completes, then registers its observer and collection macros through the post-publication callback; +- plain and soft-deleting NestedSet models boot successfully and classify SoftDeletes without nested model construction. Use explicit channels for boot interleaving; do not rely on timing sleeps. @@ -1887,6 +2037,7 @@ Port and adapt every test changed by the originating commits. At minimum prove: - literal question marks in column operators; - PostgreSQL Expression date/time compilation; - aggregate alias delimiting; +- Telescope's query watcher expects the same delimited aggregate alias as the grammar; - PostgreSQL precomputed vector full-text SQL; - Query/Eloquent/relation update subqueries and binding order; - the current relative-date integration matrix on every configured supported driver. @@ -2541,7 +2692,7 @@ if ($char === '?' && ! $isStringLiteral) { } ``` -Keep the current literal/escaped-question-mark parser and binding escaping unchanged. This removes repeated array reindexing and makes substitution linear in the number of bindings. +Keep the current literal/escaped-question-mark parser and ordinary binding escaping unchanged. Current Laravel's resource-binding correction is incomplete: it routes live and closed resources through driver binary escapers that still call `bin2hex()` and therefore throw. Normalize resource handles to their stable `Resource id #N` identity string at this observational raw-SQL boundary, then quote that identity as an ordinary string. Do not consume, rewind, or claim to reproduce stream contents that may already have been read or closed. This also removes repeated array reindexing and makes substitution linear in the number of bindings. ### Tests @@ -2551,7 +2702,7 @@ Port the originating behavior/performance regressions: - legacy and `Attribute` get/set mutators still see sibling cached-cast state; - encrypted unrelated casts are not decrypted/serialized on another attribute read; - mutable custom and date-like casts still synchronize back to raw attributes correctly; -- raw SQL substitution preserves quoted question marks, escaped question marks, resources, missing bindings, and binding order; +- raw SQL substitution preserves quoted question marks, escaped question marks, missing bindings, and binding order, and renders live or closed resources as quoted `Resource id #N` identity placeholders without reading them; - a long binding list produces identical SQL without asserting implementation timing. No benchmark is required unless implementation uncovers uncertainty. Both changes remove source-proven work without adding machinery or changing results. @@ -2691,6 +2842,11 @@ At the available column types/indexes sections: - document MariaDB `vectorIndex()` and its supported-driver boundary; - keep the existing PostgreSQL `vector`/pgvector documentation distinct. +At the existing schema-dump and migration-event sections: + +- document `schema:dump --without-migration-data` as omitting migration-table rows while retaining the schema; +- state that `MigrationStarted` and `MigrationEnded` expose the migration filename through `$name`. + ### Query builder Add brief sections at the natural existing headings: @@ -2740,6 +2896,8 @@ After editing: - ensure no unsupported SQL Server/direct-connection/deprecated surface appears; - ensure internal task, fork, pool, transaction, mutex, and URI-classifier mechanics stay out of user docs. +Do not add a new exception-metadata section solely to inventory `UniqueConstraintViolationException::$index` and `$columns`; the typed, tested class is the natural reference for that specialized surface. Do not inventory every `Closure|array` lazy-value overload on established Eloquent methods. + ## 17. Remove every superseded path The implementation is incomplete until the old ownership model is gone. @@ -2749,6 +2907,7 @@ Delete: - `src/database/src/Listeners/UnsetContextInTaskWorkerListener.php`; - its reflective listener tests and configured-base-name cleanup assumptions; - duplicate SQLite memory/URI substring checks replaced by `SQLiteDatabase`; +- RefreshDatabase's default-only literal `:memory:` check and per-loop default reuse; - tests that expect multiple independently borrowed wrappers around one shared in-memory PDO; - transaction-manager callback-before-detach paths; - any rollback retry path that can run before physical/logical cleanup succeeds; @@ -2758,6 +2917,7 @@ Delete: Replace rather than retain: - `CoroutineContext::set($key, null)` terminal cleanup with `forget($key)`; +- one deferred release registration per successful Redis same-connection pin with one registration per pool per coroutine; - manager-name-derived Redis context cleanup with proxy-owned key derivation; - release-before-pool-destruction purge/fork cleanup with exact discard; - raw SQLite filename reuse with canonical attached-database lookup; @@ -2773,6 +2933,8 @@ Do not leave: - a transaction state machine/finalizer/callback executor; - a second SQLite lock, shared-cache URI rewrite, or wrapper coordinator; - a PHP mirror of native Redis queue mode; +- a callback-specific eager-release marker or marker helper trio; +- a `WeakMap`, registry, per-coroutine key suffix, or `ReplicableContext` object for deferred-release ownership; - a mark-invalid fallback that masks `Pool::discard()` ownership failure; - comments documenting abandoned designs; - placeholder or implementation-detail-only tests. @@ -2786,13 +2948,14 @@ Before validation, run repository-wide searches for every removed class, helper, | Boundary | Success coverage | Failure coverage | |---|---|---| | `TaskCallback` | OnTask result finishing followed by terminal event | OnTask failure, finish failure, terminal failure, and all combinations preserve the earliest throwable while still attempting terminal cleanup | -| Database task cleanup | Exact base/read/write wrappers release once at task end | Partial acquisition/publication/defer failure discards exact wrapper; one release failure does not skip siblings | +| Database task cleanup | Exact base/read/write wrappers release once at task end; aliases of one shared in-memory SQLite PDO reuse one canonical owner | Partial acquisition or write-role configuration failure discards the exact wrapper; one release failure does not skip siblings | | Database fork cleanup | Resolved wrapper discard plus resolved pool flush | Unresolved owners stay unresolved; resolver failure does not skip factory flush; earliest failure wins | -| Redis task cleanup | Raw same-connection state remains pinned through one task then releases; callback-form transaction completion settles a consumed watch | Callback-form newly pinned release/no-op, divergent proxy name, release failure exhaustion, no double release, no false WATCH discard/log after successful callback transaction | +| Redis task/coroutine cleanup | Raw same-connection state remains pinned through one task then releases; one terminal defer per pool/coroutine owns raw pins while callback-form operations release immediately; copied child context publishes its own defer owner; callback-form transaction completion settles a consumed watch | Repeated callback calls produce one terminal no-op rather than unbounded deferred closures; callback-then-raw and copied-child raw pins remain terminally owned; divergent proxy name, release failure exhaustion, no double release, and no false WATCH discard/log after successful callback transaction | | Redis fork/purge | Exact proxy discard and actual proxy pool flush | Manager/factory independently unresolved or failing; invariant failure propagates | | Redis command events | Listener observation precedes ownership handoff | Success/failure listener exceptions cannot skip release or same-connection handoff; existing event precedence and cleanup failures remain truthful | | Redis transaction state | ATOMIC, unwatched clients restore selected database and requeue; WATCH through EXEC stays on one native client | MULTI/PIPELINE/abandoned WATCH discards; log failure cannot prevent discard; mode/restore failure invalidates; discard ownership failure cannot fall through to requeue | | SQLite classifier | Literal memory, encoded memory, named memory, file URIs, canonical attached path | Invalid/mixed-case modes follow native behavior; duplicate `mode` uses final value; write false throws descriptively | +| RefreshDatabase SQLite ownership | Any named memory connection triggers restore; mixed connection sets cache only their memory PDOs | A file-backed default cannot suppress named-memory restore/caching, and a memory default cannot make named file PDOs process-static | | In-memory pool | One owner serializes through existing channel | Invalid option types/counts still fail in `PoolOption`; no normalization masks configuration errors | | Transaction begin | Physical begin and logical/manager/event publication agree | Manager/event publication failure rolls back to prior level and preserves the primary error | | Managed `transaction()` commit | Listener, physical commit, manager callbacks, and event publish in order | Pre-commit failure rolls back; deadlock retry only after cleanup; lost commit detaches terminally; cleanup failure prevents retry and never replaces primary | @@ -2831,6 +2994,13 @@ Run the changed files individually while implementing: ./vendor/bin/phpunit --no-progress tests/Redis/RedisConnectionTest.php ./vendor/bin/phpunit --no-progress tests/Redis/RedisProxyTest.php ./vendor/bin/phpunit --no-progress tests/Redis/RedisManagerTest.php +./vendor/bin/phpunit --no-progress tests/Scout/Feature/SearchableModelTest.php +./vendor/bin/phpunit --no-progress tests/NestedSet +./vendor/bin/phpunit --no-progress tests/Telescope/Watchers/QueryWatcherTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Database/Sqlite/Console/MigrateFreshCommandWithJournalModeWalTest.php +./vendor/bin/phpunit --no-progress tests/Foundation/Testing/Concerns/InteractsWithParallelDatabaseTest.php +./vendor/bin/phpunit --no-progress tests/Testing/Concerns/TestDatabasesTest.php +./vendor/bin/phpunit --no-progress tests/Testbench/DefaultConfigurationTest.php ``` Then run focused package groups: @@ -2855,8 +3025,6 @@ The Redis and database integration tests must use the repository's worker-isolat After focused tests are green: ```bash -./vendor/bin/php-cs-fixer fix -./vendor/bin/phpstan composer fix ``` @@ -2877,9 +3045,10 @@ After `composer fix` passes, review the entire diff without trusting this plan: 7. Trace Eloquent first boot through same-owner recursion, sibling waiting, publication, post-publication hooks, failure, reset, and test cleanup order. 8. Compare every ported method, signature, member position, test, and doc example with current Laravel `13.x`, not historical diffs. 9. Search the whole repository for every changed contract, event, method, context key, deprecated omission, and documentation claim. -10. Re-check package dependency ownership from imports after all deletions/additions. -11. Check hot paths for new container resolution, locks, context calls, allocations, logging, yields, retries, and retained worker memory. -12. Check for dead helpers, duplicate cleanup, defensive guards that mask invariants, stale comments, and mechanisms with only hypothetical consumers. +10. Re-run both the literal `':memory:'` comparison sweep and the `mode=memory` family sweep; production classification outside `SQLiteDatabase` must be absent. +11. Re-check package dependency ownership from imports after all deletions/additions. +12. Check hot paths for new container resolution, locks, context calls, allocations, logging, yields, retries, and retained worker memory. +13. Check for dead helpers, duplicate cleanup, defensive guards that mask invariants, stale comments, and mechanisms with only hypothetical consumers. Fix straightforward omissions immediately and rerun proportionate tests. Any newly discovered design issue goes through the required second-opinion workflow. @@ -2891,10 +3060,12 @@ Then request a code review of the complete implementation, tests, docs, metadata | Change | Frequency | Effect | |---|---|---| +| Database shared-memory alias ownership | First requested-name resolution in an execution context | One local shared-PDO property read; only unsplit in-memory SQLite aliases add a canonical context lookup, preventing self-exhaustion without query or network I/O | | Eloquent boot owner `isset()` | Every normal model construction | One static array lookup; owner-approved correctness cost | | Redis native `getMode()` | Every successful phpredis pooled release | One local extension-state read, no network I/O; owner-approved correctness cost | -| Redis event cleanup slots | Every Redis proxy command | Three local throwable slots and direct branches around work already performed; fresh-review owner gate | -| Redis transaction-state routing | Every Redis wrapper/proxy command and release | Exact string comparisons for WATCH/DISCARD state and one boolean release read; callback-form multi-exec adds one string/boolean branch, while only an already-pinned successful transaction performs one context lookup and boolean clear; no lock or network I/O; fresh-review owner gate | +| Redis deferred-release deduplication | Each successful same-connection publication with no current pin | One coroutine-ID read in place of the planned coroutine-presence read plus one context owner comparison; one integer write and one closure registration per pool per coroutine, with no ordinary-command or network work; owner-approved correctness cost | +| Redis event cleanup slots | Every Redis proxy command | Three local throwable slots and direct branches around work already performed; owner-approved correctness cost | +| Redis transaction-state routing | Every Redis wrapper/proxy command and release | Exact string comparisons for WATCH/DISCARD state and one boolean release read; callback-form multi-exec adds one string/boolean branch, while only an already-pinned successful transaction performs one context lookup and boolean clear; no lock or network I/O; owner-approved correctness cost | | Per-key cached-cast merge | Ordinary Eloquent attribute reads | Removes unrelated cached-cast iteration/serialization; performance improvement | | Indexed raw SQL substitution | Query SQL formatting/logging | Removes repeated array reindexing; performance improvement | @@ -2903,7 +3074,7 @@ The Eloquent coroutine-ID lookup and Mutex acquisition occur only during incompl ### Retained worker memory - One bounded `PooledConnection` entry per requested Database connection name only while a non-coroutine task owns it. -- Existing Redis proxies own their already-required pinned context; no parallel registry is added. +- Existing Redis proxies own their already-required pinned context; a coroutine that uses same-connection commands retains one integer owner-ID context slot and one terminal closure per pool until it exits, with no parallel registry or per-call growth. - One boolean belongs to each already-existing Redis connection wrapper and describes only that native generation's unobservable watch state. - One integer boot owner only while a model class is publishing. - One existing Mutex channel per model class first booted in a coroutine remains for the worker lifetime; non-coroutine first boot creates none. @@ -2915,6 +3086,7 @@ The Eloquent coroutine-ID lookup and Mutex acquisition occur only during incompl - The SQLite classifier has four real consumers and deletes duplicated incompatible checks. - The transaction implementation uses direct phase-specific control flow; no generic abstraction is introduced. - The Redis proxy owns its real context identity; the manager only aggregates existing proxies. +- One coroutine-ID-owned terminal Redis defer per pool replaces duplicate per-pin registration while callback-form release stays immediate and copied child context self-registers; no eager-release marker, object sentinel, registry, or second cleanup path is added. - Redis command-event cleanup uses direct local throwable slots, watch state is one wrapper boolean because phpredis exposes no equivalent getter, and native DISCARD gets one explicit route around the existing Pool method collision. - Public contracts are unchanged except for supported current Laravel API parity. Concrete internal lifecycle methods remain off contracts. - Directly deprecated Laravel compatibility surfaces remain omitted rather than reintroduced. @@ -2930,7 +3102,7 @@ This work unit is complete only when: - every accepted `database-05` through `database-13` and `redis-03` through `redis-08` finding is implemented at the specified owner; - both lifecycle events have Database and Redis consumers, deterministic failure precedence, and focused tests; - no pooled Database or Redis resource can cross the supported terminal task or process boundary unowned; -- no Redis client in native MULTI/PIPELINE or abandoned WATCH state can be requeued, callback-form optimistic transactions clear consumed watch state without false discard/logging, native `Redis::discard()` reaches phpredis rather than Pool lifecycle teardown, and command-listener failure cannot leak its borrowed wrapper; +- no Redis client in native MULTI/PIPELINE or abandoned WATCH state can be requeued, repeated callback-form operations can accumulate only one terminal defer per pool/coroutine while still releasing immediately, callback-then-raw and copied-child raw pins remain terminally owned by the correct coroutine ID, callback-form optimistic transactions clear consumed watch state without false discard/logging, native `Redis::discard()` reaches phpredis rather than Pool lifecycle teardown, and command-listener failure cannot leak its borrowed wrapper; - every supported SQLite URI/memory form is classified consistently and one in-memory PDO has one owner; - physical transaction state, logical state, manager records, callbacks, events, retry decisions, and pooled reuse remain truthful on every covered edge; - Eloquent first boot is recursive-safe, coroutine-safe, retryable before publication, and Laravel-compatible after publication; From 6a378fd159bf5632ed35d1448c82c8e74c5d1e5c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:53:07 +0000 Subject: [PATCH 25/26] fix(database): reconnect after terminal PostgreSQL loss PostgreSQL may report an active transaction after the backend has already disappeared. During pooled refresh, rolling back that dead generation could throw after replacement PDOs were created, aborting reconnection and discarding the healthy replacements. Treat a lost-classified rollback failure during disconnect as proof that the old physical session is already terminal. Continue transaction-manager cleanup and PDO detachment, surface any manager failure, and retain the existing precedence for non-lost physical failures. Add deterministic coverage for lost rollback cleanup and failure precedence, and align the Database implementation plan and audit ledger with the corrected database-09 invariant. --- ...-coroutine-state-lifecycle-audit-ledger.md | 2 +- ...e-lifecycles-and-current-laravel-parity.md | 8 +-- src/database/src/Connection.php | 5 +- tests/Database/DatabaseConnectionTest.php | 57 +++++++++++++++++++ 4 files changed, 66 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index 4e8905c86..b44632fcd 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -1147,7 +1147,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Task and process ownership:** `TaskCallback` now publishes a guarded terminal event after action/finish work and preserves the earliest failure. `Server::start()` publishes one final pre-fork event immediately before native start. Database's resolver and Redis's existing proxies own their exact borrowed identities; lifecycle listeners aggregate only already-resolved owners, detach context before I/O, continue independent cleanup after failure, and discard rather than release inherited resources. No generic lifecycle registry, priority mechanism, reflective context deletion, or reconstructed configured-name cleanup remains. - **Redis ownership and native state:** Each pool/coroutine owns one terminal defer identified by the owning coroutine ID, so copied context cannot inherit false defer ownership and repeated callback-form operations cannot accumulate closures. Callback transactions still return newly pinned wrappers immediately, including in long-lived coroutines. The wrapper reads phpredis's local queue mode at release and tracks one boolean only for WATCH because phpredis exposes no corresponding getter. Native DISCARD has one explicit route around Pool's method-name collision. Ordinary Redis commands add no defer lookup or network command. - **SQLite and Testbench ownership:** `SQLiteDatabase` is the sole lowercase URI and in-memory classifier across connectors, pools, schema state, RefreshDatabase, parallel testing, the shared integration harness, and Testbench. Automatic parallel database management and file create/drop reject non-memory URI names rather than treating them as PHP paths. File refresh uses the canonical attached database and preserves its inode. Mixed RefreshDatabase connection sets cache only physical memory PDOs, and unsplit base/read/write aliases share one canonical wrapper around one memory PDO. Tests retain assigned ParaTest worker identity and use bounded wait paths. -- **Transaction truthfulness:** Begin, callback/pre-commit, physical commit, logical publication, manager callbacks, events, rollback, disconnect, and retry are explicit fixed phases. Manager records detach before user rollback callbacks and rollback callbacks exhaust deepest-first; commit callbacks retain current Laravel's stop-on-first contract. Cleanup failure prevents retry and never replaces the primary callback, listener, commit, or synthesized nested-deadlock failure. Lost physical sessions detach terminally, while non-lost physical failures preserve truthful active state. No state machine, transaction object, ambiguity model, callback registry, generic finalizer, or retry framework is introduced. +- **Transaction truthfulness:** Begin, callback/pre-commit, physical commit, logical publication, manager callbacks, events, rollback, disconnect, and retry are explicit fixed phases. Manager records detach before user rollback callbacks and rollback callbacks exhaust deepest-first; commit callbacks retain current Laravel's stop-on-first contract. Cleanup failure prevents retry and never replaces the primary callback, listener, commit, or synthesized nested-deadlock failure. A lost physical failure during disconnect means the session is already terminal, so manager cleanup and reconnection continue and any manager failure remains observable; non-lost physical failures retain their existing primacy and preserve truthful state. No state machine, transaction object, ambiguity model, callback registry, generic finalizer, or retry framework is introduced. - **Eloquent publication and current parity:** First model boot remains lock-free after stable publication, permits same-owner construction only after `$booted` is true, clears failed pre-publication ownership for retry, and retains publication through post-boot hooks. Scout and NestedSet no longer instantiate an unpublished model inside boot. Current supported Laravel Query, Eloquent, Schema, migration, connector, provider, exception, facade, and PHPDoc surfaces are ported from current source after originating-PR discovery. SQL Server, dynamic/direct connection, directly deprecated compatibility, and other approved unsupported surfaces remain omitted and explicitly recorded where future syncs look. - **Important rejected concerns:** Do not add a generic task cleanup registry, event priorities, transaction state machine, shared SQLite cache or second lock, URI rewriting for automatic parallel isolation, Redis queue-mode mirror, eager-release marker, per-pin defer, `WeakMap` defer registry, transaction abstraction, boot registry, compatibility shims, benchmarks for source-proven performance improvements, or documentation that exposes internal lifecycle choreography. Deliberate Laravel-style escape hatches remain escape hatches. Hyperf parity is not a requirement; only the bounded Redis lifecycle lesson from its fix is retained in the cleaner owning shape. - **Performance and compatibility:** Normal Database queries gain no container lookup, context operation, lock, retry, logging, yield, or registry. Stable model construction adds one static owner-map `isset()`, and first boot alone reads the coroutine ID and may use the existing Mutex. Redis release adds one local extension-state read and one boolean; same-connection publication adds one owner-ID comparison only when publishing a pin. Per-key cast merging and indexed raw-SQL substitution remove work. SQLite normalization occurs at setup, lifecycle events are cold task/start boundaries, and exhaustive cleanup is exceptional or terminal. Public Laravel APIs remain intact; supported current APIs are restored, while internal Swoole adaptations remain at their lowest owners. diff --git a/docs/plans/2026-07-24-1013-database-persistence-lifecycles-and-current-laravel-parity.md b/docs/plans/2026-07-24-1013-database-persistence-lifecycles-and-current-laravel-parity.md index a6c3ba720..3cd24143b 100644 --- a/docs/plans/2026-07-24-1013-database-persistence-lifecycles-and-current-laravel-parity.md +++ b/docs/plans/2026-07-24-1013-database-persistence-lifecycles-and-current-laravel-parity.md @@ -1454,9 +1454,9 @@ if ($exception !== null) { - publish logical level zero; - ask the transaction manager to detach all records even when the logical counter was already zero; - clear write and read PDO references unconditionally; -- preserve the earliest physical or manager/callback failure. +- preserve the earliest non-lost physical or manager/callback failure. -The PDO references are cleared in a terminal `finally`-equivalent boundary. Physical rollback failure marks the old physical session unknown before dropping it. Do not retry physical rollback after a lost-connection failure. +The PDO references are cleared in a terminal `finally`-equivalent boundary. Physical rollback failure marks the old physical session unknown before dropping it. A lost-connection failure during disconnect means the physical session is already terminal and must not prevent manager cleanup or reconnection; a manager/callback failure remains observable after that classification. Preserve every non-lost physical failure, and do not retry physical rollback after a lost-connection failure. Use a small protected helper only if the commit-lost and rollback-lost paths otherwise duplicate the same non-trivial manager/PDO terminal detach. It may not hide physical I/O or become a generic finalizer. @@ -1496,7 +1496,7 @@ Build focused failure injection for: - lost rollback terminal detachment without a second physical rollback; - manager rollback failure still attempting the event; - event failure after successful manager cleanup; -- disconnect physical failure, callback failure, PDO clearing, and earliest throwable; +- disconnect non-lost physical failure, already-terminal lost physical cleanup, callback failure, PDO clearing, and exact failure precedence; - `withFreshQueryLog()` success and exception restoration; - pooled release after every failure class, proving no false reusable state. @@ -2961,7 +2961,7 @@ Before validation, run repository-wide searches for every removed class, helper, | Managed `transaction()` commit | Listener, physical commit, manager callbacks, and event publish in order | Pre-commit failure rolls back; deadlock retry only after cleanup; lost commit detaches terminally; cleanup failure prevents retry and never replaces primary | | Explicit `commit()` | Physical commit before logical decrement | Committing listener or physical failure leaves active caller-owned state | | Rollback | Logical level updates after physical success | Non-lost failure keeps truthful active state; lost failure detaches terminally; manager/event failures run independently after physical success | -| Disconnect | Physical rollback, manager detach, and PDO nulling all complete | Every phase is attempted, both PDOs become null, and earliest failure wins even from logical level zero | +| Disconnect | Physical rollback, manager detach, and PDO nulling all complete | Every phase is attempted and both PDOs become null; lost physical cleanup is already terminal, manager failure remains observable, and non-lost physical failure stays primary even from logical level zero | | Transaction records | Full/partial rollback detach the exact record set before callbacks | Re-entry cannot see detached records; rollback callbacks exhaust deepest-first; commit callbacks retain upstream stop-on-first | | Eloquent boot | First owner publishes once; sibling waits; post-publication recursion returns | Pre-publication recursion throws; pre-publication failure retries; post-publication failure remains booted while clearing owner/lock | diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 55c2450d0..c59824562 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -967,7 +967,10 @@ public function disconnect(): void } } catch (Throwable $throwable) { $this->markSessionStateUnknown($pdo); - $exception = $throwable; + + if (! $this->causedByLostConnection($throwable)) { + $exception = $throwable; + } } try { diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index f6bee8bf2..5db4fa9a1 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -1192,6 +1192,63 @@ public function testDisconnectExhaustsCleanupAndPreservesThePhysicalFailure(): v $this->assertNull($connection->getRawReadPdo()); } + public function testDisconnectTreatsLostPhysicalRollbackFailureAsAlreadyTerminal(): void + { + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['inTransaction', 'rollBack']) + ->getMock(); + $pdo->expects($this->once())->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('rollBack')->willThrowException( + new PDOException('SQLSTATE[HY000]: General error: 7 no connection to the server') + ); + + $connection = $this->getMockConnection([], $pdo); + $connection->setReadPdo(new PDOStub); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $manager->begin('test', 1); + + $connection->disconnect(); + + $this->assertSame(0, $connection->transactionLevel()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertNull($connection->getRawPdo()); + $this->assertNull($connection->getRawReadPdo()); + } + + public function testDisconnectPreservesManagerFailureAfterLostPhysicalRollbackFailure(): void + { + $callbackFailure = new RuntimeException('rollback callback failure'); + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['inTransaction', 'rollBack']) + ->getMock(); + $pdo->expects($this->once())->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('rollBack')->willThrowException( + new PDOException('SQLSTATE[HY000]: General error: 7 no connection to the server') + ); + + $connection = $this->getMockConnection([], $pdo); + $connection->setReadPdo(new PDOStub); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $manager->begin('test', 1); + $manager->addCallbackForRollback(static function () use ($callbackFailure): never { + throw $callbackFailure; + }); + + try { + $connection->disconnect(); + $this->fail('Expected disconnect manager cleanup to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($callbackFailure, $exception); + } + + $this->assertSame(0, $connection->transactionLevel()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertNull($connection->getRawPdo()); + $this->assertNull($connection->getRawReadPdo()); + } + public function testPretendOnlyLogsQueries() { $connection = $this->getMockConnection(); From baca71ec9e86abc48a24ddf0a76f5c3be130dcc1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:53:15 +0000 Subject: [PATCH 26/26] test(horizon): bound supervisor process teardown Supervisor integration cleanup sent SIGTERM, immediately requested a hard stop, and then waited without a timeout. When the child exited from the earlier SIGTERM after Symfony recorded SIGKILL, wait() raised ProcessSignaledException; the unbounded wait could also strand suite shutdown. Use WorkerProcess::kill() as the single synchronous hard-stop and reap boundary. Exhaust cleanup across every active and terminating process, preserve the earliest genuine cleanup failure, and remove the redundant competing signal and wait phases. --- .../Horizon/Feature/SupervisorTest.php | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/Integration/Horizon/Feature/SupervisorTest.php b/tests/Integration/Horizon/Feature/SupervisorTest.php index a3f7fd975..8de39afa5 100644 --- a/tests/Integration/Horizon/Feature/SupervisorTest.php +++ b/tests/Integration/Horizon/Feature/SupervisorTest.php @@ -27,6 +27,7 @@ use Hypervel\Tests\Integration\Horizon\IntegrationTestCase; use Mockery as m; use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use Throwable; class SupervisorTest extends IntegrationTestCase { @@ -81,16 +82,18 @@ protected function terminateProcesses(): void ->concat($this->supervisor->terminatingProcesses()->pluck('process')) ->unique(static fn (WorkerProcess $process): int => spl_object_id($process)); - try { - $processes->each->terminate(); - } finally { - $processes->each->kill(); + $exception = null; - $processes->each(function (WorkerProcess $process): void { - if ($process->isStarted()) { - $process->wait(); - } - }); + $processes->each(function (WorkerProcess $process) use (&$exception): void { + try { + $process->kill(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + }); + + if ($exception !== null) { + throw $exception; } }