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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -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
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 <> " " <>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
Reference in New Issue
Block a user