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.
Files changed (123) hide show
  1. package/AGENTS.md +15 -2
  2. package/AGENTS_BACKEND.md +144 -20
  3. package/AGENTS_FRONTEND.md +161 -67
  4. package/README.md +47 -26
  5. package/dist/bin/generate-schema.js +9 -26
  6. package/dist/bin/generate-tables.js +7 -30
  7. package/dist/bin/utils.d.ts +28 -0
  8. package/dist/bin/utils.d.ts.map +1 -1
  9. package/dist/bin/utils.js +69 -0
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/lib/cli/load-dependency.d.ts +8 -0
  13. package/dist/lib/cli/load-dependency.d.ts.map +1 -0
  14. package/dist/lib/cli/load-dependency.js +21 -0
  15. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  16. package/dist/lib/cli/mysql-introspect.js +2 -18
  17. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  18. package/dist/lib/cli/pg-introspect.js +2 -17
  19. package/dist/lib/db.d.ts +1 -0
  20. package/dist/lib/db.d.ts.map +1 -1
  21. package/dist/lib/db.js +33 -55
  22. package/dist/lib/dialect.d.ts +17 -0
  23. package/dist/lib/dialect.d.ts.map +1 -1
  24. package/dist/lib/dialect.js +32 -0
  25. package/dist/lib/engine/bulk/bulk-delete.d.ts.map +1 -1
  26. package/dist/lib/engine/bulk/bulk-delete.js +8 -2
  27. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  28. package/dist/lib/engine/bulk/bulk-upsert.js +15 -43
  29. package/dist/lib/engine/rest/delete.d.ts.map +1 -1
  30. package/dist/lib/engine/rest/delete.js +14 -12
  31. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  32. package/dist/lib/engine/rest/get.js +3 -5
  33. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  34. package/dist/lib/engine/rest/insert.js +11 -31
  35. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  36. package/dist/lib/engine/rest/update.js +13 -46
  37. package/dist/lib/engine/search/search.d.ts.map +1 -1
  38. package/dist/lib/engine/search/search.js +553 -229
  39. package/dist/lib/engine/write-helpers.d.ts +39 -2
  40. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  41. package/dist/lib/engine/write-helpers.js +55 -12
  42. package/dist/lib/errors.d.ts +11 -0
  43. package/dist/lib/errors.d.ts.map +1 -0
  44. package/dist/lib/errors.js +12 -0
  45. package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
  46. package/dist/lib/schema/bulk-upsert.js +9 -25
  47. package/dist/lib/schema/helpers.d.ts +28 -1
  48. package/dist/lib/schema/helpers.d.ts.map +1 -1
  49. package/dist/lib/schema/helpers.js +38 -0
  50. package/dist/lib/schema/insert.d.ts.map +1 -1
  51. package/dist/lib/schema/insert.js +9 -19
  52. package/dist/lib/schema/search.d.ts.map +1 -1
  53. package/dist/lib/schema/search.js +138 -50
  54. package/dist/lib/schema/update.d.ts.map +1 -1
  55. package/dist/lib/schema/update.js +9 -25
  56. package/dist/lib/sql-api.d.ts +6 -6
  57. package/dist/lib/sql-api.d.ts.map +1 -1
  58. package/dist/lib/sql-api.js +10 -10
  59. package/dist/lib/table-helpers.d.ts +6 -1
  60. package/dist/lib/table-helpers.d.ts.map +1 -1
  61. package/dist/lib/table-helpers.js +42 -2
  62. package/dist/lib/tenant.d.ts +30 -0
  63. package/dist/lib/tenant.d.ts.map +1 -1
  64. package/dist/lib/tenant.js +78 -9
  65. package/dist/routes/auto/bulk-delete.routes.d.ts.map +1 -1
  66. package/dist/routes/auto/bulk-delete.routes.js +17 -23
  67. package/dist/routes/auto/bulk-upsert.routes.d.ts.map +1 -1
  68. package/dist/routes/auto/bulk-upsert.routes.js +16 -22
  69. package/dist/routes/auto/delete.routes.d.ts.map +1 -1
  70. package/dist/routes/auto/delete.routes.js +18 -24
  71. package/dist/routes/auto/get.routes.d.ts.map +1 -1
  72. package/dist/routes/auto/get.routes.js +16 -23
  73. package/dist/routes/auto/insert.routes.d.ts.map +1 -1
  74. package/dist/routes/auto/insert.routes.js +19 -26
  75. package/dist/routes/auto/plugin.d.ts.map +1 -1
  76. package/dist/routes/auto/plugin.js +40 -5
  77. package/dist/routes/auto/route-helpers.d.ts +27 -2
  78. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  79. package/dist/routes/auto/route-helpers.js +39 -1
  80. package/dist/routes/auto/search.routes.d.ts.map +1 -1
  81. package/dist/routes/auto/search.routes.js +47 -47
  82. package/dist/routes/auto/update.routes.d.ts.map +1 -1
  83. package/dist/routes/auto/update.routes.js +20 -27
  84. package/dist/types/cli.d.ts +22 -0
  85. package/dist/types/cli.d.ts.map +1 -0
  86. package/dist/types/cli.js +1 -0
  87. package/dist/types/computed.d.ts +29 -0
  88. package/dist/types/computed.d.ts.map +1 -0
  89. package/dist/types/computed.js +1 -0
  90. package/dist/types/conditions.d.ts +16 -0
  91. package/dist/types/conditions.d.ts.map +1 -0
  92. package/dist/types/conditions.js +1 -0
  93. package/dist/types/db.d.ts +22 -0
  94. package/dist/types/db.d.ts.map +1 -0
  95. package/dist/types/db.js +1 -0
  96. package/dist/types/dml.d.ts +66 -0
  97. package/dist/types/dml.d.ts.map +1 -0
  98. package/dist/types/dml.js +1 -0
  99. package/dist/types/join.d.ts +44 -0
  100. package/dist/types/join.d.ts.map +1 -0
  101. package/dist/types/join.js +1 -0
  102. package/dist/types/plugin.d.ts +20 -0
  103. package/dist/types/plugin.d.ts.map +1 -0
  104. package/dist/types/plugin.js +1 -0
  105. package/dist/types/schema.d.ts +10 -0
  106. package/dist/types/schema.d.ts.map +1 -0
  107. package/dist/types/schema.js +1 -0
  108. package/dist/types/search.d.ts +45 -0
  109. package/dist/types/search.d.ts.map +1 -0
  110. package/dist/types/search.js +1 -0
  111. package/dist/types/table.d.ts +76 -0
  112. package/dist/types/table.d.ts.map +1 -0
  113. package/dist/types/table.js +7 -0
  114. package/dist/types/tenant.d.ts +19 -0
  115. package/dist/types/tenant.d.ts.map +1 -0
  116. package/dist/types/tenant.js +1 -0
  117. package/dist/types/validation.d.ts +21 -0
  118. package/dist/types/validation.d.ts.map +1 -0
  119. package/dist/types/validation.js +1 -0
  120. package/dist/types.d.ts +12 -277
  121. package/dist/types.d.ts.map +1 -1
  122. package/dist/types.js +12 -7
  123. package/package.json +3 -3
