@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.
@@ -70,24 +70,44 @@ export abstract class ConditionBuilder<
70
70
  throw new Error("Method not implemented.");
71
71
  }
72
72
 
73
- private readonly OPERATORS = new Set([
74
- C6C.EQUAL, C6C.NOT_EQUAL, C6C.LESS_THAN, C6C.LESS_THAN_OR_EQUAL_TO,
75
- C6C.GREATER_THAN, C6C.GREATER_THAN_OR_EQUAL_TO,
76
- C6C.LIKE, C6C.NOT_LIKE,
77
- C6C.IN, C6C.NOT_IN, 'NOT IN',
78
- C6C.IS, C6C.IS_NOT,
79
- C6C.BETWEEN, 'NOT BETWEEN',
80
- C6C.MATCH_AGAINST,
81
- C6C.ST_DISTANCE_SPHERE,
82
- // spatial predicates
83
- C6C.ST_CONTAINS,
84
- C6C.ST_INTERSECTS,
85
- C6C.ST_WITHIN,
86
- C6C.ST_CROSSES,
87
- C6C.ST_DISJOINT,
88
- C6C.ST_EQUALS,
89
- C6C.ST_OVERLAPS,
90
- C6C.ST_TOUCHES
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
- buildBooleanJoinedConditions(
152
- set: any,
153
- andMode: boolean = true,
154
- params: any[] | Record<string, any> = []
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
- const addCondition = (column: any, op: any, value: any): string => {
159
- // Normalize common variants
160
- const valueNorm = (value === C6C.NULL) ? null : value;
161
- const displayOp = typeof op === 'string' ? op.replace('_', ' ') : op;
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
- const extractSubSelect = (input: any): any | undefined => {
164
- if (Array.isArray(input) && input.length >= 2 && input[0] === C6C.SUBSELECT) {
165
- return input[1];
166
- }
167
- if (input && typeof input === 'object' && C6C.SUBSELECT in input) {
168
- return input[C6C.SUBSELECT];
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
- return undefined;
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
- const rightSubSelectPayload = extractSubSelect(valueNorm);
174
- const buildSubSelect = (payload: any): string | undefined => {
175
- if (!payload) return undefined;
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, payload, params);
181
- };
182
- const rightSubSelectSql = buildSubSelect(rightSubSelectPayload);
183
-
184
- // Support function-based expressions like [C6C.ST_DISTANCE_SPHERE, col1, col2]
185
- if (
186
- typeof column === 'string' &&
187
- this.OPERATORS.has(column) &&
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
- const leftIsCol = this.isColumnRef(column);
233
- const leftIsRef = this.isTableReference(column);
234
- const rightIsCol = typeof value === 'string' && this.isColumnRef(value);
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
- if (!leftIsCol && !leftIsRef && !rightIsCol && !rightSubSelectSql) {
237
- throw new Error(`Potential SQL injection detected: '${column} ${op} ${value}'`);
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
- this.validateOperator(op);
332
+ if (operand instanceof Map) {
333
+ operand = Object.fromEntries(operand);
334
+ }
241
335
 
242
- if (op === C6C.MATCH_AGAINST && Array.isArray(value)) {
243
- const [search, mode] = value;
244
- const paramName = this.useNamedParams ? `param${Object.keys(params).length}` : null;
245
- if (this.useNamedParams) {
246
- params[paramName!] = search;
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
- let againstClause: string;
252
-
253
- switch ((mode || '').toUpperCase()) {
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 (!leftIsCol) {
266
- throw new Error(`MATCH_AGAINST requires a table reference as the left operand. Column '${column}' is not a valid table reference.`);
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
- const matchClause = `(MATCH(${column}) ${againstClause})`;
269
- this.config.verbose && console.log(`[MATCH_AGAINST] ${matchClause}`);
270
- return matchClause;
359
+
360
+ const sql = this.buildFunctionCall(key, value, params);
361
+ return { sql, isReference: false, isExpression: true, isSubSelect: false };
271
362
  }
363
+ }
272
364
 
273
- if ((op === C6C.IN || op === C6C.NOT_IN) && Array.isArray(value)) {
274
- if (rightSubSelectSql) {
275
- if (!leftIsRef) {
276
- throw new Error(`IN operator requires a table reference as the left operand. Column '${column}' is not a valid table reference.`);
277
- }
278
- const normalized = op.replace('_', ' ');
279
- return `( ${column} ${normalized} ${rightSubSelectSql} )`;
280
- }
281
- const placeholders = value.map(v =>
282
- this.isColumnRef(v) ? v : this.addParam(params, column, v)
283
- ).join(', ');
284
- const normalized = op.replace('_', ' ');
285
- if (!leftIsRef) {
286
- throw new Error(`IN operator requires a table reference as the left operand. Column '${column}' is not a valid table reference.`);
287
- }
288
- return `( ${column} ${normalized} (${placeholders}) )`;
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 (op === C6C.BETWEEN || op === 'NOT BETWEEN') {
292
- if (!Array.isArray(value) || value.length !== 2) {
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 rightIsRef: boolean = rightSubSelectSql ? false : this.isTableReference(value);
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
- if (leftIsRef && rightIsRef) {
305
- return `(${column}) ${displayOp} ${value}`;
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 (leftIsRef && rightSubSelectSql) {
309
- return `(${column}) ${displayOp} ${rightSubSelectSql}`;
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
- if (leftIsRef && !rightIsRef) {
313
- return `(${column}) ${displayOp} ${this.addParam(params, column, valueNorm)}`;
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
- if (rightIsRef) {
317
- return `(${this.addParam(params, column, column)}) ${displayOp} ${value}`;
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
- throw new Error(`Neither operand appears to be a table reference (${column}) or (${value})`);
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
- const parts: string[] = [];
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 buildFromObject = (obj: Record<string, any>, mode: boolean) => {
327
- const subParts: string[] = [];
328
- const entries = Object.entries(obj);
329
- const nonNumeric = entries.filter(([k]) => isNaN(Number(k)));
330
- const numeric = entries.filter(([k]) => !isNaN(Number(k)));
331
-
332
- const processEntry = (k: string, v: any) => {
333
- // Operator-as-key handling, e.g., { [C6C.ST_DISTANCE_SPHERE]: [arg1, arg2, threshold] }
334
- if (typeof k === 'string' && this.OPERATORS.has(k) && Array.isArray(v)) {
335
- if (k === C6C.ST_DISTANCE_SPHERE) {
336
- // Accept either [arg1, arg2, threshold] or [[arg1, arg2], threshold]
337
- let args: any[];
338
- let threshold: any;
339
- if (Array.isArray(v[0]) && v.length >= 2) {
340
- args = v[0];
341
- threshold = v[1];
342
- } else {
343
- args = v.slice(0, 2);
344
- threshold = v[2];
345
- }
346
- subParts.push(addCondition(k, args as any, threshold));
347
- return;
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
- if (typeof v === 'object' && v !== null && Object.keys(v).length === 1) {
352
- const [op, val] = Object.entries(v)[0];
353
- subParts.push(addCondition(k, op, val));
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
- // Process non-numeric keys first to preserve intuitive insertion order for params
366
- for (const [k, v] of nonNumeric) {
367
- processEntry(k, v);
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
- // Then process numeric keys (treated as grouped OR conditions)
370
- for (const [_k, v] of numeric) {
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
- return subParts.join(` ${mode ? 'AND' : 'OR'} `);
376
- };
377
-
378
- if (Array.isArray(set)) {
379
- // Detect a single condition triple: [column, op, value]
380
- if (set.length === 3 && typeof set[0] === 'string' && typeof set[1] === 'string') {
381
- const [column, rawOp, rawVal] = set as [string, string, any];
382
- const op = rawOp;
383
- const value = rawVal === C6C.NULL ? null : rawVal;
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
- const clause = parts.join(` ${booleanOperator} `);
398
- return clause ? `(${clause})` : '';
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
- const trimmed = clause.replace(/^\((.*)\)$/, '$1');
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
  }