Browse Source

Fixed #31300 -- Added GeneratedField model field.

Thanks Adam Johnson and Paolo Melchiorre for reviews.

Co-Authored-By: Lily Foote <code@lilyf.org>
Co-Authored-By: Mariusz Felisiak <felisiak.mariusz@gmail.com>
Jeremy Nauta 1 year ago
parent
commit
f333e3513e

+ 5 - 0
django/db/backends/base/features.py

@@ -353,6 +353,11 @@ class BaseDatabaseFeatures:
     # Does the backend support column comments in ADD COLUMN statements?
     # Does the backend support column comments in ADD COLUMN statements?
     supports_comments_inline = False
     supports_comments_inline = False
 
 
+    # Does the backend support stored generated columns?
+    supports_stored_generated_columns = False
+    # Does the backend support virtual generated columns?
+    supports_virtual_generated_columns = False
+
     # Does the backend support the logical XOR operator?
     # Does the backend support the logical XOR operator?
     supports_logical_xor = False
     supports_logical_xor = False
 
 

+ 25 - 1
django/db/backends/base/schema.py

@@ -332,7 +332,9 @@ class BaseDatabaseSchemaEditor:
             and self.connection.features.interprets_empty_strings_as_nulls
             and self.connection.features.interprets_empty_strings_as_nulls
         ):
         ):
             null = True
             null = True
-        if not null:
+        if field.generated:
+            yield self._column_generated_sql(field)
+        elif not null:
             yield "NOT NULL"
             yield "NOT NULL"
         elif not self.connection.features.implied_column_null:
         elif not self.connection.features.implied_column_null:
             yield "NULL"
             yield "NULL"
@@ -422,11 +424,21 @@ class BaseDatabaseSchemaEditor:
             params = []
             params = []
         return sql % default_sql, params
         return sql % default_sql, params
 
 
+    def _column_generated_sql(self, field):
+        """Return the SQL to use in a GENERATED ALWAYS clause."""
+        expression_sql, params = field.generated_sql(self.connection)
+        persistency_sql = "STORED" if field.db_persist else "VIRTUAL"
+        if params:
+            expression_sql = expression_sql % tuple(self.quote_value(p) for p in params)
+        return f"GENERATED ALWAYS AS ({expression_sql}) {persistency_sql}"
+
     @staticmethod
     @staticmethod
     def _effective_default(field):
     def _effective_default(field):
         # This method allows testing its logic without a connection.
         # This method allows testing its logic without a connection.
         if field.has_default():
         if field.has_default():
             default = field.get_default()
             default = field.get_default()
+        elif field.generated:
+            default = None
         elif not field.null and field.blank and field.empty_strings_allowed:
         elif not field.null and field.blank and field.empty_strings_allowed:
             if field.get_internal_type() == "BinaryField":
             if field.get_internal_type() == "BinaryField":
                 default = b""
                 default = b""
@@ -848,6 +860,18 @@ class BaseDatabaseSchemaEditor:
                 "(you cannot alter to or from M2M fields, or add or remove "
                 "(you cannot alter to or from M2M fields, or add or remove "
                 "through= on M2M fields)" % (old_field, new_field)
                 "through= on M2M fields)" % (old_field, new_field)
             )
             )
