Skip to content
Open
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
34 changes: 34 additions & 0 deletions src/Adapters/AdapterUtils.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<mixed> $paths
* @return array<string>
*/
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;
}
}
5 changes: 5 additions & 0 deletions src/Adapters/MagentoAdapterV2.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions src/Adapters/PrestaShopAdapterV2.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, mixed> $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.
*
Expand Down
9 changes: 9 additions & 0 deletions src/Adapters/ShopifyAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 : '');
Expand Down Expand Up @@ -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<int, array{default: string, translations: array<string, string>}> $productCollections
* @return array<string, mixed>
*/
Expand Down
44 changes: 44 additions & 0 deletions tests/Adapters/AdapterUtilsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
22 changes: 22 additions & 0 deletions tests/Adapters/MagentoAdapterV2Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

/**
Expand Down
53 changes: 53 additions & 0 deletions tests/Adapters/PrestaShopAdapterV2Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions tests/Adapters/ShopifyAdapterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Loading