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 kilian Kim Joon Hwan 김준환 + Kim Soung Ryoul 김성렬 Klaas van Schelven knox 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 @@ -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.""" 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**: ```` does not support a database index on ```` columns. +* **fields.W163**: ```` does not support comments on columns + (``db_comment``). * **fields.E170**: ``BinaryField``’s ``default`` cannot be a string. Use bytes content instead. * **fields.E180**: ```` does not support ``JSONField``\s. @@ -315,6 +317,7 @@ Related fields the table name of ````/``.``. * **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 ```` contains ``RawSQL()`` expression and won't be validated during the model ``full_clean()``. +* **models.W046**: ```` 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 ` 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 ` and +:attr:`Meta.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 `. + 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.