Skip to content

Commit e7c8f46

Browse files
committed
Add basic support for bulk delete
1 parent caaa9a3 commit e7c8f46

File tree

11 files changed

+117
-43
lines changed

11 files changed

+117
-43
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
2525
- #2694, Make `db-root-spec` stable. - @steve-chavez
2626
+ This can be used to override the OpenAPI spec with a custom database function
2727
- #1567, On bulk inserts with `?columns`, undefined json keys can get columns' DEFAULT values by using the `Prefer: undefined-keys=apply-defaults` header - @steve-chavez
28+
- #2314, Allow bulk delete by using DELETE with an array body - @laurenceisla
2829

2930
### Fixed
3031

src/PostgREST/ApiRequest.hs

+1
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ getPayload reqBody contentMediaType QueryParams{qsColumns} action PathInfo{pathI
289289
(ActionInvoke InvPost, _) -> True
290290
(ActionMutate MutationSingleUpsert, _) -> True
291291
(ActionMutate MutationUpdate, _) -> True
292+
(ActionMutate MutationDelete, _) -> not (LBS.null reqBody)
292293
_ -> False
293294

294295
columns = case action of

src/PostgREST/ApiRequest/Types.hs

+1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ data ApiRequestError
6666
= AmbiguousRelBetween Text Text [Relationship]
6767
| AmbiguousRpc [ProcDescription]
6868
| BinaryFieldError MediaType
69+
| BulkLimitNotAllowedError
6970
| MediaTypeError [ByteString]
7071
| InvalidBody ByteString
7172
| InvalidFilters

src/PostgREST/Error.hs

+31-22
Original file line numberDiff line numberDiff line change
@@ -59,28 +59,29 @@ class (JSON.ToJSON a) => PgrstError a where
5959
errorResponseFor err = responseLBS (status err) (headers err) $ errorPayload err
6060

6161
instance PgrstError ApiRequestError where
62-
status AmbiguousRelBetween{} = HTTP.status300
63-
status AmbiguousRpc{} = HTTP.status300
64-
status BinaryFieldError{} = HTTP.status406
65-
status MediaTypeError{} = HTTP.status415
66-
status InvalidBody{} = HTTP.status400
67-
status InvalidFilters = HTTP.status405
68-
status InvalidRpcMethod{} = HTTP.status405
69-
status InvalidRange{} = HTTP.status416
70-
status NotFound = HTTP.status404
71-
72-
status NoRelBetween{} = HTTP.status400
73-
status NoRpc{} = HTTP.status404
74-
status NotEmbedded{} = HTTP.status400
75-
status PutRangeNotAllowedError = HTTP.status400
76-
status QueryParamError{} = HTTP.status400
77-
status RelatedOrderNotToOne{} = HTTP.status400
78-
status SpreadNotToOne{} = HTTP.status400
79-
status UnacceptableFilter{} = HTTP.status400
80-
status UnacceptableSchema{} = HTTP.status406
81-
status UnsupportedMethod{} = HTTP.status405
82-
status LimitNoOrderError = HTTP.status400
83-
status ColumnNotFound{} = HTTP.status400
62+
status AmbiguousRelBetween{} = HTTP.status300
63+
status AmbiguousRpc{} = HTTP.status300
64+
status BinaryFieldError{} = HTTP.status406
65+
status BulkLimitNotAllowedError = HTTP.status400
66+
status MediaTypeError{} = HTTP.status415
67+
status InvalidBody{} = HTTP.status400
68+
status InvalidFilters = HTTP.status405
69+
status InvalidRpcMethod{} = HTTP.status405
70+
status InvalidRange{} = HTTP.status416
71+
status NotFound = HTTP.status404
72+
73+
status NoRelBetween{} = HTTP.status400
74+
status NoRpc{} = HTTP.status404
75+
status NotEmbedded{} = HTTP.status400
76+
status PutRangeNotAllowedError = HTTP.status400
77+
status QueryParamError{} = HTTP.status400
78+
status RelatedOrderNotToOne{} = HTTP.status400
79+
status SpreadNotToOne{} = HTTP.status400
80+
status UnacceptableFilter{} = HTTP.status400
81+
status UnacceptableSchema{} = HTTP.status406
82+
status UnsupportedMethod{} = HTTP.status405
83+
status LimitNoOrderError = HTTP.status400
84+
status ColumnNotFound{} = HTTP.status400
8485

8586
headers _ = [MediaType.toContentType MTApplicationJSON]
8687

@@ -172,6 +173,12 @@ instance JSON.ToJSON ApiRequestError where
172173
"details" .= ("Only is null or not is null filters are allowed on embedded resources":: Text),
173174
"hint" .= JSON.Null]
174175

