ChatGPT解决这个技术问题 Extra ChatGPT

Create unique constraint with null columns

I have a table with this layout:

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

I want to create a unique constraint similar to this:

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

However, this will allow multiple rows with the same (UserId, RecipeId), if MenuId IS NULL. I want to allow NULL in MenuId to store a favorite that has no associated menu, but I only want at most one of these rows per user/recipe pair.

The ideas I have so far are:

Use some hard-coded UUID (such as all zeros) instead of null. However, MenuId has a FK constraint on each user's menus, so I'd then have to create a special "null" menu for every user which is a hassle. Check for existence of a null entry using a trigger instead. I think this is a hassle and I like avoiding triggers wherever possible. Plus, I don't trust them to guarantee my data is never in a bad state. Just forget about it and check for the previous existence of a null entry in the middle-ware or in a insert function, and don't have this constraint.

I'm using Postgres 9.0. Is there any method I'm overlooking?

Why is it that will allow multiple rows with the same (UserId, RecipeId), if MenuId IS NULL?
@Drux I believe that since Null != Null, it follows that (userid, recipieid, null) != (userid, recipieid, null). So duplicates will be allowed that look identical to us, but don't compare equal to postgresql.

E
Erwin Brandstetter

Postgres 15 or newer (currently beta)

Postgres 15 adds the clause NULLS NOT DISTINCT. The release notes:

Allow unique constraints and indexes to treat NULL values as not distinct (Peter Eisentraut) Previously NULL values were always indexed as distinct values, but this can now be changed by creating constraints and indexes using UNIQUE NULLS NOT DISTINCT.

With this clause NULL is treated like just another value, and a UNIQUE constraint does not allow more than one row with the same NULL value. The task is simple now:

ALTER TABLE favorites
ADD CONSTRAINT favo_uni UNIQUE NULLS NOT DISTINCT (user_id, menu_id, recipe_id);

There are examples in the manual chapter "Unique Constraints".
The clause switches behavior for all index keys. You can't treat NULL as equal for one key, but not for another.
NULLS DISTINCT remains the default (in line with standard SQL) and does not have to be spelled out.

The same clause works for a UNIQUE index, too:

CREATE UNIQUE INDEX favo_uni_idx
ON favorites (user_id, menu_id, recipe_id) NULLS NOT DISTINCT;

Note the position of the new clause after the key fields.

Postgres 14 or older

Create two partial indexes:

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;

This way, there can only be one combination of (user_id, recipe_id) where menu_id IS NULL, effectively implementing the desired constraint.

Possible drawbacks:

