From a130aa66dd0e47ca215a95cf4b5fad27b1753525 Mon Sep 17 00:00:00 2001 From: Paulius Stuksys Date: Mon, 22 Jun 2026 15:17:44 +0300 Subject: [PATCH 1/7] synonym configuration --- src/SyncV2Sdk.php | 10 +++- .../ValueObjects/Response/SynonymResponse.php | 23 +++++++- .../Synonym/SynonymConfiguration.php | 37 +++++++++++- tests/SyncV2SdkTest.php | 33 +++++++++-- .../Response/SynonymResponseTest.php | 21 +++++++ .../Synonym/SynonymConfigurationTest.php | 57 +++++++++++++++++-- .../synonyms-ecommerce-en.json | 6 +- 7 files changed, 170 insertions(+), 17 deletions(-) diff --git a/src/SyncV2Sdk.php b/src/SyncV2Sdk.php index 03d425b..d4e7dac 100644 --- a/src/SyncV2Sdk.php +++ b/src/SyncV2Sdk.php @@ -216,7 +216,15 @@ public function getSynonyms(string $language): SynonymResponse $this->baseApiPath . 'synonyms?language=' . urlencode($language) ); - return SynonymResponse::fromArray($response); + // GET returns {language, synonyms} without count/reindex fields. + $synonyms = $response['synonyms'] ?? []; + + return SynonymResponse::fromArray([ + 'language' => $response['language'] ?? $language, + 'synonym_count' => $response['synonym_count'] ?? count($synonyms), + 'requires_reindex' => $response['requires_reindex'] ?? false, + 'synonyms' => $synonyms, + ]); } /** diff --git a/src/V2/ValueObjects/Response/SynonymResponse.php b/src/V2/ValueObjects/Response/SynonymResponse.php index 5d2c342..1e1ee0b 100644 --- a/src/V2/ValueObjects/Response/SynonymResponse.php +++ b/src/V2/ValueObjects/Response/SynonymResponse.php @@ -57,7 +57,28 @@ public static function fromArray(array $data): self language: (string) $data['language'], synonymCount: (int) $data['synonym_count'], requiresReindex: (bool) $data['requires_reindex'], - synonyms: $data['synonyms'] ?? null + synonyms: self::normalizeSynonyms($data['synonyms'] ?? null) + ); + } + + /** + * The API returns each synonym group as a Solr-format string + * ("laptop, notebook"); normalize those into term arrays. + * + * @param array>|null $synonyms + * @return array>|null + */ + private static function normalizeSynonyms(?array $synonyms): ?array + { + if ($synonyms === null) { + return null; + } + + return array_map( + static fn(string|array $group): array => is_string($group) + ? array_map('trim', explode(',', $group)) + : $group, + $synonyms ); } diff --git a/src/V2/ValueObjects/Synonym/SynonymConfiguration.php b/src/V2/ValueObjects/Synonym/SynonymConfiguration.php index 22814c2..488e57d 100644 --- a/src/V2/ValueObjects/Synonym/SynonymConfiguration.php +++ b/src/V2/ValueObjects/Synonym/SynonymConfiguration.php @@ -58,13 +58,35 @@ public function addSynonym(array $synonymGroup): self } /** + * Creates a SynonymConfiguration from an API response payload, where each + * synonym group is a Solr-format string (e.g. "laptop, notebook"). + * + * @param array $data + */ + public static function fromApiResponse(array $data): self + { + $synonyms = array_map( + static fn(string $group): array => array_map('trim', explode(',', $group)), + $data['synonyms'] ?? [] + ); + + return new self((string) ($data['language'] ?? ''), $synonyms); + } + + /** + * The API expects each synonym group as a Solr-format equivalence string + * ("laptop, notebook, computer"), not as a nested array. + * * @return array */ public function jsonSerialize(): array { return [ 'language' => $this->language, - 'synonyms' => $this->synonyms, + 'synonyms' => array_map( + static fn(array $group): string => implode(', ', $group), + $this->synonyms + ), ]; } @@ -150,6 +172,19 @@ private function validateSynonyms(array $synonyms): void $synonyms ); } + + if (str_contains($term, ',') || str_contains($term, '=>')) { + throw new InvalidArgumentException( + sprintf( + 'Synonym term at index [%d][%d] must not contain "," or "=>" (Solr syntax characters), got "%s".', + $index, + $termIndex, + $term + ), + 'synonyms', + $synonyms + ); + } } } } diff --git a/tests/SyncV2SdkTest.php b/tests/SyncV2SdkTest.php index 65126f1..b5f31bf 100644 --- a/tests/SyncV2SdkTest.php +++ b/tests/SyncV2SdkTest.php @@ -967,13 +967,12 @@ public function testGetSynonymsSuccess(): void { $language = 'en'; + // GET returns the SynonymConfiguration shape: Solr strings, no count/reindex fields $apiResponse = [ 'language' => 'en', - 'synonym_count' => 2, - 'requires_reindex' => false, 'synonyms' => [ - ['happy', 'joyful', 'cheerful'], - ['sad', 'unhappy', 'sorrowful'], + 'happy, joyful, cheerful', + 'sad, unhappy, sorrowful', ], ]; @@ -991,7 +990,31 @@ public function testGetSynonymsSuccess(): void $this->assertEquals('en', $result->language); $this->assertEquals(2, $result->synonymCount); $this->assertFalse($result->requiresReindex); - $this->assertCount(2, $result->synonyms); + $this->assertEquals( + [ + ['happy', 'joyful', 'cheerful'], + ['sad', 'unhappy', 'sorrowful'], + ], + $result->synonyms + ); + } + + public function testGetSynonymsHandlesEmptyResult(): void + { + $httpClientMock = $this->createMock(HttpClient::class); + $httpClientMock + ->expects($this->once()) + ->method('get') + ->willReturn([ + 'language' => 'en', + 'synonyms' => null, + ]); + + $sdk = $this->createSdkWithMockedHttpClient($httpClientMock); + $result = $sdk->getSynonyms('en'); + + $this->assertEquals(0, $result->synonymCount); + $this->assertEquals([], $result->synonyms); } public function testGetSynonymsAppIdIncludedInUrlPath(): void diff --git a/tests/V2/ValueObjects/Response/SynonymResponseTest.php b/tests/V2/ValueObjects/Response/SynonymResponseTest.php index e6f7eae..6c9c90d 100644 --- a/tests/V2/ValueObjects/Response/SynonymResponseTest.php +++ b/tests/V2/ValueObjects/Response/SynonymResponseTest.php @@ -88,6 +88,27 @@ public function testFromArrayWithSynonyms(): void $this->assertEquals($synonyms, $response->synonyms); } + public function testFromArrayNormalizesSolrStringSynonymsIntoGroups(): void + { + $response = SynonymResponse::fromArray([ + 'language' => 'en', + 'synonym_count' => 2, + 'requires_reindex' => false, + 'synonyms' => [ + 'laptop, notebook,computer', + 'phone, mobile', + ], + ]); + + $this->assertEquals( + [ + ['laptop', 'notebook', 'computer'], + ['phone', 'mobile'], + ], + $response->synonyms + ); + } + public function testFromArrayThrowsOnMissingLanguage(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php b/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php index a683540..be76ac8 100644 --- a/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php +++ b/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php @@ -138,7 +138,7 @@ public function testRejectsWhitespaceOnlyStringInSynonymGroup(): void new SynonymConfiguration('en', [[' ', 'laptop']]); } - public function testJsonSerializeReturnsCorrectStructure(): void + public function testJsonSerializeReturnsSolrFormatStrings(): void { $synonyms = [ ['laptop', 'notebook'], @@ -149,12 +149,51 @@ public function testJsonSerializeReturnsCorrectStructure(): void $expected = [ 'language' => 'en', - 'synonyms' => $synonyms, + 'synonyms' => [ + 'laptop, notebook', + 'phone, mobile', + ], ]; $this->assertEquals($expected, $config->jsonSerialize()); } + public function testRejectsCommaInSynonymTerm(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must not contain "," or "=>"'); + + new SynonymConfiguration('en', [['laptop, notebook', 'computer']]); + } + + public function testRejectsExplicitMappingArrowInSynonymTerm(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must not contain "," or "=>"'); + + new SynonymConfiguration('en', [['laptop => notebook', 'computer']]); + } + + public function testFromApiResponseParsesSolrStringsIntoGroups(): void + { + $config = SynonymConfiguration::fromApiResponse([ + 'language' => 'en', + 'synonyms' => [ + 'laptop, notebook,computer', + 'phone, mobile', + ], + ]); + + $this->assertEquals('en', $config->language); + $this->assertEquals( + [ + ['laptop', 'notebook', 'computer'], + ['phone', 'mobile'], + ], + $config->synonyms + ); + } + public function testToArrayReturnsJsonSerializeOutput(): void { $config = new SynonymConfiguration('en', [['laptop', 'notebook']]); @@ -242,7 +281,13 @@ public function testJsonEncodeProducesValidJson(): void $decoded = json_decode($json, true); $this->assertEquals('en', $decoded['language']); - $this->assertEquals($synonyms, $decoded['synonyms']); + $this->assertEquals( + [ + 'laptop, notebook, computer', + 'phone, mobile, smartphone', + ], + $decoded['synonyms'] + ); } public function testChainedWithMethods(): void @@ -313,9 +358,9 @@ public function testMatchesOpenApiEcommerceEnExample(): void $expected = [ 'language' => 'en', 'synonyms' => [ - ['laptop', 'notebook', 'computer'], - ['phone', 'mobile', 'smartphone'], - ['shoes', 'footwear', 'sneakers'], + 'laptop, notebook, computer', + 'phone, mobile, smartphone', + 'shoes, footwear, sneakers', ], ]; diff --git a/tests/fixtures/openapi-examples/synonyms-ecommerce-en.json b/tests/fixtures/openapi-examples/synonyms-ecommerce-en.json index 5747d07..2b17889 100644 --- a/tests/fixtures/openapi-examples/synonyms-ecommerce-en.json +++ b/tests/fixtures/openapi-examples/synonyms-ecommerce-en.json @@ -1,8 +1,8 @@ { "language": "en", "synonyms": [ - ["laptop", "notebook", "computer"], - ["phone", "mobile", "smartphone"], - ["shoes", "footwear", "sneakers"] + "laptop, notebook, computer", + "phone, mobile, smartphone", + "shoes, footwear, sneakers" ] } From c5dee3fe30ab8f297229323cd97a8afd87ffeff3 Mon Sep 17 00:00:00 2001 From: Paulius Stuksys Date: Mon, 22 Jun 2026 16:17:38 +0300 Subject: [PATCH 2/7] Address PR review feedback on synonym configuration --- src/SyncV2Sdk.php | 20 ++- .../ValueObjects/Response/SynonymResponse.php | 12 +- .../Synonym/NormalizesSynonymGroups.php | 29 ++++ .../Synonym/SynonymConfiguration.php | 163 +++++++++++------- .../Response/SynonymResponseTest.php | 32 ++-- .../Synonym/SynonymConfigurationTest.php | 21 ++- 6 files changed, 186 insertions(+), 91 deletions(-) create mode 100644 src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php diff --git a/src/SyncV2Sdk.php b/src/SyncV2Sdk.php index d4e7dac..73d1903 100644 --- a/src/SyncV2Sdk.php +++ b/src/SyncV2Sdk.php @@ -216,15 +216,29 @@ public function getSynonyms(string $language): SynonymResponse $this->baseApiPath . 'synonyms?language=' . urlencode($language) ); - // GET returns {language, synonyms} without count/reindex fields. + return SynonymResponse::fromArray( + $this->withSynonymGetDefaults($response, $language) + ); + } + + /** + * The GET endpoint may omit synonym_count/requires_reindex; default them so + * SynonymResponse::fromArray() always receives its required fields. + * + * @param array $response + * @return array + */ + private function withSynonymGetDefaults(array $response, string $language): array + { $synonyms = $response['synonyms'] ?? []; + $synonyms = is_array($synonyms) ? $synonyms : []; - return SynonymResponse::fromArray([ + return [ 'language' => $response['language'] ?? $language, 'synonym_count' => $response['synonym_count'] ?? count($synonyms), 'requires_reindex' => $response['requires_reindex'] ?? false, 'synonyms' => $synonyms, - ]); + ]; } /** diff --git a/src/V2/ValueObjects/Response/SynonymResponse.php b/src/V2/ValueObjects/Response/SynonymResponse.php index 1e1ee0b..d6bb231 100644 --- a/src/V2/ValueObjects/Response/SynonymResponse.php +++ b/src/V2/ValueObjects/Response/SynonymResponse.php @@ -5,6 +5,7 @@ namespace BradSearch\SyncSdk\V2\ValueObjects\Response; use BradSearch\SyncSdk\V2\Exceptions\InvalidArgumentException; +use BradSearch\SyncSdk\V2\ValueObjects\Synonym\NormalizesSynonymGroups; use BradSearch\SyncSdk\V2\ValueObjects\ValueObject; /** @@ -18,6 +19,8 @@ */ final readonly class SynonymResponse extends ValueObject { + use NormalizesSynonymGroups; + private const LANGUAGE_PATTERN = '/^[a-z]{2}$/'; /** @@ -65,7 +68,7 @@ public static function fromArray(array $data): self * The API returns each synonym group as a Solr-format string * ("laptop, notebook"); normalize those into term arrays. * - * @param array>|null $synonyms + * @param array|null $synonyms * @return array>|null */ private static function normalizeSynonyms(?array $synonyms): ?array @@ -74,12 +77,7 @@ private static function normalizeSynonyms(?array $synonyms): ?array return null; } - return array_map( - static fn(string|array $group): array => is_string($group) - ? array_map('trim', explode(',', $group)) - : $group, - $synonyms - ); + return array_map(self::normalizeGroup(...), $synonyms); } /** diff --git a/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php b/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php new file mode 100644 index 0000000..06c6347 --- /dev/null +++ b/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php @@ -0,0 +1,29 @@ + + */ + private static function normalizeGroup(mixed $group): array + { + if (is_string($group)) { + return array_map('trim', explode(',', $group)); + } + + return is_array($group) ? array_values($group) : []; + } +} diff --git a/src/V2/ValueObjects/Synonym/SynonymConfiguration.php b/src/V2/ValueObjects/Synonym/SynonymConfiguration.php index 488e57d..eff2b7a 100644 --- a/src/V2/ValueObjects/Synonym/SynonymConfiguration.php +++ b/src/V2/ValueObjects/Synonym/SynonymConfiguration.php @@ -15,6 +15,8 @@ */ final readonly class SynonymConfiguration extends ValueObject { + use NormalizesSynonymGroups; + private const LANGUAGE_PATTERN = '/^[a-z]{2}$/'; /** @@ -61,14 +63,22 @@ public function addSynonym(array $synonymGroup): self * Creates a SynonymConfiguration from an API response payload, where each * synonym group is a Solr-format string (e.g. "laptop, notebook"). * + * Returns null when the response carries no synonyms — an empty list is a + * valid API state, whereas constructing a config to send requires at least + * one group. + * * @param array $data */ - public static function fromApiResponse(array $data): self + public static function fromApiResponse(array $data): ?self { - $synonyms = array_map( - static fn(string $group): array => array_map('trim', explode(',', $group)), - $data['synonyms'] ?? [] - ); + $rawSynonyms = $data['synonyms'] ?? []; + $rawSynonyms = is_array($rawSynonyms) ? $rawSynonyms : []; + + if (empty($rawSynonyms)) { + return null; + } + + $synonyms = array_map(self::normalizeGroup(...), $rawSynonyms); return new self((string) ($data['language'] ?? ''), $synonyms); } @@ -127,65 +137,90 @@ private function validateSynonyms(array $synonyms): void } foreach ($synonyms as $index => $synonymGroup) { - if (!is_array($synonymGroup)) { - throw new InvalidArgumentException( - sprintf( - 'Synonym group at index %d must be an array, got %s.', - $index, - gettype($synonymGroup) - ), - 'synonyms', - $synonyms - ); - } - - if (empty($synonymGroup)) { - throw new InvalidArgumentException( - sprintf('Synonym group at index %d cannot be empty.', $index), - 'synonyms', - $synonyms - ); - } - - foreach ($synonymGroup as $termIndex => $term) { - if (!is_string($term)) { - throw new InvalidArgumentException( - sprintf( - 'Synonym term at index [%d][%d] must be a string, got %s.', - $index, - $termIndex, - gettype($term) - ), - 'synonyms', - $synonyms - ); - } - - if (trim($term) === '') { - throw new InvalidArgumentException( - sprintf( - 'Synonym term at index [%d][%d] cannot be empty.', - $index, - $termIndex - ), - 'synonyms', - $synonyms - ); - } - - if (str_contains($term, ',') || str_contains($term, '=>')) { - throw new InvalidArgumentException( - sprintf( - 'Synonym term at index [%d][%d] must not contain "," or "=>" (Solr syntax characters), got "%s".', - $index, - $termIndex, - $term - ), - 'synonyms', - $synonyms - ); - } - } + $this->validateSynonymGroup($index, $synonymGroup, $synonyms); + } + } + + /** + * Validates a single synonym group: it must be a non-empty array of terms. + * + * @param array> $synonyms Full set, for error context + * + * @throws InvalidArgumentException If the group is not a non-empty array + */ + private function validateSynonymGroup(int $index, mixed $synonymGroup, array $synonyms): void + { + if (!is_array($synonymGroup)) { + throw new InvalidArgumentException( + sprintf( + 'Synonym group at index %d must be an array, got %s.', + $index, + gettype($synonymGroup) + ), + 'synonyms', + $synonyms + ); + } + + if (empty($synonymGroup)) { + throw new InvalidArgumentException( + sprintf('Synonym group at index %d cannot be empty.', $index), + 'synonyms', + $synonyms + ); + } + + foreach ($synonymGroup as $termIndex => $term) { + $this->validateSynonymTerm($index, $termIndex, $term, $synonyms); + } + } + + /** + * Validates a single synonym term: a non-empty string free of Solr syntax + * characters ("," and "=>"). + * + * @param array> $synonyms Full set, for error context + * + * @throws InvalidArgumentException If the term is invalid + */ + private function validateSynonymTerm(int $index, int $termIndex, mixed $term, array $synonyms): void + { + if (!is_string($term)) { + throw new InvalidArgumentException( + sprintf( + 'Synonym term at index [%d][%d] must be a string, got %s.', + $index, + $termIndex, + gettype($term) + ), + 'synonyms', + $synonyms + ); + } + + if (trim($term) === '') { + throw new InvalidArgumentException( + sprintf( + 'Synonym term at index [%d][%d] cannot be empty.', + $index, + $termIndex + ), + 'synonyms', + $synonyms + ); + } + + if (str_contains($term, ',') || str_contains($term, '=>')) { + throw new InvalidArgumentException( + sprintf( + 'Synonym term at index [%d][%d] must not contain "," or "=>" (Solr syntax characters), got "%s".', + $index, + $termIndex, + $term + ), + 'synonyms', + $synonyms + ); } } } diff --git a/tests/V2/ValueObjects/Response/SynonymResponseTest.php b/tests/V2/ValueObjects/Response/SynonymResponseTest.php index 6c9c90d..33ef373 100644 --- a/tests/V2/ValueObjects/Response/SynonymResponseTest.php +++ b/tests/V2/ValueObjects/Response/SynonymResponseTest.php @@ -244,23 +244,29 @@ public function testToArrayReturnsJsonSerializeOutput(): void */ public function testMatchesOpenApiExampleResponse(): void { - $apiResponse = [ - 'language' => 'en', - 'synonym_count' => 3, - 'requires_reindex' => true, - 'synonyms' => [ - ['laptop', 'notebook', 'computer'], - ['phone', 'mobile', 'smartphone'], - ['shoes', 'footwear', 'sneakers'], - ], - ]; + $fixture = json_decode( + (string) file_get_contents( + __DIR__ . '/../../../fixtures/openapi-examples/synonyms-ecommerce-en.json' + ), + true + ); - $response = SynonymResponse::fromArray($apiResponse); + // The fixture is the Solr-string GET shape and omits count/reindex. + $response = SynonymResponse::fromArray($fixture + [ + 'synonym_count' => count($fixture['synonyms']), + 'requires_reindex' => false, + ]); $this->assertEquals('en', $response->language); $this->assertEquals(3, $response->synonymCount); - $this->assertTrue($response->requiresReindex); - $this->assertCount(3, $response->synonyms); + $this->assertEquals( + [ + ['laptop', 'notebook', 'computer'], + ['phone', 'mobile', 'smartphone'], + ['shoes', 'footwear', 'sneakers'], + ], + $response->synonyms + ); } public function testJsonEncodeProducesValidJson(): void diff --git a/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php b/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php index be76ac8..1f1bf27 100644 --- a/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php +++ b/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php @@ -184,6 +184,7 @@ public function testFromApiResponseParsesSolrStringsIntoGroups(): void ], ]); + $this->assertNotNull($config); $this->assertEquals('en', $config->language); $this->assertEquals( [ @@ -194,6 +195,18 @@ public function testFromApiResponseParsesSolrStringsIntoGroups(): void ); } + public function testFromApiResponseReturnsNullForEmptySynonyms(): void + { + $this->assertNull(SynonymConfiguration::fromApiResponse([ + 'language' => 'en', + 'synonyms' => [], + ])); + + $this->assertNull(SynonymConfiguration::fromApiResponse([ + 'language' => 'en', + ])); + } + public function testToArrayReturnsJsonSerializeOutput(): void { $config = new SynonymConfiguration('en', [['laptop', 'notebook']]); @@ -398,10 +411,10 @@ public function testAcceptsLargeSynonymGroup(): void public function testAcceptsManySynonymGroups(): void { - $synonyms = []; - for ($i = 0; $i < 100; $i++) { - $synonyms[] = ["term{$i}a", "term{$i}b"]; - } + $synonyms = array_map( + fn(int $i): array => ["term{$i}a", "term{$i}b"], + range(0, 99) + ); $config = new SynonymConfiguration('en', $synonyms); From 03f6957e3c8d36d5fa1b21bbb810158c7ebc367f Mon Sep 17 00:00:00 2001 From: Paulius Stuksys Date: Mon, 22 Jun 2026 16:26:26 +0300 Subject: [PATCH 3/7] Harden SynonymResponse against malformed synonyms payloads - normalizeSynonyms accepts mixed and returns null for non-array input, avoiding a TypeError on the public fromArray() path - Drop malformed groups that normalize to empty so the result never carries an untraceable [] - Load the OpenAPI fixture via array_merge so fixture values stay authoritative over the supplied defaults Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ValueObjects/Response/SynonymResponse.php | 16 ++++++--- .../Response/SynonymResponseTest.php | 34 +++++++++++++++++-- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/V2/ValueObjects/Response/SynonymResponse.php b/src/V2/ValueObjects/Response/SynonymResponse.php index d6bb231..0d53324 100644 --- a/src/V2/ValueObjects/Response/SynonymResponse.php +++ b/src/V2/ValueObjects/Response/SynonymResponse.php @@ -68,16 +68,24 @@ public static function fromArray(array $data): self * The API returns each synonym group as a Solr-format string * ("laptop, notebook"); normalize those into term arrays. * - * @param array|null $synonyms + * Non-array payloads (null, or the whole field arriving as a single + * string) yield null rather than a TypeError, since fromArray() is public. + * Malformed entries that normalize to an empty group are dropped so the + * result never carries an untraceable []. + * + * @param mixed $synonyms * @return array>|null */ - private static function normalizeSynonyms(?array $synonyms): ?array + private static function normalizeSynonyms(mixed $synonyms): ?array { - if ($synonyms === null) { + if (!is_array($synonyms)) { return null; } - return array_map(self::normalizeGroup(...), $synonyms); + return array_values(array_filter( + array_map(self::normalizeGroup(...), $synonyms), + static fn(array $group): bool => !empty($group) + )); } /** diff --git a/tests/V2/ValueObjects/Response/SynonymResponseTest.php b/tests/V2/ValueObjects/Response/SynonymResponseTest.php index 33ef373..07e2be3 100644 --- a/tests/V2/ValueObjects/Response/SynonymResponseTest.php +++ b/tests/V2/ValueObjects/Response/SynonymResponseTest.php @@ -109,6 +109,33 @@ public function testFromArrayNormalizesSolrStringSynonymsIntoGroups(): void ); } + public function testFromArrayHandlesNonArraySynonymsWithoutTypeError(): void + { + $response = SynonymResponse::fromArray([ + 'language' => 'en', + 'synonym_count' => 0, + 'requires_reindex' => false, + 'synonyms' => 'laptop, notebook', + ]); + + $this->assertNull($response->synonyms); + } + + public function testFromArrayDropsMalformedGroupsThatNormalizeToEmpty(): void + { + $response = SynonymResponse::fromArray([ + 'language' => 'en', + 'synonym_count' => 1, + 'requires_reindex' => false, + 'synonyms' => [ + 'laptop, notebook', + 123, + ], + ]); + + $this->assertEquals([['laptop', 'notebook']], $response->synonyms); + } + public function testFromArrayThrowsOnMissingLanguage(): void { $this->expectException(InvalidArgumentException::class); @@ -251,11 +278,12 @@ public function testMatchesOpenApiExampleResponse(): void true ); - // The fixture is the Solr-string GET shape and omits count/reindex. - $response = SynonymResponse::fromArray($fixture + [ + // The fixture is the Solr-string GET shape and omits count/reindex; + // supply defaults that the fixture overrides if it ever gains them. + $response = SynonymResponse::fromArray(array_merge([ 'synonym_count' => count($fixture['synonyms']), 'requires_reindex' => false, - ]); + ], $fixture)); $this->assertEquals('en', $response->language); $this->assertEquals(3, $response->synonymCount); From 5332eb9d4d33e6fb354860c952350bfb05d4da6a Mon Sep 17 00:00:00 2001 From: Paulius Stuksys Date: Mon, 22 Jun 2026 16:55:38 +0300 Subject: [PATCH 4/7] Trim array-form synonym groups and document parsing divergence - normalizeGroup now trims string elements and discards non-string elements in the array branch, matching the Solr-string branch - Document that fromApiResponse throws on malformed entries whereas SynonymResponse::normalizeSynonyms drops them silently - Note that getSynonyms always returns a non-null synonyms array Co-Authored-By: Claude Opus 4.8 (1M context) --- src/SyncV2Sdk.php | 4 ++++ .../Synonym/NormalizesSynonymGroups.php | 14 +++++++++++--- .../ValueObjects/Synonym/SynonymConfiguration.php | 6 ++++++ .../ValueObjects/Response/SynonymResponseTest.php | 14 ++++++++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/SyncV2Sdk.php b/src/SyncV2Sdk.php index 73d1903..b359b41 100644 --- a/src/SyncV2Sdk.php +++ b/src/SyncV2Sdk.php @@ -207,6 +207,10 @@ public function setSynonyms(SynonymConfiguration $config): SynonymResponse /** * Get search synonyms for a specific language. * + * The returned response always carries a non-null synonyms array (empty + * when there are none), unlike SynonymResponse::fromArray() called directly + * without a synonyms key, which yields null. + * * @param string $language Language code (e.g., "en", "lt") * @return SynonymResponse Typed response with synonyms data */ diff --git a/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php b/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php index 06c6347..b8135b3 100644 --- a/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php +++ b/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php @@ -13,8 +13,9 @@ trait NormalizesSynonymGroups { /** - * Normalizes a single synonym group: Solr-format strings are split on - * commas, arrays pass through, anything else collapses to an empty group. + * Normalizes a single synonym group into trimmed string terms: Solr-format + * strings are split on commas; arrays have their string elements trimmed and + * non-string elements discarded; anything else collapses to an empty group. * * @return array */ @@ -24,6 +25,13 @@ private static function normalizeGroup(mixed $group): array return array_map('trim', explode(',', $group)); } - return is_array($group) ? array_values($group) : []; + if (!is_array($group)) { + return []; + } + + return array_values(array_filter( + array_map(static fn(mixed $term): string => is_string($term) ? trim($term) : '', $group), + static fn(string $term): bool => $term !== '' + )); } } diff --git a/src/V2/ValueObjects/Synonym/SynonymConfiguration.php b/src/V2/ValueObjects/Synonym/SynonymConfiguration.php index eff2b7a..23fe1a2 100644 --- a/src/V2/ValueObjects/Synonym/SynonymConfiguration.php +++ b/src/V2/ValueObjects/Synonym/SynonymConfiguration.php @@ -68,6 +68,12 @@ public function addSynonym(array $synonymGroup): self * one group. * * @param array $data + * + * @throws InvalidArgumentException If a synonym group cannot be parsed + * (e.g. a non-string/non-array entry that + * normalizes to an empty group), unlike + * SynonymResponse::normalizeSynonyms() + * which drops such groups silently. */ public static function fromApiResponse(array $data): ?self { diff --git a/tests/V2/ValueObjects/Response/SynonymResponseTest.php b/tests/V2/ValueObjects/Response/SynonymResponseTest.php index 07e2be3..ff0ea66 100644 --- a/tests/V2/ValueObjects/Response/SynonymResponseTest.php +++ b/tests/V2/ValueObjects/Response/SynonymResponseTest.php @@ -136,6 +136,20 @@ public function testFromArrayDropsMalformedGroupsThatNormalizeToEmpty(): void $this->assertEquals([['laptop', 'notebook']], $response->synonyms); } + public function testFromArrayTrimsArrayFormGroupsAndDropsNonStringElements(): void + { + $response = SynonymResponse::fromArray([ + 'language' => 'en', + 'synonym_count' => 1, + 'requires_reindex' => false, + 'synonyms' => [ + ['laptop ', ' notebook', 123], + ], + ]); + + $this->assertEquals([['laptop', 'notebook']], $response->synonyms); + } + public function testFromArrayThrowsOnMissingLanguage(): void { $this->expectException(InvalidArgumentException::class); From 163ed463bf8e155e826c81267bc5ea092d670335 Mon Sep 17 00:00:00 2001 From: Paulius Stuksys Date: Mon, 22 Jun 2026 17:07:10 +0300 Subject: [PATCH 5/7] Filter empty terms consistently across both normalizeGroup branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Solr-string branch now drops empty/whitespace-only terms via a shared trimTerms() helper, matching the array branch — so a malformed input like "a,,b" yields ['a', 'b'] regardless of source format. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Synonym/NormalizesSynonymGroups.php | 18 ++++++++++++++++-- .../Response/SynonymResponseTest.php | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php b/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php index b8135b3..80fd938 100644 --- a/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php +++ b/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php @@ -22,15 +22,29 @@ trait NormalizesSynonymGroups private static function normalizeGroup(mixed $group): array { if (is_string($group)) { - return array_map('trim', explode(',', $group)); + return self::trimTerms(explode(',', $group)); } if (!is_array($group)) { return []; } + return self::trimTerms(array_map( + static fn(mixed $term): string => is_string($term) ? $term : '', + $group + )); + } + + /** + * Trims terms and discards any that are empty after trimming. + * + * @param array $terms + * @return array + */ + private static function trimTerms(array $terms): array + { return array_values(array_filter( - array_map(static fn(mixed $term): string => is_string($term) ? trim($term) : '', $group), + array_map('trim', $terms), static fn(string $term): bool => $term !== '' )); } diff --git a/tests/V2/ValueObjects/Response/SynonymResponseTest.php b/tests/V2/ValueObjects/Response/SynonymResponseTest.php index ff0ea66..d9fdaac 100644 --- a/tests/V2/ValueObjects/Response/SynonymResponseTest.php +++ b/tests/V2/ValueObjects/Response/SynonymResponseTest.php @@ -150,6 +150,20 @@ public function testFromArrayTrimsArrayFormGroupsAndDropsNonStringElements(): vo $this->assertEquals([['laptop', 'notebook']], $response->synonyms); } + public function testFromArrayDropsEmptyTermsFromSolrStringGroups(): void + { + $response = SynonymResponse::fromArray([ + 'language' => 'en', + 'synonym_count' => 1, + 'requires_reindex' => false, + 'synonyms' => [ + 'laptop, , notebook', + ], + ]); + + $this->assertEquals([['laptop', 'notebook']], $response->synonyms); + } + public function testFromArrayThrowsOnMissingLanguage(): void { $this->expectException(InvalidArgumentException::class); From 63bf39d0208f82be383a5af55aa5d81b9c1dad55 Mon Sep 17 00:00:00 2001 From: Paulius Stuksys Date: Thu, 25 Jun 2026 12:07:30 +0300 Subject: [PATCH 6/7] Allow synonyms to be sent at index creation time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IndexCreateRequest now accepts an optional per-language SynonymConfiguration list, serialized into the create-index payload (under a "synonyms" key, omitted when empty). This lets a full reindex/new-index job apply synonyms up front so they take effect on activation, avoiding the close -> update settings -> open cycle of a post-activation setSynonyms() call that briefly interrupts storefront search. createIndex() is unchanged — it already serializes the request body. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ValueObjects/Index/IndexCreateRequest.php | 68 +++++++++++++++++-- .../Index/IndexCreateRequestTest.php | 59 ++++++++++++++++ 2 files changed, 121 insertions(+), 6 deletions(-) diff --git a/src/V2/ValueObjects/Index/IndexCreateRequest.php b/src/V2/ValueObjects/Index/IndexCreateRequest.php index 9ad2eb9..d2b5a47 100644 --- a/src/V2/ValueObjects/Index/IndexCreateRequest.php +++ b/src/V2/ValueObjects/Index/IndexCreateRequest.php @@ -6,6 +6,7 @@ use BradSearch\SyncSdk\V2\Exceptions\InvalidArgumentException; use BradSearch\SyncSdk\V2\Exceptions\InvalidLocaleException; +use BradSearch\SyncSdk\V2\ValueObjects\Synonym\SynonymConfiguration; use BradSearch\SyncSdk\V2\ValueObjects\ValueObject; /** @@ -14,6 +15,9 @@ * This immutable ValueObject contains the required data for creating a new index: * - locales: Array of locale codes in 'xx-XX' format * - fields: Array of FieldDefinition objects + * - synonyms: Optional per-language synonym configurations, applied at index + * creation so they take effect on activation without a separate post-activation + * update (which would briefly close the active index). */ final readonly class IndexCreateRequest extends ValueObject { @@ -25,13 +29,16 @@ /** * @param array $locales Array of locale codes (e.g., ['lt-LT', 'en-US']) * @param array $fields Array of field definitions + * @param array $synonyms Optional per-language synonym configurations */ public function __construct( array $locales, - public array $fields + public array $fields, + public array $synonyms = [] ) { $this->validateLocales($locales); $this->validateFields($fields); + $this->validateSynonyms($synonyms); $this->locales = $locales; } @@ -42,7 +49,7 @@ public function __construct( */ public function withLocales(array $locales): self { - return new self($locales, $this->fields); + return new self($locales, $this->fields, $this->synonyms); } /** @@ -52,7 +59,17 @@ public function withLocales(array $locales): self */ public function withFields(array $fields): self { - return new self($this->locales, $fields); + return new self($this->locales, $fields, $this->synonyms); + } + + /** + * Returns a new instance with different synonym configurations. + * + * @param array $synonyms + */ + public function withSynonyms(array $synonyms): self + { + return new self($this->locales, $this->fields, $synonyms); } /** @@ -60,7 +77,7 @@ public function withFields(array $fields): self */ public function withAddedLocale(string $locale): self { - return new self([...$this->locales, $locale], $this->fields); + return new self([...$this->locales, $locale], $this->fields, $this->synonyms); } /** @@ -68,7 +85,15 @@ public function withAddedLocale(string $locale): self */ public function withAddedField(FieldDefinition $field): self { - return new self($this->locales, [...$this->fields, $field]); + return new self($this->locales, [...$this->fields, $field], $this->synonyms); + } + + /** + * Returns a new instance with an additional synonym configuration. + */ + public function withAddedSynonym(SynonymConfiguration $synonym): self + { + return new self($this->locales, $this->fields, [...$this->synonyms, $synonym]); } /** @@ -76,13 +101,22 @@ public function withAddedField(FieldDefinition $field): self */ public function jsonSerialize(): array { - return [ + $data = [ 'locales' => $this->locales, 'fields' => array_map( fn(FieldDefinition $field) => $field->jsonSerialize(), $this->fields ), ]; + + if ($this->synonyms !== []) { + $data['synonyms'] = array_map( + fn(SynonymConfiguration $synonym) => $synonym->jsonSerialize(), + $this->synonyms + ); + } + + return $data; } /** @@ -143,4 +177,26 @@ private function validateFields(array $fields): void } } } + + /** + * Validates that all synonym entries are SynonymConfiguration instances. + * + * @param array $synonyms + * @throws InvalidArgumentException If an entry is not a SynonymConfiguration + */ + private function validateSynonyms(array $synonyms): void + { + foreach ($synonyms as $index => $synonym) { + if (!$synonym instanceof SynonymConfiguration) { + throw new InvalidArgumentException( + sprintf( + 'Synonym at index %d must be an instance of SynonymConfiguration.', + $index + ), + 'synonyms', + $synonym + ); + } + } + } } diff --git a/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php b/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php index abf9648..b091d82 100644 --- a/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php +++ b/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php @@ -10,6 +10,7 @@ use BradSearch\SyncSdk\V2\ValueObjects\Index\FieldType; use BradSearch\SyncSdk\V2\ValueObjects\Index\IndexCreateRequest; use BradSearch\SyncSdk\V2\ValueObjects\Index\VariantAttribute; +use BradSearch\SyncSdk\V2\ValueObjects\Synonym\SynonymConfiguration; use BradSearch\SyncSdk\V2\ValueObjects\ValueObject; use JsonSerializable; use PHPUnit\Framework\TestCase; @@ -463,4 +464,62 @@ public function testInvalidFieldInMiddleOfArray(): void ] ); } + + public function testSynonymsDefaultToEmptyAndAreOmittedFromSerialization(): void + { + $request = new IndexCreateRequest( + ['lt-LT'], + [new FieldDefinition('id', FieldType::KEYWORD)] + ); + + $this->assertEquals([], $request->synonyms); + $this->assertArrayNotHasKey('synonyms', $request->jsonSerialize()); + } + + public function testSynonymsAreSerializedAsSolrStringsWhenProvided(): void + { + $request = new IndexCreateRequest( + ['en-US', 'lt-LT'], + [new FieldDefinition('name', FieldType::TEXT)], + [ + new SynonymConfiguration('en', [['laptop', 'notebook']]), + new SynonymConfiguration('lt', [['telefonas', 'mobilusis']]), + ] + ); + + $this->assertEquals( + [ + ['language' => 'en', 'synonyms' => ['laptop, notebook']], + ['language' => 'lt', 'synonyms' => ['telefonas, mobilusis']], + ], + $request->jsonSerialize()['synonyms'] + ); + } + + public function testWithSynonymsReturnsNewInstance(): void + { + $request = new IndexCreateRequest( + ['en-US'], + [new FieldDefinition('name', FieldType::TEXT)] + ); + + $synonyms = [new SynonymConfiguration('en', [['laptop', 'notebook']])]; + $updated = $request->withSynonyms($synonyms); + + $this->assertNotSame($request, $updated); + $this->assertEquals([], $request->synonyms); + $this->assertEquals($synonyms, $updated->synonyms); + } + + public function testThrowsExceptionForInvalidSynonymEntry(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Synonym at index 0 must be an instance of SynonymConfiguration.'); + + new IndexCreateRequest( + ['en-US'], + [new FieldDefinition('name', FieldType::TEXT)], + [['language' => 'en', 'synonyms' => [['laptop', 'notebook']]]] + ); + } } From 5cfbcf06f63ddc26a30e47ec3f87f5b0fb7bc756 Mon Sep 17 00:00:00 2001 From: Paulius Stuksys Date: Thu, 25 Jun 2026 18:13:57 +0300 Subject: [PATCH 7/7] address reviewer comments --- .../ValueObjects/Index/IndexCreateRequest.php | 20 +++++- .../Index/IndexCreateRequestTest.php | 65 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/V2/ValueObjects/Index/IndexCreateRequest.php b/src/V2/ValueObjects/Index/IndexCreateRequest.php index d2b5a47..fb507d5 100644 --- a/src/V2/ValueObjects/Index/IndexCreateRequest.php +++ b/src/V2/ValueObjects/Index/IndexCreateRequest.php @@ -179,13 +179,17 @@ private function validateFields(array $fields): void } /** - * Validates that all synonym entries are SynonymConfiguration instances. + * Validates that all synonym entries are SynonymConfiguration instances and + * that each language appears at most once. * * @param array $synonyms * @throws InvalidArgumentException If an entry is not a SynonymConfiguration + * or a language is configured more than once */ private function validateSynonyms(array $synonyms): void { + $seenLanguages = []; + foreach ($synonyms as $index => $synonym) { if (!$synonym instanceof SynonymConfiguration) { throw new InvalidArgumentException( @@ -197,6 +201,20 @@ private function validateSynonyms(array $synonyms): void $synonym ); } + + if (isset($seenLanguages[$synonym->language])) { + throw new InvalidArgumentException( + sprintf( + 'Duplicate synonym configuration for language "%s" at index %d; each language must appear at most once.', + $synonym->language, + $index + ), + 'synonyms', + $synonyms + ); + } + + $seenLanguages[$synonym->language] = true; } } } diff --git a/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php b/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php index b091d82..2e4a139 100644 --- a/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php +++ b/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php @@ -511,6 +511,25 @@ public function testWithSynonymsReturnsNewInstance(): void $this->assertEquals($synonyms, $updated->synonyms); } + public function testWithAddedSynonymReturnsNewInstance(): void + { + $originalSynonym = new SynonymConfiguration('en', [['laptop', 'notebook']]); + $newSynonym = new SynonymConfiguration('lt', [['telefonas', 'mobilusis']]); + + $request = new IndexCreateRequest( + ['en-US', 'lt-LT'], + [new FieldDefinition('name', FieldType::TEXT)], + [$originalSynonym] + ); + $updated = $request->withAddedSynonym($newSynonym); + + $this->assertNotSame($request, $updated); + $this->assertCount(1, $request->synonyms); + $this->assertCount(2, $updated->synonyms); + $this->assertSame($originalSynonym, $updated->synonyms[0]); + $this->assertSame($newSynonym, $updated->synonyms[1]); + } + public function testThrowsExceptionForInvalidSynonymEntry(): void { $this->expectException(InvalidArgumentException::class); @@ -522,4 +541,50 @@ public function testThrowsExceptionForInvalidSynonymEntry(): void [['language' => 'en', 'synonyms' => [['laptop', 'notebook']]]] ); } + + /** + * @dataProvider builderProvider + * @param callable(IndexCreateRequest): IndexCreateRequest $builder + */ + public function testBuildersPreserveSynonyms(callable $builder): void + { + $synonyms = [new SynonymConfiguration('en', [['laptop', 'notebook']])]; + $request = new IndexCreateRequest( + ['en-US'], + [new FieldDefinition('name', FieldType::TEXT)], + $synonyms + ); + + $result = $builder($request); + + $this->assertEquals($synonyms, $result->synonyms); + } + + /** + * @return array + */ + public static function builderProvider(): array + { + return [ + 'withLocales' => [fn(IndexCreateRequest $r) => $r->withLocales(['lt-LT'])], + 'withFields' => [fn(IndexCreateRequest $r) => $r->withFields([new FieldDefinition('title', FieldType::TEXT)])], + 'withAddedLocale' => [fn(IndexCreateRequest $r) => $r->withAddedLocale('lt-LT')], + 'withAddedField' => [fn(IndexCreateRequest $r) => $r->withAddedField(new FieldDefinition('title', FieldType::TEXT))], + ]; + } + + public function testThrowsExceptionForDuplicateSynonymLanguage(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Duplicate synonym configuration for language "en" at index 1'); + + new IndexCreateRequest( + ['en-US'], + [new FieldDefinition('name', FieldType::TEXT)], + [ + new SynonymConfiguration('en', [['laptop', 'notebook']]), + new SynonymConfiguration('en', [['phone', 'mobile']]), + ] + ); + } }