@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 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/README.md) — the developer entry
10
- point, with a module map and one page per pipeline stage.
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, ProjectStatusEnum } from '@ballistix.digital/odata-query-builder';
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`. Six rules, each measured against the
108
- server's own parser:
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
- ## Tests
92
+ ## Documentation
124
93
 
125
- The suite is split into five Jest projects, each runnable on its own.
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
- | Command | Needs Postgres | What it covers |
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
- ```bash
138
- npm run db:up # postgres:17-alpine on port 5557
139
- npm test
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/query/index.md, How `Proxy` works
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/query/index.md, How `Proxy` works
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/query/index.md, How `Proxy` works here.
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/query/index.md, How `Proxy` works here.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ballistix.digital/odata-query-builder",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Typed, fluent OData v4 query-string builder for the dialect @ballistix.digital/typeorm-extensions serves.",
5
5
  "license": "MIT",
6
6
  "repository": {