176+
toJSON BulkLimitNotAllowedError = JSON.object [
177+
"code" .= ApiRequestErrorCode21,
178+
"message" .= ("Range header and limit/offset querystring parameters are not allowed for PATCH with Prefer: params=multiple-objects" :: Text),
179+
"details" .= JSON.Null,
180+
"hint" .= JSON.Null]
181+
175182
toJSON (NoRelBetween parent child embedHint schema allRels) = JSON.object [
176183
"code" .= SchemaCacheErrorCode00,
177184
"message" .= ("Could not find a relationship between '" <> parent <> "' and '" <> child <> "' in the schema cache" :: Text),
@@ -598,6 +605,7 @@ data ErrorCode
598605
| ApiRequestErrorCode18
599606
| ApiRequestErrorCode19
600607
| ApiRequestErrorCode20
608+
| ApiRequestErrorCode21
601609
-- Schema Cache errors
602610
| SchemaCacheErrorCode00
603611
| SchemaCacheErrorCode01
@@ -644,6 +652,7 @@ buildErrorCode code = "PGRST" <> case code of
644652
ApiRequestErrorCode18 -> "118"
645653
ApiRequestErrorCode19 -> "119"
646654
ApiRequestErrorCode20 -> "120"
655+
ApiRequestErrorCode21 -> "121"
647656

648657
SchemaCacheErrorCode00 -> "200"
649658
SchemaCacheErrorCode01 -> "201"

src/PostgREST/Plan.hs

+3-1
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,8 @@ mutatePlan mutation qi ApiRequest{iPreferences=preferences, ..} sCache readReq =
518518
then mapRight (\typedColumns -> Insert qi typedColumns body (Just (MergeDuplicates, pkCols)) combinedLogic returnings mempty False) typedColumnsOrError
519519
else
520520
Left InvalidFilters
521-
MutationDelete -> Right $ Delete qi combinedLogic iTopLevelRange rootOrder returnings
521+
MutationDelete ->
522+
mapRight (\typedColumns -> Delete qi typedColumns body combinedLogic iTopLevelRange rootOrder returnings pkCols isBulk) typedColumnsOrError
522523
where
523524
confCols = fromMaybe pkCols qsOnConflict
524525
QueryParams.QueryParams{..} = iQueryParams
@@ -534,6 +535,7 @@ mutatePlan mutation qi ApiRequest{iPreferences=preferences, ..} sCache readReq =
534535
tbl = HM.lookup qi $ dbTables sCache
535536
typedColumnsOrError = resolveOrError tbl `traverse` S.toList iColumns
536537
applyDefaults = preferences.preferUndefinedKeys == Just ApplyDefaults
538+
isBulk = preferences.preferParameters == Just MultipleObjects
537539

538540
resolveOrError :: Maybe Table -> FieldName -> Either ApiRequestError TypedField
539541
resolveOrError Nothing _ = Left NotFound

src/PostgREST/Plan/MutatePlan.hs

+4
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,12 @@ data MutatePlan
3838
}
3939
| Delete
4040
{ in_ :: QualifiedIdentifier
41+
, delCols :: [TypedField]
42+
, delBody :: Maybe LBS.ByteString
4143
, where_ :: [LogicTree]
4244
, mutRange :: NonnegRange
4345
, mutOrder :: [OrderTerm]
4446
, returning :: [FieldName]
47+
, delPkFlts :: [FieldName]
48+
, isBulk :: Bool
4549
}

src/PostgREST/Query/QueryBuilder.hs

