Recursively filter array of objects

JavascriptArraysRecursionFilter

Javascript Problem Overview


Hitting a wall with this one, thought I would post it here in case some kind soul has come across a similar one. I have some data that looks something like this:

const input = [  {    value: 'Miss1',    children: [      { value: 'Miss2' },      { value: 'Hit1', children: [ { value: 'Miss3' } ] }
    ]
  },
  {
    value: 'Miss4',
    children: [
      { value: 'Miss5' },
      { value: 'Miss6', children: [ { value: 'Hit2' } ] }
    ]
  },
  {
    value: 'Miss7',
    children: [
      { value: 'Miss8' },
      { value: 'Miss9', children: [ { value: 'Miss10' } ] }
    ]
  },
  {
    value: 'Hit3',
    children: [
      { value: 'Miss11' },
      { value: 'Miss12', children: [ { value: 'Miss13' } ] }
    ]
  },
  {
    value: 'Miss14',
    children: [
      { value: 'Hit4' },
      { value: 'Miss15', children: [ { value: 'Miss16' } ] }
    ]
  },
];

I don't know at run time how deep the hierarchy will be, i.e. how many levels of objects will have a children array. I have simplified the example somewhat, I will actually need to match the value properties against an array of search terms. Let's for the moment assume that I am matching where value.includes('Hit').

I need a function that returns a new array, such that:

  • Every non-matching object with no children, or no matches in children hierarchy, should not exist in output object

  • Every object with a descendant that contains a matching object, should remain

  • All descendants of matching objects should remain

I am considering a 'matching object' to be one with a value property that contains the string Hit in this case, and vice versa.

The output should look something like the following:

const expected = [  {    value: 'Miss1',    children: [      { value: 'Hit1', children: [ { value: 'Miss3' } ] }
    ]
  },
  {
    value: 'Miss4',
    children: [
      { value: 'Miss6', children: [ { value: 'Hit2' } ] }
    ]
  },
  {
    value: 'Hit3',
    children: [
      { value: 'Miss11' },
      { value: 'Miss12', children: [ { value: 'Miss13' } ] }
    ]
  },
  {
    value: 'Miss14',
    children: [
      { value: 'Hit4' },
    ]
  }
];

Many thanks to anyone who took the time to read this far, will post my solution if I get there first.

Javascript Solutions


Solution 1 - Javascript

Using .filter() and making a recursive call as I described in the comment above is basically what you need. You just need to update each .children property with the result of the recursive call before returning.

The return value is just the .length of the resulting .children collection, so if there's at least one, the object is kept.

var res = input.filter(function f(o) {
  if (o.value.includes("Hit")) return true

  if (o.children) {
    return (o.children = o.children.filter(f)).length
  }
})

const input = [  {    value: 'Miss1',    children: [      { value: 'Miss2' },      { value: 'Hit1', children: [ { value: 'Miss3' } ] }
    ]
  },
  {
    value: 'Miss4',
    children: [
      { value: 'Miss5' },
      { value: 'Miss6', children: [ { value: 'Hit2' } ] }
    ]
  },
  {
    value: 'Miss7',
    children: [
      { value: 'Miss8' },
      { value: 'Miss9', children: [ { value: 'Miss10' } ] }
    ]
  },
  {
    value: 'Hit3',
    children: [
      { value: 'Miss11' },
      { value: 'Miss12', children: [ { value: 'Miss13' } ] }
    ]
  },
  {
    value: 'Miss14',
    children: [
      { value: 'Hit4' },
      { value: 'Miss15', children: [ { value: 'Miss16' } ] }
    ]
  },
];

var res = input.filter(function f(o) {
  if (o.value.includes("Hit")) return true

  if (o.children) {
    return (o.children = o.children.filter(f)).length
  }
})
console.log(JSON.stringify(res, null, 2))


Note that .includes() on a String is ES7, so may need to be patched for legacy browsers. You can use the traditional .indexOf("Hit") != -1 in its place.


To not mutate the original, create a map function that copies an object and use that before the filter.

function copy(o) {
  return Object.assign({}, o)
}

