0

I have some JSON with the following structure:

{
  "root": {
    "Europe": {
      "children": [
        {
          "name": "Germany"
        },
        {
          "name": "England",
          "children": [
            {
              "name": "London",
              "search_words": ["city", "capital"],
              "children": [
                {
                  "name": "Westminster",
                  "search_words": ["borough"]
                }
              ]
            },
            {
              "name": "Manchester",
              "search_words": ["city"]
            }
          ]
        },
        {
          "name": "France",
          "children": [
            {
              "name": "Paris",
              "search_words": ["city", "capital"]
            }
          ]
        }
      ]
    },
    "North America": {
      "children": [
        {
          "name": "Canada",
          "children": [
            {
              "name": "Toronto"
            },
            {
              "name": "Ottawa"
            }
          ]
        },
        {
          "name": "United States"
        }
      ]
    }
  }
}

I want to filter the JSON based on a text search. I should be able to search by both the name and any search_words. The final problem is that the JSON can be arbitrarily deep so it needs to be able to search through all levels.

I also need to loop through and print out the JSON in HTML (using Vue) and for it to update based on the search. Currently unclear how I can do this without knowing how many levels deep the JSON will go?

Any help will be greatly appreciated!

  • Use recursion to iterate them all. – John Dec 24 '20 at 11:20
  • 2
    Does this answer your question? [Filtering an array with a deeply nested array in JS](https://stackoverflow.com/questions/45482466/filtering-an-array-with-a-deeply-nested-array-in-js) or, [Recursively filter array of objects](https://stackoverflow.com/questions/38132146/recursively-filter-array-of-objects) – pilchard Dec 24 '20 at 11:22
  • Welcome to Stack Overflow! Please take the [tour](https://stackoverflow.com/tour), visit the [help center](https://stackoverflow.com/help), and read up on [asking good questions](https://stackoverflow.com/help/asking). After doing some research and [searching](https://stackoverflow.com/help/searching) for related topics on SO, try it yourself. If you're stuck, post a [Minimal, Complete, and Verifiable example](https://stackoverflow.com/help/mcve) of your attempt note exactly where you're stuck. People will be glad to help. – Scott Sauyet Dec 24 '20 at 14:21
  • This should be very easy, Use recursion. google Use recursion and learn about it. Then present some code here so we could help you out – Alen.Toma Dec 24 '20 at 19:29
  • Updated my answer to show a structure-preserving `deepFilter` operation. – Scott Sauyet Dec 31 '20 at 15:47

3 Answers3

1

I answered a similar question recently. I'm sharing it here because I think it provides a relevant foundation for this post. Before we begin though, we must first address the irregular shape of your input data -

const data2 =
  { name:"root"
  , children:
      Array.from
        ( Object.entries(data.root)
        , ([ country, _ ]) =>
            Object.assign({ name:country }, _)
        )
  }

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

Now we can see data2 is a uniform { name, children: [ ... ]} shape -

{
  "name": "root",
  "children": [
    {
      "name": "Europe",
      "children": [
        { "name": "Germany" },
        {
          "name": "England",
          "children": [
            {
              "name": "London",
              "search_words": [ "city", "capital" ],
              "children": [
                {
                  "name": "Westminster",
                  "search_words": [ "borough" ]
                }
              ]
            },
            {
              "name": "Manchester",
              "search_words": [ "city" ]
            }
          ]
        },
        {
          "name": "France",
          "children": [
            {
              "name": "Paris",
              "search_words": [ "city", "capital" ]
            }
          ]
        }
      ]
    },
    {
      "name": "North America",
      "children": [
        {
          "name": "Canada",
          "children": [
            { "name": "Toronto" },
            { "name": "Ottawa" }
          ]
        },
        { "name": "United States" }
      ]
    }
  ]
}

Now we write a generic depth-first traversal function, dft -

function* dft (t, path = [])
{ for (const _ of t.children ?? [])
    yield* dft(_, [...path, t.name ])
  yield [path, t]
}

Our dft function gives us a path to each element, e, in our input tree, t -

["root","Europe"]
{"name":"Germany"}

["root","Europe","England","London"]
{name:"Westminster", search_words:["borough"]}

["root","Europe","England"]
{name:"London", search_words:["city","capital"], children:[...]}

["root","Europe","England"]
{name:"Manchester", search_words:["city"]}

["root","Europe"]
{name:"England", children:[...]}

["root","Europe","France"]
{name:"Paris", search_words:["city","capital"]}

["root","Europe"]
{name:"France", children:[...]}

["root"]
{name:"Europe", children:[...]}

["root","North America","Canada"]
{name:"Toronto"}

Now that we know that path to each of the nodes, we can create an index which uses the path and any search_words to link back to the node -

const index = t =>
  Array.from
    ( dft(t)
    , ([path, e]) =>
        [ [...path, e.name, ...e.search_words ?? [] ] // all words to link to e
        , e                                           // e
        ]
    )
    .reduce
      ( (m, [ words, e ]) =>
          insertAll(m, words, e) // update the index using generic helper
      , new Map
      )

This depends on a generic helper insertAll -

const insertAll = (m, keys, value) =>
  keys.reduce
    ( (m, k) =>
        m.set(k, [ ...m.get(k) ?? [], value ])
    , m
    )

With index finished, we have a way to create a fast lookup for any search term -

const myIndex = 
  index(data2)

console.log(myIndex)
Map 
{ "Europe" =>
    [{"name":"Germany"},{"name":"Westminster",...},{"name":"London",...},{"name":"Manchester",...},{"name":"England"...},{"name":"Manchester",...}]},{"name":"Paris",...},{"name":"France"...},{"name":"Europe"...},{"name":"Manchester",...}]},{"name":"France"...}]}]

