Crear restricción única con columnas nulas

252

Tengo una tabla con este diseño:

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

Quiero crear una restricción única similar a esta:

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

Sin embargo, esto permitirá múltiples filas con el mismo (UserId, RecipeId), si MenuId IS NULL. Quiero permitir NULLen MenuIdalmacenar un favorito que se ha asociado ningún menú, pero yo sólo quiero lo sumo una de estas filas por par usuario / receta.

Las ideas que tengo hasta ahora son:

  1. Use algún UUID codificado (como todos los ceros) en lugar de nulo.
    Sin embargo, MenuIdtiene una restricción FK en los menús de cada usuario, por lo que tendría que crear un menú especial "nulo" para cada usuario, lo cual es una molestia.

  2. Verifique la existencia de una entrada nula utilizando un disparador en su lugar.
    Creo que esto es una molestia y me gusta evitar los desencadenantes siempre que sea posible. Además, no confío en ellos para garantizar que mis datos nunca estén en mal estado.

  3. Solo olvídalo y verifica la existencia previa de una entrada nula en el middleware o en una función de inserción, y no tienes esta restricción.

Estoy usando Postgres 9.0.

¿Hay algún método que esté pasando por alto?

Mike Christensen
fuente
¿Por qué es que permitirá múltiples filas con el mismo ( UserId, RecipeId), si MenuId IS NULL?
Drux

Respuestas:

382

Crea dos índices parciales :

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 esta manera, solo puede haber una combinación de (user_id, recipe_id)dónde menu_id IS NULL, implementando efectivamente la restricción deseada.

Posibles inconvenientes: no puede tener una referencia de clave externa (user_id, menu_id, recipe_id), no puede basarse CLUSTERen un índice parcial y las consultas sin una WHEREcondición coincidente no pueden usar el índice parcial. (Parece poco probable que desee una referencia FK de tres columnas de ancho; use la columna PK en su lugar).

Si necesita un índice completo , también puede eliminar la WHEREcondición favo_3col_uni_idxy sus requisitos aún se aplican.
El índice, que ahora comprende toda la tabla, se superpone con la otra y se hace más grande. Dependiendo de las consultas típicas y el porcentaje de NULLvalores, esto puede o no ser útil. En situaciones extremas, incluso podría ayudar mantener los tres índices (los dos parciales y el total en la parte superior).

Aparte: aconsejo no utilizar identificadores de mayúsculas y minúsculas en PostgreSQL .

Erwin Brandstetter
fuente
1
@Erwin Brandsetter: con respecto a la observación de " identificadores de mayúsculas y minúsculas ": siempre que no se utilicen comillas dobles, usar identificadores de mayúsculas y minúsculas está absolutamente bien. No hay diferencia en el uso de todos los identificadores en minúsculas (nuevamente: solo si no se utilizan comillas)
a_horse_with_no_name
14
@a_horse_with_no_name: Supongo que sabes que yo sé eso. Eso es en realidad una de las razones por las que aconsejo contra él de uso. Las personas que no conocen los detalles tan bien se confunden, como en otros identificadores RDBMS son (en parte) sensibles a mayúsculas y minúsculas. A veces las personas se confunden a sí mismas. ¡O construyen SQL dinámico y usan quote_ident () como deberían y se olvidan de pasar identificadores como cadenas en minúsculas ahora! No use identificadores de mayúsculas y minúsculas en PostgreSQL, si puede evitarlo. He visto una serie de solicitudes desesperadas aquí derivadas de esta locura.
Erwin Brandstetter
3
@a_horse_with_no_name: Sí, eso por supuesto es cierto. Pero si puede evitarlos: no desea identificadores de mayúsculas y minúsculas . No sirven para nada. Si puedes evitarlos: no los uses. Además: son simplemente feos. Las identificaciones citadas también son feas. Los identificadores SQL92 con espacios en ellos son un paso en falso realizado por un comité. No los uses.
wildplasser
2
@ Mike: Creo que tendrías que hablar con el comité de estándares SQL sobre eso, buena suerte :)
mu es demasiado corto el
1
@buffer: el costo de mantenimiento y el almacenamiento total son básicamente los mismos (excepto por una sobrecarga fija menor por índice). Cada fila solo se representa en un índice. Rendimiento: si sus resultados abarcan ambos casos, puede pagar un índice simple total adicional. Si no, un índice parcial suele ser más rápido que un índice completo, principalmente debido al tamaño más pequeño. Agregue la condición de índice a las consultas (de forma redundante) si Postgres no se da cuenta de que puede usar un índice parcial por sí mismo. Ejemplo.
Erwin Brandstetter
75

Puede crear un índice único con una fusión en el MenuId:

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

Solo necesita elegir un UUID para la COALESCE que nunca ocurrirá en la "vida real". Probablemente nunca verías un UUID cero en la vida real, pero podrías agregar una restricción CHECK si eres paranoico (y dado que realmente están para atraparte ...):

