break: remove limited updated/delete feature (#3907)

BREAKING CHANGE

As agreed on https://github.com/PostgREST/postgrest/issues/3013#issuecomment-1770186262,
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.
This commit is contained in:
Steve Chavez
2025-02-12 14:48:08 -05:00
committed by GitHub
parent 94f0edb61a
commit 307692c325
19 changed files with 20 additions and 549 deletions
+2
View File
@@ -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. - 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 - #3757, Remove support for `Prefer: params=single-object` - @joelonsql
+ This preference was deprecated in favor of Functions with an array of JSON objects + 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 ## [12.2.8] - 2025-02-10
-25
View File
@@ -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`. 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 .. raw:: html
<script type="text/javascript"> <script type="text/javascript">
-8
View File
@@ -205,14 +205,6 @@ Related to the HTTP request elements.
| | | specified in the ``select`` part of the query string. | | | | specified in the ``select`` part of the query string. |
| PGRST108 | | See :ref:`embed_filters`. | | 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. | | .. _pgrst111: | 500 | An invalid ``response.headers`` was set. |
| | | See :ref:`guc_resp_hdrs`. | | | | See :ref:`guc_resp_hdrs`. |
| PGRST111 | | | | PGRST111 | | |
-1
View File
@@ -230,7 +230,6 @@ test-suite spec
Feature.Query.ErrorSpec Feature.Query.ErrorSpec
Feature.Query.InsertSpec Feature.Query.InsertSpec
Feature.Query.JsonOperatorSpec Feature.Query.JsonOperatorSpec
Feature.Query.LimitedMutationSpec
Feature.Query.MultipleSchemaSpec Feature.Query.MultipleSchemaSpec
Feature.Query.NullsStripSpec Feature.Query.NullsStripSpec
Feature.Query.PgSafeUpdateSpec Feature.Query.PgSafeUpdateSpec
+1 -2
View File
@@ -218,9 +218,8 @@ getSchema AppConfig{configDbSchemas} hdrs method = do
lookupHeader = flip lookup hdrs lookupHeader = flip lookup hdrs
getRanges :: ByteString -> QueryParams -> RequestHeaders -> Either ApiRequestError (NonnegRange, HM.HashMap Text NonnegRange) 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) | 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 | method == "PUT" && topLevelRange /= allRange = Left PutLimitNotAllowedError
| otherwise = Right (topLevelRange, ranges) | otherwise = Right (topLevelRange, ranges)
where where
-2
View File
@@ -79,7 +79,6 @@ data ApiRequestError
| InvalidPreferences [ByteString] | InvalidPreferences [ByteString]
| InvalidRange RangeError | InvalidRange RangeError
| InvalidRpcMethod ByteString | InvalidRpcMethod ByteString
| LimitNoOrderError
| NotFound | NotFound
| NoRelBetween Text Text (Maybe Text) Text RelationshipsMap | NoRelBetween Text Text (Maybe Text) Text RelationshipsMap
| NoRpc Text Text [Text] MediaType Bool [QualifiedIdentifier] [Routine] | NoRpc Text Text [Text] MediaType Bool [QualifiedIdentifier] [Routine]
@@ -94,7 +93,6 @@ data ApiRequestError
| ColumnNotFound Text Text | ColumnNotFound Text Text
| GucHeadersError | GucHeadersError
| GucStatusError | GucStatusError
| OffLimitsChangesError Int64 Integer
| PutMatchingPkError | PutMatchingPkError
| SingularityError Integer | SingularityError Integer
| PGRSTParseError RaiseError | PGRSTParseError RaiseError
+2 -15
View File
@@ -84,11 +84,9 @@ instance PgrstError ApiRequestError where
status UnacceptableFilter{} = HTTP.status400 status UnacceptableFilter{} = HTTP.status400
status UnacceptableSchema{} = HTTP.status406 status UnacceptableSchema{} = HTTP.status406
status UnsupportedMethod{} = HTTP.status405 status UnsupportedMethod{} = HTTP.status405
status LimitNoOrderError = HTTP.status400
status ColumnNotFound{} = HTTP.status400 status ColumnNotFound{} = HTTP.status400
status GucHeadersError = HTTP.status500 status GucHeadersError = HTTP.status500
status GucStatusError = HTTP.status500 status GucStatusError = HTTP.status500
status OffLimitsChangesError{} = HTTP.status400
status PutMatchingPkError = HTTP.status400 status PutMatchingPkError = HTTP.status400
status SingularityError{} = HTTP.status406 status SingularityError{} = HTTP.status406
status PGRSTParseError{} = HTTP.status500 status PGRSTParseError{} = HTTP.status500
@@ -140,15 +138,6 @@ instance JSON.ToJSON ApiRequestError where
Nothing Nothing
(Just $ JSON.String $ "Verify that '" <> resource <> "' is included in the 'select' query parameter.") (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 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 ApiRequestErrorCode11 "response.headers guc must be a JSON array composed of objects with a single key and a string value" Nothing Nothing
@@ -618,8 +607,8 @@ data ErrorCode
| ApiRequestErrorCode06 | ApiRequestErrorCode06
| ApiRequestErrorCode07 | ApiRequestErrorCode07
| ApiRequestErrorCode08 | ApiRequestErrorCode08
| ApiRequestErrorCode09 -- | ApiRequestErrorCode09 -- no longer used (used to be mapped to LimitNoOrderError)
| ApiRequestErrorCode10 -- | ApiRequestErrorCode10 -- no longer used (used to be mapped to OffLimitsChangesError)
| ApiRequestErrorCode11 | ApiRequestErrorCode11
-- | ApiRequestErrorCode13 -- no longer used (used to be mapped to BinaryFieldError) -- | ApiRequestErrorCode13 -- no longer used (used to be mapped to BinaryFieldError)
| ApiRequestErrorCode12 | ApiRequestErrorCode12
@@ -668,8 +657,6 @@ buildErrorCode code = case code of
ApiRequestErrorCode06 -> "PGRST106" ApiRequestErrorCode06 -> "PGRST106"
ApiRequestErrorCode07 -> "PGRST107" ApiRequestErrorCode07 -> "PGRST107"
ApiRequestErrorCode08 -> "PGRST108" ApiRequestErrorCode08 -> "PGRST108"
ApiRequestErrorCode09 -> "PGRST109"
ApiRequestErrorCode10 -> "PGRST110"
ApiRequestErrorCode11 -> "PGRST111" ApiRequestErrorCode11 -> "PGRST111"
ApiRequestErrorCode12 -> "PGRST112" ApiRequestErrorCode12 -> "PGRST112"
ApiRequestErrorCode14 -> "PGRST114" ApiRequestErrorCode14 -> "PGRST114"
+2 -3
View File
@@ -937,7 +937,7 @@ mutatePlan mutation qi ApiRequest{iPreferences=Preferences{..}, ..} SchemaCache{
MutationCreate -> MutationCreate ->
mapRight (\typedColumns -> Insert qi typedColumns body ((,) <$> preferResolution <*> Just confCols) [] returnings pkCols applyDefaults) typedColumnsOrError mapRight (\typedColumns -> Insert qi typedColumns body ((,) <$> preferResolution <*> Just confCols) [] returnings pkCols applyDefaults) typedColumnsOrError
MutationUpdate -> MutationUpdate ->
mapRight (\typedColumns -> Update qi typedColumns body combinedLogic iTopLevelRange rootOrder returnings applyDefaults) typedColumnsOrError mapRight (\typedColumns -> Update qi typedColumns body combinedLogic returnings applyDefaults) typedColumnsOrError
MutationSingleUpsert -> MutationSingleUpsert ->
if null qsLogic && if null qsLogic &&
qsFilterFields == S.fromList pkCols && qsFilterFields == S.fromList pkCols &&
@@ -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 then mapRight (\typedColumns -> Insert qi typedColumns body (Just (MergeDuplicates, pkCols)) combinedLogic returnings mempty False) typedColumnsOrError
else else
Left InvalidFilters Left InvalidFilters
MutationDelete -> Right $ Delete qi combinedLogic iTopLevelRange rootOrder returnings MutationDelete -> Right $ Delete qi combinedLogic returnings
where where
ctx = ResolverContext dbTables dbRepresentations qi "json" ctx = ResolverContext dbTables dbRepresentations qi "json"
confCols = fromMaybe pkCols qsOnConflict confCols = fromMaybe pkCols qsOnConflict
@@ -960,7 +960,6 @@ mutatePlan mutation qi ApiRequest{iPreferences=Preferences{..}, ..} SchemaCache{
tbl = HM.lookup qi dbTables tbl = HM.lookup qi dbTables
pkCols = maybe mempty tablePKCols tbl pkCols = maybe mempty tablePKCols tbl
logic = map (resolveLogicTree ctx . snd) qsLogic logic = map (resolveLogicTree ctx . snd) qsLogic
rootOrder = resolveOrder ctx <$> maybe [] snd (find (\(x, _) -> null x) qsOrder)
combinedLogic = foldr (addFilterToLogicForest . resolveFilter ctx) logic qsFiltersRoot combinedLogic = foldr (addFilterToLogicForest . resolveFilter ctx) logic qsFiltersRoot
body = payRaw <$> iPayload -- the body is assumed to be json at this stage(ApiRequest validates) body = payRaw <$> iPayload -- the body is assumed to be json at this stage(ApiRequest validates)
applyDefaults = preferMissing == Just ApplyDefaults applyDefaults = preferMissing == Just ApplyDefaults
+1 -7
View File
@@ -7,9 +7,7 @@ import qualified Data.ByteString.Lazy as LBS
import PostgREST.ApiRequest.Preferences (PreferResolution) import PostgREST.ApiRequest.Preferences (PreferResolution)
import PostgREST.Plan.Types (CoercibleField, import PostgREST.Plan.Types (CoercibleField,
CoercibleLogicTree, CoercibleLogicTree)
CoercibleOrderTerm)
import PostgREST.RangeQuery (NonnegRange)
import PostgREST.SchemaCache.Identifiers (FieldName, import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier) QualifiedIdentifier)
@@ -32,15 +30,11 @@ data MutatePlan
, updCols :: [CoercibleField] , updCols :: [CoercibleField]
, updBody :: Maybe LBS.ByteString , updBody :: Maybe LBS.ByteString
, where_ :: [CoercibleLogicTree] , where_ :: [CoercibleLogicTree]
, mutRange :: NonnegRange
, mutOrder :: [CoercibleOrderTerm]
, returning :: [FieldName] , returning :: [FieldName]
, applyDefs :: Bool , applyDefs :: Bool
} }
| Delete | Delete
{ in_ :: QualifiedIdentifier { in_ :: QualifiedIdentifier
, where_ :: [CoercibleLogicTree] , where_ :: [CoercibleLogicTree]
, mutRange :: NonnegRange
, mutOrder :: [CoercibleOrderTerm]
, returning :: [FieldName] , returning :: [FieldName]
} }
+3 -13
View File
@@ -25,9 +25,9 @@ import qualified PostgREST.AppState as AppState
import qualified PostgREST.Error as Error import qualified PostgREST.Error as Error
import qualified PostgREST.Query.QueryBuilder as QueryBuilder import qualified PostgREST.Query.QueryBuilder as QueryBuilder
import qualified PostgREST.Query.Statements as Statements import qualified PostgREST.Query.Statements as Statements
import qualified PostgREST.RangeQuery as RangeQuery
import qualified PostgREST.SchemaCache as SchemaCache import qualified PostgREST.SchemaCache as SchemaCache
import PostgREST.ApiRequest (ApiRequest (..), import PostgREST.ApiRequest (ApiRequest (..),
Mutation (..)) Mutation (..))
import PostgREST.ApiRequest.Preferences (PreferCount (..), import PostgREST.ApiRequest.Preferences (PreferCount (..),
@@ -135,11 +135,10 @@ actionQuery (DbCrud plan@MutateReadPlan{mrMutation=MutationCreate, ..}) conf api
optionalRollback conf apiReq optionalRollback conf apiReq
pure $ DbCrudResult plan resultSet 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 resultSet <- writeQuery mrReadPlan mrMutatePlan mrMedia mrHandler apiReq conf
failNotSingular mrMedia resultSet failNotSingular mrMedia resultSet
failExceedsMaxAffectedPref (preferMaxAffected,preferHandling) resultSet failExceedsMaxAffectedPref (preferMaxAffected,preferHandling) resultSet
failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet
optionalRollback conf apiReq optionalRollback conf apiReq
pure $ DbCrudResult plan resultSet pure $ DbCrudResult plan resultSet
@@ -149,11 +148,10 @@ actionQuery (DbCrud plan@MutateReadPlan{mrMutation=MutationSingleUpsert, ..}) co
optionalRollback conf apiReq optionalRollback conf apiReq
pure $ DbCrudResult plan resultSet 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 resultSet <- writeQuery mrReadPlan mrMutatePlan mrMedia mrHandler apiReq conf
failNotSingular mrMedia resultSet failNotSingular mrMedia resultSet
failExceedsMaxAffectedPref (preferMaxAffected,preferHandling) resultSet failExceedsMaxAffectedPref (preferMaxAffected,preferHandling) resultSet
failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet
optionalRollback conf apiReq optionalRollback conf apiReq
pure $ DbCrudResult plan resultSet pure $ DbCrudResult plan resultSet
@@ -260,14 +258,6 @@ failExceedsMaxAffectedPref (Just (PreferMaxAffected n), handling) RSStandard{rsQ
lift SQL.condemn lift SQL.condemn
throwError $ Error.ApiRequestError . ApiRequestTypes.MaxAffectedViolationError $ toInteger queryTotal 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 -- | Set a transaction to roll back if requested
optionalRollback :: AppConfig -> ApiRequest -> DbHandler () optionalRollback :: AppConfig -> ApiRequest -> DbHandler ()
optionalRollback AppConfig{..} ApiRequest{iPreferences=Preferences{..}} = do optionalRollback AppConfig{..} ApiRequest{iPreferences=Preferences{..}} = do
+8 -42
View File
@@ -40,7 +40,6 @@ import PostgREST.Plan.MutatePlan
import PostgREST.Plan.ReadPlan import PostgREST.Plan.ReadPlan
import PostgREST.Plan.Types import PostgREST.Plan.Types
import PostgREST.Query.SqlFragment import PostgREST.Query.SqlFragment
import PostgREST.RangeQuery (allRange)
import Protolude import Protolude
@@ -134,64 +133,31 @@ mutatePlanToQuery (Insert mainQi iCols body onConflict putConditions returnings
cols = intercalateSnippet ", " $ pgFmtIdent . cfName <$> iCols cols = intercalateSnippet ", " $ pgFmtIdent . cfName <$> iCols
mergeDups = case onConflict of {Just (MergeDuplicates,_) -> True; _ -> False;} 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 returnings applyDefaults)
mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings applyDefaults)
| null uCols = | null uCols =
-- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax -- 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= -- 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 -- the select has to be based on "returnings" to make computed overloaded functions not throw
"SELECT " <> emptyBodyReturnedColumns <> " FROM " <> fromQi mainQi <> " WHERE false" "SELECT " <> emptyBodyReturnedColumns <> " FROM " <> fromQi mainQi <> " WHERE false"
| range == allRange = | otherwise =
"UPDATE " <> mainTbl <> " SET " <> nonRangeCols <> " " <> "UPDATE " <> mainTbl <> " SET " <> cols <> " " <>
fromJsonBodyF body uCols False False applyDefaults <> fromJsonBodyF body uCols False False applyDefaults <>
whereLogic <> " " <> whereLogic <> " " <>
returningF mainQi returnings 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 where
whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest) whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)
mainTbl = fromQi mainQi mainTbl = fromQi mainQi
emptyBodyReturnedColumns = if null returnings then "NULL" else intercalateSnippet ", " (pgFmtColumn (QualifiedIdentifier mempty $ qiName mainQi) <$> returnings) 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) cols = 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
mutatePlanToQuery (Delete mainQi logicForest returnings) =
"DELETE FROM " <> fromQi mainQi <> " " <>
whereLogic <> " " <>
returningF mainQi returnings
where where
whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest) 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 :: CallPlan -> PgVersion -> SQL.Snippet
callPlanToQuery (FunctionCall qi params arguments returnsScalar returnsSetOfScalar returnsCompositeAlias returnings) pgVer = callPlanToQuery (FunctionCall qi params arguments returnsScalar returnsSetOfScalar returnsCompositeAlias returnings) pgVer =
-8
View File
@@ -13,7 +13,6 @@ module PostgREST.Query.SqlFragment
, fromQi , fromQi
, limitOffsetF , limitOffsetF
, locationF , locationF
, mutRangeF
, orderF , orderF
, pgFmtColumn , pgFmtColumn
, pgFmtFilter , pgFmtFilter
@@ -513,13 +512,6 @@ currentSettingF setting =
-- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15 -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15
"nullif(current_setting('" <> setting <> "', true), '')" "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 :: QualifiedIdentifier -> [CoercibleOrderTerm] -> SQL.Snippet
orderF _ [] = mempty orderF _ [] = mempty
orderF qi ordts = "ORDER BY " <> intercalateSnippet ", " (pgFmtOrderTerm qi <$> ordts) orderF qi ordts = "ORDER BY " <> intercalateSnippet ", " (pgFmtOrderTerm qi <$> ordts)
+1 -1
View File
@@ -27,7 +27,7 @@ data Table = Table
, tableDescription :: Maybe Text , tableDescription :: Maybe Text
-- TODO Find a better way to separate tables and views -- TODO Find a better way to separate tables and views
, tableIsView :: Bool , 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 , tableInsertable :: Bool
, tableUpdatable :: Bool , tableUpdatable :: Bool
, tableDeletable :: Bool , tableDeletable :: Bool
@@ -1,305 +0,0 @@
module Feature.Query.LimitedMutationSpec where
import Data.Aeson.QQ
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
tblDataBefore = [aesonQQ|[
{ "id": 1, "name": "item-1" }
, { "id": 2, "name": "item-2" }
, { "id": 3, "name": "item-3" }
]|]
spec :: SpecWith ((), Application)
spec = do
describe "limited delete" $ do
it "works with the limit and offset query params" $
baseTable "limited_delete_items" "id" tblDataBefore
`mutatesWith`
requestMutation methodDelete "/limited_delete_items?order=id&limit=1&offset=1" mempty mempty
`shouldMutateInto`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 3, "name": "item-3" }
]|]
it "works with the limit query param plus a filter" $
baseTable "limited_delete_items" "id" tblDataBefore
`mutatesWith`
requestMutation methodDelete "/limited_delete_items?order=id&limit=1&id=gt.1" mempty mempty
`shouldMutateInto`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 3, "name": "item-3" }
]|]
it "fails without an explicit order by" $
request methodDelete "/limited_delete_items?limit=1&offset=1"
mempty
mempty
`shouldRespondWith`
[json| {
"code":"PGRST109",
"hint": "Apply an 'order' using unique column(s)",
"details": null,
"message": "A 'limit' was applied without an explicit 'order'"
}|]
{ matchStatus = 400 }
it "fails when not ordering by a unique column" $
request methodDelete "/limited_delete_items_wnonuniq_view?order=static&limit=1"
mempty
mempty
`shouldRespondWith`
[json| {
"code":"PGRST110",
"hint": null,
"details":"Results contain 3 rows changed but the maximum number allowed is 1",
"message":"The maximum number of rows allowed to change was surpassed"
}|]
{ matchStatus = 400 }
it "works with views with an explicit order by unique col" $
baseTable "limited_delete_items_view" "id" tblDataBefore
`mutatesWith`
requestMutation methodDelete "/limited_delete_items_view?order=id&limit=1&offset=1" mempty mempty
`shouldMutateInto`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 3, "name": "item-3" }
]|]
it "works with views with an explicit order by composite pk" $
baseTable "limited_delete_items_cpk_view" "id" tblDataBefore
`mutatesWith`
requestMutation methodDelete "/limited_delete_items_cpk_view?order=id,name&limit=1&offset=1" mempty mempty
`shouldMutateInto`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 3, "name": "item-3" }
]|]
it "works on a table without a pk by ordering by 'ctid'" $
baseTable "limited_delete_items_no_pk" "id" tblDataBefore
`mutatesWith`
requestMutation methodDelete "/limited_delete_items_no_pk?order=ctid&limit=1&offset=1" mempty mempty
`shouldMutateInto`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 3, "name": "item-3" }
]|]
it "ignores the Range header" $ do
baseTable "limited_delete_items" "id" tblDataBefore
`mutatesWith`
requestMutation methodDelete "/limited_delete_items"
(rangeHdrs (ByteRangeFromTo 0 0)) mempty
`shouldMutateInto`
[json|[]|]
baseTable "limited_delete_items" "id" tblDataBefore
`mutatesWith`
requestMutation methodDelete "/limited_delete_items?id=gte.2"
(rangeHdrs (ByteRangeFromTo 0 0)) mempty
`shouldMutateInto`
[json|[ { "id": 1, "name": "item-1" } ]|]
it "ignores the Range header and does not do a limited delete" $
baseTable "limited_delete_items" "id" tblDataBefore
`mutatesWith`
requestMutation methodDelete "/limited_delete_items?order=id"
(rangeHdrs (ByteRangeFromTo 0 0)) mempty
`shouldMutateInto`
[json|[]|]
it "ignores the Range header and does not throw an invalid range error" $
baseTable "limited_delete_items" "id" tblDataBefore
`mutatesWith`
requestMutation methodDelete "/limited_delete_items?order=id&limit=1&offset=1"
(rangeHdrs (ByteRangeFromTo 0 0)) mempty
`shouldMutateInto`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 3, "name": "item-3" }
]|]
it "ignores the Range header but not the limit and offset params" $
baseTable "limited_delete_items" "id" tblDataBefore
`mutatesWith`
requestMutation methodDelete "/limited_delete_items?order=id&limit=2&offset=1"
(rangeHdrs (ByteRangeFromTo 1 1)) mempty
`shouldMutateInto`
[json|[
{ "id": 1, "name": "item-1" }
]|]
describe "limited update" $ do
it "works with the limit query param" $
baseTable "limited_update_items" "id" tblDataBefore
`mutatesWith`
requestMutation methodPatch "/limited_update_items?order=id&limit=2" mempty
[json| {"name": "updated-item"} |]
`shouldMutateInto`
[json|[
{ "id": 1, "name": "updated-item" }
, { "id": 2, "name": "updated-item" }
, { "id": 3, "name": "item-3" }
]|]
it "works with the limit query param plus a filter" $
baseTable "limited_update_items" "id" tblDataBefore
`mutatesWith`
requestMutation methodPatch "/limited_update_items?order=id&limit=1&id=gt.2" mempty
[json| {"name": "updated-item"} |]
`shouldMutateInto`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 2, "name": "item-2" }
, { "id": 3, "name": "updated-item" }
]|]
it "works with the limit and offset query params" $
baseTable "limited_update_items" "id" tblDataBefore
`mutatesWith`
requestMutation methodPatch "/limited_update_items?order=id&limit=1&offset=1" mempty
[json| {"name": "updated-item"} |]
`shouldMutateInto`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 2, "name": "updated-item" }
, { "id": 3, "name": "item-3" }
]|]
it "fails without an explicit order by" $
request methodPatch "/limited_update_items?limit=1&offset=1"
mempty
[json| {"name": "updated-item"} |]
`shouldRespondWith`
[json| {
"code":"PGRST109",
"hint": "Apply an 'order' using unique column(s)",
"details": null,
"message": "A 'limit' was applied without an explicit 'order'"
}|]
{ matchStatus = 400 }
it "fails when not ordering by a unique column" $
request methodPatch "/limited_update_items_wnonuniq_view?order=static&limit=1"
mempty
[json| {"name": "updated-item"} |]
`shouldRespondWith`
[json| {
"code":"PGRST110",
"hint": null,
"details":"Results contain 3 rows changed but the maximum number allowed is 1",
"message":"The maximum number of rows allowed to change was surpassed"
}|]
{ matchStatus = 400 }
it "works with views with an explicit order by unique col" $
baseTable "limited_update_items_view" "id" tblDataBefore
`mutatesWith`
requestMutation methodPatch "/limited_update_items_view?order=id&limit=1&offset=1" mempty
[json| {"name": "updated-item"} |]
`shouldMutateInto`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 2, "name": "updated-item" }
, { "id": 3, "name": "item-3" }
]|]
it "works with views with an explicit order by composite pk" $
baseTable "limited_update_items_cpk_view" "id" tblDataBefore
`mutatesWith`
requestMutation methodPatch "/limited_update_items_cpk_view?order=id,name&limit=1&offset=1" mempty
[json| {"name": "updated-item"} |]
`shouldMutateInto`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 2, "name": "updated-item" }
, { "id": 3, "name": "item-3" }
]|]
it "works on a table without a pk by ordering by 'ctid'" $
baseTable "limited_update_items_no_pk" "id" tblDataBefore
`mutatesWith`
requestMutation methodPatch "/limited_update_items_no_pk?order=ctid&limit=1" mempty
[json| {"name": "updated-item"} |]
`shouldMutateInto`
[json|[
{ "id": 1, "name": "updated-item" }
, { "id": 2, "name": "item-2" }
, { "id": 3, "name": "item-3" }
]|]
it "ignores the Range header" $ do
baseTable "limited_update_items" "id" tblDataBefore
`mutatesWith`
requestMutation methodPatch "/limited_update_items"
(rangeHdrs (ByteRangeFromTo 0 0))
[json| {"name": "updated-item"} |]
`shouldMutateInto`
[json|[
{ "id": 1, "name": "updated-item" }
, { "id": 2, "name": "updated-item" }
, { "id": 3, "name": "updated-item" }
]|]
baseTable "limited_update_items" "id" tblDataBefore
`mutatesWith`
requestMutation methodPatch "/limited_update_items?id=gte.2"
(rangeHdrs (ByteRangeFromTo 0 0))
[json| {"name": "updated-item"} |]
`shouldMutateInto`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 2, "name": "updated-item" }
, { "id": 3, "name": "updated-item" }
]|]
it "ignores the Range header and does not do a limited update" $
baseTable "limited_update_items" "id" tblDataBefore
`mutatesWith`
requestMutation methodPatch "/limited_update_items?order=id"
(rangeHdrs (ByteRangeFromTo 0 0))
[json| {"name": "updated-item"} |]
`shouldMutateInto`
[json|[
{ "id": 1, "name": "updated-item" }
, { "id": 2, "name": "updated-item" }
, { "id": 3, "name": "updated-item" }
]|]
it "ignores the Range header and does not throw an invalid range error" $
baseTable "limited_update_items" "id" tblDataBefore
`mutatesWith`
requestMutation methodPatch "/limited_update_items?order=id&limit=1&offset=1"
(rangeHdrs (ByteRangeFromTo 0 0))
[json| {"name": "updated-item"} |]
`shouldMutateInto`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 2, "name": "updated-item" }
, { "id": 3, "name": "item-3" }
]|]
it "ignores the Range header but not the limit and offset params" $
baseTable "limited_update_items" "id" tblDataBefore
`mutatesWith`
requestMutation methodPatch "/limited_update_items?order=id&limit=2&offset=1"
(rangeHdrs (ByteRangeFromTo 1 1))
[json| {"name": "updated-item"} |]
`shouldMutateInto`
[json|[
{ "id": 1, "name": "item-1" }
, { "id": 2, "name": "updated-item" }
, { "id": 3, "name": "updated-item" }
]|]
-12
View File
@@ -361,18 +361,6 @@ spec = do
, matchHeaders = ["Preference-Applied" <:> "missing=default, return=representation"] , matchHeaders = ["Preference-Applied" <:> "missing=default, return=representation"]
} }
it "updates with limit/offset using table default values(field-with_sep) when json keys are undefined" $ do
request methodPatch "/complex_items?select=id,name&columns=name,field-with_sep&limit=1&offset=2&order=id"
[("Prefer", "return=representation"), ("Prefer", "missing=default")]
[json|{"name": "Tres"}|]
`shouldRespondWith`
[json|[
{"id":3,"name":"Tres"}
]|]
{ matchStatus = 200
, matchHeaders = ["Preference-Applied" <:> "missing=default, return=representation"]
}
it "updates table default values(field-with_sep) when json keys are undefined" $ do it "updates table default values(field-with_sep) when json keys are undefined" $ do
request methodPatch "/complex_items?id=eq.3&columns=name,field-with_sep" request methodPatch "/complex_items?id=eq.3&columns=name,field-with_sep"
[("Prefer", "return=representation"), ("Prefer", "missing=default")] [("Prefer", "return=representation"), ("Prefer", "missing=default")]
-4
View File
@@ -47,7 +47,6 @@ import qualified Feature.Query.EmbedInnerJoinSpec
import qualified Feature.Query.ErrorSpec import qualified Feature.Query.ErrorSpec
import qualified Feature.Query.InsertSpec import qualified Feature.Query.InsertSpec
import qualified Feature.Query.JsonOperatorSpec import qualified Feature.Query.JsonOperatorSpec
import qualified Feature.Query.LimitedMutationSpec
import qualified Feature.Query.MultipleSchemaSpec import qualified Feature.Query.MultipleSchemaSpec
import qualified Feature.Query.NullsStripSpec import qualified Feature.Query.NullsStripSpec
import qualified Feature.Query.PgSafeUpdateSpec import qualified Feature.Query.PgSafeUpdateSpec
@@ -269,9 +268,6 @@ main = do
before forceRollbackApp $ before forceRollbackApp $
describe "Feature.RollbackForcedSpec" Feature.RollbackSpec.forced describe "Feature.RollbackForcedSpec" Feature.RollbackSpec.forced
before withApp $
describe "Feature.Query.LimitedMutationSpec" Feature.Query.LimitedMutationSpec.spec
-- This test runs with a pre request to enable the pg-safeupdate library per-session. -- This test runs with a pre request to enable the pg-safeupdate library per-session.
-- This needs to run last, because once pg safe update is loaded, it can't be unloaded again. -- This needs to run last, because once pg safe update is loaded, it can't be unloaded again.
before pgSafeUpdateApp $ before pgSafeUpdateApp $
-33
View File
@@ -24,7 +24,6 @@ import Text.Regex.TDFA ((=~))
import Network.HTTP.Types import Network.HTTP.Types
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Text.Heredoc import Text.Heredoc
import Data.String (String) import Data.String (String)
@@ -299,38 +298,6 @@ isErrorFormat s =
keys = maybe S.empty M.keysSet obj keys = maybe S.empty M.keysSet obj
validKeys = S.fromList ["message", "details", "hint", "code"] validKeys = S.fromList ["message", "details", "hint", "code"]
-- | Follows these steps to verify if the table data changed in the db:
-- * Verifies the table data in the db before the change
-- * Does the mutation
-- * Verifies that the table data changed in the db
-- * Resets the table with the original data
shouldMutateInto :: MutationCheck -> ResponseMatcher -> WaiExpectation ()
shouldMutateInto (MutationCheck (BaseTable tblName tblOrd dataBefore) mutation) dataAfter = do
get ("/" <> tblName) `shouldRespondWith` [json|#{dataBefore}|]
mutation
get ("/" <> tblName <> "?order=" <> tblOrd) `shouldRespondWith` dataAfter
request methodPost "/rpc/reset_table"
[("Prefer", "tx=commit")]
[json| {"tbl_name": #{decodeUtf8 tblName}, "tbl_data": #{dataBefore}} |]
`shouldRespondWith` 204
-- | How the base table data will change using the requested mutation
mutatesWith :: BaseTable -> WaiExpectation () -> MutationCheck
mutatesWith = MutationCheck
-- | The original table data before it is modified.
-- The column order is needed for an accurate comparison after the mutation
baseTable :: ByteString -> ByteString -> JSON.Value -> BaseTable
baseTable = BaseTable
-- | The mutation (update/delete) that will be applied to the base table
requestMutation :: Method -> ByteString -> [Header] -> BL.ByteString -> WaiExpectation ()
requestMutation method path headers body =
request method path (("Prefer", "tx=commit") : headers) body `shouldRespondWith` "" { matchStatus = 204 }
data BaseTable = BaseTable ByteString ByteString JSON.Value
data MutationCheck = MutationCheck BaseTable (WaiExpectation ())
planCost :: SResponse -> Float planCost :: SResponse -> Float
planCost resp = planCost resp =
let res = simpleBody resp ^? nth 0 . key "Plan" . key "Total Cost" in let res = simpleBody resp ^? nth 0 . key "Plan" . key "Total Cost" in
-18
View File
@@ -741,24 +741,6 @@ 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_update_items CASCADE;
INSERT INTO test.limited_update_items VALUES (1, 'item-1'), (2, 'item-2'), (3, 'item-3');
TRUNCATE TABLE test.limited_update_items_cpk CASCADE;
INSERT INTO test.limited_update_items_cpk VALUES (1, 'item-1'), (2, 'item-2'), (3, 'item-3');
TRUNCATE TABLE test.limited_update_items_no_pk CASCADE;
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');
TRUNCATE TABLE test.xmltest CASCADE; TRUNCATE TABLE test.xmltest CASCADE;
INSERT INTO test.xmltest VALUES INSERT INTO test.xmltest VALUES
(1, '<myxml>foo</myxml>'), (1, '<myxml>foo</myxml>'),
-50
View File
@@ -2512,56 +2512,6 @@ CREATE AGGREGATE test.unsupported_agg (*) (
STYPE = int STYPE = int
); );
create table limited_update_items(
id int primary key
, name text
);
create table limited_update_items_cpk(
id int
, name text
, primary key (id, name)
);
create table limited_update_items_no_pk(
id int
, name text
);
create view limited_update_items_view as
select * from limited_update_items;
create view limited_update_items_wnonuniq_view as
select *, 'static'::text as static from limited_update_items;
create view limited_update_items_cpk_view as
select * from limited_update_items_cpk;
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 view limited_delete_items_wnonuniq_view as
select *, 'static'::text as static from limited_delete_items;
create view limited_delete_items_cpk_view as
select * from limited_delete_items_cpk;
create function reset_table(tbl_name text default '', tbl_data json default '[]') returns void as $_$ begin create function reset_table(tbl_name text default '', tbl_data json default '[]') returns void as $_$ begin
execute format( execute format(
$$ $$