+        elif old_field.generated != new_field.generated or (
+            new_field.generated
+            and (
+                old_field.db_persist != new_field.db_persist
+                or old_field.generated_sql(self.connection)
+                != new_field.generated_sql(self.connection)
+            )
+        ):
+            raise ValueError(
+                f"Modifying GeneratedFields is not supported - the field {new_field} "
+                "must be removed and re-added with the new definition."
+            )
 
 
         self._alter_field(
         self._alter_field(
             model,
             model,

+ 3 - 0
django/db/backends/mysql/features.py

@@ -60,6 +60,9 @@ class DatabaseFeatures(BaseDatabaseFeatures):
     order_by_nulls_first = True
     order_by_nulls_first = True
     supports_logical_xor = True
     supports_logical_xor = True
 
 
+    supports_stored_generated_columns = True
+    supports_virtual_generated_columns = True
+
     @cached_property
     @cached_property
     def minimum_database_version(self):
     def minimum_database_version(self):
         if self.connection.mysql_is_mariadb:
         if self.connection.mysql_is_mariadb:

+ 2 - 0
django/db/backends/oracle/features.py

@@ -70,6 +70,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
     supports_ignore_conflicts = False
     supports_ignore_conflicts = False
     max_query_params = 2**16 - 1
     max_query_params = 2**16 - 1
     supports_partial_indexes = False
     supports_partial_indexes = False
+    supports_stored_generated_columns = False
+    supports_virtual_generated_columns = True
     can_rename_index = True
     can_rename_index = True
     supports_slicing_ordering_in_compound = True
     supports_slicing_ordering_in_compound = True
     requires_compound_order_by_subquery = True
     requires_compound_order_by_subquery = True

+ 2 - 0
django/db/backends/postgresql/features.py

@@ -70,6 +70,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
     supports_update_conflicts = True
     supports_update_conflicts = True
     supports_update_conflicts_with_target = True
     supports_update_conflicts_with_target = True
     supports_covering_indexes = True
     supports_covering_indexes = True
+    supports_stored_generated_columns = True
+    supports_virtual_generated_columns = False
     can_rename_index = True
     can_rename_index = True
     test_collations = {
     test_collations = {
         "non_default": "sv-x-icu",
         "non_default": "sv-x-icu",

+ 2 - 0
django/db/backends/sqlite3/features.py

@@ -40,6 +40,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
     supports_json_field_contains = False
     supports_json_field_contains = False
     supports_update_conflicts = True
     supports_update_conflicts = True
     supports_update_conflicts_with_target = True
     supports_update_conflicts_with_target = True
+    supports_stored_generated_columns = Database.sqlite_version_info >= (3, 31, 0)
+    supports_virtual_generated_columns = Database.sqlite_version_info >= (3, 31, 0)
     test_collations = {
     test_collations = {
         "ci": "nocase",
         "ci": "nocase",
         "cs": "binary",
         "cs": "binary",

+ 8 - 2
django/db/backends/sqlite3/introspection.py

@@ -91,7 +91,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
         interface.
         interface.
         """
         """
         cursor.execute(
         cursor.execute(
-            "PRAGMA table_info(%s)" % self.connection.ops.quote_name(table_name)
+            "PRAGMA table_xinfo(%s)" % self.connection.ops.quote_name(table_name)
         )
         )
         table_info = cursor.fetchall()
         table_info = cursor.fetchall()
         if not table_info:
         if not table_info:
@@ -129,7 +129,13 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
                 pk == 1,
                 pk == 1,
                 name in json_columns,
                 name in json_columns,
             )
             )
-            for cid, name, data_type, notnull, default, pk in table_info
+            for cid, name, data_type, notnull, default, pk, hidden in table_info
+            if hidden
+            in [
+                0,  # Normal column.
+                2,  # Virtual generated column.
+                3,  # Stored generated column.
+            ]
         ]
         ]
 
 
     def get_sequences(self, cursor, table_name, table_fields=()):
     def get_sequences(self, cursor, table_name, table_fields=()):

+ 1 - 1
django/db/backends/sqlite3/schema.py

@@ -135,7 +135,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
             # Choose a default and insert it into the copy map
             # Choose a default and insert it into the copy map
             if (
             if (
                 create_field.db_default is NOT_PROVIDED
                 create_field.db_default is NOT_PROVIDED
-                and not create_field.many_to_many
+                and not (create_field.many_to_many or create_field.generated)
                 and create_field.concrete
                 and create_field.concrete
             ):
             ):
                 mapping[create_field.column] = self.prepare_default(
                 mapping[create_field.column] = self.prepare_default(

+ 2 - 0
django/db/models/__init__.py

@@ -38,6 +38,7 @@ from django.db.models.expressions import (
 from django.db.models.fields import *  # NOQA
 from django.db.models.fields import *  # NOQA
 from django.db.models.fields import __all__ as fields_all
 from django.db.models.fields import __all__ as fields_all
 from django.db.models.fields.files import FileField, ImageField
 from django.db.models.fields.files import FileField, ImageField
+from django.db.models.fields.generated import GeneratedField
 from django.db.models.fields.json import JSONField
 from django.db.models.fields.json import JSONField
 from django.db.models.fields.proxy import OrderWrt
 from django.db.models.fields.proxy import OrderWrt
 from django.db.models.indexes import *  # NOQA
 from django.db.models.indexes import *  # NOQA
@@ -92,6 +93,7 @@ __all__ += [
     "WindowFrame",
     "WindowFrame",
     "FileField",
     "FileField",
     "ImageField",
     "ImageField",
+    "GeneratedField",
     "JSONField",
     "JSONField",
     "OrderWrt",
     "OrderWrt",
     "Lookup",
     "Lookup",

+ 6 - 5
django/db/models/base.py

@@ -508,7 +508,7 @@ class Model(AltersData, metaclass=ModelBase):
         for field in fields_iter:
         for field in fields_iter:
             is_related_object = False
             is_related_object = False
             # Virtual field
             # Virtual field
-            if field.attname not in kwargs and field.column is None:
+            if field.attname not in kwargs and field.column is None or field.generated:
                 continue
                 continue
             if kwargs:
             if kwargs:
                 if isinstance(field.remote_field, ForeignObjectRel):
                 if isinstance(field.remote_field, ForeignObjectRel):
@@ -1050,10 +1050,11 @@ class Model(AltersData, metaclass=ModelBase):
                         ),
                         ),
                     )["_order__max"]
                     )["_order__max"]
                 )
                 )
