Взаимоисключающие отношения «многие ко многим»

9

У меня есть таблица, containersкоторая может иметь отношение многие ко многим к нескольким таблицам, скажем plants, так animalsи есть bacteria. Каждый контейнер может содержать произвольное количество растений, животных или бактерий, и каждое растение, животное или бактерия могут находиться в произвольном количестве контейнеров.

Пока это очень просто, но у меня возникла проблема с тем, что каждый контейнер должен содержать только элементы одного типа. Смешанные контейнеры, которые, например, содержат как растения, так и животных, должны быть нарушением ограничений в базе данных.

Моя оригинальная схема для этого была следующей:

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


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


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


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

Но с этой схемой я не могу придумать, как реализовать ограничение, что контейнеры должны быть однородными.

Есть ли способ реализовать это со ссылочной целостностью и обеспечить на уровне базы данных однородность контейнеров?

Я использую Postgres 9.6 для этого.

Злой ученый
источник
1
Являются ли контейнеры однородными? То есть можно ли опустошить контейнер, в котором содержатся растения сегодня, и без каких-либо изменений содержать животных или бактерии завтра?
RDFozz
@RDFozz Я не планирую допустить этого в пользовательском интерфейсе, но в принципе это было бы возможно. Это на самом деле не имеет смысла, удаление контейнера и создание нового было бы типичным действием. Но если контейнер изменил тип контента, он ничего не сломал
Mad Scientist

Ответы:

10

Существует способ реализовать это декларативно, только не сильно меняя текущие настройки, если вы согласны ввести некоторую избыточность. Последующее может считаться развитием предложения RDFozz , хотя эта идея полностью сформировалась в моей голове, прежде чем я прочитал его ответ (и в любом случае он достаточно отличается, чтобы оправдать свой собственный ответный пост).

Реализация

Вот что вы делаете, шаг за шагом:

  1. Создайте containerTypesтаблицу в соответствии с предложенной в ответе RDFozz:

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

    Заполните его предварительно определенными идентификаторами для каждого типа. В целях этого ответа, пусть они соответствуют примеру RDFozz: 1 для растений, 2 для животных, 3 для бактерий.

  2. Добавьте containerType_idстолбец containersи сделайте его ненулевым и внешним ключом.

    ALTER TABLE containers
    ADD containerType_id int NOT NULL
      REFERENCES containerTypes (id);
  3. Предполагая, что idстолбец уже является первичным ключом containers, создайте уникальное ограничение для (id, containerType_id).

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

    Вот где начинаются увольнения. Если idобъявлен первичным ключом, мы можем быть уверены, что он уникален. Если он уникален, любая комбинация idи другого столбца обязательно должна быть уникальной без дополнительного объявления уникальности - так какой смысл? Дело в том, что формально объявляя уникальную пару столбцов, мы позволяем им ссылаться , то есть быть целью ограничения внешнего ключа, о чем эта часть.

  4. Добавить containerType_idстолбец в каждой из таблиц перехода ( containers_animals, containers_plants, containers_bacteria). Сделать его внешним ключом совершенно необязательно. Важно убедиться, что столбец имеет одинаковое значение для всех строк, различное для каждой таблицы: 1 для containers_plants, 2 для containers_animals, 3 для containers_bacteria, в соответствии с описаниями в containerTypes. В каждом случае вы также можете сделать это значение по умолчанию, чтобы упростить ваши операторы вставки:

    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. В каждой из соединительных таблиц сделайте пару столбцов (container_id, containerType_id)ссылкой на ограничение внешнего ключа 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);

    Если container_idоно уже определено как ссылка containers, не стесняйтесь удалять это ограничение из каждой таблицы, так как больше не нужно.

Как это работает

Добавив столбец типа контейнера и включив его в ограничения внешнего ключа, вы подготовите механизм, предотвращающий изменение типа контейнера. Изменение типа в containersтипе было бы возможно только в том случае, если внешние ключи были определены с помощью DEFERRABLEпредложения, чего не должно быть в этой реализации.

