fastify-auto-sqlapi 0.0.1 → 0.0.4
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 +56 -0
- package/AGENTS_BACKEND.md +833 -0
- package/AGENTS_FRONTEND.md +414 -0
- package/README.md +19 -1
- package/dist/bin/generate-tables.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +8 -1
- package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
- package/dist/lib/cli/tables-codegen.js +28 -0
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +14 -4
- package/dist/lib/engine/rest/insert.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.js +8 -5
- package/dist/lib/engine/rest/update.d.ts.map +1 -1
- package/dist/lib/engine/rest/update.js +8 -5
- 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/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/update.d.ts.map +1 -1
- package/dist/lib/schema/update.js +4 -3
- package/dist/routes/auto/plugin.d.ts.map +1 -1
- package/dist/routes/auto/plugin.js +12 -1
- package/dist/types.d.ts +21 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -7
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
# fastify-auto-sqlapi — Backend Configuration
|
|
2
|
+
|
|
3
|
+
How to set up schemas, tables, and configure the plugin.
|
|
4
|
+
|
|
5
|
+
## Setup Workflow
|
|
6
|
+
|
|
7
|
+
### 1. Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install fastify-auto-sqlapi
|
|
11
|
+
# PostgreSQL:
|
|
12
|
+
npm install fastify @fastify/postgres
|
|
13
|
+
# MySQL/MariaDB:
|
|
14
|
+
npm install fastify mysql2
|
|
15
|
+
# Optional (for Swagger UI):
|
|
16
|
+
npm install @fastify/swagger @fastify/swagger-ui
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### 2. Configure `sqlapi.config.ts` (CLI only)
|
|
20
|
+
|
|
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
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
// sqlapi.config.ts — interface: { outputDir: string; schema?: string; dialect?: DialectName }
|
|
25
|
+
export default {
|
|
26
|
+
outputDir: './src/db', // base directory for generated files (default: './src/db')
|
|
27
|
+
schema: 'public', // DB schema to introspect (default: 'public', PostgreSQL only)
|
|
28
|
+
dialect: 'postgres', // 'postgres' | 'mysql' | 'mariadb' (default: 'postgres')
|
|
29
|
+
};
|
|
30
|
+
```
|
|
31
|
+
|
|
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
|
+
|
|
34
|
+
### 3. Configure database connection (env vars)
|
|
35
|
+
|
|
36
|
+
The CLI reads DB connection from environment variables (NOT from the config file). `DATABASE_URL` takes priority for all dialects.
|
|
37
|
+
|
|
38
|
+
**PostgreSQL:**
|
|
39
|
+
- `DATABASE_URL` — full connection string, OR:
|
|
40
|
+
- `POSTGRES_HOST` (default: `127.0.0.1`), `POSTGRES_PORT` (default: `5433`), `POSTGRES_USER` (default: `test`), `POSTGRES_PASSWORD` (default: `test`), `POSTGRES_DB` (default: `testdb`)
|
|
41
|
+
|
|
42
|
+
**MySQL/MariaDB:**
|
|
43
|
+
- `DATABASE_URL` — full connection string, OR:
|
|
44
|
+
- `MYSQL_HOST` (default: `127.0.0.1`), `MYSQL_PORT` (default: `3306`), `MYSQL_USER` (default: `test`), `MYSQL_PASSWORD` (default: `test`), `MYSQL_DB` (default: `testdb`)
|
|
45
|
+
|
|
46
|
+
The CLI automatically loads a `.env` file from the current working directory (if it exists). Variables already set in the environment are not overridden.
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
# .env
|
|
50
|
+
DATABASE_URL=postgres://user:pass@localhost:5432/mydb
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
"scripts": {
|
|
55
|
+
"sqlapi:generate-schema": "sqlapi-generate-schema",
|
|
56
|
+
"sqlapi:generate-tables": "sqlapi-generate-tables --all"
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
At runtime, the Fastify plugin uses `@fastify/postgres` (for PG) or `mysql2` (for MySQL/MariaDB) which you configure separately (see step 6).
|
|
61
|
+
|
|
62
|
+
### 4. Generate Schema files
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npx sqlapi-generate-schema # PostgreSQL (default)
|
|
66
|
+
npx sqlapi-generate-schema --dialect mysql # MySQL/MariaDB
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
This introspects the database and generates one `Schema*.ts` file per table in `outputDir/schemas/`. These files are auto-generated and should not be manually edited. They contain TypeBox field definitions, `col()` for camelCase→snake_case mapping, and validation schemas. MySQL/MariaDB requires `mysql2` as a peer dependency.
|
|
70
|
+
|
|
71
|
+
### 5. Generate tables template
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npx sqlapi-generate-tables customer customer_order # specific tables (space separated)
|
|
75
|
+
npx sqlapi-generate-tables customer,customer_order # specific tables (comma separated)
|
|
76
|
+
npx sqlapi-generate-tables --all # all tables
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
This reads the Schema files from `outputDir/schemas/` and generates one `Table*.ts` file per table plus a `dbTables.ts` index in `outputDir/tables/`:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
src/db/
|
|
83
|
+
schemas/
|
|
84
|
+
SchemaCustomer.ts # from generate-schema (do not edit)
|
|
85
|
+
SchemaCustomerOrder.ts
|
|
86
|
+
tables/
|
|
87
|
+
TableCustomer.ts # generated — skip if already exists
|
|
88
|
+
TableCustomerOrder.ts
|
|
89
|
+
dbTables.ts # generated — skip if already exists
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Each `Table*.ts` file contains a `defineTable()` call with:
|
|
93
|
+
- Auto-detected primary keys
|
|
94
|
+
- Auto-detected foreign key relations (from field naming convention `*Id`)
|
|
95
|
+
- Commented example of `extraFiltersValidation` + `extendedCondition`
|
|
96
|
+
- All optional keys as commented code, ready to uncomment
|
|
97
|
+
- `export const TableXxx` (named export)
|
|
98
|
+
|
|
99
|
+
**No files are ever overwritten** — if they already exist, they are skipped. This makes it safe to re-run the command when new tables are added to the database. Import paths are generated without extensions (the consumer's tsconfig decides resolution).
|
|
100
|
+
|
|
101
|
+
**Edit the `Table*.ts` and `dbTables.ts` files to customize** — these files are yours to maintain.
|
|
102
|
+
|
|
103
|
+
### 6. Create the Fastify server
|
|
104
|
+
|
|
105
|
+
**PostgreSQL setup:**
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
import Fastify from 'fastify';
|
|
109
|
+
import fastifyPostgres from '@fastify/postgres';
|
|
110
|
+
import { fastifyAutoSqlApi } from 'fastify-auto-sqlapi';
|
|
111
|
+
import { dbTables } from './src/db/tables/dbTables.js';
|
|
112
|
+
|
|
113
|
+
const app = Fastify();
|
|
114
|
+
|
|
115
|
+
await app.register(fastifyPostgres, {
|
|
116
|
+
connectionString: 'postgres://user:pass@localhost:5432/mydb',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await app.register(fastifyAutoSqlApi, {
|
|
120
|
+
DbTables: dbTables, // REQUIRED — Record<string, ITable>
|
|
121
|
+
// dialect: 'postgres', // optional — default is 'postgres'
|
|
122
|
+
swagger: true, // optional — true or SwaggerOptions object
|
|
123
|
+
prefix: '/auto', // optional — standard Fastify prefix
|
|
124
|
+
onRequests: [], // optional — global auth hooks (run on every route)
|
|
125
|
+
getTenantId: (request) => request.user?.organizationId ?? null, // optional — multi-tenant
|
|
126
|
+
debug: true, // optional — log all SQL queries and params to console
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await app.listen({ port: 3000 });
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**MySQL/MariaDB setup:**
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
import Fastify from 'fastify';
|
|
136
|
+
import mysql from 'mysql2/promise';
|
|
137
|
+
import { fastifyAutoSqlApi, mysqlQueryable } from 'fastify-auto-sqlapi';
|
|
138
|
+
import { dbTables } from './src/db/tables/dbTables.js';
|
|
139
|
+
|
|
140
|
+
const app = Fastify();
|
|
141
|
+
|
|
142
|
+
const pool = mysql.createPool({
|
|
143
|
+
host: '127.0.0.1', port: 3306,
|
|
144
|
+
user: 'root', password: 'pass', database: 'mydb',
|
|
145
|
+
});
|
|
146
|
+
app.decorate('mysql', pool);
|
|
147
|
+
|
|
148
|
+
await app.register(fastifyAutoSqlApi, {
|
|
149
|
+
DbTables: dbTables,
|
|
150
|
+
dialect: 'mysql', // or 'mariadb' (MariaDB supports RETURNING)
|
|
151
|
+
swagger: true,
|
|
152
|
+
prefix: '/auto',
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await app.listen({ port: 3000 });
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Granular composition (any dialect):**
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
import {
|
|
162
|
+
searchRoutes, getRoutes, insertRoutes, updateRoutes,
|
|
163
|
+
deleteRoutes, bulkUpsertRoutes, bulkDeleteRoutes, setupSwagger,
|
|
164
|
+
} from 'fastify-auto-sqlapi';
|
|
165
|
+
|
|
166
|
+
await app.register(async (instance) => {
|
|
167
|
+
await setupSwagger(instance, { swagger: true });
|
|
168
|
+
|
|
169
|
+
const opts = { DbTables: dbTables, dialect: 'mysql' };
|
|
170
|
+
await instance.register(searchRoutes, opts);
|
|
171
|
+
await instance.register(getRoutes, opts);
|
|
172
|
+
await instance.register(insertRoutes, opts);
|
|
173
|
+
await instance.register(updateRoutes, opts);
|
|
174
|
+
await instance.register(deleteRoutes, opts);
|
|
175
|
+
await instance.register(bulkUpsertRoutes, opts);
|
|
176
|
+
await instance.register(bulkDeleteRoutes, opts);
|
|
177
|
+
}, { prefix: '/auto' });
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Note**: After registering the plugin, `app.sqlApi` is available for custom routes — see [SqlApi](#sqlapi--programmatic-high-level-api).
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## defineTable() — Complete Reference
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
import {defineTable, exportTableInfo, Type} from 'fastify-auto-sqlapi';
|
|
188
|
+
// Import additional utilities only when needed:
|
|
189
|
+
// import {buildRelation, buildUpsertRules, buildUpsertRule, ConditionBuilder} from 'fastify-auto-sqlapi';
|
|
190
|
+
// import type {DbTables} from 'fastify-auto-sqlapi';
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Minimal table
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
import {defineTable, exportTableInfo} from 'fastify-auto-sqlapi';
|
|
197
|
+
import {SchemaCustomer as Schema} from '../schemas/SchemaCustomer';
|
|
198
|
+
|
|
199
|
+
export const TableCustomer = defineTable({
|
|
200
|
+
primary: 'id',
|
|
201
|
+
...exportTableInfo(Schema),
|
|
202
|
+
});
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`exportTableInfo(Schema)` returns `{ Schema, filters, extraFilters }`. The `filters` function auto-builds WHERE conditions from any schema field present in the request.
|
|
206
|
+
|
|
207
|
+
**Important**: there is no `tableName` key in `defineTable()`. The SQL table name comes from `Schema.tableName` (set by the CLI). The route URL name is the key you use in the `dbTables` record (e.g. `{ customer: TableCustomer }` → `/search/customer`).
|
|
208
|
+
|
|
209
|
+
### Composite primary key (link/junction tables)
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
import {defineTable, exportTableInfo} from 'fastify-auto-sqlapi';
|
|
213
|
+
import {SchemaAgentTeamLink as Schema} from '../schemas/SchemaAgentTeamLink';
|
|
214
|
+
|
|
215
|
+
export const TableAgentTeamLink = defineTable({
|
|
216
|
+
primary: ['agentId', 'teamId'],
|
|
217
|
+
...exportTableInfo(Schema),
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
When `primary` is an array, RETURNING clauses include all PK columns, and response shapes contain all PK fields.
|
|
222
|
+
|
|
223
|
+
### All keys
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
export const TableCustomer = defineTable({
|
|
227
|
+
// REQUIRED
|
|
228
|
+
primary: 'id', // PK field name (camelCase), or array for composite: ['agentId', 'teamId']
|
|
229
|
+
...exportTableInfo(Schema), // Schema + auto-filter builder
|
|
230
|
+
|
|
231
|
+
// OPTIONAL
|
|
232
|
+
defaultOrder: 'name', // ORDER BY default (supports multi: 'name ASC, id DESC')
|
|
233
|
+
excludeFromCreation: ['id'], // Fields omitted from INSERT — MUST include auto-increment PKs
|
|
234
|
+
distinctResults: true, // Use SELECT DISTINCT
|
|
235
|
+
|
|
236
|
+
// JOINS — relations to other tables
|
|
237
|
+
allowedReadJoins: [ // Available for search queries (joins, joinFilters, joinGroups)
|
|
238
|
+
buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'),
|
|
239
|
+
buildRelation(SchemaCustomer, 'id', SchemaAddress, 'customerId', 'id, city, zip'),
|
|
240
|
+
],
|
|
241
|
+
allowedWriteJoins: [ // Available for insert/update secondaries
|
|
242
|
+
buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'),
|
|
243
|
+
],
|
|
244
|
+
|
|
245
|
+
// UPSERT — ON CONFLICT resolution
|
|
246
|
+
upsertMap: buildUpsertRules(
|
|
247
|
+
buildUpsertRule(SchemaCustomer, ['id']), // main table conflict key
|
|
248
|
+
buildUpsertRule(SchemaOrder, ['id']), // secondary conflict key
|
|
249
|
+
),
|
|
250
|
+
|
|
251
|
+
// TENANT — automatic row-level isolation
|
|
252
|
+
tenantScope: { column: 'organization_id' }, // direct: column on this table
|
|
253
|
+
// OR indirect: resolve via JOIN to parent table
|
|
254
|
+
// tenantScope: {
|
|
255
|
+
// column: 'organization_id',
|
|
256
|
+
// through: { schema: SchemaCustomer, localField: 'customerId', foreignField: 'id' },
|
|
257
|
+
// },
|
|
258
|
+
|
|
259
|
+
// SCHEMA OVERRIDES — tighten auto-generated schema validation without editing Schema files
|
|
260
|
+
schemaOverrides: {
|
|
261
|
+
email: Type.String({ format: 'email' }),
|
|
262
|
+
name: Type.String({ minLength: 1, maxLength: 100 }),
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
// VALIDATION — structured field-level validation (runs after schema, before hooks)
|
|
266
|
+
validate: async (db, req, main, secondaries) => {
|
|
267
|
+
// Return ValidationError[] — tuple: [field, code] or [field, code, message]
|
|
268
|
+
// message defaults to code if omitted
|
|
269
|
+
const errors = [];
|
|
270
|
+
if (main.name === '') {
|
|
271
|
+
errors.push(['name', 'required', 'cannot be empty']);
|
|
272
|
+
}
|
|
273
|
+
// Cross-entity: validate secondaries (e.g. date overlap in periods)
|
|
274
|
+
if (secondaries?.customer_order) {
|
|
275
|
+
// ... check overlaps, business rules across related records
|
|
276
|
+
}
|
|
277
|
+
return errors;
|
|
278
|
+
},
|
|
279
|
+
validateBulk: async (db, req, items) => {
|
|
280
|
+
// Called once with ALL items in bulk-upsert. Use for cross-item validation.
|
|
281
|
+
// items: Array<{ main, secondaries? }>
|
|
282
|
+
return []; // ValidationError[]
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
// HOOKS — side effects (runs after validation)
|
|
286
|
+
beforeInsert: async (db, req, record) => {
|
|
287
|
+
// Mutate record before INSERT. record is snake_case.
|
|
288
|
+
record.created_by = req.user.id;
|
|
289
|
+
},
|
|
290
|
+
afterInsert: async (db, req, record, secondaryRecords) => {
|
|
291
|
+
// Called after INSERT + secondaries. record is camelCase.
|
|
292
|
+
},
|
|
293
|
+
beforeUpdate: async (db, req, fields) => {
|
|
294
|
+
// Mutate fields before UPDATE. fields is snake_case.
|
|
295
|
+
fields.updated_by = req.user.id;
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
// AUTH — per-table request hooks
|
|
299
|
+
onRequests: [
|
|
300
|
+
async (request, reply) => {
|
|
301
|
+
if (!request.user) return reply.status(401).send({ error: 'Unauthorized' });
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
});
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### buildRelation signature
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
buildRelation(mainSchema, mainField, joinSchema, joinField, selection?)
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
- `mainField`: field(s) in main table (string or string[])
|
|
314
|
+
- `joinField`: field in join table that references the main
|
|
315
|
+
- `selection`: SQL columns to select (default `'*'`), e.g. `'id, name, total'`
|
|
316
|
+
|
|
317
|
+
The join works as: `SELECT {selection} FROM {joinTable} WHERE {joinField} IN ({mainField values})`
|
|
318
|
+
|
|
319
|
+
### extraFilters + extendedCondition
|
|
320
|
+
|
|
321
|
+
For filters that don't map to real columns (e.g. full-text search `q`):
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
import {defineTable, exportTableInfo, Type, ConditionBuilder} from 'fastify-auto-sqlapi';
|
|
325
|
+
import {SchemaCustomer as Schema} from '../schemas/SchemaCustomer';
|
|
326
|
+
|
|
327
|
+
const extraFiltersValidation = Type.Object({
|
|
328
|
+
q: Type.String(),
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
export const TableCustomer = defineTable({
|
|
332
|
+
primary: 'id',
|
|
333
|
+
...exportTableInfo(
|
|
334
|
+
Schema,
|
|
335
|
+
extraFiltersValidation,
|
|
336
|
+
// `filters` is auto-typed with keys from Schema.fields + extraFiltersValidation
|
|
337
|
+
(condition, filters) => {
|
|
338
|
+
if (filters.q) {
|
|
339
|
+
const or = new ConditionBuilder('OR');
|
|
340
|
+
or.isILike(Schema.col('name'), `%${filters.q}%`);
|
|
341
|
+
or.isILike(Schema.col('email'), `%${filters.q}%`);
|
|
342
|
+
condition.append(or);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
),
|
|
346
|
+
});
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
`extraFilters` accepts either a `Type.Object({...})` or a plain `Record<string, TSchema>`. Extra filter fields appear in Swagger but are NOT auto-applied as `WHERE col = value` — they are handled exclusively by the `extendedCondition` callback. The `filters` parameter in the callback is fully typed with autocomplete for all schema fields + extra filter keys.
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
## ConditionBuilder API
|
|
354
|
+
|
|
355
|
+
Used in `extendedCondition` callbacks, hooks, and exposed via the `conditions` field in the search API (see [AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md#conditions-advanced-filters)).
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
const cb = new ConditionBuilder('AND'); // or 'OR'
|
|
359
|
+
cb.isEqual('column', value);
|
|
360
|
+
cb.isNotEqual('column', value);
|
|
361
|
+
cb.isGreater('column', value);
|
|
362
|
+
cb.isGreaterOrEqual('column', value);
|
|
363
|
+
cb.isLess('column', value);
|
|
364
|
+
cb.isLessOrEqual('column', value);
|
|
365
|
+
cb.isLike('column', '%value%');
|
|
366
|
+
cb.isILike('column', '%value%'); // case-insensitive LIKE
|
|
367
|
+
cb.isBetween('column', from, to);
|
|
368
|
+
cb.isIn('column', [val1, val2]);
|
|
369
|
+
cb.isNotIn('column', [val1, val2]);
|
|
370
|
+
cb.isNull('column', true);
|
|
371
|
+
cb.isNotNull('column', true);
|
|
372
|
+
cb.raw('column::text = $1', [value]); // raw SQL with params (backend only, NOT exposed in API)
|
|
373
|
+
cb.append(otherConditionBuilder); // nest conditions (backend only, NOT exposed in API)
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
All field-based methods (except `raw` and `append`) are available in the search API `conditions` array. New methods added to ConditionBuilder are automatically available after adding them to the whitelist.
|
|
377
|
+
|
|
378
|
+
All values are parameterized (`$1, $2, ...`), never interpolated.
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## SqlApi — Programmatic High-Level API
|
|
383
|
+
|
|
384
|
+
Use `SqlApi` to perform CRUD operations from custom routes with the same capabilities as the auto-generated endpoints. The internal auto-generated routes also use `SqlApi`, guaranteeing a single code path.
|
|
385
|
+
|
|
386
|
+
### Using `app.sqlApi` (recommended)
|
|
387
|
+
|
|
388
|
+
After registering the plugin, `app.sqlApi` is available everywhere — no extra configuration needed:
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
await app.register(fastifyAutoSqlApi, {
|
|
392
|
+
DbTables: dbTables,
|
|
393
|
+
dialect: 'mysql',
|
|
394
|
+
swagger: true,
|
|
395
|
+
prefix: '/auto',
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// app.sqlApi is available in any route — even outside the plugin scope
|
|
399
|
+
app.get('/billing/:customerId', async (request) => {
|
|
400
|
+
const customerId = Number(request.params.customerId);
|
|
401
|
+
return app.sqlApi.search('subscription', {
|
|
402
|
+
filters: { customerId, status: 'active' },
|
|
403
|
+
joinFilters: { customer_order: { status: 'pending' } },
|
|
404
|
+
paginator: { page: 1, itemsPerPage: 50 },
|
|
405
|
+
}, request);
|
|
406
|
+
});
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
`app.sqlApi` inherits all configuration from the plugin registration (dialect, DbTables, tenant, debug). No need to pass them again.
|
|
410
|
+
|
|
411
|
+
### Using `createSqlApi` (standalone — for background jobs or without the plugin)
|
|
412
|
+
|
|
413
|
+
When you need a `SqlApi` instance without registering the full plugin (e.g. background jobs, scripts, tests):
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
import { createSqlApi, mysqlQueryable } from 'fastify-auto-sqlapi';
|
|
417
|
+
|
|
418
|
+
// Option 1: pass a raw pool — SqlApi creates the QueryClient internally
|
|
419
|
+
const sqlApi = createSqlApi(mysqlQueryable(pool), dbTables, { dialect: 'mysql' });
|
|
420
|
+
|
|
421
|
+
// Option 2: pass a pre-built QueryClient
|
|
422
|
+
import { createQueryClient } from 'fastify-auto-sqlapi';
|
|
423
|
+
const db = createQueryClient(mysqlQueryable(pool), 'mysql');
|
|
424
|
+
const sqlApi = createSqlApi(db, dbTables, { dialect: 'mysql' });
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
**Important**: when using `createSqlApi` standalone, always pass `dialect` in the options. It configures both the QueryClient (identifier quoting, placeholders) and the ConditionBuilder (used by filters). Without it, defaults to PostgreSQL syntax.
|
|
428
|
+
|
|
429
|
+
### Available methods
|
|
430
|
+
|
|
431
|
+
```typescript
|
|
432
|
+
// Search — full filter, join, pagination, aggregation support
|
|
433
|
+
sqlApi.search(tableName, {
|
|
434
|
+
filters?, joinFilters?, joins?, joinGroups?,
|
|
435
|
+
orderBy?, paginator?, computeMin?, computeMax?, computeSum?, computeAvg?,
|
|
436
|
+
}, request?): Promise<SearchResult>
|
|
437
|
+
|
|
438
|
+
// Get single record by PK
|
|
439
|
+
sqlApi.get(tableName, id, request?): Promise<GetResult>
|
|
440
|
+
|
|
441
|
+
// Insert — with optional secondaries, hooks, tenant
|
|
442
|
+
sqlApi.insert(tableName, { record, secondaries? }, request?): Promise<InsertResult>
|
|
443
|
+
|
|
444
|
+
// Update — with optional secondaries, deletions, hooks, tenant
|
|
445
|
+
sqlApi.update(tableName, { record, secondaries?, deletions? }, request?): Promise<UpdateResult>
|
|
446
|
+
|
|
447
|
+
// Delete by PK
|
|
448
|
+
sqlApi.delete(tableName, id, request?): Promise<DeleteResult>
|
|
449
|
+
|
|
450
|
+
// Bulk upsert
|
|
451
|
+
sqlApi.bulkUpsert(tableName, items, request?): Promise<BulkUpsertResult[]>
|
|
452
|
+
|
|
453
|
+
// Bulk delete
|
|
454
|
+
sqlApi.bulkDelete(tableName, ids, request?): Promise<BulkDeleteResult[]>
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
The `request` parameter is optional. Pass it when you need tenant resolution or hooks (which receive `req`). Without it, tenant filtering is skipped and hooks receive `undefined` as the request.
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
## QueryClient API
|
|
462
|
+
|
|
463
|
+
Available in hooks via the `db` parameter:
|
|
464
|
+
|
|
465
|
+
```typescript
|
|
466
|
+
db.insert(tableName, record, pkCol); // INSERT, returns PK row
|
|
467
|
+
db.insertOrUpdate(tableName, record, conflictKeys, pkCol); // INSERT ON CONFLICT, returns PK row
|
|
468
|
+
db.bulkInsert(tableName, records, pkCol, chunkSize?); // Multi-row INSERT, returns PK rows
|
|
469
|
+
db.bulkInsertOrUpdate(tableName, records, conflictKeys, pkCol, chunkSize?); // Multi-row UPSERT, returns PK rows
|
|
470
|
+
db.update(tableName, record, where, extraCondition?); // UPDATE, returns affectedRows (number)
|
|
471
|
+
db.delete(tableName, where); // DELETE, returns affectedRows (number)
|
|
472
|
+
db.select({ tableName, columns?, where, values, limit?, orderBy?, joins?, distinct? }); // SELECT, returns rows
|
|
473
|
+
db.query(sql, values); // Raw query
|
|
474
|
+
db.expression(value); // Raw SQL expression (not parameterized)
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`pkCol` can be a string (`'id'`) or an array (`['agent_id', 'team_id']`) for composite PKs. PostgreSQL/MariaDB use `RETURNING`, MySQL uses `insertId`.
|
|
478
|
+
|
|
479
|
+
Record keys are snake_case column names. Values are parameterized.
|
|
480
|
+
|
|
481
|
+
---
|
|
482
|
+
|
|
483
|
+
## Swagger
|
|
484
|
+
|
|
485
|
+
```typescript
|
|
486
|
+
import { setupSwagger } from 'fastify-auto-sqlapi';
|
|
487
|
+
|
|
488
|
+
// Basic (UI at /documentation)
|
|
489
|
+
await setupSwagger(instance, { swagger: true });
|
|
490
|
+
|
|
491
|
+
// Custom
|
|
492
|
+
await setupSwagger(instance, {
|
|
493
|
+
swagger: {
|
|
494
|
+
title: 'My API',
|
|
495
|
+
description: 'Auto-generated CRUD API',
|
|
496
|
+
version: '1.0.0',
|
|
497
|
+
routePrefix: '/docs',
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Requires `@fastify/swagger` and `@fastify/swagger-ui` as peer deps. If not installed, logs a warning and continues.
|
|
503
|
+
|
|
504
|
+
---
|
|
505
|
+
|
|
506
|
+
## Multi-Tenant Filtering
|
|
507
|
+
|
|
508
|
+
Automatic row-level isolation per tenant on all CRUD operations. Zero code in route handlers — just configure and go.
|
|
509
|
+
|
|
510
|
+
### Setup
|
|
511
|
+
|
|
512
|
+
```typescript
|
|
513
|
+
import { fastifyAutoSqlApi } from 'fastify-auto-sqlapi';
|
|
514
|
+
|
|
515
|
+
await app.register(fastifyAutoSqlApi, {
|
|
516
|
+
DbTables: dbTables,
|
|
517
|
+
// Return tenant ID(s) from request. null/undefined = admin (no filter).
|
|
518
|
+
getTenantId: (request) => {
|
|
519
|
+
const orgId = request.user?.organizationId;
|
|
520
|
+
return orgId ?? null;
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
### Direct tenant (column on the table itself)
|
|
526
|
+
|
|
527
|
+
```typescript
|
|
528
|
+
const TableCustomer = defineTable({
|
|
529
|
+
primary: 'id',
|
|
530
|
+
...exportTableInfo(SchemaCustomer),
|
|
531
|
+
tenantScope: { column: 'organization_id' },
|
|
532
|
+
});
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
Behavior:
|
|
536
|
+
- **Read** (search, get, delete, bulk-delete): adds `AND organization_id IN ($N)` to WHERE
|
|
537
|
+
- **Insert**: auto-injects `organization_id` if single tenant; validates if already present (403 if mismatch); 400 if multi-tenant without explicit value
|
|
538
|
+
- **Update**: strips `organization_id` from SET (can't change tenant), adds to WHERE condition
|
|
539
|
+
- **Bulk upsert**: auto-injects on all records
|
|
540
|
+
|
|
541
|
+
### Indirect tenant (via JOIN to parent table)
|
|
542
|
+
|
|
543
|
+
```typescript
|
|
544
|
+
const TableOrder = defineTable({
|
|
545
|
+
primary: 'id',
|
|
546
|
+
...exportTableInfo(SchemaOrder),
|
|
547
|
+
tenantScope: {
|
|
548
|
+
column: 'organization_id',
|
|
549
|
+
through: {
|
|
550
|
+
schema: SchemaCustomer, // parent table schema
|
|
551
|
+
localField: 'customer_id', // FK on this table
|
|
552
|
+
foreignField: 'id', // PK on parent table
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
Behavior:
|
|
559
|
+
- **Read**: INNER JOIN to parent table + WHERE on parent's tenant column
|
|
560
|
+
- **Insert/Bulk upsert**: validates FK references belong to tenant (1 batch query)
|
|
561
|
+
- **Update**: pre-check via SELECT with INNER JOIN (404 if not found)
|
|
562
|
+
- **Delete**: subquery `DELETE WHERE pk IN (SELECT ... INNER JOIN ... WHERE tenant IN (...))`
|
|
563
|
+
|
|
564
|
+
### Multi-tenant users
|
|
565
|
+
|
|
566
|
+
```typescript
|
|
567
|
+
getTenantId: (request) => {
|
|
568
|
+
// Return array for users managing multiple tenants
|
|
569
|
+
return request.user?.organizationIds ?? null; // e.g. [1, 2, 3]
|
|
570
|
+
},
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
Uses `IN ($N, $M, ...)` for all WHERE clauses (same as single tenant, just with more values).
|
|
574
|
+
|
|
575
|
+
### Admin bypass
|
|
576
|
+
|
|
577
|
+
When `getTenantId` returns `null` or `undefined`, no filtering is applied (full access).
|
|
578
|
+
|
|
579
|
+
### Tables without tenant
|
|
580
|
+
|
|
581
|
+
Tables without `tenantScope` are unaffected — no filtering regardless of `getTenantId` result.
|
|
582
|
+
|
|
583
|
+
### Error codes
|
|
584
|
+
|
|
585
|
+
- **403** — Record doesn't belong to tenant, or explicit tenant value doesn't match
|
|
586
|
+
- **400** — Multi-tenant user on insert without explicit tenant value (ambiguous)
|
|
587
|
+
- **404** — Update with indirect tenant, record not found for this tenant
|
|
588
|
+
|
|
589
|
+
---
|
|
590
|
+
|
|
591
|
+
## Key Conventions
|
|
592
|
+
|
|
593
|
+
- **camelCase in API, snake_case in DB**: all request/response fields are camelCase. The plugin converts automatically via `col()`.
|
|
594
|
+
- **All fields Optional in response**: `RETURNING *` may return any subset. Response schemas use `Type.Partial`.
|
|
595
|
+
- **`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
|
+
- **`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
|
+
- **`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
|
+
- **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
|
+
- **`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).
|
|
601
|
+
- **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
|
+
- **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
|
+
|
|
604
|
+
---
|
|
605
|
+
|
|
606
|
+
## Common Backend Patterns
|
|
607
|
+
|
|
608
|
+
### Auth middleware (global)
|
|
609
|
+
|
|
610
|
+
```typescript
|
|
611
|
+
const opts = {
|
|
612
|
+
DbTables: dbTables,
|
|
613
|
+
onRequests: [async (req, reply) => {
|
|
614
|
+
if (!req.headers.authorization) return reply.status(401).send({ error: 'Unauthorized' });
|
|
615
|
+
}],
|
|
616
|
+
};
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
### Auth middleware (per-table)
|
|
620
|
+
|
|
621
|
+
```typescript
|
|
622
|
+
const TableAdmin = defineTable({
|
|
623
|
+
primary: 'id',
|
|
624
|
+
...exportTableInfo(SchemaAdmin),
|
|
625
|
+
onRequests: [async (req, reply) => {
|
|
626
|
+
if (req.user.role !== 'admin') return reply.status(403).send({ error: 'Forbidden' });
|
|
627
|
+
}],
|
|
628
|
+
});
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
### Custom validation (field-level, cross-entity)
|
|
632
|
+
|
|
633
|
+
```typescript
|
|
634
|
+
import type { ValidationError } from 'fastify-auto-sqlapi';
|
|
635
|
+
|
|
636
|
+
const TableSession = defineTable({
|
|
637
|
+
primary: 'id',
|
|
638
|
+
...exportTableInfo(SchemaSession),
|
|
639
|
+
allowedWriteJoins: [
|
|
640
|
+
buildRelation(SchemaSession, 'id', SchemaPeriod, 'sessionId'),
|
|
641
|
+
],
|
|
642
|
+
validate: async (db, req, main, secondaries) => {
|
|
643
|
+
// ValidationError is a tuple: [field, code] or [field, code, message]
|
|
644
|
+
// message defaults to code if omitted
|
|
645
|
+
const errors: ValidationError[] = [];
|
|
646
|
+
|
|
647
|
+
// Simple field validation
|
|
648
|
+
if (!main.name) {
|
|
649
|
+
errors.push(['name', 'required']); // message defaults to 'required'
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Async validation (uniqueness check)
|
|
653
|
+
if (main.code) {
|
|
654
|
+
const existing = await db.query('SELECT 1 FROM session WHERE code = $1 AND id != $2', [main.code, main.id ?? 0]);
|
|
655
|
+
if (existing.rows.length) {
|
|
656
|
+
errors.push(['code', 'unique', 'already exists']);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Cross-entity: validate periods don't overlap
|
|
661
|
+
const periods = secondaries?.session_period;
|
|
662
|
+
if (periods?.length) {
|
|
663
|
+
for (let i = 0; i < periods.length; i++) {
|
|
664
|
+
for (let j = i + 1; j < periods.length; j++) {
|
|
665
|
+
if (periods[i].startDate < periods[j].endDate && periods[j].startDate < periods[i].endDate) {
|
|
666
|
+
errors.push([`session_period[${j}].startDate`, 'overlap', 'overlaps with another period']);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return errors;
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
`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:
|
|
678
|
+
|
|
679
|
+
```typescript
|
|
680
|
+
const TableSession = defineTable({
|
|
681
|
+
// ...
|
|
682
|
+
validateBulk: async (db, req, items) => {
|
|
683
|
+
// items: Array<{ main, secondaries? }>
|
|
684
|
+
// Check for duplicate codes across all items in the batch
|
|
685
|
+
const codes = items.map(i => i.main.code).filter(Boolean);
|
|
686
|
+
const unique = new Set(codes);
|
|
687
|
+
if (unique.size !== codes.length) {
|
|
688
|
+
return [['code', 'batch_unique', 'duplicate codes in batch']];
|
|
689
|
+
}
|
|
690
|
+
return [];
|
|
691
|
+
},
|
|
692
|
+
});
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
### Auto-fill audit fields
|
|
696
|
+
|
|
697
|
+
```typescript
|
|
698
|
+
const TableCustomer = defineTable({
|
|
699
|
+
primary: 'id',
|
|
700
|
+
...exportTableInfo(SchemaCustomer),
|
|
701
|
+
beforeInsert: async (db, req, record) => {
|
|
702
|
+
record.created_by = req.user.id;
|
|
703
|
+
record.created_at = db.expression('NOW()');
|
|
704
|
+
},
|
|
705
|
+
beforeUpdate: async (db, req, fields) => {
|
|
706
|
+
fields.updated_by = req.user.id;
|
|
707
|
+
fields.updated_at = db.expression('NOW()');
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
### Full-text search filter
|
|
713
|
+
|
|
714
|
+
```typescript
|
|
715
|
+
const extraFilters = Type.Object({ q: Type.String() });
|
|
716
|
+
|
|
717
|
+
...exportTableInfo(Schema, extraFilters, (condition, filters) => {
|
|
718
|
+
if (filters.q) {
|
|
719
|
+
const or = new ConditionBuilder('OR');
|
|
720
|
+
or.isILike(Schema.col('name'), `%${filters.q}%`);
|
|
721
|
+
or.isILike(Schema.col('email'), `%${filters.q}%`);
|
|
722
|
+
or.isILike(Schema.col('phoneNumber'), `%${filters.q}%`);
|
|
723
|
+
condition.append(or);
|
|
724
|
+
}
|
|
725
|
+
}),
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
### Date range filter
|
|
729
|
+
|
|
730
|
+
```typescript
|
|
731
|
+
const extraFilters = Type.Object({
|
|
732
|
+
dateFrom: Type.String(),
|
|
733
|
+
dateTo: Type.String(),
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
...exportTableInfo(Schema, extraFilters, (condition, filters) => {
|
|
737
|
+
if (filters.dateFrom && filters.dateTo) {
|
|
738
|
+
condition.isBetween(Schema.col('orderDate'), filters.dateFrom, filters.dateTo);
|
|
739
|
+
} else if (filters.dateFrom) {
|
|
740
|
+
condition.isGreater(Schema.col('orderDate'), filters.dateFrom);
|
|
741
|
+
}
|
|
742
|
+
}),
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
### Multi-tenant from JWT/header
|
|
746
|
+
|
|
747
|
+
```typescript
|
|
748
|
+
await app.register(fastifyAutoSqlApi, {
|
|
749
|
+
DbTables: dbTables,
|
|
750
|
+
getTenantId: (request) => {
|
|
751
|
+
// From JWT claims
|
|
752
|
+
return request.user?.organizationId ?? null;
|
|
753
|
+
|
|
754
|
+
// Or from header (multi-tenant support)
|
|
755
|
+
// const header = request.headers['x-tenant-id'] as string | undefined;
|
|
756
|
+
// if (!header) return null;
|
|
757
|
+
// const ids = header.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
|
|
758
|
+
// return ids.length === 0 ? null : ids.length === 1 ? ids[0] : ids;
|
|
759
|
+
},
|
|
760
|
+
});
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
### Register only specific routes
|
|
764
|
+
|
|
765
|
+
You don't have to register all route plugins. Pick only what you need:
|
|
766
|
+
|
|
767
|
+
```typescript
|
|
768
|
+
await instance.register(searchRoutes, opts); // read-only API
|
|
769
|
+
await instance.register(getRoutes, opts);
|
|
770
|
+
// No insert/update/delete/bulk routes = read-only
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
### Multiple prefixes for different access levels
|
|
774
|
+
|
|
775
|
+
```typescript
|
|
776
|
+
// Public: search + get only
|
|
777
|
+
await app.register(async (instance) => {
|
|
778
|
+
const opts = { DbTables: publicTables };
|
|
779
|
+
await instance.register(searchRoutes, opts);
|
|
780
|
+
await instance.register(getRoutes, opts);
|
|
781
|
+
}, { prefix: '/public' });
|
|
782
|
+
|
|
783
|
+
// Admin: full CRUD
|
|
784
|
+
await app.register(async (instance) => {
|
|
785
|
+
const opts = { DbTables: allTables, onRequests: [adminAuth] };
|
|
786
|
+
await instance.register(searchRoutes, opts);
|
|
787
|
+
await instance.register(getRoutes, opts);
|
|
788
|
+
await instance.register(insertRoutes, opts);
|
|
789
|
+
await instance.register(updateRoutes, opts);
|
|
790
|
+
await instance.register(deleteRoutes, opts);
|
|
791
|
+
await instance.register(bulkUpsertRoutes, opts);
|
|
792
|
+
await instance.register(bulkDeleteRoutes, opts);
|
|
793
|
+
}, { prefix: '/admin' });
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
---
|
|
797
|
+
|
|
798
|
+
## FAQ / Gotchas
|
|
799
|
+
|
|
800
|
+
### Schema files `default export` — do I need to register them?
|
|
801
|
+
|
|
802
|
+
No. The generated Schema files export a default Fastify plugin that calls `fastify.addSchema()`. **You do NOT need to register them manually.** The plugin works without it. The default export exists only for advanced use cases where you want `$ref` schema resolution in Swagger. If you don't need it, ignore it.
|
|
803
|
+
|
|
804
|
+
### `sqlapi.config.ts` module warning
|
|
805
|
+
|
|
806
|
+
When running the CLI, Node may show: `Module type of file:///...sqlapi.config.ts is not specified...`. This is harmless — Node detects `export default` and reparses as ESM. To suppress it, ensure your project has `"type": "module"` in `package.json`, or rename the config to `sqlapi.config.mjs`.
|
|
807
|
+
|
|
808
|
+
### TypeBox version conflicts
|
|
809
|
+
|
|
810
|
+
The CLI generates Schema files that import `Type` and `Static` from `fastify-auto-sqlapi` (not from `@sinclair/typebox` directly). This ensures the consumer uses the same TypeBox version bundled with the plugin, avoiding `[Kind]` type errors from duplicate TypeBox installations.
|
|
811
|
+
|
|
812
|
+
### Prefix behavior
|
|
813
|
+
|
|
814
|
+
The `prefix` is a standard Fastify register option. Pass it alongside the plugin options:
|
|
815
|
+
|
|
816
|
+
```typescript
|
|
817
|
+
await app.register(fastifyAutoSqlApi, { DbTables: dbTables, prefix: '/api' });
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
The plugin internally strips `prefix` before passing options to sub-route plugins, so it is applied only once. Without prefix, routes are at root (`/search/customer`, `/rest/customer/:id`, etc.).
|
|
821
|
+
|
|
822
|
+
### Dialect differences
|
|
823
|
+
|
|
824
|
+
| | PostgreSQL | MySQL | MariaDB |
|
|
825
|
+
|---|---|---|---|
|
|
826
|
+
| **Identifier quoting** | `"id"` | `` `id` `` | `` `id` `` |
|
|
827
|
+
| **Placeholders** | `$1, $2` | `?, ?` | `?, ?` |
|
|
828
|
+
| **RETURNING** | Yes | No | Yes (10.5+) |
|
|
829
|
+
| **Upsert syntax** | `ON CONFLICT ... DO UPDATE` | `ON DUPLICATE KEY UPDATE` | `ON DUPLICATE KEY UPDATE` |
|
|
830
|
+
| **Auto-increment PK** | Via `RETURNING` | Via `insertId` | Via `RETURNING` |
|
|
831
|
+
| **CLI env vars** | `POSTGRES_*` | `MYSQL_*` | `MYSQL_*` |
|
|
832
|
+
| **CLI introspection** | `pg` | `mysql2` | `mysql2` |
|
|
833
|
+
|