diff --git a/composer.json b/composer.json index 95434309..03f37586 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,7 @@ } ], "require": { - "php": "8.*|7.4.*|7.3.*|7.2.*", + "php": ">=8.1", "ext-json": "*", "ext-iconv": "*", "ext-xmlwriter": "*", @@ -17,12 +17,13 @@ "ext-curl": "*", "ext-zip": "*", "ext-simplexml": "*", - "symfony/yaml": "6.*|5.*|^4.3|^3.4", + "symfony/yaml": "6.*", "beberlei/assert": "^3.3", "adhocore/cli": "^0.9.0", "firebase/php-jwt": "^v6.8", "aspera/xlsx-reader": "^1.1.0", - "psr/log": "^3.0" + "psr/log": "^3.0", + "induxx/shared-app-libraries": "dev-main as 1.0" }, "require-dev": { "roave/security-advisories": "dev-master", @@ -47,6 +48,12 @@ "Tests\\Misery\\": "tests/" } }, + "repositories": [ + { + "type": "composer", + "url": "https://app-satis.induxx.be" + } + ], "scripts": { "test": [ "php vendor/bin/phpunit", diff --git a/config/template/akeneo/jsonl/pull_akeneo-entities.yaml b/config/template/akeneo/jsonl/pull_akeneo-entities.yaml new file mode 100644 index 00000000..bcb030f0 --- /dev/null +++ b/config/template/akeneo/jsonl/pull_akeneo-entities.yaml @@ -0,0 +1,20 @@ +context: + filter: [] + identifier_filter_list: [] + query: '' + +pipeline: + input: + http: + type: rest_api + account: '%akeneo_read_connection%' + endpoint: '%endpoint%' + method: GET + filter: '%filter%' + identifier_filter_list: '%identifier_filter_list%' + limiters: + querystring: '%query%' + output: + writer: + type: jsonl + filename: 'akeneo_%endpoint%.jsonl' diff --git a/config/template/akeneo/json/pull_akeneo-entities.yaml b/config/template/akeneo/jsonl/pull_akeneo-entity_options.yaml similarity index 77% rename from config/template/akeneo/json/pull_akeneo-entities.yaml rename to config/template/akeneo/jsonl/pull_akeneo-entity_options.yaml index 54ce30e2..c8065af4 100644 --- a/config/template/akeneo/json/pull_akeneo-entities.yaml +++ b/config/template/akeneo/jsonl/pull_akeneo-entity_options.yaml @@ -5,6 +5,8 @@ pipeline: account: '%akeneo_read_connection%' endpoint: '%endpoint%' method: GET + multiple: true + attribute_list: '%attribute_options%' output: writer: type: jsonl diff --git a/config/template/akeneo/json/pull_akeneo-reference-entities.yaml b/config/template/akeneo/jsonl/pull_akeneo-reference-entities.yaml similarity index 100% rename from config/template/akeneo/json/pull_akeneo-reference-entities.yaml rename to config/template/akeneo/jsonl/pull_akeneo-reference-entities.yaml diff --git a/config/template/akeneo/json/push_akeneo-entities.yaml b/config/template/akeneo/jsonl/push_akeneo-entities.yaml similarity index 100% rename from config/template/akeneo/json/push_akeneo-entities.yaml rename to config/template/akeneo/jsonl/push_akeneo-entities.yaml diff --git a/config/template/akeneo/json/push_akeneo-reference-entities.yaml b/config/template/akeneo/jsonl/push_akeneo-reference-entities.yaml similarity index 100% rename from config/template/akeneo/json/push_akeneo-reference-entities.yaml rename to config/template/akeneo/jsonl/push_akeneo-reference-entities.yaml diff --git a/docker/fpm/Dockerfile b/docker/fpm/Dockerfile index fc9bf280..f82236ef 100644 --- a/docker/fpm/Dockerfile +++ b/docker/fpm/Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.0-fpm +FROM php:8.1-fpm COPY --from=composer /usr/bin/composer /usr/bin/composer @@ -13,10 +13,14 @@ COPY app.ini $PHP_INI_DIR/conf.d/app.ini RUN apt update RUN apt install -y moreutils +# pyhton panda (Transformations : Use a Virtual Environment /opt/venv) RUN apt-get update && \ - apt-get --no-install-recommends --no-install-suggests --yes --quiet install python3 python3-pip && \ - pip install pandas && \ - apt-get clean && apt-get --yes --quiet autoremove --purge && \ - rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ - /usr/share/doc/* /usr/share/groff/* /usr/share/info/* /usr/share/linda/* \ - /usr/share/lintian/* /usr/share/locale/* /usr/share/man/* \ No newline at end of file + apt-get install --no-install-recommends --yes python3 python3-pip python3-venv && \ + python3 -m venv /opt/venv && \ + /opt/venv/bin/pip install pandas && \ + apt-get clean && apt-get --yes autoremove --purge && \ + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ + /usr/share/doc/* /usr/share/groff/* /usr/share/info/* /usr/share/linda/* \ + /usr/share/lintian/* /usr/share/locale/* /usr/share/man/* + +ENV PATH="/opt/venv/bin:$PATH" \ No newline at end of file diff --git a/src/Command/TransformationCommand.php b/src/Command/TransformationCommand.php index 0744e7d0..b19e34c0 100644 --- a/src/Command/TransformationCommand.php +++ b/src/Command/TransformationCommand.php @@ -3,6 +3,10 @@ namespace Misery\Command; use Ahc\Cli\Input\Command; +use App\Component\ChangeManager\ChangeManager; +use App\Component\Common\Resource\ChangeResource; +use App\Infra\Adapter\KeyValueStore\FileKeyValueStoreAdapter; +use App\Infra\Redis\RedisIdentityScope; use Assert\Assertion; use Misery\Component\Common\FileManager\LocalFileManager; use Misery\Component\Common\Functions\ArrayFunctions; @@ -74,15 +78,34 @@ public function execute(string $file, string $source, string $workpath, bool $de new OutputLogger() ); + // setting up a fake File based key-value store for our change-manager + $configurationFactory->setChangeManager( + new ChangeManager( + new ChangeResource( + new FileKeyValueStoreAdapter($workpath.DIRECTORY_SEPARATOR.'store'), + new RedisIdentityScope() + ) + ) + ); + + // reading the app_context file + $transformationDir = pathinfo($file, PATHINFO_DIRNAME); + $contextFile = $transformationDir.DIRECTORY_SEPARATOR.'app_context.yaml'; + $context = (is_file($contextFile)) ? Yaml::parseFile($contextFile) : []; + $transformationFile = ArrayFunctions::array_filter_recursive(Yaml::parseFile($file), function ($value) { return $value !== NULL; }); + + // merging it with the original MAIN-step.yaml, after this point the two are merged + $transformationFile = ArrayFunctions::array_merge_recursive($context, $transformationFile); + $configuration = $configurationFactory->parseDirectivesFromConfiguration( array_replace_recursive($transformationFile, [ 'context' => [ # emulated operation datetime stamps - 'operation_create_datetime' => (new \DateTime('NOW'))->format('Hd-m-Y-H-i-s'), - 'last_completed_operation_datetime' => (new \DateTime('NOW'))->modify('-2 hours')->format('Hd-m-Y-H-i-s'), + 'operation_create_datetime' => (new \DateTime('NOW'))->format($transformationFile['context']['date_format'] ?? 'Y-m-d H:i:s'), + 'last_completed_operation_datetime' => (new \DateTime('NOW'))->modify('-2 hours')->format($transformationFile['context']['date_format'] ?? 'Y-m-d H:i:s'), 'transformation_file' => $file, 'sources' => $source, 'scripts' => __DIR__.'/../../scripts', diff --git a/src/Component/Action/ActionItemInterface.php b/src/Component/Action/ActionItemInterface.php new file mode 100644 index 00000000..1a9b1f35 --- /dev/null +++ b/src/Component/Action/ActionItemInterface.php @@ -0,0 +1,10 @@ + null, + 'field' => null, 'format' => '%s', ]; + public function applyAsItem(ItemInterface $item): void + { + $format = $this->getOption('format'); + $field = $this->getOption('field', $this->getOption('key')); + if (null == $field) { + return; + } + + $dataValues = []; + foreach (ValueFormatter::getKeys($format) as $key) { + $dataValues[$key] = $item->getItem($key)?->getDataValue(); + } + + $item->addItem( + $field, + ValueFormatter::format($format, $dataValues) + ); + } + public function apply(array $item): array { $field = $this->getOption('field', $this->getOption('key')); @@ -28,7 +47,7 @@ public function apply(array $item): array } // don't check if array_key_exist here, concat should always work, if the field doesn't exist - // somewhat the point to form a new field from concatination + // somewhat the point to form a new field from concatenation $item[$field] = ValueFormatter::format($this->getOption('format'), $item); return $item; diff --git a/src/Component/Action/CopyAction.php b/src/Component/Action/CopyAction.php index 8d7352cf..7ac338d7 100644 --- a/src/Component/Action/CopyAction.php +++ b/src/Component/Action/CopyAction.php @@ -5,8 +5,9 @@ use Misery\Component\Common\Options\OptionsInterface; use Misery\Component\Common\Options\OptionsTrait; use Misery\Component\Converter\Matcher; +use Misery\Model\DataStructure\ItemInterface; -class CopyAction implements ActionInterface, OptionsInterface +class CopyAction implements OptionsInterface, ActionItemInterface { use OptionsTrait; @@ -18,6 +19,18 @@ class CopyAction implements ActionInterface, OptionsInterface 'to' => null, ]; + public function applyAsItem(ItemInterface $item): void + { + $from = $this->getOption('from'); + $to = $this->getOption('to'); + + if (null === $from || null === $to) { + return; + } + + $item->copyItem($from, $to); + } + public function apply(array $item): array { $to = $this->getOption('to'); diff --git a/src/Component/Action/DebugAction.php b/src/Component/Action/DebugAction.php index 06f7054c..456d41f2 100644 --- a/src/Component/Action/DebugAction.php +++ b/src/Component/Action/DebugAction.php @@ -2,10 +2,12 @@ namespace Misery\Component\Action; +use JetBrains\PhpStorm\NoReturn; use Misery\Component\Common\Options\OptionsInterface; use Misery\Component\Common\Options\OptionsTrait; +use Misery\Model\DataStructure\ItemInterface; -class DebugAction implements OptionsInterface, ActionInterface +class DebugAction implements OptionsInterface, ActionInterface, ActionItemInterface { use OptionsTrait; @@ -17,6 +19,11 @@ class DebugAction implements OptionsInterface, ActionInterface 'until_field' => null, ]; + public function applyAsItem(ItemInterface $item): void + { + dd($item); + } + public function apply(array $item): array { $untilField = $this->getOption('until_field'); diff --git a/src/Component/Action/ExpandAction.php b/src/Component/Action/ExpandAction.php index f5683da0..611af7c5 100644 --- a/src/Component/Action/ExpandAction.php +++ b/src/Component/Action/ExpandAction.php @@ -4,8 +4,9 @@ use Misery\Component\Common\Options\OptionsInterface; use Misery\Component\Common\Options\OptionsTrait; +use Misery\Model\DataStructure\ItemInterface; -class ExpandAction implements OptionsInterface +class ExpandAction implements OptionsInterface, ActionItemInterface { use OptionsTrait; @@ -17,6 +18,18 @@ class ExpandAction implements OptionsInterface 'list' => null, ]; + public function applyAsItem(ItemInterface $item): void + { + $list = $this->getOption('set', $this->getOption('list', [])); + if (empty($list)) { + return; + } + + foreach ($list as $itemCode => $itemValue) { + $item->addItem($itemCode, $itemValue); + } + } + public function apply(array $item): array { return array_replace_recursive($this->getOption('set', $this->getOption('list', [])), $item); diff --git a/src/Component/Action/ExtensionAction.php b/src/Component/Action/ExtensionAction.php index e01bf5d1..55af09e5 100644 --- a/src/Component/Action/ExtensionAction.php +++ b/src/Component/Action/ExtensionAction.php @@ -8,8 +8,9 @@ use Misery\Component\Configurator\ConfigurationTrait; use Misery\Component\Configurator\ReadOnlyConfiguration; use Misery\Component\Extension\ExtensionInterface; +use Misery\Model\DataStructure\ItemInterface; -class ExtensionAction implements OptionsInterface, ConfigurationAwareInterface +class ExtensionAction implements OptionsInterface, ConfigurationAwareInterface, ActionItemInterface { use OptionsTrait; use ConfigurationTrait; @@ -22,6 +23,24 @@ class ExtensionAction implements OptionsInterface, ConfigurationAwareInterface 'extension' => null, ]; + public function applyAsItem(ItemInterface $item): void + { + $extension = $this->getOption('extension'); + if (null === $extension) { + return; + } + + // loadExtension + if (null === $this->extension) { + $extensionFile = $this->configuration->getExtensions()[$extension.'.php'] ?? null; + $this->extension = $this->loadExtension($extensionFile, 'Extensions\\'.$extension); + } + + if (method_exists($this->extension, 'applyAsItem')) { + $this->extension->applyAsItem($item); + } + } + public function apply($item): array { $extension = $this->getOption('extension'); diff --git a/src/Component/Action/FirstValueAction.php b/src/Component/Action/FirstValueAction.php new file mode 100644 index 00000000..b9467abb --- /dev/null +++ b/src/Component/Action/FirstValueAction.php @@ -0,0 +1,83 @@ + [], + 'store_field' => null, + 'default_value' => null, + ]; + + public function applyAsItem(ItemInterface $item): void + { + $defaultValue = $this->getOption('default_value'); + $storeField = $this->getOption('store_field'); + $fields = $this->getOption('fields'); + + foreach ($fields as $field) { + if (!empty($item->getItem($field)->getDataValue())) { + $item->copyItem($field, $storeField); + return; + } + } + + $item->addItem($storeField, $defaultValue); + } + + public function apply(array $item): array + { + $defaultValue = $this->getOption('default_value'); + $storeField = $this->getOption('store_field'); + $fields = $this->getOption('fields'); + $matcher = Matcher::create('values|'.$storeField); + + foreach ($fields as $field) { + $field = $this->findMatchedValueData($item, $field) ?? $field; + + // COPY matcher if match is found + if (isset($item[$field]['matcher'])) { + $matcher = $item[$field]['matcher']->duplicateWithNewKey($storeField); + $item[$matcher->getMainKey()] = $item[$field]; + $item[$matcher->getMainKey()]['matcher'] = $matcher; + return $item; + } + } + + if (!isset($item[$matcher->getMainKey()])) { + $item[$matcher->getMainKey()] = [ + 'matcher' => $matcher, + 'data' => $defaultValue, + 'locale' => null, + 'scope' => null, + ]; + } + + return $item; + } + + private function findMatchedValueData(array $item, string $field): int|string|null + { + foreach ($item as $key => $itemValue) { + $matcher = $itemValue['matcher'] ?? null; + /** @var $matcher Matcher */ + if ($matcher && $matcher->matches($field)) { + return $key; + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/Component/Action/FrameAction.php b/src/Component/Action/FrameAction.php index 7ddeb379..2e665cf0 100644 --- a/src/Component/Action/FrameAction.php +++ b/src/Component/Action/FrameAction.php @@ -4,8 +4,9 @@ use Misery\Component\Common\Options\OptionsInterface; use Misery\Component\Common\Options\OptionsTrait; +use Misery\Model\DataStructure\ItemInterface; -class FrameAction implements OptionsInterface +class FrameAction implements OptionsInterface, ActionItemInterface { use OptionsTrait; @@ -17,6 +18,20 @@ class FrameAction implements OptionsInterface 'list' => [], ]; + public function applyAsItem(ItemInterface $item): void + { + $fields = $this->getOption('fields', $this->getOption('list')); + if (empty($fields)) { + return; + } + + // lets generate a multi-dimensional array + if (isset($fields[0])) { + $fields = array_fill_keys($fields, null); + } + $item->reFrame($fields); + } + public function apply(array $item): array { $fields = $this->getOption('fields', $this->getOption('list')); diff --git a/src/Component/Action/GroupAction.php b/src/Component/Action/GroupAction.php index 18d489f9..6cb4eeab 100644 --- a/src/Component/Action/GroupAction.php +++ b/src/Component/Action/GroupAction.php @@ -6,8 +6,9 @@ use Misery\Component\Common\Options\OptionsTrait; use Misery\Component\Configurator\ConfigurationAwareInterface; use Misery\Component\Configurator\ConfigurationTrait; +use Misery\Model\DataStructure\ItemInterface; -class GroupAction implements OptionsInterface, ConfigurationAwareInterface +class GroupAction implements OptionsInterface, ConfigurationAwareInterface, ActionInterface { use OptionsTrait; use ConfigurationTrait; @@ -20,6 +21,20 @@ class GroupAction implements OptionsInterface, ConfigurationAwareInterface 'actionProcessor' => null, ]; + public function applyAsItem(ItemInterface $item): void + { + if ($this->getOption('name') && !$this->getOption('actionProcessor')) { + $this->setOption( + 'actionProcessor', + $this->getConfiguration()->getGroupedActions($this->getOption('name')) + ); + } + + if ($this->getOption('actionProcessor')) { + $this->getOption('actionProcessor')->process($item); + } + } + public function apply(array $item): array { if ($this->getOption('name') && !$this->getOption('actionProcessor')) { diff --git a/src/Component/Action/ItemActionProcessor.php b/src/Component/Action/ItemActionProcessor.php index d67e253c..4b8dbc24 100644 --- a/src/Component/Action/ItemActionProcessor.php +++ b/src/Component/Action/ItemActionProcessor.php @@ -2,18 +2,19 @@ namespace Misery\Component\Action; +use Misery\Model\DataStructure\ItemInterface; + class ItemActionProcessor { - private $configurationRules; - - public function __construct(array $configurationRules) - { - $this->configurationRules = $configurationRules; - } + public function __construct(private readonly array $configurationRules) {} - public function process(array $item): array + public function process(ItemInterface|array $item): ItemInterface|array { foreach ($this->configurationRules as $name => $action) { + if ($item instanceof ItemInterface) { + $action->applyAsItem($item); + continue; + } $item = $action->apply($item); } diff --git a/src/Component/Action/ItemActionProcessorFactory.php b/src/Component/Action/ItemActionProcessorFactory.php index c6461087..9cdbe49b 100644 --- a/src/Component/Action/ItemActionProcessorFactory.php +++ b/src/Component/Action/ItemActionProcessorFactory.php @@ -7,18 +7,12 @@ use Misery\Component\Common\Registry\RegistryInterface; use Misery\Component\Configurator\Configuration; use Misery\Component\Configurator\ConfigurationAwareInterface; -use Misery\Component\Configurator\ConfigurationManager; use Misery\Component\Reader\ItemReaderAwareInterface; use Misery\Component\Source\SourceCollection; class ItemActionProcessorFactory implements RegisteredByNameInterface { - private $registry; - - public function __construct(RegistryInterface $registry) - { - $this->registry = $registry; - } + public function __construct(private readonly RegistryInterface $registry) {} public function createActionProcessor(SourceCollection $sources, array $configuration): ItemActionProcessor { diff --git a/src/Component/Action/KeyMapperAction.php b/src/Component/Action/KeyMapperAction.php index bb7f1775..659fee18 100644 --- a/src/Component/Action/KeyMapperAction.php +++ b/src/Component/Action/KeyMapperAction.php @@ -6,8 +6,9 @@ use Misery\Component\Common\Options\OptionsTrait; use Misery\Component\Converter\Matcher; use Misery\Component\Mapping\ColumnMapper; +use Misery\Model\DataStructure\ItemInterface; -class KeyMapperAction implements OptionsInterface +class KeyMapperAction implements OptionsInterface, ActionItemInterface { use OptionsTrait; @@ -27,6 +28,32 @@ public function __construct() 'reverse' => false, ]; + public function applyAsItem(ItemInterface $item): void + { + $reverse = $this->getOption('reverse'); + $list = array_filter($this->getOption('list')); + + if ($reverse) { + $keys = []; + foreach ($list as $match => $replacer) { + if ($item->hasItem($replacer)) { + $item->copyItem($replacer, $match); + $keys[] = $replacer; + } + } + foreach ($keys as $keyToUnset) { + $item->removeItem($keyToUnset); + } + return; + } + + foreach ($list as $match => $replacer) { + if ($item->hasItem($match)) { + $item->moveItem($match, $replacer); + } + } + } + public function apply(array $item): array { $reverse = $this->getOption('reverse'); diff --git a/src/Component/Action/ListMapperAction.php b/src/Component/Action/ListMapperAction.php index ffe3a968..d60fd8d9 100644 --- a/src/Component/Action/ListMapperAction.php +++ b/src/Component/Action/ListMapperAction.php @@ -4,8 +4,9 @@ use Misery\Component\Common\Options\OptionsInterface; use Misery\Component\Common\Options\OptionsTrait; +use Misery\Model\DataStructure\ItemInterface; -class ListMapperAction implements ActionInterface, OptionsInterface +class ListMapperAction implements OptionsInterface, ActionItemInterface { use OptionsTrait; @@ -18,6 +19,30 @@ class ListMapperAction implements ActionInterface, OptionsInterface 'list' => [], ]; + public function applyAsItem(ItemInterface $item): void + { + if ([] === $this->getOption('list')) { + return; + } + + if (null === $this->getOption('field')) { + return; + } + $field = $this->getOption('field'); + $storeField = $this->getOption('store_field'); + + $itemNode = $item->getItem($field); + $dataValue = $itemNode?->getDataValue(); + if ($dataValue && array_key_exists($dataValue, $this->getOption('list'))) { + $newValue = $this->options['list'][$dataValue]; + if ($storeField) { + $item->copyItem($storeField, $newValue); + } else { + $item->editItemValue($field, $newValue); + } + } + } + public function apply(array $item): array { $value = $item[$this->options['field']] ?? null; diff --git a/src/Component/Action/MakeItemAction.php b/src/Component/Action/MakeItemAction.php new file mode 100644 index 00000000..7ffa1845 --- /dev/null +++ b/src/Component/Action/MakeItemAction.php @@ -0,0 +1,55 @@ + 'akeneo', + 'attribute_types:list' => [], + ]; + + public function applyAsItem(ItemInterface $item): array + { + $fields = []; + foreach ($item->getItemNodes() as $code => $fieldValue) { + if (null === $fieldValue) { + continue; + } + $matcher = $fieldValue->getMatcher(); + + if ($matcher->matches('values')) { + $fields['values'][$matcher->getPrimaryKey()][] = $fieldValue->getValue(); + } elseif ($matcher->matches('labels')) { + $fields['labels'][$matcher->getPrimaryKey()][] = $fieldValue->getValue(); + } else { + $fields[$code] = $fieldValue->getValue(); + } + } + + return $fields; + } + + public function apply(array $item): ItemInterface + { + $attributeTypes = $this->getOption('attribute_types:list'); + if ([] !== $attributeTypes) { + $attributeTypes = $this->configuration->getList($attributeTypes); + } + + return AkeneoItemBuilder::fromProductApiPayload($item, ['attribute_types' => $attributeTypes]); + } +} \ No newline at end of file diff --git a/src/Component/Action/RemoveAction.php b/src/Component/Action/RemoveAction.php index 2fc91c3e..07965f29 100644 --- a/src/Component/Action/RemoveAction.php +++ b/src/Component/Action/RemoveAction.php @@ -4,8 +4,9 @@ use Misery\Component\Common\Options\OptionsInterface; use Misery\Component\Common\Options\OptionsTrait; +use Misery\Model\DataStructure\ItemInterface; -class RemoveAction implements ActionInterface, OptionsInterface +class RemoveAction implements OptionsInterface, ActionItemInterface { use OptionsTrait; @@ -17,6 +18,14 @@ class RemoveAction implements ActionInterface, OptionsInterface 'fields' => [], ]; + public function applyAsItem(ItemInterface $item): void + { + $fields = $this->getOption('keys', $this->getOption('fields')); + foreach ($fields as $field) { + $item->removeItem($field); + } + } + public function apply(array $item): array { $fields = $this->getOption('keys', $this->getOption('fields')); diff --git a/src/Component/Action/RenameAction.php b/src/Component/Action/RenameAction.php index fd5a7cc9..95a6e23e 100644 --- a/src/Component/Action/RenameAction.php +++ b/src/Component/Action/RenameAction.php @@ -4,9 +4,11 @@ use Misery\Component\Common\Options\OptionsInterface; use Misery\Component\Common\Options\OptionsTrait; +use Misery\Component\Converter\Matcher; use Misery\Component\Mapping\ColumnMapper; +use Misery\Model\DataStructure\ItemInterface; -class RenameAction implements OptionsInterface +class RenameAction implements OptionsInterface, ActionItemInterface { use OptionsTrait; @@ -19,16 +21,27 @@ public function __construct() $this->mapper = new ColumnMapper(); } - /** @var array */ - private $options = [ + private array $options = [ 'from' => null, 'to' => null, 'suffix' => null, 'exclude_list' => [], 'filter_list' => null, 'fields' => [], + 'strict_mode' => true, ]; + public function applyAsItem(ItemInterface $item): void + { + $from = $this->getOption('from'); + $to = $this->getOption('to'); + if (!$from || !$to) { + return; + } + + $item->moveItem($from, $to); + } + public function apply(array $item): array { $from = $this->getOption('from'); diff --git a/src/Component/Action/RetainAction.php b/src/Component/Action/RetainAction.php index 602dd327..f0075190 100644 --- a/src/Component/Action/RetainAction.php +++ b/src/Component/Action/RetainAction.php @@ -4,6 +4,7 @@ use Misery\Component\Common\Options\OptionsInterface; use Misery\Component\Common\Options\OptionsTrait; +use Misery\Model\DataStructure\ItemInterface; class RetainAction implements OptionsInterface { @@ -13,7 +14,8 @@ class RetainAction implements OptionsInterface /** @var array */ private $options = [ - 'keys' => [], + 'keys' => null, + 'fields' => [], 'mode' => 'multi', ]; @@ -22,7 +24,8 @@ class RetainAction implements OptionsInterface */ private function applyForSingleDimensionItem(array $item): array { - $keys = array_intersect($this->options['keys'], array_keys($item)); + $fields = $this->getOption('keys', $this->getOption('fields')); + $keys = array_intersect($fields, array_keys($item)); if (empty($keys)) { return $item; } @@ -35,6 +38,22 @@ private function applyForSingleDimensionItem(array $item): array return $tmp; } + public function applyAsItem(ItemInterface $item): ItemInterface + { + $fields = $this->getOption('keys', $this->getOption('fields')); + if (empty($fields)) { + return $item; + } + + $itemCodesToRemove = array_diff($item->getItemCodes(), $fields); + foreach ($itemCodesToRemove as $itemCode) { + $item->removeItem($itemCode); + } + + return $item; + } + + /** * The default apply will look into for multi dimensional array */ @@ -44,7 +63,7 @@ public function apply(array $item): array return $this->applyForSingleDimensionItem($item); } - $optionsToKeep = $this->options['keys']; + $optionsToKeep = $this->getOption('keys', $this->getOption('fields')); // we loop all configured option values $valuesToKeep = $this->getNestedValuesToKeep($optionsToKeep); diff --git a/src/Component/Action/SetValueAction.php b/src/Component/Action/SetValueAction.php index ed9284e6..50e3d0ee 100644 --- a/src/Component/Action/SetValueAction.php +++ b/src/Component/Action/SetValueAction.php @@ -5,8 +5,9 @@ use Misery\Component\Common\Options\OptionsInterface; use Misery\Component\Common\Options\OptionsTrait; use Misery\Component\Converter\Matcher; +use Misery\Model\DataStructure\ItemInterface; -class SetValueAction implements ActionInterface, OptionsInterface +class SetValueAction implements ActionItemInterface, OptionsInterface { use OptionsTrait; @@ -17,17 +18,41 @@ class SetValueAction implements ActionInterface, OptionsInterface 'key' => null, 'field' => null, 'value' => null, + 'allow_creation' => false, ]; + public function applyAsItem(ItemInterface $item): void + { + $allowCreation = $this->getOption('allow_creation'); + $field = $this->getOption('field', $this->getOption('key')); + $value = $this->getOption('value'); + + if ($allowCreation) { + $item->addItem($field, $value); + return; + } + + if ($item->hasItem($field)) { + $item->editItemValue($field, $value); + } + } + public function apply(array $item): array { + $allowCreation = $this->getOption('allow_creation'); $field = $this->getOption('field', $this->getOption('key')); $value = $this->getOption('value'); - $key = $this->findMatchedValueData($item, $field); - if ($key) { - $item[$key]['data'] = $value; + $field = $this->findMatchedValueData($item, $field) ?? $field; + + // matcher based data object + if (isset($item[$field]['data'])) { + $item[$field]['data'] = $value; + return $item; + } + if ($allowCreation) { + $item[$field] = $value; return $item; } diff --git a/src/Component/Action/SkipAction.php b/src/Component/Action/SkipAction.php index e3b797a1..88165156 100644 --- a/src/Component/Action/SkipAction.php +++ b/src/Component/Action/SkipAction.php @@ -5,8 +5,9 @@ use Misery\Component\Common\Options\OptionsInterface; use Misery\Component\Common\Options\OptionsTrait; use Misery\Component\Common\Pipeline\Exception\SkipPipeLineException; +use Misery\Model\DataStructure\ItemInterface; -class SkipAction implements OptionsInterface, ActionInterface +class SkipAction implements OptionsInterface, ActionItemInterface { use OptionsTrait; @@ -20,6 +21,14 @@ class SkipAction implements OptionsInterface, ActionInterface ]; private array $values = []; + public function applyAsItem(ItemInterface $item): void + { + $field = $this->getOption('field'); + $dateValue = $item->getItem($field)?->getDataValue(); + + $this->apply([$field => $dateValue]); + } + public function apply(array $item): array { $field = $this->getOption('field'); diff --git a/src/Component/Action/StoreAction.php b/src/Component/Action/StoreAction.php index c6986d24..dc4cd5dc 100644 --- a/src/Component/Action/StoreAction.php +++ b/src/Component/Action/StoreAction.php @@ -2,13 +2,12 @@ namespace Misery\Component\Action; -use App\Component\ChangeManager\ChangeSetLabelMaker; - +use Misery\Component\Common\Functions\ArrayFunctions; use Misery\Component\Common\Options\OptionsInterface; use Misery\Component\Common\Options\OptionsTrait; use Misery\Component\Configurator\ConfigurationAwareInterface; use Misery\Component\Configurator\ConfigurationTrait; -use Misery\Component\Converter\Matcher; +use App\Component\ChangeManager\ChangeSetLabelMaker; class StoreAction implements ActionInterface, OptionsInterface, ConfigurationAwareInterface { @@ -24,10 +23,21 @@ class StoreAction implements ActionInterface, OptionsInterface, ConfigurationAwa public ItemActionProcessor $trueActionProcessor; public ItemActionProcessor $falseActionProcessor; + private array $defaults = [ + 'change_manager' => [ + 'all_values' => true, + 'values' => [], + 'context' => [ + 'locales' => [], + 'scope' => null, + ], + ], + ]; + /** @var array */ private $options = [ 'event' => null, - 'identifier' => null, + 'identifier' => 'identifier', 'entity' => 'product', 'change_manager' => [ 'all_values' => true, @@ -37,6 +47,7 @@ class StoreAction implements ActionInterface, OptionsInterface, ConfigurationAwa 'scope' => null, ], ], + 'store_product' => true, 'init' => false, 'true_action' => [], 'false_action' => [], @@ -55,6 +66,14 @@ public function init(): void $this->falseActionProcessor = $this->configuration->generateActionProcessor($falseAction); } $this->setOption('init', true); + + $this->setOption( + 'change_manager', + ArrayFunctions::array_merge_recursive( + $this->defaults['change_manager'], + $this->getOption('change_manager') + ) + ); } } @@ -106,13 +125,16 @@ public function apply(array $item): array $item = $this->trueActionProcessor->process($item); } + $this->storeProduct($identifier); + + return $item; + } else { + // see GroupAction, get actionProcessor, process your action(s) if ([] !== $falseAction) { $item = $this->falseActionProcessor->process($item); } - $this->storeProduct($identifier); - return $item; } @@ -124,6 +146,10 @@ public function apply(array $item): array private function storeProduct(string $identifier): void { - $this->configuration->changeManager->persistChange($identifier); + $storeProduct = $this->getOption('store_product'); + + if ($storeProduct) { + $this->configuration->changeManager->persistChange($identifier); + } } } \ No newline at end of file diff --git a/src/Component/Akeneo/Client/ApiReader.php b/src/Component/Akeneo/Client/ApiReader.php index eb466749..5e21634d 100644 --- a/src/Component/Akeneo/Client/ApiReader.php +++ b/src/Component/Akeneo/Client/ApiReader.php @@ -2,190 +2,30 @@ namespace Misery\Component\Akeneo\Client; -use Misery\Component\Common\Client\ApiClientInterface; -use Misery\Component\Common\Client\ApiEndpointInterface; -use Misery\Component\Common\Client\Paginator; -use Misery\Component\Common\Utils\ValueFormatter; -use Misery\Component\Reader\ItemReader; +use App\Component\Common\Resource\EntityResourceInterface; use Misery\Component\Reader\ReaderInterface; class ApiReader implements ReaderInterface { - private $client; - private $page; - private $endpoint; - private $context; - private $activeEndpoint; - public function __construct( - ApiClientInterface $client, - ApiEndpointInterface $endpoint, - array $context - ) { - $this->client = $client; - $this->endpoint = $endpoint; - $this->context = $context; - } - - private function request($endpoint = false): array - { - if (!$endpoint) { - $endpoint = $this->endpoint->getAll(); - } - - // todo - create function for this. Check how filtering must be applied. - if(isset($this->context['limiters']['query_array'])) { - $endpoint = $this->client->getUrlGenerator()->generate($endpoint); - - $params = ['search' => json_encode($this->context['limiters']['query_array'])]; - if ($this->endpoint instanceof ApiProductModelsEndpoint || $this->endpoint instanceof ApiProductsEndpoint) { - $params['pagination_type'] = 'search_after'; - } - - return $this->client - ->search($endpoint, $params) - ->getResponse() - ->getContent(); - } - - if(isset($this->context['limiters']['querystring'])) { - $querystring = preg_replace('/\s+/', '+', $this->context['limiters']['querystring']); - $querystring = ValueFormatter::format($querystring, $this->context); - $endpoint = sprintf($querystring, $endpoint); - } - - $items = []; - if (isset($this->context['filters']) && !empty($this->context['filters'])) { - if ($this->endpoint instanceof ApiProductModelsEndpoint || $this->endpoint instanceof ApiProductsEndpoint) { - $endpoint = sprintf('%s?pagination_type=search_after&search=', $endpoint); - } else { - $endpoint = sprintf('%s?search=', $endpoint); - } - foreach ($this->context['filters'] as $attrCode => $filterValues) { - $valueChunks = array_chunk(array_values($filterValues),100); - foreach ($valueChunks as $filterChunk) { - $filter = [$attrCode => [['operator' => 'IN', 'value' => $filterChunk]]]; - $chunkEndpoint = sprintf('%s%s&limit=100', $endpoint, json_encode($filter)); - - $result = $this->client - ->get($this->client->getUrlGenerator()->generate($chunkEndpoint)) - ->getResponse() - ->getContent(); - - if (empty($items)){ - $items = $result; - - continue; - } - - $items['_embedded']['items'] = array_merge( - $items['_embedded']['items'], - $result['_embedded']['items'] - ); - } - } - - return $items; - } - - $url = $this->client->getUrlGenerator()->generate($endpoint); - if ($this->endpoint instanceof ApiProductModelsEndpoint || $this->endpoint instanceof ApiProductsEndpoint) { - if(!strpos($url, 'pagination_type')) { - if(isset($this->context['limiters']['querystring'])) { - $url = sprintf('%s&pagination_type=search_after', $url); - } else { - $url = sprintf('%s?pagination_type=search_after', $url); - } - } - } - - $items = $this->client - ->get($url) - ->getResponse() - ->getContent(); - - // when supplying a container we jump inside that container to find loopable items - if ($this->context['container']) { - if (array_key_exists($this->context['container'], $items)) { - $items['_embedded']['items'] = $items[$this->context['container']]; - unset($items[$this->context['container']]); - } - } - - return $items; - } + private readonly EntityResourceInterface $entityResource, + private ?\Iterator $cursor = null + ) {} public function read() { - if (isset($this->context['multiple'])) { - return $this->readMultiple(); + if ($this->cursor === null) { + $this->cursor = $this->entityResource->getAll(); } - - // TODO we need to align all readers together into this version - if (str_contains($this->endpoint::class, 'E5DalApi')) { - // new Paginator - if (null === $this->page) { - $this->page = $this->client->getPaginator($this->endpoint->getAll()); - } - $item = $this->page->current(); - $this->page->next(); - return $item; + if (!$this->cursor->valid()) { + return null; } - - if (null === $this->page) { - $this->page = Paginator::create($this->client, $this->request()); - } - - $item = $this->page->getItems()->current(); - if (!$item) { - $this->page = $this->page->getNextPage(); - if (!$this->page) { - return false; - } - $item = $this->page->getItems()->current(); - } - $this->page->getItems()->next(); - - unset($item['_links']); + $item = $this->cursor->current(); + $this->cursor->next(); return $item; } - public function readMultiple() - { - foreach ($this->context['list'] as $key => $endpointItem) { - if ($this->activeEndpoint !== $endpointItem || null === $this->page) { - $endpoint = sprintf($this->endpoint->getAll(), $endpointItem); - $this->page = Paginator::create($this->client, $this->request($endpoint)); - $this->activeEndpoint = $endpointItem; - } - - $item = $this->page->getItems()->current(); - if (!$item) { - $this->page = $this->page->getNextPage(); - if (!$this->page) { - unset($this->context['list'][$key]); - - return $this->readMultiple(); - } - $item = $this->page->getItems()->current(); - } - - $this->page->getItems()->next(); - if (!$item) { - unset($this->context['list'][$key]); - - return $this->readMultiple(); - } - - unset($item['_links']); - - return $item; - } - - return false; - } - public function getIterator(): \Iterator { while ($item = $this->read()) { @@ -195,45 +35,17 @@ public function getIterator(): \Iterator public function find(array $constraints): ReaderInterface { - // TODO we need to implement a find or search int the API - $reader = $this; - foreach ($constraints as $columnName => $rowValue) { - if (is_string($rowValue)) { - $rowValue = [$rowValue]; - } - - $reader = $reader->filter(static function ($row) use ($rowValue, $columnName) { - return in_array($row[$columnName], $rowValue); - }); - } - - return $reader; + throw new \RuntimeException('Not implemented'); } public function filter(callable $callable): ReaderInterface { - return new ItemReader($this->processFilter($callable)); - } - - private function processFilter(callable $callable): \Generator - { - foreach ($this->getIterator() as $key => $row) { - if (true === $callable($row)) { - yield $key => $row; - } - } + throw new \RuntimeException('Not implemented'); } public function map(callable $callable): ReaderInterface { - return new ItemReader($this->processMap($callable)); - } - - private function processMap(callable $callable): \Generator - { - foreach ($this->getIterator() as $key => $row) { - yield $key => $callable($row); - } + throw new \RuntimeException('Not implemented'); } public function getItems(): array diff --git a/src/Component/Akeneo/Client/ApiReaderOld.php b/src/Component/Akeneo/Client/ApiReaderOld.php new file mode 100644 index 00000000..a1eaacf2 --- /dev/null +++ b/src/Component/Akeneo/Client/ApiReaderOld.php @@ -0,0 +1,248 @@ +client = $client; + $this->endpoint = $endpoint; + $this->context = $context; + } + + private function request($endpoint = false): array + { + if (!$endpoint) { + $endpoint = $this->endpoint->getAll(); + } + + // todo - create function for this. Check how filtering must be applied. + if(isset($this->context['limiters']['query_array'])) { + $endpoint = $this->client->getUrlGenerator()->generate($endpoint); + + $params = ['search' => json_encode($this->context['limiters']['query_array'])]; + if ($this->endpoint instanceof ApiProductModelsEndpoint || $this->endpoint instanceof ApiProductsEndpoint) { + $params['pagination_type'] = 'search_after'; + } + + return $this->client + ->search($endpoint, $params) + ->getResponse() + ->getContent(); + } + + if(isset($this->context['limiters']['querystring'])) { + $querystring = preg_replace('/\s+/', '+', $this->context['limiters']['querystring']); + $querystring = ValueFormatter::format($querystring, $this->context); + $endpoint = sprintf($querystring, $endpoint); + } + + $items = []; + if (isset($this->context['filters']) && !empty($this->context['filters'])) { + if ($this->endpoint instanceof ApiProductModelsEndpoint || $this->endpoint instanceof ApiProductsEndpoint) { + $endpoint = sprintf('%s?pagination_type=search_after&search=', $endpoint); + } else { + $endpoint = sprintf('%s?search=', $endpoint); + } + foreach ($this->context['filters'] as $attrCode => $filterValues) { + $valueChunks = array_chunk(array_values($filterValues),100); + foreach ($valueChunks as $filterChunk) { + $filter = [$attrCode => [['operator' => 'IN', 'value' => $filterChunk]]]; + $chunkEndpoint = sprintf('%s%s&limit=100', $endpoint, json_encode($filter)); + + $result = $this->client + ->get($this->client->getUrlGenerator()->generate($chunkEndpoint)) + ->getResponse() + ->getContent(); + + if (empty($items)){ + $items = $result; + + continue; + } + + $items['_embedded']['items'] = array_merge( + $items['_embedded']['items'], + $result['_embedded']['items'] + ); + } + } + + return $items; + } + + $url = $this->client->getUrlGenerator()->generate($endpoint); + if ($this->endpoint instanceof ApiProductModelsEndpoint || $this->endpoint instanceof ApiProductsEndpoint) { + if(!strpos($url, 'pagination_type')) { + if(isset($this->context['limiters']['querystring'])) { + $url = sprintf('%s&pagination_type=search_after', $url); + } else { + $url = sprintf('%s?pagination_type=search_after', $url); + } + } + } + + $items = $this->client + ->get($url) + ->getResponse() + ->getContent(); + + // when supplying a container we jump inside that container to find loopable items + if ($this->context['container']) { + if (array_key_exists($this->context['container'], $items)) { + $items['_embedded']['items'] = $items[$this->context['container']]; + unset($items[$this->context['container']]); + } + } + + return $items; + } + + public function read() + { + if (isset($this->context['multiple'])) { + return $this->readMultiple(); + } + + // TODO we need to align all readers together into this version + if (str_contains($this->endpoint::class, 'E5DalApi')) { + // new Paginator + if (null === $this->page) { + $this->page = $this->client->getPaginator($this->endpoint->getAll()); + } + $item = $this->page->current(); + $this->page->next(); + return $item; + } + + if (null === $this->page) { + $this->page = Paginator::create($this->client, $this->request()); + } + + $item = $this->page->getItems()->current(); + if (!$item) { + $this->page = $this->page->getNextPage(); + if (!$this->page) { + return false; + } + $item = $this->page->getItems()->current(); + } + $this->page->getItems()->next(); + + unset($item['_links']); + + return $item; + } + + public function readMultiple() + { + foreach ($this->context['list'] as $key => $endpointItem) { + if ($this->activeEndpoint !== $endpointItem || null === $this->page) { + $endpoint = sprintf($this->endpoint->getAll(), $endpointItem); + $this->page = Paginator::create($this->client, $this->request($endpoint)); + $this->activeEndpoint = $endpointItem; + } + + $item = $this->page->getItems()->current(); + if (!$item) { + $this->page = $this->page->getNextPage(); + if (!$this->page) { + unset($this->context['list'][$key]); + + return $this->readMultiple(); + } + $item = $this->page->getItems()->current(); + } + + $this->page->getItems()->next(); + if (!$item) { + unset($this->context['list'][$key]); + + return $this->readMultiple(); + } + + unset($item['_links']); + + return $item; + } + + return false; + } + + public function getIterator(): \Iterator + { + while ($item = $this->read()) { + yield $item; + } + } + + public function find(array $constraints): ReaderInterface + { + // TODO we need to implement a find or search int the API + $reader = $this; + foreach ($constraints as $columnName => $rowValue) { + if (is_string($rowValue)) { + $rowValue = [$rowValue]; + } + + $reader = $reader->filter(static function ($row) use ($rowValue, $columnName) { + return in_array($row[$columnName], $rowValue); + }); + } + + return $reader; + } + + public function filter(callable $callable): ReaderInterface + { + return new ItemReader($this->processFilter($callable)); + } + + private function processFilter(callable $callable): \Generator + { + foreach ($this->getIterator() as $key => $row) { + if (true === $callable($row)) { + yield $key => $row; + } + } + } + + public function map(callable $callable): ReaderInterface + { + return new ItemReader($this->processMap($callable)); + } + + private function processMap(callable $callable): \Generator + { + foreach ($this->getIterator() as $key => $row) { + yield $key => $callable($row); + } + } + + public function getItems(): array + { + return iterator_to_array($this->getIterator()); + } + + public function clear(): void + { + // TODO: Implement clear() method. + } +} diff --git a/src/Component/Akeneo/Client/HttpReaderFactory.php b/src/Component/Akeneo/Client/HttpReaderFactory.php index d0c55c10..d98f063d 100644 --- a/src/Component/Akeneo/Client/HttpReaderFactory.php +++ b/src/Component/Akeneo/Client/HttpReaderFactory.php @@ -2,12 +2,20 @@ namespace Misery\Component\Akeneo\Client; +use App\Component\Akeneo\Api\Resources\AkeneoAttributeOptionsResource; +use App\Component\Akeneo\Api\Resources\AkeneoAttributesResource; +use App\Component\Akeneo\Api\Resources\AkeneoCategoryResource; +use App\Component\Akeneo\Api\Resources\AkeneoFamiliesResource; +use App\Component\Akeneo\Api\Resources\AkeneoProductsResource; +use App\Component\Akeneo\Api\Resources\AkeneoReferenceEntityRecordResource; +use App\Component\Akeneo\Api\Resources\AkeneoReferenceEntityResource; +use App\Component\Common\Cursor\MultiCursor; +use App\Component\Common\Resource\EntityResourceInterface; +use App\Component\Common\Resource\SearchAbleEntityResourceInterface; use Assert\Assert; -use Misery\Component\Common\Client\Endpoint\BasicApiEndpoint; use Misery\Component\Common\Registry\RegisteredByNameInterface; use Misery\Component\Configurator\Configuration; use Misery\Component\Reader\ReaderInterface; -use Misery\Component\Writer\ItemWriterInterface; class HttpReaderFactory implements RegisteredByNameInterface { @@ -18,73 +26,109 @@ public function createFromConfiguration(array $configuration, Configuration $con 'type must be filled in.' )->notEmpty()->string()->inArray(['rest_api']); - if ($configuration['type'] === 'rest_api') { - Assert::that( - $configuration['endpoint'], - 'endpoint must be filled in.' - )->notEmpty()->string(); - - Assert::that( - $configuration['method'], - 'method must be filled in.' - )->notEmpty()->string()->inArray([ - 'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'MULTI_PATCH', 'get', 'post', 'put', 'delete', 'patch', 'multi_patch' - ]); - - $endpoint = $configuration['endpoint']; - $method = $configuration['method']; - - Assert::that( - $endpoint, - 'endpoint must be valid.' - )->notNull()->notEmpty(); - - $context = ['filters' => []]; - $context['container'] = $configuration['container'] ?? null; - $configContext = $config->getContext(); - - if (isset($configuration['identifier_filter_list'])) { - $context['multiple'] = true; - $context['list'] = is_array($configuration['identifier_filter_list']) ? $configuration['identifier_filter_list'] : $config->getList($configuration['identifier_filter_list']); - } + Assert::that( + $configuration['endpoint'], + 'endpoint must be filled in.' + )->notEmpty()->string(); + + Assert::that( + $configuration['method'], + 'method must be filled in.' + )->notEmpty()->string()->inArray(['GET', 'get']); + + $endpoint = $configuration['endpoint']; + + $context = ['filters' => []]; + $context['container'] = $configuration['container'] ?? null; + $configContext = $config->getContext(); + + if (isset($configuration['identifier_filter_list'])) { + $context['multiple'] = true; + $context['list'] = is_array($configuration['identifier_filter_list']) ? $configuration['identifier_filter_list'] : $config->getList($configuration['identifier_filter_list']); + } - if (isset($configuration['filters'])) { - $filters = $configuration['filters']; - foreach ($filters as $fieldCode => $filterConfig) { - foreach ($filterConfig as $filterType => $value) { - if ($filterType === 'list') { - $context['filters'][$fieldCode] = $config->getList($value); - } + if (isset($configuration['filters'])) { + $filters = $configuration['filters']; + foreach ($filters as $fieldCode => $filterConfig) { + foreach ($filterConfig as $filterType => $value) { + if ($filterType === 'list') { + $context['filters'][$fieldCode] = $config->getList($value); } } } + } - $context['limiters'] = $configuration['limiters'] ?? []; - if (isset($configuration['akeneo-filter'])) { - $akeneoFilter = $configuration['akeneo-filter']; - if (!isset($configContext['akeneo_filters'][$akeneoFilter])) { - throw new \Exception(sprintf('The configuration is using an Akeneo filter code (%s) wich is not linked to this job profile.', $configuration['akeneo-filter'])); - } + $context['limiters'] = $configuration['limiters'] ?? []; + if (isset($configuration['akeneo-filter'])) { + $akeneoFilter = $configuration['akeneo-filter']; + if (!isset($configContext['akeneo_filters'][$akeneoFilter])) { + throw new \Exception(sprintf('The configuration is using an Akeneo filter code (%s) wich is not linked to this job profile.', $configuration['akeneo-filter'])); + } - // create query string - $context['limiters']['query_array'] = $configContext['akeneo_filters'][$akeneoFilter]['search']; + // create query string + $context['limiters']['query_array'] = $configContext['akeneo_filters'][$akeneoFilter]['search']; + } + + $accountCode = $configuration['account'] ?? null; + if (!$accountCode) { + throw new \Exception(sprintf('Account "%s" not found.', $accountCode)); + } + + $client = $config->getAccount($accountCode); + $resources = $config->getResourceCollection($accountCode); + + if (!$client && !$resources) { + throw new \Exception(sprintf('Account "%s" not found.', $accountCode)); + } + + if ($resources) { + $endpoints = [ + 'attributes' => AkeneoAttributesResource::NAME, + 'families' => AkeneoFamiliesResource::NAME, + 'categories' => AkeneoCategoryResource::NAME, + 'products' => AkeneoProductsResource::NAME, + 'options' => AkeneoAttributeOptionsResource::NAME, + 'reference-entities' => AkeneoReferenceEntityRecordResource::NAME, + ]; + $endpoint = $endpoints[$endpoint] ?? null; + + Assert::that($endpoint)->notNull('Unknown endpoint: ' . $endpoint); + + /** @var EntityResourceInterface|SearchAbleEntityResourceInterface $resource */ + $resource = $resources->getResource($endpoint); + + if (!empty($configuration['filter'])) { + $cursor = $resource->query($configuration['filter']); + + return new ApiReader($resource, $cursor); } - $accountCode = (isset($configuration['account'])) ? $configuration['account'] : 'source_resource'; - $account = $config->getAccount($accountCode); + $queryString = $context['limiters']['querystring'] ?? null; + if (!empty($queryString)) { + $cursor = $resource->querystring($queryString); + + return new ApiReader($resource, $cursor); + } - if (!$account) { - throw new \Exception(sprintf('Account "%s" not found.', $accountCode)); + if (!empty($configuration['identifier_filter_list']) && $endpoint === AkeneoReferenceEntityRecordResource::NAME) { + $cursor = new MultiCursor(); + foreach ($configuration['identifier_filter_list'] as $identifier) { + $cursor->addCursor($resource->getAllRecords($identifier)); + } + return new ApiReader($resource, $cursor); } - return new ApiReader( - $account, - $account->getApiEndpoint($endpoint), - $context + $configContext - ); + if (!empty($configuration['identifier_filter_list']) && $endpoint === AkeneoAttributeOptionsResource::NAME) { + $cursor = new MultiCursor(); + foreach ($configuration['identifier_filter_list'] as $identifier) { + $cursor->addCursor($resource->getAllByAttributeCode($identifier)); + } + return new ApiReader($resource, $cursor); + } + return new ApiReader($resource); } - throw new \Exception('Unknown type: ' . $configuration['type']); + throw new \Exception('Unknown or un-configured endpoint: ' . $endpoint); } public function getName(): string diff --git a/src/Component/Akeneo/DataStructure/AkeneoItemBuilder.php b/src/Component/Akeneo/DataStructure/AkeneoItemBuilder.php new file mode 100644 index 00000000..0439b1bc --- /dev/null +++ b/src/Component/Akeneo/DataStructure/AkeneoItemBuilder.php @@ -0,0 +1,107 @@ +isArray() + ->keyExists('values') + ; + + foreach ($productData as $property => $propertyValue) { + if ($property === 'values') { + continue; + } + if (is_array($propertyValue)) { + $propertyValue['matcher'] = Matcher::create($property); + } + $itemObj->addItem($property, $propertyValue); + } + + // convert every value into an ItemNode + foreach ($productData['values'] as $attributeCode => $productValues) { + foreach ($productValues as $productValue) { + $matcher = Matcher::create('values|'.$attributeCode, $productValue['locale'], $productValue['scope']); + // don't overwrite a type when it has been given + $productValue['type'] = $productValue['type'] ?? $attributeData[$matcher->getPrimaryKey()] ?? null; + $productValue['matcher'] = $matcher; + + $itemObj->addItem($matcher->getMainKey(), $productValue, $context); + } + } + + return $itemObj; + } + + public static function fromCatalogApiPayload(array $catalogData, array $context = []): ItemInterface + { + Assert::that($context)->keyExists('class', 'You need to supply a class'); + + $itemObj = new Item($context['class']); + + Assert::that($catalogData) + ->isArray() + ->keyExists('code') + ; + + foreach ($catalogData as $property => $propertyValue) { + if ($property === 'labels') { + foreach ($propertyValue as $locale => $labelValue) { + $value['matcher'] = $m = Matcher::create($property, $locale); + $value['data'] = $labelValue; + $itemObj->addItem($m->getMainKey(), $value); + } + continue; + } + if (is_array($propertyValue)) { + $propertyValue['matcher'] = Matcher::create($property); + } + $itemObj->addItem($property, $propertyValue); + } + + return $itemObj; + } +} diff --git a/src/Component/Akeneo/DataStructure/AkeneoScope.php b/src/Component/Akeneo/DataStructure/AkeneoScope.php new file mode 100644 index 00000000..dc6789c2 --- /dev/null +++ b/src/Component/Akeneo/DataStructure/AkeneoScope.php @@ -0,0 +1,67 @@ +channelCode = $channelCode; + $this->localeCode = $localeCode; + } + + public static function fromArray(array $data): self + { + $self = new self(); + $self->channelCode = $data['scope'] ?? $data['channel_code'] ?? $data['channel'] ?? null; + $self->localeCode = $data['locale'] ?? $data['locale_code'] ?? $data['scope_locale'] ?? null; + + return $self; + } + + public function equals(ScopeInterface $scope): bool + { + return + $this->getChannel() === $scope->getChannel() && + $this->getLocale() === $scope->getLocale() + ; + } + + public function getChannel(): ?string + { + return $this->channelCode; + } + + public function getLocale(): ?string + { + return $this->localeCode; + } + + public function isLocalizable(): bool + { + return $this->localeCode !== null; + } + + public function isScopable(): bool + { + return $this->channelCode !== null; + } + + public function toArray(): array + { + return [ + 'channel' => $this->channelCode, + 'locale' => $this->localeCode, + ]; + } + + public function __toString(): string + { + return implode('|', array_values($this->toArray())); + } +} \ No newline at end of file diff --git a/src/Component/Akeneo/DataStructure/BuildProductMatcher.php b/src/Component/Akeneo/DataStructure/BuildProductMatcher.php new file mode 100644 index 00000000..653b6bd6 --- /dev/null +++ b/src/Component/Akeneo/DataStructure/BuildProductMatcher.php @@ -0,0 +1,91 @@ +getItemNodes() as $code => $itemNode) { + $matcher = $itemNode->getMatcher(); + + if ($matcher->matches('values')) { + $fields['values'][$matcher->getPrimaryKey()][] = $itemNode->getValue(); + } else { + $fields[$code] = $itemNode->getValue(); + } + } + + return $fields; + } + + public static function revertMatcherToProduct(array $productData): array + { + $fields = []; + foreach ($productData as $field => &$fieldValue) { + $matcher = $fieldValue['matcher'] ?? null; + if ($matcher instanceof Matcher) { + unset($fieldValue['matcher']); + $fields['values'][$matcher->getPrimaryKey()][] = $fieldValue; + } else { + $fields[$field] = $fieldValue; + } + } + + return $fields; + } + + public static function revertToKeyValue(array $productData): array + { + $fields = []; + foreach ($productData as $field => $fieldValue) { + $fields[$field] = $fieldValue; + $matcher = $fieldValue['matcher'] ?? null; + if ($matcher instanceof Matcher) { + $fields[$field] = $fieldValue['data'] ?? null; + } + } + + return $fields; + } +} \ No newline at end of file diff --git a/src/Component/Akeneo/DataStructure/Item.php b/src/Component/Akeneo/DataStructure/Item.php new file mode 100644 index 00000000..e8a3fb68 --- /dev/null +++ b/src/Component/Akeneo/DataStructure/Item.php @@ -0,0 +1,86 @@ +matcher = Matcher::create($this->code, $this->scope->getLocale(), $this->scope->getChannel()); + if ($this->matcher->isProperty()) { + $this->type = 'property'; + } + + // when dealing with an array, we expect the 'data' property + if (is_array($this->value) && array_key_exists('data', $this->value)) { + + $this->type = $this->value['type'] ?? 'generic'; + + if (isset($this->value['matcher']) && $this->value['matcher'] instanceof Matcher) { + $this->matcher = $this->value['matcher']; + } + } + } + + public static function withContext(string $code, $value, array $context = []) + { + $scope = new Scope(); + if (array_key_exists('locale', $context) && array_key_exists('scope', $context)) { + $scope = Scope::fromArray($context); + } + + return new self($code, $value, $scope); + } + + public function getContext(): array + { + return [ + 'locale' => $this->scope->getLocale(), + 'scope' => $this->scope->getChannel(), + 'original-code' => $this->getMatcher()->getPrimaryKey(), + 'code' => $this->code, + ]; + } + + public function getScope(): Scope + { + return $this->scope; + } + + public function equals(string $code): bool + { + $this->matcher->matches($code); + } + + public function getMatcher(): Matcher + { + return $this->matcher; + } + + public function getType(): string + { + return $this->type; + } + + public function getCode(): string + { + return $this->code; + } + + public function getValue() + { + return $this->value; + } + + public function getDataValue() + { + return $this->value['data'] ?? null; + } +} \ No newline at end of file diff --git a/src/Component/Akeneo/DataStructure/ItemCollection.php b/src/Component/Akeneo/DataStructure/ItemCollection.php new file mode 100644 index 00000000..6de4739b --- /dev/null +++ b/src/Component/Akeneo/DataStructure/ItemCollection.php @@ -0,0 +1,118 @@ + $value) { + $this->addItem($name, $value); + } + } + + public function addItem(string $code, $itemValue, array $context = []) + { + $this->items[$code] = Item::withContext($code, $itemValue, $context); + } + + public function copyItem(string $fromAttributeCode, string $toAttributeCode, $dataValue = null): void + { + $item = $this->getItem($fromAttributeCode); + if (!$item) { + return; + } + + $matcher = $item->getMatcher()->duplicateWithNewKey($toAttributeCode); + + $itemValue = $item->getValue(); + // setting some values + $itemValue['matcher'] = $matcher; + $itemValue['type'] = $item->getType(); + if (isset($dataValue)) { + $itemValue['data'] = $dataValue; + } + + $this->addItem($matcher->getMainKey(), $itemValue, $item->getContext()); + } + + public function editItemValue(string $code, $dataValue): void + { + $item = $this->getItem($code); + $itemValue = $item->getValue(); + $itemValue['data'] = $dataValue; + $this->addItem($item->getCode(), $itemValue, $item->getContext()); + } + + /** + * Removes a value for a given attribute code. + * + * @param string $code The attribute code to remove. + * @return void + */ + public function removeItem(string $code): void + { + unset($this->items[$code]); + } + + public function getItem(string $code): ?Item + { + return $this->items[$code] ?? $this->getItemByMatch($code) ?? null; + } + /** + * Gets all attributes. + * + * @return Item[] The array of all attributes. + */ + public function getItems(): array + { + return $this->items; + } + + public function getItemsByScope(Scope $scope): \Generator + { + foreach ($this->items as $code => $item) { + if ($item->getScope()->equals($type)) { + yield $code => $item; + } + } + } + + public function getItemByMatch(string $match): ?Item + { + foreach ($this->items as $code => $item) { + if ($item->getMatcher()->matches($match)) { + return $item; + } + } + return null; + } + + public function getItemsByMatch(string $match): \Generator + { + foreach ($this->items as $code => $item) { + if ($item->getMatcher()->matches($match)) { + yield $code => $item; + } + } + } + + public function getItemsByType(string $type): \Generator + { + foreach ($this->items as $code => $item) { + if ($item->getType() == $type) { + yield $code => $item; + } + } + } +} diff --git a/src/Component/Common/Client/ApiClientFactory.php b/src/Component/Common/Client/ApiClientFactory.php index cdf650d9..f03d41e6 100644 --- a/src/Component/Common/Client/ApiClientFactory.php +++ b/src/Component/Common/Client/ApiClientFactory.php @@ -2,14 +2,21 @@ namespace Misery\Component\Common\Client; -use Misery\Component\Akeneo\Client\AkeneoApiClientAccount; +use App\Component\Akeneo\Api\Client\AkeneoApiClientAccount; +use App\Component\Common\Client\ApiClient; +use App\Component\Common\Client\ApiCurlClient; +use App\Component\Common\Client\ApiClientInterface; +use Misery\Component\Common\Client\ApiClientInterface as BaseApiClientInterface; use Misery\Component\Connections\BusinessCentral\Client\MicrosoftDynamicsOauthAccount; use Misery\Component\Common\Registry\RegisteredByNameInterface; use Misery\Component\Connections\E5Dal\Client\E5DalAPIAccount; +/** + * This Factory looks like a specific multi-tool implementation + */ class ApiClientFactory implements RegisteredByNameInterface { - public function createFromConfiguration(array $account): ApiClientInterface + public function createFromConfiguration(array $account): ApiClientInterface|BaseApiClientInterface { $type = $account['type'] ?? null; if ($type === 'basic_auth') { @@ -28,7 +35,7 @@ public function createFromConfiguration(array $account): ApiClientInterface if ($type === 'microsoft_oauth') { try { // no need to authorize a basic auth - $client = new ApiClient($account['domain']); + $client = new ApiCurlClient($account['domain']); $account = new MicrosoftDynamicsOauthAccount( $account['client_id'], @@ -48,7 +55,7 @@ public function createFromConfiguration(array $account): ApiClientInterface if ($type === 'e5_dal_token') { try { // no need to authorize token is fixed - $client = new ApiClient($account['domain']); + $client = new ApiCurlClient($account['domain']); $account = new E5DalAPIAccount($account['token']); $client->authorize($account); @@ -60,16 +67,16 @@ public function createFromConfiguration(array $account): ApiClientInterface } try { - $client = new ApiClient($account['domain']); - $account = new AkeneoApiClientAccount( + rtrim($account['domain'], '/'), $account['username'], $account['password'], $account['client_id'], $account['secret'] ?? $account['client_secret'] ); - $client->authorize($account); + $client = new ApiClient($account); + $client->authorize(); return $client; } catch (\Exception $e) { diff --git a/src/Component/Common/Client/Paginator.php b/src/Component/Common/Client/Paginator.php index be6fa847..3ee1fdf1 100644 --- a/src/Component/Common/Client/Paginator.php +++ b/src/Component/Common/Client/Paginator.php @@ -17,11 +17,11 @@ class Paginator public function __construct( ApiClientInterface $client, - ItemCollection $items, - string $first = null, - string $previous = null, - string $next = null, - int $count = null + ItemCollection $items, + string $first = null, + string $previous = null, + string $next = null, + int $count = null ) { $this->client = $client; $this->first = $first; diff --git a/src/Component/Common/Collection/ArrayCollection.php b/src/Component/Common/Collection/ArrayCollection.php index 5b2bec40..f8a19eff 100644 --- a/src/Component/Common/Collection/ArrayCollection.php +++ b/src/Component/Common/Collection/ArrayCollection.php @@ -24,17 +24,27 @@ public function set($key, $item): void $this->items[$key] = $item; } + public function containsKey($key): bool + { + return isset($this->items[$key]); + } + public function get($key): self { return new self([$this->items[$key] ?? null]); } + public function remove($key): void + { + unset($this->items[$key]); + } + public function first() { return current($this->items); } - public function merge(ArrayCollection $collection) + public function merge(ArrayCollection $collection): void { foreach ($collection->getValues() as $item) { $this->items[] = $item; @@ -66,6 +76,11 @@ public function getValues(): array return $this->items; } + public function toArray(): array + { + return $this->items; + } + public function addValues(array $items): void { foreach (array_filter($items) as $key => $item) { @@ -76,5 +91,6 @@ public function addValues(array $items): void public function purge(): void { $this->items = []; + } } \ No newline at end of file diff --git a/src/Component/Common/Functions/ArrayFunctions.php b/src/Component/Common/Functions/ArrayFunctions.php index 1e4a4e19..54ce6484 100644 --- a/src/Component/Common/Functions/ArrayFunctions.php +++ b/src/Component/Common/Functions/ArrayFunctions.php @@ -202,6 +202,20 @@ public static function array_filter_recursive(array $array, callable $callback): }); } + public static function array_merge_recursive(array &$array1, array $array2) { + foreach ($array2 as $key => $value) { + // If the value is an array and the key exists in both arrays + if (is_array($value) && isset($array1[$key]) && is_array($array1[$key])) { + self::array_merge_recursive($array1[$key], $value); + } else { + // Overwrite the value in the first array + $array1[$key] = $value; + } + } + + return $array1; + } + public static function fill_with_empty(array $array): array { return array_fill_keys($array, null); diff --git a/src/Component/Common/Pipeline/ActionPipe.php b/src/Component/Common/Pipeline/ActionPipe.php index ece389f5..683e1083 100644 --- a/src/Component/Common/Pipeline/ActionPipe.php +++ b/src/Component/Common/Pipeline/ActionPipe.php @@ -6,13 +6,7 @@ class ActionPipe implements PipeInterface { - private $actionProcessor; - - public function __construct(ItemActionProcessor $actionProcessor) - { - - $this->actionProcessor = $actionProcessor; - } + public function __construct(private readonly ItemActionProcessor $actionProcessor) {} public function pipe(array $item): array { diff --git a/src/Component/Common/Pipeline/Pipeline.php b/src/Component/Common/Pipeline/Pipeline.php index 13ea6db7..318db625 100644 --- a/src/Component/Common/Pipeline/Pipeline.php +++ b/src/Component/Common/Pipeline/Pipeline.php @@ -13,19 +13,19 @@ class Pipeline use LoggerAwareTrait; /** @var PipeReaderInterface */ - private $in; + private $input; /** @var PipeWriterInterface */ private $invalid; /** @var PipeInterface[] */ - private $lines = []; + private $pipeLines = []; /** @var PipeWriterInterface[] */ - private $out = []; + private $outputs = []; /** @var NullItemDebugger */ private $debugger; public function input(PipeReaderInterface $reader): self { - $this->in = $reader; + $this->input = $reader; $this->debugger = new NullItemDebugger(); return $this; @@ -33,7 +33,7 @@ public function input(PipeReaderInterface $reader): self public function line(PipeInterface $pipe): self { - $this->lines[] = $pipe; + $this->pipeLines[] = $pipe; return $this; } @@ -47,33 +47,33 @@ public function invalid(PipeWriterInterface $writer): self public function output(PipeWriterInterface $writer): self { - $this->out[] = $writer; + $this->outputs[] = $writer; return $this; } - public function runInDebugMode(int $amount = -1, int $lineNumber = -1) + public function runInDebugMode(int $amount = -1, int $lineNumber = -1): void { $this->debugger = new ItemDebugger(); $this->run($amount, $lineNumber); } - public function run(int $amount = -1, int $lineNumber = -1) + public function run(int $amount = -1, int $lineNumber = -1): void { $i = 0; // looping - while ($i !== $amount && $item = $this->in->read()) { + while ($i !== $amount && $item = $this->input->read()) { $i++; if ($i !== $lineNumber && $lineNumber !== -1) { continue; } $this->debugger->log($item, 'original item'); try { - foreach ($this->lines as $line) { - $item = $line->pipe($item); + foreach ($this->pipeLines as $pipeLine) { + $item = $pipeLine->pipe($item); } - foreach ($this->out as $out) { - $out->write($item); + foreach ($this->outputs as $output) { + $output->write($item); } } catch (SkipPipeLineException $exception) { if (!empty($exception->getMessage())) { @@ -94,13 +94,14 @@ public function run(int $amount = -1, int $lineNumber = -1) break; } } + unset($output); // stopping - $this->in->stop(); + $this->input->stop(); - foreach ($this->out as $out) { + foreach ($this->outputs as $output) { try { - $out->stop(); + $output->stop(); } catch (SkipPipeLineException $exception) { continue; diff --git a/src/Component/Common/Utils/ContextFormatter.php b/src/Component/Common/Utils/ContextFormatter.php index 08270fe5..0269345d 100644 --- a/src/Component/Common/Utils/ContextFormatter.php +++ b/src/Component/Common/Utils/ContextFormatter.php @@ -4,31 +4,84 @@ class ContextFormatter { - public static function format(array $context, array $data): array + /** + * Formats the data array by replacing placeholders with context values. + * + * @param array $context The context containing placeholder replacements. + * @param mixed $data The data to format (can be an array or scalar). + * @return mixed The formatted data. + */ + public static function format(array $context, $data): mixed { $replacements = []; foreach ($context as $key => $contextValue) { $replacements["%$key%"] = $contextValue; } - $newData = []; - foreach ($data as $key => &$value) { - if (is_string($key)) { - $newKey = strtr($key, $replacements); - $newData[$newKey] = $value; - } else { - $newData[$key] = $value; + return self::recursiveFormat($data, $replacements); + } + + /** + * Recursively formats data by replacing placeholders in both keys and values. + * + * @param mixed $data The data to format. + * @param array $replacements The array of replacements. + * @return mixed The formatted data. + */ + private static function recursiveFormat($data, array $replacements): mixed + { + if (is_array($data)) { + $newData = []; + foreach ($data as $key => $value) { + // Replace placeholders in keys if the key is a string + $newKey = is_string($key) ? self::replacePlaceholdersInString($key, $replacements) : $key; + // Recursively format the value + $newValue = self::recursiveFormat($value, $replacements); + $newData[$newKey] = $newValue; } + return $newData; + } elseif (is_string($data)) { + // Replace the value if it's a string + return self::replacePlaceholdersInValue($data, $replacements); + } else { + // For other data types (int, float, bool, null), return as is + return $data; } + } - foreach ($newData as &$value) { - if (is_array($value)) { - $value = self::format($context, $value); - } elseif (is_string($value)) { - $value = strtr($value, $replacements); - } + /** + * Replaces placeholders in a string with corresponding values. + * + * @param string $string The string containing placeholders. + * @param array $replacements The array of replacements. + * @return mixed The replaced value. + */ + private static function replacePlaceholdersInString(string $string, array $replacements): mixed + { + if (array_key_exists($string, $replacements)) { + // If the entire string is a placeholder, return the replacement value directly + return $replacements[$string]; + } else { + // Otherwise, replace placeholders within the string + return strtr($string, array_filter($replacements, 'is_scalar')); } + } - return $newData; + /** + * Handles replacement when the value is a string. + * + * @param string $value The value containing placeholders. + * @param array $replacements The array of replacements. + * @return mixed The replaced value. + */ + private static function replacePlaceholdersInValue(string $value, array $replacements): mixed + { + if (array_key_exists($value, $replacements)) { + // Return the replacement directly, even if it's an array or object + return $replacements[$value]; + } else { + // Replace within the string if it's not an exact match + return strtr($value, array_filter($replacements, 'is_scalar')); + } } } diff --git a/src/Component/Common/Utils/ValueFormatter.php b/src/Component/Common/Utils/ValueFormatter.php index 73c79fcc..99c35fe2 100644 --- a/src/Component/Common/Utils/ValueFormatter.php +++ b/src/Component/Common/Utils/ValueFormatter.php @@ -25,6 +25,12 @@ public static function format(string $format, array $values): string return strtr($format, $replacements); } + public static function getKeys(string $format): array + { + preg_match_all('/%([a-zA-Z0-9_]+)%/', $format, $matches); + return $matches[1] ?? []; + } + public static function recursiveFormat(string $format, array $values): string { foreach ($values as $value) { diff --git a/src/Component/Configurator/Configuration.php b/src/Component/Configurator/Configuration.php index 3450772a..4de2afed 100644 --- a/src/Component/Configurator/Configuration.php +++ b/src/Component/Configurator/Configuration.php @@ -3,13 +3,15 @@ namespace Misery\Component\Configurator; use App\Component\ChangeManager\ChangeManager; +use App\Component\Common\Resource\NamedResourceInterface; +use App\Component\Common\Resource\ResourceCollectionInterface; use Misery\Component\Action\ItemActionProcessorFactory; use Psr\Log\LoggerAwareTrait; use Psr\Log\LoggerInterface; use Misery\Component\Action\ItemActionProcessor; use Misery\Component\BluePrint\BluePrint; -use Misery\Component\Common\Client\ApiClient; -use Misery\Component\Common\Client\ApiClientInterface; +use App\Component\Common\Client\ApiClient; +use App\Component\Common\Client\ApiClientInterface; use Misery\Component\Common\Collection\ArrayCollection; use Misery\Component\Common\FileManager\LocalFileManager; use Misery\Component\Common\Pipeline\Pipeline; @@ -48,6 +50,7 @@ class Configuration private $accounts; private $isMultiStep = false; public ChangeManager $changeManager; + private array $resourceCollections = []; public ItemActionProcessorFactory $actionFactory; private array $extensions = []; @@ -86,11 +89,16 @@ public function setAsMultiStep(): void $this->isMultiStep = true; } - public function addContext(array $context) + public function addContext(array $context): void { $this->context = array_merge($this->context, $context); } + public function setContext(string $key, $value): void + { + $this->context[$key] = $value; + } + public function getContext(string $key = null) { return $key !== null ? $this->context[$key] ?? null: $this->context; @@ -106,6 +114,21 @@ public function getSources(): SourceCollection return $this->sources; } + public function addResourceCollection(ResourceCollectionInterface|NamedResourceInterface $resourceCollection): void + { + $this->resourceCollections[$resourceCollection->getName()] = $resourceCollection; + } + + public function getResourceCollection(string $name):? ResourceCollectionInterface + { + return $this->resourceCollections[$name] ?? null; + } + + public function getResourceCollections(): array + { + return $this->resourceCollections; + } + public function addAccount(string $name, ApiClientInterface $apiClient) { $this->accounts[$name] = $apiClient; diff --git a/src/Component/Configurator/ConfigurationFactory.php b/src/Component/Configurator/ConfigurationFactory.php index 18cbebaa..dca6500f 100644 --- a/src/Component/Configurator/ConfigurationFactory.php +++ b/src/Component/Configurator/ConfigurationFactory.php @@ -49,7 +49,7 @@ public function init( ); } - public function setChangeManager(ChangeManager $changeManager) + public function setChangeManager(ChangeManager $changeManager): void { $this->config->changeManager = $changeManager; } @@ -108,7 +108,7 @@ public function parseDirectivesFromConfiguration(array $configuration): Configur case $key === 'transformation_steps'; $this->config->setAsMultiStep(); $this->config->getLogger()->info(sprintf("Multi Step [%s]", basename($this->config->getContext('transformation_file')))); - $this->manager->addTransformationSteps($configuration['transformation_steps'], $configuration); + $this->manager->addTransformationSteps($configuration['transformation_steps'], $configuration, $this->config->getContext()); break; case $key === 'pipeline'; $this->manager->configurePipelines($configuration['pipeline']); diff --git a/src/Component/Configurator/ConfigurationManager.php b/src/Component/Configurator/ConfigurationManager.php index e6bdf86a..443cc458 100644 --- a/src/Component/Configurator/ConfigurationManager.php +++ b/src/Component/Configurator/ConfigurationManager.php @@ -2,10 +2,15 @@ namespace Misery\Component\Configurator; +use App\Component\Akeneo\Api\Client\AkeneoApiClientAccount; +use App\Component\Akeneo\Api\Resources\AkeneoResourceCollection; +use App\Component\Common\Client\ApiClientInterface; +use App\Component\Common\Resource\ResourceCollectionInterface; use Assert\Assert; use Assert\Assertion; use Misery\Component\Action\ItemActionProcessor; use Misery\Component\Action\ItemActionProcessorFactory; +use Misery\Component\Akeneo\Client\HttpReaderFactory; use Misery\Component\Akeneo\Client\HttpWriterFactory; use Misery\Component\BluePrint\BluePrint; use Misery\Component\BluePrint\BluePrintFactory; @@ -121,74 +126,63 @@ public function addContext(array $configuration): void $this->config->addContext($configuration); } - public function addTransformationSteps(array $transformationSteps, array $masterConfiguration): void - { + /** + * TODO this needs to be broken into more steps, not clear + * context grows, but needs to be reset to previous state per step + */ + public function addTransformationSteps(array $transformationSteps, array $masterConfiguration, array $context): void + {; /** @var ProjectDirectories $projectDirectories */ $projectDirectories = $this->factory->getFactory('project_directories'); - $debug = $this->config->getContext('debug'); $dirName = pathinfo($this->config->getContext('transformation_file'))['dirname'] ?? null; unset($masterConfiguration['transformation_steps']); - # list of transformations + // Iterate over the transformation steps foreach ($transformationSteps as $transformationFile) { - // this code detects the run | with option - // and creates virtual steps, so you don't need to repeat yourself + // Check if 'run' is specified if (isset($transformationFile['run'])) { $file = $transformationFile['run']; - $withArray = $transformationFile['with']; - - // Get the number of iterations needed - $iterationCount = count(current($withArray)); - - // Iterate over each index - for ($i = 0; $i < $iterationCount; $i++) { - $context = []; - // Build the context for the current index - foreach ($withArray as $key => $values) { - if (isset($values[$i])) { - $context[$key] = $values[$i]; + // Handle 'once_with' directive + if (isset($transformationFile['once_with'])) { + $this->addTransformationSteps([$file], $masterConfiguration, array_merge($context, $transformationFile['once_with'])); - // Save the context in the master configuration - $masterConfiguration['context'][$key] = $values[$i]; - } + // Handle 'all_with' directive + } elseif (isset($transformationFile['all_with'])) { + // Iterate over each combination of parameters + foreach ($this->arrayCartesianItem($transformationFile['all_with']) as $comboContext) { + $this->addTransformationSteps([$file], $masterConfiguration, array_merge($context, $comboContext)); } - - $this->addTransformationSteps([$file], $masterConfiguration); } continue; } - $file = $dirName . DIRECTORY_SEPARATOR . $transformationFile; - if (!is_file($file) && $projectDirectories->getTemplatePath()->isFile($transformationFile)) { - $file = $projectDirectories->getTemplatePath()->getAbsolutePath($transformationFile); + // Process the transformation file as before + $filePath = $dirName . DIRECTORY_SEPARATOR . $transformationFile; + if (!is_file($filePath) && $projectDirectories->getTemplatePath()->isFile($transformationFile)) { + $filePath = $projectDirectories->getTemplatePath()->getAbsolutePath($transformationFile); } else { - Assertion::file($file); + Assertion::file($filePath); } - // we need to start a new configuration manager. - $transformationFile = ArrayFunctions::array_filter_recursive(Yaml::parseFile($file), function ($value) { - return $value !== NULL; + // Read and parse the transformation file + $transformationContent = ArrayFunctions::array_filter_recursive(Yaml::parseFile($filePath), function ($value) { + return $value !== null; }); - $configuration = array_replace_recursive($masterConfiguration, $transformationFile, [ - 'context' => [ - 'try' => $transformationFile['context']['try'] ?? null, - 'debug' => $debug, - 'dirname' => $dirName, - 'transformation_file' => $file, - ]]); + $configuration = array_replace_recursive($transformationContent, $masterConfiguration); + $configuration['context'] = array_merge($configuration['context'], $context); $configuration = $this->factory->parseDirectivesFromConfiguration($configuration); - // only start the process if our transformation file has a pipeline - if (!isset($transformationFile['pipeline']) && !isset($transformationFile['shell'])) { + // Start the process if the transformation file has a pipeline or shell + if (!isset($transformationContent['pipeline']) && !isset($transformationContent['shell'])) { continue; } (new ProcessManager($configuration))->startProcess(); - // TODO connect the outputs here + // Execute shell commands if any if ($shellCommands = $configuration->getShellCommands()) { $shellCommands->exec(); $configuration->clearShellCommands(); @@ -196,7 +190,24 @@ public function addTransformationSteps(array $transformationSteps, array $master } } - public function configureShellCommands(array $configuration) + // Helper function to compute Cartesian product of arrays + private function arrayCartesianItem($arrays): array + { + $result = [[]]; + foreach ($arrays as $key => $values) { + $append = []; + foreach ($result as $resultData) { + foreach ((array) $values as $item) { + $resultData[$key] = $item; + $append[] = $resultData; + } + } + $result = $append; + } + return $result; + } + + public function configureShellCommands(array $configuration): void { /** @var ShellCommandFactory $factory */ $factory = $this->factory->getFactory('shell'); @@ -214,12 +225,23 @@ public function configurePipelines(array $configuration): void ); } + public function createResourceCollection(string $name, array $account): void + { + $this->config->addResourceCollection( + new AkeneoResourceCollection($name, $account) + ); + } + public function configureAccounts(array $configuration): void { /** @var ApiClientFactory $factory */ $factory = $this->factory->getFactory('api_client'); foreach ($configuration as $account) { - $this->config->addAccount($account['name'], $factory->createFromConfiguration($account)); + if (isset($account['resourceType']) && str_starts_with($account['resourceType'], 'api-ak')) { + $this->createResourceCollection($account['name'], $account); + } else { + $this->config->addAccount($account['name'], $factory->createFromConfiguration($account)); + } } } @@ -364,6 +386,7 @@ public function configureMapping(array $configuration): void public function createHTTPReader(array $configuration): ReaderInterface { + /** @var HttpReaderFactory $factory */ $factory = $this->factory->getFactory('http_reader'); $reader = $factory->createFromConfiguration($configuration, $this->config); diff --git a/src/Component/Configurator/ReadOnlyConfiguration.php b/src/Component/Configurator/ReadOnlyConfiguration.php index 9ef74977..e3956f9a 100644 --- a/src/Component/Configurator/ReadOnlyConfiguration.php +++ b/src/Component/Configurator/ReadOnlyConfiguration.php @@ -2,6 +2,7 @@ namespace Misery\Component\Configurator; +use App\Component\Common\Resource\ResourceCollectionInterface; use Misery\Component\Action\ItemActionProcessor; use Misery\Component\BluePrint\BluePrint; use Misery\Component\Common\Client\ApiClient; @@ -25,6 +26,7 @@ class ReadOnlyConfiguration private array $filters = []; private SourceCollection $sources; private array $lists; + private array $resourceCollections = []; public static function loadFromConfiguration(Configuration $configuration): self { @@ -33,6 +35,7 @@ public static function loadFromConfiguration(Configuration $configuration): self $self->lists = $configuration->getLists(); $self->filters = $configuration->getFilters(); $self->mappings = $configuration->getMappings(); + $self->resourceCollections = $configuration->getResourceCollections(); return $self; } @@ -42,6 +45,19 @@ public function getSources(): SourceCollection return $this->sources; } + /** + * @return ResourceCollectionInterface[] + */ + public function getResourceCollections(): array + { + return $this->resourceCollections; + } + + public function getResourceCollection(string $name):? ResourceCollectionInterface + { + return $this->resourceCollections[$name] ?? null; + } + public function getLists(): array { return array_map(function ($list) { diff --git a/src/Component/Converter/AkeneoProductApiConverter.php b/src/Component/Converter/AkeneoProductApiConverter.php index 319096e0..26ccbf82 100644 --- a/src/Component/Converter/AkeneoProductApiConverter.php +++ b/src/Component/Converter/AkeneoProductApiConverter.php @@ -30,7 +30,7 @@ public function convert(array $item): array // first we need to convert the values foreach ($item[$container] ?? [] as $key => $valueSet) { foreach ($valueSet ?? [] as $value) { - $matcher = Matcher::create($container.'|'.$key, $value['locale'], $value['scope']); + $matcher = Matcher::create($container.'|'.$key, $value['locale'] ?? null, $value['scope'] ?? null); $tmp[$keyMain = $matcher->getMainKey()] = $value['data'] ?? null; if ($this->getOption('structure') === 'matcher') { $tmp[$keyMain] = $value; diff --git a/src/Component/Converter/Item/Product.php b/src/Component/Converter/Item/Product.php new file mode 100644 index 00000000..7c3e5435 --- /dev/null +++ b/src/Component/Converter/Item/Product.php @@ -0,0 +1,65 @@ + $value) { + $result[$key] = is_array($value) ? $value['data'] ?? $value : $value; + } + + return $result; + } + + /** + * COPY/PASTA \Misery\Component\Akeneo\AkeneoTypeBasedDataConverter + */ + private function numberize($value) + { + if (is_integer($value)) { + return $value; + } + if (is_float($value)) { + return $value; + } + if (is_string($value)) { + $posNum = str_replace(',', '.', $value); + return is_numeric($posNum) ? $posNum: $value; + } + } + + public function getName(): string + { + return 'item/product'; + } +} \ No newline at end of file diff --git a/src/Component/Converter/Matcher.php b/src/Component/Converter/Matcher.php index 5d638163..b4566a19 100644 --- a/src/Component/Converter/Matcher.php +++ b/src/Component/Converter/Matcher.php @@ -49,7 +49,12 @@ public function isScopable(): bool public function getPrimaryKey(): string { - return $this->matches[1]; + return $this->matches[1] ?? $this->matches[0]; + } + + public function isProperty(): bool + { + return count($this->matches) === 1; } public function getRowKey(): string @@ -64,7 +69,7 @@ public function getMainKey(): string public function matches(string $match): bool { - return in_array($match, $this->matches); + return in_array($match, $this->matches) || $match === $this->getMainKey(); } public function duplicateWithNewKey(string $newPrimaryKey): self @@ -74,6 +79,12 @@ public function duplicateWithNewKey(string $newPrimaryKey): self $matcher->locale = $this->locale; $matcher->matches = $this->matches; $matcher->matches[1] = $newPrimaryKey; + if (count(explode($this->separator, $newPrimaryKey)) > 1) { + $matcher->matches = explode($this->separator, $newPrimaryKey); + } + + // obelink-xml-v3 + //$matcher->matches = explode($this->separator, $newPrimaryKey); return $matcher; } diff --git a/src/Component/Converter/ObelinkPurchaseLoop.php b/src/Component/Converter/ObelinkPurchaseLoop.php new file mode 100644 index 00000000..6c889e35 --- /dev/null +++ b/src/Component/Converter/ObelinkPurchaseLoop.php @@ -0,0 +1,65 @@ + null, + ]; + + public function __construct(private readonly ConverterInterface $productConverter) {} + + public function load(array $item): ItemCollection + { + return new ItemCollection($this->convert($item)); + } + + public function convert(array $item): array + { + $result = []; + + $item = $this->productConverter->convert($item); + + $suppliers = 1; + while($suppliers <= 5) { + $supplierCode = $item['values|supplier_' . $suppliers]['data'] ?? null; + $supplierDeliverCode = $item['values|supplier_'.$suppliers.'_deliver']['data'] ?? null; + + if(!$supplierCode || !$supplierDeliverCode){ + $suppliers++; + continue; + } + + $orderMultiplier = $item['values|supplier_'.$suppliers.'_amount_order_factor']['data'] ?? null; + $item['suppliers_data'] = [ + 'supplier_index' => $suppliers, + 'supplier_code' => (string) $supplierCode, + 'supplier_deliver-code' => (string) $supplierDeliverCode, + 'order_multiplier' => (int) $orderMultiplier, + ]; + $suppliers++; + $result[] = $item; + } + + return $result; + } + + public function revert(array $item): array + { + return $item; + } + + public function getName(): string + { + return 'obelink/purchase/api'; + } +} diff --git a/src/Component/Mapping/ColumnMapper.php b/src/Component/Mapping/ColumnMapper.php index 4aa6417b..6c1bcfb9 100644 --- a/src/Component/Mapping/ColumnMapper.php +++ b/src/Component/Mapping/ColumnMapper.php @@ -8,9 +8,14 @@ */ class ColumnMapper implements Mapper { + public function __construct(private readonly bool $strictMode = true) {} + public function map(array $item, array $mappings) { - if (count(array_diff(array_keys($mappings), array_keys($item))) == count(array_keys($mappings))) { + // Check for missing mapped items + $mappingDif = count(array_diff(array_keys($mappings), array_keys($item))) == count(array_keys($mappings)); + + if ($this->strictMode && $mappingDif) { throw new \InvalidArgumentException(sprintf( 'No mapped items %s are not found in item.', json_encode($mappings) diff --git a/src/Component/Reader/ItemReader.php b/src/Component/Reader/ItemReader.php index ff124300..5a1df4a3 100644 --- a/src/Component/Reader/ItemReader.php +++ b/src/Component/Reader/ItemReader.php @@ -35,7 +35,7 @@ public function index(array $lines): ItemReaderInterface private function processIndex(array $lines): \Generator { foreach ($lines as $lineNr) { - $this->seek($lineNr); + $this->cursor instanceof \SeekableIterator ? $this->cursor->seek($lineNr) : $this->seek($lineNr); yield $lineNr => $this->cursor->current(); } } diff --git a/src/Component/Source/SourceCollectionFactory.php b/src/Component/Source/SourceCollectionFactory.php index 9fc5d8a4..f4bad15c 100644 --- a/src/Component/Source/SourceCollectionFactory.php +++ b/src/Component/Source/SourceCollectionFactory.php @@ -65,7 +65,7 @@ private function addSourceFileToCollection(SourceCollection $sourceCollection, s Assert::that($file)->file(); $path = pathinfo($file); - if (in_array(strtolower($path['extension']), ['json', 'buffer'])) { + if (in_array(strtolower($path['extension']), ['json', 'jsonl', 'buffer'])) { $sourceCollection->add( Source::createSimple(JsonFileParser::create($file), $alias ?? $path['basename']) ); diff --git a/src/Component/Statement/StatementBuilder.php b/src/Component/Statement/StatementBuilder.php index 5ccabe48..35c2784f 100644 --- a/src/Component/Statement/StatementBuilder.php +++ b/src/Component/Statement/StatementBuilder.php @@ -120,11 +120,19 @@ private static function fromExpression(string $whenString, array $context): Stat $statement = EqualsStatement::prepare(new SetValueAction()); $orFields = explode(' OR ', $whenString) ?? []; - if (count($orFields) === 2) { - $fields = explode(' == ', $orFields[0]); - $statement->when($fields[0], $fields[1]); - $fields = explode(' == ', $orFields[1]); - $statement->or($fields[0], $fields[1]); + if (count($orFields) > 1) { + $collection = new StatementCollection(); + foreach ($orFields as $i => $orField) { + $fields = explode(' ', $orField); + if ($i === 0) { + $statement = self::buildFromOperator($fields[1], $context); + $statement->when($fields[0], $fields[2] ?? null); + } else { + $statement->or($fields[0], $fields[2] ?? null); + } + } + $collection->add($statement); + return $collection; } $containsFields = explode(' CONTAINS ', $whenString) ?? []; diff --git a/src/Component/Writer/BatchWriter.php b/src/Component/Writer/BatchWriter.php new file mode 100644 index 00000000..f89d88cb --- /dev/null +++ b/src/Component/Writer/BatchWriter.php @@ -0,0 +1,61 @@ +batchSize = $batchSize; + $this->options = $options; + + // Parse the base filename and extension + $pathInfo = pathinfo($filename); + $this->baseFilename = $pathInfo['dirname'] . DIRECTORY_SEPARATOR . $pathInfo['filename']; + $this->extension = isset($pathInfo['extension']) ? '.' . $pathInfo['extension'] : ''; + + $this->openNewWriter(); + } + + private function openNewWriter(): void + { + // Generate the batch filename + $batchFilename = sprintf('%s-%d%s', $this->baseFilename, $this->currentBatchIndex, $this->extension); + $this->writer = new $this->className($batchFilename, $this->options); + $this->currentBatchCount = 0; + } + + public function write(array $data, bool $loopItem = true): void + { + $this->writer->write($data, $loopItem); + $this->currentBatchCount++; + + // Check if batch size is reached + if ($this->currentBatchCount >= $this->batchSize) { + $this->writer->close(); + $this->currentBatchIndex++; + $this->openNewWriter(); + } + } + + public function close(): void + { + if ($this->writer) { + $this->writer->close(); + $this->writer = null; + } + } + + public function __destruct() + { + $this->close(); + } +} diff --git a/src/Component/Writer/ItemWriterFactory.php b/src/Component/Writer/ItemWriterFactory.php index feeb23be..5d7dbedc 100644 --- a/src/Component/Writer/ItemWriterFactory.php +++ b/src/Component/Writer/ItemWriterFactory.php @@ -21,7 +21,18 @@ public function createFromConfiguration( )->notEmpty()->string()->inArray(['xml', 'buffer', 'buffer_csv', 'csv', 'yaml', 'yml', 'xlsx', 'json', 'jsonl']); $filename = $fileManager->provisionPath($configuration['filename']); + $batchSize = $configuration['batch_size'] ?? 0; if ($configuration['type'] === 'xml') { + + if ($batchSize !== 0) { + return new BatchWriter( + XmlWriter::class, + $filename, + $batchSize, + $configuration['options'] ?? [] + ); + } + return new XmlWriter( $filename, $configuration['options'] ?? [] diff --git a/src/Component/Writer/XmlWriter.php b/src/Component/Writer/XmlWriter.php index 48987347..fdc7d17e 100644 --- a/src/Component/Writer/XmlWriter.php +++ b/src/Component/Writer/XmlWriter.php @@ -7,6 +7,7 @@ class XmlWriter implements ItemWriterInterface { public const CONTAINER = 'container'; + public const LOOP_ITEM = 'loop_item'; public const HEADER = 'header'; public const START = 'start'; @@ -29,7 +30,6 @@ public function __construct( array $options = [] ) { $this->filename = $filename; - Assertion::writeable($filename); $this->options = $options; $start = isset($options[self::START]) > 0 ? $options[self::START]: []; @@ -61,8 +61,12 @@ public function __construct( } } - public function write(array $data): void + public function write(array $data, bool $loopItem = true): void { + if ($loopItem && isset($this->options[self::LOOP_ITEM])) { + $this->writer->startElement($this->options[self::LOOP_ITEM]); + } + if (isset($data['@attributes'])) { foreach ($data['@attributes'] as $attributeName => $attributeValue) { $this->writer->writeAttribute($attributeName, $attributeValue); @@ -77,12 +81,13 @@ public function write(array $data): void $this->writer->writeCdata($data['@CDATA']); return; } + foreach($data as $key => $value) { if (\is_array($value)) { if (\is_string($key) && is_numeric(current(array_keys($value)))) { foreach ($value as $i => $collectionValue) { $this->writer->startElement($key); - $this->write($collectionValue); + $this->write($collectionValue, false); $this->writer->endElement(); } continue; @@ -90,13 +95,13 @@ public function write(array $data): void if (\is_string($key)) { $this->writer->startElement($key); - $this->write($value); + $this->write($value, false); $this->writer->endElement(); continue; } if (\is_numeric($key)) { - $this->write($value); + $this->write($value, false); } continue; @@ -106,6 +111,10 @@ public function write(array $data): void $this->writer->writeElement($key, $value); } } + + if ($loopItem && isset($this->options[self::LOOP_ITEM])) { + $this->writer->endElement(); + } } public function clear(): void diff --git a/src/Model/DataStructure/Item.php b/src/Model/DataStructure/Item.php new file mode 100644 index 00000000..5abf0354 --- /dev/null +++ b/src/Model/DataStructure/Item.php @@ -0,0 +1,169 @@ +itemNodes as $node) { + Assert::that($node)->isInstanceOf(ItemNode::class); + } + } + + public function getClass(): string + { + return $this->class; + } + + public function addItem(string $code, mixed $itemValue, array $context = []): void + { + $this->itemNodes[$code] = ItemNode::withContext($code, $itemValue, $context); + } + + public function copyItem(string $fromCode, string $toCode): void + { + $item = $this->getItem($fromCode); + if (!$item) { + return; + } + + $itemValue = $item->getValue(); + if (null === $itemValue) { + return; + } + + // Property Values + if (is_string($itemValue)) { + $this->addItem($toCode, $itemValue, $item->getContext()); + return; + } + + $matcher = $item->getMatcher()->duplicateWithNewKey($toCode); + + $itemValue['type'] = $item->getType(); + $itemValue['matcher'] = $matcher; + + $this->addItem($matcher->getPrimaryKey(), $itemValue, $item->getContext()); + } + + public function reFrame(array $orderedFields, bool $appendRemaining = false): void + { + // Create a new array with the specified order + $orderedNodes = []; + foreach (array_keys($orderedFields) as $key) { + $orderedNodes[$key] = $this->itemNodes[$key] ?? $orderedFields[$key]; + } + + // Append any remaining items not in the order + if ($appendRemaining) { + foreach ($this->itemNodes as $key => $node) { + if (!array_key_exists($key, $orderedNodes)) { + $orderedNodes[$key] = $node; + } + } + } + + // Replace the original array with the reordered array + $this->itemNodes = $orderedNodes; + } + + public function moveItem(string $fromCode, string $toCode): void + { + $this->copyItem($fromCode, $toCode); + $this->removeItem($fromCode); + } + + public function editItemValue(string $code, $dataValue): void + { + $item = $this->getItem($code); + $itemValue = $item->getValue(); + if (is_string($itemValue)) { + $itemValue = $dataValue; + } else { + $itemValue['data'] = $dataValue; + } + $this->addItem($item->getCode(), $itemValue, $item->getContext()); + } + + /** + * Removes a value for a given attribute code. + * + * @param string $code The attribute code to remove. + * @return void + */ + public function removeItem(string $code): void + { + if ($this->hasItem($code)) { + unset($this->itemNodes[$code]); + } + } + + public function hasItem(string $code): bool + { + return array_key_exists($code, $this->itemNodes) ?? $this->getItemByMatch($code) !== null; + } + + public function getItem(string $codeOrMatch): ?ItemNode + { + return $this->itemNodes[$codeOrMatch] ?? $this->getItemByMatch($codeOrMatch) ?? null; + } + + /** + * Gets all attributes. + * + * @return Item[] The array of all attributes. + */ + public function getItemNodes(): array + { + return $this->itemNodes; + } + + public function getItemCodes(): array + { + return array_keys($this->itemNodes); + } + + public function getItemsByScope(ScopeInterface $scope): \Generator + { + foreach ($this->itemNodes as $code => $item) { + if ($item->getScope()->equals($scope)) { + yield $code => $item; + } + } + } + + public function getItemByMatch(string $match): ?ItemNode + { + foreach ($this->itemNodes as $code => $item) { + if ($item->getMatcher()->matches($match)) { + return $item; + } + } + return null; + } + + public function getItemsByMatch(string $match): \Generator + { + foreach ($this->itemNodes as $code => $item) { + if ($item->getMatcher()->matches($match)) { + yield $code => $item; + } + } + } + + public function getItemsByType(string $type): \Generator + { + foreach ($this->itemNodes as $code => $item) { + if ($item->getType() == $type) { + yield $code => $item; + } + } + } +} diff --git a/src/Model/DataStructure/ItemInterface.php b/src/Model/DataStructure/ItemInterface.php new file mode 100644 index 00000000..fa10527c --- /dev/null +++ b/src/Model/DataStructure/ItemInterface.php @@ -0,0 +1,109 @@ + A generator yielding matching items. + */ + public function getItemsByMatch(string $match): \Generator; + + /** + * Retrieves items by their type as a generator. + * + * @param string $type The type to filter items by. + * @return \Generator A generator yielding items of the given type. + */ + public function getItemsByType(string $type): \Generator; +} diff --git a/src/Model/DataStructure/ItemNode.php b/src/Model/DataStructure/ItemNode.php new file mode 100644 index 00000000..12facb07 --- /dev/null +++ b/src/Model/DataStructure/ItemNode.php @@ -0,0 +1,85 @@ +matcher = Matcher::create($this->code, $this->scope->getLocale(), $this->scope->getChannel()); + if ($this->matcher->isProperty()) { + $this->type = 'property'; + } + + // when dealing with an array, we expect the 'data' property + if (is_array($this->value) && array_key_exists('data', $this->value)) { + + $this->type = $this->value['type'] ?? 'generic'; + + if (isset($this->value['matcher']) && $this->value['matcher'] instanceof Matcher) { + $this->matcher = $this->value['matcher']; + } + } + } + + public static function withContext(string $code, $value, array $context = []): ItemNode + { + $scope = new AkeneoScope(); + if (array_key_exists('locale', $context) && array_key_exists('scope', $context)) { + $scope = AkeneoScope::fromArray($context); + } + + return new self($code, $value, $scope); + } + + public function getContext(): array + { + return [ + 'locale' => $this->scope->getLocale(), + 'scope' => $this->scope->getChannel(), + 'original-code' => $this->getMatcher()->getPrimaryKey(), + 'code' => $this->code, + ]; + } + + public function getScope(): ScopeInterface + { + return $this->scope; + } + + public function equals(string $code): bool + { + $this->matcher->matches($code); + } + + public function getMatcher(): Matcher + { + return $this->matcher; + } + + public function getType(): string + { + return $this->type; + } + + public function getCode(): string + { + return $this->code; + } + + public function getValue() + { + return $this->value; + } + + public function getDataValue() + { + return $this->value['data'] ?? $this->value ?? null; + } +} \ No newline at end of file diff --git a/src/Model/DataStructure/ItemNodeInterface.php b/src/Model/DataStructure/ItemNodeInterface.php new file mode 100644 index 00000000..748972af --- /dev/null +++ b/src/Model/DataStructure/ItemNodeInterface.php @@ -0,0 +1,17 @@ +register(\Misery\Component\Action\DateTimeAction::NAME, new Misery\Component\Action\DateTimeAction()) ->register(Misery\Component\Action\FrameAction::NAME, new Misery\Component\Action\FrameAction()) ->register(Misery\Component\Action\GroupAction::NAME, new Misery\Component\Action\GroupAction()) - ->register(\Misery\Component\Action\StoreAction::NAME, new Misery\Component\Action\StoreAction()) + ->register(Misery\Component\Action\StoreAction::NAME, new Misery\Component\Action\StoreAction()) + ->register(Misery\Component\Action\MakeItemAction::NAME, new Misery\Component\Action\MakeItemAction()) ; #$statementRegistry = new Misery\Component\Common\Registry\Registry('statement'); diff --git a/test_run.sh b/test_run.sh new file mode 100644 index 00000000..e69de29b diff --git a/tests/Component/Action/SetValueActionTest.php b/tests/Component/Action/SetValueActionTest.php index a5fad08c..f4f31520 100644 --- a/tests/Component/Action/SetValueActionTest.php +++ b/tests/Component/Action/SetValueActionTest.php @@ -81,10 +81,11 @@ public function test_it_should_set_a_null_value_action(): void public function test_it_should_set_fields_std_data() { $format = new SetValueAction(); + $matcher = Matcher::create('values|street'); $item = [ 'values|street' => [ - 'matcher' => Matcher::create('street'), + 'matcher' => $matcher, 'scope' => null, 'locale' => null, 'data' => '123 Main Street', @@ -98,7 +99,7 @@ public function test_it_should_set_fields_std_data() $this->assertEquals([ 'values|street' => [ - 'matcher' => Matcher::create('street'), + 'matcher' => $matcher, 'scope' => null, 'locale' => null, 'data' => null, @@ -112,7 +113,7 @@ public function test_it_should_set_fields_std_data() $this->assertEquals([ 'values|street' => [ - 'matcher' => Matcher::create('street'), + 'matcher' => $matcher, 'scope' => null, 'locale' => null, 'data' => 'unknown', diff --git a/tests/Component/Akeneo/DataStructure/AkeneoItemBuilderTest.php b/tests/Component/Akeneo/DataStructure/AkeneoItemBuilderTest.php new file mode 100644 index 00000000..816856bd --- /dev/null +++ b/tests/Component/Akeneo/DataStructure/AkeneoItemBuilderTest.php @@ -0,0 +1,110 @@ + '1234', + 'values' => [ + 'weight' => [ + [ + 'locale' => null, + 'scope' => null, + 'data' => [ + 'amount' => 1, + 'unit' => 'GRAM' + ] + ] + ], + 'color' => [ + [ + 'locale' => 'en_US', + 'scope' => 'ecom', + 'data' => 'red', + ] + ], + 'brand' => [ + [ + 'locale' => 'en_US', + 'scope' => null, + 'data' => 'nike' + ] + ], + ], + ]; + + $context = [ + 'attribute_types' => [ + 'weight' => 'metric', + 'color' => 'simple_select', + 'brand' => 'reference_data', + ], + ]; + + // Call the method + $item = AkeneoItemBuilder::fromProductApiPayload($productData, $context); + + // Assertions + $this->assertInstanceOf(ItemInterface::class, $item); + + $this->assertSame(['identifier', 'values|weight', 'values|color|en_US|ecom', 'values|brand|en_US'], $item->getItemCodes()); + $colorData = $item->getItem('values|color|en_US|ecom')->getValue(); + + $this->assertEquals( + [ + 'locale' => 'en_US', + 'scope' => 'ecom', + 'type' => 'simple_select', + 'matcher' => Matcher::create('values|color', 'en_US', 'ecom'), + 'data' => 'red', + ], + $colorData + ); + + $weightData = $item->getItem('values|weight')->getValue(); + + $this->assertEquals( + [ + 'locale' => null, + 'scope' => null, + 'type' => 'metric', + 'matcher' => Matcher::create('values|weight'), + 'data' => [ + 'amount' => 1, + 'unit' => 'GRAM' + ], + ], + $weightData + ); + } + + public function testFromCatalogApiPayload(): void + { + $catalogData = [ + 'code' => 'black', + 'labels' => [ + 'nl_BE' => 'zwart', + 'en_US' => 'black', + ] + ]; + + // Call the method + $item = AkeneoItemBuilder::fromCatalogApiPayload($catalogData, ['class' => 'attribute']); + + // Assertions + $this->assertInstanceOf(ItemInterface::class, $item); + + $this->assertSame(['code', 'labels|nl_BE', 'labels|en_US'], $item->getItemCodes()); + + $this->assertSame('black', $item->getItem('labels|en_US')->getDataValue()); + $this->assertSame('zwart', $item->getItem('labels|nl_BE')->getDataValue()); + } +} diff --git a/tests/Component/Common/Utils/ContextFormatterTest.php b/tests/Component/Common/Utils/ContextFormatterTest.php index 01f2b33e..e85a0922 100644 --- a/tests/Component/Common/Utils/ContextFormatterTest.php +++ b/tests/Component/Common/Utils/ContextFormatterTest.php @@ -228,4 +228,71 @@ public function testContextFormatWithMultipleFoundValuesAndKeys() $this->assertEquals($expectedResult, $result); } + + public function testContextFormatWithMultipleMixedValuesAndKeys() + { + $context = [ + 'locale' => 'en_US', + 'code' => 'some_code', + 'idx', [], + 'filter' => null, + "id_list" => ['1', '2', '3'], + ]; + $data = [ + 'filter' => [ + [ + 'name' => 'my_filter', + 'source' => 'some_path.csv', + 'options' => [ + 'filter' => [ + 'code' => '%code%', + 'locale' => '%locale%', + ], + 'return_value' => '%code%', + ], + ] + ], + 'actions' => [ + 'first_action' => [ + 'context' => [ + '%code%' => 'code', + ] + ], + 'second_action' => [ + 'id_list' => '%id_list%', + 'filter' => '%filter%', + ], + ], + ]; + $expectedResult = [ + 'filter' => [ + [ + 'name' => 'my_filter', + 'source' => 'some_path.csv', + 'options' => [ + 'filter' => [ + 'code' => 'some_code', + 'locale' => 'en_US', + ], + 'return_value' => 'some_code', + ], + ] + ], + 'actions' => [ + 'first_action' => [ + 'context' => [ + 'some_code' => 'code', + ] + ], + 'second_action' => [ + 'filter' => null, + 'id_list' => ['1', '2', '3'], + ], + ] + ]; + + $result = ContextFormatter::format($context, $data); + + $this->assertEquals($expectedResult, $result); + } } diff --git a/tests/Component/Configurator/ConfigurationTest.php b/tests/Component/Configurator/ConfigurationTest.php index 6e01aac7..6a8ad80b 100644 --- a/tests/Component/Configurator/ConfigurationTest.php +++ b/tests/Component/Configurator/ConfigurationTest.php @@ -5,7 +5,7 @@ use Misery\Component\Common\Collection\ArrayCollection; use PHPUnit\Framework\TestCase; use Misery\Component\Configurator\Configuration; -use Misery\Component\Common\Client\ApiClientInterface; +use App\Component\Common\Client\ApiClientInterface; use Misery\Component\BluePrint\BluePrint; use Misery\Component\Common\FileManager\LocalFileManager; use Misery\Component\Common\Pipeline\Pipeline; diff --git a/tests/Component/Statement/StatementBuilderTest.php b/tests/Component/Statement/StatementBuilderTest.php index d176f536..6786a2cc 100644 --- a/tests/Component/Statement/StatementBuilderTest.php +++ b/tests/Component/Statement/StatementBuilderTest.php @@ -86,7 +86,7 @@ public function testFromExpressionWithOr(): void { $whenString = 'field1 == value1 OR field2 == value2'; $statement = StatementBuilder::build($whenString); - $this->assertInstanceOf(EqualsStatement::class, $statement); + $this->assertInstanceOf(CollectionStatement::class, $statement); // Here you might want to add more specific assertions to check if the OR condition is properly set }