diff --git a/packages/seal-algolia-adapter/src/AlgoliaSearcher.php b/packages/seal-algolia-adapter/src/AlgoliaSearcher.php index 69fd681a..e5f60802 100644 --- a/packages/seal-algolia-adapter/src/AlgoliaSearcher.php +++ b/packages/seal-algolia-adapter/src/AlgoliaSearcher.php @@ -161,9 +161,11 @@ private function recursiveResolveFilterConditions(Index $index, array $condition $filters = []; foreach ($conditions as $filter) { - if ($filter instanceof Condition\InCondition) { - $filter = $filter->createOrCondition(); - } + $filter = match (true) { + $filter instanceof Condition\InCondition => $filter->createOrCondition(), + $filter instanceof Condition\NotInCondition => $filter->createAndCondition(), + default => $filter, + }; match (true) { $filter instanceof Condition\IdentifierCondition => $filters[] = $index->getIdentifierField()->name . ':' . $this->escapeFilterValue($filter->identifier), diff --git a/packages/seal-elasticsearch-adapter/src/ElasticsearchSearcher.php b/packages/seal-elasticsearch-adapter/src/ElasticsearchSearcher.php index 534294b3..ac4fa5ff 100644 --- a/packages/seal-elasticsearch-adapter/src/ElasticsearchSearcher.php +++ b/packages/seal-elasticsearch-adapter/src/ElasticsearchSearcher.php @@ -196,7 +196,8 @@ private function recursiveResolveFilterConditions(array $indexes, array $filters $filter instanceof Condition\GreaterThanEqualCondition => $filterQueries[]['range'][$this->getFilterField($indexes, $filter->field)]['gte'] = $filter->value, $filter instanceof Condition\LessThanCondition => $filterQueries[]['range'][$this->getFilterField($indexes, $filter->field)]['lt'] = $filter->value, $filter instanceof Condition\LessThanEqualCondition => $filterQueries[]['range'][$this->getFilterField($indexes, $filter->field)]['lte'] = $filter->value, - $filter instanceof Condition\InCondition => $filterQueries[]['terms'][$this->getFilterField($indexes, $filter->field)] = $filter->values, + $filter instanceof Condition\InCondition, => $filterQueries[]['terms'][$this->getFilterField($indexes, $filter->field)] = $filter->values, + $filter instanceof Condition\NotInCondition => $filterQueries[]['bool']['must_not']['terms'][$this->getFilterField($indexes, $filter->field)] = $filter->values, $filter instanceof Condition\GeoDistanceCondition => $filterQueries[]['geo_distance'] = [ 'distance' => $filter->distance, $this->getFilterField($indexes, $filter->field) => [ diff --git a/packages/seal-loupe-adapter/src/LoupeSearcher.php b/packages/seal-loupe-adapter/src/LoupeSearcher.php index 75a62460..1696d4d9 100644 --- a/packages/seal-loupe-adapter/src/LoupeSearcher.php +++ b/packages/seal-loupe-adapter/src/LoupeSearcher.php @@ -151,6 +151,7 @@ private function recursiveResolveFilterConditions(Index $index, array $condition $filter instanceof Condition\LessThanCondition => $filters[] = $this->loupeHelper->formatField($filter->field) . ' < ' . $this->escapeFilterValue($filter->value), $filter instanceof Condition\LessThanEqualCondition => $filters[] = $this->loupeHelper->formatField($filter->field) . ' <= ' . $this->escapeFilterValue($filter->value), $filter instanceof Condition\InCondition => $filters[] = $this->loupeHelper->formatField($filter->field) . ' IN (' . \implode(', ', \array_map(fn ($value) => $this->escapeFilterValue($value), $filter->values)) . ')', + $filter instanceof Condition\NotInCondition => $filters[] = $this->loupeHelper->formatField($filter->field) . ' NOT IN (' . \implode(', ', \array_map(fn ($value) => $this->escapeFilterValue($value), $filter->values)) . ')', $filter instanceof Condition\GeoDistanceCondition => $filters[] = \sprintf( '_geoRadius(%s, %s, %s, %s)', $this->loupeHelper->formatField($filter->field), diff --git a/packages/seal-meilisearch-adapter/src/MeilisearchSearcher.php b/packages/seal-meilisearch-adapter/src/MeilisearchSearcher.php index a7135c02..7c9dd26e 100644 --- a/packages/seal-meilisearch-adapter/src/MeilisearchSearcher.php +++ b/packages/seal-meilisearch-adapter/src/MeilisearchSearcher.php @@ -126,6 +126,14 @@ private function escapeFilterValue(string|int|float|bool $value): string }; } + private function escapeArrayFilterValues(array $value): string + { + return implode( + ', ', + array_map([$this, 'escapeFilterValue'], $value) + ); + } + /** * @param object[] $conditions */ @@ -143,6 +151,7 @@ private function recursiveResolveFilterConditions(Index $index, array $condition $filter instanceof Condition\SearchCondition => $query = $filter->query, $filter instanceof Condition\EqualCondition => $filters[] = $filter->field . ' = ' . $this->escapeFilterValue($filter->value), $filter instanceof Condition\NotEqualCondition => $filters[] = $filter->field . ' != ' . $this->escapeFilterValue($filter->value), + $filter instanceof Condition\NotInCondition => $filters[] = $filter->field . ' NOT IN [' . $this->escapeArrayFilterValues($filter->values) . ']', $filter instanceof Condition\GreaterThanCondition => $filters[] = $filter->field . ' > ' . $this->escapeFilterValue($filter->value), $filter instanceof Condition\GreaterThanEqualCondition => $filters[] = $filter->field . ' >= ' . $this->escapeFilterValue($filter->value), $filter instanceof Condition\LessThanCondition => $filters[] = $filter->field . ' < ' . $this->escapeFilterValue($filter->value), diff --git a/packages/seal-memory-adapter/src/MemorySearcher.php b/packages/seal-memory-adapter/src/MemorySearcher.php index a857ec7f..ac976169 100644 --- a/packages/seal-memory-adapter/src/MemorySearcher.php +++ b/packages/seal-memory-adapter/src/MemorySearcher.php @@ -125,6 +125,16 @@ private function filterDocuments(Index $index, array $documents, object $filter) if ([] === \array_intersect($filter->values, $values)) { continue; } + } elseif ($filter instanceof Condition\NotInCondition) { + if (\str_contains($filter->field, '.')) { + throw new \RuntimeException('Nested fields are not supported yet.'); + } + + $values = (array) ($document[$filter->field] ?? []); + + if ([] !== \array_intersect($filter->values, $values)) { + continue; + } } elseif ($filter instanceof Condition\NotEqualCondition) { if (\str_contains($filter->field, '.')) { throw new \RuntimeException('Nested fields are not supported yet.'); diff --git a/packages/seal-opensearch-adapter/src/OpensearchSearcher.php b/packages/seal-opensearch-adapter/src/OpensearchSearcher.php index f52a37db..796cc0be 100644 --- a/packages/seal-opensearch-adapter/src/OpensearchSearcher.php +++ b/packages/seal-opensearch-adapter/src/OpensearchSearcher.php @@ -174,6 +174,7 @@ private function recursiveResolveFilterConditions(array $indexes, array $filters $filter instanceof Condition\LessThanCondition => $filterQueries[]['range'][$this->getFilterField($indexes, $filter->field)]['lt'] = $filter->value, $filter instanceof Condition\LessThanEqualCondition => $filterQueries[]['range'][$this->getFilterField($indexes, $filter->field)]['lte'] = $filter->value, $filter instanceof Condition\InCondition => $filterQueries[]['terms'][$this->getFilterField($indexes, $filter->field)] = $filter->values, + $filter instanceof Condition\NotInCondition => $filterQueries[]['bool']['must_not']['terms'][$this->getFilterField($indexes, $filter->field)] = $filter->values, $filter instanceof Condition\GeoDistanceCondition => $filterQueries[]['geo_distance'] = [ 'distance' => $filter->distance, $this->getFilterField($indexes, $filter->field) => [ diff --git a/packages/seal-redisearch-adapter/src/RediSearchSearcher.php b/packages/seal-redisearch-adapter/src/RediSearchSearcher.php index 7b0de08a..ac51e539 100644 --- a/packages/seal-redisearch-adapter/src/RediSearchSearcher.php +++ b/packages/seal-redisearch-adapter/src/RediSearchSearcher.php @@ -214,9 +214,11 @@ private function recursiveResolveFilterConditions(Index $index, array $condition $filters = []; foreach ($conditions as $filter) { - if ($filter instanceof Condition\InCondition) { - $filter = $filter->createOrCondition(); - } + $filter = match (true) { + $filter instanceof Condition\InCondition => $filter->createOrCondition(), + $filter instanceof Condition\NotInCondition => $filter->createAndCondition(), + default => $filter, + }; match (true) { $filter instanceof Condition\SearchCondition => $filters[] = '%%' . \implode('%% ', \explode(' ', $this->escapeFilterValue($filter->query))) . '%%', // levenshtein of 2 per word diff --git a/packages/seal-solr-adapter/src/SolrSearcher.php b/packages/seal-solr-adapter/src/SolrSearcher.php index 99ec42bf..51624ace 100644 --- a/packages/seal-solr-adapter/src/SolrSearcher.php +++ b/packages/seal-solr-adapter/src/SolrSearcher.php @@ -180,9 +180,11 @@ private function recursiveResolveFilterConditions(Index $index, array $condition $filters = []; foreach ($conditions as $filter) { - if ($filter instanceof Condition\InCondition) { - $filter = $filter->createOrCondition(); - } + $filter = match (true) { + $filter instanceof Condition\InCondition => $filter->createOrCondition(), + $filter instanceof Condition\NotInCondition => $filter->createAndCondition(), + default => $filter, + }; match (true) { $filter instanceof Condition\SearchCondition => $queryText = $filter->query, diff --git a/packages/seal-typesense-adapter/src/TypesenseSearcher.php b/packages/seal-typesense-adapter/src/TypesenseSearcher.php index d7068368..4957fee8 100644 --- a/packages/seal-typesense-adapter/src/TypesenseSearcher.php +++ b/packages/seal-typesense-adapter/src/TypesenseSearcher.php @@ -143,9 +143,11 @@ private function recursiveResolveFilterConditions(Index $index, array $condition $filters = []; foreach ($conditions as $filter) { - if ($filter instanceof Condition\InCondition) { - $filter = $filter->createOrCondition(); - } + $filter = match (true) { + $filter instanceof Condition\InCondition => $filter->createOrCondition(), + $filter instanceof Condition\NotInCondition => $filter->createAndCondition(), + default => $filter, + }; match (true) { $filter instanceof Condition\IdentifierCondition => $filters[] = 'id:=' . $this->escapeFilterValue($filter->identifier), diff --git a/packages/seal/src/Search/Condition/NotInCondition.php b/packages/seal/src/Search/Condition/NotInCondition.php new file mode 100644 index 00000000..5a2bc6d5 --- /dev/null +++ b/packages/seal/src/Search/Condition/NotInCondition.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Schranz\Search\SEAL\Search\Condition; + +class NotInCondition +{ + /** + * @param list $values + */ + public function __construct( + public readonly string $field, + public readonly array $values, + ) { + } + + /** + * @internal This method is for internal use and should not be called from outside. + * + * Some search engines do not support the `NOT IN` operator, so we need to convert it to an `AND` condition. + */ + public function createAndCondition(): AndCondition + { + /** @var array $conditions */ + $conditions = []; + foreach ($this->values as $value) { + $conditions[] = new NotEqualCondition($this->field, $value); + } + + return new AndCondition(...$conditions); + } +} diff --git a/packages/seal/src/Testing/AbstractSearcherTestCase.php b/packages/seal/src/Testing/AbstractSearcherTestCase.php index f02c0e14..93146924 100644 --- a/packages/seal/src/Testing/AbstractSearcherTestCase.php +++ b/packages/seal/src/Testing/AbstractSearcherTestCase.php @@ -919,6 +919,53 @@ public function testInCondition(): void } } + public function testNotInCondition(): void + { + $documents = TestingHelper::createComplexFixtures(); + + $schema = self::getSchema(); + + foreach ($documents as $document) { + self::$taskHelper->tasks[] = self::$indexer->save( + $schema->indexes[TestingHelper::INDEX_COMPLEX], + $document, + ['return_slow_promise_result' => true], + ); + } + self::$taskHelper->waitForAll(); + + $search = new SearchBuilder($schema, self::$searcher); + $search->addIndex(TestingHelper::INDEX_COMPLEX); + $search->addFilter(new Condition\NotInCondition('tags', ['UI'])); + + $expectedDocumentsVariantA = [ + $documents[2], + $documents[3], + ]; + + $expectedDocumentsVariantB = [ + $documents[3], + $documents[2], + ]; + + $loadedDocuments = [...$search->getResult()]; + $this->assertCount(2, $loadedDocuments); + + $this->assertTrue( + $expectedDocumentsVariantA === $loadedDocuments + || $expectedDocumentsVariantB === $loadedDocuments, + 'Not correct documents where found.', + ); + + foreach ($documents as $document) { + self::$taskHelper->tasks[] = self::$indexer->delete( + $schema->indexes[TestingHelper::INDEX_COMPLEX], + $document['uuid'], + ['return_slow_promise_result' => true], + ); + } + } + public function testSortByAsc(): void { $documents = TestingHelper::createComplexFixtures();