@carbonorm/carbonnode 6.0.20 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +46 -1
  2. package/dist/constants/C6Constants.d.ts +342 -338
  3. package/dist/executors/SqlExecutor.d.ts +1 -0
  4. package/dist/index.cjs.js +538 -254
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.esm.js +531 -255
  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/types/mysqlTypes.d.ts +6 -1
  15. package/dist/types/ormInterfaces.d.ts +6 -5
  16. package/package.json +2 -2
  17. package/scripts/assets/handlebars/C6.test.ts.handlebars +4 -4
  18. package/src/__tests__/expressServer.e2e.test.ts +26 -17
  19. package/src/__tests__/httpExecutorSingular.e2e.test.ts +53 -14
  20. package/src/__tests__/normalizeSingularRequest.test.ts +26 -8
  21. package/src/__tests__/sakila-db/C6.js +1 -1
  22. package/src/__tests__/sakila-db/C6.mysqldump.json +1 -1
  23. package/src/__tests__/sakila-db/C6.mysqldump.sql +1 -1
  24. package/src/__tests__/sakila-db/C6.sqlAllowList.json +1 -1
  25. package/src/__tests__/sakila-db/C6.test.ts +4 -4
  26. package/src/__tests__/sakila-db/C6.ts +1 -1
  27. package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.json +6 -6
  28. package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.latest.json +3 -3
  29. package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.json +1 -1
  30. package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.lookup.json +3 -3
  31. package/src/__tests__/sakila-db/sqlResponses/C6.address.post.json +19 -12
  32. package/src/__tests__/sakila-db/sqlResponses/C6.address.post.latest.json +5 -5
  33. package/src/__tests__/sakila-db/sqlResponses/C6.address.put.json +1 -1
  34. package/src/__tests__/sakila-db/sqlResponses/C6.address.put.lookup.json +5 -5
  35. package/src/__tests__/sakila-db/sqlResponses/C6.category.post.json +4 -4
  36. package/src/__tests__/sakila-db/sqlResponses/C6.category.post.latest.json +2 -2
  37. package/src/__tests__/sakila-db/sqlResponses/C6.category.put.json +1 -1
  38. package/src/__tests__/sakila-db/sqlResponses/C6.category.put.lookup.json +2 -2
  39. package/src/__tests__/sakila-db/sqlResponses/C6.city.post.json +4 -4
  40. package/src/__tests__/sakila-db/sqlResponses/C6.city.post.latest.json +2 -2
  41. package/src/__tests__/sakila-db/sqlResponses/C6.city.put.json +1 -1
  42. package/src/__tests__/sakila-db/sqlResponses/C6.city.put.lookup.json +2 -2
  43. package/src/__tests__/sakila-db/sqlResponses/C6.country.post.json +4 -4
  44. package/src/__tests__/sakila-db/sqlResponses/C6.country.post.latest.json +2 -2
  45. package/src/__tests__/sakila-db/sqlResponses/C6.country.put.json +1 -1
  46. package/src/__tests__/sakila-db/sqlResponses/C6.country.put.lookup.json +2 -2
  47. package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.json +10 -10
  48. package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.latest.json +5 -5
  49. package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.json +1 -1
  50. package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.lookup.json +5 -5
  51. package/src/__tests__/sakila-db/sqlResponses/C6.film.post.json +4 -4
  52. package/src/__tests__/sakila-db/sqlResponses/C6.film.post.latest.json +2 -2
  53. package/src/__tests__/sakila-db/sqlResponses/C6.film.put.json +1 -1
  54. package/src/__tests__/sakila-db/sqlResponses/C6.film.put.lookup.json +2 -2
  55. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.json +2 -2
  56. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.latest.json +1 -1
  57. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.json +1 -1
  58. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.lookup.json +1 -1
  59. package/src/__tests__/sakila-db/sqlResponses/C6.language.post.json +4 -4
  60. package/src/__tests__/sakila-db/sqlResponses/C6.language.post.latest.json +2 -2
  61. package/src/__tests__/sakila-db/sqlResponses/C6.language.put.json +1 -1
  62. package/src/__tests__/sakila-db/sqlResponses/C6.language.put.lookup.json +2 -2
  63. package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.json +4 -4
  64. package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.latest.json +2 -2
  65. package/src/__tests__/sakila-db/sqlResponses/C6.payment.put.lookup.json +2 -2
  66. package/src/__tests__/sakila-db/sqlResponses/C6.rental.join.json +10 -10
  67. package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.json +6 -6
  68. package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.latest.json +3 -3
  69. package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.json +1 -1
  70. package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.lookup.json +3 -3
  71. package/src/__tests__/sqlBuilders.complex.test.ts +62 -74
  72. package/src/__tests__/sqlBuilders.expressions.test.ts +58 -30
  73. package/src/__tests__/sqlBuilders.test.ts +68 -4
  74. package/src/constants/C6Constants.ts +3 -1
  75. package/src/executors/HttpExecutor.ts +2 -1
  76. package/src/executors/SqlExecutor.ts +25 -1
  77. package/src/index.ts +1 -0
  78. package/src/orm/builders/AggregateBuilder.ts +67 -106
  79. package/src/orm/builders/ConditionBuilder.ts +69 -93
  80. package/src/orm/builders/ExpressionSerializer.ts +275 -0
  81. package/src/orm/builders/PaginationBuilder.ts +24 -34
  82. package/src/orm/queryHelpers.ts +29 -0
  83. package/src/types/mysqlTypes.ts +130 -9
  84. package/src/types/ormInterfaces.ts +6 -7
  85. package/src/utils/normalizeSingularRequest.ts +11 -4
