Skip to content

Commit 741908d

Browse files
committed
add: partial document update using Pgrst Patch
Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
1 parent fa09e4a commit 741908d

File tree

15 files changed

+266
-21
lines changed

15 files changed

+266
-21
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
1212
+ The exposed schemas are now listed in the `hint` instead of the `message` field.
1313
- Improve error details of `PGRST301` error by @taimoorzaeem in #4051
1414
- Bounded JWT cache using the SIEVE algorithm by @mkleczek in #4084
15+
- Add partial document update using PGRST Patch by @taimoorzaeem in #3166
1516

1617
### Fixed
1718

postgrest.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ test-suite spec
239239
Feature.Query.JsonOperatorSpec
240240
Feature.Query.MultipleSchemaSpec
241241
Feature.Query.NullsStripSpec
242+
Feature.Query.PgrstPatchSpec
242243
Feature.Query.PgSafeUpdateSpec
243244
Feature.Query.PlanSpec
244245
Feature.Query.PostGISSpec

src/PostgREST/ApiRequest.hs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ userApiRequest :: AppConfig -> Preferences.Preferences -> Request -> RequestBody
8080
userApiRequest conf prefs req reqBody = do
8181
resource <- getResource conf $ pathInfo req
8282
(schema, negotiatedByProfile) <- getSchema conf hdrs method
83-
act <- getAction resource schema method
83+
act <- getAction resource schema method contentMediaType
8484
qPrms <- first QueryParamError $ QueryParams.parse (actIsInvokeSafe act) $ rawQueryString req
8585
(topLevelRange, ranges) <- getRanges method qPrms hdrs
8686
(payload, columns) <- getPayload reqBody contentMediaType qPrms act
@@ -126,8 +126,8 @@ getResource AppConfig{configOpenApiMode, configDbRootSpec} = \case
126126
["rpc", pName] -> Right $ ResourceRoutine pName
127127
_ -> Left InvalidResourcePath
128128

129-
getAction :: Resource -> Schema -> ByteString -> Either ApiRequestError Action
130-
getAction resource schema method =
129+
getAction :: Resource -> Schema -> ByteString -> MediaType -> Either ApiRequestError Action
130+
getAction resource schema method mediaType =
131131
case (resource, method) of
132132
(ResourceRoutine rout, "HEAD") -> Right . ActDb $ ActRoutine (qi rout) $ InvRead True
133133
(ResourceRoutine rout, "GET") -> Right . ActDb $ ActRoutine (qi rout) $ InvRead False
@@ -139,9 +139,11 @@ getAction resource schema method =
139139
(ResourceRelation rel, "GET") -> Right . ActDb $ ActRelationRead (qi rel) False
140140
(ResourceRelation rel, "POST") -> Right . ActDb $ ActRelationMut (qi rel) MutationCreate
141141
(ResourceRelation rel, "PUT") -> Right . ActDb $ ActRelationMut (qi rel) MutationSingleUpsert
142-
(ResourceRelation rel, "PATCH") -> Right . ActDb $ ActRelationMut (qi rel) MutationUpdate
143142
(ResourceRelation rel, "DELETE") -> Right . ActDb $ ActRelationMut (qi rel) MutationDelete
144143
(ResourceRelation rel, "OPTIONS") -> Right $ ActRelationInfo (qi rel)
144+
(ResourceRelation rel, "PATCH") -> case mediaType of -- do pgrst patch with vendored pgrst patch content-type
145+
MTVndPgrstPatch -> Right . ActDb $ ActRelationMut (qi rel) MutationPgrstPatch
146+
_ -> Right . ActDb $ ActRelationMut (qi rel) MutationUpdate
145147

146148
(ResourceSchema, "HEAD") -> Right . ActDb $ ActSchemaRead schema True
147149
(ResourceSchema, "GET") -> Right . ActDb $ ActSchemaRead schema False

