fastify-auto-sqlapi 0.0.3 → 0.0.5

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 +2 -2
  2. package/AGENTS_BACKEND.md +111 -15
  3. package/AGENTS_FRONTEND.md +126 -1
  4. package/README.md +135 -18
  5. package/dist/bin/generate-schema.js +3 -3
  6. package/dist/bin/generate-tables.js +1 -1
  7. package/dist/bin/utils.d.ts +1 -1
  8. package/dist/bin/utils.d.ts.map +1 -1
  9. package/dist/bin/utils.js +2 -2
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  13. package/dist/lib/cli/mysql-introspect.js +9 -2
  14. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  15. package/dist/lib/cli/pg-introspect.js +8 -1
  16. package/dist/lib/cli/schema-codegen.d.ts +1 -1
  17. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  18. package/dist/lib/cli/schema-codegen.js +11 -4
  19. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  20. package/dist/lib/cli/tables-codegen.js +30 -1
  21. package/dist/lib/db.d.ts.map +1 -1
  22. package/dist/lib/db.js +10 -4
  23. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  24. package/dist/lib/engine/bulk/bulk-upsert.js +41 -27
  25. package/dist/lib/engine/rest/get.js +1 -1
  26. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  27. package/dist/lib/engine/rest/insert.js +25 -21
  28. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  29. package/dist/lib/engine/rest/update.js +42 -38
  30. package/dist/lib/engine/search/search.d.ts.map +1 -1
  31. package/dist/lib/engine/search/search.js +235 -39
  32. package/dist/lib/engine/validate.d.ts +11 -0
  33. package/dist/lib/engine/validate.d.ts.map +1 -0
  34. package/dist/lib/engine/validate.js +29 -0
  35. package/dist/lib/engine/write-helpers.js +4 -4
  36. package/dist/lib/naming.d.ts +11 -2
  37. package/dist/lib/naming.d.ts.map +1 -1
  38. package/dist/lib/naming.js +33 -5
  39. package/dist/lib/schema/bulk-upsert.js +2 -2
  40. package/dist/lib/schema/helpers.d.ts +5 -0
  41. package/dist/lib/schema/helpers.d.ts.map +1 -1
  42. package/dist/lib/schema/helpers.js +15 -0
  43. package/dist/lib/schema/insert.js +2 -2
  44. package/dist/lib/schema/search.d.ts.map +1 -1
  45. package/dist/lib/schema/search.js +16 -10
  46. package/dist/lib/schema/update.d.ts.map +1 -1
  47. package/dist/lib/schema/update.js +4 -3
  48. package/dist/lib/table-helpers.d.ts.map +1 -1
  49. package/dist/lib/table-helpers.js +9 -1
  50. package/dist/routes/auto/plugin.d.ts.map +1 -1
  51. package/dist/routes/auto/plugin.js +12 -1
  52. package/dist/types.d.ts +47 -8
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +8 -8
package/AGENTS.md CHANGED
@@ -8,9 +8,9 @@ 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, hooks, key conventions, common backend patterns, FAQ/gotchas, dialect differences.
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.
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), joins usage, joinGroups/aggregations, pagination, computed values.
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.
14
14
 
15
15
  ## Quick Reference
16
16
 
package/AGENTS_BACKEND.md CHANGED
@@ -21,15 +21,16 @@ npm install @fastify/swagger @fastify/swagger-ui
21
21
  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
22
 
23
23
  ```typescript
24
- // sqlapi.config.ts — interface: { outputDir: string; schema?: string; dialect?: DialectName }
24
+ // sqlapi.config.ts — interface: { outputDir: string; schema?: string; dialect?: DialectName; envFile?: string }
25
25
  export default {
26
26
  outputDir: './src/db', // base directory for generated files (default: './src/db')
27
27
  schema: 'public', // DB schema to introspect (default: 'public', PostgreSQL only)
28
28
  dialect: 'postgres', // 'postgres' | 'mysql' | 'mariadb' (default: 'postgres')
29
+ // envFile: '../../.env', // path to .env file, relative to cwd (default: '.env')
29
30
  };
30
31
  ```
