@carbonorm/carbonnode 3.8.3 → 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -1
- package/dist/api/C6Constants.d.ts +4 -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 +384 -118
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +382 -119
- 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 +225 -1
- package/src/api/C6Constants.ts +2 -0
- package/src/api/orm/builders/AggregateBuilder.ts +39 -2
- package/src/api/orm/builders/ConditionBuilder.ts +83 -9
- package/src/api/orm/builders/JoinBuilder.ts +117 -6
- package/src/api/orm/builders/PaginationBuilder.ts +12 -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.
|
|
@@ -131,4 +152,207 @@ describe('SQL Builders - Complex SELECTs', () => {
|
|
|
131
152
|
expect(sql).toMatch(/MATCH\(actor\.first_name\) AGAINST\(\? IN BOOLEAN MODE\)/);
|
|
132
153
|
expect(params).toEqual(['alpha beta']);
|
|
133
154
|
});
|
|
155
|
+
|
|
156
|
+
it('supports IS NOT NULL via object mapping syntax', () => {
|
|
157
|
+
const config = buildTestConfig();
|
|
158
|
+
|
|
159
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
160
|
+
SELECT: ['actor.actor_id'],
|
|
161
|
+
WHERE: { 'actor.last_name': [C6C.IS_NOT, C6C.NULL] }
|
|
162
|
+
} as any, false);
|
|
163
|
+
|
|
164
|
+
const { sql, params } = qb.build('actor');
|
|
165
|
+
expect(sql).toMatch(/\(actor\.last_name\) IS NOT \?/);
|
|
166
|
+
expect(params).toEqual([null]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('supports IS NOT NULL via numeric-key triple array syntax', () => {
|
|
170
|
+
const config = buildTestConfig();
|
|
171
|
+
|
|
172
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
173
|
+
SELECT: ['actor.actor_id'],
|
|
174
|
+
WHERE: { 0: ['actor.last_name', C6C.IS_NOT, C6C.NULL] }
|
|
175
|
+
} as any, false);
|
|
176
|
+
|
|
177
|
+
const { sql, params } = qb.build('actor');
|
|
178
|
+
expect(sql).toMatch(/\(actor\.last_name\) IS NOT \?/);
|
|
179
|
+
expect(params).toEqual([null]);
|
|
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('leaves normal table joins unaffected', () => {
|
|
305
|
+
const config = buildTestConfig();
|
|
306
|
+
|
|
307
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
308
|
+
[C6C.SELECT]: ['actor.actor_id'],
|
|
309
|
+
[C6C.JOIN]: {
|
|
310
|
+
[C6C.INNER]: {
|
|
311
|
+
'film_actor fa': { 'fa.actor_id': [C6C.EQUAL, 'actor.actor_id'] },
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
} as any, false);
|
|
315
|
+
|
|
316
|
+
const { sql } = qb.build('actor');
|
|
317
|
+
expect(sql).toContain('INNER JOIN `film_actor` AS `fa` ON ((fa.actor_id) = actor.actor_id)');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('supports scalar subselects in SELECT and WHERE clauses', () => {
|
|
321
|
+
const config = buildParcelConfig();
|
|
322
|
+
|
|
323
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
324
|
+
[C6C.SELECT]: [
|
|
325
|
+
Property_Units.UNIT_ID,
|
|
326
|
+
[
|
|
327
|
+
C6C.SUBSELECT,
|
|
328
|
+
{
|
|
329
|
+
[C6C.SELECT]: [[C6C.COUNT, Parcel_Sales.PARCEL_ID]],
|
|
330
|
+
[C6C.FROM]: Parcel_Sales.TABLE_NAME,
|
|
331
|
+
[C6C.WHERE]: { [Parcel_Sales.SALE_PRICE]: [C6C.GREATER_THAN, 0] },
|
|
332
|
+
},
|
|
333
|
+
C6C.AS,
|
|
334
|
+
'sale_count',
|
|
335
|
+
],
|
|
336
|
+
],
|
|
337
|
+
[C6C.WHERE]: {
|
|
338
|
+
[Property_Units.UNIT_ID]: [
|
|
339
|
+
C6C.IN,
|
|
340
|
+
[
|
|
341
|
+
C6C.SUBSELECT,
|
|
342
|
+
{
|
|
343
|
+
[C6C.SELECT]: [Parcel_Sales.PARCEL_ID],
|
|
344
|
+
[C6C.FROM]: Parcel_Sales.TABLE_NAME,
|
|
345
|
+
[C6C.WHERE]: { [Parcel_Sales.SALE_PRICE]: [C6C.GREATER_THAN, 5000] },
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
],
|
|
349
|
+
},
|
|
350
|
+
} as any, false);
|
|
351
|
+
|
|
352
|
+
const { sql, params } = qb.build(Property_Units.TABLE_NAME);
|
|
353
|
+
|
|
354
|
+
expect(sql).toContain('SELECT property_units.unit_id, (SELECT COUNT(parcel_sales.parcel_id)');
|
|
355
|
+
expect(sql).toContain('WHERE ( property_units.unit_id IN (SELECT parcel_sales.parcel_id');
|
|
356
|
+
expect(params).toContain(5000);
|
|
357
|
+
});
|
|
134
358
|
});
|
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,7 @@ export const C6Constants = {
|
|
|
99
100
|
SECOND: 'SECOND',
|
|
100
101
|
SECOND_MICROSECOND: 'SECOND_MICROSECOND',
|
|
101
102
|
SELECT: 'SELECT',
|
|
103
|
+
SUBSELECT: 'SUBSELECT',
|
|
102
104
|
|
|
103
105
|
// MySQL Spatial Functions
|
|
104
106
|
ST_AREA: 'ST_Area',
|
|
@@ -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
|
|
|
@@ -22,8 +30,37 @@ export abstract class AggregateBuilder<G extends OrmGenerics> extends Executor<G
|
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
const F = String(fn).toUpperCase();
|
|
33
|
+
|
|
34
|
+
if (F === C6C.SUBSELECT) {
|
|
35
|
+
if (!params) {
|
|
36
|
+
throw new Error('Scalar subselects in SELECT require parameter tracking.');
|
|
37
|
+
}
|
|
38
|
+
const subRequest = args[0];
|
|
39
|
+
const subSql = (this as any).buildScalarSubSelect?.(subRequest, params);
|
|
40
|
+
if (!subSql) {
|
|
41
|
+
throw new Error('Failed to build scalar subselect.');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let expr = subSql;
|
|
45
|
+
if (alias) {
|
|
46
|
+
this.selectAliases.add(alias);
|
|
47
|
+
expr += ` AS ${alias}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.config.verbose && console.log(`[SELECT] ${expr}`);
|
|
51
|
+
|
|
52
|
+
return expr;
|
|
53
|
+
}
|
|
54
|
+
|
|
25
55
|
const argList = args
|
|
26
|
-
.map(arg =>
|
|
56
|
+
.map(arg => {
|
|
57
|
+
if (Array.isArray(arg)) return this.buildAggregateField(arg, params);
|
|
58
|
+
if (typeof arg === 'string') {
|
|
59
|
+
this.assertValidIdentifier(arg, 'SELECT expression');
|
|
60
|
+
return arg;
|
|
61
|
+
}
|
|
62
|
+
return String(arg);
|
|
63
|
+
})
|
|
27
64
|
.join(', ');
|
|
28
65
|
|
|
29
66
|
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
|
|
|
@@ -127,6 +156,31 @@ export abstract class ConditionBuilder<
|
|
|
127
156
|
const booleanOperator = andMode ? 'AND' : 'OR';
|
|
128
157
|
|
|
129
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;
|
|
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
|
+
|
|
130
184
|
// Support function-based expressions like [C6C.ST_DISTANCE_SPHERE, col1, col2]
|
|
131
185
|
if (
|
|
132
186
|
typeof column === 'string' &&
|
|
@@ -157,7 +211,7 @@ export abstract class ConditionBuilder<
|
|
|
157
211
|
const leftIsRef = this.isTableReference(column);
|
|
158
212
|
const rightIsCol = typeof value === 'string' && this.isColumnRef(value);
|
|
159
213
|
|
|
160
|
-
if (!leftIsCol && !leftIsRef && !rightIsCol) {
|
|
214
|
+
if (!leftIsCol && !leftIsRef && !rightIsCol && !rightSubSelectSql) {
|
|
161
215
|
throw new Error(`Potential SQL injection detected: '${column} ${op} ${value}'`);
|
|
162
216
|
}
|
|
163
217
|
|
|
@@ -195,6 +249,13 @@ export abstract class ConditionBuilder<
|
|
|
195
249
|
}
|
|
196
250
|
|
|
197
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
|
+
}
|
|
198
259
|
const placeholders = value.map(v =>
|
|
199
260
|
this.isColumnRef(v) ? v : this.addParam(params, column, v)
|
|
200
261
|
).join(', ');
|
|
@@ -216,18 +277,22 @@ export abstract class ConditionBuilder<
|
|
|
216
277
|
return `(${column}) ${op.replace('_', ' ')} ${this.addParam(params, column, start)} AND ${this.addParam(params, column, end)}`;
|
|
217
278
|
}
|
|
218
279
|
|
|
219
|
-
const rightIsRef: boolean = this.isTableReference(value);
|
|
280
|
+
const rightIsRef: boolean = rightSubSelectSql ? false : this.isTableReference(value);
|
|
220
281
|
|
|
221
282
|
if (leftIsRef && rightIsRef) {
|
|
222
|
-
return `(${column}) ${
|
|
283
|
+
return `(${column}) ${displayOp} ${value}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (leftIsRef && rightSubSelectSql) {
|
|
287
|
+
return `(${column}) ${displayOp} ${rightSubSelectSql}`;
|
|
223
288
|
}
|
|
224
289
|
|
|
225
290
|
if (leftIsRef && !rightIsRef) {
|
|
226
|
-
return `(${column}) ${
|
|
291
|
+
return `(${column}) ${displayOp} ${this.addParam(params, column, valueNorm)}`;
|
|
227
292
|
}
|
|
228
293
|
|
|
229
294
|
if (rightIsRef) {
|
|
230
|
-
return `(${this.addParam(params, column, column)}) ${
|
|
295
|
+
return `(${this.addParam(params, column, column)}) ${displayOp} ${value}`;
|
|
231
296
|
}
|
|
232
297
|
|
|
233
298
|
throw new Error(`Neither operand appears to be a table reference (${column}) or (${value})`);
|
|
@@ -271,9 +336,18 @@ export abstract class ConditionBuilder<
|
|
|
271
336
|
};
|
|
272
337
|
|
|
273
338
|
if (Array.isArray(set)) {
|
|
274
|
-
|
|
275
|
-
|
|
339
|
+
// Detect a single condition triple: [column, op, value]
|
|
340
|
+
if (set.length === 3 && typeof set[0] === 'string' && typeof set[1] === 'string') {
|
|
341
|
+
const [column, rawOp, rawVal] = set as [string, string, any];
|
|
342
|
+
const op = rawOp;
|
|
343
|
+
const value = rawVal === C6C.NULL ? null : rawVal;
|
|
344
|
+
const sub = addCondition(column, op, value);
|
|
276
345
|
if (sub) parts.push(sub);
|
|
346
|
+
} else {
|
|
347
|
+
for (const item of set) {
|
|
348
|
+
const sub = this.buildBooleanJoinedConditions(item, false, params);
|
|
349
|
+
if (sub) parts.push(sub);
|
|
350
|
+
}
|
|
277
351
|
}
|
|
278
352
|
} else if (typeof set === 'object' && set !== null) {
|
|
279
353
|
const sub = buildFromObject(set, andMode);
|
|
@@ -1,20 +1,88 @@
|
|
|
1
1
|
import {OrmGenerics} from "../../types/ormGenerics";
|
|
2
2
|
import {ConditionBuilder} from "./ConditionBuilder";
|
|
3
|
+
import {C6C} from "../../C6Constants";
|
|
4
|
+
import {resolveDerivedTable, isDerivedTableKey} from "../queryHelpers";
|
|
3
5
|
|
|
4
6
|
export abstract class JoinBuilder<G extends OrmGenerics> extends ConditionBuilder<G>{
|
|
5
7
|
|
|
8
|
+
protected createSelectBuilder(
|
|
9
|
+
_request: any
|
|
10
|
+
): { build(table: string, isSubSelect: boolean): { sql: string; params: any[] | Record<string, any> } } {
|
|
11
|
+
throw new Error('Subclasses must implement createSelectBuilder to support derived table serialization.');
|
|
12
|
+
}
|
|
13
|
+
|
|
6
14
|
buildJoinClauses(joinArgs: any, params: any[] | Record<string, any>): string {
|
|
7
15
|
let sql = '';
|
|
8
16
|
|
|
9
17
|
for (const joinType in joinArgs) {
|
|
10
18
|
const joinKind = joinType.replace('_', ' ').toUpperCase();
|
|
19
|
+
const entries: Array<[any, any]> = [];
|
|
20
|
+
const joinSection = joinArgs[joinType];
|
|
21
|
+
|
|
22
|
+
if (joinSection instanceof Map) {
|
|
23
|
+
joinSection.forEach((value, key) => {
|
|
24
|
+
entries.push([key, value]);
|
|
25
|
+
});
|
|
26
|
+
} else {
|
|
27
|
+
for (const raw in joinSection) {
|
|
28
|
+
entries.push([raw, joinSection[raw]]);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const [rawKey, conditions] of entries) {
|
|
33
|
+
const raw = typeof rawKey === 'string' ? rawKey : String(rawKey);
|
|
34
|
+
const [table, aliasCandidate] = raw.trim().split(/\s+/, 2);
|
|
35
|
+
if (!table) continue;
|
|
36
|
+
|
|
37
|
+
if (isDerivedTableKey(table)) {
|
|
38
|
+
const derived = resolveDerivedTable(table);
|
|
39
|
+
if (!derived) {
|
|
40
|
+
throw new Error(`Derived table '${table}' was not registered. Wrap the object with derivedTable(...) before using it in JOIN.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const configuredAliasRaw = derived[C6C.AS];
|
|
44
|
+
const configuredAlias = typeof configuredAliasRaw === 'string' ? configuredAliasRaw.trim() : '';
|
|
45
|
+
const alias = (aliasCandidate ?? configuredAlias).trim();
|
|
46
|
+
|
|
47
|
+
if (!alias) {
|
|
48
|
+
throw new Error('Derived tables require an alias via C6C.AS.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.registerAlias(alias, table);
|
|
52
|
+
|
|
53
|
+
const subRequest = derived[C6C.SUBSELECT];
|
|
54
|
+
if (!subRequest || typeof subRequest !== 'object') {
|
|
55
|
+
throw new Error('Derived tables must include a C6C.SUBSELECT payload.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fromTable = subRequest[C6C.FROM];
|
|
59
|
+
if (typeof fromTable !== 'string' || fromTable.trim() === '') {
|
|
60
|
+
throw new Error('Derived table subselects require a base table defined with C6C.FROM.');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const subBuilder = this.createSelectBuilder(subRequest as any);
|
|
64
|
+
const { sql: subSql, params: subParams } = subBuilder.build(fromTable, true);
|
|
65
|
+
const normalizedSql = this.integrateSubSelectParams(subSql, subParams, params);
|
|
11
66
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
67
|
+
const formatted = normalizedSql.trim().split('\n').map(line => ` ${line}`).join('\n');
|
|
68
|
+
const joinSql = `(\n${formatted}\n) AS \`${alias}\``;
|
|
69
|
+
const onClause = this.buildBooleanJoinedConditions(conditions, true, params);
|
|
70
|
+
sql += ` ${joinKind} JOIN ${joinSql}`;
|
|
71
|
+
if (onClause) {
|
|
72
|
+
sql += ` ON ${onClause}`;
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
const alias = aliasCandidate;
|
|
76
|
+
if (alias) {
|
|
77
|
+
this.registerAlias(alias, table);
|
|
78
|
+
}
|
|
79
|
+
const joinSql = alias ? `\`${table}\` AS \`${alias}\`` : `\`${table}\``;
|
|
80
|
+
const onClause = this.buildBooleanJoinedConditions(conditions, true, params);
|
|
81
|
+
sql += ` ${joinKind} JOIN ${joinSql}`;
|
|
82
|
+
if (onClause) {
|
|
83
|
+
sql += ` ON ${onClause}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
18
86
|
}
|
|
19
87
|
}
|
|
20
88
|
|
|
@@ -22,4 +90,47 @@ export abstract class JoinBuilder<G extends OrmGenerics> extends ConditionBuilde
|
|
|
22
90
|
|
|
23
91
|
return sql;
|
|
24
92
|
}
|
|
93
|
+
|
|
94
|
+
protected integrateSubSelectParams(
|
|
95
|
+
subSql: string,
|
|
96
|
+
subParams: any[] | Record<string, any>,
|
|
97
|
+
target: any[] | Record<string, any>
|
|
98
|
+
): string {
|
|
99
|
+
if (!subParams) return subSql;
|
|
100
|
+
|
|
101
|
+
if (this.useNamedParams) {
|
|
102
|
+
let normalized = subSql;
|
|
103
|
+
const extras = subParams as Record<string, any>;
|
|
104
|
+
for (const key of Object.keys(extras)) {
|
|
105
|
+
const placeholder = this.addParam(target, '', extras[key]);
|
|
106
|
+
const original = `:${key}`;
|
|
107
|
+
if (original !== placeholder) {
|
|
108
|
+
normalized = normalized.split(original).join(placeholder);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return normalized;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
(target as any[]).push(...(subParams as any[]));
|
|
115
|
+
return subSql;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
protected buildScalarSubSelect(
|
|
119
|
+
subRequest: any,
|
|
120
|
+
params: any[] | Record<string, any>
|
|
121
|
+
): string {
|
|
122
|
+
if (!subRequest || typeof subRequest !== 'object') {
|
|
123
|
+
throw new Error('Scalar subselect requires a C6C.SUBSELECT object payload.');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const fromTable = subRequest[C6C.FROM];
|
|
127
|
+
if (typeof fromTable !== 'string' || fromTable.trim() === '') {
|
|
128
|
+
throw new Error('Scalar subselects require a base table specified with C6C.FROM.');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const subBuilder = this.createSelectBuilder(subRequest as any);
|
|
132
|
+
const { sql: subSql, params: subParams } = subBuilder.build(fromTable, true);
|
|
133
|
+
const normalized = this.integrateSubSelectParams(subSql, subParams, params).trim();
|
|
134
|
+
return `(${normalized})`;
|
|
135
|
+
}
|
|
25
136
|
}
|
|
@@ -17,7 +17,7 @@ export abstract class PaginationBuilder<G extends OrmGenerics> extends JoinBuild
|
|
|
17
17
|
* }
|
|
18
18
|
* ```
|
|
19
19
|
*/
|
|
20
|
-
buildPaginationClause(pagination: any): string {
|
|
20
|
+
buildPaginationClause(pagination: any, params?: any[] | Record<string, any>): string {
|
|
21
21
|
let sql = "";
|
|
22
22
|
|
|
23
23
|
/* -------- ORDER BY -------- */
|
|
@@ -25,10 +25,20 @@ export abstract class PaginationBuilder<G extends OrmGenerics> extends JoinBuild
|
|
|
25
25
|
const orderParts: string[] = [];
|
|
26
26
|
|
|
27
27
|
for (const [key, val] of Object.entries(pagination[C6Constants.ORDER])) {
|
|
28
|
+
if (typeof key === 'string' && key.includes('.')) {
|
|
29
|
+
this.assertValidIdentifier(key, 'ORDER BY');
|
|
30
|
+
}
|
|
28
31
|
// FUNCTION CALL: val is an array of args
|
|
29
32
|
if (Array.isArray(val)) {
|
|
30
33
|
const args = val
|
|
31
|
-
.map((arg) =>
|
|
34
|
+
.map((arg) => {
|
|
35
|
+
if (Array.isArray(arg)) return this.buildAggregateField(arg, params);
|
|
36
|
+
if (typeof arg === 'string' && arg.includes('.')) {
|
|
37
|
+
this.assertValidIdentifier(arg, 'ORDER BY argument');
|
|
38
|
+
return arg;
|
|
39
|
+
}
|
|
40
|
+
return String(arg);
|
|
41
|
+
})
|
|
32
42
|
.join(", ");
|
|
33
43
|
orderParts.push(`${key}(${args})`);
|
|
34
44
|
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { OrmGenerics } from "../../types/ormGenerics";
|
|
2
2
|
import { SqlBuilderResult } from "../utils/sqlUtils";
|
|
3
3
|
import { JoinBuilder } from "../builders/JoinBuilder";
|
|
4
|
+
import { SelectQueryBuilder } from "./SelectQueryBuilder";
|
|
4
5
|
|
|
5
6
|
export class DeleteQueryBuilder<G extends OrmGenerics> extends JoinBuilder<G> {
|
|
7
|
+
protected createSelectBuilder(request: any) {
|
|
8
|
+
return new SelectQueryBuilder(this.config as any, request, this.useNamedParams);
|
|
9
|
+
}
|
|
10
|
+
|
|
6
11
|
build(
|
|
7
12
|
table: string
|
|
8
13
|
): SqlBuilderResult {
|