@uniqu/url 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
@@ -4,7 +4,7 @@
4
4
  <img src="../../logo.svg" alt="uniqu" height="80">
5
5
  </p>
6
6
 
7
- URL query string parser that produces the [Uniqu](../../README.md) canonical query format. Human-readable URL syntax with full filter expressions, sorting, pagination, and projection.
7
+ URL query string parser and builder for the [Uniqu](../../README.md) canonical query format. Human-readable URL syntax with full filter expressions, sorting, pagination, projection, and aggregation.
8
8
 
9
9
  ## Install
10
10
 
@@ -151,11 +151,75 @@ Control keywords start with `$` and are separated from filter expressions:
151
151
  | `$limit` | `$top` | `$limit=20` | `{ $limit: 20 }` |
152
152
  | `$skip` | — | `$skip=40` | `{ $skip: 40 }` |
153
153
  | `$count` | — | `$count` | `{ $count: true }` |
154
+ | `$groupBy` | — | `$groupBy=currency,region` | `{ $groupBy: ['currency', 'region'] }` |
154
155
  | `$with` | — | `$with=posts,author` | `{ $with: [{ name: 'posts', filter: {}, controls: {} }, ...] }` |
155
156
  | `$<custom>` | — | `$search=term` | `{ $search: 'term' }` |
156
157
 
157
158
  Prefix a field with `-` in `$select` to exclude it. When any exclusion is present, `$select` produces an object (`{ name: 1, password: 0 }`); otherwise it produces an array (`['name', 'email']`). Prefix with `-` in `$order` for descending sort.
158
159
 
160
+ ### Aggregate Functions in `$select`
161
+
162
+ `$select` supports aggregate function calls using `fn(field)` syntax. An optional alias can be specified with `:alias`:
163
+
164
+ ```
165
+ $select=sum(amount) → [{ $fn: 'sum', $field: 'amount', $as: 'sum_amount' }]
166
+ $select=sum(amount):total → [{ $fn: 'sum', $field: 'amount', $as: 'total' }]
167
+ $select=count(*) → [{ $fn: 'count', $field: '*', $as: 'count_star' }]
168
+ $select=sum(amount),currency → [{ $fn: 'sum', $field: 'amount', $as: 'sum_amount' }, 'currency']
169
+ ```
170
+
171
+ When no alias is given, one is auto-generated as `{fn}_{field}` (with `*` becoming `star`).
172
+
173
+ Supported functions: `sum`, `count`, `avg`, `min`, `max`, plus any custom function name — consumers validate supported functions.
174
+
175
+ When aggregates are present, `$select` always uses the array form (even if `-` prefixed fields are mixed in).
176
+
177
+ ### Grouping (`$groupBy`)
178
+
179
+ `$groupBy` declares which fields to group by. Comma-separated:
180
+
181
+ ```
182
+ $groupBy=currency → ['currency']
183
+ $groupBy=currency,region → ['currency', 'region']
184
+ ```
185
+
186
+ #### Aggregation Example
187
+
188
+ ```
189
+ $select=sum(amount):total,count(*),currency&$groupBy=currency&$sort=-total&$limit=10
190
+ ```
191
+
192
+ Produces:
193
+
194
+ ```ts
195
+ {
196
+ controls: {
197
+ $select: [
198
+ 'currency',
199
+ { $fn: 'sum', $field: 'amount', $as: 'total' },
200
+ { $fn: 'count', $field: '*', $as: 'count_star' },
201
+ ],
202
+ $groupBy: ['currency'],
203
+ $sort: { total: -1 },
204
+ $limit: 10,
205
+ },
206
+ insights: Map {
207
+ 'amount' => Set { 'sum' },
208
+ '*' => Set { 'count' },
209
+ 'currency' => Set { '$select', '$groupBy' },
210
+ 'total' => Set { '$order' },
211
+ },
212
+ }
213
+ ```
214
+
215
+ Insights track aggregate functions with bare names (`'sum'`, `'count'`), distinct from `$`-prefixed control ops (`'$select'`, `'$groupBy'`).
216
+
217
+ Aggregates and `$groupBy` work inside `$with` sub-queries too:
218
+
219
+ ```
220
+ $with=orders($select=sum(total):revenue&$groupBy=status)
221
+ ```
222
+
159
223
  ### Relation Loading (`$with`)
160
224
 
161
225
  `$with` declares which relations to populate alongside the primary query. Relations are comma-separated:
@@ -279,7 +343,7 @@ Uniqu parses and types the `$with` declaration. The consumer (e.g. a database ad
279
343
 
280
344
  ## Insights
281
345
 
282
- Insights are computed **eagerly** during URL parsing — a `Map<string, Set<InsightOp>>` recording which fields are used and with which operators. This includes both filter operators and control usage (`$select`, `$order`).
346
+ Insights are computed **eagerly** during URL parsing — a `Map<string, Set<InsightOp>>` recording which fields are used and with which operators. This includes filter operators, control usage (`$select`, `$order`, `$groupBy`), and aggregate functions (`sum`, `avg`, etc. — bare names without `$` prefix).
283
347
 
284
348
  For queries constructed as JSON objects (not parsed from URL), use `computeInsights()` from `@uniqu/core` for **lazy** computation.
285
349
 
@@ -340,12 +404,76 @@ Produces:
340
404
  }
