Skip to content

Commit

Permalink
Build objects from arbitrary levels of nesting
Browse files Browse the repository at this point in the history
This is a recursive implementation, but recursion depth is limited to
the number of levels of nesting, with arguments reducing in size as we
go (i.e. logarithmic)
  • Loading branch information
rylnd committed Apr 3, 2021
1 parent 1cb213c commit cab30d4
Show file tree
Hide file tree
Showing 2 changed files with 129 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { EventHit } from '../../../../../../common/search_strategy';
import { TIMELINE_EVENTS_FIELDS } from './constants';
import { buildObjectFromField, formatTimelineData } from './helpers';
import { buildObjectForFieldPath, formatTimelineData } from './helpers';
import { eventHit } from '../mocks';

describe('#formatTimelineData', () => {
Expand Down Expand Up @@ -400,27 +400,42 @@ describe('#formatTimelineData', () => {
});
});

describe('buildObjectFromField', () => {
it('base case', () => {
expect(buildObjectFromField('@timestamp', eventHit)).toEqual({
describe('buildObjectForFieldPath', () => {
it('builds an object from a single non-nested field', () => {
expect(buildObjectForFieldPath('@timestamp', eventHit)).toEqual({
'@timestamp': ['2020-11-17T14:48:08.922Z'],
});
});

it('another base case', () => {
const event = {
it('does not misinterpret non-nested fields with a common prefix', () => {
// @ts-expect-error hit is minimal
const hit: EventHit = {
fields: {
'foo.bar': ['baz'],
'foo.barBaz': ['foo'],
},
};

expect(buildObjectForFieldPath('foo.barBaz', hit)).toEqual({
foo: { barBaz: ['foo'] },
});
});

it('builds an array of objects from a nested field', () => {
// @ts-expect-error hit is minimal
const hit: EventHit = {
fields: {
foo: [{ bar: ['baz'] }],
},
};
// @ts-expect-error nestedEvent is minimal
expect(buildObjectFromField('foo.bar', event, 'foo')).toEqual({
expect(buildObjectForFieldPath('foo.bar', hit, 'foo')).toEqual({
foo: [{ bar: ['baz'] }],
});
});

it('simple nested', () => {
const nestedEvent = {
it('builds intermediate objects for nested fields', () => {
// @ts-expect-error nestedHit is minimal
const nestedHit: EventHit = {
fields: {
'foo.bar': [
{
Expand All @@ -429,8 +444,7 @@ describe('#formatTimelineData', () => {
],
},
};
// @ts-expect-error nestedEvent is minimal
expect(buildObjectFromField('foo.bar.baz', nestedEvent, 'foo.bar')).toEqual({
expect(buildObjectForFieldPath('foo.bar.baz', nestedHit, 'foo.bar')).toEqual({
foo: {
bar: [
{
Expand All @@ -441,9 +455,9 @@ describe('#formatTimelineData', () => {
});
});

it('nested field', () => {
it('builds intermediate objects at multiple levels', () => {
expect(
buildObjectFromField('threat.indicator.matched.atomic', eventHit, 'threat.indicator')
buildObjectForFieldPath('threat.indicator.matched.atomic', eventHit, 'threat.indicator')
).toEqual({
threat: {
indicator: [
Expand All @@ -462,9 +476,9 @@ describe('#formatTimelineData', () => {
});
});

it('nested field with multiple values', () => {
it('preserves multiple values for a single leaf', () => {
expect(
buildObjectFromField('threat.indicator.matched.field', eventHit, 'threat.indicator')
buildObjectForFieldPath('threat.indicator.matched.field', eventHit, 'threat.indicator')
).toEqual({
threat: {
indicator: [
Expand All @@ -482,5 +496,76 @@ describe('#formatTimelineData', () => {
},
});
});

describe('multiple levels of nested fields', () => {
let nestedHit: EventHit;

beforeEach(() => {
// @ts-expect-error nestedHit is minimal
nestedHit = {
fields: {
'nested_1.foo': [
{
'nested_2.bar': [
{ leaf: ['leaf_value'], leaf_2: ['leaf_2_value'] },
{ leaf_2: ['leaf_2_value_2', 'leaf_2_value_3'] },
],
},
{
'nested_2.bar': [
{ leaf: ['leaf_value_2'], leaf_2: ['leaf_2_value_4'] },
{ leaf: ['leaf_value_3'], leaf_2: ['leaf_2_value_5'] },
],
},
],
},
};
});

it('includes objects without the field', () => {
expect(
buildObjectForFieldPath('nested_1.foo.nested_2.bar.leaf', nestedHit, 'nested_1.foo')
).toEqual({
nested_1: {
foo: [
{
nested_2: {
bar: [{ leaf: ['leaf_value'] }, { leaf: [] }],
},
},
{
nested_2: {
bar: [{ leaf: ['leaf_value_2'] }, { leaf: ['leaf_value_3'] }],
},
},
],
},
});
});

it('groups multiple leaf values', () => {
expect(
buildObjectForFieldPath('nested_1.foo.nested_2.bar.leaf_2', nestedHit, 'nested_1.foo')
).toEqual({
nested_1: {
foo: [
{
nested_2: {
bar: [
{ leaf_2: ['leaf_2_value'] },
{ leaf_2: ['leaf_2_value_2', 'leaf_2_value_3'] },
],
},
},
{
nested_2: {
bar: [{ leaf_2: ['leaf_2_value_4'] }, { leaf_2: ['leaf_2_value_5'] }],
},
},
],
},
});
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
*/

import { set } from '@elastic/safer-lodash-set';
import { get, has, merge, uniq } from 'lodash/fp';
import { get, has, isObject, merge, uniq } from 'lodash/fp';
import { Ecs } from '../../../../../../common/ecs';
import {
EventHit,
TimelineEdges,
Expand Down Expand Up @@ -100,21 +101,31 @@ const getValuesFromFields = async (
);
};

export const buildObjectFromField = (
type Fields = Record<string, unknown[] | Fields[]>;

const buildNestedObject = (fieldPath: string, fields: Fields): Partial<Ecs> => {
const nestedParentPath = getNestedParentPath(fieldPath, fields);
if (!nestedParentPath) {
return set({}, fieldPath, toStringArray(get(fieldPath, fields)));
}

const subPath = fieldPath.replace(`${nestedParentPath}.`, '');
const subFields = (get(nestedParentPath, fields) ?? []) as Fields[];

return set(
{},
nestedParentPath,
subFields.map((subField) => buildNestedObject(subPath, subField))
);
};

export const buildObjectForFieldPath = (
fieldPath: string,
hit: EventHit,
nestedParentPath?: string
) => {
): Partial<Ecs> => {
if (nestedParentPath) {
const childPath = fieldPath.replace(`${nestedParentPath}.`, '');
return set(
{},
nestedParentPath,
(get(nestedParentPath, hit.fields) ?? []).map((nestedFields) => {
const value = get(childPath, nestedFields);
return set({}, childPath, toStringArray(value));
})
);
return buildNestedObject(fieldPath, hit.fields);
} else {
const value = has(fieldPath, hit._source)
? get(fieldPath, hit._source)
Expand All @@ -126,7 +137,10 @@ export const buildObjectFromField = (
/**
* If a prefix of our full field path is present as a field, we know that our field is nested
*/
const getNestedParentPath = (fieldPath: string, fields: Record<string, unknown[]> = {}) => {
const getNestedParentPath = (fieldPath: string, fields: Fields): string | undefined => {
if (!isObject(fields)) {
return;
}
const fieldParts = fieldPath.split('.');
return Object.keys(fields).find(
(field) =>
Expand Down Expand Up @@ -161,7 +175,7 @@ const mergeTimelineFieldsWithHit = async <T>(
ecs: ecsFields.includes(fieldName)
? {
...get('node.ecs', flattenedFields),
...buildObjectFromField(fieldName, hit, nestedParentPath),
...buildObjectForFieldPath(fieldName, hit, nestedParentPath),
}
: get('node.ecs', flattenedFields),
},
Expand Down

0 comments on commit cab30d4

Please sign in to comment.