Créer une contrainte unique avec des colonnes nulles

252

J'ai un tableau avec cette disposition:

CREATE TABLE Favorites
(
  FavoriteId uuid NOT NULL PRIMARY KEY,
  UserId uuid NOT NULL,
  RecipeId uuid NOT NULL,
  MenuId uuid
)

Je veux créer une contrainte unique similaire à ceci:

ALTER TABLE Favorites
ADD CONSTRAINT Favorites_UniqueFavorite UNIQUE(UserId, MenuId, RecipeId);

Cependant, cela autorisera plusieurs lignes avec le même (UserId, RecipeId), si MenuId IS NULL. Je veux permettre NULLà MenuIdstocker un favori qui n'a pas de menu associé, mais je veux seulement au plus une de ces lignes par paire utilisateur / recette.

Les idées que j'ai jusqu'à présent sont les suivantes:

  1. Utilisez des UUID codés en dur (tels que tous les zéros) au lieu de null.
    Cependant, MenuIda une contrainte FK sur les menus de chaque utilisateur, donc je devrais alors créer un menu spécial "nul" pour chaque utilisateur, ce qui est un problème.

  2. Vérifiez l'existence d'une entrée nulle en utilisant un déclencheur à la place.
    Je pense que c'est un problème et j'aime éviter les déclencheurs dans la mesure du possible. De plus, je ne leur fais pas confiance pour garantir que mes données ne sont jamais en mauvais état.

  3. Oubliez-le et vérifiez l'existence précédente d'une entrée nulle dans le middleware ou dans une fonction d'insertion, et n'avez pas cette contrainte.

J'utilise Postgres 9.0.

Y a-t-il une méthode que j'oublie?

Mike Christensen
la source
Pourquoi autorisera-t-on plusieurs lignes avec le même ( UserId, RecipeId), si MenuId IS NULL?
Drux

Réponses:

382

Créez deux index partiels :

CREATE UNIQUE INDEX favo_3col_uni_idx ON favorites (user_id, menu_id, recipe_id)
WHERE menu_id IS NOT NULL;

CREATE UNIQUE INDEX favo_2col_uni_idx ON favorites (user_id, recipe_id)
WHERE menu_id IS NULL;

De cette façon, il ne peut y avoir qu'une seule combinaison d' (user_id, recipe_id)menu_id IS NULL, en mettant effectivement en œuvre la contrainte souhaitée.

Inconvénients possibles: vous ne pouvez pas avoir de référence de clé étrangère (user_id, menu_id, recipe_id), vous ne pouvez pas vous baser CLUSTERsur un index partiel et les requêtes sans WHEREcondition de correspondance ne peuvent pas utiliser l'index partiel. (Il semble peu probable que vous souhaitiez une référence FK de trois colonnes de large - utilisez plutôt la colonne PK).

Si vous avez besoin d'un index complet , vous pouvez également supprimer la WHEREcondition de favo_3col_uni_idxet vos exigences sont toujours appliquées.
L'index, qui comprend maintenant la table entière, se chevauche avec l'autre et s'agrandit. Selon les requêtes typiques et le pourcentage de NULLvaleurs, cela peut être utile ou non. Dans des situations extrêmes, il peut même être utile de maintenir les trois index (les deux partiels et un total en haut).

En plus: je conseille de ne pas utiliser d' identificateurs de casse mixtes dans PostgreSQL .

