diff --git a/django/db/backends/base/schema.py b/django/db/backends/base/schema.py index 09db3f10966..8ec92ebf6ac 100644 --- a/django/db/backends/base/schema.py +++ b/django/db/backends/base/schema.py @@ -1,7 +1,6 @@ import hashlib 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 import six from django.utils.encoding import force_bytes @@ -380,7 +379,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.many_to_many 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) @@ -424,7 +423,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.many_to_many 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 814b94ffee0..031bf778406 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.apps.registry import Apps from django.db.backends.base.schema import BaseDatabaseSchemaEditor -from django.db.models.fields.related import ManyToManyField from django.utils import six import _sqlite3 @@ -71,7 +70,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.many_to_many: mapping[field.column] = self.quote_value( self.effective_default(field) ) @@ -94,7 +93,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.many_to_many and field.rel.through._meta.auto_created: return self.delete_model(field.rel.through) # Work inside a new app registry apps = Apps() @@ -173,7 +172,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.many_to_many and field.rel.through._meta.auto_created: return self.create_model(field.rel.through) self._remake_table(model, create_fields=[field]) @@ -183,7 +182,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): but for M2Ms may involve deleting a table. """ # M2M fields are a special case - if isinstance(field, ManyToManyField): + if field.many_to_many: # 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/tests/schema/fields.py b/tests/schema/fields.py new file mode 100644 index 00000000000..4f70c96b0be --- /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. + """ + many_to_many = True + + def __init__(self, to, db_constraint=True, swappable=True, **kwargs): + try: + to._meta + except AttributeError: + to = str(to) + kwargs['rel'] = ManyToManyRel( + self, 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, **kwargs): + 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, **kwargs) + 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'] + _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 65b4ab5c25b..2dd56d7248e 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, @@ -1303,3 +1304,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 many_to_many + """ + # 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)