Relations plusieurs à plusieurs mutuellement exclusives

9

J'ai une table containersqui peut avoir une relation plusieurs à plusieurs avec plusieurs tables, disons que ce sont plants, animalset bacteria. Chaque conteneur peut contenir un nombre arbitraire de plantes, d'animaux ou de bactéries, et chaque plante, animal ou bactérie peut se trouver dans un nombre arbitraire de conteneurs.

Jusqu'à présent, c'est très simple, mais la partie avec laquelle je rencontre un problème est que chaque conteneur ne doit contenir que des éléments du même type. Les conteneurs mixtes contenant par exemple à la fois des plantes et des animaux devraient constituer une violation des contraintes dans la base de données.

Mon schéma d'origine pour cela était le suivant:

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


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


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


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

Mais avec ce schéma, je ne peux pas trouver comment implémenter la contrainte que les conteneurs doivent être homogènes.

Existe-t-il un moyen de l'implémenter avec une intégrité référentielle et de garantir au niveau de la base de données que les conteneurs sont homogènes?

J'utilise Postgres 9.6 pour cela.

Scientifique fou
la source
1
Les conteneurs sont-ils homogènes? C'est-à-dire, un conteneur qui contient des plantes aujourd'hui peut-il être vidé et, sans aucun changement, contenir des animaux ou des bactéries demain?
RDFozz
@RDFozz Je n'ai pas l'intention de permettre cela dans l'interface utilisateur, mais en principe, ce serait possible. Cela n'a pas vraiment de sens de le faire, la suppression du conteneur et la création d'un nouveau serait l'action typique. Mais si un conteneur changeait le type de contenu, il ne casserait rien
Mad Scientist

Réponses:

10

Il existe un moyen de l'implémenter de manière déclarative uniquement sans modifier considérablement votre configuration actuelle, si vous acceptez d'y introduire une certaine redondance. Ce qui suit peut être considéré comme un développement de la suggestion de RDFozz , bien que l'idée se soit pleinement formée dans mon esprit avant de lire sa réponse (et elle est suffisamment différente pour justifier sa propre réponse de toute façon).

la mise en oeuvre

Voici ce que vous faites, étape par étape:

  1. Créez un containerTypestableau dans le sens de celui suggéré dans la réponse de RDFozz:

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

    Remplissez-le avec des ID prédéfinis pour chaque type. Aux fins de cette réponse, laissez-les correspondre à l'exemple de RDFozz: 1 pour les plantes, 2 pour les animaux, 3 pour les bactéries.

  2. Ajoutez une containerType_idcolonne à containerset rendez-la non nullable et une clé étrangère.

    ALTER TABLE containers
    ADD containerType_id int NOT NULL
      REFERENCES containerTypes (id);
    
  3. En supposant que la idcolonne est déjà la clé primaire de containers, créez une contrainte unique sur (id, containerType_id).

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

    C'est là que les licenciements commencent. Si idest déclaré être la clé primaire, nous pouvons être assurés qu'il est unique. S'il est unique, toute combinaison de idet une autre colonne est aussi nécessairement unique sans déclaration supplémentaire d'unicité - alors, quel est le point? Le fait est qu'en déclarant formellement la paire de colonnes unique, nous les laissons être référencables , c'est-à-dire être la cible d'une contrainte de clé étrangère, ce qui est le sujet de cette partie.

  4. Ajouter une containerType_idcolonne à chacune des tables de jonction ( containers_animals, containers_plants, containers_bacteria). En faire une clé étrangère est complètement facultatif. Ce qui est crucial est de s'assurer que la colonne a la même valeur pour toutes les lignes, différente pour chaque table: 1 pour containers_plants, 2 pour containers_animals, 3 pour containers_bacteria, selon les descriptions de containerTypes. Dans chaque cas, vous pouvez également définir cette valeur par défaut pour simplifier vos instructions d'insertion:

    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. Dans chacune des tables de jonction, faites de la paire de colonnes (container_id, containerType_id)une référence de contrainte de clé étrangère 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);
    

    Si container_idest déjà défini comme référence containers, n'hésitez pas à supprimer cette contrainte de chaque table car elle n'est plus nécessaire.

Comment ça fonctionne

En ajoutant la colonne de type de conteneur et en la faisant participer aux contraintes de clé étrangère, vous préparez un mécanisme empêchant le type de conteneur de changer. Changer le type dans le containerstype ne serait possible que si les clés étrangères étaient définies avec la DEFERRABLEclause, qu'elles ne sont pas censées être dans cette implémentation.

