-
Notifications
You must be signed in to change notification settings - Fork 301
Expand file tree
/
Copy pathNullsNotDistinctTest.hs
More file actions
274 lines (233 loc) · 11 KB
/
NullsNotDistinctTest.hs
File metadata and controls
274 lines (233 loc) · 11 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
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
{-# 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.Exception (SomeException, try)
import Control.Monad (unless, void, when)
import Control.Monad.IO.Class (MonadIO (..))
import Control.Monad.Trans.Reader (ReaderT)
import Data.Text (Text)
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 qualified Test.Hspec.Expectations.Lifted as Lifted
import PgInit
-- Test entities with and without NULLS NOT DISTINCT
share
[mkPersist sqlSettings, mkMigrate "nullsNotDistinctMigrate"]
[persistLowerCase|
-- Standard unique constraint (allows multiple NULLs)
StandardUnique
name Text
email Text Maybe
UniqueStandardEmail name email !force
deriving Eq Show
-- 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
cleanDB
:: (BaseBackend backend ~ SqlBackend, PersistQueryWrite backend, MonadIO m)
=> ReaderT backend m ()
cleanDB = do
deleteWhere ([] :: [Filter StandardUnique])
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
cleanDB
-- These should both succeed with standard unique
k1 <- insert $ StandardUnique "user1" Nothing
k2 <- insert $ StandardUnique "user2" Nothing
-- Verify both were inserted
count1 <- count [StandardUniqueName ==. "user1"]
count2 <- count [StandardUniqueName ==. "user2"]
liftIO $ do
count1 `Lifted.shouldBe` 1
count2 `Lifted.shouldBe` 1
it "standard unique prevents duplicate non-NULLs" $ do
( runDb $ do
cleanDB
_ <- 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
cleanDB
-- 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 `Lifted.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 `Lifted.shouldBe` k2
Nothing -> Hspec.expectationFailure "getBy should find non-NULL values"
describe "PostgreSQL 15+ features" $ do
it "NULLS NOT DISTINCT prevents multiple NULLs (PostgreSQL 15+)" $ do
runDb $ do
supported <- isPostgres15OrHigher
when supported $ do
-- Run the migration to ensure constraint is created
void $ runMigrationSilent nullsNotDistinctMigrate
unless supported $
liftIO $
Hspec.pendingWith "Requires PostgreSQL 15 or higher"
-- Now test the constraint enforcement separately
( runDb $ do
cleanDB
void $ runMigrationSilent nullsNotDistinctMigrate
_ <- insert $ NullsNotDistinctUnique "user1" Nothing
-- Same name and email - this should violate the unique constraint
_ <- insert $ NullsNotDistinctUnique "user1" Nothing
return ()
)
`Hspec.shouldThrow` Hspec.anyException
it "NULLS NOT DISTINCT with multiple nullable fields (PostgreSQL 15+)" $ do
-- First test that different NULL patterns work
runDb $ do
supported <- isPostgres15OrHigher
if supported
then do
cleanDB
-- First record with NULLs
_ <- insert $ MultiFieldNND "test1" Nothing Nothing
-- Different NULL pattern should succeed
_ <- insert $ MultiFieldNND "test1" (Just "value") Nothing
_ <- insert $ MultiFieldNND "test1" Nothing (Just 42)
count' <- count ([] :: [Filter MultiFieldNND])
liftIO $ count' `Hspec.shouldBe` 3
else
liftIO $ Hspec.pendingWith "Requires PostgreSQL 15 or higher"
-- Test duplicate prevention with same NULL pattern
( runDb $ do
supported <- isPostgres15OrHigher
when supported $ do
cleanDB
_ <- insert $ MultiFieldNND "test1" Nothing Nothing
_ <- insert $ MultiFieldNND "test1" Nothing Nothing
return ()
)
`Hspec.shouldThrow` Hspec.anyException
it "getBy finds NULL values with NULLS NOT DISTINCT (PostgreSQL 15+)" $ do
runDb $ do
supported <- isPostgres15OrHigher
if supported
then do
cleanDB
void $ runMigrationSilent nullsNotDistinctMigrate
-- 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
-- We expect getBy TO find the entity with NULLS NOT DISTINCT
liftIO $ case result of
Just (Entity key _) -> key `Hspec.shouldBe` k1
Nothing ->
Hspec.expectationFailure
"getBy should find NULL values when !nullsNotDistinct is set"
else
liftIO $ Hspec.pendingWith "Requires PostgreSQL 15 or higher"
it "migration generates correct constraints" $ do
runDb $ do
-- Run migration to create tables
void $ runMigrationSilent nullsNotDistinctMigrate
-- Check that constraints were created
-- This query checks PostgreSQL's information schema
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'"
[]
supported <- isPostgres15OrHigher
liftIO $ case constraints of
[] -> return () -- Tables might not exist yet
results -> do
-- Check if any constraint has NULLS NOT DISTINCT
let
hasNND =
any
( \(Single _, Single def) ->
"NULLS NOT DISTINCT" `T.isInfixOf` def
)
results
when supported $
hasNND `Hspec.shouldBe` True