@carbonorm/carbonnode 6.0.19 → 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 (89) hide show
  1. package/README.md +46 -1
  2. package/dist/constants/C6Constants.d.ts +342 -338
  3. package/dist/executors/SqlExecutor.d.ts +8 -0
  4. package/dist/index.cjs.js +751 -272
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.esm.js +744 -273
  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 +7 -5
  16. package/dist/utils/cacheManager.d.ts +3 -2
  17. package/package.json +2 -2
  18. package/scripts/assets/handlebars/C6.test.ts.handlebars +4 -4
  19. package/src/__tests__/cacheManager.test.ts +28 -0
  20. package/src/__tests__/expressServer.e2e.test.ts +26 -17
  21. package/src/__tests__/httpExecutorSingular.e2e.test.ts +53 -14
  22. package/src/__tests__/normalizeSingularRequest.test.ts +26 -8
  23. package/src/__tests__/sakila-db/C6.js +1 -1
  24. package/src/__tests__/sakila-db/C6.mysqldump.json +1 -1
  25. package/src/__tests__/sakila-db/C6.mysqldump.sql +1 -1
  26. package/src/__tests__/sakila-db/C6.sqlAllowList.json +1 -1
  27. package/src/__tests__/sakila-db/C6.test.ts +4 -4
  28. package/src/__tests__/sakila-db/C6.ts +1 -1
  29. package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.json +11 -4
  30. package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.latest.json +3 -3
  31. package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.json +1 -1
  32. package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.lookup.json +3 -3
  33. package/src/__tests__/sakila-db/sqlResponses/C6.address.post.json +26 -7
  34. package/src/__tests__/sakila-db/sqlResponses/C6.address.post.latest.json +5 -5
  35. package/src/__tests__/sakila-db/sqlResponses/C6.address.put.json +1 -1
  36. package/src/__tests__/sakila-db/sqlResponses/C6.address.put.lookup.json +5 -5
  37. package/src/__tests__/sakila-db/sqlResponses/C6.category.post.json +9 -3
  38. package/src/__tests__/sakila-db/sqlResponses/C6.category.post.latest.json +2 -2
  39. package/src/__tests__/sakila-db/sqlResponses/C6.category.put.json +1 -1
  40. package/src/__tests__/sakila-db/sqlResponses/C6.category.put.lookup.json +2 -2
  41. package/src/__tests__/sakila-db/sqlResponses/C6.city.post.json +10 -3
  42. package/src/__tests__/sakila-db/sqlResponses/C6.city.post.latest.json +2 -2
  43. package/src/__tests__/sakila-db/sqlResponses/C6.city.put.json +1 -1
  44. package/src/__tests__/sakila-db/sqlResponses/C6.city.put.lookup.json +2 -2
  45. package/src/__tests__/sakila-db/sqlResponses/C6.country.post.json +9 -3
  46. package/src/__tests__/sakila-db/sqlResponses/C6.country.post.latest.json +2 -2
  47. package/src/__tests__/sakila-db/sqlResponses/C6.country.put.json +1 -1
  48. package/src/__tests__/sakila-db/sqlResponses/C6.country.put.lookup.json +2 -2
  49. package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.json +18 -6
  50. package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.latest.json +5 -5
  51. package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.json +1 -1
  52. package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.lookup.json +5 -5
  53. package/src/__tests__/sakila-db/sqlResponses/C6.film.post.json +18 -3
  54. package/src/__tests__/sakila-db/sqlResponses/C6.film.post.latest.json +2 -2
  55. package/src/__tests__/sakila-db/sqlResponses/C6.film.put.json +1 -1
  56. package/src/__tests__/sakila-db/sqlResponses/C6.film.put.lookup.json +2 -2
  57. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.json +9 -2
  58. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.latest.json +1 -1
  59. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.json +1 -1
  60. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.lookup.json +1 -1
  61. package/src/__tests__/sakila-db/sqlResponses/C6.language.post.json +9 -3
  62. package/src/__tests__/sakila-db/sqlResponses/C6.language.post.latest.json +2 -2
  63. package/src/__tests__/sakila-db/sqlResponses/C6.language.put.json +1 -1
  64. package/src/__tests__/sakila-db/sqlResponses/C6.language.put.lookup.json +2 -2
  65. package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.json +13 -3
  66. package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.latest.json +2 -2
  67. package/src/__tests__/sakila-db/sqlResponses/C6.payment.put.lookup.json +2 -2
  68. package/src/__tests__/sakila-db/sqlResponses/C6.rental.join.json +10 -10
  69. package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.json +14 -4
  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__/sqlBuilders.complex.test.ts +62 -74
  74. package/src/__tests__/sqlBuilders.expressions.test.ts +58 -30
  75. package/src/__tests__/sqlBuilders.test.ts +68 -4
  76. package/src/__tests__/sqlExecutorPostUuid.test.ts +185 -0
  77. package/src/constants/C6Constants.ts +3 -1
  78. package/src/executors/HttpExecutor.ts +35 -6
  79. package/src/executors/SqlExecutor.ts +232 -4
  80. package/src/index.ts +1 -0
  81. package/src/orm/builders/AggregateBuilder.ts +67 -106
  82. package/src/orm/builders/ConditionBuilder.ts +69 -93
  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/types/mysqlTypes.ts +130 -9
  87. package/src/types/ormInterfaces.ts +7 -7
  88. package/src/utils/cacheManager.ts +6 -4
  89. package/src/utils/normalizeSingularRequest.ts +11 -4
