From 7e1095b06ce20586aac03f5dbb6aef87a9125959 Mon Sep 17 00:00:00 2001 From: egidijus Date: Fri, 31 Jul 2026 09:47:34 +0300 Subject: [PATCH 1/3] add categoriesFlat field with split category levels (BRD-1150) Synonym and stemmed matching never reach a leaf category: synonyms need an exact value match against the stored path string, and the engine's analyzer only processes the first ~20 characters, so deep levels of 'Store > Summer > Men > T-Shirts' are invisible to search. Each adapter now also emits categoriesFlat_{locale}: the unique level values of every category path, split on ' > '. The field is search-only. categories and categoryDefault are untouched, so filter aggregations and the storefront category tree stay exactly as they are. Appending levels into categories itself was tested and rejected: level values dominate the top-50 facet buckets and pollute the filter sidebar (see BRD-1150). PrestaShopAdapterV2 covers PrestaShop and WooCommerce; MagentoAdapterV2 splits its hierarchical paths; ShopifyAdapter splits the taxonomy fullName only because tags are already flat single values. The field only becomes searchable once brad-app adds it to the mapping and search presets and applications reindex. --- src/Adapters/AdapterUtils.php | 34 ++++++++++++++ src/Adapters/MagentoAdapterV2.php | 5 ++ src/Adapters/PrestaShopAdapterV2.php | 27 +++++++++++ src/Adapters/ShopifyAdapter.php | 8 ++++ tests/Adapters/AdapterUtilsTest.php | 44 ++++++++++++++++++ tests/Adapters/MagentoAdapterV2Test.php | 22 +++++++++ tests/Adapters/PrestaShopAdapterV2Test.php | 53 ++++++++++++++++++++++ tests/Adapters/ShopifyAdapterTest.php | 41 +++++++++++++++++ 8 files changed, 234 insertions(+) diff --git a/src/Adapters/AdapterUtils.php b/src/Adapters/AdapterUtils.php index 7ba03b4..f293d5e 100644 --- a/src/Adapters/AdapterUtils.php +++ b/src/Adapters/AdapterUtils.php @@ -161,4 +161,38 @@ public static function buildError( 'exception' => $exception, ]; } + + /** + * Split hierarchical category paths into their unique level values, + * preserving first-seen order. + * + * ["Store > Summer > Men", "Store > Spring"] -> ["Store", "Summer", "Men", "Spring"] + * + * Feeds the search-only categoriesFlat field: each level is analyzed from + * position 0, so synonym and stemmed matching reach every level of a long + * path. Only ' > ' with surrounding spaces delimits levels — a bare '>' + * inside a category name is not split. + * + * @param array $paths + * @return array + */ + public static function splitCategoryLevels(array $paths): array + { + $levels = []; + + foreach ($paths as $path) { + if (!is_string($path) || $path === '') { + continue; + } + + foreach (explode(' > ', $path) as $level) { + $level = trim($level); + if ($level !== '' && !in_array($level, $levels, true)) { + $levels[] = $level; + } + } + } + + return $levels; + } } diff --git a/src/Adapters/MagentoAdapterV2.php b/src/Adapters/MagentoAdapterV2.php index cc7d1e4..f2d76ac 100644 --- a/src/Adapters/MagentoAdapterV2.php +++ b/src/Adapters/MagentoAdapterV2.php @@ -148,6 +148,11 @@ public function transformProduct(array $product): Product $categories = $this->buildHierarchicalCategories($product); if (!empty($categories)) { $additionalFields["categories_{$locale}"] = $categories; + + $flatCategories = AdapterUtils::splitCategoryLevels($categories); + if (!empty($flatCategories)) { + $additionalFields["categoriesFlat_{$locale}"] = $flatCategories; + } } $categoryDefault = $this->extractDefaultCategory($product); diff --git a/src/Adapters/PrestaShopAdapterV2.php b/src/Adapters/PrestaShopAdapterV2.php index 27d0034..cfca970 100644 --- a/src/Adapters/PrestaShopAdapterV2.php +++ b/src/Adapters/PrestaShopAdapterV2.php @@ -135,6 +135,7 @@ public function transformProduct(array $product): Product // Handle categories $this->extractCategories($additionalFields, $product); + $this->addFlatCategories($additionalFields); $this->extractCategoryDefault($additionalFields, $product); // Handle product URLs @@ -545,6 +546,32 @@ private function extractCategoryDefault(array &$result, array $product): void $this->extractCategory($product[$categoryFieldName], $categoryFieldName, $result); } + /** + * Add a categoriesFlat_{locale} field per collected categories_{locale} field. + * + * @param array $result + */ + private function addFlatCategories(array &$result): void + { + $flatFields = []; + + foreach ($result as $key => $paths) { + if (!str_starts_with($key, 'categories_') || !is_array($paths)) { + continue; + } + + $locale = substr($key, strlen('categories_')); + $levels = AdapterUtils::splitCategoryLevels($paths); + if (!empty($levels)) { + $flatFields["categoriesFlat_{$locale}"] = $levels; + } + } + + foreach ($flatFields as $key => $levels) { + $result[$key] = $levels; + } + } + /** * Transform features to flat locale-specific fields. * diff --git a/src/Adapters/ShopifyAdapter.php b/src/Adapters/ShopifyAdapter.php index d2ce7e3..19c56b1 100644 --- a/src/Adapters/ShopifyAdapter.php +++ b/src/Adapters/ShopifyAdapter.php @@ -186,6 +186,11 @@ private function buildLocaleFields( if (!empty($localeCategories)) { $fields["categories_{$locale}"] = $localeCategories; } + // Taxonomy only: tags are already flat single values with nothing to split. + $flatCategories = AdapterUtils::splitCategoryLevels([$categoryDefault]); + if (!empty($flatCategories)) { + $fields["categoriesFlat_{$locale}"] = $flatCategories; + } $localeProductType = $this->translated($localeTranslations, 'product_type') ?? ($locale === $primaryLocale ? $nativeProductType : ''); @@ -235,6 +240,8 @@ private function buildPlainFields( string $primaryLocale, ): array { $collections = $this->resolveCollectionTitles($productCollections, $primaryLocale, $primaryLocale); + // Taxonomy only: tags are already flat single values with nothing to split. + $flatCategories = AdapterUtils::splitCategoryLevels([$categoryDefault]); return array_filter([ 'name' => $title, @@ -242,6 +249,7 @@ private function buildPlainFields( 'brand' => $brand !== '' ? $brand : null, 'categoryDefault' => $categoryDefault, 'categories' => $this->buildCategories($categoryDefault, $this->extractTags($product)), + 'categoriesFlat' => !empty($flatCategories) ? $flatCategories : null, 'productType' => $nativeProductType !== '' ? $nativeProductType : null, 'productUrl' => $productUrl !== '' ? $productUrl : null, 'collections' => !empty($collections) ? $collections : null, diff --git a/tests/Adapters/AdapterUtilsTest.php b/tests/Adapters/AdapterUtilsTest.php index 92ee2ad..7b7eba9 100644 --- a/tests/Adapters/AdapterUtilsTest.php +++ b/tests/Adapters/AdapterUtilsTest.php @@ -198,4 +198,48 @@ public function testBuildErrorWithNullException(): void $this->assertSame('invalid_structure', $result['type']); $this->assertNull($result['exception']); } + + public function testSplitCategoryLevelsDedupesAcrossPaths(): void + { + $result = AdapterUtils::splitCategoryLevels([ + 'Store > Summer > Men > T-Shirts', + 'Store > Spring > Men > Shirtlings', + ]); + + $this->assertSame( + ['Store', 'Summer', 'Men', 'T-Shirts', 'Spring', 'Shirtlings'], + $result + ); + } + + public function testSplitCategoryLevelsSingleLevelPath(): void + { + $this->assertSame(['Women'], AdapterUtils::splitCategoryLevels(['Women'])); + } + + public function testSplitCategoryLevelsEmptyInput(): void + { + $this->assertSame([], AdapterUtils::splitCategoryLevels([])); + } + + public function testSplitCategoryLevelsRequiresSpacedDelimiter(): void + { + $result = AdapterUtils::splitCategoryLevels(['A>B > C']); + + $this->assertSame(['A>B', 'C'], $result); + } + + public function testSplitCategoryLevelsTrimsAndDropsEmptySegments(): void + { + $result = AdapterUtils::splitCategoryLevels(['Men > > Shoes ', '']); + + $this->assertSame(['Men', 'Shoes'], $result); + } + + public function testSplitCategoryLevelsSkipsNonStringValues(): void + { + $result = AdapterUtils::splitCategoryLevels([null, 42, 'Men > Shoes']); + + $this->assertSame(['Men', 'Shoes'], $result); + } } diff --git a/tests/Adapters/MagentoAdapterV2Test.php b/tests/Adapters/MagentoAdapterV2Test.php index 05dd1de..59b52f9 100644 --- a/tests/Adapters/MagentoAdapterV2Test.php +++ b/tests/Adapters/MagentoAdapterV2Test.php @@ -916,6 +916,28 @@ public function testFullProductTransformation(): void // --- Helpers --- + public function testCategoriesFlatContainsUniqueLevels(): void + { + $product = $this->adapter->transformProduct($this->buildMinimalProduct([ + 'categories' => [ + ['id' => '2', 'name' => 'Tools', 'path' => '1/2', 'level' => 1], + ['id' => '5', 'name' => 'Drills', 'path' => '1/2/5', 'level' => 2], + ], + ])); + $serialized = $product->jsonSerialize(); + + $this->assertSame(['Tools', 'Tools > Drills'], $serialized['categories_lt-LT']); + $this->assertSame(['Tools', 'Drills'], $serialized['categoriesFlat_lt-LT']); + } + + public function testNoCategoriesFlatWithoutCategories(): void + { + $product = $this->adapter->transformProduct($this->buildMinimalProduct()); + $serialized = $product->jsonSerialize(); + + $this->assertArrayNotHasKey('categoriesFlat_lt-LT', $serialized); + } + /** * @param array $overrides * @return array diff --git a/tests/Adapters/PrestaShopAdapterV2Test.php b/tests/Adapters/PrestaShopAdapterV2Test.php index 60fad47..d7aef44 100644 --- a/tests/Adapters/PrestaShopAdapterV2Test.php +++ b/tests/Adapters/PrestaShopAdapterV2Test.php @@ -140,6 +140,59 @@ public function testTransformSimpleProduct(): void $this->assertEquals('Springa', $product->additionalFields['brand_en-US']); $this->assertEquals('http://prestashop/sneakers/1807-sneakers.html', $product->additionalFields['productUrl_en-US']); $this->assertEquals(['Men', 'Men > Shoes'], $product->additionalFields['categories_en-US']); + $this->assertEquals(['Men', 'Shoes'], $product->additionalFields['categoriesFlat_en-US']); + } + + public function testTransformAddsFlatCategoryLevelsPerLocale(): void + { + $product = $this->getMinimalProductData('1807', 'SKU-123'); + $product['categories'] = [ + 'lvl2' => [ + [ + 'remoteId' => '148', + 'localizedValues' => [ + 'path' => [ + 'en-US' => 'Store > Summer > Men > T-Shirts', + 'lt-LT' => 'Parduotuvė > Vasara > Vyrai > Marškinėliai', + ], + ], + ], + ], + 'lvl3' => [ + [ + 'remoteId' => '163', + 'localizedValues' => [ + 'path' => [ + 'en-US' => 'Store > Spring > Men > Shirtlings', + ], + ], + ], + ], + ]; + + $result = $this->adapter->transform(['products' => [$product]]); + $fields = $result['products'][0]->additionalFields; + + $this->assertSame( + ['Store > Summer > Men > T-Shirts', 'Store > Spring > Men > Shirtlings'], + $fields['categories_en-US'] + ); + $this->assertSame( + ['Store', 'Summer', 'Men', 'T-Shirts', 'Spring', 'Shirtlings'], + $fields['categoriesFlat_en-US'] + ); + $this->assertSame( + ['Parduotuvė', 'Vasara', 'Vyrai', 'Marškinėliai'], + $fields['categoriesFlat_lt-LT'] + ); + } + + public function testNoFlatCategoriesWithoutCategories(): void + { + $result = $this->adapter->transform($this->getMinimalValidProduct()); + $fields = $result['products'][0]->additionalFields; + + $this->assertArrayNotHasKey('categoriesFlat_en-US', $fields); } public function testTransformProductWithMultipleLocales(): void diff --git a/tests/Adapters/ShopifyAdapterTest.php b/tests/Adapters/ShopifyAdapterTest.php index e527260..bdc95dc 100644 --- a/tests/Adapters/ShopifyAdapterTest.php +++ b/tests/Adapters/ShopifyAdapterTest.php @@ -893,6 +893,47 @@ public function testCategoriesArrayContainsTaxonomyAndTagsOnly(): void $this->assertArrayNotHasKey('productType', $c); } + public function testCategoriesFlatContainsTaxonomyLevelsOnly(): void + { + $taxProduct = $this->makeProduct('gid://shopify/Product/1', 'Tee', 'Desc', 'BrandX', 'Shoes'); + $taxProduct['node']['category'] = [ + 'id' => 'gid://shopify/TaxonomyCategory/aa-1-13-8', + 'name' => 'Shirts', + 'fullName' => 'Apparel & Accessories > Clothing > Shirts', + ]; + $taxProduct['node']['tags'] = ['summer', 'cotton']; + + // No taxonomy: tags alone are already flat single values — nothing to split. + $noTaxProduct = $this->makeProduct('gid://shopify/Product/2', 'Ski', 'Desc', 'BrandX', 'Winter'); + $noTaxProduct['node']['category'] = null; + $noTaxProduct['node']['tags'] = ['cold']; + + $result = $this->adapter->transform($this->makeShopifyResponse([$taxProduct, $noTaxProduct])); + [$a, $b] = $result['products']; + + $this->assertSame(['Apparel & Accessories', 'Clothing', 'Shirts'], $a['categoriesFlat']); + $this->assertNotContains('summer', $a['categoriesFlat']); + $this->assertArrayNotHasKey('categoriesFlat', $b); + } + + public function testCategoriesFlatIsLocaleSuffixedInLocaleMode(): void + { + $product = $this->makeProduct('gid://shopify/Product/1', 'Tee', 'Desc', 'BrandX', 'Shoes'); + $product['node']['category'] = [ + 'id' => 'gid://shopify/TaxonomyCategory/aa-1-13-8', + 'name' => 'Shirts', + 'fullName' => 'Apparel & Accessories > Clothing > Shirts', + ]; + $product['node']['tags'] = ['summer']; + + $result = $this->adapter->transform($this->makeShopifyResponse([$product], 'en'), ['en', 'lt']); + $p = $result['products'][0]; + + $this->assertSame(['Apparel & Accessories', 'Clothing', 'Shirts'], $p['categoriesFlat_en']); + $this->assertSame(['Apparel & Accessories', 'Clothing', 'Shirts'], $p['categoriesFlat_lt']); + $this->assertNotContains('summer', $p['categoriesFlat_en']); + } + public function testMalformedCategoryFieldEmitsProductTypeAsOwnField(): void { $cases = [ From 36ca453f50b4bc2c8e42ac34ba36a03b704048d5 Mon Sep 17 00:00:00 2001 From: GantasG Date: Tue, 1 Sep 2026 22:11:04 +0300 Subject: [PATCH 2/3] tidy categoriesFlat adapter tests (BRD-1150) Move the new Magento tests above the helpers marker and drop a narration comment from the Shopify test. --- tests/Adapters/MagentoAdapterV2Test.php | 4 ++-- tests/Adapters/ShopifyAdapterTest.php | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Adapters/MagentoAdapterV2Test.php b/tests/Adapters/MagentoAdapterV2Test.php index 59b52f9..2e74d35 100644 --- a/tests/Adapters/MagentoAdapterV2Test.php +++ b/tests/Adapters/MagentoAdapterV2Test.php @@ -914,8 +914,6 @@ public function testFullProductTransformation(): void $this->assertArrayNotHasKey('description', $serialized); } - // --- Helpers --- - public function testCategoriesFlatContainsUniqueLevels(): void { $product = $this->adapter->transformProduct($this->buildMinimalProduct([ @@ -938,6 +936,8 @@ public function testNoCategoriesFlatWithoutCategories(): void $this->assertArrayNotHasKey('categoriesFlat_lt-LT', $serialized); } + // --- Helpers --- + /** * @param array $overrides * @return array diff --git a/tests/Adapters/ShopifyAdapterTest.php b/tests/Adapters/ShopifyAdapterTest.php index bdc95dc..274baad 100644 --- a/tests/Adapters/ShopifyAdapterTest.php +++ b/tests/Adapters/ShopifyAdapterTest.php @@ -903,7 +903,6 @@ public function testCategoriesFlatContainsTaxonomyLevelsOnly(): void ]; $taxProduct['node']['tags'] = ['summer', 'cotton']; - // No taxonomy: tags alone are already flat single values — nothing to split. $noTaxProduct = $this->makeProduct('gid://shopify/Product/2', 'Ski', 'Desc', 'BrandX', 'Winter'); $noTaxProduct['node']['category'] = null; $noTaxProduct['node']['tags'] = ['cold']; From ad477a96be3ec382f490948cd28cd85f70349a3d Mon Sep 17 00:00:00 2001 From: GantasG Date: Tue, 1 Sep 2026 22:50:01 +0300 Subject: [PATCH 3/3] BRD-1150: keep categoriesFlat out of the Shopify V1 path ShopifyAdapter is shared: brad-app's V1ProductTransformer calls transform() with no locales, which routes to buildPlainFields(), and the V1 bulk path posts the operation payload with no field whitelist. An unsuffixed categoriesFlat there would hand a V1 tenant a field its index never mapped, plus full checksum churn, for no benefit - brad-app's presets define only categoriesFlat_{locale}. Emit the field in locale mode only. V1 output is now byte-identical to main for every platform. --- src/Adapters/ShopifyAdapter.php | 7 +++--- tests/Adapters/ShopifyAdapterTest.php | 34 +++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/Adapters/ShopifyAdapter.php b/src/Adapters/ShopifyAdapter.php index 19c56b1..6b79e7e 100644 --- a/src/Adapters/ShopifyAdapter.php +++ b/src/Adapters/ShopifyAdapter.php @@ -225,6 +225,10 @@ private function buildLocaleFields( /** * Build plain (non-localized) fields for backward compatibility. * + * This branch also serves the V1 sync path, whose bulk payload is pushed + * without a field whitelist, so no new field may be added here: + * categoriesFlat is emitted only in locale mode (buildLocaleFields). + * * @param array}> $productCollections * @return array */ @@ -240,8 +244,6 @@ private function buildPlainFields( string $primaryLocale, ): array { $collections = $this->resolveCollectionTitles($productCollections, $primaryLocale, $primaryLocale); - // Taxonomy only: tags are already flat single values with nothing to split. - $flatCategories = AdapterUtils::splitCategoryLevels([$categoryDefault]); return array_filter([ 'name' => $title, @@ -249,7 +251,6 @@ private function buildPlainFields( 'brand' => $brand !== '' ? $brand : null, 'categoryDefault' => $categoryDefault, 'categories' => $this->buildCategories($categoryDefault, $this->extractTags($product)), - 'categoriesFlat' => !empty($flatCategories) ? $flatCategories : null, 'productType' => $nativeProductType !== '' ? $nativeProductType : null, 'productUrl' => $productUrl !== '' ? $productUrl : null, 'collections' => !empty($collections) ? $collections : null, diff --git a/tests/Adapters/ShopifyAdapterTest.php b/tests/Adapters/ShopifyAdapterTest.php index 274baad..687a3d1 100644 --- a/tests/Adapters/ShopifyAdapterTest.php +++ b/tests/Adapters/ShopifyAdapterTest.php @@ -907,12 +907,12 @@ public function testCategoriesFlatContainsTaxonomyLevelsOnly(): void $noTaxProduct['node']['category'] = null; $noTaxProduct['node']['tags'] = ['cold']; - $result = $this->adapter->transform($this->makeShopifyResponse([$taxProduct, $noTaxProduct])); + $result = $this->adapter->transform($this->makeShopifyResponse([$taxProduct, $noTaxProduct], 'en'), ['en']); [$a, $b] = $result['products']; - $this->assertSame(['Apparel & Accessories', 'Clothing', 'Shirts'], $a['categoriesFlat']); - $this->assertNotContains('summer', $a['categoriesFlat']); - $this->assertArrayNotHasKey('categoriesFlat', $b); + $this->assertSame(['Apparel & Accessories', 'Clothing', 'Shirts'], $a['categoriesFlat_en']); + $this->assertNotContains('summer', $a['categoriesFlat_en']); + $this->assertArrayNotHasKey('categoriesFlat_en', $b); } public function testCategoriesFlatIsLocaleSuffixedInLocaleMode(): void @@ -933,6 +933,32 @@ public function testCategoriesFlatIsLocaleSuffixedInLocaleMode(): void $this->assertNotContains('summer', $p['categoriesFlat_en']); } + /** + * The no-locale branch also serves the V1 sync path, whose bulk payload is + * pushed without a field whitelist. A V1 tenant must not start receiving a + * field its index never mapped. + */ + public function testCategoriesFlatIsNotEmittedWithoutLocales(): void + { + $product = $this->makeProduct('gid://shopify/Product/1', 'Tee', 'Desc', 'BrandX', 'Shoes'); + $product['node']['category'] = [ + 'id' => 'gid://shopify/TaxonomyCategory/aa-1-13-8', + 'name' => 'Shirts', + 'fullName' => 'Apparel & Accessories > Clothing > Shirts', + ]; + $product['node']['tags'] = ['summer']; + + $result = $this->adapter->transform($this->makeShopifyResponse([$product])); + $p = $result['products'][0]; + + foreach (array_keys($p) as $key) { + $this->assertStringStartsNotWith('categoriesFlat', (string) $key); + } + + $this->assertSame('Apparel & Accessories > Clothing > Shirts', $p['categoryDefault']); + $this->assertContains('Apparel & Accessories > Clothing > Shirts', $p['categories']); + } + public function testMalformedCategoryFieldEmitsProductTypeAsOwnField(): void { $cases = [