Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions mgq.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
}
6 changes: 5 additions & 1 deletion test/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
}
}
Expand Down
7 changes: 7 additions & 0 deletions test/validate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
100 changes: 100 additions & 0 deletions test/where.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
}
});