typenil

typenil

developer

typenil

developer

Hijacking Default Django 'Through' Tables

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.

Join me in becoming a little less terrible every day.