Restrição - uma linha booleana é verdadeira, todas as outras linhas são falsas

13

Eu tenho uma coluna: standard BOOLEAN NOT NULL

Gostaria de aplicar uma linha True e todas as outras False. Não há FKs ou qualquer outra coisa, dependendo dessa restrição. Eu sei que posso fazer isso com o plpgsql, mas isso parece uma marreta. Eu preferiria algo como um CHECKou UNIQUErestrição. Quanto mais simples, melhor.

Uma linha deve ser True, nem todas podem ser False (portanto, a primeira linha inserida precisa ser True).

A linha precisará ser atualizada, o que significa que tenho que esperar para verificar as restrições até que as atualizações sejam concluídas, pois todas as linhas podem ser definidas como False primeiro e uma linha True depois.

Existe um FK entre products.tax_rate_ide tax_rate.id, mas não tem nada a ver com a taxa de imposto padrão ou padrão, que é selecionável pelo usuário para facilitar a criação de novos produtos.

PostgreSQL 9.5, se for importante.

fundo

A tabela é a taxa de imposto. Uma das taxas de imposto é o padrão ( standardjá que o padrão é um comando do Postgres). Quando um novo produto é adicionado, a taxa de imposto padrão é aplicada ao produto. Se não houver standard, o banco de dados deve adivinhar ou todos os tipos de verificações desnecessárias. A solução simples, pensei, era garantir que exista um standard.

Por "padrão" acima, quero dizer para a camada de apresentação (UI). Existe uma opção do usuário para alterar a taxa de imposto padrão. Eu preciso adicionar verificações extras para garantir que a GUI / usuário não tente definir o tax_rate_id como NULL ou apenas defina uma taxa de imposto padrão.

theGtknerd
fonte
Então você tem a sua resposta?
Erwin Brandstetter
Sim, tenho a minha resposta, muito obrigado pela sua contribuição, @ErwinBrandstetter. Estou inclinado em direção a um gatilho por enquanto. Este é um projeto de código aberto no meu próprio tempo. Quando eu realmente implementá-lo, marcarei a resposta aceita como uso.
theGtknerd

Respostas:

15

Variante 1

Como tudo o que você precisa é de uma única coluna standard = true, defina o padrão como NULL em todas as outras linhas. Então, uma UNIQUErestrição simples funciona, pois os valores NULL não a violam:

CREATE TABLE taxrate (
   taxrate int PRIMARY KEY
 , standard bool DEFAULT true
 , CONSTRAINT standard_true_or_null CHECK (standard) -- yes, that's the whole constraint
 , CONSTRAINT standard_only_1_true UNIQUE (standard)
);

DEFAULTé um lembrete opcional de que a primeira linha inserida deve se tornar o padrão. Não está forçando nada. Embora não seja possível definir mais de uma linha standard = true, você ainda pode definir todas as linhas NULL. Não há uma maneira limpa de evitar isso com apenas restrições em uma única tabela. CHECKrestrições não consideram outras linhas (sem truques sujos).

Palavras-chave:

Atualizar:

BEGIN;
UPDATE taxrate SET standard = NULL WHERE standard;
UPDATE taxrate SET standard = TRUE WHERE taxrate = 2;
COMMIT;

Para permitir um comando como (onde a restrição é satisfeita apenas no final da instrução):

WITH kingdead AS (
   UPDATE taxrate
   SET standard = NULL
   WHERE standard
   )
UPDATE taxrate
SET standard = TRUE
WHERE taxrate = 1;

.. a UNIQUErestrição teria que ser DEFERRABLE. Vejo:

dbfiddle aqui

Variante 2

Tenha uma segunda tabela com uma única linha como:

Crie isso como superusuário:

CREATE TABLE taxrate (
   taxrate int PRIMARY KEY
);

CREATE TABLE taxrate_standard (
   taxrate int PRIMARY KEY REFERENCES taxrate
);

CREATE UNIQUE INDEX taxrate_standard_singleton ON taxrate_standard ((true));  -- singleton

REVOKE DELETE ON TABLE taxrate_standard FROM public;  -- can't delete

INSERT INTO taxrate (taxrate) VALUES (42);
INSERT INTO taxrate_standard (taxrate) VALUES (42);

Agora, há sempre uma única linha apontando para o padrão (neste caso simples, também representando diretamente a taxa padrão). Somente um superusuário poderia quebrá-lo. Você também pode não permitir isso com um gatilho BEFORE DELETE.