31
32
 
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`.
33
+ 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
34
 
34
35
  ### 3. Configure database connection (env vars)
35
36
 
@@ -256,17 +257,43 @@ export const TableCustomer = defineTable({
256
257
  // through: { schema: SchemaCustomer, localField: 'customerId', foreignField: 'id' },
257
258
  // },
258
259
 
259
- // HOOKS
260
+ // SCHEMA OVERRIDES — tighten auto-generated schema validation without editing Schema files
261
+ schemaOverrides: {
262
+ email: Type.String({ format: 'email' }),
263
+ name: Type.String({ minLength: 1, maxLength: 100 }),
264
+ },
265
+
266
+ // VALIDATION — structured field-level validation (runs after schema, before hooks)
267
+ validate: async (db, req, main, secondaries) => {
268
+ // Return ValidationError[] — tuple: [field, code] or [field, code, message]
269
+ // message defaults to code if omitted
270
+ const errors = [];
271
+ if (main.name === '') {
272
+ errors.push(['name', 'required', 'cannot be empty']);
273
+ }
274
+ // Cross-entity: validate secondaries (e.g. date overlap in periods)
275
+ if (secondaries?.customer_order) {
276
+ // ... check overlaps, business rules across related records
277
+ }
278
+ return errors;
279
+ },
280
+ validateBulk: async (db, req, items) => {
281
+ // Called once with ALL items in bulk-upsert. Use for cross-item validation.
282
+ // items: Array<{ main, secondaries? }>
283
+ return []; // ValidationError[]
284
+ },
285
+
286
+ // HOOKS — side effects (runs after validation). All receive camelCase (schema field names).
260
287
  beforeInsert: async (db, req, record) => {
261
- // Mutate record before INSERT. record is snake_case.
262
- record.created_by = req.user.id;
288
+ // Mutate record before INSERT. Use camelCase (schema) field names — conversion is automatic.
289
+ record.createdBy = req.user.id;
263
290
  },
264
291
  afterInsert: async (db, req, record, secondaryRecords) => {
265
- // Called after INSERT + secondaries. record is camelCase.
292
+ // Called after INSERT + secondaries. record is camelCase (input merged with generated PK).
266
293
  },
267
294
  beforeUpdate: async (db, req, fields) => {
268
- // Mutate fields before UPDATE. fields is snake_case.
269
- fields.updated_by = req.user.id;
295
+ // Mutate fields before UPDATE. camelCase. PK is present for reference but excluded from UPDATE SET.
296
+ fields.updatedBy = req.user.id;
270
297
  },
271
298
 
272
299
  // AUTH — per-table request hooks
@@ -404,6 +431,8 @@ const sqlApi = createSqlApi(db, dbTables, { dialect: 'mysql' });
404
431
 
405
432
  ```typescript
406
433
  // Search — full filter, join, pagination, aggregation support
