@uniqu/url 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 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,173 @@
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
+ "$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.$sort) {
126
+ let seg = "";
127
+ for (const [field, dir] of Object.entries(controls.$sort)) {
128
+ const s = dir === -1 ? `-${field}` : field;
129
+ seg = seg ? seg + "," + s : s;
130
+ }
131
+ if (seg) {
132
+ const part = `$sort=${seg}`;
133
+ result = result ? result + "&" + part : part;
134
+ }
135
+ }
136
+ if (controls.$limit !== void 0) {
137
+ const part = `$limit=${controls.$limit}`;
138
+ result = result ? result + "&" + part : part;
139
+ }
140
+ if (controls.$skip !== void 0) {
141
+ const part = `$skip=${controls.$skip}`;
142
+ result = result ? result + "&" + part : part;
143
+ }
144
+ if (controls.$count) result = result ? result + "&$count" : "$count";
145
+ if (controls.$with) {
146
+ let seg = "";
147
+ for (const entry of controls.$with) {
148
+ let s;
149
+ if (typeof entry === "string") s = entry;
150
+ else {
151
+ const rel = entry;
152
+ const inner = buildUrl({
153
+ filter: rel.filter,
154
+ controls: rel.controls
155
+ });
156
+ s = inner ? `${rel.name}(${inner})` : rel.name;
157
+ }
158
+ seg = seg ? seg + "," + s : s;
159
+ }
160
+ if (seg) {
161
+ const part = `$with=${seg}`;
162
+ result = result ? result + "&" + part : part;
163
+ }
164
+ }
165
+ for (const [key, value] of Object.entries(controls)) if (key.startsWith("$") && !KNOWN_CONTROL_KEYS.has(key)) {
166
+ const part = value !== void 0 && value !== "" ? `${key}=${value}` : key;
167
+ result = result ? result + "&" + part : part;
168
+ }
169
+ return result;
170
+ }
171
+
172
+ //#endregion
173
+ 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,172 @@
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
+ "$sort",
94
+ "$limit",
95
+ "$skip",
96
+ "$count",
97
+ "$with"
98
+ ]);
99
+ function serializeControls(controls) {
100
+ let result = "";
101
+ if (controls.$select) {
102
+ let seg = "";
103
+ if (Array.isArray(controls.$select)) for (const entry of controls.$select) {
104
+ let s;
105
+ if (typeof entry === "string") s = entry;
106
+ else {
107
+ const agg = entry;
108
+ s = agg.$as ? `${agg.$fn}(${agg.$field}):${agg.$as}` : `${agg.$fn}(${agg.$field})`;
109
+ }
110
+ seg = seg ? seg + "," + s : s;
111
+ }
112
+ else for (const [field, val] of Object.entries(controls.$select)) {
113
+ const s = val === 0 ? `-${field}` : field;
114
+ seg = seg ? seg + "," + s : s;
115
+ }
116
+ if (seg) result = `$select=${seg}`;
117
+ }
118
+ if (controls.$groupBy?.length) {
119
+ let seg = "";
120
+ for (const field of controls.$groupBy) seg = seg ? seg + "," + field : field;
121
+ const part = `$groupBy=${seg}`;
122
+ result = result ? result + "&" + part : part;
123
+ }
124
+ if (controls.$sort) {
125
+ let seg = "";
126
+ for (const [field, dir] of Object.entries(controls.$sort)) {
127
+ const s = dir === -1 ? `-${field}` : field;
128
+ seg = seg ? seg + "," + s : s;
129
+ }
130
+ if (seg) {
131
+ const part = `$sort=${seg}`;
132
+ result = result ? result + "&" + part : part;
133
+ }
134
+ }
135
+ if (controls.$limit !== void 0) {
136
+ const part = `$limit=${controls.$limit}`;
137
+ result = result ? result + "&" + part : part;
138
+ }
139
+ if (controls.$skip !== void 0) {
140
+ const part = `$skip=${controls.$skip}`;
141
+ result = result ? result + "&" + part : part;
142
+ }
143
+ if (controls.$count) result = result ? result + "&$count" : "$count";
144
+ if (controls.$with) {
145
+ let seg = "";
146
+ for (const entry of controls.$with) {
147
+ let s;
148
+ if (typeof entry === "string") s = entry;
149
+ else {
150
+ const rel = entry;
151
+ const inner = buildUrl({
152
+ filter: rel.filter,
153
+ controls: rel.controls
154
+ });
155
+ s = inner ? `${rel.name}(${inner})` : rel.name;
156
+ }
157
+ seg = seg ? seg + "," + s : s;
158
+ }
159
+ if (seg) {
160
+ const part = `$with=${seg}`;
161
+ result = result ? result + "&" + part : part;
162
+ }
163
+ }
164
+ for (const [key, value] of Object.entries(controls)) if (key.startsWith("$") && !KNOWN_CONTROL_KEYS.has(key)) {
165
+ const part = value !== void 0 && value !== "" ? `${key}=${value}` : key;
166
+ result = result ? result + "&" + part : part;
167
+ }
168
+ return result;
169
+ }
170
+
171
+ //#endregion
172
+ 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,14 @@ 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
313
  currentMerge[key] = {};