, "Germany" => 
    [{"name":"Germany"}]

, "England" =>
    [{"name":"Westminster",...},{"name":"London",...},{"name":"Manchester",...},{"name":"England"...},{"name":"Manchester",...}]}]

, "London" =>
    [{"name":"Westminster",...},{"name":"London",...}]

, "Westminster" =>
    [{"name":"Westminster",...}]

, "borough" =>
    [{"name":"Westminster",...}]

, "city" =>
    [{"name":"London",...},{"name":"Manchester",...},{"name":"Paris",...}]

, "capital" =>
    [{"name":"London",...},{"name":"Paris",...}]

, "Manchester" =>
    [{"name":"Manchester",...}]

, "France" =>
    [{"name":"Paris",...},{"name":"France"...}]

, "Paris" =>
    [{"name":"Paris",...}]

, "North America" =>
    [{"name":"Toronto"},{"name":"Ottawa"},{"name":"Canada"...},{"name":"United States"},{"name":"North America"...},
    {"name":"United States"}]}]

, "Canada" =>
    [{"name":"Toronto"},{"name":"Ottawa"},{"name":"Canada"...}]

, "Toronto" =>
    [{"name":"Toronto"}]

, "Ottawa" =>
    [{"name":"Ottawa"}]

, "United States" =>
    [{"name":"United States"}]   
}

This should highlight the remaining inconsistencies in your data. For example, you have some nodes nested under city, capital, or borough. Also worth noting that we should probably use s.toLowerCase() on all of the index keys so that lookups can be case-insensitive. This is an exercise left for the reader.

Creating the index is easy and you only need to do it once -

const myIndex = 
  index(data2)

Your index can be reused for as many lookups as you need -

console.log(myIndex.get("Toronto") ?? [])
console.log(myIndex.get("France") ?? [])
console.log(myIndex.get("Paris") ?? [])
console.log(myIndex.get("Canada") ?? [])
console.log(myIndex.get("Zorp") ?? [])
[{"name":"Toronto"}]
[{"name":"Paris",...},{"name":"France"...}]
[{"name":"Paris",...}]
[{"name":"Toronto"},{"name":"Ottawa"},{"name":"Canada"...}]
[]

Inserting the results in you Vue application is left for you.

Mulan
  • 129,518
  • 31
  • 228
  • 259
0

As Thankyou points out, your inconsistent data format makes it harder to write nice code for this. My approach is slightly different. Instead of transforming your data, I write a wrapper to my generic function to handle this output in a more useful manner.

We start with a function collect, that will work recursively with {name?, search_words?, children?, ...rest} objects, returning the nodes that match a given predicate and recurring on the children. We call this with a function, search, that takes a search term and makes a predicate from it. (Here we test if name or any search_term matches the term; this would be easy to modify for partial matches, for case insensitivity, and so on.)

Then we write the wrapper I mentioned, searchLocations. It descends into the .root node and then maps and combines the results of calling search on each of the root's values.

const collect = (pred) => ({children = [], ...rest}) => [
  ... (pred (rest) ? [rest] : []),
  ... children .flatMap (collect (pred))
]
  
const search = (term) => 
  collect (({name = '', search_words = []}) => name == term || search_words .includes (term))

const searchLocations = (locations, term) => 
  Object.values (locations .root) .flatMap (search (term))

const locations = {root: {Europe: {children: [{name: "Germany"}, {name: "England", children: [{name: "London", search_words: ["city", "capital"], children: [{name: "Westminster", search_words: ["borough"]}]}, {name: "Manchester", search_words: ["city"]}]}, {name: "France", children: [{name: "Paris", search_words: ["city", "capital"]}]}]}, "North America": {children: [{name: "Canada", children: [{name: "Toronto"}, {name: "Ottawa"}]}, {name: "United States"}]}}}

