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.
Files changed (43) hide show
  1. package/dist/api/decorators.d.ts +2 -3
  2. package/dist/api/decorators.d.ts.map +1 -1
  3. package/dist/api/decorators.js +1 -1
  4. package/dist/api/sonamu.d.ts.map +1 -1
  5. package/dist/api/sonamu.js +5 -5
  6. package/dist/database/base-model.d.ts.map +1 -1
  7. package/dist/database/base-model.js +8 -3
  8. package/dist/database/puri.d.ts +8 -3
  9. package/dist/database/puri.d.ts.map +1 -1
  10. package/dist/database/puri.js +120 -15
  11. package/dist/database/puri.types.d.ts +5 -2
  12. package/dist/database/puri.types.d.ts.map +1 -1
  13. package/dist/database/puri.types.js +1 -1
  14. package/dist/storage/buffered-file.d.ts +3 -3
  15. package/dist/storage/buffered-file.d.ts.map +1 -1
  16. package/dist/storage/buffered-file.js +2 -2
  17. package/dist/storage/index.d.ts +1 -1
  18. package/dist/storage/index.d.ts.map +1 -1
  19. package/dist/storage/index.js +1 -1
  20. package/dist/storage/storage-manager.d.ts +3 -4
  21. package/dist/storage/storage-manager.d.ts.map +1 -1
  22. package/dist/storage/storage-manager.js +2 -2
  23. package/dist/storage/types.d.ts +3 -2
  24. package/dist/storage/types.d.ts.map +1 -1
  25. package/dist/storage/uploaded-file.d.ts +3 -3
  26. package/dist/storage/uploaded-file.d.ts.map +1 -1
  27. package/dist/storage/uploaded-file.js +1 -1
  28. package/package.json +1 -1
  29. package/src/api/__tests__/sonamu.storage.test.ts +145 -0
  30. package/src/api/decorators.ts +2 -3
  31. package/src/api/sonamu.ts +6 -6
  32. package/src/database/__tests__/puri-hydration.test.ts +226 -0
  33. package/src/database/base-model.ts +7 -3
  34. package/src/database/puri.ts +204 -15
  35. package/src/database/puri.types.test-d.ts +29 -0
  36. package/src/database/puri.types.ts +10 -2
  37. package/src/storage/__tests__/storage-manager.test.ts +64 -0
  38. package/src/storage/buffered-file.ts +3 -3
  39. package/src/storage/index.ts +1 -1
  40. package/src/storage/storage-manager.ts +4 -5
  41. package/src/storage/storage.test-d.ts +47 -0
  42. package/src/storage/types.ts +3 -2
  43. 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
- let unloadedRows = await limitedQb;
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
- // SAFETY: 쿼리 빌더의 제네릭 계약과 선행 검증이 이 타입을 보장합니다.
558
- let loadedRows = (await resolveLoaderQb) as any[];
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) {
@@ -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
- prefix = "",
721
+ path: readonly string[] = [],
621
722
  ): Generator<[string, SelectValue<TTables>], void> {
622
723
  for (const [key, value] of Object.entries(selectObj)) {
623
- const fullKey = prefix ? `${prefix}__${key}` : key;
724
+ const fieldPath = [...path, key];
725
+ const fullKey = fieldPath.join("__");
624
726
 
625
727
  if (isNestedSelectObject(value)) {
626
- yield* this.flattenSelectEntries(value, fullKey);
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>, prefix = "") {
634
- return Object.fromEntries(this.flattenSelectEntries(selectObj, prefix));
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.knexQuery.then(onfulfilled as any, onrejected);
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.knexQuery.catch(onrejected);
1680
+ ): Promise<Expand<TResult>[] | TResult2> {
1681
+ return this.then().catch(onrejected);
1500
1682
  }
1501
- finally(onfinally?: (() => void) | null): Promise<TResult> {
1502
- return this.knexQuery.finally(onfinally);
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 this.knexQuery.then(onfulfilled as any, onrejected);
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.knexQuery.catch(onrejected);
2373
+ return this.then().catch(onrejected);
2185
2374
  }
2186
2375
 
2187
2376
  finally(onfinally?: (() => void) | null): Promise<TResolved> {
2188
- return this.knexQuery.finally(onfinally);
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
- ? IsNullableJoinedTable<TTables, JoinPath<Prefix, K & string>> extends true // 주어진 테이블이 FK nullable에 leftJoin되었는지 여부에 따라 select 결과 객체의 타입이 달라집니다.
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
- ? IsNullableJoinedTable<TTables, JoinPath<Prefix, K & string>> extends true
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