Skip to content

Commit 0ea4d9c

Browse files
committed
feat: return RAISE WARNING notices in error responses
Add a db-warnings-enabled config (default false). When enabled and a request fails, PostgreSQL warnings raised during the failing statement are returned as a structured "warnings" array alongside the existing error fields (severity, SQLSTATE code, message, detail, hint). Successful responses are unchanged. Capture uses a custom libpq notice receiver in the vendored hasql (Hasql.LibPq14.Notices) that copies the PGresult diagnostics before libpq frees them, instead of postgresql-libpq's notice buffer, which flattens every notice to text and drops severity, SQLSTATE, detail and hint. Notices accumulate in a bounded per-connection buffer (100, oldest dropped on overflow), are drained after every statement execution and attached to the session error, and the receiver closure is freed on connection release. The internal hasql pipeline result loop is consolidated through IO.getResults, replacing three copies of the same single <*> dropRemainders glue. Fixes #1071
1 parent 08811db commit 0ea4d9c

21 files changed

Lines changed: 428 additions & 94 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. From versio
44

55
## Unreleased
66

7+
### Added
8+
9+
- New `db-warnings-enabled` config: include PostgreSQL `RAISE WARNING` messages (severity, SQLSTATE, message, detail, hint) as a structured `warnings` array in error responses. Warnings are only surfaced when the request fails; successful responses are unaffected. Fixes #1071.
10+
711
### Changes
812

913
- Fix sporadic "PGRST303 JWT issued at future" errors by @steve-chavez in #5196

postgrest.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ library hasql
100100
Hasql.Statement
101101
Hasql.Transaction
102102
Hasql.Transaction.Sessions
103+
Hasql.LibPq14.Notices
103104
other-modules: Hasql.Commands
104105
Hasql.Connection.Config
105106
Hasql.Connection.Config.ConnectionString

src/hasql/Hasql/Connection/Core.hs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import qualified Hasql.Connection.Config as Config
66
import qualified Hasql.Connection.Setting as Setting
77
import qualified Hasql.IO as IO
88
import qualified Hasql.LibPq14 as LibPQ
9+
import Hasql.LibPq14.Notices (NoticeChannel)
10+
import qualified Hasql.LibPq14.Notices as Notices
911
import Hasql.Prelude
1012
import qualified Hasql.PreparedStatementRegistry as PreparedStatementRegistry
1113

@@ -21,6 +23,8 @@ data Connection
2123
!Bool
2224
-- | Prepared statement registry.
2325
!PreparedStatementRegistry.PreparedStatementRegistry
26+
-- | Buffer of notices received from the server during command execution.
27+
!Notices.NoticeChannel
2428

2529
-- |
2630
-- Possible details of the connection acquistion error.
@@ -37,27 +41,38 @@ acquire settings =
3741
runExceptT $ do
3842
pqConnection <- lift (IO.acquireConnection (Config.connectionString config))
3943
lift (IO.checkConnectionStatus pqConnection) >>= traverse_ throwError
40-
lift (IO.initConnection pqConnection)
4144
integerDatetimes <- lift (IO.getIntegerDatetimes pqConnection)
4245
registry <- lift IO.acquirePreparedStatementRegistry
4346
pqConnectionRef <- lift (newMVar pqConnection)
44-
pure (Connection (Config.usePreparedStatements config) pqConnectionRef integerDatetimes registry)
47+
-- The channel is created last so that only 'initConnection' can fail
48+
-- after its FunPtr exists; that one call is guarded below.
49+
noticeChannel <- lift Notices.newNoticeChannel
50+
lift
51+
( IO.initConnection pqConnection noticeChannel
52+
`onException` do
53+
-- Finish libpq first so its receiver can no longer fire, then
54+
-- free the closure; also covers the underlying connection itself.
55+
IO.releaseConnection pqConnection
56+
Notices.destroyNoticeChannel noticeChannel
57+
)
58+
pure (Connection (Config.usePreparedStatements config) pqConnectionRef integerDatetimes registry noticeChannel)
4559
where
4660
config = Config.fromUpdates settings
4761

4862
-- |
4963
-- Release the connection.
5064
release :: Connection -> IO ()
51-
release (Connection _ pqConnectionRef _ _) =
65+
release (Connection _ pqConnectionRef _ _ noticeChannel) =
5266
mask_ $ do
5367
nullConnection <- LibPQ.newNullConnection
5468
pqConnection <- swapMVar pqConnectionRef nullConnection
5569
IO.releaseConnection pqConnection
70+
Notices.destroyNoticeChannel noticeChannel
5671

