Relaciones de muchos a muchos mutuamente excluyentes

9

Tengo una tabla containersque puede tener una relación de muchos a muchos con varias tablas, digamos que son plants, animalsy bacteria. Cada contenedor puede contener un número arbitrario de plantas, animales o bacterias, y cada planta, animal o bacteria puede estar en un número arbitrario de contenedores.

Hasta ahora, esto es muy sencillo, pero la parte con la que tengo un problema es que cada contenedor solo debe contener elementos del mismo tipo. Los recipientes mixtos que, por ejemplo, contienen plantas y animales deberían ser una violación de restricciones en la base de datos.

Mi esquema original para esto fue el siguiente:

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


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


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


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

Pero con este esquema, no se me ocurre cómo implementar la restricción de que los contenedores deben ser homogéneos.

¿Hay alguna manera de implementar esto con integridad referencial y asegurar en el nivel de la base de datos que los contenedores sean homogéneos?

Estoy usando Postgres 9.6 para esto.

Científico loco
fuente
1
¿Son los envases homogéneos? Es decir, ¿se puede vaciar un recipiente que contiene plantas hoy y, sin ningún cambio, contener animales o bacterias mañana?
RDFozz
@RDFozz No tengo planes para permitir eso en la interfaz de usuario, pero en principio sería posible. Realmente no tiene sentido hacer eso, eliminar el contenedor y crear uno nuevo sería la acción típica. Pero si un contenedor cambiara el tipo de contenido, no rompería nada
Mad Scientist

Respuestas:

10

Hay una manera de implementar esto declarativamente solo sin cambiar mucho su configuración actual, si acepta introducir algo de redundancia. Lo que sigue puede considerarse un desarrollo en la sugerencia de RDFozz , aunque la idea se formó completamente en mi mente antes de leer su respuesta (y de todos modos es lo suficientemente diferente como para justificar su propia publicación de respuestas).

Implementación

Esto es lo que haces, paso a paso:

  1. Cree una containerTypestabla similar a la sugerida en la respuesta de RDFozz:

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

    Rellene con ID predefinidos para cada tipo. Para el propósito de esta respuesta, permítales que coincidan con el ejemplo de RDFozz: 1 para plantas, 2 para animales, 3 para bacterias.

  2. Agregue una containerType_idcolumna containersy hágala no anulable y una clave foránea.

    ALTER TABLE containers
    ADD containerType_id int NOT NULL
      REFERENCES containerTypes (id);
  3. Suponiendo que la idcolumna ya es la clave principal de containers, cree una restricción única en (id, containerType_id).

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

    Aquí es donde comienzan las redundancias. Si idse declara que es la clave principal, podemos estar seguros de que es única. Si es único, cualquier combinación de idy otra columna también será única sin una declaración adicional de unicidad, entonces, ¿cuál es el punto? El punto es que al declarar formalmente el par de columnas único, dejamos que sean referibles , es decir, que sean el objetivo de una restricción de clave externa, que es de lo que trata esta parte.

  4. Añadir una containerType_idcolumna para cada una de las tablas de unión ( containers_animals, containers_plants, containers_bacteria). Convertirlo en una clave externa es completamente opcional. Lo crucial es asegurarse de que la columna tenga el mismo valor para todas las filas, diferente para cada tabla: 1 para containers_plants, 2 para containers_animals, 3 para containers_bacteria, de acuerdo con las descripciones en containerTypes. En cada caso, también puede hacer que ese valor sea el predeterminado para simplificar sus declaraciones de inserción:

    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. En cada una de las tablas de unión, haga del par de columnas (container_id, containerType_id)una referencia de restricción de clave externa 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_idya se definió como una referencia containers, no dude en eliminar esa restricción de cada tabla, ya que ya no es necesario.

Cómo funciona

Al agregar la columna de tipo de contenedor y hacer que participe en las restricciones de clave externa, prepara un mecanismo que evita que cambie el tipo de contenedor. Cambiar el tipo en el containerstipo sería posible solo si las claves externas se definieran con la DEFERRABLEcláusula, que no se supone que estén en esta implementación.