341
405
  ```
342
406
 
407
+ ## URL Builder
408
+
409
+ Build URL query strings from `Uniquery` objects — the inverse of `parseUrl`. Available as a separate entry point for optimal bundle size:
410
+
411
+ ```ts
412
+ import { buildUrl } from '@uniqu/url/builder'
413
+ ```
414
+
415
+ ### Usage
416
+
417
+ ```ts
418
+ const url = buildUrl({
419
+ filter: { status: 'active', age: { $gte: 18 } },
420
+ controls: {
421
+ $select: ['name', 'email'],
422
+ $sort: { createdAt: -1 },
423
+ $limit: 20,
424
+ },
425
+ })
426
+ // → "status=active&age>=18&$select=name,email&$sort=-createdAt&$limit=20"
427
+ ```
428
+
429
+ ### `buildUrl(query: Uniquery): string`
430
+
431
+ Accepts a `Uniquery` object and returns a URL query string (without leading `?`).
432
+
433
+ All features are supported:
434
+ - Filter expressions (comparisons, `$and`/`$or`/`$not`, `$in`/`$nin`, `$exists`, `$regex`)
435
+ - Controls (`$select`, `$sort`, `$limit`, `$skip`, `$count`, `$groupBy`, `$with`)
436
+ - Aggregates in `$select` (`sum(amount):total`)
437
+ - Nested `$with` sub-queries
438
+ - Pass-through custom `$`-prefixed controls
439
+
440
+ ### Value serialization
441
+
442
+ - Strings that look like numbers, booleans, or `null` are automatically quoted (`'25'`, `'true'`, `'null'`)
443
+ - Strings with special characters (`&`, `^`, `=`, spaces, quotes) are quoted and escaped
444
+ - Leading-zero numbers (`007`) stay as bare strings
445
+ - `Date` values are serialized as quoted ISO strings
446
+ - `RegExp` values are serialized as `/pattern/flags`
447
+
448
+ ### Round-tripping
449
+
450
+ `buildUrl` produces output compatible with `parseUrl`:
451
+
452
+ ```ts
453
+ import { parseUrl } from '@uniqu/url'
454
+ import { buildUrl } from '@uniqu/url/builder'
455
+
456
+ const query = { filter: { age: { $gte: 18 } }, controls: { $limit: 10 } }
457
+ const url = buildUrl(query)
458
+ const parsed = parseUrl(url)
459
+ // parsed.filter → { age: { $gte: 18 } }
460
+ // parsed.controls.$limit → 10
461
+ ```
462
+
463
+ ### Bundle optimization
464
+
465
+ The builder is a separate entry point (`@uniqu/url/builder`) so that apps using only `buildUrl` don't pull in the lexer and parser code, and vice versa.
466
+
343
467
  ## API Reference
344
468
 
345
469
  ### `parseUrl(raw: string): UrlQuery`
346
470
 
347
471
  Parse a URL query string (without the leading `?`) into the uniqu format.
348
472
 
473
+ ### `buildUrl(query: Uniquery): string`
474
+
475
+ Build a URL query string from a `Uniquery` object. Imported from `@uniqu/url/builder`.
476
+
349
477
  ### `UrlQuery`
350
478
 
351
479
  ```ts
