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.
Files changed (54) hide show
  1. package/AGENTS.md +1 -1
  2. package/AGENTS_BACKEND.md +76 -2
  3. package/AGENTS_FRONTEND.md +69 -545
  4. package/BREAKING_CHANGES.md +63 -0
  5. package/CHANGELOG.md +69 -0
  6. package/README.md +98 -11
  7. package/dist/index.d.ts +5 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +6 -1
  10. package/dist/lib/agent/manifest.d.ts +53 -0
  11. package/dist/lib/agent/manifest.d.ts.map +1 -0
  12. package/dist/lib/agent/manifest.js +153 -0
  13. package/dist/lib/agent/tool-schemas.d.ts +36 -0
  14. package/dist/lib/agent/tool-schemas.d.ts.map +1 -0
  15. package/dist/lib/agent/tool-schemas.js +47 -0
  16. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  17. package/dist/lib/cli/schema-codegen.js +9 -1
  18. package/dist/lib/engine/bulk/bulk-delete.d.ts.map +1 -1
  19. package/dist/lib/engine/bulk/bulk-delete.js +5 -1
  20. package/dist/lib/engine/rest/delete.d.ts.map +1 -1
  21. package/dist/lib/engine/rest/delete.js +4 -1
  22. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  23. package/dist/lib/engine/rest/get.js +4 -1
  24. package/dist/lib/nullable.d.ts +17 -0
  25. package/dist/lib/nullable.d.ts.map +1 -0
  26. package/dist/lib/nullable.js +24 -0
  27. package/dist/lib/schema/search.d.ts +11 -9
  28. package/dist/lib/schema/search.d.ts.map +1 -1
  29. package/dist/lib/schema/search.js +27 -10
  30. package/dist/lib/table-helpers.d.ts.map +1 -1
  31. package/dist/lib/table-helpers.js +12 -2
  32. package/dist/routes/auto/agent-manifest.routes.d.ts +12 -0
  33. package/dist/routes/auto/agent-manifest.routes.d.ts.map +1 -0
  34. package/dist/routes/auto/agent-manifest.routes.js +30 -0
  35. package/dist/routes/auto/bulk-delete.routes.d.ts.map +1 -1
  36. package/dist/routes/auto/bulk-delete.routes.js +1 -0
  37. package/dist/routes/auto/delete.routes.d.ts.map +1 -1
  38. package/dist/routes/auto/delete.routes.js +1 -0
  39. package/dist/routes/auto/get.routes.d.ts.map +1 -1
  40. package/dist/routes/auto/get.routes.js +1 -0
  41. package/dist/routes/auto/plugin.d.ts.map +1 -1
  42. package/dist/routes/auto/plugin.js +4 -0
  43. package/dist/routes/auto/route-helpers.d.ts +7 -0
  44. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  45. package/dist/routes/auto/route-helpers.js +11 -0
  46. package/dist/routes/auto/search.routes.js +3 -3
  47. package/dist/types/plugin.d.ts +7 -0
  48. package/dist/types/plugin.d.ts.map +1 -1
  49. package/dist/types/table.d.ts +6 -0
  50. package/dist/types/table.d.ts.map +1 -1
  51. package/dist/types/table.js +8 -0
  52. package/docs/adr/0007-explicit-table-generation.md +39 -0
  53. package/docs/adr/README.md +1 -0
  54. package/package.json +1 -1
@@ -1,587 +1,111 @@
1
- # fastify-auto-sqlapi — Frontend API Usage
1
+ # fastify-auto-sqlapi — client API (LLM reference)
2
2
 
3
- How to call the auto-generated CRUD endpoints.
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
- > ⚠️ **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
+ 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
- ## Generated Endpoints
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 /auto/search/customer — search with filters in body
13
- GET /auto/rest/customer/:id — get single record by PK
14
- POST /auto/rest/customer — insert record (+ secondaries)
15
- PUT /auto/rest/customer — update record (+ secondaries + deletions)
16
- DELETE /auto/rest/customer/:id — delete record by PK
17
- PUT /auto/bulk/customer — bulk upsert (array of items)
18
- POST /auto/bulk/customer/delete — bulk delete (array of PKs)
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
- Without prefix, routes are at root: `/search/customer`, `/rest/customer/:id`, etc.
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
- - `orderBy` — SQL ORDER BY clause (default: `defaultOrder` from table config). Supports dotted notations for joins, see below.
38
- - `page` + `itemsPerPage` — pagination (when either is present, response includes `pagination` object; `page` defaults to 1, `itemsPerPage` defaults to 500). `itemsPerPage` is capped server-side at `maxItemsPerPage` (default 1000); sending a larger value returns 400.
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
- ### Body (all fields optional, empty `{}` returns up to `maxItemsPerPage` records)
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": { "name": "Mario", "q": "search term" },
46
- "conditions": [
47
- { "field": "createdAt", "method": "isGreater", "params": ["2024-01-01"] }
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
- "joinMustExist": {
50
- "labels": { "filters": { "labelId": 1 } }
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
- "min": ["total"],
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,email" }
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
- SQL generated:
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
- Request related child table data via `joinMultiple`. Aliases must come from `allowedReadJoins` declarations with `unique: false`. Each entry accepts `{ filters?, conditions?, selection? }`.
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", "email": "m@t.it" }],
369
- "joinLeft": {
370
- "creator": [{ "id": 7, "name": "Alice", "email": "a@x.it" }]
371
- },
372
- "joinMultiple": {
373
- "orders": [{ "id": 10, "customerId": 1, "total": 50 }]
374
- },
375
- "joinGroup": {
376
- "orders": {
377
- "sum": { "total": 150 },
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
- `joinLeft`, `joinMultiple`, `joinGroup`, `pagination` appear ONLY if requested. A simple search returns just `{ table, main }`.
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
- ## DELETE /rest/{tableName}/:id
82
+ ## WRITES
472
83
 
473
- Deletes a single record by primary key.
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
- **Response (200):** PK-only.
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
- ```json
478
- { "main": { "id": 1 } }
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
- 404 if not found.
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
- All main records are inserted/upserted in a single SQL query. Secondaries and deletions are processed per-item. `secondaries`/`deletions` keys are **aliases**.
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).