As built-in migrations do Django

Quem usa o Django há mais tempo já ouviu falar do South. Famosa biblioteca responsável por trazer o comportamento de migrations para o Django. Sem dúvida impactou inúmeros projetos e transformou o processo de deploy de toda a comunidade envolvida com o framework.

Nas versões mais recentes do Django, contamos com um engine built-in, muito prático, e que traz alguns conceitos que ficaram famosos com o South.

Vamos nessa dar adeus ao syncdb, e começar a nossa aventura com o migrate.

O que são migrations?

Diretamente da documentação do South:

(...) são uma forma de alterar o schema do seu banco de dados de uma versão para outra.

Você mantém em seu projeto uma série de instruções que modificam o database de acordo com a evolução do projeto. Quando uma nova pessoa fizer parte do time, não é necessário a realização de nenhum "dump" SQL, apenas a execução dessas migrations é o suficiente para você ter o db pronto para uso.

Dentro do ecossistema Django, as built-in migrations vieram como uma excelente contribuição ao projeto. A distribuição de apps fica mais coerente, agora que o desenvolvedor não precisa mais de uma "segunda biblioteca" para passar a usar um app que estamos distribuindo.

Faça migrations

Para exemplificar como toda essa mágica funciona, vamos criar um app chamado "listadecompras":

$ python manage.py startapp lista_de_compras

Nosso application terá uma estrutura similar a essa:

lista_de_compras/
    __init__.py
    admin.py
    apps.py
    migrations/
        __init__.py
    models.py
    tests.py
    views.py

Não esqueça de adicioná-lo ao INSTALLED_APPS.

Vamos nos focar na pasta migrations e no models.py. Nesse último, criaremos o modelo Item:

# models.py

class Item(models.Model):
    nome = models.CharField(max_length=255)
    quantidade = models.IntegerField(default=1)

Agora, na linha de comando, invocamos o comando de criação de migrations:

$ python manage.py makemigrations

Se tudo ocorreu bem, na pasta migrations do app, um novo arquivo Python foi criado:

# migrations/0001_initial.py

from __future__ import unicode_literals
from django.db import migrations, models


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='Item',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('nome', models.CharField(max_length=255)),
                ('quantidade', models.IntegerField(default=1)),
            ],
        ),
    ]

Por partes:

O unicode_literals é uma ferramenta que torna possível a execução de seu projeto em Python 2 e 3. Ela tem a função de "marcar" suas strings como unicode, como explicado na documentação do Django.

É criada (automaticamente) uma classe Migration herdando de migrations.Migration. Nela, temos 3 propriedades muito importantes:

  • initial: Informamos explicitamente ao Django que essa é a migration inicial do app. Essa informação é importante para alguns casos, como quando é preciso executar o --fake-initial.
  • dependencies: Aqui informamos as dependências da nossa migration. Por exemplo, se dependermos de outra migration ou app, precisamos deixar claro que há uma dependência para manter uma ordem coerente de execução.
  • operations: Por fim, as operações que precisam ser executadas. No exemplo acima, estamos criando a tabela lista_de_compras_item, com os campos id, nome e quantidade.

No more syncdb

Calma! A tabela ainda não existe no banco dados. Para que isso aconteça é necessário executar o comando migrate:

$ python manage.py migrate

migrate executará todas as migrations pendentes do seu projeto. Caso queira executar apenas de um app:

$ python manage.py migrate lista_de_compras

Evoluindo o Schema

Vamos supor que exista o requisito de criar um API REST para o projeto, logo, expor o campo id não é uma boa prática! Precisamos de um campo único e não sequencial, que trará um pouco de obfuscation e segurança ao nosso endpoint.

Primeiro, adicionamos o campo ao modelo:

# models.py

class Item(models.Model):
    uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
    nome = models.CharField(max_length=255)
    quantidade = models.IntegerField(default=1)

Seguindo o procedimento, para homologar as alterações em uma migration, precisamos do makemigrations:

$ python manage.py makemigrations lista_de_compras

