Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add GeoBoundingBox filter #84

Merged
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
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) {
Toflar marked this conversation as resolved.
Show resolved Hide resolved
// 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
Loading