@@ -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
- "joinFilters": {
48
- "customer_label_link": { "labelId": 1 }
49
+ "joinMustExist": {
50
+ "labels": { "filters": { "labelId": 1 } }
49
51
  },
50
- "joins": {
51
- "customer_order": { "filters": { "status": "pending" } }
52
+ "joinMultiple": {
53
+ "orders": { "filters": { "status": "pending" } }
52
54
  },
53
- "joinGroups": {
54
- "customer_order": {
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), `joinFilters` (EXISTS), pagination, etc.
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 the same dot notation as `orderBy`: `<joinTable>.<fn>.<field>`.
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": "session.count.id", "method": "isGreaterOrEqual", "params": [4] }
126
+ { "field": "sessions.count.id", "method": "isGreaterOrEqual", "params": [4] }
120
127
  ],
121
- "joinGroups": {
122
- "session": { "aggregations": { "count": ["id"] } }
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
- - Same declaration requirement as orderBy: the joinGroup must be declared in `joinGroups.<table>.aggregations.<fn>` with the referenced field.
139
- - The joinGroup's own `filters` are applied inside the correlated subquery (consistent with the breakdown). Example: filter customers whose SUM of *completed* orders > 1000 by declaring `joinGroups.order.filters: { status: 'completed' }`.
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
- ### Join Filters (filter main by related table)
151
+ ### `joinMustExist` (filter main by related table — EXISTS)
145
152
 
146
- `joinFilters` restrict **main table results** based on conditions on a related table. Only tables listed in `allowedReadJoins` are available. Uses `EXISTS` subquery — no duplicate rows, works correctly with pagination.
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
- "joinFilters": {
152
- "customer_label_link": { "labelId": 1 }
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
- `joinFilters` support the same filter fields as the related table (schema fields + extra filters defined via `extendedCondition`). Can be combined with `filters`, `joins`, `joinGroups`, and pagination.
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
- **Key difference from `joins`**: `joinFilters` filter which main records are returned. `joins` fetch related data for the returned main records. They can be used together.
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
- ### Joins
176
+ ### `joinMultiple` (fetch related child rows)
170
177
 
171
- Request related table data via `joins`. Only tables listed in `allowedReadJoins` are available. Each join key is the table name, with optional `filters` to narrow the joined results.
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
- The join executes as a separate query: `SELECT ... FROM {joinTable} WHERE {fk} IN ({main PKs})`. Results are grouped by FK.
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
- ### Join Groups (Aggregations)
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
- `joinGroups` compute aggregated data on related tables:
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
- "joinGroups": {
182
- "customer_order": {
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
- ### Ordering
224
+ Example — monthly revenue per customer (the backend declared a `monthBucket` computed):
205
225
 
206
- Use the `orderBy` query parameter:
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
- ?orderBy=name ASC
210
- ?orderBy=name ASC, id DESC
211
- ?orderBy=created_at DESC
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
- If not specified, the table's `defaultOrder` is used.
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
- #### Ordering by joinGroup aggregations
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
- You can order the main results by an aggregation computed on a joined table using dot notation: `<joinTable>.<fn>.<field>`.
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=session.sum.duration%20DESC
305
+ POST /auto/search/user?orderBy=sessions.sum.duration%20DESC
226
306
  ```
227
307
  ```json
228
308
  {
229
- "joinGroups": {
230
- "session": {
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** (`joinGroups.<table>.aggregations.<fn>` must include the field). The engine refuses undeclared references with 400.
249
- - If the joinGroup declares `aggregations.filters`, those filters are applied inside the correlated subquery — consistent with the breakdown.
250
- - **`aggregations.by`**: allowed **only** when `by` is the same field used for the join correlation (e.g. `by: "userId"` when joining user→session via userId). In that case each main row maps to exactly one group, so the ordering is well-defined *and* the response contains the per-row breakdown you can render next to each row. Using `by` on any other column is rejected with 400 (the result would be bucketed per group, not per row).
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 field ordering: `?orderBy=session.sum.duration DESC, name ASC`.
253
- - The joinGroup breakdown is still returned in the response (`joinGroups.session.sum.duration` etc.) as before — you get both ordered main results and the aggregated summary.
254
- - **No-data rows**: rows with no matching joined records are coalesced to `0` via `COALESCE(..., 0)`. Practically: on DESC they appear last, on ASC they appear first. This is universal across PostgreSQL, MySQL, and MariaDB.
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
- "joins": {
282
- "customer_order": [{ "id": 10, "customerId": 1, "total": 50 }]
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
- "joinGroups": {
285
- "customer_order": {
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
- `joins`, `joinGroups`, `pagination` appear ONLY if requested. A simple search returns just `{ table, main }`.
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
- "customer_order": [
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. Only tables in `allowedWriteJoins` are accepted. FK fields (e.g. `customerId` in orders) are auto-filled from the inserted main record's PK.
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
- "customer_order": [{ "id": 10 }]
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
- "customer_order": [{ "total": 75, "status": "shipped" }]
447
+ "orders": [{ "total": 75, "status": "shipped" }]
357
448
  },
358
449
  "deletions": {
359
- "customer_order": [{ "id": 10 }]
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": { "customer_order": [{ "id": 20 }] },
374
- "deletions": { "customer_order": [{ "id": 10 }] }
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": { "customer_order": [{ "total": 50 }] },
405
- "deletions": { "customer_order": [{ "id": 99 }] }
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": "session_period[1].startDate", "code": "overlap", "message": "overlaps with another period" }
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 `session_period[1].startDate` for custom validation)
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: { session_period: periods } }),
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
- - **Joins are virtual**: Not SQL JOINs. They execute separate queries and group results by FK. This keeps the main query simple and avoids N+1 issues through batching.
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
- - **Virtual joins** — fetch related records without complex SQL
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
- "joinFilters": {
259
- "order": {
265
+ "joinMustExist": {
266
+ "orders": {
260
267
  "filters": { "status": "completed" },
261
268
  "conditions": [{ "field": "total", "method": "isGreater", "params": [50] }]
262
269
  }
263
270
  },
264
- "joins": {
265
- "order": {
271
+ "joinMultiple": {
272
+ "orders": {
266
273
  "filters": { "status": "completed" },
267
- "conditions": [{ "field": "total", "method": "isGreater", "params": [50] }]
274
+ "selection": "id,total,status"
268
275
  }
269
276
  },
270
- "joinGroups": {
271
- "order": {
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
- - **`joinFilters`** — EXISTS-based filtering: "main rows where at least one related row matches". Accepts `{ filters, conditions }` (both optional). Only tables in `allowedReadJoins`.
291
- - **`joins`** — fetch related records (virtual join via separate query). Accepts `{ filters, conditions }`.
292
- - **`joinGroups`** — aggregations on related tables. Supports `sum`, `min`, `max`, `avg`, `count`, `distinctCount`, and optional `by` for GROUP BY. Accepts `{ filters, conditions }` inside the subquery.
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
- **Dot-notation in `conditions` and `orderBy`** — use `<joinTable>.<fn>.<field>` to filter/order the main rows by an aggregation value (HAVING-style). Example: `orderBy=order.sum.total DESC` or `conditions: [{ field: 'order.count.id', method: 'isGreaterOrEqual', params: [4] }]`. The joinGroup must be declared in the body.
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
- "joins": { "order": [{ "id": 10, "customerId": 1, "total": 50 }] },
305
- "joinGroups": {
306
- "order": {
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
- `joins`, `joinGroups`, and `pagination` only appear when requested. A simple `{}` body returns `{ table, main }`. `pagination.computed` appears only if `computeMin`/`computeMax`/`computeSum`/`computeAvg` are used.
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
- "order": [{ "total": 50, "status": "pending" }]
352
+ "orders": [{ "total": 50, "status": "pending" }]
336
353
  }
337
354
  }
338
355
  ```
339
356
 
340
- `secondaries` is optional. FK fields are auto-filled from the inserted main record.
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": { "order": [{ "total": 75 }] },
352
- "deletions": { "order": [{ "id": 10 }] }
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
- - **Joins are virtual** — separate `SELECT ... WHERE fk IN (...)` queries, not SQL JOINs
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,