Skip to content

Commit 74e5ca0

Browse files
committed
nested query builder
1 parent b3d1a43 commit 74e5ca0

4 files changed

Lines changed: 108 additions & 11 deletions

File tree

src/Eloquent/Builder.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ class Builder extends BaseEloquentBuilder
7272
'todsl',
7373
'bucket',
7474
'bucketaggregation',
75+
'nestedquery',
7576
'openpit',
7677
'bulkinsert',
7778
'createonly',

src/Eloquent/Docs/ModelDocs.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
* @method static $this orWhereGeoDistance($field, $distance, $location, $distanceType = null, $validationMethod = null)
7878
* @method static $this orWhereNotGeoDistance($field, $distance, $location, $distanceType = null, $validationMethod = null)
7979
*-----------------------------------
80+
* @method static \PDPhilip\Elasticsearch\Query\Builder nestedQuery(string $column)
8081
* @method static $this whereNestedObject($column, $query, $filterInnerHits = false, $options = [], $boolean = 'and', $not = false)
8182
* @method static $this whereNotNestedObject($column, $query, $filterInnerHits = false, $options = [])
8283
* @method static $this orWhereNestedObject($column, $query, $filterInnerHits = false, $options = [])

src/Query/Concerns/BuildsNestedQueries.php

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,43 @@
66

77
use Closure;
88
use Illuminate\Support\Str;
9+
use PDPhilip\Elasticsearch\Exceptions\BuilderException;
910
use PDPhilip\Elasticsearch\Exceptions\RuntimeException;
11+
use PDPhilip\Elasticsearch\Query\Builder;
1012

1113
/**
1214
* Nested object and parent/child relationship queries.
1315
* For when your docs have docs inside docs.
1416
*/
1517
trait BuildsNestedQueries
1618
{
19+
/**
20+
* Return a Builder pre-configured for a nested path.
21+
*
22+
* Use this when you need to build the inner nested query programmatically
23+
* (across functions, conditionals, loops) rather than inside a closure.
24+
* The returned Builder can be passed straight to whereNestedObject():
25+
*
26+
* $inner = BlogPost::nestedQuery('comments');
27+
* $inner->where('country', 'Peru');
28+
* if ($minLikes) $inner->where('likes', '>=', $minLikes);
29+
*
30+
* BlogPost::whereNestedObject('comments', $inner)->get();
31+
*/
32+
public function nestedQuery(string $column): Builder
33+
{
34+
$query = $this->newQuery($this->from);
35+
$query->options()->add('parentField', $column);
36+
37+
return $query;
38+
}
39+
1740
/**
1841
* Query nested objects within a document.
1942
* https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-nested-query.html
43+
*
44+
* The $query argument may be a Closure (most common), a pre-built Builder
45+
* returned by nestedQuery(), or a raw DSL string.
2046
*/
2147
public function whereNestedObject($column, $query, $filterInnerHits = false, $options = [], $boolean = 'and', $not = false): self
2248
{
@@ -28,16 +54,39 @@ public function whereNestedObject($column, $query, $filterInnerHits = false, $op
2854
}
2955
$options = $options->toArray();
3056

31-
if (! is_string($query) && is_callable($query)) {
57+
$query = $this->resolveNestedQuery($column, $query);
58+
59+
$this->wheres[] = compact('column', 'query', 'type', 'boolean', 'not', 'options');
60+
61+
return $this;
62+
}
63+
64+
/**
65+
* Normalize the $query argument accepted by whereNestedObject / filterNested.
66+
* Accepts a Closure, a pre-built Builder, or a raw DSL string.
67+
*/
68+
protected function resolveNestedQuery(string $column, mixed $query): mixed
69+
{
70+
if ($query instanceof Builder) {
71+
return $query;
72+
}
73+
74+
if (is_string($query)) {
75+
return $query;
76+
}
77+
78+
if (is_callable($query)) {
3279
$callback = $query;
33-
$query = $this->newQuery($from);
80+
$query = $this->newQuery($this->from);
3481
$query->options()->add('parentField', $column);
3582
call_user_func($callback, $query);
36-
}
3783

38-
$this->wheres[] = compact('column', 'query', 'type', 'boolean', 'not', 'options');
84+
return $query;
85+
}
3986

40-
return $this;
87+
throw new BuilderException(
88+
'Nested query must be a Closure, a Builder from nestedQuery(), or a raw DSL string. Got '.get_debug_type($query).'.'
89+
);
4190
}
4291

4392
public function orWhereNestedObject($column, $query, $filterInnerHits = false, $options = []): self
@@ -74,12 +123,7 @@ public function filterNested($column, $query, $options = [])
74123
$options = $this->setOptions($options, 'nested');
75124
$options = $options->toArray();
76125

77-
if (! is_string($query) && is_callable($query)) {
78-
$callback = $query;
79-
$query = $this->newQuery($from);
80-
$query->options()->add('parentField', $column);
81-
call_user_func($callback, $query);
82-
}
126+
$query = $this->resolveNestedQuery($column, $query);
83127

84128
$this->wheres[] = compact('column', 'query', 'type', 'boolean', 'not', 'options');
85129

tests/QueryElasticsearchSpecificTest.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,57 @@
141141
expect($count)->toBe(2);
142142
});
143143

