From b2f0530a5d5baa37c7dae576d5be32e544feee1e Mon Sep 17 00:00:00 2001 From: sviat-radius Date: Wed, 8 Apr 2026 19:37:39 +0200 Subject: [PATCH 01/10] Add YAML metadata driver for settings configuration Introduce a metadata driver abstraction (following Doctrine ORM's pattern) that allows defining settings metadata in YAML files instead of PHP attributes. This separates the application layer (plain PHP settings classes) from the infrastructure layer (YAML configuration), enabling cleaner domain code. New components: - MetadataDriverInterface: contract for loading settings metadata from any source - AttributeDriver: extracts existing attribute-reading logic from MetadataManager - YamlDriver: reads settings metadata from YAML mapping files - ChainDriver: chains multiple drivers, attribute driver takes precedence The YAML driver is opt-in: configure `yaml_mapping_paths` in the bundle config and install symfony/yaml. Existing attribute-based configuration is unaffected. --- composer.json | 9 +- config/services.php | 25 ++ docs/configuration.md | 7 + docs/usage/yaml_configuration.md | 167 +++++++++ src/DependencyInjection/Configuration.php | 5 + .../JbtronicsSettingsExtension.php | 51 +++ src/Manager/SettingsRegistry.php | 353 ++++++++++-------- src/Metadata/Driver/AttributeDriver.php | 123 ++++++ src/Metadata/Driver/ChainDriver.php | 103 +++++ .../Driver/MetadataDriverInterface.php | 77 ++++ src/Metadata/Driver/YamlDriver.php | 236 ++++++++++++ src/Metadata/MetadataManager.php | 108 ++---- .../Settings/YamlConfiguredSettings.php | 48 +++ .../Fixtures/Settings/YamlEmbeddedTarget.php | 20 + .../Settings/YamlWithEmbeddedSettings.php | 35 ++ ...tures.Settings.YamlConfiguredSettings.yaml | 26 ++ ...res.Settings.YamlWithEmbeddedSettings.yaml | 15 + tests/Metadata/Driver/AttributeDriverTest.php | 81 ++++ tests/Metadata/Driver/ChainDriverTest.php | 93 +++++ tests/Metadata/Driver/YamlDriverTest.php | 133 +++++++ 20 files changed, 1474 insertions(+), 241 deletions(-) create mode 100644 docs/usage/yaml_configuration.md create mode 100644 src/Metadata/Driver/AttributeDriver.php create mode 100644 src/Metadata/Driver/ChainDriver.php create mode 100644 src/Metadata/Driver/MetadataDriverInterface.php create mode 100644 src/Metadata/Driver/YamlDriver.php create mode 100644 tests/Fixtures/Settings/YamlConfiguredSettings.php create mode 100644 tests/Fixtures/Settings/YamlEmbeddedTarget.php create mode 100644 tests/Fixtures/Settings/YamlWithEmbeddedSettings.php create mode 100644 tests/Fixtures/yaml_mappings/Jbtronics.SettingsBundle.Tests.Fixtures.Settings.YamlConfiguredSettings.yaml create mode 100644 tests/Fixtures/yaml_mappings/Jbtronics.SettingsBundle.Tests.Fixtures.Settings.YamlWithEmbeddedSettings.yaml create mode 100644 tests/Metadata/Driver/AttributeDriverTest.php create mode 100644 tests/Metadata/Driver/ChainDriverTest.php create mode 100644 tests/Metadata/Driver/YamlDriverTest.php diff --git a/composer.json b/composer.json index 34973ab..572d72f 100644 --- a/composer.json +++ b/composer.json @@ -19,11 +19,15 @@ { "name": "Jan Böhmer", "email": "mail@jan-boehmer.de" + }, + { + "name": "Sviatoslav Vysitskyi" } ], "suggest": { "symfony/twig-bridge": "Allows to access settings in twig templates", - "doctrine/doctrine-bundle": "To use the doctrine ORM storage" + "doctrine/doctrine-bundle": "To use the doctrine ORM storage", + "symfony/yaml": "To use the YAML metadata driver for settings configuration" }, "require": { "php": "^8.1", @@ -51,7 +55,8 @@ "phpstan/phpstan-symfony": "^1.3", "doctrine/doctrine-bundle": "^2.11", "doctrine/orm": "^3.0", - "doctrine/doctrine-fixtures-bundle": "^3.5" + "doctrine/doctrine-fixtures-bundle": "^3.5", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "config": { "allow-plugins": { diff --git a/config/services.php b/config/services.php index 7ae0d36..11d2811 100644 --- a/config/services.php +++ b/config/services.php @@ -33,6 +33,10 @@ use Jbtronics\SettingsBundle\Manager\SettingsRegistryInterface; use Jbtronics\SettingsBundle\Manager\SettingsResetter; use Jbtronics\SettingsBundle\Manager\SettingsResetterInterface; +use Jbtronics\SettingsBundle\Metadata\Driver\AttributeDriver; +use Jbtronics\SettingsBundle\Metadata\Driver\ChainDriver; +use Jbtronics\SettingsBundle\Metadata\Driver\MetadataDriverInterface; +use Jbtronics\SettingsBundle\Metadata\Driver\YamlDriver; use Jbtronics\SettingsBundle\Metadata\MetadataManager; use Jbtronics\SettingsBundle\Metadata\MetadataManagerInterface; use Jbtronics\SettingsBundle\Migrations\SettingsMigrationInterface; @@ -60,11 +64,31 @@ $services->instanceof(\Jbtronics\SettingsBundle\Migrations\SettingsMigration::class) ->call('setParameterTypeRegistry', [service('jbtronics.settings.parameter_type_registry')]); + /********************************************************************************** + * Metadata Drivers + **********************************************************************************/ + $services->set('jbtronics.settings.metadata_driver.attribute', AttributeDriver::class); + + $services->set('jbtronics.settings.metadata_driver.yaml', YamlDriver::class) + ->args([ + '$yamlMappingPaths' => '%jbtronics.settings.yaml_mapping_paths%', + ]); + + $services->set('jbtronics.settings.metadata_driver', ChainDriver::class) + ->args([ + '$drivers' => [ + service('jbtronics.settings.metadata_driver.attribute'), + service('jbtronics.settings.metadata_driver.yaml'), + ], + ]); + $services->alias(MetadataDriverInterface::class, 'jbtronics.settings.metadata_driver'); + $services->set('jbtronics.settings.settings_registry', SettingsRegistry::class) ->args([ '$directories' => '%jbtronics.settings.search_paths%', '$cache' => service('jbtronics.settings.cache.metadata_service'), '$debug_mode' => '%kernel.debug%', + '$metadataDriver' => service('jbtronics.settings.metadata_driver'), ]); $services->alias(SettingsRegistryInterface::class, 'jbtronics.settings.settings_registry'); @@ -74,6 +98,7 @@ '$debug_mode' => '%kernel.debug%', '$settingsRegistry' => service('jbtronics.settings.settings_registry'), '$parameterTypeGuesser' => service('jbtronics.settings.parameter_type_guesser'), + '$metadataDriver' => service('jbtronics.settings.metadata_driver'), '$defaultStorageAdapter' => '%jbtronics.settings.default_storage_adapter%', '$defaultCacheable' => '%jbtronics.settings.cache.default_cacheable%', ]); diff --git a/docs/configuration.md b/docs/configuration.md index 9a75822..dca46a9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,6 +21,13 @@ jbtronics_settings: search_paths: - '%kernel.project_dir%/src/Settings' + # Directories containing YAML mapping files for settings classes. + # This allows defining settings metadata in YAML instead of PHP attributes. + # See the YAML Configuration documentation for more information. + yaml_mapping_paths: [] + # Example: + # - '%kernel.project_dir%/config/settings' + # The class name of the service, which is used on all storage adapters if # non is set explicitly. Can be null, if the storage adapter is configured # explicitly everywhere default_storage_adapter: ~ diff --git a/docs/usage/yaml_configuration.md b/docs/usage/yaml_configuration.md new file mode 100644 index 0000000..8213825 --- /dev/null +++ b/docs/usage/yaml_configuration.md @@ -0,0 +1,167 @@ +--- +title: YAML Configuration +layout: default +parent: Usage +nav_order: 2 +--- + +# YAML Configuration + +By default, settings classes are configured using PHP attributes (`#[Settings]`, `#[SettingsParameter]`, etc.). As an alternative, you can define settings metadata in YAML files. This allows you to keep your settings classes as plain PHP in the application layer, while the infrastructure-level configuration (storage adapters, parameter types, labels, etc.) lives in YAML files — following the same pattern as Doctrine ORM's YAML mapping. + +## Requirements + +Install the `symfony/yaml` package: + +```bash +composer require symfony/yaml +``` + +## Setup + +Configure the directories where your YAML mapping files are located in your bundle configuration: + +```yaml +# config/packages/jbtronics_settings.yaml + +jbtronics_settings: + yaml_mapping_paths: + - '%kernel.project_dir%/config/settings' +``` + +## Defining settings in YAML + +### Step 1: Create a plain PHP settings class + +Your settings class is a regular PHP class with typed properties. No attributes needed. + +```php +booleanNode('save_after_migration')->defaultTrue()->end() + ->arrayNode('yaml_mapping_paths') + ->defaultValue([]) + ->scalarPrototype()->end() + ->end() + ->end(); $this->addFileStorageConfiguration($rootNode); diff --git a/src/DependencyInjection/JbtronicsSettingsExtension.php b/src/DependencyInjection/JbtronicsSettingsExtension.php index e1aa9d0..26fe631 100644 --- a/src/DependencyInjection/JbtronicsSettingsExtension.php +++ b/src/DependencyInjection/JbtronicsSettingsExtension.php @@ -25,11 +25,13 @@ namespace Jbtronics\SettingsBundle\DependencyInjection; +use Jbtronics\SettingsBundle\Metadata\Driver\YamlDriver; use Jbtronics\SettingsBundle\Migrations\SettingsMigrationInterface; use Jbtronics\SettingsBundle\ParameterTypes\ParameterTypeInterface; use Jbtronics\SettingsBundle\Storage\StorageAdapterInterface; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; @@ -66,6 +68,7 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('jbtronics.settings.proxy_dir', $config['proxy_dir']); $container->setParameter('jbtronics.settings.proxy_namespace', $config['proxy_namespace']); $container->setParameter('jbtronics.settings.search_paths', $config['search_paths']); + $container->setParameter('jbtronics.settings.yaml_mapping_paths', $config['yaml_mapping_paths']); $container->setParameter('jbtronics.settings.default_storage_adapter', $config['default_storage_adapter']); $container->setParameter('jbtronics.settings.save_after_migration', $config['save_after_migration']); @@ -81,5 +84,53 @@ public function load(array $configs, ContainerBuilder $container): void $container->setAlias('jbtronics.settings.cache.metadata_service', $config['cache']['metadata_service']); $container->setParameter('jbtronics.settings.cache.ttl', $config['cache']['ttl']); $container->setParameter('jbtronics.settings.cache.invalidate_on_env_change', $config['cache']['invalidate_on_env_change']); + + // Register YAML-configured settings classes as injectable services + $this->registerYamlSettingsServices($container, $config['yaml_mapping_paths']); + } + + /** + * Eagerly discovers YAML-configured settings classes and registers them as services, + * so that they can be dependency-injected just like attribute-configured settings. + */ + private function registerYamlSettingsServices(ContainerBuilder $container, array $yamlMappingPaths): void + { + if (empty($yamlMappingPaths) || !class_exists(\Symfony\Component\Yaml\Yaml::class)) { + return; + } + + // Use the YamlDriver to discover classes at build time + $yamlDriver = new YamlDriver($yamlMappingPaths); + $classNames = $yamlDriver->getAllManagedClassNames(); + + foreach ($classNames as $className) { + $classMetadata = $yamlDriver->loadClassMetadata($className); + if ($classMetadata === null) { + continue; + } + + $dependencyInjectable = $classMetadata->dependencyInjectable; + + // Register the class as a service definition so the compiler pass can configure it + if (!$container->hasDefinition($className)) { + $definition = new Definition($className); + $definition->setAutoconfigured(true); + $container->setDefinition($className, $definition); + } + + $definition = $container->getDefinition($className); + + if (method_exists($definition, 'addResourceTag')) { //Symfony 7.3+ + $definition->addResourceTag(self::RESSOURCE_TAG_SETTINGS, [ + 'injectable' => $dependencyInjectable, + ]); + } else { + if ($dependencyInjectable) { + $definition->addTag(self::TAG_INJECTABLE_SETTINGS); + } else { + $definition->addTag(ConfigureInjectableSettingsPass::TAG_TO_REMOVE); + } + } + } } } \ No newline at end of file diff --git a/src/Manager/SettingsRegistry.php b/src/Manager/SettingsRegistry.php index f0c842d..472bb94 100644 --- a/src/Manager/SettingsRegistry.php +++ b/src/Manager/SettingsRegistry.php @@ -1,161 +1,192 @@ -debug_mode) { - return $this->getSettingsClassUncached(); - } - - return $this->cache->get(self::CACHE_KEY, function () { - return $this->getSettingsClassUncached(); - }); - } - private function getSettingsClassUncached(): array - { - $classes = $this->searchInPathes($this->directories); - - $tmp = []; - - //Determine the short name for each class - foreach ($classes as $class) { - $reflClass = new \ReflectionClass($class); - $attributes = $reflClass->getAttributes(Settings::class); - - if (count($attributes) > 0) { - $attribute = $attributes[0]; - /** @var Settings $settings */ - $settings = $attribute->newInstance(); - - $name = $settings->name ?? self::generateDefaultNameFromClassName($class); - - //Ensure that the name is unique - if (isset($tmp[$name])) { - throw new \InvalidArgumentException(sprintf('There is already a class with the name %s (%s)!', $name, $tmp[$name])); - } - - $tmp[$name] = $class; - } - } - - return $tmp; - } - - /** - * @param string[] $pathes - * @return string[] - */ - private function searchInPathes(array $pathes): array - { - $classes = []; - - foreach ($pathes as $path) { - //Skip non-existing directories - if (!is_dir($path)) { - continue; - } - - //Find all PHP classes in the given directories - $constructs = Constructs::fromDirectory($path); - $names = array_map(static function (Construct $construct): string { - return $construct->name(); - }, $constructs); - - $classes = array_merge($classes, $names); - } - - //Now filter out all classes, which donot have the #[Settings] attribute - $settings_classes = []; - - foreach ($classes as $class) { - $reflClass = new \ReflectionClass($class); - $attributes = $reflClass->getAttributes(Settings::class); - if (count($attributes) > 0) { - $settings_classes[] = $class; - } - } - - return $settings_classes; - } - - /** - * Generates a default name for the given class, based on the class name. - * This is used, if no name is configured in the #[ConfigClass] attribute. - * @param \ReflectionClass|string $class The class to generate the name for. Either given as classstring or as ReflectionClass - * @phpstan-param \ReflectionClass|class-string $class - * @return string - */ - public static function generateDefaultNameFromClassName(\ReflectionClass|string $class): string - { - if (is_string($class)) { - $reflectionClass = new \ReflectionClass($class); - } else { - $reflectionClass = $class; - } - - $tmp = $reflectionClass->getShortName(); - //Remove the "Settings" suffix - return strtolower(str_replace('Settings', '', $tmp)); - } - - public function getSettingsClassByName(string $name): string - { - $classes = $this->getSettingsClasses(); - if (!isset($classes[$name])) { - throw new \InvalidArgumentException(sprintf('The settings class with the name "%s" does not exist!', $name)); - } - - return $classes[$name]; - } -} \ No newline at end of file +debug_mode) { + return $this->getSettingsClassUncached(); + } + + return $this->cache->get(self::CACHE_KEY, function () { + return $this->getSettingsClassUncached(); + }); + } + private function getSettingsClassUncached(): array + { + $classes = $this->searchInPathes($this->directories); + + // Also discover classes from the metadata driver (e.g. YAML-configured classes) + if ($this->metadataDriver !== null) { + $driverClasses = $this->metadataDriver->getAllManagedClassNames(); + $classes = array_unique(array_merge($classes, $driverClasses)); + } + + $tmp = []; + + //Determine the short name for each class + foreach ($classes as $class) { + $name = $this->resolveSettingsName($class); + + if ($name === null) { + continue; + } + + //Ensure that the name is unique + if (isset($tmp[$name])) { + throw new \InvalidArgumentException(sprintf('There is already a class with the name %s (%s)!', $name, $tmp[$name])); + } + + $tmp[$name] = $class; + } + + return $tmp; + } + + /** + * Resolves the settings name for a given class, using either attributes or the metadata driver. + * @return string|null The name, or null if the class is not a settings class + */ + private function resolveSettingsName(string $class): ?string + { + $reflClass = new \ReflectionClass($class); + $attributes = $reflClass->getAttributes(Settings::class); + + if (count($attributes) > 0) { + /** @var Settings $settings */ + $settings = $attributes[0]->newInstance(); + return $settings->name ?? self::generateDefaultNameFromClassName($class); + } + + // Try the metadata driver for non-attribute classes (e.g. YAML) + if ($this->metadataDriver !== null && $this->metadataDriver->isSettingsClass($class)) { + $classMetadata = $this->metadataDriver->loadClassMetadata($class); + if ($classMetadata !== null) { + return $classMetadata->name ?? self::generateDefaultNameFromClassName($class); + } + } + + return null; + } + + /** + * @param string[] $pathes + * @return string[] + */ + private function searchInPathes(array $pathes): array + { + $classes = []; + + foreach ($pathes as $path) { + //Skip non-existing directories + if (!is_dir($path)) { + continue; + } + + //Find all PHP classes in the given directories + $constructs = Constructs::fromDirectory($path); + $names = array_map(static function (Construct $construct): string { + return $construct->name(); + }, $constructs); + + $classes = array_merge($classes, $names); + } + + //Now filter out all classes, which donot have the #[Settings] attribute + $settings_classes = []; + + foreach ($classes as $class) { + $reflClass = new \ReflectionClass($class); + $attributes = $reflClass->getAttributes(Settings::class); + if (count($attributes) > 0) { + $settings_classes[] = $class; + } + } + + return $settings_classes; + } + + /** + * Generates a default name for the given class, based on the class name. + * This is used, if no name is configured in the #[ConfigClass] attribute. + * @param \ReflectionClass|string $class The class to generate the name for. Either given as classstring or as ReflectionClass + * @phpstan-param \ReflectionClass|class-string $class + * @return string + */ + public static function generateDefaultNameFromClassName(\ReflectionClass|string $class): string + { + if (is_string($class)) { + $reflectionClass = new \ReflectionClass($class); + } else { + $reflectionClass = $class; + } + + $tmp = $reflectionClass->getShortName(); + //Remove the "Settings" suffix + return strtolower(str_replace('Settings', '', $tmp)); + } + + public function getSettingsClassByName(string $name): string + { + $classes = $this->getSettingsClasses(); + if (!isset($classes[$name])) { + throw new \InvalidArgumentException(sprintf('The settings class with the name "%s" does not exist!', $name)); + } + + return $classes[$name]; + } +} diff --git a/src/Metadata/Driver/AttributeDriver.php b/src/Metadata/Driver/AttributeDriver.php new file mode 100644 index 0000000..62ed7c6 --- /dev/null +++ b/src/Metadata/Driver/AttributeDriver.php @@ -0,0 +1,123 @@ +getAttributes(Settings::class); + + return count($attributes) > 0; + } + + public function loadClassMetadata(string $className): ?Settings + { + $reflClass = new \ReflectionClass($className); + $attributes = $reflClass->getAttributes(Settings::class); + + if (count($attributes) < 1) { + return null; + } + if (count($attributes) > 1) { + throw new \LogicException(sprintf('The class "%s" has more than one Settings attributes! Only one is allowed', $className)); + } + + return $attributes[0]->newInstance(); + } + + public function loadParameterMetadata(string $className): array + { + $parameters = []; + $reflProperties = PropertyAccessHelper::getProperties($className); + + foreach ($reflProperties as $reflProperty) { + $attributes = $reflProperty->getAttributes(SettingsParameter::class); + if (count($attributes) < 1) { + continue; + } + if (count($attributes) > 1) { + throw new \LogicException(sprintf( + 'The property "%s" of the class "%s" has more than one SettingsParameter attributes! Only one is allowed', + $reflProperty->getName(), + $className + )); + } + + $parameters[$reflProperty->getName()] = $attributes[0]->newInstance(); + } + + return $parameters; + } + + public function loadEmbeddedMetadata(string $className): array + { + $embeddeds = []; + $reflProperties = PropertyAccessHelper::getProperties($className); + + foreach ($reflProperties as $reflProperty) { + $attributes = $reflProperty->getAttributes(EmbeddedSettings::class); + if (count($attributes) < 1) { + continue; + } + if (count($attributes) > 1) { + throw new \LogicException(sprintf( + 'The property "%s" of the class "%s" has more than one EmbeddedSettings attributes! Only one is allowed', + $reflProperty->getName(), + $className + )); + } + + $embeddeds[$reflProperty->getName()] = $attributes[0]->newInstance(); + } + + return $embeddeds; + } + + public function getAllManagedClassNames(): array + { + // The attribute driver relies on directory scanning in SettingsRegistry, + // so it does not independently know about managed classes. + return []; + } +} diff --git a/src/Metadata/Driver/ChainDriver.php b/src/Metadata/Driver/ChainDriver.php new file mode 100644 index 0000000..71ab0ce --- /dev/null +++ b/src/Metadata/Driver/ChainDriver.php @@ -0,0 +1,103 @@ +drivers as $driver) { + if ($driver->isSettingsClass($className)) { + return true; + } + } + + return false; + } + + public function loadClassMetadata(string $className): ?Settings + { + foreach ($this->drivers as $driver) { + if ($driver->isSettingsClass($className)) { + return $driver->loadClassMetadata($className); + } + } + + return null; + } + + public function loadParameterMetadata(string $className): array + { + foreach ($this->drivers as $driver) { + if ($driver->isSettingsClass($className)) { + return $driver->loadParameterMetadata($className); + } + } + + return []; + } + + public function loadEmbeddedMetadata(string $className): array + { + foreach ($this->drivers as $driver) { + if ($driver->isSettingsClass($className)) { + return $driver->loadEmbeddedMetadata($className); + } + } + + return []; + } + + public function getAllManagedClassNames(): array + { + $classNames = []; + foreach ($this->drivers as $driver) { + foreach ($driver->getAllManagedClassNames() as $className) { + $classNames[$className] = true; + } + } + + return array_keys($classNames); + } +} diff --git a/src/Metadata/Driver/MetadataDriverInterface.php b/src/Metadata/Driver/MetadataDriverInterface.php new file mode 100644 index 0000000..59dad2f --- /dev/null +++ b/src/Metadata/Driver/MetadataDriverInterface.php @@ -0,0 +1,77 @@ + + */ + public function loadParameterMetadata(string $className): array; + + /** + * Load property-level embedded settings metadata for the given class. + * Returns an associative array keyed by property name. + * @param string $className The fully qualified class name + * @return array + */ + public function loadEmbeddedMetadata(string $className): array; + + /** + * Returns all class names this driver knows about. + * Used for discovery of settings classes that may not be found by directory scanning. + * @return string[] + */ + public function getAllManagedClassNames(): array; +} diff --git a/src/Metadata/Driver/YamlDriver.php b/src/Metadata/Driver/YamlDriver.php new file mode 100644 index 0000000..77400a6 --- /dev/null +++ b/src/Metadata/Driver/YamlDriver.php @@ -0,0 +1,236 @@ + App.Settings.TestSettings.yaml + * + * Note: callable envVarMapper is not supported in YAML configuration. + * Only class-string mappers (service references) can be used. + */ +final class YamlDriver implements MetadataDriverInterface +{ + /** @var array|null Parsed YAML config keyed by class name */ + private ?array $classConfigs = null; + + /** + * @param string[] $yamlMappingPaths Directories containing YAML mapping files + */ + public function __construct( + private readonly array $yamlMappingPaths, + ) { + if (!class_exists(Yaml::class)) { + throw new \LogicException('The symfony/yaml package is required to use the YAML metadata driver. Install it with: composer require symfony/yaml'); + } + } + + public function isSettingsClass(string $className): bool + { + $this->ensureInitialized(); + return isset($this->classConfigs[$className]); + } + + public function loadClassMetadata(string $className): ?Settings + { + $this->ensureInitialized(); + + if (!isset($this->classConfigs[$className])) { + return null; + } + + $config = $this->classConfigs[$className]; + + return new Settings( + name: $config['name'] ?? null, + storageAdapter: $config['storageAdapter'] ?? null, + storageAdapterOptions: $config['storageAdapterOptions'] ?? [], + groups: $config['groups'] ?? null, + version: isset($config['version']) ? (int) $config['version'] : null, + migrationService: $config['migrationService'] ?? null, + dependencyInjectable: $config['dependencyInjectable'] ?? true, + label: $config['label'] ?? null, + description: $config['description'] ?? null, + cacheable: $config['cacheable'] ?? null, + ); + } + + public function loadParameterMetadata(string $className): array + { + $this->ensureInitialized(); + + if (!isset($this->classConfigs[$className])) { + return []; + } + + $config = $this->classConfigs[$className]; + $parameters = []; + + foreach ($config['parameters'] ?? [] as $propertyName => $paramConfig) { + $envVarMode = EnvVarMode::INITIAL; + if (isset($paramConfig['envVarMode'])) { + $envVarMode = self::resolveEnvVarMode($paramConfig['envVarMode'], $propertyName, $className); + } + + $parameters[$propertyName] = new SettingsParameter( + type: $paramConfig['type'] ?? null, + name: $paramConfig['name'] ?? null, + label: $paramConfig['label'] ?? null, + description: $paramConfig['description'] ?? null, + options: $paramConfig['options'] ?? [], + formType: $paramConfig['formType'] ?? null, + formOptions: $paramConfig['formOptions'] ?? [], + nullable: $paramConfig['nullable'] ?? null, + groups: $paramConfig['groups'] ?? null, + envVar: $paramConfig['envVar'] ?? null, + envVarMode: $envVarMode, + envVarMapper: $paramConfig['envVarMapper'] ?? null, + cloneable: $paramConfig['cloneable'] ?? true, + ); + } + + return $parameters; + } + + public function loadEmbeddedMetadata(string $className): array + { + $this->ensureInitialized(); + + if (!isset($this->classConfigs[$className])) { + return []; + } + + $config = $this->classConfigs[$className]; + $embeddeds = []; + + foreach ($config['embeddedSettings'] ?? [] as $propertyName => $embedConfig) { + $embeddeds[$propertyName] = new EmbeddedSettings( + target: $embedConfig['target'] ?? null, + groups: $embedConfig['groups'] ?? null, + label: $embedConfig['label'] ?? null, + description: $embedConfig['description'] ?? null, + formOptions: $embedConfig['formOptions'] ?? null, + ); + } + + return $embeddeds; + } + + public function getAllManagedClassNames(): array + { + $this->ensureInitialized(); + return array_keys($this->classConfigs); + } + + private function ensureInitialized(): void + { + if ($this->classConfigs !== null) { + return; + } + + $this->classConfigs = []; + + foreach ($this->yamlMappingPaths as $path) { + if (!is_dir($path)) { + continue; + } + + $files = glob($path . '/*.yaml') ?: []; + $ymlFiles = glob($path . '/*.yml') ?: []; + $files = array_merge($files, $ymlFiles); + + foreach ($files as $file) { + $this->loadFile($file); + } + } + } + + private static function resolveEnvVarMode(string $value, string $propertyName, string $className): EnvVarMode + { + foreach (EnvVarMode::cases() as $case) { + if ($case->name === $value) { + return $case; + } + } + + throw new \InvalidArgumentException(sprintf( + 'Invalid envVarMode "%s" for parameter "%s" of class "%s". Valid values are: %s', + $value, + $propertyName, + $className, + implode(', ', array_map(static fn(EnvVarMode $m) => $m->name, EnvVarMode::cases())) + )); + } + + private function loadFile(string $file): void + { + $content = Yaml::parseFile($file); + + if (!is_array($content)) { + return; + } + + foreach ($content as $className => $config) { + if (!is_string($className) || !is_array($config)) { + throw new \InvalidArgumentException(sprintf( + 'Invalid YAML settings mapping in file "%s": top-level keys must be fully qualified class names mapping to configuration arrays.', + $file + )); + } + + if (!class_exists($className)) { + throw new \InvalidArgumentException(sprintf( + 'Class "%s" referenced in YAML settings mapping file "%s" does not exist.', + $className, + $file + )); + } + + if (isset($this->classConfigs[$className])) { + throw new \InvalidArgumentException(sprintf( + 'Duplicate YAML settings mapping for class "%s" found in file "%s". Each class may only be mapped once.', + $className, + $file + )); + } + + $this->classConfigs[$className] = $config; + } + } +} diff --git a/src/Metadata/MetadataManager.php b/src/Metadata/MetadataManager.php index 7ebfd4b..762b1e1 100644 --- a/src/Metadata/MetadataManager.php +++ b/src/Metadata/MetadataManager.php @@ -29,6 +29,7 @@ use Jbtronics\SettingsBundle\Helper\ProxyClassNameHelper; use Jbtronics\SettingsBundle\Manager\SettingsRegistry; use Jbtronics\SettingsBundle\Manager\SettingsRegistryInterface; +use Jbtronics\SettingsBundle\Metadata\Driver\MetadataDriverInterface; use Jbtronics\SettingsBundle\Settings\EmbeddedSettings; use Jbtronics\SettingsBundle\Settings\Settings; use Jbtronics\SettingsBundle\Settings\SettingsParameter; @@ -49,6 +50,7 @@ public function __construct( private readonly bool $debug_mode, private readonly SettingsRegistryInterface $settingsRegistry, private readonly ParameterTypeGuesserInterface $parameterTypeGuesser, + private readonly MetadataDriverInterface $metadataDriver, private readonly ?string $defaultStorageAdapter = null, private readonly bool $defaultCacheable = false, ) { @@ -56,18 +58,14 @@ public function __construct( public function isSettingsClass(string|object $className): bool { - //If the given name is not a class name, try to resolve the name via SettingsRegistry - if (!class_exists($className)) { + if (is_object($className)) { + $className = ProxyClassNameHelper::resolveEffectiveClass($className); + } elseif (!class_exists($className)) { + //If the given name is not a class name, try to resolve the name via SettingsRegistry $className = $this->settingsRegistry->getSettingsClassByName($className); } - //Check if the given class contains a #[ConfigClass] attribute. - //If yes, return true, otherwise return false. - - $reflClass = new \ReflectionClass($className); - $attributes = $reflClass->getAttributes(Settings::class); - - return count($attributes) > 0; + return $this->metadataDriver->isSettingsClass($className); } public function getSettingsMetadata(string|object $className): SettingsMetadata @@ -97,43 +95,34 @@ public function getSettingsMetadata(string|object $className): SettingsMetadata private function getMetadataUncached(string $className): SettingsMetadata { - //Retrieve the #[ConfigClass] attribute from the given class. - $reflClass = new \ReflectionClass($className); - $attributes = $reflClass->getAttributes(Settings::class); + $classAttribute = $this->metadataDriver->loadClassMetadata($className); - if (count($attributes) < 1) { - throw new \LogicException(sprintf('The class "%s" is not a config class. Add the #[Settings] attribute to the class.', - $className)); - } - if (count($attributes) > 1) { - throw new \LogicException(sprintf('The class "%s" has more than one Settings atrributes! Only one is allowed', + if ($classAttribute === null) { + throw new \LogicException(sprintf('The class "%s" is not a settings class. Add the #[Settings] attribute to the class or configure it via YAML.', $className)); } - /** @var Settings $classAttribute */ - $classAttribute = $attributes[0]->newInstance(); + $parameterAttributes = $this->metadataDriver->loadParameterMetadata($className); + $embeddedAttributes = $this->metadataDriver->loadEmbeddedMetadata($className); + $parameters = []; $embeddeds = []; + //Build parameter metadata from driver-provided attributes + foreach ($parameterAttributes as $propertyName => $attribute) { + $reflProperty = PropertyAccessHelper::getAccessibleReflectionProperty($className, $propertyName); + $parameterMetadata = $this->buildParameterMetadata($className, $reflProperty, $attribute, $classAttribute); + $parameters[] = $parameterMetadata; + } - //Retrieve all parameter attributes on the properties of the given class - $reflProperties = PropertyAccessHelper::getProperties($className); - foreach ($reflProperties as $reflProperty) { - - //Try to parse the parameter metadata from the property - $parameterMetadata = $this->parseParameterMetadata($className, $reflProperty, $classAttribute); - if ($parameterMetadata !== null) { - $parameters[] = $parameterMetadata; - } - - //Try to parse the embedded metadata from the property - $embeddedMetadata = $this->parseEmbeddedMetadata($className, $reflProperty, $classAttribute); - if ($embeddedMetadata !== null) { - $embeddeds[] = $embeddedMetadata; - } + //Build embedded metadata from driver-provided attributes + foreach ($embeddedAttributes as $propertyName => $attribute) { + $reflProperty = PropertyAccessHelper::getAccessibleReflectionProperty($className, $propertyName); + $embeddedMetadata = $this->buildEmbeddedMetadata($className, $reflProperty, $attribute, $classAttribute); + $embeddeds[] = $embeddedMetadata; } - //Ensure that the settings version is greather than 0 + //Ensure that the settings version is greater than 0 if ($classAttribute->version !== null && $classAttribute->version <= 0) { throw new \LogicException(sprintf("The version of the settings class %s must be greater than zero! %d given", $className, $classAttribute->version)); } @@ -159,29 +148,10 @@ className: $className, } /** - * Tries to parse the embedded metadata from the given property. If the property is not an embedded settings, null is returned. - * @param string $className - * @param \ReflectionProperty $reflProperty - * @param Settings $classAttribute - * @return EmbeddedSettingsMetadata|null + * Builds embedded metadata from the given EmbeddedSettings attribute and reflection property. */ - private function parseEmbeddedMetadata(string $className, \ReflectionProperty $reflProperty, Settings $classAttribute): ?EmbeddedSettingsMetadata + private function buildEmbeddedMetadata(string $className, \ReflectionProperty $reflProperty, EmbeddedSettings $attribute, Settings $classAttribute): EmbeddedSettingsMetadata { - $attributes = $reflProperty->getAttributes(EmbeddedSettings::class); - //Skip properties without a EmbeddedSettings attribute - if (count($attributes) < 1) { - return null; - } - - if (count($attributes) > 1) { - throw new \LogicException(sprintf('The property "%s" of the class "%s" has more than one EmbeddedSettings attributes! Only one is allowed', - $reflProperty->getName(), $className)); - } - - //Create a new instance of the attribute - /** @var EmbeddedSettings $attribute */ - $attribute = $attributes[0]->newInstance(); - //If the target class is not set explicitly, try to guess it from the property type $targetClass = $attribute->target; if ($targetClass === null) { @@ -207,28 +177,10 @@ className: $className, } /** - * Tries to parse the parameter metadata from the given property. If the property is not a settings parameter, null is returned. - * @param string $className - * @param \ReflectionProperty $reflProperty - * @param Settings $classAttribute - * @return ParameterMetadata|null + * Builds parameter metadata from the given SettingsParameter attribute and reflection property. */ - private function parseParameterMetadata(string $className, \ReflectionProperty $reflProperty, Settings $classAttribute): ?ParameterMetadata + private function buildParameterMetadata(string $className, \ReflectionProperty $reflProperty, SettingsParameter $attribute, Settings $classAttribute): ParameterMetadata { - $attributes = $reflProperty->getAttributes(SettingsParameter::class); - //Skip properties without a SettingsParameter attribute - if (count($attributes) < 1) { - return null; - } - if (count($attributes) > 1) { - throw new \LogicException(sprintf('The property "%s" of the class "%s" has more than one ConfigEntry attributes! Only one is allowed', - $reflProperty->getName(), $className)); - } - - //Add it to our list - /** @var SettingsParameter $attribute */ - $attribute = $attributes[0]->newInstance(); - //Try to guess type $type = $attribute->type ?? $this->parameterTypeGuesser->guessParameterType($reflProperty); if ($type === null) { @@ -318,4 +270,4 @@ private function resolveEmbeddedCascadeUncached(string $className, ?array $done_ return array_unique($done_classes); } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Settings/YamlConfiguredSettings.php b/tests/Fixtures/Settings/YamlConfiguredSettings.php new file mode 100644 index 0000000..02aa0e6 --- /dev/null +++ b/tests/Fixtures/Settings/YamlConfiguredSettings.php @@ -0,0 +1,48 @@ +name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getCount(): ?int + { + return $this->count; + } + + public function setCount(?int $count): void + { + $this->count = $count; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function setEnabled(bool $enabled): void + { + $this->enabled = $enabled; + } +} diff --git a/tests/Fixtures/Settings/YamlEmbeddedTarget.php b/tests/Fixtures/Settings/YamlEmbeddedTarget.php new file mode 100644 index 0000000..1f322ed --- /dev/null +++ b/tests/Fixtures/Settings/YamlEmbeddedTarget.php @@ -0,0 +1,20 @@ +title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getChild(): ?YamlEmbeddedTarget + { + return $this->child; + } + + public function setChild(?YamlEmbeddedTarget $child): void + { + $this->child = $child; + } +} diff --git a/tests/Fixtures/yaml_mappings/Jbtronics.SettingsBundle.Tests.Fixtures.Settings.YamlConfiguredSettings.yaml b/tests/Fixtures/yaml_mappings/Jbtronics.SettingsBundle.Tests.Fixtures.Settings.YamlConfiguredSettings.yaml new file mode 100644 index 0000000..f692785 --- /dev/null +++ b/tests/Fixtures/yaml_mappings/Jbtronics.SettingsBundle.Tests.Fixtures.Settings.YamlConfiguredSettings.yaml @@ -0,0 +1,26 @@ +Jbtronics\SettingsBundle\Tests\Fixtures\Settings\YamlConfiguredSettings: + storageAdapter: Jbtronics\SettingsBundle\Storage\InMemoryStorageAdapter + name: yaml_configured + label: "YAML Configured Settings" + description: "Settings configured via YAML" + groups: + - admin + dependencyInjectable: true + cacheable: false + + parameters: + name: + type: Jbtronics\SettingsBundle\ParameterTypes\StringType + label: "Name" + description: "The name parameter" + + count: + type: Jbtronics\SettingsBundle\ParameterTypes\IntType + label: "Count" + nullable: true + + enabled: + type: Jbtronics\SettingsBundle\ParameterTypes\BoolType + label: "Enabled" + groups: + - general diff --git a/tests/Fixtures/yaml_mappings/Jbtronics.SettingsBundle.Tests.Fixtures.Settings.YamlWithEmbeddedSettings.yaml b/tests/Fixtures/yaml_mappings/Jbtronics.SettingsBundle.Tests.Fixtures.Settings.YamlWithEmbeddedSettings.yaml new file mode 100644 index 0000000..5c0a94a --- /dev/null +++ b/tests/Fixtures/yaml_mappings/Jbtronics.SettingsBundle.Tests.Fixtures.Settings.YamlWithEmbeddedSettings.yaml @@ -0,0 +1,15 @@ +Jbtronics\SettingsBundle\Tests\Fixtures\Settings\YamlWithEmbeddedSettings: + storageAdapter: Jbtronics\SettingsBundle\Storage\InMemoryStorageAdapter + name: yaml_with_embedded + label: "YAML With Embedded Settings" + + parameters: + title: + type: Jbtronics\SettingsBundle\ParameterTypes\StringType + label: "Title" + + embeddedSettings: + child: + target: Jbtronics\SettingsBundle\Tests\Fixtures\Settings\YamlEmbeddedTarget + label: "Child Settings" + description: "An embedded child" diff --git a/tests/Metadata/Driver/AttributeDriverTest.php b/tests/Metadata/Driver/AttributeDriverTest.php new file mode 100644 index 0000000..ad341af --- /dev/null +++ b/tests/Metadata/Driver/AttributeDriverTest.php @@ -0,0 +1,81 @@ +driver = new AttributeDriver(); + } + + public function testIsSettingsClassReturnsTrueForAnnotatedClass(): void + { + $this->assertTrue($this->driver->isSettingsClass(SimpleSettings::class)); + } + + public function testIsSettingsClassReturnsFalseForPlainClass(): void + { + $this->assertFalse($this->driver->isSettingsClass(YamlConfiguredSettings::class)); + } + + public function testIsSettingsClassReturnsFalseForNonExistentClass(): void + { + $this->assertFalse($this->driver->isSettingsClass('NonExistent\\Class')); + } + + public function testLoadClassMetadataReturnsSettingsForAnnotatedClass(): void + { + $settings = $this->driver->loadClassMetadata(SimpleSettings::class); + $this->assertInstanceOf(Settings::class, $settings); + $this->assertNotNull($settings->storageAdapter); + $this->assertEquals('Simple Settings', $settings->label); + } + + public function testLoadClassMetadataReturnsNullForPlainClass(): void + { + $this->assertNull($this->driver->loadClassMetadata(YamlConfiguredSettings::class)); + } + + public function testLoadParameterMetadataReturnsParameters(): void + { + $parameters = $this->driver->loadParameterMetadata(SimpleSettings::class); + + $this->assertNotEmpty($parameters); + // SimpleSettings has value1, value2, value3 + $this->assertArrayHasKey('value1', $parameters); + $this->assertArrayHasKey('value2', $parameters); + $this->assertArrayHasKey('value3', $parameters); + $this->assertContainsOnlyInstancesOf(SettingsParameter::class, $parameters); + } + + public function testLoadParameterMetadataReturnsEmptyForPlainClass(): void + { + $parameters = $this->driver->loadParameterMetadata(YamlConfiguredSettings::class); + $this->assertEmpty($parameters); + } + + public function testLoadEmbeddedMetadataReturnsEmbeddeds(): void + { + $embeddeds = $this->driver->loadEmbeddedMetadata(EmbedSettings::class); + $this->assertNotEmpty($embeddeds); + } + + public function testGetAllManagedClassNamesReturnsEmpty(): void + { + // AttributeDriver relies on directory scanning, not self-discovery + $this->assertEmpty($this->driver->getAllManagedClassNames()); + } +} diff --git a/tests/Metadata/Driver/ChainDriverTest.php b/tests/Metadata/Driver/ChainDriverTest.php new file mode 100644 index 0000000..ef2df11 --- /dev/null +++ b/tests/Metadata/Driver/ChainDriverTest.php @@ -0,0 +1,93 @@ +driver = new ChainDriver([ + new AttributeDriver(), + new YamlDriver([self::YAML_MAPPING_DIR]), + ]); + } + + public function testIsSettingsClassForAttributeClass(): void + { + $this->assertTrue($this->driver->isSettingsClass(SimpleSettings::class)); + } + + public function testIsSettingsClassForYamlClass(): void + { + $this->assertTrue($this->driver->isSettingsClass(YamlConfiguredSettings::class)); + } + + public function testIsSettingsClassReturnsFalseForUnknown(): void + { + $this->assertFalse($this->driver->isSettingsClass(\stdClass::class)); + } + + public function testLoadClassMetadataFromAttributes(): void + { + $settings = $this->driver->loadClassMetadata(SimpleSettings::class); + $this->assertNotNull($settings); + $this->assertEquals('Simple Settings', $settings->label); + } + + public function testLoadClassMetadataFromYaml(): void + { + $settings = $this->driver->loadClassMetadata(YamlConfiguredSettings::class); + $this->assertNotNull($settings); + $this->assertEquals('yaml_configured', $settings->name); + } + + public function testLoadClassMetadataReturnsNullForUnknown(): void + { + $this->assertNull($this->driver->loadClassMetadata(\stdClass::class)); + } + + public function testLoadParameterMetadataFromAttributes(): void + { + $params = $this->driver->loadParameterMetadata(SimpleSettings::class); + $this->assertNotEmpty($params); + $this->assertArrayHasKey('value1', $params); + } + + public function testLoadParameterMetadataFromYaml(): void + { + $params = $this->driver->loadParameterMetadata(YamlConfiguredSettings::class); + $this->assertNotEmpty($params); + $this->assertArrayHasKey('name', $params); + } + + public function testGetAllManagedClassNamesMergesFromAllDrivers(): void + { + $classNames = $this->driver->getAllManagedClassNames(); + + // AttributeDriver returns empty, YamlDriver returns its classes + $this->assertContains(YamlConfiguredSettings::class, $classNames); + } + + public function testAttributeDriverTakesPrecedence(): void + { + // If a class has both attributes and YAML, the attribute driver should win + // (because it's first in the chain) + $settings = $this->driver->loadClassMetadata(SimpleSettings::class); + $this->assertNotNull($settings); + // Verify it came from attributes (has the attribute-specific label) + $this->assertEquals('Simple Settings', $settings->label); + } +} diff --git a/tests/Metadata/Driver/YamlDriverTest.php b/tests/Metadata/Driver/YamlDriverTest.php new file mode 100644 index 0000000..10528cb --- /dev/null +++ b/tests/Metadata/Driver/YamlDriverTest.php @@ -0,0 +1,133 @@ +driver = new YamlDriver([self::YAML_MAPPING_DIR]); + } + + public function testIsSettingsClassReturnsTrueForYamlConfiguredClass(): void + { + $this->assertTrue($this->driver->isSettingsClass(YamlConfiguredSettings::class)); + } + + public function testIsSettingsClassReturnsFalseForAttributeConfiguredClass(): void + { + $this->assertFalse($this->driver->isSettingsClass(SimpleSettings::class)); + } + + public function testGetAllManagedClassNames(): void + { + $classNames = $this->driver->getAllManagedClassNames(); + + $this->assertContains(YamlConfiguredSettings::class, $classNames); + $this->assertContains(YamlWithEmbeddedSettings::class, $classNames); + $this->assertNotContains(SimpleSettings::class, $classNames); + } + + public function testLoadClassMetadata(): void + { + $settings = $this->driver->loadClassMetadata(YamlConfiguredSettings::class); + + $this->assertInstanceOf(Settings::class, $settings); + $this->assertEquals('yaml_configured', $settings->name); + $this->assertEquals(InMemoryStorageAdapter::class, $settings->storageAdapter); + $this->assertEquals('YAML Configured Settings', $settings->label); + $this->assertEquals('Settings configured via YAML', $settings->description); + $this->assertEquals(['admin'], $settings->groups); + $this->assertTrue($settings->dependencyInjectable); + $this->assertFalse($settings->cacheable); + } + + public function testLoadClassMetadataReturnsNullForUnknownClass(): void + { + $this->assertNull($this->driver->loadClassMetadata(SimpleSettings::class)); + } + + public function testLoadParameterMetadata(): void + { + $parameters = $this->driver->loadParameterMetadata(YamlConfiguredSettings::class); + + $this->assertCount(3, $parameters); + $this->assertArrayHasKey('name', $parameters); + $this->assertArrayHasKey('count', $parameters); + $this->assertArrayHasKey('enabled', $parameters); + + // Check the 'name' parameter + $nameParam = $parameters['name']; + $this->assertInstanceOf(SettingsParameter::class, $nameParam); + $this->assertEquals(StringType::class, $nameParam->type); + $this->assertEquals('Name', $nameParam->label); + $this->assertEquals('The name parameter', $nameParam->description); + + // Check the 'count' parameter (nullable) + $countParam = $parameters['count']; + $this->assertEquals(IntType::class, $countParam->type); + $this->assertTrue($countParam->nullable); + + // Check the 'enabled' parameter (with custom groups) + $enabledParam = $parameters['enabled']; + $this->assertEquals(BoolType::class, $enabledParam->type); + $this->assertEquals(['general'], $enabledParam->groups); + } + + public function testLoadParameterMetadataReturnsEmptyForUnknownClass(): void + { + $this->assertEmpty($this->driver->loadParameterMetadata(SimpleSettings::class)); + } + + public function testLoadEmbeddedMetadata(): void + { + $embeddeds = $this->driver->loadEmbeddedMetadata(YamlWithEmbeddedSettings::class); + + $this->assertCount(1, $embeddeds); + $this->assertArrayHasKey('child', $embeddeds); + + $child = $embeddeds['child']; + $this->assertInstanceOf(EmbeddedSettings::class, $child); + $this->assertEquals(YamlEmbeddedTarget::class, $child->target); + $this->assertEquals('Child Settings', $child->label); + $this->assertEquals('An embedded child', $child->description); + } + + public function testLoadEmbeddedMetadataReturnsEmptyForClassWithoutEmbeddeds(): void + { + $this->assertEmpty($this->driver->loadEmbeddedMetadata(YamlConfiguredSettings::class)); + } + + public function testDriverWithNonExistentDirectory(): void + { + $driver = new YamlDriver(['/non/existent/path']); + $this->assertEmpty($driver->getAllManagedClassNames()); + } + + public function testDriverWithEmptyPaths(): void + { + $driver = new YamlDriver([]); + $this->assertEmpty($driver->getAllManagedClassNames()); + $this->assertFalse($driver->isSettingsClass(YamlConfiguredSettings::class)); + } +} From 3c3fe0d9a3b9e77ad9a8d118ec6b5f030d788890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 19 Apr 2026 21:53:11 +0200 Subject: [PATCH 02/10] Add phpstan hints to MetadataDriverInterface --- src/Metadata/Driver/MetadataDriverInterface.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Metadata/Driver/MetadataDriverInterface.php b/src/Metadata/Driver/MetadataDriverInterface.php index 59dad2f..7d15054 100644 --- a/src/Metadata/Driver/MetadataDriverInterface.php +++ b/src/Metadata/Driver/MetadataDriverInterface.php @@ -39,8 +39,10 @@ interface MetadataDriverInterface { /** - * Returns whether this driver can provide metadata for the given class. + * Returns whether this driver can provide metadata for the given class, which effectively means that his is a + * managed settings class under the responsibility of this driver. * @param string $className The fully qualified class name + * @phpstan-var class-string $className * @return bool */ public function isSettingsClass(string $className): bool; @@ -48,6 +50,7 @@ public function isSettingsClass(string $className): bool; /** * Load the class-level settings configuration for the given class. * @param string $className The fully qualified class name + * @phpstan-var class-string $className * @return Settings|null The Settings attribute-equivalent object, or null if not managed by this driver */ public function loadClassMetadata(string $className): ?Settings; @@ -56,6 +59,7 @@ public function loadClassMetadata(string $className): ?Settings; * Load property-level parameter metadata for the given class. * Returns an associative array keyed by property name. * @param string $className The fully qualified class name + * @phpstan-param class-string $className * @return array */ public function loadParameterMetadata(string $className): array; @@ -64,6 +68,7 @@ public function loadParameterMetadata(string $className): array; * Load property-level embedded settings metadata for the given class. * Returns an associative array keyed by property name. * @param string $className The fully qualified class name + * @phpstan-param class-string $className * @return array */ public function loadEmbeddedMetadata(string $className): array; @@ -72,6 +77,7 @@ public function loadEmbeddedMetadata(string $className): array; * Returns all class names this driver knows about. * Used for discovery of settings classes that may not be found by directory scanning. * @return string[] + * @phpstan-return class-string[] */ public function getAllManagedClassNames(): array; } From a20905565e9f330e5b59c3723628c563861151ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 19 Apr 2026 22:05:17 +0200 Subject: [PATCH 03/10] Link to github contributors page in composer.json authors --- composer.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 572d72f..826b9e2 100644 --- a/composer.json +++ b/composer.json @@ -18,10 +18,12 @@ "authors": [ { "name": "Jan Böhmer", - "email": "mail@jan-boehmer.de" + "email": "mail@jan-boehmer.de", + "role": "Maintainer" }, { - "name": "Sviatoslav Vysitskyi" + "name": "Github Contributors", + "homepage": "https://github.com/jbtronics/settings-bundle/graphs/contributors" } ], "suggest": { From 9c8c8bed1db4371b90759eecff959316a72fc9af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 19 Apr 2026 23:07:25 +0200 Subject: [PATCH 04/10] Moved attribute class discovery from settings registry to AttributeDriver to decouple it further and have a single origin of truth --- config/services.php | 7 +- src/Manager/SettingsRegistry.php | 75 ++----------------- src/Metadata/Driver/AttributeDriver.php | 44 ++++++++++- tests/Manager/SettingsRegistryTest.php | 15 +++- tests/Metadata/Driver/AttributeDriverTest.php | 10 ++- tests/Metadata/Driver/ChainDriverTest.php | 4 +- 6 files changed, 76 insertions(+), 79 deletions(-) diff --git a/config/services.php b/config/services.php index 11d2811..25f99a7 100644 --- a/config/services.php +++ b/config/services.php @@ -67,7 +67,11 @@ /********************************************************************************** * Metadata Drivers **********************************************************************************/ - $services->set('jbtronics.settings.metadata_driver.attribute', AttributeDriver::class); + $services->set('jbtronics.settings.metadata_driver.attribute', AttributeDriver::class) + ->args([ + '$searchPathes' => '%jbtronics.settings.search_paths%', + ]) + ; $services->set('jbtronics.settings.metadata_driver.yaml', YamlDriver::class) ->args([ @@ -85,7 +89,6 @@ $services->set('jbtronics.settings.settings_registry', SettingsRegistry::class) ->args([ - '$directories' => '%jbtronics.settings.search_paths%', '$cache' => service('jbtronics.settings.cache.metadata_service'), '$debug_mode' => '%kernel.debug%', '$metadataDriver' => service('jbtronics.settings.metadata_driver'), diff --git a/src/Manager/SettingsRegistry.php b/src/Manager/SettingsRegistry.php index 472bb94..788b5af 100644 --- a/src/Manager/SettingsRegistry.php +++ b/src/Manager/SettingsRegistry.php @@ -25,10 +25,7 @@ namespace Jbtronics\SettingsBundle\Manager; -use Ergebnis\Classy\Construct; -use Ergebnis\Classy\Constructs; use Jbtronics\SettingsBundle\Metadata\Driver\MetadataDriverInterface; -use Jbtronics\SettingsBundle\Settings\Settings; use Symfony\Contracts\Cache\CacheInterface; /** @@ -42,16 +39,15 @@ final class SettingsRegistry implements SettingsRegistryInterface private const CACHE_KEY = 'jbtronics.settings.settings_classes'; /** - * @param array $directories The directories to scan for configuration classes + * @param MetadataDriverInterface $metadataDriver The metadata driver to use for discovering additional settings classes * @param CacheInterface $cache The cache to use for caching the configuration classes * @param bool $debug_mode If true, the cache is ignored and the directories are scanned on every request - * @param MetadataDriverInterface|null $metadataDriver The metadata driver to use for discovering additional settings classes + * @param array $directories The directories to scan for configuration classes */ public function __construct( - private readonly array $directories, + private readonly MetadataDriverInterface $metadataDriver, private readonly CacheInterface $cache, - private readonly bool $debug_mode, - private readonly ?MetadataDriverInterface $metadataDriver = null, + private readonly bool $debug_mode ) { } @@ -68,13 +64,7 @@ public function getSettingsClasses(): array } private function getSettingsClassUncached(): array { - $classes = $this->searchInPathes($this->directories); - - // Also discover classes from the metadata driver (e.g. YAML-configured classes) - if ($this->metadataDriver !== null) { - $driverClasses = $this->metadataDriver->getAllManagedClassNames(); - $classes = array_unique(array_merge($classes, $driverClasses)); - } + $classes = $this->metadataDriver->getAllManagedClassNames(); $tmp = []; @@ -103,63 +93,14 @@ private function getSettingsClassUncached(): array */ private function resolveSettingsName(string $class): ?string { - $reflClass = new \ReflectionClass($class); - $attributes = $reflClass->getAttributes(Settings::class); - - if (count($attributes) > 0) { - /** @var Settings $settings */ - $settings = $attributes[0]->newInstance(); - return $settings->name ?? self::generateDefaultNameFromClassName($class); - } - - // Try the metadata driver for non-attribute classes (e.g. YAML) - if ($this->metadataDriver !== null && $this->metadataDriver->isSettingsClass($class)) { - $classMetadata = $this->metadataDriver->loadClassMetadata($class); - if ($classMetadata !== null) { - return $classMetadata->name ?? self::generateDefaultNameFromClassName($class); - } + $classMetadata = $this->metadataDriver->loadClassMetadata($class); + if ($classMetadata !== null) { + return $classMetadata->name ?? self::generateDefaultNameFromClassName($class); } return null; } - /** - * @param string[] $pathes - * @return string[] - */ - private function searchInPathes(array $pathes): array - { - $classes = []; - - foreach ($pathes as $path) { - //Skip non-existing directories - if (!is_dir($path)) { - continue; - } - - //Find all PHP classes in the given directories - $constructs = Constructs::fromDirectory($path); - $names = array_map(static function (Construct $construct): string { - return $construct->name(); - }, $constructs); - - $classes = array_merge($classes, $names); - } - - //Now filter out all classes, which donot have the #[Settings] attribute - $settings_classes = []; - - foreach ($classes as $class) { - $reflClass = new \ReflectionClass($class); - $attributes = $reflClass->getAttributes(Settings::class); - if (count($attributes) > 0) { - $settings_classes[] = $class; - } - } - - return $settings_classes; - } - /** * Generates a default name for the given class, based on the class name. * This is used, if no name is configured in the #[ConfigClass] attribute. diff --git a/src/Metadata/Driver/AttributeDriver.php b/src/Metadata/Driver/AttributeDriver.php index 62ed7c6..23d26ae 100644 --- a/src/Metadata/Driver/AttributeDriver.php +++ b/src/Metadata/Driver/AttributeDriver.php @@ -28,6 +28,8 @@ namespace Jbtronics\SettingsBundle\Metadata\Driver; +use Ergebnis\Classy\Construct; +use Ergebnis\Classy\Constructs; use Jbtronics\SettingsBundle\Helper\PropertyAccessHelper; use Jbtronics\SettingsBundle\Settings\EmbeddedSettings; use Jbtronics\SettingsBundle\Settings\Settings; @@ -39,6 +41,15 @@ */ final class AttributeDriver implements MetadataDriverInterface { + /** + * @param string[] $searchPathes Directories to search for settings classes (e.g. src/Settings/) + */ + public function __construct( + private readonly array $searchPathes, + ) { + + } + public function isSettingsClass(string $className): bool { if (!class_exists($className)) { @@ -116,8 +127,35 @@ public function loadEmbeddedMetadata(string $className): array public function getAllManagedClassNames(): array { - // The attribute driver relies on directory scanning in SettingsRegistry, - // so it does not independently know about managed classes. - return []; + $pathes = $this->searchPathes; + $classes = []; + + foreach ($pathes as $path) { + //Skip non-existing directories + if (!is_dir($path)) { + continue; + } + + //Find all PHP classes in the given directories + $constructs = Constructs::fromDirectory($path); + $names = array_map(static function (Construct $construct): string { + return $construct->name(); + }, $constructs); + + $classes = array_merge($classes, $names); + } + + //Now filter out all classes, which donot have the #[Settings] attribute + $settings_classes = []; + + foreach ($classes as $class) { + $reflClass = new \ReflectionClass($class); + $attributes = $reflClass->getAttributes(Settings::class); + if (count($attributes) > 0) { + $settings_classes[] = $class; + } + } + + return $settings_classes; } } diff --git a/tests/Manager/SettingsRegistryTest.php b/tests/Manager/SettingsRegistryTest.php index 24eb48b..83699e6 100644 --- a/tests/Manager/SettingsRegistryTest.php +++ b/tests/Manager/SettingsRegistryTest.php @@ -26,6 +26,7 @@ namespace Jbtronics\SettingsBundle\Tests\Manager; use Jbtronics\SettingsBundle\Manager\SettingsRegistry; +use Jbtronics\SettingsBundle\Metadata\Driver\AttributeDriver; use Jbtronics\SettingsBundle\Tests\TestApplication\Helpers\NonCloneableClass; use Jbtronics\SettingsBundle\Tests\TestApplication\Settings\CacheableSettings; use Jbtronics\SettingsBundle\Tests\TestApplication\Settings\EnvVarSettings; @@ -44,12 +45,16 @@ class SettingsRegistryTest extends TestCase { public function testGetConfigClasses(): void { - $configurationRegistry = new SettingsRegistry( + $metadataDriver = new AttributeDriver( [ __DIR__.'/../TestApplication/src/Settings/', ], + ); + + $configurationRegistry = new SettingsRegistry( + $metadataDriver, new NullAdapter(), - false, + false ); $this->assertEquals([ @@ -68,10 +73,14 @@ public function testGetConfigClasses(): void public function testGetSettingsClassByName(): void { - $configurationRegistry = new SettingsRegistry( + $metadataDriver = new AttributeDriver( [ __DIR__.'/../TestApplication/src/Settings/', ], + ); + + $configurationRegistry = new SettingsRegistry( + $metadataDriver, new NullAdapter(), false, ); diff --git a/tests/Metadata/Driver/AttributeDriverTest.php b/tests/Metadata/Driver/AttributeDriverTest.php index ad341af..2598df1 100644 --- a/tests/Metadata/Driver/AttributeDriverTest.php +++ b/tests/Metadata/Driver/AttributeDriverTest.php @@ -18,7 +18,9 @@ class AttributeDriverTest extends TestCase protected function setUp(): void { - $this->driver = new AttributeDriver(); + $this->driver = new AttributeDriver([ + __DIR__.'/../../TestApplication/src/Settings/', + ]); } public function testIsSettingsClassReturnsTrueForAnnotatedClass(): void @@ -75,7 +77,9 @@ public function testLoadEmbeddedMetadataReturnsEmbeddeds(): void public function testGetAllManagedClassNamesReturnsEmpty(): void { - // AttributeDriver relies on directory scanning, not self-discovery - $this->assertEmpty($this->driver->getAllManagedClassNames()); + $classes = $this->driver->getAllManagedClassNames(); + $this->assertIsArray($classes); + $this->assertContainsOnly('string', $classes); + $this->assertContains(SimpleSettings::class, $classes); } } diff --git a/tests/Metadata/Driver/ChainDriverTest.php b/tests/Metadata/Driver/ChainDriverTest.php index ef2df11..86f6c42 100644 --- a/tests/Metadata/Driver/ChainDriverTest.php +++ b/tests/Metadata/Driver/ChainDriverTest.php @@ -20,7 +20,9 @@ class ChainDriverTest extends TestCase protected function setUp(): void { $this->driver = new ChainDriver([ - new AttributeDriver(), + new AttributeDriver([ + __DIR__.'/../../TestApplication/src/Settings', + ], ), new YamlDriver([self::YAML_MAPPING_DIR]), ]); } From 9e8115c62d7320348a3af80b484709cdde5daf8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 20 Apr 2026 00:21:45 +0200 Subject: [PATCH 05/10] Added tests for container injectability of yaml settings --- .../JbtronicsSettingsExtension.php | 2 + .../RegisterCompileTimeMetadataPass.php | 11 ++++ .../InjectableSettingsTest.php | 16 +++++- .../config/packages/settings_bundle.php | 4 ++ ...ation.Settings.YamlConfiguredSettings.yaml | 26 ++++++++++ .../Service/InjectableSettingsTestService.php | 3 +- .../Settings/AppYamlConfiguredSettings.php | 50 +++++++++++++++++++ 7 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 src/DependencyInjection/RegisterCompileTimeMetadataPass.php create mode 100644 tests/TestApplication/config/yaml_mapping/Jbtronics.SettingsBundle.Tests.TestApplication.Settings.YamlConfiguredSettings.yaml create mode 100644 tests/TestApplication/src/Settings/AppYamlConfiguredSettings.php diff --git a/src/DependencyInjection/JbtronicsSettingsExtension.php b/src/DependencyInjection/JbtronicsSettingsExtension.php index 26fe631..9292718 100644 --- a/src/DependencyInjection/JbtronicsSettingsExtension.php +++ b/src/DependencyInjection/JbtronicsSettingsExtension.php @@ -95,6 +95,8 @@ public function load(array $configs, ContainerBuilder $container): void */ private function registerYamlSettingsServices(ContainerBuilder $container, array $yamlMappingPaths): void { + return; + if (empty($yamlMappingPaths) || !class_exists(\Symfony\Component\Yaml\Yaml::class)) { return; } diff --git a/src/DependencyInjection/RegisterCompileTimeMetadataPass.php b/src/DependencyInjection/RegisterCompileTimeMetadataPass.php new file mode 100644 index 0000000..9677393 --- /dev/null +++ b/src/DependencyInjection/RegisterCompileTimeMetadataPass.php @@ -0,0 +1,11 @@ +settingsManager = self::getContainer()->get(SettingsManagerInterface::class); $this->injectableSettingsTestService = self::getContainer()->get(InjectableSettingsTestService::class); - $this->simpleSettingsAsService = self::getContainer()->get(SimpleSettings::class); } public function testInjection(): void @@ -69,4 +69,18 @@ public function testInjection(): void $this->assertSame('changed value', $injectedInstance->getValue1()); } + + public function testYamlInnjection(): void + { + //The instance injected via the service container should be the same as the one injected via the constructor + $injectedInstance = $this->injectableSettingsTestService->appYamlConfiguredSettings; + + //The injected Instance should be lazy loaded + $this->assertTrue(LazyObjectTestHelper::isLazyObject($injectedInstance)); + + //And the same as the one retrieved from the settings manager + /** @var AppYamlConfiguredSettings $fromManager */ + $fromManager = $this->settingsManager->get(AppYamlConfiguredSettings::class); + $this->assertSame($injectedInstance, $fromManager); + } } \ No newline at end of file diff --git a/tests/TestApplication/config/packages/settings_bundle.php b/tests/TestApplication/config/packages/settings_bundle.php index f9ae7c4..f4cc5c7 100644 --- a/tests/TestApplication/config/packages/settings_bundle.php +++ b/tests/TestApplication/config/packages/settings_bundle.php @@ -15,4 +15,8 @@ ->defaultEntityClass(SettingsEntry::class) ->prefetchAll(true) ; + + $config->yamlMappingPaths([ + __DIR__ . '/../yaml_mapping' + ]); }; \ No newline at end of file diff --git a/tests/TestApplication/config/yaml_mapping/Jbtronics.SettingsBundle.Tests.TestApplication.Settings.YamlConfiguredSettings.yaml b/tests/TestApplication/config/yaml_mapping/Jbtronics.SettingsBundle.Tests.TestApplication.Settings.YamlConfiguredSettings.yaml new file mode 100644 index 0000000..4b0b648 --- /dev/null +++ b/tests/TestApplication/config/yaml_mapping/Jbtronics.SettingsBundle.Tests.TestApplication.Settings.YamlConfiguredSettings.yaml @@ -0,0 +1,26 @@ +Jbtronics\SettingsBundle\Tests\TestApplication\Settings\AppYamlConfiguredSettings: + storageAdapter: Jbtronics\SettingsBundle\Storage\InMemoryStorageAdapter + name: app_yaml_configured + label: "YAML Configured Settings" + description: "Settings configured via YAML" + groups: + - admin + dependencyInjectable: true + cacheable: false + + parameters: + name: + type: Jbtronics\SettingsBundle\ParameterTypes\StringType + label: "Name" + description: "The name parameter" + + count: + type: Jbtronics\SettingsBundle\ParameterTypes\IntType + label: "Count" + nullable: true + + enabled: + type: Jbtronics\SettingsBundle\ParameterTypes\BoolType + label: "Enabled" + groups: + - general diff --git a/tests/TestApplication/src/Service/InjectableSettingsTestService.php b/tests/TestApplication/src/Service/InjectableSettingsTestService.php index cf3cf28..104b6d0 100644 --- a/tests/TestApplication/src/Service/InjectableSettingsTestService.php +++ b/tests/TestApplication/src/Service/InjectableSettingsTestService.php @@ -27,6 +27,7 @@ namespace Jbtronics\SettingsBundle\Tests\TestApplication\Service; +use Jbtronics\SettingsBundle\Tests\TestApplication\Settings\AppYamlConfiguredSettings; use Jbtronics\SettingsBundle\Tests\TestApplication\Settings\GuessableSettings; use Jbtronics\SettingsBundle\Tests\TestApplication\Settings\SimpleSettings; use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; @@ -34,7 +35,7 @@ #[Autoconfigure(public: true)] class InjectableSettingsTestService { - public function __construct(public readonly SimpleSettings $simpleSettings) + public function __construct(public readonly SimpleSettings $simpleSettings, public readonly AppYamlConfiguredSettings $appYamlConfiguredSettings) { } } \ No newline at end of file diff --git a/tests/TestApplication/src/Settings/AppYamlConfiguredSettings.php b/tests/TestApplication/src/Settings/AppYamlConfiguredSettings.php new file mode 100644 index 0000000..aa054b4 --- /dev/null +++ b/tests/TestApplication/src/Settings/AppYamlConfiguredSettings.php @@ -0,0 +1,50 @@ +name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getCount(): ?int + { + return $this->count; + } + + public function setCount(?int $count): void + { + $this->count = $count; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function setEnabled(bool $enabled): void + { + $this->enabled = $enabled; + } +} \ No newline at end of file From f0f1a581f448ac97ae46f8051be2ec6710b36d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 20 Apr 2026 00:39:38 +0200 Subject: [PATCH 06/10] Abstracted service registration logic into Metadatadriver and added tests to ensure yaml configured drivers are actually injectable --- .../JbtronicsSettingsExtension.php | 57 +++------------- .../RegisterCompileTimeMetadataPass.php | 11 --- src/DependencyInjection/TagSettingsPass.php | 67 +++++++++++++++++++ src/JbtronicsSettingsBundle.php | 2 + .../CompileTimeMetadataDriverInterface.php | 27 ++++++++ src/Metadata/Driver/YamlDriver.php | 28 +++++++- .../InjectableSettingsTest.php | 3 +- 7 files changed, 133 insertions(+), 62 deletions(-) delete mode 100644 src/DependencyInjection/RegisterCompileTimeMetadataPass.php create mode 100644 src/DependencyInjection/TagSettingsPass.php create mode 100644 src/Metadata/Driver/CompileTimeMetadataDriverInterface.php diff --git a/src/DependencyInjection/JbtronicsSettingsExtension.php b/src/DependencyInjection/JbtronicsSettingsExtension.php index 9292718..221f9b5 100644 --- a/src/DependencyInjection/JbtronicsSettingsExtension.php +++ b/src/DependencyInjection/JbtronicsSettingsExtension.php @@ -25,6 +25,8 @@ namespace Jbtronics\SettingsBundle\DependencyInjection; +use Jbtronics\SettingsBundle\Metadata\Driver\CompileTimeMetadataDriverInterface; +use Jbtronics\SettingsBundle\Metadata\Driver\MetadataDriverInterface; use Jbtronics\SettingsBundle\Metadata\Driver\YamlDriver; use Jbtronics\SettingsBundle\Migrations\SettingsMigrationInterface; use Jbtronics\SettingsBundle\ParameterTypes\ParameterTypeInterface; @@ -32,6 +34,7 @@ use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; @@ -42,6 +45,8 @@ final class JbtronicsSettingsExtension extends Extension public const TAG_STORAGE_ADAPTER = 'jbtronics.settings.storage_adapter'; public const TAG_MIGRATION = 'jbtronics.settings.migration'; + + public const TAG_INJECTABLE_SETTINGS = 'jbtronics.settings.injectable_settings'; public const RESSOURCE_TAG_SETTINGS = 'jbtronics.settings'; @@ -85,54 +90,10 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('jbtronics.settings.cache.ttl', $config['cache']['ttl']); $container->setParameter('jbtronics.settings.cache.invalidate_on_env_change', $config['cache']['invalidate_on_env_change']); - // Register YAML-configured settings classes as injectable services - $this->registerYamlSettingsServices($container, $config['yaml_mapping_paths']); - } - /** - * Eagerly discovers YAML-configured settings classes and registers them as services, - * so that they can be dependency-injected just like attribute-configured settings. - */ - private function registerYamlSettingsServices(ContainerBuilder $container, array $yamlMappingPaths): void - { - return; - - if (empty($yamlMappingPaths) || !class_exists(\Symfony\Component\Yaml\Yaml::class)) { - return; - } - - // Use the YamlDriver to discover classes at build time - $yamlDriver = new YamlDriver($yamlMappingPaths); - $classNames = $yamlDriver->getAllManagedClassNames(); - - foreach ($classNames as $className) { - $classMetadata = $yamlDriver->loadClassMetadata($className); - if ($classMetadata === null) { - continue; - } - - $dependencyInjectable = $classMetadata->dependencyInjectable; - - // Register the class as a service definition so the compiler pass can configure it - if (!$container->hasDefinition($className)) { - $definition = new Definition($className); - $definition->setAutoconfigured(true); - $container->setDefinition($className, $definition); - } - - $definition = $container->getDefinition($className); - - if (method_exists($definition, 'addResourceTag')) { //Symfony 7.3+ - $definition->addResourceTag(self::RESSOURCE_TAG_SETTINGS, [ - 'injectable' => $dependencyInjectable, - ]); - } else { - if ($dependencyInjectable) { - $definition->addTag(self::TAG_INJECTABLE_SETTINGS); - } else { - $definition->addTag(ConfigureInjectableSettingsPass::TAG_TO_REMOVE); - } - } - } + $container->setParameter('jbtronics.settings.metadata_compiler_providers', [ + YamlDriver::class, + ]); + } } \ No newline at end of file diff --git a/src/DependencyInjection/RegisterCompileTimeMetadataPass.php b/src/DependencyInjection/RegisterCompileTimeMetadataPass.php deleted file mode 100644 index 9677393..0000000 --- a/src/DependencyInjection/RegisterCompileTimeMetadataPass.php +++ /dev/null @@ -1,11 +0,0 @@ -getParameterBag()->all(); + + $metadata_compiler_providers = $containerParams['jbtronics.settings.metadata_compiler_providers'] ?? []; + + foreach ($metadata_compiler_providers as $providerClass) { + if (!class_exists($providerClass)) { + throw new LogicException('Metadata compiler provider class "' . $providerClass . '" does not exist. Please check your configuration.'); + } + if (!is_a($providerClass, CompileTimeMetadataDriverInterface::class, true)) { + throw new LogicException('Metadata compiler provider class "' . $providerClass . '" must implement ' . MetadataDriverInterface::class . '. Please check your configuration.'); + } + + + $compileTimeMetadata = $providerClass::getServiceMetadataForContainerCompilation($containerParams); + + foreach ($compileTimeMetadata as $className => $classMetadata) { + $dependencyInjectable = $classMetadata->dependencyInjectable; + + // Register the class as a service definition so the compiler pass can configure it + if (!$container->hasDefinition($className)) { + $definition = new Definition($className); + $definition->setAutoconfigured(true); + $container->setDefinition($className, $definition); + } + + $definition = $container->getDefinition($className); + + if (method_exists($definition, 'addResourceTag')) { //Symfony 7.3+ + $definition->addResourceTag(JbtronicsSettingsExtension::RESSOURCE_TAG_SETTINGS, [ + 'injectable' => $dependencyInjectable, + ]); + } else { + if ($dependencyInjectable) { + $definition->addTag(JbtronicsSettingsExtension::TAG_INJECTABLE_SETTINGS); + } else { + $definition->addTag(ConfigureInjectableSettingsPass::TAG_TO_REMOVE); + } + } + } + } + } +} \ No newline at end of file diff --git a/src/JbtronicsSettingsBundle.php b/src/JbtronicsSettingsBundle.php index 7d2b8fc..3f9f641 100644 --- a/src/JbtronicsSettingsBundle.php +++ b/src/JbtronicsSettingsBundle.php @@ -28,6 +28,7 @@ use Closure; use Jbtronics\SettingsBundle\DependencyInjection\JbtronicsSettingsExtension; use Jbtronics\SettingsBundle\DependencyInjection\ConfigureInjectableSettingsPass; +use Jbtronics\SettingsBundle\DependencyInjection\TagSettingsPass; use Jbtronics\SettingsBundle\Proxy\Autoloader; use Jbtronics\SettingsBundle\Proxy\ProxyFactoryInterface; use Jbtronics\SettingsBundle\Settings\Settings; @@ -51,6 +52,7 @@ public function build(ContainerBuilder $container): void parent::build($container); $this->processSettingsServices($container); + $container->addCompilerPass(new TagSettingsPass()); $container->addCompilerPass(new ConfigureInjectableSettingsPass()); } diff --git a/src/Metadata/Driver/CompileTimeMetadataDriverInterface.php b/src/Metadata/Driver/CompileTimeMetadataDriverInterface.php new file mode 100644 index 0000000..6f232a7 --- /dev/null +++ b/src/Metadata/Driver/CompileTimeMetadataDriverInterface.php @@ -0,0 +1,27 @@ + A list of service metadata, keyed by class name and the settings attribute configuration for the service to be injected + * @phpstan-return array + */ + public static function getServiceMetadataForContainerCompilation(array $containerParameters): array; +} \ No newline at end of file diff --git a/src/Metadata/Driver/YamlDriver.php b/src/Metadata/Driver/YamlDriver.php index 77400a6..f2e2aaf 100644 --- a/src/Metadata/Driver/YamlDriver.php +++ b/src/Metadata/Driver/YamlDriver.php @@ -45,7 +45,7 @@ * Note: callable envVarMapper is not supported in YAML configuration. * Only class-string mappers (service references) can be used. */ -final class YamlDriver implements MetadataDriverInterface +final class YamlDriver implements MetadataDriverInterface, CompileTimeMetadataDriverInterface { /** @var array|null Parsed YAML config keyed by class name */ private ?array $classConfigs = null; @@ -233,4 +233,30 @@ private function loadFile(string $file): void $this->classConfigs[$className] = $config; } } + + public static function getServiceMetadataForContainerCompilation(array $containerParameters): array + { + $yamlMappingPaths = $containerParameters['jbtronics.settings.yaml_mapping_paths'] ?? []; + + if (empty($yamlMappingPaths) || !class_exists(\Symfony\Component\Yaml\Yaml::class)) { + return []; + } + + // Use the YamlDriver to discover classes at build time + $yamlDriver = new YamlDriver($yamlMappingPaths); + $classNames = $yamlDriver->getAllManagedClassNames(); + + $output = []; + + foreach ($classNames as $className) { + $classMetadata = $yamlDriver->loadClassMetadata($className); + if ($classMetadata === null) { + continue; + } + + $output[$className] = $classMetadata; + } + + return $output; + } } diff --git a/tests/DependencyInjection/InjectableSettingsTest.php b/tests/DependencyInjection/InjectableSettingsTest.php index ca09cb1..ee29ca0 100644 --- a/tests/DependencyInjection/InjectableSettingsTest.php +++ b/tests/DependencyInjection/InjectableSettingsTest.php @@ -29,6 +29,7 @@ namespace Jbtronics\SettingsBundle\Tests\DependencyInjection; use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface; +use Jbtronics\SettingsBundle\Tests\Fixtures\Settings\YamlConfiguredSettings; use Jbtronics\SettingsBundle\Tests\Proxy\LazyObjectTestHelper; use Jbtronics\SettingsBundle\Tests\TestApplication\Service\InjectableSettingsTestService; use Jbtronics\SettingsBundle\Tests\TestApplication\Settings\AppYamlConfiguredSettings; @@ -57,8 +58,6 @@ public function testInjection(): void //The injected Instance should be lazy loaded $this->assertTrue(LazyObjectTestHelper::isLazyObject($injectedInstance)); - $this->assertSame($this->simpleSettingsAsService, $injectedInstance); - //And the same as the one retrieved from the settings manager /** @var SimpleSettings $fromManager */ $fromManager = $this->settingsManager->get(SimpleSettings::class); From 90f0c8318acc3165805c7358224e012601a2ecc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 20 Apr 2026 00:46:26 +0200 Subject: [PATCH 07/10] Added comments --- src/DependencyInjection/TagSettingsPass.php | 2 ++ src/Metadata/Driver/AttributeDriver.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/DependencyInjection/TagSettingsPass.php b/src/DependencyInjection/TagSettingsPass.php index 3be1e57..be82c3e 100644 --- a/src/DependencyInjection/TagSettingsPass.php +++ b/src/DependencyInjection/TagSettingsPass.php @@ -15,6 +15,8 @@ /** * This compiler pass is responsible for processing the metadata provided by compile-time metadata providers and tagging the corresponding service definitions accordingly. * It ensures that the classes provided by the metadata providers are registered as services and tagged with the appropriate tags for further processing by other compiler passes. + * + * This only handles compile time metadata providers. The registration of the attributes is donne in JbtronicsSettingsBundle, as it has to happen before the compiler pass runs. * @internal */ final class TagSettingsPass implements CompilerPassInterface diff --git a/src/Metadata/Driver/AttributeDriver.php b/src/Metadata/Driver/AttributeDriver.php index 23d26ae..f0d0a49 100644 --- a/src/Metadata/Driver/AttributeDriver.php +++ b/src/Metadata/Driver/AttributeDriver.php @@ -38,6 +38,8 @@ /** * Metadata driver that reads settings metadata from PHP 8.1+ attributes. * This is the default driver and provides backwards compatibility with the existing attribute-based configuration. + * + * The compile time registration for dependency injection is done in the JbtronicsSettingsExtension class */ final class AttributeDriver implements MetadataDriverInterface { From b4a5c89cf78032055380270a9bf55dcbcf071a02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 20 Apr 2026 01:17:40 +0200 Subject: [PATCH 08/10] Allow to configure which metadata drivers should be queried during container compilation --- docs/configuration.md | 7 +++++++ src/DependencyInjection/Configuration.php | 6 ++++++ .../JbtronicsSettingsExtension.php | 21 ++++++++++++++++--- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index dca46a9..e387430 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,6 +45,13 @@ jbtronics_settings: # The directory where the proxy classes should be stored proxy_dir: '%kernel.cache_dir%/jbtronics_settings/proxies' + + # ADVANCED: The metadata drivers to retrieve the settings metadata, when the container is compiled. + # You do not need to configure this, unless you implement a custom metadata driver, whose settings should be dependency injectable. + # By default, the YamlDriver is registered, when yaml mapping paths are configured + metadata_compiler_providers: ~ + # Example: + # - 'Jbtronics\SettingsBundle\Metadata\Driver\YamlDriver' # The configuration for caching of settings cache: diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 59d92a5..95fb44d 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -61,6 +61,12 @@ public function getConfigTreeBuilder(): TreeBuilder ->scalarPrototype()->end() ->end() + ->arrayNode('metadata_compiler_providers') + ->treatNullLike([]) + ->defaultValue([]) + ->scalarPrototype()->end() + ->end() + ->end(); $this->addFileStorageConfiguration($rootNode); diff --git a/src/DependencyInjection/JbtronicsSettingsExtension.php b/src/DependencyInjection/JbtronicsSettingsExtension.php index 221f9b5..ece54e3 100644 --- a/src/DependencyInjection/JbtronicsSettingsExtension.php +++ b/src/DependencyInjection/JbtronicsSettingsExtension.php @@ -91,9 +91,24 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('jbtronics.settings.cache.invalidate_on_env_change', $config['cache']['invalidate_on_env_change']); - $container->setParameter('jbtronics.settings.metadata_compiler_providers', [ - YamlDriver::class, - ]); + //When metadata compiler providers are configured, use that value + if (count($config['metadata_compiler_providers'] ?? []) > 0) { + //Set empty array, when first value is an empty string + if (count($config['metadata_compiler_providers']) === 1 && $config['metadata_compiler_providers'][0] === '') { + $config['metadata_compiler_providers'] = []; + } + + $container->setParameter('jbtronics.settings.metadata_compiler_providers', $config['metadata_compiler_providers']); + } else { + $providers = []; + + //Add YamlDriver as default, if yaml_mapping_paths are configured, otherwise don't add it, as it would be useless and just consume resources + if (count($container->getParameter('jbtronics.settings.yaml_mapping_paths')) > 0) { + $providers[] = YamlDriver::class; + } + + $container->setParameter('jbtronics.settings.metadata_compiler_providers', $providers); + } } } \ No newline at end of file From 6225fe9b6f35cda05ae7ed8cbd44b3194614fa5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 20 Apr 2026 01:21:23 +0200 Subject: [PATCH 09/10] Added an warning about use of yaml configuration --- docs/usage/yaml_configuration.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/usage/yaml_configuration.md b/docs/usage/yaml_configuration.md index 8213825..7b7e60d 100644 --- a/docs/usage/yaml_configuration.md +++ b/docs/usage/yaml_configuration.md @@ -7,6 +7,9 @@ nav_order: 2 # YAML Configuration +{: .important } +> It is recommended to use PHP attributes for settings configuration. This YAML configuration option is currently more considered experimentally, and might change in the future or be removed completely in future versions. + By default, settings classes are configured using PHP attributes (`#[Settings]`, `#[SettingsParameter]`, etc.). As an alternative, you can define settings metadata in YAML files. This allows you to keep your settings classes as plain PHP in the application layer, while the infrastructure-level configuration (storage adapters, parameter types, labels, etc.) lives in YAML files — following the same pattern as Doctrine ORM's YAML mapping. ## Requirements From 4144ddfe0d3e997bd89ff815fb8916df0dce747b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 20 Apr 2026 01:22:42 +0200 Subject: [PATCH 10/10] Fixed phpstan issues --- src/Manager/SettingsRegistry.php | 1 - src/Metadata/Driver/MetadataDriverInterface.php | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Manager/SettingsRegistry.php b/src/Manager/SettingsRegistry.php index 788b5af..3975741 100644 --- a/src/Manager/SettingsRegistry.php +++ b/src/Manager/SettingsRegistry.php @@ -42,7 +42,6 @@ final class SettingsRegistry implements SettingsRegistryInterface * @param MetadataDriverInterface $metadataDriver The metadata driver to use for discovering additional settings classes * @param CacheInterface $cache The cache to use for caching the configuration classes * @param bool $debug_mode If true, the cache is ignored and the directories are scanned on every request - * @param array $directories The directories to scan for configuration classes */ public function __construct( private readonly MetadataDriverInterface $metadataDriver, diff --git a/src/Metadata/Driver/MetadataDriverInterface.php b/src/Metadata/Driver/MetadataDriverInterface.php index 7d15054..13318c8 100644 --- a/src/Metadata/Driver/MetadataDriverInterface.php +++ b/src/Metadata/Driver/MetadataDriverInterface.php @@ -42,7 +42,7 @@ interface MetadataDriverInterface * Returns whether this driver can provide metadata for the given class, which effectively means that his is a * managed settings class under the responsibility of this driver. * @param string $className The fully qualified class name - * @phpstan-var class-string $className + * @phpstan-param class-string $className * @return bool */ public function isSettingsClass(string $className): bool; @@ -50,7 +50,7 @@ public function isSettingsClass(string $className): bool; /** * Load the class-level settings configuration for the given class. * @param string $className The fully qualified class name - * @phpstan-var class-string $className + * @phpstan-param class-string $className * @return Settings|null The Settings attribute-equivalent object, or null if not managed by this driver */ public function loadClassMetadata(string $className): ?Settings;