Я использую Prisma и Postgresql для проекта по хранению рецептов и управлению ими.
Я изо всех сил пытаюсь реализовать связь «многие ко многим», где я могу ограничить одну сторону требованием хотя бы одного значения для поля отношения.
Рецепты могут иметь один или несколько ароматов. Ароматы могут быть включены в ноль или более рецептов.
Я использую явную таблицу отношений, потому что мне нужно хранить в ней дополнительные данные (количество каждого аромата в рецепте).
Модели рецепта, аромата и отношений следующие:
model Recipe {
id Int @id @default(autoincrement())
name String
base String? @default("50/50")
description String? @default("aucune description")
rating Int? @default(1)
aromas RecipeToAromas[]
}
model Aroma {
id Int @id @default(autoincrement())
name String
brand Brand? @relation(fields: [brandId], references: [id])
brandId Int? @default(1)
recipes RecipeToAromas[]
@@unique([name, brandId], name: "aromaIdentifier")
}
model RecipeToAromas {
id Int @id @default(autoincrement())
recipeId Int
aromaId Int
quantityMl Int
recipe Recipe @relation(fields: [recipeId], references: [id])
aroma Aroma @relation(fields: [aromaId], references: [id])
}
Я хочу ограничить рецепты, чтобы в них был хотя бы один аромат.
По определению «Многие ко многим» определяет отношение «ноль ко многим».
Я подумал о решении проблемы добавлением дополнительного отношения «Один ко многим» между Рецептом и Ароматом.
Это будет означать добавление дополнительного поля аромата в Рецепт для хранения одного требуемого аромата (и переименуйте поле ароматов в дополнительныеАромасы, чтобы избежать путаницы):
model Recipe {
id Int @id @default(autoincrement())
name String
base String? @default("50/50")
description String? @default("aucune description")
rating Int? @default(1)
aromas RecipeToAromas[]
aroma Aroma @relation(fields: [aromaId], references: [id])
aromaId Int
}
И добавление поля рецепта в Aroma, необходимое для установления связи:
model Aroma {
id Int @id @default(autoincrement())
name String
brand Brand? @relation(fields: [brandId], references: [id])
brandId Int? @default(1)
recipes RecipeToAromas[]
recipe Recipe[]
@@unique([name, brandId], name: "aromaIdentifier")
}
Но это неправильно, поскольку у меня будут дубликаты: рецепты и поля рецептов в Aroma будут хранить идентичные данные.
** Редактировать ** Я попытался решить проблему, используя это решение, оно создает вторую проблему: Каждый аромат в рецепте должен быть уникальным в этом рецепте (это отражается соединением @unique в реляционной базе данных).
Если я добавлю связь «Один ко многим» между «Рецептом» и «Ароматом», то аромат можно будет сохранить в рецепте более одного раза:
await prisma.recipe.create({
data: {
name: "First recipe",
aromaId: 1,
aromas: {
create: [
{ aromaId: 1, quantityMl: 2 },
{ aromaId: 2, quantityMl: 2 },
{ aromaId: 3, quantityMl: 2 },
],
},
},
});
Конечно, я мог бы обойти эту проблему, просто полагаясь на проверку функций мутации и пользовательский ввод. И, вероятно, попытайтесь добавить уровень безопасности с помощью типов, поскольку я использую машинописный текст. Но я чувствую, что это сделает базу данных хрупкой и подверженной ошибкам, особенно если мне придется сотрудничать с другими разработчиками или даже использовать базу данных в другом проекте.
Я не смог найти ни одного ресурса, освещающего подобную ситуацию, и, конечно, я потратил много времени на поиск и перечитывание документации.
Я новичок в Prisma (начал вчера), и у меня не так уж много опыта работы с СУРБД, поэтому кажется, что я что-то упускаю.
Привет, спасибо за ответ. Должен признаться, я удивлен, поскольку ожидал, что это очень распространенная закономерность. Думаю, на данный момент я буду полагаться на проверку входных данных, а позже углублюсь в SQL.



