Skip to content

Commit e5a253d

Browse files
committed
Nested Field Aggs
1 parent 6803ef6 commit e5a253d

7 files changed

Lines changed: 504 additions & 6 deletions

File tree

src/Query/Grammar/Concerns/CompilesAggregations.php

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ protected function compileBucketAggregations(Builder $builder, $sorts = null): a
9191
}
9292

9393
$result = $this->compileAggregation($builder, $aggregation);
94+
95+
// Wrap in nested agg if bucket targets nested fields
96+
$nestedPath = $this->detectNestedPathFromBucketAgg($builder, $aggregation);
97+
if ($nestedPath) {
98+
$result = $this->wrapInNestedAgg($result, $nestedPath, $builder);
99+
}
100+
94101
$aggregations = $aggregations->mergeRecursive($result);
95102
}
96103

@@ -264,4 +271,130 @@ protected function compileFilterAggregation(mixed $args): array
264271

265272
return DslFactory::filterAggregation(array_merge($filter['query'] ?? [], $filter['filter'] ?? []));
266273
}
274+
275+
// ----------------------------------------------------------------------
276+
// Nested field aggregation support
277+
// ES requires aggregations on nested fields to be wrapped in a nested agg context.
278+
// ----------------------------------------------------------------------
279+
280+
/**
281+
* Wrap compiled aggregations in a nested agg when targeting nested fields.
282+
* If a whereNestedObject filter exists on the same path, injects it as a
283+
* filter agg inside the nested wrapper for accurate sub-document filtering.
284+
*/
285+
protected function wrapInNestedAgg(array $aggs, ?string $nestedPath, ?Builder $builder = null): array
286+
{
287+
if (! $nestedPath) {
288+
return $aggs;
289+
}
290+
291+
$key = 'nested_'.str_replace('.', '_', $nestedPath);
292+
293+
// Check for a whereNestedObject filter on the same path
294+
$nestedFilter = $builder ? $this->extractNestedFilter($nestedPath, $builder) : null;
295+
296+
if ($nestedFilter) {
297+
return [
298+
$key => [
299+
'nested' => ['path' => $nestedPath],
300+
'aggs' => [
301+
'filtered' => [
302+
'filter' => $nestedFilter,
303+
'aggs' => $aggs,
304+
],
305+
],
306+
],
307+
];
308+
}
309+
310+
return [
311+
$key => [
312+
'nested' => ['path' => $nestedPath],
313+
'aggs' => $aggs,
314+
],
315+
];
316+
}
317+
318+
/**
319+
* Find the common nested path shared by all fields.
320+
* Returns null if any field is non-nested or paths differ.
321+
*/
322+
protected function resolveCommonNestedPath(array $fields, Builder $builder): ?string
323+
{
324+
$nestedPath = null;
325+
326+
foreach ($fields as $field) {
327+
$fieldPath = $this->getNestedPath($field, $builder);
328+
329+
if ($fieldPath === null) {
330+
return null;
331+
}
332+
333+
if ($nestedPath === null) {
334+
$nestedPath = $fieldPath;
335+
} elseif ($nestedPath !== $fieldPath) {
336+
return null;
337+
}
338+
}
339+
340+
return $nestedPath;
341+
}
342+
343+
/**
344+
* Detect the nested path from a bucket aggregation's arguments.
345+
* Inspects composite sources and terms field references.
346+
*/
347+
protected function detectNestedPathFromBucketAgg(Builder $builder, array $aggregation): ?string
348+
{
349+
$type = $aggregation['type'];
350+
$args = $aggregation['args'];
351+
352+
if ($type === 'composite' && is_array($args)) {
353+
foreach ($args as $source) {
354+
if (! is_array($source)) {
355+
continue;
356+
}
357+
foreach (array_keys($source) as $field) {
358+
$path = $this->getNestedPath($field, $builder);
359+
if ($path) {
360+
return $path;
361+
}
362+
}
363+
}
364+
}
365+
366+
if ($type === 'terms') {
367+
$field = is_array($args) ? ($args['field'] ?? $aggregation['key']) : $aggregation['key'];
368+
369+
return $this->getNestedPath($field, $builder);
370+
}
371+
372+
return null;
373+
}
374+
375+
/**
376+
* Extract the filter condition from a whereNestedObject on the same path.
377+
* Returns a compiled ES filter body, or null if no matching filter exists.
378+
*/
379+
protected function extractNestedFilter(string $nestedPath, Builder $builder): ?array
380+
{
381+
if (empty($builder->wheres)) {
382+
return null;
383+
}
384+
385+
foreach ($builder->wheres as $where) {
386+
if (($where['type'] ?? null) !== 'NestedObject' || ($where['column'] ?? null) !== $nestedPath) {
387+
continue;
388+
}
389+
390+
$compiled = $this->compileWheres($where['query']);
391+
$filter = array_merge($compiled['query'] ?? [], $compiled['filter'] ?? []);
392+
393+
if (! empty($filter)) {
394+
return $filter;
395+
}
396+
}
397+
398+
return null;
399+
}
267400
}

