Skip to content
Open
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
7 changes: 4 additions & 3 deletions django-stubs/db/models/base.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ from django.core.exceptions import ObjectDoesNotExist, ObjectNotUpdated, Validat
from django.db.models import BaseConstraint, Field, QuerySet
from django.db.models.manager import Manager
from django.db.models.options import Options
from django.db.models.utils import AltersData
from typing_extensions import Self, override

_Self = TypeVar("_Self", bound=Model)
Expand Down Expand Up @@ -35,7 +36,7 @@ class ModelBase(type):
@property
def _default_manager(cls: type[_Self]) -> Manager[_Self]: ... # type: ignore[misc]

class Model(metaclass=ModelBase):
class Model(AltersData, metaclass=ModelBase):
# Note: these two metaclass generated attributes don't really exist on the 'Model'
# class, runtime they are only added on concrete subclasses of 'Model'. The
# metaclass also sets up correct inheritance from concrete parent models exceptions.
Expand All @@ -59,7 +60,7 @@ class Model(metaclass=ModelBase):
def __getstate__(self) -> dict: ...
else:
def __getstate__(self) -> dict: ...
def _get_pk_val(self, meta: Options[Self] | None = None) -> str: ...
def _get_pk_val(self, meta: Options[Model] | None = None) -> str: ...
def get_deferred_fields(self) -> set[str]: ...
def refresh_from_db(
self,
Expand Down Expand Up @@ -114,7 +115,7 @@ class Model(metaclass=ModelBase):
def clean(self) -> None: ...
def validate_unique(self, exclude: Collection[str] | None = None) -> None: ...
def date_error_message(self, lookup_type: str, field_name: str, unique_for: str) -> ValidationError: ...
def unique_error_message(self, model_class: type[Self], unique_check: Sequence[str]) -> ValidationError: ...
def unique_error_message(self, model_class: type[Model], unique_check: Sequence[str]) -> ValidationError: ...
def get_constraints(self) -> list[tuple[type[Model], Sequence[BaseConstraint]]]: ...
def validate_constraints(self, exclude: Collection[str] | None = None) -> None: ...
def full_clean(
Expand Down
3 changes: 2 additions & 1 deletion django-stubs/db/models/query.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ from django.db.models import Manager
from django.db.models.base import Model
from django.db.models.expressions import Combinable, OrderBy
from django.db.models.sql.query import Query, RawQuery
from django.db.models.utils import AltersData
from django.utils.functional import cached_property
from typing_extensions import Self, TypeVar, override

Expand Down Expand Up @@ -63,7 +64,7 @@ class _SupportsContains(Generic[_ContainsT]):
def __contains__(self, item: _ContainsT, /) -> bool: ...

# Using `object` (not `_Row | None`) to satisfy Collection protocol and support `User | AnonymousUser` patterns
class QuerySet(_SupportsContains[object], Iterable[_Row], Sized, Generic[_Model, _Row]):
class QuerySet(AltersData, _SupportsContains[object], Iterable[_Row], Sized, Generic[_Model, _Row]):
model: type[_Model]
query: Query
_iterable_class: type[BaseIterable]
Expand Down
38 changes: 38 additions & 0 deletions tests/assert_type/db/models/test_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from collections.abc import Iterable

from django.db import models
from django.db.models.query import QuerySet
from typing_extensions import assert_type, override


class MyModel(models.Model):
class Meta:
app_label = "myapp"


class OtherModel(models.Model):
class Meta:
app_label = "myapp"


# Override with QuerySet[Model] — valid
class OverrideWithModel(models.Model):
@override
def refresh_from_db(
self,
using: str | None = None,
fields: Iterable[str] | None = None,
from_queryset: QuerySet[models.Model] | None = None,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

MyModel won't work here, right?

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.

Right, QuerySet[MyModel] errors on both mypy and pyright since it narrows the parameter type. Should we add another test case for it?

Copy link
Copy Markdown
Member

@sobolevn sobolevn Mar 27, 2026

Choose a reason for hiding this comment

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

Yes, please. But, it seems kinda right to have it typed as QuerySet[MyModel]?

Because passing from_queryset=QuerySet[SomeOther] with no error does seem like a false negative to me.

Do you have any ideas on how we can make this right?

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.

We could try self: _Self with QuerySet[_Self] - this ties the queryset to the actual model type. So user.refresh_from_db(from_queryset=article_qs) would correctly error. It's stricter than runtime since QuerySet[Model] gets rejected for typed subclass instances, but I think passing a wrong model's queryset is a bug waiting to happen anyway :)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's try this :)

Copy link
Copy Markdown
Contributor Author

@emmanuel-ferdman emmanuel-ferdman Mar 27, 2026

Choose a reason for hiding this comment

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

Code updated :)

) -> None: ...

class Meta:
app_label = "myapp"


def test_correct_queryset(m: MyModel, qs: QuerySet[MyModel]) -> None:
m.refresh_from_db(from_queryset=qs)
assert_type(m.refresh_from_db(), None)


def test_wrong_queryset(m: MyModel, qs: QuerySet[OtherModel]) -> None:
m.refresh_from_db(from_queryset=qs) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] # pyrefly: ignore[bad-argument-type]
Loading