Browse Source

Fixed #18468 -- Added support for comments on columns and tables.

Thanks Jared Chung, Tom Carrick, David Smith, Nick Pope, and Mariusz
Felisiak for reviews.

Co-authored-by: Mariusz Felisiak <felisiak.mariusz@gmail.com>
Co-authored-by: Nick Pope <nick@nickpope.me.uk>
kimsoungryoul 2 years ago
parent
commit
78f163a4fb
35 changed files with 846 additions and 37 deletions
  1. 1 0
      AUTHORS
  2. 31 13
      django/core/management/commands/inspectdb.py
  3. 5 0
      django/db/backends/base/features.py
  4. 87 4
      django/db/backends/base/schema.py
  5. 2 0
      django/db/backends/mysql/features.py
  6. 19 6
      django/db/backends/mysql/introspection.py
  7. 12 1
      django/db/backends/mysql/schema.py
  8. 1 0
      django/db/backends/oracle/features.py
  9. 32 8
      django/db/backends/oracle/introspection.py
  10. 1 0
      django/db/backends/postgresql/features.py
  11. 7 4
      django/db/backends/postgresql/introspection.py
  12. 23 0
      django/db/migrations/autodetector.py
  13. 2 0
      django/db/migrations/operations/__init__.py
  14. 38 0
      django/db/migrations/operations/models.py
  15. 24 0
      django/db/models/base.py
  16. 26 0
      django/db/models/fields/__init__.py
  17. 8 0
      django/db/models/fields/related.py
  18. 2 0
      django/db/models/options.py
  19. 5 0
      docs/ref/checks.txt
  20. 11 0
      docs/ref/migration-operations.txt
  21. 15 0
      docs/ref/models/fields.txt
  22. 18 0
      docs/ref/models/options.txt
  23. 9 0
      docs/ref/schema-editor.txt
  24. 35 0
      docs/releases/4.2.txt
  25. 8 0
      tests/inspectdb/models.py
  26. 18 0
      tests/inspectdb/tests.py
  27. 8 0
      tests/introspection/models.py
  28. 21 0
      tests/introspection/tests.py
  29. 31 0
      tests/invalid_models_tests/test_models.py
  30. 32 0
      tests/invalid_models_tests/test_ordinary_fields.py
  31. 8 1
      tests/invalid_models_tests/test_relative_fields.py
  32. 60 0
      tests/migrations/test_autodetector.py
  33. 14 0
      tests/migrations/test_base.py
  34. 31 0
      tests/migrations/test_operations.py
  35. 201 0
      tests/schema/tests.py

+ 1 - 0
AUTHORS

@@ -557,6 +557,7 @@ answer newbie questions, and generally made Django that much better:
     Kieran Holland <http://www.kieranholland.com>
     kilian <kilian.cavalotti@lip6.fr>
     Kim Joon Hwan 김준환 <xncbf12@gmail.com>
+    Kim Soung Ryoul 김성렬 <kimsoungryoul@gmail.com>
     Klaas van Schelven <klaas@vanschelven.com>
     knox <christobzr@gmail.com>
     konrad@gwu.edu

+ 31 - 13
django/core/management/commands/inspectdb.py

@@ -78,18 +78,16 @@ class Command(BaseCommand):
             )
             yield "from %s import models" % self.db_module
             known_models = []
-            table_info = connection.introspection.get_table_list(cursor)
-
             # Determine types of tables and/or views to be introspected.
             types = {"t"}
             if options["include_partitions"]:
                 types.add("p")
             if options["include_views"]:
                 types.add("v")
+            table_info = connection.introspection.get_table_list(cursor)
+            table_info = {info.name: info for info in table_info if info.type in types}
 
-            for table_name in options["table"] or sorted(
-                info.name for info in table_info if info.type in types
-            ):
+            for table_name in options["table"] or sorted(name for name in table_info):
                 if table_name_filter is not None and callable(table_name_filter):
                     if not table_name_filter(table_name):
                         continue
@@ -232,6 +230,10 @@ class Command(BaseCommand):
                     if field_type.startswith(("ForeignKey(", "OneToOneField(")):
                         field_desc += ", models.DO_NOTHING"
 
+                    # Add comment.
+                    if connection.features.supports_comments and row.comment:
+                        extra_params["db_comment"] = row.comment
+
                     if extra_params:
                         if not field_desc.endswith("("):
                             field_desc += ", "
@@ -242,14 +244,22 @@ class Command(BaseCommand):
                     if comment_notes:
                         field_desc += "  # " + " ".join(comment_notes)
                     yield "    %s" % field_desc
-                is_view = any(
-                    info.name == table_name and info.type == "v" for info in table_info
-                )
-                is_partition = any(
-                    info.name == table_name and info.type == "p" for info in table_info
-                )
+                comment = None
+                if info := table_info.get(table_name):
+                    is_view = info.type == "v"
+                    is_partition = info.type == "p"
+                    if connection.features.supports_comments:
+                        comment = info.comment
+                else:
+                    is_view = False
+                    is_partition = False
                 yield from self.get_meta(
-                    table_name, constraints, column_to_field_name, is_view, is_partition
+                    table_name,
+                    constraints,
+                    column_to_field_name,
+                    is_view,
+                    is_partition,
+                    comment,
                 )
 
     def normalize_col_name(self, col_name, used_column_names, is_relation):
