Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c40a13c
fix: keep keyword search intact when a country filter is also present…
zigzagdev Sep 6, 2026
55f52ad
test: cover combined keyword and country filter in AlgoliaWorldHerita…
zigzagdev Sep 6, 2026
2c43727
Merge pull request #594 from zigzagdev/fix/algolia-keyword-country-fi…
zigzagdev Oct 24, 2026
7bb042f
fix: move unresolved countryName filter from filters to optionalFilters
zigzagdev Oct 24, 2026
66311ae
test: cover countryName optionalFilters behavior in AlgoliaWorldHerit…
zigzagdev Oct 24, 2026
2988b89
Merge pull request #602 from zigzagdev/fix/algolia-country-name-optio…
zigzagdev Oct 24, 2026
2536218
feat: add algolia:sync-index-settings command to manage attributesFor…
zigzagdev Oct 24, 2026
6dc737f
test: cover AlgoliaSyncIndexSettings dry-run output and adapter-filte…
zigzagdev Oct 24, 2026
f1dc12e
fix: make --dry-run work without Algolia credentials configured
zigzagdev Oct 25, 2026
e2373e3
refactor: extract AlgoliaIndexSettingsSyncer with injectable SearchCl…
zigzagdev Oct 25, 2026
bd2849b
refactor: delegate attributesForFaceting sync to AlgoliaIndexSettings…
zigzagdev Oct 25, 2026
e236927
test: cover AlgoliaIndexSettingsSyncer and the command's credential/h…
zigzagdev Oct 25, 2026
84b835d
Merge pull request #603 from zigzagdev/fix/algolia-sync-index-settings
zigzagdev Oct 25, 2026
57d2d86
fix: log and skip non-numeric Algolia hit ids instead of silently dro…
zigzagdev Oct 25, 2026
132befc
test: cover non-numeric id handling in AlgoliaWorldHeritageSearchAdap…
zigzagdev Oct 25, 2026
287b1ec
Merge pull request #604 from zigzagdev/fix/algolia-non-numeric-id-han…
zigzagdev Oct 26, 2026
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
64 changes: 64 additions & 0 deletions src/app/Console/Commands/AlgoliaSyncIndexSettings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace App\Console\Commands;

use Algolia\AlgoliaSearch\Api\SearchClient;
use App\Packages\Domains\WorldHeritage\Adapter\AlgoliaIndexSettingsSyncer;
use Illuminate\Console\Command;

class AlgoliaSyncIndexSettings extends Command
{
protected $signature = 'algolia:sync-index-settings {--dry-run}';

protected $description = 'Apply the attributesForFaceting declared in code to the Algolia index settings.';

/**
* Every attribute the search Adapter filters or optional-filters on must
* be declared here, or Algolia rejects the request with a facet error.
* Attributes not used for filtering by the Adapter (e.g. country_names_jp,
* region, study_regions) are kept as plain facets rather than
* filterOnly() for now: whether the frontend needs their facet counts
* has not been confirmed (see Domains/README.md).
*/
public const ATTRIBUTES_FOR_FACETING = [
'category',
'country',
'country_name_jp',
'country_names_jp',
'criteria',
'is_endangered',
'region',
'state_party_codes',
'study_region',
'study_regions',
];

public function handle(): int
{
$indexName = config('algolia.algolia_index');

// Dry-run never talks to Algolia, so it must not depend on credentials being configured.
if ($this->option('dry-run')) {
$this->info('[dry-run] would set attributesForFaceting on index: ' . $indexName);
$this->line(json_encode(self::ATTRIBUTES_FOR_FACETING, JSON_PRETTY_PRINT));
return self::SUCCESS;
}

$appId = config('algolia.algolia_app_id');
$apiKey = config('algolia.algolia_write_api_key');

if (!$appId || !$apiKey || !$indexName) {
$this->error('Missing ALGOLIA_APP_ID / ALGOLIA_WRITE_API_KEY / ALGOLIA_INDEX');
return self::FAILURE;
}

// Resolved lazily (only reached once credentials are confirmed present) so
// dry-run and the missing-credentials path never touch the container binding.
$syncer = new AlgoliaIndexSettingsSyncer($this->laravel->make(SearchClient::class));
$syncer->syncAttributesForFaceting($indexName, self::ATTRIBUTES_FOR_FACETING);

$this->info("Done: attributesForFaceting synced on index={$indexName}");

return self::SUCCESS;
}
}
16 changes: 15 additions & 1 deletion src/app/Packages/Domains/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,18 @@ AlgoliaAdapter
SearchResult (Ports)
↓
Dto
```
```

---

## Algolia Index Settings

`attributesForFaceting` is managed as code via `App\Console\Commands\AlgoliaSyncIndexSettings::ATTRIBUTES_FOR_FACETING`,
applied with `php artisan algolia:sync-index-settings` (use `--dry-run` to preview without writing).
Every attribute that `AlgoliaWorldHeritageSearchAdapter` filters or optional-filters on must be
listed there, or Algolia rejects the request.

