fastify-auto-sqlapi 0.0.4 → 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 (37) hide show
  1. package/AGENTS_BACKEND.md +18 -14
  2. package/AGENTS_FRONTEND.md +78 -1
  3. package/README.md +117 -18
  4. package/dist/bin/generate-schema.js +3 -3
  5. package/dist/bin/generate-tables.js +3 -3
  6. package/dist/bin/utils.d.ts +1 -1
  7. package/dist/bin/utils.d.ts.map +1 -1
  8. package/dist/bin/utils.js +2 -2
  9. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  10. package/dist/lib/cli/mysql-introspect.js +9 -2
  11. package/dist/lib/cli/schema-codegen.d.ts +1 -1
  12. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  13. package/dist/lib/cli/schema-codegen.js +11 -4
  14. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  15. package/dist/lib/cli/tables-codegen.js +4 -3
  16. package/dist/lib/db.d.ts.map +1 -1
  17. package/dist/lib/db.js +10 -4
  18. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  19. package/dist/lib/engine/bulk/bulk-upsert.js +40 -36
  20. package/dist/lib/engine/rest/get.js +1 -1
  21. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  22. package/dist/lib/engine/rest/insert.js +24 -23
  23. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  24. package/dist/lib/engine/rest/update.js +41 -40
  25. package/dist/lib/engine/search/search.d.ts.map +1 -1
  26. package/dist/lib/engine/search/search.js +235 -39
  27. package/dist/lib/engine/write-helpers.js +4 -4
  28. package/dist/lib/naming.d.ts +11 -2
  29. package/dist/lib/naming.d.ts.map +1 -1
  30. package/dist/lib/naming.js +33 -5
  31. package/dist/lib/schema/search.d.ts.map +1 -1
  32. package/dist/lib/schema/search.js +16 -10
  33. package/dist/lib/table-helpers.d.ts.map +1 -1
  34. package/dist/lib/table-helpers.js +9 -1
  35. package/dist/types.d.ts +26 -7
  36. package/dist/types.d.ts.map +1 -1
  37. package/package.json +8 -5
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
 
@@ -282,17 +283,17 @@ export const TableCustomer = defineTable({
282
283
  return []; // ValidationError[]
283
284
  },
284
285
 
285
- // HOOKS — side effects (runs after validation)
286
+ // HOOKS — side effects (runs after validation). All receive camelCase (schema field names).
286
287
  beforeInsert: async (db, req, record) => {
287
- // Mutate record before INSERT. record is snake_case.
288
- 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;
289
290
  },
290
291
  afterInsert: async (db, req, record, secondaryRecords) => {
291
- // Called after INSERT + secondaries. record is camelCase.
292
+ // Called after INSERT + secondaries. record is camelCase (input merged with generated PK).
292
293
  },
293
294
  beforeUpdate: async (db, req, fields) => {
294
- // Mutate fields before UPDATE. fields is snake_case.
295
- 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;
296
297
  },
297
298
 
298
299
  // AUTH — per-table request hooks
@@ -430,6 +431,8 @@ const sqlApi = createSqlApi(db, dbTables, { dialect: 'mysql' });
430
431
 
431
432
  ```typescript
432
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)
433
436
  sqlApi.search(tableName, {
434
437
  filters?, joinFilters?, joins?, joinGroups?,
435
438
  orderBy?, paginator?, computeMin?, computeMax?, computeSum?, computeAvg?,
@@ -590,14 +593,14 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
590
593
 
591
594
  ## Key Conventions
592
595
 
593
- - **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()`.
594
597
  - **All fields Optional in response**: `RETURNING *` may return any subset. Response schemas use `Type.Partial`.
595
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.
596
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.
597
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.
598
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.
599
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.
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).
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.
601
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 } } })`.
602
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).
603
606
 
@@ -699,12 +702,13 @@ const TableCustomer = defineTable({
699
702
  primary: 'id',
700
703
  ...exportTableInfo(SchemaCustomer),
701
704
  beforeInsert: async (db, req, record) => {
702
- record.created_by = req.user.id;
703
- 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()');
704
708
  },
705
709
  beforeUpdate: async (db, req, fields) => {
706
- fields.updated_by = req.user.id;
707
- fields.updated_at = db.expression('NOW()');
710
+ fields.updatedBy = req.user.id;
711
+ fields.updatedAt = db.expression('NOW()');
708
712
  },
709
713
  });
710
714
  ```
