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:
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>
|
Kieran Holland <http://www.kieranholland.com>
|
||||||
kilian <kilian.cavalotti@lip6.fr>
|
kilian <kilian.cavalotti@lip6.fr>
|
||||||
Kim Joon Hwan 김준환 <xncbf12@gmail.com>
|
Kim Joon Hwan 김준환 <xncbf12@gmail.com>
|
||||||
|
Kim Soung Ryoul 김성렬 <kimsoungryoul@gmail.com>
|
||||||
Klaas van Schelven <klaas@vanschelven.com>
|
Klaas van Schelven <klaas@vanschelven.com>
|
||||||
knox <christobzr@gmail.com>
|
knox <christobzr@gmail.com>
|
||||||
konrad@gwu.edu
|
konrad@gwu.edu
|
||||||
|
@ -78,18 +78,16 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
yield "from %s import models" % self.db_module
|
yield "from %s import models" % self.db_module
|
||||||
known_models = []
|
known_models = []
|
||||||
table_info = connection.introspection.get_table_list(cursor)
|
|
||||||
|
|
||||||
# Determine types of tables and/or views to be introspected.
|
# Determine types of tables and/or views to be introspected.
|
||||||
types = {"t"}
|
types = {"t"}
|
||||||
if options["include_partitions"]:
|
if options["include_partitions"]:
|
||||||
types.add("p")
|
types.add("p")
|
||||||
if options["include_views"]:
|
if options["include_views"]:
|
||||||
types.add("v")
|
types.add("v")
|
||||||
|
table_info = connection.introspection.get_table_list(cursor)
|
||||||
|
table_info = {info.name: info for info in table_info if info.type in types}
|
||||||
|
|
||||||
for table_name in options["table"] or sorted(
|
for table_name in options["table"] or sorted(name for name in table_info):
|
||||||
info.name for info in table_info if info.type in types
|
|
||||||
):
|
|
||||||
if table_name_filter is not None and callable(table_name_filter):
|
if table_name_filter is not None and callable(table_name_filter):
|
||||||
if not table_name_filter(table_name):
|
if not table_name_filter(table_name):
|
||||||
continue
|
continue
|
||||||
@ -232,6 +230,10 @@ class Command(BaseCommand):
|
|||||||
if field_type.startswith(("ForeignKey(", "OneToOneField(")):
|
if field_type.startswith(("ForeignKey(", "OneToOneField(")):
|
||||||
field_desc += ", models.DO_NOTHING"
|
field_desc += ", models.DO_NOTHING"
|
||||||
|
|
||||||
|
# Add comment.
|
||||||
|
if connection.features.supports_comments and row.comment:
|
||||||
|
extra_params["db_comment"] = row.comment
|
||||||
|
|
||||||
if extra_params:
|
if extra_params:
|
||||||
if not field_desc.endswith("("):
|
if not field_desc.endswith("("):
|
||||||
field_desc += ", "
|
field_desc += ", "
|
||||||
@ -242,14 +244,22 @@ class Command(BaseCommand):
|
|||||||
if comment_notes:
|
if comment_notes:
|
||||||
field_desc += " # " + " ".join(comment_notes)
|
field_desc += " # " + " ".join(comment_notes)
|
||||||
yield " %s" % field_desc
|
yield " %s" % field_desc
|
||||||
is_view = any(
|
comment = None
|
||||||
info.name == table_name and info.type == "v" for info in table_info
|
if info := table_info.get(table_name):
|
||||||
)
|
is_view = info.type == "v"
|
||||||
is_partition = any(
|
is_partition = info.type == "p"
|
||||||
info.name == table_name and info.type == "p" for info in table_info
|
if connection.features.supports_comments:
|
||||||
)
|
comment = info.comment
|
||||||
|
else:
|
||||||
|
is_view = False
|
||||||
|
is_partition = False
|
||||||
yield from self.get_meta(
|
yield from self.get_meta(
|
||||||
table_name, constraints, column_to_field_name, is_view, is_partition
|
table_name,
|
||||||
|
constraints,
|
||||||
|
column_to_field_name,
|
||||||
|
is_view,
|
||||||
|
is_partition,
|
||||||
|
comment,
|
||||||
)
|
)
|
||||||
|
|
||||||
def normalize_col_name(self, col_name, used_column_names, is_relation):
|
def normalize_col_name(self, col_name, used_column_names, is_relation):
|
||||||
@ -353,7 +363,13 @@ class Command(BaseCommand):
|
|||||||
return field_type, field_params, field_notes
|
return field_type, field_params, field_notes
|
||||||
|
|
||||||
def get_meta(
|
def get_meta(
|
||||||
self, table_name, constraints, column_to_field_name, is_view, is_partition
|
self,
|
||||||
|
table_name,
|
||||||
|
constraints,
|
||||||
|
column_to_field_name,
|
||||||
|
is_view,
|
||||||
|
is_partition,
|
||||||
|
comment,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Return a sequence comprising the lines of code necessary
|
Return a sequence comprising the lines of code necessary
|
||||||
@ -391,4 +407,6 @@ class Command(BaseCommand):
|
|||||||
if unique_together:
|
if unique_together:
|
||||||
tup = "(" + ", ".join(unique_together) + ",)"
|
tup = "(" + ", ".join(unique_together) + ",)"
|
||||||
meta += [" unique_together = %s" % tup]
|
meta += [" unique_together = %s" % tup]
|
||||||
|
if comment:
|
||||||
|
meta += [f" db_table_comment = {comment!r}"]
|
||||||
return meta
|
return meta
|
||||||
|
@ -334,6 +334,11 @@ class BaseDatabaseFeatures:
|
|||||||
# Does the backend support non-deterministic collations?
|
# Does the backend support non-deterministic collations?
|
||||||
supports_non_deterministic_collations = True
|
supports_non_deterministic_collations = True
|
||||||
|
|
||||||
|
# Does the backend support column and table comments?
|
||||||
|
supports_comments = False
|
||||||
|
# Does the backend support column comments in ADD COLUMN statements?
|
||||||
|
supports_comments_inline = False
|
||||||
|
|
||||||
# Does the backend support the logical XOR operator?
|
# Does the backend support the logical XOR operator?
|
||||||
supports_logical_xor = False
|
supports_logical_xor = False
|
||||||
|
|
||||||
|
@ -141,6 +141,9 @@ class BaseDatabaseSchemaEditor:
|
|||||||
|
|
||||||
sql_delete_procedure = "DROP PROCEDURE %(procedure)s"
|
sql_delete_procedure = "DROP PROCEDURE %(procedure)s"
|
||||||
|
|
||||||
|
sql_alter_table_comment = "COMMENT ON TABLE %(table)s IS %(comment)s"
|
||||||
|
sql_alter_column_comment = "COMMENT ON COLUMN %(table)s.%(column)s IS %(comment)s"
|
||||||
|
|
||||||
def __init__(self, connection, collect_sql=False, atomic=True):
|
def __init__(self, connection, collect_sql=False, atomic=True):
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.collect_sql = collect_sql
|
self.collect_sql = collect_sql
|
||||||
@ -289,6 +292,8 @@ class BaseDatabaseSchemaEditor:
|
|||||||
yield column_db_type
|
yield column_db_type
|
||||||
if collation := field_db_params.get("collation"):
|
if collation := field_db_params.get("collation"):
|
||||||
yield self._collate_sql(collation)
|
yield self._collate_sql(collation)
|
||||||
|
if self.connection.features.supports_comments_inline and field.db_comment:
|
||||||
|
yield self._comment_sql(field.db_comment)
|
||||||
# Work out nullability.
|
# Work out nullability.
|
||||||
null = field.null
|
null = field.null
|
||||||
# Include a default value, if requested.
|
# Include a default value, if requested.
|
||||||
@ -445,6 +450,23 @@ class BaseDatabaseSchemaEditor:
|
|||||||
# definition.
|
# definition.
|
||||||
self.execute(sql, params or None)
|
self.execute(sql, params or None)
|
||||||
|
|
||||||
|
if self.connection.features.supports_comments:
|
||||||
|
# Add table comment.
|
||||||
|
if model._meta.db_table_comment:
|
||||||
|
self.alter_db_table_comment(model, None, model._meta.db_table_comment)
|
||||||
|
# Add column comments.
|
||||||
|
if not self.connection.features.supports_comments_inline:
|
||||||
|
for field in model._meta.local_fields:
|
||||||
|
if field.db_comment:
|
||||||
|
field_db_params = field.db_parameters(
|
||||||
|
connection=self.connection
|
||||||
|
)
|
||||||
|
field_type = field_db_params["type"]
|
||||||
|
self.execute(
|
||||||
|
*self._alter_column_comment_sql(
|
||||||
|
model, field, field_type, field.db_comment
|
||||||
|
)
|
||||||
|
)
|
||||||
# Add any field index and index_together's (deferred as SQLite
|
# Add any field index and index_together's (deferred as SQLite
|
||||||
# _remake_table needs it).
|
# _remake_table needs it).
|
||||||
self.deferred_sql.extend(self._model_indexes_sql(model))
|
self.deferred_sql.extend(self._model_indexes_sql(model))
|
||||||
@ -614,6 +636,15 @@ class BaseDatabaseSchemaEditor:
|
|||||||
if isinstance(sql, Statement):
|
if isinstance(sql, Statement):
|
||||||
sql.rename_table_references(old_db_table, new_db_table)
|
sql.rename_table_references(old_db_table, new_db_table)
|
||||||
|
|
||||||
|
def alter_db_table_comment(self, model, old_db_table_comment, new_db_table_comment):
|
||||||
|
self.execute(
|
||||||
|
self.sql_alter_table_comment
|
||||||
|
% {
|
||||||
|
"table": self.quote_name(model._meta.db_table),
|
||||||
|
"comment": self.quote_value(new_db_table_comment or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def alter_db_tablespace(self, model, old_db_tablespace, new_db_tablespace):
|
def alter_db_tablespace(self, model, old_db_tablespace, new_db_tablespace):
|
||||||
"""Move a model's table between tablespaces."""
|
"""Move a model's table between tablespaces."""
|
||||||
self.execute(
|
self.execute(
|
||||||
@ -693,6 +724,18 @@ class BaseDatabaseSchemaEditor:
|
|||||||
"changes": changes_sql,
|
"changes": changes_sql,
|
||||||
}
|
}
|
||||||
self.execute(sql, params)
|
self.execute(sql, params)
|
||||||
|
# Add field comment, if required.
|
||||||
|
if (
|
||||||
|
field.db_comment
|
||||||
|
and self.connection.features.supports_comments
|
||||||
|
and not self.connection.features.supports_comments_inline
|
||||||
|
):
|
||||||
|
field_type = db_params["type"]
|
||||||
|
self.execute(
|
||||||
|
*self._alter_column_comment_sql(
|
||||||
|
model, field, field_type, field.db_comment
|
||||||
|
)
|
||||||
|
)
|
||||||
# Add an index, if required
|
# Add an index, if required
|
||||||
self.deferred_sql.extend(self._field_indexes_sql(model, field))
|
self.deferred_sql.extend(self._field_indexes_sql(model, field))
|
||||||
# Reset connection if required
|
# Reset connection if required
|
||||||
@ -813,6 +856,11 @@ class BaseDatabaseSchemaEditor:
|
|||||||
self.connection.features.supports_foreign_keys
|
self.connection.features.supports_foreign_keys
|
||||||
and old_field.remote_field
|
and old_field.remote_field
|
||||||
and old_field.db_constraint
|
and old_field.db_constraint
|
||||||
|
and self._field_should_be_altered(
|
||||||
|
old_field,
|
||||||
|
new_field,
|
||||||
|
ignore={"db_comment"},
|
||||||
|
)
|
||||||
):
|
):
|
||||||
fk_names = self._constraint_names(
|
fk_names = self._constraint_names(
|
||||||
model, [old_field.column], foreign_key=True
|
model, [old_field.column], foreign_key=True
|
||||||
@ -949,11 +997,15 @@ class BaseDatabaseSchemaEditor:
|
|||||||
# Type suffix change? (e.g. auto increment).
|
# Type suffix change? (e.g. auto increment).
|
||||||
old_type_suffix = old_field.db_type_suffix(connection=self.connection)
|
old_type_suffix = old_field.db_type_suffix(connection=self.connection)
|
||||||
new_type_suffix = new_field.db_type_suffix(connection=self.connection)
|
new_type_suffix = new_field.db_type_suffix(connection=self.connection)
|
||||||
# Type or collation change?
|
# Type, collation, or comment change?
|
||||||
if (
|
if (
|
||||||
old_type != new_type
|
old_type != new_type
|
||||||
or old_type_suffix != new_type_suffix
|
or old_type_suffix != new_type_suffix
|
||||||
or old_collation != new_collation
|
or old_collation != new_collation
|
||||||
|
or (
|
||||||
|
self.connection.features.supports_comments
|
||||||
|
and old_field.db_comment != new_field.db_comment
|
||||||
|
)
|
||||||
):
|
):
|
||||||
fragment, other_actions = self._alter_column_type_sql(
|
fragment, other_actions = self._alter_column_type_sql(
|
||||||
model, old_field, new_field, new_type, old_collation, new_collation
|
model, old_field, new_field, new_type, old_collation, new_collation
|
||||||
@ -1211,12 +1263,26 @@ class BaseDatabaseSchemaEditor:
|
|||||||
an ALTER TABLE statement and a list of extra (sql, params) tuples to
|
an ALTER TABLE statement and a list of extra (sql, params) tuples to
|
||||||
run once the field is altered.
|
run once the field is altered.
|
||||||
"""
|
"""
|
||||||
|
other_actions = []
|
||||||
if collate_sql := self._collate_sql(
|
if collate_sql := self._collate_sql(
|
||||||
new_collation, old_collation, model._meta.db_table
|
new_collation, old_collation, model._meta.db_table
|
||||||
):
|
):
|
||||||
collate_sql = f" {collate_sql}"
|
collate_sql = f" {collate_sql}"
|
||||||
else:
|
else:
|
||||||
collate_sql = ""
|
collate_sql = ""
|
||||||
|
# Comment change?
|
||||||
|
comment_sql = ""
|
||||||
|
if self.connection.features.supports_comments and not new_field.many_to_many:
|
||||||
|
if old_field.db_comment != new_field.db_comment:
|
||||||
|
# PostgreSQL and Oracle can't execute 'ALTER COLUMN ...' and
|
||||||
|
# 'COMMENT ON ...' at the same time.
|
||||||
|
sql, params = self._alter_column_comment_sql(
|
||||||
|
model, new_field, new_type, new_field.db_comment
|
||||||
|
)
|
||||||
|
if sql:
|
||||||
|
other_actions.append((sql, params))
|
||||||
|
if new_field.db_comment:
|
||||||
|
comment_sql = self._comment_sql(new_field.db_comment)
|
||||||
return (
|
return (
|
||||||
(
|
(
|
||||||
self.sql_alter_column_type
|
self.sql_alter_column_type
|
||||||
@ -1224,12 +1290,27 @@ class BaseDatabaseSchemaEditor:
|
|||||||
"column": self.quote_name(new_field.column),
|
"column": self.quote_name(new_field.column),
|
||||||
"type": new_type,
|
"type": new_type,
|
||||||
"collation": collate_sql,
|
"collation": collate_sql,
|
||||||
|
"comment": comment_sql,
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
|
other_actions,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _alter_column_comment_sql(self, model, new_field, new_type, new_db_comment):
|
||||||
|
return (
|
||||||
|
self.sql_alter_column_comment
|
||||||
|
% {
|
||||||
|
"table": self.quote_name(model._meta.db_table),
|
||||||
|
"column": self.quote_name(new_field.column),
|
||||||
|
"comment": self._comment_sql(new_db_comment),
|
||||||
|
},
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _comment_sql(self, comment):
|
||||||
|
return self.quote_value(comment or "")
|
||||||
|
|
||||||
def _alter_many_to_many(self, model, old_field, new_field, strict):
|
def _alter_many_to_many(self, model, old_field, new_field, strict):
|
||||||
"""Alter M2Ms to repoint their to= endpoints."""
|
"""Alter M2Ms to repoint their to= endpoints."""
|
||||||
# Rename the through table
|
# Rename the through table
|
||||||
@ -1423,16 +1504,18 @@ class BaseDatabaseSchemaEditor:
|
|||||||
output.append(self._create_index_sql(model, fields=[field]))
|
output.append(self._create_index_sql(model, fields=[field]))
|
||||||
return output
|
return output
|
||||||
|
|
||||||
def _field_should_be_altered(self, old_field, new_field):
|
def _field_should_be_altered(self, old_field, new_field, ignore=None):
|
||||||
|
ignore = ignore or set()
|
||||||
_, old_path, old_args, old_kwargs = old_field.deconstruct()
|
_, old_path, old_args, old_kwargs = old_field.deconstruct()
|
||||||
_, new_path, new_args, new_kwargs = new_field.deconstruct()
|
_, new_path, new_args, new_kwargs = new_field.deconstruct()
|
||||||
# Don't alter when:
|
# Don't alter when:
|
||||||
# - changing only a field name
|
# - changing only a field name
|
||||||
# - changing an attribute that doesn't affect the schema
|
# - changing an attribute that doesn't affect the schema
|
||||||
|
# - changing an attribute in the provided set of ignored attributes
|
||||||
# - adding only a db_column and the column name is not changed
|
# - adding only a db_column and the column name is not changed
|
||||||
for attr in old_field.non_db_attrs:
|
for attr in ignore.union(old_field.non_db_attrs):
|
||||||
old_kwargs.pop(attr, None)
|
old_kwargs.pop(attr, None)
|
||||||
for attr in new_field.non_db_attrs:
|
for attr in ignore.union(new_field.non_db_attrs):
|
||||||
new_kwargs.pop(attr, None)
|
new_kwargs.pop(attr, None)
|
||||||
return self.quote_name(old_field.column) != self.quote_name(
|
return self.quote_name(old_field.column) != self.quote_name(
|
||||||
new_field.column
|
new_field.column
|
||||||
|
@ -18,6 +18,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
|
|||||||
requires_explicit_null_ordering_when_grouping = True
|
requires_explicit_null_ordering_when_grouping = True
|
||||||
atomic_transactions = False
|
atomic_transactions = False
|
||||||
can_clone_databases = True
|
can_clone_databases = True
|
||||||
|
supports_comments = True
|
||||||
|
supports_comments_inline = True
|
||||||
supports_temporal_subtraction = True
|
supports_temporal_subtraction = True
|
||||||
supports_slicing_ordering_in_compound = True
|
supports_slicing_ordering_in_compound = True
|
||||||
supports_index_on_text_field = False
|
supports_index_on_text_field = False
|
||||||
|
@ -5,18 +5,20 @@ from MySQLdb.constants import FIELD_TYPE
|
|||||||
|
|
||||||
from django.db.backends.base.introspection import BaseDatabaseIntrospection
|
from django.db.backends.base.introspection import BaseDatabaseIntrospection
|
||||||
from django.db.backends.base.introspection import FieldInfo as BaseFieldInfo
|
from django.db.backends.base.introspection import FieldInfo as BaseFieldInfo
|
||||||
from django.db.backends.base.introspection import TableInfo
|
from django.db.backends.base.introspection import TableInfo as BaseTableInfo
|
||||||
from django.db.models import Index
|
from django.db.models import Index
|
||||||
from django.utils.datastructures import OrderedSet
|
from django.utils.datastructures import OrderedSet
|
||||||
|
|
||||||
FieldInfo = namedtuple(
|
FieldInfo = namedtuple(
|
||||||
"FieldInfo", BaseFieldInfo._fields + ("extra", "is_unsigned", "has_json_constraint")
|
"FieldInfo",
|
||||||
|
BaseFieldInfo._fields + ("extra", "is_unsigned", "has_json_constraint", "comment"),
|
||||||
)
|
)
|
||||||
InfoLine = namedtuple(
|
InfoLine = namedtuple(
|
||||||
"InfoLine",
|
"InfoLine",
|
||||||
"col_name data_type max_len num_prec num_scale extra column_default "
|
"col_name data_type max_len num_prec num_scale extra column_default "
|
||||||
"collation is_unsigned",
|
"collation is_unsigned comment",
|
||||||
)
|
)
|
||||||
|
TableInfo = namedtuple("TableInfo", BaseTableInfo._fields + ("comment",))
|
||||||
|
|
||||||
|
|
||||||
class DatabaseIntrospection(BaseDatabaseIntrospection):
|
class DatabaseIntrospection(BaseDatabaseIntrospection):
|
||||||
@ -68,9 +70,18 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
|
|||||||
|
|
||||||
def get_table_list(self, cursor):
|
def get_table_list(self, cursor):
|
||||||
"""Return a list of table and view names in the current database."""
|
"""Return a list of table and view names in the current database."""
|
||||||
cursor.execute("SHOW FULL TABLES")
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
table_name,
|
||||||
|
table_type,
|
||||||
|
table_comment
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
"""
|
||||||
|
)
|
||||||
return [
|
return [
|
||||||
TableInfo(row[0], {"BASE TABLE": "t", "VIEW": "v"}.get(row[1]))
|
TableInfo(row[0], {"BASE TABLE": "t", "VIEW": "v"}.get(row[1]), row[2])
|
||||||
for row in cursor.fetchall()
|
for row in cursor.fetchall()
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -128,7 +139,8 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
|
|||||||
CASE
|
CASE
|
||||||
WHEN column_type LIKE '%% unsigned' THEN 1
|
WHEN column_type LIKE '%% unsigned' THEN 1
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END AS is_unsigned
|
END AS is_unsigned,
|
||||||
|
column_comment
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_name = %s AND table_schema = DATABASE()
|
WHERE table_name = %s AND table_schema = DATABASE()
|
||||||
""",
|
""",
|
||||||
@ -159,6 +171,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
|
|||||||
info.extra,
|
info.extra,
|
||||||
info.is_unsigned,
|
info.is_unsigned,
|
||||||
line[0] in json_constraints,
|
line[0] in json_constraints,
|
||||||
|
info.comment,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return fields
|
return fields
|
||||||
|
@ -9,7 +9,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
|
|||||||
|
|
||||||
sql_alter_column_null = "MODIFY %(column)s %(type)s NULL"
|
sql_alter_column_null = "MODIFY %(column)s %(type)s NULL"
|
||||||
sql_alter_column_not_null = "MODIFY %(column)s %(type)s NOT NULL"
|
sql_alter_column_not_null = "MODIFY %(column)s %(type)s NOT NULL"
|
||||||
sql_alter_column_type = "MODIFY %(column)s %(type)s%(collation)s"
|
sql_alter_column_type = "MODIFY %(column)s %(type)s%(collation)s%(comment)s"
|
||||||
sql_alter_column_no_default_null = "ALTER COLUMN %(column)s SET DEFAULT NULL"
|
sql_alter_column_no_default_null = "ALTER COLUMN %(column)s SET DEFAULT NULL"
|
||||||
|
|
||||||
# No 'CASCADE' which works as a no-op in MySQL but is undocumented
|
# No 'CASCADE' which works as a no-op in MySQL but is undocumented
|
||||||
@ -32,6 +32,9 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
|
|||||||
|
|
||||||
sql_create_index = "CREATE INDEX %(name)s ON %(table)s (%(columns)s)%(extra)s"
|
sql_create_index = "CREATE INDEX %(name)s ON %(table)s (%(columns)s)%(extra)s"
|
||||||
|
|
||||||
|
sql_alter_table_comment = "ALTER TABLE %(table)s COMMENT = %(comment)s"
|
||||||
|
sql_alter_column_comment = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sql_delete_check(self):
|
def sql_delete_check(self):
|
||||||
if self.connection.mysql_is_mariadb:
|
if self.connection.mysql_is_mariadb:
|
||||||
@ -228,3 +231,11 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
|
|||||||
def _rename_field_sql(self, table, old_field, new_field, new_type):
|
def _rename_field_sql(self, table, old_field, new_field, new_type):
|
||||||
new_type = self._set_field_new_type_null_status(old_field, new_type)
|
new_type = self._set_field_new_type_null_status(old_field, new_type)
|
||||||
return super()._rename_field_sql(table, old_field, new_field, new_type)
|
return super()._rename_field_sql(table, old_field, new_field, new_type)
|
||||||
|
|
||||||
|
def _alter_column_comment_sql(self, model, new_field, new_type, new_db_comment):
|
||||||
|
# Comment is alter when altering the column type.
|
||||||
|
return "", []
|
||||||
|
|
||||||
|
def _comment_sql(self, comment):
|
||||||
|
comment_sql = super()._comment_sql(comment)
|
||||||
|
return f" COMMENT {comment_sql}"
|
||||||
|
@ -25,6 +25,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
|
|||||||
supports_partially_nullable_unique_constraints = False
|
supports_partially_nullable_unique_constraints = False
|
||||||
supports_deferrable_unique_constraints = True
|
supports_deferrable_unique_constraints = True
|
||||||
truncates_names = True
|
truncates_names = True
|
||||||
|
supports_comments = True
|
||||||
supports_tablespaces = True
|
supports_tablespaces = True
|
||||||
supports_sequence_reset = False
|
supports_sequence_reset = False
|
||||||
can_introspect_materialized_views = True
|
can_introspect_materialized_views = True
|
||||||
|
@ -5,10 +5,13 @@ import cx_Oracle
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.backends.base.introspection import BaseDatabaseIntrospection
|
from django.db.backends.base.introspection import BaseDatabaseIntrospection
|
||||||
from django.db.backends.base.introspection import FieldInfo as BaseFieldInfo
|
from django.db.backends.base.introspection import FieldInfo as BaseFieldInfo
|
||||||
from django.db.backends.base.introspection import TableInfo
|
from django.db.backends.base.introspection import TableInfo as BaseTableInfo
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
|
|
||||||
FieldInfo = namedtuple("FieldInfo", BaseFieldInfo._fields + ("is_autofield", "is_json"))
|
FieldInfo = namedtuple(
|
||||||
|
"FieldInfo", BaseFieldInfo._fields + ("is_autofield", "is_json", "comment")
|
||||||
|
)
|
||||||
|
TableInfo = namedtuple("TableInfo", BaseTableInfo._fields + ("comment",))
|
||||||
|
|
||||||
|
|
||||||
class DatabaseIntrospection(BaseDatabaseIntrospection):
|
class DatabaseIntrospection(BaseDatabaseIntrospection):
|
||||||
@ -77,8 +80,14 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
|
|||||||
"""Return a list of table and view names in the current database."""
|
"""Return a list of table and view names in the current database."""
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
SELECT table_name, 't'
|
SELECT
|
||||||
|
user_tables.table_name,
|
||||||
|
't',
|
||||||
|
user_tab_comments.comments
|
||||||
FROM user_tables
|
FROM user_tables
|
||||||
|
LEFT OUTER JOIN
|
||||||
|
user_tab_comments
|
||||||
|
ON user_tab_comments.table_name = user_tables.table_name
|
||||||
WHERE
|
WHERE
|
||||||
NOT EXISTS (
|
NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@ -86,13 +95,13 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
|
|||||||
WHERE user_mviews.mview_name = user_tables.table_name
|
WHERE user_mviews.mview_name = user_tables.table_name
|
||||||
)
|
)
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT view_name, 'v' FROM user_views
|
SELECT view_name, 'v', NULL FROM user_views
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT mview_name, 'v' FROM user_mviews
|
SELECT mview_name, 'v', NULL FROM user_mviews
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
TableInfo(self.identifier_converter(row[0]), row[1])
|
TableInfo(self.identifier_converter(row[0]), row[1], row[2])
|
||||||
for row in cursor.fetchall()
|
for row in cursor.fetchall()
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -131,10 +140,15 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
|
|||||||
)
|
)
|
||||||
THEN 1
|
THEN 1
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END as is_json
|
END as is_json,
|
||||||
|
user_col_comments.comments as col_comment
|
||||||
FROM user_tab_cols
|
FROM user_tab_cols
|
||||||
LEFT OUTER JOIN
|
LEFT OUTER JOIN
|
||||||
user_tables ON user_tables.table_name = user_tab_cols.table_name
|
user_tables ON user_tables.table_name = user_tab_cols.table_name
|
||||||
|
LEFT OUTER JOIN
|
||||||
|
user_col_comments ON
|
||||||
|
user_col_comments.column_name = user_tab_cols.column_name AND
|
||||||
|
user_col_comments.table_name = user_tab_cols.table_name
|
||||||
WHERE user_tab_cols.table_name = UPPER(%s)
|
WHERE user_tab_cols.table_name = UPPER(%s)
|
||||||
""",
|
""",
|
||||||
[table_name],
|
[table_name],
|
||||||
@ -146,6 +160,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
|
|||||||
collation,
|
collation,
|
||||||
is_autofield,
|
is_autofield,
|
||||||
is_json,
|
is_json,
|
||||||
|
comment,
|
||||||
)
|
)
|
||||||
for (
|
for (
|
||||||
column,
|
column,
|
||||||
@ -154,6 +169,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
|
|||||||
display_size,
|
display_size,
|
||||||
is_autofield,
|
is_autofield,
|
||||||
is_json,
|
is_json,
|
||||||
|
comment,
|
||||||
) in cursor.fetchall()
|
) in cursor.fetchall()
|
||||||
}
|
}
|
||||||
self.cache_bust_counter += 1
|
self.cache_bust_counter += 1
|
||||||
@ -165,7 +181,14 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
|
|||||||
description = []
|
description = []
|
||||||
for desc in cursor.description:
|
for desc in cursor.description:
|
||||||
name = desc[0]
|
name = desc[0]
|
||||||
display_size, default, collation, is_autofield, is_json = field_map[name]
|
(
|
||||||
|
display_size,
|
||||||
|
default,
|
||||||
|
collation,
|
||||||
|
is_autofield,
|
||||||
|
is_json,
|
||||||
|
comment,
|
||||||
|
) = field_map[name]
|
||||||
name %= {} # cx_Oracle, for some reason, doubles percent signs.
|
name %= {} # cx_Oracle, for some reason, doubles percent signs.
|
||||||
description.append(
|
description.append(
|
||||||
FieldInfo(
|
FieldInfo(
|
||||||
@ -180,6 +203,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
|
|||||||
collation,
|
collation,
|
||||||
is_autofield,
|
is_autofield,
|
||||||
is_json,
|
is_json,
|
||||||
|
comment,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return description
|
return description
|
||||||
|
@ -22,6 +22,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
|
|||||||
has_select_for_update_skip_locked = True
|
has_select_for_update_skip_locked = True
|
||||||
has_select_for_no_key_update = True
|
has_select_for_no_key_update = True
|
||||||
can_release_savepoints = True
|
can_release_savepoints = True
|
||||||
|
supports_comments = True
|
||||||
supports_tablespaces = True
|
supports_tablespaces = True
|
||||||
supports_transactions = True
|
supports_transactions = True
|
||||||
can_introspect_materialized_views = True
|
can_introspect_materialized_views = True
|
||||||
|
@ -2,10 +2,11 @@ from collections import namedtuple
|
|||||||
|
|
||||||
from django.db.backends.base.introspection import BaseDatabaseIntrospection
|
from django.db.backends.base.introspection import BaseDatabaseIntrospection
|
||||||
from django.db.backends.base.introspection import FieldInfo as BaseFieldInfo
|
from django.db.backends.base.introspection import FieldInfo as BaseFieldInfo
|
||||||
from django.db.backends.base.introspection import TableInfo
|
from django.db.backends.base.introspection import TableInfo as BaseTableInfo
|
||||||
from django.db.models import Index
|
from django.db.models import Index
|
||||||
|
|
||||||
FieldInfo = namedtuple("FieldInfo", BaseFieldInfo._fields + ("is_autofield",))
|
FieldInfo = namedtuple("FieldInfo", BaseFieldInfo._fields + ("is_autofield", "comment"))
|
||||||
|
TableInfo = namedtuple("TableInfo", BaseTableInfo._fields + ("comment",))
|
||||||
|
|
||||||
|
|
||||||
class DatabaseIntrospection(BaseDatabaseIntrospection):
|
class DatabaseIntrospection(BaseDatabaseIntrospection):
|
||||||
@ -62,7 +63,8 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
|
|||||||
WHEN c.relispartition THEN 'p'
|
WHEN c.relispartition THEN 'p'
|
||||||
WHEN c.relkind IN ('m', 'v') THEN 'v'
|
WHEN c.relkind IN ('m', 'v') THEN 'v'
|
||||||
ELSE 't'
|
ELSE 't'
|
||||||
END
|
END,
|
||||||
|
obj_description(c.oid)
|
||||||
FROM pg_catalog.pg_class c
|
FROM pg_catalog.pg_class c
|
||||||
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||||
WHERE c.relkind IN ('f', 'm', 'p', 'r', 'v')
|
WHERE c.relkind IN ('f', 'm', 'p', 'r', 'v')
|
||||||
@ -91,7 +93,8 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
|
|||||||
NOT (a.attnotnull OR (t.typtype = 'd' AND t.typnotnull)) AS is_nullable,
|
NOT (a.attnotnull OR (t.typtype = 'd' AND t.typnotnull)) AS is_nullable,
|
||||||
pg_get_expr(ad.adbin, ad.adrelid) AS column_default,
|
pg_get_expr(ad.adbin, ad.adrelid) AS column_default,
|
||||||
CASE WHEN collname = 'default' THEN NULL ELSE collname END AS collation,
|
CASE WHEN collname = 'default' THEN NULL ELSE collname END AS collation,
|
||||||
a.attidentity != '' AS is_autofield
|
a.attidentity != '' AS is_autofield,
|
||||||
|
col_description(a.attrelid, a.attnum) AS column_comment
|
||||||
FROM pg_attribute a
|
FROM pg_attribute a
|
||||||
LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
|
LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
|
||||||
LEFT JOIN pg_collation co ON a.attcollation = co.oid
|
LEFT JOIN pg_collation co ON a.attcollation = co.oid
|
||||||
|
@ -170,6 +170,7 @@ class MigrationAutodetector:
|
|||||||
self.generate_created_proxies()
|
self.generate_created_proxies()
|
||||||
self.generate_altered_options()
|
self.generate_altered_options()
|
||||||
self.generate_altered_managers()
|
self.generate_altered_managers()
|
||||||
|
self.generate_altered_db_table_comment()
|
||||||
|
|
||||||
# Create the renamed fields and store them in self.renamed_fields.
|
# Create the renamed fields and store them in self.renamed_fields.
|
||||||
# They are used by create_altered_indexes(), generate_altered_fields(),
|
# They are used by create_altered_indexes(), generate_altered_fields(),
|
||||||
@ -1552,6 +1553,28 @@ class MigrationAutodetector:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def generate_altered_db_table_comment(self):
|
||||||
|
models_to_check = self.kept_model_keys.union(
|
||||||
|
self.kept_proxy_keys, self.kept_unmanaged_keys
|
||||||
|
)
|
||||||
|
for app_label, model_name in sorted(models_to_check):
|
||||||
|
old_model_name = self.renamed_models.get(
|
||||||
|
(app_label, model_name), model_name
|
||||||
|
)
|
||||||
|
old_model_state = self.from_state.models[app_label, old_model_name]
|
||||||
|
new_model_state = self.to_state.models[app_label, model_name]
|
||||||
|
|
||||||
|
old_db_table_comment = old_model_state.options.get("db_table_comment")
|
||||||
|
new_db_table_comment = new_model_state.options.get("db_table_comment")
|
||||||
|
if old_db_table_comment != new_db_table_comment:
|
||||||
|
self.add_operation(
|
||||||
|
app_label,
|
||||||
|
operations.AlterModelTableComment(
|
||||||
|
name=model_name,
|
||||||
|
table_comment=new_db_table_comment,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def generate_altered_options(self):
|
def generate_altered_options(self):
|
||||||
"""
|
"""
|
||||||
Work out if any non-schema-affecting options have changed and make an
|
Work out if any non-schema-affecting options have changed and make an
|
||||||
|
@ -6,6 +6,7 @@ from .models import (
|
|||||||
AlterModelManagers,
|
AlterModelManagers,
|
||||||
AlterModelOptions,
|
AlterModelOptions,
|
||||||
AlterModelTable,
|
AlterModelTable,
|
||||||
|
AlterModelTableComment,
|
||||||
AlterOrderWithRespectTo,
|
AlterOrderWithRespectTo,
|
||||||
AlterUniqueTogether,
|
AlterUniqueTogether,
|
||||||
CreateModel,
|
CreateModel,
|
||||||
@ -21,6 +22,7 @@ __all__ = [
|
|||||||
"CreateModel",
|
"CreateModel",
|
||||||
"DeleteModel",
|
"DeleteModel",
|
||||||
"AlterModelTable",
|
"AlterModelTable",
|
||||||
|
"AlterModelTableComment",
|
||||||
"AlterUniqueTogether",
|
"AlterUniqueTogether",
|
||||||
"RenameModel",
|
"RenameModel",
|
||||||
"AlterIndexTogether",
|
"AlterIndexTogether",
|
||||||
|
@ -529,6 +529,44 @@ class AlterModelTable(ModelOptionOperation):
|
|||||||
return "alter_%s_table" % self.name_lower
|
return "alter_%s_table" % self.name_lower
|
||||||
|
|
||||||
|
|
||||||
|
class AlterModelTableComment(ModelOptionOperation):
|
||||||
|
def __init__(self, name, table_comment):
|
||||||
|
self.table_comment = table_comment
|
||||||
|
super().__init__(name)
|
||||||
|
|
||||||
|
def deconstruct(self):
|
||||||
|
kwargs = {
|
||||||
|
"name": self.name,
|
||||||
|
"table_comment": self.table_comment,
|
||||||
|
}
|
||||||
|
return (self.__class__.__qualname__, [], kwargs)
|
||||||
|
|
||||||
|
def state_forwards(self, app_label, state):
|
||||||
|
state.alter_model_options(
|
||||||
|
app_label, self.name_lower, {"db_table_comment": self.table_comment}
|
||||||
|
)
|
||||||
|
|
||||||
|
def database_forwards(self, app_label, schema_editor, from_state, to_state):
|
||||||
|
new_model = to_state.apps.get_model(app_label, self.name)
|
||||||
|
if self.allow_migrate_model(schema_editor.connection.alias, new_model):
|
||||||
|
old_model = from_state.apps.get_model(app_label, self.name)
|
||||||
|
schema_editor.alter_db_table_comment(
|
||||||
|
new_model,
|
||||||
|
old_model._meta.db_table_comment,
|
||||||
|
new_model._meta.db_table_comment,
|
||||||
|
)
|
||||||
|
|
||||||
|
def database_backwards(self, app_label, schema_editor, from_state, to_state):
|
||||||
|
return self.database_forwards(app_label, schema_editor, from_state, to_state)
|
||||||
|
|
||||||
|
def describe(self):
|
||||||
|
return f"Alter {self.name} table comment"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def migration_name_fragment(self):
|
||||||
|
return f"alter_{self.name_lower}_table_comment"
|
||||||
|
|
||||||
|
|
||||||
class AlterTogetherOptionOperation(ModelOptionOperation):
|
class AlterTogetherOptionOperation(ModelOptionOperation):
|
||||||
option_name = None
|
option_name = None
|
||||||
|
|
||||||
|
@ -1556,6 +1556,7 @@ class Model(AltersData, metaclass=ModelBase):
|
|||||||
*cls._check_ordering(),
|
*cls._check_ordering(),
|
||||||
*cls._check_constraints(databases),
|
*cls._check_constraints(databases),
|
||||||
*cls._check_default_pk(),
|
*cls._check_default_pk(),
|
||||||
|
*cls._check_db_table_comment(databases),
|
||||||
]
|
]
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
@ -1592,6 +1593,29 @@ class Model(AltersData, metaclass=ModelBase):
|
|||||||
]
|
]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _check_db_table_comment(cls, databases):
|
||||||
|
if not cls._meta.db_table_comment:
|
||||||
|
return []
|
||||||
|
errors = []
|
||||||
|
for db in databases:
|
||||||
|
if not router.allow_migrate_model(db, cls):
|
||||||
|
continue
|
||||||
|
connection = connections[db]
|
||||||
|
if not (
|
||||||
|
connection.features.supports_comments
|
||||||
|
or "supports_comments" in cls._meta.required_db_features
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
checks.Warning(
|
||||||
|
f"{connection.display_name} does not support comments on "
|
||||||
|
f"tables (db_table_comment).",
|
||||||
|
obj=cls,
|
||||||
|
id="models.W046",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return errors
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _check_swappable(cls):
|
def _check_swappable(cls):
|
||||||
"""Check if the swapped model exists."""
|
"""Check if the swapped model exists."""
|
||||||
|
@ -200,6 +200,7 @@ class Field(RegisterLookupMixin):
|
|||||||
auto_created=False,
|
auto_created=False,
|
||||||
validators=(),
|
validators=(),
|
||||||
error_messages=None,
|
error_messages=None,
|
||||||
|
db_comment=None,
|
||||||
):
|
):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.verbose_name = verbose_name # May be set by set_attributes_from_name
|
self.verbose_name = verbose_name # May be set by set_attributes_from_name
|
||||||
@ -221,6 +222,7 @@ class Field(RegisterLookupMixin):
|
|||||||
self.help_text = help_text
|
self.help_text = help_text
|
||||||
self.db_index = db_index
|
self.db_index = db_index
|
||||||
self.db_column = db_column
|
self.db_column = db_column
|
||||||
|
self.db_comment = db_comment
|
||||||
self._db_tablespace = db_tablespace
|
self._db_tablespace = db_tablespace
|
||||||
self.auto_created = auto_created
|
self.auto_created = auto_created
|
||||||
|
|
||||||
@ -259,6 +261,7 @@ class Field(RegisterLookupMixin):
|
|||||||
*self._check_field_name(),
|
*self._check_field_name(),
|
||||||
*self._check_choices(),
|
*self._check_choices(),
|
||||||
*self._check_db_index(),
|
*self._check_db_index(),
|
||||||
|
*self._check_db_comment(**kwargs),
|
||||||
*self._check_null_allowed_for_primary_keys(),
|
*self._check_null_allowed_for_primary_keys(),
|
||||||
*self._check_backend_specific_checks(**kwargs),
|
*self._check_backend_specific_checks(**kwargs),
|
||||||
*self._check_validators(),
|
*self._check_validators(),
|
||||||
@ -385,6 +388,28 @@ class Field(RegisterLookupMixin):
|
|||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def _check_db_comment(self, databases=None, **kwargs):
|
||||||
|
if not self.db_comment or not databases:
|
||||||
|
return []
|
||||||
|
errors = []
|
||||||
|
for db in databases:
|
||||||
|
if not router.allow_migrate_model(db, self.model):
|
||||||
|
continue
|
||||||
|
connection = connections[db]
|
||||||
|
if not (
|
||||||
|
connection.features.supports_comments
|
||||||
|
or "supports_comments" in self.model._meta.required_db_features
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
checks.Warning(
|
||||||
|
f"{connection.display_name} does not support comments on "
|
||||||
|
f"columns (db_comment).",
|
||||||
|
obj=self,
|
||||||
|
id="fields.W163",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return errors
|
||||||
|
|
||||||
def _check_null_allowed_for_primary_keys(self):
|
def _check_null_allowed_for_primary_keys(self):
|
||||||
if (
|
if (
|
||||||
self.primary_key
|
self.primary_key
|
||||||
@ -538,6 +563,7 @@ class Field(RegisterLookupMixin):
|
|||||||
"choices": None,
|
"choices": None,
|
||||||
"help_text": "",
|
"help_text": "",
|
||||||
"db_column": None,
|
"db_column": None,
|
||||||
|
"db_comment": None,
|
||||||
"db_tablespace": None,
|
"db_tablespace": None,
|
||||||
"auto_created": False,
|
"auto_created": False,
|
||||||
"validators": [],
|
"validators": [],
|
||||||
|
@ -1428,6 +1428,14 @@ class ManyToManyField(RelatedField):
|
|||||||
id="fields.W345",
|
id="fields.W345",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if self.db_comment:
|
||||||
|
warnings.append(
|
||||||
|
checks.Warning(
|
||||||
|
"db_comment has no effect on ManyToManyField.",
|
||||||
|
obj=self,
|
||||||
|
id="fields.W346",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return warnings
|
return warnings
|
||||||
|
|
||||||
|
@ -30,6 +30,7 @@ DEFAULT_NAMES = (
|
|||||||
"verbose_name",
|
"verbose_name",
|
||||||
"verbose_name_plural",
|
"verbose_name_plural",
|
||||||
"db_table",
|
"db_table",
|
||||||
|
"db_table_comment",
|
||||||
"ordering",
|
"ordering",
|
||||||
"unique_together",
|
"unique_together",
|
||||||
"permissions",
|
"permissions",
|
||||||
@ -112,6 +113,7 @@ class Options:
|
|||||||
self.verbose_name = None
|
self.verbose_name = None
|
||||||
self.verbose_name_plural = None
|
self.verbose_name_plural = None
|
||||||
self.db_table = ""
|
self.db_table = ""
|
||||||
|
self.db_table_comment = ""
|
||||||
self.ordering = []
|
self.ordering = []
|
||||||
self._ordering_clash = False
|
self._ordering_clash = False
|
||||||
self.indexes = []
|
self.indexes = []
|
||||||
|
@ -196,6 +196,8 @@ Model fields
|
|||||||
* **fields.W161**: Fixed default value provided.
|
* **fields.W161**: Fixed default value provided.
|
||||||
* **fields.W162**: ``<database>`` does not support a database index on
|
* **fields.W162**: ``<database>`` does not support a database index on
|
||||||
``<field data type>`` columns.
|
``<field data type>`` columns.
|
||||||
|
* **fields.W163**: ``<database>`` does not support comments on columns
|
||||||
|
(``db_comment``).
|
||||||
* **fields.E170**: ``BinaryField``’s ``default`` cannot be a string. Use bytes
|
* **fields.E170**: ``BinaryField``’s ``default`` cannot be a string. Use bytes
|
||||||
content instead.
|
content instead.
|
||||||
* **fields.E180**: ``<database>`` does not support ``JSONField``\s.
|
* **fields.E180**: ``<database>`` does not support ``JSONField``\s.
|
||||||
@ -315,6 +317,7 @@ Related fields
|
|||||||
the table name of ``<model>``/``<model>.<field name>``.
|
the table name of ``<model>``/``<model>.<field name>``.
|
||||||
* **fields.W345**: ``related_name`` has no effect on ``ManyToManyField`` with a
|
* **fields.W345**: ``related_name`` has no effect on ``ManyToManyField`` with a
|
||||||
symmetrical relationship, e.g. to "self".
|
symmetrical relationship, e.g. to "self".
|
||||||
|
* **fields.W346**: ``db_comment`` has no effect on ``ManyToManyField``.
|
||||||
|
|
||||||
Models
|
Models
|
||||||
------
|
------
|
||||||
@ -400,6 +403,8 @@ Models
|
|||||||
expressions.
|
expressions.
|
||||||
* **models.W045**: Check constraint ``<constraint>`` contains ``RawSQL()``
|
* **models.W045**: Check constraint ``<constraint>`` contains ``RawSQL()``
|
||||||
expression and won't be validated during the model ``full_clean()``.
|
expression and won't be validated during the model ``full_clean()``.
|
||||||
|
* **models.W046**: ``<database>`` does not support comments on tables
|
||||||
|
(``db_table_comment``).
|
||||||
|
|
||||||
Security
|
Security
|
||||||
--------
|
--------
|
||||||
|
@ -88,6 +88,17 @@ lose any data in the old table.
|
|||||||
Changes the model's table name (the :attr:`~django.db.models.Options.db_table`
|
Changes the model's table name (the :attr:`~django.db.models.Options.db_table`
|
||||||
option on the ``Meta`` subclass).
|
option on the ``Meta`` subclass).
|
||||||
|
|
||||||
|
``AlterModelTableComment``
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
.. versionadded:: 4.2
|
||||||
|
|
||||||
|
.. class:: AlterModelTableComment(name, table_comment)
|
||||||
|
|
||||||
|
Changes the model's table comment (the
|
||||||
|
:attr:`~django.db.models.Options.db_table_comment` option on the ``Meta``
|
||||||
|
subclass).
|
||||||
|
|
||||||
``AlterUniqueTogether``
|
``AlterUniqueTogether``
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|
||||||
|
@ -325,6 +325,21 @@ characters that aren't allowed in Python variable names -- notably, the
|
|||||||
hyphen -- that's OK. Django quotes column and table names behind the
|
hyphen -- that's OK. Django quotes column and table names behind the
|
||||||
scenes.
|
scenes.
|
||||||
|
|
||||||
|
``db_comment``
|
||||||
|
--------------
|
||||||
|
|
||||||
|
.. versionadded:: 4.2
|
||||||
|
|
||||||
|
.. attribute:: Field.db_comment
|
||||||
|
|
||||||
|
The comment on the database column to use for this field. It is useful for
|
||||||
|
documenting fields for individuals with direct database access who may not be
|
||||||
|
looking at your Django code. For example::
|
||||||
|
|
||||||
|
pub_date = models.DateTimeField(
|
||||||
|
db_comment="Date and time when the article was published",
|
||||||
|
)
|
||||||
|
|
||||||
``db_index``
|
``db_index``
|
||||||
------------
|
------------
|
||||||
|
|
||||||
|
@ -91,6 +91,24 @@ Django quotes column and table names behind the scenes.
|
|||||||
backends; except for Oracle, however, the quotes have no effect. See the
|
backends; except for Oracle, however, the quotes have no effect. See the
|
||||||
:ref:`Oracle notes <oracle-notes>` for more details.
|
:ref:`Oracle notes <oracle-notes>` for more details.
|
||||||
|
|
||||||
|
``db_table_comment``
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
.. versionadded:: 4.2
|
||||||
|
|
||||||
|
.. attribute:: Options.db_table_comment
|
||||||
|
|
||||||
|
The comment on the database table to use for this model. It is useful for
|
||||||
|
documenting database tables for individuals with direct database access who may
|
||||||
|
not be looking at your Django code. For example::
|
||||||
|
|
||||||
|
class Answer(models.Model):
|
||||||
|
question = models.ForeignKey(Question, on_delete=models.CASCADE)
|
||||||
|
answer = models.TextField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table_comment = "Question answers"
|
||||||
|
|
||||||
``db_tablespace``
|
``db_tablespace``
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
|
@ -127,6 +127,15 @@ value.
|
|||||||
|
|
||||||
Renames the model's table from ``old_db_table`` to ``new_db_table``.
|
Renames the model's table from ``old_db_table`` to ``new_db_table``.
|
||||||
|
|
||||||
|
``alter_db_table_comment()``
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
.. versionadded:: 4.2
|
||||||
|
|
||||||
|
.. method:: BaseDatabaseSchemaEditor.alter_db_table_comment(model, old_db_table_comment, new_db_table_comment)
|
||||||
|
|
||||||
|
Change the ``model``’s table comment to ``new_db_table_comment``.
|
||||||
|
|
||||||
``alter_db_tablespace()``
|
``alter_db_tablespace()``
|
||||||
-------------------------
|
-------------------------
|
||||||
|
|
||||||
|
@ -40,6 +40,41 @@ in the future.
|
|||||||
.. _psycopg: https://www.psycopg.org/psycopg3/
|
.. _psycopg: https://www.psycopg.org/psycopg3/
|
||||||
.. _psycopg library: https://pypi.org/project/psycopg/
|
.. _psycopg library: https://pypi.org/project/psycopg/
|
||||||
|
|
||||||
|
Comments on columns and tables
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
The new :attr:`Field.db_comment <django.db.models.Field.db_comment>` and
|
||||||
|
:attr:`Meta.db_table_comment <django.db.models.Options.db_table_comment>`
|
||||||
|
options allow creating comments on columns and tables, respectively. For
|
||||||
|
example::
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
class Question(models.Model):
|
||||||
|
text = models.TextField(db_comment="Poll question")
|
||||||
|
pub_date = models.DateTimeField(
|
||||||
|
db_comment="Date and time when the question was published",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table_comment = "Poll questions"
|
||||||
|
|
||||||
|
|
||||||
|
class Answer(models.Model):
|
||||||
|
question = models.ForeignKey(
|
||||||
|
Question,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
db_comment="Reference to a question"
|
||||||
|
)
|
||||||
|
answer = models.TextField(db_comment="Question answer")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table_comment = "Question answers"
|
||||||
|
|
||||||
|
Also, the new :class:`~django.db.migrations.operations.AlterModelTableComment`
|
||||||
|
operation allows changing table comments defined in the
|
||||||
|
:attr:`Meta.db_table_comment <django.db.models.Options.db_table_comment>`.
|
||||||
|
|
||||||
Mitigation for the BREACH attack
|
Mitigation for the BREACH attack
|
||||||
--------------------------------
|
--------------------------------
|
||||||
|
|
||||||
|
@ -132,3 +132,11 @@ class FuncUniqueConstraint(models.Model):
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
required_db_features = {"supports_expression_indexes"}
|
required_db_features = {"supports_expression_indexes"}
|
||||||
|
|
||||||
|
|
||||||
|
class DbComment(models.Model):
|
||||||
|
rank = models.IntegerField(db_comment="'Rank' column comment")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table_comment = "Custom table comment"
|
||||||
|
required_db_features = {"supports_comments"}
|
||||||
|
@ -129,6 +129,24 @@ class InspectDBTestCase(TestCase):
|
|||||||
"null_json_field = models.JSONField(blank=True, null=True)", output
|
"null_json_field = models.JSONField(blank=True, null=True)", output
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@skipUnlessDBFeature("supports_comments")
|
||||||
|
def test_db_comments(self):
|
||||||
|
out = StringIO()
|
||||||
|
call_command("inspectdb", "inspectdb_dbcomment", stdout=out)
|
||||||
|
output = out.getvalue()
|
||||||
|
integer_field_type = connection.features.introspected_field_types[
|
||||||
|
"IntegerField"
|
||||||
|
]
|
||||||
|
self.assertIn(
|
||||||
|
f"rank = models.{integer_field_type}("
|
||||||
|
f"db_comment=\"'Rank' column comment\")",
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
" db_table_comment = 'Custom table comment'",
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
@skipUnlessDBFeature("supports_collation_on_charfield")
|
@skipUnlessDBFeature("supports_collation_on_charfield")
|
||||||
@skipUnless(test_collation, "Language collations are not supported.")
|
@skipUnless(test_collation, "Language collations are not supported.")
|
||||||
def test_char_field_db_collation(self):
|
def test_char_field_db_collation(self):
|
||||||
|
@ -102,3 +102,11 @@ class UniqueConstraintConditionModel(models.Model):
|
|||||||
condition=models.Q(color__isnull=True),
|
condition=models.Q(color__isnull=True),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DbCommentModel(models.Model):
|
||||||
|
name = models.CharField(max_length=15, db_comment="'Name' column comment")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table_comment = "Custom table comment"
|
||||||
|
required_db_features = {"supports_comments"}
|
||||||
|
@ -9,6 +9,7 @@ from .models import (
|
|||||||
City,
|
City,
|
||||||
Comment,
|
Comment,
|
||||||
Country,
|
Country,
|
||||||
|
DbCommentModel,
|
||||||
District,
|
District,
|
||||||
Reporter,
|
Reporter,
|
||||||
UniqueConstraintConditionModel,
|
UniqueConstraintConditionModel,
|
||||||
@ -179,6 +180,26 @@ class IntrospectionTests(TransactionTestCase):
|
|||||||
[connection.introspection.get_field_type(r[1], r) for r in desc],
|
[connection.introspection.get_field_type(r[1], r) for r in desc],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@skipUnlessDBFeature("supports_comments")
|
||||||
|
def test_db_comments(self):
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
desc = connection.introspection.get_table_description(
|
||||||
|
cursor, DbCommentModel._meta.db_table
|
||||||
|
)
|
||||||
|
table_list = connection.introspection.get_table_list(cursor)
|
||||||
|
self.assertEqual(
|
||||||
|
["'Name' column comment"],
|
||||||
|
[field.comment for field in desc if field.name == "name"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["Custom table comment"],
|
||||||
|
[
|
||||||
|
table.comment
|
||||||
|
for table in table_list
|
||||||
|
if table.name == "introspection_dbcommentmodel"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
# Regression test for #9991 - 'real' types in postgres
|
# Regression test for #9991 - 'real' types in postgres
|
||||||
@skipUnlessDBFeature("has_real_datatype")
|
@skipUnlessDBFeature("has_real_datatype")
|
||||||
def test_postgresql_real_type(self):
|
def test_postgresql_real_type(self):
|
||||||
|
@ -1872,6 +1872,37 @@ class OtherModelTests(SimpleTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@isolate_apps("invalid_models_tests")
|
||||||
|
class DbTableCommentTests(TestCase):
|
||||||
|
def test_db_table_comment(self):
|
||||||
|
class Model(models.Model):
|
||||||
|
class Meta:
|
||||||
|
db_table_comment = "Table comment"
|
||||||
|
|
||||||
|
errors = Model.check(databases=self.databases)
|
||||||
|
expected = (
|
||||||
|
[]
|
||||||
|
if connection.features.supports_comments
|
||||||
|
else [
|
||||||
|
Warning(
|
||||||
|
f"{connection.display_name} does not support comments on tables "
|
||||||
|
f"(db_table_comment).",
|
||||||
|
obj=Model,
|
||||||
|
id="models.W046",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertEqual(errors, expected)
|
||||||
|
|
||||||
|
def test_db_table_comment_required_db_features(self):
|
||||||
|
class Model(models.Model):
|
||||||
|
class Meta:
|
||||||
|
db_table_comment = "Table comment"
|
||||||
|
required_db_features = {"supports_comments"}
|
||||||
|
|
||||||
|
self.assertEqual(Model.check(databases=self.databases), [])
|
||||||
|
|
||||||
|
|
||||||
class MultipleAutoFieldsTests(TestCase):
|
class MultipleAutoFieldsTests(TestCase):
|
||||||
def test_multiple_autofields(self):
|
def test_multiple_autofields(self):
|
||||||
msg = (
|
msg = (
|
||||||
|
@ -1023,3 +1023,35 @@ class JSONFieldTests(TestCase):
|
|||||||
field = models.JSONField(default=callable_default)
|
field = models.JSONField(default=callable_default)
|
||||||
|
|
||||||
self.assertEqual(Model._meta.get_field("field").check(), [])
|
self.assertEqual(Model._meta.get_field("field").check(), [])
|
||||||
|
|
||||||
|
|
||||||
|
@isolate_apps("invalid_models_tests")
|
||||||
|
class DbCommentTests(TestCase):
|
||||||
|
def test_db_comment(self):
|
||||||
|
class Model(models.Model):
|
||||||
|
field = models.IntegerField(db_comment="Column comment")
|
||||||
|
|
||||||
|
errors = Model._meta.get_field("field").check(databases=self.databases)
|
||||||
|
expected = (
|
||||||
|
[]
|
||||||
|
if connection.features.supports_comments
|
||||||
|
else [
|
||||||
|
DjangoWarning(
|
||||||
|
f"{connection.display_name} does not support comments on columns "
|
||||||
|
f"(db_comment).",
|
||||||
|
obj=Model._meta.get_field("field"),
|
||||||
|
id="fields.W163",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertEqual(errors, expected)
|
||||||
|
|
||||||
|
def test_db_comment_required_db_features(self):
|
||||||
|
class Model(models.Model):
|
||||||
|
field = models.IntegerField(db_comment="Column comment")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
required_db_features = {"supports_comments"}
|
||||||
|
|
||||||
|
errors = Model._meta.get_field("field").check(databases=self.databases)
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
@ -94,7 +94,9 @@ class RelativeFieldTests(SimpleTestCase):
|
|||||||
name = models.CharField(max_length=20)
|
name = models.CharField(max_length=20)
|
||||||
|
|
||||||
class ModelM2M(models.Model):
|
class ModelM2M(models.Model):
|
||||||
m2m = models.ManyToManyField(Model, null=True, validators=[lambda x: x])
|
m2m = models.ManyToManyField(
|
||||||
|
Model, null=True, validators=[lambda x: x], db_comment="Column comment"
|
||||||
|
)
|
||||||
|
|
||||||
field = ModelM2M._meta.get_field("m2m")
|
field = ModelM2M._meta.get_field("m2m")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@ -110,6 +112,11 @@ class RelativeFieldTests(SimpleTestCase):
|
|||||||
obj=field,
|
obj=field,
|
||||||
id="fields.W341",
|
id="fields.W341",
|
||||||
),
|
),
|
||||||
|
DjangoWarning(
|
||||||
|
"db_comment has no effect on ManyToManyField.",
|
||||||
|
obj=field,
|
||||||
|
id="fields.W346",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -773,6 +773,14 @@ class AutodetectorTests(BaseAutodetectorTests):
|
|||||||
"verbose_name": "Authi",
|
"verbose_name": "Authi",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
author_with_db_table_comment = ModelState(
|
||||||
|
"testapp",
|
||||||
|
"Author",
|
||||||
|
[
|
||||||
|
("id", models.AutoField(primary_key=True)),
|
||||||
|
],
|
||||||
|
{"db_table_comment": "Table comment"},
|
||||||
|
)
|
||||||
author_with_db_table_options = ModelState(
|
author_with_db_table_options = ModelState(
|
||||||
"testapp",
|
"testapp",
|
||||||
"Author",
|
"Author",
|
||||||
@ -2349,6 +2357,58 @@ class AutodetectorTests(BaseAutodetectorTests):
|
|||||||
changes, "testapp", 0, 1, name="newauthor", table="author_three"
|
changes, "testapp", 0, 1, name="newauthor", table="author_three"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_alter_db_table_comment_add(self):
|
||||||
|
changes = self.get_changes(
|
||||||
|
[self.author_empty], [self.author_with_db_table_comment]
|
||||||
|
)
|
||||||
|
self.assertNumberMigrations(changes, "testapp", 1)
|
||||||
|
self.assertOperationTypes(changes, "testapp", 0, ["AlterModelTableComment"])
|
||||||
|
self.assertOperationAttributes(
|
||||||
|
changes, "testapp", 0, 0, name="author", table_comment="Table comment"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_alter_db_table_comment_change(self):
|
||||||
|
author_with_new_db_table_comment = ModelState(
|
||||||
|
"testapp",
|
||||||
|
"Author",
|
||||||
|
[
|
||||||
|
("id", models.AutoField(primary_key=True)),
|
||||||
|
],
|
||||||
|
{"db_table_comment": "New table comment"},
|
||||||
|
)
|
||||||
|
changes = self.get_changes(
|
||||||
|
[self.author_with_db_table_comment],
|
||||||
|
[author_with_new_db_table_comment],
|
||||||
|
)
|
||||||
|
self.assertNumberMigrations(changes, "testapp", 1)
|
||||||
|
self.assertOperationTypes(changes, "testapp", 0, ["AlterModelTableComment"])
|
||||||
|
self.assertOperationAttributes(
|
||||||
|
changes,
|
||||||
|
"testapp",
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
name="author",
|
||||||
|
table_comment="New table comment",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_alter_db_table_comment_remove(self):
|
||||||
|
changes = self.get_changes(
|
||||||
|
[self.author_with_db_table_comment],
|
||||||
|
[self.author_empty],
|
||||||
|
)
|
||||||
|
self.assertNumberMigrations(changes, "testapp", 1)
|
||||||
|
self.assertOperationTypes(changes, "testapp", 0, ["AlterModelTableComment"])
|
||||||
|
self.assertOperationAttributes(
|
||||||
|
changes, "testapp", 0, 0, name="author", db_table_comment=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_alter_db_table_comment_no_changes(self):
|
||||||
|
changes = self.get_changes(
|
||||||
|
[self.author_with_db_table_comment],
|
||||||
|
[self.author_with_db_table_comment],
|
||||||
|
)
|
||||||
|
self.assertNumberMigrations(changes, "testapp", 0)
|
||||||
|
|
||||||
def test_identical_regex_doesnt_alter(self):
|
def test_identical_regex_doesnt_alter(self):
|
||||||
from_state = ModelState(
|
from_state = ModelState(
|
||||||
"testapp",
|
"testapp",
|
||||||
|
@ -75,6 +75,20 @@ class MigrationTestBase(TransactionTestCase):
|
|||||||
def assertColumnCollation(self, table, column, collation, using="default"):
|
def assertColumnCollation(self, table, column, collation, using="default"):
|
||||||
self.assertEqual(self._get_column_collation(table, column, using), collation)
|
self.assertEqual(self._get_column_collation(table, column, using), collation)
|
||||||
|
|
||||||
|
def _get_table_comment(self, table, using):
|
||||||
|
with connections[using].cursor() as cursor:
|
||||||
|
return next(
|
||||||
|
t.comment
|
||||||
|
for t in connections[using].introspection.get_table_list(cursor)
|
||||||
|
if t.name == table
|
||||||
|
)
|
||||||
|
|
||||||
|
def assertTableComment(self, table, comment, using="default"):
|
||||||
|
self.assertEqual(self._get_table_comment(table, using), comment)
|
||||||
|
|
||||||
|
def assertTableCommentNotExists(self, table, using="default"):
|
||||||
|
self.assertIn(self._get_table_comment(table, using), [None, ""])
|
||||||
|
|
||||||
def assertIndexExists(
|
def assertIndexExists(
|
||||||
self, table, columns, value=True, using="default", index_type=None
|
self, table, columns, value=True, using="default", index_type=None
|
||||||
):
|
):
|
||||||
|
@ -1922,6 +1922,37 @@ class OperationTests(OperationTestBase):
|
|||||||
operation.database_forwards(app_label, editor, new_state, project_state)
|
operation.database_forwards(app_label, editor, new_state, project_state)
|
||||||
self.assertColumnExists(rider_table, "pony_id")
|
self.assertColumnExists(rider_table, "pony_id")
|
||||||
|
|
||||||
|
@skipUnlessDBFeature("supports_comments")
|
||||||
|
def test_alter_model_table_comment(self):
|
||||||
|
app_label = "test_almotaco"
|
||||||
|
project_state = self.set_up_test_model(app_label)
|
||||||
|
pony_table = f"{app_label}_pony"
|
||||||
|
# Add table comment.
|
||||||
|
operation = migrations.AlterModelTableComment("Pony", "Custom pony comment")
|
||||||
|
self.assertEqual(operation.describe(), "Alter Pony table comment")
|
||||||
|
self.assertEqual(operation.migration_name_fragment, "alter_pony_table_comment")
|
||||||
|
new_state = project_state.clone()
|
||||||
|
operation.state_forwards(app_label, new_state)
|
||||||
|
self.assertEqual(
|
||||||
|
new_state.models[app_label, "pony"].options["db_table_comment"],
|
||||||
|
"Custom pony comment",
|
||||||
|
)
|
||||||
|
self.assertTableCommentNotExists(pony_table)
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
operation.database_forwards(app_label, editor, project_state, new_state)
|
||||||
|
self.assertTableComment(pony_table, "Custom pony comment")
|
||||||
|
# Reversal.
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
operation.database_backwards(app_label, editor, new_state, project_state)
|
||||||
|
self.assertTableCommentNotExists(pony_table)
|
||||||
|
# Deconstruction.
|
||||||
|
definition = operation.deconstruct()
|
||||||
|
self.assertEqual(definition[0], "AlterModelTableComment")
|
||||||
|
self.assertEqual(definition[1], [])
|
||||||
|
self.assertEqual(
|
||||||
|
definition[2], {"name": "Pony", "table_comment": "Custom pony comment"}
|
||||||
|
)
|
||||||
|
|
||||||
def test_alter_field_pk(self):
|
def test_alter_field_pk(self):
|
||||||
"""
|
"""
|
||||||
The AlterField operation on primary keys (things like PostgreSQL's
|
The AlterField operation on primary keys (things like PostgreSQL's
|
||||||
|
@ -273,6 +273,27 @@ class SchemaTests(TransactionTestCase):
|
|||||||
if f.name == column
|
if f.name == column
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_column_comment(self, table, column):
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
return next(
|
||||||
|
f.comment
|
||||||
|
for f in connection.introspection.get_table_description(cursor, table)
|
||||||
|
if f.name == column
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_table_comment(self, table):
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
return next(
|
||||||
|
t.comment
|
||||||
|
for t in connection.introspection.get_table_list(cursor)
|
||||||
|
if t.name == table
|
||||||
|
)
|
||||||
|
|
||||||
|
def assert_column_comment_not_exists(self, table, column):
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
columns = connection.introspection.get_table_description(cursor, table)
|
||||||
|
self.assertFalse(any([c.name == column and c.comment for c in columns]))
|
||||||
|
|
||||||
def assertIndexOrder(self, table, index, order):
|
def assertIndexOrder(self, table, index, order):
|
||||||
constraints = self.get_constraints(table)
|
constraints = self.get_constraints(table)
|
||||||
self.assertIn(index, constraints)
|
self.assertIn(index, constraints)
|
||||||
@ -4390,6 +4411,186 @@ class SchemaTests(TransactionTestCase):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@skipUnlessDBFeature("supports_comments")
|
||||||
|
def test_add_db_comment_charfield(self):
|
||||||
|
comment = "Custom comment"
|
||||||
|
field = CharField(max_length=255, db_comment=comment)
|
||||||
|
field.set_attributes_from_name("name_with_comment")
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.create_model(Author)
|
||||||
|
editor.add_field(Author, field)
|
||||||
|
self.assertEqual(
|
||||||
|
self.get_column_comment(Author._meta.db_table, "name_with_comment"),
|
||||||
|
comment,
|
||||||
|
)
|
||||||
|
|
||||||
|
@skipUnlessDBFeature("supports_comments")
|
||||||
|
def test_add_db_comment_and_default_charfield(self):
|
||||||
|
comment = "Custom comment with default"
|
||||||
|
field = CharField(max_length=255, default="Joe Doe", db_comment=comment)
|
||||||
|
field.set_attributes_from_name("name_with_comment_default")
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.create_model(Author)
|
||||||
|
Author.objects.create(name="Before adding a new field")
|
||||||
|
editor.add_field(Author, field)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.get_column_comment(Author._meta.db_table, "name_with_comment_default"),
|
||||||
|
comment,
|
||||||
|
)
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
f"SELECT name_with_comment_default FROM {Author._meta.db_table};"
|
||||||
|
)
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
self.assertEqual(row[0], "Joe Doe")
|
||||||
|
|
||||||
|
@skipUnlessDBFeature("supports_comments")
|
||||||
|
def test_alter_db_comment(self):
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.create_model(Author)
|
||||||
|
# Add comment.
|
||||||
|
old_field = Author._meta.get_field("name")
|
||||||
|
new_field = CharField(max_length=255, db_comment="Custom comment")
|
||||||
|
new_field.set_attributes_from_name("name")
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.alter_field(Author, old_field, new_field, strict=True)
|
||||||
|
self.assertEqual(
|
||||||
|
self.get_column_comment(Author._meta.db_table, "name"),
|
||||||
|
"Custom comment",
|
||||||
|
)
|
||||||
|
# Alter comment.
|
||||||
|
old_field = new_field
|
||||||
|
new_field = CharField(max_length=255, db_comment="New custom comment")
|
||||||
|
new_field.set_attributes_from_name("name")
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.alter_field(Author, old_field, new_field, strict=True)
|
||||||
|
self.assertEqual(
|
||||||
|
self.get_column_comment(Author._meta.db_table, "name"),
|
||||||
|
"New custom comment",
|
||||||
|
)
|
||||||
|
# Remove comment.
|
||||||
|
old_field = new_field
|
||||||
|
new_field = CharField(max_length=255)
|
||||||
|
new_field.set_attributes_from_name("name")
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.alter_field(Author, old_field, new_field, strict=True)
|
||||||
|
self.assertIn(
|
||||||
|
self.get_column_comment(Author._meta.db_table, "name"),
|
||||||
|
[None, ""],
|
||||||
|
)
|
||||||
|
|
||||||
|
@skipUnlessDBFeature("supports_comments", "supports_foreign_keys")
|
||||||
|
def test_alter_db_comment_foreign_key(self):
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.create_model(Author)
|
||||||
|
editor.create_model(Book)
|
||||||
|
|
||||||
|
comment = "FK custom comment"
|
||||||
|
old_field = Book._meta.get_field("author")
|
||||||
|
new_field = ForeignKey(Author, CASCADE, db_comment=comment)
|
||||||
|
new_field.set_attributes_from_name("author")
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.alter_field(Book, old_field, new_field, strict=True)
|
||||||
|
self.assertEqual(
|
||||||
|
self.get_column_comment(Book._meta.db_table, "author_id"),
|
||||||
|
comment,
|
||||||
|
)
|
||||||
|
|
||||||
|
@skipUnlessDBFeature("supports_comments")
|
||||||
|
def test_alter_field_type_preserve_comment(self):
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.create_model(Author)
|
||||||
|
|
||||||
|
comment = "This is the name."
|
||||||
|
old_field = Author._meta.get_field("name")
|
||||||
|
new_field = CharField(max_length=255, db_comment=comment)
|
||||||
|
new_field.set_attributes_from_name("name")
|
||||||
|
new_field.model = Author
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.alter_field(Author, old_field, new_field, strict=True)
|
||||||
|
self.assertEqual(
|
||||||
|
self.get_column_comment(Author._meta.db_table, "name"),
|
||||||
|
comment,
|
||||||
|
)
|
||||||
|
# Changing a field type should preserve the comment.
|
||||||
|
old_field = new_field
|
||||||
|
new_field = CharField(max_length=511, db_comment=comment)
|
||||||
|
new_field.set_attributes_from_name("name")
|
||||||
|
new_field.model = Author
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.alter_field(Author, new_field, old_field, strict=True)
|
||||||
|
# Comment is preserved.
|
||||||
|
self.assertEqual(
|
||||||
|
self.get_column_comment(Author._meta.db_table, "name"),
|
||||||
|
comment,
|
||||||
|
)
|
||||||
|
|
||||||
|
@isolate_apps("schema")
|
||||||
|
@skipUnlessDBFeature("supports_comments")
|
||||||
|
def test_db_comment_table(self):
|
||||||
|
class ModelWithDbTableComment(Model):
|
||||||
|
class Meta:
|
||||||
|
app_label = "schema"
|
||||||
|
db_table_comment = "Custom table comment"
|
||||||
|
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.create_model(ModelWithDbTableComment)
|
||||||
|
self.isolated_local_models = [ModelWithDbTableComment]
|
||||||
|
self.assertEqual(
|
||||||
|
self.get_table_comment(ModelWithDbTableComment._meta.db_table),
|
||||||
|
"Custom table comment",
|
||||||
|
)
|
||||||
|
# Alter table comment.
|
||||||
|
old_db_table_comment = ModelWithDbTableComment._meta.db_table_comment
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.alter_db_table_comment(
|
||||||
|
ModelWithDbTableComment, old_db_table_comment, "New table comment"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.get_table_comment(ModelWithDbTableComment._meta.db_table),
|
||||||
|
"New table comment",
|
||||||
|
)
|
||||||
|
# Remove table comment.
|
||||||
|
old_db_table_comment = ModelWithDbTableComment._meta.db_table_comment
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.alter_db_table_comment(
|
||||||
|
ModelWithDbTableComment, old_db_table_comment, None
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
self.get_table_comment(ModelWithDbTableComment._meta.db_table),
|
||||||
|
[None, ""],
|
||||||
|
)
|
||||||
|
|
||||||
|
@isolate_apps("schema")
|
||||||
|
@skipUnlessDBFeature("supports_comments", "supports_foreign_keys")
|
||||||
|
def test_db_comments_from_abstract_model(self):
|
||||||
|
class AbstractModelWithDbComments(Model):
|
||||||
|
name = CharField(
|
||||||
|
max_length=255, db_comment="Custom comment", null=True, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = "schema"
|
||||||
|
abstract = True
|
||||||
|
db_table_comment = "Custom table comment"
|
||||||
|
|
||||||
|
class ModelWithDbComments(AbstractModelWithDbComments):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.create_model(ModelWithDbComments)
|
||||||
|
self.isolated_local_models = [ModelWithDbComments]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.get_column_comment(ModelWithDbComments._meta.db_table, "name"),
|
||||||
|
"Custom comment",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.get_table_comment(ModelWithDbComments._meta.db_table),
|
||||||
|
"Custom table comment",
|
||||||
|
)
|
||||||
|
|
||||||
@unittest.skipUnless(connection.vendor == "postgresql", "PostgreSQL specific")
|
@unittest.skipUnless(connection.vendor == "postgresql", "PostgreSQL specific")
|
||||||
def test_alter_field_add_index_to_charfield(self):
|
def test_alter_field_add_index_to_charfield(self):
|
||||||
# Create the table and verify no initial indexes.
|
# Create the table and verify no initial indexes.
|
||||||
|
Loading…
Reference in New Issue
Block a user