console .log ('Toronto', searchLocations (locations, 'Toronto'))
console .log ('borough', searchLocations (locations, 'borough'))
console .log ('capital', searchLocations (locations, 'capital'))
.as-console-wrapper {max-height: 100% !important; top: 0}

If what you want, as it sounds like you might, is the same structure as the input, keeping only the nodes necessary to include the matches, then we should be able to do something similar starting with a tree filtering function. I will try to look at that after the holidays.

Update

I did look at this again, looking to filter the tree as a tree. The code wasn't much harder. But this time, I did use a convert function to turn your data into a more consistent recursive structure. Thus the whole object becomes an array with two elements at the root, one with name "Europe" and the other with name "North America", each with their existing children nodes. This makes all further processing easier.

There are two key functions here:

The first is a general-purpose deepFilter function, which takes a predicate and an array of items that may have children nodes structured like their parents, and returns a new version containing anything which matches the predicate and their entirely ancestry. It looks like this:

const deepFilter = (pred) => (xs) =>
  xs .flatMap (({children = [], ...rest}, _, __, kids = deepFilter (pred) (children)) =>
    pred (rest) || kids.length
      ? [{...rest, ...(kids.length ? {children: kids} : {})}]
      : []
  )

The second is specific to this problem: searchLocation. It calls deepFilter using a predicate constructed from a search term and the converted structure already discussed. It uses a convert helper for the structure, and a search helper to turn the search term into a predicate that looks for (case-insensitive) partial matches on the name and all search terms.

const searchLocations = (loc, locations = convert(loc)) => (term) =>
  term.length ? deepFilter (search (term)) (locations) : locations

This is demonstrated by a user interface which shows the locations in nested <UL>s, with a search box that filters the locations in real time.

If, for instance, you enter just "w" in the search box, you will get

Europe
  England
    London (city, capital)
      Westminster (borough)
North America
  Canada
    Ottawa

as "Westminster" and "Ottawa" are the only matches.

If you enter "city" you will get

Europe
  England
    London (city, capital)
    Manchester (city)
  France
    Paris (city, capital)

You can see this in action in this snippet:

// utility function
const deepFilter = (pred) => (xs) =>
  xs .flatMap (({children = [], ...rest}, _, __, kids = deepFilter (pred) (children)) =>
    pred (rest) || kids.length
      ? [{...rest, ...(kids.length ? {children: kids} : {})}]
      : []
  )

// helper functions
const search = (t = '', term = t.toLowerCase()) => ({name = '', search_words = []}) =>
  term.length &&  (
    name .toLowerCase () .includes (term) ||
    search_words .some (word => word .toLowerCase() .includes (term))
  )

const convert = ({root}) => 
  Object.entries (root) .map (([name, v]) => ({name, ...v}))


// main function
const searchLocations = (loc, locations = convert(loc)) => (term) =>
  term.length ? deepFilter (search (term)) (locations) : locations


// sample data
const myData = { root: { Europe: { children: [{ name: 'Germany' }, { name: 'England', children: [{ name: 'London', search_words: ['city', 'capital'], children: [{ name: 'Westminster', search_words: ['borough'] }] }, { name: 'Manchester', search_words: ['city'] }] }, { name: 'France', children: [{ name: 'Paris', search_words: ['city', 'capital'] }] }] }, 'North America': { children: [{ name: 'Canada', children: [{ name: 'Toronto' }, { name: 'Ottawa' }] }, { name: 'United States' }] } } };


// main function specialized to given data
const mySearch = searchLocations(myData)


// UI demo
const format = (locations, searchTerm) => `<ul>${
  locations.map(loc => `<li>${
    loc.name + 
    (loc.search_words ? ` (${loc.search_words. join(', ')})` : ``) + 
    (loc.children ? format(loc.children, searchTerm) : '')
  }</li>`)
  .join('') 
}</ul>`

const render = (locations, searchTerm) => 
  document .getElementById ('main') .innerHTML = format (locations, searchTerm)

document .getElementById ('search') .addEventListener (
  'keyup',
  (e) => render (mySearch (e.target.value))
)

// show demo
render (mySearch (''))
<div style="float: right" id="control">
  <label>Search: <input type="text" id="search"/></label>
</div>
<div style="margin-top: -1em" id="main"></div>

Obviously, this does not use Vue to generate the tree, only some string manipulation and innerHTML. I leave that part to you. But it should show another way to filter a nested structure.

Scott Sauyet
  • 49,207
  • 4
  • 49
  • 103
0

Not entirely clear what you are looking for from your questions, but I'm guessing you need to modify the data to ensure that when it gets rendered, the matched data is correctly highlighted?

Here is a solution to find the matched objects using object-scan

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

