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
48 changes: 28 additions & 20 deletions mypy_django_plugin/transformers/querysets.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,31 @@ def _extract_model_type_var_upper_bound(ctx: MethodContext) -> Instance | None:
return None


def _resolve_annotate_row_type(
api: TypeChecker,
default_return_type: Instance,
annotated_model: ProperType,
expression_types: dict[str, MypyType],
) -> MypyType:
if len(default_return_type.args) <= 1:
return annotated_model
original_row_type = get_proper_type(default_return_type.args[1])
if isinstance(original_row_type, TypedDictType):
return api.named_generic_type(
"builtins.dict",
[api.named_generic_type("builtins.str", []), AnyType(TypeOfAny.from_omitted_generics)],
)
if isinstance(original_row_type, TupleType):
if original_row_type.partial_fallback.type.has_base("typing.NamedTuple"):
# Rebuild the NamedTuple with existing fields + annotation fields.
annotation_fields = {name: AnyType(TypeOfAny.from_omitted_generics) for name in expression_types}
return helpers.extend_oneoff_named_tuple(api, "Row", original_row_type, annotation_fields)
return api.named_generic_type("builtins.tuple", [AnyType(TypeOfAny.from_omitted_generics)])
if isinstance(original_row_type, Instance) and helpers.is_model_type(original_row_type.type):
return annotated_model
return original_row_type


def extract_proper_type_queryset_annotate(ctx: MethodContext, django_context: DjangoContext) -> MypyType:
django_model = helpers.get_model_info_from_qs_ctx(ctx, django_context)
if django_model is None:
Expand All @@ -424,7 +449,8 @@ def extract_proper_type_queryset_annotate(ctx: MethodContext, django_context: Dj
if expression_types:
fields_dict = helpers.make_typeddict(api, expression_types)
upper_annotated = get_annotated_type(api, upper_bound, fields_dict=fields_dict)
return default_return_type.copy_modified(args=[upper_annotated, upper_annotated])
row_type = _resolve_annotate_row_type(api, default_return_type, upper_annotated, expression_types)
return default_return_type.copy_modified(args=[upper_annotated, row_type])
return AnyType(TypeOfAny.from_omitted_generics)

default_return_type = get_proper_type(ctx.default_return_type)
Expand Down Expand Up @@ -452,25 +478,7 @@ def extract_proper_type_queryset_annotate(ctx: MethodContext, django_context: Dj
fields_dict = helpers.make_typeddict(api, all_fields)
annotated_type = get_annotated_type(api, django_model.typ, fields_dict=fields_dict)

row_type: MypyType
if len(default_return_type.args) > 1:
original_row_type = get_proper_type(default_return_type.args[1])
row_type = original_row_type
if isinstance(original_row_type, TypedDictType):
row_type = api.named_generic_type(
"builtins.dict", [api.named_generic_type("builtins.str", []), AnyType(TypeOfAny.from_omitted_generics)]
)
elif isinstance(original_row_type, TupleType):
if original_row_type.partial_fallback.type.has_base("typing.NamedTuple"):
# Rebuild the NamedTuple with existing fields + annotation fields.
annotation_fields = {name: AnyType(TypeOfAny.from_omitted_generics) for name in expression_types}
row_type = helpers.extend_oneoff_named_tuple(api, "Row", original_row_type, annotation_fields)
else:
row_type = api.named_generic_type("builtins.tuple", [AnyType(TypeOfAny.from_omitted_generics)])
elif isinstance(original_row_type, Instance) and helpers.is_model_type(original_row_type.type):
row_type = annotated_type
else:
row_type = annotated_type
row_type = _resolve_annotate_row_type(api, default_return_type, annotated_type, expression_types)
return default_return_type.copy_modified(args=[annotated_type, row_type])


Expand Down
13 changes: 13 additions & 0 deletions tests/typecheck/managers/querysets/test_annotate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1012,3 +1012,16 @@

class FooModel(models.Model):
objects = FooManager()

- case: values_then_annotate_on_generic_queryset_typevar
Copy link
Copy Markdown
Contributor

@delfick delfick May 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems here is another case that shows a problem

- case: values_are_carried_through
  main: |
    from typing import Any
    from typing_extensions import reveal_type, TypeVar
    from django.db import models
    import myapp.models

    with_annotate = myapp.models.Thing.objects.all().annotate(foo=models.Max("id"))

    reveal_type(with_annotate) # N: Revealed type is "django.db.models.query.QuerySet[myapp.models.Thing@AnnotatedWith[TypedDict({'foo': Any})], myapp.models.Thing@AnnotatedWith[TypedDict({'foo': Any})]]"

    with_values = with_annotate.values("id").distinct()

    reveal_type(with_values) # N: Revealed type is "django.db.models.query.QuerySet[myapp.models.Thing@AnnotatedWith[TypedDict({'foo': Any})], TypedDict({'id': int})]"
  installed_apps:
    - myapp
  files:
    - path: myapp/__init__.py
    - path: myapp/models.py
      content: |
        from django.db import models
        from typing import Self

        class Thing(models.Model):
            pass

If I change with_values = with_annotate.values("id").distinct() to not have the distinct() then it passes.

edit: and seems similar for doing a slice qs[:max_sample_results]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also I've noticed that the .annotate will erase the custom queryset type if there is one.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've run out of time to investigate, but seems for that new test case above, the fix will be something to do with

return default_return_type.copy_modified(args=[annotated_type, annotated_type])

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, both cases should be fixed by #3383

main: |
from typing import Any
from typing_extensions import reveal_type, TypeVar
from django.db import models

_Model = TypeVar("_Model", bound=models.Model)

def latest_max(qs: models.QuerySet[_Model]) -> dict[str, Any] | None:
annotated = qs.values("id").annotate(foo=models.Max("id"))
reveal_type(annotated) # N: Revealed type is "django.db.models.query.QuerySet[django.db.models.base.Model, dict[str, Any]]"
return annotated.first()
Loading