@carbonorm/carbonnode 3.8.4 → 3.9.2
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 +48 -1
- package/dist/api/C6Constants.d.ts +6 -0
- package/dist/api/orm/builders/AggregateBuilder.d.ts +2 -1
- package/dist/api/orm/builders/ConditionBuilder.d.ts +3 -0
- package/dist/api/orm/builders/JoinBuilder.d.ts +8 -0
- package/dist/api/orm/builders/PaginationBuilder.d.ts +1 -1
- package/dist/api/orm/queries/DeleteQueryBuilder.d.ts +2 -0
- package/dist/api/orm/queries/SelectQueryBuilder.d.ts +1 -0
- package/dist/api/orm/queries/UpdateQueryBuilder.d.ts +2 -0
- package/dist/api/orm/queryHelpers.d.ts +5 -0
- package/dist/index.cjs.js +409 -114
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +407 -115
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/fixtures/c6.fixture.ts +39 -0
- package/src/__tests__/fixtures/pu.fixture.ts +72 -0
- package/src/__tests__/sakila-db/C6.js +1 -1
- package/src/__tests__/sakila-db/C6.ts +1 -1
- package/src/__tests__/sqlBuilders.complex.test.ts +281 -1
- package/src/api/C6Constants.ts +4 -1
- package/src/api/orm/builders/AggregateBuilder.ts +70 -2
- package/src/api/orm/builders/ConditionBuilder.ts +66 -4
- package/src/api/orm/builders/JoinBuilder.ts +117 -6
- package/src/api/orm/builders/PaginationBuilder.ts +19 -2
- package/src/api/orm/queries/DeleteQueryBuilder.ts +5 -0
- package/src/api/orm/queries/SelectQueryBuilder.ts +6 -2
- package/src/api/orm/queries/UpdateQueryBuilder.ts +6 -1
- package/src/api/orm/queryHelpers.ts +59 -0
|
@@ -1,7 +1,28 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { C6C } from '../api/C6Constants';
|
|
3
3
|
import { SelectQueryBuilder } from '../api/orm/queries/SelectQueryBuilder';
|
|
4
|
-
import {
|
|
4
|
+
import { derivedTable, F } from '../api/orm/queryHelpers';
|
|
5
|
+
import { buildParcelConfig, buildTestConfig } from './fixtures/c6.fixture';
|
|
6
|
+
|
|
7
|
+
const Property_Units = {
|
|
8
|
+
TABLE_NAME: 'property_units',
|
|
9
|
+
UNIT_ID: 'property_units.unit_id',
|
|
10
|
+
LOCATION: 'property_units.location',
|
|
11
|
+
PARCEL_ID: 'property_units.parcel_id',
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
const Parcel_Sales = {
|
|
15
|
+
TABLE_NAME: 'parcel_sales',
|
|
16
|
+
PARCEL_ID: 'parcel_sales.parcel_id',
|
|
17
|
+
SALE_PRICE: 'parcel_sales.sale_price',
|
|
18
|
+
SALE_TYPE: 'parcel_sales.sale_type',
|
|
19
|
+
SALE_DATE: 'parcel_sales.sale_date',
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
const Parcel_Building_Details = {
|
|
23
|
+
TABLE_NAME: 'parcel_building_details',
|
|
24
|
+
PARCEL_ID: 'parcel_building_details.parcel_id',
|
|
25
|
+
} as const;
|
|
5
26
|
|
|
6
27
|
/**
|
|
7
28
|
* Complex SELECT coverage focused on WHERE operators, JOIN chains, ORDER, and pagination.
|
|
@@ -157,4 +178,263 @@ describe('SQL Builders - Complex SELECTs', () => {
|
|
|
157
178
|
expect(sql).toMatch(/\(actor\.last_name\) IS NOT \?/);
|
|
158
179
|
expect(params).toEqual([null]);
|
|
159
180
|
});
|
|
181
|
+
|
|
182
|
+
it('serializes derived table joins with parameter hoisting and alias scoping', () => {
|
|
183
|
+
const config = buildParcelConfig();
|
|
184
|
+
const unitIdParam = 42;
|
|
185
|
+
const ALLOWED_SALE_TYPES = ['A', 'B', 'C', 'D', 'E', 'F'];
|
|
186
|
+
const parsedDateRanges = [
|
|
187
|
+
{ start: '2023-01-01', end: '2023-01-31' },
|
|
188
|
+
{ start: '2023-02-01', end: '2023-02-28' },
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
const puTarget = derivedTable({
|
|
192
|
+
[C6C.SUBSELECT]: {
|
|
193
|
+
[C6C.SELECT]: [Property_Units.LOCATION],
|
|
194
|
+
[C6C.FROM]: Property_Units.TABLE_NAME,
|
|
195
|
+
[C6C.WHERE]: { [Property_Units.UNIT_ID]: [C6C.EQUAL, unitIdParam] },
|
|
196
|
+
[C6C.LIMIT]: 1,
|
|
197
|
+
},
|
|
198
|
+
[C6C.AS]: 'pu_target',
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const innerJoin: any = {
|
|
202
|
+
'parcel_sales ps': {
|
|
203
|
+
'ps.parcel_id': [C6C.EQUAL, Property_Units.PARCEL_ID],
|
|
204
|
+
},
|
|
205
|
+
'parcel_building_details pbd': {
|
|
206
|
+
'pbd.parcel_id': [C6C.EQUAL, Property_Units.PARCEL_ID],
|
|
207
|
+
},
|
|
208
|
+
[puTarget as any]: {},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
212
|
+
[C6C.SELECT]: [
|
|
213
|
+
Property_Units.UNIT_ID,
|
|
214
|
+
Property_Units.LOCATION,
|
|
215
|
+
F(Property_Units.LOCATION, 'pu_target'),
|
|
216
|
+
],
|
|
217
|
+
[C6C.JOIN]: {
|
|
218
|
+
[C6C.INNER]: innerJoin,
|
|
219
|
+
},
|
|
220
|
+
[C6C.WHERE]: {
|
|
221
|
+
[Property_Units.UNIT_ID]: [C6C.NOT_EQUAL, unitIdParam],
|
|
222
|
+
[Parcel_Sales.SALE_PRICE]: [C6C.NOT_EQUAL, 0],
|
|
223
|
+
[Parcel_Sales.SALE_TYPE]: { [C6C.IN]: ALLOWED_SALE_TYPES },
|
|
224
|
+
0: parsedDateRanges.map(({ start, end }) => ({
|
|
225
|
+
[Parcel_Sales.SALE_DATE]: [C6C.BETWEEN, [start, end]],
|
|
226
|
+
})),
|
|
227
|
+
},
|
|
228
|
+
[C6C.PAGINATION]: {
|
|
229
|
+
[C6C.LIMIT]: 200,
|
|
230
|
+
[C6C.ORDER]: {
|
|
231
|
+
[C6C.ST_DISTANCE_SPHERE]: [
|
|
232
|
+
Property_Units.LOCATION,
|
|
233
|
+
F(Property_Units.LOCATION, 'pu_target'),
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
} as any, false);
|
|
238
|
+
|
|
239
|
+
const { sql, params } = qb.build(Property_Units.TABLE_NAME);
|
|
240
|
+
|
|
241
|
+
expect(sql).toContain('SELECT property_units.unit_id, property_units.location, pu_target.location FROM `property_units`');
|
|
242
|
+
expect(sql).toContain('INNER JOIN `parcel_sales` AS `ps`');
|
|
243
|
+
expect(sql).toContain('INNER JOIN `parcel_building_details` AS `pbd`');
|
|
244
|
+
expect(sql).toMatch(/INNER JOIN \(\s+SELECT property_units\.location/);
|
|
245
|
+
expect(sql).toContain('WHERE (property_units.unit_id) <> ?');
|
|
246
|
+
expect(sql).toContain('AND (parcel_sales.sale_price) <> ?');
|
|
247
|
+
expect(sql).toContain('ORDER BY ST_Distance_Sphere(property_units.location, pu_target.location)');
|
|
248
|
+
expect(sql.trim().endsWith('LIMIT 200')).toBe(true);
|
|
249
|
+
|
|
250
|
+
expect(params).toEqual([
|
|
251
|
+
unitIdParam,
|
|
252
|
+
unitIdParam,
|
|
253
|
+
0,
|
|
254
|
+
...ALLOWED_SALE_TYPES,
|
|
255
|
+
parsedDateRanges[0].start,
|
|
256
|
+
parsedDateRanges[0].end,
|
|
257
|
+
parsedDateRanges[1].start,
|
|
258
|
+
parsedDateRanges[1].end,
|
|
259
|
+
]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('supports derived joins with ON clauses referencing the alias', () => {
|
|
263
|
+
const config = buildParcelConfig();
|
|
264
|
+
|
|
265
|
+
const recentSales = derivedTable({
|
|
266
|
+
[C6C.SUBSELECT]: {
|
|
267
|
+
[C6C.SELECT]: [Parcel_Sales.PARCEL_ID],
|
|
268
|
+
[C6C.FROM]: Parcel_Sales.TABLE_NAME,
|
|
269
|
+
[C6C.WHERE]: { [Parcel_Sales.SALE_PRICE]: [C6C.GREATER_THAN, 50000] },
|
|
270
|
+
[C6C.LIMIT]: 1,
|
|
271
|
+
},
|
|
272
|
+
[C6C.AS]: 'recent_sales',
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const innerJoin: any = {
|
|
276
|
+
[recentSales as any]: {
|
|
277
|
+
'recent_sales.parcel_id': [C6C.EQUAL, Property_Units.PARCEL_ID],
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
282
|
+
[C6C.SELECT]: [Property_Units.UNIT_ID],
|
|
283
|
+
[C6C.JOIN]: { [C6C.INNER]: innerJoin },
|
|
284
|
+
[C6C.WHERE]: { [Property_Units.UNIT_ID]: [C6C.GREATER_THAN, 1] },
|
|
285
|
+
} as any, false);
|
|
286
|
+
|
|
287
|
+
const { sql, params } = qb.build(Property_Units.TABLE_NAME);
|
|
288
|
+
|
|
289
|
+
expect(sql).toMatch(/INNER JOIN \(\s+SELECT parcel_sales\.parcel_id/);
|
|
290
|
+
expect(sql).toContain('ON ((recent_sales.parcel_id) = property_units.parcel_id)');
|
|
291
|
+
expect(params[0]).toBe(50000);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('throws when referencing an unknown alias in SELECT expressions', () => {
|
|
295
|
+
const config = buildParcelConfig();
|
|
296
|
+
|
|
297
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
298
|
+
[C6C.SELECT]: [F(Property_Units.LOCATION, 'missing_alias')],
|
|
299
|
+
} as any, false);
|
|
300
|
+
|
|
301
|
+
expect(() => qb.build(Property_Units.TABLE_NAME)).toThrowError(/Unknown table or alias 'missing_alias'/);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('orders by distance to a literal ST_Point with numeric string coords', () => {
|
|
305
|
+
const config = buildParcelConfig();
|
|
306
|
+
|
|
307
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
308
|
+
[C6C.SELECT]: [Property_Units.UNIT_ID],
|
|
309
|
+
[C6C.PAGINATION]: {
|
|
310
|
+
[C6C.LIMIT]: 200,
|
|
311
|
+
[C6C.ORDER]: {
|
|
312
|
+
[C6C.ST_DISTANCE_SPHERE]: [
|
|
313
|
+
Property_Units.LOCATION,
|
|
314
|
+
[C6C.ST_POINT, ['-104.8967729', '39.3976764']],
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
} as any, false);
|
|
319
|
+
|
|
320
|
+
const { sql } = qb.build(Property_Units.TABLE_NAME);
|
|
321
|
+
expect(sql).toContain('ORDER BY ST_Distance_Sphere(property_units.location, ST_POINT(-104.8967729, 39.3976764))');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('orders by distance to ST_SRID(ST_Point(lng, lat), 4326)', () => {
|
|
325
|
+
const config = buildParcelConfig();
|
|
326
|
+
|
|
327
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
328
|
+
[C6C.SELECT]: [Property_Units.UNIT_ID],
|
|
329
|
+
[C6C.PAGINATION]: {
|
|
330
|
+
[C6C.LIMIT]: 50,
|
|
331
|
+
[C6C.ORDER]: {
|
|
332
|
+
[C6C.ST_DISTANCE_SPHERE]: [
|
|
333
|
+
Property_Units.LOCATION,
|
|
334
|
+
[C6C.ST_SRID, [C6C.ST_POINT, [10, 20]], 4326],
|
|
335
|
+
],
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
} as any, false);
|
|
339
|
+
|
|
340
|
+
const { sql } = qb.build(Property_Units.TABLE_NAME);
|
|
341
|
+
expect(sql).toContain('ORDER BY ST_Distance_Sphere(property_units.location, ST_SRID(ST_POINT(10, 20), 4326))');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('orders by distance using placeholders via PARAM inside nested ST_Point', () => {
|
|
345
|
+
const config = buildParcelConfig();
|
|
346
|
+
|
|
347
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
348
|
+
[C6C.SELECT]: [Property_Units.UNIT_ID],
|
|
349
|
+
[C6C.PAGINATION]: {
|
|
350
|
+
[C6C.LIMIT]: 25,
|
|
351
|
+
[C6C.ORDER]: {
|
|
352
|
+
[C6C.ST_DISTANCE_SPHERE]: [
|
|
353
|
+
Property_Units.LOCATION,
|
|
354
|
+
[C6C.ST_SRID, [C6C.ST_POINT, [[C6C.PARAM, 10], [C6C.PARAM, 20]]], 4326],
|
|
355
|
+
],
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
} as any, false);
|
|
359
|
+
|
|
360
|
+
const { sql, params } = qb.build(Property_Units.TABLE_NAME);
|
|
361
|
+
expect(sql).toContain('ORDER BY ST_Distance_Sphere(property_units.location, ST_SRID(ST_POINT(?, ?), 4326))');
|
|
362
|
+
expect(params.slice(-2)).toEqual([10, 20]);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('orders by distance using named params via PARAM inside nested ST_Point', () => {
|
|
366
|
+
const config = buildParcelConfig();
|
|
367
|
+
|
|
368
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
369
|
+
[C6C.SELECT]: [Property_Units.UNIT_ID],
|
|
370
|
+
[C6C.PAGINATION]: {
|
|
371
|
+
[C6C.LIMIT]: 25,
|
|
372
|
+
[C6C.ORDER]: {
|
|
373
|
+
[C6C.ST_DISTANCE_SPHERE]: [
|
|
374
|
+
Property_Units.LOCATION,
|
|
375
|
+
[C6C.ST_SRID, [C6C.ST_POINT, [[C6C.PARAM, 10], [C6C.PARAM, 20]]], 4326],
|
|
376
|
+
],
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
} as any, true);
|
|
380
|
+
|
|
381
|
+
const { sql, params } = qb.build(Property_Units.TABLE_NAME);
|
|
382
|
+
expect(sql).toMatch(/ST_SRID\(ST_POINT\(:param0, :param1\), 4326\)/);
|
|
383
|
+
expect(params).toEqual({ param0: 10, param1: 20 });
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('leaves normal table joins unaffected', () => {
|
|
387
|
+
const config = buildTestConfig();
|
|
388
|
+
|
|
389
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
390
|
+
[C6C.SELECT]: ['actor.actor_id'],
|
|
391
|
+
[C6C.JOIN]: {
|
|
392
|
+
[C6C.INNER]: {
|
|
393
|
+
'film_actor fa': { 'fa.actor_id': [C6C.EQUAL, 'actor.actor_id'] },
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
} as any, false);
|
|
397
|
+
|
|
398
|
+
const { sql } = qb.build('actor');
|
|
399
|
+
expect(sql).toContain('INNER JOIN `film_actor` AS `fa` ON ((fa.actor_id) = actor.actor_id)');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('supports scalar subselects in SELECT and WHERE clauses', () => {
|
|
403
|
+
const config = buildParcelConfig();
|
|
404
|
+
|
|
405
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
406
|
+
[C6C.SELECT]: [
|
|
407
|
+
Property_Units.UNIT_ID,
|
|
408
|
+
[
|
|
409
|
+
C6C.SUBSELECT,
|
|
410
|
+
{
|
|
411
|
+
[C6C.SELECT]: [[C6C.COUNT, Parcel_Sales.PARCEL_ID]],
|
|
412
|
+
[C6C.FROM]: Parcel_Sales.TABLE_NAME,
|
|
413
|
+
[C6C.WHERE]: { [Parcel_Sales.SALE_PRICE]: [C6C.GREATER_THAN, 0] },
|
|
414
|
+
},
|
|
415
|
+
C6C.AS,
|
|
416
|
+
'sale_count',
|
|
417
|
+
],
|
|
418
|
+
],
|
|
419
|
+
[C6C.WHERE]: {
|
|
420
|
+
[Property_Units.UNIT_ID]: [
|
|
421
|
+
C6C.IN,
|
|
422
|
+
[
|
|
423
|
+
C6C.SUBSELECT,
|
|
424
|
+
{
|
|
425
|
+
[C6C.SELECT]: [Parcel_Sales.PARCEL_ID],
|
|
426
|
+
[C6C.FROM]: Parcel_Sales.TABLE_NAME,
|
|
427
|
+
[C6C.WHERE]: { [Parcel_Sales.SALE_PRICE]: [C6C.GREATER_THAN, 5000] },
|
|
428
|
+
},
|
|
429
|
+
],
|
|
430
|
+
],
|
|
431
|
+
},
|
|
432
|
+
} as any, false);
|
|
433
|
+
|
|
434
|
+
const { sql, params } = qb.build(Property_Units.TABLE_NAME);
|
|
435
|
+
|
|
436
|
+
expect(sql).toContain('SELECT property_units.unit_id, (SELECT COUNT(parcel_sales.parcel_id)');
|
|
437
|
+
expect(sql).toContain('WHERE ( property_units.unit_id IN (SELECT parcel_sales.parcel_id');
|
|
438
|
+
expect(params).toContain(5000);
|
|
439
|
+
});
|
|
160
440
|
});
|
package/src/api/C6Constants.ts
CHANGED
|
@@ -62,6 +62,7 @@ export const C6Constants = {
|
|
|
62
62
|
INTERVAL: 'INTERVAL',
|
|
63
63
|
|
|
64
64
|
JOIN: 'JOIN',
|
|
65
|
+
FROM: 'FROM',
|
|
65
66
|
|
|
66
67
|
LEFT: 'LEFT',
|
|
67
68
|
LEFT_OUTER: 'LEFT_OUTER',
|
|
@@ -99,6 +100,8 @@ export const C6Constants = {
|
|
|
99
100
|
SECOND: 'SECOND',
|
|
100
101
|
SECOND_MICROSECOND: 'SECOND_MICROSECOND',
|
|
101
102
|
SELECT: 'SELECT',
|
|
103
|
+
SUBSELECT: 'SUBSELECT',
|
|
104
|
+
PARAM: 'PARAM',
|
|
102
105
|
|
|
103
106
|
// MySQL Spatial Functions
|
|
104
107
|
ST_AREA: 'ST_Area',
|
|
@@ -186,4 +189,4 @@ export const C6Constants = {
|
|
|
186
189
|
};
|
|
187
190
|
|
|
188
191
|
|
|
189
|
-
export const C6C = C6Constants;
|
|
192
|
+
export const C6C = C6Constants;
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import {Executor} from "../../executors/Executor";
|
|
2
2
|
import {OrmGenerics} from "../../types/ormGenerics";
|
|
3
|
+
import {C6C} from "../../C6Constants";
|
|
3
4
|
|
|
4
5
|
export abstract class AggregateBuilder<G extends OrmGenerics> extends Executor<G>{
|
|
5
6
|
protected selectAliases: Set<string> = new Set<string>();
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
// Overridden in ConditionBuilder where alias tracking is available.
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
10
|
+
protected assertValidIdentifier(_identifier: string, _context: string): void {
|
|
11
|
+
// no-op placeholder for subclasses that do not implement alias validation
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
buildAggregateField(field: string | any[], params?: any[] | Record<string, any>): string {
|
|
8
15
|
if (typeof field === 'string') {
|
|
16
|
+
this.assertValidIdentifier(field, 'SELECT field');
|
|
9
17
|
return field;
|
|
10
18
|
}
|
|
11
19
|
|
|
@@ -13,6 +21,19 @@ export abstract class AggregateBuilder<G extends OrmGenerics> extends Executor<G
|
|
|
13
21
|
throw new Error('Invalid SELECT field entry');
|
|
14
22
|
}
|
|
15
23
|
|
|
24
|
+
// If the array represents a tuple/literal list (e.g., [lng, lat]) rather than a
|
|
25
|
+
// function call like [FN, ...args], serialize the list as a comma-separated
|
|
26
|
+
// literal sequence so parent calls (like ORDER BY FN(<here>)) can embed it.
|
|
27
|
+
const isNumericString = (s: string) => /^-?\d+(?:\.\d+)?$/.test(String(s).trim());
|
|
28
|
+
if (typeof field[0] !== 'string' || isNumericString(field[0])) {
|
|
29
|
+
return field
|
|
30
|
+
.map((arg) => {
|
|
31
|
+
if (Array.isArray(arg)) return this.buildAggregateField(arg, params);
|
|
32
|
+
return String(arg);
|
|
33
|
+
})
|
|
34
|
+
.join(', ');
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
let [fn, ...args] = field;
|
|
17
38
|
let alias: string | undefined;
|
|
18
39
|
|
|
@@ -22,8 +43,55 @@ export abstract class AggregateBuilder<G extends OrmGenerics> extends Executor<G
|
|
|
22
43
|
}
|
|
23
44
|
|
|
24
45
|
const F = String(fn).toUpperCase();
|
|
46
|
+
|
|
47
|
+
// Parameter placeholder helper: [C6C.PARAM, value]
|
|
48
|
+
if (F === C6C.PARAM) {
|
|
49
|
+
if (!params) {
|
|
50
|
+
throw new Error('PARAM requires parameter tracking.');
|
|
51
|
+
}
|
|
52
|
+
const value = args[0];
|
|
53
|
+
// Use empty column context; ORDER/SELECT literals have no column typing.
|
|
54
|
+
// @ts-ignore addParam is provided by ConditionBuilder in our hierarchy.
|
|
55
|
+
return this.addParam(params, '', value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (F === C6C.SUBSELECT) {
|
|
59
|
+
if (!params) {
|
|
60
|
+
throw new Error('Scalar subselects in SELECT require parameter tracking.');
|
|
61
|
+
}
|
|
62
|
+
const subRequest = args[0];
|
|
63
|
+
const subSql = (this as any).buildScalarSubSelect?.(subRequest, params);
|
|
64
|
+
if (!subSql) {
|
|
65
|
+
throw new Error('Failed to build scalar subselect.');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let expr = subSql;
|
|
69
|
+
if (alias) {
|
|
70
|
+
this.selectAliases.add(alias);
|
|
71
|
+
expr += ` AS ${alias}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.config.verbose && console.log(`[SELECT] ${expr}`);
|
|
75
|
+
|
|
76
|
+
return expr;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const identifierPathRegex = /^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/;
|
|
80
|
+
|
|
25
81
|
const argList = args
|
|
26
|
-
.map(arg =>
|
|
82
|
+
.map(arg => {
|
|
83
|
+
if (Array.isArray(arg)) return this.buildAggregateField(arg, params);
|
|
84
|
+
if (typeof arg === 'string') {
|
|
85
|
+
if (identifierPathRegex.test(arg)) {
|
|
86
|
+
this.assertValidIdentifier(arg, 'SELECT expression');
|
|
87
|
+
return arg;
|
|
88
|
+
}
|
|
89
|
+
// Treat numeric-looking strings as literals, not identifier paths
|
|
90
|
+
if (isNumericString(arg)) return arg;
|
|
91
|
+
return arg;
|
|
92
|
+
}
|
|
93
|
+
return String(arg);
|
|
94
|
+
})
|
|
27
95
|
.join(', ');
|
|
28
96
|
|
|
29
97
|
let expr: string;
|
|
@@ -3,31 +3,57 @@ import {OrmGenerics} from "../../types/ormGenerics";
|
|
|
3
3
|
import {DetermineResponseDataType} from "../../types/ormInterfaces";
|
|
4
4
|
import {convertHexIfBinary, SqlBuilderResult} from "../utils/sqlUtils";
|
|
5
5
|
import {AggregateBuilder} from "./AggregateBuilder";
|
|
6
|
+
import {isDerivedTableKey} from "../queryHelpers";
|
|
6
7
|
|
|
7
8
|
export abstract class ConditionBuilder<
|
|
8
9
|
G extends OrmGenerics
|
|
9
10
|
> extends AggregateBuilder<G> {
|
|
10
11
|
|
|
11
12
|
protected aliasMap: Record<string, string> = {};
|
|
13
|
+
protected derivedAliases: Set<string> = new Set<string>();
|
|
12
14
|
|
|
13
15
|
protected initAlias(baseTable: string, joins?: any): void {
|
|
14
16
|
this.aliasMap = { [baseTable]: baseTable };
|
|
17
|
+
this.derivedAliases = new Set<string>();
|
|
15
18
|
|
|
16
19
|
if (!joins) return;
|
|
17
20
|
|
|
18
21
|
for (const joinType in joins) {
|
|
19
22
|
for (const raw in joins[joinType]) {
|
|
20
|
-
const [table, alias] = raw.split(
|
|
21
|
-
|
|
23
|
+
const [table, alias] = raw.trim().split(/\s+/, 2);
|
|
24
|
+
if (!table) continue;
|
|
25
|
+
this.registerAlias(alias || table, table);
|
|
22
26
|
}
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
protected registerAlias(alias: string, table: string): void {
|
|
31
|
+
this.aliasMap[alias] = table;
|
|
32
|
+
if (isDerivedTableKey(table)) {
|
|
33
|
+
this.derivedAliases.add(alias);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
protected assertValidIdentifier(identifier: string, context: string): void {
|
|
38
|
+
if (typeof identifier !== 'string') return;
|
|
39
|
+
if (!identifier.includes('.')) return;
|
|
40
|
+
|
|
41
|
+
const [alias] = identifier.split('.', 2);
|
|
42
|
+
if (!(alias in this.aliasMap)) {
|
|
43
|
+
throw new Error(`Unknown table or alias '${alias}' referenced in ${context}: '${identifier}'.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
26
47
|
protected isColumnRef(ref: string): boolean {
|
|
27
48
|
if (typeof ref !== 'string' || !ref.includes('.')) return false;
|
|
28
49
|
|
|
29
50
|
const [prefix, column] = ref.split('.', 2);
|
|
30
51
|
const tableName = this.aliasMap[prefix] || prefix;
|
|
52
|
+
|
|
53
|
+
if (isDerivedTableKey(tableName) || this.derivedAliases.has(prefix)) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
31
57
|
const table = this.config.C6?.TABLES?.[tableName];
|
|
32
58
|
if (!table) return false;
|
|
33
59
|
|
|
@@ -77,6 +103,9 @@ export abstract class ConditionBuilder<
|
|
|
77
103
|
}
|
|
78
104
|
const [prefix, column] = val.split('.');
|
|
79
105
|
const tableName = this.aliasMap[prefix] ?? prefix;
|
|
106
|
+
if (isDerivedTableKey(tableName) || this.derivedAliases.has(prefix)) {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
80
109
|
const table = this.config.C6?.TABLES?.[tableName];
|
|
81
110
|
if (!table || !table.COLUMNS) return false;
|
|
82
111
|
|
|
@@ -130,6 +159,28 @@ export abstract class ConditionBuilder<
|
|
|
130
159
|
// Normalize common variants
|
|
131
160
|
const valueNorm = (value === C6C.NULL) ? null : value;
|
|
132
161
|
const displayOp = typeof op === 'string' ? op.replace('_', ' ') : op;
|
|
162
|
+
|
|
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];
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const rightSubSelectPayload = extractSubSelect(valueNorm);
|
|
174
|
+
const buildSubSelect = (payload: any): string | undefined => {
|
|
175
|
+
if (!payload) return undefined;
|
|
176
|
+
const builder = (this as any).buildScalarSubSelect;
|
|
177
|
+
if (typeof builder !== 'function') {
|
|
178
|
+
throw new Error('Scalar subselect handling requires JoinBuilder context.');
|
|
179
|
+
}
|
|
180
|
+
return builder.call(this, payload, params);
|
|
181
|
+
};
|
|
182
|
+
const rightSubSelectSql = buildSubSelect(rightSubSelectPayload);
|
|
183
|
+
|
|
133
184
|
// Support function-based expressions like [C6C.ST_DISTANCE_SPHERE, col1, col2]
|
|
134
185
|
if (
|
|
135
186
|
typeof column === 'string' &&
|
|
@@ -160,7 +211,7 @@ export abstract class ConditionBuilder<
|
|
|
160
211
|
const leftIsRef = this.isTableReference(column);
|
|
161
212
|
const rightIsCol = typeof value === 'string' && this.isColumnRef(value);
|
|
162
213
|
|
|
163
|
-
if (!leftIsCol && !leftIsRef && !rightIsCol) {
|
|
214
|
+
if (!leftIsCol && !leftIsRef && !rightIsCol && !rightSubSelectSql) {
|
|
164
215
|
throw new Error(`Potential SQL injection detected: '${column} ${op} ${value}'`);
|
|
165
216
|
}
|
|
166
217
|
|
|
@@ -198,6 +249,13 @@ export abstract class ConditionBuilder<
|
|
|
198
249
|
}
|
|
199
250
|
|
|
200
251
|
if ((op === C6C.IN || op === C6C.NOT_IN) && Array.isArray(value)) {
|
|
252
|
+
if (rightSubSelectSql) {
|
|
253
|
+
if (!leftIsRef) {
|
|
254
|
+
throw new Error(`IN operator requires a table reference as the left operand. Column '${column}' is not a valid table reference.`);
|
|
255
|
+
}
|
|
256
|
+
const normalized = op.replace('_', ' ');
|
|
257
|
+
return `( ${column} ${normalized} ${rightSubSelectSql} )`;
|
|
258
|
+
}
|
|
201
259
|
const placeholders = value.map(v =>
|
|
202
260
|
this.isColumnRef(v) ? v : this.addParam(params, column, v)
|
|
203
261
|
).join(', ');
|
|
@@ -219,12 +277,16 @@ export abstract class ConditionBuilder<
|
|
|
219
277
|
return `(${column}) ${op.replace('_', ' ')} ${this.addParam(params, column, start)} AND ${this.addParam(params, column, end)}`;
|
|
220
278
|
}
|
|
221
279
|
|
|
222
|
-
const rightIsRef: boolean = this.isTableReference(value);
|
|
280
|
+
const rightIsRef: boolean = rightSubSelectSql ? false : this.isTableReference(value);
|
|
223
281
|
|
|
224
282
|
if (leftIsRef && rightIsRef) {
|
|
225
283
|
return `(${column}) ${displayOp} ${value}`;
|
|
226
284
|
}
|
|
227
285
|
|
|
286
|
+
if (leftIsRef && rightSubSelectSql) {
|
|
287
|
+
return `(${column}) ${displayOp} ${rightSubSelectSql}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
228
290
|
if (leftIsRef && !rightIsRef) {
|
|
229
291
|
return `(${column}) ${displayOp} ${this.addParam(params, column, valueNorm)}`;
|
|
230
292
|
}
|