@carbonorm/carbonnode 6.0.20 → 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 (90) hide show
  1. package/README.md +521 -259
  2. package/dist/constants/C6Constants.d.ts +342 -338
  3. package/dist/executors/SqlExecutor.d.ts +1 -0
  4. package/dist/index.cjs.js +746 -290
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.esm.js +737 -291
  8. package/dist/index.esm.js.map +1 -1
  9. package/dist/orm/builders/AggregateBuilder.d.ts +5 -1
  10. package/dist/orm/builders/ConditionBuilder.d.ts +2 -3
  11. package/dist/orm/builders/ExpressionSerializer.d.ts +22 -0
  12. package/dist/orm/builders/PaginationBuilder.d.ts +4 -6
  13. package/dist/orm/queryHelpers.d.ts +12 -1
  14. package/dist/orm/utils/sqlUtils.d.ts +1 -0
  15. package/dist/types/mysqlTypes.d.ts +6 -1
  16. package/dist/types/ormInterfaces.d.ts +7 -5
  17. package/dist/utils/sqlAllowList.d.ts +5 -3
  18. package/package.json +2 -2
  19. package/scripts/assets/handlebars/C6.test.ts.handlebars +4 -4
  20. package/src/__tests__/expressServer.e2e.test.ts +26 -17
  21. package/src/__tests__/fixtures/c6.fixture.ts +33 -0
  22. package/src/__tests__/httpExecutorSingular.e2e.test.ts +53 -14
  23. package/src/__tests__/normalizeSingularRequest.test.ts +26 -8
  24. package/src/__tests__/sakila-db/C6.js +1 -1
  25. package/src/__tests__/sakila-db/C6.mysqldump.json +1 -1
  26. package/src/__tests__/sakila-db/C6.mysqldump.sql +1 -1
  27. package/src/__tests__/sakila-db/C6.sqlAllowList.json +1 -1
  28. package/src/__tests__/sakila-db/C6.test.ts +4 -4
  29. package/src/__tests__/sakila-db/C6.ts +1 -1
  30. package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.json +6 -6
  31. package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.latest.json +3 -3
  32. package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.json +1 -1
  33. package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.lookup.json +3 -3
  34. package/src/__tests__/sakila-db/sqlResponses/C6.address.post.json +19 -12
  35. package/src/__tests__/sakila-db/sqlResponses/C6.address.post.latest.json +5 -5
  36. package/src/__tests__/sakila-db/sqlResponses/C6.address.put.json +1 -1
  37. package/src/__tests__/sakila-db/sqlResponses/C6.address.put.lookup.json +5 -5
  38. package/src/__tests__/sakila-db/sqlResponses/C6.category.post.json +4 -4
  39. package/src/__tests__/sakila-db/sqlResponses/C6.category.post.latest.json +2 -2
  40. package/src/__tests__/sakila-db/sqlResponses/C6.category.put.json +1 -1
  41. package/src/__tests__/sakila-db/sqlResponses/C6.category.put.lookup.json +2 -2
  42. package/src/__tests__/sakila-db/sqlResponses/C6.city.post.json +4 -4
  43. package/src/__tests__/sakila-db/sqlResponses/C6.city.post.latest.json +2 -2
  44. package/src/__tests__/sakila-db/sqlResponses/C6.city.put.json +1 -1
  45. package/src/__tests__/sakila-db/sqlResponses/C6.city.put.lookup.json +2 -2
  46. package/src/__tests__/sakila-db/sqlResponses/C6.country.post.json +4 -4
  47. package/src/__tests__/sakila-db/sqlResponses/C6.country.post.latest.json +2 -2
  48. package/src/__tests__/sakila-db/sqlResponses/C6.country.put.json +1 -1
  49. package/src/__tests__/sakila-db/sqlResponses/C6.country.put.lookup.json +2 -2
  50. package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.json +10 -10
  51. package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.latest.json +5 -5
  52. package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.json +1 -1
  53. package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.lookup.json +5 -5
  54. package/src/__tests__/sakila-db/sqlResponses/C6.film.post.json +4 -4
  55. package/src/__tests__/sakila-db/sqlResponses/C6.film.post.latest.json +2 -2
  56. package/src/__tests__/sakila-db/sqlResponses/C6.film.put.json +1 -1
  57. package/src/__tests__/sakila-db/sqlResponses/C6.film.put.lookup.json +2 -2
  58. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.json +2 -2
  59. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.latest.json +1 -1
  60. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.json +1 -1
  61. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.lookup.json +1 -1
  62. package/src/__tests__/sakila-db/sqlResponses/C6.language.post.json +4 -4
  63. package/src/__tests__/sakila-db/sqlResponses/C6.language.post.latest.json +2 -2
  64. package/src/__tests__/sakila-db/sqlResponses/C6.language.put.json +1 -1
  65. package/src/__tests__/sakila-db/sqlResponses/C6.language.put.lookup.json +2 -2
  66. package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.json +4 -4
  67. package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.latest.json +2 -2
  68. package/src/__tests__/sakila-db/sqlResponses/C6.payment.put.lookup.json +2 -2
  69. package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.json +6 -6
  70. package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.latest.json +3 -3
  71. package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.json +1 -1
  72. package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.lookup.json +3 -3
  73. package/src/__tests__/sqlAllowList.test.ts +56 -1
  74. package/src/__tests__/sqlBuilders.complex.test.ts +62 -74
  75. package/src/__tests__/sqlBuilders.expressions.test.ts +58 -30
  76. package/src/__tests__/sqlBuilders.test.ts +106 -5
  77. package/src/constants/C6Constants.ts +3 -1
  78. package/src/executors/HttpExecutor.ts +2 -1
  79. package/src/executors/SqlExecutor.ts +29 -4
  80. package/src/index.ts +1 -0
  81. package/src/orm/builders/AggregateBuilder.ts +67 -106
  82. package/src/orm/builders/ConditionBuilder.ts +72 -103
  83. package/src/orm/builders/ExpressionSerializer.ts +275 -0
  84. package/src/orm/builders/PaginationBuilder.ts +24 -34
  85. package/src/orm/queryHelpers.ts +29 -0
  86. package/src/orm/utils/sqlUtils.ts +172 -4
  87. package/src/types/mysqlTypes.ts +130 -9
  88. package/src/types/ormInterfaces.ts +7 -7
  89. package/src/utils/normalizeSingularRequest.ts +11 -4
  90. package/src/utils/sqlAllowList.ts +44 -11