Erwin Brandstetter
la source
1
@Erwin Brandsetter: à propos de la remarque " identificateurs de casse mixte ": Tant qu'il n'y a pas de guillemets doubles, l'utilisation d'identificateurs casés mixtes est tout à fait correct. Il n'y a pas de différence dans l'utilisation de tous les identifiants en minuscules (encore une fois: uniquement si aucune citation n'est utilisée)
a_horse_with_no_name
14
@a_horse_with_no_name: Je suppose que vous savez que je le sais. C'est en fait l' une des raisons pour lesquelles je conseille contre son usage. Les personnes qui ne connaissent pas si bien les détails se confondent, comme dans d'autres identifiants SGBDR, sont (en partie) sensibles à la casse. Parfois, les gens se confondent. Ou ils construisent du SQL dynamique et utilisent quote_ident () comme ils le devraient et oublient de passer des identifiants sous forme de chaînes minuscules maintenant! N'utilisez pas d'identificateurs de casse mixtes dans PostgreSQL, si vous pouvez l'éviter. J'ai vu ici un certain nombre de demandes désespérées découlant de cette folie.
Erwin Brandstetter
3
@a_horse_with_no_name: Oui, c'est bien sûr vrai. Mais si vous pouvez les éviter: vous ne voulez pas d'identificateurs de casse mixtes . Ils ne servent à rien. Si vous pouvez les éviter: ne les utilisez pas. En plus: ils sont tout simplement moches. Les identités citées sont laides aussi. Les identifiants SQL92 avec des espaces sont un faux pas fait par un comité. Ne les utilisez pas.
Wildplasser
2
@Mike: Je pense que tu devrais en parler au comité des normes SQL, bonne chance :)
mu est trop court
1
@buffer: les coûts de maintenance et le stockage total sont fondamentalement les mêmes (à l'exception d'une surcharge fixe mineure par index). Chaque ligne n'est représentée que dans un seul index. Performances: si vos résultats couvrent les deux cas, un indice simple total supplémentaire peut payer. Sinon, un index partiel est généralement plus rapide qu'un index complet, principalement en raison de sa plus petite taille. Ajoutez la condition d'index aux requêtes (de manière redondante) si Postgres ne comprend pas qu'il peut utiliser un index partiel par lui-même. Exemple.
Erwin Brandstetter
75

Vous pouvez créer un index unique avec une fusion sur le MenuId:

CREATE UNIQUE INDEX
Favorites_UniqueFavorite ON Favorites
(UserId, COALESCE(MenuId, '00000000-0000-0000-0000-000000000000'), RecipeId);

Il vous suffirait de choisir un UUID pour le COALESCE qui ne se produira jamais dans la "vraie vie". Vous ne verriez probablement jamais un UUID zéro dans la vraie vie, mais vous pourriez ajouter une contrainte CHECK si vous êtes paranoïaque (et puisqu'ils sont vraiment là pour vous obtenir ...):

alter table Favorites
add constraint check
(MenuId <> '00000000-0000-0000-0000-000000000000')
mu est trop court
la source
1
Cela comporte la faille (théorique), à ​​savoir qu'une entrée avec menu_id = '00000000-0000-0000-0000-000000000000' peut déclencher de fausses violations uniques - mais vous l'avez déjà corrigé dans votre commentaire.
Erwin Brandstetter
2
@muistooshort: Oui, c'est une bonne solution. Simplifiez-vous (MenuId <> '00000000-0000-0000-0000-000000000000')cependant. NULLest autorisé par défaut. Btw, il existe trois types de personnes. Les paranoïaques et les gens qui ne font pas de bases de données. Le troisième type publie occasionnellement des questions sur SO dans la perplexité. ;)
Erwin Brandstetter
2
@Erwin: Vous ne voulez pas dire "les paranoïaques et ceux avec des bases de données cassées"?
mu est trop court
2
Cette excellente solution permet d'inclure très facilement une colonne nulle d'un type plus simple, tel qu'un entier, dans une contrainte unique.
Markus Pscheidt
2
Il est vrai qu'un UUID ne fournira pas cette chaîne particulière, non seulement à cause des probabilités impliquées, mais aussi parce que ce n'est pas un UUID valide . Un générateur UUID n'est pas libre d'utiliser n'importe quel chiffre hexadécimal dans n'importe quelle position, par exemple une position est réservée pour le numéro de version de l'UUID.
Toby 1 Kenobi
1

Vous pouvez stocker des favoris sans menu associé dans une table distincte:

CREATE TABLE FavoriteWithoutMenu
(
  FavoriteWithoutMenuId uuid NOT NULL, --Primary key
  UserId uuid NOT NULL,
  RecipeId uuid NOT NULL,
  UNIQUE KEY (UserId, RecipeId)
)
ypercubeᵀᴹ
la source
Une idée intéressante. Cela rend l'insertion un peu plus compliquée. Je devrais vérifier si une ligne existe déjà en FavoriteWithoutMenupremier. Si c'est le cas, j'ajoute simplement un lien de menu - sinon je crée d'abord la FavoriteWithoutMenuligne, puis je la lie à un menu si nécessaire. Cela rend également la sélection de tous les favoris dans une requête très difficile: je devrais faire quelque chose de bizarre comme sélectionner d'abord tous les liens de menu, puis sélectionner tous les favoris dont les ID n'existent pas dans la première requête. Je ne sais pas si j'aime ça.
Mike Christensen
Je ne pense pas que l'insertion soit plus compliquée. Si vous souhaitez insérer un enregistrement avec NULL MenuId, vous insérez dans ce tableau. Sinon, à la Favoritestable. Mais interroger, oui, ce sera plus compliqué.
ypercubeᵀᴹ
En fait, grattez cela, sélectionner tous les favoris ne serait qu'une seule jointure GAUCHE pour obtenir le menu. Hmm oui, cela pourrait être la voie à suivre ..
Mike Christensen
L'INSERTION devient plus compliquée si vous souhaitez ajouter la même recette à plusieurs menus, car vous avez une contrainte UNIQUE sur UserId / RecipeId sur FavoriteWithoutMenu. Je n'aurais besoin de créer cette ligne que si elle n'existait pas déjà.
Mike Christensen
1
Merci! Cette réponse mérite un +1 car il s'agit plus d'une chose purement cross-base de données SQL .. Cependant, dans ce cas, je vais suivre la route d'index partiel car elle ne nécessite aucune modification de mon schéma et je l'aime :)
Mike Christensen
-1

