@carbonorm/carbonnode 3.9.5 → 3.9.6
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 +459 -243
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +459 -243
- 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 +163 -0
- package/src/api/C6Constants.ts +2 -0
- package/src/api/orm/builders/ConditionBuilder.ts +479 -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,501 @@ 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
|
+
}
|
|
231
|
+
|
|
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
|
+
}
|
|
172
240
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
+
}
|
|
231
294
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
+
}
|
|
300
|
+
|
|
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);
|
|
307
|
+
|
|
308
|
+
if (operand === C6C.NULL) {
|
|
309
|
+
operand = null;
|
|
310
|
+
}
|
|
235
311
|
|
|
236
|
-
|
|
237
|
-
|
|
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
|
+
}
|
|
325
|
+
|
|
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
|
+
}
|
|
239
331
|
|
|
240
|
-
|
|
332
|
+
if (operand instanceof Map) {
|
|
333
|
+
operand = Object.fromEntries(operand);
|
|
334
|
+
}
|
|
241
335
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
} else {
|
|
248
|
-
params.push(search);
|
|
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
|
+
}
|
|
407
|
+
|
|
408
|
+
const clause = `(MATCH(${leftInfo.sql}) ${againstClause})`;
|
|
409
|
+
this.config.verbose && console.log(`[MATCH_AGAINST] ${clause}`);
|
|
410
|
+
return clause;
|
|
411
|
+
}
|
|
303
412
|
|
|
304
|
-
|
|
305
|
-
|
|
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.`);
|
|
318
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.`);
|
|
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);
|
|
516
|
+
}
|
|
517
|
+
if (this.BOOLEAN_OPERATORS.has(op)) {
|
|
518
|
+
const expression = this.buildBooleanExpression({ [op]: operand }, params, 'AND');
|
|
519
|
+
return expression;
|
|
349
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);
|
|
364
529
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
530
|
+
return this.joinBooleanParts(subParts, 'AND');
|
|
531
|
+
}
|
|
532
|
+
|
|
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
|
+
if (node.length === 3 && typeof node[0] === 'string' && typeof node[1] === 'string') {
|
|
549
|
+
return this.buildOperatorExpression(node[1], [node[0], node[2]], params, node[0]);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const parts = node
|
|
553
|
+
.map(item => this.buildBooleanExpression(item, params, 'OR'))
|
|
554
|
+
.filter(Boolean);
|
|
555
|
+
return this.joinBooleanParts(parts, 'OR');
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (node instanceof Map) {
|
|
559
|
+
node = Object.fromEntries(node);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (typeof node !== 'object') {
|
|
563
|
+
throw new Error('Invalid WHERE clause structure.');
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const entries = Object.entries(node);
|
|
567
|
+
if (entries.length === 0) return '';
|
|
568
|
+
|
|
569
|
+
if (entries.length === 1) {
|
|
570
|
+
const [key, value] = entries[0];
|
|
571
|
+
if (this.BOOLEAN_OPERATORS.has(key)) {
|
|
572
|
+
if (!Array.isArray(value)) {
|
|
573
|
+
throw new Error(`${key} expects an array of expressions.`);
|
|
574
|
+
}
|
|
575
|
+
const op = this.BOOLEAN_OPERATORS.get(key)!;
|
|
576
|
+
const parts = value
|
|
577
|
+
.map(item => this.buildBooleanExpression(item, params, op))
|
|
578
|
+
.filter(Boolean);
|
|
579
|
+
return this.joinBooleanParts(parts, op);
|
|
580
|
+
}
|
|
581
|
+
if (this.isOperator(key)) {
|
|
582
|
+
return this.buildOperatorExpression(key, value, params);
|
|
368
583
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const sub = this.buildBooleanJoinedConditions(v, false, params);
|
|
372
|
-
if (sub) subParts.push(sub);
|
|
584
|
+
if (!isNaN(Number(key))) {
|
|
585
|
+
return this.buildBooleanExpression(value, params, 'OR');
|
|
373
586
|
}
|
|
587
|
+
}
|
|
374
588
|
|
|
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);
|
|
589
|
+
const parts: string[] = [];
|
|
590
|
+
const nonNumeric = entries.filter(([k]) => isNaN(Number(k)));
|
|
591
|
+
const numeric = entries.filter(([k]) => !isNaN(Number(k)));
|
|
592
|
+
|
|
593
|
+
for (const [key, value] of nonNumeric) {
|
|
594
|
+
if (this.BOOLEAN_OPERATORS.has(key)) {
|
|
595
|
+
const op = this.BOOLEAN_OPERATORS.get(key)!;
|
|
596
|
+
if (!Array.isArray(value)) {
|
|
597
|
+
throw new Error(`${key} expects an array of expressions.`);
|
|
390
598
|
}
|
|
599
|
+
const nested = value
|
|
600
|
+
.map(item => this.buildBooleanExpression(item, params, op))
|
|
601
|
+
.filter(Boolean);
|
|
602
|
+
if (nested.length) {
|
|
603
|
+
parts.push(this.joinBooleanParts(nested, op));
|
|
604
|
+
}
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (this.isOperator(key)) {
|
|
609
|
+
parts.push(this.buildOperatorExpression(key, value, params));
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
parts.push(this.buildLegacyColumnCondition(key, value, params));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
for (const [, value] of numeric) {
|
|
617
|
+
const nested = this.buildBooleanExpression(value, params, 'OR');
|
|
618
|
+
if (nested) {
|
|
619
|
+
parts.push(nested);
|
|
391
620
|
}
|
|
392
|
-
} else if (typeof set === 'object' && set !== null) {
|
|
393
|
-
const sub = buildFromObject(set, andMode);
|
|
394
|
-
if (sub) parts.push(sub);
|
|
395
621
|
}
|
|
396
622
|
|
|
397
|
-
|
|
398
|
-
|
|
623
|
+
return this.joinBooleanParts(parts, defaultOperator);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
buildBooleanJoinedConditions(
|
|
627
|
+
set: any,
|
|
628
|
+
andMode: boolean = true,
|
|
629
|
+
params: any[] | Record<string, any> = []
|
|
630
|
+
): string {
|
|
631
|
+
const expression = this.buildBooleanExpression(set, params, andMode ? 'AND' : 'OR');
|
|
632
|
+
if (!expression) return '';
|
|
633
|
+
return this.ensureWrapped(expression);
|
|
399
634
|
}
|
|
400
635
|
|
|
401
636
|
buildWhereClause(whereArg: any, params: any[] | Record<string, any>): string {
|
|
402
637
|
const clause = this.buildBooleanJoinedConditions(whereArg, true, params);
|
|
403
638
|
if (!clause) return '';
|
|
404
|
-
|
|
639
|
+
|
|
640
|
+
let trimmed = clause.trim();
|
|
641
|
+
const upper = trimmed.toUpperCase();
|
|
642
|
+
|
|
643
|
+
if (!upper.includes(' AND ') && !upper.includes(' OR ')) {
|
|
644
|
+
if (trimmed.startsWith('(') && trimmed.endsWith(')')) {
|
|
645
|
+
const inner = trimmed.substring(1, trimmed.length - 1);
|
|
646
|
+
const innerUpper = inner.toUpperCase();
|
|
647
|
+
const requiresOuterWrap =
|
|
648
|
+
innerUpper.includes(' IN ') ||
|
|
649
|
+
innerUpper.includes(' BETWEEN ') ||
|
|
650
|
+
innerUpper.includes(' SELECT ');
|
|
651
|
+
|
|
652
|
+
if (requiresOuterWrap) {
|
|
653
|
+
trimmed = `( ${inner.trim()} )`;
|
|
654
|
+
} else {
|
|
655
|
+
trimmed = inner.trim();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
405
660
|
this.config.verbose && console.log(`[WHERE] ${trimmed}`);
|
|
406
661
|
return ` WHERE ${trimmed}`;
|
|
407
662
|
}
|