@ballistix.digital/odata-query-builder 0.2.0 → 0.3.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 +39 -101
- package/dist/module/query/service/queryChain.service.d.ts +2 -2
- package/dist/module/query/service/queryChain.service.js +2 -2
- package/dist/module/query/util/filter/scopeProxy.util.d.ts +1 -1
- package/dist/module/query/util/filter/scopeProxy.util.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,12 +6,30 @@ A typed, fluent builder for the OData v4 query strings that
|
|
|
6
6
|
It builds a **query string**. It does not fetch, and it does not know about the
|
|
7
7
|
`_embedded` / `_page` response envelope. It has **zero runtime dependencies**.
|
|
8
8
|
|
|
9
|
-
Full documentation lives in [`docs/`](docs/
|
|
10
|
-
|
|
9
|
+
Full documentation lives in [`docs/`](docs/index.md): a
|
|
10
|
+
[guide](docs/guide/index.md) for a developer who uses the package, and
|
|
11
|
+
[maintainer pages](docs/maintainer/index.md) for a developer who changes it.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm i @ballistix.digital/odata-query-builder
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
It needs Node 20 or newer, and TypeScript 5.9, 6.0 or 7.0.
|
|
20
|
+
|
|
21
|
+
## Example
|
|
11
22
|
|
|
12
23
|
```ts
|
|
13
|
-
import { odataQuery
|
|
24
|
+
import { odataQuery } from '@ballistix.digital/odata-query-builder';
|
|
25
|
+
|
|
26
|
+
enum ProjectStatusEnum {
|
|
27
|
+
DRAFT = 'draft',
|
|
28
|
+
ACTIVE = 'active',
|
|
29
|
+
ARCHIVED = 'archived',
|
|
30
|
+
}
|
|
14
31
|
|
|
32
|
+
// ProjectDto is your own DTO type. Getting started shows its shape.
|
|
15
33
|
const query = odataQuery<ProjectDto>()
|
|
16
34
|
.filter((v, o) =>
|
|
17
35
|
o.and(
|
|
@@ -26,8 +44,12 @@ const query = odataQuery<ProjectDto>()
|
|
|
26
44
|
.top(25)
|
|
27
45
|
.count()
|
|
28
46
|
.toQueryString();
|
|
47
|
+
// $filter=archived eq false and status in ('draft','active') and children/any(a:a/priority ge 2)&$select=id,name,status&$expand=parent($select=name;$expand=owner($select=fullName))&$orderby=name desc&$top=25&$count=true
|
|
29
48
|
```
|
|
30
49
|
|
|
50
|
+
[Getting started](docs/guide/getting-started.md#the-one-assumption-_embedded)
|
|
51
|
+
prints the `ProjectDto` shape the examples use.
|
|
52
|
+
|
|
31
53
|
## What makes it typed
|
|
32
54
|
|
|
33
55
|
The scope a `filter()` callback receives is derived from the DTO type alone:
|
|
@@ -56,112 +78,28 @@ v.year.eq(v.date.asDate().year());
|
|
|
56
78
|
// year eq year(date)
|
|
57
79
|
```
|
|
58
80
|
|
|
59
|
-
## The surface
|
|
60
|
-
|
|
61
|
-
| Verb | Emits |
|
|
62
|
-
| --- | --- |
|
|
63
|
-
| `filter(cb)` | `$filter`; repeated calls AND together |
|
|
64
|
-
| `search(term, cols)` | `$search` + the mandatory `$searchColumns` |
|
|
65
|
-
| `select(...)` | `$select`; the own columns plus embedded leaf paths; omit it and nothing is narrowed, at any depth |
|
|
66
|
-
| `expand.<rel>(cb?)` | `$expand`, with nested `$filter`/`$select`/`$expand` |
|
|
67
|
-
| `orderBy(key, direction?)` | `$orderby`, ascending by default; `key` may be a to-one relation path or an embedded leaf path |
|
|
68
|
-
| `top` / `skip` / `count` | `$top` / `$skip` / `$count=true` |
|
|
69
|
-
| `toQueryString()` | the whole string, already URL-safe |
|
|
70
|
-
| `toParams()` | the same options as a map, escaping undone |
|
|
71
|
-
|
|
72
|
-
`odataItem<T>()` is the single-row variant: `$select` and `$expand` only.
|
|
73
|
-
|
|
74
|
-
An embedded object column (a DTO property that is a plain object) reads as a
|
|
75
|
-
namespace of columns, so `v.address.geo.lat` filters, selects and sorts as
|
|
76
|
-
`address/geo/lat`.
|
|
77
|
-
|
|
78
|
-
Inside a filter callback the second argument carries the combinators —
|
|
79
|
-
`o.and`, `o.or`, `o.not`, `o.now`, `o.maxdatetime`, `o.mindatetime`, `o.raw` —
|
|
80
|
-
and the same functions are importable. A single predicate needs no combinator.
|
|
81
|
-
|
|
82
|
-
`isNull()` and `isNotNull()` are how you compare against null; `eq(null)` is a
|
|
83
|
-
compile error, because "no filter" and "is null" must not look alike.
|
|
84
|
-
|
|
85
|
-
## Filtered expansions
|
|
86
|
-
|
|
87
|
-
From `@ballistix.digital/typeorm-extensions` 3.8.0 the server accepts a nested
|
|
88
|
-
`$filter`, so the nested chain has `filter()`:
|
|
89
|
-
|
|
90
|
-
```ts
|
|
91
|
-
odataQuery<ProjectDto>()
|
|
92
|
-
.expand.children((c) => c.filter((x) => x.priority.ge(2023)).select('priority'));
|
|
93
|
-
// $expand=children($filter=priority ge 2023;$select=priority)
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
The filter narrows the EXPANSION only. Every root row still comes back: a row
|
|
97
|
-
whose collection matched nothing arrives with an empty collection, a to-one
|
|
98
|
-
expansion that fails the filter arrives as null, and `$count` and the paging
|
|
99
|
-
window are unchanged.
|
|
100
|
-
|
|
101
|
-
`$orderby`, `$top`, `$skip`, `$count` and `$levels` inside `$expand` are still
|
|
102
|
-
rejected by the server, so they are still absent from the nested chain.
|
|
103
|
-
|
|
104
81
|
## Escaping
|
|
105
82
|
|
|
106
83
|
`toQueryString()` is already URL-safe. **Do not** run `encodeURIComponent` over
|
|
107
|
-
it — that turns `%25` into `%2525`.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
| In a literal | Emitted as | Why |
|
|
111
|
-
| --- | --- | --- |
|
|
112
|
-
| `'` | `''` | the OData string escape |
|
|
113
|
-
| `%` | `%25` | the query string is percent-decoded before parsing |
|
|
114
|
-
| `+` | `%2B` | a bare `+` is read as whitespace |
|
|
115
|
-
| `&` | `%26` | a bare `&` starts another query option |
|
|
116
|
-
| `#` | `%23` | a bare `#` starts the URL fragment |
|
|
117
|
-
| space | `%20` | only inside literals; the spaces between tokens stay raw |
|
|
84
|
+
it — that turns `%25` into `%2525`.
|
|
85
|
+
[The escape rules](docs/guide/sending-the-query.md#the-escape-rules) list what
|
|
86
|
+
each literal character becomes.
|
|
118
87
|
|
|
119
88
|
Use `toParams()` when the caller has its own serialiser (`URLSearchParams`,
|
|
120
89
|
ky): it hands back the raw values so that serialiser's own encoding is the only
|
|
121
90
|
one applied.
|
|
122
91
|
|
|
123
|
-
##
|
|
92
|
+
## Documentation
|
|
124
93
|
|
|
125
|
-
|
|
94
|
+
- [Getting started](docs/guide/getting-started.md) — install, the `_embedded` assumption, a first query.
|
|
95
|
+
- [Querying](docs/guide/querying.md) — every verb, the `expand` namespace, filtered expansions and `odataItem`.
|
|
96
|
+
- [Filtering](docs/guide/filtering.md) — operators, functions, lambdas, dates, `cast` and the combinators.
|
|
97
|
+
- [Sending the query](docs/guide/sending-the-query.md) — `toQueryString()` or `toParams()`, and the escape rules.
|
|
98
|
+
- [Errors](docs/guide/errors.md) — what the builder throws and how to catch it.
|
|
99
|
+
- [Cookbook](docs/guide/cookbook.md) — twelve call shapes with their strings.
|
|
126
100
|
|
|
127
|
-
|
|
128
|
-
| --- | --- | --- |
|
|
129
|
-
| `npm run test:unit` | no | Golden strings for the whole emission surface, the combination matrix, `toParams()` equivalence, every runtime guard, and a cross-check that feeds each emitted string through the real server-side parser |
|
|
130
|
-
| `npm run test:types` | no | The compile gate: every `.ok` fixture compiles clean and every `// @errorline TSxxxx` marker in an `.err` fixture errors with that code, on TypeScript 5.9, 6.0 and 7.0 |
|
|
131
|
-
| `npm run test:packaging` | no | `npm pack --dry-run`: only `dist/**` ships |
|
|
132
|
-
| `npm run test:integration` | yes | The emitted strings executed against real Postgres through the published `typeorm-extensions`, asserting rows |
|
|
133
|
-
| `npm run test:e2e` | yes | The same strings sent to a real NestJS app, asserting the response envelope and the 400 body for each rejected construct |
|
|
134
|
-
| `npm run test:coverage` | no | `unit`, with the coverage gate |
|
|
135
|
-
| `npm test` | yes | `npm run build`, then all five projects |
|
|
101
|
+
## Contributing
|
|
136
102
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
npm run db:down
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
The database-backed tiers skip themselves with a clear message when the
|
|
144
|
-
sidecar is down. See [Testing](docs/testing.md) for the shared test setup,
|
|
145
|
-
the fixtures, the seed data, and how to add a test to each tier.
|
|
146
|
-
|
|
147
|
-
## Layout
|
|
148
|
-
|
|
149
|
-
```
|
|
150
|
-
src/index.ts the public API: odataQuery, odataItem, and every exported type
|
|
151
|
-
src/module/common/ what two or more areas share; it imports nothing
|
|
152
|
-
src/module/common/domain/ ExceptionCode, ExceptionDetail, Scalar and Unwrap
|
|
153
|
-
src/module/common/exception/ QueryBuilderException
|
|
154
|
-
src/module/common/util/ escape.util — the one literal-escaping mechanism
|
|
155
|
-
src/module/query/ the builder: three stages, QueryChainService, shared state, the two error classes
|
|
156
|
-
src/module/query/domain/ QueryState, and one folder per stage: chain/, filter/
|
|
157
|
-
src/module/query/exception/ InvalidFilterError, ReservedNameError
|
|
158
|
-
src/module/query/service/ queryChain.service.ts (QueryChainService) and one facade per stage: filter/, emit/
|
|
159
|
-
src/module/query/util/ pure-function folders for two stages: filter/, emit/
|
|
160
|
-
test/unit/ no database
|
|
161
|
-
test/types/ the compile gate, three TypeScript versions
|
|
162
|
-
test/integration/ executes queries against seeded Postgres
|
|
163
|
-
test/e2e/ a real NestJS app, the response envelope, the rejected constructs
|
|
164
|
-
test/packaging/ tarball guard
|
|
165
|
-
test/fixtures/ DTO twins, entities, the seed data
|
|
166
|
-
test/support/ shared test setup: datasource, app, env
|
|
167
|
-
```
|
|
103
|
+
To run the tests and find your way around the source, read
|
|
104
|
+
[Conventions](docs/maintainer/conventions.md#run-the-repository-locally) and
|
|
105
|
+
[Testing](docs/maintainer/testing.md#the-five-tiers).
|
|
@@ -33,8 +33,8 @@ export declare class QueryChainService {
|
|
|
33
33
|
* `QueryChainService` and not the handler.
|
|
34
34
|
*
|
|
35
35
|
* `chain.expand.parent((parent) => parent.select('name'))` gives
|
|
36
|
-
* `$expand=parent($select=name)`. See docs/
|
|
37
|
-
* here.
|
|
36
|
+
* `$expand=parent($select=name)`. See docs/maintainer/architecture.md, How
|
|
37
|
+
* `Proxy` works here.
|
|
38
38
|
*/
|
|
39
39
|
get expand(): unknown;
|
|
40
40
|
select(...keys: string[]): this;
|
|
@@ -21,8 +21,8 @@ class QueryChainService {
|
|
|
21
21
|
* `QueryChainService` and not the handler.
|
|
22
22
|
*
|
|
23
23
|
* `chain.expand.parent((parent) => parent.select('name'))` gives
|
|
24
|
-
* `$expand=parent($select=name)`. See docs/
|
|
25
|
-
* here.
|
|
24
|
+
* `$expand=parent($select=name)`. See docs/maintainer/architecture.md, How
|
|
25
|
+
* `Proxy` works here.
|
|
26
26
|
*/
|
|
27
27
|
get expand() {
|
|
28
28
|
return new Proxy(Object.create(null), {
|
|
@@ -12,7 +12,7 @@ export declare const nodeOf: (value: unknown) => ExpressionNode | undefined;
|
|
|
12
12
|
* still reaches the trap.
|
|
13
13
|
*
|
|
14
14
|
* `v.name.eq('x')` on `rootScope()` gives `name eq 'x'`. See
|
|
15
|
-
* docs/
|
|
15
|
+
* docs/maintainer/architecture.md, How `Proxy` works here.
|
|
16
16
|
*/
|
|
17
17
|
export declare const createMember: (node: ExpressionNode, parent?: ExpressionNode, name?: string) => any;
|
|
18
18
|
/**
|
|
@@ -50,7 +50,7 @@ exports.nodeOf = nodeOf;
|
|
|
50
50
|
* still reaches the trap.
|
|
51
51
|
*
|
|
52
52
|
* `v.name.eq('x')` on `rootScope()` gives `name eq 'x'`. See
|
|
53
|
-
* docs/
|
|
53
|
+
* docs/maintainer/architecture.md, How `Proxy` works here.
|
|
54
54
|
*/
|
|
55
55
|
const createMember = (node, parent, name) =>
|
|
56
56
|
// The target is never invoked: the `apply` trap owns every call. It only
|
package/package.json
CHANGED