@rdbms-erd/core 0.1.2 → 0.1.3

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/src/index.ts CHANGED
@@ -1,1272 +1,1386 @@
1
- export type BuiltinRdbmsDialect =
2
- | "mssql"
3
- | "oracle"
4
- | "mysql"
5
- | "postgres"
6
- | "sqlite";
7
- export type RdbmsDialect = BuiltinRdbmsDialect | (string & {});
8
-
9
- export { alignNodePositions, type AlignCommand } from "./alignment";
10
-
11
- export const LOGICAL_DATA_TYPES = [
12
- "TEXT",
13
- "DATE",
14
- "TIME",
15
- "DATETIME",
16
- "NUMBER",
17
- "DECIMAL",
18
- "FLOAT",
19
- "BOOLEAN",
20
- "JSON",
21
- "UUID",
22
- "BINARY",
23
- ] as const;
24
-
25
- export type LogicalDataType = (typeof LOGICAL_DATA_TYPES)[number];
26
-
27
- export interface ColumnModel {
28
- id: string;
29
- logicalName: string;
30
- physicalName: string;
31
- /** 컬럼 설명(업무/도메인 메모). */
32
- description?: string;
33
- logicalType: LogicalDataType;
34
- physicalType: string;
35
- defaultValue?: string;
36
- nullable: boolean;
37
- isPrimaryKey?: boolean;
38
- isForeignKey?: boolean;
39
- referencesPrimaryColumnId?: string;
40
- /** 테이블 노드에서 해당 컬럼 행 배경색 */
41
- color?: string;
42
- }
43
-
44
- export interface TableModel {
45
- id: string;
46
- logicalName: string;
47
- physicalName: string;
48
- /** 테이블 설명(업무/도메인 메모). */
49
- description?: string;
50
- /** Dialect-dependent schema/catalog qualifier (e.g. `public`, `dbo`). */
51
- schemaName?: string;
52
- color?: string;
53
- columns: ColumnModel[];
54
- }
55
-
56
- export interface RelationshipModel {
57
- id: string;
58
- /** 참조 원본(PK) 테이블 */
59
- sourceTableId: string;
60
- /** 참조 대상(FK) 테이블 */
61
- targetTableId: string;
62
- /** source(PK) 컬럼 id */
63
- sourceColumnId?: string;
64
- /** target(FK) 컬럼 id */
65
- targetColumnId?: string;
66
- /** 연결 시 생성된 FK 컬럼인지 여부(삭제 시 컬럼 정리용) */
67
- autoCreatedTargetColumn?: boolean;
68
- /** FK가 최초로 연결된 PK 컬럼 id(컬럼명 변경과 무관) */
69
- originPkColumnId?: string;
70
- /** 타깃 cardinality. 기본 1:N */
71
- cardinality?: "1:1" | "1:N";
72
- /** 출발(소스) 엣지의 테이블 내부 절대 Y 좌표(px). */
73
- sourceLineY?: number;
74
- /** @deprecated legacy ratio(0~1). sourceLineY가 없을 때만 fallback으로 사용 */
75
- sourceLineRatio?: number;
76
- /** 관계선의 중간 꺾임 위치 비율(0~1). 두 테이블 앵커 사이의 비율로 저장한다. */
77
- linePivotRatio?: number;
78
- /** true이면 캔버스에서 해당 관계선을 기본적으로 숨긴다. 툴의「숨긴 관계선 보기」로만 표시 가능. */
79
- canvasLineHidden?: boolean;
80
- }
81
-
82
- /**
83
- * 캔버스에 관계선을 그릴지 여부.
84
- * - `revealHiddenLines === false`: `canvasLineHidden`인 관계는 제외.
85
- * - `revealHiddenLines === true`: 숨김 처리된 관계도 함께 그린다(표시만, 속성은 바꾸지 않음).
86
- */
87
- export function isRelationshipLineRenderable(
88
- rel: RelationshipModel,
89
- revealHiddenLines: boolean,
90
- ): boolean {
91
- if (rel.canvasLineHidden === true) return revealHiddenLines;
92
- return true;
93
- }
94
-
95
- /** 구버전 `ColumnModel.showFkRelationLine === false`를 관계의 `canvasLineHidden`으로 옮긴 뒤 컬럼 플래그를 제거한다. */
96
- export function migrateLegacyFkLineVisibility(model: DesignModel): void {
97
- for (const table of model.tables) {
98
- for (const col of table.columns) {
99
- const legacy = (col as { showFkRelationLine?: boolean })
100
- .showFkRelationLine;
101
- if (legacy === false && col.isForeignKey) {
102
- for (const rel of model.relationships) {
103
- if (
104
- rel.targetTableId === table.id &&
105
- rel.targetColumnId === col.id
106
- ) {
107
- rel.canvasLineHidden = true;
108
- }
109
- }
110
- }
111
- if ("showFkRelationLine" in col) {
112
- delete (col as { showFkRelationLine?: boolean })
113
- .showFkRelationLine;
114
- }
115
- }
116
- }
117
- }
118
-
119
- export interface IndexModel {
120
- id: string;
121
- tableId: string;
122
- name: string;
123
- columns: string[];
124
- unique: boolean;
125
- }
126
-
127
- export interface DiagramLayout {
128
- nodePositions: Record<string, { x: number; y: number }>;
129
- }
130
-
131
- export interface DesignModel {
132
- dialect: RdbmsDialect;
133
- tables: TableModel[];
134
- relationships: RelationshipModel[];
135
- indexes: IndexModel[];
136
- }
137
-
138
- export interface DesignDocument {
139
- schemaVersion: number;
140
- model: DesignModel;
141
- layout: DiagramLayout;
142
- settings?: Record<string, unknown>;
143
- }
144
-
145
- export interface DialectCapability {
146
- supportsSchema: boolean;
147
- }
148
-
149
- export type DdlStyleQuote = "double" | "backtick" | "bracket";
150
- export type DdlStyleBooleanLiteral = "trueFalse" | "oneZero";
151
-
152
- export interface DdlStyle {
153
- quote: DdlStyleQuote;
154
- boolLiteral?: DdlStyleBooleanLiteral;
155
- nowKeyword?: string;
156
- }
157
-
158
- export interface LogicalTypeMeta {
159
- id: LogicalDataType;
160
- label?: string;
161
- defaultPhysicalType: string;
162
- }
163
-
164
- export interface DialectMetaJson {
165
- id: RdbmsDialect;
166
- label: string;
167
- supportsSchema: boolean;
168
- logicalTypes: LogicalTypeMeta[];
169
- ddlStyle?: DdlStyle;
170
- }
171
-
172
- export interface DialectMeta {
173
- id: RdbmsDialect;
174
- label: string;
175
- capabilities: DialectCapability;
176
- logicalTypes: readonly LogicalDataType[];
177
- defaultPhysicalTypeMap: Partial<Record<LogicalDataType, string>>;
178
- }
179
-
180
- export type DdlScope =
181
- | { kind: "all" }
182
- | { kind: "selected"; tableIds: string[] };
183
-
184
- export interface DdlGenerateInput {
185
- doc: DesignDocument;
186
- dialectId: RdbmsDialect;
187
- scope: DdlScope;
188
- }
189
-
190
- export interface DdlGenerateOutput {
191
- sql: string;
192
- diagnostics?: DdlDiagnostic[];
193
- }
194
-
195
- export type DdlGeneratorHook = (
196
- input: DdlGenerateInput,
197
- ) => DdlGenerateOutput | Promise<DdlGenerateOutput>;
198
-
199
- export interface DdlGeneratorRules {
200
- quoteIdentifier?: (dialect: RdbmsDialect, identifier: string) => string;
201
- toDefaultExpression?: (
202
- dialect: RdbmsDialect,
203
- col: ColumnModel,
204
- ) => string | null;
205
- }
206
-
207
- export interface DbMetaAdapter {
208
- listDialects: () => DialectMeta[];
209
- getDialectMeta: (dialect: RdbmsDialect) => DialectMeta | undefined;
210
- getDefaultPhysicalType: (
211
- dialect: RdbmsDialect,
212
- logicalType: LogicalDataType,
213
- ) => string;
214
- getDdlRules: (dialect: RdbmsDialect) => DdlGeneratorRules;
215
- }
216
-
217
- export interface CoreDbMetaOptions {
218
- dbMetaAdapter?: DbMetaAdapter;
219
- hostMetas?: DialectMetaJson[];
220
- hostDdlGenerators?: Record<string, DdlGeneratorHook>;
221
- fallbackOnHookError?: boolean;
222
- }
223
-
224
- function isObject(value: unknown): value is Record<string, unknown> {
225
- return typeof value === "object" && value !== null;
226
- }
227
-
228
- export const BUILTIN_DIALECTS: readonly BuiltinRdbmsDialect[] = [
229
- "mssql",
230
- "oracle",
231
- "mysql",
232
- "postgres",
233
- "sqlite",
234
- ] as const;
235
-
236
- const DIALECT_DEFAULT_TYPE_MAP: Record<
237
- BuiltinRdbmsDialect,
238
- Record<LogicalDataType, string>
239
- > = {
240
- mssql: {
241
- TEXT: "NVARCHAR(255)",
242
- DATE: "DATE",
243
- TIME: "TIME",
244
- DATETIME: "DATETIME2",
245
- NUMBER: "INT",
246
- DECIMAL: "DECIMAL(10,2)",
247
- FLOAT: "FLOAT",
248
- BOOLEAN: "BIT",
249
- JSON: "NVARCHAR(MAX)",
250
- UUID: "UNIQUEIDENTIFIER",
251
- BINARY: "VARBINARY(255)",
252
- },
253
- oracle: {
254
- TEXT: "VARCHAR2(255)",
255
- DATE: "DATE",
256
- TIME: "TIMESTAMP",
257
- DATETIME: "TIMESTAMP",
258
- NUMBER: "NUMBER(10)",
259
- DECIMAL: "NUMBER(10,2)",
260
- FLOAT: "BINARY_FLOAT",
261
- BOOLEAN: "NUMBER(1)",
262
- JSON: "CLOB",
263
- UUID: "RAW(16)",
264
- BINARY: "RAW(255)",
265
- },
266
- mysql: {
267
- TEXT: "VARCHAR(255)",
268
- DATE: "DATE",
269
- TIME: "TIME",
270
- DATETIME: "DATETIME",
271
- NUMBER: "INT",
272
- DECIMAL: "DECIMAL(10,2)",
273
- FLOAT: "FLOAT",
274
- BOOLEAN: "BOOLEAN",
275
- JSON: "JSON",
276
- UUID: "CHAR(36)",
277
- BINARY: "VARBINARY(255)",
278
- },
279
- postgres: {
280
- TEXT: "VARCHAR(255)",
281
- DATE: "DATE",
282
- TIME: "TIME",
283
- DATETIME: "TIMESTAMP",
284
- NUMBER: "INTEGER",
285
- DECIMAL: "NUMERIC(10,2)",
286
- FLOAT: "REAL",
287
- BOOLEAN: "BOOLEAN",
288
- JSON: "JSONB",
289
- UUID: "UUID",
290
- BINARY: "BYTEA",
291
- },
292
- sqlite: {
293
- TEXT: "TEXT",
294
- DATE: "TEXT",
295
- TIME: "TEXT",
296
- DATETIME: "TEXT",
297
- NUMBER: "INTEGER",
298
- DECIMAL: "NUMERIC(10,2)",
299
- FLOAT: "REAL",
300
- BOOLEAN: "INTEGER",
301
- JSON: "TEXT",
302
- UUID: "TEXT",
303
- BINARY: "BLOB",
304
- },
305
- };
306
-
307
- const DIALECT_CAPABILITIES: Record<
308
- BuiltinRdbmsDialect,
309
- { supportsSchema: boolean }
310
- > = {
311
- mssql: { supportsSchema: true },
312
- oracle: { supportsSchema: true },
313
- mysql: { supportsSchema: true },
314
- postgres: { supportsSchema: true },
315
- sqlite: { supportsSchema: false },
316
- };
317
-
318
- const DIALECT_LABELS: Record<BuiltinRdbmsDialect, string> = {
319
- mssql: "MS SQL Server",
320
- oracle: "Oracle",
321
- mysql: "MySQL",
322
- postgres: "PostgreSQL",
323
- sqlite: "SQLite",
324
- };
325
-
326
- const DIALECT_DDL_STYLE: Record<BuiltinRdbmsDialect, DdlStyle> = {
327
- mssql: {
328
- quote: "bracket",
329
- boolLiteral: "oneZero",
330
- nowKeyword: "GETDATE()",
331
- },
332
- oracle: {
333
- quote: "double",
334
- boolLiteral: "oneZero",
335
- nowKeyword: "CURRENT_TIMESTAMP",
336
- },
337
- mysql: {
338
- quote: "backtick",
339
- boolLiteral: "oneZero",
340
- nowKeyword: "CURRENT_TIMESTAMP",
341
- },
342
- postgres: {
343
- quote: "double",
344
- boolLiteral: "trueFalse",
345
- nowKeyword: "CURRENT_TIMESTAMP",
346
- },
347
- sqlite: {
348
- quote: "double",
349
- boolLiteral: "oneZero",
350
- nowKeyword: "CURRENT_TIMESTAMP",
351
- },
352
- };
353
-
354
- function toLogicalTypeMetas(dialect: BuiltinRdbmsDialect): LogicalTypeMeta[] {
355
- return LOGICAL_DATA_TYPES.map((id) => ({
356
- id,
357
- defaultPhysicalType: DIALECT_DEFAULT_TYPE_MAP[dialect][id],
358
- }));
359
- }
360
-
361
- export const BUILTIN_DIALECT_METAS_JSON: DialectMetaJson[] =
362
- BUILTIN_DIALECTS.map((dialect) => ({
363
- id: dialect,
364
- label: DIALECT_LABELS[dialect],
365
- supportsSchema: DIALECT_CAPABILITIES[dialect].supportsSchema,
366
- logicalTypes: toLogicalTypeMetas(dialect),
367
- ddlStyle: DIALECT_DDL_STYLE[dialect],
368
- }));
369
-
370
- function defaultDdlRules(): DdlGeneratorRules {
371
- return {
372
- quoteIdentifier: (dialect, identifier) => {
373
- if (dialect === "mssql") return `[${identifier}]`;
374
- if (dialect === "mysql") return `\`${identifier}\``;
375
- return `"${identifier}"`;
376
- },
377
- toDefaultExpression: (dialect, col) => {
378
- const raw = col.defaultValue?.trim();
379
- if (!raw) return null;
380
- const upper = raw.toUpperCase();
381
- const isFunctionLike =
382
- /[()]/.test(raw) ||
383
- upper === "NULL" ||
384
- upper === "CURRENT_TIMESTAMP" ||
385
- upper === "CURRENT_DATE";
386
- if (isFunctionLike) return raw;
387
- if (
388
- col.logicalType === "NUMBER" ||
389
- col.logicalType === "DECIMAL" ||
390
- col.logicalType === "FLOAT"
391
- )
392
- return raw;
393
- if (col.logicalType === "BOOLEAN") {
394
- if (upper === "TRUE" || upper === "FALSE") {
395
- if (
396
- dialect === "mssql" ||
397
- dialect === "oracle" ||
398
- dialect === "sqlite"
399
- )
400
- return upper === "TRUE" ? "1" : "0";
401
- return upper;
402
- }
403
- if (raw === "1" || raw === "0") return raw;
404
- return quoteSqlString(raw);
405
- }
406
- if (col.logicalType === "DATE" || col.logicalType === "DATETIME") {
407
- if (dialect === "mssql" && upper === "NOW") return "GETDATE()";
408
- if (upper === "NOW") return "CURRENT_TIMESTAMP";
409
- return quoteSqlString(raw);
410
- }
411
- if (
412
- (raw.startsWith("'") && raw.endsWith("'")) ||
413
- (raw.startsWith('"') && raw.endsWith('"'))
414
- )
415
- return raw;
416
- return quoteSqlString(raw);
417
- },
418
- };
419
- }
420
-
421
- function toAdapterDialectMeta(meta: DialectMetaJson): DialectMeta {
422
- const typeMap: Partial<Record<LogicalDataType, string>> = {};
423
- for (const lt of meta.logicalTypes) typeMap[lt.id] = lt.defaultPhysicalType;
424
- return {
425
- id: meta.id,
426
- label: meta.label,
427
- capabilities: { supportsSchema: meta.supportsSchema },
428
- logicalTypes: meta.logicalTypes.map((lt) => lt.id),
429
- defaultPhysicalTypeMap: typeMap,
430
- };
431
- }
432
-
433
- export function mergeDialectMetas(
434
- base: DialectMetaJson[],
435
- host: DialectMetaJson[] = [],
436
- ): DialectMetaJson[] {
437
- const byId = new Map<string, DialectMetaJson>(
438
- base.map((meta) => [meta.id, meta]),
439
- );
440
- for (const meta of host) byId.set(meta.id, meta);
441
- return Array.from(byId.values());
442
- }
443
-
444
- export function resolveDialectMetas(
445
- options?: CoreDbMetaOptions,
446
- ): DialectMetaJson[] {
447
- return mergeDialectMetas(
448
- BUILTIN_DIALECT_METAS_JSON,
449
- options?.hostMetas ?? [],
450
- );
451
- }
452
-
453
- function buildDbMetaAdapterFromMetas(metas: DialectMetaJson[]): DbMetaAdapter {
454
- const byId = new Map<RdbmsDialect, DialectMeta>(
455
- metas.map((meta) => [meta.id, toAdapterDialectMeta(meta)]),
456
- );
457
- const defaultRules = defaultDdlRules();
458
- return {
459
- listDialects: () => Array.from(byId.values()),
460
- getDialectMeta: (dialect) => byId.get(dialect),
461
- getDefaultPhysicalType: (dialect, logicalType) => {
462
- const mapped =
463
- byId.get(dialect)?.defaultPhysicalTypeMap[logicalType];
464
- return mapped ?? DIALECT_DEFAULT_TYPE_MAP.postgres[logicalType];
465
- },
466
- getDdlRules: (dialect) => {
467
- const json = metas.find((m) => m.id === dialect);
468
- const style = json?.ddlStyle;
469
- if (!style) return defaultRules;
470
- const quoteIdentifier: DdlGeneratorRules["quoteIdentifier"] = (
471
- _d,
472
- identifier,
473
- ) => {
474
- if (style.quote === "bracket") return `[${identifier}]`;
475
- if (style.quote === "backtick") return `\`${identifier}\``;
476
- return `"${identifier}"`;
477
- };
478
- const toDefaultExpression: DdlGeneratorRules["toDefaultExpression"] =
479
- (_d, col) => {
480
- const raw = col.defaultValue?.trim();
481
- if (!raw) return null;
482
- const upper = raw.toUpperCase();
483
- const isFunctionLike =
484
- /[()]/.test(raw) ||
485
- upper === "NULL" ||
486
- upper === "CURRENT_TIMESTAMP" ||
487
- upper === "CURRENT_DATE";
488
- if (isFunctionLike) return raw;
489
- if (
490
- col.logicalType === "NUMBER" ||
491
- col.logicalType === "DECIMAL" ||
492
- col.logicalType === "FLOAT"
493
- )
494
- return raw;
495
- if (col.logicalType === "BOOLEAN") {
496
- if (upper === "TRUE" || upper === "FALSE") {
497
- if ((style.boolLiteral ?? "oneZero") === "oneZero")
498
- return upper === "TRUE" ? "1" : "0";
499
- return upper;
500
- }
501
- if (raw === "1" || raw === "0") return raw;
502
- return quoteSqlString(raw);
503
- }
504
- if (
505
- col.logicalType === "DATE" ||
506
- col.logicalType === "DATETIME"
507
- ) {
508
- if (upper === "NOW")
509
- return style.nowKeyword ?? "CURRENT_TIMESTAMP";
510
- return quoteSqlString(raw);
511
- }
512
- if (
513
- (raw.startsWith("'") && raw.endsWith("'")) ||
514
- (raw.startsWith('"') && raw.endsWith('"'))
515
- )
516
- return raw;
517
- return quoteSqlString(raw);
518
- };
519
- return { quoteIdentifier, toDefaultExpression };
520
- },
521
- };
522
- }
523
-
524
- export function createDefaultDbMetaAdapter(
525
- overrides?: Partial<DbMetaAdapter>,
526
- ): DbMetaAdapter {
527
- const base = buildDbMetaAdapterFromMetas(BUILTIN_DIALECT_METAS_JSON);
528
- return { ...base, ...overrides };
529
- }
530
-
531
- export const defaultDbMetaAdapter = createDefaultDbMetaAdapter();
532
-
533
- function resolveDbMetaAdapter(options?: CoreDbMetaOptions): DbMetaAdapter {
534
- if (options?.dbMetaAdapter) return options.dbMetaAdapter;
535
- if (options?.hostMetas && options.hostMetas.length > 0) {
536
- return buildDbMetaAdapterFromMetas(resolveDialectMetas(options));
537
- }
538
- return defaultDbMetaAdapter;
539
- }
540
-
541
- export function defaultPhysicalType(
542
- dialect: RdbmsDialect,
543
- logicalType: LogicalDataType,
544
- options?: CoreDbMetaOptions,
545
- ): string {
546
- return resolveDbMetaAdapter(options).getDefaultPhysicalType(
547
- dialect,
548
- logicalType,
549
- );
550
- }
551
-
552
- export function getRdbmsDialectCapability(
553
- dialect: RdbmsDialect,
554
- options?: CoreDbMetaOptions,
555
- ): DialectCapability {
556
- const meta = resolveDbMetaAdapter(options).getDialectMeta(dialect);
557
- return meta?.capabilities ?? { supportsSchema: false };
558
- }
559
-
560
- export function dialectSupportsSchema(
561
- dialect: RdbmsDialect,
562
- options?: CoreDbMetaOptions,
563
- ): boolean {
564
- return getRdbmsDialectCapability(dialect, options).supportsSchema;
565
- }
566
-
567
- function parseTypeArguments(physicalType: string): number[] | null {
568
- const m = physicalType.match(/\(([^)]+)\)/);
569
- if (!m) return null;
570
- const values = m[1]
571
- .split(",")
572
- .map((v) => Number.parseInt(v.trim(), 10))
573
- .filter((v) => Number.isFinite(v));
574
- return values.length > 0 ? values : null;
575
- }
576
-
577
- function convertPhysicalTypeByLogicalType(
578
- physicalType: string,
579
- logicalType: LogicalDataType,
580
- nextDialect: RdbmsDialect,
581
- options?: CoreDbMetaOptions,
582
- ): string {
583
- const args = parseTypeArguments(physicalType);
584
- const precision = args?.[0];
585
- const scale = args?.[1];
586
- const length = args?.[0];
587
-
588
- if (logicalType === "NUMBER") {
589
- if (nextDialect === "oracle") {
590
- if (precision && scale !== undefined)
591
- return `NUMBER(${precision},${scale})`;
592
- if (precision) return `NUMBER(${precision})`;
593
- return "NUMBER(10)";
594
- }
595
- if (nextDialect === "mssql") {
596
- if (precision && scale !== undefined)
597
- return `NUMERIC(${precision},${scale})`;
598
- if (precision) return `NUMERIC(${precision},0)`;
599
- return "INT";
600
- }
601
- if (nextDialect === "mysql") {
602
- if (precision && scale !== undefined)
603
- return `DECIMAL(${precision},${scale})`;
604
- if (precision) return `DECIMAL(${precision},0)`;
605
- return "INT";
606
- }
607
- if (nextDialect === "postgres") {
608
- if (precision && scale !== undefined)
609
- return `NUMERIC(${precision},${scale})`;
610
- if (precision) return `NUMERIC(${precision},0)`;
611
- return "INTEGER";
612
- }
613
- return "INTEGER";
614
- }
615
-
616
- if (logicalType === "DECIMAL") {
617
- if (precision && scale !== undefined) {
618
- if (nextDialect === "oracle")
619
- return `NUMBER(${precision},${scale})`;
620
- if (nextDialect === "postgres")
621
- return `NUMERIC(${precision},${scale})`;
622
- return `DECIMAL(${precision},${scale})`;
623
- }
624
- return defaultPhysicalType(nextDialect, logicalType, options);
625
- }
626
-
627
- if (logicalType === "FLOAT") {
628
- if (nextDialect === "oracle") return "BINARY_FLOAT";
629
- if (nextDialect === "sqlite") return "REAL";
630
- return "FLOAT";
631
- }
632
-
633
- if (logicalType === "TEXT") {
634
- if (nextDialect === "oracle")
635
- return length ? `VARCHAR2(${length})` : "VARCHAR2(255)";
636
- if (nextDialect === "mssql")
637
- return length ? `NVARCHAR(${length})` : "NVARCHAR(255)";
638
- if (nextDialect === "mysql")
639
- return length ? `VARCHAR(${length})` : "VARCHAR(255)";
640
- if (nextDialect === "postgres")
641
- return length ? `VARCHAR(${length})` : "VARCHAR(255)";
642
- return "TEXT";
643
- }
644
-
645
- if (logicalType === "BINARY") {
646
- if (nextDialect === "mssql")
647
- return length ? `VARBINARY(${length})` : "VARBINARY(255)";
648
- if (nextDialect === "oracle")
649
- return length ? `RAW(${length})` : "RAW(255)";
650
- if (nextDialect === "mysql")
651
- return length ? `VARBINARY(${length})` : "VARBINARY(255)";
652
- if (nextDialect === "postgres") return "BYTEA";
653
- return "BLOB";
654
- }
655
-
656
- return defaultPhysicalType(nextDialect, logicalType, options);
657
- }
658
-
659
- export function convertDesignDialect(
660
- doc: DesignDocument,
661
- nextDialect: RdbmsDialect,
662
- options?: CoreDbMetaOptions,
663
- ): DesignDocument {
664
- if (doc.model.dialect === nextDialect) return doc;
665
- const supportsSchema = dialectSupportsSchema(nextDialect, options);
666
- return {
667
- ...doc,
668
- model: {
669
- ...doc.model,
670
- dialect: nextDialect,
671
- tables: doc.model.tables.map((table) => ({
672
- ...table,
673
- schemaName: supportsSchema ? table.schemaName : undefined,
674
- columns: table.columns.map((col) => ({
675
- ...col,
676
- physicalType: convertPhysicalTypeByLogicalType(
677
- col.physicalType,
678
- col.logicalType,
679
- nextDialect,
680
- options,
681
- ),
682
- })),
683
- })),
684
- },
685
- };
686
- }
687
-
688
- export function applyLogicalTypeChange(
689
- column: ColumnModel,
690
- nextLogicalType: LogicalDataType,
691
- dialect: RdbmsDialect,
692
- options?: CoreDbMetaOptions,
693
- ): ColumnModel {
694
- return {
695
- ...column,
696
- logicalType: nextLogicalType,
697
- physicalType: defaultPhysicalType(dialect, nextLogicalType, options),
698
- };
699
- }
700
-
701
- export function createColumn(
702
- dialect: RdbmsDialect,
703
- params: {
704
- id: string;
705
- logicalName: string;
706
- /** 생략 논리 이름과 동일한 물리 이름으로 생성된다. */
707
- physicalName?: string;
708
- description?: string;
709
- logicalType: LogicalDataType;
710
- defaultValue?: string;
711
- nullable?: boolean;
712
- isPrimaryKey?: boolean;
713
- isForeignKey?: boolean;
714
- referencesPrimaryColumnId?: string;
715
- color?: string;
716
- },
717
- options?: CoreDbMetaOptions,
718
- ): ColumnModel {
719
- const physicalName = params.physicalName ?? params.logicalName;
720
- const isPrimaryKey = params.isPrimaryKey ?? false;
721
- const isForeignKey = params.isForeignKey ?? false;
722
- const col: ColumnModel = {
723
- id: params.id,
724
- logicalName: params.logicalName,
725
- physicalName,
726
- description: params.description,
727
- logicalType: params.logicalType,
728
- physicalType: defaultPhysicalType(dialect, params.logicalType, options),
729
- defaultValue: params.defaultValue,
730
- nullable: isPrimaryKey ? false : (params.nullable ?? true),
731
- isPrimaryKey,
732
- isForeignKey,
733
- referencesPrimaryColumnId: params.referencesPrimaryColumnId,
734
- };
735
- if (params.color !== undefined) col.color = params.color;
736
- return col;
737
- }
738
-
739
- export function createEmptyDesign(
740
- dialect: RdbmsDialect = "mssql",
741
- ): DesignDocument {
742
- return {
743
- schemaVersion: 1,
744
- model: {
745
- dialect,
746
- tables: [],
747
- relationships: [],
748
- indexes: [],
749
- },
750
- layout: {
751
- nodePositions: {},
752
- },
753
- };
754
- }
755
-
756
- export function serializeDesign(doc: DesignDocument): string {
757
- return JSON.stringify(doc, null, 2);
758
- }
759
-
760
- export function parseDesign(
761
- json: string,
762
- options?: CoreDbMetaOptions,
763
- ): DesignDocument {
764
- const parsed = JSON.parse(json) as unknown;
765
- return validateDesignDocument(parsed, options);
766
- }
767
-
768
- export function validateDesignDocument(
769
- input: unknown,
770
- options?: CoreDbMetaOptions,
771
- ): DesignDocument {
772
- if (!isObject(input)) {
773
- throw new Error("Invalid design document: root must be object");
774
- }
775
-
776
- if (input.schemaVersion !== 1) {
777
- throw new Error("Unsupported schemaVersion");
778
- }
779
-
780
- if (
781
- !isObject(input.model) ||
782
- !Array.isArray(input.model.tables) ||
783
- !Array.isArray(input.model.relationships) ||
784
- !Array.isArray(input.model.indexes)
785
- ) {
786
- throw new Error("Invalid design document: model is malformed");
787
- }
788
-
789
- const dialects = new Set(
790
- resolveDbMetaAdapter(options)
791
- .listDialects()
792
- .map((d) => d.id),
793
- );
794
- const modelDialect = (input.model as unknown as { dialect?: unknown })
795
- .dialect;
796
- if (
797
- typeof modelDialect !== "string" ||
798
- !dialects.has(modelDialect as RdbmsDialect)
799
- ) {
800
- throw new Error("Invalid design document: model.dialect is invalid");
801
- }
802
-
803
- if (!isObject(input.layout) || !isObject(input.layout.nodePositions)) {
804
- throw new Error("Invalid design document: layout is malformed");
805
- }
806
-
807
- const doc = input as unknown as DesignDocument;
808
- migrateLegacyFkLineVisibility(doc.model);
809
- return doc;
810
- }
811
-
812
- export function roundTripDesign(doc: DesignDocument): DesignDocument {
813
- return parseDesign(serializeDesign(doc));
814
- }
815
-
816
- function quoteIdentifier(
817
- dialect: RdbmsDialect,
818
- identifier: string,
819
- options?: CoreDbMetaOptions,
820
- ): string {
821
- const quote =
822
- resolveDbMetaAdapter(options).getDdlRules(dialect).quoteIdentifier ??
823
- defaultDdlRules().quoteIdentifier!;
824
- return quote(dialect, identifier);
825
- }
826
-
827
- function qualifiedTableName(
828
- table: TableModel,
829
- dialect: RdbmsDialect,
830
- options?: CoreDbMetaOptions,
831
- ): string {
832
- const schema = table.schemaName?.trim();
833
- if (dialectSupportsSchema(dialect, options) && schema) {
834
- return `${quoteIdentifier(dialect, schema, options)}.${quoteIdentifier(dialect, table.physicalName, options)}`;
835
- }
836
- return quoteIdentifier(dialect, table.physicalName, options);
837
- }
838
-
839
- function quoteSqlString(value: string): string {
840
- return `'${value.replaceAll("'", "''")}'`;
841
- }
842
-
843
- function toDefaultExpression(
844
- dialect: RdbmsDialect,
845
- col: ColumnModel,
846
- options?: CoreDbMetaOptions,
847
- ): string | null {
848
- const handler =
849
- resolveDbMetaAdapter(options).getDdlRules(dialect)
850
- .toDefaultExpression ?? defaultDdlRules().toDefaultExpression!;
851
- return handler(dialect, col);
852
- }
853
-
854
- function joinColumnDefs(
855
- table: TableModel,
856
- dialect: RdbmsDialect,
857
- options?: CoreDbMetaOptions,
858
- ): string[] {
859
- const defs = table.columns.map((col) => {
860
- const nullable = col.nullable ? "NULL" : "NOT NULL";
861
- const defaultExpr = toDefaultExpression(dialect, col, options);
862
- const defaultSql = defaultExpr ? ` DEFAULT ${defaultExpr}` : "";
863
- return ` ${quoteIdentifier(dialect, col.physicalName, options)} ${col.physicalType}${defaultSql} ${nullable}`;
864
- });
865
- const pkColumns = table.columns
866
- .filter((col) => col.isPrimaryKey)
867
- .map((col) => quoteIdentifier(dialect, col.physicalName, options));
868
- if (pkColumns.length > 0) {
869
- defs.push(` PRIMARY KEY (${pkColumns.join(", ")})`);
870
- }
871
- return defs;
872
- }
873
-
874
- function createTableSql(
875
- table: TableModel,
876
- dialect: RdbmsDialect,
877
- options?: CoreDbMetaOptions,
878
- ): string {
879
- const columns = joinColumnDefs(table, dialect, options);
880
- return `CREATE TABLE ${qualifiedTableName(table, dialect, options)} (\n${columns.join(",\n")}\n);`;
881
- }
882
-
883
- function createRelationshipSql(
884
- rel: RelationshipModel,
885
- model: DesignModel,
886
- dialect: RdbmsDialect,
887
- index: number,
888
- options?: CoreDbMetaOptions,
889
- ): string | null {
890
- const sourceTable = model.tables.find(
891
- (table) => table.id === rel.sourceTableId,
892
- );
893
- const targetTable = model.tables.find(
894
- (table) => table.id === rel.targetTableId,
895
- );
896
- if (
897
- !sourceTable ||
898
- !targetTable ||
899
- !rel.sourceColumnId ||
900
- !rel.targetColumnId
901
- ) {
902
- return null;
903
- }
904
-
905
- const sourceColumn = sourceTable.columns.find(
906
- (col) => col.id === rel.sourceColumnId,
907
- );
908
- const targetColumn = targetTable.columns.find(
909
- (col) => col.id === rel.targetColumnId,
910
- );
911
- if (!sourceColumn || !targetColumn) {
912
- return null;
913
- }
914
-
915
- const fkName = `FK_${targetTable.physicalName}_${sourceTable.physicalName}_${index + 1}`;
916
- return [
917
- `ALTER TABLE ${qualifiedTableName(targetTable, dialect, options)}`,
918
- ` ADD CONSTRAINT ${quoteIdentifier(dialect, fkName, options)}`,
919
- ` FOREIGN KEY (${quoteIdentifier(dialect, targetColumn.physicalName, options)})`,
920
- ` REFERENCES ${qualifiedTableName(sourceTable, dialect, options)} (${quoteIdentifier(dialect, sourceColumn.physicalName, options)});`,
921
- ].join("\n");
922
- }
923
-
924
- function createIndexSql(
925
- indexModel: IndexModel,
926
- model: DesignModel,
927
- dialect: RdbmsDialect,
928
- options?: CoreDbMetaOptions,
929
- ): string | null {
930
- const table = model.tables.find((item) => item.id === indexModel.tableId);
931
- if (!table || indexModel.columns.length === 0) {
932
- return null;
933
- }
934
- const unique = indexModel.unique ? "UNIQUE " : "";
935
- const columns = indexModel.columns
936
- .map((col) => quoteIdentifier(dialect, col, options))
937
- .join(", ");
938
- return `CREATE ${unique}INDEX ${quoteIdentifier(dialect, indexModel.name, options)} ON ${qualifiedTableName(table, dialect, options)} (${columns});`;
939
- }
940
-
941
- function buildDdlSql(doc: DesignDocument, options?: CoreDbMetaOptions): string {
942
- const { model } = doc;
943
- const tableSql = model.tables.map((table) =>
944
- createTableSql(table, model.dialect, options),
945
- );
946
- const relSql = model.relationships
947
- .map((rel, index) =>
948
- createRelationshipSql(rel, model, model.dialect, index, options),
949
- )
950
- .filter((item): item is string => Boolean(item));
951
- return [...tableSql, ...relSql].join("\n\n");
952
- }
953
-
954
- function buildIndexDdlSql(
955
- doc: DesignDocument,
956
- options?: CoreDbMetaOptions,
957
- ): string {
958
- const { model } = doc;
959
- const statements = model.indexes
960
- .map((index) => createIndexSql(index, model, model.dialect, options))
961
- .filter((item): item is string => Boolean(item));
962
- return statements.join("\n\n");
963
- }
964
-
965
- function sliceDocByScope(doc: DesignDocument, scope: DdlScope): DesignDocument {
966
- if (scope.kind === "all") return doc;
967
- const selected = new Set(scope.tableIds);
968
- return {
969
- ...doc,
970
- model: {
971
- ...doc.model,
972
- tables: doc.model.tables.filter((t) => selected.has(t.id)),
973
- relationships: doc.model.relationships.filter(
974
- (r) =>
975
- selected.has(r.sourceTableId) &&
976
- selected.has(r.targetTableId),
977
- ),
978
- indexes: doc.model.indexes.filter((i) => selected.has(i.tableId)),
979
- },
980
- };
981
- }
982
-
983
- function styleBasedDdlGenerator(
984
- input: DdlGenerateInput,
985
- options?: CoreDbMetaOptions,
986
- ): DdlGenerateOutput {
987
- const scoped = sliceDocByScope(input.doc, input.scope);
988
- return {
989
- sql: buildDdlSql(scoped, options),
990
- diagnostics: analyzeDdlDocument(scoped, options),
991
- };
992
- }
993
-
994
- function hasBuiltinDialect(
995
- dialectId: RdbmsDialect,
996
- ): dialectId is BuiltinRdbmsDialect {
997
- return (BUILTIN_DIALECTS as readonly string[]).includes(dialectId);
998
- }
999
-
1000
- function getBuiltinDdlGenerator(
1001
- dialectId: RdbmsDialect,
1002
- _options?: CoreDbMetaOptions,
1003
- ): DdlGeneratorHook | undefined {
1004
- if (!hasBuiltinDialect(dialectId)) return undefined;
1005
- return (input) => styleBasedDdlGenerator(input, _options);
1006
- }
1007
-
1008
- function invokeDdlGeneratorSync(
1009
- generator: DdlGeneratorHook,
1010
- input: DdlGenerateInput,
1011
- ): DdlGenerateOutput {
1012
- const result = generator(input);
1013
- if (
1014
- result &&
1015
- typeof (result as Promise<DdlGenerateOutput>).then === "function"
1016
- ) {
1017
- throw new Error("Async DDL generator is not supported in sync API");
1018
- }
1019
- return result as DdlGenerateOutput;
1020
- }
1021
-
1022
- function runDdlGenerator(
1023
- doc: DesignDocument,
1024
- scope: DdlScope,
1025
- options?: CoreDbMetaOptions,
1026
- ): DdlGenerateOutput {
1027
- const input: DdlGenerateInput = {
1028
- doc,
1029
- dialectId: doc.model.dialect,
1030
- scope,
1031
- };
1032
- const hostGenerator = options?.hostDdlGenerators?.[doc.model.dialect];
1033
- const builtinGenerator = getBuiltinDdlGenerator(doc.model.dialect, options);
1034
- const fallback = () => styleBasedDdlGenerator(input, options);
1035
- const fallbackOnError = options?.fallbackOnHookError ?? true;
1036
-
1037
- const selectedGenerator = hostGenerator ?? builtinGenerator;
1038
- if (!selectedGenerator) return fallback();
1039
- try {
1040
- return invokeDdlGeneratorSync(selectedGenerator, input);
1041
- } catch (error) {
1042
- if (!fallbackOnError) throw error;
1043
- return fallback();
1044
- }
1045
- }
1046
-
1047
- /** DDL/인덱스 분석 공통 심각도 */
1048
- export type DdlDiagnosticSeverity = "error" | "warning";
1049
-
1050
- /** 구조화된 DDL 관련 진단(에러·경고) */
1051
- export interface DdlDiagnostic {
1052
- severity: DdlDiagnosticSeverity;
1053
- code: string;
1054
- message: string;
1055
- /** 예: relationship:rel1, index:ix1, table:t1 */
1056
- context?: string;
1057
- }
1058
-
1059
- /**
1060
- * 통일된 한 줄 텍스트 포맷.
1061
- * 예: `[WARN][DDL_REL_MISSING_COLUMNS] ... | relationship:rel1`
1062
- */
1063
- export function formatDdlDiagnostic(d: DdlDiagnostic): string {
1064
- const level = d.severity === "error" ? "ERROR" : "WARN";
1065
- const tail = d.context ? ` | ${d.context}` : "";
1066
- return `[${level}][${d.code}] ${d.message}${tail}`;
1067
- }
1068
-
1069
- export function formatDdlDiagnostics(diagnostics: DdlDiagnostic[]): string {
1070
- return diagnostics.map(formatDdlDiagnostic).join("\n");
1071
- }
1072
-
1073
- /**
1074
- * 모델 전체에 대한 DDL·인덱스 관련 진단(테이블/관계/인덱스).
1075
- * 검증 예외(Invalid design document)와는 별도로, 생성 가능 여부와 경고를 나열한다.
1076
- */
1077
- export function analyzeDdlDocument(
1078
- doc: DesignDocument,
1079
- options?: CoreDbMetaOptions,
1080
- ): DdlDiagnostic[] {
1081
- const out: DdlDiagnostic[] = [];
1082
- const { model } = doc;
1083
- const tableById = new Map(model.tables.map((t) => [t.id, t]));
1084
- const physicalTableNames = new Map<string, string[]>();
1085
-
1086
- for (const table of model.tables) {
1087
- const qualifiedName =
1088
- dialectSupportsSchema(model.dialect, options) &&
1089
- table.schemaName?.trim()
1090
- ? `${table.schemaName.trim()}.${table.physicalName}`
1091
- : table.physicalName;
1092
- const list = physicalTableNames.get(qualifiedName) ?? [];
1093
- list.push(table.id);
1094
- physicalTableNames.set(qualifiedName, list);
1095
- if (table.columns.length === 0) {
1096
- out.push({
1097
- severity: "warning",
1098
- code: "DDL_EMPTY_TABLE",
1099
- message:
1100
- "컬럼이 없는 테이블은 CREATE TABLE 구문이 비어 있거나 무의미할 수 있다.",
1101
- context: `table:${table.id}`,
1102
- });
1103
- }
1104
- }
1105
-
1106
- for (const [name, ids] of physicalTableNames) {
1107
- if (ids.length > 1) {
1108
- out.push({
1109
- severity: "warning",
1110
- code: "DDL_DUPLICATE_TABLE_NAME",
1111
- message: `동일한 물리 테이블명 "${name}"이 ${ids.length}개 테이블에 사용되었다.`,
1112
- context: `tables:${ids.join(",")}`,
1113
- });
1114
- }
1115
- }
1116
-
1117
- for (const rel of model.relationships) {
1118
- const ctx = `relationship:${rel.id}`;
1119
- const sourceTable = tableById.get(rel.sourceTableId);
1120
- const targetTable = tableById.get(rel.targetTableId);
1121
- if (!sourceTable || !targetTable) {
1122
- out.push({
1123
- severity: "error",
1124
- code: "DDL_REL_UNKNOWN_TABLE",
1125
- message:
1126
- "관계의 소스 또는 타겟 테이블을 찾을 수 없어 FK DDL을 생성할 수 없다.",
1127
- context: ctx,
1128
- });
1129
- continue;
1130
- }
1131
- if (!rel.sourceColumnId || !rel.targetColumnId) {
1132
- out.push({
1133
- severity: "warning",
1134
- code: "DDL_REL_MISSING_COLUMNS",
1135
- message: "소스/타겟 컬럼이 지정되지 않아 FK DDL을 생략한다.",
1136
- context: ctx,
1137
- });
1138
- continue;
1139
- }
1140
- const sourceColumn = sourceTable.columns.find(
1141
- (c) => c.id === rel.sourceColumnId,
1142
- );
1143
- const targetColumn = targetTable.columns.find(
1144
- (c) => c.id === rel.targetColumnId,
1145
- );
1146
- if (!sourceColumn || !targetColumn) {
1147
- out.push({
1148
- severity: "warning",
1149
- code: "DDL_REL_UNKNOWN_COLUMN",
1150
- message:
1151
- "관계에 지정된 컬럼 id를 테이블에서 찾을 수 없어 FK DDL을 생략한다.",
1152
- context: ctx,
1153
- });
1154
- }
1155
- }
1156
-
1157
- for (const indexModel of model.indexes) {
1158
- const ctx = `index:${indexModel.id}`;
1159
- const table = tableById.get(indexModel.tableId);
1160
- if (!table) {
1161
- out.push({
1162
- severity: "warning",
1163
- code: "IDX_UNKNOWN_TABLE",
1164
- message:
1165
- "인덱스가 가리키는 테이블을 찾을 수 없어 인덱스 DDL을 생략한다.",
1166
- context: ctx,
1167
- });
1168
- continue;
1169
- }
1170
- if (indexModel.columns.length === 0) {
1171
- out.push({
1172
- severity: "warning",
1173
- code: "IDX_EMPTY_COLUMNS",
1174
- message: "인덱스 컬럼 목록이 비어 있어 인덱스 DDL을 생략한다.",
1175
- context: ctx,
1176
- });
1177
- continue;
1178
- }
1179
- for (const colPhys of indexModel.columns) {
1180
- if (!table.columns.some((c) => c.physicalName === colPhys)) {
1181
- out.push({
1182
- severity: "warning",
1183
- code: "IDX_UNKNOWN_COLUMN",
1184
- message: `인덱스가 참조하는 물리 컬럼명 "${colPhys}"을() 테이블 "${table.physicalName}"에서 찾을 수 없다.`,
1185
- context: ctx,
1186
- });
1187
- }
1188
- }
1189
- }
1190
-
1191
- return out;
1192
- }
1193
-
1194
- export function generateDdlWithDiagnostics(
1195
- doc: DesignDocument,
1196
- options?: CoreDbMetaOptions,
1197
- ): { sql: string; diagnostics: DdlDiagnostic[] } {
1198
- const out = runDdlGenerator(doc, { kind: "all" }, options);
1199
- return {
1200
- sql: out.sql,
1201
- diagnostics: out.diagnostics ?? analyzeDdlDocument(doc, options),
1202
- };
1203
- }
1204
-
1205
- export function generateIndexDdlWithDiagnostics(
1206
- doc: DesignDocument,
1207
- options?: CoreDbMetaOptions,
1208
- ): { sql: string; diagnostics: DdlDiagnostic[] } {
1209
- return {
1210
- sql: buildIndexDdlSql(doc, options),
1211
- diagnostics: analyzeDdlDocument(doc, options),
1212
- };
1213
- }
1214
-
1215
- export function generateDdl(
1216
- doc: DesignDocument,
1217
- options?: CoreDbMetaOptions,
1218
- ): string {
1219
- return runDdlGenerator(doc, { kind: "all" }, options).sql;
1220
- }
1221
-
1222
- export function generateDdlForSelection(
1223
- doc: DesignDocument,
1224
- tableIds: string[],
1225
- options?: CoreDbMetaOptions,
1226
- ): { sql: string; diagnostics: DdlDiagnostic[] } {
1227
- const out = runDdlGenerator(doc, { kind: "selected", tableIds }, options);
1228
- const diagnostics =
1229
- out.diagnostics ??
1230
- analyzeDdlDocument(
1231
- sliceDocByScope(doc, { kind: "selected", tableIds }),
1232
- options,
1233
- );
1234
- return { sql: out.sql, diagnostics };
1235
- }
1236
-
1237
- export function generateIndexDdl(
1238
- doc: DesignDocument,
1239
- options?: CoreDbMetaOptions,
1240
- ): string {
1241
- return buildIndexDdlSql(doc, options);
1242
- }
1243
-
1244
- /** 성능·부하 테스트용: 지정 개수의 빈 테이블과 격자 레이아웃을 생성한다. */
1245
- export function createLargeDesign(
1246
- tableCount: number,
1247
- dialect: RdbmsDialect = "postgres",
1248
- ): DesignDocument {
1249
- const doc = createEmptyDesign(dialect);
1250
- const cols = 20;
1251
- for (let i = 0; i < tableCount; i++) {
1252
- const id = `t-${i}`;
1253
- doc.model.tables.push({
1254
- id,
1255
- logicalName: `엔티티${i}`,
1256
- physicalName: `TB_T${i}`,
1257
- columns: [
1258
- createColumn(dialect, {
1259
- id: `${id}-pk`,
1260
- logicalName: "식별자",
1261
- logicalType: "NUMBER",
1262
- nullable: false,
1263
- }),
1264
- ],
1265
- });
1266
- doc.layout.nodePositions[id] = {
1267
- x: (i % cols) * 220,
1268
- y: Math.floor(i / cols) * 120,
1269
- };
1270
- }
1271
- return doc;
1272
- }
1
+ export type BuiltinRdbmsDialect =
2
+ | "mssql"
3
+ | "oracle"
4
+ | "mysql"
5
+ | "postgres"
6
+ | "sqlite";
7
+ export type RdbmsDialect = BuiltinRdbmsDialect | (string & {});
8
+
9
+ export { alignNodePositions, type AlignCommand } from "./alignment";
10
+
11
+ export const LOGICAL_DATA_TYPES = [
12
+ "TEXT",
13
+ "DATE",
14
+ "TIME",
15
+ "DATETIME",
16
+ "NUMBER",
17
+ "DECIMAL",
18
+ "FLOAT",
19
+ "BOOLEAN",
20
+ "JSON",
21
+ "UUID",
22
+ "BINARY",
23
+ ] as const;
24
+
25
+ export type LogicalDataType = (typeof LOGICAL_DATA_TYPES)[number];
26
+
27
+ export interface ColumnModel {
28
+ id: string;
29
+ logicalName: string;
30
+ physicalName: string;
31
+ /** 컬럼 설명(업무/도메인 메모). */
32
+ description?: string;
33
+ logicalType: LogicalDataType;
34
+ physicalType: string;
35
+ defaultValue?: string;
36
+ nullable: boolean;
37
+ isPrimaryKey?: boolean;
38
+ isForeignKey?: boolean;
39
+ referencesPrimaryColumnId?: string;
40
+ /** 테이블 노드에서 해당 컬럼 행 배경색 */
41
+ color?: string;
42
+ }
43
+
44
+ export interface TableModel {
45
+ id: string;
46
+ logicalName: string;
47
+ physicalName: string;
48
+ /** 테이블 설명(업무/도메인 메모). */
49
+ description?: string;
50
+ /** Dialect-dependent schema/catalog qualifier (e.g. `public`, `dbo`). */
51
+ schemaName?: string;
52
+ color?: string;
53
+ columns: ColumnModel[];
54
+ }
55
+
56
+ export interface RelationshipModel {
57
+ id: string;
58
+ /** 참조 원본(PK) 테이블 */
59
+ sourceTableId: string;
60
+ /** 참조 대상(FK) 테이블 */
61
+ targetTableId: string;
62
+ /** source(PK) 컬럼 id */
63
+ sourceColumnId?: string;
64
+ /** target(FK) 컬럼 id */
65
+ targetColumnId?: string;
66
+ /** 연결 시 생성된 FK 컬럼인지 여부(삭제 시 컬럼 정리용) */
67
+ autoCreatedTargetColumn?: boolean;
68
+ /** FK가 최초로 연결된 PK 컬럼 id(컬럼명 변경과 무관) */
69
+ originPkColumnId?: string;
70
+ /** 타깃 cardinality. 기본 1:N */
71
+ cardinality?: "1:1" | "1:N";
72
+ /** 출발(소스) 엣지의 테이블 내부 절대 Y 좌표(px). */
73
+ sourceLineY?: number;
74
+ /** @deprecated legacy ratio(0~1). sourceLineY가 없을 때만 fallback으로 사용 */
75
+ sourceLineRatio?: number;
76
+ /** 관계선의 중간 꺾임 위치 비율(0~1). 두 테이블 앵커 사이의 비율로 저장한다. */
77
+ linePivotRatio?: number;
78
+ /** true이면 캔버스에서 해당 관계선을 기본적으로 숨긴다. 툴의「숨긴 관계선 보기」로만 표시 가능. */
79
+ canvasLineHidden?: boolean;
80
+ }
81
+
82
+ /**
83
+ * 캔버스에 관계선을 그릴지 여부.
84
+ * - `revealHiddenLines === false`: `canvasLineHidden`인 관계는 제외.
85
+ * - `revealHiddenLines === true`: 숨김 처리된 관계도 함께 그린다(표시만, 속성은 바꾸지 않음).
86
+ */
87
+ export function isRelationshipLineRenderable(
88
+ rel: RelationshipModel,
89
+ revealHiddenLines: boolean,
90
+ ): boolean {
91
+ if (rel.canvasLineHidden === true) return revealHiddenLines;
92
+ return true;
93
+ }
94
+
95
+ /** 구버전 `ColumnModel.showFkRelationLine === false`를 관계의 `canvasLineHidden`으로 옮긴 뒤 컬럼 플래그를 제거한다. */
96
+ export function migrateLegacyFkLineVisibility(model: DesignModel): void {
97
+ for (const table of model.tables) {
98
+ for (const col of table.columns) {
99
+ const legacy = (col as { showFkRelationLine?: boolean })
100
+ .showFkRelationLine;
101
+ if (legacy === false && col.isForeignKey) {
102
+ for (const rel of model.relationships) {
103
+ if (
104
+ rel.targetTableId === table.id &&
105
+ rel.targetColumnId === col.id
106
+ ) {
107
+ rel.canvasLineHidden = true;
108
+ }
109
+ }
110
+ }
111
+ if ("showFkRelationLine" in col) {
112
+ delete (col as { showFkRelationLine?: boolean })
113
+ .showFkRelationLine;
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ export interface IndexModel {
120
+ id: string;
121
+ tableId: string;
122
+ name: string;
123
+ columns: string[];
124
+ unique: boolean;
125
+ }
126
+
127
+ export interface DiagramLayout {
128
+ nodePositions: Record<string, { x: number; y: number }>;
129
+ }
130
+
131
+ export interface DesignModel {
132
+ dialect: RdbmsDialect;
133
+ tables: TableModel[];
134
+ relationships: RelationshipModel[];
135
+ indexes: IndexModel[];
136
+ }
137
+
138
+ export interface DesignDocument {
139
+ schemaVersion: number;
140
+ model: DesignModel;
141
+ layout: DiagramLayout;
142
+ settings?: Record<string, unknown>;
143
+ }
144
+
145
+ export interface DialectCapability {
146
+ supportsSchema: boolean;
147
+ }
148
+
149
+ export type DdlStyleQuote = "double" | "backtick" | "bracket";
150
+ export type DdlStyleBooleanLiteral = "trueFalse" | "oneZero";
151
+
152
+ export interface DdlStyle {
153
+ quote: DdlStyleQuote;
154
+ boolLiteral?: DdlStyleBooleanLiteral;
155
+ nowKeyword?: string;
156
+ }
157
+
158
+ export interface LogicalTypeMeta {
159
+ id: LogicalDataType;
160
+ label?: string;
161
+ defaultPhysicalType: string;
162
+ }
163
+
164
+ export interface DialectMetaJson {
165
+ id: RdbmsDialect;
166
+ label: string;
167
+ supportsSchema: boolean;
168
+ logicalTypes: LogicalTypeMeta[];
169
+ ddlStyle?: DdlStyle;
170
+ }
171
+
172
+ export interface DialectMeta {
173
+ id: RdbmsDialect;
174
+ label: string;
175
+ capabilities: DialectCapability;
176
+ logicalTypes: readonly LogicalDataType[];
177
+ defaultPhysicalTypeMap: Partial<Record<LogicalDataType, string>>;
178
+ }
179
+
180
+ export type DdlScope =
181
+ | { kind: "all" }
182
+ | { kind: "selected"; tableIds: string[] };
183
+
184
+ export interface DdlGenerateInput {
185
+ doc: DesignDocument;
186
+ dialectId: RdbmsDialect;
187
+ scope: DdlScope;
188
+ }
189
+
190
+ export interface DdlGenerateOutput {
191
+ sql: string;
192
+ diagnostics?: DdlDiagnostic[];
193
+ }
194
+
195
+ export type DdlGeneratorHook = (
196
+ input: DdlGenerateInput,
197
+ ) => DdlGenerateOutput | Promise<DdlGenerateOutput>;
198
+
199
+ export interface DdlGeneratorRules {
200
+ quoteIdentifier?: (dialect: RdbmsDialect, identifier: string) => string;
201
+ toDefaultExpression?: (
202
+ dialect: RdbmsDialect,
203
+ col: ColumnModel,
204
+ ) => string | null;
205
+ }
206
+
207
+ export interface DbMetaAdapter {
208
+ listDialects: () => DialectMeta[];
209
+ getDialectMeta: (dialect: RdbmsDialect) => DialectMeta | undefined;
210
+ getDefaultPhysicalType: (
211
+ dialect: RdbmsDialect,
212
+ logicalType: LogicalDataType,
213
+ ) => string;
214
+ getDdlRules: (dialect: RdbmsDialect) => DdlGeneratorRules;
215
+ }
216
+
217
+ export interface CoreDbMetaOptions {
218
+ dbMetaAdapter?: DbMetaAdapter;
219
+ hostMetas?: DialectMetaJson[];
220
+ hostDdlGenerators?: Record<string, DdlGeneratorHook>;
221
+ fallbackOnHookError?: boolean;
222
+ }
223
+
224
+ function isObject(value: unknown): value is Record<string, unknown> {
225
+ return typeof value === "object" && value !== null;
226
+ }
227
+
228
+ export const BUILTIN_DIALECTS: readonly BuiltinRdbmsDialect[] = [
229
+ "mssql",
230
+ "oracle",
231
+ "mysql",
232
+ "postgres",
233
+ "sqlite",
234
+ ] as const;
235
+
236
+ const DIALECT_DEFAULT_TYPE_MAP: Record<
237
+ BuiltinRdbmsDialect,
238
+ Record<LogicalDataType, string>
239
+ > = {
240
+ mssql: {
241
+ TEXT: "NVARCHAR(255)",
242
+ DATE: "DATE",
243
+ TIME: "TIME",
244
+ DATETIME: "DATETIME2",
245
+ NUMBER: "INT",
246
+ DECIMAL: "DECIMAL(10,2)",
247
+ FLOAT: "FLOAT",
248
+ BOOLEAN: "BIT",
249
+ JSON: "NVARCHAR(MAX)",
250
+ UUID: "UNIQUEIDENTIFIER",
251
+ BINARY: "VARBINARY(255)",
252
+ },
253
+ oracle: {
254
+ TEXT: "VARCHAR2(255)",
255
+ DATE: "DATE",
256
+ TIME: "TIMESTAMP",
257
+ DATETIME: "TIMESTAMP",
258
+ NUMBER: "NUMBER(10)",
259
+ DECIMAL: "NUMBER(10,2)",
260
+ FLOAT: "BINARY_FLOAT",
261
+ BOOLEAN: "NUMBER(1)",
262
+ JSON: "CLOB",
263
+ UUID: "RAW(16)",
264
+ BINARY: "RAW(255)",
265
+ },
266
+ mysql: {
267
+ TEXT: "VARCHAR(255)",
268
+ DATE: "DATE",
269
+ TIME: "TIME",
270
+ DATETIME: "DATETIME",
271
+ NUMBER: "INT",
272
+ DECIMAL: "DECIMAL(10,2)",
273
+ FLOAT: "FLOAT",
274
+ BOOLEAN: "BOOLEAN",
275
+ JSON: "JSON",
276
+ UUID: "CHAR(36)",
277
+ BINARY: "VARBINARY(255)",
278
+ },
279
+ postgres: {
280
+ TEXT: "VARCHAR(255)",
281
+ DATE: "DATE",
282
+ TIME: "TIME",
283
+ DATETIME: "TIMESTAMP",
284
+ NUMBER: "INTEGER",
285
+ DECIMAL: "NUMERIC(10,2)",
286
+ FLOAT: "REAL",
287
+ BOOLEAN: "BOOLEAN",
288
+ JSON: "JSONB",
289
+ UUID: "UUID",
290
+ BINARY: "BYTEA",
291
+ },
292
+ sqlite: {
293
+ TEXT: "TEXT",
294
+ DATE: "TEXT",
295
+ TIME: "TEXT",
296
+ DATETIME: "TEXT",
297
+ NUMBER: "INTEGER",
298
+ DECIMAL: "NUMERIC(10,2)",
299
+ FLOAT: "REAL",
300
+ BOOLEAN: "INTEGER",
301
+ JSON: "TEXT",
302
+ UUID: "TEXT",
303
+ BINARY: "BLOB",
304
+ },
305
+ };
306
+
307
+ const DIALECT_CAPABILITIES: Record<
308
+ BuiltinRdbmsDialect,
309
+ { supportsSchema: boolean }
310
+ > = {
311
+ mssql: { supportsSchema: true },
312
+ oracle: { supportsSchema: true },
313
+ mysql: { supportsSchema: true },
314
+ postgres: { supportsSchema: true },
315
+ sqlite: { supportsSchema: false },
316
+ };
317
+
318
+ const DIALECT_LABELS: Record<BuiltinRdbmsDialect, string> = {
319
+ mssql: "MS SQL Server",
320
+ oracle: "Oracle",
321
+ mysql: "MySQL",
322
+ postgres: "PostgreSQL",
323
+ sqlite: "SQLite",
324
+ };
325
+
326
+ const DIALECT_DDL_STYLE: Record<BuiltinRdbmsDialect, DdlStyle> = {
327
+ mssql: {
328
+ quote: "bracket",
329
+ boolLiteral: "oneZero",
330
+ nowKeyword: "GETDATE()",
331
+ },
332
+ oracle: {
333
+ quote: "double",
334
+ boolLiteral: "oneZero",
335
+ nowKeyword: "CURRENT_TIMESTAMP",
336
+ },
337
+ mysql: {
338
+ quote: "backtick",
339
+ boolLiteral: "oneZero",
340
+ nowKeyword: "CURRENT_TIMESTAMP",
341
+ },
342
+ postgres: {
343
+ quote: "double",
344
+ boolLiteral: "trueFalse",
345
+ nowKeyword: "CURRENT_TIMESTAMP",
346
+ },
347
+ sqlite: {
348
+ quote: "double",
349
+ boolLiteral: "oneZero",
350
+ nowKeyword: "CURRENT_TIMESTAMP",
351
+ },
352
+ };
353
+
354
+ function toLogicalTypeMetas(dialect: BuiltinRdbmsDialect): LogicalTypeMeta[] {
355
+ return LOGICAL_DATA_TYPES.map((id) => ({
356
+ id,
357
+ defaultPhysicalType: DIALECT_DEFAULT_TYPE_MAP[dialect][id],
358
+ }));
359
+ }
360
+
361
+ export const BUILTIN_DIALECT_METAS_JSON: DialectMetaJson[] =
362
+ BUILTIN_DIALECTS.map((dialect) => ({
363
+ id: dialect,
364
+ label: DIALECT_LABELS[dialect],
365
+ supportsSchema: DIALECT_CAPABILITIES[dialect].supportsSchema,
366
+ logicalTypes: toLogicalTypeMetas(dialect),
367
+ ddlStyle: DIALECT_DDL_STYLE[dialect],
368
+ }));
369
+
370
+ function defaultDdlRules(): DdlGeneratorRules {
371
+ return {
372
+ quoteIdentifier: (dialect, identifier) => {
373
+ if (dialect === "mssql") return `[${identifier}]`;
374
+ if (dialect === "mysql") return `\`${identifier}\``;
375
+ return `"${identifier}"`;
376
+ },
377
+ toDefaultExpression: (dialect, col) => {
378
+ const raw = col.defaultValue?.trim();
379
+ if (!raw) return null;
380
+ const upper = raw.toUpperCase();
381
+ const isFunctionLike =
382
+ /[()]/.test(raw) ||
383
+ upper === "NULL" ||
384
+ upper === "CURRENT_TIMESTAMP" ||
385
+ upper === "CURRENT_DATE";
386
+ if (isFunctionLike) return raw;
387
+ if (
388
+ col.logicalType === "NUMBER" ||
389
+ col.logicalType === "DECIMAL" ||
390
+ col.logicalType === "FLOAT"
391
+ )
392
+ return raw;
393
+ if (col.logicalType === "BOOLEAN") {
394
+ if (upper === "TRUE" || upper === "FALSE") {
395
+ if (
396
+ dialect === "mssql" ||
397
+ dialect === "oracle" ||
398
+ dialect === "sqlite"
399
+ )
400
+ return upper === "TRUE" ? "1" : "0";
401
+ return upper;
402
+ }
403
+ if (raw === "1" || raw === "0") return raw;
404
+ return quoteSqlString(raw);
405
+ }
406
+ if (col.logicalType === "DATE" || col.logicalType === "DATETIME") {
407
+ if (dialect === "mssql" && upper === "NOW") return "GETDATE()";
408
+ if (upper === "NOW") return "CURRENT_TIMESTAMP";
409
+ return quoteSqlString(raw);
410
+ }
411
+ if (
412
+ (raw.startsWith("'") && raw.endsWith("'")) ||
413
+ (raw.startsWith('"') && raw.endsWith('"'))
414
+ )
415
+ return raw;
416
+ return quoteSqlString(raw);
417
+ },
418
+ };
419
+ }
420
+
421
+ function toAdapterDialectMeta(meta: DialectMetaJson): DialectMeta {
422
+ const typeMap: Partial<Record<LogicalDataType, string>> = {};
423
+ for (const lt of meta.logicalTypes) typeMap[lt.id] = lt.defaultPhysicalType;
424
+ return {
425
+ id: meta.id,
426
+ label: meta.label,
427
+ capabilities: { supportsSchema: meta.supportsSchema },
428
+ logicalTypes: meta.logicalTypes.map((lt) => lt.id),
429
+ defaultPhysicalTypeMap: typeMap,
430
+ };
431
+ }
432
+
433
+ export function mergeDialectMetas(
434
+ base: DialectMetaJson[],
435
+ host: DialectMetaJson[] = [],
436
+ ): DialectMetaJson[] {
437
+ const byId = new Map<string, DialectMetaJson>(
438
+ base.map((meta) => [meta.id, meta]),
439
+ );
440
+ for (const meta of host) byId.set(meta.id, meta);
441
+ return Array.from(byId.values());
442
+ }
443
+
444
+ export function resolveDialectMetas(
445
+ options?: CoreDbMetaOptions,
446
+ ): DialectMetaJson[] {
447
+ return mergeDialectMetas(
448
+ BUILTIN_DIALECT_METAS_JSON,
449
+ options?.hostMetas ?? [],
450
+ );
451
+ }
452
+
453
+ function buildDbMetaAdapterFromMetas(metas: DialectMetaJson[]): DbMetaAdapter {
454
+ const byId = new Map<RdbmsDialect, DialectMeta>(
455
+ metas.map((meta) => [meta.id, toAdapterDialectMeta(meta)]),
456
+ );
457
+ const defaultRules = defaultDdlRules();
458
+ return {
459
+ listDialects: () => Array.from(byId.values()),
460
+ getDialectMeta: (dialect) => byId.get(dialect),
461
+ getDefaultPhysicalType: (dialect, logicalType) => {
462
+ const mapped =
463
+ byId.get(dialect)?.defaultPhysicalTypeMap[logicalType];
464
+ return mapped ?? DIALECT_DEFAULT_TYPE_MAP.postgres[logicalType];
465
+ },
466
+ getDdlRules: (dialect) => {
467
+ const json = metas.find((m) => m.id === dialect);
468
+ const style = json?.ddlStyle;
469
+ if (!style) return defaultRules;
470
+ const quoteIdentifier: DdlGeneratorRules["quoteIdentifier"] = (
471
+ _d,
472
+ identifier,
473
+ ) => {
474
+ if (style.quote === "bracket") return `[${identifier}]`;
475
+ if (style.quote === "backtick") return `\`${identifier}\``;
476
+ return `"${identifier}"`;
477
+ };
478
+ const toDefaultExpression: DdlGeneratorRules["toDefaultExpression"] =
479
+ (_d, col) => {
480
+ const raw = col.defaultValue?.trim();
481
+ if (!raw) return null;
482
+ const upper = raw.toUpperCase();
483
+ const isFunctionLike =
484
+ /[()]/.test(raw) ||
485
+ upper === "NULL" ||
486
+ upper === "CURRENT_TIMESTAMP" ||
487
+ upper === "CURRENT_DATE";
488
+ if (isFunctionLike) return raw;
489
+ if (
490
+ col.logicalType === "NUMBER" ||
491
+ col.logicalType === "DECIMAL" ||
492
+ col.logicalType === "FLOAT"
493
+ )
494
+ return raw;
495
+ if (col.logicalType === "BOOLEAN") {
496
+ if (upper === "TRUE" || upper === "FALSE") {
497
+ if ((style.boolLiteral ?? "oneZero") === "oneZero")
498
+ return upper === "TRUE" ? "1" : "0";
499
+ return upper;
500
+ }
501
+ if (raw === "1" || raw === "0") return raw;
502
+ return quoteSqlString(raw);
503
+ }
504
+ if (
505
+ col.logicalType === "DATE" ||
506
+ col.logicalType === "DATETIME"
507
+ ) {
508
+ if (upper === "NOW")
509
+ return style.nowKeyword ?? "CURRENT_TIMESTAMP";
510
+ return quoteSqlString(raw);
511
+ }
512
+ if (
513
+ (raw.startsWith("'") && raw.endsWith("'")) ||
514
+ (raw.startsWith('"') && raw.endsWith('"'))
515
+ )
516
+ return raw;
517
+ return quoteSqlString(raw);
518
+ };
519
+ return { quoteIdentifier, toDefaultExpression };
520
+ },
521
+ };
522
+ }
523
+
524
+ export function createDefaultDbMetaAdapter(
525
+ overrides?: Partial<DbMetaAdapter>,
526
+ ): DbMetaAdapter {
527
+ const base = buildDbMetaAdapterFromMetas(BUILTIN_DIALECT_METAS_JSON);
528
+ return { ...base, ...overrides };
529
+ }
530
+
531
+ export const defaultDbMetaAdapter = createDefaultDbMetaAdapter();
532
+
533
+ function resolveDbMetaAdapter(options?: CoreDbMetaOptions): DbMetaAdapter {
534
+ if (options?.dbMetaAdapter) return options.dbMetaAdapter;
535
+ if (options?.hostMetas && options.hostMetas.length > 0) {
536
+ return buildDbMetaAdapterFromMetas(resolveDialectMetas(options));
537
+ }
538
+ return defaultDbMetaAdapter;
539
+ }
540
+
541
+ export function defaultPhysicalType(
542
+ dialect: RdbmsDialect,
543
+ logicalType: LogicalDataType,
544
+ options?: CoreDbMetaOptions,
545
+ ): string {
546
+ return resolveDbMetaAdapter(options).getDefaultPhysicalType(
547
+ dialect,
548
+ logicalType,
549
+ );
550
+ }
551
+
552
+ function normalizePhysicalCompare(s: string): string {
553
+ return s.trim().toUpperCase().replace(/\s+/g, " ");
554
+ }
555
+
556
+ function physicalTypeBaseName(s: string): string {
557
+ const n = normalizePhysicalCompare(s);
558
+ const p = n.indexOf("(");
559
+ return p >= 0 ? n.slice(0, p).trim() : n;
560
+ }
561
+
562
+ /**
563
+ * 물리 데이터 유형 문자열에서 방언 메타의 기본 매핑(및 흔한 SQL 별칭)으로 논리 유형을 추정한다.
564
+ */
565
+ export function inferLogicalTypeFromPhysical(
566
+ dialect: RdbmsDialect,
567
+ physicalType: string,
568
+ options?: CoreDbMetaOptions,
569
+ ): LogicalDataType {
570
+ const raw = physicalType?.trim();
571
+ if (!raw) return "TEXT";
572
+
573
+ const metas = resolveDialectMetas(options);
574
+ const meta = metas.find((m) => m.id === dialect);
575
+ const normFull = normalizePhysicalCompare(raw);
576
+ const base = physicalTypeBaseName(raw);
577
+
578
+ if (meta) {
579
+ for (const lt of meta.logicalTypes) {
580
+ if (
581
+ normalizePhysicalCompare(lt.defaultPhysicalType) === normFull
582
+ ) {
583
+ return lt.id;
584
+ }
585
+ }
586
+ for (const lt of meta.logicalTypes) {
587
+ if (physicalTypeBaseName(lt.defaultPhysicalType) === base) {
588
+ return lt.id;
589
+ }
590
+ }
591
+ }
592
+
593
+ const b = base;
594
+ if (
595
+ b === "INT" ||
596
+ b === "INTEGER" ||
597
+ b === "BIGINT" ||
598
+ b === "SMALLINT" ||
599
+ b === "TINYINT" ||
600
+ b === "NUMBER" ||
601
+ b === "SERIAL" ||
602
+ b === "SERIAL4" ||
603
+ b === "SERIAL8"
604
+ ) {
605
+ return "NUMBER";
606
+ }
607
+ if (
608
+ b === "DECIMAL" ||
609
+ b === "NUMERIC" ||
610
+ b === "MONEY" ||
611
+ b === "SMALLMONEY"
612
+ ) {
613
+ return "DECIMAL";
614
+ }
615
+ if (
616
+ b === "FLOAT" ||
617
+ b === "REAL" ||
618
+ b === "DOUBLE" ||
619
+ b === "BINARY_FLOAT" ||
620
+ b === "BINARY_DOUBLE"
621
+ ) {
622
+ return "FLOAT";
623
+ }
624
+ if (b === "BIT" || b === "BOOLEAN" || b === "BOOL") {
625
+ return "BOOLEAN";
626
+ }
627
+ if (
628
+ b === "DATETIME" ||
629
+ b === "DATETIME2" ||
630
+ b === "SMALLDATETIME" ||
631
+ b === "TIMESTAMP" ||
632
+ b === "TIMESTAMPTZ"
633
+ ) {
634
+ return "DATETIME";
635
+ }
636
+ if (b === "DATE") return "DATE";
637
+ if (b === "TIME" || b === "TIMETZ") return "TIME";
638
+ if (
639
+ b.includes("CHAR") ||
640
+ b === "TEXT" ||
641
+ b === "CLOB" ||
642
+ b === "NCLOB" ||
643
+ b === "NCHAR" ||
644
+ b === "NVARCHAR" ||
645
+ b === "VARCHAR" ||
646
+ b === "VARCHAR2"
647
+ ) {
648
+ return "TEXT";
649
+ }
650
+ if (b === "JSON" || b === "JSONB") return "JSON";
651
+ if (b === "UUID" || b === "UNIQUEIDENTIFIER") return "UUID";
652
+ if (
653
+ b === "BINARY" ||
654
+ b === "VARBINARY" ||
655
+ b === "RAW" ||
656
+ b === "BYTEA" ||
657
+ b === "BLOB" ||
658
+ b === "IMAGE"
659
+ ) {
660
+ return "BINARY";
661
+ }
662
+
663
+ return "TEXT";
664
+ }
665
+
666
+ export function getRdbmsDialectCapability(
667
+ dialect: RdbmsDialect,
668
+ options?: CoreDbMetaOptions,
669
+ ): DialectCapability {
670
+ const meta = resolveDbMetaAdapter(options).getDialectMeta(dialect);
671
+ return meta?.capabilities ?? { supportsSchema: false };
672
+ }
673
+
674
+ export function dialectSupportsSchema(
675
+ dialect: RdbmsDialect,
676
+ options?: CoreDbMetaOptions,
677
+ ): boolean {
678
+ return getRdbmsDialectCapability(dialect, options).supportsSchema;
679
+ }
680
+
681
+ function parseTypeArguments(physicalType: string): number[] | null {
682
+ const m = physicalType.match(/\(([^)]+)\)/);
683
+ if (!m) return null;
684
+ const values = m[1]
685
+ .split(",")
686
+ .map((v) => Number.parseInt(v.trim(), 10))
687
+ .filter((v) => Number.isFinite(v));
688
+ return values.length > 0 ? values : null;
689
+ }
690
+
691
+ function convertPhysicalTypeByLogicalType(
692
+ physicalType: string,
693
+ logicalType: LogicalDataType,
694
+ nextDialect: RdbmsDialect,
695
+ options?: CoreDbMetaOptions,
696
+ ): string {
697
+ const args = parseTypeArguments(physicalType);
698
+ const precision = args?.[0];
699
+ const scale = args?.[1];
700
+ const length = args?.[0];
701
+
702
+ if (logicalType === "NUMBER") {
703
+ if (nextDialect === "oracle") {
704
+ if (precision && scale !== undefined)
705
+ return `NUMBER(${precision},${scale})`;
706
+ if (precision) return `NUMBER(${precision})`;
707
+ return "NUMBER(10)";
708
+ }
709
+ if (nextDialect === "mssql") {
710
+ if (precision && scale !== undefined)
711
+ return `NUMERIC(${precision},${scale})`;
712
+ if (precision) return `NUMERIC(${precision},0)`;
713
+ return "INT";
714
+ }
715
+ if (nextDialect === "mysql") {
716
+ if (precision && scale !== undefined)
717
+ return `DECIMAL(${precision},${scale})`;
718
+ if (precision) return `DECIMAL(${precision},0)`;
719
+ return "INT";
720
+ }
721
+ if (nextDialect === "postgres") {
722
+ if (precision && scale !== undefined)
723
+ return `NUMERIC(${precision},${scale})`;
724
+ if (precision) return `NUMERIC(${precision},0)`;
725
+ return "INTEGER";
726
+ }
727
+ return "INTEGER";
728
+ }
729
+
730
+ if (logicalType === "DECIMAL") {
731
+ if (precision && scale !== undefined) {
732
+ if (nextDialect === "oracle")
733
+ return `NUMBER(${precision},${scale})`;
734
+ if (nextDialect === "postgres")
735
+ return `NUMERIC(${precision},${scale})`;
736
+ return `DECIMAL(${precision},${scale})`;
737
+ }
738
+ return defaultPhysicalType(nextDialect, logicalType, options);
739
+ }
740
+
741
+ if (logicalType === "FLOAT") {
742
+ if (nextDialect === "oracle") return "BINARY_FLOAT";
743
+ if (nextDialect === "sqlite") return "REAL";
744
+ return "FLOAT";
745
+ }
746
+
747
+ if (logicalType === "TEXT") {
748
+ if (nextDialect === "oracle")
749
+ return length ? `VARCHAR2(${length})` : "VARCHAR2(255)";
750
+ if (nextDialect === "mssql")
751
+ return length ? `NVARCHAR(${length})` : "NVARCHAR(255)";
752
+ if (nextDialect === "mysql")
753
+ return length ? `VARCHAR(${length})` : "VARCHAR(255)";
754
+ if (nextDialect === "postgres")
755
+ return length ? `VARCHAR(${length})` : "VARCHAR(255)";
756
+ return "TEXT";
757
+ }
758
+
759
+ if (logicalType === "BINARY") {
760
+ if (nextDialect === "mssql")
761
+ return length ? `VARBINARY(${length})` : "VARBINARY(255)";
762
+ if (nextDialect === "oracle")
763
+ return length ? `RAW(${length})` : "RAW(255)";
764
+ if (nextDialect === "mysql")
765
+ return length ? `VARBINARY(${length})` : "VARBINARY(255)";
766
+ if (nextDialect === "postgres") return "BYTEA";
767
+ return "BLOB";
768
+ }
769
+
770
+ return defaultPhysicalType(nextDialect, logicalType, options);
771
+ }
772
+
773
+ export function convertDesignDialect(
774
+ doc: DesignDocument,
775
+ nextDialect: RdbmsDialect,
776
+ options?: CoreDbMetaOptions,
777
+ ): DesignDocument {
778
+ if (doc.model.dialect === nextDialect) return doc;
779
+ const supportsSchema = dialectSupportsSchema(nextDialect, options);
780
+ return {
781
+ ...doc,
782
+ model: {
783
+ ...doc.model,
784
+ dialect: nextDialect,
785
+ tables: doc.model.tables.map((table) => ({
786
+ ...table,
787
+ schemaName: supportsSchema ? table.schemaName : undefined,
788
+ columns: table.columns.map((col) => ({
789
+ ...col,
790
+ physicalType: convertPhysicalTypeByLogicalType(
791
+ col.physicalType,
792
+ col.logicalType,
793
+ nextDialect,
794
+ options,
795
+ ),
796
+ })),
797
+ })),
798
+ },
799
+ };
800
+ }
801
+
802
+ export function applyLogicalTypeChange(
803
+ column: ColumnModel,
804
+ nextLogicalType: LogicalDataType,
805
+ dialect: RdbmsDialect,
806
+ options?: CoreDbMetaOptions,
807
+ ): ColumnModel {
808
+ return {
809
+ ...column,
810
+ logicalType: nextLogicalType,
811
+ physicalType: defaultPhysicalType(dialect, nextLogicalType, options),
812
+ };
813
+ }
814
+
815
+ export function createColumn(
816
+ dialect: RdbmsDialect,
817
+ params: {
818
+ id: string;
819
+ logicalName: string;
820
+ /** 생략 시 논리 이름과 동일한 물리 이름으로 생성된다. */
821
+ physicalName?: string;
822
+ description?: string;
823
+ logicalType: LogicalDataType;
824
+ defaultValue?: string;
825
+ nullable?: boolean;
826
+ isPrimaryKey?: boolean;
827
+ isForeignKey?: boolean;
828
+ referencesPrimaryColumnId?: string;
829
+ color?: string;
830
+ },
831
+ options?: CoreDbMetaOptions,
832
+ ): ColumnModel {
833
+ const physicalName = params.physicalName ?? params.logicalName;
834
+ const isPrimaryKey = params.isPrimaryKey ?? false;
835
+ const isForeignKey = params.isForeignKey ?? false;
836
+ const col: ColumnModel = {
837
+ id: params.id,
838
+ logicalName: params.logicalName,
839
+ physicalName,
840
+ description: params.description,
841
+ logicalType: params.logicalType,
842
+ physicalType: defaultPhysicalType(dialect, params.logicalType, options),
843
+ defaultValue: params.defaultValue,
844
+ nullable: isPrimaryKey ? false : (params.nullable ?? true),
845
+ isPrimaryKey,
846
+ isForeignKey,
847
+ referencesPrimaryColumnId: params.referencesPrimaryColumnId,
848
+ };
849
+ if (params.color !== undefined) col.color = params.color;
850
+ return col;
851
+ }
852
+
853
+ export function createEmptyDesign(
854
+ dialect: RdbmsDialect = "mssql",
855
+ ): DesignDocument {
856
+ return {
857
+ schemaVersion: 1,
858
+ model: {
859
+ dialect,
860
+ tables: [],
861
+ relationships: [],
862
+ indexes: [],
863
+ },
864
+ layout: {
865
+ nodePositions: {},
866
+ },
867
+ };
868
+ }
869
+
870
+ export function serializeDesign(doc: DesignDocument): string {
871
+ return JSON.stringify(doc, null, 2);
872
+ }
873
+
874
+ export function parseDesign(
875
+ json: string,
876
+ options?: CoreDbMetaOptions,
877
+ ): DesignDocument {
878
+ const parsed = JSON.parse(json) as unknown;
879
+ return validateDesignDocument(parsed, options);
880
+ }
881
+
882
+ export function validateDesignDocument(
883
+ input: unknown,
884
+ options?: CoreDbMetaOptions,
885
+ ): DesignDocument {
886
+ if (!isObject(input)) {
887
+ throw new Error("Invalid design document: root must be object");
888
+ }
889
+
890
+ if (input.schemaVersion !== 1) {
891
+ throw new Error("Unsupported schemaVersion");
892
+ }
893
+
894
+ if (
895
+ !isObject(input.model) ||
896
+ !Array.isArray(input.model.tables) ||
897
+ !Array.isArray(input.model.relationships) ||
898
+ !Array.isArray(input.model.indexes)
899
+ ) {
900
+ throw new Error("Invalid design document: model is malformed");
901
+ }
902
+
903
+ const dialects = new Set(
904
+ resolveDbMetaAdapter(options)
905
+ .listDialects()
906
+ .map((d) => d.id),
907
+ );
908
+ const modelDialect = (input.model as unknown as { dialect?: unknown })
909
+ .dialect;
910
+ if (
911
+ typeof modelDialect !== "string" ||
912
+ !dialects.has(modelDialect as RdbmsDialect)
913
+ ) {
914
+ throw new Error("Invalid design document: model.dialect is invalid");
915
+ }
916
+
917
+ if (!isObject(input.layout) || !isObject(input.layout.nodePositions)) {
918
+ throw new Error("Invalid design document: layout is malformed");
919
+ }
920
+
921
+ const doc = input as unknown as DesignDocument;
922
+ migrateLegacyFkLineVisibility(doc.model);
923
+ return doc;
924
+ }
925
+
926
+ export function roundTripDesign(doc: DesignDocument): DesignDocument {
927
+ return parseDesign(serializeDesign(doc));
928
+ }
929
+
930
+ function quoteIdentifier(
931
+ dialect: RdbmsDialect,
932
+ identifier: string,
933
+ options?: CoreDbMetaOptions,
934
+ ): string {
935
+ const quote =
936
+ resolveDbMetaAdapter(options).getDdlRules(dialect).quoteIdentifier ??
937
+ defaultDdlRules().quoteIdentifier!;
938
+ return quote(dialect, identifier);
939
+ }
940
+
941
+ function qualifiedTableName(
942
+ table: TableModel,
943
+ dialect: RdbmsDialect,
944
+ options?: CoreDbMetaOptions,
945
+ ): string {
946
+ const schema = table.schemaName?.trim();
947
+ if (dialectSupportsSchema(dialect, options) && schema) {
948
+ return `${quoteIdentifier(dialect, schema, options)}.${quoteIdentifier(dialect, table.physicalName, options)}`;
949
+ }
950
+ return quoteIdentifier(dialect, table.physicalName, options);
951
+ }
952
+
953
+ function quoteSqlString(value: string): string {
954
+ return `'${value.replaceAll("'", "''")}'`;
955
+ }
956
+
957
+ function toDefaultExpression(
958
+ dialect: RdbmsDialect,
959
+ col: ColumnModel,
960
+ options?: CoreDbMetaOptions,
961
+ ): string | null {
962
+ const handler =
963
+ resolveDbMetaAdapter(options).getDdlRules(dialect)
964
+ .toDefaultExpression ?? defaultDdlRules().toDefaultExpression!;
965
+ return handler(dialect, col);
966
+ }
967
+
968
+ function joinColumnDefs(
969
+ table: TableModel,
970
+ dialect: RdbmsDialect,
971
+ options?: CoreDbMetaOptions,
972
+ ): string[] {
973
+ const defs = table.columns.map((col) => {
974
+ const nullable = col.nullable ? "NULL" : "NOT NULL";
975
+ const defaultExpr = toDefaultExpression(dialect, col, options);
976
+ const defaultSql = defaultExpr ? ` DEFAULT ${defaultExpr}` : "";
977
+ return ` ${quoteIdentifier(dialect, col.physicalName, options)} ${col.physicalType}${defaultSql} ${nullable}`;
978
+ });
979
+ const pkColumns = table.columns
980
+ .filter((col) => col.isPrimaryKey)
981
+ .map((col) => quoteIdentifier(dialect, col.physicalName, options));
982
+ if (pkColumns.length > 0) {
983
+ defs.push(` PRIMARY KEY (${pkColumns.join(", ")})`);
984
+ }
985
+ return defs;
986
+ }
987
+
988
+ function createTableSql(
989
+ table: TableModel,
990
+ dialect: RdbmsDialect,
991
+ options?: CoreDbMetaOptions,
992
+ ): string {
993
+ const columns = joinColumnDefs(table, dialect, options);
994
+ return `CREATE TABLE ${qualifiedTableName(table, dialect, options)} (\n${columns.join(",\n")}\n);`;
995
+ }
996
+
997
+ function createRelationshipSql(
998
+ rel: RelationshipModel,
999
+ model: DesignModel,
1000
+ dialect: RdbmsDialect,
1001
+ index: number,
1002
+ options?: CoreDbMetaOptions,
1003
+ ): string | null {
1004
+ const sourceTable = model.tables.find(
1005
+ (table) => table.id === rel.sourceTableId,
1006
+ );
1007
+ const targetTable = model.tables.find(
1008
+ (table) => table.id === rel.targetTableId,
1009
+ );
1010
+ if (
1011
+ !sourceTable ||
1012
+ !targetTable ||
1013
+ !rel.sourceColumnId ||
1014
+ !rel.targetColumnId
1015
+ ) {
1016
+ return null;
1017
+ }
1018
+
1019
+ const sourceColumn = sourceTable.columns.find(
1020
+ (col) => col.id === rel.sourceColumnId,
1021
+ );
1022
+ const targetColumn = targetTable.columns.find(
1023
+ (col) => col.id === rel.targetColumnId,
1024
+ );
1025
+ if (!sourceColumn || !targetColumn) {
1026
+ return null;
1027
+ }
1028
+
1029
+ const fkName = `FK_${targetTable.physicalName}_${sourceTable.physicalName}_${index + 1}`;
1030
+ return [
1031
+ `ALTER TABLE ${qualifiedTableName(targetTable, dialect, options)}`,
1032
+ ` ADD CONSTRAINT ${quoteIdentifier(dialect, fkName, options)}`,
1033
+ ` FOREIGN KEY (${quoteIdentifier(dialect, targetColumn.physicalName, options)})`,
1034
+ ` REFERENCES ${qualifiedTableName(sourceTable, dialect, options)} (${quoteIdentifier(dialect, sourceColumn.physicalName, options)});`,
1035
+ ].join("\n");
1036
+ }
1037
+
1038
+ function createIndexSql(
1039
+ indexModel: IndexModel,
1040
+ model: DesignModel,
1041
+ dialect: RdbmsDialect,
1042
+ options?: CoreDbMetaOptions,
1043
+ ): string | null {
1044
+ const table = model.tables.find((item) => item.id === indexModel.tableId);
1045
+ if (!table || indexModel.columns.length === 0) {
1046
+ return null;
1047
+ }
1048
+ const unique = indexModel.unique ? "UNIQUE " : "";
1049
+ const columns = indexModel.columns
1050
+ .map((col) => quoteIdentifier(dialect, col, options))
1051
+ .join(", ");
1052
+ return `CREATE ${unique}INDEX ${quoteIdentifier(dialect, indexModel.name, options)} ON ${qualifiedTableName(table, dialect, options)} (${columns});`;
1053
+ }
1054
+
1055
+ function buildDdlSql(doc: DesignDocument, options?: CoreDbMetaOptions): string {
1056
+ const { model } = doc;
1057
+ const tableSql = model.tables.map((table) =>
1058
+ createTableSql(table, model.dialect, options),
1059
+ );
1060
+ const relSql = model.relationships
1061
+ .map((rel, index) =>
1062
+ createRelationshipSql(rel, model, model.dialect, index, options),
1063
+ )
1064
+ .filter((item): item is string => Boolean(item));
1065
+ return [...tableSql, ...relSql].join("\n\n");
1066
+ }
1067
+
1068
+ function buildIndexDdlSql(
1069
+ doc: DesignDocument,
1070
+ options?: CoreDbMetaOptions,
1071
+ ): string {
1072
+ const { model } = doc;
1073
+ const statements = model.indexes
1074
+ .map((index) => createIndexSql(index, model, model.dialect, options))
1075
+ .filter((item): item is string => Boolean(item));
1076
+ return statements.join("\n\n");
1077
+ }
1078
+
1079
+ function sliceDocByScope(doc: DesignDocument, scope: DdlScope): DesignDocument {
1080
+ if (scope.kind === "all") return doc;
1081
+ const selected = new Set(scope.tableIds);
1082
+ return {
1083
+ ...doc,
1084
+ model: {
1085
+ ...doc.model,
1086
+ tables: doc.model.tables.filter((t) => selected.has(t.id)),
1087
+ relationships: doc.model.relationships.filter(
1088
+ (r) =>
1089
+ selected.has(r.sourceTableId) &&
1090
+ selected.has(r.targetTableId),
1091
+ ),
1092
+ indexes: doc.model.indexes.filter((i) => selected.has(i.tableId)),
1093
+ },
1094
+ };
1095
+ }
1096
+
1097
+ function styleBasedDdlGenerator(
1098
+ input: DdlGenerateInput,
1099
+ options?: CoreDbMetaOptions,
1100
+ ): DdlGenerateOutput {
1101
+ const scoped = sliceDocByScope(input.doc, input.scope);
1102
+ return {
1103
+ sql: buildDdlSql(scoped, options),
1104
+ diagnostics: analyzeDdlDocument(scoped, options),
1105
+ };
1106
+ }
1107
+
1108
+ function hasBuiltinDialect(
1109
+ dialectId: RdbmsDialect,
1110
+ ): dialectId is BuiltinRdbmsDialect {
1111
+ return (BUILTIN_DIALECTS as readonly string[]).includes(dialectId);
1112
+ }
1113
+
1114
+ function getBuiltinDdlGenerator(
1115
+ dialectId: RdbmsDialect,
1116
+ _options?: CoreDbMetaOptions,
1117
+ ): DdlGeneratorHook | undefined {
1118
+ if (!hasBuiltinDialect(dialectId)) return undefined;
1119
+ return (input) => styleBasedDdlGenerator(input, _options);
1120
+ }
1121
+
1122
+ function invokeDdlGeneratorSync(
1123
+ generator: DdlGeneratorHook,
1124
+ input: DdlGenerateInput,
1125
+ ): DdlGenerateOutput {
1126
+ const result = generator(input);
1127
+ if (
1128
+ result &&
1129
+ typeof (result as Promise<DdlGenerateOutput>).then === "function"
1130
+ ) {
1131
+ throw new Error("Async DDL generator is not supported in sync API");
1132
+ }
1133
+ return result as DdlGenerateOutput;
1134
+ }
1135
+
1136
+ function runDdlGenerator(
1137
+ doc: DesignDocument,
1138
+ scope: DdlScope,
1139
+ options?: CoreDbMetaOptions,
1140
+ ): DdlGenerateOutput {
1141
+ const input: DdlGenerateInput = {
1142
+ doc,
1143
+ dialectId: doc.model.dialect,
1144
+ scope,
1145
+ };
1146
+ const hostGenerator = options?.hostDdlGenerators?.[doc.model.dialect];
1147
+ const builtinGenerator = getBuiltinDdlGenerator(doc.model.dialect, options);
1148
+ const fallback = () => styleBasedDdlGenerator(input, options);
1149
+ const fallbackOnError = options?.fallbackOnHookError ?? true;
1150
+
1151
+ const selectedGenerator = hostGenerator ?? builtinGenerator;
1152
+ if (!selectedGenerator) return fallback();
1153
+ try {
1154
+ return invokeDdlGeneratorSync(selectedGenerator, input);
1155
+ } catch (error) {
1156
+ if (!fallbackOnError) throw error;
1157
+ return fallback();
1158
+ }
1159
+ }
1160
+
1161
+ /** DDL/인덱스 분석 공통 심각도 */
1162
+ export type DdlDiagnosticSeverity = "error" | "warning";
1163
+
1164
+ /** 구조화된 DDL 관련 진단(에러·경고) */
1165
+ export interface DdlDiagnostic {
1166
+ severity: DdlDiagnosticSeverity;
1167
+ code: string;
1168
+ message: string;
1169
+ /** 예: relationship:rel1, index:ix1, table:t1 */
1170
+ context?: string;
1171
+ }
1172
+
1173
+ /**
1174
+ * 통일된 텍스트 포맷.
1175
+ * 예: `[WARN][DDL_REL_MISSING_COLUMNS] ... | relationship:rel1`
1176
+ */
1177
+ export function formatDdlDiagnostic(d: DdlDiagnostic): string {
1178
+ const level = d.severity === "error" ? "ERROR" : "WARN";
1179
+ const tail = d.context ? ` | ${d.context}` : "";
1180
+ return `[${level}][${d.code}] ${d.message}${tail}`;
1181
+ }
1182
+
1183
+ export function formatDdlDiagnostics(diagnostics: DdlDiagnostic[]): string {
1184
+ return diagnostics.map(formatDdlDiagnostic).join("\n");
1185
+ }
1186
+
1187
+ /**
1188
+ * 모델 전체에 대한 DDL·인덱스 관련 진단(테이블/관계/인덱스).
1189
+ * 검증 예외(Invalid design document)와는 별도로, 생성 가능 여부와 경고를 나열한다.
1190
+ */
1191
+ export function analyzeDdlDocument(
1192
+ doc: DesignDocument,
1193
+ options?: CoreDbMetaOptions,
1194
+ ): DdlDiagnostic[] {
1195
+ const out: DdlDiagnostic[] = [];
1196
+ const { model } = doc;
1197
+ const tableById = new Map(model.tables.map((t) => [t.id, t]));
1198
+ const physicalTableNames = new Map<string, string[]>();
1199
+
1200
+ for (const table of model.tables) {
1201
+ const qualifiedName =
1202
+ dialectSupportsSchema(model.dialect, options) &&
1203
+ table.schemaName?.trim()
1204
+ ? `${table.schemaName.trim()}.${table.physicalName}`
1205
+ : table.physicalName;
1206
+ const list = physicalTableNames.get(qualifiedName) ?? [];
1207
+ list.push(table.id);
1208
+ physicalTableNames.set(qualifiedName, list);
1209
+ if (table.columns.length === 0) {
1210
+ out.push({
1211
+ severity: "warning",
1212
+ code: "DDL_EMPTY_TABLE",
1213
+ message:
1214
+ "컬럼이 없는 테이블은 CREATE TABLE 구문이 비어 있거나 무의미할 수 있다.",
1215
+ context: `table:${table.id}`,
1216
+ });
1217
+ }
1218
+ }
1219
+
1220
+ for (const [name, ids] of physicalTableNames) {
1221
+ if (ids.length > 1) {
1222
+ out.push({
1223
+ severity: "warning",
1224
+ code: "DDL_DUPLICATE_TABLE_NAME",
1225
+ message: `동일한 물리 테이블명 "${name}"이 ${ids.length}개 테이블에 사용되었다.`,
1226
+ context: `tables:${ids.join(",")}`,
1227
+ });
1228
+ }
1229
+ }
1230
+
1231
+ for (const rel of model.relationships) {
1232
+ const ctx = `relationship:${rel.id}`;
1233
+ const sourceTable = tableById.get(rel.sourceTableId);
1234
+ const targetTable = tableById.get(rel.targetTableId);
1235
+ if (!sourceTable || !targetTable) {
1236
+ out.push({
1237
+ severity: "error",
1238
+ code: "DDL_REL_UNKNOWN_TABLE",
1239
+ message:
1240
+ "관계의 소스 또는 타겟 테이블을 찾을 수 없어 FK DDL을 생성할 수 없다.",
1241
+ context: ctx,
1242
+ });
1243
+ continue;
1244
+ }
1245
+ if (!rel.sourceColumnId || !rel.targetColumnId) {
1246
+ out.push({
1247
+ severity: "warning",
1248
+ code: "DDL_REL_MISSING_COLUMNS",
1249
+ message: "소스/타겟 컬럼이 지정되지 않아 FK DDL을 생략한다.",
1250
+ context: ctx,
1251
+ });
1252
+ continue;
1253
+ }
1254
+ const sourceColumn = sourceTable.columns.find(
1255
+ (c) => c.id === rel.sourceColumnId,
1256
+ );
1257
+ const targetColumn = targetTable.columns.find(
1258
+ (c) => c.id === rel.targetColumnId,
1259
+ );
1260
+ if (!sourceColumn || !targetColumn) {
1261
+ out.push({
1262
+ severity: "warning",
1263
+ code: "DDL_REL_UNKNOWN_COLUMN",
1264
+ message:
1265
+ "관계에 지정된 컬럼 id를 테이블에서 찾을 수 없어 FK DDL을 생략한다.",
1266
+ context: ctx,
1267
+ });
1268
+ }
1269
+ }
1270
+
1271
+ for (const indexModel of model.indexes) {
1272
+ const ctx = `index:${indexModel.id}`;
1273
+ const table = tableById.get(indexModel.tableId);
1274
+ if (!table) {
1275
+ out.push({
1276
+ severity: "warning",
1277
+ code: "IDX_UNKNOWN_TABLE",
1278
+ message:
1279
+ "인덱스가 가리키는 테이블을 찾을 수 없어 인덱스 DDL을 생략한다.",
1280
+ context: ctx,
1281
+ });
1282
+ continue;
1283
+ }
1284
+ if (indexModel.columns.length === 0) {
1285
+ out.push({
1286
+ severity: "warning",
1287
+ code: "IDX_EMPTY_COLUMNS",
1288
+ message: "인덱스 컬럼 목록이 비어 있어 인덱스 DDL을 생략한다.",
1289
+ context: ctx,
1290
+ });
1291
+ continue;
1292
+ }
1293
+ for (const colPhys of indexModel.columns) {
1294
+ if (!table.columns.some((c) => c.physicalName === colPhys)) {
1295
+ out.push({
1296
+ severity: "warning",
1297
+ code: "IDX_UNKNOWN_COLUMN",
1298
+ message: `인덱스가 참조하는 물리 컬럼명 "${colPhys}"을(를) 테이블 "${table.physicalName}"에서 찾을 수 없다.`,
1299
+ context: ctx,
1300
+ });
1301
+ }
1302
+ }
1303
+ }
1304
+
1305
+ return out;
1306
+ }
1307
+
1308
+ export function generateDdlWithDiagnostics(
1309
+ doc: DesignDocument,
1310
+ options?: CoreDbMetaOptions,
1311
+ ): { sql: string; diagnostics: DdlDiagnostic[] } {
1312
+ const out = runDdlGenerator(doc, { kind: "all" }, options);
1313
+ return {
1314
+ sql: out.sql,
1315
+ diagnostics: out.diagnostics ?? analyzeDdlDocument(doc, options),
1316
+ };
1317
+ }
1318
+
1319
+ export function generateIndexDdlWithDiagnostics(
1320
+ doc: DesignDocument,
1321
+ options?: CoreDbMetaOptions,
1322
+ ): { sql: string; diagnostics: DdlDiagnostic[] } {
1323
+ return {
1324
+ sql: buildIndexDdlSql(doc, options),
1325
+ diagnostics: analyzeDdlDocument(doc, options),
1326
+ };
1327
+ }
1328
+
1329
+ export function generateDdl(
1330
+ doc: DesignDocument,
1331
+ options?: CoreDbMetaOptions,
1332
+ ): string {
1333
+ return runDdlGenerator(doc, { kind: "all" }, options).sql;
1334
+ }
1335
+
1336
+ export function generateDdlForSelection(
1337
+ doc: DesignDocument,
1338
+ tableIds: string[],
1339
+ options?: CoreDbMetaOptions,
1340
+ ): { sql: string; diagnostics: DdlDiagnostic[] } {
1341
+ const out = runDdlGenerator(doc, { kind: "selected", tableIds }, options);
1342
+ const diagnostics =
1343
+ out.diagnostics ??
1344
+ analyzeDdlDocument(
1345
+ sliceDocByScope(doc, { kind: "selected", tableIds }),
1346
+ options,
1347
+ );
1348
+ return { sql: out.sql, diagnostics };
1349
+ }
1350
+
1351
+ export function generateIndexDdl(
1352
+ doc: DesignDocument,
1353
+ options?: CoreDbMetaOptions,
1354
+ ): string {
1355
+ return buildIndexDdlSql(doc, options);
1356
+ }
1357
+
1358
+ /** 성능·부하 테스트용: 지정 개수의 빈 테이블과 격자 레이아웃을 생성한다. */
1359
+ export function createLargeDesign(
1360
+ tableCount: number,
1361
+ dialect: RdbmsDialect = "postgres",
1362
+ ): DesignDocument {
1363
+ const doc = createEmptyDesign(dialect);
1364
+ const cols = 20;
1365
+ for (let i = 0; i < tableCount; i++) {
1366
+ const id = `t-${i}`;
1367
+ doc.model.tables.push({
1368
+ id,
1369
+ logicalName: `엔티티${i}`,
1370
+ physicalName: `TB_T${i}`,
1371
+ columns: [
1372
+ createColumn(dialect, {
1373
+ id: `${id}-pk`,
1374
+ logicalName: "식별자",
1375
+ logicalType: "NUMBER",
1376
+ nullable: false,
1377
+ }),
1378
+ ],
1379
+ });
1380
+ doc.layout.nodePositions[id] = {
1381
+ x: (i % cols) * 220,
1382
+ y: Math.floor(i / cols) * 120,
1383
+ };
1384
+ }
1385
+ return doc;
1386
+ }