mirror of https://github.com/django/django.git
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:
parent
68ef274bc5
commit
78f163a4fb
1
AUTHORS
1
AUTHORS
|
@ -557,6 +557,7 @@ answer newbie questions, and generally made Django that much better:
|
|||
Kieran Holland <http://www.kieranholland.com>
|
||||
kilian <kilian.cavalotti@lip6.fr>
|
||||
Kim Joon Hwan 김준환 <xncbf12@gmail.com>
|
||||
Kim Soung Ryoul 김성렬 <kimsoungryoul@gmail.com>
|
||||
Klaas van Schelven <klaas@vanschelven.com>
|
||||
knox <christobzr@gmail.com>
|
||||
konrad@gwu.edu
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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": [],
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -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
|
||||
--------
|
||||
|
|
|
@ -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``
|
||||
-----------------------
|
||||
|
||||
|
|
|
@ -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``
|
||||
------------
|
||||
|
||||
|
|
|
@ -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``
|
||||
-----------------
|
||||
|
||||
|
|
|
@ -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()``
|
||||
-------------------------
|
||||
|
||||
|
|
|
@ -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
|
||||
--------------------------------
|
||||
|
||||
|
|
|
@ -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"}
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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"}
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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, [])
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue