@uniqu/url 0.0.2 → 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 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.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, 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 = decodeURIComponent(rest.join("="));
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 _controls;
409
- (_controls = controls).$sort ?? (_controls.$sort = {});
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.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, 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 = decodeURIComponent(rest.join("="));
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 _controls;
408
- (_controls = controls).$sort ?? (_controls.$sort = {});
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.2",
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.2"
31
+ "@uniqu/core": "^0.0.3"
32
32
  },
33
33
  "scripts": {
34
34
  "pub": "pnpm publish --access public",