@carbonorm/carbonnode 4.0.1 → 5.0.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 (155) hide show
  1. package/README.md +158 -49
  2. package/dist/api/executors/SqlExecutor.d.ts +6 -0
  3. package/dist/api/handlers/ExpressHandler.d.ts +2 -1
  4. package/dist/api/types/ormInterfaces.d.ts +12 -0
  5. package/dist/api/utils/sqlAllowList.d.ts +2 -0
  6. package/dist/index.cjs.js +247 -10
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.esm.js +246 -11
  10. package/dist/index.esm.js.map +1 -1
  11. package/package.json +1 -1
  12. package/scripts/assets/handlebars/C6.test.ts.handlebars +578 -32
  13. package/scripts/generateRestBindings.cjs +5 -5
  14. package/scripts/generateRestBindings.ts +5 -5
  15. package/src/__tests__/fixtures/createTestServer.ts +11 -3
  16. package/src/__tests__/fixtures/sqlResponses/actor.get.json +13 -0
  17. package/src/__tests__/fixtures/sqlResponses/sqlAllowList.blocked.json +3 -0
  18. package/src/__tests__/fixtures/sqlResponses/sqlAllowList.json +3 -0
  19. package/src/__tests__/sakila-db/C6.js +1 -1
  20. package/src/__tests__/sakila-db/C6.mysql.cnf +6 -0
  21. package/src/__tests__/sakila-db/C6.mysqldump.json +1 -0
  22. package/src/__tests__/sakila-db/C6.mysqldump.sql +720 -0
  23. package/src/__tests__/sakila-db/C6.sqlAllowList.json +94 -0
  24. package/src/__tests__/sakila-db/C6.test.ts +578 -32
  25. package/src/__tests__/sakila-db/C6.ts +1 -1
  26. package/src/__tests__/sakila-db/sqlResponses/C6.actor.delete.json +10 -0
  27. package/src/__tests__/sakila-db/sqlResponses/C6.actor.delete.lookup.json +9 -0
  28. package/src/__tests__/sakila-db/sqlResponses/C6.actor.get.json +14 -0
  29. package/src/__tests__/sakila-db/sqlResponses/C6.actor.join.json +15 -0
  30. package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.json +12 -0
  31. package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.latest.json +14 -0
  32. package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.json +11 -0
  33. package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.lookup.json +16 -0
  34. package/src/__tests__/sakila-db/sqlResponses/C6.actor.seed.json +14 -0
  35. package/src/__tests__/sakila-db/sqlResponses/C6.address.delete.json +10 -0
  36. package/src/__tests__/sakila-db/sqlResponses/C6.address.delete.lookup.json +9 -0
  37. package/src/__tests__/sakila-db/sqlResponses/C6.address.fk.current.json +358 -0
  38. package/src/__tests__/sakila-db/sqlResponses/C6.address.fk.referenced.json +158 -0
  39. package/src/__tests__/sakila-db/sqlResponses/C6.address.get.json +22 -0
  40. package/src/__tests__/sakila-db/sqlResponses/C6.address.join.json +24 -0
  41. package/src/__tests__/sakila-db/sqlResponses/C6.address.post.json +16 -0
  42. package/src/__tests__/sakila-db/sqlResponses/C6.address.post.latest.json +22 -0
  43. package/src/__tests__/sakila-db/sqlResponses/C6.address.put.json +11 -0
  44. package/src/__tests__/sakila-db/sqlResponses/C6.address.put.lookup.json +24 -0
  45. package/src/__tests__/sakila-db/sqlResponses/C6.address.seed.json +22 -0
  46. package/src/__tests__/sakila-db/sqlResponses/C6.category.delete.json +10 -0
  47. package/src/__tests__/sakila-db/sqlResponses/C6.category.delete.lookup.json +9 -0
  48. package/src/__tests__/sakila-db/sqlResponses/C6.category.get.json +13 -0
  49. package/src/__tests__/sakila-db/sqlResponses/C6.category.join.json +14 -0
  50. package/src/__tests__/sakila-db/sqlResponses/C6.category.post.json +11 -0
  51. package/src/__tests__/sakila-db/sqlResponses/C6.category.post.latest.json +13 -0
  52. package/src/__tests__/sakila-db/sqlResponses/C6.category.put.json +11 -0
  53. package/src/__tests__/sakila-db/sqlResponses/C6.category.put.lookup.json +15 -0
  54. package/src/__tests__/sakila-db/sqlResponses/C6.category.seed.json +13 -0
  55. package/src/__tests__/sakila-db/sqlResponses/C6.city.delete.json +10 -0
  56. package/src/__tests__/sakila-db/sqlResponses/C6.city.delete.lookup.json +9 -0
  57. package/src/__tests__/sakila-db/sqlResponses/C6.city.fk.current.json +158 -0
  58. package/src/__tests__/sakila-db/sqlResponses/C6.city.fk.referenced.json +133 -0
  59. package/src/__tests__/sakila-db/sqlResponses/C6.city.get.json +14 -0
  60. package/src/__tests__/sakila-db/sqlResponses/C6.city.join.json +15 -0
  61. package/src/__tests__/sakila-db/sqlResponses/C6.city.post.json +12 -0
  62. package/src/__tests__/sakila-db/sqlResponses/C6.city.post.latest.json +14 -0
  63. package/src/__tests__/sakila-db/sqlResponses/C6.city.put.json +11 -0
  64. package/src/__tests__/sakila-db/sqlResponses/C6.city.put.lookup.json +16 -0
  65. package/src/__tests__/sakila-db/sqlResponses/C6.city.seed.json +14 -0
  66. package/src/__tests__/sakila-db/sqlResponses/C6.country.delete.json +10 -0
  67. package/src/__tests__/sakila-db/sqlResponses/C6.country.delete.lookup.json +9 -0
  68. package/src/__tests__/sakila-db/sqlResponses/C6.country.get.json +13 -0
  69. package/src/__tests__/sakila-db/sqlResponses/C6.country.join.json +15 -0
  70. package/src/__tests__/sakila-db/sqlResponses/C6.country.post.json +11 -0
  71. package/src/__tests__/sakila-db/sqlResponses/C6.country.post.latest.json +13 -0
  72. package/src/__tests__/sakila-db/sqlResponses/C6.country.put.json +11 -0
  73. package/src/__tests__/sakila-db/sqlResponses/C6.country.put.lookup.json +15 -0
  74. package/src/__tests__/sakila-db/sqlResponses/C6.country.seed.json +13 -0
  75. package/src/__tests__/sakila-db/sqlResponses/C6.customer.delete.json +10 -0
  76. package/src/__tests__/sakila-db/sqlResponses/C6.customer.delete.lookup.json +9 -0
  77. package/src/__tests__/sakila-db/sqlResponses/C6.customer.fk.current.json +283 -0
  78. package/src/__tests__/sakila-db/sqlResponses/C6.customer.fk.referenced.json +358 -0
  79. package/src/__tests__/sakila-db/sqlResponses/C6.customer.get.json +19 -0
  80. package/src/__tests__/sakila-db/sqlResponses/C6.customer.join.json +29 -0
  81. package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.json +17 -0
  82. package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.latest.json +19 -0
  83. package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.json +11 -0
  84. package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.lookup.json +21 -0
  85. package/src/__tests__/sakila-db/sqlResponses/C6.customer.seed.json +19 -0
  86. package/src/__tests__/sakila-db/sqlResponses/C6.film.delete.json +10 -0
  87. package/src/__tests__/sakila-db/sqlResponses/C6.film.delete.lookup.json +9 -0
  88. package/src/__tests__/sakila-db/sqlResponses/C6.film.fk.current.json +383 -0
  89. package/src/__tests__/sakila-db/sqlResponses/C6.film.fk.referenced.json +38 -0
  90. package/src/__tests__/sakila-db/sqlResponses/C6.film.get.json +23 -0
  91. package/src/__tests__/sakila-db/sqlResponses/C6.film.join.json +24 -0
  92. package/src/__tests__/sakila-db/sqlResponses/C6.film.post.json +20 -0
  93. package/src/__tests__/sakila-db/sqlResponses/C6.film.post.latest.json +23 -0
  94. package/src/__tests__/sakila-db/sqlResponses/C6.film.put.json +11 -0
  95. package/src/__tests__/sakila-db/sqlResponses/C6.film.put.lookup.json +25 -0
  96. package/src/__tests__/sakila-db/sqlResponses/C6.film.seed.json +23 -0
  97. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.delete.json +10 -0
  98. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.delete.lookup.json +9 -0
  99. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.fk.current.json +158 -0
  100. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.fk.referenced.json +20 -0
  101. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.get.json +14 -0
  102. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.join.json +25 -0
  103. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.json +12 -0
  104. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.latest.json +14 -0
  105. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.json +11 -0
  106. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.lookup.json +16 -0
  107. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.seed.json +14 -0
  108. package/src/__tests__/sakila-db/sqlResponses/C6.language.delete.json +10 -0
  109. package/src/__tests__/sakila-db/sqlResponses/C6.language.delete.lookup.json +9 -0
  110. package/src/__tests__/sakila-db/sqlResponses/C6.language.get.json +13 -0
  111. package/src/__tests__/sakila-db/sqlResponses/C6.language.join.json +24 -0
  112. package/src/__tests__/sakila-db/sqlResponses/C6.language.post.json +11 -0
  113. package/src/__tests__/sakila-db/sqlResponses/C6.language.post.latest.json +13 -0
  114. package/src/__tests__/sakila-db/sqlResponses/C6.language.put.json +11 -0
  115. package/src/__tests__/sakila-db/sqlResponses/C6.language.put.lookup.json +15 -0
  116. package/src/__tests__/sakila-db/sqlResponses/C6.language.seed.json +13 -0
  117. package/src/__tests__/sakila-db/sqlResponses/C6.payment.delete.json +10 -0
  118. package/src/__tests__/sakila-db/sqlResponses/C6.payment.delete.lookup.json +9 -0
  119. package/src/__tests__/sakila-db/sqlResponses/C6.payment.fk.current.json +233 -0
  120. package/src/__tests__/sakila-db/sqlResponses/C6.payment.fk.referenced.json +233 -0
  121. package/src/__tests__/sakila-db/sqlResponses/C6.payment.get.json +17 -0
  122. package/src/__tests__/sakila-db/sqlResponses/C6.payment.join.json +24 -0
  123. package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.json +15 -0
  124. package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.latest.json +17 -0
  125. package/src/__tests__/sakila-db/sqlResponses/C6.payment.put.json +11 -0
  126. package/src/__tests__/sakila-db/sqlResponses/C6.payment.put.lookup.json +19 -0
  127. package/src/__tests__/sakila-db/sqlResponses/C6.payment.seed.json +17 -0
  128. package/src/__tests__/sakila-db/sqlResponses/C6.rental.delete.json +10 -0
  129. package/src/__tests__/sakila-db/sqlResponses/C6.rental.delete.lookup.json +9 -0
  130. package/src/__tests__/sakila-db/sqlResponses/C6.rental.fk.current.json +233 -0
  131. package/src/__tests__/sakila-db/sqlResponses/C6.rental.fk.referenced.json +34 -0
  132. package/src/__tests__/sakila-db/sqlResponses/C6.rental.get.json +17 -0
  133. package/src/__tests__/sakila-db/sqlResponses/C6.rental.join.json +24 -0
  134. package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.json +15 -0
  135. package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.latest.json +17 -0
  136. package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.json +11 -0
  137. package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.lookup.json +19 -0
  138. package/src/__tests__/sakila-db/sqlResponses/C6.rental.seed.json +17 -0
  139. package/src/__tests__/sakila-db/sqlResponses/C6.staff.fk.current.json +34 -0
  140. package/src/__tests__/sakila-db/sqlResponses/C6.staff.fk.referenced.json +20 -0
  141. package/src/__tests__/sakila-db/sqlResponses/C6.staff.get.json +21 -0
  142. package/src/__tests__/sakila-db/sqlResponses/C6.staff.join.json +31 -0
  143. package/src/__tests__/sakila-db/sqlResponses/C6.staff.seed.json +21 -0
  144. package/src/__tests__/sakila-db/sqlResponses/C6.store.fk.current.json +20 -0
  145. package/src/__tests__/sakila-db/sqlResponses/C6.store.fk.referenced.json +34 -0
  146. package/src/__tests__/sakila-db/sqlResponses/C6.store.get.json +14 -0
  147. package/src/__tests__/sakila-db/sqlResponses/C6.store.join.json +24 -0
  148. package/src/__tests__/sakila-db/sqlResponses/C6.store.seed.json +14 -0
  149. package/src/__tests__/sakila.generated.test.ts +31 -0
  150. package/src/__tests__/sqlAllowList.test.ts +135 -0
  151. package/src/api/executors/SqlExecutor.ts +156 -0
  152. package/src/api/handlers/ExpressHandler.ts +10 -1
  153. package/src/api/types/ormInterfaces.ts +15 -0
  154. package/src/api/utils/sqlAllowList.ts +54 -0
  155. package/src/index.ts +1 -0
