@uniqu/url 0.0.1 → 0.0.3
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 +112 -0
- package/dist/index.cjs +54 -6
- package/dist/index.mjs +54 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -151,10 +151,117 @@ 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' }, { name: 'author' }] }` |
|
|
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
|
+
$sort: { createdAt: -1 },
|
|
183
|
+
$limit: 5,
|
|
184
|
+
},
|
|
185
|
+
]
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
All controls are supported inside parens: `$sort`, `$limit`, `$skip`, `$select`, and nested `$with`.
|
|
189
|
+
|
|
190
|
+
#### Nested Relations
|
|
191
|
+
|
|
192
|
+
`$with` is recursive — relations can load their own sub-relations to any depth:
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
$with=posts($sort=-createdAt&$limit=5&$with=comments($limit=10&$with=author),tags)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
This produces a tree:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
controls.$with = [
|
|
202
|
+
{
|
|
203
|
+
name: 'posts',
|
|
204
|
+
$sort: { createdAt: -1 },
|
|
205
|
+
$limit: 5,
|
|
206
|
+
$with: [
|
|
207
|
+
{ name: 'comments', $limit: 10, $with: [{ name: 'author' }] },
|
|
208
|
+
{ name: 'tags' },
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
]
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Inside each level of parens, `&` separates parameters and `,` separates sibling relations within `$with=`. The parser handles balanced parentheses correctly across nesting levels.
|
|
215
|
+
|
|
216
|
+
#### Combined Example
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
status=active&$with=posts($sort=-createdAt&$limit=5&$select=title,body&status=published),author
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Produces:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
{
|
|
226
|
+
filter: { status: 'active' },
|
|
227
|
+
controls: {
|
|
228
|
+
$with: [
|
|
229
|
+
{
|
|
230
|
+
name: 'posts',
|
|
231
|
+
filter: { status: 'published' },
|
|
232
|
+
$sort: { createdAt: -1 },
|
|
233
|
+
$limit: 5,
|
|
234
|
+
$select: ['title', 'body'],
|
|
235
|
+
},
|
|
236
|
+
{ name: 'author' },
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
insights: Map {
|
|
240
|
+
'status' => Set { '$eq' },
|
|
241
|
+
'posts' => Set { '$with' },
|
|
242
|
+
'author' => Set { '$with' },
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
#### Edge Cases
|
|
248
|
+
|
|
249
|
+
| Case | Behavior |
|
|
250
|
+
|------|----------|
|
|
251
|
+
| `$with=posts,posts` | Deduplicated — one entry |
|
|
252
|
+
| `$with=` or `$with` | No relations (empty/omitted) |
|
|
253
|
+
| `$with=posts()` | Empty parens — same as `$with=posts` |
|
|
254
|
+
| Unknown relation names | Recorded as-is — consumer validates against its schema |
|
|
255
|
+
|
|
256
|
+
#### Consumer Responsibility
|
|
257
|
+
|
|
258
|
+
Uniqu parses and types the `$with` declaration. The consumer (e.g. a database adapter) is responsible for:
|
|
259
|
+
|
|
260
|
+
- **Execution strategy** — JOINs, subqueries, or separate queries
|
|
261
|
+
- **Relation validation** — checking that relation names exist on the entity
|
|
262
|
+
- **Circular reference detection** — preventing infinite `$with` chains
|
|
263
|
+
- **Depth limits** — restricting nesting depth for performance
|
|
264
|
+
|
|
158
265
|
## Insights
|
|
159
266
|
|
|
160
267
|
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 +275,7 @@ $select=firstName,-client.ssn
|
|
|
168
275
|
&$order=-createdAt,score
|
|
169
276
|
&$limit=50&$skip=10
|
|
170
277
|
&$count
|
|
278
|
+
&$with=posts($sort=-date&$limit=5&status=published),profile
|
|
171
279
|
&$exists=client.phone
|
|
172
280
|
&$!exists=deletedAt
|
|
173
281
|
&age>=18&age<=30
|
|
@@ -205,6 +313,10 @@ Produces:
|
|
|
205
313
|
$limit: 50,
|
|
206
314
|
$skip: 10,
|
|
207
315
|
$count: true,
|
|
316
|
+
$with: [
|
|
317
|
+
{ name: 'posts', filter: { status: 'published' }, $sort: { date: -1 }, $limit: 5 },
|
|
318
|
+
{ name: 'profile' },
|
|
319
|
+
],
|
|
208
320
|
},
|
|
209
321
|
}
|
|
210
322
|
```
|
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, selectInsights, orderInsights } = handleControls(controlParts);
|
|
349
|
+
const { controls, selectInsights, orderInsights, withInsights } = handleControls(controlParts);
|
|
350
350
|
let filter = {};
|
|
351
351
|
let parser;
|
|
352
352
|
if (exprParts.length) {
|
|
@@ -356,20 +356,67 @@ function buildExists(fields, positive) {
|
|
|
356
356
|
} else parser = new Parser([]);
|
|
357
357
|
for (const f of selectInsights) parser.captureInsight(f, "$select");
|
|
358
358
|
for (const f of orderInsights) parser.captureInsight(f, "$order");
|
|
359
|
+
for (const f of withInsights) parser.captureInsight(f, "$with");
|
|
359
360
|
return {
|
|
360
361
|
filter,
|
|
361
362
|
controls,
|
|
362
363
|
insights: parser.getInsights()
|
|
363
364
|
};
|
|
364
365
|
}
|
|
366
|
+
/** Split a string by `sep` at the top level (ignoring separators inside balanced parentheses). */ function splitTopLevel(str, sep) {
|
|
367
|
+
const parts = [];
|
|
368
|
+
let depth = 0;
|
|
369
|
+
let start = 0;
|
|
370
|
+
for (let i = 0; i < str.length; i++) if (str[i] === "(") depth++;
|
|
371
|
+
else if (str[i] === ")") depth--;
|
|
372
|
+
else if (str[i] === sep && depth === 0) {
|
|
373
|
+
parts.push(str.slice(start, i));
|
|
374
|
+
start = i + 1;
|
|
375
|
+
}
|
|
376
|
+
parts.push(str.slice(start));
|
|
377
|
+
return parts;
|
|
378
|
+
}
|
|
379
|
+
/** Parse a single `$with` segment like `posts` or `posts($sort=-createdAt&status=active)`. */ function parseWithSegment(seg) {
|
|
380
|
+
if (!seg) return null;
|
|
381
|
+
const parenIdx = seg.indexOf("(");
|
|
382
|
+
if (parenIdx === -1) return { name: seg };
|
|
383
|
+
const name = seg.slice(0, parenIdx);
|
|
384
|
+
if (!name) return null;
|
|
385
|
+
const inner = seg.slice(parenIdx + 1, -1);
|
|
386
|
+
if (!inner) return { name };
|
|
387
|
+
const sub = parseUrl(inner);
|
|
388
|
+
const rel = { name };
|
|
389
|
+
if (Object.keys(sub.filter).length) rel.filter = sub.filter;
|
|
390
|
+
if (sub.controls.$sort) rel.$sort = sub.controls.$sort;
|
|
391
|
+
if (sub.controls.$skip != null) rel.$skip = sub.controls.$skip;
|
|
392
|
+
if (sub.controls.$limit != null) rel.$limit = sub.controls.$limit;
|
|
393
|
+
if (sub.controls.$select) rel.$select = sub.controls.$select;
|
|
394
|
+
if (sub.controls.$with?.length) rel.$with = sub.controls.$with;
|
|
395
|
+
return rel;
|
|
396
|
+
}
|
|
365
397
|
function handleControls(parts) {
|
|
366
398
|
const controls = {};
|
|
367
399
|
const selectInsights = /* @__PURE__ */ new Set();
|
|
368
400
|
const orderInsights = /* @__PURE__ */ new Set();
|
|
401
|
+
const withInsights = /* @__PURE__ */ new Set();
|
|
369
402
|
for (const raw of parts) {
|
|
370
403
|
const [key, ...rest] = raw.split("=");
|
|
371
|
-
const value =
|
|
404
|
+
const value = rest.join("=");
|
|
372
405
|
switch (key) {
|
|
406
|
+
case "$with": {
|
|
407
|
+
var _controls;
|
|
408
|
+
if (!value) break;
|
|
409
|
+
(_controls = controls).$with ?? (_controls.$with = []);
|
|
410
|
+
const seen = new Set(controls.$with.map((r) => r.name));
|
|
411
|
+
for (const seg of splitTopLevel(value, ",")) {
|
|
412
|
+
const rel = parseWithSegment(seg);
|
|
413
|
+
if (!rel || seen.has(rel.name)) continue;
|
|
414
|
+
seen.add(rel.name);
|
|
415
|
+
controls.$with.push(rel);
|
|
416
|
+
withInsights.add(rel.name);
|
|
417
|
+
}
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
373
420
|
case "$select": {
|
|
374
421
|
let hasExclusion = false;
|
|
375
422
|
const fields = [];
|
|
@@ -405,8 +452,8 @@ function handleControls(parts) {
|
|
|
405
452
|
}
|
|
406
453
|
case "$sort":
|
|
407
454
|
case "$order":
|
|
408
|
-
var
|
|
409
|
-
(
|
|
455
|
+
var _controls1;
|
|
456
|
+
(_controls1 = controls).$sort ?? (_controls1.$sort = {});
|
|
410
457
|
value.split(",").forEach((f) => {
|
|
411
458
|
if (!f) return;
|
|
412
459
|
orderInsights.add(f.replace(/^-/, ""));
|
|
@@ -430,7 +477,8 @@ function handleControls(parts) {
|
|
|
430
477
|
return {
|
|
431
478
|
controls,
|
|
432
479
|
selectInsights,
|
|
433
|
-
orderInsights
|
|
480
|
+
orderInsights,
|
|
481
|
+
withInsights
|
|
434
482
|
};
|
|
435
483
|
}
|
|
436
484
|
|
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, selectInsights, orderInsights } = handleControls(controlParts);
|
|
348
|
+
const { controls, selectInsights, orderInsights, withInsights } = handleControls(controlParts);
|
|
349
349
|
let filter = {};
|
|
350
350
|
let parser;
|
|
351
351
|
if (exprParts.length) {
|
|
@@ -355,20 +355,67 @@ function buildExists(fields, positive) {
|
|
|
355
355
|
} else parser = new Parser([]);
|
|
356
356
|
for (const f of selectInsights) parser.captureInsight(f, "$select");
|
|
357
357
|
for (const f of orderInsights) parser.captureInsight(f, "$order");
|
|
358
|
+
for (const f of withInsights) parser.captureInsight(f, "$with");
|
|
358
359
|
return {
|
|
359
360
|
filter,
|
|
360
361
|
controls,
|
|
361
362
|
insights: parser.getInsights()
|
|
362
363
|
};
|
|
363
364
|
}
|
|
365
|
+
/** Split a string by `sep` at the top level (ignoring separators inside balanced parentheses). */ function splitTopLevel(str, sep) {
|
|
366
|
+
const parts = [];
|
|
367
|
+
let depth = 0;
|
|
368
|
+
let start = 0;
|
|
369
|
+
for (let i = 0; i < str.length; i++) if (str[i] === "(") depth++;
|
|
370
|
+
else if (str[i] === ")") depth--;
|
|
371
|
+
else if (str[i] === sep && depth === 0) {
|
|
372
|
+
parts.push(str.slice(start, i));
|
|
373
|
+
start = i + 1;
|
|
374
|
+
}
|
|
375
|
+
parts.push(str.slice(start));
|
|
376
|
+
return parts;
|
|
377
|
+
}
|
|
378
|
+
/** Parse a single `$with` segment like `posts` or `posts($sort=-createdAt&status=active)`. */ function parseWithSegment(seg) {
|
|
379
|
+
if (!seg) return null;
|
|
380
|
+
const parenIdx = seg.indexOf("(");
|
|
381
|
+
if (parenIdx === -1) return { name: seg };
|
|
382
|
+
const name = seg.slice(0, parenIdx);
|
|
383
|
+
if (!name) return null;
|
|
384
|
+
const inner = seg.slice(parenIdx + 1, -1);
|
|
385
|
+
if (!inner) return { name };
|
|
386
|
+
const sub = parseUrl(inner);
|
|
387
|
+
const rel = { name };
|
|
388
|
+
if (Object.keys(sub.filter).length) rel.filter = sub.filter;
|
|
389
|
+
if (sub.controls.$sort) rel.$sort = sub.controls.$sort;
|
|
390
|
+
if (sub.controls.$skip != null) rel.$skip = sub.controls.$skip;
|
|
391
|
+
if (sub.controls.$limit != null) rel.$limit = sub.controls.$limit;
|
|
392
|
+
if (sub.controls.$select) rel.$select = sub.controls.$select;
|
|
393
|
+
if (sub.controls.$with?.length) rel.$with = sub.controls.$with;
|
|
394
|
+
return rel;
|
|
395
|
+
}
|
|
364
396
|
function handleControls(parts) {
|
|
365
397
|
const controls = {};
|
|
366
398
|
const selectInsights = /* @__PURE__ */ new Set();
|
|
367
399
|
const orderInsights = /* @__PURE__ */ new Set();
|
|
400
|
+
const withInsights = /* @__PURE__ */ new Set();
|
|
368
401
|
for (const raw of parts) {
|
|
369
402
|
const [key, ...rest] = raw.split("=");
|
|
370
|
-
const value =
|
|
403
|
+
const value = rest.join("=");
|
|
371
404
|
switch (key) {
|
|
405
|
+
case "$with": {
|
|
406
|
+
var _controls;
|
|
407
|
+
if (!value) break;
|
|
408
|
+
(_controls = controls).$with ?? (_controls.$with = []);
|
|
409
|
+
const seen = new Set(controls.$with.map((r) => r.name));
|
|
410
|
+
for (const seg of splitTopLevel(value, ",")) {
|
|
411
|
+
const rel = parseWithSegment(seg);
|
|
412
|
+
if (!rel || seen.has(rel.name)) continue;
|
|
413
|
+
seen.add(rel.name);
|
|
414
|
+
controls.$with.push(rel);
|
|
415
|
+
withInsights.add(rel.name);
|
|
416
|
+
}
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
372
419
|
case "$select": {
|
|
373
420
|
let hasExclusion = false;
|
|
374
421
|
const fields = [];
|
|
@@ -404,8 +451,8 @@ function handleControls(parts) {
|
|
|
404
451
|
}
|
|
405
452
|
case "$sort":
|
|
406
453
|
case "$order":
|
|
407
|
-
var
|
|
408
|
-
(
|
|
454
|
+
var _controls1;
|
|
455
|
+
(_controls1 = controls).$sort ?? (_controls1.$sort = {});
|
|
409
456
|
value.split(",").forEach((f) => {
|
|
410
457
|
if (!f) return;
|
|
411
458
|
orderInsights.add(f.replace(/^-/, ""));
|
|
@@ -429,7 +476,8 @@ function handleControls(parts) {
|
|
|
429
476
|
return {
|
|
430
477
|
controls,
|
|
431
478
|
selectInsights,
|
|
432
|
-
orderInsights
|
|
479
|
+
orderInsights,
|
|
480
|
+
withInsights
|
|
433
481
|
};
|
|
434
482
|
}
|
|
435
483
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniqu/url",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
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.3"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"pub": "pnpm publish --access public",
|