diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md index 1c3019bfb..07aa761d8 100644 --- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md @@ -226,3 +226,83 @@ Append package entries in checklist order. Keep each entry compact but complete - **Implementation and cleanup:** Added the two parent-delegating methods with a focused explanation of the non-obvious preload boundary. Added a deterministic legacy-parent fixture that reproduces Composer's relevant method surface without depending on an installed Composer version. Migrated every remaining raw Console PHPUnit test to the framework unit-test base so coroutine execution, Mockery verification, and framework teardown remain authoritative. - **Validation:** Reflection coverage pins both methods to the concrete class and their contract return types. An isolated subprocess loads the literal Hypervel class against the legacy parent shape, the exact installed Composer PHAR preload succeeds, focused console and corrected-base tests pass, and the complete `composer fix` gate is green. Fresh self-review and independent code-review sign-off are complete. - **Revalidation:** The strengthened console contract remains unchanged and valid. The later full `console` audit must retain this class-load boundary. + +### Consolidate reflection metadata and correct callable inference + +- **Architecture and inspected risk surfaces:** Reflection is a provider-free metadata package. Its worker-lifetime state consists of immutable reflection objects, computed class metadata, readonly framework attribute instances, and immutable function-local helper objects. The audit covered every package source file, package-owned test, type surface, public helper documentation, every framework caller, the duplicate container and DI reflection managers, all cache resets, current Laravel source/tests/docs, and the complete changed-file surfaces of the relevant upstream pull requests. No coroutine, container binding, timer, channel, process, native resource, or throwable cleanup owner exists in the package. + +| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision | +|---|---|---|---|---|---| +| `reflection-01` | Defect | Minor | High | `firstClosureParameterTypes()` skips an invalid actual first parameter and classifies a closure by a later typed parameter | Inspect only the first parameter, distinguish no parameter from missing usable type information, and preserve valid union support | +| `reflection-02` | Defect | Minor | High | `Reflector::isCallable()` throws for malformed mixed callable arrays and accepts arrays with extra members | Validate the complete two-member callable-array shape before the existing PHP 7.4-compatible logic | +| `reflection-03` | Defect | Minor | High | `closureReturnTypes()` exposes the unresolved literal `parent`, producing invalid container abstractions and lazy-proxy classes | Reject `parent` with `self` and `static`; retain first-parameter fallback rather than adding relative-type resolution | +| `reflection-04` | Improvement | Improvement | High | Reflection metadata is owned by three duplicate worker-static caches with dead APIs and duplicate cleanup | Make `ClassMetadataCache` the sole class/method metadata owner and delete both package-local managers, dead APIs/tests, and redundant resets | +| `reflection-05` | Defect | Minor | High | Several native types and public PHPDoc declarations are wider, weaker, or incomplete relative to their reachable behavior | Narrow valid class targets, port current conditional and exception annotations, and document the real two-argument proxy callback | +| `reflection-06` | Improvement | Improvement | High | Public `lazy()` and `proxy()` helpers were ported without their current upstream runtime tests, type assertions, or user documentation | Port the current distinct behavior/type coverage and add proportionate helper documentation | + +- **Important rejected concerns:** Keep `object|string` on the shared metadata APIs: removing one measurable approximately 7–12 nanosecond normalization branch does not justify narrowing a useful shared API. Retain Laravel's deliberate `Exception`-only attribute-constructor fallback and null caching; programming `Error`s must still surface and remain uncached. Do not add relative-type resolution, reflection locks, recursive nested-trait discovery, a general callable rewrite, guards around invalid lazy-helper overload combinations, a package README without package-specific content, or a framework-wide conversion of unrelated reflection construction. +- **Cross-package implications:** `reflection-01` affects `events` and `foundation`; `reflection-02` affects `console`, `routing`, `view`, and `foundation`; `reflection-03` affects `container`; `reflection-04` affects `container`, `di`, `support`, `queue`, and `testing`; `reflection-05` adds accurate exception documentation in `database`, `events`, and `routing`. Those package checkboxes remain open until their own complete audits. +- **Approved implementation boundary:** The owner approved the canonical metadata owner, deletion of the redundant internal managers, the evidence-backed public class-target narrowing, and the public helper documentation. Preserve the flexible `object|string` cache API and separate class/method arrays. Route only the existing duplicate-manager consumers and scoped same-package reflection sites through the cache; do not convert unrelated framework reflection calls. Add no lock, context state, registry, compatibility layer, or relative-type resolver. +- **Implementation:** `ClassMetadataCache` now owns canonical class and method metadata, and the duplicate container and DI managers, their dead APIs/tests, and redundant test-state resets are removed. Existing manager consumers and the scoped reflection-package sites use the canonical cache. Callable-array validation, first-parameter inference, relative return-type rejection, class-target types, exception annotations, and proxy callback documentation now match their verified behavior. The current `lazy()`/`proxy()` runtime, type, and user-documentation surfaces are complete. +- **Regression tests:** Focused coverage reproduces the invalid-first-parameter, malformed-callable-array, and unresolved-`parent` failures; preserves valid union, callable, and attribute-fallback behavior; verifies canonical class/method identity and reset semantics; and covers current `lazy()`/`proxy()` runtime and PHPStan inference behavior. +- **Performance and complexity:** The consolidation is a net deletion and retains lock-free worker-static metadata reads. Separate canonical class/method caches benchmark at least as well as the duplicate managers. Existing `object|string` normalization remains for API flexibility; no new hot-path work is introduced. Closure-first-parameter handling becomes simpler and cheaper by removing collection traversal. +- **Laravel-facing result:** Supported Laravel-facing runtime call shapes and configuration remain unchanged. Current upstream `lazy()` and `proxy()` behavior and documentation are complete, and narrowed types reject only invalid inputs. +- **Validation and review:** Focused Reflection, Container, DI, Events, Routing, Eloquent, and Foundation tests are green. Dedicated helper runtime and PHPStan type coverage, `composer analyse`, the complete `composer fix` gate, `git diff --check`, stale-reference scans, package-checklist parity, fresh self-review, independent code review, and owner pre-commit approval are complete. +- **Assessment:** The accepted design fixes reproduced callable/type failures and removes duplicate metadata ownership without speculative lifecycle machinery or a public compatibility shim. + +### Preserve configuration identity across worker reloads + +- **Architecture and inspected risk surfaces:** Config is a lock-free worker-shared repository whose concrete instance is retained by long-lived framework consumers. Foundation bootstraps and reloads that repository before each worker starts. The audit covered the complete Config source, contract, facade, metadata, tests, all repository consumers, Foundation bootstrap and reload paths, Testbench overrides, framework test cleanup, the Reverb integration fixture, current Laravel source/tests/docs, and relevant Hypervel history. + +| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision | +|---|---|---|---|---|---| +| `config-01` | Defect | Major | High | Reload replaces the Config repository object, so already-resolved worker services retain stale configuration | Preserve the existing concrete repository identity, evaluate a rebuild against a temporary fresh binding, copy the complete successful result into the stable instance without recording it as a boot mutation, and restore the prior binding if evaluation fails | +| `config-02` | Defect | Major | High | Boot mutations are recorded only after the reload listener is resolved and flattened by key, losing earlier writes and exact parent/child write order | Attach one Foundation-owned instance mutation tracker when configuration bootstrap succeeds, retain mutation batches in exact order, replay them once before worker start, then permanently seal recording in that worker | +| `config-03` | Defect | Major | High | Integer ArrayAccess offsets are accepted by PHP and Laravel but forwarded into Config's strict string-key methods, causing `TypeError` | Normalize integer offsets to strings at the ArrayAccess boundary and cover positive and negative keys | +| `config-04` | Defect | Minor | High | Typed getter exception and collection generic annotations are incomplete | Restore the current upstream `InvalidArgumentException` and collection-value documentation without changing runtime behavior | +| `config-05` | Defect | Minor | High | A Foundation bootstrap test constructs an Application from the raw PHPUnit base and bypasses authoritative container/static cleanup | Use the framework unit-test base for the stateful test class | +| `config-06` | Defect | Minor | High | Config's split manifest declares an unused direct Container dependency | Remove the false dependency edge | + +- **Important rejected concerns:** Do not make configuration request-scoped, compact or normalize the ordered mutation log, add a trie, eviction policy, lock, coroutine context, callback registry, public multi-observer API, callback-failure transaction layer, or post-replay recording lifecycle. `BeforeWorkerStart` runs once per worker, replacement workers inherit the master boot log, and post-start process-global config mutation is unsupported. Do not add `getMany()` or `collection()` to the Config contract: neither has an external Config consumer, so contract expansion would be theoretical flexibility without a real conforming-implementation need. Do not convert widespread concrete Config injection to the contract; typed concrete injection is deliberate and resolves the same stable singleton. +- **Cross-package implications:** Foundation owns the mutation tracker and identity-preserving reload; Testing drops the obsolete listener-static reset; Reverb drops its eager-listener workaround. Later full `foundation`, `testing`, and `reverb` audits must retain and revalidate those boundaries. +- **Approved implementation boundary:** Replace the listener's static mutation array and stop callback with one auto-singleton Foundation tracker. The repository exposes one non-nullable internal mutation-observer hook and one internal whole-state replacement method; neither belongs to the public Config contract or facade. Replay seals before its empty-log return and suppresses self-recording with one boolean. Initial bootstrap and failed-rebuild behavior remain explicit and transactional. +- **Implementation:** Successful reloads now evaluate configuration against a temporary fresh binding and replace the stable repository's complete state only after evaluation succeeds; failed rebuilds restore the prior binding and preserve the original exception. A Foundation-owned tracker attaches at successful bootstrap, records exact mutation batches in order, and seals before one-time worker replay. The superseded listener statics, test reset, Reverb eager-resolution workaround, public callback surface, stale comments, and unused split dependency are removed. Integer ArrayAccess offsets are normalized at the boundary, typed getter and collection documentation is complete, and the stateful bootstrap test uses the framework base class. +- **Regression tests:** Cover mutations before listener resolution, exact parent/child/parent last-write order, child-only writes preserving untouched rebuilt siblings, repeated reload sealing without re-recording, stable retained repository identity, failed-rebuild binding restoration with the original exception, internal observer notification, and positive/negative integer offset get/set/isset/unset behavior. +- **Performance and complexity:** Config reads remain unchanged and lock-free. The only added production work is one object resolution and closure installation during bootstrap plus one boolean branch on boot-time writes while recording. Replay is once per worker. The accepted design removes static state, prevents unbounded post-boot retention, and rejects every speculative registry, compactor, context, and synchronization layer. +- **Laravel-facing result:** Laravel's Config API, configuration structure, and documented behavior are unchanged. The removed `afterSettingCallback()` method was a Hypervel-specific public callback on the contract, facade annotation, and concrete repository; it is replaced by narrower internal concrete hooks owned by Foundation. +- **Validation and review:** Focused Config, Foundation bootstrap/reload, Testing, and Reverb coverage is green. Strict split-manifest validation, both PHPStan configurations, the complete `composer fix` gate, `git diff --check`, stale-reference scans, fresh caller/callee and lifecycle review, independent code review, and owner pre-commit approval are complete. +- **Assessment:** The agreed design fixes demonstrated worker-bootstrap ordering and stale-reference defects at their shared Foundation/Config boundary while preserving Laravel-facing configuration behavior and adding no request hot-path work. + +### Coordinate shared container construction and complete current contextual resolution + +- **Architecture and inspected risk surfaces:** Container is a Laravel-shaped, Swoole-aware dependency container. Explicit singletons and safe unbound concrete classes are shared for the worker lifetime, scoped bindings live in coroutine context, and immutable reflection recipes are cached statically. The audit covered every Container source file and test, all binding and lifecycle mutations, aliases, contextual attributes, callable injection, contracts and facade annotations, Auth/Cache/Log manager consumers, current Laravel source/tests/docs and originating pull requests, the completed Reflection audit assumptions, and the relevant Swoole channel and coroutine-parent primitives. + +| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision | +|---|---|---|---|---|---| +| `container-01` | Defect | Major | High | Concurrent yielding first resolutions can construct and publish different explicit or automatic singleton instances | Coordinate cacheable first construction per canonical abstract, publish only one completed result, propagate owner failures, allow retry, and reject coordinator-induced ancestry or wait-graph cycles deterministically | +| `container-02` | Defect | Major | High | Cached constructor recipes retain object-valued defaults and reuse them forever instead of preserving PHP's fresh-default semantics | Retain reflection only for contextual attributes and recursively object-containing defaults; keep scalar, enum, and object-free-array defaults on the cached fast path | +| `container-03` | Defect | Major | High | Constructor variadics cast one resolved concrete object to its properties and lose the dependency | Normalize one object to a one-element list while retaining contextual arrays; split the variadic failure branch so no static-analysis suppression is needed | +| `container-04` | Defect | Major | High | The zero-argument closure fast path treats class-bound first-class callables as anonymous and skips their contextual build stack | Keep the shortcut only for anonymous closures; route class-bound callables through the existing build-stack boundary | +| `container-05` | Defect | Major | High | The resolution-depth guard runs after mutating coroutine state, leaks the resolving stack on failure, and permits one extra level | Enforce the limit before every state push with the exact maximum and preserve all coroutine stacks on failure | +| `container-06` | Defect | Major | High | Binding, scoped, instance, extension, removal, and auto-singleton transitions invalidate different subsets of lifecycle state | Centralize registration invalidation while preserving the scoped marker across temporary `instance()` replacement so `forgetInstance()` restores scoped behavior | +| `container-07` | Defect | Critical | High | Indirect alias cycles recurse until process memory exhaustion | Reject a proposed alias edge synchronously when it closes a cycle | +| `container-08` | Defect | Minor | High | Contextual-attribute resolution is missing current parameter metadata, route-name inference, memo-cache selection, enum identifiers, and named logger resolution | Port the complete current Laravel contextual-attribute surface from the checked-out development branch and pass `ReflectionParameter` through every container, method, and route call path | +| `container-09` | Defect | Minor | High | Auth, Cache, and Log managers reject enum identifiers accepted by current Laravel APIs, while Cache's falsey fallback also replaces the valid store name `'0'` | Normalize `UnitEnum` identifiers at the manager boundary, including integer-backed zero and cache-driver forgetting, and use a null-only default-store fallback without changing other string behavior | +| `container-10` | Defect | Major | High | Forwarding named logging directly to Monolog loses Hypervel's wrapper, coroutine-local channel context, and event dispatch | Add a typed named-logger operation that clones the wrapper, replaces only its Monolog instance, deliberately shares the channel-family context slot, and fails clearly for non-Monolog drivers | +| `container-11` | Defect | Minor | High | Container exception metadata omits failures exposed by `get()` and contextual-attribute resolution | Port the current accurate exception annotations and facade metadata | +| `container-12` | Defect | Major | High | A resolving-callback failure leaves a provisionally cached singleton, scoped instance, or auto-singleton observable even though resolution failed | Track the exact cache/value publication and roll it back by identity on every failure while preserving any callback-installed replacement | +| `container-13` | Defect | Minor | High | The public mixed-value instance API accepts `null`, but `isset()`-only presence checks make null registrations and singleton results disappear | Pair the existing fast `isset()` checks with `array_key_exists()` only on misses so null values remain observable without slowing normal non-null hits | +| `container-14` | Defect | Minor | High | An unresolved scalar in an unscoped Closure dereferences a nonexistent declaring class and masks the intended container exception with `Error` | Build the diagnostic from the callable owner when present and otherwise emit the normal closure resolution message | +| `container-15` | Defect | Major | High | Closure forms of `bindIf()`, `singletonIf()`, and `scopedIf()` always reach a string-only lookup and throw before their declared return types are registered | Apply the conditional check independently to each reflected return type through the shared registration path | +| `container-16` | Defect | Minor | High | `addContextualBinding()` advertises a Closure abstract that its alias lookup cannot accept and that always throws | Narrow the concrete contract, implementation, and facade metadata to the string identifiers the API actually supports | + +- **Important rejected concerns:** Preserve auto-singletoning, immutable static reflection recipes, lock-free reflection reads, contextual/scoped lifetime rules, current `bound()` semantics, and the existing flush boundary. Do not add arbitrary wait timeouts, duplicate-construction fallback, a general application deadlock detector, LRU/TTL metadata eviction, cross-coroutine scoped mutation, automatic request-context propagation through singletons, scope inference for memo-cache attributes, or style-only upstream rewrites. Ordinary user-created synchronization cycles remain user errors; the coordinator detects only native ancestry and wait cycles it can introduce. +- **Cross-package implications:** Auth, Cache, Log, Routing, Contracts, and Support receive current Laravel-facing enum/contextual behavior. Reflection recipes retain the canonical `ClassMetadataCache` owner established by `reflection-03` and `reflection-04`. Later full audits of those consumer packages must retain and revalidate these boundaries. +- **Approved implementation boundary:** The owner approved the direct `ext-swoole:^6.2` dependency and one predictable empty-coordinator-map branch needed to prevent a provisional singleton from escaping before resolving callbacks complete. Shared-resolution ancestry checks, wait-cycle checks, and wait-edge insertion remain one synchronous non-yielding section; only the subsequent channel wait may yield, and wait edges are removed in `finally`. The owner also approved current Laravel enum normalization in hot Auth/Cache/Log entry points and adding existing `buildWith()` behavior to the Hypervel-owned Container contract and App facade. +- **Additional cleanup:** `buildWith()` is now declared by the Hypervel Container contract and App facade. Unread worker-cached recipe fields are removed, both recipe DTOs are internal, constructor recipes retain diagnostic reflection strings only where they are consumed, and attribute-discovery caches survive explicit rebinding because explicit bindings already take precedence over immutable class lifecycle attributes. +- **Implementation:** Cacheable first explicit and automatic singleton construction now uses one per-abstract shared resolution with deterministic ancestry and wait-cycle rejection, failure fan-out, cancellation-safe edge cleanup, and retry. Registration and cache invalidation share one lifecycle boundary; null values, callback rollback, temporary scoped replacement, auto-singleton extension, alias validation, depth cleanup, variadics, callable inference, conditional Closure bindings, and closure diagnostics now follow their declared contracts. Parameter recipes preserve fresh object defaults without penalizing object-free defaults. Current contextual attributes receive parameter metadata, Auth/Cache/Log managers accept enum identifiers, Cache preserves the valid store name `'0'`, and named loggers retain Hypervel's wrapper, events, and coroutine-local channel family. +- **Regression tests:** Deterministically cover concurrent explicit and automatic singleton convergence, callback completion ordering, owner failure fan-out and retry, descendant and multi-owner cycle rejection, waiter cancellation cleanup, and callback-failure rollback inside and outside coroutines. Cover fresh direct and nested object defaults while proving scalar and enum defaults remain cached; concrete/contextual variadics; class-bound zero-argument callables; exact depth cleanup; every lifecycle transition and alias cycle; null-valued instances; conditional Closure bindings; closure diagnostics; every new contextual attribute and enum manager path; falsey cache identifiers; named logger identity, shared channel context, separate-channel isolation, and event dispatch; contract, facade, documentation, and exception metadata. +- **Performance and complexity:** Warmed container resolution gains one predictable empty-array branch before cache reads so an in-progress singleton cannot escape before completion; all coordination allocation, ancestry traversal, graph work, and yielding are confined to contended first construction. Static reflection caching and auto-singletoning remain intact. Fresh reflection is limited to the uncommon object-default/contextual-attribute recipes whose semantics require it. Anonymous zero-argument closures keep their shortcut. Enum normalization adds one `instanceof` branch at the affected manager entry points. No generalized synchronization framework or speculative compatibility layer is added. +- **Laravel-facing result:** Current Laravel contextual attributes and enum manager identifiers are added compatibly. Existing string APIs, configuration, and documented behavior remain unchanged. `buildWith()` expands only Hypervel's own contract to describe behavior the concrete container already provides. Alias, variadic, default-value, lifecycle, and named-logger changes correct invalid behavior without changing supported call shapes. +- **Validation and review:** Every changed Container, Auth, Cache, Log, Routing, and Console test is green. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, stale-reference scans, a fresh caller/callee and lifecycle review, and independent code review are complete. The final review found no unresolved behavioral, architectural, performance, coroutine-safety, test, or overengineering concern; the only surviving documentation annotation was verified as formatter-owned. The owner approved committing the completed work. +- **Assessment:** The final design fixes demonstrated lifecycle, typing, callback, cache, and concurrent singleton failures at their owning boundaries. Shared-resolution coordination is deliberately limited to contended first construction; it enforces Hypervel's documented one-instance worker contract and prevents provisional callback state from escaping without introducing a general synchronization framework. Normal warmed resolution retains its existing caches and gains only one predictable empty-map branch. diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md index 35ac90e68..dc8c4558b 100644 --- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md @@ -19,12 +19,14 @@ Finish with a framework that reads as if it was designed for a long-lived Swoole - cleanup is exhaustive and preserves the earliest failure; - waits for external progress are bounded when the peer or owner can disappear; - native false/error results cannot violate declared PHP contracts; -- public APIs remain Laravel-shaped where intended and accurately describe Hypervel behavior; +- Laravel public APIs, configuration, documented behavior, and conventional extension patterns remain compatible by default; - hot paths do not gain unnecessary allocations, container lookups, locks, retries, logging, or yields; - tests reproduce real failure modes deterministically and release everything they own; - no stale compatibility layer, workaround, dead branch, obsolete helper, misleading comment, or superseded documentation remains after a correction. -Hypervel 0.4 is greenfield. Backward compatibility, churn, and blast radius do not justify retaining a flawed design. They also do not justify a rewrite without evidence. The correct result is the simplest design that fully expresses the verified ownership and state-lifetime rules. +Hypervel 0.4 is greenfield. Backward compatibility, churn, and blast radius do not justify retaining a flawed Hypervel-specific API or internal design. This freedom also covers surfaces directly deprecated or removed by the package's actual upstream; it does not make Laravel's current public API, configuration keys and structure, documented behavior, or conventional extension patterns disposable. + +Any intentional divergence from that Laravel-facing contract is exceptional and requires a concrete, meaningful Hypervel benefit plus explicit owner approval. Present the reasoning, compatibility impact, alternatives, performance effect, complexity cost, and ongoing upstream-comparison burden before implementation. Code-style preference, subjective tidiness, theoretical flexibility without real consumers, or benchmark noise is not a meaningful benefit. The correct result is the simplest design that fixes the verified problem while preserving useful upstream parity. ## What this audit is not @@ -37,9 +39,13 @@ Complexity must pay for itself with at least one of: - a clear general capability with real consumers and owner approval; - deletion of greater or riskier complexity elsewhere. +Typical Laravel lifecycle semantics define the supported contract. A package that intentionally relies on model events, middleware, listeners, transactions, or another documented mechanism is not defective merely because userland can explicitly bypass that mechanism. Do not build a parallel enforcement path for `withoutEvents()`, raw database writes, disabled middleware, direct transport access, or comparable deliberate bypasses unless the public contract explicitly promises behavior through that bypass. + +Underengineering is equally a failure. Fix every verified defect completely at its lowest owning boundary, never with a partial fix or a local patch over a broken shared contract, and always surface meaningful evidence-backed improvements rather than dropping them to avoid effort. Restraint applies to speculative machinery and cosmetic change, not to complete fixes or worthwhile opportunities. + Do not treat an upstream difference as a bug without tracing it. Do not treat upstream parity as proof of correctness. A real Hypervel defect remains a defect when Laravel, Hyperf, Symfony, or an SDK has the same hole. -The audit categories are discovery lenses, not boundaries around what may be corrected. Any genuine issue discovered while auditing, implementing, testing, or reviewing must be investigated, assigned to its lowest owning boundary, and taken through the same consensus, owner-approval, implementation, validation, and review workflow—even when it is outside the current package, initial taxonomy, or changed diff. Do not dismiss a verified issue as unrelated or defer it merely to preserve package order. This rule applies only after the evidence threshold is met; it does not turn speculative concerns into work. +The audit categories are discovery lenses, not boundaries around what may be corrected. Any genuine issue discovered while auditing, implementing, testing, or reviewing must be investigated, assigned to its lowest owning boundary, and taken through the applicable consensus, implementation, validation, review, and approval workflow—even when it is outside the current package, initial taxonomy, or changed diff. Do not dismiss a verified issue as unrelated or defer it merely to preserve package order. This rule applies only after the evidence threshold is met; it does not turn speculative concerns, deliberate bypasses, unsupported use, or contract violations into work. ## Backing research and current runtime facts @@ -237,6 +243,8 @@ A correctness guard on a cold failure path has a different cost from a new lock Any proposed change with a measured or source-proven hot-path regression requires explicit owner approval before implementation, even when it fixes a defect. Present the expected frequency and magnitude, the evidence, and the viable alternatives. Do not hide an unavoidable tradeoff inside a general correctness claim. +Performance improvements must provide a meaningful practical benefit after accounting for code complexity and divergence from upstream. Measure representative behavior where practical. Always surface an evidence-backed opportunity to the owner, but do not implement it without approval; a micro-optimization within measurement noise is neither a reason to diverge nor an actionable finding. + ### 8. Remove superseded design completely When a fix changes the owning model, delete obsolete helpers, callbacks, properties, config keys, comments, tests, and documentation. Do not leave a compatibility path or comment describing behavior that no longer exists. Preserve intentional upstream comments unless the new design makes them incorrect. @@ -247,14 +255,14 @@ The established patterns later in this plan are a vocabulary, not a lookup table ### 10. Reject speculative complexity -Record low-confidence concerns under rejected or unresolved analysis. Do not implement them. Broader non-defect improvements require the same evidence threshold and explicit owner approval before implementation. +Record low-confidence concerns under rejected or unresolved analysis. Do not implement them. Surface every evidence-backed, meaningful non-defect improvement to the owner with its benefit, cost, and alternatives, then stop for explicit approval. This requirement exists to keep worthwhile opportunities visible, not to discourage finding them. ## Finding classification ### Category - **Defect:** current behavior can be wrong, unsafe, leaky, stuck, or observably inconsistent. -- **Improvement:** no current defect, but a measured or clearly general simplification, performance gain, or flexibility gain exists. +- **Improvement:** no current defect, but an evidence-backed, meaningful practical simplification, performance gain, flexibility gain, or general capability exists; style-only churn and negligible micro-optimizations do not qualify. - **Intentional difference:** behavior differs from upstream for a verified Hypervel reason and is safe. - **Userland footgun:** an API is correct within its intended boot/test boundary but can be misused without clear guidance. - **Rejected concern:** source tracing disproves the suspected failure or the state is not realistic enough to justify machinery. @@ -517,17 +525,19 @@ Inspect package facades and contracts as part of the public surface. Verify: - native and docblock types cover every reachable value; - callbacks and proxies do not leak borrowed internals; - fluent/static return types return the advertised receiver; -- Laravel-facing config keys and method names stay intact where intended; -- Hypervel-specific differences are necessary for Swoole and documented where the repository rules require it; +- Laravel's current public APIs, configuration keys and structure, documented behavior, and conventional extension patterns remain compatible by default; +- any proposed Laravel-facing divergence has a concrete, meaningful Hypervel benefit and is presented to the owner as a stop gate before implementation; +- Hypervel-specific differences are necessary for Swoole or provide another owner-approved meaningful benefit and are documented where the repository rules require it; - before removing a deprecated API, verify that the package's direct upstream explicitly deprecates it; a deprecation in an underlying dependency does not count when the direct upstream retains the wrapper; - a bug is fixed even when upstream shares it; +- deliberate lifecycle bypasses and unsupported use are not treated as defects unless the public contract promises behavior through them; - optional observational events use `hasListeners()`; - dynamic container results are narrowed so static analysis checks method calls; - caches are keyed by every input that changes construction and by nothing that only changes presentation; - worker caches have bounded or intentional retention; - new correctness logic adds no unjustified hot-path overhead. -For a possible optimization, measure or establish the cost and usage pattern before proposing it. Broader improvements that are not defect fixes require owner approval before implementation. +For a possible optimization, measure or establish the cost and usage pattern before proposing it. Surface every meaningful evidence-backed optimization or broader improvement to the owner, including the upstream-maintenance cost and viable alternatives, then stop for approval before implementation. Do not propose style-only divergence or performance changes whose effect is merely noise. ### Stage 11 — Complete the repository trace @@ -883,9 +893,9 @@ The second-opinion review thread holds proposals and discarded ideas. Add only t After second-opinion consensus: -- a package-local defect fix may proceed to implementation and later code review without another owner checkpoint; -- an Improvement-category change requires explicit owner approval before implementation; -- a public-contract change or a shared lower-level contract change affecting multiple packages requires explicit owner approval before implementation, even when it fixes a defect; +- a verified defect fix may proceed to implementation and later code review without another owner checkpoint when it preserves the Laravel-facing contract, adds no measured or source-proven hot-path regression, and has a settled design; +- every Improvement-category finding must be surfaced to the owner with its meaningful practical benefit, cost, alternatives, and upstream-parity effect, and requires explicit approval before implementation; +- changing a Laravel public API, configuration key or structure, documented behavior, or conventional extension pattern is always an exceptional stop gate requiring explicit owner approval, even when motivated by a defect or broader architectural improvement; - any change with a measured or source-proven hot-path performance regression requires explicit owner approval before implementation, even when it fixes a defect; - any choice the evidence cannot settle returns to the owner rather than being guessed. @@ -938,6 +948,8 @@ Request an independent review of the complete diff and validation. Continue unti Update the companion-ledger work-unit block with implemented changes, cross-package revalidation, tests/gates, and review sign-off. Give it a concise work-unit heading; multiple ledger work units may later be included in one owner-selected pull request. Prepare the routing index, cross-package dependency index, and package-checklist changes in this plan. Remove wording that describes abandoned designs and do not duplicate branch, pull-request, or commit references in the audit documents. +Record the Laravel-facing result explicitly as one of: public API and configuration unchanged; only directly upstream-deprecated surface removed; or intentional divergence approved by the owner with the meaningful reason preserved. + ### 10. Owner pre-commit checkpoint After every code change is complete, all gates are green, the fresh self-review is complete, and the independent code review is signed off: @@ -974,9 +986,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** `reflection` -- **Ledger entries required for the active work:** none -- **Pending revalidation carried into the active work:** none +- **Active package or work unit:** `context` +- **Ledger entries required for the active work:** `Coordinate shared container construction and complete current contextual resolution` (`container-05`, `container-06`) +- **Pending revalidation carried into the active work:** `container-05`, `container-06` Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -993,6 +1005,16 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `testbench-01` | `testbench` | `foundation`; later full `testbench` and `foundation` audits | `Restore Conditionable proxy truthiness`; shared finding `testbench-01` | | `http-01` | `http` | `macroable`, `testing`; later full `http` and `testing` audits | `Complete Macroable callable and test-state handling`; shared finding `http-01` | | `console-01` | `console` | `contracts`; later full `console` audit | `Preserve typed console contracts during Composer scripts`; shared finding `console-01` | +| `reflection-01` | `reflection` | `events`, `foundation`; later full `events` and `foundation` audits | `Consolidate reflection metadata and correct callable inference`; finding `reflection-01` | +| `reflection-02` | `reflection` | `console`, `routing`, `view`, `foundation`; later full consumer audits | `Consolidate reflection metadata and correct callable inference`; finding `reflection-02` | +| `reflection-04` | `reflection` | `di`, `support`, `queue`, `testing`; later full consumer audits | `Consolidate reflection metadata and correct callable inference`; finding `reflection-04` | +| `config-01` | `config` | `foundation`; later full `foundation` audit | `Preserve configuration identity across worker reloads`; finding `config-01` | +| `config-02` | `foundation` | `testing`, `reverb`; later full consumer audits | `Preserve configuration identity across worker reloads`; finding `config-02` | +| `container-05` | `container` | `context`; later full `context` audit | `Coordinate shared container construction and complete current contextual resolution`; finding `container-05` | +| `container-06` | `container` | `context`; later full `context` audit | `Coordinate shared container construction and complete current contextual resolution`; finding `container-06` | +| `container-08` | `container` | `auth`, `cache`, `log`, `routing`, `support`; later full consumer audits | `Coordinate shared container construction and complete current contextual resolution`; finding `container-08` | +| `container-09` | `auth`, `cache`, `log` | `container` (revalidation complete); later full `auth`, `cache`, and `log` audits | `Coordinate shared container construction and complete current contextual resolution`; finding `container-09` | +| `container-10` | `log` | `container` (revalidation complete); later full `log` audit | `Coordinate shared container construction and complete current contextual resolution`; finding `container-10` | ## Package checklist @@ -1019,9 +1041,9 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen - [x] `conditionable` - [x] `macroable` - [x] `collections` -- [ ] `reflection` -- [ ] `config` -- [ ] `container` +- [x] `reflection` +- [x] `config` +- [x] `container` - [ ] `context` - [ ] `di` - [ ] `events` diff --git a/src/auth/src/AuthManager.php b/src/auth/src/AuthManager.php index d5748da20..43300115d 100755 --- a/src/auth/src/AuthManager.php +++ b/src/auth/src/AuthManager.php @@ -11,6 +11,9 @@ use Hypervel\Contracts\Auth\StatefulGuard; use Hypervel\Contracts\Container\Container; use InvalidArgumentException; +use UnitEnum; + +use function Hypervel\Support\enum_value; /** * @mixin Guard @@ -59,8 +62,12 @@ public function __construct( /** * Attempt to get the guard from the local cache. */ - public function guard(?string $name = null): Guard|StatefulGuard + public function guard(UnitEnum|string|null $name = null): Guard|StatefulGuard { + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + $name ??= $this->getDefaultDriver(); return $this->guards[$name] ??= $this->resolve($name); @@ -177,8 +184,12 @@ public function getDefaultDriver(): string /** * Set the default guard driver the factory should serve. */ - public function shouldUse(?string $name): void + public function shouldUse(UnitEnum|string|null $name): void { + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + $name ??= $this->getDefaultDriver(); $this->setDefaultDriver($name); @@ -191,8 +202,12 @@ public function shouldUse(?string $name): void * * Uses coroutine Context so one request's override doesn't affect others. */ - public function setDefaultDriver(string $name): void + public function setDefaultDriver(UnitEnum|string $name): void { + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + CoroutineContext::set(self::DEFAULT_GUARD_CONTEXT_KEY, $name); } diff --git a/src/boost/docs/container.md b/src/boost/docs/container.md index ea1e1e8bc..53dc4765e 100644 --- a/src/boost/docs/container.md +++ b/src/boost/docs/container.md @@ -460,14 +460,14 @@ class PhotoController extends Controller { public function __construct( #[Auth('web')] protected Guard $auth, - #[Cache('redis')] protected Repository $cache, + #[Cache('redis', memo: true)] protected Repository $cache, #[Config('app.timezone')] protected string $timezone, #[Context('uuid')] protected string $uuid, #[Context('ulid', hidden: true)] protected string $ulid, #[DB('mysql')] protected Connection $connection, #[Give(DatabaseRepository::class)] protected UserRepository $users, - #[Log('daily')] protected LoggerInterface $log, - #[RouteParameter('photo')] protected Photo $photo, + #[Log('daily', name: 'photos')] protected LoggerInterface $log, + #[RouteParameter] protected Photo $photo, #[Tag('reports')] protected iterable $reports, ) { // ... @@ -475,6 +475,10 @@ class PhotoController extends Controller } ``` +The `RouteParameter` attribute resolves the route parameter matching the variable name. If needed, you may specify the route parameter name explicitly: `#[RouteParameter('photo')]`. + +Pass `memo: true` to `Cache` to inject a request-scoped memoized repository. The optional `name` argument on `Log` creates a named logger and is supported by Monolog-backed channels. Driver identifiers accepted by `Auth`, `Authenticated`, `Cache`, `Log`, and `Storage` may also be unit or backed enum cases; Hypervel uses the unit case name or backed value as the identifier. + Furthermore, Hypervel provides `CurrentUser` and `Authenticated` attributes for injecting the currently authenticated user into a given route or class. `CurrentUser` requires that a user is authenticated; `Authenticated` returns `null` when no user is authenticated, which is useful for optional auth: ```php @@ -505,6 +509,7 @@ namespace App\Attributes; use Attribute; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Container\ContextualAttribute; +use ReflectionParameter; #[Attribute(Attribute::TARGET_PARAMETER)] class Config implements ContextualAttribute @@ -521,15 +526,22 @@ class Config implements ContextualAttribute * * @param self $attribute * @param \Hypervel\Contracts\Container\Container $container + * @param \ReflectionParameter $parameter * @return mixed */ - public static function resolve(self $attribute, Container $container) + public static function resolve( + self $attribute, + Container $container, + ReflectionParameter $parameter, + ): mixed { return $container->make('config')->get($attribute->key, $attribute->default); } } ``` +The current `ReflectionParameter` is passed as the third argument, allowing custom attributes to inspect the parameter name or type when resolving a value. + ### Binding Primitives diff --git a/src/boost/docs/helpers.md b/src/boost/docs/helpers.md index 6132a7ee2..d6bb89ab9 100644 --- a/src/boost/docs/helpers.md +++ b/src/boost/docs/helpers.md @@ -204,6 +204,7 @@ Hypervel includes a variety of global "helper" PHP functions. Many of these func [fake](#method-fake) [filled](#method-filled) [info](#method-info) +[lazy](#method-lazy) [literal](#method-literal) [logger](#method-logger) [method_field](#method-method-field) @@ -212,6 +213,7 @@ Hypervel includes a variety of global "helper" PHP functions. Many of these func [once](#method-once) [optional](#method-optional) [policy](#method-policy) +[proxy](#method-proxy) [redirect](#method-redirect) [report](#method-report) [report_if](#method-report-if) @@ -2778,6 +2780,58 @@ An array of contextual data may also be passed to the function: info('User login attempt failed.', ['id' => $user->id]); ``` + +#### `lazy()` {.collection-method} + +The `lazy` function creates a lazy ghost object. The object has the requested class immediately, but its initializer does not run until a non-eager property is first accessed: + +```php +class Report +{ + public function __construct( + public string $path, + public string $format = 'csv', + ) { + } +} + +$report = lazy(Report::class, fn () => [ + 'path' => storage_path('reports/monthly.csv'), + 'format' => 'csv', +]); + +// The initializer runs when the property is first accessed... +$path = $report->path; +``` + +When the initializer returns an array, its values are passed to the class constructor as positional or named arguments. Alternatively, the initializer may construct the lazy instance directly: + +```php +$report = lazy(Report::class, function (Report $report) { + $report->__construct(storage_path('reports/monthly.csv')); +}); +``` + +The class name may be inferred from the initializer's first parameter type: + +```php +$report = lazy(fn (Report $report) => [ + storage_path('reports/monthly.csv'), +]); +``` + +Properties passed through the `eager` argument are available without triggering initialization: + +```php +$report = lazy( + Report::class, + fn () => [storage_path('reports/monthly.csv')], + eager: ['format' => 'csv'], +); + +$format = $report->format; // Does not initialize the object... +``` + #### `literal()` {.collection-method} @@ -2924,6 +2978,39 @@ The `policy` method retrieves a [policy](/docs/{{version}}/authorization#creatin $policy = policy(App\Models\User::class); ``` + +#### `proxy()` {.collection-method} + +The `proxy` function creates a lazy proxy. Unlike a [lazy ghost](#method-lazy), which initializes the same object, a proxy's factory returns the real object that replaces the proxy when it is first used: + +```php +$report = proxy(Report::class, function (Report $proxy) { + return new Report(storage_path('reports/monthly.csv')); +}); + +// The factory runs and returns the real Report instance... +$path = $report->path; +``` + +The class may be inferred from the callback's return type or, when no usable return type is declared, its first parameter type: + +```php +$report = proxy(fn (): Report => new Report( + storage_path('reports/monthly.csv'), +)); +``` + +As with `lazy`, the optional `eager` argument makes selected proxy properties available before initialization. The factory receives those values as its second argument so it may copy them to the real object when required: + +```php +$report = proxy(Report::class, function (Report $proxy, array $eager) { + return new Report( + storage_path('reports/monthly.csv'), + $eager['format'], + ); +}, eager: ['format' => 'csv']); +``` + #### `redirect()` {.collection-method} diff --git a/src/cache/src/CacheManager.php b/src/cache/src/CacheManager.php index f2934c4fb..313053912 100644 --- a/src/cache/src/CacheManager.php +++ b/src/cache/src/CacheManager.php @@ -17,6 +17,9 @@ use InvalidArgumentException; use Mockery; use Mockery\LegacyMockInterface; +use UnitEnum; + +use function Hypervel\Support\enum_value; /** * @mixin \Hypervel\Contracts\Cache\Repository @@ -51,9 +54,13 @@ public function __construct( /** * Get a cache store instance by name, wrapped in a repository. */ - public function store(?string $name = null): CacheRepository + public function store(UnitEnum|string|null $name = null): CacheRepository { - $name = $name ?: $this->getDefaultDriver(); + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $name ??= $this->getDefaultDriver(); return $this->stores[$name] ??= $this->resolve($name); } @@ -61,7 +68,7 @@ public function store(?string $name = null): CacheRepository /** * Get a cache driver instance. */ - public function driver(?string $driver = null): CacheRepository + public function driver(UnitEnum|string|null $driver = null): CacheRepository { return $this->store($driver); } @@ -72,8 +79,12 @@ public function driver(?string $driver = null): CacheRepository * The memoized repository is isolated to the current coroutine and resets * when the coroutine ends. */ - public function memo(?string $driver = null): CacheRepository + public function memo(UnitEnum|string|null $driver = null): CacheRepository { + if ($driver instanceof UnitEnum) { + $driver = (string) enum_value($driver); + } + $driver = $driver ?? $this->getDefaultDriver(); // Laravel uses a scoped container binding here. Hypervel stores this @@ -401,8 +412,12 @@ public function getDefaultDriver(): string * * Boot-only. Mutates process-global config; per-request use races across coroutines. */ - public function setDefaultDriver(string $name): void + public function setDefaultDriver(UnitEnum|string $name): void { + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + $this->app['config']['cache.default'] = $name; } @@ -413,11 +428,15 @@ public function setDefaultDriver(string $name): void * coroutines may already hold a reference to the store and next resolution * will rebuild with fresh connections. */ - public function forgetDriver(array|string|null $name = null): static + public function forgetDriver(array|UnitEnum|string|null $name = null): static { $name ??= $this->getDefaultDriver(); - foreach ((array) $name as $cacheName) { + foreach (is_array($name) ? $name : [$name] as $cacheName) { + if ($cacheName instanceof UnitEnum) { + $cacheName = (string) enum_value($cacheName); + } + if (isset($this->stores[$cacheName])) { unset($this->stores[$cacheName]); } @@ -433,8 +452,12 @@ public function forgetDriver(array|string|null $name = null): static * coroutines may already hold a reference to the store and next resolution * will rebuild with fresh connections. */ - public function purge(?string $name = null): void + public function purge(UnitEnum|string|null $name = null): void { + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + $name ??= $this->getDefaultDriver(); unset($this->stores[$name]); diff --git a/src/config/composer.json b/src/config/composer.json index 19de44a07..41ff94ace 100644 --- a/src/config/composer.json +++ b/src/config/composer.json @@ -31,7 +31,6 @@ "require": { "php": "^8.4", "hypervel/collections": "^0.4", - "hypervel/container": "^0.4", "hypervel/contracts": "^0.4", "hypervel/macroable": "^0.4", "hypervel/support": "^0.4" @@ -44,4 +43,4 @@ "dev-main": "0.4-dev" } } -} \ No newline at end of file +} diff --git a/src/config/src/Repository.php b/src/config/src/Repository.php index 874ff531b..4ea6ad322 100644 --- a/src/config/src/Repository.php +++ b/src/config/src/Repository.php @@ -17,12 +17,9 @@ class Repository implements ArrayAccess, ConfigContract use Macroable; /** - * Callback invoked after each `set` call. - * - * Instance-scoped (not static) so that only the container's Repository - * triggers the callback. Test-created instances won't pollute shared state. + * The observer invoked after each configuration mutation. */ - protected ?Closure $afterSettingCallback = null; + protected ?Closure $mutationObserver = null; /** * Create a new configuration repository. @@ -73,6 +70,7 @@ public function getMany(array $keys): array * Get the specified string configuration value. * * @param null|(Closure():(null|string))|string $default + * @throws InvalidArgumentException */ public function string(string $key, mixed $default = null): string { @@ -91,6 +89,7 @@ public function string(string $key, mixed $default = null): string * Get the specified integer configuration value. * * @param null|(Closure():(null|int))|int $default + * @throws InvalidArgumentException */ public function integer(string $key, mixed $default = null): int { @@ -109,6 +108,7 @@ public function integer(string $key, mixed $default = null): int * Get the specified float configuration value. * * @param null|(Closure():(null|float))|float $default + * @throws InvalidArgumentException */ public function float(string $key, mixed $default = null): float { @@ -127,6 +127,7 @@ public function float(string $key, mixed $default = null): float * Get the specified boolean configuration value. * * @param null|bool|(Closure():(null|bool)) $default + * @throws InvalidArgumentException */ public function boolean(string $key, mixed $default = null): bool { @@ -146,6 +147,7 @@ public function boolean(string $key, mixed $default = null): bool * * @param null|array|(Closure():(null|array)) $default * @return array + * @throws InvalidArgumentException */ public function array(string $key, mixed $default = null): array { @@ -164,6 +166,8 @@ public function array(string $key, mixed $default = null): array * Get the specified configuration value as a Collection. * * @param null|array|(Closure():(null|array)) $default + * @return Collection + * @throws InvalidArgumentException */ public function collection(string $key, mixed $default = null): Collection { @@ -185,8 +189,8 @@ public function set(array|string $key, mixed $value = null): void Arr::set($this->items, $key, $value); } - if ($this->afterSettingCallback) { - call_user_func($this->afterSettingCallback, $keys); + if ($this->mutationObserver) { + call_user_func($this->mutationObserver, $keys); } } @@ -231,35 +235,43 @@ public function all(): array } /** - * Set callback after calling `set` function. + * Replace all configuration items without reporting a mutation. + * + * @internal + */ + public function replaceItems(array $items): void + { + $this->items = $items; + } + + /** + * Set the observer invoked after each configuration mutation. * - * Boot or tests only. The callback persists on the singleton config - * repository for the worker lifetime and applies to every subsequent - * `set` call; per-request mutation races across coroutines. + * @internal */ - public function afterSettingCallback(?Closure $callback): void + public function setMutationObserver(Closure $observer): void { - $this->afterSettingCallback = $callback; + $this->mutationObserver = $observer; } /** * Determine if the given configuration option exists. * - * @param string $key + * @param int|string $key */ public function offsetExists($key): bool { - return $this->has($key); + return $this->has((string) $key); } /** * Get a configuration option. * - * @param string $key + * @param int|string $key */ public function offsetGet($key): mixed { - return $this->get($key); + return $this->get((string) $key); } /** @@ -269,12 +281,12 @@ public function offsetGet($key): mixed * per-request mutation races across coroutines and affects every concurrent * request. * - * @param string $key + * @param int|string $key * @param mixed $value */ public function offsetSet($key, $value): void { - $this->set($key, $value); + $this->set((string) $key, $value); } /** @@ -284,11 +296,11 @@ public function offsetSet($key, $value): void * per-request mutation races across coroutines and affects every concurrent * request. * - * @param string $key + * @param int|string $key */ public function offsetUnset($key): void { - $this->set($key, null); + $this->set((string) $key, null); } /** diff --git a/src/container/composer.json b/src/container/composer.json index efd6697d4..430063b8c 100644 --- a/src/container/composer.json +++ b/src/container/composer.json @@ -24,6 +24,7 @@ ], "require": { "php": "^8.4", + "ext-swoole": "^6.2", "psr/container": "^2.0.1", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", @@ -43,4 +44,4 @@ "sort-packages": true }, "minimum-stability": "dev" -} \ No newline at end of file +} diff --git a/src/container/src/Attributes/Auth.php b/src/container/src/Attributes/Auth.php index d06a53696..a6b433991 100644 --- a/src/container/src/Attributes/Auth.php +++ b/src/container/src/Attributes/Auth.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Auth\StatefulGuard; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Container\ContextualAttribute; +use UnitEnum; #[Attribute(Attribute::TARGET_PARAMETER)] class Auth implements ContextualAttribute @@ -16,7 +17,7 @@ class Auth implements ContextualAttribute /** * Create a new class instance. */ - public function __construct(public ?string $guard = null) + public function __construct(public UnitEnum|string|null $guard = null) { } diff --git a/src/container/src/Attributes/Authenticated.php b/src/container/src/Attributes/Authenticated.php index 79d0e3a23..62cb8c26d 100644 --- a/src/container/src/Attributes/Authenticated.php +++ b/src/container/src/Attributes/Authenticated.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Container\ContextualAttribute; +use UnitEnum; #[Attribute(Attribute::TARGET_PARAMETER)] class Authenticated implements ContextualAttribute @@ -15,7 +16,7 @@ class Authenticated implements ContextualAttribute /** * Create a new class instance. */ - public function __construct(public ?string $guard = null) + public function __construct(public UnitEnum|string|null $guard = null) { } diff --git a/src/container/src/Attributes/Cache.php b/src/container/src/Attributes/Cache.php index 7a9a5a7f3..a25394824 100644 --- a/src/container/src/Attributes/Cache.php +++ b/src/container/src/Attributes/Cache.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Cache\Repository; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Container\ContextualAttribute; +use UnitEnum; #[Attribute(Attribute::TARGET_PARAMETER)] class Cache implements ContextualAttribute @@ -15,8 +16,10 @@ class Cache implements ContextualAttribute /** * Create a new class instance. */ - public function __construct(public ?string $store = null) - { + public function __construct( + public UnitEnum|string|null $store = null, + public bool $memo = false, + ) { } /** @@ -24,6 +27,8 @@ public function __construct(public ?string $store = null) */ public static function resolve(self $attribute, Container $container): Repository { - return $container->make('cache')->store($attribute->store); + return $attribute->memo + ? $container->make('cache')->memo($attribute->store) + : $container->make('cache')->store($attribute->store); } } diff --git a/src/container/src/Attributes/Log.php b/src/container/src/Attributes/Log.php index 2a4bd953c..f7608ab7c 100644 --- a/src/container/src/Attributes/Log.php +++ b/src/container/src/Attributes/Log.php @@ -7,16 +7,25 @@ use Attribute; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Container\ContextualAttribute; +use Hypervel\Log\Logger; use Psr\Log\LoggerInterface; +use UnitEnum; + +use function Hypervel\Support\enum_value; #[Attribute(Attribute::TARGET_PARAMETER)] class Log implements ContextualAttribute { /** * Create a new class instance. + * + * @param null|string|UnitEnum $channel the log configuration's channel name + * @param null|string|UnitEnum $name The name to prefix all logs with. Only to be used with Monolog drivers. */ - public function __construct(public ?string $channel = null) - { + public function __construct( + public UnitEnum|string|null $channel = null, + public UnitEnum|string|null $name = null, + ) { } /** @@ -24,6 +33,21 @@ public function __construct(public ?string $channel = null) */ public static function resolve(self $attribute, Container $container): LoggerInterface { - return $container->make('log')->channel($attribute->channel); + $channel = $attribute->channel instanceof UnitEnum + ? (string) enum_value($attribute->channel) + : $attribute->channel; + + /** @var Logger $logger */ + $logger = $container->make('log')->channel($channel); + + if ($attribute->name !== null) { + $name = $attribute->name instanceof UnitEnum + ? (string) enum_value($attribute->name) + : $attribute->name; + + $logger = $logger->withName($name); + } + + return $logger; } } diff --git a/src/container/src/Attributes/RouteParameter.php b/src/container/src/Attributes/RouteParameter.php index d14faf7b5..3cd4f677f 100644 --- a/src/container/src/Attributes/RouteParameter.php +++ b/src/container/src/Attributes/RouteParameter.php @@ -7,6 +7,7 @@ use Attribute; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Container\ContextualAttribute; +use ReflectionParameter; #[Attribute(Attribute::TARGET_PARAMETER)] class RouteParameter implements ContextualAttribute @@ -14,15 +15,15 @@ class RouteParameter implements ContextualAttribute /** * Create a new class instance. */ - public function __construct(public string $parameter) + public function __construct(public ?string $parameter = null) { } /** * Resolve the route parameter. */ - public static function resolve(self $attribute, Container $container): mixed + public static function resolve(self $attribute, Container $container, ReflectionParameter $parameter): mixed { - return $container->make('request')->route($attribute->parameter); + return $container->make('request')->route($attribute->parameter ?? $parameter->getName()); } } diff --git a/src/container/src/Attributes/Storage.php b/src/container/src/Attributes/Storage.php index 57cd10f4a..b7a84910a 100644 --- a/src/container/src/Attributes/Storage.php +++ b/src/container/src/Attributes/Storage.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Container\ContextualAttribute; use Hypervel\Contracts\Filesystem\Filesystem; +use UnitEnum; #[Attribute(Attribute::TARGET_PARAMETER)] class Storage implements ContextualAttribute @@ -15,7 +16,7 @@ class Storage implements ContextualAttribute /** * Create a new class instance. */ - public function __construct(public ?string $disk = null) + public function __construct(public UnitEnum|string|null $disk = null) { } diff --git a/src/container/src/BoundMethod.php b/src/container/src/BoundMethod.php index 6188048e3..e53c7a421 100644 --- a/src/container/src/BoundMethod.php +++ b/src/container/src/BoundMethod.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Contracts\Container\BindingResolutionException; +use Hypervel\Support\ClassMetadataCache; use InvalidArgumentException; use ReflectionException; use ReflectionFunction; @@ -136,24 +137,14 @@ protected static function getMethodRecipe(string $className, string $methodName) */ protected static function computeMethodRecipe(string $className, string $methodName): array { - $reflector = ReflectionManager::reflectMethod($className, $methodName); + $reflector = ClassMetadataCache::reflectMethod($className, $methodName); $recipes = []; - foreach ($reflector->getParameters() as $index => $param) { - $recipes[$index] = new ParameterRecipe( - name: $param->getName(), - position: $index, - declaringClassName: $param->getDeclaringClass()?->getName() ?? $className, - className: Util::getParameterClassName($param), - hasType: $param->hasType(), - hasDefault: $param->isDefaultValueAvailable(), - default: $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null, - isVariadic: $param->isVariadic(), - isOptional: $param->isOptional(), - allowsNull: $param->allowsNull(), - attributes: $param->getAttributes(), - contextualAttribute: Util::getContextualAttributeFromDependency($param), - reflectionString: (string) $param, + foreach ($reflector->getParameters() as $index => $parameter) { + $recipes[$index] = ParameterRecipe::fromParameter( + $parameter, + $className, + includeReflectionString: true, ); } @@ -180,21 +171,11 @@ protected static function computeFunctionRecipe(string $functionName): array $reflector = new ReflectionFunction($functionName); $recipes = []; - foreach ($reflector->getParameters() as $index => $param) { - $recipes[$index] = new ParameterRecipe( - name: $param->getName(), - position: $index, - declaringClassName: $param->getDeclaringClass()?->getName() ?? $functionName, - className: Util::getParameterClassName($param), - hasType: $param->hasType(), - hasDefault: $param->isDefaultValueAvailable(), - default: $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null, - isVariadic: $param->isVariadic(), - isOptional: $param->isOptional(), - allowsNull: $param->allowsNull(), - attributes: $param->getAttributes(), - contextualAttribute: Util::getContextualAttributeFromDependency($param), - reflectionString: (string) $param, + foreach ($reflector->getParameters() as $index => $parameter) { + $recipes[$index] = ParameterRecipe::fromParameter( + $parameter, + $functionName, + includeReflectionString: true, ); } @@ -228,7 +209,10 @@ protected static function resolveMethodRecipeParameters(Container $container, ar $pendingDependencies[] = $parameters[$recipe->name]; unset($parameters[$recipe->name]); } elseif ($recipe->contextualAttribute !== null) { - $pendingDependencies[] = $container->resolveFromAttribute($recipe->contextualAttribute); + $pendingDependencies[] = $container->resolveFromAttribute( + $recipe->contextualAttribute, + $recipe->getReflectionParameter(), + ); } elseif ($recipe->className !== null) { if (array_key_exists($recipe->className, $parameters)) { $pendingDependencies[] = $parameters[$recipe->className]; @@ -239,12 +223,12 @@ protected static function resolveMethodRecipeParameters(Container $container, ar ? $variadicDependencies : [$variadicDependencies]); } elseif ($recipe->hasDefault && ! $container->bound($recipe->className)) { - $pendingDependencies[] = $recipe->default; + $pendingDependencies[] = $recipe->getDefaultValue(); } else { $pendingDependencies[] = $container->make($recipe->className); } } elseif ($recipe->hasDefault) { - $pendingDependencies[] = $recipe->default; + $pendingDependencies[] = $recipe->getDefaultValue(); } elseif (! $recipe->isOptional && ! array_key_exists($recipe->name, $parameters)) { $message = "Unable to resolve dependency [{$recipe->reflectionString}] in class {$recipe->declaringClassName}"; throw new BindingResolutionException($message); @@ -335,7 +319,7 @@ protected static function addDependencyForCallParameter( unset($parameters[$paramName]); } elseif ($attribute = Util::getContextualAttributeFromDependency($parameter)) { - $pendingDependencies[] = $container->resolveFromAttribute($attribute); + $pendingDependencies[] = $container->resolveFromAttribute($attribute, $parameter); } elseif (! is_null($className = Util::getParameterClassName($parameter))) { if (array_key_exists($className, $parameters)) { $pendingDependencies[] = $parameters[$className]; @@ -355,7 +339,10 @@ protected static function addDependencyForCallParameter( } elseif ($parameter->isDefaultValueAvailable()) { $pendingDependencies[] = $parameter->getDefaultValue(); } elseif (! $parameter->isOptional() && ! array_key_exists($paramName, $parameters)) { - $message = "Unable to resolve dependency [{$parameter}] in class {$parameter->getDeclaringClass()->getName()}"; + $declaringClass = $parameter->getDeclaringClass(); + $declaringName = $declaringClass?->getName() ?? $parameter->getDeclaringFunction()->getName(); + $declaringType = $declaringClass === null ? 'function' : 'class'; + $message = "Unable to resolve dependency [{$parameter}] in {$declaringType} {$declaringName}"; throw new BindingResolutionException($message); } diff --git a/src/container/src/BuildRecipe.php b/src/container/src/BuildRecipe.php index f515a0dce..9ee3475df 100644 --- a/src/container/src/BuildRecipe.php +++ b/src/container/src/BuildRecipe.php @@ -13,6 +13,8 @@ * is instantiable, has a constructor, its class-level attributes, and its * constructor parameters (as ParameterRecipe objects). Created once per class * per worker lifetime by Container::computeBuildRecipe(). + * + * @internal */ readonly class BuildRecipe { @@ -21,7 +23,6 @@ * @param ParameterRecipe[] $parameters */ public function __construct( - public string $className, public bool $classExists, public bool $isInstantiable, public bool $hasConstructor, diff --git a/src/container/src/Container.php b/src/container/src/Container.php index f02de546e..3c1768e99 100755 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -17,6 +17,7 @@ use Hypervel\Contracts\Container\ContextualAttribute; use Hypervel\Contracts\Container\ContextualBindingBuilder as ContextualBindingBuilderContract; use Hypervel\Contracts\Container\SelfBuilding; +use Hypervel\Support\ClassMetadataCache; use Hypervel\Support\Traits\ReflectsClosures; use InvalidArgumentException; use LogicException; @@ -24,6 +25,9 @@ use ReflectionClass; use ReflectionException; use ReflectionFunction; +use ReflectionParameter; +use Swoole\Coroutine as SwooleCoroutine; +use Throwable; use TypeError; class Container implements ArrayAccess, ContainerContract @@ -101,7 +105,7 @@ class Container implements ArrayAccess, ContainerContract /** * The container's shared instances. * - * @var object[] + * @var array */ protected $instances = []; @@ -117,6 +121,20 @@ class Container implements ArrayAccess, ContainerContract */ protected $autoSingletons = []; + /** + * Cacheable first resolutions currently owned by a coroutine. + * + * @var array + */ + protected array $sharedResolutions = []; + + /** + * Container waits keyed by the waiting coroutine ID. + * + * @var array + */ + protected array $sharedResolutionWaits = []; + /** * The abstract names registered as scoped bindings. * @@ -289,6 +307,7 @@ public function bound(string $abstract): bool { return isset($this->bindings[$abstract]) || isset($this->instances[$abstract]) + || array_key_exists($abstract, $this->instances) || $this->isAlias($abstract); } @@ -307,7 +326,8 @@ public function resolved(string $abstract): bool } return isset($this->resolved[$abstract]) - || isset($this->instances[$abstract]); + || isset($this->instances[$abstract]) + || array_key_exists($abstract, $this->instances); } /** @@ -315,7 +335,7 @@ public function resolved(string $abstract): bool */ public function isShared(string $abstract): bool { - if (isset($this->instances[$abstract])) { + if (isset($this->instances[$abstract]) || array_key_exists($abstract, $this->instances)) { return true; } @@ -323,6 +343,10 @@ public function isShared(string $abstract): bool return true; } + if (isset($this->bindings[$abstract])) { + return false; + } + if (! class_exists($abstract)) { return false; } @@ -347,6 +371,10 @@ public function isScoped(string $abstract): bool return true; } + if (isset($this->bindings[$abstract])) { + return false; + } + if ($this->getScopedType($abstract) === 'scoped') { $this->scopedInstances[$abstract] = true; @@ -375,7 +403,7 @@ protected function getScopedType(ReflectionClass|string $reflection): ?string try { $reflection = $reflection instanceof ReflectionClass ? $reflection - : ReflectionManager::reflectClass($reflection); + : ClassMetadataCache::reflectClass($reflection); } catch (ReflectionException) { return $this->checkedForSingletonOrScopedAttributes[$className] = null; } @@ -415,14 +443,28 @@ public function bind(Closure|string $abstract, Closure|string|null $concrete = n $this->bindBasedOnClosureReturnTypes( $abstract, $concrete, - $shared + $shared, ); return; } + $this->registerBinding($abstract, $concrete, $shared, scoped: false); + } + + /** + * Register one binding and its lifecycle. + */ + protected function registerBinding(string $abstract, Closure|string|null $concrete, bool $shared, bool $scoped): void + { $this->dropStaleInstances($abstract); + if ($scoped) { + $this->scopedInstances[$abstract] = true; + } else { + unset($this->scopedInstances[$abstract]); + } + // If no concrete type was given, we will simply set the concrete type to the // abstract type. After that, the concrete type to be registered as shared // without being forced to state their classes in both of the parameters. @@ -511,7 +553,7 @@ public function callMethodBinding(string $method, mixed $instance): mixed * runtime use races across coroutines and changes dependency resolution for * every subsequent build of the concrete. */ - public function addContextualBinding(string $concrete, Closure|string $abstract, mixed $implementation): void + public function addContextualBinding(string $concrete, string $abstract, mixed $implementation): void { $this->contextual[$concrete][$this->getAlias($abstract)] = $implementation; } @@ -519,11 +561,22 @@ public function addContextualBinding(string $concrete, Closure|string $abstract, /** * Register a binding if it hasn't already been registered. * - * Boot-only. Delegates to bind() when the abstract is unbound, mutating the - * worker-lifetime container for every subsequent request. + * Boot-only. Conditional bindings mutate the worker-lifetime container for + * every subsequent request. */ public function bindIf(Closure|string $abstract, Closure|string|null $concrete = null, bool $shared = false): void { + if ($abstract instanceof Closure) { + $this->bindBasedOnClosureReturnTypes( + $abstract, + $concrete, + $shared, + onlyIfUnbound: true, + ); + + return; + } + if (! $this->bound($abstract)) { $this->bind($abstract, $concrete, $shared); } @@ -544,14 +597,12 @@ public function singleton(Closure|string $abstract, Closure|string|null $concret /** * Register a shared binding if it hasn't already been registered. * - * Boot-only. Delegates to singleton() when the abstract is unbound, mutating - * the worker-lifetime container for every subsequent request. + * Boot-only. Conditional singleton bindings mutate the worker-lifetime + * container for every subsequent request. */ public function singletonIf(Closure|string $abstract, Closure|string|null $concrete = null): void { - if (! $this->bound($abstract)) { - $this->singleton($abstract, $concrete); - } + $this->bindIf($abstract, $concrete, true); } /** @@ -562,29 +613,35 @@ public function singletonIf(Closure|string $abstract, Closure|string|null $concr */ public function scoped(Closure|string $abstract, Closure|string|null $concrete = null): void { - // When $abstract is a closure, singleton() delegates to bindBasedOnClosureReturnTypes() - // which registers each return type as a separate binding. We must store those return - // type class names in $scopedInstances (not the Closure) so that isScoped() correctly - // identifies them and resolve() routes their instances to Context. if ($abstract instanceof Closure) { - foreach ($this->closureReturnTypes($abstract) as $type) { - $this->scopedInstances[$type] = true; - } - } else { - $this->scopedInstances[$abstract] = true; + $this->bindBasedOnClosureReturnTypes($abstract, $concrete, shared: true, scoped: true); + + return; } - $this->singleton($abstract, $concrete); + $this->registerBinding($abstract, $concrete, shared: true, scoped: true); } /** * Register a scoped binding if it hasn't already been registered. * - * Boot-only. Delegates to scoped() when the abstract is unbound, mutating - * the worker-lifetime container for every subsequent request. + * Boot-only. Conditional scoped bindings mutate the worker-lifetime + * container for every subsequent request. */ public function scopedIf(Closure|string $abstract, Closure|string|null $concrete = null): void { + if ($abstract instanceof Closure) { + $this->bindBasedOnClosureReturnTypes( + $abstract, + $concrete, + shared: true, + scoped: true, + onlyIfUnbound: true, + ); + + return; + } + if (! $this->bound($abstract)) { $this->scoped($abstract, $concrete); } @@ -593,14 +650,23 @@ public function scopedIf(Closure|string $abstract, Closure|string|null $concrete /** * Register a binding with the container based on the given Closure's return types. */ - protected function bindBasedOnClosureReturnTypes(Closure $abstract, Closure|string|null $concrete = null, bool $shared = false): void - { + protected function bindBasedOnClosureReturnTypes( + Closure $abstract, + Closure|string|null $concrete = null, + bool $shared = false, + bool $scoped = false, + bool $onlyIfUnbound = false, + ): void { $abstracts = $this->closureReturnTypes($abstract); $concrete = $abstract; foreach ($abstracts as $abstract) { - $this->bind($abstract, $concrete, $shared); + if ($onlyIfUnbound && $this->bound($abstract)) { + continue; + } + + $this->registerBinding($abstract, $concrete, $shared, $scoped); } } @@ -616,9 +682,13 @@ public function extend(string $abstract, Closure $closure): void { $abstract = $this->getAlias($abstract); - if (isset($this->instances[$abstract])) { + if (isset($this->instances[$abstract]) || array_key_exists($abstract, $this->instances)) { $this->instances[$abstract] = $closure($this->instances[$abstract], $this); + $this->rebound($abstract); + } elseif (isset($this->autoSingletons[$abstract])) { + $this->autoSingletons[$abstract] = $closure($this->autoSingletons[$abstract], $this); + $this->rebound($abstract); } elseif ($this->isScoped($abstract) && CoroutineContext::has(self::SCOPED_CONTEXT_PREFIX . $abstract)) { // Apply the extender to the current cached scoped instance immediately. @@ -660,6 +730,8 @@ public function instance(string $abstract, mixed $instance): mixed unset($this->aliases[$abstract]); + $this->forgetCachedInstances($abstract); + // We'll check to determine if this type has been bound before, and if it has // we will fire the rebound callbacks registered with the container and it // can be updated with consuming classes that have gotten resolved here. @@ -742,6 +814,22 @@ public function alias(string $abstract, string $alias): void throw new LogicException("[{$abstract}] is aliased to itself."); } + $target = $abstract; + $visited = []; + + while (isset($this->aliases[$target])) { + if ($target === $alias || isset($visited[$target])) { + throw new LogicException("Alias [{$alias}] would create a circular alias chain."); + } + + $visited[$target] = true; + $target = $this->aliases[$target]; + } + + if ($target === $alias) { + throw new LogicException("Alias [{$alias}] would create a circular alias chain."); + } + $this->removeAbstractAlias($alias); $this->aliases[$alias] = $abstract; @@ -829,7 +917,10 @@ public function call(callable|string $callback, array $parameters = [], ?string // don't need DI resolution. Uses getNumberOfParameters() (not // getNumberOfRequiredParameters()) because BoundMethod injects // optional typed parameters when resolvable. - if (empty($parameters) && $reflection->getNumberOfParameters() === 0) { + if ($reflection->isAnonymous() + && empty($parameters) + && $reflection->getNumberOfParameters() === 0 + ) { return $callback(); } @@ -945,6 +1036,9 @@ public function make(string $abstract, array $parameters = []): mixed * * @param class-string|string $id * @return ($id is class-string ? TClass : mixed) + * + * @throws CircularDependencyException + * @throws EntryNotFoundException */ public function get(string $id): mixed { @@ -989,6 +1083,17 @@ protected function resolve(string $abstract, array $parameters = [], bool $raise $needsContextualBuild = ! empty($parameters) || ! is_null($concrete); + // The owner may publish its provisional instance before resolving callbacks + // finish so same-coroutine callbacks can re-resolve it. Other coroutines must + // wait for the complete resolution instead of observing that partial state. + if (! $needsContextualBuild + && $this->sharedResolutions !== [] + && isset($this->sharedResolutions[$abstract]) + && $this->sharedResolutions[$abstract]->ownerId !== SwooleCoroutine::getCid() + ) { + return $this->awaitSharedResolution($abstract, $this->sharedResolutions[$abstract]); + } + // For scoped bindings, check coroutine-local Context instead of process-global $instances. if ($this->isScoped($abstract) && ! $needsContextualBuild) { $contextKey = self::SCOPED_CONTEXT_PREFIX . $abstract; @@ -1000,7 +1105,9 @@ protected function resolve(string $abstract, array $parameters = [], bool $raise // If an instance of the type is currently being managed as a singleton we'll // just return an existing instance instead of instantiating new instances // so the developer can keep using the same objects instance every time. - if (isset($this->instances[$abstract]) && ! $needsContextualBuild) { + if ((isset($this->instances[$abstract]) || array_key_exists($abstract, $this->instances)) + && ! $needsContextualBuild + ) { return $this->instances[$abstract]; } @@ -1009,6 +1116,16 @@ protected function resolve(string $abstract, array $parameters = [], bool $raise return $this->autoSingletons[$abstract]; } + // Depth guard — safety net for indirect cycles (e.g., through interfaces) + // where the abstract names differ from the concretes in the resolving stack. + $depth = CoroutineContext::get(self::DEPTH_CONTEXT_KEY, 0); + + if ($depth >= static::MAX_RESOLUTION_DEPTH) { + throw new CircularDependencyException( + 'Maximum resolution depth (' . static::MAX_RESOLUTION_DEPTH . ') exceeded — possible circular dependency' + ); + } + // Check for circular dependency — if this abstract is already being // resolved in the current coroutine's resolution chain, we have a cycle. // Uses a dedicated resolving stack (not the build stack) so that call()'s @@ -1030,18 +1147,11 @@ protected function resolve(string $abstract, array $parameters = [], bool $raise $pushedToResolvingStack = true; } - // Depth guard — safety net for indirect cycles (e.g., through interfaces) - // where the abstract names differ from the concretes in the resolving stack. - $depth = CoroutineContext::get(self::DEPTH_CONTEXT_KEY, 0); - - if ($depth > static::MAX_RESOLUTION_DEPTH) { - throw new CircularDependencyException( - 'Maximum resolution depth (' . static::MAX_RESOLUTION_DEPTH . ') exceeded — possible circular dependency' - ); - } - $this->pushParameterOverrides($parameters); CoroutineContext::set(self::DEPTH_CONTEXT_KEY, $depth + 1); + $sharedResolution = null; + $publishedCache = null; + $publishedValue = null; // try/finally ensures Context cleanup even when resolution throws — in Swoole's // long-running model, exceptions don't terminate the worker, so leaked overrides @@ -1051,6 +1161,16 @@ protected function resolve(string $abstract, array $parameters = [], bool $raise $concrete = $this->getConcrete($abstract); } + if ($this->shouldCoordinateSharedResolution( + $abstract, + $concrete, + $needsContextualBuild, + $raiseEvents, + )) { + $sharedResolution = new SharedResolution(SwooleCoroutine::getCid()); + $this->sharedResolutions[$abstract] = $sharedResolution; + } + // We're ready to instantiate an instance of the concrete type registered for // the binding. This will instantiate the types, as well as resolve any of // its "nested" dependencies recursively until all have gotten resolved. @@ -1074,8 +1194,12 @@ protected function resolve(string $abstract, array $parameters = [], bool $raise if ($this->isShared($abstract)) { if ($this->isScoped($abstract)) { CoroutineContext::set(self::SCOPED_CONTEXT_PREFIX . $abstract, $object); + $publishedCache = 'scoped'; + $publishedValue = $object; } else { $this->instances[$abstract] = $object; + $publishedCache = 'instance'; + $publishedValue = $object; } } elseif ($raiseEvents && ! isset($this->bindings[$abstract]) && is_string($concrete) && class_exists($concrete) && ! is_a($concrete, SelfBuilding::class, true) @@ -1096,6 +1220,8 @@ protected function resolve(string $abstract, array $parameters = [], bool $raise // Stored in $autoSingletons (not $instances) so bound() doesn't report // these as explicitly registered, preserving resolveClass() default values. $this->autoSingletons[$abstract] = $object; + $publishedCache = 'auto-singleton'; + $publishedValue = $object; } } @@ -1110,11 +1236,45 @@ protected function resolve(string $abstract, array $parameters = [], bool $raise $this->resolved[$abstract] = true; } + if ($sharedResolution !== null) { + unset($this->sharedResolutions[$abstract]); + $sharedResolution->complete($object); + } + return $object; - } catch (CircularDependencyException $e) { - // Enrich the exception with this level's abstract name as it bubbles up, - // building the full dependency chain (e.g., A -> B -> C -> A). - $e->addDefinitionName($abstract); + } catch (Throwable $e) { + if ($publishedCache === 'scoped') { + $contextKey = self::SCOPED_CONTEXT_PREFIX . $abstract; + + if (CoroutineContext::has($contextKey) + && CoroutineContext::get($contextKey) === $publishedValue + ) { + CoroutineContext::forget($contextKey); + } + } elseif ($publishedCache === 'instance') { + if (array_key_exists($abstract, $this->instances) + && $this->instances[$abstract] === $publishedValue + ) { + unset($this->instances[$abstract]); + } + } elseif ($publishedCache === 'auto-singleton') { + if (array_key_exists($abstract, $this->autoSingletons) + && $this->autoSingletons[$abstract] === $publishedValue + ) { + unset($this->autoSingletons[$abstract]); + } + } + + if ($sharedResolution !== null) { + unset($this->sharedResolutions[$abstract]); + $sharedResolution->fail($e); + } + + if ($e instanceof CircularDependencyException) { + // Enrich the exception with this level's abstract name as it bubbles up, + // building the full dependency chain (e.g., A -> B -> C -> A). + $e->addDefinitionName($abstract); + } throw $e; } finally { @@ -1127,6 +1287,96 @@ protected function resolve(string $abstract, array $parameters = [], bool $raise } } + /** + * Determine whether this miss can publish a worker-shared instance. + */ + protected function shouldCoordinateSharedResolution( + string $abstract, + mixed $concrete, + bool $needsContextualBuild, + bool $raiseEvents, + ): bool { + if ($needsContextualBuild || SwooleCoroutine::getCid() <= 0 || $this->isScoped($abstract)) { + return false; + } + + if ($this->isShared($abstract)) { + return true; + } + + return $raiseEvents + && ! isset($this->bindings[$abstract]) + && is_string($concrete) + && class_exists($concrete) + && ! is_a($concrete, SelfBuilding::class, true); + } + + /** + * Wait for a shared resolution without introducing a coordinator cycle. + */ + protected function awaitSharedResolution(string $abstract, SharedResolution $resolution): mixed + { + $coroutineId = SwooleCoroutine::getCid(); + + // A parent can synchronously join its child outside the container. Letting + // that descendant wait back on the parent would deadlock both coroutines. + if ($this->sharedResolutionOwnerIsAncestor($resolution->ownerId, $coroutineId) + || $this->sharedResolutionWaitWouldCycle($coroutineId, $resolution->ownerId) + ) { + $exception = new CircularDependencyException; + $exception->addDefinitionName($abstract); + + throw $exception; + } + + // The checks and edge insertion above do not yield. The channel wait must + // remain the first yielding operation so another waiter cannot form a cycle + // between validation and publication of this edge. + $this->sharedResolutionWaits[$coroutineId] = $resolution->ownerId; + + try { + return $resolution->await(); + } finally { + unset($this->sharedResolutionWaits[$coroutineId]); + } + } + + /** + * Determine whether the resolution owner is an ancestor of a coroutine. + */ + protected function sharedResolutionOwnerIsAncestor(int $ownerId, int $coroutineId): bool + { + while ($coroutineId > 0) { + if ($coroutineId === $ownerId) { + return true; + } + + $coroutineId = SwooleCoroutine::getPcid($coroutineId); + + if ($coroutineId === false) { + return false; + } + } + + return false; + } + + /** + * Determine whether adding a container wait edge would close a cycle. + */ + protected function sharedResolutionWaitWouldCycle(int $waiterId, int $ownerId): bool + { + while (isset($this->sharedResolutionWaits[$ownerId])) { + $ownerId = $this->sharedResolutionWaits[$ownerId]; + + if ($ownerId === $waiterId) { + return true; + } + } + + return false; + } + /** * Get the concrete type for a given abstract. */ @@ -1155,8 +1405,8 @@ protected function getConcreteBindingFromAttributes(string $abstract): mixed $this->checkedForAttributeBindings[$abstract] = true; try { - $reflected = ReflectionManager::reflectClass($abstract); - } catch (ReflectionException) { // @phpstan-ignore catch.neverThrown + $reflected = ClassMetadataCache::reflectClass($abstract); + } catch (ReflectionException) { return $abstract; } @@ -1381,7 +1631,6 @@ protected function computeBuildRecipe(string $concrete): BuildRecipe if (! $classExists) { return new BuildRecipe( - className: $concrete, classExists: false, isInstantiable: false, hasConstructor: false, @@ -1390,13 +1639,12 @@ classAttributes: [], ); } - $reflector = ReflectionManager::reflectClass($concrete); + $reflector = ClassMetadataCache::reflectClass($concrete); $constructor = $reflector->getConstructor(); $classAttributes = $reflector->getAttributes(); if ($constructor === null) { return new BuildRecipe( - className: $concrete, classExists: true, isInstantiable: $reflector->isInstantiable(), hasConstructor: false, @@ -1407,27 +1655,11 @@ classAttributes: $classAttributes, $parameters = []; - foreach ($constructor->getParameters() as $index => $param) { - $parameters[$index] = new ParameterRecipe( - name: $param->getName(), - position: $index, - // Use getDeclaringClass() for accurate error messages with inherited constructors. - // If class B extends A and A declares the constructor, this returns A's name. - declaringClassName: $param->getDeclaringClass()?->getName() ?? $concrete, - className: Util::getParameterClassName($param), - hasType: $param->hasType(), - hasDefault: $param->isDefaultValueAvailable(), - default: $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null, - isVariadic: $param->isVariadic(), - isOptional: $param->isOptional(), - allowsNull: $param->allowsNull(), - attributes: $param->getAttributes(), - contextualAttribute: Util::getContextualAttributeFromDependency($param), - ); + foreach ($constructor->getParameters() as $index => $parameter) { + $parameters[$index] = ParameterRecipe::fromParameter($parameter, $concrete); } return new BuildRecipe( - className: $concrete, classExists: true, isInstantiable: $reflector->isInstantiable(), hasConstructor: true, @@ -1601,7 +1833,10 @@ protected function resolveRecipeParameters(BuildRecipe $recipe): array // Contextual attributes are checked BEFORE class/primitive resolution. // This is critical for #[Config], #[Give], etc. to work correctly. if ($paramRecipe->contextualAttribute !== null) { - $result = $this->resolveFromAttribute($paramRecipe->contextualAttribute); + $result = $this->resolveFromAttribute( + $paramRecipe->contextualAttribute, + $paramRecipe->getReflectionParameter(), + ); } // If the class is null, it means the dependency is a string or some other @@ -1616,7 +1851,7 @@ protected function resolveRecipeParameters(BuildRecipe $recipe): array } if ($paramRecipe->isVariadic) { - $results = array_merge($results, (array) $result); + $results = array_merge($results, is_array($result) ? $result : [$result]); } else { $results[] = $result; } @@ -1656,7 +1891,7 @@ protected function resolvePrimitive(ParameterRecipe $param): mixed } if ($param->hasDefault) { - return $param->default; + return $param->getDefaultValue(); } if ($param->isVariadic) { @@ -1685,24 +1920,20 @@ protected function resolveClass(ParameterRecipe $param): mixed if ($param->hasDefault && ! $this->bound($className) && $this->findInContextualBindings($className) === null) { - return $param->default; + return $param->getDefaultValue(); } - try { - return $param->isVariadic - ? $this->resolveVariadicClass($param) - : $this->make($className); + if (! $param->isVariadic) { + return $this->make($className); } // If we can not resolve the class instance, we will check to see if the value // is variadic. If it is, we will return an empty array as the value of the // dependency similarly to how we handle scalar values in this situation. - catch (BindingResolutionException $e) { - if ($param->isVariadic) { // @phpstan-ignore if.alwaysFalse - return []; - } - - throw $e; + try { + return $this->resolveVariadicClass($param); + } catch (BindingResolutionException) { + return []; } } @@ -1724,8 +1955,10 @@ protected function resolveVariadicClass(ParameterRecipe $param): mixed /** * Resolve a dependency based on an attribute. + * + * @throws BindingResolutionException */ - public function resolveFromAttribute(ReflectionAttribute $attribute): mixed + public function resolveFromAttribute(ReflectionAttribute $attribute, ReflectionParameter $parameter): mixed { $handler = $this->contextualAttributes[$attribute->getName()] ?? null; @@ -1739,7 +1972,7 @@ public function resolveFromAttribute(ReflectionAttribute $attribute): mixed throw new BindingResolutionException("Contextual binding attribute [{$attribute->getName()}] has no registered handler."); } - return $handler($instance, $this); + return $handler($instance, $this, $parameter); } /** @@ -2012,7 +2245,18 @@ public function forgetExtenders(string $abstract): void */ protected function dropStaleInstances(string $abstract): void { - unset($this->instances[$abstract], $this->aliases[$abstract], $this->autoSingletons[$abstract]); + $this->removeAbstractAlias($abstract); + unset($this->aliases[$abstract]); + + $this->forgetCachedInstances($abstract); + } + + /** + * Forget every cached instance for an abstract without changing its lifecycle. + */ + protected function forgetCachedInstances(string $abstract): void + { + unset($this->instances[$abstract], $this->autoSingletons[$abstract]); CoroutineContext::forget(self::SCOPED_CONTEXT_PREFIX . $abstract); } @@ -2026,9 +2270,7 @@ protected function dropStaleInstances(string $abstract): void */ public function forgetInstance(string $abstract): void { - unset($this->instances[$abstract], $this->autoSingletons[$abstract]); - - CoroutineContext::forget(self::SCOPED_CONTEXT_PREFIX . $abstract); + $this->forgetCachedInstances($abstract); } /** @@ -2101,13 +2343,14 @@ public function flush(): void $this->bindings = []; $this->instances = []; $this->autoSingletons = []; + $this->sharedResolutions = []; + $this->sharedResolutionWaits = []; $this->abstractAliases = []; $this->scopedInstances = []; $this->checkedForAttributeBindings = []; $this->checkedForSingletonOrScopedAttributes = []; static::flushState(); - ReflectionManager::flushState(); BoundMethod::flushState(); } @@ -2176,9 +2419,9 @@ public function offsetSet($key, $value): void */ public function offsetUnset($key): void { - unset($this->bindings[$key], $this->instances[$key], $this->resolved[$key]); + unset($this->bindings[$key], $this->resolved[$key], $this->scopedInstances[$key]); - CoroutineContext::forget(self::SCOPED_CONTEXT_PREFIX . $key); + $this->dropStaleInstances($key); } /** diff --git a/src/container/src/ParameterRecipe.php b/src/container/src/ParameterRecipe.php index 30179383e..d9ebdd931 100644 --- a/src/container/src/ParameterRecipe.php +++ b/src/container/src/ParameterRecipe.php @@ -4,15 +4,19 @@ namespace Hypervel\Container; +use LogicException; use ReflectionAttribute; +use ReflectionParameter; +use UnitEnum; /** - * Cached constructor parameter metadata. + * Cached dependency parameter metadata. * - * Stores the result of analyzing a single constructor parameter via - * reflection. Created once per parameter per class per worker lifetime - * by Container::computeBuildRecipe(), avoiding repeated reflection on - * every build() call. + * Stores the result of analyzing one constructor, method, or function + * parameter. Recipes are reused for the worker lifetime, avoiding repeated + * reflection on every container build or method call. + * + * @internal */ readonly class ParameterRecipe { @@ -21,18 +25,95 @@ */ public function __construct( public string $name, - public int $position, public string $declaringClassName, public ?string $className, public bool $hasType, public bool $hasDefault, public mixed $default, + public bool $refreshDefault, public bool $isVariadic, public bool $isOptional, public bool $allowsNull, public array $attributes, public ?ReflectionAttribute $contextualAttribute, + public ?ReflectionParameter $reflectionParameter, public string $reflectionString = '', ) { } + + /** + * Create a cached recipe from a reflected parameter. + */ + public static function fromParameter( + ReflectionParameter $parameter, + string $declaringClassName, + bool $includeReflectionString = false, + ): self { + $hasDefault = $parameter->isDefaultValueAvailable(); + $default = $hasDefault ? $parameter->getDefaultValue() : null; + $refreshDefault = $hasDefault && self::containsRefreshableObject($default); + $contextualAttribute = Util::getContextualAttributeFromDependency($parameter); + + return new self( + name: $parameter->getName(), + declaringClassName: $parameter->getDeclaringClass()?->getName() ?? $declaringClassName, + className: Util::getParameterClassName($parameter), + hasType: $parameter->hasType(), + hasDefault: $hasDefault, + default: $refreshDefault ? null : $default, + refreshDefault: $refreshDefault, + isVariadic: $parameter->isVariadic(), + isOptional: $parameter->isOptional(), + allowsNull: $parameter->allowsNull(), + attributes: $parameter->getAttributes(), + contextualAttribute: $contextualAttribute, + reflectionParameter: $refreshDefault || $contextualAttribute !== null ? $parameter : null, + reflectionString: $includeReflectionString ? (string) $parameter : '', + ); + } + + /** + * Get the parameter's default value. + */ + public function getDefaultValue(): mixed + { + return $this->refreshDefault + ? $this->getReflectionParameter()->getDefaultValue() + : $this->default; + } + + /** + * Get the reflected parameter retained for dynamic resolution. + */ + public function getReflectionParameter(): ReflectionParameter + { + return $this->reflectionParameter + ?? throw new LogicException('No reflected parameter is retained for this recipe.'); + } + + /** + * Determine whether a default contains a non-enum object. + */ + protected static function containsRefreshableObject(mixed $value): bool + { + if ($value instanceof UnitEnum) { + return false; + } + + if (is_object($value)) { + return true; + } + + if (! is_array($value)) { + return false; + } + + foreach ($value as $item) { + if (self::containsRefreshableObject($item)) { + return true; + } + } + + return false; + } } diff --git a/src/container/src/ReflectionManager.php b/src/container/src/ReflectionManager.php deleted file mode 100644 index f8ce7443a..000000000 --- a/src/container/src/ReflectionManager.php +++ /dev/null @@ -1,75 +0,0 @@ ->, method?: array, property?: array} - */ - protected static array $container = []; - - /** - * Get a cached ReflectionClass for the given class name. - * - * @template T of object - * - * @param class-string $className - * @return ReflectionClass - * - * @throws ReflectionException - */ - public static function reflectClass(string $className): ReflectionClass - { - return static::$container['class'][$className] // @phpstan-ignore return.type - ??= new ReflectionClass($className); - } - - /** - * Get a cached ReflectionMethod for the given class and method. - * - * @throws ReflectionException - */ - public static function reflectMethod(string $className, string $method): ReflectionMethod - { - $key = $className . '::' . $method; - - return static::$container['method'][$key] - ??= static::reflectClass($className)->getMethod($method); - } - - /** - * Get a cached ReflectionProperty for the given class and property. - * - * @throws ReflectionException - */ - public static function reflectProperty(string $className, string $property): ReflectionProperty - { - $key = $className . '::' . $property; - - return static::$container['property'][$key] - ??= static::reflectClass($className)->getProperty($property); - } - - /** - * Flush all static state. - */ - public static function flushState(): void - { - static::$container = []; - } -} diff --git a/src/container/src/SharedResolution.php b/src/container/src/SharedResolution.php new file mode 100644 index 000000000..50cbc5579 --- /dev/null +++ b/src/container/src/SharedResolution.php @@ -0,0 +1,74 @@ +signal = new Channel(1); + } + + /** + * Publish the completed resolution and wake every waiter. + */ + public function complete(mixed $value): void + { + $this->value = $value; + $this->settled = true; + $this->signal->close(); + } + + /** + * Publish the resolution failure and wake every waiter. + */ + public function fail(Throwable $failure): void + { + $this->failure = $failure; + $this->settled = true; + $this->signal->close(); + } + + /** + * Wait for and return the completed resolution. + * + * @throws BindingResolutionException + * @throws Throwable + */ + public function await(): mixed + { + if (! $this->settled) { + $this->signal->pop(); + } + + if (! $this->settled) { + throw new BindingResolutionException('Shared container resolution was interrupted before completion.'); + } + + if ($this->failure !== null) { + throw $this->failure; + } + + return $this->value; + } +} diff --git a/src/contracts/src/Auth/Factory.php b/src/contracts/src/Auth/Factory.php index 2e8ce7f18..0eb8d03c0 100644 --- a/src/contracts/src/Auth/Factory.php +++ b/src/contracts/src/Auth/Factory.php @@ -4,12 +4,14 @@ namespace Hypervel\Contracts\Auth; +use UnitEnum; + interface Factory { /** * Get a guard instance by name. */ - public function guard(?string $name = null): Guard|StatefulGuard; + public function guard(UnitEnum|string|null $name = null): Guard|StatefulGuard; /** * Get the default authentication driver name. @@ -19,5 +21,5 @@ public function getDefaultDriver(): string; /** * Set the default guard the factory should serve. */ - public function shouldUse(?string $name): void; + public function shouldUse(UnitEnum|string|null $name): void; } diff --git a/src/contracts/src/Cache/Factory.php b/src/contracts/src/Cache/Factory.php index 56752aaf0..510cdd6ad 100644 --- a/src/contracts/src/Cache/Factory.php +++ b/src/contracts/src/Cache/Factory.php @@ -4,10 +4,12 @@ namespace Hypervel\Contracts\Cache; +use UnitEnum; + interface Factory { /** * Get a cache store instance by name. */ - public function store(?string $name = null): Repository; + public function store(UnitEnum|string|null $name = null): Repository; } diff --git a/src/contracts/src/Config/Repository.php b/src/contracts/src/Config/Repository.php index 126898e48..d0e1a8a6a 100644 --- a/src/contracts/src/Config/Repository.php +++ b/src/contracts/src/Config/Repository.php @@ -5,6 +5,7 @@ namespace Hypervel\Contracts\Config; use Closure; +use InvalidArgumentException; interface Repository { @@ -22,6 +23,7 @@ public function get(array|string $key, mixed $default = null): mixed; * Get the specified string configuration value. * * @param null|(Closure():(null|string))|string $default + * @throws InvalidArgumentException */ public function string(string $key, mixed $default = null): string; @@ -29,6 +31,7 @@ public function string(string $key, mixed $default = null): string; * Get the specified integer configuration value. * * @param null|(Closure():(null|int))|int $default + * @throws InvalidArgumentException */ public function integer(string $key, mixed $default = null): int; @@ -36,6 +39,7 @@ public function integer(string $key, mixed $default = null): int; * Get the specified float configuration value. * * @param null|(Closure():(null|float))|float $default + * @throws InvalidArgumentException */ public function float(string $key, mixed $default = null): float; @@ -43,6 +47,7 @@ public function float(string $key, mixed $default = null): float; * Get the specified boolean configuration value. * * @param null|bool|(Closure():(null|bool)) $default + * @throws InvalidArgumentException */ public function boolean(string $key, mixed $default = null): bool; @@ -51,6 +56,7 @@ public function boolean(string $key, mixed $default = null): bool; * * @param null|array|(Closure():(null|array)) $default * @return array + * @throws InvalidArgumentException */ public function array(string $key, mixed $default = null): array; @@ -68,15 +74,6 @@ public function all(): array; */ public function set(array|string $key, mixed $value = null): void; - /** - * Set callback after calling `set` function. - * - * Boot or tests only. The callback persists on the singleton config - * repository for the worker lifetime and applies to every subsequent - * `set` call. - */ - public function afterSettingCallback(?Closure $callback): void; - /** * Prepend a value onto an array configuration value. * diff --git a/src/contracts/src/Container/Container.php b/src/contracts/src/Container/Container.php index 2c189f5f7..7a88157a5 100644 --- a/src/contracts/src/Container/Container.php +++ b/src/contracts/src/Container/Container.php @@ -102,7 +102,7 @@ public function forgetInstance(string $abstract): void; /** * Add a contextual binding to the container. */ - public function addContextualBinding(string $concrete, Closure|string $abstract, mixed $implementation): void; + public function addContextualBinding(string $concrete, string $abstract, mixed $implementation): void; /** * Define a contextual binding. @@ -146,6 +146,16 @@ public function make(string $abstract, array $parameters = []): mixed; */ public function build(Closure|string $concrete): mixed; + /** + * Instantiate a concrete instance with the given parameter overrides. + * + * Unlike make(), this always creates a fresh instance — it bypasses + * bindings, aliases, and singleton/auto-singleton caching. + * + * @throws BindingResolutionException + */ + public function buildWith(Closure|string $concrete, array $parameters = []): mixed; + /** * Call the given Closure / class@method and inject its dependencies. */ diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 23af9ff17..6ff2db9b1 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -28,6 +28,7 @@ use Hypervel\Support\Traits\ForwardsCalls; use InvalidArgumentException; use ReflectionClass; +use ReflectionException; use ReflectionMethod; use SortDirection; @@ -1995,6 +1996,8 @@ public static function __callStatic(string $method, array $parameters): mixed /** * Register the given mixin with the builder. + * + * @throws ReflectionException */ protected static function registerMixin(object $mixin, bool $replace): void { diff --git a/src/di/composer.json b/src/di/composer.json index b753a5843..eb96a2351 100644 --- a/src/di/composer.json +++ b/src/di/composer.json @@ -37,6 +37,7 @@ "hypervel/container": "^0.4", "hypervel/filesystem": "^0.4", "hypervel/pipeline": "^0.4", + "hypervel/reflection": "^0.4", "hypervel/support": "^0.4", "nikic/php-parser": "^5.7" }, diff --git a/src/di/src/Aop/ProceedingJoinPoint.php b/src/di/src/Aop/ProceedingJoinPoint.php index 11deb49b1..8077f480f 100644 --- a/src/di/src/Aop/ProceedingJoinPoint.php +++ b/src/di/src/Aop/ProceedingJoinPoint.php @@ -6,7 +6,7 @@ use Closure; use Hypervel\Di\Exceptions\Exception; -use Hypervel\Di\ReflectionManager; +use Hypervel\Support\ClassMetadataCache; use ReflectionFunction; use ReflectionMethod; @@ -71,7 +71,7 @@ public function getArguments(): array */ public function getReflectMethod(): ReflectionMethod { - return ReflectionManager::reflectMethod( + return ClassMetadataCache::reflectMethod( $this->className, $this->methodName ); diff --git a/src/di/src/ReflectionManager.php b/src/di/src/ReflectionManager.php deleted file mode 100644 index c2ddf097d..000000000 --- a/src/di/src/ReflectionManager.php +++ /dev/null @@ -1,110 +0,0 @@ -getMethod($method); - } - return static::$container['method'][$key]; - } - - /** - * Get a cached ReflectionProperty for the given class and property. - */ - public static function reflectProperty(string $className, string $property): ReflectionProperty - { - $key = $className . '::' . $property; - if (! isset(static::$container['property'][$key])) { - if (! class_exists($className)) { - throw new InvalidArgumentException("Class {$className} does not exist"); - } - static::$container['property'][$key] = static::reflectClass($className)->getProperty($property); - } - return static::$container['property'][$key]; - } - - /** - * Get all property names for the given class. - * - * @return array - */ - public static function reflectPropertyNames(string $className): array - { - if (! isset(static::$container['property_names'][$className])) { - if (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className)) { - throw new InvalidArgumentException("Class {$className} does not exist"); - } - $properties = static::reflectClass($className)->getProperties(); - $result = []; - foreach ($properties as $property) { - $result[] = $property->getName(); - } - static::$container['property_names'][$className] = $result; - } - return static::$container['property_names'][$className]; - } - - /** - * Get the default value of a reflection property. - */ - public static function getPropertyDefaultValue(ReflectionProperty $property): mixed - { - return $property->getDefaultValue(); - } - - /** - * Get the raw container data. - */ - public static function getContainer(): array - { - return static::$container; - } - - /** - * Flush all static state. - */ - public static function flushState(): void - { - static::$container = []; - } -} diff --git a/src/events/src/Dispatcher.php b/src/events/src/Dispatcher.php index 5e9636d8b..907be96d8 100755 --- a/src/events/src/Dispatcher.php +++ b/src/events/src/Dispatcher.php @@ -41,6 +41,7 @@ use Hypervel\Support\Traits\Macroable; use Hypervel\Support\Traits\ReflectsClosures; use ReflectionClass; +use ReflectionException; use function Hypervel\Support\enum_value; @@ -886,6 +887,8 @@ protected function queueHandler(string $class, string $method, array $arguments) * * @param class-string $class * @return array{TListener, CallQueuedListener} + * + * @throws ReflectionException */ protected function createListenerAndJob(string $class, string $method, array $arguments): array { diff --git a/src/foundation/src/Bootstrap/LoadConfiguration.php b/src/foundation/src/Bootstrap/LoadConfiguration.php index c879b6e4b..6205b073a 100644 --- a/src/foundation/src/Bootstrap/LoadConfiguration.php +++ b/src/foundation/src/Bootstrap/LoadConfiguration.php @@ -8,9 +8,11 @@ use Hypervel\Config\Repository; use Hypervel\Contracts\Config\Repository as RepositoryContract; use Hypervel\Contracts\Foundation\Application; +use Hypervel\Foundation\Configuration\ConfigMutationTracker; use Hypervel\Support\Collection; use SplFileInfo; use Symfony\Component\Finder\Finder; +use Throwable; class LoadConfiguration { @@ -22,10 +24,22 @@ class LoadConfiguration protected static ?Closure $alwaysUseConfig = null; /** - * Bootstrap the given application. + * Bootstrap the given application while preserving its configuration repository. + * + * Reloads are evaluated against a temporary repository so a failed rebuild can + * restore the previous binding. Successful rebuilds copy their contents into + * the existing repository before boot-mutation tracking is attached. */ public function bootstrap(Application $app): void { + $previousConfig = $app->bound('config') + ? $app->make('config') + : null; + + $previousConfig = $previousConfig instanceof Repository + ? $previousConfig + : null; + $items = []; // First we will see if we have a cache configuration file. If we do, we'll load @@ -50,20 +64,37 @@ public function bootstrap(Application $app): void // options available to the developer for use in various parts of this app. $app->instance('config', $config = new Repository($items)); - if (! $loadedFromCache) { - $this->loadConfigurationFiles($app, $config); - } + try { + if (! $loadedFromCache) { + $this->loadConfigurationFiles($app, $config); + } + + // Finally, we will set the application's environment based on the configuration + // values that were loaded. We will pass a callback which will be used to get + // the environment in a web context where an "--env" switch is not present. + $app->detectEnvironment(fn () => $config->string('app.env', 'production')); - // Finally, we will set the application's environment based on the configuration - // values that were loaded. We will pass a callback which will be used to get - // the environment in a web context where an "--env" switch is not present. - $app->detectEnvironment(fn () => $config->string('app.env', 'production')); + $app->resolveEnvironmentUsing($app->environment(...)); - $app->resolveEnvironmentUsing($app->environment(...)); + date_default_timezone_set($config->get('app.timezone', 'UTC')); - date_default_timezone_set($config->get('app.timezone', 'UTC')); + mb_internal_encoding('UTF-8'); + } catch (Throwable $exception) { + if ($previousConfig !== null) { + $app->instance('config', $previousConfig); + } + + throw $exception; + } + + if ($previousConfig !== null) { + // Long-lived services retain this object, so reload its contents instead of + // publishing a replacement that those services would never observe. + $previousConfig->replaceItems($config->all()); + $app->instance('config', $config = $previousConfig); + } - mb_internal_encoding('UTF-8'); + $app->make(ConfigMutationTracker::class)->observe($config); } /** diff --git a/src/foundation/src/Configuration/ConfigMutationTracker.php b/src/foundation/src/Configuration/ConfigMutationTracker.php new file mode 100644 index 000000000..fbc27d168 --- /dev/null +++ b/src/foundation/src/Configuration/ConfigMutationTracker.php @@ -0,0 +1,55 @@ +> + */ + protected array $mutations = []; + + /** + * Whether configuration mutations should be recorded. + */ + protected bool $recording = true; + + /** + * Observe configuration mutations made during application boot. + * + * Boot-only. Calling this outside application bootstrap replaces + * Foundation's mutation observer and can track the wrong lifecycle. + */ + public function observe(Repository $config): void + { + $config->setMutationObserver(function (array $values): void { + if ($this->recording) { + $this->mutations[] = $values; + } + }); + } + + /** + * Replay the recorded mutations and stop tracking changes in this worker. + * + * Boot-only. Calling this outside the before-worker-start boundary + * permanently stops recording and can omit later boot mutations. + */ + public function replay(Repository $config): void + { + $this->recording = false; + + foreach ($this->mutations as $mutation) { + $config->set($mutation); + } + } +} diff --git a/src/foundation/src/Listeners/ReloadDotenvAndConfig.php b/src/foundation/src/Listeners/ReloadDotenvAndConfig.php index 874aac2a1..e28ea3d8b 100644 --- a/src/foundation/src/Listeners/ReloadDotenvAndConfig.php +++ b/src/foundation/src/Listeners/ReloadDotenvAndConfig.php @@ -8,19 +8,15 @@ use Hypervel\Core\Events\BeforeWorkerStart; use Hypervel\Foundation\Application; use Hypervel\Foundation\Bootstrap\LoadConfiguration; +use Hypervel\Foundation\Configuration\ConfigMutationTracker; use Hypervel\Support\DotenvManager; class ReloadDotenvAndConfig { - protected static array $modifiedItems = []; - - protected static bool $stopCallback = false; - - public function __construct(protected Application $container) - { - $this->setConfigCallback( - $this->container->make(Repository::class) - ); + public function __construct( + protected Application $container, + protected ConfigMutationTracker $configMutationTracker + ) { } /** @@ -36,8 +32,7 @@ protected function reloadConfig(): void { $config = $this->rebuildConfigRepository(); - $this->setConfigCallback($config); - $this->replayModifiedItems($config); + $this->configMutationTracker->replay($config); } protected function reloadDotenv(): void @@ -52,23 +47,6 @@ protected function reloadDotenv(): void ); } - /** - * Track runtime config mutations on the active repository instance. - */ - protected function setConfigCallback(Repository $config): void - { - $config->afterSettingCallback(function (array $values): void { - if (static::$stopCallback) { - return; - } - - static::$modifiedItems = array_replace( - static::$modifiedItems, - $values - ); - }); - } - /** * Rebuild the config repository through the normal foundation bootstrap path. */ @@ -78,31 +56,4 @@ protected function rebuildConfigRepository(): Repository return $this->container->make(Repository::class); } - - /** - * Reapply runtime config mutations onto a freshly rebuilt repository. - */ - protected function replayModifiedItems(Repository $config): void - { - if (static::$modifiedItems === []) { - return; - } - - static::$stopCallback = true; - - try { - $config->set(static::$modifiedItems); - } finally { - static::$stopCallback = false; - } - } - - /** - * Flush all static state. - */ - public static function flushState(): void - { - static::$modifiedItems = []; - static::$stopCallback = false; - } } diff --git a/src/log/src/LogManager.php b/src/log/src/LogManager.php index b61b2a442..293a652ac 100644 --- a/src/log/src/LogManager.php +++ b/src/log/src/LogManager.php @@ -30,6 +30,9 @@ use Psr\Log\LoggerInterface; use Stringable; use Throwable; +use UnitEnum; + +use function Hypervel\Support\enum_value; /** * @mixin \Hypervel\Log\Logger @@ -98,7 +101,7 @@ public function stack(array $channels, ?string $channel = null): LoggerInterface /** * Get a log channel instance. */ - public function channel(?string $channel = null): LoggerInterface + public function channel(UnitEnum|string|null $channel = null): LoggerInterface { return $this->driver($channel); } @@ -106,8 +109,12 @@ public function channel(?string $channel = null): LoggerInterface /** * Get a log driver instance. */ - public function driver(?string $driver = null): LoggerInterface + public function driver(UnitEnum|string|null $driver = null): LoggerInterface { + if ($driver instanceof UnitEnum) { + $driver = (string) enum_value($driver); + } + return $this->get($this->parseDriver($driver)); } @@ -532,8 +539,12 @@ public function getDefaultDriver(): ?string * * Boot-only. Mutates process-global config; per-request use races across coroutines. */ - public function setDefaultDriver(string $name): void + public function setDefaultDriver(UnitEnum|string $name): void { + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + $this->app->make('config')->set('logging.default', $name); } diff --git a/src/log/src/Logger.php b/src/log/src/Logger.php index 8b3bf7aa5..fbddc1fd3 100755 --- a/src/log/src/Logger.php +++ b/src/log/src/Logger.php @@ -12,6 +12,7 @@ use Hypervel\Log\Context\Repository as ContextRepository; use Hypervel\Log\Events\MessageLogged; use Hypervel\Support\Traits\Conditionable; +use Monolog\Logger as Monolog; use Psr\Log\LoggerInterface; use RuntimeException; use Stringable; @@ -21,15 +22,15 @@ class Logger implements LoggerInterface use Conditionable; /** - * The CoroutineContext key prefix for per-instance logger context. + * The CoroutineContext key prefix for per-channel logger context. */ protected const CONTEXT_KEY_PREFIX = '__log.channel_context.'; /** - * The coroutine-local key for this logger instance's context. + * The coroutine-local key for this logger channel's context. * - * Each Logger gets its own CoroutineContext slot so that - * withContext() on one channel does not leak into others. + * Named variants share their source channel's slot so context added through + * either wrapper remains channel-local without leaking to other channels. */ protected readonly string $contextKey; @@ -142,6 +143,23 @@ protected function writeLog(string $level, Arrayable|Jsonable|Stringable|array|s $this->fireLogEvent($level, $message, $context); } + /** + * Return a named variant of the logger. + * + * @throws RuntimeException + */ + public function withName(string $name): self + { + if (! $this->logger instanceof Monolog) { + throw new RuntimeException('Named loggers are only supported by Monolog drivers.'); + } + + $logger = clone $this; + $logger->logger = $this->logger->withName($name); + + return $logger; + } + /** * Add context to all future logs. * diff --git a/src/reflection/src/ClassInvoker.php b/src/reflection/src/ClassInvoker.php index 4a613553d..cf5e41d14 100644 --- a/src/reflection/src/ClassInvoker.php +++ b/src/reflection/src/ClassInvoker.php @@ -16,7 +16,7 @@ class ClassInvoker public function __construct( protected object $instance ) { - $this->reflection = new ReflectionClass($instance); + $this->reflection = ClassMetadataCache::reflectClass($instance); } /** diff --git a/src/reflection/src/ClassMetadataCache.php b/src/reflection/src/ClassMetadataCache.php index 11ce79658..9725f7aca 100644 --- a/src/reflection/src/ClassMetadataCache.php +++ b/src/reflection/src/ClassMetadataCache.php @@ -6,6 +6,8 @@ use Exception; use ReflectionClass; +use ReflectionException; +use ReflectionMethod; use ReflectionProperty; class ClassMetadataCache @@ -15,6 +17,11 @@ class ClassMetadataCache */ protected static array $classes = []; + /** + * @var array> + */ + protected static array $methods = []; + /** * @var array> */ @@ -45,6 +52,8 @@ class ClassMetadataCache * * @param class-string|object $target * @return ReflectionClass + * + * @throws ReflectionException */ public static function reflectClass(object|string $target): ReflectionClass { @@ -53,11 +62,28 @@ public static function reflectClass(object|string $target): ReflectionClass return static::$classes[$class] ??= new ReflectionClass($class); } + /** + * Get the cached reflection method for the given class and method. + * + * @param class-string|object $target + * + * @throws ReflectionException + */ + public static function reflectMethod(object|string $target, string $method): ReflectionMethod + { + $class = static::className($target); + + return static::$methods[$class][$method] + ??= static::reflectClass($class)->getMethod($method); + } + /** * Get the cached default properties for the given class. * * @param class-string|object $target * @return array + * + * @throws ReflectionException */ public static function defaultProperties(object|string $target): array { @@ -72,6 +98,8 @@ public static function defaultProperties(object|string $target): array * * @param class-string|object $target * @return list + * + * @throws ReflectionException */ public static function properties(object|string $target): array { @@ -86,6 +114,8 @@ public static function properties(object|string $target): array * * @param class-string|object $target * @param class-string $attributeClass + * + * @throws ReflectionException */ public static function getAttribute(object|string $target, string $attributeClass): ?CachedClassAttribute { @@ -107,6 +137,8 @@ public static function getAttribute(object|string $target, string $attributeClas * * @param class-string|object $target * @param class-string $attributeClass + * + * @throws ReflectionException */ public static function hasClassAttribute(object|string $target, string $attributeClass): bool { @@ -179,6 +211,9 @@ protected static function resolveAttribute(string $class, string $attributeClass $reflection = $reflection->getParentClass(); } while ($reflection !== false); } catch (Exception) { + // Attribute configuration is optional, so constructor exceptions fall + // back to the property/default. Programming Errors must still surface + // and remain uncached, which is why this deliberately catches Exception. } return null; @@ -201,6 +236,7 @@ protected static function className(object|string $target): string public static function flushState(): void { static::$classes = []; + static::$methods = []; static::$defaultProperties = []; static::$properties = []; static::$attributes = []; diff --git a/src/reflection/src/Reflector.php b/src/reflection/src/Reflector.php index 6f9007895..9ac84382d 100644 --- a/src/reflection/src/Reflector.php +++ b/src/reflection/src/Reflector.php @@ -5,8 +5,8 @@ namespace Hypervel\Support; use ReflectionAttribute; -use ReflectionClass; use ReflectionEnum; +use ReflectionException; use ReflectionMethod; use ReflectionNamedType; use ReflectionParameter; @@ -23,13 +23,15 @@ public static function isCallable(mixed $var, bool $syntaxOnly = false): bool return is_callable($var, $syntaxOnly); } - if ((! isset($var[0]) || ! isset($var[1])) + if (count($var) !== 2 + || ! array_key_exists(0, $var) + || ! array_key_exists(1, $var) + || (! is_string($var[0]) && ! is_object($var[0])) || ! is_string($var[1])) { return false; } - if ($syntaxOnly - && (is_string($var[0]) || is_object($var[0]))) { + if ($syntaxOnly) { return true; } @@ -65,7 +67,7 @@ public static function isCallable(mixed $var, bool $syntaxOnly = false): bool * @param class-string $attribute * @return null|TAttribute */ - public static function getClassAttribute(mixed $objectOrClass, string $attribute, bool $ascend = false): ?object + public static function getClassAttribute(object|string $objectOrClass, string $attribute, bool $ascend = false): ?object { return static::getClassAttributes($objectOrClass, $attribute, $ascend)->flatten()->first(); } @@ -78,11 +80,13 @@ public static function getClassAttribute(mixed $objectOrClass, string $attribute * * @param class-string|TTarget $objectOrClass * @param class-string $attribute - * @return Collection, Collection>|Collection + * @return ($includeParents is true ? Collection, Collection> : Collection) + * + * @throws ReflectionException */ - public static function getClassAttributes(mixed $objectOrClass, string $attribute, bool $includeParents = false): Collection + public static function getClassAttributes(object|string $objectOrClass, string $attribute, bool $includeParents = false): Collection { - $reflectionClass = new ReflectionClass($objectOrClass); + $reflectionClass = ClassMetadataCache::reflectClass($objectOrClass); $attributes = []; @@ -137,7 +141,7 @@ public static function getParameterClassNames(ReflectionParameter $parameter): a /** * Get the given type's class name. */ - protected static function getTypeName(ReflectionParameter $parameter, ReflectionNamedType $type): ?string + protected static function getTypeName(ReflectionParameter $parameter, ReflectionNamedType $type): string { $name = $type->getName(); @@ -163,7 +167,7 @@ public static function isParameterSubclassOf(ReflectionParameter $parameter, str return $paramClassName && (class_exists($paramClassName) || interface_exists($paramClassName)) - && (new ReflectionClass($paramClassName))->isSubclassOf($className); + && ClassMetadataCache::reflectClass($paramClassName)->isSubclassOf($className); } /** diff --git a/src/reflection/src/Traits/ReflectsClosures.php b/src/reflection/src/Traits/ReflectsClosures.php index 9cf3e8e51..2f4be46dd 100644 --- a/src/reflection/src/Traits/ReflectsClosures.php +++ b/src/reflection/src/Traits/ReflectsClosures.php @@ -47,32 +47,29 @@ protected function firstClosureParameterType(Closure $closure): string */ protected function firstClosureParameterTypes(Closure $closure): array { - $reflection = new ReflectionFunction($closure); - - /** @var list> $types */ - $types = Collection::make($reflection->getParameters())->mapWithKeys(function ($parameter) { - if ($parameter->isVariadic()) { - return [$parameter->getName() => null]; - } - - return [$parameter->getName() => Reflector::getParameterClassNames($parameter)]; - })->filter()->values()->all(); + $parameters = (new ReflectionFunction($closure))->getParameters(); - if (empty($types)) { + if ($parameters === []) { throw new RuntimeException('The given Closure has no parameters.'); } - if (empty($types[0])) { + $types = $parameters[0]->isVariadic() + ? [] + : Reflector::getParameterClassNames($parameters[0]); + + if ($types === []) { throw new RuntimeException('The first parameter of the given Closure is missing a type hint.'); } - return $types[0]; + return array_values($types); } /** * Get the class names / types of the parameters of the given Closure. * * @return array + * + * @throws ReflectionException */ protected function closureParameterTypes(Closure $closure): array { @@ -91,6 +88,8 @@ protected function closureParameterTypes(Closure $closure): array * Get the class names / types of the return type of the given Closure. * * @return list + * + * @throws ReflectionException */ protected function closureReturnTypes(Closure $closure): array { @@ -111,7 +110,7 @@ protected function closureReturnTypes(Closure $closure): array return $namedTypes ->reject(fn (ReflectionNamedType $type) => $type->isBuiltin()) - ->reject(fn (ReflectionNamedType $type) => in_array($type->getName(), ['static', 'self'])) + ->reject(fn (ReflectionNamedType $type) => in_array($type->getName(), ['static', 'self', 'parent'])) ->map(fn (ReflectionNamedType $type) => $type->getName()) ->values() ->all(); diff --git a/src/reflection/src/helpers.php b/src/reflection/src/helpers.php index 0e51c5927..c49e23ff9 100644 --- a/src/reflection/src/helpers.php +++ b/src/reflection/src/helpers.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Hypervel\Support\ClassMetadataCache; use Hypervel\Support\Traits\ReflectsClosures; if (! function_exists('lazy')) { @@ -14,6 +15,8 @@ * @param (Closure(TValue): mixed)|int $callback * @param array $eager * @return TValue + * + * @throws \ReflectionException */ function lazy(string|Closure $class, Closure|int $callback = 0, int $options = 0, array $eager = []): object { @@ -32,7 +35,7 @@ public function typeFromParameter(Closure $callback): string ? [$class, $callback, $options] : [$closureReflector->typeFromParameter($class), $class, $callback ?: $options]; - $reflectionClass = new ReflectionClass($class); + $reflectionClass = ClassMetadataCache::reflectClass($class); $instance = $reflectionClass->newLazyGhost(function ($instance) use ($callback) { $result = $callback($instance); @@ -56,10 +59,12 @@ public function typeFromParameter(Closure $callback): string * * @template TValue of object * - * @param class-string|(Closure(TValue): TValue) $class - * @param (Closure(TValue): TValue)|int $callback + * @param class-string|(Closure(TValue, array): TValue) $class + * @param (Closure(TValue, array): TValue)|int $callback * @param array $eager * @return TValue + * + * @throws \ReflectionException */ function proxy(string|Closure $class, Closure|int $callback = 0, int $options = 0, array $eager = []): object { @@ -78,7 +83,7 @@ public function get(Closure $callback): string ? [$class, $callback, $options] : [$closureReflector->get($class), $class, $callback ?: $options]; - $reflectionClass = new ReflectionClass($class); + $reflectionClass = ClassMetadataCache::reflectClass($class); $proxy = $reflectionClass->newLazyProxy(function () use ($callback, $eager, &$proxy) { $instance = $callback($proxy, $eager); diff --git a/src/routing/src/ResolvesRouteDependencies.php b/src/routing/src/ResolvesRouteDependencies.php index f8a373d01..1da6c0b03 100644 --- a/src/routing/src/ResolvesRouteDependencies.php +++ b/src/routing/src/ResolvesRouteDependencies.php @@ -87,7 +87,7 @@ public function resolveMethodDependencies(array $parameters, array $reflectedPar protected function transformDependency(ReflectionParameter $parameter, array $parameters, object $skippableValue): mixed { if ($attribute = Util::getContextualAttributeFromDependency($parameter)) { - return $this->container->resolveFromAttribute($attribute); + return $this->container->resolveFromAttribute($attribute, $parameter); } $className = Reflector::getParameterClassName($parameter); diff --git a/src/routing/src/RouteSignatureParameters.php b/src/routing/src/RouteSignatureParameters.php index 45df8623e..d39adf183 100644 --- a/src/routing/src/RouteSignatureParameters.php +++ b/src/routing/src/RouteSignatureParameters.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Support\Reflector; use Hypervel\Support\Str; +use ReflectionException; use ReflectionFunction; use ReflectionMethod; use ReflectionParameter; @@ -79,6 +80,8 @@ public static function flushCache(): void * Get the parameters for the given class / method by string. * * @return array + * + * @throws ReflectionException */ protected static function fromClassMethodString(string $uses): array { diff --git a/src/support/src/Facades/App.php b/src/support/src/Facades/App.php index 2bc41c30a..b7e1c013e 100644 --- a/src/support/src/Facades/App.php +++ b/src/support/src/Facades/App.php @@ -95,7 +95,7 @@ * @method static bool hasMethodBinding(string $method) * @method static void bindMethod(array|string $method, \Closure $callback) * @method static mixed callMethodBinding(string $method, mixed $instance) - * @method static void addContextualBinding(string $concrete, \Closure|string $abstract, mixed $implementation) + * @method static void addContextualBinding(string $concrete, string $abstract, mixed $implementation) * @method static void bindIf(\Closure|string $abstract, \Closure|string|null $concrete = null, bool $shared = false) * @method static void singleton(\Closure|string $abstract, \Closure|string|null $concrete = null) * @method static void singletonIf(\Closure|string $abstract, \Closure|string|null $concrete = null) @@ -115,7 +115,8 @@ * @method static object|mixed make(string $abstract, array $parameters = []) * @method static object|mixed get(string $id) * @method static object build(string|\Closure $concrete) - * @method static mixed resolveFromAttribute(\ReflectionAttribute $attribute) + * @method static mixed buildWith(string|\Closure $concrete, array $parameters = []) + * @method static mixed resolveFromAttribute(\ReflectionAttribute $attribute, \ReflectionParameter $parameter) * @method static void beforeResolving(\Closure|string $abstract, \Closure|null $callback = null) * @method static void resolving(\Closure|string $abstract, \Closure|null $callback = null) * @method static void afterResolving(\Closure|string $abstract, \Closure|null $callback = null) diff --git a/src/support/src/Facades/Auth.php b/src/support/src/Facades/Auth.php index f72f81d0e..003db882e 100644 --- a/src/support/src/Facades/Auth.php +++ b/src/support/src/Facades/Auth.php @@ -5,12 +5,12 @@ namespace Hypervel\Support\Facades; /** - * @method static \Hypervel\Contracts\Auth\Guard|\Hypervel\Contracts\Auth\StatefulGuard guard(string|null $name = null) + * @method static \Hypervel\Contracts\Auth\Guard|\Hypervel\Contracts\Auth\StatefulGuard guard(\UnitEnum|string|null $name = null) * @method static \Hypervel\Auth\SessionGuard createSessionDriver(string $name, array $config) * @method static \Hypervel\Auth\TokenGuard createTokenDriver(string $name, array $config) * @method static string getDefaultDriver() - * @method static void shouldUse(string|null $name) - * @method static void setDefaultDriver(string $name) + * @method static void shouldUse(\UnitEnum|string|null $name) + * @method static void setDefaultDriver(\UnitEnum|string $name) * @method static \Hypervel\Auth\AuthManager viaRequest(string $driver, callable $callback) * @method static \Closure userResolver() * @method static \Hypervel\Auth\AuthManager resolveUsersUsing(\Closure $userResolver) diff --git a/src/support/src/Facades/Cache.php b/src/support/src/Facades/Cache.php index fc4529488..4b02de42b 100644 --- a/src/support/src/Facades/Cache.php +++ b/src/support/src/Facades/Cache.php @@ -7,17 +7,17 @@ use Mockery; /** - * @method static \Hypervel\Contracts\Cache\Repository store(string|null $name = null) - * @method static \Hypervel\Contracts\Cache\Repository driver(string|null $driver = null) - * @method static \Hypervel\Contracts\Cache\Repository memo(string|null $driver = null) + * @method static \Hypervel\Contracts\Cache\Repository store(\UnitEnum|string|null $name = null) + * @method static \Hypervel\Contracts\Cache\Repository driver(\UnitEnum|string|null $driver = null) + * @method static \Hypervel\Contracts\Cache\Repository memo(\UnitEnum|string|null $driver = null) * @method static \Hypervel\Contracts\Cache\Repository resolve(string $name) * @method static \Hypervel\Contracts\Cache\Repository build(array $config) * @method static \Hypervel\Cache\Repository repository(\Hypervel\Contracts\Cache\Store $store, array $config = []) * @method static void refreshEventDispatcher() * @method static string getDefaultDriver() - * @method static void setDefaultDriver(string $name) - * @method static \Hypervel\Cache\CacheManager forgetDriver(array|string|null $name = null) - * @method static void purge(string|null $name = null) + * @method static void setDefaultDriver(\UnitEnum|string $name) + * @method static \Hypervel\Cache\CacheManager forgetDriver(array|\UnitEnum|string|null $name = null) + * @method static void purge(\UnitEnum|string|null $name = null) * @method static \Hypervel\Cache\CacheManager extend(string $driver, \Closure $callback) * @method static \Hypervel\Cache\CacheManager setApplication(\Hypervel\Contracts\Container\Container $app) * @method static mixed pull(\UnitEnum|string $key, \Closure|mixed $default = null) diff --git a/src/support/src/Facades/Config.php b/src/support/src/Facades/Config.php index 108177239..cc83e027f 100644 --- a/src/support/src/Facades/Config.php +++ b/src/support/src/Facades/Config.php @@ -18,7 +18,6 @@ * @method static void prepend(string $key, mixed $value) * @method static void push(string $key, mixed $value) * @method static array all() - * @method static void afterSettingCallback(\Closure|null $callback) * @method static void macro(string $name, callable|object $macro) * @method static void mixin(object $mixin, bool $replace = true) * @method static bool hasMacro(string $name) diff --git a/src/support/src/Facades/Log.php b/src/support/src/Facades/Log.php index a0158c474..3a915ffa4 100644 --- a/src/support/src/Facades/Log.php +++ b/src/support/src/Facades/Log.php @@ -7,14 +7,14 @@ /** * @method static \Psr\Log\LoggerInterface build(array $config) * @method static \Psr\Log\LoggerInterface stack(array $channels, string|null $channel = null) - * @method static \Psr\Log\LoggerInterface channel(string|null $channel = null) - * @method static \Psr\Log\LoggerInterface driver(string|null $driver = null) + * @method static \Psr\Log\LoggerInterface channel(\UnitEnum|string|null $channel = null) + * @method static \Psr\Log\LoggerInterface driver(\UnitEnum|string|null $driver = null) * @method static \Hypervel\Log\LogManager shareContext(array $context) * @method static array sharedContext() * @method static \Hypervel\Log\LogManager withoutContext(null|string[] $keys = null) * @method static \Hypervel\Log\LogManager flushSharedContext() * @method static string|null getDefaultDriver() - * @method static void setDefaultDriver(string $name) + * @method static void setDefaultDriver(\UnitEnum|string $name) * @method static \Hypervel\Log\LogManager extend(string $driver, \Closure $callback) * @method static void forgetChannel(string|null $driver = null) * @method static array getChannels() @@ -30,6 +30,7 @@ * @method static \Hypervel\Log\LogManager setApplication(\Hypervel\Contracts\Foundation\Application $app) * @method static void write(string $level, \Hypervel\Contracts\Support\Arrayable|\Hypervel\Contracts\Support\Jsonable|\Stringable|array|string $message, array $context = []) * @method static \Hypervel\Log\Logger withContext(array $context = []) + * @method static \Hypervel\Log\Logger withName(string $name) * @method static array getContext() * @method static void listen(\Closure $callback) * @method static \Psr\Log\LoggerInterface getLogger() diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 20cc4952f..0b7b0f717 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -135,7 +135,6 @@ protected function flushFrameworkState(): void \Hypervel\Container\BoundMethod::flushState(); \Hypervel\Container\Container::flushState(); \Hypervel\Container\Container::setInstance(null); - \Hypervel\Container\ReflectionManager::flushState(); \Hypervel\Context\CoroutineContext::flush(); \Hypervel\Contracts\Database\ModelIdentifier::flushState(); \Hypervel\Cookie\CookieJar::flushState(); @@ -172,7 +171,6 @@ protected function flushFrameworkState(): void \Hypervel\Di\Aop\AspectManager::flushState(); \Hypervel\Di\Aop\AstVisitorRegistry::flushState(); \Hypervel\Di\ClassMap\ClassMapManager::flushState(); - \Hypervel\Di\ReflectionManager::flushState(); \Hypervel\Events\Dispatcher::flushState(); \Hypervel\Filesystem\Filesystem::flushState(); \Hypervel\Filesystem\FilesystemAdapter::flushState(); @@ -196,7 +194,6 @@ protected function flushFrameworkState(): void \Hypervel\Foundation\Http\Middleware\PreventRequestForgery::flushState(); \Hypervel\Foundation\Http\Middleware\PreventRequestsDuringMaintenance::flushState(); \Hypervel\Foundation\Http\Middleware\TrimStrings::flushState(); - \Hypervel\Foundation\Listeners\ReloadDotenvAndConfig::flushState(); \Hypervel\Foundation\PackageManifest::flushState(); \Hypervel\Foundation\Support\Providers\EventServiceProvider::flushState(); \Hypervel\Foundation\Support\Providers\RouteServiceProvider::flushState(); diff --git a/tests/Auth/AuthenticateMiddlewareTest.php b/tests/Auth/AuthenticateMiddlewareTest.php index 780f93c2c..59b70e2bc 100644 --- a/tests/Auth/AuthenticateMiddlewareTest.php +++ b/tests/Auth/AuthenticateMiddlewareTest.php @@ -170,6 +170,46 @@ public function testCustomDriverClosureBoundObjectIsAuthManager() $this->assertSame($this->auth, $boundTo); } + public function testAuthManagerCanResolveBackedEnumGuard(): void + { + $driver = $this->registerAuthDriver('default', true); + + $guard1 = $this->auth->guard(GuardName::Default); + $guard2 = $this->auth->guard('default'); + + $this->assertSame($guard1, $guard2); + $this->assertSame($driver, $guard1); + } + + public function testAuthManagerCanResolveZeroBackedEnumGuard(): void + { + $driver = $this->registerAuthDriver('0', true); + + $guard1 = $this->auth->guard(NumericGuardName::Zero); + $guard2 = $this->auth->guard('0'); + + $this->assertSame($guard1, $guard2); + $this->assertSame($driver, $guard1); + } + + public function testShouldUseAcceptsBackedEnum(): void + { + $this->registerAuthDriver('default', true); + $secondary = $this->registerAuthDriver('secondary', true); + + $this->auth->shouldUse(GuardName::Secondary); + + $this->assertSame('secondary', $this->auth->getDefaultDriver()); + $this->assertSame($secondary, $this->auth->guard()); + } + + public function testSetDefaultDriverAcceptsBackedEnum(): void + { + $this->auth->setDefaultDriver(GuardName::Secondary); + + $this->assertSame('secondary', $this->auth->getDefaultDriver()); + } + /** * Create a new config repository instance. */ @@ -181,6 +221,7 @@ protected function createConfig(): Config 'guards' => [ 'default' => ['driver' => 'default'], 'secondary' => ['driver' => 'secondary'], + '0' => ['driver' => '0'], __CLASS__ => ['driver' => __CLASS__], ], ], @@ -236,3 +277,14 @@ protected function authenticate(string ...$guards): void $this->assertSame($request, $nextParam); } } + +enum GuardName: string +{ + case Default = 'default'; + case Secondary = 'secondary'; +} + +enum NumericGuardName: int +{ + case Zero = 0; +} diff --git a/tests/Cache/CacheManagerTest.php b/tests/Cache/CacheManagerTest.php index a771f5eb7..22939679b 100644 --- a/tests/Cache/CacheManagerTest.php +++ b/tests/Cache/CacheManagerTest.php @@ -525,6 +525,126 @@ public function testMakesRepositoryWithoutDispatcherWhenEventsDisabled() $this->assertNull($repoWithoutEvents->getEventDispatcher()); } + public function testEnumStoreCanBeResolved(): void + { + $app = $this->getApp([ + 'cache' => [ + 'stores' => [ + 'array' => ['driver' => 'array'], + ], + ], + ]); + $cacheManager = new CacheManager($app); + + $store = $cacheManager->store(CacheStoreName::ArrayStore); + + $this->assertInstanceOf(ArrayStore::class, $store->getStore()); + $this->assertSame($store, $cacheManager->store(CacheStoreName::ArrayStore)); + } + + public function testZeroStoreNameCanBeResolvedFromEnumAndString(): void + { + $app = $this->getApp([ + 'cache' => [ + 'default' => 'array', + 'stores' => [ + '0' => ['driver' => 'array'], + 'array' => ['driver' => 'array'], + ], + ], + ]); + $cacheManager = new CacheManager($app); + + $store = $cacheManager->store(NumericCacheStoreName::Zero); + + $this->assertInstanceOf(ArrayStore::class, $store->getStore()); + $this->assertSame($store, $cacheManager->store('0')); + $this->assertNotSame($store, $cacheManager->store()); + } + + public function testEnumDriverCanBeResolved(): void + { + $app = $this->getApp([ + 'cache' => [ + 'stores' => [ + 'array' => ['driver' => 'array'], + ], + ], + ]); + $cacheManager = new CacheManager($app); + + $store = $cacheManager->driver(CacheStoreName::ArrayStore); + + $this->assertInstanceOf(ArrayStore::class, $store->getStore()); + } + + public function testEnumMemoStoreCanBeResolved(): void + { + $app = $this->getApp([ + 'cache' => [ + 'stores' => [ + 'array' => ['driver' => 'array'], + ], + ], + ]); + $cacheManager = new CacheManager($app); + + $store = $cacheManager->memo(CacheStoreName::ArrayStore); + + $this->assertSame($store, $cacheManager->memo(CacheStoreName::ArrayStore)); + } + + public function testForgetDriverAcceptsEnum(): void + { + $app = $this->getApp([ + 'cache' => [ + 'stores' => [ + 'array' => ['driver' => 'array'], + ], + ], + ]); + $cacheManager = new CacheManager($app); + + $repo1 = $cacheManager->store(CacheStoreName::ArrayStore); + $cacheManager->forgetDriver(CacheStoreName::ArrayStore); + $repo2 = $cacheManager->store(CacheStoreName::ArrayStore); + + $this->assertNotSame($repo1, $repo2); + } + + public function testPurgeAcceptsEnum(): void + { + $app = $this->getApp([ + 'cache' => [ + 'stores' => [ + 'array' => ['driver' => 'array'], + ], + ], + ]); + $cacheManager = new CacheManager($app); + + $repo1 = $cacheManager->store(CacheStoreName::ArrayStore); + $cacheManager->purge(CacheStoreName::ArrayStore); + $repo2 = $cacheManager->store(CacheStoreName::ArrayStore); + + $this->assertNotSame($repo1, $repo2); + } + + public function testSetDefaultDriverAcceptsEnum(): void + { + $app = $this->getApp([ + 'cache' => [ + 'default' => 'old', + 'stores' => [], + ], + ]); + $cacheManager = new CacheManager($app); + + $cacheManager->setDefaultDriver(CacheStoreName::ArrayStore); + + $this->assertSame('array', $app->get('config')->get('cache.default')); + } + protected function getApp(array $userConfig): Container { $app = new Container; @@ -571,3 +691,13 @@ protected function getAppWithRedis(array $userConfig): Container return $app; } } + +enum CacheStoreName: string +{ + case ArrayStore = 'array'; +} + +enum NumericCacheStoreName: int +{ + case Zero = 0; +} diff --git a/tests/Config/RepositoryTest.php b/tests/Config/RepositoryTest.php index ba54f5327..b5808f448 100644 --- a/tests/Config/RepositoryTest.php +++ b/tests/Config/RepositoryTest.php @@ -175,10 +175,10 @@ public function testSetArray() $this->assertNull($this->repository->get('key5')); } - public function testAfterSettingCallback() + public function testMutationObserver() { $result = null; - $this->repository->afterSettingCallback(function (array $values) use (&$result) { + $this->repository->setMutationObserver(function (array $values) use (&$result) { $result = $values; }); @@ -189,7 +189,10 @@ public function testAfterSettingCallback() $this->assertSame($expected, $result); - $this->repository->afterSettingCallback(null); + $this->repository->replaceItems(['replacement' => true]); + + $this->assertSame($expected, $result); + $this->assertSame(['replacement' => true], $this->repository->all()); } public function testPrepend() @@ -250,6 +253,7 @@ public function testOffsetExists() $this->assertTrue(isset($this->repository['empty_string'])); $this->assertTrue(isset($this->repository['numeric_value'])); $this->assertFalse(isset($this->repository['non_numeric'])); + $this->assertFalse(isset($this->repository[-1])); } public function testOffsetGet() @@ -260,6 +264,10 @@ public function testOffsetGet() 'x' => 'xxx', 'y' => 'yyy', ], $this->repository['associate']); + + $this->repository[-1] = 'negative'; + + $this->assertSame('negative', $this->repository[-1]); } public function testOffsetSet() @@ -279,8 +287,8 @@ public function testOffsetSet() $this->repository[''] = 'value'; $this->assertSame('value', $this->repository['']); - $this->repository['123'] = '123'; - $this->assertSame('123', $this->repository['123']); + $this->repository[123] = '123'; + $this->assertSame('123', $this->repository[123]); } public function testOffsetUnset() @@ -292,6 +300,12 @@ public function testOffsetUnset() $this->assertArrayHasKey('associate', $this->repository->all()); $this->assertNull($this->repository->get('associate')); + + $this->repository[-1] = 'negative'; + unset($this->repository[-1]); + + $this->assertTrue(isset($this->repository[-1])); + $this->assertNull($this->repository[-1]); } public function testsItIsMacroable() diff --git a/tests/Container/ContainerCallTest.php b/tests/Container/ContainerCallTest.php index e425b2e6d..f1d5d2847 100644 --- a/tests/Container/ContainerCallTest.php +++ b/tests/Container/ContainerCallTest.php @@ -234,6 +234,24 @@ public function testCallWithoutRequiredParamsOnClosureThrowsException() }); } + public function testCallWithoutRequiredParamsOnUnscopedClosureThrowsException(): void + { + $container = new Container; + $closure = Closure::bind(static function ($foo): void { + }, null, null); + + try { + $container->call($closure); + $this->fail('Expected the unresolvable closure dependency to throw.'); + } catch (BindingResolutionException $exception) { + $this->assertStringContainsString( + 'Unable to resolve dependency [Parameter #0 [ $foo ]]', + $exception->getMessage(), + ); + $this->assertStringContainsString('in function {closure:', $exception->getMessage()); + } + } + public function testCallWithNullableClassParameterDefaultValue() { $container = new Container; @@ -286,6 +304,18 @@ public function testMethodRecipeCacheIsPopulatedForArrayCallables() $this->assertArrayHasKey($key, $cache); $this->assertCount(2, $cache[$key]); $this->assertInstanceOf(ParameterRecipe::class, $cache[$key][0]); + $this->assertNotSame('', $cache[$key][0]->reflectionString); + } + + public function testMethodRecipeObjectDefaultsAreFreshAcrossCalls(): void + { + $container = new Container; + $callable = [new ContainerCallObjectDefaultStub, 'handle']; + + $first = $container->call($callable); + $second = $container->call($callable); + + $this->assertNotSame($first, $second); } public function testMethodRecipeCacheIsClearedOnFlush() @@ -350,6 +380,18 @@ public function testGlobalFunctionStringsDoNotPopulateMethodRecipeCache() $this->assertArrayHasKey($functionName, $functionCache); $this->assertCount(2, $functionCache[$functionName]); $this->assertInstanceOf(ParameterRecipe::class, $functionCache[$functionName][0]); + $this->assertNotSame('', $functionCache[$functionName][0]->reflectionString); + } + + public function testFunctionRecipeObjectDefaultsAreFreshAcrossCalls(): void + { + $container = new Container; + $function = 'Hypervel\Tests\Container\containerTestObjectDefault'; + + $first = $container->call($function); + $second = $container->call($function); + + $this->assertNotSame($first, $second); } public function testMethodRecipeCacheIsPopulatedForStaticMethodStrings() @@ -532,6 +574,19 @@ function containerTestInject(ContainerCallConcreteStub $stub, $default = 'taylor return func_get_args(); } +function containerTestObjectDefault(stdClass $default = new stdClass): stdClass +{ + return $default; +} + +class ContainerCallObjectDefaultStub +{ + public function handle(stdClass $default = new stdClass): stdClass + { + return $default; + } +} + class ContainerStaticMethodStub { public static function inject(ContainerCallConcreteStub $stub, $default = 'taylor') diff --git a/tests/Container/ContainerTest.php b/tests/Container/ContainerTest.php index 0622362df..e82f290ef 100755 --- a/tests/Container/ContainerTest.php +++ b/tests/Container/ContainerTest.php @@ -10,13 +10,14 @@ use Hypervel\Container\Attributes\Singleton; use Hypervel\Container\Container; use Hypervel\Container\EntryNotFoundException; -use Hypervel\Container\ReflectionManager; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Container\BindingResolutionException; use Hypervel\Contracts\Container\CircularDependencyException; use Hypervel\Contracts\Container\ContextualAttribute; use Hypervel\Contracts\Container\SelfBuilding; use Hypervel\Tests\TestCase; use InvalidArgumentException; +use LogicException; use Psr\Container\ContainerExceptionInterface; use ReflectionProperty; use stdClass; @@ -91,6 +92,19 @@ public function testBindIfDoesRegisterIfServiceNotRegisteredYet() $this->assertSame('Dayle', $container->make('name')); } + public function testBindIfWithClosureRegistersOnlyUnboundReturnTypes(): void + { + $container = new Container; + $container->bind(IContainerContractStub::class, ContainerImplementationStubTwo::class); + + $container->bindIf(function (): IContainerContractStub|ContainerImplementationStub { + return new ContainerImplementationStub; + }); + + $this->assertInstanceOf(ContainerImplementationStubTwo::class, $container->make(IContainerContractStub::class)); + $this->assertInstanceOf(ContainerImplementationStub::class, $container->make(ContainerImplementationStub::class)); + } + public function testSingletonIfDoesntRegisterIfBindingAlreadyRegistered() { $container = new Container; @@ -119,6 +133,17 @@ public function testSingletonIfDoesRegisterIfBindingNotRegisteredYet() $this->assertSame($firstInstantiation, $secondInstantiation); } + public function testSingletonIfWithClosureRegistersSharedReturnType(): void + { + $container = new Container; + $container->singletonIf(fn (): ContainerConcreteStub => new ContainerConcreteStub); + + $this->assertSame( + $container->make(ContainerConcreteStub::class), + $container->make(ContainerConcreteStub::class), + ); + } + public function testSharedClosureResolution() { $container = new Container; @@ -178,6 +203,20 @@ public function testScopedIf() $this->assertNotSame('bar', $container->make('class')); } + public function testScopedIfWithClosureRegistersScopedReturnType(): void + { + $container = new Container; + $container->scopedIf(fn (): ContainerConcreteStub => new ContainerConcreteStub); + + $first = $container->make(ContainerConcreteStub::class); + + $this->assertSame($first, $container->make(ContainerConcreteStub::class)); + + $container->forgetScopedInstances(); + + $this->assertNotSame($first, $container->make(ContainerConcreteStub::class)); + } + public function testScopedClosureResets() { $container = new Container; @@ -329,6 +368,55 @@ public function testBindingAnInstanceAsShared() $this->assertSame($bound, $object); } + public function testNullInstanceIsAResolvedSharedBinding(): void + { + $container = new Container; + $container->instance('nullable', null); + + $this->assertTrue($container->bound('nullable')); + $this->assertTrue($container->has('nullable')); + $this->assertTrue($container->resolved('nullable')); + $this->assertTrue($container->isShared('nullable')); + $this->assertNull($container->make('nullable')); + $this->assertNull($container->get('nullable')); + } + + public function testNullSingletonValueIsCached(): void + { + $container = new Container; + $resolutions = 0; + + $container->singleton('nullable', function () use (&$resolutions) { + ++$resolutions; + + return null; + }); + + $this->assertNull($container->make('nullable')); + $this->assertNull($container->make('nullable')); + $this->assertSame(1, $resolutions); + } + + public function testNullInstanceCanBeExtended(): void + { + $container = new Container; + $container->instance('nullable', null); + + $container->extend('nullable', fn ($value) => $value ?? 'extended'); + + $this->assertSame('extended', $container->make('nullable')); + } + + public function testRegisteredNullInstanceOverridesObjectDefault(): void + { + $container = new Container; + $container->instance(ContainerObjectDefaultDependency::class, null); + + $resolved = $container->make(ContainerObjectDefaultConsumer::class); + + $this->assertNull($resolved->dependency); + } + public function testResolutionOfDefaultParameters() { $container = new Container; @@ -365,6 +453,61 @@ public function testResolutionOfClassWithDefaultParametersAndContextualBindings( $this->assertInstanceOf(ContainerConcreteStub::class, $instance->default); } + public function testObjectDefaultsAreFreshAcrossCachedBuildRecipes(): void + { + $container = new Container; + + $first = $container->build(ContainerObjectDefaultStub::class); + $second = $container->build(ContainerObjectDefaultStub::class); + + $this->assertNotSame($first->value, $second->value); + $this->assertNotSame($first->nested[0], $second->nested[0]); + } + + public function testScalarAndEnumDefaultsDoNotRetainReflectionParameters(): void + { + Container::flushState(); + + $container = new Container; + $container->build(ContainerCachedDefaultStub::class); + + $buildRecipes = new ReflectionProperty(Container::class, 'buildRecipes'); + $recipe = $buildRecipes->getValue()[ContainerCachedDefaultStub::class]; + + foreach ($recipe->parameters as $parameter) { + $this->assertNull($parameter->reflectionParameter); + $this->assertFalse($parameter->refreshDefault); + $this->assertSame('', $parameter->reflectionString); + } + } + + public function testConcreteVariadicDependencyIsPreservedAsOneArgument(): void + { + $container = new Container; + + $resolved = $container->make(ContainerVariadicDependencyConsumer::class); + + $this->assertCount(1, $resolved->dependencies); + $this->assertInstanceOf(ContainerConcreteStub::class, $resolved->dependencies[0]); + } + + public function testContextualVariadicDependenciesRemainAnArray(): void + { + $container = new Container; + $first = new ContainerConcreteStub; + $second = new ContainerConcreteStub; + $container->instance('first.variadic', $first); + $container->instance('second.variadic', $second); + + $container->when(ContainerVariadicDependencyConsumer::class) + ->needs(ContainerConcreteStub::class) + ->give(['first.variadic', 'second.variadic']); + + $resolved = $container->make(ContainerVariadicDependencyConsumer::class); + + $this->assertSame([$first, $second], $resolved->dependencies); + } + public function testBound() { $container = new Container; @@ -517,6 +660,50 @@ public function testForgetInstanceForgetsScopedInstance() $this->assertNotSame($first, $second); } + public function testForgettingTemporaryInstanceRestoresScopedLifecycle(): void + { + $container = new Container; + $container->scoped(ContainerConcreteStub::class); + $temporary = new ContainerConcreteStub; + + $container->instance(ContainerConcreteStub::class, $temporary); + + $this->assertSame($temporary, $container->make(ContainerConcreteStub::class)); + + $container->forgetInstance(ContainerConcreteStub::class); + + $restored = $container->make(ContainerConcreteStub::class); + + $this->assertNotSame($temporary, $restored); + $this->assertSame($restored, $container->make(ContainerConcreteStub::class)); + } + + public function testExplicitTransientBindingOverridesScopedAttribute(): void + { + $container = new Container; + $container->bind(ContainerScopedAttribute::class); + + $first = $container->make(ContainerScopedAttribute::class); + $second = $container->make(ContainerScopedAttribute::class); + + $this->assertFalse($container->isScoped(ContainerScopedAttribute::class)); + $this->assertFalse($container->isShared(ContainerScopedAttribute::class)); + $this->assertNotSame($first, $second); + } + + public function testExplicitSingletonBindingOverridesScopedAttribute(): void + { + $container = new Container; + $container->singleton(ContainerScopedAttribute::class); + + $first = $container->make(ContainerScopedAttribute::class); + + $container->forgetScopedInstances(); + + $this->assertFalse($container->isScoped(ContainerScopedAttribute::class)); + $this->assertSame($first, $container->make(ContainerScopedAttribute::class)); + } + public function testForgetInstancesForgetsAllInstances() { $container = new Container; @@ -566,20 +753,6 @@ public function testContainerFlushFlushesAllBindingsAliasesAndResolvedInstances( $this->assertFalse($container->isShared('ConcreteStub')); } - public function testFlushClearsReflectionCache(): void - { - $container = new Container; - $container->make(ContainerConcreteStub::class); - - $before = ReflectionManager::reflectClass(ContainerConcreteStub::class); - - $container->flush(); - - $after = ReflectionManager::reflectClass(ContainerConcreteStub::class); - - $this->assertNotSame($before, $after); - } - public function testFlushStateClearsBuildRecipeCache() { Container::flushState(); @@ -641,6 +814,28 @@ public function testOffsetUnsetClearsScopedInstance() $this->assertNotSame($first, $second); } + public function testOffsetUnsetClearsScopedLifecycleMarker(): void + { + $container = new Container; + $container->scoped(ContainerConcreteStub::class); + + unset($container[ContainerConcreteStub::class]); + + $this->assertFalse($container->isScoped(ContainerConcreteStub::class)); + } + + public function testExtendingResolvedAutoSingletonUpdatesCachedInstance(): void + { + $container = new Container; + $original = $container->make(AutoSingletonStub::class); + $replacement = new AutoSingletonStub; + + $container->extend(AutoSingletonStub::class, fn ($instance) => $replacement); + + $this->assertNotSame($original, $replacement); + $this->assertSame($replacement, $container->make(AutoSingletonStub::class)); + } + public function testResolvedResolvesAliasToBindingNameBeforeChecking() { $container = new Container; @@ -694,6 +889,35 @@ public function testGetAliasRecursive() $this->assertTrue($container->isAlias('foo')); } + public function testIndirectAliasCycleIsRejectedBeforeMutation(): void + { + $container = new Container; + $container->alias('service', 'first'); + $container->alias('first', 'second'); + + try { + $container->alias('second', 'service'); + $this->fail('Expected the alias cycle to be rejected.'); + } catch (LogicException $exception) { + $this->assertSame('Alias [service] would create a circular alias chain.', $exception->getMessage()); + } + + $this->assertSame('service', $container->getAlias('second')); + $this->assertFalse($container->isAlias('service')); + } + + public function testExistingAliasCycleIsRejectedWithoutLooping(): void + { + $container = new Container; + $aliases = new ReflectionProperty(Container::class, 'aliases'); + $aliases->setValue($container, ['first' => 'second', 'second' => 'first']); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Alias [third] would create a circular alias chain.'); + + $container->alias('first', 'third'); + } + public function testItThrowsExceptionWhenAbstractIsSameAsAlias() { $this->expectException('LogicException'); @@ -1178,6 +1402,27 @@ public function testCircularDependencyThroughContextualBindingIsDetected() $container->make(ContextualCircularA::class); } + public function testDepthLimitFailureDoesNotMutateResolutionState(): void + { + $container = new ContainerStateInspectionStub; + CoroutineContext::set(Container::DEPTH_CONTEXT_KEY, $container->resolutionLimit()); + + try { + $container->make(ContainerConcreteStub::class); + $this->fail('Expected the resolution depth limit to be enforced.'); + } catch (CircularDependencyException $exception) { + $this->assertStringContainsString('Maximum resolution depth', $exception->getMessage()); + } + + $this->assertSame($container->resolutionLimit(), CoroutineContext::get(Container::DEPTH_CONTEXT_KEY)); + $this->assertSame([], $container->resolvingStack()); + $this->assertSame([], $container->parameterOverrideStack()); + + CoroutineContext::set(Container::DEPTH_CONTEXT_KEY, 0); + + $this->assertInstanceOf(ContainerConcreteStub::class, $container->make(ContainerConcreteStub::class)); + } + // --- Auto-singleton behavior tests --- public function testAutoSingletonCachesUnboundConcreteClass() @@ -1649,3 +1894,70 @@ public static function newInstance(): self return new self($_SERVER['__selfBuilding.counter']); } } + +class ContainerObjectDefaultDependency +{ +} + +class ContainerObjectDefaultConsumer +{ + public function __construct( + public readonly ?ContainerObjectDefaultDependency $dependency = new ContainerObjectDefaultDependency, + ) { + } +} + +class ContainerObjectDefaultStub +{ + public function __construct( + public readonly stdClass $value = new stdClass, + public readonly array $nested = [new stdClass], + ) { + } +} + +enum ContainerCachedDefaultEnvironment +{ + case Production; +} + +class ContainerCachedDefaultStub +{ + public function __construct( + public readonly string $name = 'hypervel', + public readonly ContainerCachedDefaultEnvironment $environment = ContainerCachedDefaultEnvironment::Production, + public readonly array $options = ['enabled' => true], + ) { + } +} + +class ContainerVariadicDependencyConsumer +{ + /** + * @var ContainerConcreteStub[] + */ + public readonly array $dependencies; + + public function __construct(ContainerConcreteStub ...$dependencies) + { + $this->dependencies = $dependencies; + } +} + +class ContainerStateInspectionStub extends Container +{ + public function resolutionLimit(): int + { + return parent::MAX_RESOLUTION_DEPTH; + } + + public function resolvingStack(): array + { + return $this->getResolvingStack(); + } + + public function parameterOverrideStack(): array + { + return $this->getParameterOverrideStack(); + } +} diff --git a/tests/Container/ContextualAttributeBindingTest.php b/tests/Container/ContextualAttributeBindingTest.php index 91167e180..4bc28d3c4 100644 --- a/tests/Container/ContextualAttributeBindingTest.php +++ b/tests/Container/ContextualAttributeBindingTest.php @@ -33,10 +33,13 @@ use Hypervel\Filesystem\FilesystemManager; use Hypervel\Http\Request; use Hypervel\Log\Context\Repository as ContextRepository; +use Hypervel\Log\Logger as HypervelLogger; use Hypervel\Log\LogManager; use Hypervel\Testbench\TestCase; use Mockery as m; use Psr\Log\LoggerInterface; +use ReflectionParameter; +use RuntimeException; class ContextualAttributeBindingTest extends TestCase { @@ -147,6 +150,18 @@ public function testAuthedAttribute() return $guard; }); + $manager->shouldReceive('guard')->with(AuthGuardUnitEnum::unit)->andReturnUsing(function () { + $guard = m::mock(GuardContract::class); + $guard->shouldReceive('user')->andReturn(m::mock(AuthenticatableContract::class)); + + return $guard; + }); + $manager->shouldReceive('guard')->with(AuthGuardBackedEnum::Backed)->andReturnUsing(function () { + $guard = m::mock(GuardContract::class); + $guard->shouldReceive('user')->andReturn(m::mock(AuthenticatableContract::class)); + + return $guard; + }); return $manager; }); @@ -161,6 +176,10 @@ public function testCacheAttribute() $manager = m::mock(CacheManager::class); $manager->shouldReceive('store')->with('foo')->andReturn(m::mock(CacheRepository::class)); $manager->shouldReceive('store')->with('bar')->andReturn(m::mock(CacheRepository::class)); + $manager->shouldReceive('store')->with(CacheStoreUnitEnum::unit)->andReturn(m::mock(CacheRepository::class)); + $manager->shouldReceive('store')->with(CacheStoreBackedEnum::Backed)->andReturn(m::mock(CacheRepository::class)); + $manager->shouldReceive('memo')->with('foo')->andReturn(m::mock(CacheRepository::class)); + $manager->shouldReceive('memo')->with('bar')->andReturn(m::mock(CacheRepository::class)); return $manager; }); @@ -203,6 +222,8 @@ public function testAuthAttribute() $manager = m::mock(AuthManager::class); $manager->shouldReceive('guard')->with('foo')->andReturn(m::mock(GuardContract::class)); $manager->shouldReceive('guard')->with('bar')->andReturn(m::mock(GuardContract::class)); + $manager->shouldReceive('guard')->with(AuthGuardUnitEnum::unit)->andReturn(m::mock(GuardContract::class)); + $manager->shouldReceive('guard')->with(AuthGuardBackedEnum::Backed)->andReturn(m::mock(GuardContract::class)); return $manager; }); @@ -217,6 +238,16 @@ public function testLogAttribute() $manager = m::mock(LogManager::class); $manager->shouldReceive('channel')->with('foo')->andReturn(m::mock(LoggerInterface::class)); $manager->shouldReceive('channel')->with('bar')->andReturn(m::mock(LoggerInterface::class)); + $manager->shouldReceive('channel')->with('unit_channel')->andReturn(m::mock(LoggerInterface::class)); + $manager->shouldReceive('channel')->with('7')->andReturn(m::mock(LoggerInterface::class)); + + $named = m::mock(HypervelLogger::class); + $named->shouldReceive('withName')->with('tenant')->andReturn(m::mock(HypervelLogger::class)); + $manager->shouldReceive('channel')->with('named')->andReturn($named); + + $integerNamed = m::mock(HypervelLogger::class); + $integerNamed->shouldReceive('withName')->with('9')->andReturn(m::mock(HypervelLogger::class)); + $manager->shouldReceive('channel')->with('integer-named')->andReturn($integerNamed); return $manager; }); @@ -224,6 +255,21 @@ public function testLogAttribute() $container->make(LogTest::class); } + public function testNamedLogAttributeRejectsCustomNonMonologDriver(): void + { + $container = $this->app; + $container->make('config')->set('logging.channels.custom', ['driver' => 'custom']); + + $manager = new LogManager($container); + $manager->extend('custom', fn () => m::mock(LoggerInterface::class)); + $container->instance('log', $manager); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Named loggers are only supported by Monolog drivers.'); + + $container->make(CustomNamedLogTest::class); + } + public function testRouteParameterAttribute() { $container = new Container; @@ -238,6 +284,20 @@ public function testRouteParameterAttribute() $container->make(RouteParameterTest::class); } + public function testRouteParameterAttributeWithoutParameterName(): void + { + $container = new Container; + $container->singleton('request', function () { + $request = m::mock(Request::class); + $request->shouldReceive('route')->with('foo')->andReturn(m::mock(Model::class)); + $request->shouldReceive('route')->with('bar')->andReturn('bar'); + + return $request; + }); + + $container->make(RouteParameterTestWithoutParameterName::class); + } + public function testContextAttribute() { $container = new Container; @@ -267,6 +327,8 @@ public function testStorageAttribute() $manager = m::mock(FilesystemManager::class); $manager->shouldReceive('disk')->with('foo')->andReturn(m::mock(Filesystem::class)); $manager->shouldReceive('disk')->with('bar')->andReturn(m::mock(Filesystem::class)); + $manager->shouldReceive('disk')->with(StorageDiskUnitEnum::unit)->andReturn(m::mock(Filesystem::class)); + $manager->shouldReceive('disk')->with(StorageDiskBackedEnum::Backed)->andReturn(m::mock(Filesystem::class)); return $manager; }); @@ -344,6 +406,29 @@ public function testTagAttribute() $this->assertEquals([1, 2], iterator_to_array($value)); } + + public function testParameterIsPassedToContextualAttributeResolver(): void + { + $container = new Container; + + $value = $container->make(HasParameterAwareAttribute::class); + + $this->assertSame('name', $value->name); + } + + public function testParameterIsPassedToContextualAttributeResolverOnAppCall(): void + { + $container = new Container; + + $value = $container->call(function ( + #[ContainerTestParameterAwareAttribute] + ?string $name + ) { + return $name; + }); + + $this->assertSame('name', $value); + } } #[Attribute(Attribute::TARGET_PARAMETER)] @@ -355,6 +440,56 @@ public function __construct( } } +enum StorageDiskUnitEnum +{ + case unit; +} + +enum StorageDiskBackedEnum: string +{ + case Backed = 'backed'; +} + +enum AuthGuardUnitEnum +{ + case unit; +} + +enum AuthGuardBackedEnum: string +{ + case Backed = 'backed'; +} + +enum CacheStoreUnitEnum +{ + case unit; +} + +enum CacheStoreBackedEnum: string +{ + case Backed = 'backed'; +} + +enum LogChannelUnitEnum +{ + case unit_channel; +} + +enum LogChannelBackedEnum: int +{ + case Backed = 7; +} + +enum LogNameUnitEnum +{ + case tenant; +} + +enum LogNameBackedEnum: int +{ + case Backed = 9; +} + interface ContainerTestContract { } @@ -440,6 +575,15 @@ public function after(self $attribute, object $value, Container $container): voi } } +#[Attribute(Attribute::TARGET_PARAMETER)] +final class ContainerTestParameterAwareAttribute implements ContextualAttribute +{ + public function resolve(self $attribute, Container $container, ReflectionParameter $parameter): string + { + return $parameter->getName(); + } +} + final class ContainerTestHasConfigValueWithResolvePropertyAndAfterCallback { public function __construct( @@ -462,15 +606,35 @@ public function __construct(public bool $param) final class AuthedTest { - public function __construct(#[Authenticated('foo')] AuthenticatableContract $foo, #[CurrentUser('bar')] AuthenticatableContract $bar) - { + public function __construct( + #[Authenticated('foo')] + AuthenticatableContract $foo, + #[CurrentUser('bar')] + AuthenticatableContract $bar, + #[Authenticated(AuthGuardUnitEnum::unit)] + AuthenticatableContract $unit, + #[CurrentUser(AuthGuardBackedEnum::Backed)] + AuthenticatableContract $backed, + ) { } } final class CacheTest { - public function __construct(#[Cache('foo')] CacheRepository $foo, #[Cache('bar')] CacheRepository $bar) - { + public function __construct( + #[Cache('foo')] + CacheRepository $foo, + #[Cache('bar')] + CacheRepository $bar, + #[Cache(CacheStoreUnitEnum::unit)] + CacheRepository $unit, + #[Cache(CacheStoreBackedEnum::Backed)] + CacheRepository $backed, + #[Cache('foo', memo: true)] + CacheRepository $fooMemoized, + #[Cache('bar', memo: true)] + CacheRepository $barMemoized, + ) { } } @@ -504,14 +668,41 @@ public function __construct(#[Database('foo')] Connection $foo, #[Database('bar' final class GuardTest { - public function __construct(#[Auth('foo')] GuardContract $foo, #[Auth('bar')] GuardContract $bar) - { + public function __construct( + #[Auth('foo')] + GuardContract $foo, + #[Auth('bar')] + GuardContract $bar, + #[Auth(AuthGuardUnitEnum::unit)] + GuardContract $unit, + #[Auth(AuthGuardBackedEnum::Backed)] + GuardContract $backed, + ) { } } final class LogTest { - public function __construct(#[Log('foo')] LoggerInterface $foo, #[Log('bar')] LoggerInterface $bar) + public function __construct( + #[Log('foo')] + LoggerInterface $foo, + #[Log('bar')] + LoggerInterface $bar, + #[Log(LogChannelUnitEnum::unit_channel)] + LoggerInterface $unit, + #[Log(LogChannelBackedEnum::Backed)] + LoggerInterface $backed, + #[Log('named', LogNameUnitEnum::tenant)] + LoggerInterface $named, + #[Log('integer-named', LogNameBackedEnum::Backed)] + LoggerInterface $integerNamed, + ) { + } +} + +final class CustomNamedLogTest +{ + public function __construct(#[Log('custom', 'tenant')] LoggerInterface $logger) { } } @@ -523,13 +714,28 @@ public function __construct(#[RouteParameter('foo')] Model $foo, #[RouteParamete } } -final class StorageTest +final class RouteParameterTestWithoutParameterName { - public function __construct(#[Storage('foo')] Filesystem $foo, #[Storage('bar')] Filesystem $bar) + public function __construct(#[RouteParameter] Model $foo, #[RouteParameter] string $bar) { } } +final class StorageTest +{ + public function __construct( + #[Storage('foo')] + Filesystem $foo, + #[Storage('bar')] + Filesystem $bar, + #[Storage(StorageDiskUnitEnum::unit)] + Filesystem $unit, + #[Storage(StorageDiskBackedEnum::Backed)] + Filesystem $backed, + ) { + } +} + final class GiveTestSimple { public function __construct( @@ -565,3 +771,12 @@ public function __construct( ) { } } + +final class HasParameterAwareAttribute +{ + public function __construct( + #[ContainerTestParameterAwareAttribute] + public readonly ?string $name, + ) { + } +} diff --git a/tests/Container/CoroutineSafetyTest.php b/tests/Container/CoroutineSafetyTest.php index a2f227eb4..5527f9a9e 100644 --- a/tests/Container/CoroutineSafetyTest.php +++ b/tests/Container/CoroutineSafetyTest.php @@ -5,9 +5,16 @@ namespace Hypervel\Tests\Container; use Hypervel\Container\Container; +use Hypervel\Container\SharedResolution; use Hypervel\Contracts\Container\BindingResolutionException; +use Hypervel\Contracts\Container\CircularDependencyException; use Hypervel\Tests\TestCase; use RuntimeException; +use stdClass; +use Swoole\Coroutine as SwooleCoroutine; +use Swoole\Coroutine\CanceledException; +use Swoole\Coroutine\Channel; +use Throwable; use function Hypervel\Coroutine\parallel; @@ -253,6 +260,307 @@ public function testExceptionDuringBuildDoesNotCorruptBuildStack(): void ); } } + + public function testConcurrentSingletonWaitsForResolvingCallbacks(): void + { + $container = new CoroutineInspectingContainer; + $callbackEntered = new Channel(1); + $releaseCallback = new Channel(1); + $waiterEntered = new Channel(1); + $container->waiterEntered = $waiterEntered; + $constructions = 0; + + $container->singleton('service', function () use (&$constructions) { + $service = new stdClass; + $service->status = 'constructed'; + ++$constructions; + + return $service; + }); + $container->resolving('service', function ($service) use ($callbackEntered, $releaseCallback): void { + $callbackEntered->push(true); + $releaseCallback->pop(); + $service->status = 'ready'; + }); + + $results = parallel([ + 'owner' => fn () => $container->make('service'), + 'waiter' => function () use ($callbackEntered, $container) { + $callbackEntered->pop(); + + return $container->make('service'); + }, + 'release' => function () use ($waiterEntered, $releaseCallback): bool { + $waiterObserved = $waiterEntered->pop(1); + $releaseCallback->push(true); + + return $waiterObserved; + }, + ]); + + $this->assertTrue($results['release']); + $this->assertSame(1, $constructions); + $this->assertSame($results['owner'], $results['waiter']); + $this->assertSame('ready', $results['owner']->status); + } + + public function testConcurrentAutoSingletonConstructionConverges(): void + { + $container = new CoroutineInspectingContainer; + $dependencyEntered = new Channel(1); + $releaseDependency = new Channel(2); + $waiterEntered = new Channel(1); + $container->waiterEntered = $waiterEntered; + + $container->bind(CoroutineCoordinatedDependency::class, function () use ($dependencyEntered, $releaseDependency) { + $dependencyEntered->push(true); + $releaseDependency->pop(); + + return new CoroutineCoordinatedDependency; + }); + CoroutineCoordinatedService::$constructions = 0; + + try { + $results = parallel([ + 'owner' => fn () => $container->make(CoroutineCoordinatedService::class), + 'waiter' => function () use ($dependencyEntered, $container) { + $dependencyEntered->pop(); + + return $container->make(CoroutineCoordinatedService::class); + }, + 'release' => function () use ($waiterEntered, $releaseDependency): bool { + $waiterObserved = $waiterEntered->pop(1); + $releaseDependency->push(true); + $releaseDependency->push(true); + + return $waiterObserved; + }, + ]); + $constructions = CoroutineCoordinatedService::$constructions; + } finally { + CoroutineCoordinatedService::$constructions = 0; + } + + $this->assertTrue($results['release']); + $this->assertSame(1, $constructions); + $this->assertSame($results['owner'], $results['waiter']); + } + + public function testConcurrentFailureFansOutAndAllowsRetry(): void + { + $container = new CoroutineInspectingContainer; + $callbackEntered = new Channel(1); + $releaseCallback = new Channel(1); + $waiterEntered = new Channel(1); + $container->waiterEntered = $waiterEntered; + $constructions = 0; + $shouldFail = true; + + $container->singleton('service', function () use (&$constructions) { + $service = new stdClass; + $service->construction = ++$constructions; + + return $service; + }); + $container->resolving('service', function () use (&$shouldFail, $callbackEntered, $releaseCallback): void { + if (! $shouldFail) { + return; + } + + $callbackEntered->push(true); + $releaseCallback->pop(); + + throw new RuntimeException('callback failed'); + }); + + $results = parallel([ + 'owner' => function () use ($container) { + try { + return $container->make('service'); + } catch (RuntimeException $exception) { + return $exception; + } + }, + 'waiter' => function () use ($callbackEntered, $container) { + $callbackEntered->pop(); + + try { + return $container->make('service'); + } catch (RuntimeException $exception) { + return $exception; + } + }, + 'release' => function () use ($waiterEntered, $releaseCallback): bool { + $waiterObserved = $waiterEntered->pop(1); + $releaseCallback->push(true); + + return $waiterObserved; + }, + ]); + + $this->assertTrue($results['release']); + $this->assertInstanceOf(RuntimeException::class, $results['owner']); + $this->assertSame($results['owner'], $results['waiter']); + $this->assertSame(1, $constructions); + + $shouldFail = false; + $retried = $container->make('service'); + + $this->assertSame(2, $retried->construction); + $this->assertSame($retried, $container->make('service')); + } + + public function testDescendantCannotWaitOnAncestorOwnedResolution(): void + { + $container = new Container; + $descendantResult = new Channel(1); + + $container->singleton('service', function () use ($container, $descendantResult) { + $childId = SwooleCoroutine::create(function () use ($container, $descendantResult): void { + try { + $container->make('service'); + } catch (CanceledException) { + return; + } catch (Throwable $exception) { + $descendantResult->push($exception); + } + }); + + try { + $exception = $descendantResult->pop(1); + + if ($exception === false) { + $this->fail('The descendant remained blocked on its ancestor-owned resolution.'); + } + + $this->assertInstanceOf(CircularDependencyException::class, $exception); + } finally { + if (SwooleCoroutine::exists($childId)) { + SwooleCoroutine::cancel($childId, true); + } + } + + return new stdClass; + }); + + $this->assertInstanceOf(stdClass::class, $container->make('service')); + } + + public function testCoordinatorWaitCycleIsRejected(): void + { + $container = new Container; + $firstEntered = new Channel(1); + $secondEntered = new Channel(1); + $resultChannel = new Channel(2); + + $container->singleton('first', function () use ($container, $firstEntered, $secondEntered) { + $firstEntered->push(true); + $secondEntered->pop(); + + return $container->make('second'); + }); + $container->singleton('second', function () use ($container, $firstEntered, $secondEntered) { + $secondEntered->push(true); + $firstEntered->pop(); + + return $container->make('first'); + }); + + $firstId = SwooleCoroutine::create( + function () use ($container, $resultChannel): void { + try { + $result = $container->make('first'); + } catch (CanceledException) { + return; + } catch (Throwable $exception) { + $result = $exception; + } + + $resultChannel->push(['first', $result]); + }, + ); + $secondId = SwooleCoroutine::create( + function () use ($container, $resultChannel): void { + try { + $result = $container->make('second'); + } catch (CanceledException) { + return; + } catch (Throwable $exception) { + $result = $exception; + } + + $resultChannel->push(['second', $result]); + }, + ); + + try { + $firstResult = $resultChannel->pop(1); + $secondResult = $resultChannel->pop(1); + } finally { + foreach ([$firstId, $secondId] as $childId) { + if (SwooleCoroutine::exists($childId)) { + SwooleCoroutine::cancel($childId, true); + } + } + } + + $this->assertIsArray($firstResult, 'The first coordinator result timed out.'); + $this->assertIsArray($secondResult, 'The second coordinator result timed out.'); + + $results = [ + $firstResult[0] => $firstResult[1], + $secondResult[0] => $secondResult[1], + ]; + + $this->assertInstanceOf(CircularDependencyException::class, $results['first']); + $this->assertSame($results['first'], $results['second']); + } + + public function testCanceledWaiterRemovesCoordinatorEdge(): void + { + $container = new CoroutineInspectingContainer; + $ownerEntered = new Channel(1); + $releaseOwner = new Channel(1); + $ownerFinished = new Channel(1); + $waiterFinished = new Channel(1); + + $container->singleton('service', function () use ($ownerEntered, $releaseOwner) { + $ownerEntered->push(true); + $releaseOwner->pop(); + + return new stdClass; + }); + + SwooleCoroutine::create(function () use ($container, $ownerFinished): void { + $ownerFinished->push($container->make('service')); + }); + $ownerEntered->pop(); + + $waiterId = SwooleCoroutine::create(function () use ($container, $waiterFinished): void { + try { + $container->make('service'); + } catch (CanceledException $exception) { + $waiterFinished->push($exception); + } + }); + + try { + $deadline = microtime(true) + 1; + + while ($container->waitCount() !== 1 && microtime(true) < $deadline) { + usleep(100); + } + + $this->assertSame(1, $container->waitCount()); + $this->assertTrue(SwooleCoroutine::cancel($waiterId, true)); + $this->assertInstanceOf(CanceledException::class, $waiterFinished->pop(1)); + $this->assertSame(0, $container->waitCount()); + } finally { + $releaseOwner->push(true); + } + + $this->assertInstanceOf(stdClass::class, $ownerFinished->pop(1)); + } } // --- Stub classes for coroutine safety tests --- @@ -346,3 +654,34 @@ public function __construct(string $unresolvable) { } } + +class CoroutineInspectingContainer extends Container +{ + public ?Channel $waiterEntered = null; + + protected function awaitSharedResolution(string $abstract, SharedResolution $resolution): mixed + { + $this->waiterEntered?->push(true); + + return parent::awaitSharedResolution($abstract, $resolution); + } + + public function waitCount(): int + { + return count($this->sharedResolutionWaits); + } +} + +class CoroutineCoordinatedDependency +{ +} + +class CoroutineCoordinatedService +{ + public static int $constructions = 0; + + public function __construct(public readonly CoroutineCoordinatedDependency $dependency) + { + ++self::$constructions; + } +} diff --git a/tests/Container/ReflectionManagerTest.php b/tests/Container/ReflectionManagerTest.php deleted file mode 100644 index 7d6ff6707..000000000 --- a/tests/Container/ReflectionManagerTest.php +++ /dev/null @@ -1,109 +0,0 @@ -assertSame($first, $second); - } - - public function testReflectMethodReturnsSameInstanceOnRepeatedCalls(): void - { - $first = ReflectionManager::reflectMethod(ReflectionManagerTestStub::class, 'greet'); - $second = ReflectionManager::reflectMethod(ReflectionManagerTestStub::class, 'greet'); - - $this->assertSame($first, $second); - } - - public function testReflectPropertyReturnsSameInstanceOnRepeatedCalls(): void - { - $first = ReflectionManager::reflectProperty(ReflectionManagerTestStub::class, 'name'); - $second = ReflectionManager::reflectProperty(ReflectionManagerTestStub::class, 'name'); - - $this->assertSame($first, $second); - } - - public function testFlushStateResetsClassCache(): void - { - $before = ReflectionManager::reflectClass(ReflectionManagerTestStub::class); - - ReflectionManager::flushState(); - - $after = ReflectionManager::reflectClass(ReflectionManagerTestStub::class); - - $this->assertNotSame($before, $after); - } - - public function testFlushStateResetsMethodCache(): void - { - $before = ReflectionManager::reflectMethod(ReflectionManagerTestStub::class, 'greet'); - - ReflectionManager::flushState(); - - $after = ReflectionManager::reflectMethod(ReflectionManagerTestStub::class, 'greet'); - - $this->assertNotSame($before, $after); - } - - public function testFlushStateResetsPropertyCache(): void - { - $before = ReflectionManager::reflectProperty(ReflectionManagerTestStub::class, 'name'); - - ReflectionManager::flushState(); - - $after = ReflectionManager::reflectProperty(ReflectionManagerTestStub::class, 'name'); - - $this->assertNotSame($before, $after); - } - - public function testNonExistentClassThrowsReflectionException(): void - { - $this->expectException(ReflectionException::class); - - ReflectionManager::reflectClass('NonExistentClass'); - } - - public function testNonExistentMethodThrowsReflectionException(): void - { - $this->expectException(ReflectionException::class); - - ReflectionManager::reflectMethod(ReflectionManagerTestStub::class, 'nonExistentMethod'); - } - - public function testNonExistentPropertyThrowsReflectionException(): void - { - $this->expectException(ReflectionException::class); - - ReflectionManager::reflectProperty(ReflectionManagerTestStub::class, 'nonExistentProperty'); - } - - public function testReflectMethodUsesCachedClassReflection(): void - { - $classReflection = ReflectionManager::reflectClass(ReflectionManagerTestStub::class); - $methodReflection = ReflectionManager::reflectMethod(ReflectionManagerTestStub::class, 'greet'); - - // The method's declaring class should be the same cached reflection - $this->assertSame($classReflection->getName(), $methodReflection->getDeclaringClass()->getName()); - } -} - -class ReflectionManagerTestStub -{ - public string $name = 'test'; - - public function greet(): string - { - return 'hello'; - } -} diff --git a/tests/Container/ResolvingCallbackNonCoroutineTest.php b/tests/Container/ResolvingCallbackNonCoroutineTest.php new file mode 100644 index 000000000..740395215 --- /dev/null +++ b/tests/Container/ResolvingCallbackNonCoroutineTest.php @@ -0,0 +1,48 @@ +singleton('service', function () use (&$constructions) { + $service = new stdClass; + $service->construction = ++$constructions; + + return $service; + }); + $container->resolving('service', function () use (&$shouldFail): void { + if ($shouldFail) { + $shouldFail = false; + + throw new RuntimeException('callback failed'); + } + }); + + try { + $container->make('service'); + $this->fail('Expected the resolving callback to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('callback failed', $exception->getMessage()); + } + + $resolved = $container->make('service'); + + $this->assertSame(2, $resolved->construction); + $this->assertSame($resolved, $container->make('service')); + } +} diff --git a/tests/Container/ResolvingCallbackTest.php b/tests/Container/ResolvingCallbackTest.php index eba7e177c..c7d903ae3 100644 --- a/tests/Container/ResolvingCallbackTest.php +++ b/tests/Container/ResolvingCallbackTest.php @@ -5,7 +5,9 @@ namespace Hypervel\Tests\Container; use Hypervel\Container\Container; +use Hypervel\Contracts\Container\CircularDependencyException; use Hypervel\Tests\TestCase; +use RuntimeException; use stdClass; class ResolvingCallbackTest extends TestCase @@ -579,6 +581,123 @@ public function testCallbacksFireInDeterministicOrderAcrossBuckets() 'concrete.after', ], $order); } + + public function testScopedCallbackFailureRollsBackPublishedValue(): void + { + $container = new Container; + $constructions = 0; + $shouldFail = true; + + $container->scoped('service', function () use (&$constructions) { + $service = new stdClass; + $service->construction = ++$constructions; + + return $service; + }); + $container->resolving('service', function () use (&$shouldFail): void { + if ($shouldFail) { + $shouldFail = false; + + throw new RuntimeException('callback failed'); + } + }); + + try { + $container->make('service'); + $this->fail('Expected the resolving callback to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('callback failed', $exception->getMessage()); + } + + $resolved = $container->make('service'); + + $this->assertSame(2, $resolved->construction); + $this->assertSame($resolved, $container->make('service')); + } + + public function testCircularCallbackFailureRollsBackPublishedValue(): void + { + $container = new Container; + $constructions = 0; + + $container->singleton('service', function () use (&$constructions) { + $service = new stdClass; + $service->construction = ++$constructions; + + return $service; + }); + $container->resolving('service', fn () => $container->make(ResolvingCircularA::class)); + + try { + $container->make('service'); + $this->fail('Expected the resolving callback to expose a circular dependency.'); + } catch (CircularDependencyException $exception) { + $this->assertSame([ + ResolvingCircularA::class, + ResolvingCircularB::class, + ResolvingCircularA::class, + ], $exception->getDependencyChain()); + } + + try { + $container->make('service'); + } catch (CircularDependencyException) { + // A second construction proves the first failed value was not retained. + } + + $this->assertSame(2, $constructions); + } + + public function testAutoSingletonCallbackFailureRollsBackPublishedValue(): void + { + $container = new Container; + $shouldFail = true; + ResolvingAutoSingletonStub::$constructions = 0; + + try { + $container->resolving(ResolvingAutoSingletonStub::class, function () use (&$shouldFail): void { + if ($shouldFail) { + $shouldFail = false; + + throw new RuntimeException('callback failed'); + } + }); + + try { + $container->make(ResolvingAutoSingletonStub::class); + $this->fail('Expected the resolving callback to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('callback failed', $exception->getMessage()); + } + + $resolved = $container->make(ResolvingAutoSingletonStub::class); + + $this->assertSame(2, ResolvingAutoSingletonStub::$constructions); + $this->assertSame($resolved, $container->make(ResolvingAutoSingletonStub::class)); + } finally { + ResolvingAutoSingletonStub::$constructions = 0; + } + } + + public function testExplicitReplacementSurvivesLaterCallbackFailure(): void + { + $container = new Container; + $replacement = new stdClass; + $container->singleton('service', fn () => new stdClass); + $container->resolving('service', function () use ($container, $replacement): void { + $container->instance('service', $replacement); + }); + $container->resolving('service', fn () => throw new RuntimeException('callback failed')); + + try { + $container->make('service'); + $this->fail('Expected the resolving callback to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('callback failed', $exception->getMessage()); + } + + $this->assertSame($replacement, $container->make('service')); + } } interface ResolvingContractStub @@ -592,3 +711,27 @@ class ResolvingImplementationStub implements ResolvingContractStub class ResolvingImplementationStubTwo implements ResolvingContractStub { } + +class ResolvingAutoSingletonStub +{ + public static int $constructions = 0; + + public function __construct() + { + ++self::$constructions; + } +} + +class ResolvingCircularA +{ + public function __construct(ResolvingCircularB $dependency) + { + } +} + +class ResolvingCircularB +{ + public function __construct(ResolvingCircularA $dependency) + { + } +} diff --git a/tests/Foundation/Bootstrap/LoadConfigurationTest.php b/tests/Foundation/Bootstrap/LoadConfigurationTest.php index 5cc86559b..01b7a4ae3 100644 --- a/tests/Foundation/Bootstrap/LoadConfigurationTest.php +++ b/tests/Foundation/Bootstrap/LoadConfigurationTest.php @@ -5,11 +5,15 @@ namespace Hypervel\Tests\Foundation\Bootstrap; use Closure; +use Hypervel\Config\Repository; +use Hypervel\Contracts\Config\Repository as RepositoryContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Application; use Hypervel\Foundation\Bootstrap\LoadConfiguration; -use PHPUnit\Framework\TestCase; +use Hypervel\Tests\TestCase; use ReflectionClass; +use RuntimeException; class LoadConfigurationTest extends TestCase { @@ -133,4 +137,37 @@ public function testAppConfigOverridesBaseConfigValues() // Base configs should still be loaded for keys not in the app config dir $this->assertNotNull($app['config']['auth']); } + + public function testFailedReloadRestoresThePreviousRepositoryAndException(): void + { + $app = new Application; + (new LoadConfiguration)->bootstrap($app); + + $originalConfig = $app->make(Repository::class); + $originalConfig->set('app.name', 'Original Hypervel'); + $exception = new RuntimeException('Configuration failed.'); + + try { + (new FailingLoadConfiguration($exception))->bootstrap($app); + + $this->fail('The configuration bootstrap did not fail.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame($originalConfig, $app->make(Repository::class)); + $this->assertSame('Original Hypervel', $originalConfig->get('app.name')); + } +} + +class FailingLoadConfiguration extends LoadConfiguration +{ + public function __construct(protected RuntimeException $exception) + { + } + + protected function loadConfigurationFiles(ApplicationContract $app, RepositoryContract $repository): void + { + throw $this->exception; + } } diff --git a/tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php b/tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php index 4b369578d..1097cf955 100644 --- a/tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php +++ b/tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php @@ -27,14 +27,12 @@ protected function setUp(): void DotenvManager::flushState(); Env::flushState(); - ReloadDotenvAndConfig::flushState(); } protected function tearDown(): void { DotenvManager::flushState(); Env::flushState(); - ReloadDotenvAndConfig::flushState(); $this->restoreAppName(); parent::tearDown(); @@ -52,7 +50,7 @@ public function testReloadsUsingApplicationEnvironmentFile() $app->loadEnvironmentFrom('.env.testing'); $event = m::mock(BeforeWorkerStart::class); - $listener = new ReloadDotenvAndConfig($app); + $listener = $app->make(ReloadDotenvAndConfig::class); $listener->handle($event); // After reload, values should come from .env.testing. @@ -70,31 +68,87 @@ public function testSkipsReloadWhenEnvironmentFileDoesNotExist() $this->assertSame('Hypervel', Env::get('APP_NAME')); $event = m::mock(BeforeWorkerStart::class); - $listener = new ReloadDotenvAndConfig($app); + $listener = $app->make(ReloadDotenvAndConfig::class); $listener->handle($event); // Values should still be from the original .env since reload was skipped. $this->assertSame('Hypervel', Env::get('APP_NAME')); } - public function testReloadConfigRebuildsARealRepositoryAndReplaysRuntimeMutations() + public function testReloadPreservesRepositoryIdentityAndMutationsMadeBeforeListenerResolution(): void { $app = $this->createApp(); - $listener = new ReloadDotenvAndConfig($app); $originalConfig = $app->make(Repository::class); $originalConfig->set('app.name', 'Reloaded Hypervel'); - $listener->handle(m::mock(BeforeWorkerStart::class)); + $app->make(ReloadDotenvAndConfig::class)->handle(m::mock(BeforeWorkerStart::class)); $reloadedConfig = $app->make(Repository::class); $this->assertInstanceOf(Repository::class, $reloadedConfig); $this->assertNotInstanceOf(ConfigFacade::class, $reloadedConfig); - $this->assertNotSame($originalConfig, $reloadedConfig); + $this->assertSame($originalConfig, $reloadedConfig); $this->assertSame('Reloaded Hypervel', $reloadedConfig->get('app.name')); } + public function testReloadReplaysOverlappingMutationsInTheirOriginalOrder(): void + { + $app = $this->createApp(); + $config = $app->make(Repository::class); + + $config->set('app', ['name' => 'First']); + $config->set('app.name', 'Second'); + $config->set('app', ['name' => 'Last']); + + $app->make(ReloadDotenvAndConfig::class)->handle(m::mock(BeforeWorkerStart::class)); + + $this->assertSame(['name' => 'Last'], $config->get('app')); + } + + public function testReloadPreservesUntouchedSiblingsWhenReplayingAChildMutation(): void + { + $app = $this->createApp(); + $config = $app->make(Repository::class); + $environment = $config->get('app.env'); + + $config->set('app.name', 'Reloaded Hypervel'); + + $app->make(ReloadDotenvAndConfig::class)->handle(m::mock(BeforeWorkerStart::class)); + + $this->assertSame('Reloaded Hypervel', $config->get('app.name')); + $this->assertSame($environment, $config->get('app.env')); + } + + public function testTrackerSealsAfterReplayAndDoesNotRecordLaterMutations(): void + { + $app = $this->createApp(); + $config = $app->make(Repository::class); + $listener = $app->make(ReloadDotenvAndConfig::class); + + $config->set('app.name', 'Boot Mutation'); + $listener->handle(m::mock(BeforeWorkerStart::class)); + + $config->set('app.name', 'Post Start Mutation'); + $listener->handle(m::mock(BeforeWorkerStart::class)); + + $this->assertSame('Boot Mutation', $config->get('app.name')); + } + + public function testTrackerSealsWhenThereAreNoBootMutations(): void + { + $app = $this->createApp(); + $config = $app->make(Repository::class); + $listener = $app->make(ReloadDotenvAndConfig::class); + $originalName = $config->get('app.name'); + + $listener->handle(m::mock(BeforeWorkerStart::class)); + $config->set('app.name', 'Post Start Mutation'); + $listener->handle(m::mock(BeforeWorkerStart::class)); + + $this->assertSame($originalName, $config->get('app.name')); + } + protected function createApp(): Application { $app = new Application(__DIR__ . '/../Fixtures/envs'); diff --git a/tests/Integration/Console/CallbackSchedulingTest.php b/tests/Integration/Console/CallbackSchedulingTest.php index 994c286ab..0a7210981 100644 --- a/tests/Integration/Console/CallbackSchedulingTest.php +++ b/tests/Integration/Console/CallbackSchedulingTest.php @@ -18,6 +18,7 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Testbench\TestCase; use RuntimeException; +use UnitEnum; class CallbackSchedulingTest extends TestCase { @@ -35,7 +36,7 @@ public function __construct() $this->store = new Repository(new ArrayStore(true)); } - public function store(?string $name = null): Repository + public function store(UnitEnum|string|null $name = null): Repository { return $this->store; } diff --git a/tests/Integration/Console/CommandSchedulingTest.php b/tests/Integration/Console/CommandSchedulingTest.php index 605c2000e..cf27a60a6 100644 --- a/tests/Integration/Console/CommandSchedulingTest.php +++ b/tests/Integration/Console/CommandSchedulingTest.php @@ -17,6 +17,7 @@ use Hypervel\Support\Carbon; use Hypervel\Support\Facades\Artisan; use Hypervel\Testbench\TestCase; +use UnitEnum; class CommandSchedulingTest extends TestCase { @@ -37,7 +38,7 @@ public function __construct() $this->store = new Repository(new WorkerArrayStore(true)); } - public function store(?string $name = null): Repository + public function store(UnitEnum|string|null $name = null): Repository { return $this->store; } diff --git a/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php b/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php index a6492658a..0f40487cc 100644 --- a/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php +++ b/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php @@ -18,6 +18,7 @@ use Hypervel\Support\Sleep; use Hypervel\Testbench\TestCase; use PHPUnit\Framework\Attributes\DataProvider; +use UnitEnum; class SubMinuteSchedulingTest extends TestCase { @@ -40,7 +41,7 @@ public function __construct() $this->store = new Repository(new WorkerArrayStore(true)); } - public function store(?string $name = null): Repository + public function store(UnitEnum|string|null $name = null): Repository { return $this->store; } diff --git a/tests/Integration/Reverb/server.php b/tests/Integration/Reverb/server.php index 02c559e27..5acac262a 100644 --- a/tests/Integration/Reverb/server.php +++ b/tests/Integration/Reverb/server.php @@ -22,7 +22,6 @@ */ use Hypervel\Engine\Coroutine; -use Hypervel\Foundation\Listeners\ReloadDotenvAndConfig; use Hypervel\Reverb\Contracts\ApplicationProvider; use Hypervel\Reverb\ReverbServiceProvider; use Hypervel\Reverb\Servers\Hypervel\ReverbRouter; @@ -61,8 +60,8 @@ $label = $scaling ? 'Reverb Redis test server' : ($workerNum > 1 ? "Reverb multi-worker test server ({$workerNum} workers)" : 'Reverb test server'); // Set Reverb app env vars BEFORE boot so the config file picks them up. -// These survive the worker-start config reload (ReloadDotenvAndConfig) -// because the reload re-reads env vars and rebuilds config from disk. +// These survive the worker-start config reload because the reload re-reads +// environment values while rebuilding configuration from disk. // Only set defaults — don't override values already set by the caller. $defaults = [ 'REVERB_APP_KEY' => 'reverb-key', @@ -89,12 +88,6 @@ // Boot a fully bootstrapped Hypervel app with Reverb enabled. $app = TestbenchApplication::create( resolvingCallback: function ($app) use ($workerNum) { - // Resolve ReloadDotenvAndConfig to register the config tracking callback - // BEFORE any config()->set() calls. This ensures runtime config mutations - // (like the server config below) are tracked and replayed when the worker - // starts and rebuilds config from disk. - $app->make(ReloadDotenvAndConfig::class); - // Clear the default HTTP server entry — the test server only needs the // Reverb WebSocket server. Must happen before the provider registers so // registerWebSocketServer() appends to an empty array. @@ -118,7 +111,8 @@ } // Add additional test apps (env vars only support one app). - // These mutations are tracked by ReloadDotenvAndConfig and survive worker restart. + // Boot-time configuration mutations are tracked automatically and survive + // the worker-start configuration rebuild. $app->make('config')->set('reverb.apps.apps.1', [ 'key' => 'reverb-key-2', 'secret' => 'reverb-secret-2', diff --git a/tests/Log/LogLoggerTest.php b/tests/Log/LogLoggerTest.php index ac99e38d3..3f47be5ef 100644 --- a/tests/Log/LogLoggerTest.php +++ b/tests/Log/LogLoggerTest.php @@ -16,6 +16,7 @@ use Monolog\Handler\TestHandler; use Monolog\Level; use Monolog\Logger as Monolog; +use Psr\Log\LoggerInterface; use RuntimeException; class LogLoggerTest extends TestCase @@ -175,6 +176,51 @@ public function toArray(): array $this->assertTrue($handler->hasDebugRecords()); } + public function testNamedLoggerPreservesWrapperBehaviorAndSharesChannelContext(): void + { + $monolog = new Monolog('base'); + $handler = new TestHandler(Level::Debug); + $monolog->pushHandler($handler); + + $events = new Dispatcher; + $writer = new Logger($monolog, $events); + $writer->withContext(['request_id' => 'request-1']); + + $named = $writer->withName('tenant'); + + $this->assertNotSame($writer, $named); + $this->assertNotSame($writer->getLogger(), $named->getLogger()); + $this->assertSame('base', $monolog->getName()); + $this->assertSame('tenant', $named->getLogger()->getName()); + $this->assertSame($events, $named->getEventDispatcher()); + $this->assertSame(['request_id' => 'request-1'], $named->getContext()); + + $named->withContext(['tenant_id' => 42]); + + $this->assertSame(['request_id' => 'request-1', 'tenant_id' => 42], $writer->getContext()); + + $event = null; + $events->listen(MessageLogged::class, function (MessageLogged $message) use (&$event): void { + $event = $message; + }); + + $named->info('hello'); + + $this->assertSame('tenant', $handler->getRecords()[0]->channel); + $this->assertSame(['request_id' => 'request-1', 'tenant_id' => 42], $event->context); + + $other = new Logger(new Monolog('other')); + $this->assertSame([], $other->getContext()); + } + + public function testNamedLoggerRejectsNonMonologDrivers(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Named loggers are only supported by Monolog drivers.'); + + (new Logger(m::mock(LoggerInterface::class)))->withName('tenant'); + } + // -- Hypervel-specific tests -- public function testWithContext() diff --git a/tests/Log/LogManagerTest.php b/tests/Log/LogManagerTest.php index 5c7ae6e3f..5fe9ff502 100644 --- a/tests/Log/LogManagerTest.php +++ b/tests/Log/LogManagerTest.php @@ -807,6 +807,57 @@ public function testCustomDriverClosureBoundObjectIsLogManager() $this->assertSame($manager, $manager->channel(__CLASS__)->getLogger()); } + public function testLogManagerCanResolveBackedEnumChannel(): void + { + $manager = new LogManager($this->app); + + $logger1 = $manager->channel(LogChannelName::Single); + $logger2 = $manager->channel('single'); + + $this->assertSame($logger1, $logger2); + } + + public function testLogManagerCanResolveUnitEnumChannel(): void + { + $manager = new LogManager($this->app); + + $logger1 = $manager->channel(UnitLogChannelName::single); + $logger2 = $manager->channel('single'); + + $this->assertSame($logger1, $logger2); + } + + public function testLogManagerCanResolveZeroBackedEnumChannel(): void + { + $config = $this->app->make('config'); + $config->set('logging.channels.0', $config->get('logging.channels.single')); + + $manager = new LogManager($this->app); + + $logger1 = $manager->channel(NumericLogChannelName::Zero); + $logger2 = $manager->channel('0'); + + $this->assertSame($logger1, $logger2); + } + + public function testLogManagerCanResolveBackedEnumDriver(): void + { + $manager = new LogManager($this->app); + + $logger1 = $manager->driver(LogChannelName::Single); + $logger2 = $manager->driver('single'); + + $this->assertSame($logger1, $logger2); + } + + public function testSetDefaultDriverAcceptsBackedEnum(): void + { + $manager = new LogManager($this->app); + $manager->setDefaultDriver(LogChannelName::Single); + + $this->assertSame('single', $this->app->make('config')->get('logging.default')); + } + // -- Hypervel-specific tests -- public function testItSharesContextWithChannelsResolvedAfterSharing() @@ -880,3 +931,18 @@ public function log($level, Stringable|string $message, array $context = []): vo ]; } } + +enum LogChannelName: string +{ + case Single = 'single'; +} + +enum UnitLogChannelName +{ + case single; +} + +enum NumericLogChannelName: int +{ + case Zero = 0; +} diff --git a/tests/Routing/ReflectionParameterCachingTest.php b/tests/Routing/ReflectionParameterCachingTest.php index 1e6e8408e..20fe85a66 100644 --- a/tests/Routing/ReflectionParameterCachingTest.php +++ b/tests/Routing/ReflectionParameterCachingTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Routing\ReflectionParameterCachingTest; use Closure; +use Hypervel\Container\Attributes\RouteParameter; use Hypervel\Container\Container; use Hypervel\Contracts\Routing\Registrar; use Hypervel\Events\Dispatcher; @@ -161,6 +162,22 @@ public function testClosureDispatchCorrectlyResolvesParameters() $this->assertSame('dayle', $response->getContent()); } + public function testClosureDispatchPassesParameterMetadataToContextualAttributes(): void + { + $container = new Container; + $request = Request::create('foo/taylor', 'GET'); + $container->instance('request', $request); + + $router = $this->getRouter($container); + $router->get('foo/{name}', function (#[RouteParameter] string $name) { + return $name; + }); + + $response = $router->dispatch($request); + + $this->assertSame('taylor', $response->getContent()); + } + public function testClosureDispatchDoesNotReuseStaleParametersWhenClosureObjectIdIsReused() { $router = $this->getRouter(); diff --git a/tests/Support/ClassMetadataCacheTest.php b/tests/Support/ClassMetadataCacheTest.php index d71368a31..9c359455a 100644 --- a/tests/Support/ClassMetadataCacheTest.php +++ b/tests/Support/ClassMetadataCacheTest.php @@ -9,6 +9,7 @@ use Hypervel\Support\ClassMetadataCache; use Hypervel\Tests\TestCase; use ReflectionClass; +use ReflectionException; use ReflectionProperty; use RuntimeException; @@ -17,11 +18,33 @@ class ClassMetadataCacheTest extends TestCase public function testReflectClassReturnsCachedReflection(): void { $first = ClassMetadataCache::reflectClass(ClassMetadataCacheFixture::class); - $second = ClassMetadataCache::reflectClass(ClassMetadataCacheFixture::class); + $second = ClassMetadataCache::reflectClass(new ClassMetadataCacheFixture); $this->assertSame($first, $second); } + public function testReflectMethodReturnsCachedReflection(): void + { + $first = ClassMetadataCache::reflectMethod(ClassMetadataCacheFixture::class, 'greet'); + $second = ClassMetadataCache::reflectMethod(new ClassMetadataCacheFixture, 'greet'); + + $this->assertSame($first, $second); + } + + public function testReflectClassThrowsForMissingClass(): void + { + $this->expectException(ReflectionException::class); + + ClassMetadataCache::reflectClass('MissingClassMetadataCacheFixture'); + } + + public function testReflectMethodThrowsForMissingMethod(): void + { + $this->expectException(ReflectionException::class); + + ClassMetadataCache::reflectMethod(ClassMetadataCacheFixture::class, 'missingMethod'); + } + public function testDefaultPropertiesAreCached(): void { $this->assertSame(['name' => 'hypervel'], ClassMetadataCache::defaultProperties(ClassMetadataCacheFixture::class)); @@ -141,17 +164,22 @@ public function testPropertyAttributePresenceUsesDeclaringClassKey(): void public function testFlushStateClearsCachedMetadata(): void { - $before = ClassMetadataCache::reflectClass(ClassMetadataCacheFixture::class); + $classBefore = ClassMetadataCache::reflectClass(ClassMetadataCacheFixture::class); + $methodBefore = ClassMetadataCache::reflectMethod(ClassMetadataCacheFixture::class, 'greet'); ClassMetadataCache::getAttribute(ClassMetadataCacheAttributedFixture::class, ClassMetadataCacheAttribute::class); ClassMetadataCache::hasClassAttribute(ClassMetadataCacheParentFixture::class, ClassMetadataCacheAttribute::class); ClassMetadataCache::flushState(); - $after = ClassMetadataCache::reflectClass(ClassMetadataCacheFixture::class); - - $this->assertNotSame($before, $after); + $this->assertSame([], $this->staticProperty('methods')); $this->assertSame([], $this->staticProperty('attributes')); $this->assertSame([], $this->staticProperty('classAttributePresence')); + + $classAfter = ClassMetadataCache::reflectClass(ClassMetadataCacheFixture::class); + $methodAfter = ClassMetadataCache::reflectMethod(ClassMetadataCacheFixture::class, 'greet'); + + $this->assertNotSame($classBefore, $classAfter); + $this->assertNotSame($methodBefore, $methodAfter); } /** @@ -184,6 +212,11 @@ protected function propertyByName(array $properties, string $name): ReflectionPr class ClassMetadataCacheFixture { public string $name = 'hypervel'; + + public function greet(): string + { + return 'hello'; + } } #[ClassMetadataCacheAttribute('class')] diff --git a/tests/Support/ReflectionHelpersTest.php b/tests/Support/ReflectionHelpersTest.php new file mode 100644 index 000000000..7649774f9 --- /dev/null +++ b/tests/Support/ReflectionHelpersTest.php @@ -0,0 +1,522 @@ +__construct('foo', 'bar'); + }); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('bar', $instance->second); + } + + public function testLazyCanAcceptShortClosure(): void + { + $instance = lazy( + ReflectionHelperLazyClass::class, + fn (ReflectionHelperLazyClass $instance) => $instance->__construct('foo', 'bar') + ); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('bar', $instance->second); + } + + public function testLazyThrowsExceptionWhenConstructorIsNotCalled(): void + { + $instance = lazy(ReflectionHelperLazyClass::class, function (ReflectionHelperLazyClass $instance): void { + }); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + + $this->expectException(Error::class); + $this->expectExceptionMessage( + 'Typed property Hypervel\Tests\Support\ReflectionHelperLazyClass::$first must not be accessed before initialization' + ); + + $instance->first; + } + + public function testLazyCanAcceptHashForProperties(): void + { + $instance = lazy(ReflectionHelperLazyClass::class, fn (ReflectionHelperLazyClass $instance) => [ + 'second' => 'bar', + 'first' => 'foo', + ]); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('bar', $instance->second); + } + + public function testLazyCanAcceptListForProperties(): void + { + $instance = lazy( + ReflectionHelperLazyClass::class, + fn (ReflectionHelperLazyClass $instance) => ['foo', 'bar'] + ); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('bar', $instance->second); + } + + public function testLazyCanAcceptSingleValueForConstructor(): void + { + $instance = lazy( + ReflectionHelperLazyClassWithArrayParameter::class, + fn (ReflectionHelperLazyClassWithArrayParameter $instance) => [['foo']] + ); + + $this->assertFalse(ReflectionHelperLazyClassWithArrayParameter::$constructorCalled); + $this->assertSame(['foo'], $instance->first); + $this->assertTrue(ReflectionHelperLazyClassWithArrayParameter::$constructorCalled); + } + + public function testLazySupportsPositionAndNamedArguments(): void + { + $instance = lazy( + ReflectionHelperLazyClass::class, + fn (ReflectionHelperLazyClass $instance) => ['foo', 'second' => 'bar'] + ); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('bar', $instance->second); + } + + public function testLazyThrowsWhenPositionalArgumentsComeAfterNamedArguments(): void + { + $instance = lazy( + ReflectionHelperLazyClass::class, + fn (ReflectionHelperLazyClass $instance) => ['second' => 'bar', 'foo'] + ); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->expectException(Error::class); + $this->expectExceptionMessage('Cannot use positional argument after named argument during unpacking'); + + $instance->first; + } + + public function testLazyCanReturnInitializedObject(): void + { + $instance = lazy(ReflectionHelperLazyClass::class, function (ReflectionHelperLazyClass $instance) { + $instance->__construct('foo'); + + return $instance; + }); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertNull($instance->second); + } + + public function testLazyMustInitializeObject(): void + { + $instance = lazy( + ReflectionHelperLazyClass::class, + fn (ReflectionHelperLazyClass $instance) => new ReflectionHelperLazyClass('foo') + ); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->expectException(Error::class); + $this->expectExceptionMessage( + 'Typed property Hypervel\Tests\Support\ReflectionHelperLazyClass::$first must not be accessed before initialization' + ); + + $instance->first; + } + + public function testLazyCanEagerlySetProperties(): void + { + $instance = lazy( + ReflectionHelperLazyClass::class, + fn () => ['foo', 'bar'], + eager: ['eager' => 'baz'] + ); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('baz', $instance->eager); + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('baz', $instance->eager); + } + + public function testClosureOnlyLazy(): void + { + $instance = lazy(function (ReflectionHelperLazyClass $instance): void { + $instance->__construct('foo', 'bar'); + }); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('bar', $instance->second); + } + + public function testClosureOnlyLazyCanAcceptShortClosure(): void + { + $instance = lazy(fn (ReflectionHelperLazyClass $instance) => $instance->__construct('foo', 'bar')); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('bar', $instance->second); + } + + public function testClosureOnlyLazyThrowsExceptionWhenConstructorIsNotCalled(): void + { + $instance = lazy(function (ReflectionHelperLazyClass $instance): void { + }); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + + $this->expectException(Error::class); + $this->expectExceptionMessage( + 'Typed property Hypervel\Tests\Support\ReflectionHelperLazyClass::$first must not be accessed before initialization' + ); + + $instance->first; + } + + public function testClosureOnlyLazyThrowsWhenNoClassIsSpecifiedInClosure(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The first parameter of the given Closure is missing a type hint.'); + + lazy(function ($instance): void { + }); + } + + public function testClosureOnlyLazyCanAcceptHashForProperties(): void + { + $instance = lazy(fn (ReflectionHelperLazyClass $instance) => [ + 'second' => 'bar', + 'first' => 'foo', + ]); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('bar', $instance->second); + } + + public function testClosureOnlyLazyCanAcceptListForProperties(): void + { + $instance = lazy(fn (ReflectionHelperLazyClass $instance) => ['foo', 'bar']); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('bar', $instance->second); + } + + public function testClosureOnlyLazyCanAcceptSingleValueForConstructor(): void + { + $instance = lazy(fn (ReflectionHelperLazyClassWithArrayParameter $instance) => [['foo']]); + + $this->assertFalse(ReflectionHelperLazyClassWithArrayParameter::$constructorCalled); + $this->assertSame(['foo'], $instance->first); + $this->assertTrue(ReflectionHelperLazyClassWithArrayParameter::$constructorCalled); + } + + public function testClosureOnlyLazySupportsPositionAndNamedArguments(): void + { + $instance = lazy(fn (ReflectionHelperLazyClass $instance) => ['foo', 'second' => 'bar']); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('bar', $instance->second); + } + + public function testClosureOnlyLazyThrowsWhenPositionalArgumentsComeAfterNamedArguments(): void + { + $instance = lazy(fn (ReflectionHelperLazyClass $instance) => ['second' => 'bar', 'foo']); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->expectException(Error::class); + $this->expectExceptionMessage('Cannot use positional argument after named argument during unpacking'); + + $instance->first; + } + + public function testClosureOnlyLazyCanReturnInitializedObject(): void + { + $instance = lazy(function (ReflectionHelperLazyClass $instance) { + $instance->__construct('foo'); + + return $instance; + }); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertNull($instance->second); + } + + public function testClosureOnlyLazyMustInitializeObject(): void + { + $instance = lazy( + fn (ReflectionHelperLazyClass $instance) => new ReflectionHelperLazyClass('foo') + ); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->expectException(Error::class); + $this->expectExceptionMessage( + 'Typed property Hypervel\Tests\Support\ReflectionHelperLazyClass::$first must not be accessed before initialization' + ); + + $instance->first; + } + + public function testProxy(): void + { + $factory = fn () => new ReflectionHelperLazyClass('foo', 'bar'); + $instance = proxy( + ReflectionHelperLazyClass::class, + fn (ReflectionHelperLazyClass $proxy) => $factory() + ); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('bar', $instance->second); + } + + public function testProxyCanEagerlySetProperties(): void + { + $factory = fn () => new ReflectionHelperLazyClass('foo', 'bar'); + $instance = proxy( + ReflectionHelperLazyClass::class, + fn () => $factory(), + eager: ['eager' => 'baz'] + ); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('baz', $instance->eager); + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertFalse(isset($instance->eager)); + } + + public function testProxyCanCopyEagerPropertiesToActualObject(): void + { + $factory = fn () => new ReflectionHelperLazyClass('foo', 'bar'); + $instance = proxy(ReflectionHelperLazyClass::class, function ($proxy, array $eager) use ($factory) { + $instance = $factory(); + + foreach ($eager as $property => $value) { + $instance->{$property} = $value; + } + + return $instance; + }, eager: ['eager' => 'baz']); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('baz', $instance->eager); + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('baz', $instance->eager); + } + + public function testProxyCanAcceptShortClosure(): void + { + $factory = fn () => new ReflectionHelperLazyClass('foo', 'bar'); + $instance = proxy( + ReflectionHelperLazyClass::class, + fn (ReflectionHelperLazyClass $proxy) => $factory() + ); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('bar', $instance->second); + } + + public function testProxyThrowsExceptionWhenObjectIsNotReturned(): void + { + $instance = proxy(ReflectionHelperLazyClass::class, function (ReflectionHelperLazyClass $proxy): void { + }); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + + $this->expectException(Error::class); + $this->expectExceptionMessage( + 'Lazy proxy factory must return an instance of a class compatible with ' + . 'Hypervel\Tests\Support\ReflectionHelperLazyClass, null returned' + ); + + $instance->first; + } + + public function testProxyMustNotInitializeProxy(): void + { + $instance = proxy(ReflectionHelperLazyClass::class, function (ReflectionHelperLazyClass $proxy) { + $proxy->__construct('foo'); + + return $proxy; + }); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->expectException(Error::class); + $this->expectExceptionMessage('Lazy proxy factory must return a non-lazy object'); + + $instance->first; + } + + public function testClosureOnlyProxy(): void + { + $factory = fn () => new ReflectionHelperLazyClass('foo', 'bar'); + $instance = proxy(function (ReflectionHelperLazyClass $proxy) use ($factory) { + return $factory(); + }); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('bar', $instance->second); + } + + public function testClosureOnlyProxyCanAcceptShortClosure(): void + { + $factory = fn () => new ReflectionHelperLazyClass('foo', 'bar'); + $instance = proxy(fn (ReflectionHelperLazyClass $proxy) => $factory()); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('bar', $instance->second); + } + + public function testClosureOnlyProxyThrowsExceptionWhenObjectIsNotReturned(): void + { + $instance = proxy(function (ReflectionHelperLazyClass $proxy): void { + }); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + + $this->expectException(Error::class); + $this->expectExceptionMessage( + 'Lazy proxy factory must return an instance of a class compatible with ' + . 'Hypervel\Tests\Support\ReflectionHelperLazyClass, null returned' + ); + + $instance->first; + } + + public function testClosureOnlyProxyThrowsWhenNoClassIsSpecifiedInClosure(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The first parameter of the given Closure is missing a type hint.'); + + proxy(function ($proxy): void { + }); + } + + public function testClosureOnlyProxyMustNotInitializeProxy(): void + { + $instance = proxy(function (ReflectionHelperLazyClass $proxy) { + $proxy->__construct('foo'); + + return $proxy; + }); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->expectException(Error::class); + $this->expectExceptionMessage('Lazy proxy factory must return a non-lazy object'); + + $instance->first; + } + + public function testProxyCanUseClosureReturnTypeForClassDetection(): void + { + $factory = fn () => new ReflectionHelperLazyClass('foo', 'bar'); + $instance = proxy(fn (): ReflectionHelperLazyClass => $factory()); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('bar', $instance->second); + } + + public function testProxyFallsBackToParameterTypeForRelativeReturnType(): void + { + $instance = proxy(ReflectionHelperParentReturnFactory::make(...)); + + $this->assertFalse(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('foo', $instance->first); + $this->assertTrue(ReflectionHelperLazyClass::$constructorCalled); + $this->assertSame('bar', $instance->second); + } +} + +class ReflectionHelperLazyClass +{ + public static bool $constructorCalled = false; + + public string $eager; + + public function __construct( + public string $first, + public ?string $second = null, + ) { + self::$constructorCalled = true; + } +} + +class ReflectionHelperLazyClassWithArrayParameter +{ + public static bool $constructorCalled = false; + + public function __construct( + public array $first, + ) { + self::$constructorCalled = true; + } +} + +class ReflectionHelperParentReturnFactory extends ReflectionHelperLazyClass +{ + public static function make(ReflectionHelperLazyClass $proxy): parent + { + return new parent('foo', 'bar'); + } +} diff --git a/tests/Support/SupportReflectorTest.php b/tests/Support/SupportReflectorTest.php index b180f9e24..c507423e2 100644 --- a/tests/Support/SupportReflectorTest.php +++ b/tests/Support/SupportReflectorTest.php @@ -15,56 +15,56 @@ class SupportReflectorTest extends TestCase { - public function testGetClassName() + public function testGetClassName(): void { $method = (new ReflectionClass(PendingMailFake::class))->getMethod('send'); $this->assertSame(Mailable::class, Reflector::getParameterClassName($method->getParameters()[0])); } - public function testEmptyClassName() + public function testEmptyClassName(): void { $method = (new ReflectionClass(MailFake::class))->getMethod('assertSent'); $this->assertNull(Reflector::getParameterClassName($method->getParameters()[0])); } - public function testStringTypeName() + public function testStringTypeName(): void { $method = (new ReflectionClass(BusFake::class))->getMethod('dispatchedAfterResponse'); $this->assertNull(Reflector::getParameterClassName($method->getParameters()[0])); } - public function testSelfClassName() + public function testSelfClassName(): void { $method = (new ReflectionClass(Model::class))->getMethod('newPivot'); $this->assertSame(Model::class, Reflector::getParameterClassName($method->getParameters()[0])); } - public function testParentClassName() + public function testParentClassName(): void { $method = (new ReflectionClass(B::class))->getMethod('f'); $this->assertSame(A::class, Reflector::getParameterClassName($method->getParameters()[0])); } - public function testParameterSubclassOfInterface() + public function testParameterSubclassOfInterface(): void { $method = (new ReflectionClass(TestClassWithInterfaceSubclassParameter::class))->getMethod('f'); $this->assertTrue(Reflector::isParameterSubclassOf($method->getParameters()[0], IA::class)); } - public function testUnionTypeName() + public function testUnionTypeName(): void { $method = (new ReflectionClass(C::class))->getMethod('f'); $this->assertNull(Reflector::getParameterClassName($method->getParameters()[0])); } - public function testIsCallable() + public function testIsCallable(): void { $this->assertTrue(Reflector::isCallable(function () { })); @@ -78,7 +78,17 @@ public function testIsCallable() $this->assertTrue(Reflector::isCallable(['TotallyMissingClass', 'foo'], true)); } - public function testGetClassAttributes() + public function testIsCallableRejectsMalformedCallableArrays(): void + { + $this->assertFalse(Reflector::isCallable([true, 'f'])); + $this->assertFalse(Reflector::isCallable([123, 'f'])); + $this->assertFalse(Reflector::isCallable([[], 'f'])); + $this->assertFalse(Reflector::isCallable([true, 'f'], true)); + $this->assertFalse(Reflector::isCallable([B::class, 'f', 'extra'])); + $this->assertFalse(Reflector::isCallable(['class' => B::class, 'method' => 'f'])); + } + + public function testGetClassAttributes(): void { require_once __DIR__ . '/Fixtures/ClassesWithAttributes.php'; @@ -120,7 +130,7 @@ public function testGetClassAttributes() ); } - public function testGetClassAttribute() + public function testGetClassAttribute(): void { require_once __DIR__ . '/Fixtures/ClassesWithAttributes.php'; diff --git a/tests/Support/SupportReflectsClosuresTest.php b/tests/Support/SupportReflectsClosuresTest.php index 0de704909..92039b582 100644 --- a/tests/Support/SupportReflectsClosuresTest.php +++ b/tests/Support/SupportReflectsClosuresTest.php @@ -7,11 +7,12 @@ use Closure; use Hypervel\Support\Traits\ReflectsClosures; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; class SupportReflectsClosuresTest extends TestCase { - public function testReflectsClosures() + public function testReflectsClosures(): void { $this->assertParameterTypes([ExampleParameter::class], function (ExampleParameter $one) { // assert the Closure isn't actually executed @@ -35,7 +36,7 @@ public function testReflectsClosures() }); } - public function testItReturnsTheFirstParameterType() + public function testItReturnsTheFirstParameterType(): void { $type = ReflectsClosuresClass::reflectFirst(function (ExampleParameter $a) { }); @@ -43,7 +44,7 @@ public function testItReturnsTheFirstParameterType() $this->assertInstanceOf($type, new ExampleParameter); } - public function testItThrowsWhenNoParameters() + public function testItThrowsWhenNoParameters(): void { $this->expectException(RuntimeException::class); @@ -51,7 +52,7 @@ public function testItThrowsWhenNoParameters() }); } - public function testItThrowsWhenNoFirstParameterType() + public function testItThrowsWhenNoFirstParameterType(): void { $this->expectException(RuntimeException::class); @@ -59,7 +60,7 @@ public function testItThrowsWhenNoFirstParameterType() }); } - public function testItWorksWithUnionTypes() + public function testItWorksWithUnionTypes(): void { $types = ReflectsClosuresClass::reflectFirstAll(function (ExampleParameter $a, $b) { }); @@ -78,22 +79,51 @@ public function testItWorksWithUnionTypes() ], $types); } - public function testItWorksWithUnionTypesWithNoTypeHints() + public function testItWorksWithUnionTypesWithNoTypeHints(): void { $this->expectException(RuntimeException::class); - $types = ReflectsClosuresClass::reflectFirstAll(function ($a, $b) { + ReflectsClosuresClass::reflectFirstAll(function ($a, $b) { }); } - public function testItWorksWithUnionTypesWithNoArguments() + public function testItWorksWithUnionTypesWithNoArguments(): void { $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The given Closure has no parameters.'); - $types = ReflectsClosuresClass::reflectFirstAll(function () { + ReflectsClosuresClass::reflectFirstAll(function () { }); } + #[DataProvider('invalidFirstParameterProvider')] + public function testFirstParameterTypesRejectInvalidActualFirstParameter(Closure $closure): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The first parameter of the given Closure is missing a type hint.'); + + ReflectsClosuresClass::reflectFirstAll($closure); + } + + public static function invalidFirstParameterProvider(): array + { + return [ + 'untyped' => [function ($first, ExampleParameter $second) { + }], + 'builtin' => [function (string $first, ExampleParameter $second) { + }], + 'variadic' => [function (ExampleParameter ...$first) { + }], + ]; + } + + public function testClosureReturnTypesRejectRelativeClassNames(): void + { + $this->assertSame([], ReflectsClosuresClass::reflectReturns(RelativeReturnTypeClosures::returnsParent(...))); + $this->assertSame([], ReflectsClosuresClass::reflectReturns(RelativeReturnTypeClosures::returnsSelf(...))); + $this->assertSame([], ReflectsClosuresClass::reflectReturns(RelativeReturnTypeClosures::returnsStatic(...))); + } + private function assertParameterTypes(array $expected, Closure $closure): void { $types = ReflectsClosuresClass::reflect($closure); @@ -120,6 +150,11 @@ public static function reflectFirstAll(Closure $closure): array { return (new static)->firstClosureParameterTypes($closure); } + + public static function reflectReturns(Closure $closure): array + { + return (new static)->closureReturnTypes($closure); + } } class ExampleParameter @@ -129,3 +164,25 @@ class ExampleParameter class AnotherExampleParameter { } + +class RelativeReturnTypeParent +{ +} + +class RelativeReturnTypeClosures extends RelativeReturnTypeParent +{ + public static function returnsParent(): parent + { + return new parent; + } + + public static function returnsSelf(): self + { + return new self; + } + + public static function returnsStatic(): static + { + return new static; + } +} diff --git a/types/Reflection/helpers.php b/types/Reflection/helpers.php new file mode 100644 index 000000000..5d90254a3 --- /dev/null +++ b/types/Reflection/helpers.php @@ -0,0 +1,63 @@ + [] +)); +assertType('ReflectionTypeLazyClass', lazy(fn (ReflectionTypeLazyClass $instance) => [])); + +assertType('ReflectionTypeLazyClass', proxy( + ReflectionTypeLazyClass::class, + function (ReflectionTypeLazyClass $proxy, array $eager) { + assertType('ReflectionTypeLazyClass', $proxy); + assertType('array', $eager); + + return new ReflectionTypeLazyClass; + } +)); +assertType('ReflectionTypeLazyClass', proxy( + fn (ReflectionTypeLazyClass $proxy) => new ReflectionTypeLazyClass +)); +assertType('ReflectionTypeLazyClass', proxy(fn (): ReflectionTypeLazyClass => new ReflectionTypeLazyClass)); + +assertType( + 'ReflectionTypeAttribute|null', + Reflector::getClassAttribute(ReflectionTypeTarget::class, ReflectionTypeAttribute::class) +); +assertType( + 'Hypervel\Support\Collection', + Reflector::getClassAttributes(new ReflectionTypeTarget, ReflectionTypeAttribute::class) +); +assertType( + 'Hypervel\Support\Collection, Hypervel\Support\Collection>', + Reflector::getClassAttributes(ReflectionTypeTarget::class, ReflectionTypeAttribute::class, true) +); + +assertType('ReflectionClass', ClassMetadataCache::reflectClass(ReflectionTypeTarget::class)); +assertType('ReflectionClass', ClassMetadataCache::reflectClass(new ReflectionTypeTarget)); +assertType('ReflectionMethod', ClassMetadataCache::reflectMethod(ReflectionTypeTarget::class, 'method')); +assertType('ReflectionMethod', ClassMetadataCache::reflectMethod(new ReflectionTypeTarget, 'method')); + +class ReflectionTypeLazyClass +{ +} + +#[Attribute] +class ReflectionTypeAttribute +{ +} + +#[ReflectionTypeAttribute] +class ReflectionTypeTarget +{ + public function method(): void + { + } +}