@carbonorm/carbonnode 3.9.5 → 3.10.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/dist/api/C6Constants.d.ts +4 -0
- package/dist/api/orm/builders/ConditionBuilder.d.ts +14 -2
- package/dist/index.cjs.js +467 -242
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +467 -242
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/sakila-db/C6.js +1 -1
- package/src/__tests__/sakila-db/C6.ts +1 -1
- package/src/__tests__/sqlBuilders.expressions.test.ts +192 -0
- package/src/api/C6Constants.ts +2 -0
- package/src/api/orm/builders/ConditionBuilder.ts +489 -224
- package/src/api/orm/builders/JoinBuilder.ts +8 -5
|
@@ -70,24 +70,44 @@ export abstract class ConditionBuilder<
|
|
|
70
70
|
throw new Error("Method not implemented.");
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
private readonly
|
|
74
|
-
C6C.
|
|
75
|
-
|
|
76
|
-
C6C.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
C6C.
|
|
82
|
-
|
|
83
|
-
C6C.
|
|
84
|
-
C6C.
|
|
85
|
-
C6C.
|
|
86
|
-
C6C.
|
|
87
|
-
C6C.
|
|
88
|
-
C6C.
|
|
89
|
-
C6C.
|
|
90
|
-
C6C.
|
|
73
|
+
private readonly BOOLEAN_OPERATORS = new Map<string, 'AND' | 'OR'>([
|
|
74
|
+
[C6C.AND, 'AND'],
|
|
75
|
+
['AND', 'AND'],
|
|
76
|
+
[C6C.OR, 'OR'],
|
|
77
|
+
['OR', 'OR'],
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
private readonly OPERATOR_ALIASES = new Map<string, string>([
|
|
81
|
+
[C6C.EQUAL, C6C.EQUAL],
|
|
82
|
+
['=', C6C.EQUAL],
|
|
83
|
+
[C6C.EQUAL_NULL_SAFE, C6C.EQUAL_NULL_SAFE],
|
|
84
|
+
['<=>', C6C.EQUAL_NULL_SAFE],
|
|
85
|
+
[C6C.NOT_EQUAL, C6C.NOT_EQUAL],
|
|
86
|
+
['<>', C6C.NOT_EQUAL],
|
|
87
|
+
[C6C.LESS_THAN, C6C.LESS_THAN],
|
|
88
|
+
['<', C6C.LESS_THAN],
|
|
89
|
+
[C6C.LESS_THAN_OR_EQUAL_TO, C6C.LESS_THAN_OR_EQUAL_TO],
|
|
90
|
+
['<=', C6C.LESS_THAN_OR_EQUAL_TO],
|
|
91
|
+
[C6C.GREATER_THAN, C6C.GREATER_THAN],
|
|
92
|
+
['>', C6C.GREATER_THAN],
|
|
93
|
+
[C6C.GREATER_THAN_OR_EQUAL_TO, C6C.GREATER_THAN_OR_EQUAL_TO],
|
|
94
|
+
['>=', C6C.GREATER_THAN_OR_EQUAL_TO],
|
|
95
|
+
[C6C.LIKE, C6C.LIKE],
|
|
96
|
+
['LIKE', C6C.LIKE],
|
|
97
|
+
[C6C.NOT_LIKE, 'NOT LIKE'],
|
|
98
|
+
['NOT LIKE', 'NOT LIKE'],
|
|
99
|
+
[C6C.IN, C6C.IN],
|
|
100
|
+
['IN', C6C.IN],
|
|
101
|
+
[C6C.NOT_IN, 'NOT IN'],
|
|
102
|
+
['NOT IN', 'NOT IN'],
|
|
103
|
+
[C6C.IS, C6C.IS],
|
|
104
|
+
['IS', C6C.IS],
|
|
105
|
+
[C6C.IS_NOT, 'IS NOT'],
|
|
106
|
+
['IS NOT', 'IS NOT'],
|
|
107
|
+
[C6C.BETWEEN, C6C.BETWEEN],
|
|
108
|
+
['BETWEEN', C6C.BETWEEN],
|
|
109
|
+
['NOT BETWEEN', 'NOT BETWEEN'],
|
|
110
|
+
[C6C.MATCH_AGAINST, C6C.MATCH_AGAINST],
|
|
91
111
|
]);
|
|
92
112
|
|
|
93
113
|
private isTableReference(val: any): boolean {
|
|
@@ -117,12 +137,6 @@ export abstract class ConditionBuilder<
|
|
|
117
137
|
);
|
|
118
138
|
}
|
|
119
139
|
|
|
120
|
-
private validateOperator(op: string) {
|
|
121
|
-
if (!this.OPERATORS.has(op)) {
|
|
122
|
-
throw new Error(`Invalid or unsupported SQL operator detected: '${op}'`);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
140
|
public addParam(
|
|
127
141
|
params: any[] | Record<string, any>,
|
|
128
142
|
column: string,
|
|
@@ -148,260 +162,511 @@ export abstract class ConditionBuilder<
|
|
|
148
162
|
}
|
|
149
163
|
}
|
|
150
164
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
): string {
|
|
156
|
-
const booleanOperator = andMode ? 'AND' : 'OR';
|
|
165
|
+
private normalizeOperatorKey(op: string): string | undefined {
|
|
166
|
+
if (typeof op !== 'string') return undefined;
|
|
167
|
+
return this.OPERATOR_ALIASES.get(op);
|
|
168
|
+
}
|
|
157
169
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
170
|
+
private formatOperator(op: string): string {
|
|
171
|
+
const normalized = this.normalizeOperatorKey(op);
|
|
172
|
+
if (!normalized) {
|
|
173
|
+
throw new Error(`Invalid or unsupported SQL operator detected: '${op}'`);
|
|
174
|
+
}
|
|
162
175
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
176
|
+
switch (normalized) {
|
|
177
|
+
case 'NOT LIKE':
|
|
178
|
+
case 'NOT IN':
|
|
179
|
+
case 'IS NOT':
|
|
180
|
+
case 'NOT BETWEEN':
|
|
181
|
+
return normalized;
|
|
182
|
+
case C6C.MATCH_AGAINST:
|
|
183
|
+
return C6C.MATCH_AGAINST;
|
|
184
|
+
default:
|
|
185
|
+
return normalized;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private isOperator(op: string): boolean {
|
|
190
|
+
return !!this.normalizeOperatorKey(op);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private looksLikeSafeFunctionExpression(value: string): boolean {
|
|
194
|
+
if (typeof value !== 'string') return false;
|
|
195
|
+
|
|
196
|
+
const trimmed = value.trim();
|
|
197
|
+
if (trimmed.length === 0) return false;
|
|
198
|
+
|
|
199
|
+
if (trimmed.includes(';') || trimmed.includes('--') || trimmed.includes('/*') || trimmed.includes('*/')) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!trimmed.includes('(') || !trimmed.endsWith(')')) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const functionMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*\(/);
|
|
208
|
+
if (!functionMatch) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const allowedCharacters = /^[A-Za-z0-9_().,'"\s-]+$/;
|
|
213
|
+
if (!allowedCharacters.test(trimmed)) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let depth = 0;
|
|
218
|
+
for (const char of trimmed) {
|
|
219
|
+
if (char === '(') {
|
|
220
|
+
depth += 1;
|
|
221
|
+
} else if (char === ')') {
|
|
222
|
+
depth -= 1;
|
|
223
|
+
if (depth < 0) {
|
|
224
|
+
return false;
|
|
169
225
|
}
|
|
170
|
-
|
|
171
|
-
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return depth === 0;
|
|
230
|
+
}
|
|
172
231
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
232
|
+
private ensureWrapped(expression: string): string {
|
|
233
|
+
const trimmed = expression.trim();
|
|
234
|
+
if (!trimmed) return trimmed;
|
|
235
|
+
if (trimmed.startsWith('(') && trimmed.endsWith(')')) {
|
|
236
|
+
return trimmed;
|
|
237
|
+
}
|
|
238
|
+
return `(${trimmed})`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private joinBooleanParts(parts: string[], operator: 'AND' | 'OR'): string {
|
|
242
|
+
if (parts.length === 0) return '';
|
|
243
|
+
if (parts.length === 1) {
|
|
244
|
+
return parts[0];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return parts
|
|
248
|
+
.map(part => {
|
|
249
|
+
const trimmed = part.trim();
|
|
250
|
+
const upper = trimmed.toUpperCase();
|
|
251
|
+
const containsAnd = upper.includes(' AND ');
|
|
252
|
+
const containsOr = upper.includes(' OR ');
|
|
253
|
+
const needsWrap =
|
|
254
|
+
(operator === 'AND' && containsOr) ||
|
|
255
|
+
(operator === 'OR' && containsAnd);
|
|
256
|
+
return needsWrap ? `(${trimmed})` : trimmed;
|
|
257
|
+
})
|
|
258
|
+
.join(` ${operator} `);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private normalizeFunctionField(field: any, params: any[] | Record<string, any>): any {
|
|
262
|
+
if (field instanceof Map) {
|
|
263
|
+
field = Object.fromEntries(field);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (Array.isArray(field)) {
|
|
267
|
+
if (field.length === 0) return field;
|
|
268
|
+
const [fn, ...args] = field;
|
|
269
|
+
const normalizedArgs = args.map(arg => this.normalizeFunctionField(arg, params));
|
|
270
|
+
return [fn, ...normalizedArgs];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (field && typeof field === 'object') {
|
|
274
|
+
if (C6C.SUBSELECT in field) {
|
|
176
275
|
const builder = (this as any).buildScalarSubSelect;
|
|
177
276
|
if (typeof builder !== 'function') {
|
|
178
277
|
throw new Error('Scalar subselect handling requires JoinBuilder context.');
|
|
179
278
|
}
|
|
180
|
-
return builder.call(this,
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
Array.isArray(op)
|
|
189
|
-
) {
|
|
190
|
-
// Helper to serialize operand which may be a qualified identifier or a nested function array
|
|
191
|
-
const serializeOperand = (arg: any): string => {
|
|
192
|
-
const identifierPathRegex = /^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/;
|
|
193
|
-
if (Array.isArray(arg)) {
|
|
194
|
-
// Delegate to aggregate builder to handle nested functions/params
|
|
195
|
-
// @ts-ignore - buildAggregateField is defined upstream in AggregateBuilder
|
|
196
|
-
return this.buildAggregateField(arg, params);
|
|
197
|
-
}
|
|
198
|
-
if (typeof arg === 'string') {
|
|
199
|
-
if (identifierPathRegex.test(arg)) {
|
|
200
|
-
this.assertValidIdentifier(arg, 'WHERE argument');
|
|
201
|
-
return arg;
|
|
202
|
-
}
|
|
203
|
-
return arg;
|
|
204
|
-
}
|
|
205
|
-
return String(arg);
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
if (column === C6C.ST_DISTANCE_SPHERE) {
|
|
209
|
-
const [col1, col2] = op;
|
|
210
|
-
const threshold = Array.isArray(value) ? value[0] : value;
|
|
211
|
-
const left = serializeOperand(col1);
|
|
212
|
-
const right = serializeOperand(col2);
|
|
213
|
-
return `ST_Distance_Sphere(${left}, ${right}) < ${this.addParam(params, '', threshold)}`;
|
|
214
|
-
}
|
|
215
|
-
if ([
|
|
216
|
-
C6C.ST_CONTAINS,
|
|
217
|
-
C6C.ST_INTERSECTS,
|
|
218
|
-
C6C.ST_WITHIN,
|
|
219
|
-
C6C.ST_CROSSES,
|
|
220
|
-
C6C.ST_DISJOINT,
|
|
221
|
-
C6C.ST_EQUALS,
|
|
222
|
-
C6C.ST_OVERLAPS,
|
|
223
|
-
C6C.ST_TOUCHES
|
|
224
|
-
].includes(column)) {
|
|
225
|
-
const [geom1, geom2] = op;
|
|
226
|
-
const left = serializeOperand(geom1);
|
|
227
|
-
const right = serializeOperand(geom2);
|
|
228
|
-
return `${column}(${left}, ${right})`;
|
|
279
|
+
return builder.call(this, field[C6C.SUBSELECT], params);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const entries = Object.entries(field);
|
|
283
|
+
if (entries.length === 1) {
|
|
284
|
+
const [key, value] = entries[0];
|
|
285
|
+
if (this.isOperator(key)) {
|
|
286
|
+
return this.buildOperatorExpression(key, value, params);
|
|
229
287
|
}
|
|
288
|
+
return this.buildFunctionCall(key, value, params);
|
|
230
289
|
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return field;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private buildFunctionCall(fn: string, value: any, params: any[] | Record<string, any>): string {
|
|
296
|
+
const args = Array.isArray(value) ? value : [value];
|
|
297
|
+
const normalized = this.normalizeFunctionField([fn, ...args], params);
|
|
298
|
+
return this.buildAggregateField(normalized, params);
|
|
299
|
+
}
|
|
231
300
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
301
|
+
private serializeOperand(
|
|
302
|
+
operand: any,
|
|
303
|
+
params: any[] | Record<string, any>,
|
|
304
|
+
contextColumn?: string
|
|
305
|
+
): { sql: string; isReference: boolean; isExpression: boolean; isSubSelect: boolean } {
|
|
306
|
+
const asParam = (val: any): string => this.addParam(params, contextColumn ?? '', val);
|
|
235
307
|
|
|
236
|
-
|
|
237
|
-
|
|
308
|
+
if (operand === C6C.NULL) {
|
|
309
|
+
operand = null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (operand === null || typeof operand === 'number' || typeof operand === 'boolean') {
|
|
313
|
+
return { sql: asParam(operand), isReference: false, isExpression: false, isSubSelect: false };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (typeof operand === 'string') {
|
|
317
|
+
if (this.isTableReference(operand) || this.isColumnRef(operand)) {
|
|
318
|
+
return { sql: operand, isReference: true, isExpression: false, isSubSelect: false };
|
|
319
|
+
}
|
|
320
|
+
if (this.looksLikeSafeFunctionExpression(operand)) {
|
|
321
|
+
return { sql: operand.trim(), isReference: false, isExpression: true, isSubSelect: false };
|
|
238
322
|
}
|
|
323
|
+
return { sql: asParam(operand), isReference: false, isExpression: false, isSubSelect: false };
|
|
324
|
+
}
|
|
239
325
|
|
|
240
|
-
|
|
326
|
+
if (Array.isArray(operand)) {
|
|
327
|
+
const normalized = this.normalizeFunctionField(operand, params);
|
|
328
|
+
const sql = this.buildAggregateField(normalized, params);
|
|
329
|
+
return { sql, isReference: false, isExpression: true, isSubSelect: false };
|
|
330
|
+
}
|
|
241
331
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
332
|
+
if (operand instanceof Map) {
|
|
333
|
+
operand = Object.fromEntries(operand);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (typeof operand === 'object' && operand !== null) {
|
|
337
|
+
if (C6C.SUBSELECT in operand) {
|
|
338
|
+
const builder = (this as any).buildScalarSubSelect;
|
|
339
|
+
if (typeof builder !== 'function') {
|
|
340
|
+
throw new Error('Scalar subselect handling requires JoinBuilder context.');
|
|
249
341
|
}
|
|
342
|
+
const subSql = builder.call(this, operand[C6C.SUBSELECT], params);
|
|
343
|
+
return { sql: subSql, isReference: false, isExpression: true, isSubSelect: true };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const entries = Object.entries(operand);
|
|
347
|
+
if (entries.length === 1) {
|
|
348
|
+
const [key, value] = entries[0];
|
|
250
349
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
case 'BOOLEAN':
|
|
255
|
-
againstClause = this.useNamedParams ? `AGAINST(:${paramName} IN BOOLEAN MODE)` : `AGAINST(? IN BOOLEAN MODE)`;
|
|
256
|
-
break;
|
|
257
|
-
case 'WITH QUERY EXPANSION':
|
|
258
|
-
againstClause = this.useNamedParams ? `AGAINST(:${paramName} WITH QUERY EXPANSION)` : `AGAINST(? WITH QUERY EXPANSION)`;
|
|
259
|
-
break;
|
|
260
|
-
default: // NATURAL or undefined
|
|
261
|
-
againstClause = this.useNamedParams ? `AGAINST(:${paramName})` : `AGAINST(?)`;
|
|
262
|
-
break;
|
|
350
|
+
if (this.isOperator(key)) {
|
|
351
|
+
const sql = this.buildOperatorExpression(key, value, params);
|
|
352
|
+
return { sql: this.ensureWrapped(sql), isReference: false, isExpression: true, isSubSelect: false };
|
|
263
353
|
}
|
|
264
354
|
|
|
265
|
-
if (
|
|
266
|
-
|
|
355
|
+
if (this.BOOLEAN_OPERATORS.has(key)) {
|
|
356
|
+
const sql = this.buildBooleanExpression({ [key]: value }, params, 'AND');
|
|
357
|
+
return { sql: this.ensureWrapped(sql), isReference: false, isExpression: true, isSubSelect: false };
|
|
267
358
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
return
|
|
359
|
+
|
|
360
|
+
const sql = this.buildFunctionCall(key, value, params);
|
|
361
|
+
return { sql, isReference: false, isExpression: true, isSubSelect: false };
|
|
271
362
|
}
|
|
363
|
+
}
|
|
272
364
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
365
|
+
throw new Error('Unsupported operand type in SQL expression.');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private buildOperatorExpression(
|
|
369
|
+
op: string,
|
|
370
|
+
rawOperands: any,
|
|
371
|
+
params: any[] | Record<string, any>,
|
|
372
|
+
contextColumn?: string
|
|
373
|
+
): string {
|
|
374
|
+
const operator = this.formatOperator(op);
|
|
375
|
+
|
|
376
|
+
if (operator === C6C.MATCH_AGAINST) {
|
|
377
|
+
if (!Array.isArray(rawOperands) || rawOperands.length !== 2) {
|
|
378
|
+
throw new Error('MATCH_AGAINST requires an array of two operands.');
|
|
379
|
+
}
|
|
380
|
+
const [left, right] = rawOperands;
|
|
381
|
+
const leftInfo = this.serializeOperand(left, params, contextColumn);
|
|
382
|
+
if (!leftInfo.isReference) {
|
|
383
|
+
throw new Error('MATCH_AGAINST requires the left operand to be a table reference.');
|
|
289
384
|
}
|
|
290
385
|
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
throw new Error(`BETWEEN operator requires an array of two values`);
|
|
294
|
-
}
|
|
295
|
-
const [start, end] = value;
|
|
296
|
-
if (!leftIsRef) {
|
|
297
|
-
throw new Error(`BETWEEN operator requires a table reference as the left operand. Column '${column}' is not a valid table reference.`);
|
|
298
|
-
}
|
|
299
|
-
return `(${column}) ${op.replace('_', ' ')} ${this.addParam(params, column, start)} AND ${this.addParam(params, column, end)}`;
|
|
386
|
+
if (!Array.isArray(right) || right.length === 0) {
|
|
387
|
+
throw new Error('MATCH_AGAINST expects an array [search, mode?].');
|
|
300
388
|
}
|
|
301
389
|
|
|
302
|
-
const
|
|
390
|
+
const [search, mode] = right;
|
|
391
|
+
const placeholder = this.addParam(params, leftInfo.sql, search);
|
|
392
|
+
let againstClause: string;
|
|
393
|
+
switch (typeof mode === 'string' ? mode.toUpperCase() : '') {
|
|
394
|
+
case 'BOOLEAN':
|
|
395
|
+
againstClause = `AGAINST(${placeholder} IN BOOLEAN MODE)`;
|
|
396
|
+
break;
|
|
397
|
+
case 'WITH QUERY EXPANSION':
|
|
398
|
+
againstClause = `AGAINST(${placeholder} WITH QUERY EXPANSION)`;
|
|
399
|
+
break;
|
|
400
|
+
case 'NATURAL LANGUAGE MODE':
|
|
401
|
+
againstClause = `AGAINST(${placeholder} IN NATURAL LANGUAGE MODE)`;
|
|
402
|
+
break;
|
|
403
|
+
default:
|
|
404
|
+
againstClause = `AGAINST(${placeholder})`;
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
303
407
|
|
|
304
|
-
|
|
305
|
-
|
|
408
|
+
const clause = `(MATCH(${leftInfo.sql}) ${againstClause})`;
|
|
409
|
+
this.config.verbose && console.log(`[MATCH_AGAINST] ${clause}`);
|
|
410
|
+
return clause;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const operands = Array.isArray(rawOperands) ? rawOperands : [rawOperands];
|
|
414
|
+
|
|
415
|
+
if (operator === C6C.IN || operator === 'NOT IN') {
|
|
416
|
+
if (operands.length < 2) {
|
|
417
|
+
throw new Error(`${operator} requires two operands.`);
|
|
418
|
+
}
|
|
419
|
+
const [leftRaw, ...rest] = operands;
|
|
420
|
+
const left = leftRaw;
|
|
421
|
+
const right = rest.length <= 1 ? rest[0] : rest;
|
|
422
|
+
const leftInfo = this.serializeOperand(left, params, typeof left === 'string' ? left : contextColumn);
|
|
423
|
+
if (!leftInfo.isReference) {
|
|
424
|
+
throw new Error(`${operator} requires the left operand to be a table reference.`);
|
|
306
425
|
}
|
|
307
426
|
|
|
308
|
-
if (
|
|
309
|
-
|
|
427
|
+
if (Array.isArray(right)) {
|
|
428
|
+
if (right.length === 0) {
|
|
429
|
+
throw new Error(`${operator} requires at least one value.`);
|
|
430
|
+
}
|
|
431
|
+
if (right.length === 2 && right[0] === C6C.SUBSELECT) {
|
|
432
|
+
const sub = this.serializeOperand(right, params, typeof left === 'string' ? left : contextColumn);
|
|
433
|
+
return `( ${leftInfo.sql} ${operator} ${sub.sql} )`;
|
|
434
|
+
}
|
|
435
|
+
const placeholders = right.map(item => {
|
|
436
|
+
if (typeof item === 'string' && this.isTableReference(item)) {
|
|
437
|
+
return item;
|
|
438
|
+
}
|
|
439
|
+
const { sql } = this.serializeOperand(item, params, typeof left === 'string' ? left : contextColumn);
|
|
440
|
+
return sql;
|
|
441
|
+
});
|
|
442
|
+
return `( ${leftInfo.sql} ${operator} (${placeholders.join(', ')}) )`;
|
|
310
443
|
}
|
|
311
444
|
|
|
312
|
-
|
|
313
|
-
|
|
445
|
+
const rightInfo = this.serializeOperand(right, params, typeof left === 'string' ? left : contextColumn);
|
|
446
|
+
if (!rightInfo.isSubSelect) {
|
|
447
|
+
throw new Error(`${operator} requires an array of values or a subselect.`);
|
|
314
448
|
}
|
|
449
|
+
return `( ${leftInfo.sql} ${operator} ${rightInfo.sql} )`;
|
|
450
|
+
}
|
|
315
451
|
|
|
316
|
-
|
|
317
|
-
|
|
452
|
+
if (operator === C6C.BETWEEN || operator === 'NOT BETWEEN') {
|
|
453
|
+
let left: any;
|
|
454
|
+
let start: any;
|
|
455
|
+
let end: any;
|
|
456
|
+
if (operands.length === 3) {
|
|
457
|
+
[left, start, end] = operands;
|
|
458
|
+
} else if (operands.length === 2 && Array.isArray(operands[1]) && operands[1].length === 2) {
|
|
459
|
+
[left, [start, end]] = operands as [any, any[]];
|
|
460
|
+
} else {
|
|
461
|
+
throw new Error(`${operator} requires three operands.`);
|
|
462
|
+
}
|
|
463
|
+
const leftInfo = this.serializeOperand(left, params, typeof left === 'string' ? left : contextColumn);
|
|
464
|
+
if (!leftInfo.isReference) {
|
|
465
|
+
throw new Error(`${operator} requires the left operand to be a table reference.`);
|
|
318
466
|
}
|
|
467
|
+
const startInfo = this.serializeOperand(start, params, typeof left === 'string' ? left : contextColumn);
|
|
468
|
+
const endInfo = this.serializeOperand(end, params, typeof left === 'string' ? left : contextColumn);
|
|
469
|
+
const betweenOperator = operator === 'NOT BETWEEN' ? 'NOT BETWEEN' : 'BETWEEN';
|
|
470
|
+
return `${this.ensureWrapped(leftInfo.sql)} ${betweenOperator} ${startInfo.sql} AND ${endInfo.sql}`;
|
|
471
|
+
}
|
|
319
472
|
|
|
320
|
-
|
|
473
|
+
if (operands.length !== 2) {
|
|
474
|
+
throw new Error(`${operator} requires two operands.`);
|
|
475
|
+
}
|
|
321
476
|
|
|
322
|
-
|
|
477
|
+
let [leftOperand, rightOperand] = operands;
|
|
478
|
+
const leftInfo = this.serializeOperand(leftOperand, params, typeof leftOperand === 'string' ? leftOperand : contextColumn);
|
|
479
|
+
const rightInfo = this.serializeOperand(rightOperand, params, typeof leftOperand === 'string' ? leftOperand : contextColumn);
|
|
323
480
|
|
|
324
|
-
|
|
481
|
+
if (!leftInfo.isReference && !leftInfo.isExpression && !rightInfo.isReference && !rightInfo.isExpression) {
|
|
482
|
+
throw new Error(`Potential SQL injection detected: '${operator}' with non-reference operands.`);
|
|
483
|
+
}
|
|
325
484
|
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
485
|
+
const leftSql = leftInfo.isExpression ? leftInfo.sql : this.ensureWrapped(leftInfo.sql);
|
|
486
|
+
const rightSql = rightInfo.isExpression ? rightInfo.sql : rightInfo.sql;
|
|
487
|
+
|
|
488
|
+
return `${leftSql} ${operator} ${rightSql}`;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private buildLegacyColumnCondition(
|
|
492
|
+
column: string,
|
|
493
|
+
value: any,
|
|
494
|
+
params: any[] | Record<string, any>
|
|
495
|
+
): string {
|
|
496
|
+
if (value instanceof Map) {
|
|
497
|
+
value = Object.fromEntries(value);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (Array.isArray(value)) {
|
|
501
|
+
if (value.length >= 2 && typeof value[0] === 'string') {
|
|
502
|
+
const [op, ...rest] = value;
|
|
503
|
+
return this.buildOperatorExpression(op, [column, ...rest], params, column);
|
|
504
|
+
}
|
|
505
|
+
if (value.length === 3 && typeof value[0] === 'string' && typeof value[1] === 'string') {
|
|
506
|
+
return this.buildOperatorExpression(value[1], [value[0], value[2]], params, value[0]);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (typeof value === 'object' && value !== null) {
|
|
511
|
+
const entries = Object.entries(value);
|
|
512
|
+
if (entries.length === 1) {
|
|
513
|
+
const [op, operand] = entries[0];
|
|
514
|
+
if (this.isOperator(op)) {
|
|
515
|
+
return this.buildOperatorExpression(op, [column, operand], params, column);
|
|
349
516
|
}
|
|
517
|
+
if (this.BOOLEAN_OPERATORS.has(op)) {
|
|
518
|
+
const expression = this.buildBooleanExpression({ [op]: operand }, params, 'AND');
|
|
519
|
+
return expression;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
350
522
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
} else if (Array.isArray(v) && v.length >= 2 && typeof v[0] === 'string') {
|
|
355
|
-
const [op, val] = v as [string, any];
|
|
356
|
-
subParts.push(addCondition(k, op, val));
|
|
357
|
-
} else if (typeof v === 'object' && v !== null) {
|
|
358
|
-
const sub = this.buildBooleanJoinedConditions(v, mode, params);
|
|
359
|
-
if (sub) subParts.push(sub);
|
|
360
|
-
} else {
|
|
361
|
-
subParts.push(addCondition(k, '=', v));
|
|
523
|
+
const subParts = entries.map(([op, operand]) => {
|
|
524
|
+
if (this.isOperator(op)) {
|
|
525
|
+
return this.buildOperatorExpression(op, [column, operand], params, column);
|
|
362
526
|
}
|
|
363
|
-
|
|
527
|
+
return this.buildBooleanExpression({ [op]: operand }, params, 'AND');
|
|
528
|
+
}).filter(Boolean);
|
|
529
|
+
|
|
530
|
+
return this.joinBooleanParts(subParts, 'AND');
|
|
531
|
+
}
|
|
364
532
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
533
|
+
return this.buildOperatorExpression(C6C.EQUAL, [column, value], params, column);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private buildBooleanExpression(
|
|
537
|
+
node: any,
|
|
538
|
+
params: any[] | Record<string, any>,
|
|
539
|
+
defaultOperator: 'AND' | 'OR'
|
|
540
|
+
): string {
|
|
541
|
+
if (node === null || node === undefined) {
|
|
542
|
+
return '';
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (Array.isArray(node)) {
|
|
546
|
+
if (node.length === 0) return '';
|
|
547
|
+
|
|
548
|
+
// Support both [left, operator, right] and [operator, left, right]
|
|
549
|
+
if (node.length === 3 && typeof node[0] === 'string' && typeof node[1] === 'string') {
|
|
550
|
+
const opAsSecond = this.isOperator(node[1]);
|
|
551
|
+
const opAsFirst = this.isOperator(node[0]);
|
|
552
|
+
|
|
553
|
+
if (opAsSecond) {
|
|
554
|
+
return this.buildOperatorExpression(node[1], [node[0], node[2]], params, node[0]);
|
|
555
|
+
}
|
|
556
|
+
if (opAsFirst) {
|
|
557
|
+
return this.buildOperatorExpression(node[0], [node[1], node[2]], params, node[1]);
|
|
558
|
+
}
|
|
559
|
+
// fall-through to treat as grouped expressions
|
|
368
560
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
561
|
+
|
|
562
|
+
const parts = node
|
|
563
|
+
.map(item => this.buildBooleanExpression(item, params, 'OR'))
|
|
564
|
+
.filter(Boolean);
|
|
565
|
+
return this.joinBooleanParts(parts, 'OR');
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (node instanceof Map) {
|
|
569
|
+
node = Object.fromEntries(node);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (typeof node !== 'object') {
|
|
573
|
+
throw new Error('Invalid WHERE clause structure.');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const entries = Object.entries(node);
|
|
577
|
+
if (entries.length === 0) return '';
|
|
578
|
+
|
|
579
|
+
if (entries.length === 1) {
|
|
580
|
+
const [key, value] = entries[0];
|
|
581
|
+
if (this.BOOLEAN_OPERATORS.has(key)) {
|
|
582
|
+
if (!Array.isArray(value)) {
|
|
583
|
+
throw new Error(`${key} expects an array of expressions.`);
|
|
584
|
+
}
|
|
585
|
+
const op = this.BOOLEAN_OPERATORS.get(key)!;
|
|
586
|
+
const parts = value
|
|
587
|
+
.map(item => this.buildBooleanExpression(item, params, op))
|
|
588
|
+
.filter(Boolean);
|
|
589
|
+
return this.joinBooleanParts(parts, op);
|
|
590
|
+
}
|
|
591
|
+
if (this.isOperator(key)) {
|
|
592
|
+
return this.buildOperatorExpression(key, value, params);
|
|
593
|
+
}
|
|
594
|
+
if (!isNaN(Number(key))) {
|
|
595
|
+
return this.buildBooleanExpression(value, params, 'OR');
|
|
373
596
|
}
|
|
597
|
+
}
|
|
374
598
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if (
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const sub = addCondition(column, op, value);
|
|
385
|
-
if (sub) parts.push(sub);
|
|
386
|
-
} else {
|
|
387
|
-
for (const item of set) {
|
|
388
|
-
const sub = this.buildBooleanJoinedConditions(item, false, params);
|
|
389
|
-
if (sub) parts.push(sub);
|
|
599
|
+
const parts: string[] = [];
|
|
600
|
+
const nonNumeric = entries.filter(([k]) => isNaN(Number(k)));
|
|
601
|
+
const numeric = entries.filter(([k]) => !isNaN(Number(k)));
|
|
602
|
+
|
|
603
|
+
for (const [key, value] of nonNumeric) {
|
|
604
|
+
if (this.BOOLEAN_OPERATORS.has(key)) {
|
|
605
|
+
const op = this.BOOLEAN_OPERATORS.get(key)!;
|
|
606
|
+
if (!Array.isArray(value)) {
|
|
607
|
+
throw new Error(`${key} expects an array of expressions.`);
|
|
390
608
|
}
|
|
609
|
+
const nested = value
|
|
610
|
+
.map(item => this.buildBooleanExpression(item, params, op))
|
|
611
|
+
.filter(Boolean);
|
|
612
|
+
if (nested.length) {
|
|
613
|
+
parts.push(this.joinBooleanParts(nested, op));
|
|
614
|
+
}
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (this.isOperator(key)) {
|
|
619
|
+
parts.push(this.buildOperatorExpression(key, value, params));
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
parts.push(this.buildLegacyColumnCondition(key, value, params));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
for (const [, value] of numeric) {
|
|
627
|
+
const nested = this.buildBooleanExpression(value, params, 'OR');
|
|
628
|
+
if (nested) {
|
|
629
|
+
parts.push(nested);
|
|
391
630
|
}
|
|
392
|
-
} else if (typeof set === 'object' && set !== null) {
|
|
393
|
-
const sub = buildFromObject(set, andMode);
|
|
394
|
-
if (sub) parts.push(sub);
|
|
395
631
|
}
|
|
396
632
|
|
|
397
|
-
|
|
398
|
-
|
|
633
|
+
return this.joinBooleanParts(parts, defaultOperator);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
buildBooleanJoinedConditions(
|
|
637
|
+
set: any,
|
|
638
|
+
andMode: boolean = true,
|
|
639
|
+
params: any[] | Record<string, any> = []
|
|
640
|
+
): string {
|
|
641
|
+
const expression = this.buildBooleanExpression(set, params, andMode ? 'AND' : 'OR');
|
|
642
|
+
if (!expression) return '';
|
|
643
|
+
return this.ensureWrapped(expression);
|
|
399
644
|
}
|
|
400
645
|
|
|
401
646
|
buildWhereClause(whereArg: any, params: any[] | Record<string, any>): string {
|
|
402
647
|
const clause = this.buildBooleanJoinedConditions(whereArg, true, params);
|
|
403
648
|
if (!clause) return '';
|
|
404
|
-
|
|
649
|
+
|
|
650
|
+
let trimmed = clause.trim();
|
|
651
|
+
const upper = trimmed.toUpperCase();
|
|
652
|
+
|
|
653
|
+
if (!upper.includes(' AND ') && !upper.includes(' OR ')) {
|
|
654
|
+
if (trimmed.startsWith('(') && trimmed.endsWith(')')) {
|
|
655
|
+
const inner = trimmed.substring(1, trimmed.length - 1);
|
|
656
|
+
const innerUpper = inner.toUpperCase();
|
|
657
|
+
const requiresOuterWrap =
|
|
658
|
+
innerUpper.includes(' IN ') ||
|
|
659
|
+
innerUpper.includes(' BETWEEN ') ||
|
|
660
|
+
innerUpper.includes(' SELECT ');
|
|
661
|
+
|
|
662
|
+
if (requiresOuterWrap) {
|
|
663
|
+
trimmed = `( ${inner.trim()} )`;
|
|
664
|
+
} else {
|
|
665
|
+
trimmed = inner.trim();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
405
670
|
this.config.verbose && console.log(`[WHERE] ${trimmed}`);
|
|
406
671
|
return ` WHERE ${trimmed}`;
|
|
407
672
|
}
|