@@ -14,6 +14,7 @@ import type {
1414} from '@kbn/rule-data-utils' ;
1515import { ALERT_URL , ALERT_UUID } from '@kbn/rule-data-utils' ;
1616import { intersection as lodashIntersection , isArray } from 'lodash' ;
17+ import { setWith } from '@kbn/safer-lodash-set' ;
1718
1819import { getAlertDetailsUrl } from '../../../../../common/utils/alert_detail_path' ;
1920import { DEFAULT_ALERTS_INDEX } from '../../../../../common/constants' ;
@@ -246,11 +247,139 @@ export const objectArrayIntersection = (objects: object[]) => {
246247 }
247248} ;
248249
250+ const isPlainObject = ( v : unknown ) : v is Record < string , unknown > =>
251+ typeof v === 'object' && v !== null && ! Array . isArray ( v ) ;
252+
253+ /**
254+ * Flattens an object to path-value pairs and records which paths came from
255+ * dot-notation keys. A path is "from dot" when it was produced by expanding
256+ * a single key that contained '.'.
257+ */
258+ const flattenToPathValuesWithNotation = (
259+ obj : object
260+ ) : { pathValues : [ string [ ] , unknown ] [ ] ; dotPaths : Set < string > } => {
261+ const pathValues : [ string [ ] , unknown ] [ ] = [ ] ;
262+ const dotPaths = new Set < string > ( ) ;
263+ const stack : [ object , string [ ] ] [ ] = [ [ obj , [ ] ] ] ;
264+ while ( stack . length > 0 ) {
265+ const [ o , prefix ] = stack . pop ( ) as [ object , string [ ] ] ;
266+ for ( const [ k , v ] of Object . entries ( o ) ) {
267+ const path = prefix . concat ( k . includes ( '.' ) ? k . split ( '.' ) : [ k ] ) ;
268+ const keyWasDot = k . includes ( '.' ) ;
269+ if ( isPlainObject ( v ) ) {
270+ stack . push ( [ v , path ] ) ;
271+ } else {
272+ pathValues . push ( [ path , v ] ) ;
273+ if ( keyWasDot ) {
274+ dotPaths . add ( path . join ( '.' ) ) ;
275+ }
276+ }
277+ }
278+ }
279+ return { pathValues, dotPaths } ;
280+ } ;
281+
282+ /**
283+ * Flattens an object to path-value pairs (paths as string arrays).
284+ * Dot-notation keys are expanded so 'user.email' and user: { email } yield the same path.
285+ */
286+ const flattenToPathValues = ( obj : object ) : [ string [ ] , unknown ] [ ] =>
287+ flattenToPathValuesWithNotation ( obj ) . pathValues ;
288+
289+ /**
290+ * Builds a nested object from path-value pairs.
291+ */
292+ const unflatten = ( pathValues : [ string [ ] , unknown ] [ ] ) : Record < string , unknown > => {
293+ const result : Record < string , unknown > = { } ;
294+ for ( const [ path , value ] of pathValues ) {
295+ setWith ( result , path , value ) ;
296+ }
297+ return result ;
298+ } ;
299+
300+ /** Result of intersecting two values: either a literal value to set, or a nested pair to process later. */
301+ type IntersectionResult =
302+ | { kind : 'value' ; value : unknown }
303+ | { kind : 'nested' ; a : Record < string , unknown > ; b : Record < string , unknown > } ;
304+
305+ /**
306+ * Computes the intersection of two values (primitives, arrays, or nested objects).
307+ * For nested objects, returns a descriptor so the caller can push work onto the stack.
308+ */
309+ const intersectValues = ( aVal : unknown , bVal : unknown ) : IntersectionResult | undefined => {
310+ if ( isPlainObject ( aVal ) && isPlainObject ( bVal ) ) {
311+ return { kind : 'nested' , a : aVal , b : bVal } ;
312+ }
313+ if ( aVal === bVal ) {
314+ return { kind : 'value' , value : aVal } ;
315+ }
316+ if ( isArray ( aVal ) || isArray ( bVal ) ) {
317+ const arrA = isArray ( aVal ) ? aVal : [ aVal ] ;
318+ const arrB = isArray ( bVal ) ? bVal : [ bVal ] ;
319+ return { kind : 'value' , value : lodashIntersection ( arrA , arrB ) } ;
320+ }
321+ return undefined ;
322+ } ;
323+
324+ const isEmptyOrAllUndefined = ( o : Record < string , unknown > ) : boolean =>
325+ Object . keys ( o ) . length === 0 || Object . values ( o ) . every ( ( v ) => v === undefined ) ;
326+
327+ /** Removes nested objects that are empty or have only undefined values (iterative). */
328+ const pruneEmptyNestedObjects = ( obj : Record < string , unknown > ) : void => {
329+ type PruneItem = [ Record < string , unknown > | null , string | null , Record < string , unknown > ] ;
330+ const pruneStack : PruneItem [ ] = [ [ null , null , obj ] ] ;
331+ const toPrune : PruneItem [ ] = [ ] ;
332+ while ( pruneStack . length > 0 ) {
333+ const item = pruneStack . pop ( ) as PruneItem ;
334+ toPrune . push ( item ) ;
335+ const [ , , o ] = item ;
336+ for ( const [ k , v ] of Object . entries ( o ) ) {
337+ if ( isPlainObject ( v ) ) {
338+ pruneStack . push ( [ o , k , v ] ) ;
339+ }
340+ }
341+ }
342+ for ( let i = toPrune . length - 1 ; i >= 0 ; i -- ) {
343+ const [ parent , key , o ] = toPrune [ i ] ;
344+ if ( parent !== null && key !== null && isEmptyOrAllUndefined ( o ) ) {
345+ delete parent [ key ] ;
346+ }
347+ }
348+ } ;
349+
350+ const hasDefinedValues = ( obj : Record < string , unknown > ) : boolean =>
351+ Object . values ( obj ) . some ( ( v ) => v !== undefined ) ;
352+
353+ /**
354+ * Converts a normalized (nested) intersection result to output form:
355+ * paths that were dot notation in both inputs stay as dot keys; others stay nested.
356+ */
357+ const applyNotationPreference = (
358+ intersectionNested : Record < string , unknown > ,
359+ aDotPaths : Set < string > ,
360+ bDotPaths : Set < string >
361+ ) : Record < string , unknown > => {
362+ const pathValues = flattenToPathValues ( intersectionNested ) ;
363+ const result : Record < string , unknown > = { } ;
364+ for ( const [ path , value ] of pathValues ) {
365+ const pathStr = path . join ( '.' ) ;
366+ if ( aDotPaths . has ( pathStr ) && bDotPaths . has ( pathStr ) ) {
367+ result [ pathStr ] = value ;
368+ } else {
369+ setWith ( result , path , value ) ;
370+ }
371+ }
372+ return result ;
373+ } ;
374+
249375/**
250- * Finds the intersection of two objects by recursively
251- * finding the "intersection" of each of of their common keys'
376+ * Finds the intersection of two objects iteratively by
377+ * finding the "intersection" of each of their common keys'
252378 * values. If an intersection cannot be found between a key's
253379 * values, the value will be undefined in the returned object.
380+ * Dot-notation keys (e.g. 'user.email') and nested notation
381+ * (e.g. user: { email }) are treated as the same; the result
382+ * is always in nested form.
254383 *
255384 * @param a object
256385 * @param b object
@@ -260,40 +389,40 @@ export const objectPairIntersection = (a: object | undefined, b: object | undefi
260389 if ( a === undefined || b === undefined ) {
261390 return undefined ;
262391 }
263- const intersection : Record < string , unknown > = { } ;
264- Object . entries ( a ) . forEach ( ( [ key , aVal ] ) => {
265- if ( key in b ) {
266- const bVal = ( b as Record < string , unknown > ) [ key ] ;
267- if (
268- typeof aVal === 'object' &&
269- ! ( aVal instanceof Array ) &&
270- aVal !== null &&
271- typeof bVal === 'object' &&
272- ! ( bVal instanceof Array ) &&
273- bVal !== null
274- ) {
275- intersection [ key ] = objectPairIntersection ( aVal , bVal ) ;
276- } else if ( aVal === bVal ) {
277- intersection [ key ] = aVal ;
278- } else if ( isArray ( aVal ) && isArray ( bVal ) ) {
279- intersection [ key ] = lodashIntersection ( aVal , bVal ) ;
280- } else if ( isArray ( aVal ) && ! isArray ( bVal ) ) {
281- intersection [ key ] = lodashIntersection ( aVal , [ bVal ] ) ;
282- } else if ( ! isArray ( aVal ) && isArray ( bVal ) ) {
283- intersection [ key ] = lodashIntersection ( [ aVal ] , bVal ) ;
392+ const { pathValues : aPathValues , dotPaths : aDotPaths } = flattenToPathValuesWithNotation ( a ) ;
393+ const { pathValues : bPathValues , dotPaths : bDotPaths } = flattenToPathValuesWithNotation ( b ) ;
394+ const aNorm = unflatten ( aPathValues ) ;
395+ const bNorm = unflatten ( bPathValues ) ;
396+ const intersectionNested : Record < string , unknown > = { } ;
397+ const stack : [ Record < string , unknown > , Record < string , unknown > , Record < string , unknown > ] [ ] = [
398+ [ intersectionNested , aNorm , bNorm ] ,
399+ ] ;
400+ while ( stack . length > 0 ) {
401+ const [ target , aObj , bObj ] = stack . pop ( ) as [
402+ Record < string , unknown > ,
403+ Record < string , unknown > ,
404+ Record < string , unknown >
405+ ] ;
406+ for ( const [ key , aVal ] of Object . entries ( aObj ) ) {
407+ if ( key in bObj ) {
408+ const result = intersectValues ( aVal , bObj [ key ] ) ;
409+ if ( result === undefined ) {
410+ // eslint-disable-next-line no-continue
411+ continue ;
412+ }
413+ if ( result . kind === 'nested' ) {
414+ const nextTarget : Record < string , unknown > = { } ;
415+ target [ key ] = nextTarget ;
416+ stack . push ( [ nextTarget , result . a , result . b ] ) ;
417+ } else {
418+ target [ key ] = result . value ;
419+ }
284420 }
285421 }
286- } ) ;
287- // Count up the number of entries that are NOT undefined in the intersection
288- // If there are no keys OR all entries are undefined, return undefined
289- if (
290- Object . values ( intersection ) . reduce (
291- ( acc : number , value ) => ( value !== undefined ? acc + 1 : acc ) ,
292- 0
293- ) === 0
294- ) {
422+ }
423+ pruneEmptyNestedObjects ( intersectionNested ) ;
424+ if ( ! hasDefinedValues ( intersectionNested ) ) {
295425 return undefined ;
296- } else {
297- return intersection ;
298426 }
427+ return applyNotationPreference ( intersectionNested , aDotPaths , bDotPaths ) ;
299428} ;
0 commit comments