packages feed

postgrest-9.0.1: test/spec/Feature/EmbedInnerJoinSpec.hs

module Feature.EmbedInnerJoinSpec where

import Network.HTTP.Types
import Network.Wai        (Application)

import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON

import Protolude  hiding (get)
import SpecHelper

spec :: SpecWith ((), Application)
spec =
  describe "Embedding with an inner join" $ do
    context "many-to-one relationships" $ do
      it "ignores null embeddings while the default left join doesn't" $ do
        get "/projects?select=id,clients!inner(id)" `shouldRespondWith`
          [json|[
            {"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},
            {"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}]|]
          { matchHeaders = [matchContentTypeJson] }
        get "/projects?select=id,clients!left(id)" `shouldRespondWith`
          [json|[
            {"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},
            {"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}},
            {"id":5,"clients":null}]|]
          { matchHeaders = [matchContentTypeJson] }
        request methodHead "/projects?select=id,clients!inner(id)" [("Prefer", "count=exact")] mempty
          `shouldRespondWith` ""
          { matchStatus  = 200
          , matchHeaders = [ matchContentTypeJson
                           , "Content-Range" <:> "0-3/4" ]
          }

      it "filters source tables when the embedded table is filtered" $ do
        get "/projects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`
          [json|[
            {"id":1,"clients":{"id":1}},
            {"id":2,"clients":{"id":1}}]|]
          { matchHeaders = [matchContentTypeJson] }
        get "/projects?select=id,clients!inner(id)&clients.id=eq.2" `shouldRespondWith`
          [json|[
            {"id":3,"clients":{"id":2}},
            {"id":4,"clients":{"id":2}}] |]
          { matchHeaders = [matchContentTypeJson] }
        get "/projects?select=id,clients!inner(id)&clients.id=eq.0" `shouldRespondWith`
          [json|[]|]
          { matchHeaders = [matchContentTypeJson] }
        request methodHead "/projects?select=id,clients!inner(id)&clients.id=eq.1" [("Prefer", "count=exact")] mempty
          `shouldRespondWith` ""
          { matchStatus  = 200
          , matchHeaders = [ matchContentTypeJson
                           , "Content-Range" <:> "0-1/2" ]
          }

      it "filters source tables when a two levels below embedded table is filtered" $ do
        get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" `shouldRespondWith`
          [json|[
            {"id":1,"projects":{"id":1,"clients":{"id":1}}},
            {"id":2,"projects":{"id":1,"clients":{"id":1}}},
            {"id":3,"projects":{"id":2,"clients":{"id":1}}},
            {"id":4,"projects":{"id":2,"clients":{"id":1}}}]|]
          { matchHeaders = [matchContentTypeJson] }
        get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`
          [json|[
            {"id":5,"projects":{"id":3,"clients":{"id":2}}},
            {"id":6,"projects":{"id":3,"clients":{"id":2}}},
            {"id":7,"projects":{"id":4,"clients":{"id":2}}},
            {"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]
          { matchHeaders = [matchContentTypeJson] }
        request methodHead "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" [("Prefer", "count=exact")] mempty
          `shouldRespondWith` ""
          { matchStatus  = 200
          , matchHeaders = [ matchContentTypeJson
                           , "Content-Range" <:> "0-3/4" ]
          }

      it "only affects the source table rows if his direct embedding is an inner join" $ do
        get "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`
          [json|[
            {"id":1,"projects":null},
            {"id":2,"projects":null},
            {"id":3,"projects":null},
            {"id":4,"projects":null},
            {"id":5,"projects":{"id":3,"clients":{"id":2}}},
            {"id":6,"projects":{"id":3,"clients":{"id":2}}},
            {"id":7,"projects":{"id":4,"clients":{"id":2}}},
            {"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]
          { matchHeaders = [matchContentTypeJson] }
        request methodHead "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" [("Prefer", "count=exact")] mempty
          `shouldRespondWith` ""
          { matchStatus  = 200
          , matchHeaders = [ matchContentTypeJson
                           , "Content-Range" <:> "0-7/8" ]
          }

      it "works with views" $ do
        get "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" `shouldRespondWith`
          [json| [{"title":"1984","authors":{"name":"George Orwell"}}] |]
          { matchHeaders = [matchContentTypeJson] }
        request methodHead "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" [("Prefer", "count=exact")] mempty
          `shouldRespondWith` ""
          { matchStatus  = 200
          , matchHeaders = [ matchContentTypeJson
                           , "Content-Range" <:> "0-0/1" ]
          }

    context "one-to-many relationships" $ do
      it "ignores empty array embeddings while the default left join doesn't" $ do
        get "/entities?select=id,child_entities!inner(id)" `shouldRespondWith`
          [json|[
            {"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},
            {"id":2,"child_entities":[{"id":3}, {"id":6}]}]|]
          { matchHeaders = [matchContentTypeJson] }
        get "/entities?select=id,child_entities!left(id)" `shouldRespondWith`
          [json| [
            {"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},
            {"id":2,"child_entities":[{"id":3}, {"id":6}]},
            {"id":3,"child_entities":[]},
            {"id":4,"child_entities":[]}] |]
          { matchHeaders = [matchContentTypeJson] }
        request methodHead "/entities?select=id,child_entities!inner(id)" [("Prefer", "count=exact")] mempty
          `shouldRespondWith` ""
          { matchStatus  = 200
          , matchHeaders = [ matchContentTypeJson
                           , "Content-Range" <:> "0-1/2" ]
          }

      it "filters source tables when the embedded table is filtered" $ do
        get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" `shouldRespondWith`
          [json|[{"id":1,"child_entities":[{"id":1}]}]|]
          { matchHeaders = [matchContentTypeJson] }
        get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.3" `shouldRespondWith`
          [json|[{"id":2,"child_entities":[{"id":3}]}]|]
          { matchHeaders = [matchContentTypeJson] }
        get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.0" `shouldRespondWith`
          [json|[]|]
          { matchHeaders = [matchContentTypeJson] }
        request methodHead "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" [("Prefer", "count=exact")] mempty
          `shouldRespondWith` ""
          { matchStatus  = 200
          , matchHeaders = [ matchContentTypeJson
                           , "Content-Range" <:> "0-0/1" ]
          }

      it "filters source tables when a two levels below embedded table is filtered" $ do
        get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)"
          `shouldRespondWith`
          [json|[
            {
              "id": 1,
              "child_entities": [
                { "id": 1, "grandchild_entities": [ { "id": 1 } ] },
                { "id": 2, "grandchild_entities": [ { "id": 5 } ] }]
            }
          ]|]
          { matchHeaders = [matchContentTypeJson] }
        get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`
          [json|[
            {
              "id": 1,
              "child_entities": [
                { "id": 1, "grandchild_entities": [ { "id": 2 } ] } ]
            }
          ]|]
          { matchHeaders = [matchContentTypeJson] }
        request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)" [("Prefer", "count=exact")] mempty
          `shouldRespondWith` ""
          { matchStatus  = 200
          , matchHeaders = [ matchContentTypeJson
                           , "Content-Range" <:> "0-0/1" ]
          }

      it "only affects the source table rows if his direct embedding is an inner join" $ do
        get "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`
          [json|[
            {
              "id": 1,
              "child_entities": [
                { "id": 1, "grandchild_entities": [ { "id": 2 } ] },
                { "id": 2, "grandchild_entities": [] },
                { "id": 4, "grandchild_entities": [] },
                { "id": 5, "grandchild_entities": [] } ]
            },
            {
              "id": 2,
              "child_entities": [
                { "id": 3, "grandchild_entities": [] },
                { "id": 6, "grandchild_entities": [] } ]
            }
          ]|]
          { matchHeaders = [matchContentTypeJson] }
        request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" [("Prefer", "count=exact")] mempty
          `shouldRespondWith` ""
          { matchStatus  = 200
          , matchHeaders = [ matchContentTypeJson
                           , "Content-Range" <:> "0-1/2" ]
          }

      it "works with views" $ do
        get "/authors?select=*,books!inner(*)&books.title=eq.1984" `shouldRespondWith`
          [json| [{"id":1,"name":"George Orwell","books":[{"id":1,"title":"1984","publication_year":1949,"author_id":1}]}] |]
          { matchHeaders = [matchContentTypeJson] }
        request methodHead "/authors?select=*,books!inner(*)&books.title=eq.1984" [("Prefer", "count=exact")] mempty
          `shouldRespondWith` ""
          { matchStatus  = 200
          , matchHeaders = [ matchContentTypeJson
                           , "Content-Range" <:> "0-0/1" ]
          }

    context "many-to-many relationships" $ do
      it "ignores empty array embeddings while the default left join doesn't" $ do
        get "/products?select=id,suppliers!inner(id)" `shouldRespondWith`
          [json| [
            {"id":1,"suppliers":[{"id":1}, {"id":2}]},
            {"id":2,"suppliers":[{"id":1}, {"id":3}]}] |]
          { matchHeaders = [matchContentTypeJson] }
        get "/products?select=id,suppliers!left(id)" `shouldRespondWith`
          [json| [
            {"id":1,"suppliers":[{"id":1}, {"id":2}]},
            {"id":2,"suppliers":[{"id":1}, {"id":3}]},
            {"id":3,"suppliers":[]}] |]
          { matchHeaders = [matchContentTypeJson] }
        request methodHead "/products?select=id,suppliers!inner(id)" [("Prefer", "count=exact")] mempty
          `shouldRespondWith` ""
          { matchStatus  = 200
          , matchHeaders = [ matchContentTypeJson
                           , "Content-Range" <:> "0-1/2" ]
          }

      it "filters source tables when the embedded table is filtered" $ do
        get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" `shouldRespondWith`
          [json| [{"id":1,"suppliers":[{"id":2}]}] |]
          { matchHeaders = [matchContentTypeJson] }
        get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.3" `shouldRespondWith`
          [json| [{"id":2,"suppliers":[{"id":3}]}] |]
          { matchHeaders = [matchContentTypeJson] }
        get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.0" `shouldRespondWith`
          [json| [] |]
          { matchHeaders = [matchContentTypeJson] }
        request methodHead "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" [("Prefer", "count=exact")] mempty
          `shouldRespondWith` ""
          { matchStatus  = 200
          , matchHeaders = [ matchContentTypeJson
                           , "Content-Range" <:> "0-0/1" ]
          }

      it "filters source tables when a two levels below embedded table is filtered" $ do
        get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3"
          `shouldRespondWith`
          [json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":3}]}]}] |]
          { matchHeaders = [matchContentTypeJson] }
        get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.4"
          `shouldRespondWith`
          [json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":4}]}]}] |]
          { matchHeaders = [matchContentTypeJson] }
        request methodHead "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3" [("Prefer", "count=exact")] mempty
          `shouldRespondWith` ""
          { matchStatus  = 200
          , matchHeaders = [ matchContentTypeJson
                           , "Content-Range" <:> "0-0/1" ]
          }

      it "only affects the source table rows if his direct embedding is an inner join" $ do
        get "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" `shouldRespondWith`
          [json|[
            {"id":1,"suppliers":[{"id":1,"trade_unions":[]}, {"id":2,"trade_unions":[{"id":3}]}]},
            {"id":2,"suppliers":[{"id":1,"trade_unions":[]}, {"id":3,"trade_unions":[]}]}]|]
          { matchHeaders = [matchContentTypeJson] }
        request methodHead "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" [("Prefer", "count=exact")] mempty
          `shouldRespondWith` ""
          { matchStatus  = 200
          , matchHeaders = [ matchContentTypeJson
                           , "Content-Range" <:> "0-1/2" ]
          }

      it "works with views" $ do
        get "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" `shouldRespondWith`
          [json| [{"id":1,"name":"john","films":[{"id":12,"title":"douze commandements"}]}] |]
          { matchHeaders = [matchContentTypeJson] }
        get "/films?select=*,actors!inner(*)&actors.name=eq.john" `shouldRespondWith`
          [json| [{"id":12,"title":"douze commandements","actors":[{"id":1,"name":"john"}]}] |]
          { matchHeaders = [matchContentTypeJson] }
        request methodHead "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" [("Prefer", "count=exact")] mempty
          `shouldRespondWith` ""
          { matchStatus  = 200
          , matchHeaders = [ matchContentTypeJson
                           , "Content-Range" <:> "0-0/1" ]
          }

    it "works with m2o and m2m relationships combined" $ do
      get "/projects?select=name,clients!inner(name),users!inner(name)" `shouldRespondWith`
        [json| [
          {"name":"Windows 7","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}, {"name":"Dwight Schrute"}]},
          {"name":"Windows 10","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}]},
          {"name":"IOS","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}, {"name":"Dwight Schrute"}]},
          {"name":"OSX","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}]}]|]
        { matchHeaders = [matchContentTypeJson] }
      request methodHead "/projects?select=name,clients!inner(name),users!inner(name)" [("Prefer", "count=exact")] mempty
        `shouldRespondWith` ""
        { matchStatus  = 200
        , matchHeaders = [ matchContentTypeJson
                         , "Content-Range" <:> "0-3/4" ]
        }

    it "works with rpc" $ do
      get "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`
        [json| [{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}}] |]
        { matchHeaders = [matchContentTypeJson] }
      request methodHead "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" [("Prefer", "count=exact")] mempty
        `shouldRespondWith` ""
        { matchStatus  = 200
        , matchHeaders = [ matchContentTypeJson
                         , "Content-Range" <:> "0-1/2" ]
        }

    it "works when using hints" $ do
      get "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" `shouldRespondWith`
        [json| [{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}] |]
        { matchHeaders = [matchContentTypeJson] }
      get "/projects?select=id,client!inner(id)&client.id=eq.2" `shouldRespondWith`
        [json| [{"id":3,"client":{"id":2}}, {"id":4,"client":{"id":2}}] |]
        { matchHeaders = [matchContentTypeJson] }
      request methodHead "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" [("Prefer", "count=exact")] mempty
        `shouldRespondWith` ""
        { matchStatus  = 200
        , matchHeaders = [ matchContentTypeJson
                         , "Content-Range" <:> "0-1/2" ]
        }

    it "works with many one-to-many relationships" $ do
      -- https://github.com/PostgREST/postgrest/issues/1977
      get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)" `shouldRespondWith`
        [json|[
          {"id":1,"name":"Walmart","contact":[{"name":"Wally Walton"}, {"name":"Wilma Wellers"}],"clientinfo":[{"other":"123 Main St"}]},
          {"id":2,"name":"Target", "contact":[{"name":"Tabby Targo"}],"clientinfo":[{"other":"456 South 3rd St"}]},
          {"id":3,"name":"Big Lots","contact":[{"name":"Bobby Bots"}, {"name":"Bonnie Bits"}, {"name":"Billy Boats"}],"clientinfo":[{"other":"789 Palm Tree Ln"}]}
        ]|]
        { matchHeaders = [matchContentTypeJson] }
      get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)&contact.name=eq.Wally%20Walton" `shouldRespondWith`
        [json|[
          {"id":1,"name":"Walmart","contact":[{"name":"Wally Walton"}],"clientinfo":[{"other":"123 Main St"}]}
        ]|]
        { matchHeaders = [matchContentTypeJson] }
      get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)&clientinfo.other=eq.456%20South%203rd%20St" `shouldRespondWith`
        [json|[
          {"id":2,"name":"Target","clientinfo":[{"other":"456 South 3rd St"}],"contact":[{"name":"Tabby Targo"}]}
        ]|]
        { matchHeaders = [matchContentTypeJson] }
      request methodHead "/client?select=id,name,contact!inner(name),clientinfo!inner(other)" [("Prefer", "count=exact")] mempty
        `shouldRespondWith` ""
        { matchStatus  = 200
        , matchHeaders = [ matchContentTypeJson
                         , "Content-Range" <:> "0-2/3" ]
        }