Build a JSON Database in Node.js (Part 4): Retrieve Records

65 min read·Dec 5, 2025

Article banner

In this part, we'll implement a method for safely retrieving records from a table using a compact, intuitive query object, directly inspired from MongoDB's API.

Ready? Let's build!

Retrieve records from a table

Within the Model class, let's define a new method named find() responsible for retrieving, filtering, and ordering records from a table.

model.js
// ...

export default class Model {
  // ...

  find(filters = {}) {
    //
  }
}

This method takes as parameter a filters object with the following optional properties:

  • where <Object>: The list of attribute filters. If undefined, all records are returned.
  • order <Array>: The sorting order of records based on an attribute's value. If undefined, records are returned in natural order.
  • limit <number>: The maximum number of records. If undefined, all records matching the where clause are returned.
  • attributes <Array>: The list of attributes to retrieve. If undefined, all attributes are included.

Make a shallow copy of records

In order to safely retrieve and successively filter records without mutating the original ones stored in the table, let's create a shallow copy of these records using the map() method.

model.js
// ...

export default class Model {
  // ...

  find(filters = {}) {
    let results = this.#table.map(record => ({ ...record }));
  }
}

Filter results based on attribute values

In its first form, the filters.where property is an optional object in the following format, that allows you to filter the records using a list of field conditions that logically compare attributes to arbitrary values:

find({
  where: {
    attribute: { $operator: value, ... },
    ...
  }
})

Where:

  • attribute <string>: The name of an attribute (e.g., 'price').

  • $operator <string>: A comparison operator (e.g., '$gte'). Must be one of:

    • $eq: equal (=)
    • $neq: not equal (!=)
    • $gt: greater than (>)
    • $gte: greater or equal (>=)
    • $lt: less than (<)
    • $lte: less or equal (<=)
    • $in: in ([])
    • $nin: not in (![])
  • value <any>: An arbitrary value to compare the attribute to using the specified operator (e.g., 1000).

👉 When using this syntax, all the field conditions must evaluate to true for the record to be included in the result set, which is equivalent to using the logical AND operator.


In its second form, the filters.where property is an optional object in the following format primarily inspired from MongoDB's query language, that allows you to filter the records using logical groups that combine field conditions:

find({
  where: {
    $group: [
      { attribute: { $operator: value, ... } },
      ...
    ]
  }
})

Where $group is either:

  • $and <Array[<Object>]>: A list of field conditions, where all must evaluate to true for the record to be included in the result set (e.g., $and: [{}, ...]).
  • $or <Array[<Object>]>: A list of field conditions, where at least one must evaluate to true for the record to be included in the result set (e.g., $or: [{}, ...]).

When using this syntax, you can also nest logical groups the following way:

find({
  where: {
    $group: [
      { attribute: { $operator: value } },
      { $group: [
        { attribute: { $operator: value } },
      ] }
    ]
  }
})

Example 1

Let's consider the following query:

Freelancers.find({
  where: {
    status: { $eq: 'active' },
    daily_rate: { $gte: 400 }
  }
});

Which is equivalent to the following query:

Freelancers.find({
  where: {
    $and: [
      { status: { $eq: 'active' } },
      { daily_rate: { $gte: 400 } }
    ]
  }
});

Which in plain English translates to:

"Retrieve all the records from the freelancers table where the status attribute is equal to 'active' and the daily_rate attribute is greater than or equal to 400".

And in SQL to:

SELECT * FROM freelancers WHERE status = 'active' AND daily_rate >= 400;

Example 2

Let's consider the following query:

Freelancers.find({
  where: {
    daily_rate: { $gte: 400, $lt: 650 }
  }
});

Which in plain English translates to:

"Retrieve all the records from the freelancers table where the daily_rate attribute is greater than or equal to 400 and less than 650".

And in SQL to:

SELECT * FROM freelancers WHERE daily_rate >= 400 AND daily_rate < 650;

Example 3

Let's consider the following query:

Freelancers.find({
  where: {
    $and: [
      { status: { $eq: 'active' } },
      {
        $or: [
          { city: { $eq: 'Quebec' } },
          { city: { $eq: 'Montreal' } }
        ]
      }
    ],
  }
});

Which in plain English translates to:

"Retrieve all the records from the freelancers table, where the status attribute is equal to 'active', and where the city attribute is either equal to 'Quebec' or 'Montreal'.

And in SQL to:

SELECT * FROM freelancers WHERE status = 'active' AND (city = 'Quebec' OR city = 'Montreal');

Example 4

Let's consider the following query:

Freelancers.find({
  where: {
    $or: [
      {
        $and: [
          { city: { $eq: 'Quebec' } },
          { daily_rate: { $lte: 800 } }
        ]
      },
      {
        $and: [
          { city: { $eq: 'Montreal' } },
          { daily_rate: { $lt: 500 } }
        ]
      }
    ]
  }
});

Which in plain English translates to:

"Retrieve all the records from the freelancers table, where the city attribute is equal to 'Quebec' and the daily_rate attribute is less than or equal to 800, or where the city attribute is equal to 'Montreal' and the daily_rate attribute is less than 500.

And in SQL to:

SELECT * FROM freelancers WHERE (city = 'Quebec' AND daily_rate <= 800) OR (city = 'Montreal' AND daily_rate < 500);

Evaluate field conditions

Let's define a new private method named #applyCondition() in the Model class responsible for logically evaluating a field condition:

model.js
// ...

export default class Model {
  // ...
  
  #applyCondition(attrValue, opSet) {
    //
  }

  // ...
}

This method takes as parameters:

  • attrValue <any>: The attribute's value to compare (e.g., 650).
  • opSet <Object>: The list of logical operator sets to compare the attribute's value to (e.g., { $gte: 450, $lt: 800 }).