-            fields = meta.local_concrete_fields
-            if not pk_set:
-                fields = [f for f in fields if f is not meta.auto_field]
-
+            fields = [
+                f
+                for f in meta.local_concrete_fields
+                if not f.generated and (pk_set or f is not meta.auto_field)
+            ]
             returning_fields = meta.db_returning_fields
             returning_fields = meta.db_returning_fields
             results = self._do_insert(
             results = self._do_insert(
                 cls._base_manager, using, fields, returning_fields, raw
                 cls._base_manager, using, fields, returning_fields, raw

+ 3 - 0
django/db/models/fields/__init__.py

@@ -165,6 +165,7 @@ class Field(RegisterLookupMixin):
     one_to_many = None
     one_to_many = None
     one_to_one = None
     one_to_one = None
     related_model = None
     related_model = None
+    generated = False
 
 
     descriptor_class = DeferredAttribute
     descriptor_class = DeferredAttribute
 
 
@@ -646,6 +647,8 @@ class Field(RegisterLookupMixin):
             path = path.replace("django.db.models.fields.related", "django.db.models")
             path = path.replace("django.db.models.fields.related", "django.db.models")
         elif path.startswith("django.db.models.fields.files"):
         elif path.startswith("django.db.models.fields.files"):
             path = path.replace("django.db.models.fields.files", "django.db.models")
             path = path.replace("django.db.models.fields.files", "django.db.models")
+        elif path.startswith("django.db.models.fields.generated"):
+            path = path.replace("django.db.models.fields.generated", "django.db.models")
         elif path.startswith("django.db.models.fields.json"):
         elif path.startswith("django.db.models.fields.json"):
             path = path.replace("django.db.models.fields.json", "django.db.models")
             path = path.replace("django.db.models.fields.json", "django.db.models")
         elif path.startswith("django.db.models.fields.proxy"):
         elif path.startswith("django.db.models.fields.proxy"):

+ 151 - 0
django/db/models/fields/generated.py

@@ -0,0 +1,151 @@
+from django.core import checks
+from django.db import connections, router
+from django.db.models.sql import Query
+
+from . import NOT_PROVIDED, Field
+
+__all__ = ["GeneratedField"]
+
+
+class GeneratedField(Field):
+    generated = True
+    db_returning = True
+
+    _query = None
+    _resolved_expression = None
+    output_field = None
+
+    def __init__(self, *, expression, db_persist=None, output_field=None, **kwargs):
+        if kwargs.setdefault("editable", False):
+            raise ValueError("GeneratedField cannot be editable.")
+        if not kwargs.setdefault("blank", True):
+            raise ValueError("GeneratedField must be blank.")
+        if kwargs.get("default", NOT_PROVIDED) is not NOT_PROVIDED:
+            raise ValueError("GeneratedField cannot have a default.")
+        if kwargs.get("db_default", NOT_PROVIDED) is not NOT_PROVIDED:
+            raise ValueError("GeneratedField cannot have a database default.")
+        if db_persist not in (True, False):
+            raise ValueError("GeneratedField.db_persist must be True or False.")
+
+        self.expression = expression
+        self._output_field = output_field
+        self.db_persist = db_persist
+        super().__init__(**kwargs)
+
+    def contribute_to_class(self, *args, **kwargs):
+        super().contribute_to_class(*args, **kwargs)
+
+        self._query = Query(model=self.model, alias_cols=False)
+        self._resolved_expression = self.expression.resolve_expression(
+            self._query, allow_joins=False
+        )
+        self.output_field = (
+            self._output_field
+            if self._output_field is not None
+            else self._resolved_expression.output_field
+        )
+        # Register lookups from the output_field class.
+        for lookup_name, lookup in self.output_field.get_class_lookups().items():
+            self.register_lookup(lookup, lookup_name=lookup_name)
+
+    def generated_sql(self, connection):
+        return self._resolved_expression.as_sql(
+            compiler=connection.ops.compiler("SQLCompiler")(
+                self._query, connection=connection, using=None
+            ),
+            connection=connection,
+        )
+
+    def check(self, **kwargs):
+        databases = kwargs.get("databases") or []
+        return [
+            *super().check(**kwargs),
+            *self._check_supported(databases),
+            *self._check_persistence(databases),
+        ]
+
+    def _check_supported(self, databases):
+        errors = []
+        for db in databases:
+            if not router.allow_migrate_model(db, self.model):
+                continue
+            connection = connections[db]
+            if (
+                self.model._meta.required_db_vendor
+                and self.model._meta.required_db_vendor != connection.vendor
+            ):
+                continue
+            if not (
+                connection.features.supports_virtual_generated_columns
+                or "supports_stored_generated_columns"
+                in self.model._meta.required_db_features
+            ) and not (
+                connection.features.supports_stored_generated_columns
+                or "supports_virtual_generated_columns"
+                in self.model._meta.required_db_features
+            ):
+                errors.append(
+                    checks.Error(
+                        f"{connection.display_name} does not support GeneratedFields.",
+                        obj=self,
+                        id="fields.E220",
+                    )
+                )
+        return errors
+
+    def _check_persistence(self, databases):
+        errors = []
+        for db in databases:
+            if not router.allow_migrate_model(db, self.model):
+                continue
+            connection = connections[db]
+            if (
+                self.model._meta.required_db_vendor
+                and self.model._meta.required_db_vendor != connection.vendor
+            ):
+                continue
+            if not self.db_persist and not (
+                connection.features.supports_virtual_generated_columns
+                or "supports_virtual_generated_columns"
+                in self.model._meta.required_db_features
+            ):
+                errors.append(
+                    checks.Error(
+                        f"{connection.display_name} does not support non-persisted "
+                        "GeneratedFields.",
+                        obj=self,
+                        id="fields.E221",
+                        hint="Set db_persist=True on the field.",
+                    )
+                )
+            if self.db_persist and not (
+                connection.features.supports_stored_generated_columns
+                or "supports_stored_generated_columns"
+                in self.model._meta.required_db_features
+            ):
+                errors.append(
+                    checks.Error(
+                        f"{connection.display_name} does not support persisted "
+                        "GeneratedFields.",
+                        obj=self,
+                        id="fields.E222",
+                        hint="Set db_persist=False on the field.",
+                    )
+                )
+        return errors
+
+    def deconstruct(self):
+        name, path, args, kwargs = super().deconstruct()
+        del kwargs["blank"]
+        del kwargs["editable"]
+        kwargs["db_persist"] = self.db_persist
+        kwargs["expression"] = self.expression
+        if self._output_field is not None:
+            kwargs["output_field"] = self._output_field
+        return name, path, args, kwargs
+
+    def get_internal_type(self):
+        return self.output_field.get_internal_type()
+
+    def db_parameters(self, connection):
+        return self.output_field.db_parameters(connection)

+ 3 - 1
django/db/models/query.py

@@ -689,6 +689,8 @@ class QuerySet(AltersData):
                 obj.pk = obj._meta.pk.get_pk_value_on_save(obj)
                 obj.pk = obj._meta.pk.get_pk_value_on_save(obj)
             if not connection.features.supports_default_keyword_in_bulk_insert:
             if not connection.features.supports_default_keyword_in_bulk_insert:
                 for field in obj._meta.fields:
                 for field in obj._meta.fields:
+                    if field.generated:
+                        continue
                     value = getattr(obj, field.attname)
                     value = getattr(obj, field.attname)
                     if isinstance(value, DatabaseDefault):
                     if isinstance(value, DatabaseDefault):
                         setattr(obj, field.attname, field.db_default)
                         setattr(obj, field.attname, field.db_default)
@@ -804,7 +806,7 @@ class QuerySet(AltersData):
             unique_fields,
             unique_fields,
         )
         )
         self._for_write = True
         self._for_write = True
