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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log.

## [Unreleased]

- [PR-38](https://github.com/itk-dev/event-database-api/pull/38)
Extract SearchParamsBuilder from ElasticSearchIndex and unit-test the query DSL
- [PR-37](https://github.com/itk-dev/event-database-api/pull/37)
Upload test coverage to Codecov in CI
- [PR-36](https://github.com/itk-dev/event-database-api/pull/36)
Expand Down
124 changes: 2 additions & 122 deletions src/Service/ElasticSearch/ElasticSearchIndex.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
namespace App\Service\ElasticSearch;

use App\Exception\IndexException;
use App\Model\FilterType;
use App\Model\IndexName;
use App\Model\SearchResults;
use App\Service\IndexInterface;
use Elastic\Elasticsearch\Client;
Expand All @@ -18,6 +16,7 @@ class ElasticSearchIndex implements IndexInterface
{
public function __construct(
private readonly Client $client,
private readonly SearchParamsBuilder $paramsBuilder,
) {
}

Expand Down Expand Up @@ -113,7 +112,7 @@ private function getByCustomIdField(string $indexName, int|string $id, string $i

public function getAll(string $indexName, array $filters = [], int $from = 0, int $size = 10): SearchResults
{
$params = $this->buildParams($indexName, $filters, $from, $size);
$params = $this->paramsBuilder->buildParams($indexName, $filters, $from, $size);

try {
/** @var Elasticsearch $response */
Expand All @@ -132,79 +131,6 @@ public function getAll(string $indexName, array $filters = [], int $from = 0, in
);
}

/**
* Builds the parameters for the Elasticsearch search request.
*
* @param string $indexName
* The name of the index to search in
* @param array $filters
* An array of filters to apply to the search query
* @param int $from
* The starting offset for the search results
* @param int $size
* The maximum number of search results to return
*
* @return array
* The built parameters for the Elasticsearch search request
*/
private function buildParams(string $indexName, array $filters, int $from, int $size): array
{
$params = [
'index' => $indexName,
'body' => [
'query' => [
'match_all' => (object) [],
],
'size' => $size,
'from' => $from,
// @TODO: make a proper sort filter to allow client to set sort direction
'sort' => $this->getSort($indexName),
],
];

$body = $this->buildBody($filters);
if ([] !== $body) {
$params['body']['query'] = $body;
}

return $params;
}

/**
* Builds the body for Elasticsearch request using the given filters.
*
* @param array $filters
* The filters to be included in the body
*
* @return array
* The built body for Elasticsearch request
*/
private function buildBody(array $filters): array
{
$body = [];
$combined = (bool) count($filters[FilterType::Filters->value]);
foreach ($filters[FilterType::Filters->value] as $filter) {
if ($combined) {
if (!array_key_exists('bool', $body)) {
$body['bool'] = ['must' => []];
}
// Ensure that associative arrays and lists are not combined with keys "0","1" etc. in the final json.
// So we need to loop over lists to ensure keys are "reset" in the final body statement.
if (array_is_list($filter)) {
foreach ($filter as $val) {
$body['bool']['must'][] = $val;
}
} else {
$body['bool']['must'][] = $filter;
}
} else {
$body += $filter;
}
}

return $body;
}

/**
* Parses the response from Elasticsearch and returns it as an array.
*
Expand Down Expand Up @@ -254,50 +180,4 @@ private function getTotalHits(array $data): int
{
return $data['hits']['total']['value'] ?? 0;
}

/**
* Get the sorting configuration for a specific index.
*
* This method returns an array containing the sorting configuration based on the given index name.
* If the index name matches one of the predefined index names, a specific sorting configuration will be returned.
* Otherwise, an empty array will be returned indicating no sorting is required.
*
* @param string $indexName the name of the index
*
* @return array the sorting configuration
*/
private function getSort(string $indexName): array
{
// Translates a string or int into the corresponding Enum case, if any.
// If there is no matching case defined, it will return null.
$indexName = IndexName::tryFrom($indexName);

return match ($indexName) {
IndexName::Events => [
'_score',
[
'title.keyword' => [
'order' => 'asc',
],
],
],
IndexName::DailyOccurrences, IndexName::Occurrences => [
'start' => [
'order' => 'asc',
'format' => 'strict_date_optional_time_nanos',
],
],
IndexName::Tags, IndexName::Vocabularies,IndexName::Locations, IndexName::Organizations => [
'_score',
[
'name.keyword' => [
'order' => 'asc',
],
],
],
default => [
'_score',
],
};
}
}
100 changes: 100 additions & 0 deletions src/Service/ElasticSearch/SearchParamsBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

namespace App\Service\ElasticSearch;

use App\Model\FilterType;
use App\Model\IndexName;

/**
* Builds the Elasticsearch `search` request parameters (query + pagination +
* sort) from the compiled filter clauses.
*
* Extracted from ElasticSearchIndex as a pure, dependency-free service so the
* query-DSL construction can be unit-tested without a live Elasticsearch.
*/
class SearchParamsBuilder
{
/**
* @param array<string, array<int, mixed>> $filters compiled clauses keyed by FilterType
*
* @return array<string, mixed>
*/
public function buildParams(string $indexName, array $filters, int $from, int $size): array
{
$params = [
'index' => $indexName,
'body' => [
'query' => [
'match_all' => (object) [],
],
'size' => $size,
'from' => $from,
// @TODO: make a proper sort filter to allow client to set sort direction
'sort' => $this->buildSort($indexName),
],
];

$body = $this->buildBody($filters);
if ([] !== $body) {
$params['body']['query'] = $body;
}

return $params;
}

/**
* Combines the filter clauses into a single `bool`/`must` query.
*
* @param array<string, array<int, mixed>> $filters
*
* @return array<string, mixed>
*/
private function buildBody(array $filters): array
{
$body = [];
foreach ($filters[FilterType::Filters->value] as $filter) {
if (!array_key_exists('bool', $body)) {
$body['bool'] = ['must' => []];
}
// Ensure that associative arrays and lists are not combined with keys "0","1" etc. in the final json.
// So we need to loop over lists to ensure keys are "reset" in the final body statement.
if (array_is_list($filter)) {
foreach ($filter as $val) {
$body['bool']['must'][] = $val;
}
} else {
$body['bool']['must'][] = $filter;
}
}

return $body;
}

/**
* The per-index sort configuration.
*
* @return array<int|string, mixed>
*/
private function buildSort(string $indexName): array
{
return match (IndexName::tryFrom($indexName)) {
IndexName::Events => [
'_score',
['title.keyword' => ['order' => 'asc']],
],
IndexName::DailyOccurrences, IndexName::Occurrences => [
'start' => [
'order' => 'asc',
'format' => 'strict_date_optional_time_nanos',
],
],
IndexName::Tags, IndexName::Vocabularies, IndexName::Locations, IndexName::Organizations => [
'_score',
['name.keyword' => ['order' => 'asc']],
],
default => [
'_score',
],
};
}
}
37 changes: 37 additions & 0 deletions tests/Unit/Service/ElasticSearch/ElasticIndexExceptionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace App\Tests\Unit\Service\ElasticSearch;

use App\Service\ElasticSearch\ElasticIndexException;
use PHPUnit\Framework\TestCase;

/**
* Pins how ElasticSearch client errors are turned into human-readable messages.
* A 400 is parsed from the ES error JSON into "Type: reason"; anything else
* collapses to a generic "Bad Request".
*/
class ElasticIndexExceptionTest extends TestCase
{
// Goal: a 400 parse error is rendered as a readable "Parse exception: <reason>".
public function test400ParsesElasticErrorMessage(): void
{
$raw = '400 Bad Request: {"error":{"root_cause":[{"type":"parse_exception",'
.'"reason":"failed to parse date field [2004-02-12T15:19:21+0000]: [details]"}]}}';

$exception = new ElasticIndexException($raw, 400);

self::assertSame(
'Parse exception: failed to parse date field [2004-02-12T15:19:21+0000]',
$exception->getMessage(),
);
self::assertSame(400, $exception->getCode());
}

// Goal: non-400 codes collapse to a generic message (no ES JSON to parse).
public function testNon400CollapsesToBadRequest(): void
{
$exception = new ElasticIndexException('500 Internal Server Error: something', 500);

self::assertSame('Bad Request', $exception->getMessage());
}
}
Loading
Loading