diff --git a/AGENTS.md b/AGENTS.md index e1c712928b..994210223b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,7 +202,8 @@ Build complete, long-term solutions, not MVPs or local workarounds. A broad chan - **Use Laravel observer conventions** — place Eloquent observers in a top-level `Observers/` directory. Register model-specific observers with `#[ObservedBy(...)]`; use `observe()` only for dynamic registration or observers supplied automatically by a reusable concern. - **Use attributed local scopes** — define local Eloquent query scopes as protected methods marked with `#[Scope]`, rather than legacy public `scopeFoo()` methods. Use separate scope classes only for genuine global scopes. - **No class docblocks unless warranted** — only add a class-level docblock if something unusual or complex needs explanation: purpose, architectural role, usage patterns. Never write one that inventories the class — trait lists, method summaries, "registers X, configures Y" — that duplicates the members' own docblocks and goes stale. Method docblocks (title only, Laravel-style, imperative mood: "Return", not "Returns") are always added, except on test methods, whether identified by a test name prefix or PHPUnit’s `#[Test]` attribute. A body can accompany the title for complex methods that need further explanation. -- **Add comments only where they're genuinely useful** (required Laravel-style method title docblocks and config section comment blocks still apply) — a short WHY for logic that isn't obvious from reading the code, the reason behind a bug fix, or logic that's coupled to code in other files and hard to understand in isolation. Avoid unnecessary code comments; don't comment what the code does, and don't annotate framework divergences, routine casts, or type normalizations. When reconciling upstream changes, add missing explanations for non-obvious Hypervel behavior being retained, so future syncs can understand why it exists. +- **Add comments only where they're genuinely useful** (required Laravel-style method title docblocks and config section comment blocks still apply) — a short WHY for logic that isn't obvious from reading the code, the reason behind a bug fix, or logic that's coupled to code in other files and hard to understand in isolation. Avoid unnecessary code comments; don't comment what the code does, and don't annotate routine casts or type normalizations. When reconciling upstream changes, add missing explanations for non-obvious Hypervel behavior being retained, so future syncs can understand why it exists. +- **Explain intentional public API and behavior differences from upstream** — Where a future sync could revert or overwrite an intentional difference, add a concise comment beside the relevant code explaining why it differs. Do not annotate mechanical porting changes. Comments on internal implementation differences follow the general commenting rule. - **Don't make classes final by default** — keep classes open; add `final` only when it protects a real invariant or avoids a concrete framework/API problem, e.g. immutability, coroutine-safety, or a security guarantee. - **Place methods logically, not at the end** — group new methods with related ones (getters with getters, setters with setters). Two exceptions: preserve upstream order when merging ported code (see Porting rules), and `flushState()` has its own placement rule (see Static state and test cleanup). - **Only extract methods when justified** — extract only when the logic is complex enough to benefit from a name, it's likely to be reused, or two or more methods call it. Don't extract a simple one-liner with a single caller. @@ -861,7 +862,7 @@ When ported code adds a provider or listener, wire providers and aliases in both Keep everything else: behavioral descriptions, `@see` links, `@throws` annotations, warnings, contract explanations, usage notes. Modernize the title line to imperative form ("Returns" → "Return", "Retrieves" → "Retrieve") but do not remove or rewrite the body content beneath it. Translate non-English comments to English and fix grammar errors. -- **Record intentional Laravel differences where future ports will look** — When a Laravel feature is intentionally not ported because it does not fit Hypervel's Swoole/coroutine architecture, or because Hypervel has a better native equivalent, record it in three places so a future port cannot miss it: (1) the package README under `Differences From Laravel`, following the Package READMEs rules above and explaining what to use instead; (2) a concise source comment at the natural insertion point where the skipped method/class would otherwise sit; (3) a concise `REMOVED:` comment at the matching upstream test location when tests are skipped. This is a narrow exception to the "don't annotate divergences" rule: it applies only to intentionally omitted methods or features, never to ordinary ported-and-adapted code. Closed decisions only — real gaps still worth doing go in `docs/todo.md`. +- **Record intentionally omitted upstream features where future syncs will look** — When a Laravel feature is intentionally not ported because it does not fit Hypervel's Swoole/coroutine architecture, or because Hypervel has a better native equivalent, record it in three places so a future port cannot miss it: (1) the package README under `Differences From Laravel`, following the Package READMEs rules above and explaining what to use instead; (2) a concise source comment at the natural insertion point where the skipped method/class would otherwise sit; (3) a concise `REMOVED:` comment at the matching upstream test location when tests are skipped. This three-location requirement applies only to intentionally omitted methods or features, not ordinary ported-and-adapted code. Closed decisions only — real gaps still worth doing go in `docs/todo.md`. - **Replace framework names in code** — any occurrence of the word `laravel` or `hyperf` in ported code (string literals, comments, prefixes, identifiers, etc.) must be replaced with `hypervel`, preserving the original casing. For example: `laravel_reserved_` → `hypervel_reserved_`, `LaravelExcelExporter` → `HypervelExcelExporter`, `HYPERF_VERSION` → `HYPERVEL_VERSION`. This does not apply to namespaces (which have their own conversion rules) or to references that describe the upstream source (e.g., docblock `@see` links to Laravel/Hyperf source). - **Don't copy Laravel/Hyperf-specific framework details just to stay 1:1** — preserve public APIs and behavior while translating upstream-specific packages, providers, bootstrap mechanisms, and architecture to Hypervel equivalents. If no equivalent exists, reach consensus on the appropriate adaptation through a second-opinion loop with your peer agent before implementing. diff --git a/docs/plans/2026-10-22-0911-framework-correctness-improvements.md b/docs/plans/2026-10-22-0911-framework-correctness-improvements.md new file mode 100644 index 0000000000..723bd9f2a2 --- /dev/null +++ b/docs/plans/2026-10-22-0911-framework-correctness-improvements.md @@ -0,0 +1,75 @@ +# Framework Correctness Improvements + +## Scope and status + +Independently useful database, queue, validation, decimal-casting and JSON-decoding improvements. Keep this plan independent of private applications and packages; describe framework requirements without their names, paths, or domain details. Preserve Laravel signatures and protected extension points, except for explicitly approved behavior changes. + +The 0.4 integration preserves both sets of supported behavior. Record further framework changes here when they warrant planning, without duplicating consumer-specific implementation plans. + +## Design + +- **Database capability:** add `Connection::supportsSkipLocked(): bool`, false by default, rather than widening the driver-neutral `ConnectionInterface`. PDO implementations reuse the current engine/version decision and its connection-local memoization/reset behavior. Preserve configured version overrides and MariaDB detection through either driver: supported MariaDB servers at the 10.11 baseline, MySQL 8.0.1+, Vitess/PlanetScale 19+, PostgreSQL 9.5+; SQLite remains false. Migrate Database Queue's internal capability use while preserving `getLockForPopping(): bool|string` and its invocation. Outbox claims use the same capability and the fluent locking API, retaining their PostgreSQL CTE and conditional-update fallback. Do not retain parallel engine/version logic; retire superseded `@internal` popping helpers after their callers migrate. +- **Query-builder API:** add fluent `lockForUpdate()->skipLocked()` without changing `lockForUpdate()` or `sharedLock()` signatures. The no-argument modifier requires an existing update/shared lock; reject custom-string combinations in either setter order. Carry intent structurally; grammars own engine SQL, including MySQL's `for share` versus MariaDB's `lock in share mode`. Unsupported versions retain their normal lock behavior; callers needing a nonblocking claim check the connection capability and choose compare-and-swap updates. No lock-mode class hierarchy or domain-specific framework claim service. +- **Queue middleware:** add `RateLimited::releasePadding(int $seconds): static`, nonnegative, with a default of three seconds preserving current behavior. `releaseAfter()` retains fixed-delay precedence. Include the property in the serialization whitelist. Callers can opt into `releasePadding(0)` without additional I/O or lookup for existing jobs. +- **Validation defect:** guard both numeric field operands of `gt/gte/lt/lte` with their respective attribute names. Keep `multiple_of` validation and calculation together in `validateMultipleOf()` for compiled and delegated execution, without a separate single-caller calculation helper. Exponent rejection and numeric parse errors fail validation rather than escaping as math exceptions. Comparison-message formatting preserves the `getSize()` hook and displays the original trimmed value if its exponent is rejected, without expanding it or throwing again. Preserve the attribute-aware override callback; developer-supplied rule literals remain trusted. Components already require Brick Math 1.0; no further version migration is needed. +- **Decimal casts:** prevent scientific-notation expansion beyond ±1000 in request and Eloquent decimal casts, throwing their existing math exception. Share exponent extraction through internal `Number::exponent()` and the default `Number::MAX_EXPONENT` with validation, without limiting ordinary decimal-string length or changing rational parsing. Include `int` in Eloquent's decimal-cast input type so integer values retain their precision instead of passing through float conversion. Test both casts, exponent boundaries, unchanged explicit decimals/fractions and large integer precision with bounded inputs. + +### Write index hints and outbox lock scope + +- Retain index hints on MySQL/MariaDB updates and forced indexes on plain SQLite updates. Honor MySQL/MariaDB DELETE hints through the native single-target multi-table form when an unjoined delete has no ordering or limit; honor hints on joined deletes as well. Derive the delete target before appending the table hint, preserving aliases and prefixes. Keep unhinted SQL and ordered/limited unjoined deletes unchanged. Plain SQLite deletes use `indexed by`; joined, limited and offset SQLite writes keep their existing selection subqueries. PostgreSQL remains unchanged. +- Explain this intentional difference from Laravel beside the grammar: ignoring requested indexes lets InnoDB scan and lock unrelated rows. A missing named index raises the native error. Hints do not guarantee lock confinement: an ID-only delete or locking read can still choose a covering scan. Retain real ownership/eligibility predicates; do not insert dummy predicates or introduce an exact-ID delete API. A caller requiring confined ID-only deletion needs its own driving-ID join. Document supported forms and limitations in query feature documentation; no README differences or porting-guide entry. +- Apply a shared native primary-index gate to outbox exact-ID mutations and claimed-row lookups while preserving lease and eligibility predicates. InnoDB claims select only IDs and original eligibility through the ordered due index, update those IDs, then read the owned message bodies within the transaction. This adds one bounded read and prevents payload selection from choosing a scan/filesort that locks beyond the batch. Allow three framework transaction attempts: concurrent due-index scans can share an end gap and deadlock when claim updates move their entries to future lease deadlines. The database-only closure can safely retry with its original token after rollback. Share the owned-row lookup with conditional claims; PostgreSQL retains its single-statement claim. Legitimate short SKIP LOCKED batches remain valid, but parallel relays must make progress when due rows outside each other's bounded batch are available. Cover native and conditional claim updates, accepted/deferred/failed settlement and operator batch retries. Express failed-release CASE updates through the builder using internally generated integer deadlines; keep external values bound and account for the three remaining non-ID bindings. +- Extend existing grammar and native index-hint tests for deletes, aliases, prefixes, joins, limits and missing indexes. Extend the existing native outbox fixture for mutation contention rather than adding another harness. Verify MySQL 5.7/8.4/9, MariaDB 10.11/12.3, PostgreSQL and SQLite behavior as applicable, including the conditional claim path on MySQL 5.7. Complete formatting, analysis, affected tests and peer review before committing. + +### Model-specific Eloquent builder types + +- Let `UseEloquentBuilder` accept `class-string>`, matching the existing builder property and factory return type. The attribute accepts builders bound to a particular model; `Builder` incorrectly excludes them because the model parameter is invariant. Align the resolved-class cache and resolver return annotation with the same boundary. Runtime construction and public signatures remain unchanged. +- Cover the attribute with a model-bounded generic builder and `HasBuilder`, including a model subclass and a terminal query result in the type fixtures. + +### Constrained relationship-loading types + +- Correct model loading annotations to accept keyed constraints and nested relation specifications already supported at runtime, including model and collection `fresh()` and both paginator morph-loader families. Eager callbacks receive `Relation`; aggregate callbacks receive the related Eloquent `Builder`. Align the forwarding methods without changing native signatures or execution. +- Morph loaders take a map keyed by class names or morph aliases, including integer aliases, whose values are relation specifications or strings. Keep the actual outer map shape in the existing fixtures and cover string/integer aliases across model, collection and paginator loaders. Aggregate constraints accept names, builder callbacks and empty arrays, not nonempty nested relationship arrays; preserve the corresponding callback-inference assertions without claiming nested aggregate support. +- Extend existing model and collection type fixtures to verify constrained inputs, callback types and fluent returns. Keep the separate `Builder::with()` callback parameter's native `Closure` type; its array form supports callable constraints. Reuse existing aggregate callback runtime tests rather than adding duplicate cases. Run source and fixture analysis and affected database tests. + +### Raw attribute replacement + +- Clear both Eloquent cast-value caches and their stored-value tracking before `setRawAttributes()` optionally calls `syncOriginal()`. Synchronization reads attributes through the cached casts; clearing afterwards lets stale objects overwrite the replacement values and marks those wrong values clean. Preserve the signature and unsynchronized replacement behavior, and explain the intentional ordering difference beside the code. Retain the incoming cached-object reconciliation and its distinct in-place-mutation regression alongside the accessor-cache and unsynchronized-replacement cases. +- Use one regression parameterized over synchronization, covering both `AsArrayObject` and an `Attribute` getter/setter object. Check replacement values, raw originals and dirty state without database setup. + +### Immutable date modifier types + +- Keep `CarbonImmutable::addMonths()` metadata typed as `static`, including its optional anchor-day argument. Carbon preserves subclasses at runtime; inherited metadata naming the base class incorrectly widens fluent results. Extend the existing modifier type fixture and reuse the runtime metadata/subclass test. No runtime or API behavior changes. +- The complete fix belongs in Carbon's PHPDoc generator, which emits concrete class names for fluent returns. Retain the existing local annotations until a corrected release is adopted, then remove the redundant annotations and their reflection test while keeping type regressions. Track that dependency update and cleanup in [the framework todo](../todo.md#framework-wide); do not duplicate Carbon's full generated method list here. + +### Exact JSON numbers + +- Add `Json::decodeNumbersAsStrings(string $json, bool $assoc = true, int $depth = self::MAXIMUM_NESTING_DEPTH): mixed` for exact numeric API inputs. Return every numeric token as its original string, including integers, trailing zeros and exponent notation; preserve ordinary strings, booleans, null and native array/object shapes. +- Validate the original document through `Json::validate()` before quoting numeric tokens: quoting alone can turn malformed input into valid JSON. Use a linear walk that skips existing strings and escaped bytes, then delegate to `Json::decode()`. Retain native errors and the existing public depth convention. No new parser framework, number wrapper, flags parameter or changes to ordinary decoding. +- Cover exact values, escaped strings, associative/object results, malformed numbers and object keys, invalid UTF-8 and depth limits in the existing JSON tests. + +### Pooled in-memory database restoration + +- `RefreshDatabase` and `DatabaseTruncation` must restore cached SQLite databases at the physical pool owner. Setting the PDO only on a logical lease loses the restored schema when setup releases that lease; `LazilyRefreshDatabase` shares the affected refresh path. +- Add `DatabasePool::restoreSharedInMemorySqlitePdo(PDO $pdo): void`, marked `Tests only.` Drain idle wrappers through existing pool cleanup, then replace the shared PDO. Subsequent borrows use the current factory and dispatcher. Preserve normal connection/lease semantics and add no production hot-path work. +- Add testing `DatabaseConnectionResolver::restoreInMemoryPdoIfPooled(UnitEnum|string|null $name, PDO $pdo): bool`, also marked `Tests only.` Reuse `poolEnabled()`; return false without resolving connections when disabled. Return true immediately when the named pool already holds the supplied PDO, before releasing any leases. Otherwise release idle leases, restore through that pool and return true. Preserve existing physical-pool name resolution. +- In `LazilyRefreshDatabase`, restore cached in-memory databases before resolving connections and registering lazy callbacks, using the existing `usingInMemoryDatabases()` condition. Migrations and transactions remain lazy. The callback's ordinary restoration becomes a no-op for an already-restored pool, so query-first, cursor-first and explicitly pinned first use retain normal session behavior. Explain this upstream difference concisely; do not change production query or foreign-key suppression pinning. +- Keep both traits' Laravel protected signatures and bare restoration bodies unchanged. Use the pooled operation only when a bound `db.resolver` is the testing resolver and the operation returns true. Custom resolvers and manager doubles retain the existing PDO/type-check/dispatcher path. Do not implicitly read test state during pool construction, add a registry, remigrate every test or substitute a file database for this fix. +- Document that in-memory SQLite pools use one session and serialize queries; tests requiring simultaneous database sessions still need a file-backed or server database. + +### Cancellation through database callbacks + +- Recognize cancellation carried by a callback or cleanup exception through `Coroutine::causedByCancellation()`, preserving the original exception and foreign-cancellation ownership. Query execution and streaming must not wrap cancellation, retry it, count it as a query error or dispatch `QueryFailed`. +- Stop commit callbacks on cancellation. Finish rollback callbacks while retaining cancellation ahead of ordinary failures. Apply the same rule through transaction manager/event notification and recovery boundaries, connection disconnect and retained-connection termination. Keep native-only PDO and join checks unchanged; do not add speculative cleanup machinery. +- Never retry a canceled transaction or wrap its failure as a deadlock, even when its cleanup exception names a deadlock. +- Preserve chained cancellation through model pruning callbacks and managed connection destruction. Pruning stops without reporting cancellation; discarded connections still release pool capacity before rethrowing it. +- Extend existing cancellation tests with chained exceptions at query, transaction callback and cleanup boundaries. Preserve public signatures and ordinary-error behavior. Validate the framework and affected database consumers together. + +## Verification + +- Reuse the existing database version/override/PDO-reset and queue-hook cases; add the additive builder grammar/unsupported-path checks and real multi-connection lock contention coverage. +- Test limiter default/fixed/zero-padding/long-delay behavior and serialization. +- Test exponent rejection on both comparison operands and `multiple_of`, callback overrides, and compiled/interpreted parity using safe inputs such as `1e1001`, not memory-exhaustion experiments. +- Run each edited test file immediately and retain applicable upstream tests. For the 0.4 integration, run affected tests sequentially, source/type/skeleton analysis, targeted formatting and the Testbench package-mode suite with one worker. The owner has prohibited further full local runs and database matrices; use PR CI for full-suite and native database verification before merge. MySQL 5.7 must retain conditional outbox claims, legacy group limits and nested-union coverage. Check affected consumers against the combined framework before completing review. +- No cache change is required: stack TTL caps, nullable unique-lock stores, and documented flexible-cache microcache behavior are already handled by current components. +- For pooled SQLite restoration, cover setup-to-test coroutine handoff with a cached PDO seeded before eager refresh, and ordinary/pinned first queries with a cached PDO seeded before lazy hook registration. Testbench normally clears refresh state between applications; do not mistake remigration for restoration coverage. Retain the truncation cross-application regression, resolver idle release/reborrow checks, bare-path tests and existing transaction-first lazy coverage. Run the affected database/reset tests and the required Testbench package-mode suite. diff --git a/docs/todo.md b/docs/todo.md index 70c89c0162..1fc9323f59 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -21,6 +21,8 @@ ## Framework-wide +- Once a released `nesbot/carbon` version fixes generated fluent return annotations to preserve subclasses, raise the applicable Carbon minimum through Composer and remove the redundant `@method static` overrides from `Hypervel\Support\CarbonImmutable` (`src/support/src/CarbonImmutable.php`). Carbon's `phpdoc.php::compileDoc()` currently emits concrete `CarbonImmutable`/`CarbonInterface` return types for these methods even though runtime preserves the calling class. Retain the existing overrides until a fixed release is verified; do not expand them into a duplicate of Carbon's complete method list. Keep `types/Support/CarbonImmutable.php` as dependency regression coverage, verify further subclasses as well, and remove `SupportCarbonImmutableTest::testMagicModifierMetadataMatchesRuntimeStaticType()` if no local overrides remain. + - Investigate a small shared serializer contract with native PHP and igbinary implementations for storage and transport boundaries where configurable encoding provides a measured benefit. Select the implementation per store or subsystem, resolve it once during service construction, and keep the selection fixed during worker execution; an optional facade can expose application access. Benchmark representative cache values, sessions, and jobs for encode/decode time, payload size, and complete storage or transport cost, including any base64 encoding. Igbinary can reduce size and decode time for repeated data, but can encode small payloads more slowly; an `auto` option would express a documented preference among compatible installed serializers, not discover the fastest one or benchmark every call. Audit call sites rather than replacing every `serialize()` / `unserialize()` call: preserve native format contracts and internal key generation, reuse existing PhpRedis serialization and Lua packing, and preserve class restrictions (`igbinary_unserialize()` has no native `allowed_classes` equivalent). Queue JSON payloads and their native `O:` detection need explicit format and reader changes before supporting binary encoding. Define format identification and migration so persisted values remain readable across configuration changes and rolling deployments. Use Symfony Cache's `MarshallerInterface` / `DefaultMarshaller` as prior art rather than its object-normalizing Serializer component. GMP is not a general serializer; native PHP and igbinary already round-trip nested GMP values, while `gmp_export()` only exports an integer's magnitude and loses its sign. - Resolve the production dependency cycle between `hypervel/foundation` and `hypervel/testing`, which currently causes test-only classes, PHPUnit integration, and Mockery to propagate through `hypervel/support` into every package that depends on Support. Decide whether Foundation's testing namespace moves to `hypervel/testing` or Foundation changes its dependency to development-only while preserving split-package installation and public namespaces. - Design a connection-owned service identity and capability API for external backends that packages can query without repeated hot-path probes. Redis/Valkey and database connections already expose fragments of this information in different forms; prefer lazy detection cached for the current connection or pool generation, with invalidation on reconnect and purge, over an eager process-global startup registry that performs unused I/O or survives a backend change. Start with concrete consumers and capability checks rather than a universal version-comparison abstraction. @@ -30,6 +32,7 @@ - Audit unmatched PHPStan inline ignores and global patterns with `reportUnmatchedIgnoredErrors` enabled — currently 196 unmatched inline ignores across 99 files plus 5 unmatched global patterns. Remove only suppressions that no longer match after tracing the underlying code; do not replace correct source with runtime branches or wider types merely to keep static analysis green. Decide as part of the work whether `phpstan.neon.dist` should then set `reportUnmatchedIgnoredErrors: true` permanently, since leaving it `false` lets the suppressions rot again. - Add PHPStan Eloquent extensions that preserve `Eloquent\Builder` for non-passthrough methods forwarded to `Query\Builder`, and expose model named scopes on Eloquent builders and relations. The query-builder mixin currently gives fluent calls the wrong builder type, while named scopes are treated as nonexistent methods; these gaps force scopes to split mutation from return and leave `HasDatabaseNotifications` with `method.notFound` suppressions. This will be the repository's first PHPStan extension, so use Larastan as prior art and wire the extensions into `phpstan.neon.dist` without maintaining duplicate query-method or scope lists in `@method` annotations. - Investigate static analysis that turns the coroutine-safety and worker-lifetime rules in `AGENTS.md` into automated checks, so mistakes no longer depend on agent steering and review alone. Decide which tool fits each check: PHPStan rules for detection with type information, Rector in dry-run mode, or another linter. Start with property writes outside the constructor on container-resolved classes (auto-singletons and singletons) that are not `Transient`, scoped, or execution-scoped, since per-request data stored there leaks to concurrent requests in the same worker. Intentional worker-lifetime state such as manager driver caches, registries, and resolved configuration needs an explicit way to opt out, so measure false positives across `src/` before choosing that mechanism. Then evaluate other patterns: static property writes outside boot code and `flushState()`; static caches keyed by request-derived values; request state captured in constructors of classes that are not execution-scoped; `Config::set()` during request handling; child coroutines started inside `DB::transaction()`, which borrow their own pooled connection and therefore run outside the transaction; and raw `sleep()`/`usleep()` in source instead of `Sleep`. Ship the useful rules so applications and private packages can enable the same checks. +- Audit remaining cancellation catches in callback, fallback and I/O paths for cleanup failures that hide cancellation in an exception chain. Trace supported execution paths, fix confirmed cases that retry or continue work after cancellation, and add focused regression tests. Preserve valid native-only checks, cleanup-error reporting and foreign-cancellation ownership; an exact exception check alone is not evidence of a defect. - Investigate a focused PHPStan rule for cancellation checks that miss cleanup exception chains. Detect bare `CanceledException` checks around callbacks or listeners, including `releaseIdleConnections()`, where `Coroutine::causedByCancellation()` is needed to prevent retries or continued work after cancellation. Use the cancellation regression cases to assess detection and false positives; preserve valid native-only checks, cleanup-error reporting and foreign-cancellation boundaries. Integrate useful checks with the coroutine-safety analysis above rather than attempting to infer every ownership decision automatically. - Audit typed input accessors across `InteractsWithData`, Support `DataObject`, and Hypervel Data. Define the accepted integer, float, and boolean forms for each public contract. Extract a neutral Support conversion primitive only if `InteractsWithData` and `DataObject` converge on exactly the same strict semantics, and decide separately whether Hypervel Data should adopt those semantics rather than changing its tested permissive casts as a side effect. diff --git a/src/connection-pool/src/ConnectionPool.php b/src/connection-pool/src/ConnectionPool.php index 0e043a3cb9..e6defb8910 100644 --- a/src/connection-pool/src/ConnectionPool.php +++ b/src/connection-pool/src/ConnectionPool.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\ConnectionPool\UsageTracker; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Log\StdoutLoggerInterface; +use Hypervel\Coroutine\Coroutine; use Hypervel\Coroutine\PoolChannel; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -104,16 +105,18 @@ public function borrow(): ConnectionContract } $this->idleMonitor?->start(); - } catch (CanceledException $cancellation) { - try { - $this->discard($connection); - } catch (CanceledException) { - } catch (Throwable $exception) { - $this->report($exception); + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + try { + $this->discard($connection); + } catch (CanceledException) { + } catch (Throwable $cleanupException) { + $this->report($cleanupException); + } + + throw $exception; } - throw $cancellation; - } catch (Throwable $exception) { $this->report($exception); } @@ -227,7 +230,11 @@ public function close(): void while ($connection = $this->popIdleConnection()) { try { $this->destroyConnection($connection); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { + if (! Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $cancellation ??= $exception; } } @@ -368,9 +375,11 @@ protected function destroyConnection(ConnectionContract $connection): void try { $connection->close(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $this->report($exception); } finally { unset( diff --git a/src/database/src/Concerns/ManagesTransactions.php b/src/database/src/Concerns/ManagesTransactions.php index 5543abbdcc..842d1295ec 100644 --- a/src/database/src/Concerns/ManagesTransactions.php +++ b/src/database/src/Concerns/ManagesTransactions.php @@ -5,11 +5,11 @@ namespace Hypervel\Database\Concerns; use Closure; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Connection; use Hypervel\Database\DeadlockException; use LogicException; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; /** @@ -92,17 +92,21 @@ public function transaction(Closure $callback, int $attempts = 1): mixed $levelBeingCommitted, $this->transactions ); - } catch (CanceledException $cancellation) { - throw $cancellation; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception = $throwable; } try { $this->fireConnectionEvent('committed'); - } catch (CanceledException $cancellation) { - throw $cancellation; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } @@ -124,9 +128,12 @@ public function transaction(Closure $callback, int $attempts = 1): mixed */ protected function handleTransactionException(Throwable $e, int $currentAttempt, int $maxAttempts): void { + // Cleanup may report a deadlock while carrying cancellation; do not retry or reclassify it. + $canceled = Coroutine::causedByCancellation($e); + // Roll back failed nested work before propagating it to the outer retry boundary. // If the server already discarded its savepoints, rollback disconnects the unusable session. - if ($this->causedByConcurrencyError($e) + if (! $canceled && $this->causedByConcurrencyError($e) && $this->transactions > 1) { $e = new DeadlockException( $e->getMessage(), @@ -142,19 +149,17 @@ protected function handleTransactionException(Throwable $e, int $currentAttempt, try { $this->rollBack(); - } catch (CanceledException $cancellation) { - if (! $e instanceof CanceledException) { - throw $cancellation; + } catch (Throwable $cleanup) { + if (! $canceled && Coroutine::causedByCancellation($cleanup)) { + throw $cleanup; } - $cleanedUp = false; - } catch (Throwable) { $cleanedUp = false; } // Only retry after all failed work is closed; a callback may leave manual transaction levels open. // Nested deadlocks already passed the driver's policy, which may not recognize our wrapper. - if ($cleanedUp + if (! $canceled && $cleanedUp && $this->transactions === 0 && ($e instanceof DeadlockException || $this->causedByConcurrencyError($e)) && $currentAttempt < $maxAttempts) { @@ -190,11 +195,11 @@ public function beginTransaction(): void } catch (Throwable $exception) { try { $this->rollBack($previousLevel); - } catch (CanceledException $cancellation) { - if (! $exception instanceof CanceledException) { - throw $cancellation; + } catch (Throwable $cleanup) { + if (Coroutine::causedByCancellation($cleanup) && ! Coroutine::causedByCancellation($exception)) { + throw $cleanup; } - } catch (Throwable) { + // Preserve the transaction publication failure. } @@ -263,17 +268,21 @@ public function commit(): void $levelBeingCommitted, $this->transactions ); - } catch (CanceledException $cancellation) { - throw $cancellation; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception = $throwable; } try { $this->fireConnectionEvent('committed'); - } catch (CanceledException $cancellation) { - throw $cancellation; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } @@ -292,9 +301,11 @@ protected function handleCommitTransactionException(Throwable $e, int $currentAt if ($this->causedByLostConnection($e)) { try { $this->forgetLostConnection(); - } catch (CanceledException $cancellation) { - throw $cancellation; - } catch (Throwable) { + } catch (Throwable $cleanup) { + if (Coroutine::causedByCancellation($cleanup)) { + throw $cleanup; + } + // Preserve the physical commit failure. } @@ -305,17 +316,16 @@ protected function handleCommitTransactionException(Throwable $e, int $currentAt try { $this->rollBack(0); - } catch (CanceledException $cancellation) { - if (! $e instanceof CanceledException) { - throw $cancellation; + } catch (Throwable $cleanup) { + if (Coroutine::causedByCancellation($cleanup) && ! Coroutine::causedByCancellation($e)) { + throw $cleanup; } - $cleanedUp = false; - } catch (Throwable) { $cleanedUp = false; } if ($cleanedUp + && ! Coroutine::causedByCancellation($e) && $this->causedByConcurrencyError($e) && $currentAttempt < $maxAttempts) { return; @@ -352,11 +362,11 @@ public function rollBack(?int $toLevel = null): void // A missing or failed savepoint cannot safely preserve the enclosing transaction. try { $this->disconnect(); - } catch (CanceledException $cancellation) { - if (! $exception instanceof CanceledException) { - throw $cancellation; + } catch (Throwable $cleanup) { + if (Coroutine::causedByCancellation($cleanup) && ! Coroutine::causedByCancellation($exception)) { + throw $cleanup; } - } catch (Throwable) { + // Preserve the rollback failure after detaching the unusable connection. } } @@ -372,17 +382,21 @@ public function rollBack(?int $toLevel = null): void $this->getNameWithReadWriteType() ?? '', $this->transactions ); - } catch (CanceledException $cancellation) { - throw $cancellation; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception = $throwable; } try { $this->fireConnectionEvent('rollingBack'); - } catch (CanceledException $cancellation) { - throw $cancellation; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } @@ -401,9 +415,11 @@ protected function handleRollBackException(Throwable $e): void if ($this->causedByLostConnection($e)) { try { $this->forgetLostConnection(); - } catch (CanceledException $cancellation) { - throw $cancellation; - } catch (Throwable) { + } catch (Throwable $cleanup) { + if (Coroutine::causedByCancellation($cleanup)) { + throw $cleanup; + } + // Preserve the physical rollback failure. } } diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 47bb9689d9..45562c7ba7 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -12,6 +12,7 @@ use Hypervel\Context\NonCopyableContext; use Hypervel\Contracts\Database\Query\Expression as ExpressionContract; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\Events\QueryExecuted; @@ -589,9 +590,11 @@ protected function run(string $query, array $bindings, Closure $callback): mixed $callback ); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $events = $this->events; if ($events?->hasListeners(QueryFailed::class)) { @@ -663,9 +666,13 @@ protected function runStreaming(string $query, array $bindings, Closure $callbac $this->latestReadWriteTypeRetrieved = $readWriteType; } } - } catch (CanceledException|StreamClosedException $exception) { + } catch (StreamClosedException $exception) { throw $exception; } catch (Exception $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + ++$this->errorCount; throw $this->newQueryException($query, $bindings, $exception); @@ -682,9 +689,13 @@ protected function runStreaming(string $query, array $bindings, Closure $callbac yield from $this->handleQueryException($exception, $query, $bindings, $execute); } - } catch (CanceledException|StreamClosedException $exception) { + } catch (StreamClosedException $exception) { throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $events = $this->events; if ($events?->hasListeners(QueryFailed::class)) { @@ -725,9 +736,11 @@ protected function runQueryCallback(string $query, array $bindings, Closure $cal // If an exception occurs when attempting to run a query, we'll format the error // message to include the SQL and, unless masked, its bindings. This provides // more context for the developer than just the database's original error. - catch (CanceledException $exception) { - throw $exception; - } catch (Exception $e) { + catch (Exception $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + ++$this->errorCount; throw $this->newQueryException($query, $bindings, $e); @@ -997,7 +1010,7 @@ public function disconnect(): void $this->resetTransactionState(); } catch (Throwable $throwable) { if ($exception === null - || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException) + || (Coroutine::causedByCancellation($throwable) && ! Coroutine::causedByCancellation($exception)) ) { $exception = $throwable; } @@ -1870,6 +1883,14 @@ public function withoutTablePrefix(Closure $callback): mixed */ abstract public function getServerVersion(): string; + /** + * Determine whether the server supports skipping locked rows. + */ + public function supportsSkipLocked(): bool + { + return false; + } + /** * Register a connection resolver. * diff --git a/src/database/src/ConnectionResolver.php b/src/database/src/ConnectionResolver.php index f2f5784be7..4ebaa6a63e 100755 --- a/src/database/src/ConnectionResolver.php +++ b/src/database/src/ConnectionResolver.php @@ -11,7 +11,6 @@ use Hypervel\Database\Pool\ConnectionLease; use Hypervel\Database\Pool\PooledConnection; use Hypervel\Database\Pool\PoolManager; -use Swoole\Coroutine\CanceledException; use Throwable; use UnitEnum; @@ -358,11 +357,11 @@ protected function discardFailedConnection(ConnectionLease|PooledConnection $own { try { $owner->discard(); - } catch (CanceledException $cancellation) { - if (! $exception instanceof CanceledException) { - throw $cancellation; + } catch (Throwable $cleanupException) { + if (Coroutine::causedByCancellation($cleanupException) && ! Coroutine::causedByCancellation($exception)) { + throw $cleanupException; } - } catch (Throwable) { + // Preserve the connection setup failure. } } @@ -383,7 +382,7 @@ protected function terminateConnections(Closure $terminate): void $terminate($connection); } catch (Throwable $throwable) { if ($exception === null - || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException) + || (Coroutine::causedByCancellation($throwable) && ! Coroutine::causedByCancellation($exception)) ) { $exception = $throwable; } diff --git a/src/database/src/DatabaseTransactionRecord.php b/src/database/src/DatabaseTransactionRecord.php index e0038b774e..962af1256a 100755 --- a/src/database/src/DatabaseTransactionRecord.php +++ b/src/database/src/DatabaseTransactionRecord.php @@ -4,7 +4,7 @@ namespace Hypervel\Database; -use Swoole\Coroutine\CanceledException; +use Hypervel\Coroutine\Coroutine; use Throwable; class DatabaseTransactionRecord @@ -75,9 +75,11 @@ public function executeCallbacks(): void foreach ($this->callbacks as $callback) { try { $callback(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } } @@ -98,10 +100,12 @@ public function executeCallbacksForRollback(): void foreach ($this->callbacksForRollback as $callback) { try { $callback(); - } catch (CanceledException $canceledException) { - $cancellation ??= $canceledException; } catch (Throwable $throwable) { - $exception ??= $throwable; + if (Coroutine::causedByCancellation($throwable)) { + $cancellation ??= $throwable; + } else { + $exception ??= $throwable; + } } } diff --git a/src/database/src/DatabaseTransactionsManager.php b/src/database/src/DatabaseTransactionsManager.php index 3e96b46030..cc9997ad87 100755 --- a/src/database/src/DatabaseTransactionsManager.php +++ b/src/database/src/DatabaseTransactionsManager.php @@ -5,8 +5,8 @@ namespace Hypervel\Database; use Hypervel\Context\CoroutineContext; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Collection; -use Swoole\Coroutine\CanceledException; use Throwable; /** @@ -240,9 +240,11 @@ protected function executeCommitCallbacks(Collection $transactions): void foreach ($transactions as $transaction) { try { $transaction->executeCallbacks(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } } @@ -265,10 +267,12 @@ protected function executeRollbackCallbacks(Collection $transactions): void foreach ($transactions as $transaction) { try { $transaction->executeCallbacksForRollback(); - } catch (CanceledException $canceledException) { - $cancellation ??= $canceledException; } catch (Throwable $throwable) { - $exception ??= $throwable; + if (Coroutine::causedByCancellation($throwable)) { + $cancellation ??= $throwable; + } else { + $exception ??= $throwable; + } } } diff --git a/src/database/src/Eloquent/Attributes/UseEloquentBuilder.php b/src/database/src/Eloquent/Attributes/UseEloquentBuilder.php index e965a73ed2..fa42cb7194 100644 --- a/src/database/src/Eloquent/Attributes/UseEloquentBuilder.php +++ b/src/database/src/Eloquent/Attributes/UseEloquentBuilder.php @@ -6,7 +6,6 @@ use Attribute; use Hypervel\Database\Eloquent\Builder; -use Hypervel\Database\Eloquent\Model; /** * Declare the Eloquent builder class for a model using an attribute. @@ -26,7 +25,7 @@ class UseEloquentBuilder /** * Create a new attribute instance. * - * @param class-string> $builderClass + * @param class-string> $builderClass */ public function __construct( public string $builderClass, diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 198c803ea0..6912a02bfb 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -1719,7 +1719,7 @@ protected function createNestedWhere(array $whereSlice, string $boolean = 'and') /** * Specify relationships that should be eager loaded. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations * @param (Closure(Relation<*,*,*>): mixed)|string|null $callback */ public function with(array|string $relations, Closure|string|null $callback = null): static @@ -1750,7 +1750,7 @@ public function without(mixed $relations): static /** * Set the relationships that should be eager loaded while removing any previously added eager loading specifications. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function withOnly(array|string $relations): static { diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php index eb38ce687c..545d62ad17 100644 --- a/src/database/src/Eloquent/Collection.php +++ b/src/database/src/Eloquent/Collection.php @@ -351,7 +351,7 @@ function (BaseCollection $models) use ($path): void { /** * Load a set of relationships onto the mixed relationship collection. * - * @param array $relations + * @param array): mixed)|string>|string> $relations */ public function loadMorph(string $relation, array $relations): static { @@ -368,7 +368,7 @@ public function loadMorph(string $relation, array $relations): static /** * Load a set of relationship counts onto the mixed relationship collection. * - * @param array $relations + * @param array): mixed)|string>|string> $relations */ public function loadMorphCount(string $relation, array $relations): static { @@ -496,7 +496,7 @@ public function mapWithKeys(callable $callback): BaseCollection /** * Reload a fresh model instance from the database for all the entities. * - * @param array|string $with + * @param array): mixed)|string>|string $with * * @throws MissingAttributeException */ diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index d2bb2a9d36..fd6da400d1 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -45,6 +45,7 @@ use Hypervel\Support\Facades\Crypt; use Hypervel\Support\Facades\Date; use Hypervel\Support\Facades\Hash; +use Hypervel\Support\Number; use Hypervel\Support\Str; use Hypervel\Support\StrCache; use InvalidArgumentException; @@ -1482,10 +1483,18 @@ public function fromFloat(mixed $value): float * * @throws MathException */ - protected function asDecimal(float|string $value, int $decimals): string + protected function asDecimal(float|int|string $value, int $decimals): string { + // Keep integers exact and avoid PHP 8.5's warning when stringifying NAN. + $value = is_float($value) && is_nan($value) ? 'NAN' : (string) $value; + + // Bound scientific-notation expansion without restricting explicit decimal precision. + if (Number::exceedsMaxExponent($value)) { + throw new MathException('Unable to cast value to a decimal.'); + } + try { - return (string) BigDecimal::of((string) $value)->toScale($decimals, RoundingMode::HalfUp); + return (string) BigDecimal::of($value)->toScale($decimals, RoundingMode::HalfUp); } catch (BrickMathException $e) { throw new MathException('Unable to cast value to a decimal.', previous: $e); } @@ -2083,7 +2092,7 @@ public function setRawAttributes(array $attributes, bool $sync = false): static { $this->attributes = $attributes; - // Synchronizing original values must not write back objects from the old row. + // Laravel clears these after syncing, which writes objects from the old row over the replacement values. $this->classCastCache = []; $this->classCastStoredValues = []; $this->attributeCastCache = []; diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index f32a34c77a..cf543182f6 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -294,7 +294,7 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt /** * Cache of resolved custom builder classes per model. * - * @var array, class-string>|false> + * @var array, class-string>|false> */ protected static array $resolvedBuilderClasses = []; @@ -1012,7 +1012,7 @@ public function load(array|string $relations): static /** * Eager load relationships on the polymorphic relation of a model. * - * @param array $relations + * @param array): mixed)|string>|string> $relations */ public function loadMorph(string $relation, array $relations): static { @@ -1118,7 +1118,7 @@ public function loadExists(array|string $relations): static /** * Eager load relationship column aggregation on the polymorphic relation of a model. * - * @param array $relations + * @param array): mixed)|string>|string> $relations */ public function loadMorphAggregate(string $relation, array $relations, Expression|string $column, ?string $function = null): static { @@ -1136,7 +1136,7 @@ public function loadMorphAggregate(string $relation, array $relations, Expressio /** * Eager load relationship counts on the polymorphic relation of a model. * - * @param array $relations + * @param array): mixed)|string>|string> $relations */ public function loadMorphCount(string $relation, array $relations): static { @@ -1146,7 +1146,7 @@ public function loadMorphCount(string $relation, array $relations): static /** * Eager load relationship max column values on the polymorphic relation of a model. * - * @param array $relations + * @param array): mixed)|string>|string> $relations */ public function loadMorphMax(string $relation, array $relations, Expression|string $column): static { @@ -1156,7 +1156,7 @@ public function loadMorphMax(string $relation, array $relations, Expression|stri /** * Eager load relationship min column values on the polymorphic relation of a model. * - * @param array $relations + * @param array): mixed)|string>|string> $relations */ public function loadMorphMin(string $relation, array $relations, Expression|string $column): static { @@ -1166,7 +1166,7 @@ public function loadMorphMin(string $relation, array $relations, Expression|stri /** * Eager load relationship column summations on the polymorphic relation of a model. * - * @param array $relations + * @param array): mixed)|string>|string> $relations */ public function loadMorphSum(string $relation, array $relations, Expression|string $column): static { @@ -1176,7 +1176,7 @@ public function loadMorphSum(string $relation, array $relations, Expression|stri /** * Eager load relationship average column values on the polymorphic relation of a model. * - * @param array $relations + * @param array): mixed)|string>|string> $relations */ public function loadMorphAvg(string $relation, array $relations, Expression|string $column): static { @@ -2151,7 +2151,7 @@ public function newEloquentBuilder(QueryBuilder $query): Builder /** * Resolve the custom Eloquent builder class from the model attributes. * - * @return class-string|false + * @return class-string>|false */ protected function resolveCustomBuilderClass(): string|false { @@ -2299,7 +2299,7 @@ public function jsonSerialize(): mixed /** * Reload a fresh model instance from the database. * - * @param array|string $with + * @param array): mixed)|string>|string $with */ public function fresh(array|string $with = []): ?static { diff --git a/src/database/src/Eloquent/Prunable.php b/src/database/src/Eloquent/Prunable.php index b9d9faefa3..1506f7b03f 100644 --- a/src/database/src/Eloquent/Prunable.php +++ b/src/database/src/Eloquent/Prunable.php @@ -6,9 +6,9 @@ use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Events\ModelsPruned; use LogicException; -use Swoole\Coroutine\CanceledException; use Throwable; trait Prunable @@ -32,9 +32,11 @@ public function pruneAll(int $chunkSize = 1000): int $model->prune(); ++$total; - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + app(ExceptionHandler::class)->report($e); } }); diff --git a/src/database/src/MySqlConnection.php b/src/database/src/MySqlConnection.php index 4e621dcb5d..862412d170 100755 --- a/src/database/src/MySqlConnection.php +++ b/src/database/src/MySqlConnection.php @@ -301,23 +301,22 @@ public function getServerVersion(): string } /** - * Resolve the lock clause supported when popping queued jobs. + * Resolve whether the server supports skipping locked rows. */ - protected function resolveLockForPopping(): bool|string + protected function resolveSupportsSkipLocked(): bool { $version = (string) ($this->getConfig('version') ?? $this->getServerVersion()); $isMaria = str_contains($version, 'MariaDB') || $this->isMaria(); $version = $this->normalizeServerVersion($version); + // Keep this case-sensitive: default Vitess reports a MySQL version with a "-Vitess" + // suffix and must use the MySQL comparison below. Overrides such as "19.0.0-vitess" + // carry the Vitess release number required by this branch. if (Str::contains($version, ['vitess', 'PlanetScale'])) { - return version_compare(Str::before($version, '-'), '19.0', '>=') - ? 'FOR UPDATE SKIP LOCKED' - : true; + return version_compare(Str::before($version, '-'), '19.0', '>='); } - return $isMaria || version_compare($version, '8.0.1', '>=') - ? 'FOR UPDATE SKIP LOCKED' - : true; + return $isMaria || version_compare($version, '8.0.1', '>='); } /** diff --git a/src/database/src/PdoConnection.php b/src/database/src/PdoConnection.php index 4fd6b872aa..0ef405c8de 100755 --- a/src/database/src/PdoConnection.php +++ b/src/database/src/PdoConnection.php @@ -42,9 +42,9 @@ class PdoConnection extends Connection protected int $fetchMode = PDO::FETCH_OBJ; /** - * The lock clause supported by the current physical connection generation. + * Whether the current physical connection generation supports skipping locked rows. */ - protected bool|string|null $lockForPopping = null; + protected ?bool $supportsSkipLocked = null; /** * The maximum number of statement bindings supported by the current physical connection generation. @@ -489,7 +489,7 @@ public function detachPdoResources(Closure $pdo, ?Closure $readPdo): void $this->pdo = $pdo; $this->readPdo = $readPdo; - $this->lockForPopping = null; + $this->supportsSkipLocked = null; $this->maxBindings = null; } @@ -707,7 +707,7 @@ protected function hasDriverResources(): bool public function setPdo(PDO|Closure|null $pdo): static { $this->transactions = 0; - $this->lockForPopping = null; + $this->supportsSkipLocked = null; $this->maxBindings = null; // A dead PDO may warn during destruction; that must not interrupt replacement. @@ -970,21 +970,19 @@ public function getServerVersion(): string } /** - * Get the lock clause supported when popping queued jobs. - * - * @internal + * Determine whether the server supports skipping locked rows. */ - public function lockForPopping(): bool|string + public function supportsSkipLocked(): bool { - return $this->lockForPopping ??= $this->resolveLockForPopping(); + return $this->supportsSkipLocked ??= $this->resolveSupportsSkipLocked(); } /** - * Resolve the lock clause supported when popping queued jobs. + * Resolve whether the server supports skipping locked rows. */ - protected function resolveLockForPopping(): bool|string + protected function resolveSupportsSkipLocked(): bool { - return true; + return false; } /** diff --git a/src/database/src/Pool/ConnectionLease.php b/src/database/src/Pool/ConnectionLease.php index 00a06e69b7..e1ec165252 100644 --- a/src/database/src/Pool/ConnectionLease.php +++ b/src/database/src/Pool/ConnectionLease.php @@ -6,13 +6,13 @@ use Closure; use Hypervel\Context\NonCopyableContext; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Connection; use Hypervel\Database\Connectors\ConnectionFactory; use Hypervel\Database\LeasableConnection; use Hypervel\Database\PdoConnection; use LogicException; use PDO; -use Swoole\Coroutine\CanceledException; use Throwable; /** @@ -229,11 +229,11 @@ protected function discardAfterFailure(Throwable $exception): void { try { $this->pooledConnection?->discard(); - } catch (CanceledException $cancellation) { - if (! $exception instanceof CanceledException) { - throw $cancellation; + } catch (Throwable $cleanupException) { + if (Coroutine::causedByCancellation($cleanupException) && ! Coroutine::causedByCancellation($exception)) { + throw $cleanupException; } - } catch (Throwable) { + // Preserve the acquisition or connection-listener failure. } } diff --git a/src/database/src/Pool/DatabasePool.php b/src/database/src/Pool/DatabasePool.php index 903de2bc6b..3c28e41d98 100644 --- a/src/database/src/Pool/DatabasePool.php +++ b/src/database/src/Pool/DatabasePool.php @@ -17,6 +17,7 @@ use Hypervel\Database\SQLiteDatabase; use Hypervel\Support\Arr; use InvalidArgumentException; +use LogicException; use PDO; use Swoole\Coroutine\CanceledException; use Throwable; @@ -132,6 +133,24 @@ public function getSharedInMemorySqlitePdo(): ?PDO return $this->sharedInMemorySqlitePdo; } + /** + * Restore the shared in-memory database retained between tests. + * + * Tests only. Replaces the database used by every subsequent pool borrower. + */ + public function restoreSharedInMemorySqlitePdo(PDO $pdo): void + { + if ($this->getBorrowedCount() > 0) { + throw new LogicException('Cannot restore the in-memory SQLite database while connections are borrowed.'); + } + + while ($connection = $this->popIdleConnection()) { + $this->destroyConnection($connection); + } + + $this->sharedInMemorySqlitePdo = $pdo; + } + /** * Record the query grammar date format of one of the pool's connections. * diff --git a/src/database/src/PostgresConnection.php b/src/database/src/PostgresConnection.php index 4858d670b2..f4eef2c6fa 100755 --- a/src/database/src/PostgresConnection.php +++ b/src/database/src/PostgresConnection.php @@ -140,15 +140,13 @@ protected function parseUniqueConstraintViolation(Exception $exception): array } /** - * Resolve the lock clause supported when popping queued jobs. + * Resolve whether the server supports skipping locked rows. */ - protected function resolveLockForPopping(): bool|string + protected function resolveSupportsSkipLocked(): bool { $version = (string) ($this->getConfig('version') ?? $this->getServerVersion()); - return version_compare($version, '9.5', '>=') - ? 'FOR UPDATE SKIP LOCKED' - : true; + return version_compare($version, '9.5', '>='); } /** diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index bd9261c078..1705f1b24b 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -215,6 +215,11 @@ class Builder implements BuilderContract */ public string|bool|null $lock = null; + /** + * Whether to skip rows locked by another transaction. + */ + public bool $skipLocked = false; + /** * The query execution timeout in seconds. */ @@ -2975,6 +2980,10 @@ public function unionAll(Closure|self|EloquentBuilder $query): static */ public function lock(string|bool $value = true): static { + if ($this->skipLocked && is_string($value)) { + throw new InvalidArgumentException('Skip locked requires lockForUpdate() or sharedLock().'); + } + $this->lock = $value; $this->useWritePdo(); @@ -2997,6 +3006,20 @@ public function sharedLock(): static return $this->lock(false); } + /** + * Skip rows locked by another transaction when supported by the server. + */ + public function skipLocked(): static + { + if (! is_bool($this->lock)) { + throw new InvalidArgumentException('Skip locked requires lockForUpdate() or sharedLock().'); + } + + $this->skipLocked = true; + + return $this; + } + /** * Set a query execution timeout in seconds. * @@ -3378,6 +3401,8 @@ protected function markKeysetCondition(int $offset, ?string $column = null): voi /** * Retain membership ordering before cursor traversal reverses it. + * + * @internal paging hook for framework and database-driver builders */ public function prepareCursorOrdering(): void { @@ -3399,6 +3424,8 @@ public function prepareCursorOrdering(): void /** * Restore the operation's ordering when a global scope replaces its paging orders. + * + * @internal paging hook for framework and database-driver builders */ public function restoreKeysetOrdersFrom(self $source): bool { @@ -3430,6 +3457,8 @@ public function restoreKeysetOrdersFrom(self $source): bool /** * Sort an ID iteration by its paging column before any other order. + * + * @internal */ public function prioritizeKeysetOrders(bool $limited = false): static { @@ -3455,6 +3484,8 @@ public function prioritizeKeysetOrders(bool $limited = false): static /** * Reject limited ID iteration when another order would choose different rows. + * + * @internal */ public function ensureLimitedIterationOrdersById(string $column, bool $descending): void { @@ -3481,6 +3512,7 @@ public function ensureLimitedIterationOrdersById(string $column, bool $descendin * * @param list $orders * @param array $bindings + * @internal */ public function addCursorPaginationConditions(array $orders, array $bindings): static { @@ -3696,6 +3728,8 @@ protected function getKeysetSelection(string $column): array /** * Get the selected output name for a paging column. + * + * @internal */ public function getKeysetOutputColumn(string $parameter, bool $source = false): string { diff --git a/src/database/src/Query/Grammars/MySqlGrammar.php b/src/database/src/Query/Grammars/MySqlGrammar.php index b67c44ade6..48b8b06b80 100755 --- a/src/database/src/Query/Grammars/MySqlGrammar.php +++ b/src/database/src/Query/Grammars/MySqlGrammar.php @@ -361,6 +361,12 @@ protected function compileUnionOffset(Builder $query, int $offset): string protected function compileLock(Builder $query, bool|string $value): string { if (! is_string($value)) { + if ($query->skipLocked && $this->connection->supportsSkipLocked()) { + // MySQL's legacy shared-lock syntax cannot take SKIP LOCKED; MariaDB requires it. + return ($value ? 'for update' : ($this->connection->isMaria() ? 'lock in share mode' : 'for share')) + . ' skip locked'; + } + return $value ? 'for update' : 'lock in share mode'; } @@ -495,7 +501,7 @@ protected function compileJsonUpdateColumn(string $key, array $values): string */ protected function compileUpdateWithoutJoins(Builder $query, string $table, string $columns, string $where): string { - $sql = parent::compileUpdateWithoutJoins($query, $this->compileUpdatedTable($query, $table), $columns, $where); + $sql = parent::compileUpdateWithoutJoins($query, $this->compileTableWithIndexHint($query, $table), $columns, $where); if (! empty($query->orders)) { $sql .= ' ' . $this->compileOrders($query, $query->orders); @@ -513,14 +519,16 @@ protected function compileUpdateWithoutJoins(Builder $query, string $table, stri */ protected function compileUpdateWithJoins(Builder $query, string $table, string $columns, string $where): string { - return parent::compileUpdateWithJoins($query, $this->compileUpdatedTable($query, $table), $columns, $where); + return parent::compileUpdateWithJoins($query, $this->compileTableWithIndexHint($query, $table), $columns, $where); } /** - * Compile the table an update statement changes, with the query's index hint. + * Compile a write target with the query's index hint. */ - protected function compileUpdatedTable(Builder $query, string $table): string + protected function compileTableWithIndexHint(Builder $query, string $table): string { + // Unlike Laravel, writes honor requested indexes: a scan can lock + // unrelated rows even when the predicate names only specific IDs. return $query->indexHint === null ? $table : $table . ' ' . $this->compileIndexHint($query, $query->indexHint); } @@ -561,6 +569,12 @@ public function prepareBindingsForUpdateWithJoins(array $bindings, array $values */ protected function compileDeleteWithoutJoins(Builder $query, string $table, string $where): string { + // MySQL and older MariaDB versions accept DELETE hints only in the + // multi-table form, which cannot preserve single-table ORDER BY/LIMIT. + if ($query->indexHint !== null && empty($query->orders) && ! isset($query->limit)) { + return $this->compileDeleteWithIndexHint($query, $table, $where); + } + $sql = parent::compileDeleteWithoutJoins($query, $table, $where); // When using MySQL, delete statements may contain order by statements and limits @@ -585,7 +599,9 @@ protected function compileDeleteWithoutJoins(Builder $query, string $table, stri */ protected function compileDeleteWithJoins(Builder $query, string $table, string $where): string { - $sql = parent::compileDeleteWithJoins($query, $table, $where); + $sql = $query->indexHint === null + ? parent::compileDeleteWithJoins($query, $table, $where) + : $this->compileDeleteWithIndexHint($query, $table, $where); if (! empty($query->orders)) { $sql .= ' ' . $this->compileOrders($query, $query->orders); @@ -598,6 +614,18 @@ protected function compileDeleteWithJoins(Builder $query, string $table, string return $sql; } + /** + * Compile a hinted delete with its target separate from the table reference. + */ + protected function compileDeleteWithIndexHint(Builder $query, string $table, string $where): string + { + $alias = Str::afterLast($table, ' as '); + $table = $this->compileTableWithIndexHint($query, $table); + $joins = isset($query->joins) ? ' ' . $this->compileJoins($query, $query->joins) : ''; + + return "delete {$alias} from {$table}{$joins} {$where}"; + } + /** * Compile a query to get the number of open connections for a database. */ diff --git a/src/database/src/Query/Grammars/PostgresGrammar.php b/src/database/src/Query/Grammars/PostgresGrammar.php index 92871e7bdc..6120e656eb 100755 --- a/src/database/src/Query/Grammars/PostgresGrammar.php +++ b/src/database/src/Query/Grammars/PostgresGrammar.php @@ -319,7 +319,11 @@ protected function compileHavingBitwise(array $having): string protected function compileLock(Builder $query, bool|string $value): string { if (! is_string($value)) { - return $value ? 'for update' : 'for share'; + $lock = $value ? 'for update' : 'for share'; + + return $query->skipLocked && $this->connection->supportsSkipLocked() + ? $lock . ' skip locked' + : $lock; } return $value; diff --git a/src/database/src/Query/Grammars/SQLiteGrammar.php b/src/database/src/Query/Grammars/SQLiteGrammar.php index 7d849b531a..14de09b82f 100755 --- a/src/database/src/Query/Grammars/SQLiteGrammar.php +++ b/src/database/src/Query/Grammars/SQLiteGrammar.php @@ -224,15 +224,22 @@ public function compileUpdate(Builder $query, array $values): string /** * Compile an update statement without joins into SQL. - * - * A forced index applies to the updated table, as SQLite's "indexed by" - * clause allows. */ protected function compileUpdateWithoutJoins(Builder $query, string $table, string $columns, string $where): string { + return parent::compileUpdateWithoutJoins($query, $this->compileTableWithIndexHint($query, $table), $columns, $where); + } + + /** + * Compile a plain write target with its requested forced index. + */ + protected function compileTableWithIndexHint(Builder $query, string $table): string + { + // Unlike Laravel, plain writes honor forced indexes. Joined and + // limited writes already apply their hint in the selection subquery. $hint = $query->indexHint === null ? '' : $this->compileIndexHint($query, $query->indexHint); - return parent::compileUpdateWithoutJoins($query, $hint === '' ? $table : "{$table} {$hint}", $columns, $where); + return $hint === '' ? $table : "{$table} {$hint}"; } /** @@ -364,6 +371,14 @@ public function compileDelete(Builder $query): string return parent::compileDelete($query); } + /** + * Compile a plain delete with its requested forced index. + */ + protected function compileDeleteWithoutJoins(Builder $query, string $table, string $where): string + { + return parent::compileDeleteWithoutJoins($query, $this->compileTableWithIndexHint($query, $table), $where); + } + /** * Compile a delete statement with joins or limit into SQL. */ diff --git a/src/docs/ai-sdk.md b/src/docs/ai-sdk.md index d3acc9f82a..9ac2ad957f 100644 --- a/src/docs/ai-sdk.md +++ b/src/docs/ai-sdk.md @@ -264,7 +264,7 @@ Likewise, you must configure a default transcription model to use `Transcription #### OpenAI-Compatible Classification -To use `Classification` with an OpenAI-compatible provider, configure a default classification model. Requests are sent to the endpoint's `/decisions` route in OpenAI's format, so your endpoint must support it: +If your endpoint implements OpenAI's decisions endpoint, you may use [classification](#classification) with an OpenAI-compatible provider. The SDK sends questions to the endpoint's `/decisions` route and supports [image attachments](#classifying-images). Since the SDK does not know which models your endpoint supports, configure a default classification model or pass a model to the `classify` method: ```php 'local' => [ @@ -273,7 +273,7 @@ To use `Classification` with an OpenAI-compatible provider, configure a default 'key' => env('LOCAL_AI_API_KEY'), 'models' => [ 'classification' => [ - 'default' => 'local-classifier', + 'default' => env('LOCAL_AI_MODEL'), ], ], ], @@ -929,7 +929,7 @@ $response = (new SalesCoach)->prompt('Analyze this sales transcript...'); return $response['score']; ``` -When a run ends without structured output, such as when it pauses for [tool approval](#human-tool-approval) or is [stopped](#stopping-a-run) before its first step, the response's `structured` property is an empty array. +When a run ends without structured output, such as when it pauses for [tool approval](#human-tool-approval) or is [stopped](#stopping-runs) before its first step, the response's `structured` property is an empty array. #### Nested Objects @@ -2001,7 +2001,10 @@ class RefundsAgent implements Agent, CanActAsTool, HasTools If a sub-agent does not implement `CanActAsTool`, Hypervel will use the agent's class basename as the tool name and a generic description that asks the parent agent to pass a clear, self-contained task description. Each sub-agent invocation runs in isolation and does not receive the parent agent's conversation history. -When the parent agent is [streaming](#streaming), its sub-agents stream as well, except sub-agents with [structured output](#structured-output), which return their result once complete. As a sub-agent's text grows, the parent periodically emits `ToolResult` events containing the text produced so far. These events are marked as preliminary and are followed by the tool call's final result. Short responses may only produce the final result. You may skip preliminary events when iterating manually: +> [!WARNING] +> Sub-agents do not currently support tools that require [human approval](#human-tool-approval). If a sub-agent's tool requires approval, the sub-agent pauses. However, the parent agent receives a normal tool result and continues its run without notifying your application of the pending approval. Register tools that require approval on the top-level agent. + +When the parent agent is [streaming](#streaming), its sub-agents stream as well, unless they produce [structured output](#structured-output). For streaming sub-agents, the parent agent emits preliminary `ToolResult` events containing the text the sub-agent has produced so far. The tool call's final result follows these events, so you may skip preliminary events when iterating manually. Hypervel batches progress updates as the text grows, so short responses may only produce the final result: ```php use Hypervel\Ai\Streaming\Events\ToolResult; @@ -2081,7 +2084,7 @@ class LogPrompts } ``` -Middleware resolved from class names follows the normal container lifetime rules; shared middleware should be stateless. Middleware wraps each model step, not individual tool calls or child-agent runs. It can [end the run](#stopping-a-run) before the next model call, but does not interrupt a provider request that is already running. +Middleware resolved from class names follows the normal container lifetime rules; shared middleware should be stateless. Middleware wraps each model step, not individual tool calls or child-agent runs. It can [end the run](#stopping-runs) before the next model call, but does not interrupt a provider request that is already running. In addition to the `provider`, `model`, `instructions`, `messages`, and `tools` that are about to be sent, the step exposes the steps that have already completed, their combined usage, and the progress of the run: @@ -2148,7 +2151,7 @@ public function handle(PendingStep $step, Closure $next) } ``` -Middleware must return the result of `$next`, or its own `StepResponse` to answer the step without invoking the model, such as when serving a cached response. Returning any other value will throw a `LogicException`. +Middleware must return the result of `$next`, a `StepResponse`, or `StepResult::stop()`. Return a `StepResponse` to answer the step without invoking the model, such as when serving a cached response. Return `StepResult::stop()` to end the run. Any other return value causes a `LogicException`. You may also attach middleware when invoking an agent, without changing its class. The `withMiddleware` method appends middleware that runs before the agent's declared middleware, in the order supplied: @@ -2163,23 +2166,41 @@ $response = SalesCoach::make() You may pass middleware class names, instances, or closures. Middleware added this way remains on the agent instance for subsequent calls. A stream captures the added middleware when it is created, so adding more middleware afterwards does not change that stream. - -#### Stopping a Run + +#### Stopping Runs -The `Stop` middleware ends a run before its next model call. Its `when` and `unless` methods accept a boolean or a closure that receives the pending step: +To end an agent's run before its next model call, use the `Stop` middleware. For example, you may stop a run when a user presses a "stop" button or the run reaches an input-token budget. The response retains the completed steps and their combined usage: ```php +use Hypervel\Ai\Contracts\HasMiddleware; use Hypervel\Ai\Middleware\Stop; use Hypervel\Ai\PendingStep; +use Hypervel\Support\Facades\Cache; -$response = SalesCoach::make() - ->withMiddleware([ - Stop::when(fn (PendingStep $step) => $step->usage->totalTokens() > 50_000), - ]) - ->prompt('Review this sales call.'); +public function middleware(): array +{ + return [ + // Stop when the user has pressed the "stop" button... + Stop::when(fn (PendingStep $step) => Cache::has("chat:{$this->user->id}:stopped")), + + // Stop when the run has used at least 50,000 input tokens... + Stop::unless(fn (PendingStep $step) => $step->usage->inputTokens < 50_000), + ]; +} ``` -A stopped run returns the steps that completed before it stopped, and a remembered conversation keeps their messages. If no step completed, the response text is empty. A stopped stream ends with the `cancelled` finish reason. +Before each step, `when` stops the run if the condition is true, and `unless` stops the run if the condition is false. Conditions may be booleans or closures that receive the pending step. A stopped streaming run has a `cancelled` finish reason. A remembered conversation keeps the completed steps' messages. If no step completed, the response text is empty. + +Custom middleware may also end the run by returning `StepResult::stop()` instead of the result of `$next`: + +```php +use Hypervel\Ai\Gateway\StepResult; + +public function handle(PendingStep $step, Closure $next) +{ + return $this->cancelled($step) ? StepResult::stop() : $next($step); +} +``` Queued agents and queued AI event listeners require serializable middleware, captured values, and tool instances stored on the agent. Queued prompt listeners also serialize all tools resolved through `withTools`, including declared tools retained by its closure form. For tools that contain closures, such as `SimilaritySearch`, declare them in `tools` and omit `withTools` when using queued prompt listeners. @@ -3224,10 +3245,10 @@ $result = Classification::of('Inspect the product in this photo.', [ ->classify(provider: 'openai'); ``` -When images are given string keys, each image is labeled with its key, so your questions may refer to the images by name: +To refer to a specific image in a question, attach images with string keys. The SDK labels each image with its key: ```php -$result = Classification::of('Two product photos.', [ +$result = Classification::of('Inspect the products in these photos.', [ 'front' => Image::fromPath($front), 'back' => Image::fromPath($back), ]) diff --git a/src/docs/database-testing.md b/src/docs/database-testing.md index 472ecadb5d..269001c7eb 100644 --- a/src/docs/database-testing.md +++ b/src/docs/database-testing.md @@ -65,6 +65,8 @@ If you would like to totally reset the database, you may use the `Hypervel\Found By default, database tests reuse one shared connection per connection name. For tests that issue queries concurrently, use a file-backed or server database and set `database.connections.{name}.pool.testing_enabled` to `true` in your test environment configuration. Each coroutine then borrows its own connection. Use `DatabaseTruncation` or `DatabaseMigrations` for these tests, since writes committed by child coroutines are not rolled back by the test coroutine's transaction. +An in-memory SQLite pool uses a single session, so its database queries run one at a time even when pooling is enabled. Use a file-backed or server database when your test needs simultaneous database sessions. + Transaction-based traits require a connection with real transaction support. `DatabaseTruncation` uses the connection's schema builder to reset the selected tables, allowing custom database drivers to provide their own reset behavior without replacing the native testing traits. Driver authors should follow the [schema-builder extension contracts](/docs/{{version}}/database#extending-database-connections). diff --git a/src/docs/eloquent-mutators.md b/src/docs/eloquent-mutators.md index f61e397e96..984b94c33f 100644 --- a/src/docs/eloquent-mutators.md +++ b/src/docs/eloquent-mutators.md @@ -255,7 +255,7 @@ The `casts` method should return an array where the key is the name of the attri -The `decimal` cast requires a non-negative integer precision, such as `decimal:2`. +The `decimal` cast requires a non-negative integer precision, such as `decimal:2`. Scientific-notation inputs must have an exponent between -1000 and 1000; larger exponents throw a `Hypervel\Support\Exceptions\MathException` to prevent excessive memory allocation. This does not limit the length of an ordinary decimal string. To demonstrate attribute casting, let's cast the `is_admin` attribute, which is stored in our database as an integer (`0` or `1`) to a boolean value: diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md index c7255f2898..338e2fe96e 100644 --- a/src/docs/filesystem.md +++ b/src/docs/filesystem.md @@ -919,6 +919,9 @@ $path = Storage::putFileAs( ); ``` +> [!WARNING] +> Never derive the filename argument of `storeAs` or `putFileAs` from user-supplied input. An attacker could supply a name such as `shell.php`, causing the stored file to be executed if your web server permits PHP execution in that location. Use `hashName()` or a system-generated identifier for the stored filename, and persist the original name separately if you need to display it. + > [!WARNING] > Unprintable and invalid unicode characters will automatically be removed from file paths. Therefore, you may wish to sanitize your file paths before passing them to Hypervel's file storage methods. File paths are normalized using the `League\Flysystem\WhitespacePathNormalizer::normalizePath` method. diff --git a/src/docs/inertia.md b/src/docs/inertia.md index 33a61bcdff..cd719c5d06 100644 --- a/src/docs/inertia.md +++ b/src/docs/inertia.md @@ -236,6 +236,7 @@ Version labels refer to the Inertia client-side libraries. Features marked with - [Code Splitting](#code-splitting) - [Vite Plugin](#code-splitting-vite-plugin) - [Manual Vite](#code-splitting-manual-vite) + - [Preloading the Initial Page](#code-splitting-preloading-the-initial-page) - [DevTools](#devtools) - [Installation](#devtools-installation) - [The Timeline](#devtools-the-timeline) @@ -381,7 +382,7 @@ Next, create a `resources/views/app.blade.php` file. This root template will be ``` -For React applications, it's recommended to include the `@viteReactRefresh` directive before the `@vite` directive to enable Fast Refresh in development. Use `resources/js/app.jsx` in the `@vite` directive when following the React setup below, or `resources/js/app.tsx` for TypeScript. The entry point must match your Vite configuration. +For React applications, it's recommended to include the `@viteReactRefresh` directive before the `@vite` directive to enable Fast Refresh in development. You may also pass the current page component to the `@vite` directive to [preload the initial page](#code-splitting-preloading-the-initial-page). Use `resources/js/app.jsx` in the `@vite` directive when following the React setup below, or `resources/js/app.tsx` for TypeScript. The entry point must match your Vite configuration. The `` component renders a `
` element with an `id` of `app`. This element serves as the mounting point for your JavaScript application. You may customize the `id` by passing a different value to the component. @@ -12283,6 +12284,49 @@ resolve: name => { }, ``` + + +### Preloading the Initial Page + +On the first visit to your application, the browser must download and execute your main JavaScript bundle before it knows which page component to load. You may avoid this extra round trip by passing the current page component to the `@vite` directive in your root template, which preloads the page component and its dependencies alongside your main bundle. + +```blade tab=Vue +@vite(['resources/js/app.js', "resources/js/Pages/{$page['component']}.vue"]) +``` + +```blade tab=React +@vite(['resources/js/app.jsx', "resources/js/Pages/{$page['component']}.jsx"]) +``` + +```blade tab=Svelte +@vite(['resources/js/app.js', "resources/js/Pages/{$page['component']}.svelte"]) +``` + +The `$page` variable is available in your root template and contains the [page object](https://inertiajs.com/docs/v3/core-concepts/the-protocol#the-page-object). + + + +#### Link Headers + +Hypervel's `AddLinkHeadersForPreloadedAssets` middleware adds every preloaded asset to a `Link` response header, allowing the browser to start downloading them before parsing the HTML. Since the dependencies of the page component are included as well, this header may exceed your web server's response header buffer on larger pages, causing the initial page load to fail. Subsequent Inertia visits are not affected, as they return JSON without rendering the root template. + +You may limit the number of assets included in the header using the middleware's `using()` method: + +```php +use App\Http\Middleware\HandleInertiaRequests; +use Hypervel\Foundation\Configuration\Middleware; +use Hypervel\Http\Middleware\AddLinkHeadersForPreloadedAssets; + +->withMiddleware(function (Middleware $middleware) { + $middleware->web(append: [ + HandleInertiaRequests::class, + AddLinkHeadersForPreloadedAssets::using(limit: 10), + ]); +}) +``` + +Assets beyond the limit are still preloaded by the `` tags rendered by the `@vite` directive, so removing the middleware entirely has little impact. Alternatively, you may increase your web server's response header buffer, such as Nginx's `proxy_buffer_size` when proxying requests to Hypervel. + ## DevTools diff --git a/src/docs/mcp.md b/src/docs/mcp.md index be1466cf24..c56140e438 100644 --- a/src/docs/mcp.md +++ b/src/docs/mcp.md @@ -1858,12 +1858,11 @@ Mcp::web('/mcp/weather', WeatherExample::class) ]); ``` + #### New Passport Installation If your application is not already using Passport, follow Passport's [installation and deployment guide](/docs/{{version}}/passport#installation) to add Passport to your application. You should have an `OAuthenticatable` model, new authentication guard, and Passport keys before moving on. -Dynamic client registration applies `passport.middleware` and Passport's named `passport` rate limiter. You may customize that limiter using Passport's [rate limiting configuration](/docs/{{version}}/passport#rate-limiting). - Clients may register up to 10 redirect URIs, each at most 1,024 characters long. Dynamically registered clients are marked as third-party clients, so Passport's `firstParty()` method returns `false` even though they have no owner. This keeps them subject to consent when your application skips consent for its own clients. @@ -1928,6 +1927,17 @@ OAuth2.1 is the documented authentication mechanism in the Model Context Protoco If your application is already using [Sanctum](/docs/{{version}}/sanctum) then adding Passport may be cumbersome. In this instance, we recommend using Sanctum without Passport until you have a clear, necessary requirement to use an MCP client that only supports OAuth. + +#### Rate Limiting OAuth Routes + +As expected by the MCP specification and RFC 7591, the dynamic client registration route registered by `Mcp::oauthRoutes` is publicly accessible, allowing any MCP client to register itself without authentication. Each registration creates a new OAuth client in your database. Hypervel automatically applies `passport.middleware` and Passport's named `passport` rate limiter. Customize that limiter to fit your application's traffic using Passport's [rate limiting configuration](/docs/{{version}}/passport#rate-limiting). + +If your MCP clients do not need to register themselves dynamically, you may disable the client registration route entirely by passing `false` to the `registration` argument: + +```php +Mcp::oauthRoutes(registration: false); +``` + ### Sanctum diff --git a/src/docs/queries.md b/src/docs/queries.md index a2d973428f..f89500c2dd 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -420,7 +420,21 @@ DB::table('orders') ->update(['status' => 'shipped']); ``` -SQLite treats a forced index as a requirement rather than a preference: a query fails if the index does not exist, or if SQLite cannot use it for the query, such as a partial index whose condition the query's constraints do not imply. +MySQL and MariaDB also apply index hints to deletes without an `orderBy` or `limit`, and to joined deletes. Ordered or limited deletes without joins ignore the hint because the supported delete syntax cannot combine them. SQLite applies `forceIndex` to plain deletes; joined writes and writes with a limit or offset apply it to their selection subquery. + +For example, to delete expired orders on MySQL or MariaDB: + +```php +DB::table('orders') + ->forceIndex('primary') + ->whereIn('id', $orderIds) + ->where('status', 'expired') + ->delete(); +``` + +An index hint does not guarantee that a query locks only matching rows. MySQL and MariaDB can still scan when a delete or locking read uses only the primary key. Keep the query's actual ownership and eligibility conditions, and check its execution plan when lock scope matters. + +On MySQL, MariaDB, and SQLite, an applied hint naming an index that does not exist raises a database error. SQLite also fails when it cannot use a forced index for the query, such as a partial index whose condition the query's constraints do not imply. ## Raw Expressions @@ -1911,6 +1925,24 @@ DB::transaction(function () { }); ``` +When several workers read from a queue-like table, you may call `skipLocked` after `lockForUpdate` or `sharedLock` to skip rows locked by another transaction: + +```php +DB::transaction(function () { + $jobs = DB::table('jobs') + ->where('status', 'pending') + ->orderBy('id') + ->limit(100) + ->lockForUpdate() + ->skipLocked() + ->get(); + + // Process or claim the selected jobs within this transaction. +}); +``` + +Skipping locked rows does not give a complete view of the matching data, so use it for work claiming rather than general-purpose reads. The modifier cannot be combined with a custom string lock clause. On unsupported database versions, the query retains its normal locking behavior; SQLite does not acquire row locks. If your operation requires skipping locked rows, check `DB::supportsSkipLocked()` before choosing this approach. + ## Reusable Query Components diff --git a/src/docs/queues.md b/src/docs/queues.md index 3d59e697df..3cd0904095 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -828,6 +828,14 @@ public function middleware(): array } ``` +By default, the middleware adds three seconds to the rate limiter's calculated retry delay. You may adjust this padding without replacing the calculated delay: + +```php +return [(new RateLimited('backups'))->releasePadding(0)]; +``` + +Padding must be non-negative. A delay set with `releaseAfter` takes precedence over the calculated delay and padding. + If you do not want a job to be retried when it is rate limited, you may use the `dontRelease` method: ```php diff --git a/src/docs/validation.md b/src/docs/validation.md index 69b4e48ee3..d92f7465c6 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -772,7 +772,9 @@ Hypervel supports the following primitive casts: Primitive and enum casts preserve `null`. A custom caster receives a present `null` value so it may decide how nullable input should be handled. Missing paths are skipped. -Use validation rules that guarantee each submitted value can be cast. For example, validate JSON strings with the `json` rule, decimals with the `numeric` rule, and formatted dates with the matching `date_format` rule. An incompatible value causes casting to throw. +Use validation rules that guarantee each submitted value can be cast. For example, validate JSON strings with the `json` rule, decimals with the `numeric` rule and appropriate `min` and `max` bounds, and formatted dates with the matching `date_format` rule. An incompatible value causes casting to throw. + +Decimal casts accept scientific notation with exponents between -1000 and 1000. Values outside that range throw a `Hypervel\Support\Exceptions\MathException` to prevent excessive memory allocation, even if they passed the `numeric` rule. This does not limit the length of an ordinary decimal string. Date and time casts accept `DateTimeInterface` instances, Unix timestamps, and date strings. Numeric strings without an explicit format are treated as Unix timestamps. An explicit format takes precedence, so `datetime:!Y` parses the value `2026` as a year rather than a timestamp. @@ -2538,6 +2540,9 @@ Even though you only need to specify the extensions, this rule actually validate This validation rule does not verify agreement between the MIME type and the extension the user assigned to the file. For example, the `mimes:png` validation rule would consider a file containing valid PNG content to be a valid PNG image, even if the file is named `photo.txt`. If you would like to validate the user-assigned extension of the file, you may use the [extensions](#rule-extensions) rule. +> [!WARNING] +> Passing image validation does not remove embedded code. A file that begins with GIF or JPEG magic bytes can pass `mimes:gif` or `mimes:jpg` validation while also containing a PHP payload. Always use a system-generated filename (e.g. `hashName()`) when storing uploaded files, and configure your web server to deny PHP execution inside storage directories. + #### min:_value_ diff --git a/src/foundation/src/Http/Concerns/HasCasts.php b/src/foundation/src/Http/Concerns/HasCasts.php index 8a71f6f825..ea9af96e52 100644 --- a/src/foundation/src/Http/Concerns/HasCasts.php +++ b/src/foundation/src/Http/Concerns/HasCasts.php @@ -18,6 +18,7 @@ use Hypervel\Support\Exceptions\MathException; use Hypervel\Support\Facades\Date; use Hypervel\Support\Json; +use Hypervel\Support\Number; use Hypervel\Validation\ValidationData; use InvalidArgumentException; use stdClass; @@ -443,8 +444,16 @@ protected function fromFloat(mixed $value): float */ protected function asDecimal(mixed $value, int $decimals): string { + // Avoid PHP 8.5's warning when stringifying NAN before Brick rejects it. + $value = is_float($value) && is_nan($value) ? 'NAN' : (string) $value; + + // Validated numeric input can still contain exponents that exhaust worker memory. + if (Number::exceedsMaxExponent($value)) { + throw new MathException('Unable to cast value to a decimal.'); + } + try { - return (string) BigDecimal::of((string) $value)->toScale($decimals, RoundingMode::HalfUp); + return (string) BigDecimal::of($value)->toScale($decimals, RoundingMode::HalfUp); } catch (BrickMathException $exception) { throw new MathException('Unable to cast value to a decimal.', previous: $exception); } diff --git a/src/foundation/src/Testing/DatabaseConnectionResolver.php b/src/foundation/src/Testing/DatabaseConnectionResolver.php index 24fc4c4c30..56fe83e95d 100644 --- a/src/foundation/src/Testing/DatabaseConnectionResolver.php +++ b/src/foundation/src/Testing/DatabaseConnectionResolver.php @@ -16,6 +16,7 @@ use Hypervel\Database\ConnectionResolver; use Hypervel\Database\Pool\DatabasePool; use Hypervel\Database\Pool\PooledConnection; +use PDO; use Throwable; use UnitEnum; @@ -307,6 +308,31 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa return $connection; } + /** + * Restore a pooled in-memory database, returning false when pooling is disabled. + * + * Tests only. Replaces the database shared by this application's pool borrowers. + */ + public function restoreInMemoryPdoIfPooled(UnitEnum|string|null $name, PDO $pdo): bool + { + $connectionName = ConnectionName::parse($this->connectionName($name)); + + if (! $this->poolEnabled($connectionName)) { + return false; + } + + $pool = $this->poolManager->pool($connectionName->requested); + + if ($pool->getSharedInMemorySqlitePdo() === $pdo) { + return true; + } + + static::releaseIdleConnections(); + $pool->restoreSharedInMemorySqlitePdo($pdo); + + return true; + } + /** * Determine whether this test uses coroutine-owned pooled connections. */ diff --git a/src/foundation/src/Testing/DatabaseTruncation.php b/src/foundation/src/Testing/DatabaseTruncation.php index 2b224b5291..60e59a2349 100644 --- a/src/foundation/src/Testing/DatabaseTruncation.php +++ b/src/foundation/src/Testing/DatabaseTruncation.php @@ -106,11 +106,18 @@ protected function restoreInMemoryDatabases(): void $database = $this->app->make('db'); $defaultConnection = $this->app->make('config')->string('database.default'); + $resolver = $this->app->bound('db.resolver') ? $this->app->make('db.resolver') : null; foreach ($this->connectionsToTruncate() as $name) { $connectionName = $name ?? $defaultConnection; if (isset(RefreshDatabaseState::$inMemoryConnections[$connectionName])) { + // A lease loses setPdo() on release; restore the pool's shared database instead. + if ($resolver instanceof DatabaseConnectionResolver + && $resolver->restoreInMemoryPdoIfPooled($name, RefreshDatabaseState::$inMemoryConnections[$connectionName])) { + continue; + } + // The PDO outlives its original application; the dispatcher must not. $connection = $database->connection($name); diff --git a/src/foundation/src/Testing/LazilyRefreshDatabase.php b/src/foundation/src/Testing/LazilyRefreshDatabase.php index 52dc6d6af7..25b6c9fbfd 100644 --- a/src/foundation/src/Testing/LazilyRefreshDatabase.php +++ b/src/foundation/src/Testing/LazilyRefreshDatabase.php @@ -33,6 +33,11 @@ public function refreshDatabase(): void */ protected function setUpLazilyRefreshDatabaseInCoroutine(): void { + // Restore before registering callbacks: the first query already pins its pooled session. + if ($this->usingInMemoryDatabases()) { + $this->restoreInMemoryDatabase(); + } + $database = $this->app->make('db'); $callback = function (): void { diff --git a/src/foundation/src/Testing/RefreshDatabase.php b/src/foundation/src/Testing/RefreshDatabase.php index 5efba22eba..a4ea2dc42d 100644 --- a/src/foundation/src/Testing/RefreshDatabase.php +++ b/src/foundation/src/Testing/RefreshDatabase.php @@ -57,11 +57,18 @@ public function refreshDatabase(): void protected function restoreInMemoryDatabase(): void { $database = $this->app->make('db'); + $resolver = $this->app->bound('db.resolver') ? $this->app->make('db.resolver') : null; foreach ($this->connectionsToTransact() as $name) { $connectionName = $name ?? $this->getRefreshConnection(); if (isset(RefreshDatabaseState::$inMemoryConnections[$connectionName])) { + // A lease loses setPdo() on release; restore the pool's shared database instead. + if ($resolver instanceof DatabaseConnectionResolver + && $resolver->restoreInMemoryPdoIfPooled($name, RefreshDatabaseState::$inMemoryConnections[$connectionName])) { + continue; + } + $connection = $database->connection($name); if (! $connection instanceof PdoConnection) { diff --git a/src/jwt/src/Providers/Lcobucci.php b/src/jwt/src/Providers/Lcobucci.php index 86268a70b1..cf791e877e 100644 --- a/src/jwt/src/Providers/Lcobucci.php +++ b/src/jwt/src/Providers/Lcobucci.php @@ -39,7 +39,7 @@ class Lcobucci extends Provider implements ProviderContract /** * The configuration that signs and verifies tokens. */ - protected Configuration $config; + protected ?Configuration $config; /** * Whether the provider has no private key, so it can only verify tokens. @@ -86,6 +86,8 @@ public function __construct( */ public function encode(array $payload): string { + $config = $this->config(); + if ($this->verifiesOnly) { throw new JwtException('Private key is not set.'); } @@ -94,7 +96,7 @@ public function encode(array $payload): string try { return $builder - ->getToken($this->config->signer(), $this->config->signingKey()) + ->getToken($config->signer(), $config->signingKey()) ->toString(); } catch (Exception $e) { throw new JwtException('Could not create token: ' . $e->getMessage(), $e->getCode(), $e); @@ -108,9 +110,11 @@ public function encode(array $payload): string */ public function decode(#[SensitiveParameter] string $token): array { + $config = $this->config(); + try { /** @var Plain */ - $token = $this->config->parser()->parse($token); + $token = $config->parser()->parse($token); } catch (Throwable $exception) { throw new TokenInvalidException( 'Could not decode token: ' . $exception->getMessage(), @@ -120,7 +124,7 @@ public function decode(#[SensitiveParameter] string $token): array } try { - $verified = $this->config->validator()->validate($token, ...$this->config->validationConstraints()); + $verified = $config->validator()->validate($token, ...$config->validationConstraints()); } catch (ConversionFailed) { // ECDSA verification rejects a signature of the wrong length before comparing it. $verified = false; @@ -141,7 +145,7 @@ public function decode(#[SensitiveParameter] string $token): array */ protected function getBuilderFromClaims(array $payload): Builder { - $builder = $this->config->builder(); + $builder = $this->config()->builder(); foreach ($payload as $key => $value) { switch ($key) { @@ -192,6 +196,27 @@ protected function getDateFromClaim(int|string|DateTimeInterface|DateInterval $v return DateTimeImmutable::createFromFormat('U', (string) $value); } + /** + * Get the configuration, rebuilding it if the algorithm or keys have changed. + */ + protected function config(): Configuration + { + return $this->config ?? $this->configure(); + } + + /** + * Discard the configuration so it and the signer are rebuilt on next use. + * + * @return $this + */ + protected function resetConfig(): static + { + // Algorithm and keys can be changed in either order before the provider is used. + $this->config = null; + + return $this; + } + /** * Build the configuration. */ @@ -218,24 +243,24 @@ protected function buildConfig(): Configuration * * @throws JwtException */ - protected function configure(?Configuration $config = null): void + protected function configure(?Configuration $config = null): Configuration { // buildConfig() reads the signer and the verification-only flag. $this->signer = $this->getSigner(); $this->verifiesOnly = $config === null && $this->isAsymmetric() && ! $this->getPrivateKey(); - $this->config = $config ?? $this->buildConfig(); + + return $this->config = $config ?? $this->buildConfig(); } /** - * Rebuild the signer and configuration after a setting changes. + * Discard the configuration after a setting changes. * - * A configuration given to the constructor is replaced by one built from the new settings. - * - * @throws JwtException + * A configuration given to the constructor is replaced by one built from the new settings + * on next use, when invalid settings also raise their configuration error. */ protected function onConfigurationChanged(): void { - $this->configure(); + $this->resetConfig(); } /** diff --git a/src/jwt/src/Providers/Provider.php b/src/jwt/src/Providers/Provider.php index f5bf2ec283..f9d62a8d5b 100644 --- a/src/jwt/src/Providers/Provider.php +++ b/src/jwt/src/Providers/Provider.php @@ -161,7 +161,7 @@ abstract protected function isAsymmetric(): bool; * Hook fired after any of algo/secret/keys is mutated. * * Subclasses that cache derived state (signers, configuration objects) - * override this to rebuild it so the new values actually take effect. + * override this to invalidate it so the new values take effect on next use. */ protected function onConfigurationChanged(): void { diff --git a/src/pagination/src/AbstractCursorPaginator.php b/src/pagination/src/AbstractCursorPaginator.php index 420394512a..d2192cee61 100644 --- a/src/pagination/src/AbstractCursorPaginator.php +++ b/src/pagination/src/AbstractCursorPaginator.php @@ -10,8 +10,10 @@ use Exception; use Hypervel\Contracts\Support\Htmlable; use Hypervel\Contracts\View\Factory; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\Pivot; +use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Http\Resources\Json\JsonResource; use Hypervel\Support\Arr; use Hypervel\Support\Collection; @@ -360,7 +362,7 @@ protected function buildFragment(): string /** * Load a set of relationships onto the mixed relationship collection. * - * @param array> $relations + * @param array): mixed)|string>|string> $relations * @return $this */ public function loadMorph(string $relation, array $relations): static @@ -374,7 +376,7 @@ public function loadMorph(string $relation, array $relations): static /** * Load a set of relationship counts onto the mixed relationship collection. * - * @param array> $relations + * @param array): mixed)|string>|string> $relations * @return $this */ public function loadMorphCount(string $relation, array $relations): static diff --git a/src/pagination/src/AbstractPaginator.php b/src/pagination/src/AbstractPaginator.php index fb02d12f2c..72f003aada 100644 --- a/src/pagination/src/AbstractPaginator.php +++ b/src/pagination/src/AbstractPaginator.php @@ -9,6 +9,8 @@ use Hypervel\Contracts\Support\CanBeEscapedWhenCastToString; use Hypervel\Contracts\Support\Htmlable; use Hypervel\Contracts\View\Factory; +use Hypervel\Database\Eloquent\Builder; +use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\Traits\ForwardsCalls; @@ -284,7 +286,7 @@ protected function buildFragment(): string /** * Load a set of relationships onto the mixed relationship collection. * - * @param array> $relations + * @param array): mixed)|string>|string> $relations * @return $this */ public function loadMorph(string $relation, array $relations): static @@ -298,7 +300,7 @@ public function loadMorph(string $relation, array $relations): static /** * Load a set of relationship counts onto the mixed relationship collection. * - * @param array> $relations + * @param array): mixed)|string>|string> $relations * @return $this */ public function loadMorphCount(string $relation, array $relations): static diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index 89c25988d5..6a509dcdbb 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -12,12 +12,12 @@ use Hypervel\Contracts\Queue\PublishesOutboxMessages; use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Coroutine\Coroutine; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Database\DetectsConcurrencyErrors; use Hypervel\Database\DetectsLostConnections; -use Hypervel\Database\PdoConnection; use Hypervel\Database\Query\Builder; use Hypervel\Database\QueryException; use Hypervel\Queue\Concerns\InsertsDatabaseRows; @@ -634,8 +634,8 @@ protected function getLockForPopping(): bool|string { $connection = $this->getDatabase(); - return $connection instanceof PdoConnection - ? $connection->lockForPopping() + return $connection instanceof Connection && $connection->supportsSkipLocked() + ? 'FOR UPDATE SKIP LOCKED' : true; } diff --git a/src/queue/src/Middleware/RateLimited.php b/src/queue/src/Middleware/RateLimited.php index 326eeeb363..a0c72d4740 100644 --- a/src/queue/src/Middleware/RateLimited.php +++ b/src/queue/src/Middleware/RateLimited.php @@ -11,6 +11,7 @@ use Hypervel\RateLimiter\RateLimiter; use Hypervel\RateLimiter\Unlimited; use Hypervel\Support\Collection; +use InvalidArgumentException; use UnitEnum; use function Hypervel\Support\enum_value; @@ -37,6 +38,11 @@ class RateLimited */ public DateTimeInterface|int|null $releaseAfter = null; + /** + * The extra seconds added to the rate limiter's retry delay. + */ + protected int $releasePadding = 3; + /** * Indicates if the job should be released if the limit is exceeded. */ @@ -87,8 +93,9 @@ protected function handleJob(mixed $job, callable $next, array $limits, Limiter { foreach ($limiter->consumeMany(array_values($limits), $this->limiterName) as $result) { if ($result->denied()) { + // An explicit zero delay requests an immediate retry rather than the calculated delay. return $this->shouldRelease - ? $job->release($this->releaseAfter ?? $result->retryAfter() + 3) + ? $job->release($this->releaseAfter ?? $result->retryAfter() + $this->releasePadding) : false; } } @@ -106,6 +113,20 @@ public function releaseAfter(DateTimeInterface|int $releaseAfter): static return $this; } + /** + * Set the extra seconds added to the rate limiter's retry delay. + */ + public function releasePadding(int $seconds): static + { + if ($seconds < 0) { + throw new InvalidArgumentException('Release padding must not be negative.'); + } + + $this->releasePadding = $seconds; + + return $this; + } + /** * Do not release the job back to the queue if the limit is exceeded. */ @@ -137,6 +158,7 @@ public function __sleep(): array 'limiterName', 'storeName', 'releaseAfter', + 'releasePadding', 'shouldRelease', ]; } diff --git a/src/queue/src/Outbox/DatabaseOutboxRepository.php b/src/queue/src/Outbox/DatabaseOutboxRepository.php index a9e2c375ba..5864b3dbf6 100644 --- a/src/queue/src/Outbox/DatabaseOutboxRepository.php +++ b/src/queue/src/Outbox/DatabaseOutboxRepository.php @@ -4,9 +4,10 @@ namespace Hypervel\Queue\Outbox; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; -use Hypervel\Database\PdoConnection; +use Hypervel\Database\MySqlConnection; use Hypervel\Database\Query\Builder; use Hypervel\Database\Query\Expression; use Hypervel\Queue\Concerns\InsertsDatabaseRows; @@ -84,12 +85,12 @@ public function claim(string $database, int $limit): array $now = $this->currentTime(); $token = (string) Str::uuid(); $expiresAt = $now + $this->retryAfter; - $lock = $connection instanceof PdoConnection ? $connection->lockForPopping() : true; + $skipLocked = $connection instanceof Connection && $connection->supportsSkipLocked(); $limit = min($limit, $this->maxBindings($connection) - 3); - if ($lock === 'FOR UPDATE SKIP LOCKED' && $connection->getDriverName() === 'pgsql') { + if ($skipLocked && $connection->getDriverName() === 'pgsql') { $rows = $this->claimOnPostgres($connection, $limit, $now, $expiresAt, $token); - } elseif ($lock === 'FOR UPDATE SKIP LOCKED') { + } elseif ($skipLocked) { $rows = $this->claimWithSkipLocked($connection, $limit, $now, $expiresAt, $token); } else { $rows = $this->claimWithConditionalUpdate($connection, $limit, $now, $expiresAt, $token); @@ -142,31 +143,27 @@ protected function claimOnPostgres(ConnectionInterface $connection, int $limit, */ protected function claimWithSkipLocked(ConnectionInterface $connection, int $limit, int $now, int $expiresAt, string $token): array { + // Moving claimed rows to a future deadline can deadlock on the due-index gap. return $connection->transaction(function () use ($connection, $limit, $now, $expiresAt, $token): array { - $rows = $this->query($connection) + // Keep the locking read covered by the due index. Fetching payloads + // here can choose a filesort that locks rows beyond the batch limit. + $candidates = $this->query($connection) ->where('available_at', '<=', $now) ->orderBy('available_at')->orderBy('id') - ->limit($limit)->lock('FOR UPDATE SKIP LOCKED') - ->get()->all(); + ->limit($limit)->lockForUpdate()->skipLocked() + ->pluck('available_at', 'id')->all(); - if ($rows === []) { + if ($candidates === []) { return []; } - $this->query($connection)->whereIn('id', array_column($rows, 'id'))->increment('attempts', 1, [ + $this->whereIds($this->query($connection), array_keys($candidates))->increment('attempts', 1, [ 'lease_token' => $token, 'available_at' => $expiresAt, ]); - foreach ($rows as $row) { - $row->eligible_at = $row->available_at; - $row->available_at = $expiresAt; - $row->lease_token = $token; - ++$row->attempts; - } - - return $rows; - }); + return $this->getClaimedRows($connection, $candidates, $token); + }, attempts: 3); } /** @@ -186,11 +183,22 @@ protected function claimWithConditionalUpdate(ConnectionInterface $connection, i } $ids = array_keys($candidates); - $this->query($connection) - ->whereIn('id', $ids)->where('available_at', '<=', $now) + $this->whereIds($this->query($connection), $ids)->where('available_at', '<=', $now) ->increment('attempts', 1, ['lease_token' => $token, 'available_at' => $expiresAt]); - $rows = $this->query($connection)->whereIn('id', $ids)->where('lease_token', $token)->get()->all(); + return $this->getClaimedRows($connection, $candidates, $token); + } + + /** + * Read the rows owned by this claim with their original eligibility. + * + * @param array $candidates + * @return list + */ + protected function getClaimedRows(ConnectionInterface $connection, array $candidates, string $token): array + { + $rows = $this->whereIds($this->query($connection), array_keys($candidates)) + ->where('lease_token', $token)->get()->all(); foreach ($rows as $row) { $row->eligible_at = $candidates[$row->id]; @@ -215,13 +223,15 @@ public function settle(string $database, array $messages, OutboxBatchResult $res $settled = 0; $maxBindings = $this->maxBindings($connection); + // Keep the lease predicate: besides fencing stale relays, it + // avoids an ID-only covering scan despite the primary-index hint. foreach (array_chunk($result->accepted, $maxBindings - 1) as $ids) { - $settled += $this->query($connection)->where('lease_token', $token)->whereIn('id', $ids)->delete(); + $settled += $this->whereIds($this->query($connection)->where('lease_token', $token), $ids)->delete(); } foreach (array_chunk($result->deferred, $maxBindings - 2) as $ids) { $grammar = $connection->getQueryGrammar(); - $settled += $this->query($connection)->where('lease_token', $token)->whereIn('id', $ids)->update([ + $settled += $this->whereIds($this->query($connection)->where('lease_token', $token), $ids)->update([ 'lease_token' => null, 'available_at' => new Expression($grammar->wrap('deliver_at')), 'attempts' => new Expression($grammar->wrap('attempts') . ' - 1'), @@ -247,23 +257,24 @@ protected function releaseFailed(ConnectionInterface $connection, string $token, $deadlines = array_map(static fn (int $delay): int => $now + random_int(max(1, (int) ceil($delay / 2)), $delay), self::RETRY_DELAYS); $error = mb_substr($error, 0, 4096); $grammar = $connection->getQueryGrammar(); - $table = $grammar->wrapTable($this->table); $attempts = $grammar->wrap('attempts'); $cases = []; - foreach (array_keys($deadlines) as $index) { - $cases[] = $index === count($deadlines) - 1 ? 'ELSE ?' : 'WHEN ' . $attempts . ' <= ' . ($index + 1) . ' THEN ?'; + // Only internally generated integers enter this expression; error + // text, lease tokens and IDs remain bound by the query builder. + foreach ($deadlines as $index => $deadline) { + $cases[] = $index === count($deadlines) - 1 ? "ELSE {$deadline}" : 'WHEN ' . $attempts . ' <= ' . ($index + 1) . " THEN {$deadline}"; } $case = 'CASE ' . implode(' ', $cases) . ' END'; $affected = 0; - foreach (array_chunk($ids, $this->maxBindings($connection) - count($deadlines) - 2) as $chunk) { - $parameters = implode(', ', array_fill(0, count($chunk), '?')); - $affected += $connection->update( - "UPDATE {$table} SET available_at = {$case}, lease_token = NULL, last_error = ? WHERE lease_token = ? AND id IN ({$parameters})", - [...$deadlines, $error, $token, ...$chunk], - ); + foreach (array_chunk($ids, $this->maxBindings($connection) - 3) as $chunk) { + $affected += $this->whereIds($this->query($connection)->where('lease_token', $token), $chunk)->update([ + 'available_at' => new Expression($case), + 'lease_token' => null, + 'last_error' => $error, + ]); } return $affected; @@ -355,7 +366,7 @@ public function retryAll(string $database): int break; } - $affected += $this->retryable($connection, $now)->whereIn('id', $ids)->update([ + $affected += $this->whereIds($this->retryable($connection, $now), $ids)->update([ 'available_at' => $now, 'lease_token' => null, 'last_error' => null, @@ -393,6 +404,22 @@ protected function withoutLiveLease(Builder $query, int $now): Builder }); } + /** + * Restrict a query to selected IDs using InnoDB's primary index. + * + * @param list $ids + */ + protected function whereIds(Builder $query, array $ids): Builder + { + // An InnoDB scan can deadlock after disjoint SKIP LOCKED claims, or + // prevent settlement after messages have already been published. + if ($query->getConnection() instanceof MySqlConnection) { + $query->forceIndex('PRIMARY'); + } + + return $query->whereIn('id', $ids); + } + /** * Query the configured table on the writer, including for operator mutations. */ diff --git a/src/support/src/CarbonImmutable.php b/src/support/src/CarbonImmutable.php index ddd46adf33..ce147e8d10 100644 --- a/src/support/src/CarbonImmutable.php +++ b/src/support/src/CarbonImmutable.php @@ -19,6 +19,7 @@ * @method static addMinute() * @method static addMinutes(int|float $value = 1) * @method static addMonth() + * @method static addMonths(int|float $value = 1, ?int $anchorDay = null) * @method static addSecond() * @method static addSeconds(int|float $value = 1) * @method static ceilSecond(float $precision = 1) diff --git a/src/support/src/Facades/DB.php b/src/support/src/Facades/DB.php index 4b37a64297..87b1da7b6b 100644 --- a/src/support/src/Facades/DB.php +++ b/src/support/src/Facades/DB.php @@ -110,6 +110,7 @@ * @method static \Hypervel\Database\Connection setTablePrefix(string $prefix) * @method static \Hypervel\Database\Connection setTransactionManager(\Hypervel\Database\DatabaseTransactionsManager $manager) * @method static bool statement(string $query, array $bindings = []) + * @method static bool supportsSkipLocked() * @method static \Hypervel\Database\Query\Builder table(\Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|\UnitEnum|string $table, string|null $as = null) * @method static int|null threadCount() * @method static float totalQueryDuration() diff --git a/src/support/src/Json.php b/src/support/src/Json.php index e105262f27..c7131eaf82 100644 --- a/src/support/src/Json.php +++ b/src/support/src/Json.php @@ -42,6 +42,58 @@ public static function decode(string $json, bool $assoc = true, int $depth = sel return json_decode($json, $assoc, self::nativeDecodingDepth($depth), $flags | JSON_THROW_ON_ERROR); } + /** + * Decode JSON with every number preserved as its original string. + * + * @throws JsonException + */ + public static function decodeNumbersAsStrings(string $json, bool $assoc = true, int $depth = self::MAXIMUM_NESTING_DEPTH): mixed + { + // Quoting numbers before validation could make malformed JSON valid, such as {1:2}. + if (! self::validate($json, $depth)) { + throw new JsonException(json_last_error_msg(), json_last_error()); + } + + $length = strlen($json); + $offset = 0; + $start = 0; + $quoted = ''; + + while ($offset < $length) { + $offset += strcspn($json, '"-0123456789', $offset); + + if ($offset === $length) { + break; + } + + if ($json[$offset] === '"') { + ++$offset; + + while (true) { + $offset += strcspn($json, '"\\', $offset); + + if ($json[$offset] === '"') { + ++$offset; + + break; + } + + $offset += 2; + } + + continue; + } + + $numberLength = strspn($json, '0123456789+-.eE', $offset); + $quoted .= substr($json, $start, $offset - $start) + . '"' . substr($json, $offset, $numberLength) . '"'; + $offset += $numberLength; + $start = $offset; + } + + return self::decode($quoted . substr($json, $start), $assoc, $depth); + } + /** * Validate a JSON string. * diff --git a/src/support/src/Number.php b/src/support/src/Number.php index 2fbbcf28c6..6f92c12db4 100644 --- a/src/support/src/Number.php +++ b/src/support/src/Number.php @@ -16,6 +16,11 @@ class Number { use Macroable; + /** + * The largest scientific-notation exponent expanded by default. + */ + public const int MAX_EXPONENT = 1000; + /** * The default locale. */ @@ -64,6 +69,32 @@ public static function format(float|int $number, ?int $precision = null, ?int $m return $formatter->format(static::withoutNegativeZero($number, $formatter->format(...))); } + /** + * Get the exponent of a numeric string written in scientific notation. + * + * @internal + */ + public static function exponent(string $number): ?int + { + if (! is_numeric($number) || ($position = stripos($number, 'e')) === false) { + return null; + } + + return (int) substr($number, $position + 1); + } + + /** + * Determine if a numeric string exceeds the default scientific-notation exponent range. + * + * @internal + */ + public static function exceedsMaxExponent(string $number): bool + { + $exponent = static::exponent($number); + + return $exponent !== null && ($exponent > static::MAX_EXPONENT || $exponent < -static::MAX_EXPONENT); + } + /** * Parse the given string according to the specified format type. */ diff --git a/src/validation/src/Concerns/ReplacesAttributes.php b/src/validation/src/Concerns/ReplacesAttributes.php index 0e6e274e4d..b5cccebd1c 100644 --- a/src/validation/src/Concerns/ReplacesAttributes.php +++ b/src/validation/src/Concerns/ReplacesAttributes.php @@ -5,6 +5,7 @@ namespace Hypervel\Validation\Concerns; use Hypervel\Support\Arr; +use Hypervel\Support\Exceptions\MathException; use Hypervel\Support\Str; trait ReplacesAttributes @@ -453,7 +454,14 @@ protected function replaceGt(string $message, string $attribute, string $rule, a return str_replace(':value', $this->getDisplayableAttribute($parameters[0]), $message); } - return str_replace(':value', (string) $this->getSize($attribute, $value), $message); + try { + $size = $this->getSize($attribute, $value); + } catch (MathException) { + // Rejected exponents fail validation; rendering their message must not throw. + $size = $this->trim($value); + } + + return str_replace(':value', (string) $size, $message); } /** diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php index 2def0ec913..9cb1f7588c 100644 --- a/src/validation/src/Concerns/ValidatesAttributes.php +++ b/src/validation/src/Concerns/ValidatesAttributes.php @@ -24,6 +24,7 @@ use Hypervel\Support\Exceptions\MathException; use Hypervel\Support\Facades\Date; use Hypervel\Support\Json; +use Hypervel\Support\Number; use Hypervel\Support\Str; use Hypervel\Validation\FakeDnsGetRecordWrapper; use Hypervel\Validation\Rules\Exists; @@ -1358,7 +1359,9 @@ public function validateGt(string $attribute, mixed $value, mixed $parameters): if ($this->hasRule($attribute, $this->numericRules) && is_numeric($value) && is_numeric($comparedToValue)) { try { - return BigNumber::of((string) $this->trim($value))->isGreaterThan((string) $this->trim($comparedToValue)); + // Either field can contain scientific notation that expands beyond the allowed range. + return BigNumber::of((string) $this->ensureExponentWithinAllowedRange($attribute, $this->trim($value))) + ->isGreaterThan((string) $this->ensureExponentWithinAllowedRange($parameters[0], $this->trim($comparedToValue))); } catch (MathException|BrickMathException) { return false; } @@ -1402,7 +1405,9 @@ public function validateLt(string $attribute, mixed $value, mixed $parameters): if ($this->hasRule($attribute, $this->numericRules) && is_numeric($value) && is_numeric($comparedToValue)) { try { - return BigNumber::of((string) $this->trim($value))->isLessThan((string) $this->trim($comparedToValue)); + // Either field can contain scientific notation that expands beyond the allowed range. + return BigNumber::of((string) $this->ensureExponentWithinAllowedRange($attribute, $this->trim($value))) + ->isLessThan((string) $this->ensureExponentWithinAllowedRange($parameters[0], $this->trim($comparedToValue))); } catch (MathException|BrickMathException) { return false; } @@ -1446,7 +1451,9 @@ public function validateGte(string $attribute, mixed $value, mixed $parameters): if ($this->hasRule($attribute, $this->numericRules) && is_numeric($value) && is_numeric($comparedToValue)) { try { - return BigNumber::of((string) $this->trim($value))->isGreaterThanOrEqualTo((string) $this->trim($comparedToValue)); + // Either field can contain scientific notation that expands beyond the allowed range. + return BigNumber::of((string) $this->ensureExponentWithinAllowedRange($attribute, $this->trim($value))) + ->isGreaterThanOrEqualTo((string) $this->ensureExponentWithinAllowedRange($parameters[0], $this->trim($comparedToValue))); } catch (MathException|BrickMathException) { return false; } @@ -1490,7 +1497,9 @@ public function validateLte(string $attribute, mixed $value, mixed $parameters): if ($this->hasRule($attribute, $this->numericRules) && is_numeric($value) && is_numeric($comparedToValue)) { try { - return BigNumber::of((string) $this->trim($value))->isLessThanOrEqualTo((string) $this->trim($comparedToValue)); + // Either field can contain scientific notation that expands beyond the allowed range. + return BigNumber::of((string) $this->ensureExponentWithinAllowedRange($attribute, $this->trim($value))) + ->isLessThanOrEqualTo((string) $this->ensureExponentWithinAllowedRange($parameters[0], $this->trim($comparedToValue))); } catch (MathException|BrickMathException) { return false; } @@ -1928,30 +1937,19 @@ public function validateMissingWithAll(string $attribute, mixed $value, mixed $p * Validate the value of an attribute is a multiple of a given value. * * @param array $parameters - * - * @throws MathException */ public function validateMultipleOf(string $attribute, mixed $value, mixed $parameters): bool { $this->requireParameterCount(1, $parameters, 'multiple_of'); - return $this->isMultipleOf($value, $parameters[0]); - } - - /** - * Determine if a value is an exact multiple of a divisor. - * - * @throws MathException - */ - protected function isMultipleOf(mixed $value, mixed $divisor): bool - { - if (! is_numeric($value) || ! is_numeric($divisor)) { + if (! is_numeric($value) || ! is_numeric($parameters[0])) { return false; } try { - $numerator = BigDecimal::of((string) $this->trim($value)); - $denominator = BigDecimal::of((string) $this->trim($divisor)); + // Numeric input can still expand to an unsafe size before divisibility is checked. + $numerator = BigDecimal::of((string) $this->ensureExponentWithinAllowedRange($attribute, $this->trim($value))); + $denominator = BigDecimal::of((string) $this->trim($parameters[0])); if ($numerator->isZero() && $denominator->isZero()) { return false; @@ -1966,8 +1964,9 @@ protected function isMultipleOf(mixed $value, mixed $divisor): bool } return $numerator->remainder($denominator)->isZero(); - } catch (BrickMathException $e) { - throw new MathException('An error occurred while handling the multiple_of input values.', previous: $e); + } catch (MathException|BrickMathException) { + // Malformed numeric input fails validation rather than escaping as an application error. + return false; } } @@ -2795,16 +2794,13 @@ protected function ensureExponentWithinAllowedRange(string $attribute, mixed $va $stringValue = (string) $value; - if (! Str::contains($stringValue, 'e', ignoreCase: true)) { + if (($scale = Number::exponent($stringValue)) === null) { return $value; } - $scale = (int) (Str::contains($stringValue, 'e') - ? Str::after($stringValue, 'e') - : Str::after($stringValue, 'E')); - $withinRange = ( - $this->ensureExponentWithinAllowedRangeUsing ?? fn ($scale) => $scale <= 1000 && $scale >= -1000 + $this->ensureExponentWithinAllowedRangeUsing + ?? fn (int $scale) => $scale <= Number::MAX_EXPONENT && $scale >= -Number::MAX_EXPONENT )($scale, $attribute, $value); if (! $withinRange) { diff --git a/src/validation/src/PlanExecutor.php b/src/validation/src/PlanExecutor.php index a635677f16..6259603434 100644 --- a/src/validation/src/PlanExecutor.php +++ b/src/validation/src/PlanExecutor.php @@ -355,7 +355,7 @@ protected function executeInline(InlineCheck $check, mixed $value, string $attri CheckType::DateBeforeOrEq => $this->inlineCompareDates($value, $check->param, '<='), CheckType::DateEquals => $this->inlineCompareDates($value, $check->param, '='), - CheckType::MultipleOf => $this->isMultipleOf($value, $check->param), + CheckType::MultipleOf => $this->validateMultipleOf($attribute, $value, [$check->param]), }; } diff --git a/tests/ConnectionPool/ConnectionPoolTest.php b/tests/ConnectionPool/ConnectionPoolTest.php index f1609218aa..2e68fb854a 100644 --- a/tests/ConnectionPool/ConnectionPoolTest.php +++ b/tests/ConnectionPool/ConnectionPoolTest.php @@ -131,9 +131,12 @@ public function testSnapshotsDistinguishCreationBorrowingAndCleanup(): void ], $pool->getStats()); } - public function testCloseDrainsEveryIdleConnectionBeforeRethrowingCancellation(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup failure carrying cancellation')] + public function testCloseDrainsEveryIdleConnectionBeforeRethrowingCancellation(bool $chained): void { $cancellation = new CanceledException('close canceled'); + $cancellation = $chained ? new RuntimeException('cleanup failed', previous: $cancellation) : $cancellation; $connections = [ new PoolConnectionStub(closeCallback: static fn (): bool => throw $cancellation), new PoolConnectionStub, @@ -152,7 +155,7 @@ static function () use (&$connections): Connection { try { $pool->close(); $this->fail('Closing an idle connection was expected to be canceled.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -418,9 +421,14 @@ function () use (&$connections): Connection { $pool->release($replacement); } - public function testDiscardRepairsCapacityBeforeRethrowingCancellation(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup failure carrying cancellation')] + public function testDiscardRepairsCapacityBeforeRethrowingCancellation(bool $chained): void { $cancellation = new CanceledException('close canceled'); + if ($chained) { + $cancellation = new RuntimeException('close cleanup failed', previous: $cancellation); + } $connections = [ new PoolConnectionStub(closeCallback: static fn (): bool => throw $cancellation), new PoolConnectionStub, @@ -436,7 +444,7 @@ static function () use (&$connections): Connection { try { $pool->discard($connection); $this->fail('Discarding the connection was expected to be canceled.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -828,9 +836,12 @@ public function testUsageTrackerFailureIsReportedWithoutLosingBorrow(): void $pool->release($connection); } - public function testUsageMaintenanceCancellationDiscardsTheUnreturnedBorrow(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup failure carrying cancellation')] + public function testUsageMaintenanceCancellationDiscardsTheUnreturnedBorrow(bool $chained): void { $cancellation = new CanceledException('maintenance canceled'); + $cancellation = $chained ? new RuntimeException('cleanup failed', previous: $cancellation) : $cancellation; $connections = [ new PoolConnectionStub, new PoolConnectionStub(closeCallback: static fn (): bool => throw $cancellation), @@ -860,7 +871,7 @@ public function shouldTrimExcessIdle(): bool try { $pool->borrow(); $this->fail('Usage maintenance was expected to be canceled.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } diff --git a/tests/Database/ConnectionResolverTest.php b/tests/Database/ConnectionResolverTest.php index 2a83bb0b17..ae0f07f6a8 100644 --- a/tests/Database/ConnectionResolverTest.php +++ b/tests/Database/ConnectionResolverTest.php @@ -18,8 +18,10 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PDO; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; use function Hypervel\Coroutine\run; @@ -306,10 +308,15 @@ public function testTerminalReleaseExhaustsConnectionsAndPreservesTheFirstFailur $resolver->releaseConnections(); } - public function testTerminalReleasePrioritizesTheFirstCancellationAndStillExhaustsConnections(): void + #[TestWith([false])] + #[TestWith([true])] + public function testTerminalReleasePrioritizesTheFirstCancellationAndStillExhaustsConnections(bool $chained): void { $ordinaryFailure = new RuntimeException('First release failed.'); $firstCancellation = new CanceledException('Second release was canceled.'); + if ($chained) { + $firstCancellation = new RuntimeException('Release cleanup failed.', previous: $firstCancellation); + } $secondCancellation = new CanceledException('Third release was canceled.'); $poolManager = m::mock(PoolManager::class); $resolver = $this->makeResolver('first', $poolManager); @@ -335,7 +342,7 @@ public function testTerminalReleasePrioritizesTheFirstCancellationAndStillExhaus try { $resolver->releaseConnections(); $this->fail('Expected release cancellation to propagate.'); - } catch (CanceledException $throwable) { + } catch (Throwable $throwable) { $this->assertSame($firstCancellation, $throwable); } @@ -467,10 +474,13 @@ public function testDiscardFailureDoesNotReplaceTheSetupFailure(): void } } - public function testDiscardCancellationReplacesAnOrdinarySetupFailure(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup failure carrying cancellation')] + public function testDiscardCancellationReplacesAnOrdinarySetupFailure(bool $chained): void { $setupException = new RuntimeException('Connection retrieval failed.'); $discardCancellation = new CanceledException('Discard was canceled.'); + $discardCancellation = $chained ? new RuntimeException('Discard cleanup failed.', previous: $discardCancellation) : $discardCancellation; $poolManager = m::mock(PoolManager::class); $pool = m::mock(DatabasePool::class); $wrapper = m::mock(PooledConnection::class, ['usesSessionLeases' => false]); @@ -486,7 +496,7 @@ public function testDiscardCancellationReplacesAnOrdinarySetupFailure(): void try { $resolver->connection(); $this->fail('Expected discard cancellation to propagate.'); - } catch (CanceledException $throwable) { + } catch (Throwable $throwable) { $this->assertSame($discardCancellation, $throwable); } } @@ -515,9 +525,12 @@ public function testSetupCancellationRemainsPrimaryOverAnOrdinaryDiscardFailure( } } - public function testSetupCancellationRemainsPrimaryOverDiscardCancellation(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'setup failure carrying cancellation')] + public function testSetupCancellationRemainsPrimaryOverDiscardCancellation(bool $chained): void { $setupCancellation = new CanceledException('Connection setup was canceled.'); + $setupCancellation = $chained ? new RuntimeException('Connection setup cleanup failed.', previous: $setupCancellation) : $setupCancellation; $discardCancellation = new CanceledException('Discard was canceled.'); $poolManager = m::mock(PoolManager::class); $pool = m::mock(DatabasePool::class); @@ -534,7 +547,7 @@ public function testSetupCancellationRemainsPrimaryOverDiscardCancellation(): vo try { $resolver->connection(); $this->fail('Expected setup cancellation to propagate.'); - } catch (CanceledException $throwable) { + } catch (Throwable $throwable) { $this->assertSame($setupCancellation, $throwable); } } diff --git a/tests/Database/DatabaseConcernsHasAttributesTest.php b/tests/Database/DatabaseConcernsHasAttributesTest.php index f48b6fe410..f4b2c2c930 100644 --- a/tests/Database/DatabaseConcernsHasAttributesTest.php +++ b/tests/Database/DatabaseConcernsHasAttributesTest.php @@ -4,12 +4,15 @@ namespace Hypervel\Tests\Database; +use ArrayObject; +use Hypervel\Database\Eloquent\Casts\AsArrayObject; use Hypervel\Database\Eloquent\Casts\Attribute; use Hypervel\Database\Eloquent\Concerns\HasAttributes; use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Collection; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; class DatabaseConcernsHasAttributesTest extends TestCase { @@ -66,6 +69,43 @@ public function testUnsettingCachedAttribute(): void $this->assertFalse($instance->cachedAttributeIsset('cacheableProperty')); } + + #[TestWith([false])] + #[TestWith([true])] + public function testReplacingRawAttributesDiscardsCachedCastValues(bool $sync): void + { + $instance = new HasAttributesWithCachedObjects; + $original = ['payload' => '{"days":30}', 'settings' => '{"days":30}']; + $replacement = ['payload' => '{"days":15}', 'settings' => '{"days":15}']; + $instance->setRawAttributes($original, true); + + $this->assertSame(30, $instance->getAttribute('payload')['days']); + $this->assertSame(30, $instance->getAttribute('settings')['days']); + + $instance->setRawAttributes($replacement, $sync); + + $this->assertSame($replacement, $instance->getAttributes()); + $this->assertSame(15, $instance->getAttribute('payload')['days']); + $this->assertSame(15, $instance->getAttribute('settings')['days']); + $this->assertSame($sync ? $replacement : $original, $instance->getRawOriginal()); + $this->assertSame(! $sync, $instance->isDirty()); + } +} + +class HasAttributesWithCachedObjects extends Model +{ + protected array $casts = ['payload' => AsArrayObject::class]; + + /** + * Get the cached settings object. + */ + protected function settings(): Attribute + { + return Attribute::make( + get: static fn (string $value): ArrayObject => new ArrayObject(json_decode($value, true, flags: JSON_THROW_ON_ERROR)), + set: static fn (ArrayObject $value): string => json_encode($value->getArrayCopy(), JSON_THROW_ON_ERROR), + ); + } } class HasAttributesWithoutConstructor diff --git a/tests/Database/DatabaseConnectionLeaseTest.php b/tests/Database/DatabaseConnectionLeaseTest.php index 2602a8b74f..5c3b18a9a4 100644 --- a/tests/Database/DatabaseConnectionLeaseTest.php +++ b/tests/Database/DatabaseConnectionLeaseTest.php @@ -21,7 +21,9 @@ use Hypervel\Database\Events\ConnectionEstablished; use Hypervel\Database\Events\QueryExecuted; use Hypervel\Database\PdoConnection; +use Hypervel\Database\Pool\ConnectionLease; use Hypervel\Database\Pool\DatabasePool; +use Hypervel\Database\Pool\PooledConnection; use Hypervel\Database\Pool\PoolManager; use Hypervel\Database\PostgresConnection; use Hypervel\Database\Schema\Blueprint; @@ -56,6 +58,7 @@ use Hypervel\Tests\Grpc\Fixtures\ClientCallClientFactory; use Hypervel\Tests\Socialite\Fixtures\GenericTestProviderStub; use LogicException; +use Mockery as m; use PDO; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; @@ -1010,6 +1013,34 @@ public function testSessionConfiguratorsRunOnTheLogicalOwnerAfterEachBorrowerCha ], $configurator->applied); } + public function testAcquisitionCleanupPropagatesWrappedCancellation(): void + { + $setupFailure = new RuntimeException('Connection listener failed.'); + $cleanupFailure = new RuntimeException('Discard cleanup failed.', previous: new CanceledException('Discard canceled.')); + $physical = new SQLiteConnection(new PDO('sqlite::memory:'), ':memory:', '', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + $pool = m::mock(DatabasePool::class); + $wrapper = m::mock(PooledConnection::class); + $pool->expects('borrow')->once()->andReturn($wrapper); + $wrapper->expects('attachLease')->twice(); + $wrapper->expects('getDriverConnection')->once()->andReturn($physical); + $wrapper->expects('dispatchConnectionEstablishedEvent')->once()->andThrow($setupFailure); + $wrapper->expects('discard')->once()->andThrow($cleanupFailure); + + $lease = new ConnectionLease($pool, $wrapper, $this->app->make('db.factory'), $physical); + $lease->detach(); + + try { + $lease->connection->getPdo(); + $this->fail('Expected cleanup cancellation to propagate.'); + } catch (Throwable $exception) { + $this->assertSame($cleanupFailure, $exception, $exception::class . ': ' . $exception->getMessage()); + } + } + #[DataProvider('connectionListenerFailures')] public function testConnectionListenerFailuresSettleTheLeaseOnce(bool $reacquire, bool $cancel): void { diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index a833dffc26..2cc42a1eb9 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -776,10 +776,10 @@ public function testRunMethodRetriesOnFailure(): void }]); } - public function testRunMethodPreservesQueryCancellationWithoutRetryOrLogging(): void + #[DataProvider('cancellations')] + public function testRunMethodPreservesQueryCancellationWithoutRetryOrLogging(Throwable $cancellation): void { $method = (new ReflectionClass(Connection::class))->getMethod('run'); - $cancellation = new CanceledException('query canceled'); $pdo = $this->createStub(PDOStub::class); $connection = $this->getMockConnection(['tryAgainIfCausedByLostConnection'], $pdo); $connection->expects($this->never())->method('tryAgainIfCausedByLostConnection'); @@ -790,7 +790,7 @@ public function testRunMethodPreservesQueryCancellationWithoutRetryOrLogging(): try { $method->invokeArgs($connection, ['select 1', [], fn () => throw $cancellation]); $this->fail('Expected the query cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -798,6 +798,15 @@ public function testRunMethodPreservesQueryCancellationWithoutRetryOrLogging(): $this->assertSame([], $connection->getQueryLog()); } + /** + * Supply direct cancellation and a cleanup exception carrying it. + */ + public static function cancellations(): iterable + { + yield 'direct' => [new CanceledException('Operation canceled.')]; + yield 'chained cleanup' => [new RuntimeException('Cleanup failed.', previous: new CanceledException('Operation canceled.'))]; + } + public function testRunDispatchesQueryFailedWithTheFinalLogicalFailure(): void { $method = (new ReflectionClass(Connection::class))->getMethod('run'); @@ -1235,7 +1244,8 @@ public function testStreamingConsumerExceptionsCleanUpWithoutQueryEvents(): void $this->assertSame([], $connection->getQueryLog()); } - public function testStreamingCancellationCleansUpWithoutWrappingOrEvents(): void + #[DataProvider('cancellations')] + public function testStreamingCancellationCleansUpWithoutWrappingOrEvents(Throwable $cancellation): void { $connection = new NeutralConnectionForTest; $connection->enableQueryLog(); @@ -1243,7 +1253,6 @@ public function testStreamingCancellationCleansUpWithoutWrappingOrEvents(): void $events->shouldNotReceive('hasListeners'); $events->shouldNotReceive('dispatch'); $connection->setEventDispatcher($events); - $cancellation = new CanceledException('stream canceled'); $cleaned = false; $thrown = null; @@ -1257,7 +1266,7 @@ public function testStreamingCancellationCleansUpWithoutWrappingOrEvents(): void $cleaned = true; } })); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $thrown = $exception; } @@ -1595,11 +1604,11 @@ public function testBeginPublicationFailureRollsBackAndPreservesTheOriginalFailu $this->assertFalse($connection->getPdo()->inTransaction()); } - public function testBeginPublicationCleanupPreservesCancellation(): void + #[DataProvider('cancellations')] + public function testBeginPublicationCleanupPreservesCancellation(Throwable $cancellation): void { $connection = $this->getSqliteTransactionConnection(); $publicationFailure = new RuntimeException('manager begin failure'); - $cancellation = new CanceledException('rollback cleanup canceled'); $manager = m::mock(DatabaseTransactionsManager::class); $manager->expects('begin')->with('default', 1)->andThrow($publicationFailure); $manager->expects('rollback')->with('default', 0)->andThrow($cancellation); @@ -1608,7 +1617,7 @@ public function testBeginPublicationCleanupPreservesCancellation(): void try { $connection->beginTransaction(); $this->fail('Expected rollback cleanup cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -1616,10 +1625,10 @@ public function testBeginPublicationCleanupPreservesCancellation(): void $this->assertFalse($connection->getPdo()->inTransaction()); } - public function testBeginPublicationCancellationRemainsPrimaryOverCleanupCancellation(): void + #[DataProvider('cancellations')] + public function testBeginPublicationCancellationRemainsPrimaryOverCleanupCancellation(Throwable $cancellation): void { $connection = $this->getSqliteTransactionConnection(); - $cancellation = new CanceledException('manager begin canceled'); $cleanupCancellation = new CanceledException('manager rollback canceled'); $manager = m::mock(DatabaseTransactionsManager::class); $manager->expects('begin')->with('default', 1)->andThrow($cancellation); @@ -1629,7 +1638,7 @@ public function testBeginPublicationCancellationRemainsPrimaryOverCleanupCancell try { $connection->beginTransaction(); $this->fail('Expected transaction publication cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -1761,11 +1770,11 @@ public function testManagedTransactionDoesNotRetryWhenRollbackCleanupFails(): vo $this->assertCount(0, $manager->getCommittedTransactions()); } - public function testManagedTransactionRollbackCleanupPreservesCancellation(): void + #[DataProvider('cancellations')] + public function testManagedTransactionRollbackCleanupPreservesCancellation(Throwable $cancellation): void { $connection = $this->getSqliteTransactionConnection(); $failure = new RuntimeException('transaction callback failure'); - $cancellation = new CanceledException('rollback cleanup canceled'); $manager = m::mock(DatabaseTransactionsManager::class); $manager->expects('begin')->with('default', 1); $manager->expects('rollback')->with('default', 0)->andThrow($cancellation); @@ -1774,7 +1783,7 @@ public function testManagedTransactionRollbackCleanupPreservesCancellation(): vo try { $connection->transaction(static fn (): never => throw $failure); $this->fail('Expected rollback cleanup cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -1782,10 +1791,10 @@ public function testManagedTransactionRollbackCleanupPreservesCancellation(): vo $this->assertFalse($connection->getPdo()->inTransaction()); } - public function testManagedTransactionCancellationRemainsPrimaryOverRollbackCancellation(): void + #[DataProvider('cancellations')] + public function testManagedTransactionCancellationRemainsPrimaryOverRollbackCancellation(Throwable $cancellation): void { $connection = $this->getSqliteTransactionConnection(); - $cancellation = new CanceledException('transaction callback canceled'); $cleanupCancellation = new CanceledException('rollback cleanup canceled'); $manager = m::mock(DatabaseTransactionsManager::class); $manager->expects('begin')->with('default', 1); @@ -1795,7 +1804,7 @@ public function testManagedTransactionCancellationRemainsPrimaryOverRollbackCanc try { $connection->transaction(static fn (): never => throw $cancellation); $this->fail('Expected transaction callback cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -1803,6 +1812,35 @@ public function testManagedTransactionCancellationRemainsPrimaryOverRollbackCanc $this->assertFalse($connection->getPdo()->inTransaction()); } + #[TestWith([false])] + #[TestWith([true])] + public function testCancellationIsNotRetriedOrWrappedWhenItsCleanupErrorMentionsDeadlock(bool $nested): void + { + $connection = $this->getSqliteTransactionConnection(); + $failure = new RuntimeException('Deadlock found when trying to get lock', previous: new CanceledException('Callback canceled.')); + $calls = 0; + if ($nested) { + $connection->beginTransaction(); + } + + try { + $connection->transaction(static function () use ($failure, &$calls): never { + ++$calls; + throw $failure; + }, attempts: 2); + $this->fail('Cancellation must stop the transaction without reclassification.'); + } catch (Throwable $exception) { + $this->assertSame($failure, $exception); + } finally { + if ($nested) { + $connection->rollBack(); + } + } + + $this->assertSame(1, $calls); + $this->assertSame(0, $connection->transactionLevel()); + } + public function testNestedDeadlockFailureRemainsPrimaryWhenRollbackCleanupFails(): void { $connection = $this->getSqliteTransactionConnection(); @@ -1846,14 +1884,14 @@ public function testNestedDeadlockFailureRemainsPrimaryWhenRollbackCleanupFails( $connection->rollBack(); } - public function testNestedDeadlockRollbackCleanupPreservesCancellation(): void + #[DataProvider('cancellations')] + public function testNestedDeadlockRollbackCleanupPreservesCancellation(Throwable $cancellation): void { $connection = new NeutralTransactionConnectionForTest( 'analytics', '', ['name' => 'analytics', 'driver' => 'http'] ); - $cancellation = new CanceledException('nested rollback cleanup canceled'); $manager = m::mock(DatabaseTransactionsManager::class); $manager->shouldReceive('begin')->twice(); $manager->expects('rollback')->with('analytics', 1)->andThrow($cancellation); @@ -1869,7 +1907,7 @@ public function testNestedDeadlockRollbackCleanupPreservesCancellation(): void try { $connection->transaction(static fn (): never => throw $failure); $this->fail('Expected nested rollback cleanup cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -1937,10 +1975,10 @@ public function testManagerCommitFailureStillDispatchesCommittedEventAfterPhysic $this->assertFalse($connection->getPdo()->inTransaction()); } - public function testManagedTransactionManagerCancellationSkipsCommittedEvent(): void + #[DataProvider('cancellations')] + public function testManagedTransactionManagerCancellationSkipsCommittedEvent(Throwable $cancellation): void { $connection = $this->getSqliteTransactionConnection(); - $cancellation = new CanceledException('manager commit canceled'); $manager = m::mock(DatabaseTransactionsManager::class); $manager->expects('begin')->with('default', 1); $manager->expects('commit')->with('default', 1, 0)->andThrow($cancellation); @@ -1954,7 +1992,7 @@ public function testManagedTransactionManagerCancellationSkipsCommittedEvent(): try { $connection->transaction(static fn (): null => null); $this->fail('Expected manager commit cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -1962,11 +2000,11 @@ public function testManagedTransactionManagerCancellationSkipsCommittedEvent(): $this->assertFalse($connection->getPdo()->inTransaction()); } - public function testManagedTransactionEventCancellationSupersedesManagerFailure(): void + #[DataProvider('cancellations')] + public function testManagedTransactionEventCancellationSupersedesManagerFailure(Throwable $cancellation): void { $connection = $this->getSqliteTransactionConnection(); $managerFailure = new RuntimeException('manager commit failure'); - $cancellation = new CanceledException('committed event canceled'); $manager = m::mock(DatabaseTransactionsManager::class); $manager->expects('begin')->with('default', 1); $manager->expects('commit')->with('default', 1, 0)->andThrow($managerFailure); @@ -1981,7 +2019,7 @@ public function testManagedTransactionEventCancellationSupersedesManagerFailure( try { $connection->transaction(static fn (): null => null); $this->fail('Expected committed event cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -1989,10 +2027,10 @@ public function testManagedTransactionEventCancellationSupersedesManagerFailure( $this->assertFalse($connection->getPdo()->inTransaction()); } - public function testExplicitCommitManagerCancellationSkipsCommittedEvent(): void + #[DataProvider('cancellations')] + public function testExplicitCommitManagerCancellationSkipsCommittedEvent(Throwable $cancellation): void { $connection = $this->getSqliteTransactionConnection(); - $cancellation = new CanceledException('manager commit canceled'); $manager = m::mock(DatabaseTransactionsManager::class); $manager->expects('begin')->with('default', 1); $manager->expects('commit')->with('default', 1, 0)->andThrow($cancellation); @@ -2006,7 +2044,7 @@ public function testExplicitCommitManagerCancellationSkipsCommittedEvent(): void try { $connection->commit(); $this->fail('Expected manager commit cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -2014,11 +2052,11 @@ public function testExplicitCommitManagerCancellationSkipsCommittedEvent(): void $this->assertFalse($connection->getPdo()->inTransaction()); } - public function testExplicitCommitEventCancellationSupersedesManagerFailure(): void + #[DataProvider('cancellations')] + public function testExplicitCommitEventCancellationSupersedesManagerFailure(Throwable $cancellation): void { $connection = $this->getSqliteTransactionConnection(); $managerFailure = new RuntimeException('manager commit failure'); - $cancellation = new CanceledException('committed event canceled'); $manager = m::mock(DatabaseTransactionsManager::class); $manager->expects('begin')->with('default', 1); $manager->expects('commit')->with('default', 1, 0)->andThrow($managerFailure); @@ -2033,7 +2071,7 @@ public function testExplicitCommitEventCancellationSupersedesManagerFailure(): v try { $connection->commit(); $this->fail('Expected committed event cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -2077,9 +2115,9 @@ public function testCommitFailureDoesNotRetryWhenRollbackCleanupFails(): void $this->assertCount(0, $manager->getCommittedTransactions()); } - public function testLostConnectionCommitCleanupPreservesCancellation(): void + #[DataProvider('cancellations')] + public function testLostConnectionCommitCleanupPreservesCancellation(Throwable $cancellation): void { - $cancellation = new CanceledException('lost connection cleanup canceled'); $pdo = $this->getMockBuilder(PDOStub::class) ->onlyMethods(['beginTransaction', 'commit']) ->getMock(); @@ -2096,7 +2134,7 @@ public function testLostConnectionCommitCleanupPreservesCancellation(): void try { $connection->transaction(static fn (): null => null); $this->fail('Expected lost connection cleanup cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -2104,9 +2142,9 @@ public function testLostConnectionCommitCleanupPreservesCancellation(): void $this->assertNull($connection->getRawPdo()); } - public function testCommitRollbackCleanupPreservesCancellationWithoutRetry(): void + #[DataProvider('cancellations')] + public function testCommitRollbackCleanupPreservesCancellationWithoutRetry(Throwable $cancellation): void { - $cancellation = new CanceledException('commit rollback cleanup canceled'); $pdo = $this->getMockBuilder(PDOStub::class) ->onlyMethods(['beginTransaction', 'commit', 'inTransaction', 'rollBack']) ->getMock(); @@ -2128,7 +2166,7 @@ public function testCommitRollbackCleanupPreservesCancellationWithoutRetry(): vo ++$callbackCalls; }, 2); $this->fail('Expected commit rollback cleanup cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -2195,10 +2233,10 @@ public function testManagerRollbackFailureStillDispatchesRolledBackEvent(): void $this->assertFalse($connection->getPdo()->inTransaction()); } - public function testExplicitRollbackManagerCancellationSkipsRolledBackEvent(): void + #[DataProvider('cancellations')] + public function testExplicitRollbackManagerCancellationSkipsRolledBackEvent(Throwable $cancellation): void { $connection = $this->getSqliteTransactionConnection(); - $cancellation = new CanceledException('manager rollback canceled'); $manager = m::mock(DatabaseTransactionsManager::class); $manager->expects('begin')->with('default', 1); $manager->expects('rollback')->with('default', 0)->andThrow($cancellation); @@ -2211,7 +2249,7 @@ public function testExplicitRollbackManagerCancellationSkipsRolledBackEvent(): v try { $connection->rollBack(); $this->fail('Expected manager rollback cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -2219,11 +2257,11 @@ public function testExplicitRollbackManagerCancellationSkipsRolledBackEvent(): v $this->assertFalse($connection->getPdo()->inTransaction()); } - public function testExplicitRollbackEventCancellationSupersedesManagerFailure(): void + #[DataProvider('cancellations')] + public function testExplicitRollbackEventCancellationSupersedesManagerFailure(Throwable $cancellation): void { $connection = $this->getSqliteTransactionConnection(); $managerFailure = new RuntimeException('manager rollback failure'); - $cancellation = new CanceledException('rolled back event canceled'); $manager = m::mock(DatabaseTransactionsManager::class); $manager->expects('begin')->with('default', 1); $manager->expects('rollback')->with('default', 0)->andThrow($managerFailure); @@ -2237,7 +2275,7 @@ public function testExplicitRollbackEventCancellationSupersedesManagerFailure(): try { $connection->rollBack(); $this->fail('Expected rolled back event cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -2269,9 +2307,9 @@ public function testRolledBackEventFailureOccursAfterManagerCleanup(): void $this->assertFalse($connection->getPdo()->inTransaction()); } - public function testLostConnectionRollbackCleanupPreservesCancellation(): void + #[DataProvider('cancellations')] + public function testLostConnectionRollbackCleanupPreservesCancellation(Throwable $cancellation): void { - $cancellation = new CanceledException('lost rollback cleanup canceled'); $pdo = $this->getMockBuilder(PDOStub::class) ->onlyMethods(['beginTransaction', 'inTransaction', 'rollBack']) ->getMock(); @@ -2290,7 +2328,7 @@ public function testLostConnectionRollbackCleanupPreservesCancellation(): void try { $connection->rollBack(); $this->fail('Expected lost rollback cleanup cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } diff --git a/tests/Database/DatabasePdoConnectionTest.php b/tests/Database/DatabasePdoConnectionTest.php index 608771296d..0305ace137 100755 --- a/tests/Database/DatabasePdoConnectionTest.php +++ b/tests/Database/DatabasePdoConnectionTest.php @@ -480,7 +480,7 @@ public function testDatabaseCapabilities( string $connectionClass, string $serverVersion, ?string $configuredVersion, - bool|string $expectedLock, + bool $supportsSkipLocked, int $expectedMaxBindings, ): void { $pdo = new VersionedPDOStub($serverVersion); @@ -493,26 +493,27 @@ public function testDatabaseCapabilities( /** @var class-string $connectionClass */ $connection = new $connectionClass($pdo, 'test_db', '', $config); - $this->assertSame($expectedLock, $connection->lockForPopping()); + $this->assertSame($supportsSkipLocked, $connection->supportsSkipLocked()); $this->assertSame($expectedMaxBindings, $connection->maxBindings()); } public static function databaseCapabilityProvider(): array { return [ - 'unknown PDO connection' => [PdoConnection::class, '1.0', null, true, 999], - 'MySQL before SKIP LOCKED' => [MySqlConnection::class, '8.0.0', null, true, 65_535], - 'MySQL with SKIP LOCKED' => [MySqlConnection::class, '8.0.1', null, 'FOR UPDATE SKIP LOCKED', 65_535], - 'MariaDB through MySQL' => [MySqlConnection::class, '5.5.5-10.11.0-MariaDB', null, 'FOR UPDATE SKIP LOCKED', 65_535], - 'MariaDB connection' => [MariaDbConnection::class, '10.11.0-MariaDB', null, 'FOR UPDATE SKIP LOCKED', 65_535], - 'configured MySQL override' => [MySqlConnection::class, '8.0.0', '8.0.1', 'FOR UPDATE SKIP LOCKED', 65_535], - 'configured MariaDB marker' => [MySqlConnection::class, '8.0.0', '5.5.5-10.11.0-MariaDB', 'FOR UPDATE SKIP LOCKED', 65_535], - 'Vitess before SKIP LOCKED' => [MySqlConnection::class, '8.0.0', '18.0.0-vitess', true, 65_535], - 'Vitess with SKIP LOCKED' => [MySqlConnection::class, '8.0.0', '19.0.0-vitess', 'FOR UPDATE SKIP LOCKED', 65_535], - 'PlanetScale with SKIP LOCKED' => [MySqlConnection::class, '8.0.0', '19.0.0-PlanetScale', 'FOR UPDATE SKIP LOCKED', 65_535], - 'PostgreSQL before SKIP LOCKED' => [PostgresConnection::class, '9.4', null, true, 65_535], - 'PostgreSQL with SKIP LOCKED' => [PostgresConnection::class, '9.5', null, 'FOR UPDATE SKIP LOCKED', 65_535], - 'SQLite' => [SQLiteConnection::class, '', null, true, 32_766], + 'unknown PDO connection' => [PdoConnection::class, '1.0', null, false, 999], + 'MySQL before SKIP LOCKED' => [MySqlConnection::class, '8.0.0', null, false, 65_535], + 'MySQL with SKIP LOCKED' => [MySqlConnection::class, '8.0.1', null, true, 65_535], + 'MariaDB through MySQL' => [MySqlConnection::class, '5.5.5-10.11.0-MariaDB', null, true, 65_535], + 'MariaDB connection' => [MariaDbConnection::class, '10.11.0-MariaDB', null, true, 65_535], + 'configured MySQL override' => [MySqlConnection::class, '8.0.0', '8.0.1', true, 65_535], + 'configured MariaDB marker' => [MySqlConnection::class, '8.0.0', '5.5.5-10.11.0-MariaDB', true, 65_535], + 'Vitess before SKIP LOCKED' => [MySqlConnection::class, '8.0.0', '18.0.0-vitess', false, 65_535], + 'Vitess with SKIP LOCKED' => [MySqlConnection::class, '8.0.0', '19.0.0-vitess', true, 65_535], + 'PlanetScale with SKIP LOCKED' => [MySqlConnection::class, '8.0.0', '19.0.0-PlanetScale', true, 65_535], + 'PostgreSQL before SKIP LOCKED' => [PostgresConnection::class, '9.4', null, false, 65_535], + 'PostgreSQL with SKIP LOCKED' => [PostgresConnection::class, '9.5', null, true, 65_535], + 'configured PostgreSQL override' => [PostgresConnection::class, '9.4', '9.5', true, 65_535], + 'SQLite' => [SQLiteConnection::class, '', null, false, 32_766], ]; } @@ -523,9 +524,9 @@ public function testDatabaseCapabilitiesAreCachedUntilThePdoIsReplaced(): void public int $bindingResolutionCount = 0; - protected function resolveLockForPopping(): bool|string + protected function resolveSupportsSkipLocked(): bool { - return ++$this->lockResolutionCount === 1 ? true : 'FOR UPDATE SKIP LOCKED'; + return ++$this->lockResolutionCount > 1; } protected function resolveMaxBindings(): int @@ -534,8 +535,8 @@ protected function resolveMaxBindings(): int } }; - $this->assertTrue($connection->lockForPopping()); - $this->assertTrue($connection->lockForPopping()); + $this->assertFalse($connection->supportsSkipLocked()); + $this->assertFalse($connection->supportsSkipLocked()); $this->assertSame(999, $connection->maxBindings()); $this->assertSame(999, $connection->maxBindings()); $this->assertSame(1, $connection->lockResolutionCount); @@ -543,10 +544,17 @@ protected function resolveMaxBindings(): int $connection->setPdo(new PDOStub); - $this->assertSame('FOR UPDATE SKIP LOCKED', $connection->lockForPopping()); + $this->assertTrue($connection->supportsSkipLocked()); $this->assertSame(65_535, $connection->maxBindings()); $this->assertSame(2, $connection->lockResolutionCount); $this->assertSame(2, $connection->bindingResolutionCount); + + $connection->detachPdoResources(fn () => new PDOStub, null); + + $this->assertTrue($connection->supportsSkipLocked()); + $this->assertSame(65_535, $connection->maxBindings()); + $this->assertSame(3, $connection->lockResolutionCount); + $this->assertSame(3, $connection->bindingResolutionCount); } public function testEscapingUsesThePdoThatExecutedTheLastWrite(): void @@ -1182,14 +1190,18 @@ public function testManagedTransactionRejectsManuallyEndingItsOwnTransaction(str }); } - #[TestWith([false])] - #[TestWith([true])] - public function testFailedSavepointCleanupPreservesErrorAndCancellationPriority(bool $cancel): void + #[TestWith([false, false])] + #[TestWith([true, false])] + #[TestWith([true, true])] + public function testFailedSavepointCleanupPreservesErrorAndCancellationPriority(bool $cancel, bool $callbackCancels): void { $failure = new PDOException('Savepoint rollback failed.'); $cleanupFailure = $cancel ? new CanceledException('Disconnect canceled.') : new RuntimeException('Disconnect failed.'); + if ($callbackCancels) { + $cleanupFailure = new RuntimeException('Rollback callback cleanup failed.', previous: $cleanupFailure); + } $pdo = $this->getMockBuilder(PDOStub::class) ->onlyMethods(['beginTransaction', 'exec', 'inTransaction', 'rollBack'])->getMock(); $pdo->expects($this->once())->method('beginTransaction'); @@ -1201,13 +1213,19 @@ public function testFailedSavepointCleanupPreservesErrorAndCancellationPriority( return 0; }); $pdo->expects($this->once())->method('inTransaction')->willReturn(true); - $pdo->expects($this->once())->method('rollBack')->willThrowException($cleanupFailure); + $pdo->expects($this->once())->method('rollBack')->willThrowException( + $callbackCancels ? new RuntimeException('Physical rollback failed.') : $cleanupFailure + ); $connection = $this->getMockConnection([], $pdo); $manager = new DatabaseTransactionsManager; $connection->setTransactionManager($manager); $connection->beginTransaction(); $connection->beginTransaction(); + if ($callbackCancels) { + $connection->afterRollBack(static fn (): never => throw $cleanupFailure); + } + try { $connection->rollBack(1); $this->fail('Rollback failure must propagate.'); diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 388b8d5c57..8ef047517b 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -6909,6 +6909,65 @@ public function testPostgresLock(): void $this->assertEquals(['baz'], $builder->getBindings()); } + #[DataProvider('skipLockedGrammarProvider')] + public function testSkipLockedUsesTheServerLockSyntax( + string $factory, + bool $supported, + ?bool $isMaria, + string $updateSql, + string $sharedSql, + ): void { + $builder = $this->{$factory}()->from('jobs'); + $connection = $builder->getConnection(); + $connection->shouldReceive('supportsSkipLocked')->andReturn($supported); + + if ($isMaria !== null) { + $connection->shouldReceive('isMaria')->andReturn($isMaria); + } + + $builder->lockForUpdate(); + $this->assertSame($builder, $builder->skipLocked()); + $this->assertSame($updateSql, $builder->toSql()); + $this->assertSame($sharedSql, $builder->sharedLock()->toSql()); + } + + public static function skipLockedGrammarProvider(): array + { + return [ + 'MySQL' => ['getMySqlBuilder', true, false, 'select * from `jobs` for update skip locked', 'select * from `jobs` for share skip locked'], + 'MariaDB through MySQL' => ['getMySqlBuilder', true, true, 'select * from `jobs` for update skip locked', 'select * from `jobs` lock in share mode skip locked'], + 'older MySQL' => ['getMySqlBuilder', false, null, 'select * from `jobs` for update', 'select * from `jobs` lock in share mode'], + 'PostgreSQL' => ['getPostgresBuilder', true, null, 'select * from "jobs" for update skip locked', 'select * from "jobs" for share skip locked'], + 'older PostgreSQL' => ['getPostgresBuilder', false, null, 'select * from "jobs" for update', 'select * from "jobs" for share'], + 'SQLite' => ['getSQLiteBuilder', false, null, 'select * from "jobs"', 'select * from "jobs"'], + 'base grammar' => ['getBuilder', false, null, 'select * from "jobs"', 'select * from "jobs"'], + ]; + } + + public function testSkipLockedRequiresAnExistingLock(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs('Skip locked requires lockForUpdate() or sharedLock().'); + + $this->getBuilder()->skipLocked(); + } + + public function testSkipLockedCannotFollowACustomLock(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs('Skip locked requires lockForUpdate() or sharedLock().'); + + $this->getBuilder()->lock('for update nowait')->skipLocked(); + } + + public function testCustomLockCannotFollowSkipLocked(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs('Skip locked requires lockForUpdate() or sharedLock().'); + + $this->getBuilder()->lockForUpdate()->skipLocked()->lock('for update nowait'); + } + public function testSelectWithLockUsesWritePdo(): void { $builder = $this->getMySqlBuilderWithProcessor(); @@ -8838,6 +8897,37 @@ public function testIndexHintsOnMySqlUpdates(): void $this->assertSame(1, $builder->from('users')->forceIndex('primary')->where('id', 1)->update(['email' => 'foo'])); } + public function testIndexHintsOnMySqlDeletes(): void + { + foreach (['useIndex' => 'use', 'forceIndex' => 'force', 'ignoreIndex' => 'ignore'] as $method => $hint) { + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->expects('delete')->with("delete `users` from `users` {$hint} index (test_index) where `id` = ?", [1])->andReturn(1); + $this->assertSame(1, $builder->from('users')->{$method}('test_index')->where('id', 1)->delete()); + } + + $builder = $this->getMySqlBuilder(prefix: 'prefix_'); + $builder->getConnection()->expects('delete')->with('delete `prefix_u` from `prefix_users` as `prefix_u` force index (primary) where `prefix_u`.`id` = ?', [1])->andReturn(1); + $this->assertSame(1, $builder->from('users as u')->forceIndex('primary')->where('u.id', 1)->delete()); + + $builder = $this->getMySqlBuilder(prefix: 'prefix_'); + $builder->getConnection()->expects('delete')->with('delete `prefix_u` from `prefix_users` as `prefix_u` force index (primary) inner join `prefix_orders` on `prefix_u`.`id` = `prefix_orders`.`user_id` and `prefix_orders`.`status` = ? where `prefix_u`.`id` = ?', ['paid', 1])->andReturn(1); + $this->assertSame(1, $builder->from('users as u')->forceIndex('primary')->join('orders', static function (JoinClause $join): void { + $join->on('u.id', '=', 'orders.user_id')->where('orders.status', 'paid'); + })->where('u.id', 1)->delete()); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->expects('delete')->with('delete from `users` where `id` = ? order by `id` asc', [1])->andReturn(1); + $this->assertSame(1, $builder->from('users')->forceIndex('primary')->where('id', 1)->orderBy('id')->delete()); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->expects('delete')->with('delete from `users` where `id` = ? limit 1', [1])->andReturn(1); + $this->assertSame(1, $builder->from('users')->forceIndex('primary')->where('id', 1)->limit(1)->delete()); + + $builder = $this->getMariaDbBuilder(); + $builder->getConnection()->expects('delete')->with('delete `users` from `users` force index (primary) where `id` = ?', [1])->andReturn(1); + $this->assertSame(1, $builder->from('users')->forceIndex('primary')->where('id', 1)->delete()); + } + public function testUseIndexSqlite(): void { $builder = $this->getSQLiteBuilder(); @@ -8883,6 +8973,29 @@ public function testIndexHintsOnSqliteUpdates(): void })->where('users.id', 1)->update(['email' => 'foo'])); } + public function testIndexHintsOnSqliteDeletes(): void + { + $builder = $this->getSQLiteBuilder(prefix: 'prefix_'); + $builder->getConnection()->expects('delete')->with('delete from "prefix_users" as "prefix_u" indexed by test_index where "prefix_u"."id" = ?', [1])->andReturn(1); + $this->assertSame(1, $builder->from('users as u')->forceIndex('test_index')->where('u.id', 1)->delete()); + + foreach (['useIndex', 'ignoreIndex'] as $method) { + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->expects('delete')->with('delete from "users" where "id" = ?', [1])->andReturn(1); + $this->assertSame(1, $builder->from('users')->{$method}('test_index')->where('id', 1)->delete()); + } + + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->expects('delete')->with('delete from "users" where "rowid" in (select "users"."rowid" from "users" indexed by test_index where "id" > ? order by "id" asc limit 3)', [1])->andReturn(1); + $this->assertSame(1, $builder->from('users')->forceIndex('test_index')->where('id', '>', 1)->orderBy('id')->limit(3)->delete()); + + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->expects('delete')->with('delete from "users" where "rowid" in (select "users"."rowid" from "users" indexed by test_index inner join "orders" on "users"."id" = "orders"."user_id" and "orders"."status" = ? where "users"."id" = ?)', ['paid', 1])->andReturn(1); + $this->assertSame(1, $builder->from('users')->forceIndex('test_index')->join('orders', static function (JoinClause $join): void { + $join->on('users.id', '=', 'orders.user_id')->where('orders.status', 'paid'); + })->where('users.id', 1)->delete()); + } + public function testClone(): void { $builder = $this->getBuilder(); diff --git a/tests/Database/DatabaseTransactionsManagerTest.php b/tests/Database/DatabaseTransactionsManagerTest.php index bd64555322..6e32aa9f44 100755 --- a/tests/Database/DatabaseTransactionsManagerTest.php +++ b/tests/Database/DatabaseTransactionsManagerTest.php @@ -10,6 +10,7 @@ use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Engine\Channel; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; use Throwable; @@ -574,11 +575,16 @@ public function testRollbackExecutesCallbacksForCommittedSavepointsWhenOuterRoll $this->assertCount(0, $manager->getCommittedTransactions()); } - public function testRollbackCallbacksFinishTheBoundedDrainAndPreserveCancellation(): void + #[TestWith([false])] + #[TestWith([true])] + public function testRollbackCallbacksFinishTheBoundedDrainAndPreserveCancellation(bool $chained): void { $manager = new DatabaseTransactionsManager; $callbacks = []; $cancellation = new CanceledException; + if ($chained) { + $cancellation = new RuntimeException('Rollback cleanup failed.', previous: $cancellation); + } $manager->begin('default', 1); $manager->addCallbackForRollback(function () use (&$callbacks): void { @@ -603,7 +609,7 @@ public function testRollbackCallbacksFinishTheBoundedDrainAndPreserveCancellatio try { $manager->rollback('default', 0); $this->fail('Expected rollback cancellation to escape.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -719,11 +725,16 @@ public function testCommitCallbacksExhaustEveryRecordAndPreserveTheEarliestFailu $this->assertCount(0, $manager->getCommittedTransactions()); } - public function testCommitCancellationStopsTheCurrentAndRemainingTransactionRecords(): void + #[TestWith([false])] + #[TestWith([true])] + public function testCommitCancellationStopsTheCurrentAndRemainingTransactionRecords(bool $chained): void { $manager = new DatabaseTransactionsManager; $callbacks = []; $cancellation = new CanceledException; + if ($chained) { + $cancellation = new RuntimeException('Commit cleanup failed.', previous: $cancellation); + } $manager->begin('default', 1); $manager->addCallback(function () use (&$callbacks): void { @@ -747,7 +758,7 @@ public function testCommitCancellationStopsTheCurrentAndRemainingTransactionReco try { $manager->commit('default', 1, 0); $this->fail('Expected commit cancellation to escape.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } diff --git a/tests/Foundation/Http/FormRequestCastingTest.php b/tests/Foundation/Http/FormRequestCastingTest.php index 3bb5fa5a2c..205c1336e6 100644 --- a/tests/Foundation/Http/FormRequestCastingTest.php +++ b/tests/Foundation/Http/FormRequestCastingTest.php @@ -284,6 +284,43 @@ public function testJsonCastsRejectInvalidInput(): void } } + #[DataProvider('decimalParsingProvider')] + public function testDecimalParsingPreservesPrecisionAndBoundsExponentExpansion(float|int|string $value, ?string $expected): void + { + $request = $this->validateRequest(ConfigurableDecimalRequest::class, [ + 'value' => $value, + 'declaration' => 'decimal:2', + ]); + + if ($expected === null) { + $this->expectException(MathException::class); + $this->expectExceptionMessageIs('Unable to cast value to a decimal.'); + $request->validated(); + + return; + } + + $this->assertSame($expected, $request->validated('value')); + $this->assertSame($expected, $request->safe()->all()['value']); + } + + /** + * Provide decimal inputs for precision and exponent-limit checks. + */ + public static function decimalParsingProvider(): array + { + return [ + 'positive exponent rejected' => ['1e1001', null], + 'negative exponent rejected' => ['1E-1001', null], + 'positive boundary accepted' => ['1e1000', '1' . str_repeat('0', 1000) . '.00'], + 'negative boundary accepted' => ['1e-1000', '0.00'], + 'explicit long decimal preserved' => [str_repeat('9', 1002) . '.125', str_repeat('9', 1002) . '.13'], + 'fraction preserved' => ['1/4', '0.25'], + 'integer precision preserved' => [9007199254740993, '9007199254740993.00'], + 'NAN rejected without warnings' => [NAN, null], + ]; + } + /** * Test invalid decimal input uses the framework math exception boundary. */ diff --git a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php index ac9f945f12..ecd6d5f2ee 100644 --- a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php +++ b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php @@ -5,21 +5,82 @@ namespace Hypervel\Tests\Foundation\Testing; use Hypervel\Container\Container; +use Hypervel\Contracts\Foundation\Application; use Hypervel\Database\Connection; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Database\Pool\PooledConnection; use Hypervel\Database\Pool\PoolManager; use Hypervel\Foundation\Testing\DatabaseConnectionResolver; use Hypervel\Foundation\Testing\DatabaseTransactionsManager as TestingTransactionsManager; +use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; +use LogicException; use Mockery as m; +use PDO; use ReflectionProperty; use RuntimeException; #[WithConfig('database.default', 'testing')] class DatabaseConnectionResolverTest extends TestCase { + /** + * Use coroutine-owned SQLite sessions for restoration tests. + */ + protected function definePooledDatabase(Application $app): void + { + $app->make('config')->set('database.connections.testing.pool.testing_enabled', true); + } + + #[DefineEnvironment('definePooledDatabase')] + public function testRestoredPdoSurvivesIdleRelease(): void + { + $resolver = $this->app->make('db.resolver'); + $connection = $resolver->connection('testing::read'); + $connection->statement('create table previous_database (value integer)'); + $pool = $this->app->make(PoolManager::class)->pool('testing'); + $saved = new PDO('sqlite::memory:'); + $saved->exec('create table restored_database (value integer)'); + $saved->exec('insert into restored_database values (42)'); + + $this->assertTrue($resolver->restoreInMemoryPdoIfPooled('testing::read', $saved)); + $this->assertSame($saved, $pool->getSharedInMemorySqlitePdo()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(42, $connection->table('restored_database')->value('value')); + $this->assertSame($this->app->make('events'), $connection->getEventDispatcher()); + + $resolver->releaseIdleConnections(); + + $this->assertSame(42, $connection->table('restored_database')->value('value')); + } + + #[DefineEnvironment('definePooledDatabase')] + public function testRestorationRejectsAPinnedSessionWithoutReplacingTheDatabase(): void + { + $resolver = $this->app->make('db.resolver'); + $connection = $resolver->connection('testing'); + $pool = $this->app->make(PoolManager::class)->pool('testing'); + $previous = $pool->getSharedInMemorySqlitePdo(); + $saved = new PDO('sqlite::memory:'); + + $connection->withPinnedSession(function () use ($resolver, $connection, $pool, $previous, $saved): void { + $this->assertSame($previous, $connection->getPdo()); + + try { + $resolver->restoreInMemoryPdoIfPooled('testing', $saved); + $this->fail('Expected restoration to reject the borrowed session.'); + } catch (LogicException $exception) { + $this->assertSame('Cannot restore the in-memory SQLite database while connections are borrowed.', $exception->getMessage()); + } + + $this->assertSame($previous, $pool->getSharedInMemorySqlitePdo()); + $this->assertSame($previous, $connection->getPdo()); + }); + + $this->assertTrue($resolver->restoreInMemoryPdoIfPooled('testing', $saved)); + $this->assertSame($saved, $connection->getPdo()); + } + public function testFlushDisconnectsCachedConnection(): void { $resolver = $this->app->make(DatabaseConnectionResolver::class); diff --git a/tests/Integration/Database/EloquentModelDecimalCastingTest.php b/tests/Integration/Database/EloquentModelDecimalCastingTest.php index 8885247649..71706b8b1c 100644 --- a/tests/Integration/Database/EloquentModelDecimalCastingTest.php +++ b/tests/Integration/Database/EloquentModelDecimalCastingTest.php @@ -41,6 +41,43 @@ public function testItHandlesExponent(): void $this->assertSame('123.45678900000000000000', $model->amount); } + #[DataProvider('decimalParsingProvider')] + public function testDecimalParsingPreservesPrecisionAndBoundsExponentExpansion(float|int|string $value, ?string $expected): void + { + $model = new class extends Model { + protected array $casts = ['amount' => 'decimal:2']; + }; + $model->amount = $value; + + if ($expected === null) { + $this->expectException(MathException::class); + $this->expectExceptionMessageIs('Unable to cast value to a decimal.'); + $model->toArray(); + + return; + } + + $this->assertSame($expected, $model->amount); + $this->assertSame($expected, $model->toArray()['amount']); + } + + /** + * Provide decimal inputs for precision and exponent-limit checks. + */ + public static function decimalParsingProvider(): array + { + return [ + 'positive exponent rejected' => ['1e1001', null], + 'negative exponent rejected' => ['1E-1001', null], + 'positive boundary accepted' => ['1e1000', '1' . str_repeat('0', 1000) . '.00'], + 'negative boundary accepted' => ['1e-1000', '0.00'], + 'explicit long decimal preserved' => [str_repeat('9', 1002) . '.125', str_repeat('9', 1002) . '.13'], + 'fraction preserved' => ['1/4', '0.25'], + 'integer precision preserved' => [9007199254740993, '9007199254740993.00'], + 'NAN rejected without warnings' => [NAN, null], + ]; + } + public function testItHandlesIntegersWithUnderscores(): void { $model = new class extends Model { diff --git a/tests/Integration/Database/EloquentPrunableTest.php b/tests/Integration/Database/EloquentPrunableTest.php index 3a5e44c53d..e02c0a0fcc 100644 --- a/tests/Integration/Database/EloquentPrunableTest.php +++ b/tests/Integration/Database/EloquentPrunableTest.php @@ -16,7 +16,10 @@ use Hypervel\Support\Facades\Exceptions; use Hypervel\Support\Facades\Schema; use LogicException; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; /** * The fixtures are intentionally smaller than Laravel's because this suite @@ -149,18 +152,21 @@ public function testPruneWithExceptionAtOneOfModels(): void Exceptions::assertReported(fn (Exception $exception) => $exception->getMessage() === 'foo bar'); } - public function testPruneRethrowsCancellationWithoutReportingIt(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup failure carrying cancellation')] + public function testPruneRethrowsCancellationWithoutReportingIt(bool $chained): void { Exceptions::fake(); - PrunableWithCancellation::insert(array_fill(0, 10, ['name' => 'foo'])); + PrunableWithCancellation::insert(array_fill(0, 10, ['name' => $chained ? 'chained' : 'direct'])); try { (new PrunableWithCancellation)->pruneAll(); $this->fail('The cancellation was not rethrown.'); - } catch (CanceledException $exception) { - $this->assertSame('canceled', $exception->getMessage()); + } catch (Throwable $exception) { + $this->assertSame($chained ? RuntimeException::class : CanceledException::class, $exception::class); + $this->assertSame($chained ? 'cleanup failed' : 'canceled', $exception->getMessage()); } Exceptions::assertNothingReported(); @@ -240,7 +246,11 @@ public function prunable(): Builder public function prune(): int|bool|null { if ($this->id === 5) { - throw new CanceledException('canceled'); + $cancellation = new CanceledException('canceled'); + + throw $this->name === 'chained' + ? new RuntimeException('cleanup failed', previous: $cancellation) + : $cancellation; } return true; diff --git a/tests/Integration/Database/SkipLockedTest.php b/tests/Integration/Database/SkipLockedTest.php new file mode 100644 index 0000000000..d9894f268f --- /dev/null +++ b/tests/Integration/Database/SkipLockedTest.php @@ -0,0 +1,86 @@ +make('config'); + $default = $config->string('database.default'); + $connection = $config->array("database.connections.{$default}"); + $connection['lock_timeout'] = 1; + $connection['pool'] = [ + 'testing_enabled' => true, + 'min_retained_connections' => 1, + 'max_connections' => 1, + 'heartbeat_interval' => null, + ]; + + $config->set('database.connections.skip_locked', $connection); + } + + public function testBothLockModesSkipRowsHeldByAnotherConnection(): void + { + if (! DB::connection()->supportsSkipLocked()) { + $this->markTestSkipped('This server does not support skipping locked rows.'); + } + + $holderPool = new DatabasePool($this->app, 'skip_locked'); + $contenderPool = new DatabasePool($this->app, 'skip_locked'); + $holderLease = $holderPool->borrow(); + + try { + $contenderLease = $contenderPool->borrow(); + + try { + $holder = $holderLease->getConnection(); + $contender = $contenderLease->getConnection(); + $schema = $holder->getSchemaBuilder(); + $schema->create('skip_locked_rows', function (Blueprint $table): void { + $table->integer('id')->primary(); + }); + + try { + $holder->table('skip_locked_rows')->insert([['id' => 1], ['id' => 2]]); + + foreach (['lockForUpdate', 'sharedLock'] as $lockMethod) { + $holder->beginTransaction(); + + try { + $holder->table('skip_locked_rows')->where('id', 1)->lockForUpdate()->first(); + + $contender->transaction(function () use ($contender, $lockMethod): void { + $rows = $contender->table('skip_locked_rows') + ->orderBy('id')->{$lockMethod}()->skipLocked()->pluck('id'); + + $this->assertSame([2], $rows->all()); + }); + } finally { + $holder->rollBack(); + } + } + } finally { + $schema->drop('skip_locked_rows'); + } + } finally { + $contenderLease->release(); + } + } finally { + $holderLease->release(); + $holderPool->close(); + $contenderPool->close(); + } + } +} diff --git a/tests/Integration/Database/UpdateIndexHintTest.php b/tests/Integration/Database/WriteIndexHintTest.php similarity index 57% rename from tests/Integration/Database/UpdateIndexHintTest.php rename to tests/Integration/Database/WriteIndexHintTest.php index 43059c3f03..67cbd17472 100644 --- a/tests/Integration/Database/UpdateIndexHintTest.php +++ b/tests/Integration/Database/WriteIndexHintTest.php @@ -12,12 +12,13 @@ use Hypervel\Engine\Channel; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; +use PHPUnit\Framework\Attributes\TestWith; use function Hypervel\Coroutine\parallel; -class UpdateIndexHintTest extends DatabaseTestCase +class WriteIndexHintTest extends DatabaseTestCase { - private const string CONNECTION_NAME = 'update_index_hint_test'; + private const string CONNECTION_NAME = 'write_index_hint_test'; protected function defineEnvironment(ApplicationContract $app): void { @@ -68,35 +69,76 @@ public function testUpdatesTakeIndexHints(): void } } - public function testAnUpdateForcingAnIndexThatDoesNotExistFails(): void + public function testDeletesTakeIndexHints(): void + { + $hints = match ($this->driver) { + 'pgsql' => [null], + 'sqlite' => ['forceIndex'], + default => ['useIndex', 'forceIndex', 'ignoreIndex'], + }; + + foreach ($hints as $index => $hint) { + $first = $index * 2 + 1; + $query = DB::table('hinted_posts as target'); + $deleted = ($hint === null ? $query : $query->{$hint}('hinted_posts_title_index')) + ->whereIn('target.title', ["post {$first}", 'post ' . ($first + 1)])->delete(); + + $this->assertSame(2, $deleted); + $this->assertSame(0, DB::table('hinted_posts')->whereIn('id', [$first, $first + 1])->count()); + } + + $this->assertSame(30 - count($hints) * 2, DB::table('hinted_posts')->count()); + + DB::table('hinted_posts')->where('id', 28)->update(['votes' => 1]); + $joined = DB::table('hinted_posts as target') + ->join('hinted_posts as matches', 'target.id', '=', 'matches.id') + ->where('matches.votes', 0)->whereIn('target.id', [27, 28]); + if ($this->driver !== 'pgsql') { + $joined->forceIndex('hinted_posts_title_index'); + } + $this->assertSame(1, $joined->delete()); + $this->assertSame([28 => 1], DB::table('hinted_posts')->whereIn('id', [27, 28])->pluck('votes', 'id')->all()); + } + + #[TestWith(['update'])] + #[TestWith(['delete'])] + public function testAWriteForcingAnIndexThatDoesNotExistFails(string $operation): void { $this->skipIfDriver('pgsql'); try { - DB::table('hinted_posts')->forceIndex('hinted_posts_missing_index')->where('id', 1)->update(['votes' => 1]); + $query = DB::table('hinted_posts')->forceIndex('hinted_posts_missing_index')->where('id', 1); + $operation === 'update' ? $query->update(['votes' => 1]) : $query->delete(); - $this->fail('The update ran without its index hint.'); + $this->fail('The write ran without its index hint.'); } catch (QueryException $exception) { $this->assertStringContainsString('hinted_posts_missing_index', $exception->getMessage()); } $this->assertSame(0, (int) DB::table('hinted_posts')->sum('votes')); + $this->assertSame(30, DB::table('hinted_posts')->count()); } - public function testAnUpdateForcingThePrimaryKeyLocksOnlyTheRowsItNames(): void + #[TestWith(['update'])] + #[TestWith(['delete'])] + public function testAWriteForcingThePrimaryKeyLocksOnlyTheRowsItNames(string $operation): void { if (! in_array($this->driver, ['mysql', 'mariadb'], true)) { $this->markTestSkipped('InnoDB locks the rows a statement reads; other databases are not affected.'); } + if ($operation === 'delete') { + DB::table('hinted_posts')->where('id', 1)->update(['votes' => 1]); + } + $holderPool = new DatabasePool($this->app, self::CONNECTION_NAME); $contenderPool = new DatabasePool($this->app, self::CONNECTION_NAME); - $updated = new Channel(1); + $written = new Channel(1); $released = new Channel(1); try { [$count, $free] = parallel([ - function () use ($holderPool, $updated, $released): int { + function () use ($holderPool, $written, $released, $operation): int { /** @var PooledConnection $pooledConnection */ $pooledConnection = $holderPool->borrow(); $connection = $pooledConnection->getConnection(); @@ -104,8 +146,11 @@ function () use ($holderPool, $updated, $released): int { try { $connection->beginTransaction(); // Most of a small table, which both servers otherwise read whole, locking every row. - $count = $connection->table('hinted_posts')->forceIndex('primary')->whereIn('id', range(1, 20))->update(['votes' => 1]); - $updated->push(true); + $query = $connection->table('hinted_posts')->forceIndex('primary')->whereIn('id', range(1, 20)); + // Deletes retain their real eligibility condition. An ID-only + // delete can still choose a covering scan despite the hint. + $count = $operation === 'update' ? $query->update(['votes' => 1]) : $query->where('votes', 0)->delete(); + $written->push(true); $released->pop(5); $connection->commit(); @@ -118,8 +163,8 @@ function () use ($holderPool, $updated, $released): int { $pooledConnection->release(); } }, - function () use ($contenderPool, $updated, $released): ?int { - $updated->pop(5); + function () use ($contenderPool, $written, $released): ?int { + $written->pop(5); /** @var PooledConnection $pooledConnection */ $pooledConnection = $contenderPool->borrow(); @@ -138,8 +183,10 @@ function () use ($contenderPool, $updated, $released): ?int { $contenderPool->close(); } - $this->assertSame(20, $count); + $this->assertSame($operation === 'update' ? 20 : 19, $count); $this->assertSame(30, $free); - $this->assertSame(20, (int) DB::table('hinted_posts')->sum('votes')); + $this->assertSame($operation === 'update' ? 20 : 1, (int) DB::table('hinted_posts')->sum('votes')); + $this->assertSame($operation === 'update' ? 30 : 11, DB::table('hinted_posts')->count()); + $this->assertSame(1, DB::table('hinted_posts')->where('id', 1)->value('votes')); } } diff --git a/tests/Integration/Queue/Fixtures/OutboxClaimTestCase.php b/tests/Integration/Queue/Fixtures/OutboxClaimTestCase.php index 7d5aea66e6..d0cc135230 100644 --- a/tests/Integration/Queue/Fixtures/OutboxClaimTestCase.php +++ b/tests/Integration/Queue/Fixtures/OutboxClaimTestCase.php @@ -20,6 +20,8 @@ use Hypervel\Testbench\Attributes\ResetRefreshDatabaseState; use Hypervel\Testbench\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; use function Hypervel\Coroutine\parallel; @@ -39,6 +41,9 @@ protected function defineEnvironment(Application $app): void $config->set("database.connections.{$database}.pool.testing_enabled", true); $config->set("database.connections.{$database}.pool.max_connections", 4); $config->set("database.connections.{$database}.pool.heartbeat_interval", null); + if ($config->string("database.connections.{$database}.driver") !== 'sqlite') { + $config->set("database.connections.{$database}.lock_timeout", 1); + } } /** @@ -61,7 +66,7 @@ public function testClaimBoundsAndStatementCountOnTheLiveEngine(): void $database = $connection->getName(); $rows = [$this->row($now - 10), $this->row($now - 30), $this->row($now - 20), $this->row($now + 60)]; $repository->insert($connection, $rows); - $skipLocked = $connection->lockForPopping() === 'FOR UPDATE SKIP LOCKED'; + $skipLocked = $connection->supportsSkipLocked(); $transactions = 0; $this->app->make('events')->listen(TransactionBeginning::class, static function () use (&$transactions): void { ++$transactions; }); $connection->enableQueryLog(); @@ -75,7 +80,7 @@ public function testClaimBoundsAndStatementCountOnTheLiveEngine(): void } $postgres = $connection->getDriverName() === 'pgsql'; - $this->assertCount($postgres ? 1 : ($skipLocked ? 2 : 3), $queries); + $this->assertCount($postgres ? 1 : 3, $queries); $this->assertSame($skipLocked && ! $postgres ? 1 : 0, $transactions); $this->assertSame([$rows[1]['uuid'], $rows[2]['uuid']], array_column($messages, 'uuid')); $this->assertSame([$now - 30, $now - 20], array_column($messages, 'eligibleAt')); @@ -121,7 +126,7 @@ public function testCompetingClaimsAndExpiredOwnershipOnIndependentSessions(): v $database = $connection->getName(); $rows = array_map(fn (): array => $this->row($now), range(1, 4)); $repository->insert($connection, $rows); - $fallback = $connection->lockForPopping() !== 'FOR UPDATE SKIP LOCKED'; + $fallback = ! $connection->supportsSkipLocked(); // Force both fallback claimants to see the same candidates before either updates them. if ($fallback) { @@ -180,7 +185,7 @@ public function testSkipLockedClaimsAdvancePastAnUncommittedOwner(): void { $connection = DB::connection(); - if ($connection->lockForPopping() !== 'FOR UPDATE SKIP LOCKED') { + if (! $connection->supportsSkipLocked()) { $this->markTestSkipped('This engine uses the conditional-update claim path.'); } @@ -221,6 +226,192 @@ function () use ($repository, $database, $ready, $release): array { $this->assertSame([$locked['uuid']], array_column($repository->claim($database, 1), 'uuid')); } + public function testConcurrentClaimsLeaveRowsForEachRelay(): void + { + $connection = DB::connection(); + if (! $connection->supportsSkipLocked() || $connection->getDriverName() === 'pgsql') { + $this->markTestSkipped('This regression needs the InnoDB locking-read claim path.'); + } + + Date::setTestNow('2040-01-01 12:00:00'); + $now = Date::now()->getTimestamp(); + $repository = $this->app->make(DatabaseOutboxRepository::class); + $database = $connection->getName(); + $repository->insert($connection, array_map( + fn (int $index): array => $this->row($now - ($index % 2 === 0 ? 20 : 10)), + range(0, 127), + )); + $ready = new Channel(2); + $release = new Channel(2); + + // Interleave the actual SELECT and UPDATE, after both relays + // have acquired their own candidate locks. + $this->app->make('events')->listen(QueryExecuted::class, function (QueryExecuted $event) use ($ready, $release): void { + if (str_contains($event->sql, 'job_outbox') && str_contains($event->sql, 'for update')) { + $this->assertTrue($ready->push(true, 5)); + $this->assertTrue($release->pop(5)); + } + }); + + try { + [$first, $second] = parallel([ + fn (): array => $repository->claim($database, 64), + fn (): array => $repository->claim($database, 64), + function () use ($ready, $release): void { + $this->assertTrue($ready->pop(5)); + $this->assertTrue($ready->pop(5)); + $release->push(true); + $release->push(true); + }, + ]); + $this->assertNotEmpty($first); + $this->assertNotEmpty($second); + $this->assertSame([], array_intersect(array_column($first, 'id'), array_column($second, 'id'))); + } finally { + $ready->close(); + $release->close(); + } + } + + public function testConcurrentClaimsRetryWhenMovingDueIndexEntries(): void + { + $connection = DB::connection(); + if (! $connection->supportsSkipLocked() || $connection->getDriverName() === 'pgsql') { + $this->markTestSkipped('This regression needs InnoDB next-key locking.'); + } + + Date::setTestNow('2040-01-01 12:00:00'); + $now = Date::now()->getTimestamp(); + $repository = $this->app->make(DatabaseOutboxRepository::class); + $database = $connection->getName(); + $repository->insert($connection, array_map(fn (): array => $this->row($now - 10), range(1, 4))); + $held = new Channel(1); + $unlock = new Channel(1); + $startSecond = new Channel(1); + $selected = new Channel(2); + $update = new Channel(2); + $reads = 0; + $this->app->make('events')->listen(QueryExecuted::class, function (QueryExecuted $event) use ($selected, $update, &$reads): void { + if (str_starts_with($event->sql, 'select ') && str_contains($event->sql, 'skip locked') && ++$reads <= 2) { + $this->assertTrue($selected->push(true, 5)); + $this->assertTrue($update->pop(5)); + } + }); + + try { + [, $first, $second] = parallel([ + function () use ($held, $unlock, $startSecond, $now): void { + DB::transaction(function () use ($held, $unlock, $now): void { + $index = DB::connection()->getSchemaBuilder()->qualifyIndexName('job_outbox_available_at_id_index'); + // Hold the secondary entries, so the first relay skips them + // before taking any locks on their primary records. + DB::table('job_outbox')->forceIndex($index)->where('available_at', $now - 10) + ->whereIn('id', [2, 4])->lockForUpdate()->get(); + $held->push(true); + $this->assertTrue($unlock->pop(5)); + }); + $startSecond->push(true); + }, + function () use ($held, $repository, $database): array { + $this->assertTrue($held->pop(5)); + + return $repository->claim($database, 100); + }, + function () use ($startSecond, $repository, $database): array { + $this->assertTrue($startSecond->pop(5)); + + return $repository->claim($database, 100); + }, + function () use ($selected, $unlock, $update): void { + $this->assertTrue($selected->pop(5)); + $unlock->push(true); + $this->assertTrue($selected->pop(5)); + $update->push(true); + $update->push(true); + }, + ]); + $messages = [...$first, ...$second]; + $ids = array_column($messages, 'id'); + sort($ids); + $this->assertSame([1, 2, 3, 4], $ids); + $this->assertSame([1, 1, 1, 1], array_column($messages, 'attempts')); + } finally { + $held->close(); + $unlock->close(); + $startSecond->close(); + $selected->close(); + $update->close(); + } + } + + #[TestWith(['accepted'])] + #[TestWith(['deferred'])] + #[TestWith(['failed'])] + #[TestWith(['retry'])] + public function testBatchMutationsDoNotWaitForAnotherRelaysRows(string $operation): void + { + $connection = DB::connection(); + if ($connection->getDriverName() === 'sqlite') { + $this->markTestSkipped('These mutation regressions require row locks.'); + } + + Date::setTestNow('2040-01-01 12:00:00'); + $now = Date::now()->getTimestamp(); + $repository = $this->app->make(DatabaseOutboxRepository::class); + $database = $connection->getName(); + $rows = []; + for ($index = 0; $index < 128; ++$index) { + $rows[] = [...$this->row($now - ($index % 2 === 0 ? 20 : 10)), 'deliver_at' => $now + 600]; + } + $repository->insert($connection, $rows); + $ready = new Channel(2); + $release = new Channel(2); + + try { + $messages = $repository->claim($database, 64); + $other = $repository->claim($database, 64); + $this->assertCount(64, $messages); + $this->assertCount(64, $other); + $ids = array_column($messages, 'id'); + if ($operation === 'retry') { + $connection->table('job_outbox')->whereIn('id', $ids)->update(['available_at' => $now - 1]); + } + + [, $affected] = parallel([ + function () use ($other, $ready, $release): void { + DB::transaction(function () use ($other, $ready, $release): void { + $query = DB::table('job_outbox')->whereIn('id', array_column($other, 'id')); + if (in_array(DB::connection()->getDriverName(), ['mysql', 'mariadb'], true)) { + $query->forceIndex('PRIMARY'); + } + $query->lockForUpdate()->get(); + $ready->push(true); + $this->assertTrue($release->pop(5)); + }); + }, + function () use ($repository, $database, $messages, $ids, $operation, $ready, $release): int { + try { + $this->assertTrue($ready->pop(5)); + + return match ($operation) { + 'accepted' => $repository->settle($database, $messages, new OutboxBatchResult($ids)), + 'deferred' => $repository->settle($database, $messages, new OutboxBatchResult(deferred: $ids)), + 'failed' => $repository->settle($database, $messages, new OutboxBatchResult(failure: new RuntimeException('Transport failed.'))), + 'retry' => $repository->retryAll($database), + }; + } finally { + $release->push(true); + } + }, + ]); + $this->assertSame(64, $affected); + $this->assertSame(64, DB::table('job_outbox')->where('lease_token', $other[0]->leaseToken)->count()); + } finally { + $ready->close(); + $release->close(); + } + } + /** * Create opaque stored bytes and exact epoch deadlines for claim tests. */ diff --git a/tests/Jwt/Providers/LcobucciTest.php b/tests/Jwt/Providers/LcobucciTest.php index 25acf659d7..dd5250a72f 100644 --- a/tests/Jwt/Providers/LcobucciTest.php +++ b/tests/Jwt/Providers/LcobucciTest.php @@ -513,7 +513,7 @@ public function testShouldReturnTheKeys(): void $this->assertSame($keys, $provider->getKeys()); } - public function testSetAlgoTakesEffectOnEncoding(): void + public function testShouldSignWithTheNewAlgorithmAfterSettingTheAlgorithm(): void { $payload = ['sub' => 1, 'iat' => $this->testNowTimestamp]; @@ -523,10 +523,23 @@ public function testSetAlgoTakesEffectOnEncoding(): void $token = $provider->encode($payload); $header = json_decode(base64_decode(explode('.', $token)[0]), true); + $this->assertEquals(Provider::ALGO_HS512, $provider->getAlgo()); $this->assertEquals(Provider::ALGO_HS512, $header['alg']); + $this->assertEquals('1', $provider->decode($token)['sub']); } - public function testSetSecretTakesEffectOnSigning(): void + public function testShouldNotVerifyTokensSignedWithThePreviousSecretAfterSettingTheSecret(): void + { + $this->expectException(TokenInvalidException::class); + $this->expectExceptionMessageIs('Token Signature could not be verified.'); + + $provider = $this->getProvider($this->getRandomString(), Provider::ALGO_HS256); + $token = $provider->encode(['sub' => 1]); + + $provider->setSecret($this->getRandomString())->decode($token); + } + + public function testShouldSignWithTheNewSecretAfterSettingTheSecret(): void { $payload = ['sub' => 1, 'iat' => $this->testNowTimestamp]; @@ -548,6 +561,46 @@ public function testSetSecretTakesEffectOnSigning(): void $this->getProvider($originalSecret, Provider::ALGO_HS256)->decode($token); } + public function testCanSwitchFromASymmetricToAnAsymmetricAlgorithm(): void + { + $provider = $this->getProvider($this->getRandomString(), Provider::ALGO_HS256) + ->setAlgo(Provider::ALGO_RS256) + ->setKeys(['private' => $this->getDummyPrivateKey(), 'public' => $this->getDummyPublicKey()]); + + $token = $provider->encode(['sub' => 1]); + $header = json_decode(base64_decode(explode('.', $token)[0]), true); + + $this->assertEquals(Provider::ALGO_RS256, $header['alg']); + $this->assertEquals('1', $provider->decode($token)['sub']); + } + + #[DataProvider('incompleteAsymmetricConfigurationProvider')] + public function testShouldThrowAnExceptionWhenSwitchingToAnAsymmetricAlgorithmWithoutKeys(bool $decode): void + { + $provider = $this->getProvider($this->getRandomString(), Provider::ALGO_HS256) + ->setAlgo(Provider::ALGO_RS256); + + $this->expectException(JwtException::class); + $this->expectExceptionMessageIs('Public key is not set.'); + + if ($decode) { + $provider->decode('foo.bar.baz'); + } else { + $provider->encode(['sub' => 1]); + } + } + + /** + * Provide operations that require a complete signing configuration. + */ + public static function incompleteAsymmetricConfigurationProvider(): array + { + return [ + 'encode' => [false], + 'decode' => [true], + ]; + } + public function testShouldThrowAnExceptionWhenTheSecretHasBeenUpdatedAndAnOldTokenIsUsed(): void { $payload = ['sub' => '1', 'exp' => $this->testNowTimestamp + 3600, 'iat' => $this->testNowTimestamp, 'iss' => '/foo']; @@ -714,14 +767,6 @@ public static function credentialFailureProvider(): array '__construct', [0, 2], ], - 'new keys' => [ - static fn (): Lcobucci => (new Lcobucci('does_not_matter', Provider::ALGO_RS256, [ - 'private' => file_get_contents("{$keys}/id_rsa"), - 'public' => file_get_contents("{$keys}/id_rsa.pub"), - ]))->setKeys(['private' => file_get_contents("{$keys}/id_rsa_alt")]), - 'setKeys', - [0], - ], 'key file' => [ static fn (): Lcobucci => new Lcobucci('does_not_matter', Provider::ALGO_RS256, [ 'private' => "file://{$keys}/missing", diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index dba089bbe6..60209c9932 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -15,6 +15,7 @@ use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\DatabaseTransactionsManager; @@ -94,13 +95,13 @@ public function testQueueNamesPreserveZeroAndDefaultEmptyString(): void $this->assertSame('Emails', $queue->getQueue(UnitQueueName::Emails)); } - public function testLockForPoppingUsesThePdoConnectionCapability(): void + public function testLockForPoppingUsesTheConnectionCapability(): void { $resolver = m::mock(ConnectionResolverInterface::class); - $connection = m::mock(PdoConnection::class); + $connection = m::mock(Connection::class); $resolver->shouldReceive('connection')->once()->with(null)->andReturn($connection); - $connection->shouldReceive('lockForPopping')->once()->andReturn('FOR UPDATE SKIP LOCKED'); + $connection->shouldReceive('supportsSkipLocked')->once()->andReturn(true); $queue = new TestDatabaseQueue( resolver: $resolver, @@ -113,7 +114,7 @@ public function testLockForPoppingUsesThePdoConnectionCapability(): void $this->assertSame('FOR UPDATE SKIP LOCKED', $queue->lockForPopping()); } - public function testLockForPoppingUsesAConservativeFallbackForNonPdoConnections(): void + public function testLockForPoppingUsesAConservativeFallbackForInterfaceOnlyConnections(): void { $resolver = m::mock(ConnectionResolverInterface::class); $resolver->shouldReceive('connection')->once()->with(null)->andReturn(m::mock(ConnectionInterface::class)); diff --git a/tests/Queue/RateLimitedTest.php b/tests/Queue/RateLimitedTest.php index 6649b1c8c2..85369f8279 100644 --- a/tests/Queue/RateLimitedTest.php +++ b/tests/Queue/RateLimitedTest.php @@ -11,8 +11,10 @@ use Hypervel\RateLimiter\LimitResult; use Hypervel\RateLimiter\RateLimiter; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use Mockery as m; use Mockery\MockInterface; +use PHPUnit\Framework\Attributes\DataProvider; use stdClass; enum RateLimitedTestStringEnum: string @@ -107,14 +109,19 @@ public function testExplicitStoreOverridesTheNamedLimiterStoreAndSurvivesSeriali $this->assertSame('handled', $middleware->handle(new stdClass, fn (): string => 'handled')); } - public function testDeniedJobUsesDecisionRetryTimeAndThreeSecondBuffer(): void - { + #[DataProvider('retryDelayProvider')] + public function testDeniedJobUsesConfiguredRetryDelayAfterSerialization( + int $retryMicroseconds, + ?int $padding, + ?int $fixedDelay, + int $expectedDelay, + ): void { $policy = Limit::perMinute(1); $store = m::mock(Limiter::class); $store->shouldReceive('consumeMany') ->once() ->with([$policy], 'uploads') - ->andReturn([new LimitResult(false, 1, 0, 7_000_000, 7_000_000)]); + ->andReturn([new LimitResult(false, 1, 0, $retryMicroseconds, $retryMicroseconds)]); $manager = $this->mockRateLimiter(); $manager->shouldReceive('limiter')->andReturn(fn () => $policy); @@ -122,9 +129,42 @@ public function testDeniedJobUsesDecisionRetryTimeAndThreeSecondBuffer(): void $manager->shouldReceive('store')->with(null)->andReturn($store); $job = m::mock(); - $job->shouldReceive('release')->once()->with(10)->andReturnNull(); + $job->shouldReceive('release')->once()->with($expectedDelay)->andReturnNull(); + + $middleware = new RateLimited('uploads'); + + if ($padding !== null) { + $this->assertSame($middleware, $middleware->releasePadding($padding)); + } + + if ($fixedDelay !== null) { + $middleware->releaseAfter($fixedDelay); + } + + $middleware = unserialize(serialize($middleware)); + + $this->assertNull($middleware->handle($job, fn () => 'handled')); + } + + public static function retryDelayProvider(): array + { + return [ + 'default padding' => [7_000_000, null, null, 10], + 'custom padding' => [7_000_000, 5, null, 12], + 'zero padding preserves rounding' => [100_000, 0, null, 1], + 'zero padding preserves long delays' => [3_600_000_000, 0, null, 3600], + 'fixed delay overrides padding' => [7_000_000, 5, 60, 60], + 'fixed zero overrides padding' => [7_000_000, 5, 0, 0], + ]; + } + + public function testNegativeReleasePaddingIsRejected(): void + { + $this->mockRateLimiter(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Release padding must not be negative.'); - $this->assertNull((new RateLimited('uploads'))->handle($job, fn () => 'handled')); + (new RateLimited('uploads'))->releasePadding(-1); } public function testGroupUsesTheFirstDeniedPolicyToReleaseTheJob(): void diff --git a/tests/Support/JsonTest.php b/tests/Support/JsonTest.php index 4fea646dd7..18faca18d4 100644 --- a/tests/Support/JsonTest.php +++ b/tests/Support/JsonTest.php @@ -10,6 +10,7 @@ use Hypervel\Tests\TestCase; use JsonException; use PHPUnit\Framework\Attributes\DataProvider; +use stdClass; use ValueError; class JsonTest extends TestCase @@ -104,6 +105,63 @@ public function testDecodeThrowsOnInvalidJson(): void Json::decode('{invalid}'); } + #[DataProvider('jsonWithExactNumbers')] + public function testDecodeNumbersAsStringsPreservesNumberTokens(string $json, mixed $expected): void + { + $this->assertSame($expected, Json::decodeNumbersAsStrings($json)); + } + + /** + * Provide numeric tokens and strings whose contents must remain untouched. + */ + public static function jsonWithExactNumbers(): iterable + { + yield 'decimal scalar' => ['1.234567890123456789', '1.234567890123456789']; + yield 'lexical forms' => ['[1.10,-0,1E+2,1e9999]', ['1.10', '-0', '1E+2', '1e9999']]; + yield 'nested values' => [ + '{"123":{"rates":[123456789012345678901,0.000000000000000001]},"flags":[true,false,null],"empty":[]}', + [123 => ['rates' => ['123456789012345678901', '0.000000000000000001']], 'flags' => [true, false, null], 'empty' => []], + ]; + yield 'null scalar' => ['null', null]; + yield 'string scalar' => ['"123\u0034"', '1234']; + + $escaped = str_repeat('é123"\\', 2000); + + yield 'escaped strings' => [json_encode([$escaped, 1], JSON_THROW_ON_ERROR), [$escaped, '1']]; + } + + public function testDecodeNumbersAsStringsPreservesObjectShape(): void + { + $value = Json::decodeNumbersAsStrings('{"rate":1.10,"empty":{}}', false); + + $this->assertSame('1.10', $value->rate); + $this->assertInstanceOf(stdClass::class, $value->empty); + } + + #[DataProvider('invalidJsonWithNumbers')] + public function testDecodeNumbersAsStringsRejectsOriginalInvalidInput(string $json, int $depth): void + { + json_decode($json, true, $depth + 1); + $error = json_last_error(); + + $this->expectException(JsonException::class); + $this->expectExceptionCode($error); + + Json::decodeNumbersAsStrings($json, depth: $depth); + } + + /** + * Provide invalid documents that token quoting must not repair. + */ + public static function invalidJsonWithNumbers(): iterable + { + yield 'leading zero' => ['[01]', 8]; + yield 'missing fraction' => ['[1.]', 8]; + yield 'numeric key' => ['{1:2}', 8]; + yield 'invalid UTF-8' => ["[\"\xff\",1]", 8]; + yield 'too deep' => ['[[1]]', 1]; + } + public function testEncodeThrowsOnInvalidValue(): void { $this->expectException(JsonException::class); @@ -117,6 +175,7 @@ public function testDefaultMaximumNestingDepthRoundTrips(): void $json = Json::encode($value); $this->assertSame($value, Json::decode($json)); + $this->assertSame($value, Json::decodeNumbersAsStrings($json)); $this->assertTrue(Json::validate($json)); } @@ -126,6 +185,7 @@ public function testExplicitMaximumNestingDepthRoundTrips(): void $json = Json::encode($value, depth: 8); $this->assertSame($value, Json::decode($json, depth: 8)); + $this->assertSame($value, Json::decodeNumbersAsStrings($json, depth: 8)); $this->assertTrue(Json::validate($json, depth: 8)); } diff --git a/tests/Testbench/Databases/MigrateWithHypervelMigrationsTest.php b/tests/Testbench/Databases/MigrateWithHypervelMigrationsTest.php index d8e340b7be..5f80ef854e 100644 --- a/tests/Testbench/Databases/MigrateWithHypervelMigrationsTest.php +++ b/tests/Testbench/Databases/MigrateWithHypervelMigrationsTest.php @@ -49,6 +49,7 @@ public function itStartsTheFirstUserTransactionInsideTheLazyTestTransaction(): v $connection->beginTransaction(); $this->assertSame(2, $connection->transactionLevel()); + $this->assertSame(0, $connection->table('users')->count()); $connection->rollBack(); diff --git a/tests/Testbench/Databases/MigrateWithHypervelMigrationsUsingDatabaseTruncationTest.php b/tests/Testbench/Databases/MigrateWithHypervelMigrationsUsingDatabaseTruncationTest.php index f02dfb4081..b9e21c048a 100644 --- a/tests/Testbench/Databases/MigrateWithHypervelMigrationsUsingDatabaseTruncationTest.php +++ b/tests/Testbench/Databases/MigrateWithHypervelMigrationsUsingDatabaseTruncationTest.php @@ -19,6 +19,7 @@ use function Hypervel\Testbench\workbench_path; #[WithConfig('database.default', 'testing')] +#[WithConfig('database.connections.testing.pool.testing_enabled', true)] class MigrateWithHypervelMigrationsUsingDatabaseTruncationTest extends TestCase { use DatabaseTruncation; @@ -61,6 +62,8 @@ public function itMigratesOnceAndThenTruncatesAcrossApplicationLifecycles(int $l $this->assertSame($lifecycle === 2, $this->truncated); $this->assertTrue(Schema::hasTable('users')); $this->assertTrue(Schema::hasTable('testbench_users')); + DB::releaseIdleConnections(); + $this->assertTrue(Schema::hasTable('testbench_users')); $this->assertSame(0, DB::connection()->transactionLevel()); $this->assertSame( 0, diff --git a/tests/Testbench/Databases/PooledLazilyRefreshDatabaseTest.php b/tests/Testbench/Databases/PooledLazilyRefreshDatabaseTest.php new file mode 100644 index 0000000000..a2cb110021 --- /dev/null +++ b/tests/Testbench/Databases/PooledLazilyRefreshDatabaseTest.php @@ -0,0 +1,59 @@ +exec('create table retained_records (value integer)'); + $saved->exec('insert into retained_records values (42)'); + RefreshDatabaseState::$inMemoryConnections['testing'] = $saved; + RefreshDatabaseState::$migrated = true; + } + + #[DataProvider('firstQueries')] + public function testRestoresTheDatabaseBeforeTheFirstQuery(string $scope): void + { + $connection = DB::connection(); + $this->assertSame(0, $connection->transactionLevel()); + $this->assertSame(RefreshDatabaseState::$inMemoryConnections['testing'], $connection->getPdo()); + $query = fn (): mixed => $connection->table('retained_records')->value('value'); + + $this->assertSame(42, match ($scope) { + 'pinned' => $connection->withPinnedSession($query), + 'transaction' => $connection->transaction($query), + default => $query(), + }); + $this->assertSame(1, $connection->transactionLevel()); + } + + /** + * Provide first-query scopes that must share the restored database. + */ + public static function firstQueries(): array + { + return ['ordinary query' => ['ordinary'], 'explicitly pinned query' => ['pinned'], 'transaction first' => ['transaction']]; + } +} diff --git a/tests/Testbench/Databases/PooledRefreshDatabaseTest.php b/tests/Testbench/Databases/PooledRefreshDatabaseTest.php new file mode 100644 index 0000000000..3f44c63d93 --- /dev/null +++ b/tests/Testbench/Databases/PooledRefreshDatabaseTest.php @@ -0,0 +1,41 @@ +exec('create table retained_records (value integer)'); + $saved->exec('insert into retained_records values (42)'); + RefreshDatabaseState::$inMemoryConnections['testing'] = $saved; + RefreshDatabaseState::$migrated = true; + } + + public function testRestoredDatabaseSurvivesTheSetupCoroutine(): void + { + $connection = DB::connection(); + $this->assertSame(1, $connection->transactionLevel()); + $this->assertSame(RefreshDatabaseState::$inMemoryConnections['testing'], $connection->getPdo()); + $this->assertSame(42, $connection->table('retained_records')->value('value')); + } +} diff --git a/tests/Validation/ValidationCompiledExecutionTest.php b/tests/Validation/ValidationCompiledExecutionTest.php index 5893068dee..93e6df7b8f 100644 --- a/tests/Validation/ValidationCompiledExecutionTest.php +++ b/tests/Validation/ValidationCompiledExecutionTest.php @@ -177,6 +177,82 @@ function (int $scale, string $attribute, string $value) use (&$calls, &$argument } } + public function testFieldComparisonsEnforceEachOperandsExponentPolicy(): void + { + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + foreach (['gt', 'gte', 'lt', 'lte'] as $rule) { + foreach (['value', 'other'] as $attribute) { + $data = ['value' => '1e1001', 'other' => '1E-1001']; + $validator = $this->makeValidator($data, ['value' => $rule . ':other'], validatorClass: $validatorClass); + $seen = []; + $validator->ensureExponentWithinAllowedRangeUsing( + function (int $scale, string $field, mixed $input) use (&$seen, $attribute): bool { + $seen[] = [$field, $input]; + + return $field !== $attribute; + }, + ); + + $this->assertFalse($validator->passes()); + $this->assertContains([$attribute, $data[$attribute]], $seen); + } + } + } + } + + public function testRejectedComparisonExponentsRemainPrintableInValidationMessages(): void + { + $translator = new Translator(new ArrayLoader, 'en'); + $translator->addLines(['validation.gt.numeric' => ':attribute must be greater than :value.'], 'en'); + + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + $validator = new $validatorClass($translator, ['value' => 5, 'other' => '1e1001'], ['value' => 'gt:other']); + + $this->assertFalse($validator->passes()); + $this->assertSame('value must be greater than 1e1001.', $validator->messages()->first('value')); + } + } + + public function testNumericInputBeyondTheDefaultExponentRangeFailsValidation(): void + { + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + foreach ([ + [['value' => '1e1001', 'other' => 1], 'gt:other'], + [['value' => '1e1001', 'other' => 1], 'gte:other'], + [['value' => '1e-1001', 'other' => 1], 'lt:other'], + [['value' => '1e-1001', 'other' => 1], 'lte:other'], + [['value' => 1, 'other' => '1e-1001'], 'gt:other'], + [['value' => 1, 'other' => '1e-1001'], 'gte:other'], + [['value' => 1, 'other' => '1e1001'], 'lt:other'], + [['value' => 1, 'other' => '1e1001'], 'lte:other'], + [['value' => '1e1001'], 'multiple_of:1'], + [['value' => '1e-1001'], 'multiple_of:1e-1001'], + [['value' => "5\x0C"], 'multiple_of:1'], + [['value' => "\0" . '5'], 'multiple_of:1'], + [['value' => NAN], 'multiple_of:1'], + ] as [$data, $rule]) { + $this->assertFalse($this->makeValidator($data, ['value' => $rule], validatorClass: $validatorClass)->passes()); + } + } + } + + public function testMultipleOfHonorsExponentOverridesWithoutRestrictingRuleLiterals(): void + { + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + $validator = $this->makeValidator(['value' => '1e1001'], ['value' => 'multiple_of:1'], validatorClass: $validatorClass); + $arguments = []; + $validator->ensureExponentWithinAllowedRangeUsing(function (...$values) use (&$arguments): bool { + $arguments[] = $values; + + return true; + }); + + $this->assertTrue($validator->passes()); + $this->assertSame([[1001, 'value', '1e1001']], $arguments); + $this->assertTrue($this->makeValidator(['value' => 0], ['value' => 'multiple_of:1e1001'], validatorClass: $validatorClass)->passes()); + } + } + public function testMalformedAndNonFiniteNumericSizesFailWithoutWarnings(): void { $literalCases = [ diff --git a/types/Database/Eloquent/Collection.php b/types/Database/Eloquent/Collection.php index ff3b74d997..4ef1b4c33d 100644 --- a/types/Database/Eloquent/Collection.php +++ b/types/Database/Eloquent/Collection.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Hypervel\Contracts\Database\Query\Expression; -use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Collection; use function PHPStan\Testing\assertType; @@ -36,37 +35,43 @@ assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate(['string'], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate(['string' => []], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate(['string'], 'string', 'string')); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate(['string' => function (Builder $query) { +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate(['string' => function ($query) { + assertType('Hypervel\Database\Eloquent\Builder<*>', $query); }], 'string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadCount('string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadCount(['string'])); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadCount(['string' => []])); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadCount(['string' => function (Builder $query) { +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadCount(['string' => function ($query) { + assertType('Hypervel\Database\Eloquent\Builder<*>', $query); }])); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMax('string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMax(['string'], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMax(['string' => []], 'string')); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMax(['string' => function (Builder $query) { +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMax(['string' => function ($query) { + assertType('Hypervel\Database\Eloquent\Builder<*>', $query); }], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMin('string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMin(['string'], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMin(['string' => []], 'string')); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMin(['string' => function (Builder $query) { +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMin(['string' => function ($query) { + assertType('Hypervel\Database\Eloquent\Builder<*>', $query); }], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadSum('string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadSum(['string'], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadSum(['string' => []], 'string')); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadSum(['string' => function (Builder $query) { +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadSum(['string' => function ($query) { + assertType('Hypervel\Database\Eloquent\Builder<*>', $query); }], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAvg('string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAvg(['string'], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAvg(['string' => []], 'string')); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAvg(['string' => function (Builder $query) { +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAvg(['string' => function ($query) { + assertType('Hypervel\Database\Eloquent\Builder<*>', $query); }], 'string')); /** @@ -86,7 +91,8 @@ function assertEloquentCollectionAggregateExpressionTypes(Collection $collection assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists('string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string'])); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string' => []])); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string' => function (Builder $query) { +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string' => function ($query) { + assertType('Hypervel\Database\Eloquent\Builder<*>', $query); }])); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMissing('string')); @@ -96,13 +102,21 @@ function assertEloquentCollectionAggregateExpressionTypes(Collection $collection // assertType('Hypervel\Database\Eloquent\Relations\Relation<*,*,*>', $query); }])); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorph('string', ['string'])); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorph('string', ['string' => ['foo' => fn ($q) => $q]])); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorph('string', ['string' => 'foo'])); - -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorphCount('string', ['string'])); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorphCount('string', ['string' => ['foo' => fn ($q) => $q]])); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorphCount('string', ['string' => 'foo'])); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorph('string', [User::class => ['string']])); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorph('string', [User::class => ['string' => ['foo' => fn ($q) => $q]]])); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorph('string', [User::class => ['string' => function ($query) { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $query); +}]])); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorph('string', [User::class => 'string'])); + +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorphCount('string', [User::class => ['string']])); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorphCount('string', [User::class => ['foo' => fn ($q) => $q]])); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorphCount('string', [User::class => ['string' => function ($query) { + assertType('Hypervel\Database\Eloquent\Builder<*>', $query); +}]])); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorphCount('string', [User::class => 'string'])); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorph('string', [2 => ['string']])); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorphCount('string', [2 => ['string' => []]])); assertType('bool', $collection->contains(function ($user) { assertType('User', $user); @@ -169,6 +183,9 @@ function assertEloquentCollectionAggregateExpressionTypes(Collection $collection 'Hypervel\Database\Eloquent\Collection', $collection->fresh(['string']) ); +assertType('Hypervel\Database\Eloquent\Collection', $collection->fresh(['string' => function ($relation) { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); +}])); assertType('Hypervel\Database\Eloquent\Collection', $collection->diff($collection)); assertType('Hypervel\Database\Eloquent\Collection', $collection->diff([new User])); diff --git a/types/Database/Eloquent/Model.php b/types/Database/Eloquent/Model.php index 782225c118..92c45033ae 100644 --- a/types/Database/Eloquent/Model.php +++ b/types/Database/Eloquent/Model.php @@ -10,6 +10,7 @@ use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\HasCollection; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Notifications\DatabaseNotification; use User; @@ -42,6 +43,39 @@ function test(User $user, Post $post, Comment $comment, Article $article, Databa assertType('Hypervel\Database\Eloquent\Builder', User::query()); assertType('Hypervel\Database\Eloquent\Builder', $user->newQuery()); + assertType('Hypervel\Database\Eloquent\Builder', User::with(['posts' => function ($relation) { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }])); + assertType('User', $user->load(['posts' => function ($relation) { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }])); + assertType('User', $user->loadMissing(['posts' => function ($relation) { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }])); + assertType('User|null', $user->fresh(['posts' => function ($relation) { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }])); + assertType('User', $user->load(['posts' => ['comments' => fn ($relation) => $relation]])); + assertType('User', $user->loadMorph('parentable', [Post::class => ['comments' => function ($relation) { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }]])); + assertType('User', $user->loadMorph('parentable', [Post::class => 'comments'])); + + $constraint = new class { + /** + * Constrain eager loading through an invokable object. + * + * @param Relation<*, *, *> $relation + */ + public function __invoke(Relation $relation): void + { + $relation->where('active', true); + } + }; + assertType('Hypervel\Database\Eloquent\Builder', User::with(['posts' => $constraint])); + assertType('Hypervel\Database\Eloquent\Builder', $user->newQuery()->withOnly(['posts' => $constraint])); + assertType('User', $user->load(['posts' => $constraint])); + assertType('User', $user->loadMissing(['posts' => $constraint])); assertType('Hypervel\Database\Eloquent\Builder', $user->withTrashed()); assertType('Hypervel\Database\Eloquent\Builder', $user->onlyTrashed()); assertType('Hypervel\Database\Eloquent\Builder', $user->withoutTrashed()); @@ -69,6 +103,35 @@ function test(User $user, Post $post, Comment $comment, Article $article, Databa assertType('User', $user->loadMorphMin('parentable', [Post::class => ['comments']], $expression)); assertType('User', $user->loadMorphSum('parentable', [Post::class => ['comments']], $expression)); assertType('User', $user->loadMorphAvg('parentable', [Post::class => ['comments']], $expression)); + assertType('User', $user->loadCount(['posts' => function ($query) { + assertType('Hypervel\Database\Eloquent\Builder<*>', $query); + }])); + assertType('User', $user->loadMorphCount('parentable', [Post::class => ['comments' => function ($query) { + assertType('Hypervel\Database\Eloquent\Builder<*>', $query); + }]])); + $aggregate = static fn (Builder $query): Builder => $query->where('active', true); + assertType('User', $user->loadAggregate(['posts' => $aggregate], $expression, 'sum')); + assertType('User', $user->loadMax(['posts' => $aggregate], $expression)); + assertType('User', $user->loadMin(['posts' => $aggregate], $expression)); + assertType('User', $user->loadSum(['posts' => $aggregate], $expression)); + assertType('User', $user->loadAvg(['posts' => $aggregate], $expression)); + assertType('User', $user->loadExists(['posts' => $aggregate])); + assertType('User', $user->loadMorphAggregate('parentable', [Post::class => ['comments' => $aggregate]], $expression, 'sum')); + assertType('User', $user->loadMorphMax('parentable', [Post::class => ['comments' => $aggregate]], $expression)); + assertType('User', $user->loadMorphMin('parentable', [Post::class => ['comments' => $aggregate]], $expression)); + assertType('User', $user->loadMorphSum('parentable', [Post::class => ['comments' => $aggregate]], $expression)); + assertType('User', $user->loadMorphAvg('parentable', [Post::class => ['comments' => $aggregate]], $expression)); + assertType('User', $user->loadMorphCount('parentable', [Post::class => 'comments'])); + + foreach (['post', 2] as $alias) { + assertType('User', $user->loadMorph('parentable', [$alias => ['comments']])); + assertType('User', $user->loadMorphAggregate('parentable', [$alias => ['comments' => []]], $expression, 'sum')); + assertType('User', $user->loadMorphCount('parentable', [$alias => ['comments' => []]])); + assertType('User', $user->loadMorphMax('parentable', [$alias => ['comments' => []]], $expression)); + assertType('User', $user->loadMorphMin('parentable', [$alias => ['comments' => []]], $expression)); + assertType('User', $user->loadMorphSum('parentable', [$alias => ['comments' => []]], $expression)); + assertType('User', $user->loadMorphAvg('parentable', [$alias => ['comments' => []]], $expression)); + } assertType('bool', $user->restore()); assertType('User', $user->restoreOrCreate()); diff --git a/types/Database/Eloquent/UseEloquentBuilder.php b/types/Database/Eloquent/UseEloquentBuilder.php new file mode 100644 index 0000000000..4e74649b78 --- /dev/null +++ b/types/Database/Eloquent/UseEloquentBuilder.php @@ -0,0 +1,46 @@ +', Post::query()); + assertType('Hypervel\Types\UseEloquentBuilder\PostBuilder', FeaturedPost::query()); + assertType('Hypervel\Types\UseEloquentBuilder\FeaturedPost|null', FeaturedPost::query()->published()->first()); +} + +#[UseEloquentBuilder(PostBuilder::class)] +class Post extends Model +{ + /** @use HasBuilder> */ + use HasBuilder; +} + +class FeaturedPost extends Post +{ +} + +/** + * @template TModel of Post + * + * @extends Builder + */ +class PostBuilder extends Builder +{ + /** + * Limit results to published posts. + */ + public function published(): static + { + return $this->whereNotNull('published_at'); + } +} diff --git a/types/Pagination/Paginator.php b/types/Pagination/Paginator.php index a6a63dd89a..1f96401ed1 100644 --- a/types/Pagination/Paginator.php +++ b/types/Pagination/Paginator.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Pagination\CursorPaginator; use Hypervel\Pagination\LengthAwarePaginator; use Hypervel\Pagination\Paginator; @@ -9,7 +10,7 @@ use function PHPStan\Testing\assertType; -$items = [new Post, new Post, new Post]; +$items = new EloquentCollection([new Post, new Post, new Post]); /** @var Paginator $paginator */ $paginator = new Paginator($items, 1, 1); @@ -19,6 +20,19 @@ assertType('string|null', $paginator->fragment()); assertType('Hypervel\Pagination\Paginator', $paginator->fragment('foo')); +assertType('Hypervel\Pagination\Paginator', $paginator->loadMorph('parentable', [Post::class => ['comments' => function ($relation) { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); +}]])); +assertType('Hypervel\Pagination\Paginator', $paginator->loadMorphCount('parentable', [Post::class => ['comments' => function ($query) { + assertType('Hypervel\Database\Eloquent\Builder<*>', $query); +}]])); +assertType('Hypervel\Pagination\Paginator', $paginator->loadMorph('parentable', [Post::class => 'comments'])); +assertType('Hypervel\Pagination\Paginator', $paginator->loadMorphCount('parentable', [Post::class => 'comments'])); + +foreach (['post', 2] as $alias) { + assertType('Hypervel\Pagination\Paginator', $paginator->loadMorph('parentable', [$alias => ['comments']])); + assertType('Hypervel\Pagination\Paginator', $paginator->loadMorphCount('parentable', [$alias => ['comments' => []]])); +} $paginator->each(function ($post) { assertType('Post', $post); @@ -57,6 +71,19 @@ assertType('string|null', $cursorPaginator->fragment()); assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginator->fragment('foo')); +assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginator->loadMorph('parentable', [Post::class => ['comments' => function ($relation) { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); +}]])); +assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginator->loadMorphCount('parentable', [Post::class => ['comments' => function ($query) { + assertType('Hypervel\Database\Eloquent\Builder<*>', $query); +}]])); +assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginator->loadMorph('parentable', [Post::class => 'comments'])); +assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginator->loadMorphCount('parentable', [Post::class => 'comments'])); + +foreach (['post', 2] as $alias) { + assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginator->loadMorph('parentable', [$alias => ['comments']])); + assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginator->loadMorphCount('parentable', [$alias => ['comments' => []]])); +} $cursorPaginator->each(function ($post) { assertType('Post', $post); diff --git a/types/Support/CarbonImmutable.php b/types/Support/CarbonImmutable.php index 5df73aafff..c643d165de 100644 --- a/types/Support/CarbonImmutable.php +++ b/types/Support/CarbonImmutable.php @@ -14,6 +14,7 @@ assertType(CarbonImmutable::class, $date->addMinute()); assertType(CarbonImmutable::class, $date->addMinutes()); assertType(CarbonImmutable::class, $date->addMinutes(1.5)); +assertType(CarbonImmutable::class, $date->addMonths(2, anchorDay: 31)); assertType(CarbonImmutable::class, $date->addSecond()); assertType(CarbonImmutable::class, $date->addSeconds()); assertType(CarbonImmutable::class, $date->addSeconds(1.5));