@uniqu/core 0.0.3 → 0.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 +44 -18
- package/dist/index.cjs +14 -4
- package/dist/index.d.ts +48 -18
- package/dist/index.mjs +14 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ A `FilterExpr` is either a **comparison node** (leaf) or a **logical node** (bra
|
|
|
87
87
|
|
|
88
88
|
### Relation Loading (`$with`)
|
|
89
89
|
|
|
90
|
-
`$with` declares which relations to populate alongside the primary query. Each
|
|
90
|
+
`$with` declares which relations to populate alongside the primary query. Each relation is a full `Uniquery` sub-query (a `WithRelation`) with its own `name`, `filter`, `controls`, and `insights`:
|
|
91
91
|
|
|
92
92
|
```ts
|
|
93
93
|
import type { Uniquery, WithRelation } from '@uniqu/core'
|
|
@@ -99,30 +99,29 @@ const query: Uniquery = {
|
|
|
99
99
|
{
|
|
100
100
|
name: 'posts',
|
|
101
101
|
filter: { status: 'published' },
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
102
|
+
controls: {
|
|
103
|
+
$sort: { createdAt: -1 },
|
|
104
|
+
$limit: 5,
|
|
105
|
+
$select: ['title', 'body'],
|
|
106
|
+
$with: [
|
|
107
|
+
{ name: 'comments', filter: {}, controls: { $limit: 10 } },
|
|
108
|
+
{ name: 'author', filter: {}, controls: {} },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
109
111
|
},
|
|
110
|
-
{ name: 'profile' },
|
|
112
|
+
{ name: 'profile', filter: {}, controls: {} },
|
|
111
113
|
],
|
|
112
114
|
},
|
|
113
115
|
}
|
|
114
116
|
```
|
|
115
117
|
|
|
116
|
-
`WithRelation`
|
|
118
|
+
`WithRelation` is a `Uniquery` with a required `name`:
|
|
117
119
|
|
|
118
120
|
```ts
|
|
119
|
-
|
|
120
|
-
name: string
|
|
121
|
-
filter?: FilterExpr
|
|
122
|
-
}
|
|
121
|
+
type WithRelation = Uniquery & { name: string }
|
|
123
122
|
```
|
|
124
123
|
|
|
125
|
-
This means
|
|
124
|
+
The `Uniquery` type itself has an optional `name` — when present it is a nested relation, when absent it is the root query. This means every `$with` entry is a self-contained query with its own `filter`, `controls` (including `$sort`, `$skip`, `$limit`, `$select`, nested `$with`, and pass-through keywords), and optional `insights`. The structure is recursive to any depth.
|
|
126
125
|
|
|
127
126
|
Uniqu is a query parser, not an ORM. It records what was requested — the consumer (e.g. a database adapter) decides how to execute it (JOINs, subqueries, separate queries), validates relation names against its schema, and enforces depth/security limits.
|
|
128
127
|
|
|
@@ -236,7 +235,34 @@ const insights = computeInsights(query.filter, query.controls)
|
|
|
236
235
|
// }
|
|
237
236
|
```
|
|
238
237
|
|
|
239
|
-
Relation names from `$with` are captured with the `$with` insight operator
|
|
238
|
+
Relation names from `$with` are captured with the `$with` insight operator. Nested `$with` insights bubble up to the parent with dot-notation prefixed field names, and each relation also carries its own scoped `insights`:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
const controls: UniqueryControls = {
|
|
242
|
+
$with: [
|
|
243
|
+
{
|
|
244
|
+
name: 'tasks',
|
|
245
|
+
filter: {},
|
|
246
|
+
controls: {
|
|
247
|
+
$with: [
|
|
248
|
+
{ name: 'comments', filter: { body: { $regex: 'Great' } }, controls: {} },
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const insights = computeInsights({}, controls)
|
|
256
|
+
// Map {
|
|
257
|
+
// 'tasks' => Set { '$with' },
|
|
258
|
+
// 'tasks.comments' => Set { '$with' },
|
|
259
|
+
// 'tasks.comments.body' => Set { '$regex' },
|
|
260
|
+
// }
|
|
261
|
+
|
|
262
|
+
// Each relation also has its own scoped insights:
|
|
263
|
+
// tasks.insights => Map { 'comments' => Set { '$with' }, 'comments.body' => Set { '$regex' } }
|
|
264
|
+
// comments.insights => Map { 'body' => Set { '$regex' } }
|
|
265
|
+
```
|
|
240
266
|
|
|
241
267
|
Use cases: field whitelisting, operator auditing, index planning, relation validation.
|
|
242
268
|
|
|
@@ -266,8 +292,8 @@ const insights = getInsights(query)
|
|
|
266
292
|
| `ComparisonNode<T>` | Leaf node with typed field comparisons |
|
|
267
293
|
| `LogicalNode<T>` | `{ $and: ... } \| { $or: ... } \| { $not: ... }` — variants are mutually exclusive via `never` |
|
|
268
294
|
| `UniqueryControls<T>` | Pagination, sorting, projection — `$select`/`$sort` constrained to `keyof T` when typed |
|
|
269
|
-
| `Uniquery<T>` | `{ filter
|
|
270
|
-
| `WithRelation` |
|
|
295
|
+
| `Uniquery<T>` | `{ name?, filter, controls, insights? }` — root query (no name) or nested relation (with name) |
|
|
296
|
+
| `WithRelation` | `Uniquery & { name: string }` — a `$with` relation with a required name |
|
|
271
297
|
| `InsightOp` | `ComparisonOp \| '$select' \| '$order' \| '$with'` |
|
|
272
298
|
| `UniqueryInsights` | `Map<string, Set<InsightOp>>` |
|
|
273
299
|
|
package/dist/index.cjs
CHANGED
|
@@ -8,6 +8,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
|
8
8
|
* - Bare primitive values are normalized to `comparison(field, '$eq', value)`.
|
|
9
9
|
* - Multi-field ComparisonNodes are combined via `visitor.and(...)`.
|
|
10
10
|
*/ function walkFilter(expr, visitor) {
|
|
11
|
+
if (!expr) return void 0;
|
|
11
12
|
if ("$and" in expr && expr.$and !== void 0) {
|
|
12
13
|
const children = expr.$and.map((child) => walkFilter(child, visitor));
|
|
13
14
|
return visitor.and(children);
|
|
@@ -54,25 +55,34 @@ function isPrimitive(x) {
|
|
|
54
55
|
}
|
|
55
56
|
set.add(op);
|
|
56
57
|
}
|
|
57
|
-
|
|
58
|
+
const visitor = {
|
|
58
59
|
comparison(field, op) {
|
|
59
60
|
capture(field, op);
|
|
60
61
|
},
|
|
61
62
|
and() {},
|
|
62
63
|
or() {},
|
|
63
64
|
not() {}
|
|
64
|
-
}
|
|
65
|
+
};
|
|
66
|
+
if (filter) walkFilter(filter, visitor);
|
|
65
67
|
if (controls?.$select) if (Array.isArray(controls.$select)) for (const field of controls.$select) capture(field, "$select");
|
|
66
68
|
else for (const field of Object.keys(controls.$select)) capture(field, "$select");
|
|
67
69
|
if (controls?.$sort) for (const field of Object.keys(controls.$sort)) capture(field, "$order");
|
|
68
|
-
if (controls?.$with) for (const rel of controls.$with)
|
|
70
|
+
if (controls?.$with) for (const rel of controls.$with) {
|
|
71
|
+
capture(rel.name, "$with");
|
|
72
|
+
const nested = rel.insights ?? computeInsights(rel.filter, rel.controls);
|
|
73
|
+
if (nested.size) rel.insights = nested;
|
|
74
|
+
for (const [field, ops] of nested) {
|
|
75
|
+
const prefixed = `${rel.name}.${field}`;
|
|
76
|
+
for (const op of ops) capture(prefixed, op);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
69
79
|
return insights;
|
|
70
80
|
}
|
|
71
81
|
/**
|
|
72
82
|
* Return insights for a query — uses pre-computed insights when present,
|
|
73
83
|
* computes lazily otherwise.
|
|
74
84
|
*/ function getInsights(query) {
|
|
75
|
-
return query.insights ?? computeInsights(query.filter, query.controls);
|
|
85
|
+
return query.insights ?? computeInsights(query.filter ?? {}, query.controls);
|
|
76
86
|
}
|
|
77
87
|
|
|
78
88
|
//#endregion
|
package/dist/index.d.ts
CHANGED
|
@@ -65,32 +65,62 @@ type LogicalNode<T = Record<string, unknown>> = {
|
|
|
65
65
|
$and?: never;
|
|
66
66
|
$or?: never;
|
|
67
67
|
};
|
|
68
|
-
/** A relation to populate alongside the primary query. */
|
|
69
|
-
interface WithRelation extends Omit<UniqueryControls, '$count'> {
|
|
70
|
-
/** Relation name (e.g. "posts", "posts.author"). */
|
|
71
|
-
name: string;
|
|
72
|
-
/** Filter scoped to this relation. */
|
|
73
|
-
filter?: FilterExpr;
|
|
74
|
-
}
|
|
75
68
|
/** Query controls (pagination, projection, sorting). Generic `T` constrains field names in `$select` and `$sort`. */
|
|
76
|
-
interface UniqueryControls<T = Record<string, unknown>> {
|
|
69
|
+
interface UniqueryControls<T = Record<string, unknown>, Nav extends Record<string, unknown> = Record<string, unknown>> {
|
|
77
70
|
$sort?: Partial<Record<keyof T & string, 1 | -1>>;
|
|
78
71
|
$skip?: number;
|
|
79
72
|
$limit?: number;
|
|
80
73
|
$count?: boolean;
|
|
81
74
|
$select?: (keyof T & string)[] | Partial<Record<keyof T & string, 0 | 1>>;
|
|
82
|
-
/** Relations to populate alongside the
|
|
83
|
-
$with?:
|
|
75
|
+
/** Relations to populate alongside the query. */
|
|
76
|
+
$with?: TypedWithRelation<Nav>[];
|
|
84
77
|
/** Pass-through for unknown $-prefixed keywords. */
|
|
85
78
|
[key: `$${string}`]: unknown;
|
|
86
79
|
}
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Canonical query representation.
|
|
82
|
+
* When `name` is present this is a nested relation (sub-query inside `$with`).
|
|
83
|
+
* When absent it is the root query.
|
|
84
|
+
*/
|
|
85
|
+
interface Uniquery<T = Record<string, unknown>, Nav extends Record<string, unknown> = Record<string, unknown>> {
|
|
86
|
+
/** Relation name. Present only for nested `$with` sub-queries. */
|
|
87
|
+
name?: string;
|
|
88
|
+
filter?: FilterExpr<T>;
|
|
89
|
+
controls?: UniqueryControls<T, Nav>;
|
|
90
|
+
/** Pre-computed insights. */
|
|
92
91
|
insights?: UniqueryInsights;
|
|
93
92
|
}
|
|
93
|
+
/** Unwrap array types to get the element type for nav props. */
|
|
94
|
+
type NavTarget<T> = T extends Array<infer U> ? U : T;
|
|
95
|
+
/**
|
|
96
|
+
* A typed $with relation entry.
|
|
97
|
+
* When Nav is typed (from __navProps), name is constrained to known nav prop keys.
|
|
98
|
+
* Each entry gets its own filter/controls typed to the target entity.
|
|
99
|
+
* Falls back to untyped WithRelation when Nav has no known keys.
|
|
100
|
+
*/
|
|
101
|
+
type TypedWithRelation<Nav extends Record<string, unknown>> = [
|
|
102
|
+
keyof Nav & string
|
|
103
|
+
] extends [never] ? WithRelation : {
|
|
104
|
+
[K in keyof Nav & string]: {
|
|
105
|
+
name: K;
|
|
106
|
+
filter?: FilterExpr<NavTarget<Nav[K]> extends {
|
|
107
|
+
__ownProps: infer F;
|
|
108
|
+
} ? F : Record<string, unknown>>;
|
|
109
|
+
controls?: UniqueryControls<NavTarget<Nav[K]> extends {
|
|
110
|
+
__ownProps: infer F;
|
|
111
|
+
} ? F : Record<string, unknown>, NavTarget<Nav[K]> extends {
|
|
112
|
+
__navProps: infer N extends Record<string, unknown>;
|
|
113
|
+
} ? N : Record<string, unknown>>;
|
|
114
|
+
insights?: UniqueryInsights;
|
|
115
|
+
};
|
|
116
|
+
}[keyof Nav & string];
|
|
117
|
+
/** Untyped $with relation — used when Nav generic is not provided. */
|
|
118
|
+
type WithRelation = {
|
|
119
|
+
name: string;
|
|
120
|
+
filter?: FilterExpr;
|
|
121
|
+
controls?: UniqueryControls;
|
|
122
|
+
insights?: UniqueryInsights;
|
|
123
|
+
};
|
|
94
124
|
/** Insight operator includes comparison ops plus control-derived ops. */
|
|
95
125
|
type InsightOp = ComparisonOp | '$select' | '$order' | '$with';
|
|
96
126
|
/** Map of field names to the set of operators used on that field. */
|
|
@@ -119,7 +149,7 @@ interface FilterVisitor<R> {
|
|
|
119
149
|
* - Bare primitive values are normalized to `comparison(field, '$eq', value)`.
|
|
120
150
|
* - Multi-field ComparisonNodes are combined via `visitor.and(...)`.
|
|
121
151
|
*/
|
|
122
|
-
declare function walkFilter<R>(expr: FilterExpr, visitor: FilterVisitor<R>): R;
|
|
152
|
+
declare function walkFilter<R>(expr: FilterExpr | undefined, visitor: FilterVisitor<R>): R | undefined;
|
|
123
153
|
declare function isPrimitive(x: unknown): x is Primitive;
|
|
124
154
|
|
|
125
155
|
/**
|
|
@@ -127,7 +157,7 @@ declare function isPrimitive(x: unknown): x is Primitive;
|
|
|
127
157
|
* This is the lazy counterpart to the eager insight capture done during
|
|
128
158
|
* URL parsing.
|
|
129
159
|
*/
|
|
130
|
-
declare function computeInsights(filter
|
|
160
|
+
declare function computeInsights(filter?: FilterExpr, controls?: UniqueryControls): UniqueryInsights;
|
|
131
161
|
/**
|
|
132
162
|
* Return insights for a query — uses pre-computed insights when present,
|
|
133
163
|
* computes lazily otherwise.
|
|
@@ -135,4 +165,4 @@ declare function computeInsights(filter: FilterExpr, controls?: UniqueryControls
|
|
|
135
165
|
declare function getInsights(query: Uniquery): UniqueryInsights;
|
|
136
166
|
|
|
137
167
|
export { computeInsights, getInsights, isPrimitive, walkFilter };
|
|
138
|
-
export type { ComparisonNode, ComparisonOp, FieldOps, FieldOpsFor, FieldValue, FilterExpr, FilterVisitor, InsightOp, LogicalNode, Primitive, Uniquery, UniqueryControls, UniqueryInsights, WithRelation };
|
|
168
|
+
export type { ComparisonNode, ComparisonOp, FieldOps, FieldOpsFor, FieldValue, FilterExpr, FilterVisitor, InsightOp, LogicalNode, NavTarget, Primitive, TypedWithRelation, Uniquery, UniqueryControls, UniqueryInsights, WithRelation };
|
package/dist/index.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* - Bare primitive values are normalized to `comparison(field, '$eq', value)`.
|
|
7
7
|
* - Multi-field ComparisonNodes are combined via `visitor.and(...)`.
|
|
8
8
|
*/ function walkFilter(expr, visitor) {
|
|
9
|
+
if (!expr) return void 0;
|
|
9
10
|
if ("$and" in expr && expr.$and !== void 0) {
|
|
10
11
|
const children = expr.$and.map((child) => walkFilter(child, visitor));
|
|
11
12
|
return visitor.and(children);
|
|
@@ -52,25 +53,34 @@ function isPrimitive(x) {
|
|
|
52
53
|
}
|
|
53
54
|
set.add(op);
|
|
54
55
|
}
|
|
55
|
-
|
|
56
|
+
const visitor = {
|
|
56
57
|
comparison(field, op) {
|
|
57
58
|
capture(field, op);
|
|
58
59
|
},
|
|
59
60
|
and() {},
|
|
60
61
|
or() {},
|
|
61
62
|
not() {}
|
|
62
|
-
}
|
|
63
|
+
};
|
|
64
|
+
if (filter) walkFilter(filter, visitor);
|
|
63
65
|
if (controls?.$select) if (Array.isArray(controls.$select)) for (const field of controls.$select) capture(field, "$select");
|
|
64
66
|
else for (const field of Object.keys(controls.$select)) capture(field, "$select");
|
|
65
67
|
if (controls?.$sort) for (const field of Object.keys(controls.$sort)) capture(field, "$order");
|
|
66
|
-
if (controls?.$with) for (const rel of controls.$with)
|
|
68
|
+
if (controls?.$with) for (const rel of controls.$with) {
|
|
69
|
+
capture(rel.name, "$with");
|
|
70
|
+
const nested = rel.insights ?? computeInsights(rel.filter, rel.controls);
|
|
71
|
+
if (nested.size) rel.insights = nested;
|
|
72
|
+
for (const [field, ops] of nested) {
|
|
73
|
+
const prefixed = `${rel.name}.${field}`;
|
|
74
|
+
for (const op of ops) capture(prefixed, op);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
67
77
|
return insights;
|
|
68
78
|
}
|
|
69
79
|
/**
|
|
70
80
|
* Return insights for a query — uses pre-computed insights when present,
|
|
71
81
|
* computes lazily otherwise.
|
|
72
82
|
*/ function getInsights(query) {
|
|
73
|
-
return query.insights ?? computeInsights(query.filter, query.controls);
|
|
83
|
+
return query.insights ?? computeInsights(query.filter ?? {}, query.controls);
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
//#endregion
|