Hijacking Default Django 'Through' Tables

in python •  5 years ago 

A few times in the last year, I've run into the need to add some metadata to a Django many-to-many relationship. By default, there's no explicit model to add fields to, but - if you're working on an active project - you probably have existing data in the default 'through' table that you don't want to lose. So what are you to do if you don't want to have to create a completely new table and migrate the data over? Let's hijack the existing one.

The existing models:

from django.db import models

class ModelA(models.Model):
    b_models = models.ManyToManyField("app.ModelB", related_name="a_models", blank=True)

    class Meta:
        db_table = "app_model_a"


class ModelB(models.Model):
    class Meta:
        db_table = "app_model_b"

Create a model that matches the existing 'through' table exactly - and make sure to specify the existing table name using Meta.db_table:

from django.db import models


class ModelAModelB(models.Model):

    modela = models.ForeignKey("app.ModelA", on_delete=models.CASCADE)
    modelb = models.ForeignKey("app.ModelB", on_delete=models.CASCADE)

    class Meta:
        db_table = "app_model_a_model_b"

Update the many-to-many relationship to use the new model:


class ModelA(models.Model):
    b_models = models.ManyToManyField("app.ModelB", related_name="a_models", through="app.ModelAModelB", blank=True)

    class Meta:
        db_table = "app_model_a"



Now generate a new migration with python manage.py makemigrations; it'll need some editing. The initial migration:

import datetime
from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('api_app', '69420_whatever_your_last_migration_was'),
    ]

    operations = [
        migrations.CreateModel(
            name='ModelAModelB',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
            ],
            options={
                'db_table': 'app_model_a_model_b',
            },
        ),
        migrations.AlterField(
            model_name='modela',
            name='b_models',
            field=models.ManyToManyField(blank=True, related_name='a_models', through='app.ModelAModelB', to='app.ModelB'),
        ),
        migrations.AddField(
            model_name='modelamodelb',
            name='modela',
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.ModelA'),
        ),
        migrations.AddField(
            model_name='modelamodelb',
            name='modelb',
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.ModelB'),
        ),
    ]

We just need to wrap these operations in migrations.SeparateDatabaseAndState to get the models in sync without screwing up the existing database setup. All of the changes above represent state changes:

import datetime
from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('api_app', '69420_whatever_your_last_migration_was'),
    ]

    state_operations = [
        migrations.CreateModel(
            name='ModelAModelB',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
            ],
            options={
                'db_table': 'app_model_a_model_b',
            },
        ),
        migrations.AlterField(
            model_name='modela',
            name='b_models',
            field=models.ManyToManyField(blank=True, related_name='a_models', through='app.ModelAModelB', to='app.ModelB'),
        ),
        migrations.AddField(
            model_name='modelamodelb',
            name='modela',
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.ModelA'),
        ),
        migrations.AddField(
            model_name='modelamodelb',
            name='modelb',
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.ModelB'),
        ),
    ]

    operations = [
        migrations.SeparateDatabaseAndState(
            state_operations=state_operations,
        )
    ]

And that should do it. That migration should successfully tie the existing 'through' table to your new model. Now you can add fields and create additional migrations as normal.

For my notes - filtering on 'through' fields

I want to make one additional note - since it wasn't immediately obvious to me. With Django 2.2, you should be able to filter on the 'through' table fields by using the lowercase name of the 'through' model.

You can add something like state to the 'through' model:

class ModelAModelB(models.Model):

    modela = models.ForeignKey("app.ModelA", on_delete=models.CASCADE)
    modelb = models.ForeignKey("app.ModelB", on_delete=models.CASCADE)
    state = models.CharField(
      max_length=16,
      null=True,
      blank=True,
      choices=[("good", "good"), ("ungood", "ungood")]
    )

    class Meta:
        db_table = "app_model_a_model_b"

Then filtering on this field looks a lot like this:

# querysets
ModelA.objects.filter(modelamodelb__state="good")
ModelB.objects.filter(modelamodelb__state="ungood")
ModelAModelB.objects.filter(state="good")

# related manager on instances
a_instance = ModelA.objects.first()
a_instance.b_models.filter(modelamodelb__state="ungood")

b_instance = ModelB.objects.first()
b_instance.a_models.filter(modelamodelb__state="good")

Now hopefully I can just come back to this post next time I need to do this.


Prefer to catch my posts elsewhere?
Originally posted on my blog: https://typenil.com/hijacking-default-django-through-tables/

Authors get paid when people like you upvote their post.
If you enjoyed what you read here, create your account today and start earning FREE STEEM!
Sort Order:  

Congratulations @typenil! You received a personal award!

Happy Birthday! - You are on the Steem blockchain for 2 years!

You can view your badges on your Steem Board and compare to others on the Steem Ranking

Do not miss the last post from @steemitboard:

Use your witness votes and get the Community Badge
Vote for @Steemitboard as a witness to get one more award and increased upvotes!