@@ -353,7 +363,13 @@ class Command(BaseCommand):
         return field_type, field_params, field_notes
 
     def get_meta(
-        self, table_name, constraints, column_to_field_name, is_view, is_partition
+        self,
+        table_name,
+        constraints,
+        column_to_field_name,
+        is_view,
+        is_partition,
+        comment,
     ):
         """
         Return a sequence comprising the lines of code necessary
@@ -391,4 +407,6 @@ class Command(BaseCommand):
         if unique_together:
             tup = "(" + ", ".join(unique_together) + ",)"
             meta += ["        unique_together = %s" % tup]
+        if comment:
+            meta += [f"        db_table_comment = {comment!r}"]
         return meta

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

@@ -334,6 +334,11 @@ class BaseDatabaseFeatures:
     # Does the backend support non-deterministic collations?
     supports_non_deterministic_collations = True
 
+    # Does the backend support column and table comments?
+    supports_comments = False
+    # Does the backend support column comments in ADD COLUMN statements?
+    supports_comments_inline = False
+
     # Does the backend support the logical XOR operator?
     supports_logical_xor = False
 

+ 87 - 4
django/db/backends/base/schema.py

@@ -141,6 +141,9 @@ class BaseDatabaseSchemaEditor:
 
     sql_delete_procedure = "DROP PROCEDURE %(procedure)s"
 
+    sql_alter_table_comment = "COMMENT ON TABLE %(table)s IS %(comment)s"
+    sql_alter_column_comment = "COMMENT ON COLUMN %(table)s.%(column)s IS %(comment)s"
+
     def __init__(self, connection, collect_sql=False, atomic=True):
         self.connection = connection
         self.collect_sql = collect_sql
@@ -289,6 +292,8 @@ class BaseDatabaseSchemaEditor:
         yield column_db_type
         if collation := field_db_params.get("collation"):
             yield self._collate_sql(collation)
+        if self.connection.features.supports_comments_inline and field.db_comment:
+            yield self._comment_sql(field.db_comment)
         # Work out nullability.
         null = field.null
         # Include a default value, if requested.
@@ -445,6 +450,23 @@ class BaseDatabaseSchemaEditor:
         # definition.
         self.execute(sql, params or None)
 
+        if self.connection.features.supports_comments:
+            # Add table comment.
+            if model._meta.db_table_comment:
+                self.alter_db_table_comment(model, None, model._meta.db_table_comment)
+            # Add column comments.
+            if not self.connection.features.supports_comments_inline:
+                for field in model._meta.local_fields:
+                    if field.db_comment:
+                        field_db_params = field.db_parameters(
+                            connection=self.connection
+                        )
+                        field_type = field_db_params["type"]
+                        self.execute(
+                            *self._alter_column_comment_sql(
+                                model, field, field_type, field.db_comment
+                            )
+                        )
         # Add any field index and index_together's (deferred as SQLite
         # _remake_table needs it).
         self.deferred_sql.extend(self._model_indexes_sql(model))
@@ -614,6 +636,15 @@ class BaseDatabaseSchemaEditor:
             if isinstance(sql, Statement):
                 sql.rename_table_references(old_db_table, new_db_table)
 
+    def alter_db_table_comment(self, model, old_db_table_comment, new_db_table_comment):
+        self.execute(
+            self.sql_alter_table_comment
+            % {
+                "table": self.quote_name(model._meta.db_table),
+                "comment": self.quote_value(new_db_table_comment or ""),
+            }
+        )
+
     def alter_db_tablespace(self, model, old_db_tablespace, new_db_tablespace):
         """Move a model's table between tablespaces."""
         self.execute(
@@ -693,6 +724,18 @@ class BaseDatabaseSchemaEditor:
                 "changes": changes_sql,
             }
             self.execute(sql, params)
+        # Add field comment, if required.
+        if (
+            field.db_comment
+            and self.connection.features.supports_comments
+            and not self.connection.features.supports_comments_inline
+        ):
+            field_type = db_params["type"]
+            self.execute(
+                *self._alter_column_comment_sql(
+                    model, field, field_type, field.db_comment
+                )
+            )
         # Add an index, if required
         self.deferred_sql.extend(self._field_indexes_sql(model, field))
         # Reset connection if required
@@ -813,6 +856,11 @@ class BaseDatabaseSchemaEditor:
             self.connection.features.supports_foreign_keys
             and old_field.remote_field
             and old_field.db_constraint
+            and self._field_should_be_altered(
+                old_field,
+                new_field,
+                ignore={"db_comment"},
+            )
         ):
             fk_names = self._constraint_names(
                 model, [old_field.column], foreign_key=True
@@ -949,11 +997,15 @@ class BaseDatabaseSchemaEditor:
         # Type suffix change? (e.g. auto increment).
         old_type_suffix = old_field.db_type_suffix(connection=self.connection)
         new_type_suffix = new_field.db_type_suffix(connection=self.connection)
-        # Type or collation change?
+        # Type, collation, or comment change?
         if (
             old_type != new_type
             or old_type_suffix != new_type_suffix
             or old_collation != new_collation
+            or (
+                self.connection.features.supports_comments
+                and old_field.db_comment != new_field.db_comment
+            )
         ):
             fragment, other_actions = self._alter_column_type_sql(
                 model, old_field, new_field, new_type, old_collation, new_collation
@@ -1211,12 +1263,26 @@ class BaseDatabaseSchemaEditor:
         an ALTER TABLE statement and a list of extra (sql, params) tuples to
         run once the field is altered.
         """
+        other_actions = []
         if collate_sql := self._collate_sql(
             new_collation, old_collation, model._meta.db_table
         ):
             collate_sql = f" {collate_sql}"
         else:
             collate_sql = ""
+        # Comment change?
+        comment_sql = ""
+        if self.connection.features.supports_comments and not new_field.many_to_many:
+            if old_field.db_comment != new_field.db_comment:
+                # PostgreSQL and Oracle can't execute 'ALTER COLUMN ...' and
+                # 'COMMENT ON ...' at the same time.
+                sql, params = self._alter_column_comment_sql(
+                    model, new_field, new_type, new_field.db_comment
+                )
+                if sql:
+                    other_actions.append((sql, params))
+            if new_field.db_comment:
+                comment_sql = self._comment_sql(new_field.db_comment)
         return (
             (
                 self.sql_alter_column_type
@@ -1224,12 +1290,27 @@ class BaseDatabaseSchemaEditor:
                     "column": self.quote_name(new_field.column),
                     "type": new_type,
                     "collation": collate_sql,
+                    "comment": comment_sql,
                 },
                 [],
             ),
+            other_actions,
+        )
+
+    def _alter_column_comment_sql(self, model, new_field, new_type, new_db_comment):
+        return (
+            self.sql_alter_column_comment
+            % {
+                "table": self.quote_name(model._meta.db_table),
+                "column": self.quote_name(new_field.column),
+                "comment": self._comment_sql(new_db_comment),
+            },
             [],
         )
 
+    def _comment_sql(self, comment):
+        return self.quote_value(comment or "")
+
     def _alter_many_to_many(self, model, old_field, new_field, strict):
         """Alter M2Ms to repoint their to= endpoints."""
         # Rename the through table
@@ -1423,16 +1504,18 @@ class BaseDatabaseSchemaEditor:
             output.append(self._create_index_sql(model, fields=[field]))
         return output
 
-    def _field_should_be_altered(self, old_field, new_field):
+    def _field_should_be_altered(self, old_field, new_field, ignore=None):
+        ignore = ignore or set()
         _, old_path, old_args, old_kwargs = old_field.deconstruct()
         _, new_path, new_args, new_kwargs = new_field.deconstruct()
         # Don't alter when:
         # - changing only a field name
         # - changing an attribute that doesn't affect the schema
+        # - changing an attribute in the provided set of ignored attributes
         # - adding only a db_column and the column name is not changed
-        for attr in old_field.non_db_attrs:
+        for attr in ignore.union(old_field.non_db_attrs):
             old_kwargs.pop(attr, None)
-        for attr in new_field.non_db_attrs:
+        for attr in ignore.union(new_field.non_db_attrs):
             new_kwargs.pop(attr, None)
         return self.quote_name(old_field.column) != self.quote_name(
             new_field.column

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

@@ -18,6 +18,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
     requires_explicit_null_ordering_when_grouping = True
     atomic_transactions = False
     can_clone_databases = True
+    supports_comments = True
+    supports_comments_inline = True
     supports_temporal_subtraction = True
     supports_slicing_ordering_in_compound = True
     supports_index_on_text_field = False

+ 19 - 6
django/db/backends/mysql/introspection.py

@@ -5,18 +5,20 @@ from MySQLdb.constants import FIELD_TYPE
 
 from django.db.backends.base.introspection import BaseDatabaseIntrospection
 from django.db.backends.base.introspection import FieldInfo as BaseFieldInfo
-from django.db.backends.base.introspection import TableInfo
+from django.db.backends.base.introspection import TableInfo as BaseTableInfo
 from django.db.models import Index
 from django.utils.datastructures import OrderedSet
 
 FieldInfo = namedtuple(
-    "FieldInfo", BaseFieldInfo._fields + ("extra", "is_unsigned", "has_json_constraint")
+    "FieldInfo",
+    BaseFieldInfo._fields + ("extra", "is_unsigned", "has_json_constraint", "comment"),
 )
 InfoLine = namedtuple(
     "InfoLine",
     "col_name data_type max_len num_prec num_scale extra column_default "
-    "collation is_unsigned",
+    "collation is_unsigned comment",
 )
+TableInfo = namedtuple("TableInfo", BaseTableInfo._fields + ("comment",))
 
 
 class DatabaseIntrospection(BaseDatabaseIntrospection):
@@ -68,9 +70,18 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
 
     def get_table_list(self, cursor):
         """Return a list of table and view names in the current database."""
-        cursor.execute("SHOW FULL TABLES")
+        cursor.execute(
+            """
+            SELECT
+                table_name,
+                table_type,
+                table_comment
+            FROM information_schema.tables
+            WHERE table_schema = DATABASE()
+            """
+        )
         return [
-            TableInfo(row[0], {"BASE TABLE": "t", "VIEW": "v"}.get(row[1]))
+            TableInfo(row[0], {"BASE TABLE": "t", "VIEW": "v"}.get(row[1]), row[2])
             for row in cursor.fetchall()
         ]
 
@@ -128,7 +139,8 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
                 CASE
                     WHEN column_type LIKE '%% unsigned' THEN 1
                     ELSE 0
-                END AS is_unsigned
+                END AS is_unsigned,
+                column_comment
             FROM information_schema.columns
             WHERE table_name = %s AND table_schema = DATABASE()
             """,
@@ -159,6 +171,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
                     info.extra,
                     info.is_unsigned,
                     line[0] in json_constraints,
+                    info.comment,
                 )
             )
         return fields

