@@ -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}
0 commit comments