@uniqu/core 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/LICENSE +21 -0
- package/README.md +239 -0
- package/dist/index.cjs +78 -0
- package/dist/index.d.ts +129 -0
- package/dist/index.mjs +73 -0
- package/package.json +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 moostjs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# @uniqu/core
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="../../logo.svg" alt="uniqu" height="80">
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
Canonical query format types and transport-agnostic utilities for the Uniqu query representation.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add @uniqu/core
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Query Format
|
|
16
|
+
|
|
17
|
+
A `Uniquery` consists of a **filter** (recursive expression tree) and **controls** (pagination, projection, sorting):
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import type { Uniquery, FilterExpr } from '@uniqu/core'
|
|
21
|
+
|
|
22
|
+
const query: Uniquery = {
|
|
23
|
+
filter: {
|
|
24
|
+
age: { $gte: 18, $lte: 30 },
|
|
25
|
+
status: { $ne: 'DELETED' },
|
|
26
|
+
role: { $in: ['Admin', 'Editor'] },
|
|
27
|
+
},
|
|
28
|
+
controls: {
|
|
29
|
+
$sort: { createdAt: -1 },
|
|
30
|
+
$limit: 20,
|
|
31
|
+
$select: ['name', 'email'],
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Filter Expressions
|
|
37
|
+
|
|
38
|
+
A `FilterExpr` is either a **comparison node** (leaf) or a **logical node** (branch):
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// Comparison — one or more field conditions
|
|
42
|
+
{ age: { $gte: 18 }, status: 'active' }
|
|
43
|
+
|
|
44
|
+
// Bare primitive is implicit $eq
|
|
45
|
+
{ name: 'John' } // equivalent to { name: { $eq: 'John' } }
|
|
46
|
+
|
|
47
|
+
// Logical — $and / $or wrapping child expressions
|
|
48
|
+
{ $or: [
|
|
49
|
+
{ age: { $gt: 25 } },
|
|
50
|
+
{ status: 'VIP' },
|
|
51
|
+
]}
|
|
52
|
+
|
|
53
|
+
// Negation — $not wrapping a single child
|
|
54
|
+
{ $not: { status: 'DELETED' } }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Comparison Operators
|
|
58
|
+
|
|
59
|
+
| Operator | Description | Value Type |
|
|
60
|
+
|----------|-------------|------------|
|
|
61
|
+
| `$eq` | Equal (implicit when bare value) | `Primitive` |
|
|
62
|
+
| `$ne` | Not equal | `Primitive` |
|
|
63
|
+
| `$gt` | Greater than | `Primitive` |
|
|
64
|
+
| `$gte` | Greater than or equal | `Primitive` |
|
|
65
|
+
| `$lt` | Less than | `Primitive` |
|
|
66
|
+
| `$lte` | Less than or equal | `Primitive` |
|
|
67
|
+
| `$in` | In list | `Primitive[]` |
|
|
68
|
+
| `$nin` | Not in list | `Primitive[]` |
|
|
69
|
+
| `$regex` | Regular expression match | `RegExp \| string` |
|
|
70
|
+
| `$exists` | Field existence check | `boolean` |
|
|
71
|
+
|
|
72
|
+
`Primitive` = `string | number | boolean | null | RegExp | Date`
|
|
73
|
+
|
|
74
|
+
> **Note on `Date`:** `Date` is included for direct code usage (e.g. `{ createdAt: { $gt: new Date() } }`). The URL parser produces ISO strings, not `Date` instances. Adapters are responsible for converting `Date` to their native format (`.toISOString()` for SQL, native `Date` for MongoDB).
|
|
75
|
+
|
|
76
|
+
### Controls
|
|
77
|
+
|
|
78
|
+
| Field | Type | Description |
|
|
79
|
+
|-------|------|-------------|
|
|
80
|
+
| `$sort` | `Record<string, 1 \| -1>` | Sort fields (1 = asc, -1 = desc) |
|
|
81
|
+
| `$skip` | `number` | Skip N results |
|
|
82
|
+
| `$limit` | `number` | Limit to N results |
|
|
83
|
+
| `$count` | `boolean` | Request total count |
|
|
84
|
+
| `$select` | `string[] \| Record<string, 0 \| 1>` | Field projection — array for inclusion, object for exclusion/mixed |
|
|
85
|
+
| `$<custom>` | `unknown` | Arbitrary pass-through keywords |
|
|
86
|
+
|
|
87
|
+
## Type-Safe Filters
|
|
88
|
+
|
|
89
|
+
`FilterExpr<T>` accepts a generic entity type for compile-time field and value checking. Dot-notation paths are always allowed for nested access:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
interface User {
|
|
93
|
+
name: string
|
|
94
|
+
age: number
|
|
95
|
+
active: boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const filter: FilterExpr<User> = {
|
|
99
|
+
name: 'John', // string — ok
|
|
100
|
+
age: { $gte: 18 }, // number — ok
|
|
101
|
+
active: true, // boolean — ok
|
|
102
|
+
'address.city': 'NYC', // dot-notation — always allowed
|
|
103
|
+
// age: { $gte: 'old' }, // type error: string not assignable to number
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Without a generic argument, `FilterExpr` accepts any string keys with any values (untyped mode).
|
|
108
|
+
|
|
109
|
+
### Type-Safe Controls
|
|
110
|
+
|
|
111
|
+
`UniqueryControls<T>` constrains `$select` and `$sort` field names when a type parameter is provided:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
const query: Uniquery<User> = {
|
|
115
|
+
filter: { name: 'John' },
|
|
116
|
+
controls: {
|
|
117
|
+
$select: ['name', 'email'], // ✅ autocomplete, catches typos
|
|
118
|
+
$sort: { name: 1 }, // ✅ only known fields
|
|
119
|
+
// $select: ['foo'], // type error: 'foo' is not keyof User
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Tree Walker
|
|
125
|
+
|
|
126
|
+
`walkFilter` traverses a filter tree and calls a visitor at each node. The generic return type `R` is controlled by the visitor — `string` for SQL rendering, `boolean` for validation, `void` for side-effect traversals:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
import { walkFilter, type FilterVisitor } from '@uniqu/core'
|
|
130
|
+
|
|
131
|
+
// Example: render to a SQL WHERE clause
|
|
132
|
+
const sqlVisitor: FilterVisitor<string> = {
|
|
133
|
+
comparison(field, op, value) {
|
|
134
|
+
const ops: Record<string, string> = {
|
|
135
|
+
$eq: '=', $ne: '!=', $gt: '>', $gte: '>=', $lt: '<', $lte: '<=',
|
|
136
|
+
}
|
|
137
|
+
if (ops[op]) return `${field} ${ops[op]} ${JSON.stringify(value)}`
|
|
138
|
+
if (op === '$in') return `${field} IN (${(value as unknown[]).map(v => JSON.stringify(v)).join(', ')})`
|
|
139
|
+
if (op === '$regex') return `${field} ~ ${value}`
|
|
140
|
+
if (op === '$exists') return value ? `${field} IS NOT NULL` : `${field} IS NULL`
|
|
141
|
+
return `${field} ${op} ${JSON.stringify(value)}`
|
|
142
|
+
},
|
|
143
|
+
and: (children) => children.join(' AND '),
|
|
144
|
+
or: (children) => `(${children.join(' OR ')})`,
|
|
145
|
+
not: (child) => `NOT (${child})`,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const where = walkFilter(query.filter, sqlVisitor)
|
|
149
|
+
// "age >= 18 AND age <= 30 AND status != \"DELETED\" AND role IN (\"Admin\", \"Editor\")"
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Visitor Interface
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
interface FilterVisitor<R> {
|
|
156
|
+
/** Called for each field comparison (bare values normalized to $eq). */
|
|
157
|
+
comparison(field: string, op: ComparisonOp, value: Primitive | Primitive[]): R
|
|
158
|
+
|
|
159
|
+
/** Combine children with AND logic. */
|
|
160
|
+
and(children: R[]): R
|
|
161
|
+
|
|
162
|
+
/** Combine children with OR logic. */
|
|
163
|
+
or(children: R[]): R
|
|
164
|
+
|
|
165
|
+
/** Negate a child expression. */
|
|
166
|
+
not(child: R): R
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Walker Behavior
|
|
171
|
+
|
|
172
|
+
- Bare primitive values (`{ name: 'John' }`) are normalized to `comparison(field, '$eq', value)` calls
|
|
173
|
+
- Multi-field comparison nodes (`{ age: ..., status: ... }`) are expanded into individual `comparison` calls wrapped in `visitor.and(...)`
|
|
174
|
+
- `$and` / `$or` nodes recurse into children and call the corresponding visitor method
|
|
175
|
+
- `$not` nodes recurse into the single child and call `visitor.not(...)`
|
|
176
|
+
- Empty nodes call `visitor.and([])`
|
|
177
|
+
|
|
178
|
+
## Lazy Insights
|
|
179
|
+
|
|
180
|
+
`computeInsights` walks an already-built query to produce a map of field names to the set of operators used on each field. This is the lazy counterpart to the eager insights computed during URL parsing:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import { computeInsights } from '@uniqu/core'
|
|
184
|
+
|
|
185
|
+
const insights = computeInsights(query.filter, query.controls)
|
|
186
|
+
// Map {
|
|
187
|
+
// 'age' => Set { '$gte', '$lte' },
|
|
188
|
+
// 'status' => Set { '$ne' },
|
|
189
|
+
// 'role' => Set { '$in' },
|
|
190
|
+
// 'createdAt' => Set { '$order' },
|
|
191
|
+
// 'name' => Set { '$select' },
|
|
192
|
+
// 'email' => Set { '$select' },
|
|
193
|
+
// }
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Use cases: field whitelisting, operator auditing, index planning.
|
|
197
|
+
|
|
198
|
+
### `getInsights`
|
|
199
|
+
|
|
200
|
+
`getInsights` returns pre-computed insights when present on the query, or computes them lazily:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import { getInsights } from '@uniqu/core'
|
|
204
|
+
|
|
205
|
+
const insights = getInsights(query)
|
|
206
|
+
// Uses query.insights if present (e.g. from parseUrl), otherwise calls computeInsights
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## API Reference
|
|
210
|
+
|
|
211
|
+
### Types
|
|
212
|
+
|
|
213
|
+
| Export | Description |
|
|
214
|
+
|--------|-------------|
|
|
215
|
+
| `Primitive` | `string \| number \| boolean \| null \| RegExp \| Date` |
|
|
216
|
+
| `ComparisonOp` | Union of all `$`-prefixed operator names |
|
|
217
|
+
| `FieldOpsFor<V>` | Per-field typed operator map |
|
|
218
|
+
| `FieldOps` | Untyped operator map (`FieldOpsFor<Primitive>`) |
|
|
219
|
+
| `FieldValue` | `Primitive \| FieldOps` |
|
|
220
|
+
| `FilterExpr<T>` | `ComparisonNode<T> \| LogicalNode<T>` |
|
|
221
|
+
| `ComparisonNode<T>` | Leaf node with typed field comparisons |
|
|
222
|
+
| `LogicalNode<T>` | `{ $and: ... } \| { $or: ... } \| { $not: ... }` — variants are mutually exclusive via `never` |
|
|
223
|
+
| `UniqueryControls<T>` | Pagination, sorting, projection — `$select`/`$sort` constrained to `keyof T` when typed |
|
|
224
|
+
| `Uniquery<T>` | `{ filter: FilterExpr<T>, controls: UniqueryControls<T>, insights?: UniqueryInsights }` |
|
|
225
|
+
| `InsightOp` | `ComparisonOp \| '$select' \| '$order'` |
|
|
226
|
+
| `UniqueryInsights` | `Map<string, Set<InsightOp>>` |
|
|
227
|
+
|
|
228
|
+
### Functions
|
|
229
|
+
|
|
230
|
+
| Export | Signature | Description |
|
|
231
|
+
|--------|-----------|-------------|
|
|
232
|
+
| `walkFilter` | `<R>(expr: FilterExpr, visitor: FilterVisitor<R>) => R` | Traverse filter tree with visitor callbacks |
|
|
233
|
+
| `computeInsights` | `(filter: FilterExpr, controls?: UniqueryControls) => UniqueryInsights` | Lazily compute field/operator usage map |
|
|
234
|
+
| `getInsights` | `(query: Uniquery) => UniqueryInsights` | Return pre-computed or lazily computed insights |
|
|
235
|
+
| `isPrimitive` | `(x: unknown) => x is Primitive` | Type guard for primitive values |
|
|
236
|
+
|
|
237
|
+
## License
|
|
238
|
+
|
|
239
|
+
[MIT](../../LICENSE)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
|
|
3
|
+
//#region packages/core/src/walk.ts
|
|
4
|
+
/**
|
|
5
|
+
* Walk a filter expression tree, calling visitor callbacks at each node.
|
|
6
|
+
* Returns the fully assembled result from the visitor.
|
|
7
|
+
*
|
|
8
|
+
* - Bare primitive values are normalized to `comparison(field, '$eq', value)`.
|
|
9
|
+
* - Multi-field ComparisonNodes are combined via `visitor.and(...)`.
|
|
10
|
+
*/ function walkFilter(expr, visitor) {
|
|
11
|
+
if ("$and" in expr && expr.$and !== void 0) {
|
|
12
|
+
const children = expr.$and.map((child) => walkFilter(child, visitor));
|
|
13
|
+
return visitor.and(children);
|
|
14
|
+
}
|
|
15
|
+
if ("$or" in expr && expr.$or !== void 0) {
|
|
16
|
+
const children = expr.$or.map((child) => walkFilter(child, visitor));
|
|
17
|
+
return visitor.or(children);
|
|
18
|
+
}
|
|
19
|
+
if ("$not" in expr && expr.$not !== void 0) {
|
|
20
|
+
const child = walkFilter(expr.$not, visitor);
|
|
21
|
+
return visitor.not(child);
|
|
22
|
+
}
|
|
23
|
+
const node = expr;
|
|
24
|
+
const entries = Object.entries(node);
|
|
25
|
+
if (entries.length === 0) return visitor.and([]);
|
|
26
|
+
const results = [];
|
|
27
|
+
for (const [field, value] of entries) if (isPrimitive(value)) results.push(visitor.comparison(field, "$eq", value));
|
|
28
|
+
else {
|
|
29
|
+
const ops = value;
|
|
30
|
+
for (const [op, opValue] of Object.entries(ops)) results.push(visitor.comparison(field, op, opValue));
|
|
31
|
+
}
|
|
32
|
+
return results.length === 1 ? results[0] : visitor.and(results);
|
|
33
|
+
}
|
|
34
|
+
function isPrimitive(x) {
|
|
35
|
+
return x === null || typeof x === "string" || typeof x === "number" || typeof x === "boolean" || x instanceof RegExp || x instanceof Date;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region packages/core/src/insights.ts
|
|
40
|
+
/**
|
|
41
|
+
* Compute insights (field → operators map) from an already-built query.
|
|
42
|
+
* This is the lazy counterpart to the eager insight capture done during
|
|
43
|
+
* URL parsing.
|
|
44
|
+
*/ function computeInsights(filter, controls) {
|
|
45
|
+
const insights = /* @__PURE__ */ new Map();
|
|
46
|
+
function capture(field, op) {
|
|
47
|
+
let set = insights.get(field);
|
|
48
|
+
if (!set) {
|
|
49
|
+
set = /* @__PURE__ */ new Set();
|
|
50
|
+
insights.set(field, set);
|
|
51
|
+
}
|
|
52
|
+
set.add(op);
|
|
53
|
+
}
|
|
54
|
+
walkFilter(filter, {
|
|
55
|
+
comparison(field, op) {
|
|
56
|
+
capture(field, op);
|
|
57
|
+
},
|
|
58
|
+
and() {},
|
|
59
|
+
or() {},
|
|
60
|
+
not() {}
|
|
61
|
+
});
|
|
62
|
+
if (controls?.$select) if (Array.isArray(controls.$select)) for (const field of controls.$select) capture(field, "$select");
|
|
63
|
+
else for (const field of Object.keys(controls.$select)) capture(field, "$select");
|
|
64
|
+
if (controls?.$sort) for (const field of Object.keys(controls.$sort)) capture(field, "$order");
|
|
65
|
+
return insights;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Return insights for a query — uses pre-computed insights when present,
|
|
69
|
+
* computes lazily otherwise.
|
|
70
|
+
*/ function getInsights(query) {
|
|
71
|
+
return query.insights ?? computeInsights(query.filter, query.controls);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
//#endregion
|
|
75
|
+
exports.computeInsights = computeInsights;
|
|
76
|
+
exports.getInsights = getInsights;
|
|
77
|
+
exports.isPrimitive = isPrimitive;
|
|
78
|
+
exports.walkFilter = walkFilter;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scalar value types supported in filter expressions.
|
|
3
|
+
*
|
|
4
|
+
* `Date` is included for direct code usage (e.g. `{ createdAt: { $gt: new Date() } }`).
|
|
5
|
+
* The URL parser produces ISO strings, not `Date` instances.
|
|
6
|
+
* Adapters are responsible for handling both: convert `Date` to their native format
|
|
7
|
+
* (e.g. `.toISOString()` for SQL params, native `Date` for MongoDB).
|
|
8
|
+
*/
|
|
9
|
+
type Primitive = string | number | boolean | null | RegExp | Date;
|
|
10
|
+
/** All comparison operators supported by the filter format. */
|
|
11
|
+
type ComparisonOp = '$eq' | '$ne' | '$gt' | '$gte' | '$lt' | '$lte' | '$in' | '$nin' | '$regex' | '$exists';
|
|
12
|
+
/**
|
|
13
|
+
* Per-field typed operator map. When `V` is the field's value type, operators
|
|
14
|
+
* are constrained accordingly:
|
|
15
|
+
* - `$regex` is only available when `V` extends `string`
|
|
16
|
+
* - `$gt/$gte/$lt/$lte` are only available when `V` extends `number | string | Date`
|
|
17
|
+
*/
|
|
18
|
+
type FieldOpsFor<V> = {
|
|
19
|
+
$eq?: V;
|
|
20
|
+
$ne?: V;
|
|
21
|
+
$in?: V[];
|
|
22
|
+
$nin?: V[];
|
|
23
|
+
$exists?: boolean;
|
|
24
|
+
} & (V extends string ? {
|
|
25
|
+
$regex?: RegExp | string;
|
|
26
|
+
} : {}) & (V extends number | string | Date ? {
|
|
27
|
+
$gt?: V;
|
|
28
|
+
$gte?: V;
|
|
29
|
+
$lt?: V;
|
|
30
|
+
$lte?: V;
|
|
31
|
+
} : {});
|
|
32
|
+
/** Untyped operator map. */
|
|
33
|
+
type FieldOps = FieldOpsFor<Primitive>;
|
|
34
|
+
/** A field can hold a bare primitive (implicit $eq) or an explicit operator map. */
|
|
35
|
+
type FieldValue = Primitive | FieldOps;
|
|
36
|
+
/**
|
|
37
|
+
* A filter expression is either a comparison leaf or a logical branch.
|
|
38
|
+
* `T` is the entity shape — provides type-safe field names and value types.
|
|
39
|
+
* Defaults to `Record<string, unknown>` (untyped).
|
|
40
|
+
*/
|
|
41
|
+
type FilterExpr<T = Record<string, unknown>> = ComparisonNode<T> | LogicalNode<T>;
|
|
42
|
+
/**
|
|
43
|
+
* Leaf node: one or more field comparisons.
|
|
44
|
+
* Known keys from `T` get typed values; arbitrary string keys
|
|
45
|
+
* (e.g. dot-notation paths like `"client.age"`) are always allowed.
|
|
46
|
+
*/
|
|
47
|
+
type ComparisonNode<T = Record<string, unknown>> = {
|
|
48
|
+
[K in keyof T & string]?: T[K] | FieldOpsFor<T[K]>;
|
|
49
|
+
} & Record<string, unknown>;
|
|
50
|
+
/**
|
|
51
|
+
* Branch node: logical combination of child expressions.
|
|
52
|
+
* Each variant forbids the other logical keys via `never` to prevent
|
|
53
|
+
* mixing comparison fields with logical operators at the type level.
|
|
54
|
+
*/
|
|
55
|
+
type LogicalNode<T = Record<string, unknown>> = {
|
|
56
|
+
$and: FilterExpr<T>[];
|
|
57
|
+
$or?: never;
|
|
58
|
+
$not?: never;
|
|
59
|
+
} | {
|
|
60
|
+
$or: FilterExpr<T>[];
|
|
61
|
+
$and?: never;
|
|
62
|
+
$not?: never;
|
|
63
|
+
} | {
|
|
64
|
+
$not: FilterExpr<T>;
|
|
65
|
+
$and?: never;
|
|
66
|
+
$or?: never;
|
|
67
|
+
};
|
|
68
|
+
/** Query controls (pagination, projection, sorting). Generic `T` constrains field names in `$select` and `$sort`. */
|
|
69
|
+
interface UniqueryControls<T = Record<string, unknown>> {
|
|
70
|
+
$sort?: Partial<Record<keyof T & string, 1 | -1>>;
|
|
71
|
+
$skip?: number;
|
|
72
|
+
$limit?: number;
|
|
73
|
+
$count?: boolean;
|
|
74
|
+
$select?: (keyof T & string)[] | Partial<Record<keyof T & string, 0 | 1>>;
|
|
75
|
+
/** Pass-through for unknown $-prefixed keywords. */
|
|
76
|
+
[key: `$${string}`]: unknown;
|
|
77
|
+
}
|
|
78
|
+
/** Top-level query: filter tree + controls. */
|
|
79
|
+
interface Uniquery<T = Record<string, unknown>> {
|
|
80
|
+
filter: FilterExpr<T>;
|
|
81
|
+
controls: UniqueryControls<T>;
|
|
82
|
+
/** Pre-computed insights. When present, consumers should use `getInsights()` which trusts these instead of recomputing. */
|
|
83
|
+
insights?: UniqueryInsights;
|
|
84
|
+
}
|
|
85
|
+
/** Insight operator includes comparison ops plus control-derived ops. */
|
|
86
|
+
type InsightOp = ComparisonOp | '$select' | '$order';
|
|
87
|
+
/** Map of field names to the set of operators used on that field. */
|
|
88
|
+
type UniqueryInsights = Map<string, Set<InsightOp>>;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Visitor callbacks for controlling how filter nodes are processed.
|
|
92
|
+
* Generic parameter `R` is the return type — `string` for SQL rendering,
|
|
93
|
+
* `boolean` for validation, `FilterExpr` for AST transforms, `void` for
|
|
94
|
+
* side-effect-only traversals (e.g. insight collection).
|
|
95
|
+
*/
|
|
96
|
+
interface FilterVisitor<R> {
|
|
97
|
+
/** Called for each field comparison. */
|
|
98
|
+
comparison(field: string, op: ComparisonOp, value: Primitive | Primitive[]): R;
|
|
99
|
+
/** Combine children with AND logic. */
|
|
100
|
+
and(children: R[]): R;
|
|
101
|
+
/** Combine children with OR logic. */
|
|
102
|
+
or(children: R[]): R;
|
|
103
|
+
/** Negate a child expression. */
|
|
104
|
+
not(child: R): R;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Walk a filter expression tree, calling visitor callbacks at each node.
|
|
108
|
+
* Returns the fully assembled result from the visitor.
|
|
109
|
+
*
|
|
110
|
+
* - Bare primitive values are normalized to `comparison(field, '$eq', value)`.
|
|
111
|
+
* - Multi-field ComparisonNodes are combined via `visitor.and(...)`.
|
|
112
|
+
*/
|
|
113
|
+
declare function walkFilter<R>(expr: FilterExpr, visitor: FilterVisitor<R>): R;
|
|
114
|
+
declare function isPrimitive(x: unknown): x is Primitive;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Compute insights (field → operators map) from an already-built query.
|
|
118
|
+
* This is the lazy counterpart to the eager insight capture done during
|
|
119
|
+
* URL parsing.
|
|
120
|
+
*/
|
|
121
|
+
declare function computeInsights(filter: FilterExpr, controls?: UniqueryControls): UniqueryInsights;
|
|
122
|
+
/**
|
|
123
|
+
* Return insights for a query — uses pre-computed insights when present,
|
|
124
|
+
* computes lazily otherwise.
|
|
125
|
+
*/
|
|
126
|
+
declare function getInsights(query: Uniquery): UniqueryInsights;
|
|
127
|
+
|
|
128
|
+
export { computeInsights, getInsights, isPrimitive, walkFilter };
|
|
129
|
+
export type { ComparisonNode, ComparisonOp, FieldOps, FieldOpsFor, FieldValue, FilterExpr, FilterVisitor, InsightOp, LogicalNode, Primitive, Uniquery, UniqueryControls, UniqueryInsights };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//#region packages/core/src/walk.ts
|
|
2
|
+
/**
|
|
3
|
+
* Walk a filter expression tree, calling visitor callbacks at each node.
|
|
4
|
+
* Returns the fully assembled result from the visitor.
|
|
5
|
+
*
|
|
6
|
+
* - Bare primitive values are normalized to `comparison(field, '$eq', value)`.
|
|
7
|
+
* - Multi-field ComparisonNodes are combined via `visitor.and(...)`.
|
|
8
|
+
*/ function walkFilter(expr, visitor) {
|
|
9
|
+
if ("$and" in expr && expr.$and !== void 0) {
|
|
10
|
+
const children = expr.$and.map((child) => walkFilter(child, visitor));
|
|
11
|
+
return visitor.and(children);
|
|
12
|
+
}
|
|
13
|
+
if ("$or" in expr && expr.$or !== void 0) {
|
|
14
|
+
const children = expr.$or.map((child) => walkFilter(child, visitor));
|
|
15
|
+
return visitor.or(children);
|
|
16
|
+
}
|
|
17
|
+
if ("$not" in expr && expr.$not !== void 0) {
|
|
18
|
+
const child = walkFilter(expr.$not, visitor);
|
|
19
|
+
return visitor.not(child);
|
|
20
|
+
}
|
|
21
|
+
const node = expr;
|
|
22
|
+
const entries = Object.entries(node);
|
|
23
|
+
if (entries.length === 0) return visitor.and([]);
|
|
24
|
+
const results = [];
|
|
25
|
+
for (const [field, value] of entries) if (isPrimitive(value)) results.push(visitor.comparison(field, "$eq", value));
|
|
26
|
+
else {
|
|
27
|
+
const ops = value;
|
|
28
|
+
for (const [op, opValue] of Object.entries(ops)) results.push(visitor.comparison(field, op, opValue));
|
|
29
|
+
}
|
|
30
|
+
return results.length === 1 ? results[0] : visitor.and(results);
|
|
31
|
+
}
|
|
32
|
+
function isPrimitive(x) {
|
|
33
|
+
return x === null || typeof x === "string" || typeof x === "number" || typeof x === "boolean" || x instanceof RegExp || x instanceof Date;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region packages/core/src/insights.ts
|
|
38
|
+
/**
|
|
39
|
+
* Compute insights (field → operators map) from an already-built query.
|
|
40
|
+
* This is the lazy counterpart to the eager insight capture done during
|
|
41
|
+
* URL parsing.
|
|
42
|
+
*/ function computeInsights(filter, controls) {
|
|
43
|
+
const insights = /* @__PURE__ */ new Map();
|
|
44
|
+
function capture(field, op) {
|
|
45
|
+
let set = insights.get(field);
|
|
46
|
+
if (!set) {
|
|
47
|
+
set = /* @__PURE__ */ new Set();
|
|
48
|
+
insights.set(field, set);
|
|
49
|
+
}
|
|
50
|
+
set.add(op);
|
|
51
|
+
}
|
|
52
|
+
walkFilter(filter, {
|
|
53
|
+
comparison(field, op) {
|
|
54
|
+
capture(field, op);
|
|
55
|
+
},
|
|
56
|
+
and() {},
|
|
57
|
+
or() {},
|
|
58
|
+
not() {}
|
|
59
|
+
});
|
|
60
|
+
if (controls?.$select) if (Array.isArray(controls.$select)) for (const field of controls.$select) capture(field, "$select");
|
|
61
|
+
else for (const field of Object.keys(controls.$select)) capture(field, "$select");
|
|
62
|
+
if (controls?.$sort) for (const field of Object.keys(controls.$sort)) capture(field, "$order");
|
|
63
|
+
return insights;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Return insights for a query — uses pre-computed insights when present,
|
|
67
|
+
* computes lazily otherwise.
|
|
68
|
+
*/ function getInsights(query) {
|
|
69
|
+
return query.insights ?? computeInsights(query.filter, query.controls);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
//#endregion
|
|
73
|
+
export { computeInsights, getInsights, isPrimitive, walkFilter };
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uniqu/core",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Canonical query format types, tree walker, and utilities for Uniqu",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Artem Maltsev",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/moostjs/uniqu.git",
|
|
10
|
+
"directory": "packages/core"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/moostjs/uniqu/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/moostjs/uniqu/tree/main/packages/core#readme",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "dist/index.mjs",
|
|
18
|
+
"types": "dist/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.mjs",
|
|
23
|
+
"require": "./dist/index.cjs"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"pub": "pnpm publish --access public",
|
|
32
|
+
"test": "vitest"
|
|
33
|
+
}
|
|
34
|
+
}
|