-        fields = opts.concrete_fields
+        fields = [f for f in opts.concrete_fields if not f.generated]
         objs = list(objs)
         objs = list(objs)
         self._prepare_for_bulk_create(objs)
         self._prepare_for_bulk_create(objs)
         with transaction.atomic(using=self.db, savepoint=False):
         with transaction.atomic(using=self.db, savepoint=False):

+ 4 - 0
django/db/models/query_utils.py

@@ -198,6 +198,10 @@ class DeferredAttribute:
             # might be able to reuse the already loaded value. Refs #18343.
             # might be able to reuse the already loaded value. Refs #18343.
             val = self._check_parent_chain(instance)
             val = self._check_parent_chain(instance)
             if val is None:
             if val is None:
+                if instance.pk is None and self.field.generated:
+                    raise FieldError(
+                        "Cannot read a generated field from an unsaved model."
+                    )
                 instance.refresh_from_db(fields=[field_name])
                 instance.refresh_from_db(fields=[field_name])
             else:
             else:
                 data[field_name] = val
                 data[field_name] = val

+ 3 - 0
django/db/models/sql/subqueries.py

@@ -108,6 +108,9 @@ class UpdateQuery(Query):
         called add_update_targets() to hint at the extra information here.
         called add_update_targets() to hint at the extra information here.
         """
         """
         for field, model, val in values_seq:
         for field, model, val in values_seq:
+            # Omit generated fields.
+            if field.generated:
+                continue
             if hasattr(val, "resolve_expression"):
             if hasattr(val, "resolve_expression"):
                 # Resolve expressions here so that annotations are no longer needed
                 # Resolve expressions here so that annotations are no longer needed
                 val = val.resolve_expression(self, allow_joins=False, for_save=True)
                 val = val.resolve_expression(self, allow_joins=False, for_save=True)

+ 5 - 0
docs/ref/checks.txt

@@ -208,6 +208,11 @@ Model fields
 * **fields.E180**: ``<database>`` does not support ``JSONField``\s.
 * **fields.E180**: ``<database>`` does not support ``JSONField``\s.
 * **fields.E190**: ``<database>`` does not support a database collation on
 * **fields.E190**: ``<database>`` does not support a database collation on
   ``<field_type>``\s.
   ``<field_type>``\s.
+* **fields.E220**: ``<database>`` does not support ``GeneratedField``\s.
+* **fields.E221**: ``<database>`` does not support non-persisted
+  ``GeneratedField``\s.
+* **fields.E222**: ``<database>`` does not support persisted
+  ``GeneratedField``\s.
 * **fields.E900**: ``IPAddressField`` has been removed except for support in
 * **fields.E900**: ``IPAddressField`` has been removed except for support in
   historical migrations.
   historical migrations.
 * **fields.W900**: ``IPAddressField`` has been deprecated. Support for it
 * **fields.W900**: ``IPAddressField`` has been deprecated. Support for it

+ 65 - 0
docs/ref/models/fields.txt

@@ -1215,6 +1215,71 @@ when :attr:`~django.forms.Field.localize` is ``False`` or
     information on the difference between the two, see Python's documentation
     information on the difference between the two, see Python's documentation
     for the :mod:`decimal` module.
     for the :mod:`decimal` module.
 
 
+``GeneratedField``
+------------------
+
+.. versionadded:: 5.0
+
+.. class:: GeneratedField(expression, db_persist=None, output_field=None, **kwargs)
+
+A field that is always computed based on other fields in the model. This field
+is managed and updated by the database itself. Uses the ``GENERATED ALWAYS``
+SQL syntax.
+
+There are two kinds of generated columns: stored and virtual. A stored
+generated column is computed when it is written (inserted or updated) and
+occupies storage as if it were a regular column. A virtual generated column
+occupies no storage and is computed when it is read. Thus, a virtual generated
+column is similar to a view and a stored generated column is similar to a
+materialized view.
+
+.. attribute:: GeneratedField.expression
+
+    An :class:`Expression` used by the database to automatically set the field
+    value each time the model is changed.
+
+    The expressions should be deterministic and only reference fields within
+    the model (in the same database table). Generated fields cannot reference
+    other generated fields. Database backends can impose further restrictions.
+
+.. attribute:: GeneratedField.db_persist
+
+    Determines if the database column should occupy storage as if it were a
+    real column. If ``False``, the column acts as a virtual column and does
+    not occupy database storage space.
+
+    PostgreSQL only supports persisted columns. Oracle only supports virtual
+    columns.
+
+.. attribute:: GeneratedField.output_field
+
+    An optional model field instance to define the field's data type. This can
+    be used to customize attributes like the field's collation. By default, the
+    output field is derived from ``expression``.
+
+.. admonition:: Refresh the data
+
+    Since the database always computed the value, the object must be reloaded
+    to access the new value after :meth:`~Model.save()`, for example, by using
+    :meth:`~Model.refresh_from_db()`.
+
+.. admonition:: Database limitations
+
+    There are many database-specific restrictions on generated fields that
+    Django doesn't validate and the database may raise an error e.g. PostgreSQL
+    requires functions and operators referenced in a generated columns to be
+    marked as ``IMMUTABLE`` .
+
+    You should always check that ``expression`` is supported on your database.
+    Check out `MariaDB`_, `MySQL`_, `Oracle`_, `PostgreSQL`_, or `SQLite`_
+    docs.
+
+.. _MariaDB: https://mariadb.com/kb/en/generated-columns/#expression-support
+.. _MySQL: https://dev.mysql.com/doc/refman/en/create-table-generated-columns.html
+.. _Oracle: https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/CREATE-TABLE.html#GUID-F9CE0CC3-13AE-4744-A43C-EAC7A71AAAB6__BABIIGBD
+.. _PostgreSQL: https://www.postgresql.org/docs/current/ddl-generated-columns.html
+.. _SQLite: https://www.sqlite.org/gencol.html#limitations
+
 ``GenericIPAddressField``
 ``GenericIPAddressField``
 -------------------------
 -------------------------
 
 