@@ -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']>> {
@@ -200,45 +223,6 @@ export abstract class ConditionBuilder<
200
223
  return !!this.normalizeOperatorKey(op);
201
224
  }
202
225
 
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
226
  private ensureWrapped(expression: string): string {
243
227
  const trimmed = expression.trim();
244
228
  if (!trimmed) return trimmed;
@@ -268,46 +252,6 @@ export abstract class ConditionBuilder<
268
252
  .join(` ${operator} `);
269
253
  }
270
254
 
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
255
  private serializeOperand(
312
256
  operand: any,
313
257
  params: any[] | Record<string, any>,
@@ -328,19 +272,15 @@ export abstract class ConditionBuilder<
328
272
  }
329
273
 
330
274
  if (typeof operand === 'string') {
331
- if (this.isTableReference(operand) || this.isColumnRef(operand)) {
332
- return { sql: operand, isReference: true, isExpression: false, isSubSelect: false };
333
- }
334
- if (this.looksLikeSafeFunctionExpression(operand)) {
335
- return { sql: operand.trim(), isReference: false, isExpression: true, isSubSelect: false };
275
+ const trimmed = operand.trim();
276
+ if (this.isReferenceExpression(trimmed) || this.isTableReference(trimmed) || this.isColumnRef(trimmed)) {
277
+ return { sql: trimmed, isReference: true, isExpression: false, isSubSelect: false };
336
278
  }
337
- return { sql: asParam(operand), isReference: false, isExpression: false, isSubSelect: false };
279
+ throw new Error(`Bare string '${operand}' is not a reference. Wrap literal strings with [C6C.LIT, value].`);
338
280
  }
339
281
 
340
282
  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 };
283
+ return this.serializeExpression(operand, params, 'SQL expression', contextColumn);
344
284
  }
345
285
 
346
286
  if (operand instanceof Map) {
@@ -371,16 +311,33 @@ export abstract class ConditionBuilder<
371
311
  return { sql: this.ensureWrapped(sql), isReference: false, isExpression: true, isSubSelect: false };
372
312
  }
373
313
 
374
- const sql = this.buildFunctionCall(key, value, params);
375
- return { sql, isReference: false, isExpression: true, isSubSelect: false };
314
+ throw new Error('Object-rooted expressions are not supported. Use tuple syntax instead.');
376
315
  }
