@vbnz/conditions 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +259 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +136 -0
- package/package.json +30 -0
- package/src/index.ts +195 -0
- package/tsconfig.json +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# @vbnz/conditions
|
|
2
|
+
|
|
3
|
+
TypeScript библиотека для проверки условий по данным.
|
|
4
|
+
|
|
5
|
+
## Установка
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @vbnz/conditions
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Базовое использование
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { check } from "@vbnz/conditions";
|
|
15
|
+
|
|
16
|
+
const result = check(
|
|
17
|
+
{ "{{ profile.uuid }}": { $eq: "some uuid" } },
|
|
18
|
+
{ profile: { uuid: "some uuid" } }
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
console.log(result); // true
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Библиотека поддерживает шаблонные ключи вида `{{ path.to.value }}` для доступа к вложенным полям.
|
|
25
|
+
|
|
26
|
+
## Операторы сравнения
|
|
27
|
+
|
|
28
|
+
### `$eq` — равно
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
check({ status: { $eq: "active" } }, { status: "active" }); // true
|
|
32
|
+
check({ "{{ user.age }}": { $eq: 25 } }, { user: { age: 25 } }); // true
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### `$ne` — не равно
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
check({ status: { $ne: "blocked" } }, { status: "active" }); // true
|
|
39
|
+
check({ "{{ user.age }}": { $ne: 18 } }, { user: { age: 25 } }); // true
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### `$gt` — больше
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
check({ "{{ user.age }}": { $gt: 18 } }, { user: { age: 25 } }); // true
|
|
46
|
+
check({ "{{ score }}": { $gt: 100 } }, { score: 50 }); // false
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### `$gte` — больше или равно
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
check({ "{{ user.age }}": { $gte: 18 } }, { user: { age: 18 } }); // true
|
|
53
|
+
check({ "{{ user.age }}": { $gte: 21 } }, { user: { age: 18 } }); // false
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### `$lt` — меньше
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
check({ "{{ user.age }}": { $lt: 30 } }, { user: { age: 25 } }); // true
|
|
60
|
+
check({ "{{ score }}": { $lt: 50 } }, { score: 50 }); // false
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### `$lte` — меньше или равно
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
check({ "{{ user.age }}": { $lte: 30 } }, { user: { age: 30 } }); // true
|
|
67
|
+
check({ "{{ user.age }}": { $lte: 20 } }, { user: { age: 25 } }); // false
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### `$between` — в диапазоне (включительно)
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
check({ "{{ score }}": { $between: [0, 100] } }, { score: 75 }); // true
|
|
74
|
+
check({ "{{ score }}": { $between: [0, 50] } }, { score: 75 }); // false
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### `$notBetween` — вне диапазона
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
check({ "{{ score }}": { $notBetween: [0, 50] } }, { score: 75 }); // true
|
|
81
|
+
check({ "{{ score }}": { $notBetween: [0, 100] } }, { score: 75 }); // false
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Операторы включения
|
|
85
|
+
|
|
86
|
+
### `$in` — значение в массиве
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
check({ status: { $in: ["active", "pending"] } }, { status: "active" }); // true
|
|
90
|
+
check({ status: { $in: ["blocked"] } }, { status: "active" }); // false
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### `$notIn` — значение не в массиве
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
check({ status: { $notIn: ["blocked", "deleted"] } }, { status: "active" }); // true
|
|
97
|
+
check({ status: { $notIn: ["active"] } }, { status: "active" }); // false
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Строковые операторы
|
|
101
|
+
|
|
102
|
+
### `$like` — шаблон SQL LIKE (`%` — любые символы, `_` — один символ)
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
check({ name: { $like: "John%" } }, { name: "John Doe" }); // true
|
|
106
|
+
check({ name: { $like: "%Doe" } }, { name: "John Doe" }); // true
|
|
107
|
+
check({ name: { $like: "J_hn" } }, { name: "John" }); // true
|
|
108
|
+
check({ name: { $like: "john%" } }, { name: "John Doe" }); // false (регистрозависимый)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### `$iLike` — регистронезависимый LIKE
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
check({ name: { $iLike: "john%" } }, { name: "John Doe" }); // true
|
|
115
|
+
check({ name: { $iLike: "%DOE" } }, { name: "John Doe" }); // true
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### `$startsWith` — начинается с
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
check({ name: { $startsWith: "John" } }, { name: "John Doe" }); // true
|
|
122
|
+
check({ name: { $startsWith: "Doe" } }, { name: "John Doe" }); // false
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### `$endsWith` — заканчивается на
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
check({ name: { $endsWith: "Doe" } }, { name: "John Doe" }); // true
|
|
129
|
+
check({ name: { $endsWith: "John" } }, { name: "John Doe" }); // false
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### `$substring` — содержит подстроку
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
check({ name: { $substring: "hn D" } }, { name: "John Doe" }); // true
|
|
136
|
+
check({ name: { $substring: "xyz" } }, { name: "John Doe" }); // false
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### `$regexp` — соответствует регулярному выражению
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
check({ email: { $regexp: "^.+@.+\\.com$" } }, { email: "test@example.com" }); // true
|
|
143
|
+
check({ email: { $regexp: /^.+@.+\.com$/ } }, { email: "test@example.com" }); // true (RegExp объект)
|
|
144
|
+
check({ email: { $regexp: "\\.org$" } }, { email: "test@example.com" }); // false
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### `$notRegexp` — не соответствует регулярному выражению
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
check({ email: { $notRegexp: "\\.org$" } }, { email: "test@example.com" }); // true
|
|
151
|
+
check({ email: { $notRegexp: "\\.com$" } }, { email: "test@example.com" }); // false
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Операторы для массивов и объектов
|
|
155
|
+
|
|
156
|
+
### `$contains` — содержит элемент / подстроку / подобъект
|
|
157
|
+
|
|
158
|
+
Для строк:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
check({ bio: { $contains: "developer" } }, { bio: "I am a developer" }); // true
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Для массивов:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
check({ tags: { $contains: "typescript" } }, { tags: ["js", "ts", "typescript"] }); // true
|
|
168
|
+
check({ tags: { $contains: ["js", "ts"] } }, { tags: ["js", "ts", "node"] }); // true (все элементы)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Для объектов:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
check(
|
|
175
|
+
{ profile: { $contains: { role: "admin" } } },
|
|
176
|
+
{ profile: { role: "admin", name: "John" } }
|
|
177
|
+
); // true
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### `$overlap` — пересечение массивов
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
check({ tags: { $overlap: ["ts", "python"] } }, { tags: ["js", "ts", "node"] }); // true (есть "ts")
|
|
184
|
+
check({ tags: { $overlap: ["go", "rust"] } }, { tags: ["js", "ts"] }); // false
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Специальные операторы
|
|
188
|
+
|
|
189
|
+
### `$is` — точное совпадение (аналог `$eq`, для явности)
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
check({ value: { $is: null } }, { value: null }); // true
|
|
193
|
+
check({ value: { $is: undefined } }, { value: undefined }); // true
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### `$not` — отрицание условия
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
check({ status: { $not: { $eq: "blocked" } } }, { status: "active" }); // true
|
|
200
|
+
check({ "{{ user.age }}": { $not: { $lt: 18 } } }, { user: { age: 25 } }); // true
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Логические операторы
|
|
204
|
+
|
|
205
|
+
### `$and` — логическое И
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
check(
|
|
209
|
+
{
|
|
210
|
+
$and: [
|
|
211
|
+
{ "{{ user.age }}": { $gte: 18 } },
|
|
212
|
+
{ "{{ user.verified }}": { $eq: true } }
|
|
213
|
+
]
|
|
214
|
+
},
|
|
215
|
+
{ user: { age: 25, verified: true } }
|
|
216
|
+
); // true
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### `$or` — логическое ИЛИ
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
check(
|
|
223
|
+
{
|
|
224
|
+
$or: [
|
|
225
|
+
{ role: { $eq: "admin" } },
|
|
226
|
+
{ role: { $eq: "moderator" } }
|
|
227
|
+
]
|
|
228
|
+
},
|
|
229
|
+
{ role: "moderator" }
|
|
230
|
+
); // true
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### `$not` на уровне условия — отрицание всего блока
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
check(
|
|
237
|
+
{ $not: { status: { $eq: "blocked" } } },
|
|
238
|
+
{ status: "active" }
|
|
239
|
+
); // true
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Комбинирование операторов
|
|
243
|
+
|
|
244
|
+
Операторы можно комбинировать в одном условии:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
check(
|
|
248
|
+
{
|
|
249
|
+
"{{ user.age }}": { $gte: 18, $lte: 65 },
|
|
250
|
+
status: { $in: ["active", "pending"] },
|
|
251
|
+
name: { $iLike: "%john%" }
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
user: { age: 25 },
|
|
255
|
+
status: "active",
|
|
256
|
+
name: "John Doe"
|
|
257
|
+
}
|
|
258
|
+
); // true
|
|
259
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type Primitive = string | number | boolean | null | undefined;
|
|
2
|
+
export type OperatorCondition = {
|
|
3
|
+
$eq?: unknown;
|
|
4
|
+
$ne?: unknown;
|
|
5
|
+
$gt?: number;
|
|
6
|
+
$gte?: number;
|
|
7
|
+
$lt?: number;
|
|
8
|
+
$lte?: number;
|
|
9
|
+
$in?: unknown[];
|
|
10
|
+
$notIn?: unknown[];
|
|
11
|
+
$between?: [number, number];
|
|
12
|
+
$notBetween?: [number, number];
|
|
13
|
+
$like?: string;
|
|
14
|
+
$iLike?: string;
|
|
15
|
+
$startsWith?: string;
|
|
16
|
+
$endsWith?: string;
|
|
17
|
+
$substring?: string;
|
|
18
|
+
$regexp?: string | RegExp;
|
|
19
|
+
$notRegexp?: string | RegExp;
|
|
20
|
+
$contains?: unknown;
|
|
21
|
+
$overlap?: unknown[];
|
|
22
|
+
$is?: unknown;
|
|
23
|
+
$not?: FieldCondition;
|
|
24
|
+
};
|
|
25
|
+
export type FieldCondition = Primitive | OperatorCondition;
|
|
26
|
+
export type Condition = {
|
|
27
|
+
[key: string]: FieldCondition | Condition[] | Condition;
|
|
28
|
+
} & {
|
|
29
|
+
$and?: Condition[];
|
|
30
|
+
$or?: Condition[];
|
|
31
|
+
$not?: Condition;
|
|
32
|
+
};
|
|
33
|
+
export declare function check(condition: Condition, payload: unknown): boolean;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ==================== Types ====================
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.check = check;
|
|
5
|
+
// ==================== Utils ====================
|
|
6
|
+
function isObject(value) {
|
|
7
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function getByPath(source, path) {
|
|
10
|
+
if (!path)
|
|
11
|
+
return undefined;
|
|
12
|
+
return path.split(".").reduce((acc, key) => {
|
|
13
|
+
return isObject(acc) ? acc[key] : undefined;
|
|
14
|
+
}, source);
|
|
15
|
+
}
|
|
16
|
+
function extractPath(key) {
|
|
17
|
+
const match = key.match(/^\{\{\s*(.+?)\s*\}\}$/);
|
|
18
|
+
return match ? match[1].trim() : key;
|
|
19
|
+
}
|
|
20
|
+
function toRegExp(pattern, flags) {
|
|
21
|
+
return pattern instanceof RegExp
|
|
22
|
+
? new RegExp(pattern.source, flags !== null && flags !== void 0 ? flags : pattern.flags)
|
|
23
|
+
: new RegExp(pattern, flags);
|
|
24
|
+
}
|
|
25
|
+
function likeToRegExp(pattern, insensitive = false) {
|
|
26
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
27
|
+
const regex = `^${escaped.replace(/%/g, ".*").replace(/_/g, ".")}$`;
|
|
28
|
+
return new RegExp(regex, insensitive ? "i" : "");
|
|
29
|
+
}
|
|
30
|
+
const operators = {
|
|
31
|
+
$eq: (actual, expected) => actual === expected,
|
|
32
|
+
$ne: (actual, expected) => actual !== expected,
|
|
33
|
+
$is: (actual, expected) => actual === expected,
|
|
34
|
+
$gt: (actual, expected) => typeof actual === "number" && actual > expected,
|
|
35
|
+
$gte: (actual, expected) => typeof actual === "number" && actual >= expected,
|
|
36
|
+
$lt: (actual, expected) => typeof actual === "number" && actual < expected,
|
|
37
|
+
$lte: (actual, expected) => typeof actual === "number" && actual <= expected,
|
|
38
|
+
$in: (actual, expected) => Array.isArray(expected) && expected.includes(actual),
|
|
39
|
+
$notIn: (actual, expected) => Array.isArray(expected) && !expected.includes(actual),
|
|
40
|
+
$between: (actual, expected) => {
|
|
41
|
+
if (typeof actual !== "number" || !Array.isArray(expected))
|
|
42
|
+
return false;
|
|
43
|
+
const [min, max] = expected;
|
|
44
|
+
return actual >= min && actual <= max;
|
|
45
|
+
},
|
|
46
|
+
$notBetween: (actual, expected) => {
|
|
47
|
+
if (typeof actual !== "number" || !Array.isArray(expected))
|
|
48
|
+
return false;
|
|
49
|
+
const [min, max] = expected;
|
|
50
|
+
return actual < min || actual > max;
|
|
51
|
+
},
|
|
52
|
+
$like: (actual, expected) => typeof actual === "string" && likeToRegExp(expected).test(actual),
|
|
53
|
+
$iLike: (actual, expected) => typeof actual === "string" && likeToRegExp(expected, true).test(actual),
|
|
54
|
+
$startsWith: (actual, expected) => typeof actual === "string" && actual.startsWith(expected),
|
|
55
|
+
$endsWith: (actual, expected) => typeof actual === "string" && actual.endsWith(expected),
|
|
56
|
+
$substring: (actual, expected) => typeof actual === "string" && actual.includes(expected),
|
|
57
|
+
$regexp: (actual, expected) => typeof actual === "string" && toRegExp(expected).test(actual),
|
|
58
|
+
$notRegexp: (actual, expected) => typeof actual === "string" && !toRegExp(expected).test(actual),
|
|
59
|
+
$contains: (actual, expected) => {
|
|
60
|
+
if (typeof actual === "string") {
|
|
61
|
+
return typeof expected === "string" && actual.includes(expected);
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(actual)) {
|
|
64
|
+
return Array.isArray(expected)
|
|
65
|
+
? expected.every(item => actual.includes(item))
|
|
66
|
+
: actual.includes(expected);
|
|
67
|
+
}
|
|
68
|
+
if (isObject(actual) && isObject(expected)) {
|
|
69
|
+
return Object.entries(expected).every(([k, v]) => actual[k] === v);
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
},
|
|
73
|
+
$overlap: (actual, expected) => {
|
|
74
|
+
if (!Array.isArray(actual) || !Array.isArray(expected))
|
|
75
|
+
return false;
|
|
76
|
+
return expected.some(item => actual.includes(item));
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
// ==================== Evaluation ====================
|
|
80
|
+
function evaluateOperators(actual, condition) {
|
|
81
|
+
for (const [op, expected] of Object.entries(condition)) {
|
|
82
|
+
if (op === "$not") {
|
|
83
|
+
if (evaluateField(actual, expected))
|
|
84
|
+
return false;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const fn = operators[op];
|
|
88
|
+
if (!fn)
|
|
89
|
+
continue;
|
|
90
|
+
if (!fn(actual, expected))
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
function evaluateField(actual, condition) {
|
|
96
|
+
if (!isObject(condition)) {
|
|
97
|
+
return actual === condition;
|
|
98
|
+
}
|
|
99
|
+
const hasOperator = Object.keys(condition).some(key => key.startsWith("$"));
|
|
100
|
+
if (!hasOperator) {
|
|
101
|
+
return actual === condition;
|
|
102
|
+
}
|
|
103
|
+
return evaluateOperators(actual, condition);
|
|
104
|
+
}
|
|
105
|
+
// ==================== Logical Operators ====================
|
|
106
|
+
function evaluateLogical(condition, payload) {
|
|
107
|
+
if (condition.$and) {
|
|
108
|
+
if (!condition.$and.every(c => check(c, payload)))
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (condition.$or) {
|
|
112
|
+
if (!condition.$or.some(c => check(c, payload)))
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (condition.$not) {
|
|
116
|
+
if (check(condition.$not, payload))
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
// ==================== Main API ====================
|
|
122
|
+
function check(condition, payload) {
|
|
123
|
+
// Logical operators
|
|
124
|
+
if (!evaluateLogical(condition, payload))
|
|
125
|
+
return false;
|
|
126
|
+
// Field conditions
|
|
127
|
+
for (const [rawKey, rule] of Object.entries(condition)) {
|
|
128
|
+
if (rawKey.startsWith("$"))
|
|
129
|
+
continue;
|
|
130
|
+
const path = extractPath(rawKey);
|
|
131
|
+
const actual = getByPath(payload, path);
|
|
132
|
+
if (!evaluateField(actual, rule))
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vbnz/conditions",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Lightweight condition checker",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.json",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"conditions",
|
|
23
|
+
"rules",
|
|
24
|
+
"typescript"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"typescript": "^5.6.3"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// ==================== Types ====================
|
|
2
|
+
|
|
3
|
+
export type Primitive = string | number | boolean | null | undefined;
|
|
4
|
+
|
|
5
|
+
export type OperatorCondition = {
|
|
6
|
+
$eq?: unknown;
|
|
7
|
+
$ne?: unknown;
|
|
8
|
+
$gt?: number;
|
|
9
|
+
$gte?: number;
|
|
10
|
+
$lt?: number;
|
|
11
|
+
$lte?: number;
|
|
12
|
+
$in?: unknown[];
|
|
13
|
+
$notIn?: unknown[];
|
|
14
|
+
$between?: [number, number];
|
|
15
|
+
$notBetween?: [number, number];
|
|
16
|
+
$like?: string;
|
|
17
|
+
$iLike?: string;
|
|
18
|
+
$startsWith?: string;
|
|
19
|
+
$endsWith?: string;
|
|
20
|
+
$substring?: string;
|
|
21
|
+
$regexp?: string | RegExp;
|
|
22
|
+
$notRegexp?: string | RegExp;
|
|
23
|
+
$contains?: unknown;
|
|
24
|
+
$overlap?: unknown[];
|
|
25
|
+
$is?: unknown;
|
|
26
|
+
$not?: FieldCondition;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type FieldCondition = Primitive | OperatorCondition;
|
|
30
|
+
|
|
31
|
+
export type Condition = {
|
|
32
|
+
[key: string]: FieldCondition | Condition[] | Condition;
|
|
33
|
+
} & {
|
|
34
|
+
$and?: Condition[];
|
|
35
|
+
$or?: Condition[];
|
|
36
|
+
$not?: Condition;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ==================== Utils ====================
|
|
40
|
+
|
|
41
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getByPath(source: unknown, path: string): unknown {
|
|
46
|
+
if (!path) return undefined;
|
|
47
|
+
|
|
48
|
+
return path.split(".").reduce<unknown>((acc, key) => {
|
|
49
|
+
return isObject(acc) ? acc[key] : undefined;
|
|
50
|
+
}, source);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function extractPath(key: string): string {
|
|
54
|
+
const match = key.match(/^\{\{\s*(.+?)\s*\}\}$/);
|
|
55
|
+
return match ? match[1].trim() : key;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toRegExp(pattern: string | RegExp, flags?: string): RegExp {
|
|
59
|
+
return pattern instanceof RegExp
|
|
60
|
+
? new RegExp(pattern.source, flags ?? pattern.flags)
|
|
61
|
+
: new RegExp(pattern, flags);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function likeToRegExp(pattern: string, insensitive = false): RegExp {
|
|
65
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
66
|
+
const regex = `^${escaped.replace(/%/g, ".*").replace(/_/g, ".")}$`;
|
|
67
|
+
return new RegExp(regex, insensitive ? "i" : "");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ==================== Operators ====================
|
|
71
|
+
|
|
72
|
+
type OperatorFn = (actual: unknown, expected: unknown) => boolean;
|
|
73
|
+
|
|
74
|
+
const operators: Record<string, OperatorFn> = {
|
|
75
|
+
$eq: (actual, expected) => actual === expected,
|
|
76
|
+
$ne: (actual, expected) => actual !== expected,
|
|
77
|
+
$is: (actual, expected) => actual === expected,
|
|
78
|
+
|
|
79
|
+
$gt: (actual, expected) => typeof actual === "number" && actual > (expected as number),
|
|
80
|
+
$gte: (actual, expected) => typeof actual === "number" && actual >= (expected as number),
|
|
81
|
+
$lt: (actual, expected) => typeof actual === "number" && actual < (expected as number),
|
|
82
|
+
$lte: (actual, expected) => typeof actual === "number" && actual <= (expected as number),
|
|
83
|
+
|
|
84
|
+
$in: (actual, expected) => Array.isArray(expected) && expected.includes(actual),
|
|
85
|
+
$notIn: (actual, expected) => Array.isArray(expected) && !expected.includes(actual),
|
|
86
|
+
|
|
87
|
+
$between: (actual, expected) => {
|
|
88
|
+
if (typeof actual !== "number" || !Array.isArray(expected)) return false;
|
|
89
|
+
const [min, max] = expected as [number, number];
|
|
90
|
+
return actual >= min && actual <= max;
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
$notBetween: (actual, expected) => {
|
|
94
|
+
if (typeof actual !== "number" || !Array.isArray(expected)) return false;
|
|
95
|
+
const [min, max] = expected as [number, number];
|
|
96
|
+
return actual < min || actual > max;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
$like: (actual, expected) => typeof actual === "string" && likeToRegExp(expected as string).test(actual),
|
|
100
|
+
$iLike: (actual, expected) => typeof actual === "string" && likeToRegExp(expected as string, true).test(actual),
|
|
101
|
+
|
|
102
|
+
$startsWith: (actual, expected) => typeof actual === "string" && actual.startsWith(expected as string),
|
|
103
|
+
$endsWith: (actual, expected) => typeof actual === "string" && actual.endsWith(expected as string),
|
|
104
|
+
$substring: (actual, expected) => typeof actual === "string" && actual.includes(expected as string),
|
|
105
|
+
|
|
106
|
+
$regexp: (actual, expected) => typeof actual === "string" && toRegExp(expected as string | RegExp).test(actual),
|
|
107
|
+
$notRegexp: (actual, expected) => typeof actual === "string" && !toRegExp(expected as string | RegExp).test(actual),
|
|
108
|
+
|
|
109
|
+
$contains: (actual, expected) => {
|
|
110
|
+
if (typeof actual === "string") {
|
|
111
|
+
return typeof expected === "string" && actual.includes(expected);
|
|
112
|
+
}
|
|
113
|
+
if (Array.isArray(actual)) {
|
|
114
|
+
return Array.isArray(expected)
|
|
115
|
+
? expected.every(item => actual.includes(item))
|
|
116
|
+
: actual.includes(expected);
|
|
117
|
+
}
|
|
118
|
+
if (isObject(actual) && isObject(expected)) {
|
|
119
|
+
return Object.entries(expected).every(([k, v]) => actual[k] === v);
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
$overlap: (actual, expected) => {
|
|
125
|
+
if (!Array.isArray(actual) || !Array.isArray(expected)) return false;
|
|
126
|
+
return expected.some(item => actual.includes(item));
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// ==================== Evaluation ====================
|
|
131
|
+
|
|
132
|
+
function evaluateOperators(actual: unknown, condition: OperatorCondition): boolean {
|
|
133
|
+
for (const [op, expected] of Object.entries(condition)) {
|
|
134
|
+
if (op === "$not") {
|
|
135
|
+
if (evaluateField(actual, expected as FieldCondition)) return false;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const fn = operators[op];
|
|
140
|
+
if (!fn) continue;
|
|
141
|
+
|
|
142
|
+
if (!fn(actual, expected)) return false;
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function evaluateField(actual: unknown, condition: FieldCondition): boolean {
|
|
148
|
+
if (!isObject(condition)) {
|
|
149
|
+
return actual === condition;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const hasOperator = Object.keys(condition).some(key => key.startsWith("$"));
|
|
153
|
+
if (!hasOperator) {
|
|
154
|
+
return actual === condition;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return evaluateOperators(actual, condition as OperatorCondition);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ==================== Logical Operators ====================
|
|
161
|
+
|
|
162
|
+
function evaluateLogical(condition: Condition, payload: unknown): boolean {
|
|
163
|
+
if (condition.$and) {
|
|
164
|
+
if (!condition.$and.every(c => check(c, payload))) return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (condition.$or) {
|
|
168
|
+
if (!condition.$or.some(c => check(c, payload))) return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (condition.$not) {
|
|
172
|
+
if (check(condition.$not, payload)) return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ==================== Main API ====================
|
|
179
|
+
|
|
180
|
+
export function check(condition: Condition, payload: unknown): boolean {
|
|
181
|
+
// Logical operators
|
|
182
|
+
if (!evaluateLogical(condition, payload)) return false;
|
|
183
|
+
|
|
184
|
+
// Field conditions
|
|
185
|
+
for (const [rawKey, rule] of Object.entries(condition)) {
|
|
186
|
+
if (rawKey.startsWith("$")) continue;
|
|
187
|
+
|
|
188
|
+
const path = extractPath(rawKey);
|
|
189
|
+
const actual = getByPath(payload, path);
|
|
190
|
+
|
|
191
|
+
if (!evaluateField(actual, rule as FieldCondition)) return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return true;
|
|
195
|
+
}
|