@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 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.split("&");
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, 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 f of selectInsights) parser.captureInsight(f, "$select");
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 selectInsights = /* @__PURE__ */ new Set();
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 = decodeURIComponent(rest.join("="));
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
- selectInsights.add(name);
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
- selectInsights.add(name);
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 _controls;
409
- (_controls = controls).$sort ?? (_controls.$sort = {});
462
+ var _controls1;
463
+ (_controls1 = controls).$sort ?? (_controls1.$sort = {});
410
464
  value.split(",").forEach((f) => {
411
465
  if (!f) return;
412
- orderInsights.add(f.replace(/^-/, ""));
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
- selectInsights,
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.split("&");
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, 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 f of selectInsights) parser.captureInsight(f, "$select");
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 selectInsights = /* @__PURE__ */ new Set();
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 = decodeURIComponent(rest.join("="));
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
- selectInsights.add(name);
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
- selectInsights.add(name);
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 _controls;
408
- (_controls = controls).$sort ?? (_controls.$sort = {});
461
+ var _controls1;
462
+ (_controls1 = controls).$sort ?? (_controls1.$sort = {});
409
463
  value.split(",").forEach((f) => {
410
464
  if (!f) return;
411
- orderInsights.add(f.replace(/^-/, ""));
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
- selectInsights,
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.2",
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.2"
31
+ "@uniqu/core": "^0.0.4"
32
32
  },
33
33
  "scripts": {
34
34
  "pub": "pnpm publish --access public",