* fix some typos and spelling * fix pg_source CTE name should be prefixed with pgrst_ * added tests for overloaded computed columns on patch calls
352 lines
17 KiB
Haskell
352 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 DuplicateRecordFields #-}
|
|
{-# LANGUAGE FlexibleContexts #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
|
|
module PostgREST.DbRequestBuilder (
|
|
readRequest
|
|
, mutateRequest
|
|
, returningCols
|
|
) where
|
|
|
|
import qualified Data.HashMap.Strict as M
|
|
import qualified Data.Set as S
|
|
|
|
import Control.Arrow ((***))
|
|
import Data.Either.Combinators (mapLeft)
|
|
import Data.Foldable (foldr1)
|
|
import Data.List (delete)
|
|
import Data.Text (isInfixOf)
|
|
|
|
import Control.Applicative
|
|
import Data.Tree
|
|
import Network.Wai
|
|
|
|
import PostgREST.ApiRequest (Action (..), ApiRequest (..))
|
|
import PostgREST.Error (ApiRequestError (..), errorResponseFor)
|
|
import PostgREST.Parsers
|
|
import PostgREST.RangeQuery (NonnegRange, allRange, restrictRange)
|
|
import PostgREST.Types
|
|
import Protolude hiding (from)
|
|
|
|
readRequest :: Schema -> TableName -> Maybe Integer -> [Relation] -> ApiRequest -> Either Response ReadRequest
|
|
readRequest schema rootTableName maxRows allRels apiRequest =
|
|
mapLeft errorResponseFor $
|
|
treeRestrictRange maxRows =<<
|
|
augmentRequestWithJoin schema rootRels =<<
|
|
addFiltersOrdersRanges apiRequest <*>
|
|
(initReadRequest rootName <$> pRequestSelect sel)
|
|
where
|
|
sel = fromMaybe "*" $ iSelect apiRequest -- default to all columns requested (SELECT *) for a non existent ?select querystring param
|
|
(rootName, rootRels) = rootWithRels schema rootTableName allRels (iAction apiRequest)
|
|
|
|
-- Get the root table name with its relationships according to the Action type.
|
|
-- This is done because of the shape of the final SQL Query. The mutation cases are wrapped in a WITH {sourceCTEName}(see Statements.hs).
|
|
-- So we need a FROM {sourceCTEName} instead of FROM {tableName}.
|
|
rootWithRels :: Schema -> TableName -> [Relation] -> Action -> (QualifiedIdentifier, [Relation])
|
|
rootWithRels schema rootTableName allRels action = case action of
|
|
ActionRead _ -> (QualifiedIdentifier schema rootTableName, allRels) -- normal read case
|
|
_ -> (QualifiedIdentifier mempty sourceCTEName, mapMaybe toSourceRel allRels ++ allRels) -- mutation cases and calling proc
|
|
where
|
|
-- To enable embedding in the sourceCTEName cases we need to replace the foreign key tableName in the Relation
|
|
-- with {sourceCTEName}. This way findRel can find relationships with sourceCTEName.
|
|
toSourceRel :: Relation -> Maybe Relation
|
|
toSourceRel r@Relation{relTable=t}
|
|
| rootTableName == tableName t = Just $ r {relTable=t {tableName=sourceCTEName}}
|
|
| otherwise = Nothing
|
|
|
|
-- Build the initial 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 http://github.com/PostgREST/postgrest/issues/987.
|
|
initReadRequest :: QualifiedIdentifier -> [Tree SelectItem] -> ReadRequest
|
|
initReadRequest rootQi =
|
|
foldr (treeEntry rootDepth) initial
|
|
where
|
|
rootDepth = 0
|
|
rootSchema = qiSchema rootQi
|
|
rootName = qiName rootQi
|
|
initial = Node (Select [] rootQi Nothing [] [] [] [] allRange, (rootName, Nothing, Nothing, Nothing, rootDepth)) []
|
|
treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
|
|
treeEntry depth (Node fld@((fn, _),_,alias, embedHint) 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 [] (QualifiedIdentifier rootSchema fn) Nothing [] [] [] [] allRange,
|
|
(fn, Nothing, alias, embedHint, nxtDepth)) [])
|
|
fldForest:rForest
|
|
|
|
treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest
|
|
treeRestrictRange maxRows request = pure $ nodeRestrictRange maxRows <$> request
|
|
where
|
|
nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode
|
|
nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)
|
|
|
|
augmentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either ApiRequestError ReadRequest
|
|
augmentRequestWithJoin schema allRels request =
|
|
addRels schema allRels Nothing request
|
|
>>= addJoinConditions Nothing
|
|
|
|
addRels :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
|
|
addRels schema allRels parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, hint, depth)) forest) =
|
|
case parentNode of
|
|
Just (Node (Select{from=parentNodeQi}, _) _) ->
|
|
let newFrom r = if qiName tbl == nodeName then tableQi (relFTable r) else tbl
|
|
newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, Nothing, depth))) <$> rel
|
|
rel = findRel schema allRels (qiName parentNodeQi) nodeName hint
|
|
in
|
|
Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest)
|
|
_ ->
|
|
let rn = (query, (nodeName, Nothing, alias, Nothing, depth)) in
|
|
Node rn <$> updateForest (Just $ Node rn forest)
|
|
where
|
|
updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]
|
|
updateForest rq = mapM (addRels schema allRels rq) forest
|
|
|
|
-- Finds a relationship between an origin and a target in the request: /origin?select=target(*)
|
|
-- If more than one relationship is found then the request is ambiguous and we return an error.
|
|
-- In that case the request can be disambiguated by adding precision to the target or by using a hint: /origin?select=target!hint(*)
|
|
-- The elements will be matched according to these rules:
|
|
-- origin = table / view
|
|
-- target = table / view / constraint / column-from-origin
|
|
-- hint = table / view / constraint / column-from-origin / column-from-target
|
|
-- (hint can take table / view values to aid in finding the junction in an m2m relationship)
|
|
findRel :: Schema -> [Relation] -> NodeName -> NodeName -> Maybe EmbedHint -> Either ApiRequestError Relation
|
|
findRel schema allRels origin target hint =
|
|
case rel of
|
|
[] -> Left $ NoRelBetween origin target
|
|
[r] -> Right r
|
|
rs ->
|
|
-- Return error if more than one relationship is found, unless we're in a self reference case.
|
|
--
|
|
-- Here we handle a self reference relationship to not cause a breaking change:
|
|
-- In a self reference we get two relationships with the same foreign key and relTable/relFtable but with different cardinalities(m2o/o2m)
|
|
-- We output the O2M rel, the M2O rel can be obtained by using the origin column as an embed hint.
|
|
let [rel0, rel1] = take 2 rs in
|
|
if length rs == 2 && relConstraint rel0 == relConstraint rel1 && relTable rel0 == relTable rel1 && relFTable rel0 == relFTable rel1
|
|
then note (NoRelBetween origin target) (find (\r -> relType r == O2M) rs)
|
|
else Left $ AmbiguousRelBetween origin target rs
|
|
where
|
|
matchFKSingleCol hint_ cols = length cols == 1 && hint_ == (colName <$> head cols)
|
|
rel = filter (
|
|
\Relation{relTable, relColumns, relConstraint, relFTable, relFColumns, relType, relJunction} ->
|
|
-- Both relationship ends need to be on the exposed schema
|
|
schema == tableSchema relTable && schema == tableSchema relFTable &&
|
|
(
|
|
-- /projects?select=clients(*)
|
|
origin == tableName relTable && -- projects
|
|
target == tableName relFTable || -- clients
|
|
|
|
-- /projects?select=projects_client_id_fkey(*)
|
|
(
|
|
origin == tableName relTable && -- projects
|
|
Just target == relConstraint -- projects_client_id_fkey
|
|
) ||
|
|
-- /projects?select=client_id(*)
|
|
(
|
|
origin == tableName relTable && -- projects
|
|
matchFKSingleCol (Just target) relColumns -- client_id
|
|
)
|
|
) && (
|
|
isNothing hint || -- hint is optional
|
|
|
|
-- /projects?select=clients!projects_client_id_fkey(*)
|
|
hint == relConstraint || -- projects_client_id_fkey
|
|
|
|
-- /projects?select=clients!client_id(*) or /projects?select=clients!id(*)
|
|
matchFKSingleCol hint relColumns || -- client_id
|
|
matchFKSingleCol hint relFColumns || -- id
|
|
|
|
-- /users?select=tasks!users_tasks(*)
|
|
(
|
|
relType == M2M && -- many-to-many between users and tasks
|
|
hint == (tableName . junTable <$> relJunction) -- users_tasks
|
|
)
|
|
)
|
|
) allRels
|
|
|
|
-- previousAlias is only used for the case of self joins
|
|
addJoinConditions :: Maybe Alias -> ReadRequest -> Either ApiRequestError ReadRequest
|
|
addJoinConditions previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_, rel, _, _, depth)) forest) =
|
|
case rel of
|
|
Just r@Relation{relType=O2M} -> Node (augmentQuery r, nodeProps) <$> updatedForest
|
|
Just r@Relation{relType=M2O} -> Node (augmentQuery r, nodeProps) <$> updatedForest
|
|
Just r@Relation{relType=M2M, relJunction=junction} ->
|
|
case junction of
|
|
Just Junction{junTable} ->
|
|
let rq = augmentQuery r in
|
|
Node (rq{implicitJoins=tableQi junTable:implicitJoins rq}, nodeProps) <$> updatedForest
|
|
Nothing ->
|
|
Left UnknownRelation
|
|
Nothing -> Node node <$> updatedForest
|
|
where
|
|
newAlias = case isSelfReference <$> rel of
|
|
Just True
|
|
| depth /= 0 -> Just (qiName tbl <> "_" <> show depth) -- root node doesn't get aliased
|
|
| otherwise -> Nothing
|
|
_ -> Nothing
|
|
augmentQuery r =
|
|
foldr
|
|
(\jc rq@Select{joinConditions=jcs} -> rq{joinConditions=jc:jcs})
|
|
query{fromAlias=newAlias}
|
|
(getJoinConditions previousAlias newAlias r)
|
|
updatedForest = mapM (addJoinConditions 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 jun) =
|
|
case typ of
|
|
O2M ->
|
|
zipWith (toJoinCondition tN ftN) cols fCols
|
|
M2O ->
|
|
zipWith (toJoinCondition tN ftN) cols fCols
|
|
M2M -> case jun of
|
|
Just (Junction jt _ jc1 _ jc2) ->
|
|
let jtn = tableName jt in
|
|
zipWith (toJoinCondition tN jtn) cols jc1 ++ zipWith (toJoinCondition ftN jtn) fCols jc2
|
|
Nothing -> []
|
|
where
|
|
toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition
|
|
toJoinCondition tb ftb c fc =
|
|
let qi1 = removeSourceCTESchema tSchema tb
|
|
qi2 = removeSourceCTESchema tSchema ftb in
|
|
JoinCondition (maybe qi1 (QualifiedIdentifier mempty) previousAlias, colName c)
|
|
(maybe qi2 (QualifiedIdentifier mempty) newAlias, colName fc)
|
|
|
|
-- On mutation and calling proc cases we wrap the target table in a WITH {sourceCTEName}
|
|
-- if this happens remove the schema `FROM "schema"."{sourceCTEName}"` and use only the
|
|
-- `FROM "{sourceCTEName}"`. If the schema remains the FROM would be invalid.
|
|
removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier
|
|
removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then mempty else schema) tbl
|
|
|
|
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 ApiRequestError 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 :: Schema -> TableName -> ApiRequest -> S.Set FieldName -> [FieldName] -> ReadRequest -> Either Response MutateRequest
|
|
mutateRequest schema tName apiRequest cols pkCols readReq = mapLeft errorResponseFor $
|
|
case action of
|
|
ActionCreate -> do
|
|
confCols <- case iOnConflict apiRequest of
|
|
Nothing -> pure pkCols
|
|
Just param -> pRequestOnConflict param
|
|
pure $ Insert qi cols ((,) <$> iPreferResolution apiRequest <*> Just confCols) [] returnings
|
|
ActionUpdate -> Update qi cols <$> 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 qi cols (Just (MergeDuplicates, pkCols)) <$> combinedLogic <*> pure returnings
|
|
else
|
|
Left InvalidFilters) =<< filters
|
|
ActionDelete -> Delete qi <$> combinedLogic <*> pure returnings
|
|
_ -> Left UnsupportedVerb
|
|
where
|
|
qi = QualifiedIdentifier schema tName
|
|
action = iAction apiRequest
|
|
returnings =
|
|
if iPreferRepresentation apiRequest == None
|
|
then []
|
|
else returningCols readReq
|
|
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)
|
|
|
|
returningCols :: ReadRequest -> [FieldName]
|
|
returningCols rr@(Node _ forest) = returnings
|
|
where
|
|
fldNames = fstFieldNames rr
|
|
-- Without fkCols, when a mutateRequest to /projects?select=name,clients(name) occurs, the RETURNING SQL part would be
|
|
-- `RETURNING name`(see QueryBuilder).
|
|
-- This would make the embedding fail because the following JOIN would need the "client_id" column from projects.
|
|
-- So this adds the foreign key columns to ensure the embedding succeeds, result would be `RETURNING name, client_id`.
|
|
-- This also works for the other relType's.
|
|
fkCols = concat $ mapMaybe (\case
|
|
Node (_, (_, Just Relation{relColumns=cols, relType=relTyp}, _, _, _)) _ -> case relTyp of
|
|
O2M -> Just cols
|
|
M2O -> Just cols
|
|
M2M -> Just cols
|
|
_ -> Nothing
|
|
) forest
|
|
-- However if the "client_id" is present, e.g. mutateRequest to /projects?select=client_id,name,clients(name)
|
|
-- we would get `RETURNING client_id, name, client_id` and then we would produce the "column reference \"client_id\" is ambiguous"
|
|
-- error from PostgreSQL. So we deduplicate with Set:
|
|
returnings = S.toList . S.fromList $ fldNames ++ (colName <$> fkCols)
|
|
|
|
-- 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
|