src/Query/Grammar/Concerns/FieldUtilities.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Illuminate\Support\Facades\Config;
1010
use PDPhilip\Elasticsearch\Exceptions\BuilderException;
1111
use PDPhilip\Elasticsearch\Query\Builder;
12+
use PDPhilip\Elasticsearch\Schema\Schema;
1213

1314
/**
1415
* Field mapping, value conversion, and other utility methods.
@@ -120,6 +121,63 @@ public function getIndexableField(string $textField, Builder $builder): string
120121
throw new BuilderException("{$textField} does not have a keyword field.");
121122
}
122123

124+
/**
125+
* Get the nested mapping path for a dotted field.
126+
* For 'tags.key', returns 'tags' if tags is mapped as nested.
127+
* Supports multi-level nesting: 'a.b.c' checks 'a' then 'a.b'.
128+
*
129+
* Uses the index mapping API (getMappings) rather than the field mapping API,
130+
* because the field mapping API doesn't return nested parent entries.
131+
*/
132+
public function getNestedPath(string $field, Builder $builder): ?string
133+
{
134+
if (! str_contains($field, '.')) {
135+
return null;
136+
}
137+
138+
$nestedPaths = $this->resolveNestedPaths($builder);
139+
140+
$segments = explode('.', $field);
141+
$path = '';
142+
for ($i = 0; $i < count($segments) - 1; $i++) {
143+
$path = $path ? $path.'.'.$segments[$i] : $segments[$i];
144+
if (in_array($path, $nestedPaths)) {
145+
return $path;
146+
}
147+
}
148+
149+
return null;
150+
}
151+
152+
/**
153+
* Resolve all nested field paths for the builder's index.
154+
* Cached per index to avoid repeated API calls.
155+
*/
156+
protected function resolveNestedPaths(Builder $builder): array
157+
{
158+
$cacheKey = $builder->from.'_nested_paths';
159+
$cached = $builder->options()->get($cacheKey);
160+
if ($cached !== null) {
161+
return $cached;
162+
}
163+
164+
$nestedPaths = [];
165+
try {
166+
$mapping = Schema::connection($builder->connection->getName())->getMappings($builder->getFrom());
167+
foreach ($mapping as $field => $details) {
168+
if (is_array($details) && ($details['type'] ?? null) === 'nested') {
169+
$nestedPaths[] = $field;
170+
}
171+
}
172+
} catch (\Exception) {
173+
// Index may not exist yet — no nested paths to detect
174+
}
175+
176+
$builder->options()->add($cacheKey, $nestedPaths);
177+
178+
return $nestedPaths;
179+
}
180+
123181
/**
124182
* Date format from config.
125183
*/

src/Query/Grammar/Grammar.php

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,15 @@ private function compileSelectAggregations(Builder $query, DslBuilder $dsl): voi
268268
$aggs[$aggregation['key']]['key'] = $aggregation['type'].'_'.$aggregation['key'];
269269
}
270270