@@ -0,0 +1,181 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ let _uniqu_core = require("@uniqu/core");
3
+
4
+ //#region packages/url/src/builder.ts
5
+ /**
6
+ * Build a URL query string from a Uniquery object.
7
+ * Produces output compatible with `parseUrl` from `@uniqu/url`.
8
+ *
9
+ * @param query - The canonical query to serialize
10
+ * @returns URL query string without leading "?"
11
+ */ function buildUrl(query) {
12
+ const filterStr = query.filter ? serializeFilter(query.filter) : "";
13
+ const controlStr = query.controls ? serializeControls(query.controls) : "";
14
+ if (filterStr && controlStr) return filterStr + "&" + controlStr;
15
+ return filterStr || controlStr;
16
+ }
17
+ function serializeFilter(expr) {
18
+ if ("$and" in expr && expr.$and !== void 0) {
19
+ let result = "";
20
+ for (const child of expr.$and) {
21
+ const s = serializeFilter(child);
22
+ if (s) result = result ? result + "&" + s : s;
23
+ }
24
+ return result;
25
+ }
26
+ if ("$or" in expr && expr.$or !== void 0) {
27
+ let result = "";
28
+ for (const child of expr.$or) {
29
+ const s = serializeFilter(child);
30
+ if (s) result = result ? result + "^" + s : s;
31
+ }
32
+ return result;
33
+ }
34
+ if ("$not" in expr && expr.$not !== void 0) {
35
+ const inner = serializeFilter(expr.$not);
36
+ return inner ? `!(${inner})` : "";
37
+ }
38
+ let result = "";
39
+ for (const [field, value] of Object.entries(expr)) if ((0, _uniqu_core.isPrimitive)(value)) {
40
+ const part = `${field}=${serializeValue(value)}`;
41
+ result = result ? result + "&" + part : part;
42
+ } else for (const [op, opValue] of Object.entries(value)) {
43
+ const part = serializeComparison(field, op, opValue);
44
+ result = result ? result + "&" + part : part;
45
+ }
46
+ return result;
47
+ }
48
+ function serializeComparison(field, op, value) {
49
+ switch (op) {
50
+ case "$eq": return `${field}=${serializeValue(value)}`;
51
+ case "$ne": return `${field}!=${serializeValue(value)}`;
52
+ case "$gt": return `${field}>${serializeValue(value)}`;
53
+ case "$gte": return `${field}>=${serializeValue(value)}`;
54
+ case "$lt": return `${field}<${serializeValue(value)}`;
55
+ case "$lte": return `${field}<=${serializeValue(value)}`;
56
+ case "$regex": return `${field}~=${serializeValue(value)}`;
57
+ case "$in": {
58
+ let list = "";
59
+ for (const item of value) {
60
+ const s = serializeValue(item);
61
+ list = list ? list + "," + s : s;
62
+ }
63
+ return `${field}{${list}}`;
64
+ }
65
+ case "$nin": {
66
+ let list = "";
67
+ for (const item of value) {
68
+ const s = serializeValue(item);
69
+ list = list ? list + "," + s : s;
70
+ }
71
+ return `${field}!{${list}}`;
72
+ }
73
+ case "$exists": return value ? `$exists=${field}` : `$!exists=${field}`;
74
+ default: return `${field}${op}${serializeValue(value)}`;
75
+ }
76
+ }
77
+ function serializeValue(value) {
78
+ if (value === null) return "null";
79
+ if (value === true) return "true";
80
+ if (value === false) return "false";
81
+ if (typeof value === "number") return String(value);
82
+ if (value instanceof RegExp) return value.toString();
83
+ if (value instanceof Date) return `'${value.toISOString()}'`;
84
+ const str = String(value);
85
+ if (str === "null" || str === "true" || str === "false") return `'${str}'`;
86
+ if (/^\d/.test(str) && !isNaN(Number(str)) && !str.startsWith("0")) return `'${str}'`;
87
+ if (str.startsWith("/") && /\/[gimsuy]*$/.test(str)) return str;
88
+ if (/[&^=!<>~(){},\s'\\]/.test(str)) return `'${str.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
89
+ return str;
90
+ }
91
+ const KNOWN_CONTROL_KEYS = new Set([
92
+ "$select",
93
+ "$groupBy",
94
+ "$having",
95
+ "$sort",
96
+ "$limit",
97
+ "$skip",
98
+ "$count",
99
+ "$with"
100
+ ]);
101
+ function serializeControls(controls) {
102
+ let result = "";
103
+ if (controls.$select) {
104
+ let seg = "";
105
+ if (Array.isArray(controls.$select)) for (const entry of controls.$select) {
106
+ let s;
107
+ if (typeof entry === "string") s = entry;
108
+ else {
109
+ const agg = entry;
110
+ s = agg.$as ? `${agg.$fn}(${agg.$field}):${agg.$as}` : `${agg.$fn}(${agg.$field})`;
111
+ }
112
+ seg = seg ? seg + "," + s : s;
113
+ }
114
+ else for (const [field, val] of Object.entries(controls.$select)) {
115
+ const s = val === 0 ? `-${field}` : field;
116
+ seg = seg ? seg + "," + s : s;
117
+ }
118
+ if (seg) result = `$select=${seg}`;
119
+ }
120
+ if (controls.$groupBy?.length) {
121
+ let seg = "";
122
+ for (const field of controls.$groupBy) seg = seg ? seg + "," + field : field;
123
+ const part = `$groupBy=${seg}`;
124
+ result = result ? result + "&" + part : part;
125
+ }
126
+ if (controls.$having) {
127
+ const havingStr = serializeFilter(controls.$having);
128
+ if (havingStr) {
129
+ const part = "$and" in controls.$having ? `$having=(${havingStr})` : `$having=${havingStr}`;
130
+ result = result ? result + "&" + part : part;
131
+ }
132
+ }
133
+ if (controls.$sort) {
134
+ let seg = "";
135
+ for (const [field, dir] of Object.entries(controls.$sort)) {
136
+ const s = dir === -1 ? `-${field}` : field;
137
+ seg = seg ? seg + "," + s : s;
138
+ }
139
+ if (seg) {
140
+ const part = `$sort=${seg}`;
141
+ result = result ? result + "&" + part : part;
142
+ }
143
+ }
144
+ if (controls.$limit !== void 0) {
145
+ const part = `$limit=${controls.$limit}`;
146
+ result = result ? result + "&" + part : part;
147
+ }
148
+ if (controls.$skip !== void 0) {
149
+ const part = `$skip=${controls.$skip}`;
150
+ result = result ? result + "&" + part : part;
151
+ }
152
+ if (controls.$count) result = result ? result + "&$count" : "$count";
153
+ if (controls.$with) {
154
+ let seg = "";
155
+ for (const entry of controls.$with) {
156
+ let s;
157
+ if (typeof entry === "string") s = entry;
158
+ else {
159
+ const rel = entry;
160
+ const inner = buildUrl({
161
+ filter: rel.filter,
162
+ controls: rel.controls
163
+ });
164
+ s = inner ? `${rel.name}(${inner})` : rel.name;
165
+ }
166
+ seg = seg ? seg + "," + s : s;
167
+ }
168
+ if (seg) {
169
+ const part = `$with=${seg}`;
170
+ result = result ? result + "&" + part : part;
171
+ }
172
+ }
173
+ for (const [key, value] of Object.entries(controls)) if (key.startsWith("$") && !KNOWN_CONTROL_KEYS.has(key)) {
174
+ const part = value !== void 0 && value !== "" ? `${key}=${value}` : key;
175
+ result = result ? result + "&" + part : part;
176
+ }
177
+ return result;
178
+ }
179
+
180
+ //#endregion
181
+ exports.buildUrl = buildUrl;
@@ -0,0 +1,12 @@
1
+ import { Uniquery } from '@uniqu/core';
2
+
3
+ /**
4
+ * Build a URL query string from a Uniquery object.
5
+ * Produces output compatible with `parseUrl` from `@uniqu/url`.
6
+ *
7
+ * @param query - The canonical query to serialize
8
+ * @returns URL query string without leading "?"
9
+ */
10
+ declare function buildUrl(query: Uniquery): string;
11
+
12
+ export { buildUrl };
@@ -0,0 +1,180 @@
1
+ import { isPrimitive } from "@uniqu/core";
2
+
3
+ //#region packages/url/src/builder.ts
4
+ /**
5
+ * Build a URL query string from a Uniquery object.
6
+ * Produces output compatible with `parseUrl` from `@uniqu/url`.
7
+ *
8
+ * @param query - The canonical query to serialize
9
+ * @returns URL query string without leading "?"
10
+ */ function buildUrl(query) {
11
+ const filterStr = query.filter ? serializeFilter(query.filter) : "";
12
+ const controlStr = query.controls ? serializeControls(query.controls) : "";
13
+ if (filterStr && controlStr) return filterStr + "&" + controlStr;
14
+ return filterStr || controlStr;
15
+ }
16
+ function serializeFilter(expr) {
17
+ if ("$and" in expr && expr.$and !== void 0) {
18
+ let result = "";
19
+ for (const child of expr.$and) {
20
+ const s = serializeFilter(child);
21
+ if (s) result = result ? result + "&" + s : s;
22
+ }
23
+ return result;
24
+ }
25
+ if ("$or" in expr && expr.$or !== void 0) {
26
+ let result = "";
27
+ for (const child of expr.$or) {
28
+ const s = serializeFilter(child);
29
+ if (s) result = result ? result + "^" + s : s;
30
+ }
31
+ return result;
32
+ }
33
+ if ("$not" in expr && expr.$not !== void 0) {
34
+ const inner = serializeFilter(expr.$not);
35
+ return inner ? `!(${inner})` : "";
36
+ }
37
+ let result = "";
38
+ for (const [field, value] of Object.entries(expr)) if (isPrimitive(value)) {
39
+ const part = `${field}=${serializeValue(value)}`;
40
+ result = result ? result + "&" + part : part;
41
+ } else for (const [op, opValue] of Object.entries(value)) {
42
+ const part = serializeComparison(field, op, opValue);
43
+ result = result ? result + "&" + part : part;
44
+ }
45
+ return result;
46
+ }
47
+ function serializeComparison(field, op, value) {
48
+ switch (op) {
49
+ case "$eq": return `${field}=${serializeValue(value)}`;
50
+ case "$ne": return `${field}!=${serializeValue(value)}`;
51
+ case "$gt": return `${field}>${serializeValue(value)}`;
52
+ case "$gte": return `${field}>=${serializeValue(value)}`;
53
+ case "$lt": return `${field}<${serializeValue(value)}`;
54
+ case "$lte": return `${field}<=${serializeValue(value)}`;
55
+ case "$regex": return `${field}~=${serializeValue(value)}`;
56
+ case "$in": {
57
+ let list = "";
58
+ for (const item of value) {
59
+ const s = serializeValue(item);
60
+ list = list ? list + "," + s : s;
61
+ }
62
+ return `${field}{${list}}`;
63
+ }
64
+ case "$nin": {
65
+ let list = "";
66
+ for (const item of value) {
67
+ const s = serializeValue(item);
68
+ list = list ? list + "," + s : s;
69
+ }
70
+ return `${field}!{${list}}`;
71
+ }
72
+ case "$exists": return value ? `$exists=${field}` : `$!exists=${field}`;
73
+ default: return `${field}${op}${serializeValue(value)}`;
74
+ }
75
+ }
76
+ function serializeValue(value) {
77
+ if (value === null) return "null";
78
+ if (value === true) return "true";
79
+ if (value === false) return "false";
80
+ if (typeof value === "number") return String(value);
81
+ if (value instanceof RegExp) return value.toString();
82
+ if (value instanceof Date) return `'${value.toISOString()}'`;
83
+ const str = String(value);
84
+ if (str === "null" || str === "true" || str === "false") return `'${str}'`;
85
+ if (/^\d/.test(str) && !isNaN(Number(str)) && !str.startsWith("0")) return `'${str}'`;
86
+ if (str.startsWith("/") && /\/[gimsuy]*$/.test(str)) return str;
87
+ if (/[&^=!<>~(){},\s'\\]/.test(str)) return `'${str.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
88
+ return str;
89
+ }
90
+ const KNOWN_CONTROL_KEYS = new Set([
91
+ "$select",
92
+ "$groupBy",
93
+ "$having",
94
+ "$sort",
95
+ "$limit",
96
+ "$skip",
97
+ "$count",
98
+ "$with"
99
+ ]);
100
+ function serializeControls(controls) {
101
+ let result = "";
102
+ if (controls.$select) {
103
+ let seg = "";
104
+ if (Array.isArray(controls.$select)) for (const entry of controls.$select) {
105
+ let s;
106
+ if (typeof entry === "string") s = entry;
107
+ else {
108
+ const agg = entry;
109
+ s = agg.$as ? `${agg.$fn}(${agg.$field}):${agg.$as}` : `${agg.$fn}(${agg.$field})`;
110
+ }
111
+ seg = seg ? seg + "," + s : s;
112
+ }
113
+ else for (const [field, val] of Object.entries(controls.$select)) {
114
+ const s = val === 0 ? `-${field}` : field;
115
+ seg = seg ? seg + "," + s : s;
116
+ }
117
+ if (seg) result = `$select=${seg}`;
118
+ }
119
+ if (controls.$groupBy?.length) {
120
+ let seg = "";
121
+ for (const field of controls.$groupBy) seg = seg ? seg + "," + field : field;
122
+ const part = `$groupBy=${seg}`;
123
+ result = result ? result + "&" + part : part;
124
+ }
125
+ if (controls.$having) {
126
+ const havingStr = serializeFilter(controls.$having);
127
+ if (havingStr) {
128
+ const part = "$and" in controls.$having ? `$having=(${havingStr})` : `$having=${havingStr}`;
129
+ result = result ? result + "&" + part : part;
130
+ }
131
+ }
132
+ if (controls.$sort) {
133
+ let seg = "";
134
+ for (const [field, dir] of Object.entries(controls.$sort)) {
135
+ const s = dir === -1 ? `-${field}` : field;
136
+ seg = seg ? seg + "," + s : s;
137
+ }
138
+ if (seg) {
139
+ const part = `$sort=${seg}`;
140
+ result = result ? result + "&" + part : part;
141
+ }
142
+ }
143
+ if (controls.$limit !== void 0) {
144
+ const part = `$limit=${controls.$limit}`;
145
+ result = result ? result + "&" + part : part;
146
+ }
147
+ if (controls.$skip !== void 0) {
148
+ const part = `$skip=${controls.$skip}`;
149
+ result = result ? result + "&" + part : part;
150
+ }
151
+ if (controls.$count) result = result ? result + "&$count" : "$count";
152
+ if (controls.$with) {
153
+ let seg = "";
154
+ for (const entry of controls.$with) {
155
+ let s;
156
+ if (typeof entry === "string") s = entry;
157
+ else {
158
+ const rel = entry;
159
+ const inner = buildUrl({
160
+ filter: rel.filter,
161
+ controls: rel.controls
162
+ });
163
+ s = inner ? `${rel.name}(${inner})` : rel.name;
164
+ }
165
+ seg = seg ? seg + "," + s : s;
166
+ }
167
+ if (seg) {
168
+ const part = `$with=${seg}`;
169
+ result = result ? result + "&" + part : part;
170
+ }
171
+ }
172
+ for (const [key, value] of Object.entries(controls)) if (key.startsWith("$") && !KNOWN_CONTROL_KEYS.has(key)) {
173
+ const part = value !== void 0 && value !== "" ? `${key}=${value}` : key;
174
+ result = result ? result + "&" + part : part;
175
+ }
176
+ return result;
177
+ }
178
+
179
+ //#endregion
180
+ export { buildUrl };
package/dist/index.cjs CHANGED
@@ -289,7 +289,7 @@ var Parser = class {
289
289
  }
290
290
  };
291
291
  function unescapeString(str) {
292
- return str.replace(/(^'|'$)/gu, "");
292
+ return str.replace(/(^'|'$)/gu, "").replace(/\\(.)/gu, "$1");
293
293
  }
294
294
  /**
295
295
  * Attempt to merge an array of simple nodes produced by `parseTerm`.
@@ -305,14 +305,15 @@ function unescapeString(str) {
305
305
  for (const [key, val] of Object.entries(node)) if (key in currentMerge) {
306
306
  const currentVal = currentMerge[key];
307
307
  const currentOps = (0, _uniqu_core.isPrimitive)(currentVal) ? ["$eq"] : Object.keys(currentVal);
308
- const otherOps = (0, _uniqu_core.isPrimitive)(val) ? /* @__PURE__ */ new Set("$eq") : new Set(Object.keys(val));
308
+ const otherOps = (0, _uniqu_core.isPrimitive)(val) ? new Set(["$eq"]) : new Set(Object.keys(val));
309
309
  if (currentOps.some((op) => otherOps.has(op))) {
310
310
  merged.push(currentMerge);
311
311
  currentMerge = {};
312
312
  } else {
313
- currentMerge[key] = {};
314
- for (const op of currentOps) currentMerge[key][op] = (0, _uniqu_core.isPrimitive)(currentVal) ? currentVal : currentVal[op];
315
- for (const op of Array.from(otherOps)) currentMerge[key][op] = (0, _uniqu_core.isPrimitive)(val) ? val : val[op];
313
+ const m = {};
314
+ for (const op of currentOps) m[op] = (0, _uniqu_core.isPrimitive)(currentVal) ? currentVal : currentVal[op];
315
+ for (const op of otherOps) m[op] = (0, _uniqu_core.isPrimitive)(val) ? val : val[op];
316
+ currentMerge[key] = m;
316
317
  }
317
318
  } else currentMerge[key] = val;
318
319
  }
@@ -350,9 +351,9 @@ function buildExists(fields, positive) {
350
351
  let filter = {};
351
352
  let parser;
352
353
  if (exprParts.length) {
353
- parser = new Parser(lex(exprParts.join("&")));
354
- filter = parser.parseExpression();
355
- parser.expectEof();
354
+ const parsed = parseFilterExpr(exprParts.join("&"));
355
+ parser = parsed.parser;
356
+ filter = parsed.expr;
356
357
  } else parser = new Parser([]);
357
358
  for (const [field, op] of controlInsights) parser.captureInsight(field, op);
358
359
  return {
@@ -374,6 +375,15 @@ function buildExists(fields, positive) {
374
375
  parts.push(str.slice(start));
375
376
  return parts;
376
377
  }
378
+ /** Lex + parse a raw filter expression string. */ function parseFilterExpr(raw) {
379
+ const parser = new Parser(lex(raw));
380
+ const expr = parser.parseExpression();
381
+ parser.expectEof();
382
+ return {
383
+ expr,
384
+ parser
385
+ };
386
+ }
377
387
  /** Parse a single `$with` segment like `posts` or `posts($sort=-createdAt&status=active)`. */ function parseWithSegment(seg) {
378
388
  if (!seg) return null;
379
389
  const parenIdx = seg.indexOf("(");
@@ -403,14 +413,16 @@ function handleControls(parts) {
403
413
  const controls = {};
404
414
  const controlInsights = [];
405
415
  for (const raw of parts) {
406
- const [key, ...rest] = raw.split("=");
407
- const value = rest.join("=");
416
+ const eqIdx = raw.indexOf("=");
417
+ const key = eqIdx === -1 ? raw : raw.slice(0, eqIdx);
418
+ const value = eqIdx === -1 ? "" : raw.slice(eqIdx + 1);
408
419
  switch (key) {
409
420
  case "$with": {
410
421
  var _controls;
411
422
  if (!value) break;
412
423
  (_controls = controls).$with ?? (_controls.$with = []);
413
- const seen = new Set(controls.$with.map((r) => r.name));
424
+ const seen = /* @__PURE__ */ new Set();
425
+ for (const r of controls.$with) seen.add(typeof r === "string" ? r : r.name);
414
426
  for (const seg of splitTopLevel(value, ",")) {
415
427
  const rel = parseWithSegment(seg);
416
428
  if (!rel || seen.has(rel.name)) continue;
@@ -425,35 +437,50 @@ function handleControls(parts) {
425
437
  break;
426
438
  }
427
439
  case "$select": {
440
+ const items = value.split(",");
428
441
  let hasExclusion = false;
429
- const fields = [];
430
- value.split(",").forEach((f) => {
431
- if (!f) return;
432
- if (f.startsWith("-")) {
433
- hasExclusion = true;
434
- fields.push({
435
- name: f.slice(1),
436
- include: false
442
+ let hasAggregate = false;
443
+ for (const f of items) {
444
+ if (!f) continue;
445
+ if (f.startsWith("-")) hasExclusion = true;
446
+ else if (/^\w+\(/.test(f)) hasAggregate = true;
447
+ }
448
+ if (hasAggregate || !hasExclusion) {
449
+ const arr = Array.isArray(controls.$select) ? controls.$select : [];
450
+ for (const f of items) {
451
+ if (!f || /^\w+\(/.test(f)) continue;
452
+ arr.push(f);
453
+ controlInsights.push([f, "$select"]);
454
+ }
455
+ for (const f of items) {
456
+ if (!f) continue;
457
+ const aggMatch = /^(\w+)\((\*|[\w.]+)\)(?::([\w.]+))?$/.exec(f);
458
+ if (!aggMatch) continue;
459
+ const fn = aggMatch[1];
460
+ const field = aggMatch[2];
461
+ const alias = aggMatch[3] ?? (field === "*" ? `${fn}_star` : `${fn}_${field}`);
462
+ arr.push({
463
+ $fn: fn,
464
+ $field: field,
465
+ $as: alias
437
466
  });
438
- } else fields.push({
439
- name: f,
440
- include: true
441
- });
442
- });
443
- if (hasExclusion) {
444
- const obj = controls.$select ?? {};
445
- for (const { name, include } of fields) {
446
- obj[name] = include ? 1 : 0;
447
- controlInsights.push([name, "$select"]);
467
+ controlInsights.push([field, fn]);
448
468
  }
449
- controls.$select = obj;
469
+ controls.$select = arr;
450
470
  } else {
451
- const arr = Array.isArray(controls.$select) ? controls.$select : [];
452
- for (const { name } of fields) {
453
- arr.push(name);
454
- controlInsights.push([name, "$select"]);
471
+ const obj = controls.$select ?? {};
472
+ for (const f of items) {
473
+ if (!f) continue;
474
+ if (f.startsWith("-")) {
475
+ const name = f.slice(1);
476
+ obj[name] = 0;
477
+ controlInsights.push([name, "$select"]);
478
+ } else {
479
+ obj[f] = 1;
480
+ controlInsights.push([f, "$select"]);
481
+ }
455
482
  }
456
- controls.$select = arr;
483
+ controls.$select = obj;
457
484
  }
458
485
  break;
459
486
  }
@@ -461,13 +488,36 @@ function handleControls(parts) {
461
488
  case "$order":
462
489
  var _controls1;
463
490
  (_controls1 = controls).$sort ?? (_controls1.$sort = {});
464
- value.split(",").forEach((f) => {
465
- if (!f) return;
466
- controlInsights.push([f.replace(/^-/, ""), "$order"]);
467
- if (f.startsWith("-")) controls.$sort[f.slice(1)] = -1;
468
- else controls.$sort[f] = 1;
469
- });
491
+ for (const f of value.split(",")) {
492
+ if (!f) continue;
493
+ if (f.startsWith("-")) {
494
+ const name = f.slice(1);
495
+ controls.$sort[name] = -1;
496
+ controlInsights.push([name, "$order"]);
497
+ } else {
498
+ controls.$sort[f] = 1;
499
+ controlInsights.push([f, "$order"]);
500
+ }
501
+ }
470
502
  break;
503
+ case "$groupBy":
504
+ var _controls2;
505
+ if (!value) break;
506
+ (_controls2 = controls).$groupBy ?? (_controls2.$groupBy = []);
507
+ for (const f of value.split(",")) {
508
+ if (!f) continue;
509
+ controls.$groupBy.push(f);
510
+ controlInsights.push([f, "$groupBy"]);
511
+ }
512
+ break;
513
+ case "$having": {
514
+ if (!value) break;
515
+ const { expr, parser: hp } = parseFilterExpr(value);
516
+ if (controls.$having) controls.$having = { $and: [controls.$having, expr] };
517
+ else controls.$having = expr;
518
+ for (const [field] of hp.getInsights()) controlInsights.push([field, "$having"]);
519
+ break;
520
+ }
471
521
  case "$limit":
472
522
  case "$top":
473
523
  controls.$limit = Number(value);
package/dist/index.mjs CHANGED
@@ -288,7 +288,7 @@ var Parser = class {
288
288
  }
289
289
  };
290
290
  function unescapeString(str) {
291
- return str.replace(/(^'|'$)/gu, "");
291
+ return str.replace(/(^'|'$)/gu, "").replace(/\\(.)/gu, "$1");
292
292
  }
293
293
  /**
294
294
  * Attempt to merge an array of simple nodes produced by `parseTerm`.
@@ -304,14 +304,15 @@ function unescapeString(str) {
304
304
  for (const [key, val] of Object.entries(node)) if (key in currentMerge) {
305
305
  const currentVal = currentMerge[key];
306
306
  const currentOps = isPrimitive(currentVal) ? ["$eq"] : Object.keys(currentVal);
307
- const otherOps = isPrimitive(val) ? /* @__PURE__ */ new Set("$eq") : new Set(Object.keys(val));
307
+ const otherOps = isPrimitive(val) ? new Set(["$eq"]) : new Set(Object.keys(val));
308
308
  if (currentOps.some((op) => otherOps.has(op))) {
309
309
  merged.push(currentMerge);
310
310
  currentMerge = {};
311
311
  } else {
312
- currentMerge[key] = {};
313
- for (const op of currentOps) currentMerge[key][op] = isPrimitive(currentVal) ? currentVal : currentVal[op];
314
- for (const op of Array.from(otherOps)) currentMerge[key][op] = isPrimitive(val) ? val : val[op];
312
+ const m = {};
313
+ for (const op of currentOps) m[op] = isPrimitive(currentVal) ? currentVal : currentVal[op];
314
+ for (const op of otherOps) m[op] = isPrimitive(val) ? val : val[op];
315
+ currentMerge[key] = m;
315
316
  }
316
317
  } else currentMerge[key] = val;
317
318
  }
@@ -349,9 +350,9 @@ function buildExists(fields, positive) {
349
350
  let filter = {};
350
351
  let parser;
351
352
  if (exprParts.length) {
352
- parser = new Parser(lex(exprParts.join("&")));
353
- filter = parser.parseExpression();
354
- parser.expectEof();
353
+ const parsed = parseFilterExpr(exprParts.join("&"));
354
+ parser = parsed.parser;
355
+ filter = parsed.expr;
355
356
  } else parser = new Parser([]);
356
357
  for (const [field, op] of controlInsights) parser.captureInsight(field, op);
357
358
  return {
@@ -373,6 +374,15 @@ function buildExists(fields, positive) {
373
374
  parts.push(str.slice(start));
374
375
  return parts;
375
376
  }
377
+ /** Lex + parse a raw filter expression string. */ function parseFilterExpr(raw) {
378
+ const parser = new Parser(lex(raw));
379
+ const expr = parser.parseExpression();
380
+ parser.expectEof();
381
+ return {
382
+ expr,
383
+ parser
384
+ };
385
+ }
376
386
  /** Parse a single `$with` segment like `posts` or `posts($sort=-createdAt&status=active)`. */ function parseWithSegment(seg) {
377
387
  if (!seg) return null;
378
388
  const parenIdx = seg.indexOf("(");
@@ -402,14 +412,16 @@ function handleControls(parts) {
402
412
  const controls = {};
403
413
  const controlInsights = [];
404
414
  for (const raw of parts) {
405
- const [key, ...rest] = raw.split("=");
406
- const value = rest.join("=");
415
+ const eqIdx = raw.indexOf("=");
416
+ const key = eqIdx === -1 ? raw : raw.slice(0, eqIdx);
417
+ const value = eqIdx === -1 ? "" : raw.slice(eqIdx + 1);
407
418
  switch (key) {
408
419
  case "$with": {
409
420
  var _controls;
410
421
  if (!value) break;
411
422
  (_controls = controls).$with ?? (_controls.$with = []);
412
- const seen = new Set(controls.$with.map((r) => r.name));
423
+ const seen = /* @__PURE__ */ new Set();
424
+ for (const r of controls.$with) seen.add(typeof r === "string" ? r : r.name);
413
425
  for (const seg of splitTopLevel(value, ",")) {
414
426
  const rel = parseWithSegment(seg);
415
427
  if (!rel || seen.has(rel.name)) continue;
@@ -424,35 +436,50 @@ function handleControls(parts) {
424
436
  break;
425
437
  }
426
438
  case "$select": {
439
+ const items = value.split(",");
427
440
  let hasExclusion = false;
428
- const fields = [];
429
- value.split(",").forEach((f) => {
430
- if (!f) return;
431
- if (f.startsWith("-")) {
432
- hasExclusion = true;
433
- fields.push({
434
- name: f.slice(1),
435
- include: false
441
+ let hasAggregate = false;
442
+ for (const f of items) {
443
+ if (!f) continue;
444
+ if (f.startsWith("-")) hasExclusion = true;
445
+ else if (/^\w+\(/.test(f)) hasAggregate = true;
446
+ }
447
+ if (hasAggregate || !hasExclusion) {
448
+ const arr = Array.isArray(controls.$select) ? controls.$select : [];
449
+ for (const f of items) {
450
+ if (!f || /^\w+\(/.test(f)) continue;
451
+ arr.push(f);
452
+ controlInsights.push([f, "$select"]);
453
+ }
454
+ for (const f of items) {
455
+ if (!f) continue;
456
+ const aggMatch = /^(\w+)\((\*|[\w.]+)\)(?::([\w.]+))?$/.exec(f);
457
+ if (!aggMatch) continue;
458
+ const fn = aggMatch[1];
459
+ const field = aggMatch[2];
460
+ const alias = aggMatch[3] ?? (field === "*" ? `${fn}_star` : `${fn}_${field}`);
461
+ arr.push({
462
+ $fn: fn,
463
+ $field: field,
464
+ $as: alias
436
465
  });
437
- } else fields.push({
438
- name: f,
439
- include: true
440
- });
441
- });
442
- if (hasExclusion) {
443
- const obj = controls.$select ?? {};
444
- for (const { name, include } of fields) {
445
- obj[name] = include ? 1 : 0;
446
- controlInsights.push([name, "$select"]);
466
+ controlInsights.push([field, fn]);
447
467
  }
448
- controls.$select = obj;
468
+ controls.$select = arr;
449
469
  } else {
450
- const arr = Array.isArray(controls.$select) ? controls.$select : [];
451
- for (const { name } of fields) {
452
- arr.push(name);
453
- controlInsights.push([name, "$select"]);
470
+ const obj = controls.$select ?? {};
471
+ for (const f of items) {
472
+ if (!f) continue;
473
+ if (f.startsWith("-")) {
474
+ const name = f.slice(1);
475
+ obj[name] = 0;
476
+ controlInsights.push([name, "$select"]);
477
+ } else {
478
+ obj[f] = 1;
479
+ controlInsights.push([f, "$select"]);
480
+ }
454
481
  }
455
- controls.$select = arr;
482
+ controls.$select = obj;
456
483
  }
457
484
  break;
458
485
  }
@@ -460,13 +487,36 @@ function handleControls(parts) {
460
487
  case "$order":
461
488
  var _controls1;
462
489
  (_controls1 = controls).$sort ?? (_controls1.$sort = {});
463
- value.split(",").forEach((f) => {
464
- if (!f) return;
465
- controlInsights.push([f.replace(/^-/, ""), "$order"]);
466
- if (f.startsWith("-")) controls.$sort[f.slice(1)] = -1;
467
- else controls.$sort[f] = 1;
468
- });
490
+ for (const f of value.split(",")) {
491
+ if (!f) continue;
492
+ if (f.startsWith("-")) {
493
+ const name = f.slice(1);
494
+ controls.$sort[name] = -1;
495
+ controlInsights.push([name, "$order"]);
496
+ } else {
497
+ controls.$sort[f] = 1;
498
+ controlInsights.push([f, "$order"]);
499
+ }
500
+ }
469
501
  break;
502
+ case "$groupBy":
503
+ var _controls2;
504
+ if (!value) break;
505
+ (_controls2 = controls).$groupBy ?? (_controls2.$groupBy = []);
506
+ for (const f of value.split(",")) {
507
+ if (!f) continue;
508
+ controls.$groupBy.push(f);
509
+ controlInsights.push([f, "$groupBy"]);
510
+ }
511
+ break;
512
+ case "$having": {
513
+ if (!value) break;
514
+ const { expr, parser: hp } = parseFilterExpr(value);
515
+ if (controls.$having) controls.$having = { $and: [controls.$having, expr] };
516
+ else controls.$having = expr;
517
+ for (const [field] of hp.getInsights()) controlInsights.push([field, "$having"]);
518
+ break;
519
+ }
470
520
  case "$limit":
471
521
  case "$top":
472
522
  controls.$limit = Number(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqu/url",
3
- "version": "0.0.6",
3
+ "version": "0.1.1",
4
4
  "description": "URL query string parser producing the Uniqu canonical query format",
5
5
  "license": "MIT",
6
6
  "author": "Artem Maltsev",
@@ -22,13 +22,30 @@
22
22
  "import": "./dist/index.mjs",
23
23
  "require": "./dist/index.cjs"
24
24
  },
25
+ "./builder": {
26
+ "types": "./dist/builder.d.ts",
27
+ "import": "./dist/builder.mjs",
28
+ "require": "./dist/builder.cjs"
29
+ },
25
30
  "./package.json": "./package.json"
26
31
  },
32
+ "build": [
33
+ {
34
+ "entries": [
35
+ "src/index.ts"
36
+ ]
37
+ },
38
+ {
39
+ "entries": [
40
+ "src/builder.ts"
41
+ ]
42
+ }
43
+ ],
27
44
  "files": [
28
45
  "dist"
29
46
  ],
30
47
  "dependencies": {
31
- "@uniqu/core": "^0.0.6"
48
+ "@uniqu/core": "^0.1.1"
32
49
  },
33
50
  "scripts": {
34
51
  "pub": "pnpm publish --access public",