diff --git a/CHANGELOG.md b/CHANGELOG.md
index f6bc56e64..5ebb4a0be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- #1435, Add `request.method` and `request.path` GUCs - @steve-chavez
- #1088, Allow adding headers to GET/POST/PATCH/PUT/DELETE responses through the `response.headers` GUC - @steve-chavez
- #1427, Allow overriding provided headers(Location, Content-Type, etc) through the `response.headers` GUC - @steve-chavez
+- #1450, Allow multiple schemas to be exposed in one instance. The schema to use can be selected through the headers `Accept-Profile` for GET/HEAD and `Content-Profile` for POST/PATCH/PUT/DELETE - @steve-chavez, @mahmoudkassem
### Fixed
diff --git a/main/Main.hs b/main/Main.hs
index 1d03fe421..f6030ab75 100644
--- a/main/Main.hs
+++ b/main/Main.hs
@@ -45,7 +45,7 @@ import PostgREST.OpenAPI (isMalformedProxyUri)
import PostgREST.Types (ConnectionStatus (..), DbStructure,
PgVersion (..), Schema,
minimumPgVersion)
-import Protolude hiding (hPutStrLn, replace)
+import Protolude hiding (hPutStrLn, head, replace)
#ifndef mingw32_HOST_OS
@@ -73,11 +73,11 @@ import System.Posix.Signals
connectionWorker
:: ThreadId -- ^ This thread is killed if pg version is unsupported
-> P.Pool -- ^ The PostgreSQL connection pool
- -> Schema -- ^ Schema PostgREST is serving up
+ -> [Schema] -- ^ Schemas PostgREST is serving up
-> IORef (Maybe DbStructure) -- ^ mutable reference to 'DbStructure'
-> IORef Bool -- ^ Used as a binary Semaphore
-> IO ()
-connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
+connectionWorker mainTid pool schemas refDbStructure refIsWorkerOn = do
isWorkerOn <- readIORef refIsWorkerOn
unless isWorkerOn $ do
atomicWriteIORef refIsWorkerOn True
@@ -93,7 +93,7 @@ connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
NotConnected -> return () -- Unreachable
Connected actualPgVersion -> do -- Procede with initialization
result <- P.use pool $ do
- dbStructure <- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure schema actualPgVersion
+ dbStructure <- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure schemas actualPgVersion
liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure
case result of
Left e -> do
@@ -162,7 +162,8 @@ main = do
-- readOptions builds the 'AppConfig' from the config file specified on the
-- command line
conf <- loadDbUriFile =<< loadSecretFile =<< readOptions
- let host = configHost conf
+ let schemas = toList $ configSchemas conf
+ host = configHost conf
port = configPort conf
proxy = configOpenAPIProxyUri conf
maybeSocketAddr = configSocket conf
@@ -175,6 +176,7 @@ main = do
. setServerName (toS $ "postgrest/" <> prettyVersion) $
defaultSettings
+
whenLeft socketFileMode panic
-- Checks that the provided proxy uri is formated correctly
@@ -205,7 +207,7 @@ main = do
connectionWorker
mainTid
pool
- (configSchema conf)
+ schemas
refDbStructure
refIsWorkerOn
--
@@ -227,7 +229,7 @@ main = do
Catch $ connectionWorker
mainTid
pool
- (configSchema conf)
+ schemas
refDbStructure
refIsWorkerOn
) Nothing
@@ -246,7 +248,7 @@ main = do
(connectionWorker
mainTid
pool
- (configSchema conf)
+ schemas
refDbStructure
refIsWorkerOn)
in case maybeSocketAddr of
diff --git a/postgrest.cabal b/postgrest.cabal
index 3dac3c109..24b6bfebf 100644
--- a/postgrest.cabal
+++ b/postgrest.cabal
@@ -148,6 +148,7 @@ test-suite spec
Feature.UpsertSpec
Feature.RawOutputTypesSpec
Feature.HtmlRawOutputSpec
+ Feature.MultipleSchemaSpec
SpecHelper
TestTypes
hs-source-dirs: test
diff --git a/src/PostgREST/ApiRequest.hs b/src/PostgREST/ApiRequest.hs
index a5ed551cd..cf095bb38 100644
--- a/src/PostgREST/ApiRequest.hs
+++ b/src/PostgREST/ApiRequest.hs
@@ -28,7 +28,8 @@ import qualified Data.Vector as V
import Control.Arrow ((***))
import Data.Aeson.Types (emptyArray, emptyObject)
-import Data.List (last, lookup, partition)
+import Data.List (elem, last, lookup, partition)
+import Data.List.NonEmpty (NonEmpty, head)
import Data.Maybe (fromJust)
import Data.Ranged.Ranges (Range (..), emptyRange,
rangeIntersection)
@@ -48,7 +49,7 @@ import PostgREST.RangeQuery (NonnegRange, allRange, rangeGeq,
rangeLimit, rangeOffset, rangeRequested,
restrictRange)
import PostgREST.Types
-import Protolude
+import Protolude hiding (head)
type RequestBody = BL.ByteString
@@ -96,11 +97,14 @@ data ApiRequest = ApiRequest {
, iCookies :: [(Text, Text)] -- ^ Request Cookies
, iPath :: ByteString -- ^ Raw request path
, iMethod :: ByteString -- ^ Raw request method
+ , iProfile :: Maybe Schema -- ^ The request profile for enabling use of multiple schemas. Follows the spec in hhttps://www.w3.org/TR/dx-prof-conneg/ttps://www.w3.org/TR/dx-prof-conneg/.
+ , iSchema :: Schema -- ^ The request schema. Can vary depending on iProfile.
}
-- | Examines HTTP request and translates it into user intent.
-userApiRequest :: Schema -> Maybe Text -> Request -> RequestBody -> Either ApiRequestError ApiRequest
-userApiRequest schema rootSpec req reqBody
+userApiRequest :: NonEmpty Schema -> Maybe Text -> Request -> RequestBody -> Either ApiRequestError ApiRequest
+userApiRequest confSchemas rootSpec req reqBody
+ | isJust profile && fromJust profile `notElem` confSchemas = Left $ UnacceptableSchema $ toList confSchemas
| isTargetingProc && method `notElem` ["HEAD", "GET", "POST"] = Left ActionInappropriate
| topLevelRange == emptyRange = Left InvalidRange
| shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload
@@ -137,6 +141,8 @@ userApiRequest schema rootSpec req reqBody
, iCookies = maybe [] parseCookiesText $ lookupHeader "Cookie"
, iPath = rawPathInfo req
, iMethod = method
+ , iProfile = profile
+ , iSchema = schema
}
where
-- queryString with '+' converted to ' '(space)
@@ -208,6 +214,18 @@ userApiRequest schema rootSpec req reqBody
"DELETE" -> ActionDelete
"OPTIONS" -> ActionInfo
_ -> ActionInspect{isHead=False}
+
+ defaultSchema = head confSchemas
+ profile
+ | length confSchemas <= 1 -- only enable content negotiation by profile when there are multiple schemas specified in the config
+ = Nothing
+ | action `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionDelete] -- POST/PATCH/PUT/DELETE don't use the same header as per the spec
+ = Just $ maybe defaultSchema toS $ lookupHeader "Content-Profile"
+ | action `elem` [ActionRead True, ActionRead False, ActionInvoke InvGet, ActionInvoke InvHead, ActionInvoke InvPost,
+ ActionInspect False, ActionInspect True, ActionInfo]
+ = Just $ maybe defaultSchema toS $ lookupHeader "Accept-Profile"
+ | otherwise = Nothing
+ schema = fromMaybe defaultSchema profile
target = case path of
[] -> case rootSpec of
Just pName -> TargetProc (QualifiedIdentifier schema pName) True
diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs
index 4c90ff9a0..14b69ebf6 100644
--- a/src/PostgREST/App.hs
+++ b/src/PostgREST/App.hs
@@ -78,8 +78,9 @@ postgrest conf refDbStructure pool getTime worker =
Nothing -> respond . errorResponseFor $ ConnectionLostError
Just dbStructure -> do
response <- do
- -- Need to parse ?columns early because findProc needs it to solve overloaded functions
- let apiReq = userApiRequest (configSchema conf) (configRootSpec conf) req body
+ -- Need to parse ?columns early because findProc needs it to solve overloaded functions.
+ -- TODO: move this logic to the app function
+ let apiReq = userApiRequest (configSchemas conf) (configRootSpec conf) req body
apiReqCols = (,) <$> apiReq <*> (pRequestColumns =<< iColumns <$> apiReq)
case apiReqCols of
Left err -> return . errorResponseFor $ err
@@ -145,8 +146,9 @@ app dbStructure proc cols conf apiRequest =
else pure tableTotal
| otherwise -> pure tableTotal
let (status, contentRange) = rangeStatusHeader topLevelRange queryTotal total
- headers = addHeadersIfNotIncluded
- [toHeader contentType, contentRange, contentLocationH tName (iCanonicalQS apiRequest)]
+ headers = addHeadersIfNotIncluded (catMaybes [
+ Just $ toHeader contentType, Just contentRange,
+ Just $ contentLocationH tName (iCanonicalQS apiRequest), profileH])
(unwrapGucHeader <$> ghdrs)
rBody = if headersOnly then mempty else toS body
return $
@@ -168,19 +170,18 @@ app dbStructure proc cols conf apiRequest =
Left _ -> return . errorResponseFor $ GucHeadersError
Right ghdrs -> do
let
- (ctHeader, rBody) = if iPreferRepresentation apiRequest == Full
- then (Just $ toHeader contentType, toS body)
- else (Nothing, mempty)
- headers = addHeadersIfNotIncluded (catMaybes [
+ (ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
+ then ([Just $ toHeader contentType, profileH], toS body)
+ else ([], mempty)
+ headers = addHeadersIfNotIncluded (catMaybes ([
if null fields
then Nothing
else Just $ locationH tName fields
- , ctHeader
, Just $ contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
, if null pkCols && isNothing (iOnConflict apiRequest)
then Nothing
else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest
- ]) (unwrapGucHeader <$> ghdrs)
+ ] ++ ctHeaders)) (unwrapGucHeader <$> ghdrs)
if contentType == CTSingularJSON && queryTotal /= 1
then do
HT.condemn
@@ -206,10 +207,10 @@ app dbStructure proc cols conf apiRequest =
| iPreferRepresentation apiRequest == Full = status200
| otherwise = status204
contentRangeHeader = contentRangeH 0 (queryTotal - 1) $ if shouldCount then Just queryTotal else Nothing
- (ctHeader, rBody) = if iPreferRepresentation apiRequest == Full
- then (Just $ toHeader contentType, toS body)
- else (Nothing, mempty)
- headers = addHeadersIfNotIncluded (catMaybes [Just contentRangeHeader, ctHeader]) (unwrapGucHeader <$> ghdrs)
+ (ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
+ then ([Just $ toHeader contentType, profileH], toS body)
+ else ([], mempty)
+ headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)
if contentType == CTSingularJSON && queryTotal /= 1
then do
HT.condemn
@@ -239,7 +240,7 @@ app dbStructure proc cols conf apiRequest =
case gucHeaders of
Left _ -> return . errorResponseFor $ GucHeadersError
Right ghdrs -> do
- let headers = addHeadersIfNotIncluded [toHeader contentType] (unwrapGucHeader <$> ghdrs)
+ let headers = addHeadersIfNotIncluded (catMaybes [Just $ toHeader contentType, profileH]) (unwrapGucHeader <$> ghdrs)
(status, rBody) = if iPreferRepresentation apiRequest == Full then (status200, toS body) else (status204, mempty)
-- Makes sure the querystring pk matches the payload pk
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted, PUT /items?id=eq.14 { "id" : 2, .. } is rejected
@@ -267,10 +268,10 @@ app dbStructure proc cols conf apiRequest =
let
status = if iPreferRepresentation apiRequest == Full then status200 else status204
contentRangeHeader = contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
- (ctHeader, rBody) = if iPreferRepresentation apiRequest == Full
- then (Just $ toHeader contentType, toS body)
- else (Nothing, mempty)
- headers = addHeadersIfNotIncluded (catMaybes [Just contentRangeHeader, ctHeader]) (unwrapGucHeader <$> ghdrs)
+ (ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
+ then ([Just $ toHeader contentType, profileH], toS body)
+ else ([], mempty)
+ headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)
if contentType == CTSingularJSON
&& queryTotal /= 1
then do
@@ -305,7 +306,9 @@ app dbStructure proc cols conf apiRequest =
Left _ -> return . errorResponseFor $ GucHeadersError
Right ghdrs -> do
let (status, contentRange) = rangeStatusHeader topLevelRange queryTotal tableTotal
- headers = addHeadersIfNotIncluded [toHeader contentType, contentRange] (unwrapGucHeader <$> ghdrs)
+ headers = addHeadersIfNotIncluded
+ (catMaybes [Just $ toHeader contentType, Just contentRange, profileH])
+ (unwrapGucHeader <$> ghdrs)
rBody = if invMethod == InvHead then mempty else toS body
if contentType == CTSingularJSON && queryTotal /= 1
then do
@@ -329,7 +332,7 @@ app dbStructure proc cols conf apiRequest =
H.statement tSchema accessibleTables <*>
H.statement tSchema schemaDescription <*>
H.statement tSchema accessibleProcs
- return $ responseLBS status200 [toHeader CTOpenAPI] (if headersOnly then mempty else toS body)
+ return $ responseLBS status200 (catMaybes [Just $ toHeader CTOpenAPI, profileH]) (if headersOnly then mempty else toS body)
_ -> return notFound
@@ -343,6 +346,7 @@ app dbStructure proc cols conf apiRequest =
topLevelRange = iTopLevelRange apiRequest
returnsScalar = maybe False procReturnsScalar proc
pgVer = pgVersion dbStructure
+ profileH = contentProfileH <$> iProfile apiRequest
readSqlParts s t =
let
@@ -413,3 +417,7 @@ locationH tName fields =
contentLocationH :: TableName -> ByteString -> Header
contentLocationH tName qString =
("Content-Location", "/" <> toS tName <> if BS.null qString then mempty else "?" <> toS qString)
+
+contentProfileH :: Schema -> Header
+contentProfileH schema =
+ ("Content-Profile", toS schema)
diff --git a/src/PostgREST/Config.hs b/src/PostgREST/Config.hs
index 0326a64a6..77302a5b5 100644
--- a/src/PostgREST/Config.hs
+++ b/src/PostgREST/Config.hs
@@ -37,6 +37,7 @@ import Control.Lens (preview)
import Control.Monad (fail)
import Crypto.JWT (StringOrURI, stringOrUri)
import Data.List (lookup)
+import Data.List.NonEmpty (NonEmpty, fromList)
import Data.Scientific (floatingOrInteger)
import Data.Text (dropEnd, dropWhileEnd,
intercalate, lines, splitOn,
@@ -71,7 +72,7 @@ data AppConfig = AppConfig {
configDatabase :: Text
, configAnonRole :: Text
, configOpenAPIProxyUri :: Maybe Text
- , configSchema :: Text
+ , configSchemas :: NonEmpty Text
, configHost :: Text
, configPort :: Int
, configSocket :: Maybe Text
@@ -154,8 +155,8 @@ readOptions = do
AppConfig
<$> reqString "db-uri"
<*> reqString "db-anon-role"
- <*> optString "openapi-server-proxy-uri"
- <*> reqString "db-schema"
+ <*> optString "server-proxy-uri"
+ <*> (fromList . splitOnCommas <$> reqValue "db-schema")
<*> (fromMaybe "!4" <$> optString "server-host")
<*> (fromMaybe 3000 <$> optInt "server-port")
<*> optString "server-unix-socket"
@@ -199,6 +200,9 @@ readOptions = do
reqString :: C.Key -> C.Parser C.Config Text
reqString k = C.required k C.string
+ reqValue :: C.Key -> C.Parser C.Config C.Value
+ reqValue k = C.required k C.value
+
optString :: C.Key -> C.Parser C.Config (Maybe Text)
optString k = mfilter (/= "") <$> C.optional k C.string
diff --git a/src/PostgREST/DbStructure.hs b/src/PostgREST/DbStructure.hs
index 53d2f6c6e..f3cadf1a4 100644
--- a/src/PostgREST/DbStructure.hs
+++ b/src/PostgREST/DbStructure.hs
@@ -44,15 +44,15 @@ import PostgREST.Private.Common
import PostgREST.Types
import Protolude
-getDbStructure :: Schema -> PgVersion -> HT.Transaction DbStructure
-getDbStructure schema pgVer = do
- HT.sql "set local schema ''" -- for getting the fully qualified name(schema.name) of every db object
+getDbStructure :: [Schema] -> PgVersion -> HT.Transaction DbStructure
+getDbStructure schemas pgVer = do
+ HT.sql "set local schema ''" -- This voids the search path. The following queries need this for getting the fully qualified name(schema.name) of every db object
tabs <- HT.statement () allTables
- cols <- HT.statement schema $ allColumns tabs
- srcCols <- HT.statement schema $ allSourceColumns cols pgVer
+ cols <- HT.statement schemas $ allColumns tabs
+ srcCols <- HT.statement schemas $ allSourceColumns cols pgVer
m2oRels <- HT.statement () $ allM2ORels tabs cols
keys <- HT.statement () $ allPrimaryKeys tabs
- procs <- HT.statement schema allProcs
+ procs <- HT.statement schemas allProcs
let rels = addM2MRels . addO2MRels $ addViewM2ORels srcCols m2oRels
cols' = addForeignKeys rels cols
@@ -126,13 +126,14 @@ sourceColumnFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2
col2 = findCol s2 t2 c2
findCol s t c = find (\col -> (tableSchema . colTable) col == s && (tableName . colTable) col == t && colName col == c) allCols
-decodeProcs :: HD.Result (M.HashMap Text [ProcDescription])
+decodeProcs :: HD.Result ProcsMap
decodeProcs =
-- Duplicate rows for a function means they're overloaded, order these by least args according to ProcDescription Ord instance
- map sort . M.fromListWith (++) . map ((\(x,y) -> (x, [y])) . addName) <$> HD.rowList tblRow
+ map sort . M.fromListWith (++) . map ((\(x,y) -> (x, [y])) . addKey) <$> HD.rowList procRow
where
- tblRow = ProcDescription
+ procRow = ProcDescription
<$> column HD.text
+ <*> column HD.text
<*> nullableColumn HD.text
<*> (parseArgs <$> column HD.text)
<*> (parseRetType
@@ -142,8 +143,8 @@ decodeProcs =
<*> column HD.char)
<*> (parseVolatility <$> column HD.char)
- addName :: ProcDescription -> (Text, ProcDescription)
- addName pd = (pdName pd, pd)
+ addKey :: ProcDescription -> (QualifiedIdentifier, ProcDescription)
+ addKey pd = (QualifiedIdentifier (pdSchema pd) (pdName pd), pd)
parseArgs :: Text -> [PgArg]
parseArgs = mapMaybe parseArg . filter (not . isPrefixOf "OUT" . toS) . map strip . split (==',')
@@ -176,31 +177,34 @@ decodeProcs =
| v == 's' = Stable
| otherwise = Volatile -- only 'v' can happen here
-allProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
-allProcs = H.Statement (toS procsSqlQuery) (param HE.text) decodeProcs True
+allProcs :: H.Statement [Schema] ProcsMap
+allProcs = H.Statement (toS sql) (arrayParam HE.text) decodeProcs True
+ where
+ sql = procsSqlQuery <> " WHERE pn.nspname = ANY($1)"
-accessibleProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
+accessibleProcs :: H.Statement Schema ProcsMap
accessibleProcs = H.Statement (toS sql) (param HE.text) decodeProcs True
where
- sql = procsSqlQuery <> " AND has_function_privilege(p.oid, 'execute')"
+ sql = procsSqlQuery <> " WHERE pn.nspname = $1 AND has_function_privilege(p.oid, 'execute')"
procsSqlQuery :: SqlQuery
procsSqlQuery = [q|
- SELECT p.proname as "proc_name",
- d.description as "proc_description",
- pg_get_function_arguments(p.oid) as "args",
- tn.nspname as "rettype_schema",
- coalesce(comp.relname, t.typname) as "rettype_name",
- p.proretset as "rettype_is_setof",
- t.typtype as "rettype_typ",
- p.provolatile
+ SELECT
+ pn.nspname as "proc_schema",
+ p.proname as "proc_name",
+ d.description as "proc_description",
+ pg_get_function_arguments(p.oid) as "args",
+ tn.nspname as "rettype_schema",
+ coalesce(comp.relname, t.typname) as "rettype_name",
+ p.proretset as "rettype_is_setof",
+ t.typtype as "rettype_typ",
+ p.provolatile
FROM pg_proc p
JOIN pg_namespace pn ON pn.oid = p.pronamespace
JOIN pg_type t ON t.oid = p.prorettype
JOIN pg_namespace tn ON tn.oid = t.typnamespace
LEFT JOIN pg_class comp ON comp.oid = t.typrelid
LEFT JOIN pg_catalog.pg_description as d on d.objoid = p.oid
- WHERE pn.nspname = $1
|]
schemaDescription :: H.Statement Schema (Maybe Text)
@@ -384,9 +388,9 @@ allTables =
GROUP BY table_schema, table_name, insertable
ORDER BY table_schema, table_name |]
-allColumns :: [Table] -> H.Statement Schema [Column]
+allColumns :: [Table] -> H.Statement [Schema] [Column]
allColumns tabs =
- H.Statement sql (param HE.text) (decodeColumns tabs) True
+ H.Statement sql (arrayParam HE.text) (decodeColumns tabs) True
where
sql = [q|
SELECT DISTINCT
@@ -424,7 +428,7 @@ allColumns tabs =
AND c.relkind IN ('r', 'v', 'f', 'm')
AND r.conrelid = c.oid
AND c.relnamespace = n.oid
- AND n.nspname NOT IN ('pg_catalog', 'information_schema', $1)
+ AND n.nspname <> ANY (ARRAY['pg_catalog', 'information_schema'] || $1)
),
/*
-- CTE based on information_schema.columns
@@ -526,7 +530,7 @@ allColumns tabs =
AND a.attnum > 0
AND NOT a.attisdropped
AND (c.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char", 'm'::"char"]))
- AND (nc.nspname = $1 OR kc.r_oid IS NOT NULL) /*--filter only columns that are FK/PK or in the api schema */
+ AND (nc.nspname = ANY ($1) OR kc.r_oid IS NOT NULL) /*--filter only columns that are FK/PK or in the api schema */
/*--AND (pg_has_role(c.relowner, 'USAGE'::text) OR has_column_privilege(c.oid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/
)
SELECT
@@ -719,9 +723,9 @@ pkFromRow :: [Table] -> (Schema, Text, Text) -> Maybe PrimaryKey
pkFromRow tabs (s, t, n) = PrimaryKey <$> table <*> pure n
where table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs
-allSourceColumns :: [Column] -> PgVersion -> H.Statement Schema [SourceColumn]
+allSourceColumns :: [Column] -> PgVersion -> H.Statement [Schema] [SourceColumn]
allSourceColumns cols pgVer =
- H.Statement sql (param HE.text) (decodeSourceColumns cols) True
+ H.Statement sql (arrayParam HE.text) (decodeSourceColumns cols) True
-- query explanation at https://gist.github.com/steve-chavez/7ee0e6590cddafb532e5f00c46275569
where
subselectRegex :: Text
@@ -740,7 +744,7 @@ allSourceColumns cols pgVer =
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
join pg_rewrite r on r.ev_class = c.oid
- where (c.relkind in ('v', 'm')) and n.nspname = $1
+ where (c.relkind in ('v', 'm')) and n.nspname = ANY ($1)
),
removed_subselects as(
select
diff --git a/src/PostgREST/Error.hs b/src/PostgREST/Error.hs
index 05b74439f..1e0b8e42d 100644
--- a/src/PostgREST/Error.hs
+++ b/src/PostgREST/Error.hs
@@ -51,6 +51,7 @@ data ApiRequestError
| NoRelBetween Text Text
| AmbiguousRelBetween Text Text [Relation]
| InvalidFilters
+ | UnacceptableSchema [Text]
| UnknownRelation -- Unreachable?
| UnsupportedVerb -- Unreachable?
deriving (Show, Eq)
@@ -65,6 +66,7 @@ instance PgrstError ApiRequestError where
status (ParseRequestError _ _) = HT.status400
status (NoRelBetween _ _) = HT.status400
status AmbiguousRelBetween{} = HT.status300
+ status (UnacceptableSchema _) = HT.status406
headers _ = [toHeader CTApplicationJSON]
@@ -89,6 +91,8 @@ instance JSON.ToJSON ApiRequestError where
"message" .= ("Unsupported HTTP verb" :: Text)]
toJSON InvalidFilters = JSON.object [
"message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)]
+ toJSON (UnacceptableSchema schemas) = JSON.object [
+ "message" .= ("The schema must be one of the following: " <> T.intercalate ", " schemas)]
compressedRel :: Relation -> JSON.Value
compressedRel rel =
diff --git a/src/PostgREST/Middleware.hs b/src/PostgREST/Middleware.hs
index 89dc46493..93d3e4ec2 100644
--- a/src/PostgREST/Middleware.hs
+++ b/src/PostgREST/Middleware.hs
@@ -27,7 +27,7 @@ import PostgREST.Config (AppConfig (..), corsPolicy)
import PostgREST.Error (SimpleError (JwtTokenInvalid, JwtTokenMissing),
errorResponseFor)
import PostgREST.QueryBuilder (setLocalQuery, setLocalSearchPathQuery)
-import Protolude
+import Protolude hiding (head)
runWithClaims :: AppConfig -> JWTAttempt ->
(ApiRequest -> H.Transaction Response) ->
@@ -50,7 +50,7 @@ runWithClaims conf eClaims app req =
appSettingsSql = setLocalQuery mempty <$> configSettings conf
setRoleSql = maybeToList $ (\x ->
setLocalQuery mempty ("role", unquoted x)) <$> M.lookup "role" claimsWithRole
- setSearchPathSql = setLocalSearchPathQuery $ configSchema conf : configExtraSearchPath conf
+ setSearchPathSql = setLocalSearchPathQuery (iSchema req : configExtraSearchPath conf)
-- role claim defaults to anon if not specified in jwt
claimsWithRole = M.union claims (M.singleton "role" anon)
anon = JSON.String . toS $ configAnonRole conf
diff --git a/src/PostgREST/Private/Common.hs b/src/PostgREST/Private/Common.hs
index fafabdc04..c1acc9258 100644
--- a/src/PostgREST/Private/Common.hs
+++ b/src/PostgREST/Private/Common.hs
@@ -20,3 +20,6 @@ element = HD.element . HD.nonNullable
param :: HE.Value a -> HE.Params a
param = HE.param . HE.nonNullable
+
+arrayParam :: HE.Value a -> HE.Params [a]
+arrayParam = param . HE.array . HE.dimension foldl' . HE.element . HE.nonNullable
diff --git a/src/PostgREST/Types.hs b/src/PostgREST/Types.hs
index 2e65e3a7e..0d5c34b06 100644
--- a/src/PostgREST/Types.hs
+++ b/src/PostgREST/Types.hs
@@ -2,6 +2,7 @@
Module : PostgREST.Types
Description : PostgREST common types and functions used by the rest of the modules
-}
+{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
module PostgREST.Types where
@@ -105,8 +106,7 @@ data DbStructure = DbStructure {
, dbColumns :: [Column]
, dbRelations :: [Relation]
, dbPrimaryKeys :: [PrimaryKey]
--- ProcDescription is a list because a function can be overloaded
-, dbProcs :: M.HashMap Text [ProcDescription]
+, dbProcs :: ProcsMap
, pgVersion :: PgVersion
} deriving (Show, Eq)
@@ -132,7 +132,8 @@ data ProcVolatility = Volatile | Stable | Immutable
deriving (Eq, Show, Ord)
data ProcDescription = ProcDescription {
- pdName :: Text
+ pdSchema :: Schema
+, pdName :: Text
, pdDescription :: Maybe Text
, pdArgs :: [PgArg]
, pdReturnType :: RetType
@@ -141,18 +142,23 @@ data ProcDescription = ProcDescription {
-- Order by least number of args in the case of overloaded functions
instance Ord ProcDescription where
- ProcDescription name1 des1 args1 rt1 vol1 `compare` ProcDescription name2 des2 args2 rt2 vol2
- | name1 == name2 && length args1 < length args2 = LT
- | name1 == name2 && length args1 > length args2 = GT
- | otherwise = (name1, des1, args1, rt1, vol1) `compare` (name2, des2, args2, rt2, vol2)
+ ProcDescription schema1 name1 des1 args1 rt1 vol1 `compare` ProcDescription schema2 name2 des2 args2 rt2 vol2
+ | schema1 == schema2 && name1 == name2 && length args1 < length args2 = LT
+ | schema2 == schema2 && name1 == name2 && length args1 > length args2 = GT
+ | otherwise = (schema1, name1, des1, args1, rt1, vol1) `compare` (schema2, name2, des2, args2, rt2, vol2)
+
+-- | A map of all procs, all of which can be overloaded(one entry will have more than one ProcDescription).
+-- | It uses a HashMap for a faster lookup.
+type ProcsMap = M.HashMap QualifiedIdentifier [ProcDescription]
{-|
Search a pg procedure by its parameters. Since a function can be overloaded, the name is not enough to find it.
An overloaded function can have a different volatility or even a different return type.
+ Ideally, handling overloaded functions should be left to pg itself. But we need to know certain proc attributes in advance.
-}
-findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> M.HashMap Text [ProcDescription] -> Maybe ProcDescription
+findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> ProcsMap -> Maybe ProcDescription
findProc qi payloadKeys paramsAsSingleObject allProcs =
- case M.lookup (qiName qi) allProcs of
+ case M.lookup qi allProcs of
Nothing -> Nothing
Just [proc] -> Just proc -- if it's not an overloaded function then immediately get the ProcDescription
Just procs -> find matches procs -- Handle overloaded functions case
@@ -254,8 +260,8 @@ data OrderTerm = OrderTerm {
data QualifiedIdentifier = QualifiedIdentifier {
qiSchema :: Schema
, qiName :: TableName
-} deriving (Show, Eq, Ord)
-
+} deriving (Show, Eq, Ord, Generic)
+instance Hashable QualifiedIdentifier
-- | The relationship [cardinality](https://en.wikipedia.org/wiki/Cardinality_(data_modeling)).
-- | TODO: missing one-to-one
@@ -512,6 +518,9 @@ pgVersion112 = PgVersion 110002 "11.2"
pgVersion114 :: PgVersion
pgVersion114 = PgVersion 110004 "11.4"
+pgVersion121 :: PgVersion
+pgVersion121 = PgVersion 120001 "12.1"
+
sourceCTEName :: SqlFragment
sourceCTEName = "pg_source"
diff --git a/test/Feature/JsonOperatorSpec.hs b/test/Feature/JsonOperatorSpec.hs
index 7367bcdab..b9318d4bd 100644
--- a/test/Feature/JsonOperatorSpec.hs
+++ b/test/Feature/JsonOperatorSpec.hs
@@ -7,7 +7,7 @@ import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
-import PostgREST.Types (PgVersion, pgVersion112)
+import PostgREST.Types (PgVersion, pgVersion112, pgVersion121)
import Protolude hiding (get)
import SpecHelper
@@ -26,7 +26,12 @@ spec actualPgVersion = describe "json and jsonb operators" $ do
it "fails on bad casting (data of the wrong format)" $
get "/complex_items?select=settings->foo->>bar::integer"
- `shouldRespondWith` [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
+ `shouldRespondWith` (
+ if actualPgVersion >= pgVersion121 then
+ [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"baz\""} |]
+ else
+ [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
+ )
{ matchStatus = 400 , matchHeaders = [] }
it "obtains a json subfield two levels (string)" $
diff --git a/test/Feature/MultipleSchemaSpec.hs b/test/Feature/MultipleSchemaSpec.hs
new file mode 100644
index 000000000..d45bc888e
--- /dev/null
+++ b/test/Feature/MultipleSchemaSpec.hs
@@ -0,0 +1,324 @@
+module Feature.MultipleSchemaSpec where
+
+import Control.Lens ((^?))
+import Data.Aeson.Lens
+import Data.Aeson.QQ
+
+import Network.HTTP.Types
+import Network.Wai (Application)
+import Network.Wai.Test (SResponse (simpleHeaders), simpleBody)
+
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude
+import SpecHelper
+
+import PostgREST.Types (PgVersion, pgVersion96)
+
+spec :: PgVersion -> SpecWith ((), Application)
+spec actualPgVersion =
+ describe "multiple schemas in single instance" $ do
+ context "Reading tables on different schemas" $ do
+ it "succeeds in reading table from default schema v1 if no schema is selected via header" $
+ request methodGet "/parents" [] "" `shouldRespondWith`
+ [json|[
+ {"id":1,"name":"parent v1-1"},
+ {"id":2,"name":"parent v1-2"}
+ ]|]
+ {
+ matchStatus = 200
+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
+ }
+
+ it "succeeds in reading table from default schema v1 after explicitly passing it in the header" $
+ request methodGet "/parents" [("Accept-Profile", "v1")] "" `shouldRespondWith`
+ [json|[
+ {"id":1,"name":"parent v1-1"},
+ {"id":2,"name":"parent v1-2"}
+ ]|]
+ {
+ matchStatus = 200
+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
+ }
+
+ it "succeeds in reading table from schema v2" $
+ request methodGet "/parents" [("Accept-Profile", "v2")] "" `shouldRespondWith`
+ [json|[
+ {"id":3,"name":"parent v2-3"},
+ {"id":4,"name":"parent v2-4"}
+ ]|]
+ {
+ matchStatus = 200
+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
+ }
+
+ it "succeeds in reading another_table from schema v2" $
+ request methodGet "/another_table" [("Accept-Profile", "v2")] "" `shouldRespondWith`
+ [json|[
+ {"id":5,"another_value":"value 5"},
+ {"id":6,"another_value":"value 6"}
+ ]|]
+ {
+ matchStatus = 200
+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
+ }
+
+ it "doesn't find another_table in schema v1" $
+ request methodGet "/another_table" [("Accept-Profile", "v1")] "" `shouldRespondWith` 404
+
+ it "fails trying to read table from unkown schema" $
+ request methodGet "/parents" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
+ [json|{"message":"The schema must be one of the following: v1, v2"}|]
+ {
+ matchStatus = 406
+ }
+
+ context "Inserting tables on different schemas" $ do
+ it "succeeds inserting on default schema and returning it" $
+ request methodPost "/childs" [("Prefer", "return=representation")] [json|{"name": "child v1-1", "parent_id": 1}|]
+ `shouldRespondWith`
+ [json|[{"id":1, "name": "child v1-1", "parent_id": 1}]|]
+ {
+ matchStatus = 201
+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
+ }
+
+ it "succeeds inserting on the v1 schema and returning its parent" $
+ request methodPost "/childs?select=id,parent(*)" [("Prefer", "return=representation"), ("Content-Profile", "v1")]
+ [json|{"name": "child v1-2", "parent_id": 2}|]
+ `shouldRespondWith`
+ [json|[{"id":2, "parent": {"id": 2, "name": "parent v1-2"}}]|]
+ {
+ matchStatus = 201
+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
+ }
+
+ it "succeeds inserting on the v2 schema and returning its parent" $
+ request methodPost "/childs?select=id,parent(*)" [("Prefer", "return=representation"), ("Content-Profile", "v2")]
+ [json|{"name": "child v2-3", "parent_id": 3}|]
+ `shouldRespondWith`
+ [json|[{"id":1, "parent": {"id": 3, "name": "parent v2-3"}}]|]
+ {
+ matchStatus = 201
+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
+ }
+
+ it "fails when inserting on an unknown schema" $
+ request methodPost "/childs" [("Content-Profile", "unknown")]
+ [json|{"name": "child 4", "parent_id": 4}|]
+ `shouldRespondWith`
+ [json|{"message":"The schema must be one of the following: v1, v2"}|]
+ {
+ matchStatus = 406
+ }
+
+ context "calling procs on different schemas" $ do
+ it "succeeds in calling the default schema proc" $
+ request methodGet "/rpc/get_parents_below?id=6" [] ""
+ `shouldRespondWith`
+ [json|[{"id":1,"name":"parent v1-1"}, {"id":2,"name":"parent v1-2"}]|]
+ {
+ matchStatus = 200
+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
+ }
+
+ it "succeeds in calling the v1 schema proc and embedding" $
+ request methodGet "/rpc/get_parents_below?id=6&select=id,name,childs(id,name)" [("Accept-Profile", "v1")] ""
+ `shouldRespondWith`
+ [json| [
+ {"id":1,"name":"parent v1-1","childs":[{"id":1,"name":"child v1-1"}]},
+ {"id":2,"name":"parent v1-2","childs":[{"id":2,"name":"child v1-2"}]}] |]
+ {
+ matchStatus = 200
+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
+ }
+
+ it "succeeds in calling the v2 schema proc and embedding" $
+ request methodGet "/rpc/get_parents_below?id=6&select=id,name,childs(id,name)" [("Accept-Profile", "v2")] ""
+ `shouldRespondWith`
+ [json| [
+ {"id":3,"name":"parent v2-3","childs":[{"id":1,"name":"child v2-3"}]},
+ {"id":4,"name":"parent v2-4","childs":[]}] |]
+ {
+ matchStatus = 200
+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
+ }
+
+ context "Modifying tables on different schemas" $ do
+ it "succeeds in patching on the v1 schema and returning its parent" $
+ request methodPatch "/childs?select=name,parent(name)&id=eq.1" [("Content-Profile", "v1"), ("Prefer", "return=representation")]
+ [json|{"name": "child v1-1 updated"}|]
+ `shouldRespondWith`
+ [json|[{"name":"child v1-1 updated", "parent": {"name": "parent v1-1"}}]|]
+ {
+ matchStatus = 200
+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
+ }
+
+ it "succeeds in patching on the v2 schema and returning its parent" $
+ request methodPatch "/childs?select=name,parent(name)&id=eq.1" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
+ [json|{"name": "child v2-1 updated"}|]
+ `shouldRespondWith`
+ [json|[{"name":"child v2-1 updated", "parent": {"name": "parent v2-3"}}]|]
+ {
+ matchStatus = 200
+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
+ }
+
+ it "succeeds on deleting on the v2 schema" $ do
+ request methodDelete "/childs?id=eq.1" [("Content-Profile", "v2"), ("Prefer", "return=representation")] ""
+ `shouldRespondWith` [json|[{"id": 1, "name": "child v2-1 updated", "parent_id": 3}]|]
+ {
+ matchStatus = 200
+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
+ }
+ request methodGet "/childs?id=eq.1" [("Accept-Profile", "v2")] ""
+ `shouldRespondWith` "[]"
+ {
+ matchStatus = 200
+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
+ }
+
+ when (actualPgVersion >= pgVersion96) $
+ it "succeeds on PUT on the v2 schema" $
+ request methodPut "/childs?id=eq.111" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
+ [json| [ { "id": 111, "name": "child v2-111", "parent_id": null } ]|]
+ `shouldRespondWith`
+ [json|[{ "id": 111, "name": "child v2-111", "parent_id": null }]|]
+ {
+ matchStatus = 200
+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
+ }
+
+ context "OpenAPI output" $ do
+ it "succeeds in reading table definition from default schema v1 if no schema is selected via header" $ do
+ req <- request methodGet "/" [] ""
+
+ liftIO $ do
+ simpleHeaders req `shouldSatisfy` matchHeader "Content-Profile" "v1"
+
+ let def = simpleBody req ^? key "definitions" . key "parents"
+
+ def `shouldBe` Just
+ [aesonQQ|
+ {
+ "type" : "object",
+ "properties" : {
+ "id" : {
+ "description" : "Note:\nThis is a Primary Key.",
+ "format" : "integer",
+ "type" : "integer"
+ },
+ "name" : {
+ "format" : "text",
+ "type" : "string"
+ }
+ },
+ "required" : [
+ "id"
+ ]
+ }
+ |]
+
+ it "succeeds in reading table definition from default schema v1 after explicitly passing it in the header" $ do
+ r <- request methodGet "/" [("Accept-Profile", "v1")] ""
+
+ liftIO $ do
+ simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v1"
+
+ let def = simpleBody r ^? key "definitions" . key "parents"
+
+ def `shouldBe` Just
+ [aesonQQ|
+ {
+ "type" : "object",
+ "properties" : {
+ "id" : {
+ "description" : "Note:\nThis is a Primary Key.",
+ "format" : "integer",
+ "type" : "integer"
+ },
+ "name" : {
+ "format" : "text",
+ "type" : "string"
+ }
+ },
+ "required" : [
+ "id"
+ ]
+ }
+ |]
+
+ it "succeeds in reading table definition from schema v2" $ do
+ r <- request methodGet "/" [("Accept-Profile", "v2")] ""
+
+ liftIO $ do
+ simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"
+
+ let def = simpleBody r ^? key "definitions" . key "parents"
+
+ def `shouldBe` Just
+ [aesonQQ|
+ {
+ "type" : "object",
+ "properties" : {
+ "id" : {
+ "description" : "Note:\nThis is a Primary Key.",
+ "format" : "integer",
+ "type" : "integer"
+ },
+ "name" : {
+ "format" : "text",
+ "type" : "string"
+ }
+ },
+ "required" : [
+ "id"
+ ]
+ }
+ |]
+
+ it "succeeds in reading another_table definition from schema v2" $ do
+ r <- request methodGet "/" [("Accept-Profile", "v2")] ""
+
+ liftIO $ do
+ simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"
+
+ let def = simpleBody r ^? key "definitions" . key "another_table"
+
+ def `shouldBe` Just
+ [aesonQQ|
+ {
+ "type" : "object",
+ "properties" : {
+ "id" : {
+ "description" : "Note:\nThis is a Primary Key.",
+ "format" : "integer",
+ "type" : "integer"
+ },
+ "another_value" : {
+ "format" : "text",
+ "type" : "string"
+ }
+ },
+ "required" : [
+ "id"
+ ]
+ }
+ |]
+
+ it "doesn't find another_table definition in schema v1" $ do
+ r <- request methodGet "/" [("Accept-Profile", "v1")] ""
+
+ liftIO $ do
+ let def = simpleBody r ^? key "definitions" . key "another_table"
+ def `shouldBe` Nothing
+
+ it "fails trying to read definitions from unkown schema" $
+ request methodGet "/" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
+ [json|{"message":"The schema must be one of the following: v1, v2"}|]
+ {
+ matchStatus = 406
+ }
diff --git a/test/Feature/QuerySpec.hs b/test/Feature/QuerySpec.hs
index fec9b74b6..05574e75d 100644
--- a/test/Feature/QuerySpec.hs
+++ b/test/Feature/QuerySpec.hs
@@ -10,7 +10,7 @@ import Test.Hspec.Wai.JSON
import Text.Heredoc
-import PostgREST.Types (PgVersion, pgVersion112)
+import PostgREST.Types (PgVersion, pgVersion112, pgVersion121)
import Protolude hiding (get)
import SpecHelper
@@ -833,8 +833,12 @@ spec actualPgVersion = do
it "only returns an empty result set if the in value is empty" $
get "/items_with_different_col_types?int_data=in.( ,3,4)"
- `shouldRespondWith`
+ `shouldRespondWith` (
+ if actualPgVersion >= pgVersion121 then
+ [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"\""} |]
+ else
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"\""} |]
+ )
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
@@ -870,3 +874,9 @@ spec actualPgVersion = do
{"site":"hub.docker.com", "link":{"url":"http://postgrest.org/en/v6.0/admin.html"}}
]|]
{ matchHeaders = [matchContentTypeJson] }
+
+ it "shouldn't produce a Content-Profile header since only a single schema is exposed" $ do
+ r <- get "/items"
+ liftIO $ do
+ let respHeaders = simpleHeaders r
+ respHeaders `shouldSatisfy` noProfileHeader
diff --git a/test/Main.hs b/test/Main.hs
index 84cf717ea..869f6a667 100644
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -6,16 +6,17 @@ import qualified Hasql.Transaction.Sessions as HT
import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
updateAction)
import Data.Function (id)
+import Data.List.NonEmpty (toList)
import Data.Time.Clock (getCurrentTime)
import Data.IORef
import Test.Hspec
import PostgREST.App (postgrest)
+import PostgREST.Config (AppConfig (..))
import PostgREST.DbStructure (getDbStructure, getPgVersion)
-import PostgREST.Types (DbStructure (..), pgVersion95,
- pgVersion96)
-import Protolude
+import PostgREST.Types (pgVersion95, pgVersion96)
+import Protolude hiding (toList)
import SpecHelper
import qualified Feature.AndOrParamsSpec
@@ -31,6 +32,7 @@ import qualified Feature.ExtraSearchPathSpec
import qualified Feature.HtmlRawOutputSpec
import qualified Feature.InsertSpec
import qualified Feature.JsonOperatorSpec
+import qualified Feature.MultipleSchemaSpec
import qualified Feature.NoJwtSpec
import qualified Feature.NonexistentSchemaSpec
import qualified Feature.PgVersion95Spec
@@ -50,45 +52,49 @@ import qualified Feature.UpsertSpec
main :: IO ()
main = do
+ getTime <- mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime }
+
testDbConn <- getEnvVarWithDefault "POSTGREST_TEST_CONNECTION" "postgres://postgrest_test@localhost/postgrest_test"
setupDb testDbConn
pool <- P.acquire (3, 10, toS testDbConn)
- result <- P.use pool $ do
- ver <- getPgVersion
- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure "test" ver
+ actualPgVersion <- either (panic.show) id <$> P.use pool getPgVersion
- let dbStructure = either (panic.show) id result
+ refDbStructure <- (newIORef . Just) =<< setupDbStructure pool (configSchemas $ testCfg testDbConn) actualPgVersion
- getTime <- mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime }
+ let
+ -- For tests that run with the same refDbStructure
+ app cfg = return ((), postgrest (cfg testDbConn) refDbStructure pool getTime $ pure ())
- refDbStructure <- newIORef $ Just dbStructure
-
- let app cfg = return ((), postgrest (cfg testDbConn) refDbStructure pool getTime $ pure ())
+ -- For tests that run with a different DbStructure(depends on configSchemas)
+ appDbs cfg = do
+ dbs <- (newIORef . Just) =<< setupDbStructure pool (configSchemas $ cfg testDbConn) actualPgVersion
+ return ((), postgrest (cfg testDbConn) dbs pool getTime $ pure ())
let withApp = app testCfg
maxRowsApp = app testMaxRowsCfg
- unicodeApp = app testUnicodeCfg
proxyApp = app testProxyCfg
noJwtApp = app testCfgNoJWT
binaryJwtApp = app testCfgBinaryJWT
audJwtApp = app testCfgAudienceJWT
asymJwkApp = app testCfgAsymJWK
asymJwkSetApp = app testCfgAsymJWKSet
- nonexistentSchemaApp = app testNonexistentSchemaCfg
extraSearchPathApp = app testCfgExtraSearchPath
rootSpecApp = app testCfgRootSpec
htmlRawOutputApp = app testCfgHtmlRawOutput
responseHeadersApp = app testCfgResponseHeaders
+ unicodeApp = appDbs testUnicodeCfg
+ nonexistentSchemaApp = appDbs testNonexistentSchemaCfg
+ multipleSchemaApp = appDbs testMultipleSchemaCfg
+
let reset, analyze :: IO ()
reset = resetDb testDbConn
analyze = do
analyzeTable testDbConn "items"
analyzeTable testDbConn "child_entities"
- actualPgVersion = pgVersion dbStructure
extraSpecs =
[("Feature.UpsertSpec", Feature.UpsertSpec.spec) | actualPgVersion >= pgVersion95] ++
[("Feature.PgVersion95Spec", Feature.PgVersion95Spec.spec) | actualPgVersion >= pgVersion95]
@@ -172,3 +178,11 @@ main = do
describe "Feature.RootSpec" Feature.RootSpec.spec
before responseHeadersApp $
describe "Feature.PgVersion96Spec" Feature.PgVersion96Spec.spec
+
+ -- this test runs with multiple schemas
+ before multipleSchemaApp $
+ describe "Feature.MultipleSchemaSpec" $ Feature.MultipleSchemaSpec.spec actualPgVersion
+
+ where
+ setupDbStructure pool schemas ver =
+ either (panic.show) id <$> P.use pool (HT.transaction HT.ReadCommitted HT.Read $ getDbStructure (toList schemas) ver)
diff --git a/test/SpecHelper.hs b/test/SpecHelper.hs
index 40f158da7..394a9f4c3 100644
--- a/test/SpecHelper.hs
+++ b/test/SpecHelper.hs
@@ -11,6 +11,7 @@ import Control.Monad (void)
import Data.Aeson (Value (..), decode, encode)
import Data.CaseInsensitive (CI (..))
import Data.List (lookup)
+import Data.List.NonEmpty (fromList)
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
import System.Environment (getEnv)
import System.Process (readProcess)
@@ -63,7 +64,7 @@ getEnvVarWithDefault var def = toS <$>
_baseCfg :: AppConfig
_baseCfg = -- Connection Settings
- AppConfig mempty "postgrest_test_anonymous" Nothing "test" "localhost" 3000
+ AppConfig mempty "postgrest_test_anonymous" Nothing (fromList ["test"]) "localhost" 3000
-- No user configured Unix Socket
Nothing
-- No user configured Unix Socket file mode (defaults to 660)
@@ -93,7 +94,7 @@ testCfgNoJWT :: Text -> AppConfig
testCfgNoJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Nothing }
testUnicodeCfg :: Text -> AppConfig
-testUnicodeCfg testDbConn = (testCfg testDbConn) { configSchema = "تست" }
+testUnicodeCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["تست"] }
testMaxRowsCfg :: Text -> AppConfig
testMaxRowsCfg testDbConn = (testCfg testDbConn) { configMaxRows = Just 2 }
@@ -127,7 +128,7 @@ testCfgAsymJWKSet testDbConn = (testCfg testDbConn) {
}
testNonexistentSchemaCfg :: Text -> AppConfig
-testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchema = "nonexistent" }
+testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["nonexistent"] }
testCfgExtraSearchPath :: Text -> AppConfig
testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configExtraSearchPath = ["public", "extensions"] }
@@ -141,6 +142,9 @@ testCfgHtmlRawOutput testDbConn = (testCfg testDbConn) { configRawMediaTypes = [
testCfgResponseHeaders :: Text -> AppConfig
testCfgResponseHeaders testDbConn = (testCfg testDbConn) { configReqCheck = Just "custom_headers" }
+testMultipleSchemaCfg :: Text -> AppConfig
+testMultipleSchemaCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["v1", "v2"] }
+
setupDb :: Text -> IO ()
setupDb dbConn = do
loadFixture dbConn "database"
@@ -181,6 +185,9 @@ matchHeader name valRegex headers =
noBlankHeader :: [Header] -> Bool
noBlankHeader = notElem mempty
+noProfileHeader :: [Header] -> Bool
+noProfileHeader headers = isNothing $ find ((== "Content-Profile") . fst) headers
+
authHeaderBasic :: BS.ByteString -> BS.ByteString -> Header
authHeaderBasic u p =
(hAuthorization, "Basic " <> (toS . B64.encode . toS $ u <> ":" <> p))
diff --git a/test/fixtures/data.sql b/test/fixtures/data.sql
index c37da7519..5eff88712 100644
--- a/test/fixtures/data.sql
+++ b/test/fixtures/data.sql
@@ -572,3 +572,12 @@ INSERT INTO activities(id, schedule_id, camera_id) VALUES(2, 3, 'CAM-123');
TRUNCATE TABLE unit_workdays CASCADE;
INSERT INTO unit_workdays VALUES(1, '2019-12-02', 1, 1, 2, 3);
+
+TRUNCATE TABLE v1.parents CASCADE;
+INSERT INTO v1.parents VALUES(1, 'parent v1-1'), (2, 'parent v1-2');
+
+TRUNCATE TABLE v2.parents CASCADE;
+INSERT INTO v2.parents VALUES(3, 'parent v2-3'), (4, 'parent v2-4');
+
+TRUNCATE TABLE v2.another_table CASCADE;
+INSERT INTO v2.another_table VALUES(5, 'value 5'), (6, 'value 6');
diff --git a/test/fixtures/database.sql b/test/fixtures/database.sql
index 52e6aad94..80b684e2c 100644
--- a/test/fixtures/database.sql
+++ b/test/fixtures/database.sql
@@ -1,3 +1,3 @@
set client_min_messages to warning;
-DROP SCHEMA IF EXISTS test, private, postgrest, jwt, public, تست, extensions CASCADE;
+DROP SCHEMA IF EXISTS test, private, postgrest, jwt, public, تست, extensions, v1, v2 CASCADE;
DROP TYPE IF EXISTS jwt_token CASCADE;
diff --git a/test/fixtures/privileges.sql b/test/fixtures/privileges.sql
index 70230cd99..34be7600b 100644
--- a/test/fixtures/privileges.sql
+++ b/test/fixtures/privileges.sql
@@ -6,6 +6,8 @@ GRANT USAGE ON SCHEMA
, public
, "تست"
, extensions
+ , v1
+ , v2
TO postgrest_test_anonymous;
-- Schema test objects
@@ -122,6 +124,11 @@ GRANT ALL ON TABLE
, unit_workdays
, stuff
, loc_test
+ , v1.parents
+ , v2.parents
+ , v2.another_table
+ , v1.childs
+ , v2.childs
TO postgrest_test_anonymous;
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
@@ -131,6 +138,8 @@ GRANT USAGE ON SEQUENCE
, items_id_seq
, callcounter_count
, leak_id_seq
+ , v1.childs_id_seq
+ , v2.childs_id_seq
TO postgrest_test_anonymous;
-- Privileges for non anonymous users
diff --git a/test/fixtures/schema.sql b/test/fixtures/schema.sql
index 5f868f241..0fb270403 100755
--- a/test/fixtures/schema.sql
+++ b/test/fixtures/schema.sql
@@ -18,6 +18,8 @@ CREATE SCHEMA private;
CREATE SCHEMA test;
CREATE SCHEMA تست;
CREATE SCHEMA extensions;
+CREATE SCHEMA v1;
+CREATE SCHEMA v2;
--
-- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: -
@@ -1693,3 +1695,45 @@ create table loc_test (
id int primary key
, c text
);
+
+-- tables to test multi schema access in one instance
+create table v1.parents (
+ id int primary key
+, name text
+);
+
+create table v1.childs (
+ id serial primary key
+, name text
+, parent_id int
+, constraint parent foreign key(parent_id)
+ references v1.parents(id)
+);
+
+create function v1.get_parents_below(id int)
+returns setof v1.parents as $$
+ select * from v1.parents where id < $1;
+$$ language sql;
+
+create table v2.parents (
+ id int primary key
+, name text
+);
+
+create table v2.childs (
+ id serial primary key
+, name text
+, parent_id int
+, constraint parent foreign key(parent_id)
+ references v2.parents(id)
+);
+
+create table v2.another_table (
+ id int primary key
+, another_value text
+);
+
+create function v2.get_parents_below(id int)
+returns setof v2.parents as $$
+ select * from v2.parents where id < $1;
+$$ language sql;
diff --git a/test/memory-tests.sh b/test/memory-tests.sh
index 2da192e7a..78377c99c 100755
--- a/test/memory-tests.sh
+++ b/test/memory-tests.sh
@@ -94,21 +94,21 @@ setUp
echo "Running memory usage tests.."
-jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "12M"
-jsonKeyTest "1M" "POST" "/leak?columns=blob" "12M"
-jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "12M"
+jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "13M"
+jsonKeyTest "1M" "POST" "/leak?columns=blob" "13M"
+jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "13M"
-jsonKeyTest "10M" "POST" "/rpc/leak?columns=blob" "40M"
-jsonKeyTest "10M" "POST" "/leak?columns=blob" "40M"
-jsonKeyTest "10M" "PATCH" "/leak?id=eq.1&columns=blob" "40M"
+jsonKeyTest "10M" "POST" "/rpc/leak?columns=blob" "41M"
+jsonKeyTest "10M" "POST" "/leak?columns=blob" "41M"
+jsonKeyTest "10M" "PATCH" "/leak?id=eq.1&columns=blob" "41M"
-jsonKeyTest "50M" "POST" "/rpc/leak?columns=blob" "170M"
-jsonKeyTest "50M" "POST" "/leak?columns=blob" "170M"
-jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "170M"
+jsonKeyTest "50M" "POST" "/rpc/leak?columns=blob" "171M"
+jsonKeyTest "50M" "POST" "/leak?columns=blob" "171M"
+jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "171M"
-postJsonArrayTest "1000" "/perf_articles?columns=id,body" "10M"
-postJsonArrayTest "10000" "/perf_articles?columns=id,body" "10M"
-postJsonArrayTest "100000" "/perf_articles?columns=id,body" "20M"
+postJsonArrayTest "1000" "/perf_articles?columns=id,body" "11M"
+postJsonArrayTest "10000" "/perf_articles?columns=id,body" "11M"
+postJsonArrayTest "100000" "/perf_articles?columns=id,body" "21M"
cleanUp