fastify-auto-sqlapi 0.0.5 → 0.1.2
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/AGENTS.md +15 -2
- package/AGENTS_BACKEND.md +144 -20
- package/AGENTS_FRONTEND.md +161 -67
- package/README.md +47 -26
- package/dist/bin/generate-schema.js +9 -26
- package/dist/bin/generate-tables.js +7 -30
- package/dist/bin/utils.d.ts +28 -0
- package/dist/bin/utils.d.ts.map +1 -1
- package/dist/bin/utils.js +69 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/cli/load-dependency.d.ts +8 -0
- package/dist/lib/cli/load-dependency.d.ts.map +1 -0
- package/dist/lib/cli/load-dependency.js +21 -0
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +2 -18
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +2 -17
- package/dist/lib/db.d.ts +1 -0
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +33 -55
- package/dist/lib/dialect.d.ts +17 -0
- package/dist/lib/dialect.d.ts.map +1 -1
- package/dist/lib/dialect.js +32 -0
- package/dist/lib/engine/bulk/bulk-delete.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-delete.js +8 -2
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +15 -43
- package/dist/lib/engine/rest/delete.d.ts.map +1 -1
- package/dist/lib/engine/rest/delete.js +14 -12
- package/dist/lib/engine/rest/get.d.ts.map +1 -1
- package/dist/lib/engine/rest/get.js +3 -5
- package/dist/lib/engine/rest/insert.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.js +11 -31
- package/dist/lib/engine/rest/update.d.ts.map +1 -1
- package/dist/lib/engine/rest/update.js +13 -46
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +553 -229
- package/dist/lib/engine/write-helpers.d.ts +39 -2
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +55 -12
- package/dist/lib/errors.d.ts +11 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +12 -0
- package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/schema/bulk-upsert.js +9 -25
- package/dist/lib/schema/helpers.d.ts +28 -1
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +38 -0
- package/dist/lib/schema/insert.d.ts.map +1 -1
- package/dist/lib/schema/insert.js +9 -19
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +138 -50
- package/dist/lib/schema/update.d.ts.map +1 -1
- package/dist/lib/schema/update.js +9 -25
- package/dist/lib/sql-api.d.ts +6 -6
- package/dist/lib/sql-api.d.ts.map +1 -1
- package/dist/lib/sql-api.js +10 -10
- package/dist/lib/table-helpers.d.ts +6 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +42 -2
- package/dist/lib/tenant.d.ts +30 -0
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +78 -9
- package/dist/routes/auto/bulk-delete.routes.d.ts.map +1 -1
- package/dist/routes/auto/bulk-delete.routes.js +17 -23
- package/dist/routes/auto/bulk-upsert.routes.d.ts.map +1 -1
- package/dist/routes/auto/bulk-upsert.routes.js +16 -22
- package/dist/routes/auto/delete.routes.d.ts.map +1 -1
- package/dist/routes/auto/delete.routes.js +18 -24
- package/dist/routes/auto/get.routes.d.ts.map +1 -1
- package/dist/routes/auto/get.routes.js +16 -23
- package/dist/routes/auto/insert.routes.d.ts.map +1 -1
- package/dist/routes/auto/insert.routes.js +19 -26
- package/dist/routes/auto/plugin.d.ts.map +1 -1
- package/dist/routes/auto/plugin.js +40 -5
- package/dist/routes/auto/route-helpers.d.ts +27 -2
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +39 -1
- package/dist/routes/auto/search.routes.d.ts.map +1 -1
- package/dist/routes/auto/search.routes.js +47 -47
- package/dist/routes/auto/update.routes.d.ts.map +1 -1
- package/dist/routes/auto/update.routes.js +20 -27
- package/dist/types/cli.d.ts +22 -0
- package/dist/types/cli.d.ts.map +1 -0
- package/dist/types/cli.js +1 -0
- package/dist/types/computed.d.ts +29 -0
- package/dist/types/computed.d.ts.map +1 -0
- package/dist/types/computed.js +1 -0
- package/dist/types/conditions.d.ts +16 -0
- package/dist/types/conditions.d.ts.map +1 -0
- package/dist/types/conditions.js +1 -0
- package/dist/types/db.d.ts +22 -0
- package/dist/types/db.d.ts.map +1 -0
- package/dist/types/db.js +1 -0
- package/dist/types/dml.d.ts +66 -0
- package/dist/types/dml.d.ts.map +1 -0
- package/dist/types/dml.js +1 -0
- package/dist/types/join.d.ts +44 -0
- package/dist/types/join.d.ts.map +1 -0
- package/dist/types/join.js +1 -0
- package/dist/types/plugin.d.ts +20 -0
- package/dist/types/plugin.d.ts.map +1 -0
- package/dist/types/plugin.js +1 -0
- package/dist/types/schema.d.ts +10 -0
- package/dist/types/schema.d.ts.map +1 -0
- package/dist/types/schema.js +1 -0
- package/dist/types/search.d.ts +45 -0
- package/dist/types/search.d.ts.map +1 -0
- package/dist/types/search.js +1 -0
- package/dist/types/table.d.ts +76 -0
- package/dist/types/table.d.ts.map +1 -0
- package/dist/types/table.js +7 -0
- package/dist/types/tenant.d.ts +19 -0
- package/dist/types/tenant.d.ts.map +1 -0
- package/dist/types/tenant.js +1 -0
- package/dist/types/validation.d.ts +21 -0
- package/dist/types/validation.d.ts.map +1 -0
- package/dist/types/validation.js +1 -0
- package/dist/types.d.ts +12 -277
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +12 -7
- package/package.json +3 -3
package/AGENTS_FRONTEND.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
How to call the auto-generated CRUD endpoints.
|
|
4
4
|
|
|
5
|
+
> ⚠️ **Migrating from a previous version?** The join API was redesigned (no backward compat). See **[BREAKING_CHANGES.md](./BREAKING_CHANGES.md)** for the full migration guide — request/response key renames, new `joinLeft` family, dotted notation rules, secondaries key renames. Common to backend and frontend.
|
|
6
|
+
|
|
5
7
|
## Generated Endpoints
|
|
6
8
|
|
|
7
9
|
The plugin defines routes with these base paths: `/search/`, `/rest/`, `/bulk/`. The prefix from `register()` is prepended by Fastify. For a table `customer` with `prefix: '/auto'`:
|
|
@@ -32,7 +34,7 @@ Without prefix, routes are at root: `/search/customer`, `/rest/customer/:id`, et
|
|
|
32
34
|
&computeMin=id&computeMax=id&computeSum=total&computeAvg=total
|
|
33
35
|
```
|
|
34
36
|
|
|
35
|
-
- `orderBy` — SQL ORDER BY clause (default: `defaultOrder` from table config)
|
|
37
|
+
- `orderBy` — SQL ORDER BY clause (default: `defaultOrder` from table config). Supports dotted notations for joins, see below.
|
|
36
38
|
- `page` + `itemsPerPage` — pagination (when either is present, response includes `pagination` object; `page` defaults to 1, `itemsPerPage` defaults to 500)
|
|
37
39
|
- `computeMin`, `computeMax`, `computeSum`, `computeAvg` — aggregate a column, returned in `pagination.computed`
|
|
38
40
|
|
|
@@ -44,14 +46,14 @@ Without prefix, routes are at root: `/search/customer`, `/rest/customer/:id`, et
|
|
|
44
46
|
"conditions": [
|
|
45
47
|
{ "field": "createdAt", "method": "isGreater", "params": ["2024-01-01"] }
|
|
46
48
|
],
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
+
"joinMustExist": {
|
|
50
|
+
"labels": { "filters": { "labelId": 1 } }
|
|
49
51
|
},
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
+
"joinMultiple": {
|
|
53
|
+
"orders": { "filters": { "status": "pending" } }
|
|
52
54
|
},
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
+
"joinGroup": {
|
|
56
|
+
"orders": {
|
|
55
57
|
"aggregations": {
|
|
56
58
|
"by": "status",
|
|
57
59
|
"sum": ["total"],
|
|
@@ -61,10 +63,15 @@ Without prefix, routes are at root: `/search/customer`, `/rest/customer/:id`, et
|
|
|
61
63
|
},
|
|
62
64
|
"filters": { "status": "active" }
|
|
63
65
|
}
|
|
66
|
+
},
|
|
67
|
+
"joinLeft": {
|
|
68
|
+
"creator": { "selection": "id,name,email" }
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
71
|
```
|
|
67
72
|
|
|
73
|
+
The keys inside `joinMustExist`, `joinMultiple`, `joinGroup`, `joinLeft` are **aliases** declared by the backend in `buildRelation`. By default an alias equals the joined table's name; backends may override it (e.g. `'orders'` instead of `'customer_order'`) or declare multiple aliases for the same table (e.g. `'creator'` and `'updater'` both pointing to `user`). Check the Swagger description of each `/search/{table}` endpoint to see the available aliases.
|
|
74
|
+
|
|
68
75
|
### Filters
|
|
69
76
|
|
|
70
77
|
Pass any schema field as a key in `filters`. The plugin auto-applies `WHERE col = value` for each. Extra filters (defined via `extraFiltersValidation` in the table config) are also accepted but handled by custom logic.
|
|
@@ -103,23 +110,23 @@ Pass any schema field as a key in `filters`. The plugin auto-applies `WHERE col
|
|
|
103
110
|
Also available: `isNotGreater`, `isNotGreaterOrEqual`, `isNotLess`, `isNotLessOrEqual`.
|
|
104
111
|
|
|
105
112
|
- **Multiple conditions on the same field** — it's an array, so `total > 100 AND total < 500` is natural
|
|
106
|
-
- **Combinable** with `filters` (equality), `
|
|
113
|
+
- **Combinable** with `filters` (equality), `joinMustExist` (EXISTS), pagination, etc.
|
|
107
114
|
- **Field validation** — field names are validated against the table schema (400 if unknown)
|
|
108
115
|
- **Method validation** — only whitelisted methods are allowed (`raw`, `append` etc. are blocked)
|
|
109
116
|
|
|
110
117
|
#### Conditions on joinGroup aggregations (HAVING-style)
|
|
111
118
|
|
|
112
|
-
You can filter main rows based on the value of a joinGroup aggregation using
|
|
119
|
+
You can filter main rows based on the value of a `joinGroup` aggregation using 3-parti dot notation: `<alias>.<fn>.<field>`.
|
|
113
120
|
|
|
114
121
|
Example: "users with at least 4 sessions":
|
|
115
122
|
|
|
116
123
|
```json
|
|
117
124
|
{
|
|
118
125
|
"conditions": [
|
|
119
|
-
{ "field": "
|
|
126
|
+
{ "field": "sessions.count.id", "method": "isGreaterOrEqual", "params": [4] }
|
|
120
127
|
],
|
|
121
|
-
"
|
|
122
|
-
"
|
|
128
|
+
"joinGroup": {
|
|
129
|
+
"sessions": { "aggregations": { "count": ["id"] } }
|
|
123
130
|
}
|
|
124
131
|
}
|
|
125
132
|
```
|
|
@@ -135,21 +142,21 @@ WHERE COALESCE((
|
|
|
135
142
|
```
|
|
136
143
|
|
|
137
144
|
**Rules:**
|
|
138
|
-
-
|
|
139
|
-
- The joinGroup's own `filters` are applied inside the correlated subquery (consistent with the breakdown).
|
|
145
|
+
- The joinGroup must be declared in `joinGroup.<alias>.aggregations.<fn>` with the referenced field. The engine refuses undeclared references with 400.
|
|
146
|
+
- The joinGroup's own `filters` are applied inside the correlated subquery (consistent with the breakdown).
|
|
140
147
|
- All ConditionBuilder methods work: `isEqual`, `isGreater`, `isLess`, `isBetween`, `isIn`, `isNull`, `isNotNull`, etc.
|
|
141
148
|
- Can be combined with plain-field conditions and with aggregation `orderBy` in the same request.
|
|
142
|
-
- Respects `aggregations.by` rules: allowed when `by` equals the correlation FK, otherwise 400.
|
|
149
|
+
- Respects `aggregations.by` rules: allowed only when `by` equals the correlation FK, otherwise 400.
|
|
143
150
|
|
|
144
|
-
###
|
|
151
|
+
### `joinMustExist` (filter main by related table — EXISTS)
|
|
145
152
|
|
|
146
|
-
`
|
|
153
|
+
`joinMustExist` restricts **main table results** based on conditions on a related child table. Aliases must come from `allowedReadJoins` declarations with `unique: false`. Uses `EXISTS` subquery — no duplicate rows, works correctly with pagination.
|
|
147
154
|
|
|
148
155
|
```json
|
|
149
156
|
{
|
|
150
157
|
"filters": { "name": "Mario" },
|
|
151
|
-
"
|
|
152
|
-
"
|
|
158
|
+
"joinMustExist": {
|
|
159
|
+
"labels": { "filters": { "labelId": 1 } }
|
|
153
160
|
}
|
|
154
161
|
}
|
|
155
162
|
```
|
|
@@ -162,24 +169,37 @@ WHERE "name" = $1
|
|
|
162
169
|
WHERE "customer_id" = "customer"."id" AND "label_id" = $2)
|
|
163
170
|
```
|
|
164
171
|
|
|
165
|
-
`
|
|
172
|
+
`joinMustExist` accepts `{ filters, conditions }` (both optional). Supports schema fields + extra filters defined via `extendedCondition` on the related table. Combinable with `filters`, `joinMultiple`, `joinGroup`, `joinLeft`, and pagination.
|
|
166
173
|
|
|
167
|
-
**
|
|
174
|
+
**Difference from `joinMultiple`**: `joinMustExist` filters which main records are returned. `joinMultiple` fetches related child data for the returned main records. They can be used together.
|
|
168
175
|
|
|
169
|
-
###
|
|
176
|
+
### `joinMultiple` (fetch related child rows)
|
|
170
177
|
|
|
171
|
-
Request related table data via `
|
|
178
|
+
Request related child table data via `joinMultiple`. Aliases must come from `allowedReadJoins` declarations with `unique: false`. Each entry accepts `{ filters?, conditions?, selection? }`.
|
|
172
179
|
|
|
173
|
-
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"joinMultiple": {
|
|
183
|
+
"orders": {
|
|
184
|
+
"filters": { "status": "pending" },
|
|
185
|
+
"selection": "id,total,status"
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`selection` is optional and overrides the default declared in `buildRelation` (which itself defaults to `'*'`).
|
|
174
192
|
|
|
175
|
-
|
|
193
|
+
The fetch executes as a separate side query: `SELECT {selection} FROM {childTable} WHERE {fk} IN ({main PKs})`. Returned in `result.joinMultiple.<alias>` as an array.
|
|
176
194
|
|
|
177
|
-
`
|
|
195
|
+
### `joinGroup` (aggregations on related child rows)
|
|
196
|
+
|
|
197
|
+
`joinGroup` computes aggregated values on a related child table:
|
|
178
198
|
|
|
179
199
|
```json
|
|
180
200
|
{
|
|
181
|
-
"
|
|
182
|
-
"
|
|
201
|
+
"joinGroup": {
|
|
202
|
+
"orders": {
|
|
183
203
|
"aggregations": {
|
|
184
204
|
"by": "status",
|
|
185
205
|
"sum": ["total"],
|
|
@@ -195,39 +215,99 @@ The join executes as a separate query: `SELECT ... FROM {joinTable} WHERE {fk} I
|
|
|
195
215
|
}
|
|
196
216
|
```
|
|
197
217
|
|
|
198
|
-
- `by` — GROUP BY field
|
|
218
|
+
- `by` — GROUP BY name (optional). Either a schema field on the join table or a **computed field** declared on it (e.g. for date bucketing, the backend declares a computed using `db.dateTrunc(unit, qiCol('orderDate'))` and you reference it by name).
|
|
199
219
|
- `sum`, `min`, `max`, `avg` — aggregate functions on specified columns
|
|
200
220
|
- `count` — COUNT(col)
|
|
201
221
|
- `distinctCount` — COUNT(DISTINCT col)
|
|
202
222
|
- `filters` — optional, narrow rows before aggregation
|
|
203
223
|
|
|
204
|
-
|
|
224
|
+
Example — monthly revenue per customer (the backend declared a `monthBucket` computed):
|
|
205
225
|
|
|
206
|
-
|
|
226
|
+
```json
|
|
227
|
+
{
|
|
228
|
+
"joinGroup": {
|
|
229
|
+
"orders": {
|
|
230
|
+
"aggregations": {
|
|
231
|
+
"by": "monthBucket",
|
|
232
|
+
"sum": ["total"]
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
```
|
|
207
238
|
|
|
239
|
+
Response:
|
|
240
|
+
```json
|
|
241
|
+
{
|
|
242
|
+
"joinGroup": {
|
|
243
|
+
"orders": {
|
|
244
|
+
"rows": [
|
|
245
|
+
{ "by": "2026-01-01", "sum_total": 150 },
|
|
246
|
+
{ "by": "2026-02-01", "sum_total": 200 }
|
|
247
|
+
]
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
208
251
|
```
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
252
|
+
|
|
253
|
+
Returned as `result.joinGroup.<alias>` — keyed by the function name when no `by`, or with a `rows` array when `by` is set.
|
|
254
|
+
|
|
255
|
+
Aliases must come from `allowedReadJoins` declarations with `unique: false`.
|
|
256
|
+
|
|
257
|
+
### `joinLeft` (embed N:1 parent inline)
|
|
258
|
+
|
|
259
|
+
`joinLeft` embeds a parent record (N:1 relation) into the result. Aliases must come from `allowedReadJoins` declarations with `unique: true`. Each entry accepts `{ filters?, conditions?, selection? }`.
|
|
260
|
+
|
|
261
|
+
```json
|
|
262
|
+
{
|
|
263
|
+
"joinLeft": {
|
|
264
|
+
"creator": {},
|
|
265
|
+
"updater": { "filters": { "active": true }, "selection": "id,name" }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
212
268
|
```
|
|
213
269
|
|
|
214
|
-
|
|
270
|
+
The engine attaches a real `LEFT JOIN <parentTable> AS <alias>` to the main query **only when needed**:
|
|
271
|
+
- when the request includes `filters` or `conditions` on the parent (effectively turns into INNER JOIN behavior on those aliases — main rows whose parent doesn't match are excluded);
|
|
272
|
+
- when `orderBy` uses 2-parti dotted notation referring to a parent field (`<alias>.<field>`).
|
|
273
|
+
|
|
274
|
+
Otherwise only a side query `WHERE pk IN (distinct fk values)` is issued — no LEFT JOIN, no row duplication. Either way, the response shape is the same.
|
|
275
|
+
|
|
276
|
+
The parent rows are returned in `result.joinLeft.<alias>` as a deduplicated array. The client maps a main row to its parent by FK lookup:
|
|
277
|
+
|
|
278
|
+
```js
|
|
279
|
+
const parent = result.joinLeft.creator.find((u) => u.id === mainRow.userId);
|
|
280
|
+
```
|
|
215
281
|
|
|
216
|
-
|
|
282
|
+
> **Limitation**: `extraFilters` declared via `extendedCondition` on the parent table are **not** applied inside `joinLeft.filters` (only schema fields). The other join families fully support extraFilters.
|
|
217
283
|
|
|
218
|
-
|
|
284
|
+
### Ordering
|
|
285
|
+
|
|
286
|
+
Use the `orderBy` query parameter. Three forms are supported:
|
|
287
|
+
|
|
288
|
+
| Form | Source of `<alias>` | Example |
|
|
289
|
+
|------|---------------------|---------|
|
|
290
|
+
| `<field> [ASC\|DESC]` | main schema | `?orderBy=name ASC` |
|
|
291
|
+
| `<alias>.<field> [ASC\|DESC]` | `joinLeft`-eligible (`unique: true`) | `?orderBy=creator.name ASC` |
|
|
292
|
+
| `<alias>.<fn>.<field> [ASC\|DESC]` | `joinGroup` declared in same body | `?orderBy=orders.sum.total DESC` |
|
|
293
|
+
|
|
294
|
+
Multiple parts may be comma-separated: `?orderBy=creator.name ASC, id DESC`.
|
|
295
|
+
|
|
296
|
+
If not specified, the table's `defaultOrder` is used.
|
|
297
|
+
|
|
298
|
+
#### Ordering by joinGroup aggregations (3-parti)
|
|
219
299
|
|
|
220
300
|
Supported functions: `sum`, `min`, `max`, `avg`, `count`, `distinctCount`.
|
|
221
301
|
|
|
222
302
|
Example: get users ordered by total session duration DESC.
|
|
223
303
|
|
|
224
304
|
```
|
|
225
|
-
POST /auto/search/user?orderBy=
|
|
305
|
+
POST /auto/search/user?orderBy=sessions.sum.duration%20DESC
|
|
226
306
|
```
|
|
227
307
|
```json
|
|
228
308
|
{
|
|
229
|
-
"
|
|
230
|
-
"
|
|
309
|
+
"joinGroup": {
|
|
310
|
+
"sessions": {
|
|
231
311
|
"aggregations": { "sum": ["duration"] }
|
|
232
312
|
}
|
|
233
313
|
}
|
|
@@ -245,13 +325,21 @@ ORDER BY (
|
|
|
245
325
|
```
|
|
246
326
|
|
|
247
327
|
**Rules:**
|
|
248
|
-
- The joinGroup **must be declared in the request body** (`
|
|
249
|
-
- If the joinGroup declares `
|
|
250
|
-
- **`aggregations.by`**: allowed
|
|
328
|
+
- The joinGroup **must be declared in the request body** (`joinGroup.<alias>.aggregations.<fn>` must include the field). The engine refuses undeclared references with 400.
|
|
329
|
+
- If the joinGroup declares `filters`, those filters are applied inside the correlated subquery — consistent with the breakdown.
|
|
330
|
+
- **`aggregations.by`**: allowed only when `by` is the same field used for the join correlation. Using `by` on any other column is rejected with 400.
|
|
251
331
|
- **Not allowed** when the main table has `distinctResults: true`. 400 error.
|
|
252
|
-
- Can be combined with plain
|
|
253
|
-
- The joinGroup breakdown is still returned in the response (`
|
|
254
|
-
- **No-data rows**: rows with no matching joined records are coalesced to `0` via `COALESCE(..., 0)`.
|
|
332
|
+
- Can be combined with plain-field ordering and with 2-parti `joinLeft` ordering: `?orderBy=sessions.sum.duration DESC, creator.name ASC, name`.
|
|
333
|
+
- The joinGroup breakdown is still returned in the response (`result.joinGroup.<alias>`) — you get both ordered main results and the aggregated summary.
|
|
334
|
+
- **No-data rows**: rows with no matching joined records are coalesced to `0` via `COALESCE(..., 0)`. On DESC they appear last, on ASC they appear first.
|
|
335
|
+
|
|
336
|
+
#### Ordering by parent fields (2-parti)
|
|
337
|
+
|
|
338
|
+
```
|
|
339
|
+
?orderBy=creator.name ASC
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
The alias must be declared with `unique: true` (so it's eligible for `joinLeft`). The engine adds a real `LEFT JOIN` on the main query to make the column orderable. Main row count is preserved (no duplication, since N:1).
|
|
255
343
|
|
|
256
344
|
### Pagination
|
|
257
345
|
|
|
@@ -278,11 +366,14 @@ These compute aggregates on the main table's result set (respecting filters). Re
|
|
|
278
366
|
{
|
|
279
367
|
"table": "customer",
|
|
280
368
|
"main": [{ "id": 1, "name": "Mario", "email": "m@t.it" }],
|
|
281
|
-
"
|
|
282
|
-
"
|
|
369
|
+
"joinLeft": {
|
|
370
|
+
"creator": [{ "id": 7, "name": "Alice", "email": "a@x.it" }]
|
|
371
|
+
},
|
|
372
|
+
"joinMultiple": {
|
|
373
|
+
"orders": [{ "id": 10, "customerId": 1, "total": 50 }]
|
|
283
374
|
},
|
|
284
|
-
"
|
|
285
|
-
"
|
|
375
|
+
"joinGroup": {
|
|
376
|
+
"orders": {
|
|
286
377
|
"sum": { "total": 150 },
|
|
287
378
|
"rows": [{ "by": "pending", "sum_total": 100 }]
|
|
288
379
|
}
|
|
@@ -296,7 +387,7 @@ These compute aggregates on the main table's result set (respecting filters). Re
|
|
|
296
387
|
}
|
|
297
388
|
```
|
|
298
389
|
|
|
299
|
-
`
|
|
390
|
+
`joinLeft`, `joinMultiple`, `joinGroup`, `pagination` appear ONLY if requested. A simple search returns just `{ table, main }`.
|
|
300
391
|
|
|
301
392
|
---
|
|
302
393
|
|
|
@@ -322,7 +413,7 @@ Returns 404 if not found.
|
|
|
322
413
|
{
|
|
323
414
|
"main": { "name": "Mario", "email": "m@t.it" },
|
|
324
415
|
"secondaries": {
|
|
325
|
-
"
|
|
416
|
+
"orders": [
|
|
326
417
|
{ "total": 50, "status": "pending" }
|
|
327
418
|
]
|
|
328
419
|
}
|
|
@@ -330,7 +421,7 @@ Returns 404 if not found.
|
|
|
330
421
|
```
|
|
331
422
|
|
|
332
423
|
- `main` — the record to insert. Omit auto-increment PKs (they are in `excludeFromCreation`).
|
|
333
|
-
- `secondaries` — optional. Related records to insert.
|
|
424
|
+
- `secondaries` — optional. Related records to insert. Keys are **aliases** from `allowedWriteJoins`. FK fields (e.g. `customerId` in orders) are auto-filled from the inserted main record's PK.
|
|
334
425
|
|
|
335
426
|
**Response (201):** PK-only for main and secondaries.
|
|
336
427
|
|
|
@@ -338,7 +429,7 @@ Returns 404 if not found.
|
|
|
338
429
|
{
|
|
339
430
|
"main": { "id": 1 },
|
|
340
431
|
"secondaries": {
|
|
341
|
-
"
|
|
432
|
+
"orders": [{ "id": 10 }]
|
|
342
433
|
}
|
|
343
434
|
}
|
|
344
435
|
```
|
|
@@ -353,25 +444,25 @@ Returns 404 if not found.
|
|
|
353
444
|
{
|
|
354
445
|
"main": { "id": 1, "name": "Mario Updated", "email": "new@t.it" },
|
|
355
446
|
"secondaries": {
|
|
356
|
-
"
|
|
447
|
+
"orders": [{ "total": 75, "status": "shipped" }]
|
|
357
448
|
},
|
|
358
449
|
"deletions": {
|
|
359
|
-
"
|
|
450
|
+
"orders": [{ "id": 10 }]
|
|
360
451
|
}
|
|
361
452
|
}
|
|
362
453
|
```
|
|
363
454
|
|
|
364
455
|
- `main` — MUST include the PK. Only changed fields need to be sent (partial update).
|
|
365
|
-
- `secondaries` — optional. New related records to insert (FK auto-filled).
|
|
366
|
-
- `deletions` — optional. Related records to delete (by PK).
|
|
456
|
+
- `secondaries` — optional. New related records to insert (FK auto-filled). Keys are **aliases**.
|
|
457
|
+
- `deletions` — optional. Related records to delete (by PK). Keys are **aliases**.
|
|
367
458
|
|
|
368
459
|
**Response (200):** PK-only for main, secondaries, and deletions.
|
|
369
460
|
|
|
370
461
|
```json
|
|
371
462
|
{
|
|
372
463
|
"main": { "id": 1 },
|
|
373
|
-
"secondaries": { "
|
|
374
|
-
"deletions":
|
|
464
|
+
"secondaries": { "orders": [{ "id": 20 }] },
|
|
465
|
+
"deletions": { "orders": [{ "id": 10 }] }
|
|
375
466
|
}
|
|
376
467
|
```
|
|
377
468
|
|
|
@@ -401,8 +492,8 @@ Insert or update multiple records in a single request.
|
|
|
401
492
|
[
|
|
402
493
|
{
|
|
403
494
|
"main": { "name": "Mario", "email": "m@t.it" },
|
|
404
|
-
"secondaries": { "
|
|
405
|
-
"deletions": { "
|
|
495
|
+
"secondaries": { "orders": [{ "total": 50 }] },
|
|
496
|
+
"deletions": { "orders": [{ "id": 99 }] }
|
|
406
497
|
},
|
|
407
498
|
{
|
|
408
499
|
"main": { "name": "Luigi", "email": "l@t.it" }
|
|
@@ -410,7 +501,7 @@ Insert or update multiple records in a single request.
|
|
|
410
501
|
]
|
|
411
502
|
```
|
|
412
503
|
|
|
413
|
-
All main records are inserted/upserted in a single SQL query. Secondaries and deletions are processed per-item.
|
|
504
|
+
All main records are inserted/upserted in a single SQL query. Secondaries and deletions are processed per-item. `secondaries`/`deletions` keys are **aliases**.
|
|
414
505
|
|
|
415
506
|
**Response (200):** Array of `{ main (PK-only), secondaries? (PK-only), deletions? }`.
|
|
416
507
|
|
|
@@ -452,13 +543,13 @@ Both return the same response format:
|
|
|
452
543
|
"message": "Validation failed",
|
|
453
544
|
"fields": [
|
|
454
545
|
{ "path": "body.main.name", "code": "required", "message": "must have required property 'name'" },
|
|
455
|
-
{ "path": "
|
|
546
|
+
{ "path": "periods[1].startDate", "code": "overlap", "message": "overlaps with another period" }
|
|
456
547
|
]
|
|
457
548
|
}
|
|
458
549
|
```
|
|
459
550
|
|
|
460
551
|
Each entry in `fields`:
|
|
461
|
-
- `path` — the field path (e.g. `body.main.name` for schema errors, `name` or `
|
|
552
|
+
- `path` — the field path (e.g. `body.main.name` for schema errors, `name` or `periods[1].startDate` for custom validation)
|
|
462
553
|
- `code` — machine-readable error code (e.g. `required`, `type`, `overlap`, `unique`)
|
|
463
554
|
- `message` — human-readable description
|
|
464
555
|
|
|
@@ -468,7 +559,7 @@ Each entry in `fields`:
|
|
|
468
559
|
const response = await fetch('/api/rest/session', {
|
|
469
560
|
method: 'POST',
|
|
470
561
|
headers: { 'Content-Type': 'application/json' },
|
|
471
|
-
body: JSON.stringify({ main: { name: '' }, secondaries: {
|
|
562
|
+
body: JSON.stringify({ main: { name: '' }, secondaries: { periods } }),
|
|
472
563
|
});
|
|
473
564
|
|
|
474
565
|
if (!response.ok) {
|
|
@@ -488,4 +579,7 @@ if (!response.ok) {
|
|
|
488
579
|
|
|
489
580
|
- **PK-only responses**: Insert, update, delete, bulk operations return only the primary key fields, not the full record. This is by design for performance and consistency.
|
|
490
581
|
- **camelCase fields**: All request and response fields use camelCase (e.g. `customerId`, not `customer_id`). The plugin converts automatically.
|
|
491
|
-
- **
|
|
582
|
+
- **Aliases everywhere**: in request bodies, response payloads, `secondaries`/`deletions`, and dotted notation, the keys are aliases declared by the backend in `buildRelation`. Default = the joined table's name; can be overridden (e.g. `orders` for `customer_order`).
|
|
583
|
+
- **Computed fields**: the backend may declare virtual fields via `computedFields` on a table. They appear in Swagger as additional filter/orderBy names, behave exactly like schema fields (filters, conditions, orderBy, computeMin/Max/Sum/Avg), and are projected into the main response only when explicitly listed in `selectComputed: ['<name>']` in the body.
|
|
584
|
+
- **`joinMultiple` / `joinMustExist` / `joinGroup`** are 1:N (child→main) and use side queries / EXISTS / correlated subqueries — no row duplication of main.
|
|
585
|
+
- **`joinLeft`** is N:1 (parent→main) and adds a real `LEFT JOIN` on demand (only when filtering/ordering by parent).
|
package/README.md
CHANGED
|
@@ -9,7 +9,8 @@ Point it at your database, and get a full REST API with search, advanced conditi
|
|
|
9
9
|
- **Zero boilerplate** — define your tables, get 7 endpoints each
|
|
10
10
|
- **No ORM** — raw SQL via `pg` + parameterized queries
|
|
11
11
|
- **TypeBox validation** — request/response schemas auto-generated from your DB
|
|
12
|
-
- **
|
|
12
|
+
- **Joins** — four explicit families (`joinMustExist`, `joinMultiple`, `joinGroup`, `joinLeft`) with alias support
|
|
13
|
+
- **Computed fields** — declare virtual fields as SQL expressions in `defineTable`; usable like schema fields in filters/orderBy/conditions, and opt-in projected in the response via `selectComputed`. Covers JSON extraction, derived columns, dialect-aware date bucketing
|
|
13
14
|
- **Bulk operations** — batch insert/upsert/delete in single queries
|
|
14
15
|
- **Multi-tenant** — automatic row-level isolation, zero code in handlers
|
|
15
16
|
- **Validation** — structured field-level validation with cross-entity support
|
|
@@ -17,6 +18,8 @@ Point it at your database, and get a full REST API with search, advanced conditi
|
|
|
17
18
|
- **Swagger UI** — optional, auto-configured from your schemas
|
|
18
19
|
- **Composable** — register all routes or pick only what you need
|
|
19
20
|
|
|
21
|
+
> ⚠️ **Migrating from a previous version?** The join API was redesigned. See **[BREAKING_CHANGES.md](./BREAKING_CHANGES.md)** for the full migration guide.
|
|
22
|
+
|
|
20
23
|
## Quick Start
|
|
21
24
|
|
|
22
25
|
### 1. Install
|
|
@@ -143,12 +146,16 @@ const TableCustomer = defineTable({
|
|
|
143
146
|
excludeFromCreation: ['id'], // omit from INSERT (e.g. auto-increment)
|
|
144
147
|
distinctResults: true, // SELECT DISTINCT
|
|
145
148
|
|
|
146
|
-
// Relations
|
|
149
|
+
// Relations — alias defaults to joinSchema.tableName. Override with `{ alias: '...' }`
|
|
150
|
+
// when you join the same table twice (e.g. `createdBy`/`updatedBy`) or want a friendlier
|
|
151
|
+
// name. Set `unique: true` for N:1 (parent) relations to enable `joinLeft`.
|
|
147
152
|
allowedReadJoins: [
|
|
148
|
-
buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'),
|
|
153
|
+
buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order'
|
|
154
|
+
buildRelation(SchemaSession, 'userId', SchemaUser, 'id', { unique: true }), // alias = 'user', N:1
|
|
155
|
+
buildRelation(SchemaSession, 'updatedBy', SchemaUser, 'id', { alias: 'updater', unique: true }),
|
|
149
156
|
],
|
|
150
157
|
allowedWriteJoins: [
|
|
151
|
-
buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'),
|
|
158
|
+
buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order'
|
|
152
159
|
],
|
|
153
160
|
|
|
154
161
|
// Upsert (ON CONFLICT)
|
|
@@ -255,20 +262,20 @@ Search with filters, advanced conditions, pagination, ordering, joins, and aggre
|
|
|
255
262
|
{ "field": "total", "method": "isGreater", "params": [100] },
|
|
256
263
|
{ "field": "createdAt", "method": "isBetween", "params": ["2024-01-01", "2024-12-31"] }
|
|
257
264
|
],
|
|
258
|
-
"
|
|
259
|
-
"
|
|
265
|
+
"joinMustExist": {
|
|
266
|
+
"orders": {
|
|
260
267
|
"filters": { "status": "completed" },
|
|
261
268
|
"conditions": [{ "field": "total", "method": "isGreater", "params": [50] }]
|
|
262
269
|
}
|
|
263
270
|
},
|
|
264
|
-
"
|
|
265
|
-
"
|
|
271
|
+
"joinMultiple": {
|
|
272
|
+
"orders": {
|
|
266
273
|
"filters": { "status": "completed" },
|
|
267
|
-
"
|
|
274
|
+
"selection": "id,total,status"
|
|
268
275
|
}
|
|
269
276
|
},
|
|
270
|
-
"
|
|
271
|
-
"
|
|
277
|
+
"joinGroup": {
|
|
278
|
+
"orders": {
|
|
272
279
|
"aggregations": {
|
|
273
280
|
"by": "status",
|
|
274
281
|
"sum": ["total"],
|
|
@@ -278,20 +285,29 @@ Search with filters, advanced conditions, pagination, ordering, joins, and aggre
|
|
|
278
285
|
"count": ["id"],
|
|
279
286
|
"distinctCount": ["status"]
|
|
280
287
|
},
|
|
281
|
-
"filters": { "status": "completed" }
|
|
282
|
-
"conditions": [{ "field": "total", "method": "isGreater", "params": [0] }]
|
|
288
|
+
"filters": { "status": "completed" }
|
|
283
289
|
}
|
|
290
|
+
},
|
|
291
|
+
"joinLeft": {
|
|
292
|
+
"creator": { "selection": "id,name,email" }
|
|
284
293
|
}
|
|
285
294
|
}
|
|
286
295
|
```
|
|
287
296
|
|
|
288
297
|
- **`filters`** — equality-based, flat key/value. Supports schema fields + `extraFilters`.
|
|
289
298
|
- **`conditions`** — array of `{ field, method, params }`. Methods: `isEqual`, `isNotEqual`, `isGreater`, `isGreaterOrEqual`, `isLess`, `isLessOrEqual`, `isLike`, `isILike`, `isIn`, `isNotIn`, `isBetween`, `isNotBetween`, `isNull`, `isNotNull`.
|
|
290
|
-
- **`
|
|
291
|
-
- **`
|
|
292
|
-
- **`
|
|
299
|
+
- **`joinMustExist`** — EXISTS-based filter: "main rows where at least one related row matches". Accepts `{ filters, conditions }` (both optional). Aliases must come from `allowedReadJoins` declarations with `unique: false`.
|
|
300
|
+
- **`joinMultiple`** — fetches related child rows in a side query. Accepts `{ filters, conditions, selection }`. Same `unique: false` aliases.
|
|
301
|
+
- **`joinGroup`** — aggregations on the related table. Supports `sum`, `min`, `max`, `avg`, `count`, `distinctCount`, and optional `by` for GROUP BY (a schema field name or a computed-field name declared on the join table — e.g. for date bucketing declare a computed using `db.dateTrunc('month', qiCol('orderDate'))`). Accepts `{ filters, conditions }`. Same `unique: false` aliases.
|
|
302
|
+
- **`joinLeft`** — embeds an N:1 parent. Real `LEFT JOIN` is added on demand (only when the request has `filters`/`conditions` on the parent or uses 2-parti `orderBy` on this alias). Aliases must be declared with `unique: true`. Accepts `{ filters, conditions, selection }`.
|
|
303
|
+
|
|
304
|
+
**Dot-notation in `orderBy` and `conditions`**:
|
|
293
305
|
|
|
294
|
-
|
|
306
|
+
| Form | Source | Example |
|
|
307
|
+
|------|--------|---------|
|
|
308
|
+
| `<field>` | main schema | `orderBy=name ASC` |
|
|
309
|
+
| `<alias>.<field>` | `joinLeft` aliases (`unique: true`) | `orderBy=creator.name ASC` |
|
|
310
|
+
| `<alias>.<fn>.<field>` | `joinGroup` aliases declared in the same body | `orderBy=orders.sum.total DESC`, or `conditions: [{ field: 'orders.count.id', method: 'isGreaterOrEqual', params: [4] }]` |
|
|
295
311
|
|
|
296
312
|
**Querystring** (optional): `orderBy`, `page`, `itemsPerPage`, `computeMin`, `computeMax`, `computeSum`, `computeAvg`
|
|
297
313
|
|
|
@@ -301,9 +317,10 @@ Search with filters, advanced conditions, pagination, ordering, joins, and aggre
|
|
|
301
317
|
{
|
|
302
318
|
"table": "customer",
|
|
303
319
|
"main": [{ "id": 1, "name": "Mario", "email": "m@test.it" }],
|
|
304
|
-
"
|
|
305
|
-
"
|
|
306
|
-
|
|
320
|
+
"joinLeft": { "creator": [{ "id": 7, "name": "Alice", "email": "a@x.it" }] },
|
|
321
|
+
"joinMultiple": { "orders": [{ "id": 10, "customerId": 1, "total": 50 }] },
|
|
322
|
+
"joinGroup": {
|
|
323
|
+
"orders": {
|
|
307
324
|
"sum": { "total": 300 },
|
|
308
325
|
"count": { "id": 2 },
|
|
309
326
|
"rows": [{ "by": "completed", "sum_total": 300, "count_id": 2 }]
|
|
@@ -318,7 +335,7 @@ Search with filters, advanced conditions, pagination, ordering, joins, and aggre
|
|
|
318
335
|
}
|
|
319
336
|
```
|
|
320
337
|
|
|
321
|
-
`
|
|
338
|
+
`joinLeft`, `joinMultiple`, `joinGroup`, and `pagination` appear only when requested. A simple `{}` body returns `{ table, main }`. `pagination.computed` appears only if `computeMin`/`computeMax`/`computeSum`/`computeAvg` are used.
|
|
322
339
|
|
|
323
340
|
### GET /rest/{table}/:id
|
|
324
341
|
|
|
@@ -332,12 +349,12 @@ Returns `{ main: { ... } }` or 404.
|
|
|
332
349
|
{
|
|
333
350
|
"main": { "name": "Mario", "email": "m@test.it" },
|
|
334
351
|
"secondaries": {
|
|
335
|
-
"
|
|
352
|
+
"orders": [{ "total": 50, "status": "pending" }]
|
|
336
353
|
}
|
|
337
354
|
}
|
|
338
355
|
```
|
|
339
356
|
|
|
340
|
-
`secondaries`
|
|
357
|
+
`secondaries` keys are the **alias** declared in `allowedWriteJoins`. FK fields are auto-filled from the inserted main record.
|
|
341
358
|
|
|
342
359
|
**Response (201):** `{ main: { ... }, secondaries: { ... } }`
|
|
343
360
|
|
|
@@ -348,8 +365,8 @@ Returns `{ main: { ... } }` or 404.
|
|
|
348
365
|
```json
|
|
349
366
|
{
|
|
350
367
|
"main": { "id": 1, "name": "Updated Name" },
|
|
351
|
-
"secondaries": { "
|
|
352
|
-
"deletions":
|
|
368
|
+
"secondaries": { "orders": [{ "total": 75 }] },
|
|
369
|
+
"deletions": { "orders": [{ "id": 10 }] }
|
|
353
370
|
}
|
|
354
371
|
```
|
|
355
372
|
|
|
@@ -480,7 +497,9 @@ await app.register(async (instance) => {
|
|
|
480
497
|
|
|
481
498
|
- **camelCase everywhere in the API** — requests, responses, `validate`, and all hooks (`beforeInsert`, `beforeUpdate`, `afterInsert`) use schema field names
|
|
482
499
|
- **Conversion to DB column format is automatic** via `colMap` — supports both snake_case and camelCase DB columns (e.g. betterauth-style)
|
|
483
|
-
- **
|
|
500
|
+
- **Aliases identify joins** — declared in `buildRelation({ alias })`, used as keys in request/response/`secondaries`/dotted notation
|
|
501
|
+
- **`joinMustExist` / `joinMultiple` / `joinGroup`** are 1:N (child→main) and use side queries / EXISTS / correlated subqueries — no row duplication
|
|
502
|
+
- **`joinLeft`** is N:1 (parent→main) and adds a real `LEFT JOIN` on demand (only when filtering/ordering by parent)
|
|
484
503
|
- **All response fields are Optional** — response schemas use `Type.Partial` since `RETURNING *` may return any subset
|
|
485
504
|
|
|
486
505
|
## Re-exports
|
|
@@ -516,7 +535,9 @@ import type {
|
|
|
516
535
|
ValidationError, // [field, code] | [field, code, message]
|
|
517
536
|
ValidatorFn,
|
|
518
537
|
BulkValidatorFn,
|
|
538
|
+
JoinDefinition,
|
|
519
539
|
JoinRefFilter,
|
|
540
|
+
JoinFetchRequest,
|
|
520
541
|
JoinGroupRequest,
|
|
521
542
|
SearchCondition,
|
|
522
543
|
ConditionMethod,
|