2
\$\begingroup\$

Note: I'm still very new to the code-review stack exchange, so if this question does not align with the community guidelines please leave a comment below explaining and I will do my best to modify it to adhere to the community guidelines.

Explanation

I'm attempting to implement a filter() function that functions most similarly to (I might be wrong in my understanding here) a WHERE query on a schema-less database that only allows for AND operators when concatenating WHERE clauses (no OR), and = operators for comparison (no <, >, CONTAINS, etc. operators).

Requirements

I hope this describes the functions expected behavior well enough.

The function should take in an array of objects and a filter. It should return objects within the array that are considered valid within the context of the filter. The filter is a subset of key/values that MUST be present in each object for them to be considered valid within the context of the filter.

Simple Example

Given the data:

const data = [
    {
        name: 'John',
        age: 10
    },
    {
        name: 'Jill',
        age: 15
    },
    {
        name: 'Jack',
        age: 10
    }
];

And the filter:

{
    age: 10
}

The results would look like:

[
    {
        name: 'John',
        age: 10
    },
    {
        name: 'Jack',
        age: 10
    }
]

Code I'd like reviewed

Currently, my function looks like this:

Keep in mind I allow the data to also be an object, in which case we get the data array using Object.values(data);. This is a requirement of the project I'm implementing this function in. So, please ignore the first few lines of the function and just consider that data is always an Array.

function filter(data, f) {
    // Make sure we're dealing with an array, if an object is passed,
    // just consider it's values.

    let finalData;

    if (Array.isArray(data)) {
        finalData = data;
    }
    
    if (finalData === undefined && typeof data === 'object') {
        finalData = Object.values(data);
    }

    if (finalData === undefined) {
        throw new Error('Data being filtered must be either an Array or an Object.');
    }

    // Define a function to use for filtering the object. This may use recurrsion if one of
    // the values in the filter is another object.

    const isInFilter = (obj, f) => {
        let filterTermsMet = true;
        for (const filteredProp in f) {
            filterTermsMet = (typeof f[filteredProp] === 'object' && !Array.isArray(f[filteredProp]))
                ? isInFilter(obj[filteredProp], f[filteredProp])
                : obj[filteredProp] === f[filteredProp];
    
            if (!filterTermsMet) {
                break;
            }
        }
    
        return filterTermsMet;
    }

    // Perform the filter on the final object and return the result.

    return finalData.filter(element => {
        return isInFilter(element, f);
    });
};

Review I'm looking for / Questions I have of peers

Please bear in mind that I'm relatively new to JavaScript/Node.js.

  1. Am I doing this the expected way within the context of JavaScript/Node.js? I.e is there perhaps some built-in I'm unaware of that performs similar logic or could simplify my function?
  2. Is there a more efficient way to perform this in JavaScript?
  3. Is there anything my function isn't taking into consideration, missing in general or overlooking when taking this approach?
  4. Is it a bad idea to use recursion with the anon function declared in the function when considering sub-objects in the filter? If so, why?
  5. Any other comments / constructive criticism you might have.

More Complex Examples

Input Data

const objectsArr = [
    {
        name: 'John',
        age: 25,
        physical: {
            height: 6.2,
            weight: 200
        }
    },
    {
        name: 'Jason',
        age: 24,
        physical: {
            height: 6.0,
            weight: 200
        }
    },
    {
        name: 'Jenny',
        age: 25,
        physical: {
            height: 5.3,
            weight: 105
        }
    },
    {
        name: 'Kevin',
        age: 21,
        physical: {
            height: 5.3,
            weight: 189
        }
    },
    {
        name: 'Phil',
        age: 24,
        physical: {
            height: 6.0,
            weight: 189
        }
    }
];

Run Filters

let filterToUse;

filterToUse = { // normal condition
    age: 25
};
console.log(filterToUse, filter(objectsArr, filterToUse).map(person => person.name));

filterToUse = { // sub-object condition
    physical: {
        weight: 200
    }
};
console.log(filterToUse, filter(objectsArr, filterToUse).map(person => person.name));

filterToUse = { // alternate sub-object condition
    physical: {
        height: 5.3
    }
};
console.log(filterToUse, filter(objectsArr, filterToUse).map(person => person.name));

