Skip to content

Commit ead03cd

Browse files
Spamerczclaude
andcommitted
feat(query): add geo shape query
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 93bcfe1 commit ead03cd

3 files changed

Lines changed: 152 additions & 0 deletions

File tree

doc/02-query-objects.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,23 @@ new \Spameri\ElasticQuery\Query\GeoDistance(
441441
);
442442
```
443443

444+
##### GeoShape Query
445+
Match `geo_shape`-indexed documents against an arbitrary geometry.
446+
- Class: `\Spameri\ElasticQuery\Query\GeoShape`
447+
- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-geo-shape-query.html)
448+
- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/GeoShape.php)
449+
450+
```php
451+
new \Spameri\ElasticQuery\Query\GeoShape(
452+
field: 'location',
453+
shape: [
454+
'type' => 'envelope',
455+
'coordinates' => [[13.0, 53.0], [14.0, 52.0]],
456+
],
457+
relation: 'within', // intersects | disjoint | within | contains
458+
);
459+
```
460+
444461
##### GeoBoundingBox Query
445462
Match documents whose geo_point falls inside a top-left/bottom-right rectangle.
446463
- Class: `\Spameri\ElasticQuery\Query\GeoBoundingBox`

src/Query/GeoShape.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace Spameri\ElasticQuery\Query;
6+
7+
/**
8+
* @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-geo-shape-query.html
9+
*/
10+
class GeoShape implements \Spameri\ElasticQuery\Query\LeafQueryInterface
11+
{
12+
13+
/**
14+
* @param array<string, mixed> $shape GeoJSON-style shape, e.g.
15+
* ['type' => 'envelope', 'coordinates' => [[13, 53], [14, 52]]].
16+
*/
17+
public function __construct(
18+
private string $field,
19+
private array $shape,
20+
private string $relation = 'intersects',
21+
private bool|null $ignoreUnmapped = null,
22+
)
23+
{
24+
if ( ! \in_array($relation, ['intersects', 'disjoint', 'within', 'contains'], true)) {
25+
throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException(
26+
'GeoShape relation must be one of: intersects, disjoint, within, contains.',
27+
);
28+
}
29+
}
30+
31+
32+
public function key(): string
33+
{
34+
return 'geo_shape_' . $this->field;
35+
}
36+
37+
38+
/**
39+
* @return array<string, array<string, array<string, mixed>>>
40+
*/
41+
public function toArray(): array
42+
{
43+
$inner = [
44+
'shape' => $this->shape,
45+
'relation' => $this->relation,
46+
];
47+
48+
if ($this->ignoreUnmapped !== null) {
49+
$inner['ignore_unmapped'] = $this->ignoreUnmapped;
50+
}
51+
52+
return [
53+
'geo_shape' => [
54+
$this->field => $inner,
55+
],
56+
];
57+
}
58+
59+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace SpameriTests\ElasticQuery\Query;
4+
5+
require_once __DIR__ . '/../../bootstrap.php';
6+
7+
8+
class GeoShape extends \Tester\TestCase
9+
{
10+
11+
private const INDEX = 'spameri_test_query_geo_shape';
12+
13+
14+
public function setUp(): void
15+
{
16+
$ch = \curl_init();
17+
\curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX);
18+
\curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1);
19+
\curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT');
20+
\curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
21+
22+
\curl_exec($ch);
23+
}
24+
25+
26+
public function testToArray(): void
27+
{
28+
$geoShape = new \Spameri\ElasticQuery\Query\GeoShape(
29+
field: 'location',
30+
shape: [
31+
'type' => 'envelope',
32+
'coordinates' => [[13.0, 53.0], [14.0, 52.0]],
33+
],
34+
relation: 'within',
35+
);
36+
37+
$array = $geoShape->toArray();
38+
39+
\Tester\Assert::same('envelope', $array['geo_shape']['location']['shape']['type']);
40+
\Tester\Assert::same('within', $array['geo_shape']['location']['relation']);
41+
}
42+
43+
44+
public function testRelationValidated(): void
45+
{
46+
\Tester\Assert::exception(
47+
static function (): void {
48+
new \Spameri\ElasticQuery\Query\GeoShape('loc', ['type' => 'point', 'coordinates' => [0, 0]], 'nonsense');
49+
},
50+
\Spameri\ElasticQuery\Exception\InvalidArgumentException::class,
51+
);
52+
}
53+
54+
55+
public function testKey(): void
56+
{
57+
$geoShape = new \Spameri\ElasticQuery\Query\GeoShape('location', ['type' => 'point', 'coordinates' => [0, 0]]);
58+
59+
\Tester\Assert::same('geo_shape_location', $geoShape->key());
60+
}
61+
62+
63+
public function tearDown(): void
64+
{
65+
$ch = \curl_init();
66+
\curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX);
67+
\curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1);
68+
\curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE');
69+
\curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
70+
71+
\curl_exec($ch);
72+
}
73+
74+
}
75+
76+
(new GeoShape())->run();

0 commit comments

Comments
 (0)