@uniqu/url 0.0.6 → 0.1.0
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 +130 -2
- package/dist/builder.cjs +173 -0
- package/dist/builder.d.ts +12 -0
- package/dist/builder.mjs +172 -0
- package/dist/index.cjs +68 -36
- package/dist/index.mjs +68 -36
- package/package.json +19 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<img src="../../logo.svg" alt="uniqu" height="80">
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
|
-
URL query string parser
|
|
7
|
+
URL query string parser and builder for the [Uniqu](../../README.md) canonical query format. Human-readable URL syntax with full filter expressions, sorting, pagination, projection, and aggregation.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -151,11 +151,75 @@ 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
|
+
| `$groupBy` | — | `$groupBy=currency,region` | `{ $groupBy: ['currency', 'region'] }` |
|
|
154
155
|
| `$with` | — | `$with=posts,author` | `{ $with: [{ name: 'posts', filter: {}, controls: {} }, ...] }` |
|
|
155
156
|
| `$<custom>` | — | `$search=term` | `{ $search: 'term' }` |
|
|
156
157
|
|
|
157
158
|
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.
|
|
158
159
|
|
|
160
|
+
### Aggregate Functions in `$select`
|
|
161
|
+
|
|
162
|
+
`$select` supports aggregate function calls using `fn(field)` syntax. An optional alias can be specified with `:alias`:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
$select=sum(amount) → [{ $fn: 'sum', $field: 'amount', $as: 'sum_amount' }]
|
|
166
|
+
$select=sum(amount):total → [{ $fn: 'sum', $field: 'amount', $as: 'total' }]
|
|
167
|
+
$select=count(*) → [{ $fn: 'count', $field: '*', $as: 'count_star' }]
|
|
168
|
+
$select=sum(amount),currency → [{ $fn: 'sum', $field: 'amount', $as: 'sum_amount' }, 'currency']
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
When no alias is given, one is auto-generated as `{fn}_{field}` (with `*` becoming `star`).
|
|
172
|
+
|
|
173
|
+
Supported functions: `sum`, `count`, `avg`, `min`, `max`, plus any custom function name — consumers validate supported functions.
|
|
174
|
+
|
|
175
|
+
When aggregates are present, `$select` always uses the array form (even if `-` prefixed fields are mixed in).
|
|
176
|
+
|
|
177
|
+
### Grouping (`$groupBy`)
|
|
178
|
+
|
|
179
|
+
`$groupBy` declares which fields to group by. Comma-separated:
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
$groupBy=currency → ['currency']
|
|
183
|
+
$groupBy=currency,region → ['currency', 'region']
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
#### Aggregation Example
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
$select=sum(amount):total,count(*),currency&$groupBy=currency&$sort=-total&$limit=10
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Produces:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
{
|
|
196
|
+
controls: {
|
|
197
|
+
$select: [
|
|
198
|
+
'currency',
|
|
199
|
+
{ $fn: 'sum', $field: 'amount', $as: 'total' },
|
|
200
|
+
{ $fn: 'count', $field: '*', $as: 'count_star' },
|
|
201
|
+
],
|
|
202
|
+
$groupBy: ['currency'],
|
|
203
|
+
$sort: { total: -1 },
|
|
204
|
+
$limit: 10,
|
|
205
|
+
},
|
|
206
|
+
insights: Map {
|
|
207
|
+
'amount' => Set { 'sum' },
|
|
208
|
+
'*' => Set { 'count' },
|
|
209
|
+
'currency' => Set { '$select', '$groupBy' },
|
|
210
|
+
'total' => Set { '$order' },
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Insights track aggregate functions with bare names (`'sum'`, `'count'`), distinct from `$`-prefixed control ops (`'$select'`, `'$groupBy'`).
|
|
216
|
+
|
|
217
|
+
Aggregates and `$groupBy` work inside `$with` sub-queries too:
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
$with=orders($select=sum(total):revenue&$groupBy=status)
|
|
221
|
+
```
|
|
222
|
+
|
|
159
223
|
### Relation Loading (`$with`)
|
|
160
224
|
|
|
161
225
|
`$with` declares which relations to populate alongside the primary query. Relations are comma-separated:
|
|
@@ -279,7 +343,7 @@ Uniqu parses and types the `$with` declaration. The consumer (e.g. a database ad
|
|
|
279
343
|
|
|
280
344
|
## Insights
|
|
281
345
|
|
|
282
|
-
Insights are computed **eagerly** during URL parsing — a `Map<string, Set<InsightOp>>` recording which fields are used and with which operators. This includes
|
|
346
|
+
Insights are computed **eagerly** during URL parsing — a `Map<string, Set<InsightOp>>` recording which fields are used and with which operators. This includes filter operators, control usage (`$select`, `$order`, `$groupBy`), and aggregate functions (`sum`, `avg`, etc. — bare names without `$` prefix).
|
|
283
347
|
|
|
284
348
|
For queries constructed as JSON objects (not parsed from URL), use `computeInsights()` from `@uniqu/core` for **lazy** computation.
|
|
285
349
|
|
|
@@ -340,12 +404,76 @@ Produces:
|
|
|
340
404
|
}
|
|
341
405
|
```
|
|
342
406
|
|
|
407
|
+
## URL Builder
|
|
408
|
+
|
|
409
|
+
Build URL query strings from `Uniquery` objects — the inverse of `parseUrl`. Available as a separate entry point for optimal bundle size:
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
import { buildUrl } from '@uniqu/url/builder'
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
### Usage
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
const url = buildUrl({
|
|
419
|
+
filter: { status: 'active', age: { $gte: 18 } },
|
|
420
|
+
controls: {
|
|
421
|
+
$select: ['name', 'email'],
|
|
422
|
+
$sort: { createdAt: -1 },
|
|
423
|
+
$limit: 20,
|
|
424
|
+
},
|
|
425
|
+
})
|
|
426
|
+
// → "status=active&age>=18&$select=name,email&$sort=-createdAt&$limit=20"
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### `buildUrl(query: Uniquery): string`
|
|
430
|
+
|
|
431
|
+
Accepts a `Uniquery` object and returns a URL query string (without leading `?`).
|
|
432
|
+
|
|
433
|
+
All features are supported:
|
|
434
|
+
- Filter expressions (comparisons, `$and`/`$or`/`$not`, `$in`/`$nin`, `$exists`, `$regex`)
|
|
435
|
+
- Controls (`$select`, `$sort`, `$limit`, `$skip`, `$count`, `$groupBy`, `$with`)
|
|
436
|
+
- Aggregates in `$select` (`sum(amount):total`)
|
|
437
|
+
- Nested `$with` sub-queries
|
|
438
|
+
- Pass-through custom `$`-prefixed controls
|
|
439
|
+
|
|
440
|
+
### Value serialization
|
|
441
|
+
|
|
442
|
+
- Strings that look like numbers, booleans, or `null` are automatically quoted (`'25'`, `'true'`, `'null'`)
|
|
443
|
+
- Strings with special characters (`&`, `^`, `=`, spaces, quotes) are quoted and escaped
|
|
444
|
+
- Leading-zero numbers (`007`) stay as bare strings
|
|
445
|
+
- `Date` values are serialized as quoted ISO strings
|
|
446
|
+
- `RegExp` values are serialized as `/pattern/flags`
|
|
447
|
+
|
|
448
|
+
### Round-tripping
|
|
449
|
+
|
|
450
|
+
`buildUrl` produces output compatible with `parseUrl`:
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
import { parseUrl } from '@uniqu/url'
|
|
454
|
+
import { buildUrl } from '@uniqu/url/builder'
|
|
455
|
+
|
|
456
|
+
const query = { filter: { age: { $gte: 18 } }, controls: { $limit: 10 } }
|
|
457
|
+
const url = buildUrl(query)
|
|
458
|
+
const parsed = parseUrl(url)
|
|
459
|
+
// parsed.filter → { age: { $gte: 18 } }
|
|
460
|
+
// parsed.controls.$limit → 10
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Bundle optimization
|
|
464
|
+
|
|
465
|
+
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.
|
|
466
|
+
|
|
343
467
|
## API Reference
|
|
344
468
|
|
|
345
469
|
### `parseUrl(raw: string): UrlQuery`
|
|
346
470
|
|
|
347
471
|
Parse a URL query string (without the leading `?`) into the uniqu format.
|
|
348
472
|
|
|
473
|
+
### `buildUrl(query: Uniquery): string`
|
|
474
|
+
|
|
475
|
+
Build a URL query string from a `Uniquery` object. Imported from `@uniqu/url/builder`.
|
|
476
|
+
|
|
349
477
|
### `UrlQuery`
|
|
350
478
|
|
|
351
479
|
```ts
|
package/dist/builder.cjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
let _uniqu_core = require("@uniqu/core");
|
|
3
|
+
|
|
4
|
+
//#region packages/url/src/builder.ts
|
|
5
|
+
/**
|
|
6
|
+
* Build a URL query string from a Uniquery object.
|
|
7
|
+
* Produces output compatible with `parseUrl` from `@uniqu/url`.
|
|
8
|
+
*
|
|
9
|
+
* @param query - The canonical query to serialize
|
|
10
|
+
* @returns URL query string without leading "?"
|
|
11
|
+
*/ function buildUrl(query) {
|
|
12
|
+
const filterStr = query.filter ? serializeFilter(query.filter) : "";
|
|
13
|
+
const controlStr = query.controls ? serializeControls(query.controls) : "";
|
|
14
|
+
if (filterStr && controlStr) return filterStr + "&" + controlStr;
|
|
15
|
+
return filterStr || controlStr;
|
|
16
|
+
}
|
|
17
|
+
function serializeFilter(expr) {
|
|
18
|
+
if ("$and" in expr && expr.$and !== void 0) {
|
|
19
|
+
let result = "";
|
|
20
|
+
for (const child of expr.$and) {
|
|
21
|
+
const s = serializeFilter(child);
|
|
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);
|
|
30
|
+
if (s) result = result ? result + "^" + s : s;
|
|
31
|
+
}
|
|
32
|
+
return 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 ((0, _uniqu_core.isPrimitive)(value)) {
|
|
40
|
+
const part = `${field}=${serializeValue(value)}`;
|
|
41
|
+
result = result ? result + "&" + part : part;
|
|
42
|
+
} else for (const [op, opValue] of Object.entries(value)) {
|
|
43
|
+
const part = serializeComparison(field, op, opValue);
|
|
44
|
+
result = result ? result + "&" + part : part;
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
function serializeComparison(field, op, value) {
|
|
49
|
+
switch (op) {
|
|
50
|
+
case "$eq": return `${field}=${serializeValue(value)}`;
|
|
51
|
+
case "$ne": return `${field}!=${serializeValue(value)}`;
|
|
52
|
+
case "$gt": return `${field}>${serializeValue(value)}`;
|
|
53
|
+
case "$gte": return `${field}>=${serializeValue(value)}`;
|
|
54
|
+
case "$lt": return `${field}<${serializeValue(value)}`;
|
|
55
|
+
case "$lte": return `${field}<=${serializeValue(value)}`;
|
|
56
|
+
case "$regex": return `${field}~=${serializeValue(value)}`;
|
|
57
|
+
case "$in": {
|
|
58
|
+
let list = "";
|
|
59
|
+
for (const item of value) {
|
|
60
|
+
const s = serializeValue(item);
|
|
61
|
+
list = list ? list + "," + s : s;
|
|
62
|
+
}
|
|
63
|
+
return `${field}{${list}}`;
|
|
64
|
+
}
|
|
65
|
+
case "$nin": {
|
|
66
|
+
let list = "";
|
|
67
|
+
for (const item of value) {
|
|
68
|
+
const s = serializeValue(item);
|
|
69
|
+
list = list ? list + "," + s : s;
|
|
70
|
+
}
|
|
71
|
+
return `${field}!{${list}}`;
|
|
72
|
+
}
|
|
73
|
+
case "$exists": return value ? `$exists=${field}` : `$!exists=${field}`;
|
|
74
|
+
default: return `${field}${op}${serializeValue(value)}`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function serializeValue(value) {
|
|
78
|
+
if (value === null) return "null";
|
|
79
|
+
if (value === true) return "true";
|
|
80
|
+
if (value === false) return "false";
|
|
81
|
+
if (typeof value === "number") return String(value);
|
|
82
|
+
if (value instanceof RegExp) return value.toString();
|
|
83
|
+
if (value instanceof Date) return `'${value.toISOString()}'`;
|
|
84
|
+
const str = String(value);
|
|
85
|
+
if (str === "null" || str === "true" || str === "false") return `'${str}'`;
|
|
86
|
+
if (/^\d/.test(str) && !isNaN(Number(str)) && !str.startsWith("0")) return `'${str}'`;
|
|
87
|
+
if (str.startsWith("/") && /\/[gimsuy]*$/.test(str)) return str;
|
|
88
|
+
if (/[&^=!<>~(){},\s'\\]/.test(str)) return `'${str.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
89
|
+
return str;
|
|
90
|
+
}
|
|
91
|
+
const KNOWN_CONTROL_KEYS = new Set([
|
|
92
|
+
"$select",
|
|
93
|
+
"$groupBy",
|
|
94
|
+
"$sort",
|
|
95
|
+
"$limit",
|
|
96
|
+
"$skip",
|
|
97
|
+
"$count",
|
|
98
|
+
"$with"
|
|
99
|
+
]);
|
|
100
|
+
function serializeControls(controls) {
|
|
101
|
+
let result = "";
|
|
102
|
+
if (controls.$select) {
|
|
103
|
+
let seg = "";
|
|
104
|
+
if (Array.isArray(controls.$select)) for (const entry of controls.$select) {
|
|
105
|
+
let s;
|
|
106
|
+
if (typeof entry === "string") s = entry;
|
|
107
|
+
else {
|
|
108
|
+
const agg = entry;
|
|
109
|
+
s = agg.$as ? `${agg.$fn}(${agg.$field}):${agg.$as}` : `${agg.$fn}(${agg.$field})`;
|
|
110
|
+
}
|
|
111
|
+
seg = seg ? seg + "," + s : s;
|
|
112
|
+
}
|
|
113
|
+
else for (const [field, val] of Object.entries(controls.$select)) {
|
|
114
|
+
const s = val === 0 ? `-${field}` : field;
|
|
115
|
+
seg = seg ? seg + "," + s : s;
|
|
116
|
+
}
|
|
117
|
+
if (seg) result = `$select=${seg}`;
|
|
118
|
+
}
|
|
119
|
+
if (controls.$groupBy?.length) {
|
|
120
|
+
let seg = "";
|
|
121
|
+
for (const field of controls.$groupBy) seg = seg ? seg + "," + field : field;
|
|
122
|
+
const part = `$groupBy=${seg}`;
|
|
123
|
+
result = result ? result + "&" + part : part;
|
|
124
|
+
}
|
|
125
|
+
if (controls.$sort) {
|
|
126
|
+
let seg = "";
|
|
127
|
+
for (const [field, dir] of Object.entries(controls.$sort)) {
|
|
128
|
+
const s = dir === -1 ? `-${field}` : field;
|
|
129
|
+
seg = seg ? seg + "," + s : s;
|
|
130
|
+
}
|
|
131
|
+
if (seg) {
|
|
132
|
+
const part = `$sort=${seg}`;
|
|
133
|
+
result = result ? result + "&" + part : part;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (controls.$limit !== void 0) {
|
|
137
|
+
const part = `$limit=${controls.$limit}`;
|
|
138
|
+
result = result ? result + "&" + part : part;
|
|
139
|
+
}
|
|
140
|
+
if (controls.$skip !== void 0) {
|
|
141
|
+
const part = `$skip=${controls.$skip}`;
|
|
142
|
+
result = result ? result + "&" + part : part;
|
|
143
|
+
}
|
|
144
|
+
if (controls.$count) result = result ? result + "&$count" : "$count";
|
|
145
|
+
if (controls.$with) {
|
|
146
|
+
let seg = "";
|
|
147
|
+
for (const entry of controls.$with) {
|
|
148
|
+
let s;
|
|
149
|
+
if (typeof entry === "string") s = entry;
|
|
150
|
+
else {
|
|
151
|
+
const rel = entry;
|
|
152
|
+
const inner = buildUrl({
|
|
153
|
+
filter: rel.filter,
|
|
154
|
+
controls: rel.controls
|
|
155
|
+
});
|
|
156
|
+
s = inner ? `${rel.name}(${inner})` : rel.name;
|
|
157
|
+
}
|
|
158
|
+
seg = seg ? seg + "," + s : s;
|
|
159
|
+
}
|
|
160
|
+
if (seg) {
|
|
161
|
+
const part = `$with=${seg}`;
|
|
162
|
+
result = result ? result + "&" + part : part;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const [key, value] of Object.entries(controls)) if (key.startsWith("$") && !KNOWN_CONTROL_KEYS.has(key)) {
|
|
166
|
+
const part = value !== void 0 && value !== "" ? `${key}=${value}` : key;
|
|
167
|
+
result = result ? result + "&" + part : part;
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
//#endregion
|
|
173
|
+
exports.buildUrl = buildUrl;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Uniquery } from '@uniqu/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build a URL query string from a Uniquery object.
|
|
5
|
+
* Produces output compatible with `parseUrl` from `@uniqu/url`.
|
|
6
|
+
*
|
|
7
|
+
* @param query - The canonical query to serialize
|
|
8
|
+
* @returns URL query string without leading "?"
|
|
9
|
+
*/
|
|
10
|
+
declare function buildUrl(query: Uniquery): string;
|
|
11
|
+
|
|
12
|
+
export { buildUrl };
|
package/dist/builder.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { isPrimitive } from "@uniqu/core";
|
|
2
|
+
|
|
3
|
+
//#region packages/url/src/builder.ts
|
|
4
|
+
/**
|
|
5
|
+
* Build a URL query string from a Uniquery object.
|
|
6
|
+
* Produces output compatible with `parseUrl` from `@uniqu/url`.
|
|
7
|
+
*
|
|
8
|
+
* @param query - The canonical query to serialize
|
|
9
|
+
* @returns URL query string without leading "?"
|
|
10
|
+
*/ function buildUrl(query) {
|
|
11
|
+
const filterStr = query.filter ? serializeFilter(query.filter) : "";
|
|
12
|
+
const controlStr = query.controls ? serializeControls(query.controls) : "";
|
|
13
|
+
if (filterStr && controlStr) return filterStr + "&" + controlStr;
|
|
14
|
+
return filterStr || controlStr;
|
|
15
|
+
}
|
|
16
|
+
function serializeFilter(expr) {
|
|
17
|
+
if ("$and" in expr && expr.$and !== void 0) {
|
|
18
|
+
let result = "";
|
|
19
|
+
for (const child of expr.$and) {
|
|
20
|
+
const s = serializeFilter(child);
|
|
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);
|
|
29
|
+
if (s) result = result ? result + "^" + s : s;
|
|
30
|
+
}
|
|
31
|
+
return 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 (isPrimitive(value)) {
|
|
39
|
+
const part = `${field}=${serializeValue(value)}`;
|
|
40
|
+
result = result ? result + "&" + part : part;
|
|
41
|
+
} else for (const [op, opValue] of Object.entries(value)) {
|
|
42
|
+
const part = serializeComparison(field, op, opValue);
|
|
43
|
+
result = result ? result + "&" + part : part;
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
function serializeComparison(field, op, value) {
|
|
48
|
+
switch (op) {
|
|
49
|
+
case "$eq": return `${field}=${serializeValue(value)}`;
|
|
50
|
+
case "$ne": return `${field}!=${serializeValue(value)}`;
|
|
51
|
+
case "$gt": return `${field}>${serializeValue(value)}`;
|
|
52
|
+
case "$gte": return `${field}>=${serializeValue(value)}`;
|
|
53
|
+
case "$lt": return `${field}<${serializeValue(value)}`;
|
|
54
|
+
case "$lte": return `${field}<=${serializeValue(value)}`;
|
|
55
|
+
case "$regex": return `${field}~=${serializeValue(value)}`;
|
|
56
|
+
case "$in": {
|
|
57
|
+
let list = "";
|
|
58
|
+
for (const item of value) {
|
|
59
|
+
const s = serializeValue(item);
|
|
60
|
+
list = list ? list + "," + s : s;
|
|
61
|
+
}
|
|
62
|
+
return `${field}{${list}}`;
|
|
63
|
+
}
|
|
64
|
+
case "$nin": {
|
|
65
|
+
let list = "";
|
|
66
|
+
for (const item of value) {
|
|
67
|
+
const s = serializeValue(item);
|
|
68
|
+
list = list ? list + "," + s : s;
|
|
69
|
+
}
|
|
70
|
+
return `${field}!{${list}}`;
|
|
71
|
+
}
|
|
72
|
+
case "$exists": return value ? `$exists=${field}` : `$!exists=${field}`;
|
|
73
|
+
default: return `${field}${op}${serializeValue(value)}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function serializeValue(value) {
|
|
77
|
+
if (value === null) return "null";
|
|
78
|
+
if (value === true) return "true";
|
|
79
|
+
if (value === false) return "false";
|
|
80
|
+
if (typeof value === "number") return String(value);
|
|
81
|
+
if (value instanceof RegExp) return value.toString();
|
|
82
|
+
if (value instanceof Date) return `'${value.toISOString()}'`;
|
|
83
|
+
const str = String(value);
|
|
84
|
+
if (str === "null" || str === "true" || str === "false") return `'${str}'`;
|
|
85
|
+
if (/^\d/.test(str) && !isNaN(Number(str)) && !str.startsWith("0")) return `'${str}'`;
|
|
86
|
+
if (str.startsWith("/") && /\/[gimsuy]*$/.test(str)) return str;
|
|
87
|
+
if (/[&^=!<>~(){},\s'\\]/.test(str)) return `'${str.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
88
|
+
return str;
|
|
89
|
+
}
|
|
90
|
+
const KNOWN_CONTROL_KEYS = new Set([
|
|
91
|
+
"$select",
|
|
92
|
+
"$groupBy",
|
|
93
|
+
"$sort",
|
|
94
|
+
"$limit",
|
|
95
|
+
"$skip",
|
|
96
|
+
"$count",
|
|
97
|
+
"$with"
|
|
98
|
+
]);
|
|
99
|
+
function serializeControls(controls) {
|
|
100
|
+
let result = "";
|
|
101
|
+
if (controls.$select) {
|
|
102
|
+
let seg = "";
|
|
103
|
+
if (Array.isArray(controls.$select)) for (const entry of controls.$select) {
|
|
104
|
+
let s;
|
|
105
|
+
if (typeof entry === "string") s = entry;
|
|
106
|
+
else {
|
|
107
|
+
const agg = entry;
|
|
108
|
+
s = agg.$as ? `${agg.$fn}(${agg.$field}):${agg.$as}` : `${agg.$fn}(${agg.$field})`;
|
|
109
|
+
}
|
|
110
|
+
seg = seg ? seg + "," + s : s;
|
|
111
|
+
}
|
|
112
|
+
else for (const [field, val] of Object.entries(controls.$select)) {
|
|
113
|
+
const s = val === 0 ? `-${field}` : field;
|
|
114
|
+
seg = seg ? seg + "," + s : s;
|
|
115
|
+
}
|
|
116
|
+
if (seg) result = `$select=${seg}`;
|
|
117
|
+
}
|
|
118
|
+
if (controls.$groupBy?.length) {
|
|
119
|
+
let seg = "";
|
|
120
|
+
for (const field of controls.$groupBy) seg = seg ? seg + "," + field : field;
|
|
121
|
+
const part = `$groupBy=${seg}`;
|
|
122
|
+
result = result ? result + "&" + part : part;
|
|
123
|
+
}
|
|
124
|
+
if (controls.$sort) {
|
|
125
|
+
let seg = "";
|
|
126
|
+
for (const [field, dir] of Object.entries(controls.$sort)) {
|
|
127
|
+
const s = dir === -1 ? `-${field}` : field;
|
|
128
|
+
seg = seg ? seg + "," + s : s;
|
|
129
|
+
}
|
|
130
|
+
if (seg) {
|
|
131
|
+
const part = `$sort=${seg}`;
|
|
132
|
+
result = result ? result + "&" + part : part;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (controls.$limit !== void 0) {
|
|
136
|
+
const part = `$limit=${controls.$limit}`;
|
|
137
|
+
result = result ? result + "&" + part : part;
|
|
138
|
+
}
|
|
139
|
+
if (controls.$skip !== void 0) {
|
|
140
|
+
const part = `$skip=${controls.$skip}`;
|
|
141
|
+
result = result ? result + "&" + part : part;
|
|
142
|
+
}
|
|
143
|
+
if (controls.$count) result = result ? result + "&$count" : "$count";
|
|
144
|
+
if (controls.$with) {
|
|
145
|
+
let seg = "";
|
|
146
|
+
for (const entry of controls.$with) {
|
|
147
|
+
let s;
|
|
148
|
+
if (typeof entry === "string") s = entry;
|
|
149
|
+
else {
|
|
150
|
+
const rel = entry;
|
|
151
|
+
const inner = buildUrl({
|
|
152
|
+
filter: rel.filter,
|
|
153
|
+
controls: rel.controls
|
|
154
|
+
});
|
|
155
|
+
s = inner ? `${rel.name}(${inner})` : rel.name;
|
|
156
|
+
}
|
|
157
|
+
seg = seg ? seg + "," + s : s;
|
|
158
|
+
}
|
|
159
|
+
if (seg) {
|
|
160
|
+
const part = `$with=${seg}`;
|
|
161
|
+
result = result ? result + "&" + part : part;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const [key, value] of Object.entries(controls)) if (key.startsWith("$") && !KNOWN_CONTROL_KEYS.has(key)) {
|
|
165
|
+
const part = value !== void 0 && value !== "" ? `${key}=${value}` : key;
|
|
166
|
+
result = result ? result + "&" + part : part;
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
//#endregion
|
|
172
|
+
export { buildUrl };
|
package/dist/index.cjs
CHANGED
|
@@ -289,7 +289,7 @@ var Parser = class {
|
|
|
289
289
|
}
|
|
290
290
|
};
|
|
291
291
|
function unescapeString(str) {
|
|
292
|
-
return str.replace(/(^'|'$)/gu, "");
|
|
292
|
+
return str.replace(/(^'|'$)/gu, "").replace(/\\(.)/gu, "$1");
|
|
293
293
|
}
|
|
294
294
|
/**
|
|
295
295
|
* Attempt to merge an array of simple nodes produced by `parseTerm`.
|
|
@@ -305,14 +305,14 @@ function unescapeString(str) {
|
|
|
305
305
|
for (const [key, val] of Object.entries(node)) if (key in currentMerge) {
|
|
306
306
|
const currentVal = currentMerge[key];
|
|
307
307
|
const currentOps = (0, _uniqu_core.isPrimitive)(currentVal) ? ["$eq"] : Object.keys(currentVal);
|
|
308
|
-
const otherOps = (0, _uniqu_core.isPrimitive)(val) ?
|
|
308
|
+
const otherOps = (0, _uniqu_core.isPrimitive)(val) ? new Set(["$eq"]) : new Set(Object.keys(val));
|
|
309
309
|
if (currentOps.some((op) => otherOps.has(op))) {
|
|
310
310
|
merged.push(currentMerge);
|
|
311
311
|
currentMerge = {};
|
|
312
312
|
} else {
|
|
313
313
|
currentMerge[key] = {};
|
|
314
314
|
for (const op of currentOps) currentMerge[key][op] = (0, _uniqu_core.isPrimitive)(currentVal) ? currentVal : currentVal[op];
|
|
315
|
-
for (const op of
|
|
315
|
+
for (const op of otherOps) currentMerge[key][op] = (0, _uniqu_core.isPrimitive)(val) ? val : val[op];
|
|
316
316
|
}
|
|
317
317
|
} else currentMerge[key] = val;
|
|
318
318
|
}
|
|
@@ -403,14 +403,16 @@ function handleControls(parts) {
|
|
|
403
403
|
const controls = {};
|
|
404
404
|
const controlInsights = [];
|
|
405
405
|
for (const raw of parts) {
|
|
406
|
-
const
|
|
407
|
-
const
|
|
406
|
+
const eqIdx = raw.indexOf("=");
|
|
407
|
+
const key = eqIdx === -1 ? raw : raw.slice(0, eqIdx);
|
|
408
|
+
const value = eqIdx === -1 ? "" : raw.slice(eqIdx + 1);
|
|
408
409
|
switch (key) {
|
|
409
410
|
case "$with": {
|
|
410
411
|
var _controls;
|
|
411
412
|
if (!value) break;
|
|
412
413
|
(_controls = controls).$with ?? (_controls.$with = []);
|
|
413
|
-
const seen = new Set(
|
|
414
|
+
const seen = /* @__PURE__ */ new Set();
|
|
415
|
+
for (const r of controls.$with) seen.add(typeof r === "string" ? r : r.name);
|
|
414
416
|
for (const seg of splitTopLevel(value, ",")) {
|
|
415
417
|
const rel = parseWithSegment(seg);
|
|
416
418
|
if (!rel || seen.has(rel.name)) continue;
|
|
@@ -425,35 +427,50 @@ function handleControls(parts) {
|
|
|
425
427
|
break;
|
|
426
428
|
}
|
|
427
429
|
case "$select": {
|
|
430
|
+
const items = value.split(",");
|
|
428
431
|
let hasExclusion = false;
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if (!f)
|
|
432
|
-
if (f.startsWith("-"))
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
432
|
+
let hasAggregate = false;
|
|
433
|
+
for (const f of items) {
|
|
434
|
+
if (!f) continue;
|
|
435
|
+
if (f.startsWith("-")) hasExclusion = true;
|
|
436
|
+
else if (/^\w+\(/.test(f)) hasAggregate = true;
|
|
437
|
+
}
|
|
438
|
+
if (hasAggregate || !hasExclusion) {
|
|
439
|
+
const arr = Array.isArray(controls.$select) ? controls.$select : [];
|
|
440
|
+
for (const f of items) {
|
|
441
|
+
if (!f || /^\w+\(/.test(f)) continue;
|
|
442
|
+
arr.push(f);
|
|
443
|
+
controlInsights.push([f, "$select"]);
|
|
444
|
+
}
|
|
445
|
+
for (const f of items) {
|
|
446
|
+
if (!f) continue;
|
|
447
|
+
const aggMatch = /^(\w+)\((\*|[\w.]+)\)(?::([\w.]+))?$/.exec(f);
|
|
448
|
+
if (!aggMatch) continue;
|
|
449
|
+
const fn = aggMatch[1];
|
|
450
|
+
const field = aggMatch[2];
|
|
451
|
+
const alias = aggMatch[3] ?? (field === "*" ? `${fn}_star` : `${fn}_${field}`);
|
|
452
|
+
arr.push({
|
|
453
|
+
$fn: fn,
|
|
454
|
+
$field: field,
|
|
455
|
+
$as: alias
|
|
437
456
|
});
|
|
438
|
-
|
|
439
|
-
name: f,
|
|
440
|
-
include: true
|
|
441
|
-
});
|
|
442
|
-
});
|
|
443
|
-
if (hasExclusion) {
|
|
444
|
-
const obj = controls.$select ?? {};
|
|
445
|
-
for (const { name, include } of fields) {
|
|
446
|
-
obj[name] = include ? 1 : 0;
|
|
447
|
-
controlInsights.push([name, "$select"]);
|
|
457
|
+
controlInsights.push([field, fn]);
|
|
448
458
|
}
|
|
449
|
-
controls.$select =
|
|
459
|
+
controls.$select = arr;
|
|
450
460
|
} else {
|
|
451
|
-
const
|
|
452
|
-
for (const
|
|
453
|
-
|
|
454
|
-
|
|
461
|
+
const obj = controls.$select ?? {};
|
|
462
|
+
for (const f of items) {
|
|
463
|
+
if (!f) continue;
|
|
464
|
+
if (f.startsWith("-")) {
|
|
465
|
+
const name = f.slice(1);
|
|
466
|
+
obj[name] = 0;
|
|
467
|
+
controlInsights.push([name, "$select"]);
|
|
468
|
+
} else {
|
|
469
|
+
obj[f] = 1;
|
|
470
|
+
controlInsights.push([f, "$select"]);
|
|
471
|
+
}
|
|
455
472
|
}
|
|
456
|
-
controls.$select =
|
|
473
|
+
controls.$select = obj;
|
|
457
474
|
}
|
|
458
475
|
break;
|
|
459
476
|
}
|
|
@@ -461,12 +478,27 @@ function handleControls(parts) {
|
|
|
461
478
|
case "$order":
|
|
462
479
|
var _controls1;
|
|
463
480
|
(_controls1 = controls).$sort ?? (_controls1.$sort = {});
|
|
464
|
-
value.split(",")
|
|
465
|
-
if (!f)
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
481
|
+
for (const f of value.split(",")) {
|
|
482
|
+
if (!f) continue;
|
|
483
|
+
if (f.startsWith("-")) {
|
|
484
|
+
const name = f.slice(1);
|
|
485
|
+
controls.$sort[name] = -1;
|
|
486
|
+
controlInsights.push([name, "$order"]);
|
|
487
|
+
} else {
|
|
488
|
+
controls.$sort[f] = 1;
|
|
489
|
+
controlInsights.push([f, "$order"]);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
break;
|
|
493
|
+
case "$groupBy":
|
|
494
|
+
var _controls2;
|
|
495
|
+
if (!value) break;
|
|
496
|
+
(_controls2 = controls).$groupBy ?? (_controls2.$groupBy = []);
|
|
497
|
+
for (const f of value.split(",")) {
|
|
498
|
+
if (!f) continue;
|
|
499
|
+
controls.$groupBy.push(f);
|
|
500
|
+
controlInsights.push([f, "$groupBy"]);
|
|
501
|
+
}
|
|
470
502
|
break;
|
|
471
503
|
case "$limit":
|
|
472
504
|
case "$top":
|
package/dist/index.mjs
CHANGED
|
@@ -288,7 +288,7 @@ var Parser = class {
|
|
|
288
288
|
}
|
|
289
289
|
};
|
|
290
290
|
function unescapeString(str) {
|
|
291
|
-
return str.replace(/(^'|'$)/gu, "");
|
|
291
|
+
return str.replace(/(^'|'$)/gu, "").replace(/\\(.)/gu, "$1");
|
|
292
292
|
}
|
|
293
293
|
/**
|
|
294
294
|
* Attempt to merge an array of simple nodes produced by `parseTerm`.
|
|
@@ -304,14 +304,14 @@ function unescapeString(str) {
|
|
|
304
304
|
for (const [key, val] of Object.entries(node)) if (key in currentMerge) {
|
|
305
305
|
const currentVal = currentMerge[key];
|
|
306
306
|
const currentOps = isPrimitive(currentVal) ? ["$eq"] : Object.keys(currentVal);
|
|
307
|
-
const otherOps = isPrimitive(val) ?
|
|
307
|
+
const otherOps = isPrimitive(val) ? new Set(["$eq"]) : new Set(Object.keys(val));
|
|
308
308
|
if (currentOps.some((op) => otherOps.has(op))) {
|
|
309
309
|
merged.push(currentMerge);
|
|
310
310
|
currentMerge = {};
|
|
311
311
|
} else {
|
|
312
312
|
currentMerge[key] = {};
|
|
313
313
|
for (const op of currentOps) currentMerge[key][op] = isPrimitive(currentVal) ? currentVal : currentVal[op];
|
|
314
|
-
for (const op of
|
|
314
|
+
for (const op of otherOps) currentMerge[key][op] = isPrimitive(val) ? val : val[op];
|
|
315
315
|
}
|
|
316
316
|
} else currentMerge[key] = val;
|
|
317
317
|
}
|
|
@@ -402,14 +402,16 @@ function handleControls(parts) {
|
|
|
402
402
|
const controls = {};
|
|
403
403
|
const controlInsights = [];
|
|
404
404
|
for (const raw of parts) {
|
|
405
|
-
const
|
|
406
|
-
const
|
|
405
|
+
const eqIdx = raw.indexOf("=");
|
|
406
|
+
const key = eqIdx === -1 ? raw : raw.slice(0, eqIdx);
|
|
407
|
+
const value = eqIdx === -1 ? "" : raw.slice(eqIdx + 1);
|
|
407
408
|
switch (key) {
|
|
408
409
|
case "$with": {
|
|
409
410
|
var _controls;
|
|
410
411
|
if (!value) break;
|
|
411
412
|
(_controls = controls).$with ?? (_controls.$with = []);
|
|
412
|
-
const seen = new Set(
|
|
413
|
+
const seen = /* @__PURE__ */ new Set();
|
|
414
|
+
for (const r of controls.$with) seen.add(typeof r === "string" ? r : r.name);
|
|
413
415
|
for (const seg of splitTopLevel(value, ",")) {
|
|
414
416
|
const rel = parseWithSegment(seg);
|
|
415
417
|
if (!rel || seen.has(rel.name)) continue;
|
|
@@ -424,35 +426,50 @@ function handleControls(parts) {
|
|
|
424
426
|
break;
|
|
425
427
|
}
|
|
426
428
|
case "$select": {
|
|
429
|
+
const items = value.split(",");
|
|
427
430
|
let hasExclusion = false;
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
if (!f)
|
|
431
|
-
if (f.startsWith("-"))
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
431
|
+
let hasAggregate = false;
|
|
432
|
+
for (const f of items) {
|
|
433
|
+
if (!f) continue;
|
|
434
|
+
if (f.startsWith("-")) hasExclusion = true;
|
|
435
|
+
else if (/^\w+\(/.test(f)) hasAggregate = true;
|
|
436
|
+
}
|
|
437
|
+
if (hasAggregate || !hasExclusion) {
|
|
438
|
+
const arr = Array.isArray(controls.$select) ? controls.$select : [];
|
|
439
|
+
for (const f of items) {
|
|
440
|
+
if (!f || /^\w+\(/.test(f)) continue;
|
|
441
|
+
arr.push(f);
|
|
442
|
+
controlInsights.push([f, "$select"]);
|
|
443
|
+
}
|
|
444
|
+
for (const f of items) {
|
|
445
|
+
if (!f) continue;
|
|
446
|
+
const aggMatch = /^(\w+)\((\*|[\w.]+)\)(?::([\w.]+))?$/.exec(f);
|
|
447
|
+
if (!aggMatch) continue;
|
|
448
|
+
const fn = aggMatch[1];
|
|
449
|
+
const field = aggMatch[2];
|
|
450
|
+
const alias = aggMatch[3] ?? (field === "*" ? `${fn}_star` : `${fn}_${field}`);
|
|
451
|
+
arr.push({
|
|
452
|
+
$fn: fn,
|
|
453
|
+
$field: field,
|
|
454
|
+
$as: alias
|
|
436
455
|
});
|
|
437
|
-
|
|
438
|
-
name: f,
|
|
439
|
-
include: true
|
|
440
|
-
});
|
|
441
|
-
});
|
|
442
|
-
if (hasExclusion) {
|
|
443
|
-
const obj = controls.$select ?? {};
|
|
444
|
-
for (const { name, include } of fields) {
|
|
445
|
-
obj[name] = include ? 1 : 0;
|
|
446
|
-
controlInsights.push([name, "$select"]);
|
|
456
|
+
controlInsights.push([field, fn]);
|
|
447
457
|
}
|
|
448
|
-
controls.$select =
|
|
458
|
+
controls.$select = arr;
|
|
449
459
|
} else {
|
|
450
|
-
const
|
|
451
|
-
for (const
|
|
452
|
-
|
|
453
|
-
|
|
460
|
+
const obj = controls.$select ?? {};
|
|
461
|
+
for (const f of items) {
|
|
462
|
+
if (!f) continue;
|
|
463
|
+
if (f.startsWith("-")) {
|
|
464
|
+
const name = f.slice(1);
|
|
465
|
+
obj[name] = 0;
|
|
466
|
+
controlInsights.push([name, "$select"]);
|
|
467
|
+
} else {
|
|
468
|
+
obj[f] = 1;
|
|
469
|
+
controlInsights.push([f, "$select"]);
|
|
470
|
+
}
|
|
454
471
|
}
|
|
455
|
-
controls.$select =
|
|
472
|
+
controls.$select = obj;
|
|
456
473
|
}
|
|
457
474
|
break;
|
|
458
475
|
}
|
|
@@ -460,12 +477,27 @@ function handleControls(parts) {
|
|
|
460
477
|
case "$order":
|
|
461
478
|
var _controls1;
|
|
462
479
|
(_controls1 = controls).$sort ?? (_controls1.$sort = {});
|
|
463
|
-
value.split(",")
|
|
464
|
-
if (!f)
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
480
|
+
for (const f of value.split(",")) {
|
|
481
|
+
if (!f) continue;
|
|
482
|
+
if (f.startsWith("-")) {
|
|
483
|
+
const name = f.slice(1);
|
|
484
|
+
controls.$sort[name] = -1;
|
|
485
|
+
controlInsights.push([name, "$order"]);
|
|
486
|
+
} else {
|
|
487
|
+
controls.$sort[f] = 1;
|
|
488
|
+
controlInsights.push([f, "$order"]);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
break;
|
|
492
|
+
case "$groupBy":
|
|
493
|
+
var _controls2;
|
|
494
|
+
if (!value) break;
|
|
495
|
+
(_controls2 = controls).$groupBy ?? (_controls2.$groupBy = []);
|
|
496
|
+
for (const f of value.split(",")) {
|
|
497
|
+
if (!f) continue;
|
|
498
|
+
controls.$groupBy.push(f);
|
|
499
|
+
controlInsights.push([f, "$groupBy"]);
|
|
500
|
+
}
|
|
469
501
|
break;
|
|
470
502
|
case "$limit":
|
|
471
503
|
case "$top":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniqu/url",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "URL query string parser producing the Uniqu canonical query format",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Artem Maltsev",
|
|
@@ -22,13 +22,30 @@
|
|
|
22
22
|
"import": "./dist/index.mjs",
|
|
23
23
|
"require": "./dist/index.cjs"
|
|
24
24
|
},
|
|
25
|
+
"./builder": {
|
|
26
|
+
"types": "./dist/builder.d.ts",
|
|
27
|
+
"import": "./dist/builder.mjs",
|
|
28
|
+
"require": "./dist/builder.cjs"
|
|
29
|
+
},
|
|
25
30
|
"./package.json": "./package.json"
|
|
26
31
|
},
|
|
32
|
+
"build": [
|
|
33
|
+
{
|
|
34
|
+
"entries": [
|
|
35
|
+
"src/index.ts"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"entries": [
|
|
40
|
+
"src/builder.ts"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
],
|
|
27
44
|
"files": [
|
|
28
45
|
"dist"
|
|
29
46
|
],
|
|
30
47
|
"dependencies": {
|
|
31
|
-
"@uniqu/core": "^0.0
|
|
48
|
+
"@uniqu/core": "^0.1.0"
|
|
32
49
|
},
|
|
33
50
|
"scripts": {
|
|
34
51
|
"pub": "pnpm publish --access public",
|