var res = input.map(copy).filter(function f(o) {
  if (o.value.includes("Hit")) return true

  if (o.children) {
    return (o.children = o.children.map(copy).filter(f)).length
  }
})

To really squeeze the code down, you could do this:

var res = input.filter(function f(o) {
  return o.value.includes("Hit") ||
         o.children && (o.children = o.children.filter(f)).length
})

Though it gets a little hard to read.

Solution 2 - Javascript

Here's a function that'll do what you're looking for. Essentially it will test every item in arr for a match, then recursively call filter on its children. Also Object.assign is used so that the underlying object isn't changed.

function filter(arr, term) {
    var matches = [];
    if (!Array.isArray(arr)) return matches;

    arr.forEach(function(i) {
	    if (i.value.includes(term)) {
		    matches.push(i);
	    } else {
		    let childResults = filter(i.children, term);
		    if (childResults.length)
			    matches.push(Object.assign({}, i, { children: childResults }));
	    }
    })

    return matches;
}

Solution 3 - Javascript

I think it will be a recursive solution. Here is one that I tried.

function find(obj, key) {
  if (obj.value && obj.value.indexOf(key) > -1){
    return true;
  }
  if (obj.children && obj.children.length > 0){
    return obj.children.reduce(function(obj1, obj2){
      return find(obj1, key) || find(obj2, key);
    }, {}); 
  } 
  return false;
}

var output = input.filter(function(obj){
     return find(obj, 'Hit');
 });
console.log('Result', output);

Solution 4 - Javascript

const input = [  {    value: 'Miss1',    children: [      { value: 'Miss1' },      { value: 'Hit1', children: [ { value: 'Miss3' } ] }
    ]
  },
  {
    value: 'Miss4',
    children: [
      { value: 'Miss5' },
      { value: 'Miss6', children: [ { value: 'Hit2' } ] }
    ]
  },
  {
    value: 'Miss7',
    children: [
      { value: 'Miss8' },
      { value: 'Miss9', children: [ { value: 'Miss10' } ] }
    ]
  },
  {
    value: 'Hit3',
    children: [
      { value: 'Miss11' },
      { value: 'Miss12', children: [ { value: 'Miss13' } ] }
    ]
  },
  {
    value: 'Miss14asds',
    children: [
      { value: 'Hit4sdas' },
      { value: 'Miss15', children: [ { value: 'Miss16' } ] }
    ]
  },
];

function filter(arr, term) {
    var matches = [];
    
    if (!Array.isArray(arr)) return matches;

    arr.forEach(function(i) {
     
        if (i.value === term) {
    
         const filterData =  (i.children && Array.isArray(i.children))? i.children.filter(values => values.value ===term):[];
         console.log(filterData)
         i.children =filterData;
            matches.push(i);
          
        } else {
       // console.log(i.children)
            let childResults = filter(i.children, term);
            if (childResults.length)
     matches.push(Object.assign({}, i, { children: childResults }));
        }
    })

    return matches;
}


const filterData= filter(input,'Miss1');
console.log(filterData)

Below code for filter the parent and child array data using recursive function

const input = [  {    value: 'Miss1',    children: [      { value: 'Miss2' },      { value: 'Hit1', children: [ { value: 'Miss3' } ] }
    ]
  },
  {
    value: 'Miss4',
    children: [
      { value: 'Miss5' },
      { value: 'Miss6', children: [ { value: 'Hit2' } ] }
    ]
  },
  {
    value: 'Miss7',
    children: [
      { value: 'Miss8' },
      { value: 'Miss9', children: [ { value: 'Miss10' } ] }
    ]
  },
  {
    value: 'Hit3',
    children: [
      { value: 'Miss11' },
      { value: 'Miss12', children: [ { value: 'Miss13' } ] }
    ]
  },
  {
    value: 'Miss14',
    children: [
      { value: 'Hit4' },
      { value: 'Miss15', children: [ { value: 'Miss16' } ] }
    ]
  },
];

var res = input.filter(function f(o) {
  if (o.value.includes("Hit")) return true

  if (o.children) {
    return (o.children = o.children.filter(f)).length
  }
})
console.log(JSON.stringify(res, null, 2))

Solution 5 - Javascript

