@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.
- package/README.md +509 -292
- package/dist/index.cjs.js +209 -37
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +208 -38
- package/dist/index.esm.js.map +1 -1
- package/dist/orm/utils/sqlUtils.d.ts +1 -0
- package/dist/types/ormInterfaces.d.ts +1 -0
- package/dist/utils/sqlAllowList.d.ts +5 -3
- package/package.json +1 -1
- package/src/__tests__/fixtures/c6.fixture.ts +33 -0
- package/src/__tests__/sakila-db/C6.js +1 -1
- package/src/__tests__/sakila-db/C6.mysqldump.json +1 -1
- package/src/__tests__/sakila-db/C6.mysqldump.sql +1 -1
- package/src/__tests__/sakila-db/C6.ts +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.json +6 -6
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.latest.json +3 -3
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.lookup.json +3 -3
- package/src/__tests__/sakila-db/sqlResponses/C6.address.post.json +10 -10
- package/src/__tests__/sakila-db/sqlResponses/C6.address.post.latest.json +5 -5
- package/src/__tests__/sakila-db/sqlResponses/C6.address.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.address.put.lookup.json +5 -5
- package/src/__tests__/sakila-db/sqlResponses/C6.category.post.json +4 -4
- package/src/__tests__/sakila-db/sqlResponses/C6.category.post.latest.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.category.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.category.put.lookup.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.city.post.json +4 -4
- package/src/__tests__/sakila-db/sqlResponses/C6.city.post.latest.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.city.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.city.put.lookup.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.country.post.json +4 -4
- package/src/__tests__/sakila-db/sqlResponses/C6.country.post.latest.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.country.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.country.put.lookup.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.json +10 -10
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.latest.json +5 -5
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.lookup.json +5 -5
- package/src/__tests__/sakila-db/sqlResponses/C6.film.post.json +4 -4
- package/src/__tests__/sakila-db/sqlResponses/C6.film.post.latest.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.film.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.film.put.lookup.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.latest.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.lookup.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.language.post.json +4 -4
- package/src/__tests__/sakila-db/sqlResponses/C6.language.post.latest.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.language.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.language.put.lookup.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.json +4 -4
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.latest.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.put.lookup.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.join.json +10 -10
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.json +6 -6
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.latest.json +3 -3
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.lookup.json +3 -3
- package/src/__tests__/sqlAllowList.test.ts +56 -1
- package/src/__tests__/sqlBuilders.test.ts +38 -1
- package/src/executors/SqlExecutor.ts +4 -3
- package/src/orm/builders/ConditionBuilder.ts +3 -10
- package/src/orm/utils/sqlUtils.ts +172 -4
- package/src/types/ormInterfaces.ts +1 -0
- 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-
|
|
11
|
-
"last_update": "2026-02-
|
|
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-
|
|
23
|
-
"2026-02-
|
|
22
|
+
"2026-02-16 21:50:08",
|
|
23
|
+
"2026-02-16 21:50:08"
|
|
24
24
|
]
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"rest": [
|
|
3
3
|
{
|
|
4
|
-
"rental_id":
|
|
5
|
-
"rental_date": "2005-05-
|
|
6
|
-
"inventory_id":
|
|
7
|
-
"customer_id":
|
|
8
|
-
"return_date": "2005-
|
|
9
|
-
"staff_id":
|
|
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": "
|
|
13
|
-
"last_name": "
|
|
14
|
-
"email": "
|
|
15
|
-
"address_id":
|
|
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-
|
|
6
|
+
"rental_date": "2026-02-16 21:50:08",
|
|
7
7
|
"inventory_id": 1,
|
|
8
8
|
"customer_id": 1,
|
|
9
|
-
"return_date": "2026-02-
|
|
9
|
+
"return_date": "2026-02-16 21:50:08",
|
|
10
10
|
"staff_id": 1,
|
|
11
|
-
"last_update": "2026-02-
|
|
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-
|
|
18
|
+
"2026-02-16 21:50:08",
|
|
19
19
|
1,
|
|
20
20
|
1,
|
|
21
|
-
"2026-02-
|
|
21
|
+
"2026-02-16 21:50:08",
|
|
22
22
|
1,
|
|
23
|
-
"2026-02-
|
|
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-
|
|
5
|
+
"rental_date": "2026-02-16T21:50:08.000Z",
|
|
6
6
|
"inventory_id": 1,
|
|
7
7
|
"customer_id": 1,
|
|
8
|
-
"return_date": "2026-02-
|
|
8
|
+
"return_date": "2026-02-16T21:50:08.000Z",
|
|
9
9
|
"staff_id": 1,
|
|
10
|
-
"last_update": "2026-02-
|
|
10
|
+
"last_update": "2026-02-16T21:50:08.000Z"
|
|
11
11
|
}
|
|
12
12
|
],
|
|
13
13
|
"sql": {
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
"rest": [
|
|
3
3
|
{
|
|
4
4
|
"rental_id": 16050,
|
|
5
|
-
"rental_date": "2026-02-
|
|
5
|
+
"rental_date": "2026-02-16T21:50:08.000Z",
|
|
6
6
|
"inventory_id": 1,
|
|
7
7
|
"customer_id": 1,
|
|
8
|
-
"return_date": "2026-02-
|
|
8
|
+
"return_date": "2026-02-16T21:50:08.000Z",
|
|
9
9
|
"staff_id": 1,
|
|
10
|
-
"last_update": "2026-02-
|
|
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,
|
|
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
|
|
1074
|
-
const
|
|
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 {
|
|
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
|
-
|
|
179
|
-
|
|
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
|
+
}
|
|
@@ -6,7 +6,12 @@ type AllowListCacheEntry = {
|
|
|
6
6
|
size: number;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
-
|
|
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
|
|
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(
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
248
|
+
.map((entry) => normalizeSqlWith(entry, sqlQueryNormalizer))
|
|
216
249
|
.filter((entry) => entry.length > 0)
|
|
217
250
|
)).sort();
|
|
218
251
|
|