@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
@@ -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";
@@ -65,6 +65,29 @@ export abstract class ConditionBuilder<
65
65
  return false;
66
66
  }
67
67
 
68
+ protected override isReferenceExpression(value: string): boolean {
69
+ const trimmed = value.trim();
70
+ if (trimmed === '*') {
71
+ return true;
72
+ }
73
+
74
+ if (trimmed.includes('.')) {
75
+ if (/^[A-Za-z_][A-Za-z0-9_]*\.\*$/.test(trimmed)) {
76
+ return true;
77
+ }
78
+ if (this.isTableReference(trimmed) || this.isColumnRef(trimmed)) {
79
+ return true;
80
+ }
81
+ if (/^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) {
82
+ this.assertValidIdentifier(trimmed, 'SQL reference');
83
+ return true;
84
+ }
85
+ return false;
86
+ }
87
+
88
+ return super.isReferenceExpression(trimmed);
89
+ }
90
+
68
91
  abstract build(table: string): SqlBuilderResult;
69
92
 
70
93
  execute(): Promise<DetermineResponseDataType<G['RequestMethod'], G['RestTableInterface']>> {
@@ -152,15 +175,8 @@ export abstract class ConditionBuilder<
152
175
  column: string,
153
176
  value: any
154
177
  ): string {
155
- // Determine column definition from C6.TABLES to support type-aware conversions (e.g., BINARY hex -> Buffer)
156
- let columnDef: any | undefined;
157
- if (typeof column === 'string' && column.includes('.')) {
158
- const [tableName, colName] = column.split('.', 2);
159
- const table = this.config.C6?.TABLES?.[tableName];
160
- // Support both short-keyed and fully-qualified TYPE_VALIDATION entries
161
- columnDef = table?.TYPE_VALIDATION?.[colName] ?? table?.TYPE_VALIDATION?.[`${tableName}.${colName}`];
162
- }
163
- const val = convertHexIfBinary(column, value, columnDef);
178
+ const columnDef = this.resolveColumnDefinition(column);
179
+ const val = convertSqlValueForColumn(column, value, columnDef);
164
180
 
165
181
  if (this.useNamedParams) {
166
182
  const key = `param${Object.keys(params).length}`;
@@ -200,45 +216,6 @@ export abstract class ConditionBuilder<
200
216
  return !!this.normalizeOperatorKey(op);
201
217
  }
202
218
 
203
- private looksLikeSafeFunctionExpression(value: string): boolean {
204
- if (typeof value !== 'string') return false;
205
-
206
- const trimmed = value.trim();
207
- if (trimmed.length === 0) return false;
208
-
209
- if (trimmed.includes(';') || trimmed.includes('--') || trimmed.includes('/*') || trimmed.includes('*/')) {
210
- return false;
211
- }
212
-
213
- if (!trimmed.includes('(') || !trimmed.endsWith(')')) {
214
- return false;
215
- }
216
-
217
- const functionMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*\(/);
218
- if (!functionMatch) {
219
- return false;
220
- }
221
-
222
- const allowedCharacters = /^[A-Za-z0-9_().,'"\s-]+$/;
223
- if (!allowedCharacters.test(trimmed)) {
224
- return false;
225
- }
226
-
227
- let depth = 0;
228
- for (const char of trimmed) {
229
- if (char === '(') {
230
- depth += 1;
231
- } else if (char === ')') {
232
- depth -= 1;
233
- if (depth < 0) {
234
- return false;
235
- }
236
- }
237
- }
238
-
239
- return depth === 0;
240
- }
241
-
242
219
  private ensureWrapped(expression: string): string {
243
220
  const trimmed = expression.trim();
244
221
  if (!trimmed) return trimmed;
@@ -268,46 +245,6 @@ export abstract class ConditionBuilder<
268
245
  .join(` ${operator} `);
269
246
  }
270
247
 
271
- private normalizeFunctionField(field: any, params: any[] | Record<string, any>): any {
272
- if (field instanceof Map) {
273
- field = Object.fromEntries(field);
274
- }
275
-
276
- if (Array.isArray(field)) {
277
- if (field.length === 0) return field;
278
- const [fn, ...args] = field;
279
- const normalizedArgs = args.map(arg => this.normalizeFunctionField(arg, params));
280
- return [fn, ...normalizedArgs];
281
- }
282
-
283
- if (field && typeof field === 'object') {
284
- if (C6C.SUBSELECT in field) {
285
- const builder = (this as any).buildScalarSubSelect;
286
- if (typeof builder !== 'function') {
287
- throw new Error('Scalar subselect handling requires JoinBuilder context.');
288
- }
289
- return builder.call(this, field[C6C.SUBSELECT], params);
290
- }
291
-
292
- const entries = Object.entries(field);
293
- if (entries.length === 1) {
294
- const [key, value] = entries[0];
295
- if (this.isOperator(key)) {
296
- return this.buildOperatorExpression(key, value, params);
297
- }
298
- return this.buildFunctionCall(key, value, params);
299
- }
300
- }
301
-
302
- return field;
303
- }
304
-
305
- private buildFunctionCall(fn: string, value: any, params: any[] | Record<string, any>): string {
306
- const args = Array.isArray(value) ? value : [value];
307
- const normalized = this.normalizeFunctionField([fn, ...args], params);
308
- return this.buildAggregateField(normalized, params);
309
- }
310
-
311
248
  private serializeOperand(
312
249
  operand: any,
313
250
  params: any[] | Record<string, any>,
@@ -328,19 +265,15 @@ export abstract class ConditionBuilder<
328
265
  }
329
266
 
330
267
  if (typeof operand === 'string') {
331
- if (this.isTableReference(operand) || this.isColumnRef(operand)) {
332
- return { sql: operand, isReference: true, isExpression: false, isSubSelect: false };
268
+ const trimmed = operand.trim();
269
+ if (this.isReferenceExpression(trimmed) || this.isTableReference(trimmed) || this.isColumnRef(trimmed)) {
270
+ return { sql: trimmed, isReference: true, isExpression: false, isSubSelect: false };
333
271
  }
334
- if (this.looksLikeSafeFunctionExpression(operand)) {
335
- return { sql: operand.trim(), isReference: false, isExpression: true, isSubSelect: false };
336
- }
337
- return { sql: asParam(operand), isReference: false, isExpression: false, isSubSelect: false };
272
+ throw new Error(`Bare string '${operand}' is not a reference. Wrap literal strings with [C6C.LIT, value].`);
338
273
  }
339
274
 
340
275
  if (Array.isArray(operand)) {
341
- const normalized = this.normalizeFunctionField(operand, params);
342
- const sql = this.buildAggregateField(normalized, params);
343
- return { sql, isReference: false, isExpression: true, isSubSelect: false };
276
+ return this.serializeExpression(operand, params, 'SQL expression', contextColumn);
344
277
  }
345
278
 
346
279
  if (operand instanceof Map) {
@@ -371,16 +304,33 @@ export abstract class ConditionBuilder<
371
304
  return { sql: this.ensureWrapped(sql), isReference: false, isExpression: true, isSubSelect: false };
372
305
  }
373
306
 
374
- const sql = this.buildFunctionCall(key, value, params);
375
- return { sql, isReference: false, isExpression: true, isSubSelect: false };
307
+ throw new Error('Object-rooted expressions are not supported. Use tuple syntax instead.');
376
308
  }
377
309
  }
378
310
 
379
311
  throw new Error('Unsupported operand type in SQL expression.');
380
312
  }
