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..6b79e7e 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 : ''); @@ -220,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 */ 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..2e74d35 100644 --- a/tests/Adapters/MagentoAdapterV2Test.php +++ b/tests/Adapters/MagentoAdapterV2Test.php @@ -914,6 +914,28 @@ public function testFullProductTransformation(): void $this->assertArrayNotHasKey('description', $serialized); } + 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); + } + // --- Helpers --- /** 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..687a3d1 100644 --- a/tests/Adapters/ShopifyAdapterTest.php +++ b/tests/Adapters/ShopifyAdapterTest.php @@ -893,6 +893,72 @@ 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']; + + $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], 'en'), ['en']); + [$a, $b] = $result['products']; + + $this->assertSame(['Apparel & Accessories', 'Clothing', 'Shirts'], $a['categoriesFlat_en']); + $this->assertNotContains('summer', $a['categoriesFlat_en']); + $this->assertArrayNotHasKey('categoriesFlat_en', $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']); + } + + /** + * 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 = [