22 Commits
Author SHA1 Message Date
Paul Brinkmeier 4db509c1b5 Don't include build tools in main lib output 2025-10-07 00:47:50 +02:00
Paul Brinkmeier af16429d82 Also expose ConnectionError in main module 2025-08-24 21:46:07 +02:00
Paul Brinkmeier 42398e71bb Expose ConnectionError constructors 2025-08-24 21:40:12 +02:00
Paul Brinkmeier 1c32e4244e Add actual row number to row number errors 2025-08-20 22:22:21 +02:00
paul 9e9e0204bb Add ToField Day 2025-07-28 08:57:01 +02:00
paul b39da29d13 Bump version 2025-07-20 02:48:53 +02:00
paul 3379fa9df7 Check whether connection succeeded and set encoding in connect 2025-07-20 02:48:14 +02:00
paul 2fddc958b9 Bump version 2025-07-15 23:27:12 +02:00
paul 9a2dbbd3cc Avoid double-closing the connection 2025-07-15 23:22:52 +02:00
paul d17865265a Add Connection type 2025-07-15 22:33:19 +02:00
paul 3879c2603f Add name OID and register it as a textual type 2025-07-15 18:26:34 +02:00
paul 79085906a5 Add flake-utils 2025-07-15 18:11:11 +02:00
paul 8ddeb8a61a Use specific nixpkgs 2025-07-15 18:07:16 +02:00
paul aa1bb459be Set system to aarch64-darwin 2025-07-15 16:44:15 +02:00
paul d6703f9646 Add tuple decoding into multiple records 2024-07-03 17:00:58 +02:00
paul 6d3b1e660a Slightly change readme example code 2024-07-03 11:22:18 +02:00
paul 97e5d9e61c Add Maybe as possible row container type 2024-07-03 11:12:07 +02:00
paul 56585dd5f1 Make the container type for fetch customizable 2024-07-03 11:03:36 +02:00
paul 2b2a048197 Make Connection parameter go last in query functions 2024-07-03 10:09:38 +02:00
paul ef52bd9235 Bump nixpkgs 2024-06-25 22:36:51 +02:00
paul 5a36ec328f Use cabal2nix for flake expression 2024-06-25 22:30:15 +02:00
paul 19a1aa26e4 Minor fixes 2024-06-20 20:10:51 +02:00
16 changed files with 446 additions and 196 deletions
+4 -3
View File
@@ -33,8 +33,8 @@ data User = User
instance Opium.FromRow User where
getUsers :: Connection -> IO (Either Opium.Error [Users])
getUsers conn = Opium.fetch_ conn "SELECT * FROM user"
getUsers :: Connection -> IO (Either Opium.Error [User])
getUsers = Opium.fetch_ "SELECT * FROM user"
```
The `Opium.FromRow` instance is implemented generically for all product types ("records"). It looks up the field name in the query result and decodes the column value using `Opium.FromField`.
@@ -51,7 +51,7 @@ instance Opium.FromRow ScoreByAge where
getScoreByAge :: Connection -> IO ScoreByAge
getScoreByAge conn = do
let query = "SELECT regr_intercept(score, age) AS t, regr_slope(score, age) AS m FROM user"
Right [x] <- Opium.fetch_ conn query
Right (Identity x) <- Opium.fetch_ query conn
pure x
```
@@ -83,3 +83,4 @@ getScoreByAge conn = do
- [ ] `FromRow`
- [ ] Custom `FromField` impls
- [ ] Improve type errors when trying to `instance` a type that isn't a record (e.g. sum type)
- [ ] Improve documentation for `fromRow` module
Generated
+44 -8
View File
@@ -1,23 +1,59 @@
{
"nodes": {
"nixpkgs": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1681753173,
"narHash": "sha256-MrGmzZWLUqh2VstoikKLFFIELXm/lsf/G9U9zR96VD4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0a4206a51b386e5cda731e8ac78d76ad924c7125",
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1752536923,
"narHash": "sha256-fdgPZR7VFSSRIQKOJLcs3qCJBWM64Uak0gAGtWTYAd8=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "c665e4d918eda5d78a175ed8d300809c44932160",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "release-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
+22 -29
View File
@@ -1,35 +1,28 @@
{
description = "An opionated Postgres library";
outputs = { self, nixpkgs }:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
in {
apps.x86_64-linux.cabal = {
type = "app";
program = "${nixpkgs.legacyPackages.x86_64-linux.cabal-install}/bin/cabal";
};
devShells.x86_64-linux.default = pkgs.mkShell {
packages = [
pkgs.cabal-install
pkgs.haskellPackages.implicit-hie
(pkgs.ghc.withPackages (hp: with hp; [
attoparsec
containers
bytestring
hspec
postgresql-libpq
text
time
transformers
vector
]))
inputs.nixpkgs.url = "github:nixos/nixpkgs/release-25.05";
inputs.flake-utils.url = "github:numtide/flake-utils";
pkgs.haskell-language-server
];
shellHook = ''
PS1="<opium> ''${PS1}"
'';
outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
in {
packages.opium = pkgs.haskell.lib.overrideCabal (pkgs.haskellPackages.developPackage { root = ./.; }) {
# Currently the tests require a running Postgres instance.
# This is not automated yet, so don't export the tests.
doCheck = false;
};
};
devShells.default = (pkgs.haskellPackages.developPackage {
root = ./.;
modifier = drv:
pkgs.haskell.lib.addBuildTools drv [
pkgs.cabal-install
pkgs.haskellPackages.implicit-hie
pkgs.haskell-language-server
];
}).env;
}
);
}
+72 -39
View File
@@ -1,13 +1,20 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
module Database.PostgreSQL.Opium
-- * Connection Management
( Connection
, ConnectionError (..)
, connect
, close
-- * Queries
--
-- Functions for performing queries. @fetch@ retrieves rows, @execute@ doesn't.
-- The 'Connection' parameter comes last to facilitate currying for implicitly passing in the connection, e.g. from some framework's connection pool.
--
-- | TODO: Add @newtype Query = Query Text@ with @IsString@ instance to make constructing query strings at run time harder.
( fetch
, fetch
, fetch_
, execute
, execute_
@@ -23,51 +30,68 @@ module Database.PostgreSQL.Opium
)
where
import Control.Monad (void)
import Control.Monad (unless, void)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Except (ExceptT (..), except, runExceptT)
import Control.Monad.Trans.Except (ExceptT (..), runExceptT, throwE)
import Data.Functor.Identity (Identity (..))
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import Database.PostgreSQL.LibPQ
( Connection
, Result
)
import Database.PostgreSQL.LibPQ (Result)
import qualified Data.Text.Encoding as Encoding
import qualified Database.PostgreSQL.LibPQ as LibPQ
import Database.PostgreSQL.Opium.Connection (Connection, ConnectionError (..), connect, close, withRawConnection)
import Database.PostgreSQL.Opium.Error (Error (..), ErrorPosition (..))
import Database.PostgreSQL.Opium.FromField (FromField (..), RawField (..))
import Database.PostgreSQL.Opium.FromRow (FromRow (..))
import Database.PostgreSQL.Opium.FromRow (FromRow (..), ColumnTable)
import Database.PostgreSQL.Opium.ToField (ToField (..))
import Database.PostgreSQL.Opium.ToParamList (ToParamList (..))
-- The order of the type parameters is important, because it is more common to use type applications for providing the row type.
fetch
:: forall a b. (ToParamList b, FromRow a)
=> Connection
-> Text
-> b
-> IO (Either Error [a])
fetch conn query params = runExceptT $ do
result <- execParams conn query params
columnTable <- ExceptT $ getColumnTable @a Proxy result
nRows <- liftIO $ LibPQ.ntuples result
mapM (ExceptT . fromRow result columnTable) [0..nRows - 1]
class RowContainer c where
extract :: FromRow a => Result -> LibPQ.Row -> ColumnTable -> ExceptT Error IO (c a)
fetch_ :: forall a. FromRow a => Connection -> Text -> IO (Either Error [a])
fetch_ conn query = fetch conn query ()
instance RowContainer [] where
extract result nRows columnTable = do
mapM (ExceptT . fromRow result columnTable) [0..nRows - 1]
instance RowContainer Maybe where
extract result nRows columnTable
| nRows == 0 = pure Nothing
| nRows == 1 = Just <$> ExceptT (fromRow result columnTable 0)
| otherwise = throwE $ ErrorMoreThanOneRow $ fromEnum nRows
instance RowContainer Identity where
extract result nRows columnTable = do
unless (nRows == 1) $ throwE $ ErrorNotExactlyOneRow $ fromEnum nRows
Identity <$> ExceptT (fromRow result columnTable 0)
-- The order of the type parameters is important, because it is more common to use type applications for providing the row type and row container type.
fetch
:: forall a b c. (ToParamList c, FromRow a, RowContainer b)
=> Text
-> c
-> Connection
-> IO (Either Error (b a))
fetch query params conn = runExceptT $ do
result <- execParams conn query params
nRows <- liftIO $ LibPQ.ntuples result
columnTable <- ExceptT $ getColumnTable @a Proxy result
extract result nRows columnTable
fetch_ :: forall a c. (FromRow a, RowContainer c) => Text -> Connection -> IO (Either Error (c a))
fetch_ query = fetch query ()
execute
:: forall a. ToParamList a
=> Connection
-> Text
=> Text
-> a
-> Connection
-> IO (Either Error ())
execute conn query params = runExceptT $ void $ execParams conn query params
execute query params conn = runExceptT $ void $ execParams conn query params
execute_ :: Connection -> Text -> IO (Either Error ())
execute_ conn query = execute conn query ()
execute_ :: Text -> Connection -> IO (Either Error ())
execute_ query = execute query ()
execParams
:: ToParamList a
@@ -76,14 +100,23 @@ execParams
-> a
-> ExceptT Error IO Result
execParams conn query params = do
let queryBytes = Encoding.encodeUtf8 query
liftIO (LibPQ.execParams conn queryBytes (toParamList params) LibPQ.Binary) >>= \case
Nothing ->
except $ Left ErrorNoResult
Just result -> do
status <- liftIO $ LibPQ.resultStatus result
mbMessage <- liftIO $ LibPQ.resultErrorMessage result
case mbMessage of
Just "" -> pure result
Nothing -> pure result
Just message -> except $ Left $ ErrorInvalidResult status $ Encoding.decodeUtf8 message
-- Actually run the query while locking the connection.
mbResult <- ExceptT runQuery
-- Check whether the result is valid or nah.
result <- mbResult `orThrow` ErrorNoResult
status <- liftIO $ LibPQ.resultStatus result
mbMessage <- liftIO $ LibPQ.resultErrorMessage result
case mbMessage of
Just "" -> pure result
Nothing -> pure result
Just message -> throwE $ ErrorInvalidResult status $ Encoding.decodeUtf8 message
where
orThrow (Just x) _ = pure x
orThrow Nothing e = throwE e
queryBytes = Encoding.encodeUtf8 query
runQuery =
flip withRawConnection conn $ \mbRawConn -> runExceptT $ do
rawConn <- mbRawConn `orThrow` ErrorConnectionClosed
liftIO $ LibPQ.execParams rawConn queryBytes (toParamList params) LibPQ.Binary
@@ -0,0 +1,68 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
module Database.PostgreSQL.Opium.Connection
( Connection
, ConnectionError (..)
, unsafeWithRawConnection
, withRawConnection
, connect
, close
) where
import Control.Concurrent.MVar (MVar, newMVar, modifyMVar_, withMVar)
import Control.Exception (Exception)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Database.PostgreSQL.LibPQ (ConnStatus (..))
import GHC.Stack (HasCallStack)
import qualified Data.Text.Encoding as Encoding
import qualified Database.PostgreSQL.LibPQ as LibPQ
newtype Connection = Connection
{ rawConnection :: MVar (Maybe LibPQ.Connection)
}
newtype ConnectionError = ConnectionError Text
deriving (Show)
instance Exception ConnectionError
withRawConnection
:: HasCallStack
=> (Maybe LibPQ.Connection -> IO a)
-> Connection
-> IO a
withRawConnection f conn = withMVar conn.rawConnection f
unsafeWithRawConnection
:: HasCallStack
=> (LibPQ.Connection -> IO a)
-> Connection
-> IO a
unsafeWithRawConnection f = withRawConnection $ \case
Nothing -> error "raw connection is missing! perhaps the connection was already closed."
Just rawConn -> f rawConn
connect :: Text -> IO (Either ConnectionError Connection)
connect connectionString = do
-- Appending the client_encoding setting overrides any previous setting in the connection string.
-- We set the client encoding here to make sure we can use it below for decoding connection
-- error messages.
rawConn <- LibPQ.connectdb $ Encoding.encodeUtf8 $ connectionString <> " client_encoding=UTF8"
status <- LibPQ.status rawConn
if status == ConnectionOk then
Right . Connection <$> newMVar (Just rawConn)
else do
rawError <- fromMaybe "" <$> LibPQ.errorMessage rawConn
pure $ Left $ ConnectionError $ Encoding.decodeUtf8Lenient rawError
close :: Connection -> IO ()
close conn = modifyMVar_ conn.rawConnection $ \case
Just rawConn -> do
LibPQ.finish rawConn
pure Nothing
Nothing ->
pure Nothing
+3
View File
@@ -17,6 +17,9 @@ data Error
| ErrorInvalidOid Text Oid
| ErrorUnexpectedNull ErrorPosition
| ErrorInvalidField ErrorPosition Oid ByteString String
| ErrorNotExactlyOneRow Int
| ErrorMoreThanOneRow Int
| ErrorConnectionClosed
deriving (Eq, Show)
instance Exception Error where
+1 -1
View File
@@ -61,7 +61,7 @@ instance FromField ByteString where
-- | See https://www.postgresql.org/docs/current/datatype-character.html.
-- Accepts @text@, @character@ and @character varying@.
instance FromField Text where
validOid Proxy = eq Oid.text \/ eq Oid.character \/ eq Oid.characterVarying
validOid Proxy = eq Oid.name \/ eq Oid.text \/ eq Oid.character \/ eq Oid.characterVarying
parseField = Encoding.decodeUtf8 <$> AP.takeByteString
-- Accepts @text@, @character@ and @character varying@.
+31 -5
View File
@@ -14,6 +14,7 @@ module Database.PostgreSQL.Opium.FromRow
-- * FromRow
( FromRow (..)
-- * Internal
, ColumnTable
, toListColumnTable
) where
@@ -52,12 +53,34 @@ class FromRow a where
fromRow result columnTable row =
runExceptT $ to <$> fromRow' @0 FRProxy (FromRowCtx result columnTable) row
instance
( Generic a
, GetColumnTable' (Rep a)
, FromRow' 0 (Rep a)
, Generic b
, GetColumnTable' (Rep b)
, FromRow' (NumberOfMembers (Rep a)) (Rep b)
) => FromRow (a, b) where
getColumnTable Proxy result = runExceptT $ do
ctA <- newColumnTable <$> getColumnTable' @(Rep a) Proxy result
ctB <- newColumnTable <$> getColumnTable' @(Rep b) Proxy result
pure $ ctA `concatColumnTables` ctB
fromRow result ct row = runExceptT $ do
x <- to <$> fromRow' @0 FRProxy (FromRowCtx result ct) row
y <- to <$> fromRow' @(NumberOfMembers (Rep a)) FRProxy (FromRowCtx result ct) row
pure (x, y)
newtype ColumnTable = ColumnTable (Vector (Column, Oid))
deriving (Eq, Show)
newColumnTable :: [(Column, Oid)] -> ColumnTable
newColumnTable = ColumnTable . Vector.fromList
concatColumnTables :: ColumnTable -> ColumnTable -> ColumnTable
concatColumnTables (ColumnTable a) (ColumnTable b) =
ColumnTable $ a <> b
indexColumnTable :: ColumnTable -> Int -> (Column, Oid)
indexColumnTable (ColumnTable v) i = v `Vector.unsafeIndex` i
@@ -89,7 +112,7 @@ type family NumberOfMembers f where
NumberOfMembers (M1 D _ f) = NumberOfMembers f
-- The constructor has as many members as the type that it contains.
NumberOfMembers (M1 C _ f) = NumberOfMembers f
-- A product type has as many types as its subtypes have together.
-- A product type has as many members as its subtypes have together.
NumberOfMembers (f :*: g) = NumberOfMembers f + NumberOfMembers g
-- A selector has/is exactly one member.
NumberOfMembers (M1 S _ f) = 1
@@ -99,19 +122,23 @@ data FromRowCtx = FromRowCtx
Result -- ^ Obtained from 'LibPQ.execParams'.
ColumnTable -- ^ 'Vector' of expected columns indices and OIDs.
-- Specialized proxy type to be used instead of `Proxy (n, f)`
data FRProxy (n :: Nat) (f :: Type -> Type) = FRProxy
class FromRow' (n :: Nat) (f :: Type -> Type) where
fromRow' :: FRProxy n f -> FromRowCtx -> Row -> ExceptT Error IO (f p)
instance FromRow' n f => FromRow' n (M1 D c f) where
fromRow' FRProxy ctx row = M1 <$> fromRow' @n FRProxy ctx row
fromRow' FRProxy ctx row =
M1 <$> fromRow' @n FRProxy ctx row
instance FromRow' n f => FromRow' n (M1 C c f) where
fromRow' FRProxy ctx row = M1 <$> fromRow' @n FRProxy ctx row
fromRow' FRProxy ctx row =
M1 <$> fromRow' @n FRProxy ctx row
instance (FromRow' n f, FromRow' (n + NumberOfMembers f) g) => FromRow' n (f :*: g) where
fromRow' FRProxy ctx row = (:*:) <$> fromRow' @n FRProxy ctx row <*> fromRow' @(n + NumberOfMembers f) FRProxy ctx row
fromRow' FRProxy ctx row =
(:*:) <$> fromRow' @n FRProxy ctx row <*> fromRow' @(n + NumberOfMembers f) FRProxy ctx row
instance {-# OVERLAPPABLE #-} (KnownNat n, KnownSymbol nameSym, FromField t) => FromRow' n (M1 S ('MetaSel ('Just nameSym) nu ns dl) (Rec0 t)) where
fromRow' FRProxy = decodeField memberIndex nameText $ \row ->
@@ -158,4 +185,3 @@ decodeField memberIndex nameText g (FromRowCtx result columnTable) row = do
first
(ErrorInvalidField (ErrorPosition row nameText) oid field)
(Just <$> fromField field)
+3
View File
@@ -9,6 +9,9 @@ bytea = Oid 17
-- string types
name :: Oid
name = Oid 19
text :: Oid
text = Oid 25
+10
View File
@@ -7,8 +7,10 @@ module Database.PostgreSQL.Opium.ToField
import Data.Bits (Bits (..))
import Data.ByteString (ByteString)
import Data.Int (Int32)
import Data.List (singleton)
import Data.Text (Text)
import Data.Time (Day, diffDays, fromGregorian)
import Data.Word (Word32)
import Database.PostgreSQL.LibPQ (Format (..), Oid)
import Unsafe.Coerce (unsafeCoerce)
@@ -48,3 +50,11 @@ instance ToField Float where
instance ToField Double where
toField x = Just (Oid.doublePrecision, encodeBigEndian @Word 8 $ unsafeCoerce x, Binary)
toPostgresJulian :: Day -> Integer
toPostgresJulian date = diffDays date postgresEpoch
where
postgresEpoch = fromGregorian 2000 1 1
instance ToField Day where
toField x = Just (Oid.date, encodeBigEndian @Int32 4 $ fromIntegral $ toPostgresJulian x, Binary)
+6 -2
View File
@@ -20,7 +20,7 @@ name: opium
-- PVP summary: +-+------- breaking API changes
-- | | +----- non-breaking API additions
-- | | | +--- code changes with no API change
version: 0.1.0.0
version: 1.1.1.0
-- A short (one-line) description of the package.
-- synopsis:
@@ -63,6 +63,7 @@ library
Database.PostgreSQL.Opium,
Database.PostgreSQL.Opium.FromField,
Database.PostgreSQL.Opium.FromRow,
Database.PostgreSQL.Opium.Connection,
Database.PostgreSQL.Opium.ToField
-- Modules included in this library but not exported.
@@ -117,13 +118,16 @@ test-suite opium-test
other-modules:
SpecHook,
Database.PostgreSQL.OpiumSpec,
Database.PostgreSQL.Opium.FromFieldSpec
Database.PostgreSQL.Opium.FromFieldSpec,
Database.PostgreSQL.Opium.FromRowSpec,
Database.PostgreSQL.Opium.ToFieldSpec
-- Test dependencies.
build-depends:
base,
opium,
bytestring,
containers,
hspec,
postgresql-libpq,
time,
+11 -12
View File
@@ -14,7 +14,6 @@ import Data.Time
, timeOfDayToTime
)
import Data.Text (Text)
import Database.PostgreSQL.LibPQ (Connection)
import Database.PostgreSQL.Opium (FromRow)
import GHC.Generics (Generic)
import Test.Hspec (SpecWith, describe, it, shouldBe, shouldSatisfy)
@@ -113,15 +112,15 @@ newtype ARawField = ARawField
instance FromRow ARawField where
shouldFetch :: (Eq a, FromRow a, Show a) => Connection -> Text -> [a] -> IO ()
shouldFetch :: (Eq a, FromRow a, Show a) => Opium.Connection -> Text -> [a] -> IO ()
shouldFetch conn query expectedRows = do
actualRows <- Opium.fetch_ conn query
actualRows <- Opium.fetch_ query conn
actualRows `shouldBe` Right expectedRows
(/\) :: (a -> Bool) -> (a -> Bool) -> a -> Bool
p /\ q = \x -> p x && q x
spec :: SpecWith Connection
spec :: SpecWith Opium.Connection
spec = do
describe "FromField Int" $ do
it "Decodes smallint" $ \conn -> do
@@ -213,15 +212,15 @@ spec = do
shouldFetch conn "SELECT 4.2::real AS float" [AFloat 4.2]
it "Decodes NaN::real" $ \conn -> do
Right [AFloat value] <- Opium.fetch_ conn "SELECT 'NaN'::real AS float"
Right [AFloat value] <- Opium.fetch_ "SELECT 'NaN'::real AS float" conn
value `shouldSatisfy` isNaN
it "Decodes Infinity::real" $ \conn -> do
Right [AFloat value] <- Opium.fetch_ conn "SELECT 'Infinity'::real AS float"
Right [AFloat value] <- Opium.fetch_ "SELECT 'Infinity'::real AS float" conn
value `shouldSatisfy` (isInfinite /\ (> 0))
it "Decodes -Infinity::real" $ \conn -> do
Right [AFloat value] <- Opium.fetch_ conn "SELECT '-Infinity'::real AS float"
Right [AFloat value] <- Opium.fetch_ "SELECT '-Infinity'::real AS float" conn
value `shouldSatisfy` (isInfinite /\ (< 0))
describe "FromField Double" $ do
@@ -229,22 +228,22 @@ spec = do
shouldFetch conn "SELECT 4.2::double precision AS double" [ADouble 4.2]
it "Decodes NaN::double precision" $ \conn -> do
Right [ADouble value] <- Opium.fetch_ conn "SELECT 'NaN'::double precision AS double"
Right [ADouble value] <- Opium.fetch_ "SELECT 'NaN'::double precision AS double" conn
value `shouldSatisfy` isNaN
it "Decodes Infinity::double precision" $ \conn -> do
Right [ADouble value] <- Opium.fetch_ conn "SELECT 'Infinity'::double precision AS double"
Right [ADouble value] <- Opium.fetch_ "SELECT 'Infinity'::double precision AS double" conn
value `shouldSatisfy` (isInfinite /\ (> 0))
it "Decodes -Infinity::double precision" $ \conn -> do
Right [ADouble value] <- Opium.fetch_ conn "SELECT '-Infinity'::double precision AS double"
Right [ADouble value] <- Opium.fetch_ "SELECT '-Infinity'::double precision AS double" conn
value `shouldSatisfy` (isInfinite /\ (< 0))
it "Decodes {inf,-inf}::double precision" $ \conn -> do
Right [ADouble value0] <- Opium.fetch_ conn "SELECT 'inf'::double precision AS double"
Right [ADouble value0] <- Opium.fetch_ "SELECT 'inf'::double precision AS double" conn
value0 `shouldSatisfy` (isInfinite /\ (> 0))
Right [ADouble value1] <- Opium.fetch_ conn "SELECT '-inf'::double precision AS double"
Right [ADouble value1] <- Opium.fetch_ "SELECT '-inf'::double precision AS double" conn
value1 `shouldSatisfy` (isInfinite /\ (< 0))
describe "FromField Bool" $ do
@@ -0,0 +1,92 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
module Database.PostgreSQL.Opium.FromRowSpec (spec) where
import Data.ByteString (ByteString)
import Data.Proxy (Proxy (..))
import Test.Hspec (SpecWith, aroundWith, describe, it, shouldBe)
import qualified Database.PostgreSQL.LibPQ as LibPQ
import Database.PostgreSQL.Opium.Connection (unsafeWithRawConnection)
import Database.PostgreSQL.OpiumSpec (ManyFields (..), MaybeTest (..), Person (..), Only (..))
import qualified Database.PostgreSQL.Opium as Opium
import qualified Database.PostgreSQL.Opium.FromRow as Opium.FromRow
shouldHaveColumns
:: Opium.FromRow a
=> Proxy a
-> LibPQ.Connection
-> ByteString
-> [LibPQ.Column]
-> IO ()
shouldHaveColumns proxy conn query expectedColumns = do
Just result <- LibPQ.execParams conn query [] LibPQ.Binary
columnTable <- Opium.getColumnTable proxy result
let actualColumns = fmap (map fst . Opium.FromRow.toListColumnTable) columnTable
actualColumns `shouldBe` Right expectedColumns
-- These test the mapping from Result to ColumnTable/FromRow instances.
-- They use the raw LibPQ connection for retrieving the Results.
spec :: SpecWith Opium.Connection
spec = aroundWith unsafeWithRawConnection $ do
describe "getColumnTable" $ do
it "Gets the column table for a result" $ \conn -> do
shouldHaveColumns @Person Proxy conn
"SELECT name, age FROM person"
[0, 1]
it "Gets the numbers right for funky configurations" $ \conn -> do
shouldHaveColumns @Person Proxy conn
"SELECT age, name FROM person"
[1, 0]
shouldHaveColumns @Person Proxy conn
"SELECT 0 AS a, 1 AS b, 2 AS c, age, 4 AS d, name FROM person"
[5, 3]
it "Fails for missing columns" $ \conn -> do
Just result <- LibPQ.execParams conn "SELECT 0 AS a FROM person" [] LibPQ.Binary
columnTable <- Opium.getColumnTable @Person Proxy result
columnTable `shouldBe` Left (Opium.ErrorMissingColumn "name")
describe "fromRow" $ do
it "Decodes rows in a Result" $ \conn -> do
Just result <- LibPQ.execParams conn "SELECT * FROM person" [] LibPQ.Binary
Right columnTable <- Opium.getColumnTable @Person Proxy result
row0 <- Opium.fromRow @Person result columnTable 0
row0 `shouldBe` Right (Person "paul" 25)
row1 <- Opium.fromRow @Person result columnTable 1
row1 `shouldBe` Right (Person "albus" 103)
it "Decodes NULL into Nothing for Maybes" $ \conn -> do
Just result <- LibPQ.execParams conn "SELECT NULL AS a" [] LibPQ.Binary
Right columnTable <- Opium.getColumnTable @MaybeTest Proxy result
row <- Opium.fromRow result columnTable 0
row `shouldBe` Right (MaybeTest Nothing)
it "Decodes values into Just for Maybes" $ \conn -> do
Just result <- LibPQ.execParams conn "SELECT 'abc' AS a" [] LibPQ.Binary
Right columnTable <- Opium.getColumnTable @MaybeTest Proxy result
row <- Opium.fromRow result columnTable 0
row `shouldBe` Right (MaybeTest $ Just "abc")
it "Works for many fields" $ \conn -> do
Just result <- LibPQ.execParams conn "SELECT 'abc' AS a, 42 AS b, 1.0::double precision AS c, 'test' AS d, true AS e" [] LibPQ.Binary
Right columnTable <- Opium.getColumnTable @ManyFields Proxy result
row <- Opium.fromRow result columnTable 0
row `shouldBe` Right (ManyFields "abc" 42 1.0 "test" True)
it "Decodes multiple records into a tuple" $ \conn -> do
Just result <- LibPQ.execParams conn "SELECT 'albus' AS name, 123 AS age, 42 AS only" [] LibPQ.Binary
Right columnTable <- Opium.getColumnTable @(Person, Only Int) Proxy result
row <- Opium.fromRow @(Person, Only Int) result columnTable 0
row `shouldBe` Right (Person "albus" 123, Only 42)
@@ -0,0 +1,30 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
module Database.PostgreSQL.Opium.ToFieldSpec (spec) where
import Data.Functor.Identity (Identity (..))
import Data.Text (Text)
import Data.Time (fromGregorian)
import GHC.Generics (Generic)
import Test.Hspec (SpecWith, describe, it, shouldBe)
import qualified Database.PostgreSQL.Opium as Opium
newtype Result = Result
{ a :: Bool
} deriving (Show, Eq, Generic)
instance Opium.FromRow Result
shouldBeTrue :: Opium.ToParamList p => Opium.Connection -> Text -> p -> IO ()
shouldBeTrue conn query params = do
result <- Opium.fetch query params conn
result `shouldBe` Right (Identity (Result True))
spec :: SpecWith Opium.Connection
spec = do
describe "ToField Day" $ do
it "Encodes Day" $ \conn -> do
shouldBeTrue conn "SELECT $1 = date '1997-09-29' AS a" (Identity $ fromGregorian 1997 9 29)
shouldBeTrue conn "SELECT $1 = date '2025-07-28' AS a" (Identity $ fromGregorian 2025 7 28)
+38 -78
View File
@@ -4,20 +4,16 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
module Database.PostgreSQL.OpiumSpec (spec) where
module Database.PostgreSQL.OpiumSpec (ManyFields (..), MaybeTest (..), Person (..), Only (..), spec) where
import Data.ByteString (ByteString)
import Data.Either (isLeft)
import Data.Functor.Identity (Identity (..))
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import Database.PostgreSQL.LibPQ (Connection)
import GHC.Generics (Generic)
import Test.Hspec (SpecWith, describe, it, shouldBe, shouldSatisfy)
import qualified Database.PostgreSQL.LibPQ as LibPQ
import qualified Database.PostgreSQL.Opium as Opium
import qualified Database.PostgreSQL.Opium.FromRow as Opium.FromRow
data Person = Person
{ name :: Text
@@ -55,99 +51,63 @@ newtype Only a = Only
instance Opium.FromField a => Opium.FromRow (Only a) where
shouldHaveColumns
:: Opium.FromRow a
=> Proxy a
-> Connection
-> ByteString
-> [LibPQ.Column]
-> IO ()
shouldHaveColumns proxy conn query expectedColumns = do
Just result <- LibPQ.execParams conn query [] LibPQ.Binary
columnTable <- Opium.getColumnTable proxy result
let actualColumns = fmap (map fst . Opium.FromRow.toListColumnTable) columnTable
actualColumns `shouldBe` Right expectedColumns
spec :: SpecWith Connection
spec :: SpecWith Opium.Connection
spec = do
describe "getColumnTable" $ do
it "Gets the column table for a result" $ \conn -> do
shouldHaveColumns @Person Proxy conn
"SELECT name, age FROM person"
[0, 1]
it "Gets the numbers right for funky configurations" $ \conn -> do
shouldHaveColumns @Person Proxy conn
"SELECT age, name FROM person"
[1, 0]
shouldHaveColumns @Person Proxy conn
"SELECT 0 AS a, 1 AS b, 2 AS c, age, 4 AS d, name FROM person"
[5, 3]
it "Fails for missing columns" $ \conn -> do
Just result <- LibPQ.execParams conn "SELECT 0 AS a FROM person" [] LibPQ.Binary
columnTable <- Opium.getColumnTable @Person Proxy result
columnTable `shouldBe` Left (Opium.ErrorMissingColumn "name")
describe "fromRow" $ do
it "Decodes rows in a Result" $ \conn -> do
Just result <- LibPQ.execParams conn "SELECT * FROM person" [] LibPQ.Binary
Right columnTable <- Opium.getColumnTable @Person Proxy result
row0 <- Opium.fromRow @Person result columnTable 0
row0 `shouldBe` Right (Person "paul" 25)
row1 <- Opium.fromRow @Person result columnTable 1
row1 `shouldBe` Right (Person "albus" 103)
it "Decodes NULL into Nothing for Maybes" $ \conn -> do
Just result <- LibPQ.execParams conn "SELECT NULL AS a" [] LibPQ.Binary
Right columnTable <- Opium.getColumnTable @MaybeTest Proxy result
row <- Opium.fromRow result columnTable 0
row `shouldBe` Right (MaybeTest Nothing)
it "Decodes values into Just for Maybes" $ \conn -> do
Just result <- LibPQ.execParams conn "SELECT 'abc' AS a" [] LibPQ.Binary
Right columnTable <- Opium.getColumnTable @MaybeTest Proxy result
row <- Opium.fromRow result columnTable 0
row `shouldBe` Right (MaybeTest $ Just "abc")
it "Works for many fields" $ \conn -> do
Just result <- LibPQ.execParams conn "SELECT 'abc' AS a, 42 AS b, 1.0::double precision AS c, 'test' AS d, true AS e" [] LibPQ.Binary
Right columnTable <- Opium.getColumnTable @ManyFields Proxy result
row <- Opium.fromRow result columnTable 0
row `shouldBe` Right (ManyFields "abc" 42 1.0 "test" True)
describe "fetch" $ do
it "Passes numbered parameters and retrieves a list of rows" $ \conn -> do
rows <- Opium.fetch conn "SELECT ($1 + $2) AS only" (17 :: Int, 25 :: Int)
rows <- Opium.fetch "SELECT ($1 + $2) AS only" (17 :: Int, 25 :: Int) conn
rows `shouldBe` Right [Only (42 :: Int)]
it "Uses Identity to pass single parameters" $ \conn -> do
rows <- Opium.fetch conn "SELECT count(*) AS only FROM person WHERE name = $1" $ Identity ("paul" :: Text)
rows <- Opium.fetch "SELECT count(*) AS only FROM person WHERE name = $1" (Identity ("paul" :: Text)) conn
rows `shouldBe` Right [Only (1 :: Int)]
describe "fetch_" $ do
it "Retrieves a list of rows" $ \conn -> do
rows <- Opium.fetch_ conn "SELECT * FROM person"
rows <- Opium.fetch_ "SELECT * FROM person" conn
rows `shouldBe` Right [Person "paul" 25, Person "albus" 103]
it "Fails for invalid queries" $ \conn -> do
rows <- Opium.fetch_ @Person conn "MRTLBRNFT"
rows <- Opium.fetch_ @Person @[] "MRTLBRNFT" conn
rows `shouldSatisfy` isLeft
it "Fails for unexpected NULLs" $ \conn -> do
rows <- Opium.fetch_ @Person conn "SELECT NULL AS name, 0 AS age"
rows <- Opium.fetch_ @Person @[] "SELECT NULL AS name, 0 AS age" conn
rows `shouldBe` Left (Opium.ErrorUnexpectedNull (Opium.ErrorPosition 0 "name"))
it "Fails for the wrong column type" $ \conn -> do
rows <- Opium.fetch_ @Person conn "SELECT 'quby' AS name, 'indeterminate' AS age"
rows <- Opium.fetch_ @Person @[] "SELECT 'quby' AS name, 'indeterminate' AS age" conn
rows `shouldBe` Left (Opium.ErrorInvalidOid "age" $ LibPQ.Oid 25)
it "Works for the readme regression example" $ \conn -> do
rows <- Opium.fetch_ @ScoreByAge conn "SELECT regr_intercept(score, age) AS t, regr_slope(score, age) AS m FROM person"
rows <- Opium.fetch_ @ScoreByAge @[] "SELECT regr_intercept(score, age) AS t, regr_slope(score, age) AS m FROM person" conn
rows `shouldSatisfy` \case { (Right [ScoreByAge _ _]) -> True; _ -> False }
it "Accepts exactly one row when Identity is the row container type" $ \conn -> do
row <- Opium.fetch_ "SELECT 42 AS only" conn
row `shouldBe` Right (Identity (Only (42 :: Int)))
it "Does not accept zero rows when Identity is the row container type" $ \conn -> do
row <- Opium.fetch_ @(Only Int) @Identity "SELECT 42 AS only WHERE false" conn
row `shouldSatisfy` isLeft
it "Does not accept two rows when Identity is the row container type" $ \conn -> do
row <- Opium.fetch_ @(Only Int) @Identity "SELECT 17 AS only UNION ALL SELECT 25 AS only" conn
row `shouldSatisfy` isLeft
it "Accepts zero rows when Maybe is the row container type" $ \conn -> do
row <- Opium.fetch_ @(Only Int) @Maybe "SELECT 17 AS only WHERE false" conn
row `shouldBe` Right Nothing
it "Accepts one row when Maybe is the row container type" $ \conn -> do
row <- Opium.fetch_ @(Only Int) @Maybe "SELECT 42 AS only" conn
row `shouldBe` Right (Just (Only 42))
it "Does not accept two rows when Maybe is the row container type" $ \conn -> do
row <- Opium.fetch_ @(Only Int) @Maybe "SELECT 17 AS only UNION ALL SELECT 25 AS only" conn
row `shouldSatisfy` isLeft
describe "close" $ do
it "Does not crash when called twice on the same connection" $ \conn -> do
Opium.close conn
Opium.close conn
+11 -19
View File
@@ -3,16 +3,15 @@
module SpecHook (hook) where
import Control.Exception (bracket)
import Database.PostgreSQL.LibPQ (Connection)
import System.Environment (lookupEnv)
import Test.Hspec (Spec, SpecWith, around)
import Text.Printf (printf)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Encoding
import qualified Database.PostgreSQL.LibPQ as LibPQ
setupConnection :: IO Connection
import qualified Database.PostgreSQL.Opium as Opium
setupConnection :: IO Opium.Connection
setupConnection = do
Just dbUser <- lookupEnv "DB_USER"
Just dbPass <- lookupEnv "DB_PASS"
@@ -20,24 +19,17 @@ setupConnection = do
Just dbPort <- lookupEnv "DB_PORT"
let dsn = printf "host=localhost user=%s password=%s dbname=%s port=%s" dbUser dbPass dbName dbPort
conn <- LibPQ.connectdb $ Encoding.encodeUtf8 $ Text.pack dsn
_ <- LibPQ.setClientEncoding conn "UTF8"
Right conn <- Opium.connect $ Text.pack dsn
_ <- LibPQ.exec conn "CREATE TABLE person (name TEXT NOT NULL, age INT NOT NULL, score DOUBLE PRECISION NOT NULL, motto TEXT)"
_ <- LibPQ.exec conn "INSERT INTO person VALUES ('paul', 25, 30), ('albus', 103, 50.42)"
Right _ <- Opium.execute_ "DROP TABLE IF EXISTS person" conn
Right _ <- Opium.execute_ "CREATE TABLE person (name TEXT NOT NULL, age INT NOT NULL, score DOUBLE PRECISION NOT NULL, motto TEXT)" conn
Right _ <- Opium.execute_ "INSERT INTO person VALUES ('paul', 25, 30), ('albus', 103, 50.42)" conn
pure conn
teardownConnection :: Connection -> IO ()
teardownConnection :: Opium.Connection -> IO ()
teardownConnection conn = do
_ <- LibPQ.exec conn "DROP TABLE person"
LibPQ.finish conn
Opium.close conn
class SpecInput a where
hook :: SpecWith a -> Spec
instance SpecInput Connection where
hook = around $ bracket setupConnection teardownConnection
instance SpecInput () where
hook = id
hook :: SpecWith Opium.Connection -> Spec
hook = around $ bracket setupConnection teardownConnection