@carbonorm/carbonnode 6.1.0 → 6.1.1

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 (65) hide show
  1. package/README.md +509 -292
  2. package/dist/index.cjs.js +209 -37
  3. package/dist/index.cjs.js.map +1 -1
  4. package/dist/index.esm.js +208 -38
  5. package/dist/index.esm.js.map +1 -1
  6. package/dist/orm/utils/sqlUtils.d.ts +1 -0
  7. package/dist/types/ormInterfaces.d.ts +1 -0
  8. package/dist/utils/sqlAllowList.d.ts +5 -3
  9. package/package.json +1 -1
  10. package/src/__tests__/fixtures/c6.fixture.ts +33 -0
  11. package/src/__tests__/sakila-db/C6.js +1 -1
  12. package/src/__tests__/sakila-db/C6.mysqldump.json +1 -1
  13. package/src/__tests__/sakila-db/C6.mysqldump.sql +1 -1
  14. package/src/__tests__/sakila-db/C6.ts +1 -1
  15. package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.json +6 -6
  16. package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.latest.json +3 -3
  17. package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.json +1 -1
  18. package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.lookup.json +3 -3
  19. package/src/__tests__/sakila-db/sqlResponses/C6.address.post.json +10 -10
  20. package/src/__tests__/sakila-db/sqlResponses/C6.address.post.latest.json +5 -5
  21. package/src/__tests__/sakila-db/sqlResponses/C6.address.put.json +1 -1
  22. package/src/__tests__/sakila-db/sqlResponses/C6.address.put.lookup.json +5 -5
  23. package/src/__tests__/sakila-db/sqlResponses/C6.category.post.json +4 -4
  24. package/src/__tests__/sakila-db/sqlResponses/C6.category.post.latest.json +2 -2
  25. package/src/__tests__/sakila-db/sqlResponses/C6.category.put.json +1 -1
  26. package/src/__tests__/sakila-db/sqlResponses/C6.category.put.lookup.json +2 -2
  27. package/src/__tests__/sakila-db/sqlResponses/C6.city.post.json +4 -4
  28. package/src/__tests__/sakila-db/sqlResponses/C6.city.post.latest.json +2 -2
  29. package/src/__tests__/sakila-db/sqlResponses/C6.city.put.json +1 -1
  30. package/src/__tests__/sakila-db/sqlResponses/C6.city.put.lookup.json +2 -2
  31. package/src/__tests__/sakila-db/sqlResponses/C6.country.post.json +4 -4
  32. package/src/__tests__/sakila-db/sqlResponses/C6.country.post.latest.json +2 -2
  33. package/src/__tests__/sakila-db/sqlResponses/C6.country.put.json +1 -1
  34. package/src/__tests__/sakila-db/sqlResponses/C6.country.put.lookup.json +2 -2
  35. package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.json +10 -10
  36. package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.latest.json +5 -5
  37. package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.json +1 -1
  38. package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.lookup.json +5 -5
  39. package/src/__tests__/sakila-db/sqlResponses/C6.film.post.json +4 -4
  40. package/src/__tests__/sakila-db/sqlResponses/C6.film.post.latest.json +2 -2
  41. package/src/__tests__/sakila-db/sqlResponses/C6.film.put.json +1 -1
  42. package/src/__tests__/sakila-db/sqlResponses/C6.film.put.lookup.json +2 -2
  43. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.json +2 -2
  44. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.latest.json +1 -1
  45. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.json +1 -1
  46. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.lookup.json +1 -1
  47. package/src/__tests__/sakila-db/sqlResponses/C6.language.post.json +4 -4
  48. package/src/__tests__/sakila-db/sqlResponses/C6.language.post.latest.json +2 -2
  49. package/src/__tests__/sakila-db/sqlResponses/C6.language.put.json +1 -1
  50. package/src/__tests__/sakila-db/sqlResponses/C6.language.put.lookup.json +2 -2
  51. package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.json +4 -4
  52. package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.latest.json +2 -2
  53. package/src/__tests__/sakila-db/sqlResponses/C6.payment.put.lookup.json +2 -2
  54. package/src/__tests__/sakila-db/sqlResponses/C6.rental.join.json +10 -10
  55. package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.json +6 -6
  56. package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.latest.json +3 -3
  57. package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.json +1 -1
  58. package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.lookup.json +3 -3
  59. package/src/__tests__/sqlAllowList.test.ts +56 -1
  60. package/src/__tests__/sqlBuilders.test.ts +38 -1
  61. package/src/executors/SqlExecutor.ts +4 -3
  62. package/src/orm/builders/ConditionBuilder.ts +3 -10
  63. package/src/orm/utils/sqlUtils.ts +172 -4
  64. package/src/types/ormInterfaces.ts +1 -0
  65. package/src/utils/sqlAllowList.ts +44 -11
