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.
- 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/dict/sonamu-dictionary.d.ts.map +1 -1
- package/dist/dict/sonamu-dictionary.js +24 -9
- 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 +5 -2
- 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/dict/sonamu-dictionary.ts +28 -6
- 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
|
@@ -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
|
|
@@ -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 =
|
|
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,
|
|
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] =
|
|
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
|
|
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 디스크 이름
|
|
38
|
+
* @param diskName 설정에 등록한 디스크 이름
|
|
39
39
|
* @param key 저장 경로 (예: 'uploads/avatar.png')
|
|
40
40
|
* @returns 저장된 파일의 URL
|
|
41
41
|
*/
|
|
42
|
-
async saveToDisk(diskName:
|
|
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);
|
package/src/storage/index.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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 디스크 이름
|
|
16
|
+
* @param diskName 설정에 등록한 디스크 이름
|
|
18
17
|
*/
|
|
19
|
-
use(diskName:
|
|
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
|
+
});
|