@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.
- package/README.md +521 -259
- package/dist/constants/C6Constants.d.ts +342 -338
- package/dist/executors/SqlExecutor.d.ts +1 -0
- package/dist/index.cjs.js +746 -290
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +737 -291
- package/dist/index.esm.js.map +1 -1
- package/dist/orm/builders/AggregateBuilder.d.ts +5 -1
- package/dist/orm/builders/ConditionBuilder.d.ts +2 -3
- package/dist/orm/builders/ExpressionSerializer.d.ts +22 -0
- package/dist/orm/builders/PaginationBuilder.d.ts +4 -6
- package/dist/orm/queryHelpers.d.ts +12 -1
- package/dist/orm/utils/sqlUtils.d.ts +1 -0
- package/dist/types/mysqlTypes.d.ts +6 -1
- package/dist/types/ormInterfaces.d.ts +7 -5
- package/dist/utils/sqlAllowList.d.ts +5 -3
- package/package.json +2 -2
- package/scripts/assets/handlebars/C6.test.ts.handlebars +4 -4
- package/src/__tests__/expressServer.e2e.test.ts +26 -17
- package/src/__tests__/fixtures/c6.fixture.ts +33 -0
- package/src/__tests__/httpExecutorSingular.e2e.test.ts +53 -14
- package/src/__tests__/normalizeSingularRequest.test.ts +26 -8
- package/src/__tests__/sakila-db/C6.js +1 -1
- package/src/__tests__/sakila-db/C6.mysqldump.json +1 -1
- package/src/__tests__/sakila-db/C6.mysqldump.sql +1 -1
- package/src/__tests__/sakila-db/C6.sqlAllowList.json +1 -1
- package/src/__tests__/sakila-db/C6.test.ts +4 -4
- package/src/__tests__/sakila-db/C6.ts +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.json +6 -6
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.latest.json +3 -3
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.lookup.json +3 -3
- package/src/__tests__/sakila-db/sqlResponses/C6.address.post.json +19 -12
- package/src/__tests__/sakila-db/sqlResponses/C6.address.post.latest.json +5 -5
- package/src/__tests__/sakila-db/sqlResponses/C6.address.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.address.put.lookup.json +5 -5
- package/src/__tests__/sakila-db/sqlResponses/C6.category.post.json +4 -4
- package/src/__tests__/sakila-db/sqlResponses/C6.category.post.latest.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.category.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.category.put.lookup.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.city.post.json +4 -4
- package/src/__tests__/sakila-db/sqlResponses/C6.city.post.latest.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.city.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.city.put.lookup.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.country.post.json +4 -4
- package/src/__tests__/sakila-db/sqlResponses/C6.country.post.latest.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.country.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.country.put.lookup.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.json +10 -10
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.latest.json +5 -5
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.lookup.json +5 -5
- package/src/__tests__/sakila-db/sqlResponses/C6.film.post.json +4 -4
- package/src/__tests__/sakila-db/sqlResponses/C6.film.post.latest.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.film.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.film.put.lookup.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.latest.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.lookup.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.language.post.json +4 -4
- package/src/__tests__/sakila-db/sqlResponses/C6.language.post.latest.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.language.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.language.put.lookup.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.json +4 -4
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.latest.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.put.lookup.json +2 -2
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.json +6 -6
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.latest.json +3 -3
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.json +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.lookup.json +3 -3
- package/src/__tests__/sqlAllowList.test.ts +56 -1
- package/src/__tests__/sqlBuilders.complex.test.ts +62 -74
- package/src/__tests__/sqlBuilders.expressions.test.ts +58 -30
- package/src/__tests__/sqlBuilders.test.ts +106 -5
- package/src/constants/C6Constants.ts +3 -1
- package/src/executors/HttpExecutor.ts +2 -1
- package/src/executors/SqlExecutor.ts +29 -4
- package/src/index.ts +1 -0
- package/src/orm/builders/AggregateBuilder.ts +67 -106
- package/src/orm/builders/ConditionBuilder.ts +72 -103
- package/src/orm/builders/ExpressionSerializer.ts +275 -0
- package/src/orm/builders/PaginationBuilder.ts +24 -34
- package/src/orm/queryHelpers.ts +29 -0
- package/src/orm/utils/sqlUtils.ts +172 -4
- package/src/types/mysqlTypes.ts +130 -9
- package/src/types/ormInterfaces.ts +7 -7
- package/src/utils/normalizeSingularRequest.ts +11 -4
- 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 {
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
15
|
-
* [property_units.
|
|
16
|
-
*
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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(", ")}`;
|
package/src/orm/queryHelpers.ts
CHANGED
|
@@ -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];
|