@@ -27,6 +27,43 @@ import { getLogContext, LogLevel, logWithLevel } from "../utils/logLevel";
27
27
 
28
28
  const SQL_ALLOWLIST_BLOCKED_CODE = "SQL_ALLOWLIST_BLOCKED";
29
29
 
30
+ const fillRandomBytes = (bytes: Uint8Array): void => {
31
+ const cryptoRef = (globalThis as { crypto?: Crypto }).crypto;
32
+ if (!cryptoRef || typeof cryptoRef.getRandomValues !== "function") {
33
+ throw new Error("Secure random source unavailable: crypto.getRandomValues is required for UUID generation.");
34
+ }
35
+ cryptoRef.getRandomValues(bytes);
36
+ };
37
+
38
+ const generateUuidV7 = (): string => {
39
+ const bytes = new Uint8Array(16);
40
+ const random = new Uint8Array(10);
41
+ fillRandomBytes(random);
42
+
43
+ const timestampMs = Date.now();
44
+ bytes[0] = Math.floor(timestampMs / 1099511627776) & 0xff; // 2^40
45
+ bytes[1] = Math.floor(timestampMs / 4294967296) & 0xff; // 2^32
46
+ bytes[2] = Math.floor(timestampMs / 16777216) & 0xff; // 2^24
47
+ bytes[3] = Math.floor(timestampMs / 65536) & 0xff; // 2^16
48
+ bytes[4] = Math.floor(timestampMs / 256) & 0xff; // 2^8
49
+ bytes[5] = timestampMs & 0xff;
50
+
51
+ // RFC 9562 UUIDv7 layout
52
+ bytes[6] = 0x70 | (random[0] & 0x0f); // version 7 + rand_a high bits
53
+ bytes[7] = random[1]; // rand_a low bits
54
+ bytes[8] = 0x80 | (random[2] & 0x3f); // variant + rand_b high bits
55
+ bytes[9] = random[3];
56
+ bytes[10] = random[4];
57
+ bytes[11] = random[5];
58
+ bytes[12] = random[6];
59
+ bytes[13] = random[7];
60
+ bytes[14] = random[8];
61
+ bytes[15] = random[9];
62
+
63
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
64
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
65
+ };
66
+
30
67
  export type SqlAllowListBlockedError = Error & {
31
68
  code: typeof SQL_ALLOWLIST_BLOCKED_CODE;
32
69
  tableName?: string;
@@ -72,6 +109,157 @@ const createSqlAllowListBlockedError = (args: {
72
109
  export class SqlExecutor<
73
110
  G extends OrmGenerics
74
111
  > extends Executor<G> {
112
+ private getPostRequestRows(): Record<string, any>[] {
113
+ const request = this.request as any;
114
+ if (!request) return [];
115
+
116
+ if (Array.isArray(request)) {
117
+ return request as Record<string, any>[];
118
+ }
119
+
120
+ if (
121
+ Array.isArray(request.dataInsertMultipleRows)
122
+ && request.dataInsertMultipleRows.length > 0
123
+ ) {
124
+ return request.dataInsertMultipleRows as Record<string, any>[];
125
+ }
126
+
127
+ const verb = C6C.REPLACE in request ? C6C.REPLACE : C6C.INSERT;
128
+ if (verb in request && request[verb] && typeof request[verb] === "object") {
129
+ return [request[verb] as Record<string, any>];
130
+ }
131
+
132
+ if (typeof request === "object") {
133
+ return [request as Record<string, any>];
134
+ }
135
+
136
+ return [];
137
+ }
138
+
139
+ private getTypeValidationForColumn(shortKey: string, fullKey: string): Record<string, any> | undefined {
140
+ const validation = (this.config.restModel as any)?.TYPE_VALIDATION;
141
+ if (!validation || typeof validation !== "object") return undefined;
142
+ return validation[shortKey] ?? validation[fullKey];
143
+ }
144
+
145
+ private isUuidLikePrimaryColumn(columnDef: Record<string, any> | undefined): boolean {
146
+ if (!columnDef || typeof columnDef !== "object") return false;
147
+ if (columnDef.AUTO_INCREMENT === true) return false;
148
+
149
+ const mysqlType = String(columnDef.MYSQL_TYPE ?? "").toLowerCase();
150
+ const maxLength = String(columnDef.MAX_LENGTH ?? "").trim();
151
+
152
+ if (mysqlType.includes("uuid")) return true;
153
+
154
+ const isBinary16 = mysqlType.includes("binary")
155
+ && (maxLength === "16" || /\b16\b/.test(mysqlType) || mysqlType === "binary");
156
+ const isUuidString = (mysqlType.includes("char") || mysqlType.includes("varchar"))
157
+ && (maxLength === "32" || maxLength === "36");
158
+
159
+ return isBinary16 || isUuidString;
160
+ }
161
+
162
+ private hasDefinedValue(value: unknown): boolean {
163
+ if (value === undefined || value === null) return false;
164
+ if (typeof value === "string" && value.trim() === "") return false;
165
+ return true;
166
+ }
167
+
168
+ private generatePrimaryUuidValue(columnDef: Record<string, any>): string {
169
+ const mysqlType = String(columnDef.MYSQL_TYPE ?? "").toLowerCase();
170
+ const maxLength = String(columnDef.MAX_LENGTH ?? "").trim();
171
+ const uuid = generateUuidV7();
172
+
173
+ // BINARY(16) and CHAR/VARCHAR(32) commonly persist UUIDs as 32-hex.
174
+ if (mysqlType.includes("binary") || maxLength === "32") {
175
+ return uuid.replace(/-/g, "").toUpperCase();
176
+ }
177
+
178
+ return uuid;
179
+ }
180
+
181
+ private assignMissingPostPrimaryUuids(): void {
182
+ if (this.config.requestMethod !== C6C.POST) return;
183
+
184
+ const rows = this.getPostRequestRows();
185
+ if (rows.length === 0) return;
186
+
187
+ const columns = this.config.restModel.COLUMNS as Record<string, string>;
188
+ const tableName = this.config.restModel.TABLE_NAME as string;
189
+ const primaryShorts = this.config.restModel.PRIMARY_SHORT ?? [];
190
+
191
+ const primaryColumns = primaryShorts
192
+ .map((shortKey) => {
193
+ const fullKey = Object.keys(columns).find((key) => columns[key] === shortKey)
194
+ ?? `${tableName}.${shortKey}`;
195
+ const columnDef = this.getTypeValidationForColumn(shortKey, fullKey);
196
+ return { shortKey, fullKey, columnDef };
197
+ })
198
+ .filter(({ columnDef }) => this.isUuidLikePrimaryColumn(columnDef));
199
+
200
+ if (primaryColumns.length === 0) return;
201
+
202
+ for (const row of rows) {
203
+ if (!row || typeof row !== "object") continue;
204
+
205
+ const useQualifiedKeyByDefault = Object.keys(row).some((key) => key.includes("."));
206
+
207
+ for (const primaryColumn of primaryColumns) {
208
+ const existing = row[primaryColumn.shortKey] ?? row[primaryColumn.fullKey];
209
+ if (this.hasDefinedValue(existing)) continue;
210
+
211
+ const generated = this.generatePrimaryUuidValue(primaryColumn.columnDef!);
212
+ if (Object.prototype.hasOwnProperty.call(row, primaryColumn.shortKey)) {
213
+ row[primaryColumn.shortKey] = generated;
214
+ continue;
215
+ }
216
+ if (Object.prototype.hasOwnProperty.call(row, primaryColumn.fullKey)) {
217
+ row[primaryColumn.fullKey] = generated;
218
+ continue;
219
+ }
220
+
221
+ row[useQualifiedKeyByDefault ? primaryColumn.fullKey : primaryColumn.shortKey] = generated;
222
+ }
223
+ }
224
+ }
225
+
226
+ private buildPostResponseRows(insertId?: number | string): Record<string, unknown>[] {
227
+ const rows = this.getPostRequestRows();
228
+ if (rows.length === 0) return [];
229
+
230
+ const columns = this.config.restModel.COLUMNS as Record<string, string>;
231
+ const validColumns = new Set(Object.values(columns));
232
+ const pkShorts = this.config.restModel.PRIMARY_SHORT ?? [];
233
+ const now = new Date().toISOString();
234
+
235
+ return rows.map((row, index) => {
236
+ const normalized = this.normalizeRequestPayload(row ?? {});
237
+
238
+ if (validColumns.has("changed_at") && normalized.changed_at === undefined) {
239
+ normalized.changed_at = now;
240
+ }
241
+ if (validColumns.has("created_at") && normalized.created_at === undefined) {
242
+ normalized.created_at = now;
243
+ }
244
+ if (validColumns.has("updated_at") && normalized.updated_at === undefined) {
245
+ normalized.updated_at = now;
246
+ }
247
+
248
+ // When DB generated PK is numeric/autoincrement, expose it for the single-row insert.
249
+ if (
250
+ index === 0
251
+ && insertId !== undefined
252
+ && insertId !== null
253
+ && pkShorts.length === 1
254
+ && !this.hasDefinedValue(normalized[pkShorts[0]])
255
+ ) {
256
+ normalized[pkShorts[0]] = insertId;
257
+ }
258
+
259
+ return normalized;
260
+ });
261
+ }
262
+
75
263
  private resolveSqlLogMethod(method: iRestMethods, sql: string): string {
76
264
  const token = sql.trim().split(/\s+/, 1)[0]?.toUpperCase();
77
265
  if (token) return token;
@@ -112,6 +300,8 @@ export class SqlExecutor<
112
300
  throw e;
113
301
  }
114
302
 
303
+ this.assignMissingPostPrimaryUuids();
304
+
115
305
  const logContext = getLogContext(this.config, this.request);
116
306
  logWithLevel(
117
307
  LogLevel.DEBUG,
@@ -248,6 +438,7 @@ export class SqlExecutor<
248
438
  C6C.REPLACE,
249
439
  "dataInsertMultipleRows",
250
440
  "cacheResults",
441
+ "skipReactBootstrap",
251
442
  "fetchDependencies",
252
443
  "debug",
253
444
  "success",
@@ -340,7 +531,7 @@ export class SqlExecutor<
340
531
  }
341
532
 
342
533
  if (value !== undefined) {
343
- pkValues[pkShort] = value;
534
+ pkValues[pkShort] = this.unwrapPrimaryKeyValue(value);
344
535
  }
345
536
  }
346
537
 
@@ -351,6 +542,29 @@ export class SqlExecutor<
351
542
  return Object.keys(pkValues).length > 0 ? pkValues : null;
352
543
  }
353
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
+
354
568
  private extractPrimaryKeyValuesFromData(data: any): Record<string, any> | null {
355
569
  if (!data) return null;
356
570
  const row = Array.isArray(data) ? data[0] : data;
@@ -549,6 +763,9 @@ export class SqlExecutor<
549
763
  const logContext = getLogContext(this.config, this.request);
550
764
  const cacheResults = method === C6C.GET
551
765
  && (this.request.cacheResults ?? true);
766
+ const cacheAllowListStatus: SqlAllowListStatus = this.config.sqlAllowListPath
767
+ ? "allowed"
768
+ : "not verified";
552
769
 
553
770
  const cacheRequestData = cacheResults
554
771
  ? JSON.parse(JSON.stringify(this.request ?? {}))
@@ -560,7 +777,13 @@ export class SqlExecutor<
560
777
 
561
778
  const evictFromCache =
562
779
  method === C6C.GET && cacheResults && cacheRequestData
563
- ? () => evictCacheEntry(method, tableName, cacheRequestData, logContext)
780
+ ? () => evictCacheEntry(
781
+ method,
782
+ tableName,
783
+ cacheRequestData,
784
+ logContext,
785
+ cacheAllowListStatus,
786
+ )
564
787
  : undefined;
565
788
 
566
789
  if (cacheResults) {
@@ -568,7 +791,8 @@ export class SqlExecutor<
568
791
  method,
569
792
  tableName,
570
793
  cacheRequestData,
571
- logContext
794
+ logContext,
795
+ cacheAllowListStatus,
572
796
  );
573
797
  if (cachedRequest) {
574
798
  const cachedData = (await cachedRequest).data;
@@ -611,12 +835,14 @@ export class SqlExecutor<
611
835
  setCache(method, tableName, cacheRequestData, {
612
836
  requestArgumentsSerialized,
613
837
  request: cacheRequest,
838
+ allowListStatus: cacheAllowListStatus,
614
839
  });
615
840
 
616
841
  const cacheResponse = await cacheRequest;
617
842
  setCache(method, tableName, cacheRequestData, {
618
843
  requestArgumentsSerialized,
619
844
  request: cacheRequest,
845
+ allowListStatus: cacheAllowListStatus,
620
846
  response: cacheResponse,
621
847
  final: true,
622
848
  });
@@ -695,7 +921,9 @@ export class SqlExecutor<
695
921
  return {
696
922
  affected: result.affectedRows as number,
697
923
  insertId: result.insertId as number,
698
- rest: [],
924
+ rest: method === C6C.POST
925
+ ? this.buildPostResponseRows(result.insertId as number | string | undefined)
926
+ : [],
699
927
  sql: { sql: sqlExecution.sql, values: sqlExecution.values },
700
928
  } as DetermineResponseDataType<G['RequestMethod'], G['RestTableInterface']>;
701
929
  }
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
  }