Here is a good solution which utilizes the array reduce function which results in more readable code then the other solutions. Also it is more readable in my opinion. We are calling the filter function recursively to filter an array along with its children

const input = [  {    value: "Miss1",    children: [      { value: "Miss2" },      { value: "Hit1", children: [{ value: "Miss3" }] },
    ],
  },
  {
    value: "Miss4",
    children: [
      { value: "Miss5" },
      { value: "Miss6", children: [{ value: "Hit2" }] },
    ],
  },
  {
    value: "Miss7",
    children: [
      { value: "Miss8" },
      { value: "Miss9", children: [{ value: "Miss10" }] },
    ],
  },
  {
    value: "Hit3",
    children: [
      { value: "Miss11" },
      { value: "Miss12", children: [{ value: "Miss13" }] },
    ],
  },
  {
    value: "Miss14",
    children: [
      { value: "Hit4" },
      { value: "Miss15", children: [{ value: "Miss16" }] },
    ],
  },
];

function recursiveFilter(arr) {
  return arr.reduce(function filter(prev, item) {
    if (item.value.includes("Hit")) {
      if (item.children && item.children.length > 0) {
        return prev.concat({
          ...item,
          children: item.children.reduce(filter, []),
        });
      } else {
        return item;
      }
    }
    return prev;
  }, []);
}

console.log(recursiveFilter(input));

Solution 6 - Javascript

Array.prototype.flatMap is a good fit for recursive filtering. Similar to map, filter and reduce, using flatMap does not modify the original input -

const findHits = (t = []) =>
  t.flatMap(({ value, children }) => {
    if (value.startsWith("Hit"))
      return [{ value, children }]
    else {
      const r = findHits(children)
      return r.length ? [{ value, children: r }] : []
    }
  })

const input =
  [{value:'Miss1',children:[{value:'Miss2'},{value:'Hit1', children:[{value:'Miss3'}]}]},{value:'Miss4',children:[{value:'Miss5'},{value:'Miss6', children:[{value:'Hit2'}]}]},{value:'Miss7',children:[{value:'Miss8'},{value:'Miss9', children:[{value:'Miss10'}]}]},{value:'Hit3',children:[{value:'Miss11'},{value:'Miss12', children:[{value:'Miss13'}]}]},{value:'Miss14',children:[{value:'Hit4'},{value:'Miss15', children:[{value:'Miss16'}]}]}]

const result =
  findHits(input)

console.log(JSON.stringify(result, null, 2))

[  {    "value": "Miss1",    "children": [      {        "value": "Hit1",        "children": [          {            "value": "Miss3"          }        ]
      }
    ]
  },
  {
    "value": "Miss4",
    "children": [
      {
        "value": "Miss6",
        "children": [
          {
            "value": "Hit2"
          }
        ]
      }
    ]
  },
  {
    "value": "Hit3",
    "children": [
      {
        "value": "Miss11"
      },
      {
        "value": "Miss12",
        "children": [
          {
            "value": "Miss13"
          }
        ]
      }
    ]
  },
  {
    "value": "Miss14",
    "children": [
      {
        "value": "Hit4"
      }
    ]
  }
]

Solution 7 - Javascript

Alternatively you can use _.filterDeep method from deepdash extension for lodash:

var keyword = 'Hit';
var foundHit = _.filterDeep(
  input,
  function(value) {
    return value.value.includes(keyword);
  },
  {
    tree: true,
    onTrue: { skipChildren: true },
  }
);

Here is a full test for your case

Solution 8 - Javascript

Here is a different type of solution using object-scan.

This solution is iterative instead of recursive. It works because object-scan iterates in delete safe order. Basically we traverse into the tree and break on any match. Then we keep track of matches at the different depth (ensuring that we reset that information appropriately as we traverse into a neighbouring branches).

It's mostly an academic exercise as the recursive approach is cleaner and faster. However this answer might be interesting if there is additional processing that needs to be done or stack depth errors are a problem.

// const objectScan = require('object-scan');

