From ab1955a05e369337c3eaad1a2d34de346932179d Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Tue, 2 Aug 2022 11:44:26 +0200 Subject: [PATCH] Fixed #33881 -- Added support for database collations to ArrayField(Char/TextFields). --- django/contrib/postgres/fields/array.py | 6 +++ django/db/backends/base/schema.py | 7 +++- django/db/backends/oracle/schema.py | 6 ++- django/db/backends/postgresql/schema.py | 34 +++++++++++++++-- tests/schema/tests.py | 49 +++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 8 deletions(-) diff --git a/django/contrib/postgres/fields/array.py b/django/contrib/postgres/fields/array.py index 7269198674..46c3e07b21 100644 --- a/django/contrib/postgres/fields/array.py +++ b/django/contrib/postgres/fields/array.py @@ -25,6 +25,7 @@ class ArrayField(CheckFieldDefaultMixin, Field): def __init__(self, base_field, size=None, **kwargs): self.base_field = base_field + self.db_collation = getattr(self.base_field, "db_collation", None) self.size = size if self.size: self.default_validators = [ @@ -97,6 +98,11 @@ class ArrayField(CheckFieldDefaultMixin, Field): size = self.size or "" return "%s[%s]" % (self.base_field.cast_db_type(connection), size) + def db_parameters(self, connection): + db_params = super().db_parameters(connection) + db_params["collation"] = self.db_collation + return db_params + def get_placeholder(self, value, compiler, connection): return "%s::{}".format(self.db_type(connection)) diff --git a/django/db/backends/base/schema.py b/django/db/backends/base/schema.py index 54f13be798..5e111aed73 100644 --- a/django/db/backends/base/schema.py +++ b/django/db/backends/base/schema.py @@ -950,7 +950,7 @@ class BaseDatabaseSchemaEditor: if old_collation != new_collation: # Collation change handles also a type change. fragment = self._alter_column_collation_sql( - model, new_field, new_type, new_collation + model, new_field, new_type, new_collation, old_field ) actions.append(fragment) # Type change? @@ -1079,6 +1079,7 @@ class BaseDatabaseSchemaEditor: new_rel.field, rel_type, rel_collation, + old_rel.field, ) other_actions = [] else: @@ -1226,7 +1227,9 @@ class BaseDatabaseSchemaEditor: [], ) - def _alter_column_collation_sql(self, model, new_field, new_type, new_collation): + def _alter_column_collation_sql( + self, model, new_field, new_type, new_collation, old_field + ): return ( self.sql_alter_column_collate % { diff --git a/django/db/backends/oracle/schema.py b/django/db/backends/oracle/schema.py index f2a5099110..1bf12293e8 100644 --- a/django/db/backends/oracle/schema.py +++ b/django/db/backends/oracle/schema.py @@ -242,9 +242,11 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): ) return cursor.fetchone()[0] - def _alter_column_collation_sql(self, model, new_field, new_type, new_collation): + def _alter_column_collation_sql( + self, model, new_field, new_type, new_collation, old_field + ): if new_collation is None: new_collation = self._get_default_collation(model._meta.db_table) return super()._alter_column_collation_sql( - model, new_field, new_type, new_collation + model, new_field, new_type, new_collation, old_field ) diff --git a/django/db/backends/postgresql/schema.py b/django/db/backends/postgresql/schema.py index a22ae0094f..3d4ddeeb6d 100644 --- a/django/db/backends/postgresql/schema.py +++ b/django/db/backends/postgresql/schema.py @@ -112,9 +112,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): ) return None - def _alter_column_type_sql(self, model, old_field, new_field, new_type): - self.sql_alter_column_type = "ALTER COLUMN %(column)s TYPE %(type)s" - # Cast when data type changed. + def _using_sql(self, new_field, old_field): using_sql = " USING %(column)s::%(type)s" new_internal_type = new_field.get_internal_type() old_internal_type = old_field.get_internal_type() @@ -123,9 +121,18 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): if list(self._field_base_data_types(old_field)) != list( self._field_base_data_types(new_field) ): - self.sql_alter_column_type += using_sql + return using_sql elif self._field_data_type(old_field) != self._field_data_type(new_field): + return using_sql + return "" + + def _alter_column_type_sql(self, model, old_field, new_field, new_type): + self.sql_alter_column_type = "ALTER COLUMN %(column)s TYPE %(type)s" + # Cast when data type changed. + if using_sql := self._using_sql(new_field, old_field): self.sql_alter_column_type += using_sql + new_internal_type = new_field.get_internal_type() + old_internal_type = old_field.get_internal_type() # Make ALTER TYPE with IDENTITY make sense. table = strip_quotes(model._meta.db_table) auto_field_types = { @@ -186,6 +193,25 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): else: return super()._alter_column_type_sql(model, old_field, new_field, new_type) + def _alter_column_collation_sql( + self, model, new_field, new_type, new_collation, old_field + ): + sql = self.sql_alter_column_collate + # Cast when data type changed. + if using_sql := self._using_sql(new_field, old_field): + sql += using_sql + return ( + sql + % { + "column": self.quote_name(new_field.column), + "type": new_type, + "collation": " " + self._collate_sql(new_collation) + if new_collation + else "", + }, + [], + ) + def _alter_field( self, model, diff --git a/tests/schema/tests.py b/tests/schema/tests.py index 2868f59501..0702b54135 100644 --- a/tests/schema/tests.py +++ b/tests/schema/tests.py @@ -1236,6 +1236,55 @@ class SchemaTests(TransactionTestCase): with self.assertRaisesMessage(DataError, msg): editor.alter_field(ArrayModel, old_field, new_field, strict=True) + @isolate_apps("schema") + @unittest.skipUnless(connection.vendor == "postgresql", "PostgreSQL specific") + @skipUnlessDBFeature( + "supports_collation_on_charfield", + "supports_non_deterministic_collations", + ) + def test_db_collation_arrayfield(self): + from django.contrib.postgres.fields import ArrayField + + ci_collation = "case_insensitive" + cs_collation = "en-x-icu" + + def drop_collation(): + with connection.cursor() as cursor: + cursor.execute(f"DROP COLLATION IF EXISTS {ci_collation}") + + with connection.cursor() as cursor: + cursor.execute( + f"CREATE COLLATION IF NOT EXISTS {ci_collation} (provider = icu, " + f"locale = 'und-u-ks-level2', deterministic = false)" + ) + self.addCleanup(drop_collation) + + class ArrayModel(Model): + field = ArrayField(CharField(max_length=16, db_collation=ci_collation)) + + class Meta: + app_label = "schema" + + # Create the table. + with connection.schema_editor() as editor: + editor.create_model(ArrayModel) + self.isolated_local_models = [ArrayModel] + self.assertEqual( + self.get_column_collation(ArrayModel._meta.db_table, "field"), + ci_collation, + ) + # Alter collation. + old_field = ArrayModel._meta.get_field("field") + new_field_cs = ArrayField(CharField(max_length=16, db_collation=cs_collation)) + new_field_cs.set_attributes_from_name("field") + new_field_cs.model = ArrayField + with connection.schema_editor() as editor: + editor.alter_field(ArrayModel, old_field, new_field_cs, strict=True) + self.assertEqual( + self.get_column_collation(ArrayModel._meta.db_table, "field"), + cs_collation, + ) + def test_alter_textfield_to_null(self): """ #24307 - Should skip an alter statement on databases with