@@ -7,8 +7,8 @@
7
7
  "staff_id": 1,
8
8
  "rental_id": 1,
9
9
  "amount": 1,
10
- "payment_date": "2026-02-14 22:07:15",
11
- "last_update": "2026-02-14 22:07:15",
10
+ "payment_date": "2026-02-16 21:50:08",
11
+ "last_update": "2026-02-16 21:50:08",
12
12
  "payment_id": 16050
13
13
  }
14
14
  ],
@@ -19,8 +19,8 @@
19
19
  1,
20
20
  1,
21
21
  1,
22
- "2026-02-14 22:07:15",
23
- "2026-02-14 22:07:15"
22
+ "2026-02-16 21:50:08",
23
+ "2026-02-16 21:50:08"
24
24
  ]
25
25
  }
26
26
  }
@@ -6,8 +6,8 @@
6
6
  "staff_id": 1,
7
7
  "rental_id": 1,
8
8
  "amount": "1.00",
9
- "payment_date": "2026-02-14T22:07:15.000Z",
10
- "last_update": "2026-02-14T22:07:15.000Z"
9
+ "payment_date": "2026-02-16T21:50:08.000Z",
10
+ "last_update": "2026-02-16T21:50:08.000Z"
11
11
  }
12
12
  ],
13
13
  "sql": {
@@ -6,8 +6,8 @@
6
6
  "staff_id": 1,
7
7
  "rental_id": 1,
8
8
  "amount": "1.00",
9
- "payment_date": "2026-02-14T22:07:15.000Z",
10
- "last_update": "2026-02-14T22:07:15.000Z"
9
+ "payment_date": "2026-02-16T21:50:08.000Z",
10
+ "last_update": "2026-02-16T21:50:08.000Z"
11
11
  }
12
12
  ],
