sonamu 0.7.39 → 0.7.41
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/tasks/workflow-manager.d.ts.map +1 -1
- package/dist/tasks/workflow-manager.js +9 -1
- package/dist/template/implementations/generated_http.template.d.ts.map +1 -1
- package/dist/template/implementations/generated_http.template.js +3 -2
- package/dist/template/implementations/queries.template.d.ts.map +1 -1
- package/dist/template/implementations/queries.template.js +10 -2
- package/dist/template/implementations/sd.template.d.ts.map +1 -1
- package/dist/template/implementations/sd.template.js +34 -21
- package/dist/template/implementations/view_form.template.d.ts.map +1 -1
- package/dist/template/implementations/view_form.template.js +4 -2
- package/dist/template/implementations/view_id_async_select.template.js +2 -2
- package/dist/template/zod-converter.js +4 -2
- package/dist/testing/fixture-manager.d.ts +18 -6
- package/dist/testing/fixture-manager.d.ts.map +1 -1
- package/dist/testing/fixture-manager.js +124 -46
- package/dist/ui-web/assets/{index-Bfv7V57v.css → index-CWExqVO5.css} +1 -1
- package/dist/ui-web/assets/{index-DCf469Xl.js → index-DoZuAOiq.js} +69 -67
- package/dist/ui-web/index.html +2 -2
- package/package.json +3 -3
- package/src/tasks/workflow-manager.ts +10 -0
- package/src/template/implementations/generated_http.template.ts +2 -1
- package/src/template/implementations/queries.template.ts +23 -12
- package/src/template/implementations/sd.template.ts +33 -20
- package/src/template/implementations/view_form.template.ts +3 -1
- package/src/template/implementations/view_id_async_select.template.ts +1 -1
- package/src/template/zod-converter.ts +3 -1
- package/src/testing/fixture-manager.ts +167 -59
package/dist/ui-web/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<link rel="icon" type="image/svg+xml" href="/sonamu-ui/setting.svg" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>{{projectName}}: Sonamu UI</title>
|
|
8
|
-
<script type="module" crossorigin src="/sonamu-ui/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/sonamu-ui/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/sonamu-ui/assets/index-DoZuAOiq.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/sonamu-ui/assets/index-CWExqVO5.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sonamu",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.41",
|
|
4
4
|
"description": "Sonamu — TypeScript Fullstack API Framework",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -113,9 +113,9 @@
|
|
|
113
113
|
"exceljs": "^4.4.0",
|
|
114
114
|
"zod": "^4.1.12",
|
|
115
115
|
"@sonamu-kit/hmr-hook": "^0.4.1",
|
|
116
|
+
"@sonamu-kit/ts-loader": "^2.1.3",
|
|
116
117
|
"@sonamu-kit/hmr-runner": "^0.1.1",
|
|
117
|
-
"@sonamu-kit/tasks": "^0.1.3"
|
|
118
|
-
"@sonamu-kit/ts-loader": "^2.1.3"
|
|
118
|
+
"@sonamu-kit/tasks": "^0.1.3"
|
|
119
119
|
},
|
|
120
120
|
"devDependencies": {
|
|
121
121
|
"@biomejs/biome": "^2.3.10",
|
|
@@ -198,6 +198,11 @@ export class WorkflowManager {
|
|
|
198
198
|
workflow: Pick<WorkflowMetadata, "id" | "name" | "version">,
|
|
199
199
|
schedule: WorkflowMetadata["schedules"][number],
|
|
200
200
|
) {
|
|
201
|
+
// Worker가 활성화된 노드에서만 처리
|
|
202
|
+
if (!this.#worker) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
201
206
|
const task = cronSchedule(
|
|
202
207
|
schedule.expression,
|
|
203
208
|
(async (
|
|
@@ -227,6 +232,11 @@ export class WorkflowManager {
|
|
|
227
232
|
|
|
228
233
|
// cron task를 중지
|
|
229
234
|
async unscheduleTask(name: string) {
|
|
235
|
+
// Worker가 활성화된 노드에서만 처리
|
|
236
|
+
if (!this.#worker) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
230
240
|
const taskItem = this.#scheduledTasks.get(name);
|
|
231
241
|
if (!taskItem) {
|
|
232
242
|
console.error("scheduled task not found", name);
|
|
@@ -75,7 +75,8 @@ export class Template__generated_http extends Template {
|
|
|
75
75
|
);
|
|
76
76
|
} else if (zodType instanceof z.ZodArray) {
|
|
77
77
|
return [this.zodTypeToReqDefault((zodType as z.ZodArray<z.ZodType>).element, name)];
|
|
78
|
-
} else if (zodType instanceof z.ZodString) {
|
|
78
|
+
} else if (zodType instanceof z.core.$ZodString) {
|
|
79
|
+
// NOTE: z.ZodString으로 비교하면 z.url(), z.email() 등의 타입에서 문제가 생기므로 z.core.$ZodString으로 비교함
|
|
79
80
|
if (name.endsWith("_at") || name.endsWith("_date") || name === "range") {
|
|
80
81
|
return "2000-01-01";
|
|
81
82
|
} else {
|
|
@@ -90,22 +90,33 @@ ${functions.join("\n\n")}
|
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// tanstack-query API가 없으면 헬퍼 함수와 import를 포함하지 않습니다.
|
|
94
|
+
// 새 프로젝트에서 첫 빌드 시 sync가 아직 안 되어 namespace가 비어있을 수 있고,
|
|
95
|
+
// 이때 createSSRQuery가 unused로 빌드 에러가 발생하는 것을 방지합니다.
|
|
96
|
+
const hasQueries = namespaces.length > 0;
|
|
97
|
+
|
|
93
98
|
return {
|
|
94
99
|
...this.getTargetAndPath(),
|
|
95
100
|
body: namespaces.join("\n\n"),
|
|
96
101
|
importKeys: diff(unique(importKeys), typeParamNames),
|
|
97
|
-
customHeaders:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
102
|
+
customHeaders: hasQueries
|
|
103
|
+
? [
|
|
104
|
+
"/** biome-ignore-all lint: generated는 무시 */",
|
|
105
|
+
"/** biome-ignore-all assist: generated는 무시 */",
|
|
106
|
+
"",
|
|
107
|
+
`import type { SSRQuery } from 'sonamu/ssr';`,
|
|
108
|
+
"",
|
|
109
|
+
`// SSRQuery 헬퍼 함수`,
|
|
110
|
+
`function createSSRQuery(modelName: string, methodName: string, params: any[], serviceKey: [string, string]): SSRQuery {`,
|
|
111
|
+
` return { modelName, methodName, params, serviceKey, __brand: 'SSRQuery' } as SSRQuery;`,
|
|
112
|
+
`}`,
|
|
113
|
+
"",
|
|
114
|
+
]
|
|
115
|
+
: [
|
|
116
|
+
"/** biome-ignore-all lint: generated는 무시 */",
|
|
117
|
+
"/** biome-ignore-all assist: generated는 무시 */",
|
|
118
|
+
"",
|
|
119
|
+
],
|
|
109
120
|
};
|
|
110
121
|
}
|
|
111
122
|
}
|
|
@@ -38,27 +38,27 @@ export class Template__sd extends Template {
|
|
|
38
38
|
? `
|
|
39
39
|
import { Sonamu } from "sonamu";
|
|
40
40
|
|
|
41
|
-
const DEFAULT_LOCALE = "${defaultLocale}";
|
|
42
|
-
const SUPPORTED_LOCALES = ${JSON.stringify(supportedLocales)};
|
|
43
|
-
function getCurrentLocale():
|
|
41
|
+
const DEFAULT_LOCALE = "${defaultLocale}" as const;
|
|
42
|
+
const SUPPORTED_LOCALES = ${JSON.stringify(supportedLocales)} as const;
|
|
43
|
+
function getCurrentLocale(): (typeof SUPPORTED_LOCALES)[number] {
|
|
44
44
|
try {
|
|
45
45
|
const ctx = Sonamu.getContext();
|
|
46
|
-
return ctx.locale ?? DEFAULT_LOCALE;
|
|
46
|
+
return ctx.locale as (typeof SUPPORTED_LOCALES)[number] ?? DEFAULT_LOCALE;
|
|
47
47
|
} catch (_) {
|
|
48
48
|
return DEFAULT_LOCALE;
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
`.trim()
|
|
52
52
|
: `
|
|
53
|
-
const DEFAULT_LOCALE = "${defaultLocale}";
|
|
54
|
-
const SUPPORTED_LOCALES = ${JSON.stringify(supportedLocales)};
|
|
55
|
-
let _currentLocale = DEFAULT_LOCALE;
|
|
53
|
+
const DEFAULT_LOCALE = "${defaultLocale}" as const;
|
|
54
|
+
const SUPPORTED_LOCALES = ${JSON.stringify(supportedLocales)} as const;
|
|
55
|
+
let _currentLocale: (typeof SUPPORTED_LOCALES)[number] = DEFAULT_LOCALE;
|
|
56
56
|
|
|
57
|
-
export function setLocale(locale:
|
|
57
|
+
export function setLocale(locale: (typeof SUPPORTED_LOCALES)[number]) {
|
|
58
58
|
_currentLocale = locale;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
export function getCurrentLocale():
|
|
61
|
+
export function getCurrentLocale(): (typeof SUPPORTED_LOCALES)[number] {
|
|
62
62
|
return _currentLocale;
|
|
63
63
|
}
|
|
64
64
|
`.trim();
|
|
@@ -136,33 +136,46 @@ export function SD<K extends DictKey>(key: K): SDReturnType<K> {
|
|
|
136
136
|
* const EN = SD.locale("en");
|
|
137
137
|
* EN("common.save") // → "Save"
|
|
138
138
|
*/
|
|
139
|
-
SD.locale = (locale:
|
|
139
|
+
SD.locale = (locale: (typeof SUPPORTED_LOCALES)[number]) => <K extends DictKey>(key: K): SDReturnType<K> => {
|
|
140
140
|
return getDictValue(key, locale);
|
|
141
141
|
};
|
|
142
142
|
|
|
143
|
+
// Localized 가능한 Column 타입 계산
|
|
144
|
+
type LocalizedBaseColumn<T> = {
|
|
145
|
+
[K in keyof T & string]: K extends \`\${infer Base}_\${(typeof SUPPORTED_LOCALES)[number]}\` ? Base : K;
|
|
146
|
+
}[keyof T & string];
|
|
147
|
+
|
|
143
148
|
/**
|
|
144
149
|
* locale에 따라 적절한 컬럼 값을 반환합니다.
|
|
145
150
|
* DB에 name, name_ko, name_en처럼 localized column이 있을 때 사용합니다.
|
|
146
|
-
*
|
|
147
|
-
* 우선순위 (ko
|
|
148
|
-
* 우선순위 (en
|
|
151
|
+
*
|
|
152
|
+
* 우선순위 (지원 로케일은 ko/jp/en이고, 서비스의 기본 로케일은 ko, 사용자의 로케일은 jp일 때): column_jp → column → column_ko → column_en
|
|
153
|
+
* 우선순위 (지원 로케일은 ko/jp/en이고, 서비스의 기본 로케일은 en, 사용자의 로케일은 ko일 때): column_ko → column → column_en → column_jp
|
|
149
154
|
*
|
|
150
155
|
* @example
|
|
151
156
|
* localizedColumn(tag, "name")
|
|
152
157
|
*/
|
|
153
|
-
export function localizedColumn<T extends Record<string, unknown>, K extends
|
|
158
|
+
export function localizedColumn<T extends Record<string, unknown>, K extends LocalizedBaseColumn<T>>(
|
|
154
159
|
row: T,
|
|
155
160
|
column: K,
|
|
156
161
|
): string | undefined {
|
|
157
162
|
const locale = getCurrentLocale();
|
|
158
|
-
const otherLocales = SUPPORTED_LOCALES.filter((l: string) => l !== locale);
|
|
159
|
-
const localizedKey = (column: K, locale:
|
|
160
|
-
const keys = [
|
|
163
|
+
const otherLocales = SUPPORTED_LOCALES.filter((l: string) => l !== locale && l !== DEFAULT_LOCALE);
|
|
164
|
+
const localizedKey = (column: K, locale: (typeof SUPPORTED_LOCALES)[number]) => \`\${column}_\${locale}\`;
|
|
165
|
+
const keys = [
|
|
166
|
+
localizedKey(column, locale),
|
|
167
|
+
column,
|
|
168
|
+
localizedKey(column, DEFAULT_LOCALE),
|
|
169
|
+
...otherLocales.map((l) => localizedKey(column, l)),
|
|
170
|
+
];
|
|
161
171
|
|
|
162
172
|
for (const key of keys) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
173
|
+
if (!(key in row)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (row[key] !== null && row[key] !== undefined && row[key] !== "") {
|
|
178
|
+
return String(row[key]);
|
|
166
179
|
}
|
|
167
180
|
}
|
|
168
181
|
|
|
@@ -138,7 +138,9 @@ export class Template__view_form extends Template {
|
|
|
138
138
|
value = Object.keys(col.zodType.enum)[0];
|
|
139
139
|
} else if (col.zodType instanceof z.ZodBoolean) {
|
|
140
140
|
value = false;
|
|
141
|
-
} else if (col.zodType instanceof z.ZodString) {
|
|
141
|
+
} else if (col.zodType instanceof z.core.$ZodString) {
|
|
142
|
+
// NOTE: z.ZodString으로 비교하면 z.url(), z.email() 등의 타입에서 문제가 생기므로 z.core.$ZodString으로 비교함
|
|
143
|
+
// FIXME: email이나 url 타입 등에 대한 처리가 필요함
|
|
142
144
|
if (col.renderType === "string-datetime") {
|
|
143
145
|
value = "now()";
|
|
144
146
|
} else {
|
|
@@ -147,8 +147,8 @@ export function ${names.capital}IdAsyncSelect<T extends ${names.capital}SubsetKe
|
|
|
147
147
|
return (
|
|
148
148
|
<MultiSelect
|
|
149
149
|
options={options}
|
|
150
|
+
value={multiValue}
|
|
150
151
|
onValueChange={handleMultiChange}
|
|
151
|
-
defaultValue={multiValue}
|
|
152
152
|
placeholder={placeholder}
|
|
153
153
|
disabled={disabled}
|
|
154
154
|
className={className}
|
|
@@ -627,7 +627,9 @@ export function zodTypeToRenderingNode(
|
|
|
627
627
|
function resolveRenderType(key: string, zodType: z.ZodTypeAny): RenderingNode["renderType"] {
|
|
628
628
|
if (zodType instanceof z.ZodDate) {
|
|
629
629
|
return "datetime";
|
|
630
|
-
} else if (zodType instanceof z.ZodString) {
|
|
630
|
+
} else if (zodType instanceof z.core.$ZodString) {
|
|
631
|
+
// NOTE: z.ZodString으로 비교하면 z.url(), z.email() 등의 타입에서 문제가 생기므로 z.core.$ZodString으로 비교함
|
|
632
|
+
// FIXME: email이나 url 타입 등에 대한 처리가 필요함
|
|
631
633
|
if (zodType.description === "SQLDateTimeString") {
|
|
632
634
|
return "string-datetime";
|
|
633
635
|
} else if (key.endsWith("date")) {
|
|
@@ -14,6 +14,7 @@ import { type UBRef, UpsertBuilder } from "../database/upsert-builder";
|
|
|
14
14
|
import type { Entity } from "../entity/entity";
|
|
15
15
|
import { EntityManager } from "../entity/entity-manager";
|
|
16
16
|
import {
|
|
17
|
+
type BelongsToOneRelationProp,
|
|
17
18
|
type DatabaseSchemaExtend,
|
|
18
19
|
type EntityProp,
|
|
19
20
|
type FixtureImportResult,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
isRelationProp,
|
|
27
28
|
isVirtualProp,
|
|
28
29
|
type ManyToManyRelationProp,
|
|
30
|
+
type OneToOneRelationProp,
|
|
29
31
|
} from "../types/types";
|
|
30
32
|
import { RelationGraph } from "./_relation-graph";
|
|
31
33
|
|
|
@@ -65,7 +67,6 @@ export class FixtureManagerClass {
|
|
|
65
67
|
// UpsertBuilder 기반 import를 위한 상태
|
|
66
68
|
private builder: UpsertBuilder = new UpsertBuilder();
|
|
67
69
|
private fixtureRefMap: Map<string, UBRef> = new Map();
|
|
68
|
-
private uuidToFixtureId: Map<string, string> = new Map();
|
|
69
70
|
private skippedFixtures: Map<string, { entityId: string; existingId: number }> = new Map();
|
|
70
71
|
|
|
71
72
|
init() {
|
|
@@ -422,8 +423,13 @@ export class FixtureManagerClass {
|
|
|
422
423
|
|
|
423
424
|
/**
|
|
424
425
|
* 1. RelationGraph로 fixture 단위 삽입 순서 계산 (self-reference 포함)
|
|
425
|
-
* 2.
|
|
426
|
-
* 3.
|
|
426
|
+
* 2. 테이블별 레벨별로 UpsertBuilder에 등록 및 upsert 실행
|
|
427
|
+
* 3. 순서 기반 uuid→id 매핑 (UpsertBuilder가 uuid를 DB에 저장하지 않으므로)
|
|
428
|
+
*
|
|
429
|
+
* UpsertBuilder는 self-reference가 있으면 buildInsertLevels()로 재정렬하여
|
|
430
|
+
* 등록 순서와 반환 순서가 달라질 수 있습니다. 이를 방지하기 위해
|
|
431
|
+
* FixtureManager가 레벨별로 나눠서 처리하여 각 upsert 호출에서는
|
|
432
|
+
* self-reference가 없도록 합니다.
|
|
427
433
|
*/
|
|
428
434
|
async insertFixtures(
|
|
429
435
|
dbName: keyof SonamuDBConfig,
|
|
@@ -434,7 +440,6 @@ export class FixtureManagerClass {
|
|
|
434
440
|
// 초기화
|
|
435
441
|
this.builder = new UpsertBuilder();
|
|
436
442
|
this.fixtureRefMap = new Map();
|
|
437
|
-
this.uuidToFixtureId = new Map();
|
|
438
443
|
this.skippedFixtures = new Map();
|
|
439
444
|
|
|
440
445
|
const db = createKnexInstance(Sonamu.dbConfig[dbName]);
|
|
@@ -445,7 +450,7 @@ export class FixtureManagerClass {
|
|
|
445
450
|
this.relationGraph.buildGraph(fixtures);
|
|
446
451
|
const insertionOrder = this.relationGraph.getInsertionOrder();
|
|
447
452
|
|
|
448
|
-
// 2.
|
|
453
|
+
// 2. 스킵할 fixture 먼저 처리 (override 체크)
|
|
449
454
|
for (const fixtureId of insertionOrder) {
|
|
450
455
|
const fixture = fixtures.find((f) => f.fixtureId === fixtureId);
|
|
451
456
|
if (!fixture) continue;
|
|
@@ -469,52 +474,82 @@ export class FixtureManagerClass {
|
|
|
469
474
|
`Skipped ${fixture.entityId}#${fixture.id} (existing: #${existingId}, override: false)`,
|
|
470
475
|
),
|
|
471
476
|
);
|
|
472
|
-
continue;
|
|
473
477
|
}
|
|
474
|
-
|
|
475
|
-
this.registerFixture(fixture);
|
|
476
|
-
console.log(
|
|
477
|
-
chalk.blue(
|
|
478
|
-
`Registered ${fixture.entityId}#${fixture.id}${fixture.override ? ` (override existing: #${fixture.target?.id})` : ""}`,
|
|
479
|
-
),
|
|
480
|
-
);
|
|
481
478
|
}
|
|
482
479
|
|
|
483
|
-
// 3. 테이블별
|
|
484
|
-
const
|
|
480
|
+
// 3. 테이블별 fixture 그룹화 (insertionOrder 순서 기반)
|
|
481
|
+
const fixturesByTable = new Map<string, FixtureRecord[]>();
|
|
482
|
+
const tableOrder: string[] = [];
|
|
485
483
|
|
|
486
|
-
|
|
487
|
-
|
|
484
|
+
for (const fixtureId of insertionOrder) {
|
|
485
|
+
// 스킵된 fixture 제외
|
|
486
|
+
if (this.skippedFixtures.has(fixtureId)) continue;
|
|
488
487
|
|
|
489
|
-
|
|
490
|
-
|
|
488
|
+
const fixture = fixtures.find((f) => f.fixtureId === fixtureId);
|
|
489
|
+
if (!fixture) continue;
|
|
490
|
+
|
|
491
|
+
const entity = EntityManager.get(fixture.entityId);
|
|
492
|
+
const tableName = entity.table;
|
|
491
493
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
494
|
+
if (!fixturesByTable.has(tableName)) {
|
|
495
|
+
fixturesByTable.set(tableName, []);
|
|
496
|
+
tableOrder.push(tableName);
|
|
497
|
+
}
|
|
498
|
+
fixturesByTable.get(tableName)?.push(fixture);
|
|
499
|
+
}
|
|
495
500
|
|
|
496
|
-
|
|
497
|
-
|
|
501
|
+
await db.transaction(async (trx) => {
|
|
502
|
+
const insertedIdsByTable = new Map<string, Map<string, number>>();
|
|
498
503
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
.select("uuid", "id")
|
|
504
|
-
.whereIn("uuid", uuids);
|
|
504
|
+
// 4. 테이블별 레벨별 처리
|
|
505
|
+
for (const tableName of tableOrder) {
|
|
506
|
+
const tableFixtures = fixturesByTable.get(tableName) ?? [];
|
|
507
|
+
const levels = this.groupFixturesByLevel(tableFixtures);
|
|
505
508
|
|
|
506
|
-
|
|
507
|
-
|
|
509
|
+
for (const levelFixtures of levels) {
|
|
510
|
+
// 해당 레벨의 fixture들 register
|
|
511
|
+
for (const fixture of levelFixtures) {
|
|
512
|
+
this.registerFixture(fixture, insertedIdsByTable);
|
|
513
|
+
console.log(
|
|
514
|
+
chalk.blue(
|
|
515
|
+
`Registered ${fixture.entityId}#${fixture.id}${fixture.override ? ` (override)` : ""}`,
|
|
516
|
+
),
|
|
517
|
+
);
|
|
508
518
|
}
|
|
509
519
|
|
|
510
|
-
|
|
520
|
+
// upsert 실행 전 uuid 목록 저장
|
|
521
|
+
const table = this.builder.getTable(tableName);
|
|
522
|
+
const uuids = table.rows.map((row) => row.uuid as string);
|
|
523
|
+
|
|
524
|
+
console.log(
|
|
525
|
+
chalk.blue(
|
|
526
|
+
`Upserting ${tableName} with ${uuids.length} rows (level ${levels.indexOf(levelFixtures) + 1}/${levels.length})`,
|
|
527
|
+
),
|
|
528
|
+
);
|
|
529
|
+
const ids = await this.builder.upsert(trx, tableName as keyof DatabaseSchemaExtend);
|
|
530
|
+
|
|
531
|
+
// 순서 기반 uuid -> id 매핑
|
|
532
|
+
// self-reference가 없으므로 등록 순서 = 반환 순서 보장
|
|
533
|
+
if (uuids.length > 0 && uuids.length === ids.length) {
|
|
534
|
+
const existingMap = insertedIdsByTable.get(tableName) ?? new Map<string, number>();
|
|
535
|
+
for (let i = 0; i < uuids.length; i++) {
|
|
536
|
+
existingMap.set(uuids[i], ids[i]);
|
|
537
|
+
}
|
|
538
|
+
insertedIdsByTable.set(tableName, existingMap);
|
|
539
|
+
} else if (uuids.length !== ids.length) {
|
|
540
|
+
console.warn(
|
|
541
|
+
chalk.yellow(
|
|
542
|
+
`Warning: uuid count (${uuids.length}) != id count (${ids.length}) for ${tableName}`,
|
|
543
|
+
),
|
|
544
|
+
);
|
|
545
|
+
}
|
|
511
546
|
}
|
|
512
547
|
}
|
|
513
548
|
|
|
514
|
-
//
|
|
549
|
+
// 5. ManyToMany 관계 처리
|
|
515
550
|
await this.processManyToManyRelations(trx, fixtures, insertedIdsByTable);
|
|
516
551
|
|
|
517
|
-
//
|
|
552
|
+
// 6. 결과 수집
|
|
518
553
|
for (const fixture of fixtures) {
|
|
519
554
|
const entity = EntityManager.get(fixture.entityId);
|
|
520
555
|
|
|
@@ -555,8 +590,12 @@ export class FixtureManagerClass {
|
|
|
555
590
|
|
|
556
591
|
/**
|
|
557
592
|
* FixtureRecord를 UpsertBuilder에 등록
|
|
593
|
+
* @param insertedIdsByTable 이미 upsert된 테이블의 uuid→id 매핑 (레벨별 처리 시 사용)
|
|
558
594
|
*/
|
|
559
|
-
private registerFixture(
|
|
595
|
+
private registerFixture(
|
|
596
|
+
fixture: FixtureRecord,
|
|
597
|
+
insertedIdsByTable?: Map<string, Map<string, number>>,
|
|
598
|
+
): UBRef {
|
|
560
599
|
const entity = EntityManager.get(fixture.entityId);
|
|
561
600
|
const row: Record<string, unknown> = {};
|
|
562
601
|
|
|
@@ -571,8 +610,13 @@ export class FixtureManagerClass {
|
|
|
571
610
|
continue;
|
|
572
611
|
}
|
|
573
612
|
|
|
574
|
-
//
|
|
575
|
-
if (
|
|
613
|
+
// Generated column은 INSERT에서 제외 (DB가 자동 생성)
|
|
614
|
+
if ("generated" in prop && prop.generated) {
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// id 처리: Override 모드일 때만 기존 값 사용
|
|
619
|
+
if (propName === "id") {
|
|
576
620
|
if (isOverrideMode && existingRecord) {
|
|
577
621
|
// Override: 기존 레코드의 값 사용 → UPDATE
|
|
578
622
|
row[propName] = existingRecord.columns[propName]?.value;
|
|
@@ -598,8 +642,18 @@ export class FixtureManagerClass {
|
|
|
598
642
|
} else {
|
|
599
643
|
const relatedRef = this.fixtureRefMap.get(relatedFixtureId);
|
|
600
644
|
if (relatedRef) {
|
|
601
|
-
// 이미
|
|
602
|
-
|
|
645
|
+
// 이미 upsert된 같은 테이블 fixture 확인
|
|
646
|
+
const relatedEntity = EntityManager.get(prop.with);
|
|
647
|
+
const relatedInsertedIds = insertedIdsByTable?.get(relatedEntity.table);
|
|
648
|
+
const actualId = relatedInsertedIds?.get(relatedRef.uuid);
|
|
649
|
+
|
|
650
|
+
if (actualId !== undefined) {
|
|
651
|
+
// 이미 upsert됨 → 실제 ID 사용
|
|
652
|
+
row[`${propName}_id`] = actualId;
|
|
653
|
+
} else {
|
|
654
|
+
// 아직 upsert 안됨 → UBRef 사용
|
|
655
|
+
row[`${propName}_id`] = relatedRef;
|
|
656
|
+
}
|
|
603
657
|
} else {
|
|
604
658
|
// fixtures에 포함되지 않은 레코드 → ID 그대로 사용
|
|
605
659
|
row[`${propName}_id`] = relatedId;
|
|
@@ -619,7 +673,6 @@ export class FixtureManagerClass {
|
|
|
619
673
|
console.log(chalk.blue(`Registering ${entity.table} - ${inspect(row, false, null, true)}`));
|
|
620
674
|
const ref = this.builder.register(entity.table, row);
|
|
621
675
|
this.fixtureRefMap.set(fixture.fixtureId, ref);
|
|
622
|
-
this.uuidToFixtureId.set(ref.uuid, fixture.fixtureId);
|
|
623
676
|
|
|
624
677
|
return ref;
|
|
625
678
|
}
|
|
@@ -648,24 +701,6 @@ export class FixtureManagerClass {
|
|
|
648
701
|
}
|
|
649
702
|
}
|
|
650
703
|
|
|
651
|
-
/**
|
|
652
|
-
* 테이블 순서 추출 (fixtures에 포함된 테이블만)
|
|
653
|
-
*/
|
|
654
|
-
private getTableOrder(fixtures: FixtureRecord[]): (keyof DatabaseSchemaExtend)[] {
|
|
655
|
-
const tables: string[] = [];
|
|
656
|
-
const seen = new Set<string>();
|
|
657
|
-
|
|
658
|
-
for (const fixture of fixtures) {
|
|
659
|
-
const entity = EntityManager.get(fixture.entityId);
|
|
660
|
-
if (!seen.has(entity.table)) {
|
|
661
|
-
seen.add(entity.table);
|
|
662
|
-
tables.push(entity.table);
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
return tables as (keyof DatabaseSchemaExtend)[];
|
|
667
|
-
}
|
|
668
|
-
|
|
669
704
|
private async processManyToManyRelations(
|
|
670
705
|
trx: Knex.Transaction,
|
|
671
706
|
fixtures: FixtureRecord[],
|
|
@@ -746,6 +781,79 @@ export class FixtureManagerClass {
|
|
|
746
781
|
}
|
|
747
782
|
}
|
|
748
783
|
|
|
784
|
+
/**
|
|
785
|
+
* 같은 테이블 내 fixture들을 self-reference 레벨별로 분할
|
|
786
|
+
* - self-reference가 없는 fixture들: Level 0
|
|
787
|
+
* - Level 0을 참조하는 fixture들: Level 1
|
|
788
|
+
* - 반복
|
|
789
|
+
*
|
|
790
|
+
* UpsertBuilder가 self-reference가 있으면 buildInsertLevels()로 재정렬하여
|
|
791
|
+
* 등록 순서와 반환 순서가 달라질 수 있습니다.
|
|
792
|
+
* 이를 방지하기 위해 FixtureManager가 레벨별로 나눠서 처리합니다.
|
|
793
|
+
*/
|
|
794
|
+
private groupFixturesByLevel(fixtures: FixtureRecord[]): FixtureRecord[][] {
|
|
795
|
+
if (fixtures.length === 0) {
|
|
796
|
+
return [];
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const entity = EntityManager.get(fixtures[0].entityId);
|
|
800
|
+
|
|
801
|
+
// self-reference relation prop 찾기
|
|
802
|
+
const selfRefProps = entity.props.filter(
|
|
803
|
+
(p): p is BelongsToOneRelationProp | OneToOneRelationProp =>
|
|
804
|
+
isRelationProp(p) &&
|
|
805
|
+
(isBelongsToOneRelationProp(p) || (isOneToOneRelationProp(p) && p.hasJoinColumn)) &&
|
|
806
|
+
p.with === entity.id,
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
if (selfRefProps.length === 0) {
|
|
810
|
+
// self-reference 없음 → 단일 레벨
|
|
811
|
+
return [fixtures];
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// 레벨별 분할 (topological sort)
|
|
815
|
+
const levels: FixtureRecord[][] = [];
|
|
816
|
+
const remaining = new Set(fixtures.map((f) => f.fixtureId));
|
|
817
|
+
const processed = new Set<string>();
|
|
818
|
+
|
|
819
|
+
while (remaining.size > 0) {
|
|
820
|
+
const currentLevel: FixtureRecord[] = [];
|
|
821
|
+
|
|
822
|
+
for (const fixture of fixtures) {
|
|
823
|
+
if (!remaining.has(fixture.fixtureId)) continue;
|
|
824
|
+
|
|
825
|
+
// self-reference가 모두 이미 처리됐거나 null인 경우
|
|
826
|
+
const canProcess = selfRefProps.every((prop) => {
|
|
827
|
+
const refId = fixture.columns[prop.name]?.value as number | null;
|
|
828
|
+
if (refId === null || refId === undefined) return true;
|
|
829
|
+
const refFixtureId = `${prop.with}#${refId}`;
|
|
830
|
+
// 이미 처리됐거나, 현재 fixtures에 포함되지 않은 경우 (외부 참조)
|
|
831
|
+
return processed.has(refFixtureId) || !remaining.has(refFixtureId);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
if (canProcess) {
|
|
835
|
+
currentLevel.push(fixture);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (currentLevel.length === 0) {
|
|
840
|
+
const remainingIds = Array.from(remaining).join(", ");
|
|
841
|
+
throw new Error(
|
|
842
|
+
`Circular self-reference detected in ${entity.table}. Remaining fixtures: ${remainingIds}`,
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
for (const fixture of currentLevel) {
|
|
847
|
+
remaining.delete(fixture.fixtureId);
|
|
848
|
+
processed.add(fixture.fixtureId);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
levels.push(currentLevel);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return levels;
|
|
855
|
+
}
|
|
856
|
+
|
|
749
857
|
private async checkUniqueViolation(db: Knex, entity: Entity, fixture: FixtureRecord) {
|
|
750
858
|
const _uniqueIndexes = entity.indexes?.filter((i) => i.type === "unique") ?? [];
|
|
751
859
|
|