377
316
  }
378
317
 
379
318
  throw new Error('Unsupported operand type in SQL expression.');
380
319
  }
381
320
 
321
+ private isExpressionTuple(value: any): boolean {
322
+ if (!Array.isArray(value) || value.length === 0 || typeof value[0] !== 'string') {
323
+ return false;
324
+ }
325
+
326
+ const token = String(value[0]).toUpperCase();
327
+ return (
328
+ token === C6C.AS
329
+ || token === C6C.DISTINCT
330
+ || token === C6C.CALL
331
+ || token === C6C.LIT
332
+ || token === C6C.PARAM
333
+ || token === C6C.SUBSELECT
334
+ || this.isKnownFunction(value[0])
335
+ );
336
+ }
337
+
382
338
  private isPlainArrayLiteral(value: any, allowColumnRefs = false): boolean {
383
339
  if (!Array.isArray(value)) return false;
340
+ if (this.isExpressionTuple(value)) return false;
384
341
  return value.every(item => {
385
342
  if (item === null) return true;
386
343
  const type = typeof item;
@@ -444,6 +401,19 @@ export abstract class ConditionBuilder<
444
401
  return this.addParam(params, contextColumn ?? '', JSON.stringify(normalized));
445
402
  }
446
403
 
404
+ if (
405
+ normalized === C6C.NULL
406
+ || normalized === null
407
+ || typeof normalized === 'string'
408
+ || typeof normalized === 'number'
409
+ || typeof normalized === 'boolean'
410
+ || normalized instanceof Date
411
+ || (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(normalized))
412
+ ) {
413
+ const scalar = normalized === C6C.NULL ? null : normalized;
414
+ return this.addParam(params, contextColumn ?? '', scalar);
415
+ }
416
+
447
417
  let sql: string;
448
418
  let isReference: boolean;
449
419
  let isExpression: boolean;
@@ -546,7 +516,9 @@ export abstract class ConditionBuilder<
546
516
 
547
517
  const payload = this.ensurePlainObject(payloadRaw);
548
518
  let subSelect: any;
549
- if (payload && typeof payload === 'object' && C6C.SUBSELECT in payload) {
519
+ if (Array.isArray(payload) && payload.length === 2 && String(payload[0]).toUpperCase() === C6C.SUBSELECT) {
520
+ subSelect = this.ensurePlainObject(payload[1]);
521
+ } else if (payload && typeof payload === 'object' && C6C.SUBSELECT in payload) {
550
522
  subSelect = this.ensurePlainObject(payload[C6C.SUBSELECT]);
551
523
  } else if (payload && typeof payload === 'object') {
552
524
  subSelect = payload;
@@ -664,7 +636,11 @@ export abstract class ConditionBuilder<
664
636
  }
665
637
 
666
638
  const [search, mode] = right;
667
- const placeholder = this.addParam(params, leftInfo.sql, search);
639
+ const searchInfo = this.serializeOperand(search, params, leftInfo.sql);
640
+ if (searchInfo.isReference || searchInfo.isExpression || searchInfo.isSubSelect) {
641
+ throw new Error('MATCH_AGAINST search payload must be a literal value (wrap strings with [C6C.LIT, value]).');
642
+ }
643
+ const placeholder = searchInfo.sql;
668
644
  let againstClause: string;
669
645
  switch (typeof mode === 'string' ? mode.toUpperCase() : '') {
670
646
  case 'BOOLEAN':
@@ -784,7 +760,7 @@ export abstract class ConditionBuilder<
784
760
  if (!Array.isArray(value)) {
785
761
  throw new Error(`${column} expects an array of arguments.`);
786
762
  }
787
- return this.buildFunctionCall(column, value, params);
763
+ return this.serializeExpression([column, ...value], params, `WHERE function ${column}`, column).sql;
788
764
  }
789
765
  }
790
766
 
@@ -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];