From 83e5cd2c6068ea975e61dae6dcbc538c8ea93550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moraes?= Date: Wed, 14 Oct 2026 16:59:06 -0300 Subject: [PATCH] =?UTF-8?q?Aproveita=20=C3=ADndices=20existentes=20s=C3=B3?= =?UTF-8?q?=20se=20forem=20os=20esperados=20pela=20constraint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O CREATE UNIQUE INDEX IF NOT EXISTS aceitava qualquer relação com o mesmo nome, inclusive um índice não único ou inválido, e registrava a constraint no estado do Django sem que o banco a aplicasse. Também duplicava em SQL a definição já presente no UniqueConstraint. AddConstraintSeAusente substitui o AddConstraint nas migrations 0071 e 0072. Se não houver índice com o nome da constraint, cria pelo próprio Django. Se houver um índice único e válido sobre as mesmas colunas, ele é aproveitado. Em qualquer outro caso, a migration falha com uma mensagem clara. Os testes conferem os índices antes e depois de aplicar as migrations, nos casos em que eles já existem e em que estão ausentes, e verificam a falha com um índice não único. --- .../0071_votoparlamentar_unique_constraint.py | 43 ++------- .../0072_votacao_aberta_unique_constraint.py | 39 ++------ sapl/sessao/migrations/_operacoes.py | 43 +++++++++ sapl/sessao/tests/test_votacao_nominal.py | 88 ++++++++++++++++--- 4 files changed, 134 insertions(+), 79 deletions(-) create mode 100644 sapl/sessao/migrations/_operacoes.py diff --git a/sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py b/sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py index dbddafda8..5429abd08 100644 --- a/sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py +++ b/sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py @@ -3,22 +3,7 @@ from django.db import migrations, models from django.db.models import Count -# Antes de ser renumerada no #3855, esta migration se chamava -# 0070_votoparlamentar_unique_constraint e chegou a ser aplicada em alguns -# bancos de produção. Eles já têm os índices, mas não registram a migration -# com o nome atual: o IF NOT EXISTS evita o DuplicateTable neles. -# Nos demais, o SQL é o mesmo que o AddConstraint geraria. -INDICE_UNICO_SQL = """ - CREATE UNIQUE INDEX IF NOT EXISTS "{nome}" - ON "sessao_votoparlamentar" ("parlamentar_id", "{coluna}") - WHERE "{coluna}" IS NOT NULL; -""" - - -def indice_unico(nome, coluna): - return migrations.RunSQL( - INDICE_UNICO_SQL.format(nome=nome, coluna=coluna), - 'DROP INDEX IF EXISTS "{}";'.format(nome)) +from sapl.sessao.migrations._operacoes import AddConstraintSeAusente def remove_votos_duplicados(apps, schema_editor): @@ -60,24 +45,12 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython( remove_votos_duplicados, migrations.RunPython.noop), - migrations.SeparateDatabaseAndState( - database_operations=[ - indice_unico( - 'sessao_votoparlamentar_unique_parlamentar_ordem', - 'ordem_id'), - indice_unico( - 'sessao_votoparlamentar_unique_parlamentar_expediente', - 'expediente_id'), - ], - state_operations=[ - migrations.AddConstraint( - model_name='votoparlamentar', - constraint=models.UniqueConstraint(condition=models.Q(ordem__isnull=False), fields=('parlamentar', 'ordem'), name='sessao_votoparlamentar_unique_parlamentar_ordem'), - ), - migrations.AddConstraint( - model_name='votoparlamentar', - constraint=models.UniqueConstraint(condition=models.Q(expediente__isnull=False), fields=('parlamentar', 'expediente'), name='sessao_votoparlamentar_unique_parlamentar_expediente'), - ), - ], + AddConstraintSeAusente( + model_name='votoparlamentar', + constraint=models.UniqueConstraint(condition=models.Q(ordem__isnull=False), fields=('parlamentar', 'ordem'), name='sessao_votoparlamentar_unique_parlamentar_ordem'), + ), + AddConstraintSeAusente( + model_name='votoparlamentar', + constraint=models.UniqueConstraint(condition=models.Q(expediente__isnull=False), fields=('parlamentar', 'expediente'), name='sessao_votoparlamentar_unique_parlamentar_expediente'), ), ] diff --git a/sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py b/sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py index e65c25772..f60236138 100644 --- a/sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py +++ b/sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py @@ -2,20 +2,7 @@ from django.db import migrations, models -# Mesmo caso da 0071: com o nome antigo 0071_votacao_aberta_unique_constraint -# esta migration já foi aplicada em alguns bancos de produção, que têm os -# índices sem registrar a migration com o nome atual. -INDICE_UNICO_SQL = """ - CREATE UNIQUE INDEX IF NOT EXISTS "{nome}" - ON "{tabela}" ("votacao_aberta") - WHERE "votacao_aberta" = true; -""" - - -def indice_unico(nome, tabela): - return migrations.RunSQL( - INDICE_UNICO_SQL.format(nome=nome, tabela=tabela), - 'DROP INDEX IF EXISTS "{}";'.format(nome)) +from sapl.sessao.migrations._operacoes import AddConstraintSeAusente def fecha_materias_abertas_duplicadas(apps, schema_editor): @@ -49,22 +36,12 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython( fecha_materias_abertas_duplicadas, migrations.RunPython.noop), - migrations.SeparateDatabaseAndState( - database_operations=[ - indice_unico('sessao_expedientemateria_unique_votacao_aberta', - 'sessao_expedientemateria'), - indice_unico('sessao_ordemdia_unique_votacao_aberta', - 'sessao_ordemdia'), - ], - state_operations=[ - migrations.AddConstraint( - model_name='expedientemateria', - constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_expedientemateria_unique_votacao_aberta'), - ), - migrations.AddConstraint( - model_name='ordemdia', - constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_ordemdia_unique_votacao_aberta'), - ), - ], + AddConstraintSeAusente( + model_name='expedientemateria', + constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_expedientemateria_unique_votacao_aberta'), + ), + AddConstraintSeAusente( + model_name='ordemdia', + constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_ordemdia_unique_votacao_aberta'), ), ] diff --git a/sapl/sessao/migrations/_operacoes.py b/sapl/sessao/migrations/_operacoes.py new file mode 100644 index 000000000..98c8c243e --- /dev/null +++ b/sapl/sessao/migrations/_operacoes.py @@ -0,0 +1,43 @@ +from django.db import migrations + + +class AddConstraintSeAusente(migrations.AddConstraint): + """ + AddConstraint que aproveita um índice já existente com o mesmo nome, desde + que ele seja único, válido e sobre as mesmas colunas. Se não for, falha em + vez de registrar no estado do Django uma constraint que o banco não aplica. + + Antes de serem renumeradas no #3855, as migrations 0071 e 0072 se chamavam + 0070_votoparlamentar_unique_constraint e + 0071_votacao_aberta_unique_constraint e chegaram a ser aplicadas em alguns + bancos de produção. Eles já têm os índices, mas não registram as migrations + com os nomes atuais, e o AddConstraint puro falharia com DuplicateTable. + """ + + def database_forwards(self, app_label, schema_editor, from_state, + to_state): + model = to_state.apps.get_model(app_label, self.model_name) + nome = self.constraint.name + connection = schema_editor.connection + with connection.cursor() as cursor: + existente = connection.introspection.get_constraints( + cursor, model._meta.db_table).get(nome) + if existente is not None: + cursor.execute( + 'SELECT indisvalid FROM pg_index ' + 'WHERE indexrelid = %s::regclass', [nome]) + existente['valid'] = cursor.fetchone()[0] + + if existente is None: + super().database_forwards( + app_label, schema_editor, from_state, to_state) + return + + colunas = [model._meta.get_field(campo).column + for campo in self.constraint.fields] + if not (existente['unique'] and existente['valid'] and + existente['columns'] == colunas): + raise ValueError( + 'O índice "{}" já existe, mas não é o índice único e válido ' + 'sobre ({}) esperado pela constraint. Remova-o e rode o ' + 'migrate de novo.'.format(nome, ', '.join(colunas))) diff --git a/sapl/sessao/tests/test_votacao_nominal.py b/sapl/sessao/tests/test_votacao_nominal.py index 886a0dd9a..c44d29253 100644 --- a/sapl/sessao/tests/test_votacao_nominal.py +++ b/sapl/sessao/tests/test_votacao_nominal.py @@ -667,28 +667,90 @@ def test_migracao_0072_fecha_duplicatas_antes_da_constraint(): assert encerrada.registro_aberto is False -@pytest.mark.django_db(transaction=False) -@pytest.mark.parametrize('anterior, migracao', [ +MIGRACOES_COM_INDICES_UNICOS = [ ('0070_presenca_unica_por_sessao', - '0071_votoparlamentar_unique_constraint'), + '0071_votoparlamentar_unique_constraint', + ('sessao_votoparlamentar_unique_parlamentar_ordem', + 'sessao_votoparlamentar_unique_parlamentar_expediente')), ('0071_votoparlamentar_unique_constraint', - '0072_votacao_aberta_unique_constraint'), -]) -def test_migracao_tolera_indices_ja_existentes(anterior, migracao): + '0072_votacao_aberta_unique_constraint', + ('sessao_expedientemateria_unique_votacao_aberta', + 'sessao_ordemdia_unique_votacao_aberta')), +] + + +@pytest.fixture(scope='module') +def migration_loader(): + from django.db.migrations.loader import MigrationLoader + + # Sem conexão, só lê o grafo de migrations do disco, uma vez por módulo. + return MigrationLoader(None) + + +def _definicao_indice(nome): + from django.db import connection + + with connection.cursor() as cursor: + cursor.execute( + 'SELECT indexdef FROM pg_indexes WHERE indexname = %s', [nome]) + linha = cursor.fetchone() + return linha[0] if linha else None + + +def _aplica_migracao(loader, anterior, migracao): + from django.db import connection + + estado = loader.project_state(('sessao', anterior)) + with connection.schema_editor() as schema_editor: + loader.get_migration('sessao', migracao).apply(estado, schema_editor) + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('indices_existentes', [True, False], + ids=['indices_existentes', 'indices_ausentes']) +@pytest.mark.parametrize('anterior, migracao, indices', + MIGRACOES_COM_INDICES_UNICOS, ids=['0071', '0072']) +def test_migracao_cria_ou_aproveita_indices_unicos( + migration_loader, anterior, migracao, indices, indices_existentes): """ Bancos que aplicaram estas migrations com a numeração antiga (0070_votoparlamentar_unique_constraint e 0071_votacao_aberta_unique_constraint) já têm os índices, mas não as - registram com o nome atual. Reaplicá-las não pode falhar com - DuplicateTable — o banco de teste já tem os índices, como eles. + registram com o nome atual. Reaplicá-las deve aproveitar os índices em vez + de falhar com DuplicateTable; num banco sem eles, devem criá-los iguais. """ from django.db import connection - from django.db.migrations.loader import MigrationLoader - loader = MigrationLoader(connection) - estado = loader.project_state(('sessao', anterior)) - with connection.schema_editor() as schema_editor: - loader.get_migration('sessao', migracao).apply(estado, schema_editor) + definicoes = {nome: _definicao_indice(nome) for nome in indices} + for definicao in definicoes.values(): + assert definicao.startswith('CREATE UNIQUE INDEX') + assert ' WHERE ' in definicao + + if not indices_existentes: + with connection.cursor() as cursor: + for nome in indices: + cursor.execute('DROP INDEX {}'.format(nome)) + + _aplica_migracao(migration_loader, anterior, migracao) + + assert {nome: _definicao_indice(nome) for nome in indices} == definicoes + + +@pytest.mark.django_db(transaction=False) +def test_migracao_falha_se_indice_existente_nao_for_unico(migration_loader): + from django.db import connection + + with connection.cursor() as cursor: + cursor.execute('DROP INDEX sessao_ordemdia_unique_votacao_aberta') + cursor.execute('CREATE INDEX sessao_ordemdia_unique_votacao_aberta ' + 'ON sessao_ordemdia (votacao_aberta)') + + with pytest.raises( + ValueError, + match='"sessao_ordemdia_unique_votacao_aberta" já existe'): + _aplica_migracao(migration_loader, + '0071_votoparlamentar_unique_constraint', + '0072_votacao_aberta_unique_constraint') @pytest.mark.django_db(transaction=False)