1
0
mirror of https://github.com/django/django.git synced 2025-09-25 07:59:11 +00:00

Refs #28586 -- Split descriptor from GenericForeignKey.

This makes GenericForeignKey more similar to other fields which act as
descriptors, preparing it to add “fetcher protocol” support in a clear and
consistent way.
This commit is contained in:
Adam Johnson 2025-04-14 15:46:37 +01:00 committed by Jacob Walls
parent 762d3be8c5
commit 74a9c2711c
5 changed files with 60 additions and 38 deletions

View File

@ -5,7 +5,7 @@ from django.core.checks import Error
def check_generic_foreign_keys(app_configs, **kwargs): def check_generic_foreign_keys(app_configs, **kwargs):
from .fields import GenericForeignKey from .fields import GenericForeignKeyDescriptor
if app_configs is None: if app_configs is None:
models = apps.get_models() models = apps.get_models()
@ -14,14 +14,14 @@ def check_generic_foreign_keys(app_configs, **kwargs):
app_config.get_models() for app_config in app_configs app_config.get_models() for app_config in app_configs
) )
errors = [] errors = []
fields = ( descriptors = (
obj obj
for model in models for model in models
for obj in vars(model).values() for obj in vars(model).values()
if isinstance(obj, GenericForeignKey) if isinstance(obj, GenericForeignKeyDescriptor)
) )
for field in fields: for descriptor in descriptors:
errors.extend(field.check()) errors.extend(descriptor.field.check())
return errors return errors

View File