You cannot have a foreign key referencing (user_id, menu_id, recipe_id). (It seems unlikely you'd want a FK reference three columns wide - use the PK column instead!)

You cannot base CLUSTER on a partial index.

Queries without a matching WHERE condition cannot use the partial index.

If you need a complete index, you can alternatively drop the WHERE condition from favo_3col_uni_idx and your requirements are still enforced.
The index, now comprising the whole table, overlaps with the other one and gets bigger. Depending on typical queries and the percentage of NULL values, this may or may not be useful. In extreme situations it may even help to maintain all three indexes (the two partial ones and a total on top).

This is a good solution for a single nullable column, maybe for two. But it gets out of hands quickly for more as you need a separate partial index for every combination of nullable columns, so the number grows binomially. For multiple nullable columns, see instead:

Why doesn't my UNIQUE constraint trigger?

Aside: I advise not to use mixed case identifiers in PostgreSQL.


@a_horse_with_no_name: I assume you know that I know that. That is actually one of the reasons I advise against it's usage. People who do not know the specifics so well get confused, as in other RDBMS identifiers are (partly) case sensitive. Sometimes people confuse themselves. Or they build dynamic SQL and use quote_ident() as they should and forget to pass identifiers as lower case strings now! Do not use mixed case identifiers in PostgreSQL, if you can avoid it. I have seen a number of desperate requests here stemming from this folly.
@a_horse_with_no_name: Yes, that is of course true. But if you can avoid them: you don't want mixed case identifiers. They serve no purpose. If you can avoid them: don't use them. Besides: they are just plain ugly. Quoted identifies are ugly, too. SQL92 identifiers with spaces in them are a misstep made by a committee. Don't use them.
@Mike: I think you'd have to talk to the SQL standards committee about that, good luck :)
Do we really need the WHERE menu_id IS NOT NULL; in the first index for the non-null case? Isn't just CREATE UNIQUE INDEX favorites_3col_uni_idx ON favorites (user_id, menu_id, recipe_id) the same thing?
@Toby1Kenobi: The Latin plural is. But the English plural is more common.
m
mu is too short

You could create a unique index with a coalesce on the MenuId:

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

You'd just need to pick a UUID for the COALESCE that will never occur in "real life". You'd probably never see a zero UUID in real life but you could add a CHECK constraint if you are paranoid (and since they really are out to get you...):

alter table Favorites
add constraint check
(MenuId <> '00000000-0000-0000-0000-000000000000')

@muistooshort: Yup, that is a proper solution. Simplify to (MenuId <> '00000000-0000-0000-0000-000000000000') though. NULL is allowed by default. Btw, there is three kinds of people. The paranoid ones, and people who don't do databases. The third kind occasionally posts questions on SO in bewilderment. ;)
@Erwin: Don't you mean "the paranoid ones and the ones with broken databases"?
This excellent solution makes it very easy to include a null column of a simpler type, such as integer, in a unique constraint.
It's true that a UUID wont come up with that particular string, not only because of the probabilities involved, but also because it's not a valid UUID. A UUID generator is not free to use any hex digit in any position, for example one position is reserved for the version number of the UUID.
This idea is way simpler and removes the combinatorial problem of multiple nullable fields requiring n^2 partial indexes. This should be the accepted answer.
y
ypercubeᵀᴹ

You can store favourites with no associated menu in a separate table:

CREATE TABLE FavoriteWithoutMenu
(
  FavoriteWithoutMenuId uuid NOT NULL, --Primary key
  UserId uuid NOT NULL,
  RecipeId uuid NOT NULL,
  UNIQUE KEY (UserId, RecipeId)
)

An interesting idea. It makes inserting a bit more complicated. I would need to check if a row already exists in FavoriteWithoutMenu first. If so, I just add a menu link - otherwise I create the FavoriteWithoutMenu row first and then link it to a menu if necessary. It also makes selecting all the favorites in one query very difficult: I'd have to do something weird like select all the menu links first, and then select all the Favorites whose IDs don't exist within the first query. I'm not sure if I like that.
I don't think inserting as more complicated. If you want to insert a record with NULL MenuId, you insert into this table. If not, to the Favorites table. But querying, yes, it will be more complicated.
Actually scratch that, selecting all favorites would just be a single LEFT join to get the menu. Hmm yea this might be the way to go..
The INSERT becomes more complicated if you want to add the same recipe to more than one menu, since you have a UNIQUE constraint on UserId/RecipeId on FavoriteWithoutMenu. I'd need to create this row only if it didn't exist already.
Thanks! This answer deserves a +1 since it's more of a cross-database pure SQL thing.. However, in this case I'm gonna go the partial index route because it requires no changes to my schema and I like it :)
w
wildplasser

I think there is a semantic problem here. In my view, a user can have a (but only one) favourite recipe to prepare a specific menu. (The OP has menu and recipe mixed up; if I am wrong: please interchange MenuId and RecipeId below) That implies that {user,menu} should be a unique key in this table. And it should point to exactly one recipe. If the user has no favourite recipe for this specific menu no row should exist for this {user,menu} key pair. Also: the surrogate key (FaVouRiteId) is superfluous: composite primary keys are perfectly valid for relational-mapping tables.

That would lead to the reduced table definition:

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

Yea this is right. Except, in my case I want to support having a favorite that doesn't belong to any menu. Imagine it like your Bookmarks in your browser. You might just "bookmark" a page. Or, you could create sub-folders of bookmarks and title them different things. I want to allow users to favorite a recipe, or create sub-folders of favorites called menus.
As I said: it is all about semantics. (I was thinking about food, obviously) Having a favourite "that does not belong to any menu" makes no sense to me. You cannot favour something that does not exist, IMHO.
Seems like some db normalization could help. Create a second table that relates recipes to menus (or not). Though it generalizes the problem and allows for more than one menu that a recipe could be part of. Regardless, the question was about unique indexes in PostgreSQL. Thanks.