diff --git a/composer.json b/composer.json index 0b7eb69d..10eeaa39 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,8 @@ "autoload": { "files": [ "src/Assets/version.php", - "src/Cache/functions.php" + "src/Cache/functions.php", + "src/Container/functions.php" ], "psr-4": { "Tribe\\Libs\\ACF\\": "src/ACF/", diff --git a/src/Container/Definition/Helper/ContextualDefinitionHelper.php b/src/Container/Definition/Helper/ContextualDefinitionHelper.php new file mode 100644 index 00000000..8e2044ac --- /dev/null +++ b/src/Container/Definition/Helper/ContextualDefinitionHelper.php @@ -0,0 +1,129 @@ + concrete relationship. + * + * @var array + */ + protected $contextual = []; + + /** + * Map a concrete class/instance to an interface/abstract or even another concrete class. + * + * @param string $interface The fully qualified class name or interface. + * @param string|\Closure|callable $concrete The concrete fully qualified class name or closure that returns an instance. + * + * @return $this + */ + public function contextualParameter( string $interface, $concrete ): self { + $this->contextual[ $interface ] = $concrete; + + return $this; + } + + /** + * Create the definition with the replaced concrete instances. + * + * @param string $entryName + * + * @throws \DI\Definition\Exception\InvalidDefinition + * + * @return \DI\Definition\Definition + */ + public function getDefinition( string $entryName ): Definition { + $definition = parent::getDefinition( $entryName ); + + if ( ! empty( $this->contextual ) ) { + $parameters = $this->replaceParameters( $definition, $this->contextual ); + $constructorInjection = MethodInjection::constructor( $parameters ); + + $definition->setConstructorInjection( $constructorInjection ); + + // We now perform all the constructor injection, make sure the parent doesn't. + unset( $this->constructor ); + } + + return $definition; + } + + /** + * Replace interfaces/abstract constructor parameters with their concrete implementation. + * + * @throws \DI\Definition\Exception\InvalidDefinition + */ + protected function replaceParameters( ObjectDefinition $definition, array $parameters ): array { + $replaced = []; + + try { + $constructorParameters = ( new ReflectionClass( $definition->getClassName() ) ) + ->getConstructor() + ->getParameters(); + } catch ( ReflectionException $e ) { + throw InvalidDefinition::create( + $definition, + sprintf( 'Could not get constructor ReflectionParameters from %s', $definition->getClassName() ) + ); + } + + // Map constructor parameters to their numbered index. The order must match what's in the constructor. + foreach ( $constructorParameters as $index => $parameter ) { + $type = $parameter->getType(); + + // Get built in type values from the constructor parameters + if ( $type instanceof ReflectionNamedType && $type->isBuiltin() ) { + $replaced[ $index ] = $this->constructor[ $parameter->getName() ]; + continue; + } + + $interface = $parameter->getClass()->getName(); + + // Skip parameters that haven't been defined + if ( ! isset( $parameters[ $interface ] ) ) { + continue; + } + + // Create the definition based on the type provided, possibly replacing the interface with its concrete. + $replaced[ $index ] = $this->createDefinition( $interface, $parameters[ $interface ] ); + } + + return $replaced; + } + + /** + * @param string $interface + * @param string|callable $concrete + * + * @return callable|\DI\Definition\AutowireDefinition + */ + protected function createDefinition( string $interface, $concrete ) { + return is_callable( $concrete ) ? $concrete : new AutowireDefinition( $interface, $concrete ); + } + +} diff --git a/src/Container/functions.php b/src/Container/functions.php new file mode 100644 index 00000000..0071c2a7 --- /dev/null +++ b/src/Container/functions.php @@ -0,0 +1,20 @@ +contextualParameter( Interface::class, Concrete::class ); + * @example Tribe\Libs\Container\autowire()->contextualParameter( Interface::class, static fn () => new Concrete() ); + * + * @param string|null $className + * + * @return \Tribe\Libs\Container\Definition\Helper\ContextualDefinitionHelper + */ +function autowire( ?string $className = null ): ContextualDefinitionHelper { + return new ContextualDefinitionHelper( $className ); +} diff --git a/tests/integration/Tribe/Libs/Container/ContextualDefinitionTest.php b/tests/integration/Tribe/Libs/Container/ContextualDefinitionTest.php new file mode 100644 index 00000000..37731de0 --- /dev/null +++ b/tests/integration/Tribe/Libs/Container/ContextualDefinitionTest.php @@ -0,0 +1,213 @@ +builder = new ContainerBuilder(); + } + + public function test_it_maps_concrete_instance_to_an_interface(): void { + $this->builder->addDefinitions( [ + ColorManager::class => Container\autowire() + ->contextualParameter( Color::class, Red::class ), + ] ); + + $container = $this->builder->build(); + $color = $container->get( ColorManager::class )->get_color(); + + $this->assertSame( 'red', $color ); + } + + public function test_it_maps_a_callable_to_an_interface(): void { + $this->builder->addDefinitions( [ + ColorManager::class => Container\autowire() + ->contextualParameter( Color::class, static function () { + return new Blue(); + } ), + ] ); + + $container = $this->builder->build(); + $color = $container->get( ColorManager::class )->get_color(); + + $this->assertSame( 'blue', $color ); + } + + public function test_it_maps_a_callable_to_an_interface_via_parameter_injection(): void { + $this->builder->addDefinitions( [ + ColorManager::class => Container\autowire() + ->contextualParameter( Color::class, static function ( ContainerInterface $c ) { + return $c->get( Green::class ); + } ), + ] ); + + $container = $this->builder->build(); + $color = $container->get( ColorManager::class )->get_color(); + + $this->assertSame( 'green', $color ); + } + + public function test_it_maps_a_callable_to_an_interface_via_factory(): void { + $color_state = 'blue'; + + $this->builder->addDefinitions( [ + ColorManager::class => Container\autowire() + ->contextualParameter( Color::class, static function ( ContainerInterface $c ) use ( $color_state ) { + // Mimic a simple factory + switch ( $color_state ) { + case 'red': + return $c->get( Red::class ); + case 'blue': + return $c->get( Blue::class ); + case 'green': + return $c->get( Green::class ); + default: + throw new InvalidArgumentException( 'Invalid color' ); + } + } ), + ] ); + + $container = $this->builder->build(); + $color = $container->get( ColorManager::class )->get_color(); + + $this->assertSame( $color_state, $color ); + } + + public function test_maps_multiple_strategies_at_once_using_class_constants(): void { + $this->builder->addDefinitions( [ + RedColorManager::class => Container\autowire() + ->contextualParameter( Color::class, Red::class ), + + BlueColorManager::class => Container\autowire() + ->contextualParameter( Color::class, Blue::class ), + + GreenColorManager::class => Container\autowire() + ->contextualParameter( Color::class, Green::class ), + ] ); + + $container = $this->builder->build(); + + $red = $container->get( RedColorManager::class )->get_color(); + $blue = $container->get( BlueColorManager::class )->get_color(); + $green = $container->get( GreenColorManager::class )->get_color(); + + $this->assertSame( 'red', $red ); + $this->assertSame( 'blue', $blue ); + $this->assertSame( 'green', $green ); + } + + public function test_maps_multiple_strategies_at_once_using_callables(): void { + $this->builder->addDefinitions( [ + RedColorManager::class => Container\autowire() + ->contextualParameter( Color::class, static function () { + return new Red(); + } ), + BlueColorManager::class => Container\autowire() + ->contextualParameter( Color::class, static function () { + return new Blue(); + } ), + GreenColorManager::class => Container\autowire() + ->contextualParameter( Color::class, static function () { + return new Green(); + } ), + ] ); + + $container = $this->builder->build(); + + $red = $container->get( RedColorManager::class )->get_color(); + $blue = $container->get( BlueColorManager::class )->get_color(); + $green = $container->get( GreenColorManager::class )->get_color(); + + $this->assertSame( 'red', $red ); + $this->assertSame( 'blue', $blue ); + $this->assertSame( 'green', $green ); + } + + public function test_maps_multiple_strategies_at_once_using_callables_with_container_injection(): void { + $this->builder->addDefinitions( [ + RedColorManager::class => Container\autowire() + ->contextualParameter( Color::class, static function ( ContainerInterface $c ) { + return $c->get( Red::class ); + } ), + + BlueColorManager::class => Container\autowire() + ->contextualParameter( Color::class, static function ( ContainerInterface $c ) { + return $c->get( Blue::class ); + } ), + + GreenColorManager::class => Container\autowire() + ->contextualParameter( Color::class, static function ( ContainerInterface $c ) { + return $c->get( Green::class ); + } ), + ] ); + + $container = $this->builder->build(); + + $red = $container->get( RedColorManager::class )->get_color(); + $blue = $container->get( BlueColorManager::class )->get_color(); + $green = $container->get( GreenColorManager::class )->get_color(); + + $this->assertSame( 'red', $red ); + $this->assertSame( 'blue', $blue ); + $this->assertSame( 'green', $green ); + } + + public function test_it_maps_multiple_dependencies(): void { + $this->builder->addDefinitions( [ + MultipleDependencyClass::class => Container\autowire() + ->contextualParameter( Color::class, Red::class ) + ->contextualParameter( Task::class, SampleTask::class ) + ->constructorParameter( 'test_string', 'hello' ) + ] ); + + $container = $this->builder->build(); + $instance = $container->get( MultipleDependencyClass::class ); + + $this->assertInstanceOf( SampleTask::class, $instance->get_task() ); + $this->assertInstanceOf( Red::class, $instance->get_color() ); + $this->assertSame( 'hello', $instance->get_test_string() ); + } + + public function test_it_maps_multiple_dependencies_with_method_setting(): void { + $this->builder->addDefinitions( [ + MultipleDependencyClass::class => Container\autowire() + ->contextualParameter( Color::class, Red::class ) + ->contextualParameter( Task::class, SampleTask::class ) + ->constructorParameter( 'test_string', 'hello' ) + ->method( 'set_color', new Green() ), + ] ); + + $container = $this->builder->build(); + $instance = $container->get( MultipleDependencyClass::class ); + + $this->assertInstanceOf( SampleTask::class, $instance->get_task() ); + $this->assertInstanceOf( Green::class, $instance->get_color() ); + $this->assertSame( 'hello', $instance->get_test_string() ); + } + +} diff --git a/tests/integration/Tribe/Libs/Support/Contextual/MultipleDependencyClass.php b/tests/integration/Tribe/Libs/Support/Contextual/MultipleDependencyClass.php new file mode 100644 index 00000000..e9e83950 --- /dev/null +++ b/tests/integration/Tribe/Libs/Support/Contextual/MultipleDependencyClass.php @@ -0,0 +1,47 @@ +task = $task; + $this->color = $color; + $this->test_string = $test_string; + } + + public function get_task(): Task { + return $this->task; + } + + public function get_color(): Color { + return $this->color; + } + + public function get_test_string(): string { + return $this->test_string; + } + + public function set_color( Color $color ): void { + $this->color = $color; + } + +} diff --git a/tests/integration/Tribe/Libs/Support/Contextual/Strategy/Color.php b/tests/integration/Tribe/Libs/Support/Contextual/Strategy/Color.php new file mode 100644 index 00000000..a9d2315f --- /dev/null +++ b/tests/integration/Tribe/Libs/Support/Contextual/Strategy/Color.php @@ -0,0 +1,14 @@ +color = $color; + } + + public function get_color(): string { + return $this->color->get(); + } + +} diff --git a/tests/integration/Tribe/Libs/Support/Contextual/Strategy/Colors/Blue.php b/tests/integration/Tribe/Libs/Support/Contextual/Strategy/Colors/Blue.php new file mode 100644 index 00000000..03d22605 --- /dev/null +++ b/tests/integration/Tribe/Libs/Support/Contextual/Strategy/Colors/Blue.php @@ -0,0 +1,13 @@ +