From 330cc916458078e88d5401edecea3dbe3d660554 Mon Sep 17 00:00:00 2001 From: Joe Nelson Date: Sun, 13 Mar 2016 14:20:54 -0700 Subject: [PATCH 1/5] Protect unicode values in requests --- src/PostgREST/QueryBuilder.hs | 24 ++++++++++++------------ test/Feature/InsertSpec.hs | 33 ++++++++++++++++++++++++++++----- test/Feature/QuerySpec.hs | 6 +++++- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/PostgREST/QueryBuilder.hs b/src/PostgREST/QueryBuilder.hs index 87487ba32..75754cd6b 100644 --- a/src/PostgREST/QueryBuilder.hs +++ b/src/PostgREST/QueryBuilder.hs @@ -93,11 +93,11 @@ encodeUniformObjs = createReadStatement :: SqlQuery -> SqlQuery -> NonnegRange -> Bool -> Bool -> Bool -> H.Query () ResultsWithCount createReadStatement selectQuery countQuery range isSingle countTotal asCsv = - H.statement sql HE.unit decodeStandard True + H.statement (cs sql) HE.unit decodeStandard True where sql = [qc| WITH {sourceCTEName} AS ({selectQuery}) SELECT {cols} - FROM ( SELECT * FROM {sourceCTEName} {limitF range}) t |] + FROM ( SELECT * FROM {sourceCTEName} {limitF range}) t |] :: Text countResultF = if countTotal then "("<>countQuery<>")" else "null" cols = intercalate ", " [ countResultF <> " AS total_result_set", @@ -116,20 +116,20 @@ createWriteStatement :: QualifiedIdentifier -> SqlQuery -> SqlQuery -> Bool -> createWriteStatement _ _ _ _ _ _ _ (PayloadParseError _) = undefined createWriteStatement _ _ mutateQuery _ None _ _ (PayloadJSON (UniformObjects _)) = - H.statement sql encodeUniformObjs decodeStandardMay True + H.statement (cs sql) encodeUniformObjs decodeStandardMay True where sql = [qc| WITH {sourceCTEName} AS ({mutateQuery}) - SELECT '', 0, '', '' |] + SELECT '', 0, '', '' |] :: Text createWriteStatement qi _ mutateQuery isSingle HeadersOnly pKeys _ (PayloadJSON (UniformObjects _)) = - H.statement sql encodeUniformObjs decodeStandardMay True + H.statement (cs sql) encodeUniformObjs decodeStandardMay True where sql = [qc| WITH {sourceCTEName} AS ({mutateQuery} RETURNING {fromQi qi}.*) SELECT {cols} - FROM (SELECT 1 FROM {sourceCTEName}) t |] + FROM (SELECT 1 FROM {sourceCTEName}) t |] :: Text cols = intercalate ", " [ "'' AS total_result_set", "pg_catalog.count(t) AS page_total", @@ -139,12 +139,12 @@ createWriteStatement qi _ mutateQuery isSingle HeadersOnly createWriteStatement qi selectQuery mutateQuery isSingle Full pKeys asCsv (PayloadJSON (UniformObjects _)) = - H.statement sql encodeUniformObjs decodeStandardMay True + H.statement (cs sql) encodeUniformObjs decodeStandardMay True where sql = [qc| WITH {sourceCTEName} AS ({mutateQuery} RETURNING {fromQi qi}.*) SELECT {cols} - FROM ({selectQuery}) t |] + FROM ({selectQuery}) t |] :: Text cols = intercalate ", " [ "'' AS total_result_set", -- when updateing it does not make sense "pg_catalog.count(t) AS page_total", @@ -206,7 +206,7 @@ addJoinConditions schema (Node (query, (n, r)) forest) = type ProcResults = (Maybe Int64, Int64, JSON.Value) callProc :: QualifiedIdentifier -> JSON.Object -> NonnegRange -> Bool -> H.Query () (Maybe ProcResults) callProc qi params range countTotal = - H.statement sql HE.unit decodeProc True + H.statement (cs sql) HE.unit decodeProc True where sql = [qc| WITH t AS (select * {_callSql}) @@ -217,13 +217,13 @@ callProc qi params range countTotal = coalesce(array_agg(row_to_json(r)), '\{}') )::character varying FROM (select * from t {limitF range}) r; - |] + |] :: Text _args = intercalate "," $ map _assignment (HM.toList params) _assignment (n,v) = pgFmtIdent n <> ":=" <> insertableValue v - _callSql = [qc| from {fromQi qi}({_args}) |] :: BS.ByteString + _callSql = [qc| from {fromQi qi}({_args}) |] :: Text _countExpr = if countTotal then "(select pg_catalog.count(1) from t)" - else "null::bigint" :: BS.ByteString + else "null::bigint" :: Text decodeProc = HD.maybeRow procRow procRow = (,,) <$> HD.nullableValue HD.int8 <*> HD.value HD.int8 <*> HD.value HD.json diff --git a/test/Feature/InsertSpec.hs b/test/Feature/InsertSpec.hs index 7b3d41bde..c157b84f6 100644 --- a/test/Feature/InsertSpec.hs +++ b/test/Feature/InsertSpec.hs @@ -9,10 +9,11 @@ import SpecHelper import qualified Data.Aeson as JSON import Data.Maybe (fromJust) +import Data.Monoid ((<>)) import Text.Heredoc import Network.HTTP.Types.Header import Network.HTTP.Types -import Control.Monad (replicateM_) +import Control.Monad (replicateM_, void) import TestTypes(IncPK(..), CompoundPK(..)) import Network.Wai (Application) @@ -233,10 +234,21 @@ spec = 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 does not fail because the extra columns are ignored - -- 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 + + context "with unicode values" $ + it "succeeds and returns usable location header" $ do + let payload = [json| { "a":"圍棋", "b":"¥" } |] + p <- request methodPost "/no_pk" + [("Prefer", "return=representation")] + payload + liftIO $ do + simpleBody p `shouldBe` payload + simpleStatus p `shouldBe` created201 + + let Just location = lookup hLocation $ simpleHeaders p + r <- get location + liftIO $ simpleBody r `shouldBe` "["<>payload<>"]" + describe "Putting record" $ do @@ -387,6 +399,17 @@ spec = do , matchHeaders = [] } + context "with unicode values" $ + it "succeeds and returns values intact" $ do + void $ request methodPost "/no_pk" [] + [json| { "a":"patchme", "b":"patchme" } |] + let payload = [json| { "a":"圍棋", "b":"¥" } |] + p <- request methodPatch "/no_pk?a=eq.patchme&b=eq.patchme" + [("Prefer", "return=representation")] payload + liftIO $ do + simpleBody p `shouldBe` "["<>payload<>"]" + simpleStatus p `shouldBe` ok200 + describe "Row level permission" $ it "set user_id when inserting rows" $ do let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0" diff --git a/test/Feature/QuerySpec.hs b/test/Feature/QuerySpec.hs index 7e43f465d..0d0f71a1d 100644 --- a/test/Feature/QuerySpec.hs +++ b/test/Feature/QuerySpec.hs @@ -390,11 +390,15 @@ spec = do post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith` [json| [] |] - context "a proc that returns plain text" $ + context "a proc that returns plain text" $ do it "returns proper json" $ post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith` [json| [{"sayhello":"Hello, world"}] |] + it "can handle unicode" $ + post "/rpc/sayhello" [json| { "name": "¥" } |] `shouldRespondWith` + [json| [{"sayhello":"Hello, ¥"}] |] + context "improper input" $ do it "rejects unknown content type even if payload is good" $ request methodPost "/rpc/sayhello" From b858626e177317cc0c2469424498ed54c5f027c4 Mon Sep 17 00:00:00 2001 From: Joe Nelson Date: Sun, 13 Mar 2016 14:21:56 -0700 Subject: [PATCH 2/5] For correctness include charset=utf-8 in responses --- src/PostgREST/ApiRequest.hs | 4 ++-- src/PostgREST/App.hs | 2 +- test/Feature/AuthSpec.hs | 4 ++-- test/Feature/InsertSpec.hs | 10 +++++----- test/Feature/QuerySpec.hs | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/PostgREST/ApiRequest.hs b/src/PostgREST/ApiRequest.hs index f2c1815b3..92be95859 100644 --- a/src/PostgREST/ApiRequest.hs +++ b/src/PostgREST/ApiRequest.hs @@ -41,8 +41,8 @@ data PreferRepresentation = Full | HeadersOnly | None deriving Eq -- route responses and upload payloads data ContentType = ApplicationJSON | TextCSV deriving Eq instance Show ContentType where - show ApplicationJSON = "application/json" - show TextCSV = "text/csv" + show ApplicationJSON = "application/json; charset=utf-8" + show TextCSV = "text/csv; charset=utf-8" {-| Describes what the user wants to do. This data type is a diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index 44672e09f..752bcc3c3 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -249,7 +249,7 @@ contentRangeH frm to total = fromInRange = frm <= to jsonH :: Header -jsonH = (hContentType, "application/json") +jsonH = (hContentType, "application/json; charset=utf-8") formatRelationError :: Text -> Text formatRelationError = formatGeneralError diff --git a/test/Feature/AuthSpec.hs b/test/Feature/AuthSpec.hs index 55e05efab..5181f8ec9 100644 --- a/test/Feature/AuthSpec.hs +++ b/test/Feature/AuthSpec.hs @@ -21,7 +21,7 @@ spec = describe "authorization" $ do `shouldRespondWith` ResponseMatcher { matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"} |] , matchStatus = 200 - , matchHeaders = ["Content-Type" <:> "application/json"] + , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"] } it "sql functions can encode custom and standard claims" $ @@ -29,7 +29,7 @@ spec = describe "authorization" $ do `shouldRespondWith` ResponseMatcher { matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmdW4iLCJqdGkiOiJmb28iLCJuYmYiOjEzMDA4MTkzODAsImV4cCI6MTMwMDgxOTM4MCwiaHR0cDovL3Bvc3RncmVzdC5jb20vZm9vIjp0cnVlLCJpc3MiOiJqb2UiLCJyb2xlIjoicG9zdGdyZXN0X3Rlc3QiLCJpYXQiOjEzMDA4MTkzODAsImF1ZCI6ImV2ZXJ5b25lIn0._tQCF79-ZZGMlLktd3csM_bVaiMg7A8YvIb6K2hcu5w"} |] , matchStatus = 200 - , matchHeaders = ["Content-Type" <:> "application/json"] + , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"] } it "sql functions can read custom and standard claims variables" $ do diff --git a/test/Feature/InsertSpec.hs b/test/Feature/InsertSpec.hs index c157b84f6..106242a89 100644 --- a/test/Feature/InsertSpec.hs +++ b/test/Feature/InsertSpec.hs @@ -42,7 +42,7 @@ spec = do } |] `shouldRespondWith` ResponseMatcher { matchBody = Just [str|{"integer":14,"varchar":"testing!"}|] , matchStatus = 201 - , matchHeaders = ["Content-Type" <:> "application/json"] + , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"] } it "includes related data after insert" $ @@ -50,7 +50,7 @@ spec = do [str|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` ResponseMatcher { matchBody = Just [str|{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}|] , matchStatus = 201 - , matchHeaders = ["Content-Type" <:> "application/json", "Location" <:> "/projects?id=eq.6"] + , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8", "Location" <:> "/projects?id=eq.6"] } @@ -184,7 +184,7 @@ spec = do `shouldRespondWith` ResponseMatcher { matchBody = Just inserted , matchStatus = 201 - , matchHeaders = ["Content-Type" <:> "text/csv"] + , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"] } -- p <- request methodPost "/menagerie" [("Content-Type", "text/csv")] -- [str|integer,double,varchar,boolean,date,money,enum @@ -203,7 +203,7 @@ spec = do `shouldRespondWith` ResponseMatcher { matchBody = Just "a,b\nbar,baz" , matchStatus = 201 - , matchHeaders = ["Content-Type" <:> "text/csv", + , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8", "Location" <:> "/no_pk?a=eq.bar&b=eq.baz"] } @@ -225,7 +225,7 @@ spec = do `shouldRespondWith` ResponseMatcher { matchBody = Just "a,b\n,foo" , matchStatus = 201 - , matchHeaders = ["Content-Type" <:> "text/csv", + , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8", "Location" <:> "/no_pk?a=is.null&b=eq.foo"] } diff --git a/test/Feature/QuerySpec.hs b/test/Feature/QuerySpec.hs index 0d0f71a1d..71b353187 100644 --- a/test/Feature/QuerySpec.hs +++ b/test/Feature/QuerySpec.hs @@ -337,7 +337,7 @@ spec = do `shouldRespondWith` ResponseMatcher { matchBody = Just "k,extra\nxyyx,u\nxYYx,v" , matchStatus = 200 - , matchHeaders = ["Content-Type" <:> "text/csv"] + , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"] } describe "Canonical location" $ do From 3c5fab009be1664c84b351679c8980e225f47b93 Mon Sep 17 00:00:00 2001 From: Joe Nelson Date: Sun, 13 Mar 2016 14:22:36 -0700 Subject: [PATCH 3/5] Remove ancient test comments --- test/Feature/InsertSpec.hs | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/test/Feature/InsertSpec.hs b/test/Feature/InsertSpec.hs index 106242a89..50cda0f99 100644 --- a/test/Feature/InsertSpec.hs +++ b/test/Feature/InsertSpec.hs @@ -146,13 +146,6 @@ spec = do , matchHeaders = ["Location" <:> [str|/json?data=eq.{"foo":"bar"}|]] } - -- TODO! the test above seems right, why was the one below working before and not now - -- p <- request methodPost "/json" [("Prefer", "return=representation")] inserted - -- liftIO $ do - -- simpleBody p `shouldBe` inserted - -- simpleHeaders p `shouldSatisfy` matchHeader hLocation "/json\\?data=eq\\.%7B%22foo%22%3A%22bar%22%7D" - -- simpleStatus p `shouldBe` created201 - it "serializes nested array" $ do let inserted = [json| { "data": [1,2,3] } |] request methodPost "/json" @@ -163,12 +156,6 @@ spec = do , matchStatus = 201 , matchHeaders = ["Location" <:> [str|/json?data=eq.[1,2,3]|]] } - -- TODO! the test above seems right, why was the one below working before and not now - -- p <- request methodPost "/json" [("Prefer", "return=representation")] inserted - -- liftIO $ do - -- simpleBody p `shouldBe` inserted - -- simpleHeaders p `shouldSatisfy` matchHeader hLocation "/json\\?data=eq\\.%5B1%2C2%2C3%5D" - -- simpleStatus p `shouldBe` created201 describe "CSV insert" $ do @@ -186,14 +173,6 @@ spec = do , matchStatus = 201 , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"] } - -- 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 context "requesting full representation" $ do it "returns full details of inserted record" $ @@ -207,17 +186,6 @@ spec = do "Location" <:> "/no_pk?a=eq.bar&b=eq.baz"] } - -- it "can post nulls (old way)" $ do - -- pendingWith "changed the response when in csv mode" - -- 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"] - -- } it "can post nulls" $ request methodPost "/no_pk" [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")] From e5fed86965219d8c05efcbbfe8e52ec43ced7d23 Mon Sep 17 00:00:00 2001 From: Joe Nelson Date: Sun, 13 Mar 2016 14:33:00 -0700 Subject: [PATCH 4/5] Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6dc7da03..0eef34f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased ### Fixed +- Preserve unicode values in insert,update,rpc (regression) - @begriffs - Prevent duplicate call to stored procs (regression) - @begriffs - Allow SQL functions to generate registered JWT claims - @begriffs - Terminate gracefully on SIGTERM (for use in Docker) - @recmo From 3bfe64dd06c1473b5f64bdb693687e4fca6d9b15 Mon Sep 17 00:00:00 2001 From: Joe Nelson Date: Wed, 16 Mar 2016 21:04:18 -0700 Subject: [PATCH 5/5] Create monomorphic statement function to force use of Text --- src/PostgREST/QueryBuilder.hs | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/PostgREST/QueryBuilder.hs b/src/PostgREST/QueryBuilder.hs index 75754cd6b..61e05495b 100644 --- a/src/PostgREST/QueryBuilder.hs +++ b/src/PostgREST/QueryBuilder.hs @@ -43,6 +43,7 @@ import Data.List (find, (\\)) import Data.Monoid ((<>)) import Data.Text (Text, intercalate, unwords, replace, isInfixOf, toLower, split) import qualified Data.Text as T (map, takeWhile) +import qualified Data.Text.Encoding as T import Data.String.Conversions (cs) import Control.Applicative ((<|>)) import Control.Monad (join) @@ -93,11 +94,11 @@ encodeUniformObjs = createReadStatement :: SqlQuery -> SqlQuery -> NonnegRange -> Bool -> Bool -> Bool -> H.Query () ResultsWithCount createReadStatement selectQuery countQuery range isSingle countTotal asCsv = - H.statement (cs sql) HE.unit decodeStandard True + unicodeStatement sql HE.unit decodeStandard True where sql = [qc| WITH {sourceCTEName} AS ({selectQuery}) SELECT {cols} - FROM ( SELECT * FROM {sourceCTEName} {limitF range}) t |] :: Text + FROM ( SELECT * FROM {sourceCTEName} {limitF range}) t |] countResultF = if countTotal then "("<>countQuery<>")" else "null" cols = intercalate ", " [ countResultF <> " AS total_result_set", @@ -116,20 +117,20 @@ createWriteStatement :: QualifiedIdentifier -> SqlQuery -> SqlQuery -> Bool -> createWriteStatement _ _ _ _ _ _ _ (PayloadParseError _) = undefined createWriteStatement _ _ mutateQuery _ None _ _ (PayloadJSON (UniformObjects _)) = - H.statement (cs sql) encodeUniformObjs decodeStandardMay True + unicodeStatement sql encodeUniformObjs decodeStandardMay True where sql = [qc| WITH {sourceCTEName} AS ({mutateQuery}) - SELECT '', 0, '', '' |] :: Text + SELECT '', 0, '', '' |] createWriteStatement qi _ mutateQuery isSingle HeadersOnly pKeys _ (PayloadJSON (UniformObjects _)) = - H.statement (cs sql) encodeUniformObjs decodeStandardMay True + unicodeStatement sql encodeUniformObjs decodeStandardMay True where sql = [qc| WITH {sourceCTEName} AS ({mutateQuery} RETURNING {fromQi qi}.*) SELECT {cols} - FROM (SELECT 1 FROM {sourceCTEName}) t |] :: Text + FROM (SELECT 1 FROM {sourceCTEName}) t |] cols = intercalate ", " [ "'' AS total_result_set", "pg_catalog.count(t) AS page_total", @@ -139,12 +140,12 @@ createWriteStatement qi _ mutateQuery isSingle HeadersOnly createWriteStatement qi selectQuery mutateQuery isSingle Full pKeys asCsv (PayloadJSON (UniformObjects _)) = - H.statement (cs sql) encodeUniformObjs decodeStandardMay True + unicodeStatement sql encodeUniformObjs decodeStandardMay True where sql = [qc| WITH {sourceCTEName} AS ({mutateQuery} RETURNING {fromQi qi}.*) SELECT {cols} - FROM ({selectQuery}) t |] :: Text + FROM ({selectQuery}) t |] cols = intercalate ", " [ "'' AS total_result_set", -- when updateing it does not make sense "pg_catalog.count(t) AS page_total", @@ -206,7 +207,7 @@ addJoinConditions schema (Node (query, (n, r)) forest) = type ProcResults = (Maybe Int64, Int64, JSON.Value) callProc :: QualifiedIdentifier -> JSON.Object -> NonnegRange -> Bool -> H.Query () (Maybe ProcResults) callProc qi params range countTotal = - H.statement (cs sql) HE.unit decodeProc True + unicodeStatement sql HE.unit decodeProc True where sql = [qc| WITH t AS (select * {_callSql}) @@ -217,7 +218,7 @@ callProc qi params range countTotal = coalesce(array_agg(row_to_json(r)), '\{}') )::character varying FROM (select * from t {limitF range}) r; - |] :: Text + |] _args = intercalate "," $ map _assignment (HM.toList params) _assignment (n,v) = pgFmtIdent n <> ":=" <> insertableValue v _callSql = [qc| from {fromQi qi}({_args}) |] :: Text @@ -439,6 +440,9 @@ getJoinConditions (Relation t cols ft fcs typ lt lc1 lc2) = toFilter :: Text -> Text -> Column -> Column -> Filter toFilter tb ftb c fc = Filter (colName c, Nothing) "=" (VForeignKey (QualifiedIdentifier s tb) (ForeignKey fc{colTable=(colTable fc){tableName=ftb}})) +unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Query a b +unicodeStatement = H.statement . T.encodeUtf8 + emptyOnNull :: Text -> [a] -> Text emptyOnNull val x = if null x then "" else val