How To Filter Deeply Nested Json By Multiple Attributes With Vue/javascript
Solution 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 -
constindex = 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
, newMap
)
This depends on a generic helper insertAll
-
constinsertAll = (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.
Solution 2:
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.
constcollect = (pred) => ({children = [], ...rest}) => [
... (pred (rest) ? [rest] : []),
... children .flatMap (collect (pred))
]
constsearch = (term) =>
collect (({name = '', search_words = []}) => name == term || search_words .includes (term))
constsearchLocations = (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:
constdeepFilter = (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.
constsearchLocations = (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 functionconstdeepFilter = (pred) => (xs) =>
xs .flatMap (({children = [], ...rest}, _, __, kids = deepFilter (pred) (children)) =>
pred (rest) || kids.length
? [{...rest, ...(kids.length ? {children: kids} : {})}]
: []
)
// helper functionsconstsearch = (t = '', term = t.toLowerCase()) => ({name = '', search_words = []}) =>
term.length && (
name .toLowerCase () .includes (term) ||
search_words .some (word => word .toLowerCase() .includes (term))
)
constconvert = ({root}) =>
Object.entries (root) .map (([name, v]) => ({name, ...v}))
// main functionconstsearchLocations = (loc, locations = convert(loc)) => (term) =>
term.length ? deepFilter (search (term)) (locations) : locations
// sample dataconst 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 dataconst mySearch = searchLocations(myData)
// UI democonstformat = (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>`constrender = (locations, searchTerm) =>
document .getElementById ('main') .innerHTML = format (locations, searchTerm)
document .getElementById ('search') .addEventListener (
'keyup',
(e) => render (mySearch (e.target.value))
)
// show demo
render (mySearch (''))
<divstyle="float: right"id="control"><label>Search: <inputtype="text"id="search"/></label></div><divstyle="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.
Solution 3:
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 camelcaseconstmySearchFn = (term) => ({ name, search_words = [] }) => name === term || search_words.includes(term);
constsearch = (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}
<scriptsrc="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 camelcaseconstmySearchFn = (term) => ({ name, search_words = [] }) => name === term || search_words.includes(term);
constsearch = (input, searchFn) => objectScan(['**[*]'], {
filterFn: ({ value }) => {
if (searchFn(value)) {
value.css = { highlight: true };
returntrue;
} else {
delete value.css;
returnfalse;
}
},
rtn: 'count'// return number of matches
})(input);
console.log(search(myData, mySearchFn('Toronto')));
// => 1console.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')));
// => 1console.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')));
// => 2console.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}
<scriptsrc="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.
Post a Comment for "How To Filter Deeply Nested Json By Multiple Attributes With Vue/javascript"