@rdbms-erd/core 0.1.1 → 0.1.2

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,334 +1,1047 @@
1
- export type RdbmsDialect = "mssql" | "oracle" | "mysql" | "postgres";
1
+ export type BuiltinRdbmsDialect =
2
+ | "mssql"
3
+ | "oracle"
4
+ | "mysql"
5
+ | "postgres"
6
+ | "sqlite";
7
+ export type RdbmsDialect = BuiltinRdbmsDialect | (string & {});
2
8
 
3
9
  export { alignNodePositions, type AlignCommand } from "./alignment";
4
10
 
5
- export type LogicalDataType = "TEXT" | "DATE" | "DATETIME" | "NUMBER" | "FLOAT";
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];
6
26
 
7
27
  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;
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;
22
42
  }
23
43
 
24
44
  export interface TableModel {
25
- id: string;
26
- logicalName: string;
27
- physicalName: string;
28
- color?: string;
29
- columns: ColumnModel[];
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[];
30
54
  }
31
55
 
32
56
  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;
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;
46
80
  }
47
81
 
48
82
  /**
49
- * 캔버스에 관계선을 그릴지 여부(전역 표시 + FK 컬럼의 라인 표시 옵션).
83
+ * 캔버스에 관계선을 그릴지 여부.
84
+ * - `revealHiddenLines === false`: `canvasLineHidden`인 관계는 제외.
85
+ * - `revealHiddenLines === true`: 숨김 처리된 관계도 함께 그린다(표시만, 속성은 바꾸지 않음).
50
86
  */
51
87
  export function isRelationshipLineRenderable(
52
- rel: RelationshipModel,
53
- model: DesignModel,
54
- globalLinesVisible: boolean
88
+ rel: RelationshipModel,
89
+ revealHiddenLines: boolean,
55
90
  ): 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;
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
+ }
63
117
  }
64
118
 
65
119
  export interface IndexModel {
66
- id: string;
67
- tableId: string;
68
- name: string;
69
- columns: string[];
70
- unique: boolean;
120
+ id: string;
121
+ tableId: string;
122
+ name: string;
123
+ columns: string[];
124
+ unique: boolean;
71
125
  }
72
126
 
73
127
  export interface DiagramLayout {
74
- nodePositions: Record<string, { x: number; y: number }>;
128
+ nodePositions: Record<string, { x: number; y: number }>;
75
129
  }
76
130
 
77
131
  export interface DesignModel {
78
- dialect: RdbmsDialect;
79
- tables: TableModel[];
80
- relationships: RelationshipModel[];
81
- indexes: IndexModel[];
132
+ dialect: RdbmsDialect;
133
+ tables: TableModel[];
134
+ relationships: RelationshipModel[];
135
+ indexes: IndexModel[];
82
136
  }
83
137
 
84
138
  export interface DesignDocument {
85
- schemaVersion: number;
86
- model: DesignModel;
87
- layout: DiagramLayout;
88
- settings?: Record<string, unknown>;
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;
89
222
  }
90
223
 
91
224
  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
- }
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
+ },
124
352
  };
125
353
 
126
- export function defaultPhysicalType(dialect: RdbmsDialect, logicalType: LogicalDataType): string {
127
- return DIALECT_DEFAULT_TYPE_MAP[dialect][logicalType];
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
+ };
128
686
  }
129
687
 
130
688
  export function applyLogicalTypeChange(
131
- column: ColumnModel,
132
- nextLogicalType: LogicalDataType,
133
- dialect: RdbmsDialect
689
+ column: ColumnModel,
690
+ nextLogicalType: LogicalDataType,
691
+ dialect: RdbmsDialect,
692
+ options?: CoreDbMetaOptions,
134
693
  ): ColumnModel {
135
- return {
136
- ...column,
137
- logicalType: nextLogicalType,
138
- physicalType: defaultPhysicalType(dialect, nextLogicalType)
139
- };
694
+ return {
695
+ ...column,
696
+ logicalType: nextLogicalType,
697
+ physicalType: defaultPhysicalType(dialect, nextLogicalType, options),
698
+ };
140
699
  }
141
700
 
142
701
  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: []
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;
187
716
  },
188
- layout: {
189
- nodePositions: {}
190
- }
191
- };
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
+ };
192
754
  }
193
755
 
194
756
  export function serializeDesign(doc: DesignDocument): string {
195
- return JSON.stringify(doc, null, 2);
757
+ return JSON.stringify(doc, null, 2);
196
758
  }
197
759
 
198
- export function parseDesign(json: string): DesignDocument {
199
- const parsed = JSON.parse(json) as unknown;
200
- return validateDesignDocument(parsed);
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);
201
766
  }
202
767
 
203
- export function validateDesignDocument(input: unknown): DesignDocument {
204
- if (!isObject(input)) {
205
- throw new Error("Invalid design document: root must be object");
206
- }
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
+ }
207
775
 
208
- if (input.schemaVersion !== 1) {
209
- throw new Error("Unsupported schemaVersion");
210
- }
776
+ if (input.schemaVersion !== 1) {
777
+ throw new Error("Unsupported schemaVersion");
778
+ }
211
779
 
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
- }
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
+ }
215
788
 
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
- }
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
+ }
221
802
 
