@rdbms-erd/core 0.1.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/package.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@rdbms-erd/core",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ }
11
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { alignNodePositions } from "../alignment";
3
+
4
+ describe("alignNodePositions", () => {
5
+ it("aligns left to min x", () => {
6
+ const positions = { a: { x: 10, y: 0 }, b: { x: 50, y: 0 } };
7
+ const out = alignNodePositions(["a", "b"], positions, "left");
8
+ expect(out.a.x).toBe(10);
9
+ expect(out.b.x).toBe(10);
10
+ });
11
+
12
+ it("distributes horizontal gap", () => {
13
+ const positions = { a: { x: 0, y: 0 }, b: { x: 100, y: 0 }, c: { x: 200, y: 0 } };
14
+ const out = alignNodePositions(["a", "b", "c"], positions, "h-gap");
15
+ expect(out.a.x).toBe(0);
16
+ expect(out.b.x).toBe(100);
17
+ expect(out.c.x).toBe(200);
18
+ });
19
+ });
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createLargeDesign, roundTripDesign, serializeDesign } from "../index";
3
+
4
+ describe("large model (500 tables)", () => {
5
+ it("serializes and round-trips within reasonable time", () => {
6
+ const doc = createLargeDesign(500, "postgres");
7
+ const t0 = performance.now();
8
+ const json = serializeDesign(doc);
9
+ const parsed = roundTripDesign(JSON.parse(json) as typeof doc);
10
+ const ms = performance.now() - t0;
11
+ expect(parsed.model.tables).toHaveLength(500);
12
+ expect(ms).toBeLessThan(8000);
13
+ });
14
+ });
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { analyzeDdlDocument, createColumn, createEmptyDesign, formatDdlDiagnostic, generateDdlWithDiagnostics } from "../index";
3
+
4
+ describe("analyzeDdlDocument / formatDdlDiagnostic", () => {
5
+ it("formats diagnostics with unified template", () => {
6
+ const line = formatDdlDiagnostic({
7
+ severity: "warning",
8
+ code: "DDL_REL_MISSING_COLUMNS",
9
+ message: "소스/타겟 컬럼이 지정되지 않아 FK DDL을 생략한다.",
10
+ context: "relationship:r1"
11
+ });
12
+ expect(line).toBe(
13
+ "[WARN][DDL_REL_MISSING_COLUMNS] 소스/타겟 컬럼이 지정되지 않아 FK DDL을 생략한다. | relationship:r1"
14
+ );
15
+ });
16
+
17
+ it("warns on relationship without column ids", () => {
18
+ const doc = createEmptyDesign("postgres");
19
+ doc.model.tables.push({
20
+ id: "a",
21
+ logicalName: "A",
22
+ physicalName: "TA",
23
+ columns: [createColumn("postgres", { id: "c1", logicalName: "x", logicalType: "TEXT" })]
24
+ });
25
+ doc.model.tables.push({
26
+ id: "b",
27
+ logicalName: "B",
28
+ physicalName: "TB",
29
+ columns: [createColumn("postgres", { id: "c2", logicalName: "y", logicalType: "TEXT" })]
30
+ });
31
+ doc.model.relationships.push({
32
+ id: "r1",
33
+ sourceTableId: "a",
34
+ targetTableId: "b"
35
+ });
36
+ const d = analyzeDdlDocument(doc);
37
+ expect(d.some((x) => x.code === "DDL_REL_MISSING_COLUMNS")).toBe(true);
38
+ });
39
+
40
+ it("generateDdlWithDiagnostics returns sql and same analyze list", () => {
41
+ const doc = createEmptyDesign("mysql");
42
+ doc.model.tables.push({
43
+ id: "t1",
44
+ logicalName: "T",
45
+ physicalName: "TT",
46
+ columns: [createColumn("mysql", { id: "c1", logicalName: "id", logicalType: "NUMBER", nullable: false })]
47
+ });
48
+ const { sql, diagnostics } = generateDdlWithDiagnostics(doc);
49
+ expect(sql).toContain("CREATE TABLE");
50
+ expect(diagnostics.length).toBeGreaterThanOrEqual(0);
51
+ });
52
+ });
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createColumn, createEmptyDesign, generateDdl, generateIndexDdl } from "../index";
3
+
4
+ function minimalDoc(dialect: "mssql" | "oracle" | "mysql" | "postgres") {
5
+ const doc = createEmptyDesign(dialect);
6
+ doc.model.tables.push({
7
+ id: "parent",
8
+ logicalName: "부모",
9
+ physicalName: "TB_PARENT",
10
+ columns: [
11
+ createColumn(dialect, {
12
+ id: "pk",
13
+ logicalName: "식별자",
14
+ logicalType: "NUMBER",
15
+ nullable: false
16
+ })
17
+ ]
18
+ });
19
+ doc.model.tables.push({
20
+ id: "child",
21
+ logicalName: "자식",
22
+ physicalName: "TB_CHILD",
23
+ columns: [
24
+ createColumn(dialect, {
25
+ id: "fk",
26
+ logicalName: "부모참조",
27
+ logicalType: "NUMBER",
28
+ nullable: false
29
+ })
30
+ ]
31
+ });
32
+ doc.model.relationships.push({
33
+ id: "rel1",
34
+ sourceTableId: "parent",
35
+ targetTableId: "child",
36
+ sourceColumnId: "pk",
37
+ targetColumnId: "fk"
38
+ });
39
+ doc.model.indexes.push({
40
+ id: "ix1",
41
+ tableId: "child",
42
+ name: "IX_CHILD_PARENT",
43
+ columns: ["부모참조"],
44
+ unique: false
45
+ });
46
+ return doc;
47
+ }
48
+
49
+ describe("generateDdl / generateIndexDdl", () => {
50
+ it.each(["postgres", "mysql", "oracle", "mssql"] as const)("dialect %s emits CREATE TABLE and FK", (dialect) => {
51
+ const sql = generateDdl(minimalDoc(dialect));
52
+ expect(sql).toContain("CREATE TABLE");
53
+ expect(sql.toUpperCase()).toContain("FOREIGN KEY");
54
+ });
55
+
56
+ it("emits CREATE INDEX", () => {
57
+ const ix = generateIndexDdl(minimalDoc("postgres"));
58
+ expect(ix.toUpperCase()).toContain("CREATE ");
59
+ expect(ix.toUpperCase()).toContain("INDEX");
60
+ });
61
+
62
+ it("includes PK, NOT NULL, and DEFAULT in table DDL", () => {
63
+ const doc = createEmptyDesign("postgres");
64
+ doc.model.tables.push({
65
+ id: "t1",
66
+ logicalName: "사용자",
67
+ physicalName: "TB_USER",
68
+ columns: [
69
+ createColumn("postgres", {
70
+ id: "id",
71
+ logicalName: "아이디",
72
+ physicalName: "USER_ID",
73
+ logicalType: "NUMBER",
74
+ isPrimaryKey: true
75
+ }),
76
+ createColumn("postgres", {
77
+ id: "name",
78
+ logicalName: "이름",
79
+ physicalName: "USER_NAME",
80
+ logicalType: "TEXT",
81
+ nullable: false,
82
+ defaultValue: "UNKNOWN"
83
+ })
84
+ ]
85
+ });
86
+ const sql = generateDdl(doc);
87
+ expect(sql).toContain("PRIMARY KEY");
88
+ expect(sql).toContain("NOT NULL");
89
+ expect(sql).toContain("DEFAULT 'UNKNOWN'");
90
+ });
91
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createColumn,
4
+ createEmptyDesign,
5
+ isRelationshipLineRenderable,
6
+ parseDesign,
7
+ roundTripDesign,
8
+ serializeDesign,
9
+ validateDesignDocument
10
+ } from "../index";
11
+
12
+ describe("DesignDocument", () => {
13
+ it("round-trips", () => {
14
+ const doc = createEmptyDesign("mysql");
15
+ doc.model.tables.push({
16
+ id: "t1",
17
+ logicalName: "엔티티",
18
+ physicalName: "TB_T1",
19
+ columns: []
20
+ });
21
+ const again = roundTripDesign(doc);
22
+ expect(again.model.dialect).toBe("mysql");
23
+ expect(again.model.tables).toHaveLength(1);
24
+ });
25
+
26
+ it("rejects invalid dialect", () => {
27
+ const raw = JSON.parse(serializeDesign(createEmptyDesign()));
28
+ raw.model.dialect = "sqlite";
29
+ expect(() => validateDesignDocument(raw)).toThrow(/dialect/);
30
+ });
31
+
32
+ it("parseDesign validates", () => {
33
+ const json = serializeDesign(createEmptyDesign("oracle"));
34
+ expect(parseDesign(json).model.dialect).toBe("oracle");
35
+ });
36
+
37
+ it("createEmptyDesign defaults to mssql", () => {
38
+ expect(createEmptyDesign().model.dialect).toBe("mssql");
39
+ });
40
+
41
+ it("isRelationshipLineRenderable respects FK column flag and global toggle", () => {
42
+ const doc = createEmptyDesign("postgres");
43
+ doc.model.tables.push(
44
+ {
45
+ id: "a",
46
+ logicalName: "A",
47
+ physicalName: "TA",
48
+ columns: [createColumn("postgres", { id: "apk", logicalName: "id", logicalType: "NUMBER", nullable: false, isPrimaryKey: true })]
49
+ },
50
+ {
51
+ id: "b",
52
+ logicalName: "B",
53
+ physicalName: "TB",
54
+ columns: [
55
+ createColumn("postgres", {
56
+ id: "bfk",
57
+ logicalName: "aid",
58
+ logicalType: "NUMBER",
59
+ isForeignKey: true,
60
+ showFkRelationLine: false
61
+ })
62
+ ]
63
+ }
64
+ );
65
+ const rel = {
66
+ id: "r1",
67
+ sourceTableId: "a",
68
+ targetTableId: "b",
69
+ sourceColumnId: "apk",
70
+ targetColumnId: "bfk"
71
+ };
72
+ doc.model.relationships.push(rel);
73
+ expect(isRelationshipLineRenderable(rel, doc.model, false)).toBe(false);
74
+ expect(isRelationshipLineRenderable(rel, doc.model, true)).toBe(false);
75
+ doc.model.tables[1].columns[0] = { ...doc.model.tables[1].columns[0], showFkRelationLine: true };
76
+ expect(isRelationshipLineRenderable(rel, doc.model, true)).toBe(true);
77
+ });
78
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { applyLogicalTypeChange, createColumn, defaultPhysicalType } from "../index";
3
+
4
+ describe("logical / physical types", () => {
5
+ it("createColumn defaults physical name to logical name and sets default physical type", () => {
6
+ const col = createColumn("postgres", {
7
+ id: "c1",
8
+ logicalName: "이름",
9
+ logicalType: "TEXT"
10
+ });
11
+ expect(col.physicalName).toBe("이름");
12
+ expect(col.physicalType).toBe(defaultPhysicalType("postgres", "TEXT"));
13
+ });
14
+
15
+ it("createColumn uses explicit physical name when provided", () => {
16
+ const col = createColumn("postgres", {
17
+ id: "c1",
18
+ logicalName: "이름",
19
+ physicalName: "Name",
20
+ logicalType: "TEXT"
21
+ });
22
+ expect(col.physicalName).toBe("Name");
23
+ });
24
+
25
+ it("applyLogicalTypeChange resets physical to default", () => {
26
+ let col = createColumn("mssql", {
27
+ id: "c1",
28
+ logicalName: "수량",
29
+ physicalName: "Qty",
30
+ logicalType: "NUMBER"
31
+ });
32
+ col = { ...col, physicalType: "BIGINT" };
33
+ col = applyLogicalTypeChange(col, "FLOAT", "mssql");
34
+ expect(col.logicalType).toBe("FLOAT");
35
+ expect(col.physicalType).toBe(defaultPhysicalType("mssql", "FLOAT"));
36
+ });
37
+ });
@@ -0,0 +1,71 @@
1
+ export type AlignCommand =
2
+ | "left"
3
+ | "h-center"
4
+ | "right"
5
+ | "top"
6
+ | "v-center"
7
+ | "bottom"
8
+ | "h-gap"
9
+ | "v-gap";
10
+
11
+ /**
12
+ * 멀티 선택된 노드 위치를 bounding box 기준으로 정렬/분배한다.
13
+ * positions는 id -> 좌표 맵이며, 반환값은 동일 키에 갱신된 좌표만 포함한다.
14
+ */
15
+ export function alignNodePositions(
16
+ selectedIds: string[],
17
+ positions: Record<string, { x: number; y: number }>,
18
+ command: AlignCommand
19
+ ): Record<string, { x: number; y: number }> {
20
+ const entries = selectedIds
21
+ .map((id) => {
22
+ const pos = positions[id];
23
+ return pos ? { id, pos: { ...pos } } : null;
24
+ })
25
+ .filter((e): e is { id: string; pos: { x: number; y: number } } => Boolean(e));
26
+
27
+ if (entries.length < 2) {
28
+ return {};
29
+ }
30
+
31
+ const xs = entries.map((e) => e.pos.x);
32
+ const ys = entries.map((e) => e.pos.y);
33
+ const minX = Math.min(...xs);
34
+ const maxX = Math.max(...xs);
35
+ const minY = Math.min(...ys);
36
+ const maxY = Math.max(...ys);
37
+ const centerX = (minX + maxX) / 2;
38
+ const centerY = (minY + maxY) / 2;
39
+
40
+ const sortByX = [...entries].sort((a, b) => a.pos.x - b.pos.x);
41
+ const sortByY = [...entries].sort((a, b) => a.pos.y - b.pos.y);
42
+
43
+ const out: Record<string, { x: number; y: number }> = {};
44
+
45
+ for (const e of entries) {
46
+ const next = { ...e.pos };
47
+ if (command === "left") next.x = minX;
48
+ if (command === "h-center") next.x = centerX;
49
+ if (command === "right") next.x = maxX;
50
+ if (command === "top") next.y = minY;
51
+ if (command === "v-center") next.y = centerY;
52
+ if (command === "bottom") next.y = maxY;
53
+ out[e.id] = next;
54
+ }
55
+
56
+ if (command === "h-gap") {
57
+ const gap = entries.length > 1 ? (maxX - minX) / (entries.length - 1) : 0;
58
+ sortByX.forEach((e, index) => {
59
+ out[e.id] = { ...e.pos, x: minX + gap * index };
60
+ });
61
+ }
62
+
63
+ if (command === "v-gap") {
64
+ const gap = entries.length > 1 ? (maxY - minY) / (entries.length - 1) : 0;
65
+ sortByY.forEach((e, index) => {
66
+ out[e.id] = { ...e.pos, y: minY + gap * index };
67
+ });
68
+ }
69
+
70
+ return out;
71
+ }
package/src/index.ts ADDED
@@ -0,0 +1,506 @@
1
+ export type RdbmsDialect = "mssql" | "oracle" | "mysql" | "postgres";
2
+
3
+ export { alignNodePositions, type AlignCommand } from "./alignment";
4
+
5
+ export type LogicalDataType = "TEXT" | "DATE" | "DATETIME" | "NUMBER" | "FLOAT";
6
+
7
+ export interface ColumnModel {
8
+ id: string;
9
+ logicalName: string;
10
+ physicalName: string;
11
+ logicalType: LogicalDataType;
12
+ physicalType: string;
13
+ defaultValue?: string;
14
+ nullable: boolean;
15
+ isPrimaryKey?: boolean;
16
+ isForeignKey?: boolean;
17
+ referencesPrimaryColumnId?: string;
18
+ /** FK 컬럼에서만 사용. false이면 캔버스에서 해당 FK 관계선을 숨긴다(전역 선 표시가 켜져 있을 때만 적용). */
19
+ showFkRelationLine?: boolean;
20
+ /** 테이블 노드에서 해당 컬럼 행 배경색 */
21
+ color?: string;
22
+ }
23
+
24
+ export interface TableModel {
25
+ id: string;
26
+ logicalName: string;
27
+ physicalName: string;
28
+ color?: string;
29
+ columns: ColumnModel[];
30
+ }
31
+
32
+ export interface RelationshipModel {
33
+ id: string;
34
+ /** 참조 원본(PK) 테이블 */
35
+ sourceTableId: string;
36
+ /** 참조 대상(FK) 테이블 */
37
+ targetTableId: string;
38
+ /** source(PK) 컬럼 id */
39
+ sourceColumnId?: string;
40
+ /** target(FK) 컬럼 id */
41
+ targetColumnId?: string;
42
+ /** 연결 시 생성된 FK 컬럼인지 여부(삭제 시 컬럼 정리용) */
43
+ autoCreatedTargetColumn?: boolean;
44
+ /** FK가 최초로 연결된 PK 컬럼 id(컬럼명 변경과 무관) */
45
+ originPkColumnId?: string;
46
+ }
47
+
48
+ /**
49
+ * 캔버스에 관계선을 그릴지 여부(전역 표시 + FK 컬럼의 라인 표시 옵션).
50
+ */
51
+ export function isRelationshipLineRenderable(
52
+ rel: RelationshipModel,
53
+ model: DesignModel,
54
+ globalLinesVisible: boolean
55
+ ): boolean {
56
+ if (!globalLinesVisible) return false;
57
+ if (!rel.targetColumnId) return true;
58
+ const targetTable = model.tables.find((t) => t.id === rel.targetTableId);
59
+ const col = targetTable?.columns.find((c) => c.id === rel.targetColumnId);
60
+ if (!col) return true;
61
+ if (!col.isForeignKey) return true;
62
+ return col.showFkRelationLine !== false;
63
+ }
64
+
65
+ export interface IndexModel {
66
+ id: string;
67
+ tableId: string;
68
+ name: string;
69
+ columns: string[];
70
+ unique: boolean;
71
+ }
72
+
73
+ export interface DiagramLayout {
74
+ nodePositions: Record<string, { x: number; y: number }>;
75
+ }
76
+
77
+ export interface DesignModel {
78
+ dialect: RdbmsDialect;
79
+ tables: TableModel[];
80
+ relationships: RelationshipModel[];
81
+ indexes: IndexModel[];
82
+ }
83
+
84
+ export interface DesignDocument {
85
+ schemaVersion: number;
86
+ model: DesignModel;
87
+ layout: DiagramLayout;
88
+ settings?: Record<string, unknown>;
89
+ }
90
+
91
+ function isObject(value: unknown): value is Record<string, unknown> {
92
+ return typeof value === "object" && value !== null;
93
+ }
94
+
95
+ const DIALECT_DEFAULT_TYPE_MAP: Record<RdbmsDialect, Record<LogicalDataType, string>> = {
96
+ mssql: {
97
+ TEXT: "NVARCHAR(255)",
98
+ DATE: "DATE",
99
+ DATETIME: "DATETIME2",
100
+ NUMBER: "INT",
101
+ FLOAT: "FLOAT"
102
+ },
103
+ oracle: {
104
+ TEXT: "VARCHAR2(255)",
105
+ DATE: "DATE",
106
+ DATETIME: "TIMESTAMP",
107
+ NUMBER: "NUMBER(10)",
108
+ FLOAT: "BINARY_FLOAT"
109
+ },
110
+ mysql: {
111
+ TEXT: "VARCHAR(255)",
112
+ DATE: "DATE",
113
+ DATETIME: "DATETIME",
114
+ NUMBER: "INT",
115
+ FLOAT: "FLOAT"
116
+ },
117
+ postgres: {
118
+ TEXT: "VARCHAR(255)",
119
+ DATE: "DATE",
120
+ DATETIME: "TIMESTAMP",
121
+ NUMBER: "INTEGER",
122
+ FLOAT: "REAL"
123
+ }
124
+ };
125
+
126
+ export function defaultPhysicalType(dialect: RdbmsDialect, logicalType: LogicalDataType): string {
127
+ return DIALECT_DEFAULT_TYPE_MAP[dialect][logicalType];
128
+ }
129
+
130
+ export function applyLogicalTypeChange(
131
+ column: ColumnModel,
132
+ nextLogicalType: LogicalDataType,
133
+ dialect: RdbmsDialect
134
+ ): ColumnModel {
135
+ return {
136
+ ...column,
137
+ logicalType: nextLogicalType,
138
+ physicalType: defaultPhysicalType(dialect, nextLogicalType)
139
+ };
140
+ }
141
+
142
+ export function createColumn(
143
+ dialect: RdbmsDialect,
144
+ params: {
145
+ id: string;
146
+ logicalName: string;
147
+ /** 생략 시 논리 이름과 동일한 물리 이름으로 생성된다. */
148
+ physicalName?: string;
149
+ logicalType: LogicalDataType;
150
+ defaultValue?: string;
151
+ nullable?: boolean;
152
+ isPrimaryKey?: boolean;
153
+ isForeignKey?: boolean;
154
+ referencesPrimaryColumnId?: string;
155
+ showFkRelationLine?: boolean;
156
+ color?: string;
157
+ }
158
+ ): ColumnModel {
159
+ const physicalName = params.physicalName ?? params.logicalName;
160
+ const isPrimaryKey = params.isPrimaryKey ?? false;
161
+ const isForeignKey = params.isForeignKey ?? false;
162
+ const col: ColumnModel = {
163
+ id: params.id,
164
+ logicalName: params.logicalName,
165
+ physicalName,
166
+ logicalType: params.logicalType,
167
+ physicalType: defaultPhysicalType(dialect, params.logicalType),
168
+ defaultValue: params.defaultValue,
169
+ nullable: isPrimaryKey ? false : (params.nullable ?? true),
170
+ isPrimaryKey,
171
+ isForeignKey,
172
+ referencesPrimaryColumnId: params.referencesPrimaryColumnId
173
+ };
174
+ if (params.color !== undefined) col.color = params.color;
175
+ if (isForeignKey && params.showFkRelationLine === false) col.showFkRelationLine = false;
176
+ return col;
177
+ }
178
+
179
+ export function createEmptyDesign(dialect: RdbmsDialect = "mssql"): DesignDocument {
180
+ return {
181
+ schemaVersion: 1,
182
+ model: {
183
+ dialect,
184
+ tables: [],
185
+ relationships: [],
186
+ indexes: []
187
+ },
188
+ layout: {
189
+ nodePositions: {}
190
+ }
191
+ };
192
+ }
193
+
194
+ export function serializeDesign(doc: DesignDocument): string {
195
+ return JSON.stringify(doc, null, 2);
196
+ }
197
+
198
+ export function parseDesign(json: string): DesignDocument {
199
+ const parsed = JSON.parse(json) as unknown;
200
+ return validateDesignDocument(parsed);
201
+ }
202
+
203
+ export function validateDesignDocument(input: unknown): DesignDocument {
204
+ if (!isObject(input)) {
205
+ throw new Error("Invalid design document: root must be object");
206
+ }
207
+
208
+ if (input.schemaVersion !== 1) {
209
+ throw new Error("Unsupported schemaVersion");
210
+ }
211
+
212
+ if (!isObject(input.model) || !Array.isArray(input.model.tables) || !Array.isArray(input.model.relationships) || !Array.isArray(input.model.indexes)) {
213
+ throw new Error("Invalid design document: model is malformed");
214
+ }
215
+
216
+ const dialects: RdbmsDialect[] = ["mssql", "oracle", "mysql", "postgres"];
217
+ const modelDialect = (input.model as unknown as { dialect?: unknown }).dialect;
218
+ if (typeof modelDialect !== "string" || !dialects.includes(modelDialect as RdbmsDialect)) {
219
+ throw new Error("Invalid design document: model.dialect is invalid");
220
+ }
221
+
222
+ if (!isObject(input.layout) || !isObject(input.layout.nodePositions)) {
223
+ throw new Error("Invalid design document: layout is malformed");
224
+ }
225
+
226
+ return input as unknown as DesignDocument;
227
+ }
228
+
229
+ export function roundTripDesign(doc: DesignDocument): DesignDocument {
230
+ return parseDesign(serializeDesign(doc));
231
+ }
232
+
233
+ function quoteIdentifier(dialect: RdbmsDialect, identifier: string): string {
234
+ if (dialect === "mssql") return `[${identifier}]`;
235
+ if (dialect === "mysql") return `\`${identifier}\``;
236
+ return `"${identifier}"`;
237
+ }
238
+
239
+ function quoteSqlString(value: string): string {
240
+ return `'${value.replaceAll("'", "''")}'`;
241
+ }
242
+
243
+ function toDefaultExpression(dialect: RdbmsDialect, col: ColumnModel): string | null {
244
+ const raw = col.defaultValue?.trim();
245
+ if (!raw) return null;
246
+ const upper = raw.toUpperCase();
247
+ const isFunctionLike = /[()]/.test(raw) || upper === "NULL" || upper === "CURRENT_TIMESTAMP" || upper === "CURRENT_DATE";
248
+ if (isFunctionLike) return raw;
249
+ if (col.logicalType === "NUMBER" || col.logicalType === "FLOAT") return raw;
250
+ if (col.logicalType === "DATE" || col.logicalType === "DATETIME") {
251
+ if (dialect === "mssql" && upper === "NOW") return "GETDATE()";
252
+ if (upper === "NOW") return "CURRENT_TIMESTAMP";
253
+ return quoteSqlString(raw);
254
+ }
255
+ if ((raw.startsWith("'") && raw.endsWith("'")) || (raw.startsWith('"') && raw.endsWith('"'))) {
256
+ return raw;
257
+ }
258
+ return quoteSqlString(raw);
259
+ }
260
+
261
+ function joinColumnDefs(table: TableModel, dialect: RdbmsDialect): string[] {
262
+ const defs = table.columns.map((col) => {
263
+ const nullable = col.nullable ? "NULL" : "NOT NULL";
264
+ const defaultExpr = toDefaultExpression(dialect, col);
265
+ const defaultSql = defaultExpr ? ` DEFAULT ${defaultExpr}` : "";
266
+ return ` ${quoteIdentifier(dialect, col.physicalName)} ${col.physicalType}${defaultSql} ${nullable}`;
267
+ });
268
+ const pkColumns = table.columns.filter((col) => col.isPrimaryKey).map((col) => quoteIdentifier(dialect, col.physicalName));
269
+ if (pkColumns.length > 0) {
270
+ defs.push(` PRIMARY KEY (${pkColumns.join(", ")})`);
271
+ }
272
+ return defs;
273
+ }
274
+
275
+ function createTableSql(table: TableModel, dialect: RdbmsDialect): string {
276
+ const columns = joinColumnDefs(table, dialect);
277
+ return `CREATE TABLE ${quoteIdentifier(dialect, table.physicalName)} (\n${columns.join(",\n")}\n);`;
278
+ }
279
+
280
+ function createRelationshipSql(
281
+ rel: RelationshipModel,
282
+ model: DesignModel,
283
+ dialect: RdbmsDialect,
284
+ index: number
285
+ ): string | null {
286
+ const sourceTable = model.tables.find((table) => table.id === rel.sourceTableId);
287
+ const targetTable = model.tables.find((table) => table.id === rel.targetTableId);
288
+ if (!sourceTable || !targetTable || !rel.sourceColumnId || !rel.targetColumnId) {
289
+ return null;
290
+ }
291
+
292
+ const sourceColumn = sourceTable.columns.find((col) => col.id === rel.sourceColumnId);
293
+ const targetColumn = targetTable.columns.find((col) => col.id === rel.targetColumnId);
294
+ if (!sourceColumn || !targetColumn) {
295
+ return null;
296
+ }
297
+
298
+ const fkName = `FK_${targetTable.physicalName}_${sourceTable.physicalName}_${index + 1}`;
299
+ return [
300
+ `ALTER TABLE ${quoteIdentifier(dialect, targetTable.physicalName)}`,
301
+ ` ADD CONSTRAINT ${quoteIdentifier(dialect, fkName)}`,
302
+ ` FOREIGN KEY (${quoteIdentifier(dialect, targetColumn.physicalName)})`,
303
+ ` REFERENCES ${quoteIdentifier(dialect, sourceTable.physicalName)} (${quoteIdentifier(dialect, sourceColumn.physicalName)});`
304
+ ].join("\n");
305
+ }
306
+
307
+ function createIndexSql(indexModel: IndexModel, model: DesignModel, dialect: RdbmsDialect): string | null {
308
+ const table = model.tables.find((item) => item.id === indexModel.tableId);
309
+ if (!table || indexModel.columns.length === 0) {
310
+ return null;
311
+ }
312
+ const unique = indexModel.unique ? "UNIQUE " : "";
313
+ const columns = indexModel.columns.map((col) => quoteIdentifier(dialect, col)).join(", ");
314
+ return `CREATE ${unique}INDEX ${quoteIdentifier(dialect, indexModel.name)} ON ${quoteIdentifier(dialect, table.physicalName)} (${columns});`;
315
+ }
316
+
317
+ function buildDdlSql(doc: DesignDocument): string {
318
+ const { model } = doc;
319
+ const tableSql = model.tables.map((table) => createTableSql(table, model.dialect));
320
+ const relSql = model.relationships
321
+ .map((rel, index) => createRelationshipSql(rel, model, model.dialect, index))
322
+ .filter((item): item is string => Boolean(item));
323
+ return [...tableSql, ...relSql].join("\n\n");
324
+ }
325
+
326
+ function buildIndexDdlSql(doc: DesignDocument): string {
327
+ const { model } = doc;
328
+ const statements = model.indexes
329
+ .map((index) => createIndexSql(index, model, model.dialect))
330
+ .filter((item): item is string => Boolean(item));
331
+ return statements.join("\n\n");
332
+ }
333
+
334
+ /** DDL/인덱스 분석 공통 심각도 */
335
+ export type DdlDiagnosticSeverity = "error" | "warning";
336
+
337
+ /** 구조화된 DDL 관련 진단(에러·경고) */
338
+ export interface DdlDiagnostic {
339
+ severity: DdlDiagnosticSeverity;
340
+ code: string;
341
+ message: string;
342
+ /** 예: relationship:rel1, index:ix1, table:t1 */
343
+ context?: string;
344
+ }
345
+
346
+ /**
347
+ * 통일된 한 줄 텍스트 포맷.
348
+ * 예: `[WARN][DDL_REL_MISSING_COLUMNS] ... | relationship:rel1`
349
+ */
350
+ export function formatDdlDiagnostic(d: DdlDiagnostic): string {
351
+ const level = d.severity === "error" ? "ERROR" : "WARN";
352
+ const tail = d.context ? ` | ${d.context}` : "";
353
+ return `[${level}][${d.code}] ${d.message}${tail}`;
354
+ }
355
+
356
+ export function formatDdlDiagnostics(diagnostics: DdlDiagnostic[]): string {
357
+ return diagnostics.map(formatDdlDiagnostic).join("\n");
358
+ }
359
+
360
+ /**
361
+ * 모델 전체에 대한 DDL·인덱스 관련 진단(테이블/관계/인덱스).
362
+ * 검증 예외(Invalid design document)와는 별도로, 생성 가능 여부와 경고를 나열한다.
363
+ */
364
+ export function analyzeDdlDocument(doc: DesignDocument): DdlDiagnostic[] {
365
+ const out: DdlDiagnostic[] = [];
366
+ const { model } = doc;
367
+ const tableById = new Map(model.tables.map((t) => [t.id, t]));
368
+ const physicalTableNames = new Map<string, string[]>();
369
+
370
+ for (const table of model.tables) {
371
+ const list = physicalTableNames.get(table.physicalName) ?? [];
372
+ list.push(table.id);
373
+ physicalTableNames.set(table.physicalName, list);
374
+ if (table.columns.length === 0) {
375
+ out.push({
376
+ severity: "warning",
377
+ code: "DDL_EMPTY_TABLE",
378
+ message: "컬럼이 없는 테이블은 CREATE TABLE 구문이 비어 있거나 무의미할 수 있다.",
379
+ context: `table:${table.id}`
380
+ });
381
+ }
382
+ }
383
+
384
+ for (const [name, ids] of physicalTableNames) {
385
+ if (ids.length > 1) {
386
+ out.push({
387
+ severity: "warning",
388
+ code: "DDL_DUPLICATE_TABLE_NAME",
389
+ message: `동일한 물리 테이블명 "${name}"이 ${ids.length}개 테이블에 사용되었다.`,
390
+ context: `tables:${ids.join(",")}`
391
+ });
392
+ }
393
+ }
394
+
395
+ for (const rel of model.relationships) {
396
+ const ctx = `relationship:${rel.id}`;
397
+ const sourceTable = tableById.get(rel.sourceTableId);
398
+ const targetTable = tableById.get(rel.targetTableId);
399
+ if (!sourceTable || !targetTable) {
400
+ out.push({
401
+ severity: "error",
402
+ code: "DDL_REL_UNKNOWN_TABLE",
403
+ message: "관계의 소스 또는 타겟 테이블을 찾을 수 없어 FK DDL을 생성할 수 없다.",
404
+ context: ctx
405
+ });
406
+ continue;
407
+ }
408
+ if (!rel.sourceColumnId || !rel.targetColumnId) {
409
+ out.push({
410
+ severity: "warning",
411
+ code: "DDL_REL_MISSING_COLUMNS",
412
+ message: "소스/타겟 컬럼이 지정되지 않아 FK DDL을 생략한다.",
413
+ context: ctx
414
+ });
415
+ continue;
416
+ }
417
+ const sourceColumn = sourceTable.columns.find((c) => c.id === rel.sourceColumnId);
418
+ const targetColumn = targetTable.columns.find((c) => c.id === rel.targetColumnId);
419
+ if (!sourceColumn || !targetColumn) {
420
+ out.push({
421
+ severity: "warning",
422
+ code: "DDL_REL_UNKNOWN_COLUMN",
423
+ message: "관계에 지정된 컬럼 id를 테이블에서 찾을 수 없어 FK DDL을 생략한다.",
424
+ context: ctx
425
+ });
426
+ }
427
+ }
428
+
429
+ for (const indexModel of model.indexes) {
430
+ const ctx = `index:${indexModel.id}`;
431
+ const table = tableById.get(indexModel.tableId);
432
+ if (!table) {
433
+ out.push({
434
+ severity: "warning",
435
+ code: "IDX_UNKNOWN_TABLE",
436
+ message: "인덱스가 가리키는 테이블을 찾을 수 없어 인덱스 DDL을 생략한다.",
437
+ context: ctx
438
+ });
439
+ continue;
440
+ }
441
+ if (indexModel.columns.length === 0) {
442
+ out.push({
443
+ severity: "warning",
444
+ code: "IDX_EMPTY_COLUMNS",
445
+ message: "인덱스 컬럼 목록이 비어 있어 인덱스 DDL을 생략한다.",
446
+ context: ctx
447
+ });
448
+ continue;
449
+ }
450
+ for (const colPhys of indexModel.columns) {
451
+ if (!table.columns.some((c) => c.physicalName === colPhys)) {
452
+ out.push({
453
+ severity: "warning",
454
+ code: "IDX_UNKNOWN_COLUMN",
455
+ message: `인덱스가 참조하는 물리 컬럼명 "${colPhys}"을(를) 테이블 "${table.physicalName}"에서 찾을 수 없다.`,
456
+ context: ctx
457
+ });
458
+ }
459
+ }
460
+ }
461
+
462
+ return out;
463
+ }
464
+
465
+ export function generateDdlWithDiagnostics(doc: DesignDocument): { sql: string; diagnostics: DdlDiagnostic[] } {
466
+ return { sql: buildDdlSql(doc), diagnostics: analyzeDdlDocument(doc) };
467
+ }
468
+
469
+ export function generateIndexDdlWithDiagnostics(doc: DesignDocument): { sql: string; diagnostics: DdlDiagnostic[] } {
470
+ return { sql: buildIndexDdlSql(doc), diagnostics: analyzeDdlDocument(doc) };
471
+ }
472
+
473
+ export function generateDdl(doc: DesignDocument): string {
474
+ return buildDdlSql(doc);
475
+ }
476
+
477
+ export function generateIndexDdl(doc: DesignDocument): string {
478
+ return buildIndexDdlSql(doc);
479
+ }
480
+
481
+ /** 성능·부하 테스트용: 지정 개수의 빈 테이블과 격자 레이아웃을 생성한다. */
482
+ export function createLargeDesign(tableCount: number, dialect: RdbmsDialect = "postgres"): DesignDocument {
483
+ const doc = createEmptyDesign(dialect);
484
+ const cols = 20;
485
+ for (let i = 0; i < tableCount; i++) {
486
+ const id = `t-${i}`;
487
+ doc.model.tables.push({
488
+ id,
489
+ logicalName: `엔티티${i}`,
490
+ physicalName: `TB_T${i}`,
491
+ columns: [
492
+ createColumn(dialect, {
493
+ id: `${id}-pk`,
494
+ logicalName: "식별자",
495
+ logicalType: "NUMBER",
496
+ nullable: false
497
+ })
498
+ ]
499
+ });
500
+ doc.layout.nodePositions[id] = {
501
+ x: (i % cols) * 220,
502
+ y: Math.floor(i / cols) * 120
503
+ };
504
+ }
505
+ return doc;
506
+ }