+ 12 - 1
django/db/backends/mysql/schema.py

@@ -9,7 +9,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
 
     sql_alter_column_null = "MODIFY %(column)s %(type)s NULL"
     sql_alter_column_not_null = "MODIFY %(column)s %(type)s NOT NULL"
-    sql_alter_column_type = "MODIFY %(column)s %(type)s%(collation)s"
+    sql_alter_column_type = "MODIFY %(column)s %(type)s%(collation)s%(comment)s"
     sql_alter_column_no_default_null = "ALTER COLUMN %(column)s SET DEFAULT NULL"
 
     # No 'CASCADE' which works as a no-op in MySQL but is undocumented
@@ -32,6 +32,9 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
 
     sql_create_index = "CREATE INDEX %(name)s ON %(table)s (%(columns)s)%(extra)s"
 
+    sql_alter_table_comment = "ALTER TABLE %(table)s COMMENT = %(comment)s"
+    sql_alter_column_comment = None
+
     @property
     def sql_delete_check(self):
         if self.connection.mysql_is_mariadb:
@@ -228,3 +231,11 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
     def _rename_field_sql(self, table, old_field, new_field, new_type):
         new_type = self._set_field_new_type_null_status(old_field, new_type)
         return super()._rename_field_sql(table, old_field, new_field, new_type)
+
+    def _alter_column_comment_sql(self, model, new_field, new_type, new_db_comment):
+        # Comment is alter when altering the column type.
+        return "", []
+
+    def _comment_sql(self, comment):
+        comment_sql = super()._comment_sql(comment)
+        return f" COMMENT {comment_sql}"

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

@@ -25,6 +25,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
     supports_partially_nullable_unique_constraints = False
     supports_deferrable_unique_constraints = True
     truncates_names = True
+    supports_comments = True
     supports_tablespaces = True
     supports_sequence_reset = False
     can_introspect_materialized_views = True

+ 32 - 8
django/db/backends/oracle/introspection.py

@@ -5,10 +5,13 @@ import cx_Oracle
 from django.db import models
 from django.db.backends.base.introspection import BaseDatabaseIntrospection
 from django.db.backends.base.introspection import FieldInfo as BaseFieldInfo
-from django.db.backends.base.introspection import TableInfo
+from django.db.backends.base.introspection import TableInfo as BaseTableInfo
 from django.utils.functional import cached_property
 
-FieldInfo = namedtuple("FieldInfo", BaseFieldInfo._fields + ("is_autofield", "is_json"))
+FieldInfo = namedtuple(
+    "FieldInfo", BaseFieldInfo._fields + ("is_autofield", "is_json", "comment")
+)
+TableInfo = namedtuple("TableInfo", BaseTableInfo._fields + ("comment",))
 
 
 class DatabaseIntrospection(BaseDatabaseIntrospection):
@@ -77,8 +80,14 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
         """Return a list of table and view names in the current database."""
         cursor.execute(
             """
-            SELECT table_name, 't'
+            SELECT
+                user_tables.table_name,
+                't',
+                user_tab_comments.comments
             FROM user_tables
+            LEFT OUTER JOIN
+                user_tab_comments
+                ON user_tab_comments.table_name = user_tables.table_name
             WHERE
                 NOT EXISTS (
                     SELECT 1
@@ -86,13 +95,13 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
                     WHERE user_mviews.mview_name = user_tables.table_name
                 )
             UNION ALL
-            SELECT view_name, 'v' FROM user_views
+            SELECT view_name, 'v', NULL FROM user_views
             UNION ALL
-            SELECT mview_name, 'v' FROM user_mviews
+            SELECT mview_name, 'v', NULL FROM user_mviews
         """
         )
         return [
-            TableInfo(self.identifier_converter(row[0]), row[1])
+            TableInfo(self.identifier_converter(row[0]), row[1], row[2])
             for row in cursor.fetchall()
         ]
 
@@ -131,10 +140,15 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
                     )
                     THEN 1
                     ELSE 0
-                END as is_json
+                END as is_json,
+                user_col_comments.comments as col_comment
             FROM user_tab_cols
             LEFT OUTER JOIN
                 user_tables ON user_tables.table_name = user_tab_cols.table_name
