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:
@@ -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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 | | |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -94,7 +93,6 @@ data ApiRequestError
|
||||
| ColumnNotFound Text Text
|
||||
| GucHeadersError
|
||||
| GucStatusError
|
||||
| OffLimitsChangesError Int64 Integer
|
||||
| PutMatchingPkError
|
||||
| SingularityError Integer
|
||||
| PGRSTParseError RaiseError
|
||||
|
||||
+2
-15
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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 &&
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
+3
-13
@@ -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 (..),
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -13,7 +13,6 @@ module PostgREST.Query.SqlFragment
|
||||
, fromQi
|
||||
, limitOffsetF
|
||||
, locationF
|
||||
, mutRangeF
|
||||
, orderF
|
||||
, pgFmtColumn
|
||||
, pgFmtFilter
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
]|]
|
||||
@@ -361,18 +361,6 @@ spec = do
|
||||
, 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
|
||||
request methodPatch "/complex_items?id=eq.3&columns=name,field-with_sep"
|
||||
[("Prefer", "return=representation"), ("Prefer", "missing=default")]
|
||||
|
||||
@@ -47,7 +47,6 @@ import qualified Feature.Query.EmbedInnerJoinSpec
|
||||
import qualified Feature.Query.ErrorSpec
|
||||
import qualified Feature.Query.InsertSpec
|
||||
import qualified Feature.Query.JsonOperatorSpec
|
||||
import qualified Feature.Query.LimitedMutationSpec
|
||||
import qualified Feature.Query.MultipleSchemaSpec
|
||||
import qualified Feature.Query.NullsStripSpec
|
||||
import qualified Feature.Query.PgSafeUpdateSpec
|
||||
@@ -269,9 +268,6 @@ main = do
|
||||
before forceRollbackApp $
|
||||
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 needs to run last, because once pg safe update is loaded, it can't be unloaded again.
|
||||
before pgSafeUpdateApp $
|
||||
|
||||
@@ -24,7 +24,6 @@ import Text.Regex.TDFA ((=~))
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
import Text.Heredoc
|
||||
|
||||
import Data.String (String)
|
||||
@@ -299,38 +298,6 @@ isErrorFormat s =
|
||||
keys = maybe S.empty M.keysSet obj
|
||||
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 resp =
|
||||
let res = simpleBody resp ^? nth 0 . key "Plan" . key "Total Cost" in
|
||||
|
||||
Vendored
-18
@@ -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;
|
||||
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;
|
||||
INSERT INTO test.xmltest VALUES
|
||||
(1, '<myxml>foo</myxml>'),
|
||||
|
||||
Vendored
-50
@@ -2512,56 +2512,6 @@ CREATE AGGREGATE test.unsupported_agg (*) (
|
||||
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
|
||||
execute format(
|
||||
$$
|
||||
|
||||
Reference in New Issue
Block a user