All currently declared attributes are plain facets (facet counts enabled), none use `filterOnly()`.
`country_names_jp`, `region`, and `study_regions` are not used as filters by the Adapter today, so
they are candidates for `filterOnly()` to shrink the index — but this has not been changed yet,
since whether the frontend relies on their facet counts has not been confirmed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace App\Packages\Domains\WorldHeritage\Adapter;

use Algolia\AlgoliaSearch\Api\SearchClient;

class AlgoliaIndexSettingsSyncer
{
public function __construct(
private SearchClient $client,
) {}

public function syncAttributesForFaceting(string $indexName, array $attributesForFaceting): void
{
$res = $this->client->setSettings($indexName, [
'attributesForFaceting' => $attributesForFaceting,
]);

$taskId = $res['taskID'] ?? null;
if ($taskId !== null) {
$this->client->waitForTask($indexName, $taskId);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Packages\Domains\WorldHeritage\Ports\Dto\HeritageSearchResult;
use App\Packages\Domains\WorldHeritage\Ports\WorldHeritageSearchPort;
use App\Packages\Features\QueryUseCases\ListQuery\AlgoliaSearchListQuery;
use Illuminate\Support\Facades\Log;

class AlgoliaWorldHeritageSearchAdapter implements WorldHeritageSearchPort
{
Expand All @@ -30,30 +31,40 @@ public function search(
*/
$filters = [];

/**
* Optional filters: unlike $filters, these never exclude a record —
* they only boost the ranking of records that match. Used when a
* country has not been confirmed via ISO3, so an input that fails to
* resolve to a real country (e.g. "Jap") still falls back to plain
* full-text search instead of returning 0 results.
*/
$optionalFilters = [];

/**
* Country filtering strategy:
* - If ISO3 is explicitly provided, use strict ISO3 filtering and disable full-text search.
* - If ISO3 is explicitly provided, filter by state_party_codes:<ISO3>.
* This is a confirmed country, so it narrows results like any other filter.
* - Otherwise, attempt to treat the provided countryName as one of:
* - English country name (e.g. "Japan" / "japan")
* - Japanese name (e.g. "日本")
* - ISO3 (e.g. "JPN")
*
* In that case we build an OR-filter across relevant fields.
* This is unconfirmed, so it goes into $optionalFilters: matching
* records rank higher, but a non-matching countryName does not
* reduce the full-text search results to zero.
*
* Either way, the country signal never blanks out the keyword: like
* every other filter (region/category/year/criteria/is_endangered),
* it narrows/boosts alongside the keyword search rather than
* replacing it. A keyword combined with an explicit country param
* (e.g. keyword="temple" & country_iso3=JPN) must narrow by both.
*/
$queryString = $query->keyword ?? '';

if ($this->hasValue($query->countryIso3)) {
// Requirement: ISO3 -> query='' and filter by state_party_codes:<ISO3>
$filters[] = 'state_party_codes:' . $this->escapeToken($query->countryIso3);
$queryString = '';
} elseif ($this->hasValue($query->countryName)) {
$filters[] = $this->buildCountryOrFilter($query->countryName);
/**
* IMPORTANT:
* Do not blank out query here.
* If the input was not a real country (e.g. "japan" typo / random word),
* we still want full-text search to work rather than returning "top hits".
*/
$optionalFilters[] = $this->buildCountryOrFilter($query->countryName);
}

/**
Expand Down Expand Up @@ -102,13 +113,14 @@ public function search(

/**
* Guardrail:
* Never execute Algolia with query='' AND no filters,
* Never execute Algolia with query='' AND no filters AND no optional filters,
* otherwise you will get "top results" unrelated to the user input.
*/
$hasAnyFilter = $filters !== [];
$hasOptionalFilter = $optionalFilters !== [];
$hasQueryText = $this->hasValue($queryString);

if (!$hasAnyFilter && !$hasQueryText) {
if (!$hasAnyFilter && !$hasOptionalFilter && !$hasQueryText) {
// Prefer returning empty result rather than misleading "top hits".
return new HeritageSearchResult(
ids: [],
Expand All @@ -127,6 +139,7 @@ public function search(
'page' => $firstPage,
'hitsPerPage' => $perPage,
'filters' => $hasAnyFilter ? implode(' AND ', $filters) : null,
'optionalFilters' => $hasOptionalFilter ? $optionalFilters : null,
/**
* Only the id is ever read from the response (see below):
* the full record is re-fetched from the DB via findByIdsPreserveOrder.
Expand All @@ -140,11 +153,26 @@ public function search(

$hits = $response['hits'] ?? [];

$ids = array_values(array_filter(array_map(static function (array $h) {
return isset($h['id'])
? (int) $h['id']
: (isset($h['objectID']) ? (int) $h['objectID'] : null);
}, $hits)));
$ids = [];
foreach ($hits as $hit) {
$rawId = $hit['id'] ?? $hit['objectID'] ?? null;

if ($rawId === null) {
continue;
}

if (!is_numeric($rawId)) {
// Don't silently coerce a non-numeric id to 0 and drop it via
// array_filter: that would hide an Algolia/DB data inconsistency.
Log::warning('Algolia search hit has a non-numeric id', [
'id' => $hit['id'] ?? null,
'objectID' => $hit['objectID'] ?? null,
]);
continue;
}

$ids[] = (int) $rawId;
}

$nbHits = (int) ($response['nbHits'] ?? 0);
$nbPages = (int) ($response['nbPages'] ?? 0);
Expand All @@ -161,11 +189,14 @@ public function search(
}

/**
* Build an OR-filter that can match different country input shapes:
* Build an OR-expression that can match different country input shapes:
* - English: country (exact match)
* - Japanese: country_name_jp (exact match)
* - ISO3-like: state_party_codes:<ISO3>
*
* Used as an entry in $optionalFilters (not $filters): a non-match
* does not exclude a record, it just skips the ranking boost.
*
* Example output:
* (country:"Japan" OR country_name_jp:"日本" OR state_party_codes:JPN)
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

namespace App\Packages\Domains\WorldHeritage\Tests;

use Algolia\AlgoliaSearch\Api\SearchClient;
use App\Packages\Domains\WorldHeritage\Adapter\AlgoliaIndexSettingsSyncer;
use Mockery;
use PHPUnit\Framework\Attributes\DoesNotPerformAssertions;
use PHPUnit\Framework\TestCase;

class AlgoliaIndexSettingsSyncerTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}

#[DoesNotPerformAssertions]
public function test_waits_for_the_indexing_task_when_algolia_returns_a_task_id(): void
{
$client = Mockery::mock(SearchClient::class);
$client->shouldReceive('setSettings')
->once()
->with('world_heritages', ['attributesForFaceting' => ['category', 'country']])
->andReturn(['taskID' => 42]);
$client->shouldReceive('waitForTask')->once()->with('world_heritages', 42);

$syncer = new AlgoliaIndexSettingsSyncer($client);
$syncer->syncAttributesForFaceting('world_heritages', ['category', 'country']);
}

#[DoesNotPerformAssertions]
public function test_does_not_wait_when_algolia_returns_no_task_id(): void
{
$client = Mockery::mock(SearchClient::class);
$client->shouldReceive('setSettings')->once()->andReturn([]);
$client->shouldNotReceive('waitForTask');

$syncer = new AlgoliaIndexSettingsSyncer($client);
$syncer->syncAttributesForFaceting('world_heritages', ['category']);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

namespace App\Packages\Domains\WorldHeritage\Tests;

use Algolia\AlgoliaSearch\Api\SearchClient;
use App\Console\Commands\AlgoliaSyncIndexSettings;
use Illuminate\Support\Facades\Artisan;
use Mockery;
use Tests\TestCase;

class AlgoliaSyncIndexSettingsTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}

public function test_dry_run_prints_attributes_for_faceting_without_calling_algolia(): void
{
$exitCode = Artisan::call('algolia:sync-index-settings', ['--dry-run' => true]);

$this->assertSame(0, $exitCode);

$output = Artisan::output();
foreach (AlgoliaSyncIndexSettings::ATTRIBUTES_FOR_FACETING as $attribute) {
$this->assertStringContainsString($attribute, $output);
}
}

public function test_fails_when_write_credentials_are_missing(): void
{
config(['algolia.algolia_write_api_key' => null]);

$exitCode = Artisan::call('algolia:sync-index-settings');

$this->assertSame(1, $exitCode);
$this->assertStringContainsString('Missing', Artisan::output());
}

public function test_syncs_attributes_for_faceting_when_credentials_are_present(): void
{
config([
'algolia.algolia_app_id' => 'dummy-app',
'algolia.algolia_write_api_key' => 'dummy-key',
'algolia.algolia_index' => 'world_heritages',
]);

$client = Mockery::mock(SearchClient::class);
$client->shouldReceive('setSettings')
->once()
->with('world_heritages', ['attributesForFaceting' => AlgoliaSyncIndexSettings::ATTRIBUTES_FOR_FACETING])
->andReturn(['taskID' => 123]);
$client->shouldReceive('waitForTask')->once()->with('world_heritages', 123);

$this->app->instance(SearchClient::class, $client);

$exitCode = Artisan::call('algolia:sync-index-settings');

$this->assertSame(0, $exitCode);
$this->assertStringContainsString('Done', Artisan::output());
}

public function test_attributes_for_faceting_covers_every_attribute_the_adapter_filters_on(): void
{
// Every attribute AlgoliaWorldHeritageSearchAdapter uses in $filters
// or $optionalFilters must be declared here, or Algolia rejects the
// facet filter request.
$usedByAdapter = [
'state_party_codes',
'country',
'country_name_jp',
'study_region',
'category',
'criteria',
'is_endangered',
];

foreach ($usedByAdapter as $attribute) {
$this->assertContains($attribute, AlgoliaSyncIndexSettings::ATTRIBUTES_FOR_FACETING);
}
}
}
Loading
Loading