diff --git a/postgrest.cabal b/postgrest.cabal index 4f27a5e5a..81efcf4db 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -122,6 +122,7 @@ Test-Suite spec , Feature.CorsSpec , Feature.DeleteSpec , Feature.InsertSpec + , Feature.JsonOperatorSpec , Feature.NoJwtSpec , Feature.PgVersion96Spec , Feature.ProxySpec diff --git a/src/PostgREST/Parsers.hs b/src/PostgREST/Parsers.hs index 58721ecf3..a4bdb1ace 100644 --- a/src/PostgREST/Parsers.hs +++ b/src/PostgREST/Parsers.hs @@ -57,7 +57,7 @@ lexeme p = ws *> p <* ws pTreePath :: Parser (EmbedPath, Field) pTreePath = do p <- pFieldName `sepBy1` pDelimiter - jp <- optionMaybe pJsonPath + jp <- option [] pJsonPath return (init p, (last p, jp)) pFieldForest :: Parser [Tree SelectItem] @@ -87,7 +87,7 @@ pJsonPath :: Parser [Text] pJsonPath = (<>) <$> many pJsonPathStep <*> ( (:[]) <$> (string "->>" *> pFieldName) ) pField :: Parser Field -pField = lexeme $ (,) <$> pFieldName <*> optionMaybe pJsonPath +pField = lexeme $ (,) <$> pFieldName <*> option [] pJsonPath aliasSeparator :: Parser () aliasSeparator = char ':' >> notFollowedBy (char ':') @@ -112,7 +112,7 @@ pFieldSelect = lexeme $ ) <|> do s <- pStar - return ((s, Nothing), Nothing, Nothing, Nothing) + return ((s, []), Nothing, Nothing, Nothing) pOpExpr :: Parser SingleVal -> Parser OpExpr pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation diff --git a/src/PostgREST/QueryBuilder.hs b/src/PostgREST/QueryBuilder.hs index 4d1491df0..15f58abc7 100644 --- a/src/PostgREST/QueryBuilder.hs +++ b/src/PostgREST/QueryBuilder.hs @@ -447,14 +447,14 @@ pgFmtLogicTree qi (Expr hasNot op forest) = notOp <> " (" <> intercalate (" " <> where notOp = if hasNot then "NOT" else "" pgFmtLogicTree qi (Stmnt flt) = pgFmtFilter qi flt -pgFmtJsonPath :: Maybe JsonPath -> SqlFragment -pgFmtJsonPath (Just [x]) = "->>" <> pgFmtLit x -pgFmtJsonPath (Just (x:xs)) = "->" <> pgFmtLit x <> pgFmtJsonPath ( Just xs ) -pgFmtJsonPath _ = "" +pgFmtJsonPath :: JsonPath -> SqlFragment +pgFmtJsonPath [] = "" +pgFmtJsonPath [x] = "->>" <> pgFmtLit x +pgFmtJsonPath (x:xs) = "->" <> pgFmtLit x <> pgFmtJsonPath xs -pgFmtAs :: Maybe JsonPath -> Maybe Alias -> SqlFragment -pgFmtAs Nothing Nothing = "" -pgFmtAs (Just xx) Nothing = case lastMay xx of +pgFmtAs :: JsonPath -> Maybe Alias -> SqlFragment +pgFmtAs [] Nothing = "" +pgFmtAs jp Nothing = case lastMay jp of Just alias -> " AS " <> pgFmtIdent alias Nothing -> "" pgFmtAs _ (Just alias) = " AS " <> pgFmtIdent alias diff --git a/src/PostgREST/Types.hs b/src/PostgREST/Types.hs index 2e13aef50..7e0e17756 100644 --- a/src/PostgREST/Types.hs +++ b/src/PostgREST/Types.hs @@ -239,7 +239,7 @@ data LogicTree = Expr Bool LogicOperator [LogicTree] | Stmnt Filter deriving (Sh type FieldName = Text type JsonPath = [Text] -type Field = (FieldName, Maybe JsonPath) +type Field = (FieldName, JsonPath) type Alias = Text type Cast = Text type NodeName = Text diff --git a/test/Feature/AndOrParamsSpec.hs b/test/Feature/AndOrParamsSpec.hs index b37ae8e8d..8869eb7b2 100644 --- a/test/Feature/AndOrParamsSpec.hs +++ b/test/Feature/AndOrParamsSpec.hs @@ -193,10 +193,6 @@ spec = it "can query columns that begin with and/or reserved words" $ get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200 - it "can query jsonb columns" $ - get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith` - [json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] } - it "fails when using IN without () and provides meaningful error message" $ get "/entities?or=(id.in.1,2,id.eq.3)" `shouldRespondWith` [json|{ diff --git a/test/Feature/InsertSpec.hs b/test/Feature/InsertSpec.hs index e8213987a..24e77e0b0 100644 --- a/test/Feature/InsertSpec.hs +++ b/test/Feature/InsertSpec.hs @@ -390,16 +390,6 @@ spec = do [json| [{ a: "keepme", b: null }] |] { matchHeaders = [matchContentTypeJson] } - it "can set a json column to escaped value" $ do - _ <- post "/json" [json| { data: {"escaped":"bar"} } |] - request methodPatch "/json?data->>escaped=eq.bar" - [("Prefer", "return=representation")] - [json| { "data": { "escaped":" \"bar" } } |] - `shouldRespondWith` [json| [{ "data": { "escaped":" \"bar" } }] |] - { matchStatus = 200 - , matchHeaders = [] - } - it "can update based on a computed column" $ request methodPatch "/items?always_true=eq.false" diff --git a/test/Feature/JsonOperatorSpec.hs b/test/Feature/JsonOperatorSpec.hs new file mode 100644 index 000000000..00ff92c5d --- /dev/null +++ b/test/Feature/JsonOperatorSpec.hs @@ -0,0 +1,92 @@ +module Feature.JsonOperatorSpec 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 "json and jsonb operators" $ do + context "Shaping response with select parameter" $ do + it "obtains a json subfield one level with casting" $ + get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith` + [json| [{"foo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text" + { matchHeaders = [matchContentTypeJson] } + + it "renames json subfield one level with casting" $ + get "/complex_items?id=eq.1&select=myFoo:settings->>foo::json" `shouldRespondWith` + [json| [{"myFoo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text" + { matchHeaders = [matchContentTypeJson] } + + it "fails on bad casting (data of the wrong format)" $ + get "/complex_items?select=settings->foo->>bar::integer" + `shouldRespondWith` [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |] + { matchStatus = 400 , matchHeaders = [] } + + it "obtains a json subfield two levels (string)" $ + get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith` + [json| [{"bar":"baz"}] |] + { matchHeaders = [matchContentTypeJson] } + + it "renames json subfield two levels (string)" $ + get "/complex_items?id=eq.1&select=myBar:settings->foo->>bar" `shouldRespondWith` + [json| [{"myBar":"baz"}] |] + { matchHeaders = [matchContentTypeJson] } + + it "obtains a json subfield two levels with casting (int)" $ + get "/complex_items?id=eq.1&select=settings->foo->>int::integer" `shouldRespondWith` + [json| [{"int":1}] |] -- the value in the db is an int, but here we expect a string for now + { matchHeaders = [matchContentTypeJson] } + + it "renames json subfield two levels with casting (int)" $ + get "/complex_items?id=eq.1&select=myInt:settings->foo->>int::integer" `shouldRespondWith` + [json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now + { matchHeaders = [matchContentTypeJson] } + + context "filtering response" $ do + it "can filter by properties inside json column" $ do + get "/json?data->foo->>bar=eq.baz" `shouldRespondWith` + [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |] + { matchHeaders = [matchContentTypeJson] } + get "/json?data->foo->>bar=eq.fake" `shouldRespondWith` + [json| [] |] + { matchHeaders = [matchContentTypeJson] } + + it "can filter by properties inside json column using not" $ + get "/json?data->foo->>bar=not.eq.baz" `shouldRespondWith` + [json| [] |] + { matchHeaders = [matchContentTypeJson] } + + it "can filter by properties inside json column using ->>" $ + get "/json?data->>id=eq.1" `shouldRespondWith` + [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |] + { matchHeaders = [matchContentTypeJson] } + + it "can be filtered with and/or" $ + get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith` + [json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] } + + context "ordering response" $ do + it "orders by a json column property asc" $ + get "/json?order=data->>id.asc" `shouldRespondWith` + [json| [{"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}, {"data": {"id": 3}}] |] + { matchHeaders = [matchContentTypeJson] } + + it "orders by a json column with two level property nulls first" $ + get "/json?order=data->foo->>bar.nullsfirst" `shouldRespondWith` + [json| [{"data": {"id": 3}}, {"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}] |] + { matchHeaders = [matchContentTypeJson] } + + context "Patching record, in a nonempty table" $ do + it "can set a json column to escaped value" $ do + _ <- post "/json" [json| { data: {"escaped":"bar"} } |] + request methodPatch "/json?data->>escaped=eq.bar" + [("Prefer", "return=representation")] + [json| { "data": { "escaped":" \"bar" } } |] + `shouldRespondWith` [json| [{ "data": { "escaped":" \"bar" } }] |] + { matchStatus = 200 , matchHeaders = [] } diff --git a/test/Feature/QuerySpec.hs b/test/Feature/QuerySpec.hs index 1f23b132f..9bf7cbc67 100644 --- a/test/Feature/QuerySpec.hs +++ b/test/Feature/QuerySpec.hs @@ -216,23 +216,6 @@ spec = do [json| [{"settings":{"foo":{"int":1,"bar":"baz"}}}] |] { matchHeaders = [matchContentTypeJson] } - it "json subfield one level with casting (json)" $ - get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith` - [json| [{"foo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text" - { matchHeaders = [matchContentTypeJson] } - - it "rename json subfield one level with casting (json)" $ - get "/complex_items?id=eq.1&select=myFoo:settings->>foo::json" `shouldRespondWith` - [json| [{"myFoo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text" - { matchHeaders = [matchContentTypeJson] } - - it "fails on bad casting (data of the wrong format)" $ - get "/complex_items?select=settings->foo->>bar::integer" - `shouldRespondWith` [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |] - { matchStatus = 400 - , matchHeaders = [] - } - it "fails on bad casting (wrong cast type)" $ get "/complex_items?select=id::fakecolumntype" `shouldRespondWith` [json| {"hint":null,"details":null,"code":"42704","message":"type \"fakecolumntype\" does not exist"} |] @@ -241,27 +224,6 @@ spec = do } - it "json subfield two levels (string)" $ - get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith` - [json| [{"bar":"baz"}] |] - { matchHeaders = [matchContentTypeJson] } - - it "rename json subfield two levels (string)" $ - get "/complex_items?id=eq.1&select=myBar:settings->foo->>bar" `shouldRespondWith` - [json| [{"myBar":"baz"}] |] - { matchHeaders = [matchContentTypeJson] } - - - it "json subfield two levels with casting (int)" $ - get "/complex_items?id=eq.1&select=settings->foo->>int::integer" `shouldRespondWith` - [json| [{"int":1}] |] -- the value in the db is an int, but here we expect a string for now - { matchHeaders = [matchContentTypeJson] } - - it "rename json subfield two levels with casting (int)" $ - get "/complex_items?id=eq.1&select=myInt:settings->foo->>int::integer" `shouldRespondWith` - [json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now - { matchHeaders = [matchContentTypeJson] } - it "requesting parents and children" $ get "/projects?id=eq.1&select=id, name, clients(*), tasks(id, name)" `shouldRespondWith` [json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|] @@ -630,16 +592,6 @@ spec = do , matchHeaders = ["Content-Range" <:> "0-1/*"] } - it "by a json column property asc" $ - get "/json?order=data->>id.asc" `shouldRespondWith` - [json| [{"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}, {"data": {"id": 3}}] |] - { matchHeaders = [matchContentTypeJson] } - - it "by a json column with two level property nulls first" $ - get "/json?order=data->foo->>bar.nullsfirst" `shouldRespondWith` - [json| [{"data": {"id": 3}}, {"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}] |] - { matchHeaders = [matchContentTypeJson] } - it "without other constraints" $ get "/items?order=id.asc" `shouldRespondWith` 200 @@ -771,22 +723,6 @@ spec = do respHeaders `shouldSatisfy` matchHeader "Content-Location" "/simple_pk" - describe "jsonb" $ do - it "can filter by properties inside json column" $ do - get "/json?data->foo->>bar=eq.baz" `shouldRespondWith` - [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |] - { matchHeaders = [matchContentTypeJson] } - get "/json?data->foo->>bar=eq.fake" `shouldRespondWith` - [json| [] |] - { matchHeaders = [matchContentTypeJson] } - it "can filter by properties inside json column using not" $ - get "/json?data->foo->>bar=not.eq.baz" `shouldRespondWith` - [json| [] |] - { matchHeaders = [matchContentTypeJson] } - it "can filter by properties inside json column using ->>" $ - get "/json?data->>id=eq.1" `shouldRespondWith` - [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |] - { matchHeaders = [matchContentTypeJson] } describe "weird requests" $ do it "can query as normal" $ do diff --git a/test/Main.hs b/test/Main.hs index 7f188cc03..f382bb3cc 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -22,6 +22,7 @@ import qualified Feature.ConcurrentSpec import qualified Feature.CorsSpec import qualified Feature.DeleteSpec import qualified Feature.InsertSpec +import qualified Feature.JsonOperatorSpec import qualified Feature.NoJwtSpec import qualified Feature.QueryLimitedSpec import qualified Feature.QuerySpec @@ -77,6 +78,7 @@ main = do , ("Feature.CorsSpec" , Feature.CorsSpec.spec) , ("Feature.DeleteSpec" , Feature.DeleteSpec.spec) , ("Feature.InsertSpec" , Feature.InsertSpec.spec) + , ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec) , ("Feature.QuerySpec" , Feature.QuerySpec.spec) , ("Feature.RpcSpec" , Feature.RpcSpec.spec) , ("Feature.RangeSpec" , Feature.RangeSpec.spec)