Skip to content

Commit ed65629

Browse files
Validate select_related lookups (#2806)
1 parent 2602ed8 commit ed65629

File tree

4 files changed

+216
-2
lines changed

4 files changed

+216
-2
lines changed

mypy_django_plugin/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ def manager_and_queryset_method_hooks(self) -> dict[str, Callable[[MethodContext
168168
"prefetch_related": partial(
169169
querysets.extract_prefetch_related_annotations, django_context=self.django_context
170170
),
171+
"select_related": partial(querysets.validate_select_related, django_context=self.django_context),
171172
}
172173

173174
def get_method_hook(self, fullname: str) -> Callable[[MethodContext], MypyType] | None:

mypy_django_plugin/transformers/querysets.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,3 +619,79 @@ def extract_prefetch_related_annotations(ctx: MethodContext, django_context: Dja
619619
row_type = annotated_model
620620

621621
return default_return_type.copy_modified(args=[annotated_model, row_type])
622+
623+
624+
def _get_select_related_field_choices(model_cls: type[Model]) -> set[str]:
625+
"""
626+
Get valid field choices for select_related lookups.
627+
Based on Django's SQLCompiler.get_related_selections._get_field_choices method.
628+
"""
629+
opts = model_cls._meta
630+
631+
# Direct relation fields (forward relations)
632+
direct_choices = (f.name for f in opts.fields if f.is_relation)
633+
634+
# Reverse relation fields (backward relations with unique=True)
635+
reverse_choices = (f.field.related_query_name() for f in opts.related_objects if f.field.unique)
636+
return {*direct_choices, *reverse_choices}
637+
638+
639+
def _validate_select_related_lookup(
640+
ctx: MethodContext,
641+
django_context: DjangoContext,
642+
model_cls: type[Model],
643+
lookup: str,
644+
) -> bool:
645+
"""Validate a single select_related lookup string."""
646+
if not lookup.strip():
647+
ctx.api.fail(
648+
f'Invalid field name "{lookup}" in select_related lookup',
649+
ctx.context,
650+
)
651+
return False
652+
653+
lookup_parts = lookup.split("__")
654+
observed_model = model_cls
655+
for i, part in enumerate(lookup_parts):
656+
valid_choices = _get_select_related_field_choices(observed_model)
657+
658+
if part not in valid_choices:
659+
ctx.api.fail(
660+
f'Invalid field name "{part}" in select_related lookup. '
661+
f"Choices are: {', '.join(sorted(valid_choices)) or '(none)'}",
662+
ctx.context,
663+
)
664+
return False
665+
666+
if i < len(lookup_parts) - 1: # Not the last part
667+
try:
668+
field, observed_model = django_context.resolve_lookup_into_field(observed_model, part)
669+
if field is None:
670+
return False
671+
except (FieldError, LookupsAreUnsupported):
672+
# For good measure, but we should never reach this since we already validated the part name
673+
return False
674+
675+
return True
676+
677+
678+
def validate_select_related(ctx: MethodContext, django_context: DjangoContext) -> MypyType:
679+
"""
680+
Validates that all lookup strings passed to select_related() resolve to actual model fields and relations.
681+
682+
Extracted and adapted from `django.db.models.sql.compiler.SQLCompiler.get_related_selections`
683+
"""
684+
if not (
685+
isinstance(ctx.type, Instance)
686+
and (django_model := helpers.get_model_info_from_qs_ctx(ctx, django_context)) is not None
687+
and ctx.arg_types
688+
and ctx.arg_types[0]
689+
):
690+
return ctx.default_return_type
691+
692+
for lookup_type in ctx.arg_types[0]:
693+
lookup_value = helpers.get_literal_str_type(get_proper_type(lookup_type))
694+
if lookup_value is not None:
695+
_validate_select_related_lookup(ctx, django_context, django_model.cls, lookup_value)
696+
697+
return ctx.default_return_type

tests/typecheck/managers/querysets/test_basic_methods.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@
9999
Book.objects.all().select_related()
100100
Book.objects.select_related(None)
101101
Book.objects.all().select_related(None)
102-
Book.objects.select_related("author", "dd") # Should be Error on invalid lookup 'dd' !
103-
Book.objects.all().select_related("author", "dd") # Should be Error on invalid lookup 'dd' !
102+
Book.objects.select_related("author", "dd") # E: Invalid field name "dd" in select_related lookup. Choices are: author [misc]
103+
Book.objects.all().select_related("author", "dd") # E: Invalid field name "dd" in select_related lookup. Choices are: author [misc]
104104
105105
Book.objects.select_related("None", None)
106106
Book.objects.all().select_related("None", None)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
- case: select_related_valid_lookups
2+
installed_apps:
3+
- myapp
4+
main: |
5+
from myapp.models import Article, Author, Category, Blog, Post
6+
7+
# Valid forward relations
8+
Article.objects.select_related("author")
9+
Article.objects.select_related("category")
10+
11+
# Valid reverse relations (unique=True)
12+
Author.objects.select_related("profile")
13+
14+
# Valid chained lookups
15+
Article.objects.select_related("author__profile")
16+
Article.objects.select_related("category__parent")
17+
Post.objects.select_related("blog__owner")
18+
Post.objects.select_related("category__parent__parent")
19+
20+
# Multiple valid lookups
21+
Article.objects.select_related("author", "category")
22+
Article.objects.select_related("author__profile", "category")
23+
24+
# Self-referential relationships
25+
Category.objects.select_related("parent")
26+
Category.objects.select_related("parent__parent")
27+
28+
# Variables containing strings
29+
author_lookup = "aaa"
30+
Article.objects.select_related(author_lookup)
31+
32+
# Dynamic lookups (should not be validated)
33+
def get_lookup() -> str:
34+
return "author"
35+
36+
Article.objects.select_related(get_lookup())
37+
38+
# Chaining select_related calls
39+
qs1 = Article.objects.select_related("author")
40+
qs2 = qs1.select_related("category")
41+
Article.objects.select_related("author").select_related("category")
42+
43+
files:
44+
- path: myapp/__init__.py
45+
- path: myapp/models.py
46+
content: |
47+
from django.db import models
48+
49+
class Category(models.Model):
50+
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True)
51+
52+
class Author(models.Model):
53+
pass
54+
55+
class AuthorProfile(models.Model):
56+
author = models.OneToOneField(Author, on_delete=models.CASCADE, related_name='profile')
57+
58+
class Blog(models.Model):
59+
owner = models.ForeignKey(Author, on_delete=models.CASCADE)
60+
61+
class Article(models.Model):
62+
title = models.CharField(max_length=200) # Keep for testing non-relation field
63+
author = models.ForeignKey(Author, on_delete=models.CASCADE)
64+
category = models.ForeignKey(Category, on_delete=models.CASCADE)
65+
66+
class Post(models.Model):
67+
blog = models.ForeignKey(Blog, on_delete=models.CASCADE)
68+
author = models.ForeignKey(Author, on_delete=models.CASCADE)
69+
category = models.ForeignKey(Category, on_delete=models.CASCADE)
70+
71+
- case: select_related_invalid_lookups
72+
installed_apps:
73+
- myapp
74+
main: |
75+
from myapp.models import Article, Author, Category, Post
76+
from typing import Literal
77+
78+
# Invalid field names
79+
Article.objects.select_related("nonexistent") # E: Invalid field name "nonexistent" in select_related lookup. Choices are: author, category [misc]
80+
Article.objects.select_related("title") # E: Invalid field name "title" in select_related lookup. Choices are: author, category [misc]
81+
82+
# Invalid chained lookups
83+
Article.objects.select_related("author__nonexistent") # E: Invalid field name "nonexistent" in select_related lookup. Choices are: profile [misc]
84+
Article.objects.select_related("category__invalid") # E: Invalid field name "invalid" in select_related lookup. Choices are: parent [misc]
85+
86+
# Reverse many-to-many (not allowed)
87+
Category.objects.select_related("article_set") # E: Invalid field name "article_set" in select_related lookup. Choices are: parent [misc]
88+
89+
# Mixed valid and invalid in same call
90+
Post.objects.select_related("blog", "invalid") # E: Invalid field name "invalid" in select_related lookup. Choices are: author, blog, category [misc]
91+
92+
# Intermediary variable not validated if non literal
93+
invalid_lookup = "nonexistent"
94+
Article.objects.select_related(invalid_lookup) # No error - variables not validated
95+
96+
# Intermediary variable validated if literal
97+
invalid_lookup2: Literal["nonexistent"] = "nonexistent"
98+
Article.objects.select_related(invalid_lookup2) # E: Invalid field name "nonexistent" in select_related lookup. Choices are: author, category [misc]
99+
100+
# Chaining with invalid lookups
101+
qs3 = Article.objects.select_related("author").select_related("invalid") # E: Invalid field name "invalid" in select_related lookup. Choices are: author, category [misc]
102+
Article.objects.select_related("author").select_related("invalid") # E: Invalid field name "invalid" in select_related lookup. Choices are: author, category [misc]
103+
104+
# Multiple arguments with invalid lookups
105+
Article.objects.select_related("author", "invalid", "category") # E: Invalid field name "invalid" in select_related lookup. Choices are: author, category [misc]
106+
107+
# Whitespace-only lookup (invalid)
108+
Article.objects.select_related("") # E: Invalid field name "" in select_related lookup [misc]
109+
Article.objects.select_related(" ") # E: Invalid field name " " in select_related lookup [misc]
110+
111+
files:
112+
- path: myapp/__init__.py
113+
- path: myapp/models.py
114+
content: |
115+
from django.db import models
116+
117+
class Category(models.Model):
118+
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True)
119+
120+
class Author(models.Model):
121+
pass
122+
123+
class AuthorProfile(models.Model):
124+
author = models.OneToOneField(Author, on_delete=models.CASCADE, related_name='profile')
125+
126+
class Blog(models.Model):
127+
owner = models.ForeignKey(Author, on_delete=models.CASCADE)
128+
129+
class Article(models.Model):
130+
title = models.CharField(max_length=200) # Keep for testing non-relation field
131+
author = models.ForeignKey(Author, on_delete=models.CASCADE)
132+
category = models.ForeignKey(Category, on_delete=models.CASCADE)
133+
134+
class Post(models.Model):
135+
blog = models.ForeignKey(Blog, on_delete=models.CASCADE)
136+
author = models.ForeignKey(Author, on_delete=models.CASCADE)
137+
category = models.ForeignKey(Category, on_delete=models.CASCADE)

0 commit comments

Comments
 (0)