+16-7
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ getSelectsJoins rr@(Node ReadPlan{select, relName, relToParent=Just rel, relAggA
8383
mutatePlanToQuery :: MutatePlan -> SQL.Snippet
8484
mutatePlanToQuery (Insert mainQi iCols body onConflct putConditions returnings _ applyDefaults) =
8585
"INSERT INTO " <> SQL.sql (fromQi mainQi) <> SQL.sql (if null iCols then " " else "(" <> cols <> ") ") <>
86-
fromJsonBodyF body iCols True False applyDefaults <>
86+
fromJsonBodyF body iCols True False False applyDefaults <>
8787
-- Only used for PUT
8888
(if null putConditions then mempty else "WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree (QualifiedIdentifier mempty "pgrst_body") <$> putConditions)) <>
8989
SQL.sql (BS.unwords [
@@ -114,13 +114,13 @@ mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings a
114114

115115
| range == allRange =
116116
"UPDATE " <> mainTbl <> " SET " <> SQL.sql nonRangeCols <> " " <>
117-
fromJsonBodyF body uCols False False applyDefaults <>
117+
fromJsonBodyF body uCols False False False applyDefaults <>
118118
whereLogic <> " " <>
119119
SQL.sql (returningF mainQi returnings)
120120

121121
| otherwise =
122122
"WITH " <>
123-
"pgrst_update_body AS (" <> fromJsonBodyF body uCols True True applyDefaults <> "), " <>
123+
"pgrst_update_body AS (" <> fromJsonBodyF body uCols True False True applyDefaults <> "), " <>
124124
"pgrst_affected_rows AS (" <>
125125
"SELECT " <> SQL.sql rangeIdF <> " FROM " <> mainTbl <>
126126
whereLogic <> " " <>
@@ -140,10 +140,12 @@ mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings a
140140
rangeCols = BS.intercalate ", " ((\col -> pgFmtIdent (tfName col) <> " = (SELECT " <> pgFmtIdent (tfName col) <> " FROM pgrst_update_body) ") <$> uCols)
141141
(whereRangeIdF, rangeIdF) = mutRangeF mainQi (fst . otTerm <$> ordts)
142142

143-
mutatePlanToQuery (Delete mainQi logicForest range ordts returnings)
143+
mutatePlanToQuery (Delete mainQi dCols body logicForest range ordts returnings pkFlts isBulk)
144144
| range == allRange =
145145
"DELETE FROM " <> SQL.sql (fromQi mainQi) <> " " <>
146-
whereLogic <> " " <>
146+
(if isBulk
147+
then fromJsonBodyF body dCols False True False False <> whereLogicBulk
148+
else whereLogic) <> " " <>
147149
SQL.sql (returningF mainQi returnings)
148150

149151
| otherwise =
@@ -160,8 +162,15 @@ mutatePlanToQuery (Delete mainQi logicForest range ordts returnings)
160162
SQL.sql (returningF mainQi returnings)
161163

162164
where
163-
whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)
165+
whereLogic = pgFmtWhereF (null logicForest) logicForestF
166+
whereLogicBulk = pgFmtWhereF (null logicForest && null pkFlts) (logicForestF <> pgrstDeleteBodyF)
167+
pgFmtWhereF hasEmptyLogic flts = if hasEmptyLogic then mempty else " WHERE " <> intercalateSnippet " AND " flts
168+
-- whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)
164169
(whereRangeIdF, rangeIdF) = mutRangeF mainQi (fst . otTerm <$> ordts)
170+
logicForestF = pgFmtLogicTree mainQi <$> logicForest
171+
pgrstDeleteBodyF = pgFmtBodyFilter mainQi (QualifiedIdentifier mempty "pgrst_body") <$> pkFlts
172+
pgFmtBodyFilter table cte f = SQL.sql (pgFmtColumn table f <> " = " <> pgFmtColumn cte f)
173+
-- pgrstDeleteBodyF = SQL.sql (BS.intercalate " AND " $ (\x -> pgFmtColumn mainQi x <> " = " <> pgFmtColumn (QualifiedIdentifier mempty "pgrst_delete_body") x) <$> S.toList dCols)
165174

166175
callPlanToQuery :: CallPlan -> SQL.Snippet
167176
callPlanToQuery (FunctionCall qi params args returnsScalar multipleCall returnings) =
@@ -171,7 +180,7 @@ callPlanToQuery (FunctionCall qi params args returnsScalar multipleCall returnin
171180
fromCall = case params of
172181
OnePosParam prm -> "FROM " <> callIt (singleParameter args $ encodeUtf8 $ ppType prm)
173182
KeyParams [] -> "FROM " <> callIt mempty
174-
KeyParams prms -> fromJsonBodyF args ((\p -> TypedField (ppName p) (ppType p) Nothing) <$> prms) False (not multipleCall) False <> ", " <>
183+
KeyParams prms -> fromJsonBodyF args ((\p -> TypedField (ppName p) (ppType p) Nothing) <$> prms) False False (not multipleCall) False <> ", " <>
175184
"LATERAL " <> callIt (fmtParams prms)
176185

177186
callIt :: SQL.Snippet -> SQL.Snippet

src/PostgREST/Query/SqlFragment.hs

+4-3
Original file line numberDiff line numberDiff line change
@@ -231,11 +231,12 @@ pgFmtSelectItem table (f@(fName, jp), Nothing, alias) = pgFmtField table f <> SQ
231231
pgFmtSelectItem table (f@(fName, jp), Just cast, alias) = "CAST (" <> pgFmtField table f <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )" <> SQL.sql (pgFmtAs fName jp alias)
232232

233233
-- TODO: At this stage there shouldn't be a Maybe since ApiRequest should ensure that an INSERT/UPDATE has a body
234-
fromJsonBodyF :: Maybe LBS.ByteString -> [TypedField] -> Bool -> Bool -> Bool -> SQL.Snippet
235-
fromJsonBodyF body fields includeSelect includeLimitOne includeDefaults =
234+
fromJsonBodyF :: Maybe LBS.ByteString -> [TypedField] -> Bool -> Bool -> Bool -> Bool -> SQL.Snippet
235+
fromJsonBodyF body fields includeSelect includeUsing includeLimitOne includeDefaults =
236236
SQL.sql
237237
(if includeSelect then "SELECT " <> parsedCols <> " " else mempty) <>
238-
"FROM (SELECT " <> jsonPlaceHolder <> " AS json_data) pgrst_payload, " <>
238+
(if includeUsing then "USING " else "FROM ") <>
239+
"(SELECT " <> jsonPlaceHolder <> " AS json_data) pgrst_payload, " <>
239240
-- convert a json object into a json array, this way we can use json_to_recordset for all json payloads
240241
-- Otherwise we'd have to use json_to_record for json objects and json_to_recordset for json arrays
241242
-- We do this in SQL to avoid processing the JSON in application code

test/spec/Feature/Query/DeleteSpec.hs

+27-10
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,17 @@ import Test.Hspec.Wai.JSON
1212
import Protolude hiding (get)
1313
import SpecHelper
1414

15-
tblDataBefore = [aesonQQ|[
16-
{ "id": 1, "name": "item-1" }
17-
, { "id": 2, "name": "item-2" }
18-
, { "id": 3, "name": "item-3" }
19-
]|]
15+
tblDataBeforeLimit = [aesonQQ|[
16+
{ "id": 1, "name": "item-1" }
17+
, { "id": 2, "name": "item-2" }
18+
, { "id": 3, "name": "item-3" }
19+
]|]
20+
21+
tblDataBeforeBulk = [aesonQQ|[
22+
{ "id": 1, "name": "item-1", "observation": null }
23+
, { "id": 2, "name": "item-2", "observation": null }
24+
, { "id": 3, "name": "item-3", "observation": null }
25+
]|]
2026

2127
spec :: SpecWith ((), Application)
2228
spec =
@@ -152,7 +158,7 @@ spec =
152158

153159
context "limited delete" $ do
154160
it "works with the limit and offset query params" $
155-
baseTable "limited_delete_items" "id" tblDataBefore
161+
baseTable "limited_delete_items" "id" tblDataBeforeLimit
156162
`mutatesWith`
157163
requestMutation methodDelete "/limited_delete_items?order=id&limit=1&offset=1" mempty
158164
`shouldMutateInto`
@@ -162,7 +168,7 @@ spec =
162168
]|]
163169

164170
it "works with the limit query param plus a filter" $
165-
baseTable "limited_delete_items" "id" tblDataBefore
171+
baseTable "limited_delete_items" "id" tblDataBeforeLimit
166172
`mutatesWith`
167173
requestMutation methodDelete "/limited_delete_items?order=id&limit=1&id=gt.1" mempty
168174
`shouldMutateInto`
@@ -198,7 +204,7 @@ spec =
198204
{ matchStatus = 400 }
199205

200206
it "works with views with an explicit order by unique col" $
201-
baseTable "limited_delete_items_view" "id" tblDataBefore
207+
baseTable "limited_delete_items_view" "id" tblDataBeforeLimit
202208
`mutatesWith`
203209
requestMutation methodDelete "/limited_delete_items_view?order=id&limit=1&offset=1" mempty
204210
`shouldMutateInto`
@@ -208,7 +214,7 @@ spec =
208214
]|]
209215

