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>
kilian <kilian.cavalotti@lip6.fr>
Kim Joon Hwan 김준환 <xncbf12@gmail.com>
Kim Soung Ryoul 김성렬 <kimsoungryoul@gmail.com>
Klaas van Schelven <klaas@vanschelven.com>
knox <christobzr@gmail.com>
konrad@gwu.edu

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

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 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

View File

@ -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}"

View File

@ -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

View File

@ -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

View File

@ -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

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 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

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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."""

View File

@ -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": [],

View File

@ -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

View File

@ -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 = []

View File

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

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`
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``
-----------------------

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
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``
------------

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
: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``
-----------------

View File

@ -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()``
-------------------------

View File

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

View File

@ -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"}

View File

@ -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):

View File

@ -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"}

View File

@ -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):

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

View File

@ -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, [])

View File

@ -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",
),
],
)

View File

@ -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",

View File

@ -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
):

View File

@ -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

View File

@ -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.