Add UPSERT for POST with Prefer:resoultion=merge/ignore-duplicates

This commit is contained in:
steve-chavez
2018-02-21 07:33:54 -05:00
committed by Steve Chávez
parent 102392e4ab
commit 85b1dc0eb4
12 changed files with 137 additions and 20 deletions
+1
View File
@@ -132,6 +132,7 @@ Test-Suite spec
, Feature.AndOrParamsSpec
, Feature.RpcSpec
, Feature.NonexistentSchemaSpec
, Feature.UpsertSpec
, SpecHelper
, TestTypes
Build-Depends: aeson
+6 -1
View File
@@ -55,7 +55,7 @@ data Target = TargetIdent QualifiedIdentifier
deriving Eq
-- | How to return the inserted data
data PreferRepresentation = Full | HeadersOnly | None deriving Eq
--
{-|
Describes what the user wants to do. This data type is a
translation of the raw elements of an HTTP request into domain
@@ -80,6 +80,8 @@ data ApiRequest = ApiRequest {
, iPreferSingleObjectParameter :: Bool
-- | Whether the client wants a result count (slower)
, iPreferCount :: Bool
-- | Whether the client wants to UPSERT or ignore records on PK conflict
, iPreferResolution :: Maybe PreferResolution
-- | Filters on the result ("id", "eq.10")
, iFilters :: [(Text, Text)]
-- | &and and &or parameters used for complex boolean logic
@@ -114,6 +116,9 @@ userApiRequest schema req reqBody
, iPreferRepresentation = representation
, iPreferSingleObjectParameter = singleObject
, iPreferCount = hasPrefer "count=exact"
, iPreferResolution = if hasPrefer "resolution=merge-duplicates" then Just MergeDuplicates
else if hasPrefer "resolution=ignore-duplicates" then Just IgnoreDuplicates
else Nothing
, iFilters = filters
, iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ]
, iSelect = toS $ fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams
+2 -2
View File
@@ -278,7 +278,7 @@ app dbStructure proc conf apiRequest =
uri Nothing = ("http", host, port, "/")
uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)
uri' = uri proxy
encodeApi ti sd procs = encodeOpenAPI (concat $ M.elems procs) (toTableInfo ti) uri' sd (dbPrimaryKeys dbStructure)
encodeApi ti sd procs = encodeOpenAPI (concat $ M.elems procs) (toTableInfo ti) uri' sd allPrKeys
body <- encodeApi <$> H.query schema accessibleTables <*> H.query schema schemaDescription <*> H.query schema accessibleProcs
return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body
@@ -311,7 +311,7 @@ app dbStructure proc conf apiRequest =
readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) proc apiRequest
fldNames = fieldNames <$> readReq
readDbRequest = DbRead <$> readReq
mutateDbRequest = DbMutate <$> (mutateRequest apiRequest =<< fldNames)
mutateDbRequest = DbMutate <$> (mutateRequest apiRequest allPrKeys =<< fldNames)
selectQuery = requestToQuery schema False <$> readDbRequest
mutateQuery = requestToQuery schema False <$> mutateDbRequest
countQuery = requestToCountQuery schema <$> readDbRequest
+4
View File
@@ -19,6 +19,7 @@ module PostgREST.Config ( prettyVersion
, readOptions
, corsPolicy
, minimumPgVersion
, pgVersion95
, pgVersion96
, AppConfig (..)
)
@@ -231,3 +232,6 @@ minimumPgVersion = PgVersion 90400 "9.4"
pgVersion96 :: PgVersion
pgVersion96 = PgVersion 90600 "9.6"
pgVersion95 :: PgVersion
pgVersion95 = PgVersion 90500 "9.5"
+9 -8
View File
@@ -302,21 +302,22 @@ toSourceRelation mt r@(Relation t _ ft _ _ rt _ _)
| Just mt == (tableName <$> rt) = Just $ r {relLTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}
| otherwise = Nothing
mutateRequest :: ApiRequest -> [FieldName] -> Either Response MutateRequest
mutateRequest apiRequest fldNames = mapLeft apiRequestError $
mutateRequest :: ApiRequest -> [PrimaryKey] -> [FieldName] -> Either Response MutateRequest
mutateRequest apiRequest pks fldNames = mapLeft apiRequestError $
case action of
ActionCreate -> Right $ Insert rootTableName payload returnings
ActionCreate -> Right $ Insert rootTableName payload pkCols_ (iPreferResolution apiRequest) returnings
ActionUpdate -> Update rootTableName <$> pure payload <*> combinedLogic <*> pure returnings
ActionDelete -> Delete rootTableName <$> combinedLogic <*> pure returnings
_ -> Left UnsupportedVerb
_ -> Left UnsupportedVerb
where
action = iAction apiRequest
payload = fromJust $ iPayload apiRequest
rootTableName = -- TODO: Make it safe
let target = iTarget apiRequest in
case target of
(TargetIdent (QualifiedIdentifier _ t) ) -> t
(schema, rootTableName) = -- TODO: Make it safe
case iTarget apiRequest of
TargetIdent (QualifiedIdentifier s t) -> (s, t)
_ -> undefined
pkCols_ = pkName <$> filter (filterPk schema rootTableName) pks
filterPk sc table pk = sc == (tableSchema . pkTable) pk && table == (tableName . pkTable) pk
returnings = if iPreferRepresentation apiRequest == None then [] else fldNames
filters = map snd <$> mapM pRequestFilter mutateFilters
logic = map snd <$> mapM pRequestLogicTree logicFilters
+8 -2
View File
@@ -275,7 +275,7 @@ requestToQuery schema isParent (DbRead (Node (Select colSelects tbls logicForest
--getQueryParts is not total but requestToQuery is called only after addJoinConditions which ensures the only
--posible relations are Child Parent Many
getQueryParts _ _ = undefined
requestToQuery schema _ (DbMutate (Insert mainTbl p@(PayloadJSON _ pType keys) returnings)) =
requestToQuery schema _ (DbMutate (Insert mainTbl p@(PayloadJSON _ pType keys) _pkCols _onConflict returnings)) =
unwords [
("WITH " <> ignoredBody) `emptyOnFalse` not payloadIsEmpty,
"INSERT INTO ", fromQi qi, if payloadIsEmpty then " " else "(" <> cols <> ") ",
@@ -286,7 +286,13 @@ requestToQuery schema _ (DbMutate (Insert mainTbl p@(PayloadJSON _ pType keys) r
"SELECT " <> cols <> " FROM ",
case pType of
PJObject -> "json_populate_record"
PJArray _ -> "json_populate_recordset", "(null::", fromQi qi, ", $1) "],
PJArray _ -> "json_populate_recordset", "(null::", fromQi qi, ", $1)"],
case _onConflict of
Just IgnoreDuplicates -> "ON CONFLICT(" <> intercalate ", " _pkCols <> ") DO NOTHING "
Just MergeDuplicates -> "ON CONFLICT(" <> intercalate ", " _pkCols <> ") DO UPDATE SET " <>
intercalate ", " (pgFmtIdent <> const " = EXCLUDED." <> pgFmtIdent <$> S.toList (keys `S.difference` S.fromList _pkCols))
Nothing -> "",
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings
]
where
+3 -1
View File
@@ -25,6 +25,8 @@ data ApiRequestError = ActionInappropriate
| UnsupportedVerb
deriving (Show, Eq)
data PreferResolution = MergeDuplicates | IgnoreDuplicates deriving (Eq, Show)
data DbStructure = DbStructure {
dbTables :: [Table]
, dbColumns :: [Column]
@@ -262,7 +264,7 @@ type EmbedPath = [Text]
data Filter = Filter { field::Field, opExpr::OpExpr } deriving (Show, Eq)
data ReadQuery = Select { select::[SelectItem], from::[TableName], where_::[LogicTree], order::Maybe [OrderTerm], range_::NonnegRange } deriving (Show, Eq)
data MutateQuery = Insert { in_::TableName, qPayload::PayloadJSON, returning::[FieldName] }
data MutateQuery = Insert { in_::TableName, qPayload::PayloadJSON, pkCols::[Text], onConflict:: Maybe PreferResolution, returning::[FieldName] }
| Delete { in_::TableName, where_::[LogicTree], returning::[FieldName] }
| Update { in_::TableName, qPayload::PayloadJSON, where_::[LogicTree], returning::[FieldName] } deriving (Show, Eq)
type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe RelationDetail))
+73
View File
@@ -0,0 +1,73 @@
module Feature.UpsertSpec where
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Network.HTTP.Types
import SpecHelper
import Network.Wai (Application)
import Protolude hiding (get)
spec :: SpecWith Application
spec =
describe "UPSERT" $
context "POST with Prefer headers" $ do
context "when Prefer: resolution=merge-duplicates is specified" $ do
it "does upsert on pk conflict" $
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json| [
{ "name": "Javascript", "rank": 6 },
{ "name": "Java", "rank": 5 }
]|] `shouldRespondWith` [json| [
{ "name": "Javascript", "rank": 6 },
{ "name": "Java", "rank": 5 }
]|]
{ matchStatus = 201
, matchHeaders = [matchContentTypeJson]
}
it "does upsert on composite pk conflict" $
request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json| [
{ "first_name": "Frances M.", "last_name": "Roe", "salary": "30000" },
{ "first_name": "Peter S.", "last_name": "Yang", "salary": 42000 }
]|] `shouldRespondWith` [json| [
{ "first_name": "Frances M.", "last_name": "Roe", "salary": "$30,000.00", "company": "One-Up Realty", "occupation": "Author" },
{ "first_name": "Peter S.", "last_name": "Yang", "salary": "$42,000.00", "company": null, "occupation": null }
]|]
{ matchStatus = 201
, matchHeaders = [matchContentTypeJson]
}
context "when Prefer: resolution=ignore-duplicates is specified" $ do
it "ignores records on pk conflict" $ do
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json|[
{ "name": "PHP", "rank": 9 },
{ "name": "Python", "rank": 10 }
]|] `shouldRespondWith` [json|[
{ "name": "PHP", "rank": 9 }
]|]
{ matchStatus = 201
, matchHeaders = [matchContentTypeJson]
}
get "/tiobe_pls?rank=gte.9" `shouldRespondWith`
[json| [{ "name": "PHP", "rank": 9 }] |]
{ matchHeaders = [matchContentTypeJson] }
it "ignores records on composite pk conflict" $ do
request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json|[
{ "first_name": "Daniel B.", "last_name": "Lyon", "salary": "72000", "company": null, "occupation": null },
{ "first_name": "Sara M.", "last_name": "Torpey", "salary": 60000, "company": "Burstein-Applebee", "occupation": "Soil scientist" }
]|] `shouldRespondWith` [json|[
{ "first_name": "Sara M.", "last_name": "Torpey", "salary": "$60,000.00", "company": "Burstein-Applebee", "occupation": "Soil scientist" }
]|]
{ matchStatus = 201
, matchHeaders = [matchContentTypeJson]
}
get "/employees?first_name=eq.Daniel B.&last_name=eq.Lyon" `shouldRespondWith`
[json| [{ "first_name": "Daniel B.", "last_name": "Lyon", "salary": "$36,000.00", "company": "Dubrow's Cafeteria", "occupation": "Packer" }] |]
{ matchHeaders = [matchContentTypeJson] }
+6 -3
View File
@@ -6,7 +6,7 @@ import SpecHelper
import qualified Hasql.Pool as P
import PostgREST.App (postgrest)
import PostgREST.Config (pgVersion96, configSettings)
import PostgREST.Config (pgVersion95, pgVersion96, configSettings)
import PostgREST.DbStructure (getDbStructure, getPgVersion, fillSessionWithSettings)
import PostgREST.Types (DbStructure(..))
import Data.Function (id)
@@ -32,6 +32,7 @@ import qualified Feature.AndOrParamsSpec
import qualified Feature.RpcSpec
import qualified Feature.NonexistentSchemaSpec
import qualified Feature.PgVersion96Spec
import qualified Feature.UpsertSpec
import Protolude
@@ -62,7 +63,9 @@ main = do
reset = P.use pool (fillSessionWithSettings (configSettings $ testCfg testDbConn)) >> resetDb testDbConn
actualPgVersion = pgVersion dbStructure
pg96spec | actualPgVersion >= pgVersion96 = [("Feature.PgVersion96Spec" , Feature.PgVersion96Spec.spec)]
upsertSpec | actualPgVersion >= pgVersion95 = [("Feature.UpsertSpec", Feature.UpsertSpec.spec)]
| otherwise = []
pg96spec | actualPgVersion >= pgVersion96 = [("Feature.PgVersion96Spec", Feature.PgVersion96Spec.spec)]
| otherwise = []
specs = uncurry describe <$> [
@@ -78,7 +81,7 @@ main = do
, ("Feature.StructureSpec" , Feature.StructureSpec.spec)
, ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec)
, ("Feature.NonexistentSchemaSpec" , Feature.NonexistentSchemaSpec.spec)
] ++ pg96spec
] ++ pg96spec ++ upsertSpec
hspec $ do
mapM_ (beforeAll_ reset . before withApp) specs
+9 -3
View File
@@ -334,6 +334,12 @@ INSERT INTO part VALUES (1), (2), (3), (4);
TRUNCATE TABLE being_part CASCADE;
INSERT INTO being_part VALUES (1,1), (2,1), (3,2), (4,3);
--
-- PostgreSQL database dump complete
--
TRUNCATE TABLE employees CASCADE;
INSERT INTO employees VALUES
('Frances M.', 'Roe', '24000', 'One-Up Realty', 'Author'),
('Daniel B.', 'Lyon', '36000', 'Dubrow''s Cafeteria', 'Packer'),
('Edwin S.', 'Smith', '48000', 'Pro Garden Management', 'Marine biologist');
TRUNCATE TABLE tiobe_pls CASCADE;
INSERT INTO tiobe_pls VALUES ('Java', 1), ('C', 2), ('Python', 4);
+2
View File
@@ -63,6 +63,8 @@ GRANT ALL ON TABLE
, part
, leak
, perf_articles
, employees
, tiobe_pls
TO postgrest_test_anonymous;
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
+14
View File
@@ -1357,3 +1357,17 @@ create table test.perf_articles(
id integer not null,
body text not null
);
create table test.employees(
first_name text,
last_name text,
salary money,
company text,
occupation text,
primary key(first_name, last_name)
);
create table test.tiobe_pls(
name text primary key,
rank smallint
);