feat: add limited delete

This commit is contained in:
steve-chavez
2022-03-26 15:29:13 +01:00
committed by Steve Chavez
parent f4becf99ad
commit 331e88ea39
9 changed files with 242 additions and 23 deletions
+2 -2
View File
@@ -18,7 +18,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
+ #1689, Add the ability to run without `db-anon-role` disabling anonymous access. - @wolfgangwalther + #1689, Add the ability to run without `db-anon-role` disabling anonymous access. - @wolfgangwalther
- #1543, Allow access to fields of composite types in select=, order= and filters through JSON operators -> and ->>. - @wolfgangwalther - #1543, Allow access to fields of composite types in select=, order= and filters through JSON operators -> and ->>. - @wolfgangwalther
- #2075, Allow access to array items in ?select=, ?order= and filters through JSON operators -> and ->>. - @wolfgangwalther - #2075, Allow access to array items in ?select=, ?order= and filters through JSON operators -> and ->>. - @wolfgangwalther
- #2156, Allow applying `limit/offset` to UPDATE to only affect a subset of rows - @steve-chavez - #2156, Allow applying `limit/offset` to UPDATE/DELETE to only affect a subset of rows - @steve-chavez
+ Uses the table primary key, so it needs a select privilege on the primary key columns + Uses the table primary key, so it needs a select privilege on the primary key columns
+ If no primary key is available, it will fallback to using the "ctid" system column(will also require a select privilege on it) + If no primary key is available, it will fallback to using the "ctid" system column(will also require a select privilege on it)
+ Will work on views if the PK(or "ctid") is present on its SELECT clause + Will work on views if the PK(or "ctid") is present on its SELECT clause
@@ -47,7 +47,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- #2001, Return 204 No Content without Content-Type for RPCs returning VOID - @wolfgangwalther - #2001, Return 204 No Content without Content-Type for RPCs returning VOID - @wolfgangwalther
+ Previously, those RPCs would return "null" as a body with Content-Type: application/json. + Previously, those RPCs would return "null" as a body with Content-Type: application/json.
- #2156, `limit/offset` now limits the affected rows on UPDATE - @steve-chavez - #2156, `limit/offset` now limits the affected rows on UPDATE/DELETE - @steve-chavez
+ Previously, `limit/offset` only limited the returned rows but not the actual updated rows + Previously, `limit/offset` only limited the returned rows but not the actual updated rows
## [9.0.0] - 2021-11-25 ## [9.0.0] - 2021-11-25
+25 -10
View File
@@ -125,13 +125,13 @@ mutateRequestToQuery (Update mainQi uCols body logicForest (range, rangeId) retu
"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 (SELECT * FROM json_populate_recordset (null::" <> mainTbl <> " , " <> SQL.sql selectBody <> " ) LIMIT 1), " <>
"pgrst_affected_rows AS (" <> "pgrst_affected_rows AS (" <>
"SELECT " <> SQL.sql _rangeId <> " FROM " <> mainTbl <> "SELECT " <> SQL.sql rangeIdF <> " FROM " <> mainTbl <>
whereLogic <> " " <> whereLogic <> " " <>
"ORDER BY " <> SQL.sql _rangeId <> " " <> limitOffsetF range <> "ORDER BY " <> SQL.sql rangeIdF <> " " <> limitOffsetF range <>
") " <> ") " <>
"UPDATE " <> mainTbl <> " SET " <> SQL.sql rangeCols <> "UPDATE " <> mainTbl <> " SET " <> SQL.sql rangeCols <>
"FROM pgrst_affected_rows " <> "FROM pgrst_affected_rows " <>
"WHERE " <> SQL.sql whereRangeId <> " " <> "WHERE " <> SQL.sql whereRangeIdF <> " " <>
SQL.sql (returningF mainQi returnings) SQL.sql (returningF mainQi returnings)
where where
@@ -140,14 +140,29 @@ mutateRequestToQuery (Update mainQi uCols body logicForest (range, rangeId) retu
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 <> const " = _." <> pgFmtIdent <$> S.toList uCols)
rangeCols = BS.intercalate ", " ((\col -> pgFmtIdent col <> " = (SELECT " <> pgFmtIdent col <> " FROM pgrst_update_body) ") <$> S.toList uCols) rangeCols = BS.intercalate ", " ((\col -> pgFmtIdent col <> " = (SELECT " <> pgFmtIdent col <> " FROM pgrst_update_body) ") <$> S.toList uCols)
_rangeId = if null rangeId then pgFmtColumn mainQi "ctid" else BS.intercalate ", " (pgFmtColumn mainQi <$> rangeId) (whereRangeIdF, rangeIdF) = mutRangeF mainQi rangeId
whereRangeId = BS.intercalate " AND " $
(\col -> pgFmtColumn mainQi col <> " = " <> pgFmtColumn (QualifiedIdentifier mempty "pgrst_affected_rows") col) <$> (if null rangeId then ["ctid"] else rangeId)
mutateRequestToQuery (Delete mainQi logicForest returnings) = mutateRequestToQuery (Delete mainQi logicForest (range, rangeId) returnings)
"DELETE FROM " <> SQL.sql (fromQi mainQi) <> " " <> | range == allRange =
(if null logicForest then mempty else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree mainQi) logicForest)) <> " " <> "DELETE FROM " <> SQL.sql (fromQi mainQi) <> " " <>
SQL.sql (returningF mainQi returnings) whereLogic <> " " <>
SQL.sql (returningF mainQi returnings)
| otherwise =
"WITH " <>
"pgrst_affected_rows AS (" <>
"SELECT " <> SQL.sql rangeIdF <> " FROM " <> SQL.sql (fromQi mainQi) <>
whereLogic <> " " <>
"ORDER BY " <> SQL.sql rangeIdF <> " " <> limitOffsetF range <>
") " <>
"DELETE FROM " <> SQL.sql (fromQi mainQi) <> " " <>
"USING pgrst_affected_rows " <>
"WHERE " <> SQL.sql whereRangeIdF <> " " <>
SQL.sql (returningF mainQi returnings)
where
whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)
(whereRangeIdF, rangeIdF) = mutRangeF mainQi rangeId
requestToCallProcQuery :: CallRequest -> SQL.Snippet requestToCallProcQuery :: CallRequest -> SQL.Snippet
requestToCallProcQuery (FunctionCall qi params args returnsScalar multipleCall returnings) = requestToCallProcQuery (FunctionCall qi params args returnsScalar multipleCall returnings) =
+10
View File
@@ -17,6 +17,7 @@ module PostgREST.Query.SqlFragment
, fromQi , fromQi
, limitOffsetF , limitOffsetF
, locationF , locationF
, mutRangeF
, normalizedBody , normalizedBody
, pgFmtColumn , pgFmtColumn
, pgFmtIdent , pgFmtIdent
@@ -334,3 +335,12 @@ unknownLiteral = unknownEncoder . encodeUtf8
intercalateSnippet :: ByteString -> [SQL.Snippet] -> SQL.Snippet intercalateSnippet :: ByteString -> [SQL.Snippet] -> SQL.Snippet
intercalateSnippet _ [] = mempty intercalateSnippet _ [] = mempty
intercalateSnippet frag snippets = foldr1 (\a b -> a <> SQL.sql frag <> b) snippets intercalateSnippet frag snippets = foldr1 (\a b -> a <> SQL.sql frag <> b) snippets
-- the "ctid" system column is always available to tables
mutRangeF :: QualifiedIdentifier -> [FieldName] -> (SqlFragment, SqlFragment)
mutRangeF mainQi rangeId = (
BS.intercalate " AND " $
(\col -> pgFmtColumn mainQi col <> " = " <> pgFmtColumn (QualifiedIdentifier mempty "pgrst_affected_rows") col) <$>
(if null rangeId then ["ctid"] else rangeId)
, if null rangeId then pgFmtColumn mainQi "ctid" else BS.intercalate ", " (pgFmtColumn mainQi <$> rangeId)
)
+1 -1
View File
@@ -332,7 +332,7 @@ mutateRequest mutation schema tName ApiRequest{..} pkCols readReq = mapLeft ApiR
then Right $ Insert qi iColumns body (Just (MergeDuplicates, pkCols)) combinedLogic returnings then Right $ Insert qi iColumns body (Just (MergeDuplicates, pkCols)) combinedLogic returnings
else else
Left InvalidFilters Left InvalidFilters
MutationDelete -> Right $ Delete qi combinedLogic returnings MutationDelete -> Right $ Delete qi combinedLogic (iTopLevelRange, pkCols) returnings
where where
confCols = fromMaybe pkCols qsOnConflict confCols = fromMaybe pkCols qsOnConflict
QueryParams.QueryParams{..} = iQueryParams QueryParams.QueryParams{..} = iQueryParams
+1
View File
@@ -141,6 +141,7 @@ data MutateQuery
| Delete | Delete
{ in_ :: QualifiedIdentifier { in_ :: QualifiedIdentifier
, where_ :: [LogicTree] , where_ :: [LogicTree]
, mutRange :: (NonnegRange, [FieldName])
, returning :: [FieldName] , returning :: [FieldName]
} }
+161
View File
@@ -115,3 +115,164 @@ spec =
{ matchStatus = 204 { matchStatus = 204
, matchHeaders = [matchHeaderAbsent hContentType] , matchHeaders = [matchHeaderAbsent hContentType]
} }
context "limited delete" $ do
it "works with the limit and offset query params" $ do
get "/limited_delete_items"
`shouldRespondWith`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 2, "name": "item-2" }
, { "id": 3, "name": "item-3" }
]|]
request methodDelete "/limited_delete_items?limit=1&offset=1"
[("Prefer", "tx=commit")]
mempty
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Preference-Applied" <:> "tx=commit" ]
}
get "/limited_delete_items?order=id"
`shouldRespondWith`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 3, "name": "item-3" }
]|]
request methodPost "/rpc/reset_limited_items"
[("Prefer", "tx=commit")]
[json| {"tbl_name": "limited_delete_items"} |]
`shouldRespondWith` ""
{ matchStatus = 204 }
it "works with the limit query param plus a filter" $ do
get "/limited_delete_items"
`shouldRespondWith`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 2, "name": "item-2" }
, { "id": 3, "name": "item-3" }
]|]
request methodDelete "/limited_delete_items?limit=1&id=gt.1"
[("Prefer", "tx=commit")]
mempty
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Preference-Applied" <:> "tx=commit" ]
}
get "/limited_delete_items?order=id"
`shouldRespondWith`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 3, "name": "item-3" }
]|]
request methodPost "/rpc/reset_limited_items"
[("Prefer", "tx=commit")]
[json| {"tbl_name": "limited_delete_items"} |]
`shouldRespondWith` ""
{ matchStatus = 204 }
it "works on a table with a composite pk" $ do
get "/limited_delete_items_cpk"
`shouldRespondWith`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 2, "name": "item-2" }
, { "id": 3, "name": "item-3" }
]|]
request methodDelete "/limited_delete_items_cpk?limit=1&offset=1"
[("Prefer", "tx=commit")]
mempty
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Preference-Applied" <:> "tx=commit" ]
}
get "/limited_delete_items_cpk"
`shouldRespondWith`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 3, "name": "item-3" }
]|]
request methodPost "/rpc/reset_limited_items"
[("Prefer", "tx=commit")]
[json| {"tbl_name": "limited_delete_items_cpk"} |]
`shouldRespondWith` ""
{ matchStatus = 204 }
it "works with views with an inferred pk" $ do
get "/limited_delete_items_view"
`shouldRespondWith`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 2, "name": "item-2" }
, { "id": 3, "name": "item-3" }
]|]
request methodDelete "/limited_delete_items_view?limit=1&offset=1"
[("Prefer", "tx=commit")]
mempty
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Preference-Applied" <:> "tx=commit" ]
}
get "/limited_delete_items_view"
`shouldRespondWith`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 3, "name": "item-3" }
]|]
request methodPost "/rpc/reset_limited_items"
[("Prefer", "tx=commit")]
[json| {"tbl_name": "limited_delete_items_view"} |]
`shouldRespondWith` ""
{ matchStatus = 204 }
it "works on a table without a pk" $ do
get "/limited_delete_items_no_pk"
`shouldRespondWith`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 2, "name": "item-2" }
, { "id": 3, "name": "item-3" }
]|]
request methodDelete "/limited_delete_items_no_pk?limit=1&offset=1"
[("Prefer", "tx=commit")]
mempty
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Preference-Applied" <:> "tx=commit" ]
}
get "/limited_delete_items_no_pk"
`shouldRespondWith`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 3, "name": "item-3" }
]|]
request methodPost "/rpc/reset_limited_items"
[("Prefer", "tx=commit")]
[json| {"tbl_name": "limited_delete_items_no_pk"} |]
`shouldRespondWith` ""
{ matchStatus = 204 }
+15 -6
View File
@@ -737,11 +737,20 @@ INSERT INTO test.fav_numbers VALUES (ROW(0.5, 0.5), 'A'), (ROW(0.6, 0.6), 'B');
TRUNCATE TABLE test.arrays CASCADE; TRUNCATE TABLE test.arrays CASCADE;
INSERT INTO test.arrays VALUES (0, '{1,2,3}', '{{1,2,3},{4,5,6},{7,8,9}}'), (1, '{11,12,13}', '{{11,12,13},{14,15,16},{17,18,19}}'); INSERT INTO test.arrays VALUES (0, '{1,2,3}', '{{1,2,3},{4,5,6},{7,8,9}}'), (1, '{11,12,13}', '{{11,12,13},{14,15,16},{17,18,19}}');
TRUNCATE TABLE test.limited_updated_items CASCADE; TRUNCATE TABLE test.limited_update_items CASCADE;
INSERT INTO test.limited_updated_items VALUES (1, 'item-1'), (2, 'item-2'), (3, 'item-3'); INSERT INTO test.limited_update_items VALUES (1, 'item-1'), (2, 'item-2'), (3, 'item-3');
TRUNCATE TABLE test.limited_updated_items_cpk CASCADE; TRUNCATE TABLE test.limited_update_items_cpk CASCADE;
INSERT INTO test.limited_updated_items_cpk VALUES (1, 'item-1'), (2, 'item-2'), (3, 'item-3'); INSERT INTO test.limited_update_items_cpk VALUES (1, 'item-1'), (2, 'item-2'), (3, 'item-3');
TRUNCATE TABLE test.limited_updated_items_no_pk CASCADE; TRUNCATE TABLE test.limited_update_items_no_pk CASCADE;
INSERT INTO test.limited_updated_items_no_pk VALUES (1, 'item-1'), (2, 'item-2'), (3, 'item-3'); INSERT INTO test.limited_update_items_no_pk VALUES (1, 'item-1'), (2, 'item-2'), (3, 'item-3');
TRUNCATE TABLE test.limited_delete_items CASCADE;
INSERT INTO test.limited_delete_items VALUES (1, 'item-1'), (2, 'item-2'), (3, 'item-3');
TRUNCATE TABLE test.limited_delete_items_cpk CASCADE;
INSERT INTO test.limited_delete_items_cpk VALUES (1, 'item-1'), (2, 'item-2'), (3, 'item-3');
TRUNCATE TABLE test.limited_delete_items_no_pk CASCADE;
INSERT INTO test.limited_delete_items_no_pk VALUES (1, 'item-1'), (2, 'item-2'), (3, 'item-3');
+8 -4
View File
@@ -163,10 +163,14 @@ GRANT ALL ON TABLE
, clientinfo , clientinfo
, contact , contact
, chores , chores
, limited_mut_items , limited_update_items
, limited_mut_items_cpk , limited_update_items_cpk
, limited_mut_items_no_pk , limited_update_items_no_pk
, limited_mut_items_view , limited_update_items_view
, limited_delete_items
, limited_delete_items_cpk
, limited_delete_items_no_pk
, limited_delete_items_view
TO postgrest_test_anonymous; TO postgrest_test_anonymous;
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous; GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
+19
View File
@@ -2488,6 +2488,25 @@ create table limited_update_items_no_pk(
create view limited_update_items_view as create view limited_update_items_view as
select * from limited_update_items; select * from limited_update_items;
create table limited_delete_items(
id int primary key
, name text
);
create table limited_delete_items_cpk(
id int
, name text
, primary key (id, name)
);
create table limited_delete_items_no_pk(
id int
, name text
);
create view limited_delete_items_view as
select * from limited_delete_items;
create function reset_limited_items(tbl_name text default '') returns void as $_$ begin create function reset_limited_items(tbl_name text default '') returns void as $_$ begin
execute format( execute format(
$$ $$