filterToUse = { // sub-object condition, multiple conditions (AND)
    age: 24,
    physical: {
        height: 6.0
    }
};
console.log(filterToUse, filter(objectsArr, filterToUse).map(person => person.name));

Results

{ age: 25 } [ 'John', 'Jenny' ]
{ physical: { weight: 200 } } [ 'John', 'Jason' ]
{ physical: { height: 5.3 } } [ 'Jenny', 'Kevin' ]
{ age: 24, physical: { height: 6 } } [ 'Jason', 'Phil' ]
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

Nicely done.

  1. The first thing I did was change your console.log to check that the results are what was expected: console.log(filterToUse, filter(objectsArr, filterToUse).map(person => person.name).toString()===[ 'John', 'Jenny' ].toString()); so that I could get true or false instead of having to look at the arrays. You might want to consider a testing framework.
  2. You're checking === undefined to see if the previous if was executed. Instead just use else. This also makes it clearer that one of the branches must happen.
  3. element => { return isInFilter(element, f); } should just be element => isInFilter(element, f)
  4. (Your #1.) [].every tests that every element in an array passes some test. This is exactly what we need, and eliminates the need for filterTermsMet. (It also has the quick return that your break was serving.)
  5. for (const filteredProp in f) is dangerous, because filteredProp may end up being properties from the filter chain. for (const filteredProp of f) would be better. But even better would be Object.keys(f).forEach(...). But since you also need f[filteredProp], lets use Object.entries(f).forEach(...).
  6. To me, f is a generic function that will be doing something. Let's call it what you end up calling it: filterToUse.
  7. Comments that duplicate code are superfluous, which leads to the danger of having wrong comments. The only one I'll leave is referring to the recursion, and I'll simplify that.
  8. I'm not clear on the purpose of !Array.isArray(f[filteredProp]). If it is an array, then we end up in the non-recursive portion, and we'll end up with false. Is that the intent? If you're trying to validate the input, then there's a lot more that you should be validating. That might need a comment.
  9. (Your #4.) Recursion is unavoidable if you're needing to have arbitrarily deep objects to compare. Regarding the const isInFilter = () => {}, I'm going to defer to https://softwareengineering.stackexchange.com/q/364086 and turn it into function isInFiler() {} (see also https://stackoverflow.com/q/34361379/2336725).
  10. Your #3 really depends on how thorough you want to validate input. If this is an internal thing, then it seems fine to me. If you're going to have malicious attackers giving you data or filters, then you might need to dig a bit deeper.
  11. If you swap the order of the arguments to isInFilter, then you'd have element => isInFilter(filterToUse,element), which is equivalent to isInFilter.bind(undefined, filterToUse). I like using Function.bind, but that may be more of a personal preference.
  12. Our filter function is now:
function filter(data, filterToUse) {
    let finalData;

    if ( Array.isArray(data) ) {
        finalData = data;
    } else if ( typeof data === 'object' ) {
        finalData = Object.values(data);
    } else {
        throw new Error('Data being filtered must be either an Array or an Object.');
    }

    function isInFilter(obj, filterToUse) {
        return Object.entries(filterToUse)
            .every(function([filteredProp,filteredValue]) {
                return (typeof filteredValue === 'object' && !Array.isArray(filteredValue))
                // !Array.isArray(filteredValue) because ...
                ? isInFilter(obj[filteredProp], filteredValue) // recurse
                : obj[filteredProp] === filteredValue;
            });
    }

    return finalData.filter(element => isInFilter(element, filterToUse));
}

This is basically equivalent to your original, and doesn't seem much more simplifiable to me. So I think the answer to your #2 is "no".

\$\endgroup\$
1
  • \$\begingroup\$ Bravo! This is exactly the type of feedback I was looking for, much appreciated. Regarding the !Array.isArray(filteredValue), I did this because typeof filteredValue when filteredValue is an Array will still return object, which without thinking I assumed wouldn't work with the subscripting. However, I noticed that you could simply use an array index as the filter key and it would work the same, so you're right. I think it would be better to remove it. Oversight on my part that neutered the function a little bit, not sure why I didn't think about that lol \$\endgroup\$
    – Nathan F.
    Commented Jan 3, 2022 at 4:28

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.