Utwórz unikalne ograniczenie z pustymi kolumnami

251

Mam stół z tym układem:

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

Chcę utworzyć unikalne ograniczenie podobne do tego:

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

Pozwoli to jednak na wiele wierszy z tym samym (UserId, RecipeId), jeśli MenuId IS NULL. Chcę pozwolić NULLna MenuIdprzechowywanie faworyta, który nie ma menu powiązanych, ale chcę tylko co najwyżej jeden z tych wierszy na parę użytkownik / receptury.

Dotychczasowe pomysły to:

  1. Użyj jakiegoś zakodowanego UUID (takiego jak wszystkie zera) zamiast null.
    Jednak MenuIdma ograniczenie FK w menu każdego użytkownika, więc musiałbym stworzyć specjalne menu „zerowe” dla każdego użytkownika, co jest kłopotliwe.

  2. Sprawdź, czy istnieje wpis zerowy, używając wyzwalacza.
    Myślę, że to kłopot i lubię unikać wyzwalaczy tam, gdzie to możliwe. Ponadto nie ufam im, aby zagwarantować, że moje dane nigdy nie będą w złym stanie.

  3. Po prostu zapomnij o tym i sprawdź wcześniejsze istnienie pustego wpisu w oprogramowaniu pośrednim lub w funkcji wstawiania i nie masz tego ograniczenia.

Używam Postgres 9.0.

Czy przeoczyłem jakąś metodę?

Mike Christensen
źródło
Dlaczego to pozwala na wiele wierszy z tym samym ( UserId, RecipeId), jeśli MenuId IS NULL?
Drux,

Odpowiedzi:

382

Utwórz dwa indeksy częściowe :

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;

W ten sposób może istnieć tylko jedna kombinacja (user_id, recipe_id)gdzie menu_id IS NULL, skutecznie wdrażając pożądane ograniczenie.

Możliwe wady: nie możesz mieć odwołania do klucza obcego (user_id, menu_id, recipe_id), nie możesz bazować CLUSTERna indeksie częściowym, a zapytania bez pasującego WHEREwarunku nie mogą używać indeksu częściowego. (Wydaje się mało prawdopodobne, abyś chciał mieć odniesienie FK o szerokości trzech kolumn - zamiast tego użyj kolumny PK).

Jeśli potrzebujesz pełnego indeksu, możesz alternatywnie usunąć WHEREwarunek, favo_3col_uni_idxa Twoje wymagania są nadal egzekwowane.
Indeks, który obejmuje teraz całą tabelę, pokrywa się z drugą tabelą i staje się większy. W zależności od typowych zapytań i odsetka NULLwartości może to być przydatne lub nie. W skrajnych sytuacjach może nawet pomóc utrzymać wszystkie trzy indeksy (dwa częściowe i suma na górze).

Poza tym: Radzę nie używać identyfikatorów mieszanych w PostgreSQL .

