Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5485b8ca9a | ||
|
|
895e9c536c | ||
|
|
16c767134c | ||
|
|
0a8b836435 | ||
|
|
5796f86100 | ||
|
|
101eac1cce | ||
|
|
1ae14afdf2 | ||
|
|
69090bd224 | ||
|
|
5d5160fbd7 |
@@ -38,7 +38,7 @@ jobs:
|
|||||||
|
|
||||||
# This is required for backport action to cherry-pick the PR
|
# This is required for backport action to cherry-pick the PR
|
||||||
- name: Fetch PR ref
|
- name: Fetch PR ref
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
token: ${{ steps.app-token.outputs.token }}
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ jobs:
|
|||||||
runs-on: ${{ matrix.runs-on }}
|
runs-on: ${{ matrix.runs-on }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
- uses: haskell-actions/setup@0512451d82f3ca8c147db62e30464e7c4ca63d30 # v2.9.1
|
- uses: haskell-actions/setup@dc63c94789664bb2910876ec3dfeeaa24d23b96b # v2.10.2
|
||||||
with:
|
with:
|
||||||
# This must match the version in stack.yaml's resolver
|
# This must match the version in stack.yaml's resolver
|
||||||
ghc-version: 9.6.7
|
ghc-version: 9.6.7
|
||||||
@@ -177,7 +177,7 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
- uses: haskell-actions/setup@0512451d82f3ca8c147db62e30464e7c4ca63d30 # v2.9.1
|
- uses: haskell-actions/setup@dc63c94789664bb2910876ec3dfeeaa24d23b96b # v2.10.2
|
||||||
with:
|
with:
|
||||||
ghc-version: ${{ matrix.ghc }}
|
ghc-version: ${{ matrix.ghc }}
|
||||||
- name: Cache .cabal
|
- name: Cache .cabal
|
||||||
|
|||||||
+6
-2
@@ -4,9 +4,13 @@ All notable changes to this project will be documented in this file. From versio
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
### Added
|
## [14.4] - 2026-01-29
|
||||||
|
|
||||||
- Log error when `db-schemas` config contains schema `pg_catalog` or `information_schema` by @taimoorzaeem in #4359
|
### Fixed
|
||||||
|
|
||||||
|
- Ensure Listener connections are released by @mkleczek in #4614
|
||||||
|
- Fix incorrectly filtering the returned representation for PATCH requests when using `or/and` filters by @laurenceisla in #3707
|
||||||
|
- Fix listener running with exception masked after first failure by @mkleczek #4615
|
||||||
|
|
||||||
## [14.3] - 2026-01-03
|
## [14.3] - 2026-01-03
|
||||||
|
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ linkcheck_ignore = [
|
|||||||
r"https://www.cybertec-postgresql.com/.*",
|
r"https://www.cybertec-postgresql.com/.*",
|
||||||
# Odd SSL error
|
# Odd SSL error
|
||||||
r"https://www.dripdepot.com",
|
r"https://www.dripdepot.com",
|
||||||
|
r"https://www.euronodes.com",
|
||||||
# New GitHub UI delays comment load, so anchor fails
|
# New GitHub UI delays comment load, so anchor fails
|
||||||
r"https://github.com/.*#issuecomment",
|
r"https://github.com/.*#issuecomment",
|
||||||
# Random 500 Internal Server Error
|
# Random 500 Internal Server Error
|
||||||
|
|||||||
@@ -213,7 +213,6 @@ In Production
|
|||||||
Here are some companies that use PostgREST in production.
|
Here are some companies that use PostgREST in production.
|
||||||
|
|
||||||
* `Catarse <https://www.catarse.me>`_
|
* `Catarse <https://www.catarse.me>`_
|
||||||
* `Datrium <https://www.datrium.com>`_
|
|
||||||
* `Drip Depot <https://www.dripdepot.com>`_
|
* `Drip Depot <https://www.dripdepot.com>`_
|
||||||
* `Image-charts <https://www.image-charts.com>`_
|
* `Image-charts <https://www.image-charts.com>`_
|
||||||
* `Netwo <https://www.netwo.io>`_
|
* `Netwo <https://www.netwo.io>`_
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
name: postgrest
|
name: postgrest
|
||||||
version: 14.3
|
version: 14.4
|
||||||
synopsis: REST API for any Postgres database
|
synopsis: REST API for any Postgres database
|
||||||
description: Reads the schema of a PostgreSQL database and creates RESTful routes
|
description: Reads the schema of a PostgreSQL database and creates RESTful routes
|
||||||
for tables, views, and functions, supporting all HTTP methods that security
|
for tables, views, and functions, supporting all HTTP methods that security
|
||||||
|
|||||||
+36
-21
@@ -1,3 +1,4 @@
|
|||||||
|
{-# LANGUAGE LambdaCase #-}
|
||||||
{-# LANGUAGE MultiWayIf #-}
|
{-# LANGUAGE MultiWayIf #-}
|
||||||
{-# LANGUAGE RecordWildCards #-}
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ import PostgREST.Version (prettyVersion)
|
|||||||
import qualified PostgREST.AppState as AppState
|
import qualified PostgREST.AppState as AppState
|
||||||
import qualified PostgREST.Config as Config
|
import qualified PostgREST.Config as Config
|
||||||
|
|
||||||
|
import Data.Either.Combinators (whenRight)
|
||||||
import Protolude
|
import Protolude
|
||||||
|
|
||||||
-- | Starts the Listener in a thread
|
-- | Starts the Listener in a thread
|
||||||
@@ -22,15 +24,16 @@ runListener :: AppState -> IO ()
|
|||||||
runListener appState = do
|
runListener appState = do
|
||||||
AppConfig{..} <- getConfig appState
|
AppConfig{..} <- getConfig appState
|
||||||
when configDbChannelEnabled $
|
when configDbChannelEnabled $
|
||||||
void . forkIO $ retryingListen appState
|
void . forkIO . void $ retryingListen appState
|
||||||
|
|
||||||
-- | Starts a LISTEN connection and handles notifications. It recovers with exponential backoff with a cap of 32 seconds, if the LISTEN connection is lost.
|
-- | Starts a LISTEN connection and handles notifications. It recovers with exponential backoff with a cap of 32 seconds, if the LISTEN connection is lost.
|
||||||
retryingListen :: AppState -> IO ()
|
-- | This function never returns (but can throw) and return type enforces that.
|
||||||
|
retryingListen :: AppState -> IO Void
|
||||||
retryingListen appState = do
|
retryingListen appState = do
|
||||||
AppConfig{..} <- AppState.getConfig appState
|
AppConfig{..} <- AppState.getConfig appState
|
||||||
let
|
let
|
||||||
dbChannel = toS configDbChannel
|
dbChannel = toS configDbChannel
|
||||||
handleFinally err = do
|
onError err = do
|
||||||
AppState.putIsListenerOn appState False
|
AppState.putIsListenerOn appState False
|
||||||
observer $ DBListenFail dbChannel (Right err)
|
observer $ DBListenFail dbChannel (Right err)
|
||||||
unless configDbPoolAutomaticRecovery $
|
unless configDbPoolAutomaticRecovery $
|
||||||
@@ -42,29 +45,39 @@ retryingListen appState = do
|
|||||||
threadDelay (delay * oneSecondInMicro)
|
threadDelay (delay * oneSecondInMicro)
|
||||||
unless (delay == maxDelay) $
|
unless (delay == maxDelay) $
|
||||||
AppState.putNextListenerDelay appState (delay * 2)
|
AppState.putNextListenerDelay appState (delay * 2)
|
||||||
|
-- loop running the listener
|
||||||
retryingListen appState
|
retryingListen appState
|
||||||
|
|
||||||
-- forkFinally allows to detect if the thread dies
|
-- Execute the listener with with error handling
|
||||||
void . flip forkFinally handleFinally $ do
|
handle onError $ do
|
||||||
dbOrError <- SQL.acquire $ toUtf8 (Config.addTargetSessionAttrs $ Config.addFallbackAppName prettyVersion configDbUri)
|
-- Make sure we don't leak connections on errors
|
||||||
case dbOrError of
|
bracket
|
||||||
Right db -> do
|
-- acquire connection
|
||||||
SQL.listen db $ SQL.toPgIdentifier dbChannel
|
(SQL.acquire $ toUtf8 (Config.addTargetSessionAttrs $ Config.addFallbackAppName prettyVersion configDbUri))
|
||||||
AppState.putIsListenerOn appState True
|
-- release connection
|
||||||
|
(`whenRight` releaseConnection) $
|
||||||
|
-- use connection
|
||||||
|
\case
|
||||||
|
Right db -> do
|
||||||
|
SQL.listen db $ SQL.toPgIdentifier dbChannel
|
||||||
|
AppState.putIsListenerOn appState True
|
||||||
|
|
||||||
delay <- AppState.getNextListenerDelay appState
|
delay <- AppState.getNextListenerDelay appState
|
||||||
when (delay > 1) $ do -- if we did a retry
|
when (delay > 1) $ do -- if we did a retry
|
||||||
-- assume we lost notifications, refresh the schema cache
|
-- assume we lost notifications, refresh the schema cache
|
||||||
AppState.schemaCacheLoader appState
|
AppState.schemaCacheLoader appState
|
||||||
-- reset the delay
|
-- reset the delay
|
||||||
AppState.putNextListenerDelay appState 1
|
AppState.putNextListenerDelay appState 1
|
||||||
|
|
||||||
observer $ DBListenStart dbChannel
|
observer $ DBListenStart dbChannel
|
||||||
SQL.waitForNotifications handleNotification db
|
|
||||||
|
|
||||||
Left err -> do
|
-- wait for notifications
|
||||||
observer $ DBListenFail dbChannel (Left err)
|
-- this will never return, in case of an error it will throw and be caught by onError
|
||||||
exitFailure
|
forever $ SQL.waitForNotifications handleNotification db
|
||||||
|
|
||||||
|
Left err -> do
|
||||||
|
observer $ DBListenFail dbChannel (Left err)
|
||||||
|
exitFailure
|
||||||
where
|
where
|
||||||
observer = AppState.getObserver appState
|
observer = AppState.getObserver appState
|
||||||
mainThreadId = AppState.getMainThreadId appState
|
mainThreadId = AppState.getMainThreadId appState
|
||||||
@@ -79,3 +92,5 @@ retryingListen appState = do
|
|||||||
|
|
||||||
cacheReloader =
|
cacheReloader =
|
||||||
AppState.schemaCacheLoader appState
|
AppState.schemaCacheLoader appState
|
||||||
|
|
||||||
|
releaseConnection = void . forkIO . handle (observer . DBListenerConnectionCleanupFail) . SQL.release
|
||||||
|
|||||||
@@ -44,10 +44,11 @@ data Observation
|
|||||||
| SchemaCacheLoadedObs Double
|
| SchemaCacheLoadedObs Double
|
||||||
| ConnectionRetryObs Int
|
| ConnectionRetryObs Int
|
||||||
| DBListenStart Text
|
| DBListenStart Text
|
||||||
| DBListenFail Text (Either SQL.ConnectionError (Either SomeException ()))
|
| DBListenFail Text (Either SQL.ConnectionError SomeException)
|
||||||
| DBListenRetry Int
|
| DBListenRetry Int
|
||||||
| DBListenerGotSCacheMsg ByteString
|
| DBListenerGotSCacheMsg ByteString
|
||||||
| DBListenerGotConfigMsg ByteString
|
| DBListenerGotConfigMsg ByteString
|
||||||
|
| DBListenerConnectionCleanupFail SomeException
|
||||||
| QueryObs MainQuery Status
|
| QueryObs MainQuery Status
|
||||||
| ConfigReadErrorObs SQL.UsageError
|
| ConfigReadErrorObs SQL.UsageError
|
||||||
| ConfigInvalidObs Text
|
| ConfigInvalidObs Text
|
||||||
@@ -118,6 +119,8 @@ observationMessage = \case
|
|||||||
"Received a schema cache reload message on the " <> show channel <> " channel"
|
"Received a schema cache reload message on the " <> show channel <> " channel"
|
||||||
DBListenerGotConfigMsg channel ->
|
DBListenerGotConfigMsg channel ->
|
||||||
"Received a config reload message on the " <> show channel <> " channel"
|
"Received a config reload message on the " <> show channel <> " channel"
|
||||||
|
DBListenerConnectionCleanupFail ex ->
|
||||||
|
"Failed during listener connection cleanup: " <> showOnSingleLine '\t' (show ex)
|
||||||
QueryObs{} ->
|
QueryObs{} ->
|
||||||
mempty -- TODO pending refactor: The logic for printing the query cannot be done here. Join the observationMessage function into observationLogger to avoid this mempty.
|
mempty -- TODO pending refactor: The logic for printing the query cannot be done here. Join the observationMessage function into observationLogger to avoid this mempty.
|
||||||
ConfigReadErrorObs usageErr ->
|
ConfigReadErrorObs usageErr ->
|
||||||
@@ -164,9 +167,8 @@ observationMessage = \case
|
|||||||
showListenerConnError :: SQL.ConnectionError -> Text
|
showListenerConnError :: SQL.ConnectionError -> Text
|
||||||
showListenerConnError = maybe "Connection error" (showOnSingleLine '\t' . T.decodeUtf8)
|
showListenerConnError = maybe "Connection error" (showOnSingleLine '\t' . T.decodeUtf8)
|
||||||
|
|
||||||
showListenerException :: Either SomeException () -> Text
|
showListenerException :: SomeException -> Text
|
||||||
showListenerException (Right _) = "Failed getting notifications" -- should not happen as the listener will never finish (hasql-notifications uses `forever` internally) with a Right result
|
showListenerException = showOnSingleLine '\t' . show
|
||||||
showListenerException (Left e) = showOnSingleLine '\t' $ show e
|
|
||||||
|
|
||||||
|
|
||||||
showOnSingleLine :: Char -> Text -> Text
|
showOnSingleLine :: Char -> Text -> Text
|
||||||
|
|||||||
@@ -965,10 +965,17 @@ addRanges ApiRequest{..} rReq =
|
|||||||
|
|
||||||
addLogicTrees :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either Error ReadPlanTree
|
addLogicTrees :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either Error ReadPlanTree
|
||||||
addLogicTrees ctx ApiRequest{..} rReq =
|
addLogicTrees ctx ApiRequest{..} rReq =
|
||||||
foldr addLogicTreeToNode (Right rReq) qsLogic
|
foldr addLogicTreeToNode (Right rReq) logic
|
||||||
where
|
where
|
||||||
QueryParams.QueryParams{..} = iQueryParams
|
QueryParams.QueryParams{..} = iQueryParams
|
||||||
|
|
||||||
|
logic =
|
||||||
|
case iAction of
|
||||||
|
ActDb (ActRelationRead _ _) -> qsLogic
|
||||||
|
ActDb (ActRoutine _ _) -> qsLogic
|
||||||
|
-- For mutations, take the non-root logic filters. These will only affect the embeddings and not the top level of the returned representation.
|
||||||
|
_ -> filter (not . null . fst) qsLogic
|
||||||
|
|
||||||
addLogicTreeToNode :: (EmbedPath, LogicTree) -> Either Error ReadPlanTree -> Either Error ReadPlanTree
|
addLogicTreeToNode :: (EmbedPath, LogicTree) -> Either Error ReadPlanTree -> Either Error ReadPlanTree
|
||||||
addLogicTreeToNode = updateNode (\t (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=resolveLogicTree ctx{qi=fromTable} t:lf} f)
|
addLogicTreeToNode = updateNode (\t (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=resolveLogicTree ctx{qi=fromTable} t:lf} f)
|
||||||
|
|
||||||
|
|||||||
@@ -252,21 +252,39 @@ spec =
|
|||||||
[json|[{"id": 7, "entities":null}, {"id": 8, "entities": {"id": 2}}, {"id": 9, "entities": {"id": 3}}]|]
|
[json|[{"id": 7, "entities":null}, {"id": 8, "entities": {"id": 2}}, {"id": 9, "entities": {"id": 3}}]|]
|
||||||
{ matchStatus = 201 }
|
{ matchStatus = 201 }
|
||||||
|
|
||||||
context "used with PATCH" $
|
context "used with PATCH" $ do
|
||||||
it "succeeds when using and/or params" $
|
it "succeeds when using and/or params" $
|
||||||
request methodPatch "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
|
request methodPatch "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
|
||||||
[("Prefer", "return=representation")]
|
[("Prefer", "return=representation")]
|
||||||
[json|{ name : "updated grandchild entity"}|] `shouldRespondWith`
|
[json|{ name : "updated grandchild entity"}|] `shouldRespondWith`
|
||||||
[json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|]
|
[json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "succeeds when the filtered column is modified" $
|
||||||
|
request methodPatch "/entities?select=id,name&or=(name.is.null,name.like.*test*)"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
[json|{ "name" : "updated entity" }|] `shouldRespondWith`
|
||||||
|
[json|[{ "id": 4, "name": "updated entity" }]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "succeeds when the filtered column is not selected in the returned representation" $
|
||||||
|
request methodPatch "/entities?select=id&or=(name.is.null,name.like.*test*)"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
[json|{ "name" : "updated entity" }|] `shouldRespondWith`
|
||||||
|
[json|[{ "id": 4 }]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
context "used with DELETE" $
|
context "used with DELETE" $ do
|
||||||
it "succeeds when using and/or params" $
|
it "succeeds when using and/or params" $
|
||||||
request methodDelete "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
|
request methodDelete "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
|
||||||
[("Prefer", "return=representation")]
|
[("Prefer", "return=representation")]
|
||||||
""
|
""
|
||||||
`shouldRespondWith`
|
`shouldRespondWith`
|
||||||
[json|[{ "id": 1, "name" : "grandchild entity 1" },{ "id": 2, "name" : "grandchild entity 2" }]|]
|
[json|[{ "id": 1, "name" : "grandchild entity 1" },{ "id": 2, "name" : "grandchild entity 2" }]|]
|
||||||
|
it "succeeds when the filtered column is not selected in the returned representation" $
|
||||||
|
request methodDelete "/entities?select=id&or=(name.is.null,name.like.*test*)"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
""
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{ "id": 4 }]|]
|
||||||
|
|
||||||
it "can query columns that begin with and/or reserved words" $
|
it "can query columns that begin with and/or reserved words" $
|
||||||
get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200
|
get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200
|
||||||
|
|||||||
Reference in New Issue
Block a user