Within this method, let's throw an error if the list of operator sets is not an object.

model.js
// ...

export default class Model {
  // ...

  #applyCondition(attrValue, opSet) {
    if (!isPlainObject(opSet)) {
      throw new Error(`Model: #applyCondition(): "opSet" must be an object of operators`);
    }
  }

  // ...
}

And let's:

  1. Iterate on every operator set.
  2. Translate each set into a logical expression and immediately return false if the expression evaluates to false, otherwise return true.
  3. Throw an error if the operator of a set is invalid.
model.js
// ...

export default class Model {
  // ...

  #applyCondition(attrValue, opSet) {
    // ...

    for (const [operator, operand] of Object.entries(opSet)) {
      switch (operator) {
        case '$eq':
          if (attrValue !== operand) return false;
          break;
        case '$neq':
          if (attrValue === operand) return false;
          break;
        case '$gt':
          if (!(attrValue > operand)) return false;
          break;
        case '$gte':
          if (!(attrValue >= operand)) return false;
          break;
        case '$lt':
          if (!(attrValue < operand)) return false;
          break;
        case '$lte':
          if (!(attrValue <= operand)) return false;
          break;
        case '$in':
          if (!Array.isArray(operand)) {
            throw new Error(`Model: find(): "filters.where.$in" must be an array`);
          } else if (!operand.includes(attrValue)) {
            return false;
          }
          break;
        case '$nin':
          if (!Array.isArray(operand)) {
            throw new Error(`Model: find(): "filters.where.$nin" must be an array`);
          } else if (operand.includes(attrValue)) {
            return false;
          }
          break;
        default:
          throw new Error(`Model: #applyCondition(): unknown operator "${operator}"`);
      }
    }
    return true;
  }

  // ...
}

Evaluate logical operations

Let's define a new private method named #evalFilters() in the Model class responsible for evaluating the filters.where parameter object:

model.js
// ...

export default class Model {
  // ...

  #evalFilters(record, filters) {
    //
  }

  // ...
}

This method takes as parameters:

  • record <Object>: The record to filter.
  • filters <Object>: The list of filters from the filters.where parameter the record's attributes must match.

Within this method, let's:

  1. Return true if the filters parameter is undefined.
  2. Otherwise, throw an error if the filters parameter is not an object.
model.js
// ...

export default class Model {
  // ...

  #evalFilters(record, filters) {
    if (filters === undefined) {
      return true;
    } else if (!isPlainObject(filters)) {
      throw new Error(`Model: find(): "filters.where" must be an object`);
    }
  }

  // ...
}

To first evaluate the field conditions present in the filters object, let's

  1. Iterate on its properties.
  2. Throw an error if the key is not a valid logical operator (i.e., $and, $or).
  3. Skip the key for now if it is a logical operator.
  4. Otherwise, execute the #applyCondition() method and return false if it returns false.
model.js
// ...

export default class Model {
  // ...

  #evalFilters(record, filters) {
    // ...

    for (const [key, expression] of Object.entries(filters)) {
      if (key.startsWith('$') && key !== '$and' && key !== '$or') {
        throw new Error(`Model: find(): unknown logical operator "${key}" in "filters.where"`);
      } else if (key === '$and' || key === '$or') {
        continue;
      } else if (!this.#applyCondition(record[key], expression)) {
        return false;
      }
    }
  }

  // ...
}

Let's check if the logical AND group is defined and:

  1. Throw an error if the $and property is not a non-empty array.
  2. Otherwise, recursively evaluate each field condition and return false if any returns false.
model.js
// ...

export default class Model {
  // ...

  #evalFilters(record, filters) {
    // ...

    if (filters.$and !== undefined) {
      if (!Array.isArray(filters.$and) || filters.$and.length === 0) {
        throw new Error(`Model: find: "filters.where.$and" must be a non-empty array`);
      } else if (!filters.$and.every(subfilters => this.#evalFilters(record, subfilters))) {
        return false;
      }
    }
  }

  // ...
}

💡 In JavaScript, the every() method returns false if it finds one element in the array that does not satisfy the provided testing function. Otherwise, it returns true.

Let's check if the logical OR group is defined and:

  1. Throw an error if the $or property is not a non-empty array.
  2. Otherwise, recursively evaluate each field condition and return false if all return false.
model.js
// ...

export default class Model {
  // ...

  #evalFilters(record, filters) {
    // ...

    if (filters.$or !== undefined) {
      if (!Array.isArray(filters.$or) || filters.$or.length === 0) {
        throw new Error(`Model: find: "filters.where.$or" must be a non-empty array`);
      } else if (!filters.$or.some(subfilters => this.#evalFilters(record, subfilters))) {
        return false;
      }
    }
  }

  // ...
}

💡 In JavaScript, the some() method returns true if it finds one element in the array that satisfies the provided testing function. Otherwise, it returns false.

Last but not least, let's return true if none of the previous evaluations returned false.

model.js
// ...

export default class Model {
  // ...

  #evalFilters(record, filters) {
    // ...

    return true;
  }

  // ...
}

Finally, within the find() method, let's:

  1. Use the #evalFilters() method to filter out the records from the result set that don't match the list of filters defined in the filters.where parameter.
  2. Return the results array.
model.js
// ...

export default class Model {
  // ...

  find(filters = {}) {
    let results = this.#table
      .map(record => ({ ...record }))
      .filter(result => this.#evalFilters(result, filters.where));

    return results;
  }
}

Conclusion

Congratulations!

You now have a method that uses an intuitive query object to retrieve and filter records.

In the next part, you'll learn how to extend this method's capabilities in order to sort, limit, and filter records.

Read next: Build a JSON Database in Node.js (Part 5): Order, Limit & Filter Records