@carbonorm/carbonnode 3.9.4 → 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 +473 -244
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +473 -244
- 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.complex.test.ts +23 -0
- package/src/__tests__/sqlBuilders.expressions.test.ts +163 -0
- package/src/api/C6Constants.ts +2 -0
- package/src/api/orm/builders/AggregateBuilder.ts +17 -1
- package/src/api/orm/builders/ConditionBuilder.ts +479 -224
- package/src/api/orm/builders/JoinBuilder.ts +8 -5
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { C6C } from '../api/C6Constants';
|
|
3
|
+
import { SelectQueryBuilder } from '../api/orm/queries/SelectQueryBuilder';
|
|
4
|
+
import { buildParcelConfig } from './fixtures/c6.fixture';
|
|
5
|
+
|
|
6
|
+
const Property_Units = {
|
|
7
|
+
TABLE_NAME: 'property_units',
|
|
8
|
+
LOCATION: 'property_units.location',
|
|
9
|
+
PARCEL_ID: 'property_units.parcel_id',
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
const Parcel_Sales = {
|
|
13
|
+
TABLE_NAME: 'parcel_sales',
|
|
14
|
+
PARCEL_ID: 'parcel_sales.parcel_id',
|
|
15
|
+
SALE_DATE: 'parcel_sales.sale_date',
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
const Parcel_Building_Details = {
|
|
19
|
+
TABLE_NAME: 'parcel_building_details',
|
|
20
|
+
PARCEL_ID: 'parcel_building_details.parcel_id',
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
describe('Explicit SQL expression grammar', () => {
|
|
24
|
+
it('supports operator-rooted nested function expressions with explicit OR grouping', () => {
|
|
25
|
+
const config = buildParcelConfig();
|
|
26
|
+
|
|
27
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
28
|
+
[C6C.SELECT]: [Property_Units.PARCEL_ID],
|
|
29
|
+
[C6C.WHERE]: {
|
|
30
|
+
[C6C.OR]: [
|
|
31
|
+
{
|
|
32
|
+
[C6C.LESS_THAN_OR_EQUAL_TO]: [
|
|
33
|
+
{
|
|
34
|
+
[C6C.ST_DISTANCE_SPHERE]: [
|
|
35
|
+
Property_Units.LOCATION,
|
|
36
|
+
{ [C6C.ST_GEOMFROMTEXT]: ['POINT(39.4972468 -105.0403593)', 4326] },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
5000,
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
[C6C.GREATER_THAN]: [
|
|
44
|
+
{
|
|
45
|
+
[C6C.ST_AREA]: [
|
|
46
|
+
{ [C6C.ST_GEOMFROMTEXT]: ['POLYGON((0 0,1 0,1 1,0 1,0 0))', 4326] },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
10000,
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
} as any, false);
|
|
55
|
+
|
|
56
|
+
const { sql, params } = qb.build(Property_Units.TABLE_NAME);
|
|
57
|
+
|
|
58
|
+
expect(sql).toContain('WHERE');
|
|
59
|
+
expect(sql).toMatch(/ST_DISTANCE_SPHERE\(property_units.location, ST_GEOMFROMTEXT\('/);
|
|
60
|
+
expect(sql).toMatch(/ST_AREA\(ST_GEOMFROMTEXT\('/);
|
|
61
|
+
expect(sql).toMatch(/<= \?/);
|
|
62
|
+
expect(sql).toMatch(/> \?/);
|
|
63
|
+
expect(sql).toContain('OR');
|
|
64
|
+
expect(params).toEqual([5000, 10000]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('treats safe raw function expressions as SQL expressions', () => {
|
|
68
|
+
const config = buildParcelConfig();
|
|
69
|
+
|
|
70
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
71
|
+
[C6C.SELECT]: [Property_Units.PARCEL_ID],
|
|
72
|
+
[C6C.WHERE]: {
|
|
73
|
+
0: [
|
|
74
|
+
[
|
|
75
|
+
"ST_Distance_Sphere(property_units.location, ST_GeomFromText('POINT(39.4972468 -105.0403593)', 4326))",
|
|
76
|
+
C6C.LESS_THAN_OR_EQUAL_TO,
|
|
77
|
+
5000,
|
|
78
|
+
],
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
} as any, false);
|
|
82
|
+
|
|
83
|
+
const { sql, params } = qb.build(Property_Units.TABLE_NAME);
|
|
84
|
+
expect(sql).toMatch(
|
|
85
|
+
/ST_Distance_Sphere\(property_units\.location, ST_GeomFromText\('POINT\(39\.4972468 -105\.0403593\)', 4326\)\) <= \?/
|
|
86
|
+
);
|
|
87
|
+
expect(params.slice(-1)[0]).toBe(5000);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('rejects raw function expressions containing unsafe tokens', () => {
|
|
91
|
+
const config = buildParcelConfig();
|
|
92
|
+
|
|
93
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
94
|
+
[C6C.SELECT]: [Property_Units.PARCEL_ID],
|
|
95
|
+
[C6C.WHERE]: {
|
|
96
|
+
0: [
|
|
97
|
+
[
|
|
98
|
+
"ST_Distance_Sphere(property_units.location, ST_GeomFromText('POINT(39.4972468 -105.0403593)', 4326)); DROP TABLE users",
|
|
99
|
+
C6C.LESS_THAN,
|
|
100
|
+
1000,
|
|
101
|
+
],
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
} as any, false);
|
|
105
|
+
|
|
106
|
+
expect(() => qb.build(Property_Units.TABLE_NAME)).toThrowError(/Potential SQL injection detected/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('supports explicit AND groupings composed of nested OR clauses', () => {
|
|
110
|
+
const config = buildParcelConfig();
|
|
111
|
+
|
|
112
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
113
|
+
[C6C.SELECT]: [Property_Units.PARCEL_ID],
|
|
114
|
+
[C6C.WHERE]: {
|
|
115
|
+
[C6C.AND]: [
|
|
116
|
+
{ [C6C.GREATER_THAN]: [Property_Units.PARCEL_ID, 100] },
|
|
117
|
+
{
|
|
118
|
+
[C6C.OR]: [
|
|
119
|
+
{ [C6C.BETWEEN]: [Parcel_Sales.SALE_DATE, ['2021-01-01', '2022-06-30']] },
|
|
120
|
+
{ [C6C.BETWEEN]: [Parcel_Sales.SALE_DATE, ['2023-01-01', '2024-06-30']] },
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
} as any, false);
|
|
126
|
+
|
|
127
|
+
const { sql, params } = qb.build(Property_Units.TABLE_NAME);
|
|
128
|
+
|
|
129
|
+
expect(sql).toMatch(/\(property_units\.parcel_id\) > \?/);
|
|
130
|
+
expect(sql).toMatch(/\(parcel_sales\.sale_date\) BETWEEN \? AND \?/);
|
|
131
|
+
expect(sql).toContain('AND');
|
|
132
|
+
expect(sql).toContain('OR');
|
|
133
|
+
expect(params).toEqual([100, '2021-01-01', '2022-06-30', '2023-01-01', '2024-06-30']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('serializes JOIN clauses deterministically based on declaration order', () => {
|
|
137
|
+
const config = buildParcelConfig();
|
|
138
|
+
|
|
139
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
140
|
+
[C6C.SELECT]: [Property_Units.PARCEL_ID],
|
|
141
|
+
[C6C.JOIN]: {
|
|
142
|
+
[C6C.LEFT]: {
|
|
143
|
+
[Parcel_Sales.TABLE_NAME]: {
|
|
144
|
+
[Parcel_Sales.PARCEL_ID]: Property_Units.PARCEL_ID,
|
|
145
|
+
},
|
|
146
|
+
[Parcel_Building_Details.TABLE_NAME]: {
|
|
147
|
+
[Parcel_Building_Details.PARCEL_ID]: Property_Units.PARCEL_ID,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
[C6C.WHERE]: { [Property_Units.PARCEL_ID]: [C6C.EQUAL, 1] },
|
|
152
|
+
} as any, false);
|
|
153
|
+
|
|
154
|
+
const { sql } = qb.build(Property_Units.TABLE_NAME);
|
|
155
|
+
|
|
156
|
+
const firstJoin = sql.indexOf('JOIN `parcel_sales`');
|
|
157
|
+
const secondJoin = sql.indexOf('JOIN `parcel_building_details`');
|
|
158
|
+
|
|
159
|
+
expect(firstJoin).toBeGreaterThan(-1);
|
|
160
|
+
expect(secondJoin).toBeGreaterThan(firstJoin);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
package/src/api/C6Constants.ts
CHANGED
|
@@ -6,6 +6,7 @@ export const C6Constants = {
|
|
|
6
6
|
ADDTIME: 'ADDTIME',
|
|
7
7
|
AS: 'AS',
|
|
8
8
|
ASC: 'ASC',
|
|
9
|
+
AND: 'AND',
|
|
9
10
|
|
|
10
11
|
BETWEEN: 'BETWEEN',
|
|
11
12
|
|
|
@@ -92,6 +93,7 @@ export const C6Constants = {
|
|
|
92
93
|
NULL: 'NULL',
|
|
93
94
|
|
|
94
95
|
ORDER: 'ORDER',
|
|
96
|
+
OR: 'OR',
|
|
95
97
|
|
|
96
98
|
PAGE: 'PAGE',
|
|
97
99
|
PAGINATION: 'PAGINATION',
|
|
@@ -43,6 +43,11 @@ export abstract class AggregateBuilder<G extends OrmGenerics> extends Executor<G
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
const F = String(fn).toUpperCase();
|
|
46
|
+
const isGeomFromText = F === C6C.ST_GEOMFROMTEXT.toUpperCase();
|
|
47
|
+
|
|
48
|
+
if (args.length === 1 && Array.isArray(args[0])) {
|
|
49
|
+
args = args[0];
|
|
50
|
+
}
|
|
46
51
|
|
|
47
52
|
// Parameter placeholder helper: [C6C.PARAM, value]
|
|
48
53
|
if (F === C6C.PARAM) {
|
|
@@ -79,7 +84,7 @@ export abstract class AggregateBuilder<G extends OrmGenerics> extends Executor<G
|
|
|
79
84
|
const identifierPathRegex = /^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/;
|
|
80
85
|
|
|
81
86
|
const argList = args
|
|
82
|
-
.map(arg => {
|
|
87
|
+
.map((arg, index) => {
|
|
83
88
|
if (Array.isArray(arg)) return this.buildAggregateField(arg, params);
|
|
84
89
|
if (typeof arg === 'string') {
|
|
85
90
|
if (identifierPathRegex.test(arg)) {
|
|
@@ -88,6 +93,17 @@ export abstract class AggregateBuilder<G extends OrmGenerics> extends Executor<G
|
|
|
88
93
|
}
|
|
89
94
|
// Treat numeric-looking strings as literals, not identifier paths
|
|
90
95
|
if (isNumericString(arg)) return arg;
|
|
96
|
+
|
|
97
|
+
if (isGeomFromText && index === 0) {
|
|
98
|
+
const trimmed = arg.trim();
|
|
99
|
+
const alreadyQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2;
|
|
100
|
+
if (alreadyQuoted) {
|
|
101
|
+
return trimmed;
|
|
102
|
+
}
|
|
103
|
+
const escaped = arg.replace(/'/g, "''");
|
|
104
|
+
return `'${escaped}'`;
|
|
105
|
+
}
|
|
106
|
+
|
|
91
107
|
return arg;
|
|
92
108
|
}
|
|
93
109
|
return String(arg);
|