362 lines
17 KiB
Haskell
362 lines
17 KiB
Haskell
{-|
|
|
Module : PostgREST.DbRequestBuilder
|
|
Description : PostgREST database request builder
|
|
|
|
This module is in charge of building an intermediate representation(ReadRequest, MutateRequest) between the HTTP request and the final resulting SQL query.
|
|
|
|
A query tree is built in case of resource embedding. By inferring the relationship between tables, join conditions are added for every embedded resource.
|
|
-}
|
|
{-# LANGUAGE FlexibleContexts #-}
|
|
{-# LANGUAGE DuplicateRecordFields#-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE MultiWayIf #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
module PostgREST.DbRequestBuilder (
|
|
readRequest
|
|
, mutateRequest
|
|
, fieldNames
|
|
) where
|
|
|
|
import Control.Applicative
|
|
import Control.Arrow ((***))
|
|
import Control.Lens.Getter (view)
|
|
import Control.Lens.Tuple (_1)
|
|
import qualified Data.ByteString.Char8 as BS
|
|
import Data.List (delete)
|
|
import Data.Maybe (fromJust)
|
|
import qualified Data.Set as S
|
|
import Data.Text (isInfixOf)
|
|
import Data.Tree
|
|
import Data.Either.Combinators (mapLeft)
|
|
|
|
import Network.Wai
|
|
|
|
import Data.Foldable (foldr1)
|
|
import qualified Data.HashMap.Strict as M
|
|
|
|
import PostgREST.ApiRequest ( ApiRequest(..)
|
|
, PreferRepresentation(..)
|
|
, Action(..), Target(..)
|
|
, PreferRepresentation (..)
|
|
)
|
|
import PostgREST.Error (apiRequestError)
|
|
import PostgREST.Parsers
|
|
import PostgREST.RangeQuery (NonnegRange, restrictRange, allRange)
|
|
import PostgREST.Types
|
|
|
|
import Protolude hiding (from)
|
|
import Text.Regex.TDFA ((=~))
|
|
import Unsafe (unsafeHead)
|
|
|
|
readRequest :: Maybe Integer -> [Relation] -> Maybe ProcDescription -> ApiRequest -> Either Response ReadRequest
|
|
readRequest maxRows allRels proc apiRequest =
|
|
mapLeft apiRequestError $
|
|
treeRestrictRange maxRows =<<
|
|
augumentRequestWithJoin schema relations =<<
|
|
addFiltersOrdersRanges apiRequest <*>
|
|
(buildReadRequest <$> pRequestSelect (iSelect apiRequest))
|
|
where
|
|
action = iAction apiRequest
|
|
(schema, rootTableName) = fromJust $ -- Make it safe
|
|
let target = iTarget apiRequest in
|
|
case target of
|
|
(TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t)
|
|
(TargetProc (QualifiedIdentifier s pName) ) -> Just (s, tName)
|
|
where
|
|
tName = case pdReturnType <$> proc of
|
|
Just (SetOf (Composite qi)) -> qiName qi
|
|
Just (Single (Composite qi)) -> qiName qi
|
|
_ -> pName
|
|
|
|
_ -> Nothing
|
|
|
|
-- Build tree with a Depth attribute so when a self join occurs we can differentiate the parent and child tables by having
|
|
-- an alias like "table_depth", this is related to issue #987.
|
|
buildReadRequest :: [Tree SelectItem] -> ReadRequest
|
|
buildReadRequest fieldTree =
|
|
let rootDepth = 0
|
|
rootNodeName = if action == ActionRead then rootTableName else sourceCTEName in
|
|
foldr (treeEntry rootDepth) (Node (Select [] rootNodeName Nothing [] [] [] [] allRange, (rootNodeName, Nothing, Nothing, Nothing, rootDepth)) []) fieldTree
|
|
where
|
|
treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
|
|
treeEntry depth (Node fld@((fn, _),_,alias,relationDetail) fldForest) (Node (q, i) rForest) =
|
|
let nxtDepth = succ depth in
|
|
case fldForest of
|
|
[] -> Node (q {select=fld:select q}, i) rForest
|
|
_ -> Node (q, i) $
|
|
foldr (treeEntry nxtDepth) (Node (Select [] fn Nothing [] [] [] [] allRange, (fn, Nothing, alias, relationDetail, nxtDepth)) []) fldForest:rForest
|
|
|
|
relations :: [Relation]
|
|
relations = case action of
|
|
ActionCreate -> fakeSourceRelations ++ allRels
|
|
ActionUpdate -> fakeSourceRelations ++ allRels
|
|
ActionDelete -> fakeSourceRelations ++ allRels
|
|
ActionInvoke _ -> fakeSourceRelations ++ allRels
|
|
_ -> allRels
|
|
where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels
|
|
|
|
-- in a relation where one of the tables matches "TableName"
|
|
-- replace the name to that table with pg_source
|
|
-- this "fake" relations is needed so that in a mutate query
|
|
-- we can look at the "returning *" part which is wrapped with a "with"
|
|
-- as just another table that has relations with other tables
|
|
toSourceRelation :: TableName -> Relation -> Maybe Relation
|
|
toSourceRelation mt r@(Relation t _ ft _ _ rt _ _)
|
|
| mt == tableName t = Just $ r {relTable=t {tableName=sourceCTEName}}
|
|
| mt == tableName ft = Just $ r {relFTable=t {tableName=sourceCTEName}}
|
|
| Just mt == (tableName <$> rt) = Just $ r {relLinkTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}
|
|
| otherwise = Nothing
|
|
|
|
treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest
|
|
treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` request
|
|
where
|
|
nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode
|
|
nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)
|
|
|
|
augumentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either ApiRequestError ReadRequest
|
|
augumentRequestWithJoin schema allRels request =
|
|
addRelations schema allRels Nothing request
|
|
>>= addJoinConditions schema Nothing
|
|
|
|
addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
|
|
addRelations schema allRelations parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, relationDetail, depth)) forest) =
|
|
case parentNode of
|
|
Just (Node (Select{from=parentNodeTable}, _) _) ->
|
|
let newFrom r = if tbl == nodeName then tableName (relTable r) else tbl
|
|
newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, Nothing, depth))) <$> rel
|
|
rel :: Either ApiRequestError Relation
|
|
rel = note (NoRelationBetween parentNodeTable nodeName) $
|
|
findRelation schema allRelations nodeName parentNodeTable relationDetail in
|
|
Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest)
|
|
_ ->
|
|
let rn = (query, (nodeName, Just r, alias, Nothing, depth))
|
|
r = Relation t [] t [] Root Nothing Nothing Nothing
|
|
t = Table schema nodeName Nothing True in -- !!! TODO find another way to get the table from the query
|
|
Node rn <$> updateForest (Just $ Node rn forest)
|
|
where
|
|
updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]
|
|
updateForest rq = mapM (addRelations schema allRelations rq) forest
|
|
|
|
findRelation :: Schema -> [Relation] -> NodeName -> TableName -> Maybe RelationDetail -> Maybe Relation
|
|
findRelation schema allRelations nodeTableName parentNodeTableName relationDetail =
|
|
find (\Relation{relTable, relColumns, relFTable, relFColumns, relType, relLinkTable} ->
|
|
-- Both relation ends need to be on the exposed schema
|
|
schema == tableSchema relTable && schema == tableSchema relFTable &&
|
|
case relationDetail of
|
|
Nothing ->
|
|
|
|
-- (request) => projects { ..., clients{...} }
|
|
-- will match
|
|
-- (relation type) => parent
|
|
-- (entity) => clients {id}
|
|
-- (foriegn entity) => projects {client_id}
|
|
(
|
|
nodeTableName == tableName relTable && -- match relation table name
|
|
parentNodeTableName == tableName relFTable -- match relation foreign table name
|
|
) ||
|
|
|
|
-- (request) => projects { ..., client_id{...} }
|
|
-- will match
|
|
-- (relation type) => parent
|
|
-- (entity) => clients {id}
|
|
-- (foriegn entity) => projects {client_id}
|
|
(
|
|
parentNodeTableName == tableName relFTable &&
|
|
length relFColumns == 1 &&
|
|
-- match common foreign key names(table_name_id, table_name_fk) to table_name
|
|
(toS ("^" <> colName (unsafeHead relFColumns) <> "_?(?:|[iI][dD]|[fF][kK])$") :: BS.ByteString) =~ (toS nodeTableName :: BS.ByteString)
|
|
)
|
|
|
|
-- (request) => project_id { ..., client_id{...} }
|
|
-- will match
|
|
-- (relation type) => parent
|
|
-- (entity) => clients {id}
|
|
-- (foriegn entity) => projects {client_id}
|
|
-- this case works becasue before reaching this place
|
|
-- addRelation will turn project_id to project so the above condition will match
|
|
|
|
Just rd ->
|
|
|
|
-- (request) => clients { ..., projects.client_id{...} }
|
|
-- will match
|
|
-- (relation type) => child
|
|
-- (entity) => clients {id}
|
|
-- (foriegn entity) => projects {client_id}
|
|
(
|
|
relType == Child &&
|
|
nodeTableName == tableName relTable && -- match relation table name
|
|
parentNodeTableName == tableName relFTable && -- match relation foreign table name
|
|
length relColumns == 1 &&
|
|
rd == colName (unsafeHead relColumns)
|
|
) ||
|
|
|
|
-- (request) => message { ..., person_detail.sender{...} }
|
|
-- will match
|
|
-- (relation type) => parent
|
|
-- (entity) => message {sender}
|
|
-- (foriegn entity) => person_detail {id}
|
|
(
|
|
relType == Parent &&
|
|
nodeTableName == tableName relTable && -- match relation table name
|
|
parentNodeTableName == tableName relFTable && -- match relation foreign table name
|
|
length relFColumns == 1 &&
|
|
rd == colName (unsafeHead relFColumns)
|
|
) ||
|
|
|
|
-- (request) => tasks { ..., users.tasks_users{...} }
|
|
-- will match
|
|
-- (relation type) => many
|
|
-- (entity) => users
|
|
-- (foriegn entity) => tasks
|
|
(
|
|
relType == Many &&
|
|
nodeTableName == tableName relTable && -- match relation table name
|
|
parentNodeTableName == tableName relFTable && -- match relation foreign table name
|
|
rd == tableName (fromJust relLinkTable)
|
|
)
|
|
) allRelations
|
|
|
|
-- previousAlias is only used for the case of self joins
|
|
addJoinConditions :: Schema -> Maybe Alias -> ReadRequest -> Either ApiRequestError ReadRequest
|
|
addJoinConditions schema previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_, relation, _, _, depth)) forest) =
|
|
case relation of
|
|
Just Relation{relType=Root} -> Node node <$> updatedForest -- this is the root node
|
|
Just rel@Relation{relType=Parent} -> Node (augmentQuery rel, nodeProps) <$> updatedForest
|
|
Just rel@Relation{relType=Child} -> Node (augmentQuery rel, nodeProps) <$> updatedForest
|
|
Just rel@Relation{relType=Many, relLinkTable=(Just linkTable)} ->
|
|
let rq = augmentQuery rel in
|
|
Node (rq{implicitJoins=tableName linkTable:implicitJoins rq}, nodeProps) <$> updatedForest
|
|
_ -> Left UnknownRelation
|
|
where
|
|
newAlias = case isSelfJoin <$> relation of
|
|
Just True
|
|
| depth /= 0 -> Just (tbl <> "_" <> show depth) -- root node doesn't get aliased
|
|
| otherwise -> Nothing
|
|
_ -> Nothing
|
|
augmentQuery rel =
|
|
foldr
|
|
(\jc rq@Select{joinConditions=jcs} -> rq{joinConditions=jc:jcs})
|
|
query{fromAlias=newAlias}
|
|
(getJoinConditions previousAlias newAlias rel)
|
|
updatedForest = mapM (addJoinConditions schema newAlias) forest
|
|
|
|
-- previousAlias and newAlias are used in the case of self joins
|
|
getJoinConditions :: Maybe Alias -> Maybe Alias -> Relation -> [JoinCondition]
|
|
getJoinConditions previousAlias newAlias (Relation Table{tableSchema=tSchema, tableName=tN} cols Table{tableName=ftN} fCols typ lt lc1 lc2) =
|
|
if | typ == Child || typ == Parent ->
|
|
zipWith (toJoinCondition tN ftN) cols fCols
|
|
| typ == Many ->
|
|
let ltN = maybe "" tableName lt in
|
|
zipWith (toJoinCondition tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toJoinCondition ftN ltN) fCols (fromMaybe [] lc2)
|
|
| typ == Root -> witness
|
|
where
|
|
toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition
|
|
toJoinCondition tb ftb c fc =
|
|
let qi1 = QualifiedIdentifier tSchema tb
|
|
qi2 = QualifiedIdentifier tSchema ftb in
|
|
JoinCondition (maybe qi1 (QualifiedIdentifier mempty) newAlias, colName c)
|
|
(maybe qi2 (QualifiedIdentifier mempty) previousAlias, colName fc)
|
|
|
|
addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> ReadRequest)
|
|
addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
|
|
flip (foldr addFilter) <$> filters,
|
|
flip (foldr addOrder) <$> orders,
|
|
flip (foldr addRange) <$> ranges,
|
|
flip (foldr addLogicTree) <$> logicForest
|
|
]
|
|
{-
|
|
The esence of what is going on above is that we are composing tree functions
|
|
of type (ReadRequest->ReadRequest) that are in (Either ParseError a) context
|
|
-}
|
|
where
|
|
filters :: Either ApiRequestError [(EmbedPath, Filter)]
|
|
filters = mapM pRequestFilter flts
|
|
logicForest :: Either ApiRequestError [(EmbedPath, LogicTree)]
|
|
logicForest = mapM pRequestLogicTree logFrst
|
|
action = iAction apiRequest
|
|
-- there can be no filters on the root table when we are doing insert/update/delete
|
|
(flts, logFrst) =
|
|
case action of
|
|
ActionInvoke _ -> (iFilters apiRequest, iLogic apiRequest)
|
|
ActionRead -> (iFilters apiRequest, iLogic apiRequest)
|
|
_ -> join (***) (filter (( "." `isInfixOf` ) . fst)) (iFilters apiRequest, iLogic apiRequest)
|
|
orders :: Either ApiRequestError [(EmbedPath, [OrderTerm])]
|
|
orders = mapM pRequestOrder $ iOrder apiRequest
|
|
ranges :: Either ApiRequestError [(EmbedPath, NonnegRange)]
|
|
ranges = mapM pRequestRange $ M.toList $ iRange apiRequest
|
|
|
|
addFilterToNode :: Filter -> ReadRequest -> ReadRequest
|
|
addFilterToNode flt (Node (q@Select {where_=lf}, i) f) = Node (q{where_=addFilterToLogicForest flt lf}::ReadQuery, i) f
|
|
|
|
addFilter :: (EmbedPath, Filter) -> ReadRequest -> ReadRequest
|
|
addFilter = addProperty addFilterToNode
|
|
|
|
addOrderToNode :: [OrderTerm] -> ReadRequest -> ReadRequest
|
|
addOrderToNode o (Node (q,i) f) = Node (q{order=o}, i) f
|
|
|
|
addOrder :: (EmbedPath, [OrderTerm]) -> ReadRequest -> ReadRequest
|
|
addOrder = addProperty addOrderToNode
|
|
|
|
addRangeToNode :: NonnegRange -> ReadRequest -> ReadRequest
|
|
addRangeToNode r (Node (q,i) f) = Node (q{range_=r}, i) f
|
|
|
|
addRange :: (EmbedPath, NonnegRange) -> ReadRequest -> ReadRequest
|
|
addRange = addProperty addRangeToNode
|
|
|
|
addLogicTreeToNode :: LogicTree -> ReadRequest -> ReadRequest
|
|
addLogicTreeToNode t (Node (q@Select{where_=lf},i) f) = Node (q{where_=t:lf}::ReadQuery, i) f
|
|
|
|
addLogicTree :: (EmbedPath, LogicTree) -> ReadRequest -> ReadRequest
|
|
addLogicTree = addProperty addLogicTreeToNode
|
|
|
|
addProperty :: (a -> ReadRequest -> ReadRequest) -> (EmbedPath, a) -> ReadRequest -> ReadRequest
|
|
addProperty f ([], a) rr = f a rr
|
|
addProperty f (targetNodeName:remainingPath, a) (Node rn forest) =
|
|
case pathNode of
|
|
Nothing -> Node rn forest -- the property is silenty dropped in the Request does not contain the required path
|
|
Just tn -> Node rn (addProperty f (remainingPath, a) tn:delete tn forest)
|
|
where
|
|
pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest
|
|
|
|
mutateRequest :: ApiRequest -> TableName -> [Text] -> [FieldName] -> Either Response MutateRequest
|
|
mutateRequest apiRequest tName pkCols fldNames = mapLeft apiRequestError $
|
|
case action of
|
|
ActionCreate -> Right $ Insert tName (pjKeys payload) ((,) <$> iPreferResolution apiRequest <*> Just pkCols) [] returnings
|
|
ActionUpdate -> Update tName (pjKeys payload) <$> combinedLogic <*> pure returnings
|
|
ActionSingleUpsert ->
|
|
(\flts ->
|
|
if null (iLogic apiRequest) &&
|
|
S.fromList (fst <$> iFilters apiRequest) == S.fromList pkCols &&
|
|
not (null (S.fromList pkCols)) &&
|
|
all (\case
|
|
Filter _ (OpExpr False (Op "eq" _)) -> True
|
|
_ -> False) flts
|
|
then Insert tName (pjKeys payload) (Just (MergeDuplicates, pkCols)) <$> combinedLogic <*> pure returnings
|
|
else
|
|
Left InvalidFilters) =<< filters
|
|
ActionDelete -> Delete tName <$> combinedLogic <*> pure returnings
|
|
_ -> Left UnsupportedVerb
|
|
where
|
|
action = iAction apiRequest
|
|
payload = fromJust $ iPayload apiRequest
|
|
returnings = if iPreferRepresentation apiRequest == None then [] else fldNames
|
|
filters = map snd <$> mapM pRequestFilter mutateFilters
|
|
logic = map snd <$> mapM pRequestLogicTree logicFilters
|
|
combinedLogic = foldr addFilterToLogicForest <$> logic <*> filters
|
|
-- update/delete filters can be only on the root table
|
|
(mutateFilters, logicFilters) = join (***) onlyRoot (iFilters apiRequest, iLogic apiRequest)
|
|
onlyRoot = filter (not . ( "." `isInfixOf` ) . fst)
|
|
|
|
fieldNames :: ReadRequest -> [FieldName]
|
|
fieldNames (Node (sel, _) forest) =
|
|
map (fst . view _1) (select sel) ++ map colName fks
|
|
where
|
|
fks = concatMap (fromMaybe [] . f) forest
|
|
f (Node (_, (_, Just Relation{relFColumns=cols, relType=Parent}, _, _, _)) _) = Just cols
|
|
f _ = Nothing
|
|
|
|
-- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree
|
|
-- they are later concatenated with AND in the QueryBuilder
|
|
addFilterToLogicForest :: Filter -> [LogicTree] -> [LogicTree]
|
|
addFilterToLogicForest flt lf = Stmnt flt : lf
|