fastify-auto-sqlapi 0.1.2 → 0.1.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_BACKEND.md +41 -2
- package/AGENTS_FRONTEND.md +7 -5
- package/README.md +105 -5
- package/dist/bin/generate-schema.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/adapters/mysql-adapter.d.ts +4 -0
- package/dist/lib/adapters/mysql-adapter.d.ts.map +1 -1
- package/dist/lib/adapters/mysql-adapter.js +28 -17
- package/dist/lib/adapters/pg-adapter.d.ts +4 -0
- package/dist/lib/adapters/pg-adapter.d.ts.map +1 -1
- package/dist/lib/adapters/pg-adapter.js +18 -7
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +3 -1
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +13 -4
- package/dist/lib/cli/schema-codegen.d.ts +2 -1
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +17 -3
- package/dist/lib/cli/tables-codegen.d.ts +2 -0
- package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
- package/dist/lib/cli/tables-codegen.js +43 -12
- package/dist/lib/db.d.ts +17 -0
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +47 -0
- package/dist/lib/engine/bulk/bulk-delete.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-delete.js +25 -8
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +12 -9
- package/dist/lib/engine/rest/delete.d.ts.map +1 -1
- package/dist/lib/engine/rest/delete.js +9 -5
- 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 +41 -34
- package/dist/lib/engine/rest/update.d.ts.map +1 -1
- package/dist/lib/engine/rest/update.js +74 -57
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +94 -34
- package/dist/lib/schema/bulk-delete.d.ts +1 -1
- package/dist/lib/schema/bulk-delete.d.ts.map +1 -1
- package/dist/lib/schema/bulk-delete.js +2 -2
- package/dist/lib/schema/bulk-upsert.d.ts +1 -1
- package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/schema/bulk-upsert.js +5 -3
- package/dist/lib/schema/helpers.d.ts +2 -0
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +3 -2
- package/dist/lib/schema/insert.d.ts.map +1 -1
- package/dist/lib/schema/insert.js +4 -1
- package/dist/lib/schema/update.d.ts.map +1 -1
- package/dist/lib/schema/update.js +4 -1
- package/dist/lib/sql-api.d.ts +2 -0
- package/dist/lib/sql-api.d.ts.map +1 -1
- package/dist/lib/sql-api.js +1 -0
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +10 -9
- package/dist/lib/tenant.d.ts +24 -2
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +0 -0
- package/dist/routes/auto/bulk-delete.routes.d.ts.map +1 -1
- package/dist/routes/auto/bulk-delete.routes.js +3 -1
- package/dist/routes/auto/bulk-upsert.routes.d.ts.map +1 -1
- package/dist/routes/auto/bulk-upsert.routes.js +3 -1
- package/dist/routes/auto/delete.routes.d.ts.map +1 -1
- package/dist/routes/auto/delete.routes.js +1 -0
- package/dist/routes/auto/get.routes.d.ts.map +1 -1
- package/dist/routes/auto/get.routes.js +1 -0
- package/dist/routes/auto/insert.routes.d.ts.map +1 -1
- package/dist/routes/auto/insert.routes.js +1 -0
- package/dist/routes/auto/route-helpers.d.ts +3 -1
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +3 -0
- package/dist/routes/auto/search.routes.d.ts.map +1 -1
- package/dist/routes/auto/search.routes.js +9 -0
- package/dist/routes/auto/update.routes.d.ts.map +1 -1
- package/dist/routes/auto/update.routes.js +1 -0
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/cli.d.ts.map +1 -1
- package/dist/types/db.d.ts +11 -0
- package/dist/types/db.d.ts.map +1 -1
- package/dist/types/plugin.d.ts +16 -0
- package/dist/types/plugin.d.ts.map +1 -1
- package/dist/types/plugin.js +4 -1
- package/dist/types/schema.d.ts +2 -0
- package/dist/types/schema.d.ts.map +1 -1
- package/dist/types/search.d.ts +7 -0
- package/dist/types/search.d.ts.map +1 -1
- package/dist/types/table.d.ts +40 -2
- package/dist/types/table.d.ts.map +1 -1
- package/package.json +2 -2
package/AGENTS_BACKEND.md
CHANGED
|
@@ -126,6 +126,8 @@ await app.register(fastifyAutoSqlApi, {
|
|
|
126
126
|
prefix: '/auto', // optional — standard Fastify prefix
|
|
127
127
|
onRequests: [], // optional — global auth hooks (run on every route)
|
|
128
128
|
getTenantId: (request) => request.user?.organizationId ?? null, // optional — multi-tenant
|
|
129
|
+
maxItemsPerPage: 1000, // optional — cap on search page size / no-paginator LIMIT (default 1000)
|
|
130
|
+
maxBulkItems: 1000, // optional — cap on bulk array length (default 1000)
|
|
129
131
|
debug: true, // optional — log all SQL queries and params to console
|
|
130
132
|
});
|
|
131
133
|
|
|
@@ -305,6 +307,25 @@ export const TableCustomer = defineTable({
|
|
|
305
307
|
// Mutate fields before UPDATE. camelCase. PK is present for reference but excluded from UPDATE SET.
|
|
306
308
|
fields.updatedBy = req.user.id;
|
|
307
309
|
},
|
|
310
|
+
afterUpdate: async (db, req, record, secondaryRecords, deletionRecords) => {
|
|
311
|
+
// Called after UPDATE + secondaries + deletions, INSIDE the write transaction: throwing rolls back.
|
|
312
|
+
},
|
|
313
|
+
beforeDelete: async (db, req, id) => {
|
|
314
|
+
// Throw to abort the single delete. For tenant-scoped tables runs only after ownership is verified.
|
|
315
|
+
},
|
|
316
|
+
afterDelete: async (db, req, id) => {
|
|
317
|
+
// Called after a successful single delete (not on 404).
|
|
318
|
+
},
|
|
319
|
+
beforeBulkDelete: async (db, req, ids) => {
|
|
320
|
+
// Called ONCE with all ids before a bulk delete. Throw to abort the whole batch.
|
|
321
|
+
},
|
|
322
|
+
afterBulkDelete: async (db, req, deletedIds) => {
|
|
323
|
+
// Called ONCE with the ids ACTUALLY deleted (may be a subset of the requested ids).
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
// OPERATIONS — whitelist of auto-generated HTTP routes for this table.
|
|
327
|
+
// Omitted = ALL operations exposed (default-open!). Does not affect programmatic sqlApi.*.
|
|
328
|
+
// operations: ['search', 'get'],
|
|
308
329
|
|
|
309
330
|
// AUTH — per-table request hooks
|
|
310
331
|
onRequests: [
|
|
@@ -340,6 +361,21 @@ buildRelation(mainSchema, mainField, joinSchema, joinField, options?)
|
|
|
340
361
|
|
|
341
362
|
**Choosing `unique`**: if `joinField` is the PK (or part of the composite PK) of `joinSchema`, the relation is N:1 — you almost certainly want `unique: true` so the alias is usable in `joinLeft`.
|
|
342
363
|
|
|
364
|
+
**Owned child tables (translations, `*_info` details): use a writeJoin, not a standalone table.** A table that only exists as a child of a parent — e.g. `product_info` with composite PK `(product_id, lang)` — should be an `allowedWriteJoins` on the parent, not its own `DbTables` entry. The engine auto-fills the FK (`product_id`); add it to `upsertMap` (conflict key = the composite PK) to upsert children passing only their own fields. Avoids redundant endpoints/validators.
|
|
365
|
+
|
|
366
|
+
```typescript
|
|
367
|
+
// on the parent (product) table:
|
|
368
|
+
allowedWriteJoins: [
|
|
369
|
+
buildRelation(SchemaProduct, 'id', SchemaProductInfo, 'productId', { alias: 'translations' }),
|
|
370
|
+
],
|
|
371
|
+
upsertMap: buildUpsertRules(
|
|
372
|
+
buildUpsertRule(SchemaProductInfo, ['productId', 'lang']), // composite conflict key
|
|
373
|
+
),
|
|
374
|
+
// → PUT /rest/product { "main": {...}, "secondaries": { "translations": [{ "lang": "en", "name": "Bike" }] } }
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Expose a composite-PK table as a standalone CRUD table only when it stands on its own (M:N link tables, natural keys) — composite PKs are fully supported there too.
|
|
378
|
+
|
|
343
379
|
**Aliasing the same table twice**: declare two `buildRelation` entries with different `alias`. Example: a `session` table referencing `user` for both `createdBy` and `updatedBy`:
|
|
344
380
|
|
|
345
381
|
```typescript
|
|
@@ -664,6 +700,7 @@ Behavior:
|
|
|
664
700
|
- **Insert**: auto-injects `organization_id` if single tenant; validates if already present (403 if mismatch); 400 if multi-tenant without explicit value
|
|
665
701
|
- **Update**: strips `organization_id` from SET (can't change tenant), adds to WHERE condition
|
|
666
702
|
- **Bulk upsert**: auto-injects on all records
|
|
703
|
+
- **Upsert conflict guard** (`upsertMap` set): before the `ON CONFLICT DO UPDATE`, a probe rejects (403) any incoming conflict key that matches a row owned by another tenant — an upsert cannot overwrite or re-assign a foreign tenant's row.
|
|
667
704
|
|
|
668
705
|
### Indirect tenant (via JOIN to parent table)
|
|
669
706
|
|
|
@@ -685,7 +722,7 @@ const TableOrder = defineTable({
|
|
|
685
722
|
Behavior:
|
|
686
723
|
- **Read**: INNER JOIN to parent table + WHERE on parent's tenant column
|
|
687
724
|
- **Insert/Bulk upsert**: validates FK references belong to tenant (1 batch query)
|
|
688
|
-
- **Update**: pre-check via SELECT with INNER JOIN (404 if not found)
|
|
725
|
+
- **Update**: pre-check via SELECT with INNER JOIN (404 if not found). If the payload also changes the through-FK (`localField`), the new value is re-validated against the tenant — moving a record into another tenant's scope is rejected (403).
|
|
689
726
|
- **Delete**: subquery `DELETE WHERE pk IN (SELECT ... INNER JOIN ... WHERE tenant IN (...))`
|
|
690
727
|
|
|
691
728
|
### Multi-tenant users
|
|
@@ -724,8 +761,10 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
|
|
|
724
761
|
- **`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.
|
|
725
762
|
- **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.
|
|
726
763
|
- **`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.
|
|
727
|
-
- **All hooks and validators receive camelCase records**: `validate
|
|
764
|
+
- **All hooks and validators receive camelCase records**: `validate` and the whole hook matrix (`beforeInsert`/`afterInsert`, `beforeUpdate`/`afterUpdate`, `beforeDelete`/`afterDelete`, `beforeBulkDelete`/`afterBulkDelete`) 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.
|
|
728
765
|
- **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 } } })`.
|
|
766
|
+
- **Write-body whitelist**: insert/update/bulk-upsert bodies (`main` + secondaries items) are `additionalProperties: false`. Unknown properties are rejected with 400 — only columns in the generated Schema (as narrowed by `schemaOverrides`/`excludeFromCreation`) can be written, so no mass assignment of unexposed columns. Trim the Schema to keep sensitive columns unwritable.
|
|
767
|
+
- **Request limits**: `maxItemsPerPage` (default 1000) caps the search page size and is applied as the `LIMIT` even when no paginator is sent (so an empty-body search can't dump a whole table); over-limit `itemsPerPage` → 400. `maxBulkItems` (default 1000) caps the bulk array length via schema `maxItems` → 400. Programmatic `sqlApi.*` calls are uncapped.
|
|
729
768
|
- **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).
|
|
730
769
|
|
|
731
770
|
---
|
package/AGENTS_FRONTEND.md
CHANGED
|
@@ -35,10 +35,10 @@ Without prefix, routes are at root: `/search/customer`, `/rest/customer/:id`, et
|
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
- `orderBy` — SQL ORDER BY clause (default: `defaultOrder` from table config). Supports dotted notations for joins, see below.
|
|
38
|
-
- `page` + `itemsPerPage` — pagination (when either is present, response includes `pagination` object; `page` defaults to 1, `itemsPerPage` defaults to 500)
|
|
38
|
+
- `page` + `itemsPerPage` — pagination (when either is present, response includes `pagination` object; `page` defaults to 1, `itemsPerPage` defaults to 500). `itemsPerPage` is capped server-side at `maxItemsPerPage` (default 1000); sending a larger value returns 400.
|
|
39
39
|
- `computeMin`, `computeMax`, `computeSum`, `computeAvg` — aggregate a column, returned in `pagination.computed`
|
|
40
40
|
|
|
41
|
-
### Body (all fields optional, empty `{}` returns
|
|
41
|
+
### Body (all fields optional, empty `{}` returns up to `maxItemsPerPage` records)
|
|
42
42
|
|
|
43
43
|
```json
|
|
44
44
|
{
|
|
@@ -350,7 +350,7 @@ Add `page` or `itemsPerPage` to the query string to enable pagination:
|
|
|
350
350
|
?itemsPerPage=10 # page defaults to 1
|
|
351
351
|
```
|
|
352
352
|
|
|
353
|
-
When either is present, the response includes a `pagination` object with total count, total pages, and the paginator values. `page` defaults to 1, `itemsPerPage` defaults to 500.
|
|
353
|
+
When either is present, the response includes a `pagination` object with total count, total pages, and the paginator values. `page` defaults to 1, `itemsPerPage` defaults to 500. `itemsPerPage` is capped at `maxItemsPerPage` (default 1000) — a larger value returns 400. Even without pagination, a search returns at most `maxItemsPerPage` rows.
|
|
354
354
|
|
|
355
355
|
### Computed values (aggregations on main table)
|
|
356
356
|
|
|
@@ -503,6 +503,8 @@ Insert or update multiple records in a single request.
|
|
|
503
503
|
|
|
504
504
|
All main records are inserted/upserted in a single SQL query. Secondaries and deletions are processed per-item. `secondaries`/`deletions` keys are **aliases**.
|
|
505
505
|
|
|
506
|
+
The array is capped at `maxBulkItems` (default 1000); a longer array returns 400 — chunk client-side if you have more.
|
|
507
|
+
|
|
506
508
|
**Response (200):** Array of `{ main (PK-only), secondaries? (PK-only), deletions? }`.
|
|
507
509
|
|
|
508
510
|
---
|
|
@@ -517,7 +519,7 @@ Delete multiple records by PK.
|
|
|
517
519
|
[{ "id": 1 }, { "id": 2 }, { "id": 3 }]
|
|
518
520
|
```
|
|
519
521
|
|
|
520
|
-
Each object must contain the PK field. All deletions execute as a single `DELETE WHERE pk IN (...)`.
|
|
522
|
+
Each object must contain the PK field. All deletions execute as a single `DELETE WHERE pk IN (...)`. The array is capped at `maxBulkItems` (default 1000) — a longer array returns 400.
|
|
521
523
|
|
|
522
524
|
**Response (200):** Array of PK-only objects.
|
|
523
525
|
|
|
@@ -531,7 +533,7 @@ Each object must contain the PK field. All deletions execute as a single `DELETE
|
|
|
531
533
|
|
|
532
534
|
Insert, update, and bulk-upsert endpoints may return **structured field-level validation errors** (400). Two sources:
|
|
533
535
|
|
|
534
|
-
1. **Schema validation** (TypeBox/Ajv) — type mismatches, missing required fields
|
|
536
|
+
1. **Schema validation** (TypeBox/Ajv) — type mismatches, missing required fields, and **unknown properties** (write bodies reject any field not in the table Schema — send only real columns, don't include extra keys)
|
|
535
537
|
2. **Custom validation** (`validate` / `validateBulk` on the table) — business rules, cross-entity checks
|
|
536
538
|
|
|
537
539
|
Both return the same response format:
|
package/README.md
CHANGED
|
@@ -14,7 +14,8 @@ Point it at your database, and get a full REST API with search, advanced conditi
|
|
|
14
14
|
- **Bulk operations** — batch insert/upsert/delete in single queries
|
|
15
15
|
- **Multi-tenant** — automatic row-level isolation, zero code in handlers
|
|
16
16
|
- **Validation** — structured field-level validation with cross-entity support
|
|
17
|
-
- **Hooks** —
|
|
17
|
+
- **Hooks** — full before/after matrix (insert, update, delete, bulk delete) for custom logic
|
|
18
|
+
- **Transactions** — insert/update with secondaries run atomically (rollback on failure)
|
|
18
19
|
- **Swagger UI** — optional, auto-configured from your schemas
|
|
19
20
|
- **Composable** — register all routes or pick only what you need
|
|
20
21
|
|
|
@@ -183,10 +184,16 @@ const TableCustomer = defineTable({
|
|
|
183
184
|
return [];
|
|
184
185
|
},
|
|
185
186
|
|
|
186
|
-
// Hooks (
|
|
187
|
+
// Hooks (run after validation) — all receive camelCase records (schema field names).
|
|
188
|
+
// after* hooks for insert/update run INSIDE the write transaction: throwing rolls back.
|
|
187
189
|
beforeInsert: async (db, req, record) => { /* camelCase; mutations propagate to INSERT */ },
|
|
188
|
-
beforeUpdate: async (db, req, fields) => { /* camelCase; PK included for reference, excluded from UPDATE SET */ },
|
|
189
190
|
afterInsert: async (db, req, record, secondaryRecords) => { /* camelCase; input merged with generated PK */ },
|
|
191
|
+
beforeUpdate: async (db, req, fields) => { /* camelCase; PK included for reference, excluded from UPDATE SET */ },
|
|
192
|
+
afterUpdate: async (db, req, record, secondaryRecords, deletionRecords) => { /* after UPDATE + secondaries + deletions */ },
|
|
193
|
+
beforeDelete: async (db, req, id) => { /* throw to abort the deletion */ },
|
|
194
|
+
afterDelete: async (db, req, id) => { /* after a successful single delete */ },
|
|
195
|
+
beforeBulkDelete: async (db, req, ids) => { /* called ONCE with all ids; throw to abort the batch */ },
|
|
196
|
+
afterBulkDelete: async (db, req, deletedIds) => { /* called ONCE with the ids ACTUALLY deleted */ },
|
|
190
197
|
|
|
191
198
|
// Auth (per-table)
|
|
192
199
|
onRequests: [
|
|
@@ -231,6 +238,8 @@ await app.register(fastifyAutoSqlApi, {
|
|
|
231
238
|
swagger: true, // Optional — enable Swagger UI (or pass SwaggerOptions)
|
|
232
239
|
onRequests: [authMiddleware], // Optional — global hooks applied to every route
|
|
233
240
|
getTenantId: (req) => id, // Optional — multi-tenant function
|
|
241
|
+
maxItemsPerPage: 1000, // Optional — cap on search page size (default: 1000)
|
|
242
|
+
maxBulkItems: 1000, // Optional — cap on bulk array length (default: 1000)
|
|
234
243
|
debug: true, // Optional — log all SQL queries and params
|
|
235
244
|
});
|
|
236
245
|
```
|
|
@@ -243,10 +252,74 @@ await app.register(fastifyAutoSqlApi, {
|
|
|
243
252
|
| `swagger` | `boolean \| SwaggerOptions` | No | Enable Swagger UI |
|
|
244
253
|
| `onRequests` | `Function[]` | No | Global auth/middleware hooks |
|
|
245
254
|
| `getTenantId` | `(req) => id \| null` | No | Tenant resolver for multi-tenant |
|
|
255
|
+
| `maxItemsPerPage` | `number` | No | Max search page size, and the row `LIMIT` applied when no paginator is sent (default: `1000`) |
|
|
256
|
+
| `maxBulkItems` | `number` | No | Max number of items accepted by the bulk endpoints (default: `1000`) |
|
|
246
257
|
| `debug` | `boolean` | No | Log all SQL queries to console |
|
|
247
258
|
|
|
248
259
|
For MySQL/MariaDB, register `mysql2/promise` pool instead of `@fastify/postgres` and pass `dialect: 'mysql'` or `'mariadb'`.
|
|
249
260
|
|
|
261
|
+
## Security
|
|
262
|
+
|
|
263
|
+
> ⚠️ **The plugin is open by default.** Registering it without any configuration exposes
|
|
264
|
+
> **all operations on all tables in `DbTables`** (read AND write, including bulk delete) to
|
|
265
|
+
> anyone who can reach the server. This is intentional — the plugin provides the tools and
|
|
266
|
+
> does not impose an auth model — but it means **you** are responsible for locking it down
|
|
267
|
+
> before exposing it.
|
|
268
|
+
|
|
269
|
+
Three layers are available, combinable:
|
|
270
|
+
|
|
271
|
+
**1. Authentication / authorization hooks** — `onRequests` runs before every auto-generated
|
|
272
|
+
route (globally or per table):
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
await app.register(fastifyAutoSqlApi, {
|
|
276
|
+
DbTables: dbTables,
|
|
277
|
+
onRequests: [async (request, reply) => {
|
|
278
|
+
await request.jwtVerify(); // or any auth check; throw/reply to block
|
|
279
|
+
}],
|
|
280
|
+
});
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**2. Per-table operation whitelist** — `operations` limits which routes are registered for a
|
|
284
|
+
table. Omitted = all operations (default). Unlisted operations are not registered at all
|
|
285
|
+
(they answer 404):
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
const TableAuditLog = defineTable({
|
|
289
|
+
primary: 'id',
|
|
290
|
+
...exportTableInfo(SchemaAuditLog),
|
|
291
|
+
operations: ['search', 'get'], // read-only over HTTP: no insert/update/delete/bulk
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Note: `operations` gates the HTTP routes only. The programmatic `fastify.sqlApi.*` methods
|
|
296
|
+
are always available to your own code.
|
|
297
|
+
|
|
298
|
+
**3. Multi-tenancy** — `getTenantId` + `tenantScope` filter every query by tenant (see
|
|
299
|
+
[Multi-Tenant](#multi-tenant)).
|
|
300
|
+
|
|
301
|
+
### Request limits
|
|
302
|
+
|
|
303
|
+
Two hard caps bound how much work a single request can trigger (defense against accidental or
|
|
304
|
+
malicious resource exhaustion — relevant precisely because the API is open by default):
|
|
305
|
+
|
|
306
|
+
- **Search result size** — `itemsPerPage` above `maxItemsPerPage` (default `1000`) is rejected
|
|
307
|
+
with `400`. When a search omits pagination entirely, the same value is applied as a `LIMIT`, so
|
|
308
|
+
an empty-body `POST /search/:table` can never dump an entire table.
|
|
309
|
+
- **Bulk array size** — the `PUT /bulk/:table` and `POST /bulk/:table/delete` bodies reject arrays
|
|
310
|
+
longer than `maxBulkItems` (default `1000`) at schema validation.
|
|
311
|
+
|
|
312
|
+
Both are configurable in the plugin options. The programmatic `fastify.sqlApi.*` methods are not
|
|
313
|
+
capped — they run your own trusted code.
|
|
314
|
+
|
|
315
|
+
### Write whitelist
|
|
316
|
+
|
|
317
|
+
Insert/update/bulk-upsert bodies reject **unknown properties** (`additionalProperties: false`):
|
|
318
|
+
only columns present in the generated Schema (as narrowed by `schemaOverrides` /
|
|
319
|
+
`excludeFromCreation`) can be written. An unexpected field is a `400`, never a silently-written
|
|
320
|
+
column — the Schema is the write whitelist. Trim the generated Schema (or use `excludeFromCreation`)
|
|
321
|
+
to keep sensitive columns out of it.
|
|
322
|
+
|
|
250
323
|
## API Reference
|
|
251
324
|
|
|
252
325
|
### POST /search/{table}
|
|
@@ -311,6 +384,8 @@ Search with filters, advanced conditions, pagination, ordering, joins, and aggre
|
|
|
311
384
|
|
|
312
385
|
**Querystring** (optional): `orderBy`, `page`, `itemsPerPage`, `computeMin`, `computeMax`, `computeSum`, `computeAvg`
|
|
313
386
|
|
|
387
|
+
> `itemsPerPage` is capped at `maxItemsPerPage` (default `1000`); a larger value returns `400`. A search with no `page`/`itemsPerPage` returns at most that many rows (no full-table dumps). See [Request limits](#request-limits).
|
|
388
|
+
|
|
314
389
|
**Response:**
|
|
315
390
|
|
|
316
391
|
```json
|
|
@@ -356,6 +431,20 @@ Returns `{ main: { ... } }` or 404.
|
|
|
356
431
|
|
|
357
432
|
`secondaries` keys are the **alias** declared in `allowedWriteJoins`. FK fields are auto-filled from the inserted main record.
|
|
358
433
|
|
|
434
|
+
> **Owned child tables (translations, `*_info` details): use a writeJoin, not a standalone table.**
|
|
435
|
+
> A table that only exists as a child of a parent — e.g. `product_info` with composite PK `(product_id, lang)` — should be an `allowedWriteJoins` on the parent, not its own `DbTables` entry. The engine auto-fills the FK (`product_id`); add it to `upsertMap` (conflict key = the composite PK) to upsert children passing only their own fields:
|
|
436
|
+
> ```typescript
|
|
437
|
+
> // on the parent (product) table:
|
|
438
|
+
> allowedWriteJoins: [
|
|
439
|
+
> buildRelation(SchemaProduct, 'id', SchemaProductInfo, 'productId', { alias: 'translations' }),
|
|
440
|
+
> ],
|
|
441
|
+
> upsertMap: buildUpsertRules(
|
|
442
|
+
> buildUpsertRule(SchemaProductInfo, ['productId', 'lang']), // composite conflict key
|
|
443
|
+
> ),
|
|
444
|
+
> // → PUT /rest/product { "main": {...}, "secondaries": { "translations": [{ "lang": "en", "name": "Bike" }] } }
|
|
445
|
+
> ```
|
|
446
|
+
> Expose a composite-PK table as a standalone CRUD table only when it stands on its own (M:N link tables, natural keys) — composite PKs are fully supported there too.
|
|
447
|
+
|
|
359
448
|
**Response (201):** `{ main: { ... }, secondaries: { ... } }`
|
|
360
449
|
|
|
361
450
|
### PUT /rest/{table}
|
|
@@ -440,6 +529,17 @@ defineTable({
|
|
|
440
529
|
|
|
441
530
|
Tables without `tenantScope` are unaffected.
|
|
442
531
|
|
|
532
|
+
### Isolation guarantees on writes
|
|
533
|
+
|
|
534
|
+
Tenant isolation is enforced on write paths, not just reads:
|
|
535
|
+
|
|
536
|
+
- **Upsert conflicts are ownership-checked.** On a tenant-scoped table with an `upsertMap`, an
|
|
537
|
+
upsert (`POST /rest/:table` or `PUT /bulk/:table`) whose conflict key matches a row owned by
|
|
538
|
+
another tenant is rejected with `403` — it cannot overwrite or re-assign that row.
|
|
539
|
+
- **The tenant link cannot be changed to another tenant.** For direct scopes the tenant column is
|
|
540
|
+
stripped from update payloads. For indirect scopes, changing the through-FK (`localField`) is
|
|
541
|
+
allowed only to a value the caller owns; otherwise the update returns `403`.
|
|
542
|
+
|
|
443
543
|
## Swagger
|
|
444
544
|
|
|
445
545
|
Enabled by passing `swagger: true` (or a config object) to the plugin options. Requires `@fastify/swagger` and `@fastify/swagger-ui` as peer dependencies.
|
|
@@ -495,7 +595,7 @@ await app.register(async (instance) => {
|
|
|
495
595
|
|
|
496
596
|
## Conventions
|
|
497
597
|
|
|
498
|
-
- **camelCase everywhere in the API** — requests, responses, `validate`, and all hooks (`beforeInsert`, `beforeUpdate`, `
|
|
598
|
+
- **camelCase everywhere in the API** — requests, responses, `validate`, and all hooks (`beforeInsert`/`afterInsert`, `beforeUpdate`/`afterUpdate`, `beforeDelete`/`afterDelete`, `beforeBulkDelete`/`afterBulkDelete`) use schema field names
|
|
499
599
|
- **Conversion to DB column format is automatic** via `colMap` — supports both snake_case and camelCase DB columns (e.g. betterauth-style)
|
|
500
600
|
- **Aliases identify joins** — declared in `buildRelation({ alias })`, used as keys in request/response/`secondaries`/dotted notation
|
|
501
601
|
- **`joinMustExist` / `joinMultiple` / `joinGroup`** are 1:N (child→main) and use side queries / EXISTS / correlated subqueries — no row duplication
|
|
@@ -549,7 +649,7 @@ After registering the plugin, `app.sqlApi` is decorated on the Fastify instance
|
|
|
549
649
|
## Requirements
|
|
550
650
|
|
|
551
651
|
- Node.js >= 18
|
|
552
|
-
- Fastify >=
|
|
652
|
+
- Fastify >= 5 (peer dependency)
|
|
553
653
|
- One of:
|
|
554
654
|
- **PostgreSQL** + `pg` + `@fastify/postgres`
|
|
555
655
|
- **MySQL** / **MariaDB** + `mysql2`
|
|
@@ -54,10 +54,10 @@ await runCli('fastify-auto-sqlapi: generating schemas', async () => {
|
|
|
54
54
|
let untouched = 0;
|
|
55
55
|
const generatedFiles = new Set();
|
|
56
56
|
for (const schemaName of Object.keys(tableMap)) {
|
|
57
|
-
const { name: tableName, fields, colMap } = tableMap[schemaName];
|
|
57
|
+
const { name: tableName, fields, colMap, primary } = tableMap[schemaName];
|
|
58
58
|
const filename = path.join(schemasDir, `${schemaName}.ts`);
|
|
59
59
|
generatedFiles.add(`${schemaName}.ts`);
|
|
60
|
-
const content = generateSchemaFile(schemaName, tableName, fields, colMap);
|
|
60
|
+
const content = generateSchemaFile(schemaName, tableName, fields, colMap, primary);
|
|
61
61
|
let status = 'created';
|
|
62
62
|
let color = CONSOLE_COLORS.green;
|
|
63
63
|
if (fs.existsSync(filename)) {
|
package/dist/index.d.ts
CHANGED
|
@@ -30,5 +30,5 @@ export { BulkDeleteTableBody, BulkDeleteTableResponse } from './lib/schema/bulk-
|
|
|
30
30
|
export { default as bulkDeleteRoutes } from './routes/auto/bulk-delete.routes.js';
|
|
31
31
|
export { Type, type Static } from '@sinclair/typebox';
|
|
32
32
|
export { ConditionBuilder, Expression } from 'node-condition-builder';
|
|
33
|
-
export type { SqlApiConfig, ColumnInfo, TableMap, Queryable, SqlResult, DbRecord, DbRecordValue, SelectOptions, SchemaDefinition, JoinDefinition, ITable, DbTables, SqlApiPluginOptions, SwaggerOptions, SearchParams, SearchResult, Paginator, PaginationResult, TableFilterFn, ExtendedConditionFn, FilterRecord, AggregationRequest, JoinGroupRequest, ComputedFieldFn, ComputedFieldContext, ComputedFieldExpr, GetParams, GetResult, DeleteParams, DeleteResult, InsertParams, InsertResult, BulkUpsertItem, BulkUpsertParams, BulkUpsertResult, BulkDeleteParams, BulkDeleteResult, ConditionMethod, SearchCondition, UpdateParams, UpdateResult, TenantId, TenantScope, TenantScopeDirect, TenantScopeIndirect, TenantContext, ValidationError, ValidatorFn, BulkValidatorItem, BulkValidatorFn, } from './types.js';
|
|
33
|
+
export type { SqlApiConfig, ColumnInfo, TableMap, Queryable, TransactionQueryable, SqlResult, DbRecord, DbRecordValue, SelectOptions, SchemaDefinition, JoinDefinition, ITable, TableOperation, DbTables, SqlApiPluginOptions, SwaggerOptions, SearchParams, SearchResult, Paginator, PaginationResult, TableFilterFn, ExtendedConditionFn, FilterRecord, AggregationRequest, JoinGroupRequest, ComputedFieldFn, ComputedFieldContext, ComputedFieldExpr, GetParams, GetResult, DeleteParams, DeleteResult, InsertParams, InsertResult, BulkUpsertItem, BulkUpsertParams, BulkUpsertResult, BulkDeleteParams, BulkDeleteResult, ConditionMethod, SearchCondition, UpdateParams, UpdateResult, TenantId, TenantScope, TenantScopeDirect, TenantScopeIndirect, TenantContext, ValidationError, ValidatorFn, BulkValidatorItem, BulkValidatorFn, } from './types.js';
|
|
34
34
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,WAAW,EACX,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,eAAe,GAChB,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAG1E,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAE,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAGjF,OAAO,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAGjE,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,aAAa,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAGxH,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAGhD,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACxD,YAAY,EAAE,aAAa,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAIlH,OAAO,EAAE,OAAO,IAAI,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAGvE,OAAO,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAGtD,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAC7D,OAAO,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAC1G,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAGzE,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,EAAE,eAAe,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAC9E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAGzE,OAAO,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AACrD,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAGnE,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAGzE,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,EAAE,eAAe,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAC9E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAGzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AACpE,OAAO,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,6BAA6B,CAAC;AAC3F,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,qCAAqC,CAAC;AAGlF,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AACpE,OAAO,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,6BAA6B,CAAC;AAC3F,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,qCAAqC,CAAC;AAGlF,OAAO,EAAE,IAAI,EAAE,KAAK,MAAM,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAGtE,YAAY,EACV,YAAY,EACZ,UAAU,EACV,QAAQ,EACR,SAAS,EACT,SAAS,EACT,QAAQ,EACR,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,cAAc,EACd,MAAM,EACN,QAAQ,EACR,mBAAmB,EACnB,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,SAAS,EACT,gBAAgB,EAChB,aAAa,EACb,mBAAmB,EACnB,YAAY,EACZ,kBAAkB,EAClB,gBAAgB,EAChB,eAAe,EACf,oBAAoB,EACpB,iBAAiB,EACjB,SAAS,EACT,SAAS,EACT,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,cAAc,EACd,gBAAgB,EAChB,gBAAgB,EAChB,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,EACf,eAAe,EACf,YAAY,EACZ,YAAY,EACZ,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,mBAAmB,EACnB,aAAa,EACb,eAAe,EACf,WAAW,EACX,iBAAiB,EACjB,eAAe,GAChB,MAAM,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -4,6 +4,10 @@ export interface MysqlQueryable {
|
|
|
4
4
|
affectedRows?: number;
|
|
5
5
|
insertId?: number;
|
|
6
6
|
}, unknown]>;
|
|
7
|
+
/** Present on mysql2 promise pools (and @fastify/mysql): checks out a dedicated connection. */
|
|
8
|
+
getConnection?(): Promise<MysqlQueryable & {
|
|
9
|
+
release(): void;
|
|
10
|
+
}>;
|
|
7
11
|
}
|
|
8
12
|
export declare function mysqlQueryable(pool: MysqlQueryable): Queryable;
|
|
9
13
|
//# sourceMappingURL=mysql-adapter.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mysql-adapter.d.ts","sourceRoot":"","sources":["../../../src/lib/adapters/mysql-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,
|
|
1
|
+
{"version":3,"file":"mysql-adapter.d.ts","sourceRoot":"","sources":["../../../src/lib/adapters/mysql-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAmC,MAAM,gBAAgB,CAAC;AAEjF,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,OAAO,EAAE,GACjB,OAAO,CAAC,CAAC,CAAC,EAAE,GAAG;QAAE,YAAY,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,OAAO,CAAC,CAAC,CAAC;IAC1E,+FAA+F;IAC/F,aAAa,CAAC,IAAI,OAAO,CAAC,cAAc,GAAG;QAAE,OAAO,IAAI,IAAI,CAAA;KAAE,CAAC,CAAC;CACjE;AA2BD,wBAAgB,cAAc,CAAC,IAAI,EAAE,cAAc,GAAG,SAAS,CAc9D"}
|
|
@@ -1,21 +1,32 @@
|
|
|
1
|
-
|
|
2
|
-
return {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
if (Array.isArray(result) && !('affectedRows' in result)) {
|
|
7
|
-
return {
|
|
8
|
-
rows: result,
|
|
9
|
-
affectedRows: result.length,
|
|
10
|
-
};
|
|
11
|
-
}
|
|
12
|
-
// INSERT/UPDATE/DELETE returns ResultSetHeader-like
|
|
13
|
-
const header = result;
|
|
1
|
+
function wrapQuery(client) {
|
|
2
|
+
return async (text, values) => {
|
|
3
|
+
const [result] = await client.query(text, values);
|
|
4
|
+
// SELECT returns an array of rows
|
|
5
|
+
if (Array.isArray(result) && !('affectedRows' in result)) {
|
|
14
6
|
return {
|
|
15
|
-
rows:
|
|
16
|
-
affectedRows:
|
|
17
|
-
insertId: header.insertId,
|
|
7
|
+
rows: result,
|
|
8
|
+
affectedRows: result.length,
|
|
18
9
|
};
|
|
19
|
-
}
|
|
10
|
+
}
|
|
11
|
+
// INSERT/UPDATE/DELETE returns ResultSetHeader-like
|
|
12
|
+
const header = result;
|
|
13
|
+
return {
|
|
14
|
+
rows: [],
|
|
15
|
+
affectedRows: header.affectedRows ?? 0,
|
|
16
|
+
insertId: header.insertId,
|
|
17
|
+
};
|
|
20
18
|
};
|
|
21
19
|
}
|
|
20
|
+
export function mysqlQueryable(pool) {
|
|
21
|
+
const queryable = { query: wrapQuery(pool) };
|
|
22
|
+
if (typeof pool.getConnection === 'function') {
|
|
23
|
+
queryable.connect = async () => {
|
|
24
|
+
const conn = await pool.getConnection();
|
|
25
|
+
return {
|
|
26
|
+
query: wrapQuery(conn),
|
|
27
|
+
release: () => conn.release(),
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return queryable;
|
|
32
|
+
}
|
|
@@ -4,6 +4,10 @@ export interface PgQueryable {
|
|
|
4
4
|
rows: T[];
|
|
5
5
|
rowCount: number | null;
|
|
6
6
|
}>;
|
|
7
|
+
/** Present on pg.Pool (and @fastify/postgres): checks out a dedicated client. */
|
|
8
|
+
connect?(): Promise<PgQueryable & {
|
|
9
|
+
release(): void;
|
|
10
|
+
}>;
|
|
7
11
|
}
|
|
8
12
|
export declare function pgQueryable(client: PgQueryable): Queryable;
|
|
9
13
|
//# sourceMappingURL=pg-adapter.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pg-adapter.d.ts","sourceRoot":"","sources":["../../../src/lib/adapters/pg-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,
|
|
1
|
+
{"version":3,"file":"pg-adapter.d.ts","sourceRoot":"","sources":["../../../src/lib/adapters/pg-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAmC,MAAM,gBAAgB,CAAC;AAEjF,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,OAAO,EAAE,GACjB,OAAO,CAAC;QAAE,IAAI,EAAE,CAAC,EAAE,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CAAC;IACnD,iFAAiF;IACjF,OAAO,CAAC,IAAI,OAAO,CAAC,WAAW,GAAG;QAAE,OAAO,IAAI,IAAI,CAAA;KAAE,CAAC,CAAC;CACxD;AAeD,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,CAc1D"}
|
|
@@ -1,11 +1,22 @@
|
|
|
1
|
+
function wrapQuery(client) {
|
|
2
|
+
return async (text, values) => {
|
|
3
|
+
const result = await client.query(text, values);
|
|
4
|
+
return {
|
|
5
|
+
rows: result.rows,
|
|
6
|
+
affectedRows: result.rowCount ?? 0,
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
}
|
|
1
10
|
export function pgQueryable(client) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
11
|
+
const queryable = { query: wrapQuery(client) };
|
|
12
|
+
if (typeof client.connect === 'function') {
|
|
13
|
+
queryable.connect = async () => {
|
|
14
|
+
const conn = await client.connect();
|
|
5
15
|
return {
|
|
6
|
-
|
|
7
|
-
|
|
16
|
+
query: wrapQuery(conn),
|
|
17
|
+
release: () => conn.release(),
|
|
8
18
|
};
|
|
9
|
-
}
|
|
10
|
-
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return queryable;
|
|
11
22
|
}
|
|
@@ -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,
|
|
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,CA2BvB"}
|
|
@@ -70,7 +70,7 @@ export async function introspectMysqlTables(connectionConfig, schema) {
|
|
|
70
70
|
const mysql2 = loadOptionalDependency('mysql2/promise', 'npm install mysql2');
|
|
71
71
|
const connection = await mysql2.createConnection(connectionConfig);
|
|
72
72
|
try {
|
|
73
|
-
const [rows] = await connection.query(`SELECT table_name, column_name, data_type, column_default, is_nullable
|
|
73
|
+
const [rows] = await connection.query(`SELECT table_name, column_name, data_type, column_default, is_nullable, column_key, extra
|
|
74
74
|
FROM information_schema.columns
|
|
75
75
|
WHERE table_schema = ?
|
|
76
76
|
ORDER BY table_name, ordinal_position`, [schema]);
|
|
@@ -81,6 +81,8 @@ export async function introspectMysqlTables(connectionConfig, schema) {
|
|
|
81
81
|
udt_name: mapMysqlType(row.DATA_TYPE || row.data_type),
|
|
82
82
|
column_default: row.COLUMN_DEFAULT || row.column_default,
|
|
83
83
|
is_nullable: row.IS_NULLABLE || row.is_nullable,
|
|
84
|
+
is_primary: (row.COLUMN_KEY || row.column_key) === 'PRI',
|
|
85
|
+
is_auto_increment: String(row.EXTRA || row.extra || '').includes('auto_increment'),
|
|
84
86
|
}));
|
|
85
87
|
}
|
|
86
88
|
finally {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pg-introspect.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/pg-introspect.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,wBAAgB,qBAAqB,IAAI,MAAM,CAY9C;AAED,wBAAsB,gBAAgB,CACpC,gBAAgB,EAAE,MAAM,EACxB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,EAAE,CAAC,
|
|
1
|
+
{"version":3,"file":"pg-introspect.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/pg-introspect.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,wBAAgB,qBAAqB,IAAI,MAAM,CAY9C;AAED,wBAAsB,gBAAgB,CACpC,gBAAgB,EAAE,MAAM,EACxB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,EAAE,CAAC,CA8BvB"}
|
|
@@ -16,10 +16,19 @@ export async function introspectTables(connectionString, schema) {
|
|
|
16
16
|
const client = new pg.Client({ connectionString });
|
|
17
17
|
try {
|
|
18
18
|
await client.connect();
|
|
19
|
-
const result = await client.query(`SELECT table_name, column_name, udt_name, column_default, is_nullable
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
const result = await client.query(`SELECT c.table_name, c.column_name, c.udt_name, c.column_default, c.is_nullable,
|
|
20
|
+
(pk.column_name IS NOT NULL) AS is_primary
|
|
21
|
+
FROM information_schema.columns c
|
|
22
|
+
LEFT JOIN (
|
|
23
|
+
SELECT kcu.table_name, kcu.column_name
|
|
24
|
+
FROM information_schema.table_constraints tc
|
|
25
|
+
JOIN information_schema.key_column_usage kcu
|
|
26
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
27
|
+
AND tc.table_schema = kcu.table_schema
|
|
28
|
+
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1
|
|
29
|
+
) pk ON pk.table_name = c.table_name AND pk.column_name = c.column_name
|
|
30
|
+
WHERE c.table_schema = $1
|
|
31
|
+
ORDER BY c.table_name, c.ordinal_position`, [schema]);
|
|
23
32
|
return result.rows;
|
|
24
33
|
}
|
|
25
34
|
finally {
|
|
@@ -2,7 +2,8 @@ import type { ColumnInfo, TableMap } from '../../types.js';
|
|
|
2
2
|
export declare function convertColType(udtName: string, columnInfo: {
|
|
3
3
|
column_default: string | null;
|
|
4
4
|
is_nullable: string;
|
|
5
|
+
is_auto_increment?: boolean;
|
|
5
6
|
}): string;
|
|
6
7
|
export declare function buildTableMap(rows: ColumnInfo[]): TableMap;
|
|
7
|
-
export declare function generateSchemaFile(schemaName: string, tableName: string, fields: Record<string, string>, colMap: Record<string, string
|
|
8
|
+
export declare function generateSchemaFile(schemaName: string, tableName: string, fields: Record<string, string>, colMap: Record<string, string>, primary?: string[]): string;
|
|
8
9
|
//# 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,
|
|
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,CAAC;IAAC,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAAE,GAC9F,MAAM,CAqDR;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,QAAQ,CAqC1D;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,EAC9B,OAAO,CAAC,EAAE,MAAM,EAAE,GACjB,MAAM,CA4CR"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { toCamelCase, toSchemaName } from '../naming.js';
|
|
2
2
|
export function convertColType(udtName, columnInfo) {
|
|
3
|
-
|
|
3
|
+
// AUTO_INCREMENT (mysql) behaves like a default: the DB generates the value.
|
|
4
|
+
const hasDefault = columnInfo.column_default || columnInfo.is_auto_increment;
|
|
4
5
|
const isNullable = columnInfo.is_nullable === 'YES';
|
|
5
6
|
const wrap = (t) => {
|
|
6
7
|
return hasDefault || isNullable ? `Type.Optional(${t})` : t;
|
|
@@ -58,17 +59,30 @@ export function buildTableMap(rows) {
|
|
|
58
59
|
name: row.table_name,
|
|
59
60
|
fields: {},
|
|
60
61
|
colMap: {},
|
|
62
|
+
primary: [],
|
|
61
63
|
};
|
|
62
64
|
}
|
|
63
65
|
const fieldName = toCamelCase(row.column_name);
|
|
66
|
+
// Two columns mapping to the same camelCase field (e.g. user_name + userName) would
|
|
67
|
+
// silently overwrite each other in the colMap, dropping one column from the API.
|
|
68
|
+
const existing = acc[schemaName].colMap[fieldName];
|
|
69
|
+
if (existing !== undefined && existing !== row.column_name) {
|
|
70
|
+
throw new Error(`Naming collision on table "${row.table_name}": columns "${existing}" and ` +
|
|
71
|
+
`"${row.column_name}" both map to the API field "${fieldName}". ` +
|
|
72
|
+
`Rename one of the columns (or exclude the table) before generating schemas.`);
|
|
73
|
+
}
|
|
64
74
|
acc[schemaName].fields[fieldName] = convertColType(row.udt_name, row);
|
|
65
75
|
acc[schemaName].colMap[fieldName] = row.column_name;
|
|
76
|
+
if (row.is_primary) {
|
|
77
|
+
acc[schemaName].primary.push(fieldName);
|
|
78
|
+
}
|
|
66
79
|
return acc;
|
|
67
80
|
}, {});
|
|
68
81
|
}
|
|
69
|
-
export function generateSchemaFile(schemaName, tableName, fields, colMap) {
|
|
82
|
+
export function generateSchemaFile(schemaName, tableName, fields, colMap, primary) {
|
|
70
83
|
const fieldsStr = JSON.stringify(fields, null, 2).replace(/"(.*?)"\s*:\s*"Type(.*?)"/g, '$1: Type$2');
|
|
71
84
|
const colMapStr = JSON.stringify(colMap, null, 2);
|
|
85
|
+
const primaryKeyLine = primary?.length ? `\n primaryKey: ${JSON.stringify(primary)},` : '';
|
|
72
86
|
return `// Auto-generated by fastify-auto-sqlapi. Do not edit.
|
|
73
87
|
import { Type } from "fastify-auto-sqlapi";
|
|
74
88
|
import type { Static } from "fastify-auto-sqlapi";
|
|
@@ -94,7 +108,7 @@ export const Schema = {
|
|
|
94
108
|
fields: _Schema,
|
|
95
109
|
validation,
|
|
96
110
|
tableName: "${tableName}",
|
|
97
|
-
partialValidation
|
|
111
|
+
partialValidation,${primaryKeyLine}
|
|
98
112
|
};
|
|
99
113
|
export const ${schemaName} = Schema;
|
|
100
114
|
export type TypeSchema = Static<typeof Schema.validation>;
|
|
@@ -3,6 +3,8 @@ export interface ParsedSchema {
|
|
|
3
3
|
tableName: string;
|
|
4
4
|
fields: string[];
|
|
5
5
|
fieldTypes: Record<string, string>;
|
|
6
|
+
/** PRIMARY KEY fields declared in the schema file (`primaryKey: [...]`), when present. */
|
|
7
|
+
primary?: string[];
|
|
6
8
|
}
|
|
7
9
|
export interface DetectedRelation {
|
|
8
10
|
parentSchemaName: string;
|
|
@@ -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;
|
|
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;IACnC,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;CACpB;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,CAuCpE;AAuCD,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"}
|