fastify-auto-sqlapi 0.1.10 → 0.1.11
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 +1 -1
- package/AGENTS_BACKEND.md +76 -2
- package/AGENTS_FRONTEND.md +69 -545
- package/BREAKING_CHANGES.md +63 -0
- package/CHANGELOG.md +69 -0
- package/README.md +98 -11
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/lib/agent/manifest.d.ts +53 -0
- package/dist/lib/agent/manifest.d.ts.map +1 -0
- package/dist/lib/agent/manifest.js +153 -0
- package/dist/lib/agent/tool-schemas.d.ts +36 -0
- package/dist/lib/agent/tool-schemas.d.ts.map +1 -0
- package/dist/lib/agent/tool-schemas.js +47 -0
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +9 -1
- package/dist/lib/engine/bulk/bulk-delete.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-delete.js +5 -1
- package/dist/lib/engine/rest/delete.d.ts.map +1 -1
- package/dist/lib/engine/rest/delete.js +4 -1
- package/dist/lib/engine/rest/get.d.ts.map +1 -1
- package/dist/lib/engine/rest/get.js +4 -1
- package/dist/lib/nullable.d.ts +17 -0
- package/dist/lib/nullable.d.ts.map +1 -0
- package/dist/lib/nullable.js +24 -0
- package/dist/lib/schema/search.d.ts +11 -9
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +27 -10
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +12 -2
- package/dist/routes/auto/agent-manifest.routes.d.ts +12 -0
- package/dist/routes/auto/agent-manifest.routes.d.ts.map +1 -0
- package/dist/routes/auto/agent-manifest.routes.js +30 -0
- package/dist/routes/auto/bulk-delete.routes.d.ts.map +1 -1
- package/dist/routes/auto/bulk-delete.routes.js +1 -0
- package/dist/routes/auto/delete.routes.d.ts.map +1 -1
- package/dist/routes/auto/delete.routes.js +1 -0
- package/dist/routes/auto/get.routes.d.ts.map +1 -1
- package/dist/routes/auto/get.routes.js +1 -0
- package/dist/routes/auto/plugin.d.ts.map +1 -1
- package/dist/routes/auto/plugin.js +4 -0
- package/dist/routes/auto/route-helpers.d.ts +7 -0
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +11 -0
- package/dist/routes/auto/search.routes.js +3 -3
- package/dist/types/plugin.d.ts +7 -0
- package/dist/types/plugin.d.ts.map +1 -1
- package/dist/types/table.d.ts +6 -0
- package/dist/types/table.d.ts.map +1 -1
- package/dist/types/table.js +8 -0
- package/docs/adr/0007-explicit-table-generation.md +39 -0
- package/docs/adr/README.md +1 -0
- package/package.json +1 -1
package/AGENTS_FRONTEND.md
CHANGED
|
@@ -1,587 +1,111 @@
|
|
|
1
|
-
# fastify-auto-sqlapi —
|
|
1
|
+
# fastify-auto-sqlapi — client API (LLM reference)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This file is the request GRAMMAR. For this deployment's tables/fields/aliases (the
|
|
4
|
+
vocabulary), fetch `GET {p}/agent/manifest.md` if available, or consult Swagger.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
Rules: all request/response fields camelCase. `{p}` = deployment prefix (e.g. `/api`). Join keys are backend-declared ALIASES (default = joined table name; list per table in Swagger). Write responses are PK-only. Write bodies reject unknown properties (400) — send only real schema fields. Arrays for bulk are capped (default 1000/request; chunk client-side). Composite-PK tables have no get/delete/bulk-delete (404) — use search / update.
|
|
6
7
|
|
|
7
|
-
##
|
|
8
|
-
|
|
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'`:
|
|
8
|
+
## Endpoints
|
|
10
9
|
|
|
11
10
|
```
|
|
12
|
-
POST /
|
|
13
|
-
GET /
|
|
14
|
-
POST /
|
|
15
|
-
PUT /
|
|
16
|
-
DELETE /
|
|
17
|
-
PUT /
|
|
18
|
-
POST /
|
|
11
|
+
POST {p}/search/{table} body=search request → {table, main:[...], ...}
|
|
12
|
+
GET {p}/rest/{table}/:id → {main:{...}} | 404
|
|
13
|
+
POST {p}/rest/{table} body={main, secondaries?} → 201 {main:{id}, secondaries?}
|
|
14
|
+
PUT {p}/rest/{table} body={main(+PK), secondaries?, deletions?} → {main:{id}, ...}
|
|
15
|
+
DELETE {p}/rest/{table}/:id → {main:{id}} | 404
|
|
16
|
+
PUT {p}/bulk/{table} body=[{main, secondaries?, deletions?},...] → [{main:{id}},...]
|
|
17
|
+
POST {p}/bulk/{table}/delete body=[{id:1},{id:2}] → [{main:{id:1}},...]
|
|
19
18
|
```
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
## POST /search/{tableName}
|
|
26
|
-
|
|
27
|
-
**Important**: Search uses `POST` (not GET) because filters are passed in the request body as JSON.
|
|
28
|
-
|
|
29
|
-
### Query parameters (all optional)
|
|
30
|
-
|
|
31
|
-
```
|
|
32
|
-
?orderBy=name ASC, id DESC
|
|
33
|
-
&page=1&itemsPerPage=20
|
|
34
|
-
&computeMin=id&computeMax=id&computeSum=total&computeAvg=total
|
|
35
|
-
```
|
|
20
|
+
## SEARCH
|
|
36
21
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
- `computeMin`, `computeMax`, `computeSum`, `computeAvg` — aggregate a column, returned in `pagination.computed`
|
|
22
|
+
Query string (all optional): `?orderBy=...&page=1&itemsPerPage=20&computeMin=f&computeMax=f&computeSum=f&computeAvg=f`
|
|
23
|
+
Body (all optional; `{}` = first N rows). Everything below is combinable in one request:
|
|
40
24
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
```json
|
|
25
|
+
```jsonc
|
|
26
|
+
// POST {p}/search/customer?orderBy=orders.sum.total DESC, creator.name ASC, id&page=1&itemsPerPage=20&computeSum=total
|
|
44
27
|
{
|
|
45
|
-
"filters": { "
|
|
46
|
-
"conditions": [
|
|
47
|
-
{ "field": "
|
|
28
|
+
"filters": { "status": "active", "deletedById": null }, // equality AND; null → IS NULL; extraFilters names (backend-defined, see manifest) also accepted here
|
|
29
|
+
"conditions": [ // advanced, AND, same field repeatable
|
|
30
|
+
{ "field": "total", "method": "isBetween", "params": [100, 500] },
|
|
31
|
+
{ "field": "name", "method": "isILike", "params": ["%mario%"] },
|
|
32
|
+
{ "field": "status", "method": "isIn", "params": [["active", "pending"]] },
|
|
33
|
+
{ "field": "orders.count.id", "method": "isGreaterOrEqual", "params": [4] } // HAVING-style: needs matching joinGroup below
|
|
48
34
|
],
|
|
49
|
-
"
|
|
50
|
-
|
|
35
|
+
"selectComputed": ["displayName"], // backend computed fields, projected into main rows only if listed
|
|
36
|
+
"joinMustExist": { // EXISTS on child: filters WHICH main rows return; no data fetched
|
|
37
|
+
"labels": { "filters": { "labelId": 1 }, "conditions": [] }
|
|
51
38
|
},
|
|
52
|
-
"joinMultiple": {
|
|
53
|
-
"orders": { "filters": { "status": "pending" } }
|
|
39
|
+
"joinMultiple": { // side query: fetches child rows OF the returned mains
|
|
40
|
+
"orders": { "filters": { "status": "pending" }, "selection": "id,total,status" }
|
|
54
41
|
},
|
|
55
|
-
"joinGroup": {
|
|
42
|
+
"joinGroup": { // aggregations on child rows
|
|
56
43
|
"orders": {
|
|
57
44
|
"aggregations": {
|
|
58
|
-
"by": "status",
|
|
59
|
-
"sum": ["total"],
|
|
60
|
-
"
|
|
61
|
-
"max": ["total"],
|
|
62
|
-
"distinctCount": ["status"]
|
|
45
|
+
"by": "status", // optional GROUP BY (schema field or backend computed, e.g. month bucket)
|
|
46
|
+
"sum": ["total"], "min": ["total"], "max": ["total"], "avg": ["total"],
|
|
47
|
+
"count": ["id"], "distinctCount": ["status"]
|
|
63
48
|
},
|
|
64
|
-
"filters": { "status": "active" }
|
|
49
|
+
"filters": { "status": "active" } // narrows child rows before aggregating
|
|
65
50
|
}
|
|
66
51
|
},
|
|
67
|
-
"joinLeft": {
|
|
68
|
-
"creator": { "selection": "id,name
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
```
|
|
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
|
-
|
|
75
|
-
### Filters
|
|
76
|
-
|
|
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.
|
|
78
|
-
|
|
79
|
-
### Conditions (advanced filters)
|
|
80
|
-
|
|
81
|
-
`conditions` allows using any ConditionBuilder method for advanced comparisons. Each condition specifies a `field`, a `method`, and `params` (values after the field).
|
|
82
|
-
|
|
83
|
-
```json
|
|
84
|
-
{
|
|
85
|
-
"filters": { "status": "active" },
|
|
86
|
-
"conditions": [
|
|
87
|
-
{ "field": "total", "method": "isGreater", "params": [100] },
|
|
88
|
-
{ "field": "total", "method": "isLess", "params": [500] },
|
|
89
|
-
{ "field": "name", "method": "isILike", "params": ["%mario%"] },
|
|
90
|
-
{ "field": "createdAt", "method": "isBetween", "params": ["2024-01-01", "2024-12-31"] },
|
|
91
|
-
{ "field": "status", "method": "isIn", "params": [["active", "pending"]] },
|
|
92
|
-
{ "field": "deletedAt", "method": "isNull", "params": [] }
|
|
93
|
-
]
|
|
94
|
-
}
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
**Available methods** (mapped to ConditionBuilder):
|
|
98
|
-
|
|
99
|
-
| Method | SQL | params |
|
|
100
|
-
|---|---|---|
|
|
101
|
-
| `isEqual`, `isNotEqual` | `= $1` / `!= $1` | `[value]` |
|
|
102
|
-
| `isGreater`, `isGreaterOrEqual` | `> $1` / `>= $1` | `[value]` |
|
|
103
|
-
| `isLess`, `isLessOrEqual` | `< $1` / `<= $1` | `[value]` |
|
|
104
|
-
| `isLike`, `isNotLike` | `LIKE $1` | `["%pattern%"]` |
|
|
105
|
-
| `isILike`, `isNotILike` | `ILIKE $1` | `["%pattern%"]` |
|
|
106
|
-
| `isIn`, `isNotIn` | `IN (...)` | `[[val1, val2]]` |
|
|
107
|
-
| `isBetween`, `isNotBetween` | `BETWEEN $1 AND $2` | `[from, to]` |
|
|
108
|
-
| `isNull`, `isNotNull` | `IS NULL` / `IS NOT NULL` | `[]` |
|
|
109
|
-
|
|
110
|
-
Also available: `isNotGreater`, `isNotGreaterOrEqual`, `isNotLess`, `isNotLessOrEqual`.
|
|
111
|
-
|
|
112
|
-
- **Multiple conditions on the same field** — it's an array, so `total > 100 AND total < 500` is natural
|
|
113
|
-
- **Combinable** with `filters` (equality), `joinMustExist` (EXISTS), pagination, etc.
|
|
114
|
-
- **Field validation** — field names are validated against the table schema (400 if unknown)
|
|
115
|
-
- **Method validation** — only whitelisted methods are allowed (`raw`, `append` etc. are blocked)
|
|
116
|
-
|
|
117
|
-
#### Conditions on joinGroup aggregations (HAVING-style)
|
|
118
|
-
|
|
119
|
-
You can filter main rows based on the value of a `joinGroup` aggregation using 3-parti dot notation: `<alias>.<fn>.<field>`.
|
|
120
|
-
|
|
121
|
-
Example: "users with at least 4 sessions":
|
|
122
|
-
|
|
123
|
-
```json
|
|
124
|
-
{
|
|
125
|
-
"conditions": [
|
|
126
|
-
{ "field": "sessions.count.id", "method": "isGreaterOrEqual", "params": [4] }
|
|
127
|
-
],
|
|
128
|
-
"joinGroup": {
|
|
129
|
-
"sessions": { "aggregations": { "count": ["id"] } }
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
Generated SQL:
|
|
135
|
-
```sql
|
|
136
|
-
SELECT * FROM "user"
|
|
137
|
-
WHERE COALESCE((
|
|
138
|
-
SELECT COUNT("session"."id")
|
|
139
|
-
FROM "session"
|
|
140
|
-
WHERE "session"."user_id" = "user"."id"
|
|
141
|
-
), 0) >= $1
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
**Rules:**
|
|
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).
|
|
147
|
-
- All ConditionBuilder methods work: `isEqual`, `isGreater`, `isLess`, `isBetween`, `isIn`, `isNull`, `isNotNull`, etc.
|
|
148
|
-
- Can be combined with plain-field conditions and with aggregation `orderBy` in the same request.
|
|
149
|
-
- Respects `aggregations.by` rules: allowed only when `by` equals the correlation FK, otherwise 400.
|
|
150
|
-
|
|
151
|
-
### `joinMustExist` (filter main by related table — EXISTS)
|
|
152
|
-
|
|
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.
|
|
154
|
-
|
|
155
|
-
```json
|
|
156
|
-
{
|
|
157
|
-
"filters": { "name": "Mario" },
|
|
158
|
-
"joinMustExist": {
|
|
159
|
-
"labels": { "filters": { "labelId": 1 } }
|
|
52
|
+
"joinLeft": { // N:1 parent embed (alias declared unique by backend)
|
|
53
|
+
"creator": { "selection": "id,name" } // filters/conditions here EXCLUDE mains whose parent doesn't match; schema fields only (extraFilters NOT usable here)
|
|
160
54
|
}
|
|
161
55
|
}
|
|
162
56
|
```
|
|
163
57
|
|
|
164
|
-
|
|
165
|
-
```sql
|
|
166
|
-
SELECT * FROM "customer"
|
|
167
|
-
WHERE "name" = $1
|
|
168
|
-
AND EXISTS (SELECT 1 FROM "customer_label_link"
|
|
169
|
-
WHERE "customer_id" = "customer"."id" AND "label_id" = $2)
|
|
170
|
-
```
|
|
171
|
-
|
|
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.
|
|
173
|
-
|
|
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.
|
|
175
|
-
|
|
176
|
-
### `joinMultiple` (fetch related child rows)
|
|
58
|
+
Response (each section present only if requested):
|
|
177
59
|
|
|
178
|
-
|
|
179
|
-
|
|
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 `'*'`).
|
|
192
|
-
|
|
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.
|
|
194
|
-
|
|
195
|
-
### `joinGroup` (aggregations on related child rows)
|
|
196
|
-
|
|
197
|
-
`joinGroup` computes aggregated values on a related child table:
|
|
198
|
-
|
|
199
|
-
```json
|
|
200
|
-
{
|
|
201
|
-
"joinGroup": {
|
|
202
|
-
"orders": {
|
|
203
|
-
"aggregations": {
|
|
204
|
-
"by": "status",
|
|
205
|
-
"sum": ["total"],
|
|
206
|
-
"min": ["total"],
|
|
207
|
-
"max": ["total"],
|
|
208
|
-
"avg": ["total"],
|
|
209
|
-
"count": ["id"],
|
|
210
|
-
"distinctCount": ["status"]
|
|
211
|
-
},
|
|
212
|
-
"filters": { "status": "active" }
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
```
|
|
217
|
-
|
|
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).
|
|
219
|
-
- `sum`, `min`, `max`, `avg` — aggregate functions on specified columns
|
|
220
|
-
- `count` — COUNT(col)
|
|
221
|
-
- `distinctCount` — COUNT(DISTINCT col)
|
|
222
|
-
- `filters` — optional, narrow rows before aggregation
|
|
223
|
-
|
|
224
|
-
Example — monthly revenue per customer (the backend declared a `monthBucket` computed):
|
|
225
|
-
|
|
226
|
-
```json
|
|
227
|
-
{
|
|
228
|
-
"joinGroup": {
|
|
229
|
-
"orders": {
|
|
230
|
-
"aggregations": {
|
|
231
|
-
"by": "monthBucket",
|
|
232
|
-
"sum": ["total"]
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
```
|
|
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
|
-
}
|
|
251
|
-
```
|
|
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
|
-
}
|
|
268
|
-
```
|
|
269
|
-
|
|
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
|
-
```
|
|
281
|
-
|
|
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.
|
|
283
|
-
|
|
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)
|
|
299
|
-
|
|
300
|
-
Supported functions: `sum`, `min`, `max`, `avg`, `count`, `distinctCount`.
|
|
301
|
-
|
|
302
|
-
Example: get users ordered by total session duration DESC.
|
|
303
|
-
|
|
304
|
-
```
|
|
305
|
-
POST /auto/search/user?orderBy=sessions.sum.duration%20DESC
|
|
306
|
-
```
|
|
307
|
-
```json
|
|
308
|
-
{
|
|
309
|
-
"joinGroup": {
|
|
310
|
-
"sessions": {
|
|
311
|
-
"aggregations": { "sum": ["duration"] }
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
Generated SQL (simplified):
|
|
318
|
-
```sql
|
|
319
|
-
SELECT * FROM "user"
|
|
320
|
-
ORDER BY (
|
|
321
|
-
SELECT SUM("session"."duration")
|
|
322
|
-
FROM "session"
|
|
323
|
-
WHERE "session"."user_id" = "user"."id"
|
|
324
|
-
) DESC
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
**Rules:**
|
|
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.
|
|
331
|
-
- **Not allowed** when the main table has `distinctResults: true`. 400 error.
|
|
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).
|
|
343
|
-
|
|
344
|
-
### Pagination
|
|
345
|
-
|
|
346
|
-
Add `page` or `itemsPerPage` to the query string to enable pagination:
|
|
347
|
-
|
|
348
|
-
```
|
|
349
|
-
?page=1&itemsPerPage=20
|
|
350
|
-
?itemsPerPage=10 # page defaults to 1
|
|
351
|
-
```
|
|
352
|
-
|
|
353
|
-
When either is present, the response includes a `pagination` object with total count, total pages, and the paginator values. `page` defaults to 1, `itemsPerPage` defaults to 500. `itemsPerPage` is capped at `maxItemsPerPage` (default 1000) — a larger value returns 400. Even without pagination, a search returns at most `maxItemsPerPage` rows.
|
|
354
|
-
|
|
355
|
-
### Computed values (aggregations on main table)
|
|
356
|
-
|
|
357
|
-
```
|
|
358
|
-
?computeMin=id&computeMax=id&computeSum=total&computeAvg=total
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
These compute aggregates on the main table's result set (respecting filters). Results appear in `pagination.computed`. Requires pagination to be active (`page` or `itemsPerPage` set).
|
|
362
|
-
|
|
363
|
-
### Response
|
|
364
|
-
|
|
365
|
-
```json
|
|
60
|
+
```jsonc
|
|
366
61
|
{
|
|
367
62
|
"table": "customer",
|
|
368
|
-
"main": [{ "id": 1, "name": "Mario", "
|
|
369
|
-
"
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
},
|
|
375
|
-
"
|
|
376
|
-
"
|
|
377
|
-
|
|
378
|
-
"rows": [{ "by": "pending", "sum_total": 100 }]
|
|
379
|
-
}
|
|
380
|
-
},
|
|
381
|
-
"pagination": {
|
|
382
|
-
"total": 25,
|
|
383
|
-
"pages": 3,
|
|
384
|
-
"computed": { "min": { "id": 1 }, "max": { "id": 100 } },
|
|
63
|
+
"main": [{ "id": 1, "name": "Mario", "creatorId": 7, "displayName": "..." }],
|
|
64
|
+
"joinMultiple": { "orders": [{ "id": 10, "customerId": 1, "total": 50 }] },
|
|
65
|
+
"joinGroup": { "orders": {
|
|
66
|
+
"sum": { "total": 150 }, // keyed by fn when no "by"
|
|
67
|
+
"rows": [{ "by": "pending", "sum_total": 100 }] // rows[] when "by" is set
|
|
68
|
+
}},
|
|
69
|
+
"joinLeft": { "creator": [{ "id": 7, "name": "Alice" }] }, // deduped; map client-side: main.creatorId === creator.id
|
|
70
|
+
"pagination": { // present when page/itemsPerPage sent
|
|
71
|
+
"total": 25, "pages": 3,
|
|
72
|
+
"computed": { "sum": { "total": 999 } }, // computeMin/Max/Sum/Avg (require pagination)
|
|
385
73
|
"paginator": { "page": 1, "itemsPerPage": 20 }
|
|
386
74
|
}
|
|
387
75
|
}
|
|
388
76
|
```
|
|
389
77
|
|
|
390
|
-
`
|
|
391
|
-
|
|
392
|
-
---
|
|
393
|
-
|
|
394
|
-
## GET /rest/{tableName}/:id
|
|
395
|
-
|
|
396
|
-
Returns a single record by primary key.
|
|
397
|
-
|
|
398
|
-
**Response (200):**
|
|
399
|
-
|
|
400
|
-
```json
|
|
401
|
-
{ "main": { "id": 1, "name": "Mario", "email": "m@t.it" } }
|
|
402
|
-
```
|
|
403
|
-
|
|
404
|
-
Returns 404 if not found.
|
|
405
|
-
|
|
406
|
-
---
|
|
407
|
-
|
|
408
|
-
## POST /rest/{tableName} (Insert)
|
|
409
|
-
|
|
410
|
-
**Body:**
|
|
411
|
-
|
|
412
|
-
```json
|
|
413
|
-
{
|
|
414
|
-
"main": { "name": "Mario", "email": "m@t.it" },
|
|
415
|
-
"secondaries": {
|
|
416
|
-
"orders": [
|
|
417
|
-
{ "total": 50, "status": "pending" }
|
|
418
|
-
]
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
```
|
|
422
|
-
|
|
423
|
-
- `main` — the record to insert. Omit auto-increment PKs (they are in `excludeFromCreation`).
|
|
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.
|
|
425
|
-
|
|
426
|
-
**Response (201):** PK-only for main and secondaries.
|
|
427
|
-
|
|
428
|
-
```json
|
|
429
|
-
{
|
|
430
|
-
"main": { "id": 1 },
|
|
431
|
-
"secondaries": {
|
|
432
|
-
"orders": [{ "id": 10 }]
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
```
|
|
436
|
-
|
|
437
|
-
---
|
|
438
|
-
|
|
439
|
-
## PUT /rest/{tableName} (Update)
|
|
440
|
-
|
|
441
|
-
**Body:**
|
|
442
|
-
|
|
443
|
-
```json
|
|
444
|
-
{
|
|
445
|
-
"main": { "id": 1, "name": "Mario Updated", "email": "new@t.it" },
|
|
446
|
-
"secondaries": {
|
|
447
|
-
"orders": [{ "total": 75, "status": "shipped" }]
|
|
448
|
-
},
|
|
449
|
-
"deletions": {
|
|
450
|
-
"orders": [{ "id": 10 }]
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
```
|
|
454
|
-
|
|
455
|
-
- `main` — MUST include the PK. Only changed fields need to be sent (partial update).
|
|
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**.
|
|
458
|
-
|
|
459
|
-
**Response (200):** PK-only for main, secondaries, and deletions.
|
|
460
|
-
|
|
461
|
-
```json
|
|
462
|
-
{
|
|
463
|
-
"main": { "id": 1 },
|
|
464
|
-
"secondaries": { "orders": [{ "id": 20 }] },
|
|
465
|
-
"deletions": { "orders": [{ "id": 10 }] }
|
|
466
|
-
}
|
|
467
|
-
```
|
|
78
|
+
`orderBy` forms (comma-separable): `field [ASC|DESC]` | `alias.parentField` (joinLeft-eligible alias, adds LEFT JOIN) | `alias.fn.field` (fn: sum|min|max|avg|count|distinctCount — the joinGroup with that fn+field MUST be in the body; rows without children sort as 0). For 3-part orderBy and HAVING-style conditions: `aggregations.by` is allowed only when it equals the correlation FK (else 400); aggregation orderBy is rejected (400) on tables with distinctResults.
|
|
468
79
|
|
|
469
|
-
|
|
80
|
+
Condition methods (params arity): `isEqual|isNotEqual|isGreater|isGreaterOrEqual|isLess|isLessOrEqual|isNotGreater|isNotGreaterOrEqual|isNotLess|isNotLessOrEqual` → `[v]`; `isLike|isNotLike|isILike|isNotILike` → `["%p%"]`; `isBetween|isNotBetween` → `[from,to]`; `isIn|isNotIn` → `[[v1,v2]]`; `isNull|isNotNull` → `[]`. Unknown field or method → 400.
|
|
470
81
|
|
|
471
|
-
##
|
|
82
|
+
## WRITES
|
|
472
83
|
|
|
473
|
-
|
|
84
|
+
```jsonc
|
|
85
|
+
// POST {p}/rest/customer (insert; omit auto-generated PKs)
|
|
86
|
+
{ "main": { "name": "Mario", "email": "m@t.it" },
|
|
87
|
+
"secondaries": { "orders": [{ "total": 50, "status": "pending" }] } } // child FK auto-filled from new main PK
|
|
88
|
+
// → 201 { "main": { "id": 1 }, "secondaries": { "orders": [{ "id": 10 }] } }
|
|
474
89
|
|
|
475
|
-
|
|
90
|
+
// PUT {p}/rest/customer (update; main MUST include PK; partial fields ok; null = set column NULL)
|
|
91
|
+
{ "main": { "id": 1, "email": "new@t.it" },
|
|
92
|
+
"secondaries": { "orders": [{ "total": 75 }] }, // inserts/upserts children, FK auto-filled
|
|
93
|
+
"deletions": { "orders": [{ "id": 10 }] } } // deletes children by PK (scoped to this main)
|
|
94
|
+
// → 200 { "main": { "id": 1 }, "secondaries": { "orders": [{ "id": 20 }] }, "deletions": { "orders": [{ "id": 10 }] } }
|
|
476
95
|
|
|
477
|
-
|
|
478
|
-
{ "main": { "
|
|
96
|
+
// PUT {p}/bulk/customer (bulk upsert; one SQL for mains; per-item secondaries/deletions)
|
|
97
|
+
[ { "main": { "name": "Mario" }, "secondaries": { "orders": [{ "total": 50 }] } },
|
|
98
|
+
{ "main": { "name": "Luigi" } } ]
|
|
99
|
+
// → 200 [ { "main": { "id": 1 }, "secondaries": {...} }, { "main": { "id": 2 } } ]
|
|
479
100
|
```
|
|
480
101
|
|
|
481
|
-
|
|
102
|
+
## Errors
|
|
482
103
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
## PUT /bulk/{tableName} (Bulk Upsert)
|
|
486
|
-
|
|
487
|
-
Insert or update multiple records in a single request.
|
|
488
|
-
|
|
489
|
-
**Body:**
|
|
104
|
+
400 (schema or business validation) — map `fields[].path` to form fields:
|
|
490
105
|
|
|
491
106
|
```json
|
|
492
|
-
|
|
493
|
-
{
|
|
494
|
-
"main": { "name": "Mario", "email": "m@t.it" },
|
|
495
|
-
"secondaries": { "orders": [{ "total": 50 }] },
|
|
496
|
-
"deletions": { "orders": [{ "id": 99 }] }
|
|
497
|
-
},
|
|
498
|
-
{
|
|
499
|
-
"main": { "name": "Luigi", "email": "l@t.it" }
|
|
500
|
-
}
|
|
501
|
-
]
|
|
107
|
+
{ "statusCode": 400, "error": "Bad Request", "message": "Validation failed",
|
|
108
|
+
"fields": [ { "path": "body.main.name", "code": "required", "message": "must have required property 'name'" } ] }
|
|
502
109
|
```
|
|
503
110
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
The array is capped at `maxBulkItems` (default 1000); a longer array returns 400 — chunk client-side if you have more.
|
|
507
|
-
|
|
508
|
-
**Response (200):** Array of `{ main (PK-only), secondaries? (PK-only), deletions? }`.
|
|
509
|
-
|
|
510
|
-
---
|
|
511
|
-
|
|
512
|
-
## POST /bulk/{tableName}/delete (Bulk Delete)
|
|
513
|
-
|
|
514
|
-
Delete multiple records by PK.
|
|
515
|
-
|
|
516
|
-
**Body:**
|
|
517
|
-
|
|
518
|
-
```json
|
|
519
|
-
[{ "id": 1 }, { "id": 2 }, { "id": 3 }]
|
|
520
|
-
```
|
|
521
|
-
|
|
522
|
-
Each object must contain the PK field. All deletions execute as a single `DELETE WHERE pk IN (...)`. The array is capped at `maxBulkItems` (default 1000) — a longer array returns 400.
|
|
523
|
-
|
|
524
|
-
**Response (200):** Array of PK-only objects.
|
|
525
|
-
|
|
526
|
-
```json
|
|
527
|
-
[{ "main": { "id": 1 } }, { "main": { "id": 2 } }]
|
|
528
|
-
```
|
|
529
|
-
|
|
530
|
-
---
|
|
531
|
-
|
|
532
|
-
## Validation Errors
|
|
533
|
-
|
|
534
|
-
Insert, update, and bulk-upsert endpoints may return **structured field-level validation errors** (400). Two sources:
|
|
535
|
-
|
|
536
|
-
1. **Schema validation** (TypeBox/Ajv) — type mismatches, missing required fields, and **unknown properties** (write bodies reject any field not in the table Schema — send only real columns, don't include extra keys)
|
|
537
|
-
2. **Custom validation** (`validate` / `validateBulk` on the table) — business rules, cross-entity checks
|
|
538
|
-
|
|
539
|
-
Both return the same response format:
|
|
540
|
-
|
|
541
|
-
```json
|
|
542
|
-
{
|
|
543
|
-
"statusCode": 400,
|
|
544
|
-
"error": "Bad Request",
|
|
545
|
-
"message": "Validation failed",
|
|
546
|
-
"fields": [
|
|
547
|
-
{ "path": "body.main.name", "code": "required", "message": "must have required property 'name'" },
|
|
548
|
-
{ "path": "periods[1].startDate", "code": "overlap", "message": "overlaps with another period" }
|
|
549
|
-
]
|
|
550
|
-
}
|
|
551
|
-
```
|
|
552
|
-
|
|
553
|
-
Each entry in `fields`:
|
|
554
|
-
- `path` — the field path (e.g. `body.main.name` for schema errors, `name` or `periods[1].startDate` for custom validation)
|
|
555
|
-
- `code` — machine-readable error code (e.g. `required`, `type`, `overlap`, `unique`)
|
|
556
|
-
- `message` — human-readable description
|
|
557
|
-
|
|
558
|
-
### Handling validation errors in the frontend
|
|
559
|
-
|
|
560
|
-
```typescript
|
|
561
|
-
const response = await fetch('/api/rest/session', {
|
|
562
|
-
method: 'POST',
|
|
563
|
-
headers: { 'Content-Type': 'application/json' },
|
|
564
|
-
body: JSON.stringify({ main: { name: '' }, secondaries: { periods } }),
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
if (!response.ok) {
|
|
568
|
-
const error = await response.json();
|
|
569
|
-
if (error.fields) {
|
|
570
|
-
// Field-level errors — display inline on the form
|
|
571
|
-
for (const { path, message } of error.fields) {
|
|
572
|
-
setFieldError(path, message);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
```
|
|
577
|
-
|
|
578
|
-
---
|
|
579
|
-
|
|
580
|
-
## Response Conventions
|
|
581
|
-
|
|
582
|
-
- **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.
|
|
583
|
-
- **camelCase fields**: All request and response fields use camelCase (e.g. `customerId`, not `customer_id`). The plugin converts automatically.
|
|
584
|
-
- **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`).
|
|
585
|
-
- **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.
|
|
586
|
-
- **`joinMultiple` / `joinMustExist` / `joinGroup`** are 1:N (child→main) and use side queries / EXISTS / correlated subqueries — no row duplication of main.
|
|
587
|
-
- **`joinLeft`** is N:1 (parent→main) and adds a real `LEFT JOIN` on demand (only when filtering/ordering by parent).
|
|
111
|
+
Other: 404 (get/delete not found), 400 `itemsPerPage exceeds the maximum` (lower it), 500 raw DB errors (constraint violations surface unmapped).
|