sonamu 0.13.2 → 0.13.4

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 (46) 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/dict/sonamu-dictionary.d.ts.map +1 -1
  15. package/dist/dict/sonamu-dictionary.js +24 -9
  16. package/dist/storage/buffered-file.d.ts +3 -3
  17. package/dist/storage/buffered-file.d.ts.map +1 -1
  18. package/dist/storage/buffered-file.js +2 -2
  19. package/dist/storage/index.d.ts +1 -1
  20. package/dist/storage/index.d.ts.map +1 -1
  21. package/dist/storage/index.js +1 -1
  22. package/dist/storage/storage-manager.d.ts +3 -4
  23. package/dist/storage/storage-manager.d.ts.map +1 -1
  24. package/dist/storage/storage-manager.js +2 -2
  25. package/dist/storage/types.d.ts +3 -2
  26. package/dist/storage/types.d.ts.map +1 -1
  27. package/dist/storage/uploaded-file.d.ts +3 -3
  28. package/dist/storage/uploaded-file.d.ts.map +1 -1
  29. package/dist/storage/uploaded-file.js +1 -1
  30. package/package.json +5 -2
  31. package/src/api/__tests__/sonamu.storage.test.ts +145 -0
  32. package/src/api/decorators.ts +2 -3
  33. package/src/api/sonamu.ts +6 -6
  34. package/src/database/__tests__/puri-hydration.test.ts +226 -0
  35. package/src/database/base-model.ts +7 -3
  36. package/src/database/puri.ts +204 -15
  37. package/src/database/puri.types.test-d.ts +29 -0
  38. package/src/database/puri.types.ts +10 -2
  39. package/src/dict/sonamu-dictionary.ts +28 -6
  40. package/src/storage/__tests__/storage-manager.test.ts +64 -0
  41. package/src/storage/buffered-file.ts +3 -3
  42. package/src/storage/index.ts +1 -1
  43. package/src/storage/storage-manager.ts +4 -5
  44. package/src/storage/storage.test-d.ts +47 -0
  45. package/src/storage/types.ts +3 -2
  46. package/src/storage/uploaded-file.ts +4 -4
@@ -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
@@ -2,13 +2,14 @@ import { execSync } from "child_process";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
4
 
5
- import { Workbook } from "@sheetkit/node";
5
+ import { type JsRowCell, Workbook } from "@sheetkit/node";
6
6
  import ts from "@typescript/typescript6";
7
7
 
8
8
  import { Sonamu } from "../api/sonamu";
9
9
  import { formatCode } from "../code-formatting";
10
10
  import { EntityManager } from "../entity/entity-manager";
11
11
  import { BadRequestException } from "../exceptions/so-exceptions";
12
+ import { isStringValue } from "../utils/runtime-value";
12
13
  import { SD } from "./sd";
