Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Api/Resource/QueryBuilderProducts.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ class QueryBuilderProducts
#[Groups([self::GROUP_FRONT_READ])]
public array $offers = [];

/** @var array<int, array{rule: ?string, action: ?string, product_ids: int[], offers: array}> one entry per executed display action */
/** @var array<int, array{rule: ?string, action: ?string, description: ?string, product_ids: int[], offers: array}> one entry per executed display action */
#[Groups([self::GROUP_FRONT_READ])]
public array $actions = [];

Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Config/query_builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions Hook/FrontThemeHook.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'])),
]);
}
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <SQL>`: 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.
Expand All @@ -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/<theme>/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/<theme>/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.

Expand All @@ -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.
Expand All @@ -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).

Expand Down
3 changes: 2 additions & 1 deletion Service/HookResultPresenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public function __construct(
* hook: string,
* product_ids: int[],
* offers: array<int, array{rate: float, label: ?string, cumulative: bool}>,
* actions: array<int, array{rule: ?string, action: ?string, product_ids: int[], offers: array}>
* actions: array<int, array{rule: ?string, action: ?string, description: ?string, product_ids: int[], offers: array}>
* }
*/
public function present(string $hookCode, RuntimeContext $runtimeContext, bool $withOffers = true): array
Expand All @@ -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,
];
}
Expand Down
39 changes: 32 additions & 7 deletions Service/SqlBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -428,25 +428,34 @@ private function compileComparison(
case 'notNull':
return $leftHandSide . ' IS NOT NULL';
case '=':
case '!=':
case '<':
case '<=':
case '>':
case '>=':
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':
Expand All @@ -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(
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/<theme>/modules/QueryBuilder/product-list.html.twig
(variables: hook, rule_name, action_name, product_ids). #}
(variables: hook, rule_name, action_name, action_description, product_ids). #}
<section class="QueryBuilderProductList" data-hook="{{ hook }}" data-rule="{{ rule_name }}" data-action="{{ action_name }}">
<twig:Layouts:CrossSelling:Base :filters="{id: product_ids, itemsPerPage: product_ids|length}" title="{{ action_name }}" />
</section>
104 changes: 104 additions & 0 deletions tests/Integration/HookPayloadTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare(strict_types=1);

namespace QueryBuilder\Tests\Integration;

use PHPUnit\Framework\Attributes\Test;
use QueryBuilder\Action\ActionInterface;
use QueryBuilder\Action\DisplayProductsListAction;
use QueryBuilder\Enum\Context;
use QueryBuilder\Model\QueryBuilderAction;
use QueryBuilder\Model\QueryBuilderRule;
use QueryBuilder\Query\RuntimeContext;
use QueryBuilder\QueryBuilder;
use QueryBuilder\Service\HookResultPresenter;
use Thelia\Model\ModuleQuery;
use Thelia\Module\BaseModule;
use Thelia\Test\ActionIntegrationTestCase;

/**
* Each display action of the hook payload carries its description, a free label for the front.
*
* Prerequisites: a shop test database (bin/test-prepare) where the module is
* activated (module:activate QueryBuilder).
*/
final class HookPayloadTest extends ActionIntegrationTestCase
{
private const HOOK = 'home.top';

protected function setUp(): void
{
parent::setUp();

$module = ModuleQuery::create()->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();
}
}
Loading
Loading