-
Notifications
You must be signed in to change notification settings - Fork 303
Expand file tree
/
Copy pathNullsNotDistinctTest.hs
More file actions
255 lines (217 loc) · 10.6 KB
/
Copy pathNullsNotDistinctTest.hs
File metadata and controls
255 lines (217 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
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