diff --git a/CHANGELOG.md b/CHANGELOG.md index f5db60d..28ebf42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- erdify now recognises ninja-style `ModelSchema` classes — those taking their + fields from a Django model via an inner `Meta` (django-ninja) or `Config` + (ninja-schema) — and **skips them with an explanatory warning** instead of + drawing an entity with no fields. Naming `ModelSchema` via `--base-classes` + previously produced an empty box beside the real table, which was worse than + no support at all. Detection is narrow: only an inner `Meta`/`Config` that + assigns `model` counts, so an ordinary Pydantic model with a nested config + class and Django's own `class Meta` are both untouched. Resolving those + schemas properly remains open in + [#171](https://github.com/devsuit-berlin/erdify/issues/171). + ### Fixed - `docs/examples/frameworks.svg` is well-formed XML again. Its header comment diff --git a/docs/frameworks/limitations.md b/docs/frameworks/limitations.md index bb963f9..8792cff 100644 --- a/docs/frameworks/limitations.md +++ b/docs/frameworks/limitations.md @@ -145,6 +145,7 @@ names, so it can be wrong in both directions. | Composite foreign keys | Not supported | | Foreign keys inside `sa_column=Column(...)` | Not detected | | Unannotated `x = relationship("Target")` | Not detected | +| ninja-style `ModelSchema` (fields from an inner `Meta`/`Config`) | Detected and skipped with a warning, rather than drawn empty ([#171](https://github.com/devsuit-berlin/erdify/issues/171)) | | `attrs` / `msgspec` models | Not supported ([erdantic](../comparison.md) covers these) | | Live database introspection | Not supported by design | diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 7a936a1..3049739 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -162,9 +162,20 @@ defined in a scanned file is picked up too. `ninja.ModelSchema` and `ninja_schema.ModelSchema` take their fields from a Django model via an inner `Meta` / `Config` class, leaving the class body - empty. Naming those bases produces entities with no fields, so it is not - supported — see - [issue #171](https://github.com/devsuit-berlin/erdify/issues/171). + empty. erdify does not resolve that reference, so naming those bases would + produce entities with no fields. It detects the shape and **skips them** + instead, with a note on stderr: + + ``` + Warning: skipped UserSchema - its fields come from Meta.model (User), which + erdify does not resolve. Drawing it would add an entity with no fields. + ``` + + The Django model itself is still drawn as usual. Whether a response schema + belongs in an ERD at all — it is a projection of a table, not a table — is + an open question in + [issue #171](https://github.com/devsuit-berlin/erdify/issues/171); if you + have a view, that is the place for it. ## Empty results (`--allow-empty`) diff --git a/src/erdify/parser.py b/src/erdify/parser.py index e90e5af..8a4453b 100644 --- a/src/erdify/parser.py +++ b/src/erdify/parser.py @@ -196,6 +196,17 @@ def parse_all_models(self) -> Tuple[Dict[str, EntityInfo], Dict[str, EnumInfo]]: continue source = self._classify_source(class_node) if source is not None and (self.sources is None or source in self.sources): + derived = self._model_derived_schema(class_node) if source == "pydantic" else None + if derived is not None: + holder, target = derived + print( + f"Warning: skipped {class_node.name} - its fields come from " + f"{holder}.model ({target}), which erdify does not resolve. " + "Drawing it would add an entity with no fields. " + "See https://github.com/devsuit-berlin/erdify/issues/171", + file=sys.stderr, + ) + continue self._parse_table_class(class_node, source) # Synthesize link entities from module-level Core Table(...) association @@ -369,6 +380,41 @@ def _classify_source(self, class_node: ast.ClassDef) -> str | None: return None + def _model_derived_schema(self, class_node: ast.ClassDef) -> Tuple[str, str] | None: + """Detect a ninja-style ``ModelSchema`` and report where its fields come from. + + django-ninja's ``ModelSchema`` (inner ``Meta``) and ninja-schema's + (inner ``Config``) take their fields from a Django model named in that + inner class rather than from the class body. erdify cannot resolve the + reference, so the entity would be drawn with no fields at all — worse + than not drawing it. Detection is deliberately narrow: only an inner + ``Meta``/``Config`` that assigns ``model`` counts, so an ordinary + Pydantic model with a nested config class is untouched. + + Returns: + ``(holder, target)`` where *holder* is ``"Meta"`` or ``"Config"`` + and *target* is the referenced model's name, or ``None`` when the + class is not a model-derived schema. + """ + for node in class_node.body: + if not isinstance(node, ast.ClassDef) or node.name not in ("Meta", "Config"): + continue + for inner in node.body: + if not isinstance(inner, ast.Assign): + continue + for target in inner.targets: + if not (isinstance(target, ast.Name) and target.id == "model"): + continue + value = inner.value + if isinstance(value, ast.Name): + return node.name, value.id + if isinstance(value, ast.Attribute): + return node.name, value.attr + if isinstance(value, ast.Call): + return node.name, "unresolved" + return node.name, "unresolved" + return None + def _inherits_django_model( self, class_node: ast.ClassDef, visited: set[str] | None = None ) -> bool: diff --git a/tests/test_base_classes.py b/tests/test_base_classes.py index e1980b5..63416e1 100644 --- a/tests/test_base_classes.py +++ b/tests/test_base_classes.py @@ -104,3 +104,119 @@ def test_pyproject_config(self, tmp_path: Path, capsys): assert result == 0 assert "Widget" in capsys.readouterr().out + + +class TestModelDerivedSchemas: + """ninja-style ModelSchema classes are skipped, not drawn empty (#171).""" + + @staticmethod + def _write(tmp_path: Path, body: str) -> Path: + (tmp_path / "models.py").write_text(body) + return tmp_path + + DJANGO_MODEL = ( + "from django.db import models\n" + "from ninja import ModelSchema\n\n\n" + "class User(models.Model):\n" + " name = models.CharField(max_length=100)\n" + " email = models.EmailField()\n\n\n" + ) + + def test_meta_form_is_skipped(self, tmp_path: Path, capsys): + """django-ninja's ModelSchema uses an inner Meta.""" + root = self._write( + tmp_path, + self.DJANGO_MODEL + "class UserSchema(ModelSchema):\n" + ' class Meta:\n model = User\n fields = ["id", "name"]\n', + ) + + entities, _ = parse_models_directory(root, base_classes=["ModelSchema"]) + + assert "User" in entities + assert "UserSchema" not in entities + err = capsys.readouterr().err + assert "skipped UserSchema" in err + assert "Meta.model (User)" in err + assert "issues/171" in err + + def test_config_form_is_skipped(self, tmp_path: Path): + """ninja-schema uses an inner Config instead.""" + root = self._write( + tmp_path, + self.DJANGO_MODEL + "class UserSchema(ModelSchema):\n" + ' class Config:\n model = User\n include = ["id"]\n', + ) + + entities, _ = parse_models_directory(root, base_classes=["ModelSchema"]) + + assert "UserSchema" not in entities + + def test_partial_schema_is_also_skipped(self, tmp_path: Path): + """Explicit fields alongside Meta.model would still be an incomplete entity.""" + root = self._write( + tmp_path, + self.DJANGO_MODEL + "class UserSchema(ModelSchema):\n" + " display_name: str\n\n" + " class Meta:\n model = User\n", + ) + + entities, _ = parse_models_directory(root, base_classes=["ModelSchema"]) + + assert "UserSchema" not in entities + + def test_qualified_model_reference(self, tmp_path: Path, capsys): + """model = app.User resolves to the attribute name for the message.""" + root = self._write( + tmp_path, + self.DJANGO_MODEL + "class UserSchema(ModelSchema):\n" + " class Meta:\n model = accounts.User\n", + ) + + parse_models_directory(root, base_classes=["ModelSchema"]) + + assert "Meta.model (User)" in capsys.readouterr().err + + def test_ordinary_pydantic_model_is_untouched(self, tmp_path: Path, capsys): + """A nested config class without `model =` must not trigger the guard.""" + root = self._write( + tmp_path, + "from pydantic import BaseModel\n\n\n" + "class Widget(BaseModel):\n" + " id: int\n" + " name: str\n\n" + " class Config:\n frozen = True\n", + ) + + entities, _ = parse_models_directory(root) + + assert "Widget" in entities + assert "skipped" not in capsys.readouterr().err + + def test_django_meta_is_untouched(self, tmp_path: Path, capsys): + """Django's own class Meta must not be mistaken for a schema config.""" + root = self._write( + tmp_path, + "from django.db import models\n\n\n" + "class User(models.Model):\n" + " name = models.CharField(max_length=100)\n\n" + ' class Meta:\n db_table = "user"\n', + ) + + entities, _ = parse_models_directory(root) + + assert entities["User"].table_name == "user" + assert "skipped" not in capsys.readouterr().err + + def test_cli_reports_it(self, tmp_path: Path, capsys): + """The warning reaches stderr through the CLI too.""" + root = self._write( + tmp_path, + self.DJANGO_MODEL + "class UserSchema(ModelSchema):\n" + " class Meta:\n model = User\n", + ) + + with patch.object(sys, "argv", ["erdify", str(root), "--base-classes", "ModelSchema"]): + result = main() + + assert result == 0 + assert "skipped UserSchema" in capsys.readouterr().err