@@ -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:
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
 
@@ -176,10 +176,10 @@ const TableCustomer = defineTable({
176
176
  return [];
177
177
  },
178
178
 
179
- // Hooks (runs after validation)
180
- beforeInsert: async (db, req, record) => { /* record is snake_case */ },
181
- beforeUpdate: async (db, req, fields) => { /* fields is snake_case */ },
182
- afterInsert: async (db, req, record, secondaryRecords) => { /* record is camelCase */ },
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 */ },
183
183
 
184
184
  // Auth (per-table)
185
185
  onRequests: [
@@ -219,37 +219,80 @@ const TableCustomer = defineTable({
219
219
  ```typescript
220
220
  await app.register(fastifyAutoSqlApi, {
221
221
  DbTables: dbTables, // Required — your table definitions
222
+ dialect: 'postgres', // Optional — 'postgres' | 'mysql' | 'mariadb' (default: 'postgres')
222
223
  prefix: '/api', // Optional — URL prefix for all routes
223
224
  swagger: true, // Optional — enable Swagger UI (or pass SwaggerOptions)
224
225
  onRequests: [authMiddleware], // Optional — global hooks applied to every route
225
226
  getTenantId: (req) => id, // Optional — multi-tenant function
227
+ debug: true, // Optional — log all SQL queries and params
226
228
  });
227
229
  ```
228
230
 
229
231
  | Option | Type | Required | Description |
230
232
  |--------|------|----------|-------------|
231
233
  | `DbTables` | `Record<string, ITable>` | Yes | Table configurations |
234
+ | `dialect` | `'postgres' \| 'mysql' \| 'mariadb'` | No | DB dialect (default: `'postgres'`) |
232
235
  | `prefix` | `string` | No | URL prefix (e.g. `/api`) |
233
236
  | `swagger` | `boolean \| SwaggerOptions` | No | Enable Swagger UI |
234
237
  | `onRequests` | `Function[]` | No | Global auth/middleware hooks |
235
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'`.
236
242
 
237
243
  ## API Reference
238
244
 
239
245
  ### POST /search/{table}
240
246
 
241
- Search with filters, pagination, ordering, joins, and aggregations.
247
+ Search with filters, advanced conditions, pagination, ordering, joins, and aggregations.
242
248
 
243
249
  **Request body** (all fields optional):
244
250
 
245
251
  ```json
246
252
  {
247
253
  "filters": { "name": "Mario" },
248
- "joins": { "order": {} },
249
- "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
+ }
250
285
  }
251
286
  ```
252
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
+
253
296
  **Querystring** (optional): `orderBy`, `page`, `itemsPerPage`, `computeMin`, `computeMax`, `computeSum`, `computeAvg`
254
297
 
255
298
  **Response:**
@@ -259,11 +302,23 @@ Search with filters, pagination, ordering, joins, and aggregations.
259
302
  "table": "customer",
260
303
  "main": [{ "id": 1, "name": "Mario", "email": "m@test.it" }],
261
304
  "joins": { "order": [{ "id": 10, "customerId": 1, "total": 50 }] },
262
- "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
+ }
263
318
  }
264
319
  ```
265
320
 
266
- `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.
267
322
 
268
323
  ### GET /rest/{table}/:id
269
324
 
@@ -312,6 +367,24 @@ Returns the deleted record or 404.
312
367
 
313
368
  **Body:** Array of objects with the PK field, e.g. `[{ "id": 1 }, { "id": 2 }]`. Executes as a single `DELETE WHERE pk IN (...)`.
314
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
+
315
388
  ## Multi-Tenant
316
389
 
317
390
  Automatic row-level isolation on all CRUD operations. Configure once, no code in handlers.
@@ -405,9 +478,9 @@ await app.register(async (instance) => {
405
478
 
406
479
  ## Conventions
407
480
 
408
- - **camelCase in API, snake_case in DB** — the plugin converts automatically
409
- - **Joins are virtual** — they execute separate `SELECT ... WHERE fk IN (...)` queries, not SQL JOINs
410
- - **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
411
484
  - **All response fields are Optional** — response schemas use `Type.Partial` since `RETURNING *` may return any subset
412
485
 
413
486
  ## Re-exports
@@ -417,23 +490,49 @@ The package re-exports commonly needed utilities so you don't need to install th
417
490
  ```typescript
418
491
  import {
419
492
  Type, // from @sinclair/typebox
493
+ type Static, // from @sinclair/typebox
420
494
  ConditionBuilder, // from node-condition-builder
421
495
  Expression, // from node-condition-builder
422
- QueryClient, // raw SQL query helper
496
+
497
+ // Table configuration
423
498
  defineTable,
424
499
  exportTableInfo,
425
500
  buildRelation,
426
501
  buildUpsertRule,
427
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,
428
523
  } from 'fastify-auto-sqlapi';
429
524
  ```
430
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
+
431
528
  ## Requirements
432
529
 
433
530
  - Node.js >= 18
434
- - PostgreSQL
435
- - Fastify >= 4
436
- - `@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
437
536
 
438
537
  ## License
439
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)) {
@@ -4,7 +4,6 @@ import * as path from 'node:path';
4
4
  import { loadConfig } from '../lib/cli/config.js';
5
5
  import { parseSchemaFile, generateSingleTableFile, generateDbTablesIndex } from '../lib/cli/tables-codegen.js';
6
6
  import { loadEnvFile, CONSOLE_COLORS, display, displayAsTableRow, error } from './utils.js';
7
- loadEnvFile();
8
7
  function printHelp() {
9
8
  console.log('');
10
9
  display('Usage:', CONSOLE_COLORS.yellow);
@@ -53,9 +52,10 @@ async function main() {
53
52
  }
54
53
  display('++++++ fastify-auto-sqlapi: generating tables template ++++++', CONSOLE_COLORS.yellow);
55
54
  const config = await loadConfig();
55
+ loadEnvFile(config.envFile);
56
56
  const outputDir = path.resolve(process.cwd(), cliArgs.output || config.outputDir);
57
- const schemasDir = outputDir;
58
- const tablesDir = outputDir;
57
+ const schemasDir = path.join(outputDir, 'schemas');
58
+ const tablesDir = path.join(outputDir, 'tables');
59
59
  if (!fs.existsSync(schemasDir)) {
60
60
  error(`Schemas directory not found: ${schemasDir}`);
61
61
  display('Run sqlapi-generate-schema first.', CONSOLE_COLORS.magenta);
@@ -2,7 +2,7 @@
2
2
  * Load .env file from cwd if it exists.
3
3
  * Does not override already-set environment variables.
4
4
  */
5
- export declare function loadEnvFile(): void;
5
+ export declare function loadEnvFile(envFile?: string): void;
6
6
  export declare const CONSOLE_COLORS: {
7
7
  readonly black: 30;
8
8
  readonly gray: 90;
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/bin/utils.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,wBAAgB,WAAW,IAAI,IAAI,CAqBlC;AAED,eAAO,MAAM,cAAc;;;;;;;;CAQjB,CAAC;AAEX,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,KAAK,GAAE,MAAU,GAChB,IAAI,CAQN;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,GAAE,MAAU,GAAG,IAAI,CAE7D;AAED,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAExC"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/bin/utils.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,wBAAgB,WAAW,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAqBlD;AAED,eAAO,MAAM,cAAc;;;;;;;;CAQjB,CAAC;AAEX,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,KAAK,GAAE,MAAU,GAChB,IAAI,CAQN;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,GAAE,MAAU,GAAG,IAAI,CAE7D;AAED,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAExC"}
package/dist/bin/utils.js CHANGED
@@ -4,8 +4,8 @@ import { resolve } from 'node:path';
4
4
  * Load .env file from cwd if it exists.
5
5
  * Does not override already-set environment variables.
6
6
  */
7
- export function loadEnvFile() {
8
- const envPath = resolve(process.cwd(), '.env');
7
+ export function loadEnvFile(envFile) {
8
+ const envPath = resolve(process.cwd(), envFile || '.env');
9
9
  if (!existsSync(envPath))
10
10
  return;
11
11
  const content = readFileSync(envPath, 'utf-8');
@@ -1 +1 @@
1
- {"version":3,"file":"mysql-introspect.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/mysql-introspect.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,wBAAgB,0BAA0B,IAAI;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAmBA;AAkDD,wBAAsB,qBAAqB,CACzC,gBAAgB,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,EAClG,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,EAAE,CAAC,CAiCvB"}
1
+ {"version":3,"file":"mysql-introspect.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/mysql-introspect.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,wBAAgB,0BAA0B,IAAI;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAmBA;AAkDD,wBAAsB,qBAAqB,CACzC,gBAAgB,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,EAClG,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,EAAE,CAAC,CAuCvB"}
@@ -67,7 +67,8 @@ function mapMysqlType(dataType) {
67
67
  }
68
68
  export async function introspectMysqlTables(connectionConfig, schema) {
69
69
  // Dynamic import: mysql2 is optional peer dependency.
70
- // Use createRequire from cwd so it works with npm link.
70
+ // Try the consumer's CWD first (so it works when installed), then fall back to
71
+ // the plugin's own node_modules (so it works in the plugin's own tests).
71
72
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
73
  let mysql2;
73
74
  try {
@@ -75,7 +76,13 @@ export async function introspectMysqlTables(connectionConfig, schema) {
75
76
  mysql2 = require('mysql2/promise');
76
77
  }
77
78
  catch {
78
- throw new Error('mysql2 is required for MySQL/MariaDB introspection. Install it with: npm install mysql2');
79
+ try {
80
+ const require = createRequire(import.meta.url);
81
+ mysql2 = require('mysql2/promise');
82
+ }
83
+ catch {
84
+ throw new Error('mysql2 is required for MySQL/MariaDB introspection. Install it with: npm install mysql2');
85
+ }
79
86
  }
80
87
  const connection = await mysql2.createConnection(connectionConfig);
81
88
  try {
@@ -4,5 +4,5 @@ export declare function convertColType(udtName: string, columnInfo: {
4
4
  is_nullable: string;
5
5
  }): string;
6
6
  export declare function buildTableMap(rows: ColumnInfo[]): TableMap;
7
- export declare function generateSchemaFile(schemaName: string, tableName: string, fields: Record<string, string>): string;
7
+ export declare function generateSchemaFile(schemaName: string, tableName: string, fields: Record<string, string>, colMap: Record<string, string>): string;
8
8
  //# sourceMappingURL=schema-codegen.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"schema-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/schema-codegen.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE3D,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,UAAU,EAAE;IAAE,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GACjE,MAAM,CAoDR;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,QAAQ,CAkB1D;AAED,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7B,MAAM,CAsCR"}
1
+ {"version":3,"file":"schema-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/schema-codegen.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE3D,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,UAAU,EAAE;IAAE,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GACjE,MAAM,CAoDR;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,QAAQ,CAqB1D;AAED,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7B,MAAM,CA2CR"}
@@ -57,20 +57,26 @@ export function buildTableMap(rows) {
57
57
  acc[schemaName] = {
58
58
  name: row.table_name,
59
59
  fields: {},
60
+ colMap: {},
60
61
  };
61
62
  }
62
- acc[schemaName].fields[toCamelCase(row.column_name)] = convertColType(row.udt_name, row);
63
+ const fieldName = toCamelCase(row.column_name);
64
+ acc[schemaName].fields[fieldName] = convertColType(row.udt_name, row);
65
+ acc[schemaName].colMap[fieldName] = row.column_name;
63
66
  return acc;
64
67
  }, {});
65
68
  }
66
- export function generateSchemaFile(schemaName, tableName, fields) {
69
+ export function generateSchemaFile(schemaName, tableName, fields, colMap) {
67
70
  const fieldsStr = JSON.stringify(fields, null, 2).replace(/"(.*?)"\s*:\s*"Type(.*?)"/g, '$1: Type$2');
71
+ const colMapStr = JSON.stringify(colMap, null, 2);
68
72
  return `// Auto-generated by fastify-auto-sqlapi. Do not edit.
69
- import { Type, toUnderscore } from "fastify-auto-sqlapi";
73
+ import { Type } from "fastify-auto-sqlapi";
70
74
  import type { Static } from "fastify-auto-sqlapi";
71
75
 
72
76
  const _Schema = ${fieldsStr};
73
77
 
78
+ const _colMap: Record<string, string> = ${colMapStr};
79
+
74
80
  const validation = Type.Object(_Schema, { $id: "${schemaName}" });
75
81
  const partialValidation = Type.Object(
76
82
  Object.fromEntries(
@@ -83,7 +89,8 @@ const partialValidation = Type.Object(
83
89
  );
84
90
 
85
91
  export const Schema = {
86
- col: (f: keyof typeof _Schema) => toUnderscore(f),
92
+ col: (f: keyof typeof _Schema) => _colMap[f as string] ?? f,
93
+ colMap: _colMap,
87
94
  fields: _Schema,
88
95
  validation,
89
96
  tableName: "${tableName}",
@@ -1 +1 @@
1
- {"version":3,"file":"tables-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/tables-codegen.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,gBAAgB;IAC/B,gBAAgB,EAAE,MAAM,CAAC;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;CACpB;AAID,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CA2BpE;AAgBD,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,gBAAgB,EAAE,CAwB3E;AAID,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,GAAG,MAAM,CAuHhG;AAID,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CAqBrE;AAID,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CA6FlE"}
1
+ {"version":3,"file":"tables-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/tables-codegen.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,gBAAgB;IAC/B,gBAAgB,EAAE,MAAM,CAAC;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;CACpB;AAID,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CA2BpE;AAgBD,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,gBAAgB,EAAE,CAwB3E;AAID,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,GAAG,MAAM,CAwHhG;AAID,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CAqBrE;AAID,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CA6FlE"}
@@ -98,6 +98,7 @@ export function generateSingleTableFile(schema, allSchemas) {
98
98
  const lines = [];
99
99
  // Imports
100
100
  lines.push(`import {defineTable, exportTableInfo, Type} from 'fastify-auto-sqlapi';`);
101
+ lines.push(`import type {ValidationError} from 'fastify-auto-sqlapi';`);
101
102
  lines.push(`import {${schema.schemaName} as Schema} from '../schemas/${schema.schemaName}';`);
102
103
  // Commented imports for related schemas
103
104
  for (const relSchema of relatedSchemas) {
@@ -157,7 +158,7 @@ export function generateSingleTableFile(schema, allSchemas) {
157
158
  // validate: use first non-PK field for example
158
159
  const exampleField = schema.fields.find(f => f !== pk) || schema.fields[0];
159
160
  lines.push(` validate: async (db, req, main, secondaries) => {`);
160
- lines.push(` const errors = [];`);
161
+ lines.push(` const errors: ValidationError[] = [];`);
161
162
  lines.push(` // if (!main.${exampleField}) errors.push(['${exampleField}', 'required']);`);
162
163
  lines.push(` return errors;`);
163
164
  lines.push(` },`);
@@ -209,7 +210,7 @@ export function generateTablesFile(schemas) {
209
210
  const lines = [];
210
211
  // Imports
211
212
  lines.push(`import {defineTable, exportTableInfo, Type} from 'fastify-auto-sqlapi';`);
212
- lines.push(`import type { DbTables } from 'fastify-auto-sqlapi';`);
213
+ lines.push(`import type { DbTables, ValidationError } from 'fastify-auto-sqlapi';`);
213
214
  lines.push(``);
214
215
  // Schema imports
215
216
  for (const schema of schemas) {
@@ -247,7 +248,7 @@ export function generateTablesFile(schemas) {
247
248
  lines.push(` // schemaOverrides: {},`);
248
249
  const exampleFieldLegacy = schema.fields.find(f => f !== pk) || schema.fields[0];
249
250
  lines.push(` validate: async (db, req, main, secondaries) => {`);
250
- lines.push(` const errors = [];`);
251
+ lines.push(` const errors: ValidationError[] = [];`);
251
252
  lines.push(` // if (!main.${exampleFieldLegacy}) errors.push(['${exampleFieldLegacy}', 'required']);`);
252
253
  lines.push(` return errors;`);
253
254
  lines.push(` },`);