@uniqu/core 0.1.0 → 0.1.2
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 +38 -3
- package/dist/index.cjs +15 -1
- package/dist/index.d.ts +46 -7
- package/dist/index.mjs +15 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,6 +83,7 @@ A `FilterExpr` is either a **comparison node** (leaf) or a **logical node** (bra
|
|
|
83
83
|
| `$count` | `boolean` | Request total count |
|
|
84
84
|
| `$select` | `SelectExpr<T>` | Field projection — array of strings/aggregates for inclusion, object for exclusion/mixed |
|
|
85
85
|
| `$groupBy` | `(keyof T & string)[]` | Fields to group by for aggregate queries |
|
|
86
|
+
| `$having` | `FilterExpr` | Post-aggregation filter on aliases and dimension fields |
|
|
86
87
|
| `$with` | `(WithRelation \| string)[]` | Relations to populate alongside the primary query |
|
|
87
88
|
| `$<custom>` | `unknown` | Arbitrary pass-through keywords |
|
|
88
89
|
|
|
@@ -164,6 +165,36 @@ interface AggregateExpr {
|
|
|
164
165
|
|
|
165
166
|
Known functions are `sum`, `count`, `avg`, `min`, `max` (`AggregateFn`), but `$fn` accepts any string for extensibility — consumers validate and execute supported functions.
|
|
166
167
|
|
|
168
|
+
#### Post-Aggregation Filter (`$having`)
|
|
169
|
+
|
|
170
|
+
`$having` filters groups after aggregation — the equivalent of SQL `HAVING`. It operates on aggregate result aliases and dimension fields:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
const query: Uniquery = {
|
|
174
|
+
filter: { status: 'active' },
|
|
175
|
+
controls: {
|
|
176
|
+
$select: [
|
|
177
|
+
'currency',
|
|
178
|
+
{ $fn: 'sum', $field: 'amount', $as: 'total' },
|
|
179
|
+
],
|
|
180
|
+
$groupBy: ['currency'],
|
|
181
|
+
$having: { total: { $gt: 1000 } },
|
|
182
|
+
$sort: { total: -1 },
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`$having` accepts a full `FilterExpr` — logical operators (`$and`, `$or`, `$not`) and all comparison operators are supported. It is untyped (`FilterExpr` without a generic) because its fields are aggregate aliases that don't exist on the entity type `T`.
|
|
188
|
+
|
|
189
|
+
Insights track `$having` fields with the `'$having'` op:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// insights for the query above:
|
|
193
|
+
// 'total' => Set { '$having', '$order' }
|
|
194
|
+
// 'currency' => Set { '$select', '$groupBy' }
|
|
195
|
+
// 'amount' => Set { 'sum' }
|
|
196
|
+
```
|
|
197
|
+
|
|
167
198
|
Insights track aggregate usage with bare function names (not `$`-prefixed), making it easy to distinguish controls from aggregates:
|
|
168
199
|
|
|
169
200
|
```ts
|
|
@@ -341,13 +372,17 @@ const insights = getInsights(query)
|
|
|
341
372
|
| `ComparisonNode<T>` | Leaf node — keys constrained to `keyof T` when typed |
|
|
342
373
|
| `LogicalNode<T>` | `{ $and: ... } \| { $or: ... } \| { $not: ... }` — variants are mutually exclusive via `never` |
|
|
343
374
|
| `AggregateFn` | `'sum' \| 'count' \| 'avg' \| 'min' \| 'max'` |
|
|
344
|
-
| `AggregateExpr
|
|
375
|
+
| `AggregateExpr<Fn, Field, Alias>` | `{ $fn, $field, $as? }` — aggregate function call in `$select`. Generic params preserve literal types for result inference |
|
|
345
376
|
| `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 |
|
|
377
|
+
| `UniqueryControls<T>` | Pagination, sorting, projection, grouping, `$having` — `$select`/`$sort`/`$groupBy` constrained to `keyof T` when typed |
|
|
347
378
|
| `Uniquery<T>` | `{ name?, filter, controls, insights? }` — root query (no name) or nested relation (with name) |
|
|
348
379
|
| `TypedWithRelation<Nav>` | Typed `$with` entry — `keyof Nav & string` or object with typed filter/controls |
|
|
349
380
|
| `WithRelation` | Untyped `$with` relation with `{ name: string, filter?, controls?, insights? }` |
|
|
350
|
-
| `
|
|
381
|
+
| `AggregateControls<T, D, M>` | Typed aggregate controls — `$groupBy` required, `$with` forbidden, `$select` constrained to dimensions + aggregates |
|
|
382
|
+
| `AggregateQuery<T, D, M>` | Typed aggregate query — `{ filter?, controls, insights? }` with dimension/measure constraints |
|
|
383
|
+
| `AggregateResult<T, Select>` | Infer result row type from `$select` — dimensions preserve original types, aggregates → `number` (min/max preserve field type) |
|
|
384
|
+
| `ResolveAlias<A>` | Resolve the output alias of an `AggregateExpr` — uses `$as` if provided, otherwise `{fn}_{field}` |
|
|
385
|
+
| `InsightOp` | `ComparisonOp \| '$select' \| '$order' \| '$with' \| '$groupBy' \| '$having' \| AggregateFn \| string` |
|
|
351
386
|
| `UniqueryInsights` | `Map<string, Set<InsightOp>>` |
|
|
352
387
|
|
|
353
388
|
### Functions
|
package/dist/index.cjs
CHANGED
|
@@ -72,7 +72,21 @@ function isPrimitive(x) {
|
|
|
72
72
|
else capture(entry.$field, entry.$fn);
|
|
73
73
|
else for (const field of Object.keys(controls.$select)) capture(field, "$select");
|
|
74
74
|
if (controls?.$groupBy) for (const field of controls.$groupBy) capture(field, "$groupBy");
|
|
75
|
-
if (controls?.$
|
|
75
|
+
if (controls?.$having) walkFilter(controls.$having, {
|
|
76
|
+
comparison(field) {
|
|
77
|
+
capture(field, "$having");
|
|
78
|
+
},
|
|
79
|
+
and() {},
|
|
80
|
+
or() {},
|
|
81
|
+
not() {}
|
|
82
|
+
});
|
|
83
|
+
if (controls?.$sort) {
|
|
84
|
+
let aliasToField;
|
|
85
|
+
if (Array.isArray(controls.$select)) {
|
|
86
|
+
for (const entry of controls.$select) if (typeof entry !== "string" && entry.$as) (aliasToField ?? (aliasToField = /* @__PURE__ */ new Map())).set(entry.$as, entry.$field);
|
|
87
|
+
}
|
|
88
|
+
for (const field of Object.keys(controls.$sort)) capture(aliasToField?.get(field) ?? field, "$order");
|
|
89
|
+
}
|
|
76
90
|
if (controls?.$with) for (const entry of controls.$with) {
|
|
77
91
|
if (typeof entry === "string") {
|
|
78
92
|
capture(entry, "$with");
|
package/dist/index.d.ts
CHANGED
|
@@ -67,14 +67,14 @@ type LogicalNode<T = Record<string, unknown>> = {
|
|
|
67
67
|
};
|
|
68
68
|
/** Known aggregate function names. Consumers may support additional functions via the (string & {}) escape hatch. */
|
|
69
69
|
type AggregateFn = 'sum' | 'count' | 'avg' | 'min' | 'max';
|
|
70
|
-
/** A single aggregate function call within $select. */
|
|
71
|
-
interface AggregateExpr {
|
|
70
|
+
/** A single aggregate function call within $select. Generic params preserve literal types for result inference. */
|
|
71
|
+
interface AggregateExpr<Fn extends string = AggregateFn | (string & {}), Field extends string = string, Alias extends string = string> {
|
|
72
72
|
/** Function name (sum, count, avg, min, max, or custom). */
|
|
73
|
-
$fn:
|
|
73
|
+
$fn: Fn;
|
|
74
74
|
/** Field to aggregate. '*' for count(*). */
|
|
75
|
-
$field:
|
|
75
|
+
$field: Field;
|
|
76
76
|
/** Alias for the result. Auto-generated by URL parser if omitted. */
|
|
77
|
-
$as?:
|
|
77
|
+
$as?: Alias;
|
|
78
78
|
}
|
|
79
79
|
/**
|
|
80
80
|
* Projection definition.
|
|
@@ -92,6 +92,8 @@ interface UniqueryControls<T = Record<string, unknown>, Nav extends Record<strin
|
|
|
92
92
|
$select?: SelectExpr<T>;
|
|
93
93
|
/** Fields to group by for aggregate queries. */
|
|
94
94
|
$groupBy?: (keyof T & string)[];
|
|
95
|
+
/** Post-aggregation filter. Operates on aggregate aliases and dimension fields. */
|
|
96
|
+
$having?: FilterExpr;
|
|
95
97
|
/** Relations to populate alongside the query. */
|
|
96
98
|
$with?: TypedWithRelation<Nav>[];
|
|
97
99
|
/** Pass-through for unknown $-prefixed keywords. */
|
|
@@ -145,9 +147,46 @@ type WithRelation = {
|
|
|
145
147
|
* Insight operator includes comparison ops, control ops ($-prefixed),
|
|
146
148
|
* and aggregate function names (bare, e.g. 'sum', 'avg').
|
|
147
149
|
*/
|
|
148
|
-
type InsightOp = ComparisonOp | '$select' | '$order' | '$with' | '$groupBy' | AggregateFn | (string & {});
|
|
150
|
+
type InsightOp = ComparisonOp | '$select' | '$order' | '$with' | '$groupBy' | '$having' | AggregateFn | (string & {});
|
|
149
151
|
/** Map of field names to the set of operators used on that field. */
|
|
150
152
|
type UniqueryInsights = Map<string, Set<InsightOp>>;
|
|
153
|
+
/** Aggregate query controls. Separate from UniqueryControls: $groupBy is required, $with is forbidden. */
|
|
154
|
+
interface AggregateControls<T = Record<string, unknown>, D extends keyof T & string = keyof T & string, M extends keyof T & string = keyof T & string> {
|
|
155
|
+
$groupBy: D[];
|
|
156
|
+
$select?: (D | AggregateExpr<AggregateFn, M | '*'>)[];
|
|
157
|
+
$having?: FilterExpr;
|
|
158
|
+
$sort?: Record<string, 1 | -1>;
|
|
159
|
+
$skip?: number;
|
|
160
|
+
$limit?: number;
|
|
161
|
+
$count?: boolean;
|
|
162
|
+
[key: `$${string}`]: unknown;
|
|
163
|
+
}
|
|
164
|
+
/** Aggregate query — no name (can't nest), no Nav (no $with). */
|
|
165
|
+
interface AggregateQuery<T = Record<string, unknown>, D extends keyof T & string = keyof T & string, M extends keyof T & string = keyof T & string> {
|
|
166
|
+
filter?: FilterExpr<T>;
|
|
167
|
+
controls: AggregateControls<T, D, M>;
|
|
168
|
+
insights?: UniqueryInsights;
|
|
169
|
+
}
|
|
170
|
+
/** Resolve the output alias of an AggregateExpr. Uses $as if provided, otherwise generates {fn}_{field}. */
|
|
171
|
+
type ResolveAlias<A extends AggregateExpr> = A extends {
|
|
172
|
+
$as: infer Alias extends string;
|
|
173
|
+
} ? Alias : A extends {
|
|
174
|
+
$fn: infer Fn extends string;
|
|
175
|
+
$field: infer F extends string;
|
|
176
|
+
} ? `${Fn}_${F}` : string;
|
|
177
|
+
/**
|
|
178
|
+
* Infer the result row type from an aggregate query's $select.
|
|
179
|
+
* Dimension fields preserve their original type from T.
|
|
180
|
+
* Aggregate expressions: min/max preserve original type, others → number.
|
|
181
|
+
*/
|
|
182
|
+
type AggregateResult<T, Select extends readonly (string | AggregateExpr)[]> = {
|
|
183
|
+
[K in Extract<Select[number], string> & keyof T]: T[K];
|
|
184
|
+
} & {
|
|
185
|
+
[A in Extract<Select[number], AggregateExpr> as ResolveAlias<A>]: A extends {
|
|
186
|
+
$fn: 'min' | 'max';
|
|
187
|
+
$field: infer F extends keyof T & string;
|
|
188
|
+
} ? T[F] : number;
|
|
189
|
+
};
|
|
151
190
|
|
|
152
191
|
/**
|
|
153
192
|
* Visitor callbacks for controlling how filter nodes are processed.
|
|
@@ -188,4 +227,4 @@ declare function computeInsights(filter?: FilterExpr, controls?: UniqueryControl
|
|
|
188
227
|
declare function getInsights(query: Uniquery): UniqueryInsights;
|
|
189
228
|
|
|
190
229
|
export { computeInsights, getInsights, isPrimitive, walkFilter };
|
|
191
|
-
export type { AggregateExpr, AggregateFn, ComparisonNode, ComparisonOp, FieldOps, FieldOpsFor, FieldValue, FilterExpr, FilterVisitor, InsightOp, LogicalNode, NavTarget, Primitive, SelectExpr, TypedWithRelation, Uniquery, UniqueryControls, UniqueryInsights, WithRelation };
|
|
230
|
+
export type { AggregateControls, AggregateExpr, AggregateFn, AggregateQuery, AggregateResult, ComparisonNode, ComparisonOp, FieldOps, FieldOpsFor, FieldValue, FilterExpr, FilterVisitor, InsightOp, LogicalNode, NavTarget, Primitive, ResolveAlias, SelectExpr, TypedWithRelation, Uniquery, UniqueryControls, UniqueryInsights, WithRelation };
|
package/dist/index.mjs
CHANGED
|
@@ -70,7 +70,21 @@ function isPrimitive(x) {
|
|
|
70
70
|
else capture(entry.$field, entry.$fn);
|
|
71
71
|
else for (const field of Object.keys(controls.$select)) capture(field, "$select");
|
|
72
72
|
if (controls?.$groupBy) for (const field of controls.$groupBy) capture(field, "$groupBy");
|
|
73
|
-
if (controls?.$
|
|
73
|
+
if (controls?.$having) walkFilter(controls.$having, {
|
|
74
|
+
comparison(field) {
|
|
75
|
+
capture(field, "$having");
|
|
76
|
+
},
|
|
77
|
+
and() {},
|
|
78
|
+
or() {},
|
|
79
|
+
not() {}
|
|
80
|
+
});
|
|
81
|
+
if (controls?.$sort) {
|
|
82
|
+
let aliasToField;
|
|
83
|
+
if (Array.isArray(controls.$select)) {
|
|
84
|
+
for (const entry of controls.$select) if (typeof entry !== "string" && entry.$as) (aliasToField ?? (aliasToField = /* @__PURE__ */ new Map())).set(entry.$as, entry.$field);
|
|
85
|
+
}
|
|
86
|
+
for (const field of Object.keys(controls.$sort)) capture(aliasToField?.get(field) ?? field, "$order");
|
|
87
|
+
}
|
|
74
88
|
if (controls?.$with) for (const entry of controls.$with) {
|
|
75
89
|
if (typeof entry === "string") {
|
|
76
90
|
capture(entry, "$with");
|