210216
it "works with views with an explicit order by composite pk" $
211-
baseTable "limited_delete_items_cpk_view" "id" tblDataBefore
217+
baseTable "limited_delete_items_cpk_view" "id" tblDataBeforeLimit
212218
`mutatesWith`
213219
requestMutation methodDelete "/limited_delete_items_cpk_view?order=id,name&limit=1&offset=1" mempty
214220
`shouldMutateInto`
@@ -218,11 +224,22 @@ spec =
218224
]|]
219225

220226
it "works on a table without a pk by ordering by 'ctid'" $
221-
baseTable "limited_delete_items_no_pk" "id" tblDataBefore
227+
baseTable "limited_delete_items_no_pk" "id" tblDataBeforeLimit
222228
`mutatesWith`
223229
requestMutation methodDelete "/limited_delete_items_no_pk?order=ctid&limit=1&offset=1" mempty
224230
`shouldMutateInto`
225231
[json|[
226232
{ "id": 1, "name": "item-1" }
227233
, { "id": 3, "name": "item-3" }
228234
]|]
235+
236+
-- context "bulk deletes" $ do
237+
-- it "can delete tables with simple pk" $
238+
-- baseTable "bulk_delete_items" "id" tblDataBeforeBulk
239+
-- `mutatesWith`
240+
-- requestMutation methodDelete "/bulk_delete_items" mempty
241+
-- `shouldMutateInto`
242+
-- [json|[
243+
-- { "id": 1, "name": "any name 1" }
244+
-- , { "id": 3, "name": "any name 3" }
245+
-- ]|]

