Skip to content

Commit 6b79b6f

Browse files
authored
feat(elasticsearch): filtering on nested fields (#5835)
* feat(elasticsearch): filtering on nested fields * Add additional checks and function doc for less confusion * Fixing tests
1 parent 6f90626 commit 6b79b6f

File tree

11 files changed

+242
-9
lines changed

11 files changed

+242
-9
lines changed

features/elasticsearch/match_filter.feature

+51
Original file line numberDiff line numberDiff line change
@@ -429,3 +429,54 @@ Feature: Match filter on collections from Elasticsearch
429429
}
430430
}
431431
"""
432+
433+
Scenario: Match filter on a multi-level nested property of text type with new elasticsearch operations
434+
When I send a "GET" request to "/books?library.relatedGenres.name=Fiction"
435+
Then the response status code should be 200
436+
And the response should be in JSON
437+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
438+
And the JSON should be valid according to this schema:
439+
"""
440+
{
441+
"type": "object",
442+
"properties": {
443+
"@context": {"pattern": "^/contexts/Book$"},
444+
"@id": {"pattern": "^/books$"},
445+
"@type": {"pattern": "^hydra:Collection$"},
446+
"hydra:member": {
447+
"type": "array",
448+
"additionalItems": false,
449+
"maxItems": 2,
450+
"minItems": 2,
451+
"items": [
452+
{
453+
"type": "object",
454+
"properties": {
455+
"@id": {
456+
"type": "string",
457+
"pattern": "^/books/0acfd90d-5bfe-4e42-b708-dc38bf20677c$"
458+
}
459+
}
460+
},
461+
{
462+
"type": "object",
463+
"properties": {
464+
"@id": {
465+
"type": "string",
466+
"pattern": "^/books/f36a0026-0635-4865-86a6-5adb21d94d64$"
467+
}
468+
}
469+
}
470+
]
471+
},
472+
"hydra:view": {
473+
"type": "object",
474+
"properties": {
475+
"@id": {"pattern": "^/books\\?library.relatedGenres.name=Fiction$"},
476+
"@type": {"pattern": "^hydra:PartialCollectionView$"}
477+
}
478+
}
479+
}
480+
}
481+
"""
482+

features/elasticsearch/read.feature

+39-3
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,7 @@ Feature: Retrieve from Elasticsearch
499499
},
500500
"hydra:search": {
501501
"@type": "hydra:IriTemplate",
502-
"hydra:template": "/books{?order[id],order[library.id],message,message[],library.firstName,library.firstName[]}",
502+
"hydra:template": "/books{?order[id],order[library.id],message,message[],library.firstName,library.firstName[],library.relatedGenres.name,library.relatedGenres.name[]}",
503503
"hydra:variableRepresentation": "BasicRepresentation",
504504
"hydra:mapping": [
505505
{
@@ -537,6 +537,18 @@ Feature: Retrieve from Elasticsearch
537537
"variable": "library.firstName[]",
538538
"property": "library.firstName",
539539
"required": false
540+
},
541+
{
542+
"@type": "IriTemplateMapping",
543+
"variable": "library.relatedGenres.name",
544+
"property": "library.relatedGenres.name",
545+
"required": false
546+
},
547+
{
548+
"@type": "IriTemplateMapping",
549+
"variable": "library.relatedGenres.name[]",
550+
"property": "library.relatedGenres.name",
551+
"required": false
540552
}
541553
]
542554
}
@@ -615,7 +627,7 @@ Feature: Retrieve from Elasticsearch
615627
},
616628
"hydra:search": {
617629
"@type": "hydra:IriTemplate",
618-
"hydra:template": "/books{?order[id],order[library.id],message,message[],library.firstName,library.firstName[]}",
630+
"hydra:template": "/books{?order[id],order[library.id],message,message[],library.firstName,library.firstName[],library.relatedGenres.name,library.relatedGenres.name[]}",
619631
"hydra:variableRepresentation": "BasicRepresentation",
620632
"hydra:mapping": [
621633
{
@@ -653,6 +665,18 @@ Feature: Retrieve from Elasticsearch
653665
"variable": "library.firstName[]",
654666
"property": "library.firstName",
655667
"required": false
668+
},
669+
{
670+
"@type": "IriTemplateMapping",
671+
"variable": "library.relatedGenres.name",
672+
"property": "library.relatedGenres.name",
673+
"required": false
674+
},
675+
{
676+
"@type": "IriTemplateMapping",
677+
"variable": "library.relatedGenres.name[]",
678+
"property": "library.relatedGenres.name",
679+
"required": false
656680
}
657681
]
658682
}
@@ -714,7 +738,7 @@ Feature: Retrieve from Elasticsearch
714738
},
715739
"hydra:search": {
716740
"@type": "hydra:IriTemplate",
717-
"hydra:template": "/books{?order[id],order[library.id],message,message[],library.firstName,library.firstName[]}",
741+
"hydra:template": "/books{?order[id],order[library.id],message,message[],library.firstName,library.firstName[],library.relatedGenres.name,library.relatedGenres.name[]}",
718742
"hydra:variableRepresentation": "BasicRepresentation",
719743
"hydra:mapping": [
720744
{
@@ -752,6 +776,18 @@ Feature: Retrieve from Elasticsearch
752776
"variable": "library.firstName[]",
753777
"property": "library.firstName",
754778
"required": false
779+
},
780+
{
781+
"@type": "IriTemplateMapping",
782+
"variable": "library.relatedGenres.name",
783+
"property": "library.relatedGenres.name",
784+
"required": false
785+
},
786+
{
787+
"@type": "IriTemplateMapping",
788+
"variable": "library.relatedGenres.name[]",
789+
"property": "library.relatedGenres.name",
790+
"required": false
755791
}
756792
]
757793
}

src/Elasticsearch/Tests/Filter/MatchFilterTest.php

+32-1
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ public function testApply(): void
8787
);
8888
}
8989

90-
public function testApplyWithNestedProperty(): void
90+
public function testApplyWithNestedArrayProperty(): void
9191
{
9292
$fooType = new Type(Type::BUILTIN_TYPE_ARRAY, false, Foo::class, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, Foo::class));
9393
$barType = new Type(Type::BUILTIN_TYPE_STRING);
@@ -119,6 +119,37 @@ public function testApplyWithNestedProperty(): void
119119
);
120120
}
121121

122+
public function testApplyWithNestedObjectProperty(): void
123+
{
124+
$fooType = new Type(Type::BUILTIN_TYPE_OBJECT, false, Foo::class);
125+
$barType = new Type(Type::BUILTIN_TYPE_STRING);
126+
127+
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
128+
$propertyMetadataFactoryProphecy->create(Foo::class, 'foo')->willReturn((new ApiProperty())->withBuiltinTypes([$fooType]))->shouldBeCalled();
129+
$propertyMetadataFactoryProphecy->create(Foo::class, 'bar')->willReturn((new ApiProperty())->withBuiltinTypes([$barType]))->shouldBeCalled();
130+
131+
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
132+
$resourceClassResolverProphecy->isResourceClass(Foo::class)->willReturn(true)->shouldBeCalled();
133+
134+
$nameConverterProphecy = $this->prophesize(NameConverterInterface::class);
135+
$nameConverterProphecy->normalize('foo.bar', Foo::class, null, Argument::type('array'))->willReturn('foo.bar')->shouldBeCalled();
136+
137+
$matchFilter = new MatchFilter(
138+
$this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(),
139+
$propertyMetadataFactoryProphecy->reveal(),
140+
$resourceClassResolverProphecy->reveal(),
141+
$this->prophesize(IriConverterInterface::class)->reveal(),
142+
$this->prophesize(PropertyAccessorInterface::class)->reveal(),
143+
$nameConverterProphecy->reveal(),
144+
['foo.bar' => null]
145+
);
146+
147+
self::assertSame(
148+
['bool' => ['must' => [['match' => ['foo.bar' => 'Krupicka']]]]],
149+
$matchFilter->apply([], Foo::class, null, ['filters' => ['foo.bar' => 'Krupicka']])
150+
);
151+
}
152+
122153
public function testApplyWithInvalidFilters(): void
123154
{
124155
$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);

src/Elasticsearch/Tests/Filter/TermFilterTest.php

+32-1
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ public function testApply(): void
8787
);
8888
}
8989

90-
public function testApplyWithNestedProperty(): void
90+
public function testApplyWithNestedArrayProperty(): void
9191
{
9292
$fooType = new Type(Type::BUILTIN_TYPE_ARRAY, false, Foo::class, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, Foo::class));
9393
$barType = new Type(Type::BUILTIN_TYPE_STRING);
@@ -119,6 +119,37 @@ public function testApplyWithNestedProperty(): void
119119
);
120120
}
121121

122+
public function testApplyWithNestedObjectProperty(): void
123+
{
124+
$fooType = new Type(Type::BUILTIN_TYPE_OBJECT, false, Foo::class);
125+
$barType = new Type(Type::BUILTIN_TYPE_STRING);
126+
127+
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
128+
$propertyMetadataFactoryProphecy->create(Foo::class, 'foo')->willReturn((new ApiProperty())->withBuiltinTypes([$fooType]))->shouldBeCalled();
129+
$propertyMetadataFactoryProphecy->create(Foo::class, 'bar')->willReturn((new ApiProperty())->withBuiltinTypes([$barType]))->shouldBeCalled();
130+
131+
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
132+
$resourceClassResolverProphecy->isResourceClass(Foo::class)->willReturn(true)->shouldBeCalled();
133+
134+
$nameConverterProphecy = $this->prophesize(NameConverterInterface::class);
135+
$nameConverterProphecy->normalize('foo.bar', Foo::class, null, Argument::type('array'))->willReturn('foo.bar')->shouldBeCalled();
136+
137+
$termFilter = new TermFilter(
138+
$this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(),
139+
$propertyMetadataFactoryProphecy->reveal(),
140+
$resourceClassResolverProphecy->reveal(),
141+
$this->prophesize(IriConverterInterface::class)->reveal(),
142+
$this->prophesize(PropertyAccessorInterface::class)->reveal(),
143+
$nameConverterProphecy->reveal(),
144+
['foo.bar' => null]
145+
);
146+
147+
self::assertSame(
148+
['bool' => ['must' => [['term' => ['foo.bar' => 'Krupicka']]]]],
149+
$termFilter->apply([], Foo::class, null, ['filters' => ['foo.bar' => 'Krupicka']])
150+
);
151+
}
152+
122153
public function testApplyWithInvalidFilters(): void
123154
{
124155
$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);

src/Elasticsearch/Tests/Util/FieldDatatypeTraitTest.php

+15
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ public function testGetNestedFieldPath(): void
3232
$fieldDatatype = $this->getValidFieldDatatype();
3333

3434
self::assertSame('foo.bar', $fieldDatatype->getNestedFieldPath(Foo::class, 'foo.bar.baz'));
35+
self::assertNull($fieldDatatype->getNestedFieldPath(Foo::class, 'foo.baz'));
36+
self::assertNull($fieldDatatype->getNestedFieldPath(Foo::class, 'baz'));
37+
}
38+
39+
public function testGetNestedFieldInNestedCollection(): void
40+
{
41+
$fieldDatatype = $this->getValidFieldDatatype();
42+
43+
self::assertSame('foo.foo.bar', $fieldDatatype->getNestedFieldPath(Foo::class, 'foo.foo.bar.baz'));
44+
self::assertNull($fieldDatatype->getNestedFieldPath(Foo::class, 'foo.foo.baz'));
45+
self::assertSame('foo.bar', $fieldDatatype->getNestedFieldPath(Foo::class, 'foo.bar.foo.baz'));
46+
self::assertSame('bar', $fieldDatatype->getNestedFieldPath(Foo::class, 'bar.foo'));
3547
self::assertNull($fieldDatatype->getNestedFieldPath(Foo::class, 'baz'));
3648
}
3749

@@ -72,17 +84,20 @@ public function testIsNestedField(): void
7284
$fieldDatatype = $this->getValidFieldDatatype();
7385

7486
self::assertTrue($fieldDatatype->isNestedField(Foo::class, 'foo.bar.baz'));
87+
self::assertFalse($fieldDatatype->isNestedField(Foo::class, 'foo.baz'));
7588
self::assertFalse($fieldDatatype->isNestedField(Foo::class, 'baz'));
7689
}
7790

7891
private function getValidFieldDatatype()
7992
{
8093
$fooType = new Type(Type::BUILTIN_TYPE_OBJECT, false, Foo::class);
8194
$barType = new Type(Type::BUILTIN_TYPE_ARRAY, false, Foo::class, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, Foo::class));
95+
$bazType = new Type(Type::BUILTIN_TYPE_STRING, false, Foo::class);
8296

8397
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
8498
$propertyMetadataFactoryProphecy->create(Foo::class, 'foo')->willReturn((new ApiProperty())->withBuiltinTypes([$fooType]))->shouldBeCalled();
8599
$propertyMetadataFactoryProphecy->create(Foo::class, 'bar')->willReturn((new ApiProperty())->withBuiltinTypes([$barType]))->shouldBeCalled();
100+
$propertyMetadataFactoryProphecy->create(Foo::class, 'baz')->willReturn((new ApiProperty())->withBuiltinTypes([$bazType]));
86101

87102
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
88103
$resourceClassResolverProphecy->isResourceClass(Foo::class)->willReturn(true)->shouldBeCalled();

src/Elasticsearch/Util/FieldDatatypeTrait.php

+8-1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ private function isNestedField(string $resourceClass, string $property): bool
4343

4444
/**
4545
* Get the nested path to the decomposed given property (e.g.: foo.bar.baz => foo.bar).
46+
*
47+
* Elasticsearch can save arrays of Objects as nested documents.
48+
* In the case of foo.bar.baz
49+
* foo.bar will be returned if foo.bar is an array of objects.
50+
* If neither foo nor bar is an array, it is not a nested property and will return null.
4651
*/
4752
private function getNestedFieldPath(string $resourceClass, string $property): ?string
4853
{
@@ -78,7 +83,9 @@ private function getNestedFieldPath(string $resourceClass, string $property): ?s
7883
&& null !== ($className = $type->getClassName())
7984
&& $this->resourceClassResolver->isResourceClass($className)
8085
) {
81-
return $currentProperty;
86+
$nestedPath = $this->getNestedFieldPath($className, implode('.', $properties));
87+
88+
return null === $nestedPath ? $currentProperty : "$currentProperty.$nestedPath";
8289
}
8390
}
8491

tests/Fixtures/Elasticsearch/Fixtures/book.json

+22-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,17 @@
66
"gender": "male",
77
"age": 31,
88
"firstName": "Kilian",
9-
"lastName": "Jornet"
9+
"lastName": "Jornet",
10+
"relatedGenres": [
11+
{
12+
"id": "5b45d21a-8cdf-4dac-9237-4fea7bb1535f%",
13+
"name": "Fiction"
14+
},
15+
{
16+
"id": "e7a24c7d-84a8-4133-b6b2-4bf050b36023",
17+
"name": "Contemporary"
18+
}
19+
]
1020
},
1121
"date": "2017-01-01 01:01:01",
1222
"message": "The north summit, Store Vengetind Thanks for t... These Top 10 Women of a fk... Francois is the field which."
@@ -90,7 +100,17 @@
90100
"gender": "male",
91101
"age": 35,
92102
"firstName": "Anton",
93-
"lastName": "Krupicka"
103+
"lastName": "Krupicka",
104+
"relatedGenres": [
105+
{
106+
"id": "5b45d21a-8cdf-4dac-9237-4fea7bb1535f%",
107+
"name": "Fiction"
108+
},
109+
{
110+
"id": "e7a24c7d-84a8-4133-b6b2-4bf050b36023",
111+
"name": "Contemporary"
112+
}
113+
]
94114
},
95115
"date": "2017-08-08 08:08:08",
96116
"message": "Whoever curates the Marathon yesterday. Truly inspiring stuff. The new is straight. Such a couple!"

tests/Fixtures/Elasticsearch/Mappings/book.json

+12
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@
2020
},
2121
"lastName": {
2222
"type": "text"
23+
},
24+
"relatedGenres": {
25+
"type": "nested",
26+
"properties": {
27+
"id": {
28+
"type": "keyword"
29+
},
30+
"name": {
31+
"type": "text"
32+
}
33+
},
34+
"dynamic": "strict"
2335
}
2436
},
2537
"dynamic": "strict"

tests/Fixtures/Elasticsearch/Model/Book.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
#[ApiResource(operations: [new Get(provider: ItemProvider::class), new GetCollection(provider: CollectionProvider::class)], normalizationContext: ['groups' => ['book:read']], stateOptions: new Options(index: 'book'))]
2929
#[ApiFilter(OrderFilter::class, properties: ['id', 'library.id'])]
30-
#[ApiFilter(MatchFilter::class, properties: ['message', 'library.firstName'])]
30+
#[ApiFilter(MatchFilter::class, properties: ['message', 'library.firstName', 'library.relatedGenres.name'])]
3131
class Book
3232
{
3333
#[Groups(['book:read', 'library:read'])]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\Elasticsearch\Model;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use Symfony\Component\Serializer\Annotation\Groups;
18+
19+
#[ApiResource]
20+
class Genre
21+
{
22+
#[Groups(['genre:read', 'book:read'])]
23+
public ?string $id = null;
24+
25+
#[Groups(['genre:read', 'book:read'])]
26+
public ?string $name = null;
27+
}

tests/Fixtures/Elasticsearch/Model/Library.php

+3
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,7 @@ class Library
4646
/** @var Book[] */
4747
#[Groups(['library:read'])]
4848
public array $books = [];
49+
50+
/** @var Genre[] */
51+
public array $relatedGenres = [];
4952
}

0 commit comments

Comments
 (0)