@rdbms-erd/core 0.1.0 → 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/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # @rdbms-erd/core
2
+
3
+ `@rdbms-erd/core` is the headless model/DDL package for rdbms-erd.
4
+ It provides document types, validation/serialization, logical->physical type defaults,
5
+ DDL generation, diagnostics, and host-extensible database metadata.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm i @rdbms-erd/core
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```ts
16
+ import { createEmptyDesign, createColumn, generateDdl } from "@rdbms-erd/core";
17
+
18
+ const doc = createEmptyDesign("postgres");
19
+ doc.model.tables.push({
20
+ id: "table-users",
21
+ logicalName: "Users",
22
+ physicalName: "users",
23
+ columns: [
24
+ createColumn("postgres", {
25
+ id: "col-users-id",
26
+ logicalName: "ID",
27
+ physicalName: "id",
28
+ logicalType: "NUMBER",
29
+ nullable: false,
30
+ isPrimaryKey: true,
31
+ }),
32
+ ],
33
+ });
34
+
35
+ console.log(generateDdl(doc));
36
+ ```
37
+
38
+ ## Core Concepts
39
+
40
+ - `DesignDocument`: ER JSON source of truth
41
+ - `RdbmsDialect`: dialect id stored in document
42
+ - `LogicalDataType`: normalized logical type ids
43
+ - `DialectMetaJson`: host-overridable per-dialect metadata
44
+ - `DdlGeneratorHook`: optional per-dialect SQL generation hook
45
+
46
+ ## Relationship Model Notes
47
+
48
+ `RelationshipModel` supports canvas-specific rendering state used by the designer:
49
+
50
+ - `cardinality?: "1:1" | "1:N"`
51
+ - `canvasLineHidden?: boolean`
52
+ - `linePivotRatio?: number` (middle vertical segment ratio)
53
+ - `sourceLineY?: number` (source edge absolute Y inside table)
54
+ - `sourceLineRatio?: number` (legacy fallback; kept for compatibility)
55
+
56
+ ## Host-Extensible DB Metadata
57
+
58
+ ### 1) Override/append dialect metadata with JSON
59
+
60
+ Use `hostMetas` in options. Merge policy:
61
+
62
+ - same `id`: override builtin
63
+ - new `id`: append
64
+
65
+ ```ts
66
+ import { resolveDialectMetas } from "@rdbms-erd/core";
67
+
68
+ const metas = resolveDialectMetas({
69
+ hostMetas: [
70
+ {
71
+ id: "acme",
72
+ label: "AcmeDB",
73
+ supportsSchema: true,
74
+ logicalTypes: [
75
+ { id: "TEXT", defaultPhysicalType: "STRING(255)" },
76
+ { id: "NUMBER", defaultPhysicalType: "INT64" },
77
+ ],
78
+ ddlStyle: { quote: "double", boolLiteral: "oneZero" },
79
+ },
80
+ ],
81
+ });
82
+ ```
83
+
84
+ ### 2) Optional per-dialect DDL function hook
85
+
86
+ If a dialect DDL is complex, provide a function in `hostDdlGenerators`.
87
+ If omitted, style-based builtin SQL generation is used.
88
+
89
+ ```ts
90
+ import {
91
+ generateDdlForSelection,
92
+ type DdlGenerateInput,
93
+ } from "@rdbms-erd/core";
94
+
95
+ const sql = generateDdlForSelection(doc, ["table-users"], {
96
+ hostDdlGenerators: {
97
+ acme: (input: DdlGenerateInput) => {
98
+ // input.scope.kind: "all" | "selected"
99
+ return { sql: "-- custom acme ddl" };
100
+ },
101
+ },
102
+ fallbackOnHookError: true,
103
+ }).sql;
104
+ ```
105
+
106
+ ## Main APIs
107
+
108
+ - Document lifecycle:
109
+ - `createEmptyDesign`
110
+ - `serializeDesign`, `parseDesign`, `validateDesignDocument`, `roundTripDesign`
111
+ - Type defaults:
112
+ - `defaultPhysicalType`
113
+ - `createColumn`
114
+ - `applyLogicalTypeChange`
115
+ - `convertDesignDialect`
116
+ - DDL:
117
+ - `generateDdl`
118
+ - `generateDdlForSelection`
119
+ - `generateIndexDdl`
120
+ - `generateDdlWithDiagnostics`
121
+ - `generateIndexDdlWithDiagnostics`
122
+ - Metadata:
123
+ - `resolveDialectMetas`
124
+ - `mergeDialectMetas`
125
+ - `createDefaultDbMetaAdapter` (legacy/advanced adapter path)
126
+
127
+ ## Notes
128
+
129
+ - DDL function hooks are runtime values and are **not serialized** in `DesignDocument`.
130
+ - `DesignDocument` stores only dialect id and model data.
131
+
132
+ ## License
133
+
134
+ MIT
package/package.json CHANGED
@@ -1,11 +1,19 @@
1
- {
2
- "name": "@rdbms-erd/core",
3
- "version": "0.1.0",
4
- "license": "MIT",
5
- "type": "module",
6
- "main": "./src/index.ts",
7
- "types": "./src/index.ts",
8
- "exports": {
9
- ".": "./src/index.ts"
10
- }
11
- }
1
+ {
2
+ "name": "@rdbms-erd/core",
3
+ "version": "0.1.2",
4
+ "license": "MIT",
5
+ "keywords": [
6
+ "erd",
7
+ "database",
8
+ "schema",
9
+ "ddl",
10
+ "sql",
11
+ "rdbms"
12
+ ],
13
+ "type": "module",
14
+ "main": "./src/index.ts",
15
+ "types": "./src/index.ts",
16
+ "exports": {
17
+ ".": "./src/index.ts"
18
+ }
19
+ }
@@ -1,19 +1,19 @@
1
- import { describe, expect, it } from "vitest";
2
- import { alignNodePositions } from "../alignment";
3
-
4
- describe("alignNodePositions", () => {
5
- it("aligns left to min x", () => {
6
- const positions = { a: { x: 10, y: 0 }, b: { x: 50, y: 0 } };
7
- const out = alignNodePositions(["a", "b"], positions, "left");
8
- expect(out.a.x).toBe(10);
9
- expect(out.b.x).toBe(10);
10
- });
11
-
12
- it("distributes horizontal gap", () => {
13
- const positions = { a: { x: 0, y: 0 }, b: { x: 100, y: 0 }, c: { x: 200, y: 0 } };
14
- const out = alignNodePositions(["a", "b", "c"], positions, "h-gap");
15
- expect(out.a.x).toBe(0);
16
- expect(out.b.x).toBe(100);
17
- expect(out.c.x).toBe(200);
18
- });
19
- });
1
+ import { describe, expect, it } from "vitest";
2
+ import { alignNodePositions } from "../alignment";
3
+
4
+ describe("alignNodePositions", () => {
5
+ it("aligns left to min x", () => {
6
+ const positions = { a: { x: 10, y: 0 }, b: { x: 50, y: 0 } };
7
+ const out = alignNodePositions(["a", "b"], positions, "left");
8
+ expect(out.a.x).toBe(10);
9
+ expect(out.b.x).toBe(10);
10
+ });
11
+
12
+ it("distributes horizontal gap", () => {
13
+ const positions = { a: { x: 0, y: 0 }, b: { x: 100, y: 0 }, c: { x: 200, y: 0 } };
14
+ const out = alignNodePositions(["a", "b", "c"], positions, "h-gap");
15
+ expect(out.a.x).toBe(0);
16
+ expect(out.b.x).toBe(100);
17
+ expect(out.c.x).toBe(200);
18
+ });
19
+ });
@@ -1,14 +1,14 @@
1
- import { describe, expect, it } from "vitest";
2
- import { createLargeDesign, roundTripDesign, serializeDesign } from "../index";
3
-
4
- describe("large model (500 tables)", () => {
5
- it("serializes and round-trips within reasonable time", () => {
6
- const doc = createLargeDesign(500, "postgres");
7
- const t0 = performance.now();
8
- const json = serializeDesign(doc);
9
- const parsed = roundTripDesign(JSON.parse(json) as typeof doc);
10
- const ms = performance.now() - t0;
11
- expect(parsed.model.tables).toHaveLength(500);
12
- expect(ms).toBeLessThan(8000);
13
- });
14
- });
1
+ import { describe, expect, it } from "vitest";
2
+ import { createLargeDesign, roundTripDesign, serializeDesign } from "../index";
3
+
4
+ describe("large model (500 tables)", () => {
5
+ it("serializes and round-trips within reasonable time", () => {
6
+ const doc = createLargeDesign(500, "postgres");
7
+ const t0 = performance.now();
8
+ const json = serializeDesign(doc);
9
+ const parsed = roundTripDesign(JSON.parse(json) as typeof doc);
10
+ const ms = performance.now() - t0;
11
+ expect(parsed.model.tables).toHaveLength(500);
12
+ expect(ms).toBeLessThan(8000);
13
+ });
14
+ });
@@ -0,0 +1,166 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createColumn,
4
+ createDefaultDbMetaAdapter,
5
+ createEmptyDesign,
6
+ generateDdl,
7
+ generateDdlForSelection,
8
+ LOGICAL_DATA_TYPES,
9
+ resolveDialectMetas,
10
+ serializeDesign,
11
+ validateDesignDocument,
12
+ type DdlGenerateInput,
13
+ type DdlGenerateOutput,
14
+ type DbMetaAdapter,
15
+ type DialectMetaJson,
16
+ type DialectMeta,
17
+ type LogicalDataType
18
+ } from "../index";
19
+
20
+ function createAcmeAdapter(): DbMetaAdapter {
21
+ const base = createDefaultDbMetaAdapter();
22
+ const acmeTypeMap: Record<LogicalDataType, string> = {
23
+ TEXT: "STRING(255)",
24
+ DATE: "DATE",
25
+ TIME: "TIME",
26
+ DATETIME: "TIMESTAMP",
27
+ NUMBER: "INT64",
28
+ DECIMAL: "DECIMAL(10,2)",
29
+ FLOAT: "DOUBLE",
30
+ BOOLEAN: "BOOL",
31
+ JSON: "JSON",
32
+ UUID: "UUID",
33
+ BINARY: "BYTES"
34
+ };
35
+ const acmeMeta: DialectMeta = {
36
+ id: "acme",
37
+ label: "AcmeDB",
38
+ capabilities: { supportsSchema: true },
39
+ logicalTypes: LOGICAL_DATA_TYPES,
40
+ defaultPhysicalTypeMap: acmeTypeMap
41
+ };
42
+ return {
43
+ listDialects: () => [...base.listDialects(), acmeMeta],
44
+ getDialectMeta: (dialect) => (dialect === "acme" ? acmeMeta : base.getDialectMeta(dialect)),
45
+ getDefaultPhysicalType: (dialect, logicalType) =>
46
+ dialect === "acme" ? acmeTypeMap[logicalType] : base.getDefaultPhysicalType(dialect, logicalType),
47
+ getDdlRules: (dialect) =>
48
+ dialect === "acme"
49
+ ? {
50
+ ...base.getDdlRules(dialect),
51
+ quoteIdentifier: (_d, id) => `<${id}>`
52
+ }
53
+ : base.getDdlRules(dialect)
54
+ };
55
+ }
56
+
57
+ describe("db meta adapter", () => {
58
+ it("allows host dialect for createColumn default physical type", () => {
59
+ const adapter = createAcmeAdapter();
60
+ const col = createColumn(
61
+ "acme",
62
+ {
63
+ id: "c1",
64
+ logicalName: "name",
65
+ logicalType: "TEXT"
66
+ },
67
+ { dbMetaAdapter: adapter }
68
+ );
69
+ expect(col.physicalType).toBe("STRING(255)");
70
+ });
71
+
72
+ it("applies host quote rule and schema support to DDL", () => {
73
+ const adapter = createAcmeAdapter();
74
+ const doc = createEmptyDesign("acme");
75
+ doc.model.tables.push({
76
+ id: "t1",
77
+ logicalName: "사용자",
78
+ physicalName: "users",
79
+ schemaName: "core",
80
+ columns: [
81
+ createColumn(
82
+ "acme",
83
+ {
84
+ id: "id1",
85
+ logicalName: "id",
86
+ physicalName: "id",
87
+ logicalType: "NUMBER",
88
+ nullable: false,
89
+ isPrimaryKey: true
90
+ },
91
+ { dbMetaAdapter: adapter }
92
+ )
93
+ ]
94
+ });
95
+
96
+ const sql = generateDdl(doc, { dbMetaAdapter: adapter });
97
+ expect(sql).toContain("CREATE TABLE <core>.<users>");
98
+ expect(sql).toContain("<id> INT64");
99
+ });
100
+
101
+ it("validates custom dialect when adapter provides it", () => {
102
+ const adapter = createAcmeAdapter();
103
+ const doc = createEmptyDesign("acme");
104
+ const raw = JSON.parse(serializeDesign(doc));
105
+ expect(() => validateDesignDocument(raw, { dbMetaAdapter: adapter })).not.toThrow();
106
+ });
107
+
108
+ it("merges host metas by id (override/append)", () => {
109
+ const hostMetas: DialectMetaJson[] = [
110
+ {
111
+ id: "postgres",
112
+ label: "Postgres Override",
113
+ supportsSchema: true,
114
+ logicalTypes: [{ id: "TEXT", defaultPhysicalType: "TEXT" }]
115
+ },
116
+ {
117
+ id: "acme_json",
118
+ label: "Acme JSON",
119
+ supportsSchema: true,
120
+ logicalTypes: [{ id: "TEXT", defaultPhysicalType: "STRING(255)" }]
121
+ }
122
+ ];
123
+ const resolved = resolveDialectMetas({ hostMetas });
124
+ expect(resolved.find((m) => m.id === "postgres")?.label).toBe("Postgres Override");
125
+ expect(resolved.some((m) => m.id === "acme_json")).toBe(true);
126
+ });
127
+
128
+ it("calls host ddl generator with selected scope", () => {
129
+ const doc = createEmptyDesign("postgres");
130
+ doc.model.tables.push(
131
+ { id: "t1", logicalName: "A", physicalName: "a", columns: [createColumn("postgres", { id: "c1", logicalName: "id", logicalType: "NUMBER", nullable: false, isPrimaryKey: true })] },
132
+ { id: "t2", logicalName: "B", physicalName: "b", columns: [createColumn("postgres", { id: "c2", logicalName: "id", logicalType: "NUMBER", nullable: false, isPrimaryKey: true })] }
133
+ );
134
+ const calls: DdlGenerateInput[] = [];
135
+ const hostGenerator = (input: DdlGenerateInput): DdlGenerateOutput => {
136
+ calls.push(input);
137
+ return { sql: "-- custom" };
138
+ };
139
+ const out = generateDdlForSelection(doc, ["t1"], {
140
+ hostDdlGenerators: { postgres: hostGenerator }
141
+ });
142
+ expect(out.sql).toBe("-- custom");
143
+ expect(calls).toHaveLength(1);
144
+ expect(calls[0].scope.kind).toBe("selected");
145
+ });
146
+
147
+ it("falls back to style generator when host hook throws", () => {
148
+ const doc = createEmptyDesign("postgres");
149
+ doc.model.tables.push({
150
+ id: "t1",
151
+ logicalName: "사용자",
152
+ physicalName: "users",
153
+ columns: [createColumn("postgres", { id: "c1", logicalName: "id", logicalType: "NUMBER", nullable: false, isPrimaryKey: true })]
154
+ });
155
+ const sql = generateDdl(doc, {
156
+ hostDdlGenerators: {
157
+ postgres: () => {
158
+ throw new Error("boom");
159
+ }
160
+ },
161
+ fallbackOnHookError: true
162
+ });
163
+ expect(sql).toContain("CREATE TABLE");
164
+ expect(sql).toContain("\"users\"");
165
+ });
166
+ });
@@ -1,52 +1,52 @@
1
- import { describe, expect, it } from "vitest";
2
- import { analyzeDdlDocument, createColumn, createEmptyDesign, formatDdlDiagnostic, generateDdlWithDiagnostics } from "../index";
3
-
4
- describe("analyzeDdlDocument / formatDdlDiagnostic", () => {
5
- it("formats diagnostics with unified template", () => {
6
- const line = formatDdlDiagnostic({
7
- severity: "warning",
8
- code: "DDL_REL_MISSING_COLUMNS",
9
- message: "소스/타겟 컬럼이 지정되지 않아 FK DDL을 생략한다.",
10
- context: "relationship:r1"
11
- });
12
- expect(line).toBe(
13
- "[WARN][DDL_REL_MISSING_COLUMNS] 소스/타겟 컬럼이 지정되지 않아 FK DDL을 생략한다. | relationship:r1"
14
- );
15
- });
16
-
17
- it("warns on relationship without column ids", () => {
18
- const doc = createEmptyDesign("postgres");
19
- doc.model.tables.push({
20
- id: "a",
21
- logicalName: "A",
22
- physicalName: "TA",
23
- columns: [createColumn("postgres", { id: "c1", logicalName: "x", logicalType: "TEXT" })]
24
- });
25
- doc.model.tables.push({
26
- id: "b",
27
- logicalName: "B",
28
- physicalName: "TB",
29
- columns: [createColumn("postgres", { id: "c2", logicalName: "y", logicalType: "TEXT" })]
30
- });
31
- doc.model.relationships.push({
32
- id: "r1",
33
- sourceTableId: "a",
34
- targetTableId: "b"
35
- });
36
- const d = analyzeDdlDocument(doc);
37
- expect(d.some((x) => x.code === "DDL_REL_MISSING_COLUMNS")).toBe(true);
38
- });
39
-
40
- it("generateDdlWithDiagnostics returns sql and same analyze list", () => {
41
- const doc = createEmptyDesign("mysql");
42
- doc.model.tables.push({
43
- id: "t1",
44
- logicalName: "T",
45
- physicalName: "TT",
46
- columns: [createColumn("mysql", { id: "c1", logicalName: "id", logicalType: "NUMBER", nullable: false })]
47
- });
48
- const { sql, diagnostics } = generateDdlWithDiagnostics(doc);
49
- expect(sql).toContain("CREATE TABLE");
50
- expect(diagnostics.length).toBeGreaterThanOrEqual(0);
51
- });
52
- });
1
+ import { describe, expect, it } from "vitest";
2
+ import { analyzeDdlDocument, createColumn, createEmptyDesign, formatDdlDiagnostic, generateDdlWithDiagnostics } from "../index";
3
+
4
+ describe("analyzeDdlDocument / formatDdlDiagnostic", () => {
5
+ it("formats diagnostics with unified template", () => {
6
+ const line = formatDdlDiagnostic({
7
+ severity: "warning",
8
+ code: "DDL_REL_MISSING_COLUMNS",
9
+ message: "소스/타겟 컬럼이 지정되지 않아 FK DDL을 생략한다.",
10
+ context: "relationship:r1"
11
+ });
12
+ expect(line).toBe(
13
+ "[WARN][DDL_REL_MISSING_COLUMNS] 소스/타겟 컬럼이 지정되지 않아 FK DDL을 생략한다. | relationship:r1"
14
+ );
15
+ });
16
+
17
+ it("warns on relationship without column ids", () => {
18
+ const doc = createEmptyDesign("postgres");
19
+ doc.model.tables.push({
20
+ id: "a",
21
+ logicalName: "A",
22
+ physicalName: "TA",
23
+ columns: [createColumn("postgres", { id: "c1", logicalName: "x", logicalType: "TEXT" })]
24
+ });
25
+ doc.model.tables.push({
26
+ id: "b",
27
+ logicalName: "B",
28
+ physicalName: "TB",
29
+ columns: [createColumn("postgres", { id: "c2", logicalName: "y", logicalType: "TEXT" })]
30
+ });
31
+ doc.model.relationships.push({
32
+ id: "r1",
33
+ sourceTableId: "a",
34
+ targetTableId: "b"
35
+ });
36
+ const d = analyzeDdlDocument(doc);
37
+ expect(d.some((x) => x.code === "DDL_REL_MISSING_COLUMNS")).toBe(true);
38
+ });
39
+
40
+ it("generateDdlWithDiagnostics returns sql and same analyze list", () => {
41
+ const doc = createEmptyDesign("mysql");
42
+ doc.model.tables.push({
43
+ id: "t1",
44
+ logicalName: "T",
45
+ physicalName: "TT",
46
+ columns: [createColumn("mysql", { id: "c1", logicalName: "id", logicalType: "NUMBER", nullable: false })]
47
+ });
48
+ const { sql, diagnostics } = generateDdlWithDiagnostics(doc);
49
+ expect(sql).toContain("CREATE TABLE");
50
+ expect(diagnostics.length).toBeGreaterThanOrEqual(0);
51
+ });
52
+ });