144+
it('accepts a pre-built Builder from nestedQuery()', function () {
145+
$inner = Post::nestedQuery('comments');
146+
expect($inner)->toBeInstanceOf(Builder::class);
147+
148+
$inner->where('country', 'USA')->where('likes', '>=', 15);
149+
150+
$posts = Post::whereNestedObject('comments', $inner)->get();
151+
expect($posts)->toHaveCount(1)
152+
->and($posts[0]['title'])->toBe('Getting Started with Laravel');
153+
});
154+
155+
it('produces identical results for closure and pre-built Builder forms', function () {
156+
$closureForm = Post::whereNestedObject('comments', function (Builder $q) {
157+
$q->where('country', 'USA');
158+
})->get()->pluck('title')->sort()->values()->all();
159+
160+
$inner = Post::nestedQuery('comments')->where('country', 'USA');
161+
$builderForm = Post::whereNestedObject('comments', $inner)->get()->pluck('title')->sort()->values()->all();
162+
163+
expect($builderForm)->toBe($closureForm);
164+
});
165+
166+
it('builds nested query programmatically across conditionals', function () {
167+
$filters = ['country' => 'USA'];
168+
$minLikes = 15;
169+
170+
$inner = Post::nestedQuery('comments');
171+
foreach ($filters as $field => $value) {
172+
$inner->where($field, $value);
173+
}
174+
if ($minLikes) {
175+
$inner->where('likes', '>=', $minLikes);
176+
}
177+
178+
$count = Post::whereNestedObject('comments', $inner)->count();
179+
expect($count)->toBe(1);
180+
});
181+
182+
it('supports pre-built Builder with filterInnerHits', function () {
183+
$inner = Post::nestedQuery('comments')->where('country', 'USA');
184+
185+
$posts = Post::whereNestedObject('comments', $inner, true)->get();
186+
expect($posts)->toHaveCount(2)
187+
->and($posts[0]['comments'])->toHaveCount(1)
188+
->and($posts[1]['comments'])->toHaveCount(1);
189+
});
190+
191+
it('throws BuilderException for invalid nested query argument', function () {
192+
Post::whereNestedObject('comments', 12345);
193+
})->throws(\PDPhilip\Elasticsearch\Exceptions\BuilderException::class);
194+
144195
it('can search with field boosting', function () {
145196
$users = User::search('John', 'best_fields', ['name' => 5, 'description' => 1])->get();
146197
expect($users)->toHaveCount(2)

0 commit comments

Comments
 (0)