@uniqu/url 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
@@ -152,6 +152,7 @@ Control keywords start with `$` and are separated from filter expressions:
152
152
  | `$skip` | — | `$skip=40` | `{ $skip: 40 }` |
153
153
  | `$count` | — | `$count` | `{ $count: true }` |
154
154
  | `$groupBy` | — | `$groupBy=currency,region` | `{ $groupBy: ['currency', 'region'] }` |
155
+ | `$having` | — | `$having=total>1000` | `{ $having: { total: { $gt: 1000 } } }` |
155
156
  | `$with` | — | `$with=posts,author` | `{ $with: [{ name: 'posts', filter: {}, controls: {} }, ...] }` |
156
157
  | `$<custom>` | — | `$search=term` | `{ $search: 'term' }` |
157
158
 
@@ -220,6 +221,31 @@ Aggregates and `$groupBy` work inside `$with` sub-queries too:
220
221
  $with=orders($select=sum(total):revenue&$groupBy=status)
221
222
  ```
222
223
 
224
+ ### Post-Aggregation Filter (`$having`)
225
+
226
+ `$having` filters groups after aggregation (SQL `HAVING`). It accepts the same filter expression syntax as the main query:
227
+
228
+ ```
229
+ $having=total>1000 → { $having: { total: { $gt: 1000 } } }
230
+ $having=total>1000&$having=count_star>=5 → { $having: { $and: [...] } } (AND-merged)
231
+ $having=total>1000^avg_price<50 → { $having: { $or: [...] } } (OR via ^)
232
+ $having=!(total<100) → { $having: { $not: {...} } } (NOT via !())
233
+ ```
234
+
235
+ For multi-condition `$having` with AND, either use multiple `$having` params (AND-merged automatically) or wrap in parentheses:
236
+
237
+ ```
238
+ $having=(total>1000&count_star>=5)
239
+ ```
240
+
241
+ `$having` works inside `$with` sub-queries:
242
+
243
+ ```
244
+ $with=orders($select=sum(total):revenue&$groupBy=status&$having=revenue>500)
245
+ ```
246
+
247
+ Insights track `$having` fields with the `'$having'` op.
248
+
223
249
  ### Relation Loading (`$with`)
224
250
 
225
251
  `$with` declares which relations to populate alongside the primary query. Relations are comma-separated:
@@ -432,7 +458,7 @@ Accepts a `Uniquery` object and returns a URL query string (without leading `?`)
432
458
 
433
459
  All features are supported:
434
460
  - Filter expressions (comparisons, `$and`/`$or`/`$not`, `$in`/`$nin`, `$exists`, `$regex`)
435
- - Controls (`$select`, `$sort`, `$limit`, `$skip`, `$count`, `$groupBy`, `$with`)
461
+ - Controls (`$select`, `$sort`, `$limit`, `$skip`, `$count`, `$groupBy`, `$having`, `$with`)
436
462
  - Aggregates in `$select` (`sum(amount):total`)
437
463
  - Nested `$with` sub-queries
438
464
  - Pass-through custom `$`-prefixed controls
package/dist/builder.cjs CHANGED
@@ -91,6 +91,7 @@ function serializeValue(value) {
91
91
  const KNOWN_CONTROL_KEYS = new Set([
92
92
  "$select",
93
93
  "$groupBy",
94
+ "$having",
94
95
  "$sort",
95
96
  "$limit",
96
97
  "$skip",
@@ -122,6 +123,13 @@ function serializeControls(controls) {
122
123
  const part = `$groupBy=${seg}`;
123
124
  result = result ? result + "&" + part : part;
124
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
+ }
125
133
  if (controls.$sort) {
126
134
  let seg = "";
127
135
  for (const [field, dir] of Object.entries(controls.$sort)) {
package/dist/builder.mjs CHANGED
@@ -90,6 +90,7 @@ function serializeValue(value) {
90
90
  const KNOWN_CONTROL_KEYS = new Set([
91
91
  "$select",
92
92
  "$groupBy",
93
+ "$having",
93
94
  "$sort",
94
95
  "$limit",
95
96
  "$skip",
@@ -121,6 +122,13 @@ function serializeControls(controls) {
121
122
  const part = `$groupBy=${seg}`;
122
123
  result = result ? result + "&" + part : part;
123
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
+ }
124
132
  if (controls.$sort) {
125
133
  let seg = "";
126
134
  for (const [field, dir] of Object.entries(controls.$sort)) {
package/dist/index.cjs CHANGED
@@ -310,9 +310,10 @@ function unescapeString(str) {
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 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("(");
@@ -402,6 +412,7 @@ function buildExists(fields, positive) {
402
412
  function handleControls(parts) {
403
413
  const controls = {};
404
414
  const controlInsights = [];
415
+ const aliasToField = /* @__PURE__ */ new Map();
405
416
  for (const raw of parts) {
406
417
  const eqIdx = raw.indexOf("=");
407
418
  const key = eqIdx === -1 ? raw : raw.slice(0, eqIdx);
@@ -454,6 +465,7 @@ function handleControls(parts) {
454
465
  $field: field,
455
466
  $as: alias
456
467
  });
468
+ aliasToField.set(alias, field);
457
469
  controlInsights.push([field, fn]);
458
470
  }
459
471
  controls.$select = arr;
@@ -500,6 +512,14 @@ function handleControls(parts) {
500
512
  controlInsights.push([f, "$groupBy"]);
501
513
  }
502
514
  break;
515
+ case "$having": {
516
+ if (!value) break;
517
+ const { expr, parser: hp } = parseFilterExpr(value);
518
+ if (controls.$having) controls.$having = { $and: [controls.$having, expr] };
519
+ else controls.$having = expr;
520
+ for (const [field] of hp.getInsights()) controlInsights.push([field, "$having"]);
521
+ break;
522
+ }
503
523
  case "$limit":
504
524
  case "$top":
505
525
  controls.$limit = Number(value);
@@ -513,6 +533,13 @@ function handleControls(parts) {
513
533
  default: controls[key] = value;
514
534
  }
515
535
  }
536
+ if (aliasToField.size) for (let i = 0; i < controlInsights.length; i++) {
537
+ const [field, op] = controlInsights[i];
538
+ if (op === "$order") {
539
+ const real = aliasToField.get(field);
540
+ if (real) controlInsights[i] = [real, op];
541
+ }
542
+ }
516
543
  return {
517
544
  controls,
518
545
  controlInsights
package/dist/index.mjs CHANGED
@@ -309,9 +309,10 @@ function unescapeString(str) {
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 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("(");
@@ -401,6 +411,7 @@ function buildExists(fields, positive) {
401
411
  function handleControls(parts) {
402
412
  const controls = {};
403
413
  const controlInsights = [];
414
+ const aliasToField = /* @__PURE__ */ new Map();
404
415
  for (const raw of parts) {
405
416
  const eqIdx = raw.indexOf("=");
406
417
  const key = eqIdx === -1 ? raw : raw.slice(0, eqIdx);
@@ -453,6 +464,7 @@ function handleControls(parts) {
453
464
  $field: field,
454
465
  $as: alias
455
466
  });
467
+ aliasToField.set(alias, field);
456
468
  controlInsights.push([field, fn]);
457
469
  }
458
470
  controls.$select = arr;
@@ -499,6 +511,14 @@ function handleControls(parts) {
499
511
  controlInsights.push([f, "$groupBy"]);
500
512
  }
501
513
  break;
514
+ case "$having": {
515
+ if (!value) break;
516
+ const { expr, parser: hp } = parseFilterExpr(value);
517
+ if (controls.$having) controls.$having = { $and: [controls.$having, expr] };
518
+ else controls.$having = expr;
519
+ for (const [field] of hp.getInsights()) controlInsights.push([field, "$having"]);
520
+ break;
521
+ }
502
522
  case "$limit":
503
523
  case "$top":
504
524
  controls.$limit = Number(value);
@@ -512,6 +532,13 @@ function handleControls(parts) {
512
532
  default: controls[key] = value;
513
533
  }
514
534
  }
535
+ if (aliasToField.size) for (let i = 0; i < controlInsights.length; i++) {
536
+ const [field, op] = controlInsights[i];
537
+ if (op === "$order") {
538
+ const real = aliasToField.get(field);
539
+ if (real) controlInsights[i] = [real, op];
540
+ }
541
+ }
515
542
  return {
516
543
  controls,
517
544
  controlInsights
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqu/url",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "URL query string parser producing the Uniqu canonical query format",
5
5
  "license": "MIT",
6
6
  "author": "Artem Maltsev",
@@ -45,7 +45,7 @@
45
45
  "dist"
46
46
  ],
47
47
  "dependencies": {
48
- "@uniqu/core": "^0.1.0"
48
+ "@uniqu/core": "^0.1.2"
49
49
  },
50
50
  "scripts": {
51
51
  "pub": "pnpm publish --access public",