Skip to content

Commit

Permalink
break: remove limited updated/delete feature (#3907)
Browse files Browse the repository at this point in the history
BREAKING CHANGE

As agreed on #3013 (comment),
this removes the limited update/delete feature.

The feature was complicated, largely unused and caused other bugs in
mutations.

It was added in #2195 and #2211.
  • Loading branch information
steve-chavez authored Feb 12, 2025
1 parent 94f0edb commit 307692c
Show file tree
Hide file tree
Showing 19 changed files with 20 additions and 549 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Previously, this would silently return 200 - OK on the root endpoint, but don't provide any usable endpoints.
- #3757, Remove support for `Prefer: params=single-object` - @joelonsql
+ This preference was deprecated in favor of Functions with an array of JSON objects
- #3013, Drop support for Limited updates/deletes
+ The feature was complicated and largely unused.

## [12.2.8] - 2025-02-10

Expand Down
25 changes: 0 additions & 25 deletions docs/references/api/tables_views.rst
Original file line number Diff line number Diff line change
Expand Up @@ -730,31 +730,6 @@ Deletions also support :ref:`prefer_return`, :ref:`resource_embedding` and :ref:
Beware of accidentally deleting all rows in a table. To learn to prevent that see :ref:`block_fulltable`.
.. _limited_update_delete:
Limited Update/Delete
=====================
You can limit the amount of affected rows by :ref:`update` or :ref:`delete` with the ``limit`` query parameter. For this, you must add an explicit ``order`` on a unique column(s).
.. code-block:: bash
curl -X PATCH "/users?limit=10&order=id&last_login=lt.2020-01-01" \
-H "Content-Type: application/json" \
-d '{ "status": "inactive" }'
.. code-block:: bash
curl -X DELETE "http://localhost:3000/users?limit=10&order=id&status=eq.inactive"
If your table has no unique columns, you can use the `ctid <https://www.postgresql.org/docs/current/ddl-system-columns.html>`_ system column.
Using ``offset`` to target a different subset of rows is also possible.
.. note::
There is no native ``UPDATE...LIMIT`` or ``DELETE...LIMIT`` support in PostgreSQL; the generated query simulates that behavior and is based on `this Crunchy Data blog post <https://www.crunchydata.com/blog/simulating-update-or-delete-with-limit-in-postgres-ctes-to-the-rescue>`_.
.. raw:: html
<script type="text/javascript">
Expand Down
8 changes: 0 additions & 8 deletions docs/references/errors.rst
Original file line number Diff line number Diff line change
Expand Up @@ -205,14 +205,6 @@ Related to the HTTP request elements.
| | | specified in the ``select`` part of the query string. |
| PGRST108 | | See :ref:`embed_filters`. |
+---------------+-------------+-------------------------------------------------------------+
| .. _pgrst109: | 400 | Restricting a Deletion or an Update using limits must |
| | | include the ordering of a unique column. |
| PGRST109 | | See :ref:`limited_update_delete`. |
+---------------+-------------+-------------------------------------------------------------+
| .. _pgrst110: | 400 | When restricting a Deletion or an Update using limits |
| | | modifies more rows than the maximum specified in the limit. |
| PGRST110 | | See :ref:`limited_update_delete`. |
+---------------+-------------+-------------------------------------------------------------+
| .. _pgrst111: | 500 | An invalid ``response.headers`` was set. |
| | | See :ref:`guc_resp_hdrs`. |
| PGRST111 | | |
Expand Down
1 change: 0 additions & 1 deletion postgrest.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,6 @@ test-suite spec
Feature.Query.ErrorSpec
Feature.Query.InsertSpec
Feature.Query.JsonOperatorSpec
Feature.Query.LimitedMutationSpec
Feature.Query.MultipleSchemaSpec
Feature.Query.NullsStripSpec
Feature.Query.PgSafeUpdateSpec
Expand Down
3 changes: 1 addition & 2 deletions src/PostgREST/ApiRequest.hs
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,8 @@ getSchema AppConfig{configDbSchemas} hdrs method = do
lookupHeader = flip lookup hdrs

getRanges :: ByteString -> QueryParams -> RequestHeaders -> Either ApiRequestError (NonnegRange, HM.HashMap Text NonnegRange)
getRanges method QueryParams{qsOrder,qsRanges} hdrs
getRanges method QueryParams{qsRanges} hdrs
| isInvalidRange = Left $ InvalidRange (if rangeIsEmpty headerRange then LowerGTUpper else NegativeLimit)
| method `elem` ["PATCH", "DELETE"] && not (null qsRanges) && null qsOrder = Left LimitNoOrderError
| method == "PUT" && topLevelRange /= allRange = Left PutLimitNotAllowedError
| otherwise = Right (topLevelRange, ranges)
where
Expand Down
2 changes: 0 additions & 2 deletions src/PostgREST/ApiRequest/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ data ApiRequestError
| InvalidPreferences [ByteString]
| InvalidRange RangeError
| InvalidRpcMethod ByteString
| LimitNoOrderError
| NotFound
| NoRelBetween Text Text (Maybe Text) Text RelationshipsMap
| NoRpc Text Text [Text] MediaType Bool [QualifiedIdentifier] [Routine]
Expand All @@ -94,7 +93,6 @@ data ApiRequestError
| ColumnNotFound Text Text
| GucHeadersError
| GucStatusError
| OffLimitsChangesError Int64 Integer
| PutMatchingPkError
| SingularityError Integer
| PGRSTParseError RaiseError
Expand Down
17 changes: 2 additions & 15 deletions src/PostgREST/Error.hs
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,9 @@ instance PgrstError ApiRequestError where
status UnacceptableFilter{} = HTTP.status400
status UnacceptableSchema{} = HTTP.status406
status UnsupportedMethod{} = HTTP.status405
status LimitNoOrderError = HTTP.status400
status ColumnNotFound{} = HTTP.status400
status GucHeadersError = HTTP.status500
status GucStatusError = HTTP.status500
status OffLimitsChangesError{} = HTTP.status400
status PutMatchingPkError = HTTP.status400
status SingularityError{} = HTTP.status406
status PGRSTParseError{} = HTTP.status500
Expand Down Expand Up @@ -140,15 +138,6 @@ instance JSON.ToJSON ApiRequestError where
Nothing
(Just $ JSON.String $ "Verify that '" <> resource <> "' is included in the 'select' query parameter.")

toJSON LimitNoOrderError = toJsonPgrstError
ApiRequestErrorCode09 "A 'limit' was applied without an explicit 'order'" Nothing (Just "Apply an 'order' using unique column(s)")

toJSON (OffLimitsChangesError n maxs) = toJsonPgrstError
ApiRequestErrorCode10
"The maximum number of rows allowed to change was surpassed"
(Just $ JSON.String $ T.unwords ["Results contain", show n, "rows changed but the maximum number allowed is", show maxs])
Nothing

toJSON GucHeadersError = toJsonPgrstError
ApiRequestErrorCode11 "response.headers guc must be a JSON array composed of objects with a single key and a string value" Nothing Nothing

Expand Down Expand Up @@ -618,8 +607,8 @@ data ErrorCode
| ApiRequestErrorCode06
| ApiRequestErrorCode07
| ApiRequestErrorCode08
| ApiRequestErrorCode09
| ApiRequestErrorCode10
-- | ApiRequestErrorCode09 -- no longer used (used to be mapped to LimitNoOrderError)
-- | ApiRequestErrorCode10 -- no longer used (used to be mapped to OffLimitsChangesError)
| ApiRequestErrorCode11
-- | ApiRequestErrorCode13 -- no longer used (used to be mapped to BinaryFieldError)
| ApiRequestErrorCode12
Expand Down Expand Up @@ -668,8 +657,6 @@ buildErrorCode code = case code of
ApiRequestErrorCode06 -> "PGRST106"
ApiRequestErrorCode07 -> "PGRST107"
ApiRequestErrorCode08 -> "PGRST108"
ApiRequestErrorCode09 -> "PGRST109"
ApiRequestErrorCode10 -> "PGRST110"
ApiRequestErrorCode11 -> "PGRST111"
ApiRequestErrorCode12 -> "PGRST112"
ApiRequestErrorCode14 -> "PGRST114"
Expand Down
5 changes: 2 additions & 3 deletions src/PostgREST/Plan.hs
Original file line number Diff line number Diff line change
Expand Up @@ -937,7 +937,7 @@ mutatePlan mutation qi ApiRequest{iPreferences=Preferences{..}, ..} SchemaCache{
MutationCreate ->
mapRight (\typedColumns -> Insert qi typedColumns body ((,) <$> preferResolution <*> Just confCols) [] returnings pkCols applyDefaults) typedColumnsOrError
MutationUpdate ->
mapRight (\typedColumns -> Update qi typedColumns body combinedLogic iTopLevelRange rootOrder returnings applyDefaults) typedColumnsOrError
mapRight (\typedColumns -> Update qi typedColumns body combinedLogic returnings applyDefaults) typedColumnsOrError
MutationSingleUpsert ->
if null qsLogic &&
qsFilterFields == S.fromList pkCols &&
Expand All @@ -948,7 +948,7 @@ mutatePlan mutation qi ApiRequest{iPreferences=Preferences{..}, ..} SchemaCache{
then mapRight (\typedColumns -> Insert qi typedColumns body (Just (MergeDuplicates, pkCols)) combinedLogic returnings mempty False) typedColumnsOrError
else
Left InvalidFilters
MutationDelete -> Right $ Delete qi combinedLogic iTopLevelRange rootOrder returnings
MutationDelete -> Right $ Delete qi combinedLogic returnings
where
ctx = ResolverContext dbTables dbRepresentations qi "json"
confCols = fromMaybe pkCols qsOnConflict
Expand All @@ -960,7 +960,6 @@ mutatePlan mutation qi ApiRequest{iPreferences=Preferences{..}, ..} SchemaCache{
tbl = HM.lookup qi dbTables
pkCols = maybe mempty tablePKCols tbl
logic = map (resolveLogicTree ctx . snd) qsLogic
rootOrder = resolveOrder ctx <$> maybe [] snd (find (\(x, _) -> null x) qsOrder)
combinedLogic = foldr (addFilterToLogicForest . resolveFilter ctx) logic qsFiltersRoot
body = payRaw <$> iPayload -- the body is assumed to be json at this stage(ApiRequest validates)
applyDefaults = preferMissing == Just ApplyDefaults
Expand Down
8 changes: 1 addition & 7 deletions src/PostgREST/Plan/MutatePlan.hs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ import qualified Data.ByteString.Lazy as LBS

import PostgREST.ApiRequest.Preferences (PreferResolution)
import PostgREST.Plan.Types (CoercibleField,
CoercibleLogicTree,
CoercibleOrderTerm)
import PostgREST.RangeQuery (NonnegRange)
CoercibleLogicTree)
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier)

Expand All @@ -32,15 +30,11 @@ data MutatePlan
, updCols :: [CoercibleField]
, updBody :: Maybe LBS.ByteString
, where_ :: [CoercibleLogicTree]
, mutRange :: NonnegRange
, mutOrder :: [CoercibleOrderTerm]
, returning :: [FieldName]
, applyDefs :: Bool
}
| Delete
{ in_ :: QualifiedIdentifier
, where_ :: [CoercibleLogicTree]
, mutRange :: NonnegRange
, mutOrder :: [CoercibleOrderTerm]
, returning :: [FieldName]
}
16 changes: 3 additions & 13 deletions src/PostgREST/Query.hs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ import qualified PostgREST.AppState as AppState
import qualified PostgREST.Error as Error
import qualified PostgREST.Query.QueryBuilder as QueryBuilder
import qualified PostgREST.Query.Statements as Statements
import qualified PostgREST.RangeQuery as RangeQuery
import qualified PostgREST.SchemaCache as SchemaCache


import PostgREST.ApiRequest (ApiRequest (..),
Mutation (..))
import PostgREST.ApiRequest.Preferences (PreferCount (..),
Expand Down Expand Up @@ -135,11 +135,10 @@ actionQuery (DbCrud plan@MutateReadPlan{mrMutation=MutationCreate, ..}) conf api
optionalRollback conf apiReq
pure $ DbCrudResult plan resultSet

actionQuery (DbCrud plan@MutateReadPlan{mrMutation=MutationUpdate, ..}) conf apiReq@ApiRequest{iPreferences=Preferences{..}, ..} _ _ = do
actionQuery (DbCrud plan@MutateReadPlan{mrMutation=MutationUpdate, ..}) conf apiReq@ApiRequest{iPreferences=Preferences{..}} _ _ = do
resultSet <- writeQuery mrReadPlan mrMutatePlan mrMedia mrHandler apiReq conf
failNotSingular mrMedia resultSet
failExceedsMaxAffectedPref (preferMaxAffected,preferHandling) resultSet
failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet
optionalRollback conf apiReq
pure $ DbCrudResult plan resultSet

Expand All @@ -149,11 +148,10 @@ actionQuery (DbCrud plan@MutateReadPlan{mrMutation=MutationSingleUpsert, ..}) co
optionalRollback conf apiReq
pure $ DbCrudResult plan resultSet

actionQuery (DbCrud plan@MutateReadPlan{mrMutation=MutationDelete, ..}) conf apiReq@ApiRequest{iPreferences=Preferences{..}, ..} _ _ = do
actionQuery (DbCrud plan@MutateReadPlan{mrMutation=MutationDelete, ..}) conf apiReq@ApiRequest{iPreferences=Preferences{..}} _ _ = do
resultSet <- writeQuery mrReadPlan mrMutatePlan mrMedia mrHandler apiReq conf
failNotSingular mrMedia resultSet
failExceedsMaxAffectedPref (preferMaxAffected,preferHandling) resultSet
failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet
optionalRollback conf apiReq
pure $ DbCrudResult plan resultSet

Expand Down Expand Up @@ -260,14 +258,6 @@ failExceedsMaxAffectedPref (Just (PreferMaxAffected n), handling) RSStandard{rsQ
lift SQL.condemn
throwError $ Error.ApiRequestError . ApiRequestTypes.MaxAffectedViolationError $ toInteger queryTotal

failsChangesOffLimits :: Maybe Integer -> ResultSet -> DbHandler ()
failsChangesOffLimits _ RSPlan{} = pure ()
failsChangesOffLimits Nothing _ = pure ()
failsChangesOffLimits (Just maxChanges) RSStandard{rsQueryTotal=queryTotal} =
when (queryTotal > fromIntegral maxChanges) $ do
lift SQL.condemn
throwError $ Error.ApiRequestError $ ApiRequestTypes.OffLimitsChangesError queryTotal maxChanges

-- | Set a transaction to roll back if requested
optionalRollback :: AppConfig -> ApiRequest -> DbHandler ()
optionalRollback AppConfig{..} ApiRequest{iPreferences=Preferences{..}} = do
Expand Down
50 changes: 8 additions & 42 deletions src/PostgREST/Query/QueryBuilder.hs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ import PostgREST.Plan.MutatePlan
import PostgREST.Plan.ReadPlan
import PostgREST.Plan.Types
import PostgREST.Query.SqlFragment
import PostgREST.RangeQuery (allRange)

import Protolude

Expand Down Expand Up @@ -134,64 +133,31 @@ mutatePlanToQuery (Insert mainQi iCols body onConflict putConditions returnings
cols = intercalateSnippet ", " $ pgFmtIdent . cfName <$> iCols
mergeDups = case onConflict of {Just (MergeDuplicates,_) -> True; _ -> False;}

-- An update without a limit is always filtered with a WHERE
mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings applyDefaults)
mutatePlanToQuery (Update mainQi uCols body logicForest returnings applyDefaults)
| null uCols =
-- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax
-- selecting an empty resultset from mainQi gives us the column names to prevent errors when using &select=
-- the select has to be based on "returnings" to make computed overloaded functions not throw
"SELECT " <> emptyBodyReturnedColumns <> " FROM " <> fromQi mainQi <> " WHERE false"

| range == allRange =
"UPDATE " <> mainTbl <> " SET " <> nonRangeCols <> " " <>
| otherwise =
"UPDATE " <> mainTbl <> " SET " <> cols <> " " <>
fromJsonBodyF body uCols False False applyDefaults <>
whereLogic <> " " <>
returningF mainQi returnings

| otherwise =
"WITH " <>
"pgrst_update_body AS (" <> fromJsonBodyF body uCols True True applyDefaults <> "), " <>
"pgrst_affected_rows AS (" <>
"SELECT " <> rangeIdF <> " FROM " <> mainTbl <>
whereLogic <> " " <>
orderF mainQi ordts <> " " <>
limitOffsetF range <>
") " <>
"UPDATE " <> mainTbl <> " SET " <> rangeCols <>
"FROM pgrst_affected_rows " <>
"WHERE " <> whereRangeIdF <> " " <>
returningF mainQi returnings

where
whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)
mainTbl = fromQi mainQi
emptyBodyReturnedColumns = if null returnings then "NULL" else intercalateSnippet ", " (pgFmtColumn (QualifiedIdentifier mempty $ qiName mainQi) <$> returnings)
nonRangeCols = intercalateSnippet ", " (pgFmtIdent . cfName <> const " = " <> pgFmtColumn (QualifiedIdentifier mempty "pgrst_body") . cfName <$> uCols)
rangeCols = intercalateSnippet ", " ((\col -> pgFmtIdent (cfName col) <> " = (SELECT " <> pgFmtIdent (cfName col) <> " FROM pgrst_update_body) ") <$> uCols)
(whereRangeIdF, rangeIdF) = mutRangeF mainQi (cfName . coField <$> ordts)

mutatePlanToQuery (Delete mainQi logicForest range ordts returnings)
| range == allRange =
"DELETE FROM " <> fromQi mainQi <> " " <>
whereLogic <> " " <>
returningF mainQi returnings

| otherwise =
"WITH " <>
"pgrst_affected_rows AS (" <>
"SELECT " <> rangeIdF <> " FROM " <> fromQi mainQi <>
whereLogic <> " " <>
orderF mainQi ordts <> " " <>
limitOffsetF range <>
") " <>
"DELETE FROM " <> fromQi mainQi <> " " <>
"USING pgrst_affected_rows " <>
"WHERE " <> whereRangeIdF <> " " <>
returningF mainQi returnings
cols = intercalateSnippet ", " (pgFmtIdent . cfName <> const " = " <> pgFmtColumn (QualifiedIdentifier mempty "pgrst_body") . cfName <$> uCols)

mutatePlanToQuery (Delete mainQi logicForest returnings) =
"DELETE FROM " <> fromQi mainQi <> " " <>
whereLogic <> " " <>
returningF mainQi returnings
where
whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)
(whereRangeIdF, rangeIdF) = mutRangeF mainQi (cfName . coField <$> ordts)

callPlanToQuery :: CallPlan -> PgVersion -> SQL.Snippet
callPlanToQuery (FunctionCall qi params arguments returnsScalar returnsSetOfScalar returnsCompositeAlias returnings) pgVer =
Expand Down
8 changes: 0 additions & 8 deletions src/PostgREST/Query/SqlFragment.hs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ module PostgREST.Query.SqlFragment
, fromQi
, limitOffsetF
, locationF
, mutRangeF
, orderF
, pgFmtColumn
, pgFmtFilter
Expand Down Expand Up @@ -513,13 +512,6 @@ currentSettingF setting =
-- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15
"nullif(current_setting('" <> setting <> "', true), '')"

mutRangeF :: QualifiedIdentifier -> [FieldName] -> (SQL.Snippet, SQL.Snippet)
mutRangeF mainQi rangeId =
(
intercalateSnippet " AND " $ (\col -> pgFmtColumn mainQi col <> " = " <> pgFmtColumn (QualifiedIdentifier mempty "pgrst_affected_rows") col) <$> rangeId
, intercalateSnippet ", " (pgFmtColumn mainQi <$> rangeId)
)

orderF :: QualifiedIdentifier -> [CoercibleOrderTerm] -> SQL.Snippet
orderF _ [] = mempty
orderF qi ordts = "ORDER BY " <> intercalateSnippet ", " (pgFmtOrderTerm qi <$> ordts)
Expand Down
2 changes: 1 addition & 1 deletion src/PostgREST/SchemaCache/Table.hs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ data Table = Table
, tableDescription :: Maybe Text
-- TODO Find a better way to separate tables and views
, tableIsView :: Bool
-- The following fields identify what can be done on the table/view, they're not related to the privileges granted to it
-- The following fields identify what HTTP verbs can be executed on the table/view, they're not related to the privileges granted to it
, tableInsertable :: Bool
, tableUpdatable :: Bool
, tableDeletable :: Bool
Expand Down
Loading

0 comments on commit 307692c

Please sign in to comment.