package/README.md CHANGED
@@ -47,45 +47,104 @@ npm install @carbonorm/carbonnode
47
47
 
48
48
  ## Generate Models
49
49
 
50
- The command below will generate the models for the database. The models will be generated in the output directory. We do
51
- recommend you keep this folder separate from other work. It is also best to track the output directory in your version
52
- control system. All arguments are optional. If you do not provide them the defaults will be used. The example arguments
53
- below are the defaults.
50
+ The generator produces a single `C6.ts` file containing all tables, types, and REST bindings. Keep this file in version
51
+ control and share it between server and client. All arguments are optional; the example below shows the defaults.
54
52
 
55
53
  ```bash
56
- npx generateRestBindings --user root --pass password --host 127.0.0.1 --port 3306 --dbname carbonPHP --prefix carbon_ --output /src/api/rest
54
+ npx generateRestBindings --user root --pass password --host 127.0.0.1 --port 3306 --dbname carbonPHP --prefix carbon_ --output ./shared/rest/C6.ts
57
55
  ```
58
56
 
59
- You can view the [code generated](https://github.com/CarbonORM/CarbonORM.dev/blob/www/src/api/rest/Users.tsx) by
60
- [this command](https://github.com/CarbonORM/CarbonNode/blob/main/scripts/generateRestBindings.ts) in
61
- [this repository](git@github.com:CarbonORM/CarbonNode.git). We use [Handlebars templates](https://mustache.github.io/)
62
- to generate the code.
57
+ The generated file exports `C6`, `GLOBAL_REST_PARAMETERS`, `TABLES`, `ORM`, and per-table bindings (e.g. `Users`):
58
+
59
+ ```typescript
60
+ import { C6, GLOBAL_REST_PARAMETERS, Users } from "./shared/rest/C6";
61
+ ```
62
+
63
+ You can view the generator source in
64
+ [CarbonNode](https://github.com/CarbonORM/CarbonNode/blob/main/scripts/generateRestBindings.ts). We use
65
+ [Handlebars templates](https://mustache.github.io/) to generate the code.
66
+
67
+ ### Runtime Setup
68
+
69
+ CarbonNode executes SQL directly when `GLOBAL_REST_PARAMETERS.mysqlPool` is provided. If no pool is set, it will use
70
+ the HTTP executor (useful for frontends or non-Node runtimes).
71
+
72
+ ```typescript
73
+ import mysql from "mysql2/promise";
74
+ import { GLOBAL_REST_PARAMETERS } from "./shared/rest/C6";
75
+
76
+ GLOBAL_REST_PARAMETERS.mysqlPool = mysql.createPool({
77
+ host: "127.0.0.1",
78
+ user: "root",
79
+ password: "password",
80
+ database: "carbonPHP",
81
+ });
82
+
83
+ // Optional HTTP path:
84
+ // GLOBAL_REST_PARAMETERS.axios = axiosInstance;
85
+ // GLOBAL_REST_PARAMETERS.restURL = "/rest/";
86
+
87
+ // Optional websocket broadcast on writes:
88
+ // GLOBAL_REST_PARAMETERS.websocketBroadcast = (payload) => wsServer.broadcast(JSON.stringify(payload));
89
+ ```
90
+
91
+ ### Request Flow
92
+
93
+ ```mermaid
94
+ flowchart LR
95
+ Client["App code\nActor.Get(...)" ] --> RestRequest["restOrm + restRequest"]
96
+ RestRequest -->|"Node + mysqlPool"| SqlExec["SqlExecutor"] --> MySQL[("MySQL")]
97
+ RestRequest -->|"No pool"| HttpExec["HttpExecutor"] --> RestApi["/rest/:table"]
98
+ RestApi --> Express["ExpressHandler"] --> SqlExec
99
+ ```
100
+
101
+ ### SQL Allowlist
102
+
103
+ To restrict which SQL statements can run in production, set `GLOBAL_REST_PARAMETERS.sqlAllowListPath` to a JSON file
104
+ containing allowed SQL strings. When the path is set, `SqlExecutor` normalizes whitespace and validates each query against
105
+ the allowlist. If the file is missing, an error is thrown; if the SQL is not listed, execution is blocked.
106
+
107
+ ```typescript
108
+ GLOBAL_REST_PARAMETERS.sqlAllowListPath = "/path/to/sqlAllowList.json";
109
+ ```
110
+
111
+ Allowlist format:
112
+
113
+ ```json
114
+ [
115
+ "SELECT * FROM `actor` LIMIT 1"
116
+ ]
117
+ ```
118
+
119
+ Generated tests in `src/__tests__/sakila-db/C6.test.ts` write response fixtures into `src/__tests__/sakila-db/sqlResponses/`
120
+ and compile `src/__tests__/sakila-db/C6.sqlAllowList.json` after the suite finishes. Pass that file path to enable
121
+ validation.
122
+
123
+ When using the REST handler directly, forward the path as well:
124
+
125
+ ```typescript
126
+ app.all("/rest/:table", ExpressHandler({ C6, mysqlPool, sqlAllowListPath }));
127
+ ```
63
128
 
64
129
  ### Generated Tests
65
130
 
66
- Tests are generated for each table in the database. The tests are generated in the same directory as the models.
67
- Our Jest tests are not designed to run immediately. You will need to edit the tests manually to change *xdescribe* with just
68
- *describe*. Once a test does not have xdescribe it will no longer be updated with new generation changes.
131
+ The generator also writes `C6.test.ts` alongside `C6.ts`. Tests use Vitest and the generated bindings. Keep or delete the
132
+ file depending on your workflow.
69
133
 
70
- Note - I prefer to keep tests nested in my IDE project viewer. See the documentation for
71
- [IntelliJ](https://www.jetbrains.com/help/idea/file-nesting-dialog.html) or
72
- [VSCode](https://code.visualstudio.com/updates/v1_67#_explorer-file-nesting).
134
+ The generator also writes `C6.MySqlDump.json`, `C6.mysqldump.sql`, and `C6.mysql.cnf` into the same output directory for
135
+ debugging and inspection.
73
136
 
74
137
  ### Templates
75
138
 
76
- Three templates are used to generate the models. The output will be multiple files; two files for each table in the
77
- database consisting of your GET PUT POST and DELETE methods and a Jest test file, a C6.tsx file which contains all
78
- table information and TypeScript types, and finally a websocket file which contains references to methods that are
79
- generate. Here are the templates used to generate the code:
139
+ Two templates are used to generate the output:
80
140
 
81
141
  1) [C6.ts.handlebars](https://github.com/CarbonORM/CarbonNode/blob/main/scripts/assets/handlebars/C6.ts.handlebars)
82
- 2) [Table.ts.handlebars](https://github.com/CarbonORM/CarbonNode/blob/main/scripts/assets/handlebars/Table.ts.handlebars)
83
- 3) [Websocket.ts.handlebars](https://github.com/CarbonORM/CarbonNode/blob/main/scripts/assets/handlebars/C6RestApi.ts.handlebars)
142
+ 2) [C6.test.ts.handlebars](https://github.com/CarbonORM/CarbonNode/blob/main/scripts/assets/handlebars/C6.test.ts.handlebars)
84
143
 
85
144
  #### Generation Example
86
145
 
87
146
  0) **npx generateRestBindings** is executed.
88
- 1) **The MySQL dump tool** outputs a strcture for every table.
147
+ 1) **The MySQL dump tool** outputs a structure for every table.
89
148
 
90
149
  ```mysql
91
150
  CREATE TABLE actor (
@@ -98,6 +157,7 @@ CREATE TABLE actor (
98
157
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
99
158
  ```
100
159
 
160
+ 2) **The generator** parses the table structure and creates an internal representation.
101
161
  ```typescript
102
162
  export interface iActor {
103
163
  'actor_id'?: number;
@@ -191,52 +251,102 @@ export const Actor = {
191
251
  ```
192
252
 
193
253
  3) **Profit**
194
- - C6 will produce 1-1 constants.
195
-
196
- Allowing you to do:
254
+ You import from the frontend or backend using the same syntax:
197
255
 
198
256
  ```typescript
199
- import { Actor, C6C } from "./api/rest/Actor";
257
+ import { Actor, C6 } from "./shared/rest/C6";
200
258
 
201
259
  // GET
202
- const actors = await Actor.GET({
203
- [C6C.SELECT]: [
260
+ const actors = await Actor.Get({
261
+ [C6.SELECT]: [
204
262
  Actor.ACTOR_ID,
205
263
  Actor.FIRST_NAME,
206
264
  Actor.LAST_NAME,
207
265
  ],
208
- [C6C.WHERE]: {
266
+ [C6.WHERE]: {
209
267
  [Actor.LAST_NAME]: { like: "%PITT%" },
210
268
  },
211
- [C6C.LIMIT]: 10,
269
+ [C6.PAGINATION]: { [C6.LIMIT]: 10 },
212
270
  });
213
271
 
214
272
  // POST
215
- await Actor.POST({
216
- [C6C.DATA]: {
217
- [Actor.FIRST_NAME]: "Brad",
218
- [Actor.LAST_NAME]: "Pitt",
219
- },
273
+ await Actor.Post({
274
+ [Actor.FIRST_NAME]: "Brad",
275
+ [Actor.LAST_NAME]: "Pitt",
220
276
  });
221
277
 
222
- // PUT
223
- await Actor.PUT({
224
- [C6C.WHERE]: {
225
- [Actor.ACTOR_ID]: 42,
226
- },
227
- [C6C.DATA]: {
228
- [Actor.LAST_NAME]: "Updated",
229
- },
278
+ // PUT (singular)
279
+ await Actor.Put({
280
+ [Actor.ACTOR_ID]: 42,
281
+ [Actor.LAST_NAME]: "Updated",
230
282
  });
231
283
 
232
- // DELETE
233
- await Actor.DELETE({
234
- [C6C.WHERE]: {
235
- [Actor.ACTOR_ID]: 42,
236
- },
284
+ // DELETE (singular)
285
+ await Actor.Delete({
286
+ [Actor.ACTOR_ID]: 42,
237
287
  });
238
288
  ```
239
289
 
290
+ Example response payloads (HTTP executor):
291
+
292
+ GET
293
+
294
+ ```json
295
+ {
296
+ "success": true,
297
+ "rest": [
298
+ { "actor_id": 1, "first_name": "PENELOPE", "last_name": "GUINESS" }
299
+ ],
300
+ "next": "Function"
301
+ }
302
+ ```
303
+
304
+ POST
305
+
306
+ ```json
307
+ {
308
+ "success": true,
309
+ "created": 201,
310
+ "rest": { "actor_id": 201, "first_name": "Brad", "last_name": "Pitt" }
311
+ }
312
+ ```
313
+
314
+ PUT
315
+
316
+ ```json
317
+ {
318
+ "success": true,
319
+ "updated": true,
320
+ "rest": { "actor_id": 42, "last_name": "Updated" }
321
+ }
322
+ ```
323
+
324
+ DELETE
325
+
326
+ ```json
327
+ {
328
+ "success": true,
329
+ "deleted": true,
330
+ "rest": { "actor_id": 42 }
331
+ }
332
+ ```
333
+
334
+ SQL executor responses omit `success` and include `sql` for GETs plus `affected` for writes. Express responses from `ExpressHandler` add `success: true`.
335
+
336
+ SQL executor example (GET):
337
+
338
+ ```json
339
+ {
340
+ "rest": [
341
+ { "actor_id": 1, "first_name": "PENELOPE", "last_name": "GUINESS" }
342
+ ],
343
+ "sql": {
344
+ "sql": "SELECT * FROM `actor` LIMIT 10",
345
+ "values": []
346
+ }
347
+ }
348
+ ```
349
+
240
350
  Our CarbonReact extends this solution for automatic state and pagination management.
241
351
 
242
352
 
@@ -258,4 +368,3 @@ This will configure Git to use the hooks in the `.githooks` directory. The hooks
258
368
  # Support and Issues
259
369
 
260
370
  Any issues found should be reported on [GitHub](https://github.com/CarbonORM/CarbonNode/issues).
261
-
@@ -11,6 +11,11 @@ export declare class SqlExecutor<G extends OrmGenerics> extends Executor<G> {
11
11
  [key: string]: any;
12
12
  }): string;
13
13
  private formatValue;
14
+ private stripRequestMetadata;
15
+ private normalizeRequestPayload;
16
+ private extractRequestBody;
17
+ private extractPrimaryKeyValues;
18
+ private broadcastWebsocketIfConfigured;
14
19
  runQuery(): Promise<{
15
20
  rest: any;
16
21
  sql: {
@@ -26,4 +31,5 @@ export declare class SqlExecutor<G extends OrmGenerics> extends Executor<G> {
26
31
  values: any;
27
32
  };
28
33
  }>;
34
+ private validateSqlAllowList;
29
35
  }
@@ -1,7 +1,8 @@
1
1
  import { Request, Response, NextFunction } from "express";
2
2
  import { Pool } from "mysql2/promise";
3
3
  import { iC6Object } from "../types/ormInterfaces";
4
- export declare function ExpressHandler({ C6, mysqlPool }: {
4
+ export declare function ExpressHandler({ C6, mysqlPool, sqlAllowListPath, }: {
5
5
  C6: iC6Object;
6
6
  mysqlPool: Pool;
7
+ sqlAllowListPath?: string;
7
8
  }): (req: Request, res: Response, next: NextFunction) => Promise<void>;
@@ -135,6 +135,16 @@ export interface iGetC6RestResponse<ResponseDataType extends {
135
135
  export type DetermineResponseDataType<Method extends iRestMethods, RestTableInterface extends {
136
136
  [key: string]: any;
137
137
  }, ResponseDataOverrides = {}> = (Method extends 'POST' ? iPostC6RestResponse<RestTableInterface> : Method extends 'GET' ? iGetC6RestResponse<RestTableInterface, ResponseDataOverrides> : Method extends 'PUT' ? iPutC6RestResponse<RestTableInterface> : Method extends 'DELETE' ? iDeleteC6RestResponse<RestTableInterface> : never);
138
+ export type iRestWebsocketPayload = {
139
+ REST: {
140
+ TABLE_NAME: string;
141
+ TABLE_PREFIX: string;
142
+ METHOD: iRestMethods;
143
+ REQUEST: Record<string, any>;
144
+ REQUEST_PRIMARY_KEY: Record<string, any> | null;
145
+ };
146
+ };
147
+ export type tWebsocketBroadcast = (payload: iRestWebsocketPayload) => void | Promise<void>;
138
148
  export interface iRest<RestShortTableName extends string = any, RestTableInterface extends Record<string, any> = any, PrimaryKey extends keyof RestTableInterface & string = keyof RestTableInterface & string> {
139
149
  C6: iC6Object;
140
150
  axios?: AxiosInstance;
@@ -146,7 +156,9 @@ export interface iRest<RestShortTableName extends string = any, RestTableInterfa
146
156
  requestMethod: iRestMethods;
147
157
  clearCache?: () => void;
148
158
  skipPrimaryCheck?: boolean;
159
+ websocketBroadcast?: tWebsocketBroadcast;
149
160
  verbose?: boolean;
161
+ sqlAllowListPath?: string;
150
162
  }
151
163
  export interface iConstraint {
152
164
  TABLE: string;
@@ -0,0 +1,2 @@
1
+ export declare const normalizeSql: (sql: string) => string;
2
+ export declare const loadSqlAllowList: (allowListPath: string) => Promise<Set<string>>;
package/dist/index.cjs.js CHANGED
@@ -2963,6 +2963,63 @@ function normalizeSingularRequest(requestMethod, request, restModel, removedPrim
2963
2963
  return tslib.__assign(tslib.__assign({}, normalized), { dataInsertMultipleRows: dataInsertMultipleRows, cacheResults: cacheResults, fetchDependencies: fetchDependencies, debug: debug, success: success, error: error });
2964
2964
  }
2965
2965
 
2966
+ var allowListCache = new Map();
2967
+ var normalizeSql = function (sql) {
2968
+ return sql.replace(/\s+/g, " ").trim();
2969
+ };
2970
+ var parseAllowList = function (raw, sourcePath) {
2971
+ var parsed;
2972
+ try {
2973
+ parsed = JSON.parse(raw);
2974
+ }
2975
+ catch (error) {
2976
+ throw new Error("SQL allowlist at ".concat(sourcePath, " is not valid JSON."));
2977
+ }
2978
+ if (!Array.isArray(parsed)) {
2979
+ throw new Error("SQL allowlist at ".concat(sourcePath, " must be a JSON array of strings."));
2980
+ }
2981
+ var sqlEntries = parsed
2982
+ .filter(function (entry) { return typeof entry === "string"; })
2983
+ .map(normalizeSql)
2984
+ .filter(function (entry) { return entry.length > 0; });
2985
+ if (sqlEntries.length !== parsed.length) {
2986
+ throw new Error("SQL allowlist at ".concat(sourcePath, " must contain only string entries."));
2987
+ }
2988
+ return sqlEntries;
2989
+ };
2990
+ var loadSqlAllowList = function (allowListPath) { return tslib.__awaiter(void 0, void 0, void 0, function () {
2991
+ var readFile, raw, sqlEntries, allowList;
2992
+ return tslib.__generator(this, function (_a) {
2993
+ switch (_a.label) {
2994
+ case 0:
2995
+ if (allowListCache.has(allowListPath)) {
2996
+ return [2 /*return*/, allowListCache.get(allowListPath)];
2997
+ }
2998
+ if (!isNode()) {
2999
+ throw new Error("SQL allowlist validation requires a Node runtime.");
3000
+ }
3001
+ return [4 /*yield*/, import('node:fs/promises')];
3002
+ case 1:
3003
+ readFile = (_a.sent()).readFile;
3004
+ _a.label = 2;
3005
+ case 2:
3006
+ _a.trys.push([2, 4, , 5]);
3007
+ return [4 /*yield*/, readFile(allowListPath, "utf-8")];
3008
+ case 3:
3009
+ raw = _a.sent();
3010
+ return [3 /*break*/, 5];
3011
+ case 4:
3012
+ _a.sent();
3013
+ throw new Error("SQL allowlist file not found at ".concat(allowListPath, "."));
3014
+ case 5:
3015
+ sqlEntries = parseAllowList(raw, allowListPath);
3016
+ allowList = new Set(sqlEntries);
3017
+ allowListCache.set(allowListPath, allowList);
3018
+ return [2 /*return*/, allowList];
3019
+ }
3020
+ });
3021
+ }); };
3022
+
2966
3023
  var SqlExecutor = /** @class */ (function (_super) {
2967
3024
  tslib.__extends(SqlExecutor, _super);
2968
3025
  function SqlExecutor() {
@@ -2995,10 +3052,10 @@ var SqlExecutor = /** @class */ (function (_super) {
2995
3052
  switch (_a) {
2996
3053
  case 'GET': return [3 /*break*/, 1];
2997
3054
  case 'POST': return [3 /*break*/, 3];
2998
- case 'PUT': return [3 /*break*/, 5];
2999
- case 'DELETE': return [3 /*break*/, 7];
3055
+ case 'PUT': return [3 /*break*/, 6];
3056
+ case 'DELETE': return [3 /*break*/, 9];
3000
3057
  }
3001
- return [3 /*break*/, 9];
3058
+ return [3 /*break*/, 12];
3002
3059
  case 1: return [4 /*yield*/, this.runQuery()];
3003
3060
  case 2:
3004
3061
  rest = _b.sent();
@@ -3006,16 +3063,25 @@ var SqlExecutor = /** @class */ (function (_super) {
3006
3063
  case 3: return [4 /*yield*/, this.runQuery()];
3007
3064
  case 4:
3008
3065
  result = _b.sent();
3066
+ return [4 /*yield*/, this.broadcastWebsocketIfConfigured()];
3067
+ case 5:
3068
+ _b.sent();
3009
3069
  return [2 /*return*/, result];
3010
- case 5: return [4 /*yield*/, this.runQuery()];
3011
- case 6:
3070
+ case 6: return [4 /*yield*/, this.runQuery()];
3071
+ case 7:
3012
3072
  result = _b.sent();
3013
- return [2 /*return*/, result];
3014
- case 7: return [4 /*yield*/, this.runQuery()];
3073
+ return [4 /*yield*/, this.broadcastWebsocketIfConfigured()];
3015
3074
  case 8:
3075
+ _b.sent();
3076
+ return [2 /*return*/, result];
3077
+ case 9: return [4 /*yield*/, this.runQuery()];
3078
+ case 10:
3016
3079
  result = _b.sent();
3080
+ return [4 /*yield*/, this.broadcastWebsocketIfConfigured()];
3081
+ case 11:
3082
+ _b.sent();
3017
3083
  return [2 /*return*/, result];
3018
- case 9: throw new Error("Unsupported request method: ".concat(method));
3084
+ case 12: throw new Error("Unsupported request method: ".concat(method));
3019
3085
  }
3020
3086
  });
3021
3087
  });
@@ -3076,6 +3142,149 @@ var SqlExecutor = /** @class */ (function (_super) {
3076
3142
  return "'".concat(val.toISOString().slice(0, 19).replace('T', ' '), "'");
3077
3143
  return "'".concat(JSON.stringify(val), "'");
3078
3144
  };
3145
+ SqlExecutor.prototype.stripRequestMetadata = function (source) {
3146
+ var ignoredKeys = new Set([
3147
+ C6Constants.SELECT,
3148
+ C6Constants.UPDATE,
3149
+ C6Constants.DELETE,
3150
+ C6Constants.WHERE,
3151
+ C6Constants.JOIN,
3152
+ C6Constants.PAGINATION,
3153
+ C6Constants.INSERT,
3154
+ C6Constants.REPLACE,
3155
+ "dataInsertMultipleRows",
3156
+ "cacheResults",
3157
+ "fetchDependencies",
3158
+ "debug",
3159
+ "success",
3160
+ "error",
3161
+ ]);
3162
+ var filtered = {};
3163
+ for (var _i = 0, _a = Object.entries(source); _i < _a.length; _i++) {
3164
+ var _b = _a[_i], key = _b[0], value = _b[1];
3165
+ if (!ignoredKeys.has(key)) {
3166
+ filtered[key] = value;
3167
+ }
3168
+ }
3169
+ return filtered;
3170
+ };
3171
+ SqlExecutor.prototype.normalizeRequestPayload = function (source) {
3172
+ var _a;
3173
+ var columns = this.config.restModel.COLUMNS;
3174
+ var validColumns = new Set(Object.values(columns));
3175
+ var normalized = {};
3176
+ for (var _i = 0, _b = Object.entries(source); _i < _b.length; _i++) {
3177
+ var _c = _b[_i], key = _c[0], value = _c[1];
3178
+ var shortKey = (_a = columns[key]) !== null && _a !== void 0 ? _a : (key.includes(".") ? key.split(".").pop() : key);
3179
+ if (validColumns.has(shortKey)) {
3180
+ normalized[shortKey] = value;
3181
+ }
3182
+ }
3183
+ return normalized;
3184
+ };
3185
+ SqlExecutor.prototype.extractRequestBody = function () {
3186
+ var _a, _b;
3187
+ var request = this.request;
3188
+ if (this.config.requestMethod === C6Constants.POST) {
3189
+ if (Array.isArray(request.dataInsertMultipleRows) && request.dataInsertMultipleRows.length > 0) {
3190
+ return request.dataInsertMultipleRows[0];
3191
+ }
3192
+ if (C6Constants.INSERT in request) {
3193
+ return (_a = request[C6Constants.INSERT]) !== null && _a !== void 0 ? _a : {};
3194
+ }
3195
+ if (C6Constants.REPLACE in request) {
3196
+ return (_b = request[C6Constants.REPLACE]) !== null && _b !== void 0 ? _b : {};
3197
+ }
3198
+ return this.stripRequestMetadata(request);
3199
+ }
3200
+ if (this.config.requestMethod === C6Constants.PUT) {
3201
+ if (request[C6Constants.UPDATE] && typeof request[C6Constants.UPDATE] === "object") {
3202
+ return request[C6Constants.UPDATE];
3203
+ }
3204
+ return this.stripRequestMetadata(request);
3205
+ }
3206
+ return {};
3207
+ };
3208
+ SqlExecutor.prototype.extractPrimaryKeyValues = function () {
3209
+ var _a, _b, _c;
3210
+ var request = this.request;
3211
+ var where = request === null || request === void 0 ? void 0 : request[C6Constants.WHERE];
3212
+ var sources = [request, (where && typeof where === "object" && !Array.isArray(where)) ? where : undefined];
3213
+ var columns = this.config.restModel.COLUMNS;
3214
+ var primaryShorts = (_a = this.config.restModel.PRIMARY_SHORT) !== null && _a !== void 0 ? _a : [];
3215
+ var primaryFulls = (_b = this.config.restModel.PRIMARY) !== null && _b !== void 0 ? _b : [];
3216
+ var pkValues = {};
3217
+ var _loop_1 = function (pkShort) {
3218
+ var value = undefined;
3219
+ for (var _d = 0, sources_1 = sources; _d < sources_1.length; _d++) {
3220
+ var source = sources_1[_d];
3221
+ if (source && pkShort in source) {
3222
+ value = source[pkShort];
3223
+ break;
3224
+ }
3225
+ }
3226
+ if (value === undefined) {
3227
+ var fullKey = (_c = primaryFulls.find(function (key) { return key.endsWith("." + pkShort); })) !== null && _c !== void 0 ? _c : Object.keys(columns).find(function (key) { return columns[key] === pkShort; });
3228
+ if (fullKey) {
3229
+ for (var _e = 0, sources_2 = sources; _e < sources_2.length; _e++) {
3230
+ var source = sources_2[_e];
3231
+ if (source && fullKey in source) {
3232
+ value = source[fullKey];
3233
+ break;
3234
+ }
3235
+ }
3236
+ }
3237
+ }
3238
+ if (value !== undefined) {
3239
+ pkValues[pkShort] = value;
3240
+ }
3241
+ };
3242
+ for (var _i = 0, primaryShorts_1 = primaryShorts; _i < primaryShorts_1.length; _i++) {
3243
+ var pkShort = primaryShorts_1[_i];
3244
+ _loop_1(pkShort);
3245
+ }
3246
+ if (primaryShorts.length > 0 && Object.keys(pkValues).length < primaryShorts.length) {
3247
+ return null;
3248
+ }
3249
+ return Object.keys(pkValues).length > 0 ? pkValues : null;
3250
+ };
3251
+ SqlExecutor.prototype.broadcastWebsocketIfConfigured = function () {
3252
+ return tslib.__awaiter(this, void 0, void 0, function () {
3253
+ var broadcast, payload, error_1;
3254
+ var _a, _b;
3255
+ return tslib.__generator(this, function (_c) {
3256
+ switch (_c.label) {
3257
+ case 0:
3258
+ broadcast = this.config.websocketBroadcast;
3259
+ if (!broadcast || this.config.requestMethod === C6Constants.GET)
3260
+ return [2 /*return*/];
3261
+ payload = {
3262
+ REST: {
3263
+ TABLE_NAME: this.config.restModel.TABLE_NAME,
3264
+ TABLE_PREFIX: (_b = (_a = this.config.C6) === null || _a === void 0 ? void 0 : _a.PREFIX) !== null && _b !== void 0 ? _b : "",
3265
+ METHOD: this.config.requestMethod,
3266
+ REQUEST: this.normalizeRequestPayload(this.extractRequestBody()),
3267
+ REQUEST_PRIMARY_KEY: this.extractPrimaryKeyValues(),
3268
+ },
3269
+ };
3270
+ _c.label = 1;
3271
+ case 1:
3272
+ _c.trys.push([1, 3, , 4]);
3273
+ return [4 /*yield*/, broadcast(payload)];
3274
+ case 2:
3275
+ _c.sent();
3276
+ return [3 /*break*/, 4];
3277
+ case 3:
3278
+ error_1 = _c.sent();
3279
+ if (this.config.verbose) {
3280
+ console.error("[SQL EXECUTOR] websocketBroadcast failed", error_1);
3281
+ }
3282
+ return [3 /*break*/, 4];
3283
+ case 4: return [2 /*return*/];
3284
+ }
3285
+ });
3286
+ });
3287
+ };
3079
3288
  SqlExecutor.prototype.runQuery = function () {
3080
3289
  return tslib.__awaiter(this, void 0, void 0, function () {
3081
3290
  var TABLE_NAME, method, builder, QueryResult, formatted, toUnnamed, _a, sql, values;
@@ -3107,6 +3316,9 @@ var SqlExecutor = /** @class */ (function (_super) {
3107
3316
  this.config.verbose && console.log("[SQL EXECUTOR] \uD83E\uDDE0 Formatted ".concat(method.toUpperCase(), " SQL:"), formatted);
3108
3317
  toUnnamed = namedPlaceholders();
3109
3318
  _a = toUnnamed(QueryResult.sql, QueryResult.params), sql = _a[0], values = _a[1];
3319
+ return [4 /*yield*/, this.validateSqlAllowList(sql)];
3320
+ case 1:
3321
+ _b.sent();
3110
3322
  return [4 /*yield*/, this.withConnection(function (conn) { return tslib.__awaiter(_this, void 0, void 0, function () {
3111
3323
  var result;
3112
3324
  return tslib.__generator(this, function (_a) {
@@ -3131,7 +3343,29 @@ var SqlExecutor = /** @class */ (function (_super) {
3131
3343
  }
3132
3344
  });
3133
3345
  }); })];
3134
- case 1: return [2 /*return*/, _b.sent()];
3346
+ case 2: return [2 /*return*/, _b.sent()];
3347
+ }
3348
+ });
3349
+ });
3350
+ };
3351
+ SqlExecutor.prototype.validateSqlAllowList = function (sql) {
3352
+ return tslib.__awaiter(this, void 0, void 0, function () {
3353
+ var allowListPath, allowList, normalized;
3354
+ return tslib.__generator(this, function (_a) {
3355
+ switch (_a.label) {
3356
+ case 0:
3357
+ allowListPath = this.config.sqlAllowListPath;
3358
+ if (!allowListPath) {
3359
+ return [2 /*return*/];
3360
+ }
3361
+ return [4 /*yield*/, loadSqlAllowList(allowListPath)];
3362
+ case 1:
3363
+ allowList = _a.sent();
3364
+ normalized = normalizeSql(sql);
3365
+ if (!allowList.has(normalized)) {
3366
+ throw new Error("SQL statement is not permitted by allowlist (".concat(allowListPath, ")."));
3367
+ }
3368
+ return [2 /*return*/];
3135
3369
  }
3136
3370
  });
3137
3371
  });
@@ -3148,7 +3382,7 @@ var SqlExecutor$1 = /*#__PURE__*/Object.freeze({
3148
3382
  // note sure how it would help anyone actually...
3149
3383
  function ExpressHandler(_a) {
3150
3384
  var _this = this;
3151
- var C6 = _a.C6, mysqlPool = _a.mysqlPool;
3385
+ var C6 = _a.C6, mysqlPool = _a.mysqlPool, sqlAllowListPath = _a.sqlAllowListPath;
3152
3386
  return function (req, res, next) { return tslib.__awaiter(_this, void 0, void 0, function () {
3153
3387
  var incomingMethod, table, primary, methodOverrideRaw, methodOverride, treatAsGet, method, payload, restModel, primaryKeys_1, primaryShortKeys_1, columnMap_1, resolveShortKey_1, hasPrimaryKeyValues, primaryKeyName, response, err_1;
3154
3388
  var _a, _b, _c, _d, _e, _f, _g;
@@ -3234,6 +3468,7 @@ function ExpressHandler(_a) {
3234
3468
  return [4 /*yield*/, restRequest({
3235
3469
  C6: C6,
3236
3470
  mysqlPool: mysqlPool,
3471
+ sqlAllowListPath: sqlAllowListPath,
3237
3472
  requestMethod: method,
3238
3473
  restModel: C6.TABLES[table]
3239
3474
  })(payload)];
@@ -3396,7 +3631,9 @@ exports.isLocal = isLocal;
3396
3631
  exports.isNode = isNode;
3397
3632
  exports.isTest = isTest;
3398
3633
  exports.isVerbose = isVerbose;
3634
+ exports.loadSqlAllowList = loadSqlAllowList;
3399
3635
  exports.normalizeSingularRequest = normalizeSingularRequest;
3636
+ exports.normalizeSql = normalizeSql;
3400
3637
  exports.onError = onError;
3401
3638
  exports.onSuccess = onSuccess;
3402
3639
  exports.removeInvalidKeys = removeInvalidKeys;