@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 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` | `{ $fn, $field, $as? }` — aggregate function call in `$select` |
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
- | `InsightOp` | `ComparisonOp \| '$select' \| '$order' \| '$with' \| '$groupBy' \| AggregateFn \| string` |
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?.$sort) for (const field of Object.keys(controls.$sort)) capture(field, "$order");
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: AggregateFn | (string & {});
73
+ $fn: Fn;
74
74
  /** Field to aggregate. '*' for count(*). */
75
- $field: string;
75
+ $field: Field;
76
76
  /** Alias for the result. Auto-generated by URL parser if omitted. */
77
- $as?: string;
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?.$sort) for (const field of Object.keys(controls.$sort)) capture(field, "$order");
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqu/core",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Canonical query format types, tree walker, and utilities for Uniqu",
5
5
  "license": "MIT",
6
6
  "author": "Artem Maltsev",