@ -48,8 +48,7 @@ class GenericForeignKey(FieldCacheMixin, Field):
def contribute_to_class(self, cls, name, **kwargs): def contribute_to_class(self, cls, name, **kwargs):
super().contribute_to_class(cls, name, private_only=True, **kwargs) super().contribute_to_class(cls, name, private_only=True, **kwargs)
# GenericForeignKey is its own descriptor. setattr(cls, self.attname, GenericForeignKeyDescriptor(self))
setattr(cls, self.attname, self)
def get_attname_column(self): def get_attname_column(self):
attname, column = super().get_attname_column() attname, column = super().get_attname_column()
@ -161,11 +160,19 @@ class GenericForeignKey(FieldCacheMixin, Field):
# This should never happen. I love comments like this, don't you? # This should never happen. I love comments like this, don't you?
raise Exception("Impossible arguments to GFK.get_content_type!") raise Exception("Impossible arguments to GFK.get_content_type!")
class GenericForeignKeyDescriptor:
def __init__(self, field):
self.field = field
def is_cached(self, instance):
return self.field.is_cached(instance)
def get_prefetch_querysets(self, instances, querysets=None): def get_prefetch_querysets(self, instances, querysets=None):
custom_queryset_dict = {} custom_queryset_dict = {}
if querysets is not None: if querysets is not None:
for queryset in querysets: for queryset in querysets:
ct_id = self.get_content_type( ct_id = self.field.get_content_type(
model=queryset.query.model, using=queryset.db model=queryset.query.model, using=queryset.db
).pk ).pk
if ct_id in custom_queryset_dict: if ct_id in custom_queryset_dict:
@ -179,12 +186,12 @@ class GenericForeignKey(FieldCacheMixin, Field):
fk_dict = defaultdict(set) fk_dict = defaultdict(set)
# We need one instance for each group in order to get the right db: # We need one instance for each group in order to get the right db:
instance_dict = {} instance_dict = {}
ct_attname = self.model._meta.get_field(self.ct_field).attname ct_attname = self.field.model._meta.get_field(self.field.ct_field).attname
for instance in instances: for instance in instances:
# We avoid looking for values if either ct_id or fkey value is None # We avoid looking for values if either ct_id or fkey value is None
ct_id = getattr(instance, ct_attname) ct_id = getattr(instance, ct_attname)
if ct_id is not None: if ct_id is not None:
fk_val = getattr(instance, self.fk_field) fk_val = getattr(instance, self.field.fk_field)
if fk_val is not None: if fk_val is not None:
fk_dict[ct_id].add(fk_val) fk_dict[ct_id].add(fk_val)
instance_dict[ct_id] = instance instance_dict[ct_id] = instance
@ -196,7 +203,7 @@ class GenericForeignKey(FieldCacheMixin, Field):
ret_val.extend(custom_queryset_dict[ct_id].filter(pk__in=fkeys)) ret_val.extend(custom_queryset_dict[ct_id].filter(pk__in=fkeys))
else: else:
instance = instance_dict[ct_id] instance = instance_dict[ct_id]
ct = self.get_content_type(id=ct_id, using=instance._state.db) ct = self.field.get_content_type(id=ct_id, using=instance._state.db)
ret_val.extend(ct.get_all_objects_for_this_type(pk__in=fkeys)) ret_val.extend(ct.get_all_objects_for_this_type(pk__in=fkeys))
# For doing the join in Python, we have to match both the FK val and # For doing the join in Python, we have to match both the FK val and
@ -207,17 +214,17 @@ class GenericForeignKey(FieldCacheMixin, Field):
if ct_id is None: if ct_id is None:
return None return None
else: else:
model = self.get_content_type( model = self.field.get_content_type(
id=ct_id, using=obj._state.db id=ct_id, using=obj._state.db
).model_class() ).model_class()
return str(getattr(obj, self.fk_field)), model return str(getattr(obj, self.field.fk_field)), model
return ( return (
ret_val, ret_val,
lambda obj: (obj._meta.pk.value_to_string(obj), obj.__class__), lambda obj: (obj._meta.pk.value_to_string(obj), obj.__class__),
gfk_key, gfk_key,
True, True,
self.name, self.field.name,
False, False,
) )
@ -229,16 +236,17 @@ class GenericForeignKey(FieldCacheMixin, Field):
# reload the same ContentType over and over (#5570). Instead, get the # reload the same ContentType over and over (#5570). Instead, get the
# content type ID here, and later when the actual instance is needed, # content type ID here, and later when the actual instance is needed,
# use ContentType.objects.get_for_id(), which has a global cache. # use ContentType.objects.get_for_id(), which has a global cache.
f = self.model._meta.get_field(self.ct_field) f = self.field.model._meta.get_field(self.field.ct_field)
ct_id = getattr(instance, f.attname, None) ct_id = getattr(instance, f.attname, None)
pk_val = getattr(instance, self.fk_field) pk_val = getattr(instance, self.field.fk_field)
rel_obj = self.get_cached_value(instance, default=None) rel_obj = self.field.get_cached_value(instance, default=None)
if rel_obj is None and self.is_cached(instance): if rel_obj is None and self.field.is_cached(instance):
return rel_obj return rel_obj
if rel_obj is not None: if rel_obj is not None:
ct_match = ( ct_match = (
ct_id == self.get_content_type(obj=rel_obj, using=instance._state.db).id ct_id
== self.field.get_content_type(obj=rel_obj, using=instance._state.db).id
) )
pk_match = ct_match and rel_obj._meta.pk.to_python(pk_val) == rel_obj.pk pk_match = ct_match and rel_obj._meta.pk.to_python(pk_val) == rel_obj.pk
if pk_match: if pk_match:
@ -246,26 +254,26 @@ class GenericForeignKey(FieldCacheMixin, Field):
else: else:
rel_obj = None rel_obj = None
if ct_id is not None: if ct_id is not None:
ct = self.get_content_type(id=ct_id, using=instance._state.db) ct = self.field.get_content_type(id=ct_id, using=instance._state.db)
try: try:
rel_obj = ct.get_object_for_this_type( rel_obj = ct.get_object_for_this_type(
using=instance._state.db, pk=pk_val using=instance._state.db, pk=pk_val
) )
except ObjectDoesNotExist: except ObjectDoesNotExist:
pass pass
self.set_cached_value(instance, rel_obj) self.field.set_cached_value(instance, rel_obj)
return rel_obj return rel_obj
def __set__(self, instance, value): def __set__(self, instance, value):
ct = None ct = None
fk = None fk = None
if value is not None: if value is not None:
ct = self.get_content_type(obj=value) ct = self.field.get_content_type(obj=value)
fk = value.pk fk = value.pk
setattr(instance, self.ct_field, ct) setattr(instance, self.field.ct_field, ct)
setattr(instance, self.fk_field, fk) setattr(instance, self.field.fk_field, fk)
self.set_cached_value(instance, value) self.field.set_cached_value(instance, value)
class GenericRel(ForeignObjectRel): class GenericRel(ForeignObjectRel):

View File

@ -246,7 +246,8 @@ backends.
Miscellaneous Miscellaneous
------------- -------------
* ... * :class:`~django.contrib.contenttypes.fields.GenericForeignKey` now uses a
separate descriptor class: the private ``GenericForeignKeyDescriptor``.
.. _deprecated-features-6.1: .. _deprecated-features-6.1:

View File