5772
-- |
5873
-- Execute an operation on the raw @libpq@ 'LibPQ.Connection'.
5974
--
6075
-- The access to the connection is exclusive.
6176
withLibPQConnection :: Connection -> (LibPQ.Connection -> IO a) -> IO a
62-
withLibPQConnection (Connection _ pqConnectionRef _ _) =
77+
withLibPQConnection (Connection _ pqConnectionRef _ _ _) =
6378
withMVar pqConnectionRef

src/hasql/Hasql/Errors.hs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
module Hasql.Errors where
22

33
import qualified Data.ByteString.Char8 as BC
4+
import qualified Hasql.LibPq14.Notices as Notices
45
import Hasql.Prelude
56

67
-- | Error during execution of a session.
@@ -14,15 +15,19 @@ data SessionError
1415
[Text]
1516
-- | Error details.
1617
CommandError
18+
-- | Notices (e.g. RAISE WARNING messages) buffered during the query's execution.
19+
[Notices.Notice]
1720
| -- | Error during the execution of a pipeline.
1821
PipelineError
1922
-- | Error details.
2023
CommandError
24+
-- | Notices (e.g. RAISE WARNING messages) buffered during the pipeline's execution.
25+
[Notices.Notice]
2126
deriving (Show, Eq)
2227

2328
instance Exception SessionError where
2429
displayException = \case
25-
QueryError query params commandError ->
30+
QueryError query params commandError notices ->
2631
let queryContext :: Maybe (ByteString, Int)
2732
queryContext = case commandError of
2833
ClientError _ -> Nothing
@@ -68,9 +73,28 @@ instance Exception SessionError where
6873
<> show params
6974
<> "\n Error: "
7075
<> renderCommandErrorAsReason commandError
71-
PipelineError commandError ->
72-
"PipelineError!\n Reason: " <> renderCommandErrorAsReason commandError
76+
<> renderNotices notices
77+
PipelineError commandError notices ->
78+
"PipelineError!\n Reason: "
79+
<> renderCommandErrorAsReason commandError
80+
<> renderNotices notices
7381
where
82+
renderNotices = \case
83+
[] -> mempty
84+
ns ->
85+
mconcat
86+
[ "\n Warnings:"
87+
, mconcat (renderNotice <$> ns)
88+
]
89+
renderNotice n =
90+
"\n - "
91+
<> BC.unpack (Notices.noticeSeverity n)
92+
<> " ("
93+
<> BC.unpack (Notices.noticeCode n)
94+
<> "): "
95+
<> BC.unpack (Notices.noticeMessage n)
96+
<> maybe "" (\d -> "\n Details: " <> BC.unpack d) (Notices.noticeDetail n)
97+
<> maybe "" (\h -> "\n Hint: " <> BC.unpack h) (Notices.noticeHint n)
7498
renderCommandErrorAsReason = \case
7599
ClientError (Just message) -> "Client error: " <> show message
76100
ClientError Nothing -> "Client error without details"
@@ -88,6 +112,16 @@ instance Exception SessionError where
88112
UnexpectedAmountOfRows amount ->
89113
"Unexpected amount of rows: " <> show amount
90114

115+
-- |
116+
-- Attach drained notices to a session error. No-op on success paths by
117+
-- construction: callers only invoke this when the session failed.
118+
addNotices :: [Notices.Notice] -> SessionError -> SessionError
119+
addNotices notices = \case
120+
QueryError template params commandError _ ->
121+
QueryError template params commandError notices
122+
PipelineError commandError _ ->
123+
PipelineError commandError notices
124+
91125
-- |
92126
-- An error of some command in the session.
93127
data CommandError

src/hasql/Hasql/IO.hs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import qualified Hasql.Decoders.Results as ResultsDecoders
88
import qualified Hasql.Encoders.Params as ParamsEncoders
99
import Hasql.Errors
1010
import qualified Hasql.LibPq14 as LibPQ
11+
import qualified Hasql.LibPq14.Notices as Notices
1112
import Hasql.Prelude
1213
import qualified Hasql.PreparedStatementRegistry as PreparedStatementRegistry
1314

