diff --git a/docs/search-and-filters/index.rst b/docs/search-and-filters/index.rst index b353b39c..0eadff89 100644 --- a/docs/search-and-filters/index.rst +++ b/docs/search-and-filters/index.rst @@ -204,9 +204,30 @@ The ``GeoDistanceCondition`` is used to filter results within a radius by specif The field is required to be marked as ``filterable`` in the index configuration. +GeoBoundingBoxCondition +~~~~~~~~~~~~~~~~~~~~~~~ + +The ``GeoBoundingBoxCondition`` is used to filter results within a bounding box by specifying a min latitude, min longitude, max latitude and max longitude. + +.. code-block:: php + + engine->createSearchBuilder() + ->addIndex('restaurants') + ->addFilter(new Condition\GeoBoundingBoxCondition('location', 45.494181, 9.214024, 45.449484, 9.179175)) + ->getResult(); + The field is required to be marked as ``filterable`` in the index configuration. +.. note:: + + The ``GeoBoundingBoxCondition`` is currently not supported by ``Redisearch`` adapter. + See `this Github Issue `__ for more information. + Filter on Objects and Typed Fields ---------------------------------- diff --git a/packages/seal-algolia-adapter/src/AlgoliaSearcher.php b/packages/seal-algolia-adapter/src/AlgoliaSearcher.php index 918f2b62..fcbfba26 100644 --- a/packages/seal-algolia-adapter/src/AlgoliaSearcher.php +++ b/packages/seal-algolia-adapter/src/AlgoliaSearcher.php @@ -106,6 +106,9 @@ public function search(Search $search): Result ), 'aroundRadius' => $filter->distance, ], + $filter instanceof Condition\GeoBoundingBoxCondition => $geoFilters = [ + 'insideBoundingBox' => [[$filter->northLatitude, $filter->westLongitude, $filter->southLatitude, $filter->eastLongitude]], + ], default => throw new \LogicException($filter::class . ' filter not implemented.'), }; } diff --git a/packages/seal-elasticsearch-adapter/src/ElasticsearchSearcher.php b/packages/seal-elasticsearch-adapter/src/ElasticsearchSearcher.php index 03e9317a..cc51b762 100644 --- a/packages/seal-elasticsearch-adapter/src/ElasticsearchSearcher.php +++ b/packages/seal-elasticsearch-adapter/src/ElasticsearchSearcher.php @@ -95,11 +95,21 @@ public function search(Search $search): Result $filter instanceof Condition\LessThanEqualCondition => $query['bool']['filter'][]['range'][$this->getFilterField($search->indexes, $filter->field)]['lte'] = $filter->value, $filter instanceof Condition\GeoDistanceCondition => $query['bool']['filter'][]['geo_distance'] = [ 'distance' => $filter->distance, - $filter->field => [ + $this->getFilterField($search->indexes, $filter->field) => [ 'lat' => $filter->latitude, 'lon' => $filter->longitude, ], ], + $filter instanceof Condition\GeoBoundingBoxCondition => $query['bool']['filter']['geo_bounding_box'][$this->getFilterField($search->indexes, $filter->field)] = [ + 'top_left' => [ + 'lat' => $filter->northLatitude, + 'lon' => $filter->westLongitude, + ], + 'bottom_right' => [ + 'lat' => $filter->southLatitude, + 'lon' => $filter->eastLongitude, + ], + ], default => throw new \LogicException($filter::class . ' filter not implemented.'), }; } diff --git a/packages/seal-loupe-adapter/src/LoupeSearcher.php b/packages/seal-loupe-adapter/src/LoupeSearcher.php index 3f36fc2e..247a16ba 100644 --- a/packages/seal-loupe-adapter/src/LoupeSearcher.php +++ b/packages/seal-loupe-adapter/src/LoupeSearcher.php @@ -90,9 +90,17 @@ public function search(Search $search): Result $filter instanceof Condition\GeoDistanceCondition => $filters[] = \sprintf( '_geoRadius(%s, %s, %s, %s)', $this->loupeHelper->formatField($filter->field), - $this->escapeFilterValue($filter->latitude), - $this->escapeFilterValue($filter->longitude), - $this->escapeFilterValue($filter->distance), + $filter->latitude, + $filter->longitude, + $filter->distance, + ), + $filter instanceof Condition\GeoBoundingBoxCondition => $filters[] = \sprintf( + '_geoBoundingBox(%s, %s, %s, %s, %s)', + $this->loupeHelper->formatField($filter->field), + $filter->northLatitude, + $filter->eastLongitude, + $filter->southLatitude, + $filter->westLongitude, ), default => throw new \LogicException($filter::class . ' filter not implemented.'), }; diff --git a/packages/seal-meilisearch-adapter/src/MeilisearchSearcher.php b/packages/seal-meilisearch-adapter/src/MeilisearchSearcher.php index 9b4657e4..45309403 100644 --- a/packages/seal-meilisearch-adapter/src/MeilisearchSearcher.php +++ b/packages/seal-meilisearch-adapter/src/MeilisearchSearcher.php @@ -88,9 +88,16 @@ public function search(Search $search): Result $filter instanceof Condition\LessThanEqualCondition => $filters[] = $filter->field . ' <= ' . $this->escapeFilterValue($filter->value), $filter instanceof Condition\GeoDistanceCondition => $filters[] = \sprintf( '_geoRadius(%s, %s, %s)', - $this->escapeFilterValue($filter->latitude), - $this->escapeFilterValue($filter->longitude), - $this->escapeFilterValue($filter->distance), + $filter->latitude, + $filter->longitude, + $filter->distance, + ), + $filter instanceof Condition\GeoBoundingBoxCondition => $filters[] = \sprintf( + '_geoBoundingBox([%s, %s], [%s, %s])', + $filter->northLatitude, + $filter->eastLongitude, + $filter->southLatitude, + $filter->westLongitude, ), default => throw new \LogicException($filter::class . ' filter not implemented.'), }; diff --git a/packages/seal-memory-adapter/src/MemorySearcher.php b/packages/seal-memory-adapter/src/MemorySearcher.php index 6f86c529..dcd2cbb6 100644 --- a/packages/seal-memory-adapter/src/MemorySearcher.php +++ b/packages/seal-memory-adapter/src/MemorySearcher.php @@ -179,6 +179,42 @@ public function search(Search $search): Result } } + if (false === $hasMatchingValue) { + continue 2; + } + } elseif ($filter instanceof Condition\GeoBoundingBoxCondition) { + if (\str_contains($filter->field, '.')) { + throw new \RuntimeException('Nested fields are not supported yet.'); + } + + $values = (array) ($document[$filter->field] ?? []); + if (isset($values['latitude'])) { + $values = [$values]; + } + + $hasMatchingValue = false; + foreach ($values as $value) { + if (!\is_array($value) + || !isset($value['latitude']) + || !isset($value['longitude']) + ) { + continue; + } + + $isInsideBox = $this->coordinatesInsideBox( + $value['latitude'], + $value['longitude'], + $filter->northLatitude, + $filter->eastLongitude, + $filter->southLatitude, + $filter->westLongitude, + ); + + if ($isInsideBox) { + $hasMatchingValue = true; + } + } + if (false === $hasMatchingValue) { continue 2; } @@ -216,6 +252,27 @@ public function search(Search $search): Result ); } + /** + * Returns true or false if coordinates are inside the box. + */ + private function coordinatesInsideBox( + float $latitude, + float $longitude, + float $northLatitude, + float $eastLongitude, + float $southLatitude, + float $westLongitude, + ): bool { + // Check if the latitude is between the north and south boundaries + $isWithinLatitude = $latitude <= $northLatitude && $latitude >= $southLatitude; + + // Check if the longitude is between the west and east boundaries + $isWithinLongitude = $longitude >= $westLongitude && $longitude <= $eastLongitude; + + // The point is inside the bounding box if both conditions are true + return $isWithinLatitude && $isWithinLongitude; + } + /** * Returns a distance in meters. */ diff --git a/packages/seal-opensearch-adapter/src/OpensearchSearcher.php b/packages/seal-opensearch-adapter/src/OpensearchSearcher.php index 0acc16a5..dcfd6353 100644 --- a/packages/seal-opensearch-adapter/src/OpensearchSearcher.php +++ b/packages/seal-opensearch-adapter/src/OpensearchSearcher.php @@ -90,6 +90,16 @@ public function search(Search $search): Result 'lon' => $filter->longitude, ], ], + $filter instanceof Condition\GeoBoundingBoxCondition => $query['bool']['filter']['geo_bounding_box'][$this->getFilterField($search->indexes, $filter->field)] = [ + 'top_left' => [ + 'lat' => $filter->northLatitude, + 'lon' => $filter->westLongitude, + ], + 'bottom_right' => [ + 'lat' => $filter->southLatitude, + 'lon' => $filter->eastLongitude, + ], + ], default => throw new \LogicException($filter::class . ' filter not implemented.'), }; } diff --git a/packages/seal-redisearch-adapter/src/RediSearchSearcher.php b/packages/seal-redisearch-adapter/src/RediSearchSearcher.php index 9b77c824..065c270b 100644 --- a/packages/seal-redisearch-adapter/src/RediSearchSearcher.php +++ b/packages/seal-redisearch-adapter/src/RediSearchSearcher.php @@ -79,7 +79,8 @@ public function search(Search $search): Result $index = $search->indexes[\array_key_first($search->indexes)]; $filters = []; - foreach ($search->filters as $filter) { + $parameters = []; + foreach ($search->filters as $key => $filter) { match (true) { $filter instanceof Condition\SearchCondition => $filters[] = '%%' . \implode('%% ', \explode(' ', $this->escapeFilterValue($filter->query))) . '%%', // levenshtein of 2 per word $filter instanceof Condition\IdentifierCondition => $filters[] = '@' . $index->getIdentifierField()->name . ':{' . $this->escapeFilterValue($filter->identifier) . '}', @@ -96,6 +97,26 @@ public function search(Search $search): Result $filter->latitude, ($filter->distance / 1000) . ' km', ), + $filter instanceof Condition\GeoBoundingBoxCondition => throw new \RuntimeException('Not supported by RediSearch: https://github.com/RediSearch/RediSearch/issues/680 or https://github.com/RediSearch/RediSearch/issues/5032'), + /* Keep here for future implementation: + $filter instanceof Condition\GeoBoundingBoxCondition => ($filters[] = \sprintf( + '@%s:[WITHIN $filter_%s]', + $this->getFilterField($search->indexes, $filter->field), + $key, + )) && ($parameters['filter_' . $key] = \sprintf( + 'POLYGON((%s %s, %s %s, %s %s, %s %s, %s %s))', + $filter->westLongitude, + $filter->northLatitude, + $filter->westLongitude, + $filter->southLatitude, + $filter->eastLongitude, + $filter->southLatitude, + $filter->eastLongitude, + $filter->northLatitude, + $filter->westLongitude, + $filter->northLatitude, + )), + */ default => throw new \LogicException($filter::class . ' filter not implemented.'), }; } @@ -118,6 +139,15 @@ public function search(Search $search): Result $arguments[] = ($search->limit ?: 10); } + if ([] !== $parameters) { // @phpstan-ignore-line + $arguments[] = 'PARAMS'; + $arguments[] = \count($parameters) * 2; + foreach ($parameters as $key => $value) { + $arguments[] = $key; + $arguments[] = $value; + } + } + $arguments[] = 'DIALECT'; $arguments[] = '3'; diff --git a/packages/seal-redisearch-adapter/tests/RediSearchSearcherTest.php b/packages/seal-redisearch-adapter/tests/RediSearchSearcherTest.php index 978380ca..737ccd16 100644 --- a/packages/seal-redisearch-adapter/tests/RediSearchSearcherTest.php +++ b/packages/seal-redisearch-adapter/tests/RediSearchSearcherTest.php @@ -33,4 +33,12 @@ public function testFindMultipleIndexes(): void { $this->markTestSkipped('Not supported by RediSearch: https://github.com/schranz-search/schranz-search/issues/93'); } + + /** + * @doesNotPerformAssertions + */ + public function testGeoBoundingBoxCondition(): void + { + $this->markTestSkipped('Not supported by RediSearch: https://github.com/RediSearch/RediSearch/issues/680 or https://github.com/RediSearch/RediSearch/issues/5032'); + } } diff --git a/packages/seal-solr-adapter/src/SolrSearcher.php b/packages/seal-solr-adapter/src/SolrSearcher.php index 060fdeba..d4e628b3 100644 --- a/packages/seal-solr-adapter/src/SolrSearcher.php +++ b/packages/seal-solr-adapter/src/SolrSearcher.php @@ -102,6 +102,14 @@ public function search(Search $search): Result $filter->longitude, $filter->distance / 1000, // Convert meters to kilometers ), + $filter instanceof Condition\GeoBoundingBoxCondition => $filters[] = \sprintf( + '%s:[%s,%s TO %s,%s]', // docs: https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=120723285#SolrAdaptersForLuceneSpatial4-Search + $this->getFilterField($search->indexes, $filter->field), + $filter->southLatitude, + $filter->westLongitude, + $filter->northLatitude, + $filter->eastLongitude, + ), default => throw new \LogicException($filter::class . ' filter not implemented.'), }; } diff --git a/packages/seal-typesense-adapter/src/TypesenseSearcher.php b/packages/seal-typesense-adapter/src/TypesenseSearcher.php index 9d665fd9..ce585a09 100644 --- a/packages/seal-typesense-adapter/src/TypesenseSearcher.php +++ b/packages/seal-typesense-adapter/src/TypesenseSearcher.php @@ -88,9 +88,22 @@ public function search(Search $search): Result $filter instanceof Condition\GeoDistanceCondition => $filters[] = \sprintf( '%s:(%s, %s, %s)', $filter->field, - $this->escapeFilterValue($filter->latitude), - $this->escapeFilterValue($filter->longitude), - $this->escapeFilterValue($filter->distance / 1000) . ' km', // convert to km + $filter->latitude, + $filter->longitude, + ($filter->distance / 1000) . ' km', // convert to km + ), + $filter instanceof Condition\GeoBoundingBoxCondition => $filters[] = \sprintf( + '%s:(%s, %s, %s, %s, %s, %s, %s, %s)', + $filter->field, + // TODO recheck if polygon is bigger as half of the earth if it not accidentally switches + $filter->northLatitude, + $filter->eastLongitude, + $filter->southLatitude, + $filter->eastLongitude, + $filter->southLatitude, + $filter->westLongitude, + $filter->northLatitude, + $filter->westLongitude, ), default => throw new \LogicException($filter::class . ' filter not implemented.'), }; diff --git a/packages/seal/src/Search/Condition/GeoBoundingBoxCondition.php b/packages/seal/src/Search/Condition/GeoBoundingBoxCondition.php new file mode 100644 index 00000000..023e7548 --- /dev/null +++ b/packages/seal/src/Search/Condition/GeoBoundingBoxCondition.php @@ -0,0 +1,32 @@ + + * + * 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 GeoBoundingBoxCondition +{ + /** + * The order may first be unusally, but it is the same as in common JS libraries like. + * + * @see https://docs.mapbox.com/help/glossary/bounding-box/ + * @see https://developers.google.com/maps/documentation/javascript/reference/coordinates#LatLngBounds + */ + public function __construct( + public readonly string $field, + public readonly float $northLatitude, // top + public readonly float $eastLongitude, // right + public readonly float $southLatitude, // bottom + public readonly float $westLongitude, // left + ) { + } +} diff --git a/packages/seal/src/Testing/AbstractSearcherTestCase.php b/packages/seal/src/Testing/AbstractSearcherTestCase.php index a82ebfa7..780d2942 100644 --- a/packages/seal/src/Testing/AbstractSearcherTestCase.php +++ b/packages/seal/src/Testing/AbstractSearcherTestCase.php @@ -752,6 +752,87 @@ public function testGeoDistanceCondition(): void } } + public function testGeoBoundingBoxCondition(): 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\GeoBoundingBoxCondition( + 'location', + // Dublin - Athen + 53.3498, // top + 23.7275, // right + 37.9838, // bottom + -6.2603, // left + )); + + $loadedDocuments = [...$search->getResult()]; + $this->assertGreaterThan(1, \count($loadedDocuments)); + + foreach ($loadedDocuments as $loadedDocument) { + $this->assertNotNull( + $loadedDocument['location'] ?? null, + 'Expected only documents with location document "' . $loadedDocument['uuid'] . '" without location returned.', + ); + $this->assertIsArray($loadedDocument['location']); + + $latitude = $loadedDocument['location']['latitude'] ?? null; + $longitude = $loadedDocument['location']['longitude'] ?? null; + + $this->assertNotNull( + $latitude, + 'Expected only documents with location document "' . $loadedDocument['uuid'] . '" without location latitude returned.', + ); + + $this->assertNotNull( + $longitude, + 'Expected only documents with location document "' . $loadedDocument['uuid'] . '" without location latitude returned.', + ); + + $isInBoxFunction = function( + float $latitude, + float $longitude, + float $northLatitude, + float $eastLongitude, + float $southLatitude, + float $westLongitude, + ): bool { + // Check if the latitude is between the north and south boundaries + $isWithinLatitude = $latitude <= $northLatitude && $latitude >= $southLatitude; + + // Check if the longitude is between the west and east boundaries + $isWithinLongitude = $longitude >= $westLongitude && $longitude <= $eastLongitude; + + // The point is inside the bounding box if both conditions are true + return $isWithinLatitude && $isWithinLongitude; + }; + + // TODO: Fix this test + $isInBox = $isInBoxFunction($latitude, $longitude, 53.3498, 23.7275,37.9838, -6.2603); + $this->assertTrue($isInBox, 'Document "' . $loadedDocument['uuid'] . '" is not in the box.'); + } + + foreach ($documents as $document) { + self::$taskHelper->tasks[] = self::$indexer->delete( + $schema->indexes[TestingHelper::INDEX_COMPLEX], + $document['uuid'], + ['return_slow_promise_result' => true], + ); + } + } + public function testLessThanEqualConditionMultiValue(): void { $documents = TestingHelper::createComplexFixtures();