GDW Agent Test Suite [GDW-79]

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/4175
GitOrigin-RevId: d37d7d131597af6b9cca6bd773c8dbbce8719ca5
This commit is contained in:
Daniel Chambers 2022-04-10 14:47:15 +10:00 committed by hasura-bot
parent e9436c5d97
commit 38c41b2ae6
18 changed files with 3641 additions and 8 deletions

View File

@ -1,6 +1,7 @@
active-repositories: hackage.haskell.org:merge
constraints: any.Cabal ==3.2.1.0,
Cabal -bundled-binary-generic,
any.Diff ==0.4.1,
any.HTTP ==4000.3.16,
HTTP -conduit10 -mtl1 +network-uri -warn-as-error -warp-tests,
any.HUnit ==1.6.2.0,
@ -43,7 +44,7 @@ constraints: any.Cabal ==3.2.1.0,
any.authenticate-oauth ==1.7,
any.auto-update ==0.1.6,
any.autodocodec ==0.0.1.0,
any.autodocodec-openapi3 ==0.1.0.0,
any.autodocodec-openapi3 ==0.2.0.0,
any.barbies ==2.0.3.1,
any.base ==4.14.3.0,
any.base-compat ==0.11.2,
@ -181,11 +182,13 @@ constraints: any.Cabal ==3.2.1.0,
any.hpc ==0.6.1.0,
any.hsc2hs ==0.68.8,
hsc2hs -in-ghc-tree,
any.hscolour ==1.24.4,
any.hspec ==2.9.4,
any.hspec-core ==2.9.4,
any.hspec-discover ==2.9.4,
any.hspec-expectations ==0.8.2,
any.hspec-expectations-lifted ==0.10.0,
any.hspec-expectations-pretty-diff ==0.7.2.6,
any.hspec-hedgehog ==0.0.1.2,
any.hspec-wai ==0.11.0,
any.hspec-wai-json ==0.11.0,
@ -264,6 +267,7 @@ constraints: any.Cabal ==3.2.1.0,
any.network-info ==0.2.1,
any.network-ip ==0.3.0.3,
any.network-uri ==2.6.4.1,
any.nicify-lib ==1.0.1,
any.odbc ==0.2.6,
any.old-locale ==1.0.0.7,
any.old-time ==1.1.0.3,
@ -410,6 +414,7 @@ constraints: any.Cabal ==3.2.1.0,
any.type-hint ==0.1,
any.typed-process ==0.2.8.0,
any.unbounded-delays ==0.1.1.1,
any.unicode-show ==0.1.1.0,
any.unix ==2.7.2.2,
any.unix-compat ==0.5.4,
unix-compat -old-time,
@ -464,4 +469,4 @@ constraints: any.Cabal ==3.2.1.0,
yaml +no-examples +no-exe,
any.zlib ==0.6.2.3,
zlib -bundled-c-zlib -non-blocking-ffi -pkg-config
index-state: hackage.haskell.org 2022-02-16T22:54:12Z
index-state: hackage.haskell.org 2022-04-06T04:57:40Z

View File

@ -1103,3 +1103,48 @@ test-suite tests-hspec
Test.WhereSpec
Test.RunSQLSpec
Test.InsertCheckPermissionSpec
test-suite tests-gdw-api
import: common-all, common-exe
type: exitcode-stdio-1.0
build-depends:
, aeson
, autodocodec
, autodocodec-openapi3
, base
, bytestring
, deepseq
, file-embed
, gdw-api
, hashable
, hspec
, hspec-core
, hspec-expectations-pretty-diff
, http-client
, lens
, lens-aeson
, mtl
, network-uri
, openapi3
, optparse-applicative
, scientific
, servant
, servant-client
, servant-client-core
, servant-openapi3
, text
, unordered-containers
, vector
hs-source-dirs: tests-gdw-api
-- Turning off optimizations is intentional; tests aren't
-- performance sensitive and waiting for compilation is a problem.
ghc-options: -Wall -O0 -threaded
main-is: Main.hs
other-modules:
Command
, Paths_graphql_engine
, Test.Data
, Test.QuerySpec
, Test.QuerySpec.BasicSpec
, Test.QuerySpec.RelationshipsSpec
, Test.SchemaSpec

View File