+ 7 - 0
docs/releases/5.0.txt

@@ -129,6 +129,13 @@ sets a database-computed default value. For example::
         created = models.DateTimeField(db_default=Now())
         created = models.DateTimeField(db_default=Now())
         circumference = models.FloatField(db_default=2 * Pi())
         circumference = models.FloatField(db_default=2 * Pi())
 
 
+Database generated model field
+------------------------------
+
+The new :class:`~django.db.models.GeneratedField` allows creation of database
+generated columns. This field can be used on all supported database backends
+to create a field that is always computed from other fields.
+
 More options for declaring field choices
 More options for declaring field choices
 ----------------------------------------
 ----------------------------------------
 
 

+ 112 - 0
tests/invalid_models_tests/test_ordinary_fields.py

@@ -1226,3 +1226,115 @@ class InvalidDBDefaultTests(TestCase):
         msg = f"{expression} cannot be used in db_default."
         msg = f"{expression} cannot be used in db_default."
         expected_error = Error(msg=msg, obj=field, id="fields.E012")
         expected_error = Error(msg=msg, obj=field, id="fields.E012")
         self.assertEqual(errors, [expected_error])
         self.assertEqual(errors, [expected_error])
+
+
+@isolate_apps("invalid_models_tests")
+class GeneratedFieldTests(TestCase):
+    def test_not_supported(self):
+        db_persist = connection.features.supports_stored_generated_columns
+
+        class Model(models.Model):
+            name = models.IntegerField()
+            field = models.GeneratedField(
+                expression=models.F("name"), db_persist=db_persist
+            )
+
+        expected_errors = []
+        if (
+            not connection.features.supports_stored_generated_columns
+            and not connection.features.supports_virtual_generated_columns
+        ):
+            expected_errors.append(
+                Error(
+                    f"{connection.display_name} does not support GeneratedFields.",
+                    obj=Model._meta.get_field("field"),
+                    id="fields.E220",
+                )
+            )
+        if (
+            not db_persist
+            and not connection.features.supports_virtual_generated_columns
+        ):
+            expected_errors.append(
+                Error(
+                    f"{connection.display_name} does not support non-persisted "
+                    "GeneratedFields.",
+                    obj=Model._meta.get_field("field"),
+                    id="fields.E221",
+                    hint="Set db_persist=True on the field.",
+                ),
+            )
+        self.assertEqual(
+            Model._meta.get_field("field").check(databases={"default"}),
+            expected_errors,
+        )
+
+    def test_not_supported_stored_required_db_features(self):
+        class Model(models.Model):
+            name = models.IntegerField()
+            field = models.GeneratedField(expression=models.F("name"), db_persist=True)
+
+            class Meta:
+                required_db_features = {"supports_stored_generated_columns"}
+
+        self.assertEqual(Model.check(databases=self.databases), [])
+
+    def test_not_supported_virtual_required_db_features(self):
+        class Model(models.Model):
+            name = models.IntegerField()
+            field = models.GeneratedField(expression=models.F("name"), db_persist=False)
+
+            class Meta:
+                required_db_features = {"supports_virtual_generated_columns"}
+
+        self.assertEqual(Model.check(databases=self.databases), [])
+
+    @skipUnlessDBFeature("supports_stored_generated_columns")
+    def test_not_supported_virtual(self):
+        class Model(models.Model):
+            name = models.IntegerField()
+            field = models.GeneratedField(expression=models.F("name"), db_persist=False)
+            a = models.TextField()
+
+        excepted_errors = (
+            []
+            if connection.features.supports_virtual_generated_columns
+            else [
+                Error(
+                    f"{connection.display_name} does not support non-persisted "
+                    "GeneratedFields.",
+                    obj=Model._meta.get_field("field"),
+                    id="fields.E221",
+                    hint="Set db_persist=True on the field.",
+                ),
+            ]
+        )
+        self.assertEqual(
+            Model._meta.get_field("field").check(databases={"default"}),
+            excepted_errors,
+        )
+
+    @skipUnlessDBFeature("supports_virtual_generated_columns")
+    def test_not_supported_stored(self):
+        class Model(models.Model):
+            name = models.IntegerField()
+            field = models.GeneratedField(expression=models.F("name"), db_persist=True)
+            a = models.TextField()
+
+        expected_errors = (
+            []
+            if connection.features.supports_stored_generated_columns
+            else [
+                Error(
+                    f"{connection.display_name} does not support persisted "
+                    "GeneratedFields.",
+                    obj=Model._meta.get_field("field"),
+                    id="fields.E222",
+                    hint="Set db_persist=False on the field.",
+                ),
+            ]
+        )
+        self.assertEqual(
+            Model._meta.get_field("field").check(databases={"default"}),
+            expected_errors,
+        )

+ 125 - 0
tests/migrations/test_operations.py

@@ -5,6 +5,7 @@ from django.db import IntegrityError, connection, migrations, models, transactio
 from django.db.migrations.migration import Migration
 from django.db.migrations.migration import Migration
 from django.db.migrations.operations.fields import FieldOperation
 from django.db.migrations.operations.fields import FieldOperation
 from django.db.migrations.state import ModelState, ProjectState
 from django.db.migrations.state import ModelState, ProjectState
+from django.db.models import F
 from django.db.models.expressions import Value
 from django.db.models.expressions import Value
 from django.db.models.functions import Abs, Pi
 from django.db.models.functions import Abs, Pi
 from django.db.transaction import atomic
 from django.db.transaction import atomic
