51

From the example of Django Book, I understand if I create models as following:

from xxx import B

class A(models.Model):
    b = ManyToManyField(B)

The Django would create a new table(A_B) beyond Table A, which has three columns:

  • id
  • a_id
  • b_id

But now I want to add a new column in the Table A_B, thus would be very easy if I use normal SQL, but now anyone can help me how to do? I can't find any useful information in this book.

Wei Lin
  • 749
  • 2
  • 6
  • 9
  • The table `A_B`'s purpose is solely to say which B models map to which A models and vice versa. Are you sure this is the best way to accomplish what you need to do? – chucksmash Sep 24 '12 at 14:39
  • 1
    Possible duplicate of [Django's ManyToMany Relationship with Additional Fields](http://stackoverflow.com/questions/4443190/djangos-manytomany-relationship-with-additional-fields) – Ciro Santilli OurBigBook.com May 14 '16 at 21:17

4 Answers4

110

It's very easy using django too! You can use through to define your own manytomany intermediary tables

Documentation provides an example addressing your issue:

Extra fields on many-to-many relationships

class Person(models.Model):
    name = models.CharField(max_length=128)

    def __unicode__(self):
        return self.name

class Group(models.Model):
    name = models.CharField(max_length=128)
    members = models.ManyToManyField(Person, through='Membership')

    def __unicode__(self):
        return self.name

class Membership(models.Model):
    person = models.ForeignKey(Person, on_delete=models.CASCADE)
    group = models.ForeignKey(Group, on_delete=models.CASCADE)
    date_joined = models.DateField()
    invite_reason = models.CharField(max_length=64)
Jiloc
  • 3,338
  • 3
  • 24
  • 38
dm03514
  • 54,664
  • 18
  • 108
  • 145
  • 1
    Make sure you don't forget to add the single quotes on the new table name ('Membership'), so the compiler doesn't complain if the table name is declared further down. – Katsifaris Mar 07 '19 at 08:06
  • @dm03514, through='Membership' , it is not creating the table in database. kindly help me. – Kabali Feb 07 '20 at 13:29
  • Existing ManyToMany fields cannot be altered by adding or removing 'through=', according to the migration exception I just ran into. – Tristan Brown Feb 23 '21 at 01:18
  • To get around the "M2M fields cannot be altered" exception: https://stackoverflow.com/questions/26927705/django-migration-error-you-cannot-alter-to-or-from-m2m-fields-or-add-or-remove – Tristan Brown Feb 23 '21 at 01:26
4

As @dm03514 has answered it is indeed very easy to add column to M2M table via defining explicitly the M2M through model and adding the desired field there.

However if you would like to add some column to all m2m tables - such approach wouldn't be sufficient, because it would require to define the M2M through models for all ManyToManyField's that have been defined across the project.

In my case I wanted to add a "created" timestamp column to all M2M tables that Django generates "under the hood" without the necessity of defining a separate model for every ManyToManyField field used in the project. I came up with a neat solution presented bellow. Cheers!

Introduction

While Django scans your models at startup it creates automatically an implicit through model for every ManyToManyField that does not define it explicitly.

class ManyToManyField(RelatedField):
    # (...)

    def contribute_to_class(self, cls, name, **kwargs):
        # (...)
        super().contribute_to_class(cls, name, **kwargs)

        # The intermediate m2m model is not auto created if:
        #  1) There is a manually specified intermediate, or
        #  2) The class owning the m2m field is abstract.
        #  3) The class owning the m2m field has been swapped out.
        if not cls._meta.abstract:
            if self.remote_field.through:
                def resolve_through_model(_, model, field):
                    field.remote_field.through = model
                lazy_related_operation(resolve_through_model, cls, self.remote_field.through, field=self)
            elif not cls._meta.swapped:
                self.remote_field.through = create_many_to_many_intermediary_model(self, cls)

Source: ManyToManyField.contribute_to_class()

For creation of this implicit model Django uses the create_many_to_many_intermediary_model() function, which constructs new class that inherits from models.Model and contains foreign keys to both sides of the M2M relation. Source: django.db.models.fields.related.create_many_to_many_intermediary_model()

In order to add some column to all auto generated M2M through tables you will need to monkeypatch this function.

The solution

First you should create the new version of the function that will be used to patch the original Django function. To do so just copy the code of the function from Django sources and add the desired fields to the class it returns:

# For example in: <project_root>/lib/monkeypatching/custom_create_m2m_model.py
def create_many_to_many_intermediary_model(field, klass):
    # (...)
    return type(name, (models.Model,), {
        'Meta': meta,
        '__module__': klass.__module__,
        from_: models.ForeignKey(
            klass,
            related_name='%s+' % name,
            db_tablespace=field.db_tablespace,
            db_constraint=field.remote_field.db_constraint,
            on_delete=CASCADE,
        ),
        to: models.ForeignKey(
            to_model,
            related_name='%s+' % name,
            db_tablespace=field.db_tablespace,
            db_constraint=field.remote_field.db_constraint,
            on_delete=CASCADE,
        ),
        # Add your custom-need fields here:
        'created': models.DateTimeField(
            auto_now_add=True,
            verbose_name='Created (UTC)',
        ),
    })

Then you should enclose the patching logic in a separate function:

# For example in: <project_root>/lib/monkeypatching/patches.py
def django_m2m_intermediary_model_monkeypatch():
    """ We monkey patch function responsible for creation of intermediary m2m
        models in order to inject there a "created" timestamp.
    """
    from django.db.models.fields import related
    from lib.monkeypatching.custom_create_m2m_model import create_many_to_many_intermediary_model
    setattr(
        related,
        'create_many_to_many_intermediary_model',
        create_many_to_many_intermediary_model
    )

Finally you have to perform patching, before Django kicks in. Put such code in __init__.py file located next to your Django project settings.py file:

# <project_root>/<project_name>/__init__.py
from lib.monkeypatching.patches import django_m2m_intermediary_model_monkeypatch
django_m2m_intermediary_model_monkeypatch()

Few other things worth mentioning

  1. Remember that this does not affect m2m tables that have been created in the db in the past, so if you are introducing this solution in a project that already had ManyToManyField fields migrated to db, you will need to prepare a custom migration that will add your custom columns to the tables which were created before the monkeypatch. Sample migration provided below :)

    from django.db import migrations
    
    def auto_created_m2m_fields(_models):
        """ Retrieves M2M fields from provided models but only those that have auto
            created intermediary models (not user-defined through models).
        """
        for model in _models:
            for field in model._meta.get_fields():
                if (
                        isinstance(field, models.ManyToManyField)
                        and field.remote_field.through._meta.auto_created
                ):
                    yield field
    
    def add_created_to_m2m_tables(apps, schema_editor):
        # Exclude proxy models that don't have separate tables in db
        selected_models = [
            model for model in apps.get_models()
            if not model._meta.proxy
        ]
    
        # Select only m2m fields that have auto created intermediary models and then
        # retrieve m2m intermediary db tables
        tables = [
            field.remote_field.through._meta.db_table
            for field in auto_created_m2m_fields(selected_models)
        ]
    
        for table_name in tables:
            schema_editor.execute(
                f'ALTER TABLE {table_name} ADD COLUMN IF NOT EXISTS created '
                'timestamp with time zone NOT NULL DEFAULT now()',
            )
    
    
    class Migration(migrations.Migration):
        dependencies = []
        operations = [migrations.RunPython(add_created_to_m2m_tables)]
    
  2. Remember that the solution presented only affects the tables that Django creates automatically for ManyToManyField fields that do not define the through model. If you already have some explicit m2m through models you will need to add your custom-need columns there manually.

  3. The patched create_many_to_many_intermediary_model function will apply also to the models of all 3rd-party apps listed in your INSTALLED_APPS setting.

  4. Last but not least, remember that if you upgrade Django version the original source code of the patched function may change (!). It's a good idea to setup a simple unit test that will warn you if such situation happens in the future.

