@uniqu/core 0.0.6 → 0.1.0
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 +66 -13
- package/dist/index.cjs +18 -8
- package/dist/index.d.ts +33 -10
- package/dist/index.mjs +18 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,13 +81,14 @@ A `FilterExpr` is either a **comparison node** (leaf) or a **logical node** (bra
|
|
|
81
81
|
| `$skip` | `number` | Skip N results |
|
|
82
82
|
| `$limit` | `number` | Limit to N results |
|
|
83
83
|
| `$count` | `boolean` | Request total count |
|
|
84
|
-
| `$select` | `
|
|
85
|
-
| `$
|
|
84
|
+
| `$select` | `SelectExpr<T>` | Field projection — array of strings/aggregates for inclusion, object for exclusion/mixed |
|
|
85
|
+
| `$groupBy` | `(keyof T & string)[]` | Fields to group by for aggregate queries |
|
|
86
|
+
| `$with` | `(WithRelation \| string)[]` | Relations to populate alongside the primary query |
|
|
86
87
|
| `$<custom>` | `unknown` | Arbitrary pass-through keywords |
|
|
87
88
|
|
|
88
89
|
### Relation Loading (`$with`)
|
|
89
90
|
|
|
90
|
-
`$with` declares which relations to populate alongside the primary query. Each relation
|
|
91
|
+
`$with` declares which relations to populate alongside the primary query. Each entry can be a **string** (relation name only) or a full **object** (a `WithRelation` sub-query with its own `filter`, `controls`, and `insights`):
|
|
91
92
|
|
|
92
93
|
```ts
|
|
93
94
|
import type { Uniquery, WithRelation } from '@uniqu/core'
|
|
@@ -96,6 +97,9 @@ const query: Uniquery = {
|
|
|
96
97
|
filter: { status: 'active' },
|
|
97
98
|
controls: {
|
|
98
99
|
$with: [
|
|
100
|
+
// String shorthand — just the relation name
|
|
101
|
+
'profile',
|
|
102
|
+
// Object form — full sub-query
|
|
99
103
|
{
|
|
100
104
|
name: 'posts',
|
|
101
105
|
filter: { status: 'published' },
|
|
@@ -105,11 +109,10 @@ const query: Uniquery = {
|
|
|
105
109
|
$select: ['title', 'body'],
|
|
106
110
|
$with: [
|
|
107
111
|
{ name: 'comments', filter: {}, controls: { $limit: 10 } },
|
|
108
|
-
|
|
112
|
+
'author',
|
|
109
113
|
],
|
|
110
114
|
},
|
|
111
115
|
},
|
|
112
|
-
{ name: 'profile', filter: {}, controls: {} },
|
|
113
116
|
],
|
|
114
117
|
},
|
|
115
118
|
}
|
|
@@ -121,13 +124,59 @@ const query: Uniquery = {
|
|
|
121
124
|
type WithRelation = Uniquery & { name: string }
|
|
122
125
|
```
|
|
123
126
|
|
|
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.
|
|
127
|
+
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` object 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.
|
|
128
|
+
|
|
129
|
+
When a `Nav` generic is provided, string entries and `name` fields are constrained to `keyof Nav & string`. Without a generic, any string is accepted.
|
|
125
130
|
|
|
126
131
|
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.
|
|
127
132
|
|
|
133
|
+
### Aggregation (`$groupBy` + `$select`)
|
|
134
|
+
|
|
135
|
+
`$groupBy` declares grouping fields. Aggregate functions appear as `AggregateExpr` objects in the `$select` array alongside plain field names:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import type { Uniquery, AggregateExpr } from '@uniqu/core'
|
|
139
|
+
|
|
140
|
+
const query: Uniquery = {
|
|
141
|
+
filter: { status: 'active' },
|
|
142
|
+
controls: {
|
|
143
|
+
$select: [
|
|
144
|
+
'currency',
|
|
145
|
+
{ $fn: 'sum', $field: 'amount', $as: 'total' },
|
|
146
|
+
{ $fn: 'count', $field: '*', $as: 'count' },
|
|
147
|
+
],
|
|
148
|
+
$groupBy: ['currency'],
|
|
149
|
+
$sort: { total: -1 },
|
|
150
|
+
$limit: 10,
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`AggregateExpr` has three fields:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
interface AggregateExpr {
|
|
159
|
+
$fn: AggregateFn | (string & {}) // 'sum' | 'count' | 'avg' | 'min' | 'max' | custom
|
|
160
|
+
$field: string // field name, or '*' for count(*)
|
|
161
|
+
$as?: string // optional alias for the result
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Known functions are `sum`, `count`, `avg`, `min`, `max` (`AggregateFn`), but `$fn` accepts any string for extensibility — consumers validate and execute supported functions.
|
|
166
|
+
|
|
167
|
+
Insights track aggregate usage with bare function names (not `$`-prefixed), making it easy to distinguish controls from aggregates:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// insights for the query above:
|
|
171
|
+
// 'currency' => Set { '$select', '$groupBy' }
|
|
172
|
+
// 'amount' => Set { 'sum' }
|
|
173
|
+
// '*' => Set { 'count' }
|
|
174
|
+
// 'total' => Set { '$order' }
|
|
175
|
+
```
|
|
176
|
+
|
|
128
177
|
## Type-Safe Filters
|
|
129
178
|
|
|
130
|
-
`FilterExpr<T>` accepts a generic entity type for compile-time field and value checking
|
|
179
|
+
`FilterExpr<T>` accepts a generic entity type for compile-time field and value checking:
|
|
131
180
|
|
|
132
181
|
```ts
|
|
133
182
|
interface User {
|
|
@@ -140,12 +189,12 @@ const filter: FilterExpr<User> = {
|
|
|
140
189
|
name: 'John', // string — ok
|
|
141
190
|
age: { $gte: 18 }, // number — ok
|
|
142
191
|
active: true, // boolean — ok
|
|
143
|
-
'address.city': 'NYC', // dot-notation — always allowed
|
|
144
192
|
// age: { $gte: 'old' }, // type error: string not assignable to number
|
|
193
|
+
// foo: 'bar', // type error: 'foo' is not a key of User
|
|
145
194
|
}
|
|
146
195
|
```
|
|
147
196
|
|
|
148
|
-
Without a generic argument, `FilterExpr` accepts any string keys with any values (untyped mode).
|
|
197
|
+
When typed, only keys of `T` are allowed — no arbitrary string keys. Without a generic argument, `FilterExpr` accepts any string keys with any values (untyped mode).
|
|
149
198
|
|
|
150
199
|
### Type-Safe Controls
|
|
151
200
|
|
|
@@ -289,12 +338,16 @@ const insights = getInsights(query)
|
|
|
289
338
|
| `FieldOps` | Untyped operator map (`FieldOpsFor<Primitive>`) |
|
|
290
339
|
| `FieldValue` | `Primitive \| FieldOps` |
|
|
291
340
|
| `FilterExpr<T>` | `ComparisonNode<T> \| LogicalNode<T>` |
|
|
292
|
-
| `ComparisonNode<T>` | Leaf node
|
|
341
|
+
| `ComparisonNode<T>` | Leaf node — keys constrained to `keyof T` when typed |
|
|
293
342
|
| `LogicalNode<T>` | `{ $and: ... } \| { $or: ... } \| { $not: ... }` — variants are mutually exclusive via `never` |
|
|
294
|
-
| `
|
|
343
|
+
| `AggregateFn` | `'sum' \| 'count' \| 'avg' \| 'min' \| 'max'` |
|
|
344
|
+
| `AggregateExpr` | `{ $fn, $field, $as? }` — aggregate function call in `$select` |
|
|
345
|
+
| `SelectExpr<T>` | `((keyof T & string) \| AggregateExpr)[] \| Record<keyof T & string, 0 \| 1>` |
|
|
346
|
+
| `UniqueryControls<T>` | Pagination, sorting, projection, grouping — `$select`/`$sort`/`$groupBy` constrained to `keyof T` when typed |
|
|
295
347
|
| `Uniquery<T>` | `{ name?, filter, controls, insights? }` — root query (no name) or nested relation (with name) |
|
|
296
|
-
| `
|
|
297
|
-
| `
|
|
348
|
+
| `TypedWithRelation<Nav>` | Typed `$with` entry — `keyof Nav & string` or object with typed filter/controls |
|
|
349
|
+
| `WithRelation` | Untyped `$with` relation with `{ name: string, filter?, controls?, insights? }` |
|
|
350
|
+
| `InsightOp` | `ComparisonOp \| '$select' \| '$order' \| '$with' \| '$groupBy' \| AggregateFn \| string` |
|
|
298
351
|
| `UniqueryInsights` | `Map<string, Set<InsightOp>>` |
|
|
299
352
|
|
|
300
353
|
### Functions
|
package/dist/index.cjs
CHANGED
|
@@ -10,11 +10,15 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
|
10
10
|
*/ function walkFilter(expr, visitor) {
|
|
11
11
|
if (!expr) return void 0;
|
|
12
12
|
if ("$and" in expr && expr.$and !== void 0) {
|
|
13
|
-
const
|
|
13
|
+
const andArr = expr.$and;
|
|
14
|
+
const children = [];
|
|
15
|
+
for (const child of andArr) children.push(walkFilter(child, visitor));
|
|
14
16
|
return visitor.and(children);
|
|
15
17
|
}
|
|
16
18
|
if ("$or" in expr && expr.$or !== void 0) {
|
|
17
|
-
const
|
|
19
|
+
const orArr = expr.$or;
|
|
20
|
+
const children = [];
|
|
21
|
+
for (const child of orArr) children.push(walkFilter(child, visitor));
|
|
18
22
|
return visitor.or(children);
|
|
19
23
|
}
|
|
20
24
|
if ("$not" in expr && expr.$not !== void 0) {
|
|
@@ -64,15 +68,21 @@ function isPrimitive(x) {
|
|
|
64
68
|
not() {}
|
|
65
69
|
};
|
|
66
70
|
if (filter) walkFilter(filter, visitor);
|
|
67
|
-
if (controls?.$select) if (Array.isArray(controls.$select)) for (const
|
|
71
|
+
if (controls?.$select) if (Array.isArray(controls.$select)) for (const entry of controls.$select) if (typeof entry === "string") capture(entry, "$select");
|
|
72
|
+
else capture(entry.$field, entry.$fn);
|
|
68
73
|
else for (const field of Object.keys(controls.$select)) capture(field, "$select");
|
|
74
|
+
if (controls?.$groupBy) for (const field of controls.$groupBy) capture(field, "$groupBy");
|
|
69
75
|
if (controls?.$sort) for (const field of Object.keys(controls.$sort)) capture(field, "$order");
|
|
70
|
-
if (controls?.$with) for (const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
76
|
+
if (controls?.$with) for (const entry of controls.$with) {
|
|
77
|
+
if (typeof entry === "string") {
|
|
78
|
+
capture(entry, "$with");
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
capture(entry.name, "$with");
|
|
82
|
+
const nested = entry.insights ?? computeInsights(entry.filter, entry.controls);
|
|
83
|
+
if (nested.size) entry.insights = nested;
|
|
74
84
|
for (const [field, ops] of nested) {
|
|
75
|
-
const prefixed = `${
|
|
85
|
+
const prefixed = `${entry.name}.${field}`;
|
|
76
86
|
for (const op of ops) capture(prefixed, op);
|
|
77
87
|
}
|
|
78
88
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -41,12 +41,12 @@ type FieldValue = Primitive | FieldOps;
|
|
|
41
41
|
type FilterExpr<T = Record<string, unknown>> = ComparisonNode<T> | LogicalNode<T>;
|
|
42
42
|
/**
|
|
43
43
|
* Leaf node: one or more field comparisons.
|
|
44
|
-
*
|
|
45
|
-
*
|
|
44
|
+
* When `T` is typed, only known keys are allowed.
|
|
45
|
+
* When untyped (default), any string key is accepted.
|
|
46
46
|
*/
|
|
47
47
|
type ComparisonNode<T = Record<string, unknown>> = {
|
|
48
48
|
[K in keyof T & string]?: T[K] | FieldOpsFor<T[K]>;
|
|
49
|
-
}
|
|
49
|
+
};
|
|
50
50
|
/**
|
|
51
51
|
* Branch node: logical combination of child expressions.
|
|
52
52
|
* Each variant forbids the other logical keys via `never` to prevent
|
|
@@ -65,13 +65,33 @@ type LogicalNode<T = Record<string, unknown>> = {
|
|
|
65
65
|
$and?: never;
|
|
66
66
|
$or?: never;
|
|
67
67
|
};
|
|
68
|
-
/**
|
|
68
|
+
/** Known aggregate function names. Consumers may support additional functions via the (string & {}) escape hatch. */
|
|
69
|
+
type AggregateFn = 'sum' | 'count' | 'avg' | 'min' | 'max';
|
|
70
|
+
/** A single aggregate function call within $select. */
|
|
71
|
+
interface AggregateExpr {
|
|
72
|
+
/** Function name (sum, count, avg, min, max, or custom). */
|
|
73
|
+
$fn: AggregateFn | (string & {});
|
|
74
|
+
/** Field to aggregate. '*' for count(*). */
|
|
75
|
+
$field: string;
|
|
76
|
+
/** Alias for the result. Auto-generated by URL parser if omitted. */
|
|
77
|
+
$as?: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Projection definition.
|
|
81
|
+
* - Array form: inclusion list with optional aggregates.
|
|
82
|
+
* Plain strings select fields; AggregateExpr objects define computed columns.
|
|
83
|
+
* - Object form: inclusion/exclusion map (0 or 1 per field). No aggregates in this form.
|
|
84
|
+
*/
|
|
85
|
+
type SelectExpr<T = Record<string, unknown>> = ((keyof T & string) | AggregateExpr)[] | Partial<Record<keyof T & string, 0 | 1>>;
|
|
86
|
+
/** Query controls (pagination, projection, sorting, grouping). Generic `T` constrains field names. */
|
|
69
87
|
interface UniqueryControls<T = Record<string, unknown>, Nav extends Record<string, unknown> = Record<string, unknown>> {
|
|
70
88
|
$sort?: Partial<Record<keyof T & string, 1 | -1>>;
|
|
71
89
|
$skip?: number;
|
|
72
90
|
$limit?: number;
|
|
73
91
|
$count?: boolean;
|
|
74
|
-
$select?:
|
|
92
|
+
$select?: SelectExpr<T>;
|
|
93
|
+
/** Fields to group by for aggregate queries. */
|
|
94
|
+
$groupBy?: (keyof T & string)[];
|
|
75
95
|
/** Relations to populate alongside the query. */
|
|
76
96
|
$with?: TypedWithRelation<Nav>[];
|
|
77
97
|
/** Pass-through for unknown $-prefixed keywords. */
|
|
@@ -100,7 +120,7 @@ type NavTarget<T> = T extends Array<infer U> ? U : T;
|
|
|
100
120
|
*/
|
|
101
121
|
type TypedWithRelation<Nav extends Record<string, unknown>> = [
|
|
102
122
|
keyof Nav & string
|
|
103
|
-
] extends [never] ? WithRelation : {
|
|
123
|
+
] extends [never] ? WithRelation | string : {
|
|
104
124
|
[K in keyof Nav & string]: {
|
|
105
125
|
name: K;
|
|
106
126
|
filter?: FilterExpr<NavTarget<Nav[K]> extends {
|
|
@@ -113,7 +133,7 @@ type TypedWithRelation<Nav extends Record<string, unknown>> = [
|
|
|
113
133
|
} ? N : Record<string, unknown>>;
|
|
114
134
|
insights?: UniqueryInsights;
|
|
115
135
|
};
|
|
116
|
-
}[keyof Nav & string];
|
|
136
|
+
}[keyof Nav & string] | (keyof Nav & string);
|
|
117
137
|
/** Untyped $with relation — used when Nav generic is not provided. */
|
|
118
138
|
type WithRelation = {
|
|
119
139
|
name: string;
|
|
@@ -121,8 +141,11 @@ type WithRelation = {
|
|
|
121
141
|
controls?: UniqueryControls;
|
|
122
142
|
insights?: UniqueryInsights;
|
|
123
143
|
};
|
|
124
|
-
/**
|
|
125
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Insight operator includes comparison ops, control ops ($-prefixed),
|
|
146
|
+
* and aggregate function names (bare, e.g. 'sum', 'avg').
|
|
147
|
+
*/
|
|
148
|
+
type InsightOp = ComparisonOp | '$select' | '$order' | '$with' | '$groupBy' | AggregateFn | (string & {});
|
|
126
149
|
/** Map of field names to the set of operators used on that field. */
|
|
127
150
|
type UniqueryInsights = Map<string, Set<InsightOp>>;
|
|
128
151
|
|
|
@@ -165,4 +188,4 @@ declare function computeInsights(filter?: FilterExpr, controls?: UniqueryControl
|
|
|
165
188
|
declare function getInsights(query: Uniquery): UniqueryInsights;
|
|
166
189
|
|
|
167
190
|
export { computeInsights, getInsights, isPrimitive, walkFilter };
|
|
168
|
-
export type { ComparisonNode, ComparisonOp, FieldOps, FieldOpsFor, FieldValue, FilterExpr, FilterVisitor, InsightOp, LogicalNode, NavTarget, Primitive, TypedWithRelation, Uniquery, UniqueryControls, UniqueryInsights, WithRelation };
|
|
191
|
+
export type { AggregateExpr, AggregateFn, ComparisonNode, ComparisonOp, FieldOps, FieldOpsFor, FieldValue, FilterExpr, FilterVisitor, InsightOp, LogicalNode, NavTarget, Primitive, SelectExpr, TypedWithRelation, Uniquery, UniqueryControls, UniqueryInsights, WithRelation };
|
package/dist/index.mjs
CHANGED
|
@@ -8,11 +8,15 @@
|
|
|
8
8
|
*/ function walkFilter(expr, visitor) {
|
|
9
9
|
if (!expr) return void 0;
|
|
10
10
|
if ("$and" in expr && expr.$and !== void 0) {
|
|
11
|
-
const
|
|
11
|
+
const andArr = expr.$and;
|
|
12
|
+
const children = [];
|
|
13
|
+
for (const child of andArr) children.push(walkFilter(child, visitor));
|
|
12
14
|
return visitor.and(children);
|
|
13
15
|
}
|
|
14
16
|
if ("$or" in expr && expr.$or !== void 0) {
|
|
15
|
-
const
|
|
17
|
+
const orArr = expr.$or;
|
|
18
|
+
const children = [];
|
|
19
|
+
for (const child of orArr) children.push(walkFilter(child, visitor));
|
|
16
20
|
return visitor.or(children);
|
|
17
21
|
}
|
|
18
22
|
if ("$not" in expr && expr.$not !== void 0) {
|
|
@@ -62,15 +66,21 @@ function isPrimitive(x) {
|
|
|
62
66
|
not() {}
|
|
63
67
|
};
|
|
64
68
|
if (filter) walkFilter(filter, visitor);
|
|
65
|
-
if (controls?.$select) if (Array.isArray(controls.$select)) for (const
|
|
69
|
+
if (controls?.$select) if (Array.isArray(controls.$select)) for (const entry of controls.$select) if (typeof entry === "string") capture(entry, "$select");
|
|
70
|
+
else capture(entry.$field, entry.$fn);
|
|
66
71
|
else for (const field of Object.keys(controls.$select)) capture(field, "$select");
|
|
72
|
+
if (controls?.$groupBy) for (const field of controls.$groupBy) capture(field, "$groupBy");
|
|
67
73
|
if (controls?.$sort) for (const field of Object.keys(controls.$sort)) capture(field, "$order");
|
|
68
|
-
if (controls?.$with) for (const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
if (controls?.$with) for (const entry of controls.$with) {
|
|
75
|
+
if (typeof entry === "string") {
|
|
76
|
+
capture(entry, "$with");
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
capture(entry.name, "$with");
|
|
80
|
+
const nested = entry.insights ?? computeInsights(entry.filter, entry.controls);
|
|
81
|
+
if (nested.size) entry.insights = nested;
|
|
72
82
|
for (const [field, ops] of nested) {
|
|
73
|
-
const prefixed = `${
|
|
83
|
+
const prefixed = `${entry.name}.${field}`;
|
|
74
84
|
for (const op of ops) capture(prefixed, op);
|
|
75
85
|
}
|
|
76
86
|
}
|