const myData = { root: { Europe: { children: [{ name: 'Germany' }, { name: 'England', children: [{ name: 'London', search_words: ['city', 'capital'], children: [{ name: 'Westminster', search_words: ['borough'] }] }, { name: 'Manchester', search_words: ['city'] }] }, { name: 'France', children: [{ name: 'Paris', search_words: ['city', 'capital'] }] }] }, 'North America': { children: [{ name: 'Canada', children: [{ name: 'Toronto' }, { name: 'Ottawa' }] }, { name: 'United States' }] } } };
// eslint-disable-next-line camelcase
const mySearchFn = (term) => ({ name, search_words = [] }) => name === term || search_words.includes(term);

const search = (input, searchFn) => objectScan(['**[*]'], {
  filterFn: ({ value, context }) => {
    if (searchFn(value)) {
      const { children, ...match } = value;
      context.push(match);
    }
  }
})(input, []);

console.log(search(myData, mySearchFn('Toronto')));
// => [ { name: 'Toronto' } ]
console.log(search(myData, mySearchFn('borough')));
// => [ { name: 'Westminster', search_words: [ 'borough' ] } ]
console.log(search(myData, mySearchFn('capital')));
// => [ { name: 'Paris', search_words: [ 'city', 'capital' ] }, { name: 'London', search_words: [ 'city', 'capital' ] } ]
.as-console-wrapper {max-height: 100% !important; top: 0}
<script src="https://bundle.run/object-scan@13.8.0"></script>

Disclaimer: I'm the author of object-scan

And this is how you could inject information that can then be picked up by your rendering pipeline

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

const myData = { root: { Europe: { children: [{ name: 'Germany' }, { name: 'England', children: [{ name: 'London', search_words: ['city', 'capital'], children: [{ name: 'Westminster', search_words: ['borough'] }] }, { name: 'Manchester', search_words: ['city'] }] }, { name: 'France', children: [{ name: 'Paris', search_words: ['city', 'capital'] }] }] }, 'North America': { children: [{ name: 'Canada', children: [{ name: 'Toronto' }, { name: 'Ottawa' }] }, { name: 'United States' }] } } };
// eslint-disable-next-line camelcase
const mySearchFn = (term) => ({ name, search_words = [] }) => name === term || search_words.includes(term);

const search = (input, searchFn) => objectScan(['**[*]'], {
  filterFn: ({ value }) => {
    if (searchFn(value)) {
      value.css = { highlight: true };
      return true;
    } else {
      delete value.css;
      return false;
    }
  },
  rtn: 'count' // return number of matches
})(input);

console.log(search(myData, mySearchFn('Toronto')));
// => 1
console.log(myData);
// => { root: { Europe: { children: [ { name: 'Germany' }, { name: 'England', children: [ { name: 'London', search_words: [ 'city', 'capital' ], children: [ { name: 'Westminster', search_words: [ 'borough' ] } ] }, { name: 'Manchester', search_words: [ 'city' ] } ] }, { name: 'France', children: [ { name: 'Paris', search_words: [ 'city', 'capital' ] } ] } ] }, 'North America': { children: [ { name: 'Canada', children: [ { name: 'Toronto', css: { highlight: true } }, { name: 'Ottawa' } ] }, { name: 'United States' } ] } } }

console.log(search(myData, mySearchFn('borough')));
// => 1
console.log(myData);
// => { root: { Europe: { children: [ { name: 'Germany' }, { name: 'England', children: [ { name: 'London', search_words: [ 'city', 'capital' ], children: [ { name: 'Westminster', search_words: [ 'borough' ], css: { highlight: true } } ] }, { name: 'Manchester', search_words: [ 'city' ] } ] }, { name: 'France', children: [ { name: 'Paris', search_words: [ 'city', 'capital' ] } ] } ] }, 'North America': { children: [ { name: 'Canada', children: [ { name: 'Toronto' }, { name: 'Ottawa' } ] }, { name: 'United States' } ] } } }

console.log(search(myData, mySearchFn('capital')));
// => 2
console.log(myData);
// => { root: { Europe: { children: [ { name: 'Germany' }, { name: 'England', children: [ { name: 'London', search_words: [ 'city', 'capital' ], children: [ { name: 'Westminster', search_words: [ 'borough' ] } ], css: { highlight: true } }, { name: 'Manchester', search_words: [ 'city' ] } ] }, { name: 'France', children: [ { name: 'Paris', search_words: [ 'city', 'capital' ], css: { highlight: true } } ] } ] }, 'North America': { children: [ { name: 'Canada', children: [ { name: 'Toronto' }, { name: 'Ottawa' } ] }, { name: 'United States' } ] } } }
.as-console-wrapper {max-height: 100% !important; top: 0}
<script src="https://bundle.run/object-scan@13.8.0"></script>

Disclaimer: I'm the author of object-scan

Using a library might not be worth it for you, it's a trade-off. Let me know if you have questions / thoughts on this answer.

vincent
  • 1,953
  • 3
  • 18
  • 24