@@ -50,9 +51,17 @@ getIntegerDatetimes c =
5051
_ -> False
5152

5253
{-# INLINE initConnection #-}
53-
initConnection :: LibPQ.Connection -> IO ()
54-
initConnection c =
54+
initConnection :: LibPQ.Connection -> Notices.NoticeChannel -> IO ()
55+
initConnection c noticeChannel = do
5556
void $ LibPQ.exec c (Commands.asBytes (Commands.setEncodersToUTF8 <> Commands.setMinClientMessagesToWarning))
57+
Notices.registerNoticeReceiver noticeChannel c
58+
59+
-- |
60+
-- Remove and return the notices buffered during command execution.
61+
{-# INLINE drainNotices #-}
62+
drainNotices :: Notices.NoticeChannel -> IO [Notices.Notice]
63+
drainNotices =
64+
Notices.drainNotices
5665

5766
{-# INLINE getResults #-}
5867
getResults :: LibPQ.Connection -> Bool -> ResultsDecoders.Results a -> IO (Either CommandError a)

src/hasql/Hasql/LibPq14/Notices.hs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
{-# LANGUAGE CApiFFI #-}
2+
3+
-- |
4+
-- Programmatic capture of server notices (RAISE WARNING et al).
5+
--
6+
-- Registers a libpq notice receiver that copies the structured diagnostic
7+
-- fields off each notice's PGresult before libpq frees it. Notices accumulate
8+
-- in a bounded per-connection buffer; sessions drain it after each command.
9+
--
10+
-- This bypasses postgresql-libpq's NoticeBuffer machinery entirely
11+
-- ("enableNoticeReporting" / "getNotice"), which flattens every notice to its
12+
-- rendered text and loses severity, SQLSTATE, detail and hint.
13+
module Hasql.LibPq14.Notices
14+
( Notice (..),
15+
NoticeChannel,
16+
newNoticeChannel,
17+
registerNoticeReceiver,
18+
destroyNoticeChannel,
19+
drainNotices,
20+
noticeChannelCapacity,
21+
)
22+
where
23+
24+
import qualified Data.ByteString as BS
25+
import Data.IORef
26+
import qualified Database.PostgreSQL.LibPQ as LibPQ
27+
import Database.PostgreSQL.LibPQ.Internal (PGconn, withConn)
28+
import Foreign.C.String (CString)
29+
import Foreign.C.Types (CInt (..))
30+
import Foreign.Ptr (FunPtr, Ptr,
31+
freeHaskellFunPtr, nullPtr)
32+
import Hasql.Prelude
33+
34+
-- |
35+
-- A single non-fatal message received from the server.
36+
--
37+
-- Fields mirror the libpq @PG_DIAG_*@ diagnostics of the notice's PGresult.
38+
data Notice = Notice
39+
{ -- | e.g. @WARNING@ or @NOTICE@
40+
noticeSeverity :: BS.ByteString,
41+
-- | SQLSTATE code
42+
noticeCode :: BS.ByteString,
43+
-- | Primary human-readable message
44+
noticeMessage :: BS.ByteString,
45+
noticeDetail :: Maybe BS.ByteString,
46+
noticeHint :: Maybe BS.ByteString
47+
}
48+
deriving (Show, Eq)
49+
50+
-- | Maximum notices buffered per connection. Overflow drops the oldest,
51+
-- bounding memory on connections that receive notices outside of sessions
52+
-- (e.g. dedicated LISTEN connections that never drain).
53+
noticeChannelCapacity :: Int
54+
noticeChannelCapacity = 100
55+
56+
-- | Callback signature libpq expects: @void (*)(void *arg, const PGresult *res)@.
57+
-- The result pointer is typed as @Ptr ()@ because postgresql-libpq's Internal
58+
-- module does not export @PGresult@; the same approach is used by
59+
-- "Hasql.LibPq14.Ffi" for its @PQresultStatus@ import.
60+
type NoticeReceiverCb = Ptr () -> Ptr () -> IO ()
61+
62+
foreign import ccall "wrapper"
63+
mkNoticeReceiver :: NoticeReceiverCb -> IO (FunPtr NoticeReceiverCb)
64+
65+
foreign import capi "libpq-fe.h PQsetNoticeReceiver"
66+
pqSetNoticeReceiver :: Ptr PGconn -> FunPtr NoticeReceiverCb -> Ptr () -> IO (FunPtr NoticeReceiverCb)
67+
68+
foreign import capi "libpq-fe.h PQresultErrorField"
69+
pqResultErrorField :: Ptr () -> CInt -> IO CString
70+
71+
foreign import capi "postgres_ext.h value PG_DIAG_SEVERITY" diagSeverityField :: CInt
72+
foreign import capi "postgres_ext.h value PG_DIAG_SQLSTATE" diagSqlstateField :: CInt
73+
foreign import capi "postgres_ext.h value PG_DIAG_MESSAGE_PRIMARY" diagMessagePrimaryField :: CInt
74+
foreign import capi "postgres_ext.h value PG_DIAG_MESSAGE_DETAIL" diagMessageDetailField :: CInt
75+
foreign import capi "postgres_ext.h value PG_DIAG_MESSAGE_HINT" diagMessageHintField :: CInt
76+
77+
-- | Per-connection channel: the accumulating buffer plus the registered
78+
-- receiver closure, so the 'FunPtr' can be freed on connection release.
79+
data NoticeChannel = NoticeChannel
80+
{ noticeChannelRef :: !(IORef [Notice]),
81+
noticeChannelFunPtr :: !(FunPtr NoticeReceiverCb)
82+
}
83+
84+
-- | Allocate an empty channel with its receiver closure already wired to it.
85+
newNoticeChannel :: IO NoticeChannel
86+
newNoticeChannel = do
87+
ref <- newIORef []
88+
funPtr <- mkNoticeReceiver (\_ result -> receiveNotice ref result)
89+
pure (NoticeChannel ref funPtr)
90+
91+
-- | Install the channel's receiver on the given connection. The previously
92+
-- installed receiver returned by libpq is dropped without freeing: if it was
93+
-- libpq's default handler it is a static address, and freeing foreign static
94+
-- function pointers is undefined behavior.
95+
registerNoticeReceiver :: NoticeChannel -> LibPQ.Connection -> IO ()
96+
registerNoticeReceiver channel connection =
97+
withConn connection $ \conn -> do
98+
_ <- pqSetNoticeReceiver conn (noticeChannelFunPtr channel) nullPtr
99+
pure ()
100+
101+
-- | Free the receiver closure. Must be called exactly once per channel,
102+
-- after the connection using it is finished.
103+
destroyNoticeChannel :: NoticeChannel -> IO ()
104+
destroyNoticeChannel =
105+
freeHaskellFunPtr . noticeChannelFunPtr
106+
107+
-- | Remove and return all buffered notices, oldest first.
108+
drainNotices :: NoticeChannel -> IO [Notice]
109+
drainNotices channel =
110+
atomicModifyIORef' (noticeChannelRef channel) (\old -> ([], old))
111+
112+
-- | Receiver entry point: decode the PGresult's diagnostics and buffer them.
113+
-- Runs inside libpq's input processing, while the calling session holds the
114+
-- connection lock, so 'atomicModifyIORef'' suffices.
115+
receiveNotice :: IORef [Notice] -> Ptr () -> IO ()
116+
receiveNotice buffer result = do
117+
mNotice <- decodeNotice result
118+
traverse_ (appendNotice buffer) mNotice
119+
120+
decodeNotice :: Ptr () -> IO (Maybe Notice)
121+
decodeNotice result = do
122+
mSeverity <- field diagSeverityField
123+
mCode <- field diagSqlstateField
124+
mMessage <- field diagMessagePrimaryField
125+
mDetail <- field diagMessageDetailField
126+
mHint <- field diagMessageHintField
127+
case (mSeverity, mCode, mMessage) of
128+
(Just severity, Just code, Just message) ->
129+
pure (Just (Notice severity code message mDetail mHint))
130+
_ ->
131+
pure Nothing
132+
where
133+
field ::
134+
CInt ->
135+
IO (Maybe BS.ByteString)
136+
field code = do
137+
cstr <- pqResultErrorField result code
138+
if cstr == nullPtr
139+
then pure Nothing
140+
else Just <$> BS.packCString cstr
141+
142+
appendNotice :: IORef [Notice] -> Notice -> IO ()
143+
appendNotice buffer notice =
144+
atomicModifyIORef' buffer $ \old ->
145+
let grown = old ++ [notice]
146+
excess = max 0 (length grown - noticeChannelCapacity)
147+
in (drop excess grown, ())

0 commit comments

Comments
 (0)