src/PostgREST/ApiRequest/Payload.hs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
--
77
{-# LANGUAGE LambdaCase #-}
88
{-# LANGUAGE NamedFieldPuns #-}
9+
{-# OPTIONS_GHC -fno-warn-orphans #-}
910
module PostgREST.ApiRequest.Payload
1011
( getPayload
1112
) where
@@ -18,13 +19,16 @@ import qualified Data.ByteString.Lazy as LBS
1819
import qualified Data.Csv as CSV
1920
import qualified Data.HashMap.Strict as HM
2021
import qualified Data.Map.Strict as M
22+
import qualified Data.Scientific as Sci
2123
import qualified Data.Set as S
2224
import qualified Data.Text.Encoding as T
2325
import qualified Data.Vector as V
2426

2527
import Control.Arrow ((***))
28+
import Control.Monad (fail)
29+
import Data.Aeson ((.:))
2630
import Data.Aeson.Types (emptyArray, emptyObject)
27-
import Data.Either.Combinators (mapBoth)
31+
import Data.Either.Combinators (mapBoth, mapLeft)
2832
import Network.HTTP.Types.URI (parseSimpleQuery)
2933

3034
import PostgREST.ApiRequest.QueryParams (QueryParams (..))
@@ -44,6 +48,7 @@ getPayload reqBody contentMediaType QueryParams{qsColumns} action = do
4448
(Just ProcessedJSON{payKeys}, _) -> payKeys
4549
(Just ProcessedUrlEncoded{payKeys}, _) -> payKeys
4650
(Just RawJSON{}, Just cls) -> cls
51+
(Just PgrstPatchPay{}, Just cls) -> cls
4752
_ -> S.empty
4853
return (checkedPayload, cols)
4954
where
@@ -69,8 +74,12 @@ getPayload reqBody contentMediaType QueryParams{qsColumns} action = do
6974
(MTTextPlain, True) -> Right $ RawPay reqBody
7075
(MTTextXML, True) -> Right $ RawPay reqBody
7176
(MTOctetStream, True) -> Right $ RawPay reqBody
77+
(MTVndPgrstPatch, False) -> PgrstPatchPay <$> parsePgrstPatch reqBody
7278
(ct, _) -> Left $ "Content-Type not acceptable: " <> MediaType.toMime ct
7379

80+
parsePgrstPatch :: LBS.ByteString -> Either ByteString [PgrstPatchOp]
81+
parsePgrstPatch = mapLeft BS.pack . JSON.eitherDecode
82+
7483
shouldParsePayload = case action of
7584
ActDb (ActRelationMut _ MutationDelete) -> False
7685
ActDb (ActRelationMut _ _) -> True
@@ -88,6 +97,7 @@ getPayload reqBody contentMediaType QueryParams{qsColumns} action = do
8897
_ -> False
8998
params = (T.decodeUtf8 *** T.decodeUtf8) <$> parseSimpleQuery (LBS.toStrict reqBody)
9099

100+
91101
type CsvData = V.Vector (M.Map Text LBS.ByteString)
92102

93103
{-|
@@ -136,3 +146,32 @@ payloadAttributes raw json =
136146
_ -> Just emptyPJArray
137147
where
138148
emptyPJArray = ProcessedJSON (JSON.encode emptyArray) S.empty
149+
150+
151+
instance JSON.FromJSON PgrstPatchOp where
152+
parseJSON (JSON.Object o) = do
153+
op <- parseString o "op"
154+
path <- parseString o "path"
155+
-- TODO: We need to decide what JSON "value"s are allowed in our
156+
-- our Pgrst Patch implementation.
157+
-- For now, we only have incr operator, so it's number only
158+
case op of
159+
"incr" -> Incr path <$> parseNumber o "value"
160+
_ -> fail $ "Unknown Pgrst Patch operation " ++ show op
161+
where
162+
parseString obj key = do
163+
val <- obj .: key
164+
case val of
165+
JSON.String txt -> pure txt
166+
_ -> fail $ "Expected JSON string for " ++ show key
167+
168+
parseNumber obj key = do
169+
val <- obj .: key
170+
case val of
171+
JSON.Number num -> pure $ sciToInt num
172+
_ -> fail $ "Expected JSON number for " ++ show key
173+
where
174+
sciToInt :: Sci.Scientific -> Int
175+
sciToInt = fromMaybe 0 . Sci.toBoundedInteger
176+
177+
parseJSON _ = mzero

src/PostgREST/ApiRequest/Types.hs

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
{-# LANGUAGE DuplicateRecordFields #-}
22
module PostgREST.ApiRequest.Types
33
( AggregateFunction(..)
4+
, Action (..)
45
, Alias
56
, Cast
7+
, DbAction (..)
68
, Depth
79
, EmbedParam(..)
810
, EmbedPath
911
, Field
1012
, Filter(..)
13+
, FtsOperator(..)
1114
, Hint
15+
, InvokeMethod (..)
16+
, IsVal(..)
1217
, JoinType(..)
1318
, JsonOperand(..)
1419
, JsonOperation(..)
@@ -17,26 +22,22 @@ module PostgREST.ApiRequest.Types
1722
, ListVal
1823
, LogicOperator(..)
1924
, LogicTree(..)
25+
, Mutation (..)
2026
, NodeName
2127
, OpExpr(..)
22-
, Operation (..)
2328
, OpQuantifier(..)
29+
, Operation (..)
2430
, OrderDirection(..)
2531
, OrderNulls(..)
2632
, OrderTerm(..)
27-
, SingleVal
28-
, IsVal(..)
29-
, SimpleOperator(..)
30-
, QuantOperator(..)
31-
, FtsOperator(..)
32-
, SelectItem(..)
3333
, Payload (..)
34-
, InvokeMethod (..)
35-
, Mutation (..)
36-
, Resource (..)
37-
, DbAction (..)
38-
, Action (..)
34+
, PgrstPatchOp (..)
35+
, QuantOperator(..)
3936
, RequestBody
37+
, Resource (..)
38+
, SelectItem(..)
39+
, SimpleOperator(..)
40+
, SingleVal
4041
) where
4142

4243
import qualified Data.ByteString.Lazy as LBS
@@ -56,6 +57,7 @@ data Mutation
5657
| MutationDelete
5758
| MutationSingleUpsert
5859
| MutationUpdate
60+
| MutationPgrstPatch
5961
deriving Eq
6062

6163
data Resource
@@ -88,10 +90,15 @@ data Payload
8890
-- ^ Keys of the object or if it's an array these keys are guaranteed to
8991
-- be the same across all its objects
9092
}
91-
| ProcessedUrlEncoded { payArray :: [(Text, Text)], payKeys :: S.Set Text }
92-
| RawJSON { payRaw :: LBS.ByteString }
93-
| RawPay { payRaw :: LBS.ByteString }
93+
| ProcessedUrlEncoded { payArray :: [(Text, Text)], payKeys :: S.Set Text }
94+
| RawJSON { payRaw :: LBS.ByteString }
95+
| RawPay { payRaw :: LBS.ByteString }
96+
| PgrstPatchPay { payPgrstPatch :: [PgrstPatchOp] } -- ^ PgrstPatchPay is a list of patch updates
97+
9498

99+
data PgrstPatchOp
100+
= Incr FieldName Int
101+
-- We can add more operations in the future
95102

96103
-- | The value in `/tbl?select=alias:field.aggregateFunction()::cast`
97104
data SelectItem

src/PostgREST/MediaType.hs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ data MediaType
3737
-- vendored media types
3838
| MTVndArrayJSONStrip
3939
| MTVndSingularJSON Bool
40+
| MTVndPgrstPatch
4041
-- TODO MTVndPlan should only have its options as [Text]. Its ResultAggregate should have the typed attributes.
4142
| MTVndPlan MediaType MTVndPlanFormat [MTVndPlanOption]
4243
deriving (Eq, Show, Generic, JSON.ToJSON)
@@ -72,6 +73,7 @@ toMime MTTextXML = "text/xml"
7273
toMime MTOpenAPI = "application/openapi+json"
7374
toMime (MTVndSingularJSON True) = "application/vnd.pgrst.object+json;nulls=stripped"
7475
toMime (MTVndSingularJSON False) = "application/vnd.pgrst.object+json"
76+
toMime MTVndPgrstPatch = "application/vnd.pgrst.patch+json"
7577
toMime MTUrlEncoded = "application/x-www-form-urlencoded"
7678
toMime MTOctetStream = "application/octet-stream"
7779
toMime MTAny = "*/*"
@@ -121,6 +123,9 @@ toMimePlanFormat PlanText = "text"
121123
-- >>> decodeMediaType "application/vnd.pgrst.object+json"
122124
-- MTVndSingularJSON False
123125
--
126+
-- >>> decodeMediaType "application/vnd.pgrst.patch+json"
127+
-- MTVndPgrstPatch
128+
--
124129
-- Test uppercase is parsed correctly (per issue #3478)
125130
-- >>> decodeMediaType "ApplicatIon/vnd.PgRsT.object+json"
126131
-- MTVndSingularJSON False
@@ -147,6 +152,7 @@ decodeMediaType mt = decodeMediaType' $ decodeLatin1 mt
147152
("application", "vnd.pgrst.plan+json", _) -> getPlan PlanJSON
148153
("application", "vnd.pgrst.object+json", _) -> MTVndSingularJSON strippedNulls
149154
("application", "vnd.pgrst.object", _) -> MTVndSingularJSON strippedNulls
155+
("application", "vnd.pgrst.patch+json", _) -> MTVndPgrstPatch
150156
("application", "vnd.pgrst.array+json", _) -> checkArrayNullStrip
151157
("application", "vnd.pgrst.array", _) -> checkArrayNullStrip
152158
("*","*",_) -> MTAny

src/PostgREST/Plan.hs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,7 @@ mutatePlan mutation qi ApiRequest{iPreferences=Preferences{..}, ..} SchemaCache{
988988
else
989989
Left $ ApiRequestError InvalidFilters
990990
MutationDelete -> Right $ Delete qi combinedLogic returnings
991+
MutationPgrstPatch -> Right $ PgrstPatch qi pgrstPatchBody combinedLogic returnings
991992
where
992993
ctx = ResolverContext dbTables dbRepresentations qi "json"
993994
confCols = fromMaybe pkCols qsOnConflict
@@ -1003,6 +1004,7 @@ mutatePlan mutation qi ApiRequest{iPreferences=Preferences{..}, ..} SchemaCache{
10031004
logic = map (resolveLogicTree ctx . snd) qsLogic
10041005
combinedLogic = foldr (addFilterToLogicForest . resolveFilter ctx) logic qsFiltersRoot
10051006
body = payRaw <$> iPayload -- the body is assumed to be json at this stage(ApiRequest validates)
1007+
pgrstPatchBody = payPgrstPatch <$> iPayload
10061008
applyDefaults = preferMissing == Just ApplyDefaults
10071009
typedColumnsOrError = resolveOrError ctx tbl `traverse` S.toList iColumns
10081010

src/PostgREST/Plan/MutatePlan.hs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ where
66
import qualified Data.ByteString.Lazy as LBS
77

88
import PostgREST.ApiRequest.Preferences (PreferResolution)
9+
import PostgREST.ApiRequest.Types (PgrstPatchOp (..))
910
import PostgREST.Plan.Types (CoercibleField,
1011
CoercibleLogicTree)
1112
import PostgREST.SchemaCache.Identifiers (FieldName,
1213
QualifiedIdentifier)
1314

14-
1515
import Protolude
1616

1717
data MutatePlan
@@ -38,3 +38,9 @@ data MutatePlan
3838
, where_ :: [CoercibleLogicTree]
3939
, returning :: [FieldName]
4040
}
41+
| PgrstPatch -- A modified version of JSON Patch
42+
{ in_ :: QualifiedIdentifier
43+
, patchBody :: Maybe [PgrstPatchOp]
44+
, where_ :: [CoercibleLogicTree]
45+
, returning :: [FieldName]
46+
}

src/PostgREST/Query.hs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ actionQuery (DbCrud plan@MutateReadPlan{..}) conf@AppConfig{..} apiReq@ApiReques
150150
MutationDelete -> do
151151
failNotSingular mrMedia resultSet
152152
failExceedsMaxAffectedPref (preferMaxAffected,preferHandling) resultSet
153+
MutationPgrstPatch -> do
154+
failExceedsMaxAffectedPref (preferMaxAffected,preferHandling) resultSet
153155
mainActionQuery = do
154156
resultSet <- lift $ SQL.statement mempty result
155157
failMutation resultSet

src/PostgREST/Query/QueryBuilder.hs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ getJoin fld node@(Node ReadPlan{relJoinType, relSpread} _) =
119119
correlatedSubquery (selectSubqAgg <> fromSubqAgg) aggAlias joinCondition
120120

121121
mutatePlanToQuery :: MutatePlan -> SQL.Snippet
122+
-- INSERT: Corresponds to HTTP POST and PUT methods
122123
mutatePlanToQuery (Insert mainQi iCols body onConflict putConditions returnings _ applyDefaults) =
123124
"INSERT INTO " <> fromQi mainQi <> (if null iCols then " " else "(" <> cols <> ") ") <>
124125
fromJsonBodyF body iCols True False applyDefaults <>
@@ -142,6 +143,7 @@ mutatePlanToQuery (Insert mainQi iCols body onConflict putConditions returnings
142143
cols = intercalateSnippet ", " $ pgFmtIdent . cfName <$> iCols
143144
mergeDups = case onConflict of {Just (MergeDuplicates,_) -> True; _ -> False;}
144145

146+
-- UPDATE: Corresponds to HTTP PATCH method
145147
mutatePlanToQuery (Update mainQi uCols body logicForest returnings applyDefaults)
146148
| null uCols =
147149
-- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax
@@ -161,13 +163,45 @@ mutatePlanToQuery (Update mainQi uCols body logicForest returnings applyDefaults
161163
emptyBodyReturnedColumns = if null returnings then "NULL" else intercalateSnippet ", " (pgFmtColumn (QualifiedIdentifier mempty $ qiName mainQi) <$> returnings)
162164
cols = intercalateSnippet ", " (pgFmtIdent . cfName <> const " = " <> pgFmtColumn (QualifiedIdentifier mempty "pgrst_body") . cfName <$> uCols)
163165

166+
-- DELETE: Corresponds to HTTP DELETE method
164167
mutatePlanToQuery (Delete mainQi logicForest returnings) =
165168
"DELETE FROM " <> fromQi mainQi <> " " <>
166169
whereLogic <> " " <>
167170
returningF mainQi returnings
168171
where
169172
whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)
170173

174+
-- PGRST PATCH: HTTP PATCH method with "vnd.pgrst.patch+json" Content-Type
175+
mutatePlanToQuery (PgrstPatch mainQi body logicForest returnings) =
176+
"UPDATE " <> fromQi mainQi <> " SET "
177+
<> "(" <> intercalateSnippet "," cols <> ")"
178+
<> " = "
179+
<> "ROW(" <> intercalateSnippet "," vals <> ") "
180+
<> whereLogic <> " "
181+
<> returningF mainQi returnings
182+
where
183+
-- TODO: At this stage, there must be a body. The Maybe comes from
184+
-- ApiRequest which should be refactored later to avoid 'fromJust'
185+
patchBody = fromJust body
186+
187+
cols :: [SQL.Snippet]
188+
cols = map getFieldName patchBody
189+
where
190+
getFieldName :: PgrstPatchOp -> SQL.Snippet
191+
getFieldName (Incr field _) = pgFmtIdent field
192+
193+
vals :: [SQL.Snippet]
194+
vals = map getValAndApplyOp patchBody
195+
where
196+
intToSnip = SQL.sql . BS.pack . show
197+
198+
getValAndApplyOp :: PgrstPatchOp -> SQL.Snippet
199+
getValAndApplyOp (Incr field val) =
200+
pgFmtIdent field <> " + CAST(" <> intToSnip val <> " AS INTEGER)"
201+
202+
whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)
203+
204+
171205
callPlanToQuery :: CallPlan -> SQL.Snippet
172206
callPlanToQuery (FunctionCall qi params arguments returnsScalar returnsSetOfScalar filterFields returnings) =
173207
"SELECT " <> (if returnsScalar || returnsSetOfScalar then "pgrst_call.pgrst_scalar" else returnedColumns) <> " " <>

0 commit comments

Comments
 (0)