diff --git a/psalm.xml.dist b/psalm.xml.dist index 113f41bb..bc971413 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -6,6 +6,7 @@ xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" errorBaseline="psalm-baseline.xml" findUnusedPsalmSuppress="true" + findUnusedBaselineEntry="true" findUnusedCode="true" > diff --git a/src/Config.php b/src/Config.php index ea3dc590..a4b1cf5e 100644 --- a/src/Config.php +++ b/src/Config.php @@ -74,11 +74,13 @@ * For classes known from the definitions, a type preference might be the * better approach * + * @deprecated Since 3.16.0. This class will be removed in 4.0. Please use the immutable and typesafe + * {@link Config\InjectionConfig} variant. + * * @see \Laminas\Di\Resolver\ValueInjection A container to force injection of a value * @see \Laminas\Di\Resolver\TypeInjection A container to force looking up a specific type instance for injection * * @final - * * @psalm-type TypeConfigArray = array{ * typeOf?: class-string|null, * preferences?: array|null, diff --git a/src/Config/AliasConfig.php b/src/Config/AliasConfig.php new file mode 100644 index 00000000..9d6c8168 --- /dev/null +++ b/src/Config/AliasConfig.php @@ -0,0 +1,17 @@ + + * return [ + * // This section provides global type preferences. + * // Those are visited if a specific instance has no preference definitions. + * 'preferences' => [ + * // The key is the requested class or interface name, the values are + * // the types the dependency injector should prefer. + * Some\Interface::class => Some\Preference::class + * ], + * // This configures the instantiation of specific types. + * // Types may also be purely virtual by defining the aliasOf key. + * 'types' => [ + * My\Class::class => [ + * 'preferences' => [ + * // this supercedes the global type preferences + * // when My\Class is instantiated + * Some\Interface::class => 'My.SpecificAlias' + * ], + * + * // instantiation parameters. These will only be used for + * // the instantiator (i.e. the constructor) + * 'parameters' => [ + * 'foo' => My\FooImpl::class, // Use the given type to provide the injection (depends on definition) + * 'bar' => '*' // Use the type preferences + * ], + * ], + * + * 'My.Alias' => [ + * // typeOf defines virtual classes which can be used as type + * // preferences or for newInstance calls. They allow providing + * // custom configs for a class + * 'typeOf' => Some\Class::class, + * 'preferences' => [ + * Foo::class => Bar::class + * ] + * ] + * ] + * ]; + * + * + * ## Notes on Injections + * + * Named arguments and Automatic type lookups will only work for Methods that + * are known to the dependency injector through its definitions. Injections for + * unknown methods do not perform type lookups on its own. + * + * A value injection without any lookups can be forced by providing a + * Resolver\ValueInjection instance. + * + * To force a service/class instance provide a Resolver\TypeInjection instance. + * For classes known from the definitions, a type preference might be the + * better approach + * + * @see \Laminas\Di\Resolver\ValueInjection A container to force injection of a value + * @see \Laminas\Di\Resolver\TypeInjection A container to force looking up a specific type instance for injection + */ +final readonly class InjectionConfig implements ConfigInterface +{ + /** + * @param array $types + */ + public function __construct( + private TypePreferences $preferences = new TypePreferences(), + private array $types = [] + ) { + } + + /** + * Constructs the injection config from a laminas config value + */ + public static function fromConfigValue(mixed $config): self + { + if (! is_array($config) && ! $config instanceof ArrayAccess) { + throw new InvalidConfigException( + sprintf( + 'Di configuration must be an array or implement ArrayAccess, got %s', + get_debug_type($config), + ), + ); + } + + return new self( + TypePreferences::fromConfigValue($config['preferences'] ?? []), + TypeConfig::mapFromConfigValue($config['types'] ?? []), + ); + } + + private function getTypeConfig(string $name): TypeConfig|null + { + $type = $this->types[$name] ?? null; + return $type instanceof AliasConfig ? $type->type : $type; + } + + public function isAlias(string $name): bool + { + $type = $this->types[$name] ?? null; + return $type instanceof AliasConfig; + } + + public function getConfiguredTypeNames(): array + { + return array_keys($this->types); + } + + public function getClassForAlias(string $name): string|null + { + return $this->getTypeConfig($name)?->getClassName(); + } + + public function getParameters(string $type): array + { + /** + * Psalm-Bug: https://github.com/vimeo/psalm/issues/7099 + * + * @psalm-suppress RedundantCondition + * @psalm-suppress TypeDoesNotContainNull + */ + return $this->getTypeConfig($type)?->parameters->toArray() ?? []; + } + + public function setParameters(string $type, array $params) + { + throw new LogicException( + 'Injection config is considered immutable. You can set [dependencies][auto][mutableConfig] to ' + . 'true to restore the previous, but deprecated, behavior' + ); + } + + public function getTypePreference(string $type, string|null $contextClass = null): string|null + { + if ($contextClass !== null) { + $preference = $this->getTypeConfig($contextClass)?->preferences->getPreferenceFor($type); + + if ($preference !== null) { + return $preference; + } + } + + return $this->preferences->getPreferenceFor($type); + } +} diff --git a/src/Config/Parameter.php b/src/Config/Parameter.php new file mode 100644 index 00000000..8ad7ff4f --- /dev/null +++ b/src/Config/Parameter.php @@ -0,0 +1,32 @@ + */ + private array $parameters; + + public function __construct(Parameter ...$parameters) + { + $map = []; + + foreach ($parameters as $parameter) { + $map[$parameter->name] = $parameter; + } + + $this->parameters = $map; + } + + /** + * @param mixed $parameters the parameters array from the laminas config + * @throws InvalidParametersException When a numeric key is encountered. + */ + public static function fromConfigValue(mixed $parameters): self + { + if (! is_iterable($parameters)) { + throw new InvalidParametersException( + sprintf( + 'Injection parameters must be an array, got %s', + get_debug_type($parameters) + ), + ); + } + + return new self( + ...array_values( + Util::mapIterable( + $parameters, + static function (mixed $value, string|int $key): Parameter { + if ($value instanceof Parameter) { + return $value; + } + + if (! is_string($key)) { + throw InvalidParametersException::numericParamKey($key); + } + + return Parameter::fromValue($key, $value); + }, + ), + ), + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return array_map( + static fn (Parameter $parameter) => $parameter->injection, + $this->parameters + ); + } + + public function get(string $key): Parameter | null + { + return $this->parameters[$key] ?? null; + } +} diff --git a/src/Config/TypeConfig.php b/src/Config/TypeConfig.php new file mode 100644 index 00000000..1fd7c25d --- /dev/null +++ b/src/Config/TypeConfig.php @@ -0,0 +1,134 @@ +|null, + * parameters?: array|null + * } + */ +final readonly class TypeConfig +{ + /** + * @param string $name The name of the configured type. + */ + public function __construct( + public string $name, + public TypePreferences $preferences = new TypePreferences(), + public ParameterMap $parameters = new ParameterMap(), + ) { + } + + /** + * Returns the class name for this type config + * + * @throws UndefinedClassException When the configured type does not exist. + * @return class-string + */ + public function getClassName(): string + { + if (! class_exists($this->name) && ! interface_exists($this->name)) { + throw new UndefinedClassException($this->name); + } + + return $this->name; + } + + /** + * @return array $config + */ + public static function mapFromConfigValue(mixed $config): array + { + if (! is_iterable($config)) { + throw new InvalidConfigException('The di type configuration must be iterable'); + } + + $map = []; + + /** @var mixed $value */ + foreach ($config as $key => $value) { + if (! is_string($key)) { + throw new InvalidConfigException( + sprintf( + 'A type configuration key must be a string, got %s', + get_debug_type($key), + ), + ); + } + + if (! is_array($value) && ! $value instanceof ArrayAccess) { + throw new InvalidConfigException( + sprintf( + 'Type configuration for "%s" is expected to be an array or array accessible object, got %s', + $key, + get_debug_type($value) + ), + ); + } + + /** @var string|null $alias */ + $alias = null; + $className = $key; + + try { + $preferences = TypePreferences::fromConfigValue($value['preferences'] ?? []); + $parameters = ParameterMap::fromConfigValue($value['parameters'] ?? []); + } catch (InvalidTypePreferenceException $preferencesException) { + throw new InvalidConfigException( + sprintf( + 'Invalid type preferences for "%s": %s', + $key, + $preferencesException->getMessage(), + ), + $preferencesException->getCode(), + $preferencesException, + ); + } catch (InvalidParametersException $paramsException) { + throw new InvalidConfigException( + sprintf( + 'Invalid parameter config for "%s": %s', + $key, + $paramsException->getMessage(), + ), + $paramsException->getCode(), + $paramsException, + ); + } + + if (isset($value['typeOf'])) { + assert(is_string($value['typeOf'])); + $alias = $className; + $className = $value['typeOf']; + } + + $type = new self($className, $preferences, $parameters); + + if ($alias !== null) { + $type = new AliasConfig($alias, $type); + } + + $map[$type->name] = $type; + } + + return $map; + } +} diff --git a/src/Config/TypePreferences.php b/src/Config/TypePreferences.php new file mode 100644 index 00000000..1766c8f0 --- /dev/null +++ b/src/Config/TypePreferences.php @@ -0,0 +1,66 @@ + $preferences + */ + public function __construct( + public array $preferences = [], + ) { + } + + /** + * Converts the laminas config array to a type save variant + * + * @param mixed $preferences The value from the laminas config array + * @throws InvalidTypePreferenceException When the config array contains unexpected values. + */ + public static function fromConfigValue(mixed $preferences): self + { + if (! is_iterable($preferences)) { + throw new InvalidTypePreferenceException( + sprintf( + 'Type preferences mus be an iterable item, got "%s".', + get_debug_type($preferences), + ), + ); + } + + return new self(Util::mapIterable( + $preferences, + static fn (mixed $item, string|int $key): string => is_string($item) || $item instanceof Stringable + ? "$item" + : throw new InvalidTypePreferenceException( + sprintf( + 'A type preference for %s must be a string, got %s.', + (string) $key, + get_debug_type($item), + ), + ), + )); + } + + /** + * Returns the configured preference of a requested type + * + * @param string $type The requested type to resolve + */ + public function getPreferenceFor(string $type): string | null + { + return $this->preferences[$type] ?? null; + } +} diff --git a/src/ConfigInterface.php b/src/ConfigInterface.php index a6e3092e..c4cd6f00 100644 --- a/src/ConfigInterface.php +++ b/src/ConfigInterface.php @@ -15,6 +15,8 @@ interface ConfigInterface public function isAlias(string $name): bool; /** + * @deprecated Since 3.16.0, This method will be removed in version 4.0 + * * @return string[] */ public function getConfiguredTypeNames(): array; @@ -24,7 +26,7 @@ public function getConfiguredTypeNames(): array; * * @return class-string|null */ - public function getClassForAlias(string $name): ?string; + public function getClassForAlias(string $name): string|null; /** * Returns the instantiation parameters for the given type @@ -37,6 +39,8 @@ public function getParameters(string $type): array; /** * Set the instantiation parameters for the given type * + * @deprecated Since 3.16.0. This method will be removed in version 4.0 where configuration objects are immutable + * * @param array $params * @return mixed */ @@ -45,5 +49,5 @@ public function setParameters(string $type, array $params); /** * Configured type preference */ - public function getTypePreference(string $type, ?string $contextClass = null): ?string; + public function getTypePreference(string $type, string|null $contextClass = null): string|null; } diff --git a/src/Container/ConfigFactory.php b/src/Container/ConfigFactory.php index 59dc6a5c..066724e8 100644 --- a/src/Container/ConfigFactory.php +++ b/src/Container/ConfigFactory.php @@ -27,7 +27,6 @@ class ConfigFactory { /** * @psalm-suppress MixedArrayAccess - * @return Config */ public function create(ContainerInterface $container): ConfigInterface { @@ -55,7 +54,12 @@ public function create(ContainerInterface $container): ConfigInterface $data = array_merge_recursive($legacyConfig->toArray(), $data); } - return new Config($data); + /** @var mixed $mutable */ + $mutable = $data['mutableConfig'] ?? false; + + return $mutable === true + ? new Config($data) + : Config\InjectionConfig::fromConfigValue($data); } /** diff --git a/src/Util.php b/src/Util.php new file mode 100644 index 00000000..6559ab78 --- /dev/null +++ b/src/Util.php @@ -0,0 +1,42 @@ + $array + * @param callable(ItemType, KeyType):MappedType $mapValue + * @param (callable(mixed, ItemType):KeyType)|null $mapKey + * @return array + */ + public static function mapIterable(iterable $array, callable $mapValue, callable|null $mapKey = null): array + { + $mapped = []; + + /** @psalm-var KeyType $key Assume key type when mapper accepts it without throwing a TypeError */ + foreach ($array as $key => $value) { + if ($mapKey !== null) { + $key = $mapKey($key, $value); + } + + $mapped[$key] = $mapValue($value, $key); + } + + return $mapped; + } +} diff --git a/test/Config/ParameterMapTest.php b/test/Config/ParameterMapTest.php new file mode 100644 index 00000000..9c907bba --- /dev/null +++ b/test/Config/ParameterMapTest.php @@ -0,0 +1,79 @@ + 'Type', + 'autowired' => '*', + 'valueArray' => [], + 'valueObject' => new stdClass(), + 'explicitValue' => new ValueInjection('value'), + 'explicitType' => new TypeInjection('Type'), + $explicitParam, + ]; + $expectedKeys = [ + 'implicitType', + 'autowired', + 'valueArray', + 'valueObject', + 'explicitValue', + 'explicitType', + 'explicitParam', + ]; + + $parameterMap = ParameterMap::fromConfigValue($input); + + self::assertEquals($expectedKeys, array_keys($parameterMap->toArray())); + + foreach ($expectedKeys as $key) { + self::assertSame($key, $parameterMap->get($key)?->name); + } + + self::assertSame('Type', $parameterMap->get('implicitType')?->injection); + self::assertSame('*', $parameterMap->get('autowired')?->injection); + self::assertSame($input['explicitValue'], $parameterMap->get('explicitValue')?->injection); + self::assertSame($input['explicitType'], $parameterMap->get('explicitType')?->injection); + self::assertSame($explicitParam, $parameterMap->get('explicitParam')); + self::assertEquals(new ValueInjection([]), $parameterMap->get('valueArray')?->injection); + self::assertEquals(new ValueInjection(new stdClass()), $parameterMap->get('valueObject')?->injection); + } + + /** + * @return iterable + */ + public static function provideInvalidConfigValuesTestData(): iterable + { + return [ + 'non-iterable' => ['string'], + 'numeric-key' => [ + [1 => 'ImplicitType'], + ], + ]; + } + + /** + * @dataProvider provideInvalidConfigValuesTestData + */ + public function testShouldThrowOnInvalidConfigValue(mixed $input): void + { + $this->expectException(InvalidParametersException::class); + ParameterMap::fromConfigValue($input); + } +} diff --git a/test/Config/ParameterTest.php b/test/Config/ParameterTest.php new file mode 100644 index 00000000..8c7ef908 --- /dev/null +++ b/test/Config/ParameterTest.php @@ -0,0 +1,65 @@ + + */ + public static function provideImplicitValueInjectionTestData(): iterable + { + return [ + 'bool' => ['A', true], + 'int' => ['A', 12], + 'object' => ['A', new stdClass()], + ]; + } + + /** + * @dataProvider provideImplicitValueInjectionTestData + */ + public function testShouldBuildImplicitValueInjectionFromConfig(string $name, mixed $value): void + { + $parameter = Parameter::fromValue($name, $value); + $containerMock = $this->getMockBuilder(ContainerInterface::class)->getMock(); + + self::assertSame($name, $parameter->name); + self::assertInstanceOf(ValueInjection::class, $parameter->injection); + self::assertSame($value, $parameter->injection->toValue($containerMock)); + } + + /** + * @return iterable + */ + public static function provideExplicitInjectionTestData(): iterable + { + return [ + 'string' => ['A', 'SomeService'], + 'wildcard' => ['A', '*'], + 'TypeInjection' => ['A', new TypeInjection('SomeService')], + 'ValueInjection' => ['A', new ValueInjection('SomeValue')], + ]; + } + + /** + * @dataProvider provideExplicitInjectionTestData + */ + public function testShouldKeepExplicitInjectionInfoFromConfig(string $name, string|InjectionInterface $value): void + { + $parameter = Parameter::fromValue($name, $value); + + self::assertSame($name, $parameter->name); + self::assertSame($value, $parameter->injection); + } +} diff --git a/test/Config/TypeConfigTest.php b/test/Config/TypeConfigTest.php new file mode 100644 index 00000000..34f4fcda --- /dev/null +++ b/test/Config/TypeConfigTest.php @@ -0,0 +1,127 @@ + [ + 'preferences' => [ + 'Type' => 'PreferredType', + ], + 'parameters' => [ + 'implicitType' => 'Type', + 'implicitValue' => true, + ], + ], + 'AliasConfig' => [ + 'typeOf' => 'SomeClass', + 'preferences' => [ + 'Type' => 'OtherType', + ], + 'parameters' => [ + 'implicitType' => 'OtherInjectedType', + 'implicitValue' => 17, + ], + ], + ]); + + $this->assertEquals( + [ + 'TypeConfig' => new TypeConfig( + 'TypeConfig', + new TypePreferences([ + 'Type' => 'PreferredType', + ]), + new ParameterMap( + new Parameter('implicitType', 'Type'), + new Parameter('implicitValue', new ValueInjection(true)), + ), + ), + 'AliasConfig' => new AliasConfig( + 'AliasConfig', + new TypeConfig( + 'SomeClass', + new TypePreferences([ + 'Type' => 'OtherType', + ]), + new ParameterMap( + new Parameter('implicitType', 'OtherInjectedType'), + new Parameter('implicitValue', new ValueInjection(17)), + ), + ), + ), + ], + $config + ); + } + + /** + * @return iterable + */ + public static function provideInvalidConfigInputTestData(): iterable + { + return [ + 'non-iterable' => [new stdClass()], + 'numeric type key' => [[1 => []]], + 'bad type config' => [['TypeName' => new stdClass()]], + ]; + } + + /** + * @dataProvider provideInvalidConfigInputTestData + */ + public function testShouldThrowOnInvalidConfigInput(mixed $input): void + { + $this->expectException(InvalidConfigException::class); + TypeConfig::mapFromConfigValue($input); + } + + public function testGetClassShouldThrowWhenClassDoesNotExist(): void + { + $config = new TypeConfig('UndefinedClass'); + + $this->expectException(UndefinedClassException::class); + $config->getClassName(); + } + + /** + * @return iterable + */ + public static function provideExistingTypes(): iterable + { + $types = [ + Traversable::class, + Injector::class, + ]; + + foreach ($types as $type) { + yield $type => [$type]; + } + } + + /** + * @dataProvider provideExistingTypes + */ + public function testGetClassShouldReturnExistingClasses(string $name): void + { + self::assertSame($name, (new TypeConfig($name))->getClassName()); + } +} diff --git a/test/Config/TypePreferencesTest.php b/test/Config/TypePreferencesTest.php new file mode 100644 index 00000000..ab2b6a26 --- /dev/null +++ b/test/Config/TypePreferencesTest.php @@ -0,0 +1,61 @@ + + */ + public static function provideInvalidConfigValues(): iterable + { + return [ + 'non-array' => ['Test'], + 'int-value' => [ + ['Test' => 1234], + ], + 'array-value' => [ + ['Test' => ['a', 'b']], + ], + ]; + } + + /** + * @dataProvider provideInvalidConfigValues + */ + public function testShouldThrowOnInvalidConfigValue(mixed $value): void + { + $this->expectException(InvalidTypePreferenceException::class); + TypePreferences::fromConfigValue($value); + } + + public function testShouldCreateFromConfigValue(): void + { + $c = new class () implements Stringable { + public function __toString(): string + { + return 'c'; + } + }; + + $config = TypePreferences::fromConfigValue([ + 'a' => 'b', + 'b' => $c, + ]); + + self::assertEquals( + [ + 'a' => 'b', + 'b' => 'c', + ], + $config->preferences, + ); + } +}