381
313
 
314
+ private isExpressionTuple(value: any): boolean {
315
+ if (!Array.isArray(value) || value.length === 0 || typeof value[0] !== 'string') {
316
+ return false;
317
+ }
318
+
319
+ const token = String(value[0]).toUpperCase();
320
+ return (
321
+ token === C6C.AS
322
+ || token === C6C.DISTINCT
323
+ || token === C6C.CALL
324
+ || token === C6C.LIT
325
+ || token === C6C.PARAM
326
+ || token === C6C.SUBSELECT
327
+ || this.isKnownFunction(value[0])
328
+ );
329
+ }
330
+
382
331
  private isPlainArrayLiteral(value: any, allowColumnRefs = false): boolean {
383
332
  if (!Array.isArray(value)) return false;
333
+ if (this.isExpressionTuple(value)) return false;
384
334
  return value.every(item => {
385
335
  if (item === null) return true;
386
336
  const type = typeof item;
@@ -444,6 +394,19 @@ export abstract class ConditionBuilder<
444
394
  return this.addParam(params, contextColumn ?? '', JSON.stringify(normalized));
445
395
  }
446
396
 
397
+ if (
398
+ normalized === C6C.NULL
399
+ || normalized === null
400
+ || typeof normalized === 'string'
401
+ || typeof normalized === 'number'
402
+ || typeof normalized === 'boolean'
403
+ || normalized instanceof Date
404
+ || (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(normalized))
405
+ ) {
406
+ const scalar = normalized === C6C.NULL ? null : normalized;
407
+ return this.addParam(params, contextColumn ?? '', scalar);
408
+ }
409
+
447
410
  let sql: string;
448
411
  let isReference: boolean;
449
412
  let isExpression: boolean;
@@ -546,7 +509,9 @@ export abstract class ConditionBuilder<
546
509
 
547
510
  const payload = this.ensurePlainObject(payloadRaw);
548
511
  let subSelect: any;
549
- if (payload && typeof payload === 'object' && C6C.SUBSELECT in payload) {
512
+ if (Array.isArray(payload) && payload.length === 2 && String(payload[0]).toUpperCase() === C6C.SUBSELECT) {
513
+ subSelect = this.ensurePlainObject(payload[1]);
514
+ } else if (payload && typeof payload === 'object' && C6C.SUBSELECT in payload) {
550
515
  subSelect = this.ensurePlainObject(payload[C6C.SUBSELECT]);
551
516
  } else if (payload && typeof payload === 'object') {
552
517
  subSelect = payload;
@@ -664,7 +629,11 @@ export abstract class ConditionBuilder<
664
629
  }
665
630
 
666
631
  const [search, mode] = right;
667
- const placeholder = this.addParam(params, leftInfo.sql, search);
632
+ const searchInfo = this.serializeOperand(search, params, leftInfo.sql);
633
+ if (searchInfo.isReference || searchInfo.isExpression || searchInfo.isSubSelect) {
634
+ throw new Error('MATCH_AGAINST search payload must be a literal value (wrap strings with [C6C.LIT, value]).');
635
+ }
636
+ const placeholder = searchInfo.sql;
668
637
  let againstClause: string;
669
638
  switch (typeof mode === 'string' ? mode.toUpperCase() : '') {
670
639
  case 'BOOLEAN':
@@ -784,7 +753,7 @@ export abstract class ConditionBuilder<
784
753
  if (!Array.isArray(value)) {
785
754
  throw new Error(`${column} expects an array of arguments.`);
786
755
  }
787
- return this.buildFunctionCall(column, value, params);
756
+ return this.serializeExpression([column, ...value], params, `WHERE function ${column}`, column).sql;
788
757
  }
789
758
  }
790
759
 
@@ -0,0 +1,275 @@
1
+ import {C6C} from "../../constants/C6Constants";
2
+
3
+ export type tSqlParams = any[] | Record<string, any>;
4
+
5
+ export interface iSerializedExpression {
6
+ sql: string;
7
+ isReference: boolean;
8
+ isExpression: boolean;
9
+ isSubSelect: boolean;
10
+ }
11
+
12
+ export interface iExpressionSerializerHooks {
13
+ assertValidIdentifier(identifier: string, context: string): void;
14
+ isReference(value: string): boolean;
15
+ addParam?: (params: tSqlParams, column: string, value: any) => string;
16
+ buildScalarSubSelect?: (subRequest: any, params: tSqlParams) => string;
17
+ onAlias?: (alias: string) => void;
18
+ isKnownFunction?: (functionName: string) => boolean;
19
+ }
20
+
21
+ export interface iExpressionSerializerOptions {
22
+ hooks: iExpressionSerializerHooks;
23
+ params?: tSqlParams;
24
+ context: string;
25
+ contextColumn?: string;
26
+ }
27
+
28
+ const IDENTIFIER_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
29
+
30
+ const isFiniteNumber = (value: any): value is number =>
31
+ typeof value === 'number' && Number.isFinite(value);
32
+
33
+ const normalizeToken = (token: string): string => token.trim();
34
+
35
+ const ensureParams = (opts: iExpressionSerializerOptions): tSqlParams => {
36
+ if (!opts.params) {
37
+ throw new Error(`${opts.context} requires parameter tracking for literal bindings.`);
38
+ }
39
+ if (!opts.hooks.addParam) {
40
+ throw new Error(`${opts.context} requires addParam support for literal bindings.`);
41
+ }
42
+ return opts.params;
43
+ };
44
+
45
+ const serializeStringReference = (
46
+ raw: string,
47
+ opts: iExpressionSerializerOptions,
48
+ ): iSerializedExpression => {
49
+ const value = raw.trim();
50
+
51
+ if (value === '*') {
52
+ return {
53
+ sql: value,
54
+ isReference: true,
55
+ isExpression: false,
56
+ isSubSelect: false,
57
+ };
58
+ }
59
+
60
+ if (!opts.hooks.isReference(value)) {
61
+ throw new Error(
62
+ `Bare string '${raw}' is not a reference in ${opts.context}. Wrap literal strings with [C6C.LIT, value].`,
63
+ );
64
+ }
65
+
66
+ if (value.includes('.')) {
67
+ opts.hooks.assertValidIdentifier(value, opts.context);
68
+ }
69
+
70
+ return {
71
+ sql: value,
72
+ isReference: true,
73
+ isExpression: false,
74
+ isSubSelect: false,
75
+ };
76
+ };
77
+
78
+ const serializeLiteralValue = (
79
+ value: any,
80
+ opts: iExpressionSerializerOptions,
81
+ ): iSerializedExpression => {
82
+ if (value === null || value === C6C.NULL) {
83
+ return {
84
+ sql: 'NULL',
85
+ isReference: false,
86
+ isExpression: false,
87
+ isSubSelect: false,
88
+ };
89
+ }
90
+
91
+ if (isFiniteNumber(value)) {
92
+ return {
93
+ sql: String(value),
94
+ isReference: false,
95
+ isExpression: false,
96
+ isSubSelect: false,
97
+ };
98
+ }
99
+
100
+ if (typeof value === 'boolean') {
101
+ return {
102
+ sql: value ? 'TRUE' : 'FALSE',
103
+ isReference: false,
104
+ isExpression: false,
105
+ isSubSelect: false,
106
+ };
107
+ }
108
+
109
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(value)) {
110
+ const params = ensureParams(opts);
111
+ return {
112
+ sql: opts.hooks.addParam!(params, opts.contextColumn ?? '', value),
113
+ isReference: false,
114
+ isExpression: false,
115
+ isSubSelect: false,
116
+ };
117
+ }
118
+
119
+ throw new Error(`Unsupported literal value in ${opts.context}. Use [C6C.LIT, value] for non-reference strings or complex values.`);
120
+ };
121
+
122
+ const validateAlias = (aliasRaw: any, context: string): string => {
123
+ if (typeof aliasRaw !== 'string' || aliasRaw.trim() === '') {
124
+ throw new Error(`[C6C.AS] in ${context} expects a non-empty alias string.`);
125
+ }
126
+
127
+ const alias = aliasRaw.trim();
128
+ if (!IDENTIFIER_REGEX.test(alias)) {
129
+ throw new Error(`[C6C.AS] alias '${alias}' in ${context} must be a valid SQL identifier.`);
130
+ }
131
+
132
+ return alias;
133
+ };
134
+
135
+ const validateFunctionName = (nameRaw: any, context: string): string => {
136
+ if (typeof nameRaw !== 'string' || nameRaw.trim() === '') {
137
+ throw new Error(`[C6C.CALL] in ${context} expects the custom function name as a non-empty string.`);
138
+ }
139
+
140
+ const name = normalizeToken(nameRaw);
141
+ if (!IDENTIFIER_REGEX.test(name)) {
142
+ throw new Error(`[C6C.CALL] function '${name}' in ${context} must be a valid SQL identifier.`);
143
+ }
144
+
145
+ return name;
146
+ };
147
+
148
+ const serializeFunctionArgs = (
149
+ args: any[],
150
+ opts: iExpressionSerializerOptions,
151
+ ): string => args
152
+ .map((arg) => serializeSqlExpression(arg, opts).sql)
153
+ .join(', ');
154
+
155
+ export const serializeSqlExpression = (
156
+ value: any,
157
+ opts: iExpressionSerializerOptions,
158
+ ): iSerializedExpression => {
159
+ if (value instanceof Map) {
160
+ value = Object.fromEntries(value);
161
+ }
162
+
163
+ if (Array.isArray(value)) {
164
+ if (value.length === 0) {
165
+ throw new Error(`Invalid empty expression array in ${opts.context}.`);
166
+ }
167
+
168
+ const [headRaw, ...tail] = value;
169
+ if (typeof headRaw !== 'string') {
170
+ throw new Error(`Expression arrays in ${opts.context} must start with a string token.`);
171
+ }
172
+
173
+ const head = normalizeToken(headRaw);
174
+ const token = head.toUpperCase();
175
+
176
+ if (token === C6C.AS) {
177
+ if (tail.length !== 2) {
178
+ throw new Error(`[C6C.AS] in ${opts.context} expects [C6C.AS, expression, alias].`);
179
+ }
180
+ const inner = serializeSqlExpression(tail[0], opts);
181
+ const alias = validateAlias(tail[1], opts.context);
182
+ opts.hooks.onAlias?.(alias);
183
+
184
+ return {
185
+ sql: `${inner.sql} AS ${alias}`,
186
+ isReference: false,
187
+ isExpression: true,
188
+ isSubSelect: inner.isSubSelect,
189
+ };
190
+ }
191
+
192
+ if (token === C6C.DISTINCT) {
193
+ if (tail.length !== 1) {
194
+ throw new Error(`[C6C.DISTINCT] in ${opts.context} expects [C6C.DISTINCT, expression].`);
195
+ }
196
+
197
+ const inner = serializeSqlExpression(tail[0], opts);
198
+ return {
199
+ sql: `DISTINCT ${inner.sql}`,
200
+ isReference: false,
201
+ isExpression: true,
202
+ isSubSelect: inner.isSubSelect,
203
+ };
204
+ }
205
+
206
+ if (token === C6C.LIT || token === C6C.PARAM) {
207
+ if (tail.length !== 1) {
208
+ throw new Error(`[${head}] in ${opts.context} expects [${head}, value].`);
209
+ }
210
+
211
+ const params = ensureParams(opts);
212
+ return {
213
+ sql: opts.hooks.addParam!(params, opts.contextColumn ?? '', tail[0]),
214
+ isReference: false,
215
+ isExpression: false,
216
+ isSubSelect: false,
217
+ };
218
+ }
219
+
220
+ if (token === C6C.SUBSELECT) {
221
+ if (tail.length !== 1) {
222
+ throw new Error(`[C6C.SUBSELECT] in ${opts.context} expects [C6C.SUBSELECT, payload].`);
223
+ }
224
+ if (!opts.hooks.buildScalarSubSelect) {
225
+ throw new Error(`Scalar subselects in ${opts.context} require subselect builder support.`);
226
+ }
227
+ const params = ensureParams(opts);
228
+ const subSql = opts.hooks.buildScalarSubSelect(tail[0], params);
229
+ return {
230
+ sql: subSql,
231
+ isReference: false,
232
+ isExpression: true,
233
+ isSubSelect: true,
234
+ };
235
+ }
236
+
237
+ if (token === C6C.CALL) {
238
+ const [fnNameRaw, ...args] = tail;
239
+ const fnName = validateFunctionName(fnNameRaw, opts.context);
240
+ const sqlArgs = serializeFunctionArgs(args, opts);
241
+ return {
242
+ sql: `${fnName}(${sqlArgs})`,
243
+ isReference: false,
244
+ isExpression: true,
245
+ isSubSelect: false,
246
+ };
247
+ }
248
+
249
+ if (tail.length >= 2 && String(tail[tail.length - 2]).toUpperCase() === C6C.AS) {
250
+ throw new Error(`Legacy positional AS syntax is not supported in ${opts.context}. Use [C6C.AS, expression, alias].`);
251
+ }
252
+
253
+ if (opts.hooks.isKnownFunction && !opts.hooks.isKnownFunction(head)) {
254
+ throw new Error(`Unknown SQL function '${head}' in ${opts.context}. Use [C6C.CALL, 'FUNCTION_NAME', ...args] for custom functions.`);
255
+ }
256
+
257
+ const sqlArgs = serializeFunctionArgs(tail, opts);
258
+ return {
259
+ sql: `${token}(${sqlArgs})`,
260
+ isReference: false,
261
+ isExpression: true,
262
+ isSubSelect: false,
263
+ };
264
+ }
265
+
266
+ if (typeof value === 'string') {
267
+ return serializeStringReference(value, opts);
268
+ }
269
+
270
+ if (value && typeof value === 'object') {
271
+ throw new Error(`Object-rooted expressions are not supported in ${opts.context}. Use tuple syntax instead.`);
272
+ }
273
+
274
+ return serializeLiteralValue(value, opts);
275
+ };
@@ -10,12 +10,10 @@ export abstract class PaginationBuilder<G extends OrmGenerics> extends JoinBuild
10
10
  *
11
11
  * Accepted structures:
12
12
  * ```ts
13
- * ORDER: {
14
- * // simple column with direction
15
- * [property_units.UNIT_ID]: "DESC",
16
- * // function call (array of arguments)
17
- * [C6Constants.ST_DISTANCE_SPHERE]: [property_units.LOCATION, F(property_units.LOCATION, "pu_target")]
18
- * }
13
+ * ORDER: [
14
+ * [property_units.UNIT_ID, "DESC"],
15
+ * [[C6Constants.ST_DISTANCE_SPHERE, property_units.LOCATION, F(property_units.LOCATION, "pu_target")], "ASC"],
16
+ * ]
19
17
  * ```
20
18
  */
21
19
  buildPaginationClause(pagination: any, params?: any[] | Record<string, any>): string {
@@ -25,35 +23,27 @@ export abstract class PaginationBuilder<G extends OrmGenerics> extends JoinBuild
25
23
  if (pagination?.[C6Constants.ORDER]) {
26
24
  const orderParts: string[] = [];
27
25
 
28
- for (const [key, val] of Object.entries(pagination[C6Constants.ORDER])) {
29
- if (typeof key === 'string' && key.includes('.')) {
30
- this.assertValidIdentifier(key, 'ORDER BY');
31
- }
32
- // FUNCTION CALL: val is an array of args
33
- if (Array.isArray(val)) {
34
- const identifierPathRegex = /^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/;
35
- const isNumericString = (s: string) => /^-?\d+(?:\.\d+)?$/.test(s.trim());
36
- const args = val
37
- .map((arg) => {
38
- if (Array.isArray(arg)) return this.buildAggregateField(arg, params);
39
- if (typeof arg === 'string') {
40
- if (identifierPathRegex.test(arg)) {
41
- this.assertValidIdentifier(arg, 'ORDER BY argument');
42
- return arg;
43
- }
44
- // numeric-looking strings should be treated as literals
45
- if (isNumericString(arg)) return arg;
46
- return arg;
47
- }
48
- return String(arg);
49
- })
50
- .join(", ");
51
- orderParts.push(`${key}(${args})`);
52
- }
53
- // SIMPLE COLUMN + DIR (ASC/DESC)
54
- else {
55
- orderParts.push(`${key} ${String(val).toUpperCase()}`);
26
+ const orderSpec = pagination[C6Constants.ORDER];
27
+ if (!Array.isArray(orderSpec)) {
28
+ throw new Error('PAGINATION.ORDER expects an array of terms using [expression, direction?] syntax.');
29
+ }
30
+
31
+ for (const rawTerm of orderSpec) {
32
+ let expression = rawTerm;
33
+ let direction: string = C6Constants.ASC;
34
+
35
+ if (
36
+ Array.isArray(rawTerm)
37
+ && rawTerm.length === 2
38
+ && typeof rawTerm[1] === 'string'
39
+ && (String(rawTerm[1]).toUpperCase() === C6Constants.ASC || String(rawTerm[1]).toUpperCase() === C6Constants.DESC)
40
+ ) {
41
+ expression = rawTerm[0];
42
+ direction = String(rawTerm[1]).toUpperCase();
56
43
  }
44
+
45
+ const serialized = this.serializeExpression(expression, params, 'ORDER BY expression');
46
+ orderParts.push(`${serialized.sql} ${direction}`);
57
47
  }
58
48
 
59
49
  if (orderParts.length) sql += ` ORDER BY ${orderParts.join(", ")}`;
@@ -1,5 +1,6 @@
1
1
  // Alias a table name with a given alias
2
2
  import {C6C} from "../constants/C6Constants";
3
+ import {OrderDirection, OrderTerm, SQLExpression, SQLKnownFunction} from "../types/mysqlTypes";
3
4
 
4
5
  type DerivedTableSpec = Record<string, any> & {
5
6
  [C6C.SUBSELECT]?: Record<string, any>;
@@ -90,3 +91,31 @@ export const bbox = (minLng: number, minLat: number, maxLng: number, maxLat: num
90
91
  // ST_Contains for map envelope/shape queries
91
92
  export const stContains = (envelope: string, shape: string): any[] =>
92
93
  [C6C.ST_CONTAINS, envelope, shape];
94
+
95
+ // Strongly-typed known function helper.
96
+ export const fn = <Fn extends SQLKnownFunction>(
97
+ functionName: Fn,
98
+ ...args: SQLExpression[]
99
+ ): [Fn, ...SQLExpression[]] => [functionName, ...args];
100
+
101
+ // Escape hatch for custom function names.
102
+ export const call = (
103
+ functionName: string,
104
+ ...args: SQLExpression[]
105
+ ): [typeof C6C.CALL, string, ...SQLExpression[]] => [C6C.CALL as typeof C6C.CALL, functionName, ...args];
106
+
107
+ export const alias = (
108
+ expression: SQLExpression,
109
+ aliasName: string,
110
+ ): [typeof C6C.AS, SQLExpression, string] => [C6C.AS as typeof C6C.AS, expression, aliasName];
111
+
112
+ export const distinct = (
113
+ expression: SQLExpression,
114
+ ): [typeof C6C.DISTINCT, SQLExpression] => [C6C.DISTINCT as typeof C6C.DISTINCT, expression];
115
+
116
+ export const lit = (value: any): [typeof C6C.LIT, any] => [C6C.LIT as typeof C6C.LIT, value];
117
+
118
+ export const order = (
119
+ expression: SQLExpression,
120
+ direction: OrderDirection = C6C.ASC as OrderDirection,
121
+ ): OrderTerm => [expression, direction];