purescript-postgresql-client/test/Main.purs
2023-12-16 15:24:47 -06:00

533 lines
25 KiB
Haskell

module Test.Main
( main
) where
import Prelude
import Control.Monad.Error.Class (throwError, try)
import Control.Monad.Except.Trans (runExceptT)
import Control.Monad.Trans.Class (lift)
import Data.Argonaut (Json)
import Data.Argonaut (fromArray, fromObject, fromString) as Argonaut
import Data.Array (zip)
import Data.Date (Date, canonicalDate)
import Data.DateTime.Instant (Instant, unInstant)
import Data.Decimal as D
import Data.Either (Either(..))
import Data.Enum (toEnum)
import Data.Foldable (all, length)
import Data.JSDate (JSDate, jsdate, toInstant)
import Data.JSDate as JSDate
import Data.Maybe (Maybe(..), fromJust)
import Data.Newtype (unwrap)
import Data.Number ((%))
import Data.Tuple (Tuple(..))
import Data.Tuple.Nested ((/\))
import Database.PostgreSQL (Client, Configuration, Connection(..), PGConnectionURI, PGError(..), Pool, Query(Query), Row0(Row0), Row1(Row1), Row12(..), Row2(Row2), Row3(Row3), Row9(Row9), fromClient, fromPool, parseURI)
import Database.PostgreSQL.PG (command, execute, onIntegrityError, query, scalar)
import Database.PostgreSQL.PG (withClient, withClientTransaction) as PG
import Database.PostgreSQL.Pool (new) as Pool
import Debug (traceM)
import Effect (Effect)
import Effect.Aff (Aff, error, launchAff)
import Effect.Class (liftEffect)
import Effect.Exception (message)
import Foreign.Object (Object)
import Foreign.Object (fromFoldable) as Object
import JS.BigInt (BigInt)
import JS.BigInt as BigInt
import JS.Unsafe.Stringify (unsafeStringify)
import Partial.Unsafe (unsafePartial)
import Test.Assert (assert)
import Test.Config (load) as Config
import Test.README (AppM)
import Test.README (run) as README
import Test.Unit (TestSuite, suite)
import Test.Unit as Test.Unit
import Test.Unit.Assert (equal)
import Test.Unit.Main (runTest)
bigintFromString :: String -> BigInt
bigintFromString = unsafePartial fromJust <<< BigInt.fromString
withClient :: forall a. Pool -> (Client -> AppM a) -> AppM a
withClient = PG.withClient runExceptT
withClientTransaction :: forall a. Client -> AppM a -> AppM a
withClientTransaction = PG.withClientTransaction runExceptT
pgEqual :: forall a. Eq a => Show a => a -> a -> AppM Unit
pgEqual a b = lift $ equal a b
withRollback
Client
AppM Unit
AppM Unit
withRollback client action = begin *> action *> rollback
where
conn = fromClient client
begin = execute conn (Query "BEGIN TRANSACTION") Row0
rollback = execute conn (Query "ROLLBACK TRANSACTION") Row0
test
Connection
String
AppM Unit
TestSuite
test (Connection (Left _)) name action = Test.Unit.test name $ checkPGErrors $ action
test (Connection (Right client)) name action = Test.Unit.test name $ checkPGErrors $ withRollback client action
transactionTest
String
AppM Unit
TestSuite
transactionTest name action = Test.Unit.test name $ checkPGErrors $ action
checkPGErrors :: AppM Unit -> Aff Unit
checkPGErrors action = do
runExceptT action
>>= case _ of
Left pgError -> Test.Unit.failure ("Unexpected PostgreSQL error occured:" <> unsafeStringify pgError)
Right _ -> pure unit
now Effect Instant
now = unsafePartial $ (fromJust <<< toInstant) <$> JSDate.now
date Int Int Int Date
date y m d = unsafePartial $ fromJust $ canonicalDate <$> toEnum y <*> toEnum m <*> toEnum d
jsdate_ Number Number Number Number Number Number Number JSDate
jsdate_ year month day hour minute second millisecond = jsdate { year, month, day, hour, minute, second, millisecond }
noSuchDatabaseConfig :: Configuration Configuration
noSuchDatabaseConfig config = config { database = "non-existing" <> config.database }
cannotConnectConfig :: Configuration Configuration
cannotConnectConfig config =
config
{ host = Just "127.0.0.1"
, port = Just 45287
}
main Effect Unit
main = do
void
$ launchAff do
-- Running guide from README
void $ runExceptT $ README.run
config Config.load
pool liftEffect $ Pool.new config
checkPGErrors
$ execute (fromPool pool)
( Query
"""
CREATE TEMPORARY TABLE foods (
name text NOT NULL,
delicious boolean NOT NULL,
price NUMERIC(4,2) NOT NULL,
added TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
huge_number INT8 NULL,
PRIMARY KEY (name)
);
CREATE TEMPORARY TABLE dates (
date date NOT NULL
);
CREATE TEMPORARY TABLE timestamps (
timestamp timestamptz NOT NULL
);
CREATE TEMPORARY TABLE jsons (
json json NOT NULL,
jsonb jsonb NOT NULL
);
"""
)
Row0
checkPGErrors
$ withClient pool \client -> do
liftEffect $ runTest
$ do
suite "PostgreSQL client"
$ do
let
testCount n = do
count <-
scalar (fromPool pool)
( Query
"""
SELECT count(*) = $1
FROM foods
"""
)
(Row1 n)
liftEffect <<< assert $ count == Just true
transactionTest "transaction commit" do
withClientTransaction client do
execute (fromClient client)
( Query
"""
INSERT INTO foods (name, delicious, price)
VALUES ($1, $2, $3)
"""
)
(Row3 "pork" true (D.fromString "8.30"))
testCount 1
testCount 1
execute (fromClient client)
( Query
"""
DELETE FROM foods
"""
)
Row0
transactionTest "transaction rollback on PostgreSQL error"
$ do
_ <-
try
$ withClientTransaction client do
execute (fromClient client)
( Query
"""
INSERT INTO foods (name, delicious, price)
VALUES ($1, $2, $3)
"""
)
(Row3 "pork" true (D.fromString "8.30"))
testCount 1
-- invalid SQL query --> PGError is thrown
execute (fromClient client) (Query "foo bar") Row0
-- transaction should've been rolled back
testCount 0
transactionTest "transaction rollback on JavaScript exception"
$ do
result <-
lift $ try $ runExceptT
$ withClientTransaction client do
execute (fromClient client)
( Query
"""
INSERT INTO foods (name, delicious, price)
VALUES ($1, $2, $3)
"""
)
(Row3 "pork" true (D.fromString "8.30"))
testCount 1
-- throw a JavaScript error
lift $ throwError $ error "fail"
-- make sure the JavaScript error was thrown
liftEffect
$ case result of
Left jsErr -> assert (message jsErr == "fail")
Right _ -> assert false
-- transaction should've been rolled back
testCount 0
let
handle = fromClient client
test handle "usage of rows represented by nested tuples"
$ do
execute handle
( Query
"""
INSERT INTO foods (name, delicious, price)
VALUES ($1, $2, $3), ($4, $5, $6), ($7, $8, $9)
"""
)
( ("pork" /\ true /\ (D.fromString "8.30"))
/\ ("sauerkraut" /\ false /\ (D.fromString "3.30"))
/\ ("rookworst" /\ true /\ (D.fromString "5.60"))
)
names <-
query handle
( Query
"""
SELECT name, delicious
FROM foods
WHERE delicious
ORDER BY name ASC
"""
)
Row0
liftEffect <<< assert $ names == [ "pork" /\ true, "rookworst" /\ true ]
test handle "nested tuples as rows - just one element"
$ do
let
row = date 2010 2 31 /\ unit
execute handle
( Query
"""
INSERT INTO dates (date)
VALUES ($1)
"""
)
row
rows <- query handle (Query "SELECT date FROM dates") Row0
liftEffect <<< assert $ rows == [ row ]
let
insertFood =
execute handle
( Query
"""
INSERT INTO foods (name, delicious, price, huge_number)
VALUES ($1, $2, $3, $4), ($5, $6, $7, $8), ($9, $10, $11, $12)
"""
)
( Row12
"pork"
true
(D.fromString "8.30")
(bigintFromString "123")
"sauerkraut"
false
(D.fromString "3.30")
(bigintFromString "456")
"rookworst"
true
(D.fromString "5.60")
(bigintFromString "789")
)
test handle "select column subset"
$ do
insertFood
names <-
query handle
( Query
"""
SELECT name, delicious
FROM foods
WHERE delicious
ORDER BY name ASC
"""
)
Row0
liftEffect <<< assert $ names == [ Row2 "pork" true, Row2 "rookworst" true ]
test handle "delete returning columns subset"
$ do
insertFood
deleted <-
query handle
( Query
"""
DELETE FROM foods
WHERE delicious
RETURNING name, delicious
"""
)
Row0
liftEffect <<< assert $ deleted == [ Row2 "pork" true, Row2 "rookworst" true ]
test handle "delete returning command tag value"
$ do
insertFood
deleted <-
command handle
( Query
"""
DELETE FROM foods
WHERE delicious
"""
)
Row0
liftEffect <<< assert $ deleted == 2
test handle "handling instant value"
$ do
before <- liftEffect $ (unwrap <<< unInstant) <$> now
insertFood
added <-
query handle
( Query
"""
SELECT added
FROM foods
"""
)
Row0
after <- liftEffect $ (unwrap <<< unInstant) <$> now
-- | timestamps are fetched without milliseconds so we have to
-- | round before value down
liftEffect <<< assert
$ all
( \(Row1 t) ->
(unwrap $ unInstant t) >= (before - before % 1000.0)
&& after
>= (unwrap $ unInstant t)
)
added
test handle "handling decimal value"
$ do
insertFood
sauerkrautPrice <-
query handle
( Query
"""
SELECT price
FROM foods
WHERE NOT delicious
"""
)
Row0
liftEffect <<< assert $ sauerkrautPrice == [ Row1 (D.fromString "3.30") ]
test handle "handling bigint value"
$ do
insertFood
hugeNumber <-
query handle
( Query
"""
SELECT huge_number
FROM foods
WHERE NOT delicious
"""
)
Row0
liftEffect <<< assert $ hugeNumber == [ Row1 (bigintFromString "456") ]
transactionTest "integrity error handling"
$ do
withRollback client do
result <-
onIntegrityError (pure "integrity error was handled") do
insertFood
insertFood
pure "integrity error was not handled"
liftEffect $ assert $ result == "integrity error was handled"
test handle "handling date value"
$ do
let
d1 = date 2010 2 31
d2 = date 2017 2 1
d3 = date 2020 6 31
execute handle
( Query
"""
INSERT INTO dates (date)
VALUES ($1), ($2), ($3)
"""
)
(Row3 d1 d2 d3)
(dates :: Array (Row1 Date)) <-
query handle
( Query
"""
SELECT *
FROM dates
ORDER BY date ASC
"""
)
Row0
pgEqual 3 (length dates)
liftEffect <<< assert $ all (\(Tuple (Row1 r) e) -> e == r) $ (zip dates [ d1, d2, d3 ])
test handle "handling Foreign.Object as json and jsonb"
$ do
let
jsonIn = Object.fromFoldable [ Tuple "a" 1, Tuple "a" 2, Tuple "2" 3 ]
let
expected = Object.fromFoldable [ Tuple "a" 2, Tuple "2" 3 ]
execute handle
( Query
"""
INSERT INTO jsons (json, jsonb)
VALUES ($1, $2)
"""
)
(Row2 jsonIn jsonIn)
(js Array (Row2 (Object Int) (Object Int))) <- query handle (Query """SELECT * FROM JSONS""") Row0
liftEffect $ assert $ all (\(Row2 j1 j2) j1 == expected && expected == j2) js
test handle "handling Argonaut.Json as json and jsonb for an object"
$ do
let
input = Argonaut.fromObject (Object.fromFoldable [ Tuple "a" (Argonaut.fromString "value") ])
execute handle
( Query
"""
INSERT INTO jsons (json, jsonb)
VALUES ($1, $2)
"""
)
(Row2 input input)
(js Array (Row2 (Json) (Json))) <- query handle (Query """SELECT * FROM JSONS""") Row0
liftEffect $ assert $ all (\(Row2 j1 j2) j1 == input && j2 == input) js
test handle "handling Argonaut.Json as json and jsonb for an array"
$ do
let
input = Argonaut.fromArray [ Argonaut.fromObject (Object.fromFoldable [ Tuple "a" (Argonaut.fromString "value") ]) ]
execute handle
( Query
"""
INSERT INTO jsons (json, jsonb)
VALUES ($1, $2)
"""
)
(Row2 input input)
(js Array (Row2 (Json) (Json))) <- query handle (Query """SELECT * FROM JSONS""") Row0
liftEffect $ assert $ all (\(Row2 j1 j2) j1 == input && j2 == input) js
test handle "handling jsdate value"
$ do
let
jsd1 = jsdate_ 2010.0 2.0 31.0 6.0 23.0 1.0 123.0
jsd2 = jsdate_ 2017.0 2.0 1.0 12.0 59.0 42.0 999.0
jsd3 = jsdate_ 2020.0 6.0 31.0 23.0 3.0 59.0 333.0
execute handle
( Query
"""
INSERT INTO timestamps (timestamp)
VALUES ($1), ($2), ($3)
"""
)
(Row3 jsd1 jsd2 jsd3)
(timestamps :: Array (Row1 JSDate)) <-
query handle
( Query
"""
SELECT *
FROM timestamps
ORDER BY timestamp ASC
"""
)
Row0
pgEqual 3 (length timestamps)
liftEffect <<< assert $ all (\(Tuple (Row1 r) e) -> e == r) $ (zip timestamps [ jsd1, jsd2, jsd3 ])
suite "PostgreSQL connection errors"
$ do
let
doNothing _ = pure unit
Test.Unit.test "connection refused" do
testPool <- liftEffect $ Pool.new (cannotConnectConfig config)
runExceptT (withClient testPool doNothing)
>>= case _ of
Left (ClientError _ cause) -> equal cause "ECONNREFUSED"
_ -> Test.Unit.failure "foo"
Test.Unit.test "no such database" do
testPool <- liftEffect $ Pool.new (noSuchDatabaseConfig config)
runExceptT (withClient testPool doNothing)
>>= case _ of
Left (ProgrammingError { code }) -> equal code "3D000"
_ -> Test.Unit.failure "PostgreSQL error was expected"
Test.Unit.test "get pool configuration from postgres uri" do
equal (parseURI validUriToPoolConfigs.uri) (Just validUriToPoolConfigs.poolConfig)
equal (parseURI notValidConnUri) Nothing
validUriToPoolConfigs
:: { uri :: PGConnectionURI
, poolConfig :: Configuration
}
validUriToPoolConfigs =
{ uri: "postgres://urllgqrivcyako:c52275a95b7f177e2850c49de9bfa8bedc457ce860ccca664cb15db973554969@ec2-79-124-25-231.eu-west-1.compute.amazonaws.com:5432/e7cecg4nirunpo"
, poolConfig:
{ database: "e7cecg4nirunpo"
, host: Just "ec2-79-124-25-231.eu-west-1.compute.amazonaws.com"
, idleTimeoutMillis: Nothing
, max: Nothing
, password: Just "c52275a95b7f177e2850c49de9bfa8bedc457ce860ccca664cb15db973554969"
, port: Just 5432
, user: Just "urllgqrivcyako"
}
}
notValidConnUri :: PGConnectionURI
notValidConnUri = "postgres://urllgqrivcyakoc52275a95b7f177e2850c49de9bfa8bedc457ce860ccca664cb15db973554969@ec2-79-124-25-231.eu-west-1.compute.amazonaws.com:5432/e7cecg4nirunpo"