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.
- package/AGENTS.md +2 -2
- package/AGENTS_BACKEND.md +111 -15
- package/AGENTS_FRONTEND.md +126 -1
- package/README.md +135 -18
- package/dist/bin/generate-schema.js +3 -3
- package/dist/bin/generate-tables.js +1 -1
- package/dist/bin/utils.d.ts +1 -1
- package/dist/bin/utils.d.ts.map +1 -1
- package/dist/bin/utils.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +9 -2
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +8 -1
- package/dist/lib/cli/schema-codegen.d.ts +1 -1
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +11 -4
- package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
- package/dist/lib/cli/tables-codegen.js +30 -1
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +10 -4
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +41 -27
- package/dist/lib/engine/rest/get.js +1 -1
- package/dist/lib/engine/rest/insert.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.js +25 -21
- package/dist/lib/engine/rest/update.d.ts.map +1 -1
- package/dist/lib/engine/rest/update.js +42 -38
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +235 -39
- package/dist/lib/engine/validate.d.ts +11 -0
- package/dist/lib/engine/validate.d.ts.map +1 -0
- package/dist/lib/engine/validate.js +29 -0
- package/dist/lib/engine/write-helpers.js +4 -4
- package/dist/lib/naming.d.ts +11 -2
- package/dist/lib/naming.d.ts.map +1 -1
- package/dist/lib/naming.js +33 -5
- package/dist/lib/schema/bulk-upsert.js +2 -2
- package/dist/lib/schema/helpers.d.ts +5 -0
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +15 -0
- package/dist/lib/schema/insert.js +2 -2
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +16 -10
- package/dist/lib/schema/update.d.ts.map +1 -1
- package/dist/lib/schema/update.js +4 -3
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +9 -1
- package/dist/routes/auto/plugin.d.ts.map +1 -1
- package/dist/routes/auto/plugin.js +12 -1
- package/dist/types.d.ts +47 -8
- package/dist/types.d.ts.map +1 -1
- 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
|
|
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
|
-
//
|
|
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.
|
|
262
|
-
record.
|
|
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.
|
|
269
|
-
fields.
|
|
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,
|
|
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
|
-
-
|
|
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
|
-
|
|
610
|
-
record.
|
|
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.
|
|
614
|
-
fields.
|
|
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.
|
package/AGENTS_FRONTEND.md
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
"
|
|
231
|
-
|
|
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
|
-
"
|
|
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,
|
|
391
|
-
- **
|
|
392
|
-
- **
|
|
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
|
-
|
|
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
|
-
-
|
|
417
|
-
-
|
|
418
|
-
- `@fastify/postgres`
|
|
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)) {
|