qast 2.0.3 → 2.0.5
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 +48 -734
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +16 -10
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -4
- package/dist/index.js.map +1 -1
- package/dist/parser/tokenizer.d.ts.map +1 -1
- package/dist/parser/tokenizer.js +3 -1
- package/dist/parser/tokenizer.js.map +1 -1
- package/package.json +9 -7
- package/README.zh-CN.md +0 -520
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
## QAST — Query to AST to ORM
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/qast)
|
|
4
4
|
[](https://www.npmjs.com/package/qast)
|
|
@@ -6,770 +6,84 @@
|
|
|
6
6
|
[](http://www.typescriptlang.org/)
|
|
7
7
|
[](https://github.com/hocestnonsatis/qast/blob/main/LICENSE)
|
|
8
8
|
|
|
9
|
-
**QAST** is a small, ORM-agnostic library that
|
|
9
|
+
**QAST** is a small, ORM-agnostic, zero-dependency library that turns human‑readable query strings
|
|
10
|
+
(for example: `age gt 25 and (name eq "John" or city eq "Paris")`) into an **AST** and then into **ORM filters**.
|
|
10
11
|
|
|
11
|
-
It
|
|
12
|
+
It is designed to be simple to adopt, safe by default, and easy to integrate into existing REST APIs.
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
---
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
- 🎯 **Type-Safe**: Full TypeScript support for parsed ASTs and generated filters
|
|
17
|
-
- 🔌 **ORM-Agnostic**: Works with Prisma, TypeORM, Sequelize, and more via adapters
|
|
18
|
-
- 📝 **Simple Syntax**: Natural query expressions using logical operators
|
|
19
|
-
- 🚀 **Lightweight**: No dependencies, small bundle size
|
|
16
|
+
### Features
|
|
20
17
|
|
|
21
|
-
|
|
18
|
+
- **Zero runtime dependencies** – lightweight and easy to embed
|
|
19
|
+
- **ORM‑agnostic adapters** – Prisma, TypeORM, Sequelize, Mongoose, Knex, Drizzle
|
|
20
|
+
- **Safe by default** – optional whitelists and complexity limits
|
|
21
|
+
- **TypeScript first** – fully typed API and AST
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
### Installation
|
|
22
26
|
|
|
23
27
|
```bash
|
|
24
28
|
npm install qast
|
|
25
29
|
```
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
### Basic Usage
|
|
30
|
-
|
|
31
|
-
```typescript
|
|
32
|
-
import { parseQuery, toPrismaFilter } from 'qast';
|
|
33
|
-
|
|
34
|
-
const query = 'age gt 25 and (name eq "John" or city eq "Paris")';
|
|
35
|
-
|
|
36
|
-
const ast = parseQuery(query);
|
|
37
|
-
const prismaFilter = toPrismaFilter(ast);
|
|
31
|
+
---
|
|
38
32
|
|
|
39
|
-
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
### With Validation
|
|
33
|
+
### Quick Start (Prisma example)
|
|
43
34
|
|
|
44
35
|
```typescript
|
|
45
36
|
import { parseQuery, toPrismaFilter } from 'qast';
|
|
46
37
|
|
|
47
|
-
|
|
38
|
+
// Example: ?filter=age gt 25 and name eq "John"
|
|
39
|
+
const raw = req.query.filter as string;
|
|
48
40
|
|
|
49
|
-
// Parse
|
|
50
|
-
const ast = parseQuery(
|
|
51
|
-
allowedFields: ['age', 'name', 'city'],
|
|
52
|
-
allowedOperators: ['
|
|
41
|
+
// Parse and (optionally) validate
|
|
42
|
+
const ast = parseQuery(raw, {
|
|
43
|
+
allowedFields: ['age', 'name', 'city', 'active'],
|
|
44
|
+
allowedOperators: ['eq', 'ne', 'gt', 'lt', 'gte', 'lte', 'in'],
|
|
53
45
|
validate: true,
|
|
54
46
|
});
|
|
55
47
|
|
|
56
|
-
|
|
57
|
-
await prisma.user.findMany(prismaFilter);
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
## Query Syntax
|
|
61
|
-
|
|
62
|
-
### Operators
|
|
63
|
-
|
|
64
|
-
QAST supports the following comparison operators:
|
|
65
|
-
|
|
66
|
-
- `eq` - Equal
|
|
67
|
-
- `ne` - Not equal
|
|
68
|
-
- `gt` - Greater than
|
|
69
|
-
- `lt` - Less than
|
|
70
|
-
- `gte` - Greater than or equal
|
|
71
|
-
- `lte` - Less than or equal
|
|
72
|
-
- `in` - In array
|
|
73
|
-
- `notIn` - Not in array
|
|
74
|
-
- `contains` - Contains substring (string matching)
|
|
75
|
-
- `startsWith` - String starts with pattern
|
|
76
|
-
- `endsWith` - String ends with pattern
|
|
77
|
-
- `like` - Pattern matching with wildcards (`%` and `_`)
|
|
78
|
-
- `regex` / `matches` - Regex pattern matching
|
|
79
|
-
- `between` - Range check: `age between 18 and 65`
|
|
80
|
-
- `isNull` - Null check: `email isNull`
|
|
81
|
-
- `isNotNull` - Not null check: `email isNotNull`
|
|
82
|
-
|
|
83
|
-
### Logical Operators
|
|
84
|
-
|
|
85
|
-
- `and` - Logical AND
|
|
86
|
-
- `or` - Logical OR
|
|
87
|
-
- `not` - Logical NOT: `not (age gt 25)`
|
|
88
|
-
|
|
89
|
-
### Query Clauses
|
|
90
|
-
|
|
91
|
-
- **Sorting**: `orderBy age desc, name asc` - Sort by multiple fields
|
|
92
|
-
- **Pagination**: `limit 10 offset 20` - Limit and offset results
|
|
93
|
-
- **Nested fields**: `user.profile.name eq "John"` - Access nested fields with dot notation
|
|
94
|
-
|
|
95
|
-
### Values
|
|
96
|
-
|
|
97
|
-
- **Strings**: Use single or double quotes: `"John"` or `'John'`
|
|
98
|
-
- **Numbers**: Integers or floats: `25`, `25.99`, `-10`
|
|
99
|
-
- **Booleans**: `true` or `false`
|
|
100
|
-
- **Null**: `null` - For null checks
|
|
101
|
-
- **Arrays**: For `in` and `notIn` operators: `[1,2,3]` or `["John","Jane"]`
|
|
102
|
-
|
|
103
|
-
### Examples
|
|
104
|
-
|
|
105
|
-
```typescript
|
|
106
|
-
// Simple comparison
|
|
107
|
-
'age gt 25'
|
|
108
|
-
|
|
109
|
-
// String comparison
|
|
110
|
-
'name eq "John"'
|
|
111
|
-
|
|
112
|
-
// Boolean comparison
|
|
113
|
-
'active eq true'
|
|
114
|
-
|
|
115
|
-
// Array (in operator)
|
|
116
|
-
'age in [1,2,3]'
|
|
117
|
-
|
|
118
|
-
// AND operation
|
|
119
|
-
'age gt 25 and name eq "John"'
|
|
120
|
-
|
|
121
|
-
// OR operation
|
|
122
|
-
'name eq "John" or name eq "Jane"'
|
|
123
|
-
|
|
124
|
-
// Nested parentheses
|
|
125
|
-
'age gt 25 and (name eq "John" or city eq "Paris")'
|
|
126
|
-
|
|
127
|
-
// Complex query
|
|
128
|
-
'age gt 25 and (name eq "John" or city eq "Paris") and active eq true'
|
|
129
|
-
|
|
130
|
-
// New operators
|
|
131
|
-
'age between 18 and 65'
|
|
132
|
-
'age notIn [1,2,3]'
|
|
133
|
-
'name like "John%"'
|
|
134
|
-
'email matches "^[a-z]+@example\.com$"'
|
|
135
|
-
'email isNull'
|
|
136
|
-
'email isNotNull'
|
|
137
|
-
'not (age gt 25)'
|
|
138
|
-
|
|
139
|
-
// Sorting and pagination
|
|
140
|
-
'age gt 25 orderBy age desc limit 10 offset 20'
|
|
141
|
-
'name eq "John" orderBy name asc, age desc limit 5'
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
## ORM Adapters
|
|
145
|
-
|
|
146
|
-
### Prisma
|
|
147
|
-
|
|
148
|
-
```typescript
|
|
149
|
-
import { parseQuery, toPrismaFilter } from 'qast';
|
|
150
|
-
|
|
151
|
-
const query = 'age gt 25 and name eq "John"';
|
|
152
|
-
const ast = parseQuery(query);
|
|
48
|
+
// Transform to Prisma filter
|
|
153
49
|
const filter = toPrismaFilter(ast);
|
|
154
50
|
|
|
155
|
-
|
|
156
|
-
// where: {
|
|
157
|
-
// age: { gt: 25 },
|
|
158
|
-
// name: { equals: "John" }
|
|
159
|
-
// }
|
|
160
|
-
// }
|
|
161
|
-
|
|
162
|
-
await prisma.user.findMany(filter);
|
|
51
|
+
const users = await prisma.user.findMany(filter);
|
|
163
52
|
```
|
|
164
53
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
```typescript
|
|
168
|
-
import { parseQuery, toTypeORMFilter } from 'qast';
|
|
169
|
-
import { MoreThan, Equal } from 'typeorm';
|
|
170
|
-
|
|
171
|
-
const query = 'age gt 25 and name eq "John"';
|
|
172
|
-
const ast = parseQuery(query);
|
|
173
|
-
const filter = toTypeORMFilter(ast);
|
|
54
|
+
---
|
|
174
55
|
|
|
175
|
-
|
|
176
|
-
// The adapter returns a structure that you can transform using TypeORM operators
|
|
177
|
-
// For equality, TypeORM accepts plain values directly
|
|
56
|
+
### Core Concepts
|
|
178
57
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
58
|
+
- **Query string** → parsed into an **AST**
|
|
59
|
+
- AST can be:
|
|
60
|
+
- used directly, or
|
|
61
|
+
- passed to an adapter (Prisma, TypeORM, etc.) to get an ORM‑specific filter
|
|
62
|
+
- Security and performance can be tuned via:
|
|
63
|
+
- `allowedFields`, `allowedOperators`
|
|
64
|
+
- `maxDepth`, `maxNodes`, `maxQueryLength`, `maxArrayLength`, `maxStringLength`
|
|
183
65
|
|
|
184
|
-
|
|
185
|
-
// const transformed = {
|
|
186
|
-
// age: MoreThan(25),
|
|
187
|
-
// name: "John"
|
|
188
|
-
// }
|
|
66
|
+
You do **not** need any of the ORMs installed if you only work with the AST; ORM packages are optional peer dependencies.
|
|
189
67
|
|
|
190
|
-
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
**Note**: TypeORM requires operator functions (`MoreThan`, `LessThan`, etc.) for non-equality comparisons. The adapter returns a structure with metadata that you can transform. For equality comparisons, TypeORM accepts plain values.
|
|
68
|
+
---
|
|
194
69
|
|
|
195
|
-
###
|
|
196
|
-
|
|
197
|
-
```typescript
|
|
198
|
-
import { parseQuery, toSequelizeFilter } from 'qast';
|
|
199
|
-
import { Op } from 'sequelize';
|
|
70
|
+
### Minimal API Surface
|
|
200
71
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
72
|
+
- `parseQuery(query, options?)` – parse a filter expression into an AST
|
|
73
|
+
- `parseQueryFull(query, options?)` – parse filters + `orderBy` / `limit` / `offset`
|
|
74
|
+
- `toPrismaFilter(ast)` – convert AST / QueryAST to Prisma filter object
|
|
75
|
+
- `toTypeORMFilter(ast)` – convert AST / QueryAST to a TypeORM‑style filter
|
|
76
|
+
- `toSequelizeFilter(ast)` – convert AST / QueryAST to a Sequelize‑style filter
|
|
77
|
+
- `toMongooseFilter(ast)` – convert AST / QueryAST to a Mongoose‑style filter
|
|
78
|
+
- `toKnexFilter(ast)` – convert AST / QueryAST to a Knex‑style filter
|
|
79
|
+
- `toDrizzleFilter(ast)` – convert AST / QueryAST to a Drizzle‑style filter
|
|
204
80
|
|
|
205
|
-
|
|
206
|
-
// __qast_logical__: 'and',
|
|
207
|
-
// conditions: [
|
|
208
|
-
// { age: { __qast_operator__: 'gt', value: 25 } },
|
|
209
|
-
// { name: 'John' }
|
|
210
|
-
// ]
|
|
211
|
-
// }
|
|
81
|
+
All advanced utilities (caching, cost estimation, AST transforms, etc.) are kept small and dependency‑free.
|
|
212
82
|
|
|
213
|
-
|
|
214
|
-
function transformSequelizeFilter(filter: any): any {
|
|
215
|
-
if (filter.__qast_logical__) {
|
|
216
|
-
const op = filter.__qast_logical__ === 'and' ? Op.and : Op.or;
|
|
217
|
-
return {
|
|
218
|
-
[op]: filter.conditions.map(transformSequelizeFilter),
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const result: any = {};
|
|
223
|
-
for (const [key, value] of Object.entries(filter)) {
|
|
224
|
-
if (value && typeof value === 'object' && '__qast_operator__' in value) {
|
|
225
|
-
const opKey = value.__qast_operator__;
|
|
226
|
-
const op = Op[opKey as keyof typeof Op];
|
|
227
|
-
if (opKey === 'contains') {
|
|
228
|
-
result[key] = { [Op.like]: `%${value.value}%` };
|
|
229
|
-
} else {
|
|
230
|
-
result[key] = { [op]: value.value };
|
|
231
|
-
}
|
|
232
|
-
} else {
|
|
233
|
-
result[key] = value;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
return result;
|
|
237
|
-
}
|
|
83
|
+
---
|
|
238
84
|
|
|
239
|
-
|
|
240
|
-
// transformed = {
|
|
241
|
-
// [Op.and]: [
|
|
242
|
-
// { age: { [Op.gt]: 25 } },
|
|
243
|
-
// { name: 'John' }
|
|
244
|
-
// ]
|
|
245
|
-
// }
|
|
246
|
-
|
|
247
|
-
await User.findAll({ where: transformed });
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
**Note**: Sequelize uses the `Op` object from 'sequelize'. Since Sequelize is an optional peer dependency, the adapter returns a structure with metadata (`__qast_operator__` and `__qast_logical__`) that you need to transform to use `Op` operators. For simple equality (`eq`), the adapter returns plain values which Sequelize accepts directly.
|
|
251
|
-
|
|
252
|
-
### Mongoose
|
|
253
|
-
|
|
254
|
-
```typescript
|
|
255
|
-
import { parseQueryFull, toMongooseFilter } from 'qast';
|
|
256
|
-
|
|
257
|
-
const query = 'age gt 25 orderBy age desc limit 10';
|
|
258
|
-
const queryAST = parseQueryFull(query);
|
|
259
|
-
const filter = toMongooseFilter(queryAST);
|
|
260
|
-
|
|
261
|
-
// filter = {
|
|
262
|
-
// filter: { age: { $gt: 25 } },
|
|
263
|
-
// sort: { age: -1 },
|
|
264
|
-
// limit: 10
|
|
265
|
-
// }
|
|
266
|
-
|
|
267
|
-
await User.find(filter.filter)
|
|
268
|
-
.sort(filter.sort)
|
|
269
|
-
.limit(filter.limit);
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
### Knex.js
|
|
273
|
-
|
|
274
|
-
```typescript
|
|
275
|
-
import { parseQueryFull, toKnexFilter } from 'qast';
|
|
276
|
-
|
|
277
|
-
const query = 'age gt 25 orderBy age desc limit 10';
|
|
278
|
-
const queryAST = parseQueryFull(query);
|
|
279
|
-
const filter = toKnexFilter(queryAST);
|
|
280
|
-
|
|
281
|
-
// Use with Knex query builder
|
|
282
|
-
knex('users')
|
|
283
|
-
.where(filter.whereCallback)
|
|
284
|
-
.orderBy(filter.orderBy!)
|
|
285
|
-
.limit(filter.limit!)
|
|
286
|
-
.offset(filter.offset);
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
### Drizzle ORM
|
|
290
|
-
|
|
291
|
-
```typescript
|
|
292
|
-
import { parseQueryFull, toDrizzleFilter, transformDrizzleConditions } from 'qast';
|
|
293
|
-
import { and, gt, eq, desc } from 'drizzle-orm';
|
|
294
|
-
|
|
295
|
-
const query = 'age gt 25 and name eq "John"';
|
|
296
|
-
const queryAST = parseQueryFull(query);
|
|
297
|
-
const filter = toDrizzleFilter(queryAST);
|
|
298
|
-
|
|
299
|
-
// Transform to Drizzle conditions
|
|
300
|
-
const columnMap = { age: users.age, name: users.name };
|
|
301
|
-
const where = transformDrizzleConditions(filter.where, { and, gt, eq }, columnMap);
|
|
302
|
-
|
|
303
|
-
await db.select().from(users).where(where);
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
## Query Builder API
|
|
307
|
-
|
|
308
|
-
Build queries programmatically using a fluent API:
|
|
309
|
-
|
|
310
|
-
```typescript
|
|
311
|
-
import { queryBuilder } from 'qast';
|
|
312
|
-
|
|
313
|
-
const query = queryBuilder()
|
|
314
|
-
.field('age').gt(25)
|
|
315
|
-
.and(queryBuilder().field('name').eq('John'))
|
|
316
|
-
.orderBy('age', 'desc')
|
|
317
|
-
.limit(10)
|
|
318
|
-
.offset(20)
|
|
319
|
-
.build();
|
|
320
|
-
|
|
321
|
-
// query.filter - the filter AST
|
|
322
|
-
// query.orderBy - sorting specifications
|
|
323
|
-
// query.limit - limit value
|
|
324
|
-
// query.offset - offset value
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
## AST Serialization
|
|
328
|
-
|
|
329
|
-
Convert AST back to query string:
|
|
330
|
-
|
|
331
|
-
```typescript
|
|
332
|
-
import { parseQuery, serializeQuery } from 'qast';
|
|
333
|
-
|
|
334
|
-
const query = 'age gt 25 and name eq "John"';
|
|
335
|
-
const ast = parseQuery(query);
|
|
336
|
-
const serialized = serializeQuery(ast);
|
|
337
|
-
// serialized = 'age gt 25 and name eq "John"'
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
## AST Utilities
|
|
341
|
-
|
|
342
|
-
### Clone AST
|
|
343
|
-
|
|
344
|
-
```typescript
|
|
345
|
-
import { parseQuery, cloneAST } from 'qast';
|
|
346
|
-
|
|
347
|
-
const ast = parseQuery('age gt 25');
|
|
348
|
-
const cloned = cloneAST(ast);
|
|
349
|
-
```
|
|
350
|
-
|
|
351
|
-
### Merge Queries
|
|
352
|
-
|
|
353
|
-
```typescript
|
|
354
|
-
import { parseQuery, mergeQueries } from 'qast';
|
|
355
|
-
|
|
356
|
-
const query1 = parseQuery('age gt 25');
|
|
357
|
-
const query2 = parseQuery('name eq "John"');
|
|
358
|
-
const merged = mergeQueries(query1, query2, 'AND');
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
### Get Query Statistics
|
|
362
|
-
|
|
363
|
-
```typescript
|
|
364
|
-
import { parseQuery, getQueryStats } from 'qast';
|
|
365
|
-
|
|
366
|
-
const ast = parseQuery('age gt 25 and name eq "John"');
|
|
367
|
-
const stats = getQueryStats(ast);
|
|
368
|
-
// stats.depth, stats.nodeCount, stats.fields, etc.
|
|
369
|
-
```
|
|
370
|
-
|
|
371
|
-
### Optimize AST
|
|
372
|
-
|
|
373
|
-
```typescript
|
|
374
|
-
import { parseQuery, optimizeAST } from 'qast';
|
|
375
|
-
|
|
376
|
-
const ast = parseQuery('age gt 25 and age gt 25'); // Redundant
|
|
377
|
-
const optimized = optimizeAST(ast);
|
|
378
|
-
```
|
|
379
|
-
|
|
380
|
-
### Compare Queries
|
|
381
|
-
|
|
382
|
-
```typescript
|
|
383
|
-
import { parseQuery, diffQueries } from 'qast';
|
|
384
|
-
|
|
385
|
-
const query1 = parseQuery('age gt 25');
|
|
386
|
-
const query2 = parseQuery('age gt 30');
|
|
387
|
-
const diff = diffQueries(query1, query2);
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
## Performance Features
|
|
391
|
-
|
|
392
|
-
### Query Caching
|
|
393
|
-
|
|
394
|
-
```typescript
|
|
395
|
-
import { QueryCache, createCachedParser, parseQuery } from 'qast';
|
|
396
|
-
|
|
397
|
-
// Create a cached parser
|
|
398
|
-
const cachedParse = createCachedParser(parseQuery, 100, 60000); // 100 items, 60s TTL
|
|
399
|
-
|
|
400
|
-
// Use cached parser
|
|
401
|
-
const ast = cachedParse('age gt 25'); // Cached on subsequent calls
|
|
402
|
-
```
|
|
403
|
-
|
|
404
|
-
## Security Features
|
|
405
|
-
|
|
406
|
-
### Field Sanitization
|
|
407
|
-
|
|
408
|
-
```typescript
|
|
409
|
-
import { sanitizeFieldName } from 'qast';
|
|
410
|
-
|
|
411
|
-
const safeField = sanitizeFieldName('user.name'); // Removes dangerous characters
|
|
412
|
-
```
|
|
413
|
-
|
|
414
|
-
### Query Cost Estimation
|
|
415
|
-
|
|
416
|
-
```typescript
|
|
417
|
-
import { parseQuery, estimateQueryCost } from 'qast';
|
|
418
|
-
|
|
419
|
-
const ast = parseQuery('age gt 25 and name eq "John"');
|
|
420
|
-
const cost = estimateQueryCost(ast);
|
|
421
|
-
// cost.estimatedComplexity, cost.depth, cost.nodeCount, etc.
|
|
422
|
-
```
|
|
423
|
-
|
|
424
|
-
### Rate Limiting
|
|
425
|
-
|
|
426
|
-
```typescript
|
|
427
|
-
import { RateLimiter } from 'qast';
|
|
428
|
-
|
|
429
|
-
const limiter = new RateLimiter(100, 60000); // 100 requests per 60 seconds
|
|
430
|
-
|
|
431
|
-
if (limiter.isAllowed(userId)) {
|
|
432
|
-
// Process query
|
|
433
|
-
} else {
|
|
434
|
-
// Rate limit exceeded
|
|
435
|
-
}
|
|
436
|
-
```
|
|
437
|
-
|
|
438
|
-
## API Reference
|
|
439
|
-
|
|
440
|
-
### `parseQuery(query: string, options?: ParseOptions): QastNode`
|
|
441
|
-
|
|
442
|
-
Parse a query string into an AST.
|
|
443
|
-
|
|
444
|
-
**Parameters:**
|
|
445
|
-
- `query` - The query string to parse
|
|
446
|
-
- `options` - Optional parsing options:
|
|
447
|
-
- `allowedFields?: string[]` - Whitelist of allowed field names
|
|
448
|
-
- `allowedOperators?: Operator[]` - Whitelist of allowed operators
|
|
449
|
-
- `validate?: boolean` - Whether to validate against whitelists (default: true if whitelists are provided)
|
|
450
|
-
- `maxDepth?: number` - Maximum allowed AST depth (to limit nested logical expressions)
|
|
451
|
-
- `maxNodes?: number` - Maximum allowed number of AST nodes (to limit overall query complexity)
|
|
452
|
-
- `maxQueryLength?: number` - Maximum allowed length of the raw query string (checked before parsing)
|
|
453
|
-
- `maxArrayLength?: number` - Maximum allowed length of array values (for `in` operator)
|
|
454
|
-
- `maxStringLength?: number` - Maximum allowed length of string values
|
|
455
|
-
|
|
456
|
-
**Returns:** The parsed AST node
|
|
457
|
-
|
|
458
|
-
**Example:**
|
|
459
|
-
```typescript
|
|
460
|
-
const ast = parseQuery('age gt 25', {
|
|
461
|
-
allowedFields: ['age', 'name'],
|
|
462
|
-
allowedOperators: ['gt', 'eq'],
|
|
463
|
-
validate: true,
|
|
464
|
-
maxDepth: 5,
|
|
465
|
-
maxNodes: 50,
|
|
466
|
-
maxQueryLength: 1000,
|
|
467
|
-
maxArrayLength: 100,
|
|
468
|
-
maxStringLength: 200,
|
|
469
|
-
});
|
|
470
|
-
```
|
|
471
|
-
|
|
472
|
-
### `toPrismaFilter(ast: QastNode): PrismaFilter`
|
|
473
|
-
|
|
474
|
-
Transform an AST to a Prisma filter.
|
|
475
|
-
|
|
476
|
-
**Returns:** Prisma filter object with `where` property
|
|
477
|
-
|
|
478
|
-
### `toTypeORMFilter(ast: QastNode): TypeORMFilter`
|
|
479
|
-
|
|
480
|
-
Transform an AST to a TypeORM filter.
|
|
481
|
-
|
|
482
|
-
**Returns:** TypeORM filter object with `where` property
|
|
483
|
-
|
|
484
|
-
**Note:** TypeORM requires operator functions for non-equality comparisons. You may need to transform the result.
|
|
485
|
-
|
|
486
|
-
### `parseQueryFull(query: string, options?: ParseOptions): QueryAST`
|
|
487
|
-
|
|
488
|
-
Parse a query string into a full QueryAST (includes filter, sorting, pagination).
|
|
489
|
-
|
|
490
|
-
**Returns:** QueryAST with filter, orderBy, limit, and offset
|
|
491
|
-
|
|
492
|
-
### `toPrismaFilter(ast: QastNode | QueryAST): PrismaFilter`
|
|
493
|
-
|
|
494
|
-
Transform an AST or QueryAST to a Prisma filter.
|
|
495
|
-
|
|
496
|
-
**Returns:** Prisma filter object with `where`, `orderBy`, `take`, and `skip` properties
|
|
497
|
-
|
|
498
|
-
### `toTypeORMFilter(ast: QastNode | QueryAST): TypeORMFilter`
|
|
499
|
-
|
|
500
|
-
Transform an AST or QueryAST to a TypeORM filter.
|
|
501
|
-
|
|
502
|
-
**Returns:** TypeORM filter object with `where`, `order`, `take`, and `skip` properties
|
|
503
|
-
|
|
504
|
-
**Note:** TypeORM requires operator functions for non-equality comparisons. Use `transformTypeORMOperators()` helper function.
|
|
505
|
-
|
|
506
|
-
### `toSequelizeFilter(ast: QastNode | QueryAST): SequelizeFilter`
|
|
507
|
-
|
|
508
|
-
Transform an AST or QueryAST to a Sequelize filter.
|
|
509
|
-
|
|
510
|
-
**Returns:** Sequelize filter object with `where`, `order`, `limit`, and `offset` properties
|
|
511
|
-
|
|
512
|
-
**Note:** Sequelize uses the `Op` object. Use `transformSequelizeOperators()` helper function.
|
|
513
|
-
|
|
514
|
-
### `toMongooseFilter(ast: QastNode | QueryAST): MongooseFilter`
|
|
515
|
-
|
|
516
|
-
Transform an AST or QueryAST to a Mongoose filter.
|
|
517
|
-
|
|
518
|
-
**Returns:** Mongoose filter object with `filter`, `sort`, `limit`, and `skip` properties
|
|
519
|
-
|
|
520
|
-
### `toKnexFilter(ast: QastNode | QueryAST): KnexFilter`
|
|
521
|
-
|
|
522
|
-
Transform an AST or QueryAST to a Knex filter.
|
|
523
|
-
|
|
524
|
-
**Returns:** Knex filter object with `whereCallback`, `orderBy`, `limit`, and `offset` properties
|
|
525
|
-
|
|
526
|
-
### `toDrizzleFilter(ast: QastNode | QueryAST): DrizzleFilter`
|
|
527
|
-
|
|
528
|
-
Transform an AST or QueryAST to a Drizzle filter.
|
|
529
|
-
|
|
530
|
-
**Returns:** Drizzle filter object with `where`, `orderBy`, `limit`, and `offset` properties
|
|
531
|
-
|
|
532
|
-
**Note:** Use `transformDrizzleConditions()` helper function to convert to actual Drizzle conditions.
|
|
533
|
-
|
|
534
|
-
### `validateQuery(ast: QastNode, whitelist: WhitelistOptions): void`
|
|
535
|
-
|
|
536
|
-
Validate an AST against whitelists.
|
|
537
|
-
|
|
538
|
-
**Parameters:**
|
|
539
|
-
- `ast` - The AST to validate
|
|
540
|
-
- `whitelist` - Whitelist options:
|
|
541
|
-
- `allowedFields?: string[]` - Allowed field names
|
|
542
|
-
- `allowedOperators?: Operator[]` - Allowed operators
|
|
543
|
-
|
|
544
|
-
**Throws:** `ValidationError` if validation fails
|
|
545
|
-
|
|
546
|
-
### `extractFields(ast: QastNode): string[]`
|
|
547
|
-
|
|
548
|
-
Extract all field names used in an AST.
|
|
549
|
-
|
|
550
|
-
**Returns:** Array of unique field names
|
|
551
|
-
|
|
552
|
-
### `extractOperators(ast: QastNode): Operator[]`
|
|
553
|
-
|
|
554
|
-
Extract all operators used in an AST.
|
|
555
|
-
|
|
556
|
-
**Returns:** Array of unique operators
|
|
557
|
-
|
|
558
|
-
### `validateQueryComplexity(ast: QastNode, options: ComplexityOptions): void`
|
|
559
|
-
|
|
560
|
-
Validate an AST against complexity limits.
|
|
561
|
-
|
|
562
|
-
**Parameters:**
|
|
563
|
-
- `ast` - The AST to validate
|
|
564
|
-
- `options` - Complexity options:
|
|
565
|
-
- `maxDepth?: number` - Maximum allowed AST depth
|
|
566
|
-
- `maxNodes?: number` - Maximum allowed number of nodes
|
|
567
|
-
- `maxArrayLength?: number` - Maximum allowed array length
|
|
568
|
-
- `maxStringLength?: number` - Maximum allowed string length
|
|
569
|
-
|
|
570
|
-
**Throws:** `ValidationError` if any limit is exceeded
|
|
571
|
-
|
|
572
|
-
**Example:**
|
|
573
|
-
```typescript
|
|
574
|
-
import { parseQuery, validateQueryComplexity } from 'qast';
|
|
575
|
-
|
|
576
|
-
const ast = parseQuery('age in [1,2,3,4,5]');
|
|
577
|
-
validateQueryComplexity(ast, {
|
|
578
|
-
maxDepth: 5,
|
|
579
|
-
maxNodes: 20,
|
|
580
|
-
maxArrayLength: 100,
|
|
581
|
-
maxStringLength: 200,
|
|
582
|
-
});
|
|
583
|
-
```
|
|
584
|
-
|
|
585
|
-
## Security Best Practices
|
|
586
|
-
|
|
587
|
-
1. **Always use whitelists**: Restrict which fields and operators can be used in queries.
|
|
588
|
-
|
|
589
|
-
```typescript
|
|
590
|
-
const ast = parseQuery(req.query.filter, {
|
|
591
|
-
allowedFields: ['age', 'name', 'city'],
|
|
592
|
-
allowedOperators: ['gt', 'eq', 'lt'],
|
|
593
|
-
validate: true,
|
|
594
|
-
});
|
|
595
|
-
```
|
|
596
|
-
|
|
597
|
-
2. **Validate user input**: Don't trust user-provided query strings without validation.
|
|
598
|
-
|
|
599
|
-
3. **Limit query complexity**: Use complexity limits to prevent DoS attacks.
|
|
600
|
-
|
|
601
|
-
```typescript
|
|
602
|
-
const ast = parseQuery(req.query.filter, {
|
|
603
|
-
allowedFields: ['age', 'name', 'city'],
|
|
604
|
-
allowedOperators: ['gt', 'eq', 'lt', 'in'],
|
|
605
|
-
validate: true,
|
|
606
|
-
// Complexity limits
|
|
607
|
-
maxQueryLength: 1000, // Reject queries longer than 1000 chars
|
|
608
|
-
maxDepth: 5, // Max 5 levels of nesting
|
|
609
|
-
maxNodes: 20, // Max 20 conditions
|
|
610
|
-
maxArrayLength: 100, // Max 100 items in 'in' arrays
|
|
611
|
-
maxStringLength: 200, // Max 200 chars per string value
|
|
612
|
-
});
|
|
613
|
-
```
|
|
614
|
-
|
|
615
|
-
4. **Use type checking**: Ensure values match expected types for fields.
|
|
616
|
-
|
|
617
|
-
## Error Handling
|
|
618
|
-
|
|
619
|
-
QAST provides custom error classes:
|
|
620
|
-
|
|
621
|
-
- `ParseError` - Syntax errors in query strings
|
|
622
|
-
- `ValidationError` - Validation failures (disallowed fields/operators)
|
|
623
|
-
- `TokenizationError` - Tokenization errors
|
|
624
|
-
|
|
625
|
-
```typescript
|
|
626
|
-
import { parseQuery, ParseError, ValidationError } from 'qast';
|
|
627
|
-
|
|
628
|
-
try {
|
|
629
|
-
const ast = parseQuery(query, { allowedFields: ['age'], validate: true });
|
|
630
|
-
} catch (error) {
|
|
631
|
-
if (error instanceof ParseError) {
|
|
632
|
-
console.error('Parse error:', error.message);
|
|
633
|
-
} else if (error instanceof ValidationError) {
|
|
634
|
-
console.error('Validation error:', error.message);
|
|
635
|
-
console.error('Field:', error.field);
|
|
636
|
-
console.error('Operator:', error.operator);
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
```
|
|
640
|
-
|
|
641
|
-
## TypeScript Support
|
|
642
|
-
|
|
643
|
-
QAST is written in TypeScript and provides full type definitions:
|
|
644
|
-
|
|
645
|
-
```typescript
|
|
646
|
-
import { QastNode, ComparisonNode, LogicalNode, Operator } from 'qast';
|
|
647
|
-
|
|
648
|
-
function processNode(node: QastNode): void {
|
|
649
|
-
if (node.type === 'COMPARISON') {
|
|
650
|
-
const comparison = node as ComparisonNode;
|
|
651
|
-
console.log(comparison.field, comparison.op, comparison.value);
|
|
652
|
-
} else if (node.type === 'AND' || node.type === 'OR') {
|
|
653
|
-
const logical = node as LogicalNode;
|
|
654
|
-
processNode(logical.left);
|
|
655
|
-
processNode(logical.right);
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
```
|
|
659
|
-
|
|
660
|
-
## Examples
|
|
661
|
-
|
|
662
|
-
### REST API endpoint
|
|
663
|
-
|
|
664
|
-
```typescript
|
|
665
|
-
import { parseQuery, toPrismaFilter } from 'qast';
|
|
666
|
-
import { PrismaClient } from '@prisma/client';
|
|
667
|
-
|
|
668
|
-
const prisma = new PrismaClient();
|
|
669
|
-
|
|
670
|
-
app.get('/users', async (req, res) => {
|
|
671
|
-
try {
|
|
672
|
-
const query = req.query.filter as string;
|
|
673
|
-
|
|
674
|
-
// Parse and validate query
|
|
675
|
-
const ast = parseQuery(query, {
|
|
676
|
-
allowedFields: ['age', 'name', 'city', 'active'],
|
|
677
|
-
allowedOperators: ['gt', 'lt', 'eq', 'in'],
|
|
678
|
-
validate: true,
|
|
679
|
-
});
|
|
680
|
-
|
|
681
|
-
// Transform to Prisma filter
|
|
682
|
-
const filter = toPrismaFilter(ast);
|
|
683
|
-
|
|
684
|
-
// Query database
|
|
685
|
-
const users = await prisma.user.findMany(filter);
|
|
686
|
-
|
|
687
|
-
res.json(users);
|
|
688
|
-
} catch (error) {
|
|
689
|
-
if (error instanceof ValidationError) {
|
|
690
|
-
res.status(400).json({ error: error.message });
|
|
691
|
-
} else {
|
|
692
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
});
|
|
696
|
-
```
|
|
697
|
-
|
|
698
|
-
### Express middleware
|
|
699
|
-
|
|
700
|
-
```typescript
|
|
701
|
-
import { parseQuery, toPrismaFilter, ValidationError } from 'qast';
|
|
702
|
-
|
|
703
|
-
function qastMiddleware(allowedFields: string[], allowedOperators: Operator[]) {
|
|
704
|
-
return (req, res, next) => {
|
|
705
|
-
try {
|
|
706
|
-
if (req.query.filter) {
|
|
707
|
-
const ast = parseQuery(req.query.filter, {
|
|
708
|
-
allowedFields,
|
|
709
|
-
allowedOperators,
|
|
710
|
-
validate: true,
|
|
711
|
-
});
|
|
712
|
-
|
|
713
|
-
req.qastFilter = toPrismaFilter(ast);
|
|
714
|
-
}
|
|
715
|
-
next();
|
|
716
|
-
} catch (error) {
|
|
717
|
-
if (error instanceof ValidationError) {
|
|
718
|
-
res.status(400).json({ error: error.message });
|
|
719
|
-
} else {
|
|
720
|
-
next(error);
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
};
|
|
724
|
-
}
|
|
725
|
-
```
|
|
726
|
-
|
|
727
|
-
## Comparison with Alternatives
|
|
728
|
-
|
|
729
|
-
### Why QAST?
|
|
730
|
-
|
|
731
|
-
| Feature | QAST | GraphQL | OData | Custom Parsers |
|
|
732
|
-
|---------|------|---------|-------|----------------|
|
|
733
|
-
| **Type Safety** | ✅ Full TypeScript | ❌ Runtime only | ⚠️ Partial | ❌ Usually none |
|
|
734
|
-
| **Security** | ✅ Whitelist validation | ✅ Built-in | ✅ Built-in | ⚠️ Manual |
|
|
735
|
-
| **ORM Agnostic** | ✅ Yes | ❌ No | ❌ No | ⚠️ Varies |
|
|
736
|
-
| **Zero Dependencies** | ✅ Yes | ❌ No | ❌ No | ⚠️ Varies |
|
|
737
|
-
| **Learning Curve** | ✅ Simple | ❌ Complex | ❌ Complex | ⚠️ Varies |
|
|
738
|
-
| **REST API Friendly** | ✅ Yes | ❌ Requires GraphQL endpoint | ✅ Yes | ⚠️ Varies |
|
|
739
|
-
| **Bundle Size** | ✅ < 10KB | ❌ Large | ❌ Large | ⚠️ Varies |
|
|
740
|
-
|
|
741
|
-
**Use QAST when:**
|
|
742
|
-
- You want a simple, secure query language for REST APIs
|
|
743
|
-
- You need type-safe query parsing with TypeScript
|
|
744
|
-
- You're using Prisma, TypeORM, or Sequelize
|
|
745
|
-
- You want zero dependencies and a small bundle size
|
|
746
|
-
- You need field and operator whitelisting for security
|
|
747
|
-
|
|
748
|
-
**Consider alternatives when:**
|
|
749
|
-
- You need GraphQL's full query capabilities
|
|
750
|
-
- You require standardized query protocols (OData)
|
|
751
|
-
- You have complex nested data relationships
|
|
752
|
-
|
|
753
|
-
### Example Projects
|
|
754
|
-
|
|
755
|
-
See the [`examples`](./examples) directory for complete, working examples:
|
|
756
|
-
|
|
757
|
-
- [Express.js REST API](./examples/express) — Full Express.js server with Prisma
|
|
758
|
-
- [Next.js API Routes](./examples/nextjs) — Next.js API routes with QAST
|
|
759
|
-
- [NestJS Integration](./examples/nestjs) — NestJS controller with query filtering
|
|
760
|
-
- [Interactive Playground](./examples/playground.html) — Try QAST queries in your browser
|
|
761
|
-
|
|
762
|
-
## License
|
|
85
|
+
### License
|
|
763
86
|
|
|
764
87
|
MIT © 2025
|
|
765
88
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
Contributions are welcome! Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for details.
|
|
769
|
-
|
|
770
|
-
- GitHub Repository: https://github.com/hocestnonsatis/qast
|
|
771
|
-
- Issues: https://github.com/hocestnonsatis/qast/issues
|
|
772
|
-
|
|
773
|
-
## Acknowledgments
|
|
774
|
-
|
|
775
|
-
QAST is inspired by the need for safe, type-safe query parsing in REST APIs. It aims to provide a lightweight alternative to complex query protocols while maintaining security and developer experience.
|
|
89
|
+
GitHub: https://github.com/hocestnonsatis/qast
|