1
0
mirror of https://github.com/django/django.git synced 2024-12-22 17:16:24 +00:00

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

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

Co-authored-by: Mariusz Felisiak <felisiak.mariusz@gmail.com>
Co-authored-by: Nick Pope <nick@nickpope.me.uk>
This commit is contained in:
kimsoungryoul 2022-10-16 14:59:39 +09:00 committed by Mariusz Felisiak
parent 68ef274bc5
commit 78f163a4fb
35 changed files with 846 additions and 37 deletions

View File

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

View File

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

View File

@ -334,6 +334,11 @@ class BaseDatabaseFeatures:
# Does the backend support non-deterministic collations? # Does the backend support non-deterministic collations?
supports_non_deterministic_collations = True 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? # Does the backend support the logical XOR operator?
supports_logical_xor = False supports_logical_xor = False

View File

@ -141,6 +141,9 @@ class BaseDatabaseSchemaEditor:
sql_delete_procedure = "DROP PROCEDURE %(procedure)s" 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): def __init__(self, connection, collect_sql=False, atomic=True):
self.connection = connection self.connection = connection
self.collect_sql = collect_sql self.collect_sql = collect_sql
@ -289,6 +292,8 @@ class BaseDatabaseSchemaEditor:
yield column_db_type yield column_db_type
if collation := field_db_params.get("collation"): if collation := field_db_params.get("collation"):
yield self._collate_sql(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. # Work out nullability.
null = field.null null = field.null
# Include a default value, if requested. # Include a default value, if requested.
@ -445,6 +450,23 @@ class BaseDatabaseSchemaEditor:
# definition. # definition.
self.execute(sql, params or None) 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 # Add any field index and index_together's (deferred as SQLite
# _remake_table needs it). # _remake_table needs it).
self.deferred_sql.extend(self._model_indexes_sql(model)) self.deferred_sql.extend(self._model_indexes_sql(model))
@ -614,6 +636,15 @@ class BaseDatabaseSchemaEditor:
if isinstance(sql, Statement): if isinstance(sql, Statement):
sql.rename_table_references(old_db_table, new_db_table) 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): def alter_db_tablespace(self, model, old_db_tablespace, new_db_tablespace):
"""Move a model's table between tablespaces.""" """Move a model's table between tablespaces."""
self.execute( self.execute(
@ -693,6 +724,18 @@ class BaseDatabaseSchemaEditor:
"changes": changes_sql, "changes": changes_sql,
} }
self.execute(sql, params) 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 # Add an index, if required
self.deferred_sql.extend(self._field_indexes_sql(model, field)) self.deferred_sql.extend(self._field_indexes_sql(model, field))
# Reset connection if required # Reset connection if required
@ -813,6 +856,11 @@ class BaseDatabaseSchemaEditor:
self.connection.features.supports_foreign_keys self.connection.features.supports_foreign_keys
and old_field.remote_field and old_field.remote_field
and old_field.db_constraint and old_field.db_constraint
and self._field_should_be_altered(
old_field,
new_field,
ignore={"db_comment"},
)
): ):
fk_names = self._constraint_names( fk_names = self._constraint_names(
model, [old_field.column], foreign_key=True model, [old_field.column], foreign_key=True
@ -949,11 +997,15 @@ class BaseDatabaseSchemaEditor:
# Type suffix change? (e.g. auto increment). # Type suffix change? (e.g. auto increment).
old_type_suffix = old_field.db_type_suffix(connection=self.connection) old_type_suffix = old_field.db_type_suffix(connection=self.connection)
new_type_suffix = new_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 ( if (
old_type != new_type old_type != new_type
or old_type_suffix != new_type_suffix or old_type_suffix != new_type_suffix
or old_collation != new_collation 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( fragment, other_actions = self._alter_column_type_sql(
model, old_field, new_field, new_type, old_collation, new_collation 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 an ALTER TABLE statement and a list of extra (sql, params) tuples to
run once the field is altered. run once the field is altered.
""" """
other_actions = []
if collate_sql := self._collate_sql( if collate_sql := self._collate_sql(
new_collation, old_collation, model._meta.db_table new_collation, old_collation, model._meta.db_table
): ):
collate_sql = f" {collate_sql}" collate_sql = f" {collate_sql}"
else: else:
collate_sql = "" 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 ( return (
( (
self.sql_alter_column_type self.sql_alter_column_type
@ -1224,12 +1290,27 @@ class BaseDatabaseSchemaEditor:
"column": self.quote_name(new_field.column), "column": self.quote_name(new_field.column),
"type": new_type, "type": new_type,
"collation": collate_sql, "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): def _alter_many_to_many(self, model, old_field, new_field, strict):
"""Alter M2Ms to repoint their to= endpoints.""" """Alter M2Ms to repoint their to= endpoints."""
# Rename the through table # Rename the through table
@ -1423,16 +1504,18 @@ class BaseDatabaseSchemaEditor:
output.append(self._create_index_sql(model, fields=[field])) output.append(self._create_index_sql(model, fields=[field]))
return output 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() _, old_path, old_args, old_kwargs = old_field.deconstruct()
_, new_path, new_args, new_kwargs = new_field.deconstruct() _, new_path, new_args, new_kwargs = new_field.deconstruct()
# Don't alter when: # Don't alter when:
# - changing only a field name # - changing only a field name
# - changing an attribute that doesn't affect the schema # - 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 # - 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) 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) new_kwargs.pop(attr, None)
return self.quote_name(old_field.column) != self.quote_name( return self.quote_name(old_field.column) != self.quote_name(
new_field.column new_field.column

View File

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

View File

@ -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 BaseDatabaseIntrospection
from django.db.backends.base.introspection import FieldInfo as BaseFieldInfo 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.db.models import Index
from django.utils.datastructures import OrderedSet from django.utils.datastructures import OrderedSet
FieldInfo = namedtuple( FieldInfo = namedtuple(
"FieldInfo", BaseFieldInfo._fields + ("extra", "is_unsigned", "has_json_constraint") "FieldInfo",
BaseFieldInfo._fields + ("extra", "is_unsigned", "has_json_constraint", "comment"),
) )
InfoLine = namedtuple( InfoLine = namedtuple(
"InfoLine", "InfoLine",
"col_name data_type max_len num_prec num_scale extra column_default " "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): class DatabaseIntrospection(BaseDatabaseIntrospection):
@ -68,9 +70,18 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
def get_table_list(self, cursor): def get_table_list(self, cursor):
"""Return a list of table and view names in the current database.""" """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 [ 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() for row in cursor.fetchall()
] ]
@ -128,7 +139,8 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
CASE CASE
WHEN column_type LIKE '%% unsigned' THEN 1 WHEN column_type LIKE '%% unsigned' THEN 1
ELSE 0 ELSE 0
END AS is_unsigned END AS is_unsigned,
column_comment
FROM information_schema.columns FROM information_schema.columns
WHERE table_name = %s AND table_schema = DATABASE() WHERE table_name = %s AND table_schema = DATABASE()
""", """,
@ -159,6 +171,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
info.extra, info.extra,
info.is_unsigned, info.is_unsigned,
line[0] in json_constraints, line[0] in json_constraints,
info.comment,
) )
) )
return fields return fields

View File

@ -9,7 +9,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
sql_alter_column_null = "MODIFY %(column)s %(type)s NULL" 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_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" 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 # 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_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 @property
def sql_delete_check(self): def sql_delete_check(self):
if self.connection.mysql_is_mariadb: 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): 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) 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) 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}"

View File

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

View File

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

View File

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

View File

@ -2,10 +2,11 @@ from collections import namedtuple
from django.db.backends.base.introspection import BaseDatabaseIntrospection 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 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.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): class DatabaseIntrospection(BaseDatabaseIntrospection):
@ -62,7 +63,8 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
WHEN c.relispartition THEN 'p' WHEN c.relispartition THEN 'p'
WHEN c.relkind IN ('m', 'v') THEN 'v' WHEN c.relkind IN ('m', 'v') THEN 'v'
ELSE 't' ELSE 't'
END END,
obj_description(c.oid)
FROM pg_catalog.pg_class c FROM pg_catalog.pg_class c
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('f', 'm', 'p', 'r', 'v') 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, NOT (a.attnotnull OR (t.typtype = 'd' AND t.typnotnull)) AS is_nullable,
pg_get_expr(ad.adbin, ad.adrelid) AS column_default, pg_get_expr(ad.adbin, ad.adrelid) AS column_default,
CASE WHEN collname = 'default' THEN NULL ELSE collname END AS collation, 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 FROM pg_attribute a
LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum 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 LEFT JOIN pg_collation co ON a.attcollation = co.oid

View File

@ -170,6 +170,7 @@ class MigrationAutodetector:
self.generate_created_proxies() self.generate_created_proxies()
self.generate_altered_options() self.generate_altered_options()
self.generate_altered_managers() self.generate_altered_managers()
self.generate_altered_db_table_comment()
# Create the renamed fields and store them in self.renamed_fields. # Create the renamed fields and store them in self.renamed_fields.
# They are used by create_altered_indexes(), generate_altered_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): def generate_altered_options(self):
""" """
Work out if any non-schema-affecting options have changed and make an Work out if any non-schema-affecting options have changed and make an

View File

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

View File

@ -529,6 +529,44 @@ class AlterModelTable(ModelOptionOperation):
return "alter_%s_table" % self.name_lower 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): class AlterTogetherOptionOperation(ModelOptionOperation):
option_name = None option_name = None

View File

@ -1556,6 +1556,7 @@ class Model(AltersData, metaclass=ModelBase):
*cls._check_ordering(), *cls._check_ordering(),
*cls._check_constraints(databases), *cls._check_constraints(databases),
*cls._check_default_pk(), *cls._check_default_pk(),
*cls._check_db_table_comment(databases),
] ]
return errors return errors
@ -1592,6 +1593,29 @@ class Model(AltersData, metaclass=ModelBase):
] ]
return [] 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 @classmethod
def _check_swappable(cls): def _check_swappable(cls):
"""Check if the swapped model exists.""" """Check if the swapped model exists."""

View File

@ -200,6 +200,7 @@ class Field(RegisterLookupMixin):
auto_created=False, auto_created=False,
validators=(), validators=(),
error_messages=None, error_messages=None,
db_comment=None,
): ):
self.name = name self.name = name
self.verbose_name = verbose_name # May be set by set_attributes_from_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.help_text = help_text
self.db_index = db_index self.db_index = db_index
self.db_column = db_column self.db_column = db_column
self.db_comment = db_comment
self._db_tablespace = db_tablespace self._db_tablespace = db_tablespace
self.auto_created = auto_created self.auto_created = auto_created
@ -259,6 +261,7 @@ class Field(RegisterLookupMixin):
*self._check_field_name(), *self._check_field_name(),
*self._check_choices(), *self._check_choices(),
*self._check_db_index(), *self._check_db_index(),
*self._check_db_comment(**kwargs),
*self._check_null_allowed_for_primary_keys(), *self._check_null_allowed_for_primary_keys(),
*self._check_backend_specific_checks(**kwargs), *self._check_backend_specific_checks(**kwargs),
*self._check_validators(), *self._check_validators(),
@ -385,6 +388,28 @@ class Field(RegisterLookupMixin):
else: else:
return [] 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): def _check_null_allowed_for_primary_keys(self):
if ( if (
self.primary_key self.primary_key
@ -538,6 +563,7 @@ class Field(RegisterLookupMixin):
"choices": None, "choices": None,
"help_text": "", "help_text": "",
"db_column": None, "db_column": None,
"db_comment": None,
"db_tablespace": None, "db_tablespace": None,
"auto_created": False, "auto_created": False,
"validators": [], "validators": [],

View File

@ -1428,6 +1428,14 @@ class ManyToManyField(RelatedField):
id="fields.W345", 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 return warnings

View File

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

View File

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

View File

@ -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` Changes the model's table name (the :attr:`~django.db.models.Options.db_table`
option on the ``Meta`` subclass). 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`` ``AlterUniqueTogether``
----------------------- -----------------------

View File

@ -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 hyphen -- that's OK. Django quotes column and table names behind the
scenes. 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`` ``db_index``
------------ ------------

View File

@ -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 backends; except for Oracle, however, the quotes have no effect. See the
:ref:`Oracle notes <oracle-notes>` for more details. :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`` ``db_tablespace``
----------------- -----------------

View File

@ -127,6 +127,15 @@ value.
Renames the model's table from ``old_db_table`` to ``new_db_table``. 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()`` ``alter_db_tablespace()``
------------------------- -------------------------

View File

@ -40,6 +40,41 @@ in the future.
.. _psycopg: https://www.psycopg.org/psycopg3/ .. _psycopg: https://www.psycopg.org/psycopg3/
.. _psycopg library: https://pypi.org/project/psycopg/ .. _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 Mitigation for the BREACH attack
-------------------------------- --------------------------------

View File

@ -132,3 +132,11 @@ class FuncUniqueConstraint(models.Model):
) )
] ]
required_db_features = {"supports_expression_indexes"} 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"}

View File

@ -129,6 +129,24 @@ class InspectDBTestCase(TestCase):
"null_json_field = models.JSONField(blank=True, null=True)", output "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") @skipUnlessDBFeature("supports_collation_on_charfield")
@skipUnless(test_collation, "Language collations are not supported.") @skipUnless(test_collation, "Language collations are not supported.")
def test_char_field_db_collation(self): def test_char_field_db_collation(self):

View File

@ -102,3 +102,11 @@ class UniqueConstraintConditionModel(models.Model):
condition=models.Q(color__isnull=True), 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"}

View File

@ -9,6 +9,7 @@ from .models import (
City, City,
Comment, Comment,
Country, Country,
DbCommentModel,
District, District,
Reporter, Reporter,
UniqueConstraintConditionModel, UniqueConstraintConditionModel,
@ -179,6 +180,26 @@ class IntrospectionTests(TransactionTestCase):
[connection.introspection.get_field_type(r[1], r) for r in desc], [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 # Regression test for #9991 - 'real' types in postgres
@skipUnlessDBFeature("has_real_datatype") @skipUnlessDBFeature("has_real_datatype")
def test_postgresql_real_type(self): def test_postgresql_real_type(self):

View File

@ -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): class MultipleAutoFieldsTests(TestCase):
def test_multiple_autofields(self): def test_multiple_autofields(self):
msg = ( msg = (

View File

@ -1023,3 +1023,35 @@ class JSONFieldTests(TestCase):
field = models.JSONField(default=callable_default) field = models.JSONField(default=callable_default)
self.assertEqual(Model._meta.get_field("field").check(), []) 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, [])

View File

@ -94,7 +94,9 @@ class RelativeFieldTests(SimpleTestCase):
name = models.CharField(max_length=20) name = models.CharField(max_length=20)
class ModelM2M(models.Model): 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") field = ModelM2M._meta.get_field("m2m")
self.assertEqual( self.assertEqual(
@ -110,6 +112,11 @@ class RelativeFieldTests(SimpleTestCase):
obj=field, obj=field,
id="fields.W341", id="fields.W341",
), ),
DjangoWarning(
"db_comment has no effect on ManyToManyField.",
obj=field,
id="fields.W346",
),
], ],
) )

View File

@ -773,6 +773,14 @@ class AutodetectorTests(BaseAutodetectorTests):
"verbose_name": "Authi", "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( author_with_db_table_options = ModelState(
"testapp", "testapp",
"Author", "Author",
@ -2349,6 +2357,58 @@ class AutodetectorTests(BaseAutodetectorTests):
changes, "testapp", 0, 1, name="newauthor", table="author_three" 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): def test_identical_regex_doesnt_alter(self):
from_state = ModelState( from_state = ModelState(
"testapp", "testapp",

View File

@ -75,6 +75,20 @@ class MigrationTestBase(TransactionTestCase):
def assertColumnCollation(self, table, column, collation, using="default"): def assertColumnCollation(self, table, column, collation, using="default"):
self.assertEqual(self._get_column_collation(table, column, using), collation) 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( def assertIndexExists(
self, table, columns, value=True, using="default", index_type=None self, table, columns, value=True, using="default", index_type=None
): ):

View File

@ -1922,6 +1922,37 @@ class OperationTests(OperationTestBase):
operation.database_forwards(app_label, editor, new_state, project_state) operation.database_forwards(app_label, editor, new_state, project_state)
self.assertColumnExists(rider_table, "pony_id") 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): def test_alter_field_pk(self):
""" """
The AlterField operation on primary keys (things like PostgreSQL's The AlterField operation on primary keys (things like PostgreSQL's

View File

@ -273,6 +273,27 @@ class SchemaTests(TransactionTestCase):
if f.name == column 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): def assertIndexOrder(self, table, index, order):
constraints = self.get_constraints(table) constraints = self.get_constraints(table)
self.assertIn(index, constraints) 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") @unittest.skipUnless(connection.vendor == "postgresql", "PostgreSQL specific")
def test_alter_field_add_index_to_charfield(self): def test_alter_field_add_index_to_charfield(self):
# Create the table and verify no initial indexes. # Create the table and verify no initial indexes.