Skip to content

Commit 5d22118

Browse files
committed
feat(search-query-language) maxOps
1 parent 54df881 commit 5d22118

File tree

4 files changed

+216
-66
lines changed

4 files changed

+216
-66
lines changed
Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
1-
import { describe, expect, it } from 'vitest';
2-
import { parseToMongo } from './parseToMongo';
1+
import { getRandomInt } from '@andrew_l/toolkit';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { parseToMongo as s } from './parseToMongo';
34

45
describe('parseToMongo', () => {
56
it('should handle simple comparing', () => {
6-
expect(parseToMongo('name = "andrew"')).toStrictEqual({
7+
expect(s('name = "andrew"')).toStrictEqual({
78
name: 'andrew',
89
});
910
});
1011

1112
it('should handle comparing with logical operators', () => {
12-
expect(parseToMongo('name = "andrew" OR age > 5')).toStrictEqual({
13+
expect(s('name = "andrew" OR age > 5')).toStrictEqual({
1314
$or: [{ name: 'andrew' }, { age: { $gt: 5 } }],
1415
});
1516
});
1617

1718
it('should handle comparing with complex logical operators', () => {
1819
expect(
19-
parseToMongo('(role = "ADMIN" AND name = "andrew") OR age >= 18'),
20+
s('(role = "ADMIN" AND name = "andrew") OR age >= 18'),
2021
).toStrictEqual({
2122
$or: [
2223
{ $and: [{ role: 'ADMIN' }, { name: 'andrew' }] },
@@ -26,10 +27,98 @@ describe('parseToMongo', () => {
2627
});
2728

2829
it('should handle comparing with same logical operators', () => {
29-
expect(
30-
parseToMongo('role = "ADMIN" OR name = "andrew" OR age >= 18'),
31-
).toStrictEqual({
30+
expect(s('role = "ADMIN" OR name = "andrew" OR age >= 18')).toStrictEqual({
3231
$or: [{ role: 'ADMIN' }, { name: 'andrew' }, { age: { $gte: 18 } }],
3332
});
3433
});
34+
35+
describe('options.allowEmpty', () => {
36+
it('should be false by default', () => {
37+
expect(() => s('')).toThrowError('Search query cannot be empty');
38+
});
39+
40+
it('should returns empty object when true', () => {
41+
expect(s('', { allowEmpty: true })).toStrictEqual({});
42+
});
43+
});
44+
45+
describe('options.allowedKeys', () => {
46+
it('should accept all keys by default', () => {
47+
expect(s(`key_${getRandomInt(1, 10)}=1`)).toBeTruthy();
48+
});
49+
50+
it('should accept provided keys', () => {
51+
expect(s('a=1', { allowedKeys: ['a'] })).toBeTruthy();
52+
expect(() => s('b=1', { allowedKeys: ['a'] })).toThrowError(
53+
'The search key "b" is not allowed',
54+
);
55+
});
56+
});
57+
58+
describe('options.transform', () => {
59+
it('should not transform by default', () => {
60+
expect(s('a=1 AND b="2"')).toStrictEqual({
61+
$and: [{ a: 1 }, { b: '2' }],
62+
});
63+
});
64+
65+
it('should execute global transform function', () => {
66+
const t1 = vi.fn((value: unknown, key: string) => value);
67+
const t2 = vi.fn((value: unknown, key: string) => value);
68+
69+
s('a=1', { transform: t1 }); // direct way
70+
s('a=1', { transform: [t1, t2] }); // array way
71+
s('a=1', { transform: { '*': [t2] } }); // object way
72+
73+
expect(t1).toBeCalledTimes(2);
74+
expect(t2).toBeCalledTimes(2);
75+
});
76+
77+
it('should execute key transform function', () => {
78+
const t1 = vi.fn((value: unknown, key: string) => value);
79+
const t2 = vi.fn((value: unknown, key: string) => value);
80+
81+
s('a=1', { transform: { a: t1 } }); // direct way
82+
s('a=1', { transform: { a: [t1, t2] } }); // array way
83+
84+
expect(t1).toBeCalledTimes(2);
85+
expect(t2).toBeCalledTimes(1);
86+
});
87+
88+
it('should execute chain of transform function.', () => {
89+
const t1 = vi.fn((value: unknown, key: string) => `t1`);
90+
const t2 = vi.fn((value: unknown, key: string) => `${value}:t2`);
91+
const result = {
92+
a: 't1:t2',
93+
};
94+
95+
expect(s('a=1', { transform: [t1, t2] })).toStrictEqual(result);
96+
expect(s('a=1', { transform: { '*': [t1, t2] } })).toStrictEqual(result);
97+
expect(s('a=1', { transform: { a: [t1, t2] } })).toStrictEqual(result);
98+
});
99+
});
100+
101+
describe('options.maxOps', () => {
102+
it('should be infinity by default', () => {
103+
expect(s('a=1 AND b=2 AND c=3')).toBeTruthy();
104+
});
105+
106+
it('should accept only number value', () => {
107+
const errMessage = 'maxOps must be a number greater than zero';
108+
109+
expect(() => s('abc', { maxOps: '1' as any })).toThrowError(errMessage);
110+
expect(() => s('abc', { maxOps: 0 })).toThrowError(errMessage);
111+
expect(() => s('abc', { maxOps: -1 })).toThrowError(errMessage);
112+
expect(() => s('abc', { maxOps: -Infinity })).toThrowError(errMessage);
113+
114+
expect(s('a=1', { maxOps: 1 })).toBeTruthy();
115+
expect(s('a=1', { maxOps: Infinity })).toBeTruthy();
116+
});
117+
118+
it('should throw error when max ops reached', () => {
119+
expect(() => s('a=1 AND a=2', { maxOps: 1 })).toThrowError(
120+
'Maximum search operations reached: 1',
121+
);
122+
});
123+
});
35124
});

packages/search-query-language/src/parseToMongo.ts

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
arrayable,
44
assert,
55
isFunction,
6+
isNumber,
67
} from '@andrew_l/toolkit';
78
import { NODE } from './constants';
89
import { Expression } from './Expression';
@@ -35,6 +36,12 @@ export interface ParseToMongoOptions {
3536
*/
3637
allowedKeys?: string[];
3738

39+
/**
40+
* Max of query ops combination.
41+
* @default Infinity
42+
*/
43+
maxOps?: number;
44+
3845
/**
3946
* A transformation function or a mapping of transformation functions
4047
* to modify query values before they are converted into a MongoDB query.
@@ -56,6 +63,7 @@ export interface ParseToMongoOptions {
5663

5764
interface ParseToMongoOptionsInternal {
5865
allowEmpty: boolean;
66+
maxOps: number;
5967
allowedKeys?: Set<string>;
6068
transform: Record<string, ParseToMongoTransformFn[]>;
6169
}
@@ -101,10 +109,11 @@ export function parseToMongo(
101109
}
102110

103111
const result: Record<string, any> = {};
112+
const ops = new OpsResource(opts.maxOps);
104113
const exp = new Expression(input).parse();
105114

106115
for (const node of exp.body) {
107-
Object.assign(result, reduceNode(node, opts));
116+
Object.assign(result, reduceNode(node, opts, ops));
108117
}
109118

110119
return result;
@@ -113,9 +122,11 @@ export function parseToMongo(
113122
export function reduceNode(
114123
node: NodeExpression,
115124
options: ParseToMongoOptionsInternal,
125+
opsResource: OpsResource,
116126
): Record<string, any> {
117127
switch (node.type) {
118128
case NODE.BINARY_EXPRESSION: {
129+
opsResource.assert();
119130
assert.ok(
120131
!options.allowedKeys || options.allowedKeys.has(node.left.name),
121132
`The search key "${node.left.name}" is not allowed.`,
@@ -142,22 +153,21 @@ export function reduceNode(
142153

143154
case NODE.LOGICAL_EXPRESSION: {
144155
const op = node.operator === 'AND' ? '$and' : '$or';
145-
const right = reduceNode(node.right, options);
156+
const right = reduceNode(node.right, options, opsResource);
146157

147158
// combine same operator
148159
if (
149160
node.right.type === NODE.LOGICAL_EXPRESSION &&
150161
node.right.operator === node.operator
151162
) {
152163
return {
153-
[op]: [reduceNode(node.left, options), ...right[op]],
164+
[op]: [reduceNode(node.left, options, opsResource), ...right[op]],
154165
};
155166
}
156167

157168
return {
158-
[op]: [reduceNode(node.left, options), right],
169+
[op]: [reduceNode(node.left, options, opsResource), right],
159170
};
160-
break;
161171
}
162172

163173
default: {
@@ -174,17 +184,14 @@ export function mergeOptions(
174184
const result: ParseToMongoOptionsInternal = {
175185
transform: optsTransform,
176186
allowEmpty: false,
187+
maxOps: Infinity,
177188
};
178189

179190
for (const { transform, allowedKeys, ...rest } of options) {
180191
Object.assign(result, rest);
181192

182193
if (allowedKeys?.length) {
183-
result.allowedKeys = result.allowedKeys || new Set();
184-
185-
for (const key of result.allowedKeys) {
186-
result.allowedKeys.add(key);
187-
}
194+
result.allowedKeys = new Set(allowedKeys);
188195
}
189196

190197
if (Array.isArray(transform) || isFunction(transform)) {
@@ -201,6 +208,26 @@ export function mergeOptions(
201208
return result;
202209
}
203210

211+
export class OpsResource {
212+
uses: number;
213+
214+
constructor(private max: number) {
215+
assert.ok(
216+
max === Infinity || (isNumber(max) && max > 0),
217+
'maxOps must be a number greater than zero.',
218+
);
219+
220+
this.uses = 0;
221+
}
222+
223+
assert() {
224+
assert.ok(
225+
++this.uses <= this.max,
226+
`Maximum search operations reached: ${this.max}`,
227+
);
228+
}
229+
}
230+
204231
function callTransform(
205232
fns: ParseToMongoTransformFn[],
206233
value: unknown,
Lines changed: 70 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import mongoose from 'mongoose';
22
import { describe, expect, it } from 'vitest';
3-
import { parseToMongoose } from './parseToMongoose';
3+
import type { ParseToMongoOptions } from './parseToMongo';
4+
import { MONGO_TRANSFORM, parseToMongoose } from './parseToMongoose';
45

5-
function s(schema: Record<string, any>, input: string): Record<string, any> {
6-
return parseToMongoose(new mongoose.Schema(schema), input);
6+
function s(
7+
schema: Record<string, any>,
8+
input: string,
9+
opts?: ParseToMongoOptions,
10+
): Record<string, any> {
11+
return parseToMongoose(new mongoose.Schema(schema), input, opts);
712
}
813

914
function makeValidationTest(
@@ -32,51 +37,80 @@ function makeValidationTest(
3237
}
3338

3439
describe('parseToMongoose', () => {
35-
describe('should handle number', () => {
36-
makeValidationTest(Number, {
37-
valid: [['18', 18]],
38-
invalid: ['"18"', 'false', 'true'],
40+
describe('options.allowedKeys', () => {
41+
it('should default pre-filled with schema paths', () => {
42+
expect(s({ name: String }, 'name = "andrew"')).toBeTruthy();
43+
44+
expect(() => s({ name: String }, 'age > 18')).toThrowError(
45+
'The search key "age" is not allowed.',
46+
);
3947
});
40-
});
4148

42-
describe('should handle Decimal128', () => {
43-
makeValidationTest(mongoose.Types.Decimal128, {
44-
valid: [['18', 18]],
45-
invalid: ['"18"', 'false', 'true'],
49+
it('should be overridable', () => {
50+
expect(
51+
s({ name: String }, 'age > 18', { allowedKeys: ['age'] }),
52+
).toStrictEqual({ age: { $gt: 18 } });
4653
});
4754
});
4855

49-
describe('should handle string', () => {
50-
makeValidationTest(String, {
51-
valid: [['"Andrew"', 'Andrew']],
52-
invalid: ['123', 'true', 'false'],
56+
describe('options.transform', () => {
57+
it('should accept extensions', () => {
58+
expect(s({ a: Number }, 'a=null')).toStrictEqual({ a: null });
59+
expect(() =>
60+
s({ a: Number }, 'a=null', {
61+
transform: { a: MONGO_TRANSFORM.NOT_NULLABLE },
62+
}),
63+
).toThrowError('The search key "a" cannot be null.');
5364
});
5465
});
5566

56-
describe('should handle date', () => {
57-
makeValidationTest(Date, {
58-
valid: [
59-
['"2025-03-16T20:25:52.946Z"', new Date('2025-03-16T20:25:52.946Z')],
60-
['1742156840247', new Date(1742156840247)],
61-
],
62-
invalid: ['"abc"', 'true', 'false'],
67+
describe('validation from schema', () => {
68+
describe('Number', () => {
69+
makeValidationTest(Number, {
70+
valid: [['18', 18]],
71+
invalid: ['"18"', 'false', 'true'],
72+
});
6373
});
64-
});
6574

66-
describe('should handle boolean', () => {
67-
makeValidationTest(Boolean, {
68-
valid: [
69-
['true', true],
70-
['false', false],
71-
],
72-
invalid: ['"abc"', '123'],
75+
describe('Decimal128', () => {
76+
makeValidationTest(mongoose.Types.Decimal128, {
77+
valid: [['18', 18]],
78+
invalid: ['"18"', 'false', 'true'],
79+
});
80+
});
81+
82+
describe('String', () => {
83+
makeValidationTest(String, {
84+
valid: [['"Andrew"', 'Andrew']],
85+
invalid: ['123', 'true', 'false'],
86+
});
87+
});
88+
89+
describe('Date', () => {
90+
makeValidationTest(Date, {
91+
valid: [
92+
['"2025-03-16T20:25:52.946Z"', new Date('2025-03-16T20:25:52.946Z')],
93+
['1742156840247', new Date(1742156840247)],
94+
],
95+
invalid: ['"abc"', 'true', 'false'],
96+
});
7397
});
74-
});
7598

76-
describe('should handle UUID', () => {
77-
makeValidationTest(mongoose.Types.UUID, {
78-
valid: [['"00000-0000-000"', '00000-0000-000']],
79-
invalid: ['123', 'false', 'true'],
99+
describe('Boolean', () => {
100+
makeValidationTest(Boolean, {
101+
valid: [
102+
['true', true],
103+
['false', false],
104+
],
105+
invalid: ['"abc"', '123'],
106+
});
107+
});
108+
109+
describe('UUID', () => {
110+
makeValidationTest(mongoose.Types.UUID, {
111+
valid: [['"00000-0000-000"', '00000-0000-000']],
112+
invalid: ['123', 'false', 'true'],
113+
});
80114
});
81115
});
82116
});

0 commit comments

Comments
 (0)