From ac07b05c1d68655ae474cfe82e63823bbd35cfda Mon Sep 17 00:00:00 2001 From: maz Date: Fri, 31 Jul 2026 00:34:37 -0700 Subject: [PATCH] feat(parser): support TaggableManager relations --- CHANGELOG.md | 7 ++++ cli/django_orm_lens/parser.py | 27 +++++++++--- .../fixtures/golden/readthedocs.snapshot.json | 10 +++++ ...t_aliased_module_and_third_party_fields.py | 30 ++++++++++++++ cli/tests/test_er_formats.py | 19 ++++++++- src/parser.ts | 24 ++++++++--- test/taggable-manager.test.js | 41 +++++++++++++++++++ 7 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 test/taggable-manager.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e995fbc..94c2d56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to Django ORM Lens will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Recognize django-taggit `TaggableManager` fields as many-to-many relations, + including the default tag and through models and explicit `through=` overrides. + ## [py-1.10.0] - 2026-07-31 Three defects found by running the CLI over real checkouts of django-oscar, diff --git a/cli/django_orm_lens/parser.py b/cli/django_orm_lens/parser.py index efa6b38..e11ce09 100644 --- a/cli/django_orm_lens/parser.py +++ b/cli/django_orm_lens/parser.py @@ -70,6 +70,7 @@ "GenericIPAddressField", "AutoField", "BigAutoField", "SmallAutoField", "ForeignKey", "OneToOneField", "ManyToManyField", + "TaggableManager", ] ) @@ -541,7 +542,12 @@ def _collect_defs(file_path: str, content: str) -> list[ParsedModel]: if fm: fname, ftype = fm.group(1), fm.group(2) args_block, end_idx = _read_balanced_args(lines, j) - is_rel = ftype in RELATION_TYPES + relation_kind = ( + "ManyToManyField" + if ftype == "TaggableManager" + else ftype if ftype in RELATION_TYPES else None + ) + is_rel = relation_kind is not None field = ParsedField( name=fname, type=ftype, @@ -550,13 +556,24 @@ def _collect_defs(file_path: str, content: str) -> list[ParsedModel]: line_number=j, ) if is_rel: - field.relation_kind = ftype # type: ignore[assignment] + field.relation_kind = relation_kind # type: ignore[assignment] inner = args_block.rstrip(")") - field.related_model = _extract_related(inner) + field.related_model = ( + "taggit.Tag" + if ftype == "TaggableManager" + else _extract_related(inner) + ) field.on_delete = _extract_on_delete(inner) field.related_name = _extract_related_name(inner) - if ftype == "ManyToManyField": - field.through_model = _extract_through_model(inner) + if relation_kind == "ManyToManyField": + explicit_through = _extract_through_model(inner) + field.through_model = ( + explicit_through + if explicit_through is not None + else "taggit.TaggedItem" + if ftype == "TaggableManager" + else None + ) model.fields.append(field) j = end_idx + 1 continue diff --git a/cli/tests/fixtures/golden/readthedocs.snapshot.json b/cli/tests/fixtures/golden/readthedocs.snapshot.json index e4b8182..8e1b859 100644 --- a/cli/tests/fixtures/golden/readthedocs.snapshot.json +++ b/cli/tests/fixtures/golden/readthedocs.snapshot.json @@ -632,6 +632,16 @@ "name": "has_valid_clone", "type": "BooleanField" }, + { + "args": "blank=True, ordering=[\"name\"]", + "isRelation": true, + "lineNumber": 653, + "name": "tags", + "relatedModel": "taggit.Tag", + "relationKind": "ManyToManyField", + "throughModel": "taggit.TaggedItem", + "type": "TaggableManager" + }, { "args": "\"oauth.RemoteRepository\",\n verbose_name=_(\"Connected repository\"),\n help_text=_(\"Repository connected to this project\"),\n on_delete=models.SET_NULL,\n related_name=\"projects\",\n null=True,\n blank=True,", "isRelation": true, diff --git a/cli/tests/test_aliased_module_and_third_party_fields.py b/cli/tests/test_aliased_module_and_third_party_fields.py index 50b71d1..db6e966 100644 --- a/cli/tests/test_aliased_module_and_third_party_fields.py +++ b/cli/tests/test_aliased_module_and_third_party_fields.py @@ -90,5 +90,35 @@ def test_plain_bare_import_still_works(self) -> None: ) +class TaggableManagerTest(unittest.TestCase): + def test_default_relation_metadata(self) -> None: + src = ( + "from django.db import models\n" + "from taggit.managers import TaggableManager\n\n" + "class Post(models.Model):\n" + " title = models.CharField(max_length=200)\n" + " tags = TaggableManager()\n" + ) + models = parse_models_file("blog/models.py", src) + tags = models[0].fields[1] + self.assertEqual(tags.type, "TaggableManager") + self.assertTrue(tags.is_relation) + self.assertEqual(tags.relation_kind, "ManyToManyField") + self.assertEqual(tags.related_model, "taggit.Tag") + self.assertEqual(tags.through_model, "taggit.TaggedItem") + + def test_explicit_through_overrides_default(self) -> None: + src = ( + "from django.db import models\n" + "from taggit.managers import TaggableManager\n\n" + "class Post(models.Model):\n" + " tags = TaggableManager(through=CustomTaggedItem)\n" + ) + models = parse_models_file("blog/models.py", src) + tags = models[0].fields[0] + self.assertEqual(tags.related_model, "taggit.Tag") + self.assertEqual(tags.through_model, "CustomTaggedItem") + + if __name__ == "__main__": unittest.main() diff --git a/cli/tests/test_er_formats.py b/cli/tests/test_er_formats.py index 7897b27..066b76c 100644 --- a/cli/tests/test_er_formats.py +++ b/cli/tests/test_er_formats.py @@ -16,7 +16,7 @@ from pathlib import Path from tempfile import TemporaryDirectory -from django_orm_lens.cli import main +from django_orm_lens.cli import _build_mermaid, main from django_orm_lens.er_formats import build_d2, build_dbml, build_dot, build_plantuml from django_orm_lens.parser import DEFAULT_EXCLUDES, scan_workspace @@ -183,6 +183,23 @@ def test_out_of_workspace_targets_skipped(self) -> None: for text in (build_dbml(idx), build_d2(idx), build_plantuml(idx), build_dot(idx)): self.assertNotIn("polls.Poll", text) + def test_taggable_manager_external_target_skipped(self) -> None: + idx = self._index_for( + "from django.db import models\n" + "from taggit.managers import TaggableManager\n" + "class Post(models.Model):\n" + " tags = TaggableManager()\n" + ) + outputs = ( + _build_mermaid(idx), + build_dbml(idx), + build_d2(idx), + build_plantuml(idx), + build_dot(idx), + ) + for text in outputs: + self.assertNotIn("taggit.Tag", text) + def test_fk_ref_targets_explicit_pk_column(self) -> None: """A ref must point at the column that exists on the target table — the declared PK, not a hardcoded ``id``. Also covers spaced diff --git a/src/parser.ts b/src/parser.ts index d390bb5..dcca271 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -111,6 +111,7 @@ const BARE_FIELD_TYPES = [ 'GenericIPAddressField', 'AutoField', 'BigAutoField', 'SmallAutoField', 'ForeignKey', 'OneToOneField', 'ManyToManyField', + 'TaggableManager', ].join('|'); function buildBodyRegexes(indent: number) { @@ -321,7 +322,13 @@ export function parseModelsFile(filePath: string, content: string): ParsedModel[ const fieldName = fieldMatch[1]; const fieldType = fieldMatch[2]; const { argsBlock, endIdx } = readBalancedArgs(lines, j); - const isRel = RELATION_TYPES.includes(fieldType as RelationKind); + const relationKind = + fieldType === 'TaggableManager' + ? 'ManyToManyField' + : RELATION_TYPES.includes(fieldType as RelationKind) + ? (fieldType as RelationKind) + : undefined; + const isRel = relationKind !== undefined; const field: ParsedField = { name: fieldName, type: fieldType, @@ -330,16 +337,23 @@ export function parseModelsFile(filePath: string, content: string): ParsedModel[ lineNumber: j, }; if (isRel) { - field.relationKind = fieldType as RelationKind; + field.relationKind = relationKind; const argsInner = argsBlock.slice(0, -1); - field.relatedModel = extractRelated(argsInner); + field.relatedModel = + fieldType === 'TaggableManager' + ? 'taggit.Tag' + : extractRelated(argsInner); const onDelete = extractOnDelete(argsInner); if (onDelete) field.onDelete = onDelete; const relatedName = extractRelatedName(argsInner); if (relatedName) field.relatedName = relatedName; - if (fieldType === 'ManyToManyField') { + if (relationKind === 'ManyToManyField') { const throughModel = extractThroughModel(argsInner); - if (throughModel) field.throughModel = throughModel; + if (throughModel) { + field.throughModel = throughModel; + } else if (fieldType === 'TaggableManager') { + field.throughModel = 'taggit.TaggedItem'; + } } } model.fields.push(field); diff --git a/test/taggable-manager.test.js b/test/taggable-manager.test.js new file mode 100644 index 0000000..1ddaa05 --- /dev/null +++ b/test/taggable-manager.test.js @@ -0,0 +1,41 @@ +const assert = require('node:assert/strict'); +const Module = require('node:module'); +const test = require('node:test'); + +const originalLoad = Module._load; +Module._load = function (request, parent, isMain) { + if (request === 'vscode') return {}; + return originalLoad.call(this, request, parent, isMain); +}; + +const { parseModelsFile } = require('../out/parser'); + +test.after(() => { + Module._load = originalLoad; +}); + +test('parses TaggableManager defaults as a many-to-many relation', () => { + const [post] = parseModelsFile('/project/blog/models.py', ` +class Post(models.Model): + title = models.CharField(max_length=200) + tags = TaggableManager() +`); + const tags = post.fields.find((field) => field.name === 'tags'); + + assert.equal(tags.type, 'TaggableManager'); + assert.equal(tags.isRelation, true); + assert.equal(tags.relationKind, 'ManyToManyField'); + assert.equal(tags.relatedModel, 'taggit.Tag'); + assert.equal(tags.throughModel, 'taggit.TaggedItem'); +}); + +test('uses an explicit TaggableManager through model', () => { + const [post] = parseModelsFile('/project/blog/models.py', ` +class Post(models.Model): + tags = TaggableManager(through=CustomTaggedItem) +`); + const tags = post.fields.find((field) => field.name === 'tags'); + + assert.equal(tags.relatedModel, 'taggit.Tag'); + assert.equal(tags.throughModel, 'CustomTaggedItem'); +});