Merge branch 'bulk-insert'

This commit is contained in:
Joe Nelson
2015-04-17 15:55:42 -07:00
5 changed files with 136 additions and 37 deletions
+1
View File
@@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Option to specify nulls first or last, eg `/people?order=age.desc.nullsfirst` - Option to specify nulls first or last, eg `/people?order=age.desc.nullsfirst`
- Filter nulls, `?col=is.null` and `?col=isnot.null` - Filter nulls, `?col=is.null` and `?col=isnot.null`
- Filter within jsonb, `?col->a->>b=eq.c` - Filter within jsonb, `?col->a->>b=eq.c`
- Accept CSV in post body for bulk inserts
### Fixed ### Fixed
- Allow NULL values in posts - Allow NULL values in posts
+3
View File
@@ -42,6 +42,7 @@ executable postgrest
, blaze-builder , blaze-builder
, vector , vector
, mtl , mtl
, cassava
Other-Modules: App Other-Modules: App
, Auth , Auth
, Config , Config
@@ -98,4 +99,6 @@ Test-Suite spec
, blaze-builder , blaze-builder
, vector , vector
, mtl , mtl
, cassava
, process , process
, heredoc
+64 -26
View File
@@ -2,26 +2,30 @@
module App (app, sqlError, isSqlError) where module App (app, sqlError, isSqlError) where
import Control.Monad (join) import Control.Monad (join)
import Control.Arrow ((***)) import Control.Arrow ((***), second)
import Control.Applicative import Control.Applicative
import Data.Text hiding (map) import Data.Text hiding (map)
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe, mapMaybe)
import Text.Regex.TDFA ((=~)) import Text.Regex.TDFA ((=~))
import Data.Ord (comparing) import Data.Ord (comparing)
import Data.Ranged.Ranges (emptyRange) import Data.Ranged.Ranges (emptyRange)
import Data.HashMap.Strict (keys, elems, filterWithKey, toList) import qualified Data.HashMap.Strict as M
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import Data.CaseInsensitive (original)
import Data.List (sortBy) import Data.List (sortBy)
import Data.Functor.Identity import Data.Functor.Identity
import qualified Data.Set as S import qualified Data.Set as S
import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Lazy as BL
import qualified Blaze.ByteString.Builder as BB
import qualified Data.Csv as CSV
import Network.HTTP.Types.Status import Network.HTTP.Types.Status
import Network.HTTP.Types.Header import Network.HTTP.Types.Header
import Network.HTTP.Types.URI (parseSimpleQuery) import Network.HTTP.Types.URI (parseSimpleQuery)
import Network.HTTP.Base (urlEncodeVars) import Network.HTTP.Base (urlEncodeVars)
import Network.Wai import Network.Wai
import Network.Wai.Internal (Response(..))
import Data.Aeson import Data.Aeson
import Data.Monoid import Data.Monoid
@@ -98,26 +102,40 @@ app v1schema reqBody req =
, (hLocation, "/postgrest/users?id=eq." <> cs (userId u)) , (hLocation, "/postgrest/users?id=eq." <> cs (userId u))
] "" ] ""
([table], "POST") -> ([table], "POST") -> do
handleJsonObj reqBody $ \obj -> do let qt = QualifiedTable schema (cs table)
let qt = QualifiedTable schema (cs table) echoRequested = lookup "Prefer" hdrs == Just "return=representation"
query = insertInto qt (map cs $ keys obj) (elems obj) parsed :: Either String (V.Vector Text, V.Vector (V.Vector Value))
echoRequested = lookup "Prefer" hdrs == Just "return=representation" parsed = if lookup "Content-Type" hdrs == Just "text/csv"
row <- H.maybeEx query then do
let (Identity insertedJson) = fromMaybe (Identity "{}" :: Identity Text) row rows <- CSV.decode CSV.NoHeader reqBody
Just inserted = decode (cs insertedJson) :: Maybe Object if V.null rows then Left "CSV requires header"
else Right (V.head rows, (V.map $ V.map $ parseCsvCell . cs) (V.tail rows))
primaryKeys <- map cs <$> primaryKeyColumns qt else eitherDecode reqBody >>= \val ->
let primaries = if Prelude.null primaryKeys case val of
then inserted Object obj -> Right . second V.singleton . V.unzip . V.fromList $
else filterWithKey (const . (`elem` primaryKeys)) inserted M.toList obj
let params = urlEncodeVars _ -> Left "Expecting single JSON object or CSV rows"
$ map (\t -> (cs $ fst t, cs (paramFilter $ snd t))) case parsed of
$ sortBy (comparing fst) $ toList primaries Left err -> return $ responseLBS status400 [] $
return $ responseLBS status201 encode . object $ [("message", String $ "Failed to parse JSON payload. " <> cs err)]
[ jsonH Right toBeInserted -> do
, (hLocation, "/" <> cs table <> "?" <> cs params) rows :: [Identity Text] <- H.listEx $ uncurry (insertInto qt) toBeInserted
] $ if echoRequested then cs insertedJson else "" let inserted :: [Object] = mapMaybe (decode . cs . runIdentity) rows
primaryKeys <- primaryKeyColumns qt
let responses = flip map inserted $ \obj -> do
let primaries =
if Prelude.null primaryKeys
then obj
else M.filterWithKey (const . (`elem` primaryKeys)) obj
let params = urlEncodeVars
$ map (\t -> (cs $ fst t, cs (paramFilter $ snd t)))
$ sortBy (comparing fst) $ M.toList primaries
responseLBS status201
[ jsonH
, (hLocation, "/" <> cs table <> "?" <> cs params)
] $ if echoRequested then encode obj else ""
return $ multipart status201 responses
([table], "PUT") -> ([table], "PUT") ->
handleJsonObj reqBody $ \obj -> do handleJsonObj reqBody $ \obj -> do
@@ -129,10 +147,10 @@ app v1schema reqBody req =
"You must speficy all and only primary keys as params" "You must speficy all and only primary keys as params"
else do else do
tableCols <- map (cs . colName) <$> columns qt tableCols <- map (cs . colName) <$> columns qt
let cols = map cs $ keys obj let cols = map cs $ M.keys obj
if S.fromList tableCols == S.fromList cols if S.fromList tableCols == S.fromList cols
then do then do
let vals = elems obj let vals = M.elems obj
H.unitEx $ iffNotT H.unitEx $ iffNotT
(whereT qq $ update qt cols vals) (whereT qq $ update qt cols vals)
(insertSelect qt cols vals) (insertSelect qt cols vals)
@@ -148,7 +166,7 @@ app v1schema reqBody req =
let qt = QualifiedTable schema (cs table) let qt = QualifiedTable schema (cs table)
H.unitEx H.unitEx
$ whereT qq $ whereT qq
$ update qt (map cs $ keys obj) (elems obj) $ update qt (map cs $ M.keys obj) (M.elems obj)
return $ responseLBS status204 [ jsonH ] "" return $ responseLBS status204 [ jsonH ] ""
([table], "DELETE") -> do ([table], "DELETE") -> do
@@ -227,6 +245,26 @@ handleJsonObj reqBody handler = do
jErr = encode . object $ jErr = encode . object $
[("message", String "Expecting a JSON object")] [("message", String "Expecting a JSON object")]
parseCsvCell :: BL.ByteString -> Value
parseCsvCell s = if s == "NULL" then Null else String $ cs s
multipart :: Status -> [Response] -> Response
multipart _ [] = responseLBS status204 [] ""
multipart _ [r] = r
multipart s rs =
responseLBS s [(hContentType, "multipart/mixed; boundary=\"postgrest_boundary\"")] $
BL.intercalate "\n--postgrest_boundary\n" (map renderResponseBody rs)
where
renderHeader :: Header -> BL.ByteString
renderHeader (k, v) = cs (original k) <> ": " <> cs v
renderResponseBody :: Response -> BL.ByteString
renderResponseBody (ResponseBuilder _ headers b) =
BL.intercalate "\n" (map renderHeader headers)
<> "\n\n" <> BB.toLazyByteString b
renderResponseBody _ = error
"Unable to create multipart response from non-ResponseBuilder"
data TableOptions = TableOptions { data TableOptions = TableOptions {
tblOptcolumns :: [Column] tblOptcolumns :: [Column]
+23 -11
View File
@@ -22,6 +22,7 @@ import Control.Monad (join)
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.List as L import qualified Data.List as L
import qualified Data.Vector as V
import Data.Scientific (isInteger, formatScientific, FPFormat(..)) import Data.Scientific (isInteger, formatScientific, FPFormat(..))
type PStmt = H.Stmt P.Postgres type PStmt = H.Stmt P.Postgres
@@ -108,16 +109,24 @@ returningStarT s = s { B.stmtTemplate = B.stmtTemplate s <> " RETURNING *" }
deleteFrom :: QualifiedTable -> PStmt deleteFrom :: QualifiedTable -> PStmt
deleteFrom t = B.Stmt ("delete from " <> fromQt t) empty True deleteFrom t = B.Stmt ("delete from " <> fromQt t) empty True
insertInto :: QualifiedTable -> [T.Text] -> [JSON.Value] -> PStmt insertInto :: QualifiedTable
insertInto t [] _ = B.Stmt -> V.Vector T.Text
("insert into " <> fromQt t <> " default values returning *") empty True -> V.Vector (V.Vector JSON.Value)
insertInto t cols vals = B.Stmt -> PStmt
("insert into " <> fromQt t <> " (" <> insertInto t cols vals
T.intercalate ", " (map pgFmtIdent cols) <> | V.null cols = B.Stmt ("insert into " <> fromQt t <> " default values returning *") empty True
") values (" | otherwise = B.Stmt
<> T.intercalate ", " (map insertableValue vals) ("insert into " <> fromQt t <> " (" <>
<> ") returning row_to_json(" <> fromQt t <> ".*)") T.intercalate ", " (V.toList $ V.map pgFmtIdent cols) <>
empty True ") values "
<> T.intercalate ", "
(V.toList $ V.map (\v -> "("
<> T.intercalate ", " (V.toList $ V.map insertableValue v)
<> ")"
) vals
)
<> " returning row_to_json(" <> fromQt t <> ".*)")
empty True
insertSelect :: QualifiedTable -> [T.Text] -> [JSON.Value] -> PStmt insertSelect :: QualifiedTable -> [T.Text] -> [JSON.Value] -> PStmt
insertSelect t [] _ = B.Stmt insertSelect t [] _ = B.Stmt
@@ -261,9 +270,12 @@ unquoted (JSON.Number n) =
unquoted (JSON.Bool b) = cs . show $ b unquoted (JSON.Bool b) = cs . show $ b
unquoted _ = "" unquoted _ = ""
insertableText :: T.Text -> T.Text
insertableText = (<> "::unknown") . pgFmtLit
insertableValue :: JSON.Value -> T.Text insertableValue :: JSON.Value -> T.Text
insertableValue JSON.Null = "null" insertableValue JSON.Null = "null"
insertableValue v = ((<> "::unknown") . pgFmtLit . unquoted) v insertableValue v = insertableText $ unquoted v
paramFilter :: JSON.Value -> T.Text paramFilter :: JSON.Value -> T.Text
paramFilter JSON.Null = "is.null" paramFilter JSON.Null = "is.null"
+45
View File
@@ -9,6 +9,7 @@ import SpecHelper
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import Data.Maybe (fromJust) import Data.Maybe (fromJust)
import Text.Heredoc
import Network.HTTP.Types.Header import Network.HTTP.Types.Header
import Network.HTTP.Types import Network.HTTP.Types
import Control.Monad (replicateM_) import Control.Monad (replicateM_)
@@ -93,6 +94,50 @@ spec = afterAll_ resetDb $ around withApp $ do
, matchHeaders = [] , matchHeaders = []
} }
describe "CSV insert" $ do
after_ (clearTable "menagerie") . context "disparate csv types" $
it "succeeds with multipart response" $ do
p <- request methodPost "/menagerie" [("Content-Type", "text/csv")]
[str|integer,double,varchar,boolean,date,money,enum
|13,3.14159,testing!,false,1900-01-01,$3.99,foo
|12,0.1,a string,true,1929-10-01,12,bar
|]
liftIO $ do
simpleBody p `shouldBe` "Content-Type: application/json\nLocation: /menagerie?integer=eq.13\n\n\n--postgrest_boundary\nContent-Type: application/json\nLocation: /menagerie?integer=eq.12\n\n"
simpleStatus p `shouldBe` created201
after_ (clearTable "no_pk") . context "requesting full representation" $ do
it "returns full details of inserted record" $
request methodPost "/no_pk"
[("Content-Type", "text/csv"), ("Prefer", "return=representation")]
"a,b\nbar,baz"
`shouldRespondWith` ResponseMatcher {
matchBody = Just [json| { "a":"bar", "b":"baz" } |]
, matchStatus = 201
, matchHeaders = ["Content-Type" <:> "application/json",
"Location" <:> "/no_pk?a=eq.bar&b=eq.baz"]
}
it "can post nulls" $
request methodPost "/no_pk"
[("Content-Type", "text/csv"), ("Prefer", "return=representation")]
"a,b\nNULL,foo"
`shouldRespondWith` ResponseMatcher {
matchBody = Just [json| { "a":null, "b":"foo" } |]
, matchStatus = 201
, matchHeaders = ["Content-Type" <:> "application/json",
"Location" <:> "/no_pk?a=is.null&b=eq.foo"]
}
after_ (clearTable "no_pk") . context "with wrong number of columns" $ do
it "fails for too few" $ do
p <- request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"
liftIO $ simpleStatus p `shouldBe` badRequest400
it "fails for too many" $ do
p <- request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz,bat,bad"
liftIO $ simpleStatus p `shouldBe` badRequest400
describe "Putting record" $ do describe "Putting record" $ do
context "to unkonwn uri" $ context "to unkonwn uri" $