diff --git a/composer.json b/composer.json index 130c2520..caa766fc 100644 --- a/composer.json +++ b/composer.json @@ -43,6 +43,7 @@ "nette/tester": "^2.3.4", "nextras/dbal": "^4.0 || ^5.0", "nextras/orm": "^4.0 || ^5.0", + "opensearch-project/opensearch-php": "^2.6", "phpstan/phpstan": "^2.1", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-mockery": "^2.0", diff --git a/src/DataSource/OpenSearchDataSource.php b/src/DataSource/OpenSearchDataSource.php new file mode 100644 index 00000000..3f0611fa --- /dev/null +++ b/src/DataSource/OpenSearchDataSource.php @@ -0,0 +1,210 @@ +searchParamsBuilder = new SearchParamsBuilder($indexName, true); + + if ($rowFactory === null) { + $rowFactory = static fn (array $hit): array => $hit['_source']; + } + + $this->rowFactory = $rowFactory; + } + + public function getCount(): int + { + $searchResult = $this->client->search($this->searchParamsBuilder->buildParams()); + + if (!isset($searchResult['hits'])) { + throw new UnexpectedValueException(); + } + + $count = $this->client->count($this->searchParamsBuilder->buildParams()); + + return $count['count']; + } + + /** + * {@inheritDoc} + */ + public function getData(): array + { + $searchResult = $this->client->search($this->searchParamsBuilder->buildParams()); + + if (!isset($searchResult['hits'])) { + throw new UnexpectedValueException(); + } + + return array_map($this->rowFactory, $searchResult['hits']['hits']); + } + + /** + * {@inheritDoc} + */ + public function filterOne(array $condition): IDataSource + { + foreach ($condition as $value) { + $this->searchParamsBuilder->addIdsQuery($value); + } + + return $this; + } + + public function limit(int $offset, int $limit): IDataSource + { + $this->searchParamsBuilder->setFrom($offset); + $this->searchParamsBuilder->setSize($limit); + + return $this; + } + + public function applyFilterDate(FilterDate $filter): void + { + foreach ($filter->getCondition() as $column => $value) { + $timestampFrom = null; + $timestampTo = null; + + if ($value) { + try { + $dateFrom = DateTimeHelper::tryConvertToDateTime($value, [$filter->getPhpFormat()]); + $dateFrom->setTime(0, 0, 0); + + $timestampFrom = $dateFrom->getTimestamp(); + + $dateTo = DateTimeHelper::tryConvertToDateTime($value, [$filter->getPhpFormat()]); + $dateTo->setTime(23, 59, 59); + + $timestampTo = $dateTo->getTimestamp(); + + $this->searchParamsBuilder->addRangeQuery($column, $timestampFrom, $timestampTo); + } catch (DatagridDateTimeHelperException) { + // ignore the invalid filter value + } + } + } + } + + public function applyFilterDateRange(FilterDateRange $filter): void + { + foreach ($filter->getCondition() as $column => $values) { + $timestampFrom = null; + $timestampTo = null; + + if ($values['from']) { + try { + $dateFrom = DateTimeHelper::tryConvertToDateTime($values['from'], [$filter->getPhpFormat()]); + $dateFrom->setTime(0, 0, 0); + + $timestampFrom = $dateFrom->getTimestamp(); + } catch (DatagridDateTimeHelperException) { + // ignore the invalid filter value + } + } + + if ($values['to']) { + try { + $dateTo = DateTimeHelper::tryConvertToDateTime($values['to'], [$filter->getPhpFormat()]); + $dateTo->setTime(23, 59, 59); + + $timestampTo = $dateTo->getTimestamp(); + } catch (DatagridDateTimeHelperException) { + // ignore the invalid filter value + } + } + + if (is_int($timestampFrom) || is_int($timestampTo)) { + $this->searchParamsBuilder->addRangeQuery($column, $timestampFrom, $timestampTo); + } + } + } + + public function applyFilterRange(FilterRange $filter): void + { + foreach ($filter->getCondition() as $column => $value) { + $this->searchParamsBuilder->addRangeQuery($column, $value['from'] ?? null, $value['to'] ?? null); + } + } + + public function applyFilterText(FilterText $filter): void + { + foreach ($filter->getCondition() as $column => $value) { + $options = []; + if ($filter->isCaseInsensitive()) { + $options['case_insensitive'] = true; + } + + if ($filter->isExactSearch()) { + $this->searchParamsBuilder->addMatchQuery($column, $value); + } elseif ($filter->isWildCardSearch()) { + $this->searchParamsBuilder->addWildCardQuery($column, $value, $options); + } elseif ($filter->isTermSearch()) { + $this->searchParamsBuilder->addTermQuery($column, $value, $options); + } else { + $this->searchParamsBuilder->addPhrasePrefixQuery($column, $value); + } + } + } + + public function applyFilterMultiSelect(FilterMultiSelect $filter): void + { + foreach ($filter->getCondition() as $column => $values) { + $this->searchParamsBuilder->addBooleanMatchQuery($column, $values); + } + } + + public function applyFilterSelect(FilterSelect $filter): void + { + foreach ($filter->getCondition() as $column => $value) { + $this->searchParamsBuilder->addMatchQuery($column, $value); + } + } + + /** + * {@inheritDoc} + * + * @throws RuntimeException + */ + public function sort(Sorting $sorting): IDataSource + { + if (is_callable($sorting->getSortCallback())) { + throw new RuntimeException('No can do - not implemented yet'); + } + + foreach ($sorting->getSort() as $column => $order) { + $this->searchParamsBuilder->setSort( + [$column => ['order' => strtolower($order)]] + ); + } + + return $this; + } + + public function getDataSource(): SearchParamsBuilder + { + return $this->searchParamsBuilder; + } + +} diff --git a/src/DataSource/SearchParamsBuilder.php b/src/DataSource/SearchParamsBuilder.php index 8320c7b3..9e52bb34 100644 --- a/src/DataSource/SearchParamsBuilder.php +++ b/src/DataSource/SearchParamsBuilder.php @@ -13,6 +13,8 @@ final class SearchParamsBuilder private array $phrasePrefixQueries = []; + private array $wildCardQueries = []; + private array $matchQueries = []; private array $booleanMatchQueries = []; @@ -21,7 +23,9 @@ final class SearchParamsBuilder private array $idsQueries = []; - public function __construct(private string $indexName) + private array $termQueries = []; + + public function __construct(private string $indexName, private bool $isOpensearch = false) { } @@ -50,6 +54,16 @@ public function addIdsQuery(array $ids): void $this->idsQueries[] = $ids; } + public function addWildCardQuery(string $field, string $query, array $options = []): void + { + $this->wildCardQueries[] = [$field => [$query, $options]]; + } + + public function addTermQuery(string $field, string $query, array $options = []): void + { + $this->termQueries[] = [$field => [$query, $options]]; + } + public function setSort(array $sort): void { $this->sort = $sort; @@ -91,7 +105,9 @@ public function buildParams(): array && $this->matchQueries === [] && $this->booleanMatchQueries === [] && $this->rangeQueries === [] - && $this->idsQueries === []) { + && $this->idsQueries === [] + && $this->wildCardQueries === [] + && $this->termQueries === []) { return $return; } @@ -117,9 +133,45 @@ public function buildParams(): array foreach ($matchQuery as $field => $query) { $return['body']['query']['bool']['must'][] = [ 'match' => [ - $field => [ - 'query' => $query, - ], + $field => ['query' => $query], + ], + ]; + } + } + + foreach ($this->termQueries as $matchQuery) { + foreach ($matchQuery as $field => [$query, $options]) { + $fieldQueryParams = [ + 'value' => $query, + ]; + if ($this->isOpensearch && count($options) > 0) { + $fieldQueryParams = array_merge($fieldQueryParams, $options); + } + + $return['body']['query']['bool']['must'][] = [ + 'term' => [ + $field => $fieldQueryParams, + ], + ]; + } + } + + foreach ($this->wildCardQueries as $wildCardQuery) { + foreach ($wildCardQuery as $field => [$query, $options]) { + if (!(str_contains($query, '*') || str_contains($query, '?'))) { + $query .= '*'; + } + + $fieldQueryParams = [ + 'value' => $query, + ]; + if (count($options) > 0) { + $fieldQueryParams = array_merge($fieldQueryParams, $options); + } + + $return['body']['query']['bool']['must'][] = [ + 'wildcard' => [ + $field => $fieldQueryParams, ], ]; } diff --git a/src/Filter/FilterText.php b/src/Filter/FilterText.php index e5dd27fc..e4ee7d10 100644 --- a/src/Filter/FilterText.php +++ b/src/Filter/FilterText.php @@ -14,6 +14,12 @@ class FilterText extends Filter protected bool $exact = false; + protected bool $wildCard = false; + + protected bool $caseInsensitive = false; + + protected bool $term = false; + protected bool $splitWordsSearch = true; protected bool $conjunctionSearch = false; @@ -96,4 +102,40 @@ public function hasConjunctionSearch(): bool return $this->conjunctionSearch; } + public function setWildCard(bool $wildCard = true): self + { + $this->wildCard = $wildCard; + + return $this; + } + + public function isWildCardSearch(): bool + { + return $this->wildCard; + } + + public function setCaseInsensitive(bool $caseInsensitive = true): self + { + $this->caseInsensitive = $caseInsensitive; + + return $this; + } + + public function isCaseInsensitive(): bool + { + return $this->caseInsensitive; + } + + public function setTermSearch(bool $term = true): self + { + $this->term = $term; + + return $this; + } + + public function isTermSearch(): bool + { + return $this->term; + } + } diff --git a/tests/Cases/DataSources/SearchParamsBuilderTest.phpt b/tests/Cases/DataSources/SearchParamsBuilderTest.phpt index 52620af3..0e92b6ba 100644 --- a/tests/Cases/DataSources/SearchParamsBuilderTest.phpt +++ b/tests/Cases/DataSources/SearchParamsBuilderTest.phpt @@ -15,7 +15,7 @@ final class SearchParamsBuilderTest extends TestCase public function setUp(): void { - $this->searchParamsBuilder = new SearchParamsBuilder('users', 'user'); + $this->searchParamsBuilder = new SearchParamsBuilder('users'); } public function testEmptyQuery(): void @@ -279,6 +279,246 @@ final class SearchParamsBuilderTest extends TestCase ); } + public function testWildCardQuery(): void + { + $this->searchParamsBuilder->addWildCardQuery('name', 'john'); + + Assert::same( + [ + 'index' => 'users', + 'body' => [ + 'query' => [ + 'bool' => [ + 'must' => [ + [ + 'wildcard' => [ + 'name' => [ + 'value' => 'john*', + ], + ], + ], + ], + ], + ], + ], + ], + $this->searchParamsBuilder->buildParams() + ); + } + + public function testWildCardQuery2(): void + { + $this->searchParamsBuilder->addWildCardQuery('name', 'j?hn'); + + Assert::same( + [ + 'index' => 'users', + 'body' => [ + 'query' => [ + 'bool' => [ + 'must' => [ + [ + 'wildcard' => [ + 'name' => [ + 'value' => 'j?hn', + ], + ], + ], + ], + ], + ], + ], + ], + $this->searchParamsBuilder->buildParams() + ); + } + + public function testWildCardQuery3(): void + { + $this->searchParamsBuilder->addWildCardQuery('name', '*john'); + + Assert::same( + [ + 'index' => 'users', + 'body' => [ + 'query' => [ + 'bool' => [ + 'must' => [ + [ + 'wildcard' => [ + 'name' => [ + 'value' => '*john', + ], + ], + ], + ], + ], + ], + ], + ], + $this->searchParamsBuilder->buildParams() + ); + } + + public function testWildCardQueryCaseInsensitive(): void + { + $this->searchParamsBuilder->addWildCardQuery('name', 'john', ['case_insensitive' => true]); + + Assert::same( + [ + 'index' => 'users', + 'body' => [ + 'query' => [ + 'bool' => [ + 'must' => [ + [ + 'wildcard' => [ + 'name' => [ + 'value' => 'john*', + 'case_insensitive' => true, + ], + ], + ], + ], + ], + ], + ], + ], + $this->searchParamsBuilder->buildParams() + ); + } + + public function testAllTogetherWithWildCardAndTermSearch(): void + { + $this->searchParamsBuilder->setSort(['name' => ['order' => 'desc']]); + $this->searchParamsBuilder->setFrom(0); + $this->searchParamsBuilder->setSize(20); + $this->searchParamsBuilder->addPhrasePrefixQuery('name', 'john'); + $this->searchParamsBuilder->addMatchQuery('name', 'john'); + $this->searchParamsBuilder->addBooleanMatchQuery('status', ['active', 'disabled']); + $this->searchParamsBuilder->addRangeQuery('score', 8, 64); + $this->searchParamsBuilder->addIdsQuery([0, 1, 1, 2, 3, 5, 8]); + $this->searchParamsBuilder->addWildCardQuery('name', 'john', ['case_insensitive' => true]); + $this->searchParamsBuilder->addTermQuery('name', 'john', ['case_insensitive' => true]); + + Assert::same( + [ + 'index' => 'users', + 'body' => [ + 'sort' => ['name' => ['order' => 'desc']], + 'from' => 0, + 'size' => 20, + 'query' => [ + 'bool' => [ + 'must' => [ + [ + 'multi_match' => [ + 'query' => 'john', + 'type' => 'phrase_prefix', + 'fields' => ['name'], + ], + ], + [ + 'match' => ['name' => ['query' => 'john']], + ], + + [ + 'term' => [ + 'name' => [ + 'value' => 'john', + ], + ], + ], + [ + 'wildcard' => ['name' => ['value' => 'john*', 'case_insensitive' => true]], + ], + [ + 'bool' => [ + 'should' => [ + [ + [ + 'match' => ['status' => ['query' => 'active']], + ], + [ + 'match' => ['status' => ['query' => 'disabled']], + ], + ], + ], + ], + ], + [ + 'range' => ['score' => ['gte' => 8, 'lte' => 64]], + ], + [ + 'ids' => [ + 'values' => [0, 1, 1, 2, 3, 5, 8], + ], + ], + ], + ], + ], + ], + ], + $this->searchParamsBuilder->buildParams() + ); + } + + public function testTermQueryOpenSearch(): void + { + $searchBuilder = new SearchParamsBuilder('users', true); + $searchBuilder->addTermQuery('name', 'john', ['case_insensitive' => true]); + + Assert::same( + [ + 'index' => 'users', + 'body' => [ + 'query' => [ + 'bool' => [ + 'must' => [ + [ + 'term' => [ + 'name' => [ + 'value' => 'john', + 'case_insensitive' => true, + ], + ], + ], + ], + ], + ], + ], + ], + $searchBuilder->buildParams() + ); + } + + public function testTermQueryElasticSearch(): void + { + $this->searchParamsBuilder->addTermQuery('name', 'john', ['case_insensitive' => true]); + + Assert::same( + [ + 'index' => 'users', + 'body' => [ + 'query' => [ + 'bool' => [ + 'must' => [ + [ + 'term' => [ + 'name' => [ + 'value' => 'john', + ], + ], + ], + ], + ], + ], + ], + ], + $this->searchParamsBuilder->buildParams() + ); + } + }