To do so modify the patching function to save the original Django function:

# For example in: <project_root>/lib/monkeypatching/patches.py
def django_m2m_intermediary_model_monkeypatch():
    """ We monkey patch function responsible for creation of intermediary m2m
        models in order to inject there a "created" timestamp.
    """
    from django.db.models.fields import related
    from lib.monkeypatching.custom_create_m2m_model import create_many_to_many_intermediary_model
    # Save the original Django function for test
    original_function = related.create_many_to_many_intermediary_model
    setattr(
        create_many_to_many_intermediary_model,
        '_original_django_function',
        original_function
    )
    # Patch django function with our version of this function
    setattr(
        related,
        'create_many_to_many_intermediary_model',
        create_many_to_many_intermediary_model
    )

Compute the hash of the source code of the original Django function and prepare a test that checks whether it is still the same as when you patched it:

def _hash_source_code(_obj):
    from inspect import getsourcelines
    from hashlib import md5
    source_code = ''.join(getsourcelines(_obj)[0])
    return md5(source_code.encode()).hexdigest()

def test_original_create_many_to_many_intermediary_model():
    """ This test checks whether the original Django function that has been
        patched did not changed. The hash of function source code is compared
        and if it does not match original hash, that means that Django version
        could have been upgraded and patched function could have changed.
    """
    from django.db.models.fields.related import create_many_to_many_intermediary_model
    original_function_md5_hash = '69d8cea3ce9640f64ce7b1df1c0934b8' # hash obtained before patching (Django 2.0.3)
    original_function = getattr(
        create_many_to_many_intermediary_model,
        '_original_django_function',
        None
    )
    assert original_function
    assert _hash_source_code(original_function) == original_function_md5_hash

Cheers

I hope someone will find this answer useful :)

Krzysiek
  • 7,895
  • 6
  • 37
  • 38
3

Under the hood, Django creates automatically a through model. It is possible to modify this automatic model foreign key column names.

I could not test the implications on all scenarios, so far it works properly for me.

Using Django 1.8 and onwards' _meta api:

class Person(models.Model):
    pass

