Skip to content

Commit

Permalink
feat: switched from stack to recursion
Browse files Browse the repository at this point in the history
Benchmarks show better performance
  • Loading branch information
simonecorsi committed Apr 17, 2024
1 parent 1419784 commit fb1c414
Show file tree
Hide file tree
Showing 8 changed files with 1,095 additions and 19 deletions.
74 changes: 74 additions & 0 deletions benchmarks/benchmarks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Bench, Fn } from 'tinybench';
import { validateQueryStack } from './src/stack';
import { validateQueryRecurse } from './src/recurse';

const bench = new Bench();

function generateNestedObject(options: {
valid: boolean;
levels: number;
}): Record<string, unknown> {
if (options.levels === 1) {
return { [options.valid ? '$exists' : '$exist']: false };
}

const obj: Record<string, unknown> = {};
obj[`level${options.levels}`] = generateNestedObject({
levels: options.levels - 1,
valid: options.valid,
});
return obj;
}

const tests: [string, Fn][] = [
[
'Stack: Invalid top-level object',
() =>
validateQueryStack(
generateNestedObject({
levels: 1,
valid: false,
})
),
],
[
'Stack: Invalid top-level nested',
() =>
validateQueryStack(
generateNestedObject({
levels: 100,
valid: false,
})
),
],

[
'Recursion: Invalid top-level object',
() =>
validateQueryRecurse(
generateNestedObject({
levels: 1,
valid: false,
})
),
],
[
'Recursion: Invalid top-level nested',
() =>
validateQueryRecurse(
generateNestedObject({
levels: 100,
valid: false,
})
),
],
];

for (const [label, test] of tests) {
bench.add(label as string, test);
}

// I don't have top level await
bench.warmup().then(() => {
bench.run().then(() => console.table(bench.table()));
});
35 changes: 35 additions & 0 deletions benchmarks/src/allowed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export const ALLOWED_OPERATORS = [
// Comparison Operators
'$eq',
'$gt',
'$gte',
'$lt',
'$lte',
'$ne',
'$in',
'$nin',
// Logical Operators
'$and',
'$or',
'$not',
'$nor',
// Element Operators
'$exists',
'$type',
// Array Operators
'$all',
'$elemMatch',
'$size',
// Evaluation Operators
'$expr',
'$jsonSchema',
// Geospatial Operators
'$geoWithin',
'$geoIntersects',
// Text Search Operators
'$text',
// Bitwise Operators
'$bitsAllSet',
'$bitsAnySet',
'$bitsAllClear',
];
53 changes: 53 additions & 0 deletions benchmarks/src/recurse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ALLOWED_OPERATORS } from './allowed';

function validateField(
key: string,
path: string,
allowedOperators: typeof ALLOWED_OPERATORS
): boolean {
// only validate key starting with $ to check of invalid MongoDB uperator
// eg: $exists is valid $exist is a common typo
if (key.startsWith('$') && !allowedOperators.includes(key)) {
return false;
}
return true;
}

export function validateQueryRecurse(
obj,
maxDepth: number = 0, // 0 it's up to infinity
path = '',
currentDepth = 0,
invalidFields: string[] = []
) {
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key];
const fullPath = path ? `${path}.${key}` : key;
if (typeof value === 'object' && value !== null) {
if (
// If 0 it's up to infinity
maxDepth === 0 ||
currentDepth < maxDepth
) {
validateQueryRecurse(
value,
maxDepth,
fullPath,
currentDepth + 1,
invalidFields
); // recursively recurse nested objects
}
} else {
// the first invalid field
if (!validateField(key, fullPath, ALLOWED_OPERATORS)) {
invalidFields.push(fullPath);
}
}
}
}
return {
isValidQuery: invalidFields.length === 0,
invalidFields,
};
}
58 changes: 58 additions & 0 deletions benchmarks/src/stack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ALLOWED_OPERATORS } from './allowed';

type QueryObject = Record<string, unknown>;

function processObject(
obj: QueryObject,
path: string,
allowedOperators: typeof ALLOWED_OPERATORS,
invalidFields: string[],
stack: { obj: QueryObject; path: string }[]
): void {
for (const key in obj) {
const newPath = path ? `${path}.${key}` : key;

// validate the current
const isValidField = validateField(key, allowedOperators);
if (!isValidField) {
invalidFields.push(path);
}

// if the object value is an object traverse it
if (typeof obj[key] === 'object' && obj[key] !== null) {
stack.push({ obj: obj[key] as QueryObject, path: newPath });
}
}
}

function validateField(
key: string,
allowedOperators: typeof ALLOWED_OPERATORS
): boolean {
// only validate key starting with $ to check of invalid MongoDB uperator
// eg: $exists is valid $exist is a common typo
if (key.startsWith('$') && !allowedOperators.includes(key)) {
return false;
}
return true;
}

export function validateQueryStack(
queryObj: QueryObject,
allowedOperators: typeof ALLOWED_OPERATORS = ALLOWED_OPERATORS
): { isValidQuery: boolean; invalidFields: string[] } | never {
const invalidFields: string[] = [];
const stack: { obj: QueryObject; path: string }[] = [
{ obj: queryObj, path: '' },
];

while (stack.length > 0) {
const { obj, path } = stack.pop()!;
processObject(obj, path, allowedOperators, invalidFields, stack);
}

return {
isValidQuery: !invalidFields.length,
invalidFields,
};
}
Loading

0 comments on commit fb1c414

Please sign in to comment.