dbfiddle aqui

Palavras-chave:

Você pode adicionar a VIEWpara ver o mesmo da variante 1 :

CREATE VIEW taxrate_combined AS
SELECT t.*, (ts.taxrate = t.taxrate) AS standard
FROM   taxrate t
LEFT   JOIN taxrate_standard ts USING (taxrate);

Nas consultas em que tudo o que você deseja é a taxa padrão, use (somente) taxrate_standard.taxratediretamente.


Você adicionou mais tarde:

Existe um FK entre products.tax_rate_idetax_rate.id

A implementação de uma pessoa pobre da variante 2 seria apenas adicionar uma linha products(ou qualquer tabela semelhante) apontando para a taxa de imposto padrão; um produto fictício que você pode chamar de "Taxa tributária padrão" - se sua configuração permitir.

As restrições FK reforçam a integridade referencial. Para concluir, imponha tax_rate_id IS NOT NULLa linha (se esse não for o caso da coluna em geral). E não permitir sua exclusão. Ambos podem ser acionados. Nenhuma mesa extra, mas menos elegante e menos confiável.

Erwin Brandstetter
fonte
2
Altamente recomendável a abordagem de dois mesa. Eu também sugeriria adicionar um exemplo de consulta a essa variação para que o OP possa ver o CROSS JOINpadrão, LEFT JOINo específico e, em seguida, COALESCEentre os dois.
Jpmc26
2
+1, tive a mesma ideia sobre a mesa extra, mas não tive tempo para escrever uma resposta adequadamente. Sobre a primeira tabela e o CONSTRAINT standard_only_1_true UNIQUE (standard): Suponho que a tabela não será grande, portanto não importa muito, mas como a restrição definirá um índice em toda a tabela, um índice exclusivo parcial não WHERE (standard)utilizará menos espaço?
ypercubeᵀᴹ
@ ypercubeᵀᴹ: Sim, o índice em toda a tabela é maior, isso é uma desvantagem para esta variante. Mas, como você disse: é obviamente uma mesa pequena, por isso dificilmente importa. Eu estava buscando a solução padrão mais simples, com apenas restrições. Prova de conceito. Pessoalmente, estou com jpmc26 e favorecem fortemente variante 2.
Erwin Brandstetter
9

Você pode usar um índice filtrado

create table test
(
    id int primary key,
    foo bool
);
CREATE UNIQUE INDEX only_one_row_with_column_true_uix 
    ON test (foo) WHERE (foo);  --> where foo is true
insert into test values (1, false);
insert into test values (2, true);
insert into test values (3, false);
insert into test values (4, false);
insert into test values (5, true);
ERRO: o valor duplicado da chave viola a restrição exclusiva "only_one_row_with_column_true_uix"
DETALHE: Chave (foo) = (t) já existe.

dbfiddle aqui


Mas como você disse, a primeira linha deve ser verdadeira, então você pode usar uma restrição CHECK, mas mesmo usando uma função, você pode excluir a primeira linha posteriormente.

create function check_one_true(new_foo bool)
returns int as
$$
begin
    return 
    (
        select count(*) + (case new_foo when true then 1 else 0 end)
        from test 
        where foo = true
    );
end
$$
language plpgsql stable;
alter table test 
    add constraint ck_one_true check(check_one_true(foo) = 1); 
insert into test values (1, true);
insert into test values (2, false);
insert into test values (3, false);
insert into test values (4, false);
insert into test values (5, true);
ERRO: a nova linha da relação "teste" viola a restrição de verificação "ck_one_true"
DETALHE: A linha com falha contém (5, t).

select * from test;
id | foo
-: | : -
 1 | t  
 2 f  
 3 f  
 4 f  
delete from test where id = 1;

dbfiddle aqui


Você pode resolvê-lo adicionando um gatilho BEFORE DELETE para garantir que a primeira linha (foo seja verdadeira) nunca seja excluída.

create function dont_delete_foo_true()
returns trigger as
$x$
begin
    if old.foo then
        raise exception 'Can''t delete row where foo is true.';
    end if;
    return old;
end;
$x$ language plpgsql;
create trigger trg_test_delete
before delete on test
for each row 
execute procedure dont_delete_foo_true();
delete from test where id = 1;

ERRO: Não é possível excluir a linha onde foo é verdadeiro.

dbfiddle aqui

McNets
fonte