Même s'ils pouvaient être reportés, le changement de type serait toujours impossible en raison de la contrainte de vérification de l'autre côté de la containersrelation entre la table de jonction. Chaque table de jonction autorise un seul type de conteneur spécifique. Cela empêche non seulement les références existantes de modifier le type, mais empêche également l' ajout de références de type incorrectes. Autrement dit, si vous avez un conteneur de type 2 (animaux), vous ne pouvez y ajouter des éléments qu'en utilisant le tableau où le type 2 est autorisé, ce qui est containers_animals, et ne serait pas en mesure d'ajouter des lignes le référençant, disons containers_bacteria, qui accepte uniquement des conteneurs de type 3.

Enfin, votre décision d'avoir des tables différentes pour plants, animalset bacteria, et différentes tables de jonction pour chaque type d'entité, rend déjà impossible pour un conteneur d'avoir des éléments de plus d'un type.

Ainsi, tous ces facteurs combinés garantissent, de manière purement déclarative, que tous vos conteneurs seront homogènes.

Andriy M
la source
3

Une option consiste à ajouter un containertype_idà la Containertable. Faites de la colonne NOT NULL, et une clé étrangère vers une ContainerTypetable, qui aurait des entrées pour chaque type d'élément pouvant aller dans un conteneur:

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

Pour vous assurer que le type de conteneur ne peut pas être modifié, créez un déclencheur de mise à jour qui vérifie si le a containertype_idété mis à jour et annule la modification dans ce cas.

Ensuite, dans les déclencheurs d'insertion et de mise à jour de vos tables de liens de conteneur, vérifiez le containerertype_id par rapport au type d'entité dans cette table, pour vous assurer qu'ils correspondent.

Si tout ce que vous mettez dans un conteneur doit correspondre au type et que le type ne peut pas être modifié, alors tout dans le conteneur sera du même type.

REMARQUE: Étant donné que le déclencheur sur les tables de liens détermine ce qui correspond, si vous aviez besoin d'un type de conteneur pouvant contenir des plantes et des animaux, vous pouvez créer ce type, l'affecter au conteneur et vérifier cela. . Ainsi, vous conservez la flexibilité si les choses changent à un moment donné (par exemple, vous obtenez les types "magazines" et "livres" ...).

REMARQUE la seconde: si la plupart de ce qui arrive aux conteneurs est le même, indépendamment de ce qu'ils contiennent, cela a du sens. Si vous avez des choses très différentes qui se produisent (dans le système, pas dans notre réalité physique) en fonction du contenu du conteneur, alors l'idée d'Evan Carroll d'avoir des tables séparées pour les types de conteneurs distincts est parfaitement logique. Cette solution établit que les conteneurs ont différents types lors de la création, mais les conserve dans la même table. Si vous devez vérifier le type à chaque fois que vous effectuez une action sur un conteneur, et si l'action que vous effectuez dépend du type, des tables séparées peuvent en fait être plus rapides et plus faciles.

RDFozz
la source
C'est une façon de le faire, mais il y a beaucoup d'inconvénients: cela nécessite trois analyses d'index pour réassembler la liste des conteneurs / plantes, il ralentit les insertions en ajoutant une sélection dans une table étrangère, il réduit l'intégrité à être une fonction de déclencheurs - parfois cela fonctionne mais je ne le désirerais jamais, cela ralentit également les mises à jour pour s'assurer que la colonne n'est pas modifiée. Cela dit, je pense que nous travaillons plus autour du blocage mental que pour répondre aux demandes d'une application, mais d'après les votes, je suis peut-être seul à cela.
Evan Carroll
1
Nous ne savons pas exactement ce qui doit arriver d'ici; si la majeure partie de l'application se concentre sur les conteneurs eux-mêmes (les expédier, les suivre, les localiser dans des installations de stockage, etc.), la plupart des requêtes peuvent ne pas être axées sur le contenu des conteneurs, uniquement sur les conteneurs eux-mêmes. Comme je l'ai noté, il existe certainement des scénarios où le traitement d'un conteneur de plantes comme une entité entièrement différente d'un conteneur d'animaux est logique. OP devra décider du scénario auquel il doit faire face.
RDFozz
3

