erdify 0.3.0__py3-none-any.whl
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.
- erdify/__init__.py +27 -0
- erdify/__main__.py +6 -0
- erdify/cli.py +114 -0
- erdify/config.py +41 -0
- erdify/generator.py +322 -0
- erdify/parser.py +519 -0
- erdify/py.typed +0 -0
- erdify-0.3.0.dist-info/METADATA +637 -0
- erdify-0.3.0.dist-info/RECORD +12 -0
- erdify-0.3.0.dist-info/WHEEL +4 -0
- erdify-0.3.0.dist-info/entry_points.txt +3 -0
- erdify-0.3.0.dist-info/licenses/LICENSE +21 -0
erdify/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""erdify - Generate PlantUML ERD diagrams from your models."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .config import EntityInfo, EnumInfo, FieldInfo
|
|
6
|
+
from .generator import PlantUMLGenerator, generate_plantuml
|
|
7
|
+
from .parser import ASTDatabaseParser, parse_models_directory
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("erdify")
|
|
11
|
+
except PackageNotFoundError: # package is not installed (e.g. running from a raw checkout)
|
|
12
|
+
__version__ = "0.0.0+unknown"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
# Data classes
|
|
16
|
+
"FieldInfo",
|
|
17
|
+
"EnumInfo",
|
|
18
|
+
"EntityInfo",
|
|
19
|
+
# Parser
|
|
20
|
+
"ASTDatabaseParser",
|
|
21
|
+
"parse_models_directory",
|
|
22
|
+
# Generator
|
|
23
|
+
"PlantUMLGenerator",
|
|
24
|
+
"generate_plantuml",
|
|
25
|
+
# Version
|
|
26
|
+
"__version__",
|
|
27
|
+
]
|
erdify/__main__.py
ADDED
erdify/cli.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Command-line interface for erdify."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .generator import generate_plantuml
|
|
9
|
+
from .parser import parse_models_directory
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> int:
|
|
13
|
+
"""Main entry point for the CLI."""
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="erdify",
|
|
16
|
+
description="Generate PlantUML ERD diagrams from SQLModel and SQLAlchemy models",
|
|
17
|
+
epilog="Example: erdify ./src/database -o database_erd.puml",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"input",
|
|
21
|
+
type=Path,
|
|
22
|
+
help="Directory containing model files (searches for models.py recursively)",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"-o",
|
|
26
|
+
"--output",
|
|
27
|
+
type=Path,
|
|
28
|
+
help="Output .puml file (default: stdout)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--title",
|
|
32
|
+
default="Database ERD",
|
|
33
|
+
help="Diagram title (default: 'Database ERD')",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--exclude",
|
|
37
|
+
nargs="*",
|
|
38
|
+
default=[],
|
|
39
|
+
metavar="PATTERN",
|
|
40
|
+
help=(
|
|
41
|
+
"Glob patterns (case-sensitive) to exclude entities by class name "
|
|
42
|
+
"or table name, e.g. --exclude '*Link' audit_log"
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--infer-keys",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help=(
|
|
49
|
+
"For keyless models (Pydantic/dataclass), infer a primary key from a "
|
|
50
|
+
"field named 'id' and a foreign key from '<x>_id' (target table '<x>')"
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--no-enums",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="Skip enum definitions in output",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--no-relationships",
|
|
60
|
+
action="store_true",
|
|
61
|
+
help="Skip relationship lines in output",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"-v",
|
|
65
|
+
"--version",
|
|
66
|
+
action="version",
|
|
67
|
+
version=f"%(prog)s {__version__}",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
args = parser.parse_args()
|
|
71
|
+
|
|
72
|
+
# Validate input path
|
|
73
|
+
if not args.input.exists():
|
|
74
|
+
print(f"Error: Input path does not exist: {args.input}", file=sys.stderr)
|
|
75
|
+
return 1
|
|
76
|
+
|
|
77
|
+
if not args.input.is_dir():
|
|
78
|
+
print(f"Error: Input path is not a directory: {args.input}", file=sys.stderr)
|
|
79
|
+
return 1
|
|
80
|
+
|
|
81
|
+
# Parse models
|
|
82
|
+
try:
|
|
83
|
+
entities, enums = parse_models_directory(
|
|
84
|
+
args.input, args.exclude, infer_keys=args.infer_keys
|
|
85
|
+
)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
print(f"Error parsing models: {e}", file=sys.stderr)
|
|
88
|
+
return 1
|
|
89
|
+
|
|
90
|
+
if not entities:
|
|
91
|
+
print(
|
|
92
|
+
f"Warning: No tables found in {args.input}",
|
|
93
|
+
file=sys.stderr,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Generate PlantUML
|
|
97
|
+
output = generate_plantuml(
|
|
98
|
+
entities=entities,
|
|
99
|
+
enums=enums,
|
|
100
|
+
title=args.title,
|
|
101
|
+
include_enums=not args.no_enums,
|
|
102
|
+
include_relationships=not args.no_relationships,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Write output
|
|
106
|
+
if args.output:
|
|
107
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
args.output.write_text(output)
|
|
109
|
+
print(f"Generated ERD diagram: {args.output}", file=sys.stderr)
|
|
110
|
+
print(f" Found {len(entities)} entities and {len(enums)} enums", file=sys.stderr)
|
|
111
|
+
else:
|
|
112
|
+
print(output)
|
|
113
|
+
|
|
114
|
+
return 0
|
erdify/config.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Data classes for erdify."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Tuple
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class FieldInfo:
|
|
9
|
+
"""Represents a database field/column."""
|
|
10
|
+
|
|
11
|
+
name: str
|
|
12
|
+
type_str: str
|
|
13
|
+
is_primary_key: bool = False
|
|
14
|
+
is_foreign_key: bool = False
|
|
15
|
+
is_nullable: bool = False
|
|
16
|
+
foreign_table: str | None = None
|
|
17
|
+
index: bool = False
|
|
18
|
+
default_value: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class EnumInfo:
|
|
23
|
+
"""Represents an enum type."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
values: List[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class EntityInfo:
|
|
31
|
+
"""Represents a database table/entity."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
table_name: str
|
|
35
|
+
fields: List[FieldInfo] = field(default_factory=list)
|
|
36
|
+
relationships: List[Tuple[str, str, str]] = field(
|
|
37
|
+
default_factory=list
|
|
38
|
+
) # (target, type, attribute_name)
|
|
39
|
+
is_link_table: bool = False
|
|
40
|
+
base_classes: List[str] = field(default_factory=list)
|
|
41
|
+
source: str = "sqlmodel" # one of: sqlmodel, sqlalchemy, pydantic, dataclass
|
erdify/generator.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""PlantUML ERD diagram generator."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List, Tuple
|
|
4
|
+
|
|
5
|
+
from .config import EntityInfo, EnumInfo, FieldInfo
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PlantUMLGenerator:
|
|
9
|
+
"""Generates PlantUML ERD diagram from entities."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
entities: Dict[str, EntityInfo],
|
|
14
|
+
enums: Dict[str, EnumInfo] | None = None,
|
|
15
|
+
title: str = "Database ERD",
|
|
16
|
+
):
|
|
17
|
+
self.entities = entities
|
|
18
|
+
self.enums = enums or {}
|
|
19
|
+
self.title = title
|
|
20
|
+
|
|
21
|
+
def generate(self) -> str:
|
|
22
|
+
"""Generate PlantUML diagram."""
|
|
23
|
+
lines = [
|
|
24
|
+
f"@startuml {self.title}",
|
|
25
|
+
'!define Table(name,desc) class name as "desc" << (T,#FFAAAA) >>',
|
|
26
|
+
"!define primary_key(x) <b><color:#b8861b><&key></color> x</b>",
|
|
27
|
+
"!define foreign_key(x) <color:#aaaaaa><&key></color> x",
|
|
28
|
+
"!define column(x) <color:#efefef><&media-record></color> x",
|
|
29
|
+
"",
|
|
30
|
+
"skinparam linetype ortho",
|
|
31
|
+
"skinparam roundcorner 5",
|
|
32
|
+
"skinparam class {",
|
|
33
|
+
" BackgroundColor White",
|
|
34
|
+
" ArrowColor Gray",
|
|
35
|
+
" BorderColor Gray",
|
|
36
|
+
"}",
|
|
37
|
+
"",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# Generate enums (only those used by entities)
|
|
41
|
+
used_enums = self._get_used_enums()
|
|
42
|
+
if used_enums:
|
|
43
|
+
lines.append("' Enums")
|
|
44
|
+
lines.append("")
|
|
45
|
+
for enum_name in sorted(used_enums):
|
|
46
|
+
if enum_name in self.enums:
|
|
47
|
+
lines.extend(self._generate_enum(self.enums[enum_name]))
|
|
48
|
+
lines.append("")
|
|
49
|
+
|
|
50
|
+
lines.append("' Entities")
|
|
51
|
+
lines.append("")
|
|
52
|
+
|
|
53
|
+
# Generate entities
|
|
54
|
+
for entity in self.entities.values():
|
|
55
|
+
lines.extend(self._generate_entity(entity))
|
|
56
|
+
lines.append("")
|
|
57
|
+
|
|
58
|
+
lines.append("' Relationships")
|
|
59
|
+
lines.append("")
|
|
60
|
+
|
|
61
|
+
# Build map of link tables for many-to-many relationships
|
|
62
|
+
link_table_map = self._build_link_table_map()
|
|
63
|
+
|
|
64
|
+
# Generate relationships
|
|
65
|
+
seen_relationships: set[str] = set()
|
|
66
|
+
|
|
67
|
+
# First, generate direct foreign key relationships (not through link tables)
|
|
68
|
+
for entity in self.entities.values():
|
|
69
|
+
if not entity.is_link_table:
|
|
70
|
+
for relationship in self._generate_direct_relationships(entity, link_table_map):
|
|
71
|
+
if relationship not in seen_relationships:
|
|
72
|
+
lines.append(relationship)
|
|
73
|
+
seen_relationships.add(relationship)
|
|
74
|
+
|
|
75
|
+
# Then, generate many-to-many relationships through link tables
|
|
76
|
+
for link_entity in self.entities.values():
|
|
77
|
+
if link_entity.is_link_table:
|
|
78
|
+
for relationship in self._generate_link_table_relationships(link_entity):
|
|
79
|
+
if relationship not in seen_relationships:
|
|
80
|
+
lines.append(relationship)
|
|
81
|
+
seen_relationships.add(relationship)
|
|
82
|
+
|
|
83
|
+
# Finally, draw declared relationships (Relationship()/relationship() and
|
|
84
|
+
# Pydantic/dataclass nested refs) for entity pairs not already connected by a
|
|
85
|
+
# foreign-key line. This gives keyless models their lines while avoiding
|
|
86
|
+
# duplicate lines for SQLModel/SQLAlchemy, whose relationships are already
|
|
87
|
+
# rendered from foreign keys above.
|
|
88
|
+
connected_pairs = self._connected_pairs()
|
|
89
|
+
for entity in self.entities.values():
|
|
90
|
+
for relationship, pair in self._generate_relationship_list_lines(entity):
|
|
91
|
+
if pair in connected_pairs or relationship in seen_relationships:
|
|
92
|
+
continue
|
|
93
|
+
lines.append(relationship)
|
|
94
|
+
seen_relationships.add(relationship)
|
|
95
|
+
connected_pairs.add(pair)
|
|
96
|
+
|
|
97
|
+
lines.append("")
|
|
98
|
+
lines.append("@enduml")
|
|
99
|
+
|
|
100
|
+
return "\n".join(lines)
|
|
101
|
+
|
|
102
|
+
def _generate_entity(self, entity: EntityInfo) -> List[str]:
|
|
103
|
+
"""Generate PlantUML entity definition."""
|
|
104
|
+
lines = []
|
|
105
|
+
|
|
106
|
+
# Entity header
|
|
107
|
+
if entity.is_link_table:
|
|
108
|
+
lines.append(
|
|
109
|
+
f'entity "{entity.table_name}" as {entity.name} << (L, #AAFFAA) link >> {{'
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
lines.append(f'entity "{entity.table_name}" as {entity.name} {{')
|
|
113
|
+
|
|
114
|
+
# Fields
|
|
115
|
+
if entity.fields:
|
|
116
|
+
for field in entity.fields:
|
|
117
|
+
field_line = self._format_field(field)
|
|
118
|
+
lines.append(f" {field_line}")
|
|
119
|
+
else:
|
|
120
|
+
lines.append(" ' (no fields)")
|
|
121
|
+
|
|
122
|
+
lines.append("}")
|
|
123
|
+
|
|
124
|
+
return lines
|
|
125
|
+
|
|
126
|
+
def _generate_enum(self, enum_info: EnumInfo) -> List[str]:
|
|
127
|
+
"""Generate PlantUML enum definition."""
|
|
128
|
+
lines = [f"enum {enum_info.name} << (E,#FFCC00) >> {{"]
|
|
129
|
+
for value in enum_info.values:
|
|
130
|
+
lines.append(f" {value}")
|
|
131
|
+
lines.append("}")
|
|
132
|
+
return lines
|
|
133
|
+
|
|
134
|
+
def _get_used_enums(self) -> "set[str]":
|
|
135
|
+
"""Get set of enum names used by entity fields."""
|
|
136
|
+
used_enums: set[str] = set()
|
|
137
|
+
for entity in self.entities.values():
|
|
138
|
+
for field in entity.fields:
|
|
139
|
+
# Check if field type matches a known enum
|
|
140
|
+
type_name = field.type_str.split(".")[-1]
|
|
141
|
+
if type_name in self.enums:
|
|
142
|
+
used_enums.add(type_name)
|
|
143
|
+
return used_enums
|
|
144
|
+
|
|
145
|
+
def _format_field(self, field: FieldInfo) -> str:
|
|
146
|
+
"""Format a field for PlantUML."""
|
|
147
|
+
# Determine prefix
|
|
148
|
+
if field.is_primary_key:
|
|
149
|
+
prefix = "primary_key"
|
|
150
|
+
elif field.is_foreign_key:
|
|
151
|
+
prefix = "foreign_key"
|
|
152
|
+
else:
|
|
153
|
+
prefix = "column"
|
|
154
|
+
|
|
155
|
+
# Clean up type
|
|
156
|
+
type_str = field.type_str.split(".")[-1] # Get just the class name
|
|
157
|
+
|
|
158
|
+
# Add nullable marker
|
|
159
|
+
nullable = "?" if field.is_nullable else ""
|
|
160
|
+
|
|
161
|
+
# Add default value
|
|
162
|
+
default = ""
|
|
163
|
+
if field.default_value is not None:
|
|
164
|
+
# Shorten enum defaults (OrderStatus.PENDING -> PENDING)
|
|
165
|
+
default_val = field.default_value
|
|
166
|
+
if "." in default_val:
|
|
167
|
+
default_val = default_val.split(".")[-1]
|
|
168
|
+
default = f" = {default_val}"
|
|
169
|
+
|
|
170
|
+
return f"{prefix}({field.name}) : {type_str}{nullable}{default}"
|
|
171
|
+
|
|
172
|
+
def _build_link_table_map(self) -> Dict[Tuple[str, str], str]:
|
|
173
|
+
"""Build a map of (entity1, entity2) -> link_table_name for many-to-many."""
|
|
174
|
+
link_map: Dict[Tuple[str, str], str] = {}
|
|
175
|
+
|
|
176
|
+
for entity in self.entities.values():
|
|
177
|
+
if entity.is_link_table and len(entity.fields) == 2:
|
|
178
|
+
# Link tables typically have exactly 2 foreign key fields
|
|
179
|
+
fk_fields = [f for f in entity.fields if f.is_foreign_key]
|
|
180
|
+
if len(fk_fields) == 2:
|
|
181
|
+
# Extract table names from foreign keys
|
|
182
|
+
table1 = (
|
|
183
|
+
fk_fields[0].foreign_table.split(".")[0]
|
|
184
|
+
if fk_fields[0].foreign_table
|
|
185
|
+
else None
|
|
186
|
+
)
|
|
187
|
+
table2 = (
|
|
188
|
+
fk_fields[1].foreign_table.split(".")[0]
|
|
189
|
+
if fk_fields[1].foreign_table
|
|
190
|
+
else None
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if table1 and table2:
|
|
194
|
+
# Map both directions
|
|
195
|
+
link_map[(table1, table2)] = entity.name
|
|
196
|
+
link_map[(table2, table1)] = entity.name
|
|
197
|
+
|
|
198
|
+
return link_map
|
|
199
|
+
|
|
200
|
+
def _generate_direct_relationships(
|
|
201
|
+
self, entity: EntityInfo, link_table_map: Dict[Tuple[str, str], str]
|
|
202
|
+
) -> List[str]:
|
|
203
|
+
"""Generate direct relationships (foreign keys without link tables)."""
|
|
204
|
+
lines: List[str] = []
|
|
205
|
+
|
|
206
|
+
# Generate relationships from foreign keys in this entity
|
|
207
|
+
for field in entity.fields:
|
|
208
|
+
if field.is_foreign_key and field.foreign_table:
|
|
209
|
+
target_table = field.foreign_table.split(".")[0]
|
|
210
|
+
|
|
211
|
+
# Find target entity by table name
|
|
212
|
+
target_entity = None
|
|
213
|
+
for e in self.entities.values():
|
|
214
|
+
if e.table_name == target_table:
|
|
215
|
+
target_entity = e
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
if target_entity:
|
|
219
|
+
# This is a direct foreign key relationship
|
|
220
|
+
# PlantUML syntax: }o--|| means "zero or more to exactly one"
|
|
221
|
+
rel_line = f'{entity.name} }}o--|| {target_entity.name} : "{field.name}"'
|
|
222
|
+
lines.append(rel_line)
|
|
223
|
+
|
|
224
|
+
return lines
|
|
225
|
+
|
|
226
|
+
def _entity_by_table(self, table_name: str) -> EntityInfo | None:
|
|
227
|
+
"""Find an entity by its table name."""
|
|
228
|
+
for entity in self.entities.values():
|
|
229
|
+
if entity.table_name == table_name:
|
|
230
|
+
return entity
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
def _connected_pairs(self) -> "set[frozenset[str]]":
|
|
234
|
+
"""Compute entity-name pairs already connected by a foreign-key line."""
|
|
235
|
+
pairs: set[frozenset[str]] = set()
|
|
236
|
+
for entity in self.entities.values():
|
|
237
|
+
for field in entity.fields:
|
|
238
|
+
if field.is_foreign_key and field.foreign_table:
|
|
239
|
+
target = self._entity_by_table(field.foreign_table.split(".")[0])
|
|
240
|
+
if target:
|
|
241
|
+
pairs.add(frozenset((entity.name, target.name)))
|
|
242
|
+
return pairs
|
|
243
|
+
|
|
244
|
+
def _generate_relationship_list_lines(
|
|
245
|
+
self, entity: EntityInfo
|
|
246
|
+
) -> "List[Tuple[str, frozenset[str]]]":
|
|
247
|
+
"""Generate lines from an entity's declared relationships.
|
|
248
|
+
|
|
249
|
+
Returns (line, pair) tuples where pair is the frozenset of the two
|
|
250
|
+
connected entity names, so callers can skip already-connected pairs.
|
|
251
|
+
"""
|
|
252
|
+
results: List[Tuple[str, frozenset[str]]] = []
|
|
253
|
+
for target_name, rel_type, attr in entity.relationships:
|
|
254
|
+
target = self.entities.get(target_name)
|
|
255
|
+
if not target:
|
|
256
|
+
continue
|
|
257
|
+
if rel_type == "many":
|
|
258
|
+
line = f'{entity.name} ||--o{{ {target.name} : "{attr}"'
|
|
259
|
+
else:
|
|
260
|
+
line = f'{entity.name} }}o--|| {target.name} : "{attr}"'
|
|
261
|
+
results.append((line, frozenset((entity.name, target.name))))
|
|
262
|
+
return results
|
|
263
|
+
|
|
264
|
+
def _generate_link_table_relationships(self, link_entity: EntityInfo) -> List[str]:
|
|
265
|
+
"""Generate relationships through a link table (many-to-many)."""
|
|
266
|
+
lines: List[str] = []
|
|
267
|
+
|
|
268
|
+
# Get the two foreign keys from the link table
|
|
269
|
+
fk_fields = [f for f in link_entity.fields if f.is_foreign_key]
|
|
270
|
+
|
|
271
|
+
if len(fk_fields) != 2:
|
|
272
|
+
return lines
|
|
273
|
+
|
|
274
|
+
# Find the two entities being linked
|
|
275
|
+
entities_to_link: List[Tuple[EntityInfo, str]] = []
|
|
276
|
+
for fk_field in fk_fields:
|
|
277
|
+
if fk_field.foreign_table:
|
|
278
|
+
target_table = fk_field.foreign_table.split(".")[0]
|
|
279
|
+
for e in self.entities.values():
|
|
280
|
+
if e.table_name == target_table:
|
|
281
|
+
entities_to_link.append((e, fk_field.name))
|
|
282
|
+
break
|
|
283
|
+
|
|
284
|
+
if len(entities_to_link) == 2:
|
|
285
|
+
entity1, fk1_name = entities_to_link[0]
|
|
286
|
+
entity2, fk2_name = entities_to_link[1]
|
|
287
|
+
|
|
288
|
+
# Draw: Entity1 --o{ LinkTable }o-- Entity2
|
|
289
|
+
line1 = f'{entity1.name} ||--o{{ {link_entity.name} : "{fk1_name}"'
|
|
290
|
+
line2 = f'{link_entity.name} }}o--|| {entity2.name} : "{fk2_name}"'
|
|
291
|
+
lines.append(line1)
|
|
292
|
+
lines.append(line2)
|
|
293
|
+
|
|
294
|
+
return lines
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def generate_plantuml(
|
|
298
|
+
entities: Dict[str, EntityInfo],
|
|
299
|
+
enums: Dict[str, EnumInfo] | None = None,
|
|
300
|
+
title: str = "Database ERD",
|
|
301
|
+
include_enums: bool = True,
|
|
302
|
+
include_relationships: bool = True,
|
|
303
|
+
) -> str:
|
|
304
|
+
"""
|
|
305
|
+
Generate PlantUML ERD diagram from parsed entities.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
entities: Dictionary of entity name to EntityInfo
|
|
309
|
+
enums: Optional dictionary of enum name to EnumInfo
|
|
310
|
+
title: Title for the diagram
|
|
311
|
+
include_enums: Whether to include enum definitions
|
|
312
|
+
include_relationships: Whether to include relationship lines
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
PlantUML diagram as string
|
|
316
|
+
"""
|
|
317
|
+
generator = PlantUMLGenerator(
|
|
318
|
+
entities=entities,
|
|
319
|
+
enums=enums if include_enums else None,
|
|
320
|
+
title=title,
|
|
321
|
+
)
|
|
322
|
+
return generator.generate()
|