Даже если бы они были отложенными, изменение типа все равно было бы невозможным из-за проверочного ограничения на другой стороне containersвзаимосвязи таблицы соединений. Каждая соединительная таблица допускает только один конкретный тип контейнера. Это не только предотвращает изменение типа существующими ссылками, но также предотвращает добавление неправильных ссылок на типы. То есть, если у вас есть контейнер типа 2 (животные), вы можете добавлять в него элементы только с помощью таблицы, где разрешен тип 2, то есть containers_animals, и вы не сможете добавить строки, ссылающиеся на него, скажем containers_bacteria, который принимает только контейнеры типа 3.

Наконец, ваше собственное решение иметь разные таблицы для plants, animalsи bacteriaи разные соединительные таблицы для каждого типа сущности уже делает невозможным для контейнера иметь элементы более одного типа.

Итак, все эти факторы в совокупности гарантируют, чисто декларативным образом, что все ваши контейнеры будут однородными.

Андрей М
источник
3

Один из вариантов - добавить containertype_idв Containerтаблицу. Сделайте столбец NOT NULL и внешний ключ ContainerTypeтаблицы, в котором будут записи для каждого типа элемента, который может помещаться в контейнер:

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

Чтобы убедиться, что тип контейнера не может быть изменен, создайте триггер обновления, который проверяет, было ли containertype_idобновлено, и откатывает изменения в этом случае.

Затем в триггерах вставки и обновления в таблицах ссылок контейнеров проверьте containertype_id по типу объекта в этой таблице, чтобы убедиться, что они совпадают.

Если все, что вы помещаете в контейнер, должно соответствовать типу, и тип не может быть изменен, тогда все в контейнере будет того же типа.

ПРИМЕЧАНИЕ. Поскольку триггер в таблицах ссылок определяет, что будет соответствовать, если вам необходимо иметь тип контейнера, в котором могут быть растения и животные, вы можете создать этот тип, назначить его контейнеру и проверить его. , Таким образом, вы сохраняете гибкость, если что-то изменится в какой-то момент (скажем, вы получите типы «журналы» и «книги» ...).

ПРИМЕЧАНИЕ второе: если большая часть того, что происходит с контейнерами, одинакова, независимо от того, что в них, то это имеет смысл. Если у вас происходят совершенно разные вещи (в системе, а не в нашей физической реальности) в зависимости от содержимого контейнера, то идея Эвана Кэрролла о наличии отдельных таблиц для отдельных типов контейнеров имеет вполне разумный смысл. Это решение устанавливает, что контейнеры имеют разные типы при создании, но сохраняет их в одной таблице. Если вам приходится проверять тип каждый раз, когда вы выполняете действие над контейнером, и если выполняемое вами действие зависит от типа, отдельные таблицы на самом деле могут быть быстрее и проще.

RDFozz
источник
Это способ сделать это, но есть много недостатков: для этого требуется три сканирования индекса для повторной сборки списка контейнеров / растений, это замедляет вставки, добавляя выбор в чужую таблицу, это снижает целостность, чтобы быть функцией триггеры - иногда это работает, но я бы никогда этого не пожелал, а также замедляет обновления, чтобы убедиться, что столбец не изменен. Все это говорит, я думаю, что мы работаем вокруг ментального блока больше, чем удовлетворение требований приложения, но из-за голосов я могу быть один в этом.
Эван Кэрролл
1
Мы не знаем точно, что должно произойти отсюда; если основная часть приложения сосредоточена на самих контейнерах (отправляя их, отслеживая их, размещая их в хранилищах и т. д.), то большинство запросов могут не фокусироваться на содержимом контейнеров, а только на самих контейнерах. Как я уже отмечал, есть определенные сценарии, в которых рассмотрение контейнера для растений как совершенно отличного объекта от контейнера для животных имеет смысл. ОП придется решить, с каким сценарием они столкнутся.
RDFozz
3

