Changer la clé primaire d'un modèle avec django

 02 octobre 2019   Sylvain

Catégorie / Mot clefs: Database / Postgresql, Django.


Suite à un mauvais choix au niveau du modèle de données, on peut être amené à vouloir changer la clé primaire d'un modèle.

Opération pas si simple que ça si ce modèle est référencé par des clés étrangères dans d'autres modèles.

Nous vous proposons de la réaliser en 6 étapes, pour cela nous allons partir d'une simple application data avec deux modèles :

from django.db import models


class Category(models.Model):
    name = models.CharField(max_length=32)


class Item(models.Model):
    category = models.ForeignKey(
        Category,
        related_name='items',
        on_delete=models.CASCADE
    )

1. Category: création de la nouvelle clé

La première étape consiste à créer un nouveau champ qui deviendra la nouvelle clé primaire à la fin. Dans notre exemple on ajoute un champ de type uuid.

Malheureusement on ne peut pas créer directement un champ uuid unique :

+import uuid

 from django.db import models


 class Category(models.Model):
    ...
+   uuid = models.UUIDField(
+       max_length=36,
+       unique=True,
+       default=uuid.uuid4,
+       editable=False
+   )

Avec la migration suivante:

class Migration(migrations.Migration):

    dependencies = [
        ('data', '0001_initial'),
    ]

    operations = [
        migrations.AddField(
            model_name='category',
            name='uuid',
            field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
        ),
    ]

Nous aurions cette erreur pendant la migration:

django.db.utils.IntegrityError: could not create unique index "data_category_uuid_key"
DETAIL:  Key (uuid)=(xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) is duplicated.

Comme expliqué dans la documentation django:

L’application d’une « simple » migration qui ajoute un champ unique non nul à une table qui contient déjà des enregistrements, va produire une erreur. Car la valeur utilisée pour remplir les enregistrements existants n’est générée qu’une seule fois, ce qui va à l’encontre de la contrainte d’unicité.

Nous allons donc devoir éditer la migration pour créer un champ de type uuid avec null=True, renseigner ce champ puis le passer à null=False.

def create_uuid(apps, schema_editor):
    Category = apps.get_model('data', 'Category')
    for category in Category.objects.all():
        category.uuid = uuid.uuid4()
        category.save()
class Migration(migrations.Migration):

    dependencies = [
        ('data', '0001_initial'),
    ]

    operations = [
        migrations.AddField(
            model_name='category',
            name='uuid',
            field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
        ),
        migrations.RunPython(create_uuid, reverse_code=migrations.RunPython.noop),
        migrations.AlterField(
            model_name='category',
            name='uuid',
            field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
        )
    ]

2. Item: transformer la clé étrangère category en IntegerField

 class  Item(models.Model):
-    category = models.ForeignKey(
-        Category,
-        related_name='items',
-        on_delete=models.CASCADE
-    )
+    category = models.IntegerField()
class Migration(migrations.Migration):

    dependencies = [
        ('data', '0002_...'),
    ]

    operations = [
        migrations.AlterField(
            model_name='item',
            name='category',
            field=models.IntegerField(),
        ),
    ]

3. Category: le champ uuid devient la clé primaire

Passer le champ uuid du modèle Category en clé primaire, sans oublier d'ajouter le champ id=models.IntegerField() pour éviter que django le supprime automatiquement.

 class Category(models.Model):
     uuid = models.UUIDField(
         max_length=36,
-        unique=True,
+        primary_key=True,
         default=uuid.uuid4,
         editable=False
     )
+    id = models.IntegerField()
class Migration(migrations.Migration):

    dependencies = [
        ('data', '0003_...'),
    ]

    operations = [
        migrations.AlterField(
            model_name='category',
            name='id',
            field=models.IntegerField(),
        ),
        migrations.AlterField(
            model_name='category',
            name='uuid',
            field=models.UUIDField(
                default=uuid.uuid4,
                editable=False,
                primary_key=True,
                serialize=False
            ),
        ),
    ]

4. Item: renommer le champ category en category_old

 class  Item(models.Model):
-    category = models.IntegerField()
+    category_old = models.IntegerField()
class Migration(migrations.Migration):

    dependencies = [
        ('data', '0004_...'),
    ]

    operations = [
        migrations.RenameField(
            model_name='item',
            old_name='category',
            new_name='category_old',
        ),
    ]

5. Item: ajouter la clé étrangère category

Ajouter la clé étrangère category avec null=True et le remplir à l'aide du champ category_old.

 class  Item(models.Model):
     ...
+    category = models.ForeignKey(
+        Category,
+        related_name='items',
+        on_delete=models.CASCADE,
+        null=True
+    )
def copy_category_uuid(apps, schema_editor):
    Item = apps.get_model('data', 'Item')
    Category = apps.get_model('data', 'Category')
    category_uuid = Category.objects.filter(id=models.OuterRef('category_old')) \
                                    .values_list('uuid')[:1]
    Item.objects.update(category=models.Subquery(category_uuid))


class Migration(migrations.Migration):

    dependencies = [
        ('data', '0005_auto_20191002_0909'),
    ]

    operations = [
        migrations.AddField(
            model_name='item',
            name='category',
            field=models.ForeignKey(
                null=True,
                on_delete=django.db.models.deletion.CASCADE,
                related_name='items',
                to='data.Category'
            ),
        ),
        migrations.RunPython(
            copy_category_uuid,
            reverse_code=migrations.RunPython.noop
        ),
    ]

6. Fin !

6ème et dernière étape, il ne reste plus qu'à supprimer les champs inutiles des modèles et que la clé étrangère category du modèle Item soit à null=False.

 class Category(models.Model):
     name = models.CharField(max_length=32)
     uuid = models.UUIDField(
         max_length=36,
         primary_key=True,
         default=uuid.uuid4,
         editable=False
     )
-    id = models.IntegerField()


 class  Item(models.Model):
-    category_old = models.IntegerField()
     category = models.ForeignKey(
         Category,
         related_name='items',
         on_delete=models.CASCADE,
-        null=True
     )