Repository: django/django
Issue category: Performance Issue

Cache Model._meta.swapped
Description
	 
Another candidate for caching, like #35232 before.
The Model._meta.swapped property returns the model that this one has been swapped for. Since most models are not swappable (only auth.User is officially swappable), it returns None in nearly all cases.
I found this property was the most called function in Django when profiling a subset of Django’s tests, with:
$ python -m cProfile -o profile runtests.py --parallel 1 *model*
$ python -m pstats profile <<< 'sort ncalls
stats 10000' | less
This showed 439,484 calls to swapped, taking 29ms of the 5.597s test run, or 0.5% of the total runtime.
After adding @cached_property, this is reduced to 3,653 calls, rounding down to 0ms.


Functions to edit:
django/db/models/options.py:Options.contribute_to_class
django/db/models/options.py:Options
Functions added: django/db/models/options.py:Options
django/db/models/options.py:Options.setting_changed

Patch:
diff --git a/django/db/models/options.py b/django/db/models/options.py
index ed7be7dd7a84..68a7228cbea6 100644
--- a/django/db/models/options.py
+++ b/django/db/models/options.py
@@ -5,6 +5,7 @@
 from django.apps import apps
 from django.conf import settings
 from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
+from django.core.signals import setting_changed
 from django.db import connections
 from django.db.models import AutoField, Manager, OrderWrt, UniqueConstraint
 from django.db.models.query_utils import PathInfo
@@ -230,6 +231,9 @@ def contribute_to_class(self, cls, name):
                 self.db_table, connection.ops.max_name_length()
             )
 
+        if self.swappable:
+            setting_changed.connect(self.setting_changed)
+
     def _format_names(self, objs):
         """App label/class name interpolation for object names."""
         names = {"app_label": self.app_label.lower(), "class": self.model_name}
@@ -399,7 +403,7 @@ def verbose_name_raw(self):
         with override(None):
             return str(self.verbose_name)
 
-    @property
+    @cached_property
     def swapped(self):
         """
         Has this model been swapped out for another? If so, return the model
@@ -427,6 +431,10 @@ def swapped(self):
                     return swapped_for
         return None
 
+    def setting_changed(self, *, setting, **kwargs):
+        if setting == self.swappable and "swapped" in self.__dict__:
+            del self.swapped
+
     @cached_property
     def managers(self):
         managers = []

Test patch: diff --git a/tests/model_meta/models.py b/tests/model_meta/models.py
index bc69d61a59cd..20a75baf4f2e 100644
--- a/tests/model_meta/models.py
+++ b/tests/model_meta/models.py
@@ -166,6 +166,11 @@ class Relating(models.Model):
     people_hidden = models.ManyToManyField(Person, related_name="+")
 
 
+class Swappable(models.Model):
+    class Meta:
+        swappable = "MODEL_META_TESTS_SWAPPED"
+
+
 # ParentListTests models
 class CommonAncestor(models.Model):
     pass
diff --git a/tests/model_meta/tests.py b/tests/model_meta/tests.py
index 0aa04d760d19..93883b5cf1c3 100644
--- a/tests/model_meta/tests.py
+++ b/tests/model_meta/tests.py
@@ -3,7 +3,7 @@
 from django.core.exceptions import FieldDoesNotExist
 from django.db.models import CharField, Field, ForeignObjectRel, ManyToManyField
 from django.db.models.options import EMPTY_RELATION_TREE, IMMUTABLE_WARNING
-from django.test import SimpleTestCase
+from django.test import SimpleTestCase, override_settings
 
 from .models import (
     AbstractPerson,
@@ -16,6 +16,7 @@
     Relating,
     Relation,
     SecondParent,
+    Swappable,
 )
 from .results import TEST_RESULTS
 
@@ -233,6 +234,31 @@ def test_gettext(self):
         self.assertEqual(Person._meta.verbose_name_raw, "Person")
 
 
+class SwappedTests(SimpleTestCase):
+    def test_plain_model_none(self):
+        self.assertIsNone(Relation._meta.swapped)
+
+    def test_unset(self):
+        self.assertIsNone(Swappable._meta.swapped)
+
+    def test_set_and_unset(self):
+        with override_settings(MODEL_META_TESTS_SWAPPED="model_meta.Relation"):
+            self.assertEqual(Swappable._meta.swapped, "model_meta.Relation")
+        self.assertIsNone(Swappable._meta.swapped)
+
+    def test_setting_none(self):
+        with override_settings(MODEL_META_TESTS_SWAPPED=None):
+            self.assertIsNone(Swappable._meta.swapped)
+
+    def test_setting_non_label(self):
+        with override_settings(MODEL_META_TESTS_SWAPPED="not-a-label"):
+            self.assertEqual(Swappable._meta.swapped, "not-a-label")
+
+    def test_setting_self(self):
+        with override_settings(MODEL_META_TESTS_SWAPPED="model_meta.swappable"):
+            self.assertIsNone(Swappable._meta.swapped)
+
+
 class RelationTreeTests(SimpleTestCase):
     all_models = (Relation, AbstractPerson, BasePerson, Person, ProxyPerson, Relating)