Erwin Brandstetter
źródło
1
@Erwin Brandsetter: w odniesieniu do uwagi „ identyfikatory wieloznaczne ”: tak długo, jak nie stosuje się podwójnych cudzysłowów, stosowanie identyfikatorów wieloznacznych jest absolutnie w porządku. Nie ma różnicy w używaniu wszystkich małych liter (ponownie: tylko jeśli nie są używane cudzysłowy)
a_horse_w_na_nazwie
14
@ a_horse_with_no_name: Zakładam, że wiesz, że ja to wiem. To jest rzeczywiście jeden z powodów, radzę przeciwko To użytkowania. Ludzie, którzy nie znają tak dobrze specyfiki, wpadają w zamieszanie, ponieważ w innych identyfikatorach RDBMS rozróżniana jest (częściowo) wielkość liter. Czasami ludzie się mylą. Lub budują dynamiczny SQL i używają quote_ident () tak, jak powinni i zapominają teraz przekazywać identyfikatory jako ciągi małych liter! Nie używaj mieszanych identyfikatorów wielkości liter w PostgreSQL, jeśli możesz tego uniknąć. Widziałem tutaj wiele desperackich próśb wynikających z tego szaleństwa.
Erwin Brandstetter
3
@ a_horse_with_no_name: Tak, to oczywiście prawda. Ale jeśli możesz ich uniknąć: nie chcesz mieszanych identyfikatorów . Nie służą celowi. Jeśli możesz ich uniknąć: nie używaj ich. Poza tym: są po prostu brzydkie. Cytowane identyfikatory też są brzydkie. Identyfikatory SQL92 ze spacjami są błędem popełnionym przez komisję. Nie używaj ich.
wildplasser
2
@Mike: Myślę, że powinieneś porozmawiać o tym z komitetem ds. Standardów SQL, powodzenia :)
jest za krótki
1
@bufor: Koszt utrzymania i całkowite miejsce do przechowywania są zasadniczo takie same (z wyjątkiem niewielkiego stałego narzutu na indeks). Każdy wiersz jest reprezentowany tylko w jednym indeksie. Wydajność: jeśli wyniki obejmują oba przypadki, może zapłacić dodatkowy całkowity indeks zwykły. Jeśli nie, indeks częściowy jest zwykle szybszy niż indeks całkowity, głównie z powodu mniejszego rozmiaru. Dodaj warunek indeksu do zapytań (redundantnie), jeśli Postgres nie zorientuje się, że sam może użyć indeksu częściowego. Przykład.
Erwin Brandstetter
75

Możesz stworzyć unikalny indeks z koalescencją w MenuId:

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

Musisz po prostu wybrać UUID dla WĘGLA, który nigdy nie nastąpi w „prawdziwym życiu”. Prawdopodobnie nigdy nie zobaczyłbyś zerowego UUID w prawdziwym życiu, ale możesz dodać ograniczenie CHECK, jeśli jesteś paranoikiem (a ponieważ oni naprawdę chcą cię dostać ...):

alter table Favorites
add constraint check
(MenuId <> '00000000-0000-0000-0000-000000000000')
mu jest za krótki
źródło
1
Niesie to (teoretyczną) wadę, że wpisy z menu_id = „00000000-0000-0000-0000-000000000000” mogą powodować fałszywe unikalne naruszenia - ale już to rozwiązałeś w swoim komentarzu.
Erwin Brandstetter
2
@muistooshort: Tak, to właściwe rozwiązanie. Uprość to (MenuId <> '00000000-0000-0000-0000-000000000000')jednak. NULLjest domyślnie dozwolone. Przy okazji są trzy rodzaje ludzi. Te paranoiczne i ludzie, którzy nie robią baz danych. Trzeci rodzaj od czasu do czasu z zakłopotaniem zamieszcza pytania na temat SO. ;)
Erwin Brandstetter,
2
@Erwin: Czy nie masz na myśli „tych paranoicznych i tych z uszkodzonymi bazami danych”?
mu jest za krótki
2
To doskonałe rozwiązanie ułatwia włączenie kolumny zerowej prostszego typu, na przykład liczby całkowitej, w unikalne ograniczenie.
Markus Pscheidt,
2
Prawdą jest, że identyfikator UUID nie wymyśli tego konkretnego ciągu, nie tylko ze względu na związane z nim prawdopodobieństwa, ale także dlatego, że nie jest to prawidłowy identyfikator UUID . Generator UUID nie może dowolnie używać cyfr szesnastkowych w żadnej pozycji, na przykład jedna pozycja jest zarezerwowana dla numeru wersji UUID.
Toby 1 Kenobi,
1

Możesz przechowywać ulubione bez powiązanego menu w osobnej tabeli:

