diff --git a/mgq.js b/mgq.js index 45da8e2..e00b9ac 100644 --- a/mgq.js +++ b/mgq.js @@ -49,7 +49,7 @@ const condOps = new Set([ ]); // Set of query operators -const queryOps = new Set(["$and", "$or", "$nor"]); +const queryOps = new Set(["$and", "$or", "$nor", "$where"]); /** * Creates a new Query object @@ -97,6 +97,9 @@ function matchCond(query, doc) { if (path === "$nor") { results.push(matchNor(doc, path, query.$nor)); } + if (path === "$where") { + results.push(matchWhere(doc, path, query.$where)); + } } else { const expOrOv = query[path]; const isAllExp = checkAllExp(expOrOv); @@ -182,7 +185,11 @@ function validate(query) { throw new TypeError("$nor operator value must be an array"); } } - + if (path === "$where" && !validateWhere(query.$where)) { + throw new TypeError( + "$where operator value must be a function or a string", + ); + } if (Array.isArray(query[path])) { for (const cond of query[path]) { validate(cond); @@ -304,6 +311,15 @@ function validateSize(value) { return typeof value === "number"; } +/** + * Validates $where operator + * @param {any} value - The value to validate + * @returns {boolean} + */ +function validateWhere(value) { + return typeof value === "function" || typeof value === "string"; +} + /** * Checks if the given value is a plain object * (not null, not an array, not a regex, not a date) @@ -1081,3 +1097,27 @@ function matchAll(doc, path, ov) { return false; } + +/** + * Matches if the value at the given path matches the queried $where + * @param {any} doc - Document to check + * @param {string} path - Path to the value + * @param {any} ov - Value to match against + * @returns {boolean} + */ +function matchWhere(doc, path, ov) { + if (!validateWhere(ov)) { + return false; + } + + if (typeof ov === "function") { + return ov.call(doc); + } + + if (typeof ov === "string") { + const fn = new Function(ov); + return fn.call(doc); + } + + return false; +} diff --git a/test/utils.js b/test/utils.js index 975f8dc..6e341a6 100644 --- a/test/utils.js +++ b/test/utils.js @@ -10,9 +10,13 @@ import { Collection } from "mongodb"; export async function getMongoResults(collection, query, input) { try { await collection.insertMany(structuredClone(input)); - const results = await collection.find(query).project({ _id: 0 }).toArray(); + const results = await collection + .find(query, { serializeFunctions: true }) + .project({ _id: 0 }) + .toArray(); return results; } catch (error) { + console.error(error); return []; } } diff --git a/test/validate.test.js b/test/validate.test.js index d45b563..e5543dc 100644 --- a/test/validate.test.js +++ b/test/validate.test.js @@ -116,6 +116,13 @@ describe("Query Validation", () => { TypeError, ); }); + + test("$where validation", () => { + assert.doesNotThrow(() => Query({ $where: () => {} }).validate()); + assert.doesNotThrow(() => Query({ $where: "return true" }).validate()); + assert.throws(() => Query({ $where: {} }).validate(), TypeError); + assert.throws(() => Query({ $where: 123 }).validate(), TypeError); + }); }); test("validate should return query", () => { diff --git a/test/where.test.js b/test/where.test.js new file mode 100644 index 0000000..0d855f6 --- /dev/null +++ b/test/where.test.js @@ -0,0 +1,100 @@ +import assert from "node:assert"; +import test, { after, afterEach, before, describe } from "node:test"; +import { Query } from "../mgq.js"; +import { getFilterResults, getMongoResults } from "./utils.js"; +import { Collection, MongoClient } from "mongodb"; +import { MongoMemoryServer } from "mongodb-memory-server"; + +const testCases = [ + { + name: "$where string function", + query: { + $where: "return Boolean(this.foo) && this.foo.length === 2", + }, + input: [ + { foo: "ab" }, + { foo: [1, "a"] }, + { foo: [{}, {}] }, + { foo: [1, 2, 3] }, + { foo: [] }, + { foo: null }, + ], + expected: [{ foo: "ab" }, { foo: [1, "a"] }, { foo: [{}, {}] }], + }, + { + name: "$where declared function", + query: { + $where: function () { + return this.foo && this.foo.length === 2; + }, + }, + input: [ + { foo: "ab" }, + { foo: [1, "a"] }, + { foo: [{}, {}] }, + { foo: [1, 2, 3] }, + { foo: [] }, + { foo: null }, + ], + expected: [{ foo: "ab" }, { foo: [1, "a"] }, { foo: [{}, {}] }], + }, + { + name: "$where invalid query", + query: { + $where: { foo: "bar" }, + }, + input: [{ foo: "bar" }], + expected: [], + }, +]; + +/** @type {MongoMemoryServer} */ +let mongod; + +/** @type {MongoClient} */ +let client; + +/** @type {Collection} */ +let collection; + +before(async () => { + try { + mongod = await MongoMemoryServer.create(); + const uri = mongod.getUri(); + client = new MongoClient(uri); + await client.connect(); + collection = client.db("test").collection("test"); + } catch (error) { + console.error(error); + } +}); + +after(async () => { + try { + await client.close(); + await mongod.stop(); + } catch (error) { + console.error(error); + } +}); + +afterEach(async () => { + try { + await collection.deleteMany({}); + } catch (error) { + console.error(error); + } +}); + +describe("Query $where tests", async () => { + for (const { name, query, input, expected } of testCases) { + await test(name, async () => { + const mongoExpected = await getMongoResults(collection, query, input); + assert.deepStrictEqual(mongoExpected, expected); + + const q = new Query(query); + const actual = getFilterResults(q.test.bind(q), input); + assert.deepStrictEqual(actual, expected); + }); + } +});