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.
Files changed (54) hide show
  1. package/AGENTS.md +24 -111
  2. package/AGENTS_BACKEND.md +282 -1332
  3. package/AGENTS_FRONTEND.md +70 -47
  4. package/BREAKING_CHANGES.md +131 -0
  5. package/CHANGELOG.md +139 -0
  6. package/README.md +1 -1
  7. package/dist/bin/generate-schema.js +2 -2
  8. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  9. package/dist/lib/cli/mysql-introspect.js +24 -1
  10. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  11. package/dist/lib/cli/pg-introspect.js +21 -2
  12. package/dist/lib/cli/schema-codegen.d.ts +1 -1
  13. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  14. package/dist/lib/cli/schema-codegen.js +31 -3
  15. package/dist/lib/db.d.ts.map +1 -1
  16. package/dist/lib/db.js +187 -60
  17. package/dist/lib/dialect.d.ts +15 -10
  18. package/dist/lib/dialect.d.ts.map +1 -1
  19. package/dist/lib/dialect.js +12 -31
  20. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  21. package/dist/lib/engine/bulk/bulk-upsert.js +2 -1
  22. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  23. package/dist/lib/engine/rest/insert.js +2 -1
  24. package/dist/lib/engine/write-helpers.d.ts +13 -1
  25. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  26. package/dist/lib/engine/write-helpers.js +35 -0
  27. package/dist/lib/schema/helpers.d.ts +7 -0
  28. package/dist/lib/schema/helpers.d.ts.map +1 -1
  29. package/dist/lib/schema/helpers.js +18 -0
  30. package/dist/lib/schema/insert.d.ts.map +1 -1
  31. package/dist/lib/schema/insert.js +3 -1
  32. package/dist/lib/table-helpers.d.ts.map +1 -1
  33. package/dist/lib/table-helpers.js +41 -0
  34. package/dist/lib/tenant.d.ts +6 -0
  35. package/dist/lib/tenant.d.ts.map +1 -1
  36. package/dist/lib/tenant.js +16 -0
  37. package/dist/routes/auto/bulk-delete.routes.js +1 -1
  38. package/dist/routes/auto/bulk-upsert.routes.js +1 -1
  39. package/dist/routes/auto/delete.routes.js +1 -1
  40. package/dist/routes/auto/get.routes.js +1 -1
  41. package/dist/routes/auto/insert.routes.js +1 -1
  42. package/dist/routes/auto/route-helpers.d.ts +5 -2
  43. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  44. package/dist/routes/auto/route-helpers.js +1 -1
  45. package/dist/routes/auto/search.routes.js +3 -3
  46. package/dist/routes/auto/update.routes.js +1 -1
  47. package/dist/types/cli.d.ts +11 -0
  48. package/dist/types/cli.d.ts.map +1 -1
  49. package/dist/types/schema.d.ts +7 -0
  50. package/dist/types/schema.d.ts.map +1 -1
  51. package/docs/adr/0020-no-conditional-or-mass-update-route.md +68 -0
  52. package/docs/adr/0021-upsert-targets-a-verified-unique-key.md +153 -0
  53. package/docs/adr/README.md +2 -0
  54. package/package.json +4 -4
@@ -1,111 +1,134 @@
1
1
  # fastify-auto-sqlapi — client API (LLM reference)
2
2
 
3
- This file is the request GRAMMAR. For this deployment's tables/fields/aliases (the
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
- Rules: all request/response fields are camelCase schema field names — how the database names its columns is invisible here and irrelevant, the server maps them. `{p}` = deployment prefix (e.g. `/api`). Join keys are backend-declared ALIASES (default = joined table name; list per table in Swagger). Write responses are PK-only. Write bodies reject unknown properties (400) — send only real schema fields. Arrays for bulk are capped (default 1000/request; chunk client-side). Composite-PK tables have no get/delete/bulk-delete (404) — use search / update.
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 {p}/search/{table} body=search request → {table, main:[...], ...}
12
- GET {p}/rest/{table}/:id → {main:{...}} | 404
13
- POST {p}/rest/{table} body={main, secondaries?} → 201 {main:{id}, secondaries?}
14
- PUT {p}/rest/{table} body={main(+PK), secondaries?, deletions?} → {main:{id}, ...}
15
- DELETE {p}/rest/{table}/:id → {main:{id}} | 404
16
- PUT {p}/bulk/{table} body=[{main, secondaries?, deletions?},...] → [{main:{id}},...]
17
- POST {p}/bulk/{table}/delete body=[{id:1},{id:2}] → [{main:{id:1}},...]
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
- ## SEARCH
23
+ ## Search
21
24
 
