Loading django/db/backends/schema.py +2 −3 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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) Loading Loading @@ -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: Loading django/db/backends/sqlite3/schema.py +4 −5 Original line number Diff line number Diff line Loading @@ -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): Loading Loading @@ -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) ) Loading @@ -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() Loading Loading @@ -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]) Loading @@ -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) Loading docs/releases/1.7.4.txt +4 −0 Original line number Diff line number Diff line Loading @@ -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`). tests/schema/fields.py 0 → 100644 +54 −0 Original line number Diff line number Diff line 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'] tests/schema/tests.py +45 −0 Original line number Diff line number Diff line Loading @@ -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, Loading Loading @@ -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) Loading
django/db/backends/schema.py +2 −3 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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) Loading Loading @@ -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: Loading
django/db/backends/sqlite3/schema.py +4 −5 Original line number Diff line number Diff line Loading @@ -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): Loading Loading @@ -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) ) Loading @@ -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() Loading Loading @@ -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]) Loading @@ -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) Loading
docs/releases/1.7.4.txt +4 −0 Original line number Diff line number Diff line Loading @@ -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`).
tests/schema/fields.py 0 → 100644 +54 −0 Original line number Diff line number Diff line 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']
tests/schema/tests.py +45 −0 Original line number Diff line number Diff line Loading @@ -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, Loading Loading @@ -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)