@uniqu/url 0.1.7 → 0.1.8

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
@@ -113,6 +113,14 @@ age>25^score>550&status=VIP
113
113
  → { $and: [{ $or: [{ age: { $gt: 25 } }, { score: { $gt: 550 } }] }, { status: 'VIP' }] }
114
114
  ```
115
115
 
116
+ Comparison fields and a logical operator in the same filter object are ANDed
117
+ together (see the [`@uniqu/core` README](../core/README.md#filter-expressions)),
118
+ so `buildUrl` emits them `&`-joined and parenthesizes an `$or` that has
119
+ siblings: `{ id: 101, $or: [{ s: 'a' }, { s: 'b' }] }` → `id=101&(s=a^s=b)`.
120
+ Conversely, a multi-part AND inside an `$or` is always parenthesized, whether
121
+ it is an explicit `$and` or an implicit one:
122
+ `{ $or: [{ $and: [{ a: 1 }, { b: 2 }] }, { c: 3 }] }` → `(a=1&b=2)^c=3`.
123
+
116
124
  Adjacent AND conditions on the same field are merged when safe:
117
125
 
118
126
  ```
@@ -486,6 +494,13 @@ const parsed = parseUrl(url)
486
494
  // parsed.controls.$limit → 10
487
495
  ```
488
496
 
497
+ Round-trips are semantic, not structural: `parseUrl` returns the canonical
498
+ explicit-`$and` form, with logical branches first and the merged comparison
499
+ fields last, so `{ id: 101, $or: [...] }` comes back as
500
+ `{ $and: [{ $or: [...] }, { id: 101 }] }`. Likewise an explicit `$and` inside
501
+ an `$or` is emitted parenthesized (`(a=1&b=2)^c=3`), which parses to the same
502
+ tree as the unparenthesized form.
503
+
489
504
  ### Bundle optimization
490
505
 
491
506
  The builder is a separate entry point (`@uniqu/url/builder`) so that apps using only `buildUrl` don't pull in the lexer and parser code, and vice versa.
package/dist/builder.cjs CHANGED
@@ -14,39 +14,51 @@ let _uniqu_core = require("@uniqu/core");
14
14
  if (filterStr && controlStr) return filterStr + "&" + controlStr;
15
15
  return filterStr || controlStr;
16
16
  }
17
- function serializeFilter(expr, parentOp) {
18
- if ("$and" in expr && expr.$and !== void 0) {
19
- let result = "";
20
- for (const child of expr.$and) {
21
- const s = serializeFilter(child, "$and");
22
- if (s) result = result ? result + "&" + s : s;
23
- }
24
- return result;
25
- }
26
- if ("$or" in expr && expr.$or !== void 0) {
27
- let result = "";
28
- for (const child of expr.$or) {
29
- const s = serializeFilter(child, "$or");
30
- if (s) result = result ? result + "^" + s : s;
31
- }
32
- return parentOp === "$and" && result ? `(${result})` : result;
33
- }
34
- if ("$not" in expr && expr.$not !== void 0) {
35
- const inner = serializeFilter(expr.$not);
36
- return inner ? `!(${inner})` : "";
37
- }
38
- let result = "";
39
- for (const [field, value] of Object.entries(expr)) if (value instanceof RegExp) {
40
- const part = `${field}~=${serializeValue(value)}`;
41
- result = result ? result + "&" + part : part;
42
- } else if ((0, _uniqu_core.isPrimitive)(value)) {
43
- const part = `${field}=${serializeValue(value)}`;
44
- result = result ? result + "&" + part : part;
45
- } else for (const [op, opValue] of Object.entries(value)) {
46
- const part = serializeComparison(field, op, opValue);
47
- result = result ? result + "&" + part : part;
48
- }
49
- return parentOp === "$or" && result.includes("&") ? `(${result})` : result;
17
+ /**
18
+ * Join the non-empty parts with `&` (and) or `^` (or). `&` binds tighter than
19
+ * `^`, so an `or` child inside an `and` and a multi-part `and` child inside an
20
+ * `or` are parenthesized. A single non-empty part is returned as is — its kind
21
+ * is preserved so an enclosing node can still group it correctly.
22
+ */ function joinParts(children, kind) {
23
+ const parts = children.filter((child) => child.s);
24
+ if (parts.length === 0) return {
25
+ s: "",
26
+ kind: "leaf"
27
+ };
28
+ if (parts.length === 1) return parts[0];
29
+ const wrap = kind === "and" ? "or" : "and";
30
+ const separator = kind === "and" ? "&" : "^";
31
+ return {
32
+ s: parts.map((child) => child.kind === wrap ? `(${child.s})` : child.s).join(separator),
33
+ kind
34
+ };
35
+ }
36
+ /**
37
+ * URL serializer as a `walkFilter` visitor. The walker owns the traversal
38
+ * rules (every member of a node is an implicit AND, `undefined` logical keys
39
+ * are skipped, a single-member node passes through unwrapped, one comparison
40
+ * per operator), so the serializer only decides how to spell each node.
41
+ */ const urlVisitor = {
42
+ comparison(field, op, value) {
43
+ return {
44
+ s: op === "$eq" && value instanceof RegExp ? `${field}~=${serializeValue(value)}` : serializeComparison(field, op, value),
45
+ kind: "leaf"
46
+ };
47
+ },
48
+ and: (children) => joinParts(children, "and"),
49
+ or: (children) => joinParts(children, "or"),
50
+ not: (child) => ({
51
+ s: child.s ? `!(${child.s})` : "",
52
+ kind: "leaf"
53
+ })
54
+ };
55
+ /**
56
+ * Serialize a filter expression. Every member of a node is an implicit AND,
57
+ * in key insertion order — comparison fields and logical operators may be
58
+ * mixed in the same object (Mongo semantics), e.g.
59
+ * `{ id: 101, $or: [...] }` → `id=101&(…^…)`.
60
+ */ function serializeFilter(expr) {
61
+ return (0, _uniqu_core.walkFilter)(expr, urlVisitor)?.s ?? "";
50
62
  }
51
63
  function serializeComparison(field, op, value) {
52
64
  switch (op) {
@@ -131,7 +143,7 @@ function serializeControls(controls) {
131
143
  if (controls.$having) {
132
144
  const havingStr = serializeFilter(controls.$having);
133
145
  if (havingStr) {
134
- const part = "$and" in controls.$having ? `$having=(${havingStr})` : `$having=${havingStr}`;
146
+ const part = havingStr.includes("&") ? `$having=(${havingStr})` : `$having=${havingStr}`;
135
147
  result = result ? result + "&" + part : part;
136
148
  }
137
149
  }
package/dist/builder.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { isPrimitive } from "@uniqu/core";
1
+ import { walkFilter } from "@uniqu/core";
2
2
 
3
3
  //#region packages/url/src/builder.ts
4
4
  /**
@@ -13,39 +13,51 @@ import { isPrimitive } from "@uniqu/core";
13
13
  if (filterStr && controlStr) return filterStr + "&" + controlStr;
14
14
  return filterStr || controlStr;
15
15
  }
16
- function serializeFilter(expr, parentOp) {
17
- if ("$and" in expr && expr.$and !== void 0) {
18
- let result = "";
19
- for (const child of expr.$and) {
20
- const s = serializeFilter(child, "$and");
21
- if (s) result = result ? result + "&" + s : s;
22
- }
23
- return result;
24
- }
25
- if ("$or" in expr && expr.$or !== void 0) {
26
- let result = "";
27
- for (const child of expr.$or) {
28
- const s = serializeFilter(child, "$or");
29
- if (s) result = result ? result + "^" + s : s;
30
- }
31
- return parentOp === "$and" && result ? `(${result})` : result;
32
- }
33
- if ("$not" in expr && expr.$not !== void 0) {
34
- const inner = serializeFilter(expr.$not);
35
- return inner ? `!(${inner})` : "";
36
- }
37
- let result = "";
38
- for (const [field, value] of Object.entries(expr)) if (value instanceof RegExp) {
39
- const part = `${field}~=${serializeValue(value)}`;
40
- result = result ? result + "&" + part : part;
41
- } else if (isPrimitive(value)) {
42
- const part = `${field}=${serializeValue(value)}`;
43
- result = result ? result + "&" + part : part;
44
- } else for (const [op, opValue] of Object.entries(value)) {
45
- const part = serializeComparison(field, op, opValue);
46
- result = result ? result + "&" + part : part;
47
- }
48
- return parentOp === "$or" && result.includes("&") ? `(${result})` : result;
16
+ /**
17
+ * Join the non-empty parts with `&` (and) or `^` (or). `&` binds tighter than
18
+ * `^`, so an `or` child inside an `and` and a multi-part `and` child inside an
19
+ * `or` are parenthesized. A single non-empty part is returned as is — its kind
20
+ * is preserved so an enclosing node can still group it correctly.
21
+ */ function joinParts(children, kind) {
22
+ const parts = children.filter((child) => child.s);
23
+ if (parts.length === 0) return {
24
+ s: "",
25
+ kind: "leaf"
26
+ };
27
+ if (parts.length === 1) return parts[0];
28
+ const wrap = kind === "and" ? "or" : "and";
29
+ const separator = kind === "and" ? "&" : "^";
30
+ return {
31
+ s: parts.map((child) => child.kind === wrap ? `(${child.s})` : child.s).join(separator),
32
+ kind
33
+ };
34
+ }
35
+ /**
36
+ * URL serializer as a `walkFilter` visitor. The walker owns the traversal
37
+ * rules (every member of a node is an implicit AND, `undefined` logical keys
38
+ * are skipped, a single-member node passes through unwrapped, one comparison
39
+ * per operator), so the serializer only decides how to spell each node.
40
+ */ const urlVisitor = {
41
+ comparison(field, op, value) {
42
+ return {
43
+ s: op === "$eq" && value instanceof RegExp ? `${field}~=${serializeValue(value)}` : serializeComparison(field, op, value),
44
+ kind: "leaf"
45
+ };
46
+ },
47
+ and: (children) => joinParts(children, "and"),
48
+ or: (children) => joinParts(children, "or"),
49
+ not: (child) => ({
50
+ s: child.s ? `!(${child.s})` : "",
51
+ kind: "leaf"
52
+ })
53
+ };
54
+ /**
55
+ * Serialize a filter expression. Every member of a node is an implicit AND,
56
+ * in key insertion order — comparison fields and logical operators may be
57
+ * mixed in the same object (Mongo semantics), e.g.
58
+ * `{ id: 101, $or: [...] }` → `id=101&(…^…)`.
59
+ */ function serializeFilter(expr) {
60
+ return walkFilter(expr, urlVisitor)?.s ?? "";
49
61
  }
50
62
  function serializeComparison(field, op, value) {
51
63
  switch (op) {
@@ -130,7 +142,7 @@ function serializeControls(controls) {
130
142
  if (controls.$having) {
131
143
  const havingStr = serializeFilter(controls.$having);
132
144
  if (havingStr) {
133
- const part = "$and" in controls.$having ? `$having=(${havingStr})` : `$having=${havingStr}`;
145
+ const part = havingStr.includes("&") ? `$having=(${havingStr})` : `$having=${havingStr}`;
134
146
  result = result ? result + "&" + part : part;
135
147
  }
136
148
  }
package/dist/index.cjs CHANGED
@@ -298,7 +298,7 @@ function unescapeString(str) {
298
298
  const merged = [];
299
299
  let currentMerge = {};
300
300
  for (const node of nodes) {
301
- if ("$or" in node || "$and" in node || "$not" in node) {
301
+ if (Object.keys(node).some(_uniqu_core.isLogicalKey)) {
302
302
  merged.push(node);
303
303
  continue;
304
304
  }
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { isPrimitive } from "@uniqu/core";
1
+ import { isLogicalKey, isPrimitive } from "@uniqu/core";
2
2
 
3
3
  //#region packages/url/src/tokens.ts
4
4
  /**
@@ -297,7 +297,7 @@ function unescapeString(str) {
297
297
  const merged = [];
298
298
  let currentMerge = {};
299
299
  for (const node of nodes) {
300
- if ("$or" in node || "$and" in node || "$not" in node) {
300
+ if (Object.keys(node).some(isLogicalKey)) {
301
301
  merged.push(node);
302
302
  continue;
303
303
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqu/url",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
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.7"
48
+ "@uniqu/core": "^0.1.8"
49
49
  },
50
50
  "scripts": {
51
51
  "pub": "pnpm publish --access public",