From f1519b8cc082379655717a2db1e1803ea5ad8d95 Mon Sep 17 00:00:00 2001 From: mdevaud Date: Fri, 6 Nov 2026 11:47:06 +0100 Subject: [PATCH 1/4] fix(hors-ticket): keep the rows without a value on negative operators --- Service/SqlBuilder.php | 39 ++++++++++++++++---- tests/Unit/Service/SqlBuilderTest.php | 53 +++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/Service/SqlBuilder.php b/Service/SqlBuilder.php index 4f4c23c..20452e8 100644 --- a/Service/SqlBuilder.php +++ b/Service/SqlBuilder.php @@ -428,7 +428,6 @@ private function compileComparison( case 'notNull': return $leftHandSide . ' IS NOT NULL'; case '=': - case '!=': case '<': case '<=': case '>': @@ -436,17 +435,27 @@ private function compileComparison( return sprintf( '%s %s %s', $leftHandSide, - $operator === '!=' ? '<>' : $operator, + $operator, $queryParts->bindValue($this->normalizeValue($value, $field)) ); + case '!=': + return $this->keepingNull($leftHandSide, sprintf( + '%s <> %s', + $leftHandSide, + $queryParts->bindValue($this->normalizeValue($value, $field)) + )); case 'contains': - case 'doesNotContain': return sprintf( - '%s %s %s', + '%s LIKE %s', $leftHandSide, - $operator === 'contains' ? 'LIKE' : 'NOT LIKE', $queryParts->bindValue('%' . $this->escapeLikeValue($value) . '%') ); + case 'doesNotContain': + return $this->keepingNull($leftHandSide, sprintf( + '%s NOT LIKE %s', + $leftHandSide, + $queryParts->bindValue('%' . $this->escapeLikeValue($value) . '%') + )); case 'beginsWith': return $leftHandSide . ' LIKE ' . $queryParts->bindValue($this->escapeLikeValue($value) . '%'); case 'endsWith': @@ -455,13 +464,15 @@ private function compileComparison( case 'notBetween': [$lower, $upper] = $this->normalizeRange($value, $field); - return sprintf( + $comparison = sprintf( '%s %s %s AND %s', $leftHandSide, $operator === 'between' ? 'BETWEEN' : 'NOT BETWEEN', $queryParts->bindValue($lower), $queryParts->bindValue($upper) ); + + return $operator === 'between' ? $comparison : $this->keepingNull($leftHandSide, $comparison); case 'in': case 'notIn': $placeholders = array_map( @@ -473,17 +484,31 @@ private function compileComparison( return $operator === 'in' ? '1=0' : '1=1'; } - return sprintf( + $comparison = sprintf( '%s %s (%s)', $leftHandSide, $operator === 'in' ? 'IN' : 'NOT IN', implode(', ', $placeholders) ); + + return $operator === 'in' ? $comparison : $this->keepingNull($leftHandSide, $comparison); default: throw new \InvalidArgumentException(sprintf('QueryBuilder: unsupported operator "%s".', $operator)); } } + /** + * A negative comparison keeps the rows without a value: in SQL, "NULL NOT IN (...)" and "NULL <> x" are + * unknown, so "everything but X" would silently drop the products (or the customers, through a correlated + * expression) whose value is missing. Holds for a product column or a correlated expression only: a column + * reached through an INNER join with no row for the product left the result set before the WHERE. The + * multivalued path compiles the positive form inside NOT EXISTS and never reaches here with a negative operator. + */ + private function keepingNull(string $leftHandSide, string $comparison): string + { + return sprintf('(%s IS NULL OR %s)', $leftHandSide, $comparison); + } + private function normalizeValue(mixed $value, FieldDefinition $field): int|float|string { return match ($field->type) { diff --git a/tests/Unit/Service/SqlBuilderTest.php b/tests/Unit/Service/SqlBuilderTest.php index 0564eab..d859f1b 100644 --- a/tests/Unit/Service/SqlBuilderTest.php +++ b/tests/Unit/Service/SqlBuilderTest.php @@ -4,6 +4,7 @@ namespace QueryBuilder\Tests\Unit\Service; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use QueryBuilder\Dictionary\FieldDefinition; @@ -79,6 +80,58 @@ public function aNegativeOperatorOnAMultivaluedJoinExcludesTheWholeProduct(): vo self::assertSame('Shoes', $compiled->parameters['qb_0']); } + /** @return iterable */ + public static function negativeOperators(): iterable + { + yield 'different' => ['!=', 3, '`product`.`brand_id` <> :qb_0']; + yield 'not in' => ['notIn', [3, 4], '`product`.`brand_id` NOT IN (:qb_0, :qb_1)']; + yield 'not between' => ['notBetween', [3, 4], '`product`.`brand_id` NOT BETWEEN :qb_0 AND :qb_1']; + } + + #[Test] + #[DataProvider('negativeOperators')] + public function aNegativeOperatorKeepsTheRowsWithoutAValue(string $operator, mixed $value, string $comparison): void + { + $compiled = $this->builder()->compile(self::rule('product_brand_id', $operator, $value), new RuntimeContext()); + + self::assertStringContainsString('(`product`.`brand_id` IS NULL OR ' . $comparison . ')', $compiled->sql); + } + + #[Test] + public function doesNotContainKeepsTheRowsWithoutAValue(): void + { + $compiled = $this->builder()->compile(self::rule('product_ref', 'doesNotContain', 'ABC'), new RuntimeContext()); + + self::assertStringContainsString('(`product`.`ref` IS NULL OR `product`.`ref` NOT LIKE :qb_0)', $compiled->sql); + } + + #[Test] + public function aPositiveOperatorDoesNotKeepTheRowsWithoutAValue(): void + { + foreach (['=' => 3, 'in' => [3], 'between' => [3, 4]] as $operator => $value) { + $compiled = $this->builder()->compile(self::rule('product_brand_id', $operator, $value), new RuntimeContext()); + + self::assertStringNotContainsString('IS NULL', $compiled->sql, $operator); + } + } + + #[Test] + public function aNegativeOperatorOnAMultivaluedJoinStaysANotExists(): void + { + $compiled = $this->builder()->compile(self::rule('product_category_title', '!=', 'Shoes'), new RuntimeContext()); + + self::assertStringContainsString('NOT EXISTS (SELECT 1 FROM `product_category`', $compiled->sql); + self::assertStringNotContainsString('IS NULL', $compiled->sql); + } + + #[Test] + public function aProductBoughtInTheLastThreeMonthsCountsTheCompletedOrdersOnly(): void + { + $compiled = $this->builder()->compile(self::rule('customer_purchased_last_3_months', '=', true), new RuntimeContext(customerId: 7)); + + self::assertStringContainsString("qb_ords.code NOT IN ('not_paid', 'canceled')", $compiled->sql); + } + #[Test] public function aListPlaceholderIsExpandedToOneParameterPerItem(): void { From 23c9d303ed46c70bf52c8308e44edb2e7355f962 Mon Sep 17 00:00:00 2001 From: mdevaud Date: Fri, 6 Nov 2026 11:47:06 +0100 Subject: [PATCH 2/4] fix(hors-ticket): count only completed orders in the purchased-product condition --- Config/query_builder.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Config/query_builder.yml b/Config/query_builder.yml index 87c4c39..b1a6618 100644 --- a/Config/query_builder.yml +++ b/Config/query_builder.yml @@ -175,9 +175,11 @@ fields: label: "Is the product of the current page" expression: "product.id = :product_id" type: boolean + # Completed orders only (status neither not_paid nor canceled): the order being created, still + # unpaid, must not cancel a discount or hide a product the customer has not bought yet customer_purchased_last_3_months: label: "Product bought by the customer in the last 3 months" - expression: "EXISTS (SELECT 1 FROM `order` qb_ord JOIN `order_product` qb_ordp ON qb_ordp.order_id = qb_ord.id WHERE qb_ord.customer_id = :customer_id AND qb_ordp.product_ref = product.ref AND qb_ord.created_at >= DATE_SUB(NOW(), INTERVAL 3 MONTH))" + expression: "EXISTS (SELECT 1 FROM `order` qb_ord JOIN `order_product` qb_ordp ON qb_ordp.order_id = qb_ord.id JOIN `order_status` qb_ords ON qb_ords.id = qb_ord.status_id WHERE qb_ord.customer_id = :customer_id AND qb_ordp.product_ref = product.ref AND qb_ords.code NOT IN ('not_paid', 'canceled') AND qb_ord.created_at >= DATE_SUB(NOW(), INTERVAL 3 MONTH))" type: boolean # Cart-level conditions: constant for the whole visit, so they compare the same # for every product. The CAST keeps the comparison numeric whatever the binding. From 9e1e3b890550d9aa0d651197002b81aa79e24780 Mon Sep 17 00:00:00 2001 From: mdevaud Date: Fri, 6 Nov 2026 11:47:06 +0100 Subject: [PATCH 3/4] feat(hors-ticket): expose the action description in the hook payload --- Api/Resource/QueryBuilderProducts.php | 2 +- Hook/FrontThemeHook.php | 1 + Service/HookResultPresenter.php | 3 +- .../QueryBuilder/product-list.html.twig | 2 +- tests/Integration/HookPayloadTest.php | 104 ++++++++++++++++++ 5 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 tests/Integration/HookPayloadTest.php diff --git a/Api/Resource/QueryBuilderProducts.php b/Api/Resource/QueryBuilderProducts.php index b302411..5d1e193 100644 --- a/Api/Resource/QueryBuilderProducts.php +++ b/Api/Resource/QueryBuilderProducts.php @@ -59,7 +59,7 @@ class QueryBuilderProducts #[Groups([self::GROUP_FRONT_READ])] public array $offers = []; - /** @var array one entry per executed display action */ + /** @var array one entry per executed display action */ #[Groups([self::GROUP_FRONT_READ])] public array $actions = []; diff --git a/Hook/FrontThemeHook.php b/Hook/FrontThemeHook.php index f0f1292..118324b 100644 --- a/Hook/FrontThemeHook.php +++ b/Hook/FrontThemeHook.php @@ -57,6 +57,7 @@ public function render(string $hookName, array $parameters): string 'hook' => $hookName, 'rule_name' => (string) $action['rule'], 'action_name' => (string) $action['action'], + 'action_description' => $action['description'], 'product_ids' => array_values(array_map('intval', $action['product_ids'])), ]); } diff --git a/Service/HookResultPresenter.php b/Service/HookResultPresenter.php index f7e39f1..8478283 100644 --- a/Service/HookResultPresenter.php +++ b/Service/HookResultPresenter.php @@ -28,7 +28,7 @@ public function __construct( * hook: string, * product_ids: int[], * offers: array, - * actions: array + * actions: array * } */ public function present(string $hookCode, RuntimeContext $runtimeContext, bool $withOffers = true): array @@ -47,6 +47,7 @@ public function present(string $hookCode, RuntimeContext $runtimeContext, bool $ $actions[] = [ 'rule' => $executedAction->rule->getName(), 'action' => $executedAction->action->getName(), + 'description' => $executedAction->action->getDescription() ?: null, 'product_ids' => $executedAction->result->productIds, ]; } diff --git a/templates/frontOffice/default/QueryBuilder/product-list.html.twig b/templates/frontOffice/default/QueryBuilder/product-list.html.twig index caedb3a..1e11e64 100644 --- a/templates/frontOffice/default/QueryBuilder/product-list.html.twig +++ b/templates/frontOffice/default/QueryBuilder/product-list.html.twig @@ -2,7 +2,7 @@ Relies on the Flexy cross-selling strip, which fetches the products and keeps the picked order. A theme overrides this file by shipping its own copy at templates/frontOffice//modules/QueryBuilder/product-list.html.twig - (variables: hook, rule_name, action_name, product_ids). #} + (variables: hook, rule_name, action_name, action_description, product_ids). #}
diff --git a/tests/Integration/HookPayloadTest.php b/tests/Integration/HookPayloadTest.php new file mode 100644 index 0000000..2072067 --- /dev/null +++ b/tests/Integration/HookPayloadTest.php @@ -0,0 +1,104 @@ +findOneByCode(QueryBuilder::getModuleCode()); + + if ($module === null || (int) $module->getActivate() !== BaseModule::IS_ACTIVATED) { + self::markTestSkipped('The QueryBuilder module is not activated in the test database.'); + } + } + + #[Test] + public function aDisplayActionExposesItsDescription(): void + { + $productReference = $this->displayRule('Our favourites of the week'); + + $action = $this->presentedAction($productReference); + + self::assertSame('Our favourites of the week', $action['description']); + } + + #[Test] + public function anEmptyDescriptionIsExposedAsNull(): void + { + $productReference = $this->displayRule(''); + + $action = $this->presentedAction($productReference); + + self::assertArrayHasKey('description', $action); + self::assertNull($action['description']); + } + + /** @return array{rule: ?string, action: ?string, description: ?string, product_ids: int[], offers: array} */ + private function presentedAction(string $productReference): array + { + $result = $this->getService(HookResultPresenter::class)->present(self::HOOK, new RuntimeContext(), withOffers: false); + + $actions = array_values(array_filter( + $result['actions'], + static fn (array $action): bool => $action['action'] === 'Display action ' . $productReference, + )); + self::assertCount(1, $actions); + self::assertNotSame([], $actions[0]['product_ids']); + + return $actions[0]; + } + + private function displayRule(string $description): string + { + $product = $this->factory->product($this->factory->category(), $this->factory->taxRule(), $this->factory->currency()); + + $rule = (new QueryBuilderRule()) + ->setName('Display rule under test') + ->setContext(Context::GLOBAL_SCOPE->value) + ->setHooks(json_encode([self::HOOK], \JSON_THROW_ON_ERROR)) + ->setActivate(1); + $rule->save(); + + (new QueryBuilderAction()) + ->setRuleId($rule->getId()) + ->setName('Display action ' . $product->getRef()) + ->setDescription($description) + ->setCode(DisplayProductsListAction::getCode()) + ->setType(ActionInterface::TYPE_DISPLAY) + ->setConditionTree(json_encode( + ['combinator' => 'and', 'rules' => [['field' => 'product_ref', 'operator' => '=', 'value' => $product->getRef()]]], + \JSON_THROW_ON_ERROR + )) + ->setParameters(json_encode(['limit' => 1], \JSON_THROW_ON_ERROR)) + ->setActivate(1) + ->save(); + + return (string) $product->getRef(); + } +} From 452b5898294e3b203e3f186a2e22c13f470ada14 Mon Sep 17 00:00:00 2001 From: mdevaud Date: Fri, 6 Nov 2026 11:47:06 +0100 Subject: [PATCH 4/4] docs(hors-ticket): document the fixes carried over from 1.x and test them on the shop database --- CHANGELOG.md | 11 ++ README.md | 8 +- tests/Integration/SqlBuilderExecutionTest.php | 118 ++++++++++++++++++ 3 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 tests/Integration/SqlBuilderExecutionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index edba499..627aff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this module are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +Fixes of the 1.x line (1.4.0 to 1.6.1) carried over to 2.x. + +### Added +- Each display action of the hook payload carries its `description` (`null` when empty), a free label for the front: in the `actions` of the API resource and of `query_builder_products()`, and as `action_description` in the `product-list.html.twig` template. + +### Fixed +- The negative operators (`!=`, `notIn`, `doesNotContain`, `notBetween`) on a single-valued field keep the rows without a value: they compile to `(field IS NULL OR ...)`. "Brand different from X" no longer drops the products without a brand, and "days since the last order different from 10" now matches a customer without any order. +- "Product bought by the customer in the last 3 months" counts the completed orders only (status neither `not_paid` nor `canceled`): a canceled order no longer counts as a purchase, and the order being created no longer cancels a discount. + ## [2.1.1] 2026-10-05 ### Fixed diff --git a/README.md b/README.md index 43d42fe..2c1eea1 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ Two kinds of fields: - `field: table.column`: the joins from `product` to the table are added automatically from the `joins` graph. A join flagged `multivalued` (one product, several rows) compiles its comparisons to `EXISTS` or `NOT EXISTS` subqueries, so that `notIn` or `!=` exclude the whole product instead of matching another joined row. - `expression: `: a self-contained SQL expression, subqueries allowed, which may reference runtime placeholders. +The negative operators (`!=`, `notIn`, `doesNotContain`, `notBetween`) on a single-valued field keep the rows without a value: they compile to `(field IS NULL OR field NOT IN (...))`, because in SQL `NULL NOT IN (...)` is unknown and would silently drop the products (or the customers, through a correlated expression) whose value is missing. Add a `notNull` condition to exclude the missing values explicitly. The guarantee holds for a column of `product` or a correlated `expression`: a field reached through an INNER join whose row does not exist for every product (the brand title of a product without a brand) has left the result set before the `WHERE`. Multivalued fields compile their negations to `NOT EXISTS` and are not concerned. + Runtime placeholders: `:customer_id`, `:cart_id`, `:cart_product_ids`, `:product_id` (bound to 0 outside a product page, so "is the current product = false" lets everything through), `:order_id`, `:category_id`, `:brand_id`, `:locale`, `:cart_total` (products total of the cart, taxes included) and `:delivery_country_id`, plus the ones provided by the project through `RuntimeParameterProviderInterface`. A placeholder without a value in the current visit (an anonymous visitor on a rule that needs `:customer_id`, for instance) makes the rule skip with a logged warning, and the page renders anyway. An expression may contain the `:value` token: the value entered in the editor is then expanded inside the expression, one placeholder per item, and the field must restrict its `operators` to `in` and `notIn`, which only set the polarity, or to `=` and `!=`, compiled as a one-item `in` and `notIn`. The "list of values" pick of a field with a `values_query` is stored as `=`, so such a field needs no more than `[in, notIn]` for the pick to compile. @@ -92,7 +94,7 @@ Hook points shipped, per context (the `theme_hook()` codes of Flexy): `home.top` The module implements `Thelia\Core\Hook\Theme\ThemeHookInterface` for every hook code of the dictionary: a `theme_hook('product.top', {product: product})` call in the theme runs the rules bound to `product.top` for the current visit and renders one product list per display action. The theme passes the resource it displays (product, category, brand); its id feeds the runtime context. -The product list is rendered with `templates/frontOffice/default/QueryBuilder/product-list.html.twig`, which relies on the Flexy component `Layouts:CrossSelling:Base` (products fetched by the theme, picked order kept). A theme overrides it by shipping `templates/frontOffice//modules/QueryBuilder/product-list.html.twig`; the variables are `hook`, `rule_name`, `action_name` and `product_ids`. +The product list is rendered with `templates/frontOffice/default/QueryBuilder/product-list.html.twig`, which relies on the Flexy component `Layouts:CrossSelling:Base` (products fetched by the theme, picked order kept). A theme overrides it by shipping `templates/frontOffice//modules/QueryBuilder/product-list.html.twig`; the variables are `hook`, `rule_name`, `action_name`, `action_description` (the description of the action, `null` when empty, a free label for the front) and `product_ids`. The cart discount granted by a rule is shown on the checkout pages by `cart-discount.html.twig` (same override path), once per request, on the `checkout.top` and `cart.bottom` points. @@ -116,7 +118,7 @@ Optional parameters: `product_id`, `order_id`, `category_id`, `brand_id`. On err | `hookCode` | The hook code | | `productIds` | Unique product ids selected by the display actions, in selection order | | `offers` | Discount offers keyed by product id (`rate`, `label`, `cumulative`) | -| `actions` | One entry per executed display action (`rule`, `action`, `product_ids`, `offers`) | +| `actions` | One entry per executed display action (`rule`, `action`, `description`, `product_ids`, `offers`) | | `products` | The selected products as front `Product` resources, in the same order | Query parameters: `product_id`, `category_id`, `brand_id`, `order_id`. A hook code the dictionary does not declare answers 404. Several rules bound to the same hook share one slot, capped by the highest action limit. @@ -139,7 +141,7 @@ The discount is served through the catalog price contract of the core, introduce A non-stackable rule (default) replaces the promotion the visitor would otherwise see, catalog promotion or catalog price rule of the core, only when it is better; a stackable rule applies on top of it. The core asks nothing of the price resolvers while no rule runs: the module also decorates `Thelia\Domain\Pricing\PricingActivityChecker` to declare a running discount rule as a public, visitor-dependent pricing, which makes the core ask and takes the shared API cache of the front product collections out of the way while one runs. The price orders and `min_price` / `max_price` filters of the product loop read the stored prices of the core rules only: they do not see the module discounts, as they do not see the core prices reserved to named customers. -The tree of a discount action is evaluated on every surface, cart events included. Conditions that describe a recommendation ("product in the cart = false") cancel the discount as soon as the product enters the cart: keep the tree of a discount on stable criteria (brand, category, visibility). +The tree of a discount action is evaluated on every surface, cart events included. Conditions that describe a recommendation ("product in the cart = false") cancel the discount as soon as the product enters the cart: keep the tree of a discount on stable criteria (brand, category, visibility). "Product bought by the customer in the last 3 months" counts the completed orders only (status neither not paid nor canceled): the order being created, still unpaid, does not cancel the discount. The rules are evaluated without a current product on every surface (the core asks for a batch of sale elements, not for a page): a discount rule whose conditions name the context product (`PRODUCT` context) does not price the catalog. The `QueryBuilderProductOffer` addon still exposes the rate and label on the front product resource for a decoupled front. The label is displayed in the cart discount fragment of the checkout pages (see Front). diff --git a/tests/Integration/SqlBuilderExecutionTest.php b/tests/Integration/SqlBuilderExecutionTest.php new file mode 100644 index 0000000..d4de6ae --- /dev/null +++ b/tests/Integration/SqlBuilderExecutionTest.php @@ -0,0 +1,118 @@ +findOneByCode(QueryBuilder::getModuleCode()); + + if ($module === null || (int) $module->getActivate() !== BaseModule::IS_ACTIVATED) { + self::markTestSkipped('The QueryBuilder module is not activated in the test database.'); + } + + $this->sqlBuilder = $this->getService(SqlBuilder::class); + } + + #[Test] + public function aProductWithoutBrandIsKeptByADifferentBrandCondition(): void + { + $brand = $this->factory->brand(); + $branded = $this->product(); + $branded->setBrandId($brand->getId())->save(); + $brandless = $this->product(); + + $productIds = $this->sqlBuilder->getProductIds(self::tree( + ['field' => 'product_ref', 'operator' => 'in', 'value' => [$branded->getRef(), $brandless->getRef()]], + ['field' => 'product_brand_id', 'operator' => '!=', 'value' => $brand->getId()], + ), new RuntimeContext()); + + self::assertSame([$brandless->getId()], array_map('intval', $productIds)); + } + + #[Test] + public function aCustomerWithoutOrderMatchesADifferentNumberOfDaysSinceTheLastOrder(): void + { + $product = $this->product(); + + $productIds = $this->sqlBuilder->getProductIds(self::tree( + ['field' => 'product_ref', 'operator' => '=', 'value' => $product->getRef()], + ['field' => 'customer_last_order_days', 'operator' => '!=', 'value' => 10], + ), new RuntimeContext(customerId: $this->customer()->getId())); + + self::assertSame([$product->getId()], array_map('intval', $productIds)); + } + + #[Test] + public function aCanceledOrUnpaidOrderIsNotAPurchaseOfTheLastThreeMonths(): void + { + $product = $this->product(); + $customer = $this->customer(); + + foreach ([OrderStatus::CODE_CANCELED, OrderStatus::CODE_NOT_PAID] as $statusCode) { + $order = $this->factory->order($customer, ['statusCode' => $statusCode]); + $this->factory->orderProduct($order, ['productRef' => $product->getRef()]); + } + + self::assertSame([$product->getId()], $this->notBoughtRecently($product, $customer)); + + $paidOrder = $this->factory->order($customer, ['statusCode' => OrderStatus::CODE_PAID]); + $this->factory->orderProduct($paidOrder, ['productRef' => $product->getRef()]); + + self::assertSame([], $this->notBoughtRecently($product, $customer)); + } + + /** @return int[] */ + private function notBoughtRecently(Product $product, Customer $customer): array + { + return array_map('intval', $this->sqlBuilder->getProductIds(self::tree( + ['field' => 'product_ref', 'operator' => '=', 'value' => $product->getRef()], + ['field' => 'customer_purchased_last_3_months', 'operator' => '=', 'value' => false], + ), new RuntimeContext(customerId: $customer->getId()))); + } + + private function product(): Product + { + return $this->factory->product($this->factory->category(), $this->factory->taxRule(), $this->factory->currency()); + } + + private function customer(): Customer + { + $title = CustomerTitleQuery::create()->findOne(); + self::assertNotNull($title); + + return $this->factory->customer($title); + } + + /** @param array{field: string, operator: string, value: mixed} ...$rules */ + private static function tree(array ...$rules): array + { + return ['combinator' => 'and', 'rules' => $rules]; + } +}