diff --git a/sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py b/sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py index f23ff2cea..5429abd08 100644 --- a/sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py +++ b/sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py @@ -3,6 +3,8 @@ from django.db import migrations, models from django.db.models import Count +from sapl.sessao.migrations._operacoes import AddConstraintSeAusente + def remove_votos_duplicados(apps, schema_editor): """Preserva o voto canônico antes de criar os índices únicos.""" @@ -43,11 +45,11 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython( remove_votos_duplicados, migrations.RunPython.noop), - migrations.AddConstraint( + AddConstraintSeAusente( model_name='votoparlamentar', constraint=models.UniqueConstraint(condition=models.Q(ordem__isnull=False), fields=('parlamentar', 'ordem'), name='sessao_votoparlamentar_unique_parlamentar_ordem'), ), - migrations.AddConstraint( + 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 0fc5e8d48..f60236138 100644 --- a/sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py +++ b/sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py @@ -2,6 +2,8 @@ from django.db import migrations, models +from sapl.sessao.migrations._operacoes import AddConstraintSeAusente + def fecha_materias_abertas_duplicadas(apps, schema_editor): """ @@ -34,11 +36,11 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython( fecha_materias_abertas_duplicadas, migrations.RunPython.noop), - migrations.AddConstraint( + AddConstraintSeAusente( model_name='expedientemateria', constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_expedientemateria_unique_votacao_aberta'), ), - migrations.AddConstraint( + 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 950ce62fe..c44d29253 100644 --- a/sapl/sessao/tests/test_votacao_nominal.py +++ b/sapl/sessao/tests/test_votacao_nominal.py @@ -667,6 +667,92 @@ def test_migracao_0072_fecha_duplicatas_antes_da_constraint(): assert encerrada.registro_aberto is False +MIGRACOES_COM_INDICES_UNICOS = [ + ('0070_presenca_unica_por_sessao', + '0071_votoparlamentar_unique_constraint', + ('sessao_votoparlamentar_unique_parlamentar_ordem', + 'sessao_votoparlamentar_unique_parlamentar_expediente')), + ('0071_votoparlamentar_unique_constraint', + '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 deve aproveitar os índices em vez + de falhar com DuplicateTable; num banco sem eles, devem criá-los iguais. + """ + from django.db import connection + + 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) def test_api_nao_permite_abrir_votacao_via_patch(admin_client): """