diff --git a/.github/workflows/phpstan-7.yaml b/.github/workflows/phpstan-7.yaml deleted file mode 100644 index 4505ba2..0000000 --- a/.github/workflows/phpstan-7.yaml +++ /dev/null @@ -1,23 +0,0 @@ -name: PHPStan level 7 -on: push -jobs: - phpstan-7: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/cache@v3 - with: - path: '**/vendor' - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-composer- - - uses: php-actions/composer@v6 - with: - args: --prefer-dist - php_version: '8.2' - - - name: PHPStan - uses: php-actions/phpstan@v3 - with: - path: src/ - level: 7 diff --git a/.github/workflows/phpstan-6.yaml b/.github/workflows/phpstan-9.yaml similarity index 90% rename from .github/workflows/phpstan-6.yaml rename to .github/workflows/phpstan-9.yaml index b6bd269..c0fe8c0 100644 --- a/.github/workflows/phpstan-6.yaml +++ b/.github/workflows/phpstan-9.yaml @@ -1,7 +1,7 @@ -name: PHPStan level 6 +name: PHPStan level 8 on: push jobs: - phpstan-6: + phpstan-9: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -20,4 +20,4 @@ jobs: uses: php-actions/phpstan@v3 with: path: src/ - level: 6 + level: 9 diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml index a01ebd4..7e391e1 100644 --- a/.github/workflows/quality.yaml +++ b/.github/workflows/quality.yaml @@ -1,4 +1,4 @@ -name: Quality (PHPStan lvl 5) +name: Quality (PHPStan lvl 7) on: push jobs: cs-fixer: @@ -12,7 +12,7 @@ jobs: run: | wget -q https://cs.symfony.com/download/php-cs-fixer-v3.phar -O php-cs-fixer chmod a+x php-cs-fixer - PHP_CS_FIXER_IGNORE_ENV=true ./php-cs-fixer fix src --dry-run + ./php-cs-fixer fix src --dry-run phpstan: runs-on: ubuntu-latest @@ -32,4 +32,4 @@ jobs: uses: php-actions/phpstan@v3 with: path: src/ - level: 5 + level: 7 diff --git a/composer.lock b/composer.lock index e91539c..611145c 100644 --- a/composer.lock +++ b/composer.lock @@ -618,17 +618,18 @@ "source": { "type": "git", "url": "https://github.com/php-etl/magento2-api-client.git", - "reference": "46a2c89a5275af40809f9c045577f22b0e53dce9" + "reference": "8cd739e2b1da61abcf22ba150d53874854c8cc11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-etl/magento2-api-client/zipball/46a2c89a5275af40809f9c045577f22b0e53dce9", - "reference": "46a2c89a5275af40809f9c045577f22b0e53dce9", + "url": "https://api.github.com/repos/php-etl/magento2-api-client/zipball/8cd739e2b1da61abcf22ba150d53874854c8cc11", + "reference": "8cd739e2b1da61abcf22ba150d53874854c8cc11", "shasum": "" }, "require": { "jane-php/open-api-runtime": "^7.3", - "php": "^8.0" + "php": "^8.0", + "symfony/serializer": "^6.0" }, "require-dev": { "jane-php/open-api-2": "^7.5", @@ -643,7 +644,10 @@ }, "autoload": { "psr-4": { - "Kiboko\\Magento\\": "src" + "Kiboko\\Magento\\": [ + "src/", + "generated/" + ] } }, "notification-url": "https://packagist.org/downloads/", @@ -661,7 +665,7 @@ "issues": "https://github.com/php-etl/magento2-api-client/issues", "source": "https://github.com/php-etl/magento2-api-client/tree/2.4" }, - "time": "2024-01-22T09:20:52+00:00" + "time": "2024-02-09T10:10:21+00:00" }, { "name": "php-etl/mapping-contracts", @@ -4701,5 +4705,5 @@ "php": "^8.2" }, "platform-dev": [], - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..53b8e89 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,3 @@ +parameters: + level: 7 + treatPhpDocTypesAsCertain: false diff --git a/src/CategoryLookup.php b/src/CategoryLookup.php index 6601498..7440e50 100644 --- a/src/CategoryLookup.php +++ b/src/CategoryLookup.php @@ -7,55 +7,159 @@ use Kiboko\Component\Bucket\AcceptanceResultBucket; use Kiboko\Component\Bucket\EmptyResultBucket; use Kiboko\Component\Bucket\RejectionResultBucket; +use Kiboko\Contract\Bucket\RejectionResultBucketInterface; use Kiboko\Contract\Mapping\CompiledMapperInterface; use Kiboko\Contract\Pipeline\TransformerInterface; -use Psr\SimpleCache\CacheInterface; +use Kiboko\Magento\Client; +use Kiboko\Magento\Exception\GetV1CategoriesCategoryIdBadRequestException; +use Kiboko\Magento\Exception\UnexpectedStatusCodeException; +use Kiboko\Magento\Model\CatalogDataCategoryInterface; +use Kiboko\Magento\Model\ErrorResponse; +use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Log\LoggerInterface; +/** + * @template InputType of array + * @template OutputType of InputType|array + * + * @implements TransformerInterface + */ final readonly class CategoryLookup implements TransformerInterface { + /** + * @param CompiledMapperInterface $mapper + */ public function __construct( - private \Psr\Log\LoggerInterface $logger, - private \Kiboko\Magento\Client $client, - private CacheInterface $cache, - private string $cacheKey, + private LoggerInterface $logger, + private Client $client, private CompiledMapperInterface $mapper, private string $mappingField, ) { } + /** + * @return RejectionResultBucketInterface + */ + private function rejectErrorResponse(ErrorResponse $response): RejectionResultBucketInterface + { + $this->logger->error( + $response->getMessage(), + [ + 'resource' => 'getV1CategoriesCategoryId', + 'method' => 'get', + ], + ); + + return new RejectionResultBucket($response->getMessage(), null); + } + + /** + * @return RejectionResultBucketInterface + */ + private function rejectInvalidResponse(): RejectionResultBucketInterface + { + $this->logger->error( + $message = 'The result provided by the API client does not match the expected type. The connector compilation may have fetched incompatible versions.', + [ + 'resource' => 'getV1CategoriesCategoryId', + 'method' => 'get', + ], + ); + + return new RejectionResultBucket($message, null); + } + + /** + * @param InputType $line + * + * @return OutputType + */ + public function passThrough(array $line): array + { + /* @var OutputType $line */ + return $line; + } + public function transform(): \Generator { $line = yield new EmptyResultBucket(); while (true) { + if (null === $line) { + $line = yield new EmptyResultBucket(); + continue; + } + if (null === $line[$this->mappingField]) { - $line = yield new AcceptanceResultBucket($line); + $line = yield new AcceptanceResultBucket($this->passThrough($line)); + continue; } try { - $lookup = $this->cache->get(sprintf($this->cacheKey, $line[$this->mappingField])); - - if (null === $lookup) { - $lookup = $this->client->getV1CategoriesCategoryId( - categoryId: (int) $line[$this->mappingField], - ); + $lookup = $this->client->getV1CategoriesCategoryId( + categoryId: (int) $line[$this->mappingField], + ); - if (!$lookup instanceof \Kiboko\Magento\Model\CatalogDataCategoryInterface) { - return; - } + if ($lookup instanceof ErrorResponse) { + $line = yield $this->rejectErrorResponse($lookup); + continue; + } - $this->cache->set( - sprintf($this->cacheKey, $line[$this->mappingField]), - $lookup, - ); + if (!$lookup instanceof CatalogDataCategoryInterface) { + $line = yield $this->rejectInvalidResponse(); + continue; } - } catch (\RuntimeException $exception) { - $this->logger->warning($exception->getMessage(), ['exception' => $exception, 'item' => $line]); + } catch (NetworkExceptionInterface $exception) { + $this->logger->critical( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1CategoriesCategoryId', + 'method' => 'get', + 'categoryId' => (int) $line[$this->mappingField], + 'mappingField' => $this->mappingField, + ], + ); + $line = yield new RejectionResultBucket( + 'There are some network difficulties. We could not properly connect to the Magento API. There is nothing we could no to fix this currently. Please contact the Magento administrator.', + $exception, + $this->passThrough($line), + ); + continue; + } catch (GetV1CategoriesCategoryIdBadRequestException $exception) { + $this->logger->error( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1CategoriesCategoryId', + 'method' => 'get', + 'categoryId' => (int) $line[$this->mappingField], + 'mappingField' => $this->mappingField, + ], + ); $line = yield new RejectionResultBucket( - sprintf('Something went wrong in the attempt to recover the category with id %d', (int) $line[$this->mappingField]), + 'The source API rejected our request. Ignoring line. Maybe you are requesting on incompatible versions.', $exception, - $line + $this->passThrough($line), ); continue; + } catch (UnexpectedStatusCodeException $exception) { + $this->logger->critical( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1CategoriesCategoryId', + 'method' => 'get', + 'categoryId' => (int) $line[$this->mappingField], + 'mappingField' => $this->mappingField, + ], + ); + $line = yield new RejectionResultBucket( + 'The source API responded with a status we did not expect. Aborting. Please check the availability of the source API and if there are no rate limiting or redirections active.', + $exception, + $this->passThrough($line), + ); + + return; } $output = ($this->mapper)($lookup, $line); diff --git a/src/CustomerExtractor.php b/src/CustomerExtractor.php index f033ff1..946c8e9 100644 --- a/src/CustomerExtractor.php +++ b/src/CustomerExtractor.php @@ -6,86 +6,177 @@ use Kiboko\Component\Bucket\AcceptanceResultBucket; use Kiboko\Component\Bucket\RejectionResultBucket; -use Kiboko\Contract\Bucket\ResultBucketInterface; +use Kiboko\Contract\Bucket\RejectionResultBucketInterface; use Kiboko\Contract\Pipeline\ExtractorInterface; +use Kiboko\Magento\Client; +use Kiboko\Magento\Exception\GetV1CustomersSearchInternalServerErrorException; +use Kiboko\Magento\Exception\GetV1CustomersSearchUnauthorizedException; +use Kiboko\Magento\Exception\UnexpectedStatusCodeException; +use Kiboko\Magento\Model\CustomerDataCustomerInterface; +use Kiboko\Magento\Model\CustomerDataCustomerSearchResultsInterface; +use Kiboko\Magento\Model\ErrorResponse; use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Log\LoggerInterface; -final class CustomerExtractor implements ExtractorInterface +/** + * @implements ExtractorInterface + */ +final readonly class CustomerExtractor implements ExtractorInterface { - private array $queryParameters = [ - 'searchCriteria[currentPage]' => 1, - 'searchCriteria[pageSize]' => 100, - ]; - public function __construct( - private readonly \Psr\Log\LoggerInterface $logger, - private readonly \Kiboko\Magento\Client $client, - private readonly int $pageSize = 100, - /** @var FilterGroup[] $filters */ - private readonly array $filters = [], + private LoggerInterface $logger, + private Client $client, + private QueryParameters $queryParameters, + private int $pageSize = 100, ) { } - private function compileQueryParameters(int $currentPage = 1): array + /** + * @param array $parameters + * + * @return array + */ + private function applyPagination(array $parameters, int $currentPage, int $pageSize): array { - $parameters = $this->queryParameters; - $parameters['searchCriteria[currentPage]'] = $currentPage; - $parameters['searchCriteria[pageSize]'] = $this->pageSize; + return [ + ...$parameters, + 'searchCriteria[currentPage]' => $currentPage, + 'searchCriteria[pageSize]' => $pageSize, + ]; + } - $filters = array_map(fn (FilterGroup $item, int $key) => $item->compileFilters($key), $this->filters, array_keys($this->filters)); + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectErrorResponse(ErrorResponse $response, array $parameters, int $currentPage): RejectionResultBucketInterface + { + $this->logger->error( + $response->getMessage(), + [ + 'resource' => 'getV1CustomersSearch', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); - return array_merge($parameters, ...$filters); + return new RejectionResultBucket($response->getMessage(), null); } - public function extract(): iterable + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectInvalidResponse(array $parameters, int $currentPage): RejectionResultBucketInterface { - try { - $response = $this->client->getV1CustomersSearch( - queryParameters: $this->compileQueryParameters(), - ); - - if (!$response instanceof \Kiboko\Magento\Model\CustomerDataCustomerSearchResultsInterface) { - return; - } + $this->logger->error( + $message = 'The result provided by the API client does not match the expected type. The connector compilation may have fetched incompatible versions.', + [ + 'resource' => 'getV1CustomersSearch', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); - yield $this->processResponse($response); + return new RejectionResultBucket($message, null); + } - $currentPage = 1; - $pageCount = ceil($response->getTotalCount() / $this->pageSize); - while ($currentPage++ < $pageCount) { + public function extract(): iterable + { + foreach ($this->queryParameters->walkVariants() as $parameters) { + try { + $currentPage = 1; $response = $this->client->getV1CustomersSearch( - queryParameters: $this->compileQueryParameters($currentPage), + queryParameters: $this->applyPagination($parameters, $currentPage, $this->pageSize), ); + if ($response instanceof ErrorResponse) { + yield $this->rejectErrorResponse($response, $parameters, $currentPage); - yield $this->processResponse($response); - } - } catch (NetworkExceptionInterface $exception) { - $this->logger->alert( - $exception->getMessage(), - [ - 'exception' => $exception, - 'context' => [ - 'path' => 'customer', + return; + } + if (!$response instanceof CustomerDataCustomerSearchResultsInterface) { + yield $this->rejectInvalidResponse($parameters, $currentPage); + + return; + } + $pageCount = (int) ceil($response->getTotalCount() / $this->pageSize); + + yield new AcceptanceResultBucket(...$response->getItems()); + + while ($currentPage++ < $pageCount) { + $response = $this->client->getV1CustomersSearch( + queryParameters: $this->applyPagination($parameters, $currentPage, $this->pageSize), + ); + if ($response instanceof ErrorResponse) { + yield $this->rejectErrorResponse($response, $parameters, $currentPage); + + return; + } + if (!$response instanceof CustomerDataCustomerSearchResultsInterface) { + yield $this->rejectInvalidResponse($parameters, $currentPage); + + return; + } + + yield new AcceptanceResultBucket(...$response->getItems()); + } + } catch (NetworkExceptionInterface $exception) { + $this->logger->critical( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1CustomersSearch', 'method' => 'get', - 'queryParameters' => $this->compileQueryParameters(), + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, ], - ], - ); - yield new RejectionResultBucket( - 'There are some network difficulties. We could not properly connect to the Magento API. There is nothing we could no to fix this currently. Please contact the Magento administrator.', - $exception, - ); - } catch (\Exception $exception) { - $this->logger->critical($exception->getMessage(), ['exception' => $exception]); - } - } + ); + yield new RejectionResultBucket( + 'There are some network difficulties. We could not properly connect to the Magento API. There is nothing we could no to fix this currently. Please contact the Magento administrator.', + $exception, + ); - private function processResponse($response): ResultBucketInterface - { - if ($response instanceof \Kiboko\Magento\Model\ErrorResponse) { - return new RejectionResultBucket($response->getMessage(), null, $response); - } + return; + } catch (GetV1CustomersSearchUnauthorizedException $exception) { + $this->logger->warning($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The source API responded we are not authorized to access this resource. Aborting. Please check the credentials you provided.', + $exception, + ); + + return; + } catch (GetV1CustomersSearchInternalServerErrorException $exception) { + $this->logger->error($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The source API responded it is currently unavailable due to an internal error. Aborting. Please check the availability of the source API.', + $exception, + ); - return new AcceptanceResultBucket(...$response->getItems()); + return; + } catch (UnexpectedStatusCodeException $exception) { + $this->logger->critical($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The source API responded with a status we did not expect. Aborting. Please check the availability of the source API and if there are no rate limiting or redirections active.', + $exception, + ); + + return; + } catch (\Throwable $exception) { + $this->logger->emergency($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The client failed critically. Aborting. Please contact customer support or your system administrator.', + $exception, + ); + + return; + } + } } } diff --git a/src/Filter.php b/src/Filter.php deleted file mode 100644 index 6312fac..0000000 --- a/src/Filter.php +++ /dev/null @@ -1,15 +0,0 @@ - + */ +final class ArrayFilter implements FilterInterface, \IteratorAggregate +{ + /** + * @param list $values + */ + public function __construct( + public string $field, + public string $conditionType, + public array $values, + private readonly int $threshold = 200 + ) { + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + $length = \count($this->values); + for ($offset = 0; $offset < $length; $offset += $this->threshold) { + yield [ + 'field' => $this->field, + 'value' => implode(',', array_map( + fn (bool|\DateTimeInterface|float|int|string $value) => $value instanceof \DateTimeInterface ? $value->format(\DateTimeInterface::ATOM) : (string) $value, + \array_slice($this->values, $offset, $this->threshold, false) + )), + 'conditionType' => $this->conditionType, + ]; + } + } +} diff --git a/src/Filter/FilterInterface.php b/src/Filter/FilterInterface.php new file mode 100644 index 0000000..e753cfb --- /dev/null +++ b/src/Filter/FilterInterface.php @@ -0,0 +1,12 @@ + + */ +interface FilterInterface extends \Traversable +{ +} diff --git a/src/Filter/ScalarFilter.php b/src/Filter/ScalarFilter.php new file mode 100644 index 0000000..7e8bc1d --- /dev/null +++ b/src/Filter/ScalarFilter.php @@ -0,0 +1,30 @@ + + */ +final class ScalarFilter implements FilterInterface, \IteratorAggregate +{ + public function __construct( + public string $field, + public string $conditionType, + public bool|\DateTimeInterface|float|int|string $value, + ) { + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + yield [ + 'field' => $this->field, + 'value' => $this->value instanceof \DateTimeInterface ? $this->value->format(\DateTimeInterface::ATOM) : (string) $this->value, + 'conditionType' => $this->conditionType, + ]; + } +} diff --git a/src/FilterGroup.php b/src/FilterGroup.php index 1634f83..71a3aa1 100644 --- a/src/FilterGroup.php +++ b/src/FilterGroup.php @@ -4,48 +4,84 @@ namespace Kiboko\Component\Flow\Magento2; +use Kiboko\Component\Flow\Magento2\Filter\FilterInterface; +use Kiboko\Component\Flow\Magento2\Filter\ScalarFilter; + class FilterGroup { + /** @var array */ private array $filters = []; - public function withFilter(Filter $filter): self + public function withFilter(FilterInterface $filter): self { - $this->filters[] = [ - 'field' => $filter->field, - 'value' => $filter->value, - 'condition_type' => $filter->conditionType, - ]; + $this->filters[] = $filter; return $this; } - public function withFilters(Filter ...$filters): self + public function withFilters(FilterInterface ...$filters): self { - array_walk($filters, fn (Filter $filter) => $this->filters[] = [ - 'field' => $filter->field, - 'value' => $filter->value, - 'condition_type' => $filter->conditionType, - ]); + array_push($this->filters, ...$filters); return $this; } - public function compileFilters(int $groupIndex = 0): array + /** + * @param array $parameters + * + * @return \Traversable> + */ + public function walkFilters(array $parameters, int $groupIndex = 0): \Traversable + { + if (\count($this->filters) < 1) { + return; + } + + yield from $this->buildFilters($parameters, $groupIndex, 1, ...$this->filters); + } + + /** + * @param array $parameters + * + * @return \Traversable> + */ + private function buildFilters(array $parameters, int $groupIndex, int $filterIndex, FilterInterface $first, FilterInterface ...$next): \Traversable + { + foreach ($first as $current) { + $childParameters = [ + ...$parameters, + ...[ + sprintf('searchCriteria[filterGroups][%s][filters][%s][field]', $groupIndex, $filterIndex) => $current['field'], + sprintf('searchCriteria[filterGroups][%s][filters][%s][value]', $groupIndex, $filterIndex) => $current['value'], + sprintf('searchCriteria[filterGroups][%s][filters][%s][conditionType]', $groupIndex, $filterIndex) => $current['conditionType'], + ], + ]; + + if (\count($next) >= 1) { + yield from $this->buildFilters($childParameters, $groupIndex, $filterIndex + 1, ...$next); + } else { + yield $childParameters; + } + } + } + + public function greaterThan(string $field, \DateTimeInterface|float|int|string $value): self + { + return $this->withFilter(new ScalarFilter($field, 'gt', $value)); + } + + public function lowerThan(string $field, \DateTimeInterface|float|int|string $value): self { - return array_merge(...array_map(fn (array $item, int $key) => [ - sprintf('searchCriteria[filterGroups][%s][filters][%s][field]', $groupIndex, $key) => $item['field'], - sprintf('searchCriteria[filterGroups][%s][filters][%s][value]', $groupIndex, $key) => $item['value'], - sprintf('searchCriteria[filterGroups][%s][filters][%s][conditionType]', $groupIndex, $key) => $item['condition_type'], - ], $this->filters, array_keys($this->filters))); + return $this->withFilter(new ScalarFilter($field, 'lt', $value)); } - public function greaterThan(string $field, mixed $value): self + public function greaterThanOrEqual(string $field, \DateTimeInterface|float|int|string $value): self { - return $this->withFilter(new Filter($field, 'gt', $value)); + return $this->withFilter(new ScalarFilter($field, 'gteq', $value)); } - public function greaterThanEqual(string $field, mixed $value): self + public function lowerThanOrEqual(string $field, \DateTimeInterface|float|int|string $value): self { - return $this->withFilter(new Filter($field, 'gteq', $value)); + return $this->withFilter(new ScalarFilter($field, 'lteq', $value)); } } diff --git a/src/InvoiceExtractor.php b/src/InvoiceExtractor.php index a59feb4..7a7cbc7 100644 --- a/src/InvoiceExtractor.php +++ b/src/InvoiceExtractor.php @@ -6,86 +6,168 @@ use Kiboko\Component\Bucket\AcceptanceResultBucket; use Kiboko\Component\Bucket\RejectionResultBucket; -use Kiboko\Contract\Bucket\ResultBucketInterface; +use Kiboko\Contract\Bucket\RejectionResultBucketInterface; use Kiboko\Contract\Pipeline\ExtractorInterface; +use Kiboko\Magento\Client; +use Kiboko\Magento\Exception\GetV1InvoicesUnauthorizedException; +use Kiboko\Magento\Exception\UnexpectedStatusCodeException; +use Kiboko\Magento\Model\ErrorResponse; +use Kiboko\Magento\Model\SalesDataInvoiceInterface; +use Kiboko\Magento\Model\SalesDataInvoiceSearchResultInterface; use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Log\LoggerInterface; -final class InvoiceExtractor implements ExtractorInterface +/** + * @implements ExtractorInterface + */ +final readonly class InvoiceExtractor implements ExtractorInterface { - private array $queryParameters = [ - 'searchCriteria[currentPage]' => 1, - 'searchCriteria[pageSize]' => 100, - ]; - public function __construct( - private readonly \Psr\Log\LoggerInterface $logger, - private readonly \Kiboko\Magento\Client $client, - private readonly int $pageSize = 100, - /** @var FilterGroup[] $filters */ - private readonly array $filters = [], + private LoggerInterface $logger, + private Client $client, + private QueryParameters $queryParameters, + private int $pageSize = 100, ) { } - private function compileQueryParameters(int $currentPage = 1): array + /** + * @param array $parameters + * + * @return array + */ + private function applyPagination(array $parameters, int $currentPage, int $pageSize): array { - $parameters = $this->queryParameters; - $parameters['searchCriteria[currentPage]'] = $currentPage; - $parameters['searchCriteria[pageSize]'] = $this->pageSize; + return [ + ...$parameters, + 'searchCriteria[currentPage]' => $currentPage, + 'searchCriteria[pageSize]' => $pageSize, + ]; + } - $filters = array_map(fn (FilterGroup $item, int $key) => $item->compileFilters($key), $this->filters, array_keys($this->filters)); + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectErrorResponse(ErrorResponse $response, array $parameters, int $currentPage): RejectionResultBucketInterface + { + $this->logger->error( + $response->getMessage(), + [ + 'resource' => 'getV1Invoices', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); - return array_merge($parameters, ...$filters); + return new RejectionResultBucket($response->getMessage(), null); } - public function extract(): iterable + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectInvalidResponse(array $parameters, int $currentPage): RejectionResultBucketInterface { - try { - $response = $this->client->getV1Invoices( - queryParameters: $this->compileQueryParameters(), - ); + $this->logger->error( + $message = 'The result provided by the API client does not match the expected type. The connector compilation may have fetched incompatible versions.', + [ + 'resource' => 'getV1Invoices', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); - if (!$response instanceof \Kiboko\Magento\Model\SalesDataInvoiceSearchResultInterface) { - return; - } - - yield $this->processResponse($response); + return new RejectionResultBucket($message, null); + } - $currentPage = 1; - $pageCount = ceil($response->getTotalCount() / $this->pageSize); - while ($currentPage++ < $pageCount) { + public function extract(): iterable + { + foreach ($this->queryParameters->walkVariants() as $parameters) { + try { + $currentPage = 1; $response = $this->client->getV1Invoices( - queryParameters: $this->compileQueryParameters($currentPage), + queryParameters: $this->applyPagination($parameters, $currentPage, $this->pageSize), ); + if ($response instanceof ErrorResponse) { + yield $this->rejectErrorResponse($response, $parameters, $currentPage); - yield $this->processResponse($response); - } - } catch (NetworkExceptionInterface $exception) { - $this->logger->alert( - $exception->getMessage(), - [ - 'exception' => $exception, - 'context' => [ - 'path' => 'invoice', + return; + } + if (!$response instanceof SalesDataInvoiceSearchResultInterface) { + yield $this->rejectInvalidResponse($parameters, $currentPage); + + return; + } + $pageCount = (int) ceil($response->getTotalCount() / $this->pageSize); + + yield new AcceptanceResultBucket(...$response->getItems()); + + while ($currentPage++ < $pageCount) { + $response = $this->client->getV1Invoices( + queryParameters: $this->applyPagination($parameters, $currentPage, $this->pageSize), + ); + if ($response instanceof ErrorResponse) { + yield $this->rejectErrorResponse($response, $parameters, $currentPage); + + return; + } + if (!$response instanceof SalesDataInvoiceSearchResultInterface) { + yield $this->rejectInvalidResponse($parameters, $currentPage); + + return; + } + + yield new AcceptanceResultBucket(...$response->getItems()); + } + } catch (NetworkExceptionInterface $exception) { + $this->logger->critical( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1Invoices', 'method' => 'get', - 'queryParameters' => $this->compileQueryParameters(), + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, ], - ] - ); - yield new RejectionResultBucket( - 'There are some network difficulties. We could not properly connect to the Magento API. There is nothing we could no to fix this currently. Please contact the Magento administrator.', - $exception, - ); - } catch (\Exception $exception) { - $this->logger->critical($exception->getMessage(), ['exception' => $exception]); - } - } + ); + yield new RejectionResultBucket( + 'There are some network difficulties. We could not properly connect to the Magento API. There is nothing we could no to fix this currently. Please contact the Magento administrator.', + $exception, + ); - private function processResponse($response): ResultBucketInterface - { - if ($response instanceof \Kiboko\Magento\Model\ErrorResponse) { - return new RejectionResultBucket($response->getMessage(), null, $response); - } + return; + } catch (GetV1InvoicesUnauthorizedException $exception) { + $this->logger->warning($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The source API responded we are not authorized to access this resource. Aborting. Please check the credentials you provided.', + $exception, + ); - return new AcceptanceResultBucket(...$response->getItems()); + return; + } catch (UnexpectedStatusCodeException $exception) { + $this->logger->critical($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The source API responded with a status we did not expect. Aborting. Please check the availability of the source API and if there are no rate limiting or redirections active.', + $exception, + ); + + return; + } catch (\Throwable $exception) { + $this->logger->emergency($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The client failed critically. Aborting. Please contact customer support or your system administrator.', + $exception, + ); + + return; + } + } } } diff --git a/src/Lookup.php b/src/Lookup.php deleted file mode 100644 index 6812bb6..0000000 --- a/src/Lookup.php +++ /dev/null @@ -1,69 +0,0 @@ -mappingField]) { - $line = yield new AcceptanceResultBucket($line); - } - - try { - $lookup = $this->cache->get(sprintf($this->cacheKey, $line[$this->mappingField])); - - if (null === $lookup) { - $results = $this->client->getV1ProductsAttributesAttributeCodeOptions( - attributeCode: $this->attributeCode, - ); - - $lookup = array_values(array_filter($results, fn (object $item) => $item->getValue() === $line[$this->mappingField]))[0]; - - if (!$lookup instanceof \Kiboko\Magento\Model\EavDataAttributeOptionInterface) { - return; - } - - $this->cache->set( - sprintf($this->cacheKey, $line[$this->mappingField]), - $lookup, - ); - } - } catch (\RuntimeException $exception) { - $this->logger->warning($exception->getMessage(), ['exception' => $exception, 'item' => $line]); - $line = yield new RejectionResultBucket( - sprintf('Something went wrong in the attempt to recover the attribute option for attribute %s', $this->attributeCode), - $exception, - $line - ); - continue; - } - - $output = ($this->mapper)($lookup, $line); - - $line = yield new AcceptanceResultBucket($output); - } - } -} diff --git a/src/OrderExtractor.php b/src/OrderExtractor.php index d0a16bb..211b40c 100644 --- a/src/OrderExtractor.php +++ b/src/OrderExtractor.php @@ -6,86 +6,200 @@ use Kiboko\Component\Bucket\AcceptanceResultBucket; use Kiboko\Component\Bucket\RejectionResultBucket; -use Kiboko\Contract\Bucket\ResultBucketInterface; +use Kiboko\Contract\Bucket\RejectionResultBucketInterface; use Kiboko\Contract\Pipeline\ExtractorInterface; +use Kiboko\Magento\Client; +use Kiboko\Magento\Exception\GetV1OrdersUnauthorizedException; +use Kiboko\Magento\Exception\UnexpectedStatusCodeException; +use Kiboko\Magento\Model\ErrorResponse; +use Kiboko\Magento\Model\SalesDataOrderInterface; +use Kiboko\Magento\Model\SalesDataOrderSearchResultInterface; use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException; -final class OrderExtractor implements ExtractorInterface +/** + * @implements ExtractorInterface + */ +final readonly class OrderExtractor implements ExtractorInterface { - private array $queryParameters = [ - 'searchCriteria[currentPage]' => 1, - 'searchCriteria[pageSize]' => 100, - ]; - public function __construct( - private readonly \Psr\Log\LoggerInterface $logger, - private readonly \Kiboko\Magento\Client $client, - private readonly int $pageSize = 100, - /** @var FilterGroup[] $filters */ - private readonly array $filters = [], + private LoggerInterface $logger, + private Client $client, + private QueryParameters $queryParameters, + private int $pageSize = 100, ) { } - private function compileQueryParameters(int $currentPage = 1): array + /** + * @param array $parameters + * + * @return array + */ + private function applyPagination(array $parameters, int $currentPage, int $pageSize): array { - $parameters = $this->queryParameters; - $parameters['searchCriteria[currentPage]'] = $currentPage; - $parameters['searchCriteria[pageSize]'] = $this->pageSize; + return [ + ...$parameters, + 'searchCriteria[currentPage]' => $currentPage, + 'searchCriteria[pageSize]' => $pageSize, + ]; + } - $filters = array_map(fn (FilterGroup $item, int $key) => $item->compileFilters($key), $this->filters, array_keys($this->filters)); + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectErrorResponse(ErrorResponse $response, array $parameters, int $currentPage): RejectionResultBucketInterface + { + $this->logger->error( + $response->getMessage(), + [ + 'resource' => 'getV1Orders', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); - return array_merge($parameters, ...$filters); + return new RejectionResultBucket($response->getMessage(), null); } - public function extract(): iterable + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectInvalidResponse(array $parameters, int $currentPage): RejectionResultBucketInterface { - try { - $response = $this->client->getV1Orders( - queryParameters: $this->compileQueryParameters(), - ); + $this->logger->error( + $message = 'The result provided by the API client does not match the expected type. The connector compilation may have fetched incompatible versions.', + [ + 'resource' => 'getV1Orders', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); - if (!$response instanceof \Kiboko\Magento\Model\SalesDataOrderSearchResultInterface) { - return; - } + return new RejectionResultBucket($message, null); + } - yield $this->processResponse($response); + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectUndefinedOptionsResponse(UndefinedOptionsException $response, array $parameters, int $currentPage): RejectionResultBucketInterface + { + $this->logger->error( + $message = 'The result provided by the API client does not match the expected query parameters. The connector compilation may have fetched incompatible versions.', + [ + 'resource' => 'getV1Orders', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); + + return new RejectionResultBucket($message, null); + } - $currentPage = 1; - $pageCount = ceil($response->getTotalCount() / $this->pageSize); - while ($currentPage++ < $pageCount) { + public function extract(): iterable + { + foreach ($this->queryParameters->walkVariants() as $parameters) { + try { + $currentPage = 1; $response = $this->client->getV1Orders( - queryParameters: $this->compileQueryParameters($currentPage), + queryParameters: $this->applyPagination($parameters, $currentPage, $this->pageSize), ); + if ($response instanceof ErrorResponse) { + yield $this->rejectErrorResponse($response, $parameters, $currentPage); - yield $this->processResponse($response); - } - } catch (NetworkExceptionInterface $exception) { - $this->logger->alert( - $exception->getMessage(), - [ - 'exception' => $exception, - 'context' => [ - 'path' => 'order', + return; + } + if ($response instanceof UndefinedOptionsException) { + yield $this->rejectUndefinedOptionsResponse($response, $parameters, $currentPage); + + return; + } + if (!$response instanceof SalesDataOrderSearchResultInterface) { + yield $this->rejectInvalidResponse($parameters, $currentPage); + + return; + } + $pageCount = (int) ceil($response->getTotalCount() / $this->pageSize); + + yield new AcceptanceResultBucket(...$response->getItems()); + + while ($currentPage++ < $pageCount) { + $response = $this->client->getV1Orders( + queryParameters: $this->applyPagination($parameters, $currentPage, $this->pageSize), + ); + if ($response instanceof ErrorResponse) { + yield $this->rejectErrorResponse($response, $parameters, $currentPage); + + return; + } + if ($response instanceof UndefinedOptionsException) { + yield $this->rejectUndefinedOptionsResponse($response, $parameters, $currentPage); + + return; + } + if (!$response instanceof SalesDataOrderSearchResultInterface) { + yield $this->rejectInvalidResponse($parameters, $currentPage); + + return; + } + + yield new AcceptanceResultBucket(...$response->getItems()); + } + } catch (NetworkExceptionInterface $exception) { + $this->logger->critical( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1Orders', 'method' => 'get', - 'queryParameters' => $this->compileQueryParameters(), + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, ], - ] - ); - yield new RejectionResultBucket( - 'There are some network difficulties. We could not properly connect to the Magento API. There is nothing we could no to fix this currently. Please contact the Magento administrator.', - $exception, - ); - } catch (\Exception $exception) { - $this->logger->critical($exception->getMessage(), ['exception' => $exception]); - } - } + ); + yield new RejectionResultBucket( + 'There are some network difficulties. We could not properly connect to the Magento API. There is nothing we could no to fix this currently. Please contact the Magento administrator.', + $exception, + ); - private function processResponse($response): ResultBucketInterface - { - if ($response instanceof \Kiboko\Magento\Model\ErrorResponse) { - return new RejectionResultBucket($response->getMessage(), null, $response); - } + return; + } catch (GetV1OrdersUnauthorizedException $exception) { + $this->logger->warning($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The source API responded we are not authorized to access this resource. Aborting. Please check the credentials you provided.', + $exception, + ); - return new AcceptanceResultBucket(...$response->getItems()); + return; + } catch (UnexpectedStatusCodeException $exception) { + $this->logger->critical($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The source API responded with a status we did not expect. Aborting. Please check the availability of the source API and if there are no rate limiting or redirections active.', + $exception, + ); + + return; + } catch (\Throwable $exception) { + $this->logger->emergency($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The client failed critically. Aborting. Please contact customer support or your system administrator.', + $exception, + ); + + return; + } + } } } diff --git a/src/ProductExtractor.php b/src/ProductExtractor.php index 110e079..7ea4dab 100644 --- a/src/ProductExtractor.php +++ b/src/ProductExtractor.php @@ -6,86 +6,159 @@ use Kiboko\Component\Bucket\AcceptanceResultBucket; use Kiboko\Component\Bucket\RejectionResultBucket; -use Kiboko\Contract\Bucket\ResultBucketInterface; +use Kiboko\Contract\Bucket\RejectionResultBucketInterface; use Kiboko\Contract\Pipeline\ExtractorInterface; +use Kiboko\Magento\Client; +use Kiboko\Magento\Exception\UnexpectedStatusCodeException; +use Kiboko\Magento\Model\CatalogDataProductInterface; +use Kiboko\Magento\Model\CatalogDataProductSearchResultsInterface; +use Kiboko\Magento\Model\ErrorResponse; use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Log\LoggerInterface; -final class ProductExtractor implements ExtractorInterface +/** + * @implements ExtractorInterface + */ +final readonly class ProductExtractor implements ExtractorInterface { - private array $queryParameters = [ - 'searchCriteria[currentPage]' => 1, - 'searchCriteria[pageSize]' => 100, - ]; - public function __construct( - private readonly \Psr\Log\LoggerInterface $logger, - private readonly \Kiboko\Magento\Client $client, - private readonly int $pageSize = 100, - /** @var FilterGroup[] $filters */ - private readonly array $filters = [], + private LoggerInterface $logger, + private Client $client, + private QueryParameters $queryParameters, + private int $pageSize = 100, ) { } - private function compileQueryParameters(int $currentPage = 1): array + /** + * @param array $parameters + * + * @return array + */ + private function applyPagination(array $parameters, int $currentPage, int $pageSize): array { - $parameters = $this->queryParameters; - $parameters['searchCriteria[currentPage]'] = $currentPage; - $parameters['searchCriteria[pageSize]'] = $this->pageSize; + return [ + ...$parameters, + 'searchCriteria[currentPage]' => $currentPage, + 'searchCriteria[pageSize]' => $pageSize, + ]; + } - $filters = array_map(fn (FilterGroup $item, int $key) => $item->compileFilters($key), $this->filters, array_keys($this->filters)); + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectErrorResponse(ErrorResponse $response, array $parameters, int $currentPage): RejectionResultBucketInterface + { + $this->logger->error( + $response->getMessage(), + [ + 'resource' => 'getV1Products', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); - return array_merge($parameters, ...$filters); + return new RejectionResultBucket($response->getMessage(), null); } - public function extract(): iterable + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectInvalidResponse(array $parameters, int $currentPage): RejectionResultBucketInterface { - try { - $response = $this->client->getV1Products( - queryParameters: $this->compileQueryParameters(), - ); + $this->logger->error( + $message = 'The result provided by the API client does not match the expected type. The connector compilation may have fetched incompatible versions.', + [ + 'resource' => 'getV1Products', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); - if (!$response instanceof \Kiboko\Magento\Model\CatalogDataProductSearchResultsInterface) { - return; - } - - yield $this->processResponse($response); + return new RejectionResultBucket($message, null); + } - $currentPage = 1; - $pageCount = ceil($response->getTotalCount() / $this->pageSize); - while ($currentPage++ < $pageCount) { + public function extract(): iterable + { + foreach ($this->queryParameters->walkVariants() as $parameters) { + try { + $currentPage = 1; $response = $this->client->getV1Products( - queryParameters: $this->compileQueryParameters($currentPage), + queryParameters: $this->applyPagination($parameters, $currentPage, $this->pageSize), ); + if ($response instanceof ErrorResponse) { + yield $this->rejectErrorResponse($response, $parameters, $currentPage); - yield $this->processResponse($response); - } - } catch (NetworkExceptionInterface $exception) { - $this->logger->alert( - $exception->getMessage(), - [ - 'exception' => $exception, - 'context' => [ - 'path' => 'product', + return; + } + if (!$response instanceof CatalogDataProductSearchResultsInterface) { + yield $this->rejectInvalidResponse($parameters, $currentPage); + + return; + } + $pageCount = (int) ceil($response->getTotalCount() / $this->pageSize); + + yield new AcceptanceResultBucket(...$response->getItems()); + + while ($currentPage++ < $pageCount) { + $response = $this->client->getV1Products( + queryParameters: $this->applyPagination($parameters, $currentPage, $this->pageSize), + ); + if ($response instanceof ErrorResponse) { + yield $this->rejectErrorResponse($response, $parameters, $currentPage); + + return; + } + if (!$response instanceof CatalogDataProductSearchResultsInterface) { + yield $this->rejectInvalidResponse($parameters, $currentPage); + + return; + } + + yield new AcceptanceResultBucket(...$response->getItems()); + } + } catch (NetworkExceptionInterface $exception) { + $this->logger->critical( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1Products', 'method' => 'get', - 'queryParameters' => $this->compileQueryParameters(), + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, ], - ] - ); - yield new RejectionResultBucket( - 'There are some network difficulties. We could not properly connect to the Magento API. There is nothing we could no to fix this currently. Please contact the Magento administrator.', - $exception, - ); - } catch (\Exception $exception) { - $this->logger->critical($exception->getMessage(), ['exception' => $exception]); - } - } + ); + yield new RejectionResultBucket( + 'There are some network difficulties. We could not properly connect to the Magento API. There is nothing we could no to fix this currently. Please contact the Magento administrator.', + $exception, + ); - private function processResponse($response): ResultBucketInterface - { - if ($response instanceof \Kiboko\Magento\Model\ErrorResponse) { - return new RejectionResultBucket($response->getMessage(), null, $response); - } + return; + } catch (UnexpectedStatusCodeException $exception) { + $this->logger->critical($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The source API responded with a status we did not expect. Aborting. Please check the availability of the source API and if there are no rate limiting or redirections active.', + $exception, + ); + + return; + } catch (\Throwable $exception) { + $this->logger->emergency($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The client failed critically. Aborting. Please contact customer support or your system administrator.', + $exception, + ); - return new AcceptanceResultBucket(...$response->getItems()); + return; + } + } } } diff --git a/src/ProductOptionsLookup.php b/src/ProductOptionsLookup.php new file mode 100644 index 0000000..ff893fe --- /dev/null +++ b/src/ProductOptionsLookup.php @@ -0,0 +1,191 @@ + + */ +final readonly class ProductOptionsLookup implements TransformerInterface +{ + /** + * @param CompiledMapperInterface $mapper + */ + public function __construct( + private LoggerInterface $logger, + private Client $client, + private CompiledMapperInterface $mapper, + private string $mappingField, + private string $attributeCode, + ) { + } + + /** + * @return RejectionResultBucketInterface + */ + private function rejectErrorResponse(ErrorResponse $response): RejectionResultBucketInterface + { + $this->logger->error( + $response->getMessage(), + [ + 'resource' => 'getV1ProductsAttributesAttributeCodeOptions', + 'method' => 'get', + ], + ); + + return new RejectionResultBucket($response->getMessage(), null); + } + + /** + * @return RejectionResultBucketInterface + */ + private function rejectInvalidResponse(): RejectionResultBucketInterface + { + $this->logger->error( + $message = 'The result provided by the API client does not match the expected type. The connector compilation may have fetched incompatible versions.', + [ + 'resource' => 'getV1ProductsAttributesAttributeCodeOptions', + 'method' => 'get', + ], + ); + + return new RejectionResultBucket($message, null); + } + + /** + * @param InputType $line + * + * @return OutputType + */ + public function passThrough(array $line): array + { + /* @var OutputType $line */ + return $line; + } + + public function transform(): \Generator + { + $line = yield new EmptyResultBucket(); + while (true) { + if (null === $line) { + $line = yield new EmptyResultBucket(); + continue; + } + + if (null === $line[$this->mappingField]) { + $line = yield new AcceptanceResultBucket($this->passThrough($line)); + continue; + } + + try { + $lookup = $this->client->getV1ProductsAttributesAttributeCodeOptions( + attributeCode: $this->attributeCode, + ); + + if ($lookup instanceof ErrorResponse) { + $line = yield $this->rejectErrorResponse($lookup); + continue; + } + + if (!\is_array($lookup) || !array_is_list($lookup)) { + $line = yield $this->rejectInvalidResponse(); + continue; + } + + $lookup = array_filter( + $lookup, + fn (object $item) => $item->getValue() === $line[$this->mappingField], + ); + } catch (NetworkExceptionInterface $exception) { + $this->logger->critical( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1ProductsAttributesAttributeCodeOptions', + 'method' => 'get', + 'categoryId' => (int) $line[$this->mappingField], + 'mappingField' => $this->mappingField, + ], + ); + $line = yield new RejectionResultBucket( + 'There are some network difficulties. We could not properly connect to the Magento API. There is nothing we could no to fix this currently. Please contact the Magento administrator.', + $exception, + $this->passThrough($line), + ); + continue; + } catch (GetV1ProductsAttributesAttributeCodeOptionsBadRequestException $exception) { + $this->logger->error( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1ProductsAttributesAttributeCodeOptions', + 'method' => 'get', + 'categoryId' => (int) $line[$this->mappingField], + 'mappingField' => $this->mappingField, + ], + ); + $line = yield new RejectionResultBucket( + 'The source API rejected our request. Ignoring line. Maybe you are requesting on incompatible versions.', + $exception, + $this->passThrough($line), + ); + continue; + } catch (UnexpectedStatusCodeException $exception) { + $this->logger->critical( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1ProductsAttributesAttributeCodeOptions', + 'method' => 'get', + 'categoryId' => (int) $line[$this->mappingField], + 'mappingField' => $this->mappingField, + ], + ); + $line = yield new RejectionResultBucket( + 'The source API responded with a status we did not expect. Aborting. Please check the availability of the source API and if there are no rate limiting or redirections active.', + $exception, + $this->passThrough($line), + ); + + return; + } + + reset($lookup); + $current = current($lookup); + if (\count($lookup) <= 0 || false === $current) { + $this->logger->critical( + 'The lookup did not find any related resource. The lookup operation had no effect.', + [ + 'resource' => 'getV1ProductsAttributesAttributeCodeOptions', + 'method' => 'get', + 'categoryId' => (int) $line[$this->mappingField], + 'mappingField' => $this->mappingField, + ], + ); + $line = yield new AcceptanceResultBucket($this->passThrough($line)); + continue; + } + $output = ($this->mapper)($current, $line); + + $line = yield new AcceptanceResultBucket($output); + } + } +} diff --git a/src/QueryParameters.php b/src/QueryParameters.php new file mode 100644 index 0000000..bf5ef79 --- /dev/null +++ b/src/QueryParameters.php @@ -0,0 +1,55 @@ + */ + private array $groups = []; + + public function withGroup(FilterGroup $group): self + { + $this->groups[] = $group; + + return $this; + } + + public function withGroups(FilterGroup ...$groups): self + { + array_push($this->groups, ...$groups); + + return $this; + } + + /** + * @param array $parameters + * + * @return \Traversable> + */ + public function walkVariants(array $parameters = []): \Traversable + { + if (\count($this->groups) < 1) { + return; + } + + yield from $this->buildFilters($parameters, 0, ...$this->groups); + } + + /** + * @param array $parameters + * + * @return \Traversable> + */ + private function buildFilters(array $parameters, int $groupIndex, FilterGroup $first, FilterGroup ...$next): \Traversable + { + foreach ($first->walkFilters($parameters, $groupIndex) as $current) { + if (\count($next) >= 1) { + yield from $this->buildFilters($current, $groupIndex + 1, ...$next); + } else { + yield $current; + } + } + } +} diff --git a/tests/CustomerExtractorTest.php b/tests/CustomerExtractorTest.php index 5b92e91..6f0f7e5 100644 --- a/tests/CustomerExtractorTest.php +++ b/tests/CustomerExtractorTest.php @@ -5,8 +5,9 @@ namespace Tests\Kiboko\Component\Flow\Magento2; use Kiboko\Component\Flow\Magento2\CustomerExtractor; -use Kiboko\Component\Flow\Magento2\Filter; +use Kiboko\Component\Flow\Magento2\Filter\ScalarFilter; use Kiboko\Component\Flow\Magento2\FilterGroup; +use Kiboko\Component\Flow\Magento2\QueryParameters; use Kiboko\Component\PHPUnitExtension\Assert\ExtractorAssertTrait; use Kiboko\Component\PHPUnitExtension\PipelineRunner; use Kiboko\Contract\Pipeline\PipelineRunnerInterface; @@ -56,11 +57,15 @@ public function isSuccessful(): void $extractor = new CustomerExtractor( new NullLogger(), $client, - 1, - [ - (new FilterGroup())->withFilter(new Filter('updated_at', 'eq', '2022-09-05')), - (new FilterGroup())->withFilter(new Filter('active', 'eq', true)), - ] + (new QueryParameters()) + ->withGroup( + (new FilterGroup()) + ->withFilter(new ScalarFilter('updated_at', 'eq', '2022-09-05')), + ) + ->withGroup( + (new FilterGroup()) + ->withFilter(new ScalarFilter('active', 'eq', true)), + ) ); $this->assertExtractorExtractsExactly( diff --git a/tests/Filter/ArrayFilterTest.php b/tests/Filter/ArrayFilterTest.php new file mode 100644 index 0000000..40f8a04 --- /dev/null +++ b/tests/Filter/ArrayFilterTest.php @@ -0,0 +1,46 @@ +assertCount(1, iterator_to_array($filter->getIterator(), false)); + $this->assertContains([ + 'field' => 'foo', + 'value' => '1,2,3,4', + 'conditionType' => 'in', + ], $filter); + } + + #[Test] + public function shouldProduceSeveralVariants(): void + { + $filter = new ArrayFilter('foo', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], 4); + $this->assertCount(3, iterator_to_array($filter->getIterator(), false)); + $this->assertContains([ + 'field' => 'foo', + 'value' => '1,2,3,4', + 'conditionType' => 'in', + ], $filter); + $this->assertContains([ + 'field' => 'foo', + 'value' => '5,6,7,8', + 'conditionType' => 'in', + ], $filter); + $this->assertContains([ + 'field' => 'foo', + 'value' => '9,10,11', + 'conditionType' => 'in', + ], $filter); + } +} diff --git a/tests/Filter/ScalarFilterTest.php b/tests/Filter/ScalarFilterTest.php new file mode 100644 index 0000000..960c4ad --- /dev/null +++ b/tests/Filter/ScalarFilterTest.php @@ -0,0 +1,24 @@ +assertCount(1, iterator_to_array($filter->getIterator(), false)); + $this->assertContains([ + 'field' => 'foo', + 'value' => '4', + 'conditionType' => 'eq', + ], $filter); + } +} diff --git a/tests/FilterGroupTest.php b/tests/FilterGroupTest.php new file mode 100644 index 0000000..b6c9c24 --- /dev/null +++ b/tests/FilterGroupTest.php @@ -0,0 +1,162 @@ +withFilter( + new ArrayFilter('foo', 'in', [1, 2, 3, 4], 4), + ); + $this->assertCount(1, iterator_to_array($group->walkFilters([]), false)); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + ], $group->walkFilters([])); + } + + #[Test] + public function shouldProduceSeveralVariants(): void + { + $group = new FilterGroup(); + $group->withFilter( + new ArrayFilter('foo', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], 4), + ); + $this->assertCount(3, iterator_to_array($group->walkFilters([]), false)); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + ], $group->walkFilters([])); + } + + #[Test] + public function shouldProduceDemultipliedVariants(): void + { + $group = new FilterGroup(); + $group->withFilter( + new ArrayFilter('foo', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], 4), + ); + $group->withFilter( + new ArrayFilter('bar', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], 4), + ); + $this->assertCount(12, iterator_to_array($group->walkFilters([]), false)); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '9,10,11,12', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '9,10,11,12', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '9,10,11,12', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '13,14,15', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '13,14,15', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '13,14,15', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + } +} diff --git a/tests/InvoiceExtractorTest.php b/tests/InvoiceExtractorTest.php new file mode 100644 index 0000000..2f600aa --- /dev/null +++ b/tests/InvoiceExtractorTest.php @@ -0,0 +1,69 @@ +setBaseCurrencyCode('EUR') + ->setTotalQty(1) + ->setBaseGrandTotal(59.90); + + $client = $this->createMock(Client::class); + $client + ->expects($this->once()) + ->method('getV1Invoices') + ->willReturn( + (new SalesDataInvoiceSearchResultInterface()) + ->setItems([ + $invoice, + ]) + ->setTotalCount(1) + ); + + $extractor = new InvoiceExtractor( + new NullLogger(), + $client, + (new QueryParameters()) + ->withGroup( + (new FilterGroup()) + ->withFilter(new ScalarFilter('updated_at', 'eq', '2022-09-05')), + ) + ->withGroup( + (new FilterGroup()) + ->withFilter(new ScalarFilter('active', 'eq', true)), + ) + ); + + $this->assertExtractorExtractsExactly( + [ + $invoice, + ], + $extractor + ); + } + + public function pipelineRunner(): PipelineRunnerInterface + { + return new PipelineRunner(); + } +} diff --git a/tests/OrderExtractorTest.php b/tests/OrderExtractorTest.php index 62fd958..d65326b 100644 --- a/tests/OrderExtractorTest.php +++ b/tests/OrderExtractorTest.php @@ -4,7 +4,10 @@ namespace Tests\Kiboko\Component\Flow\Magento2; +use Kiboko\Component\Flow\Magento2\Filter\ScalarFilter; +use Kiboko\Component\Flow\Magento2\FilterGroup; use Kiboko\Component\Flow\Magento2\OrderExtractor; +use Kiboko\Component\Flow\Magento2\QueryParameters; use Kiboko\Component\PHPUnitExtension\Assert\ExtractorAssertTrait; use Kiboko\Component\PHPUnitExtension\PipelineRunner; use Kiboko\Contract\Pipeline\PipelineRunnerInterface; @@ -46,6 +49,15 @@ public function isSuccessful(): void $extractor = new OrderExtractor( new NullLogger(), $client, + (new QueryParameters()) + ->withGroup( + (new FilterGroup()) + ->withFilter(new ScalarFilter('updated_at', 'eq', '2022-09-05')), + ) + ->withGroup( + (new FilterGroup()) + ->withFilter(new ScalarFilter('status', 'eq', 'complete')), + ) ); $this->assertExtractorExtractsExactly( diff --git a/tests/ProductExtractorTest.php b/tests/ProductExtractorTest.php index 9700997..9fdcace 100644 --- a/tests/ProductExtractorTest.php +++ b/tests/ProductExtractorTest.php @@ -4,7 +4,10 @@ namespace Tests\Kiboko\Component\Flow\Magento2; +use Kiboko\Component\Flow\Magento2\Filter\ScalarFilter; +use Kiboko\Component\Flow\Magento2\FilterGroup; use Kiboko\Component\Flow\Magento2\ProductExtractor; +use Kiboko\Component\Flow\Magento2\QueryParameters; use Kiboko\Component\PHPUnitExtension\Assert\ExtractorAssertTrait; use Kiboko\Component\PHPUnitExtension\PipelineRunner; use Kiboko\Contract\Pipeline\PipelineRunnerInterface; @@ -40,6 +43,18 @@ public function testIsSuccessful(): void $extractor = new ProductExtractor( new NullLogger(), $client, + (new QueryParameters()) + ->withGroup( + (new FilterGroup()) + ->withFilter(new ScalarFilter('updated_at', 'eq', '2022-09-05')), + ) + ->withGroup( + (new FilterGroup()) + ->withFilter(new ScalarFilter('status', 'eq', 'complete')) + ->withFilter(new ScalarFilter('status', 'eq', 'canceled')) + ->withFilter(new ScalarFilter('status', 'eq', 'canceled')) + ->withFilter(new ScalarFilter('status', 'eq', 'in_preparation')) + ) ); $this->assertExtractorExtractsExactly( diff --git a/tests/QueryParametersTest.php b/tests/QueryParametersTest.php new file mode 100644 index 0000000..4e71e37 --- /dev/null +++ b/tests/QueryParametersTest.php @@ -0,0 +1,172 @@ +withGroup((new FilterGroup()) + ->withFilter( + new ArrayFilter('foo', 'in', [1, 2, 3, 4], 4), + ) + ); + + $this->assertCount(1, iterator_to_array($queryParameters->walkVariants(), false)); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + } + + #[Test] + public function shouldProduceSeveralVariants(): void + { + $queryParameters = (new QueryParameters()) + ->withGroup((new FilterGroup()) + ->withFilter( + new ArrayFilter('foo', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], 4), + ) + ); + $this->assertCount(3, iterator_to_array($queryParameters->walkVariants(), false)); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + } + + #[Test] + public function shouldProduceDemultipliedVariants(): void + { + $queryParameters = (new QueryParameters()) + ->withGroup((new FilterGroup()) + ->withFilter( + new ArrayFilter('foo', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], 4), + ) + ) + ->withGroup((new FilterGroup()) + ->withFilter( + new ArrayFilter('bar', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], 4), + ) + ); + $this->assertCount(12, iterator_to_array($queryParameters->walkVariants(), false)); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '9,10,11,12', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '9,10,11,12', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '9,10,11,12', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '13,14,15', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '13,14,15', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '13,14,15', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + } +}