Relacionamentos muitos para muitos mutuamente exclusivos

9

Eu tenho uma tabela containersque pode ter um relacionamento muitos-para-muitos com várias tabelas, digamos que sejam plants, animalse bacteria. Cada recipiente pode conter um número arbitrário de plantas, animais ou bactérias, e cada planta, animal ou bactéria pode estar em um número arbitrário de recipientes.

Até agora, isso é muito simples, mas a parte com a qual estou tendo problemas é que cada contêiner deve conter apenas elementos do mesmo tipo. Recipientes misturados que contêm plantas e animais, por exemplo, devem constituir uma violação de restrição no banco de dados.

Meu esquema original para isso foi o seguinte:

containers
----------
id
...
...


containers_plants
-----------------
container_id
plant_id


containers_animals
------------------
container_id
animal_id


containers_bacteria
-------------------
container_id
bacterium_id

Mas, com esse esquema, não consigo descobrir como implementar a restrição de que os contêineres devem ser homogêneos.

Existe uma maneira de implementar isso com integridade referencial e garantir no nível do banco de dados que os contêineres são homogêneos?

Estou usando o Postgres 9.6 para isso.

Cientista maluco
fonte
11
Os contêineres são homogêneos? Ou seja, um recipiente que contém plantas hoje pode ser esvaziado e, sem nenhuma alteração, conter animais ou bactérias amanhã?
RDFozz
@RDFozz Não tenho planos de permitir isso na interface do usuário, mas, em princípio, seria possível. Realmente não faz sentido fazer isso, excluir o contêiner e criar um novo seria a ação típica. Mas se um recipiente mudou o tipo de conteúdo, ele não iria quebrar nada
Mad Scientist

Respostas:

10

Existe uma maneira de implementar isso declarativamente apenas sem alterar muito sua configuração atual, se você concordar em introduzir alguma redundância nela. O que se segue pode ser considerado um desenvolvimento da sugestão de RDFozz , embora a ideia tenha se formado completamente em minha mente antes de ler sua resposta (e é diferente o suficiente para garantir sua própria resposta).

Implementação

Aqui está o que você faz, passo a passo:

  1. Crie uma containerTypestabela ao longo da linha sugerida na resposta do RDFozz:

    CREATE TABLE containerTypes
    (
      id int PRIMARY KEY,
      description varchar(30)
    );
    

    Preencha-o com IDs predefinidos para cada tipo. Para os fins desta resposta, eles devem corresponder ao exemplo de RDFozz: 1 para plantas, 2 para animais, 3 para bactérias.

  2. Adicione uma containerType_idcoluna containerse torne-a não anulável e uma chave estrangeira.

    ALTER TABLE containers
    ADD containerType_id int NOT NULL
      REFERENCES containerTypes (id);
    
  3. Supondo que a idcoluna já seja a chave primária de containers, crie uma restrição exclusiva (id, containerType_id).

    ALTER TABLE containers
    ADD CONSTRAINT UQ_containers_id_containerTypeId
      UNIQUE (id, containerType_id);
    

    É aqui que os despedimentos começam. Se idfor declarada a chave primária, podemos ter certeza de que é única. Se for único, qualquer combinação de ide outra coluna também será única, sem declaração adicional de exclusividade - então, qual é o sentido? O ponto é que, ao declarar formalmente o par de colunas exclusivo, permitimos que eles sejam referenciáveis , ou seja, para ser o alvo de uma restrição de chave estrangeira, que é a parte desta parte.

  4. Adicionar uma containerType_idcoluna a cada uma das tabelas de junção ( containers_animals, containers_plants, containers_bacteria). Tornar uma chave estrangeira é completamente opcional. O que é crucial é garantir que a coluna tenha o mesmo valor para todas as linhas, diferente para cada tabela: 1 para containers_plants, 2 para containers_animals, 3 para containers_bacteria, de acordo com as descrições em containerTypes. Em cada caso, você também pode tornar esse valor o padrão para simplificar suas instruções de inserção:

    ALTER TABLE containers_plants
    ADD containerType_id NOT NULL
      DEFAULT (1)
      CHECK (containerType_id = 1);
    
    ALTER TABLE containers_animals
    ADD containerType_id NOT NULL
      DEFAULT (2)
      CHECK (containerType_id = 2);
    
    ALTER TABLE containers_bacteria
    ADD containerType_id NOT NULL
      DEFAULT (3)
      CHECK (containerType_id = 3);
    
  5. Em cada uma das tabelas de junção, torne o par de colunas (container_id, containerType_id)uma referência de restrição de chave estrangeira containers.

    ALTER TABLE containers_plants
    ADD CONSTRAINT FK_containersPlants_containers
      FOREIGN KEY (container_id, containerType_id)
      REFERENCES containers (id, containerType_id);
    
    ALTER TABLE containers_animals
    ADD CONSTRAINT FK_containersAnimals_containers
      FOREIGN KEY (container_id, containerType_id)
      REFERENCES containers (id, containerType_id);
    
    ALTER TABLE containers_bacteria
    ADD CONSTRAINT FK_containersBacteria_containers
      FOREIGN KEY (container_id, containerType_id)
      REFERENCES containers (id, containerType_id);
    

    Se container_idjá estiver definido como uma referência containers, sinta-se à vontade para remover essa restrição de cada tabela, pois não é mais necessária.

Como funciona

Adicionando a coluna de tipo de contêiner e fazendo com que ela participe das restrições de chave estrangeira, você prepara um mecanismo que impede a alteração do tipo de contêiner. Alterar o tipo no containerstipo seria possível apenas se as chaves estrangeiras fossem definidas com a DEFERRABLEcláusula, que elas não deveriam estar nesta implementação.

Mesmo se eles fossem adiados, a alteração do tipo ainda seria impossível devido à restrição de verificação do outro lado do containersrelacionamento da tabela de funções. Cada tabela de junção permite apenas um tipo de contêiner específico. Isso não apenas impede que as referências existentes alterem o tipo, mas também impede a adição de referências de tipo incorretas. Ou seja, se você tiver um contêiner do tipo 2 (animais), poderá adicionar itens a ele apenas usando a tabela em que o tipo 2 é permitido, ou seja containers_animals, e não seria possível adicionar linhas referenciando-o a, digamos containers_bacteria, que aceite apenas recipientes do tipo 3.

Finalmente, sua própria decisão de ter tabelas diferentes para plants, animalse bacteria, e tabelas de junção diferentes para cada tipo de entidade, já torna impossível para um contêiner ter itens de mais de um tipo.

Portanto, todos esses fatores combinados garantem, de maneira puramente declarativa, que todos os seus contêineres serão homogêneos.

Andriy M
fonte
3

Uma opção é adicionar containertype_ida à Containertabela. Torne a coluna NOT NULL e uma chave estrangeira para uma ContainerTypetabela, que teria entradas para cada tipo de item que pode ir em um contêiner:

containertype_id |   type
-----------------+-----------
        1        | plant
        2        | animal
        3        | bacteria

Para garantir que o tipo de contêiner não possa ser alterado, crie um gatilho de atualização que verifique se o containertype_idfoi atualizado e reverta a alteração nesse caso.

Em seguida, ao inserir e atualizar gatilhos nas tabelas de links de contêiner, verifique o containertype_id com o tipo de entidade nessa tabela, para garantir que eles correspondam.

Se o que você colocar em um contêiner precisar corresponder ao tipo, e o tipo não puder ser alterado, tudo no contêiner será do mesmo tipo.

NOTA: Como o gatilho nas tabelas de links é o que decidirá quais correspondências, se você precisar de um tipo de contêiner que possa conter plantas e animais, é possível criar esse tipo, atribuí-lo ao contêiner e verificar se esse . Portanto, você mantém a flexibilidade se as coisas mudarem em algum momento (digamos, você obtém os tipos "revistas" e "livros" ...).

OBSERVE a segunda: se a maior parte do que acontece com os contêineres é a mesma, independentemente do que está neles, isso faz sentido. Se você tem coisas muito diferentes que acontecem (no sistema, e não em nossa realidade física) com base no conteúdo do contêiner, a ideia de Evan Carroll de ter tabelas separadas para os tipos de contêineres faz muito sentido. Esta solução estabelece que os contêineres têm tipos diferentes na criação, mas os mantém na mesma tabela. Se você precisar verificar o tipo toda vez que executar uma ação em um contêiner, e se a ação que você executar depender do tipo, tabelas separadas poderão realmente ser mais rápidas e fáceis.

RDFozz
fonte
É uma maneira de fazer isso, mas há muitas desvantagens: fazer isso exige três varreduras de índice para remontar a lista de contêineres / plantas, diminui as inserções adicionando um select em uma tabela estrangeira, reduz a integridade a ser uma função de gatilhos - às vezes isso funciona, mas eu nunca desejaria, mas também retarda as atualizações para garantir que a coluna não seja modificada. Tudo isso dito, acho que estamos trabalhando em torno do bloqueio mental mais do que atender às demandas de um aplicativo, mas pelos votos posso estar sozinho nisso.
Evan Carroll
11
Não sabemos exatamente o que precisa acontecer daqui; se a maior parte do aplicativo estiver focada nos próprios contêineres (enviando-os, rastreando-os, localizando-os em instalações de armazenamento etc.), a maioria das consultas poderá não estar focada no conteúdo dos contêineres, apenas nos próprios contêineres. Como observei, há definitivamente cenários em que faz sentido tratar um contêiner de plantas como uma entidade completamente diferente de um contêiner de animais. O OP terá que decidir qual cenário eles enfrentam.
RDFozz
3

Se você precisa apenas de 2 ou 3 categorias (plantas / metazoa / bactérias) e deseja modelar um relacionamento XOR, talvez um "arco" seja a solução para você. Vantagem: não há necessidade de gatilhos. Diagramas de exemplo podem ser encontrados [aqui] [1]. Na sua situação, a tabela "containers" teria 3 colunas com uma restrição CHECK, permitindo uma planta ou animal ou bactéria.

Provavelmente isso não é apropriado se houver a necessidade de distinguir entre muitas categorias (por exemplo, gêneros, espécies, subespécies) no futuro. No entanto, para 2-3 grupos / categorias, isso pode funcionar.

ATUALIZAÇÃO: Inspirada nas sugestões e comentários do colaborador, uma solução diferente que permite muitos táxons (grupos de organismos relacionados, classificados pelo biólogo) e evita nomes de tabelas "específicos" (PostgreSQL 9.5).

Código DDL:

-- containers: may have more columns eg for temperature, humidity etc
create table containers ( 
  ctr_name varchar(64) unique
);

-- taxonomy - have as many taxa as needed (not just plants/animals/bacteria)
create table taxa ( 
  t_name varchar(64) unique
);

create table organisms (
  o_id integer primary key
, o_name varchar(64)
, t_name varchar(64) references taxa(t_name)
, unique (o_id, t_name) 
);

-- table for mapping containers to organisms and (their) taxon, 
-- each container contains organisms of one and the same taxon
create table collection ( 
  ctr_name varchar(64) references containers(ctr_name)
, o_id integer 
, t_name varchar(64) 
, unique (ctr_name, o_id)
);

--  exclude : taxa that are different from those already in a container
alter table collection
add exclude using gist (ctr_name with =, t_name with <>);

--  FK : is the o_id <-> t_name (organism-taxon) mapping correct?
alter table collection
add constraint taxon_fkey
foreign key (o_id, t_name) references organisms (o_id, t_name) ;

Dados de teste:

insert into containers values ('container_a'),('container_b'),('container_c');
insert into taxa values('t:plant'),('t:animal'),('t:bacterium');
insert into organisms values 
(1, 'p1', 't:plant'),(2, 'p2', 't:plant'),(3, 'p3', 't:plant'),
(11, 'a1', 't:animal'),(22, 'a1', 't:animal'),(33, 'a1', 't:animal'),
(111, 'b1', 't:bacterium'),(222, 'b1', 't:bacterium'),(333, 'b1', 't:bacterium');

Teste:

-- several plants can be in one and the same container (3 inserts succeed)
insert into collection values ('container_a', 1, 't:plant');
insert into collection values ('container_a', 2, 't:plant');
insert into collection values ('container_a', 3, 't:plant');
-- 3 inserts that fail:
-- organism id in a container must be UNIQUE
insert into collection values ('container_a', 1, 't:plant');
-- bacteria not allowed in container_a, populated by plants (EXCLUSION at work)
insert into collection values ('container_a', 333, 't:bacterium');
-- organism with id 333 is NOT a plant -> insert prevented by FK
insert into collection values ('container_a', 333, 't:plant');

Agradeço a @RDFozz e @Evan Carroll e @ypercube por sua contribuição e paciência (lendo / corrigindo minhas respostas).

Stefan
fonte
1

Primeiro, concordo com o @RDFozz na leitura da pergunta. No entanto, ele levanta algumas preocupações sobre a resposta dos stefans ,

insira a descrição da imagem aqui

Para resolver suas preocupações, apenas

  1. Remova o PRIMARY KEY
  2. Adicione as UNIQUErestrições para proteger contra entradas duplicadas.
  3. Adicione EXCLUSIONrestrições para garantir que os contêineres sejam "homogêneos"
  4. Adicione um índice c_idpara garantir um desempenho decente.
  5. Mate qualquer um que faça isso, aponte-os para minha outra resposta por sanidade.

Aqui está o que parece,

CREATE TABLE container ( 
  c_id int NOT NULL,
  p_id int,
  b_id int,
  a_id int,
  UNIQUE (c_id,p_id),
  UNIQUE (c_id,b_id),
  UNIQUE (c_id,a_id),
  EXCLUDE USING gist(c_id WITH =, (CASE WHEN p_id>0 THEN 1 ELSE 0 END) WITH <>),
  EXCLUDE USING gist(c_id WITH =, (CASE WHEN b_id>0 THEN 1 ELSE 0 END) WITH <>),
  EXCLUDE USING gist(c_id WITH =, (CASE WHEN a_id>0 THEN 1 ELSE 0 END) WITH <>),
  CHECK (
    ( p_id IS NOT NULL and b_id IS NULL and a_id IS NULL ) 
    OR ( p_id IS NULL and b_id IS NOT NULL and a_id IS NULL ) 
    OR ( p_id IS NULL and b_id IS NULL and a_id IS NOT NULL ) 
  )
);
CREATE INDEX ON container (c_id);

Agora você pode ter um contêiner com várias coisas, mas apenas um tipo de coisa em um contêiner.

# INSERT INTO container (c_id,p_id,b_id) VALUES (1,1,null);
INSERT 0 1
# INSERT INTO container (c_id,p_id,b_id) VALUES (1,null,2);
ERROR:  conflicting key value violates exclusion constraint "container_c_id_case_excl"
DETAIL:  Key (c_id, (
CASE
    WHEN p_id > 0 THEN 1
    ELSE 0
END))=(1, 0) conflicts with existing key (c_id, (
CASE
    WHEN p_id > 0 THEN 1
    ELSE 0
END))=(1, 1).

E tudo é implementado nos índices GIST.

A Grande Pirâmide de Gizé não tem nada no PostgreSQL.

Evan Carroll
fonte
0

Eu tenho um recipiente de tabela que pode ter um relacionamento muitos-para-muitos com várias tabelas, digamos que sejam plantas, animais e bactérias.

Essa é uma péssima ideia.

Mas, com esse esquema, não consigo descobrir como implementar a restrição de que os contêineres devem ser homogêneos.

E agora você sabe o porquê. =)

Acredito que você esteja preso à idéia de herança da programação orientada a objetos (OO). A herança de OO resolve um problema com a reutilização de código. No SQL, código redundante é o menor dos nossos problemas. A integridade é antes de tudo. O desempenho costuma ser o segundo. Sentiremos prazer nos dois primeiros. Não temos um "tempo de compilação" que pode eliminar os custos.

Portanto, apenas abandone sua obsessão pela reutilização de código. Recipientes para plantas, animais e bactérias são fundamentalmente diferentes em todos os lugares do mundo real. O componente de reutilização de código de "retém coisas" simplesmente não fará isso por você. Divida-os. Não apenas isso lhe proporcionará mais integridade e mais desempenho, mas, no futuro, você achará mais fácil expandir seu esquema: afinal, no seu esquema, você já teve que separar os itens contidos (plantas, animais, etc.) , parece pelo menos possível que você precise separar os contêineres. Você não vai querer redesenhar todo o seu esquema então.

Evan Carroll
fonte
A divisão dos contêineres moveria o problema para uma parte diferente do esquema, ainda preciso fazer referência aos contêineres de outras tabelas e essas partes precisariam distinguir os diferentes tipos de contêineres.
Mad Scientist
Eles saberiam em que tipo de contêiner obtiveram apenas a mesa onde encontram o contêiner. Estou confuso com o que você quer dizer? As plantas fazem referência a um único contêiner plant_containerse assim por diante. Coisas que precisam apenas de um contêiner de planta são selecionadas apenas da plant_containerstabela. Coisas que precisam de qualquer contêiner (ou seja, pesquisando todos os tipos de contêineres) podem fazer UNION ALLnas três tabelas com contêineres.
Evan Carroll