erdify 0.4.1__tar.gz → 0.4.2__tar.gz
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-0.4.1 → erdify-0.4.2}/PKG-INFO +2 -2
- {erdify-0.4.1 → erdify-0.4.2}/README.md +1 -1
- {erdify-0.4.1 → erdify-0.4.2}/pyproject.toml +1 -1
- {erdify-0.4.1 → erdify-0.4.2}/src/erdify/generator.py +5 -0
- {erdify-0.4.1 → erdify-0.4.2}/src/erdify/parser.py +118 -5
- {erdify-0.4.1 → erdify-0.4.2}/LICENSE +0 -0
- {erdify-0.4.1 → erdify-0.4.2}/src/erdify/__init__.py +0 -0
- {erdify-0.4.1 → erdify-0.4.2}/src/erdify/__main__.py +0 -0
- {erdify-0.4.1 → erdify-0.4.2}/src/erdify/cli.py +0 -0
- {erdify-0.4.1 → erdify-0.4.2}/src/erdify/config.py +0 -0
- {erdify-0.4.1 → erdify-0.4.2}/src/erdify/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: erdify
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Generate PlantUML ERD diagrams from SQLModel, SQLAlchemy, Pydantic and dataclass models
|
|
5
5
|
Keywords: sqlmodel,sqlalchemy,pydantic,dataclass,erd,plantuml,diagram,database,erdify
|
|
6
6
|
Author: devsuit GmbH, Fabian Clemenz
|
|
@@ -49,7 +49,7 @@ Description-Content-Type: text/markdown
|
|
|
49
49
|
- 🎚️ **Source Filtering** - Restrict the diagram to specific model kinds with `--sources` (e.g. ORM tables only)
|
|
50
50
|
- 📦 **Inheritance Support** - Correctly resolves fields from base classes and mixins
|
|
51
51
|
- 🏷️ **Enum Support** - Includes enum definitions in the diagram
|
|
52
|
-
- 🔄 **Link Table Detection** - Identifies many-to-many
|
|
52
|
+
- 🔄 **Link Table Detection** - Identifies many-to-many association tables structurally (two FK columns that form the primary key), regardless of class name, including SQLAlchemy Core `Table()` association tables referenced via `relationship(secondary=...)`
|
|
53
53
|
- 🎨 **Beautiful Output** - Clean, readable PlantUML with proper styling
|
|
54
54
|
|
|
55
55
|
## 📦 Installation
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
- 🎚️ **Source Filtering** - Restrict the diagram to specific model kinds with `--sources` (e.g. ORM tables only)
|
|
25
25
|
- 📦 **Inheritance Support** - Correctly resolves fields from base classes and mixins
|
|
26
26
|
- 🏷️ **Enum Support** - Includes enum definitions in the diagram
|
|
27
|
-
- 🔄 **Link Table Detection** - Identifies many-to-many
|
|
27
|
+
- 🔄 **Link Table Detection** - Identifies many-to-many association tables structurally (two FK columns that form the primary key), regardless of class name, including SQLAlchemy Core `Table()` association tables referenced via `relationship(secondary=...)`
|
|
28
28
|
- 🎨 **Beautiful Output** - Clean, readable PlantUML with proper styling
|
|
29
29
|
|
|
30
30
|
## 📦 Installation
|
|
@@ -174,6 +174,11 @@ class PlantUMLGenerator:
|
|
|
174
174
|
default_val = default_val.split(".")[-1]
|
|
175
175
|
default = f" = {default_val}"
|
|
176
176
|
|
|
177
|
+
# Omit the type suffix entirely when the type is unknown (e.g. an
|
|
178
|
+
# untyped Core Column on a synthesized link table) to avoid a dangling ":".
|
|
179
|
+
if not type_str:
|
|
180
|
+
return f"{prefix}({field.name})"
|
|
181
|
+
|
|
177
182
|
return f"{prefix}({field.name}) : {type_str}{nullable}{default}"
|
|
178
183
|
|
|
179
184
|
def _build_link_table_map(self) -> Dict[Tuple[str, str], str]:
|
|
@@ -5,7 +5,7 @@ import re
|
|
|
5
5
|
import sys
|
|
6
6
|
from fnmatch import fnmatchcase
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Dict, List, Tuple
|
|
8
|
+
from typing import Dict, List, Tuple, TypeGuard
|
|
9
9
|
|
|
10
10
|
from .config import EntityInfo, EnumInfo, FieldInfo
|
|
11
11
|
|
|
@@ -62,6 +62,10 @@ class ASTDatabaseParser:
|
|
|
62
62
|
if source is not None and (self.sources is None or source in self.sources):
|
|
63
63
|
self._parse_table_class(class_node, source)
|
|
64
64
|
|
|
65
|
+
# Synthesize link entities from module-level Core Table(...) association
|
|
66
|
+
# tables referenced by relationship(secondary=...) (#34).
|
|
67
|
+
self._parse_core_association_tables()
|
|
68
|
+
|
|
65
69
|
# Third pass: apply exclude patterns
|
|
66
70
|
self._apply_exclude_patterns()
|
|
67
71
|
|
|
@@ -230,9 +234,6 @@ class ASTDatabaseParser:
|
|
|
230
234
|
if not table_name:
|
|
231
235
|
table_name = self._to_snake_case(class_node.name)
|
|
232
236
|
|
|
233
|
-
# Check if link table
|
|
234
|
-
is_link_table = "Link" in class_node.name
|
|
235
|
-
|
|
236
237
|
# Get base classes
|
|
237
238
|
base_classes: List[str] = []
|
|
238
239
|
for base in class_node.bases:
|
|
@@ -242,7 +243,6 @@ class ASTDatabaseParser:
|
|
|
242
243
|
entity = EntityInfo(
|
|
243
244
|
name=class_node.name,
|
|
244
245
|
table_name=table_name,
|
|
245
|
-
is_link_table=is_link_table,
|
|
246
246
|
base_classes=base_classes,
|
|
247
247
|
source=source,
|
|
248
248
|
)
|
|
@@ -259,8 +259,121 @@ class ASTDatabaseParser:
|
|
|
259
259
|
entity.fields = list(fields_dict.values())
|
|
260
260
|
entity.relationships = list(relationships_dict.values())
|
|
261
261
|
|
|
262
|
+
# Detect join tables structurally: an entity whose columns are exactly
|
|
263
|
+
# two foreign keys, both part of the primary key, is an association
|
|
264
|
+
# table regardless of its class name (#35).
|
|
265
|
+
entity.is_link_table = self._is_structural_link_table(entity.fields)
|
|
266
|
+
|
|
262
267
|
self.entities[class_node.name] = entity
|
|
263
268
|
|
|
269
|
+
@staticmethod
|
|
270
|
+
def _is_structural_link_table(fields: List[FieldInfo]) -> bool:
|
|
271
|
+
"""Return True if fields describe a join table: exactly two columns,
|
|
272
|
+
both foreign keys and both part of the primary key."""
|
|
273
|
+
if len(fields) != 2:
|
|
274
|
+
return False
|
|
275
|
+
return all(f.is_foreign_key and f.is_primary_key for f in fields)
|
|
276
|
+
|
|
277
|
+
def _parse_core_association_tables(self) -> None:
|
|
278
|
+
"""Synthesize link entities from module-level Core ``Table(...)`` assignments.
|
|
279
|
+
|
|
280
|
+
``relationship(secondary=Table(...))`` points at a SQLAlchemy Core table
|
|
281
|
+
rather than a mapped class. Only class definitions become entities in the
|
|
282
|
+
normal passes, so such association tables - and their M:N - would be lost.
|
|
283
|
+
Parse module-level ``name = Table("t", metadata, Column(...), ...)``
|
|
284
|
+
assignments that are structurally association tables (exactly two FK
|
|
285
|
+
columns, both part of the primary key) and add them as link entities (#34).
|
|
286
|
+
"""
|
|
287
|
+
if self.sources is not None and "sqlalchemy" not in self.sources:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
for tree in self.file_trees.values():
|
|
291
|
+
for node in tree.body:
|
|
292
|
+
if not isinstance(node, ast.Assign):
|
|
293
|
+
continue
|
|
294
|
+
if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name):
|
|
295
|
+
continue
|
|
296
|
+
if not self._is_call_to(node.value, "Table"):
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
var_name = node.targets[0].id
|
|
300
|
+
entity = self._build_core_table_entity(var_name, node.value)
|
|
301
|
+
if entity is not None and entity.name not in self.entities:
|
|
302
|
+
self.entities[entity.name] = entity
|
|
303
|
+
|
|
304
|
+
def _build_core_table_entity(self, var_name: str, call: ast.Call) -> EntityInfo | None:
|
|
305
|
+
"""Build a link EntityInfo from a Core ``Table(...)`` call, or None if it
|
|
306
|
+
is not structurally an association table."""
|
|
307
|
+
# Table name is the first positional string arg; fall back to variable name.
|
|
308
|
+
table_name = var_name
|
|
309
|
+
if (
|
|
310
|
+
call.args
|
|
311
|
+
and isinstance(call.args[0], ast.Constant)
|
|
312
|
+
and isinstance(call.args[0].value, str)
|
|
313
|
+
):
|
|
314
|
+
table_name = call.args[0].value
|
|
315
|
+
|
|
316
|
+
fields: List[FieldInfo] = []
|
|
317
|
+
for arg in call.args:
|
|
318
|
+
if self._is_call_to(arg, "Column"):
|
|
319
|
+
field = self._parse_core_column(arg)
|
|
320
|
+
if field is not None:
|
|
321
|
+
fields.append(field)
|
|
322
|
+
|
|
323
|
+
if not self._is_structural_link_table(fields):
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
entity = EntityInfo(
|
|
327
|
+
name=var_name,
|
|
328
|
+
table_name=table_name,
|
|
329
|
+
base_classes=[],
|
|
330
|
+
source="sqlalchemy",
|
|
331
|
+
)
|
|
332
|
+
entity.fields = fields
|
|
333
|
+
entity.is_link_table = True
|
|
334
|
+
return entity
|
|
335
|
+
|
|
336
|
+
def _parse_core_column(self, call: ast.Call) -> FieldInfo | None:
|
|
337
|
+
"""Parse a Core ``Column("name", <Type>, ForeignKey(...), primary_key=...)``."""
|
|
338
|
+
if not call.args or not isinstance(call.args[0], ast.Constant):
|
|
339
|
+
return None
|
|
340
|
+
col_name = str(call.args[0].value)
|
|
341
|
+
|
|
342
|
+
is_primary_key = False
|
|
343
|
+
for keyword in call.keywords:
|
|
344
|
+
if keyword.arg == "primary_key" and isinstance(keyword.value, ast.Constant):
|
|
345
|
+
is_primary_key = bool(keyword.value.value)
|
|
346
|
+
|
|
347
|
+
fk_target = self._extract_foreign_key_arg(call)
|
|
348
|
+
|
|
349
|
+
# An optional positional column type (e.g. Column("id", Integer, ...)).
|
|
350
|
+
type_str = ""
|
|
351
|
+
for arg in call.args[1:]:
|
|
352
|
+
if isinstance(arg, ast.Name):
|
|
353
|
+
type_str = arg.id
|
|
354
|
+
break
|
|
355
|
+
if isinstance(arg, ast.Attribute):
|
|
356
|
+
type_str = arg.attr
|
|
357
|
+
break
|
|
358
|
+
|
|
359
|
+
return FieldInfo(
|
|
360
|
+
name=col_name,
|
|
361
|
+
type_str=type_str,
|
|
362
|
+
is_primary_key=is_primary_key,
|
|
363
|
+
is_foreign_key=fk_target is not None,
|
|
364
|
+
foreign_table=fk_target,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
@staticmethod
|
|
368
|
+
def _is_call_to(value: ast.expr, func_name: str) -> TypeGuard[ast.Call]:
|
|
369
|
+
"""Check whether an expression is a call to ``func_name`` (Name or attribute)."""
|
|
370
|
+
if not isinstance(value, ast.Call):
|
|
371
|
+
return False
|
|
372
|
+
func = value.func
|
|
373
|
+
return (isinstance(func, ast.Name) and func.id == func_name) or (
|
|
374
|
+
isinstance(func, ast.Attribute) and func.attr == func_name
|
|
375
|
+
)
|
|
376
|
+
|
|
264
377
|
def _collect_fields_recursive(
|
|
265
378
|
self, class_node: ast.ClassDef, visited: set[str], model_kind: str
|
|
266
379
|
) -> Tuple[Dict[str, FieldInfo], Dict[str, Tuple[str, str, str]]]:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|