class Group(models.Model):
    members = models.ManyToManyField(Person)

Group.members.through._meta.get_field('person').column = 'alt_person_id'
Group.members.through._meta.get_field('group' ).column =  'alt_group_id'

# Prior to Django 1.8 _meta can also be used, but is more hackish than this
Group.members.through.person.field.column = 'alt_person_id'
Group.members.through.group .field.column =  'alt_group_id'
ajaest
  • 587
  • 4
  • 13
  • what does it mean virtual when the db is physical? what if the table already exists? – Dejell May 03 '17 at 14:46
  • Well, maybe virtual is not the best term. I meant automatic and hidden. I will change it. I don't known which policy does Django applies if the model exists. – ajaest May 04 '17 at 15:21
1

Same as question I needed a custom models.ManyToManyField to add some columns to specific M2M relations.

My answer is Base on @Krzysiek answer with a small change, I inherit a class from models.ManyToManyField and monkeypatch its contribute_to_class method partially with unittest.mock.patch to use a custom create_many_to_many_intermediary_model instead of original one, This way I can control which M2M relations can have custom columns and also 3rd-party apps won't affected as @Krzysiek mentioned in its answer

from django.db.models.fields.related import (
    lazy_related_operation,
    resolve_relation,
    make_model_tuple,
    CASCADE,
    _,
)

from unittest.mock import patch


def custom_create_many_to_many_intermediary_model(field, klass):
    from django.db import models

    def set_managed(model, related, through):
        through._meta.managed = model._meta.managed or related._meta.managed

    to_model = resolve_relation(klass, field.remote_field.model)
    name = "%s_%s" % (klass._meta.object_name, field.name)
    lazy_related_operation(set_managed, klass, to_model, name)

    to = make_model_tuple(to_model)[1]
    from_ = klass._meta.model_name
    if to == from_:
        to = "to_%s" % to
        from_ = "from_%s" % from_

    meta = type(
        "Meta",
        (),
        {
            "db_table": field._get_m2m_db_table(klass._meta),
            "auto_created": klass,
            "app_label": klass._meta.app_label,
            "db_tablespace": klass._meta.db_tablespace,
            "unique_together": (from_, to),
            "verbose_name": _("%(from)s-%(to)s relationship")
            % {"from": from_, "to": to},
            "verbose_name_plural": _("%(from)s-%(to)s relationships")
            % {"from": from_, "to": to},
            "apps": field.model._meta.apps,
        },
    )
    # Construct and return the new class.
    return type(
        name,
        (models.Model,),
        {
            "Meta": meta,
            "__module__": klass.__module__,
            from_: models.ForeignKey(
                klass,
                related_name="%s+" % name,
                db_tablespace=field.db_tablespace,
                db_constraint=field.remote_field.db_constraint,
                on_delete=CASCADE,
            ),
            to: models.ForeignKey(
                to_model,
                related_name="%s+" % name,
                db_tablespace=field.db_tablespace,
                db_constraint=field.remote_field.db_constraint,
                on_delete=CASCADE,
            ),
            # custom-need fields here:
            "is_custom_m2m": models.BooleanField(default=False),
        },
    )


class CustomManyToManyField(models.ManyToManyField):
    def contribute_to_class(self, cls, name, **kwargs):
        ############################################################
        # Inspired by https://stackoverflow.com/a/60421834/9917276 #
        ############################################################
        with patch(
            "django.db.models.fields.related.create_many_to_many_intermediary_model",
            wraps=custom_create_many_to_many_intermediary_model,
        ):
            super().contribute_to_class(cls, name, **kwargs)

Then I use my CustomManyToManyField instead of models.ManyToMany When I want my m2m table have custom fields

class MyModel(models.Model):
    my_m2m_field = CustomManyToManyField()

Note that new custom columns may not add if m2m field already exist, and you have to add them manualy or with script by migration as @Krzysiek mentioned.

  • By doing this, how do you specify which models.ManyToManyField are using this custom patch? – Superad Aug 18 '22 at 22:28
  • 1
    @superad In model that I want to have m2m field I use my custom ManyToManyField (here I named It `SoftDeleteManyToManyField`) instead of models.ManyToManyField (Ex. `my_new_m2m = SoftDeleteManyToManyField()`) – amirhosein bidar Aug 19 '22 at 16:38
  • So you had a different problem, though similar, and show your solution for that. Does this also answer the question at the top of this page? If so, then please [edit] to make that more obvious. – Yunnosch Sep 26 '22 at 09:00
  • @Yunnosch I just completed and solved downsides of Krzysiek answer however I didn't know better way share my solution so just posted it as answer if there is better way I am glad to do it – amirhosein bidar Sep 28 '22 at 19:49
  • If you have improvements to an existing answer, from the point of view of the problem described at the top of this page, then making an answer and explaining that is the perfect way. Please understand that I did not get that from the post as it is. I recommend to be more explicit about that in the explanation of your post. Just try to avoid the misunderstanding I had. Any way, thanks for answering to my question. Have fun. – Yunnosch Sep 28 '22 at 21:34