@uniqu/core 0.0.6 → 0.1.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/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` | `string[] \| Record<string, 0 \| 1>` | Field projection — array for inclusion, object for exclusion/mixed |
85
- | `$with` | `WithRelation[]` | Relations to populate alongside the primary query |
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 is a full `Uniquery` sub-query (a `WithRelation`) with its own `name`, `filter`, `controls`, and `insights`:
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
- { name: 'author', filter: {}, controls: {} },
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. Dot-notation paths are always allowed for nested access:
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 with typed field comparisons |
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
- | `UniqueryControls<T>` | Pagination, sorting, projection `$select`/`$sort` constrained to `keyof T` when typed |
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
- | `WithRelation` | `Uniquery & { name: string }` a `$with` relation with a required name |
297
- | `InsightOp` | `ComparisonOp \| '$select' \| '$order' \| '$with'` |
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 children = expr.$and.map((child) => walkFilter(child, visitor));
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 children = expr.$or.map((child) => walkFilter(child, visitor));
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,29 @@ 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 field of controls.$select) capture(field, "$select");
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");
75
+ if (controls?.$having) walkFilter(controls.$having, {
76
+ comparison(field) {
77
+ capture(field, "$having");
78
+ },
79
+ and() {},
80
+ or() {},
81
+ not() {}
82
+ });
69
83
  if (controls?.$sort) for (const field of Object.keys(controls.$sort)) capture(field, "$order");
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;
84
+ if (controls?.$with) for (const entry of controls.$with) {
85
+ if (typeof entry === "string") {
86
+ capture(entry, "$with");
87
+ continue;
88
+ }
89
+ capture(entry.name, "$with");
90
+ const nested = entry.insights ?? computeInsights(entry.filter, entry.controls);
91
+ if (nested.size) entry.insights = nested;
74
92
  for (const [field, ops] of nested) {
75
- const prefixed = `${rel.name}.${field}`;
93
+ const prefixed = `${entry.name}.${field}`;
76
94
  for (const op of ops) capture(prefixed, op);
77
95
  }
78
96
  }
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
- * Known keys from `T` get typed values; arbitrary string keys
45
- * (e.g. dot-notation paths like `"client.age"`) are always allowed.
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
- } & Record<string, unknown>;
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,35 @@ type LogicalNode<T = Record<string, unknown>> = {
65
65
  $and?: never;
66
66
  $or?: never;
67
67
  };
68
- /** Query controls (pagination, projection, sorting). Generic `T` constrains field names in `$select` and `$sort`. */
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. 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
+ /** Function name (sum, count, avg, min, max, or custom). */
73
+ $fn: Fn;
74
+ /** Field to aggregate. '*' for count(*). */
75
+ $field: Field;
76
+ /** Alias for the result. Auto-generated by URL parser if omitted. */
77
+ $as?: Alias;
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?: (keyof T & string)[] | Partial<Record<keyof T & string, 0 | 1>>;
92
+ $select?: SelectExpr<T>;
93
+ /** Fields to group by for aggregate queries. */
94
+ $groupBy?: (keyof T & string)[];
95
+ /** Post-aggregation filter. Operates on aggregate aliases and dimension fields. */
96
+ $having?: FilterExpr;
75
97
  /** Relations to populate alongside the query. */
76
98
  $with?: TypedWithRelation<Nav>[];
77
99
  /** Pass-through for unknown $-prefixed keywords. */
@@ -100,7 +122,7 @@ type NavTarget<T> = T extends Array<infer U> ? U : T;
100
122
  */
101
123
  type TypedWithRelation<Nav extends Record<string, unknown>> = [
102
124
  keyof Nav & string
103
- ] extends [never] ? WithRelation : {
125
+ ] extends [never] ? WithRelation | string : {
104
126
  [K in keyof Nav & string]: {
105
127
  name: K;
106
128
  filter?: FilterExpr<NavTarget<Nav[K]> extends {
@@ -113,7 +135,7 @@ type TypedWithRelation<Nav extends Record<string, unknown>> = [
113
135
  } ? N : Record<string, unknown>>;
114
136
  insights?: UniqueryInsights;
115
137
  };
116
- }[keyof Nav & string];
138
+ }[keyof Nav & string] | (keyof Nav & string);
117
139
  /** Untyped $with relation — used when Nav generic is not provided. */
118
140
  type WithRelation = {
119
141
  name: string;
@@ -121,10 +143,50 @@ type WithRelation = {
121
143
  controls?: UniqueryControls;
122
144
  insights?: UniqueryInsights;
123
145
  };
124
- /** Insight operator includes comparison ops plus control-derived ops. */
125
- type InsightOp = ComparisonOp | '$select' | '$order' | '$with';
146
+ /**
147
+ * Insight operator includes comparison ops, control ops ($-prefixed),
148
+ * and aggregate function names (bare, e.g. 'sum', 'avg').
149
+ */
150
+ type InsightOp = ComparisonOp | '$select' | '$order' | '$with' | '$groupBy' | '$having' | AggregateFn | (string & {});
126
151
  /** Map of field names to the set of operators used on that field. */
127
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
+ };
128
190
 
129
191
  /**
130
192
  * Visitor callbacks for controlling how filter nodes are processed.
@@ -165,4 +227,4 @@ declare function computeInsights(filter?: FilterExpr, controls?: UniqueryControl
165
227
  declare function getInsights(query: Uniquery): UniqueryInsights;
166
228
 
167
229
  export { computeInsights, getInsights, isPrimitive, walkFilter };
168
- export type { ComparisonNode, ComparisonOp, FieldOps, FieldOpsFor, FieldValue, FilterExpr, FilterVisitor, InsightOp, LogicalNode, NavTarget, Primitive, 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
@@ -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 children = expr.$and.map((child) => walkFilter(child, visitor));
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 children = expr.$or.map((child) => walkFilter(child, visitor));
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,29 @@ 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 field of controls.$select) capture(field, "$select");
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");
73
+ if (controls?.$having) walkFilter(controls.$having, {
74
+ comparison(field) {
75
+ capture(field, "$having");
76
+ },
77
+ and() {},
78
+ or() {},
79
+ not() {}
80
+ });
67
81
  if (controls?.$sort) for (const field of Object.keys(controls.$sort)) capture(field, "$order");
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;
82
+ if (controls?.$with) for (const entry of controls.$with) {
83
+ if (typeof entry === "string") {
84
+ capture(entry, "$with");
85
+ continue;
86
+ }
87
+ capture(entry.name, "$with");
88
+ const nested = entry.insights ?? computeInsights(entry.filter, entry.controls);
89
+ if (nested.size) entry.insights = nested;
72
90
  for (const [field, ops] of nested) {
73
- const prefixed = `${rel.name}.${field}`;
91
+ const prefixed = `${entry.name}.${field}`;
74
92
  for (const op of ops) capture(prefixed, op);
75
93
  }
76
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqu/core",
3
- "version": "0.0.6",
3
+ "version": "0.1.1",
4
4
  "description": "Canonical query format types, tree walker, and utilities for Uniqu",
5
5
  "license": "MIT",
6
6
  "author": "Artem Maltsev",