Incluso si fueran diferibles, cambiar el tipo seguiría siendo imposible debido a la restricción de verificación en el otro lado de la containersrelación de la tabla de funciones. Cada tabla de unión permite solo un tipo de contenedor específico. Eso no solo evita que las referencias existentes cambien el tipo, sino que también evita la adición de referencias de tipo incorrecta. Es decir, si tiene un contenedor de tipo 2 (animales), solo puede agregarle elementos usando la tabla donde está permitido el tipo 2, que es containers_animals, y no podría agregar filas que lo hagan referencia, digamos containers_bacteria, que acepta solo contenedores tipo 3.

Finalmente, su propia decisión de tener diferentes tablas para plants, animalsy bacteria, y diferentes tablas de unión para cada tipo de entidad, ya hace imposible que un contenedor tenga elementos de más de un tipo.

Por lo tanto, todos estos factores combinados aseguran, de una manera puramente declarativa, que todos sus contenedores serán homogéneos.

Andriy M
fuente
3

Una opción es agregar containertype_ida la Containertabla. Haga que la columna NO sea NULL y que sea una clave externa para una ContainerTypetabla, que tendría entradas para cada tipo de elemento que puede ir en un contenedor:

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

Para asegurarse de que el tipo de contenedor no se puede cambiar, cree un activador de actualización que verifique si containertype_idse actualizó y revierte el cambio en ese caso.

Luego, en los desencadenadores de inserción y actualización en sus tablas de enlaces de contenedor, compruebe el containertype_id con el tipo de entidad en esa tabla, para asegurarse de que coincidan.

Si lo que pones en un contenedor tiene que coincidir con el tipo, y el tipo no se puede cambiar, entonces todo en el contenedor será del mismo tipo.

NOTA: Dado que el disparador en las tablas de enlaces es lo que decidirá qué coincide, si necesita tener un tipo de contenedor que pueda contener plantas y animales, puede crear ese tipo, asignarlo al contenedor y verificarlo . Por lo tanto, conserva la flexibilidad si las cosas cambian en algún momento (por ejemplo, obtiene los tipos "revistas" y "libros" ...).

OBSERVE el segundo: si la mayor parte de lo que sucede con los contenedores es igual, independientemente de lo que haya en ellos, entonces tiene sentido. Si suceden cosas muy diferentes (en el sistema, no en nuestra realidad física) en función del contenido del contenedor, entonces la idea de Evan Carroll de tener tablas separadas para los tipos de contenedores separados tiene mucho sentido. Esta solución establece que los contenedores tienen diferentes tipos en el momento de la creación, pero los mantiene en la misma tabla. Si tiene que verificar el tipo cada vez que realiza una acción en un contenedor, y si la acción que realiza depende del tipo, las tablas separadas en realidad podrían ser más rápidas y fáciles.

RDFozz
fuente
Es una forma de hacerlo, pero hay muchas desventajas: para hacer esto se requieren tres escaneos de índice para volver a ensamblar la lista de contenedores / plantas, ralentiza las inserciones al agregar una selección en una tabla extranjera, reduce la integridad a ser una función de disparadores: a veces eso funciona, pero nunca lo desearía, también ralentiza las actualizaciones para asegurarse de que la columna no se modifique. Dicho todo esto, creo que estamos trabajando en el bloqueo mental más que en satisfacer las demandas de una aplicación, pero por los votos puedo estar solo en eso.
Evan Carroll
1
No sabemos exactamente qué debe suceder a partir de aquí; Si la mayor parte de la aplicación se enfoca en los contenedores en sí mismos (enviándolos, rastreándolos, ubicándolos en instalaciones de almacenamiento, etc.), la mayoría de las consultas podrían no centrarse en el contenido de los contenedores, solo en los contenedores mismos. Como señalé, definitivamente hay escenarios en los que tratar un contenedor de plantas como una entidad completamente diferente de un contenedor de animales tiene sentido. OP tendrá que decidir a qué escenario se enfrentan.
RDFozz
3

