@uniqu/url 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 moostjs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # @uniqu/url
2
+
3
+ <p align="center">
4
+ <img src="../../logo.svg" alt="uniqu" height="80">
5
+ </p>
6
+
7
+ URL query string parser that produces the [Uniqu](../../README.md) canonical query format. Human-readable URL syntax with full filter expressions, sorting, pagination, and projection.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm add @uniqu/url
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import { parseUrl } from '@uniqu/url'
19
+
20
+ const { filter, controls, insights } = parseUrl(
21
+ 'age>=18&status!=DELETED&name~=/^Jo/i&$select=name,email&$limit=20'
22
+ )
23
+ ```
24
+
25
+ **Result:**
26
+
27
+ ```ts
28
+ filter = {
29
+ age: { $gte: 18 },
30
+ status: { $ne: 'DELETED' },
31
+ name: { $regex: '/^Jo/i' },
32
+ }
33
+
34
+ controls = {
35
+ $select: ['name', 'email'],
36
+ $limit: 20,
37
+ }
38
+
39
+ insights = Map {
40
+ 'age' => Set { '$gte' },
41
+ 'status' => Set { '$ne' },
42
+ 'name' => Set { '$regex', '$select' },
43
+ 'email' => Set { '$select' },
44
+ }
45
+ ```
46
+
47
+ ## Query Syntax
48
+
49
+ ### Comparison Operators
50
+
51
+ | Syntax | Operator | Example | Result |
52
+ |--------|----------|---------|--------|
53
+ | `=` | `$eq` | `status=ACTIVE` | `{ status: 'ACTIVE' }` |
54
+ | `!=` | `$ne` | `status!=DELETED` | `{ status: { $ne: 'DELETED' } }` |
55
+ | `>` | `$gt` | `age>25` | `{ age: { $gt: 25 } }` |
56
+ | `>=` | `$gte` | `age>=18` | `{ age: { $gte: 18 } }` |
57
+ | `<` | `$lt` | `price<100` | `{ price: { $lt: 100 } }` |
58
+ | `<=` | `$lte` | `price<=99.99` | `{ price: { $lte: 99.99 } }` |
59
+ | `~=` | `$regex` | `name~=/^Jo/i` | `{ name: { $regex: '/^Jo/i' } }` |
60
+
61
+ ### Lists (IN / NOT IN)
62
+
63
+ ```
64
+ role{Admin,Editor} → { role: { $in: ['Admin', 'Editor'] } }
65
+ status!{Draft,Deleted} → { status: { $nin: ['Draft', 'Deleted'] } }
66
+ ```
67
+
68
+ ### Between
69
+
70
+ ```
71
+ 25<age<35 → { age: { $gt: 25, $lt: 35 } }
72
+ 25<=age<=35 → { age: { $gte: 25, $lte: 35 } }
73
+ ```
74
+
75
+ ### Exists
76
+
77
+ ```
78
+ $exists=phone,email → { phone: { $exists: true }, email: { $exists: true } }
79
+ $!exists=deletedAt → { deletedAt: { $exists: false } }
80
+ ```
81
+
82
+ ### Negation (NOT)
83
+
84
+ `!(...)` negates a grouped expression:
85
+
86
+ ```
87
+ !(status=DELETED)
88
+ → { $not: { status: 'DELETED' } }
89
+
90
+ !(age>18&status=active)
91
+ → { $not: { age: { $gt: 18 }, status: 'active' } }
92
+
93
+ !(status=DELETED^status=ARCHIVED)
94
+ → { $not: { $or: [{ status: 'DELETED' }, { status: 'ARCHIVED' }] } }
95
+ ```
96
+
97
+ `$not` can be combined with other operators via `&` and `^`:
98
+
99
+ ```
100
+ !(role{Guest,Anonymous})&age>=18
101
+ → { $and: [{ $not: { role: { $in: ['Guest', 'Anonymous'] } } }, { age: { $gte: 18 } }] }
102
+ ```
103
+
104
+ ### Logical Operators
105
+
106
+ `&` is AND (higher precedence), `^` is OR (lower precedence). Parentheses override precedence:
107
+
108
+ ```
109
+ age>25^score>550&status=VIP
110
+ → { $or: [{ age: { $gt: 25 } }, { score: { $gt: 550 }, status: 'VIP' }] }
111
+
112
+ (age>25^score>550)&status=VIP
113
+ → { $and: [{ $or: [{ age: { $gt: 25 } }, { score: { $gt: 550 } }] }, { status: 'VIP' }] }
114
+ ```
115
+
116
+ Adjacent AND conditions on the same field are merged when safe:
117
+
118
+ ```
119
+ age>=18&age<=30 → { age: { $gte: 18, $lte: 30 } }
120
+ ```
121
+
122
+ ### Literal Types
123
+
124
+ | Syntax | Type | Examples |
125
+ |--------|------|---------|
126
+ | Bare number | `number` | `42`, `-3.14`, `0` |
127
+ | Leading zero | `string` | `007`, `00`, `01` |
128
+ | `true` / `false` | `boolean` | `flag=true` |
129
+ | `null` | `null` | `deleted=null` |
130
+ | `'quoted'` | `string` | `name='John Doe'` |
131
+ | Bare word | `string` | `status=ACTIVE` |
132
+ | `/pattern/flags` | `string` | `name~=/^Jo/i` |
133
+
134
+ ### Percent Encoding
135
+
136
+ All parts are decoded with `decodeURIComponent()` before parsing. Encode special characters in URLs:
137
+
138
+ ```
139
+ name=%27John%20Doe%27 → name: 'John Doe'
140
+ name~=%2F%5EJo%2Fi → name: { $regex: '/^Jo/i' }
141
+ ```
142
+
143
+ ## Control Keywords
144
+
145
+ Control keywords start with `$` and are separated from filter expressions:
146
+
147
+ | Keyword | Aliases | Example | Result |
148
+ |---------|---------|---------|--------|
149
+ | `$select` | — | `$select=name,email` | `{ $select: ['name', 'email'] }` |
150
+ | `$order` | `$sort` | `$order=-createdAt,score` | `{ $sort: { createdAt: -1, score: 1 } }` |
151
+ | `$limit` | `$top` | `$limit=20` | `{ $limit: 20 }` |
152
+ | `$skip` | — | `$skip=40` | `{ $skip: 40 }` |
153
+ | `$count` | — | `$count` | `{ $count: true }` |
154
+ | `$<custom>` | — | `$search=term` | `{ $search: 'term' }` |
155
+
156
+ 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
+ ## Insights
159
+
160
+ 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`).
161
+
162
+ For queries constructed as JSON objects (not parsed from URL), use `computeInsights()` from `@uniqu/core` for **lazy** computation.
163
+
164
+ ## Full Example
165
+
166
+ ```
167
+ $select=firstName,-client.ssn
168
+ &$order=-createdAt,score
169
+ &$limit=50&$skip=10
170
+ &$count
171
+ &$exists=client.phone
172
+ &$!exists=deletedAt
173
+ &age>=18&age<=30
174
+ &status!=DELETED
175
+ &name~=/^Jo/i
176
+ &role{Admin,Editor}
177
+ &25<height<35
178
+ ^score>550&price>50&price<100
179
+ ```
180
+
181
+ Produces:
182
+
183
+ ```ts
184
+ {
185
+ filter: {
186
+ $or: [
187
+ {
188
+ 'client.phone': { $exists: true },
189
+ deletedAt: { $exists: false },
190
+ age: { $gte: 18, $lte: 30 },
191
+ status: { $ne: 'DELETED' },
192
+ name: { $regex: '/^Jo/i' },
193
+ role: { $in: ['Admin', 'Editor'] },
194
+ height: { $gt: 25, $lt: 35 },
195
+ },
196
+ {
197
+ score: { $gt: 550 },
198
+ price: { $gt: 50, $lt: 100 },
199
+ },
200
+ ],
201
+ },
202
+ controls: {
203
+ $select: { firstName: 1, 'client.ssn': 0 },
204
+ $sort: { createdAt: -1, score: 1 },
205
+ $limit: 50,
206
+ $skip: 10,
207
+ $count: true,
208
+ },
209
+ }
210
+ ```
211
+
212
+ ## API Reference
213
+
214
+ ### `parseUrl(raw: string): UrlQuery`
215
+
216
+ Parse a URL query string (without the leading `?`) into the uniqu format.
217
+
218
+ ### `UrlQuery`
219
+
220
+ ```ts
221
+ interface UrlQuery extends Uniquery {
222
+ insights: UniqueryInsights
223
+ }
224
+ ```
225
+
226
+ Narrows the optional `insights` field from `Uniquery` to required — insights are eagerly computed during URL parsing. Use `getInsights()` from `@uniqu/core` to transparently handle both URL-parsed queries (pre-computed) and manually constructed queries (lazy).
227
+
228
+ ## License
229
+
230
+ [MIT](../../LICENSE)
package/dist/index.cjs ADDED
@@ -0,0 +1,438 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ let _uniqu_core = require("@uniqu/core");
3
+
4
+ //#region packages/url/src/tokens.ts
5
+ /**
6
+ * Order matters:
7
+ * - keywords before generic words
8
+ * - multi-char operators (>=, <=, !=, ~=) before single-char
9
+ * - literals before identifiers
10
+ */ const tokens = [
11
+ {
12
+ r: /^\/(?:\\.|[^\\/])*\/[imsux]*/u,
13
+ type: "regex"
14
+ },
15
+ {
16
+ r: /^'(?:\\.|[^'\\])*'/u,
17
+ type: "string"
18
+ },
19
+ {
20
+ r: /^-?(?:0(?!\d)|[1-9]\d*)(?:\.\d+)?(?!\d)/u,
21
+ type: "number"
22
+ },
23
+ {
24
+ r: /^(?:true|false)/u,
25
+ type: "boolean"
26
+ },
27
+ {
28
+ r: /^null/u,
29
+ type: "null"
30
+ },
31
+ {
32
+ r: /^!=/u,
33
+ type: "op-ne"
34
+ },
35
+ {
36
+ r: /^>=/u,
37
+ type: "op-gte"
38
+ },
39
+ {
40
+ r: /^<=/u,
41
+ type: "op-lte"
42
+ },
43
+ {
44
+ r: /^~=/u,
45
+ type: "op-regex"
46
+ },
47
+ {
48
+ r: /^=/u,
49
+ type: "op-eq"
50
+ },
51
+ {
52
+ r: /^>/u,
53
+ type: "op-gt"
54
+ },
55
+ {
56
+ r: /^</u,
57
+ type: "op-lt"
58
+ },
59
+ {
60
+ r: /^\^/u,
61
+ type: "or"
62
+ },
63
+ {
64
+ r: /^&/u,
65
+ type: "and"
66
+ },
67
+ {
68
+ r: /^\(/u,
69
+ type: "lparen"
70
+ },
71
+ {
72
+ r: /^\)/u,
73
+ type: "rparen"
74
+ },
75
+ {
76
+ r: /^\{/u,
77
+ type: "lbrace"
78
+ },
79
+ {
80
+ r: /^\}/u,
81
+ type: "rbrace"
82
+ },
83
+ {
84
+ r: /^,/u,
85
+ type: "comma"
86
+ },
87
+ {
88
+ r: /^!/u,
89
+ type: "bang"
90
+ },
91
+ {
92
+ r: /^\$!?[A-Za-z0-9_]+/u,
93
+ type: "keyword"
94
+ },
95
+ {
96
+ r: /^(?:[^&^)\s=><!]+(?:\s|\+)+[^&^)=><!]*)+/u,
97
+ type: "string"
98
+ },
99
+ {
100
+ r: /^[A-Za-z0-9_.]+/u,
101
+ type: "word"
102
+ },
103
+ {
104
+ r: /^[\s]+/u,
105
+ type: "ws"
106
+ }
107
+ ];
108
+ const tokenMap = new Map(tokens.map((t) => [t.type, t.r]));
109
+ function lex(input) {
110
+ const tokensOut = [];
111
+ let idx = 0;
112
+ while (idx < input.length) {
113
+ let matched = false;
114
+ for (const { r, type } of tokens) {
115
+ r.lastIndex = 0;
116
+ const slice = input.slice(idx);
117
+ const m = r.exec(slice);
118
+ if (m) {
119
+ matched = true;
120
+ if (type !== "ws") tokensOut.push({
121
+ type,
122
+ value: m[0],
123
+ pos: idx
124
+ });
125
+ idx += m[0].length;
126
+ break;
127
+ }
128
+ }
129
+ if (!matched) throw new SyntaxError(`Unexpected char '${input[idx]}' at ${idx} --- ${input}`);
130
+ }
131
+ return tokensOut;
132
+ }
133
+
134
+ //#endregion
135
+ //#region packages/url/src/parser.ts
136
+ function _define_property(obj, key, value) {
137
+ if (key in obj) Object.defineProperty(obj, key, {
138
+ value,
139
+ enumerable: true,
140
+ configurable: true,
141
+ writable: true
142
+ });
143
+ else obj[key] = value;
144
+ return obj;
145
+ }
146
+ const opMap = {
147
+ "op-eq": "$eq",
148
+ "op-ne": "$ne",
149
+ "op-gt": "$gt",
150
+ "op-gte": "$gte",
151
+ "op-lt": "$lt",
152
+ "op-lte": "$lte",
153
+ "op-regex": "$regex"
154
+ };
155
+ var Parser = class {
156
+ peek(offset = 0) {
157
+ return this.t[this.i + offset];
158
+ }
159
+ consume(type) {
160
+ const tok = this.t[this.i++];
161
+ if (type && tok.type !== type) throw new SyntaxError(`Expected ${type}, got "${tok.value}" at pos ${tok.pos}`);
162
+ return tok;
163
+ }
164
+ match(type) {
165
+ if (this.peek()?.type === type) {
166
+ this.consume();
167
+ return true;
168
+ }
169
+ return false;
170
+ }
171
+ expectEof() {
172
+ if (this.i !== this.t.length) throw new SyntaxError(`Unexpected token at pos ${this.t[this.i]?.pos}. End of input expected.`);
173
+ }
174
+ captureInsight(field, op) {
175
+ let set = this.insights.get(field);
176
+ if (!set) {
177
+ set = /* @__PURE__ */ new Set();
178
+ this.insights.set(field, set);
179
+ }
180
+ set.add(op);
181
+ }
182
+ getInsights() {
183
+ return this.insights;
184
+ }
185
+ /** expression := disjunction */ parseExpression() {
186
+ return this.parseDisjunction();
187
+ }
188
+ parseDisjunction() {
189
+ let node = this.parseConjunction();
190
+ const orNodes = [node];
191
+ while (this.match("or")) orNodes.push(this.parseConjunction());
192
+ return orNodes.length === 1 ? node : { $or: orNodes };
193
+ }
194
+ parseConjunction() {
195
+ const nodes = [this.parseTerm()];
196
+ while (this.match("and")) nodes.push(this.parseTerm());
197
+ if (nodes.length === 1) return nodes[0];
198
+ return mergeConjunction(nodes) ?? { $and: nodes };
199
+ }
200
+ parseTerm() {
201
+ if (this.peek()?.type === "bang" && this.peek(1)?.type === "lparen") {
202
+ this.consume("bang");
203
+ this.consume("lparen");
204
+ const inside = this.parseDisjunction();
205
+ this.consume("rparen");
206
+ return { $not: inside };
207
+ }
208
+ if (this.match("lparen")) {
209
+ const inside = this.parseDisjunction();
210
+ this.consume("rparen");
211
+ return inside;
212
+ }
213
+ if (this.peek().type === "number" || this.peek().type === "string") {
214
+ const lhsLit = this.parseLiteral();
215
+ const firstOp = this.consume().type;
216
+ if (firstOp !== "op-lt" && firstOp !== "op-lte") this.i -= 2;
217
+ else {
218
+ const field = this.consume("word").value;
219
+ const secondOpTok = this.consume();
220
+ if (secondOpTok.type !== "op-lt" && secondOpTok.type !== "op-lte") throw new SyntaxError(`Invalid between syntax at pos ${secondOpTok.pos}`);
221
+ const rhsLit = this.parseLiteral();
222
+ const out = {};
223
+ const op1 = firstOp === "op-lt" ? "$gt" : "$gte";
224
+ const op2 = secondOpTok.type === "op-lt" ? "$lt" : "$lte";
225
+ out[field] = {
226
+ [op1]: lhsLit,
227
+ [op2]: rhsLit
228
+ };
229
+ this.captureInsight(field, op1);
230
+ this.captureInsight(field, op2);
231
+ return out;
232
+ }
233
+ }
234
+ if (this.peek().type === "keyword") {
235
+ const kwTok = this.peek();
236
+ if (kwTok.value === "$exists" || kwTok.value === "$!exists") {
237
+ this.consume("keyword");
238
+ this.consume("op-eq");
239
+ const fields = [];
240
+ fields.push(this.consume("word").value);
241
+ while (this.match("comma")) fields.push(this.consume("word").value);
242
+ for (const field of fields) this.captureInsight(field, "$exists");
243
+ return buildExists(fields, kwTok.value === "$exists");
244
+ }
245
+ }
246
+ if (this.peek().type === "word" && this.peek(1)?.type === "lbrace" || this.peek(1)?.type === "bang" && this.peek(2)?.type === "lbrace") {
247
+ const field = this.consume("word").value;
248
+ let negate = false;
249
+ if (this.match("bang")) negate = true;
250
+ this.consume("lbrace");
251
+ const list = [];
252
+ list.push(this.parseLiteral());
253
+ while (this.match("comma")) list.push(this.parseLiteral());
254
+ this.consume("rbrace");
255
+ const out = {};
256
+ const op = negate ? "$nin" : "$in";
257
+ out[field] = { [op]: list };
258
+ this.captureInsight(field, op);
259
+ return out;
260
+ }
261
+ const fieldTok = this.consume("word");
262
+ const opTok = this.consume();
263
+ const lit = this.parseLiteral();
264
+ const op = opMap[opTok.type];
265
+ const field = fieldTok.value;
266
+ if (op === void 0) throw new SyntaxError(`Unsupported operator "${opTok.value}" at pos ${opTok.pos}`);
267
+ this.captureInsight(field, op);
268
+ return op === "$eq" ? { [field]: lit } : { [field]: { [op]: lit } };
269
+ }
270
+ parseLiteral() {
271
+ const tok = this.consume();
272
+ switch (tok.type) {
273
+ case "number": return Number(tok.value);
274
+ case "boolean": return tok.value === "true";
275
+ case "null": return null;
276
+ case "regex": return tok.value;
277
+ case "word": return tok.value;
278
+ case "string": return unescapeString(tok.value);
279
+ default: throw new SyntaxError(`Unexpected literal "${tok.value}" at pos ${tok.pos}`);
280
+ }
281
+ }
282
+ constructor(t) {
283
+ _define_property(this, "t", void 0);
284
+ _define_property(this, "i", void 0);
285
+ _define_property(this, "insights", void 0);
286
+ this.t = t;
287
+ this.i = 0;
288
+ this.insights = /* @__PURE__ */ new Map();
289
+ }
290
+ };
291
+ function unescapeString(str) {
292
+ return str.replace(/(^'|'$)/gu, "");
293
+ }
294
+ /**
295
+ * Attempt to merge an array of simple nodes produced by `parseTerm`.
296
+ * Returns a single flattened object if safe, or null on conflict.
297
+ */ function mergeConjunction(nodes) {
298
+ const merged = [];
299
+ let currentMerge = {};
300
+ for (const node of nodes) {
301
+ if ("$or" in node || "$and" in node || "$not" in node) {
302
+ merged.push(node);
303
+ continue;
304
+ }
305
+ for (const [key, val] of Object.entries(node)) if (key in currentMerge) {
306
+ const currentVal = currentMerge[key];
307
+ const currentOps = (0, _uniqu_core.isPrimitive)(currentVal) ? ["$eq"] : Object.keys(currentVal);
308
+ const otherOps = (0, _uniqu_core.isPrimitive)(val) ? /* @__PURE__ */ new Set("$eq") : new Set(Object.keys(val));
309
+ if (currentOps.some((op) => otherOps.has(op))) {
310
+ merged.push(currentMerge);
311
+ currentMerge = {};
312
+ } else {
313
+ currentMerge[key] = {};
314
+ for (const op of currentOps) currentMerge[key][op] = (0, _uniqu_core.isPrimitive)(currentVal) ? currentVal : currentVal[op];
315
+ for (const op of Array.from(otherOps)) currentMerge[key][op] = (0, _uniqu_core.isPrimitive)(val) ? val : val[op];
316
+ }
317
+ } else currentMerge[key] = val;
318
+ }
319
+ if (Object.keys(currentMerge).length > 0) merged.push(currentMerge);
320
+ return merged.length > 1 ? { $and: merged } : merged[0] ?? null;
321
+ }
322
+ function buildExists(fields, positive) {
323
+ const out = {};
324
+ for (const f of fields) out[f] = { $exists: positive };
325
+ return out;
326
+ }
327
+
328
+ //#endregion
329
+ //#region packages/url/src/parse-url.ts
330
+ /**
331
+ * Parse a URL query string into the uniqu canonical format.
332
+ *
333
+ * The string may contain:
334
+ * - logical connectors `&` (AND) and `^` (OR)
335
+ * - comparison operators (=, !=, >, >=, <, <=, ~=, in-list, nin-list, between)
336
+ * - grouping parentheses
337
+ * - control keywords that start with `$` (e.g. `$select`, `$limit`, `$order`)
338
+ *
339
+ * @param raw - Raw query string without the leading "?"
340
+ */ function parseUrl(raw) {
341
+ const parts = raw.split("&");
342
+ const controlParts = [];
343
+ const exprParts = [];
344
+ for (const _p of parts) {
345
+ const p = decodeURIComponent(_p);
346
+ if (/^\$[A-Za-z0-9_!]+/.test(p) && !p.startsWith("$exists=") && !p.startsWith("$!exists=")) controlParts.push(p);
347
+ else if (p.length) exprParts.push(p);
348
+ }
349
+ const { controls, selectInsights, orderInsights } = handleControls(controlParts);
350
+ let filter = {};
351
+ let parser;
352
+ if (exprParts.length) {
353
+ parser = new Parser(lex(exprParts.join("&")));
354
+ filter = parser.parseExpression();
355
+ parser.expectEof();
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");
359
+ return {
360
+ filter,
361
+ controls,
362
+ insights: parser.getInsights()
363
+ };
364
+ }
365
+ function handleControls(parts) {
366
+ const controls = {};
367
+ const selectInsights = /* @__PURE__ */ new Set();
368
+ const orderInsights = /* @__PURE__ */ new Set();
369
+ for (const raw of parts) {
370
+ const [key, ...rest] = raw.split("=");
371
+ const value = decodeURIComponent(rest.join("="));
372
+ switch (key) {
373
+ case "$select": {
374
+ let hasExclusion = false;
375
+ const fields = [];
376
+ value.split(",").forEach((f) => {
377
+ if (!f) return;
378
+ if (f.startsWith("-")) {
379
+ hasExclusion = true;
380
+ fields.push({
381
+ name: f.slice(1),
382
+ include: false
383
+ });
384
+ } else fields.push({
385
+ name: f,
386
+ include: true
387
+ });
388
+ });
389
+ if (hasExclusion) {
390
+ const obj = controls.$select ?? {};
391
+ for (const { name, include } of fields) {
392
+ obj[name] = include ? 1 : 0;
393
+ selectInsights.add(name);
394
+ }
395
+ controls.$select = obj;
396
+ } else {
397
+ const arr = Array.isArray(controls.$select) ? controls.$select : [];
398
+ for (const { name } of fields) {
399
+ arr.push(name);
400
+ selectInsights.add(name);
401
+ }
402
+ controls.$select = arr;
403
+ }
404
+ break;
405
+ }
406
+ case "$sort":
407
+ case "$order":
408
+ var _controls;
409
+ (_controls = controls).$sort ?? (_controls.$sort = {});
410
+ value.split(",").forEach((f) => {
411
+ if (!f) return;
412
+ orderInsights.add(f.replace(/^-/, ""));
413
+ if (f.startsWith("-")) controls.$sort[f.slice(1)] = -1;
414
+ else controls.$sort[f] = 1;
415
+ });
416
+ break;
417
+ case "$limit":
418
+ case "$top":
419
+ controls.$limit = Number(value);
420
+ break;
421
+ case "$skip":
422
+ controls.$skip = Number(value);
423
+ break;
424
+ case "$count":
425
+ controls.$count = true;
426
+ break;
427
+ default: controls[key] = value;
428
+ }
429
+ }
430
+ return {
431
+ controls,
432
+ selectInsights,
433
+ orderInsights
434
+ };
435
+ }
436
+
437
+ //#endregion
438
+ exports.parseUrl = parseUrl;
@@ -0,0 +1,21 @@
1
+ import { Uniquery, UniqueryInsights } from '@uniqu/core';
2
+
3
+ /** Result of parsing a URL query string. Narrows `Uniquery.insights` from optional to required (eagerly computed during parsing). */
4
+ interface UrlQuery extends Uniquery {
5
+ insights: UniqueryInsights;
6
+ }
7
+ /**
8
+ * Parse a URL query string into the uniqu canonical format.
9
+ *
10
+ * The string may contain:
11
+ * - logical connectors `&` (AND) and `^` (OR)
12
+ * - comparison operators (=, !=, >, >=, <, <=, ~=, in-list, nin-list, between)
13
+ * - grouping parentheses
14
+ * - control keywords that start with `$` (e.g. `$select`, `$limit`, `$order`)
15
+ *
16
+ * @param raw - Raw query string without the leading "?"
17
+ */
18
+ declare function parseUrl(raw: string): UrlQuery;
19
+
20
+ export { parseUrl };
21
+ export type { UrlQuery };
package/dist/index.mjs ADDED
@@ -0,0 +1,437 @@
1
+ import { isPrimitive } from "@uniqu/core";
2
+
3
+ //#region packages/url/src/tokens.ts
4
+ /**
5
+ * Order matters:
6
+ * - keywords before generic words
7
+ * - multi-char operators (>=, <=, !=, ~=) before single-char
8
+ * - literals before identifiers
9
+ */ const tokens = [
10
+ {
11
+ r: /^\/(?:\\.|[^\\/])*\/[imsux]*/u,
12
+ type: "regex"
13
+ },
14
+ {
15
+ r: /^'(?:\\.|[^'\\])*'/u,
16
+ type: "string"
17
+ },
18
+ {
19
+ r: /^-?(?:0(?!\d)|[1-9]\d*)(?:\.\d+)?(?!\d)/u,
20
+ type: "number"
21
+ },
22
+ {
23
+ r: /^(?:true|false)/u,
24
+ type: "boolean"
25
+ },
26
+ {
27
+ r: /^null/u,
28
+ type: "null"
29
+ },
30
+ {
31
+ r: /^!=/u,
32
+ type: "op-ne"
33
+ },
34
+ {
35
+ r: /^>=/u,
36
+ type: "op-gte"
37
+ },
38
+ {
39
+ r: /^<=/u,
40
+ type: "op-lte"
41
+ },
42
+ {
43
+ r: /^~=/u,
44
+ type: "op-regex"
45
+ },
46
+ {
47
+ r: /^=/u,
48
+ type: "op-eq"
49
+ },
50
+ {
51
+ r: /^>/u,
52
+ type: "op-gt"
53
+ },
54
+ {
55
+ r: /^</u,
56
+ type: "op-lt"
57
+ },
58
+ {
59
+ r: /^\^/u,
60
+ type: "or"
61
+ },
62
+ {
63
+ r: /^&/u,
64
+ type: "and"
65
+ },
66
+ {
67
+ r: /^\(/u,
68
+ type: "lparen"
69
+ },
70
+ {
71
+ r: /^\)/u,
72
+ type: "rparen"
73
+ },
74
+ {
75
+ r: /^\{/u,
76
+ type: "lbrace"
77
+ },
78
+ {
79
+ r: /^\}/u,
80
+ type: "rbrace"
81
+ },
82
+ {
83
+ r: /^,/u,
84
+ type: "comma"
85
+ },
86
+ {
87
+ r: /^!/u,
88
+ type: "bang"
89
+ },
90
+ {
91
+ r: /^\$!?[A-Za-z0-9_]+/u,
92
+ type: "keyword"
93
+ },
94
+ {
95
+ r: /^(?:[^&^)\s=><!]+(?:\s|\+)+[^&^)=><!]*)+/u,
96
+ type: "string"
97
+ },
98
+ {
99
+ r: /^[A-Za-z0-9_.]+/u,
100
+ type: "word"
101
+ },
102
+ {
103
+ r: /^[\s]+/u,
104
+ type: "ws"
105
+ }
106
+ ];
107
+ const tokenMap = new Map(tokens.map((t) => [t.type, t.r]));
108
+ function lex(input) {
109
+ const tokensOut = [];
110
+ let idx = 0;
111
+ while (idx < input.length) {
112
+ let matched = false;
113
+ for (const { r, type } of tokens) {
114
+ r.lastIndex = 0;
115
+ const slice = input.slice(idx);
116
+ const m = r.exec(slice);
117
+ if (m) {
118
+ matched = true;
119
+ if (type !== "ws") tokensOut.push({
120
+ type,
121
+ value: m[0],
122
+ pos: idx
123
+ });
124
+ idx += m[0].length;
125
+ break;
126
+ }
127
+ }
128
+ if (!matched) throw new SyntaxError(`Unexpected char '${input[idx]}' at ${idx} --- ${input}`);
129
+ }
130
+ return tokensOut;
131
+ }
132
+
133
+ //#endregion
134
+ //#region packages/url/src/parser.ts
135
+ function _define_property(obj, key, value) {
136
+ if (key in obj) Object.defineProperty(obj, key, {
137
+ value,
138
+ enumerable: true,
139
+ configurable: true,
140
+ writable: true
141
+ });
142
+ else obj[key] = value;
143
+ return obj;
144
+ }
145
+ const opMap = {
146
+ "op-eq": "$eq",
147
+ "op-ne": "$ne",
148
+ "op-gt": "$gt",
149
+ "op-gte": "$gte",
150
+ "op-lt": "$lt",
151
+ "op-lte": "$lte",
152
+ "op-regex": "$regex"
153
+ };
154
+ var Parser = class {
155
+ peek(offset = 0) {
156
+ return this.t[this.i + offset];
157
+ }
158
+ consume(type) {
159
+ const tok = this.t[this.i++];
160
+ if (type && tok.type !== type) throw new SyntaxError(`Expected ${type}, got "${tok.value}" at pos ${tok.pos}`);
161
+ return tok;
162
+ }
163
+ match(type) {
164
+ if (this.peek()?.type === type) {
165
+ this.consume();
166
+ return true;
167
+ }
168
+ return false;
169
+ }
170
+ expectEof() {
171
+ if (this.i !== this.t.length) throw new SyntaxError(`Unexpected token at pos ${this.t[this.i]?.pos}. End of input expected.`);
172
+ }
173
+ captureInsight(field, op) {
174
+ let set = this.insights.get(field);
175
+ if (!set) {
176
+ set = /* @__PURE__ */ new Set();
177
+ this.insights.set(field, set);
178
+ }
179
+ set.add(op);
180
+ }
181
+ getInsights() {
182
+ return this.insights;
183
+ }
184
+ /** expression := disjunction */ parseExpression() {
185
+ return this.parseDisjunction();
186
+ }
187
+ parseDisjunction() {
188
+ let node = this.parseConjunction();
189
+ const orNodes = [node];
190
+ while (this.match("or")) orNodes.push(this.parseConjunction());
191
+ return orNodes.length === 1 ? node : { $or: orNodes };
192
+ }
193
+ parseConjunction() {
194
+ const nodes = [this.parseTerm()];
195
+ while (this.match("and")) nodes.push(this.parseTerm());
196
+ if (nodes.length === 1) return nodes[0];
197
+ return mergeConjunction(nodes) ?? { $and: nodes };
198
+ }
199
+ parseTerm() {
200
+ if (this.peek()?.type === "bang" && this.peek(1)?.type === "lparen") {
201
+ this.consume("bang");
202
+ this.consume("lparen");
203
+ const inside = this.parseDisjunction();
204
+ this.consume("rparen");
205
+ return { $not: inside };
206
+ }
207
+ if (this.match("lparen")) {
208
+ const inside = this.parseDisjunction();
209
+ this.consume("rparen");
210
+ return inside;
211
+ }
212
+ if (this.peek().type === "number" || this.peek().type === "string") {
213
+ const lhsLit = this.parseLiteral();
214
+ const firstOp = this.consume().type;
215
+ if (firstOp !== "op-lt" && firstOp !== "op-lte") this.i -= 2;
216
+ else {
217
+ const field = this.consume("word").value;
218
+ const secondOpTok = this.consume();
219
+ if (secondOpTok.type !== "op-lt" && secondOpTok.type !== "op-lte") throw new SyntaxError(`Invalid between syntax at pos ${secondOpTok.pos}`);
220
+ const rhsLit = this.parseLiteral();
221
+ const out = {};
222
+ const op1 = firstOp === "op-lt" ? "$gt" : "$gte";
223
+ const op2 = secondOpTok.type === "op-lt" ? "$lt" : "$lte";
224
+ out[field] = {
225
+ [op1]: lhsLit,
226
+ [op2]: rhsLit
227
+ };
228
+ this.captureInsight(field, op1);
229
+ this.captureInsight(field, op2);
230
+ return out;
231
+ }
232
+ }
233
+ if (this.peek().type === "keyword") {
234
+ const kwTok = this.peek();
235
+ if (kwTok.value === "$exists" || kwTok.value === "$!exists") {
236
+ this.consume("keyword");
237
+ this.consume("op-eq");
238
+ const fields = [];
239
+ fields.push(this.consume("word").value);
240
+ while (this.match("comma")) fields.push(this.consume("word").value);
241
+ for (const field of fields) this.captureInsight(field, "$exists");
242
+ return buildExists(fields, kwTok.value === "$exists");
243
+ }
244
+ }
245
+ if (this.peek().type === "word" && this.peek(1)?.type === "lbrace" || this.peek(1)?.type === "bang" && this.peek(2)?.type === "lbrace") {
246
+ const field = this.consume("word").value;
247
+ let negate = false;
248
+ if (this.match("bang")) negate = true;
249
+ this.consume("lbrace");
250
+ const list = [];
251
+ list.push(this.parseLiteral());
252
+ while (this.match("comma")) list.push(this.parseLiteral());
253
+ this.consume("rbrace");
254
+ const out = {};
255
+ const op = negate ? "$nin" : "$in";
256
+ out[field] = { [op]: list };
257
+ this.captureInsight(field, op);
258
+ return out;
259
+ }
260
+ const fieldTok = this.consume("word");
261
+ const opTok = this.consume();
262
+ const lit = this.parseLiteral();
263
+ const op = opMap[opTok.type];
264
+ const field = fieldTok.value;
265
+ if (op === void 0) throw new SyntaxError(`Unsupported operator "${opTok.value}" at pos ${opTok.pos}`);
266
+ this.captureInsight(field, op);
267
+ return op === "$eq" ? { [field]: lit } : { [field]: { [op]: lit } };
268
+ }
269
+ parseLiteral() {
270
+ const tok = this.consume();
271
+ switch (tok.type) {
272
+ case "number": return Number(tok.value);
273
+ case "boolean": return tok.value === "true";
274
+ case "null": return null;
275
+ case "regex": return tok.value;
276
+ case "word": return tok.value;
277
+ case "string": return unescapeString(tok.value);
278
+ default: throw new SyntaxError(`Unexpected literal "${tok.value}" at pos ${tok.pos}`);
279
+ }
280
+ }
281
+ constructor(t) {
282
+ _define_property(this, "t", void 0);
283
+ _define_property(this, "i", void 0);
284
+ _define_property(this, "insights", void 0);
285
+ this.t = t;
286
+ this.i = 0;
287
+ this.insights = /* @__PURE__ */ new Map();
288
+ }
289
+ };
290
+ function unescapeString(str) {
291
+ return str.replace(/(^'|'$)/gu, "");
292
+ }
293
+ /**
294
+ * Attempt to merge an array of simple nodes produced by `parseTerm`.
295
+ * Returns a single flattened object if safe, or null on conflict.
296
+ */ function mergeConjunction(nodes) {
297
+ const merged = [];
298
+ let currentMerge = {};
299
+ for (const node of nodes) {
300
+ if ("$or" in node || "$and" in node || "$not" in node) {
301
+ merged.push(node);
302
+ continue;
303
+ }
304
+ for (const [key, val] of Object.entries(node)) if (key in currentMerge) {
305
+ const currentVal = currentMerge[key];
306
+ const currentOps = isPrimitive(currentVal) ? ["$eq"] : Object.keys(currentVal);
307
+ const otherOps = isPrimitive(val) ? /* @__PURE__ */ new Set("$eq") : new Set(Object.keys(val));
308
+ if (currentOps.some((op) => otherOps.has(op))) {
309
+ merged.push(currentMerge);
310
+ currentMerge = {};
311
+ } else {
312
+ currentMerge[key] = {};
313
+ for (const op of currentOps) currentMerge[key][op] = isPrimitive(currentVal) ? currentVal : currentVal[op];
314
+ for (const op of Array.from(otherOps)) currentMerge[key][op] = isPrimitive(val) ? val : val[op];
315
+ }
316
+ } else currentMerge[key] = val;
317
+ }
318
+ if (Object.keys(currentMerge).length > 0) merged.push(currentMerge);
319
+ return merged.length > 1 ? { $and: merged } : merged[0] ?? null;
320
+ }
321
+ function buildExists(fields, positive) {
322
+ const out = {};
323
+ for (const f of fields) out[f] = { $exists: positive };
324
+ return out;
325
+ }
326
+
327
+ //#endregion
328
+ //#region packages/url/src/parse-url.ts
329
+ /**
330
+ * Parse a URL query string into the uniqu canonical format.
331
+ *
332
+ * The string may contain:
333
+ * - logical connectors `&` (AND) and `^` (OR)
334
+ * - comparison operators (=, !=, >, >=, <, <=, ~=, in-list, nin-list, between)
335
+ * - grouping parentheses
336
+ * - control keywords that start with `$` (e.g. `$select`, `$limit`, `$order`)
337
+ *
338
+ * @param raw - Raw query string without the leading "?"
339
+ */ function parseUrl(raw) {
340
+ const parts = raw.split("&");
341
+ const controlParts = [];
342
+ const exprParts = [];
343
+ for (const _p of parts) {
344
+ const p = decodeURIComponent(_p);
345
+ if (/^\$[A-Za-z0-9_!]+/.test(p) && !p.startsWith("$exists=") && !p.startsWith("$!exists=")) controlParts.push(p);
346
+ else if (p.length) exprParts.push(p);
347
+ }
348
+ const { controls, selectInsights, orderInsights } = handleControls(controlParts);
349
+ let filter = {};
350
+ let parser;
351
+ if (exprParts.length) {
352
+ parser = new Parser(lex(exprParts.join("&")));
353
+ filter = parser.parseExpression();
354
+ parser.expectEof();
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");
358
+ return {
359
+ filter,
360
+ controls,
361
+ insights: parser.getInsights()
362
+ };
363
+ }
364
+ function handleControls(parts) {
365
+ const controls = {};
366
+ const selectInsights = /* @__PURE__ */ new Set();
367
+ const orderInsights = /* @__PURE__ */ new Set();
368
+ for (const raw of parts) {
369
+ const [key, ...rest] = raw.split("=");
370
+ const value = decodeURIComponent(rest.join("="));
371
+ switch (key) {
372
+ case "$select": {
373
+ let hasExclusion = false;
374
+ const fields = [];
375
+ value.split(",").forEach((f) => {
376
+ if (!f) return;
377
+ if (f.startsWith("-")) {
378
+ hasExclusion = true;
379
+ fields.push({
380
+ name: f.slice(1),
381
+ include: false
382
+ });
383
+ } else fields.push({
384
+ name: f,
385
+ include: true
386
+ });
387
+ });
388
+ if (hasExclusion) {
389
+ const obj = controls.$select ?? {};
390
+ for (const { name, include } of fields) {
391
+ obj[name] = include ? 1 : 0;
392
+ selectInsights.add(name);
393
+ }
394
+ controls.$select = obj;
395
+ } else {
396
+ const arr = Array.isArray(controls.$select) ? controls.$select : [];
397
+ for (const { name } of fields) {
398
+ arr.push(name);
399
+ selectInsights.add(name);
400
+ }
401
+ controls.$select = arr;
402
+ }
403
+ break;
404
+ }
405
+ case "$sort":
406
+ case "$order":
407
+ var _controls;
408
+ (_controls = controls).$sort ?? (_controls.$sort = {});
409
+ value.split(",").forEach((f) => {
410
+ if (!f) return;
411
+ orderInsights.add(f.replace(/^-/, ""));
412
+ if (f.startsWith("-")) controls.$sort[f.slice(1)] = -1;
413
+ else controls.$sort[f] = 1;
414
+ });
415
+ break;
416
+ case "$limit":
417
+ case "$top":
418
+ controls.$limit = Number(value);
419
+ break;
420
+ case "$skip":
421
+ controls.$skip = Number(value);
422
+ break;
423
+ case "$count":
424
+ controls.$count = true;
425
+ break;
426
+ default: controls[key] = value;
427
+ }
428
+ }
429
+ return {
430
+ controls,
431
+ selectInsights,
432
+ orderInsights
433
+ };
434
+ }
435
+
436
+ //#endregion
437
+ export { parseUrl };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@uniqu/url",
3
+ "version": "0.0.1",
4
+ "description": "URL query string parser producing the Uniqu canonical query format",
5
+ "license": "MIT",
6
+ "author": "Artem Maltsev",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/moostjs/uniqu.git",
10
+ "directory": "packages/url"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/moostjs/uniqu/issues"
14
+ },
15
+ "homepage": "https://github.com/moostjs/uniqu/tree/main/packages/url#readme",
16
+ "type": "module",
17
+ "main": "dist/index.mjs",
18
+ "types": "dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.mjs",
23
+ "require": "./dist/index.cjs"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "dependencies": {
31
+ "@uniqu/core": "^0.0.1"
32
+ },
33
+ "scripts": {
34
+ "pub": "pnpm publish --access public",
35
+ "test": "vitest"
36
+ }
37
+ }