-
Notifications
You must be signed in to change notification settings - Fork 302
Nulls not distinct #1609
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jkeuhlen
wants to merge
8
commits into
yesodweb:master
Choose a base branch
from
jkeuhlen:NullsNotDistinct
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Nulls not distinct #1609
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
ae45d93
Add support for nullsNotDistinct
jkeuhlen 067dd30
formatting
jkeuhlen 27c2be1
format again
jkeuhlen 569fa53
DRY up unique code
jkeuhlen f73731e
Merge branch 'master' into NullsNotDistinct
jkeuhlen 956195f
Merge remote-tracking branch 'upstream/master' into NullsNotDistinct
jkeuhlen 76e0e96
Minor tweaks + bump CI postgres version
jkeuhlen 217f165
format
jkeuhlen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,255 @@ | ||
| {-# LANGUAGE DataKinds #-} | ||
| {-# LANGUAGE DerivingStrategies #-} | ||
| {-# LANGUAGE FlexibleContexts #-} | ||
| {-# LANGUAGE FlexibleInstances #-} | ||
| {-# LANGUAGE GADTs #-} | ||
| {-# LANGUAGE GeneralizedNewtypeDeriving #-} | ||
| {-# LANGUAGE MultiParamTypeClasses #-} | ||
| {-# LANGUAGE OverloadedStrings #-} | ||
| {-# LANGUAGE QuasiQuotes #-} | ||
| {-# LANGUAGE ScopedTypeVariables #-} | ||
| {-# LANGUAGE StandaloneDeriving #-} | ||
| {-# LANGUAGE TemplateHaskell #-} | ||
| {-# LANGUAGE TypeApplications #-} | ||
| {-# LANGUAGE TypeFamilies #-} | ||
| {-# LANGUAGE UndecidableInstances #-} | ||
|
|
||
| module NullsNotDistinctTest where | ||
|
|
||
| import Control.Monad.Trans.Resource (runResourceT) | ||
| import qualified Data.Text as T | ||
| import Database.Persist | ||
| import Database.Persist.Postgresql | ||
| import Database.Persist.Postgresql.Internal | ||
| import Database.Persist.TH | ||
| import qualified Test.Hspec as Hspec | ||
|
|
||
| import PgInit | ||
|
|
||
| -- The standard unique constraint (allowing multiple NULLs) is migrated on every | ||
| -- supported PostgreSQL version, so it lives in its own migration that is always | ||
| -- safe to run. | ||
| share | ||
| [mkPersist sqlSettings, mkMigrate "standardUniqueMigrate"] | ||
| [persistLowerCase| | ||
| -- Standard unique constraint (allows multiple NULLs) | ||
| StandardUnique | ||
| name Text | ||
| email Text Maybe | ||
| UniqueStandardEmail name email !force | ||
| deriving Eq Show | ||
| |] | ||
|
|
||
| -- These entities use NULLS NOT DISTINCT, which is only valid on PostgreSQL 15+. | ||
| -- They are kept in a separate migration so that it is only ever run when the | ||
| -- server supports the feature (see 'main.hs' and the version-gated tests below). | ||
| -- Running this migration against PostgreSQL < 15 raises a syntax error. | ||
| share | ||
| [mkPersist sqlSettings, mkMigrate "nullsNotDistinctMigrate"] | ||
| [persistLowerCase| | ||
| -- Unique constraint with NULLS NOT DISTINCT (PostgreSQL 15+) | ||
| -- This should prevent multiple NULLs | ||
| NullsNotDistinctUnique | ||
| name Text | ||
| email Text Maybe | ||
| UniqueNNDEmail name email !nullsNotDistinct | ||
| deriving Eq Show | ||
|
|
||
| -- Multiple nullable fields with NULLS NOT DISTINCT | ||
| MultiFieldNND | ||
| fieldA Text | ||
| fieldB Text Maybe | ||
| fieldC Int Maybe | ||
| UniqueMultiNND fieldA fieldB fieldC !nullsNotDistinct | ||
| deriving Eq Show | ||
| |] | ||
|
|
||
| -- Helper to check PostgreSQL version | ||
| getPostgresVersion :: (MonadIO m) => ReaderT SqlBackend m (Maybe Int) | ||
| getPostgresVersion = do | ||
| result <- rawSql "SELECT current_setting('server_version_num')::integer" [] | ||
| case result of | ||
| [Single version] -> return $ Just version | ||
| _ -> return Nothing | ||
|
|
||
| isPostgres15OrHigher :: (MonadIO m) => ReaderT SqlBackend m Bool | ||
| isPostgres15OrHigher = do | ||
| mVersion <- getPostgresVersion | ||
| case mVersion of | ||
| Just version -> return $ version >= 150000 -- PostgreSQL 15.0 | ||
| Nothing -> return False | ||
|
|
||
| cleanStandard :: (MonadIO m) => ReaderT SqlBackend m () | ||
| cleanStandard = deleteWhere ([] :: [Filter StandardUnique]) | ||
|
|
||
| -- Only safe to call on PostgreSQL 15+, where the NND tables have been migrated. | ||
| cleanNND :: (MonadIO m) => ReaderT SqlBackend m () | ||
| cleanNND = do | ||
| deleteWhere ([] :: [Filter NullsNotDistinctUnique]) | ||
| deleteWhere ([] :: [Filter MultiFieldNND]) | ||
|
|
||
| specs :: Spec | ||
| specs = describe "NULLS NOT DISTINCT support" $ do | ||
| let | ||
| runDb = runConnAssert | ||
|
|
||
| it "generates correct SQL for NULLS NOT DISTINCT constraint" $ do | ||
| let | ||
| alterWithNND = | ||
| AddUniqueConstraint | ||
| (ConstraintNameDB "unique_nnd_email") | ||
| [FieldNameDB "name", FieldNameDB "email"] | ||
| ["!nullsNotDistinct"] | ||
|
|
||
| let | ||
| alterWithoutNND = | ||
| AddUniqueConstraint | ||
| (ConstraintNameDB "unique_standard_email") | ||
| [FieldNameDB "name", FieldNameDB "email"] | ||
| ["!force"] | ||
|
|
||
| let | ||
| tableName = EntityNameDB "test_table" | ||
| let | ||
| sqlWithNND = showAlterTable tableName alterWithNND | ||
| let | ||
| sqlWithoutNND = showAlterTable tableName alterWithoutNND | ||
|
|
||
| sqlWithNND | ||
| `Hspec.shouldBe` "ALTER TABLE \"test_table\" ADD CONSTRAINT \"unique_nnd_email\" UNIQUE NULLS NOT DISTINCT(\"name\",\"email\")" | ||
|
|
||
| sqlWithoutNND | ||
| `Hspec.shouldBe` "ALTER TABLE \"test_table\" ADD CONSTRAINT \"unique_standard_email\" UNIQUE(\"name\",\"email\")" | ||
|
|
||
| describe "runtime behavior" $ do | ||
| it "standard unique allows multiple NULLs" $ do | ||
| runDb $ do | ||
| cleanStandard | ||
|
|
||
| -- These should both succeed with standard unique | ||
| _ <- insert $ StandardUnique "user1" Nothing | ||
| _ <- insert $ StandardUnique "user2" Nothing | ||
|
|
||
| -- Verify both were inserted | ||
| count1 <- count [StandardUniqueName ==. "user1"] | ||
| count2 <- count [StandardUniqueName ==. "user2"] | ||
|
|
||
| liftIO $ do | ||
| count1 `Hspec.shouldBe` 1 | ||
| count2 `Hspec.shouldBe` 1 | ||
|
|
||
| it "standard unique prevents duplicate non-NULLs" $ | ||
| -- Both inserts run in the same transaction so the constraint | ||
| -- violation propagates out of runDb for shouldThrow to catch. | ||
| ( runDb $ do | ||
| cleanStandard | ||
| _ <- insert $ StandardUnique "user1" (Just "test@example.com") | ||
| _ <- insert $ StandardUnique "user1" (Just "test@example.com") | ||
| return () | ||
| ) | ||
| `Hspec.shouldThrow` Hspec.anyException | ||
|
|
||
| it | ||
| "standard unique getBy returns Nothing for NULL values (backwards compatibility)" | ||
| $ do | ||
| runDb $ do | ||
| cleanStandard | ||
|
|
||
| -- Insert a record with NULL email | ||
| _ <- insert $ StandardUnique "user1" Nothing | ||
|
|
||
| -- getBy with NULL should return Nothing (standard SQL behavior) | ||
| -- This ensures backwards compatibility - without !nullsNotDistinct, | ||
| -- getBy cannot find NULL values | ||
| result <- getBy $ UniqueStandardEmail "user1" Nothing | ||
|
|
||
| liftIO $ result `Hspec.shouldBe` Nothing | ||
|
|
||
| -- Verify that getBy still works for non-NULL values | ||
| k2 <- insert $ StandardUnique "user2" (Just "test@example.com") | ||
| result2 <- getBy $ UniqueStandardEmail "user2" (Just "test@example.com") | ||
|
|
||
| liftIO $ case result2 of | ||
| Just (Entity key _) -> key `Hspec.shouldBe` k2 | ||
| Nothing -> Hspec.expectationFailure "getBy should find non-NULL values" | ||
|
|
||
| -- The NULLS NOT DISTINCT tables are only migrated on PostgreSQL 15+, so we | ||
| -- detect support once here and only build the feature tests when the table | ||
| -- actually exists. This means a failing/absent migration can never be | ||
| -- mistaken for a passing (shouldThrow) assertion. | ||
| supportsNND <- Hspec.runIO $ runResourceT $ runConn_ isPostgres15OrHigher | ||
| describe "PostgreSQL 15+ features" $ | ||
| if not supportsNND | ||
| then | ||
| it "are skipped (requires PostgreSQL 15 or higher)" $ | ||
| Hspec.pendingWith "Requires PostgreSQL 15 or higher" | ||
| else do | ||
| it "NULLS NOT DISTINCT prevents multiple NULLs" $ | ||
| -- Same name and email twice; the second insert must violate | ||
| -- the unique constraint. Both run in one transaction. | ||
| ( runDb $ do | ||
| cleanNND | ||
| _ <- insert $ NullsNotDistinctUnique "user1" Nothing | ||
| _ <- insert $ NullsNotDistinctUnique "user1" Nothing | ||
| return () | ||
| ) | ||
| `Hspec.shouldThrow` Hspec.anyException | ||
|
|
||
| it "NULLS NOT DISTINCT with multiple nullable fields" $ do | ||
| -- Different NULL patterns are still distinct and should succeed | ||
| runDb $ do | ||
| cleanNND | ||
| _ <- insert $ MultiFieldNND "test1" Nothing Nothing | ||
| _ <- insert $ MultiFieldNND "test1" (Just "value") Nothing | ||
| _ <- insert $ MultiFieldNND "test1" Nothing (Just 42) | ||
|
|
||
| count' <- count ([] :: [Filter MultiFieldNND]) | ||
| liftIO $ count' `Hspec.shouldBe` 3 | ||
|
|
||
| -- The same NULL pattern twice should violate the constraint | ||
| ( runDb $ do | ||
| cleanNND | ||
| _ <- insert $ MultiFieldNND "test1" Nothing Nothing | ||
| _ <- insert $ MultiFieldNND "test1" Nothing Nothing | ||
| return () | ||
| ) | ||
| `Hspec.shouldThrow` Hspec.anyException | ||
|
|
||
| it "getBy finds NULL values with NULLS NOT DISTINCT" $ | ||
| runDb $ do | ||
| cleanNND | ||
|
|
||
| -- Insert with NULL | ||
| k1 <- insert $ NullsNotDistinctUnique "user1" Nothing | ||
|
|
||
| -- With our runtime detection, getBy now uses | ||
| -- IS NOT DISTINCT FROM for entities with | ||
| -- !nullsNotDistinct, allowing it to find NULL values | ||
| result <- getBy $ UniqueNNDEmail "user1" Nothing | ||
|
|
||
| liftIO $ case result of | ||
| Just (Entity key _) -> key `Hspec.shouldBe` k1 | ||
| Nothing -> | ||
| Hspec.expectationFailure | ||
| "getBy should find NULL values when !nullsNotDistinct is set" | ||
|
|
||
| it "migration generates a NULLS NOT DISTINCT constraint" $ | ||
| runDb $ do | ||
| -- Read PostgreSQL's catalog for the generated constraint. | ||
| constraints :: [(Single Text, Single Text)] <- | ||
| rawSql | ||
| "SELECT conname, pg_get_constraintdef(oid) \ | ||
| \FROM pg_constraint \ | ||
| \WHERE conrelid = 'nulls_not_distinct_unique'::regclass \ | ||
| \ AND contype = 'u'" | ||
| [] | ||
|
|
||
| let | ||
| hasNND = | ||
| any | ||
| ( \(Single _, Single def) -> | ||
| "NULLS NOT DISTINCT" `T.isInfixOf` def | ||
| ) | ||
| constraints | ||
|
|
||
| liftIO $ hasNND `Hspec.shouldBe` True |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's unclear to me if this would generally be desirable, or if there would be a preference to run against a matrix of postgres versions, but 12 is a pretty old version of postgres so I bumped to something more recent here to make sure the tests for this branch are actually meaningful.