diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..6290ef79 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,174 @@ +# AGENTS.md - import + +## Zweck & Verantwortung + +Das `import` Modul ist das **Kern-Framework** des Pacemaker Import-Systems. Es ist ein **Tier 3 Modul** und integriert alle Infrastructure-Tiers (0-2) mit Core-Logik. + +**Hauptverantwortung:** +- Zentrale Orchestrierung von Import-Operationen +- Observer Pattern für Row-Level Processing +- Repository Pattern für Daten-Persistierung +- Service Layer für Business Logic +- Event-Driven Architektur für Hooks +- 30+ Listeners und 30+ Observers + +## Architektur & Design Patterns + +### Kern-Klassen +- **ConfigurationManagerInterface**: Zentrale Konfiguration +- **ImportProcessor**: Haupt-Orchestrator für Imports +- **RowTrait**: Gemeinsame Row-Logik +- **SystemLoggerTrait**: Gemeinsames Logging + +### Observers (30+) +- **DynamicAttributeLoader**: Lädt Attribute dynamisch +- **GenericColumnCollectorObserver**: Sammelt Spalten +- **AbstractObserver**: Basis-Klasse für alle Observers +- Spezialisierte Observers für verschiedene Entity-Typen + +### Listeners (30+) +- **RenderOperationReportListener**: Rendering von Reports +- **ValidateHeaderRowListener**: Validierung von Header-Zeilen +- **ImportHistoryListener**: Tracking von Import-History +- **CacheUrlRewriteListener**: Caching von URL-Rewrites +- **ArchiveListener**: Archivierung von Import-Dateien + +### Verwendete Patterns +- **Observer Pattern**: Für Row-Level Processing +- **Repository Pattern**: Für Daten-Persistierung +- **Service Layer**: Für Business Logic +- **Event-Driven**: Für Hooks und Extensibility +- **Factory Pattern**: Für Object-Erstellung + +### Externe Dependencies +- **psr/log** - PSR-3 Logging +- **psr/cache** - PSR-6 Caching +- **psr/container** - PSR-11 DI Container +- **monolog/monolog** - Structured Logging +- **league/event** - Event-System +- **ramsey/uuid** - UUID-Generierung +- **symfony/mailer** - Email-Versand +- **laminas/laminas-filter** - Data Filtering +- **handcraftedinthealps/goodby-csv** - CSV-Parsing +- **ext-json** - JSON-Support +- **ext-zip** - ZIP-Support + +## Abhängigkeiten + +### Externe Pakete +- **psr/log**, **psr/cache**, **psr/container** - PSR Standards +- **monolog/monolog** - Logging +- **league/event** - Events +- **ramsey/uuid** - UUIDs +- **symfony/mailer** - Email +- **laminas/laminas-filter** - Filtering +- **handcraftedinthealps/goodby-csv** - CSV +- **ext-json**, **ext-zip** - PHP Extensions + +### TechDivision Dependencies +- **import-dbal** ^2.0 - DBAL-Interfaces +- **import-dbal-collection** ^2.1 - DBAL-Implementierung +- **import-cache** ^2.0 - Cache-Interfaces +- **import-cache-collection** ^2.0 - Cache-Implementierung +- **import-serializer** ^2.1 - Serializer-Interfaces +- **import-serializer-csv** ^2.1 - CSV-Serializer +- **import-configuration** ^6.1 - Konfiguration-Interfaces + +### Abhängig von diesem Modul (9 Reverse Dependencies) +1. **import-app-simple** - Simple Application +2. **import-attribute** - Attribute Importer +3. **import-category** - Category Importer +4. **import-customer** - Customer Importer +5. **import-product** - Product Importer +6. **import-converter** - Converter Framework +7. **import-ee** - EE Functionality +8. **import-configuration-jms** - JMS Configuration +9. **import-cli-simple** - Master CLI + +## Wichtige Entry Points + +### Haupt-Klassen +```php +// Configuration Manager +ConfigurationManagerInterface::getConfiguration(): ConfigurationInterface +ConfigurationManagerInterface::getOperation($name): OperationConfigurationInterface + +// Import Processor +ImportProcessor::process($configuration): void +ImportProcessor::execute($operation): void + +// Observer +AbstractObserver::handle($row): void +AbstractObserver::getSubject(): SubjectInterface + +// Listener +ListenerInterface::handle(EventInterface $event): void +``` + +### Verwendungsbeispiel +```php +// In Importern +$processor = new ImportProcessor($configuration); +$processor->process($configuration); + +// In Observers +class CustomObserver extends AbstractObserver { + public function handle($row) { + $subject = $this->getSubject(); + $subject->addRow($row); + } +} +``` + +## Events & Extension Points + +### Events +- **BeforeImportEvent**: Vor Import-Start +- **AfterImportEvent**: Nach Import-Ende +- **BeforeOperationEvent**: Vor Operation-Start +- **AfterOperationEvent**: Nach Operation-Ende +- **BeforeRowEvent**: Vor Row-Processing +- **AfterRowEvent**: Nach Row-Processing +- **ImportErrorEvent**: Bei Import-Fehler + +### Observer-Hooks +- **BeforeCreate**: Vor Create-Operation +- **AfterCreate**: Nach Create-Operation +- **BeforeUpdate**: Vor Update-Operation +- **AfterUpdate**: Nach Update-Operation +- **BeforeDelete**: Vor Delete-Operation +- **AfterDelete**: Nach Delete-Operation + +## Hints für KI-Agenten + +### Wichtig zu verstehen +1. **Tier 3 Modul**: Zentral für alle Import-Operationen +2. **Observer Pattern**: Zentral für Row-Level Processing +3. **Event-Driven**: Für Extensibility und Hooks +4. **30+ Observers & Listeners**: Umfangreiche Hook-Punkte +5. **9 Dependents**: Basis für alle Entity-Importer + +### Bei Änderungen +- **Breaking Changes**: Beachte alle 9 Dependents +- **Observer-Kompatibilität**: Neue Observers sollten optional sein +- **Event-Kompatibilität**: Neue Events sollten optional sein +- **Backward Compatibility**: Alte Imports sollten noch funktionieren + +### Implementierungs-Hinweise +- Nutze Observer Pattern für Custom Processing +- Nutze Events für Hooks +- Beachte Observer-Reihenfolge +- Erwäge Listener-Prioritäten + +## Bekannte Einschränkungen + +- **Single-Threaded**: Nicht für Multi-Threaded Imports +- **Memory-Intensive**: Große Datenmengen können Memory-Probleme verursachen +- **Keine Transaktionen**: Transaktions-Handling erfolgt in Implementierungen +- **Keine Rollback**: Fehler können zu Daten-Inkonsistenzen führen + +## Zusammenfassung + +`import` ist das **Kern-Framework** des Pacemaker-Systems. Es bietet die zentrale Orchestrierung, Observer Pattern für Row-Level Processing, und Event-Driven Architektur für Extensibility. Es ist die Basis für alle Entity-Importer. + +**Für Agenten:** Verstehe dieses Modul als **Kern-Framework** mit Observer Pattern, Repository Pattern, Service Layer, und Event-Driven Architektur. diff --git a/CHANGELOG.md b/CHANGELOG.md index 217fb63e..d80a4a28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +# Version 18.2.0 + +## Features + +### PHP 8.5 Compatibility + +* Update dependencies +* Remove PHP 8.2 support + +## Bugfixes + +* Fix deprecated fputcsv() warning by explicitly passing $escape parameter + # Version 18.1.0 ## Features diff --git a/composer.json b/composer.json index 74cb455b..c1247dda 100755 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "A library supporting generic Magento 2 import functionality", "license": "MIT", "require": { - "php": "^8.1", + "php": "^8.3", "psr/log": "^1.0|^2.0|^3.0", "psr/cache" : "^2.0|^3.0", "psr/container": "^1.1.2", @@ -11,15 +11,15 @@ "monolog/monolog": "^2.9|^3.7", "league/event": "^2.2", "ramsey/uuid": "^4.2|^4.7", - "symfony/mailer": "^5.4|^6.4", + "symfony/mailer": "^5.4|^6.4|^7.4", "laminas/laminas-filter": "^2.31", - "techdivision/import-dbal": "^2.0", - "techdivision/import-dbal-collection": "^2.1", - "techdivision/import-cache": "^2.0", - "techdivision/import-cache-collection": "^2.0", - "techdivision/import-serializer": "^2.1", - "techdivision/import-serializer-csv": "^2.1", - "techdivision/import-configuration": "^6.1", + "techdivision/import-dbal": "^2.1", + "techdivision/import-dbal-collection": "^2.2", + "techdivision/import-cache": "^2.1", + "techdivision/import-cache-collection": "^2.1", + "techdivision/import-serializer": "^2.2", + "techdivision/import-serializer-csv": "^2.2", + "techdivision/import-configuration": "^6.2", "ext-json": "*", "ext-zip": "*" }, diff --git a/src/Adapter/Goodby/Exporter.php b/src/Adapter/Goodby/Exporter.php index 55ca63df..50ba2738 100644 --- a/src/Adapter/Goodby/Exporter.php +++ b/src/Adapter/Goodby/Exporter.php @@ -137,13 +137,13 @@ public function export($filename, $rows) // export the header if (count($columnHeaders) > 0) { $this->checkRowConsistency($columnHeaders); - $csv->fputcsv($columnHeaders, $delimiter, $enclosure); + $csv->fputcsv($columnHeaders, $delimiter, $enclosure, "\\"); } // export the rows foreach ($rows as $row) { $this->checkRowConsistency($row); - $csv->fputcsv($row, $delimiter, $enclosure); + $csv->fputcsv($row, $delimiter, $enclosure, "\\"); } // flush the CSV file diff --git a/src/Callbacks/AbstractBooleanCallback.php b/src/Callbacks/AbstractBooleanCallback.php index 54817fce..df95dd43 100644 --- a/src/Callbacks/AbstractBooleanCallback.php +++ b/src/Callbacks/AbstractBooleanCallback.php @@ -67,7 +67,7 @@ public function handle(?AttributeCodeAndValueAwareObserverInterface $observer = // query whether or not, the passed value can be mapped to a boolean representation if (isset($this->booleanValues[strtolower($attributeValue)])) { - return (boolean)$this->booleanValues[strtolower($attributeValue)]; + return (bool)$this->booleanValues[strtolower($attributeValue)]; } // query whether or not we're in debug mode diff --git a/src/HeaderTrait.php b/src/HeaderTrait.php index 6cda6fe5..350ec7be 100644 --- a/src/HeaderTrait.php +++ b/src/HeaderTrait.php @@ -134,16 +134,14 @@ public function addHeader($name) */ public function mapAttributeCodeByHeaderMapping($attributeCode) { - - // load the header mappings $headerMappings = $this->getHeaderMappings(); - // query weather or not we've a mapping, if yes, map the attribute code + $attributeCode = $attributeCode ?? ''; + if (isset($headerMappings[$attributeCode])) { $attributeCode = $headerMappings[$attributeCode]; } - // return the (mapped) attribute code return $attributeCode; } } diff --git a/src/Listeners/ImportHistoryListener.php b/src/Listeners/ImportHistoryListener.php index ddb88867..8de5d70a 100644 --- a/src/Listeners/ImportHistoryListener.php +++ b/src/Listeners/ImportHistoryListener.php @@ -208,7 +208,7 @@ public function handle(EventInterface $event, ?ApplicationInterface $application $errorMessages[] = $metadata[RegistryKeys::ERROR_MESSAGE]; } // count the number of processed rows - $processedRows += (integer) $metadata[RegistryKeys::PROCESSED_ROWS]; + $processedRows += (int)$metadata[RegistryKeys::PROCESSED_ROWS]; } } } diff --git a/src/Loaders/SortedLoader.php b/src/Loaders/SortedLoader.php index daf17b7c..dab231f7 100644 --- a/src/Loaders/SortedLoader.php +++ b/src/Loaders/SortedLoader.php @@ -94,9 +94,10 @@ public function getSorters() : array */ public function load(?string $pattern = null) : \ArrayAccess { + $data = $this->getLoader()->load($pattern); // sort the files loaded by the parent loader instance - $this->getSorterImpl()->sort($data = $this->getLoader()->load($pattern)); + $this->getSorterImpl()->sort($data); // return the sorted files return $data; diff --git a/src/Subjects/AbstractEavSubject.php b/src/Subjects/AbstractEavSubject.php index a70da07c..56c1345f 100644 --- a/src/Subjects/AbstractEavSubject.php +++ b/src/Subjects/AbstractEavSubject.php @@ -213,7 +213,7 @@ public function castValueByBackendType($backendType, $value) // cast the value to an integer if ($backendType === BackendTypeKeys::BACKEND_TYPE_INT) { - return (integer) $value; + return (int)$value; } // we don't need to cast strings diff --git a/src/Subjects/AbstractSubject.php b/src/Subjects/AbstractSubject.php index 8be9e837..3ce57b03 100644 --- a/src/Subjects/AbstractSubject.php +++ b/src/Subjects/AbstractSubject.php @@ -1197,7 +1197,7 @@ public function getStoreId($storeViewCode) // query whether or not, the requested store is available if (isset($this->stores[$storeViewCode])) { - return (integer) $this->stores[$storeViewCode][MemberNames::STORE_ID]; + return (int)$this->stores[$storeViewCode][MemberNames::STORE_ID]; } // throw an exception, if not diff --git a/src/Subjects/ExportableTrait.php b/src/Subjects/ExportableTrait.php index 6c67dbf0..67f7f034 100644 --- a/src/Subjects/ExportableTrait.php +++ b/src/Subjects/ExportableTrait.php @@ -134,7 +134,8 @@ protected function overrideArtefacts($type, array $artefacts) protected function appendArtefacts($type, array $artefacts) { foreach ($artefacts as $artefact) { - $this->artefacts[$type][$this->getLastEntityId()][] = $artefact; + $lastEntityId = $this->getLastEntityId() ?? ''; + $this->artefacts[$type][$lastEntityId][] = $artefact; } } diff --git a/src/Utils/UrlKeyUtil.php b/src/Utils/UrlKeyUtil.php index 7f9fe984..69fb8123 100644 --- a/src/Utils/UrlKeyUtil.php +++ b/src/Utils/UrlKeyUtil.php @@ -290,7 +290,7 @@ public function loadUrlKey(UrlKeyAwareSubjectInterface $subject, $primaryKeyId) // initialize the entity type ID $entityType = $subject->getEntityType(); - $entityTypeId = (integer) $entityType[MemberNames::ENTITY_TYPE_ID]; + $entityTypeId = (int)$entityType[MemberNames::ENTITY_TYPE_ID]; // initialize the store view ID, use the admin store view if no store view has // been set, because the default url_key value has been set in admin store view diff --git a/symfony/DependencyInjection/ImportExtension.php b/symfony/DependencyInjection/ImportExtension.php index 1c4efd6c..9dbb380a 100644 --- a/symfony/DependencyInjection/ImportExtension.php +++ b/symfony/DependencyInjection/ImportExtension.php @@ -17,7 +17,7 @@ use Symfony\Component\Config\FileLocator; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; +use TechDivision\Import\DependencyInjection\Loader\XmlFileLoader; /** * The symfony extension implementation for the M2IF import library. @@ -30,14 +30,14 @@ */ class ImportExtension extends Extension { - /** * Load's the bundles DI configuration. * * @param array $configs The array with the configuration * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container The container instance + * @return void */ - public function load(array $configs, ContainerBuilder $container) + public function load(array $configs, ContainerBuilder $container): void { $loader = new XmlFileLoader($container, new FileLocator(dirname(__DIR__) . '/Resources/config')); $loader->load('services.xml'); diff --git a/symfony/DependencyInjection/Loader/XmlFileLoader.php b/symfony/DependencyInjection/Loader/XmlFileLoader.php new file mode 100644 index 00000000..a784a2f6 --- /dev/null +++ b/symfony/DependencyInjection/Loader/XmlFileLoader.php @@ -0,0 +1,908 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace TechDivision\Import\DependencyInjection\Loader; + +use Symfony\Component\Config\Util\XmlUtils; +use Symfony\Component\DependencyInjection\Alias; +use Symfony\Component\DependencyInjection\Argument\AbstractArgument; +use Symfony\Component\DependencyInjection\Argument\BoundArgument; +use Symfony\Component\DependencyInjection\Argument\IteratorArgument; +use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; +use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; +use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\DependencyInjection\Exception\LogicException; +use Symfony\Component\DependencyInjection\Exception\RuntimeException; +use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; +use Symfony\Component\DependencyInjection\Loader\FileLoader; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\ExpressionLanguage\Expression; + +/** + * XmlFileLoader loads XML files service definitions. + * + * @author Fabien Potencier + */ +class XmlFileLoader extends FileLoader +{ + public const NS = 'http://symfony.com/schema/dic/services'; + + protected bool $autoRegisterAliasesForSinglyImplementedInterfaces = false; + + public function load(mixed $resource, ?string $type = null): mixed + { + $path = $this->locator->locate($resource); + + $xml = $this->parseFileToDOM($path); + + $this->container->fileExists($path); + + $this->loadXml($xml, $path); + + if ($this->env) { + $xpath = new \DOMXPath($xml); + $xpath->registerNamespace('container', self::NS); + foreach ($xpath->query(\sprintf('//container:when[@env="%s"]', $this->env)) ?: [] as $root) { + $env = $this->env; + $this->env = null; + try { + $this->loadXml($xml, $path, $root); + } finally { + $this->env = $env; + } + } + } + + return null; + } + + private function loadXml(\DOMDocument $xml, string $path, ?\DOMNode $root = null): void + { + $defaults = $this->getServiceDefaults($xml, $path, $root); + + // anonymous services + $this->processAnonymousServices($xml, $path, $root); + + // imports + $this->parseImports($xml, $path, $root); + + // parameters + $this->parseParameters($xml, $path, $root); + + // extensions + $this->loadFromExtensions($xml, $root); + + // services + try { + $this->parseDefinitions($xml, $path, $defaults, $root); + } finally { + $this->instanceof = []; + $this->registerAliasesForSinglyImplementedInterfaces(); + } + } + + public function supports(mixed $resource, ?string $type = null): bool + { + if (!\is_string($resource)) { + return false; + } + + if (null === $type && 'xml' === pathinfo($resource, \PATHINFO_EXTENSION)) { + return true; + } + + return 'xml' === $type; + } + + private function parseParameters(\DOMDocument $xml, string $file, ?\DOMNode $root = null): void + { + if ($parameters = $this->getChildren($root ?? $xml->documentElement, 'parameters')) { + $this->container->getParameterBag()->add($this->getArgumentsAsPhp($parameters[0], 'parameter', $file)); + } + } + + private function parseImports(\DOMDocument $xml, string $file, ?\DOMNode $root = null): void + { + $xpath = new \DOMXPath($xml); + $xpath->registerNamespace('container', self::NS); + + if (false === $imports = $xpath->query('./container:imports/container:import', $root)) { + return; + } + + $defaultDirectory = \dirname($file); + foreach ($imports as $import) { + $this->setCurrentDir($defaultDirectory); + $this->import($import->getAttribute('resource'), XmlUtils::phpize($import->getAttribute('type')) ?: null, XmlUtils::phpize($import->getAttribute('ignore-errors')) ?: false, $file); + } + } + + private function parseDefinitions(\DOMDocument $xml, string $file, Definition $defaults, ?\DOMNode $root = null): void + { + $xpath = new \DOMXPath($xml); + $xpath->registerNamespace('container', self::NS); + + if (false === $services = $xpath->query('./container:services/container:service|./container:services/container:prototype|./container:services/container:stack', $root)) { + return; + } + $this->setCurrentDir(\dirname($file)); + + $this->instanceof = []; + $this->isLoadingInstanceof = true; + $instanceof = $xpath->query('./container:services/container:instanceof', $root); + foreach ($instanceof as $service) { + $this->setDefinition((string) $service->getAttribute('id'), $this->parseDefinition($service, $file, new Definition())); + } + + $this->isLoadingInstanceof = false; + foreach ($services as $service) { + if ('stack' === $service->tagName) { + $service->setAttribute('parent', '-'); + $definition = $this->parseDefinition($service, $file, $defaults) + ->setTags(array_merge_recursive(['container.stack' => [[]]], $defaults->getTags())) + ; + $this->setDefinition($id = (string) $service->getAttribute('id'), $definition); + $stack = []; + + foreach ($this->getChildren($service, 'service') as $k => $frame) { + $k = $frame->getAttribute('id') ?: $k; + $frame->setAttribute('id', $id.'" at index "'.$k); + + if ($alias = $frame->getAttribute('alias')) { + $this->validateAlias($frame, $file); + $stack[$k] = new Reference($alias); + } else { + $stack[$k] = $this->parseDefinition($frame, $file, $defaults) + ->setInstanceofConditionals($this->instanceof); + } + } + + $definition->setArguments($stack); + } elseif (null !== $definition = $this->parseDefinition($service, $file, $defaults)) { + if ('prototype' === $service->tagName) { + $excludes = array_column($this->getChildren($service, 'exclude'), 'nodeValue'); + if ($service->hasAttribute('exclude')) { + if (\count($excludes) > 0) { + throw new InvalidArgumentException('You cannot use both the attribute "exclude" and tags at the same time.'); + } + $excludes = [$service->getAttribute('exclude')]; + } + $this->registerClasses($definition, (string) $service->getAttribute('namespace'), (string) $service->getAttribute('resource'), $excludes, $file); + } else { + $this->setDefinition((string) $service->getAttribute('id'), $definition); + } + } + } + } + + private function getServiceDefaults(\DOMDocument $xml, string $file, ?\DOMNode $root = null): Definition + { + $xpath = new \DOMXPath($xml); + $xpath->registerNamespace('container', self::NS); + + if (null === $defaultsNode = $xpath->query('./container:services/container:defaults', $root)->item(0)) { + return new Definition(); + } + + $defaultsNode->setAttribute('id', ''); + + return $this->parseDefinition($defaultsNode, $file, new Definition()); + } + + /** + * Parses an individual Definition. + */ + private function parseDefinition(\DOMElement $service, string $file, Definition $defaults): ?Definition + { + if ($alias = $service->getAttribute('alias')) { + $this->validateAlias($service, $file); + + $this->container->setAlias($service->getAttribute('id'), $alias = new Alias($alias)); + if ($publicAttr = $service->getAttribute('public')) { + $alias->setPublic(XmlUtils::phpize($publicAttr)); + } elseif ($defaults->getChanges()['public'] ?? false) { + $alias->setPublic($defaults->isPublic()); + } + + if ($deprecated = $this->getChildren($service, 'deprecated')) { + $message = $deprecated[0]->nodeValue ?: ''; + $package = $deprecated[0]->getAttribute('package') ?: ''; + $version = $deprecated[0]->getAttribute('version') ?: ''; + + if (!$deprecated[0]->hasAttribute('package')) { + throw new InvalidArgumentException(\sprintf('Missing attribute "package" at node "deprecated" in "%s".', $file)); + } + + if (!$deprecated[0]->hasAttribute('version')) { + throw new InvalidArgumentException(\sprintf('Missing attribute "version" at node "deprecated" in "%s".', $file)); + } + + $alias->setDeprecated($package, $version, $message); + } + + return null; + } + + if ($this->isLoadingInstanceof) { + $definition = new ChildDefinition(''); + } elseif ($parent = $service->getAttribute('parent')) { + $definition = new ChildDefinition($parent); + } else { + $definition = new Definition(); + } + + if ($defaults->getChanges()['public'] ?? false) { + $definition->setPublic($defaults->isPublic()); + } + $definition->setAutowired($defaults->isAutowired()); + $definition->setAutoconfigured($defaults->isAutoconfigured()); + $definition->setChanges([]); + + foreach (['class', 'public', 'shared', 'synthetic', 'abstract'] as $key) { + if ($value = $service->getAttribute($key)) { + $method = 'set'.$key; + $definition->$method(XmlUtils::phpize($value)); + } + } + + if ($value = $service->getAttribute('lazy')) { + $definition->setLazy((bool) $value = XmlUtils::phpize($value)); + if (\is_string($value)) { + $definition->addTag('proxy', ['interface' => $value]); + } + } + + if ($value = $service->getAttribute('autowire')) { + $definition->setAutowired(XmlUtils::phpize($value)); + } + + if ($value = $service->getAttribute('autoconfigure')) { + $definition->setAutoconfigured(XmlUtils::phpize($value)); + } + + if ($files = $this->getChildren($service, 'file')) { + $definition->setFile($files[0]->nodeValue); + } + + if ($deprecated = $this->getChildren($service, 'deprecated')) { + $message = $deprecated[0]->nodeValue ?: ''; + $package = $deprecated[0]->getAttribute('package') ?: ''; + $version = $deprecated[0]->getAttribute('version') ?: ''; + + if (!$deprecated[0]->hasAttribute('package')) { + throw new InvalidArgumentException(\sprintf('Missing attribute "package" at node "deprecated" in "%s".', $file)); + } + + if (!$deprecated[0]->hasAttribute('version')) { + throw new InvalidArgumentException(\sprintf('Missing attribute "version" at node "deprecated" in "%s".', $file)); + } + + $definition->setDeprecated($package, $version, $message); + } + + $definition->setArguments($this->getArgumentsAsPhp($service, 'argument', $file, $definition instanceof ChildDefinition)); + $definition->setProperties($this->getArgumentsAsPhp($service, 'property', $file)); + + if ($factories = $this->getChildren($service, 'factory')) { + $factory = $factories[0]; + if ($function = $factory->getAttribute('function')) { + $definition->setFactory($function); + } elseif ($expression = $factory->getAttribute('expression')) { + if (!class_exists(Expression::class)) { + throw new \LogicException('The "expression" attribute cannot be used on factories without the ExpressionLanguage component. Try running "composer require symfony/expression-language".'); + } + $definition->setFactory('@='.$expression); + } else { + if ($childService = $factory->getAttribute('service')) { + $class = new Reference($childService, ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE); + } else { + $class = $factory->hasAttribute('class') ? $factory->getAttribute('class') : null; + } + + $definition->setFactory([$class, $factory->getAttribute('method') ?: '__invoke']); + } + } + + if ($constructor = $service->getAttribute('constructor')) { + if (null !== $definition->getFactory()) { + throw new LogicException(\sprintf('The "%s" service cannot declare a factory as well as a constructor.', $service->getAttribute('id'))); + } + + $definition->setFactory([null, $constructor]); + } + + if ($configurators = $this->getChildren($service, 'configurator')) { + $configurator = $configurators[0]; + if ($function = $configurator->getAttribute('function')) { + $definition->setConfigurator($function); + } else { + if ($childService = $configurator->getAttribute('service')) { + $class = new Reference($childService, ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE); + } else { + $class = $configurator->getAttribute('class'); + } + + $definition->setConfigurator([$class, $configurator->getAttribute('method') ?: '__invoke']); + } + } + + foreach ($this->getChildren($service, 'call') as $call) { + $definition->addMethodCall( + $call->getAttribute('method'), + $this->getArgumentsAsPhp($call, 'argument', $file), + XmlUtils::phpize($call->getAttribute('returns-clone')) ?: false + ); + } + + $tags = $this->getChildren($service, 'tag'); + + foreach ($tags as $tag) { + $tagNameComesFromAttribute = $tag->childElementCount || '' === $tag->nodeValue; + if ('' === $tagName = $tagNameComesFromAttribute ? $tag->getAttribute('name') : $tag->nodeValue) { + throw new InvalidArgumentException(\sprintf('The tag name for service "%s" in "%s" must be a non-empty string.', $service->getAttribute('id'), $file)); + } + + $parameters = $this->getTagAttributes($tag, \sprintf('The attribute name of tag "%s" for service "%s" in %s must be a non-empty string.', $tagName, $service->getAttribute('id'), $file)); + foreach ($tag->attributes as $name => $node) { + if ($tagNameComesFromAttribute && 'name' === $name) { + continue; + } + + if (str_contains($name, '-') && !str_contains($name, '_') && !\array_key_exists($normalizedName = str_replace('-', '_', $name), $parameters)) { + $parameters[$normalizedName] = XmlUtils::phpize($node->nodeValue); + } + // keep not normalized key + $parameters[$name] = XmlUtils::phpize($node->nodeValue); + } + + $definition->addTag($tagName, $parameters); + } + + $definition->setTags(array_merge_recursive($definition->getTags(), $defaults->getTags())); + + $bindings = $this->getArgumentsAsPhp($service, 'bind', $file); + $bindingType = $this->isLoadingInstanceof ? BoundArgument::INSTANCEOF_BINDING : BoundArgument::SERVICE_BINDING; + foreach ($bindings as $argument => $value) { + $bindings[$argument] = new BoundArgument($value, true, $bindingType, $file); + } + + // deep clone, to avoid multiple process of the same instance in the passes + $bindings = array_merge(unserialize(serialize($defaults->getBindings())), $bindings); + + if ($bindings) { + $definition->setBindings($bindings); + } + + if ($decorates = $service->getAttribute('decorates')) { + $decorationOnInvalid = $service->getAttribute('decoration-on-invalid') ?: 'exception'; + if ('exception' === $decorationOnInvalid) { + $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE; + } elseif ('ignore' === $decorationOnInvalid) { + $invalidBehavior = ContainerInterface::IGNORE_ON_INVALID_REFERENCE; + } elseif ('null' === $decorationOnInvalid) { + $invalidBehavior = ContainerInterface::NULL_ON_INVALID_REFERENCE; + } else { + throw new InvalidArgumentException(\sprintf('Invalid value "%s" for attribute "decoration-on-invalid" on service "%s". Did you mean "exception", "ignore" or "null" in "%s"?', $decorationOnInvalid, $service->getAttribute('id'), $file)); + } + + $renameId = $service->hasAttribute('decoration-inner-name') ? $service->getAttribute('decoration-inner-name') : null; + $priority = $service->hasAttribute('decoration-priority') ? $service->getAttribute('decoration-priority') : 0; + + $definition->setDecoratedService($decorates, $renameId, $priority, $invalidBehavior); + } + + if ($callable = $this->getChildren($service, 'from-callable')) { + if ($definition instanceof ChildDefinition) { + throw new InvalidArgumentException(\sprintf('Attribute "parent" is unsupported when using "" on service "%s".', $service->getAttribute('id'))); + } + + foreach ([ + 'Attribute "synthetic"' => 'isSynthetic', + 'Attribute "file"' => 'getFile', + 'Tag ""' => 'getFactory', + 'Tag ""' => 'getArguments', + 'Tag ""' => 'getProperties', + 'Tag ""' => 'getConfigurator', + 'Tag ""' => 'getMethodCalls', + ] as $key => $method) { + if ($definition->$method()) { + throw new InvalidArgumentException($key.\sprintf(' is unsupported when using "" on service "%s".', $service->getAttribute('id'))); + } + } + + $definition->setFactory(['Closure', 'fromCallable']); + + if ('Closure' !== ($definition->getClass() ?? 'Closure')) { + $definition->setLazy(true); + } else { + $definition->setClass('Closure'); + } + + $callable = $callable[0]; + if ($function = $callable->getAttribute('function')) { + $definition->setArguments([$function]); + } elseif ($expression = $callable->getAttribute('expression')) { + if (!class_exists(Expression::class)) { + throw new \LogicException('The "expression" attribute cannot be used without the ExpressionLanguage component. Try running "composer require symfony/expression-language".'); + } + $definition->setArguments(['@='.$expression]); + } else { + if ($childService = $callable->getAttribute('service')) { + $class = new Reference($childService, ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE); + } else { + $class = $callable->hasAttribute('class') ? $callable->getAttribute('class') : null; + } + + $definition->setArguments([[$class, $callable->getAttribute('method') ?: '__invoke']]); + } + } + + return $definition; + } + + /** + * Parses an XML file to a \DOMDocument. + * + * @throws InvalidArgumentException When loading of XML file returns error + */ + private function parseFileToDOM(string $file): \DOMDocument + { + try { + $dom = XmlUtils::loadFile($file, $this->validateSchema(...)); + } catch (\InvalidArgumentException $e) { + $invalidSecurityElements = []; + $errors = explode("\n", $e->getMessage()); + foreach ($errors as $i => $error) { + if (preg_match("#^\[ERROR 1871] Element '\{http://symfony\.com/schema/dic/security}([^']+)'#", $error, $matches)) { + $invalidSecurityElements[$i] = $matches[1]; + } + } + if ($invalidSecurityElements) { + $dom = XmlUtils::loadFile($file); + + foreach ($invalidSecurityElements as $errorIndex => $tagName) { + foreach ($dom->getElementsByTagNameNS('http://symfony.com/schema/dic/security', $tagName) as $element) { + if (!$parent = $element->parentNode) { + continue; + } + if ('http://symfony.com/schema/dic/security' !== $parent->namespaceURI) { + continue; + } + if ('provider' === $parent->localName || 'firewall' === $parent->localName) { + unset($errors[$errorIndex]); + } + } + } + } + if ($errors) { + throw new InvalidArgumentException(\sprintf('Unable to parse file "%s": ', $file).implode("\n", $errors), $e->getCode(), $e); + } + } + + $this->validateExtensions($dom, $file); + + return $dom; + } + + /** + * Processes anonymous services. + */ + private function processAnonymousServices(\DOMDocument $xml, string $file, ?\DOMNode $root = null): void + { + $definitions = []; + $count = 0; + $suffix = '~'.ContainerBuilder::hash($file); + + $xpath = new \DOMXPath($xml); + $xpath->registerNamespace('container', self::NS); + + // anonymous services as arguments/properties + if (false !== $nodes = $xpath->query('.//container:argument[@type="service"][not(@id)]|.//container:property[@type="service"][not(@id)]|.//container:bind[not(@id)]|.//container:factory[not(@service)]|.//container:configurator[not(@service)]', $root)) { + foreach ($nodes as $node) { + if ($services = $this->getChildren($node, 'service')) { + // give it a unique name + $id = \sprintf('.%d_%s', ++$count, preg_replace('/^.*\\\\/', '', $services[0]->getAttribute('class')).$suffix); + $node->setAttribute('id', $id); + $node->setAttribute('service', $id); + + $definitions[$id] = [$services[0], $file]; + $services[0]->setAttribute('id', $id); + + // anonymous services are always private + // we could not use the constant false here, because of XML parsing + $services[0]->setAttribute('public', 'false'); + } + } + } + + // anonymous services "in the wild" + if (false !== $nodes = $xpath->query('.//container:services/container:service[not(@id)]', $root)) { + foreach ($nodes as $node) { + throw new InvalidArgumentException(\sprintf('Top-level services must have "id" attribute, none found in "%s" at line %d.', $file, $node->getLineNo())); + } + } + + // resolve definitions + uksort($definitions, 'strnatcmp'); + foreach (array_reverse($definitions) as $id => [$domElement, $file]) { + if (null !== $definition = $this->parseDefinition($domElement, $file, new Definition())) { + $this->setDefinition($id, $definition); + } + } + } + + private function getArgumentsAsPhp(\DOMElement $node, string $name, string $file, bool $isChildDefinition = false): array + { + $arguments = []; + foreach ($this->getChildren($node, $name) as $arg) { + if ($arg->hasAttribute('name')) { + $arg->setAttribute('key', $arg->getAttribute('name')); + } + + // this is used by ChildDefinition to overwrite a specific + // argument of the parent definition + if ($arg->hasAttribute('index')) { + $key = ($isChildDefinition ? 'index_' : '').$arg->getAttribute('index'); + } elseif (!$arg->hasAttribute('key')) { + // Append an empty argument, then fetch its key to overwrite it later + $arguments[] = null; + $keys = array_keys($arguments); + $key = array_pop($keys); + } else { + $key = $arg->getAttribute('key'); + } + + switch ($arg->getAttribute('key-type')) { + case 'binary': + if (false === $key = base64_decode($key, true)) { + throw new InvalidArgumentException(\sprintf('Tag "<%s>" with key-type="binary" does not have a valid base64 encoded key in "%s".', $name, $file)); + } + break; + case 'constant': + try { + $key = \constant(trim($key)); + } catch (\Error) { + throw new InvalidArgumentException(\sprintf('The key "%s" is not a valid constant in "%s".', $key, $file)); + } + break; + } + + $trim = $arg->hasAttribute('trim') && XmlUtils::phpize($arg->getAttribute('trim')); + $onInvalid = $arg->getAttribute('on-invalid'); + $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE; + if ('ignore' == $onInvalid) { + $invalidBehavior = ContainerInterface::IGNORE_ON_INVALID_REFERENCE; + } elseif ('ignore_uninitialized' == $onInvalid) { + $invalidBehavior = ContainerInterface::IGNORE_ON_UNINITIALIZED_REFERENCE; + } elseif ('null' == $onInvalid) { + $invalidBehavior = ContainerInterface::NULL_ON_INVALID_REFERENCE; + } + + switch ($type = $arg->getAttribute('type')) { + case 'service': + if ('' === $arg->getAttribute('id')) { + throw new InvalidArgumentException(\sprintf('Tag "<%s>" with type="service" has no or empty "id" attribute in "%s".', $name, $file)); + } + + $arguments[$key] = new Reference($arg->getAttribute('id'), $invalidBehavior); + break; + case 'expression': + if (!class_exists(Expression::class)) { + throw new \LogicException('The type="expression" attribute cannot be used without the ExpressionLanguage component. Try running "composer require symfony/expression-language".'); + } + + $arguments[$key] = new Expression($arg->nodeValue); + break; + case 'collection': + $arguments[$key] = $this->getArgumentsAsPhp($arg, $name, $file); + break; + case 'iterator': + $arg = $this->getArgumentsAsPhp($arg, $name, $file); + $arguments[$key] = new IteratorArgument($arg); + break; + case 'closure': + case 'service_closure': + if ('' !== $arg->getAttribute('id')) { + $arg = new Reference($arg->getAttribute('id'), $invalidBehavior); + } else { + $arg = $this->getArgumentsAsPhp($arg, $name, $file); + } + $arguments[$key] = match ($type) { + 'service_closure' => new ServiceClosureArgument($arg), + 'closure' => (new Definition('Closure')) + ->setFactory(['Closure', 'fromCallable']) + ->addArgument($arg), + }; + break; + case 'service_locator': + $arg = $this->getArgumentsAsPhp($arg, $name, $file); + $arguments[$key] = new ServiceLocatorArgument($arg); + break; + case 'tagged': + trigger_deprecation('symfony/dependency-injection', '7.2', 'Type "tagged" is deprecated for tag <%s>, use "tagged_iterator" instead in "%s".', $name, $file); + // no break + case 'tagged_iterator': + case 'tagged_locator': + $forLocator = 'tagged_locator' === $type; + + if (!$arg->getAttribute('tag')) { + throw new InvalidArgumentException(\sprintf('Tag "<%s>" with type="%s" has no or empty "tag" attribute in "%s".', $name, $type, $file)); + } + + $excludes = array_column($this->getChildren($arg, 'exclude'), 'nodeValue'); + if ($arg->hasAttribute('exclude')) { + if (\count($excludes) > 0) { + throw new InvalidArgumentException('You cannot use both the attribute "exclude" and tags at the same time.'); + } + $excludes = [$arg->getAttribute('exclude')]; + } + + $arguments[$key] = new TaggedIteratorArgument($arg->getAttribute('tag'), $arg->getAttribute('index-by') ?: null, $arg->getAttribute('default-index-method') ?: null, $forLocator, $arg->getAttribute('default-priority-method') ?: null, $excludes, !$arg->hasAttribute('exclude-self') || XmlUtils::phpize($arg->getAttribute('exclude-self'))); + + if ($forLocator) { + $arguments[$key] = new ServiceLocatorArgument($arguments[$key]); + } + break; + case 'binary': + if (false === $value = base64_decode($arg->nodeValue)) { + throw new InvalidArgumentException(\sprintf('Tag "<%s>" with type="binary" is not a valid base64 encoded string.', $name)); + } + $arguments[$key] = $value; + break; + case 'abstract': + $arguments[$key] = new AbstractArgument($arg->nodeValue); + break; + case 'string': + $arguments[$key] = $trim ? trim($arg->nodeValue) : $arg->nodeValue; + break; + case 'constant': + $arguments[$key] = \constant(trim($arg->nodeValue)); + break; + default: + $arguments[$key] = XmlUtils::phpize($trim ? trim($arg->nodeValue) : $arg->nodeValue); + } + } + + return $arguments; + } + + /** + * Get child elements by name. + * + * @return \DOMElement[] + */ + private function getChildren(\DOMNode $node, string $name): array + { + $children = []; + foreach ($node->childNodes as $child) { + if ($child instanceof \DOMElement && $child->localName === $name && self::NS === $child->namespaceURI) { + $children[] = $child; + } + } + + return $children; + } + + private function getTagAttributes(\DOMNode $node, string $missingName): array + { + $parameters = []; + $children = $this->getChildren($node, 'attribute'); + + foreach ($children as $childNode) { + if ('' === $name = $childNode->getAttribute('name')) { + throw new InvalidArgumentException($missingName); + } + + if ($this->getChildren($childNode, 'attribute')) { + $parameters[$name] = $this->getTagAttributes($childNode, $missingName); + } else { + if (str_contains($name, '-') && !str_contains($name, '_') && !\array_key_exists($normalizedName = str_replace('-', '_', $name), $parameters)) { + $parameters[$normalizedName] = XmlUtils::phpize($childNode->nodeValue); + } + // keep not normalized key + $parameters[$name] = XmlUtils::phpize($childNode->nodeValue); + } + } + + return $parameters; + } + + /** + * Validates a documents XML schema. + * + * @throws RuntimeException When extension references a non-existent XSD file + */ + public function validateSchema(\DOMDocument $dom): bool + { + $schemaLocations = ['http://symfony.com/schema/dic/services' => str_replace('\\', '/', __DIR__.'/schema/dic/services/services-1.0.xsd')]; + + if ($element = $dom->documentElement->getAttributeNS('http://www.w3.org/2001/XMLSchema-instance', 'schemaLocation')) { + $items = preg_split('/\s+/', $element); + for ($i = 0, $nb = \count($items); $i < $nb; $i += 2) { + if (!$this->container->hasExtension($items[$i])) { + continue; + } + + if (($extension = $this->container->getExtension($items[$i])) && false !== $extension->getXsdValidationBasePath()) { + $ns = $extension->getNamespace(); + $path = str_replace([$ns, str_replace('http://', 'https://', $ns)], str_replace('\\', '/', $extension->getXsdValidationBasePath()).'/', $items[$i + 1]); + + if (!is_file($path)) { + throw new RuntimeException(\sprintf('Extension "%s" references a non-existent XSD file "%s".', get_debug_type($extension), $path)); + } + + $schemaLocations[$items[$i]] = $path; + } + } + } + + $tmpfiles = []; + $imports = ''; + foreach ($schemaLocations as $namespace => $location) { + $parts = explode('/', $location); + $locationstart = 'file:///'; + if (0 === stripos($location, 'phar://')) { + $tmpfile = tempnam(sys_get_temp_dir(), 'symfony'); + if ($tmpfile) { + copy($location, $tmpfile); + $tmpfiles[] = $tmpfile; + $parts = explode('/', str_replace('\\', '/', $tmpfile)); + } else { + array_shift($parts); + $locationstart = 'phar:///'; + } + } elseif ('\\' === \DIRECTORY_SEPARATOR && str_starts_with($location, '\\\\')) { + $locationstart = ''; + } + $drive = '\\' === \DIRECTORY_SEPARATOR ? array_shift($parts).'/' : ''; + $location = $locationstart.$drive.implode('/', array_map('rawurlencode', $parts)); + + $imports .= \sprintf(' '."\n", $namespace, $location); + } + + $source = << + + + +$imports + +EOF + ; + + if ($this->shouldEnableEntityLoader()) { + $disableEntities = libxml_disable_entity_loader(false); + $valid = @$dom->schemaValidateSource($source); + libxml_disable_entity_loader($disableEntities); + } else { + $valid = @$dom->schemaValidateSource($source); + } + foreach ($tmpfiles as $tmpfile) { + @unlink($tmpfile); + } + + return $valid; + } + + private function shouldEnableEntityLoader(): bool + { + static $dom, $schema; + if (null === $dom) { + $dom = new \DOMDocument(); + $dom->loadXML(''); + + $tmpfile = tempnam(sys_get_temp_dir(), 'symfony'); + register_shutdown_function(static function () use ($tmpfile) { + @unlink($tmpfile); + }); + $schema = ' + + +'; + file_put_contents($tmpfile, ' + + + +'); + } + + return !@$dom->schemaValidateSource($schema); + } + + private function validateAlias(\DOMElement $alias, string $file): void + { + foreach ($alias->attributes as $name => $node) { + if (!\in_array($name, ['alias', 'id', 'public'])) { + throw new InvalidArgumentException(\sprintf('Invalid attribute "%s" defined for alias "%s" in "%s".', $name, $alias->getAttribute('id'), $file)); + } + } + + foreach ($alias->childNodes as $child) { + if (!$child instanceof \DOMElement || self::NS !== $child->namespaceURI) { + continue; + } + if ('deprecated' !== $child->localName) { + throw new InvalidArgumentException(\sprintf('Invalid child element "%s" defined for alias "%s" in "%s".', $child->localName, $alias->getAttribute('id'), $file)); + } + } + } + + /** + * Validates an extension. + * + * @throws InvalidArgumentException When no extension is found corresponding to a tag + */ + private function validateExtensions(\DOMDocument $dom, string $file): void + { + foreach ($dom->documentElement->childNodes as $node) { + if (!$node instanceof \DOMElement || 'http://symfony.com/schema/dic/services' === $node->namespaceURI) { + continue; + } + + // can it be handled by an extension? + if (!$this->prepend && !$this->container->hasExtension($node->namespaceURI)) { + $extensionNamespaces = array_filter(array_map(fn (ExtensionInterface $ext) => $ext->getNamespace(), $this->container->getExtensions())); + throw new InvalidArgumentException(UndefinedExtensionHandler::getErrorMessage($node->tagName, $file, $node->namespaceURI, $extensionNamespaces)); + } + } + } + + /** + * Loads from an extension. + */ + private function loadFromExtensions(\DOMDocument $xml): void + { + foreach ($xml->documentElement->childNodes as $node) { + if (!$node instanceof \DOMElement || self::NS === $node->namespaceURI) { + continue; + } + + $values = static::convertDomElementToArray($node); + if (!\is_array($values)) { + $values = []; + } + + $this->loadExtensionConfig($node->namespaceURI, $values); + } + + $this->loadExtensionConfigs(); + } + + /** + * Converts a \DOMElement object to a PHP array. + * + * The following rules applies during the conversion: + * + * * Each tag is converted to a key value or an array + * if there is more than one "value" + * + * * The content of a tag is set under a "value" key (bar) + * if the tag also has some nested tags + * + * * The attributes are converted to keys () + * + * * The nested-tags are converted to keys (bar) + * + * @param \DOMElement $element A \DOMElement instance + */ + public static function convertDomElementToArray(\DOMElement $element): mixed + { + return XmlUtils::convertDomElementToArray($element, false); + } +} diff --git a/symfony/DependencyInjection/Loader/schema/dic/services/services-1.0.xsd b/symfony/DependencyInjection/Loader/schema/dic/services/services-1.0.xsd new file mode 100644 index 00000000..befdb658 --- /dev/null +++ b/symfony/DependencyInjection/Loader/schema/dic/services/services-1.0.xsd @@ -0,0 +1,405 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +