fastify-auto-sqlapi 0.0.4 → 0.1.1

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 (127) hide show
  1. package/AGENTS.md +15 -2
  2. package/AGENTS_BACKEND.md +160 -32
  3. package/AGENTS_FRONTEND.md +220 -49
  4. package/README.md +147 -27
  5. package/dist/bin/generate-schema.js +12 -29
  6. package/dist/bin/generate-tables.js +10 -33
  7. package/dist/bin/utils.d.ts +29 -1
  8. package/dist/bin/utils.d.ts.map +1 -1
  9. package/dist/bin/utils.js +71 -2
  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 -11
  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/cli/schema-codegen.d.ts +1 -1
  20. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  21. package/dist/lib/cli/schema-codegen.js +11 -4
  22. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  23. package/dist/lib/cli/tables-codegen.js +4 -3
  24. package/dist/lib/db.d.ts +1 -0
  25. package/dist/lib/db.d.ts.map +1 -1
  26. package/dist/lib/db.js +33 -49
  27. package/dist/lib/dialect.d.ts +17 -0
  28. package/dist/lib/dialect.d.ts.map +1 -1
  29. package/dist/lib/dialect.js +32 -0
  30. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  31. package/dist/lib/engine/bulk/bulk-upsert.js +26 -50
  32. package/dist/lib/engine/rest/delete.d.ts.map +1 -1
  33. package/dist/lib/engine/rest/delete.js +5 -10
  34. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  35. package/dist/lib/engine/rest/get.js +4 -6
  36. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  37. package/dist/lib/engine/rest/insert.js +20 -39
  38. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  39. package/dist/lib/engine/rest/update.js +35 -67
  40. package/dist/lib/engine/search/search.d.ts.map +1 -1
  41. package/dist/lib/engine/search/search.js +650 -130
  42. package/dist/lib/engine/write-helpers.d.ts +39 -2
  43. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  44. package/dist/lib/engine/write-helpers.js +58 -15
  45. package/dist/lib/errors.d.ts +11 -0
  46. package/dist/lib/errors.d.ts.map +1 -0
  47. package/dist/lib/errors.js +12 -0
  48. package/dist/lib/naming.d.ts +11 -2
  49. package/dist/lib/naming.d.ts.map +1 -1
  50. package/dist/lib/naming.js +33 -5
  51. package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
  52. package/dist/lib/schema/bulk-upsert.js +9 -25
  53. package/dist/lib/schema/helpers.d.ts +28 -1
  54. package/dist/lib/schema/helpers.d.ts.map +1 -1
  55. package/dist/lib/schema/helpers.js +38 -0
  56. package/dist/lib/schema/insert.d.ts.map +1 -1
  57. package/dist/lib/schema/insert.js +9 -19
  58. package/dist/lib/schema/search.d.ts.map +1 -1
  59. package/dist/lib/schema/search.js +147 -53
  60. package/dist/lib/schema/update.d.ts.map +1 -1
  61. package/dist/lib/schema/update.js +9 -25
  62. package/dist/lib/sql-api.d.ts +6 -6
  63. package/dist/lib/sql-api.d.ts.map +1 -1
  64. package/dist/lib/sql-api.js +8 -8
  65. package/dist/lib/table-helpers.d.ts +6 -1
  66. package/dist/lib/table-helpers.d.ts.map +1 -1
  67. package/dist/lib/table-helpers.js +51 -3
  68. package/dist/lib/tenant.d.ts +23 -0
  69. package/dist/lib/tenant.d.ts.map +1 -1
  70. package/dist/lib/tenant.js +57 -9
  71. package/dist/routes/auto/bulk-delete.routes.d.ts.map +1 -1
  72. package/dist/routes/auto/bulk-delete.routes.js +17 -23
  73. package/dist/routes/auto/bulk-upsert.routes.d.ts.map +1 -1
  74. package/dist/routes/auto/bulk-upsert.routes.js +16 -22
  75. package/dist/routes/auto/delete.routes.d.ts.map +1 -1
  76. package/dist/routes/auto/delete.routes.js +18 -24
  77. package/dist/routes/auto/get.routes.d.ts.map +1 -1
  78. package/dist/routes/auto/get.routes.js +16 -23
  79. package/dist/routes/auto/insert.routes.d.ts.map +1 -1
  80. package/dist/routes/auto/insert.routes.js +19 -26
  81. package/dist/routes/auto/route-helpers.d.ts +27 -2
  82. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  83. package/dist/routes/auto/route-helpers.js +39 -1
  84. package/dist/routes/auto/search.routes.d.ts.map +1 -1
  85. package/dist/routes/auto/search.routes.js +47 -47
  86. package/dist/routes/auto/update.routes.d.ts.map +1 -1
  87. package/dist/routes/auto/update.routes.js +20 -27
  88. package/dist/types/cli.d.ts +22 -0
  89. package/dist/types/cli.d.ts.map +1 -0
  90. package/dist/types/cli.js +1 -0
  91. package/dist/types/computed.d.ts +29 -0
  92. package/dist/types/computed.d.ts.map +1 -0
  93. package/dist/types/computed.js +1 -0
  94. package/dist/types/conditions.d.ts +16 -0
  95. package/dist/types/conditions.d.ts.map +1 -0
  96. package/dist/types/conditions.js +1 -0
  97. package/dist/types/db.d.ts +22 -0
  98. package/dist/types/db.d.ts.map +1 -0
  99. package/dist/types/db.js +1 -0
  100. package/dist/types/dml.d.ts +64 -0
  101. package/dist/types/dml.d.ts.map +1 -0
  102. package/dist/types/dml.js +1 -0
  103. package/dist/types/join.d.ts +44 -0
  104. package/dist/types/join.d.ts.map +1 -0
  105. package/dist/types/join.js +1 -0
  106. package/dist/types/plugin.d.ts +20 -0
  107. package/dist/types/plugin.d.ts.map +1 -0
  108. package/dist/types/plugin.js +1 -0
  109. package/dist/types/schema.d.ts +10 -0
  110. package/dist/types/schema.d.ts.map +1 -0
  111. package/dist/types/schema.js +1 -0
  112. package/dist/types/search.d.ts +45 -0
  113. package/dist/types/search.d.ts.map +1 -0
  114. package/dist/types/search.js +1 -0
  115. package/dist/types/table.d.ts +52 -0
  116. package/dist/types/table.d.ts.map +1 -0
  117. package/dist/types/table.js +7 -0
  118. package/dist/types/tenant.d.ts +19 -0
  119. package/dist/types/tenant.d.ts.map +1 -0
  120. package/dist/types/tenant.js +1 -0
  121. package/dist/types/validation.d.ts +21 -0
  122. package/dist/types/validation.d.ts.map +1 -0
  123. package/dist/types/validation.js +1 -0
  124. package/dist/types.d.ts +12 -258
  125. package/dist/types.d.ts.map +1 -1
  126. package/dist/types.js +12 -7
  127. package/package.json +8 -5
package/AGENTS.md CHANGED
@@ -8,9 +8,11 @@ The plugin generates REST endpoints (search, get, insert, update, delete, bulk u
8
8
 
9
9
  ## Documentation
10
10
 
11
- - **[AGENTS_BACKEND.md](./AGENTS_BACKEND.md)** — Setup workflow, CLI, schema/table generation, `defineTable()` complete reference, `buildRelation`, `extraFilters` + `extendedCondition`, **SqlApi** (programmatic high-level API for custom routes), ConditionBuilder API, QueryClient API, Swagger, multi-tenant configuration, **validation** (`validate` + `validateBulk`), hooks, key conventions, common backend patterns, FAQ/gotchas, dialect differences.
11
+ - **[BREAKING_CHANGES.md](./BREAKING_CHANGES.md)** — **READ FIRST when migrating.** Maps the old join API (`joinFilters`, `joins`, `joinGroups`) to the new alias-based one (`joinMustExist`, `joinMultiple`, `joinGroup`, `joinLeft`). Covers `buildRelation` new options-object signature (alias defaults to `joinSchema.tableName`, plus `selection` and `unique`), request/response key renames, secondaries key renames, dotted notation rules, and validation/400 errors. Common to backend and frontend.
12
12
 
13
- - **[AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md)** — Generated endpoints overview, search (POST with body filters, conditions/advanced filters using ConditionBuilder methods, query params for ordering/pagination/aggregation), joinFilters (EXISTS-based filtering by related tables), GET, INSERT (with secondaries), UPDATE (with secondaries + deletions), DELETE, bulk upsert, bulk delete, response shapes (PK-only), **validation errors** (structured field-level 400 responses), joins usage, joinGroups/aggregations, pagination, computed values.
13
+ - **[AGENTS_BACKEND.md](./AGENTS_BACKEND.md)** — Setup workflow, CLI, schema/table generation, `defineTable()` complete reference, `buildRelation` (alias + unique), `extraFilters` + `extendedCondition`, **SqlApi** (programmatic high-level API for custom routes), ConditionBuilder API, QueryClient API, Swagger, multi-tenant configuration, **validation** (`validate` + `validateBulk`), hooks, key conventions, common backend patterns, FAQ/gotchas, dialect differences.
14
+
15
+ - **[AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md)** — Generated endpoints overview, search (POST with body filters, conditions/advanced filters using ConditionBuilder methods, query params for ordering/pagination/aggregation), `joinMustExist` (EXISTS-based filtering by related tables), `joinMultiple` (fetch child rows), `joinGroup` (aggregations), `joinLeft` (embed N:1 parent inline), GET, INSERT (with secondaries), UPDATE (with secondaries + deletions), DELETE, bulk upsert, bulk delete, response shapes (PK-only), **validation errors** (structured field-level 400 responses), pagination, computed values.
14
16
 
15
17
  ## Quick Reference
16
18
 
@@ -54,3 +56,14 @@ export const TableCustomer = defineTable({
54
56
  ...exportTableInfo(Schema),
55
57
  });
56
58
  ```
59
+
60
+ ### Joins at a glance
61
+
62
+ | Family | Direction | Cardinality | Output |
63
+ |--------|-----------|-------------|--------|
64
+ | `joinMustExist` | child → main | 1:N | filters main via EXISTS |
65
+ | `joinMultiple` | child → main | 1:N | side query, child rows in `result.joinMultiple.<alias>` |
66
+ | `joinGroup` | child → main | 1:N | aggregations in `result.joinGroup.<alias>` |
67
+ | `joinLeft` | parent → main | N:1 | real LEFT JOIN (on demand), parent rows in `result.joinLeft.<alias>` |
68
+
69
+ Declare relations with `buildRelation(M, mF, J, jF, options?)`. Options are all optional: `alias` defaults to `joinSchema.tableName` (override only when joining the same table twice or to use a friendlier name), `selection` defaults to `'*'`, `unique` defaults to `false`. Set `unique: true` for N:1 (parent) relations to enable `joinLeft`. See [BREAKING_CHANGES.md](./BREAKING_CHANGES.md) for the full migration guide.
package/AGENTS_BACKEND.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  How to set up schemas, tables, and configure the plugin.
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, `buildRelation` signature, and validation rules. Common to backend and frontend.
6
+
5
7
  ## Setup Workflow
6
8
 
7
9
  ### 1. Install
@@ -21,15 +23,16 @@ npm install @fastify/swagger @fastify/swagger-ui
21
23
  Create `sqlapi.config.ts` (or `.js`) in the project root. This config is used **only by the CLI** (`sqlapi-generate-schema` / `sqlapi-generate-tables`), NOT at runtime by the Fastify plugin.
22
24
 
23
25
  ```typescript
24
- // sqlapi.config.ts — interface: { outputDir: string; schema?: string; dialect?: DialectName }
26
+ // sqlapi.config.ts — interface: { outputDir: string; schema?: string; dialect?: DialectName; envFile?: string }
25
27
  export default {
26
28
  outputDir: './src/db', // base directory for generated files (default: './src/db')
27
29
  schema: 'public', // DB schema to introspect (default: 'public', PostgreSQL only)
28
30
  dialect: 'postgres', // 'postgres' | 'mysql' | 'mariadb' (default: 'postgres')
31
+ // envFile: '../../.env', // path to .env file, relative to cwd (default: '.env')
29
32
  };
30
33
  ```
31
34
 
32
- That's it — only these three fields exist. **No connection string, no migration path.** If the file is missing, defaults are used. The `dialect` can also be passed via CLI flag `--dialect mysql`.
35
+ That's it — only these four fields exist. **No connection string, no migration path.** If the file is missing, defaults are used. The `dialect` can also be passed via CLI flag `--dialect mysql`. The `envFile` is useful in monorepo setups where the `.env` lives at the repo root.
33
36
 
34
37
  ### 3. Configure database connection (env vars)
35
38
 
@@ -233,13 +236,20 @@ export const TableCustomer = defineTable({
233
236
  excludeFromCreation: ['id'], // Fields omitted from INSERT — MUST include auto-increment PKs
234
237
  distinctResults: true, // Use SELECT DISTINCT
235
238
 
236
- // JOINS — relations to other tables
237
- allowedReadJoins: [ // Available for search queries (joins, joinFilters, joinGroups)
238
- buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'),
239
- buildRelation(SchemaCustomer, 'id', SchemaAddress, 'customerId', 'id, city, zip'),
239
+ // JOINS — relations to other tables. Alias defaults to joinSchema.tableName —
240
+ // declare it explicitly only when joining the same table twice or when you want
241
+ // a friendlier name. Set `unique: true` for N:1 (parent) relations to enable `joinLeft`.
242
+ allowedReadJoins: [ // Available for search: joinMustExist / joinMultiple / joinGroup / joinLeft
243
+ buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order' (default)
244
+ buildRelation(SchemaCustomer, 'id', SchemaAddress, 'customerId', { selection: 'id, city, zip' }), // alias = 'address'
245
+ // N:1 example (enables joinLeft):
246
+ // buildRelation(SchemaSession, 'userId', SchemaUser, 'id', { unique: true }), // alias = 'user'
247
+ // Alias multipli sulla stessa tabella (richiede alias espliciti):
248
+ // buildRelation(SchemaSession, 'createdBy', SchemaUser, 'id', { alias: 'creator', unique: true }),
249
+ // buildRelation(SchemaSession, 'updatedBy', SchemaUser, 'id', { alias: 'updater', unique: true }),
240
250
  ],
241
- allowedWriteJoins: [ // Available for insert/update secondaries
242
- buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'),
251
+ allowedWriteJoins: [ // Available for insert/update secondaries (alias is the request body key)
252
+ buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order'
243
253
  ],
244
254
 
245
255
  // UPSERT — ON CONFLICT resolution
@@ -270,8 +280,9 @@ export const TableCustomer = defineTable({
270
280
  if (main.name === '') {
271
281
  errors.push(['name', 'required', 'cannot be empty']);
272
282
  }
273
- // Cross-entity: validate secondaries (e.g. date overlap in periods)
274
- if (secondaries?.customer_order) {
283
+ // Cross-entity: validate secondaries (e.g. date overlap in periods).
284
+ // Note: secondaries are keyed by alias (declared in allowedWriteJoins), not by tableName.
285
+ if (secondaries?.orders) {
275
286
  // ... check overlaps, business rules across related records
276
287
  }
277
288
  return errors;
@@ -282,17 +293,17 @@ export const TableCustomer = defineTable({
282
293
  return []; // ValidationError[]
283
294
  },
284
295
 
285
- // HOOKS — side effects (runs after validation)
296
+ // HOOKS — side effects (runs after validation). All receive camelCase (schema field names).
286
297
  beforeInsert: async (db, req, record) => {
287
- // Mutate record before INSERT. record is snake_case.
288
- record.created_by = req.user.id;
298
+ // Mutate record before INSERT. Use camelCase (schema) field names — conversion is automatic.
299
+ record.createdBy = req.user.id;
289
300
  },
290
301
  afterInsert: async (db, req, record, secondaryRecords) => {
291
- // Called after INSERT + secondaries. record is camelCase.
302
+ // Called after INSERT + secondaries. record is camelCase (input merged with generated PK).
292
303
  },
293
304
  beforeUpdate: async (db, req, fields) => {
294
- // Mutate fields before UPDATE. fields is snake_case.
295
- fields.updated_by = req.user.id;
305
+ // Mutate fields before UPDATE. camelCase. PK is present for reference but excluded from UPDATE SET.
306
+ fields.updatedBy = req.user.id;
296
307
  },
297
308
 
298
309
  // AUTH — per-table request hooks
@@ -307,14 +318,42 @@ export const TableCustomer = defineTable({
307
318
  ### buildRelation signature
308
319
 
309
320
  ```typescript
310
- buildRelation(mainSchema, mainField, joinSchema, joinField, selection?)
321
+ buildRelation(mainSchema, mainField, joinSchema, joinField, options?)
322
+
323
+ // where options is fully optional:
324
+ // {
325
+ // alias?: string; // default = joinSchema.tableName. The key used in request/response
326
+ // // payloads, secondaries/deletions, and dotted-notation orderBy/conditions.
327
+ // selection?: string; // default '*'. Comma-separated columns: 'id, name, total'.
328
+ // unique?: boolean; // default false.
329
+ // // false → 1:N (child→main). Allowed in joinMustExist/joinMultiple/joinGroup.
330
+ // // true → N:1 (parent→main). Allowed in joinLeft only.
331
+ // }
311
332
  ```
312
333
 
313
334
  - `mainField`: field(s) in main table (string or string[])
314
- - `joinField`: field in join table that references the main
315
- - `selection`: SQL columns to select (default `'*'`), e.g. `'id, name, total'`
335
+ - `joinField`: field in join table that joins to `mainField`
336
+ - For 1:N (child→main): `joinField` is the FK on the child table → `mainField` is the PK on main
337
+ - For N:1 (parent→main): `joinField` is the PK on the parent table → `mainField` is the FK on main
338
+
339
+ **When to declare an explicit `alias`**: only when (a) you join the same table multiple times in the same `allowedReadJoins`/`allowedWriteJoins`, or (b) you want a friendlier name in the public API surface than the SQL table name (e.g. `'orders'` instead of `'customer_order'`). Otherwise omit it — the default is `joinSchema.tableName`.
316
340
 
317
- The join works as: `SELECT {selection} FROM {joinTable} WHERE {joinField} IN ({mainField values})`
341
+ **Choosing `unique`**: if `joinField` is the PK (or part of the composite PK) of `joinSchema`, the relation is N:1 — you almost certainly want `unique: true` so the alias is usable in `joinLeft`.
342
+
343
+ **Aliasing the same table twice**: declare two `buildRelation` entries with different `alias`. Example: a `session` table referencing `user` for both `createdBy` and `updatedBy`:
344
+
345
+ ```typescript
346
+ allowedReadJoins: [
347
+ buildRelation(SchemaSession, 'createdBy', SchemaUser, 'id', { alias: 'creator', unique: true }),
348
+ buildRelation(SchemaSession, 'updatedBy', SchemaUser, 'id', { alias: 'updater', unique: true }),
349
+ ]
350
+ ```
351
+
352
+ If two entries in the same `allowedReadJoins`/`allowedWriteJoins` resolve to the same alias (explicit or implicit), `defineTable` throws at startup. So you cannot accidentally have two relations sharing an alias — the error tells you to disambiguate explicitly.
353
+
354
+ The 1:N joins (`joinMustExist` / `joinMultiple` / `joinGroup`) work as: `SELECT {selection} FROM {childTable} WHERE {childFK} IN ({mainPK values})`. The N:1 join (`joinLeft`) adds `LEFT JOIN {parentTable} AS {alias} ON {alias}.{parentPK} = {main}.{mainFK}` to the main query, but only when the request actually needs it (filters/orderBy on parent fields). Otherwise a side query `SELECT FROM {parentTable} WHERE {parentPK} IN (distinct main FK values)` is used.
355
+
356
+ **`joinLeft` 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.
318
357
 
319
358
  ### extraFilters + extendedCondition
320
359
 
@@ -350,6 +389,89 @@ export const TableCustomer = defineTable({
350
389
 
351
390
  ---
352
391
 
392
+ ## Computed Fields (extension system)
393
+
394
+ `computedFields` lets you declare **virtual fields** as SQL expressions on a per-table basis. Each computed becomes usable like a regular schema field across the search API: `filters` (equality), `conditions` (operators), `orderBy` (1-parte), `computeMin/Max/Sum/Avg`, and (opt-in) in `selectComputed` for the main response. Same machinery serves JSON column extraction, derived strings, dialect-aware date/calendar bucketing — without growing the library case-by-case.
395
+
396
+ ```typescript
397
+ import { defineTable, exportTableInfo, Type } from 'fastify-auto-sqlapi';
398
+ import { SchemaCustomer as Schema } from '../schemas/SchemaCustomer';
399
+
400
+ export const TableCustomer = defineTable({
401
+ primary: 'id',
402
+ ...exportTableInfo(Schema),
403
+ computedFields: {
404
+ // JSON path extraction — dialect-aware (Postgres -> arrow, MySQL -> JSON_EXTRACT).
405
+ statusFromMeta: ({ db, qiCol }) => ({
406
+ expr: db.dialectName === 'postgres'
407
+ ? `${qiCol('metadata')}->>'status'`
408
+ : `JSON_UNQUOTE(JSON_EXTRACT(${qiCol('metadata')}, '$.status'))`,
409
+ values: [],
410
+ type: Type.String(),
411
+ }),
412
+ // Derived string column — dialect-aware concat.
413
+ fullName: ({ db, qiCol }) => ({
414
+ expr: db.dialectName === 'postgres'
415
+ ? `${qiCol('firstName')} || ' ' || ${qiCol('lastName')}`
416
+ : `CONCAT(${qiCol('firstName')}, ' ', ${qiCol('lastName')})`,
417
+ values: [],
418
+ type: Type.String(),
419
+ }),
420
+ },
421
+ });
422
+ ```
423
+
424
+ Client side, the computed name behaves like any other field:
425
+
426
+ ```json
427
+ {
428
+ "filters": { "statusFromMeta": "active" },
429
+ "conditions": [
430
+ { "field": "fullName", "method": "isLike", "params": ["%Mario%"] }
431
+ ],
432
+ "orderBy": "fullName ASC",
433
+ "selectComputed": ["fullName", "statusFromMeta"]
434
+ }
435
+ ```
436
+
437
+ The values returned by `selectComputed` appear as extra fields on each `main[i]` row.
438
+
439
+ ### `ComputedFieldFn` signature
440
+
441
+ ```typescript
442
+ type ComputedFieldFn = (ctx: {
443
+ db: QueryClient; // dialect-aware (qi, ph, dialectName, dateTrunc, ...)
444
+ qiCol(field: string, opts?: { qualifier?: string }): string;
445
+ }) => {
446
+ expr: string; // SQL fragment
447
+ values: unknown[]; // bound values (see limitations below)
448
+ type: TSchema; // REQUIRED — used by Swagger and body validation
449
+ };
450
+ ```
451
+
452
+ `qiCol(field)` returns a properly-quoted column reference, optionally prefixed by an alias qualifier — the engine passes the alias automatically when the computed is invoked inside a `joinLeft` LEFT JOIN, so the same function works both inline on the main query and as a parent-side column.
453
+
454
+ ### Validation & startup checks
455
+
456
+ `defineTable` throws synchronously if a computed name collides with a schema field or an `extraFilters` key on the same table. The error message tells you which name to change.
457
+
458
+ ### Side queries (`joinMustExist`, `joinMultiple`, `joinGroup`, `joinLeft`)
459
+
460
+ Computed fields are **per-table**: a side query operating on a join target reads `joinTableConf.computedFields`, not the main table's. So if you need `statusFromMeta` on `joinLeft.user.filters`, declare it on `TableUser`, not on `TableSession`.
461
+
462
+ For `joinLeft` specifically, the computed expr is automatically alias-prefixed by `qiCol` (the engine forwards the alias). For the other three families the side query is a separate SELECT, so plain column references suffice.
463
+
464
+ ### Limitations (first round, by design)
465
+
466
+ - **Bound `values` in computed expressions** are supported only on `filters`/`conditions` (main and `joinMustExist`/`joinMultiple`/`joinGroup`). They are rejected with 400 on `selectComputed`, `computeMin/Max/Sum/Avg`, `orderBy`, and `joinLeft.filters`/`conditions` — those paths require placeholder coordination not done in the first round. Most use cases (JSON extraction, concat, dateTrunc, simple ops) need no placeholders.
467
+ - **Computed CAN be used in `joinGroup.aggregations.by`** (just pass the computed name as a string). Bound `values` are not supported in this position (rejected with 400). The existing FK-correlation rule for `orderBy <alias>.<fn>.<field>` still applies: 3-parti aggregation orderBy on a `by` that isn't the correlation FK is rejected — by definition a computed-by produces multiple groups per main row, so this is never valid.
468
+ - **Computed cannot be used as aggregation function** (`sum`/`min`/`max`/...). The values inside `aggregations.sum: ['<name>']` must be schema field names. To aggregate on a derived expression, declare the derivation as a computed and pass the computed name as the field, BUT only via `computeMin/Max/Sum/Avg` (top-level main aggregates), not the joinGroup ones.
469
+ - **No chained computed** (a computed referencing another computed). Flat-only.
470
+ - **Read-only**. Computed fields are not usable in insert/update bodies — the consumer's `expr` is for SELECT/WHERE/ORDER BY, never for writes.
471
+ - **Same expression evaluated multiple times** when used in WHERE + ORDER BY + SELECT. Cheap exprs are fine; for expensive ones, query planner CSE often helps.
472
+
473
+ ---
474
+
353
475
  ## ConditionBuilder API
354
476
 
355
477
  Used in `extendedCondition` callbacks, hooks, and exposed via the `conditions` field in the search API (see [AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md#conditions-advanced-filters)).
@@ -400,7 +522,7 @@ app.get('/billing/:customerId', async (request) => {
400
522
  const customerId = Number(request.params.customerId);
401
523
  return app.sqlApi.search('subscription', {
402
524
  filters: { customerId, status: 'active' },
403
- joinFilters: { customer_order: { status: 'pending' } },
525
+ joinMustExist: { orders: { filters: { status: 'pending' } } },
404
526
  paginator: { page: 1, itemsPerPage: 50 },
405
527
  }, request);
406
528
  });
@@ -430,8 +552,13 @@ const sqlApi = createSqlApi(db, dbTables, { dialect: 'mysql' });
430
552
 
431
553
  ```typescript
432
554
  // Search — full filter, join, pagination, aggregation support
555
+ // orderBy supports two dotted notations:
556
+ // - 3-parti `<alias>.<fn>.<field>` for joinGroup aggregations (must be declared in the request body)
557
+ // - 2-parti `<alias>.<field>` for joinLeft parent fields
558
+ // See AGENTS_FRONTEND.md and BREAKING_CHANGES.md for the full reference.
433
559
  sqlApi.search(tableName, {
434
- filters?, joinFilters?, joins?, joinGroups?,
560
+ filters?, conditions?,
561
+ joinMustExist?, joinMultiple?, joinGroup?, joinLeft?,
435
562
  orderBy?, paginator?, computeMin?, computeMax?, computeSum?, computeAvg?,
436
563
  }, request?): Promise<SearchResult>
437
564
 
@@ -590,14 +717,14 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
590
717
 
591
718
  ## Key Conventions
592
719
 
593
- - **camelCase in API, snake_case in DB**: all request/response fields are camelCase. The plugin converts automatically via `col()`.
720
+ - **camelCase in API, any case in DB**: all request/response fields are camelCase. The plugin converts via `col()` and `colMap`. For snake_case DB columns (default), conversion is automatic. For camelCase DB columns (e.g. betterauth), the CLI generates a `colMap` that preserves the original column names — no conversion needed. Manual schemas without `colMap` fall back to `toUnderscore()`.
594
721
  - **All fields Optional in response**: `RETURNING *` may return any subset. Response schemas use `Type.Partial`.
595
722
  - **`excludeFromCreation`**: **IMPORTANT** — auto-increment PKs (e.g. `id` serial/auto_increment) MUST be listed here, otherwise INSERT will try to send them and fail. The CLI auto-detects this and adds it by default. Also useful for `createdAt`/`updatedAt` columns managed by DB defaults or hooks.
596
723
  - **`upsertMap`**: when present for a schema, INSERT becomes upsert. PostgreSQL: `ON CONFLICT (...) DO UPDATE`. MySQL/MariaDB: `ON DUPLICATE KEY UPDATE`. Applies to both main and secondary tables.
597
724
  - **`schemaOverrides`**: override auto-generated schema fields with stricter TypeBox types (e.g. `{ email: Type.String({ format: 'email' }) }`). Overrides are merged into the body schema for insert, update, and bulk-upsert. The original Schema file is never modified. In updates, overridden fields are still wrapped in Optional (validates only when present). Overrides appear in Swagger.
598
725
  - **Validation receives camelCase**: `validate` receives the original camelCase record (as sent by the client) and secondaries. Field names match the schema definition, with full TypeScript inference (`main.startDate`, not `main.start_date`). It returns `ValidationError[]` — tuples of `[field, code]` or `[field, code, message]`. If any errors are returned, the request is rejected with 400 before hooks or SQL execute.
599
726
  - **`validateBulk` replaces `validate` in bulk**: when `validateBulk` is defined, it is called once with all items and per-item `validate` is skipped. This allows optimized batch queries instead of N individual checks. When only `validate` is defined, it runs per-item as fallback.
600
- - **Hooks receive snake_case, validate receives camelCase**: `validate(db, req, main, secondaries)` gets camelCase (pre-conversion). `beforeInsert(db, req, record)` and `beforeUpdate(db, req, fields)` get snake_case (post-conversion). `afterInsert` receives camelCase (post-DB).
727
+ - **All hooks and validators receive camelCase records**: `validate`, `beforeInsert`, `beforeUpdate`, and `afterInsert` all get records keyed by schema field names (camelCase). Mutations propagate to the SQL (plugin converts to DB column format via `colMap` after the hook). The engine internally uses `snakecaseRecord(..., schema)` after user mutations to map field names to actual DB columns.
601
728
  - **Filters validation**: TypeBox schemas use `additionalProperties: false`. By default Fastify strips unknown fields silently. For 400 errors on unknown filters: `Fastify({ ajv: { customOptions: { removeAdditional: false } } })`.
602
729
  - **Tenant filtering**: when `tenantScope` is set on a table and `getTenantId` is provided in plugin options, all CRUD operations are automatically scoped to the tenant. `getTenantId` returning `null`/`undefined` = admin (no filter). Returning an array = multi-tenant user (IN clause).
603
730
 
@@ -637,7 +764,7 @@ const TableSession = defineTable({
637
764
  primary: 'id',
638
765
  ...exportTableInfo(SchemaSession),
639
766
  allowedWriteJoins: [
640
- buildRelation(SchemaSession, 'id', SchemaPeriod, 'sessionId'),
767
+ buildRelation(SchemaSession, 'id', SchemaPeriod, 'sessionId', { alias: 'periods' }),
641
768
  ],
642
769
  validate: async (db, req, main, secondaries) => {
643
770
  // ValidationError is a tuple: [field, code] or [field, code, message]
@@ -657,13 +784,13 @@ const TableSession = defineTable({
657
784
  }
658
785
  }
659
786
 
660
- // Cross-entity: validate periods don't overlap
661
- const periods = secondaries?.session_period;
787
+ // Cross-entity: validate periods don't overlap (secondaries keyed by alias)
788
+ const periods = secondaries?.periods;
662
789
  if (periods?.length) {
663
790
  for (let i = 0; i < periods.length; i++) {
664
791
  for (let j = i + 1; j < periods.length; j++) {
665
792
  if (periods[i].startDate < periods[j].endDate && periods[j].startDate < periods[i].endDate) {
666
- errors.push([`session_period[${j}].startDate`, 'overlap', 'overlaps with another period']);
793
+ errors.push([`periods[${j}].startDate`, 'overlap', 'overlaps with another period']);
667
794
  }
668
795
  }
669
796
  }
@@ -699,12 +826,13 @@ const TableCustomer = defineTable({
699
826
  primary: 'id',
700
827
  ...exportTableInfo(SchemaCustomer),
701
828
  beforeInsert: async (db, req, record) => {
702
- record.created_by = req.user.id;
703
- record.created_at = db.expression('NOW()');
829
+ // camelCase — schema field names. The plugin converts to the actual DB column names.
830
+ record.createdBy = req.user.id;
831
+ record.createdAt = db.expression('NOW()');
704
832
  },
705
833
  beforeUpdate: async (db, req, fields) => {
706
- fields.updated_by = req.user.id;
707
- fields.updated_at = db.expression('NOW()');
834
+ fields.updatedBy = req.user.id;
835
+ fields.updatedAt = db.expression('NOW()');
708
836
  },
709
837
  });
710
838
  ```