271-
$dsl->setBody(['aggs'], $this->compileNestedTermAggregations($fields, $query, $aggs));
271+
$compiledAggs = $this->compileNestedTermAggregations($fields, $query, $aggs);
272+
273+
// Wrap in nested agg if all fields share a nested path
274+
$nestedPath = $this->resolveCommonNestedPath($fields, $query);
275+
if ($nestedPath) {
276+
$compiledAggs = $this->wrapInNestedAgg($compiledAggs, $nestedPath, $query);
277+
}
278+
279+
$dsl->setBody(['aggs'], $compiledAggs);
272280
$dsl->setBody(['size'], $query->getSetLimit() ?? 0);
273281
$dsl->unsetBody(['sort']);
274282
} else {
@@ -279,10 +287,30 @@ private function compileSelectAggregations(Builder $query, DslBuilder $dsl): voi
279287
$fields = Arr::wrap($query->columns);
280288
$aggs = [];
281289

290+
// Group fields by nested path to share one wrapper per path
291+
$nestedGroups = [];
292+
$plainFields = [];
282293
foreach ($fields as $field) {
294+
$nestedPath = $this->getNestedPath($field, $query);
295+
if ($nestedPath) {
296+
$nestedGroups[$nestedPath][] = $field;
297+
} else {
298+
$plainFields[] = $field;
299+
}
300+
}
301+
302+
foreach ($plainFields as $field) {
283303
$aggs = [...$aggs, ...$this->compileNestedTermAggregations([$field], $query)];
284304
}
285305

306+
foreach ($nestedGroups as $nestedPath => $groupFields) {
307+
$innerAggs = [];
308+
foreach ($groupFields as $field) {
309+
$innerAggs = [...$innerAggs, ...$this->compileNestedTermAggregations([$field], $query)];
310+
}
311+
$aggs = [...$aggs, ...$this->wrapInNestedAgg($innerAggs, $nestedPath, $query)];
312+
}
313+
286314
$dsl->setBody(['aggs'], $aggs);
287315
$dsl->setBody(['size'], $query->getSetLimit() ?? 0);
288316
$dsl->unsetBody(['sort']);

src/Query/Processor/Concerns/ProcessesBucketAggregations.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,13 @@ protected function parseBucket($bucketAggregation, $rawAggs)
2121
$key = $bucketAggregation['key'];
2222
$type = $bucketAggregation['type'] ?? null;
2323

24+
// Unwrap nested/filter agg wrappers if the bucket key isn't at the top level
25+
if (! isset($rawAggs[$key])) {
26+
$rawAggs = $this->unwrapNestedAggregation($rawAggs, $key);
27+
}
28+
2429
if (! isset($rawAggs[$key]['buckets'])) {
25-
return $rawAggs[$key];
30+
return $rawAggs[$key] ?? [];
2631
}
2732
$result = collect($rawAggs[$key]['buckets'])->map(function ($bucket) use ($key, $type) {
2833
$metricAggs = $this->appendMetricsToBucket($bucket);

src/Query/Processor/Concerns/ProcessesDistinctAggregations.php

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@ public function processDistinctAggregations($index, $result, $columns, $withCoun
1515
foreach ($columns as $column) {
1616
$keys[] = 'by_'.$column;
1717
}
18-
$aggregations = $this->parseDistinctBucket($columns, $keys, $result['aggregations'], 0, $withCount);
18+
19+
// Unwrap nested/filter agg wrappers if the expected key isn't at the top level
20+
$aggs = $result['aggregations'];
21+
if (! empty($keys[0]) && ! isset($aggs[$keys[0]])) {
22+
$aggs = $this->unwrapNestedAggregation($aggs, $keys[0]);
23+
}
24+
25+
$aggregations = $this->parseDistinctBucket($columns, $keys, $aggs, 0, $withCount);
1926
$aggregations = collect($aggregations);
2027

2128
return $aggregations->map(function ($aggregation) use ($index) {
@@ -29,9 +36,16 @@ public function processBulkDistinctAggregations($index, $result, $columns, $with
2936
$aggregations = [];
3037
foreach ($columns as $column) {
3138
$keys = ['by_'.$column];
39+
40+
// Each column's agg may be inside its own nested wrapper
41+
$aggs = $result['aggregations'];
42+
if (! isset($aggs[$keys[0]])) {
43+
$aggs = $this->unwrapNestedAggregation($aggs, $keys[0]);
44+
}
45+
3246
$aggregations = [
3347
...$aggregations,
34-
...$this->parseDistinctBucket([$column], $keys, $result['aggregations'], 0, $withCount),
48+
...$this->parseDistinctBucket([$column], $keys, $aggs, 0, $withCount),
3549
];
3650
}
3751
$aggregations = collect($aggregations);

src/Query/Processor/Processor.php

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,11 @@ public function processAggregations(Builder $query, $result)
6666
$this->query = $query;
6767
$response = $this->getRawResponse();
6868
$this->rawAggregations = $response['aggregations'] ?? [];
69-
if (! empty($response['aggregations']['group_by']['after_key'])) {
70-
$this->query->getMetaTransfer()->set('after_key', $response['aggregations']['group_by']['after_key']);
69+
70+
// Extract after_key — may be inside a nested agg wrapper
71+
$groupByAggs = $this->unwrapNestedAggregation($this->rawAggregations, 'group_by');
72+
if (! empty($groupByAggs['group_by']['after_key'])) {
73+
$this->query->getMetaTransfer()->set('after_key', $groupByAggs['group_by']['after_key']);
7174
}
7275

7376
if (! empty($this->query->bucketAggregations)) {
@@ -319,4 +322,36 @@ public function processRaw($query, $response)
319322

320323
return $documents->all();
321324
}
325+
326+
/**
327+
* Drill into nested/filter agg wrappers to find the expected aggregation key.
328+
* Returns the aggregation array containing $expectedKey at the top level.
329+
* Handles: nested_* → filtered → expected_key (up to 3 levels deep).
330+
*/
331+
protected function unwrapNestedAggregation(array $aggregations, string $expectedKey, int $depth = 0): array
332+
{
333+
if (isset($aggregations[$expectedKey]) || $depth > 3) {
334+
return $aggregations;
335+
}
336+
337+
foreach ($aggregations as $key => $value) {
338+
if (! is_array($value)) {
339+
continue;
340+
}
341+
342+
if (isset($value[$expectedKey])) {
343+
return $value;
344+
}
345+
346+
// Drill into nested/filter wrappers (they always have doc_count)
347+
if (isset($value['doc_count'])) {
348+
$inner = $this->unwrapNestedAggregation($value, $expectedKey, $depth + 1);
349+
if (isset($inner[$expectedKey])) {
350+
return $inner;
351+
}
352+
}
353+
}
354+
355+
return $aggregations;
356+
}
322357
}

0 commit comments

Comments
 (0)