![Безумие обратных вызовов в javascript [JS]](https://i.imgur.com/WsjO6zJb.png)


Таким образом, вашу проблему можно решить двумя способами (с использованием необработанного SQL), один лучше другого. Также я собираюсь использовать синтаксис PostgreSQL, поскольку я не знаком с призмой, но я уверен, что в лучшем случае это можно перевести в модель ORM или в худшем случае вставить в Postgres как необработанные операторы SQL.
Использование триггеров
Триггеры связаны с конкретными таблицами и будут запускаться, когда в этой таблице выполняется определенное действие. В нашем случае мы хотели бы запустить его после того, как Recipe получит новый элемент. Синтаксис будет следующим (также не стесняйтесь читать документацию здесь, так как это довольно сложная тема, которую невозможно описать в нескольких предложениях)
CREATE OR REPLACE FUNCTION consistency_check_recipe() RETURNS TRIGGER AS $$
BEGIN
IF NOT EXISTS (SELECT * FROM RecipeToAromas WHERE recipeId = NEW.id)
THEN RETURN NULL;
ELSE RETURN NEW;
END IF;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER recipe_aroma_minum_check
BEFORE INSERT ON Recipe
FOR EACH ROW EXECUTE FUNCTION consistency_check_recipe();
Чтобы понять приведенный выше синтаксис, вам необходимо понять, как работают триггеры в Postgres. Триггеры бывают разных форм, и триггеры могут выполняться до или после применения оператора к базе данных (даже если он не зафиксирован) или даже до того, как транзакция будет зафиксирована.
Вышеупомянутый триггер настроен на вызов, когда новая строка вставляется в таблицу Recipe. Именно по этой причине триггеры считаются связанными с определенной таблицей, наш триггер настроен на вызов непосредственно перед каждой вставкой кортежа (как указано к триггеру уровня инструкций, который в основном используется для аудита) и до выполнения любых других проверок. И оттуда триггер выполнит созданную нами функцию. В этой функции важно отметить тот факт, что она возвращает TRIGGER. Это требование для postgres, и даже функции, которые не должны возвращать ничего, кроме void, должны иметь это в своем определении.
Еще следует отметить, что функция имеет доступ к значениям из кортежа, который поймал триггер, это предоставляется нам через конструкцию NEW для триггеров INSERT и UPDATE и конструкцию OLD для UPDATE и DELETE. В нашем примере эта конструкция предоставляет нам новый кортеж, который необходимо вставить. Он структурирован точно как таблица, поэтому мы можем проверить идентификатор, который установлен как вставленный, и посмотреть, ссылается ли он на него в отношении RecipeToAromas.
Если на него нет ссылки, мы можем вернуть значение Null, чтобы указать триггеру, что эта строка не должна быть вставлена, или в противном случае мы можем отправить новую строку, которая будет вставлена вместо нее, или, если мы хотим вставить исходную строку, мы можем просто отправьте это через конструкцию NEW, или мы могли бы даже изменить конструкцию NEW и отправить ее для вставки.
Этот пример очень хорошо работает для предотвращения вставки строк, не входящих в отношение RecipeToAromas, но создает еще одну проблему. Если мы хотим добавить рецепт, он должен присутствовать хотя бы в одном кортеже RecipeToAromas. Это создает проблему курицы и яйца. Поскольку одно требует присутствия другого, и наоборот.
Чтобы решить эту проблему, мы можем рассмотреть другие типы триггеров, особенно те, которые выполняются после оператора INSERT и после применения всех других ограничений, не связанных с триггерами. Однако это все еще не дает особой пользы, поскольку триггер обрабатывается после оператора, а не после транзакции.
Чтобы решить эту проблему, нам следует рассмотреть триггеры CONSTRAINT. Эти триггеры почти одинаковы, но они предлагают возможность отложить их выполнение до конца транзакции.
Вот их синтаксис
CREATE OR REPLACE FUNCTION consistency_check_recipe() RETURNS TRIGGER AS $$
BEGIN
IF NOT EXISTS (SELECT * FROM RecipeToAromas WHERE recipeId = NEW.ID) THEN
RAISE EXCEPTION 'Must have at least 1 Aroma';
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER recipe_aroma_check
AFTER INSERT OR UPDATE ON Recipe
INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION consistency_check_recipe();
Они указываются с помощью ключевого слова CONSTRAINT, мы также дополнительно указываем уважение (то есть, когда функция будет выполнена) с помощью инструкции INITIALLY DEFERRED (это не единственное возможное значение и не единственный способ сделать это, проверьте документацию, там есть еще оооочень) В нашем случае это означает, что функция будет выполнена только в конце блока транзакции. Что позволяет нам сделать это:
BEGIN;
INSERT INTO Recipe
VALUES (1, 'Borsh', 'Tasty Water', 'Food Nothing more', 100);
INSERT INTO RecipeToAromas
VALUES (DEFAULT, 1, 3, 4);
COMMIT;
Сначала мы вставляем новое значение Recipe, но поскольку триггер выполняется только в конце коммита, мы добавляем его в базу данных, а также добавляем ссылку через RecipeToAromas, и только тогда выполняется триггер функции, и в этот момент мы видим, что Рецепт действительно упоминается в RecipeToAromas.
Несколько вещей, на которые следует обратить внимание:
О чем следует помнить: обновления/удаления в RecipeToAromas или в Отношения рецептов могут нарушить согласованность данных, поэтому важно помнить об этом и разрабатывать на основе этого другие триггеры/вашу базу данных.
Это недешевая операция, которая может нанести ущерб производительности. Можно использовать обычные методы оптимизации запросов, а также некоторые другие, указанные в документации.
Это довольно большая тема, поэтому, если вы собираетесь что-то делать с триггерами, прочтите документацию.
Функции можно повторно использовать для нескольких триггеров.
Я мог бы отредактировать этот ответ завтра (возможно)
Спасибо ! Это решение, которое я искал, хотя оно не специфично для Prisma. Для пользователей Prisma, которых заблокировала та же проблема, вы можете проверить мой ответ ниже, поскольку, вероятно, вы не понимаете, как работает Prisma и как отношения представлены в базах данных SQL.
Вероятно, я совершаю ошибку, так как я новичок в функциях и триггерах, но получаю следующую ошибку, указывающую на первую букву слова «ИНИЦИАЛЬНО»: ОШИБКА: синтаксическая ошибка в «ИНИЦИАЛЬНО» или рядом с ней СТРОКА 13: ИСХОДНО ОТЛОЖЕНО
Нет, это моя вина, извините, я забыл добавить ключевое слово CONSTRAINT в определение TRIGGER. Я почти уверен, что есть лучший способ сделать это, чем триггер, который я вам дал, но мне нужно обратиться к документации, и я Сейчас я не дома, если вы подождите около 4 часов, я смогу дать вам полный ответ. Очень извините за половинчатый ответ
нет проблем, большое спасибо за вашу помощь, проблема заключалась в отсутствующем ключевом слове CONSTRAINT. Я предполагаю, что это решение требует ресурсов, поскольку оно выполняет согласованность_проверку_рецепта в каждой строке. Мне очень интересно узнать о любом другом решении, пока я учусь :) еще раз спасибо
Наконец я нашел четкий ответ: его необходимо реализовать с помощью проверки/валидации ввода.
Проверьте ответ, предоставленный @Ianis, который решает проблему с помощью триггеров (еще одна концепция, которую я не знал о базе данных SQL ^^') и делает базу данных более надежной.
Я думаю, что мой ответ по-прежнему полезен новичкам, использующим Prisma, таким как я, чтобы лучше понять Prisma и отношения в базах данных SQL.
Мое недоразумение на самом деле возникло из-за отсутствия у меня знаний о СУБД:
В отношениях «один ко многим» информация фактически хранится только на одной стороне. Отношения «многие-ко-многим» реализуются путем хранения двух отношений «один-ко-многим» в качестве внешних ключей в таблице отношений.
Между рецептами и ароматами существует связь «многие-ко-многим». Следовательно, структура таблицы рецептов выглядит следующим образом:
Вы можете заметить, что нет никакой информации об отношении.
Аналогично таблица Aromas выглядит так:
Информация об отношении хранится в таблице отношений (метко названной ^^') как внешние ключи:
Таким образом, при использовании Prisma единственное решение, позволяющее сделать рецепт хотя бы одним ароматом (вместо нуля или более), — это проверить/подтвердить введенные данные при создании записи рецепта.
Это связано с тем, что когда при вызове функции создания указывается аргумент аромата, Prisma фактически выполняет запрос на создание строки рецепта И на создание строки в таблице отношений.
Я запутался, потому что в модели Prisma отношения проявляются с обеих сторон, чего нет в базовой базе данных.
model Recipe {
id Int @id @default(autoincrement())
name String
base String? @default("50/50")
description String? @default("aucune description")
rating Int? @default(1)
aromas RecipeToAromas[] //the relation to the relation table appears here
}
model Aroma {
id Int @id @default(autoincrement())
name String
brand Brand? @relation(fields: [brandId], references: [id])
brandId Int? @default(1)
recipes RecipeToAromas[] //and here
@@unique([name, brandId], name: "aromaIdentifier")
}
На самом деле это указано в документации Prisma:
Примечание. Поле отношения не «проявляется» в базовой схеме базы данных. На другой стороне отношения аннотированное поле отношения и его скалярное поле отношения представляют сторону отношения, которая хранит внешний ключ в базовой базе данных.
Я думаю, вывод таков: если вы новичок, как и я, и решили использовать ORM, вы должны убедиться, что вы действительно понимаете структуры и концепции базы данных (и в идеале язык запросов), которые абстрагирует ORM.
Ну, насколько я знаю, для баз данных такого не существует. Возможно, вы можете добавить в свои миграции собственный SQL, который проверяет входные данные и выдает ошибку, если при вставке записи или попытке удалить все отношения нет хотя бы 1 значения. Однако давайте будем честными: вы не собираетесь использовать свою базу данных в нескольких проектах. так что не стоит слишком много думать об этом. Просто добавьте промежуточное программное обеспечение, используя prisma, для проверки входных данных для операций CUD и убедитесь, что для записей всегда есть хотя бы один элемент, таким образом у вас также будет возможность изменить логику в будущем!