alter table Favorites
add constraint check
(MenuId <> '00000000-0000-0000-0000-000000000000')
mu es demasiado corto
fuente
1
Esto conlleva el defecto (teórico) de que una entrada con menu_id = '00000000-0000-0000-0000-000000000000' puede desencadenar infracciones únicas falsas, pero ya lo mencionó en su comentario.
Erwin Brandstetter
2
@muistooshort: Sí, esa es una solución adecuada. Simplifica a (MenuId <> '00000000-0000-0000-0000-000000000000')aunque. NULLestá permitido por defecto. Por cierto, hay tres tipos de personas. Los paranoicos y las personas que no hacen bases de datos. El tercer tipo ocasionalmente publica preguntas sobre SO en desconcierto. ;)
Erwin Brandstetter
2
@Erwin: ¿No te refieres a "los paranoicos y los que tienen bases de datos rotas"?
mu es demasiado corto el
2
Esta excelente solución hace que sea muy fácil incluir una columna nula de un tipo más simple, como un entero, en una restricción única.
Markus Pscheidt
2
Es cierto que un UUID no presentará esa cadena en particular, no solo por las probabilidades involucradas, sino también porque no es un UUID válido . Un generador de UUID no puede utilizar ningún dígito hexadecimal en ninguna posición, por ejemplo, una posición está reservada para el número de versión del UUID.
Toby 1 Kenobi
1

Puede almacenar favoritos sin un menú asociado en una tabla separada:

CREATE TABLE FavoriteWithoutMenu
(
  FavoriteWithoutMenuId uuid NOT NULL, --Primary key
  UserId uuid NOT NULL,
  RecipeId uuid NOT NULL,
  UNIQUE KEY (UserId, RecipeId)
)
ypercubeᵀᴹ
fuente
Una idea interesante Hace que la inserción sea un poco más complicada. Tendría que verificar si ya existe una fila en FavoriteWithoutMenuprimer lugar. Si es así, solo agrego un enlace al menú; de lo contrario, FavoriteWithoutMenuprimero creo la fila y luego la enlace a un menú si es necesario. También hace que la selección de todos los favoritos en una consulta sea muy difícil: tendría que hacer algo extraño, como seleccionar primero todos los enlaces del menú y luego seleccionar todos los Favoritos cuyos ID no existen en la primera consulta. No estoy seguro si me gusta eso.
Mike Christensen
No creo que insertar sea más complicado. Si desea insertar un registro con NULL MenuId, inserte en esta tabla. Si no, a la Favoritesmesa. Pero consultar, sí, será más complicado.
ypercubeᵀᴹ
En realidad, tache eso, seleccionando todos los favoritos sería una sola combinación IZQUIERDA para obtener el menú. Hmm, sí, este podría ser el camino a seguir ..
Mike Christensen
INSERT se vuelve más complicado si desea agregar la misma receta a más de un menú, ya que tiene una restricción ÚNICA en UserId / RecipeId en FavoriteWithoutMenu. Necesitaría crear esta fila solo si aún no existiera.
Mike Christensen
1
¡Gracias! Esta respuesta merece un +1 ya que es más una cuestión de SQL puro entre bases de datos. Sin embargo, en este caso voy a ir a la ruta del índice parcial porque no requiere cambios en mi esquema y me gusta :)
Mike Christensen
-1

Creo que hay un problema semántico aquí. En mi opinión, un usuario puede tener una (pero solo una ) receta favorita para preparar un menú específico. (El OP tiene un menú y una receta mezclados; si me equivoco: intercambie MenuId y RecipeId a continuación) Eso implica que {user, menu} debería ser una clave única en esta tabla. Y debe apuntar exactamente a una receta. Si el usuario no tiene una receta favorita para este menú específico, no debe existir una fila para este par de teclas {usuario, menú}. Además: la clave sustituta (FaVouRiteId) es superflua: las claves primarias compuestas son perfectamente válidas para las tablas de mapeo relacional.

Eso llevaría a la definición de tabla reducida:

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)
);
wildplasser
fuente
2
Sí, esto es correcto. Excepto, en mi caso quiero apoyar tener un favorito que no pertenezca a ningún menú. Imagínelo como sus Marcadores en su navegador. Puede simplemente "marcar" una página. O bien, puede crear subcarpetas de marcadores y asignarles títulos diferentes. Quiero permitir a los usuarios marcar una receta como favorita o crear subcarpetas de favoritos llamadas menús.
Mike Christensen
1
Como dije: se trata de semántica. (Estaba pensando en la comida, obviamente) Tener un favorito "que no pertenece a ningún menú" no tiene sentido para mí. No puedes favorecer algo que no existe, en mi humilde opinión.
wildplasser
Parece que alguna normalización db podría ayudar. Cree una segunda tabla que relacione las recetas con los menús (o no). Aunque generaliza el problema y permite más de un menú del que una receta podría formar parte. De todos modos, la pregunta era sobre índices únicos en PostgreSQL. Gracias.
Chris