434
+ // orderBy supports dot notation for joinGroup aggregations: "session.sum.duration DESC"
435
+ // (requires the joinGroup to be declared in the same request, see AGENTS_FRONTEND.md)
407
436
  sqlApi.search(tableName, {
408
437
  filters?, joinFilters?, joins?, joinGroups?,
409
438
  orderBy?, paginator?, computeMin?, computeMax?, computeSum?, computeAvg?,
@@ -564,11 +593,14 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
564
593
 
565
594
  ## Key Conventions
566
595
 
567
- - **camelCase in API, snake_case in DB**: all request/response fields are camelCase. The plugin converts automatically via `col()`.
596
+ - **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()`.
568
597
  - **All fields Optional in response**: `RETURNING *` may return any subset. Response schemas use `Type.Partial`.
569
598
  - **`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.
570
599
  - **`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.
571
- - **Hooks receive snake_case**: `beforeInsert` and `beforeUpdate` receive snake_case records (pre-DB). `afterInsert` receives camelCase (post-DB).
600
+ - **`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.
601
+ - **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.
602
+ - **`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.
603
+ - **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.
572
604
  - **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 } } })`.
573
605
  - **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).
574
606
 
@@ -599,6 +631,70 @@ const TableAdmin = defineTable({
599
631
  });
600
632
  ```
601
633
 
634
+ ### Custom validation (field-level, cross-entity)
635
+
636
+ ```typescript
637
+ import type { ValidationError } from 'fastify-auto-sqlapi';
638
+
639
+ const TableSession = defineTable({
640
+ primary: 'id',
641
+ ...exportTableInfo(SchemaSession),
642
+ allowedWriteJoins: [
643
+ buildRelation(SchemaSession, 'id', SchemaPeriod, 'sessionId'),
644
+ ],
645
+ validate: async (db, req, main, secondaries) => {
646
+ // ValidationError is a tuple: [field, code] or [field, code, message]
647
+ // message defaults to code if omitted
648
+ const errors: ValidationError[] = [];
649
+
650
+ // Simple field validation
651
+ if (!main.name) {
652
+ errors.push(['name', 'required']); // message defaults to 'required'
653
+ }
654
+
655
+ // Async validation (uniqueness check)
656
+ if (main.code) {
657
+ const existing = await db.query('SELECT 1 FROM session WHERE code = $1 AND id != $2', [main.code, main.id ?? 0]);
658
+ if (existing.rows.length) {
659
+ errors.push(['code', 'unique', 'already exists']);
660
+ }
661
+ }
662
+
663
+ // Cross-entity: validate periods don't overlap
664
+ const periods = secondaries?.session_period;
665
+ if (periods?.length) {
666
+ for (let i = 0; i < periods.length; i++) {
667
+ for (let j = i + 1; j < periods.length; j++) {
668
+ if (periods[i].startDate < periods[j].endDate && periods[j].startDate < periods[i].endDate) {
669
+ errors.push([`session_period[${j}].startDate`, 'overlap', 'overlaps with another period']);
670
+ }
671
+ }
672
+ }
673
+ }
674
+
675
+ return errors;
676
+ },
677
+ });
678
+ ```
679
+
680
+ `validate` runs on insert, update, and each item in bulk-upsert (when `validateBulk` is not defined). When `validateBulk` is defined, it **replaces** per-item `validate` in bulk operations — use it for optimized batch queries and cross-item validation:
681
+
682
+ ```typescript
683
+ const TableSession = defineTable({
684
+ // ...
685
+ validateBulk: async (db, req, items) => {
686
+ // items: Array<{ main, secondaries? }>
687
+ // Check for duplicate codes across all items in the batch
688
+ const codes = items.map(i => i.main.code).filter(Boolean);
689
+ const unique = new Set(codes);
690
+ if (unique.size !== codes.length) {
691
+ return [['code', 'batch_unique', 'duplicate codes in batch']];
692
+ }
693
+ return [];
694
+ },
695
+ });
696
+ ```
697
+
602
698
  ### Auto-fill audit fields
603
699
 
604
700
  ```typescript
@@ -606,12 +702,13 @@ const TableCustomer = defineTable({
606
702
  primary: 'id',
607
703
  ...exportTableInfo(SchemaCustomer),
608
704
  beforeInsert: async (db, req, record) => {
609
- record.created_by = req.user.id;
610
- record.created_at = db.expression('NOW()');
705
+ // camelCase — schema field names. The plugin converts to the actual DB column names.
706
+ record.createdBy = req.user.id;
707
+ record.createdAt = db.expression('NOW()');
611
708
  },
612
709
  beforeUpdate: async (db, req, fields) => {
613
- fields.updated_by = req.user.id;
614
- fields.updated_at = db.expression('NOW()');
710
+ fields.updatedBy = req.user.id;
711
+ fields.updatedAt = db.expression('NOW()');
615
712
  },
616
713
  });
617
714
  ```
@@ -738,4 +835,3 @@ The plugin internally strips `prefix` before passing options to sub-route plugin
738
835
  | **CLI env vars** | `POSTGRES_*` | `MYSQL_*` | `MYSQL_*` |
739
836
  | **CLI introspection** | `pg` | `mysql2` | `mysql2` |
740
837
 
741
- MySQL does not support `RETURNING`, so insert/upsert responses return only the PK (from record or `insertId`), not the full row.
@@ -107,6 +107,40 @@ Also available: `isNotGreater`, `isNotGreaterOrEqual`, `isNotLess`, `isNotLessOr
107
107
  - **Field validation** — field names are validated against the table schema (400 if unknown)
108
108
  - **Method validation** — only whitelisted methods are allowed (`raw`, `append` etc. are blocked)
109
109
 
110
+ #### Conditions on joinGroup aggregations (HAVING-style)
111
+
112
+ You can filter main rows based on the value of a joinGroup aggregation using the same dot notation as `orderBy`: `<joinTable>.<fn>.<field>`.
113
+
114
+ Example: "users with at least 4 sessions":
115
+
116
+ ```json
117
+ {
118
+ "conditions": [
119
+ { "field": "session.count.id", "method": "isGreaterOrEqual", "params": [4] }
120
+ ],
121
+ "joinGroups": {
122
+ "session": { "aggregations": { "count": ["id"] } }
123
+ }
124
+ }
125
+ ```
126
+
127
+ Generated SQL:
128
+ ```sql
129
+ SELECT * FROM "user"
130
+ WHERE COALESCE((
131
+ SELECT COUNT("session"."id")
132
+ FROM "session"
133
+ WHERE "session"."user_id" = "user"."id"
134
+ ), 0) >= $1
135
+ ```
136
+
137
+ **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' }`.
140
+ - All ConditionBuilder methods work: `isEqual`, `isGreater`, `isLess`, `isBetween`, `isIn`, `isNull`, `isNotNull`, etc.
141
+ - 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.
143
+
110
144
  ### Join Filters (filter main by related table)
111
145
 
112
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.
@@ -151,6 +185,8 @@ The join executes as a separate query: `SELECT ... FROM {joinTable} WHERE {fk} I
151
185
  "sum": ["total"],
152
186
  "min": ["total"],
153
187
  "max": ["total"],
188
+ "avg": ["total"],
189
+ "count": ["id"],
154
190
  "distinctCount": ["status"]
155
191
  },
156
192
  "filters": { "status": "active" }
@@ -160,7 +196,8 @@ The join executes as a separate query: `SELECT ... FROM {joinTable} WHERE {fk} I
160
196
  ```
161
197
 
162
198
  - `by` — GROUP BY field
163
- - `sum`, `min`, `max` — aggregate functions on specified columns
199
+ - `sum`, `min`, `max`, `avg` — aggregate functions on specified columns
200
+ - `count` — COUNT(col)
164
201
  - `distinctCount` — COUNT(DISTINCT col)
165
202
  - `filters` — optional, narrow rows before aggregation
166
203
 
@@ -176,6 +213,46 @@ Use the `orderBy` query parameter:
176
213
 
177
214
  If not specified, the table's `defaultOrder` is used.
178
215
 
216
+ #### Ordering by joinGroup aggregations
217
+
218
+ You can order the main results by an aggregation computed on a joined table using dot notation: `<joinTable>.<fn>.<field>`.
219
+
220
+ Supported functions: `sum`, `min`, `max`, `avg`, `count`, `distinctCount`.
221
+
222
+ Example: get users ordered by total session duration DESC.
223
+
224
+ ```
225
+ POST /auto/search/user?orderBy=session.sum.duration%20DESC
226
+ ```
227
+ ```json
228
+ {
229
+ "joinGroups": {
230
+ "session": {
231
+ "aggregations": { "sum": ["duration"] }
232
+ }
233
+ }
234
+ }
235
+ ```
236
+
237
+ Generated SQL (simplified):
238
+ ```sql
239
+ SELECT * FROM "user"
240
+ ORDER BY (
241
+ SELECT SUM("session"."duration")
242
+ FROM "session"
243
+ WHERE "session"."user_id" = "user"."id"
244
+ ) DESC
245
+ ```
246
+
247
+ **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).
251
+ - **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.
255
+
179
256
  ### Pagination
180
257
 
181
258
  Add `page` or `itemsPerPage` to the query string to enable pagination:
@@ -359,6 +436,54 @@ Each object must contain the PK field. All deletions execute as a single `DELETE
359
436
 
360
437
  ---
361
438
 
439
+ ## Validation Errors
440
+
441
+ Insert, update, and bulk-upsert endpoints may return **structured field-level validation errors** (400). Two sources:
442
+
443
+ 1. **Schema validation** (TypeBox/Ajv) — type mismatches, missing required fields
444
+ 2. **Custom validation** (`validate` / `validateBulk` on the table) — business rules, cross-entity checks
445
+
446
+ Both return the same response format:
447
+
448
+ ```json
449
+ {
450
+ "statusCode": 400,
451
+ "error": "Bad Request",
452
+ "message": "Validation failed",
453
+ "fields": [
454
+ { "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" }
456
+ ]
457
+ }
458
+ ```
459
+
460
+ 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)
462
+ - `code` — machine-readable error code (e.g. `required`, `type`, `overlap`, `unique`)
463
+ - `message` — human-readable description
464
+
465
+ ### Handling validation errors in the frontend
466
+
467
+ ```typescript
468
+ const response = await fetch('/api/rest/session', {
469
+ method: 'POST',
470
+ headers: { 'Content-Type': 'application/json' },
471
+ body: JSON.stringify({ main: { name: '' }, secondaries: { session_period: periods } }),
472
+ });
473
+
474
+ if (!response.ok) {
475
+ const error = await response.json();
476
+ if (error.fields) {
477
+ // Field-level errors — display inline on the form
478
+ for (const { path, message } of error.fields) {
479
+ setFieldError(path, message);
480
+ }
481
+ }
482
+ }
483
+ ```
484
+
485
+ ---
486
+
362
487
  ## Response Conventions