222
- if (!isObject(input.layout) || !isObject(input.layout.nodePositions)) {
223
- throw new Error("Invalid design document: layout is malformed");
224
- }
803
+ if (!isObject(input.layout) || !isObject(input.layout.nodePositions)) {
804
+ throw new Error("Invalid design document: layout is malformed");
805
+ }
225
806
 
226
- return input as unknown as DesignDocument;
807
+ const doc = input as unknown as DesignDocument;
808
+ migrateLegacyFkLineVisibility(doc.model);
809
+ return doc;
227
810
  }
228
811
 
229
812
  export function roundTripDesign(doc: DesignDocument): DesignDocument {
230
- return parseDesign(serializeDesign(doc));
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);
231
825
  }
232
826
 
233
- function quoteIdentifier(dialect: RdbmsDialect, identifier: string): string {
234
- if (dialect === "mssql") return `[${identifier}]`;
235
- if (dialect === "mysql") return `\`${identifier}\``;
236
- return `"${identifier}"`;
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);
237
837
  }
238
838
 
239
839
  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);`;
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);`;
278
881
  }
279
882
 
280
883
  function createRelationshipSql(
281
- rel: RelationshipModel,
282
- model: DesignModel,
283
- dialect: RdbmsDialect,
284
- index: number
884
+ rel: RelationshipModel,
885
+ model: DesignModel,
886
+ dialect: RdbmsDialect,
887
+ index: number,
888
+ options?: CoreDbMetaOptions,
285
889
  ): 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");
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
+ }
332
1045
  }
333
1046
 
334
1047
  /** DDL/인덱스 분석 공통 심각도 */
@@ -336,11 +1049,11 @@ export type DdlDiagnosticSeverity = "error" | "warning";
336
1049
 
337
1050
  /** 구조화된 DDL 관련 진단(에러·경고) */
338
1051
  export interface DdlDiagnostic {
339
- severity: DdlDiagnosticSeverity;
340
- code: string;
341
- message: string;
342
- /** 예: relationship:rel1, index:ix1, table:t1 */
343
- context?: string;
1052
+ severity: DdlDiagnosticSeverity;
1053
+ code: string;
1054
+ message: string;
1055
+ /** 예: relationship:rel1, index:ix1, table:t1 */
1056
+ context?: string;
344
1057
  }
345
1058
 
346
1059
  /**
@@ -348,159 +1061,212 @@ export interface DdlDiagnostic {
348
1061
  * 예: `[WARN][DDL_REL_MISSING_COLUMNS] ... | relationship:rel1`
349
1062
  */
350
1063
  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}`;
1064
+ const level = d.severity === "error" ? "ERROR" : "WARN";
1065
+ const tail = d.context ? ` | ${d.context}` : "";
1066
+ return `[${level}][${d.code}] ${d.message}${tail}`;
354
1067
  }
355
1068
 
356
1069
  export function formatDdlDiagnostics(diagnostics: DdlDiagnostic[]): string {
357
- return diagnostics.map(formatDdlDiagnostic).join("\n");
1070
+ return diagnostics.map(formatDdlDiagnostic).join("\n");
358
1071
  }
359
1072
 
360
1073
  /**
361
1074
  * 모델 전체에 대한 DDL·인덱스 관련 진단(테이블/관계/인덱스).
362
1075
  * 검증 예외(Invalid design document)와는 별도로, 생성 가능 여부와 경고를 나열한다.
363
1076
  */
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
- });
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
+ }
426
1104
  }
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;
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
+ }
440
1115
  }
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;
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
+ }
449
1155
  }
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
- }
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
+ }
459
1189
  }
460
- }
461
1190
 
462
- return out;
1191
+ return out;
463
1192
  }
464
1193
 
465
- export function generateDdlWithDiagnostics(doc: DesignDocument): { sql: string; diagnostics: DdlDiagnostic[] } {
466
- return { sql: buildDdlSql(doc), diagnostics: analyzeDdlDocument(doc) };
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
+ };
467
1203
  }
468
1204
 
469
- export function generateIndexDdlWithDiagnostics(doc: DesignDocument): { sql: string; diagnostics: DdlDiagnostic[] } {
470
- return { sql: buildIndexDdlSql(doc), diagnostics: analyzeDdlDocument(doc) };
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
+ };
471
1213
  }
472
1214
 
473
- export function generateDdl(doc: DesignDocument): string {
474
- return buildDdlSql(doc);
1215
+ export function generateDdl(
1216
+ doc: DesignDocument,
1217
+ options?: CoreDbMetaOptions,
1218
+ ): string {
1219
+ return runDdlGenerator(doc, { kind: "all" }, options).sql;
475
1220
  }
476
1221
 
477
- export function generateIndexDdl(doc: DesignDocument): string {
478
- return buildIndexDdlSql(doc);
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);
479
1242
  }
480
1243
 
481
1244
  /** 성능·부하 테스트용: 지정 개수의 빈 테이블과 격자 레이아웃을 생성한다. */
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;
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;
506
1272
  }