Если вам нужны только 2 или 3 категории (растения / метазоа / бактерии) и вы хотите смоделировать отношения XOR, возможно, «дуга» - это решение для вас. Преимущество: нет необходимости в триггерах. Примеры диаграмм можно найти [здесь] [1]. В вашей ситуации таблица «контейнеры» будет иметь 3 столбца с ограничением CHECK, что позволяет использовать либо растение, либо животное, либо бактерию.

Это, вероятно, не подходит, если в будущем будет необходимо различать множество категорий (например, роды, виды, подвиды). Однако для 2-3 групп / категорий это может помочь.

ОБНОВЛЕНИЕ: Вдохновленное предложениями и комментариями участника, другое решение, которое допускает множество таксонов (групп родственных организмов, классифицированных биологом) и избегает «определенных» имен таблиц (PostgreSQL 9.5).

Код 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) ;

Тестовые данные:

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

Тестирование:

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

Спасибо @RDFozz и @Evan Carroll и @ypercube за их вклад и терпение (чтение / исправление моих ответов).

Штефана
источник
1

Во-первых, я согласен с @RDFozz в прочтении вопроса. Однако он высказывает некоторые опасения по поводу ответа Стефана :

введите описание изображения здесь

Чтобы решить его проблемы, просто

  1. Удалить PRIMARY KEY
  2. Добавьте UNIQUEограничения для защиты от повторяющихся записей.
  3. Добавьте EXCLUSIONограничения, чтобы гарантировать, что контейнеры "однородны"
  4. Добавьте индекс, c_idчтобы обеспечить достойную производительность.
  5. Убейте любого, кто сделает это, укажите им на мой другой ответ для здравомыслия.

Вот как это выглядит,

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

Теперь вы можете иметь один контейнер с несколькими вещами, но только один тип вещи в контейнере.

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

И все это реализовано в индексах GIST.

Великая пирамида Гизы не имеет ничего о PostgreSQL.

Эван Кэрролл
источник
0

У меня есть контейнеры для таблиц, которые могут иметь отношение «многие ко многим» к нескольким таблицам, скажем, это растения, животные и бактерии.

Это плохая идея.

Но с этой схемой я не могу придумать, как реализовать ограничение, что контейнеры должны быть однородными.

И теперь ты знаешь почему. знак равно

Я считаю, что вы застряли на идее наследования от объектно-ориентированного программирования (ОО). OO Inheritance решает проблему с повторным использованием кода. В SQL избыточный код является наименьшей из наших проблем. Честность - это прежде всего. Производительность часто вторая. Мы будем наслаждаться болью для первых двух. У нас нет «времени компиляции», которое могло бы устранить затраты.

Так что просто воздержитесь от одержимости повторным использованием кода. Контейнеры для растений, животных и бактерий принципиально различны в любом месте в реальном мире. Компонент повторного использования кода "содержит вещи" просто не сделает это за вас. Разбей их на части. Мало того, что это даст вам больше целостности и производительности, но в будущем вам будет проще расширять вашу схему: в конце концов, в вашей схеме вам уже приходилось разбивать содержащиеся в ней элементы (растения, животные и т. Д.) По крайней мере, кажется, что вам придется разбить контейнеры. Вы не будете хотеть перепроектировать всю свою схему тогда.

Эван Кэрролл
источник
Разделение контейнеров переместит проблему в другую часть схемы, мне все еще нужно сослаться на контейнеры из других таблиц, и эти части также должны будут различать различные типы контейнеров.
Безумный ученый
Они будут знать, какой тип контейнера они получают, просто по таблице, в которой они находят контейнер. Я не понимаю, что вы имеете в виду? Растения ссылаются на один контейнер в plant_containersи так далее. Вещи, для которых нужен только контейнер для растений, выбирают только из plant_containersтаблицы. Вещи, которые нуждаются в любом контейнере (то есть поиск во всех типах контейнеров), могут выполнять UNION ALLна всех трех таблицах с контейнерами.
Эван Кэрролл