Si vous n'avez besoin que de 2 ou 3 catégories (plantes / métazoaires / bactéries) et que vous souhaitez modéliser une relation XOR, peut-être qu'un "arc" est la solution pour vous. Avantage: pas besoin de déclencheurs. Des exemples de diagrammes peuvent être trouvés [ici] [1]. Dans votre situation, la table "containers" aurait 3 colonnes avec une contrainte CHECK, permettant soit une plante, soit un animal, soit une bactérie.

Cela n'est probablement pas approprié s'il sera nécessaire de faire la distinction entre de nombreuses catégories (par exemple genres, espèces, sous-espèces) à l'avenir. Cependant, pour 2-3 groupes / catégories, cela peut faire l'affaire.

MISE À JOUR: Inspirée par les suggestions et commentaires du contributeur, une solution différente qui permet de nombreux taxons (groupes d'organismes apparentés, classés par biologiste), et évite les noms de table "spécifiques" (PostgreSQL 9.5).

Code 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) ;

Données de test:

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');

Essai:

-- 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');

Merci à @RDFozz et @Evan Carroll et @ypercube pour leur contribution et leur patience (lecture / correction de mes réponses).

Stefan
la source
1

Tout d'abord, je suis d'accord avec @RDFozz sur la lecture de la question .. Cependant, il soulève quelques inquiétudes sur la réponse de Stefan ,

entrez la description de l'image ici

Pour répondre à ses préoccupations,

  1. Retirer le PRIMARY KEY
  2. Ajoutez les UNIQUEcontraintes pour vous protéger contre les entrées en double.
  3. Ajouter des EXCLUSIONcontraintes pour garantir que les conteneurs sont "homogènes"
  4. Ajoutez un index c_idpour garantir des performances décentes.
  5. Tuez tous ceux qui font cela, dirigez-les vers mon autre réponse pour raison.

Voici à quoi cela ressemble,

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);

Maintenant, vous pouvez avoir un conteneur avec plusieurs choses, mais un seul type de chose dans un conteneur.

# 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).

Et tout est implémenté sur les index GIST.

La Grande Pyramide de Gizeh n'a rien sur PostgreSQL.

Evan Carroll
la source
0

J'ai une table conteneurs qui peuvent avoir une relation plusieurs à plusieurs avec plusieurs tables, disons que ce sont des plantes, des animaux et des bactéries.

C'est une mauvaise idée.

Mais avec ce schéma, je ne peux pas trouver comment implémenter la contrainte que les conteneurs doivent être homogènes.

Et maintenant tu sais pourquoi. =)

Je crois que vous êtes coincé sur l'idée de l'héritage de la programmation orientée objet (OO). OO Inheritance résout un problème de réutilisation de code. En SQL, le code redondant est le moindre de nos problèmes. L'intégrité est d'abord et avant tout. La performance est souvent deuxième. Nous savourerons de douleur pour les deux premiers. Nous n'avons pas de «temps de compilation» qui puisse éliminer les coûts.

Donc, renoncez à votre obsession pour la réutilisation du code. Les conteneurs pour plantes, animaux et bactéries sont fondamentalement différents partout dans le monde réel. Le composant de réutilisation de code de "détient des trucs" ne le fera tout simplement pas pour vous. Brisez-les. Non seulement vous obtiendrez plus d'intégrité et plus de performances, mais à l'avenir, vous trouverez plus facile d'étendre votre schéma: après tout, dans votre schéma, vous deviez déjà séparer les éléments contenus (plantes, animaux, etc.) , semble au moins possible que vous deviez briser les conteneurs. Vous n'allez pas vouloir repenser tout votre schéma alors.

Evan Carroll
la source
Le fractionnement des conteneurs déplacerait le problème vers une autre partie du schéma, j'ai encore besoin de référencer les conteneurs d'autres tables et ces parties devraient également distinguer les différents types de conteneurs.
Mad Scientist
Ils sauraient dans quel type de conteneur ils se trouvent simplement par la table dans laquelle ils trouvent le conteneur. Je ne comprends pas ce que vous voulez dire? Les plantes font référence à un seul conteneur plant_containers, etc. Les choses qui n'ont besoin que d'un conteneur de plantes ne sont sélectionnées que dans le plant_containerstableau. Les choses qui ont besoin de n'importe quel conteneur (c'est-à-dire la recherche de tous les types de conteneurs) peuvent faire UNION ALLsur les trois tables avec des conteneurs.
Evan Carroll