314
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];
315
+ for (const op of otherOps) currentMerge[key][op] = (0, _uniqu_core.isPrimitive)(val) ? val : val[op];
316
316
  }
317
317
  } else currentMerge[key] = val;
318
318
  }
@@ -403,14 +403,16 @@ function handleControls(parts) {
403
403
  const controls = {};
404
404
  const controlInsights = [];
405
405
  for (const raw of parts) {
406
- const [key, ...rest] = raw.split("=");
407
- const value = rest.join("=");
406
+ const eqIdx = raw.indexOf("=");
407
+ const key = eqIdx === -1 ? raw : raw.slice(0, eqIdx);
408
+ const value = eqIdx === -1 ? "" : raw.slice(eqIdx + 1);
408
409
  switch (key) {
409
410
  case "$with": {
410
411
  var _controls;
411
412
  if (!value) break;
412
413
  (_controls = controls).$with ?? (_controls.$with = []);
413
- const seen = new Set(controls.$with.map((r) => r.name));
414
+ const seen = /* @__PURE__ */ new Set();
415
+ for (const r of controls.$with) seen.add(typeof r === "string" ? r : r.name);
414
416
  for (const seg of splitTopLevel(value, ",")) {
415
417
  const rel = parseWithSegment(seg);
416
418
  if (!rel || seen.has(rel.name)) continue;
@@ -425,35 +427,50 @@ function handleControls(parts) {
425
427
  break;
426
428
  }
427
429
  case "$select": {
430
+ const items = value.split(",");
428
431
  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
432
+ let hasAggregate = false;
433
+ for (const f of items) {
434
+ if (!f) continue;
435
+ if (f.startsWith("-")) hasExclusion = true;
436
+ else if (/^\w+\(/.test(f)) hasAggregate = true;
437
+ }
438
+ if (hasAggregate || !hasExclusion) {
439
+ const arr = Array.isArray(controls.$select) ? controls.$select : [];
440
+ for (const f of items) {
441
+ if (!f || /^\w+\(/.test(f)) continue;
442
+ arr.push(f);
443
+ controlInsights.push([f, "$select"]);
444
+ }
445
+ for (const f of items) {
446
+ if (!f) continue;
447
+ const aggMatch = /^(\w+)\((\*|[\w.]+)\)(?::([\w.]+))?$/.exec(f);
448
+ if (!aggMatch) continue;
449
+ const fn = aggMatch[1];
450
+ const field = aggMatch[2];
451
+ const alias = aggMatch[3] ?? (field === "*" ? `${fn}_star` : `${fn}_${field}`);
452
+ arr.push({
453
+ $fn: fn,
454
+ $field: field,
455
+ $as: alias
437
456
  });
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"]);
457
+ controlInsights.push([field, fn]);
448
458
  }
449
- controls.$select = obj;
459
+ controls.$select = arr;
450
460
  } 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"]);
461
+ const obj = controls.$select ?? {};
462
+ for (const f of items) {
463
+ if (!f) continue;
464
+ if (f.startsWith("-")) {
465
+ const name = f.slice(1);
466
+ obj[name] = 0;
467
+ controlInsights.push([name, "$select"]);
468
+ } else {
469
+ obj[f] = 1;
470
+ controlInsights.push([f, "$select"]);
471
+ }
455
472
  }
456
- controls.$select = arr;
473
+ controls.$select = obj;
457
474
  }
458
475
  break;
459
476
  }
@@ -461,12 +478,27 @@ function handleControls(parts) {
461
478
  case "$order":
462
479
  var _controls1;
463
480
  (_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
- });
481
+ for (const f of value.split(",")) {
482
+ if (!f) continue;
483
+ if (f.startsWith("-")) {
484
+ const name = f.slice(1);
485
+ controls.$sort[name] = -1;
486
+ controlInsights.push([name, "$order"]);
487
+ } else {
488
+ controls.$sort[f] = 1;
489
+ controlInsights.push([f, "$order"]);
490
+ }
491
+ }
492
+ break;
493
+ case "$groupBy":
494
+ var _controls2;
495
+ if (!value) break;
496
+ (_controls2 = controls).$groupBy ?? (_controls2.$groupBy = []);
497
+ for (const f of value.split(",")) {
498
+ if (!f) continue;
499
+ controls.$groupBy.push(f);
500
+ controlInsights.push([f, "$groupBy"]);
501
+ }
470
502
  break;
471
503
  case "$limit":
472
504
  case "$top":
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,14 @@ 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
312
  currentMerge[key] = {};
313
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];
314
+ for (const op of otherOps) currentMerge[key][op] = isPrimitive(val) ? val : val[op];
315
315
  }
316
316
  } else currentMerge[key] = val;
317
317
  }
@@ -402,14 +402,16 @@ function handleControls(parts) {
402
402
  const controls = {};
403
403
  const controlInsights = [];
404
404
  for (const raw of parts) {
405
- const [key, ...rest] = raw.split("=");
406
- const value = rest.join("=");
405
+ const eqIdx = raw.indexOf("=");
406
+ const key = eqIdx === -1 ? raw : raw.slice(0, eqIdx);
407
+ const value = eqIdx === -1 ? "" : raw.slice(eqIdx + 1);
407
408
  switch (key) {
408
409
  case "$with": {
409
410
  var _controls;
410
411
  if (!value) break;
411
412
  (_controls = controls).$with ?? (_controls.$with = []);
412
- const seen = new Set(controls.$with.map((r) => r.name));
413
+ const seen = /* @__PURE__ */ new Set();
414
+ for (const r of controls.$with) seen.add(typeof r === "string" ? r : r.name);
413
415
  for (const seg of splitTopLevel(value, ",")) {
414
416
  const rel = parseWithSegment(seg);
415
417
  if (!rel || seen.has(rel.name)) continue;
@@ -424,35 +426,50 @@ function handleControls(parts) {
424
426
  break;
425
427
  }
426
428
  case "$select": {
429
+ const items = value.split(",");
427
430
  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
431
+ let hasAggregate = false;
432
+ for (const f of items) {
433
+ if (!f) continue;
434
+ if (f.startsWith("-")) hasExclusion = true;
435
+ else if (/^\w+\(/.test(f)) hasAggregate = true;
436
+ }
437
+ if (hasAggregate || !hasExclusion) {
438
+ const arr = Array.isArray(controls.$select) ? controls.$select : [];
439
+ for (const f of items) {
440
+ if (!f || /^\w+\(/.test(f)) continue;
441
+ arr.push(f);
442
+ controlInsights.push([f, "$select"]);
443
+ }
444
+ for (const f of items) {
445
+ if (!f) continue;
446
+ const aggMatch = /^(\w+)\((\*|[\w.]+)\)(?::([\w.]+))?$/.exec(f);
447
+ if (!aggMatch) continue;
448
+ const fn = aggMatch[1];
449
+ const field = aggMatch[2];
450
+ const alias = aggMatch[3] ?? (field === "*" ? `${fn}_star` : `${fn}_${field}`);
451
+ arr.push({
452
+ $fn: fn,
453
+ $field: field,
454
+ $as: alias
436
455
  });
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"]);
456
+ controlInsights.push([field, fn]);
447
457
  }
448
- controls.$select = obj;
458
+ controls.$select = arr;
449
459
  } 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"]);
460
+ const obj = controls.$select ?? {};
461
+ for (const f of items) {
462
+ if (!f) continue;
463
+ if (f.startsWith("-")) {
464
+ const name = f.slice(1);
465
+ obj[name] = 0;
466
+ controlInsights.push([name, "$select"]);
467
+ } else {
468
+ obj[f] = 1;
469
+ controlInsights.push([f, "$select"]);
470
+ }
454
471
  }
455
- controls.$select = arr;
472
+ controls.$select = obj;
456
473
  }
457
474
  break;
458
475
  }
@@ -460,12 +477,27 @@ function handleControls(parts) {
460
477
  case "$order":
461
478
  var _controls1;
462
479
  (_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
- });
480
+ for (const f of value.split(",")) {
481
+ if (!f) continue;
482
+ if (f.startsWith("-")) {
483
+ const name = f.slice(1);
484
+ controls.$sort[name] = -1;
485
+ controlInsights.push([name, "$order"]);
486
+ } else {
487
+ controls.$sort[f] = 1;
488
+ controlInsights.push([f, "$order"]);
489
+ }
490
+ }
491
+ break;
492
+ case "$groupBy":
493
+ var _controls2;
494
+ if (!value) break;
495
+ (_controls2 = controls).$groupBy ?? (_controls2.$groupBy = []);
496
+ for (const f of value.split(",")) {
497
+ if (!f) continue;
498
+ controls.$groupBy.push(f);
499
+ controlInsights.push([f, "$groupBy"]);
500
+ }
469
501
  break;
470
502
  case "$limit":
471
503
  case "$top":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqu/url",
3
- "version": "0.0.6",
3
+ "version": "0.1.0",
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.0"
32
49
  },
33
50
  "scripts": {
34
51
  "pub": "pnpm publish --access public",