Se você executar o migrate em um banco de dados vazio, provavelmente não terá nenhum problema. Mas em um banco já existente, provavelmente uma exception acontecerá. O fato é que o uso do campo unique e o campo default, em uma migration, não é tão intuitivo como parece.

"Migrar é preciso (indiewire.com)"
Migrar é preciso (indiewire.com)

Para fazer esse procedimento, vamos seguir as recomendações da documentação do Django.

Primeiro, removemos os atributos unique=True e default=uuid.uuid4 da migration recém criada, e adicionamos o parâmetro null=True:

# migrations/0002_item_uuid.py

class Migration(migrations.Migration):

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

    operations = [
        migrations.AddField(
            model_name='item',
            name='uuid',
            field=models.UUIDField(editable=False, null=True),
        ),
    ]

O próximo passo é popular as linhas já existentes com uuids. Faremos isso através de data migrations.

Data migrations

Diretamente da documentação do Django:

Migrations that alter data are usually called “data migrations”; they’re best written as separate migrations, sitting alongside your schema migrations.

Em alguns casos precisamos inserir ou alterar dados, de acordo com a evolução do database do nosso projeto. O Django não possui nenhuma forma "automática" para geração de data migrations, por isso, o caminho ideal é utilizar o comando --empty:

$ python manage.py makemigrations --empty lista_de_compras

Com isso, uma migration "em branco" será criada, pronta para customização:

# migrations/0003_auto_20161010_1635.py

class Migration(migrations.Migration):

    dependencies = [
        ('lista_de_compras', '0002_item_uuid'),
    ]

    operations = [
    ]

Importante notar que agora o atributo dependencies está preenchido com a migration anterior à recém criada. É sempre necessário ter atenção à sua lista de dependências, para certificar-se que o fluxo acontecerá numa ordem coerente.

Criaremos a função responsável por inserir UUIDs nos registros já existentes:

# migrations/0003_auto_20161010_1635.py
import uuid

def gen_uuid(apps, schema_editor):
    Item = apps.get_model('lista_de_compras', 'Item')
    items = Item.objects.all()
    for item in items:
        item.uuid = uuid.uuid4()
        item.save()

Usamos o parâmetro apps para "importar" o modelo. Na sequência, utilizamos o ORM para pegar todos os items do banco de dados, iteramos por eles preenchendo o campo uuid.

Precisamos adicionar essa função ao atributo operations, da migration:

# migrations/0003_auto_20161010_1635.py

class Migration(migrations.Migration):

    dependencies = [
        ('lista_de_compras', '0002_item_uuid'),
    ]

    operations = [
        migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
    ]

Utilizamos a função RunPython para executar operações em nossa migration que dependem de processamento por parte da linguagem (ou do Django). Em alguns cenários, esse tipo de operação não tem um processo de rollback, portanto, deixamos explícito que não temos como desfazer a operação através do parâmetro reverse_code com valor migrations.RunPython.noop.

A migration deve ter ficado mais ou menos assim:

# migrations/0003_auto_20161010_1635.py
from __future__ import unicode_literals

import uuid
from django.db import migrations, transaction


def gen_uuid(apps, schema_editor):
    Item = apps.get_model('lista_de_compras', 'Item')
    items = Item.objects.all()
    for item in items:
        item.uuid = uuid.uuid4()
        item.save()


class Migration(migrations.Migration):

    dependencies = [
        ('lista_de_compras', '0002_item_uuid'),
    ]

    operations = [
        migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
    ]

Ainda não acabou! Uma vez que todos os registros já possuam o seu uuid preenchido, precisamos voltar à proposta original do nosso modelo: Com um campo default, e com preenchimento obrigatório. Para tanto, basta executar novamente o makemigrations. Uma nova migration será criada, com todas as constraints definidas corretamente.

Agora rode o migrate. Acabamos de criar um campo uuid, preenchemos os registros já existentes no database, e criamos constraints para os registros que virão no futuro.

Até a próxima.

Referências