@uniqu/url 0.0.2 → 0.0.4
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 +131 -0
- package/dist/index.cjs +67 -14
- package/dist/index.mjs +67 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -151,10 +151,132 @@ 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
|
+
| `$with` | — | `$with=posts,author` | `{ $with: [{ name: 'posts', filter: {}, controls: {} }, ...] }` |
|
|
154
155
|
| `$<custom>` | — | `$search=term` | `{ $search: 'term' }` |
|
|
155
156
|
|
|
156
157
|
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.
|
|
157
158
|
|
|
159
|
+
### Relation Loading (`$with`)
|
|
160
|
+
|
|
161
|
+
`$with` declares which relations to populate alongside the primary query. Relations are comma-separated:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
$with=posts,comments,author
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
#### Per-Relation Sub-Queries
|
|
168
|
+
|
|
169
|
+
Each relation can include an inline sub-query in parentheses. Inside the parens, the full query syntax applies — filters, controls, and nested `$with`:
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
$with=posts($sort=-createdAt&$limit=5&status=published)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
This parses to:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
controls.$with = [
|
|
179
|
+
{
|
|
180
|
+
name: 'posts',
|
|
181
|
+
filter: { status: 'published' },
|
|
182
|
+
controls: { $sort: { createdAt: -1 }, $limit: 5 },
|
|
183
|
+
},
|
|
184
|
+
]
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Each relation is a full `Uniquery` sub-query with its own `filter`, `controls`, and `insights`. All controls are supported inside parens: `$sort`, `$limit`, `$skip`, `$select`, `$count`, and nested `$with`.
|
|
188
|
+
|
|
189
|
+
#### Nested Relations
|
|
190
|
+
|
|
191
|
+
`$with` is recursive — relations can load their own sub-relations to any depth:
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
$with=posts($sort=-createdAt&$limit=5&$with=comments($limit=10&$with=author),tags)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
This produces a tree:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
controls.$with = [
|
|
201
|
+
{
|
|
202
|
+
name: 'posts',
|
|
203
|
+
filter: {},
|
|
204
|
+
controls: {
|
|
205
|
+
$sort: { createdAt: -1 },
|
|
206
|
+
$limit: 5,
|
|
207
|
+
$with: [
|
|
208
|
+
{
|
|
209
|
+
name: 'comments',
|
|
210
|
+
filter: {},
|
|
211
|
+
controls: {
|
|
212
|
+
$limit: 10,
|
|
213
|
+
$with: [{ name: 'author', filter: {}, controls: {} }],
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{ name: 'tags', filter: {}, controls: {} },
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
]
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Inside each level of parens, `&` separates parameters and `,` separates sibling relations within `$with=`. The parser handles balanced parentheses correctly across nesting levels.
|
|
224
|
+
|
|
225
|
+
#### Combined Example
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
status=active&$with=posts($sort=-createdAt&$limit=5&$select=title,body&status=published),author
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Produces:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
{
|
|
235
|
+
filter: { status: 'active' },
|
|
236
|
+
controls: {
|
|
237
|
+
$with: [
|
|
238
|
+
{
|
|
239
|
+
name: 'posts',
|
|
240
|
+
filter: { status: 'published' },
|
|
241
|
+
controls: {
|
|
242
|
+
$sort: { createdAt: -1 },
|
|
243
|
+
$limit: 5,
|
|
244
|
+
$select: ['title', 'body'],
|
|
245
|
+
},
|
|
246
|
+
insights: Map { 'status' => Set { '$eq' }, ... },
|
|
247
|
+
},
|
|
248
|
+
{ name: 'author', filter: {}, controls: {} },
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
insights: Map {
|
|
252
|
+
'status' => Set { '$eq' },
|
|
253
|
+
'posts' => Set { '$with' },
|
|
254
|
+
'posts.status' => Set { '$eq' },
|
|
255
|
+
'author' => Set { '$with' },
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Each `$with` relation carries its own scoped `insights`, and nested insights bubble up to the root with dot-notation prefixed field names.
|
|
261
|
+
|
|
262
|
+
#### Edge Cases
|
|
263
|
+
|
|
264
|
+
| Case | Behavior |
|
|
265
|
+
|------|----------|
|
|
266
|
+
| `$with=posts,posts` | Deduplicated — one entry |
|
|
267
|
+
| `$with=` or `$with` | No relations (empty/omitted) |
|
|
268
|
+
| `$with=posts()` | Empty parens — same as `$with=posts` |
|
|
269
|
+
| Unknown relation names | Recorded as-is — consumer validates against its schema |
|
|
270
|
+
|
|
271
|
+
#### Consumer Responsibility
|
|
272
|
+
|
|
273
|
+
Uniqu parses and types the `$with` declaration. The consumer (e.g. a database adapter) is responsible for:
|
|
274
|
+
|
|
275
|
+
- **Execution strategy** — JOINs, subqueries, or separate queries
|
|
276
|
+
- **Relation validation** — checking that relation names exist on the entity
|
|
277
|
+
- **Circular reference detection** — preventing infinite `$with` chains
|
|
278
|
+
- **Depth limits** — restricting nesting depth for performance
|
|
279
|
+
|
|
158
280
|
## Insights
|
|
159
281
|
|
|
160
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`).
|
|
@@ -168,6 +290,7 @@ $select=firstName,-client.ssn
|
|
|
168
290
|
&$order=-createdAt,score
|
|
169
291
|
&$limit=50&$skip=10
|
|
170
292
|
&$count
|
|
293
|
+
&$with=posts($sort=-date&$limit=5&status=published),profile
|
|
171
294
|
&$exists=client.phone
|
|
172
295
|
&$!exists=deletedAt
|
|
173
296
|
&age>=18&age<=30
|
|
@@ -205,6 +328,14 @@ Produces:
|
|
|
205
328
|
$limit: 50,
|
|
206
329
|
$skip: 10,
|
|
207
330
|
$count: true,
|
|
331
|
+
$with: [
|
|
332
|
+
{
|
|
333
|
+
name: 'posts',
|
|
334
|
+
filter: { status: 'published' },
|
|
335
|
+
controls: { $sort: { date: -1 }, $limit: 5 },
|
|
336
|
+
},
|
|
337
|
+
{ name: 'profile', filter: {}, controls: {} },
|
|
338
|
+
],
|
|
208
339
|
},
|
|
209
340
|
}
|
|
210
341
|
```
|
package/dist/index.cjs
CHANGED
|
@@ -338,7 +338,7 @@ function buildExists(fields, positive) {
|
|
|
338
338
|
*
|
|
339
339
|
* @param raw - Raw query string without the leading "?"
|
|
340
340
|
*/ function parseUrl(raw) {
|
|
341
|
-
const parts = raw
|
|
341
|
+
const parts = splitTopLevel(raw, "&");
|
|
342
342
|
const controlParts = [];
|
|
343
343
|
const exprParts = [];
|
|
344
344
|
for (const _p of parts) {
|
|
@@ -346,7 +346,7 @@ function buildExists(fields, positive) {
|
|
|
346
346
|
if (/^\$[A-Za-z0-9_!]+/.test(p) && !p.startsWith("$exists=") && !p.startsWith("$!exists=")) controlParts.push(p);
|
|
347
347
|
else if (p.length) exprParts.push(p);
|
|
348
348
|
}
|
|
349
|
-
const { controls,
|
|
349
|
+
const { controls, controlInsights } = handleControls(controlParts);
|
|
350
350
|
let filter = {};
|
|
351
351
|
let parser;
|
|
352
352
|
if (exprParts.length) {
|
|
@@ -354,22 +354,76 @@ function buildExists(fields, positive) {
|
|
|
354
354
|
filter = parser.parseExpression();
|
|
355
355
|
parser.expectEof();
|
|
356
356
|
} else parser = new Parser([]);
|
|
357
|
-
for (const
|
|
358
|
-
for (const f of orderInsights) parser.captureInsight(f, "$order");
|
|
357
|
+
for (const [field, op] of controlInsights) parser.captureInsight(field, op);
|
|
359
358
|
return {
|
|
360
359
|
filter,
|
|
361
360
|
controls,
|
|
362
361
|
insights: parser.getInsights()
|
|
363
362
|
};
|
|
364
363
|
}
|
|
364
|
+
/** Split a string by `sep` at the top level (ignoring separators inside balanced parentheses). */ function splitTopLevel(str, sep) {
|
|
365
|
+
const parts = [];
|
|
366
|
+
let depth = 0;
|
|
367
|
+
let start = 0;
|
|
368
|
+
for (let i = 0; i < str.length; i++) if (str[i] === "(") depth++;
|
|
369
|
+
else if (str[i] === ")") depth--;
|
|
370
|
+
else if (str[i] === sep && depth === 0) {
|
|
371
|
+
parts.push(str.slice(start, i));
|
|
372
|
+
start = i + 1;
|
|
373
|
+
}
|
|
374
|
+
parts.push(str.slice(start));
|
|
375
|
+
return parts;
|
|
376
|
+
}
|
|
377
|
+
/** Parse a single `$with` segment like `posts` or `posts($sort=-createdAt&status=active)`. */ function parseWithSegment(seg) {
|
|
378
|
+
if (!seg) return null;
|
|
379
|
+
const parenIdx = seg.indexOf("(");
|
|
380
|
+
if (parenIdx === -1) return {
|
|
381
|
+
name: seg,
|
|
382
|
+
filter: {},
|
|
383
|
+
controls: {}
|
|
384
|
+
};
|
|
385
|
+
const name = seg.slice(0, parenIdx);
|
|
386
|
+
if (!name) return null;
|
|
387
|
+
const inner = seg.slice(parenIdx + 1, -1);
|
|
388
|
+
if (!inner) return {
|
|
389
|
+
name,
|
|
390
|
+
filter: {},
|
|
391
|
+
controls: {}
|
|
392
|
+
};
|
|
393
|
+
const sub = parseUrl(inner);
|
|
394
|
+
const rel = {
|
|
395
|
+
name,
|
|
396
|
+
filter: sub.filter,
|
|
397
|
+
controls: sub.controls
|
|
398
|
+
};
|
|
399
|
+
if (sub.insights.size) rel.insights = sub.insights;
|
|
400
|
+
return rel;
|
|
401
|
+
}
|
|
365
402
|
function handleControls(parts) {
|
|
366
403
|
const controls = {};
|
|
367
|
-
const
|
|
368
|
-
const orderInsights = /* @__PURE__ */ new Set();
|
|
404
|
+
const controlInsights = [];
|
|
369
405
|
for (const raw of parts) {
|
|
370
406
|
const [key, ...rest] = raw.split("=");
|
|
371
|
-
const value =
|
|
407
|
+
const value = rest.join("=");
|
|
372
408
|
switch (key) {
|
|
409
|
+
case "$with": {
|
|
410
|
+
var _controls;
|
|
411
|
+
if (!value) break;
|
|
412
|
+
(_controls = controls).$with ?? (_controls.$with = []);
|
|
413
|
+
const seen = new Set(controls.$with.map((r) => r.name));
|
|
414
|
+
for (const seg of splitTopLevel(value, ",")) {
|
|
415
|
+
const rel = parseWithSegment(seg);
|
|
416
|
+
if (!rel || seen.has(rel.name)) continue;
|
|
417
|
+
seen.add(rel.name);
|
|
418
|
+
controls.$with.push(rel);
|
|
419
|
+
controlInsights.push([rel.name, "$with"]);
|
|
420
|
+
if (rel.insights) for (const [field, ops] of rel.insights) {
|
|
421
|
+
const prefixed = `${rel.name}.${field}`;
|
|
422
|
+
for (const op of ops) controlInsights.push([prefixed, op]);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
373
427
|
case "$select": {
|
|
374
428
|
let hasExclusion = false;
|
|
375
429
|
const fields = [];
|
|
@@ -390,14 +444,14 @@ function handleControls(parts) {
|
|
|
390
444
|
const obj = controls.$select ?? {};
|
|
391
445
|
for (const { name, include } of fields) {
|
|
392
446
|
obj[name] = include ? 1 : 0;
|
|
393
|
-
|
|
447
|
+
controlInsights.push([name, "$select"]);
|
|
394
448
|
}
|
|
395
449
|
controls.$select = obj;
|
|
396
450
|
} else {
|
|
397
451
|
const arr = Array.isArray(controls.$select) ? controls.$select : [];
|
|
398
452
|
for (const { name } of fields) {
|
|
399
453
|
arr.push(name);
|
|
400
|
-
|
|
454
|
+
controlInsights.push([name, "$select"]);
|
|
401
455
|
}
|
|
402
456
|
controls.$select = arr;
|
|
403
457
|
}
|
|
@@ -405,11 +459,11 @@ function handleControls(parts) {
|
|
|
405
459
|
}
|
|
406
460
|
case "$sort":
|
|
407
461
|
case "$order":
|
|
408
|
-
var
|
|
409
|
-
(
|
|
462
|
+
var _controls1;
|
|
463
|
+
(_controls1 = controls).$sort ?? (_controls1.$sort = {});
|
|
410
464
|
value.split(",").forEach((f) => {
|
|
411
465
|
if (!f) return;
|
|
412
|
-
|
|
466
|
+
controlInsights.push([f.replace(/^-/, ""), "$order"]);
|
|
413
467
|
if (f.startsWith("-")) controls.$sort[f.slice(1)] = -1;
|
|
414
468
|
else controls.$sort[f] = 1;
|
|
415
469
|
});
|
|
@@ -429,8 +483,7 @@ function handleControls(parts) {
|
|
|
429
483
|
}
|
|
430
484
|
return {
|
|
431
485
|
controls,
|
|
432
|
-
|
|
433
|
-
orderInsights
|
|
486
|
+
controlInsights
|
|
434
487
|
};
|
|
435
488
|
}
|
|
436
489
|
|
package/dist/index.mjs
CHANGED
|
@@ -337,7 +337,7 @@ function buildExists(fields, positive) {
|
|
|
337
337
|
*
|
|
338
338
|
* @param raw - Raw query string without the leading "?"
|
|
339
339
|
*/ function parseUrl(raw) {
|
|
340
|
-
const parts = raw
|
|
340
|
+
const parts = splitTopLevel(raw, "&");
|
|
341
341
|
const controlParts = [];
|
|
342
342
|
const exprParts = [];
|
|
343
343
|
for (const _p of parts) {
|
|
@@ -345,7 +345,7 @@ function buildExists(fields, positive) {
|
|
|
345
345
|
if (/^\$[A-Za-z0-9_!]+/.test(p) && !p.startsWith("$exists=") && !p.startsWith("$!exists=")) controlParts.push(p);
|
|
346
346
|
else if (p.length) exprParts.push(p);
|
|
347
347
|
}
|
|
348
|
-
const { controls,
|
|
348
|
+
const { controls, controlInsights } = handleControls(controlParts);
|
|
349
349
|
let filter = {};
|
|
350
350
|
let parser;
|
|
351
351
|
if (exprParts.length) {
|
|
@@ -353,22 +353,76 @@ function buildExists(fields, positive) {
|
|
|
353
353
|
filter = parser.parseExpression();
|
|
354
354
|
parser.expectEof();
|
|
355
355
|
} else parser = new Parser([]);
|
|
356
|
-
for (const
|
|
357
|
-
for (const f of orderInsights) parser.captureInsight(f, "$order");
|
|
356
|
+
for (const [field, op] of controlInsights) parser.captureInsight(field, op);
|
|
358
357
|
return {
|
|
359
358
|
filter,
|
|
360
359
|
controls,
|
|
361
360
|
insights: parser.getInsights()
|
|
362
361
|
};
|
|
363
362
|
}
|
|
363
|
+
/** Split a string by `sep` at the top level (ignoring separators inside balanced parentheses). */ function splitTopLevel(str, sep) {
|
|
364
|
+
const parts = [];
|
|
365
|
+
let depth = 0;
|
|
366
|
+
let start = 0;
|
|
367
|
+
for (let i = 0; i < str.length; i++) if (str[i] === "(") depth++;
|
|
368
|
+
else if (str[i] === ")") depth--;
|
|
369
|
+
else if (str[i] === sep && depth === 0) {
|
|
370
|
+
parts.push(str.slice(start, i));
|
|
371
|
+
start = i + 1;
|
|
372
|
+
}
|
|
373
|
+
parts.push(str.slice(start));
|
|
374
|
+
return parts;
|
|
375
|
+
}
|
|
376
|
+
/** Parse a single `$with` segment like `posts` or `posts($sort=-createdAt&status=active)`. */ function parseWithSegment(seg) {
|
|
377
|
+
if (!seg) return null;
|
|
378
|
+
const parenIdx = seg.indexOf("(");
|
|
379
|
+
if (parenIdx === -1) return {
|
|
380
|
+
name: seg,
|
|
381
|
+
filter: {},
|
|
382
|
+
controls: {}
|
|
383
|
+
};
|
|
384
|
+
const name = seg.slice(0, parenIdx);
|
|
385
|
+
if (!name) return null;
|
|
386
|
+
const inner = seg.slice(parenIdx + 1, -1);
|
|
387
|
+
if (!inner) return {
|
|
388
|
+
name,
|
|
389
|
+
filter: {},
|
|
390
|
+
controls: {}
|
|
391
|
+
};
|
|
392
|
+
const sub = parseUrl(inner);
|
|
393
|
+
const rel = {
|
|
394
|
+
name,
|
|
395
|
+
filter: sub.filter,
|
|
396
|
+
controls: sub.controls
|
|
397
|
+
};
|
|
398
|
+
if (sub.insights.size) rel.insights = sub.insights;
|
|
399
|
+
return rel;
|
|
400
|
+
}
|
|
364
401
|
function handleControls(parts) {
|
|
365
402
|
const controls = {};
|
|
366
|
-
const
|
|
367
|
-
const orderInsights = /* @__PURE__ */ new Set();
|
|
403
|
+
const controlInsights = [];
|
|
368
404
|
for (const raw of parts) {
|
|
369
405
|
const [key, ...rest] = raw.split("=");
|
|
370
|
-
const value =
|
|
406
|
+
const value = rest.join("=");
|
|
371
407
|
switch (key) {
|
|
408
|
+
case "$with": {
|
|
409
|
+
var _controls;
|
|
410
|
+
if (!value) break;
|
|
411
|
+
(_controls = controls).$with ?? (_controls.$with = []);
|
|
412
|
+
const seen = new Set(controls.$with.map((r) => r.name));
|
|
413
|
+
for (const seg of splitTopLevel(value, ",")) {
|
|
414
|
+
const rel = parseWithSegment(seg);
|
|
415
|
+
if (!rel || seen.has(rel.name)) continue;
|
|
416
|
+
seen.add(rel.name);
|
|
417
|
+
controls.$with.push(rel);
|
|
418
|
+
controlInsights.push([rel.name, "$with"]);
|
|
419
|
+
if (rel.insights) for (const [field, ops] of rel.insights) {
|
|
420
|
+
const prefixed = `${rel.name}.${field}`;
|
|
421
|
+
for (const op of ops) controlInsights.push([prefixed, op]);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
372
426
|
case "$select": {
|
|
373
427
|
let hasExclusion = false;
|
|
374
428
|
const fields = [];
|
|
@@ -389,14 +443,14 @@ function handleControls(parts) {
|
|
|
389
443
|
const obj = controls.$select ?? {};
|
|
390
444
|
for (const { name, include } of fields) {
|
|
391
445
|
obj[name] = include ? 1 : 0;
|
|
392
|
-
|
|
446
|
+
controlInsights.push([name, "$select"]);
|
|
393
447
|
}
|
|
394
448
|
controls.$select = obj;
|
|
395
449
|
} else {
|
|
396
450
|
const arr = Array.isArray(controls.$select) ? controls.$select : [];
|
|
397
451
|
for (const { name } of fields) {
|
|
398
452
|
arr.push(name);
|
|
399
|
-
|
|
453
|
+
controlInsights.push([name, "$select"]);
|
|
400
454
|
}
|
|
401
455
|
controls.$select = arr;
|
|
402
456
|
}
|
|
@@ -404,11 +458,11 @@ function handleControls(parts) {
|
|
|
404
458
|
}
|
|
405
459
|
case "$sort":
|
|
406
460
|
case "$order":
|
|
407
|
-
var
|
|
408
|
-
(
|
|
461
|
+
var _controls1;
|
|
462
|
+
(_controls1 = controls).$sort ?? (_controls1.$sort = {});
|
|
409
463
|
value.split(",").forEach((f) => {
|
|
410
464
|
if (!f) return;
|
|
411
|
-
|
|
465
|
+
controlInsights.push([f.replace(/^-/, ""), "$order"]);
|
|
412
466
|
if (f.startsWith("-")) controls.$sort[f.slice(1)] = -1;
|
|
413
467
|
else controls.$sort[f] = 1;
|
|
414
468
|
});
|
|
@@ -428,8 +482,7 @@ function handleControls(parts) {
|
|
|
428
482
|
}
|
|
429
483
|
return {
|
|
430
484
|
controls,
|
|
431
|
-
|
|
432
|
-
orderInsights
|
|
485
|
+
controlInsights
|
|
433
486
|
};
|
|
434
487
|
}
|
|
435
488
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniqu/url",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "URL query string parser producing the Uniqu canonical query format",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Artem Maltsev",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"dist"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@uniqu/core": "^0.0.
|
|
31
|
+
"@uniqu/core": "^0.0.4"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"pub": "pnpm publish --access public",
|