Crie restrição exclusiva com colunas nulas

252

Eu tenho uma tabela com este layout:

CREATE TABLE Favorites
(
  FavoriteId uuid NOT NULL PRIMARY KEY,
  UserId uuid NOT NULL,
  RecipeId uuid NOT NULL,
  MenuId uuid
)

Quero criar uma restrição única semelhante a esta:

ALTER TABLE Favorites
ADD CONSTRAINT Favorites_UniqueFavorite UNIQUE(UserId, MenuId, RecipeId);

No entanto, isso permitirá várias linhas com o mesmo (UserId, RecipeId), se MenuId IS NULL. Quero permitir que NULLem MenuIdarmazenar um favorito que tem de menu não associado, mas eu só quero, no máximo, uma dessas linhas por par usuário / receita.

As idéias que tenho até agora são:

  1. Use algum UUID codificado (como todos os zeros) em vez de nulo.
    No entanto, MenuIdhá uma restrição de FK nos menus de cada usuário, então eu teria que criar um menu "nulo" especial para cada usuário que seja um aborrecimento.

  2. Verifique a existência de uma entrada nula usando um gatilho.
    Eu acho que isso é um aborrecimento e eu gosto de evitar gatilhos sempre que possível. Além disso, não confio neles para garantir que meus dados nunca estejam em mau estado.

  3. Apenas esqueça e verifique a existência anterior de uma entrada nula no middleware ou em uma função de inserção e não tenha essa restrição.

Estou usando o Postgres 9.0.

Existe algum método que estou ignorando?

Mike Christensen
fonte
Por que é que permitirá várias linhas com o mesmo ( UserId, RecipeId), se MenuId IS NULL?
Drux

Respostas:

382

Crie dois índices parciais :

CREATE UNIQUE INDEX favo_3col_uni_idx ON favorites (user_id, menu_id, recipe_id)
WHERE menu_id IS NOT NULL;

CREATE UNIQUE INDEX favo_2col_uni_idx ON favorites (user_id, recipe_id)
WHERE menu_id IS NULL;

Dessa forma, só pode haver uma combinação de (user_id, recipe_id)onde menu_id IS NULL, implementando efetivamente a restrição desejada.

Possíveis desvantagens: você não pode ter uma referência de chave estrangeira (user_id, menu_id, recipe_id), não pode basear-se CLUSTERem um índice parcial e consultas sem uma WHEREcondição correspondente não podem usar o índice parcial. (Parece improvável que você queira uma referência ao FK com três colunas de largura - use a coluna PK).

Se você precisar de um índice completo , poderá eliminar a WHEREcondição favo_3col_uni_idxou seus requisitos ainda serão aplicados.
O índice, agora composto por toda a tabela, se sobrepõe à outra e fica maior. Dependendo das consultas típicas e da porcentagem de NULLvalores, isso pode ou não ser útil. Em situações extremas, pode até ajudar a manter os três índices (os dois parciais e o total no topo).

Além: aconselho a não usar identificadores de casos mistos no PostgreSQL .

Erwin Brandstetter
fonte
1
@Erwin Brandsetter: sobre a observação " identificadores de casos mistos ": Desde que não sejam usadas aspas duplas, o uso de identificadores de casos mistos é absolutamente bom. Não há nenhuma diferença no uso de todos os identificadores minúsculas (de novo: única se há citações são usadas)
a_horse_with_no_name
14
@a_horse_with_no_name: Presumo que você saiba que eu sei disso. Isso é realmente uma das razões que aconselham contra a sua utilização. As pessoas que não conhecem tão bem as especificidades ficam confusas, pois em outros identificadores de RDBMS são (parcialmente) sensíveis a maiúsculas e minúsculas. Às vezes as pessoas se confundem. Ou eles constroem SQL dinâmico e usam quote_ident () como deveriam e esquecem-se de passar identificadores como cadeias de letras minúsculas agora! Não use identificadores de casos mistos no PostgreSQL, se você puder evitá-lo. Tenho visto aqui vários pedidos desesperados decorrentes dessa loucura.
Erwin Brandstetter
3
@a_horse_with_no_name: Sim, isso é verdade. Mas se você pode evitá-los: não deseja identificadores mistos de maiúsculas e minúsculas . Eles não servem para nada. Se você pode evitá-los: não os use. Além disso: eles são simplesmente feios. Identidades citadas também são feias. Os identificadores SQL92 com espaços neles são um passo em falso feito por um comitê. Não os use.
wildplasser
2
@ Mike: Eu acho que você tem que falar com o comitê de padrões SQL sobre isso, boa sorte :)
mu é muito curta
1
@ buffer: O custo de manutenção e o armazenamento total são basicamente os mesmos (exceto por uma pequena sobrecarga fixa por índice). Cada linha é representada apenas em um índice. Desempenho: se seus resultados abrangerem ambos os casos, poderá ser pago um índice simples total adicional. Caso contrário, um índice parcial é normalmente mais rápido que um índice completo, principalmente devido ao tamanho menor. Adicione a condição de índice às consultas (de forma redundante) se o Postgres não descobrir que ele pode usar um índice parcial sozinho. Exemplo.
Erwin Brandstetter 15/10
75

Você pode criar um índice exclusivo com uma coalescência no MenuId:

CREATE UNIQUE INDEX
Favorites_UniqueFavorite ON Favorites
(UserId, COALESCE(MenuId, '00000000-0000-0000-0000-000000000000'), RecipeId);

