@carbonorm/carbonnode 3.7.6 → 3.7.7
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 +1 -1
- package/dist/api/orm/builders/AggregateBuilder.d.ts +1 -0
- package/dist/api/types/ormInterfaces.d.ts +2 -2
- package/dist/api/utils/cacheManager.d.ts +1 -1
- package/dist/api/utils/normalizeSingularRequest.d.ts +10 -0
- package/dist/index.cjs.js +175 -32
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +176 -34
- package/dist/index.esm.js.map +1 -1
- package/package.json +11 -6
- package/scripts/assets/handlebars/C6.test.ts.handlebars +55 -80
- package/scripts/assets/handlebars/C6.ts.handlebars +28 -2
- package/scripts/generateRestBindings.cjs +17 -6
- package/scripts/generateRestBindings.ts +22 -8
- package/src/__tests__/fixtures/c6.fixture.ts +74 -0
- package/src/__tests__/normalizeSingularRequest.test.ts +95 -0
- package/src/__tests__/sakila-db/C6.js +1487 -0
- package/src/__tests__/sakila-db/C6.test.ts +63 -0
- package/src/__tests__/sakila-db/C6.ts +2206 -0
- package/src/__tests__/sakila-db/sakila-data.sql +46444 -0
- package/src/__tests__/sakila-db/sakila-schema.sql +686 -0
- package/src/__tests__/sakila-db/sakila.mwb +0 -0
- package/src/__tests__/sakila.generated.test.ts +46 -0
- package/src/__tests__/sqlBuilders.complex.test.ts +134 -0
- package/src/__tests__/sqlBuilders.test.ts +121 -0
- package/src/api/convertForRequestBody.ts +1 -1
- package/src/api/executors/HttpExecutor.ts +14 -3
- package/src/api/executors/SqlExecutor.ts +14 -1
- package/src/api/orm/builders/AggregateBuilder.ts +3 -0
- package/src/api/orm/builders/ConditionBuilder.ts +34 -11
- package/src/api/orm/builders/PaginationBuilder.ts +10 -4
- package/src/api/orm/queries/SelectQueryBuilder.ts +3 -0
- package/src/api/orm/queries/UpdateQueryBuilder.ts +2 -1
- package/src/api/types/ormInterfaces.ts +3 -4
- package/src/api/utils/cacheManager.ts +1 -1
- package/src/api/utils/normalizeSingularRequest.ts +138 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Import the generated C6.js from sakila-db folder (ESM)
|
|
4
|
+
// This file is generated from the Sakila schema and wired with restOrm
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
6
|
+
// @ts-ignore - C6.js is JS, vitest/ts handles ESM imports fine
|
|
7
|
+
import { C6, GLOBAL_REST_PARAMETERS } from './sakila-db/C6.js';
|
|
8
|
+
|
|
9
|
+
function toPascalCase(name: string) {
|
|
10
|
+
return name.replace(/(^|_)([a-z])/g, (_m, _u, c) => c.toUpperCase());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('sakila-db generated C6 bindings', () => {
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
// Provide a mocked MySQL pool so SqlExecutor path is used without a real DB
|
|
16
|
+
const mockConn = {
|
|
17
|
+
query: vi.fn().mockImplementation(async (_sql: string, _values?: any[]) => {
|
|
18
|
+
// Return a result set shaped like mysql2/promise: [rows, fields]
|
|
19
|
+
return [[{ ok: true }], []];
|
|
20
|
+
}),
|
|
21
|
+
release: vi.fn()
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const mockPool = {
|
|
25
|
+
getConnection: vi.fn().mockResolvedValue(mockConn)
|
|
26
|
+
} as any;
|
|
27
|
+
|
|
28
|
+
// Inject mocked pool into global rest parameters used by all table bindings
|
|
29
|
+
GLOBAL_REST_PARAMETERS.mysqlPool = mockPool;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('Get(...LIMIT...) returns array rest for every generated table', async () => {
|
|
33
|
+
// Iterate over each table short name present in generated C6
|
|
34
|
+
for (const [shortName] of Object.entries(C6.TABLES as Record<string, any>)) {
|
|
35
|
+
const bindingName = toPascalCase(shortName);
|
|
36
|
+
const restBinding = (C6.ORM as Record<string, any>)[bindingName];
|
|
37
|
+
if (!restBinding || typeof restBinding.Get !== 'function') continue;
|
|
38
|
+
|
|
39
|
+
const response = await restBinding.Get({ SELECT: ['*'], [C6.PAGINATION]: { [C6.LIMIT]: 1 } } as any);
|
|
40
|
+
|
|
41
|
+
// HttpExecutor returns AxiosResponse, SqlExecutor returns plain object
|
|
42
|
+
const data = (response as any)?.data ?? response;
|
|
43
|
+
expect(Array.isArray((data as any)?.rest)).toBe(true);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { C6C } from '../api/C6Constants';
|
|
3
|
+
import { SelectQueryBuilder } from '../api/orm/queries/SelectQueryBuilder';
|
|
4
|
+
import { buildTestConfig } from './fixtures/c6.fixture';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Complex SELECT coverage focused on WHERE operators, JOIN chains, ORDER, and pagination.
|
|
8
|
+
*/
|
|
9
|
+
describe('SQL Builders - Complex SELECTs', () => {
|
|
10
|
+
it('supports nested AND/OR groups, IN/NOT IN, BETWEEN, IS NULL', () => {
|
|
11
|
+
const config = buildTestConfig();
|
|
12
|
+
|
|
13
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
14
|
+
SELECT: ['actor.actor_id', 'actor.first_name'],
|
|
15
|
+
WHERE: {
|
|
16
|
+
// AND root with one direct condition
|
|
17
|
+
'actor.first_name': [C6C.LIKE, 'A%'],
|
|
18
|
+
// OR group #1
|
|
19
|
+
0: {
|
|
20
|
+
'actor.actor_id': [C6C.IN, [1, 2, 3]]
|
|
21
|
+
},
|
|
22
|
+
// OR group #2
|
|
23
|
+
1: {
|
|
24
|
+
'actor.actor_id': [C6C.BETWEEN, [5, 10]]
|
|
25
|
+
},
|
|
26
|
+
// OR group #3
|
|
27
|
+
2: {
|
|
28
|
+
'actor.last_name': [C6C.IS, null]
|
|
29
|
+
},
|
|
30
|
+
// AND with NOT IN
|
|
31
|
+
'actor.last_name': [C6C.NOT_IN, ['SMITH', 'DOE']]
|
|
32
|
+
}
|
|
33
|
+
} as any, false);
|
|
34
|
+
|
|
35
|
+
const { sql, params } = qb.build('actor');
|
|
36
|
+
|
|
37
|
+
// SQL fragments
|
|
38
|
+
expect(sql).toContain('SELECT actor.actor_id, actor.first_name FROM `actor`');
|
|
39
|
+
expect(sql).toContain('WHERE');
|
|
40
|
+
expect(sql).toMatch(/\(actor\.first_name\) LIKE \?/);
|
|
41
|
+
expect(sql).toMatch(/\( actor\.actor_id IN \((\?,\s*){2}\?\) \)/); // 3 placeholders
|
|
42
|
+
expect(sql).toMatch(/\(actor\.actor_id\) BETWEEN \? AND \?/);
|
|
43
|
+
expect(sql).toMatch(/\(actor\.last_name\) IS \?/);
|
|
44
|
+
expect(sql).toMatch(/\( actor\.last_name NOT IN \((\?,\s*)\?\) \)/);
|
|
45
|
+
// default LIMIT
|
|
46
|
+
expect(sql.trim().endsWith('LIMIT 100')).toBe(true);
|
|
47
|
+
|
|
48
|
+
// Params order: non-numeric entries first, then grouped (implementation detail)
|
|
49
|
+
// We asserted shape above; ensure counts match
|
|
50
|
+
expect(params).toHaveLength(1 + 3 + 2 + 1 + 2); // LIKE + IN(3) + BETWEEN(2) + IS(1 param) + NOT IN(2)
|
|
51
|
+
expect(params[0]).toBe('A%');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('builds chained mixed JOINs with aliases', () => {
|
|
55
|
+
const config = buildTestConfig();
|
|
56
|
+
|
|
57
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
58
|
+
SELECT: ['actor.actor_id'],
|
|
59
|
+
JOIN: {
|
|
60
|
+
[C6C.INNER]: {
|
|
61
|
+
'film_actor fa': { 'fa.actor_id': [C6C.EQUAL, 'actor.actor_id'] }
|
|
62
|
+
},
|
|
63
|
+
[C6C.LEFT]: {
|
|
64
|
+
'film_actor fb': { 'fb.actor_id': [C6C.EQUAL, 'actor.actor_id'] }
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
WHERE: { 'actor.actor_id': [C6C.GREATER_THAN, 0] }
|
|
68
|
+
} as any, false);
|
|
69
|
+
|
|
70
|
+
const { sql, params } = qb.build('actor');
|
|
71
|
+
|
|
72
|
+
expect(sql).toContain('FROM `actor`');
|
|
73
|
+
expect(sql).toContain('INNER JOIN `film_actor` AS `fa` ON');
|
|
74
|
+
expect(sql).toContain('LEFT JOIN `film_actor` AS `fb` ON');
|
|
75
|
+
expect(sql).toMatch(/\(actor\.actor_id\) > \?/);
|
|
76
|
+
expect(params).toEqual([0]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('orders by multiple fields and honors PAGE/LIMIT offset', () => {
|
|
80
|
+
const config = buildTestConfig();
|
|
81
|
+
|
|
82
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
83
|
+
SELECT: ['actor.actor_id', 'actor.first_name'],
|
|
84
|
+
WHERE: { 'actor.actor_id': [C6C.GREATER_THAN, 10] },
|
|
85
|
+
PAGINATION: {
|
|
86
|
+
[C6C.ORDER]: {
|
|
87
|
+
'actor.last_name': 'ASC',
|
|
88
|
+
'actor.first_name': 'DESC'
|
|
89
|
+
},
|
|
90
|
+
[C6C.LIMIT]: 10,
|
|
91
|
+
[C6C.PAGE]: 3
|
|
92
|
+
}
|
|
93
|
+
} as any, false);
|
|
94
|
+
|
|
95
|
+
const { sql, params } = qb.build('actor');
|
|
96
|
+
|
|
97
|
+
expect(sql).toContain('ORDER BY actor.last_name ASC, actor.first_name DESC');
|
|
98
|
+
expect(sql.trim().endsWith('LIMIT 20, 10')).toBe(true); // (page-1)*limit, limit
|
|
99
|
+
expect(params).toEqual([10]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('supports DISTINCT and HAVING on aggregated alias', () => {
|
|
103
|
+
const config = buildTestConfig();
|
|
104
|
+
|
|
105
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
106
|
+
SELECT: [[C6C.DISTINCT, 'actor.first_name'], [C6C.COUNT, 'actor.actor_id', C6C.AS, 'cnt']],
|
|
107
|
+
GROUP_BY: 'actor.first_name',
|
|
108
|
+
HAVING: { 'cnt': [C6C.GREATER_THAN, 1] }
|
|
109
|
+
} as any, false);
|
|
110
|
+
|
|
111
|
+
const { sql, params } = qb.build('actor');
|
|
112
|
+
|
|
113
|
+
expect(sql).toContain('SELECT DISTINCT actor.first_name, COUNT(actor.actor_id) AS cnt FROM `actor`');
|
|
114
|
+
expect(sql).toContain('GROUP BY actor.first_name');
|
|
115
|
+
expect(sql).toContain('HAVING');
|
|
116
|
+
expect(params).toEqual([1]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('supports MATCH_AGAINST fulltext condition variants', () => {
|
|
120
|
+
const config = buildTestConfig();
|
|
121
|
+
|
|
122
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
123
|
+
SELECT: ['actor.actor_id'],
|
|
124
|
+
WHERE: {
|
|
125
|
+
'actor.first_name': [C6C.MATCH_AGAINST, ['alpha beta', 'BOOLEAN']]
|
|
126
|
+
}
|
|
127
|
+
} as any, false);
|
|
128
|
+
|
|
129
|
+
const { sql, params } = qb.build('actor');
|
|
130
|
+
|
|
131
|
+
expect(sql).toMatch(/MATCH\(actor\.first_name\) AGAINST\(\? IN BOOLEAN MODE\)/);
|
|
132
|
+
expect(params).toEqual(['alpha beta']);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { C6C } from '../api/C6Constants';
|
|
3
|
+
import { SelectQueryBuilder } from '../api/orm/queries/SelectQueryBuilder';
|
|
4
|
+
import { PostQueryBuilder } from '../api/orm/queries/PostQueryBuilder';
|
|
5
|
+
import { UpdateQueryBuilder } from '../api/orm/queries/UpdateQueryBuilder';
|
|
6
|
+
import { DeleteQueryBuilder } from '../api/orm/queries/DeleteQueryBuilder';
|
|
7
|
+
import { buildTestConfig } from './fixtures/c6.fixture';
|
|
8
|
+
|
|
9
|
+
describe('SQL Builders', () => {
|
|
10
|
+
it('builds SELECT with JOIN, WHERE, GROUP BY, HAVING and default LIMIT', () => {
|
|
11
|
+
const config = buildTestConfig();
|
|
12
|
+
// named params disabled -> positional params array
|
|
13
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
14
|
+
SELECT: ['actor.first_name', [C6C.COUNT, 'actor.actor_id', C6C.AS, 'cnt']],
|
|
15
|
+
JOIN: {
|
|
16
|
+
[C6C.INNER]: {
|
|
17
|
+
'film_actor fa': {
|
|
18
|
+
'fa.actor_id': [C6C.EQUAL, 'actor.actor_id']
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
WHERE: {
|
|
23
|
+
'actor.first_name': [C6C.LIKE, '%A%'],
|
|
24
|
+
0: {
|
|
25
|
+
'actor.actor_id': [C6C.GREATER_THAN, 10],
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
GROUP_BY: 'actor.first_name',
|
|
29
|
+
HAVING: {
|
|
30
|
+
'cnt': [C6C.GREATER_THAN, 1]
|
|
31
|
+
},
|
|
32
|
+
} as any, false);
|
|
33
|
+
|
|
34
|
+
const { sql, params } = qb.build('actor');
|
|
35
|
+
|
|
36
|
+
expect(sql).toContain('SELECT actor.first_name, COUNT(actor.actor_id) AS cnt FROM `actor`');
|
|
37
|
+
expect(sql).toContain('INNER JOIN `film_actor` AS `fa` ON');
|
|
38
|
+
expect(sql).toContain('(actor.first_name) LIKE ?');
|
|
39
|
+
expect(sql).toContain('(actor.actor_id) > ?');
|
|
40
|
+
expect(sql).toContain('GROUP BY actor.first_name');
|
|
41
|
+
expect(sql).toContain('HAVING');
|
|
42
|
+
expect(sql.trim().endsWith('LIMIT 100')).toBe(true);
|
|
43
|
+
expect(params).toEqual(['%A%', 10, 1]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('builds INSERT with ON DUPLICATE KEY UPDATE', () => {
|
|
47
|
+
const config = buildTestConfig();
|
|
48
|
+
const qb = new PostQueryBuilder(config as any, {
|
|
49
|
+
[C6C.REPLACE]: {
|
|
50
|
+
'actor.first_name': 'BOB',
|
|
51
|
+
'actor.last_name': 'SMITH',
|
|
52
|
+
},
|
|
53
|
+
[C6C.UPDATE]: ['first_name', 'last_name'],
|
|
54
|
+
} as any, false);
|
|
55
|
+
|
|
56
|
+
const { sql, params } = qb.build('actor');
|
|
57
|
+
|
|
58
|
+
expect(sql).toContain('REPLACE INTO `actor`');
|
|
59
|
+
expect(sql).toContain('`first_name`, `last_name`');
|
|
60
|
+
expect(sql).toContain('ON DUPLICATE KEY UPDATE `first_name` = VALUES(`first_name`), `last_name` = VALUES(`last_name`)');
|
|
61
|
+
expect(params).toEqual(['BOB', 'SMITH']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('builds UPDATE with WHERE and pagination', () => {
|
|
65
|
+
const config = buildTestConfig();
|
|
66
|
+
const qb = new UpdateQueryBuilder(config as any, {
|
|
67
|
+
[C6C.UPDATE]: {
|
|
68
|
+
'first_name': 'ALICE',
|
|
69
|
+
},
|
|
70
|
+
WHERE: {
|
|
71
|
+
'actor.actor_id': [C6C.EQUAL, 5],
|
|
72
|
+
},
|
|
73
|
+
PAGINATION: { LIMIT: 1 }
|
|
74
|
+
} as any, false);
|
|
75
|
+
|
|
76
|
+
const { sql, params } = qb.build('actor');
|
|
77
|
+
|
|
78
|
+
expect(sql.startsWith('UPDATE `actor` SET')).toBe(true);
|
|
79
|
+
expect(sql).toContain('`first_name` = ?');
|
|
80
|
+
expect(sql).toContain('WHERE (actor.actor_id) = ?');
|
|
81
|
+
expect(sql).toContain('LIMIT 1');
|
|
82
|
+
expect(params).toEqual(['ALICE', 5]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('builds DELETE with JOIN and WHERE', () => {
|
|
86
|
+
const config = buildTestConfig();
|
|
87
|
+
const qb = new DeleteQueryBuilder(config as any, {
|
|
88
|
+
JOIN: {
|
|
89
|
+
[C6C.INNER]: {
|
|
90
|
+
'film_actor fa': {
|
|
91
|
+
'fa.actor_id': [C6C.EQUAL, 'actor.actor_id']
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
WHERE: { 'actor.actor_id': [C6C.GREATER_THAN, 100] },
|
|
96
|
+
} as any, false);
|
|
97
|
+
|
|
98
|
+
const { sql, params } = qb.build('actor');
|
|
99
|
+
|
|
100
|
+
expect(sql).toContain('DELETE `actor` FROM `actor`');
|
|
101
|
+
expect(sql).toContain('INNER JOIN `film_actor` AS `fa` ON');
|
|
102
|
+
expect(sql).toContain('(actor.actor_id) > ?');
|
|
103
|
+
expect(params).toEqual([100]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('converts hex to Buffer for BINARY columns in WHERE params', () => {
|
|
107
|
+
const config = buildTestConfig();
|
|
108
|
+
const qb = new SelectQueryBuilder(config as any, {
|
|
109
|
+
WHERE: {
|
|
110
|
+
'actor.binarycol': [C6C.EQUAL, '0123456789abcdef0123456789abcdef']
|
|
111
|
+
}
|
|
112
|
+
} as any, false);
|
|
113
|
+
|
|
114
|
+
const { params } = qb.build('actor');
|
|
115
|
+
expect(Array.isArray(params)).toBe(true);
|
|
116
|
+
const buf = (params as any[])[0];
|
|
117
|
+
expect(Buffer.isBuffer(buf)).toBe(true);
|
|
118
|
+
expect((buf as Buffer).length).toBe(16);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
PUT, RequestQueryBody
|
|
16
16
|
} from "../types/ormInterfaces";
|
|
17
17
|
import {removeInvalidKeys, removePrefixIfExists, TestRestfulResponse} from "../utils/apiHelpers";
|
|
18
|
+
import { normalizeSingularRequest } from "../utils/normalizeSingularRequest";
|
|
18
19
|
import {apiRequestCache, checkCache, userCustomClearCache} from "../utils/cacheManager";
|
|
19
20
|
import {sortAndSerializeQueryObject} from "../utils/sortAndSerializeQueryObject";
|
|
20
21
|
import {Executor} from "./Executor";
|
|
@@ -336,6 +337,7 @@ export class HttpExecutor<
|
|
|
336
337
|
}
|
|
337
338
|
|
|
338
339
|
let addBackPK: (() => void) | undefined;
|
|
340
|
+
let removedPrimaryKV: { key: string; value: any } | undefined;
|
|
339
341
|
|
|
340
342
|
let apiResponse: G['RestTableInterface'][G['PrimaryKey']] | string | boolean | number | undefined;
|
|
341
343
|
|
|
@@ -411,6 +413,7 @@ export class HttpExecutor<
|
|
|
411
413
|
restRequestUri += query[primaryKey] + '/'
|
|
412
414
|
|
|
413
415
|
const removedPkValue = query[primaryKey];
|
|
416
|
+
removedPrimaryKV = { key: primaryKey, value: removedPkValue };
|
|
414
417
|
|
|
415
418
|
addBackPK = () => {
|
|
416
419
|
query ??= {} as RequestQueryBody<
|
|
@@ -472,11 +475,19 @@ export class HttpExecutor<
|
|
|
472
475
|
withCredentials: withCredentials,
|
|
473
476
|
};
|
|
474
477
|
|
|
478
|
+
// Normalize singular request (GET/PUT/DELETE) into complex ORM shape
|
|
479
|
+
const normalizedQuery = normalizeSingularRequest(
|
|
480
|
+
requestMethod as any,
|
|
481
|
+
query as any,
|
|
482
|
+
restModel as any,
|
|
483
|
+
removedPrimaryKV
|
|
484
|
+
) as typeof query;
|
|
485
|
+
|
|
475
486
|
switch (requestMethod) {
|
|
476
487
|
case GET:
|
|
477
488
|
return [{
|
|
478
489
|
...baseConfig,
|
|
479
|
-
params:
|
|
490
|
+
params: normalizedQuery
|
|
480
491
|
}];
|
|
481
492
|
|
|
482
493
|
case POST:
|
|
@@ -489,12 +500,12 @@ export class HttpExecutor<
|
|
|
489
500
|
return [convert(query), baseConfig];
|
|
490
501
|
|
|
491
502
|
case PUT:
|
|
492
|
-
return [convert(
|
|
503
|
+
return [convert(normalizedQuery), baseConfig];
|
|
493
504
|
|
|
494
505
|
case DELETE:
|
|
495
506
|
return [{
|
|
496
507
|
...baseConfig,
|
|
497
|
-
data: convert(
|
|
508
|
+
data: convert(normalizedQuery)
|
|
498
509
|
}];
|
|
499
510
|
|
|
500
511
|
default:
|
|
@@ -10,6 +10,7 @@ import namedPlaceholders from 'named-placeholders';
|
|
|
10
10
|
import {PoolConnection} from 'mysql2/promise';
|
|
11
11
|
import {Buffer} from 'buffer';
|
|
12
12
|
import {Executor} from "./Executor";
|
|
13
|
+
import { normalizeSingularRequest } from "../utils/normalizeSingularRequest";
|
|
13
14
|
|
|
14
15
|
export class SqlExecutor<
|
|
15
16
|
G extends OrmGenerics
|
|
@@ -19,10 +20,22 @@ export class SqlExecutor<
|
|
|
19
20
|
const {TABLE_NAME} = this.config.restModel;
|
|
20
21
|
const method = this.config.requestMethod;
|
|
21
22
|
|
|
23
|
+
// Normalize singular T-shaped requests into complex ORM shape (GET/PUT/DELETE)
|
|
24
|
+
try {
|
|
25
|
+
this.request = normalizeSingularRequest(
|
|
26
|
+
method as any,
|
|
27
|
+
this.request as any,
|
|
28
|
+
this.config.restModel as any,
|
|
29
|
+
undefined
|
|
30
|
+
) as typeof this.request;
|
|
31
|
+
} catch (e) {
|
|
32
|
+
// Surface normalization errors early
|
|
33
|
+
throw e;
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
this.config.verbose && console.log(`[SQL EXECUTOR] ▶️ Executing ${method} on table "${TABLE_NAME}"`);
|
|
23
37
|
this.config.verbose && console.log(`[SQL EXECUTOR] 🧩 Request:`, this.request);
|
|
24
38
|
|
|
25
|
-
|
|
26
39
|
switch (method) {
|
|
27
40
|
case 'GET': {
|
|
28
41
|
const rest = await this.runQuery();
|
|
@@ -2,6 +2,8 @@ import {Executor} from "../../executors/Executor";
|
|
|
2
2
|
import {OrmGenerics} from "../../types/ormGenerics";
|
|
3
3
|
|
|
4
4
|
export abstract class AggregateBuilder<G extends OrmGenerics> extends Executor<G>{
|
|
5
|
+
protected selectAliases: Set<string> = new Set<string>();
|
|
6
|
+
|
|
5
7
|
buildAggregateField(field: string | any[]): string {
|
|
6
8
|
if (typeof field === 'string') {
|
|
7
9
|
return field;
|
|
@@ -33,6 +35,7 @@ export abstract class AggregateBuilder<G extends OrmGenerics> extends Executor<G
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
if (alias) {
|
|
38
|
+
this.selectAliases.add(alias);
|
|
36
39
|
expr += ` AS ${alias}`;
|
|
37
40
|
}
|
|
38
41
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {C6C} from "
|
|
1
|
+
import {C6C} from "../../C6Constants";
|
|
2
2
|
import {OrmGenerics} from "../../types/ormGenerics";
|
|
3
3
|
import {DetermineResponseDataType} from "../../types/ormInterfaces";
|
|
4
4
|
import {convertHexIfBinary, SqlBuilderResult} from "../utils/sqlUtils";
|
|
@@ -65,7 +65,16 @@ export abstract class ConditionBuilder<
|
|
|
65
65
|
]);
|
|
66
66
|
|
|
67
67
|
private isTableReference(val: any): boolean {
|
|
68
|
-
if (typeof val !== 'string'
|
|
68
|
+
if (typeof val !== 'string') return false;
|
|
69
|
+
// Support aggregate aliases (e.g., SELECT COUNT(x) AS cnt ... HAVING cnt > 1)
|
|
70
|
+
if (!val.includes('.')) {
|
|
71
|
+
const isIdentifier = /^[A-Za-z_][A-Za-z0-9_]*$/.test(val);
|
|
72
|
+
// selectAliases is defined in AggregateBuilder
|
|
73
|
+
if (isIdentifier && (this as any).selectAliases?.has(val)) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
69
78
|
const [prefix, column] = val.split('.');
|
|
70
79
|
const tableName = this.aliasMap[prefix] ?? prefix;
|
|
71
80
|
const table = this.config.C6?.TABLES?.[tableName];
|
|
@@ -90,7 +99,13 @@ export abstract class ConditionBuilder<
|
|
|
90
99
|
column: string,
|
|
91
100
|
value: any
|
|
92
101
|
): string {
|
|
93
|
-
|
|
102
|
+
// Determine column definition from C6.TABLES to support type-aware conversions (e.g., BINARY hex -> Buffer)
|
|
103
|
+
let columnDef: any | undefined;
|
|
104
|
+
if (typeof column === 'string' && column.includes('.')) {
|
|
105
|
+
const [tableName] = column.split('.', 2);
|
|
106
|
+
const table = this.config.C6?.TABLES?.[tableName];
|
|
107
|
+
columnDef = table?.TYPE_VALIDATION?.[column];
|
|
108
|
+
}
|
|
94
109
|
const val = convertHexIfBinary(column, value, columnDef);
|
|
95
110
|
|
|
96
111
|
if (this.useNamedParams) {
|
|
@@ -141,7 +156,7 @@ export abstract class ConditionBuilder<
|
|
|
141
156
|
const leftIsRef = this.isTableReference(column);
|
|
142
157
|
const rightIsCol = typeof value === 'string' && this.isColumnRef(value);
|
|
143
158
|
|
|
144
|
-
if (!leftIsCol && !rightIsCol) {
|
|
159
|
+
if (!leftIsCol && !leftIsRef && !rightIsCol) {
|
|
145
160
|
throw new Error(`Potential SQL injection detected: '${column} ${op} ${value}'`);
|
|
146
161
|
}
|
|
147
162
|
|
|
@@ -222,14 +237,11 @@ export abstract class ConditionBuilder<
|
|
|
222
237
|
|
|
223
238
|
const buildFromObject = (obj: Record<string, any>, mode: boolean) => {
|
|
224
239
|
const subParts: string[] = [];
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const sub = this.buildBooleanJoinedConditions(v, false, params);
|
|
229
|
-
if (sub) subParts.push(sub);
|
|
230
|
-
continue;
|
|
231
|
-
}
|
|
240
|
+
const entries = Object.entries(obj);
|
|
241
|
+
const nonNumeric = entries.filter(([k]) => isNaN(Number(k)));
|
|
242
|
+
const numeric = entries.filter(([k]) => !isNaN(Number(k)));
|
|
232
243
|
|
|
244
|
+
const processEntry = (k: string, v: any) => {
|
|
233
245
|
if (typeof v === 'object' && v !== null && Object.keys(v).length === 1) {
|
|
234
246
|
const [op, val] = Object.entries(v)[0];
|
|
235
247
|
subParts.push(addCondition(k, op, val));
|
|
@@ -242,7 +254,18 @@ export abstract class ConditionBuilder<
|
|
|
242
254
|
} else {
|
|
243
255
|
subParts.push(addCondition(k, '=', v));
|
|
244
256
|
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// Process non-numeric keys first to preserve intuitive insertion order for params
|
|
260
|
+
for (const [k, v] of nonNumeric) {
|
|
261
|
+
processEntry(k, v);
|
|
245
262
|
}
|
|
263
|
+
// Then process numeric keys (treated as grouped OR conditions)
|
|
264
|
+
for (const [_k, v] of numeric) {
|
|
265
|
+
const sub = this.buildBooleanJoinedConditions(v, false, params);
|
|
266
|
+
if (sub) subParts.push(sub);
|
|
267
|
+
}
|
|
268
|
+
|
|
246
269
|
return subParts.join(` ${mode ? 'AND' : 'OR'} `);
|
|
247
270
|
};
|
|
248
271
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {C6Constants} from "
|
|
1
|
+
import {C6Constants} from "../../C6Constants";
|
|
2
2
|
import {OrmGenerics} from "../../types/ormGenerics";
|
|
3
3
|
import {JoinBuilder} from "./JoinBuilder";
|
|
4
4
|
|
|
@@ -44,9 +44,15 @@ export abstract class PaginationBuilder<G extends OrmGenerics> extends JoinBuild
|
|
|
44
44
|
/* -------- LIMIT / OFFSET -------- */
|
|
45
45
|
if (pagination?.[C6Constants.LIMIT] != null) {
|
|
46
46
|
const lim = parseInt(pagination[C6Constants.LIMIT], 10);
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
47
|
+
const pageRaw = pagination[C6Constants.PAGE];
|
|
48
|
+
const pageParsed = parseInt(pageRaw ?? 1, 10);
|
|
49
|
+
const page = isFinite(pageParsed) && pageParsed > 1 ? pageParsed : 1;
|
|
50
|
+
if (page === 1) {
|
|
51
|
+
sql += ` LIMIT ${lim}`;
|
|
52
|
+
} else {
|
|
53
|
+
const offset = (page - 1) * lim;
|
|
54
|
+
sql += ` LIMIT ${offset}, ${lim}`;
|
|
55
|
+
}
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
this.config.verbose && console.log(`[PAGINATION] ${sql.trim()}`);
|
|
@@ -9,6 +9,9 @@ export class SelectQueryBuilder<G extends OrmGenerics> extends PaginationBuilder
|
|
|
9
9
|
isSubSelect: boolean = false
|
|
10
10
|
): SqlBuilderResult {
|
|
11
11
|
this.aliasMap = {};
|
|
12
|
+
// reset any previously collected SELECT aliases (from AggregateBuilder)
|
|
13
|
+
// @ts-ignore
|
|
14
|
+
if (this.selectAliases && this.selectAliases.clear) this.selectAliases.clear();
|
|
12
15
|
const args = this.request;
|
|
13
16
|
this.initAlias(table, args.JOIN);
|
|
14
17
|
const params = this.useNamedParams ? {} : [];
|
|
@@ -22,7 +22,8 @@ export class UpdateQueryBuilder<G extends OrmGenerics> extends PaginationBuilder
|
|
|
22
22
|
throw new Error("No update data provided in the request.");
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const setClauses = Object.entries(this.request[C6C.UPDATE])
|
|
25
|
+
const setClauses = Object.entries(this.request[C6C.UPDATE])
|
|
26
|
+
.map(([col, val]) => `\`${col}\` = ${this.addParam(params, col, val)}`);
|
|
26
27
|
|
|
27
28
|
sql += ` SET ${setClauses.join(', ')}`;
|
|
28
29
|
|
|
@@ -66,7 +66,7 @@ export type Pagination<T = any> = {
|
|
|
66
66
|
ORDER?: Partial<Record<keyof T, OrderDirection>>;
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
-
export type RequestGetPutDeleteBody<T extends { [key: string]: any } = any> = {
|
|
69
|
+
export type RequestGetPutDeleteBody<T extends { [key: string]: any } = any> = T | {
|
|
70
70
|
SELECT?: SelectField<T>[];
|
|
71
71
|
UPDATE?: Partial<T>;
|
|
72
72
|
DELETE?: boolean;
|
|
@@ -84,6 +84,7 @@ export type iAPI<T extends { [key: string]: any }> = T & {
|
|
|
84
84
|
error?: string | ((r: AxiosResponse) => string | void);
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
+
// TODO - Eventually I believe we can support complex posts.
|
|
87
88
|
export type RequestQueryBody<
|
|
88
89
|
Method extends iRestMethods,
|
|
89
90
|
T extends { [key: string]: any },
|
|
@@ -91,9 +92,7 @@ export type RequestQueryBody<
|
|
|
91
92
|
Overrides extends { [key: string]: any } = {}
|
|
92
93
|
> = Method extends 'GET' | 'PUT' | 'DELETE'
|
|
93
94
|
? iAPI<RequestGetPutDeleteBody<Modify<T, Overrides> & Custom>>
|
|
94
|
-
:
|
|
95
|
-
? iAPI<RequestGetPutDeleteBody<Modify<T, Overrides> & Custom> & Modify<T, Overrides> & Custom>
|
|
96
|
-
: iAPI<Modify<T, Overrides> & Custom>;
|
|
95
|
+
: iAPI<Modify<T, Overrides> & Custom>;
|
|
97
96
|
|
|
98
97
|
export interface iCacheAPI<ResponseDataType = any> {
|
|
99
98
|
requestArgumentsSerialized: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {AxiosPromise} from "axios";
|
|
2
|
-
import { iCacheAPI } from "
|
|
2
|
+
import { iCacheAPI } from "../types/ormInterfaces";
|
|
3
3
|
|
|
4
4
|
// do not remove entries from this array. It is used to track the progress of API requests.
|
|
5
5
|
// position in array is important. Do not sort. To not add to begging.
|