diff --git a/CHANGELOG.md b/CHANGELOG.md index 562cb2463..089e4b626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - #1415, Add support for user defined socket permission via `server-unix-socket-mode` config option - @Dansvidania - #1383, Add support for HEAD request - @steve-chavez - #1378, Add support for `Prefer: count=planned` and `Prefer: count=estimated` on GET /table - @steve-chavez +- #1327, Add support for optional query parameter `on_conflict` to upsert with specified keys for POST - @ykst ### Fixed diff --git a/src/PostgREST/ApiRequest.hs b/src/PostgREST/ApiRequest.hs index ce45e3df3..f8d909cb4 100644 --- a/src/PostgREST/ApiRequest.hs +++ b/src/PostgREST/ApiRequest.hs @@ -87,6 +87,7 @@ data ApiRequest = ApiRequest { , iFilters :: [(Text, Text)] -- ^ Filters on the result ("id", "eq.10") , iLogic :: [(Text, Text)] -- ^ &and and &or parameters used for complex boolean logic , iSelect :: Maybe Text -- ^ &select parameter used to shape the response + , iOnConflict :: Maybe Text -- ^ &on_conflict parameter used to upsert on specific unique keys , iColumns :: Maybe Text -- ^ &columns parameter used to shape the payload , iOrder :: [(Text, Text)] -- ^ &order parameters for each level , iCanonicalQS :: ByteString -- ^ Alphabetized (canonical) request query string for response URLs @@ -122,6 +123,7 @@ userApiRequest schema rootSpec req reqBody , iFilters = filters , iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ] , iSelect = toS <$> join (lookup "select" qParams) + , iOnConflict = toS <$> join (lookup "on_conflict" qParams) , iColumns = columns , iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ] , iCanonicalQS = toS $ urlEncodeVars diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index b9ca35544..c1ae68b9b 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -169,7 +169,7 @@ app dbStructure proc cols conf apiRequest = else Nothing , Just $ contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing - , if null pkCols + , if null pkCols && isNothing (iOnConflict apiRequest) then Nothing else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest ] diff --git a/src/PostgREST/DbRequestBuilder.hs b/src/PostgREST/DbRequestBuilder.hs index 90f3e5897..8503d1136 100644 --- a/src/PostgREST/DbRequestBuilder.hs +++ b/src/PostgREST/DbRequestBuilder.hs @@ -324,7 +324,11 @@ addProperty f (targetNodeName:remainingPath, a) (Node rn forest) = mutateRequest :: Schema -> TableName -> ApiRequest -> S.Set FieldName -> [FieldName] -> ReadRequest -> Either Response MutateRequest mutateRequest schema tName apiRequest cols pkCols readReq = mapLeft errorResponseFor $ case action of - ActionCreate -> Right $ Insert qi cols ((,) <$> iPreferResolution apiRequest <*> Just pkCols) [] returnings + ActionCreate -> do + confCols <- case iOnConflict apiRequest of + Nothing -> pure pkCols + Just param -> pRequestOnConflict param + pure $ Insert qi cols ((,) <$> iPreferResolution apiRequest <*> Just confCols) [] returnings ActionUpdate -> Update qi cols <$> combinedLogic <*> pure returnings ActionSingleUpsert -> (\flts -> diff --git a/src/PostgREST/OpenAPI.hs b/src/PostgREST/OpenAPI.hs index 1c3c8759a..22520a8d6 100644 --- a/src/PostgREST/OpenAPI.hs +++ b/src/PostgREST/OpenAPI.hs @@ -133,6 +133,13 @@ makeParamDefs ti = & schema .~ ParamOther ((mempty :: ParamOtherSchema) & in_ .~ ParamQuery & type_ ?~ SwaggerString)) + , ("on_conflict", (mempty :: Param) + & name .~ "on_conflict" + & description ?~ "On Conflict" + & required ?~ False + & schema .~ ParamOther ((mempty :: ParamOtherSchema) + & in_ .~ ParamQuery + & type_ ?~ SwaggerString)) , ("order", (mempty :: Param) & name .~ "order" & description ?~ "Ordering" diff --git a/src/PostgREST/Parsers.hs b/src/PostgREST/Parsers.hs index 6f0cb65cf..b1a3ad0f6 100644 --- a/src/PostgREST/Parsers.hs +++ b/src/PostgREST/Parsers.hs @@ -30,6 +30,10 @@ pRequestSelect :: Text -> Either ApiRequestError [Tree SelectItem] pRequestSelect selStr = mapError $ parse pFieldForest ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr) +pRequestOnConflict :: Text -> Either ApiRequestError [FieldName] +pRequestOnConflict oncStr = + mapError $ parse pColumns ("failed to parse on_conflict parameter (" <> toS oncStr <> ")") (toS oncStr) + pRequestFilter :: (Text, Text) -> Either ApiRequestError (EmbedPath, Filter) pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper) where diff --git a/test/Feature/UpsertSpec.hs b/test/Feature/UpsertSpec.hs index 859e14b5a..b5d2c5f6f 100644 --- a/test/Feature/UpsertSpec.hs +++ b/test/Feature/UpsertSpec.hs @@ -49,6 +49,31 @@ spec = [json|[]|] `shouldRespondWith` [json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] } + it "INSERTs and UPDATEs rows on single unique key conflict" $ + request methodPost "/single_unique?on_conflict=unique_key" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")] + [json| [ + { "unique_key": 1, "value": "B" }, + { "unique_key": 2, "value": "C" } + ]|] `shouldRespondWith` [json| [ + { "unique_key": 1, "value": "B" }, + { "unique_key": 2, "value": "C" } + ]|] + { matchStatus = 201 + , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson] + } + + it "INSERTs and UPDATEs rows on compound unique keys conflict" $ + request methodPost "/compound_unique?on_conflict=key1,key2" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")] + [json| [ + { "key1": 1, "key2": 1, "value": "B" }, + { "key1": 1, "key2": 2, "value": "C" } + ]|] `shouldRespondWith` [json| [ + { "key1": 1, "key2": 1, "value": "B" }, + { "key1": 1, "key2": 2, "value": "C" } + ]|] + { matchStatus = 201 + , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson] + } context "when Prefer: resolution=ignore-duplicates is specified" $ do it "INSERTs and ignores rows on pk conflict" $ @@ -75,6 +100,32 @@ spec = , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson] } + it "INSERTs and ignores rows on single unique key conflict" $ + request methodPost "/single_unique?on_conflict=unique_key" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")] + [json| [ + { "unique_key": 1, "value": "B" }, + { "unique_key": 2, "value": "C" }, + { "unique_key": 3, "value": "D" } + ]|] `shouldRespondWith` [json| [ + { "unique_key": 3, "value": "D" } + ]|] + { matchStatus = 201 + , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson] + } + + it "INSERTs and UPDATEs rows on compound unique keys conflict" $ + request methodPost "/compound_unique?on_conflict=key1,key2" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")] + [json| [ + { "key1": 1, "key2": 1, "value": "B" }, + { "key1": 1, "key2": 2, "value": "C" }, + { "key1": 1, "key2": 3, "value": "D" } + ]|] `shouldRespondWith` [json| [ + { "key1": 1, "key2": 3, "value": "D" } + ]|] + { matchStatus = 201 + , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson] + } + it "succeeds if the table has only PK cols and no other cols" $ do request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")] [json|[ { "id": 1 }, { "id": 2 }, { "id": 3} ]|] diff --git a/test/fixtures/data.sql b/test/fixtures/data.sql index 0cfa282e4..6881631f2 100644 --- a/test/fixtures/data.sql +++ b/test/fixtures/data.sql @@ -344,6 +344,12 @@ INSERT INTO employees VALUES TRUNCATE TABLE tiobe_pls CASCADE; INSERT INTO tiobe_pls VALUES ('Java', 1), ('C', 2), ('Python', 4); +TRUNCATE TABLE single_unique CASCADE; +INSERT INTO single_unique (unique_key, value) VALUES (1, 'A'); + +TRUNCATE TABLE compound_unique CASCADE; +INSERT INTO compound_unique (key1, key2, value) VALUES (1, 1, 'A'); + TRUNCATE TABLE only_pk CASCADE; INSERT INTO only_pk VALUES (1), (2); diff --git a/test/fixtures/privileges.sql b/test/fixtures/privileges.sql index cff2f6da3..ec60c0626 100644 --- a/test/fixtures/privileges.sql +++ b/test/fixtures/privileges.sql @@ -66,6 +66,8 @@ GRANT ALL ON TABLE , perf_articles , employees , tiobe_pls + , single_unique + , compound_unique , only_pk , family_tree , managers diff --git a/test/fixtures/schema.sql b/test/fixtures/schema.sql index 1a7d7deb5..105e9fa06 100755 --- a/test/fixtures/schema.sql +++ b/test/fixtures/schema.sql @@ -1379,6 +1379,18 @@ create table test.tiobe_pls( rank smallint ); +create table test.single_unique( + unique_key integer unique not null, + value text +); + +create table test.compound_unique( + key1 integer not null, + key2 integer not null, + value text, + unique(key1, key2) +); + create table test.family_tree ( id text not null primary key, name text not null,