@ -5,6 +5,8 @@ module Hasura.Backends.DataWrapper.API
SchemaApi,
QueryApi,
openApiSchema,
Routes (..),
apiClient,
)
where
@ -13,6 +15,7 @@ import Data.OpenApi (OpenApi)
import Hasura.Backends.DataWrapper.API.V0.API as V0
import Servant.API
import Servant.API.Generic
import Servant.Client (Client, ClientM, client)
import Servant.OpenApi
--------------------------------------------------------------------------------
@ -42,3 +45,7 @@ type Api = SchemaApi :<|> QueryApi
-- | Provide an OpenApi 3.0 schema for the API
openApiSchema :: OpenApi
openApiSchema = toOpenApi (Proxy :: Proxy Api)
apiClient :: Client ClientM (NamedRoutes Routes)
apiClient =
client (Proxy @(NamedRoutes Routes))

View File

@ -62,7 +62,7 @@ instance HasCodec Query where
--------------------------------------------------------------------------------
data RelField = RelField
{ fieldMapping :: M.HashMap PrimaryKey ForeignKey,
{ columnMapping :: M.HashMap PrimaryKey ForeignKey,
query :: Query
}
deriving stock (Eq, Ord, Show, Generic, Data)
@ -70,7 +70,7 @@ data RelField = RelField
instance HasObjectCodec RelField where
objectCodec =
RelField
<$> requiredField "field_mapping" "Mapping from local fields to remote fields" .= fieldMapping
<$> requiredField "column_mapping" "Mapping from local fields to remote fields" .= columnMapping
<*> requiredField "query" "Relationship query" .= query
--------------------------------------------------------------------------------

View File

@ -48,7 +48,7 @@ instance HasCodec TableInfo where
codec =
object "TableInfo" $
TableInfo
<$> requiredField "table_name" "The name of the table" .= dtiName
<$> requiredField "name" "The name of the table" .= dtiName
<*> requiredField "columns" "The columns of the table" .= dtiColumns
<*> optionalFieldOrNull "primary_key" "The primary key of the table" .= dtiPrimaryKey
<*> optionalFieldOrNull "description" "Description of the table" .= dtiDescription

View File

@ -38,7 +38,7 @@ spec = do
(RelationshipField $ RelField fieldMapping query)
[aesonQQ|
{ "type": "relationship",
"field_mapping": {"id": "my_foreign_id"},
"column_mapping": {"id": "my_foreign_id"},
"query": {"fields": {}, "from": "my_table_name"}
}
|]

View File

@ -24,7 +24,7 @@ spec = do
testToFromJSONToSchema
(TableInfo (TableName "my_table_name") [] Nothing Nothing)
[aesonQQ|
{ "table_name": "my_table_name",
{ "name": "my_table_name",
"columns": []
}
|]
@ -37,7 +37,7 @@ spec = do
(Just "my description")
)
[aesonQQ|
{ "table_name": "my_table_name",
{ "name": "my_table_name",
"columns": [{"name": "id", "type": "string", "nullable": false}],
"primary_key": "id",
"description": "my description"

View File

@ -0,0 +1,197 @@
{-# LANGUAGE TemplateHaskell #-}
module Command
( Command (..),
TestOptions (..),
AgentCapabilities (..),
parseCommandLine,
)
where
import Control.Arrow (left)
import Control.Lens (contains, modifying, use, (^.), _2)
import Control.Lens.TH (makeLenses)
import Control.Monad (when)
import Control.Monad.State (State, runState)
import Data.HashSet (HashSet)
import Data.HashSet qualified as HashSet
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Version (showVersion)
import Hasura.Backends.DataWrapper.API qualified as API
import Options.Applicative
import Paths_graphql_engine qualified as PackageInfo
import Servant.Client (BaseUrl, parseBaseUrl)
import Prelude
data Command
= Test TestOptions
| ExportOpenAPISpec
data TestOptions = TestOptions
{ _toAgentBaseUrl :: BaseUrl,
_toAgentCapabilities :: AgentCapabilities,
_toParallelDegree :: Maybe Int,
_toMatch :: Maybe String,
_toSkip :: Maybe String
}
data AgentCapabilities
= AutoDetect
| Explicit API.Capabilities
data CapabilitiesState = CapabilitiesState
{ _csRemainingCapabilities :: HashSet Text,
_csCapabilitiesEnquired :: HashSet Text
}
$(makeLenses ''CapabilitiesState)
parseCommandLine :: IO Command
parseCommandLine =
execParser $
info
(helper <*> version <*> commandParser)
( fullDesc
<> header "Hasura GraphQL Data Wrapper Agent Test Utility"
)
version :: Parser (a -> a)
version =
infoOption
displayText
( long "version"
<> short 'v'
<> help "Prints the version of the application and quits"
<> hidden
)
where
displayText = "Version " <> showVersion PackageInfo.version
commandParser :: Parser Command
commandParser =
subparser
(testCommand <> exportOpenApiSpecCommand)
where
testCommand =
command
"test"
( info
(helper <*> testCommandParser)
(progDesc "Executes a suite of tests against an agent to ensure its correct function")
)
exportOpenApiSpecCommand =
command
"export-openapi-spec"
( info
(helper <*> pure ExportOpenAPISpec)
(progDesc "Exports the OpenAPI specification of the GDW API agents must implement")
)
testOptionsParser :: Parser TestOptions
testOptionsParser =
TestOptions
<$> option
baseUrl
( long "agent-base-url"
<> short 'u'
<> metavar "URL"
<> help "The base URL of the GDW agent to be tested"
)
<*> agentCapabilitiesParser
<*> optional
( option
positiveNonZeroInt
( long "jobs"
<> short 'j'
<> metavar "INT"
<> help "Run at most N parallelizable tests simultaneously (default: number of available processors)"
)
)
<*> optional
( option
auto
( long "match"
<> short 'm'
<> metavar "PATTERN"
<> help "Only run tests that match given PATTERN"
)
)
<*> optional
( option
auto
( long "skip"
<> short 's'
<> metavar "PATTERN"
<> help "Skip tests that match given PATTERN"
)
)
testCommandParser :: Parser Command
testCommandParser = Test <$> testOptionsParser
baseUrl :: ReadM BaseUrl
baseUrl = eitherReader $ left show . parseBaseUrl
positiveNonZeroInt :: ReadM Int
positiveNonZeroInt =
auto >>= \int ->
if int <= 0 then readerError "Must be a positive, non-zero integer" else pure int
agentCapabilitiesParser :: Parser AgentCapabilities
agentCapabilitiesParser =
option
agentCapabilities
( long "capabilities"
<> short 'c'
<> metavar "CAPABILITIES"
<> value AutoDetect
<> help (Text.unpack helpText)
)
where
helpText =
"The capabilities that the agent has, to determine what tests to run. By default, they will be autodetected. The valid capabilities are: " <> allCapabilitiesText
allCapabilitiesText =
"[autodetect | none | " <> Text.intercalate "," (HashSet.toList allPossibleCapabilities) <> "]"
agentCapabilities :: ReadM AgentCapabilities
agentCapabilities =
str >>= \text -> do
let capabilities = HashSet.fromList $ Text.strip <$> Text.split (== ',') text
if HashSet.member "autodetect" capabilities
then
if HashSet.size capabilities == 1
then pure AutoDetect
else readerError "You can either autodetect capabilities or specify them manually, not both"
else
if HashSet.member "none" capabilities
then
if HashSet.size capabilities == 1
then pure . Explicit . fst $ readCapabilities mempty
else readerError "You cannot specify other capabilities when specifying none"
else Explicit <$> readExplicitCapabilities capabilities
where
readExplicitCapabilities :: HashSet Text -> ReadM API.Capabilities
readExplicitCapabilities providedCapabilities =
let (capabilities, CapabilitiesState {..}) = readCapabilities providedCapabilities
in if _csRemainingCapabilities /= mempty
then readerError . Text.unpack $ "Unknown capabilities: " <> Text.intercalate "," (HashSet.toList _csRemainingCapabilities)
else pure capabilities
readCapabilities :: HashSet Text -> (API.Capabilities, CapabilitiesState)
readCapabilities providedCapabilities =
flip runState (CapabilitiesState providedCapabilities mempty) $
API.Capabilities
<$> readCapability "relationships"
readCapability :: Text -> State CapabilitiesState Bool
readCapability capability = do
modifying csCapabilitiesEnquired $ HashSet.insert capability
hasCapability <- use $ csRemainingCapabilities . contains capability
when hasCapability $
modifying csRemainingCapabilities $ HashSet.delete capability
pure hasCapability
allPossibleCapabilities :: HashSet Text
allPossibleCapabilities =
readCapabilities mempty ^. _2 . csCapabilitiesEnquired

View File

@ -0,0 +1,60 @@
module Main (main) where
import Command (AgentCapabilities (..), Command (..), TestOptions (..), parseCommandLine)
import Control.Exception (throwIO)
import Control.Monad ((>=>))
import Data.Aeson.Text (encodeToLazyText)
import Data.Proxy (Proxy (..))
import Data.Text.Lazy.IO qualified as Text
import Hasura.Backends.DataWrapper.API (Routes (..), apiClient, openApiSchema)
import Hasura.Backends.DataWrapper.API qualified as API
import Network.HTTP.Client (defaultManagerSettings, newManager)
import Servant.API (NamedRoutes)
import Servant.Client (Client, ClientError, hoistClient, mkClientEnv, runClientM, (//))
import Test.Hspec (Spec)
import Test.Hspec.Core.Runner (runSpec)
import Test.Hspec.Core.Util (filterPredicate)
import Test.Hspec.Runner (Config (..), defaultConfig, evaluateSummary)
import Test.QuerySpec qualified
import Test.SchemaSpec qualified
import Prelude
tests :: Client IO (NamedRoutes Routes) -> API.Capabilities -> Spec
tests api capabilities = do
Test.SchemaSpec.spec api capabilities
Test.QuerySpec.spec api capabilities
main :: IO ()
main = do
command <- parseCommandLine
case command of
Test testOptions -> do
api <- mkIOApiClient testOptions
agentCapabilities <- getAgentCapabilities api (_toAgentCapabilities testOptions)
runSpec (tests api agentCapabilities) (applyTestConfig defaultConfig testOptions) >>= evaluateSummary
ExportOpenAPISpec ->
Text.putStrLn $ encodeToLazyText openApiSchema
pure ()
mkIOApiClient :: TestOptions -> IO (Client IO (NamedRoutes Routes))
mkIOApiClient TestOptions {..} = do
manager <- newManager defaultManagerSettings
let clientEnv = mkClientEnv manager _toAgentBaseUrl
pure $ hoistClient (Proxy @(NamedRoutes Routes)) (flip runClientM clientEnv >=> throwClientError) apiClient
throwClientError :: Either ClientError a -> IO a
throwClientError = either throwIO pure
getAgentCapabilities :: Client IO (NamedRoutes Routes) -> AgentCapabilities -> IO API.Capabilities
getAgentCapabilities api = \case
AutoDetect -> fmap API.srCapabilities $ api // _schema
Explicit capabilities -> pure capabilities
applyTestConfig :: Config -> TestOptions -> Config
applyTestConfig config TestOptions {..} =
config
{ configConcurrentJobs = _toParallelDegree,
configFilterPredicate = filterPredicate <$> _toMatch,
configSkipPredicate = filterPredicate <$> _toSkip
}

View File

@ -0,0 +1,31 @@
# GraphQL Data Wrappers Agent Tests
This test suite provides a set of tests that is able to test any GDW agent that contains the Chinook data set to ensure the agent is behaving as expected. The test executable is designed to be distributable to customers building GDW agents, but is also useful to ensure Hasura's own agents are working correctly.
Not all tests will be appropriate for all agents. Agents self-describe their capabilities and only the tests appropriate for those capabilities will be run.
The executable also has the ability to export the OpenAPI spec of the GDW agent API so that customers can use that to ensure their agent complies with the API format.
## How to Use
First, start your GDW agent and ensure it is populated with the Chinook data set. For example, you could start the Reference Agent by following the instructions in [its README](../../gdw-agents/reference/README.md).
To run the tests against the agent (for example), you must specify the agent's URL on the command line:
```
cabal run test:tests-gdw-api -- test -u "http://localhost:8100"
```
By default, the test suite will discover what capabilities the agent exposes by querying it. Otherwise, the user can use command line flags to specify which capabilities their agent has to ensure that it exposes the expected capabilities and that the test suite only runs the tests that correspond to those capabilities.
To set the agent's available the capabilities use `-c` and comma separate them:
```
> cabal run test:tests-gdw-api -- test -u "http://localhost:8100" -c relationships
```
If `-c` is omitted, the default value is `autodetect`. If you have no capabilities, you can specify `none`.
To export the OpenAPI spec, you can run this command, and the spec will be written to stdout.
```
> cabal run test:tests-gdw-api -- export-openapi-spec
```

View File

@ -0,0 +1,90 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE TemplateHaskell #-}
module Test.Data
( Artist (..),
Album (..),
schemaTables,
artists,
artistsAsJson,
artistsAsJsonById,
albums,
albumsAsJson,
sortBy,
)
where
import Control.Lens (ix, (^?))
import Data.Aeson (FromJSON (..), Object, eitherDecodeStrict, withObject, (.:))
import Data.Aeson.Lens (_Number)
import Data.ByteString (ByteString)
import Data.FileEmbed (embedFile, makeRelativeToProject)
import Data.HashMap.Strict (HashMap)
import Data.HashMap.Strict qualified as HashMap
import Data.Hashable (Hashable)
import Data.List (sortOn)
import Data.Maybe (mapMaybe)
import Data.Scientific (Scientific)
import Data.Text (Text)
import GHC.Generics (Generic)
import Hasura.Backends.DataWrapper.API (TableInfo (..))
import Prelude
data Artist = Artist
{ _artistId :: Int,
_artistName :: Text
}
deriving stock (Eq, Show, Ord, Generic)
deriving anyclass (Hashable)
instance FromJSON Artist where
parseJSON = withObject "Artist" $ \obj ->
Artist
<$> obj .: "id"
<*> obj .: "name"
data Album = Album
{ _albumId :: Int,
_albumTitle :: Text,
_albumArtistId :: Int
}
deriving stock (Eq, Show, Ord, Generic)
deriving anyclass (Hashable)
instance FromJSON Album where
parseJSON = withObject "Album" $ \obj ->
Album
<$> obj .: "id"
<*> obj .: "title"
<*> obj .: "artist_id"
schemaBS :: ByteString
schemaBS = $(makeRelativeToProject "tests-gdw-api/Test/Data/schema-tables.json" >>= embedFile)
artistsBS :: ByteString
artistsBS = $(makeRelativeToProject "tests-gdw-api/Test/Data/artists.json" >>= embedFile)
albumsBS :: ByteString
albumsBS = $(makeRelativeToProject "tests-gdw-api/Test/Data/albums.json" >>= embedFile)
schemaTables :: [TableInfo]
schemaTables = sortOn dtiName . either error id . eitherDecodeStrict $ schemaBS
artists :: [Artist]
artists = sortOn _artistId . either error id . eitherDecodeStrict $ artistsBS
artistsAsJson :: [Object]
artistsAsJson = sortBy "id" . either error id . eitherDecodeStrict $ artistsBS
artistsAsJsonById :: HashMap Scientific Object
artistsAsJsonById =
HashMap.fromList $ mapMaybe (\artist -> (,artist) <$> artist ^? ix "id" . _Number) artistsAsJson
albums :: [Album]
albums = sortOn _albumId . either error id . eitherDecodeStrict $ albumsBS
albumsAsJson :: [Object]
albumsAsJson = sortBy "id" . either error id . eitherDecodeStrict $ albumsBS
sortBy :: Text -> [Object] -> [Object]
sortBy propName = sortOn (^? ix propName)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,46 @@
[
{
"name": "artists",
"primary_key": "id",
"description": "Collection of artists of music",
"columns": [
{
"name": "id",
"type": "number",
"nullable": false,
"description": "Artist primary key identifier"
},
{
"name": "name",
"type": "string",
"nullable": false,
"description": "The name of the artist"
}
]
},
{
"name": "albums",
"primary_key": "id",
"description": "Collection of music albums created by artists",
"columns": [
{
"name": "id",
"type": "number",
"nullable": false,
"description": "Album primary key identifier"
},
{
"name": "title",
"type": "string",
"nullable": false,
"description": "The title of the album"
},
{
"name": "artist_id",
"type": "number",
"nullable": false,
"description": "The ID of the artist that created this album"
}
]
}
]

View File

@ -0,0 +1,17 @@
module Test.QuerySpec (spec) where
import Control.Monad (when)
import Hasura.Backends.DataWrapper.API (Capabilities (..), Routes (..))
import Servant.API (NamedRoutes)
import Servant.Client (Client)
import Test.Hspec
import Test.QuerySpec.BasicSpec qualified
import Test.QuerySpec.RelationshipsSpec qualified
import Prelude
spec :: Client IO (NamedRoutes Routes) -> Capabilities -> Spec
spec api Capabilities {..} = do
describe "query API" do
Test.QuerySpec.BasicSpec.spec api
when (dcRelationships) $
Test.QuerySpec.RelationshipsSpec.spec api

View File

@ -0,0 +1,218 @@
module Test.QuerySpec.BasicSpec (spec) where
import Autodocodec.Extended (ValueWrapper (..), ValueWrapper2 (..), ValueWrapper3 (ValueWrapper3))
import Control.Lens (ix, (^?))
import Data.Aeson.Lens (AsNumber (_Number), AsPrimitive (_String))
import Data.HashMap.Strict qualified as HashMap
import Data.List (sortOn)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Ord (Down (..))
import Data.Text (Text)
import Hasura.Backends.DataWrapper.API
import Servant.API (NamedRoutes)
import Servant.Client (Client, (//))
import Test.Data qualified as Data
import Test.Hspec (Spec, describe, it)
import Test.Hspec.Expectations.Pretty (shouldBe)
import Prelude
spec :: Client IO (NamedRoutes Routes) -> Spec
spec api = describe "Basic Queries" $ do
describe "Column Fields" $ do
it "can query for a list of artists" $ do
let query = artistsQuery
receivedArtists <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query
let expectedArtists = Data.artistsAsJson
receivedArtists `shouldBe` expectedArtists
it "can query for a list of albums with a subset of columns" $ do
let fields = HashMap.fromList [("artist_id", columnField "artist_id"), ("title", columnField "title")]
let query = albumsQuery {fields}
receivedAlbums <- fmap (Data.sortBy "title" . getQueryResponse) $ api // _query $ query
let filterToRequiredProperties =
HashMap.filterWithKey (\propName _value -> propName == "artist_id" || propName == "title")
let expectedAlbums = Data.sortBy "title" $ filterToRequiredProperties <$> Data.albumsAsJson
receivedAlbums `shouldBe` expectedAlbums
it "can project columns into fields with different names" $ do
let fields = HashMap.fromList [("artist_id", columnField "id"), ("artist_name", columnField "name")]
let query = artistsQuery {fields}
receivedArtists <- fmap (Data.sortBy "artist_id" . getQueryResponse) $ api // _query $ query
let renameProperties =
HashMap.mapKeys
( \case
"id" -> "artist_id"
"name" -> "artist_name"
other -> other
)
let expectedArtists = Data.sortBy "artist_id" $ renameProperties <$> Data.artistsAsJson
receivedArtists `shouldBe` expectedArtists
describe "Limit & Offset" $ do
it "can use limit and offset to paginate results" $ do
let allQuery = artistsQuery
let page1Query = artistsQuery {limit = Just 10, offset = Just 0}
let page2Query = artistsQuery {limit = Just 10, offset = Just 10}
allArtists <- fmap getQueryResponse $ api // _query $ allQuery
page1Artists <- fmap getQueryResponse $ api // _query $ page1Query
page2Artists <- fmap getQueryResponse $ api // _query $ page2Query
page1Artists `shouldBe` take 10 allArtists
page2Artists `shouldBe` take 10 (drop 10 allArtists)
describe "Order By" $ do
it "can use order by to order results in ascending order" $ do
let orderBy = OrderBy (ColumnName "title") Ascending :| []
let query = albumsQuery {orderBy = Just orderBy}
receivedAlbums <- fmap getQueryResponse $ api // _query $ query
let expectedAlbums = sortOn (^? ix "title") Data.albumsAsJson
receivedAlbums `shouldBe` expectedAlbums
it "can use order by to order results in descending order" $ do
let orderBy = OrderBy (ColumnName "title") Descending :| []
let query = albumsQuery {orderBy = Just orderBy}
receivedAlbums <- fmap getQueryResponse $ api // _query $ query
let expectedAlbums = sortOn (Down . (^? ix "title")) Data.albumsAsJson
receivedAlbums `shouldBe` expectedAlbums
it "can use multiple order bys to order results" $ do
let orderBy = OrderBy (ColumnName "artist_id") Ascending :| [OrderBy (ColumnName "title") Descending]
let query = albumsQuery {orderBy = Just orderBy}
receivedAlbums <- fmap getQueryResponse $ api // _query $ query
let expectedAlbums =
sortOn (\album -> (album ^? ix "artist_id", Down (album ^? ix "title"))) Data.albumsAsJson
receivedAlbums `shouldBe` expectedAlbums
describe "Where" $ do
it "can filter using an equality expression" $ do
let where' = Equal (ValueWrapper2 (Column (ValueWrapper (ColumnName "id"))) (Literal (ValueWrapper (Number 2))))
let query = albumsQuery {where_ = Just where'}
receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query
let expectedAlbums =
filter ((== Just 2) . (^? ix "id" . _Number)) Data.albumsAsJson
receivedAlbums `shouldBe` expectedAlbums
it "can filter using an inequality expression" $ do
let where' = NotEqual (ValueWrapper2 (Column (ValueWrapper (ColumnName "id"))) (Literal (ValueWrapper (Number 2))))
let query = albumsQuery {where_ = Just where'}
receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query
let expectedAlbums =
filter ((/= Just 2) . (^? ix "id" . _Number)) Data.albumsAsJson
receivedAlbums `shouldBe` expectedAlbums
it "can filter using an in expression" $ do
let where' = In (ValueWrapper2 (Column (ValueWrapper (ColumnName "id"))) [Number 2, Number 3])
let query = albumsQuery {where_ = Just where'}
receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query
let expectedAlbums =
filter (flip elem [Just 2, Just 3] . (^? ix "id" . _Number)) Data.albumsAsJson
receivedAlbums `shouldBe` expectedAlbums
it "can negate an in expression filter using a not expression" $ do
let where' = Not (ValueWrapper (In (ValueWrapper2 (Column (ValueWrapper (ColumnName "id"))) [Number 2, Number 3])))
let query = albumsQuery {where_ = Just where'}
receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query
let expectedAlbums =
filter (flip notElem [Just 2, Just 3] . (^? ix "id" . _Number)) Data.albumsAsJson
receivedAlbums `shouldBe` expectedAlbums
it "can combine filters using an and expression" $ do
let where1 = Equal (ValueWrapper2 (Column (ValueWrapper (ColumnName "artist_id"))) (Literal (ValueWrapper (Number 58))))
let where2 = Equal (ValueWrapper2 (Column (ValueWrapper (ColumnName "title"))) (Literal (ValueWrapper (String "Stormbringer"))))
let where' = And (ValueWrapper [where1, where2])
let query = albumsQuery {where_ = Just where'}
receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query
let expectedAlbums =
filter
( \album ->
(album ^? ix "artist_id" . _Number == Just 58) && (album ^? ix "title" . _String == Just "Stormbringer")
)
Data.albumsAsJson
receivedAlbums `shouldBe` expectedAlbums
it "can combine filters using an or expression" $ do
let where1 = Equal (ValueWrapper2 (Column (ValueWrapper (ColumnName "id"))) (Literal (ValueWrapper (Number 2))))
let where2 = Equal (ValueWrapper2 (Column (ValueWrapper (ColumnName "id"))) (Literal (ValueWrapper (Number 3))))
let where' = Or (ValueWrapper [where1, where2])
let query = albumsQuery {where_ = Just where'}
receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query
let expectedAlbums =
filter (flip elem [Just 2, Just 3] . (^? ix "id" . _Number)) Data.albumsAsJson
receivedAlbums `shouldBe` expectedAlbums
it "can filter by applying the greater than operator" $ do
let where' = ApplyOperator (ValueWrapper3 GreaterThan (Column (ValueWrapper (ColumnName "id"))) (Literal (ValueWrapper (Number 300))))
let query = albumsQuery {where_ = Just where'}
receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query
let expectedAlbums =
filter ((> Just 300) . (^? ix "id" . _Number)) Data.albumsAsJson
receivedAlbums `shouldBe` expectedAlbums
it "can filter by applying the greater than or equal operator" $ do
let where' = ApplyOperator (ValueWrapper3 GreaterThanOrEqual (Column (ValueWrapper (ColumnName "id"))) (Literal (ValueWrapper (Number 300))))
let query = albumsQuery {where_ = Just where'}
receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query
let expectedAlbums =
filter ((>= Just 300) . (^? ix "id" . _Number)) Data.albumsAsJson
receivedAlbums `shouldBe` expectedAlbums
it "can filter by applying the less than operator" $ do
let where' = ApplyOperator (ValueWrapper3 LessThan (Column (ValueWrapper (ColumnName "id"))) (Literal (ValueWrapper (Number 100))))
let query = albumsQuery {where_ = Just where'}
receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query
let expectedAlbums =
filter ((< Just 100) . (^? ix "id" . _Number)) Data.albumsAsJson
receivedAlbums `shouldBe` expectedAlbums
it "can filter by applying the less than or equal operator" $ do
let where' = ApplyOperator (ValueWrapper3 LessThanOrEqual (Column (ValueWrapper (ColumnName "id"))) (Literal (ValueWrapper (Number 100))))
let query = albumsQuery {where_ = Just where'}
receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query
let expectedAlbums =
filter ((<= Just 100) . (^? ix "id" . _Number)) Data.albumsAsJson
receivedAlbums `shouldBe` expectedAlbums
artistsQuery :: Query
artistsQuery =
let fields = HashMap.fromList [("id", columnField "id"), ("name", columnField "name")]
tableName = TableName "artists"
in Query fields tableName Nothing Nothing Nothing Nothing
albumsQuery :: Query
albumsQuery =
let fields = HashMap.fromList [("id", columnField "id"), ("artist_id", columnField "artist_id"), ("title", columnField "title")]
tableName = TableName "albums"
in Query fields tableName Nothing Nothing Nothing Nothing
columnField :: Text -> Field
columnField = ColumnField . ValueWrapper . ColumnName

View File

@ -0,0 +1,93 @@
module Test.QuerySpec.RelationshipsSpec (spec) where
import Autodocodec.Extended (ValueWrapper (..))
import Control.Lens (ix, (^?))
import Data.Aeson (Object, Value (..))
import Data.Aeson qualified as J
import Data.Aeson.Lens (_Number)
import Data.HashMap.Strict qualified as HashMap
import Data.List.NonEmpty (NonEmpty (..))
import Data.Text (Text)
import Data.Vector qualified as Vector
import Hasura.Backends.DataWrapper.API
import Servant.API (NamedRoutes)
import Servant.Client (Client, (//))
import Test.Data qualified as Data
import Test.Hspec (Spec, describe, it)
import Test.Hspec.Expectations.Pretty (shouldBe)
import Prelude
spec :: Client IO (NamedRoutes Routes) -> Spec
spec api = describe "Relationship Queries" $ do
it "perform a many to one query by joining artist to albums" $ do
let query = albumsWithArtistQuery id
receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query
let joinInArtist (album :: Object) =
let artist = (album ^? ix "artist_id" . _Number) >>= \artistId -> Data.artistsAsJsonById ^? ix artistId
artistPropVal = maybe J.Null (Array . Vector.singleton . Object) artist
in HashMap.insert "artist" artistPropVal album
let removeArtistId = HashMap.delete "artist_id"
let expectedAlbums = (removeArtistId . joinInArtist) <$> Data.albumsAsJson
receivedAlbums `shouldBe` expectedAlbums
it "perform a one to many query by joining albums to artists" $ do
let query = artistsWithAlbumsQuery id
receivedArtists <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query
let joinInAlbums (artist :: Object) =
let artistId = artist ^? ix "id" . _Number
albums = maybe [] (\artistId' -> filter (\album -> album ^? ix "artist_id" . _Number == Just artistId') Data.albumsAsJson) artistId
albums' = Object . HashMap.delete "artist_id" <$> albums
in HashMap.insert "albums" (Array . Vector.fromList $ albums') artist
let expectedAlbums = joinInAlbums <$> Data.artistsAsJson
receivedArtists `shouldBe` expectedAlbums
albumsWithArtistQuery :: (Query -> Query) -> Query
albumsWithArtistQuery modifySubquery =
let joinFieldMapping =
HashMap.fromList
[ (PrimaryKey $ ColumnName "artist_id", ForeignKey $ ColumnName "id")
]
artistsSubquery = modifySubquery artistsQuery
fields =
HashMap.fromList
[ ("id", columnField "id"),
("title", columnField "title"),
("artist", RelationshipField $ RelField joinFieldMapping artistsSubquery)
]
in albumsQuery {fields}
artistsWithAlbumsQuery :: (Query -> Query) -> Query
artistsWithAlbumsQuery modifySubquery =
let joinFieldMapping =
HashMap.fromList
[ (PrimaryKey $ ColumnName "id", ForeignKey $ ColumnName "artist_id")
]
albumFields = HashMap.fromList [("id", columnField "id"), ("title", columnField "title")]
albumsSort = OrderBy (ColumnName "id") Ascending :| []
albumsSubquery = modifySubquery (albumsQuery {fields = albumFields, orderBy = Just albumsSort})
fields =
HashMap.fromList
[ ("id", columnField "id"),
("name", columnField "name"),
("albums", RelationshipField $ RelField joinFieldMapping albumsSubquery)
]
in artistsQuery {fields}
artistsQuery :: Query
artistsQuery =
let fields = HashMap.fromList [("id", columnField "id"), ("name", columnField "name")]
tableName = TableName "artists"
in Query fields tableName Nothing Nothing Nothing Nothing
albumsQuery :: Query
albumsQuery =
let fields = HashMap.fromList [("id", columnField "id"), ("artist_id", columnField "artist_id"), ("title", columnField "title")]
tableName = TableName "albums"
in Query fields tableName Nothing Nothing Nothing Nothing
columnField :: Text -> Field
columnField = ColumnField . ValueWrapper . ColumnName

View File

@ -0,0 +1,20 @@
module Test.SchemaSpec (spec) where
import Data.List (sortOn)
import Hasura.Backends.DataWrapper.API (Capabilities, Routes (..), SchemaResponse (..), TableInfo (..))
import Servant.API (NamedRoutes)
import Servant.Client (Client, (//))
import Test.Data qualified as Data
import Test.Hspec (Spec, describe, it)
import Test.Hspec.Expectations.Pretty (shouldBe)
import Prelude
spec :: Client IO (NamedRoutes Routes) -> Capabilities -> Spec
spec api expectedCapabilities = describe "schema API" $ do
it "returns the expected capabilities" $ do
capabilities <- fmap srCapabilities $ api // _schema
capabilities `shouldBe` expectedCapabilities
it "returns Chinook schema" $ do
tables <- fmap (sortOn dtiName . srTables) $ api // _schema
tables `shouldBe` Data.schemaTables