@@ -5741,6 +5742,130 @@ class OperationTests(OperationTestBase):
             operation.database_backwards(app_label, editor, new_state, project_state)
             operation.database_backwards(app_label, editor, new_state, project_state)
         assertModelsAndTables(after_db=False)
         assertModelsAndTables(after_db=False)
 
 
+    def _test_invalid_generated_field_changes(self, db_persist):
+        regular = models.IntegerField(default=1)
+        generated_1 = models.GeneratedField(
+            expression=F("pink") + F("pink"), db_persist=db_persist
+        )
+        generated_2 = models.GeneratedField(
+            expression=F("pink") + F("pink") + F("pink"), db_persist=db_persist
+        )
+        tests = [
+            ("test_igfc_1", regular, generated_1),
+            ("test_igfc_2", generated_1, regular),
+            ("test_igfc_3", generated_1, generated_2),
+        ]
+        for app_label, add_field, alter_field in tests:
+            project_state = self.set_up_test_model(app_label)
+            operations = [
+                migrations.AddField("Pony", "modified_pink", add_field),
+                migrations.AlterField("Pony", "modified_pink", alter_field),
+            ]
+            msg = (
+                "Modifying GeneratedFields is not supported - the field "
+                f"{app_label}.Pony.modified_pink must be removed and re-added with the "
+                "new definition."
+            )
+            with self.assertRaisesMessage(ValueError, msg):
+                self.apply_operations(app_label, project_state, operations)
+
+    @skipUnlessDBFeature("supports_stored_generated_columns")
+    def test_invalid_generated_field_changes_stored(self):
+        self._test_invalid_generated_field_changes(db_persist=True)
+
+    @skipUnlessDBFeature("supports_virtual_generated_columns")
+    def test_invalid_generated_field_changes_virtual(self):
+        self._test_invalid_generated_field_changes(db_persist=False)
+
+    @skipUnlessDBFeature(
+        "supports_stored_generated_columns",
+        "supports_virtual_generated_columns",
+    )
+    def test_invalid_generated_field_persistency_change(self):
+        app_label = "test_igfpc"
+        project_state = self.set_up_test_model(app_label)
+        operations = [
+            migrations.AddField(
+                "Pony",
+                "modified_pink",
+                models.GeneratedField(expression=F("pink"), db_persist=True),
+            ),
+            migrations.AlterField(
+                "Pony",
+                "modified_pink",
+                models.GeneratedField(expression=F("pink"), db_persist=False),
+            ),
+        ]
+        msg = (
+            "Modifying GeneratedFields is not supported - the field "
+            f"{app_label}.Pony.modified_pink must be removed and re-added with the "
+            "new definition."
+        )
+        with self.assertRaisesMessage(ValueError, msg):
+            self.apply_operations(app_label, project_state, operations)
+
+    def _test_add_generated_field(self, db_persist):
+        app_label = "test_agf"
+        operation = migrations.AddField(
+            "Pony",
+            "modified_pink",
+            models.GeneratedField(
+                expression=F("pink") + F("pink"), db_persist=db_persist
+            ),
+        )
+        project_state, new_state = self.make_test_state(app_label, operation)
+        self.assertEqual(len(new_state.models[app_label, "pony"].fields), 6)
+        # Add generated column.
+        with connection.schema_editor() as editor:
+            operation.database_forwards(app_label, editor, project_state, new_state)
+        self.assertColumnExists(f"{app_label}_pony", "modified_pink")
+        Pony = new_state.apps.get_model(app_label, "Pony")
+        obj = Pony.objects.create(pink=5, weight=3.23)
+        self.assertEqual(obj.modified_pink, 10)
+        # Reversal.
+        with connection.schema_editor() as editor:
+            operation.database_backwards(app_label, editor, new_state, project_state)
+        self.assertColumnNotExists(f"{app_label}_pony", "modified_pink")
+
+    @skipUnlessDBFeature("supports_stored_generated_columns")
+    def test_add_generated_field_stored(self):
+        self._test_add_generated_field(db_persist=True)
+
+    @skipUnlessDBFeature("supports_virtual_generated_columns")
+    def test_add_generated_field_virtual(self):
+        self._test_add_generated_field(db_persist=False)
+
+    def _test_remove_generated_field(self, db_persist):
+        app_label = "test_rgf"
+        operation = migrations.AddField(
+            "Pony",
+            "modified_pink",
+            models.GeneratedField(
+                expression=F("pink") + F("pink"), db_persist=db_persist
+            ),
+        )
+        project_state, new_state = self.make_test_state(app_label, operation)
+        self.assertEqual(len(new_state.models[app_label, "pony"].fields), 6)
+        # Add generated column.
+        with connection.schema_editor() as editor:
+            operation.database_forwards(app_label, editor, project_state, new_state)
+        project_state = new_state
+        new_state = project_state.clone()
+        operation = migrations.RemoveField("Pony", "modified_pink")
+        operation.state_forwards(app_label, new_state)
+        # Remove generated column.
+        with connection.schema_editor() as editor:
+            operation.database_forwards(app_label, editor, project_state, new_state)
+        self.assertColumnNotExists(f"{app_label}_pony", "modified_pink")
+
+    @skipUnlessDBFeature("supports_stored_generated_columns")
+    def test_remove_generated_field_stored(self):
+        self._test_remove_generated_field(db_persist=True)
+
+    @skipUnlessDBFeature("supports_virtual_generated_columns")
+    def test_remove_generated_field_virtual(self):
+        self._test_remove_generated_field(db_persist=False)
+
 
 
 class SwappableOperationTests(OperationTestBase):
 class SwappableOperationTests(OperationTestBase):
     """
     """

+ 97 - 1
tests/model_fields/models.py

