fastify-auto-sqlapi 0.2.4 → 0.4.0
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 +24 -111
- package/AGENTS_BACKEND.md +282 -1332
- package/AGENTS_FRONTEND.md +70 -47
- package/BREAKING_CHANGES.md +131 -0
- package/CHANGELOG.md +139 -0
- package/README.md +1 -1
- package/dist/bin/generate-schema.js +2 -2
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +24 -1
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +21 -2
- 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 +31 -3
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +187 -60
- package/dist/lib/dialect.d.ts +15 -10
- package/dist/lib/dialect.d.ts.map +1 -1
- package/dist/lib/dialect.js +12 -31
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +2 -1
- package/dist/lib/engine/rest/insert.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.js +2 -1
- package/dist/lib/engine/write-helpers.d.ts +13 -1
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +35 -0
- package/dist/lib/schema/helpers.d.ts +7 -0
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +18 -0
- package/dist/lib/schema/insert.d.ts.map +1 -1
- package/dist/lib/schema/insert.js +3 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +41 -0
- package/dist/lib/tenant.d.ts +6 -0
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +16 -0
- package/dist/routes/auto/bulk-delete.routes.js +1 -1
- package/dist/routes/auto/bulk-upsert.routes.js +1 -1
- package/dist/routes/auto/delete.routes.js +1 -1
- package/dist/routes/auto/get.routes.js +1 -1
- package/dist/routes/auto/insert.routes.js +1 -1
- package/dist/routes/auto/route-helpers.d.ts +5 -2
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +1 -1
- package/dist/routes/auto/search.routes.js +3 -3
- package/dist/routes/auto/update.routes.js +1 -1
- package/dist/types/cli.d.ts +11 -0
- package/dist/types/cli.d.ts.map +1 -1
- package/dist/types/schema.d.ts +7 -0
- package/dist/types/schema.d.ts.map +1 -1
- package/docs/adr/0020-no-conditional-or-mass-update-route.md +68 -0
- package/docs/adr/0021-upsert-targets-a-verified-unique-key.md +153 -0
- package/docs/adr/README.md +2 -0
- package/package.json +4 -4
package/AGENTS_FRONTEND.md
CHANGED
|
@@ -1,111 +1,134 @@
|
|
|
1
1
|
# fastify-auto-sqlapi — client API (LLM reference)
|
|
2
2
|
|
|
3
|
-
This
|
|
4
|
-
vocabulary), fetch `GET {p}/agent/manifest.md` if available, or consult Swagger.
|
|
3
|
+
This is the request GRAMMAR. The vocabulary (this deployment's tables, fields, join aliases, computed fields, extra filters) comes from `GET {p}/agent/manifest.md` when enabled, or from Swagger.
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
- All request/response fields are camelCase schema field names; the server maps them to columns.
|
|
6
|
+
- `{p}` = deployment prefix (e.g. `/api`). `{table}` = the table's key in the manifest, which may differ from the DB table name (one table can be exposed under several keys, each with its own rules).
|
|
7
|
+
- Join keys are backend-declared ALIASES (default: the joined table name).
|
|
8
|
+
- Write responses return PKs only. Send only real schema fields in write bodies: unknown ones are dropped or rejected (400), depending on the server.
|
|
9
|
+
- Composite-PK tables have no get/delete/bulk-delete routes (404): use search / update.
|
|
7
10
|
|
|
8
11
|
## Endpoints
|
|
9
12
|
|
|
10
13
|
```
|
|
11
|
-
POST
|
|
12
|
-
GET
|
|
13
|
-
POST
|
|
14
|
-
PUT
|
|
15
|
-
DELETE {p}/rest/{table}/:id
|
|
16
|
-
PUT
|
|
17
|
-
POST
|
|
14
|
+
POST {p}/search/{table} body=search request → {table, main:[...], ...}
|
|
15
|
+
GET {p}/rest/{table}/:id → {main:{...}} | 404
|
|
16
|
+
POST {p}/rest/{table} body={main, secondaries?} → 201 {main:{id}, secondaries?}
|
|
17
|
+
PUT {p}/rest/{table} body={main(+PK), secondaries?, deletions?} → {main:{id}, secondaries?, deletions?}
|
|
18
|
+
DELETE {p}/rest/{table}/:id → {main:{id}} | 404
|
|
19
|
+
PUT {p}/bulk/{table} body=[{main, secondaries?, deletions?}, ...] → [{main:{id}}, ...]
|
|
20
|
+
POST {p}/bulk/{table}/delete body=[{id:1}, {id:2}] → [{main:{id:1}}, ...] // key = the table's PK field
|
|
18
21
|
```
|
|
19
22
|
|
|
20
|
-
##
|
|
23
|
+
## Search
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
Body (all optional; `{}` = first N rows). Everything below is combinable in one request:
|
|
25
|
+
Querystring, all optional: `orderBy`, `page`, `itemsPerPage`, `computeMin|Max|Sum|Avg=<field>`. Body, all optional and combinable; `{}` returns the first N rows.
|
|
24
26
|
|
|
25
27
|
```jsonc
|
|
26
28
|
// POST {p}/search/customer?orderBy=orders.sum.total DESC, creator.name ASC, id&page=1&itemsPerPage=20&computeSum=total
|
|
27
29
|
{
|
|
28
|
-
"filters": { "status": "active", "deletedById": null },
|
|
29
|
-
"conditions": [
|
|
30
|
+
"filters": { "status": "active", "deletedById": null }, // equality, AND; null → IS NULL; also computed fields and backend extra filters (see manifest). Unknown key → 400
|
|
31
|
+
"conditions": [ // AND; the same field may repeat
|
|
30
32
|
{ "field": "total", "method": "isBetween", "params": [100, 500] },
|
|
31
33
|
{ "field": "name", "method": "isILike", "params": ["%mario%"] },
|
|
32
34
|
{ "field": "status", "method": "isIn", "params": [["active", "pending"]] },
|
|
33
|
-
{ "field": "orders.count.id", "method": "isGreaterOrEqual", "params": [4] }
|
|
35
|
+
{ "field": "orders.count.id", "method": "isGreaterOrEqual", "params": [4] } // alias.fn.field, HAVING-style: needs the matching joinGroup below
|
|
34
36
|
],
|
|
35
|
-
"selectComputed": ["displayName"],
|
|
36
|
-
"joinMustExist": {
|
|
37
|
+
"selectComputed": ["displayName"], // computed fields are projected into main rows only when listed
|
|
38
|
+
"joinMustExist": { // EXISTS on children: decides WHICH main rows return; fetches nothing
|
|
37
39
|
"labels": { "filters": { "labelId": 1 }, "conditions": [] }
|
|
38
40
|
},
|
|
39
|
-
"joinMultiple": {
|
|
40
|
-
"orders": { "filters": { "status": "pending" }, "selection": "id,total,status" }
|
|
41
|
+
"joinMultiple": { // side query: child rows of the returned main rows
|
|
42
|
+
"orders": { "filters": { "status": "pending" }, "conditions": [], "selection": "id,total,status" }
|
|
41
43
|
},
|
|
42
|
-
"joinGroup": {
|
|
44
|
+
"joinGroup": { // aggregates child rows of the returned main rows
|
|
43
45
|
"orders": {
|
|
44
|
-
"aggregations": {
|
|
45
|
-
|
|
46
|
-
"sum": ["total"], "min": ["total"], "max": ["total"], "avg": ["total"],
|
|
47
|
-
"count": ["id"], "distinctCount": ["status"]
|
|
48
|
-
},
|
|
49
|
-
"filters": { "status": "active" } // narrows child rows before aggregating
|
|
46
|
+
"aggregations": { "by": "status", "sum": ["total"], "min": ["total"], "max": ["total"], "avg": ["total"], "count": ["id"], "distinctCount": ["status"] }, // by: optional GROUP BY (field or computed)
|
|
47
|
+
"filters": { "status": "active" }, "conditions": [] // narrow children before aggregating
|
|
50
48
|
}
|
|
51
49
|
},
|
|
52
|
-
"joinLeft": {
|
|
53
|
-
"creator": { "selection": "id,name" }
|
|
50
|
+
"joinLeft": { // N:1 parent (alias declared unique by the backend)
|
|
51
|
+
"creator": { "selection": "id,name", "filters": {}, "conditions": [] } // filters here EXCLUDE mains whose parent does not match; no extra filters here
|
|
54
52
|
}
|
|
55
53
|
}
|
|
56
54
|
```
|
|
57
55
|
|
|
58
|
-
Response
|
|
56
|
+
Response; each section is present only if requested:
|
|
59
57
|
|
|
60
58
|
```jsonc
|
|
61
59
|
{
|
|
62
60
|
"table": "customer",
|
|
63
61
|
"main": [{ "id": 1, "name": "Mario", "creatorId": 7, "displayName": "..." }],
|
|
64
|
-
"joinMultiple": { "orders": [{ "id": 10, "customerId": 1, "total": 50 }] },
|
|
62
|
+
"joinMultiple": { "orders": [{ "id": 10, "customerId": 1, "total": 50 }] }, // flat list for all mains: group client-side by FK
|
|
65
63
|
"joinGroup": { "orders": {
|
|
66
|
-
"sum": { "total": 150 },
|
|
67
|
-
"rows": [{ "by": "pending", "sum_total": 100 }]
|
|
64
|
+
"sum": { "total": 150 }, // no "by": ONE total over the children of ALL returned mains
|
|
65
|
+
"rows": [{ "by": "pending", "sum_total": 100 }] // with "by": one row per group; by = FK field gives per-main values
|
|
68
66
|
}},
|
|
69
|
-
"joinLeft": { "creator": [{ "id": 7, "name": "Alice" }] },
|
|
70
|
-
"pagination": {
|
|
67
|
+
"joinLeft": { "creator": [{ "id": 7, "name": "Alice" }] }, // deduplicated: match main.creatorId === creator.id
|
|
68
|
+
"pagination": { // only when page or itemsPerPage is sent
|
|
71
69
|
"total": 25, "pages": 3,
|
|
72
|
-
"computed": { "sum": { "total": 999 } },
|
|
70
|
+
"computed": { "sum": { "total": 999 } }, // computeMin/Max/Sum/Avg over all matching rows; require pagination
|
|
73
71
|
"paginator": { "page": 1, "itemsPerPage": 20 }
|
|
74
72
|
}
|
|
75
73
|
}
|
|
76
74
|
```
|
|
77
75
|
|
|
78
|
-
|
|
76
|
+
Pagination:
|
|
77
|
+
- Without `page`/`itemsPerPage`, the server caps rows at its maximum (default 1000) and returns no `pagination` block.
|
|
78
|
+
- With `page` only, `itemsPerPage` defaults to 500 (or the server maximum if lower).
|
|
79
|
+
- Both are integers ≥ 1. `itemsPerPage` above the server maximum → 400.
|
|
79
80
|
|
|
80
|
-
|
|
81
|
+
`orderBy` is a comma-separated list, each part one of:
|
|
82
|
+
- `field [ASC|DESC]`, including computed fields.
|
|
83
|
+
- `alias.parentField`: joinLeft alias; adds the LEFT JOIN.
|
|
84
|
+
- `alias.fn.field`, with fn one of `sum|min|max|avg|count|distinctCount`. The joinGroup with that fn and field must be in the body; mains without children sort as 0.
|
|
81
85
|
|
|
82
|
-
|
|
86
|
+
For `alias.fn.field`, in both `orderBy` and conditions, the joinGroup `by` must be absent or equal to the FK (else 400). In `orderBy` it is also a 400 on tables with distinct results.
|
|
87
|
+
|
|
88
|
+
Condition methods and `params` arity:
|
|
89
|
+
- `[v]`: `isEqual|isNotEqual|isGreater|isGreaterOrEqual|isLess|isLessOrEqual|isNotGreater|isNotGreaterOrEqual|isNotLess|isNotLessOrEqual`
|
|
90
|
+
- `["%p%"]`: `isLike|isNotLike|isILike|isNotILike`
|
|
91
|
+
- `[from, to]`: `isBetween|isNotBetween`
|
|
92
|
+
- `[[v1, v2]]`: `isIn|isNotIn`
|
|
93
|
+
- `[]`: `isNull|isNotNull`
|
|
94
|
+
|
|
95
|
+
An unknown field or method, or `params` that is not an array or is too short → 400.
|
|
96
|
+
|
|
97
|
+
## Writes
|
|
83
98
|
|
|
84
99
|
```jsonc
|
|
85
|
-
// POST {p}/rest/customer
|
|
100
|
+
// POST {p}/rest/customer — insert; omit auto-generated PKs
|
|
86
101
|
{ "main": { "name": "Mario", "email": "m@t.it" },
|
|
87
|
-
"secondaries": { "orders": [{ "total": 50, "status": "pending" }] } }
|
|
102
|
+
"secondaries": { "orders": [{ "total": 50, "status": "pending" }] } } // child FK auto-filled from the new main PK
|
|
88
103
|
// → 201 { "main": { "id": 1 }, "secondaries": { "orders": [{ "id": 10 }] } }
|
|
89
104
|
|
|
90
|
-
// PUT {p}/rest/customer
|
|
105
|
+
// PUT {p}/rest/customer — update; main MUST include the PK; send only the fields to change; null sets NULL
|
|
91
106
|
{ "main": { "id": 1, "email": "new@t.it" },
|
|
92
|
-
"secondaries": { "orders": [{ "total": 75 }] },
|
|
93
|
-
"deletions":
|
|
107
|
+
"secondaries": { "orders": [{ "total": 75 }] }, // insert (or upsert) children, FK auto-filled
|
|
108
|
+
"deletions": { "orders": [{ "id": 10 }] } } // delete children by PK, only children of this main
|
|
94
109
|
// → 200 { "main": { "id": 1 }, "secondaries": { "orders": [{ "id": 20 }] }, "deletions": { "orders": [{ "id": 10 }] } }
|
|
95
110
|
|
|
96
|
-
// PUT {p}/bulk/customer
|
|
111
|
+
// PUT {p}/bulk/customer — bulk upsert; items need not share keys: an omitted field keeps the stored value (update) or takes the column default (insert)
|
|
97
112
|
[ { "main": { "name": "Mario" }, "secondaries": { "orders": [{ "total": 50 }] } },
|
|
98
113
|
{ "main": { "name": "Luigi" } } ]
|
|
99
114
|
// → 200 [ { "main": { "id": 1 }, "secondaries": {...} }, { "main": { "id": 2 } } ]
|
|
100
115
|
```
|
|
101
116
|
|
|
117
|
+
Upsert tables (see manifest):
|
|
118
|
+
- Every row, main or child, carries its conflict key: missing, null, or shared by two rows of one request → 400.
|
|
119
|
+
- When the key is the id, rows without an id are inserted.
|
|
120
|
+
- Bulk arrays are capped (default 1000): chunk client-side.
|
|
121
|
+
|
|
102
122
|
## Errors
|
|
103
123
|
|
|
104
|
-
400 (schema or business validation)
|
|
124
|
+
400 (schema or business validation): map `fields[].path` to form fields.
|
|
105
125
|
|
|
106
126
|
```json
|
|
107
127
|
{ "statusCode": 400, "error": "Bad Request", "message": "Validation failed",
|
|
108
128
|
"fields": [ { "path": "body.main.name", "code": "required", "message": "must have required property 'name'" } ] }
|
|
109
129
|
```
|
|
110
130
|
|
|
111
|
-
|
|
131
|
+
- 403: tenant violation.
|
|
132
|
+
- 404: record not found, or outside your tenant.
|
|
133
|
+
- 500: DB errors (e.g. constraint violations), unmapped, with a `requestId` to report.
|
|
134
|
+
- Per search, max 100 `conditions` per section (top level and each join) and max 20 `orderBy` parts (≤ 1024 chars); over either → 400. Collapse many equality conditions into one `isIn`.
|
package/BREAKING_CHANGES.md
CHANGED
|
@@ -692,3 +692,134 @@ beforeInsert: async (db, req, record) => {
|
|
|
692
692
|
if (req.routeOptions?.url?.startsWith('/rest/')) await notify(record); // side effect: narrow it
|
|
693
693
|
},
|
|
694
694
|
```
|
|
695
|
+
|
|
696
|
+
---
|
|
697
|
+
|
|
698
|
+
# Breaking Change — the route URL is the `DbTables` key
|
|
699
|
+
|
|
700
|
+
*Landed in **0.3.0**.*
|
|
701
|
+
|
|
702
|
+
## Why this changed
|
|
703
|
+
|
|
704
|
+
The documentation always said *"the route URL name is the key you use in the `dbTables`
|
|
705
|
+
record"*, and everything but the URL agreed: the handler, `operations`, `onRequests`, the
|
|
706
|
+
Swagger tags and the agent manifest all resolved the table config by its key. The URL alone was
|
|
707
|
+
built from `Schema.tableName`. Two consequences:
|
|
708
|
+
|
|
709
|
+
- **Several table configs over one physical table could not coexist.** Exposing
|
|
710
|
+
`conversation` once for its owner and once for whoever holds a share link — each key with its
|
|
711
|
+
own `operations`, guards and joins — put both on `/search/conversation`, and Fastify refused
|
|
712
|
+
to start: `Method 'POST' already declared for route '/search/conversation'`.
|
|
713
|
+
- **A renamed key was served under the wrong name.** `{ orders: TableCustomerOrder }` started
|
|
714
|
+
fine, but answered on `/search/customer_order` while the manifest told agent clients to call
|
|
715
|
+
`orders`.
|
|
716
|
+
|
|
717
|
+
## What changed
|
|
718
|
+
|
|
719
|
+
- Every auto route is named after the key: `/search/<key>`, `/rest/<key>`, `/rest/<key>/:id`,
|
|
720
|
+
`/bulk/<key>`, `/bulk/<key>/delete`.
|
|
721
|
+
- The search response's `table` is the key, so it matches the URL that was called and the
|
|
722
|
+
manifest entry.
|
|
723
|
+
- SQL is unchanged: it still targets `Schema.tableName`.
|
|
724
|
+
|
|
725
|
+
## Who is affected
|
|
726
|
+
|
|
727
|
+
- **A `DbTables` key of yours differs from its schema's `tableName`.** Its routes move from the
|
|
728
|
+
table name to the key, and `table` in its search responses changes the same way.
|
|
729
|
+
|
|
730
|
+
Not affected: configs generated by `sqlapi-generate-tables`, whose keys are the table names, and
|
|
731
|
+
any hand-written config that kept that convention.
|
|
732
|
+
|
|
733
|
+
## Migration
|
|
734
|
+
|
|
735
|
+
Either call the new URLs, or rename the key back to the table name to keep the old ones:
|
|
736
|
+
|
|
737
|
+
```typescript
|
|
738
|
+
// before: routes answered on /search/customer_order
|
|
739
|
+
export const dbTables: DbTables = { orders: TableCustomerOrder };
|
|
740
|
+
|
|
741
|
+
// keep the old URLs
|
|
742
|
+
export const dbTables: DbTables = { customer_order: TableCustomerOrder };
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
A client that read `table` from a search response to recover the physical table name should
|
|
746
|
+
use the key it called instead — that is now what `table` returns.
|
|
747
|
+
|
|
748
|
+
---
|
|
749
|
+
|
|
750
|
+
# Breaking Change — an upsert targets a unique key verified at startup
|
|
751
|
+
|
|
752
|
+
*Landed in **0.4.0**.* Decision and rationale: [ADR 0021](./docs/adr/0021-upsert-targets-a-verified-unique-key.md).
|
|
753
|
+
|
|
754
|
+
## Why this changed
|
|
755
|
+
|
|
756
|
+
`upsertMap` accepted any column list, and the dialects disagreed on what it meant:
|
|
757
|
+
|
|
758
|
+
- **No unique index on the declared columns**: PostgreSQL failed every write; MySQL never
|
|
759
|
+
conflicted and inserted duplicates, silently.
|
|
760
|
+
- **MySQL `ON DUPLICATE KEY UPDATE` fires on any unique index**, not on the declared key: a row
|
|
761
|
+
carrying another row's unique value (an `email`, an `id`) updated *that* row, silently, where
|
|
762
|
+
PostgreSQL refused the write.
|
|
763
|
+
- **A natural-key upsert on MySQL returned wrong PKs** for the rows it updated, and secondaries
|
|
764
|
+
were attached to them.
|
|
765
|
+
|
|
766
|
+
## What changed
|
|
767
|
+
|
|
768
|
+
- **The generated Schema carries `uniqueKeys`**, the table's unique keys other than the
|
|
769
|
+
primary key — emitted even when empty. Partial, expression and (MySQL) prefix indexes are not
|
|
770
|
+
keys an upsert can target and are left out.
|
|
771
|
+
- **`defineTable` refuses an `upsertMap` rule that is not exactly the Schema's `primaryKey` or
|
|
772
|
+
one of its `uniqueKeys`** (same fields, any order). This includes rules on write-join child
|
|
773
|
+
schemas — the child still needs no `defineTable` of its own. A rule on a view is refused.
|
|
774
|
+
- **Every upserted row must carry its key — `400` otherwise**: a natural key must be present
|
|
775
|
+
and not null; the primary key may be absent altogether (a plain insert with a generated key)
|
|
776
|
+
but not partial or null. Two rows of one request with the same key are a `400` too.
|
|
777
|
+
- **MySQL and MariaDB upsert by key** — probe, insert, update — instead of
|
|
778
|
+
`ON DUPLICATE KEY UPDATE`. A row whose other unique column belongs to another row is now a
|
|
779
|
+
database error (sanitized 500) on every dialect, never a write to that other row.
|
|
780
|
+
|
|
781
|
+
## Who is affected
|
|
782
|
+
|
|
783
|
+
- **Schemas generated before this release** used with a natural-key rule (`['email']`,
|
|
784
|
+
`['customerId', 'tag']` that is not the PK…): `defineTable` throws until they are
|
|
785
|
+
regenerated. Rules on the primary key keep working — `primaryKey` was already generated.
|
|
786
|
+
- **A rule on columns without a unique index**: it never worked on PostgreSQL, and on MySQL it
|
|
787
|
+
was inserting duplicates. `defineTable` now throws.
|
|
788
|
+
- **Clients sending upsert rows without their key**, or twice the same key in one request:
|
|
789
|
+
`400`.
|
|
790
|
+
- **MySQL/MariaDB**: an upsert costs two to three statements per chunk instead of one.
|
|
791
|
+
- **A driver configured without `CLIENT_FOUND_ROWS`** (MySQL/MariaDB): the upsert checks the
|
|
792
|
+
rows its UPDATE matched, and without that flag the driver reports changed rows instead —
|
|
793
|
+
a row re-sent unchanged would be reported as missing. `mysql2` sets the flag by default.
|
|
794
|
+
- **Code using the exported `SqlDialect` type or `getDialect()` for upserts**: `upsertSql()`
|
|
795
|
+
and `upsertUpdateSet()` are gone. They are replaced by `onConflict?: { sql(), updateSet() }`,
|
|
796
|
+
present on PostgreSQL only — its absence is what tells MySQL/MariaDB to upsert by key.
|
|
797
|
+
A custom `SqlDialect` implementation no longer compiles until it drops the two methods.
|
|
798
|
+
|
|
799
|
+
## Migration
|
|
800
|
+
|
|
801
|
+
1. Regenerate the schemas: `npx sqlapi-generate-schema`. Each Schema now ends with
|
|
802
|
+
`uniqueKeys: [...]`.
|
|
803
|
+
2. Start the application: a rule that is not a unique key fails with a message listing the
|
|
804
|
+
keys the table has, e.g.
|
|
805
|
+
`upsertMap rule on 'customer' (email) is not a unique key of the table. An upsert must target exactly one: (id) primary key (ADR 0021).`
|
|
806
|
+
Either add the unique index the rule assumed, or point the rule at an existing key.
|
|
807
|
+
3. Make sure every upsert row carries its key. For a write-join child keyed on
|
|
808
|
+
`(parentId, lang)`, the FK is filled from the parent — the client sends `lang`.
|
|
809
|
+
4. If you use `SqlDialect` directly: build an upsert through `QueryClient.insertOrUpdate` /
|
|
810
|
+
`bulkInsertOrUpdate`, which pick the right strategy per dialect, rather than from the
|
|
811
|
+
dialect's clause. The PostgreSQL clause, if you need it, is now
|
|
812
|
+
`getDialect('postgres').onConflict!.sql(keys, getDialect('postgres').onConflict!.updateSet(fields, keepAsIs))`
|
|
813
|
+
— `updateSet` returns `''` when nothing is left to update, as before. A custom dialect
|
|
814
|
+
implements `onConflict` only when its upsert syntax names its conflict target; otherwise it
|
|
815
|
+
leaves it out and gets the upsert by key.
|
|
816
|
+
|
|
817
|
+
```typescript
|
|
818
|
+
// before
|
|
819
|
+
const dialect = getDialect('postgres');
|
|
820
|
+
dialect.upsertSql(quotedKeys, dialect.upsertUpdateSet(fields, conflictKeys));
|
|
821
|
+
|
|
822
|
+
// after
|
|
823
|
+
const { onConflict } = getDialect('postgres');
|
|
824
|
+
onConflict!.sql(quotedKeys, onConflict!.updateSet(fields, conflictKeys));
|
|
825
|
+
```
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,132 @@ All notable changes to this project are documented here. This project follows
|
|
|
7
7
|
|
|
8
8
|
Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BREAKING_CHANGES.md)**.
|
|
9
9
|
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
## [0.4.0]
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **BREAKING: an upsert targets a unique key verified at startup** ([ADR 0021](./docs/adr/0021-upsert-targets-a-verified-unique-key.md)).
|
|
17
|
+
`upsertMap` accepted any column list, and on MySQL `ON DUPLICATE KEY UPDATE` fired on *any*
|
|
18
|
+
unique index: a declared key without a unique index inserted duplicates, and a row carrying
|
|
19
|
+
another row's unique value updated that other row — both silently, where PostgreSQL failed.
|
|
20
|
+
Now:
|
|
21
|
+
- the generated Schema carries `uniqueKeys` (partial, expression and prefix indexes left out);
|
|
22
|
+
- `defineTable` refuses a rule that is not exactly the `primaryKey` or a `uniqueKeys` entry —
|
|
23
|
+
write-join children included, still without a `defineTable` of their own;
|
|
24
|
+
- an upserted row without its key, or two rows of one request with the same key, is a `400`
|
|
25
|
+
(a row naming no PK field at all is still a plain insert when the key is the PK);
|
|
26
|
+
- MySQL and MariaDB upsert by key — probe, insert, update: two to three statements per chunk
|
|
27
|
+
instead of one. PostgreSQL keeps `ON CONFLICT (key)`, unchanged;
|
|
28
|
+
- the exported `SqlDialect` type loses `upsertSql()` and `upsertUpdateSet()`, replaced by
|
|
29
|
+
`onConflict?: { sql(), updateSet() }`, present on PostgreSQL only.
|
|
30
|
+
Regenerate the schemas; see
|
|
31
|
+
[BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--an-upsert-targets-a-unique-key-verified-at-startup).
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **Bulk writes no longer lose or overwrite fields when items do not share the same keys.**
|
|
36
|
+
`bulkUpsert` (route and `sqlApi`), secondaries of every write, and
|
|
37
|
+
`QueryClient.bulkInsert` / `bulkInsertOrUpdate` took the column list of the whole batch from
|
|
38
|
+
the first record. A field present only on a later item was silently dropped; a field present
|
|
39
|
+
only on an earlier item was bound as NULL for the others — skipping the column default on
|
|
40
|
+
insert (or failing on a `NOT NULL DEFAULT` column) and wiping the stored value on upsert.
|
|
41
|
+
Each record now writes exactly its own fields: records are grouped by column set, one
|
|
42
|
+
statement per group, PKs still returned in input order. Batches whose items share the same
|
|
43
|
+
keys still run as a single statement.
|
|
44
|
+
- **An upsert item holding only its conflict keys returns its PK.** Such an item has nothing
|
|
45
|
+
to update, so an existing row is left untouched — but postgres `DO NOTHING` returns no row
|
|
46
|
+
for it and mysql's `insertId` says nothing about it: the single upsert returned `undefined`
|
|
47
|
+
and a bulk upsert failed with a 500 after writing. The PK now comes from the item when it
|
|
48
|
+
holds it, otherwise it is looked up by the conflict keys (one `SELECT` per chunk).
|
|
49
|
+
- **MySQL: an upsert by a natural key returns the PK of the row it updated.** With a conflict
|
|
50
|
+
key that is not the PK (the README's `email` sync), the PKs were synthesized as
|
|
51
|
+
`insertId + n`, which counts only inserted rows: re-sending a payload returned ids of other
|
|
52
|
+
rows, or of rows that do not exist, and secondaries were attached to them. The upsert by key
|
|
53
|
+
above takes them from its probe.
|
|
54
|
+
|
|
55
|
+
### Documentation
|
|
56
|
+
|
|
57
|
+
- **Agent files rewritten for LLM readers** — `AGENTS.md`, `AGENTS_BACKEND.md` and
|
|
58
|
+
`AGENTS_FRONTEND.md` went from 107 KB to 39 KB. Duplicated sections were merged, the
|
|
59
|
+
`defineTable` reference is now one annotated example, and markdown links and emoji were
|
|
60
|
+
removed. No API changed; several statements were wrong and are corrected:
|
|
61
|
+
- write bodies are closed, but under Fastify's default `removeAdditional: true` an unknown
|
|
62
|
+
property is stripped silently; it is a `400` only with `removeAdditional: false`. Unknown
|
|
63
|
+
`filters` keys, by contrast, are always a `400`;
|
|
64
|
+
- `ConditionBuilder.raw()` takes `?` markers, not `$1`;
|
|
65
|
+
- a computed field's `values` is required (the translated-jsonb example omitted it);
|
|
66
|
+
- a `joinGroup` without `by` returns one total over the children of all returned main rows,
|
|
67
|
+
not one per row; `by` set to the FK gives per-row values;
|
|
68
|
+
- search pagination is querystring-only (`page`, `itemsPerPage`), and `distinctResults`
|
|
69
|
+
rejects only aggregation `orderBy`, not aggregation conditions;
|
|
70
|
+
- the plugin options now list `agentManifest`, `sqlApi.search` lists `selectComputed` and
|
|
71
|
+
`maxRows`, and the exports list is complete.
|
|
72
|
+
|
|
73
|
+
## [0.3.0]
|
|
74
|
+
|
|
75
|
+
### Changed
|
|
76
|
+
|
|
77
|
+
- **BREAKING: the route URL is the `DbTables` key, as documented.** Every auto route built its
|
|
78
|
+
URL from `Schema.tableName` while the handler, `operations`, the Swagger tags and the agent
|
|
79
|
+
manifest all used the key. Two table configs over the same physical table (an owner view and
|
|
80
|
+
a shared-link view of `conversation`) collided on `/search/conversation` and the app refused
|
|
81
|
+
to start; a single renamed key started fine but was served under the table name, so the
|
|
82
|
+
manifest advertised a URL that did not exist. Routes are now `/search/<key>`,
|
|
83
|
+
`/rest/<key>`, `/bulk/<key>`, and the search response's `table` names the key too. Configs
|
|
84
|
+
generated by `sqlapi-generate-tables` use the table name as key and are unaffected. See
|
|
85
|
+
[BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--the-route-url-is-the-dbtables-key).
|
|
86
|
+
- **`fastify-plugin` 5 → 6.** The only runtime dependency bump: v6 renames its entry files and
|
|
87
|
+
drops the long-deprecated `PluginOptions` type (now `PluginMetadata`), neither of which this
|
|
88
|
+
library uses. No change for consumers; still Fastify 5.
|
|
89
|
+
- **Development dependencies updated** — `@fastify/swagger-ui` 6 (still covered by the
|
|
90
|
+
`>=5.0.0` peer range), `fastify` pinned to 5.12.4, and the latest minors of the lint and
|
|
91
|
+
gate tooling. TypeScript stays on 5.9: `typescript-eslint` does not support TypeScript 7 yet.
|
|
92
|
+
|
|
93
|
+
### Fixed
|
|
94
|
+
|
|
95
|
+
- **An empty tenant list is refused, not executed.** `getTenantId` returning `[]` (a caller
|
|
96
|
+
who belongs to no tenant) used to reach the database as `organization_id IN ()` — a syntax
|
|
97
|
+
error on both dialects, so a `500` that failed closed only by accident, and one step away
|
|
98
|
+
from a leak had any builder dropped the empty predicate. `resolveTenant` now answers `403`
|
|
99
|
+
before any SQL exists, on every operation and through relations from an unscoped host.
|
|
100
|
+
`null`/`undefined` remain the only admin values; `0` and `''` remain ordinary tenant ids.
|
|
101
|
+
- **The tenant column is optional in the insert body.** The engine injects it for a
|
|
102
|
+
single-tenant caller and validates it for a multi-tenant or `anyOf` one, yet the body schema
|
|
103
|
+
still required it whenever the generated schema did — so the documented injection answered
|
|
104
|
+
`400 must have required property` over HTTP, and the only way out was a `schemaOverrides`
|
|
105
|
+
workaround (or `excludeFromCreation`, which silently broke multi-tenant callers by stripping
|
|
106
|
+
the value they must send). The own column of a direct scope and every column of an `anyOf`
|
|
107
|
+
scope are now `Optional` on insert `main` and on secondaries items of every write body — the
|
|
108
|
+
same rule that already made a secondary's FK optional. Indirect scopes are untouched: their
|
|
109
|
+
FK is an ordinary field.
|
|
110
|
+
|
|
111
|
+
### Documentation
|
|
112
|
+
|
|
113
|
+
- **[ADR 0020](./docs/adr/0020-no-conditional-or-mass-update-route.md): no conditional or mass
|
|
114
|
+
update route.** A conditional `updateWhere` operation was designed and rejected: it would be
|
|
115
|
+
the first route writing N rows chosen by the client, and it cuts across `QueryClient.update`,
|
|
116
|
+
the search condition builder, the tenant guards and the hook contract. Compare-and-set
|
|
117
|
+
(claiming a row two clients must not both claim) is a custom route on
|
|
118
|
+
`db.update(table, fields, where, extraCondition)`; `AGENTS_BACKEND.md` carries the pattern
|
|
119
|
+
("Conditional update — claim a row atomically").
|
|
120
|
+
|
|
121
|
+
### Internal
|
|
122
|
+
|
|
123
|
+
- **Tenant isolation test audit.** New specs pin what was enforced but unproven: the tenant
|
|
124
|
+
reaches every HTTP route and every `SqlApi` method; `deletions` bind the child to the main
|
|
125
|
+
row FK (an unscoped child under a scoped parent included) and follow indirect and `anyOf`
|
|
126
|
+
child scopes; a secondary cannot be re-parented by a payload FK; upserted secondaries are
|
|
127
|
+
conflict-guarded on update and bulk upsert; a multi-tenant caller cannot write an unanchored
|
|
128
|
+
secondary; direct-scope update and bulk upsert; indirect-scope delete and bulk delete; request
|
|
129
|
+
predicates on the tenant column never widen the scope; an indirect join-table scope through a
|
|
130
|
+
third table is enforced inside `joinMustExist`, `joinGroup` and aggregation subqueries.
|
|
131
|
+
- **`swagger: true` is now tested.** `setupSwagger` swallows a failed import of
|
|
132
|
+
`@fastify/swagger`/`@fastify/swagger-ui` and only logs a warning, so a dependency bump that
|
|
133
|
+
broke the documentation would have passed every test. Unit tests now assert the served
|
|
134
|
+
OpenAPI spec and UI — default path, custom `SwaggerOptions`, and under the plugin `prefix`.
|
|
135
|
+
|
|
10
136
|
## [0.2.4]
|
|
11
137
|
|
|
12
138
|
### Added
|
|
@@ -620,6 +746,19 @@ the join API redesign (documented in [BREAKING_CHANGES.md](./BREAKING_CHANGES.md
|
|
|
620
746
|
primary key support in updates, transactional insert/update with secondaries, the hook matrix,
|
|
621
747
|
and bulk operations.
|
|
622
748
|
|
|
749
|
+
[Unreleased]: https://github.com/LucaRainone/fastify-auto-sqlapi/compare/v0.4.0...HEAD
|
|
750
|
+
[0.4.0]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.4.0
|
|
751
|
+
[0.3.0]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.3.0
|
|
752
|
+
[0.2.4]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.4
|
|
753
|
+
[0.2.3]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.3
|
|
754
|
+
[0.2.2]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.2
|
|
755
|
+
[0.2.1]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.1
|
|
756
|
+
[0.2.0]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.0
|
|
757
|
+
[0.1.14]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.14
|
|
758
|
+
[0.1.13]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.13
|
|
759
|
+
[0.1.12]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.12
|
|
760
|
+
[0.1.11]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.11
|
|
761
|
+
[0.1.10]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.10
|
|
623
762
|
[0.1.7]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.7
|
|
624
763
|
[0.1.6]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.6
|
|
625
764
|
[0.1.5]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.5
|
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ Writes read the same way — *"sync these clients and their tags: update the one
|
|
|
52
52
|
// { "main": { "id": 2 } }]
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
Conflict keys are declared server-side (`upsertMap`: here customer on `email`, tag on `(customerId, tag)`), so re-sending the same payload updates rows instead of duplicating them — client-side sync without hand-written diffing.
|
|
55
|
+
Conflict keys are declared server-side (`upsertMap`: here customer on `email`, tag on `(customerId, tag)`) and checked at startup against the unique keys the database enforces, so re-sending the same payload updates rows instead of duplicating them and never touches a row through any other key — client-side sync without hand-written diffing.
|
|
56
56
|
|
|
57
57
|
And multi-tenancy is one plugin option (`getTenantId: (req) => req.user?.organizationId ?? null`), enforced on reads *and* writes with zero code in handlers. Rows of other tenants are simply invisible (`404`); here a caller in org B tries to re-point one of their own orders to a customer belonging to org A:
|
|
58
58
|
|
|
@@ -37,10 +37,10 @@ function fileStatus(filename, content) {
|
|
|
37
37
|
function writeSchemaFiles(tableMap, schemasDir) {
|
|
38
38
|
const report = { created: false, touched: false, untouched: 0, files: new Set() };
|
|
39
39
|
for (const schemaName of Object.keys(tableMap)) {
|
|
40
|
-
const { name: tableName, fields, colMap, primary, generated, isView } = tableMap[schemaName];
|
|
40
|
+
const { name: tableName, fields, colMap, primary, generated, isView, uniqueKeys } = tableMap[schemaName];
|
|
41
41
|
const filename = path.join(schemasDir, `${schemaName}.ts`);
|
|
42
42
|
report.files.add(`${schemaName}.ts`);
|
|
43
|
-
const content = generateSchemaFile(schemaName, tableName, fields, colMap, primary, generated, isView);
|
|
43
|
+
const content = generateSchemaFile(schemaName, tableName, fields, colMap, primary, generated, isView, uniqueKeys);
|
|
44
44
|
const status = fileStatus(filename, content);
|
|
45
45
|
if (status === 'created')
|
|
46
46
|
report.created = true;
|
|
@@ -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,MAAM,WAAW,qBAAqB;IACpC,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;
|
|
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,MAAM,WAAW,qBAAqB;IACpC,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;AAyBD,oFAAoF;AACpF,UAAU,cAAc;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,0BAA0B,IAAI,qBAAqB,CAmBlE;AAyED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,cAAc,GAAG,UAAU,CAejE;AAED,wBAAsB,qBAAqB,CACzC,gBAAgB,EAAE,qBAAqB,EACvC,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,EAAE,CAAC,CA+CvB"}
|
|
@@ -117,7 +117,30 @@ export async function introspectMysqlTables(connectionConfig, schema) {
|
|
|
117
117
|
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
|
|
118
118
|
WHERE c.table_schema = ?
|
|
119
119
|
ORDER BY c.table_name, c.ordinal_position`, [schema]);
|
|
120
|
-
|
|
120
|
+
// Unique indexes (constraints included) other than the primary key. An index is left out
|
|
121
|
+
// whole when any part is an expression (no COLUMN_NAME) or a prefix (SUB_PART): the first
|
|
122
|
+
// cannot be named by a column list, the second makes only the leading characters unique —
|
|
123
|
+
// neither is a key an upsert can target (ADR 0021).
|
|
124
|
+
const [uniqueRows] = await connection.query(`SELECT s.table_name AS table_name, s.column_name AS column_name, s.index_name AS index_name
|
|
125
|
+
FROM information_schema.statistics s
|
|
126
|
+
WHERE s.table_schema = ? AND s.non_unique = 0 AND s.index_name <> 'PRIMARY'
|
|
127
|
+
AND NOT EXISTS (
|
|
128
|
+
SELECT 1 FROM information_schema.statistics x
|
|
129
|
+
WHERE x.table_schema = s.table_schema AND x.table_name = s.table_name
|
|
130
|
+
AND x.index_name = s.index_name
|
|
131
|
+
AND (x.column_name IS NULL OR x.sub_part IS NOT NULL)
|
|
132
|
+
)
|
|
133
|
+
ORDER BY s.table_name, s.index_name, s.seq_in_index`, [schema]);
|
|
134
|
+
const uniqueKeys = new Map();
|
|
135
|
+
for (const u of uniqueRows) {
|
|
136
|
+
const key = `${u.table_name}.${u.column_name}`;
|
|
137
|
+
uniqueKeys.set(key, [...(uniqueKeys.get(key) ?? []), u.index_name]);
|
|
138
|
+
}
|
|
139
|
+
return rows.map((row) => {
|
|
140
|
+
const column = mapMysqlColumnRow(row);
|
|
141
|
+
const indexes = uniqueKeys.get(`${column.table_name}.${column.column_name}`);
|
|
142
|
+
return indexes ? { ...column, unique_keys: indexes } : column;
|
|
143
|
+
});
|
|
121
144
|
}
|
|
122
145
|
finally {
|
|
123
146
|
await connection.end();
|
|
@@ -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;AAmBjD,wBAAgB,qBAAqB,IAAI,MAAM,CAY9C;
|
|
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;AAmBjD,wBAAgB,qBAAqB,IAAI,MAAM,CAY9C;AAgBD,wBAAsB,gBAAgB,CACpC,gBAAgB,EAAE,MAAM,EACxB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,EAAE,CAAC,CAsFvB"}
|
|
@@ -26,7 +26,8 @@ export async function introspectTables(connectionString, schema) {
|
|
|
26
26
|
c.is_identity::text, c.is_generated::text,
|
|
27
27
|
(pk.column_name IS NOT NULL) AS is_primary,
|
|
28
28
|
(t.table_type = 'VIEW') AS is_view,
|
|
29
|
-
c.ordinal_position::int AS ordinal_position
|
|
29
|
+
c.ordinal_position::int AS ordinal_position,
|
|
30
|
+
uk.unique_keys
|
|
30
31
|
FROM information_schema.columns c
|
|
31
32
|
JOIN information_schema.tables t
|
|
32
33
|
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
|
|
@@ -38,12 +39,28 @@ export async function introspectTables(connectionString, schema) {
|
|
|
38
39
|
AND tc.table_schema = kcu.table_schema
|
|
39
40
|
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1
|
|
40
41
|
) pk ON pk.table_name = c.table_name AND pk.column_name = c.column_name
|
|
42
|
+
-- Unique indexes (constraints are backed by one) other than the primary key, over
|
|
43
|
+
-- plain columns only: a partial index (indpred) or one on an expression (indexprs)
|
|
44
|
+
-- cannot be named by a column list, so it is no upsert key (ADR 0021).
|
|
45
|
+
LEFT JOIN (
|
|
46
|
+
SELECT tbl.relname::text AS table_name, att.attname::text AS column_name,
|
|
47
|
+
array_agg(idx.relname::text ORDER BY idx.relname) AS unique_keys
|
|
48
|
+
FROM pg_index i
|
|
49
|
+
JOIN pg_class idx ON idx.oid = i.indexrelid
|
|
50
|
+
JOIN pg_class tbl ON tbl.oid = i.indrelid
|
|
51
|
+
JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
|
|
52
|
+
JOIN pg_attribute att ON att.attrelid = tbl.oid AND att.attnum = ANY(i.indkey)
|
|
53
|
+
WHERE i.indisunique AND NOT i.indisprimary
|
|
54
|
+
AND i.indpred IS NULL AND i.indexprs IS NULL
|
|
55
|
+
AND ns.nspname = $1
|
|
56
|
+
GROUP BY tbl.relname, att.attname
|
|
57
|
+
) uk ON uk.table_name = c.table_name AND uk.column_name = c.column_name
|
|
41
58
|
WHERE c.table_schema = $1
|
|
42
59
|
UNION ALL
|
|
43
60
|
SELECT mc.relname::text, ma.attname::text, mt.typname::text,
|
|
44
61
|
NULL::text, CASE WHEN ma.attnotnull THEN 'NO' ELSE 'YES' END,
|
|
45
62
|
'NO'::text, 'NEVER'::text,
|
|
46
|
-
false, true, ma.attnum::int
|
|
63
|
+
false, true, ma.attnum::int, NULL::text[]
|
|
47
64
|
FROM pg_class mc
|
|
48
65
|
JOIN pg_namespace mn ON mn.oid = mc.relnamespace
|
|
49
66
|
JOIN pg_attribute ma
|
|
@@ -55,6 +72,7 @@ export async function introspectTables(connectionString, schema) {
|
|
|
55
72
|
// The query's own columns are spelled out rather than spread: `is_identity` and
|
|
56
73
|
// `is_generated` arrive as the standard's string enums and `ordinal_position` only exists
|
|
57
74
|
// to order the union, so none of the three belongs in the `ColumnInfo` handed back.
|
|
75
|
+
// `unique_keys` is NULL for a column in no unique index.
|
|
58
76
|
return result.rows.map((row) => ({
|
|
59
77
|
table_name: row.table_name,
|
|
60
78
|
column_name: row.column_name,
|
|
@@ -67,6 +85,7 @@ export async function introspectTables(connectionString, schema) {
|
|
|
67
85
|
// mandatory field and the table cannot be inserted into at all.
|
|
68
86
|
...(row.is_identity === 'YES' ? { is_auto_increment: true } : {}),
|
|
69
87
|
...(row.is_generated === 'ALWAYS' ? { is_generated: true } : {}),
|
|
88
|
+
...(row.unique_keys?.length ? { unique_keys: row.unique_keys } : {}),
|
|
70
89
|
}));
|
|
71
90
|
}
|
|
72
91
|
finally {
|
|
@@ -30,5 +30,5 @@ export declare function applyExcludeTables(tableMap: TableMap, excludeTables?: s
|
|
|
30
30
|
* Returns the excluded columns as `table.column`, in map order.
|
|
31
31
|
*/
|
|
32
32
|
export declare function applyExcludeColumns(tableMap: TableMap, excludeColumns?: string[]): string[];
|
|
33
|
-
export declare function generateSchemaFile(schemaName: string, tableName: string, fields: Record<string, string>, colMap: Record<string, string>, primary?: string[], generated?: string[], isView?: boolean): string;
|
|
33
|
+
export declare function generateSchemaFile(schemaName: string, tableName: string, fields: Record<string, string>, colMap: Record<string, string>, primary?: string[], generated?: string[], isView?: boolean, uniqueKeys?: string[][]): string;
|
|
34
34
|
//# 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;;GAEG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,UAAU,EAAE;IACV,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,GACA,MAAM,CA6DR;
|
|
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;;GAEG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,UAAU,EAAE;IACV,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,GACA,MAAM,CA6DR;AAcD,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,QAAQ,CAwD1D;AAaD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,EAAE,aAAa,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAgBzF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,cAAc,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAmD3F;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,EAClB,SAAS,CAAC,EAAE,MAAM,EAAE,EACpB,MAAM,CAAC,EAAE,OAAO,EAChB,UAAU,CAAC,EAAE,MAAM,EAAE,EAAE,GACtB,MAAM,CAuDR"}
|