@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 +27 -1
- package/dist/builder.cjs +8 -0
- package/dist/builder.mjs +8 -0
- package/dist/index.cjs +33 -6
- package/dist/index.mjs +33 -6
- package/package.json +2 -2
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
|
-
|
|
314
|
-
for (const op of currentOps)
|
|
315
|
-
for (const op of otherOps)
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
313
|
-
for (const op of currentOps)
|
|
314
|
-
for (const op of otherOps)
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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.
|
|
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.
|
|
48
|
+
"@uniqu/core": "^0.1.2"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
51
51
|
"pub": "pnpm publish --access public",
|