@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 +21 -0
- package/README.md +230 -0
- package/dist/index.cjs +438 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.mjs +437 -0
- package/package.json +37 -0
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;
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|