@carbonorm/carbonnode 3.11.0 → 4.0.1
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 +123 -493
- package/dist/api/orm/builders/ConditionBuilder.d.ts +5 -0
- package/dist/api/orm/queries/PostQueryBuilder.d.ts +1 -1
- package/dist/api/restOrm.d.ts +4 -4
- package/dist/api/types/ormInterfaces.d.ts +32 -12
- package/dist/api/utils/cacheManager.d.ts +7 -8
- package/dist/index.cjs.js +289 -140
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +285 -139
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/cacheManager.test.ts +67 -0
- package/src/__tests__/expressServer.e2e.test.ts +104 -2
- package/src/__tests__/fixtures/c6.fixture.ts +2 -0
- package/src/__tests__/httpExecutorSingular.e2e.test.ts +35 -4
- package/src/__tests__/sakila-db/C6.js +1 -1
- package/src/__tests__/sakila-db/C6.ts +1 -1
- package/src/__tests__/sqlBuilders.test.ts +45 -0
- package/src/api/axiosInstance.ts +29 -0
- package/src/api/executors/HttpExecutor.ts +73 -97
- package/src/api/handlers/ExpressHandler.ts +30 -7
- package/src/api/orm/builders/ConditionBuilder.ts +74 -0
- package/src/api/orm/queries/PostQueryBuilder.ts +4 -2
- package/src/api/orm/queries/UpdateQueryBuilder.ts +3 -1
- package/src/api/types/ormInterfaces.ts +32 -18
- package/src/api/utils/cacheManager.ts +75 -34
- package/src/api/utils/testHelpers.ts +5 -3
- package/src/variables/isNode.ts +1 -8
package/package.json
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { AxiosPromise } from "axios";
|
|
2
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
apiRequestCache,
|
|
5
|
+
checkCache,
|
|
6
|
+
clearCache,
|
|
7
|
+
setCache,
|
|
8
|
+
} from "../api/utils/cacheManager";
|
|
9
|
+
import { checkAllRequestsComplete } from "../api/utils/testHelpers";
|
|
10
|
+
|
|
11
|
+
describe("cacheManager with map storage", () => {
|
|
12
|
+
const requestData = { id: 1 } as const;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
clearCache({ ignoreWarning: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("stores and returns cached requests", () => {
|
|
19
|
+
const mockRequest = Promise.resolve({ data: { rest: [] } }) as AxiosPromise;
|
|
20
|
+
|
|
21
|
+
setCache("GET", "table", requestData, {
|
|
22
|
+
requestArgumentsSerialized: "serialized",
|
|
23
|
+
request: mockRequest,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const cached = checkCache("GET", "table", { ...requestData });
|
|
27
|
+
expect(cached).toBe(mockRequest);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("clears cache entries", () => {
|
|
31
|
+
const mockRequest = Promise.resolve({ data: { rest: [] } }) as AxiosPromise;
|
|
32
|
+
|
|
33
|
+
setCache("GET", "table", requestData, {
|
|
34
|
+
requestArgumentsSerialized: "serialized",
|
|
35
|
+
request: mockRequest,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
clearCache({ ignoreWarning: true });
|
|
39
|
+
|
|
40
|
+
expect(apiRequestCache.size).toBe(0);
|
|
41
|
+
expect(checkCache("GET", "table", requestData)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("reports pending and completed requests via helper", () => {
|
|
45
|
+
const originalDocument = (global as any).document;
|
|
46
|
+
(global as any).document = {};
|
|
47
|
+
|
|
48
|
+
setCache("GET", "table", requestData, {
|
|
49
|
+
requestArgumentsSerialized: "pending",
|
|
50
|
+
request: Promise.resolve({ data: null }) as AxiosPromise,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(checkAllRequestsComplete()).toEqual(["pending"]);
|
|
54
|
+
|
|
55
|
+
setCache("GET", "table", requestData, {
|
|
56
|
+
requestArgumentsSerialized: "pending",
|
|
57
|
+
request: Promise.resolve({ data: null }) as AxiosPromise,
|
|
58
|
+
response: {} as any,
|
|
59
|
+
final: true,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(checkAllRequestsComplete()).toBe(true);
|
|
63
|
+
|
|
64
|
+
(global as any).document = originalDocument;
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
@@ -2,7 +2,7 @@ import mysql from "mysql2/promise";
|
|
|
2
2
|
import axios from "axios";
|
|
3
3
|
import { AddressInfo } from "net";
|
|
4
4
|
import {describe, it, expect, beforeAll, afterAll} from "vitest";
|
|
5
|
-
import {Actor, C6, GLOBAL_REST_PARAMETERS} from "./sakila-db/C6.js";
|
|
5
|
+
import {Actor, C6, Film_Actor, GLOBAL_REST_PARAMETERS} from "./sakila-db/C6.js";
|
|
6
6
|
import {C6C} from "../api/C6Constants";
|
|
7
7
|
import createTestServer from "./fixtures/createTestServer";
|
|
8
8
|
|
|
@@ -23,7 +23,12 @@ beforeAll(async () => {
|
|
|
23
23
|
const {port} = server.address() as AddressInfo;
|
|
24
24
|
|
|
25
25
|
GLOBAL_REST_PARAMETERS.restURL = `http://127.0.0.1:${port}/rest/`;
|
|
26
|
-
|
|
26
|
+
const axiosClient = axios.create();
|
|
27
|
+
axiosClient.interceptors.response.use(
|
|
28
|
+
response => response,
|
|
29
|
+
error => Promise.reject(new Error(error?.message ?? 'Request failed')),
|
|
30
|
+
);
|
|
31
|
+
GLOBAL_REST_PARAMETERS.axios = axiosClient;
|
|
27
32
|
GLOBAL_REST_PARAMETERS.verbose = false;
|
|
28
33
|
// ensure HTTP executor is used
|
|
29
34
|
// @ts-ignore
|
|
@@ -93,6 +98,79 @@ describe("ExpressHandler e2e", () => {
|
|
|
93
98
|
expect(data?.rest.length).toBe(0);
|
|
94
99
|
});
|
|
95
100
|
|
|
101
|
+
it("stringifies plain object values in PUT updates", async () => {
|
|
102
|
+
const first_name = `Json${Date.now()}`;
|
|
103
|
+
const last_name = `User${Date.now()}`;
|
|
104
|
+
|
|
105
|
+
await Actor.Post({
|
|
106
|
+
first_name,
|
|
107
|
+
last_name,
|
|
108
|
+
} as any);
|
|
109
|
+
|
|
110
|
+
const payload = { greeting: "hello", flags: [1, true] };
|
|
111
|
+
|
|
112
|
+
let data = await Actor.Get({
|
|
113
|
+
[C6C.WHERE]: { [Actor.FIRST_NAME]: first_name, [Actor.LAST_NAME]: last_name },
|
|
114
|
+
[C6C.PAGINATION]: { [C6C.LIMIT]: 1 },
|
|
115
|
+
} as any);
|
|
116
|
+
|
|
117
|
+
const actorId = data?.rest?.[0]?.actor_id;
|
|
118
|
+
expect(actorId).toBeTruthy();
|
|
119
|
+
|
|
120
|
+
await Actor.Put({
|
|
121
|
+
[C6C.WHERE]: { [Actor.ACTOR_ID]: actorId },
|
|
122
|
+
[C6C.UPDATE]: { first_name: payload },
|
|
123
|
+
} as any);
|
|
124
|
+
|
|
125
|
+
data = await Actor.Get({
|
|
126
|
+
[C6C.WHERE]: { [Actor.ACTOR_ID]: actorId },
|
|
127
|
+
} as any);
|
|
128
|
+
|
|
129
|
+
expect(data?.rest?.[0]?.first_name).toBe(JSON.stringify(payload));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("rejects operator-like objects in PUT updates", async () => {
|
|
133
|
+
const first_name = `Invalid${Date.now()}`;
|
|
134
|
+
const last_name = `User${Date.now()}`;
|
|
135
|
+
|
|
136
|
+
await Actor.Post({
|
|
137
|
+
first_name,
|
|
138
|
+
last_name,
|
|
139
|
+
} as any);
|
|
140
|
+
|
|
141
|
+
const data = await Actor.Get({
|
|
142
|
+
[C6C.WHERE]: { [Actor.FIRST_NAME]: first_name, [Actor.LAST_NAME]: last_name },
|
|
143
|
+
[C6C.PAGINATION]: { [C6C.LIMIT]: 1 },
|
|
144
|
+
} as any);
|
|
145
|
+
|
|
146
|
+
const actorId = data?.rest?.[0]?.actor_id;
|
|
147
|
+
expect(actorId).toBeTruthy();
|
|
148
|
+
|
|
149
|
+
const operatorLike = { [C6C.GREATER_THAN]: "oops" } as any;
|
|
150
|
+
|
|
151
|
+
const prevRestUrl = GLOBAL_REST_PARAMETERS.restURL;
|
|
152
|
+
const prevAxios = GLOBAL_REST_PARAMETERS.axios;
|
|
153
|
+
GLOBAL_REST_PARAMETERS.mysqlPool = pool as any;
|
|
154
|
+
delete (GLOBAL_REST_PARAMETERS as any).restURL;
|
|
155
|
+
delete (GLOBAL_REST_PARAMETERS as any).axios;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await Actor.Put({
|
|
159
|
+
[C6C.WHERE]: { [Actor.ACTOR_ID]: actorId },
|
|
160
|
+
[C6C.UPDATE]: { first_name: operatorLike },
|
|
161
|
+
} as any);
|
|
162
|
+
throw new Error('Expected PUT to reject for operator-like payload.');
|
|
163
|
+
} catch (error: any) {
|
|
164
|
+
const message = String(error?.message ?? error);
|
|
165
|
+
expect(message).toMatch(/operand/i);
|
|
166
|
+
} finally {
|
|
167
|
+
GLOBAL_REST_PARAMETERS.restURL = prevRestUrl;
|
|
168
|
+
GLOBAL_REST_PARAMETERS.axios = prevAxios;
|
|
169
|
+
// @ts-ignore
|
|
170
|
+
delete GLOBAL_REST_PARAMETERS.mysqlPool;
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
96
174
|
it("respects METHOD=GET override on POST", async () => {
|
|
97
175
|
const {restURL} = GLOBAL_REST_PARAMETERS;
|
|
98
176
|
const table = Actor.TABLE_NAME;
|
|
@@ -121,4 +199,28 @@ describe("ExpressHandler e2e", () => {
|
|
|
121
199
|
expect(response.status).toBe(200);
|
|
122
200
|
expect(response.data?.success).toBeTruthy();
|
|
123
201
|
});
|
|
202
|
+
|
|
203
|
+
it("allows composite keys when a URL primary is present", async () => {
|
|
204
|
+
const {restURL} = GLOBAL_REST_PARAMETERS;
|
|
205
|
+
const table = Film_Actor.TABLE_NAME;
|
|
206
|
+
|
|
207
|
+
const seed = await Film_Actor.Get({
|
|
208
|
+
[C6C.PAGINATION]: { [C6C.LIMIT]: 1 },
|
|
209
|
+
} as any);
|
|
210
|
+
|
|
211
|
+
const filmActor = seed.rest[0];
|
|
212
|
+
expect(filmActor).toBeTruthy();
|
|
213
|
+
|
|
214
|
+
const response = await axios.post(`${restURL}${table}/${filmActor.actor_id}?METHOD=GET`, {
|
|
215
|
+
[C6C.WHERE]: {
|
|
216
|
+
[Film_Actor.ACTOR_ID]: filmActor.actor_id,
|
|
217
|
+
[Film_Actor.FILM_ID]: filmActor.film_id,
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(response.status).toBe(200);
|
|
222
|
+
expect(response.data?.rest).toHaveLength(1);
|
|
223
|
+
expect(response.data?.rest?.[0]?.actor_id).toBe(filmActor.actor_id);
|
|
224
|
+
expect(response.data?.rest?.[0]?.film_id).toBe(filmActor.film_id);
|
|
225
|
+
});
|
|
124
226
|
});
|
|
@@ -43,6 +43,7 @@ export function buildTestConfig() {
|
|
|
43
43
|
'actor.first_name': 'first_name',
|
|
44
44
|
'actor.last_name': 'last_name',
|
|
45
45
|
'actor.binarycol': 'binarycol',
|
|
46
|
+
'actor.json_data': 'json_data',
|
|
46
47
|
} as const;
|
|
47
48
|
|
|
48
49
|
const filmActorCols = {
|
|
@@ -62,6 +63,7 @@ export function buildTestConfig() {
|
|
|
62
63
|
|
|
63
64
|
// Special-case: mark binary column as BINARY to test conversion
|
|
64
65
|
C6.TABLES.actor.TYPE_VALIDATION['binarycol'].MYSQL_TYPE = 'BINARY(16)';
|
|
66
|
+
C6.TABLES.actor.TYPE_VALIDATION['json_data'].MYSQL_TYPE = 'JSON';
|
|
65
67
|
|
|
66
68
|
const baseConfig: iRest<any, any, any> = {
|
|
67
69
|
C6,
|
|
@@ -23,7 +23,12 @@ beforeAll(async () => {
|
|
|
23
23
|
const { port } = server.address() as AddressInfo;
|
|
24
24
|
|
|
25
25
|
GLOBAL_REST_PARAMETERS.restURL = `http://127.0.0.1:${port}/rest/`;
|
|
26
|
-
|
|
26
|
+
const axiosClient = axios.create();
|
|
27
|
+
axiosClient.interceptors.response.use(
|
|
28
|
+
response => response,
|
|
29
|
+
error => Promise.reject(new Error(error?.message ?? "Request failed")),
|
|
30
|
+
);
|
|
31
|
+
GLOBAL_REST_PARAMETERS.axios = axiosClient;
|
|
27
32
|
GLOBAL_REST_PARAMETERS.verbose = false;
|
|
28
33
|
// ensure HTTP executor is used
|
|
29
34
|
// @ts-ignore
|
|
@@ -41,14 +46,14 @@ describe("HttpExecutor singular e2e", () => {
|
|
|
41
46
|
const last_name = `User${Date.now()}`;
|
|
42
47
|
|
|
43
48
|
// POST
|
|
44
|
-
|
|
45
|
-
expect(postRes.affected).toBe(1);
|
|
49
|
+
await Actor.Post({ first_name, last_name } as any);
|
|
46
50
|
|
|
47
51
|
// Fetch inserted id using complex query
|
|
48
52
|
let data = await Actor.Get({
|
|
49
53
|
[C6C.WHERE]: { [Actor.FIRST_NAME]: first_name, [Actor.LAST_NAME]: last_name },
|
|
50
54
|
[C6C.PAGINATION]: { [C6C.LIMIT]: 1 },
|
|
51
|
-
}
|
|
55
|
+
});
|
|
56
|
+
|
|
52
57
|
expect(data.rest).toHaveLength(1);
|
|
53
58
|
const testId = data.rest[0].actor_id;
|
|
54
59
|
|
|
@@ -89,4 +94,30 @@ describe("HttpExecutor singular e2e", () => {
|
|
|
89
94
|
expect(Array.isArray(data.rest)).toBe(true);
|
|
90
95
|
expect(data.rest.length).toBe(0);
|
|
91
96
|
});
|
|
97
|
+
|
|
98
|
+
it("exposes next when pagination continues", async () => {
|
|
99
|
+
const data = await Actor.Get({
|
|
100
|
+
[C6C.PAGINATION]: { [C6C.LIMIT]: 2 },
|
|
101
|
+
} as any);
|
|
102
|
+
|
|
103
|
+
expect(Array.isArray(data.rest)).toBe(true);
|
|
104
|
+
expect(data.rest).toHaveLength(2);
|
|
105
|
+
expect(typeof data.next).toBe("function");
|
|
106
|
+
|
|
107
|
+
const nextPage = await data.next?.();
|
|
108
|
+
expect(nextPage?.rest).toBeDefined();
|
|
109
|
+
expect(Array.isArray(nextPage?.rest)).toBe(true);
|
|
110
|
+
expect(nextPage?.rest?.length).toBeGreaterThan(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("exposes limit 1 does not expose next", async () => {
|
|
114
|
+
const data = await Actor.Get({
|
|
115
|
+
[C6C.PAGINATION]: { [C6C.LIMIT]: 1 },
|
|
116
|
+
} as any);
|
|
117
|
+
|
|
118
|
+
expect(Array.isArray(data.rest)).toBe(true);
|
|
119
|
+
expect(data.rest).toHaveLength(1);
|
|
120
|
+
expect(typeof data.next).toBe("undefined");
|
|
121
|
+
|
|
122
|
+
});
|
|
92
123
|
});
|
|
@@ -1342,7 +1342,7 @@ export const TABLES = {
|
|
|
1342
1342
|
};
|
|
1343
1343
|
export const C6 = {
|
|
1344
1344
|
...C6Constants,
|
|
1345
|
-
C6VERSION: '
|
|
1345
|
+
C6VERSION: '4.0.1',
|
|
1346
1346
|
IMPORT: async (tableName) => {
|
|
1347
1347
|
tableName = tableName.toLowerCase();
|
|
1348
1348
|
// if tableName is not a key in the TABLES object then throw an error
|
|
@@ -2009,7 +2009,7 @@ export type RestTableInterfaces = iActor
|
|
|
2009
2009
|
|
|
2010
2010
|
export const C6 : iC6Object<RestTableInterfaces> = {
|
|
2011
2011
|
...C6Constants,
|
|
2012
|
-
C6VERSION: '
|
|
2012
|
+
C6VERSION: '4.0.1',
|
|
2013
2013
|
IMPORT: async (tableName: string) : Promise<iDynamicApiImport> => {
|
|
2014
2014
|
|
|
2015
2015
|
tableName = tableName.toLowerCase();
|
|
@@ -61,6 +61,51 @@ describe('SQL Builders', () => {
|
|
|
61
61
|
expect(params).toEqual(['BOB', 'SMITH']);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
it('stringifies plain object inserts for JSON columns', () => {
|
|
65
|
+
const config = buildTestConfig();
|
|
66
|
+
const payload = { hello: 'world', nested: { ok: true } };
|
|
67
|
+
const qb = new PostQueryBuilder(config as any, {
|
|
68
|
+
[C6C.INSERT]: {
|
|
69
|
+
'actor.json_data': payload,
|
|
70
|
+
},
|
|
71
|
+
} as any, false);
|
|
72
|
+
|
|
73
|
+
const { sql, params } = qb.build('actor');
|
|
74
|
+
|
|
75
|
+
expect(sql).toContain('`json_data`');
|
|
76
|
+
expect(params).toEqual([JSON.stringify(payload)]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('stringifies dotted-key JSON payloads for JSON columns on UPDATE', () => {
|
|
80
|
+
const config = buildTestConfig();
|
|
81
|
+
const payload = { 'section1.preparedBy': 'Prepared by Assessorly, Co.' };
|
|
82
|
+
const qb = new UpdateQueryBuilder(config as any, {
|
|
83
|
+
[C6C.UPDATE]: {
|
|
84
|
+
'actor.json_data': payload,
|
|
85
|
+
},
|
|
86
|
+
WHERE: {
|
|
87
|
+
'actor.actor_id': [C6C.EQUAL, 5],
|
|
88
|
+
}
|
|
89
|
+
} as any, false);
|
|
90
|
+
|
|
91
|
+
const { params } = qb.build('actor');
|
|
92
|
+
|
|
93
|
+
expect(params).toEqual([JSON.stringify(payload), 5]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('throws on operator-shaped insert payloads', () => {
|
|
97
|
+
const config = buildTestConfig();
|
|
98
|
+
const qb = new PostQueryBuilder(config as any, {
|
|
99
|
+
[C6C.INSERT]: {
|
|
100
|
+
'actor.first_name': {
|
|
101
|
+
[C6C.GREATER_THAN]: 'ALICE',
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
} as any, false);
|
|
105
|
+
|
|
106
|
+
expect(() => qb.build('actor')).toThrowError(/requires two operands/);
|
|
107
|
+
});
|
|
108
|
+
|
|
64
109
|
it('builds UPDATE with WHERE and pagination', () => {
|
|
65
110
|
const config = buildTestConfig();
|
|
66
111
|
const qb = new UpdateQueryBuilder(config as any, {
|
package/src/api/axiosInstance.ts
CHANGED
|
@@ -151,7 +151,36 @@ axiosInstance.interceptors.request.use((config) => {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
(config as any).__carbonStart = performance.now();
|
|
155
|
+
|
|
154
156
|
return config;
|
|
155
157
|
});
|
|
156
158
|
|
|
159
|
+
|
|
160
|
+
axiosInstance.interceptors.response.use(
|
|
161
|
+
(response) => {
|
|
162
|
+
const end = performance.now();
|
|
163
|
+
const start = (response.config as any).__carbonStart;
|
|
164
|
+
(response as any).__carbonTiming = {
|
|
165
|
+
start,
|
|
166
|
+
end,
|
|
167
|
+
duration: end - start,
|
|
168
|
+
};
|
|
169
|
+
return response;
|
|
170
|
+
},
|
|
171
|
+
(error) => {
|
|
172
|
+
if (error.config) {
|
|
173
|
+
const end = performance.now();
|
|
174
|
+
const start = (error.config as any).__carbonStart;
|
|
175
|
+
(error as any).__carbonTiming = {
|
|
176
|
+
start,
|
|
177
|
+
end,
|
|
178
|
+
duration: end - start,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
|
|
157
186
|
export default axiosInstance
|