Skip to content
Merged
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
28 changes: 27 additions & 1 deletion src/SyncV2Sdk.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -216,7 +220,29 @@ public function getSynonyms(string $language): SynonymResponse
$this->baseApiPath . 'synonyms?language=' . urlencode($language)
);

return SynonymResponse::fromArray($response);
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<string, mixed> $response
* @return array<string, mixed>
*/
private function withSynonymGetDefaults(array $response, string $language): array
{
$synonyms = $response['synonyms'] ?? [];
$synonyms = is_array($synonyms) ? $synonyms : [];

return [
'language' => $response['language'] ?? $language,
'synonym_count' => $response['synonym_count'] ?? count($synonyms),
'requires_reindex' => $response['requires_reindex'] ?? false,
'synonyms' => $synonyms,
];
}

/**
Expand Down
86 changes: 80 additions & 6 deletions src/V2/ValueObjects/Index/IndexCreateRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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
{
Expand All @@ -25,13 +29,16 @@
/**
* @param array<string> $locales Array of locale codes (e.g., ['lt-LT', 'en-US'])
* @param array<FieldDefinition> $fields Array of field definitions
* @param array<SynonymConfiguration> $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;
}

Expand All @@ -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);
}

/**
Expand All @@ -52,37 +59,64 @@ 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<SynonymConfiguration> $synonyms
*/
public function withSynonyms(array $synonyms): self
{
return new self($this->locales, $this->fields, $synonyms);
}

/**
* Returns a new instance with an additional locale.
*/
public function withAddedLocale(string $locale): self
{
return new self([...$this->locales, $locale], $this->fields);
return new self([...$this->locales, $locale], $this->fields, $this->synonyms);
}

/**
* Returns a new instance with an additional field.
*/
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]);
}

/**
* @return array<string, mixed>
*/
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;
}

/**
Expand Down Expand Up @@ -143,4 +177,44 @@ private function validateFields(array $fields): void
}
}
}

/**
* Validates that all synonym entries are SynonymConfiguration instances and
* that each language appears at most once.
*
* @param array<mixed> $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(
sprintf(
'Synonym at index %d must be an instance of SynonymConfiguration.',
$index
),
'synonyms',
$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;
}
}
}
29 changes: 28 additions & 1 deletion src/V2/ValueObjects/Response/SynonymResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -18,6 +19,8 @@
*/
final readonly class SynonymResponse extends ValueObject
{
use NormalizesSynonymGroups;

private const LANGUAGE_PATTERN = '/^[a-z]{2}$/';

/**
Expand Down Expand Up @@ -57,10 +60,34 @@ 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.
*
* 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<int, array<int, string>>|null
*/
private static function normalizeSynonyms(mixed $synonyms): ?array
{
if (!is_array($synonyms)) {
return null;
}

return array_values(array_filter(
array_map(self::normalizeGroup(...), $synonyms),
static fn(array $group): bool => !empty($group)
));
}

/**
* @return array<string, mixed>
*/
Expand Down
51 changes: 51 additions & 0 deletions src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace BradSearch\SyncSdk\V2\ValueObjects\Synonym;

/**
* Normalizes synonym groups from API payloads into term arrays.
*
* The API returns each group as a Solr-format equivalence string
* ("laptop, notebook"); this trait converts those into term arrays.
*/
trait NormalizesSynonymGroups
{
/**
* 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<int, string>
*/
private static function normalizeGroup(mixed $group): array
{
if (is_string($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<int, string> $terms
* @return array<int, string>
*/
private static function trimTerms(array $terms): array
{
return array_values(array_filter(
array_map('trim', $terms),
static fn(string $term): bool => $term !== ''
));
}
}
Loading
Loading