summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AUTHORS1
-rw-r--r--django/core/management/commands/inspectdb.py44
-rw-r--r--django/db/backends/base/features.py5
-rw-r--r--django/db/backends/base/schema.py91
-rw-r--r--django/db/backends/mysql/features.py2
-rw-r--r--django/db/backends/mysql/introspection.py25
-rw-r--r--django/db/backends/mysql/schema.py13
-rw-r--r--django/db/backends/oracle/features.py1
-rw-r--r--django/db/backends/oracle/introspection.py40
-rw-r--r--django/db/backends/postgresql/features.py1
-rw-r--r--django/db/backends/postgresql/introspection.py11
-rw-r--r--django/db/migrations/autodetector.py23
-rw-r--r--django/db/migrations/operations/__init__.py2
-rw-r--r--django/db/migrations/operations/models.py38
-rw-r--r--django/db/models/base.py24
-rw-r--r--django/db/models/fields/__init__.py26
-rw-r--r--django/db/models/fields/related.py8
-rw-r--r--django/db/models/options.py2
-rw-r--r--docs/ref/checks.txt5
-rw-r--r--docs/ref/migration-operations.txt11
-rw-r--r--docs/ref/models/fields.txt15
-rw-r--r--docs/ref/models/options.txt18
-rw-r--r--docs/ref/schema-editor.txt9
-rw-r--r--docs/releases/4.2.txt35
-rw-r--r--tests/inspectdb/models.py8
-rw-r--r--tests/inspectdb/tests.py18
-rw-r--r--tests/introspection/models.py8
-rw-r--r--tests/introspection/tests.py21
-rw-r--r--tests/invalid_models_tests/test_models.py31
-rw-r--r--tests/invalid_models_tests/test_ordinary_fields.py32
-rw-r--r--tests/invalid_models_tests/test_relative_fields.py9
-rw-r--r--tests/migrations/test_autodetector.py60
-rw-r--r--tests/migrations/test_base.py14
-rw-r--r--tests/migrations/test_operations.py31
-rw-r--r--tests/schema/tests.py201
35 files changed, 846 insertions, 37 deletions
diff --git a/AUTHORS b/AUTHORS
index 381e85f6cf..db1454c5ee 100644
--- a/AUTHORS
+++ b/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
diff --git a/django/core/management/commands/inspectdb.py b/django/core/management/commands/inspectdb.py
index 9abf8168b4..51d7866503 100644
--- a/django/core/management/commands/inspectdb.py
+++ b/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
diff --git a/django/db/backends/base/features.py b/django/db/backends/base/features.py
index 190f728bba..b5b6c5b55d 100644
--- a/django/db/backends/base/features.py
+++ b/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
diff --git a/django/db/backends/base/schema.py b/django/db/backends/base/schema.py
index 2451fc515a..3a83e14be9 100644
--- a/django/db/backends/base/schema.py
+++ b/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
diff --git a/django/db/backends/mysql/features.py b/django/db/backends/mysql/features.py
index 563159138c..2f5cc91881 100644
--- a/django/db/backends/mysql/features.py
+++ b/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
diff --git a/django/db/backends/mysql/introspection.py b/django/db/backends/mysql/introspection.py
index b8228f3c84..a5ebf37112 100644
--- a/django/db/backends/mysql/introspection.py
+++ b/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
diff --git a/django/db/backends/mysql/schema.py b/django/db/backends/mysql/schema.py
index 6be755f8fb..64771fe6c3 100644
--- a/django/db/backends/mysql/schema.py
+++ b/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}"
diff --git a/django/db/backends/oracle/features.py b/django/db/backends/oracle/features.py
index a85cf45560..df36245af9 100644
--- a/django/db/backends/oracle/features.py
+++ b/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
diff --git a/django/db/backends/oracle/introspection.py b/django/db/backends/oracle/introspection.py
index af4e1a9e04..5d1e3e6761 100644
--- a/django/db/backends/oracle/introspection.py
+++ b/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
diff --git a/django/db/backends/postgresql/features.py b/django/db/backends/postgresql/features.py
index fd5b05aad4..49658cd267 100644
--- a/django/db/backends/postgresql/features.py
+++ b/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
diff --git a/django/db/backends/postgresql/introspection.py b/django/db/backends/postgresql/introspection.py
index a08ac2d039..d649b6fd4f 100644
--- a/django/db/backends/postgresql/introspection.py
+++ b/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
diff --git a/django/db/migrations/autodetector.py b/django/db/migrations/autodetector.py
index eae09eb65a..bb266cac9c 100644
--- a/django/db/migrations/autodetector.py
+++ b/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
diff --git a/django/db/migrations/operations/__init__.py b/django/db/migrations/operations/__init__.py
index 987c7c1fe6..90dbdf8256 100644
--- a/django/db/migrations/operations/__init__.py
+++ b/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",
diff --git a/django/db/migrations/operations/models.py b/django/db/migrations/operations/models.py
index 3a33b4aff7..a243aba0b6 100644
--- a/django/db/migrations/operations/models.py
+++ b/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
diff --git a/django/db/models/base.py b/django/db/models/base.py
index 668b8cc221..8c8a74158d 100644
--- a/django/db/models/base.py
+++ b/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
@@ -1593,6 +1594,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."""
errors = []
diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py
index 3737fa0620..e3b47d173c 100644
--- a/django/db/models/fields/__init__.py
+++ b/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": [],
diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py
index 11074de9eb..e5dd4e2a85 100644
--- a/django/db/models/fields/related.py
+++ b/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
diff --git a/django/db/models/options.py b/django/db/models/options.py
index b6b8202802..607b19fb8a 100644
--- a/django/db/models/options.py
+++ b/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 = []
diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt
index f85c90059d..fa7c633487 100644
--- a/docs/ref/checks.txt
+++ b/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
--------
diff --git a/docs/ref/migration-operations.txt b/docs/ref/migration-operations.txt
index a223ff6a23..b463bfc4ea 100644
--- a/docs/ref/migration-operations.txt
+++ b/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``
-----------------------
diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt
index 0afddc14a8..d460e81ef4 100644
--- a/docs/ref/models/fields.txt
+++ b/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``
------------
diff --git a/docs/ref/models/options.txt b/docs/ref/models/options.txt
index a1629168af..a882fcb05a 100644
--- a/docs/ref/models/options.txt
+++ b/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``
-----------------
diff --git a/docs/ref/schema-editor.txt b/docs/ref/schema-editor.txt
index 99d93f5ab4..fed3e76309 100644
--- a/docs/ref/schema-editor.txt
+++ b/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()``
-------------------------
diff --git a/docs/releases/4.2.txt b/docs/releases/4.2.txt
index 9710e889ca..90fdf9bd1b 100644
--- a/docs/releases/4.2.txt
+++ b/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
--------------------------------
diff --git a/tests/inspectdb/models.py b/tests/inspectdb/models.py
index c07cd4def1..5db4d3bc73 100644
--- a/tests/inspectdb/models.py
+++ b/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"}
diff --git a/tests/inspectdb/tests.py b/tests/inspectdb/tests.py
index 7944e5f221..2f26814625 100644
--- a/tests/inspectdb/tests.py
+++ b/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):
diff --git a/tests/introspection/models.py b/tests/introspection/models.py
index 31c1b0de80..d31eb0cbfa 100644
--- a/tests/introspection/models.py
+++ b/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"}
diff --git a/tests/introspection/tests.py b/tests/introspection/tests.py
index 459a405932..a283aa0769 100644
--- a/tests/introspection/tests.py
+++ b/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):
diff --git a/tests/invalid_models_tests/test_models.py b/tests/invalid_models_tests/test_models.py
index 5a5aeccdf5..c07c83d79d 100644
--- a/tests/invalid_models_tests/test_models.py
+++ b/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 = (
diff --git a/tests/invalid_models_tests/test_ordinary_fields.py b/tests/invalid_models_tests/test_ordinary_fields.py
index ef7f845a33..4e07c95956 100644
--- a/tests/invalid_models_tests/test_ordinary_fields.py
+++ b/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, [])
diff --git a/tests/invalid_models_tests/test_relative_fields.py b/tests/invalid_models_tests/test_relative_fields.py
index 5b4bb45ff8..075bbaefbc 100644
--- a/tests/invalid_models_tests/test_relative_fields.py
+++ b/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",
+ ),
],
)
diff --git a/tests/migrations/test_autodetector.py b/tests/migrations/test_autodetector.py
index d2f9be75f7..82ddb17543 100644
--- a/tests/migrations/test_autodetector.py
+++ b/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",
diff --git a/tests/migrations/test_base.py b/tests/migrations/test_base.py
index 3f1559b8d6..f038cd7605 100644
--- a/tests/migrations/test_base.py
+++ b/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
):
diff --git a/tests/migrations/test_operations.py b/tests/migrations/test_operations.py
index 2bcb38feae..129360629d 100644
--- a/tests/migrations/test_operations.py
+++ b/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
diff --git a/tests/schema/tests.py b/tests/schema/tests.py
index 9b2b512c1b..e325fa0c88 100644
--- a/tests/schema/tests.py
+++ b/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.