@ -19,15 +19,17 @@ class GenericForeignKeyTests(SimpleTestCase):
object_id = models.PositiveIntegerField() object_id = models.PositiveIntegerField()
content_object = GenericForeignKey() content_object = GenericForeignKey()
field = TaggedItem._meta.get_field("content_object")
expected = [ expected = [
checks.Error( checks.Error(
"The GenericForeignKey content type references the nonexistent " "The GenericForeignKey content type references the nonexistent "
"field 'TaggedItem.content_type'.", "field 'TaggedItem.content_type'.",
obj=TaggedItem.content_object, obj=field,
id="contenttypes.E002", id="contenttypes.E002",
) )
] ]
self.assertEqual(TaggedItem.content_object.check(), expected) self.assertEqual(field.check(), expected)
def test_invalid_content_type_field(self): def test_invalid_content_type_field(self):
class Model(models.Model): class Model(models.Model):
@ -35,8 +37,10 @@ class GenericForeignKeyTests(SimpleTestCase):
object_id = models.PositiveIntegerField() object_id = models.PositiveIntegerField()
content_object = GenericForeignKey("content_type", "object_id") content_object = GenericForeignKey("content_type", "object_id")
field = Model._meta.get_field("content_object")
self.assertEqual( self.assertEqual(
Model.content_object.check(), field.check(),
[ [
checks.Error( checks.Error(
"'Model.content_type' is not a ForeignKey.", "'Model.content_type' is not a ForeignKey.",
@ -44,7 +48,7 @@ class GenericForeignKeyTests(SimpleTestCase):
"GenericForeignKeys must use a ForeignKey to " "GenericForeignKeys must use a ForeignKey to "
"'contenttypes.ContentType' as the 'content_type' field." "'contenttypes.ContentType' as the 'content_type' field."
), ),
obj=Model.content_object, obj=field,
id="contenttypes.E003", id="contenttypes.E003",
) )
], ],
@ -58,8 +62,10 @@ class GenericForeignKeyTests(SimpleTestCase):
object_id = models.PositiveIntegerField() object_id = models.PositiveIntegerField()
content_object = GenericForeignKey("content_type", "object_id") content_object = GenericForeignKey("content_type", "object_id")
field = Model._meta.get_field("content_object")
self.assertEqual( self.assertEqual(
Model.content_object.check(), field.check(),
[ [
checks.Error( checks.Error(
"'Model.content_type' is not a ForeignKey to " "'Model.content_type' is not a ForeignKey to "
@ -68,7 +74,7 @@ class GenericForeignKeyTests(SimpleTestCase):
"GenericForeignKeys must use a ForeignKey to " "GenericForeignKeys must use a ForeignKey to "
"'contenttypes.ContentType' as the 'content_type' field." "'contenttypes.ContentType' as the 'content_type' field."
), ),
obj=Model.content_object, obj=field,
id="contenttypes.E004", id="contenttypes.E004",
) )
], ],
@ -80,13 +86,15 @@ class GenericForeignKeyTests(SimpleTestCase):
# missing object_id field # missing object_id field
content_object = GenericForeignKey() content_object = GenericForeignKey()
field = TaggedItem._meta.get_field("content_object")
self.assertEqual( self.assertEqual(
TaggedItem.content_object.check(), field.check(),
[ [
checks.Error( checks.Error(
"The GenericForeignKey object ID references the nonexistent " "The GenericForeignKey object ID references the nonexistent "
"field 'object_id'.", "field 'object_id'.",
obj=TaggedItem.content_object, obj=field,
id="contenttypes.E001", id="contenttypes.E001",
) )
], ],
@ -98,12 +106,14 @@ class GenericForeignKeyTests(SimpleTestCase):
object_id = models.PositiveIntegerField() object_id = models.PositiveIntegerField()
content_object_ = GenericForeignKey("content_type", "object_id") content_object_ = GenericForeignKey("content_type", "object_id")
field = Model._meta.get_field("content_object_")
self.assertEqual( self.assertEqual(
Model.content_object_.check(), field.check(),
[ [
checks.Error( checks.Error(
"Field names must not end with an underscore.", "Field names must not end with an underscore.",
obj=Model.content_object_, obj=field,
id="fields.E001", id="fields.E001",
) )
], ],

View File

@ -15,13 +15,16 @@ class GenericForeignKeyTests(TestCase):
class Model(models.Model): class Model(models.Model):
field = GenericForeignKey() field = GenericForeignKey()
self.assertEqual(str(Model.field), "contenttypes_tests.Model.field") field = Model._meta.get_field("field")
self.assertEqual(str(field), "contenttypes_tests.Model.field")
def test_get_content_type_no_arguments(self): def test_get_content_type_no_arguments(self):
field = Answer._meta.get_field("question")
with self.assertRaisesMessage( with self.assertRaisesMessage(
Exception, "Impossible arguments to GFK.get_content_type!" Exception, "Impossible arguments to GFK.get_content_type!"
): ):
Answer.question.get_content_type() field.get_content_type()
def test_get_object_cache_respects_deleted_objects(self): def test_get_object_cache_respects_deleted_objects(self):
question = Question.objects.create(text="Who?") question = Question.objects.create(text="Who?")