diff --git a/.changelog/compose-provider-extensions.md b/.changelog/compose-provider-extensions.md new file mode 100644 index 0000000..c96826e --- /dev/null +++ b/.changelog/compose-provider-extensions.md @@ -0,0 +1,6 @@ +--- +category: fixed +type: patch +--- + +Fixes cross-provider extension composition by default in `container()`, `ContainerFactory::create()` and `ContainerBuilder::build()`. The first callable factory keeps precedence and all provider extensions run in registration order after selection, including earlier extension-only providers. Selected provider failures propagate instead of being masked by fallback. Explicit `AggregateServiceProvider` retains its last-factory precedence. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f8a032b..858af2b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -89,4 +89,4 @@ jobs: run: | CONTAINER_CONSUMER_AUTOLOAD="$CONSUMER_DIR/vendor/autoload.php" php "$HARNESS_DIR/vendor/bin/phpunit" \ --no-configuration --no-coverage --no-extensions \ - --bootstrap tests/Integration/bootstrap.php tests/Integration/ProductionConsumerTest.php + --bootstrap tests/Integration/bootstrap.php tests/Integration/ProductionConsumerTest.php tests/Integration/ProviderCompositionTest.php diff --git a/docs/advanced/autowire.rst b/docs/advanced/autowire.rst index 45ad305..746000e 100644 --- a/docs/advanced/autowire.rst +++ b/docs/advanced/autowire.rst @@ -79,7 +79,9 @@ with autowiring. Register sources before the first lookup: successful resolutions are cached. If a delegate's ``get()`` throws a PSR-11 exception, aggregate fallback may try -another source. If every source fails, construction errors are preserved; see +another source. Selected provider factory/extension failures in helper, factory +and builder composition propagate immediately instead. If every independent +external source fails, construction errors are preserved; see :doc:`special-patterns` for the full fallback rules. Optional Dependencies diff --git a/docs/advanced/container-helper.rst b/docs/advanced/container-helper.rst index ca0b4db..23047c8 100644 --- a/docs/advanced/container-helper.rst +++ b/docs/advanced/container-helper.rst @@ -58,6 +58,49 @@ share their own caches and services across compositions. The runnable ``examples/07-incremental-builder.php`` demonstrates conditional registration and the independence of previously built containers. +Cross-provider composition +-------------------------- + +The helper, factory and builder compose all registered provider extensions by +default. No opt-in or additional entry point is required: + +.. code-block:: php + + $helper = container($extensionProvider, $factoryProvider); + $factory = ContainerFactory::create($extensionProvider, $factoryProvider); + $builder = (new ContainerBuilder())->add($extensionProvider, $factoryProvider)->build(); + +Providers are collected before resolving an entry. The first callable factory for +an ID wins, preserving factory precedence. All provider extensions run in +registration order after factory selection, including extensions registered +before that factory. If no provider has a factory, extensions start with null. +Successful final values are cached, including null; void extensions retain their +input and other returns replace it. Aliases and exact concrete-class extensions +retain the provider adapter's identity and cycle rules. + +Direct and configuration-discovered providers share one build-local provider +adapter. ``AggregateContainer`` retains each source's position and inferred alias +origin as private state; ``ServiceProviderContainer`` owns definitions, extension +composition and caches. This preserves factory precedence against interleaved +PSR-11/Config sources without additional wrapper classes. +Extension-only defaults use the first extension position only when no provider has +a factory. External values remain independent and are not decorated by the group. Dual-role PSR-11/provider objects remain external PSR-11 sources. +Configured lists expand one level; invalid optional entries stop that list while +retaining prior entries and later explicit initializers. Class strings are +instantiated for each build. + +Once a provider factory or extension is selected, construction/extension failures +propagate with their causes. Later factories, external sources and native +autowiring do not replace a broken selected entry. Failed/incomplete chains are +not cached and may be retried. Absence permits later sources. External PSR-11 +sources retain aggregate fallback semantics. + +Each build gets fresh adapters, provider caches and a completed aggregate wrapper +for cross-source dependencies. Supplied external containers and captured objects +may still share state. Explicit ``AggregateServiceProvider`` retains its distinct +last-factory-wins policy. Older releases may lack this correction; run +``examples/08-provider-composition.php`` against the installed library to verify it. + Supported initializers ---------------------- @@ -66,7 +109,7 @@ Initializer Behavior ==================================== ================================================================ ``ConfigInterface`` Wrapped in ``ConfigContainer`` ``Psr\Container\ContainerInterface`` Added directly to the aggregate -``ServiceProviderInterface`` Wrapped in ``ServiceProviderContainer`` +``ServiceProviderInterface`` Collected into the build's shared provider adapter ``string`` Instantiated with ``new`` and then resolved using the same rules ==================================== ================================================================ @@ -77,13 +120,14 @@ How It Works 1. Each initializer is resolved to a container instance: - If it's already a container, it's used as-is. - - If it's a service provider, it's wrapped in a ServiceProviderContainer. + - Service providers are collected into one build-local aggregate provider. - If it's a config, it's wrapped in a ConfigContainer. - If it's a string, the class is instantiated and then resolved as above. - Unknown class names and unsupported constructed objects throw an InvalidArgumentException. -2. All resolved containers are appended to an AggregateContainer. -3. If a ConfigContainer is present, it may provide nested initializers, which are also resolved and appended. -4. The final result is always an AutowireContainer wrapping the aggregate. +2. Provider definitions select the first callable factory and compose every extension in registration order. +3. The provider adapter and independent resolved containers are appended to an AggregateContainer. +4. If a ConfigContainer is present, it may provide nested initializers, which are also resolved and appended. +5. The final result is always an AutowireContainer wrapping the aggregate. Resolution order ---------------- diff --git a/docs/advanced/special-patterns.rst b/docs/advanced/special-patterns.rst index 0f3fc5a..b6e94c3 100644 --- a/docs/advanced/special-patterns.rst +++ b/docs/advanced/special-patterns.rst @@ -115,10 +115,10 @@ container exception preserving the original cause. Failed resolutions are not cached and can be retried. An ID with neither a callable factory nor a callable ID-based extension remains unknown. -When combining providers, use ``AggregateServiceProvider`` to select factories -across all providers before applying their extensions in registration order. -An extension-only provider may precede the provider supplying its factory. -Passing providers separately to ``container()`` keeps first-match-wins semantics. +The helper, factory and builder select the first callable factory across providers, +then apply every extension in registration order by default. An extension-only +provider may precede the provider supplying its factory. Explicit +``AggregateServiceProvider`` also composes extensions but uses the last factory. The final ``AutowireContainer::has()`` also checks entry discovery without calling ``get()``. A ``true`` result does not guarantee that construction will succeed; resolution errors are raised by ``get()``. @@ -128,11 +128,13 @@ Provider merge semantics There are two distinct composition models: -- ``container($providerA, $providerB)`` keeps providers separated and resolves from the first one that matches. +- ``container($providerA, $providerB)`` selects the first callable factory and composes all extensions. - ``AggregateServiceProvider($providerA, $providerB)`` merges factories first, so later factory keys overwrite earlier ones. -Use the first model for overrides and fallbacks. Use the second model when you want to -ship one combined provider object. +Use the first model when earlier factories should take precedence. Use the second +to ship one combined provider object with later-factory precedence. Selected +provider failures propagate in helper/factory/builder composition; explicit +PSR-11 sources retain aggregate fallback semantics. Aggregate container aliases and self-resolution ----------------------------------------------- @@ -167,7 +169,11 @@ Fallback and recovery behavior When ``AggregateContainer`` checks its child containers, it keeps going when a child says it has the entry but then throws a PSR not-found or container exception. This allows later -containers to recover from partial or inconsistent registrations. +containers to recover from partial or inconsistent registrations. The helper, +factory and builder register selected provider entries as required sources through +``appendRequired()``: their factory/extension failures propagate immediately, +including nested not-found errors. This prevents a broken selected definition +from becoming a successful fallback or autowired default. Wrapper container for delegation -------------------------------- diff --git a/docs/api/core.rst b/docs/api/core.rst index 74f07eb..784047c 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -112,7 +112,9 @@ inside the FastForward ecosystem while staying compatible with PSR-11 consumers. ``AggregateContainer`` ---------------------- -``AggregateContainer`` combines multiple PSR-11 containers and resolves them in order. +``AggregateContainer`` combines ordered PSR-11 containers and Interop providers. +Provider registrations share one ``ServiceProviderContainer``; source positions, +required-entry policy and inferred alias origins are private aggregate state. Built-in aliases: @@ -125,7 +127,8 @@ Resolution rules: - If an entry was already resolved once, the cached value is returned. - Otherwise, child containers are checked from first to last. -- If one child reports ``has($id)`` but throws a PSR not-found or container exception during ``get($id)``, the aggregate keeps checking later containers. +- External PSR-11 sources permit fallback after ``get()`` throws a PSR not-found or container exception. +- Provider sources select the first callable factory and apply all extensions in registration order. Selected provider failures propagate immediately; external values remain independent. - If no source succeeds, the first construction exception is rethrown. Lookups that only encounter missing entries throw ``NotFoundException``. Useful methods: @@ -176,7 +179,11 @@ interface bindings, scalar arguments and fallback behavior. ``ServiceProviderContainer`` ---------------------------- -``ServiceProviderContainer`` turns a ``ServiceProviderInterface`` into a PSR-11 container. +``ServiceProviderContainer`` turns a ``ServiceProviderInterface`` or an ordered list +of providers into a PSR-11 container. A list selects the first callable factory and +applies every extension in registration order. Passing an explicit +``AggregateServiceProvider`` retains that object's last-factory policy. Metadata is +read lazily; all results and class-extension state belong to this adapter. Important behavior: diff --git a/docs/api/providers.rst b/docs/api/providers.rst index 90ee39a..f985c4b 100644 --- a/docs/api/providers.rst +++ b/docs/api/providers.rst @@ -46,6 +46,11 @@ Use ``AggregateServiceProvider`` when: Pass providers separately to ``container()`` when: -- you want first-match-wins resolution between providers +- you want the first callable factory for each ID to win - you want easy override layering -- you want to keep provider boundaries visible in the final aggregate container +- you want all registered provider extensions applied after factory selection + +The helper, factory and builder compose providers by default. They share one +provider adapter per build. Selected construction or extension failures propagate; +PSR-11/config sources remain independent. See :doc:`../advanced/container-helper`. +An explicitly supplied ``AggregateServiceProvider`` retains last-factory precedence. diff --git a/docs/faq.rst b/docs/faq.rst index 21fdc10..bdaad1d 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -78,10 +78,12 @@ How do collisions work when multiple providers define the same ID? It depends on how you combine them: -- ``container($providerA, $providerB)``: the first matching provider wins +- ``container($providerA, $providerB)``: the first callable factory wins - ``new AggregateServiceProvider($providerA, $providerB)``: later factory keys overwrite earlier ones -This difference is intentional, so choose the model that matches your override strategy. +Both forms apply all provider extensions after factory selection, in registration +order. This factory-precedence difference is intentional; choose the model that +matches your override strategy. Can I resolve the container itself from inside a factory? --------------------------------------------------------- diff --git a/docs/getting-started/basic-usage.rst b/docs/getting-started/basic-usage.rst index 114ac05..7da5a75 100644 --- a/docs/getting-started/basic-usage.rst +++ b/docs/getting-started/basic-usage.rst @@ -89,7 +89,9 @@ or when you need to reuse an existing PSR-11 container: $container = container($catalogProvider, $legacyContainer); -When multiple initializers can resolve the same service ID, the first successful +Provider factories use first-callable precedence and all extensions run afterward +in registration order. External sources remain independent. When multiple external +initializers can resolve the same service ID, the first successful source wins. Explicit sources are checked before autowiring, including for nested and optional object dependencies. This makes ``container($override, $defaults)`` a useful pattern for tests and environment-specific overrides. Configure the sources diff --git a/docs/providers/index.rst b/docs/providers/index.rst index 0efcb35..e103d6b 100644 --- a/docs/providers/index.rst +++ b/docs/providers/index.rst @@ -26,11 +26,12 @@ different: * - Approach - Resolution behavior * - ``container($providerA, $providerB)`` - - The first provider that successfully resolves the ID wins + - The first callable factory wins; all extensions run in registration order * - ``new AggregateServiceProvider(...)`` - Factories are merged first, so later providers overwrite earlier keys -Use ``container($providerA, $providerB)`` when you want layered fallback or overrides. +Use ``container($providerA, $providerB)`` when earlier factory definitions should take precedence. +Both forms apply every participating extension after factory selection. Use ``AggregateServiceProvider`` when you want to publish one merged provider as a single unit. .. toctree:: diff --git a/docs/providers/using-providers.rst b/docs/providers/using-providers.rst index 98c183f..dbfc60e 100644 --- a/docs/providers/using-providers.rst +++ b/docs/providers/using-providers.rst @@ -78,8 +78,9 @@ Two details are easy to miss: - Factories are merged in provider order, so later providers overwrite earlier keys when the same service ID exists in both. - Extensions with the same key are composed in provider order instead of overwritten. -If you want first-match-wins semantics instead of merge semantics, do not aggregate the -providers first. Pass them separately to ``container($providerA, $providerB)``. +Pass providers directly to ``container($providerA, $providerB)`` to preserve the +first callable factory for each ID. Both forms compose every extension in order; +explicit ``AggregateServiceProvider`` uses the last duplicate factory. ``ServiceProviderContainer`` ---------------------------- @@ -101,3 +102,13 @@ Important behavior: - An ID-based extension can provide an entry without a factory; it receives ``null`` as the previous value. - Each extension's return value becomes the next value in the resolution chain. Extensions declared ``void`` retain the previous value for compatibility. - Factories and extensions receive the wrapper container, which defaults to the ``ServiceProviderContainer`` instance itself. + +Cross-provider extensions +------------------------- + +The helper, factory and builder compose all provider extensions by default, +after selecting the first callable factory for the ID. Earlier extension-only +providers no longer shadow later factories. Extensions run in registration order. +Selected factories retain their source positions, including against interleaved +external PSR-11/config sources. External entries are independent. Selected provider failures propagate instead of falling back. +See :doc:`../advanced/container-helper` for mixed-source and lifetime boundaries. diff --git a/examples/08-provider-composition.php b/examples/08-provider-composition.php new file mode 100644 index 0000000..acedbc8 --- /dev/null +++ b/examples/08-provider-composition.php @@ -0,0 +1,19 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/container + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +require __DIR__ . '/../resources/skills/fast-forward-container/examples/provider-composition.php'; diff --git a/examples/README.md b/examples/README.md index 50dc4af..278fd2c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,6 +16,8 @@ The examples are ordered from the most common onboarding flow to the more specia - `php examples/07-incremental-builder.php --with-feature`: register sources in stages with `ContainerBuilder`, enable an optional provider, and keep previous builds unchanged. Omit the flag to run without the feature. +- `php examples/08-provider-composition.php`: use default cross-provider extensions, verifying both factory/provider orders and shared final values. + Auxiliary files: - `examples/bootstrap.php`: loads the Composer autoloader and provides small CLI helper functions shared by the examples. diff --git a/resources/skills/fast-forward-container/SKILL.md b/resources/skills/fast-forward-container/SKILL.md index 1a0f014..cf0b140 100644 --- a/resources/skills/fast-forward-container/SKILL.md +++ b/resources/skills/fast-forward-container/SKILL.md @@ -126,8 +126,9 @@ It must print `configuration: ok`; mismatches throw regardless of PHP's `assert( ## Preserve resolution and lifetime rules -- Separate sources are tried in order: `container($testOverrides, $config, $defaults)`. The first successful source wins. Explicit entries precede native autowiring, including nested and optional dependencies. Later sources may recover from PSR-11 errors thrown by a source's `get()` after its `has()` succeeds. Errors from `has()`, including provider metadata failures, stop the lookup immediately. -- `AggregateServiceProvider($base, $overrides)` merges factories: **later duplicate factory keys win**. Duplicate extensions compose in provider order. This differs from first-source precedence. +- Helper, factory and builder compose all direct/configuration-discovered providers by default. The first callable factory wins; every provider extension runs in registration order after selection, including extensions before the factory. Selected factory/extension failures propagate without later-provider, external or autowiring fallback. Successful final values, including null, are cached; failed chains may be retried. +- Selected provider factories retain their original source positions against interleaved external PSR-11/config sources. All providers share one build-local adapter; AggregateContainer keeps source positions and alias origins internally. Extension-only defaults use the first extension position only when no provider has a factory. External values remain independent and are not decorated. External sources retain fallback after PSR-11 `get()` failures, while `has()` failures propagate. +- Explicit `AggregateServiceProvider($base, $overrides)` retains **later duplicate factory keys win**. Both forms compose extensions in provider order. Older supported versions may lack the default composition correction; run `examples/provider-composition.php` against the installed autoloader before relying on it. - Numeric string IDs remain opaque through provider merges and follow the same precedence as other IDs. - Interfaces and required scalars need explicit registration/factories. Bind the containing service for scalar arguments. `?Type $value` without a default remains required. - Optional named objects try explicit entries and concrete autowiring before their defaults. Missing/uninferable graphs may use the default. Constructor/factory/type/autoload/default-expression failures and cycles propagate when no later source succeeds. Do not blanket-catch failures and silently substitute null. diff --git a/resources/skills/fast-forward-container/examples/composition.php b/resources/skills/fast-forward-container/examples/composition.php index c6cf82c..91f4a7b 100644 --- a/resources/skills/fast-forward-container/examples/composition.php +++ b/resources/skills/fast-forward-container/examples/composition.php @@ -39,8 +39,9 @@ if ('base' !== $first->get('label') || 'test' !== $merged->get('label') || 'start:base:test' !== $merged->get('trail') + || 'start:base:test' !== $first->get('trail') ) { - throw new RuntimeException('Separate source precedence or merged factory/extension order failed.'); + throw new RuntimeException('Factory precedence or cross-provider extension order failed.'); } $factories = new ArrayServiceProvider([ @@ -83,9 +84,9 @@ throw new RuntimeException('A reused external container unexpectedly lost its cached instance.'); } -$broken = new ArrayServiceProvider([ +$broken = new ServiceProviderContainer(new ArrayServiceProvider([ 'recover' => static fn(): never => throw new RuntimeException('broken source'), -]); +])); $fallback = new ArrayServiceProvider([ 'recover' => static fn(): string => 'recovered', ]); diff --git a/resources/skills/fast-forward-container/examples/provider-composition.php b/resources/skills/fast-forward-container/examples/provider-composition.php new file mode 100644 index 0000000..12f6399 --- /dev/null +++ b/resources/skills/fast-forward-container/examples/provider-composition.php @@ -0,0 +1,49 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/container + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +use FastForward\Container\ContainerBuilder; +use FastForward\Container\ContainerFactory; + +use function FastForward\Container\container; +use FastForward\Container\ServiceProvider\ArrayServiceProvider; + +require ($argv[1] ?? dirname(__DIR__, 4) . '/vendor/autoload.php'); + +$calls = 0; +$extension = new ArrayServiceProvider(extensions: [ + 'demo' => static function ($container, $previous) use (&$calls): string { + ++$calls; + + return ($previous ?? 'missing') . '|extended'; + }, +]); +$factory = new ArrayServiceProvider(['demo' => static fn(): string => 'factory']); + +foreach ([[$factory, $extension], [$extension, $factory]] as $providers) { + foreach ([container(...$providers), ContainerFactory::create(...$providers), (new ContainerBuilder())->add(...$providers)->build()] as $container) { + if ('factory|extended' !== $container->get('demo') || 'factory|extended' !== $container->get('demo')) { + throw new RuntimeException('Provider order or shared result failed.'); + } + } +} + +if (6 !== $calls) { + throw new RuntimeException('Extensions must run exactly once in each build.'); +} + +echo "provider-composition: ok\n"; diff --git a/resources/skills/fast-forward-container/references/composition.md b/resources/skills/fast-forward-container/references/composition.md index 19177f4..dd78b5f 100644 --- a/resources/skills/fast-forward-container/references/composition.md +++ b/resources/skills/fast-forward-container/references/composition.md @@ -2,7 +2,7 @@ Load this for multiple sources, test overrides, aliases, extensions or factory selection. Run `../examples/composition.php` against the consumer autoloader. -## First source versus merged factory +## First factory versus explicit merged factory ```php use FastForward\Container\ServiceProvider\ArrayServiceProvider; @@ -11,13 +11,35 @@ use function FastForward\Container\container; $base = new ArrayServiceProvider(['label' => static fn(): string => 'base']); $test = new ArrayServiceProvider(['label' => static fn(): string => 'test']); -$first = container($test, $base); // label: test; first successful source wins +$first = container($test, $base); // label: test; first callable factory wins $merged = container(new AggregateServiceProvider($base, $test)); // label: test; later factory wins ``` -Separate sources first call `has()`. When it succeeds, a PSR-11 exception raised by that source's `get()` permits trying the next source. If none succeeds, the first construction error is preserved. An exception from `has()` itself, including a failure reading provider factories/extensions, propagates immediately; later sources are not tried. A successful fallback is cached; later lookups do not automatically retry the earlier source. - -Provider merges preserve numeric string IDs and apply the same later-duplicate precedence as for other IDs. +The helper, factory and builder collect provider factories before applying all +extensions in registration order. The first callable factory wins, including an +intentional null result. An extension-only provider before a factory no longer +shadows that factory. Explicit `AggregateServiceProvider` retains its distinct +last-factory precedence. Numeric string IDs remain opaque. + +All direct and one-level configuration-discovered providers share one adapter. +AggregateContainer tracks source positions and alias origins internally, preserving +selected factory positions against interleaved PSR-11/Config sources. Extension-only defaults use the first extension position only +when no provider has a factory. Inferred class aliases retain their factory position. +External containers, including dual-role objects, remain independent sources; group +extensions do not decorate their selected values. Invalid optional configured +entries stop that list, preserving prior entries and later explicit sources. + +Selected provider construction/extension failures propagate with causes; later +providers, external containers and native autowiring cannot hide them. Failed +chains remain retryable. Independent external PSR-11 sources retain aggregate +fallback: a `get()` failure may try later sources; a `has()` failure propagates. +Successful fallback values are cached. + +Each build creates fresh provider caches, class-string initializers and an +aggregate wrapper for dependencies. Existing external state and captured objects +can remain shared. Older library versions may lack this default composition +correction; run `../examples/provider-composition.php` against the installed +autoloader to verify it. No opt-in method is required. ## Select the factory by its argument contract diff --git a/resources/skills/fast-forward-container/references/versions-and-errors.md b/resources/skills/fast-forward-container/references/versions-and-errors.md index 92a58a5..0bb7f7e 100644 --- a/resources/skills/fast-forward-container/references/versions-and-errors.md +++ b/resources/skills/fast-forward-container/references/versions-and-errors.md @@ -13,7 +13,7 @@ Check `composer show fast-forward/config` and `composer show psr/container`. The | Class extensions | Apply independently to distinct instances and reuse completed results for the same identity | | Provider recursion | Detect factory/alias/extension cycles, clean up retries and isolate active Fiber paths | -See [composition and factories](composition.md) for the complete contracts and run all four bundled examples against the consumer autoloader. +See [composition and factories](composition.md) for the complete contracts and run the bundled examples against the consumer autoloader. PHP-DI DSL, attributes, proxies and compilation are outside this library's native engine. Use explicit provider factories when construction needs application-specific rules; direct PHP-DI consumers must declare their dependency. @@ -24,7 +24,7 @@ PHP-DI DSL, attributes, proxies and compilation are outside this library's nativ | Named object with explicit entry | Try explicit entries before native autowiring | | Constructible concrete named object | Recursively build and cache on success | | Optional object absent or graph structurally uninferable | Use declared default, including non-null defaults | -| Constructor/factory/autoloader/type/default-expression failure or cycle from a source's `get()` | PSR-11 errors permit later sources; propagate the first construction error if none succeeds | +| Constructor/factory/autoloader/type/default-expression failure or cycle from a source's `get()` | Selected provider errors propagate immediately; independent external PSR-11 sources may try later sources | | Source `has()` failure, including unreadable provider metadata | Propagate immediately without trying later sources | | Nullable parameter without default | Required; nullable does not mean omittable | | Required scalar, ambiguous union/intersection/DNF | Explicit factory for the containing service | @@ -35,7 +35,7 @@ Interfaces are not automatically mapped to arbitrary implementations. A concrete Missing/uninferable graphs differ from broken executable code. If an integration intentionally tolerates a known failure, catch that specific exception in its explicit factory and return the intended fallback. Avoid blanket catches around autowiring and preserve cycle diagnostics. -The native constructor resolver detects constructor cycles. The provider guard reports factory, alias and extension cycles as container errors and preserves ordered same-ID fallback to different providers. It isolates active paths per execution context; it does not synchronize concurrent service construction or control recursion inside an external container. +The native constructor resolver detects constructor cycles. The provider guard reports factory, alias and extension cycles as container errors. Default composition selects the first callable factory, so same-ID re-entry remains a cycle rather than selecting a later provider. Independent external PSR-11 containers retain their own aggregate fallback semantics. It isolates active paths per execution context; it does not synchronize concurrent service construction or control recursion inside an external container. Run `../examples/autowiring.php` with a consumer autoloader to check standalone/delegated graphs, optional absence, constructor errors and cycles. No external services or credentials are needed. diff --git a/src/AggregateContainer.php b/src/AggregateContainer.php index c77a0f8..05d869a 100644 --- a/src/AggregateContainer.php +++ b/src/AggregateContainer.php @@ -19,12 +19,18 @@ namespace FastForward\Container; use FastForward\Container\Exception\NotFoundException; +use FastForward\Container\Exception\ContainerException; +use Interop\Container\ServiceProviderInterface; +use Throwable; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface as PsrContainerInterface; use Psr\Container\NotFoundExceptionInterface; /** - * Aggregates multiple PSR-11 containers and delegates resolution requests among them. + * Aggregates ordered PSR-11 containers and Interop service providers. + * + * Providers share one adapter. Source positions and inferred alias origins are + * retained as internal state, preserving external-source precedence. * * This container implementation respects PSR-11 expectations and throws a * NotFoundException when a requested service cannot be found in any delegated container. @@ -39,7 +45,7 @@ class AggregateContainer implements ContainerInterface public const ALIAS = 'container'; /** - * @var ContainerInterface[] the array of containers aggregated by this instance + * @var list ordered source descriptors */ private array $containers = []; @@ -48,15 +54,25 @@ class AggregateContainer implements ContainerInterface */ private array $resolved = []; + /** + * @var list providers in their original registration order + */ + private readonly array $providers; + + /** + * @var array originating source positions of inferred class aliases + */ + private array $classAliasOwners = []; + /** * Constructs the AggregateContainer with one or more delegated containers. * * The constructor SHALL bind itself to common aliases, including the class name * and the PSR-11 interface, to simplify resolution of the container itself. * - * @param PsrContainerInterface ...$containers One or more container implementations to aggregate. + * @param PsrContainerInterface|ServiceProviderInterface ...$containers ordered containers and providers to compose */ - public function __construct(PsrContainerInterface ...$containers) + public function __construct(PsrContainerInterface|ServiceProviderInterface ...$containers) { $this->resolved = [ self::ALIAS => $this, @@ -65,8 +81,27 @@ public function __construct(PsrContainerInterface ...$containers) PsrContainerInterface::class => $this, ]; + $this->providers = array_values(array_filter( + $containers, + static fn(object $source): bool => ! $source instanceof PsrContainerInterface, + )); + $providerContainer = [] === $this->providers ? null : new ServiceProviderContainer($this->providers, $this); + $providerIndex = 0; + foreach ($containers as $container) { - $this->append($container); + if ($container instanceof PsrContainerInterface) { + $this->append($container); + + continue; + } + + $this->containers[] = [ + 'container' => $providerContainer, + 'providerIndex' => $providerIndex, + 'required' => true, + ]; + $this->resolved[ServiceProviderContainer::class] ??= $providerContainer; + ++$providerIndex; } } @@ -81,11 +116,29 @@ public function __construct(PsrContainerInterface ...$containers) */ public function append(PsrContainerInterface $container): void { - $this->containers[] = $container; + $this->containers[] = [ + 'container' => $container, + 'providerIndex' => null, + 'required' => false, + ]; - if (! isset($this->resolved[$container::class])) { - $this->resolved[$container::class] = $container; - } + $this->resolved[$container::class] ??= $container; + } + + /** + * Appends a source whose advertised entry failures must stop resolution. + * + * Absence still permits later sources. A failure after has() succeeds propagates, + * including a not-found exception raised while resolving a required dependency. + * + * @param PsrContainerInterface $container the authoritative source to append + * + * @return void + */ + public function appendRequired(PsrContainerInterface $container): void + { + $this->append($container); + $this->containers[array_key_last($this->containers)]['required'] = true; } /** @@ -100,7 +153,11 @@ public function append(PsrContainerInterface $container): void public function prepend(PsrContainerInterface $container): void { $this->resolved[$container::class] = $container; - array_unshift($this->containers, $container); + array_unshift($this->containers, [ + 'container' => $container, + 'providerIndex' => null, + 'required' => false, + ]); } /** @@ -119,8 +176,8 @@ public function has(string $id): bool return true; } - foreach ($this->containers as $container) { - if ($container->has($id)) { + foreach ($this->containers as $source) { + if ($this->sourceHas($source, $id)) { return true; } } @@ -135,6 +192,8 @@ public function has(string $id): bool * through the aggregated containers to resolve the entry. It MUST throw a * NotFoundException if the identifier is absent. If all candidates fail, the first * delegated construction exception SHALL be retained instead. + * Sources registered with appendRequired() SHALL propagate their advertised + * entry failures immediately, without attempting later sources. * * @param string $id the identifier of the entry to retrieve * @@ -151,18 +210,32 @@ public function get(string $id): mixed $exception = NotFoundException::forServiceID($id); - foreach ($this->containers as $container) { - if (! $container->has($id)) { + foreach ($this->containers as $source) { + $container = $source['container']; + + if (! $this->sourceHas($source, $id)) { continue; } try { $this->resolved[$id] = $container->get($id); + if (null !== $source['providerIndex'] && \is_object($this->resolved[$id])) { + $this->classAliasOwners[$this->resolved[$id]::class] ??= $source['providerIndex']; + } + return $this->resolved[$id]; - } catch (NotFoundExceptionInterface) { + } catch (NotFoundExceptionInterface $failure) { + if ($source['required']) { + throw $failure; + } + // Ignore NotFoundExceptionInterface } catch (ContainerExceptionInterface $failure) { + if ($source['required']) { + throw $failure; + } + if ($exception instanceof NotFoundExceptionInterface) { $exception = $failure; } @@ -172,6 +245,61 @@ public function get(string $id): mixed throw $exception; } + /** + * Checks a source's advertised entry and the original position of provider definitions. + * + * @param array{container: PsrContainerInterface, providerIndex: int|null, required: bool} $source the source descriptor + * @param string $id the requested identifier + * + * @return bool whether this source may resolve the entry + */ + private function sourceHas(array $source, string $id): bool + { + if (! $source['container']->has($id)) { + return false; + } + + if (null === $source['providerIndex']) { + return true; + } + + try { + return $this->providerIndexFor($id) === $source['providerIndex']; + } catch (Throwable $throwable) { + throw ContainerException::forInvalidService($id, $throwable); + } + } + + /** + * Selects factory positions before extension-only defaults without executing either. + * + * @param string $id the entry identifier + * + * @return int the originating provider index + */ + private function providerIndexFor(string $id): int + { + foreach ($this->providers as $position => $provider) { + if (\is_callable($provider->getFactories()[$id] ?? null)) { + return $position; + } + } + + foreach ($this->providers as $position => $provider) { + if (\is_callable($provider->getExtensions()[$id] ?? null)) { + return $position; + } + } + + foreach ($this->providers as $position => $provider) { + if ($id === $provider::class) { + return $position; + } + } + + return $this->classAliasOwners[$id] ?? 0; + } + /** * Determines whether the identifier has already been resolved by this container. * diff --git a/src/ContainerBuilder.php b/src/ContainerBuilder.php index 83cf6f6..a4fd217 100644 --- a/src/ContainerBuilder.php +++ b/src/ContainerBuilder.php @@ -64,109 +64,79 @@ public function add( } /** - * Creates a fresh container stack from the registered initializers. + * Builds the default two-pass provider composition without executing service callbacks. * - * This method SHALL adapt sources in registration order, append optional configured - * sources, and return an AutowireContainer wrapping the completed aggregate. - * Class names MUST be instantiable without required constructor arguments. - * The builder SHALL retain registrations without retaining assembled container state. + * The first callable factory wins. Every provider extension runs in registration + * order after factory selection. Aggregate source metadata retains source positions; + * external PSR-11/config sources remain independent. Selected failures propagate. + * Class names and adapters are recreated for each build. * - * @return ContainerInterface the composed and autowire-enabled container + * @return ContainerInterface a fresh stack with shared provider resolution state * * @throws InvalidArgumentException if an initializer class cannot be found or adapted */ public function build(): ContainerInterface { - $aggregateContainer = new AggregateContainer(); + return new AutowireContainer(new AggregateContainer(...$this->collectCompositionSources())); + } + + /** + * Expands one level of optional configured initializers in registration order. + * + * Providers remain definitions until the complete ordered list has been gathered. + * Invalid optional entries stop expansion, retaining prior and later explicit sources. + * + * @return list ordered normalized sources + */ + private function collectCompositionSources(): array + { + $sources = []; + $configKey = \sprintf('%s.%s', ConfigContainer::ALIAS, ContainerInterface::class); foreach ($this->initializers as $initializer) { - $container = $this->resolveInitializer($initializer, $aggregateContainer); - $aggregateContainer->append($container); + $source = $this->resolveCompositionSource($initializer); + $sources[] = $source; + + if (! $source instanceof ConfigContainer) { + continue; + } - if ($container instanceof ConfigContainer) { - $this->appendConfiguredContainers($container, $aggregateContainer); + try { + foreach ($source->get($configKey) as $nested) { + $sources[] = $this->resolveCompositionSource($nested); + } + } catch (Throwable) { + // Preserve the optional configuration boundary. } } - return new AutowireContainer($aggregateContainer); + return $sources; } /** - * Resolves an initializer object or constructs its class before adaptation. + * Instantiates class strings and preserves PSR-11 precedence for dual-role objects. * - * Existing objects SHALL be passed directly to adapter selection. Class names SHALL - * be instantiated with an argument-free constructor for each build. + * @param mixed $initializer an object or argument-free initializer class * - * @param mixed $initializer an initializer object or instantiable class name - * @param AggregateContainer $aggregateContainer the shared resolution wrapper + * @return PsrContainerInterface|ServiceProviderInterface a source or unadapted provider * - * @return PsrContainerInterface the adapted initializer - * - * @throws InvalidArgumentException if an initializer class cannot be found or adapted + * @throws InvalidArgumentException if the initializer cannot be adapted */ - private function resolveInitializer( - mixed $initializer, - AggregateContainer $aggregateContainer - ): PsrContainerInterface { - return match (true) { - \is_object($initializer) => $this->adaptInitializer($initializer, $aggregateContainer), - class_exists($initializer) => $this->adaptInitializer(new ($initializer)(), $aggregateContainer), - default => throw InvalidArgumentException::forUnsupportedInitializer($initializer), - }; - } + private function resolveCompositionSource(mixed $initializer): PsrContainerInterface|ServiceProviderInterface + { + if (! \is_object($initializer)) { + if (! \is_string($initializer) || ! class_exists($initializer)) { + throw InvalidArgumentException::forUnsupportedInitializer($initializer); + } + + $initializer = new ($initializer)(); + } - /** - * Selects a container implementation for a constructed initializer. - * - * PSR-11 containers SHALL take precedence over provider and configuration adapters. - * Unsupported objects MUST cause an InvalidArgumentException before reaching the aggregate. - * - * @param object $initializer the constructed initializer - * @param AggregateContainer $aggregateContainer the shared resolution wrapper - * - * @return PsrContainerInterface the container or provider/config adapter - * - * @throws InvalidArgumentException if the initializer object cannot be adapted - */ - private function adaptInitializer( - object $initializer, - AggregateContainer $aggregateContainer - ): PsrContainerInterface { return match (true) { $initializer instanceof PsrContainerInterface => $initializer, - $initializer instanceof ServiceProviderInterface => new ServiceProviderContainer( - $initializer, - $aggregateContainer, - ), + $initializer instanceof ServiceProviderInterface => $initializer, $initializer instanceof ConfigInterface => new ConfigContainer($initializer), default => throw InvalidArgumentException::forUnsupportedInitializer($initializer), }; } - - /** - * Appends additional initializers declared in a configuration container. - * - * Entries SHALL be adapted in order using the existing container configuration key. - * Failures while reading or adapting this optional configuration SHALL stop that - * expansion and be ignored, preserving previously appended and later explicit sources. - * - * @param ConfigContainer $configContainer the configuration entry source - * @param AggregateContainer $aggregateContainer the shared resolution wrapper - * - * @return void - */ - private function appendConfiguredContainers( - ConfigContainer $configContainer, - AggregateContainer $aggregateContainer - ): void { - $configKey = \sprintf('%s.%s', ConfigContainer::ALIAS, ContainerInterface::class); - - try { - foreach ($configContainer->get($configKey) as $nested) { - $aggregateContainer->append($this->resolveInitializer($nested, $aggregateContainer)); - } - } catch (Throwable) { - // Preserve the existing helper's optional configuration behavior. - } - } } diff --git a/src/ContainerFactory.php b/src/ContainerFactory.php index 2ccfd70..2b776c2 100644 --- a/src/ContainerFactory.php +++ b/src/ContainerFactory.php @@ -28,6 +28,8 @@ * * This factory SHALL register the supplied initializers with a new builder and * delegate assembly to it. The factory MUST NOT retain assembled container state. + * Providers use the first callable factory and all extensions in registration order. + * Selected provider failures propagate. External sources remain independent. * Source order, provider wrapper injection, and optional configuration policy * SHALL remain consistent with direct builder composition. */ diff --git a/src/ServiceProviderContainer.php b/src/ServiceProviderContainer.php index 889a828..0aab3f3 100644 --- a/src/ServiceProviderContainer.php +++ b/src/ServiceProviderContainer.php @@ -24,6 +24,8 @@ use FastForward\Container\Exception\ContainerException; use FastForward\Container\Exception\NotFoundException; use FastForward\Container\Extension\ServiceExtension; +use FastForward\Container\Factory\ServiceFactory; +use FastForward\Container\ServiceProvider\AggregateServiceProvider; use Interop\Container\ServiceProviderInterface; use Psr\Container\ContainerInterface as PsrContainerInterface; @@ -31,7 +33,7 @@ * Implements a PSR-11 compliant dependency injection container using a container-interop/service-provider. * * This container SHALL resolve services by delegating to the factories and extensions defined in the - * provided ServiceProviderInterface instance. + * provided provider or ordered provider list. * * Services are lazily instantiated on first request and * cached for subsequent retrieval, enforcing singleton-like behavior within the container scope. @@ -51,6 +53,16 @@ final class ServiceProviderContainer implements ContainerInterface */ private readonly PsrContainerInterface $wrapperContainer; + /** + * @var ServiceProviderInterface definitions used for extension composition + */ + private readonly ServiceProviderInterface $serviceProvider; + + /** + * @var list|null ordered providers when composing first-factory definitions + */ + private readonly ?array $providers; + /** * Final class-extension values indexed by original and same-class resulting object identities. * @@ -86,14 +98,21 @@ final class ServiceProviderContainer implements ContainerInterface * This constructor SHALL initialize the container with a service provider and an optional delegating container. * If no wrapper container is provided, the container SHALL delegate to itself. * - * @param ServiceProviderInterface $serviceProvider the service provider supplying factories and extensions + * @param ServiceProviderInterface|list $serviceProvider a provider or ordered providers; lists keep the first callable factory and compose all extensions * @param PsrContainerInterface|null $wrapperContainer An optional container for delegation. Defaults to self. */ public function __construct( - private readonly ServiceProviderInterface $serviceProvider, + ServiceProviderInterface|array $serviceProvider, ?PsrContainerInterface $wrapperContainer = null, ) { $this->wrapperContainer = $wrapperContainer ?? $this; + if (\is_array($serviceProvider)) { + $this->providers = array_values($serviceProvider); + $this->serviceProvider = new AggregateServiceProvider(...$this->providers); + } else { + $this->providers = null; + $this->serviceProvider = $serviceProvider; + } } /** @@ -115,7 +134,7 @@ public function has(string $id): bool } try { - return $this->hasEntry($id, $this->serviceProvider->getFactories()) + return $this->hasEntry($id, $this->getFactories()) || $this->hasEntry($id, extensions: $this->serviceProvider->getExtensions()); } catch (Throwable $throwable) { throw ContainerException::forInvalidService($id, $throwable); @@ -244,12 +263,46 @@ private function resolveUncachedEntry(string $id): mixed private function readProviderDefinitions(string $id): array { try { - return [$this->serviceProvider->getFactories(), $this->serviceProvider->getExtensions()]; + return [$this->getFactories(), $this->serviceProvider->getExtensions()]; } catch (Throwable $throwable) { throw ContainerException::forInvalidService($id, $throwable); } } + /** + * Reads first-callable factories for a provider list, or the single provider's own policy. + * + * Explicit AggregateServiceProvider objects retain their last-factory policy. + * Definitions are read lazily; no service callback is executed here. + * + * @return array selected factories and provider identity definitions + */ + private function getFactories(): array + { + if (null === $this->providers) { + return $this->serviceProvider->getFactories(); + } + + $factories = []; + $identities = [ + AggregateServiceProvider::class => new ServiceFactory($this->serviceProvider), + ]; + + foreach ($this->providers as $provider) { + foreach ($provider->getFactories() as $id => $factory) { + if (\is_callable($factory) && ! \array_key_exists($id, $factories)) { + $factories[$id] = $factory; + } + } + + $identities += [ + $provider::class => new ServiceFactory($provider), + ]; + } + + return $factories + $identities; + } + /** * Invokes an available factory and applies the entry's extensions to its value. * diff --git a/src/functions.php b/src/functions.php index 53e8237..b265039 100644 --- a/src/functions.php +++ b/src/functions.php @@ -40,6 +40,9 @@ * * The final container returned is an AutowireContainer that wraps an * AggregateContainer composed of all resolved sources. + * Providers compose by default: the first callable factory wins and every provider + * extension runs in registration order after selection, including earlier extensions. + * Selected provider failures propagate; external sources remain independent. * * @param ConfigInterface|PsrContainerInterface|ServiceProviderInterface|string ...$initializers * A variadic list of container initializers, optionally including config or provider classes. diff --git a/tests/Autowire/ReflectionContainerTest.php b/tests/Autowire/ReflectionContainerTest.php index 80a5d02..57c5260 100644 --- a/tests/Autowire/ReflectionContainerTest.php +++ b/tests/Autowire/ReflectionContainerTest.php @@ -34,6 +34,8 @@ use FastForward\Container\Exception\NotFoundException; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use FastForward\Container\ServiceProviderContainer; +use FastForward\Container\Factory\ServiceFactory; +use FastForward\Container\ServiceProvider\AggregateServiceProvider; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhpExtension; @@ -54,6 +56,8 @@ #[UsesClass(AutowiringException::class)] #[UsesClass(ContainerException::class)] #[UsesClass(NotFoundException::class)] +#[UsesClass(ServiceFactory::class)] +#[UsesClass(AggregateServiceProvider::class)] final class ReflectionContainerTest extends TestCase { /** diff --git a/tests/ContainerBuilderTest.php b/tests/ContainerBuilderTest.php index 450b04e..e5ccc7b 100644 --- a/tests/ContainerBuilderTest.php +++ b/tests/ContainerBuilderTest.php @@ -30,6 +30,7 @@ use FastForward\Container\Factory\ServiceFactory; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use FastForward\Container\ServiceProviderContainer; +use FastForward\Container\ServiceProvider\AggregateServiceProvider; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -49,6 +50,7 @@ #[UsesClass(ServiceFactory::class)] #[UsesClass(NotFoundException::class)] #[UsesClass(InvalidArgumentException::class)] +#[UsesClass(AggregateServiceProvider::class)] final class ContainerBuilderTest extends TestCase { /** diff --git a/tests/ContainerFunctionTest.php b/tests/ContainerFunctionTest.php index 7857882..f1ab2e9 100644 --- a/tests/ContainerFunctionTest.php +++ b/tests/ContainerFunctionTest.php @@ -31,6 +31,7 @@ use FastForward\Container\Exception\InvalidArgumentException; use FastForward\Container\Exception\NotFoundException; use FastForward\Container\ServiceProviderContainer; +use FastForward\Container\ServiceProvider\AggregateServiceProvider; use Interop\Container\ServiceProviderInterface; use PHPUnit\Framework\Attributes\CoversFunction; use PHPUnit\Framework\Attributes\CoversClass; @@ -50,6 +51,7 @@ #[UsesClass(AutowireContainer::class)] #[UsesClass(ReflectionContainer::class)] #[UsesClass(ServiceProviderContainer::class)] +#[UsesClass(AggregateServiceProvider::class)] #[UsesClass(InvalidArgumentException::class)] #[UsesClass(NotFoundException::class)] final class ContainerFunctionTest extends TestCase diff --git a/tests/Integration/Fixture/CompositionProvider.php b/tests/Integration/Fixture/CompositionProvider.php new file mode 100644 index 0000000..9ad0d13 --- /dev/null +++ b/tests/Integration/Fixture/CompositionProvider.php @@ -0,0 +1,47 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/container + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Container\Tests\Integration\Fixture; + +use stdClass; +use Interop\Container\ServiceProviderInterface; + +/** + * Argument-free initializer used by isolated production composition tests. + */ +final class CompositionProvider implements ServiceProviderInterface +{ + /** + * @return array + */ + public function getFactories(): array + { + return [ + 'entry' => static fn(): string => 'provider', + 'object' => static fn(): stdClass => new stdClass(), + ]; + } + + /** + * @return array + */ + public function getExtensions(): array + { + return []; + } +} diff --git a/tests/Integration/ProviderCompositionTest.php b/tests/Integration/ProviderCompositionTest.php new file mode 100644 index 0000000..dc2e0c7 --- /dev/null +++ b/tests/Integration/ProviderCompositionTest.php @@ -0,0 +1,673 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/container + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Container\Tests\Integration; + +use RuntimeException; +use stdClass; +use FastForward\Config\ArrayConfig; +use FastForward\Container\AggregateContainer; +use FastForward\Container\ContainerBuilder; +use FastForward\Container\ContainerFactory; +use FastForward\Container\ContainerInterface; +use FastForward\Container\Exception\ContainerException; +use FastForward\Container\Exception\InvalidArgumentException; +use FastForward\Container\Exception\NotFoundException; +use FastForward\Container\Factory\AliasFactory; +use FastForward\Container\ServiceProvider\ArrayServiceProvider; +use FastForward\Container\ServiceProviderContainer; +use FastForward\Container\Tests\Integration\Fixture\CompositionProvider; +use Interop\Container\ServiceProviderInterface; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface as PsrContainerInterface; + +use function FastForward\Container\container; + +/** + * Exercises the default composition contract against development and isolated production graphs. + */ +#[CoversNothing] +final class ProviderCompositionTest extends TestCase +{ + /** + * @return iterable + */ + public static function providerOrders(): iterable + { + foreach (['helper', 'factory', 'builder', 'aggregate', 'provider'] as $entryPoint) { + yield $entryPoint . ' factory first' => [false, $entryPoint]; + yield $entryPoint . ' extension first' => [true, $entryPoint]; + } + } + + /** + * @param bool $extensionFirst + * @param string $entryPoint + * + * @return void + */ + #[DataProvider('providerOrders')] + public function testEveryExtensionRunsOnceAfterFactorySelection(bool $extensionFirst, string $entryPoint): void + { + $events = []; + $factory = new ArrayServiceProvider([ + 'demo' => static function () use (&$events): string { + $events[] = 'factory'; + + return 'factory'; + }, + ]); + $first = new ArrayServiceProvider(extensions: [ + 'demo' => static function ($c, $previous) use (&$events): string { + $events[] = 'first'; + + return $previous . '|first'; + }, + ]); + $second = new ArrayServiceProvider(extensions: [ + 'demo' => static function ($c, $previous) use (&$events): string { + $events[] = 'second'; + + return $previous . '|second'; + }, + ]); + $sources = $extensionFirst ? [$first, $factory, $second] : [$factory, $first, $second]; + $container = match ($entryPoint) { + 'helper' => container(...$sources), + 'factory' => ContainerFactory::create(...$sources), + 'aggregate' => new AggregateContainer(...$sources), + 'provider' => new ServiceProviderContainer($sources), + default => (new ContainerBuilder())->add(...$sources)->build(), + }; + + self::assertSame([], $events); + self::assertTrue($container->has('demo')); + self::assertSame([], $events); + self::assertSame('factory|first|second', $container->get('demo')); + self::assertSame('factory|first|second', $container->get('demo')); + self::assertSame(['factory', 'first', 'second'], $events); + } + + /** + * @return void + */ + public function testEveryEntryPointKeepsFirstFactoryAndComposesExtensionsByDefault(): void + { + $first = new ArrayServiceProvider([ + '42' => static fn(): string => 'first', + ]); + $last = new ArrayServiceProvider([ + '42' => static fn(): string => 'last', + ]); + $builder = (new ContainerBuilder())->add($first, $last); + $built = $builder->build(); + + self::assertSame('first', $built->get('42')); + self::assertSame('first', container($first, $last)->get('42')); + self::assertSame('first', $builder->build()->get('42')); + self::assertSame('first', ContainerFactory::create($first, $last)->get('42')); + + $extension = new ArrayServiceProvider(extensions: [ + '42' => static fn($c, $v): string => ($v ?? 'missing') . '!', + ]); + self::assertSame('first!', container($first, $extension)->get('42')); + self::assertSame('first!', ContainerFactory::create($extension, $first, $last)->get('42')); + self::assertSame('first!', (new ContainerBuilder())->add($extension, $first, $last)->build()->get('42')); + self::assertSame('first!', container($extension, $first)->get('42')); + } + + /** + * @return iterable + */ + public static function values(): iterable + { + yield 'null' => [null]; + yield 'false' => [false]; + yield 'zero' => [0]; + yield 'string' => ['value']; + yield 'object' => [new stdClass()]; + } + + /** + * @param mixed $value + * + * @return void + */ + #[DataProvider('values')] + public function testExplicitValuesAndFinalNullAreShared(mixed $value): void + { + $calls = 0; + $seen = []; + $container = (new ContainerBuilder()) + ->add( + new ArrayServiceProvider(extensions: [ + 'value' => static function ($c, $previous) use (&$seen): mixed { + $seen[] = $previous; + + return null; + }, + ]), + new ArrayServiceProvider([ + 'value' => static function () use ($value, &$calls): mixed { + ++$calls; + + return $value; + }, + ]), + )->build(); + + self::assertTrue($container->has('value')); + self::assertNull($container->get('value')); + self::assertNull($container->get('value')); + self::assertTrue($container->has('value')); + self::assertSame([$value], $seen); + self::assertSame(1, $calls); + } + + /** + * @return void + */ + public function testAbsentFactoryDefaultsAndVoidAndReplacementExtensions(): void + { + $seen = []; + $replacement = new stdClass(); + $container = (new ContainerBuilder()) + ->add( + new ArrayServiceProvider(extensions: [ + 'entry' => static function ($c, $previous) use (&$seen): string { + $seen[] = $previous; + + return 'default'; + }, + ]), + new ArrayServiceProvider(extensions: [ + 'entry' => static function ($c, $previous) use (&$seen): void { + $seen[] = $previous; + }, + ]), + new ArrayServiceProvider(extensions: [ + 'entry' => static function ($c, $previous) use (&$seen, $replacement): stdClass { + $seen[] = $previous; + + return $replacement; + }, + ]), + )->build(); + + self::assertSame($replacement, $container->get('entry')); + self::assertSame($replacement, $container->get('entry')); + self::assertSame([null, 'default', 'default'], $seen); + self::assertFalse($container->has('unknown')); + $this->expectException(NotFoundException::class); + $container->get('unknown'); + } + + /** + * @return void + */ + public function testExactClassExtensionsAndAliasesReuseFinalIdentity(): void + { + $original = new stdClass(); + $replacement = new stdClass(); + $calls = 0; + $container = (new ContainerBuilder()) + ->add( + new ArrayServiceProvider(extensions: [ + stdClass::class => static function ($c, $previous) use (&$calls, $original): stdClass { + self::assertSame($original, $previous); + ++$calls; + + return $previous; + }, + ]), + new ArrayServiceProvider([ + 'entry' => static fn(): stdClass => $original, + 'alias' => new AliasFactory('entry'), + stdClass::class => new AliasFactory('entry'), + 'original' => static fn(): stdClass => $original, + ]), + new ArrayServiceProvider(extensions: [ + stdClass::class => static fn(): stdClass => $replacement, + ]), + )->build(); + + self::assertSame($replacement, $container->get('entry')); + self::assertSame($replacement, $container->get('alias')); + self::assertSame($replacement, $container->get('original')); + self::assertSame($replacement, $container->get(stdClass::class)); + self::assertSame(1, $calls); + } + + /** + * @param string $stage + * + * @return void + */ + #[DataProvider('failureStages')] + public function testFailureCannotFallBackAndIncompleteChainsAreRetried(string $stage): void + { + $failure = new RuntimeException('Required ' . $stage . ' failed'); + $attempts = 0; + $extensions = 0; + $fallback = 0; + $callback = static function () use (&$attempts, $failure): string { + if (1 === ++$attempts) { + throw $failure; + } + + return 'recovered'; + }; + $external = new ServiceProviderContainer(new ArrayServiceProvider([ + 'entry' => static function () use (&$fallback): string { + ++$fallback; + + return 'silent fallback'; + }, + ])); + $container = (new ContainerBuilder()) + ->add( + new ArrayServiceProvider([ + 'entry' => 'factory' === $stage ? $callback : static fn(): string => 'value', + ]), + new ArrayServiceProvider(extensions: [ + 'entry' => static function ($c, $previous) use (&$extensions): mixed { + ++$extensions; + + return $previous; + }, + ]), + $external, + new ArrayServiceProvider(extensions: 'extension' === $stage ? [ + 'entry' => $callback, + ] : []), + )->build(); + + try { + $container->get('entry'); + self::fail('Required provider failure must propagate.'); + } catch (ContainerException $containerException) { + self::assertSame($failure, $containerException->getPrevious()); + } + + self::assertSame('recovered', $container->get('entry')); + self::assertSame('recovered', $container->get('entry')); + self::assertSame(2, $attempts); + self::assertSame('factory' === $stage ? 1 : 2, $extensions); + self::assertSame(0, $fallback); + } + + /** + * @return iterable + */ + public static function failureStages(): iterable + { + yield 'factory' => ['factory']; + yield 'extension' => ['extension']; + } + + /** + * @return void + */ + public function testCyclesAreReportedAndCanBeRetriedAfterRepair(): void + { + $cycle = true; + $builder = (new ContainerBuilder()) + ->add( + new ArrayServiceProvider([ + 'a' => new AliasFactory('b'), + ]), + new ArrayServiceProvider([ + 'b' => static function ($c) use (&$cycle): mixed { + return $cycle ? $c->get('a') : 'repaired'; + }, + ]), + ); + $container = $builder->build(); + for ($attempt = 0; $attempt < 2; ++$attempt) { + try { + $container->get('a'); + self::fail('Cycle must fail on every attempt.'); + } catch (ContainerException $caught) { + self::assertStringContainsString('a', $caught->getMessage()); + } + } + + $cycle = false; + self::assertSame('repaired', $container->get('a')); + } + + /** + * @return void + */ + public function testMixedSourcesConfiguredProvidersClassStringsAndFreshBuilds(): void + { + $external = new ServiceProviderContainer(new ArrayServiceProvider([ + 'external' => static fn(): stdClass => new stdClass(), + 'entry' => static fn(): string => 'external', + ])); + $wrappers = []; + $config = new ArrayConfig([ + 'app' => [ + 'suffix' => '!', + ], + ContainerInterface::class => [ + CompositionProvider::class, + new ArrayServiceProvider(extensions: [ + 'entry' => static function ($c, $previous) use (&$wrappers): string { + $wrappers[] = $c; + self::assertTrue($c->has('external')); + + return $previous . $c->get('config.app.suffix'); + }, + ]), + ], + ]); + $builder = (new ContainerBuilder()) + ->add($config, $external); + $first = $builder->build(); + $second = $builder->build(); + self::assertSame('provider!', $first->get('entry')); + self::assertSame('provider!', $second->get('entry')); + self::assertNotSame($first->get('object'), $second->get('object')); + self::assertNotSame($first->get(CompositionProvider::class), $second->get(CompositionProvider::class)); + self::assertSame($first->get('external'), $second->get('external')); + self::assertSame($first->get('container'), $wrappers[0]); + self::assertNotSame($wrappers[0], $wrappers[1]); + self::assertSame('external', (new ContainerBuilder())->add($external, $config)->build()->get('entry')); + $builder->add(new ArrayServiceProvider([ + 'entry' => static fn(): string => 'later', + ])); + self::assertSame('provider!', $first->get('entry')); + self::assertSame('provider!', $builder->build()->get('entry')); + } + + /** + * @return void + */ + public function testEmptyAndExternalOnlyCompositionsStillAutowire(): void + { + self::assertInstanceOf(stdClass::class, (new ContainerBuilder())->build()->get(stdClass::class)); + $external = new AggregateContainer(); + $container = (new ContainerBuilder()) + ->add($external)->build(); + self::assertInstanceOf(AggregateContainer::class, $container->get(AggregateContainer::class)); + self::assertInstanceOf(stdClass::class, $container->get(stdClass::class)); + } + + /** + * @return void + */ + public function testOptionalInvalidConfiguredSourceStopsOnlyThatExpansion(): void + { + $config = new ArrayConfig([ + ContainerInterface::class => [ + new ArrayServiceProvider([ + 'kept' => static fn(): string => 'kept', + ]), + new stdClass(), + new ArrayServiceProvider([ + 'skipped' => static fn(): string => 'skipped', + ]), + ], + ]); + $container = (new ContainerBuilder()) + ->add($config, CompositionProvider::class)->build(); + self::assertSame('kept', $container->get('kept')); + self::assertFalse($container->has('skipped')); + self::assertSame('provider', $container->get('entry')); + + $this->expectException(InvalidArgumentException::class); + (new ContainerBuilder()) + ->add(stdClass::class)->build(); + } + + /** + * @return void + * + * @throws RuntimeException + */ + public function testDualRoleContainersAreNotCollectedAsProviders(): void + { + $source = new class implements PsrContainerInterface, ServiceProviderInterface { + /** + * @param string $id + * + * @return bool + */ + public function has(string $id): bool + { + return 'external' === $id; + } + + /** + * @param string $id + * + * @return mixed + */ + public function get(string $id): mixed + { + if (! $this->has($id)) { + throw NotFoundException::forServiceID($id); + } + + return 'external'; + } + + /** + * @return array + * + * @throws RuntimeException + */ + public function getFactories(): array + { + throw new RuntimeException('PSR-11 source must not become a provider.'); + } + + /** + * @return array + * + * @throws RuntimeException + */ + public function getExtensions(): array + { + throw new RuntimeException('PSR-11 source must not become a provider.'); + } + }; + $container = (new ContainerBuilder()) + ->add(CompositionProvider::class, $source)->build(); + self::assertSame('provider', $container->get('entry')); + self::assertSame('external', $container->get('external')); + } + + /** + * @return void + */ + public function testNestedConfigurationKeepsOneLevelExpansion(): void + { + $nested = new ArrayConfig([ + 'nested' => true, + ContainerInterface::class => [ + new ArrayServiceProvider([ + 'hidden' => static fn(): string => 'hidden', + ])], + ]); + $config = new ArrayConfig([ + ContainerInterface::class => [$nested, CompositionProvider::class], + ]); + $container = (new ContainerBuilder()) + ->add($config)->build(); + self::assertSame('provider', $container->get('entry')); + self::assertTrue($container->get('config.nested')); + self::assertFalse($container->has('hidden')); + } + + /** + * @return void + */ + public function testRequiredConcreteFactoryFailureCannotBeAutowired(): void + { + $failure = new RuntimeException('Explicit concrete factory failed.'); + $container = (new ContainerBuilder()) + ->add(new ArrayServiceProvider([ + stdClass::class => static fn(): never => throw $failure, + ]))->build(); + + try { + $container->get(stdClass::class); + self::fail('Autowiring must not hide the explicit factory failure.'); + } catch (ContainerException $containerException) { + self::assertSame($failure, $containerException->getPrevious()); + } + } + + /** + * @return void + */ + public function testRequiredSourcePropagatesNotFoundButAllowsAbsentEntries(): void + { + $failure = NotFoundException::forServiceID('dependency'); + $source = self::createStub(PsrContainerInterface::class); + $source->method('has') + ->willReturnCallback(static fn($id): bool => 'broken' === $id); + $source->method('get') + ->willThrowException($failure); + $aggregate = new AggregateContainer(); + $aggregate->appendRequired($source); + $aggregate->append(new ServiceProviderContainer(new ArrayServiceProvider([ + 'other' => static fn(): string => 'available', + 'broken' => static fn(): string => 'fallback', + ]))); + self::assertSame('available', $aggregate->get('other')); + $this->expectExceptionObject($failure); + $aggregate->get('broken'); + } + + /** + * @return void + */ + public function testProviderAggregationPreservesInterleavedExternalSourcePrecedence(): void + { + $extensions = 0; + $earlier = new ArrayServiceProvider( + [ + 'unrelated' => static fn(): string => 'unrelated', + ], + [ + 'entry' => static function ($c, $previous) use (&$extensions): string { + ++$extensions; + + return $previous . '|extended'; + }, + ], + ); + $external = new ServiceProviderContainer(new ArrayServiceProvider([ + 'entry' => static fn(): string => 'external', + ])); + $later = new ArrayServiceProvider([ + 'entry' => static fn(): string => 'provider', + ]); + + foreach ([ + container($earlier, $external, $later), + ContainerFactory::create($earlier, $external, $later), + (new ContainerBuilder())->add($earlier, $external, $later)->build(), + ] as $container) { + self::assertSame('external', $container->get('entry')); + self::assertSame('unrelated', $container->get('unrelated')); + } + + self::assertSame(0, $extensions); + self::assertSame('provider|extended', container($earlier, $later, $external)->get('entry')); + self::assertSame(1, $extensions); + } + + /** + * @return void + */ + public function testNullFactoryKeepsPrecedenceOverLaterFactoryAcrossEntryPoints(): void + { + $laterCalls = 0; + $first = new ArrayServiceProvider([ + 'entry' => static fn(): mixed => null, + ]); + $later = new ArrayServiceProvider([ + 'entry' => static function () use (&$laterCalls): string { + ++$laterCalls; + + return 'later'; + }, + ]); + + foreach ([ + container($first, $later), + ContainerFactory::create($first, $later), + (new ContainerBuilder())->add($first, $later)->build(), + ] as $container) { + self::assertTrue($container->has('entry')); + self::assertNull($container->get('entry')); + self::assertNull($container->get('entry')); + } + + self::assertSame(0, $laterCalls); + } + + /** + * @return void + */ + public function testInferredClassAliasRetainsItsFactorySourcePosition(): void + { + $externalValue = new stdClass(); + $providerValue = new stdClass(); + $external = new ServiceProviderContainer(new ArrayServiceProvider([ + stdClass::class => static fn(): stdClass => $externalValue, + ])); + $earlier = new ArrayServiceProvider([ + 'unrelated' => static fn(): string => 'unrelated', + ]); + $later = new ArrayServiceProvider([ + 'entry' => static fn(): stdClass => $providerValue, + ]); + $container = container($earlier, $external, $later); + + self::assertSame($providerValue, $container->get('entry')); + self::assertSame($externalValue, $container->get(stdClass::class)); + + $providerFirst = container($earlier, $later, $external); + self::assertSame($providerValue, $providerFirst->get('entry')); + self::assertSame($providerValue, $providerFirst->get(stdClass::class)); + } + + /** + * @return void + */ + public function testProviderAdapterClassAliasPreservesRegistrationOrder(): void + { + $provider = new ArrayServiceProvider([ + 'entry' => static fn(): string => 'provider', + ]); + $external = new ServiceProviderContainer(new ArrayServiceProvider()); + $providerFirst = container($provider, $external); + $externalFirst = container($external, $provider); + + self::assertNotSame($external, $providerFirst->get(ServiceProviderContainer::class)); + self::assertSame('provider', $providerFirst->get(ServiceProviderContainer::class)->get('entry')); + self::assertSame($external, $externalFirst->get(ServiceProviderContainer::class)); + } +} diff --git a/tests/Integration/SkillExamplesTest.php b/tests/Integration/SkillExamplesTest.php index de26385..fc8431d 100644 --- a/tests/Integration/SkillExamplesTest.php +++ b/tests/Integration/SkillExamplesTest.php @@ -29,6 +29,7 @@ public static function examples(): iterable yield 'composition' => ['composition.php', 'composition: ok']; yield 'autowiring' => ['autowiring.php', 'autowiring: ok']; yield 'provider corrections' => ['provider-resolution.php', 'provider-resolution: ok']; + yield 'provider composition' => ['provider-composition.php', 'provider-composition: ok']; } /** diff --git a/tests/ServiceProviderCycleTest.php b/tests/ServiceProviderCycleTest.php index 22b7b13..3b31f38 100644 --- a/tests/ServiceProviderCycleTest.php +++ b/tests/ServiceProviderCycleTest.php @@ -35,6 +35,8 @@ use FastForward\Container\Factory\AliasFactory; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use FastForward\Container\ServiceProviderContainer; +use FastForward\Container\Factory\ServiceFactory; +use FastForward\Container\ServiceProvider\AggregateServiceProvider; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -56,6 +58,8 @@ #[UsesClass(ReflectionContainer::class)] #[UsesClass(ContainerFactory::class)] #[UsesClass(ContainerBuilder::class)] +#[UsesClass(ServiceFactory::class)] +#[UsesClass(AggregateServiceProvider::class)] final class ServiceProviderCycleTest extends TestCase { /** @@ -140,7 +144,7 @@ public function factoryCyclesAcrossProvidersRetainTheCompleteWrapperPath(): void * @return void */ #[Test] - public function sameIdentifierCanFallBackToAnotherProviderDuringConstruction(): void + public function selectedFactoryCycleCannotFallBackToAnotherProvider(): void { $calls = 0; $container = ContainerFactory::create( @@ -158,8 +162,13 @@ public function sameIdentifierCanFallBackToAnotherProviderDuringConstruction(): ]), ); - self::assertSame('fallback', $container->get('a')); - self::assertSame('fallback', $container->get('a')); + try { + $container->get('a'); + self::fail('The selected factory cycle must propagate.'); + } catch (ContainerException $containerException) { + $this->assertCyclePath($containerException, 'a -> a'); + } + self::assertSame(1, $calls); } diff --git a/tests/ServiceProviderResolutionTest.php b/tests/ServiceProviderResolutionTest.php index a05cb55..e895589 100644 --- a/tests/ServiceProviderResolutionTest.php +++ b/tests/ServiceProviderResolutionTest.php @@ -414,7 +414,7 @@ public function mergedVoidExtensionPreservesPreviousValue(): void * @return void */ #[Test] - public function separateProvidersRetainFirstMatchPrecedence(): void + public function earlierExtensionUsesTheLaterFactoryBeforeExtending(): void { $first = new ArrayServiceProvider(extensions: [ 'value' => static fn(ContainerInterface $container, ?string $previous): string => $previous ?? 'first', @@ -423,7 +423,7 @@ public function separateProvidersRetainFirstMatchPrecedence(): void 'value' => static fn(): string => 'second', ]); - self::assertSame('first', container($first, $second)->get('value')); + self::assertSame('second', container($first, $second)->get('value')); } /**