diff --git a/django/db/backends/schema.py b/django/db/backends/schema.py index 7c10db7dec..12e2ab7657 100644 --- a/django/db/backends/schema.py +++ b/django/db/backends/schema.py @@ -3,7 +3,6 @@ import operator from django.db.backends.creation import BaseDatabaseCreation from django.db.backends.utils import truncate_name -from django.db.models.fields.related import ManyToManyField from django.db.transaction import atomic from django.utils.encoding import force_bytes from django.utils.log import getLogger @@ -359,7 +358,7 @@ class BaseDatabaseSchemaEditor(object): table instead (for M2M fields) """ # Special-case implicit M2M tables - if isinstance(field, ManyToManyField) and field.rel.through._meta.auto_created: + if field.get_internal_type() == 'ManyToManyField' and field.rel.through._meta.auto_created: return self.create_model(field.rel.through) # Get the column's definition definition, params = self.column_sql(model, field, include_default=True) @@ -403,7 +402,7 @@ class BaseDatabaseSchemaEditor(object): but for M2Ms may involve deleting a table. """ # Special-case implicit M2M tables - if isinstance(field, ManyToManyField) and field.rel.through._meta.auto_created: + if field.get_internal_type() == 'ManyToManyField' and field.rel.through._meta.auto_created: return self.delete_model(field.rel.through) # It might not actually have a column behind it if field.db_parameters(connection=self.connection)['type'] is None: diff --git a/django/db/backends/sqlite3/schema.py b/django/db/backends/sqlite3/schema.py index 73c70effb9..c5f56a04a7 100644 --- a/django/db/backends/sqlite3/schema.py +++ b/django/db/backends/sqlite3/schema.py @@ -4,7 +4,6 @@ from decimal import Decimal from django.utils import six from django.apps.registry import Apps from django.db.backends.schema import BaseDatabaseSchemaEditor -from django.db.models.fields.related import ManyToManyField class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): @@ -70,7 +69,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): for field in create_fields: body[field.name] = field # Choose a default and insert it into the copy map - if not isinstance(field, ManyToManyField): + if not field.get_internal_type() == 'ManyToManyField': mapping[field.column] = self.quote_value( self.effective_default(field) ) @@ -93,7 +92,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): del body[field.name] del mapping[field.column] # Remove any implicit M2M tables - if isinstance(field, ManyToManyField) and field.rel.through._meta.auto_created: + if field.get_internal_type() == 'ManyToManyField' and field.rel.through._meta.auto_created: return self.delete_model(field.rel.through) # Work inside a new app registry apps = Apps() @@ -172,7 +171,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): table instead (for M2M fields) """ # Special-case implicit M2M tables - if isinstance(field, ManyToManyField) and field.rel.through._meta.auto_created: + if field.get_internal_type() == 'ManyToManyField' and field.rel.through._meta.auto_created: return self.create_model(field.rel.through) self._remake_table(model, create_fields=[field]) @@ -182,7 +181,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): but for M2Ms may involve deleting a table. """ # M2M fields are a special case - if isinstance(field, ManyToManyField): + if field.get_internal_type() == 'ManyToManyField': # For implicit M2M tables, delete the auto-created table if field.rel.through._meta.auto_created: self.delete_model(field.rel.through) diff --git a/docs/releases/1.7.4.txt b/docs/releases/1.7.4.txt index 0d53459068..133dd44369 100644 --- a/docs/releases/1.7.4.txt +++ b/docs/releases/1.7.4.txt @@ -20,3 +20,7 @@ Bugfixes * Prevented the ``static.serve`` view from producing ``ResourceWarning``\s in certain circumstances (security fix regression, :ticket:`24193`). + +* Fixed schema check for ``ManyToManyField`` to look for internal type instead + of checking class instance, so you can write custom m2m-like fields with the + same behavior. (:ticket:`24104`). diff --git a/tests/schema/fields.py b/tests/schema/fields.py new file mode 100644 index 0000000000..596671a6bc --- /dev/null +++ b/tests/schema/fields.py @@ -0,0 +1,54 @@ +from django.db.models.fields.related import ( + create_many_to_many_intermediary_model, + ManyToManyField, ManyToManyRel, RelatedField, + RECURSIVE_RELATIONSHIP_CONSTANT, ReverseManyRelatedObjectsDescriptor, +) +from django.utils.functional import curry + + +class CustomManyToManyField(RelatedField): + """ + Ticket #24104 - Need to have a custom ManyToManyField, + which is not an inheritor of ManyToManyField. + """ + + def __init__(self, to, db_constraint=True, swappable=True, **kwargs): + try: + to._meta + except AttributeError: + to = str(to) + kwargs['verbose_name'] = kwargs.get('verbose_name', None) + kwargs['rel'] = ManyToManyRel( + to, + related_name=kwargs.pop('related_name', None), + related_query_name=kwargs.pop('related_query_name', None), + limit_choices_to=kwargs.pop('limit_choices_to', None), + symmetrical=kwargs.pop('symmetrical', to == RECURSIVE_RELATIONSHIP_CONSTANT), + through=kwargs.pop('through', None), + through_fields=kwargs.pop('through_fields', None), + db_constraint=db_constraint, + ) + self.swappable = swappable + self.db_table = kwargs.pop('db_table', None) + if kwargs['rel'].through is not None: + assert self.db_table is None, "Cannot specify a db_table if an intermediary model is used." + super(CustomManyToManyField, self).__init__(**kwargs) + + def contribute_to_class(self, cls, name): + if self.rel.symmetrical and (self.rel.to == "self" or self.rel.to == cls._meta.object_name): + self.rel.related_name = "%s_rel_+" % name + super(CustomManyToManyField, self).contribute_to_class(cls, name) + if not self.rel.through and not cls._meta.abstract and not cls._meta.swapped: + self.rel.through = create_many_to_many_intermediary_model(self, cls) + setattr(cls, self.name, ReverseManyRelatedObjectsDescriptor(self)) + self.m2m_db_table = curry(self._get_m2m_db_table, cls._meta) + + def get_internal_type(self): + return 'ManyToManyField' + + # Copy those methods from ManyToManyField because they don't call super() internally + contribute_to_related_class = ManyToManyField.__dict__['contribute_to_related_class'] + set_attributes_from_rel = ManyToManyField.__dict__['set_attributes_from_rel'] + _get_m2m_attr = ManyToManyField.__dict__['_get_m2m_attr'] + _get_m2m_reverse_attr = ManyToManyField.__dict__['_get_m2m_reverse_attr'] + _get_m2m_db_table = ManyToManyField.__dict__['_get_m2m_db_table'] diff --git a/tests/schema/tests.py b/tests/schema/tests.py index f86916b5a4..a0ca9f19eb 100644 --- a/tests/schema/tests.py +++ b/tests/schema/tests.py @@ -7,6 +7,7 @@ from django.db.models.fields import (BinaryField, BooleanField, CharField, Integ PositiveIntegerField, SlugField, TextField) from django.db.models.fields.related import ForeignKey, ManyToManyField, OneToOneField from django.db.transaction import atomic +from .fields import CustomManyToManyField from .models import (Author, AuthorWithDefaultHeight, AuthorWithM2M, Book, BookWithLongName, BookWithSlug, BookWithM2M, Tag, TagIndexed, TagM2MTest, TagUniqueRename, UniqueTest, Thing, TagThrough, BookWithM2MThrough, AuthorTag, AuthorWithM2MThrough, @@ -1310,3 +1311,47 @@ class SchemaTests(TransactionTestCase): cursor.execute("SELECT surname FROM schema_author;") item = cursor.fetchall()[0] self.assertEqual(item[0], None if connection.features.interprets_empty_strings_as_nulls else '') + + def test_custom_manytomanyfield(self): + """ + #24104 - Schema editors should look for internal type of field + """ + # Create the tables + with connection.schema_editor() as editor: + editor.create_model(AuthorWithM2M) + editor.create_model(TagM2MTest) + # Create an M2M field + new_field = CustomManyToManyField("schema.TagM2MTest", related_name="authors") + new_field.contribute_to_class(AuthorWithM2M, "tags") + # Ensure there's no m2m table there + self.assertRaises(DatabaseError, self.column_classes, new_field.rel.through) + try: + # Add the field + with connection.schema_editor() as editor: + editor.add_field( + AuthorWithM2M, + new_field, + ) + # Ensure there is now an m2m table there + columns = self.column_classes(new_field.rel.through) + self.assertEqual(columns['tagm2mtest_id'][0], "IntegerField") + + # "Alter" the field. This should not rename the DB table to itself. + with connection.schema_editor() as editor: + editor.alter_field( + AuthorWithM2M, + new_field, + new_field, + ) + + # Remove the M2M table again + with connection.schema_editor() as editor: + editor.remove_field( + AuthorWithM2M, + new_field, + ) + # Ensure there's no m2m table there + self.assertRaises(DatabaseError, self.column_classes, new_field.rel.through) + finally: + # Cleanup model states + AuthorWithM2M._meta.local_many_to_many.remove(new_field)