22
- Query string (all optional): `?orderBy=...&page=1&itemsPerPage=20&computeMin=f&computeMax=f&computeSum=f&computeAvg=f`
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 }, // equality AND; null → IS NULL; extraFilters names (backend-defined, see manifest) also accepted here
29
- "conditions": [ // advanced, AND, same field repeatable
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] } // HAVING-style: needs matching joinGroup below
35
+ { "field": "orders.count.id", "method": "isGreaterOrEqual", "params": [4] } // alias.fn.field, HAVING-style: needs the matching joinGroup below
34
36
  ],
35
- "selectComputed": ["displayName"], // backend computed fields, projected into main rows only if listed
36
- "joinMustExist": { // EXISTS on child: filters WHICH main rows return; no data fetched
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": { // side query: fetches child rows OF the returned mains
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": { // aggregations on child rows
44
+ "joinGroup": { // aggregates child rows of the returned main rows
43
45
  "orders": {
44
- "aggregations": {
45
- "by": "status", // optional GROUP BY (schema field or backend computed, e.g. month bucket)
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": { // N:1 parent embed (alias declared unique by backend)
53
- "creator": { "selection": "id,name" } // filters/conditions here EXCLUDE mains whose parent doesn't match; schema fields only (extraFilters NOT usable here)
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 (each section present only if requested):
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 }, // keyed by fn when no "by"
67
- "rows": [{ "by": "pending", "sum_total": 100 }] // rows[] when "by" is set
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" }] }, // deduped; map client-side: main.creatorId === creator.id
70
- "pagination": { // present when page/itemsPerPage sent
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 } }, // computeMin/Max/Sum/Avg (require pagination)
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
- `orderBy` forms (comma-separable): `field [ASC|DESC]` | `alias.parentField` (joinLeft-eligible alias, adds LEFT JOIN) | `alias.fn.field` (fn: sum|min|max|avg|count|distinctCount — the joinGroup with that fn+field MUST be in the body; rows without children sort as 0). For 3-part orderBy and HAVING-style conditions: `aggregations.by` is allowed only when it equals the correlation FK (else 400); aggregation orderBy is rejected (400) on tables with distinctResults.
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
- Condition methods (params arity): `isEqual|isNotEqual|isGreater|isGreaterOrEqual|isLess|isLessOrEqual|isNotGreater|isNotGreaterOrEqual|isNotLess|isNotLessOrEqual` → `[v]`; `isLike|isNotLike|isILike|isNotILike` → `["%p%"]`; `isBetween|isNotBetween` → `[from,to]`; `isIn|isNotIn` → `[[v1,v2]]`; `isNull|isNotNull` → `[]`. Unknown field or method → 400; so is a `params` that is not an array or is shorter than the arity above (`isNull`/`isNotNull` take none).
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
- ## WRITES
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 (insert; omit auto-generated PKs)
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" }] } } // child FK auto-filled from new main PK
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 (update; main MUST include PK; partial fields ok; null = set column NULL)
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 }] }, // inserts/upserts children, FK auto-filled
93
- "deletions": { "orders": [{ "id": 10 }] } } // deletes children by PK (scoped to this main)
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 (bulk upsert; one SQL for mains; per-item secondaries/deletions)
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) — map `fields[].path` to form fields:
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
- Other: 404 (get/delete not found), 400 `itemsPerPage exceeds the maximum` (lower it), 500 raw DB errors (constraint violations surface unmapped). Fixed complexity caps, per search: **100 `conditions`** per section (top level and each join family) and **20** comma-separated `orderBy` parts (`orderBy` string ≤ 1024 chars) — over either → 400: collapse a long list of equality conditions into one `isIn`. `paginator.page` and `paginator.itemsPerPage` must be integers ≥ 1 — send numbers, not querystring strings.
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`.
@@ -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;AAkBD,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,CAoBvB"}
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
- return rows.map(mapMysqlColumnRow);
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;AAcD,wBAAsB,gBAAgB,CACpC,gBAAgB,EAAE,MAAM,EACxB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,EAAE,CAAC,CAmEvB"}
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;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,QAAQ,CA0C1D;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,CAiD3F;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,GACf,MAAM,CAoDR"}
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"}