test/spec/fixtures/data.sql

+6
Original file line numberDiff line numberDiff line change
@@ -838,3 +838,9 @@ INSERT INTO posters(id,name) VALUES (1,'Mark'), (2,'Elon'), (3,'Bill'), (4,'Jeff
838838
839839
TRUNCATE TABLE subscriptions CASCADE;
840840
INSERT INTO subscriptions(subscriber,subscribed) VALUES (3,1), (4,1), (1,2);
841+
842+
TRUNCATE TABLE test.body_delete_items CASCADE;
843+
INSERT INTO test.body_delete_items (id, name, observation) VALUES (1, 'item-1', NULL), (2, 'item-2', NULL), (3, 'item-3', NULL);
844+
845+
TRUNCATE TABLE test.bulk_delete_items CASCADE;
846+
INSERT INTO test.bulk_delete_items (id, name, observation) VALUES (1, 'item-1', NULL), (2, 'item-2', NULL), (3, 'item-3', NULL);

test/spec/fixtures/schema.sql

+23
Original file line numberDiff line numberDiff line change
@@ -3110,3 +3110,26 @@ create table test.tbl_w_json(
31103110
id int,
31113111
data json
31123112
);
3113+
3114+
-- Table to test deletes with body in the payload
3115+
3116+
CREATE TABLE test.body_delete_items (
3117+
id INT PRIMARY KEY ,
3118+
name TEXT,
3119+
observation TEXT
3120+
);
3121+
3122+
-- Tables to test bulk deletes
3123+
3124+
CREATE TABLE test.bulk_delete_items (
3125+
id INT PRIMARY KEY,
3126+
name TEXT,
3127+
observation TEXT
3128+
);
3129+
3130+
CREATE TABLE test.bulk_delete_items_cpk (
3131+
id INT,
3132+
name TEXT,
3133+
observation TEXT,
3134+
PRIMARY KEY (id, name)
3135+
);

0 commit comments

Comments
 (0)