+            LEFT OUTER JOIN
+                user_col_comments ON
+                user_col_comments.column_name = user_tab_cols.column_name AND
+                user_col_comments.table_name = user_tab_cols.table_name
             WHERE user_tab_cols.table_name = UPPER(%s)
             """,
             [table_name],
@@ -146,6 +160,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
                 collation,
                 is_autofield,
                 is_json,
+                comment,
             )
             for (
                 column,
@@ -154,6 +169,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
                 display_size,
                 is_autofield,
                 is_json,
+                comment,
             ) in cursor.fetchall()
         }
         self.cache_bust_counter += 1
@@ -165,7 +181,14 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
         description = []
         for desc in cursor.description:
             name = desc[0]
-            display_size, default, collation, is_autofield, is_json = field_map[name]
+            (
+                display_size,
+                default,
+                collation,
+                is_autofield,
+                is_json,
+                comment,
+            ) = field_map[name]
             name %= {}  # cx_Oracle, for some reason, doubles percent signs.
             description.append(
                 FieldInfo(
@@ -180,6 +203,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
                     collation,
                     is_autofield,
                     is_json,
+                    comment,
                 )
             )
         return description

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

@@ -22,6 +22,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
     has_select_for_update_skip_locked = True
     has_select_for_no_key_update = True
     can_release_savepoints = True
+    supports_comments = True
     supports_tablespaces = True
     supports_transactions = True
     can_introspect_materialized_views = True

+ 7 - 4
django/db/backends/postgresql/introspection.py

@@ -2,10 +2,11 @@ from collections import namedtuple
 
 from django.db.backends.base.introspection import BaseDatabaseIntrospection
 from django.db.backends.base.introspection import FieldInfo as BaseFieldInfo
-from django.db.backends.base.introspection import TableInfo
+from django.db.backends.base.introspection import TableInfo as BaseTableInfo
 from django.db.models import Index
 
-FieldInfo = namedtuple("FieldInfo", BaseFieldInfo._fields + ("is_autofield",))
+FieldInfo = namedtuple("FieldInfo", BaseFieldInfo._fields + ("is_autofield", "comment"))
+TableInfo = namedtuple("TableInfo", BaseTableInfo._fields + ("comment",))
 
 
 class DatabaseIntrospection(BaseDatabaseIntrospection):
@@ -62,7 +63,8 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
                     WHEN c.relispartition THEN 'p'
                     WHEN c.relkind IN ('m', 'v') THEN 'v'
                     ELSE 't'
-                END
+                END,
+                obj_description(c.oid)
             FROM pg_catalog.pg_class c
             LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
             WHERE c.relkind IN ('f', 'm', 'p', 'r', 'v')
@@ -91,7 +93,8 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
                 NOT (a.attnotnull OR (t.typtype = 'd' AND t.typnotnull)) AS is_nullable,
                 pg_get_expr(ad.adbin, ad.adrelid) AS column_default,
                 CASE WHEN collname = 'default' THEN NULL ELSE collname END AS collation,
-                a.attidentity != '' AS is_autofield
+                a.attidentity != '' AS is_autofield,
+                col_description(a.attrelid, a.attnum) AS column_comment
             FROM pg_attribute a
             LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
             LEFT JOIN pg_collation co ON a.attcollation = co.oid

+ 23 - 0
django/db/migrations/autodetector.py

@@ -170,6 +170,7 @@ class MigrationAutodetector:
         self.generate_created_proxies()
         self.generate_altered_options()
         self.generate_altered_managers()
+        self.generate_altered_db_table_comment()
 
         # Create the renamed fields and store them in self.renamed_fields.
         # They are used by create_altered_indexes(), generate_altered_fields(),
@@ -1552,6 +1553,28 @@ class MigrationAutodetector:
                     ),
                 )
 
+    def generate_altered_db_table_comment(self):
+        models_to_check = self.kept_model_keys.union(
+            self.kept_proxy_keys, self.kept_unmanaged_keys
+        )
+        for app_label, model_name in sorted(models_to_check):
+            old_model_name = self.renamed_models.get(
+                (app_label, model_name), model_name
+            )
+            old_model_state = self.from_state.models[app_label, old_model_name]
+            new_model_state = self.to_state.models[app_label, model_name]
+
+            old_db_table_comment = old_model_state.options.get("db_table_comment")
+            new_db_table_comment = new_model_state.options.get("db_table_comment")
+            if old_db_table_comment != new_db_table_comment:
+                self.add_operation(
+                    app_label,
+                    operations.AlterModelTableComment(
+                        name=model_name,
+                        table_comment=new_db_table_comment,
+                    ),
+                )
+
     def generate_altered_options(self):
         """
         Work out if any non-schema-affecting options have changed and make an

+ 2 - 0
django/db/migrations/operations/__init__.py

@@ -6,6 +6,7 @@ from .models import (
     AlterModelManagers,
     AlterModelOptions,
     AlterModelTable,
+    AlterModelTableComment,
     AlterOrderWithRespectTo,
     AlterUniqueTogether,
     CreateModel,
@@ -21,6 +22,7 @@ __all__ = [
     "CreateModel",
     "DeleteModel",
     "AlterModelTable",
+    "AlterModelTableComment",
     "AlterUniqueTogether",
     "RenameModel",
     "AlterIndexTogether",

+ 38 - 0
django/db/migrations/operations/models.py

@@ -529,6 +529,44 @@ class AlterModelTable(ModelOptionOperation):
         return "alter_%s_table" % self.name_lower
 
 
+class AlterModelTableComment(ModelOptionOperation):
+    def __init__(self, name, table_comment):
+        self.table_comment = table_comment
+        super().__init__(name)
+
+    def deconstruct(self):
+        kwargs = {
+            "name": self.name,
+            "table_comment": self.table_comment,
+        }
+        return (self.__class__.__qualname__, [], kwargs)
+
+    def state_forwards(self, app_label, state):
+        state.alter_model_options(
+            app_label, self.name_lower, {"db_table_comment": self.table_comment}
+        )
+
+    def database_forwards(self, app_label, schema_editor, from_state, to_state):
+        new_model = to_state.apps.get_model(app_label, self.name)
+        if self.allow_migrate_model(schema_editor.connection.alias, new_model):
+            old_model = from_state.apps.get_model(app_label, self.name)
+            schema_editor.alter_db_table_comment(
+                new_model,
+                old_model._meta.db_table_comment,
+                new_model._meta.db_table_comment,
+            )
+
+    def database_backwards(self, app_label, schema_editor, from_state, to_state):
+        return self.database_forwards(app_label, schema_editor, from_state, to_state)
+
+    def describe(self):
+        return f"Alter {self.name} table comment"
+
+    @property
+    def migration_name_fragment(self):
+        return f"alter_{self.name_lower}_table_comment"
+
+
 class AlterTogetherOptionOperation(ModelOptionOperation):
     option_name = None
 

+ 24 - 0
django/db/models/base.py

@@ -1556,6 +1556,7 @@ class Model(AltersData, metaclass=ModelBase):
                 *cls._check_ordering(),
                 *cls._check_constraints(databases),
                 *cls._check_default_pk(),
+                *cls._check_db_table_comment(databases),
             ]
 
         return errors
@@ -1592,6 +1593,29 @@ class Model(AltersData, metaclass=ModelBase):
             ]
         return []
 
