Skip to content

Commit

Permalink
Add GeoBoundingBox filter (#84)
Browse files Browse the repository at this point in the history
Co-authored-by: Yanick Witschi <[email protected]>
  • Loading branch information
alexander-schranz and Toflar authored Sep 18, 2024
1 parent 4a7fb8b commit 54c0548
Show file tree
Hide file tree
Showing 7 changed files with 318 additions and 28 deletions.
50 changes: 49 additions & 1 deletion docs/searching.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,4 +282,52 @@ $results = [
```

[Config]: configuration.md
[Restaurant_Fixture]: ./../tests/Functional/IndexData/restaurants.json
[Restaurant_Fixture]: ./../tests/Functional/IndexData/restaurants.json

Additional to a query based on distance we can also search for locations inside a bounding box.
In this example we have 4 documents and 3 with geo coordinates (New York, London, Vienna).
We create a bounding box filter which spans from Dublin to Athens which then only matches our documents in London and Vienna.

Keep in mind that the order of the arguments is important.
The `_geoBoundingBox` expects `attributeName`, `north` (top), `east` (right), `south` (bottom), `west` (left).
In this specific example, `top` is the latitude of Dublin,`right` is the longitude of Athens,`bottom` is the latitude of Athens and`left` equals Dublin's longitude.

```php
$searchParameters = SearchParameters::create()
->withAttributesToRetrieve(['id', 'name', 'location'])
->withFilter('_geoBoundingBox(location, 53.3498, 23.7275, 37.9838, -6.2603)')
;
```

This is going to be your result:

```php
$results = [
'hits' => [
[
'id' => '2',
'title' => 'London',
'location' => [
'lat' => 51.5074,
'lng' => -0.1278,
],
],
[
'id' => '3',
'title' => 'Vienna',
'location' => [
'lat' => 48.2082,
'lng' => 16.3738,
],
],
],
'query' => '',
'hitsPerPage' => 20,
'page' => 1,
'totalPages' => 1,
'totalHits' => 2,
];
```

[Config]: configuration.md
[Restaurant_Fixture]: ./../tests/Functional/IndexData/locations.json
42 changes: 42 additions & 0 deletions src/Internal/Filter/Ast/GeoBoundingBox.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Loupe\Loupe\Internal\Filter\Ast;

use Location\Bounds;
use Location\Coordinate;

class GeoBoundingBox extends Node
{
private Bounds $bbox;

public function __construct(
public string $attributeName,
float $north,
float $east,
float $south,
float $west,
) {
$this->bbox = new Bounds(
new Coordinate($north, $west),
new Coordinate($south, $east),
);
}

public function getBbox(): Bounds
{
return $this->bbox;
}

public function toArray(): array
{
return [
'attribute' => $this->attributeName,
'north' => $this->bbox->getNorth(),
'east' => $this->bbox->getEast(),
'south' => $this->bbox->getSouth(),
'west' => $this->bbox->getWest(),
];
}
}
5 changes: 5 additions & 0 deletions src/Internal/Filter/Lexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class Lexer extends AbstractLexer

public const T_FLOAT = 5;

public const T_GEO_BOUNDING_BOX = 102;

public const T_GEO_RADIUS = 101;

public const T_GREATER_THAN = 12;
Expand Down Expand Up @@ -114,6 +116,9 @@ protected function getType(mixed &$value): int
case $value === '_geoRadius':
return self::T_GEO_RADIUS;

case $value === '_geoBoundingBox':
return self::T_GEO_BOUNDING_BOX;

// Attribute names
case IndexInfo::isValidAttributeName($value):
return self::T_ATTRIBUTE_NAME;
Expand Down
53 changes: 53 additions & 0 deletions src/Internal/Filter/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Loupe\Loupe\Internal\Filter\Ast\Ast;
use Loupe\Loupe\Internal\Filter\Ast\Concatenator;
use Loupe\Loupe\Internal\Filter\Ast\Filter;
use Loupe\Loupe\Internal\Filter\Ast\GeoBoundingBox;
use Loupe\Loupe\Internal\Filter\Ast\GeoDistance;
use Loupe\Loupe\Internal\Filter\Ast\Group;
use Loupe\Loupe\Internal\Filter\Ast\Node;
Expand Down Expand Up @@ -49,6 +50,7 @@ public function getAst(string $string, Engine $engine): Ast
if ($start && !$this->lexer->token?->isA(
Lexer::T_ATTRIBUTE_NAME,
Lexer::T_GEO_RADIUS,
Lexer::T_GEO_BOUNDING_BOX,
Lexer::T_OPEN_PARENTHESIS
)) {
$this->syntaxError('an attribute name, _geoRadius() or \'(\'');
Expand All @@ -61,6 +63,11 @@ public function getAst(string $string, Engine $engine): Ast
continue;
}

if ($this->lexer->token?->type === Lexer::T_GEO_BOUNDING_BOX) {
$this->handleGeoBoundingBox($engine);
continue;
}

if ($this->lexer->token?->type === Lexer::T_ATTRIBUTE_NAME) {
$this->handleAttribute($engine);
continue;
Expand Down Expand Up @@ -234,6 +241,52 @@ private function handleAttribute(Engine $engine): void
$this->addNode(new Filter($attributeName, Operator::fromString($operator), $this->getTokenValueBasedOnType()));
}

private function handleGeoBoundingBox(Engine $engine): void
{
$startPosition = ($this->lexer->lookahead?->position ?? 0) + 1;

$this->assertOpeningParenthesis($this->lexer->lookahead);
$this->lexer->moveNext();
$this->lexer->moveNext();

$attributeName = (string) $this->lexer->token?->value;

$this->validateFilterableAttribute($engine, $attributeName);

$this->lexer->moveNext();
$this->lexer->moveNext();
$north = $this->assertAndExtractFloat($this->lexer->token, true);
$this->assertComma($this->lexer->lookahead);

$this->lexer->moveNext();
$this->lexer->moveNext();
$east = $this->assertAndExtractFloat($this->lexer->token, true);
$this->assertComma($this->lexer->lookahead);

$this->lexer->moveNext();
$this->lexer->moveNext();
$south = $this->assertAndExtractFloat($this->lexer->token, true);
$this->assertComma($this->lexer->lookahead);

$this->lexer->moveNext();
$this->lexer->moveNext();
$west = $this->assertAndExtractFloat($this->lexer->token, true);
$this->assertClosingParenthesis($this->lexer->lookahead);

try {
$this->addNode(new GeoBoundingBox($attributeName, $north, $east, $south, $west));
} catch (\InvalidArgumentException $e) {
$this->syntaxError(
$e->getMessage(),
// create a fake token to show the user the whole value for better developer experience as we don't know
// which latitude or longitude value caused the exception
new Token(implode(', ', [$attributeName, $north, $east, $south, $west]), Lexer::T_FLOAT, $startPosition),
);
}

$this->lexer->moveNext();
}

private function handleGeoRadius(Engine $engine): void
{
$this->assertOpeningParenthesis($this->lexer->lookahead);
Expand Down
87 changes: 60 additions & 27 deletions src/Internal/Search/Searcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\DBAL\Result;
use Location\Bounds;
use Loupe\Loupe\Internal\Engine;
use Loupe\Loupe\Internal\Filter\Ast\Concatenator;
use Loupe\Loupe\Internal\Filter\Ast\Filter;
use Loupe\Loupe\Internal\Filter\Ast\GeoBoundingBox;
use Loupe\Loupe\Internal\Filter\Ast\GeoDistance;
use Loupe\Loupe\Internal\Filter\Ast\Group;
use Loupe\Loupe\Internal\Filter\Ast\Node;
Expand Down Expand Up @@ -283,6 +285,44 @@ private function createAnalyzedQuery(TokenCollection $tokens): string
return $query;
}

/**
* @return array<string|float>
*/
private function createGeoBoundingBoxWhereStatement(string $documentAlias, GeoBoundingBox|GeoDistance $node, Bounds $bounds): array
{
$whereStatement = [];

// Prevent nullable
$nullTerm = $this->queryBuilder->createNamedParameter(LoupeTypes::VALUE_NULL);
$whereStatement[] = $documentAlias . '.' . $node->attributeName . '_geo_lat';
$whereStatement[] = '!=';
$whereStatement[] = $nullTerm;
$whereStatement[] = 'AND';
$whereStatement[] = $documentAlias . '.' . $node->attributeName . '_geo_lng';
$whereStatement[] = '!=';
$whereStatement[] = $nullTerm;

$whereStatement[] = 'AND';

// Longitude
$whereStatement[] = $documentAlias . '.' . $node->attributeName . '_geo_lng';
$whereStatement[] = 'BETWEEN';
$whereStatement[] = $bounds->getWest();
$whereStatement[] = 'AND';
$whereStatement[] = $bounds->getEast();

$whereStatement[] = 'AND';

// Latitude
$whereStatement[] = $documentAlias . '.' . $node->attributeName . '_geo_lat';
$whereStatement[] = 'BETWEEN';
$whereStatement[] = $bounds->getSouth();
$whereStatement[] = 'AND';
$whereStatement[] = $bounds->getNorth();

return $whereStatement;
}

/**
* @param array<int> $states
*/
Expand Down Expand Up @@ -572,33 +612,7 @@ private function handleFilterAstNode(Node $node, array &$whereStatement): void
// locations we shouldn't.
$bounds = $node->getBbox();

// Prevent nullable
$nullTerm = $this->queryBuilder->createNamedParameter(LoupeTypes::VALUE_NULL);
$whereStatement[] = $documentAlias . '.' . $node->attributeName . '_geo_lat';
$whereStatement[] = '!=';
$whereStatement[] = $nullTerm;
$whereStatement[] = 'AND';
$whereStatement[] = $documentAlias . '.' . $node->attributeName . '_geo_lng';
$whereStatement[] = '!=';
$whereStatement[] = $nullTerm;

$whereStatement[] = 'AND';

// Longitude
$whereStatement[] = $documentAlias . '.' . $node->attributeName . '_geo_lng';
$whereStatement[] = 'BETWEEN';
$whereStatement[] = $bounds->getWest();
$whereStatement[] = 'AND';
$whereStatement[] = $bounds->getEast();

$whereStatement[] = 'AND';

// Latitude
$whereStatement[] = $documentAlias . '.' . $node->attributeName . '_geo_lat';
$whereStatement[] = 'BETWEEN';
$whereStatement[] = $bounds->getSouth();
$whereStatement[] = 'AND';
$whereStatement[] = $bounds->getNorth();
$whereStatement = [...$whereStatement, ...$this->createGeoBoundingBoxWhereStatement($documentAlias, $node, $bounds)];

// And now calculate the real distance to filter out the ones that are within the BBOX (which is a square)
// but not within the radius (which is a circle).
Expand All @@ -611,6 +625,25 @@ private function handleFilterAstNode(Node $node, array &$whereStatement): void
$whereStatement[] = ')';
}

if ($node instanceof GeoBoundingBox) {
// Not existing attributes need be handled as no match
if (!\in_array($node->attributeName, $this->engine->getIndexInfo()->getFilterableAttributes(), true)) {
$whereStatement[] = '1 = 0';
return;
}

// Start a group GeoDistance BBOX
$whereStatement[] = '(';

// Same like above for
$bounds = $node->getBbox();

$whereStatement = [...$whereStatement, ...$this->createGeoBoundingBoxWhereStatement($documentAlias, $node, $bounds)];

// End group
$whereStatement[] = ')';
}

if ($node instanceof Concatenator) {
$whereStatement[] = $node->getConcatenator();
}
Expand Down
56 changes: 56 additions & 0 deletions tests/Functional/SearchTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,62 @@ public function testFilteringAndSortingForIdentifier(): void
]);
}

public function testGeoBoundingBox(): void
{
$configuration = Configuration::create()
->withFilterableAttributes(['location'])
->withSearchableAttributes(['title'])
;

$loupe = $this->createLoupe($configuration);
$this->indexFixture($loupe, 'locations');

$dublin = [
'lat' => 53.3498,
'lng' => -6.2603,
];
$athen = [
'lat' => 37.9838,
'lng' => 23.7275,
];

$searchParameters = SearchParameters::create()
->withFilter(sprintf(
'_geoBoundingBox(location, %s, %s, %s, %s)',
$dublin['lat'],
$athen['lng'],
$athen['lat'],
$dublin['lng'],
))
;

$this->searchAndAssertResults($loupe, $searchParameters, [
'hits' => [
[
'id' => '2',
'title' => 'London',
'location' => [
'lat' => 51.5074,
'lng' => -0.1278,
],
],
[
'id' => '3',
'title' => 'Vienna',
'location' => [
'lat' => 48.2082,
'lng' => 16.3738,
],
],
],
'query' => '',
'hitsPerPage' => 20,
'page' => 1,
'totalPages' => 1,
'totalHits' => 2,
]);
}

public function testGeoSearch(): void
{
$configuration = Configuration::create()
Expand Down
Loading

0 comments on commit 54c0548

Please sign in to comment.