CREATE TABLE FavoriteWithoutMenu
(
  FavoriteWithoutMenuId uuid NOT NULL, --Primary key
  UserId uuid NOT NULL,
  RecipeId uuid NOT NULL,
  UNIQUE KEY (UserId, RecipeId)
)
ypercubeᵀᴹ
źródło
Ciekawy pomysł. To sprawia, że ​​wstawianie jest nieco bardziej skomplikowane. Musiałbym sprawdzić, czy wiersz już istnieje w FavoriteWithoutMenupierwszym. Jeśli tak, po prostu dodaję link do menu - w przeciwnym razie FavoriteWithoutMenunajpierw tworzę wiersz, a następnie w razie potrzeby łączę go z menu. Utrudnia to również wybranie wszystkich ulubionych w jednym zapytaniu: musiałbym zrobić coś dziwnego, jak najpierw wybrać wszystkie łącza menu, a następnie wybrać wszystkie Ulubione, których identyfikatory nie istnieją w pierwszym zapytaniu. Nie jestem pewien, czy mi się to podoba.
Mike Christensen
Nie sądzę, żeby wstawianie było bardziej skomplikowane. Jeśli chcesz wstawić rekord NULL MenuId, wstawisz do tej tabeli. Jeśli nie, do Favoritesstołu. Ale zapytanie, tak, będzie bardziej skomplikowane.
ypercubeᵀᴹ
Właściwie to zaznacz, wybranie wszystkich ulubionych byłoby tylko jednym LEWYM złączeniem, aby uzyskać menu. Hmm tak, to może być dobry sposób ...
Mike Christensen
WSTAW staje się bardziej skomplikowane, jeśli chcesz dodać ten sam przepis do więcej niż jednego menu, ponieważ masz WYJĄTKOWE ograniczenie na UserId / RecipeId na FavoriteWithoutMenu. Musiałbym utworzyć ten wiersz tylko wtedy, gdyby jeszcze nie istniał.
Mike Christensen
1
Dzięki! Ta odpowiedź zasługuje na +1, ponieważ jest to kwestia czystego SQL między bazami danych. Jednak w tym przypadku wybiorę trasę częściowego indeksu, ponieważ nie wymaga ona żadnych zmian w moim schemacie i podoba mi się to :)
Mike Christensen
-1

Myślę, że jest tu problem semantyczny. Moim zdaniem użytkownik może mieć (ale tylko jeden ) ulubiony przepis na przygotowanie określonego menu. (OP ma pomieszane menu i przepis; jeśli się mylę: proszę zamień MenuId i RecipeId poniżej) Oznacza to, że {użytkownik, menu} powinien być unikalnym kluczem w tej tabeli. I powinien wskazywać dokładnie jeden przepis. Jeśli użytkownik nie ma ulubionej receptury dla tego konkretnego menu, nie powinien istnieć wiersz dla tej pary kluczy {użytkownik, menu}. Ponadto: klucz zastępczy (FaVouRiteId) jest zbędny: złożone klucze podstawowe doskonale nadają się do tabel mapowania relacyjnego.

Doprowadziłoby to do ograniczenia definicji tabeli:

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
źródło
2
Tak, to prawda. Tyle że w moim przypadku chcę wesprzeć posiadanie ulubionego, który nie należy do żadnego menu. Wyobraź to sobie jak Zakładki w przeglądarce. Możesz po prostu „dodać zakładkę” do strony. Możesz też utworzyć podfoldery zakładek i nadać im tytuły. Chcę pozwolić użytkownikom na ulubienie przepisu lub tworzenie podfolderów ulubionych zwanych menu.
Mike Christensen
1
Jak powiedziałem: chodzi o semantykę. (Oczywiście myślałem o jedzeniu) Posiadanie ulubionego „nie należącego do żadnego menu” nie ma dla mnie sensu. Nie możesz faworyzować czegoś, co nie istnieje, IMHO.
wildplasser
Wydaje się, że normalizacja db mogłaby pomóc. Utwórz drugą tabelę, która wiąże przepisy z menu (lub nie). Chociaż uogólnia problem i pozwala na utworzenie więcej niż jednego menu, którego częścią może być przepis. Niezależnie od tego pytanie dotyczyło unikalnych indeksów w PostgreSQL. Dzięki.
Chris