@@ -30,22 +30,20 @@ describe('Explicit SQL expression grammar', () => {
30
30
  [C6C.OR]: [
31
31
  {
32
32
  [C6C.LESS_THAN_OR_EQUAL_TO]: [
33
- {
34
- [C6C.ST_DISTANCE_SPHERE]: [
35
- Property_Units.LOCATION,
36
- { [C6C.ST_GEOMFROMTEXT]: ['POINT(39.4972468 -105.0403593)', 4326] },
37
- ],
38
- },
33
+ [
34
+ C6C.ST_DISTANCE_SPHERE,
35
+ Property_Units.LOCATION,
36
+ [C6C.ST_GEOMFROMTEXT, [C6C.LIT, 'POINT(39.4972468 -105.0403593)'], 4326],
37
+ ],
39
38
  5000,
40
39
  ],
41
40
  },
42
41
  {
43
42
  [C6C.GREATER_THAN]: [
44
- {
45
- [C6C.ST_AREA]: [
46
- { [C6C.ST_GEOMFROMTEXT]: ['POLYGON((0 0,1 0,1 1,0 1,0 0))', 4326] },
47
- ],
48
- },
43
+ [
44
+ C6C.ST_AREA,
45
+ [C6C.ST_GEOMFROMTEXT, [C6C.LIT, 'POLYGON((0 0,1 0,1 1,0 1,0 0))'], 4326],
46
+ ],
49
47
  10000,
50
48
  ],
51
49
  },
@@ -56,12 +54,17 @@ describe('Explicit SQL expression grammar', () => {
56
54
  const { sql, params } = qb.build(Property_Units.TABLE_NAME);
57
55
 
58
56
  expect(sql).toContain('WHERE');
59
- expect(sql).toMatch(/ST_DISTANCE_SPHERE\(property_units.location, ST_GEOMFROMTEXT\('/);
60
- expect(sql).toMatch(/ST_AREA\(ST_GEOMFROMTEXT\('/);
57
+ expect(sql).toMatch(/ST_DISTANCE_SPHERE\(property_units.location, ST_GEOMFROMTEXT\(\?, 4326\)\)/);
58
+ expect(sql).toMatch(/ST_AREA\(ST_GEOMFROMTEXT\(\?, 4326\)\)/);
61
59
  expect(sql).toMatch(/<= \?/);
62
60
  expect(sql).toMatch(/> \?/);
63
61
  expect(sql).toContain('OR');
64
- expect(params).toEqual([5000, 10000]);
62
+ expect(params).toEqual([
63
+ 'POINT(39.4972468 -105.0403593)',
64
+ 5000,
65
+ 'POLYGON((0 0,1 0,1 1,0 1,0 0))',
66
+ 10000,
67
+ ]);
65
68
  });
66
69
 
67
70
  it('supports operator-first 3-tuple conditions in WHERE', () => {
@@ -85,7 +88,7 @@ describe('Explicit SQL expression grammar', () => {
85
88
  const qb = new SelectQueryBuilder(config as any, {
86
89
  [C6C.SELECT]: [Property_Units.PARCEL_ID],
87
90
  [C6C.WHERE]: {
88
- 0: [[C6C.BETWEEN, Parcel_Sales.SALE_DATE, ['2021-01-01', '2021-12-31']]],
91
+ 0: [[C6C.BETWEEN, Parcel_Sales.SALE_DATE, [[C6C.LIT, '2021-01-01'], [C6C.LIT, '2021-12-31']]]],
89
92
  },
90
93
  } as any, false);
91
94
 
@@ -94,30 +97,25 @@ describe('Explicit SQL expression grammar', () => {
94
97
  expect(params.slice(-2)).toEqual(['2021-01-01', '2021-12-31']);
95
98
  });
96
99
 
97
- it('treats safe raw function expressions as SQL expressions', () => {
100
+ it('supports tuple-based function expressions with literal wrappers', () => {
98
101
  const config = buildParcelConfig();
99
102
 
100
103
  const qb = new SelectQueryBuilder(config as any, {
101
104
  [C6C.SELECT]: [Property_Units.PARCEL_ID],
102
105
  [C6C.WHERE]: {
103
- 0: [
104
- [
105
- "ST_Distance_Sphere(property_units.location, ST_GeomFromText('POINT(39.4972468 -105.0403593)', 4326))",
106
- C6C.LESS_THAN_OR_EQUAL_TO,
107
- 5000,
108
- ],
106
+ [C6C.LESS_THAN_OR_EQUAL_TO]: [
107
+ [C6C.ST_DISTANCE_SPHERE, Property_Units.LOCATION, [C6C.ST_GEOMFROMTEXT, [C6C.LIT, 'POINT(39.4972468 -105.0403593)'], 4326]],
108
+ 5000,
109
109
  ],
110
110
  },
111
111
  } as any, false);
112
112
 
113
113
  const { sql, params } = qb.build(Property_Units.TABLE_NAME);
114
- expect(sql).toMatch(
115
- /ST_Distance_Sphere\(property_units\.location, ST_GeomFromText\('POINT\(39\.4972468 -105\.0403593\)', 4326\)\) <= \?/
116
- );
117
- expect(params.slice(-1)[0]).toBe(5000);
114
+ expect(sql).toMatch(/ST_DISTANCE_SPHERE\(property_units\.location, ST_GEOMFROMTEXT\(\?, 4326\)\) <= \?/);
115
+ expect(params).toEqual(['POINT(39.4972468 -105.0403593)', 5000]);
118
116
  });
119
117
 
120
- it('rejects raw function expressions containing unsafe tokens', () => {
118
+ it('rejects raw function expression strings', () => {
121
119
  const config = buildParcelConfig();
122
120
 
123
121
  const qb = new SelectQueryBuilder(config as any, {
@@ -133,7 +131,37 @@ describe('Explicit SQL expression grammar', () => {
133
131
  },
134
132
  } as any, false);
135
133
 
136
- expect(() => qb.build(Property_Units.TABLE_NAME)).toThrowError(/Potential SQL injection detected/);
134
+ expect(() => qb.build(Property_Units.TABLE_NAME)).toThrowError(/Bare string/);
135
+ });
136
+
137
+ it('rejects legacy positional AS syntax in function tuples', () => {
138
+ const config = buildParcelConfig();
139
+
140
+ const qb = new SelectQueryBuilder(config as any, {
141
+ [C6C.SELECT]: [[C6C.COUNT, Property_Units.PARCEL_ID, C6C.AS, 'cnt']],
142
+ } as any, false);
143
+
144
+ expect(() => qb.build(Property_Units.TABLE_NAME)).toThrowError(/Legacy positional AS syntax/);
145
+ });
146
+
147
+ it('rejects legacy positional AS syntax in column tuples', () => {
148
+ const config = buildParcelConfig();
149
+
150
+ const qb = new SelectQueryBuilder(config as any, {
151
+ [C6C.SELECT]: [[Property_Units.PARCEL_ID, C6C.AS, 'parcel_id_alias']],
152
+ } as any, false);
153
+
154
+ expect(() => qb.build(Property_Units.TABLE_NAME)).toThrowError(/Legacy positional AS syntax/);
155
+ });
156
+
157
+ it('rejects object-rooted function expressions', () => {
158
+ const config = buildParcelConfig();
159
+
160
+ const qb = new SelectQueryBuilder(config as any, {
161
+ [C6C.SELECT]: [{ [C6C.COUNT]: [Property_Units.PARCEL_ID] }],
162
+ } as any, false);
163
+
164
+ expect(() => qb.build(Property_Units.TABLE_NAME)).toThrowError(/Object-rooted expressions are not supported/);
137
165
  });
138
166
 
139
167
  it('supports explicit AND groupings composed of nested OR clauses', () => {
@@ -146,8 +174,8 @@ describe('Explicit SQL expression grammar', () => {
146
174
  { [C6C.GREATER_THAN]: [Property_Units.PARCEL_ID, 100] },
147
175
  {
148
176
  [C6C.OR]: [
149
- { [C6C.BETWEEN]: [Parcel_Sales.SALE_DATE, ['2021-01-01', '2022-06-30']] },
150
- { [C6C.BETWEEN]: [Parcel_Sales.SALE_DATE, ['2023-01-01', '2024-06-30']] },
177
+ { [C6C.BETWEEN]: [Parcel_Sales.SALE_DATE, [[C6C.LIT, '2021-01-01'], [C6C.LIT, '2022-06-30']]] },
178
+ { [C6C.BETWEEN]: [Parcel_Sales.SALE_DATE, [[C6C.LIT, '2023-01-01'], [C6C.LIT, '2024-06-30']]] },
151
179
  ],
152
180
  },
153
181
  ],
@@ -4,14 +4,15 @@ import { SelectQueryBuilder } from '../orm/queries/SelectQueryBuilder';
4
4
  import { PostQueryBuilder } from '../orm/queries/PostQueryBuilder';
5
5
  import { UpdateQueryBuilder } from '../orm/queries/UpdateQueryBuilder';
6
6
  import { DeleteQueryBuilder } from '../orm/queries/DeleteQueryBuilder';
7
- import { buildTestConfig, buildBinaryTestConfig, buildBinaryTestConfigFqn } from './fixtures/c6.fixture';
7
+ import { alias, call, distinct, fn, lit, order } from '../orm/queryHelpers';
8
+ import { buildTestConfig, buildBinaryTestConfig, buildBinaryTestConfigFqn, buildTemporalTestConfig } from './fixtures/c6.fixture';
8
9
 
9
10
  describe('SQL Builders', () => {
10
11
  it('builds SELECT with JOIN, WHERE, GROUP BY, HAVING and default LIMIT', () => {
11
12
  const config = buildTestConfig();
12
13
  // named params disabled -> positional params array
13
14
  const qb = new SelectQueryBuilder(config as any, {
14
- SELECT: ['actor.first_name', [C6C.COUNT, 'actor.actor_id', C6C.AS, 'cnt']],
15
+ SELECT: ['actor.first_name', [C6C.AS, [C6C.COUNT, 'actor.actor_id'], 'cnt']],
15
16
  JOIN: {
16
17
  [C6C.INNER]: {
17
18
  'film_actor fa': {
@@ -20,7 +21,7 @@ describe('SQL Builders', () => {
20
21
  }
21
22
  },
22
23
  WHERE: {
23
- 'actor.first_name': [C6C.LIKE, '%A%'],
24
+ 'actor.first_name': [C6C.LIKE, [C6C.LIT, '%A%']],
24
25
  0: {
25
26
  'actor.actor_id': [C6C.GREATER_THAN, 10],
26
27
  }
@@ -47,7 +48,7 @@ describe('SQL Builders', () => {
47
48
  const config = buildTestConfig();
48
49
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
49
50
  const qb = new SelectQueryBuilder(config as any, {
50
- SELECT: [[C6C.COUNT, 'actor.actor_id', C6C.AS, 'cnt']],
51
+ SELECT: [[C6C.AS, [C6C.COUNT, 'actor.actor_id'], 'cnt']],
51
52
  } as any, false);
52
53
 
53
54
  qb.build('actor');
@@ -62,6 +63,39 @@ describe('SQL Builders', () => {
62
63
  logSpy.mockRestore();
63
64
  });
64
65
 
66
+ it('supports custom functions through C6C.CALL and binds string literals', () => {
67
+ const config = buildTestConfig();
68
+ const qb = new SelectQueryBuilder(config as any, {
69
+ SELECT: [[C6C.AS, [C6C.CALL, 'COALESCE', [C6C.LIT, 'fallback'], 'actor.first_name'], 'resolved_name']],
70
+ } as any, false);
71
+
72
+ const { sql, params } = qb.build('actor');
73
+ expect(sql).toContain('COALESCE(?, actor.first_name) AS resolved_name');
74
+ expect(params).toEqual(['fallback']);
75
+ });
76
+
77
+ it('supports helper builders for fn/call/as/distinct/lit/order', () => {
78
+ const config = buildTestConfig();
79
+ const qb = new SelectQueryBuilder(config as any, {
80
+ SELECT: [
81
+ alias(distinct('actor.first_name'), 'distinct_name'),
82
+ alias(fn(C6C.COUNT, 'actor.actor_id'), 'cnt'),
83
+ call('COALESCE', lit('N/A'), 'actor.last_name'),
84
+ ],
85
+ PAGINATION: {
86
+ [C6C.ORDER]: [order(fn(C6C.COUNT, 'actor.actor_id'), 'DESC')],
87
+ [C6C.LIMIT]: 5,
88
+ },
89
+ } as any, false);
90
+
91
+ const { sql, params } = qb.build('actor');
92
+ expect(sql).toContain('DISTINCT actor.first_name AS distinct_name');
93
+ expect(sql).toContain('COUNT(actor.actor_id) AS cnt');
94
+ expect(sql).toContain('COALESCE(?, actor.last_name)');
95
+ expect(sql).toContain('ORDER BY COUNT(actor.actor_id) DESC');
96
+ expect(params).toEqual(['N/A']);
97
+ });
98
+
65
99
  it('builds INSERT with ON DUPLICATE KEY UPDATE', () => {
66
100
  const config = buildTestConfig();
67
101
  const qb = new PostQueryBuilder(config as any, {
@@ -192,6 +226,36 @@ describe('SQL Builders', () => {
192
226
  expect(params).toEqual(['ALICE', 5]);
193
227
  });
194
228
 
229
+ it('supports expression tuples in UPDATE values', () => {
230
+ const config = buildTestConfig();
231
+ const qb = new UpdateQueryBuilder(config as any, {
232
+ [C6C.UPDATE]: {
233
+ 'actor.first_name': [C6C.CONCAT, [C6C.LIT, 'Mr. '], 'actor.last_name'],
234
+ },
235
+ WHERE: {
236
+ 'actor.actor_id': [C6C.EQUAL, 7],
237
+ },
238
+ } as any, false);
239
+
240
+ const { sql, params } = qb.build('actor');
241
+ expect(sql).toContain('`first_name` = CONCAT(?, actor.last_name)');
242
+ expect(params).toEqual(['Mr. ', 7]);
243
+ });
244
+
245
+ it('supports expression tuples in INSERT values', () => {
246
+ const config = buildTestConfig();
247
+ const qb = new PostQueryBuilder(config as any, {
248
+ [C6C.INSERT]: {
249
+ 'actor.first_name': [C6C.CONCAT, [C6C.LIT, 'HEL'], [C6C.LIT, 'LO']],
250
+ 'actor.last_name': 'SMITH',
251
+ },
252
+ } as any, false);
253
+
254
+ const { sql, params } = qb.build('actor');
255
+ expect(sql).toContain('CONCAT(?, ?)');
256
+ expect(params).toEqual(['HEL', 'LO', 'SMITH']);
257
+ });
258
+
195
259
  it('builds UPDATE when columns are fully qualified', () => {
196
260
  const config = buildTestConfig();
197
261
  const qb = new UpdateQueryBuilder(config as any, {
@@ -235,7 +299,7 @@ describe('SQL Builders', () => {
235
299
  const config = buildTestConfig();
236
300
  const qb = new SelectQueryBuilder(config as any, {
237
301
  WHERE: {
238
- 'actor.binarycol': [C6C.EQUAL, '0123456789abcdef0123456789abcdef']
302
+ 'actor.binarycol': [C6C.EQUAL, [C6C.LIT, '0123456789abcdef0123456789abcdef']]
239
303
  }
240
304
  } as any, false);
241
305
 
@@ -317,4 +381,41 @@ describe('SQL Builders', () => {
317
381
  expect(Buffer.isBuffer(buf)).toBe(true);
318
382
  expect((buf as Buffer).length).toBe(16);
319
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
+ });
320
421
  });
@@ -9,6 +9,7 @@ export const C6Constants = {
9
9
  BETWEEN: 'BETWEEN',
10
10
 
11
11
  CONCAT: 'CONCAT',
12
+ CALL: 'CALL',
12
13
  CONVERT_TZ: 'CONVERT_TZ',
13
14
  COUNT: 'COUNT',
14
15
  COUNT_ALL: 'COUNT_ALL',
@@ -71,6 +72,7 @@ export const C6Constants = {
71
72
  LESS_THAN: '<',
72
73
  LESS_THAN_OR_EQUAL_TO: '<=',
73
74
  LIKE: 'LIKE',
75
+ LIT: 'LIT',
74
76
  LIMIT: 'LIMIT',
75
77
  LOCALTIME: 'LOCALTIME',
76
78
  LOCALTIMESTAMP: 'LOCALTIMESTAMP',
@@ -198,7 +200,7 @@ export const C6Constants = {
198
200
  FINISH: 'FINISH',
199
201
  VALIDATE_C6_ENTITY_ID_REGEX: '#^([a-fA-F0-9]{20,35})$#',
200
202
 
201
- };
203
+ } as const;
202
204
 
203
205
 
204
206
  export const C6C = C6Constants;
@@ -209,6 +209,7 @@ export class HttpExecutor<
209
209
  const {
210
210
  debug,
211
211
  cacheResults = (C6.GET === requestMethod),
212
+ skipReactBootstrap = false,
212
213
  dataInsertMultipleRows,
213
214
  success,
214
215
  fetchDependencies = eFetchDependencies.NONE,
@@ -557,7 +558,7 @@ export class HttpExecutor<
557
558
  response
558
559
  });
559
560
 
560
- if (undefined !== reactBootstrap && response) {
561
+ if (undefined !== reactBootstrap && response && !skipReactBootstrap) {
561
562
  switch (requestMethod) {
562
563
  case GET:
563
564
  response.data && reactBootstrap.updateRestfulObjectArrays<G['RestTableInterface']>({
@@ -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";
@@ -438,6 +438,7 @@ export class SqlExecutor<
438
438
  C6C.REPLACE,
439
439
  "dataInsertMultipleRows",
440
440
  "cacheResults",
441
+ "skipReactBootstrap",
441
442
  "fetchDependencies",
442
443
  "debug",
443
444
  "success",
@@ -530,7 +531,7 @@ export class SqlExecutor<
530
531
  }
531
532
 
532
533
  if (value !== undefined) {
533
- pkValues[pkShort] = value;
534
+ pkValues[pkShort] = this.unwrapPrimaryKeyValue(value);
534
535
  }
535
536
  }
536
537
 
@@ -541,6 +542,29 @@ export class SqlExecutor<
541
542
  return Object.keys(pkValues).length > 0 ? pkValues : null;
542
543
  }
543
544
 
545
+ private unwrapPrimaryKeyValue(value: any): any {
546
+ if (!Array.isArray(value)) return value;
547
+
548
+ if (value.length === 2) {
549
+ const [head, tail] = value;
550
+ if (head === C6C.EQUAL) {
551
+ return this.unwrapPrimaryKeyValue(tail);
552
+ }
553
+ if (head === C6C.LIT || head === C6C.PARAM) {
554
+ return tail;
555
+ }
556
+ }
557
+
558
+ if (value.length === 3) {
559
+ const [, operator, right] = value;
560
+ if (operator === C6C.EQUAL) {
561
+ return this.unwrapPrimaryKeyValue(right);
562
+ }
563
+ }
564
+
565
+ return value;
566
+ }
567
+
544
568
  private extractPrimaryKeyValuesFromData(data: any): Record<string, any> | null {
545
569
  if (!data) return null;
546
570
  const row = Array.isArray(data) ? data[0] : data;
@@ -1046,8 +1070,9 @@ export class SqlExecutor<
1046
1070
  return "not verified";
1047
1071
  }
1048
1072
 
1049
- const allowList = await loadSqlAllowList(allowListPath);
1050
- const normalized = normalizeSql(sql);
1073
+ const sqlQueryNormalizer = this.config.sqlQueryNormalizer;
1074
+ const allowList = await loadSqlAllowList(allowListPath, sqlQueryNormalizer);
1075
+ const normalized = normalizeSqlWith(sql, sqlQueryNormalizer);
1051
1076
  if (!allowList.has(normalized)) {
1052
1077
  throw createSqlAllowListBlockedError({
1053
1078
  tableName:
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export * from "./handlers/ExpressHandler";
19
19
  export * from "./orm/queryHelpers";
20
20
  export * from "./orm/builders/AggregateBuilder";
21
21
  export * from "./orm/builders/ConditionBuilder";
22
+ export * from "./orm/builders/ExpressionSerializer";
22
23
  export * from "./orm/builders/JoinBuilder";
23
24
  export * from "./orm/builders/PaginationBuilder";
24
25
  export * from "./orm/queries/DeleteQueryBuilder";
@@ -1,9 +1,18 @@
1
1
  import {Executor} from "../../executors/Executor";
2
2
  import {OrmGenerics} from "../../types/ormGenerics";
3
- import {C6C} from "../../constants/C6Constants";
3
+ import {SQL_KNOWN_FUNCTIONS} from "../../types/mysqlTypes";
4
4
  import {getLogContext, LogLevel, logWithLevel} from "../../utils/logLevel";
5
+ import {
6
+ iSerializedExpression,
7
+ serializeSqlExpression,
8
+ tSqlParams,
9
+ } from "./ExpressionSerializer";
5
10
 
6
- export abstract class AggregateBuilder<G extends OrmGenerics> extends Executor<G>{
11
+ const KNOWN_FUNCTION_LOOKUP = new Set(
12
+ SQL_KNOWN_FUNCTIONS.map((name) => String(name).toUpperCase()),
13
+ );
14
+
15
+ export abstract class AggregateBuilder<G extends OrmGenerics> extends Executor<G> {
7
16
  protected selectAliases: Set<string> = new Set<string>();
8
17
 
9
18
  // Overridden in ConditionBuilder where alias tracking is available.
@@ -12,130 +21,82 @@ export abstract class AggregateBuilder<G extends OrmGenerics> extends Executor<G
12
21
  // no-op placeholder for subclasses that do not implement alias validation
13
22
  }
14
23
 
15
- buildAggregateField(field: string | any[], params?: any[] | Record<string, any>): string {
16
- if (typeof field === 'string') {
17
- this.assertValidIdentifier(field, 'SELECT field');
18
- return field;
19
- }
24
+ protected isReferenceExpression(value: string): boolean {
25
+ if (typeof value !== 'string') return false;
20
26
 
21
- if (!Array.isArray(field) || field.length === 0) {
22
- throw new Error('Invalid SELECT field entry');
23
- }
24
-
25
- // If the array represents a tuple/literal list (e.g., [lng, lat]) rather than a
26
- // function call like [FN, ...args], serialize the list as a comma-separated
27
- // literal sequence so parent calls (like ORDER BY FN(<here>)) can embed it.
28
- const isNumericString = (s: string) => /^-?\d+(?:\.\d+)?$/.test(String(s).trim());
29
- if (typeof field[0] !== 'string' || isNumericString(field[0])) {
30
- return field
31
- .map((arg) => {
32
- if (Array.isArray(arg)) return this.buildAggregateField(arg, params);
33
- return String(arg);
34
- })
35
- .join(', ');
36
- }
27
+ const trimmed = value.trim();
28
+ if (trimmed.length === 0) return false;
37
29
 
38
- let [fn, ...args] = field;
39
- let alias: string | undefined;
30
+ if (trimmed === '*') return true;
40
31
 
41
- if (args.length >= 2 && String(args[args.length - 2]).toUpperCase() === 'AS') {
42
- alias = String(args.pop());
43
- args.pop();
32
+ if (/^[A-Za-z_][A-Za-z0-9_]*\.\*$/.test(trimmed)) {
33
+ this.assertValidIdentifier(trimmed, 'SQL reference');
34
+ return true;
44
35
  }
45
36
 
46
- const F = String(fn).toUpperCase();
47
- const isGeomFromText = F === C6C.ST_GEOMFROMTEXT.toUpperCase();
48
-
49
- if (args.length === 1 && Array.isArray(args[0])) {
50
- args = args[0];
37
+ if (/^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) {
38
+ this.assertValidIdentifier(trimmed, 'SQL reference');
39
+ return true;
51
40
  }
52
41
 
53
- // Parameter placeholder helper: [C6C.PARAM, value]
54
- if (F === C6C.PARAM) {
55
- if (!params) {
56
- throw new Error('PARAM requires parameter tracking.');
57
- }
58
- const value = args[0];
59
- // Use empty column context; ORDER/SELECT literals have no column typing.
60
- // @ts-ignore addParam is provided by ConditionBuilder in our hierarchy.
61
- return this.addParam(params, '', value);
42
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed) && this.selectAliases.has(trimmed)) {
43
+ return true;
62
44
  }
63
45
 
64
- if (F === C6C.SUBSELECT) {
65
- if (!params) {
66
- throw new Error('Scalar subselects in SELECT require parameter tracking.');
67
- }
68
- const subRequest = args[0];
69
- const subSql = (this as any).buildScalarSubSelect?.(subRequest, params);
70
- if (!subSql) {
71
- throw new Error('Failed to build scalar subselect.');
72
- }
73
-
74
- let expr = subSql;
75
- if (alias) {
76
- this.selectAliases.add(alias);
77
- expr += ` AS ${alias}`;
78
- }
79
-
80
- logWithLevel(
81
- LogLevel.DEBUG,
82
- getLogContext(this.config, this.request),
83
- console.log,
84
- `[SELECT] ${expr}`,
85
- );
86
-
87
- return expr;
88
- }
46
+ return false;
47
+ }
89
48
 
90
- const identifierPathRegex = /^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/;
49
+ protected isKnownFunction(functionName: string): boolean {
50
+ return KNOWN_FUNCTION_LOOKUP.has(functionName.trim().toUpperCase());
51
+ }
91
52
 
92
- const argList = args
93
- .map((arg, index) => {
94
- if (Array.isArray(arg)) return this.buildAggregateField(arg, params);
95
- if (typeof arg === 'string') {
96
- if (identifierPathRegex.test(arg)) {
97
- this.assertValidIdentifier(arg, 'SELECT expression');
98
- return arg;
53
+ protected serializeExpression(
54
+ expression: any,
55
+ params?: tSqlParams,
56
+ context: string = 'SQL expression',
57
+ contextColumn?: string,
58
+ ): iSerializedExpression {
59
+ return serializeSqlExpression(expression, {
60
+ params,
61
+ context,
62
+ contextColumn,
63
+ hooks: {
64
+ assertValidIdentifier: (identifier: string, hookContext: string) => {
65
+ this.assertValidIdentifier(identifier, hookContext);
66
+ },
67
+ isReference: (value: string) => this.isReferenceExpression(value),
68
+ addParam: (target: tSqlParams, column: string, value: any) => {
69
+ const addParam = (this as any).addParam;
70
+ if (typeof addParam !== 'function') {
71
+ throw new Error('Expression literal binding requires addParam support.');
99
72
  }
100
- // Treat numeric-looking strings as literals, not identifier paths
101
- if (isNumericString(arg)) return arg;
102
-
103
- if (isGeomFromText && index === 0) {
104
- const trimmed = arg.trim();
105
- const alreadyQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2;
106
- if (alreadyQuoted) {
107
- return trimmed;
108
- }
109
- const escaped = arg.replace(/'/g, "''");
110
- return `'${escaped}'`;
73
+ return addParam.call(this, target, column, value);
74
+ },
75
+ buildScalarSubSelect: (subRequest: any, target: tSqlParams) => {
76
+ const builder = (this as any).buildScalarSubSelect;
77
+ if (typeof builder !== 'function') {
78
+ throw new Error('Scalar subselects require SelectQueryBuilder context.');
111
79
  }
80
+ return builder.call(this, subRequest, target);
81
+ },
82
+ onAlias: (alias: string) => {
83
+ this.selectAliases.add(alias);
84
+ },
85
+ isKnownFunction: (functionName: string) => this.isKnownFunction(functionName),
86
+ },
87
+ });
88
+ }
112
89
 
113
- return arg;
114
- }
115
- return String(arg);
116
- })
117
- .join(', ');
118
-
119
- let expr: string;
120
-
121
- if (F === 'DISTINCT') {
122
- expr = `DISTINCT ${argList}`;
123
- } else {
124
- expr = `${F}(${argList})`;
125
- }
126
-
127
- if (alias) {
128
- this.selectAliases.add(alias);
129
- expr += ` AS ${alias}`;
130
- }
90
+ buildAggregateField(field: string | any[], params?: tSqlParams): string {
91
+ const serialized = this.serializeExpression(field, params, 'SELECT expression');
131
92
 
132
93
  logWithLevel(
133
94
  LogLevel.DEBUG,
134
95
  getLogContext(this.config, this.request),
135
96
  console.log,
136
- `[SELECT] ${expr}`,
97
+ `[SELECT] ${serialized.sql}`,
137
98
  );
138
99
 
139
- return expr;
100
+ return serialized.sql;
140
101
  }
141
102
  }