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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: erdify
3
- Version: 0.4.1
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 relationship tables
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 relationship tables
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "erdify"
3
- version = "0.4.1"
3
+ version = "0.4.2"
4
4
  description = "Generate PlantUML ERD diagrams from SQLModel, SQLAlchemy, Pydantic and dataclass models"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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