Je pense qu'il y a un problème sémantique ici. À mon avis, un utilisateur peut avoir une (mais une seule ) recette préférée pour préparer un menu spécifique. (L'OP a un menu et une recette mélangés; si je me trompe: veuillez échanger MenuId et RecipeId ci-dessous) Cela implique que {utilisateur, menu} devrait être une clé unique dans ce tableau. Et cela devrait indiquer exactement une recette. Si l'utilisateur n'a pas de recette préférée pour ce menu spécifique, aucune ligne ne doit exister pour cette paire de clés {utilisateur, menu}. Aussi: la clé de substitution (FaVouRiteId) est superflue: les clés primaires composites sont parfaitement valables pour les tables de mappage relationnel.

Cela conduirait à la définition de table réduite:

CREATE TABLE Favorites
( UserId uuid NOT NULL REFERENCES users(id)
, MenuId uuid NOT NULL REFERENCES menus(id)
, RecipeId uuid NOT NULL REFERENCES recipes(id)
, PRIMARY KEY (UserId, MenuId)
);
sauvage
la source
2
Oui c'est vrai. Sauf que dans mon cas, je veux soutenir avoir un favori qui n'appartient à aucun menu. Imaginez-le comme vos signets dans votre navigateur. Vous pouvez simplement "mettre en signet" une page. Vous pouvez également créer des sous-dossiers de signets et leur attribuer un titre différent. Je veux permettre aux utilisateurs de mettre en favoris une recette ou de créer des sous-dossiers de favoris appelés menus.
Mike Christensen
1
Comme je l'ai dit: tout est question de sémantique. (Je pensais à la nourriture, évidemment) Avoir un favori "qui n'appartient à aucun menu" n'a aucun sens pour moi. Vous ne pouvez pas favoriser quelque chose qui n'existe pas, à mon humble avis.
Wildplasser
On dirait qu'une normalisation de la base de données pourrait aider. Créez un deuxième tableau qui relie les recettes aux menus (ou non). Bien qu'il généralise le problème et autorise plusieurs menus dont une recette pourrait faire partie. Quoi qu'il en soit, la question portait sur les index uniques dans PostgreSQL. Merci.
Chris