Você só precisa escolher um UUID para o COALESCE que nunca ocorrerá na "vida real". Você provavelmente nunca veria um UUID zero na vida real, mas poderá adicionar uma restrição CHECK se for paranóico (e já que eles realmente querem buscá-lo ...):

alter table Favorites
add constraint check
(MenuId <> '00000000-0000-0000-0000-000000000000')
mu é muito curto
fonte
1
Isso carrega a falha (teórica) de que as entradas com menu_id = '00000000-0000-0000-0000-000000000000' podem desencadear violações únicas falsas - mas você já abordou isso em seu comentário.
Erwin Brandstetter
2
@muistooshort: Sim, essa é uma solução adequada. Simplifique para (MenuId <> '00000000-0000-0000-0000-000000000000')embora. NULLé permitido por padrão. Aliás, existem três tipos de pessoas. Os paranóicos e as pessoas que não fazem bancos de dados. O terceiro tipo ocasionalmente posta perguntas sobre SO com espanto. ;)
Erwin Brandstetter 27/11
2
@ Erwin: Você não quer dizer "os paranóicos e aqueles com bancos de dados quebrados"?
mu é muito curto
2
Essa excelente solução facilita muito a inclusão de uma coluna nula de um tipo mais simples, como número inteiro, em uma restrição exclusiva.
Markus Pscheidt
2
É verdade que um UUID não criará essa sequência específica, não apenas pelas probabilidades envolvidas, mas também porque não é um UUID válido . Um gerador UUID não está livre para usar qualquer dígito hexadecimal em qualquer posição, por exemplo, uma posição é reservada para o número da versão do UUID.
Toby 1 Kenobi
1

Você pode armazenar favoritos sem menu associado em uma tabela separada:

CREATE TABLE FavoriteWithoutMenu
(
  FavoriteWithoutMenuId uuid NOT NULL, --Primary key
  UserId uuid NOT NULL,
  RecipeId uuid NOT NULL,
  UNIQUE KEY (UserId, RecipeId)
)
ypercubeᵀᴹ
fonte
Uma ideia interessante. Isso torna a inserção um pouco mais complicada. Eu precisaria verificar se uma linha já existe FavoriteWithoutMenuprimeiro. Nesse caso, basta adicionar um link de menu - caso contrário, crio a FavoriteWithoutMenulinha primeiro e depois a vinculo a um menu, se necessário. Isso também dificulta a seleção de todos os favoritos em uma consulta: eu teria que fazer algo estranho, como selecionar todos os links de menu primeiro e depois selecionar todos os Favoritos cujos IDs não existem na primeira consulta. Não tenho certeza se gosto disso.
Mike Christensen
Não acho que a inserção seja mais complicada. Se você deseja inserir um registro NULL MenuId, insira nesta tabela. Caso contrário, para a Favoritesmesa. Mas consultar, sim, será mais complicado.
ypercubeᵀᴹ
Na verdade, raspe isso, selecionar todos os favoritos seria apenas uma única junção ESQUERDA para acessar o menu. Hmm sim isso pode ser o caminho a percorrer ..
Mike Christensen
O INSERT fica mais complicado se você deseja adicionar a mesma receita a mais de um menu, pois você tem uma restrição ÚNICA no UserId / RecipeId no FavoriteWithoutMenu. Eu precisaria criar essa linha apenas se ela já não existisse.
Mike Christensen
1
Obrigado! Esta resposta merece um +1, pois é mais uma coisa SQL pura entre bancos de dados. No entanto, neste caso, irei para a rota de índice parcial porque ela não requer alterações no meu esquema e eu gosto :)
Mike Christensen
-1

Eu acho que há um problema semântico aqui. Na minha opinião, um usuário pode ter uma (mas apenas uma ) receita favorita para preparar um menu específico. (O OP tem menu e receita misturados; se eu estiver errado: troque MenuId e RecipeId abaixo) Isso implica que {usuário, menu} deve ser uma chave exclusiva nesta tabela. E deve apontar para exatamente uma receita. Se o usuário não tiver uma receita favorita para este menu específico, nenhuma linha deverá existir para esse par de teclas {usuário, menu}. Além disso: a chave substituta (FaVouRiteId) é supérflua: chaves primárias compostas são perfeitamente válidas para tabelas de mapeamento relacional.

Isso levaria à definição de tabela reduzida:

CREATE TABLE Favorites
( UserId uuid NOT NULL REFERENCES users(id)
, MenuId uuid NOT NULL REFERENCES menus(id)
, RecipeId uuid NOT NULL REFERENCES recipes(id)
, PRIMARY KEY (UserId, MenuId)
);
wildplasser
fonte
2
Sim, isso está certo. Exceto que, no meu caso, desejo apoiar um favorito que não pertence a nenhum menu. Imagine isso como seus Favoritos no seu navegador. Você pode apenas "marcar" uma página. Ou então, você pode criar subpastas de favoritos e nomeá-los para coisas diferentes. Desejo permitir que os usuários adicionem uma receita aos favoritos ou criem subpastas dos favoritos chamados menus.
Mike Christensen
1
Como eu disse: é tudo sobre semântica. (Eu estava pensando em comida, obviamente) Ter um favorito "que não pertence a nenhum menu" não faz sentido para mim. Você não pode favorecer algo que não existe, IMHO.
wildplasser
Parece que alguma normalização de banco de dados pode ajudar. Crie uma segunda tabela que relacione receitas a menus (ou não). Embora generalize o problema e permita mais de um menu do qual uma receita possa fazer parte. Independentemente disso, a pergunta era sobre índices únicos no PostgreSQL. Obrigado.
19718 Chris