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 1 commit
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
40 changes: 40 additions & 0 deletions src/Internal/Filter/Ast/GeoBoundingBox.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace Loupe\Loupe\Internal\Filter\Ast;

use Location\Bounds;
use Location\Coordinate;

class GeoBoundingBox extends Node
{
public function __construct(
public string $attributeName,
public float $north,
public float $east,
public float $south,
public float $west,
alexander-schranz marked this conversation as resolved.
Show resolved Hide resolved
) {
}

public function getBbox(): Bounds
{
// phpgeo bounds are top left to bottom right but meilisearch and so loupe is top right to bottom left
return new Bounds(
Toflar marked this conversation as resolved.
Show resolved Hide resolved
new Coordinate($this->north, $this->west),
new Coordinate($this->south, $this->east),
);
}

public function toArray(): array
{
return [
'attribute' => $this->attributeName,
'north' => $this->north,
'east' => $this->east,
'south' => $this->south,
'west' => $this->west,
];
}
}
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
42 changes: 42 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,41 @@ private function handleAttribute(Engine $engine): void
$this->addNode(new Filter($attributeName, Operator::fromString($operator), $this->getTokenValueBasedOnType()));
}

private function handleGeoBoundingBox(Engine $engine): void
{
$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);

$this->addNode(new GeoBoundingBox($attributeName, $north, $east, $south, $west));

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

private function handleGeoRadius(Engine $engine): void
{
$this->assertOpeningParenthesis($this->lexer->lookahead);
Expand Down
46 changes: 46 additions & 0 deletions src/Internal/Search/Searcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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 @@ -611,6 +612,51 @@ 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();

// 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();

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

if ($node instanceof Concatenator) {
$whereStatement[] = $node->getConcatenator();
}
Expand Down
58 changes: 58 additions & 0 deletions tests/Functional/SearchTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,64 @@ 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)',
// Top Right (North East) (why north east see: https://github.com/loupe-php/loupe/issues/83)
Toflar marked this conversation as resolved.
Show resolved Hide resolved
$dublin['lat'],
$athen['lng'],
// Bottom Left (South West) (why south west see: https://github.com/loupe-php/loupe/issues/83)
$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
13 changes: 13 additions & 0 deletions tests/Unit/Internal/Filter/ParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,19 @@ public static function filterProvider(): \Generator
],
];

yield 'Basic geo bounding box' => [
Toflar marked this conversation as resolved.
Show resolved Hide resolved
'_geoBoundingBox(location, 53.3498, 23.7275, 37.9838, -6.2603)',
[
[
'attribute' => 'location',
'north' => 53.3498,
'east' => 23.7275,
'south' => 37.9838,
'west' => -6.2603,
],
],
];

yield 'Basic IN filter' => [
"genres IN ('Drama', 'Action', 'Documentary')",
[
Expand Down
Loading