fastify-auto-sqlapi 0.3.0 → 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 +271 -1381
- package/AGENTS_FRONTEND.md +70 -47
- package/BREAKING_CHANGES.md +79 -0
- package/CHANGELOG.md +63 -1
- 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/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +41 -0
- 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/0021-upsert-targets-a-verified-unique-key.md +153 -0
- package/docs/adr/README.md +1 -0
- package/package.json +1 -1
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
|
@@ -744,3 +744,82 @@ export const dbTables: DbTables = { customer_order: TableCustomerOrder };
|
|
|
744
744
|
|
|
745
745
|
A client that read `table` from a search response to recover the physical table name should
|
|
746
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
|
@@ -9,6 +9,67 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
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
|
+
|
|
12
73
|
## [0.3.0]
|
|
13
74
|
|
|
14
75
|
### Changed
|
|
@@ -685,7 +746,8 @@ the join API redesign (documented in [BREAKING_CHANGES.md](./BREAKING_CHANGES.md
|
|
|
685
746
|
primary key support in updates, transactional insert/update with secondaries, the hook matrix,
|
|
686
747
|
and bulk operations.
|
|
687
748
|
|
|
688
|
-
[Unreleased]: https://github.com/LucaRainone/fastify-auto-sqlapi/compare/v0.
|
|
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
|
|
689
751
|
[0.3.0]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.3.0
|
|
690
752
|
[0.2.4]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.4
|
|
691
753
|
[0.2.3]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.3
|
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"}
|
|
@@ -62,8 +62,21 @@ export function convertColType(udtName, columnInfo) {
|
|
|
62
62
|
return wrap('Type.String()');
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Turn the per-column index names into the table's unique keys: one field list per distinct
|
|
67
|
+
* column set, fields in table order, keys sorted so regenerating yields a stable file.
|
|
68
|
+
*/
|
|
69
|
+
function collectUniqueKeys(indexes) {
|
|
70
|
+
const bySet = new Map();
|
|
71
|
+
for (const fields of indexes.values()) {
|
|
72
|
+
bySet.set(JSON.stringify([...fields].sort()), fields);
|
|
73
|
+
}
|
|
74
|
+
return [...bySet.values()].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
75
|
+
}
|
|
65
76
|
export function buildTableMap(rows) {
|
|
66
|
-
|
|
77
|
+
// schema name → unique index name → fields (camelCase), in table column order
|
|
78
|
+
const uniqueIndexes = new Map();
|
|
79
|
+
const tableMap = rows.reduce((acc, row) => {
|
|
67
80
|
const schemaName = toSchemaName(row.table_name);
|
|
68
81
|
if (!acc[schemaName]) {
|
|
69
82
|
acc[schemaName] = {
|
|
@@ -73,7 +86,9 @@ export function buildTableMap(rows) {
|
|
|
73
86
|
primary: [],
|
|
74
87
|
generated: [],
|
|
75
88
|
isView: row.is_view === true,
|
|
89
|
+
uniqueKeys: [],
|
|
76
90
|
};
|
|
91
|
+
uniqueIndexes.set(schemaName, new Map());
|
|
77
92
|
}
|
|
78
93
|
const fieldName = toCamelCase(row.column_name);
|
|
79
94
|
// Two columns mapping to the same camelCase field (e.g. user_name + userName) would
|
|
@@ -92,8 +107,16 @@ export function buildTableMap(rows) {
|
|
|
92
107
|
if (row.is_generated) {
|
|
93
108
|
acc[schemaName].generated.push(fieldName);
|
|
94
109
|
}
|
|
110
|
+
const indexes = uniqueIndexes.get(schemaName);
|
|
111
|
+
for (const indexName of row.unique_keys ?? []) {
|
|
112
|
+
indexes.set(indexName, [...(indexes.get(indexName) ?? []), fieldName]);
|
|
113
|
+
}
|
|
95
114
|
return acc;
|
|
96
115
|
}, {});
|
|
116
|
+
for (const [schemaName, indexes] of uniqueIndexes) {
|
|
117
|
+
tableMap[schemaName].uniqueKeys = collectUniqueKeys(indexes);
|
|
118
|
+
}
|
|
119
|
+
return tableMap;
|
|
97
120
|
}
|
|
98
121
|
/** Compile a config glob — `*` is the only wildcard, everything else is literal. */
|
|
99
122
|
function globToRegExp(pattern) {
|
|
@@ -174,6 +197,8 @@ export function applyExcludeColumns(tableMap, excludeColumns) {
|
|
|
174
197
|
delete table.fields[field];
|
|
175
198
|
delete table.colMap[field];
|
|
176
199
|
table.generated = table.generated.filter((f) => f !== field);
|
|
200
|
+
// A key naming a column the API cannot write can never be an upsert key
|
|
201
|
+
table.uniqueKeys = table.uniqueKeys?.filter((key) => !key.includes(field));
|
|
177
202
|
excluded.push(`${table.name}.${columnName}`);
|
|
178
203
|
}
|
|
179
204
|
if (Object.keys(table.fields).length === 0) {
|
|
@@ -183,7 +208,7 @@ export function applyExcludeColumns(tableMap, excludeColumns) {
|
|
|
183
208
|
}
|
|
184
209
|
return excluded;
|
|
185
210
|
}
|
|
186
|
-
export function generateSchemaFile(schemaName, tableName, fields, colMap, primary, generated, isView) {
|
|
211
|
+
export function generateSchemaFile(schemaName, tableName, fields, colMap, primary, generated, isView, uniqueKeys) {
|
|
187
212
|
const fieldsStr = JSON.stringify(fields, null, 2).replace(/"(.*?)"\s*:\s*"Type(.*?)"/g, '$1: Type$2');
|
|
188
213
|
const colMapStr = JSON.stringify(colMap, null, 2);
|
|
189
214
|
const primaryKeyLine = primary?.length ? `\n primaryKey: ${JSON.stringify(primary)},` : '';
|
|
@@ -193,6 +218,9 @@ export function generateSchemaFile(schemaName, tableName, fields, colMap, primar
|
|
|
193
218
|
// A view has no PRIMARY KEY constraint to introspect, so the table generator has to be told
|
|
194
219
|
// it may not read the absence of one as "no key exists on this data".
|
|
195
220
|
const isViewLine = isView ? `\n isView: true,` : '';
|
|
221
|
+
// Emitted even when empty: `defineTable` refuses an upsert rule it cannot verify, and an
|
|
222
|
+
// absent property is what says "generated before unique keys were known" (ADR 0021).
|
|
223
|
+
const uniqueKeysLine = uniqueKeys ? `\n uniqueKeys: ${JSON.stringify(uniqueKeys)},` : '';
|
|
196
224
|
const usesNullable = Object.values(fields).some((t) => t.includes('Nullable('));
|
|
197
225
|
const valueImports = usesNullable ? 'Type, Nullable' : 'Type';
|
|
198
226
|
return `// Auto-generated by fastify-auto-sqlapi. Do not edit.
|
|
@@ -220,7 +248,7 @@ export const Schema = {
|
|
|
220
248
|
fields: _Schema,
|
|
221
249
|
validation,
|
|
222
250
|
tableName: "${tableName}",
|
|
223
|
-
partialValidation,${primaryKeyLine}${generatedLine}${isViewLine}
|
|
251
|
+
partialValidation,${primaryKeyLine}${generatedLine}${isViewLine}${uniqueKeysLine}
|
|
224
252
|
};
|
|
225
253
|
export const ${schemaName} = Schema;
|
|
226
254
|
export type TypeSchema = Static<typeof Schema.validation>;
|
package/dist/lib/db.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAgB/C,sCAAsC;AACtC,eAAO,MAAM,WAAW,GAAI,GAAG,MAAM,WAA0B,CAAC;AAEhE,qBAAa,WAAW;;IACtB,OAAO,CAAC,MAAM,CAAY;IAC1B,OAAO,CAAC,OAAO,CAAa;IAC5B,OAAO,CAAC,KAAK,CAAS;gBAEV,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,UAAU;IAKnD,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAE/B;IAED,IAAI,EAAE,IAAI,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAE9B;IAED,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,iFAAiF;IACjF,IAAI,SAAS,IAAI,UAAU,CAAC,WAAW,CAAC,CAEvC;IAED,mFAAmF;IACnF,IAAI,iBAAiB,IAAI,OAAO,CAE/B;IAED,uEAAuE;IACvE,IAAI,WAAW,IAAI,UAAU,CAAC,aAAa,CAAC,CAE3C;IAED,IAAI,SAAS,IAAI,UAAU,CAAC,WAAW,CAAC,CAEvC;IAED,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI;IAI7B,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU;IAIrC,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,OAAO,EAAE,GACjB,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;IAQxB;;;;;;;;;OASG;IACG,eAAe,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAiHnE,MAAM,CACV,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,QAAQ,EAChB,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GACvB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAgB7B,cAAc,CAClB,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,QAAQ,EAChB,YAAY,EAAE,MAAM,EAAE,EACtB,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GACvB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IA4O7B,UAAU,CACd,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,QAAQ,EAAE,EACnB,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,EACxB,SAAS,SAAqB,GAC7B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAKrC;;;;OAIG;IACG,kBAAkB,CACtB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,QAAQ,EAAE,EACnB,YAAY,EAAE,MAAM,EAAE,EACtB,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,EACxB,SAAS,SAAqB,EAC9B,aAAa,GAAE,MAAM,EAAO,GAC3B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAO/B,MAAM,CACV,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,QAAQ,EAChB,KAAK,EAAE,QAAQ,EACf,cAAc,CAAC,EAAE,gBAAgB,GAChC,OAAO,CAAC,MAAM,CAAC;IAoBZ,MAAM,CACV,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,QAAQ,EACf,cAAc,CAAC,EAAE,gBAAgB,GAChC,OAAO,CAAC,MAAM,CAAC;IAkBZ,MAAM,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EACxC,SAAS,EACT,OAAa,EACb,KAAK,EACL,MAAM,EACN,KAAY,EACZ,OAAY,EACZ,KAAU,EACV,QAAgB,GACjB,EAAE,aAAa,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC;CAchC;AAED,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,SAAS,EACf,WAAW,CAAC,EAAE,MAAM,GACnB,WAAW,CAGb"}
|