From 57ffe19b954c90f2cc4902bab0a450d014e454ac Mon Sep 17 00:00:00 2001 From: Pierre Baguet Date: Thu, 8 Oct 2026 16:53:32 +0200 Subject: [PATCH 1/6] feat: feed the customer rating facet of the product listings --- Comment.php | 9 ++- Front/RatingFacetSource.php | 59 +++++++++++++++++ Repository/RatingMetaRepository.php | 22 +++++++ Repository/RatingMetaStorageInterface.php | 10 +++ Tests/Double/InMemoryRatingMetaStorage.php | 22 +++++++ Tests/Unit/Front/RatingFacetSourceTest.php | 76 ++++++++++++++++++++++ 6 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 Front/RatingFacetSource.php create mode 100644 Tests/Unit/Front/RatingFacetSourceTest.php diff --git a/Comment.php b/Comment.php index 1293b52..d4e92dc 100755 --- a/Comment.php +++ b/Comment.php @@ -28,6 +28,7 @@ use Symfony\Component\DependencyInjection\Loader\Configurator\ServicesConfigurator; use Thelia\Core\Install\Database; use Thelia\Core\Translation\Translator; +use Thelia\Domain\Catalog\Product\ProductRatingSourceInterface; use Thelia\Model\ConfigQuery; use Thelia\Model\Lang; use Thelia\Model\LangQuery; @@ -402,7 +403,13 @@ public static function configureServices(ServicesConfigurator $servicesConfigura $servicesConfigurator->load(self::getModuleCode().'\\', __DIR__) // Tests/ is part of the tree load() walks: a test double implementing an // autoconfigured interface would otherwise be registered as a real service. - ->exclude([__DIR__.'/I18n/*', __DIR__.'/Tests/*']) + ->exclude([ + __DIR__.'/I18n/*', + __DIR__.'/Tests/*', + // The rating facet arrived with Thelia 3.3: on an older core its interface + // does not exist, and the class implementing it could not be loaded. + ...(interface_exists(ProductRatingSourceInterface::class) ? [] : [__DIR__.'/Front/RatingFacetSource.php']), + ]) ->autowire(true) ->autoconfigure(true); diff --git a/Front/RatingFacetSource.php b/Front/RatingFacetSource.php new file mode 100644 index 0000000..eada72f --- /dev/null +++ b/Front/RatingFacetSource.php @@ -0,0 +1,59 @@ +ratingScale ?? RatingType::scaleFrom(ConfigQuery::read('comment_max_rating')); + + return $this->ratings->elementIdsRatedAtLeast( + self::PRODUCT_REF, + $minimumRating * $scale / self::FACET_SCALE, + $amongProductIds, + ); + } +} diff --git a/Repository/RatingMetaRepository.php b/Repository/RatingMetaRepository.php index 67ff989..380d50f 100644 --- a/Repository/RatingMetaRepository.php +++ b/Repository/RatingMetaRepository.php @@ -32,4 +32,26 @@ public function clear(string $ref, int $refId): void ->filterByElementId($refId) ->delete(); } + + /** + * Compares the stored average with the cast RatingMetaMemo::averageExpression() orders on, + * so that the rating facet and the best-rated order read the same figure. + */ + public function elementIdsRatedAtLeast(string $ref, float $minimumAverage, ?array $amongIds = null): array + { + $query = MetaDataQuery::create() + ->filterByMetaKey(Comment::META_KEY_RATING) + ->filterByElementKey($ref) + ->where('CAST(MetaData.Value AS DECIMAL(10, 2)) >= ?', (string) $minimumAverage, \PDO::PARAM_STR); + + if ($amongIds !== null) { + if ($amongIds === []) { + return []; + } + + $query->filterByElementId($amongIds, Criteria::IN); + } + + return array_map('intval', $query->select(['ElementId'])->find()->getData()); + } } diff --git a/Repository/RatingMetaStorageInterface.php b/Repository/RatingMetaStorageInterface.php index ff5c3e4..ce6dd81 100644 --- a/Repository/RatingMetaStorageInterface.php +++ b/Repository/RatingMetaStorageInterface.php @@ -28,4 +28,14 @@ public function store(string $ref, int $refId, float $average, int $count): void * keeps showing an average computed from comments that are gone. */ public function clear(string $ref, int $refId): void; + + /** + * The elements of a kind whose stored average reaches $minimumAverage, on the scale the + * module rates on. + * + * @param list|null $amongIds the elements to look at; null for every element of the kind + * + * @return list + */ + public function elementIdsRatedAtLeast(string $ref, float $minimumAverage, ?array $amongIds = null): array; } diff --git a/Tests/Double/InMemoryRatingMetaStorage.php b/Tests/Double/InMemoryRatingMetaStorage.php index 107e0a9..8fa602c 100644 --- a/Tests/Double/InMemoryRatingMetaStorage.php +++ b/Tests/Double/InMemoryRatingMetaStorage.php @@ -49,4 +49,26 @@ public function read(string $ref, int $refId): array { return $this->values[$ref.'|'.$refId] ?? ['average' => null, 'count' => 0]; } + + public function elementIdsRatedAtLeast(string $ref, float $minimumAverage, ?array $amongIds = null): array + { + $ids = []; + + foreach ($this->values as $key => $value) { + [$storedRef, $refId] = explode('|', $key); + $refId = (int) $refId; + + if ($storedRef !== $ref || $value['average'] === null || $value['average'] < $minimumAverage) { + continue; + } + + if ($amongIds !== null && !\in_array($refId, $amongIds, true)) { + continue; + } + + $ids[] = $refId; + } + + return $ids; + } } diff --git a/Tests/Unit/Front/RatingFacetSourceTest.php b/Tests/Unit/Front/RatingFacetSourceTest.php new file mode 100644 index 0000000..0463fe6 --- /dev/null +++ b/Tests/Unit/Front/RatingFacetSourceTest.php @@ -0,0 +1,76 @@ +ratings = new InMemoryRatingMetaStorage(); + } + + public function testAThresholdKeepsTheProductsWhoseStoredAverageReachesIt(): void + { + $this->rate([1 => 4.6, 2 => 4.0, 3 => 3.99, 4 => 2.0]); + + $source = new RatingFacetSource($this->ratings, ratingScale: 5); + + self::assertSame([1, 2], $source->productIdsRatedAtLeast(4.0)); + self::assertSame([1, 2, 3, 4], $source->productIdsRatedAtLeast(2.0)); + } + + public function testTheThresholdIsBroughtToTheScaleTheShopRatesOn(): void + { + // Out of ten, "4 stars & up" means an average of 8 or more. + $this->rate([1 => 9.0, 2 => 8.0, 3 => 7.5]); + + $source = new RatingFacetSource($this->ratings, ratingScale: 10); + + self::assertSame([1, 2], $source->productIdsRatedAtLeast(4.0)); + } + + public function testOnlyTheProductsAskedAboutAreAnswered(): void + { + $this->rate([1 => 5.0, 2 => 5.0, 3 => 5.0]); + + $source = new RatingFacetSource($this->ratings, ratingScale: 5); + + self::assertSame([1, 3], $source->productIdsRatedAtLeast(4.0, [1, 3, 9])); + self::assertSame([], $source->productIdsRatedAtLeast(4.0, [])); + } + + public function testTheRatingsOfOtherElementsAreNotProductRatings(): void + { + $this->ratings->store('content', 1, 5.0, 3); + + self::assertSame([], (new RatingFacetSource($this->ratings, ratingScale: 5))->productIdsRatedAtLeast(2.0)); + } + + /** + * @param array $averages product id => stored average + */ + private function rate(array $averages): void + { + foreach ($averages as $productId => $average) { + $this->ratings->store('product', $productId, $average, 1); + } + } +} From 19c123e51a4d97c8e1b60b68bd3204a54e32ad53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Sun, 18 Oct 2026 19:11:43 +0200 Subject: [PATCH 2/6] chore: bump to 3.3.0 --- Config/module.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config/module.xml b/Config/module.xml index bf79ac7..7d5af5c 100755 --- a/Config/module.xml +++ b/Config/module.xml @@ -13,7 +13,7 @@ en_US fr_FR - 3.2.1 + 3.3.0 Julien Chanséaume From 50a4a1b04d312f01569c700b20b3db096dfa1717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Wed, 21 Oct 2026 08:27:57 +0200 Subject: [PATCH 3/6] build: require Thelia 3.3, which brings the product rating source contract --- Config/module.xml | 2 +- composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Config/module.xml b/Config/module.xml index 7d5af5c..2cb91bd 100755 --- a/Config/module.xml +++ b/Config/module.xml @@ -21,6 +21,6 @@ classic - 3.2.0 + 3.3.0 prod diff --git a/composer.json b/composer.json index 64cd8eb..8ba5245 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "license": "LGPL-3.0+", "type": "thelia-module", "require": { - "thelia/core": "^3.2", + "thelia/core": "^3.3", "thelia/installer": "~1.1" }, "extra": { From 0416bc82e7f983357e20d14e7651944519564e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Wed, 21 Oct 2026 10:07:44 +0200 Subject: [PATCH 4/6] build: keep Thelia 3.2 as the minimum, the rating source is only wired on a core that has it --- Config/module.xml | 2 +- composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Config/module.xml b/Config/module.xml index 2cb91bd..7d5af5c 100755 --- a/Config/module.xml +++ b/Config/module.xml @@ -21,6 +21,6 @@ classic - 3.3.0 + 3.2.0 prod diff --git a/composer.json b/composer.json index 8ba5245..64cd8eb 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "license": "LGPL-3.0+", "type": "thelia-module", "require": { - "thelia/core": "^3.3", + "thelia/core": "^3.2", "thelia/installer": "~1.1" }, "extra": { From c53a31778305efc75352671d863c7d53280580ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Wed, 21 Oct 2026 10:07:50 +0200 Subject: [PATCH 5/6] ci: baseline the rating source symbol guarded for a newer core --- phpstan-baseline.neon | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 phpstan-baseline.neon diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..b1e0e88 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,10 @@ +# Product rating source contract of a core newer than the published one: Comment::configureServices() wires +# Front/RatingFacetSource.php only when interface_exists() finds it. Kept out of the report so that PHPStan runs +# against the published core; an entry the core catches up with is not reported. +parameters: + reportUnmatchedIgnoredErrors: false + ignoreErrors: + - + message: '#^Class Comment\\Front\\RatingFacetSource implements unknown interface Thelia\\Domain\\Catalog\\Product\\ProductRatingSourceInterface\.$#' + count: 1 + path: Front/RatingFacetSource.php From 2d4a4acdb44c9316948b2fa45fc83143ad2199a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Wed, 21 Oct 2026 10:09:16 +0200 Subject: [PATCH 6/6] ci: leave out of the analysis the class that implements a contract of a newer core --- phpstan-baseline.neon | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b1e0e88..a7dec84 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2,9 +2,7 @@ # Front/RatingFacetSource.php only when interface_exists() finds it. Kept out of the report so that PHPStan runs # against the published core; an entry the core catches up with is not reported. parameters: - reportUnmatchedIgnoredErrors: false - ignoreErrors: - - - message: '#^Class Comment\\Front\\RatingFacetSource implements unknown interface Thelia\\Domain\\Catalog\\Product\\ProductRatingSourceInterface\.$#' - count: 1 - path: Front/RatingFacetSource.php + # PHPStan cannot ignore a class that implements an unknown interface: the class is left out of the analysis instead (still scanned, so its name resolves). + excludePaths: + analyse: + - Front/RatingFacetSource.php