const myInput = [{ value: 'Miss1', children: [{ value: 'Miss2' }, { value: 'Hit1', children: [{ value: 'Miss3' }] }] }, { value: 'Miss4', children: [{ value: 'Miss5' }, { value: 'Miss6', children: [{ value: 'Hit2' }] }] }, { value: 'Miss7', children: [{ value: 'Miss8' }, { value: 'Miss9', children: [{ value: 'Miss10' }] }] }, { value: 'Hit3', children: [{ value: 'Miss11' }, { value: 'Miss12', children: [{ value: 'Miss13' }] }] }, { value: 'Miss14', children: [{ value: 'Hit4' }, { value: 'Miss15', children: [{ value: 'Miss16' }] }] }];
const myFilterFn = ({ value }) => value.includes('Hit');

const rewrite = (input, filterFn) => objectScan(['**(^children$)'], {
  breakFn: ({ isMatch, value }) => isMatch && filterFn(value),
  filterFn: ({
    parent, property, key, value, context
  }) => {
    const len = (key.length - 1) / 2;
    if (context.prevLen <= len) {
      context.matches.length = context.prevLen + 1;
    }
    context.prevLen = len;

    if (context.matches[len + 1] === true || filterFn(value)) {
      context.matches[len] = true;
      return false;
    }
    parent.splice(property, 1);
    return true;
  },
  useArraySelector: false,
  rtn: 'count'
})(input, { prevLen: 0, matches: [] });

console.log(rewrite(myInput, myFilterFn)); // returns number of deletions
// => 8

console.log(myInput);
// => [ { value: 'Miss1', children: [ { value: 'Hit1', children: [ { value: 'Miss3' } ] } ] }, { value: 'Miss4', children: [ { value: 'Miss6', children: [ { value: 'Hit2' } ] } ] }, { value: 'Hit3', children: [ { value: 'Miss11' }, { value: 'Miss12', children: [ { value: 'Miss13' } ] } ] }, { value: 'Miss14', children: [ { value: 'Hit4' } ] } ]

<script src="https://bundle.run/[email protected]"></script>

.as-console-wrapper {max-height: 100% !important; top: 0}

Disclaimer: I'm the author of object-scan

Solution 9 - Javascript

Was looking for another way to solve this problem without directly mutating the children array and came up with this:

const input = [  {    value: 'Miss1',    children: [      { value: 'Miss2' },      { value: 'Hit1', children: [ { value: 'Miss3' } ] }
    ]
  },
  {
    value: 'Miss4',
    children: [
      { value: 'Miss5' },
      { value: 'Miss6', children: [ { value: 'Hit2' } ] }
    ]
  },
  {
    value: 'Miss7',
    children: [
      { value: 'Miss8' },
      { value: 'Miss9', children: [ { value: 'Miss10' } ] }
    ]
  },
  {
    value: 'Hit3',
    children: [
      { value: 'Miss11' },
      { value: 'Miss12', children: [ { value: 'Miss13' } ] }
    ]
  },
  {
    value: 'Miss14',
    children: [
      { value: 'Hit4' },
      { value: 'Miss15', children: [ { value: 'Miss16' } ] }
    ]
  },
];

const filtered = input.reduce(function fr(acc, curr) {
  
  if (curr.children) {
  	const children = curr.children.reduce(fr, []);
    if (children.length) {
    	return acc.concat({ ...curr, children: children });
    }
  }
  if (curr.value.includes('Hit')) {
    return acc.concat(curr);
  } else {
    return acc;
  }
}, []);

console.log(filtered);

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionNathan PowerView Question on Stackoverflow
Solution 1 - Javascriptuser1106925View Answer on Stackoverflow
Solution 2 - Javascriptjj689View Answer on Stackoverflow
Solution 3 - JavascriptZohaib IjazView Answer on Stackoverflow
Solution 4 - Javascriptsudheer nunnaView Answer on Stackoverflow
Solution 5 - JavascriptMalik BagwalaView Answer on Stackoverflow
Solution 6 - JavascriptMulanView Answer on Stackoverflow
Solution 7 - JavascriptYuri GorView Answer on Stackoverflow
Solution 8 - JavascriptvincentView Answer on Stackoverflow
Solution 9 - Javascriptbstiffler582View Answer on Stackoverflow