diff --git a/composer.json b/composer.json index 34973ab..826b9e2 100644 --- a/composer.json +++ b/composer.json @@ -18,12 +18,18 @@ "authors": [ { "name": "Jan Böhmer", - "email": "mail@jan-boehmer.de" + "email": "mail@jan-boehmer.de", + "role": "Maintainer" + }, + { + "name": "Github Contributors", + "homepage": "https://github.com/jbtronics/settings-bundle/graphs/contributors" } ], "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 +57,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..25f99a7 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,34 @@ $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) + ->args([ + '$searchPathes' => '%jbtronics.settings.search_paths%', + ]) + ; + + $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 +101,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..e387430 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: ~ @@ -38,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/docs/usage/yaml_configuration.md b/docs/usage/yaml_configuration.md new file mode 100644 index 0000000..7b7e60d --- /dev/null +++ b/docs/usage/yaml_configuration.md @@ -0,0 +1,170 @@ +--- +title: YAML Configuration +layout: default +parent: Usage +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 + +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() + + ->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 e1aa9d0..ece54e3 100644 --- a/src/DependencyInjection/JbtronicsSettingsExtension.php +++ b/src/DependencyInjection/JbtronicsSettingsExtension.php @@ -25,11 +25,16 @@ 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; use Jbtronics\SettingsBundle\Storage\StorageAdapterInterface; 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; @@ -40,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'; @@ -66,6 +73,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 +89,26 @@ 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']); + + + //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 diff --git a/src/DependencyInjection/TagSettingsPass.php b/src/DependencyInjection/TagSettingsPass.php new file mode 100644 index 0000000..be82c3e --- /dev/null +++ b/src/DependencyInjection/TagSettingsPass.php @@ -0,0 +1,69 @@ +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/Manager/SettingsRegistry.php b/src/Manager/SettingsRegistry.php index f0c842d..3975741 100644 --- a/src/Manager/SettingsRegistry.php +++ b/src/Manager/SettingsRegistry.php @@ -1,161 +1,132 @@ -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->metadataDriver->getAllManagedClassNames(); + + $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 + { + $classMetadata = $this->metadataDriver->loadClassMetadata($class); + if ($classMetadata !== null) { + return $classMetadata->name ?? self::generateDefaultNameFromClassName($class); + } + + return null; + } + + /** + * 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..f0d0a49 --- /dev/null +++ b/src/Metadata/Driver/AttributeDriver.php @@ -0,0 +1,163 @@ +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 + { + $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/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/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/MetadataDriverInterface.php b/src/Metadata/Driver/MetadataDriverInterface.php new file mode 100644 index 0000000..13318c8 --- /dev/null +++ b/src/Metadata/Driver/MetadataDriverInterface.php @@ -0,0 +1,83 @@ + + */ + 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; + + /** + * 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; +} diff --git a/src/Metadata/Driver/YamlDriver.php b/src/Metadata/Driver/YamlDriver.php new file mode 100644 index 0000000..f2e2aaf --- /dev/null +++ b/src/Metadata/Driver/YamlDriver.php @@ -0,0 +1,262 @@ + 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, CompileTimeMetadataDriverInterface +{ + /** @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; + } + } + + 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/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/DependencyInjection/InjectableSettingsTest.php b/tests/DependencyInjection/InjectableSettingsTest.php index 0bb01da..ee29ca0 100644 --- a/tests/DependencyInjection/InjectableSettingsTest.php +++ b/tests/DependencyInjection/InjectableSettingsTest.php @@ -29,8 +29,10 @@ 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; use Jbtronics\SettingsBundle\Tests\TestApplication\Settings\SimpleSettings; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -46,7 +48,6 @@ protected function setUp(): void self::bootKernel(); $this->settingsManager = self::getContainer()->get(SettingsManagerInterface::class); $this->injectableSettingsTestService = self::getContainer()->get(InjectableSettingsTestService::class); - $this->simpleSettingsAsService = self::getContainer()->get(SimpleSettings::class); } public function testInjection(): void @@ -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); @@ -69,4 +68,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/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/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 new file mode 100644 index 0000000..2598df1 --- /dev/null +++ b/tests/Metadata/Driver/AttributeDriverTest.php @@ -0,0 +1,85 @@ +driver = new AttributeDriver([ + __DIR__.'/../../TestApplication/src/Settings/', + ]); + } + + 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 + { + $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 new file mode 100644 index 0000000..86f6c42 --- /dev/null +++ b/tests/Metadata/Driver/ChainDriverTest.php @@ -0,0 +1,95 @@ +driver = new ChainDriver([ + new AttributeDriver([ + __DIR__.'/../../TestApplication/src/Settings', + ], ), + 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)); + } +} 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