@@ -6,8 +6,11 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelatio
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.files.storage import FileSystemStorage
 from django.core.files.storage import FileSystemStorage
 from django.core.serializers.json import DjangoJSONEncoder
 from django.core.serializers.json import DjangoJSONEncoder
-from django.db import models
+from django.db import connection, models
+from django.db.models import F, Value
 from django.db.models.fields.files import ImageFieldFile
 from django.db.models.fields.files import ImageFieldFile
+from django.db.models.functions import Lower
+from django.utils.functional import SimpleLazyObject
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 try:
 try:
@@ -16,6 +19,11 @@ except ImportError:
     Image = None
     Image = None
 
 
 
 
+test_collation = SimpleLazyObject(
+    lambda: connection.features.test_collations.get("non_default")
+)
+
+
 class Foo(models.Model):
 class Foo(models.Model):
     a = models.CharField(max_length=10)
     a = models.CharField(max_length=10)
     d = models.DecimalField(max_digits=5, decimal_places=3)
     d = models.DecimalField(max_digits=5, decimal_places=3)
@@ -468,3 +476,91 @@ class UUIDChild(PrimaryKeyUUIDModel):
 
 
 class UUIDGrandchild(UUIDChild):
 class UUIDGrandchild(UUIDChild):
     pass
     pass
+
+
+class GeneratedModel(models.Model):
+    a = models.IntegerField()
+    b = models.IntegerField()
+    field = models.GeneratedField(expression=F("a") + F("b"), db_persist=True)
+
+    class Meta:
+        required_db_features = {"supports_stored_generated_columns"}
+
+
+class GeneratedModelVirtual(models.Model):
+    a = models.IntegerField()
+    b = models.IntegerField()
+    field = models.GeneratedField(expression=F("a") + F("b"), db_persist=False)
+
+    class Meta:
+        required_db_features = {"supports_virtual_generated_columns"}
+
+
+class GeneratedModelParams(models.Model):
+    field = models.GeneratedField(
+        expression=Value("Constant", output_field=models.CharField(max_length=10)),
+        db_persist=True,
+    )
+
+    class Meta:
+        required_db_features = {"supports_stored_generated_columns"}
+
+
+class GeneratedModelParamsVirtual(models.Model):
+    field = models.GeneratedField(
+        expression=Value("Constant", output_field=models.CharField(max_length=10)),
+        db_persist=False,
+    )
+
+    class Meta:
+        required_db_features = {"supports_virtual_generated_columns"}
+
+
+class GeneratedModelOutputField(models.Model):
+    name = models.CharField(max_length=10)
+    lower_name = models.GeneratedField(
+        expression=Lower("name"),
+        output_field=models.CharField(db_collation=test_collation, max_length=11),
+        db_persist=True,
+    )
+
+    class Meta:
+        required_db_features = {
+            "supports_stored_generated_columns",
+            "supports_collation_on_charfield",
+        }
+
+
+class GeneratedModelOutputFieldVirtual(models.Model):
+    name = models.CharField(max_length=10)
+    lower_name = models.GeneratedField(
+        expression=Lower("name"),
+        db_persist=False,
+        output_field=models.CharField(db_collation=test_collation, max_length=11),
+    )
+
+    class Meta:
+        required_db_features = {
+            "supports_virtual_generated_columns",
+            "supports_collation_on_charfield",
+        }
+
+
+class GeneratedModelNull(models.Model):
+    name = models.CharField(max_length=10, null=True)
+    lower_name = models.GeneratedField(
+        expression=Lower("name"), db_persist=True, null=True
+    )
+
+    class Meta:
+        required_db_features = {"supports_stored_generated_columns"}
+
+
+class GeneratedModelNullVirtual(models.Model):
+    name = models.CharField(max_length=10, null=True)
+    lower_name = models.GeneratedField(
+        expression=Lower("name"), db_persist=False, null=True
+    )
+
+    class Meta:
+        required_db_features = {"supports_virtual_generated_columns"}

+ 176 - 0
tests/model_fields/test_generatedfield.py