13
14
  import {
14
15
  type DictEntry,
@@ -33,6 +34,29 @@ function colLetter(index: number): string {
33
34
  return result;
34
35
  }
35
36
 
37
+ /**
38
+ * Excel 행 셀을 사전 값 문자열로 읽습니다.
39
+ *
40
+ * SheetKit 0.5.1과 같은 결과를 유지합니다.
41
+ * - 문자열 셀은 그 값을 그대로 씁니다.
42
+ * - 수식 셀은 캐시된 결과가 아니라 수식 텍스트를 씁니다. SheetKit 1.0.1은 수식 텍스트를
43
+ * `value`와 `formula` 객체에 함께 주며, `value`가 없을 때는 `formula` 객체의 텍스트를 씁니다.
44
+ * - 숫자·날짜·불리언 셀은 빈 문자열로 둡니다. 객체 값을 문자열로 바꾸면
45
+ * "[object Object]" 같은 값이 사전에 들어가므로 변환하지 않습니다.
46
+ */
47
+ function readCellText(cell: JsRowCell | undefined): string {
48
+ if (cell === undefined) {
49
+ return "";
50
+ }
51
+ if (isStringValue(cell.value)) {
52
+ return cell.value;
53
+ }
54
+ if (cell.valueType === "formula" && cell.formula !== undefined) {
55
+ return cell.formula.formula;
56
+ }
57
+ return "";
58
+ }
59
+
36
60
  /**
37
61
  * Sonamu Dictionary 관리 클래스
38
62
  * i18n 딕셔너리의 CRUD 및 Excel import/export를 담당합니다.
@@ -757,9 +781,7 @@ export class SonamuDictionary {
757
781
  let headerRowNum = 0;
758
782
  for (const rowData of allRows) {
759
783
  const firstCell = rowData.cells.find((c) => c.column === "A");
760
- const firstCellValue = String(firstCell?.value ?? "")
761
- .trim()
762
- .toLowerCase();
784
+ const firstCellValue = readCellText(firstCell).trim().toLowerCase();
763
785
  if (firstCellValue === "key") {
764
786
  headerRowNum = rowData.row;
765
787
  break;
@@ -775,7 +797,7 @@ export class SonamuDictionary {
775
797
  const colToHeader: Map<string, string> = new Map();
776
798
  if (headerRowData) {
777
799
  for (const cell of headerRowData.cells) {
778
- colToHeader.set(cell.column, String(cell.value ?? ""));
800
+ colToHeader.set(cell.column, readCellText(cell));
779
801
  }
780
802
  }
781
803
 
@@ -787,7 +809,7 @@ export class SonamuDictionary {
787
809
  for (const cell of rowData.cells) {
788
810
  const headerName = colToHeader.get(cell.column);
789
811
  if (headerName) {
790
- rowValues[headerName] = String(cell.value ?? "");
812
+ rowValues[headerName] = readCellText(cell);
791
813
  }
792
814
  }
793
815
 
@@ -0,0 +1,64 @@
1
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { type MultipartFile } from "@fastify/multipart";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+
8
+ import { Sonamu } from "../../api/sonamu";
9
+ import { BufferedFile, drivers, StorageManager, StreamedFile } from "../index";
10
+
11
+ describe("이름 붙인 디스크", () => {
12
+ const originalStorage = Sonamu["storageValue"];
13
+ let directory: string;
14
+
15
+ beforeEach(async () => {
16
+ directory = await mkdtemp(path.join(tmpdir(), "sonamu-storage-"));
17
+ });
18
+
19
+ afterEach(async () => {
20
+ Reflect.set(Sonamu, "storageValue", originalStorage);
21
+ await rm(directory, { recursive: true, force: true });
22
+ });
23
+
24
+ it("버퍼 업로드와 스트림 파일 다운로드가 같은 이름의 디스크를 사용한다", async () => {
25
+ const factory = vi.fn(
26
+ drivers.fs({
27
+ location: directory,
28
+ visibility: "public",
29
+ urlBuilder: {
30
+ async generateURL(key) {
31
+ return `https://cdn.example.com/${key}`;
32
+ },
33
+ async generateSignedURL(key) {
34
+ return `https://cdn.example.com/${key}?signed`;
35
+ },
36
+ },
37
+ }),
38
+ );
39
+ const manager = new StorageManager({ drivers: { images: factory } });
40
+ Reflect.set(Sonamu, "storageValue", manager);
41
+ expect(factory).not.toHaveBeenCalled();
42
+
43
+ // SAFETY: BufferedFile 생성자가 읽는 필드는 filename과 mimetype뿐이며 raw는 이 테스트에서 사용하지 않는다.
44
+ const multipart = { filename: "hello.txt", mimetype: "text/plain" } as MultipartFile;
45
+ const buffer = new BufferedFile(multipart, Buffer.from("hello"));
46
+ expect(await buffer.saveToDisk("images", "hello.txt")).toBe(
47
+ "https://cdn.example.com/hello.txt",
48
+ );
49
+ expect(await readFile(path.join(directory, "hello.txt"), "utf8")).toBe("hello");
50
+ const streamed = new StreamedFile({
51
+ filename: "hello.txt",
52
+ mimetype: "text/plain",
53
+ size: 5,
54
+ key: "hello.txt",
55
+ diskName: "images",
56
+ url: "https://cdn.example.com/hello.txt",
57
+ signedUrl: "https://cdn.example.com/hello.txt?signed",
58
+ });
59
+ expect(streamed.diskName).toBe("images");
60
+ expect(await streamed.download()).toEqual(Buffer.from("hello"));
61
+ expect(factory).toHaveBeenCalledTimes(1);
62
+ expect(() => manager.use("missing")).toThrow('Unknown disk: "missing". Available: images');
63
+ });
64
+ });
@@ -3,7 +3,7 @@ import { createHash } from "crypto";
3
3
  import { type MultipartFile } from "@fastify/multipart";
4
4
 
5
5
  import { BaseFile } from "./base-file";
6
- import { type DriverKey } from "./drivers";
6
+ import { type DiskName } from "./types";
7
7
 
8
8
  /**
9
9
  * Buffer 모드로 업로드된 파일
@@ -35,11 +35,11 @@ export class BufferedFile extends BaseFile {
35
35
 
36
36
  /**
37
37
  * 파일을 디스크에 저장
38
- * @param diskName 디스크 이름 ("fs" 또는 "s3")
38
+ * @param diskName 설정에 등록한 디스크 이름
39
39
  * @param key 저장 경로 (예: 'uploads/avatar.png')
40
40
  * @returns 저장된 파일의 URL
41
41
  */
42
- async saveToDisk(diskName: DriverKey, key: string): Promise<string> {
42
+ async saveToDisk(diskName: DiskName, key: string): Promise<string> {
43
43
  // 순환 의존성 방지를 위해 동적 import
44
44
  const { Sonamu } = await import("../api/sonamu");
45
45
  const disk = Sonamu.storage.use(diskName);
@@ -3,5 +3,5 @@ export { BaseFile } from "./base-file";
3
3
  export { BufferedFile } from "./buffered-file";
4
4
  export { type DriverKey, drivers } from "./drivers";
5
5
  export { StorageManager } from "./storage-manager";
6
- export type { KeyGenerator, StorageConfig } from "./types";
6
+ export type { DiskName, KeyGenerator, StorageConfig } from "./types";
7
7
  export { UploadedFile as StreamedFile } from "./uploaded-file";
@@ -1,22 +1,21 @@
1
1
  import { Disk } from "flydrive";
2
2
 
3
3
  import { assertDefined } from "../utils/utils";
4
- import { type DriverKey } from "./drivers";
5
- import { type StorageConfig } from "./types";
4
+ import { type DiskName, type StorageConfig } from "./types";
6
5
 
7
6
  /**
8
7
  * 여러 디스크를 관리하는 매니저
9
8
  */
10
9
  export class StorageManager {
11
- private disks: Map<DriverKey, Disk> = new Map();
10
+ private disks: Map<DiskName, Disk> = new Map();
12
11
 
13
12
  constructor(private config: StorageConfig) {}
14
13
 
15
14
  /**
16
15
  * 디스크 인스턴스 반환 (lazy initialization)
17
- * @param diskName 디스크 이름 (없으면 default)
16
+ * @param diskName 설정에 등록한 디스크 이름
18
17
  */
19
- use(diskName: DriverKey): Disk {
18
+ use(diskName: DiskName): Disk {
20
19
  if (!this.disks.has(diskName)) {
21
20
  const factory = this.config.drivers[diskName];
22
21
  if (!factory) {
@@ -0,0 +1,47 @@
1
+ import { describe, expectTypeOf, it } from "vitest";
2
+
3
+ import { type SonamuConfig } from "../api/config";
4
+ import { type UploadDecoratorOptions } from "../api/decorators";
5
+ import { Sonamu } from "../api/sonamu";
6
+ import {
7
+ type BufferedFile,
8
+ type DriverKey,
9
+ drivers,
10
+ StorageManager,
11
+ type StorageConfig,
12
+ StreamedFile,
13
+ } from "./index";
14
+
15
+ describe("스토리지 디스크 이름", () => {
16
+ it("드라이버 종류와 독립적인 디스크 이름을 모든 업로드 경로에서 허용한다", () => {
17
+ const factory = drivers.fs({ location: "/tmp", visibility: "public" });
18
+ const storage = { drivers: { images: factory, private: factory } } satisfies StorageConfig;
19
+ expectTypeOf(storage).toExtend<NonNullable<SonamuConfig["server"]["storage"]>>();
20
+ const manager = new StorageManager(storage);
21
+ expectTypeOf(manager.use).parameter(0).toEqualTypeOf<string>();
22
+ expectTypeOf(Sonamu.storage.use).parameter(0).toEqualTypeOf<string>();
23
+ expectTypeOf<BufferedFile["saveToDisk"]>().parameter(0).toEqualTypeOf<string>();
24
+ expectTypeOf<StreamedFile["diskName"]>().toEqualTypeOf<string>();
25
+ expectTypeOf<DriverKey>().toEqualTypeOf<"fs" | "s3">();
26
+ const options = { consume: "stream", destination: "images" } satisfies UploadDecoratorOptions;
27
+ expectTypeOf(options).toExtend<UploadDecoratorOptions>();
28
+ expectTypeOf(
29
+ new StreamedFile({
30
+ filename: "image.jpg",
31
+ mimetype: "image/jpeg",
32
+ size: 1,
33
+ url: "https://cdn.example.com/image.jpg",
34
+ signedUrl: "",
35
+ key: "image.jpg",
36
+ diskName: "images",
37
+ }),
38
+ ).toEqualTypeOf<StreamedFile>();
39
+ });
40
+
41
+ it("fs나 s3라는 이름의 디스크를 반드시 설정할 필요가 없다", () => {
42
+ const factory = drivers.fs({ location: "/tmp", visibility: "public" });
43
+ expectTypeOf({ drivers: { images: factory } }).toExtend<StorageConfig>();
44
+ expectTypeOf({ drivers: { fs: factory } }).toExtend<StorageConfig>();
45
+ expectTypeOf({ drivers: { s3: factory } }).toExtend<StorageConfig>();
46
+ });
47
+ });