+    @classmethod
+    def _check_db_table_comment(cls, databases):
+        if not cls._meta.db_table_comment:
+            return []
+        errors = []
+        for db in databases:
+            if not router.allow_migrate_model(db, cls):
+                continue
+            connection = connections[db]
+            if not (
+                connection.features.supports_comments
+                or "supports_comments" in cls._meta.required_db_features
+            ):
+                errors.append(
+                    checks.Warning(
+                        f"{connection.display_name} does not support comments on "
+                        f"tables (db_table_comment).",
+                        obj=cls,
+                        id="models.W046",
+                    )
+                )
+        return errors
+
     @classmethod
     def _check_swappable(cls):
         """Check if the swapped model exists."""

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

@@ -200,6 +200,7 @@ class Field(RegisterLookupMixin):
         auto_created=False,
         validators=(),
         error_messages=None,
+        db_comment=None,
     ):
         self.name = name
         self.verbose_name = verbose_name  # May be set by set_attributes_from_name
@@ -221,6 +222,7 @@ class Field(RegisterLookupMixin):
         self.help_text = help_text
         self.db_index = db_index
         self.db_column = db_column
+        self.db_comment = db_comment
         self._db_tablespace = db_tablespace
         self.auto_created = auto_created
 
@@ -259,6 +261,7 @@ class Field(RegisterLookupMixin):
             *self._check_field_name(),
             *self._check_choices(),
             *self._check_db_index(),
+            *self._check_db_comment(**kwargs),
             *self._check_null_allowed_for_primary_keys(),
             *self._check_backend_specific_checks(**kwargs),
             *self._check_validators(),
@@ -385,6 +388,28 @@ class Field(RegisterLookupMixin):
         else:
             return []
 
+    def _check_db_comment(self, databases=None, **kwargs):
+        if not self.db_comment or not databases:
+            return []
+        errors = []
+        for db in databases:
+            if not router.allow_migrate_model(db, self.model):
+                continue
+            connection = connections[db]
+            if not (
+                connection.features.supports_comments
+                or "supports_comments" in self.model._meta.required_db_features
+            ):
+                errors.append(
+                    checks.Warning(
+                        f"{connection.display_name} does not support comments on "
+                        f"columns (db_comment).",
+                        obj=self,
+                        id="fields.W163",
+                    )
+                )
+        return errors
+
     def _check_null_allowed_for_primary_keys(self):
         if (
             self.primary_key
@@ -538,6 +563,7 @@ class Field(RegisterLookupMixin):
             "choices": None,
             "help_text": "",
             "db_column": None,
+            "db_comment": None,
             "db_tablespace": None,
             "auto_created": False,
             "validators": [],

+ 8 - 0
django/db/models/fields/related.py

@@ -1428,6 +1428,14 @@ class ManyToManyField(RelatedField):
                     id="fields.W345",
                 )
             )
+        if self.db_comment:
+            warnings.append(
+                checks.Warning(
+                    "db_comment has no effect on ManyToManyField.",
+                    obj=self,
+                    id="fields.W346",
+                )
+            )
 
         return warnings
 

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

@@ -30,6 +30,7 @@ DEFAULT_NAMES = (
     "verbose_name",
     "verbose_name_plural",
     "db_table",
+    "db_table_comment",
     "ordering",
     "unique_together",
     "permissions",
@@ -112,6 +113,7 @@ class Options:
         self.verbose_name = None
         self.verbose_name_plural = None
         self.db_table = ""
+        self.db_table_comment = ""
         self.ordering = []
         self._ordering_clash = False
         self.indexes = []

+ 5 - 0
docs/ref/checks.txt

@@ -196,6 +196,8 @@ Model fields
 * **fields.W161**: Fixed default value provided.
 * **fields.W162**: ``<database>`` does not support a database index on
   ``<field data type>`` columns.
+* **fields.W163**: ``<database>`` does not support comments on columns
+  (``db_comment``).
 * **fields.E170**: ``BinaryField``’s ``default`` cannot be a string. Use bytes
   content instead.
 * **fields.E180**: ``<database>`` does not support ``JSONField``\s.
@@ -315,6 +317,7 @@ Related fields
   the table name of ``<model>``/``<model>.<field name>``.
 * **fields.W345**: ``related_name`` has no effect on ``ManyToManyField`` with a
   symmetrical relationship, e.g. to "self".
+* **fields.W346**: ``db_comment`` has no effect on ``ManyToManyField``.
 
 Models
 ------
@@ -400,6 +403,8 @@ Models
   expressions.
 * **models.W045**: Check constraint ``<constraint>`` contains ``RawSQL()``
   expression and won't be validated during the model ``full_clean()``.
+* **models.W046**: ``<database>`` does not support comments on tables
+  (``db_table_comment``).
 
 Security
 --------

+ 11 - 0
docs/ref/migration-operations.txt

@@ -88,6 +88,17 @@ lose any data in the old table.
 Changes the model's table name (the :attr:`~django.db.models.Options.db_table`
 option on the ``Meta`` subclass).
 
+``AlterModelTableComment``
+--------------------------
+
+.. versionadded:: 4.2
+
+.. class:: AlterModelTableComment(name, table_comment)
+
+Changes the model's table comment (the
+:attr:`~django.db.models.Options.db_table_comment` option on the ``Meta``
+subclass).
+
 ``AlterUniqueTogether``
 -----------------------
 

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

@@ -325,6 +325,21 @@ characters that aren't allowed in Python variable names -- notably, the
 hyphen -- that's OK. Django quotes column and table names behind the
 scenes.
 
+``db_comment``
+--------------
+
+.. versionadded:: 4.2
+
+.. attribute:: Field.db_comment
+
+The comment on the database column to use for this field. It is useful for
+documenting fields for individuals with direct database access who may not be
+looking at your Django code. For example::
+
+    pub_date = models.DateTimeField(
+        db_comment="Date and time when the article was published",
+    )
+
 ``db_index``
 ------------
 

+ 18 - 0
docs/ref/models/options.txt

@@ -91,6 +91,24 @@ Django quotes column and table names behind the scenes.
    backends; except for Oracle, however, the quotes have no effect. See the
    :ref:`Oracle notes <oracle-notes>` for more details.
 
+``db_table_comment``
+--------------------
+
+.. versionadded:: 4.2
+
+.. attribute:: Options.db_table_comment
+
+The comment on the database table to use for this model. It is useful for
+documenting database tables for individuals with direct database access who may
+not be looking at your Django code. For example::
+
+    class Answer(models.Model):
+        question = models.ForeignKey(Question, on_delete=models.CASCADE)
+        answer = models.TextField()
+
+        class Meta:
+            db_table_comment = "Question answers"
+
 ``db_tablespace``
 -----------------
 

+ 9 - 0
docs/ref/schema-editor.txt

@@ -127,6 +127,15 @@ value.
 
 Renames the model's table from ``old_db_table`` to ``new_db_table``.
 
+``alter_db_table_comment()``
+----------------------------
+
+.. versionadded:: 4.2
+
+.. method:: BaseDatabaseSchemaEditor.alter_db_table_comment(model, old_db_table_comment, new_db_table_comment)
+
+Change the ``model``’s table comment to ``new_db_table_comment``.
+
 ``alter_db_tablespace()``
 -------------------------
 

+ 35 - 0
docs/releases/4.2.txt