@@ -0,0 +1,176 @@
+from django.core.exceptions import FieldError
+from django.db import IntegrityError, connection
+from django.db.models import F, GeneratedField, IntegerField
+from django.db.models.functions import Lower
+from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature
+
+from .models import (
+    GeneratedModel,
+    GeneratedModelNull,
+    GeneratedModelNullVirtual,
+    GeneratedModelOutputField,
+    GeneratedModelOutputFieldVirtual,
+    GeneratedModelParams,
+    GeneratedModelParamsVirtual,
+    GeneratedModelVirtual,
+)
+
+
+class BaseGeneratedFieldTests(SimpleTestCase):
+    def test_editable_unsupported(self):
+        with self.assertRaisesMessage(ValueError, "GeneratedField cannot be editable."):
+            GeneratedField(expression=Lower("name"), editable=True, db_persist=False)
+
+    def test_blank_unsupported(self):
+        with self.assertRaisesMessage(ValueError, "GeneratedField must be blank."):
+            GeneratedField(expression=Lower("name"), blank=False, db_persist=False)
+
+    def test_default_unsupported(self):
+        msg = "GeneratedField cannot have a default."
+        with self.assertRaisesMessage(ValueError, msg):
+            GeneratedField(expression=Lower("name"), default="", db_persist=False)
+
+    def test_database_default_unsupported(self):
+        msg = "GeneratedField cannot have a database default."
+        with self.assertRaisesMessage(ValueError, msg):
+            GeneratedField(expression=Lower("name"), db_default="", db_persist=False)
+
+    def test_db_persist_required(self):
+        msg = "GeneratedField.db_persist must be True or False."
+        with self.assertRaisesMessage(ValueError, msg):
+            GeneratedField(expression=Lower("name"))
+        with self.assertRaisesMessage(ValueError, msg):
+            GeneratedField(expression=Lower("name"), db_persist=None)
+
+    def test_deconstruct(self):
+        field = GeneratedField(expression=F("a") + F("b"), db_persist=True)
+        _, path, args, kwargs = field.deconstruct()
+        self.assertEqual(path, "django.db.models.GeneratedField")
+        self.assertEqual(args, [])
+        self.assertEqual(kwargs, {"db_persist": True, "expression": F("a") + F("b")})
+
+
+class GeneratedFieldTestMixin:
+    def _refresh_if_needed(self, m):
+        if not connection.features.can_return_columns_from_insert:
+            m.refresh_from_db()
+        return m
+
+    def test_unsaved_error(self):
+        m = self.base_model(a=1, b=2)
+        msg = "Cannot read a generated field from an unsaved model."
+        with self.assertRaisesMessage(FieldError, msg):
+            m.field
+
+    def test_create(self):
+        m = self.base_model.objects.create(a=1, b=2)
+        m = self._refresh_if_needed(m)
+        self.assertEqual(m.field, 3)
+
+    def test_non_nullable_create(self):
+        with self.assertRaises(IntegrityError):
+            self.base_model.objects.create()
+
+    def test_save(self):
+        # Insert.
+        m = self.base_model(a=2, b=4)
+        m.save()
+        m = self._refresh_if_needed(m)
+        self.assertEqual(m.field, 6)
+        # Update.
+        m.a = 4
+        m.save()
+        m.refresh_from_db()
+        self.assertEqual(m.field, 8)
+
+    def test_update(self):
+        m = self.base_model.objects.create(a=1, b=2)
+        self.base_model.objects.update(b=3)
+        m = self.base_model.objects.get(pk=m.pk)
+        self.assertEqual(m.field, 4)
+
+    def test_bulk_create(self):
+        m = self.base_model(a=3, b=4)
+        (m,) = self.base_model.objects.bulk_create([m])
+        if not connection.features.can_return_rows_from_bulk_insert:
+            m = self.base_model.objects.get()
+        self.assertEqual(m.field, 7)
+
+    def test_bulk_update(self):
+        m = self.base_model.objects.create(a=1, b=2)
+        m.a = 3
+        self.base_model.objects.bulk_update([m], fields=["a"])
+        m = self.base_model.objects.get(pk=m.pk)
+        self.assertEqual(m.field, 5)
+
+    def test_output_field_lookups(self):
+        """Lookups from the output_field are available on GeneratedFields."""
+        internal_type = IntegerField().get_internal_type()
+        min_value, max_value = connection.ops.integer_field_range(internal_type)
+        if min_value is None:
+            self.skipTest("Backend doesn't define an integer min value.")
+        if max_value is None:
+            self.skipTest("Backend doesn't define an integer max value.")
+
+        does_not_exist = self.base_model.DoesNotExist
+        underflow_value = min_value - 1
+        with self.assertNumQueries(0), self.assertRaises(does_not_exist):
+            self.base_model.objects.get(field=underflow_value)
+        with self.assertNumQueries(0), self.assertRaises(does_not_exist):
+            self.base_model.objects.get(field__lt=underflow_value)
+        with self.assertNumQueries(0), self.assertRaises(does_not_exist):
+            self.base_model.objects.get(field__lte=underflow_value)
+
+        overflow_value = max_value + 1
+        with self.assertNumQueries(0), self.assertRaises(does_not_exist):
+            self.base_model.objects.get(field=overflow_value)
+        with self.assertNumQueries(0), self.assertRaises(does_not_exist):
+            self.base_model.objects.get(field__gt=overflow_value)
+        with self.assertNumQueries(0), self.assertRaises(does_not_exist):
+            self.base_model.objects.get(field__gte=overflow_value)
+
+    @skipUnlessDBFeature("supports_collation_on_charfield")
+    def test_output_field(self):
+        collation = connection.features.test_collations.get("non_default")
+        if not collation:
+            self.skipTest("Language collations are not supported.")
+
+        m = self.output_field_model.objects.create(name="NAME")
+        field = m._meta.get_field("lower_name")
+        db_parameters = field.db_parameters(connection)
+        self.assertEqual(db_parameters["collation"], collation)
+        self.assertEqual(db_parameters["type"], field.output_field.db_type(connection))
+        self.assertNotEqual(
+            db_parameters["type"],
+            field._resolved_expression.output_field.db_type(connection),
+        )
+
+    def test_model_with_params(self):
+        m = self.params_model.objects.create()
+        m = self._refresh_if_needed(m)
+        self.assertEqual(m.field, "Constant")
+
+    def test_nullable(self):
+        m1 = self.nullable_model.objects.create()
+        m1 = self._refresh_if_needed(m1)
+        none_val = "" if connection.features.interprets_empty_strings_as_nulls else None
+        self.assertEqual(m1.lower_name, none_val)
+        m2 = self.nullable_model.objects.create(name="NaMe")
+        m2 = self._refresh_if_needed(m2)
+        self.assertEqual(m2.lower_name, "name")
+
+
+@skipUnlessDBFeature("supports_stored_generated_columns")
+class StoredGeneratedFieldTests(GeneratedFieldTestMixin, TestCase):
+    base_model = GeneratedModel
+    nullable_model = GeneratedModelNull
+    output_field_model = GeneratedModelOutputField
+    params_model = GeneratedModelParams
+
+
+@skipUnlessDBFeature("supports_virtual_generated_columns")
+class VirtualGeneratedFieldTests(GeneratedFieldTestMixin, TestCase):
+    base_model = GeneratedModelVirtual
+    nullable_model = GeneratedModelNullVirtual
+    output_field_model = GeneratedModelOutputFieldVirtual
+    params_model = GeneratedModelParamsVirtual