Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/frameworks/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
17 changes: 14 additions & 3 deletions docs/usage/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
46 changes: 46 additions & 0 deletions src/erdify/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
116 changes: 116 additions & 0 deletions tests/test_base_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading