sonamu 0.13.2 → 0.13.3
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/decorators.d.ts +2 -3
- package/dist/api/decorators.d.ts.map +1 -1
- package/dist/api/decorators.js +1 -1
- package/dist/api/sonamu.d.ts.map +1 -1
- package/dist/api/sonamu.js +5 -5
- package/dist/database/base-model.d.ts.map +1 -1
- package/dist/database/base-model.js +8 -3
- package/dist/database/puri.d.ts +8 -3
- package/dist/database/puri.d.ts.map +1 -1
- package/dist/database/puri.js +120 -15
- package/dist/database/puri.types.d.ts +5 -2
- package/dist/database/puri.types.d.ts.map +1 -1
- package/dist/database/puri.types.js +1 -1
- package/dist/storage/buffered-file.d.ts +3 -3
- package/dist/storage/buffered-file.d.ts.map +1 -1
- package/dist/storage/buffered-file.js +2 -2
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +1 -1
- package/dist/storage/storage-manager.d.ts +3 -4
- package/dist/storage/storage-manager.d.ts.map +1 -1
- package/dist/storage/storage-manager.js +2 -2
- package/dist/storage/types.d.ts +3 -2
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/storage/uploaded-file.d.ts +3 -3
- package/dist/storage/uploaded-file.d.ts.map +1 -1
- package/dist/storage/uploaded-file.js +1 -1
- package/package.json +1 -1
- package/src/api/__tests__/sonamu.storage.test.ts +145 -0
- package/src/api/decorators.ts +2 -3
- package/src/api/sonamu.ts +6 -6
- package/src/database/__tests__/puri-hydration.test.ts +226 -0
- package/src/database/base-model.ts +7 -3
- package/src/database/puri.ts +204 -15
- package/src/database/puri.types.test-d.ts +29 -0
- package/src/database/puri.types.ts +10 -2
- package/src/storage/__tests__/storage-manager.test.ts +64 -0
- package/src/storage/buffered-file.ts +3 -3
- package/src/storage/index.ts +1 -1
- package/src/storage/storage-manager.ts +4 -5
- package/src/storage/storage.test-d.ts +47 -0
- package/src/storage/types.ts +3 -2
- package/src/storage/uploaded-file.ts +4 -4
package/src/api/sonamu.ts
CHANGED
|
@@ -411,6 +411,12 @@ class SonamuClass {
|
|
|
411
411
|
const { EntityManager } = await import("../entity/entity-manager");
|
|
412
412
|
await EntityManager.autoload(doSilent);
|
|
413
413
|
|
|
414
|
+
// 테스트와 CLI에서도 URL 생성에 쓰며, 드라이버는 use() 시점까지 생성하지 않는다.
|
|
415
|
+
if (this.config.server.storage) {
|
|
416
|
+
const { StorageManager } = await import("../storage/storage-manager");
|
|
417
|
+
this.storageValue = new StorageManager(this.config.server.storage);
|
|
418
|
+
}
|
|
419
|
+
|
|
414
420
|
// Cache 초기화
|
|
415
421
|
await this.initializeCache(this.config.server.cache, forTesting);
|
|
416
422
|
|
|
@@ -490,12 +496,6 @@ class SonamuClass {
|
|
|
490
496
|
this.server = server;
|
|
491
497
|
this.websocketRuntime = new WebSocketRuntime(options.websocket);
|
|
492
498
|
|
|
493
|
-
// Storage 설정 → StorageManager 생성
|
|
494
|
-
if (options.storage) {
|
|
495
|
-
const { StorageManager } = await import("../storage/storage-manager");
|
|
496
|
-
this.storageValue = new StorageManager(options.storage);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
499
|
// 플러그인 등록
|
|
500
500
|
if (options.plugins) {
|
|
501
501
|
await this.registerPlugins(server, options.plugins);
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { knex, type Knex } from "knex";
|
|
2
|
+
import { afterAll, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { Puri } from "../puri";
|
|
5
|
+
|
|
6
|
+
type Schema = { users: { id: number; name: string; nickname: string | null; payload: object } };
|
|
7
|
+
const db = knex({ client: "pg" });
|
|
8
|
+
const query = () => new Puri<Schema, Schema, Schema["users"]>(db, "users");
|
|
9
|
+
|
|
10
|
+
type RawRow = { [column: string]: string | number | boolean | null | RawRow[] };
|
|
11
|
+
|
|
12
|
+
function result(
|
|
13
|
+
builder: Knex.QueryBuilder,
|
|
14
|
+
rows: RawRow | RawRow[] | undefined,
|
|
15
|
+
presence: Record<string, readonly boolean[]> = {},
|
|
16
|
+
) {
|
|
17
|
+
if (vi.isMockFunction(builder.clone)) builder.clone.mockRestore();
|
|
18
|
+
const clone = builder.clone.bind(builder);
|
|
19
|
+
vi.spyOn(builder, "clone").mockImplementation(() => {
|
|
20
|
+
const cloned = clone();
|
|
21
|
+
result(cloned, rows, presence);
|
|
22
|
+
return cloned;
|
|
23
|
+
});
|
|
24
|
+
vi.spyOn(builder, "then").mockImplementation((fulfilled, rejected) => {
|
|
25
|
+
const markers = [
|
|
26
|
+
...builder.toQuery().matchAll(/"([^"]+)"\.\* IS DISTINCT FROM NULL AS "([^"]+)"/g),
|
|
27
|
+
];
|
|
28
|
+
const withPresence = (row: RawRow, index: number) => ({
|
|
29
|
+
...row,
|
|
30
|
+
...Object.fromEntries(
|
|
31
|
+
markers.map(([, alias, column]) => [column, presence[alias]?.[index] ?? true]),
|
|
32
|
+
),
|
|
33
|
+
});
|
|
34
|
+
const output = Array.isArray(rows)
|
|
35
|
+
? rows.map(withPresence)
|
|
36
|
+
: rows
|
|
37
|
+
? withPresence(rows, 0)
|
|
38
|
+
: rows;
|
|
39
|
+
return Promise.resolve(output).then(fulfilled, rejected);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
afterAll(() => db.destroy());
|
|
44
|
+
|
|
45
|
+
describe("Puri 중첩 select 결과", () => {
|
|
46
|
+
it("중첩 select 별칭만 복원하고 평평한 별칭과 JSON 값은 보존한다", async () => {
|
|
47
|
+
const selected = query().select({
|
|
48
|
+
user: { id: "users.id", data: "users.payload" },
|
|
49
|
+
literal__name: "users.name",
|
|
50
|
+
payload: "users.payload",
|
|
51
|
+
});
|
|
52
|
+
const payload = [{ arbitrary__key: "원본" }];
|
|
53
|
+
result(selected.rawQuery(), [
|
|
54
|
+
{ user__id: 1, user__data: payload, literal__name: "이름", payload },
|
|
55
|
+
]);
|
|
56
|
+
const rows = await selected;
|
|
57
|
+
expect(rows).toEqual([{ user: { id: 1, data: payload }, literal__name: "이름", payload }]);
|
|
58
|
+
expect(rows[0]?.user.data).toBe(payload);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("중첩 select의 빈 JSON 배열을 관계 없음으로 바꾸지 않는다", async () => {
|
|
62
|
+
const selected = query().select({ user: { data: "users.payload" } });
|
|
63
|
+
result(selected.rawQuery(), [{ user__data: [] }]);
|
|
64
|
+
expect(await selected).toEqual([{ user: { data: [] } }]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("중첩 단계마다 null 관계를 복원하고 id가 없는 그룹도 처리한다", async () => {
|
|
68
|
+
const selected = query()
|
|
69
|
+
.leftJoin({ user: "users" }, (join) => join.on("users.id", "user.id"))
|
|
70
|
+
.leftJoin({ user__detail: "users" }, (join) => join.on("users.id", "user__detail.id"))
|
|
71
|
+
.select({ user: { id: "user.id", detail: { name: "user__detail.name" } } });
|
|
72
|
+
result(
|
|
73
|
+
selected.rawQuery(),
|
|
74
|
+
[
|
|
75
|
+
{ user__id: 1, user__detail__name: null },
|
|
76
|
+
{ user__id: null, user__detail__name: "무시할 값" },
|
|
77
|
+
],
|
|
78
|
+
{ user: [true, false], user__detail: [false, true] },
|
|
79
|
+
);
|
|
80
|
+
expect(await selected).toEqual([{ user: { id: 1, detail: null } }, { user: null }]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("nullable 필드만 선택한 일반 그룹은 null로 축약하지 않는다", async () => {
|
|
84
|
+
const selected = query().select({ profile: { nickname: "users.nickname" } });
|
|
85
|
+
result(selected.rawQuery(), [{ profile__nickname: null }]);
|
|
86
|
+
expect(await selected).toEqual([{ profile: { nickname: null } }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("LEFT JOIN 별칭과 다른 이름으로 묶은 그룹은 객체로 유지한다", async () => {
|
|
90
|
+
const selected = query()
|
|
91
|
+
.leftJoin({ colleague: "users" }, (join) => join.on("users.id", "colleague.id"))
|
|
92
|
+
.select({
|
|
93
|
+
profile: { nickname: "colleague.nickname" },
|
|
94
|
+
colleague: { nickname: "colleague.nickname" },
|
|
95
|
+
});
|
|
96
|
+
result(selected.rawQuery(), [{ profile__nickname: null, colleague__nickname: null }], {
|
|
97
|
+
colleague: [false],
|
|
98
|
+
});
|
|
99
|
+
expect(await selected).toEqual([{ profile: { nickname: null }, colleague: null }]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("INNER JOIN 별칭과 같은 그룹도 nullable 필드를 보존한다", async () => {
|
|
103
|
+
const selected = query()
|
|
104
|
+
.join({ colleague: "users" }, "users.id", "colleague.id")
|
|
105
|
+
.select({ colleague: { nickname: "colleague.nickname" } });
|
|
106
|
+
result(selected.rawQuery(), [{ colleague__nickname: null }]);
|
|
107
|
+
expect(await selected).toEqual([{ colleague: { nickname: null } }]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("선택한 값이 모두 null이어도 실제 LEFT JOIN 행이 있으면 객체를 유지한다", async () => {
|
|
111
|
+
const selected = query()
|
|
112
|
+
.leftJoin({ colleague: "users" }, (join) => join.on("users.id", "colleague.id"))
|
|
113
|
+
.select({ colleague: { nickname: "colleague.nickname" } });
|
|
114
|
+
result(selected.rawQuery(), [{ colleague__nickname: null }, { colleague__nickname: null }], {
|
|
115
|
+
colleague: [true, false],
|
|
116
|
+
});
|
|
117
|
+
expect(await selected).toEqual([{ colleague: { nickname: null } }, { colleague: null }]);
|
|
118
|
+
expect(selected.toQuery()).not.toContain("__sonamu_presence_");
|
|
119
|
+
expect(await selected.rawQuery()).toEqual([
|
|
120
|
+
{ colleague__nickname: null },
|
|
121
|
+
{ colleague__nickname: null },
|
|
122
|
+
]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("그룹화한 no-id projection은 SQL 열을 추가하지 않고 객체를 유지한다", async () => {
|
|
126
|
+
const selected = query()
|
|
127
|
+
.leftJoin({ colleague: "users" }, (join) => join.on("users.id", "colleague.id"))
|
|
128
|
+
.select({ colleague: { nickname: "colleague.nickname" } })
|
|
129
|
+
.groupBy("colleague.nickname");
|
|
130
|
+
result(selected.rawQuery(), [{ colleague__nickname: null }], { colleague: [false] });
|
|
131
|
+
expect(await selected).toEqual([{ colleague: { nickname: null } }]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it.each([false, true])(
|
|
135
|
+
"다른 이름의 관계 그룹도 id 원본 LEFT JOIN을 따른다 (원시 표현식: %s)",
|
|
136
|
+
async (raw) => {
|
|
137
|
+
const selected = query()
|
|
138
|
+
.leftJoin({ staffs: "users" }, (join) => join.on("users.id", "staffs.id"))
|
|
139
|
+
.select({ manager: { id: "staffs.id", name: "staffs.name" } });
|
|
140
|
+
if (raw) selected.appendSelect({ total: Puri.rawNumber("(SELECT COUNT(*) FROM users)") });
|
|
141
|
+
result(
|
|
142
|
+
selected.rawQuery(),
|
|
143
|
+
[
|
|
144
|
+
{ manager__id: 1, manager__name: "담당자" },
|
|
145
|
+
{ manager__id: null, manager__name: null },
|
|
146
|
+
],
|
|
147
|
+
{ staffs: [true, false] },
|
|
148
|
+
);
|
|
149
|
+
expect(await selected.clone()).toEqual([
|
|
150
|
+
{ manager: { id: 1, name: "담당자" } },
|
|
151
|
+
{ manager: null },
|
|
152
|
+
]);
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
it("관계 id 선택을 clear한 뒤 일반 nullable id 그룹을 선택하면 관계 의미도 지운다", async () => {
|
|
157
|
+
const selected = query()
|
|
158
|
+
.leftJoin({ staffs: "users" }, (join) => join.on("users.id", "staffs.id"))
|
|
159
|
+
.select({ manager: { id: "staffs.id" } })
|
|
160
|
+
.clear("select")
|
|
161
|
+
.select({ manager: { id: "users.nickname" } });
|
|
162
|
+
result(selected.rawQuery(), [{ manager__id: null }]);
|
|
163
|
+
expect(await selected).toEqual([{ manager: { id: null } }]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("객체 키 안의 구분자는 중첩 경로로 다시 해석하지 않는다", async () => {
|
|
167
|
+
const selected = query().select({ user__profile: { display__name: "users.name" } });
|
|
168
|
+
result(selected.rawQuery(), [{ user__profile__display__name: "이름" }]);
|
|
169
|
+
expect(await selected).toEqual([{ user__profile: { display__name: "이름" } }]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it.each(["then", "catch", "finally"] as const)(
|
|
173
|
+
"%s 실행에서도 중첩 결과를 반환한다",
|
|
174
|
+
async (method) => {
|
|
175
|
+
const selected = query().select({ user: { id: "users.id" } });
|
|
176
|
+
result(selected.rawQuery(), [{ user__id: 1 }]);
|
|
177
|
+
const rows =
|
|
178
|
+
method === "then"
|
|
179
|
+
? await selected.then()
|
|
180
|
+
: method === "catch"
|
|
181
|
+
? await selected.catch()
|
|
182
|
+
: await selected.finally();
|
|
183
|
+
expect(rows).toEqual([{ user: { id: 1 } }]);
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
it.each(["then", "catch", "finally"] as const)(
|
|
188
|
+
"first의 %s 실행과 조회 결과 없음도 처리한다",
|
|
189
|
+
async (method) => {
|
|
190
|
+
const selected = query()
|
|
191
|
+
.select({ user: { id: "users.id" } })
|
|
192
|
+
.first();
|
|
193
|
+
result(selected.knexQuery, { user__id: 1 });
|
|
194
|
+
const row =
|
|
195
|
+
method === "then"
|
|
196
|
+
? await selected.then()
|
|
197
|
+
: method === "catch"
|
|
198
|
+
? await selected.catch()
|
|
199
|
+
: await selected.finally();
|
|
200
|
+
expect(row).toEqual({ user: { id: 1 } });
|
|
201
|
+
result(selected.knexQuery, undefined);
|
|
202
|
+
expect(await selected).toBeUndefined();
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
it.each(["select", "columns"] as const)(
|
|
207
|
+
"clone과 appendSelect는 중첩 경로를 보존하고 clear(%s)는 복제본에만 적용된다",
|
|
208
|
+
async (statement) => {
|
|
209
|
+
const original = query().select({ user: { id: "users.id" } });
|
|
210
|
+
const cloned = original.clone().appendSelect({ detail: { name: "users.name" } });
|
|
211
|
+
result(cloned.rawQuery(), [{ user__id: 1, detail__name: "이름" }]);
|
|
212
|
+
expect(await cloned).toEqual([{ user: { id: 1 }, detail: { name: "이름" } }]);
|
|
213
|
+
cloned.clear(statement).select({ user__id: "users.id" });
|
|
214
|
+
result(cloned.rawQuery(), [{ user__id: 2 }]);
|
|
215
|
+
expect(await cloned).toEqual([{ user__id: 2 }]);
|
|
216
|
+
result(original.rawQuery(), [{ user__id: 1 }]);
|
|
217
|
+
expect(await original).toEqual([{ user: { id: 1 } }]);
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
it("rawQuery는 기존의 평평한 SQL 결과를 그대로 반환한다", async () => {
|
|
222
|
+
const selected = query().select({ user: { id: "users.id" } });
|
|
223
|
+
result(selected.rawQuery(), [{ user__id: 1 }]);
|
|
224
|
+
expect(await selected.rawQuery()).toEqual([{ user__id: 1 }]);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -9,6 +9,7 @@ import { EntityManager } from "../entity/entity-manager";
|
|
|
9
9
|
import { type FilterOperator, type FilterQuery } from "../filter/types";
|
|
10
10
|
import { normalizeFilterQuery, validateSonamuFilters } from "../filter/utils";
|
|
11
11
|
import { convertDomainToCategory } from "../logger/category";
|
|
12
|
+
import { Naite } from "../naite/naite";
|
|
12
13
|
import { type DatabaseSchemaExtend, type SonamuQueryMode } from "../types/types";
|
|
13
14
|
import { type ListResult } from "../utils/model";
|
|
14
15
|
import { isObjectValue } from "../utils/runtime-value";
|
|
@@ -517,7 +518,9 @@ export class BaseModelClass<
|
|
|
517
518
|
return qb.limit(num).offset(num * (page - 1));
|
|
518
519
|
}
|
|
519
520
|
})();
|
|
520
|
-
|
|
521
|
+
// 로더는 평평한 refId 별칭을 사용하므로 로딩을 마친 뒤 hydrate합니다.
|
|
522
|
+
Naite.t("puri:executed-query", limitedQb.toQuery());
|
|
523
|
+
let unloadedRows = await limitedQb.rawQuery();
|
|
521
524
|
|
|
522
525
|
if (debug) {
|
|
523
526
|
qb.debug();
|
|
@@ -554,8 +557,9 @@ export class BaseModelClass<
|
|
|
554
557
|
resolveLoaderQb.debug();
|
|
555
558
|
}
|
|
556
559
|
|
|
557
|
-
|
|
558
|
-
|
|
560
|
+
Naite.t("puri:executed-query", resolveLoaderQb.toQuery());
|
|
561
|
+
// SAFETY: 로더의 select 쿼리는 평평한 레코드 배열을 반환합니다.
|
|
562
|
+
let loadedRows = (await resolveLoaderQb.rawQuery()) as any[];
|
|
559
563
|
|
|
560
564
|
// 중첩 loaders가 있으면 재귀 처리
|
|
561
565
|
if (nestedLoaders && nestedLoaders.length > 0) {
|
package/src/database/puri.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/* oxlint-disable @typescript-eslint/no-explicit-any */ // Puri는 다양한 타입을 사용하고 있습니다.
|
|
2
2
|
|
|
3
3
|
import assert from "assert";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { type EventEmitter } from "node:events";
|
|
4
6
|
|
|
5
7
|
import chalk from "chalk";
|
|
6
8
|
import inflection from "inflection";
|
|
@@ -182,9 +184,108 @@ function isNestedSelectObject<TTables extends object>(
|
|
|
182
184
|
return isObjectValue(value) && !("_type" in value);
|
|
183
185
|
}
|
|
184
186
|
|
|
187
|
+
type SelectProjection = Map<string, string | SelectProjection>;
|
|
188
|
+
type SelectResultValue =
|
|
189
|
+
| string
|
|
190
|
+
| number
|
|
191
|
+
| boolean
|
|
192
|
+
| bigint
|
|
193
|
+
| Date
|
|
194
|
+
| Buffer
|
|
195
|
+
| null
|
|
196
|
+
| undefined
|
|
197
|
+
| SelectRow
|
|
198
|
+
| SelectResultValue[];
|
|
199
|
+
type SelectRow = { [column: string]: SelectResultValue };
|
|
200
|
+
type SelectResult = SelectRow | SelectRow[] | undefined;
|
|
201
|
+
|
|
202
|
+
function getSelectRelationAlias(
|
|
203
|
+
path: string,
|
|
204
|
+
correlations: ReadonlyMap<string, CorrelationRegistration>,
|
|
205
|
+
identitySources: ReadonlyMap<string, string>,
|
|
206
|
+
): string | undefined {
|
|
207
|
+
const identityAlias = identitySources.get(`${path}__id`);
|
|
208
|
+
const identityRelation =
|
|
209
|
+
identityAlias === undefined ? undefined : correlations.get(identityAlias);
|
|
210
|
+
if (identityRelation?.kind === "join" && identityRelation.joinType === "leftJoin")
|
|
211
|
+
return identityAlias;
|
|
212
|
+
const relation = correlations.get(path);
|
|
213
|
+
return relation?.kind === "join" && relation.joinType === "leftJoin" ? path : undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function hydrateSelectResult(
|
|
217
|
+
result: SelectResult,
|
|
218
|
+
paths: ReadonlyMap<string, readonly string[]>,
|
|
219
|
+
correlations: ReadonlyMap<string, CorrelationRegistration>,
|
|
220
|
+
presenceColumns: ReadonlyMap<string, string>,
|
|
221
|
+
identitySources: ReadonlyMap<string, string>,
|
|
222
|
+
): SelectResult {
|
|
223
|
+
if (paths.size === 0 || result === undefined) return result;
|
|
224
|
+
|
|
225
|
+
const hiddenColumns = new Set(presenceColumns.values());
|
|
226
|
+
const projection: SelectProjection = new Map();
|
|
227
|
+
for (const [alias, path] of paths) {
|
|
228
|
+
let parent = projection;
|
|
229
|
+
for (const key of path.slice(0, -1)) {
|
|
230
|
+
let child = parent.get(key);
|
|
231
|
+
if (!(child instanceof Map)) {
|
|
232
|
+
child = new Map();
|
|
233
|
+
parent.set(key, child);
|
|
234
|
+
}
|
|
235
|
+
parent = child;
|
|
236
|
+
}
|
|
237
|
+
const leaf = path[path.length - 1];
|
|
238
|
+
assert(leaf !== undefined);
|
|
239
|
+
parent.set(leaf, alias);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const hydrateRow = (row: SelectRow) => {
|
|
243
|
+
const project = (fields: SelectProjection, path: readonly string[]): SelectRow | null => {
|
|
244
|
+
const entries: [string, SelectResultValue][] = [];
|
|
245
|
+
for (const [key, field] of fields) {
|
|
246
|
+
if (field instanceof Map) {
|
|
247
|
+
entries.push([key, project(field, [...path, key])]);
|
|
248
|
+
} else if (Object.hasOwn(row, field)) {
|
|
249
|
+
entries.push([key, row[field]]);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const value = Object.fromEntries(entries);
|
|
253
|
+
// 그룹 이름이 달라도 명시한 id의 LEFT JOIN 원본으로 관계 존재 여부를 판단합니다.
|
|
254
|
+
const relationAlias = getSelectRelationAlias(path.join("__"), correlations, identitySources);
|
|
255
|
+
const presenceColumn =
|
|
256
|
+
relationAlias === undefined ? undefined : presenceColumns.get(relationAlias);
|
|
257
|
+
if (
|
|
258
|
+
relationAlias !== undefined &&
|
|
259
|
+
(presenceColumn !== undefined
|
|
260
|
+
? row[presenceColumn] === false
|
|
261
|
+
: Object.hasOwn(value, "id") && value.id === null)
|
|
262
|
+
) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
return value;
|
|
266
|
+
};
|
|
267
|
+
return {
|
|
268
|
+
...Object.fromEntries(
|
|
269
|
+
Object.entries(row).filter(([key]) => !paths.has(key) && !hiddenColumns.has(key)),
|
|
270
|
+
),
|
|
271
|
+
...Object.fromEntries(
|
|
272
|
+
[...projection].map(([key, fields]) => {
|
|
273
|
+
assert(fields instanceof Map);
|
|
274
|
+
return [key, project(fields, [key])];
|
|
275
|
+
}),
|
|
276
|
+
),
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// JSON 내부와 직접 지정한 __ 별칭은 중첩 select의 경로가 아니므로 탐색하지 않습니다.
|
|
281
|
+
return Array.isArray(result) ? result.map(hydrateRow) : hydrateRow(result);
|
|
282
|
+
}
|
|
283
|
+
|
|
185
284
|
export class Puri<TSchema, TTables extends object, TResult> {
|
|
186
285
|
private knexQuery: Knex.QueryBuilder;
|
|
187
286
|
private tableSpec: TableSpec | null = null;
|
|
287
|
+
private nestedSelectPaths = new Map<string, readonly string[]>();
|
|
288
|
+
private nestedSelectIdentitySources = new Map<string, string>();
|
|
188
289
|
private correlationRegistry = new Map<string, CorrelationRegistration>();
|
|
189
290
|
|
|
190
291
|
// 생성자 시그니처들
|
|
@@ -617,21 +718,28 @@ export class Puri<TSchema, TTables extends object, TResult> {
|
|
|
617
718
|
*/
|
|
618
719
|
private *flattenSelectEntries(
|
|
619
720
|
selectObj: SelectObject<TTables>,
|
|
620
|
-
|
|
721
|
+
path: readonly string[] = [],
|
|
621
722
|
): Generator<[string, SelectValue<TTables>], void> {
|
|
622
723
|
for (const [key, value] of Object.entries(selectObj)) {
|
|
623
|
-
const
|
|
724
|
+
const fieldPath = [...path, key];
|
|
725
|
+
const fullKey = fieldPath.join("__");
|
|
624
726
|
|
|
625
727
|
if (isNestedSelectObject(value)) {
|
|
626
|
-
yield* this.flattenSelectEntries(value,
|
|
728
|
+
yield* this.flattenSelectEntries(value, fieldPath);
|
|
627
729
|
} else {
|
|
730
|
+
if (fieldPath.length > 1) this.nestedSelectPaths.set(fullKey, fieldPath);
|
|
731
|
+
else this.nestedSelectPaths.delete(fullKey);
|
|
732
|
+
this.nestedSelectIdentitySources.delete(fullKey);
|
|
733
|
+
if (fieldPath.length > 1 && key === "id" && isStringValue(value) && value.includes(".")) {
|
|
734
|
+
this.nestedSelectIdentitySources.set(fullKey, value.slice(0, value.lastIndexOf(".")));
|
|
735
|
+
}
|
|
628
736
|
yield [fullKey, value];
|
|
629
737
|
}
|
|
630
738
|
}
|
|
631
739
|
}
|
|
632
740
|
|
|
633
|
-
private flattenSelect(selectObj: SelectObject<TTables
|
|
634
|
-
return Object.fromEntries(this.flattenSelectEntries(selectObj
|
|
741
|
+
private flattenSelect(selectObj: SelectObject<TTables>) {
|
|
742
|
+
return Object.fromEntries(this.flattenSelectEntries(selectObj));
|
|
635
743
|
}
|
|
636
744
|
|
|
637
745
|
// SELECT (select는 overwrite, appendSelect는 append)
|
|
@@ -681,6 +789,10 @@ export class Puri<TSchema, TTables extends object, TResult> {
|
|
|
681
789
|
// CLEAR
|
|
682
790
|
clear(statement: ClearStatements): this {
|
|
683
791
|
this.knexQuery.clear(statement);
|
|
792
|
+
if (statement === "select" || statement === "columns") {
|
|
793
|
+
this.nestedSelectPaths.clear();
|
|
794
|
+
this.nestedSelectIdentitySources.clear();
|
|
795
|
+
}
|
|
684
796
|
if (statement === "join") {
|
|
685
797
|
this.clearJoinRegistrations();
|
|
686
798
|
}
|
|
@@ -1484,28 +1596,101 @@ export class Puri<TSchema, TTables extends object, TResult> {
|
|
|
1484
1596
|
return this;
|
|
1485
1597
|
}
|
|
1486
1598
|
|
|
1599
|
+
private executeSelect(): Promise<SelectResult> {
|
|
1600
|
+
if (this.nestedSelectPaths.size === 0) return this.knexQuery.then();
|
|
1601
|
+
const presenceColumns = new Map<string, string>();
|
|
1602
|
+
// 원본 쿼리는 rawQuery와 서브셋 로더가 공유하므로 실행 복제본에만 판별식을 추가합니다.
|
|
1603
|
+
let executable = this.knexQuery;
|
|
1604
|
+
if (this.canSelectRelationPresence()) {
|
|
1605
|
+
const sql = this.toQuery();
|
|
1606
|
+
for (const path of this.nestedSelectPaths.values()) {
|
|
1607
|
+
for (let length = 1; length < path.length; length++) {
|
|
1608
|
+
const alias = getSelectRelationAlias(
|
|
1609
|
+
path.slice(0, length).join("__"),
|
|
1610
|
+
this.correlationRegistry,
|
|
1611
|
+
this.nestedSelectIdentitySources,
|
|
1612
|
+
);
|
|
1613
|
+
if (alias === undefined || presenceColumns.has(alias)) continue;
|
|
1614
|
+
let column: string;
|
|
1615
|
+
do {
|
|
1616
|
+
column = `__sonamu_presence_${randomUUID().replaceAll("-", "")}`;
|
|
1617
|
+
} while (sql.includes(column));
|
|
1618
|
+
if (presenceColumns.size === 0) {
|
|
1619
|
+
executable = this.knexQuery.clone();
|
|
1620
|
+
// Knex.clone이 복제하지 않는 실행 제한과 이벤트는 원본의 계약을 유지합니다.
|
|
1621
|
+
// SAFETY: timeout은 Knex QueryBuilder가 보관하는 실행 옵션입니다.
|
|
1622
|
+
const timeout = (this.knexQuery as any)._timeout;
|
|
1623
|
+
if (isNumberValue(timeout)) {
|
|
1624
|
+
// SAFETY: cancelOnTimeout은 timeout 옵션과 함께 Knex가 기록하는 불리언입니다.
|
|
1625
|
+
executable.timeout(timeout, {
|
|
1626
|
+
cancel: (this.knexQuery as any)._cancelOnTimeout === true,
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
// SAFETY: Knex QueryBuilder는 EventEmitter를 상속하지만 공개 타입에는 일부 메서드만 있습니다.
|
|
1630
|
+
const emitter = this.knexQuery as Knex.QueryBuilder & EventEmitter;
|
|
1631
|
+
// SAFETY: 실행 복제본도 동일한 Knex EventEmitter 구현입니다.
|
|
1632
|
+
const executionEmitter: EventEmitter = executable as Knex.QueryBuilder & EventEmitter;
|
|
1633
|
+
for (const event of emitter.eventNames()) {
|
|
1634
|
+
executionEmitter.on(event, (...args: any[]) => emitter.emit(event, ...args));
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
presenceColumns.set(alias, column);
|
|
1638
|
+
executable.select(this.knex.raw("??.* IS DISTINCT FROM NULL AS ??", [alias, column]));
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
return executable.then((result) =>
|
|
1643
|
+
hydrateSelectResult(
|
|
1644
|
+
result,
|
|
1645
|
+
this.nestedSelectPaths,
|
|
1646
|
+
this.correlationRegistry,
|
|
1647
|
+
presenceColumns,
|
|
1648
|
+
this.nestedSelectIdentitySources,
|
|
1649
|
+
),
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
private canSelectRelationPresence(): boolean {
|
|
1654
|
+
// 그룹·집계·DISTINCT·원시 표현식에는 열을 추가하면 SQL 의미가 바뀔 수 있습니다.
|
|
1655
|
+
// SAFETY: Knex의 statement 구조는 다른 JOIN/벡터 처리와 동일한 내부 계약으로 읽습니다.
|
|
1656
|
+
const statements = (this.knexQuery as any)._statements;
|
|
1657
|
+
return statements.every((statement: any) => {
|
|
1658
|
+
if (["group", "having", "union"].includes(statement.grouping)) return false;
|
|
1659
|
+
if (statement.grouping !== "columns") return true;
|
|
1660
|
+
if (statement.distinct || statement.distinctOn || statement.type) return false;
|
|
1661
|
+
return statement.value.every((column: string | { ref?: string }) =>
|
|
1662
|
+
isStringValue(column)
|
|
1663
|
+
? !column.includes("*")
|
|
1664
|
+
: isObjectValue(column) && isStringValue(column.ref),
|
|
1665
|
+
);
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1487
1669
|
// 실행 메서드들 - thenable 구현
|
|
1488
1670
|
then<TResult1 = Expand<TResult>[], TResult2 = never>(
|
|
1489
1671
|
onfulfilled?: ((value: Expand<TResult>[]) => TResult1 | PromiseLike<TResult1>) | null,
|
|
1490
1672
|
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
|
|
1491
1673
|
): Promise<TResult1 | TResult2> {
|
|
1492
1674
|
Naite.t("puri:executed-query", this.toQuery());
|
|
1493
|
-
// SAFETY:
|
|
1494
|
-
return this.
|
|
1675
|
+
// SAFETY: SQL 결과에 선택 경로만 복원하므로 선언된 결과 배열 타입을 유지합니다.
|
|
1676
|
+
return this.executeSelect().then(onfulfilled as any, onrejected);
|
|
1495
1677
|
}
|
|
1496
1678
|
catch<TResult2 = never>(
|
|
1497
1679
|
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
|
|
1498
|
-
): Promise<TResult | TResult2> {
|
|
1499
|
-
return this.
|
|
1680
|
+
): Promise<Expand<TResult>[] | TResult2> {
|
|
1681
|
+
return this.then().catch(onrejected);
|
|
1500
1682
|
}
|
|
1501
|
-
finally(onfinally?: (() => void) | null): Promise<TResult> {
|
|
1502
|
-
return this.
|
|
1683
|
+
finally(onfinally?: (() => void) | null): Promise<Expand<TResult>[]> {
|
|
1684
|
+
return this.then().finally(onfinally);
|
|
1503
1685
|
}
|
|
1504
1686
|
|
|
1505
1687
|
// 하나만 쿼리
|
|
1506
1688
|
first(): ResolvedPuri<Expand<TResult>, never> {
|
|
1507
1689
|
this.knexQuery.first();
|
|
1508
|
-
return new ResolvedPuri(this.knexQuery, this.knex, this.tableSpec)
|
|
1690
|
+
return new ResolvedPuri(this.knexQuery, this.knex, this.tableSpec, () => {
|
|
1691
|
+
// SAFETY: first의 SQL 결과에 선택 경로만 복원하므로 선언된 단일 레코드 타입을 유지합니다.
|
|
1692
|
+
return this.executeSelect() as Promise<Expand<TResult>>;
|
|
1693
|
+
});
|
|
1509
1694
|
}
|
|
1510
1695
|
|
|
1511
1696
|
// 쿼리한 레코드에서 특정 컬럼만 추출한 배열 리턴
|
|
@@ -1630,6 +1815,8 @@ export class Puri<TSchema, TTables extends object, TResult> {
|
|
|
1630
1815
|
const newPuri = new Puri<TSchema, TTables, TResult>(this.knex, "dual");
|
|
1631
1816
|
newPuri.knexQuery = this.knexQuery.clone();
|
|
1632
1817
|
newPuri.tableSpec = this.tableSpec;
|
|
1818
|
+
newPuri.nestedSelectPaths = new Map(this.nestedSelectPaths);
|
|
1819
|
+
newPuri.nestedSelectIdentitySources = new Map(this.nestedSelectIdentitySources);
|
|
1633
1820
|
newPuri.correlationRegistry = new Map(this.correlationRegistry);
|
|
1634
1821
|
return newPuri;
|
|
1635
1822
|
}
|
|
@@ -2156,6 +2343,7 @@ export class ResolvedPuri<TResolved, TReturning> implements Promise<TResolved> {
|
|
|
2156
2343
|
public knexQuery: Knex.QueryBuilder,
|
|
2157
2344
|
private knex: Knex,
|
|
2158
2345
|
private tableSpec: TableSpec | null = null,
|
|
2346
|
+
private resolveResult?: () => Promise<TResolved>,
|
|
2159
2347
|
) {}
|
|
2160
2348
|
|
|
2161
2349
|
[Symbol.toStringTag]: string = "Promise";
|
|
@@ -2174,18 +2362,19 @@ export class ResolvedPuri<TResolved, TReturning> implements Promise<TResolved> {
|
|
|
2174
2362
|
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
|
|
2175
2363
|
): Promise<TResult1 | TResult2> {
|
|
2176
2364
|
Naite.t("puri:executed-query", this.toQuery());
|
|
2365
|
+
const result = this.resolveResult ? this.resolveResult() : this.knexQuery;
|
|
2177
2366
|
// SAFETY: 쿼리 빌더의 제네릭 계약과 선행 검증이 이 타입을 보장합니다.
|
|
2178
|
-
return
|
|
2367
|
+
return result.then(onfulfilled as any, onrejected);
|
|
2179
2368
|
}
|
|
2180
2369
|
|
|
2181
2370
|
catch<TResult2 = never>(
|
|
2182
2371
|
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
|
|
2183
2372
|
): Promise<TResolved | TResult2> {
|
|
2184
|
-
return this.
|
|
2373
|
+
return this.then().catch(onrejected);
|
|
2185
2374
|
}
|
|
2186
2375
|
|
|
2187
2376
|
finally(onfinally?: (() => void) | null): Promise<TResolved> {
|
|
2188
|
-
return this.
|
|
2377
|
+
return this.then().finally(onfinally);
|
|
2189
2378
|
}
|
|
2190
2379
|
|
|
2191
2380
|
// ON CONFLICT - 컬럼 기반
|
|
@@ -922,3 +922,32 @@ describe("Puri ensureJoin methods", () => {
|
|
|
922
922
|
});
|
|
923
923
|
});
|
|
924
924
|
});
|
|
925
|
+
|
|
926
|
+
describe("Puri 중첩 결과 실행 타입", () => {
|
|
927
|
+
it("then, catch, finally가 동일한 중첩 레코드 배열을 반환한다", () => {
|
|
928
|
+
type Query = Puri<MockSchema, { users: MockSchema["users"] }, MockSchema["users"]>;
|
|
929
|
+
// SAFETY: 실행 없이 공개 쿼리 메서드의 반환 타입만 검사합니다.
|
|
930
|
+
const query = {} as Query;
|
|
931
|
+
const selected = query.select({ user: { id: "users.id", name: "users.name" } });
|
|
932
|
+
type Rows = { user: { id: number; name: string } }[];
|
|
933
|
+
expectTypeOf(selected.then()).resolves.toEqualTypeOf<Rows>();
|
|
934
|
+
expectTypeOf(selected.catch()).resolves.toEqualTypeOf<Rows>();
|
|
935
|
+
expectTypeOf(selected.finally()).resolves.toEqualTypeOf<Rows>();
|
|
936
|
+
expectTypeOf(selected.first().finally()).resolves.toEqualTypeOf<Rows[number]>();
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
describe("이름이 다른 관계 그룹의 null 추론", () => {
|
|
941
|
+
it("명시한 id의 nullable LEFT JOIN 원본을 그룹 nullability에 반영한다", () => {
|
|
942
|
+
type Tables = {
|
|
943
|
+
users: MockSchema["users"];
|
|
944
|
+
staffs: MockSchema["employees"] & LeftJoinedMarker;
|
|
945
|
+
};
|
|
946
|
+
type Selected = ParseSelectObject<Tables, { manager: { id: "staffs.id"; name: "users.name" } }>;
|
|
947
|
+
expectTypeOf<Selected>().toEqualTypeOf<{ manager: { id: number; name: string } | null }>();
|
|
948
|
+
type Nested = ParseSelectObject<Tables, { location: { manager: { id: "staffs.id" } } }>;
|
|
949
|
+
expectTypeOf<Nested>().toEqualTypeOf<{ location: { manager: { id: number } | null } }>();
|
|
950
|
+
type Plain = ParseSelectObject<Tables, { profile: { id: "users.department_id" } }>;
|
|
951
|
+
expectTypeOf<Plain>().toEqualTypeOf<{ profile: { id: number | null } }>();
|
|
952
|
+
});
|
|
953
|
+
});
|
|
@@ -208,6 +208,14 @@ export type ParseSelectObject<
|
|
|
208
208
|
TSelect extends SelectObject<TTables>,
|
|
209
209
|
> = ParseSelectObjectWithPath<TTables, TSelect, "">;
|
|
210
210
|
|
|
211
|
+
// 별칭이 다른 관계 그룹도 명시한 id의 nullable JOIN 원본을 따릅니다.
|
|
212
|
+
type IsNullableSelectGroup<TTables, TSelect, Path extends string> =
|
|
213
|
+
IsNullableJoinedTable<TTables, Path> extends true
|
|
214
|
+
? true
|
|
215
|
+
: TSelect extends { id: `${infer Alias}.${string}` }
|
|
216
|
+
? IsNullableJoinedTable<TTables, Alias>
|
|
217
|
+
: false;
|
|
218
|
+
|
|
211
219
|
// 경로를 추적하면서 Select 결과 타입을 추론합니다.
|
|
212
220
|
type ParseSelectObjectWithPath<
|
|
213
221
|
TTables extends object,
|
|
@@ -218,7 +226,7 @@ type ParseSelectObjectWithPath<
|
|
|
218
226
|
? ParseSqlExpression<TSelect[K]>
|
|
219
227
|
: IsNestedObject<TSelect[K]> extends true
|
|
220
228
|
? TSelect[K] extends NestedSelectObject<TTables>
|
|
221
|
-
?
|
|
229
|
+
? IsNullableSelectGroup<TTables, TSelect[K], JoinPath<Prefix, K & string>> extends true // 주어진 테이블이 FK nullable에 leftJoin되었는지 여부에 따라 select 결과 객체의 타입이 달라집니다.
|
|
222
230
|
? Expand<ParseSelectObjectInner<TTables, TSelect[K], JoinPath<Prefix, K & string>>> | null // 만약 해당한다면 해당 객체 자체는 nullable 하며,
|
|
223
231
|
: Expand<ParseSelectObjectInner<TTables, TSelect[K], JoinPath<Prefix, K & string>>> // 그렇지 않다면 non-nullable 합니다.
|
|
224
232
|
: never
|
|
@@ -237,7 +245,7 @@ type ParseSelectObjectInner<
|
|
|
237
245
|
? ParseSqlExpression<TSelect[K]>
|
|
238
246
|
: IsNestedObject<TSelect[K]> extends true
|
|
239
247
|
? TSelect[K] extends NestedSelectObject<TTables>
|
|
240
|
-
?
|
|
248
|
+
? IsNullableSelectGroup<TTables, TSelect[K], JoinPath<Prefix, K & string>> extends true
|
|
241
249
|
? Expand<ParseSelectObjectInner<TTables, TSelect[K], JoinPath<Prefix, K & string>>> | null
|
|
242
250
|
: Expand<ParseSelectObjectInner<TTables, TSelect[K], JoinPath<Prefix, K & string>>>
|
|
243
251
|
: never
|