@@ -40,6 +40,41 @@ in the future.
 .. _psycopg: https://www.psycopg.org/psycopg3/
 .. _psycopg library: https://pypi.org/project/psycopg/
 
+Comments on columns and tables
+------------------------------
+
+The new :attr:`Field.db_comment <django.db.models.Field.db_comment>` and
+:attr:`Meta.db_table_comment <django.db.models.Options.db_table_comment>`
+options allow creating comments on columns and tables, respectively. For
+example::
+
+    from django.db import models
+
+    class Question(models.Model):
+        text = models.TextField(db_comment="Poll question")
+        pub_date = models.DateTimeField(
+            db_comment="Date and time when the question was published",
+        )
+
+        class Meta:
+            db_table_comment = "Poll questions"
+
+
+    class Answer(models.Model):
+        question = models.ForeignKey(
+            Question,
+            on_delete=models.CASCADE,
+            db_comment="Reference to a question"
+        )
+        answer = models.TextField(db_comment="Question answer")
+
+        class Meta:
+            db_table_comment = "Question answers"
+
+Also, the new :class:`~django.db.migrations.operations.AlterModelTableComment`
+operation allows changing table comments defined in the
+:attr:`Meta.db_table_comment <django.db.models.Options.db_table_comment>`.
+
 Mitigation for the BREACH attack
 --------------------------------
 

+ 8 - 0
tests/inspectdb/models.py

@@ -132,3 +132,11 @@ class FuncUniqueConstraint(models.Model):
             )
         ]
         required_db_features = {"supports_expression_indexes"}
+
+
+class DbComment(models.Model):
+    rank = models.IntegerField(db_comment="'Rank' column comment")
+
+    class Meta:
+        db_table_comment = "Custom table comment"
+        required_db_features = {"supports_comments"}

+ 18 - 0
tests/inspectdb/tests.py

@@ -129,6 +129,24 @@ class InspectDBTestCase(TestCase):
             "null_json_field = models.JSONField(blank=True, null=True)", output
         )
 
+    @skipUnlessDBFeature("supports_comments")
+    def test_db_comments(self):
+        out = StringIO()
+        call_command("inspectdb", "inspectdb_dbcomment", stdout=out)
+        output = out.getvalue()
+        integer_field_type = connection.features.introspected_field_types[
+            "IntegerField"
+        ]
+        self.assertIn(
+            f"rank = models.{integer_field_type}("
+            f"db_comment=\"'Rank' column comment\")",
+            output,
+        )
+        self.assertIn(
+            "        db_table_comment = 'Custom table comment'",
+            output,
+        )
+
     @skipUnlessDBFeature("supports_collation_on_charfield")
     @skipUnless(test_collation, "Language collations are not supported.")
     def test_char_field_db_collation(self):

+ 8 - 0
tests/introspection/models.py

@@ -102,3 +102,11 @@ class UniqueConstraintConditionModel(models.Model):
                 condition=models.Q(color__isnull=True),
             ),
         ]
+
+
+class DbCommentModel(models.Model):
+    name = models.CharField(max_length=15, db_comment="'Name' column comment")
+
+    class Meta:
+        db_table_comment = "Custom table comment"
+        required_db_features = {"supports_comments"}

+ 21 - 0
tests/introspection/tests.py

@@ -9,6 +9,7 @@ from .models import (
     City,
     Comment,
     Country,
+    DbCommentModel,
     District,
     Reporter,
     UniqueConstraintConditionModel,
@@ -179,6 +180,26 @@ class IntrospectionTests(TransactionTestCase):
             [connection.introspection.get_field_type(r[1], r) for r in desc],
         )
 
+    @skipUnlessDBFeature("supports_comments")
+    def test_db_comments(self):
+        with connection.cursor() as cursor:
+            desc = connection.introspection.get_table_description(
+                cursor, DbCommentModel._meta.db_table
+            )
+            table_list = connection.introspection.get_table_list(cursor)
+        self.assertEqual(
+            ["'Name' column comment"],
+            [field.comment for field in desc if field.name == "name"],
+        )
+        self.assertEqual(
+            ["Custom table comment"],
+            [
+                table.comment
+                for table in table_list
+                if table.name == "introspection_dbcommentmodel"
+            ],
+        )
+
     # Regression test for #9991 - 'real' types in postgres
     @skipUnlessDBFeature("has_real_datatype")
     def test_postgresql_real_type(self):

+ 31 - 0
tests/invalid_models_tests/test_models.py

@@ -1872,6 +1872,37 @@ class OtherModelTests(SimpleTestCase):
         )
 
 
+@isolate_apps("invalid_models_tests")
+class DbTableCommentTests(TestCase):
+    def test_db_table_comment(self):
+        class Model(models.Model):
+            class Meta:
+                db_table_comment = "Table comment"
+
+        errors = Model.check(databases=self.databases)
+        expected = (
+            []
+            if connection.features.supports_comments
+            else [
+                Warning(
+                    f"{connection.display_name} does not support comments on tables "
+                    f"(db_table_comment).",
+                    obj=Model,
+                    id="models.W046",
+                ),
+            ]
+        )
+        self.assertEqual(errors, expected)
+
+    def test_db_table_comment_required_db_features(self):
+        class Model(models.Model):
+            class Meta:
+                db_table_comment = "Table comment"
+                required_db_features = {"supports_comments"}
+
+        self.assertEqual(Model.check(databases=self.databases), [])
+
+
 class MultipleAutoFieldsTests(TestCase):
     def test_multiple_autofields(self):
         msg = (

+ 32 - 0
tests/invalid_models_tests/test_ordinary_fields.py

@@ -1023,3 +1023,35 @@ class JSONFieldTests(TestCase):
             field = models.JSONField(default=callable_default)
 
         self.assertEqual(Model._meta.get_field("field").check(), [])
+
+
+@isolate_apps("invalid_models_tests")
+class DbCommentTests(TestCase):
+    def test_db_comment(self):
+        class Model(models.Model):
+            field = models.IntegerField(db_comment="Column comment")
+
+        errors = Model._meta.get_field("field").check(databases=self.databases)
+        expected = (
+            []
+            if connection.features.supports_comments
+            else [
+                DjangoWarning(
+                    f"{connection.display_name} does not support comments on columns "
+                    f"(db_comment).",
+                    obj=Model._meta.get_field("field"),
+                    id="fields.W163",
+                ),
+            ]
+        )
+        self.assertEqual(errors, expected)
+
+    def test_db_comment_required_db_features(self):
+        class Model(models.Model):
+            field = models.IntegerField(db_comment="Column comment")
+
+            class Meta:
+                required_db_features = {"supports_comments"}
+
+        errors = Model._meta.get_field("field").check(databases=self.databases)
+        self.assertEqual(errors, [])

+ 8 - 1
tests/invalid_models_tests/test_relative_fields.py

@@ -94,7 +94,9 @@ class RelativeFieldTests(SimpleTestCase):
             name = models.CharField(max_length=20)
 
         class ModelM2M(models.Model):
-            m2m = models.ManyToManyField(Model, null=True, validators=[lambda x: x])
+            m2m = models.ManyToManyField(
+                Model, null=True, validators=[lambda x: x], db_comment="Column comment"
+            )
 
         field = ModelM2M._meta.get_field("m2m")
         self.assertEqual(
@@ -110,6 +112,11 @@ class RelativeFieldTests(SimpleTestCase):
                     obj=field,
                     id="fields.W341",
                 ),
+                DjangoWarning(
+                    "db_comment has no effect on ManyToManyField.",
+                    obj=field,
+                    id="fields.W346",
+                ),
             ],
         )
 

