graphql-engine/server/tests-hspec/Test/RemoteRelationship/XToDBArrayRelationshipSpec.hs

812 lines
21 KiB
Haskell
Raw Normal View History

-- | Tests for array remote relationships to databases. Remote relationships are
-- relationships that are not local to a given source or remote schema, and are
-- handled by the engine itself. Array relationsips are 1-to-many relationships.
--
-- All tests use the same GraphQL syntax, and the only difference is in the
-- setup: we do a cartesian product of all kinds of sources we support on the
-- left-hand side and all databases we support on the right-hand side.
module Test.RemoteRelationship.XToDBArrayRelationshipSpec
( spec,
)
where
import Control.Lens (findOf, has, only, (^?!))
import Data.Aeson (Value)
import Data.Aeson.Lens (key, values, _String)
import Data.Foldable (for_)
import Data.Maybe qualified as Unsafe (fromJust)
import Harness.Backend.Postgres qualified as Postgres
import Harness.GraphqlEngine qualified as GraphqlEngine
import Harness.Quoter.Graphql (graphql)
import Harness.Quoter.Sql (sql)
import Harness.Quoter.Yaml (shouldBeYaml, shouldReturnYaml, yaml)
import Harness.State (Server, State)
import Harness.Test.Feature (Context (..))
import Harness.Test.Feature qualified as Feature
import Test.Hspec (SpecWith, describe, it)
import Prelude
--------------------------------------------------------------------------------
-- Preamble
spec :: SpecWith State
spec = Feature.runWithLocalState contexts tests
where
lhsContexts = [lhsPostgres]
rhsContexts = [rhsPostgres]
contexts = combine <$> lhsContexts <*> rhsContexts
-- | Combines a lhs and a rhs.
--
-- The rhs is set up first, then the lhs can create the remote relationship.
--
-- Teardown is done in the opposite order.
--
-- The metadata is cleared befored each setup.
combine :: LHSContext -> RHSContext -> Context (Maybe Server)
combine lhs (tableName, rhs) =
Context
{ name = "from " <> lhsName <> " to " <> rhsName,
setup = \state -> do
GraphqlEngine.clearMetadata state
rhsSetup state
lhsSetup state,
teardown = \state@(globalState, _) -> do
lhsTeardown state
rhsTeardown (globalState, ()),
customOptions =
Feature.combineOptions lhsOptions rhsOptions
}
where
Context {name = lhsName, setup = lhsSetup, teardown = lhsTeardown, customOptions = lhsOptions} =
lhs tableName
Context {name = rhsName, setup = rhsSetup, teardown = rhsTeardown, customOptions = rhsOptions} =
rhs
--------------------------------------------------------------------------------
-- | LHS context.
--
-- Each LHS context is responsible for setting up the remote relationship, and
-- for tearing it down. Each lhs context is given the JSON representation for
-- the table name on the RHS.
type LHSContext = Value -> Context (Maybe Server)
lhsPostgres :: LHSContext
lhsPostgres tableName =
Context
{ name = "Postgres",
setup = lhsPostgresSetup tableName,
teardown = lhsPostgresTeardown,
customOptions = Nothing
}
{-
lhsRemoteServer :: Value -> Context
lhsRemoteServer tableName = Context "from RS" (lhsRemoteSetup tableName) lhsRemoteTeardown
-}
--------------------------------------------------------------------------------
-- | RHS context
--
-- Each RHS context is responsible for setting up the target table, and for
-- returning the JSON representation of said table.
type RHSContext = (Value, Context ())
rhsPostgres :: RHSContext
rhsPostgres =
let table =
[yaml|
schema: hasura
name: album
|]
context =
Context
{ name = "Postgres",
setup = rhsPostgresSetup,
teardown = rhsPostgresTeardown,
customOptions = Nothing
}
in (table, context)
{-
rhsMSSQL :: (Value, Context)
rhsMSSQL = ([yaml|{"schema":"hasura", "name":"album"}|], Context "MSSQL" rhsMSSQLSetup rhsMSSQLTeardown)
-}
--------------------------------------------------------------------------------
-- LHS Postgres
lhsPostgresSetup :: Value -> State -> IO (Maybe Server)
lhsPostgresSetup rhsTableName state = do
Postgres.run_
[sql|
create table hasura.artist (
id int null,
name text not null
);
insert into hasura.artist (id, name) values
(1, 'artist1'),
(2, 'artist2'),
(3, 'artist_no_albums'),
(null, 'artist_no_id');
|]
let lhsTableName = [yaml|{"schema":"hasura", "name":"artist"}|]
sourceConfig = Postgres.defaultSourceConfiguration
GraphqlEngine.postMetadata_
state
[yaml|
type: bulk
args:
- type: pg_add_source
args:
name: source
configuration: *sourceConfig
- type: pg_track_table
args:
source: source
table: *lhsTableName
- type: pg_create_select_permission
args:
source: source
role: role1
table: *lhsTableName
permission:
columns: '*'
filter: {}
- type: pg_create_select_permission
args:
source: source
role: role2
table: *lhsTableName
permission:
columns: '*'
filter: {}
- type: pg_create_remote_relationship
args:
source: source
table: *lhsTableName
name: albums
definition:
to_source:
source: target
table: *rhsTableName
relationship_type: array
field_mapping:
id: artist_id
|]
pure Nothing
lhsPostgresTeardown :: (State, Maybe Server) -> IO ()
lhsPostgresTeardown _ =
Postgres.run_
[sql|
DROP TABLE hasura.artist;
|]
--------------------------------------------------------------------------------
-- RHS Postgres
rhsPostgresSetup :: State -> IO ()
rhsPostgresSetup state = do
Postgres.run_
[sql|
create table hasura.album (
id serial primary key,
title text not null,
artist_id int null
);
insert into hasura.album (title, artist_id) values
('album1_artist1', 1),
('album2_artist1', 1),
('album3_artist2', 2);
|]
let rhsTableName = [yaml|{"schema":"hasura", "name":"album"}|]
sourceConfig = Postgres.defaultSourceConfiguration
GraphqlEngine.postMetadata_
state
[yaml|
type: bulk
args:
- type: pg_add_source
args:
name: target
configuration: *sourceConfig
- type: pg_track_table
args:
source: target
table: *rhsTableName
- type: pg_create_select_permission
args:
source: target
role: role1
table: *rhsTableName
permission:
columns:
- title
- artist_id
filter:
artist_id:
_eq: x-hasura-artist-id
- type: pg_create_select_permission
args:
source: target
role: role2
table: *rhsTableName
permission:
columns: [id, title, artist_id]
filter:
artist_id:
_eq: x-hasura-artist-id
limit: 1
allow_aggregations: true
|]
rhsPostgresTeardown :: (State, ()) -> IO ()
rhsPostgresTeardown _ =
Postgres.run_
[sql|
DROP TABLE hasura.album;
|]
--------------------------------------------------------------------------------
-- Tests
tests :: Feature.Options -> SpecWith (State, Maybe Server)
tests opts = describe "array-relationship" $ do
schemaTests opts
executionTests opts
permissionTests opts
schemaTests :: Feature.Options -> SpecWith (State, Maybe Server)
schemaTests _opts =
-- we introspect the schema and validate it
it "graphql-schema" $ \(state, _) -> do
let query =
[graphql|
fragment type_info on __Type {
name
kind
ofType {
name
kind
ofType {
name
kind
ofType {
name
}
}
}
}
query {
artist_fields: __type(name: "hasura_artist") {
fields {
name
type {
...type_info
}
args {
name
type {
...type_info
}
}
}
}
}
|]
relationshipFieldArgsSchema =
[yaml|
- name: distinct_on
type:
kind: LIST
name: null
ofType:
kind: NON_NULL
name: null
ofType:
kind: ENUM
name: hasura_album_select_column
ofType: null
- name: limit
type:
kind: SCALAR
name: Int
ofType: null
- name: offset
type:
kind: SCALAR
name: Int
ofType: null
- name: order_by
type:
kind: LIST
name: null
ofType:
kind: NON_NULL
name: null
ofType:
kind: INPUT_OBJECT
name: hasura_album_order_by
ofType: null
- name: where
type:
kind: INPUT_OBJECT
name: hasura_album_bool_exp
ofType: null
|]
introspectionResult <- GraphqlEngine.postGraphql state query
let focusArtistFields =
key "data"
. key "artist_fields"
. key "fields"
. values
albumsField =
Unsafe.fromJust $
findOf
focusArtistFields
(has $ key "name" . _String . only "albums")
introspectionResult
albumsAggregateField =
Unsafe.fromJust $
findOf
focusArtistFields
(has $ key "name" . _String . only "albums_aggregate")
introspectionResult
-- the schema of args should be same for both albums and albums_aggregate field
for_
[ albumsField ^?! key "args",
albumsAggregateField ^?! key "args"
]
\schema ->
schema `shouldBeYaml` relationshipFieldArgsSchema
-- check the return type of albums field
shouldBeYaml
(albumsField ^?! key "type")
[yaml|
kind: NON_NULL
name: null
ofType:
kind: LIST
name: null
ofType:
kind: NON_NULL
name: null
ofType:
name: hasura_album
|]
-- check the return type of albums_aggregate field
shouldBeYaml
(albumsAggregateField ^?! key "type")
[yaml|
name: null
kind: NON_NULL
ofType:
name: hasura_album_aggregate
kind: OBJECT
ofType: null
|]
-- | Basic queries using DB-to-DB joins
executionTests :: Feature.Options -> SpecWith (State, Maybe Server)
executionTests opts = describe "execution" $ do
-- fetches the relationship data
it "related-data" $ \(state, _) -> do
let query =
[graphql|
query {
artist: hasura_artist(where: {name: {_eq: "artist1"}}) {
name
albums {
title
}
}
}
|]
expectedResponse =
[yaml|
data:
artist:
- name: artist1
albums:
- title: album1_artist1
- title: album2_artist1
|]
shouldReturnYaml
opts
(GraphqlEngine.postGraphql state query)
expectedResponse
-- when there are no matching rows, the relationship response should be []
it "related-data-empty-array" $ \(state, _) -> do
let query =
[graphql|
query {
artist: hasura_artist(where: {name: {_eq: "artist_no_albums"}}) {
name
albums {
title
}
}
}
|]
expectedResponse =
[yaml|
data:
artist:
- name: artist_no_albums
albums: []
|]
shouldReturnYaml
opts
(GraphqlEngine.postGraphql state query)
expectedResponse
-- when any of the join columns are null, the relationship should be null
it "related-data-null" $ \(state, _) -> do
let query =
[graphql|
query {
artist: hasura_artist(where: {name: {_eq: "artist_no_id"}}) {
name
albums {
title
}
}
}
|]
expectedResponse =
[yaml|
data:
artist:
- name: artist_no_id
albums: null
|]
shouldReturnYaml
opts
(GraphqlEngine.postGraphql state query)
expectedResponse
-- when the lhs response has both null and non-null values for join columns
it "related-data-non-null-and-null" $ \(state, _) -> do
let query =
[graphql|
query {
artist: hasura_artist(
where: {
_or: [
{name: {_eq: "artist1"}},
{name: {_eq: "artist_no_id"}}
]
},
order_by: {id: asc}
) {
name
albums {
title
}
}
}
|]
expectedResponse =
[yaml|
data:
artist:
- name: artist1
albums:
- title: album1_artist1
- title: album2_artist1
- name: artist_no_id
albums: null
|]
shouldReturnYaml
opts
(GraphqlEngine.postGraphql state query)
expectedResponse
-- TODO:
-- 1. where
-- 1. limit with order_by
-- 1. offset
-- 1. _aggregate
-- | tests that describe an array relationship's data in the presence of permisisons
permissionTests :: Feature.Options -> SpecWith (State, Maybe Server)
permissionTests opts = describe "permission" $ do
-- only the allowed rows on the target table are queryable
it "only-allowed-rows" $ \(state, _) -> do
let userHeaders = [("x-hasura-role", "role1"), ("x-hasura-artist-id", "1")]
query =
[graphql|
query {
artist: hasura_artist(
order_by: {id: asc}
) {
name
albums {
title
}
}
}
|]
expectedResponse =
[yaml|
data:
artist:
- name: artist1
albums:
- title: album1_artist1
- title: album2_artist1
- name: artist2
albums: []
- name: artist_no_albums
albums: []
- name: artist_no_id
albums: null
|]
shouldReturnYaml
opts
(GraphqlEngine.postGraphqlWithHeaders state userHeaders query)
expectedResponse
-- we use an introspection query to check column permissions:
-- 1. the type 'hasura_album' has only 'artist_id' and 'title', the allowed columns
-- 2. the albums field in 'hasura_artist' type is of type 'hasura_album'
it "only-allowed-columns" $ \(state, _) -> do
let userHeaders = [("x-hasura-role", "role1"), ("x-hasura-artist-id", "1")]
query =
[graphql|
query {
album_fields: __type(name: "hasura_album") {
fields {
name
}
}
artist: hasura_artist(
where: {name: {_eq: "artist1"}}
) {
name
albums(limit: 1) {
__typename
}
}
}
|]
expectedResponse =
[yaml|
data:
album_fields:
fields:
- name: artist_id
- name: title
artist:
- name: artist1
albums:
- __typename: hasura_album
|]
shouldReturnYaml
opts
(GraphqlEngine.postGraphqlWithHeaders state userHeaders query)
expectedResponse
-- _aggregate field should not be generated when 'allow_aggregations' isn't set to 'true'
it "aggregations-not-allowed" $ \(state, _) -> do
let userHeaders = [("x-hasura-role", "role1")]
query =
[graphql|
query {
artist_fields: __type(name: "hasura_artist") {
fields {
name
}
}
}
|]
expectedResponse =
[yaml|
data:
artist_fields:
fields:
- name: albums
- name: id
- name: name
|]
shouldReturnYaml
opts
(GraphqlEngine.postGraphqlWithHeaders state userHeaders query)
expectedResponse
-- _aggregate field should only be allowed when 'allow_aggregations' is set to 'true'
it "aggregations-allowed" $ \(state, _) -> do
let userHeaders = [("x-hasura-role", "role2")]
query =
[graphql|
query {
artist_fields: __type(name: "hasura_artist") {
fields {
name
}
}
}
|]
expectedResponse =
[yaml|
data:
artist_fields:
fields:
- name: albums
- name: albums_aggregate
- name: id
- name: name
|]
shouldReturnYaml
opts
(GraphqlEngine.postGraphqlWithHeaders state userHeaders query)
expectedResponse
-- permission limit should kick in when no query limit is specified
it "no-query-limit" $ \(state, _) -> do
let userHeaders = [("x-hasura-role", "role2"), ("x-hasura-artist-id", "1")]
query =
[graphql|
query {
artist: hasura_artist(
where: {name: {_eq: "artist1"}}
) {
name
albums (order_by: {id: asc}){
title
}
}
}
|]
expectedResponse =
[yaml|
data:
artist:
- name: artist1
albums:
- title: album1_artist1
|]
shouldReturnYaml
opts
(GraphqlEngine.postGraphqlWithHeaders state userHeaders query)
expectedResponse
-- query limit should be applied when query limit <= permission limit
it "user-limit-less-than-permission-limit" $ \(state, _) -> do
let userHeaders = [("x-hasura-role", "role2"), ("x-hasura-artist-id", "1")]
query =
[graphql|
query {
artist: hasura_artist(
where: {name: {_eq: "artist1"}}
) {
name
albums (order_by: {id: asc} limit: 0){
title
}
}
}
|]
expectedResponse =
[yaml|
data:
artist:
- name: artist1
albums: []
|]
shouldReturnYaml
opts
(GraphqlEngine.postGraphqlWithHeaders state userHeaders query)
expectedResponse
-- permission limit should be applied when query limit > permission limit
it "user-limit-greater-than-permission-limit" $ \(state, _) -> do
let userHeaders = [("x-hasura-role", "role2"), ("x-hasura-artist-id", "1")]
query =
[graphql|
query {
artist: hasura_artist(
where: {name: {_eq: "artist1"}}
) {
name
albums (order_by: {id: asc} limit: 4){
title
}
}
}
|]
expectedResponse =
[yaml|
data:
artist:
- name: artist1
albums:
- title: album1_artist1
|]
shouldReturnYaml
opts
(GraphqlEngine.postGraphqlWithHeaders state userHeaders query)
expectedResponse
-- permission limit should only apply on 'nodes' but not on 'aggregate'
it "aggregations" $ \(state, _) -> do
let userHeaders = [("x-hasura-role", "role2"), ("x-hasura-artist-id", "1")]
query =
[graphql|
query {
artist: hasura_artist(
where: {name: {_eq: "artist1"}}
) {
name
albums_aggregate (order_by: {id: asc}){
aggregate {
count
}
nodes {
title
}
}
}
}
|]
expectedResponse =
[yaml|
data:
artist:
- name: artist1
albums_aggregate:
aggregate:
count: 2
nodes:
- title: album1_artist1
|]
shouldReturnYaml
opts
(GraphqlEngine.postGraphqlWithHeaders state userHeaders query)
expectedResponse
-- query limit applies to both 'aggregate' and 'nodes'
it "aggregations-query-limit" $ \(state, _) -> do
let userHeaders = [("x-hasura-role", "role2"), ("x-hasura-artist-id", "1")]
query =
[graphql|
query {
artist: hasura_artist(
where: {name: {_eq: "artist1"}}
) {
name
albums_aggregate (limit: 1 order_by: {id: asc}){
aggregate {
count
}
nodes {
title
}
}
}
}
|]
expectedResponse =
[yaml|
data:
artist:
- name: artist1
albums_aggregate:
aggregate:
count: 1
nodes:
- title: album1_artist1
|]
shouldReturnYaml
opts
(GraphqlEngine.postGraphqlWithHeaders state userHeaders query)
expectedResponse