sonamu 0.7.53 → 0.8.0
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/config.d.ts +9 -1
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +1 -1
- package/dist/api/sonamu.d.ts +21 -1
- package/dist/api/sonamu.d.ts.map +1 -1
- package/dist/api/sonamu.js +159 -65
- package/dist/auth/plugins/entity-definitions/anonymous.d.ts +10 -0
- package/dist/auth/plugins/entity-definitions/anonymous.d.ts.map +1 -0
- package/dist/auth/plugins/entity-definitions/anonymous.js +23 -0
- package/dist/auth/plugins/entity-definitions/api-key.d.ts +9 -0
- package/dist/auth/plugins/entity-definitions/api-key.d.ts.map +1 -0
- package/dist/auth/plugins/entity-definitions/api-key.js +199 -0
- package/dist/auth/plugins/entity-definitions/index.d.ts +6 -0
- package/dist/auth/plugins/entity-definitions/index.d.ts.map +1 -1
- package/dist/auth/plugins/entity-definitions/index.js +20 -2
- package/dist/auth/plugins/entity-definitions/jwt.d.ts +9 -0
- package/dist/auth/plugins/entity-definitions/jwt.d.ts.map +1 -0
- package/dist/auth/plugins/entity-definitions/jwt.js +67 -0
- package/dist/auth/plugins/entity-definitions/organization.d.ts +9 -0
- package/dist/auth/plugins/entity-definitions/organization.d.ts.map +1 -0
- package/dist/auth/plugins/entity-definitions/organization.js +424 -0
- package/dist/auth/plugins/entity-definitions/passkey.d.ts +10 -0
- package/dist/auth/plugins/entity-definitions/passkey.d.ts.map +1 -0
- package/dist/auth/plugins/entity-definitions/passkey.js +129 -0
- package/dist/auth/plugins/entity-definitions/sso.d.ts +10 -0
- package/dist/auth/plugins/entity-definitions/sso.d.ts.map +1 -0
- package/dist/auth/plugins/entity-definitions/sso.js +110 -0
- package/dist/auth/plugins/entity-definitions/types.d.ts +1 -1
- package/dist/auth/plugins/entity-definitions/types.d.ts.map +1 -1
- package/dist/auth/plugins/entity-definitions/types.js +1 -1
- package/dist/auth/plugins/wrappers/admin.d.ts.map +1 -1
- package/dist/auth/plugins/wrappers/admin.js +2 -4
- package/dist/auth/plugins/wrappers/anonymous.d.ts +18 -0
- package/dist/auth/plugins/wrappers/anonymous.d.ts.map +1 -0
- package/dist/auth/plugins/wrappers/anonymous.js +26 -0
- package/dist/auth/plugins/wrappers/api-key.d.ts +18 -0
- package/dist/auth/plugins/wrappers/api-key.d.ts.map +1 -0
- package/dist/auth/plugins/wrappers/api-key.js +38 -0
- package/dist/auth/plugins/wrappers/index.d.ts +6 -0
- package/dist/auth/plugins/wrappers/index.d.ts.map +1 -1
- package/dist/auth/plugins/wrappers/index.js +7 -1
- package/dist/auth/plugins/wrappers/jwt.d.ts +18 -0
- package/dist/auth/plugins/wrappers/jwt.d.ts.map +1 -0
- package/dist/auth/plugins/wrappers/jwt.js +30 -0
- package/dist/auth/plugins/wrappers/organization.d.ts +18 -0
- package/dist/auth/plugins/wrappers/organization.d.ts.map +1 -0
- package/dist/auth/plugins/wrappers/organization.js +67 -0
- package/dist/auth/plugins/wrappers/passkey.d.ts +18 -0
- package/dist/auth/plugins/wrappers/passkey.d.ts.map +1 -0
- package/dist/auth/plugins/wrappers/passkey.js +32 -0
- package/dist/auth/plugins/wrappers/phone-number.d.ts.map +1 -1
- package/dist/auth/plugins/wrappers/phone-number.js +2 -4
- package/dist/auth/plugins/wrappers/sso.d.ts +853 -0
- package/dist/auth/plugins/wrappers/sso.d.ts.map +1 -0
- package/dist/auth/plugins/wrappers/sso.js +36 -0
- package/dist/auth/plugins/wrappers/two-factor.d.ts.map +1 -1
- package/dist/auth/plugins/wrappers/two-factor.js +2 -4
- package/dist/auth/plugins/wrappers/username.d.ts.map +1 -1
- package/dist/auth/plugins/wrappers/username.js +2 -4
- package/dist/bin/build-config.d.ts +2 -2
- package/dist/bin/build-config.js +6 -7
- package/dist/bin/cli.js +417 -32
- package/dist/bin/fixture.d.ts +27 -0
- package/dist/bin/fixture.d.ts.map +1 -0
- package/dist/bin/fixture.js +245 -0
- package/dist/cache/decorator.d.ts +4 -3
- package/dist/cache/decorator.d.ts.map +1 -1
- package/dist/cache/decorator.js +5 -4
- package/dist/cone/cone-generator.d.ts +33 -0
- package/dist/cone/cone-generator.d.ts.map +1 -0
- package/dist/cone/cone-generator.js +286 -0
- package/dist/database/_batch_update.d.ts.map +1 -1
- package/dist/database/_batch_update.js +16 -2
- package/dist/database/puri-subset.test-d.js +1 -1
- package/dist/database/puri-subset.types.d.ts +1 -1
- package/dist/database/puri-subset.types.d.ts.map +1 -1
- package/dist/database/puri-subset.types.js +1 -1
- package/dist/database/puri.d.ts +4 -0
- package/dist/database/puri.d.ts.map +1 -1
- package/dist/database/puri.js +20 -2
- package/dist/database/upsert-builder.d.ts.map +1 -1
- package/dist/database/upsert-builder.js +19 -3
- package/dist/dict/en.d.ts +15 -0
- package/dist/dict/en.d.ts.map +1 -1
- package/dist/dict/en.js +2 -1
- package/dist/dict/ko.d.ts +15 -0
- package/dist/dict/ko.d.ts.map +1 -1
- package/dist/dict/ko.js +2 -1
- package/dist/dict/rc-keys.d.ts +28 -0
- package/dist/dict/rc-keys.d.ts.map +1 -1
- package/dist/dict/rc-keys.js +31 -1
- package/dist/dict/sd.d.ts.map +1 -1
- package/dist/dict/sd.js +20 -4
- package/dist/entity/entity-manager.d.ts +298 -2
- package/dist/entity/entity-manager.d.ts.map +1 -1
- package/dist/entity/entity-manager.js +4 -1
- package/dist/entity/entity-template-cone.d.ts +14 -0
- package/dist/entity/entity-template-cone.d.ts.map +1 -0
- package/dist/entity/entity-template-cone.js +222 -0
- package/dist/entity/entity.d.ts +47 -2
- package/dist/entity/entity.d.ts.map +1 -1
- package/dist/entity/entity.js +161 -14
- package/dist/ssr/renderer.js +3 -3
- package/dist/syncer/api-parser.js +12 -1
- package/dist/syncer/checksum.d.ts +0 -14
- package/dist/syncer/checksum.d.ts.map +1 -1
- package/dist/syncer/checksum.js +1 -23
- package/dist/syncer/syncer-actions.d.ts.map +1 -1
- package/dist/syncer/syncer-actions.js +8 -2
- package/dist/syncer/syncer.d.ts +1 -1
- package/dist/syncer/syncer.d.ts.map +1 -1
- package/dist/syncer/syncer.js +17 -10
- package/dist/tasks/workflow-manager.d.ts +13 -1
- package/dist/tasks/workflow-manager.d.ts.map +1 -1
- package/dist/tasks/workflow-manager.js +18 -1
- package/dist/template/entity-converter.js +4 -4
- package/dist/template/helpers.d.ts +10 -0
- package/dist/template/helpers.d.ts.map +1 -1
- package/dist/template/helpers.js +48 -1
- package/dist/template/implementations/entry-server.template.d.ts +1 -1
- package/dist/template/implementations/entry-server.template.js +7 -2
- package/dist/template/implementations/generated.template.d.ts.map +1 -1
- package/dist/template/implementations/generated.template.js +5 -1
- package/dist/template/implementations/generated_http.template.d.ts +1 -0
- package/dist/template/implementations/generated_http.template.d.ts.map +1 -1
- package/dist/template/implementations/generated_http.template.js +6 -2
- package/dist/template/implementations/generated_sso.template.d.ts.map +1 -1
- package/dist/template/implementations/generated_sso.template.js +29 -8
- package/dist/template/implementations/queries.template.d.ts.map +1 -1
- package/dist/template/implementations/queries.template.js +9 -1
- package/dist/template/implementations/sd.template.d.ts +1 -1
- package/dist/template/implementations/sd.template.d.ts.map +1 -1
- package/dist/template/implementations/sd.template.js +28 -4
- package/dist/template/implementations/services.template.d.ts.map +1 -1
- package/dist/template/implementations/services.template.js +12 -12
- package/dist/template/implementations/view_form.template.d.ts +11 -7
- package/dist/template/implementations/view_form.template.d.ts.map +1 -1
- package/dist/template/implementations/view_form.template.js +97 -87
- package/dist/template/implementations/view_list.template.d.ts +3 -3
- package/dist/template/implementations/view_list.template.d.ts.map +1 -1
- package/dist/template/implementations/view_list.template.js +115 -109
- package/dist/template/implementations/view_search_input.template.d.ts.map +1 -1
- package/dist/template/implementations/view_search_input.template.js +18 -14
- package/dist/template/zod-converter.d.ts.map +1 -1
- package/dist/template/zod-converter.js +95 -7
- package/dist/testing/_relation-graph.js +1 -1
- package/dist/testing/data-explorer.d.ts +61 -0
- package/dist/testing/data-explorer.d.ts.map +1 -0
- package/dist/testing/data-explorer.js +274 -0
- package/dist/testing/faker-mappings.d.ts +20 -0
- package/dist/testing/faker-mappings.d.ts.map +1 -0
- package/dist/testing/faker-mappings.js +421 -0
- package/dist/testing/fixture-generator.d.ts +161 -0
- package/dist/testing/fixture-generator.d.ts.map +1 -0
- package/dist/testing/fixture-generator.js +954 -0
- package/dist/testing/fixture-manager.d.ts +6 -1
- package/dist/testing/fixture-manager.d.ts.map +1 -1
- package/dist/testing/fixture-manager.js +72 -4
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +4 -1
- package/dist/types/types.d.ts +1520 -26
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/types.js +136 -22
- package/dist/ui/ai-client.d.ts.map +1 -1
- package/dist/ui/ai-client.js +9 -4
- package/dist/ui/api.d.ts.map +1 -1
- package/dist/ui/api.js +303 -24
- package/dist/ui-web/assets/index-CsUr-_pV.js +254 -0
- package/dist/ui-web/assets/index-T42zzs1K.css +1 -0
- package/dist/ui-web/index.html +2 -2
- package/dist/utils/fs-utils.d.ts +2 -1
- package/dist/utils/fs-utils.d.ts.map +1 -1
- package/dist/utils/fs-utils.js +14 -3
- package/package.json +19 -11
- package/src/api/config.ts +12 -1
- package/src/api/sonamu.ts +179 -65
- package/src/auth/plugins/entity-definitions/anonymous.ts +17 -0
- package/src/auth/plugins/entity-definitions/api-key.ts +93 -0
- package/src/auth/plugins/entity-definitions/index.ts +18 -0
- package/src/auth/plugins/entity-definitions/jwt.ts +35 -0
- package/src/auth/plugins/entity-definitions/organization.ts +215 -0
- package/src/auth/plugins/entity-definitions/passkey.ts +64 -0
- package/src/auth/plugins/entity-definitions/sso.ts +62 -0
- package/src/auth/plugins/entity-definitions/types.ts +11 -1
- package/src/auth/plugins/wrappers/admin.ts +1 -3
- package/src/auth/plugins/wrappers/anonymous.ts +30 -0
- package/src/auth/plugins/wrappers/api-key.ts +42 -0
- package/src/auth/plugins/wrappers/index.ts +6 -0
- package/src/auth/plugins/wrappers/jwt.ts +34 -0
- package/src/auth/plugins/wrappers/organization.ts +73 -0
- package/src/auth/plugins/wrappers/passkey.ts +36 -0
- package/src/auth/plugins/wrappers/phone-number.ts +1 -3
- package/src/auth/plugins/wrappers/sso.ts +40 -0
- package/src/auth/plugins/wrappers/two-factor.ts +1 -3
- package/src/auth/plugins/wrappers/username.ts +1 -3
- package/src/bin/build-config.ts +6 -6
- package/src/bin/cli.ts +452 -31
- package/src/bin/fixture.ts +302 -0
- package/src/cache/decorator.ts +4 -3
- package/src/cone/cone-generator.ts +363 -0
- package/src/database/_batch_update.ts +11 -0
- package/src/database/puri-subset.test-d.ts +13 -13
- package/src/database/puri-subset.types.ts +1 -1
- package/src/database/puri.ts +43 -1
- package/src/database/upsert-builder.ts +16 -2
- package/src/dict/en.ts +1 -0
- package/src/dict/ko.ts +1 -0
- package/src/dict/rc-keys.ts +32 -0
- package/src/dict/sd.ts +23 -3
- package/src/entity/entity-manager.ts +4 -0
- package/src/entity/entity-template-cone.ts +298 -0
- package/src/entity/entity.ts +189 -13
- package/src/shared/app.shared.ts.txt +5 -0
- package/src/shared/web.shared.ts.txt +9 -5
- package/src/skills/project/README.md +21 -0
- package/src/skills/project/architecture.md +373 -0
- package/src/skills/project/business-logic.md +270 -0
- package/src/skills/project/requirements.md +160 -0
- package/src/skills/sonamu/SKILL.md +168 -3
- package/src/skills/sonamu/api.md +102 -0
- package/src/skills/sonamu/database.md +220 -1
- package/src/skills/sonamu/entity-relations.md +89 -1
- package/src/skills/sonamu/fixture-cli.md +501 -0
- package/src/skills/sonamu/frontend.md +214 -0
- package/src/skills/sonamu/i18n.md +95 -0
- package/src/skills/sonamu/model.md +153 -0
- package/src/skills/sonamu/project-init.md +178 -8
- package/src/skills/sonamu/scaffolding.md +112 -0
- package/src/skills/sonamu/subset.md +9 -3
- package/src/skills/sonamu/testing.md +287 -2
- package/src/skills/sonamu/workflow.md +70 -5
- package/src/ssr/renderer.ts +2 -2
- package/src/syncer/api-parser.ts +12 -0
- package/src/syncer/checksum.ts +0 -38
- package/src/syncer/syncer-actions.ts +7 -1
- package/src/syncer/syncer.ts +16 -5
- package/src/tasks/workflow-manager.ts +29 -8
- package/src/template/entity-converter.ts +3 -3
- package/src/template/helpers.ts +49 -0
- package/src/template/implementations/entry-server.template.ts +1 -1
- package/src/template/implementations/generated.template.ts +4 -0
- package/src/template/implementations/generated_http.template.ts +1 -0
- package/src/template/implementations/generated_sso.template.ts +40 -11
- package/src/template/implementations/queries.template.ts +8 -0
- package/src/template/implementations/sd.template.ts +22 -3
- package/src/template/implementations/services.template.ts +11 -10
- package/src/template/implementations/view_form.template.ts +111 -101
- package/src/template/implementations/view_list.template.ts +120 -119
- package/src/template/implementations/view_search_input.template.ts +17 -13
- package/src/template/zod-converter.ts +103 -6
- package/src/testing/_relation-graph.ts +1 -1
- package/src/testing/data-explorer.ts +427 -0
- package/src/testing/faker-mappings.ts +434 -0
- package/src/testing/fixture-generator.ts +1166 -0
- package/src/testing/fixture-manager.ts +91 -6
- package/src/testing/index.ts +3 -0
- package/src/types/types.ts +222 -26
- package/src/ui/ai-client.ts +9 -1
- package/src/ui/api.ts +429 -23
- package/src/utils/fs-utils.ts +14 -1
- package/dist/template/implementations/view_enums_select.template.d.ts +0 -17
- package/dist/template/implementations/view_enums_select.template.d.ts.map +0 -1
- package/dist/template/implementations/view_enums_select.template.js +0 -62
- package/dist/template/implementations/view_id_async_select.template.d.ts +0 -17
- package/dist/template/implementations/view_id_async_select.template.d.ts.map +0 -1
- package/dist/template/implementations/view_id_async_select.template.js +0 -125
- package/dist/ui-web/assets/index-Bd_2AkLb.css +0 -1
- package/dist/ui-web/assets/index-BpSbhQWo.js +0 -225
- package/src/template/implementations/view_enums_select.template.ts +0 -65
- package/src/template/implementations/view_id_async_select.template.ts +0 -139
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import type { Knex } from "knex";
|
|
3
|
+
import type { Entity } from "../entity/entity";
|
|
4
|
+
import type { EntityManager } from "../entity/entity-manager";
|
|
5
|
+
import type { EntityProp, FixtureImportResult, FixtureRecord } from "../types/types";
|
|
6
|
+
import { isBelongsToOneRelationProp, isOneToOneRelationProp, isRelationProp } from "../types/types";
|
|
7
|
+
import {
|
|
8
|
+
DataExplorer,
|
|
9
|
+
type ExploreWithRelationsOptions,
|
|
10
|
+
type ExploreWithRelationsResult,
|
|
11
|
+
} from "./data-explorer";
|
|
12
|
+
import { type FakerMappings, fakerMappings } from "./faker-mappings";
|
|
13
|
+
import { FixtureManager } from "./fixture-manager";
|
|
14
|
+
|
|
15
|
+
export type Locale = "ko" | "en" | "ja";
|
|
16
|
+
|
|
17
|
+
export type FixtureGeneratorOptions = {
|
|
18
|
+
locale?: Locale;
|
|
19
|
+
useLLM?: boolean;
|
|
20
|
+
enableLLMCache?: boolean;
|
|
21
|
+
llmModel?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type GeneratorContext = {
|
|
25
|
+
/** 생성 중인 fixture들 (메모리 상) */
|
|
26
|
+
fixtures: Map<string, Record<string, unknown>>;
|
|
27
|
+
|
|
28
|
+
/** 참조 데이터 캐시 (DataExplorer 결과) */
|
|
29
|
+
referenceCache: Map<string, Record<string, unknown>[]>;
|
|
30
|
+
|
|
31
|
+
/** 이미 import된 레코드를 추적하여 중복 import를 방지합니다 */
|
|
32
|
+
importedRecords: Set<string>; // "User#123"
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export class FixtureGenerator {
|
|
36
|
+
private dataExplorer: DataExplorer;
|
|
37
|
+
private locale: Locale;
|
|
38
|
+
private mappings: FakerMappings;
|
|
39
|
+
private llmCache: Map<string, unknown> = new Map();
|
|
40
|
+
private options: FixtureGeneratorOptions;
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
private sourceDb: Knex,
|
|
44
|
+
// FixtureManager.insertFixtures가 dbName 문자열을 받기 때문에 직접 사용하지 않습니다
|
|
45
|
+
// 미래 확장성을 위해 API 시그니처에는 포함시켰습니다
|
|
46
|
+
_targetDb: Knex,
|
|
47
|
+
private targetDbName: "fixture" | "test" | "production_master",
|
|
48
|
+
private entityManager: typeof EntityManager,
|
|
49
|
+
options?: FixtureGeneratorOptions,
|
|
50
|
+
) {
|
|
51
|
+
this.dataExplorer = new DataExplorer(sourceDb, entityManager);
|
|
52
|
+
this.locale = options?.locale || "ko";
|
|
53
|
+
this.mappings = fakerMappings;
|
|
54
|
+
this.options = {
|
|
55
|
+
locale: options?.locale || "ko",
|
|
56
|
+
useLLM: options?.useLLM || false,
|
|
57
|
+
enableLLMCache: options?.enableLLMCache !== false,
|
|
58
|
+
llmModel: options?.llmModel || "claude-sonnet-4-5",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Fixture 생성 (단일)
|
|
64
|
+
* @returns 생성된 fixture 데이터 (메모리 상)
|
|
65
|
+
*/
|
|
66
|
+
async generate(
|
|
67
|
+
entityName: string,
|
|
68
|
+
overrides: Record<string, unknown> = {},
|
|
69
|
+
context: GeneratorContext = this.createContext(),
|
|
70
|
+
): Promise<Record<string, unknown>> {
|
|
71
|
+
const entity = this.entityManager.get(entityName);
|
|
72
|
+
const tempId = `${entityName}#temp#${Date.now()}`; // 임시 ID
|
|
73
|
+
|
|
74
|
+
// 각 prop별 값 생성
|
|
75
|
+
const fixture: Record<string, unknown> = {};
|
|
76
|
+
|
|
77
|
+
for (const prop of entity.props) {
|
|
78
|
+
// Virtual prop은 스킵
|
|
79
|
+
if ("virtual" in prop && prop.virtual) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// override가 있으면 사용
|
|
84
|
+
if (prop.name in overrides) {
|
|
85
|
+
fixture[prop.name] = overrides[prop.name];
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// cone에서 생성 전략 확인
|
|
90
|
+
const cone = prop.cone;
|
|
91
|
+
|
|
92
|
+
// 1. Relation prop 처리
|
|
93
|
+
if (isRelationProp(prop)) {
|
|
94
|
+
const relationValue = await this.generateRelationValue(entity, prop, context);
|
|
95
|
+
// BelongsToOne, OneToOne(hasJoinColumn)의 경우 foreign key 컬럼명으로 저장
|
|
96
|
+
if (
|
|
97
|
+
isBelongsToOneRelationProp(prop) ||
|
|
98
|
+
(isOneToOneRelationProp(prop) && prop.hasJoinColumn)
|
|
99
|
+
) {
|
|
100
|
+
fixture[`${prop.name}_id`] = relationValue;
|
|
101
|
+
} else {
|
|
102
|
+
fixture[prop.name] = relationValue;
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 2. fixtureGenerator 사용
|
|
108
|
+
if (cone?.fixtureGenerator) {
|
|
109
|
+
fixture[prop.name] = await this.executeGenerator(
|
|
110
|
+
cone.fixtureGenerator as string,
|
|
111
|
+
prop,
|
|
112
|
+
entity,
|
|
113
|
+
);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 2.5. fixtureHint + LLM 사용
|
|
118
|
+
if (cone?.fixtureHint && this.options.useLLM) {
|
|
119
|
+
try {
|
|
120
|
+
fixture[prop.name] = await this.generateWithLLM(cone.fixtureHint, prop, entity);
|
|
121
|
+
continue;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.warn(
|
|
124
|
+
`[FixtureGenerator] LLM generation failed for ${entity.id}.${prop.name}, falling back to default`,
|
|
125
|
+
error instanceof Error ? error.message : error,
|
|
126
|
+
);
|
|
127
|
+
// fallback: fixtureDefault 또는 기본값으로 계속
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 3. fixtureDefault 사용
|
|
132
|
+
if (cone?.fixtureDefault !== undefined) {
|
|
133
|
+
fixture[prop.name] = cone.fixtureDefault;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 4. 타입별 기본 생성
|
|
138
|
+
fixture[prop.name] = await this.generateDefaultValue(prop, entity);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 5. password 필드 암호화
|
|
142
|
+
if ("password" in fixture && fixture.password && typeof fixture.password === "string") {
|
|
143
|
+
const bcrypt = await import("bcrypt");
|
|
144
|
+
fixture.password = await bcrypt.hash(fixture.password, 10);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
context.fixtures.set(tempId, fixture);
|
|
148
|
+
return fixture;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Relation 값 생성 + 자동 Import
|
|
153
|
+
*/
|
|
154
|
+
private async generateRelationValue(
|
|
155
|
+
entity: Entity,
|
|
156
|
+
prop: EntityProp,
|
|
157
|
+
context: GeneratorContext,
|
|
158
|
+
): Promise<number | null> {
|
|
159
|
+
if (!isRelationProp(prop)) {
|
|
160
|
+
throw new Error(`FixtureGenerator: ${entity.id}.${prop.name} is not a relation prop`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// BelongsToOne, OneToOne(hasJoinColumn)만 처리
|
|
164
|
+
if (
|
|
165
|
+
!isBelongsToOneRelationProp(prop) &&
|
|
166
|
+
!(isOneToOneRelationProp(prop) && prop.hasJoinColumn)
|
|
167
|
+
) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const cone = prop.cone;
|
|
172
|
+
const dataSource = cone?.dataSource;
|
|
173
|
+
|
|
174
|
+
// DataExplorer로 참조 데이터 조회 (sourceDb)
|
|
175
|
+
// 관계 체인을 따라가기 위해 exploreWithRelations 사용
|
|
176
|
+
if (dataSource) {
|
|
177
|
+
const cacheKey = `${prop.with}:${JSON.stringify(dataSource)}`;
|
|
178
|
+
|
|
179
|
+
if (!context.referenceCache.has(cacheKey)) {
|
|
180
|
+
const exploreResult = await this.dataExplorer.exploreWithRelations(prop.with, {
|
|
181
|
+
strategy: dataSource.strategy,
|
|
182
|
+
limit:
|
|
183
|
+
((dataSource.config as Record<string, unknown> | undefined)?.limit as
|
|
184
|
+
| number
|
|
185
|
+
| undefined) || 10,
|
|
186
|
+
includeRelations: true,
|
|
187
|
+
maxDepth: 3,
|
|
188
|
+
...(dataSource.config as Record<string, unknown> | undefined),
|
|
189
|
+
});
|
|
190
|
+
context.referenceCache.set(cacheKey, exploreResult.main.records);
|
|
191
|
+
|
|
192
|
+
// 조회한 데이터와 관계된 모든 엔티티를 targetDb에 import
|
|
193
|
+
await this.importExploreResult(exploreResult, context);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const candidates = context.referenceCache.get(cacheKey);
|
|
197
|
+
if (candidates && candidates.length > 0) {
|
|
198
|
+
// 랜덤하게 하나 선택
|
|
199
|
+
const selected = candidates[Math.floor(Math.random() * candidates.length)];
|
|
200
|
+
return selected.id as number;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// dataSource가 없을 때 자동으로 fixture DB에서 조회 시도
|
|
205
|
+
// 관계 체인을 따라가기 위해 exploreWithRelations 사용
|
|
206
|
+
const autoKey = `${prop.with}:auto`;
|
|
207
|
+
if (!context.referenceCache.has(autoKey)) {
|
|
208
|
+
// fixture DB(sourceDb)에서 자동 조회 (관계 포함)
|
|
209
|
+
const autoExploreResult = await this.dataExplorer.exploreWithRelations(prop.with, {
|
|
210
|
+
strategy: "random",
|
|
211
|
+
limit: 10,
|
|
212
|
+
includeRelations: true,
|
|
213
|
+
maxDepth: 3,
|
|
214
|
+
});
|
|
215
|
+
context.referenceCache.set(autoKey, autoExploreResult.main.records);
|
|
216
|
+
|
|
217
|
+
// 조회한 데이터와 관계된 모든 엔티티를 targetDb에 import
|
|
218
|
+
if (autoExploreResult.main.records.length > 0) {
|
|
219
|
+
await this.importExploreResult(autoExploreResult, context);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const autoCandidates = context.referenceCache.get(autoKey);
|
|
224
|
+
if (autoCandidates && autoCandidates.length > 0) {
|
|
225
|
+
// 랜덤하게 하나 선택
|
|
226
|
+
const selected = autoCandidates[Math.floor(Math.random() * autoCandidates.length)];
|
|
227
|
+
return selected.id as number;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 참조 데이터가 없으면 null 반환 (nullable인 경우)
|
|
231
|
+
if (prop.nullable) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// nullable이 아니고 데이터도 없으면 에러
|
|
236
|
+
throw new Error(
|
|
237
|
+
`FixtureGenerator: ${entity.id}.${prop.name}에 필요한 ${prop.with} 데이터가 없습니다. ` +
|
|
238
|
+
`먼저 ${prop.with}를 생성하거나 cone.dataSource를 설정하세요.`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* ExploreWithRelations 결과를 targetDb에 import
|
|
244
|
+
*
|
|
245
|
+
* 관계 체인을 따라간 결과(main + related)를 모두 import합니다.
|
|
246
|
+
* 의존성 순서는 FixtureManager.insertFixtures가 자동으로 처리합니다.
|
|
247
|
+
*/
|
|
248
|
+
private async importExploreResult(
|
|
249
|
+
exploreResult: ExploreWithRelationsResult,
|
|
250
|
+
context: GeneratorContext,
|
|
251
|
+
): Promise<void> {
|
|
252
|
+
const allFixtureRecords: FixtureRecord[] = [];
|
|
253
|
+
|
|
254
|
+
// 1. Related entities import (Company, Department 등)
|
|
255
|
+
for (const [entityId, records] of exploreResult.related.entries()) {
|
|
256
|
+
const entity = this.entityManager.get(entityId);
|
|
257
|
+
const recordsToImport: Record<string, unknown>[] = [];
|
|
258
|
+
|
|
259
|
+
console.log(chalk.cyan(`Importing related entity: ${entityId} (${records.length} records)`));
|
|
260
|
+
|
|
261
|
+
for (const record of records) {
|
|
262
|
+
const recordKey = `${entityId}#${record.id}`;
|
|
263
|
+
if (!context.importedRecords.has(recordKey)) {
|
|
264
|
+
recordsToImport.push(record);
|
|
265
|
+
context.importedRecords.add(recordKey);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (recordsToImport.length > 0) {
|
|
270
|
+
for (const record of recordsToImport) {
|
|
271
|
+
console.log(
|
|
272
|
+
chalk.gray(` - Processing ${entityId} record:`, JSON.stringify(record).slice(0, 100)),
|
|
273
|
+
);
|
|
274
|
+
const fixtureRecords = await FixtureManager.createFixtureRecord(
|
|
275
|
+
entity,
|
|
276
|
+
record as { id: number | string; [key: string]: string | number | boolean | null },
|
|
277
|
+
{ _db: this.sourceDb, singleRecord: true },
|
|
278
|
+
);
|
|
279
|
+
allFixtureRecords.push(...fixtureRecords);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 2. Main entity import (Employee 등)
|
|
285
|
+
const mainEntity = this.entityManager.get(exploreResult.main.entityId);
|
|
286
|
+
const mainRecordsToImport: Record<string, unknown>[] = [];
|
|
287
|
+
|
|
288
|
+
console.log(
|
|
289
|
+
chalk.cyan(
|
|
290
|
+
`Importing main entity: ${exploreResult.main.entityId} (${exploreResult.main.records.length} records)`,
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
for (const record of exploreResult.main.records) {
|
|
295
|
+
const recordKey = `${exploreResult.main.entityId}#${record.id}`;
|
|
296
|
+
if (!context.importedRecords.has(recordKey)) {
|
|
297
|
+
mainRecordsToImport.push(record);
|
|
298
|
+
context.importedRecords.add(recordKey);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (mainRecordsToImport.length > 0) {
|
|
303
|
+
for (const record of mainRecordsToImport) {
|
|
304
|
+
console.log(
|
|
305
|
+
chalk.gray(
|
|
306
|
+
` - Processing ${exploreResult.main.entityId} record:`,
|
|
307
|
+
JSON.stringify(record).slice(0, 100),
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
const fixtureRecords = await FixtureManager.createFixtureRecord(
|
|
311
|
+
mainEntity,
|
|
312
|
+
record as { id: number | string; [key: string]: string | number | boolean | null },
|
|
313
|
+
{ _db: this.sourceDb, singleRecord: true },
|
|
314
|
+
);
|
|
315
|
+
allFixtureRecords.push(...fixtureRecords);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 3. 모든 fixture를 한 번에 삽입 (의존성 순서 자동 처리)
|
|
320
|
+
if (allFixtureRecords.length > 0) {
|
|
321
|
+
await FixtureManager.insertFixtures(this.targetDbName, allFixtureRecords);
|
|
322
|
+
|
|
323
|
+
console.log(
|
|
324
|
+
chalk.green(
|
|
325
|
+
`Auto-imported ${exploreResult.main.entityId} with relations: ` +
|
|
326
|
+
`${exploreResult.main.records.length} main + ${exploreResult.related.size} related entities`,
|
|
327
|
+
),
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* fixtureGenerator 실행 (Faker.js만 지원)
|
|
334
|
+
*
|
|
335
|
+
* faker.* 형식의 표현식을 안전하게 파싱하여 실행합니다.
|
|
336
|
+
* 예: "faker.internet.email()" → faker.internet.email()
|
|
337
|
+
* 예: "faker.lorem.words(3)" → faker.lorem.words(3)
|
|
338
|
+
*/
|
|
339
|
+
private async executeGenerator(
|
|
340
|
+
generator: string,
|
|
341
|
+
prop: EntityProp,
|
|
342
|
+
entity: Entity,
|
|
343
|
+
): Promise<unknown> {
|
|
344
|
+
// Faker.js 표현식만 지원
|
|
345
|
+
if (generator.startsWith("faker.")) {
|
|
346
|
+
// username이나 name 필드는 한국어 faker 사용
|
|
347
|
+
const isNameField = prop.name === "username" || prop.name === "name";
|
|
348
|
+
const fakerModule = await import("@faker-js/faker");
|
|
349
|
+
const faker = isNameField ? fakerModule.fakerKO : fakerModule.faker;
|
|
350
|
+
const expr = generator.slice(6); // "faker." 제거
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
// 함수 경로와 인자 파싱
|
|
354
|
+
const match = expr.match(/^([\w.]+)(?:\((.*?)\))?$/);
|
|
355
|
+
if (!match) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`FixtureGenerator: Invalid faker expression for ${prop.name}: ${generator}`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const [, path, argsStr] = match;
|
|
362
|
+
const parts = path.split(".");
|
|
363
|
+
|
|
364
|
+
// faker 객체에서 함수 찾기
|
|
365
|
+
let fn: unknown = faker;
|
|
366
|
+
for (const part of parts) {
|
|
367
|
+
if (typeof fn === "object" && fn !== null && part in fn) {
|
|
368
|
+
fn = (fn as Record<string, unknown>)[part];
|
|
369
|
+
} else {
|
|
370
|
+
throw new Error(`FixtureGenerator: Invalid faker path for ${prop.name}: faker.${path}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 함수가 아니면 에러
|
|
375
|
+
if (typeof fn !== "function") {
|
|
376
|
+
throw new Error(`FixtureGenerator: faker.${path} is not a function (for ${prop.name})`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 인자 파싱 (JSON 형식만 지원)
|
|
380
|
+
let args: unknown[] = [];
|
|
381
|
+
if (argsStr?.trim()) {
|
|
382
|
+
try {
|
|
383
|
+
// JSON 배열로 파싱 시도
|
|
384
|
+
const parsed = JSON.parse(`[${argsStr}]`) as unknown;
|
|
385
|
+
args = Array.isArray(parsed) ? parsed : [parsed];
|
|
386
|
+
} catch {
|
|
387
|
+
// 숫자나 문자열 단일 인자 처리
|
|
388
|
+
const trimmed = argsStr.trim();
|
|
389
|
+
if (!Number.isNaN(Number(trimmed))) {
|
|
390
|
+
args = [Number(trimmed)];
|
|
391
|
+
} else if (
|
|
392
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
393
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
394
|
+
) {
|
|
395
|
+
args = [trimmed.slice(1, -1)];
|
|
396
|
+
} else {
|
|
397
|
+
throw new Error(
|
|
398
|
+
`FixtureGenerator: Cannot parse arguments for ${prop.name}: ${argsStr}`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return fn(...args);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
console.log(
|
|
407
|
+
chalk.yellow(
|
|
408
|
+
`Failed to execute generator "${generator}" for ${prop.name}, falling back to default:`,
|
|
409
|
+
),
|
|
410
|
+
error,
|
|
411
|
+
);
|
|
412
|
+
return this.generateDefaultValue(prop, entity);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// faker 이외의 표현식은 지원하지 않음
|
|
417
|
+
console.log(
|
|
418
|
+
chalk.yellow(
|
|
419
|
+
`Unsupported generator expression for ${prop.name}: ${generator}. Only faker.* expressions are supported. Using default value.`,
|
|
420
|
+
),
|
|
421
|
+
);
|
|
422
|
+
return this.generateDefaultValue(prop, entity);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* 필드의 타입과 이름을 분석하여 적절한 기본값을 생성합니다.
|
|
427
|
+
*
|
|
428
|
+
* 우선순위:
|
|
429
|
+
* 1. 필드명 패턴 매칭 (salary, budget 등 의미있는 데이터)
|
|
430
|
+
* 2. 특수 케이스 (Department name 등 도메인 지식)
|
|
431
|
+
* 3. 배열 타입 (JSON 배열)
|
|
432
|
+
* 4. Enum 타입
|
|
433
|
+
* 5. 타입별 기본값
|
|
434
|
+
*/
|
|
435
|
+
private async generateDefaultValue(prop: EntityProp, entity?: Entity): Promise<unknown> {
|
|
436
|
+
const fakerModule = await import("@faker-js/faker");
|
|
437
|
+
const faker = fakerModule.faker;
|
|
438
|
+
const fakerKO = fakerModule.fakerKO;
|
|
439
|
+
const fakerJA = fakerModule.fakerJA;
|
|
440
|
+
|
|
441
|
+
const localeFaker = this.locale === "ko" ? fakerKO : this.locale === "ja" ? fakerJA : faker;
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* 1. 필드명에서 의미를 추론하여 현실적인 데이터를 생성합니다.
|
|
445
|
+
* 예: salary → 30M~150M (한국 연봉 범위)
|
|
446
|
+
* budget → 10M~500M (프로젝트 예산 범위)
|
|
447
|
+
*/
|
|
448
|
+
const localeMappings = this.mappings[this.locale] || this.mappings.en;
|
|
449
|
+
const normalizedName = prop.name.toLowerCase().replace(/_/g, "");
|
|
450
|
+
|
|
451
|
+
for (const [pattern, config] of Object.entries(localeMappings.field_patterns)) {
|
|
452
|
+
if (normalizedName.includes(pattern.toLowerCase())) {
|
|
453
|
+
try {
|
|
454
|
+
return await this.executeFakerExpression(config.faker, prop);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
console.log(
|
|
457
|
+
chalk.yellow(
|
|
458
|
+
`Failed to execute field pattern "${pattern}" for ${prop.name}, falling back:`,
|
|
459
|
+
),
|
|
460
|
+
error,
|
|
461
|
+
);
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 2. Department name은 한국어 부서명 목록에서 선택합니다.
|
|
469
|
+
* 고유성을 위해 70% 확률로 prefix/suffix를 추가합니다.
|
|
470
|
+
*/
|
|
471
|
+
if (entity?.id === "Department" && prop.name === "name") {
|
|
472
|
+
const departments = [
|
|
473
|
+
"개발팀",
|
|
474
|
+
"기획팀",
|
|
475
|
+
"마케팅팀",
|
|
476
|
+
"영업팀",
|
|
477
|
+
"인사팀",
|
|
478
|
+
"총무팀",
|
|
479
|
+
"재무팀",
|
|
480
|
+
"회계팀",
|
|
481
|
+
"법무팀",
|
|
482
|
+
"디자인팀",
|
|
483
|
+
"IT팀",
|
|
484
|
+
"고객지원팀",
|
|
485
|
+
"품질관리팀",
|
|
486
|
+
"연구개발팀",
|
|
487
|
+
"생산팀",
|
|
488
|
+
"구매팀",
|
|
489
|
+
"물류팀",
|
|
490
|
+
];
|
|
491
|
+
const prefixes = ["신규", "통합", "전략", "글로벌", "디지털", "핵심"];
|
|
492
|
+
const suffixes = ["1팀", "2팀", "3팀", "A팀", "B팀", "본부", "센터", "그룹"];
|
|
493
|
+
|
|
494
|
+
const dept = faker.helpers.arrayElement(departments);
|
|
495
|
+
|
|
496
|
+
const random = Math.random();
|
|
497
|
+
if (random > 0.7) {
|
|
498
|
+
const prefix = faker.helpers.arrayElement(prefixes);
|
|
499
|
+
return `${prefix} ${dept}`;
|
|
500
|
+
}
|
|
501
|
+
if (random > 0.4) {
|
|
502
|
+
const suffix = faker.helpers.arrayElement(suffixes);
|
|
503
|
+
return `${dept} ${suffix}`;
|
|
504
|
+
}
|
|
505
|
+
return dept;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* 3. JSON 타입이면서 배열인 경우 (SonamuFile[], string[] 등)
|
|
510
|
+
* 필드명 패턴을 보고 적절한 배열 데이터를 생성합니다.
|
|
511
|
+
*/
|
|
512
|
+
if (prop.type === "json" && "id" in prop && prop.id) {
|
|
513
|
+
if (prop.id.endsWith("[]")) {
|
|
514
|
+
return this.generateArrayValue(prop, entity, faker, localeFaker);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** 4. Enum 타입은 정의된 값 중 하나를 랜덤 선택합니다 */
|
|
519
|
+
if (prop.type === "enum") {
|
|
520
|
+
let enumValues: string[] = [];
|
|
521
|
+
|
|
522
|
+
if ("enum" in prop && Array.isArray(prop.enum) && prop.enum.length > 0) {
|
|
523
|
+
enumValues = prop.enum;
|
|
524
|
+
} else if ("id" in prop && prop.id && entity?.enumLabels?.[prop.id]) {
|
|
525
|
+
enumValues = Object.keys(entity.enumLabels[prop.id]);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (enumValues.length > 0) {
|
|
529
|
+
return faker.helpers.arrayElement(enumValues);
|
|
530
|
+
}
|
|
531
|
+
return prop.nullable ? null : "UNKNOWN";
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (prop.type === "enum[]") {
|
|
535
|
+
let enumValues: string[] = [];
|
|
536
|
+
|
|
537
|
+
if ("enum" in prop && Array.isArray(prop.enum) && prop.enum.length > 0) {
|
|
538
|
+
enumValues = prop.enum;
|
|
539
|
+
} else if ("id" in prop && prop.id && entity?.enumLabels?.[prop.id]) {
|
|
540
|
+
enumValues = Object.keys(entity.enumLabels[prop.id]);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (enumValues.length > 0) {
|
|
544
|
+
return [faker.helpers.arrayElement(enumValues)];
|
|
545
|
+
}
|
|
546
|
+
return [];
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* 5. Vector 타입은 현재 지원하지 않으므로 null을 반환합니다.
|
|
551
|
+
* 향후 AI embedding 생성 기능 추가 시 구현 예정입니다.
|
|
552
|
+
*/
|
|
553
|
+
if (prop.type === "vector" || prop.type === "vector[]" || prop.type === "tsvector") {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** 6. 타입별 기본 Faker 표현식을 실행합니다 */
|
|
558
|
+
const typeDefault = localeMappings.type_defaults[prop.type];
|
|
559
|
+
if (typeDefault) {
|
|
560
|
+
try {
|
|
561
|
+
return await this.executeFakerExpression(typeDefault.faker, prop);
|
|
562
|
+
} catch (error) {
|
|
563
|
+
console.log(
|
|
564
|
+
chalk.yellow(`Failed to execute type default for ${prop.type}, using fallback:`, error),
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** 7. 매핑되지 않은 타입은 기본 Faker 함수로 처리합니다 */
|
|
570
|
+
switch (prop.type) {
|
|
571
|
+
case "string":
|
|
572
|
+
case "string[]":
|
|
573
|
+
return faker.lorem.words(3);
|
|
574
|
+
case "integer":
|
|
575
|
+
return faker.number.int({ min: 1, max: 1000 });
|
|
576
|
+
case "integer[]":
|
|
577
|
+
return [faker.number.int({ min: 1, max: 1000 })];
|
|
578
|
+
case "bigInteger":
|
|
579
|
+
return faker.number.bigInt({ min: 1n, max: 1000n });
|
|
580
|
+
case "bigInteger[]":
|
|
581
|
+
return [faker.number.bigInt({ min: 1n, max: 1000n })];
|
|
582
|
+
case "number":
|
|
583
|
+
case "numeric":
|
|
584
|
+
return faker.number.float({ min: 0, max: 1000 });
|
|
585
|
+
case "number[]":
|
|
586
|
+
case "numeric[]":
|
|
587
|
+
return [faker.number.float({ min: 0, max: 1000 })];
|
|
588
|
+
case "boolean":
|
|
589
|
+
return faker.datatype.boolean();
|
|
590
|
+
case "boolean[]":
|
|
591
|
+
return [faker.datatype.boolean()];
|
|
592
|
+
case "date":
|
|
593
|
+
case "date[]":
|
|
594
|
+
return faker.date.past();
|
|
595
|
+
case "json":
|
|
596
|
+
return {};
|
|
597
|
+
case "uuid":
|
|
598
|
+
case "uuid[]":
|
|
599
|
+
return faker.string.uuid();
|
|
600
|
+
default:
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* 배열 타입의 값을 생성합니다.
|
|
607
|
+
*
|
|
608
|
+
* 타입 ID와 필드명 패턴을 분석하여 적절한 배열 데이터를 생성합니다.
|
|
609
|
+
* 예: image_urls → [{url, name, mime_type}, ...]
|
|
610
|
+
* tag_ids → [1, 23, 45]
|
|
611
|
+
*/
|
|
612
|
+
private generateArrayValue(
|
|
613
|
+
prop: EntityProp,
|
|
614
|
+
_entity: Entity | undefined,
|
|
615
|
+
faker: typeof import("@faker-js/faker").faker,
|
|
616
|
+
_localeFaker: typeof import("@faker-js/faker").faker,
|
|
617
|
+
): unknown[] {
|
|
618
|
+
const count = faker.number.int({ min: 1, max: 3 });
|
|
619
|
+
|
|
620
|
+
/** SonamuFile[]은 Sonamu 내장 타입으로 구조가 정해져 있습니다 */
|
|
621
|
+
if ("id" in prop && prop.id === "SonamuFile[]") {
|
|
622
|
+
return Array.from({ length: count }, () => ({
|
|
623
|
+
url: faker.image.url(),
|
|
624
|
+
name: faker.system.fileName(),
|
|
625
|
+
mime_type: faker.helpers.arrayElement([
|
|
626
|
+
"image/jpeg",
|
|
627
|
+
"image/png",
|
|
628
|
+
"image/gif",
|
|
629
|
+
"application/pdf",
|
|
630
|
+
]),
|
|
631
|
+
}));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** 필드명에서 배열의 용도를 추론합니다 */
|
|
635
|
+
const normalizedName = prop.name.toLowerCase().replace(/_/g, "");
|
|
636
|
+
|
|
637
|
+
if (normalizedName.includes("url") || normalizedName.includes("image")) {
|
|
638
|
+
return Array.from({ length: count }, () => faker.internet.url());
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (normalizedName.includes("id") && normalizedName.endsWith("s")) {
|
|
642
|
+
return Array.from({ length: count }, () => faker.number.int({ min: 1, max: 100 }));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (normalizedName.includes("tag") || normalizedName.includes("name")) {
|
|
646
|
+
return Array.from({ length: count }, () => faker.lorem.word());
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** 패턴 매칭되지 않으면 빈 배열을 반환합니다 */
|
|
650
|
+
return [];
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* JSON 매핑의 Faker 표현식을 파싱하여 실행합니다.
|
|
655
|
+
*
|
|
656
|
+
* 표현식 예시:
|
|
657
|
+
* - "faker.internet.email()" → 인자 없음
|
|
658
|
+
* - "faker.number.int({ min: 1, max: 100 })" → JSON 인자
|
|
659
|
+
* - "{}" → 리터럴 값 (JSON.parse)
|
|
660
|
+
*
|
|
661
|
+
* fakerKO, fakerJA도 지원하여 다국어 데이터를 생성합니다.
|
|
662
|
+
*/
|
|
663
|
+
private async executeFakerExpression(expression: string, prop: EntityProp): Promise<unknown> {
|
|
664
|
+
const fakerModule = await import("@faker-js/faker");
|
|
665
|
+
const faker = fakerModule.faker;
|
|
666
|
+
const fakerKO = fakerModule.fakerKO;
|
|
667
|
+
const fakerJA = fakerModule.fakerJA;
|
|
668
|
+
|
|
669
|
+
/** Faker 표현식이 아닌 리터럴 값은 JSON으로 파싱합니다 */
|
|
670
|
+
if (!expression.startsWith("faker")) {
|
|
671
|
+
try {
|
|
672
|
+
return JSON.parse(expression);
|
|
673
|
+
} catch {
|
|
674
|
+
return expression;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** 표현식에서 Faker 객체와 경로를 추출합니다 */
|
|
679
|
+
const match = expression.match(/^(faker|fakerKO|fakerJA)\.(.*?)$/);
|
|
680
|
+
if (!match) {
|
|
681
|
+
throw new Error(`Invalid faker expression: ${expression}`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const [, fakerName, expr] = match;
|
|
685
|
+
const selectedFaker =
|
|
686
|
+
fakerName === "fakerKO" ? fakerKO : fakerName === "fakerJA" ? fakerJA : faker;
|
|
687
|
+
|
|
688
|
+
const funcMatch = expr.match(/^([\w.]+)(?:\((.*?)\))?$/);
|
|
689
|
+
if (!funcMatch) {
|
|
690
|
+
throw new Error(`Invalid faker expression for ${prop.name}: ${expression}`);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const [, path, argsStr] = funcMatch;
|
|
694
|
+
const parts = path.split(".");
|
|
695
|
+
|
|
696
|
+
/** 점 표기법(dot notation)으로 Faker 함수를 찾아갑니다 */
|
|
697
|
+
let fn: unknown = selectedFaker;
|
|
698
|
+
for (const part of parts) {
|
|
699
|
+
if (typeof fn === "object" && fn !== null && part in fn) {
|
|
700
|
+
fn = (fn as Record<string, unknown>)[part];
|
|
701
|
+
} else {
|
|
702
|
+
throw new Error(`Invalid faker path for ${prop.name}: ${fakerName}.${path}`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (typeof fn !== "function") {
|
|
707
|
+
throw new Error(`${fakerName}.${path} is not a function (for ${prop.name})`);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** 함수 인자를 JSON으로 파싱합니다 */
|
|
711
|
+
let args: unknown[] = [];
|
|
712
|
+
if (argsStr?.trim()) {
|
|
713
|
+
try {
|
|
714
|
+
const parsed = JSON.parse(`[${argsStr}]`) as unknown;
|
|
715
|
+
args = Array.isArray(parsed) ? parsed : [parsed];
|
|
716
|
+
} catch {
|
|
717
|
+
/** JSON 파싱 실패 시 단순 숫자/문자열로 시도합니다 */
|
|
718
|
+
const trimmed = argsStr.trim();
|
|
719
|
+
if (!Number.isNaN(Number(trimmed))) {
|
|
720
|
+
args = [Number(trimmed)];
|
|
721
|
+
} else if (
|
|
722
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
723
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
724
|
+
) {
|
|
725
|
+
args = [trimmed.slice(1, -1)];
|
|
726
|
+
} else {
|
|
727
|
+
throw new Error(`Cannot parse arguments for ${prop.name}: ${argsStr}`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return fn(...args);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* fixtureHint를 LLM에게 전달하여 현실적인 테스트 데이터를 생성합니다.
|
|
737
|
+
*
|
|
738
|
+
* faker.js로는 생성하기 어려운 복잡한 텍스트(자기소개, 설명문 등)를
|
|
739
|
+
* LLM을 활용하여 생성합니다. 동일한 hint에 대한 중복 호출을 방지하기 위해
|
|
740
|
+
* 캐싱을 기본으로 지원합니다 (LLM API 비용 절감).
|
|
741
|
+
*
|
|
742
|
+
* ai 패키지는 dynamic import로 불러오므로, useLLM이 false인 경우
|
|
743
|
+
* 의존성이 설치되지 않아도 fixture 생성이 정상 동작합니다.
|
|
744
|
+
*/
|
|
745
|
+
private async generateWithLLM(
|
|
746
|
+
fixtureHint: string,
|
|
747
|
+
prop: EntityProp,
|
|
748
|
+
entity: Entity,
|
|
749
|
+
): Promise<unknown> {
|
|
750
|
+
const cacheKey = `${entity.id}:${prop.name}:${fixtureHint}`;
|
|
751
|
+
if (this.options.enableLLMCache && this.llmCache.has(cacheKey)) {
|
|
752
|
+
return this.llmCache.get(cacheKey);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const apiKey = this.getApiKey();
|
|
756
|
+
const { createAnthropic } = await import("@ai-sdk/anthropic");
|
|
757
|
+
const { generateText } = await import("ai");
|
|
758
|
+
|
|
759
|
+
const { text } = await generateText({
|
|
760
|
+
model: createAnthropic({ apiKey })(this.options.llmModel || "claude-sonnet-4-5"),
|
|
761
|
+
prompt: this.buildLLMPrompt(fixtureHint, prop, entity),
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
const value = this.parseLLMResponse(text, prop.type);
|
|
765
|
+
if (this.options.enableLLMCache) {
|
|
766
|
+
this.llmCache.set(cacheKey, value);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return value;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private buildLLMPrompt(hint: string, prop: EntityProp, entity: Entity): string {
|
|
773
|
+
const locale = this.options.locale || "ko";
|
|
774
|
+
const language = locale === "ko" ? "Korean" : locale === "ja" ? "Japanese" : "English";
|
|
775
|
+
|
|
776
|
+
let prompt = `Generate test data for ${entity.id}.${prop.name} (type: ${prop.type})
|
|
777
|
+
|
|
778
|
+
Requirement: ${hint}
|
|
779
|
+
|
|
780
|
+
Rules:
|
|
781
|
+
- Return ONLY the value, no explanation or markdown
|
|
782
|
+
- Use ${language} language if applicable
|
|
783
|
+
- Format: ${this.getExpectedFormat(prop.type)}`;
|
|
784
|
+
|
|
785
|
+
// enum 타입인 경우 가능한 값 목록 추가
|
|
786
|
+
if (prop.type === "enum" || prop.type === "enum[]") {
|
|
787
|
+
let enumValues: string[] = [];
|
|
788
|
+
|
|
789
|
+
if ("enum" in prop && Array.isArray(prop.enum) && prop.enum.length > 0) {
|
|
790
|
+
enumValues = prop.enum;
|
|
791
|
+
} else if ("id" in prop && prop.id && entity?.enumLabels?.[prop.id]) {
|
|
792
|
+
enumValues = Object.keys(entity.enumLabels[prop.id]);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (enumValues.length > 0) {
|
|
796
|
+
prompt += `\n- IMPORTANT: Choose ONLY from these allowed values: ${enumValues.join(", ")}`;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
prompt += `\n\nExample: ${this.getExampleForType(prop.type, locale)}`;
|
|
801
|
+
|
|
802
|
+
return prompt;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private parseLLMResponse(text: string, propType: string): unknown {
|
|
806
|
+
const cleaned = text.trim();
|
|
807
|
+
|
|
808
|
+
// 배열 타입 처리
|
|
809
|
+
if (propType.endsWith("[]")) {
|
|
810
|
+
try {
|
|
811
|
+
const parsed = JSON.parse(cleaned);
|
|
812
|
+
const baseType = propType.slice(0, -2); // "integer[]" -> "integer"
|
|
813
|
+
|
|
814
|
+
if (Array.isArray(parsed)) {
|
|
815
|
+
return parsed.map((item) => {
|
|
816
|
+
// null/undefined는 타입별 기본값으로
|
|
817
|
+
if (item === null || item === undefined) {
|
|
818
|
+
return this.getDefaultValueForType(baseType);
|
|
819
|
+
}
|
|
820
|
+
// 객체는 JSON.stringify 후 파싱 (json 타입인 경우)
|
|
821
|
+
if (typeof item === "object") {
|
|
822
|
+
return baseType === "json"
|
|
823
|
+
? item
|
|
824
|
+
: this.parseScalarValue(JSON.stringify(item), baseType);
|
|
825
|
+
}
|
|
826
|
+
// primitive 값은 문자열로 변환 후 파싱
|
|
827
|
+
return this.parseScalarValue(String(item), baseType);
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// 단일 값이 온 경우 배열로 감싸기
|
|
832
|
+
if (parsed === null || parsed === undefined) {
|
|
833
|
+
return [this.getDefaultValueForType(baseType)];
|
|
834
|
+
}
|
|
835
|
+
return [this.parseScalarValue(String(parsed), baseType)];
|
|
836
|
+
} catch {
|
|
837
|
+
return [];
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
return this.parseScalarValue(cleaned, propType);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
private getDefaultValueForType(propType: string): unknown {
|
|
845
|
+
switch (propType) {
|
|
846
|
+
case "integer":
|
|
847
|
+
return 0;
|
|
848
|
+
case "bigInteger":
|
|
849
|
+
return 0n;
|
|
850
|
+
case "float":
|
|
851
|
+
case "number":
|
|
852
|
+
case "numeric":
|
|
853
|
+
return 0;
|
|
854
|
+
case "boolean":
|
|
855
|
+
return false;
|
|
856
|
+
case "date":
|
|
857
|
+
return new Date();
|
|
858
|
+
case "json":
|
|
859
|
+
return {};
|
|
860
|
+
case "uuid":
|
|
861
|
+
return "00000000-0000-0000-0000-000000000000";
|
|
862
|
+
default:
|
|
863
|
+
return "";
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private parseScalarValue(text: string, propType: string): unknown {
|
|
868
|
+
const cleaned = text.trim();
|
|
869
|
+
|
|
870
|
+
switch (propType) {
|
|
871
|
+
case "integer": {
|
|
872
|
+
const num = parseInt(cleaned, 10);
|
|
873
|
+
return Number.isNaN(num) ? 0 : num;
|
|
874
|
+
}
|
|
875
|
+
case "bigInteger": {
|
|
876
|
+
try {
|
|
877
|
+
return BigInt(cleaned);
|
|
878
|
+
} catch {
|
|
879
|
+
return 0n;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
case "float":
|
|
883
|
+
case "number":
|
|
884
|
+
case "numeric": {
|
|
885
|
+
const num = parseFloat(cleaned);
|
|
886
|
+
return Number.isNaN(num) ? 0 : num;
|
|
887
|
+
}
|
|
888
|
+
case "boolean":
|
|
889
|
+
return cleaned.toLowerCase() === "true";
|
|
890
|
+
case "date": {
|
|
891
|
+
const date = new Date(cleaned);
|
|
892
|
+
return Number.isNaN(date.getTime()) ? new Date() : date;
|
|
893
|
+
}
|
|
894
|
+
case "json":
|
|
895
|
+
try {
|
|
896
|
+
return JSON.parse(cleaned);
|
|
897
|
+
} catch {
|
|
898
|
+
return cleaned;
|
|
899
|
+
}
|
|
900
|
+
case "uuid":
|
|
901
|
+
case "enum":
|
|
902
|
+
return cleaned;
|
|
903
|
+
default:
|
|
904
|
+
return cleaned;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Sonamu.secret을 우선으로 하고, 없으면 환경변수에서 API 키를 읽습니다.
|
|
910
|
+
*
|
|
911
|
+
* Sonamu.secret은 프로젝트별 설정(sonamu.config.ts)이므로 더 높은 우선순위를 가지며,
|
|
912
|
+
* 환경변수는 개발 환경이나 CI/CD에서 fallback으로 사용됩니다.
|
|
913
|
+
*/
|
|
914
|
+
private getApiKey(): string {
|
|
915
|
+
let apiKey: string | undefined;
|
|
916
|
+
|
|
917
|
+
try {
|
|
918
|
+
const { Sonamu } = require("../api");
|
|
919
|
+
apiKey = Sonamu.secret?.anthropic_api_key;
|
|
920
|
+
} catch {
|
|
921
|
+
// Sonamu가 초기화되지 않은 경우 (테스트 환경 등)
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (!apiKey) {
|
|
925
|
+
apiKey = process.env.ANTHROPIC_API_KEY;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (!apiKey) {
|
|
929
|
+
throw new Error(
|
|
930
|
+
"ANTHROPIC_API_KEY not found. Set it in environment variables or Sonamu.secret.anthropic_api_key",
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
return apiKey;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
private getExpectedFormat(propType: string): string {
|
|
938
|
+
// 배열 타입 처리
|
|
939
|
+
if (propType.endsWith("[]")) {
|
|
940
|
+
const baseType = propType.slice(0, -2);
|
|
941
|
+
const baseFormat = this.getScalarFormat(baseType);
|
|
942
|
+
return `JSON array of ${baseFormat} (e.g., [${this.getExampleForType(baseType, "en")}, ...])`;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
return this.getScalarFormat(propType);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
private getScalarFormat(propType: string): string {
|
|
949
|
+
switch (propType) {
|
|
950
|
+
case "integer":
|
|
951
|
+
case "bigInteger":
|
|
952
|
+
return "integer numbers";
|
|
953
|
+
case "float":
|
|
954
|
+
case "number":
|
|
955
|
+
case "numeric":
|
|
956
|
+
return "decimal numbers";
|
|
957
|
+
case "boolean":
|
|
958
|
+
return "booleans (true or false)";
|
|
959
|
+
case "date":
|
|
960
|
+
return "ISO 8601 date strings";
|
|
961
|
+
case "json":
|
|
962
|
+
return "valid JSON object or array";
|
|
963
|
+
case "uuid":
|
|
964
|
+
return "UUID strings";
|
|
965
|
+
case "enum":
|
|
966
|
+
return "one of the allowed enum values";
|
|
967
|
+
default:
|
|
968
|
+
return "plain text strings";
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
private getExampleForType(propType: string, locale: Locale): string {
|
|
973
|
+
// 배열 타입 처리
|
|
974
|
+
if (propType.endsWith("[]")) {
|
|
975
|
+
const baseType = propType.slice(0, -2);
|
|
976
|
+
const baseExample = this.getScalarExample(baseType, locale);
|
|
977
|
+
return `[${baseExample}]`;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return this.getScalarExample(propType, locale);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
private getScalarExample(propType: string, locale: Locale): string {
|
|
984
|
+
const isKorean = locale === "ko";
|
|
985
|
+
|
|
986
|
+
switch (propType) {
|
|
987
|
+
case "integer":
|
|
988
|
+
case "bigInteger":
|
|
989
|
+
return "42";
|
|
990
|
+
case "float":
|
|
991
|
+
case "number":
|
|
992
|
+
case "numeric":
|
|
993
|
+
return "3.14";
|
|
994
|
+
case "boolean":
|
|
995
|
+
return "true";
|
|
996
|
+
case "date":
|
|
997
|
+
return "2024-01-01";
|
|
998
|
+
case "json":
|
|
999
|
+
return '{"key": "value"}';
|
|
1000
|
+
case "uuid":
|
|
1001
|
+
return "550e8400-e29b-41d4-a716-446655440000";
|
|
1002
|
+
case "enum":
|
|
1003
|
+
return "ENUM_VALUE";
|
|
1004
|
+
default:
|
|
1005
|
+
return isKorean ? "안녕하세요" : "Hello";
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* LLM 캐시 통계를 반환합니다.
|
|
1011
|
+
*/
|
|
1012
|
+
getLLMCacheStats() {
|
|
1013
|
+
return {
|
|
1014
|
+
size: this.llmCache.size,
|
|
1015
|
+
enabled: this.options.enableLLMCache,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* LLM 캐시를 초기화합니다.
|
|
1021
|
+
*/
|
|
1022
|
+
clearLLMCache() {
|
|
1023
|
+
this.llmCache.clear();
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* 컨텍스트 생성
|
|
1028
|
+
*/
|
|
1029
|
+
private createContext(): GeneratorContext {
|
|
1030
|
+
return {
|
|
1031
|
+
fixtures: new Map(),
|
|
1032
|
+
referenceCache: new Map(),
|
|
1033
|
+
importedRecords: new Set(),
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* 배치 생성 및 자동 저장
|
|
1039
|
+
*
|
|
1040
|
+
* 1. 각 spec별로 fixture 생성 (메모리)
|
|
1041
|
+
* 2. FixtureRecord로 변환
|
|
1042
|
+
* 3. FixtureManager.insertFixtures()로 targetDb에 저장
|
|
1043
|
+
*
|
|
1044
|
+
* @returns 저장된 fixture 데이터 (실제 DB ID 포함)
|
|
1045
|
+
*/
|
|
1046
|
+
async generateBatch(
|
|
1047
|
+
specs: Array<{ entity: string; count: number; overrides?: Record<string, unknown> }>,
|
|
1048
|
+
): Promise<FixtureImportResult[]> {
|
|
1049
|
+
const context = this.createContext();
|
|
1050
|
+
const generatedFixtures: Array<{ entity: string; data: Record<string, unknown> }> = [];
|
|
1051
|
+
|
|
1052
|
+
// 1. 각 spec별로 fixture 생성
|
|
1053
|
+
for (const spec of specs) {
|
|
1054
|
+
for (let i = 0; i < spec.count; i++) {
|
|
1055
|
+
const fixture = await this.generate(spec.entity, spec.overrides || {}, context);
|
|
1056
|
+
generatedFixtures.push({
|
|
1057
|
+
entity: spec.entity,
|
|
1058
|
+
data: fixture,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// 2. FixtureRecord로 변환
|
|
1064
|
+
const fixtureRecords: FixtureRecord[] = [];
|
|
1065
|
+
for (const { entity: entityName, data } of generatedFixtures) {
|
|
1066
|
+
const entity = this.entityManager.get(entityName);
|
|
1067
|
+
|
|
1068
|
+
// 임시 ID 생성 (targetDb에 INSERT 후 실제 ID를 받음)
|
|
1069
|
+
const tempId = Math.floor(Math.random() * 1000000);
|
|
1070
|
+
const records = await FixtureManager.createFixtureRecord(
|
|
1071
|
+
entity,
|
|
1072
|
+
{ ...data, id: tempId } as { id: number; [key: string]: string | number | boolean | null },
|
|
1073
|
+
{ singleRecord: true },
|
|
1074
|
+
);
|
|
1075
|
+
fixtureRecords.push(...records);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// 3. targetDb에 삽입 (FixtureManager가 의존성 정렬 처리)
|
|
1079
|
+
const results = await FixtureManager.insertFixtures(this.targetDbName, fixtureRecords);
|
|
1080
|
+
|
|
1081
|
+
console.log(
|
|
1082
|
+
chalk.green(`Generated and saved ${results.length} fixtures to ${this.targetDbName}`),
|
|
1083
|
+
);
|
|
1084
|
+
return results;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* 실제 DB(sourceDb)에서 데이터를 조회하여 fixture DB(targetDb)에 import합니다.
|
|
1089
|
+
*
|
|
1090
|
+
* 1. DataExplorer로 sourceDb에서 데이터 조회 (관련 데이터 포함)
|
|
1091
|
+
* 2. FixtureRecord로 변환
|
|
1092
|
+
* 3. targetDb에 삽입
|
|
1093
|
+
*
|
|
1094
|
+
* @param entityName - 조회할 entity 이름
|
|
1095
|
+
* @param options - 조회 옵션 (strategy, limit, includeRelations 등)
|
|
1096
|
+
* @returns 저장된 fixture 데이터 (실제 DB ID 포함)
|
|
1097
|
+
*
|
|
1098
|
+
* @example
|
|
1099
|
+
* // 프로덕션 DB에서 User 10명 + 관련 Employee, Department 가져오기
|
|
1100
|
+
* await generator.importFromSource("User", {
|
|
1101
|
+
* strategy: "sample",
|
|
1102
|
+
* limit: 10,
|
|
1103
|
+
* includeRelations: true,
|
|
1104
|
+
* maxDepth: 2
|
|
1105
|
+
* });
|
|
1106
|
+
*/
|
|
1107
|
+
async importFromSource(
|
|
1108
|
+
entityName: string,
|
|
1109
|
+
options: ExploreWithRelationsOptions,
|
|
1110
|
+
): Promise<FixtureImportResult[]> {
|
|
1111
|
+
console.log(
|
|
1112
|
+
chalk.blue(
|
|
1113
|
+
`Importing ${entityName} from source DB with options: ${JSON.stringify({ strategy: options.strategy, limit: options.limit, includeRelations: options.includeRelations, maxDepth: options.maxDepth })}`,
|
|
1114
|
+
),
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1117
|
+
// 1. DataExplorer로 sourceDb에서 데이터 조회 (관련 데이터 포함)
|
|
1118
|
+
const exploreResult = await this.dataExplorer.exploreWithRelations(entityName, options);
|
|
1119
|
+
|
|
1120
|
+
console.log(
|
|
1121
|
+
chalk.cyan(
|
|
1122
|
+
`Found ${exploreResult.main.records.length} ${entityName} records and ${exploreResult.related.size} related entities`,
|
|
1123
|
+
),
|
|
1124
|
+
);
|
|
1125
|
+
|
|
1126
|
+
// 2. FixtureRecord로 변환
|
|
1127
|
+
const fixtureRecords: FixtureRecord[] = [];
|
|
1128
|
+
|
|
1129
|
+
// 메인 entity의 records를 FixtureRecord로 변환
|
|
1130
|
+
const mainEntity = this.entityManager.get(entityName);
|
|
1131
|
+
for (const record of exploreResult.main.records) {
|
|
1132
|
+
const records = await FixtureManager.createFixtureRecord(
|
|
1133
|
+
mainEntity,
|
|
1134
|
+
record as { id: number | string; [key: string]: string | number | boolean | null },
|
|
1135
|
+
{ _db: this.sourceDb, singleRecord: true },
|
|
1136
|
+
);
|
|
1137
|
+
fixtureRecords.push(...records);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// 관련 entity의 records를 FixtureRecord로 변환
|
|
1141
|
+
for (const [relatedEntityName, relatedRecords] of exploreResult.related.entries()) {
|
|
1142
|
+
const relatedEntity = this.entityManager.get(relatedEntityName);
|
|
1143
|
+
for (const record of relatedRecords) {
|
|
1144
|
+
const records = await FixtureManager.createFixtureRecord(
|
|
1145
|
+
relatedEntity,
|
|
1146
|
+
record as { id: number | string; [key: string]: string | number | boolean | null },
|
|
1147
|
+
{ _db: this.sourceDb, singleRecord: true },
|
|
1148
|
+
);
|
|
1149
|
+
fixtureRecords.push(...records);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
console.log(chalk.gray(` - ${relatedEntityName}: ${relatedRecords.length} records`));
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// 3. targetDb에 삽입 (FixtureManager가 의존성 정렬 처리)
|
|
1156
|
+
const results = await FixtureManager.insertFixtures(this.targetDbName, fixtureRecords);
|
|
1157
|
+
|
|
1158
|
+
console.log(
|
|
1159
|
+
chalk.green(
|
|
1160
|
+
`Successfully imported ${results.length} records to ${this.targetDbName} (${exploreResult.main.records.length} ${entityName} + ${results.length - exploreResult.main.records.length} related)`,
|
|
1161
|
+
),
|
|
1162
|
+
);
|
|
1163
|
+
|
|
1164
|
+
return results;
|
|
1165
|
+
}
|
|
1166
|
+
}
|