363
488
 
364
489
  - **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.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # fastify-auto-sqlapi
2
2
 
3
- Automatic CRUD API generation from PostgreSQL tables for [Fastify](https://fastify.dev/). No ORM, no magic — just raw SQL via `pg`, with TypeBox validation and Swagger docs out of the box.
3
+ Automatic CRUD API generation from PostgreSQL, MySQL, and MariaDB tables for [Fastify](https://fastify.dev/). No ORM, no magic — just raw SQL, with TypeBox validation and Swagger docs out of the box.
4
4
 
5
- Point it at your database, and get a full REST API with search, pagination, joins, bulk operations, multi-tenant isolation, and hooks.
5
+ Point it at your database, and get a full REST API with search, advanced conditions, pagination, joins, aggregations, bulk operations, multi-tenant isolation, validation, and hooks.
6
6
 
7
7
  ## Features
8
8
 
@@ -12,6 +12,7 @@ Point it at your database, and get a full REST API with search, pagination, join
12
12
  - **Virtual joins** — fetch related records without complex SQL
13
13
  - **Bulk operations** — batch insert/upsert/delete in single queries
14
14
  - **Multi-tenant** — automatic row-level isolation, zero code in handlers
15
+ - **Validation** — structured field-level validation with cross-entity support
15
16
  - **Hooks** — `beforeInsert`, `beforeUpdate`, `afterInsert` for custom logic
16
17
  - **Swagger UI** — optional, auto-configured from your schemas
17
18
  - **Composable** — register all routes or pick only what you need
@@ -155,13 +156,30 @@ const TableCustomer = defineTable({
155
156
  buildUpsertRule(SchemaCustomer, ['id']),
156
157
  ),
157
158
 
159
+ // Schema overrides (tighten generated schema without editing Schema files)
160
+ schemaOverrides: {
161
+ email: Type.String({ format: 'email' }),
162
+ },
163
+
158
164
  // Multi-tenant isolation
159
165
  tenantScope: { column: 'organization_id' },
160
166
 
161
- // Hooks
162
- beforeInsert: async (db, req, record) => { /* record is snake_case */ },
163
- beforeUpdate: async (db, req, fields) => { /* fields is snake_case */ },
164
- afterInsert: async (db, req, record, secondaryRecords) => { /* record is camelCase */ },
167
+ // Validation (runs after schema validation, before hooks)
168
+ validate: async (db, req, main, secondaries) => {
169
+ // Return ValidationError[] — tuple: [field, code] or [field, code, message]
170
+ // message defaults to code if omitted
171
+ if (!main.name) return [['name', 'required']];
172
+ return [];
173
+ },
174
+ validateBulk: async (db, req, items) => {
175
+ // Bulk-upsert only. Called once with all items for cross-item validation.
176
+ return [];
177
+ },
178
+
179
+ // Hooks (runs after validation) — all receive camelCase records (schema field names)
180
+ beforeInsert: async (db, req, record) => { /* camelCase; mutations propagate to INSERT */ },
181
+ beforeUpdate: async (db, req, fields) => { /* camelCase; PK included for reference, excluded from UPDATE SET */ },
182
+ afterInsert: async (db, req, record, secondaryRecords) => { /* camelCase; input merged with generated PK */ },
165
183
 
166
184
  // Auth (per-table)
167
185
  onRequests: [
@@ -201,37 +219,80 @@ const TableCustomer = defineTable({
201
219
  ```typescript
202
220
  await app.register(fastifyAutoSqlApi, {
203
221
  DbTables: dbTables, // Required — your table definitions
222
+ dialect: 'postgres', // Optional — 'postgres' | 'mysql' | 'mariadb' (default: 'postgres')
204
223
  prefix: '/api', // Optional — URL prefix for all routes
205
224
  swagger: true, // Optional — enable Swagger UI (or pass SwaggerOptions)
206
225
  onRequests: [authMiddleware], // Optional — global hooks applied to every route
207
226
  getTenantId: (req) => id, // Optional — multi-tenant function
227
+ debug: true, // Optional — log all SQL queries and params
208
228
  });
209
229
  ```
210
230
 
211
231
  | Option | Type | Required | Description |
212
232
  |--------|------|----------|-------------|
213
233
  | `DbTables` | `Record<string, ITable>` | Yes | Table configurations |
234
+ | `dialect` | `'postgres' \| 'mysql' \| 'mariadb'` | No | DB dialect (default: `'postgres'`) |
214
235
  | `prefix` | `string` | No | URL prefix (e.g. `/api`) |
215
236
  | `swagger` | `boolean \| SwaggerOptions` | No | Enable Swagger UI |
216
237
  | `onRequests` | `Function[]` | No | Global auth/middleware hooks |
217
238
  | `getTenantId` | `(req) => id \| null` | No | Tenant resolver for multi-tenant |
239
+ | `debug` | `boolean` | No | Log all SQL queries to console |
240
+
241
+ For MySQL/MariaDB, register `mysql2/promise` pool instead of `@fastify/postgres` and pass `dialect: 'mysql'` or `'mariadb'`.
218
242
 
219
243
  ## API Reference
220
244
 
221
245
  ### POST /search/{table}
222
246
 
223
- Search with filters, pagination, ordering, joins, and aggregations.
247
+ Search with filters, advanced conditions, pagination, ordering, joins, and aggregations.
224
248
 
225
249
  **Request body** (all fields optional):
226
250
 
227
251
  ```json
228
252
  {
229
253
  "filters": { "name": "Mario" },
230
- "joins": { "order": {} },
231
- "paginator": { "page": 1, "itemsPerPage": 20 }
254
+ "conditions": [
255
+ { "field": "total", "method": "isGreater", "params": [100] },
256
+ { "field": "createdAt", "method": "isBetween", "params": ["2024-01-01", "2024-12-31"] }
257
+ ],
258
+ "joinFilters": {
259
+ "order": {
260
+ "filters": { "status": "completed" },
261
+ "conditions": [{ "field": "total", "method": "isGreater", "params": [50] }]
262
+ }
263
+ },
264
+ "joins": {
265
+ "order": {
266
+ "filters": { "status": "completed" },
267
+ "conditions": [{ "field": "total", "method": "isGreater", "params": [50] }]
268
+ }
269
+ },
270
+ "joinGroups": {
271
+ "order": {
272
+ "aggregations": {
273
+ "by": "status",
274
+ "sum": ["total"],
275
+ "min": ["total"],
276
+ "max": ["total"],
277
+ "avg": ["total"],
278
+ "count": ["id"],
279
+ "distinctCount": ["status"]
280
+ },
281
+ "filters": { "status": "completed" },
282
+ "conditions": [{ "field": "total", "method": "isGreater", "params": [0] }]
283
+ }
284
+ }
232
285
  }
233
286
  ```
234
287
 
288
+ - **`filters`** — equality-based, flat key/value. Supports schema fields + `extraFilters`.
289
+ - **`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.
293
+
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.
295
+
235
296
  **Querystring** (optional): `orderBy`, `page`, `itemsPerPage`, `computeMin`, `computeMax`, `computeSum`, `computeAvg`
236
297
 
237
298
  **Response:**
@@ -241,11 +302,23 @@ Search with filters, pagination, ordering, joins, and aggregations.
241
302
  "table": "customer",
242
303
  "main": [{ "id": 1, "name": "Mario", "email": "m@test.it" }],
243
304
  "joins": { "order": [{ "id": 10, "customerId": 1, "total": 50 }] },
244
- "pagination": { "total": 25, "pages": 3, "paginator": { "page": 1, "itemsPerPage": 20 } }
305
+ "joinGroups": {
306
+ "order": {
307
+ "sum": { "total": 300 },
308
+ "count": { "id": 2 },
309
+ "rows": [{ "by": "completed", "sum_total": 300, "count_id": 2 }]
310
+ }
311
+ },
312
+ "pagination": {
313
+ "total": 25,
314
+ "pages": 3,
315
+ "computed": { "min": { "id": 1 }, "max": { "id": 100 } },
316
+ "paginator": { "page": 1, "itemsPerPage": 20 }
317
+ }
245
318
  }
246
319
  ```
247
320
 
248
- `joins`, `joinGroups`, and `pagination` only appear when requested. A simple `{}` body returns `{ table, main }`.
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.
249
322
 
250
323
  ### GET /rest/{table}/:id
251
324
 
@@ -294,6 +367,24 @@ Returns the deleted record or 404.
294
367
 
295
368
  **Body:** Array of objects with the PK field, e.g. `[{ "id": 1 }, { "id": 2 }]`. Executes as a single `DELETE WHERE pk IN (...)`.
296
369
 
370
+ ### Validation errors (400)
371
+
372
+ Both schema-level (TypeBox/Ajv) and custom (`validate` / `validateBulk`) errors use the same response shape:
373
+
374
+ ```json
375
+ {
376
+ "statusCode": 400,
377
+ "error": "Bad Request",
378
+ "message": "Validation failed",
379
+ "fields": [
380
+ { "path": "main.email", "code": "format", "message": "must match format \"email\"" },
381
+ { "path": "name", "code": "required", "message": "is required" }
382
+ ]
383
+ }
384
+ ```
385
+
386
+ Custom validators return tuples `[field, code]` or `[field, code, message]` — `message` defaults to `code` if omitted. `validateBulk` replaces per-item `validate` in bulk-upsert requests.
387
+
297
388
  ## Multi-Tenant
298
389
 
299
390
  Automatic row-level isolation on all CRUD operations. Configure once, no code in handlers.
@@ -387,9 +478,9 @@ await app.register(async (instance) => {
387
478
 
388
479
  ## Conventions
389
480
 
390
- - **camelCase in API, snake_case in DB** — the plugin converts automatically
391
- - **Joins are virtual** — they execute separate `SELECT ... WHERE fk IN (...)` queries, not SQL JOINs
392
- - **Hooks receive snake_case** — `beforeInsert` and `beforeUpdate` get snake_case records; `afterInsert` gets camelCase
481
+ - **camelCase everywhere in the API** — requests, responses, `validate`, and all hooks (`beforeInsert`, `beforeUpdate`, `afterInsert`) use schema field names
482
+ - **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
393
484
  - **All response fields are Optional** — response schemas use `Type.Partial` since `RETURNING *` may return any subset
394
485
 
395
486
  ## Re-exports
@@ -399,23 +490,49 @@ The package re-exports commonly needed utilities so you don't need to install th
399
490
  ```typescript
400
491
  import {
401
492
  Type, // from @sinclair/typebox
493
+ type Static, // from @sinclair/typebox
402
494
  ConditionBuilder, // from node-condition-builder
403
495
  Expression, // from node-condition-builder
404
- QueryClient, // raw SQL query helper
496
+
497
+ // Table configuration
405
498
  defineTable,
406
499
  exportTableInfo,
407
500
  buildRelation,
408
501
  buildUpsertRule,
409
502
  buildUpsertRules,
503
+
504
+ // DB layer
505
+ QueryClient, // raw SQL query helper
506
+ createQueryClient, // factory with dialect string
507
+ pgQueryable, // pg pool adapter
508
+ mysqlQueryable, // mysql2 pool adapter
509
+
510
+ // Programmatic high-level API
511
+ createSqlApi, // standalone SqlApi (for scripts/tests)
512
+ setupSwagger, // manual Swagger registration
513
+ } from 'fastify-auto-sqlapi';
514
+
515
+ import type {
516
+ ValidationError, // [field, code] | [field, code, message]
517
+ ValidatorFn,
518
+ BulkValidatorFn,
519
+ JoinRefFilter,
520
+ JoinGroupRequest,
521
+ SearchCondition,
522
+ ConditionMethod,
410
523
  } from 'fastify-auto-sqlapi';
411
524
  ```
412
525
 
526
+ After registering the plugin, `app.sqlApi` is decorated on the Fastify instance and exposes `search`, `get`, `insert`, `update`, `delete`, `bulkUpsert`, `bulkDelete` for custom routes — same code path as the auto-generated endpoints.
527
+
413
528
  ## Requirements
414
529
 
415
530
  - Node.js >= 18
416
- - PostgreSQL
417
- - Fastify >= 4
418
- - `@fastify/postgres` >= 5
531
+ - Fastify >= 4 (peer dependency)
532
+ - One of:
533
+ - **PostgreSQL** + `pg` + `@fastify/postgres`
534
+ - **MySQL** / **MariaDB** + `mysql2`
535
+ - Optional: `@fastify/swagger` + `@fastify/swagger-ui` for Swagger UI
419
536
 
420
537
  ## License
421
538
 
@@ -6,7 +6,6 @@ import { buildConnectionString, introspectTables } from '../lib/cli/pg-introspec
6
6
  import { buildMysqlConnectionConfig, introspectMysqlTables } from '../lib/cli/mysql-introspect.js';
7
7
  import { buildTableMap, generateSchemaFile } from '../lib/cli/schema-codegen.js';
8
8
  import { loadEnvFile, CONSOLE_COLORS, display, displayAsTableRow, error } from './utils.js';
9
- loadEnvFile();
10
9
  function parseCliArgs() {
11
10
  const args = process.argv.slice(2);
12
11
  const result = {};
@@ -27,6 +26,7 @@ async function main() {
27
26
  display('++++++ fastify-auto-sqlapi: generating schemas ++++++', CONSOLE_COLORS.yellow);
28
27
  const cliArgs = parseCliArgs();
29
28
  const config = await loadConfig();
29
+ loadEnvFile(config.envFile);
30
30
  const dialect = (cliArgs.dialect || config.dialect || 'postgres');
31
31
  const schema = config.schema || (dialect === 'postgres' ? 'public' : config.schema || 'public');
32
32
  const outputDir = path.resolve(process.cwd(), cliArgs.output || config.outputDir);
@@ -67,10 +67,10 @@ async function main() {
67
67
  let untouched = 0;
68
68
  const generatedFiles = new Set();
69
69
  for (const schemaName of Object.keys(tableMap)) {
70
- const { name: tableName, fields } = tableMap[schemaName];
70
+ const { name: tableName, fields, colMap } = tableMap[schemaName];
71
71
  const filename = path.join(schemasDir, `${schemaName}.ts`);
72
72
  generatedFiles.add(`${schemaName}.ts`);
73
- const content = generateSchemaFile(schemaName, tableName, fields);
73
+ const content = generateSchemaFile(schemaName, tableName, fields, colMap);
74
74
  let status = 'created';
75
75
  let color = CONSOLE_COLORS.green;
76
76
  if (fs.existsSync(filename)) {