feat: validate ?columns mutation targets based on schema cache (#2542)

This returns an error for trying to update or insert into invalid columns, without hitting the database. This change also switches from `json_populate_recordset` for these operations `json_to_recordset` which should make no functional difference except allowing future flexibility.
This commit is contained in:
Alexander Ljungberg
2023-01-07 22:08:30 -05:00
committed by GitHub
parent 5a0f83ecb8
commit 43ad6d6aa0
14 changed files with 174 additions and 52 deletions
+1
View File
@@ -34,6 +34,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- #2548, Fix regression when embedding views with partial references to multi column FKs - @wolfgangwalther - #2548, Fix regression when embedding views with partial references to multi column FKs - @wolfgangwalther
- #2558, Fix regression when requesting limit=0 and `db-max-row` is set - @laurenceisla - #2558, Fix regression when requesting limit=0 and `db-max-row` is set - @laurenceisla
- #2542, Return a clear error without hitting the database when trying to update or insert an unknown column with `?columns` - @aljungberg
## [10.1.0] - 2022-10-28 ## [10.1.0] - 2022-10-28
+1
View File
@@ -60,6 +60,7 @@ library
PostgREST.Plan.CallPlan PostgREST.Plan.CallPlan
PostgREST.Plan.MutatePlan PostgREST.Plan.MutatePlan
PostgREST.Plan.ReadPlan PostgREST.Plan.ReadPlan
PostgREST.Plan.Types
PostgREST.RangeQuery PostgREST.RangeQuery
PostgREST.ApiRequest PostgREST.ApiRequest
PostgREST.ApiRequest.Preferences PostgREST.ApiRequest.Preferences
+3 -2
View File
@@ -84,6 +84,7 @@ data ApiRequestError
| UnacceptableFilter Text | UnacceptableFilter Text
| UnacceptableSchema [Text] | UnacceptableSchema [Text]
| UnsupportedMethod ByteString | UnsupportedMethod ByteString
| ColumnNotFound Text Text
data QPError = QPError Text Text data QPError = QPError Text Text
data RangeError data RangeError
@@ -147,7 +148,7 @@ type JsonPath = [JsonOperation]
data JsonOperation data JsonOperation
= JArrow { jOp :: JsonOperand } = JArrow { jOp :: JsonOperand }
| J2Arrow { jOp :: JsonOperand } | J2Arrow { jOp :: JsonOperand }
deriving (Eq) deriving (Eq, Ord)
-- | Represents the key(`->'key'`) or index(`->'1`::int`), the index is Text -- | Represents the key(`->'key'`) or index(`->'1`::int`), the index is Text
-- because we reuse our escaping functons and let pg do the casting with -- because we reuse our escaping functons and let pg do the casting with
@@ -155,7 +156,7 @@ data JsonOperation
data JsonOperand data JsonOperand
= JKey { jVal :: Text } = JKey { jVal :: Text }
| JIdx { jVal :: Text } | JIdx { jVal :: Text }
deriving (Eq) deriving (Eq, Ord)
-- | Boolean logic expression tree e.g. "and(name.eq.N,or(id.eq.1,id.eq.2))" is: -- | Boolean logic expression tree e.g. "and(name.eq.N,or(id.eq.1,id.eq.2))" is:
-- --
+6
View File
@@ -81,6 +81,7 @@ instance PgrstError ApiRequestError where
status UnacceptableSchema{} = HTTP.status406 status UnacceptableSchema{} = HTTP.status406
status UnsupportedMethod{} = HTTP.status405 status UnsupportedMethod{} = HTTP.status405
status LimitNoOrderError = HTTP.status400 status LimitNoOrderError = HTTP.status400
status ColumnNotFound{} = HTTP.status400
headers _ = [MediaType.toContentType MTApplicationJSON] headers _ = [MediaType.toContentType MTApplicationJSON]
@@ -216,6 +217,11 @@ instance JSON.ToJSON ApiRequestError where
"message" .= ("Could not choose the best candidate function between: " <> T.intercalate ", " [pdSchema p <> "." <> pdName p <> "(" <> T.intercalate ", " [ppName a <> " => " <> ppType a | a <- pdParams p] <> ")" | p <- procs]), "message" .= ("Could not choose the best candidate function between: " <> T.intercalate ", " [pdSchema p <> "." <> pdName p <> "(" <> T.intercalate ", " [ppName a <> " => " <> ppType a | a <- pdParams p] <> ")" | p <- procs]),
"details" .= JSON.Null, "details" .= JSON.Null,
"hint" .= ("Try renaming the parameters or the function itself in the database so function overloading can be resolved" :: Text)] "hint" .= ("Try renaming the parameters or the function itself in the database so function overloading can be resolved" :: Text)]
toJSON (ColumnNotFound relName colName) = JSON.object [
"code" .= ApiRequestErrorCode18,
"message" .= ("Column '" <> colName <> "' of relation '" <> relName <> "' does not exist" :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
-- | -- |
-- If no relationship is found then: -- If no relationship is found then:
+20 -9
View File
@@ -27,7 +27,7 @@ import qualified Data.HashMap.Strict as HM
import qualified Data.Set as S import qualified Data.Set as S
import qualified PostgREST.SchemaCache.Proc as Proc import qualified PostgREST.SchemaCache.Proc as Proc
import Data.Either.Combinators (mapLeft) import Data.Either.Combinators (mapLeft, mapRight)
import Data.List (delete) import Data.List (delete)
import Data.Tree (Tree (..)) import Data.Tree (Tree (..))
@@ -54,14 +54,15 @@ import PostgREST.SchemaCache.Relationship (Cardinality (..),
Relationship (..), Relationship (..),
RelationshipsMap, RelationshipsMap,
relIsToOne) relIsToOne)
import PostgREST.SchemaCache.Table (tablePKCols) import PostgREST.SchemaCache.Table (Table (tableName),
tablePKCols)
import PostgREST.Plan.CallPlan
import PostgREST.Plan.MutatePlan
import PostgREST.Plan.ReadPlan as ReadPlan
import PostgREST.ApiRequest.Preferences import PostgREST.ApiRequest.Preferences
import PostgREST.ApiRequest.Types import PostgREST.ApiRequest.Types
import PostgREST.Plan.CallPlan
import PostgREST.Plan.MutatePlan
import PostgREST.Plan.ReadPlan as ReadPlan
import PostgREST.Plan.Types
import qualified PostgREST.ApiRequest.QueryParams as QueryParams import qualified PostgREST.ApiRequest.QueryParams as QueryParams
@@ -400,8 +401,9 @@ mutatePlan :: Mutation -> QualifiedIdentifier -> ApiRequest -> SchemaCache -> Re
mutatePlan mutation qi ApiRequest{..} sCache readReq = mapLeft ApiRequestError $ mutatePlan mutation qi ApiRequest{..} sCache readReq = mapLeft ApiRequestError $
case mutation of case mutation of
MutationCreate -> MutationCreate ->
Right $ Insert qi iColumns body ((,) <$> iPreferResolution <*> Just confCols) [] returnings pkCols mapRight (\typedColumns -> Insert qi typedColumns body ((,) <$> iPreferResolution <*> Just confCols) [] returnings pkCols) typedColumnsOrError
MutationUpdate -> Right $ Update qi iColumns body combinedLogic iTopLevelRange rootOrder returnings MutationUpdate ->
mapRight (\typedColumns -> Update qi typedColumns body combinedLogic iTopLevelRange rootOrder returnings) typedColumnsOrError
MutationSingleUpsert -> MutationSingleUpsert ->
if null qsLogic && if null qsLogic &&
qsFilterFields == S.fromList pkCols && qsFilterFields == S.fromList pkCols &&
@@ -409,7 +411,7 @@ mutatePlan mutation qi ApiRequest{..} sCache readReq = mapLeft ApiRequestError $
all (\case all (\case
Filter _ (OpExpr False (Op OpEqual _)) -> True Filter _ (OpExpr False (Op OpEqual _)) -> True
_ -> False) qsFiltersRoot _ -> False) qsFiltersRoot
then Right $ Insert qi iColumns body (Just (MergeDuplicates, pkCols)) combinedLogic returnings mempty then mapRight (\typedColumns -> Insert qi typedColumns body (Just (MergeDuplicates, pkCols)) combinedLogic returnings mempty) typedColumnsOrError
else else
Left InvalidFilters Left InvalidFilters
MutationDelete -> Right $ Delete qi combinedLogic iTopLevelRange rootOrder returnings MutationDelete -> Right $ Delete qi combinedLogic iTopLevelRange rootOrder returnings
@@ -425,6 +427,15 @@ mutatePlan mutation qi ApiRequest{..} sCache readReq = mapLeft ApiRequestError $
rootOrder = maybe [] snd $ find (\(x, _) -> null x) qsOrder rootOrder = maybe [] snd $ find (\(x, _) -> null x) qsOrder
combinedLogic = foldr addFilterToLogicForest logic qsFiltersRoot combinedLogic = foldr addFilterToLogicForest logic qsFiltersRoot
body = payRaw <$> iPayload -- the body is assumed to be json at this stage(ApiRequest validates) body = payRaw <$> iPayload -- the body is assumed to be json at this stage(ApiRequest validates)
tbl = HM.lookup qi $ dbTables sCache
typedColumnsOrError = resolveOrError tbl `traverse` S.toList iColumns
resolveOrError :: Maybe Table -> FieldName -> Either ApiRequestError TypedField
resolveOrError Nothing _ = Left NotFound
resolveOrError (Just table) field =
case resolveTableField table field of
Nothing -> Left $ ColumnNotFound (tableName table) field
Just typedField -> Right typedField
callPlan :: ProcDescription -> ApiRequest -> ReadPlanTree -> CallPlan callPlan :: ProcDescription -> ApiRequest -> ReadPlanTree -> CallPlan
callPlan proc apiReq readReq = FunctionCall { callPlan proc apiReq readReq = FunctionCall {
+4 -3
View File
@@ -4,20 +4,21 @@ module PostgREST.Plan.MutatePlan
where where
import qualified Data.ByteString.Lazy as LBS import qualified Data.ByteString.Lazy as LBS
import qualified Data.Set as S
import PostgREST.ApiRequest.Preferences (PreferResolution) import PostgREST.ApiRequest.Preferences (PreferResolution)
import PostgREST.ApiRequest.Types (LogicTree, OrderTerm) import PostgREST.ApiRequest.Types (LogicTree, OrderTerm)
import PostgREST.Plan.Types (TypedField)
import PostgREST.RangeQuery (NonnegRange) import PostgREST.RangeQuery (NonnegRange)
import PostgREST.SchemaCache.Identifiers (FieldName, import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier) QualifiedIdentifier)
import Protolude import Protolude
data MutatePlan data MutatePlan
= Insert = Insert
{ in_ :: QualifiedIdentifier { in_ :: QualifiedIdentifier
, insCols :: S.Set FieldName , insCols :: [TypedField]
, insBody :: Maybe LBS.ByteString , insBody :: Maybe LBS.ByteString
, onConflict :: Maybe (PreferResolution, [FieldName]) , onConflict :: Maybe (PreferResolution, [FieldName])
, where_ :: [LogicTree] , where_ :: [LogicTree]
@@ -26,7 +27,7 @@ data MutatePlan
} }
| Update | Update
{ in_ :: QualifiedIdentifier { in_ :: QualifiedIdentifier
, updCols :: S.Set FieldName , updCols :: [TypedField]
, updBody :: Maybe LBS.ByteString , updBody :: Maybe LBS.ByteString
, where_ :: [LogicTree] , where_ :: [LogicTree]
, mutRange :: NonnegRange , mutRange :: NonnegRange
+24
View File
@@ -0,0 +1,24 @@
module PostgREST.Plan.Types
( TypedField(..)
, resolveTableField
) where
import qualified Data.HashMap.Strict.InsOrd as HMI
import PostgREST.SchemaCache.Identifiers (FieldName)
import PostgREST.SchemaCache.Table (Column (..), Table (..))
import Protolude
-- | A TypedField is a field with sufficient information to be read from JSON with `json_to_recordset`.
data TypedField = TypedField
{ tfName :: FieldName
, tfIRType :: Text -- ^ The initial type of the field, before any casting.
} deriving (Eq)
resolveTableField :: Table -> FieldName -> Maybe TypedField
resolveTableField table fieldName =
case HMI.lookup fieldName (tableColumns table) of
Just column -> Just $ TypedField (colName column) (colNominalType column)
Nothing -> Nothing
+11 -12
View File
@@ -17,7 +17,6 @@ module PostgREST.Query.QueryBuilder
) where ) where
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import qualified Data.Set as S
import qualified Hasql.DynamicStatements.Snippet as SQL import qualified Hasql.DynamicStatements.Snippet as SQL
import Data.Tree (Tree (..)) import Data.Tree (Tree (..))
@@ -34,6 +33,7 @@ import PostgREST.ApiRequest.Types
import PostgREST.Plan.CallPlan import PostgREST.Plan.CallPlan
import PostgREST.Plan.MutatePlan import PostgREST.Plan.MutatePlan
import PostgREST.Plan.ReadPlan import PostgREST.Plan.ReadPlan
import PostgREST.Plan.Types
import PostgREST.Query.SqlFragment import PostgREST.Query.SqlFragment
import PostgREST.RangeQuery (allRange) import PostgREST.RangeQuery (allRange)
@@ -83,9 +83,8 @@ getSelectsJoins rr@(Node ReadPlan{select, relName, relToParent=Just rel, relAggA
mutatePlanToQuery :: MutatePlan -> SQL.Snippet mutatePlanToQuery :: MutatePlan -> SQL.Snippet
mutatePlanToQuery (Insert mainQi iCols body onConflct putConditions returnings _) = mutatePlanToQuery (Insert mainQi iCols body onConflct putConditions returnings _) =
"WITH " <> normalizedBody body <> " " <> "WITH " <> normalizedBody body <> " " <>
"INSERT INTO " <> SQL.sql (fromQi mainQi) <> SQL.sql (if S.null iCols then " " else "(" <> cols <> ") ") <> "INSERT INTO " <> SQL.sql (fromQi mainQi) <> SQL.sql (if null iCols then " " else "(" <> cols <> ") ") <>
"SELECT " <> SQL.sql cols <> " " <> pgFmtSelectFromJson iCols <>
SQL.sql ("FROM json_populate_recordset (null::" <> fromQi mainQi <> ", " <> selectBody <> ") _ ") <>
-- Only used for PUT -- Only used for PUT
(if null putConditions then mempty else "WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree (QualifiedIdentifier mempty "_") <$> putConditions)) <> (if null putConditions then mempty else "WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree (QualifiedIdentifier mempty "_") <$> putConditions)) <>
SQL.sql (BS.unwords [ SQL.sql (BS.unwords [
@@ -97,18 +96,18 @@ mutatePlanToQuery (Insert mainQi iCols body onConflct putConditions returnings _
IgnoreDuplicates -> IgnoreDuplicates ->
"DO NOTHING" "DO NOTHING"
MergeDuplicates -> MergeDuplicates ->
if S.null iCols if null iCols
then "DO NOTHING" then "DO NOTHING"
else "DO UPDATE SET " <> BS.intercalate ", " (pgFmtIdent <> const " = EXCLUDED." <> pgFmtIdent <$> S.toList iCols) else "DO UPDATE SET " <> BS.intercalate ", " ((pgFmtIdent . tfName) <> const " = EXCLUDED." <> (pgFmtIdent . tfName) <$> iCols)
) onConflct, ) onConflct,
returningF mainQi returnings returningF mainQi returnings
]) ])
where where
cols = BS.intercalate ", " $ pgFmtIdent <$> S.toList iCols cols = BS.intercalate ", " $ pgFmtIdent . tfName <$> iCols
-- An update without a limit is always filtered with a WHERE -- An update without a limit is always filtered with a WHERE
mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings) mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings)
| S.null uCols = | null uCols =
-- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax -- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax
-- selecting an empty resultset from mainQi gives us the column names to prevent errors when using &select= -- selecting an empty resultset from mainQi gives us the column names to prevent errors when using &select=
-- the select has to be based on "returnings" to make computed overloaded functions not throw -- the select has to be based on "returnings" to make computed overloaded functions not throw
@@ -117,13 +116,13 @@ mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings)
| range == allRange = | range == allRange =
"WITH " <> normalizedBody body <> " " <> "WITH " <> normalizedBody body <> " " <>
"UPDATE " <> mainTbl <> " SET " <> SQL.sql nonRangeCols <> " " <> "UPDATE " <> mainTbl <> " SET " <> SQL.sql nonRangeCols <> " " <>
"FROM (SELECT * FROM json_populate_recordset (null::" <> mainTbl <> " , " <> SQL.sql selectBody <> " )) _ " <> "FROM (" <> pgFmtSelectFromJson uCols <> ") AS _ " <>
whereLogic <> " " <> whereLogic <> " " <>
SQL.sql (returningF mainQi returnings) SQL.sql (returningF mainQi returnings)
| otherwise = | otherwise =
"WITH " <> normalizedBody body <> ", " <> "WITH " <> normalizedBody body <> ", " <>
"pgrst_update_body AS (SELECT * FROM json_populate_recordset (null::" <> mainTbl <> " , " <> SQL.sql selectBody <> " ) LIMIT 1), " <> "pgrst_update_body AS (" <> pgFmtSelectFromJson uCols <> " LIMIT 1), " <>
"pgrst_affected_rows AS (" <> "pgrst_affected_rows AS (" <>
"SELECT " <> SQL.sql rangeIdF <> " FROM " <> mainTbl <> "SELECT " <> SQL.sql rangeIdF <> " FROM " <> mainTbl <>
whereLogic <> " " <> whereLogic <> " " <>
@@ -139,8 +138,8 @@ mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings)
whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest) whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)
mainTbl = SQL.sql (fromQi mainQi) mainTbl = SQL.sql (fromQi mainQi)
emptyBodyReturnedColumns = if null returnings then "NULL" else BS.intercalate ", " (pgFmtColumn (QualifiedIdentifier mempty $ qiName mainQi) <$> returnings) emptyBodyReturnedColumns = if null returnings then "NULL" else BS.intercalate ", " (pgFmtColumn (QualifiedIdentifier mempty $ qiName mainQi) <$> returnings)
nonRangeCols = BS.intercalate ", " (pgFmtIdent <> const " = _." <> pgFmtIdent <$> S.toList uCols) nonRangeCols = BS.intercalate ", " (pgFmtIdent . tfName <> const " = _." <> pgFmtIdent . tfName <$> uCols)
rangeCols = BS.intercalate ", " ((\col -> pgFmtIdent col <> " = (SELECT " <> pgFmtIdent col <> " FROM pgrst_update_body) ") <$> S.toList uCols) rangeCols = BS.intercalate ", " ((\col -> pgFmtIdent (tfName col) <> " = (SELECT " <> pgFmtIdent (tfName col) <> " FROM pgrst_update_body) ") <$> uCols)
(whereRangeIdF, rangeIdF) = mutRangeF mainQi (fst . otTerm <$> ordts) (whereRangeIdF, rangeIdF) = mutRangeF mainQi (fst . otTerm <$> ordts)
mutatePlanToQuery (Delete mainQi logicForest range ordts returnings) mutatePlanToQuery (Delete mainQi logicForest range ordts returnings)
+18 -2
View File
@@ -30,6 +30,7 @@ module PostgREST.Query.SqlFragment
, pgFmtLogicTree , pgFmtLogicTree
, pgFmtOrderTerm , pgFmtOrderTerm
, pgFmtSelectItem , pgFmtSelectItem
, pgFmtSelectFromJson
, responseHeadersF , responseHeadersF
, responseStatusF , responseStatusF
, returningF , returningF
@@ -74,6 +75,7 @@ import PostgREST.ApiRequest.Types (Alias, Cast, Field,
import PostgREST.MediaType (MTPlanFormat (..), import PostgREST.MediaType (MTPlanFormat (..),
MTPlanOption (..)) MTPlanOption (..))
import PostgREST.Plan.ReadPlan (JoinCondition (..)) import PostgREST.Plan.ReadPlan (JoinCondition (..))
import PostgREST.Plan.Types (TypedField (..))
import PostgREST.RangeQuery (NonnegRange, allRange, import PostgREST.RangeQuery (NonnegRange, allRange,
rangeLimit, rangeOffset) rangeLimit, rangeOffset)
import PostgREST.SchemaCache.Identifiers (FieldName, import PostgREST.SchemaCache.Identifiers (FieldName,
@@ -120,8 +122,8 @@ ftsOperator = \case
FilterFtsWebsearch -> "@@ websearch_to_tsquery" FilterFtsWebsearch -> "@@ websearch_to_tsquery"
-- | -- |
-- These CTEs convert a json object into a json array, this way we can use json_populate_recordset for all json payloads -- These CTEs convert a json object into a json array, this way we can use json_to_recordset for all json payloads
-- Otherwise we'd have to use json_populate_record for json objects and json_populate_recordset for json arrays -- Otherwise we'd have to use json_to_record for json objects and json_to_recordset for json arrays
-- We do this in SQL to avoid processing the JSON in application code -- We do this in SQL to avoid processing the JSON in application code
-- TODO: At this stage there shouldn't be a Maybe since ApiRequest should ensure that an INSERT/UPDATE has a body -- TODO: At this stage there shouldn't be a Maybe since ApiRequest should ensure that an INSERT/UPDATE has a body
normalizedBody :: Maybe LBS.ByteString -> SQL.Snippet normalizedBody :: Maybe LBS.ByteString -> SQL.Snippet
@@ -242,6 +244,20 @@ pgFmtSelectItem table (f@(fName, jp), Nothing, alias) = pgFmtField table f <> SQ
-- Not quoting should be fine, we validate the input on Parsers. -- Not quoting should be fine, we validate the input on Parsers.
pgFmtSelectItem table (f@(fName, jp), Just cast, alias) = "CAST (" <> pgFmtField table f <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )" <> SQL.sql (pgFmtAs fName jp alias) pgFmtSelectItem table (f@(fName, jp), Just cast, alias) = "CAST (" <> pgFmtField table f <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )" <> SQL.sql (pgFmtAs fName jp alias)
pgFmtSelectFromJson :: [TypedField] -> SQL.Snippet
pgFmtSelectFromJson fields =
SQL.sql "SELECT " <> parsedCols <> " " <>
(if null fields
-- When we are inserting no columns (e.g. using default values), we can't use our ordinary `json_to_recordset`
-- because it can't extract records with no columns (there's no valid syntax for the `AS (colName colType,...)`
-- part). But we still need to ensure as many rows are created as there are array elements.
then SQL.sql ("FROM json_array_elements (" <> selectBody <> ") _ ")
else SQL.sql ("FROM json_to_recordset (" <> selectBody <> ") AS _ " <> "(" <> typedCols <> ") ")
)
where
parsedCols = SQL.sql $ BS.intercalate ", " $ pgFmtIdent . tfName <$> fields
typedCols = BS.intercalate ", " $ pgFmtIdent . tfName <> const " " <> encodeUtf8 . tfIRType <$> fields
pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SQL.Snippet pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SQL.Snippet
pgFmtOrderTerm qi ot = pgFmtOrderTerm qi ot =
fmtOTerm ot <> " " <> fmtOTerm ot <> " " <>
+6 -5
View File
@@ -34,7 +34,8 @@ import PostgREST.SchemaCache.Relationship (Cardinality (..),
Relationship (..), Relationship (..),
RelationshipsMap) RelationshipsMap)
import PostgREST.SchemaCache.Table (Column (..), Table (..), import PostgREST.SchemaCache.Table (Column (..), Table (..),
TablesMap) TablesMap,
tableColumnsList)
import PostgREST.Version (docsVersion, prettyVersion) import PostgREST.Version (docsVersion, prettyVersion)
import PostgREST.MediaType import PostgREST.MediaType
@@ -93,8 +94,8 @@ makeTableDef rels t =
(tn, (mempty :: Schema) (tn, (mempty :: Schema)
& description .~ tableDescription t & description .~ tableDescription t
& type_ ?~ SwaggerObject & type_ ?~ SwaggerObject
& properties .~ fromList (makeProperty t rels <$> tableColumns t) & properties .~ fromList (makeProperty t rels <$> tableColumnsList t)
& required .~ fmap colName (filter (not . colNullable) $ tableColumns t)) & required .~ fmap colName (filter (not . colNullable) $ tableColumnsList t))
makeProperty :: Table -> RelationshipsMap -> Column -> (Text, Referenced Schema) makeProperty :: Table -> RelationshipsMap -> Column -> (Text, Referenced Schema)
makeProperty tbl rels col = (colName col, Inline s) makeProperty tbl rels col = (colName col, Inline s)
@@ -238,7 +239,7 @@ makeParamDefs ti =
& in_ .~ ParamQuery & in_ .~ ParamQuery
& type_ ?~ SwaggerString)) & type_ ?~ SwaggerString))
] ]
<> concat [ makeObjectBody (tableName t) : makeRowFilters (tableName t) (tableColumns t) <> concat [ makeObjectBody (tableName t) : makeRowFilters (tableName t) (tableColumnsList t)
| t <- ti | t <- ti
] ]
@@ -299,7 +300,7 @@ makePathItem t = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpdatable t
p False = pr p False = pr
p True = pw p True = pw
tn = tableName t tn = tableName t
rs = [ T.intercalate "." ["rowFilter", tn, colName c ] | c <- tableColumns t ] rs = [ T.intercalate "." ["rowFilter", tn, colName c ] | c <- tableColumnsList t ]
ref = Ref . Reference ref = Ref . Reference
makeProcPathItem :: ProcDescription -> (FilePath, PathItem) makeProcPathItem :: ProcDescription -> (FilePath, PathItem)
+20 -12
View File
@@ -26,13 +26,14 @@ module PostgREST.SchemaCache
, schemaDescription , schemaDescription
) where ) where
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
import qualified Data.Set as S import qualified Data.HashMap.Strict.InsOrd as HMI
import qualified Hasql.Decoders as HD import qualified Data.Set as S
import qualified Hasql.Encoders as HE import qualified Hasql.Decoders as HD
import qualified Hasql.Statement as SQL import qualified Hasql.Encoders as HE
import qualified Hasql.Transaction as SQL import qualified Hasql.Statement as SQL
import qualified Hasql.Transaction as SQL
import Contravariant.Extras (contrazip2) import Contravariant.Extras (contrazip2)
import Text.InterpolatedString.Perl6 (q) import Text.InterpolatedString.Perl6 (q)
@@ -52,8 +53,8 @@ import PostgREST.SchemaCache.Relationship (Cardinality (..),
Junction (..), Junction (..),
Relationship (..), Relationship (..),
RelationshipsMap) RelationshipsMap)
import PostgREST.SchemaCache.Table (Column (..), Table (..), import PostgREST.SchemaCache.Table (Column (..), ColumnMap,
TablesMap) Table (..), TablesMap)
import Protolude import Protolude
@@ -178,15 +179,20 @@ decodeTables =
<*> column HD.bool <*> column HD.bool
<*> column HD.bool <*> column HD.bool
<*> arrayColumn HD.text <*> arrayColumn HD.text
<*> compositeArrayColumn <*> parseCols (compositeArrayColumn
(Column (Column
<$> compositeField HD.text <$> compositeField HD.text
<*> nullableCompositeField HD.text <*> nullableCompositeField HD.text
<*> compositeField HD.bool <*> compositeField HD.bool
<*> compositeField HD.text <*> compositeField HD.text
<*> compositeField HD.text
<*> nullableCompositeField HD.int4 <*> nullableCompositeField HD.int4
<*> nullableCompositeField HD.text <*> nullableCompositeField HD.text
<*> compositeFieldArray HD.text) <*> compositeFieldArray HD.text))
parseCols :: HD.Row [Column] -> HD.Row ColumnMap
parseCols = fmap (HMI.fromList . map (\col@Column{colName} -> (colName, col)))
decodeRels :: HD.Result [Relationship] decodeRels :: HD.Result [Relationship]
decodeRels = decodeRels =
@@ -521,6 +527,7 @@ tablesSqlQuery pgVer =
ELSE format_type(a.atttypid, a.atttypmod) ELSE format_type(a.atttypid, a.atttypmod)
END END
END::text AS data_type, END::text AS data_type,
t.oid AS data_type_id,
information_schema._pg_char_max_length( information_schema._pg_char_max_length(
information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypid(a.*, t.*),
information_schema._pg_truetypmod(a.*, t.*) information_schema._pg_truetypmod(a.*, t.*)
@@ -556,6 +563,7 @@ tablesSqlQuery pgVer =
info.description, info.description,
info.is_nullable::boolean, info.is_nullable::boolean,
info.data_type, info.data_type,
info.data_type_id::regtype::text,
info.character_maximum_length, info.character_maximum_length,
info.column_default, info.column_default,
coalesce(enum_info.vals, '{}')) order by info.position) as columns coalesce(enum_info.vals, '{}')) order by info.position) as columns
+15 -6
View File
@@ -1,14 +1,18 @@
{-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
module PostgREST.SchemaCache.Table module PostgREST.SchemaCache.Table
( Column(..) ( Column(..)
, Table(..) , Table(..)
, tableColumnsList
, TablesMap , TablesMap
, ColumnMap
) where ) where
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
import qualified Data.HashMap.Strict.InsOrd as HMI
import PostgREST.SchemaCache.Identifiers (FieldName, import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..), QualifiedIdentifier (..),
@@ -28,9 +32,12 @@ data Table = Table
, tableUpdatable :: Bool , tableUpdatable :: Bool
, tableDeletable :: Bool , tableDeletable :: Bool
, tablePKCols :: [FieldName] , tablePKCols :: [FieldName]
, tableColumns :: [Column] , tableColumns :: ColumnMap
} }
deriving (Show, Ord, Generic, JSON.ToJSON) deriving (Show, Generic, JSON.ToJSON)
tableColumnsList :: Table -> [Column]
tableColumnsList = HMI.elems . tableColumns
instance Eq Table where instance Eq Table where
Table{tableSchema=s1,tableName=n1} == Table{tableSchema=s2,tableName=n2} = s1 == s2 && n1 == n2 Table{tableSchema=s1,tableName=n1} == Table{tableSchema=s2,tableName=n2} = s1 == s2 && n1 == n2
@@ -40,6 +47,7 @@ data Column = Column
, colDescription :: Maybe Text , colDescription :: Maybe Text
, colNullable :: Bool , colNullable :: Bool
, colType :: Text , colType :: Text
, colNominalType :: Text
, colMaxLen :: Maybe Int32 , colMaxLen :: Maybe Int32
, colDefault :: Maybe Text , colDefault :: Maybe Text
, colEnum :: [Text] , colEnum :: [Text]
@@ -47,3 +55,4 @@ data Column = Column
deriving (Eq, Show, Ord, Generic, JSON.ToJSON) deriving (Eq, Show, Ord, Generic, JSON.ToJSON)
type TablesMap = HM.HashMap QualifiedIdentifier Table type TablesMap = HM.HashMap QualifiedIdentifier Table
type ColumnMap = HMI.InsOrdHashMap FieldName Column
+23 -1
View File
@@ -420,6 +420,28 @@ spec actualPgVersion = do
, matchHeaders = [] , matchHeaders = []
} }
it "disallows ?columns which don't exist" $
post "/articles?columns=helicopter"
[json|[
{"id": 204, "body": "yyy"},
{"id": 205, "body": "zzz"}]|]
`shouldRespondWith`
[json|{"code":"PGRST118","details":null,"hint":null,"message":"Column 'helicopter' of relation 'articles' does not exist"} |]
{ matchStatus = 400
, matchHeaders = []
}
it "returns missing table error even if also has invalid ?columns" $
post "/garlic?columns=helicopter"
[json|[
{"id": 204, "body": "yyy"},
{"id": 205, "body": "zzz"}]|]
`shouldRespondWith`
[json|{} |]
{ matchStatus = 404
, matchHeaders = []
}
it "disallows array elements that are not json objects" $ it "disallows array elements that are not json objects" $
post "/articles?columns=id,body" post "/articles?columns=id,body"
[json|[ [json|[
@@ -431,7 +453,7 @@ spec actualPgVersion = do
"code": "22023", "code": "22023",
"details": null, "details": null,
"hint": null, "hint": null,
"message": "argument of json_populate_recordset must be an array of objects"}|] "message": "argument of json_to_recordset must be an array of objects"}|]
{ matchStatus = 400 { matchStatus = 400
, matchHeaders = [] , matchHeaders = []
} }
+22
View File
@@ -308,6 +308,28 @@ spec = do
request methodPatch "/articles?id=eq.2001&columns=body" [("Prefer", "return=representation")] request methodPatch "/articles?id=eq.2001&columns=body" [("Prefer", "return=representation")]
[json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith` 200 [json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith` 200
it "disallows ?columns which don't exist" $ do
request methodPatch "/articles?id=eq.1&columns=helicopter"
[("Prefer", "return=representation")]
[json|{"body": "yyy"}|]
`shouldRespondWith`
[json|{"code":"PGRST118","details":null,"hint":null,"message":"Column 'helicopter' of relation 'articles' does not exist"} |]
{ matchStatus = 400
, matchHeaders = []
}
it "returns missing table error even if also has invalid ?columns" $ do
request methodPatch "/garlic?columns=helicopter"
[("Prefer", "return=representation")]
[json|[
{"id": 204, "body": "yyy"},
{"id": 205, "body": "zzz"}]|]
`shouldRespondWith`
[json|{} |]
{ matchStatus = 404
, matchHeaders = []
}
context "tables with self reference foreign keys" $ do context "tables with self reference foreign keys" $ do
it "embeds children after update" $ it "embeds children after update" $
request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name)" request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name)"