@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.
- package/README.md +46 -1
- package/dist/constants/C6Constants.d.ts +342 -338
- package/dist/executors/SqlExecutor.d.ts +1 -0
- package/dist/index.cjs.js +538 -254
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +531 -255
- 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/types/mysqlTypes.d.ts +6 -1
- package/dist/types/ormInterfaces.d.ts +6 -5
- 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__/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.join.json +10 -10
- 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__/sqlBuilders.complex.test.ts +62 -74
- package/src/__tests__/sqlBuilders.expressions.test.ts +58 -30
- package/src/__tests__/sqlBuilders.test.ts +68 -4
- package/src/constants/C6Constants.ts +3 -1
- package/src/executors/HttpExecutor.ts +2 -1
- package/src/executors/SqlExecutor.ts +25 -1
- package/src/index.ts +1 -0
- package/src/orm/builders/AggregateBuilder.ts +67 -106
- package/src/orm/builders/ConditionBuilder.ts +69 -93
- 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/types/mysqlTypes.ts +130 -9
- package/src/types/ormInterfaces.ts +6 -7
- 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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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];
|