13
13
  "sql": {
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "rest": [
3
3
  {
4
- "rental_id": 76,
5
- "rental_date": "2005-05-25T11:30:37.000Z",
6
- "inventory_id": 3021,
7
- "customer_id": 1,
8
- "return_date": "2005-06-03T12:00:37.000Z",
9
- "staff_id": 2,
4
+ "rental_id": 1,
5
+ "rental_date": "2005-05-24T22:53:30.000Z",
6
+ "inventory_id": 367,
7
+ "customer_id": 130,
8
+ "return_date": "2005-05-26T22:04:30.000Z",
9
+ "staff_id": 1,
10
10
  "last_update": "2006-02-15T04:57:20.000Z",
11
11
  "store_id": 1,
12
- "first_name": "MARY",
13
- "last_name": "SMITH",
14
- "email": "MARY.SMITH@sakilacustomer.org",
15
- "address_id": 5,
12
+ "first_name": "CHARLOTTE",
13
+ "last_name": "HUNTER",
14
+ "email": "CHARLOTTE.HUNTER@sakilacustomer.org",
15
+ "address_id": 134,
16
16
  "active": 1,
17
17
  "create_date": "2006-02-14T22:04:36.000Z"
18
18
  }
@@ -3,24 +3,24 @@
3
3
  "insertId": 16050,
4
4
  "rest": [
5
5
  {
6
- "rental_date": "2026-02-14 22:07:15",
6
+ "rental_date": "2026-02-16 21:50:08",
7
7
  "inventory_id": 1,
8
8
  "customer_id": 1,
9
- "return_date": "2026-02-14 22:07:15",
9
+ "return_date": "2026-02-16 21:50:08",
10
10
  "staff_id": 1,
11
- "last_update": "2026-02-14 22:07:15",
11
+ "last_update": "2026-02-16 21:50:08",
12
12
  "rental_id": 16050
13
13
  }
14
14
  ],
15
15
  "sql": {
16
16
  "sql": "INSERT INTO `rental` (\n `rental_date`, `inventory_id`, `customer_id`, `return_date`, `staff_id`, `last_update`\n ) VALUES\n (?, ?, ?, ?, ?, ?)",
17
17
  "values": [
18
- "2026-02-14 22:07:15",
18
+ "2026-02-16 21:50:08",
19
19
  1,
20
20
  1,
21
- "2026-02-14 22:07:15",
21
+ "2026-02-16 21:50:08",
22
22
  1,
23
- "2026-02-14 22:07:15"
23
+ "2026-02-16 21:50:08"
24
24
  ]
25
25
  }
26
26
  }
@@ -2,12 +2,12 @@
2
2
  "rest": [
3
3
  {
4
4
  "rental_id": 16050,
5
- "rental_date": "2026-02-14T22:07:15.000Z",
5
+ "rental_date": "2026-02-16T21:50:08.000Z",
6
6
  "inventory_id": 1,
7
7
  "customer_id": 1,
8
- "return_date": "2026-02-14T22:07:15.000Z",
8
+ "return_date": "2026-02-16T21:50:08.000Z",
9
9
  "staff_id": 1,
10
- "last_update": "2026-02-14T22:07:15.000Z"
10
+ "last_update": "2026-02-16T21:50:08.000Z"
11
11
  }
12
12
  ],
13
13
  "sql": {
@@ -5,7 +5,7 @@
5
5
  "sql": {
6
6
  "sql": "UPDATE `rental` SET `rental_date` = ? WHERE (rental.rental_id) = ?",
7
7
  "values": [
8
- "2026-02-14 22:07:15",
8
+ "2026-02-16 21:50:08",
9
9
  16050
10
10
  ]
11
11
  }
@@ -2,12 +2,12 @@
2
2
  "rest": [
3
3
  {
4
4
  "rental_id": 16050,
5
- "rental_date": "2026-02-14T22:07:15.000Z",
5
+ "rental_date": "2026-02-16T21:50:08.000Z",
6
6
  "inventory_id": 1,
7
7
  "customer_id": 1,
8
- "return_date": "2026-02-14T22:07:15.000Z",
8
+ "return_date": "2026-02-16T21:50:08.000Z",
9
9
  "staff_id": 1,
10
- "last_update": "2026-02-14T22:07:15.000Z"
10
+ "last_update": "2026-02-16T21:50:08.000Z"
11
11
  }
12
12
  ],
13
13
  "sql": {
@@ -1,6 +1,6 @@
1
1
  import {describe, expect, it, vi} from "vitest";
2
2
  import path from "node:path";
3
- import {mkdir, readdir, readFile, writeFile} from "node:fs/promises";
3
+ import {mkdir, readdir, readFile, unlink, writeFile} from "node:fs/promises";
4
4
  import {Actor, C6, GLOBAL_REST_PARAMETERS} from "./sakila-db/C6.js";
5
5
  import {collectSqlAllowListEntries, compileSqlAllowList, extractSqlEntries, loadSqlAllowList, normalizeSql} from "../utils/sqlAllowList";
6
6
 
@@ -42,18 +42,21 @@ const compileSqlAllowListFromFixtures = async (): Promise<string[]> => {
42
42
  const globalRestParameters = GLOBAL_REST_PARAMETERS as typeof GLOBAL_REST_PARAMETERS & {
43
43
  mysqlPool?: unknown;
44
44
  sqlAllowListPath?: string;
45
+ sqlQueryNormalizer?: (sql: string) => string;
45
46
  verbose?: boolean;
46
47
  };
47
48
 
48
49
  const snapshotGlobals = () => ({
49
50
  mysqlPool: globalRestParameters.mysqlPool,
50
51
  sqlAllowListPath: globalRestParameters.sqlAllowListPath,
52
+ sqlQueryNormalizer: globalRestParameters.sqlQueryNormalizer,
51
53
  verbose: globalRestParameters.verbose,
52
54
  });
53
55
 
54
56
  const restoreGlobals = (snapshot: ReturnType<typeof snapshotGlobals>) => {
55
57
  globalRestParameters.mysqlPool = snapshot.mysqlPool;
56
58
  globalRestParameters.sqlAllowListPath = snapshot.sqlAllowListPath;
59
+ globalRestParameters.sqlQueryNormalizer = snapshot.sqlQueryNormalizer;
57
60
  globalRestParameters.verbose = snapshot.verbose;
58
61
  };
59
62
 
@@ -127,6 +130,58 @@ describe("SQL allowlist", () => {
127
130
  }
128
131
  });
129
132
 
133
+ it("supports custom SQL normalization via GLOBAL_REST_PARAMETERS.sqlQueryNormalizer", async () => {
134
+ await mkdir(fixturesDir, {recursive: true});
135
+ const lowerCasePath = path.join(fixturesDir, "sqlAllowList.lowercase.json");
136
+
137
+ const {pool} = buildMockPool([
138
+ {actor_id: 1, first_name: "PENELOPE", last_name: "GUINESS"},
139
+ ]);
140
+
141
+ const originalGlobals = snapshotGlobals();
142
+ try {
143
+ globalRestParameters.mysqlPool = pool as any;
144
+ globalRestParameters.sqlAllowListPath = undefined;
145
+ globalRestParameters.sqlQueryNormalizer = undefined;
146
+ globalRestParameters.verbose = false;
147
+
148
+ const baseline = await Actor.Get({
149
+ [C6.PAGINATION]: {[C6.LIMIT]: 1},
150
+ cacheResults: false,
151
+ } as any);
152
+
153
+ const normalizedBaseline = normalizeSql((baseline as any).sql.sql as string);
154
+ await writeFile(
155
+ lowerCasePath,
156
+ JSON.stringify([normalizedBaseline.toLowerCase()], null, 2),
157
+ );
158
+
159
+ globalRestParameters.sqlAllowListPath = lowerCasePath;
160
+ globalRestParameters.sqlQueryNormalizer = (sql: string) => sql.toLowerCase();
161
+
162
+ await expect(
163
+ Actor.Get({
164
+ [C6.PAGINATION]: {[C6.LIMIT]: 1},
165
+ cacheResults: false,
166
+ } as any),
167
+ ).resolves.toMatchObject({
168
+ rest: baseline.rest,
169
+ });
170
+
171
+ globalRestParameters.sqlQueryNormalizer = undefined;
172
+
173
+ await expect(
174
+ Actor.Get({
175
+ [C6.PAGINATION]: {[C6.LIMIT]: 1},
176
+ cacheResults: false,
177
+ } as any),
178
+ ).rejects.toThrow("SQL statement is not permitted");
179
+ } finally {
180
+ await unlink(lowerCasePath).catch(() => undefined);
181
+ restoreGlobals(originalGlobals);
182
+ }
183
+ });
184
+
130
185
  it("normalizes multi-row VALUES with variable row counts", () => {
131
186
  const oneRow = `
132
187
  INSERT INTO \`valuation_report_comparables\` (\`report_id\`, \`unit_id\`, \`subject_unit_id\`)
@@ -5,7 +5,7 @@ import { PostQueryBuilder } from '../orm/queries/PostQueryBuilder';
5
5
  import { UpdateQueryBuilder } from '../orm/queries/UpdateQueryBuilder';
6
6
  import { DeleteQueryBuilder } from '../orm/queries/DeleteQueryBuilder';
7
7
  import { alias, call, distinct, fn, lit, order } from '../orm/queryHelpers';
8
- import { buildTestConfig, buildBinaryTestConfig, buildBinaryTestConfigFqn } from './fixtures/c6.fixture';
8
+ import { buildTestConfig, buildBinaryTestConfig, buildBinaryTestConfigFqn, buildTemporalTestConfig } from './fixtures/c6.fixture';
9
9
 
10
10
  describe('SQL Builders', () => {
11
11
  it('builds SELECT with JOIN, WHERE, GROUP BY, HAVING and default LIMIT', () => {
@@ -381,4 +381,41 @@ describe('SQL Builders', () => {
381
381
  expect(Buffer.isBuffer(buf)).toBe(true);
382
382
  expect((buf as Buffer).length).toBe(16);
383
383
  });
384
+
385
+ it('serializes ISO-8601 strings for TIMESTAMP columns in INSERT params', () => {
386
+ const config = buildTemporalTestConfig();
387
+ const qb = new PostQueryBuilder(config as any, {
388
+ [C6C.INSERT]: {
389
+ 'events.read_at': '2026-02-16T21:27:06.679Z'
390
+ }
391
+ } as any, false);
392
+
393
+ const { params } = qb.build('events');
394
+ expect(params).toEqual(['2026-02-16 21:27:06.679']);
395
+ });
396
+
397
+ it('serializes ISO-8601 strings for DATE columns in UPDATE params', () => {
398
+ const config = buildTemporalTestConfig();
399
+ const qb = new UpdateQueryBuilder(config as any, {
400
+ [C6C.UPDATE]: {
401
+ 'events.read_on': '2026-02-16T21:27:06.679Z'
402
+ },
403
+ WHERE: { 'events.id': [C6C.EQUAL, 1] }
404
+ } as any, false);
405
+
406
+ const { params } = qb.build('events');
407
+ expect(params).toEqual(['2026-02-16', 1]);
408
+ });
409
+
410
+ it('serializes offset ISO-8601 strings for TIME columns in WHERE params', () => {
411
+ const config = buildTemporalTestConfig();
412
+ const qb = new SelectQueryBuilder(config as any, {
413
+ WHERE: {
414
+ 'events.read_time': [C6C.EQUAL, [C6C.LIT, '2026-02-16T16:27:06.679-05:00']]
415
+ }
416
+ } as any, false);
417
+
418
+ const { params } = qb.build('events');
419
+ expect(params).toEqual(['21:27:06.679']);
420
+ });
384
421
  });
@@ -22,7 +22,7 @@ import logSql, {
22
22
  } from "../utils/logSql";
23
23
  import { normalizeSingularRequest } from "../utils/normalizeSingularRequest";
24
24
  import {sortAndSerializeQueryObject} from "../utils/sortAndSerializeQueryObject";
25
- import { loadSqlAllowList, normalizeSql } from "../utils/sqlAllowList";
25
+ import { loadSqlAllowList, normalizeSqlWith } from "../utils/sqlAllowList";
26
26
  import { getLogContext, LogLevel, logWithLevel } from "../utils/logLevel";
27
27
 
28
28
  const SQL_ALLOWLIST_BLOCKED_CODE = "SQL_ALLOWLIST_BLOCKED";
@@ -1070,8 +1070,9 @@ export class SqlExecutor<
1070
1070
  return "not verified";
1071
1071
  }
1072
1072
 
1073
- const allowList = await loadSqlAllowList(allowListPath);
1074
- const normalized = normalizeSql(sql);
1073
+ const sqlQueryNormalizer = this.config.sqlQueryNormalizer;
1074
+ const allowList = await loadSqlAllowList(allowListPath, sqlQueryNormalizer);
1075
+ const normalized = normalizeSqlWith(sql, sqlQueryNormalizer);
1075
1076
  if (!allowList.has(normalized)) {
1076
1077
  throw createSqlAllowListBlockedError({
1077
1078
  tableName:
@@ -1,7 +1,7 @@
1
1
  import {C6C} from "../../constants/C6Constants";
2
2
  import {OrmGenerics} from "../../types/ormGenerics";
3
3
  import {DetermineResponseDataType} from "../../types/ormInterfaces";
4
- import {convertHexIfBinary, SqlBuilderResult} from "../utils/sqlUtils";
4
+ import {convertSqlValueForColumn, SqlBuilderResult} from "../utils/sqlUtils";
5
5
  import {AggregateBuilder} from "./AggregateBuilder";
6
6
  import {isDerivedTableKey} from "../queryHelpers";
7
7
  import {getLogContext, LogLevel, logWithLevel} from "../../utils/logLevel";
@@ -175,15 +175,8 @@ export abstract class ConditionBuilder<
175
175
  column: string,
176
176
  value: any
177
177
  ): string {
178
- // Determine column definition from C6.TABLES to support type-aware conversions (e.g., BINARY hex -> Buffer)
179
- let columnDef: any | undefined;
180
- if (typeof column === 'string' && column.includes('.')) {
181
- const [tableName, colName] = column.split('.', 2);
182
- const table = this.config.C6?.TABLES?.[tableName];
183
- // Support both short-keyed and fully-qualified TYPE_VALIDATION entries
184
- columnDef = table?.TYPE_VALIDATION?.[colName] ?? table?.TYPE_VALIDATION?.[`${tableName}.${colName}`];
185
- }
186
- const val = convertHexIfBinary(column, value, columnDef);
178
+ const columnDef = this.resolveColumnDefinition(column);
179
+ const val = convertSqlValueForColumn(column, value, columnDef);
187
180
 
188
181
  if (this.useNamedParams) {
189
182
  const key = `param${Object.keys(params).length}`;
@@ -1,6 +1,3 @@
1
-
2
-
3
-
4
1
  export interface SqlBuilderResult {
5
2
  sql: string;
6
3
  params: any[] | { [key: string]: any }; // params can be an array or an object for named placeholders
@@ -16,9 +13,180 @@ export function convertHexIfBinary(
16
13
  typeof val === 'string' &&
17
14
  /^[0-9a-fA-F]{32}$/.test(val) &&
18
15
  typeof columnDef === 'object' &&
19
- columnDef.MYSQL_TYPE.toUpperCase().includes('BINARY')
16
+ String(columnDef.MYSQL_TYPE ?? '').toUpperCase().includes('BINARY')
20
17
  ) {
21
18
  return Buffer.from(val, 'hex');
22
19
  }
23
20
  return val;
24
21
  }
22
+
23
+ type TemporalMysqlType = 'date' | 'datetime' | 'timestamp' | 'time' | 'year';
24
+
25
+ const TEMPORAL_TYPES = new Set<TemporalMysqlType>([
26
+ 'date',
27
+ 'datetime',
28
+ 'timestamp',
29
+ 'time',
30
+ 'year',
31
+ ]);
32
+
33
+ const MYSQL_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
34
+ const MYSQL_DATETIME_REGEX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{1,6})?$/;
35
+ const MYSQL_TIME_REGEX = /^-?\d{2,3}:\d{2}:\d{2}(?:\.\d{1,6})?$/;
36
+ const ISO_DATETIME_REGEX = /^(\d{4}-\d{2}-\d{2})[Tt](\d{2}:\d{2}:\d{2})(\.\d{1,6})?([zZ]|[+-]\d{2}:\d{2})?$/;
37
+
38
+ const pad2 = (value: number): string => value.toString().padStart(2, '0');
39
+
40
+ function trimFraction(value: string, precision: number): string {
41
+ const [base, fractionRaw] = value.split('.', 2);
42
+ if (precision <= 0 || !fractionRaw) return base;
43
+ return `${base}.${fractionRaw.slice(0, precision).padEnd(precision, '0')}`;
44
+ }
45
+
46
+ function normalizeFraction(raw: string | undefined, precision: number): string {
47
+ if (precision <= 0) return '';
48
+ if (!raw) return '';
49
+ const digits = raw.startsWith('.') ? raw.slice(1) : raw;
50
+ return `.${digits.slice(0, precision).padEnd(precision, '0')}`;
51
+ }
52
+
53
+ function formatDateUtc(value: Date): string {
54
+ return `${value.getUTCFullYear()}-${pad2(value.getUTCMonth() + 1)}-${pad2(value.getUTCDate())}`;
55
+ }
56
+
57
+ function formatTimeUtc(value: Date, precision: number): string {
58
+ const base = `${pad2(value.getUTCHours())}:${pad2(value.getUTCMinutes())}:${pad2(value.getUTCSeconds())}`;
59
+ if (precision <= 0) return base;
60
+
61
+ const millis = value.getUTCMilliseconds().toString().padStart(3, '0');
62
+ const fraction = millis.slice(0, Math.min(precision, 3)).padEnd(precision, '0');
63
+ return `${base}.${fraction}`;
64
+ }
65
+
66
+ function formatDateTimeUtc(value: Date, precision: number): string {
67
+ return `${formatDateUtc(value)} ${formatTimeUtc(value, precision)}`;
68
+ }
69
+
70
+ function parseEpochNumber(value: number): Date | undefined {
71
+ if (!Number.isFinite(value)) return undefined;
72
+ const abs = Math.abs(value);
73
+ if (abs >= 1e12) {
74
+ const date = new Date(value);
75
+ return Number.isNaN(date.getTime()) ? undefined : date;
76
+ }
77
+ if (abs >= 1e9) {
78
+ const date = new Date(value * 1000);
79
+ return Number.isNaN(date.getTime()) ? undefined : date;
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ function parseTemporalType(columnDef?: any): { baseType?: TemporalMysqlType; precision: number } {
85
+ const raw = String(columnDef?.MYSQL_TYPE ?? '').trim().toLowerCase();
86
+ if (!raw) return { baseType: undefined, precision: 0 };
87
+
88
+ const base = raw.split(/[\s(]/, 1)[0] as TemporalMysqlType;
89
+ if (!TEMPORAL_TYPES.has(base)) return { baseType: undefined, precision: 0 };
90
+
91
+ const precisionMatch = raw.match(/^(?:datetime|timestamp|time)\((\d+)\)/);
92
+ if (!precisionMatch) return { baseType: base, precision: 0 };
93
+ const parsed = Number.parseInt(precisionMatch[1], 10);
94
+ if (!Number.isFinite(parsed)) return { baseType: base, precision: 0 };
95
+ return { baseType: base, precision: Math.max(0, Math.min(6, parsed)) };
96
+ }
97
+
98
+ function normalizeTemporalString(
99
+ value: string,
100
+ baseType: TemporalMysqlType,
101
+ precision: number,
102
+ ): string {
103
+ const trimmed = value.trim();
104
+ if (!trimmed) return value;
105
+
106
+ if (baseType === 'date') {
107
+ if (MYSQL_DATE_REGEX.test(trimmed)) return trimmed;
108
+ const iso = trimmed.match(ISO_DATETIME_REGEX);
109
+ if (iso) {
110
+ const [, datePart, , , timezonePart] = iso;
111
+ if (!timezonePart) return datePart;
112
+ const parsed = new Date(trimmed);
113
+ return Number.isNaN(parsed.getTime()) ? value : formatDateUtc(parsed);
114
+ }
115
+ const parsed = new Date(trimmed);
116
+ return Number.isNaN(parsed.getTime()) ? value : formatDateUtc(parsed);
117
+ }
118
+
119
+ if (baseType === 'time') {
120
+ if (MYSQL_TIME_REGEX.test(trimmed)) return trimFraction(trimmed, precision);
121
+ const iso = trimmed.match(ISO_DATETIME_REGEX);
122
+ if (iso) {
123
+ const [, , timePart, fractionPart, timezonePart] = iso;
124
+ if (!timezonePart) {
125
+ return `${timePart}${normalizeFraction(fractionPart, precision)}`;
126
+ }
127
+ const parsed = new Date(trimmed);
128
+ return Number.isNaN(parsed.getTime()) ? value : formatTimeUtc(parsed, precision);
129
+ }
130
+ const parsed = new Date(trimmed);
131
+ return Number.isNaN(parsed.getTime()) ? value : formatTimeUtc(parsed, precision);
132
+ }
133
+
134
+ if (baseType === 'year') {
135
+ if (/^\d{2,4}$/.test(trimmed)) return trimmed;
136
+ const parsed = new Date(trimmed);
137
+ return Number.isNaN(parsed.getTime()) ? value : String(parsed.getUTCFullYear());
138
+ }
139
+
140
+ if (MYSQL_DATETIME_REGEX.test(trimmed)) return trimFraction(trimmed, precision);
141
+ const iso = trimmed.match(ISO_DATETIME_REGEX);
142
+ if (iso) {
143
+ const [, datePart, timePart, fractionPart, timezonePart] = iso;
144
+ if (!timezonePart) {
145
+ return `${datePart} ${timePart}${normalizeFraction(fractionPart, precision)}`;
146
+ }
147
+ const parsed = new Date(trimmed);
148
+ return Number.isNaN(parsed.getTime()) ? value : formatDateTimeUtc(parsed, precision);
149
+ }
150
+
151
+ const parsed = new Date(trimmed);
152
+ return Number.isNaN(parsed.getTime()) ? value : formatDateTimeUtc(parsed, precision);
153
+ }
154
+
155
+ function convertTemporalIfNeeded(value: any, columnDef?: any): any {
156
+ const { baseType, precision } = parseTemporalType(columnDef);
157
+ if (!baseType) return value;
158
+
159
+ if (value === null || value === undefined) return value;
160
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(value)) return value;
161
+
162
+ if (value instanceof Date) {
163
+ if (baseType === 'date') return formatDateUtc(value);
164
+ if (baseType === 'time') return formatTimeUtc(value, precision);
165
+ if (baseType === 'year') return String(value.getUTCFullYear());
166
+ return formatDateTimeUtc(value, precision);
167
+ }
168
+
169
+ if (typeof value === 'number') {
170
+ const parsed = parseEpochNumber(value);
171
+ if (!parsed) return value;
172
+ if (baseType === 'date') return formatDateUtc(parsed);
173
+ if (baseType === 'time') return formatTimeUtc(parsed, precision);
174
+ if (baseType === 'year') return String(parsed.getUTCFullYear());
175
+ return formatDateTimeUtc(parsed, precision);
176
+ }
177
+
178
+ if (typeof value === 'string') {
179
+ return normalizeTemporalString(value, baseType, precision);
180
+ }
181
+
182
+ return value;
183
+ }
184
+
185
+ export function convertSqlValueForColumn(
186
+ col: string,
187
+ val: any,
188
+ columnDef?: any
189
+ ): any {
190
+ const binaryConverted = convertHexIfBinary(col, val, columnDef);
191
+ return convertTemporalIfNeeded(binaryConverted, columnDef);
192
+ }
@@ -235,6 +235,7 @@ export interface iRest<
235
235
  logLevel?: number;
236
236
  verbose?: boolean;
237
237
  sqlAllowListPath?: string;
238
+ sqlQueryNormalizer?: (sql: string) => string;
238
239
  }
239
240
 
240
241
  export interface iConstraint {
@@ -6,7 +6,12 @@ type AllowListCacheEntry = {
6
6
  size: number;
7
7
  };
8
8
 
9
- const allowListCache = new Map<string, AllowListCacheEntry>();
9
+ export type SqlQueryNormalizer = (sql: string) => string;
10
+
11
+ const DEFAULT_NORMALIZER_CACHE_KEY = "__default__";
12
+ type AllowListCacheKey = SqlQueryNormalizer | typeof DEFAULT_NORMALIZER_CACHE_KEY;
13
+
14
+ const allowListCache = new Map<string, Map<AllowListCacheKey, AllowListCacheEntry>>();
10
15
 
11
16
  const ANSI_ESCAPE_REGEX = /\x1b\[[0-9;]*m/g;
12
17
  const COLLAPSED_BIND_ROW_REGEX = /\(\?\s*×\d+\)/g;
@@ -91,7 +96,26 @@ export const normalizeSql = (sql: string): string => {
91
96
  return normalized.replace(/\s+/g, " ").trim();
92
97
  };
93
98
 
94
- const parseAllowList = (raw: string, sourcePath: string): string[] => {
99
+ export const normalizeSqlWith = (
100
+ sql: string,
101
+ sqlQueryNormalizer?: SqlQueryNormalizer,
102
+ ): string => {
103
+ const normalized = normalizeSql(sql);
104
+ if (!sqlQueryNormalizer) return normalized;
105
+
106
+ const customized = sqlQueryNormalizer(normalized);
107
+ if (typeof customized !== "string") {
108
+ throw new Error("sqlQueryNormalizer must return a string.");
109
+ }
110
+
111
+ return customized.replace(/\s+/g, " ").trim();
112
+ };
113
+
114
+ const parseAllowList = (
115
+ raw: string,
116
+ sourcePath: string,
117
+ sqlQueryNormalizer?: SqlQueryNormalizer,
118
+ ): string[] => {
95
119
  let parsed: unknown;
96
120
  try {
97
121
  parsed = JSON.parse(raw);
@@ -105,7 +129,7 @@ const parseAllowList = (raw: string, sourcePath: string): string[] => {
105
129
 
106
130
  const sqlEntries = parsed
107
131
  .filter((entry): entry is string => typeof entry === "string")
108
- .map(normalizeSql)
132
+ .map((entry) => normalizeSqlWith(entry, sqlQueryNormalizer))
109
133
  .filter((entry) => entry.length > 0);
110
134
 
111
135
  if (sqlEntries.length !== parsed.length) {
@@ -115,7 +139,10 @@ const parseAllowList = (raw: string, sourcePath: string): string[] => {
115
139
  return sqlEntries;
116
140
  };
117
141
 
118
- export const loadSqlAllowList = async (allowListPath: string): Promise<Set<string>> => {
142
+ export const loadSqlAllowList = async (
143
+ allowListPath: string,
144
+ sqlQueryNormalizer?: SqlQueryNormalizer,
145
+ ): Promise<Set<string>> => {
119
146
  if (!isNode()) {
120
147
  throw new Error("SQL allowlist validation requires a Node runtime.");
121
148
  }
@@ -129,7 +156,10 @@ export const loadSqlAllowList = async (allowListPath: string): Promise<Set<strin
129
156
  throw new Error(`SQL allowlist file not found at ${allowListPath}.`);
130
157
  }
131
158
 
132
- const cached = allowListCache.get(allowListPath);
159
+ const pathCache = allowListCache.get(allowListPath)
160
+ ?? new Map<AllowListCacheKey, AllowListCacheEntry>();
161
+ const cacheKey: AllowListCacheKey = sqlQueryNormalizer ?? DEFAULT_NORMALIZER_CACHE_KEY;
162
+ const cached = pathCache.get(cacheKey);
133
163
  if (
134
164
  cached &&
135
165
  cached.mtimeMs === fileStat.mtimeMs &&
@@ -145,13 +175,14 @@ export const loadSqlAllowList = async (allowListPath: string): Promise<Set<strin
145
175
  throw new Error(`SQL allowlist file not found at ${allowListPath}.`);
146
176
  }
147
177
 
148
- const sqlEntries = parseAllowList(raw, allowListPath);
178
+ const sqlEntries = parseAllowList(raw, allowListPath, sqlQueryNormalizer);
149
179
  const allowList = new Set(sqlEntries);
150
- allowListCache.set(allowListPath, {
180
+ pathCache.set(cacheKey, {
151
181
  allowList,
152
182
  mtimeMs: fileStat.mtimeMs,
153
183
  size: fileStat.size,
154
184
  });
185
+ allowListCache.set(allowListPath, pathCache);
155
186
  return allowList;
156
187
  };
157
188
 
@@ -186,10 +217,11 @@ export const extractSqlEntries = (payload: unknown): string[] => {
186
217
 
187
218
  export const collectSqlAllowListEntries = (
188
219
  payload: unknown,
189
- entries: Set<string> = new Set<string>()
220
+ entries: Set<string> = new Set<string>(),
221
+ sqlQueryNormalizer?: SqlQueryNormalizer,
190
222
  ): Set<string> => {
191
223
  const sqlEntries = extractSqlEntries(payload)
192
- .map(normalizeSql)
224
+ .map((entry) => normalizeSqlWith(entry, sqlQueryNormalizer))
193
225
  .filter((entry) => entry.length > 0);
194
226
 
195
227
  sqlEntries.forEach((entry) => entries.add(entry));
@@ -199,7 +231,8 @@ export const collectSqlAllowListEntries = (
199
231
 
200
232
  export const compileSqlAllowList = async (
201
233
  allowListPath: string,
202
- entries: Iterable<string>
234
+ entries: Iterable<string>,
235
+ sqlQueryNormalizer?: SqlQueryNormalizer,
203
236
  ): Promise<string[]> => {
204
237
  if (!isNode()) {
205
238
  throw new Error("SQL allowlist compilation requires a Node runtime.");
@@ -212,7 +245,7 @@ export const compileSqlAllowList = async (
212
245
 
213
246
  const compiled = Array.from(new Set(
214
247
  Array.from(entries)
215
- .map(normalizeSql)
248
+ .map((entry) => normalizeSqlWith(entry, sqlQueryNormalizer))
216
249
  .filter((entry) => entry.length > 0)
217
250
  )).sort();
218
251