diff --git a/AGENTS.md b/AGENTS.md index 0262c73c5..a0a74e583 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -375,6 +375,10 @@ These rules apply to all tests — new tests for framework work and ported tests Test supported public behavior, meaningful branches, verified regressions, and realistic coroutine or worker-lifetime failures. Do not add production APIs, branches, or defensive machinery solely to make speculative states testable. Do not require invariants to survive deliberate framework escape hatches unless the public contract promises that behavior. +### Exceptions in tests + +PHPUnit assertion failures, skips, and incomplete markers all extend `AssertionFailedError`, which extends `RuntimeException`. A test's `try`/`catch` must not turn any of them into success. When testing behavior after an exception, the catch must not be reachable by a failure that skips the behavior under test. Satisfy that by pinning the escaped exception by identity or message, or by catching a type that excludes every other failure the code path can produce. + ### Directory layout All tests live in `tests/{PackageName}/` (PascalCase). Tests that require external services go in `tests/Integration/{PackageName}/` — see Integration tests below. When only some integration tests for a package require one service, group them in `tests/Integration/{PackageName}/{ServiceName}/`. When every integration test for the package requires that service, keep them directly in the package directory. diff --git a/docs/plans/2026-08-13-database-fetch-correctness-and-transaction-purity.md b/docs/plans/2026-08-13-database-fetch-correctness-and-transaction-purity.md new file mode 100644 index 000000000..a86f49ed7 --- /dev/null +++ b/docs/plans/2026-08-13-database-fetch-correctness-and-transaction-purity.md @@ -0,0 +1,351 @@ +# Database fetch correctness and transaction purity plan + +## Status and objective + +Correct the database connection and query-builder fetch-mode contract, preserve every returned row during streaming, make shape-owning terminals independent of query-scoped custom row modes, make transaction-state reads truthful to PHPStan through both the connection contract and `DB` facade, and repair Testbench's orphaned serve-runtime reaper. + +This is framework correctness work, not Workflow-specific behavior. It keeps Laravel's query-scoped `fetchUsing()`, deliberately removes Laravel's ineffective Capsule-wide fetch setter, restores current `selectResultSets()` parity, widens one incorrect Hypervel native return type, improves static result types, and fixes interrupted-test cleanup without adding queries, network round trips, new configuration, global state, or caches. + +## Verified defects and provenance + +Hypervel ported Laravel's PDO fetch-mode feature, including several upstream defects, and has additional Hypervel-specific omissions and typing mistakes. The current local Laravel 13.x source still contains the shared defects; the missing `selectResultSets(..., $fetchUsing)` argument is current Laravel behavior that Hypervel omitted. + +| Area | Verified behavior | Ownership | +|---|---|---| +| Raw cursor termination | `while ($record = $statement->fetch(...))` stops on valid `null`, `false`, `0`, `"0"`, and `""` rows. | Shared upstream defect | +| Raw cursor arguments | `fetch(PDO::FETCH_COLUMN, 1)` treats `1` as cursor orientation, not the requested column. SQLite returned no rows in the direct reproduction. | Shared upstream defect | +| Raw cursor defaults | `fetchAll(PDO::FETCH_COLUMN)` defaults to column `0` and `fetchAll(PDO::FETCH_CLASS)` defaults to `stdClass`, while `setFetchMode()` requires those arguments explicitly. A direct `fetch(PDO::FETCH_CLASS)` also fails without a class. | Shared upstream defect | +| Pretend cursor | The pretend callback returns `[]`; cursor then calls `fetch()` on that array. | Shared upstream defect | +| Result sets | Hypervel cannot pass PDO fetch arguments to `selectResultSets()`. | Missing current Laravel parity | +| Capsule default | `Capsule\Manager::setFetchMode()` writes `database.fetch`, but no current connection-construction path reads it. Reviving a connection-wide setter would let numeric or scalar modes break framework-owned queries such as PostgreSQL `insertGetId()`, schema introspection, and migrations. | Shared upstream dead API | +| Query terminals | A custom row mode reaches `exists()`, aggregates, pagination counts, `pluck()`, and scalar-value helpers even though those methods own their result shape. `FETCH_COLUMN` made `exists()` false and `count()` zero for a non-empty SQLite table. | Shared defect, except Hypervel alone forwards the mode from `exists()` | +| Nullable rows | `firstOrFail()` and `findOr()` use `null` as the no-row signal, so a matching scalar `null` row is treated as absent. | Shared upstream defect | +| `find()` | Hypervel declares `object\|array\|null`, but a supported scalar fetch mode returns a scalar and causes a `TypeError`. | Hypervel-only native type defect | +| Cursor callbacks | Query Builder drops a legitimate `null` row after callbacks because `reject(is_null(...))` also represents an empty callback result. | Shared upstream defect | +| Group limits | Array rows are modified by value inside `each()`, so `hypervel_row` remains in `FETCH_ASSOC` results. | Shared upstream defect | +| ID iteration | `orderedLazyById()` reads the alias as an object property and fails for supported associative rows. `eachById()` also uses PDO-controlled result keys to calculate its positional callback index. | Shared upstream defects | +| Temporary columns | `onceWithColumns()` does not restore the original selection when its callback throws. | Shared upstream defect | +| Static analysis | `transactionLevel()` reads mutable state but is considered pure. The generated facade tag cannot carry PHPStan impurity metadata. | Hypervel typing defect | +| Testbench orphan reaper | Swoole changes a serve master's command line to `{app.name}.Master`, while stale-runtime cleanup requires a `testbench serve` or `hypervel serve` command. A terminated test worker can therefore leave the live server tree behind indefinitely. | Hypervel cleanup defect | + +Direct SQLite checks also confirmed that setting the statement fetch mode once and iterating the statement returns the requested second column and preserves falsey rows. `FETCH_KEY_PAIR`, `FETCH_GROUP`, and `FETCH_UNIQUE` are whole-result aggregation modes under `fetchAll()`; statement iteration can only expose their per-row forms. Conversely, `FETCH_LAZY` and `FETCH_INTO` are legal streaming row modes that `fetchAll()` rejects. + +`Connection::selectOne()` and `Connection::scalar()` deliberately retain their fixed signatures in both Hypervel and current Laravel. They own single-result shapes and are not missing `fetchUsing` parity; `selectResultSets()` is the only omitted connection argument. + +## Public contract and performance boundaries + +### Fetch-mode ownership + +`fetchUsing()` customizes the shape of returned rows. It does not redefine methods that already promise booleans, counts, scalar values, or plucked collections. + +| Behavior | Custom fetch mode | +|---|---| +| `get`, `first`, `find`, `firstOrFail`, `findOr`, `sole` | Honor it | +| `chunk`, `each`, and `lazy` | Honor any per-row shape | +| ID-based chunk/each/lazy variants | Honor it when the row remains an array/object containing the required ID alias | +| `cursor` | Honor it per streamed row | +| `simplePaginate` and paginated result rows | Honor it | +| `cursorPaginate` | Honor array/object row modes; cursor pagination still requires named fields | +| `exists` / `doesntExist` families | Ignore it | +| aggregates and pagination-count queries | Ignore it | +| `pluck`, `implode`, `value`, `rawValue`, `soleValue` | Ignore it | + +Do not reject legal PDO modes globally or create a mode registry. Operations that need named columns already fail through their existing missing-column or array/object contracts when given an incompatible scalar mode. + +### Cost and state + +- Keep the default `get()` SQL, binding, statement, and result-processing path unchanged. Its only new local work is choosing the scoped fetch override when one is active. +- Configure a cursor's custom PDO mode once per statement, not on every fetched row. Normalize only the mode-only column and class defaults that `fetchAll()` supplies implicitly. Statement iteration stays streaming and adds no buffering. +- Shape-owning methods use one exception-safe builder-local override. Existing aggregate and pagination clones inherit it, so a `beforeQuery()` callback cannot re-enable a custom row shape for the query it is about to run. +- Rework Query Builder cursor callbacks as one lazy generator instead of a `map()` plus `reject()` pipeline. +- Keep the connection's existing fixed object default immutable. Query-scoped `fetchUsing()` changes only the statement for that query, so framework-owned queries continue to receive the named object fields they require. +- Add no SQL, reconnect, pool checkout, serialization, or network work. + +## Final implementation + +### 1. Connection fetch execution + +Update `Connection::cursor()` so pretend mode returns an explicit `null` sentinel, while real execution returns the prepared `PDOStatement`. Before applying custom arguments with `setFetchMode()` once, preserve the mode-only defaults that `fetchAll()` supplies but `setFetchMode()` requires explicitly: + +```php +$statement = $this->run($query, $bindings, function (...) { + if ($this->pretending()) { + return null; + } + + // Prepare, bind, execute, and return PDOStatement. +}); + +if ($statement === null) { + return; +} + +if ($fetchUsing !== []) { + // fetchAll() supplies default column and class arguments that setFetchMode() + // demands explicitly, so a mode-only call keeps the same meaning when streamed. + if (count($fetchUsing) === 1) { + $mode = $fetchUsing[0] & ~(PDO::FETCH_GROUP | PDO::FETCH_UNIQUE | PDO::FETCH_CLASSTYPE | PDO::FETCH_PROPS_LATE); + + if ($mode === PDO::FETCH_COLUMN) { + $fetchUsing[] = 0; + } elseif ($mode === PDO::FETCH_CLASS && ($fetchUsing[0] & PDO::FETCH_CLASSTYPE) === 0) { + $fetchUsing[] = stdClass::class; + } + } + + $statement->setFetchMode(...$fetchUsing); +} + +foreach ($statement as $record) { + yield $record; +} +``` + +Derive the base mode by clearing the runtime modifier constants rather than a numeric mask because PHP 8.5 changes their values. Do not include deprecated `FETCH_SERIALIZE`: it has no successful one-argument default to preserve. Leave every other mode and argument list to PDO validation. The default mode remains owned by `prepared()`; do not widen `run()`, add a defensive type branch, or buffer rows. + +Update the cursor docblocks on `Connection` and `ConnectionInterface` to `Generator` and correct the touched cursor/result-set title grammar. The row value is intentionally `mixed` because PDO modes can return scalars, arrays, objects, or `null`. + +Add `array $fetchUsing = []` to `Connection::selectResultSets()`, capture it in the execution closure, and pass it to every `fetchAll()`. Keep this concrete-only, matching Laravel: `selectResultSets()` is not behavior required from every `ConnectionInterface` implementation. + +Lift `ConnectionInterface::withoutTablePrefix()` to the same callback template and return contract already declared by `Connection`, so interface-typed callers retain their callback result. + +Remove the severed connection-wide API rather than reviving unsafe mutable state: + +- Remove `Capsule\Manager::setFetchMode()`. It is a public no-op in current Laravel and Hypervel because nothing consumes the `database.fetch` value it writes. +- Do not add `Connection::setFetchMode()`, manager config propagation, a configurable default constant, pool-reset behavior, a facade method, or connection-default documentation/tests. +- Keep `Connection::$fetchMode = PDO::FETCH_OBJ` as the immutable statement default. Framework-owned queries depend on named object fields, and a connection-wide numeric or scalar mode would break supported operations outside Query Builder's terminal methods. +- Add the required `REMOVED:` source and matching test comments at the natural Capsule method positions. Record the deliberate public API omission in the database package README and direct callers to query-scoped `Query\Builder::fetchUsing()`. + +Do not protect a connection-wide mode through an inventory of internal selects. PostgreSQL ID retrieval, schema processors, migration storage, and future framework-owned queries would make that list incomplete and prone to drift. + +### 2. Query Builder result-shape policy + +Keep `runSelect()` as the one row-returning connection call. Add a nullable protected fetch override and select `($this->fetchUsingOverride ?? $this->fetchUsing)` only when making that connection call. A protected, callback-generic `withoutFetchUsing()` helper sets the override to `[]`, invokes its callback, and restores the previous override in `finally`; a throwing query therefore cannot strand the override, and the callback return type is preserved. + +```php +$previousOverride = $this->fetchUsingOverride; +$this->fetchUsingOverride = []; + +try { + return $callback(); +} finally { + $this->fetchUsingOverride = $previousOverride; +} +``` + +Use that scoped helper at shape-owning terminals: + +- `exists()` calls `select()` without the fourth argument. +- `aggregate()` executes its existing clone pipeline inside `withoutFetchUsing()`; both grouped and non-grouped aggregate clones inherit the override. +- `getCountForPagination()` executes its existing count pipeline inside `withoutFetchUsing()`. The non-grouped clone needs the override; the grouped branch already runs its aggregate on a fresh builder, and remains covered as a regression guard. +- `pluck()`, `value()`, `rawValue()`, and `soleValue()` execute their existing logic inside `withoutFetchUsing()`. Preserve `rawValue()`'s existing `selectRaw()` mutation and every method's current processor and after-query-callback behavior. +- `implode()` inherits the corrected `pluck()` behavior. + +Do not clear the public `fetchUsing` property or introduce new clones: either approach lets `beforeQuery()` callbacks change semantics or changes which builder consumes them. The scoped override preserves each method's current callback ownership—original-builder methods still keep callback mutations and one-shot consumption on the caller, while existing aggregate/count clones remain clones—and ensures a callback that itself calls `fetchUsing()` cannot change a shape-owning result. An original-builder callback's chosen mode remains available to a later row-returning terminal after the override is restored. + +Do not add a policy object, terminal allowlist, execution context, or public reset helper. This is transient state on one builder instance, not coroutine or worker state, and it adds no query, I/O, or allocation proportional to result size. + +Make `onceWithColumns()` restore the original columns in `finally`; query failures must not leave a reusable builder partly mutated. + +### 3. Presence, streaming, and row-shape correctness + +Change `firstOrFail()` to retrieve the one-row collection, test collection emptiness, then return its first value. Change `findOr()` similarly after applying the ID predicate. A non-empty collection containing `null` is a found row; only an empty collection runs the fallback or throws. + +Keep `first()` and `find()` returning their existing value-or-`null` shape. Those APIs cannot distinguish a scalar `null` row from absence without an incompatible return contract; the collection-aware throwing/fallback methods can and must make that distinction internally. + +Widen Query Builder's native `find()` return type to `mixed`, with its PHPDoc carrying `TValue|null`. This is a compatible widening required by the existing public fetch-mode API. + +Replace Query Builder cursor's `map()->reject()` chain with a single lazy generator: + +```php +foreach ($this->connection->cursor(...) as $key => $item) { + $items = $this->applyAfterQueryCallbacks(new Collection([$item])); + + if ($items->isNotEmpty()) { + yield $key => $items->first(); + } +} +``` + +This preserves a real `null` row or a callback result of `collect([null])`, while an explicitly empty collection still removes the row. Keep the existing rule that a falsey non-object callback return falls back to its input collection. + +Change `withoutGroupLimitKeys()` to `transform()` each item and return the cleaned value. Unset array keys on arrays, object properties on objects, and leave scalar rows unchanged. Do not add row-wrapper objects or normalize all modes to arrays. + +Use `data_get($results->last(), $alias)` in `orderedLazyById()`, matching `orderedChunkById()`, so associative and object rows share the same alias lookup and diagnostic. + +In both `each()` and `eachById()`, calculate the documented integer callback position with a local counter, not the collection key. Preserve `each()`'s current per-chunk zero-based positions and `eachById()`'s global page offset. PDO modes such as `FETCH_UNIQUE` can produce non-sequential or string collection keys even when the row remains usable; leaking that key violates the native `int` callback contract and makes the ID variant attempt integer arithmetic on a string. + +### 4. Precise Query Builder generics + +Make raw Query Builder's result type reflect the selected PDO mode without weakening default inference: + +```php +/** + * @template TKey of array-key = int + * @template TValue = \stdClass + */ +class Builder +{ + /** @use BuildsQueries */ + use BuildsQueries; + + /** + * The @return $this tag lets @phpstan-this-out reach a chained call. + * + * @return $this + * + * @phpstan-this-out self + */ + public function fetchUsing(mixed ...$fetchUsing): static; +} +``` + +Keep the native `static` return and Laravel's same-instance `@return $this` tag. PHPStan needs the explicit tag to apply the out-type to a directly chained call; without it, only a standalone call widens. The fixed `self` out-type intentionally rebinds custom Query Builder subclasses to the base builder in PHPStan after this call. Do not add a return-type extension or weaken default query inference to preserve subclass-specific analysis on this one shape. + +- `get()` returns `Collection` and `runSelect()` returns the matching array. +- Make `Processor::processSelect()` generic over the same key/value pair so the existing pass-through processor does not erase the connection result type before `Collection` construction. +- `find()`, `first()`, `firstOrFail()`, and `sole()` use `TValue` in PHPDoc. Rename `findOr()`'s method template to `TFindOrValue` and return `TValue|TFindOrValue` so it does not shadow the builder value template. +- Make the existing result-cleanup and after-query-callback annotations preserve the builder key/value pair; do not attempt to infer arbitrary callback replacement types or change callback runtime behavior. +- Raw cursor/lazy methods remain integer-keyed because they yield rows sequentially. +- Expand `BuildsQueries` to `TKey` and `TValue`. Use `TKey` for collections and callback keys that actually flow from `get()`; keep synthesized page/position arguments as `int` and lazy outputs as integer-keyed. +- Parameterize the trait's mixin as `Query\Builder` so its declaration agrees with its templates and consumers without a class-level mixin inherit the right result pair. +- Eloquent applies the trait as `BuildsQueries`; its model result contract does not become `mixed`. +- Add `@method $this fetchUsing(mixed ...$fetchUsing)` to Eloquent Builder. Its magic forwarding always returns the Eloquent builder, but Query Builder's out-type otherwise gives a directly chained `fetchUsing()->get()` expression the raw-query result type. The explicit tag also corrects Relation's two-level Eloquent mixin; do not duplicate it on Relation or attempt to repair the existing class-identity loss after another forwarded method in the same chain. +- Calling `fetchUsing()` with no arguments resets runtime behavior but leaves static type conservatively widened. Do not add conditional PHPDoc machinery for a reset call. +- Template defaults keep unparameterized `Query\Builder` references source-compatible and avoid broad annotation churn. + +Remove stale local PHPStan ignores only when the new templates make them unnecessary. Do not add runtime casts, assertions, or global PHPStan ignores to satisfy analysis. + +### 5. Transaction-state impurity and facade generation + +Mark only `ConnectionInterface::transactionLevel()` with `@phpstan-impure`. The interface owns the semantic contract, and implementors inherit it; duplicating the tag on the trait or concrete connection would create drift. + +Add `@mixin \Hypervel\Database\ConnectionInterface` to `DB`. Since generated `@method` tags cannot carry impurity metadata, add this narrow hook beside the accessor: + +```php +protected static function ignoredFacadeDocumenterMethods(): array +{ + return ['transactionLevel']; +} +``` + +Explain in the method docblock that the mixin supplies impurity and that exclusion is name-based. Do not exclude the whole interface: the generated manager surface has richer signatures, including `DatabaseManager::disconnect($name)`, that would collide with the no-argument connection method. + +Regenerate only the `DB` facade with the repository's facade documenter. The generated `transactionLevel()` tag must disappear, `selectResultSets()` must gain the fourth argument, no connection-wide fetch setter may appear, and every other generated manager/connection method must remain. + +### 6. Testbench orphaned serve-runtime cleanup + +Remove the command-line predicate from `Bootstrapper::matchesServeProcessIdentity()` and delete its now-unused `processCommand()` helper. A real Swoole master changes its command line to `{app.name}.Master`, so command matching makes the intended reaper reject the process it owns. + +Keep every load-bearing ownership check: + +- the process is alive and orphaned under PID 1; +- the runtime's `storage/framework/hypervel.pid` equals the candidate PID, distinguishing a serve master from an orphaned PHPUnit worker; +- the runtime marker PID equals the candidate PID; +- the marker's start identity matches the live process incarnation, excluding PID reuse. + +Update the stale sweep comment, orphan predicate docblock, and macOS start-identity rationale. Do not replace the broken command check with a configurable process-title regex, add marker fields, or build a repository process supervisor. + +Add a focused positive regression using a child process whose real title is changed to `Testbench.Master`, and a negative regression with the same live identity but no server PID file. Do not launch Swoole or require a double-forked PPID-1 process; the regression owns the failed identity predicate and the pid-file safety boundary directly. + +### 7. User documentation + +Add a concise “Custom Fetch Modes” subsection after “Aggregates” and before “Select Statements” in `src/docs/queries.md`, and add it to that page's contents. Use Laravel-style prose and one `PDO::FETCH_ASSOC` example. + +Document only public behavior: + +- `fetchUsing()` forwards PDO fetch-mode arguments for row-returning Query Builder operations; +- calling it with no arguments restores the connection's fixed object fetch mode; +- booleans, aggregates, counts, plucks, and scalar helpers keep their documented shapes; +- chunking and lazy streaming honor the selected per-row shape; ID-based variants and cursor pagination still require array/object rows containing their ordering columns; +- streamed cursors apply modes per row: whole-result grouping/keying modes cannot retain `get()`'s aggregate shape, `FETCH_FUNC` is unavailable to cursors, and streaming modes such as `FETCH_LAZY` and `FETCH_INTO` are unavailable to `get()`; +- custom fetch modes belong to the base Query Builder; Eloquent hydration requires array or object rows and cannot consume scalar fetch modes; +- fetch modes are query-scoped; connections and Capsule do not expose a mutable connection-wide default. + +Do not document internal overrides, clones, statement sentinels, facade generation, PHPStan metadata, or implementation history. Add one concise database README difference for the deliberately omitted Laravel Capsule setter; other corrections are parity or bug fixes and do not belong there. + +## Testing plan + +### Connection and facade tests + +Update `tests/Database/DatabaseConnectionTest.php` and run it immediately: + +- pretend cursor logs the query, resolves no PDO, yields no values, and does not throw; +- a live SQLite cursor preserves `null`, empty string, and `"0"`, continues to later rows, defaults a mode-only `FETCH_COLUMN` to column `0`, and returns column index `1` when requested; +- mode-only `FETCH_CLASS` defaults to `stdClass`, modifier-plus-column modes still receive the default column, and `FETCH_CLASS | FETCH_CLASSTYPE` continues to take its class from column `0` without an appended class; +- `selectResultSets()` forwards the exact custom fetch arguments to every row set; +- existing statement preparation, session synchronization, bindings, query log, and read/write routing remain unchanged. + +Update the Capsule manager tests at the upstream method position with a concise `REMOVED:` comment that pins why the ineffective Laravel API is deliberately omitted. Remove every test added for manager propagation, connection-wide defaults, or pool reset because that behavior must not exist. + +Extend `tests/FacadeDocumenter/IgnoredMethodsTest.php` to prove the name-based exclusion drops only `transactionLevel()` while retaining `DatabaseManager::disconnect($name)`. Regenerate `src/support/src/Facades/DB.php`, then run that test and `tests/FacadeDocumenter/FacadeDocblocksTest.php` to pin the generated surface. + +### Query Builder integration tests + +Extend `tests/Integration/Database/QueryBuilderTest.php` and run it after editing on SQLite, then through MySQL, MariaDB, and PostgreSQL: + +- `get()` and `cursor()` return the same requested second column and preserve ordered `null`, `""`, `"0"`, and later non-empty values; +- `find()` returns a scalar without a native `TypeError`; +- `firstOrFail()` returns a found scalar `null`, and `findOr()` returns it without invoking its fallback; +- `exists`, `count`, pagination totals, `pluck`, `implode`, `value`, `rawValue`, and `soleValue` keep their documented shapes despite a deliberately incompatible custom row mode; +- a following `get()` on the same builder still uses its custom mode, proving shape-owning terminals did not clear caller state; +- on an original-builder terminal such as `pluck()`, a one-shot `beforeQuery()` callback still mutates the caller, is consumed once, and cannot force an incompatible fetch mode onto that result; its chosen mode remains available to the following row-returning query; +- `FETCH_ASSOC` group-limited results contain no internal ranking key; +- `FETCH_ASSOC` works through `lazyById()` when the ID alias is selected; +- `cursorPaginate()` returns associative rows and derives its next cursor from their ordering field under `FETCH_ASSOC`; +- `each()` keeps per-chunk integer positions and `eachById()` supplies global zero-based positions even when `FETCH_UNIQUE` gives the collection string keys; +- a failed select restores the builder's original columns through `onceWithColumns()`. + +Extend `tests/Integration/Database/AfterQueryTest.php` and run it on the same matrix: + +- an empty callback collection removes a cursor row; +- a real scalar `null` row and `collect([null])` callback result are retained; +- existing Eloquent and base-builder callback replacement behavior remains unchanged. + +Keep cross-engine assertions on portable text/null values. Add a driver-specific boolean assertion only where the driver exposes a stable native boolean; do not normalize legitimate PDO driver differences in production code. + +### Testbench cleanup tests + +Update `tests/Testbench/BootstrapperTest.php` and run it immediately: + +- a live child whose process title is `Testbench.Master`, PID file matches, runtime marker matches, and real start identity matches is recognized as the owned serve process; +- the same live identity without `storage/framework/hypervel.pid` is rejected, proving an orphaned PHPUnit worker cannot be mistaken for a serve master; +- PID-file mismatch, marker mismatch, start-identity mismatch, malformed marker, dead PID, and active-runtime protections remain covered. + +### Static type fixtures + +Update `types/Database/Query/Builder.php`: + +- default Query Builder results remain `stdClass` with integer keys; +- chained and statement-form `fetchUsing()` calls widen `get()` and relevant callbacks to `mixed` with `(int|string)` keys, matching PHPStan's rendered `array-key` form, including when a base-builder method appears later in the chain; +- cursor/lazy outputs remain integer-keyed; +- a no-argument reset remains conservatively widened; +- directly chained Eloquent and Relation `fetchUsing()->get()` results remain their model collections; statement-form Eloquent calls also retain `TModel`; + +Add `types/Database/Connection.php`: + +- a second interface and `DB::transactionLevel()` read immediately after a narrowing branch is `int`, with no intervening call that could independently invalidate memoization; +- `withoutTablePrefix()` preserves a literal callback result; +- raw cursor values are `mixed`. + +Run `vendor/bin/phpstan analyse -c phpstan.types.neon.dist` after each type-fixture change. Run the normal source PHPStan at the checkpoint to detect any generic surface that needs a precise local annotation; do not widen the global design to silence incidental errors. + +## Implementation order and verification + +1. Correct `Connection`, remove the dead Capsule-wide API and superseded connection-default work, update interface docblocks/templates, and run each affected database test file immediately after editing it. +2. Correct Query Builder terminal ownership, presence checks, cursor callbacks, group-limit cleanup, and ID iteration. Update and run `QueryBuilderTest`, then `AfterQueryTest`, one file at a time. +3. Add the Query Builder, `BuildsQueries`, and `Processor::processSelect()` generics, update Eloquent's trait application, and run the type fixtures. +4. Add transaction impurity metadata, the narrow facade mixin/exclusion, regenerate `DB`, and run facade-documenter coverage plus the new connection type fixture. +5. Fix Testbench's serve-runtime identity predicate and run `BootstrapperTest` immediately. +6. Add the user-facing query documentation and database README difference. +7. Run the full database integration group on `sqlite`, `mysql`, `mariadb`, and `pgsql` with `bin/run-database-tests.sh`. +8. Run `composer fix` once at the completed checkpoint on a host with enough headroom. Keep Composer's existing five-minute timeout and ParaTest's normal six workers; an over-budget run is unhealthy and must fail. If it fails, terminate verified orphans, correct with targeted checks, then run the failed command and every remaining `fix` entry as required by `AGENTS.md`. +9. Freshly trace every changed caller and callee for Laravel API/named-argument compatibility, PDO-mode behavior, query/binding count, callback semantics, Eloquent generic preservation, coroutine/worker state, Testbench cleanup safety, hot-path allocation, stale/dead artifacts, and overengineering. Complete adversarial peer code review before commit. + +## References + +- Current local Laravel 13.x: `Connection`, `ConnectionInterface`, `Query\Builder`, `BuildsQueries`, and database integration tests under `examples/laravel/framework`. +- Laravel PDO fetch-mode changes: [#54734](https://github.com/laravel/framework/pull/54734) and [#55394](https://github.com/laravel/framework/pull/55394). +- PHP manual: [`PDOStatement::setFetchMode`](https://www.php.net/manual/en/pdostatement.setfetchmode.php), [`PDOStatement::fetchAll`](https://www.php.net/manual/en/pdostatement.fetchall.php), and [`PDOStatement` iteration](https://www.php.net/manual/en/class.pdostatement.php). + +The work is complete only when custom row modes preserve every streamed row, row-returning terminals expose the requested shape, shape-owning terminals remain stable, nullable rows are distinguished from absence where the API permits, internal group-limit fields never leak, iteration positions never derive from PDO result keys, no mutable connection-wide fetch API or remnants remain, mutable transaction reads remain impure through the facade, Testbench safely reaps identity-matched orphaned serve trees without targeting PHPUnit workers, documentation states the supported Query Builder/Eloquent boundaries, and all four database engines plus full repository checks pass. diff --git a/src/database/README.md b/src/database/README.md index 7e41949e7..5b2067031 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -10,6 +10,7 @@ Documentation: https://hypervel.org/docs/database - 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. +- Laravel's Capsule manager exposes a `setFetchMode()` method that writes configuration its connections do not read. Hypervel omits this ineffective connection-wide setter; use `Query\Builder::fetchUsing()` for each query that needs a custom row shape. - `make:migration` omits Laravel's deprecated `--fullpath` option and obsolete Composer constructor dependency because migration creation no longer dumps autoload files. - `Blueprint::dropForeign()` widens Laravel's method signature with an optional constraint name when columns are supplied, allowing explicitly named foreign keys to be dropped portably across SQLite and the server databases. Custom `Blueprint` subclasses that override this method must accept the optional second argument. - Eloquent models that override `CREATED_AT` or `UPDATED_AT` must declare the compatible `?string` constant type, such as `public const ?string UPDATED_AT = null;`. Laravel's constants are untyped, but omitting the type from an override in Hypervel causes a fatal error. diff --git a/src/database/src/Capsule/Manager.php b/src/database/src/Capsule/Manager.php index bbc657dab..916a42bbf 100644 --- a/src/database/src/Capsule/Manager.php +++ b/src/database/src/Capsule/Manager.php @@ -15,7 +15,6 @@ use Hypervel\Database\Query\Builder; use Hypervel\Database\SimpleConnectionResolver; use Hypervel\Support\Traits\CapsuleManagerTrait; -use PDO; /** * Standalone database manager for non-production use outside full application bootstrap. @@ -60,8 +59,6 @@ public function __construct(?ContainerContract $container = null) */ protected function setupDefaultConfiguration(): void { - $this->container['config']['database.fetch'] = PDO::FETCH_OBJ; - $this->container['config']['database.default'] = 'default'; } @@ -144,15 +141,8 @@ public function bootEloquent(): void } } - /** - * Set the fetch mode for the database connections. - */ - public function setFetchMode(int $fetchMode): static - { - $this->container['config']['database.fetch'] = $fetchMode; - - return $this; - } + // REMOVED: Capsule's setter writes unused configuration and cannot safely + // define a connection-wide row shape. Use Query\Builder::fetchUsing() per query. /** * Get the database manager instance. diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php index e2639ad1a..124b6153a 100644 --- a/src/database/src/Concerns/BuildsQueries.php +++ b/src/database/src/Concerns/BuildsQueries.php @@ -23,9 +23,10 @@ use SortDirection; /** + * @template TKey of array-key * @template TValue * - * @mixin \Hypervel\Database\Query\Builder + * @mixin \Hypervel\Database\Query\Builder */ trait BuildsQueries { @@ -34,7 +35,7 @@ trait BuildsQueries /** * Chunk the results of the query. * - * @param callable(\Hypervel\Support\Collection, int): mixed $callback + * @param callable(\Hypervel\Support\Collection, int): mixed $callback */ public function chunk(int $count, callable $callback): bool { @@ -112,8 +113,10 @@ public function chunkMap(callable $callback, int $count = 1000): Collection public function each(callable $callback, int $count = 1000): bool { return $this->chunk($count, function ($results) use ($callback) { - foreach ($results as $key => $value) { - if ($callback($value, $key) === false) { + $position = 0; + + foreach ($results as $value) { + if ($callback($value, $position++) === false) { return false; } } @@ -123,7 +126,7 @@ public function each(callable $callback, int $count = 1000): bool /** * Chunk the results of a query by comparing IDs. * - * @param callable(\Hypervel\Support\Collection, int): mixed $callback + * @param callable(\Hypervel\Support\Collection, int): mixed $callback */ public function chunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool { @@ -133,7 +136,7 @@ public function chunkById(int $count, callable $callback, ?string $column = null /** * Chunk the results of a query by comparing IDs in descending order. * - * @param callable(\Hypervel\Support\Collection, int): mixed $callback + * @param callable(\Hypervel\Support\Collection, int): mixed $callback */ public function chunkByIdDesc(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool { @@ -143,7 +146,7 @@ public function chunkByIdDesc(int $count, callable $callback, ?string $column = /** * Chunk the results of a query by comparing IDs in a given order. * - * @param callable(\Hypervel\Support\Collection, int): mixed $callback + * @param callable(\Hypervel\Support\Collection, int): mixed $callback */ public function orderedChunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null, SortDirection|bool $descending = false): bool { @@ -220,8 +223,10 @@ public function orderedChunkById(int $count, callable $callback, ?string $column public function eachById(callable $callback, int $count = 1000, ?string $column = null, ?string $alias = null): bool { return $this->chunkById($count, function ($results, $page) use ($callback, $count) { - foreach ($results as $key => $value) { - if ($callback($value, (($page - 1) * $count) + $key) === false) { + $position = 0; + + foreach ($results as $value) { + if ($callback($value, (($page - 1) * $count) + $position++) === false) { return false; } } @@ -312,7 +317,7 @@ protected function orderedLazyById(int $chunkSize = 1000, ?string $column = null return; } - $lastId = $results->last()->{$alias}; + $lastId = data_get($results->last(), $alias); if ($lastId === null) { throw new RuntimeException("The lazyById operation was aborted because the [{$alias}] column is not present in the query result."); @@ -341,8 +346,11 @@ public function first(array|string $columns = ['*']) */ public function firstOrFail(array|string $columns = ['*'], ?string $message = null) { - if (! is_null($result = $this->first($columns))) { - return $result; + // Inspect collection presence so a matching scalar null row is not mistaken for no row. + $results = $this->limit(1)->get($columns); + + if ($results->isNotEmpty()) { + return $results->first(); } throw new RecordNotFoundException($message ?: 'No record found for the given query.'); diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 593f257c6..50014d19d 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -408,11 +408,11 @@ public function select(string $query, array $bindings = [], bool $useReadPdo = t } /** - * Run a select statement against the database and returns all of the result sets. + * Run a select statement against the database and return all of the result sets. */ - public function selectResultSets(string $query, array $bindings = [], bool $useReadPdo = true): array + public function selectResultSets(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): array { - return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) { + return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo, $fetchUsing) { if ($this->pretending()) { return []; } @@ -428,7 +428,7 @@ public function selectResultSets(string $query, array $bindings = [], bool $useR $sets = []; do { - $sets[] = $statement->fetchAll(); + $sets[] = $statement->fetchAll(...$fetchUsing); } while ($statement->nextRowset()); return $sets; @@ -436,15 +436,15 @@ public function selectResultSets(string $query, array $bindings = [], bool $useR } /** - * Run a select statement against the database and returns a generator. + * Run a select statement against the database and return a generator. * - * @return Generator + * @return Generator */ public function cursor(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): Generator { $statement = $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) { if ($this->pretending()) { - return []; + return null; } // First we will create a statement for the query. Then, we will set the fetch @@ -466,7 +466,27 @@ public function cursor(string $query, array $bindings = [], bool $useReadPdo = t return $statement; }); - while ($record = $statement->fetch(...$fetchUsing)) { + if ($statement === null) { + return; + } + + if ($fetchUsing !== []) { + // fetchAll() supplies default column and class arguments that setFetchMode() + // demands explicitly, so a mode-only call keeps the same meaning when streamed. + if (count($fetchUsing) === 1) { + $mode = $fetchUsing[0] & ~(PDO::FETCH_GROUP | PDO::FETCH_UNIQUE | PDO::FETCH_CLASSTYPE | PDO::FETCH_PROPS_LATE); + + if ($mode === PDO::FETCH_COLUMN) { + $fetchUsing[] = 0; + } elseif ($mode === PDO::FETCH_CLASS && ($fetchUsing[0] & PDO::FETCH_CLASSTYPE) === 0) { + $fetchUsing[] = stdClass::class; + } + } + + $statement->setFetchMode(...$fetchUsing); + } + + foreach ($statement as $record) { yield $record; } } diff --git a/src/database/src/ConnectionInterface.php b/src/database/src/ConnectionInterface.php index 6661d1fd7..c7377ea7b 100644 --- a/src/database/src/ConnectionInterface.php +++ b/src/database/src/ConnectionInterface.php @@ -45,7 +45,9 @@ public function scalar(string $query, array $bindings = [], bool $useReadPdo = t public function select(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): array; /** - * Run a select statement against the database and returns a generator. + * Run a select statement against the database and return a generator. + * + * @return Generator */ public function cursor(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): Generator; @@ -113,6 +115,8 @@ public function rollBack(?int $toLevel = null): void; /** * Get the number of active transactions. + * + * @phpstan-impure */ public function transactionLevel(): int; @@ -148,6 +152,11 @@ public function threadCount(): ?int; /** * Run a callback without the table prefix on the connection. + * + * @template TReturn + * + * @param Closure($this): TReturn $callback + * @return TReturn */ public function withoutTablePrefix(Closure $callback): mixed; diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 11172d74c..9acfa4935 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -41,12 +41,13 @@ * * @method $this whereCan(\UnitEnum|string $ability, mixed $user = null) * @method $this withCan(\UnitEnum|string|list<\UnitEnum|string> $abilities, mixed $user = null) + * @method $this fetchUsing(mixed ...$fetchUsing) * * @mixin \Hypervel\Database\Query\Builder */ class Builder implements BuilderContract { - /** @use \Hypervel\Database\Concerns\BuildsQueries */ + /** @use \Hypervel\Database\Concerns\BuildsQueries */ use BuildsQueries, ForwardsCalls, QueriesRelationships { BuildsQueries::sole as baseSole; } diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 894bc541b..46b5ee8a6 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -38,14 +38,17 @@ use LogicException; use RuntimeException; use SortDirection; -use stdClass; use UnitEnum; use function Hypervel\Support\enum_value; +/** + * @template TKey of array-key = int + * @template TValue = \stdClass + */ class Builder implements BuilderContract { - /** @use \Hypervel\Database\Concerns\BuildsQueries<\stdClass> */ + /** @use \Hypervel\Database\Concerns\BuildsQueries */ use BuildsWhereDateClauses, BuildsQueries, ExplainsQueries, ForwardsCalls, Macroable { __call as macroCall; } @@ -203,6 +206,8 @@ class Builder implements BuilderContract /** * The callbacks that should be invoked after retrieving data from the database. + * + * @var array): (Collection|void)> */ protected array $afterQueryCallbacks = []; @@ -239,6 +244,11 @@ class Builder implements BuilderContract */ public array $fetchUsing = []; + /** + * The scoped PDO fetch mode arguments for the current query, or null when no override is active. + */ + protected ?array $fetchUsingOverride = null; + /** * Create a new query builder instance. */ @@ -2806,6 +2816,8 @@ public function applyBeforeQueryCallbacks(): void /** * Register a closure to be invoked after the query is executed. + * + * @param Closure(Collection): (Collection|void) $callback */ public function afterQuery(Closure $callback): static { @@ -2816,6 +2828,9 @@ public function afterQuery(Closure $callback): static /** * Invoke the "after query" modification callbacks. + * + * @param Collection $result + * @return Collection */ public function applyAfterQueryCallbacks(Collection $result): Collection { @@ -2851,8 +2866,9 @@ public function toRawSql(): string * Execute a query for a single record by ID. * * @param array|ExpressionContract|string $columns + * @return null|TValue */ - public function find(int|string $id, ExpressionContract|array|string $columns = ['*']): object|array|null + public function find(int|string $id, ExpressionContract|array|string $columns = ['*']): mixed { return $this->where('id', '=', $id)->first($columns); } @@ -2860,11 +2876,11 @@ public function find(int|string $id, ExpressionContract|array|string $columns = /** * Execute a query for a single record by ID or call a callback. * - * @template TValue + * @template TFindOrValue * - * @param array|(Closure(): TValue)|ExpressionContract|string $columns - * @param null|(Closure(): TValue) $callback - * @return stdClass|TValue + * @param array|(Closure(): TFindOrValue)|ExpressionContract|string $columns + * @param null|(Closure(): TFindOrValue) $callback + * @return TFindOrValue|TValue */ public function findOr(mixed $id, Closure|ExpressionContract|array|string $columns = ['*'], ?Closure $callback = null): mixed { @@ -2874,8 +2890,11 @@ public function findOr(mixed $id, Closure|ExpressionContract|array|string $colum $columns = ['*']; } - if (! is_null($data = $this->find($id, $columns))) { - return $data; + // Inspect collection presence so a matching scalar null row is not mistaken for no row. + $results = $this->where('id', '=', $id)->limit(1)->get($columns); + + if ($results->isNotEmpty()) { + return $results->first(); } return $callback(); @@ -2886,9 +2905,11 @@ public function findOr(mixed $id, Closure|ExpressionContract|array|string $colum */ public function value(string $column): mixed { - $result = (array) $this->first([$column]); + return $this->withoutFetchUsing(function () use ($column) { + $result = (array) $this->first([$column]); - return count($result) > 0 ? array_first($result) : null; + return count($result) > 0 ? array_first($result) : null; + }); } /** @@ -2896,9 +2917,11 @@ public function value(string $column): mixed */ public function rawValue(string $expression, array $bindings = []): mixed { - $result = (array) $this->selectRaw($expression, $bindings)->first(); + return $this->withoutFetchUsing(function () use ($expression, $bindings) { + $result = (array) $this->selectRaw($expression, $bindings)->first(); - return count($result) > 0 ? array_first($result) : null; + return count($result) > 0 ? array_first($result) : null; + }); } /** @@ -2909,16 +2932,18 @@ public function rawValue(string $expression, array $bindings = []): mixed */ public function soleValue(string $column): mixed { - $result = (array) $this->sole([$column]); + return $this->withoutFetchUsing(function () use ($column) { + $result = (array) $this->sole([$column]); - return array_first($result); + return array_first($result); + }); } /** * Execute the query as a "select" statement. * * @param array|ExpressionContract|string $columns - * @return Collection + * @return Collection */ public function get(ExpressionContract|array|string $columns = ['*']): Collection { @@ -2933,6 +2958,8 @@ public function get(ExpressionContract|array|string $columns = ['*']): Collectio /** * Run the query as a "select" statement against the connection. + * + * @return array */ protected function runSelect(): array { @@ -2940,12 +2967,35 @@ protected function runSelect(): array $this->toSql(), $this->getBindings(), ! $this->useWritePdo, - $this->fetchUsing + $this->fetchUsingOverride ?? $this->fetchUsing ); } + /** + * Execute the given callback without custom fetch mode arguments. + * + * @template TReturn + * + * @param callable(): TReturn $callback + * @return TReturn + */ + protected function withoutFetchUsing(callable $callback): mixed + { + $previousOverride = $this->fetchUsingOverride; + $this->fetchUsingOverride = []; + + try { + return $callback(); + } finally { + $this->fetchUsingOverride = $previousOverride; + } + } + /** * Remove the group limit keys from the results in the collection. + * + * @param Collection $items + * @return Collection */ protected function withoutGroupLimitKeys(Collection $items): Collection { @@ -2958,13 +3008,17 @@ protected function withoutGroupLimitKeys(Collection $items): Collection $keysToRemove[] = '@hypervel_group := ' . $this->grammar->wrap('pivot_' . $column); } - $items->each(function ($item) use ($keysToRemove) { + return $items->transform(function ($item) use ($keysToRemove) { foreach ($keysToRemove as $key) { - unset($item->{$key}); + if (is_array($item)) { + unset($item[$key]); + } elseif (is_object($item)) { + unset($item->{$key}); + } } - }); - return $items; + return $item; + }); } /** @@ -3071,7 +3125,9 @@ protected function ensureOrderForCursorPagination(bool $shouldReverse = false): */ public function getCountForPagination(array $columns = ['*']): int { - $results = $this->runPaginationCountQuery($columns); + $results = $this->withoutFetchUsing( + fn () => $this->runPaginationCountQuery($columns) + ); // Once we have run the pagination count query, we will get the resulting count and // take into account what type of query it was. When there is a group by we will @@ -3148,7 +3204,7 @@ protected function withoutSelectAliases(array $columns): array /** * Get a lazy collection for the given query. * - * @return LazyCollection + * @return LazyCollection */ public function cursor(): LazyCollection { @@ -3156,16 +3212,21 @@ public function cursor(): LazyCollection $this->columns = ['*']; } - return (new LazyCollection(function () { - yield from $this->connection->cursor( + return new LazyCollection(function () { + // Deferred execution must read the public query mode, not a scoped terminal override. + foreach ($this->connection->cursor( $this->toSql(), $this->getBindings(), ! $this->useWritePdo, $this->fetchUsing - ); - }))->map(function ($item) { - return $this->applyAfterQueryCallbacks(new Collection([$item]))->first(); - })->reject(fn ($item) => is_null($item)); + ) as $key => $item) { + $items = $this->applyAfterQueryCallbacks(new Collection([$item])); + + if ($items->isNotEmpty()) { + yield $key => $items->first(); + } + } + }); } /** @@ -3187,35 +3248,37 @@ protected function enforceOrderBy(): void */ public function pluck(ExpressionContract|string $column, ?string $key = null): Collection { - // First, we will need to select the results of the query accounting for the - // given columns / key. Once we have the results, we will be able to take - // the results and get the exact data that was requested for the query. - $queryResult = $this->onceWithColumns( - is_null($key) || $key === $column ? [$column] : [$column, $key], - function () { - return $this->processor->processSelect( - $this, - $this->runSelect() - ); - } - ); + return $this->withoutFetchUsing(function () use ($column, $key) { + // First, we will need to select the results of the query accounting for the + // given columns / key. Once we have the results, we will be able to take + // the results and get the exact data that was requested for the query. + $queryResult = $this->onceWithColumns( + is_null($key) || $key === $column ? [$column] : [$column, $key], + function () { + return $this->processor->processSelect( + $this, + $this->runSelect() + ); + } + ); - if (empty($queryResult)) { - return new Collection; - } + if (empty($queryResult)) { + return new Collection; + } - // If the columns are qualified with a table or have an alias, we cannot use - // those directly in the "pluck" operations since the results from the DB - // are only keyed by the column itself. We'll strip the table out here. - $column = $this->stripTableForPluck($column); + // If the columns are qualified with a table or have an alias, we cannot use + // those directly in the "pluck" operations since the results from the DB + // are only keyed by the column itself. We'll strip the table out here. + $column = $this->stripTableForPluck($column); - $key = $this->stripTableForPluck($key); + $key = $this->stripTableForPluck($key); - return $this->applyAfterQueryCallbacks( - is_array($queryResult[0]) - ? $this->pluckFromArrayColumn($queryResult, $column, $key) - : $this->pluckFromObjectColumn($queryResult, $column, $key) - ); + return $this->applyAfterQueryCallbacks( + is_array($queryResult[0]) + ? $this->pluckFromArrayColumn($queryResult, $column, $key) + : $this->pluckFromObjectColumn($queryResult, $column, $key) + ); + }); } /** @@ -3294,8 +3357,7 @@ public function exists(): bool $results = $this->connection->select( $this->grammar->compileExists($this), $this->getBindings(), - ! $this->useWritePdo, - $this->fetchUsing + ! $this->useWritePdo ); // If the results have rows, we will get the row and see if the exists column is a @@ -3391,16 +3453,18 @@ public function average(ExpressionContract|string $column): mixed */ public function aggregate(string $function, array $columns = ['*']): mixed { - $results = $this->cloneWithout($this->unions || $this->havings ? [] : ['columns']) - ->cloneWithoutBindings($this->unions || $this->havings ? [] : ['select']) - ->setAggregate($function, $columns) - ->get($columns); + return $this->withoutFetchUsing(function () use ($function, $columns) { + $results = $this->cloneWithout($this->unions || $this->havings ? [] : ['columns']) + ->cloneWithoutBindings($this->unions || $this->havings ? [] : ['select']) + ->setAggregate($function, $columns) + ->get($columns); - if (! $results->isEmpty()) { - return array_change_key_case((array) $results[0])['aggregate']; - } + if (! $results->isEmpty()) { + return array_change_key_case((array) $results[0])['aggregate']; + } - return null; + return null; + }); } /** @@ -3466,11 +3530,11 @@ protected function onceWithColumns(array $columns, callable $callback): mixed $this->columns = $columns; } - $result = $callback(); - - $this->columns = $original; - - return $result; + try { + return $callback(); + } finally { + $this->columns = $original; + } } /** @@ -4107,6 +4171,12 @@ public function useWritePdo(): static /** * Set the PDO fetch mode arguments for the query. + * + * The @return $this tag lets @phpstan-this-out reach a chained call. + * + * @return $this + * + * @phpstan-this-out self */ public function fetchUsing(mixed ...$fetchUsing): static { diff --git a/src/database/src/Query/Processors/Processor.php b/src/database/src/Query/Processors/Processor.php index 12fa4d2c6..dda2feec8 100755 --- a/src/database/src/Query/Processors/Processor.php +++ b/src/database/src/Query/Processors/Processor.php @@ -10,6 +10,13 @@ class Processor { /** * Process the results of a "select" query. + * + * @template TKey of array-key + * @template TValue + * + * @param Builder $query + * @param array $results + * @return array */ public function processSelect(Builder $query, array $results): array { diff --git a/src/docs/queries.md b/src/docs/queries.md index 255fa94c6..8a8813cd9 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -5,6 +5,7 @@ - [Chunking Results](#chunking-results) - [Streaming Results Lazily](#streaming-results-lazily) - [Aggregates](#aggregates) + - [Custom Fetch Modes](#custom-fetch-modes) - [Select Statements](#select-statements) - [Raw Expressions](#raw-expressions) - [Joins](#joins) @@ -291,6 +292,30 @@ DB::table('orders')->where('finalized', 1)->doesntExistOr(function () { }); ``` + +### Custom Fetch Modes + +By default, the query builder returns each row as a `stdClass` object. You may use the `fetchUsing` method to pass a different [PDO fetch mode](https://www.php.net/manual/en/pdostatement.fetch.php) to row-returning operations: + +```php +use Hypervel\Support\Facades\DB; +use PDO; + +$users = DB::table('users') + ->fetchUsing(PDO::FETCH_ASSOC) + ->get(); + +echo $users->first()['email']; +``` + +The selected mode is also used by chunking and lazy streaming methods. Methods such as `lazyById` and cursor pagination still require array or object rows containing their ordering columns. Calling `fetchUsing` without arguments restores the connection's fixed object mode. + +Methods that return a fixed shape—including existence checks, aggregates, counts, plucks, and scalar value helpers—ignore custom fetch modes. Eloquent also expects array or object rows so it can hydrate models and cannot use scalar fetch modes. + +The `cursor` method applies the selected mode to each streamed row. Whole-result grouping and keying modes therefore cannot produce the same collection shape as `get`, and `PDO::FETCH_FUNC` is not available to cursors. Conversely, streaming modes such as `PDO::FETCH_LAZY` and `PDO::FETCH_INTO` are not available to `get`. + +Fetch modes are scoped to the query builder. Connections and Capsule do not expose a mutable connection-wide default. + ## Select Statements diff --git a/src/support/src/Facades/DB.php b/src/support/src/Facades/DB.php index 0f3b112ef..72fd28869 100644 --- a/src/support/src/Facades/DB.php +++ b/src/support/src/Facades/DB.php @@ -46,7 +46,7 @@ * @method static void clearBeforeExecutingCallbacks() * @method static void commit() * @method static void configureSessionUsing(\Hypervel\Database\SessionConfigurator $configurator) - * @method static \Generator cursor(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []) + * @method static \Generator cursor(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []) * @method static int delete(string $query, array $bindings = []) * @method static void disableQueryLog() * @method static void enableQueryLog() @@ -95,7 +95,7 @@ * @method static array select(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []) * @method static array selectFromWriteConnection(string $query, array $bindings = []) * @method static mixed selectOne(string $query, array $bindings = [], bool $useReadPdo = true) - * @method static array selectResultSets(string $query, array $bindings = [], bool $useReadPdo = true) + * @method static array selectResultSets(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []) * @method static \Hypervel\Database\Connection setDatabaseName(string $database) * @method static \Hypervel\Database\Connection setEventDispatcher(\Hypervel\Contracts\Events\Dispatcher $events) * @method static \Hypervel\Database\Connection setPdo(\PDO|\Closure|null $pdo) @@ -112,7 +112,6 @@ * @method static int|null threadCount() * @method static float totalQueryDuration() * @method static mixed transaction(\Closure $callback, int $attempts = 1) - * @method static int transactionLevel() * @method static bool unprepared(string $query) * @method static void unsetEventDispatcher() * @method static void unsetTransactionManager() @@ -125,6 +124,8 @@ * @method static mixed withoutTablePrefix(\Closure $callback) * * @see \Hypervel\Database\DatabaseManager + * + * @mixin \Hypervel\Database\ConnectionInterface */ class DB extends Facade { @@ -149,4 +150,17 @@ protected static function getFacadeAccessor(): string { return 'db'; } + + /** + * Get methods that should be excluded from the generated facade docblock. + * + * The connection mixin supplies transactionLevel()'s impurity metadata; + * name-based exclusion preserves richer manager methods with the same name. + * + * @return array + */ + protected static function ignoredFacadeDocumenterMethods(): array + { + return ['transactionLevel']; + } } diff --git a/src/testbench/src/Bootstrapper.php b/src/testbench/src/Bootstrapper.php index ea0c99920..d35827511 100644 --- a/src/testbench/src/Bootstrapper.php +++ b/src/testbench/src/Bootstrapper.php @@ -223,7 +223,7 @@ protected static function purgeStaleRuntimeCopies(string $tempDirectory, int $pi // A dir is stale when its owning PID is dead, reused by this process // without being the active copy, or an identity-matched orphaned - // serve process whose parent exited. + // serve master whose parent exited. foreach (glob($tempDirectory . '/hypervel-components-testbench-*') ?: [] as $staleDirectory) { if (! $filesystem->isDirectory($staleDirectory)) { continue; @@ -328,8 +328,7 @@ protected static function deleteRuntimeDirectory(string $directory): void * Determine if the given PID is an orphaned serve process. * * A process is considered an orphaned serve process only when its parent - * is init and its PID, command, and process incarnation all match the - * runtime directory. + * is init and its PID file and process incarnation match the runtime. */ protected static function isOrphanedServeProcess(int $pid, string $runtimeDir): bool { @@ -368,6 +367,7 @@ protected static function isOrphanedServeProcess(int $pid, string $runtimeDir): */ protected static function matchesServeProcessIdentity(int $pid, string $runtimeDir): bool { + // The pid file proves a Swoole server booted from this runtime rather than a test worker. $pidFile = join_paths($runtimeDir, 'storage/framework/hypervel.pid'); $pidContents = @file_get_contents($pidFile); @@ -378,17 +378,6 @@ protected static function matchesServeProcessIdentity(int $pid, string $runtimeD return false; } - $command = static::processCommand($pid); - - if ($command === null - || preg_match( - '/(?:^|\s)\S*(?:testbench|hypervel)(?:\.php)?\s+serve(?:\s|$)/i', - $command, - ) !== 1 - ) { - return false; - } - $marker = @file_get_contents(join_paths($runtimeDir, static::RUNTIME_PROCESS_MARKER)); if ($marker === false) { @@ -416,36 +405,11 @@ protected static function matchesServeProcessIdentity(int $pid, string $runtimeD && hash_equals($startedAt, $currentStartIdentity); } - /** - * Read the command line for a process. - */ - protected static function processCommand(int $pid): ?string - { - if (is_dir('/proc')) { - $path = "/proc/{$pid}/cmdline"; - - if (! is_readable($path) - || ($contents = @file_get_contents($path)) === false - || $contents === '' - ) { - return null; - } - - return trim(str_replace("\0", ' ', $contents)); - } - - $output = []; - exec("ps -ww -p {$pid} -o command= 2>/dev/null", $output); - $command = trim(implode("\n", $output)); - - return $command !== '' ? $command : null; - } - /** * Read the OS identity of the process incarnation. * * Linux exposes the start clock tick exactly. macOS `lstart` has one-second - * resolution, which is sufficient alongside the PID and validated command. + * resolution, the most precise portable process-start identity available there. */ protected static function processStartIdentity(int $pid): ?string { diff --git a/tests/ApiClient/PendingRequestTest.php b/tests/ApiClient/PendingRequestTest.php index 50c183ba5..4cf358feb 100644 --- a/tests/ApiClient/PendingRequestTest.php +++ b/tests/ApiClient/PendingRequestTest.php @@ -398,41 +398,61 @@ public function testTransientRequestStateIsClearedAfterEveryFailureBoundary(): v ]); $pending = new ApiClientInspectablePendingRequest; - $pending->withApiRequestMiddleware(fn (): never => throw new RuntimeException('request')); + $requestException = new RuntimeException('request'); + $pending->withApiRequestMiddleware(fn (): never => throw $requestException); + $caughtException = null; + try { $pending->get('https://example.test/request'); - $this->fail('Expected request middleware to fail.'); - } catch (RuntimeException) { - $this->assertNull($pending->activeRequest()); + } catch (RuntimeException $exception) { + $caughtException = $exception; } + $this->assertSame($requestException, $caughtException); + $this->assertNull($pending->activeRequest()); + + $responseException = new RuntimeException('response'); $pending ->replaceApiRequestMiddleware([]) - ->withApiResponseMiddleware(fn (): never => throw new RuntimeException('response')); + ->withApiResponseMiddleware(fn (): never => throw $responseException); + $caughtException = null; + try { $pending->get('https://example.test/response'); - $this->fail('Expected response middleware to fail.'); - } catch (RuntimeException) { - $this->assertNull($pending->activeRequest()); + } catch (RuntimeException $exception) { + $caughtException = $exception; } + $this->assertSame($responseException, $caughtException); + $this->assertNull($pending->activeRequest()); + $pending ->replaceApiResponseMiddleware([]) ->withResource(ApiClientThrowingResource::class); + $caughtException = null; + try { $pending->get('https://example.test/resource'); - $this->fail('Expected resource construction to fail.'); - } catch (RuntimeException) { - $this->assertNull($pending->activeRequest()); + } catch (RuntimeException $exception) { + $caughtException = $exception; } + $this->assertNotNull($caughtException); + $this->assertSame(RuntimeException::class, $caughtException::class); + $this->assertSame('resource', $caughtException->getMessage()); + $this->assertNull($pending->activeRequest()); + $pending->withResource(ApiResource::class); + $caughtException = null; + try { $pending->get('https://example.test/transport'); - $this->fail('Expected the transport to fail.'); - } catch (ConnectionException) { - $this->assertNull($pending->activeRequest()); + } catch (ConnectionException $exception) { + $caughtException = $exception; } + + $this->assertInstanceOf(ConnectionException::class, $caughtException); + $this->assertNull($pending->activeRequest()); } #[DataProvider('terminalProvider')] diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index 09ccb9943..d29d67a3b 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -33,6 +33,7 @@ use PDOStatement; use ReflectionClass; use RuntimeException; +use stdClass; class DatabaseConnectionTest extends TestCase { @@ -138,7 +139,7 @@ public function testSelectResultsetsReturnsMultipleRowset(): void $statement->expects($this->once())->method('setFetchMode'); $statement->expects($this->once())->method('bindValue')->with(1, 'foo', 2); $statement->expects($this->once())->method('execute'); - $statement->expects($this->atLeastOnce())->method('fetchAll')->willReturn(['boom']); + $statement->expects($this->atLeastOnce())->method('fetchAll')->with(PDO::FETCH_COLUMN, 1)->willReturn(['boom']); $statement->expects($this->atLeastOnce())->method('nextRowset')->willReturnCallback(function () { static $i = 1; @@ -148,7 +149,7 @@ public function testSelectResultsetsReturnsMultipleRowset(): void $mock = $this->getMockConnection(['prepareBindings'], $writePdo); $mock->setReadPdo($pdo); $mock->expects($this->once())->method('prepareBindings')->with($this->equalTo(['foo']))->willReturn(['foo']); - $results = $mock->selectResultsets('CALL a_procedure(?)', ['foo']); + $results = $mock->selectResultsets('CALL a_procedure(?)', ['foo'], true, [PDO::FETCH_COLUMN, 1]); $this->assertEquals([['boom'], ['boom']], $results); $log = $mock->getQueryLog(); $this->assertSame('CALL a_procedure(?)', $log[0]['query']); @@ -202,18 +203,72 @@ static function () use (&$resolutions): PDO { ['name' => 'test', 'driver' => 'sqlite'] ); - $connection->pretend(static function (Connection $connection): void { + $cursorRows = null; + $queries = $connection->pretend(static function (Connection $connection) use (&$cursorRows): void { $connection->select('select 1'); + $cursorRows = iterator_to_array($connection->cursor('select cursor_value')); $connection->statement('create table records (id integer)'); $connection->affectingStatement('delete from records'); $connection->unprepared('delete from records'); }); + $this->assertSame([], $cursorRows); + $this->assertSame('select cursor_value', $queries[1]['query']); $this->assertSame(0, $resolutions); $this->assertSame(0, $configurator->stateCalls); $this->assertSame(0, $configurator->applyCalls); } + public function testCursorPreservesFalseyValuesWithCustomFetchMode(): void + { + $connection = $this->getSqliteTransactionConnection(); + $connection->statement('create table records (id integer primary key, value text null)'); + $connection->insert("insert into records (id, value) values (1, null), (2, ''), (3, '0'), (4, 'later')"); + + $this->assertSame( + [null, '', '0', 'later'], + iterator_to_array($connection->cursor( + 'select id, value from records order by id', + fetchUsing: [PDO::FETCH_COLUMN, 1] + )) + ); + } + + public function testCursorPreservesModeOnlyFetchDefaults(): void + { + $connection = $this->getSqliteTransactionConnection(); + + $this->assertSame( + ['first', 'second'], + iterator_to_array($connection->cursor( + "select 'first' as value union all select 'second'", + fetchUsing: [PDO::FETCH_COLUMN] + )) + ); + + $classRows = iterator_to_array($connection->cursor( + "select 'class' as value", + fetchUsing: [PDO::FETCH_CLASS] + )); + $this->assertInstanceOf(stdClass::class, $classRows[0]); + $this->assertSame('class', $classRows[0]->value); + + $this->assertSame( + [1, 2], + iterator_to_array($connection->cursor( + 'select 1 as value union all select 2', + fetchUsing: [PDO::FETCH_GROUP | PDO::FETCH_COLUMN] + )) + ); + + $classTypeRows = iterator_to_array($connection->cursor( + "select 'stdClass' as class_name, 'typed' as value", + fetchUsing: [PDO::FETCH_CLASS | PDO::FETCH_CLASSTYPE] + )); + $this->assertInstanceOf(stdClass::class, $classTypeRows[0]); + $this->assertSame('typed', $classTypeRows[0]->value); + } + public function testMySqlInsertUsesOneSynchronizedPdoForExecutionAndInsertId(): void { $configurator = new StatementPathSessionConfigurator; diff --git a/tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php b/tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php index 8cc31a43b..e92368956 100644 --- a/tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php +++ b/tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php @@ -208,7 +208,6 @@ public function testCreateOrFirstMethodRetrievesExistingRelatedAssociatedJustNow 'select exists(select * from "pivot_table" where "pivot_table"."source_id" = ? and "pivot_table"."related_id" in (?)) as "exists"', [123, 456], false, - [], ) ->andReturn([['exists' => 1]]); @@ -260,7 +259,6 @@ public function testCreateOrFirstMethodRethrowsAttachViolationWhenExactPivotIsMi 'select exists(select * from "pivot_table" where "pivot_table"."source_id" = ? and "pivot_table"."related_id" in (?)) as "exists"', [123, 456], false, - [], )->andReturn([['exists' => 0]]); try { @@ -369,7 +367,6 @@ public function testFirstOrCreateMethodReturnsExistingRelatedWhenExactPivotExist 'select exists(select * from "pivot_table" where "pivot_table"."source_id" = ? and "pivot_table"."related_id" in (?)) as "exists"', [123, 456], false, - [], )->andReturn([['exists' => 1]]); $result = $source->related()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); @@ -416,7 +413,6 @@ public function testFirstOrCreateMethodRethrowsAttachViolationWhenExactPivotIsMi 'select exists(select * from "pivot_table" where "pivot_table"."source_id" = ? and "pivot_table"."related_id" in (?)) as "exists"', [123, 456], false, - [], )->andReturn([['exists' => 0]]); try { @@ -726,7 +722,6 @@ public function testPivotMembershipCheckIncludesEveryConfiguredPivotConstraint() 'select exists(select * from "pivot_table" where ("status" = ? and "kind" in (?, ?) and "expired_at" is null and "score" between ? and ?) and "pivot_table"."source_id" = ? and "pivot_table"."related_id" in (?)) as "exists"', ['active', 'primary', 'secondary', 10, 20, 123, 456], false, - [], )->andReturn([['exists' => 1]]); $this->assertTrue($relation->hasAttached($related)); @@ -756,7 +751,6 @@ public function testPivotMembershipCheckIncludesMorphDiscriminator(): void 'select exists(select * from "pivot_table" where "pivot_table"."source_id" = ? and "source_type" = ? and "pivot_table"."related_id" in (?)) as "exists"', [123, SourceModel::class, 456], false, - [], )->andReturn([['exists' => 1]]); $this->assertTrue($relation->hasAttached($related)); diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 1487a8266..4d8b23c57 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -1999,13 +1999,18 @@ public function testUnguardedCallDoesNotChangeUnguardedState() public function testUnguardedCallDoesNotChangeUnguardedStateOnException() { + $expectedException = new Exception; + $caughtException = null; + try { - Model::unguarded(function () { - throw new Exception; + Model::unguarded(function () use ($expectedException): never { + throw $expectedException; }); - } catch (Exception) { - // ignore the exception + } catch (Exception $exception) { + $caughtException = $exception; } + + $this->assertSame($expectedException, $caughtException); $this->assertFalse(Model::isUnguarded()); } diff --git a/tests/Database/DatabaseEloquentRelationTest.php b/tests/Database/DatabaseEloquentRelationTest.php index 1c62727cd..baabda4ae 100755 --- a/tests/Database/DatabaseEloquentRelationTest.php +++ b/tests/Database/DatabaseEloquentRelationTest.php @@ -198,19 +198,31 @@ public function testIgnoredModelsStateIsResetWhenThereAreExceptions() $this->assertFalse($related::isIgnoringTouch()); $this->assertFalse($relatedChild::isIgnoringTouch()); - try { - NoTouchingModelStub::withoutTouching(function () use ($related, $relatedChild) { - $this->assertTrue($related::isIgnoringTouch()); - $this->assertTrue($relatedChild::isIgnoringTouch()); + $expectedException = new Exception; + $relatedIgnoredTouch = null; + $relatedChildIgnoredTouch = null; + $caughtException = null; - throw new Exception; + try { + NoTouchingModelStub::withoutTouching(function () use ( + $expectedException, + $related, + &$relatedIgnoredTouch, + $relatedChild, + &$relatedChildIgnoredTouch, + ): never { + $relatedIgnoredTouch = $related::isIgnoringTouch(); + $relatedChildIgnoredTouch = $relatedChild::isIgnoringTouch(); + + throw $expectedException; }); - - $this->fail('Exception was not thrown'); - } catch (Exception) { - // Does nothing. + } catch (Exception $exception) { + $caughtException = $exception; } + $this->assertSame($expectedException, $caughtException); + $this->assertTrue($relatedIgnoredTouch); + $this->assertTrue($relatedChildIgnoredTouch); $this->assertFalse($related::isIgnoringTouch()); $this->assertFalse($relatedChild::isIgnoringTouch()); } diff --git a/tests/Database/DatabaseEloquentTimestampsTest.php b/tests/Database/DatabaseEloquentTimestampsTest.php index abe6163fe..b3abe2337 100644 --- a/tests/Database/DatabaseEloquentTimestampsTest.php +++ b/tests/Database/DatabaseEloquentTimestampsTest.php @@ -155,16 +155,22 @@ public function testWithoutTimestampRestoresWhenClosureThrowsException() $user = UserWithCreatedAndUpdated::create(['email' => 'foo@example.com']); $user->timestamps = true; + $expectedException = new RuntimeException; + $usedTimestamps = null; + $caughtException = null; try { - $user->withoutTimestamps(function () use ($user) { - $this->assertFalse($user->usesTimestamps()); - throw new RuntimeException; + $user->withoutTimestamps(function () use ($expectedException, $user, &$usedTimestamps): never { + $usedTimestamps = $user->usesTimestamps(); + + throw $expectedException; }); - $this->fail(); - } catch (RuntimeException) { + } catch (RuntimeException $exception) { + $caughtException = $exception; } + $this->assertSame($expectedException, $caughtException); + $this->assertFalse($usedTimestamps); $this->assertTrue($user->timestamps); } diff --git a/tests/Database/DatabaseManagerTest.php b/tests/Database/DatabaseManagerTest.php index 27c936b21..7ea47c6bb 100644 --- a/tests/Database/DatabaseManagerTest.php +++ b/tests/Database/DatabaseManagerTest.php @@ -48,6 +48,9 @@ public function testDisconnectDisconnectsNonPooledConnection() $this->assertNull($connection->getRawPdo()); } + // REMOVED: Capsule's setter writes unused configuration and cannot safely + // define a connection-wide row shape. Use Query\Builder::fetchUsing() per query. + public function testFlushStateClearsMacros() { try { diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 73adf77ba..f58de557b 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -4044,9 +4044,9 @@ public function testFindOrReturnsFirstResultByID() { $builder = $this->getMockQueryBuilder(); $data = m::mock(stdClass::class); - $builder->shouldReceive('first')->andReturn($data)->once(); - $builder->shouldReceive('first')->with(['column'])->andReturn($data)->once(); - $builder->shouldReceive('first')->andReturn(null)->once(); + $builder->shouldReceive('get')->with(['*'])->andReturn(new Collection([$data]))->once(); + $builder->shouldReceive('get')->with(['column'])->andReturn(new Collection([$data]))->once(); + $builder->shouldReceive('get')->with(['*'])->andReturn(new Collection)->once(); $this->assertSame($data, $builder->findOr(1, fn () => 'callback result')); $this->assertSame($data, $builder->findOr(1, ['column'], fn () => 'callback result')); @@ -4168,12 +4168,12 @@ public function testAggregateFunctions() $this->assertEquals(1, $results); $builder = $this->getBuilder(); - $builder->getConnection()->shouldReceive('select')->once()->with('select exists(select * from "users") as "exists"', [], true, [])->andReturn([['exists' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select exists(select * from "users") as "exists"', [], true)->andReturn([['exists' => 1]]); $results = $builder->from('users')->exists(); $this->assertTrue($results); $builder = $this->getBuilder(); - $builder->getConnection()->shouldReceive('select')->once()->with('select exists(select * from "users") as "exists"', [], true, [])->andReturn([['exists' => 0]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select exists(select * from "users") as "exists"', [], true)->andReturn([['exists' => 0]]); $results = $builder->from('users')->doesntExist(); $this->assertTrue($results); @@ -5241,7 +5241,7 @@ public function testPreservedAreAppliedByTruncate() public function testPreservedAreAppliedByExists() { $builder = $this->getBuilder(); - $builder->getConnection()->shouldReceive('select')->once()->with('select exists(select * from "users") as "exists"', [], true, []); + $builder->getConnection()->shouldReceive('select')->once()->with('select exists(select * from "users") as "exists"', [], true); $builder->beforeQuery(function ($builder) { $builder->from('users'); }); diff --git a/tests/Database/Eloquent/EloquentModelWithoutEventsTest.php b/tests/Database/Eloquent/EloquentModelWithoutEventsTest.php index 97099a4a6..b64ac34c8 100644 --- a/tests/Database/Eloquent/EloquentModelWithoutEventsTest.php +++ b/tests/Database/Eloquent/EloquentModelWithoutEventsTest.php @@ -67,16 +67,23 @@ public function testWithoutEventsSupportsNesting(): void public function testWithoutEventsRestoresStateAfterException(): void { $this->assertFalse(TestModel::eventsDisabled()); + $expectedException = new RuntimeException('Test exception'); + $eventsDisabledInsideCallback = null; + $caughtException = null; try { - TestModel::withoutEvents(function () { - $this->assertTrue(TestModel::eventsDisabled()); - throw new RuntimeException('Test exception'); + TestModel::withoutEvents(function () use ($expectedException, &$eventsDisabledInsideCallback): never { + $eventsDisabledInsideCallback = TestModel::eventsDisabled(); + + throw $expectedException; }); - } catch (RuntimeException) { - // Expected + } catch (RuntimeException $exception) { + $caughtException = $exception; } + $this->assertSame($expectedException, $caughtException); + $this->assertTrue($eventsDisabledInsideCallback); + // State should be restored even after exception $this->assertFalse(TestModel::eventsDisabled()); } diff --git a/tests/FacadeDocumenter/IgnoredMethodsTest.php b/tests/FacadeDocumenter/IgnoredMethodsTest.php index 6f6ddbde2..72cde732c 100644 --- a/tests/FacadeDocumenter/IgnoredMethodsTest.php +++ b/tests/FacadeDocumenter/IgnoredMethodsTest.php @@ -66,6 +66,82 @@ protected static function ignoredFacadeDocumenterMethods(): array $this->assertStringNotContainsString('@method static string hidden()', $contents); } + public function testFacadeMayExcludeOneMixinMethodWithoutHidingAProxyMethod(): void + { + $this->writeAppFile( + 'IgnoredMethods/Connection.php', + <<<'PHP' + writeAppFile( + 'IgnoredMethods/Manager.php', + <<<'PHP' + writeAppFile( + 'IgnoredMethods/MixinFacade.php', + <<<'PHP' + + */ + protected static function ignoredFacadeDocumenterMethods(): array + { + return ['transactionLevel']; + } + } + PHP + ); + + $process = $this->runDocumenter(['App\IgnoredMethods\MixinFacade']); + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput() . $process->getOutput()); + + $contents = $this->appFileContents('App\IgnoredMethods\MixinFacade'); + + $this->assertStringContainsString('@method static void disconnect(string|null $name = null)', $contents); + $this->assertStringNotContainsString('@method static int transactionLevel()', $contents); + } + public function testIgnoreHookMustBeStatic(): void { $this->writeAppFile( diff --git a/tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php b/tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php index dc199aeb9..68705fc2b 100644 --- a/tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php +++ b/tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php @@ -443,13 +443,17 @@ public function testAssertSessionHasNoErrors() ])); $response = TestResponse::fromBaseResponse(new Response); + $caughtException = null; try { $response->assertSessionHasNoErrors(); - } catch (AssertionFailedError $e) { - $this->assertStringContainsString('foo is required', $e->getMessage()); - $this->assertStringContainsString('bar is required', $e->getMessage()); + } catch (AssertionFailedError $exception) { + $caughtException = $exception; } + + $this->assertInstanceOf(AssertionFailedError::class, $caughtException); + $this->assertStringContainsString('foo is required', $caughtException->getMessage()); + $this->assertStringContainsString('bar is required', $caughtException->getMessage()); } public function testAssertSessionHas() diff --git a/tests/Integration/Cache/CacheFunnelTestCase.php b/tests/Integration/Cache/CacheFunnelTestCase.php index 03e47ed6d..ef829eb90 100644 --- a/tests/Integration/Cache/CacheFunnelTestCase.php +++ b/tests/Integration/Cache/CacheFunnelTestCase.php @@ -54,17 +54,23 @@ public function testFunnelReleasesLockAfterCallback(): void public function testFunnelLockReleasedOnException(): void { + $expectedException = new Exception('fail'); + $caughtException = null; + try { $this->cache()->funnel('test') ->limit(1) ->releaseAfter(60) ->block(0) - ->then(function () { - throw new Exception('fail'); + ->then(function () use ($expectedException): never { + throw $expectedException; }); - } catch (Exception) { + } catch (Exception $exception) { + $caughtException = $exception; } + $this->assertSame($expectedException, $caughtException); + $result = $this->cache()->funnel('test') ->limit(1) ->releaseAfter(60) diff --git a/tests/Integration/Cache/FileCacheLockTest.php b/tests/Integration/Cache/FileCacheLockTest.php index 2f67ffd0f..0dde090a5 100644 --- a/tests/Integration/Cache/FileCacheLockTest.php +++ b/tests/Integration/Cache/FileCacheLockTest.php @@ -67,17 +67,19 @@ public function testConcurrentLocksAreReleasedSafely(): void public function testLocksWithFailedBlockCallbackAreReleased(): void { $firstLock = Cache::lock('foo', 10); + $expectedException = new Exception('failed'); + $caughtException = null; try { - $firstLock->block(1, function () { - throw new Exception('failed'); + $firstLock->block(1, function () use ($expectedException): never { + throw $expectedException; }); - } catch (Exception) { - // Not testing the exception, just testing the lock - // is released regardless of the how the exception - // thrown by the callback was handled. + } catch (Exception $exception) { + $caughtException = $exception; } + $this->assertSame($expectedException, $caughtException); + $secondLock = Cache::lock('foo', 1); $this->assertTrue($secondLock->get()); diff --git a/tests/Integration/Cache/Redis/RedisCacheLockTest.php b/tests/Integration/Cache/Redis/RedisCacheLockTest.php index 5936e7f3d..782c66015 100644 --- a/tests/Integration/Cache/Redis/RedisCacheLockTest.php +++ b/tests/Integration/Cache/Redis/RedisCacheLockTest.php @@ -68,17 +68,19 @@ public function testRedisLocksWithFailedBlockCallbackAreReleased(): void Cache::store('redis')->lock('foo')->forceRelease(); $firstLock = Cache::store('redis')->lock('foo', 10); + $expectedException = new Exception('failed'); + $caughtException = null; try { - $firstLock->block(1, function () { - throw new Exception('failed'); + $firstLock->block(1, function () use ($expectedException): never { + throw $expectedException; }); - } catch (Exception) { - // Not testing the exception, just testing the lock - // is released regardless of the how the exception - // thrown by the callback was handled. + } catch (Exception $exception) { + $caughtException = $exception; } + $this->assertSame($expectedException, $caughtException); + $secondLock = Cache::store('redis')->lock('foo', 1); $this->assertTrue($secondLock->get()); diff --git a/tests/Integration/Database/AfterQueryTest.php b/tests/Integration/Database/AfterQueryTest.php index a2ab14f34..37e6f367c 100644 --- a/tests/Integration/Database/AfterQueryTest.php +++ b/tests/Integration/Database/AfterQueryTest.php @@ -8,6 +8,7 @@ use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Schema; +use PDO; class AfterQueryTest extends DatabaseTestCase { @@ -121,6 +122,32 @@ public function testAfterQueryOnBaseBuilderCursor() $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $users->pluck('id')->toArray()); } + public function testAfterQueryOnBaseBuilderCursorDistinguishesNullFromAnEmptyResult(): void + { + AfterQueryUser::create(['team_id' => null]); + + $query = AfterQueryUser::query() + ->toBase() + ->select('team_id') + ->fetchUsing(PDO::FETCH_COLUMN); + + $this->assertSame([null], $query->clone()->cursor()->all()); + $this->assertSame( + [], + $query->clone() + ->afterQuery(static fn (Collection $items): Collection => $items->take(0)) + ->cursor() + ->all() + ); + $this->assertSame( + [null], + $query->clone() + ->afterQuery(static fn (Collection $items): Collection => new Collection([null])) + ->cursor() + ->all() + ); + } + public function testAfterQueryOnEloquentPluck() { AfterQueryUser::create(); diff --git a/tests/Integration/Database/ConnectionCoroutineSafetyTest.php b/tests/Integration/Database/ConnectionCoroutineSafetyTest.php index 54249c9ce..1bf111b27 100644 --- a/tests/Integration/Database/ConnectionCoroutineSafetyTest.php +++ b/tests/Integration/Database/ConnectionCoroutineSafetyTest.php @@ -131,16 +131,22 @@ public function testUnguardedDisablesGuardingWithinCallback(): void public function testUnguardedRestoresStateAfterException(): void { $this->assertFalse(Model::isUnguarded()); + $expectedException = new RuntimeException('Test exception'); + $unguardedInsideCallback = null; + $caughtException = null; try { - Model::unguarded(function () { - $this->assertTrue(Model::isUnguarded()); - throw new RuntimeException('Test exception'); + Model::unguarded(function () use ($expectedException, &$unguardedInsideCallback): never { + $unguardedInsideCallback = Model::isUnguarded(); + + throw $expectedException; }); - } catch (RuntimeException) { - // Expected + } catch (RuntimeException $exception) { + $caughtException = $exception; } + $this->assertSame($expectedException, $caughtException); + $this->assertTrue($unguardedInsideCallback); $this->assertFalse(Model::isUnguarded()); } @@ -215,16 +221,26 @@ public function testUsingConnectionRestoresStateAfterException(): void $manager = $this->app->make(DatabaseManager::class); $originalDefault = $manager->getDefaultConnection(); $testConnection = 'sqlite'; + $expectedException = new RuntimeException('Test exception'); + $connectionInsideCallback = null; + $caughtException = null; try { - $manager->usingConnection($testConnection, function () use ($manager, $testConnection) { - $this->assertSame($testConnection, $manager->getDefaultConnection()); - throw new RuntimeException('Test exception'); + $manager->usingConnection($testConnection, function () use ( + &$connectionInsideCallback, + $expectedException, + $manager, + ): never { + $connectionInsideCallback = $manager->getDefaultConnection(); + + throw $expectedException; }); - } catch (RuntimeException) { - // Expected + } catch (RuntimeException $exception) { + $caughtException = $exception; } + $this->assertSame($expectedException, $caughtException); + $this->assertSame($testConnection, $connectionInsideCallback); $this->assertSame($originalDefault, $manager->getDefaultConnection()); } diff --git a/tests/Integration/Database/Eloquent/ModelCoroutineSafetyTest.php b/tests/Integration/Database/Eloquent/ModelCoroutineSafetyTest.php index f12e5c2c4..4793c2016 100644 --- a/tests/Integration/Database/Eloquent/ModelCoroutineSafetyTest.php +++ b/tests/Integration/Database/Eloquent/ModelCoroutineSafetyTest.php @@ -67,16 +67,22 @@ public function testWithoutEventsDisablesEventsWithinCallback(): void public function testWithoutEventsRestoresStateAfterException(): void { $this->assertFalse(Model::eventsDisabled()); + $expectedException = new RuntimeException('Test exception'); + $eventsDisabledInsideCallback = null; + $caughtException = null; try { - Model::withoutEvents(function () { - $this->assertTrue(Model::eventsDisabled()); - throw new RuntimeException('Test exception'); + Model::withoutEvents(function () use ($expectedException, &$eventsDisabledInsideCallback): never { + $eventsDisabledInsideCallback = Model::eventsDisabled(); + + throw $expectedException; }); - } catch (RuntimeException) { - // Expected + } catch (RuntimeException $exception) { + $caughtException = $exception; } + $this->assertSame($expectedException, $caughtException); + $this->assertTrue($eventsDisabledInsideCallback); $this->assertFalse(Model::eventsDisabled()); } @@ -144,16 +150,22 @@ public function testWithoutBroadcastingDisablesBroadcastingWithinCallback(): voi public function testWithoutBroadcastingRestoresStateAfterException(): void { $this->assertTrue(Model::isBroadcasting()); + $expectedException = new RuntimeException('Test exception'); + $broadcastingInsideCallback = null; + $caughtException = null; try { - Model::withoutBroadcasting(function () { - $this->assertFalse(Model::isBroadcasting()); - throw new RuntimeException('Test exception'); + Model::withoutBroadcasting(function () use ($expectedException, &$broadcastingInsideCallback): never { + $broadcastingInsideCallback = Model::isBroadcasting(); + + throw $expectedException; }); - } catch (RuntimeException) { - // Expected + } catch (RuntimeException $exception) { + $caughtException = $exception; } + $this->assertSame($expectedException, $caughtException); + $this->assertFalse($broadcastingInsideCallback); $this->assertTrue(Model::isBroadcasting()); } @@ -232,16 +244,22 @@ public function testWithoutTouchingOnSpecificModels(): void public function testWithoutTouchingRestoresStateAfterException(): void { $this->assertFalse(Model::isIgnoringTouch(CoroutineTestUser::class)); + $expectedException = new RuntimeException('Test exception'); + $ignoringTouchInsideCallback = null; + $caughtException = null; try { - Model::withoutTouching(function () { - $this->assertTrue(Model::isIgnoringTouch(CoroutineTestUser::class)); - throw new RuntimeException('Test exception'); + Model::withoutTouching(function () use ($expectedException, &$ignoringTouchInsideCallback): never { + $ignoringTouchInsideCallback = Model::isIgnoringTouch(CoroutineTestUser::class); + + throw $expectedException; }); - } catch (RuntimeException) { - // Expected + } catch (RuntimeException $exception) { + $caughtException = $exception; } + $this->assertSame($expectedException, $caughtException); + $this->assertTrue($ignoringTouchInsideCallback); $this->assertFalse(Model::isIgnoringTouch(CoroutineTestUser::class)); } @@ -315,16 +333,22 @@ public function testWithoutTimestampsDisablesTimestampsWithinCallback(): void public function testWithoutTimestampsRestoresStateAfterException(): void { $this->assertFalse(Model::isIgnoringTimestamps(CoroutineTestUser::class)); + $expectedException = new RuntimeException('Test exception'); + $ignoringTimestampsInsideCallback = null; + $caughtException = null; try { - Model::withoutTimestamps(function () { - $this->assertTrue(Model::isIgnoringTimestamps(CoroutineTestUser::class)); - throw new RuntimeException('Test exception'); + Model::withoutTimestamps(function () use ($expectedException, &$ignoringTimestampsInsideCallback): never { + $ignoringTimestampsInsideCallback = Model::isIgnoringTimestamps(CoroutineTestUser::class); + + throw $expectedException; }); - } catch (RuntimeException) { - // Expected + } catch (RuntimeException $exception) { + $caughtException = $exception; } + $this->assertSame($expectedException, $caughtException); + $this->assertTrue($ignoringTimestampsInsideCallback); $this->assertFalse(Model::isIgnoringTimestamps(CoroutineTestUser::class)); } diff --git a/tests/Integration/Database/PooledConnectionTest.php b/tests/Integration/Database/PooledConnectionTest.php index 088c0a026..26b7401ae 100644 --- a/tests/Integration/Database/PooledConnectionTest.php +++ b/tests/Integration/Database/PooledConnectionTest.php @@ -494,14 +494,18 @@ public function testUnknownReadSessionIsDetectedWithoutResolvingUnopenedPdos(): $readPdo = new PDO('sqlite::memory:'); $connection->setReadPdo($readPdo); $configurator->desiredState = 'fail'; - $configurator->applyCallback = static fn () => throw new Exception('Configuration failed.'); + $configurationException = new Exception('Configuration failed.'); + $configurator->applyCallback = static fn () => throw $configurationException; + $caughtException = null; try { $connection->getReadPdo(); - $this->fail('Expected configuration exception was not thrown.'); - } catch (Exception) { + } catch (Exception $exception) { + $caughtException = $exception; } + $this->assertSame($configurationException, $caughtException); + $connection->setPdo(static fn () => throw new Exception('Write PDO must not be resolved.')); $releasedConnection = $pooledConnection; $pooledConnection->release(); @@ -568,7 +572,8 @@ public function testInvalidNormalConnectionReconnectsAndConfiguresAFreshPdo(): v ], ]); $configurator = new PoolSessionConfigurator('session_reconnect_test'); - $configurator->applyCallback = static fn () => throw new Exception('Configuration failed.'); + $configurationException = new Exception('Configuration failed.'); + $configurator->applyCallback = static fn () => throw $configurationException; Connection::configureSessionUsing($configurator); $pool = new DbPool($this->app, 'session_reconnect_test'); $pooledConnection = null; @@ -577,13 +582,16 @@ public function testInvalidNormalConnectionReconnectsAndConfiguresAFreshPdo(): v /** @var PooledConnection $pooledConnection */ $pooledConnection = $pool->get(); $connection = $pooledConnection->getConnection(); + $caughtException = null; try { $connection->getPdo(); - $this->fail('Expected configuration exception was not thrown.'); - } catch (Exception) { + } catch (Exception $exception) { + $caughtException = $exception; } + $this->assertSame($configurationException, $caughtException); + $oldPdo = $connection->getRawPdo(); $firstPooledConnection = $pooledConnection; $pooledConnection->release(); diff --git a/tests/Integration/Database/QueryBuilderTest.php b/tests/Integration/Database/QueryBuilderTest.php index 90ee0649a..5737cbdef 100644 --- a/tests/Integration/Database/QueryBuilderTest.php +++ b/tests/Integration/Database/QueryBuilderTest.php @@ -15,6 +15,7 @@ use Hypervel\Testing\Assert as PHPUnit; use PDO; use PDOException; +use RuntimeException; class QueryBuilderTest extends DatabaseTestCase { @@ -680,6 +681,13 @@ public function testFetchUsing() 'Bar Post', ], DB::table('posts')->select(['title'])->fetchUsing(PDO::FETCH_COLUMN)->cursor()->collect()->toArray()); + $cursorPaginator = DB::table('posts') + ->orderBy('id') + ->fetchUsing(PDO::FETCH_ASSOC) + ->cursorPaginate(1, ['id', 'title']); + $this->assertSame([['id' => 1, 'title' => 'Foo Post']], $cursorPaginator->items()); + $this->assertSame(1, (int) $cursorPaginator->nextCursor()?->parameter('id')); + // Test the default 'object' fetch mode. $result = DB::table('posts')->select(['title'])->fetchUsing(PDO::FETCH_OBJ)->get()->toArray(); $result2 = DB::table('posts')->select(['title'])->fetchUsing()->get()->toArray(); @@ -689,6 +697,146 @@ public function testFetchUsing() $this->assertSame('Bar Post', $result2[1]->title); } + public function testFetchUsingPreservesFalseyRowsAcrossGetAndCursor(): void + { + Schema::create('fetch_values', function (Blueprint $table) { + $table->increments('id'); + $table->string('value')->nullable(); + }); + + DB::table('fetch_values')->insert([ + ['value' => null], + ['value' => ''], + ['value' => '0'], + ['value' => 'later'], + ]); + + $query = DB::table('fetch_values') + ->select(['id', 'value']) + ->orderBy('id') + ->fetchUsing(PDO::FETCH_COLUMN, 1); + + $this->assertSame([null, '', '0', 'later'], $query->get()->all()); + $this->assertSame([null, '', '0', 'later'], $query->cursor()->all()); + $this->assertSame('later', DB::table('fetch_values')->select('value')->fetchUsing(PDO::FETCH_COLUMN)->find(4)); + + $fallbackCalled = false; + $nullQuery = DB::table('fetch_values')->select('value')->fetchUsing(PDO::FETCH_COLUMN); + + $this->assertNull($nullQuery->clone()->where('id', 1)->firstOrFail()); + $this->assertNull($nullQuery->clone()->findOr(1, function () use (&$fallbackCalled) { + $fallbackCalled = true; + + return 'fallback'; + })); + $this->assertFalse($fallbackCalled); + } + + public function testShapeOwningTerminalsIgnoreCustomFetchModes(): void + { + $this->assertTrue(DB::table('posts')->fetchUsing(PDO::FETCH_COLUMN)->exists()); + $this->assertSame(2, DB::table('posts')->fetchUsing(PDO::FETCH_COLUMN)->count()); + $this->assertSame(['Foo Post', 'Bar Post'], DB::table('posts')->fetchUsing(PDO::FETCH_COLUMN)->pluck('title')->all()); + $this->assertSame('Foo Post,Bar Post', DB::table('posts')->fetchUsing(PDO::FETCH_COLUMN)->implode('title', ',')); + $this->assertSame('Foo Post', DB::table('posts')->orderBy('id')->fetchUsing(PDO::FETCH_COLUMN)->value('title')); + $this->assertSame(2, (int) DB::table('posts')->fetchUsing(PDO::FETCH_COLUMN)->rawValue('count(*)')); + $this->assertSame('Foo Post', DB::table('posts')->where('id', 1)->fetchUsing(PDO::FETCH_COLUMN)->soleValue('title')); + + $paginator = DB::table('posts')->orderBy('id')->fetchUsing(PDO::FETCH_COLUMN)->paginate(1, ['title']); + $this->assertSame(2, $paginator->total()); + $this->assertSame(['Foo Post'], $paginator->items()); + + $groupedPaginator = DB::table('posts') + ->select('content') + ->groupBy('content') + ->orderBy('content') + ->fetchUsing(PDO::FETCH_COLUMN) + ->paginate(1, ['content']); + $this->assertSame(1, $groupedPaginator->total()); + + $query = DB::table('posts')->select('title')->orderBy('id')->fetchUsing(PDO::FETCH_COLUMN); + $this->assertTrue($query->exists()); + $this->assertSame(['Foo Post', 'Bar Post'], $query->get()->all()); + } + + public function testShapeOwningTerminalPreservesBeforeQueryCallbackOwnership(): void + { + $callbackCalls = 0; + $query = DB::table('posts')->orderBy('id')->beforeQuery(function ($query) use (&$callbackCalls) { + ++$callbackCalls; + $query->fetchUsing(PDO::FETCH_COLUMN); + }); + + $this->assertSame(['Foo Post', 'Bar Post'], $query->pluck('title')->all()); + $this->assertSame(1, $callbackCalls); + $this->assertSame(['Foo Post', 'Bar Post'], $query->select('title')->get()->all()); + $this->assertSame(1, $callbackCalls); + } + + public function testFetchUsingSupportsGroupLimitsAndIdIteration(): void + { + $groupLimited = DB::table('posts') + ->select(['id', 'title', 'content']) + ->orderBy('id') + ->groupLimit(1, 'content') + ->fetchUsing(PDO::FETCH_ASSOC) + ->first(); + + $this->assertIsArray($groupLimited); + $this->assertSame( + [], + array_values(array_filter( + array_keys($groupLimited), + fn (string $key) => str_contains($key, 'hypervel_') + )) + ); + + $this->assertSame( + [1, 2], + DB::table('posts') + ->select(['id', 'title']) + ->fetchUsing(PDO::FETCH_ASSOC) + ->lazyById(1) + ->pluck('id') + ->all() + ); + + $eachPositions = []; + DB::table('posts') + ->selectRaw('title as fetch_key, id, content') + ->orderBy('id') + ->fetchUsing(PDO::FETCH_UNIQUE) + ->each(function (mixed $post, int $position) use (&$eachPositions): void { + $eachPositions[] = $position; + }, 2); + $this->assertSame([0, 1], $eachPositions); + + $eachByIdPositions = []; + DB::table('posts') + ->selectRaw('title as fetch_key, id, content') + ->fetchUsing(PDO::FETCH_UNIQUE) + ->eachById(function (mixed $post, int $position) use (&$eachByIdPositions): void { + $eachByIdPositions[] = $position; + }, 1); + $this->assertSame([0, 1], $eachByIdPositions); + } + + public function testFailedSelectRestoresOriginalColumns(): void + { + $query = DB::table('posts')->beforeQuery(function (): never { + throw new RuntimeException('Query failed.'); + }); + + try { + $query->pluck('title'); + $this->fail('Expected the query callback to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('Query failed.', $exception->getMessage()); + } + + $this->assertNull($query->columns); + } + protected function defineEnvironmentWouldThrowsPDOException($app): void { $this->afterApplicationCreated(function () { diff --git a/tests/Integration/Pipeline/PipelineTransactionTest.php b/tests/Integration/Pipeline/PipelineTransactionTest.php index 0c754c709..f579df355 100644 --- a/tests/Integration/Pipeline/PipelineTransactionTest.php +++ b/tests/Integration/Pipeline/PipelineTransactionTest.php @@ -81,22 +81,26 @@ public function testExceptionThrownRollsBackTransaction(): void Event::fake(); $finallyRan = false; + $expectedException = new Exception('I was thrown'); + $caughtException = null; + try { Pipeline::withinTransaction() ->send('some string') ->through([ - function ($value, $next) { - throw new Exception('I was thrown'); + function () use ($expectedException): never { + throw $expectedException; }, ]) ->finally(function () use (&$finallyRan) { $finallyRan = true; }) ->thenReturn(); - $this->fail('No exception was thrown'); - } catch (Exception) { + } catch (Exception $exception) { + $caughtException = $exception; } + $this->assertSame($expectedException, $caughtException); $this->assertTrue($finallyRan); Event::assertDispatched(TransactionBeginning::class); Event::assertDispatched(TransactionRolledBack::class); diff --git a/tests/Log/ContextTest.php b/tests/Log/ContextTest.php index 5a421f761..3b8b65e91 100644 --- a/tests/Log/ContextTest.php +++ b/tests/Log/ContextTest.php @@ -438,15 +438,18 @@ public function testScopeAddsTemporaryContextAndRestores() public function testScopeRestoresOnException() { $this->context->add('existing', 'original'); + $expectedException = new RuntimeException('test'); + $caughtException = null; try { - $this->context->scope(function () { - throw new RuntimeException('test'); + $this->context->scope(function () use ($expectedException): never { + throw $expectedException; }, ['temp' => 'scoped']); - } catch (RuntimeException) { - // expected + } catch (RuntimeException $exception) { + $caughtException = $exception; } + $this->assertSame($expectedException, $caughtException); $this->assertFalse($this->context->has('temp')); $this->assertSame('original', $this->context->get('existing')); } diff --git a/tests/Queue/FailOnExceptionMiddlewareTest.php b/tests/Queue/FailOnExceptionMiddlewareTest.php index e6ffd88bd..c1b9b31ab 100644 --- a/tests/Queue/FailOnExceptionMiddlewareTest.php +++ b/tests/Queue/FailOnExceptionMiddlewareTest.php @@ -39,16 +39,18 @@ public function testMiddleware(string $thrown, FailOnException $middleware, bool $fakeJob = new FakeJob; $job->setJob($fakeJob); + $caughtException = null; + try { $instance->call($fakeJob, [ 'command' => serialize($job), ]); - - $this->fail('Did not throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf($thrown, $e); + } catch (Throwable $exception) { + $caughtException = $exception; } + $this->assertInstanceOf($thrown, $caughtException); + $expectedToFail ? $job->assertFailed() : $job->assertNotFailed(); } @@ -85,17 +87,17 @@ public function testCanTestAgainstJobProperties(mixed $value, bool $expectedToFa $fakeJob = new FakeJob; $job->setJob($fakeJob); - $exception = null; + $caughtException = null; try { $instance->call($fakeJob, [ 'command' => serialize($job), ]); - } catch (Throwable $throwable) { - $exception = $throwable; + } catch (InvalidArgumentException $exception) { + $caughtException = $exception; } - $this->assertNotNull($exception, 'Did not throw exception'); + $this->assertInstanceOf(InvalidArgumentException::class, $caughtException, 'Did not throw expected exception'); $expectedToFail ? $job->assertFailed() : $job->assertNotFailed(); } diff --git a/tests/Scout/Feature/CoroutineSafetyTest.php b/tests/Scout/Feature/CoroutineSafetyTest.php index db546da62..2d54b31ff 100644 --- a/tests/Scout/Feature/CoroutineSafetyTest.php +++ b/tests/Scout/Feature/CoroutineSafetyTest.php @@ -240,16 +240,22 @@ public function testWhileImportingRestoresStateAfterCallbackAndOnException(): vo // Exception path $beforeException = Scout::isImporting(); $insideException = null; + $expectedException = new RuntimeException('boom'); + $caughtException = null; + try { - Scout::whileImporting(function () use (&$insideException) { + Scout::whileImporting(function () use ($expectedException, &$insideException): never { $insideException = Scout::isImporting(); - throw new RuntimeException('boom'); + + throw $expectedException; }); - } catch (RuntimeException) { - // swallow + } catch (RuntimeException $exception) { + $caughtException = $exception; } + $afterException = Scout::isImporting(); + $this->assertSame($expectedException, $caughtException); $this->assertFalse($beforeException); $this->assertTrue($insideException); $this->assertFalse($afterException); diff --git a/tests/Scout/Feature/SearchableModelTest.php b/tests/Scout/Feature/SearchableModelTest.php index fd110ee4e..7d80917ef 100644 --- a/tests/Scout/Feature/SearchableModelTest.php +++ b/tests/Scout/Feature/SearchableModelTest.php @@ -169,15 +169,19 @@ public function testWithoutSyncingToSearchExecutesCallbackAndRestoresState(): vo public function testWithoutSyncingToSearchRestoresStateOnException(): void { $this->assertTrue(SearchableModel::isSearchSyncingEnabled()); + $expectedException = new RuntimeException('Test exception'); + $caughtException = null; try { - SearchableModel::withoutSyncingToSearch(function () { - throw new RuntimeException('Test exception'); + SearchableModel::withoutSyncingToSearch(function () use ($expectedException): never { + throw $expectedException; }); - } catch (RuntimeException) { - // Expected + } catch (RuntimeException $exception) { + $caughtException = $exception; } + $this->assertSame($expectedException, $caughtException); + // Syncing should be restored even after exception $this->assertTrue(SearchableModel::isSearchSyncingEnabled()); } diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index b436d3c62..d364d85ff 100644 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -1776,16 +1776,24 @@ public function testItCanFreezeUuidsInAClosure(): void public function testItCreatesUuidsNormallyAfterFailureWithinFreezeMethod(): void { $frozenUuid = Uuid::fromString('00000000-0000-0000-0000-000000000123'); + $expectedException = new Exception('Something failed.'); + $uuidInsideCallback = null; + $caughtException = null; try { - Str::freezeUuids(function () use ($frozenUuid) { + Str::freezeUuids(function () use ($expectedException, $frozenUuid, &$uuidInsideCallback): never { Str::createUuidsUsing(fn () => $frozenUuid); - $this->assertSame($frozenUuid->toString(), Str::uuid()->toString()); - throw new Exception('Something failed.'); + $uuidInsideCallback = Str::uuid()->toString(); + + throw $expectedException; }); - } catch (Exception) { - $this->assertNotSame($frozenUuid->toString(), Str::uuid()->toString()); + } catch (Exception $exception) { + $caughtException = $exception; } + + $this->assertSame($expectedException, $caughtException); + $this->assertSame($frozenUuid->toString(), $uuidInsideCallback); + $this->assertNotSame($frozenUuid->toString(), Str::uuid()->toString()); } public function testItCanSpecifyASequenceOfUuidsToUtilise(): void @@ -1899,16 +1907,24 @@ public function testItCanFreezeUlidsInAClosure(): void public function testItCreatesUlidsNormallyAfterFailureWithinFreezeMethod(): void { $frozenUlid = new Ulid('01HGJ9Y6P4RT2R4PQJ4M0N9N8C'); + $expectedException = new Exception('Something failed'); + $ulidInsideCallback = null; + $caughtException = null; try { - Str::freezeUlids(function () use ($frozenUlid) { + Str::freezeUlids(function () use ($expectedException, $frozenUlid, &$ulidInsideCallback): never { Str::createUlidsUsing(fn () => $frozenUlid); - $this->assertSame((string) $frozenUlid, (string) Str::ulid()); - throw new Exception('Something failed'); + $ulidInsideCallback = (string) Str::ulid(); + + throw $expectedException; }); - } catch (Exception) { - $this->assertNotSame((string) $frozenUlid, (string) Str::ulid()); + } catch (Exception $exception) { + $caughtException = $exception; } + + $this->assertSame($expectedException, $caughtException); + $this->assertSame((string) $frozenUlid, $ulidInsideCallback); + $this->assertNotSame((string) $frozenUlid, (string) Str::ulid()); } public function testItCanSpecifyASequenceOfUlidsToUtilise(): void diff --git a/tests/Testbench/BootstrapperTest.php b/tests/Testbench/BootstrapperTest.php index 0ff62c920..00de20e94 100644 --- a/tests/Testbench/BootstrapperTest.php +++ b/tests/Testbench/BootstrapperTest.php @@ -269,7 +269,7 @@ public function itRollsBackTheRuntimeCopyWhenProcessMarkerCreationFails(): void mkdir($packagePath, 0777, true); mkdir($sourcePath, 0777, true); - BootstrapperIdentityProbe::setProcessIdentity(null, 'start-identity'); + BootstrapperIdentityProbe::setStartIdentity('start-identity'); try { $this->withRuntimeCopyEnvironment('bootstrapper-failed-marker', false, function () use ($filesystem, $sourcePath, $packagePath, $failure): void { @@ -290,7 +290,7 @@ public function itRollsBackTheRuntimeCopyWhenProcessMarkerCreationFails(): void }); }); } finally { - BootstrapperIdentityProbe::resetProcessIdentity(); + BootstrapperIdentityProbe::resetStartIdentity(); $this->deleteDirectory($packagePath); $this->deleteDirectory($sourcePath); $this->deleteDirectory($filesystem->runtimePath); @@ -587,39 +587,39 @@ public function itPreservesTheActiveRuntimeCopy(): void #[Test] public function itRecognizesAMatchingServeProcessIdentity(): void { - [$runtimePath, $pid] = $this->createServeIdentityRuntime(); + [$process, $pipes, $pid] = $this->startTitledProcess(); + $runtimePath = null; try { - BootstrapperIdentityProbe::setProcessIdentity( - '/usr/bin/php /workspace/src/testbench/bin/testbench serve --host=127.0.0.1', - 'start-identity', - ); + $startIdentity = BootstrapperIdentityProbe::startIdentity($pid); + $this->assertNotNull($startIdentity); + [$runtimePath] = $this->createServeIdentityRuntime($pid, $startIdentity); $this->assertTrue( BootstrapperIdentityProbe::matchesServeProcess($pid, $runtimePath), ); } finally { - BootstrapperIdentityProbe::resetProcessIdentity(); + $this->stopProcess($process, $pipes, $pid); $this->deleteDirectory($runtimePath); } } #[Test] - public function itRejectsACommandThatIsNotTestbenchServe(): void + public function itRejectsAProcessWithoutTheRuntimePidFile(): void { - [$runtimePath, $pid] = $this->createServeIdentityRuntime(); + [$process, $pipes, $pid] = $this->startTitledProcess(); + $runtimePath = null; try { - BootstrapperIdentityProbe::setProcessIdentity( - '/usr/bin/php /workspace/artisan queue:work', - 'start-identity', - ); + $startIdentity = BootstrapperIdentityProbe::startIdentity($pid); + $this->assertNotNull($startIdentity); + [$runtimePath] = $this->createServeIdentityRuntime($pid, $startIdentity, false); $this->assertFalse( BootstrapperIdentityProbe::matchesServeProcess($pid, $runtimePath), ); } finally { - BootstrapperIdentityProbe::resetProcessIdentity(); + $this->stopProcess($process, $pipes, $pid); $this->deleteDirectory($runtimePath); } } @@ -630,16 +630,13 @@ public function itRejectsAReusedPidWithADifferentStartIdentity(): void [$runtimePath, $pid] = $this->createServeIdentityRuntime(); try { - BootstrapperIdentityProbe::setProcessIdentity( - '/usr/bin/php /workspace/src/testbench/bin/testbench serve', - 'different-start-identity', - ); + BootstrapperIdentityProbe::setStartIdentity('different-start-identity'); $this->assertFalse( BootstrapperIdentityProbe::matchesServeProcess($pid, $runtimePath), ); } finally { - BootstrapperIdentityProbe::resetProcessIdentity(); + BootstrapperIdentityProbe::resetStartIdentity(); $this->deleteDirectory($runtimePath); } } @@ -651,16 +648,13 @@ public function itRejectsAMalformedProcessMarker(): void try { file_put_contents($runtimePath . '/.testbench-process', '{invalid'); - BootstrapperIdentityProbe::setProcessIdentity( - '/usr/bin/php /workspace/src/testbench/bin/testbench serve', - 'start-identity', - ); + BootstrapperIdentityProbe::setStartIdentity('start-identity'); $this->assertFalse( BootstrapperIdentityProbe::matchesServeProcess($pid, $runtimePath), ); } finally { - BootstrapperIdentityProbe::resetProcessIdentity(); + BootstrapperIdentityProbe::resetStartIdentity(); $this->deleteDirectory($runtimePath); } } @@ -671,13 +665,13 @@ public function itRejectsAnUnreadableProcessIdentity(): void [$runtimePath, $pid] = $this->createServeIdentityRuntime(); try { - BootstrapperIdentityProbe::setProcessIdentity(null, null); + BootstrapperIdentityProbe::setStartIdentity(null); $this->assertFalse( BootstrapperIdentityProbe::matchesServeProcess($pid, $runtimePath), ); } finally { - BootstrapperIdentityProbe::resetProcessIdentity(); + BootstrapperIdentityProbe::resetStartIdentity(); $this->deleteDirectory($runtimePath); } } @@ -692,16 +686,13 @@ public function itRejectsAMismatchedRuntimePidFile(): void $runtimePath . '/storage/framework/hypervel.pid', (string) ($pid + 1), ); - BootstrapperIdentityProbe::setProcessIdentity( - '/usr/bin/php /workspace/src/testbench/bin/testbench serve', - 'start-identity', - ); + BootstrapperIdentityProbe::setStartIdentity('start-identity'); $this->assertFalse( BootstrapperIdentityProbe::matchesServeProcess($pid, $runtimePath), ); } finally { - BootstrapperIdentityProbe::resetProcessIdentity(); + BootstrapperIdentityProbe::resetStartIdentity(); $this->deleteDirectory($runtimePath); } } @@ -717,16 +708,13 @@ public function itRejectsADeadPidBeforeInspectingItsIdentity(): void $runtimePath . '/storage/framework/hypervel.pid', (string) $deadPid, ); - BootstrapperIdentityProbe::setProcessIdentity( - '/usr/bin/php /workspace/src/testbench/bin/testbench serve', - 'start-identity', - ); + BootstrapperIdentityProbe::setStartIdentity('start-identity'); $this->assertFalse( BootstrapperIdentityProbe::isOrphanedServe($deadPid, $runtimePath), ); } finally { - BootstrapperIdentityProbe::resetProcessIdentity(); + BootstrapperIdentityProbe::resetStartIdentity(); $this->deleteDirectory($runtimePath); } } @@ -872,27 +860,103 @@ private function withRuntimeCopyEnvironment(string $token, bool $packageTester, * * @return array{string, int} */ - private function createServeIdentityRuntime(): array - { + private function createServeIdentityRuntime( + ?int $pid = null, + string $startIdentity = 'start-identity', + bool $writePidFile = true, + ): array { $runtimePath = $this->temporaryDirectory('serve-identity'); - $pid = getmypid(); + $pid ??= getmypid(); mkdir($runtimePath . '/storage/framework', 0777, true); - file_put_contents( - $runtimePath . '/storage/framework/hypervel.pid', - (string) $pid, - ); + + if ($writePidFile) { + file_put_contents( + $runtimePath . '/storage/framework/hypervel.pid', + (string) $pid, + ); + } + file_put_contents( $runtimePath . '/.testbench-process', json_encode([ 'pid' => $pid, - 'started_at' => 'start-identity', + 'started_at' => $startIdentity, ], JSON_THROW_ON_ERROR), ); return [$runtimePath, $pid]; } + /** + * Start a child process with Swoole's serve-master process title. + * + * @return array{0: resource, 1: array, 2: int} + */ + private function startTitledProcess(): array + { + $process = proc_open( + [ + PHP_BINARY, + '-r', + <<<'PHP' +if (! cli_set_process_title('Testbench.Master')) { + fwrite(STDOUT, "failed\n"); + exit(1); +} + +fwrite(STDOUT, "ready\n"); +fflush(STDOUT); +sleep(30); +PHP, + ], + [ + ['pipe', 'r'], + ['pipe', 'w'], + ['pipe', 'w'], + ], + $pipes, + ); + + if (! is_resource($process)) { + throw new RuntimeException('Unable to start the titled child process.'); + } + + fclose($pipes[0]); + $status = proc_get_status($process); + + if (fgets($pipes[1]) !== "ready\n" || ! $status['running']) { + $this->stopProcess($process, $pipes, (int) $status['pid']); + + throw new RuntimeException('The child process could not apply its serve-master title.'); + } + + return [$process, $pipes, (int) $status['pid']]; + } + + /** + * Stop a child process started by this test. + * + * @param resource $process + * @param array $pipes + */ + private function stopProcess(mixed $process, array $pipes, int $pid): void + { + if ($pid > 0 && posix_kill($pid, 0)) { + posix_kill($pid, SIGKILL); + } + + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + + if (is_resource($process)) { + proc_close($process); + } + } + /** * Set an isolated test token for runtime copy paths. */ @@ -975,20 +1039,20 @@ private function deleteDirectory(?string $path): void class BootstrapperIdentityProbe extends Bootstrapper { - protected static ?string $command = null; - protected static ?string $startIdentity = null; - public static function setProcessIdentity(?string $command, ?string $startIdentity): void + protected static bool $hasStartIdentityOverride = false; + + public static function setStartIdentity(?string $startIdentity): void { - static::$command = $command; static::$startIdentity = $startIdentity; + static::$hasStartIdentityOverride = true; } - public static function resetProcessIdentity(): void + public static function resetStartIdentity(): void { - static::$command = null; static::$startIdentity = null; + static::$hasStartIdentityOverride = false; } public static function matchesServeProcess(int $pid, string $runtimeDir): bool @@ -1001,11 +1065,6 @@ public static function isOrphanedServe(int $pid, string $runtimeDir): bool return parent::isOrphanedServeProcess($pid, $runtimeDir); } - protected static function processCommand(int $pid): ?string - { - return static::$command; - } - public static function startIdentity(int $pid): ?string { return parent::processStartIdentity($pid); @@ -1013,7 +1072,9 @@ public static function startIdentity(int $pid): ?string protected static function processStartIdentity(int $pid): ?string { - return static::$startIdentity; + return static::$hasStartIdentityOverride + ? static::$startIdentity + : parent::processStartIdentity($pid); } } diff --git a/tests/Testbench/Foundation/Bootstrap/CreateVendorSymlinkTest.php b/tests/Testbench/Foundation/Bootstrap/CreateVendorSymlinkTest.php index 74eec56e1..c59fcaab2 100644 --- a/tests/Testbench/Foundation/Bootstrap/CreateVendorSymlinkTest.php +++ b/tests/Testbench/Foundation/Bootstrap/CreateVendorSymlinkTest.php @@ -125,14 +125,16 @@ public function itDoesNotDeleteARealVendorDirectoryWhenLinkCreationFails(): void $filesystem->ensureDirectoryExists($application->basePath('vendor')); $filesystem->put($application->basePath('vendor/owned.txt'), 'owned'); $this->ownsVendorDirectory = true; + $caughtException = null; try { (new CreateVendorSymlinkAction($workingPath))->handle($application); - $this->fail('Expected vendor link creation to fail.'); - } catch (Throwable) { - $this->addToAssertionCount(1); + } catch (Throwable $exception) { + $caughtException = $exception; } + $this->assertNotNull($caughtException); + $this->assertFalse(is_link($application->basePath('vendor'))); $this->assertDirectoryExists($application->basePath('vendor')); $this->assertFileExists($application->basePath('vendor/owned.txt')); } diff --git a/types/Database/Connection.php b/types/Database/Connection.php new file mode 100644 index 000000000..7f8247fab --- /dev/null +++ b/types/Database/Connection.php @@ -0,0 +1,34 @@ +transactionLevel() === 0) { + assertType('int', $connection->transactionLevel()); + } + + if (DB::transactionLevel() === 0) { + assertType('int', DB::transactionLevel()); + } +} + +function testWithoutTablePrefixPreservesCallbackReturn(ConnectionInterface $connection): void +{ + assertType("'preserved'", $connection->withoutTablePrefix(fn () => 'preserved')); +} + +function testCursorRowsAreMixed(ConnectionInterface $connection): void +{ + foreach ($connection->cursor('select 1') as $key => $row) { + assertType('int', $key); + assertType('mixed', $row); + } +} diff --git a/types/Database/Eloquent/Relations.php b/types/Database/Eloquent/Relations.php index 01cb98722..8b6dc9803 100644 --- a/types/Database/Eloquent/Relations.php +++ b/types/Database/Eloquent/Relations.php @@ -17,6 +17,7 @@ use Hypervel\Database\Eloquent\Relations\MorphToMany; use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Pagination\Cursor; +use PDO; use function PHPStan\Testing\assertType; @@ -35,6 +36,7 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()); assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->getResults()); + assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->fetchUsing(PDO::FETCH_ASSOC)->get()); assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->makeMany([])); assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->createMany([])); assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->createManyQuietly([])); diff --git a/types/Database/Query/Builder.php b/types/Database/Query/Builder.php index bddad410e..4f0fa267b 100644 --- a/types/Database/Query/Builder.php +++ b/types/Database/Query/Builder.php @@ -6,6 +6,7 @@ use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Query\Builder; +use PDO; use User; use function PHPStan\Testing\assertType; @@ -14,9 +15,11 @@ function test(Builder $query, EloquentBuilder $userQuery): void { assertType('stdClass|null', $query->first()); - assertType('array|object|null', $query->find(1)); + assertType('stdClass|null', $query->find(1)); assertType('42|stdClass', $query->findOr(1, fn () => 42)); assertType('42|stdClass', $query->findOr(1, callback: fn () => 42)); + assertType('Hypervel\Support\Collection', $query->get()); + assertType('Hypervel\Support\LazyCollection', $query->cursor()); assertType('Hypervel\Database\Query\Builder', $query->selectSub($userQuery, 'alias')); assertType('Hypervel\Database\Query\Builder', $query->fromSub($userQuery, 'alias')); assertType('Hypervel\Database\Query\Builder', $query->from($userQuery, 'alias')); @@ -42,6 +45,12 @@ function test(Builder $query, EloquentBuilder $userQuery): void assertType('Hypervel\Pagination\LengthAwarePaginator', $query->paginate()); assertType('Hypervel\Contracts\Pagination\Paginator', $query->simplePaginate()); assertType('Hypervel\Contracts\Pagination\CursorPaginator', $query->cursorPaginate()); + assertType('Hypervel\Database\Eloquent\Collection', $userQuery->get()); + assertType('User|null', $userQuery->first()); + assertType( + 'Hypervel\Database\Eloquent\Collection', + $userQuery->fetchUsing(PDO::FETCH_ASSOC)->get() + ); $query->chunk(1, function ($users, $page) { assertType('Hypervel\Support\Collection', $users); @@ -72,3 +81,50 @@ function test(Builder $query, EloquentBuilder $userQuery): void assertType('Hypervel\Database\Query\Builder', $query->pipe(fn ($query) => $query)); assertType('5', $query->pipe(fn ($query) => 5)); } + +/** @param \Hypervel\Database\Eloquent\Builder $userQuery */ +function testStatementEloquentFetchUsing(EloquentBuilder $userQuery): void +{ + $userQuery->fetchUsing(PDO::FETCH_ASSOC); + + assertType('Hypervel\Database\Eloquent\Collection', $userQuery->get()); + assertType('User|null', $userQuery->first()); +} + +function testChainedFetchUsing(Builder $query): void +{ + assertType( + 'Hypervel\Support\Collection<(int|string), mixed>', + $query->fetchUsing(PDO::FETCH_ASSOC)->get() + ); + assertType( + 'Hypervel\Support\Collection<(int|string), mixed>', + $query->fetchUsing(PDO::FETCH_ASSOC)->where('active', true)->get() + ); +} + +function testStatementFetchUsing(Builder $query): void +{ + $query->fetchUsing(PDO::FETCH_UNIQUE); + + assertType('Hypervel\Support\Collection<(int|string), mixed>', $query->get()); + assertType('Hypervel\Support\LazyCollection', $query->cursor()); + assertType('Hypervel\Support\LazyCollection', $query->lazy()); + assertType('Hypervel\Support\LazyCollection', $query->lazyById()); + + $query->chunk(1, function ($items, $page): void { + assertType('Hypervel\Support\Collection<(int|string), mixed>', $items); + assertType('int', $page); + }); + $query->each(function ($item, $position): void { + assertType('mixed', $item); + assertType('int', $position); + }); +} + +function testFetchUsingResetRemainsConservative(Builder $query): void +{ + $query->fetchUsing(); + + assertType('Hypervel\Support\Collection<(int|string), mixed>', $query->get()); +}