+ 60 - 0
tests/migrations/test_autodetector.py

@@ -773,6 +773,14 @@ class AutodetectorTests(BaseAutodetectorTests):
             "verbose_name": "Authi",
         },
     )
+    author_with_db_table_comment = ModelState(
+        "testapp",
+        "Author",
+        [
+            ("id", models.AutoField(primary_key=True)),
+        ],
+        {"db_table_comment": "Table comment"},
+    )
     author_with_db_table_options = ModelState(
         "testapp",
         "Author",
@@ -2349,6 +2357,58 @@ class AutodetectorTests(BaseAutodetectorTests):
             changes, "testapp", 0, 1, name="newauthor", table="author_three"
         )
 
+    def test_alter_db_table_comment_add(self):
+        changes = self.get_changes(
+            [self.author_empty], [self.author_with_db_table_comment]
+        )
+        self.assertNumberMigrations(changes, "testapp", 1)
+        self.assertOperationTypes(changes, "testapp", 0, ["AlterModelTableComment"])
+        self.assertOperationAttributes(
+            changes, "testapp", 0, 0, name="author", table_comment="Table comment"
+        )
+
+    def test_alter_db_table_comment_change(self):
+        author_with_new_db_table_comment = ModelState(
+            "testapp",
+            "Author",
+            [
+                ("id", models.AutoField(primary_key=True)),
+            ],
+            {"db_table_comment": "New table comment"},
+        )
+        changes = self.get_changes(
+            [self.author_with_db_table_comment],
+            [author_with_new_db_table_comment],
+        )
+        self.assertNumberMigrations(changes, "testapp", 1)
+        self.assertOperationTypes(changes, "testapp", 0, ["AlterModelTableComment"])
+        self.assertOperationAttributes(
+            changes,
+            "testapp",
+            0,
+            0,
+            name="author",
+            table_comment="New table comment",
+        )
+
+    def test_alter_db_table_comment_remove(self):
+        changes = self.get_changes(
+            [self.author_with_db_table_comment],
+            [self.author_empty],
+        )
+        self.assertNumberMigrations(changes, "testapp", 1)
+        self.assertOperationTypes(changes, "testapp", 0, ["AlterModelTableComment"])
+        self.assertOperationAttributes(
+            changes, "testapp", 0, 0, name="author", db_table_comment=None
+        )
+
+    def test_alter_db_table_comment_no_changes(self):
+        changes = self.get_changes(
+            [self.author_with_db_table_comment],
+            [self.author_with_db_table_comment],
+        )
+        self.assertNumberMigrations(changes, "testapp", 0)
+
     def test_identical_regex_doesnt_alter(self):
         from_state = ModelState(
             "testapp",

+ 14 - 0
tests/migrations/test_base.py

@@ -75,6 +75,20 @@ class MigrationTestBase(TransactionTestCase):
     def assertColumnCollation(self, table, column, collation, using="default"):
         self.assertEqual(self._get_column_collation(table, column, using), collation)
 
+    def _get_table_comment(self, table, using):
+        with connections[using].cursor() as cursor:
+            return next(
+                t.comment
+                for t in connections[using].introspection.get_table_list(cursor)
+                if t.name == table
+            )
+
+    def assertTableComment(self, table, comment, using="default"):
+        self.assertEqual(self._get_table_comment(table, using), comment)
+
+    def assertTableCommentNotExists(self, table, using="default"):
+        self.assertIn(self._get_table_comment(table, using), [None, ""])
+
     def assertIndexExists(
         self, table, columns, value=True, using="default", index_type=None
     ):

+ 31 - 0
tests/migrations/test_operations.py

@@ -1922,6 +1922,37 @@ class OperationTests(OperationTestBase):
                 operation.database_forwards(app_label, editor, new_state, project_state)
         self.assertColumnExists(rider_table, "pony_id")
 
+    @skipUnlessDBFeature("supports_comments")
+    def test_alter_model_table_comment(self):
+        app_label = "test_almotaco"
+        project_state = self.set_up_test_model(app_label)
+        pony_table = f"{app_label}_pony"
+        # Add table comment.
+        operation = migrations.AlterModelTableComment("Pony", "Custom pony comment")
+        self.assertEqual(operation.describe(), "Alter Pony table comment")
+        self.assertEqual(operation.migration_name_fragment, "alter_pony_table_comment")
+        new_state = project_state.clone()
+        operation.state_forwards(app_label, new_state)
+        self.assertEqual(
+            new_state.models[app_label, "pony"].options["db_table_comment"],
+            "Custom pony comment",
+        )
+        self.assertTableCommentNotExists(pony_table)
+        with connection.schema_editor() as editor:
+            operation.database_forwards(app_label, editor, project_state, new_state)
+        self.assertTableComment(pony_table, "Custom pony comment")
+        # Reversal.
+        with connection.schema_editor() as editor:
+            operation.database_backwards(app_label, editor, new_state, project_state)
+        self.assertTableCommentNotExists(pony_table)
+        # Deconstruction.
+        definition = operation.deconstruct()
+        self.assertEqual(definition[0], "AlterModelTableComment")
+        self.assertEqual(definition[1], [])
+        self.assertEqual(
+            definition[2], {"name": "Pony", "table_comment": "Custom pony comment"}
+        )
+
     def test_alter_field_pk(self):
         """
         The AlterField operation on primary keys (things like PostgreSQL's

+ 201 - 0
tests/schema/tests.py

@@ -273,6 +273,27 @@ class SchemaTests(TransactionTestCase):
                 if f.name == column
             )
 
+    def get_column_comment(self, table, column):
+        with connection.cursor() as cursor:
+            return next(
+                f.comment
+                for f in connection.introspection.get_table_description(cursor, table)
+                if f.name == column
+            )
+
+    def get_table_comment(self, table):
+        with connection.cursor() as cursor:
+            return next(
+                t.comment
+                for t in connection.introspection.get_table_list(cursor)
+                if t.name == table
+            )
+
+    def assert_column_comment_not_exists(self, table, column):
+        with connection.cursor() as cursor:
+            columns = connection.introspection.get_table_description(cursor, table)
+        self.assertFalse(any([c.name == column and c.comment for c in columns]))
+
     def assertIndexOrder(self, table, index, order):
         constraints = self.get_constraints(table)
         self.assertIn(index, constraints)
@@ -4390,6 +4411,186 @@ class SchemaTests(TransactionTestCase):
             ],
         )
 
+    @skipUnlessDBFeature("supports_comments")
+    def test_add_db_comment_charfield(self):
+        comment = "Custom comment"
+        field = CharField(max_length=255, db_comment=comment)
+        field.set_attributes_from_name("name_with_comment")
+        with connection.schema_editor() as editor:
+            editor.create_model(Author)
+            editor.add_field(Author, field)
+        self.assertEqual(
+            self.get_column_comment(Author._meta.db_table, "name_with_comment"),
+            comment,
+        )
+
+    @skipUnlessDBFeature("supports_comments")
+    def test_add_db_comment_and_default_charfield(self):
+        comment = "Custom comment with default"
+        field = CharField(max_length=255, default="Joe Doe", db_comment=comment)
+        field.set_attributes_from_name("name_with_comment_default")
+        with connection.schema_editor() as editor:
+            editor.create_model(Author)
+            Author.objects.create(name="Before adding a new field")
+            editor.add_field(Author, field)
+
+        self.assertEqual(
+            self.get_column_comment(Author._meta.db_table, "name_with_comment_default"),
+            comment,
+        )
+        with connection.cursor() as cursor:
+            cursor.execute(
+                f"SELECT name_with_comment_default FROM {Author._meta.db_table};"
+            )
+            for row in cursor.fetchall():
+                self.assertEqual(row[0], "Joe Doe")
+
+    @skipUnlessDBFeature("supports_comments")
+    def test_alter_db_comment(self):
+        with connection.schema_editor() as editor:
+            editor.create_model(Author)
+        # Add comment.
+        old_field = Author._meta.get_field("name")
+        new_field = CharField(max_length=255, db_comment="Custom comment")
+        new_field.set_attributes_from_name("name")
+        with connection.schema_editor() as editor:
+            editor.alter_field(Author, old_field, new_field, strict=True)
+        self.assertEqual(
+            self.get_column_comment(Author._meta.db_table, "name"),
+            "Custom comment",
+        )
+        # Alter comment.
+        old_field = new_field
+        new_field = CharField(max_length=255, db_comment="New custom comment")
+        new_field.set_attributes_from_name("name")
+        with connection.schema_editor() as editor:
+            editor.alter_field(Author, old_field, new_field, strict=True)
+        self.assertEqual(
+            self.get_column_comment(Author._meta.db_table, "name"),
+            "New custom comment",
+        )
+        # Remove comment.
+        old_field = new_field
+        new_field = CharField(max_length=255)
+        new_field.set_attributes_from_name("name")
+        with connection.schema_editor() as editor:
+            editor.alter_field(Author, old_field, new_field, strict=True)
+        self.assertIn(
+            self.get_column_comment(Author._meta.db_table, "name"),
+            [None, ""],
+        )
+
+    @skipUnlessDBFeature("supports_comments", "supports_foreign_keys")
+    def test_alter_db_comment_foreign_key(self):
+        with connection.schema_editor() as editor:
+            editor.create_model(Author)
+            editor.create_model(Book)
+
+        comment = "FK custom comment"
+        old_field = Book._meta.get_field("author")
+        new_field = ForeignKey(Author, CASCADE, db_comment=comment)
+        new_field.set_attributes_from_name("author")
+        with connection.schema_editor() as editor:
+            editor.alter_field(Book, old_field, new_field, strict=True)
+        self.assertEqual(
+            self.get_column_comment(Book._meta.db_table, "author_id"),
+            comment,
+        )
+
+    @skipUnlessDBFeature("supports_comments")
+    def test_alter_field_type_preserve_comment(self):
+        with connection.schema_editor() as editor:
+            editor.create_model(Author)
+
+        comment = "This is the name."
+        old_field = Author._meta.get_field("name")
+        new_field = CharField(max_length=255, db_comment=comment)
+        new_field.set_attributes_from_name("name")
+        new_field.model = Author
+        with connection.schema_editor() as editor:
+            editor.alter_field(Author, old_field, new_field, strict=True)
+        self.assertEqual(
+            self.get_column_comment(Author._meta.db_table, "name"),
+            comment,
+        )
+        # Changing a field type should preserve the comment.
+        old_field = new_field
+        new_field = CharField(max_length=511, db_comment=comment)
+        new_field.set_attributes_from_name("name")
+        new_field.model = Author
+        with connection.schema_editor() as editor:
+            editor.alter_field(Author, new_field, old_field, strict=True)
+        # Comment is preserved.
+        self.assertEqual(
+            self.get_column_comment(Author._meta.db_table, "name"),
+            comment,
+        )
+
+    @isolate_apps("schema")
+    @skipUnlessDBFeature("supports_comments")
+    def test_db_comment_table(self):
+        class ModelWithDbTableComment(Model):
+            class Meta:
+                app_label = "schema"
+                db_table_comment = "Custom table comment"
+
+        with connection.schema_editor() as editor:
+            editor.create_model(ModelWithDbTableComment)
+        self.isolated_local_models = [ModelWithDbTableComment]
+        self.assertEqual(
+            self.get_table_comment(ModelWithDbTableComment._meta.db_table),
+            "Custom table comment",
+        )
+        # Alter table comment.
+        old_db_table_comment = ModelWithDbTableComment._meta.db_table_comment
+        with connection.schema_editor() as editor:
+            editor.alter_db_table_comment(
+                ModelWithDbTableComment, old_db_table_comment, "New table comment"
+            )
+        self.assertEqual(
+            self.get_table_comment(ModelWithDbTableComment._meta.db_table),
+            "New table comment",
+        )
+        # Remove table comment.
+        old_db_table_comment = ModelWithDbTableComment._meta.db_table_comment
+        with connection.schema_editor() as editor:
+            editor.alter_db_table_comment(
+                ModelWithDbTableComment, old_db_table_comment, None
+            )
+        self.assertIn(
+            self.get_table_comment(ModelWithDbTableComment._meta.db_table),
+            [None, ""],
+        )
+
+    @isolate_apps("schema")
+    @skipUnlessDBFeature("supports_comments", "supports_foreign_keys")
+    def test_db_comments_from_abstract_model(self):
+        class AbstractModelWithDbComments(Model):
+            name = CharField(
+                max_length=255, db_comment="Custom comment", null=True, blank=True
+            )
+
+            class Meta:
+                app_label = "schema"
+                abstract = True
+                db_table_comment = "Custom table comment"
+
+        class ModelWithDbComments(AbstractModelWithDbComments):
+            pass
+
+        with connection.schema_editor() as editor:
+            editor.create_model(ModelWithDbComments)
+        self.isolated_local_models = [ModelWithDbComments]
+
+        self.assertEqual(
+            self.get_column_comment(ModelWithDbComments._meta.db_table, "name"),
+            "Custom comment",
+        )
+        self.assertEqual(
+            self.get_table_comment(ModelWithDbComments._meta.db_table),
+            "Custom table comment",
+        )
+
     @unittest.skipUnless(connection.vendor == "postgresql", "PostgreSQL specific")
     def test_alter_field_add_index_to_charfield(self):
         # Create the table and verify no initial indexes.