Si solo necesita 2 o 3 categorías (plantas / metazoos / bacterias) y desea modelar una relación XOR, tal vez un "arco" sea la solución para usted. Ventaja: no hay necesidad de disparadores. Se pueden encontrar diagramas de ejemplo [aquí] [1]. En su situación, la tabla de "contenedores" tendría 3 columnas con una restricción CHECK, permitiendo una planta, animal o bacteria.

Esto probablemente no sea apropiado si será necesario distinguir entre muchas categorías (por ejemplo, géneros, especies, subespecies) en el futuro. Sin embargo, para 2-3 grupos / categorías esto puede ser el truco.

ACTUALIZACIÓN: Inspirada en las sugerencias y comentarios del contribuyente, una solución diferente que permite muchos taxones (grupos de organismos relacionados, clasificados por biólogo) y evita nombres de tabla "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) ;

Datos de prueba:

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

Pruebas:

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

Gracias a @RDFozz y @Evan Carroll y @ypercube por su aporte y paciencia (leyendo / corrigiendo mis respuestas).

stefan
fuente
1

Primero, estoy de acuerdo con @RDFozz en la lectura de la pregunta. Sin embargo, él plantea algunas preocupaciones sobre la respuesta de stefans ,

ingrese la descripción de la imagen aquí

Para abordar sus preocupaciones, solo

  1. Eliminar el PRIMARY KEY
  2. Agregue las UNIQUErestricciones para proteger contra entradas duplicadas.
  3. Agregue EXCLUSIONrestricciones para garantizar que los contenedores sean "homogéneos"
  4. Agregue un índice c_idpara garantizar un rendimiento decente.
  5. Mata a cualquiera que haga esto, muéstrales mi otra respuesta por cordura.

Así es como se ve,

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

Ahora puede tener un contenedor con varias cosas, pero solo un tipo de cosas en un contenedor.

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

Y todo está implementado en índices GIST.

La Gran Pirámide de Giza no tiene nada en PostgreSQL.

Evan Carroll
fuente
0

Tengo una tabla de contenedores que pueden tener una relación de muchos a muchos con varias tablas, digamos que son plantas, animales y bacterias.

Esa es una mala idea.

Pero con este esquema, no se me ocurre cómo implementar la restricción de que los contenedores deben ser homogéneos.

Y ahora ya sabes por qué. =)

Creo que está estancado en la idea de herencia de la programación orientada a objetos (OO). La herencia OO resuelve un problema con la reutilización de código. En SQL, el código redundante es el menor de nuestros problemas. La integridad es ante todo. El rendimiento es a menudo el segundo. Nos deleitará el dolor por los dos primeros. No tenemos un "tiempo de compilación" que pueda eliminar los costos.

Así que simplemente renuncie a su obsesión por la reutilización del código. Los contenedores para plantas, animales y bacterias son fundamentalmente diferentes en todos los lugares del mundo real. El componente de reutilización de código de "contiene cosas" simplemente no lo hará por usted. Romperlos aparte. No solo le dará más integridad y más rendimiento, sino que en el futuro le resultará más fácil expandir su esquema: después de todo, en su esquema ya tuvo que separar los elementos que están contenidos (plantas, animales, etc.) , parece al menos posible que tengas que romper los contenedores. Entonces no querrá rediseñar todo su esquema.

Evan Carroll
fuente
Dividir los contenedores movería el problema a una parte diferente del esquema, todavía necesito hacer referencia a los contenedores de otras tablas y esas partes también tendrían que distinguir los diferentes tipos de contenedores.
Científico loco
Sabrían qué tipo de contenedor tienen simplemente por la mesa en la que encuentran el contenedor. ¿Estoy confundido con lo que quieres decir? Las plantas hacen referencia a un solo contenedor plant_containers, y así sucesivamente. Las cosas que solo necesitan un contenedor de plantas se seleccionan solo de la plant_containerstabla. Las cosas que necesitan cualquier contenedor (es decir, buscar todos los tipos de contenedores) pueden hacer UNION ALLen las tres tablas con contenedores.
Evan Carroll