Structured Error Protocol for Data Connectors Agents - GDW-137

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/6061
Co-authored-by: Vishnu Bharathi <4211715+scriptnull@users.noreply.github.com>
GitOrigin-RevId: 855d96378030f4e01b0c74b00e20e592e51e7a49
This commit is contained in:
Lyndon Maydwell 2022-10-11 10:25:07 +10:00 committed by hasura-bot
parent 34d01caebb
commit d54bb30d3b
47 changed files with 693 additions and 181 deletions

View File

@ -1457,6 +1457,19 @@ For example, here's a query that retrieves artists ordered descending by the cou
The `QueryRequest` TypeScript type in the [reference implementation](./reference/src/types/index.ts) describes the valid request body payloads which may be passed to the `POST /query` endpoint. The response body structure is captured by the `QueryResponse` type.
### Health endpoint
Agents must expose a `/health` endpoint which must return a 204 No Content HTTP response code if the agent is up and running. This does not mean that the agent is able to connect to any data source it performs queries against, only that the agent is running and can accept requests, even if some of those requests might fail because a dependant service is unavailable.
However, this endpoint can also be used to check whether the ability of the agent to talk to a particular data source is healthy. If the endpoint is sent the `X-Hasura-DataConnector-Config` and `X-Hasura-DataConnector-SourceName` headers, then the agent is expected to check that it can successfully talk to whatever data source is being specified by those headers. If it can do so, then it must return a 204 No Content response code.
### Reporting Errors
Any non-200 response code from an Agent (except for the `/health` endpoint) will be interpreted as an error. These should be handled gracefully by `graphql-engine` but provide limited details to users. If you wish to return structured error information to users you can return a status of `500` from the `/capabilities`, `/schema`, and `/query` endpoints with the following JSON format:
```
{
"type": "uncaught-error", // This may be extended to more types in future
"message": String, // A plain-text message for display purposes
"details": Value // An arbitrary JSON Value containing error details
}
```

View File

@ -1,6 +1,6 @@
{
"name": "@hasura/dc-api-types",
"version": "0.10.0",
"version": "0.11.0",
"description": "Hasura GraphQL Engine Data Connector Agent API types",
"author": "Hasura (https://github.com/hasura/graphql-engine)",
"license": "Apache-2.0",

View File

@ -17,6 +17,16 @@
}
},
"description": ""
},
"500": {
"content": {
"application/json;charset=utf-8": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": ""
}
}
}
@ -56,6 +66,16 @@
},
"400": {
"description": "Invalid `X-Hasura-DataConnector-Config` or `X-Hasura-DataConnector-SourceName`"
},
"500": {
"content": {
"application/json;charset=utf-8": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": ""
}
}
}
@ -104,6 +124,16 @@
},
"400": {
"description": "Invalid `body` or `X-Hasura-DataConnector-Config` or `X-Hasura-DataConnector-SourceName`"
},
"500": {
"content": {
"application/json;charset=utf-8": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": ""
}
}
}
@ -682,6 +712,32 @@
},
"type": "object"
},
"ErrorResponse": {
"properties": {
"details": {
"additionalProperties": true,
"default": null,
"description": "Error details"
},
"message": {
"description": "Error message",
"type": "string"
},
"type": {
"$ref": "#/components/schemas/ErrorResponseType"
}
},
"required": [
"message"
],
"type": "object"
},
"ErrorResponseType": {
"enum": [
"uncaught-error"
],
"type": "string"
},
"SchemaResponse": {
"properties": {
"tables": {

View File

@ -23,6 +23,8 @@ export type { ComparisonValue } from './models/ComparisonValue';
export type { ConfigSchemaResponse } from './models/ConfigSchemaResponse';
export type { Constraint } from './models/Constraint';
export type { DataSchemaCapabilities } from './models/DataSchemaCapabilities';
export type { ErrorResponse } from './models/ErrorResponse';
export type { ErrorResponseType } from './models/ErrorResponseType';
export type { ExistsExpression } from './models/ExistsExpression';
export type { ExistsInTable } from './models/ExistsInTable';
export type { ExplainCapabilities } from './models/ExplainCapabilities';

View File

@ -0,0 +1,18 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ErrorResponseType } from './ErrorResponseType';
export type ErrorResponse = {
/**
* Error details
*/
details?: any;
/**
* Error message
*/
message: string;
type?: ErrorResponseType;
};

View File

@ -0,0 +1,5 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ErrorResponseType = 'uncaught-error';

View File

@ -24,7 +24,7 @@
},
"dc-api-types": {
"name": "@hasura/dc-api-types",
"version": "0.10.0",
"version": "0.11.0",
"license": "Apache-2.0",
"devDependencies": {
"@tsconfig/node16": "^1.0.3",
@ -631,7 +631,7 @@
"license": "Apache-2.0",
"dependencies": {
"@fastify/cors": "^7.0.0",
"@hasura/dc-api-types": "0.10.0",
"@hasura/dc-api-types": "0.11.0",
"fastify": "^3.29.0",
"mathjs": "^11.0.0",
"pino-pretty": "^8.0.0",
@ -1389,7 +1389,7 @@
"license": "Apache-2.0",
"dependencies": {
"@fastify/cors": "^8.1.0",
"@hasura/dc-api-types": "0.10.0",
"@hasura/dc-api-types": "0.11.0",
"fastify": "^4.4.0",
"fastify-metrics": "^9.2.1",
"nanoid": "^3.3.4",
@ -3122,7 +3122,7 @@
"version": "file:reference",
"requires": {
"@fastify/cors": "^7.0.0",
"@hasura/dc-api-types": "0.10.0",
"@hasura/dc-api-types": "0.11.0",
"@tsconfig/node16": "^1.0.3",
"@types/node": "^16.11.49",
"@types/xml2js": "^0.4.11",
@ -3613,7 +3613,7 @@
"version": "file:sqlite",
"requires": {
"@fastify/cors": "^8.1.0",
"@hasura/dc-api-types": "0.10.0",
"@hasura/dc-api-types": "0.11.0",
"@tsconfig/node16": "^1.0.3",
"@types/node": "^16.11.49",
"@types/sqlite3": "^3.1.8",

View File

@ -10,7 +10,7 @@
"license": "Apache-2.0",
"dependencies": {
"@fastify/cors": "^7.0.0",
"@hasura/dc-api-types": "0.10.0",
"@hasura/dc-api-types": "0.11.0",
"fastify": "^3.29.0",
"mathjs": "^11.0.0",
"pino-pretty": "^8.0.0",
@ -44,7 +44,7 @@
}
},
"node_modules/@hasura/dc-api-types": {
"version": "0.10.0",
"version": "0.11.0",
"license": "Apache-2.0",
"devDependencies": {
"@tsconfig/node16": "^1.0.3",

View File

@ -22,7 +22,7 @@
},
"dependencies": {
"@fastify/cors": "^7.0.0",
"@hasura/dc-api-types": "0.10.0",
"@hasura/dc-api-types": "0.11.0",
"fastify": "^3.29.0",
"mathjs": "^11.0.0",
"pino-pretty": "^8.0.0",

View File

@ -9,11 +9,11 @@ The SQLite agent currently supports the following capabilities:
* [x] GraphQL Schema
* [x] GraphQL Queries
* [ ] GraphQL Mutations
* [x] Relationships
* [x] Aggregations
* [x] Prometheus Metrics
* [ ] Exposing Foreign-Key Information
* [x] Exposing Foreign-Key Information
* [ ] Mutations
* [ ] Subscriptions
* [ ] Streaming Subscriptions
@ -26,6 +26,7 @@ Note: You are able to get detailed metadata about the agent's capabilities by
* SQLite `>= 3.38.0` or compiled in JSON support
* Required for the json_group_array() and json_group_object() aggregate SQL functions
* https://www.sqlite.org/json1.html#jgrouparray
* Note: NPM is used for the [TS Types for the DC-API protocol](https://www.npmjs.com/package/@hasura/dc-api-types)
## Build & Run
@ -41,6 +42,15 @@ Or a simple dev-loop via `entr`:
echo src/**/*.ts | xargs -n1 echo | DB_READONLY=y entr -r npm run start
```
## Docker Build & Run
```
> docker build . -t dc-sqlite-agent:latest
> docker run -it --rm -p 8100:8100 dc-sqlite-agent:latest
```
You will want to mount a volume with your database(s) so that they can be referenced in configuration.
## Options / Environment Variables
Note: Boolean flags `{FLAG}` can be provided as `1`, `true`, `yes`, or omitted and default to `false`.
@ -57,6 +67,7 @@ Note: Boolean flags `{FLAG}` can be provided as `1`, `true`, `yes`, or omitted a
| `PRETTY_PRINT_LOGS` | `{FLAG}` | `false` | Uses `pino-pretty` to pretty print request logs |
| `LOG_LEVEL` | `fatal` \| `error` \| `info` \| `debug` \| `trace` \| `silent` | `info` | The minimum log level to output |
| `METRICS` | `{FLAG}` | `false` | Enables a `/metrics` prometheus metrics endpoint.
| `QUERY_LENGTH_LIMIT` | `INT` | `Infinity` | Puts a limit on the length of generated SQL before execution. |
## Agent usage
@ -69,16 +80,6 @@ The schema is exposed via introspection, but you can limit which tables are refe
* Explicitly enumerating them via the `tables` field, or
* Toggling the `include_sqlite_meta_tables` to include or exclude sqlite meta tables.
## Docker Build & Run
```
> docker build . -t dc-sqlite-agent:latest
> docker run -it --rm -p 8100:8100 dc-sqlite-agent:latest
```
You will want to mount a volume with your database(s) so that they can be referenced in configuration.
## Dataset
The dataset used for testing the reference agent is sourced from:
@ -130,27 +131,6 @@ From the HGE repo.
* [x] ORDER clause in aggregates breaks SQLite parser for some reason
* [x] Check that looped exist check doesn't cause name conflicts
* [ ] `NOT EXISTS IS NULL` != `EXISTS IS NOT NULL`, Example:
sqlite> create table test(testid string);
sqlite> .schema
CREATE TABLE test(testid string);
sqlite> select 1 where exists(select * from test where testid is null);
sqlite> select 1 where exists(select * from test where testid is not null);
sqlite> select 1 where not exists(select * from test where testid is null);
1
sqlite> select 1 where not exists(select * from test where testid is not null);
1
sqlite> insert into test(testid) values('foo');
sqlite> insert into test(testid) values(NULL);
sqlite> select * from test;
foo
sqlite> select 1 where exists(select * from test where testid is null);
1
sqlite> select 1 where exists(select * from test where testid is not null);
1
sqlite> select 1 where not exists(select * from test where testid is null);
sqlite> select 1 where exists(select * from test where testid is not null);
1
# Known Bugs

View File

@ -10,7 +10,7 @@
"license": "Apache-2.0",
"dependencies": {
"@fastify/cors": "^8.1.0",
"@hasura/dc-api-types": "0.10.0",
"@hasura/dc-api-types": "0.11.0",
"fastify": "^4.4.0",
"fastify-metrics": "^9.2.1",
"nanoid": "^3.3.4",
@ -54,7 +54,7 @@
"license": "MIT"
},
"node_modules/@hasura/dc-api-types": {
"version": "0.10.0",
"version": "0.11.0",
"license": "Apache-2.0",
"devDependencies": {
"@tsconfig/node16": "^1.0.3",

View File

@ -22,7 +22,7 @@
},
"dependencies": {
"@fastify/cors": "^8.1.0",
"@hasura/dc-api-types": "0.10.0",
"@hasura/dc-api-types": "0.11.0",
"fastify-metrics": "^9.2.1",
"fastify": "^4.4.0",
"nanoid": "^3.3.4",

View File

@ -35,6 +35,7 @@ export const configSchema: ConfigSchemaResponse = {
config_schema: {
type: "object",
nullable: false,
required: ["db"],
properties: {
db: {
description: "The SQLite database file to use.",

View File

@ -4,7 +4,7 @@ import { getSchema } from './schema';
import { explain, queryData } from './query';
import { getConfig, tryGetConfig } from './config';
import { capabilitiesResponse } from './capabilities';
import { QueryResponse, SchemaResponse, QueryRequest, CapabilitiesResponse, ExplainResponse, RawRequest, RawResponse } from '@hasura/dc-api-types';
import { QueryResponse, SchemaResponse, QueryRequest, CapabilitiesResponse, ExplainResponse, RawRequest, RawResponse, ErrorResponse } from '@hasura/dc-api-types';
import { connect } from './db';
import { envToBool, envToString } from './util';
import metrics from 'fastify-metrics';
@ -30,6 +30,20 @@ const server = Fastify({
}
})
server.setErrorHandler(function (error, _request, reply) {
// Log error
this.log.error(error)
const errorResponse: ErrorResponse = {
type: "uncaught-error",
message: "SQLite Agent: Uncaught Exception",
details: error
};
// Send error response
reply.status(500).send(errorResponse);
})
const METRICS_ENABLED = envToBool('METRICS');
if(METRICS_ENABLED) {
@ -88,7 +102,12 @@ const sqlLogger = (sql: string): void => {
server.log.debug({sql}, "Executed SQL");
};
server.get<{ Reply: CapabilitiesResponse }>("/capabilities", async (request, _response) => {
// NOTE:
//
// While an ErrorResponse is available it is not currently used as there are no errors anticipated.
// It is included here for illustrative purposes.
//
server.get<{ Reply: CapabilitiesResponse | ErrorResponse }>("/capabilities", async (request, _response) => {
server.log.info({ headers: request.headers, query: request.body, }, "capabilities.request");
return capabilitiesResponse;
});
@ -99,12 +118,15 @@ server.get<{ Reply: SchemaResponse }>("/schema", async (request, _response) => {
return getSchema(config, sqlLogger);
});
server.post<{ Body: QueryRequest, Reply: QueryResponse }>("/query", async (request, _response) => {
server.post<{ Body: QueryRequest, Reply: QueryResponse | ErrorResponse }>("/query", async (request, response) => {
server.log.info({ headers: request.headers, query: request.body, }, "query.request");
const end = queryHistogram.startTimer()
const config = getConfig(request);
const result = queryData(config, sqlLogger, request.body);
const result : QueryResponse | ErrorResponse = await queryData(config, sqlLogger, request.body);
end();
if("message" in result) {
response.statusCode = 500;
}
return result;
});

View File

@ -1,6 +1,6 @@
import { Config } from "./config";
import { connect, SqlLogger } from "./db";
import { coerceUndefinedToNull, omap, last, coerceUndefinedOrNullToEmptyRecord, envToBool, isEmptyObject, tableNameEquals, unreachable, logDeep } from "./util";
import { coerceUndefinedToNull, omap, last, coerceUndefinedOrNullToEmptyRecord, envToBool, isEmptyObject, tableNameEquals, unreachable, logDeep, envToString, envToNum } from "./util";
import {
Expression,
BinaryComparisonOperator,
@ -20,6 +20,7 @@ import {
UnaryComparisonOperator,
ExplainResponse,
ExistsExpression,
ErrorResponse,
} from "@hasura/dc-api-types";
import { customAlphabet } from "nanoid";
@ -507,12 +508,25 @@ function tag(t: string, s: string): string {
* ```
*
*/
export async function queryData(config: Config, sqlLogger: SqlLogger, queryRequest: QueryRequest): Promise<QueryResponse> {
export async function queryData(config: Config, sqlLogger: SqlLogger, queryRequest: QueryRequest): Promise<QueryResponse | ErrorResponse> {
const db = connect(config, sqlLogger); // TODO: Should this be cached?
const q = query(queryRequest);
const [result, metadata] = await db.query(q);
return output(result);
const query_length_limit = envToNum('QUERY_LENGTH_LIMIT', Infinity);
if(q.length > query_length_limit) {
const result: ErrorResponse =
{
message: `Generated SQL Query was too long (${q.length} > ${query_length_limit})`,
details: {
"query.length": q.length,
"limit": query_length_limit
}
};
return result;
} else {
const [result, metadata] = await db.query(q);
return output(result);
}
}
/**

View File

@ -7,6 +7,6 @@ export async function runRawOperation(config: Config, sqlLogger: SqlLogger, quer
const [results, metadata] = await db.query(query.query);
return {
rows: results as Array<Record<string, any>>
rows: (results || []) as Array<Record<string, any>>
};
};

View File

@ -38,6 +38,11 @@ export function envToString(envVarName: string, defaultValue: string): string {
return val === undefined ? defaultValue : val;
}
export function envToNum(envVarName: string, defaultValue: number): number {
const val = process.env[envVarName];
return val === undefined ? defaultValue : Number(val);
}
export function last<T>(x: Array<T>): T {
return x[x.length - 1];
}

View File

@ -6,7 +6,7 @@ GENERATED_CABAL_FILES = $(foreach package_file,$(PACKAGE_YAML_FILES),$(wildcard
.PHONY: build-all
## build-all: build all haskell packages, or "have i broken anything?"
build-all: build build-tests build-integration-tests build-pro build-pro-tests build-multitenant build-multitenant-integration-tests
build-all: build build-tests build-integration-tests build-pro build-pro-tests build-multitenant build-multitenant-integration-tests build-tests-dc-api
.PHONY: build
## build: build non-pro graphql executable
@ -23,6 +23,11 @@ build-tests: $(GENERATED_CABAL_FILES)
build-integration-tests: $(GENERATED_CABAL_FILES)
cabal build api-tests
.PHONY: build-tests-dc-api
## build-dc-api-tests: build dc-api agent tests
build-tests-dc-api: $(GENERATED_CABAL_FILES)
cabal build tests-dc-api
.PHONY: build-pro
## build-pro: build pro graphql executable
build-pro: $(GENERATED_CABAL_FILES)

View File

@ -23,6 +23,7 @@ executable api-tests
, postgresql-simple
, safe-exceptions
, split
, sop-core
, test-harness
, text
, unordered-containers
@ -66,6 +67,7 @@ executable api-tests
Test.DataConnector.MetadataApiSpec
Test.DataConnector.MockAgent.AggregateQuerySpec
Test.DataConnector.MockAgent.BasicQuerySpec
Test.DataConnector.MockAgent.ErrorSpec
Test.DataConnector.MockAgent.QueryRelationshipsSpec
Test.DataConnector.MockAgent.TransformedConfigurationSpec
Test.DataConnector.QuerySpec

View File

@ -97,7 +97,7 @@ tests opts = describe "Aggregate Query Tests" $ do
)
]
]
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> rowsResponse response},
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> Right (rowsResponse response)},
_whenRequestRequired =
[graphql|
query getArtist {
@ -216,7 +216,7 @@ tests opts = describe "Aggregate Query Tests" $ do
)
]
]
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> aggregatesAndRowsResponse aggregates rows},
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> Right (aggregatesAndRowsResponse aggregates rows)},
_whenRequestRequired =
[graphql|
query getInvoices {

View File

@ -1,3 +1,4 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
-- | Query Tests for Data Connector Backend using a Mock Agent
@ -116,7 +117,7 @@ tests opts = do
(API.FieldName "title", API.mkColumnFieldValue $ Aeson.String "For Those About To Rock We Salute You")
]
]
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> rowsResponse albums},
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> Right (rowsResponse albums)},
_whenRequestRequired =
[graphql|
query getAlbum {
@ -174,7 +175,7 @@ tests opts = do
(API.FieldName "title", API.mkColumnFieldValue $ Aeson.String "Restless and Wild")
]
]
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> rowsResponse albums},
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> Right (rowsResponse albums)},
_whenRequestRequired =
[graphql|
query getAlbum {
@ -233,7 +234,7 @@ tests opts = do
[ (API.FieldName "CustomerId", API.mkColumnFieldValue $ Aeson.Number 3)
]
]
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> rowsResponse albums},
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> Right (rowsResponse albums)},
_whenRequestRequired =
[graphql|
query getCustomers {

View File

@ -0,0 +1,114 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
-- | Tests for Error Conditions in Data Connector Backends
module Test.DataConnector.MockAgent.ErrorSpec
( spec,
)
where
--------------------------------------------------------------------------------
import Data.Aeson qualified as Aeson
import Data.HashMap.Strict qualified as HashMap
import Data.List.NonEmpty qualified as NE
import Harness.Backend.DataConnector (TestCase (..))
import Harness.Backend.DataConnector qualified as DataConnector
import Harness.Quoter.Graphql (graphql)
import Harness.Quoter.Yaml (yaml)
import Harness.Test.BackendType (BackendType (..), defaultBackendTypeString, defaultSource)
import Harness.Test.Fixture qualified as Fixture
import Harness.TestEnvironment (TestEnvironment)
import Hasura.Backends.DataConnector.API qualified as API
import Hasura.Prelude
import Test.Hspec (SpecWith, describe, it)
--------------------------------------------------------------------------------
spec :: SpecWith TestEnvironment
spec =
Fixture.runWithLocalTestEnvironment
( NE.fromList
[ (Fixture.fixture $ Fixture.Backend Fixture.DataConnectorMock)
{ Fixture.mkLocalTestEnvironment = DataConnector.mkLocalTestEnvironmentMock,
Fixture.setupTeardown = \(testEnv, mockEnv) ->
[DataConnector.setupMockAction sourceMetadata DataConnector.mockBackendConfig (testEnv, mockEnv)]
}
]
)
tests
sourceMetadata :: Aeson.Value
sourceMetadata =
let source = defaultSource DataConnectorMock
backendType = defaultBackendTypeString DataConnectorMock
in [yaml|
name : *source
kind: *backendType
tables:
- table: [Album]
configuration:
custom_root_fields:
select: albums
select_by_pk: albums_by_pk
column_config:
AlbumId:
custom_name: id
Title:
custom_name: title
configuration: {}
|]
tests :: Fixture.Options -> SpecWith (TestEnvironment, DataConnector.MockAgentEnvironment)
tests opts = do
describe "Error Protocol Tests" $ do
it "handles returned errors correctly" $
DataConnector.runMockedTest opts $
let errorResponse = API.ErrorResponse API.UncaughtError "Hello World!" [yaml| { foo: "bar" } |]
required =
DataConnector.TestCaseRequired
{ _givenRequired = DataConnector.chinookMock {DataConnector._queryResponse = \_ -> Left errorResponse},
_whenRequestRequired =
[graphql|
query getAlbum {
albums(limit: 1) {
id
title
}
}
|],
_thenRequired =
[yaml|
errors:
-
extensions:
code: "data-connector-error"
path: "$"
internal:
foo: "bar"
message: "UncaughtError: Hello World!"
|]
}
in (DataConnector.defaultTestCase required)
{ _whenQuery =
Just
( API.QueryRequest
{ _qrTable = API.TableName ("Album" :| []),
_qrTableRelationships = [],
_qrQuery =
API.Query
{ _qFields =
Just $
HashMap.fromList
[ (API.FieldName "id", API.ColumnField (API.ColumnName "AlbumId")),
(API.FieldName "title", API.ColumnField (API.ColumnName "Title"))
],
_qAggregates = Nothing,
_qLimit = Just 1,
_qOffset = Nothing,
_qWhere = Nothing,
_qOrderBy = Nothing
}
}
)
}

View File

@ -1,3 +1,4 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Test.DataConnector.MockAgent.QueryRelationshipsSpec
@ -145,7 +146,7 @@ tests opts = do
)
]
]
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> rowsResponse albums},
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> Right (rowsResponse albums)},
_whenRequestRequired =
[graphql|
query getTrack {
@ -273,7 +274,7 @@ tests opts = do
(API.FieldName "Name", API.mkColumnFieldValue $ Aeson.String "Camarão que Dorme e Onda Leva")
]
]
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> rowsResponse albums},
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> Right (rowsResponse albums)},
_whenRequestRequired =
[graphql|
query getTrack {
@ -413,7 +414,7 @@ tests opts = do
[ [ (API.FieldName "EmployeeId", API.mkColumnFieldValue $ Aeson.Number 3)
]
]
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> rowsResponse albums},
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> Right (rowsResponse albums)},
_whenRequestRequired =
[graphql|
query getEmployee {

View File

@ -112,7 +112,7 @@ tests opts = do
(API.FieldName "title", API.mkColumnFieldValue $ Aeson.String "For Those About To Rock We Salute You")
]
]
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> rowsResponse albums},
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> Right (rowsResponse albums)},
_whenRequestRequired =
[graphql|
query getAlbum {

View File

@ -76,6 +76,7 @@ library
Hasura.Backends.DataConnector.API.V0.Capabilities
Hasura.Backends.DataConnector.API.V0.Column
Hasura.Backends.DataConnector.API.V0.ConfigSchema
Hasura.Backends.DataConnector.API.V0.ErrorResponse
Hasura.Backends.DataConnector.API.V0.Expression
Hasura.Backends.DataConnector.API.V0.OrderBy
Hasura.Backends.DataConnector.API.V0.Query
@ -139,6 +140,7 @@ test-suite tests-dc-api
Test.MetricsSpec
Test.ExplainSpec
Test.QuerySpec
Test.ErrorSpec
Test.QuerySpec.AggregatesSpec
Test.QuerySpec.BasicSpec
Test.QuerySpec.FilteringSpec

View File

@ -1,13 +1,21 @@
{-# LANGUAGE ScopedTypeVariables #-}
--
module Hasura.Backends.DataConnector.API
( module V0,
Api,
CapabilitiesResponses,
QueryResponses,
SchemaApi,
SchemaResponses,
QueryApi,
ConfigHeader,
Prometheus,
SourceNameHeader,
SourceName,
capabilitiesCase,
schemaCase,
queryCase,
openApiSchema,
Routes (..),
apiClient,
@ -25,29 +33,69 @@ import Hasura.Backends.DataConnector.API.V0 as V0
import Network.HTTP.Media ((//), (/:))
import Servant.API
import Servant.API.Generic
import Servant.Client (Client, ClientM, client)
import Servant.Client (Client, ClientM, client, matchUnion)
import Servant.OpenApi
import Prelude (show)
import Prelude (Maybe (Just, Nothing), Monad, show)
--------------------------------------------------------------------------------
-- Servant Routes
-- | This function defines a central place to ensure that all cases are covered for capabilities and error responses.
-- When additional responses are added to the Union, this should be updated to ensure that all responses have been considered.
-- A general function of this form doesn't seem easy to write currently as you need a type inequality with ErrorResponse.
capabilitiesCase :: a -> (CapabilitiesResponse -> a) -> (ErrorResponse -> a) -> Union CapabilitiesResponses -> a
capabilitiesCase defaultAction capabilitiesAction errorAction union = do
let capabilitiesM = matchUnion @CapabilitiesResponse union
let errorM = matchUnion @ErrorResponse union
case (capabilitiesM, errorM) of
(Just c, Nothing) -> capabilitiesAction c
(Nothing, Just e) -> errorAction e
_ -> defaultAction -- Note, this could technically include the (Just _, Just _) scenario which is not possible.
type CapabilitiesResponses = '[V0.CapabilitiesResponse, V0.ErrorResponse]
type CapabilitiesApi =
"capabilities"
:> Get '[JSON] V0.CapabilitiesResponse
:> UVerb 'GET '[JSON] CapabilitiesResponses
-- | This function defines a central place to ensure that all cases are covered for schema and error responses.
-- When additional responses are added to the Union, this should be updated to ensure that all responses have been considered.
schemaCase :: Monad m => m a -> (SchemaResponse -> m a) -> (ErrorResponse -> m a) -> Union SchemaResponses -> m a
schemaCase defaultAction schemaAction errorAction union = do
let schemaM = matchUnion @SchemaResponse union
let errorM = matchUnion @ErrorResponse union
case (schemaM, errorM) of
(Just c, Nothing) -> schemaAction c
(Nothing, Just e) -> errorAction e
_ -> defaultAction -- Note, this could technically include the (Just _, Just _) scenario which is not possible.
type SchemaResponses = '[V0.SchemaResponse, V0.ErrorResponse]
type SchemaApi =
"schema"
:> SourceNameHeader Required
:> ConfigHeader Required
:> Get '[JSON] V0.SchemaResponse
:> UVerb 'GET '[JSON] SchemaResponses
-- | This function defines a central place to ensure that all cases are covered for query and error responses.
-- When additional responses are added to the Union, this should be updated to ensure that all responses have been considered.
queryCase :: Monad m => m a -> (QueryResponse -> m a) -> (ErrorResponse -> m a) -> Union QueryResponses -> m a
queryCase defaultAction queryAction errorAction union = do
let queryM = matchUnion @QueryResponse union
let errorM = matchUnion @ErrorResponse union
case (queryM, errorM) of
(Just c, Nothing) -> queryAction c
(Nothing, Just e) -> errorAction e
_ -> defaultAction -- Note, this could technically include the (Just _, Just _) scenario which is not possible.
type QueryResponses = '[V0.QueryResponse, V0.ErrorResponse]
type QueryApi =
"query"
:> SourceNameHeader Required
:> ConfigHeader Required
:> ReqBody '[JSON] V0.QueryRequest
:> Post '[JSON] V0.QueryResponse
:> UVerb 'POST '[JSON] QueryResponses
type ExplainApi =
"explain"

View File

@ -4,6 +4,7 @@ module Hasura.Backends.DataConnector.API.V0
module Column,
module ConfigSchema,
module Expression,
module ErrorResponse,
module OrderBy,
module Query,
module Raw,
@ -19,6 +20,7 @@ import Hasura.Backends.DataConnector.API.V0.Aggregate as Aggregate
import Hasura.Backends.DataConnector.API.V0.Capabilities as Capabilities
import Hasura.Backends.DataConnector.API.V0.Column as Column
import Hasura.Backends.DataConnector.API.V0.ConfigSchema as ConfigSchema
import Hasura.Backends.DataConnector.API.V0.ErrorResponse as ErrorResponse
import Hasura.Backends.DataConnector.API.V0.Explain as Explain
import Hasura.Backends.DataConnector.API.V0.Expression as Expression
import Hasura.Backends.DataConnector.API.V0.OrderBy as OrderBy

View File

@ -33,6 +33,8 @@ import Autodocodec.OpenAPI ()
import Control.DeepSeq (NFData)
import Control.Monad ((<=<))
import Data.Aeson (FromJSON, ToJSON)
import Data.Aeson qualified as J
import Data.Aeson.Text (encodeToLazyText)
import Data.Bifunctor (first)
import Data.Data (Data, Proxy (..))
import Data.Foldable (toList)
@ -43,7 +45,7 @@ import Data.HashMap.Strict.InsOrd qualified as InsOrdHashMap
import Data.Hashable (Hashable)
import Data.List.NonEmpty (NonEmpty, nonEmpty)
import Data.Maybe (mapMaybe)
import Data.OpenApi (NamedSchema (..), OpenApiType (OpenApiObject), Schema (..), ToSchema (..), declareSchemaRef)
import Data.OpenApi (NamedSchema (..), OpenApiType (OpenApiObject, OpenApiString), Referenced (..), Schema (..), ToSchema (..), declareSchemaRef)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Lazy (toStrict)
@ -53,6 +55,8 @@ import Hasura.Backends.DataConnector.API.V0.ConfigSchema (ConfigSchemaResponse)
import Language.GraphQL.Draft.Parser qualified as GQL.Parser
import Language.GraphQL.Draft.Printer qualified as GQL.Printer
import Language.GraphQL.Draft.Syntax qualified as GQL.Syntax
import Servant.API (HasStatus)
import Servant.API.UVerb qualified as Servant
import Prelude
-- | The 'Capabilities' describes the _capabilities_ of the
@ -321,6 +325,9 @@ data CapabilitiesResponse = CapabilitiesResponse
deriving stock (Eq, Show, Generic)
deriving (FromJSON, ToJSON) via Autodocodec CapabilitiesResponse
instance Servant.HasStatus CapabilitiesResponse where
type StatusOf CapabilitiesResponse = 200
instance HasCodec CapabilitiesResponse where
codec =
object "CapabilitiesResponse" $

View File

@ -0,0 +1,70 @@
{-# LANGUAGE OverloadedLists #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
module Hasura.Backends.DataConnector.API.V0.ErrorResponse
( ErrorResponse (..),
ErrorResponseType (..),
errorResponseJsonText,
errorResponseSummary,
)
where
import Autodocodec
import Autodocodec.OpenAPI ()
import Control.DeepSeq (NFData)
import Control.Monad ((<=<))
import Data.Aeson (FromJSON, ToJSON)
import Data.Aeson qualified as J
import Data.Aeson.Text (encodeToLazyText)
import Data.Bifunctor (first)
import Data.Data (Data, Proxy (..))
import Data.Foldable (toList)
import Data.HashMap.Strict (HashMap)
import Data.HashMap.Strict qualified as HashMap
import Data.HashMap.Strict.InsOrd (InsOrdHashMap)
import Data.HashMap.Strict.InsOrd qualified as InsOrdHashMap
import Data.Hashable (Hashable)
import Data.List.NonEmpty (NonEmpty, nonEmpty)
import Data.Maybe (mapMaybe)
import Data.OpenApi (NamedSchema (..), OpenApiType (OpenApiObject, OpenApiString), Referenced (..), Schema (..), ToSchema (..), declareSchemaRef)
import Data.Text (Text, pack)
import Data.Text.Lazy (toStrict)
import GHC.Generics (Generic)
import Servant.API (HasStatus)
import Servant.API.UVerb qualified as Servant
import Prelude
data ErrorResponseType
= UncaughtError
deriving stock (Eq, Show, Generic)
instance HasCodec ErrorResponseType where
codec =
named "ErrorResponseType" $
stringConstCodec [(UncaughtError, "uncaught-error")]
data ErrorResponse = ErrorResponse
{ _crType :: ErrorResponseType,
_crMessage :: Text,
_crDetails :: J.Value
}
deriving stock (Eq, Show, Generic)
deriving (FromJSON, ToJSON, ToSchema) via Autodocodec ErrorResponse
instance HasStatus ErrorResponse where
type StatusOf ErrorResponse = 500
{-# HLINT ignore "Use tshow" #-}
errorResponseSummary :: ErrorResponse -> Text
errorResponseSummary ErrorResponse {..} = pack (show _crType) <> ": " <> _crMessage
errorResponseJsonText :: ErrorResponse -> Text
errorResponseJsonText = toStrict . encodeToLazyText
instance HasCodec ErrorResponse where
codec =
object "ErrorResponse" $
ErrorResponse
<$> optionalFieldWithDefault "type" UncaughtError "Error type" .= _crType
<*> requiredField "message" "Error message" .= _crMessage
<*> optionalFieldWithDefault "details" J.Null "Error details" .= _crDetails

View File

@ -52,6 +52,7 @@ import Hasura.Backends.DataConnector.API.V0.Expression qualified as API.V0
import Hasura.Backends.DataConnector.API.V0.OrderBy qualified as API.V0
import Hasura.Backends.DataConnector.API.V0.Relationships qualified as API.V0
import Hasura.Backends.DataConnector.API.V0.Table qualified as API.V0
import Servant.API (HasStatus (..))
import Prelude
-- | A serializable request to retrieve strutured data from some
@ -68,9 +69,12 @@ instance HasCodec QueryRequest where
codec =
object "QueryRequest" $
QueryRequest
<$> requiredField "table" "The name of the table to query" .= _qrTable
<*> requiredField "table_relationships" "The relationships between tables involved in the entire query request" .= _qrTableRelationships
<*> requiredField "query" "The details of the query against the table" .= _qrQuery
<$> requiredField "table" "The name of the table to query"
.= _qrTable
<*> requiredField "table_relationships" "The relationships between tables involved in the entire query request"
.= _qrTableRelationships
<*> requiredField "query" "The details of the query against the table"
.= _qrQuery
newtype FieldName = FieldName {unFieldName :: Text}
deriving stock (Eq, Ord, Show, Generic, Data)
@ -98,12 +102,18 @@ instance HasCodec Query where
codec =
named "Query" . object "Query" $
Query
<$> optionalFieldOrNull "fields" "Fields of the query" .= _qFields
<*> optionalFieldOrNull "aggregates" "Aggregate fields of the query" .= _qAggregates
<*> optionalFieldOrNull "limit" "Optionally limit to N results" .= _qLimit
<*> optionalFieldOrNull "offset" "Optionally offset from the Nth result" .= _qOffset
<*> optionalFieldOrNull "where" "Optionally constrain the results to satisfy some predicate" .= _qWhere
<*> optionalFieldOrNull "order_by" "Optionally order the results by the value of one or more fields" .= _qOrderBy
<$> optionalFieldOrNull "fields" "Fields of the query"
.= _qFields
<*> optionalFieldOrNull "aggregates" "Aggregate fields of the query"
.= _qAggregates
<*> optionalFieldOrNull "limit" "Optionally limit to N results"
.= _qLimit
<*> optionalFieldOrNull "offset" "Optionally offset from the Nth result"
.= _qOffset
<*> optionalFieldOrNull "where" "Optionally constrain the results to satisfy some predicate"
.= _qWhere
<*> optionalFieldOrNull "order_by" "Optionally order the results by the value of one or more fields"
.= _qOrderBy
-- | A relationship consists of the following components:
-- - a sub-query, from the perspective that a relationship field will occur
@ -120,8 +130,10 @@ data RelationshipField = RelationshipField
relationshipFieldObjectCodec :: JSONObjectCodec RelationshipField
relationshipFieldObjectCodec =
RelationshipField
<$> requiredField "relationship" "The name of the relationship to follow for the subquery" .= _rfRelationship
<*> requiredField "query" "Relationship query" .= _rfQuery
<$> requiredField "relationship" "The name of the relationship to follow for the subquery"
.= _rfRelationship
<*> requiredField "query" "Relationship query"
.= _rfQuery
-- | The specific fields that are targeted by a 'Query'.
--
@ -164,8 +176,13 @@ instance HasCodec QueryResponse where
codec =
named "QueryResponse" . object "QueryResponse" $
QueryResponse
<$> optionalFieldOrNull "rows" "The rows returned by the query, corresponding to the query's fields" .= _qrRows
<*> optionalFieldOrNull "aggregates" "The results of the aggregates returned by the query" .= _qrAggregates
<$> optionalFieldOrNull "rows" "The rows returned by the query, corresponding to the query's fields"
.= _qrRows
<*> optionalFieldOrNull "aggregates" "The results of the aggregates returned by the query"
.= _qrAggregates
instance HasStatus QueryResponse where
type StatusOf QueryResponse = 200
-- | FieldValue represents the value of a field in a 'QueryResponse', which in reality can
-- be two things. One, a column field value which can be any JSON 'J.Value', or two, a

View File

@ -13,6 +13,7 @@ import Data.Hashable (Hashable)
import Data.OpenApi (ToSchema)
import GHC.Generics (Generic)
import Hasura.Backends.DataConnector.API.V0.Table qualified as API.V0
import Servant.API qualified as Servant
import Prelude
--------------------------------------------------------------------------------
@ -31,3 +32,6 @@ instance HasCodec SchemaResponse where
codec =
object "SchemaResponse" $
SchemaResponse <$> requiredField "tables" "Available tables" .= _srTables
instance Servant.HasStatus SchemaResponse where
type StatusOf SchemaResponse = 200

View File

@ -15,9 +15,10 @@ import Hasura.Backends.DataConnector.API qualified as API
import Hasura.Backends.DataConnector.API.V0.Capabilities as API
import Network.HTTP.Client (defaultManagerSettings, newManager)
import Servant.API (NamedRoutes)
import Servant.Client (Client, ClientError, hoistClient, mkClientEnv, runClientM, (//))
import Servant.Client (Client, ClientError, hoistClient, mkClientEnv, runClientM)
import Test.CapabilitiesSpec qualified
import Test.Data (TestData, mkTestData)
import Test.Data (TestData, guardedCapabilities, mkTestData)
import Test.ErrorSpec qualified
import Test.ExplainSpec qualified
import Test.HealthSpec qualified
import Test.Hspec (Spec)
@ -41,6 +42,7 @@ tests testData api sourceName agentConfig capabilities = do
Test.CapabilitiesSpec.spec api agentConfig capabilities
Test.SchemaSpec.spec testData api sourceName agentConfig capabilities
Test.QuerySpec.spec testData api sourceName agentConfig capabilities
Test.ErrorSpec.spec testData api sourceName agentConfig capabilities
for_ (API._cMetrics capabilities) \m -> Test.MetricsSpec.spec api m
for_ (API._cExplain capabilities) \_ -> Test.ExplainSpec.spec testData api sourceName agentConfig capabilities
@ -50,7 +52,7 @@ main = do
case command of
Test testOptions@TestOptions {..} -> do
api <- mkIOApiClient testOptions
agentCapabilities <- API._crCapabilities <$> (api // _capabilities)
agentCapabilities <- API._crCapabilities <$> guardedCapabilities api
let testData = mkTestData _toTestConfig
let spec = tests testData api testSourceName _toAgentConfig agentCapabilities
case _toExportMatchStrings of

View File

@ -1,6 +1,6 @@
module Test.CapabilitiesSpec (spec) where
import Hasura.Backends.DataConnector.API (Capabilities, CapabilitiesResponse (..), Config, Routes (..), validateConfigAgainstConfigSchema)
import Hasura.Backends.DataConnector.API (Capabilities, CapabilitiesResponse (..), Config, Routes (..), capabilitiesCase, validateConfigAgainstConfigSchema)
import Servant.API (NamedRoutes)
import Servant.Client (Client, (//))
import Test.Expectations (jsonShouldBe)
@ -10,9 +10,13 @@ import Prelude
spec :: Client IO (NamedRoutes Routes) -> Config -> Capabilities -> Spec
spec api config expectedCapabilities = describe "capabilities API" $ do
it "returns the expected capabilities" $ do
CapabilitiesResponse capabilities _ <- api // _capabilities
capabilities `jsonShouldBe` expectedCapabilities
CapabilitiesResponse {..} <- capabilitiesGuard =<< (api // _capabilities)
_crCapabilities `jsonShouldBe` expectedCapabilities
it "returns a schema that can be used to validate the current config" $ do
CapabilitiesResponse _ configSchema <- api // _capabilities
validateConfigAgainstConfigSchema configSchema config `jsonShouldBe` []
CapabilitiesResponse {..} <- capabilitiesGuard =<< (api // _capabilities)
validateConfigAgainstConfigSchema _crConfigSchemaResponse config `jsonShouldBe` []
where
defaultAction = fail "Unexpected data connector capabilities response - Unexpected Type"
errorAction e = fail $ "Unexpected data connector capabilities error response: " <> show e
capabilitiesGuard = capabilitiesCase defaultAction (pure) errorAction

View File

@ -24,6 +24,12 @@ module Test.Data
_ColumnFieldString,
_ColumnFieldBoolean,
orderByColumn,
guardQueryResponse,
guardedQuery,
guardErrorResponse,
guardedCapabilities,
guardCapabilitiesResponse,
errorQuery,
)
where
@ -50,7 +56,11 @@ import Data.Scientific (Scientific)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Hasura.Backends.DataConnector.API (capabilitiesCase, queryCase)
import Hasura.Backends.DataConnector.API qualified as API
import Servant.API qualified as Servant
import Servant.Client ((//))
import Servant.Client qualified as Servant
import Text.XML qualified as XML
import Text.XML.Lens qualified as XML
import Prelude
@ -481,3 +491,33 @@ currentComparisonColumn columnName = API.ComparisonColumn API.CurrentTable $ API
orderByColumn :: [API.RelationshipName] -> Text -> API.OrderDirection -> API.OrderByElement
orderByColumn targetPath columnName orderDirection =
API.OrderByElement targetPath (API.OrderByColumn $ API.ColumnName columnName) orderDirection
guardQueryResponse :: Servant.Union API.QueryResponses -> IO API.QueryResponse
guardQueryResponse = queryCase defaultAction successAction errorAction
where
defaultAction = fail "Expected QueryResponse"
successAction q = pure q
errorAction e = fail $ "Expected QueryResponse, got " <> show e
guardedQuery :: Servant.Client IO (Servant.NamedRoutes API.Routes) -> API.SourceName -> API.Config -> API.QueryRequest -> IO API.QueryResponse
guardedQuery api sourceName config queryRequest = guardQueryResponse =<< (api // API._query) sourceName config queryRequest
guardedCapabilities :: Servant.Client IO (Servant.NamedRoutes API.Routes) -> IO API.CapabilitiesResponse
guardedCapabilities api = guardCapabilitiesResponse =<< (api // API._capabilities)
guardCapabilitiesResponse :: Servant.Union API.CapabilitiesResponses -> IO API.CapabilitiesResponse
guardCapabilitiesResponse = capabilitiesCase defaultAction successAction errorAction
where
defaultAction = fail "Expected QueryResponse"
successAction c = pure c
errorAction e = fail $ "Expected QueryResponse, got " <> show e
guardErrorResponse :: Servant.Union API.QueryResponses -> IO API.ErrorResponse
guardErrorResponse = queryCase defaultAction successAction errorAction
where
defaultAction = fail "Expected ErrorResponse"
successAction q = fail $ "Expected ErrorResponse, got " <> show q
errorAction e = pure e
errorQuery :: Servant.Client IO (Servant.NamedRoutes API.Routes) -> API.SourceName -> API.Config -> API.QueryRequest -> IO API.ErrorResponse
errorQuery api sourceName config queryRequest = guardErrorResponse =<< (api // API._query) sourceName config queryRequest

View File

@ -0,0 +1,30 @@
module Test.ErrorSpec (spec) where
import Control.Lens ((&), (?~))
import Hasura.Backends.DataConnector.API
import Servant.API (NamedRoutes)
import Servant.Client (Client)
import Test.Data (TestData (..), errorQuery)
import Test.Data qualified as Data
import Test.Hspec (Spec, describe, it, shouldBe)
import Prelude
spec :: TestData -> Client IO (NamedRoutes Routes) -> SourceName -> Config -> a -> Spec
spec TestData {..} api sourceName config _capabilities = describe "Basic Queries" do
describe "Error Protocol" do
it "returns a structured error when sending an invalid query" do
receivedArtistsError <- errorQuery api sourceName config brokenQueryRequest
_crType receivedArtistsError `shouldBe` UncaughtError
where
brokenQueryRequest :: QueryRequest
brokenQueryRequest =
let fields = Data.mkFieldsMap [("ArtistId", _tdColumnField "ArtistId"), ("Name", _tdColumnField "Name")]
query =
Data.emptyQuery
& qFields ?~ fields
& qWhere
?~ ApplyBinaryComparisonOperator
(CustomBinaryComparisonOperator "FOOBAR")
(ComparisonColumn CurrentTable (ColumnName "ArtistId"))
(ScalarValue "1")
in QueryRequest _tdArtistsTableName [] query

View File

@ -13,8 +13,8 @@ import Data.Maybe (fromMaybe, isJust, mapMaybe)
import Data.Ord (Down (..))
import Hasura.Backends.DataConnector.API
import Servant.API (NamedRoutes)
import Servant.Client (Client, (//))
import Test.Data (TestData (..))
import Servant.Client (Client)
import Test.Data (TestData (..), guardedQuery)
import Test.Data qualified as Data
import Test.Expectations (jsonShouldBe, rowsShouldBe)
import Test.Hspec (Spec, describe, it)
@ -26,7 +26,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
it "counts all rows" $ do
let aggregates = Data.mkFieldsMap [("count_all", StarCount)]
let queryRequest = invoicesQueryRequest aggregates
response <- (api // _query) sourceName config queryRequest
response <- guardedQuery api sourceName config queryRequest
let invoiceCount = length _tdInvoicesRows
let expectedAggregates = Data.mkFieldsMap [("count_all", Number $ fromIntegral invoiceCount)]
@ -38,7 +38,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
let where' = ApplyBinaryComparisonOperator Equal (_tdCurrentComparisonColumn "BillingCity") (ScalarValue (String "Oslo"))
let aggregates = Data.mkFieldsMap [("count_all", StarCount)]
let queryRequest = invoicesQueryRequest aggregates & qrQuery . qWhere ?~ where'
response <- (api // _query) sourceName config queryRequest
response <- guardedQuery api sourceName config queryRequest
let invoiceCount = length $ filter ((^? Data.field "BillingCity" . Data._ColumnFieldString) >>> (== Just "Oslo")) _tdInvoicesRows
let expectedAggregates = Data.mkFieldsMap [("count_all", Number $ fromIntegral invoiceCount)]
@ -49,7 +49,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
it "counts all rows, after applying pagination" $ do
let aggregates = Data.mkFieldsMap [("count_all", StarCount)]
let queryRequest = invoicesQueryRequest aggregates & qrQuery %~ (qLimit ?~ 20 >>> qOffset ?~ 400)
response <- (api // _query) sourceName config queryRequest
response <- guardedQuery api sourceName config queryRequest
let invoiceCount = length . take 20 $ drop 400 _tdInvoicesRows
let expectedAggregates = Data.mkFieldsMap [("count_all", Number $ fromIntegral invoiceCount)]
@ -61,7 +61,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
it "counts all rows with non-null columns" $ do
let aggregates = Data.mkFieldsMap [("count_cols", ColumnCount $ ColumnCountAggregate (_tdColumnName "BillingState") False)]
let queryRequest = invoicesQueryRequest aggregates
response <- (api // _query) sourceName config queryRequest
response <- guardedQuery api sourceName config queryRequest
let invoiceCount = length $ filter ((^? Data.field "BillingState" . Data._ColumnFieldString) >>> (/= Nothing)) _tdInvoicesRows
let expectedAggregates = Data.mkFieldsMap [("count_cols", Number $ fromIntegral invoiceCount)]
@ -73,7 +73,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
let where' = ApplyBinaryComparisonOperator GreaterThanOrEqual (_tdCurrentComparisonColumn "InvoiceId") (ScalarValue (Number 380))
let aggregates = Data.mkFieldsMap [("count_cols", ColumnCount $ ColumnCountAggregate (_tdColumnName "BillingState") False)]
let queryRequest = invoicesQueryRequest aggregates & qrQuery %~ (qLimit ?~ 20 >>> qWhere ?~ where')
response <- (api // _query) sourceName config queryRequest
response <- guardedQuery api sourceName config queryRequest
let invoiceCount =
_tdInvoicesRows
@ -90,7 +90,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
it "can count all rows with distinct non-null values in a column" $ do
let aggregates = Data.mkFieldsMap [("count_cols", ColumnCount $ ColumnCountAggregate (_tdColumnName "BillingState") True)]
let queryRequest = invoicesQueryRequest aggregates
response <- (api // _query) sourceName config queryRequest
response <- guardedQuery api sourceName config queryRequest
let billingStateCount = length . HashSet.fromList $ mapMaybe ((^? Data.field "BillingState" . Data._ColumnFieldString)) _tdInvoicesRows
let expectedAggregates = Data.mkFieldsMap [("count_cols", Number $ fromIntegral billingStateCount)]
@ -102,7 +102,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
let where' = ApplyBinaryComparisonOperator GreaterThanOrEqual (_tdCurrentComparisonColumn "InvoiceId") (ScalarValue (Number 380))
let aggregates = Data.mkFieldsMap [("count_cols", ColumnCount $ ColumnCountAggregate (_tdColumnName "BillingState") True)]
let queryRequest = invoicesQueryRequest aggregates & qrQuery %~ (qLimit ?~ 20 >>> qWhere ?~ where')
response <- (api // _query) sourceName config queryRequest
response <- guardedQuery api sourceName config queryRequest
let billingStateCount =
_tdInvoicesRows
@ -121,7 +121,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
it "can get the max total from all rows" $ do
let aggregates = Data.mkFieldsMap [("max", SingleColumn $ SingleColumnAggregate Max (_tdColumnName "Total"))]
let queryRequest = invoicesQueryRequest aggregates
response <- (api // _query) sourceName config queryRequest
response <- guardedQuery api sourceName config queryRequest
let maxTotal = maximum $ mapMaybe ((^? Data.field "Total" . Data._ColumnFieldNumber)) _tdInvoicesRows
let expectedAggregates = Data.mkFieldsMap [("max", Number maxTotal)]
@ -134,7 +134,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
let orderBy = OrderBy mempty $ _tdOrderByColumn [] "BillingPostalCode" Descending :| [_tdOrderByColumn [] "InvoiceId" Ascending]
let aggregates = Data.mkFieldsMap [("max", SingleColumn $ SingleColumnAggregate Max (_tdColumnName "Total"))]
let queryRequest = invoicesQueryRequest aggregates & qrQuery %~ (qLimit ?~ 20 >>> qWhere ?~ where' >>> qOrderBy ?~ orderBy)
response <- (api // _query) sourceName config queryRequest
response <- guardedQuery api sourceName config queryRequest
let maxTotal =
_tdInvoicesRows
@ -156,7 +156,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
("max", SingleColumn $ SingleColumnAggregate Max (_tdColumnName "Name"))
]
let queryRequest = artistsQueryRequest aggregates
response <- (api // _query) sourceName config queryRequest
response <- guardedQuery api sourceName config queryRequest
let names = mapMaybe ((^? Data.field "Name" . Data._ColumnFieldString)) _tdArtistsRows
let expectedAggregates =
@ -172,7 +172,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
let where' = ApplyBinaryComparisonOperator LessThan (_tdCurrentComparisonColumn "ArtistId") (ScalarValue (Number 0))
let aggregates = Data.mkFieldsMap [("min", SingleColumn $ SingleColumnAggregate Min (_tdColumnName "Name"))]
let queryRequest = artistsQueryRequest aggregates & qrQuery . qWhere ?~ where'
response <- (api // _query) sourceName config queryRequest
response <- guardedQuery api sourceName config queryRequest
let expectedAggregates = Data.mkFieldsMap [("min", Null)]
@ -188,7 +188,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
("maxTotal", SingleColumn $ SingleColumnAggregate Max (_tdColumnName "Total"))
]
let queryRequest = invoicesQueryRequest aggregates
response <- (api // _query) sourceName config queryRequest
response <- guardedQuery api sourceName config queryRequest
let invoiceCount = length _tdInvoicesRows
let billingStateCount = length . HashSet.fromList $ mapMaybe ((^? Data.field "BillingState" . Data._ColumnFieldString)) _tdInvoicesRows
@ -211,7 +211,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
("minTotal", SingleColumn $ SingleColumnAggregate Min (_tdColumnName "Total"))
]
let queryRequest = invoicesQueryRequest aggregates
response <- (api // _query) sourceName config queryRequest
response <- guardedQuery api sourceName config queryRequest
let maxInvoiceId = aggregate (Number . minimum) $ mapMaybe ((^? Data.field "InvoiceId" . Data._ColumnFieldNumber)) _tdInvoicesRows
let maxTotal = aggregate (Number . minimum) $ mapMaybe ((^? Data.field "Total" . Data._ColumnFieldNumber)) _tdInvoicesRows
@ -235,7 +235,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
let orderBy = OrderBy mempty $ _tdOrderByColumn [] "BillingAddress" Ascending :| [_tdOrderByColumn [] "InvoiceId" Ascending]
let aggregates = Data.mkFieldsMap [("min", SingleColumn $ SingleColumnAggregate Min (_tdColumnName "Total"))]
let queryRequest = invoicesQueryRequest aggregates & qrQuery %~ (qFields ?~ fields >>> qLimit ?~ 30 >>> qWhere ?~ where' >>> qOrderBy ?~ orderBy)
response <- (api // _query) sourceName config queryRequest
response <- guardedQuery api sourceName config queryRequest
let invoiceRows =
_tdInvoicesRows
@ -258,7 +258,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
describe "Aggregates via Relationships" $ do
it "can query aggregates via an array relationship" $ do
let query = artistsWithAlbumsQuery id & qrQuery . qLimit ?~ 5
receivedArtists <- (api // _query) sourceName config query
receivedArtists <- guardedQuery api sourceName config query
let joinInAlbums (artist :: HashMap FieldName FieldValue) = fromMaybe artist $ do
artistId <- artist ^? Data.field "ArtistId" . Data._ColumnFieldNumber
@ -283,7 +283,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
("Title", _tdColumnField "Title")
]
let query = artistsWithAlbumsQuery (qFields ?~ albumFields) & qrQuery . qLimit ?~ 5
receivedArtists <- (api // _query) sourceName config query
receivedArtists <- guardedQuery api sourceName config query
let joinInAlbums (artist :: HashMap FieldName FieldValue) = fromMaybe artist $ do
artistId <- artist ^? Data.field "ArtistId" . Data._ColumnFieldNumber
@ -303,7 +303,7 @@ spec TestData {..} api sourceName config relationshipCapabilities = describe "Ag
Data.responseAggregates receivedArtists `jsonShouldBe` mempty
it "can query with many nested relationships, with aggregates at multiple levels, with filtering, pagination and ordering" $ do
receivedArtists <- (api // _query) sourceName config deeplyNestedArtistsQuery
receivedArtists <- guardedQuery api sourceName config deeplyNestedArtistsQuery
let joinInMediaType (track :: HashMap FieldName FieldValue) = fromMaybe track $ do
mediaTypeId <- track ^? Data.field "MediaTypeId" . Data._ColumnFieldNumber

View File

@ -5,8 +5,8 @@ import Control.Lens ((%~), (&), (?~))
import Data.HashMap.Strict qualified as HashMap
import Hasura.Backends.DataConnector.API
import Servant.API (NamedRoutes)
import Servant.Client (Client, (//))
import Test.Data (TestData (..))
import Servant.Client (Client)
import Test.Data (TestData (..), guardedQuery)
import Test.Data qualified as Data
import Test.Expectations (jsonShouldBe, rowsShouldBe)
import Test.Hspec (Spec, describe, it)
@ -17,7 +17,7 @@ spec TestData {..} api sourceName config = describe "Basic Queries" $ do
describe "Column Fields" $ do
it "can query for a list of artists" $ do
let query = artistsQueryRequest
receivedArtists <- Data.sortResponseRowsBy "ArtistId" <$> (api // _query) sourceName config query
receivedArtists <- Data.sortResponseRowsBy "ArtistId" <$> guardedQuery api sourceName config query
let expectedArtists = _tdArtistsRows
Data.responseRows receivedArtists `rowsShouldBe` expectedArtists
@ -26,7 +26,7 @@ spec TestData {..} api sourceName config = describe "Basic Queries" $ do
it "can query for a list of albums with a subset of columns" $ do
let fields = Data.mkFieldsMap [("ArtistId", _tdColumnField "ArtistId"), ("Title", _tdColumnField "Title")]
let query = albumsQueryRequest & qrQuery . qFields ?~ fields
receivedAlbums <- Data.sortResponseRowsBy "Title" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "Title" <$> guardedQuery api sourceName config query
let filterToRequiredProperties =
HashMap.filterWithKey (\(FieldName propName) _value -> propName == "ArtistId" || propName == "Title")
@ -38,7 +38,7 @@ spec TestData {..} api sourceName config = describe "Basic Queries" $ do
it "can project columns into fields with different names" $ do
let fields = Data.mkFieldsMap [("Artist_Id", _tdColumnField "ArtistId"), ("Artist_Name", _tdColumnField "Name")]
let query = artistsQueryRequest & qrQuery . qFields ?~ fields
receivedArtists <- Data.sortResponseRowsBy "ArtistId" <$> (api // _query) sourceName config query
receivedArtists <- Data.sortResponseRowsBy "ArtistId" <$> guardedQuery api sourceName config query
let renameProperties =
HashMap.mapKeys
@ -58,9 +58,9 @@ spec TestData {..} api sourceName config = describe "Basic Queries" $ do
let page1Query = artistsQueryRequest & qrQuery %~ (qLimit ?~ 10 >>> qOffset ?~ 0)
let page2Query = artistsQueryRequest & qrQuery %~ (qLimit ?~ 10 >>> qOffset ?~ 10)
allArtists <- Data.responseRows <$> (api // _query) sourceName config allQuery
page1Artists <- Data.responseRows <$> (api // _query) sourceName config page1Query
page2Artists <- Data.responseRows <$> (api // _query) sourceName config page2Query
allArtists <- Data.responseRows <$> guardedQuery api sourceName config allQuery
page1Artists <- Data.responseRows <$> guardedQuery api sourceName config page1Query
page2Artists <- Data.responseRows <$> guardedQuery api sourceName config page2Query
page1Artists `rowsShouldBe` take 10 allArtists
page2Artists `rowsShouldBe` take 10 (drop 10 allArtists)

View File

@ -10,8 +10,8 @@ import Data.List (sortOn)
import Data.Maybe (isJust, mapMaybe)
import Hasura.Backends.DataConnector.API
import Servant.API (NamedRoutes)
import Servant.Client (Client, (//))
import Test.Data (TestData (..))
import Servant.Client (Client)
import Test.Data (TestData (..), guardedQuery)
import Test.Data qualified as Data
import Test.Expectations (jsonShouldBe, rowsShouldBe)
import Test.Hspec (Spec, describe, it)
@ -22,7 +22,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
it "can filter using an equality expression" $ do
let where' = ApplyBinaryComparisonOperator Equal (_tdCurrentComparisonColumn "AlbumId") (ScalarValue (Number 2))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
let expectedAlbums =
filter ((== Just 2) . (^? Data.field "AlbumId" . Data._ColumnFieldNumber)) _tdAlbumsRows
@ -33,7 +33,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
it "can filter using an inequality expression" $ do
let where' = Not (ApplyBinaryComparisonOperator Equal (_tdCurrentComparisonColumn "AlbumId") (ScalarValue (Number 2)))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
let expectedAlbums =
filter ((/= Just 2) . (^? Data.field "AlbumId" . Data._ColumnFieldNumber)) _tdAlbumsRows
@ -44,7 +44,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
it "can filter using an in expression" $ do
let where' = ApplyBinaryArrayComparisonOperator In (_tdCurrentComparisonColumn "AlbumId") [Number 2, Number 3]
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
let expectedAlbums =
filter (flip elem [Just 2, Just 3] . (^? Data.field "AlbumId" . Data._ColumnFieldNumber)) _tdAlbumsRows
@ -55,7 +55,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
it "can negate an in expression filter using a not expression" $ do
let where' = Not (ApplyBinaryArrayComparisonOperator In (_tdCurrentComparisonColumn "AlbumId") [Number 2, Number 3])
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
let expectedAlbums =
filter (flip notElem [Just 2, Just 3] . (^? Data.field "AlbumId" . Data._ColumnFieldNumber)) _tdAlbumsRows
@ -68,7 +68,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
let where2 = ApplyBinaryComparisonOperator Equal (_tdCurrentComparisonColumn "Title") (ScalarValue (String "Stormbringer"))
let where' = And [where1, where2]
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
let expectedAlbums =
filter
@ -83,7 +83,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
it "treats an empty and expression as 'true'" $ do
let where' = And []
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
Data.responseRows receivedAlbums `rowsShouldBe` _tdAlbumsRows
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
@ -93,7 +93,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
let where2 = ApplyBinaryComparisonOperator Equal (_tdCurrentComparisonColumn "AlbumId") (ScalarValue (Number 3))
let where' = Or [where1, where2]
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
let expectedAlbums =
filter (flip elem [Just 2, Just 3] . (^? Data.field "AlbumId" . Data._ColumnFieldNumber)) _tdAlbumsRows
@ -104,7 +104,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
it "treats an empty or expression as 'false'" $ do
let where' = Or []
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- (api // _query) sourceName config query
receivedAlbums <- guardedQuery api sourceName config query
Data.responseRows receivedAlbums `rowsShouldBe` []
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
@ -112,7 +112,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
it "can filter by applying the greater than operator" $ do
let where' = ApplyBinaryComparisonOperator GreaterThan (_tdCurrentComparisonColumn "AlbumId") (ScalarValue (Number 300))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
let expectedAlbums =
filter ((> Just 300) . (^? Data.field "AlbumId" . Data._ColumnFieldNumber)) _tdAlbumsRows
@ -123,7 +123,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
it "can filter by applying the greater than or equal operator" $ do
let where' = ApplyBinaryComparisonOperator GreaterThanOrEqual (_tdCurrentComparisonColumn "AlbumId") (ScalarValue (Number 300))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
let expectedAlbums =
filter ((>= Just 300) . (^? Data.field "AlbumId" . Data._ColumnFieldNumber)) _tdAlbumsRows
@ -134,7 +134,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
it "can filter by applying the less than operator" $ do
let where' = ApplyBinaryComparisonOperator LessThan (_tdCurrentComparisonColumn "AlbumId") (ScalarValue (Number 100))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
let expectedAlbums =
filter ((< Just 100) . (^? Data.field "AlbumId" . Data._ColumnFieldNumber)) _tdAlbumsRows
@ -145,7 +145,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
it "can filter by applying the less than or equal operator" $ do
let where' = ApplyBinaryComparisonOperator LessThanOrEqual (_tdCurrentComparisonColumn "AlbumId") (ScalarValue (Number 100))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
let expectedAlbums =
filter ((<= Just 100) . (^? Data.field "AlbumId" . Data._ColumnFieldNumber)) _tdAlbumsRows
@ -156,7 +156,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
it "can filter using a greater than operator with a column comparison" $ do
let where' = ApplyBinaryComparisonOperator GreaterThan (_tdCurrentComparisonColumn "AlbumId") (AnotherColumn (_tdCurrentComparisonColumn "ArtistId"))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
let expectedAlbums =
filter (\album -> (album ^? Data.field "AlbumId" . Data._ColumnFieldNumber) > (album ^? Data.field "ArtistId" . Data._ColumnFieldNumber)) _tdAlbumsRows
@ -173,7 +173,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
Exists (UnrelatedTable _tdEmployeesTableName) $
ApplyBinaryComparisonOperator Equal (_tdCurrentComparisonColumn "EmployeeId") (ScalarValue (Number 1))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
let expectedAlbums = _tdAlbumsRows
@ -185,7 +185,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
Exists (UnrelatedTable _tdEmployeesTableName) $
ApplyBinaryComparisonOperator Equal (_tdCurrentComparisonColumn "EmployeeId") (ScalarValue (Number 0))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
Data.responseRows receivedAlbums `rowsShouldBe` []
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
@ -199,7 +199,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
ApplyBinaryComparisonOperator Equal (_tdCurrentComparisonColumn "City") (ScalarValue (String "Edmonton"))
]
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
let expectedAlbums = _tdAlbumsRows
@ -214,7 +214,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
ApplyBinaryComparisonOperator Equal (_tdCurrentComparisonColumn "City") (ScalarValue (String "Calgary"))
]
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
Data.responseRows receivedAlbums `rowsShouldBe` []
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
@ -229,7 +229,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
albumsQueryRequest
& qrTableRelationships .~ [Data.onlyKeepRelationships [_tdArtistRelationshipName] _tdAlbumsTableRelationships]
& qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
let artistId =
_tdArtistsRows
@ -256,7 +256,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
Data.onlyKeepRelationships [_tdGenreRelationshipName] _tdTracksTableRelationships
]
& qrQuery . qWhere ?~ where'
receivedArtists <- Data.sortResponseRowsBy "ArtistId" <$> (api // _query) sourceName config query
receivedArtists <- Data.sortResponseRowsBy "ArtistId" <$> guardedQuery api sourceName config query
let genreId =
_tdGenresRows
@ -292,7 +292,7 @@ spec TestData {..} api sourceName config comparisonCapabilities = describe "Filt
artistsQueryRequest
& qrTableRelationships .~ [Data.onlyKeepRelationships [_tdAlbumsRelationshipName] _tdArtistsTableRelationships]
& qrQuery . qWhere ?~ where'
receivedArtists <- Data.sortResponseRowsBy "ArtistId" <$> (api // _query) sourceName config query
receivedArtists <- Data.sortResponseRowsBy "ArtistId" <$> guardedQuery api sourceName config query
let albums =
_tdAlbumsRows

View File

@ -13,8 +13,8 @@ import Data.Maybe (isJust)
import Data.Ord (Down (..))
import Hasura.Backends.DataConnector.API
import Servant.API (NamedRoutes)
import Servant.Client (Client, (//))
import Test.Data (TestData (..))
import Servant.Client (Client)
import Test.Data (TestData (..), guardedQuery)
import Test.Data qualified as Data
import Test.Expectations (jsonShouldBe, rowsShouldBe)
import Test.Hspec (Spec, describe, it)
@ -25,7 +25,7 @@ spec TestData {..} api sourceName config Capabilities {..} = describe "Order By
it "can order results in ascending order" $ do
let orderBy = OrderBy mempty $ _tdOrderByColumn [] "Title" Ascending :| []
let query = albumsQueryRequest & qrQuery . qOrderBy ?~ orderBy
receivedAlbums <- (api // _query) sourceName config query
receivedAlbums <- guardedQuery api sourceName config query
let expectedAlbums = sortOn (^? Data.field "Title") _tdAlbumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
@ -34,7 +34,7 @@ spec TestData {..} api sourceName config Capabilities {..} = describe "Order By
it "can order results in descending order" $ do
let orderBy = OrderBy mempty $ _tdOrderByColumn [] "Title" Descending :| []
let query = albumsQueryRequest & qrQuery . qOrderBy ?~ orderBy
receivedAlbums <- (api // _query) sourceName config query
receivedAlbums <- guardedQuery api sourceName config query
let expectedAlbums = sortOn (Down . (^? Data.field "Title")) _tdAlbumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
@ -43,7 +43,7 @@ spec TestData {..} api sourceName config Capabilities {..} = describe "Order By
it "can use multiple order by elements to order results" $ do
let orderBy = OrderBy mempty $ _tdOrderByColumn [] "ArtistId" Ascending :| [_tdOrderByColumn [] "Title" Descending]
let query = albumsQueryRequest & qrQuery . qOrderBy ?~ orderBy
receivedAlbums <- (api // _query) sourceName config query
receivedAlbums <- guardedQuery api sourceName config query
let expectedAlbums =
sortOn (\album -> (album ^? Data.field "ArtistId", Down (album ^? Data.field "Title"))) _tdAlbumsRows
@ -59,7 +59,7 @@ spec TestData {..} api sourceName config Capabilities {..} = describe "Order By
albumsQueryRequest
& qrQuery . qOrderBy ?~ orderBy
& qrTableRelationships .~ [Data.onlyKeepRelationships [_tdArtistRelationshipName] _tdAlbumsTableRelationships]
receivedAlbums <- (api // _query) sourceName config query
receivedAlbums <- guardedQuery api sourceName config query
let getRelatedArtist (album :: HashMap FieldName FieldValue) =
(album ^? Data.field "ArtistId" . Data._ColumnFieldNumber) >>= \artistId -> _tdArtistsRowsById ^? ix artistId
@ -81,7 +81,7 @@ spec TestData {..} api sourceName config Capabilities {..} = describe "Order By
albumsQueryRequest
& qrQuery . qOrderBy ?~ orderBy
& qrTableRelationships .~ [Data.onlyKeepRelationships [_tdArtistRelationshipName] _tdAlbumsTableRelationships]
receivedAlbums <- (api // _query) sourceName config query
receivedAlbums <- guardedQuery api sourceName config query
let getRelatedArtist (album :: HashMap FieldName FieldValue) = do
artist <- (album ^? Data.field "ArtistId" . Data._ColumnFieldNumber) >>= \artistId -> _tdArtistsRowsById ^? ix artistId
@ -127,7 +127,7 @@ spec TestData {..} api sourceName config Capabilities {..} = describe "Order By
.~ [ Data.onlyKeepRelationships [_tdAlbumRelationshipName] _tdTracksTableRelationships,
Data.onlyKeepRelationships [_tdArtistRelationshipName] _tdAlbumsTableRelationships
]
receivedTracks <- (api // _query) sourceName config query
receivedTracks <- guardedQuery api sourceName config query
let getRelatedArtist (track :: HashMap FieldName FieldValue) = do
albumId <- track ^? Data.field "AlbumId" . Data._ColumnFieldNumber
@ -151,7 +151,7 @@ spec TestData {..} api sourceName config Capabilities {..} = describe "Order By
artistsQueryRequest
& qrQuery . qOrderBy ?~ orderBy
& qrTableRelationships .~ [Data.onlyKeepRelationships [_tdAlbumsRelationshipName] _tdArtistsTableRelationships]
receivedArtists <- (api // _query) sourceName config query
receivedArtists <- guardedQuery api sourceName config query
let getAlbumsCount (artist :: HashMap FieldName FieldValue) = do
artistId <- artist ^? Data.field "ArtistId" . Data._ColumnFieldNumber
@ -175,7 +175,7 @@ spec TestData {..} api sourceName config Capabilities {..} = describe "Order By
artistsQueryRequest
& qrQuery . qOrderBy ?~ orderBy
& qrTableRelationships .~ [Data.onlyKeepRelationships [_tdAlbumsRelationshipName] _tdArtistsTableRelationships]
receivedArtists <- (api // _query) sourceName config query
receivedArtists <- guardedQuery api sourceName config query
let getAlbumsCount (artist :: HashMap FieldName FieldValue) = do
artistId <- artist ^? Data.field "ArtistId" . Data._ColumnFieldNumber
@ -220,7 +220,7 @@ spec TestData {..} api sourceName config Capabilities {..} = describe "Order By
.~ [ Data.onlyKeepRelationships [_tdArtistRelationshipName] _tdAlbumsTableRelationships,
Data.onlyKeepRelationships [_tdAlbumsRelationshipName] _tdArtistsTableRelationships
]
receivedAlbums <- (api // _query) sourceName config query
receivedAlbums <- guardedQuery api sourceName config query
let getTotalArtistAlbumsCount (album :: HashMap FieldName FieldValue) = do
artistId <- album ^? Data.field "ArtistId" . Data._ColumnFieldNumber

View File

@ -10,8 +10,8 @@ import Data.List.NonEmpty qualified as NonEmpty
import Data.Maybe (maybeToList)
import Hasura.Backends.DataConnector.API
import Servant.API (NamedRoutes)
import Servant.Client (Client, (//))
import Test.Data (TestData (..))
import Servant.Client (Client)
import Test.Data (TestData (..), guardedQuery)
import Test.Data qualified as Data
import Test.Expectations (jsonShouldBe, rowsShouldBe)
import Test.Hspec (Spec, describe, it)
@ -21,7 +21,7 @@ spec :: TestData -> Client IO (NamedRoutes Routes) -> SourceName -> Config -> Ma
spec TestData {..} api sourceName config subqueryComparisonCapabilities = describe "Relationship Queries" $ do
it "perform an object relationship query by joining artist to albums" $ do
let query = albumsWithArtistQuery id
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> guardedQuery api sourceName config query
let joinInArtist (album :: HashMap FieldName FieldValue) =
let artist = (album ^? Data.field "ArtistId" . Data._ColumnFieldNumber) >>= \artistId -> _tdArtistsRowsById ^? ix artistId
@ -35,7 +35,7 @@ spec TestData {..} api sourceName config subqueryComparisonCapabilities = descri
it "perform an array relationship query by joining albums to artists" $ do
let query = artistsWithAlbumsQuery id
receivedArtists <- Data.sortResponseRowsBy "ArtistId" <$> (api // _query) sourceName config query
receivedArtists <- Data.sortResponseRowsBy "ArtistId" <$> guardedQuery api sourceName config query
let joinInAlbums (artist :: HashMap FieldName FieldValue) =
let artistId = artist ^? Data.field "ArtistId" . Data._ColumnFieldNumber
@ -51,7 +51,7 @@ spec TestData {..} api sourceName config subqueryComparisonCapabilities = descri
it "perform an array relationship query by joining albums to artists with pagination of albums" $ do
let albumsOrdering = OrderBy mempty $ NonEmpty.fromList [_tdOrderByColumn [] "AlbumId" Ascending]
let query = artistsWithAlbumsQuery (qOffset ?~ 1 >>> qLimit ?~ 2 >>> qOrderBy ?~ albumsOrdering)
receivedArtists <- Data.sortResponseRowsBy "ArtistId" <$> (api // _query) sourceName config query
receivedArtists <- Data.sortResponseRowsBy "ArtistId" <$> guardedQuery api sourceName config query
let joinInAlbums (artist :: HashMap FieldName FieldValue) = do
let artistId = artist ^? Data.field "ArtistId" . Data._ColumnFieldNumber
@ -79,7 +79,7 @@ spec TestData {..} api sourceName config subqueryComparisonCapabilities = descri
(_tdCurrentComparisonColumn "Country")
(AnotherColumn (_tdQueryComparisonColumn "Country"))
let query = customersWithSupportRepQuery id & qrQuery . qWhere ?~ where'
receivedCustomers <- Data.sortResponseRowsBy "CustomerId" <$> (api // _query) sourceName config query
receivedCustomers <- Data.sortResponseRowsBy "CustomerId" <$> guardedQuery api sourceName config query
let joinInSupportRep (customer :: HashMap FieldName FieldValue) =
let supportRep = (customer ^? Data.field "SupportRepId" . Data._ColumnFieldNumber) >>= \employeeId -> _tdEmployeesRowsById ^? ix employeeId
@ -107,7 +107,7 @@ spec TestData {..} api sourceName config subqueryComparisonCapabilities = descri
(_tdCurrentComparisonColumn "Country")
(AnotherColumn (_tdQueryComparisonColumn "Country"))
let query = employeesWithCustomersQuery id & qrQuery . qWhere ?~ where'
receivedEmployees <- Data.sortResponseRowsBy "EmployeeId" <$> (api // _query) sourceName config query
receivedEmployees <- Data.sortResponseRowsBy "EmployeeId" <$> guardedQuery api sourceName config query
let joinInCustomers (employee :: HashMap FieldName FieldValue) =
let employeeId = employee ^? Data.field "EmployeeId" . Data._ColumnFieldNumber
@ -148,7 +148,7 @@ spec TestData {..} api sourceName config subqueryComparisonCapabilities = descri
(AnotherColumn (_tdCurrentComparisonColumn "LastName"))
let query = customersWithSupportRepQuery (\q -> q & qWhere ?~ employeesWhere) & qrQuery . qWhere ?~ customersWhere
receivedCustomers <- Data.sortResponseRowsBy "CustomerId" <$> (api // _query) sourceName config query
receivedCustomers <- Data.sortResponseRowsBy "CustomerId" <$> guardedQuery api sourceName config query
let joinInSupportRep (customer :: HashMap FieldName FieldValue) =
let supportRep = do

View File

@ -27,13 +27,13 @@ spec :: TestData -> Client IO (NamedRoutes API.Routes) -> API.SourceName -> API.
spec TestData {..} api sourceName config API.Capabilities {..} = describe "schema API" $ do
it "returns the Chinook tables" $ do
let extractTableNames = sort . fmap API._tiName
tableNames <- (extractTableNames . API._srTables) <$> (api // API._schema) sourceName config
tableNames <- (extractTableNames . API._srTables) <$> (schemaGuard =<< (api // API._schema) sourceName config)
let expectedTableNames = extractTableNames _tdSchemaTables
tableNames `jsonShouldBe` expectedTableNames
testPerTable "returns the correct columns in the Chinook tables" $ \expectedTable@API.TableInfo {..} -> do
tables <- find (\t -> API._tiName t == _tiName) . API._srTables <$> (api // API._schema) sourceName config
tables <- find (\t -> API._tiName t == _tiName) . API._srTables <$> (schemaGuard =<< (api // API._schema) sourceName config)
-- We remove some properties here so that we don't compare them since they vary between agent implementations
let extractJsonForComparison table =
@ -54,17 +54,17 @@ spec TestData {..} api sourceName config API.Capabilities {..} = describe "schem
if API._dscSupportsPrimaryKeys _cDataSchema
then testPerTable "returns the correct primary keys for the Chinook tables" $ \API.TableInfo {..} -> do
tables <- find (\t -> API._tiName t == _tiName) . API._srTables <$> (api // API._schema) sourceName config
tables <- find (\t -> API._tiName t == _tiName) . API._srTables <$> (schemaGuard =<< (api // API._schema) sourceName config)
let actualPrimaryKey = API._tiPrimaryKey <$> tables
actualPrimaryKey `jsonShouldBe` Just _tiPrimaryKey
else testPerTable "returns no primary keys for the Chinook tables" $ \API.TableInfo {..} -> do
tables <- find (\t -> API._tiName t == _tiName) . API._srTables <$> (api // API._schema) sourceName config
tables <- find (\t -> API._tiName t == _tiName) . API._srTables <$> (schemaGuard =<< (api // API._schema) sourceName config)
let actualPrimaryKey = API._tiPrimaryKey <$> tables
actualPrimaryKey `jsonShouldBe` Just []
if API._dscSupportsForeignKeys _cDataSchema
then testPerTable "returns the correct foreign keys for the Chinook tables" $ \expectedTable@API.TableInfo {..} -> do
tables <- find (\t -> API._tiName t == _tiName) . API._srTables <$> (api // API._schema) sourceName config
tables <- find (\t -> API._tiName t == _tiName) . API._srTables <$> (schemaGuard =<< (api // API._schema) sourceName config)
-- We compare only the constraints and ignore the constraint names since some agents will have
-- different constraint names
@ -75,7 +75,7 @@ spec TestData {..} api sourceName config API.Capabilities {..} = describe "schem
actualConstraints `jsonShouldBe` expectedConstraints
else testPerTable "returns no foreign keys for the Chinook tables" $ \API.TableInfo {..} -> do
tables <- find (\t -> API._tiName t == _tiName) . API._srTables <$> (api // API._schema) sourceName config
tables <- find (\t -> API._tiName t == _tiName) . API._srTables <$> (schemaGuard =<< (api // API._schema) sourceName config)
let actualJsonConstraints = API._tiForeignKeys <$> tables
actualJsonConstraints `jsonShouldBe` Just (API.ForeignKeys mempty)
@ -86,3 +86,7 @@ spec TestData {..} api sourceName config API.Capabilities {..} = describe "schem
forM_ _tdSchemaTables $ \expectedTable@API.TableInfo {..} -> do
it (Text.unpack . NonEmpty.last $ API.unTableName _tiName) $
test expectedTable
schemaGuard = API.schemaCase defaultAction pure errorAction
defaultAction = fail "Error resolving source schema"
errorAction e = fail ("Error resolving source schema: " <> Text.unpack (API.errorResponseJsonText e))

View File

@ -1,3 +1,5 @@
{-# LANGUAGE DataKinds #-}
module Harness.Backend.DataConnector.MockAgent
( MockConfig (..),
chinookMock,
@ -11,6 +13,7 @@ import Data.HashMap.Strict.InsOrd qualified as HMap
import Data.IORef qualified as I
import Data.OpenApi qualified as OpenApi
import Data.Proxy
import Data.SOP.BasicFunctors qualified as SOP
import Hasura.Backends.DataConnector.API qualified as API
import Hasura.Prelude
import Network.Wai.Handler.Warp qualified as Warp
@ -18,10 +21,13 @@ import Servant
--------------------------------------------------------------------------------
-- Note: Only the _queryResponse field allows mock errors at present.
-- This can be extended at a later point if required.
--
data MockConfig = MockConfig
{ _capabilitiesResponse :: API.CapabilitiesResponse,
_schemaResponse :: API.SchemaResponse,
_queryResponse :: API.QueryRequest -> API.QueryResponse
_queryResponse :: API.QueryRequest -> Either API.ErrorResponse API.QueryResponse
}
mkTableName :: Text -> API.TableName
@ -536,28 +542,30 @@ chinookMock =
MockConfig
{ _capabilitiesResponse = capabilities,
_schemaResponse = schema,
_queryResponse = \_ -> API.QueryResponse (Just []) Nothing
_queryResponse = \_ -> Right $ API.QueryResponse (Just []) Nothing
}
--------------------------------------------------------------------------------
mockCapabilitiesHandler :: I.IORef MockConfig -> Handler API.CapabilitiesResponse
mockCapabilitiesHandler :: I.IORef MockConfig -> Handler (Union API.CapabilitiesResponses)
mockCapabilitiesHandler mcfg = liftIO $ do
cfg <- I.readIORef mcfg
pure $ _capabilitiesResponse cfg
pure $ inject $ SOP.I $ _capabilitiesResponse cfg
mockSchemaHandler :: I.IORef MockConfig -> I.IORef (Maybe API.Config) -> API.SourceName -> API.Config -> Handler API.SchemaResponse
mockSchemaHandler :: I.IORef MockConfig -> I.IORef (Maybe API.Config) -> API.SourceName -> API.Config -> Handler (Union API.SchemaResponses)
mockSchemaHandler mcfg mQueryConfig _sourceName queryConfig = liftIO $ do
cfg <- I.readIORef mcfg
I.writeIORef mQueryConfig (Just queryConfig)
pure $ _schemaResponse cfg
pure $ inject $ SOP.I $ _schemaResponse cfg
mockQueryHandler :: I.IORef MockConfig -> I.IORef (Maybe API.QueryRequest) -> I.IORef (Maybe API.Config) -> API.SourceName -> API.Config -> API.QueryRequest -> Handler API.QueryResponse
mockQueryHandler :: I.IORef MockConfig -> I.IORef (Maybe API.QueryRequest) -> I.IORef (Maybe API.Config) -> API.SourceName -> API.Config -> API.QueryRequest -> Handler (Union API.QueryResponses)
mockQueryHandler mcfg mquery mQueryCfg _sourceName queryConfig query = liftIO $ do
handler <- fmap _queryResponse $ I.readIORef mcfg
I.writeIORef mquery (Just query)
I.writeIORef mQueryCfg (Just queryConfig)
pure $ handler query
case handler query of
Left e -> pure $ inject $ SOP.I e
Right r -> pure $ inject $ SOP.I r
-- Returns an empty explain response for now
explainHandler :: API.SourceName -> API.Config -> API.QueryRequest -> Handler API.ExplainResponse

View File

@ -37,6 +37,7 @@ library
, resourcet
, safe-exceptions
, servant-server
, sop-core
, template-haskell
, text
, th-lift

View File

@ -10,12 +10,14 @@ where
import Data.Aeson qualified as J
import Data.Environment qualified as Env
import Data.Text.Extended (toTxt)
import Hasura.Backends.DataConnector.API (errorResponseSummary, queryCase)
import Hasura.Backends.DataConnector.API qualified as API
import Hasura.Backends.DataConnector.API.V0.ErrorResponse (ErrorResponse (..))
import Hasura.Backends.DataConnector.Adapter.ConfigTransform (transformSourceConfig)
import Hasura.Backends.DataConnector.Adapter.Types (SourceConfig (..))
import Hasura.Backends.DataConnector.Agent.Client (AgentClientT)
import Hasura.Backends.DataConnector.Plan qualified as DC
import Hasura.Base.Error (Code (..), QErr, throw400, throw500)
import Hasura.Base.Error (Code (..), QErr, throw400, throw400WithDetail, throw500)
import Hasura.EncJSON (EncJSON, encJFromBuilder, encJFromJValue)
import Hasura.GraphQL.Execute.Backend (BackendExecute (..), DBStepInfo (..), ExplainPlan (..))
import Hasura.GraphQL.Namespace qualified as GQL
@ -76,9 +78,14 @@ buildQueryAction sourceName SourceConfig {..} DC.QueryPlan {..} = do
when (DC.queryHasRelations _qpRequest && isNothing (API._cRelationships _scCapabilities)) $
throw400 NotSupported "Agents must provide their own dataloader."
let apiQueryRequest = Witch.into @API.QueryRequest _qpRequest
queryResponse <- (genericClient // API._query) (toTxt sourceName) _scConfig apiQueryRequest
queryResponse <- queryGuard =<< (genericClient // API._query) (toTxt sourceName) _scConfig apiQueryRequest
reshapedResponse <- _qpResponseReshaper queryResponse
pure . encJFromBuilder $ J.fromEncoding reshapedResponse
where
errorAction e = throw400WithDetail DataConnectorError (errorResponseSummary e) (_crDetails e)
defaultAction = throw400 DataConnectorError "Unexpected data connector capabilities response - Unexpected Type"
queryGuard = queryCase defaultAction pure errorAction
-- Delegates the generation to the Agent's /explain endpoint if it has that capability,
-- otherwise, returns the IR sent to the agent.

View File

@ -17,12 +17,14 @@ import Data.Sequence qualified as Seq
import Data.Sequence.NonEmpty qualified as NESeq
import Data.Text qualified as Text
import Data.Text.Extended (toTxt, (<<>), (<>>))
import Hasura.Backends.DataConnector.API (capabilitiesCase, errorResponseSummary, schemaCase)
import Hasura.Backends.DataConnector.API qualified as API
import Hasura.Backends.DataConnector.API.V0.ErrorResponse (_crDetails)
import Hasura.Backends.DataConnector.Adapter.ConfigTransform (transformConnSourceConfig)
import Hasura.Backends.DataConnector.Adapter.Types qualified as DC
import Hasura.Backends.DataConnector.Agent.Client (AgentClientContext (..), runAgentClientT)
import Hasura.Backends.Postgres.SQL.Types (PGDescription (..))
import Hasura.Base.Error (Code (..), QErr, decodeValue, throw400, throw500, withPathK)
import Hasura.Base.Error (Code (..), QErr, decodeValue, throw400, throw400WithDetail, throw500, withPathK)
import Hasura.Incremental qualified as Inc
import Hasura.Logging (Hasura, Logger)
import Hasura.Prelude
@ -112,11 +114,15 @@ resolveBackendInfo' logger = proc (invalidationKeys, optionsMap) -> do
HTTP.Manager ->
m (Either QErr DC.DataConnectorInfo)
getDataConnectorCapabilities options@DC.DataConnectorOptions {..} manager = runExceptT do
API.CapabilitiesResponse {..} <-
capabilitiesU <-
runTraceTWithReporter noReporter "capabilities"
. flip runAgentClientT (AgentClientContext logger _dcoUri manager Nothing)
$ genericClient // API._capabilities
return $ DC.DataConnectorInfo options _crCapabilities _crConfigSchemaResponse
let defaultAction = throw400 DataConnectorError "Unexpected data connector capabilities response - Unexpected Type"
capabilitiesAction API.CapabilitiesResponse {..} = pure $ DC.DataConnectorInfo options _crCapabilities _crConfigSchemaResponse
capabilitiesCase defaultAction capabilitiesAction errorAction capabilitiesU
resolveSourceConfig' ::
MonadIO m =>
@ -144,11 +150,15 @@ resolveSourceConfig'
validateConfiguration sourceName dataConnectorName _dciConfigSchemaResponse transformedConfig
schemaResponse <-
schemaResponseU <-
runTraceTWithReporter noReporter "resolve source"
. flip runAgentClientT (AgentClientContext logger _dcoUri manager (DC.sourceTimeoutMicroseconds <$> timeout))
$ (genericClient // API._schema) (toTxt sourceName) transformedConfig
let defaultAction = throw400 DataConnectorError "Unexpected data connector schema response - Unexpected Type"
schemaResponse <- schemaCase defaultAction pure errorAction schemaResponseU
pure
DC.SourceConfig
{ _scEndpoint = _dcoUri,
@ -384,3 +394,6 @@ columnTypeToScalarType = \case
RQL.T.C.ColumnScalar scalarType -> scalarType
-- NOTE: This should be unreachable:
RQL.T.C.ColumnEnumReference _ -> DC.StringTy
errorAction :: MonadError QErr m => API.ErrorResponse -> m a
errorAction e = throw400WithDetail DataConnectorError (errorResponseSummary e) (_crDetails e)

View File

@ -20,6 +20,7 @@ module Hasura.Base.Error
internalError,
QErrM,
throw400,
throw400WithDetail,
throw404,
throw405,
throw409,
@ -292,6 +293,10 @@ type QErrM m = (MonadError QErr m)
throw400 :: (QErrM m) => Code -> Text -> m a
throw400 c t = throwError $ err400 c t
throw400WithDetail :: (QErrM m) => Code -> Text -> Value -> m a
throw400WithDetail c t detail =
throwError $ (err400 c t) {qeInternal = Just $ ExtraInternal detail}
throw404 :: (QErrM m) => Text -> m a
throw404 t = throwError $ err404 NotFound t

View File

@ -42,7 +42,9 @@ import Data.HashMap.Strict.InsOrd qualified as InsOrdHashMap
import Data.HashMap.Strict.InsOrd qualified as OMap
import Data.Text.Extended
import Data.Text.Extended qualified as Text.E
import Hasura.Backends.DataConnector.API (errorResponseSummary, schemaCase)
import Hasura.Backends.DataConnector.API qualified as API
import Hasura.Backends.DataConnector.API.V0.ErrorResponse (_crDetails)
import Hasura.Backends.DataConnector.Adapter.Types qualified as DC.Types
import Hasura.Backends.DataConnector.Agent.Client qualified as Agent.Client
import Hasura.Base.Error
@ -74,6 +76,7 @@ import Hasura.SQL.BackendMap qualified as BackendMap
import Hasura.Server.Logging (MetadataLog (..))
import Hasura.Tracing qualified as Tracing
import Network.HTTP.Client.Manager qualified as HTTP.Manager
import Servant.API (Union)
import Servant.Client ((//))
import Servant.Client.Generic qualified as Servant.Client
@ -364,7 +367,7 @@ runGetSourceTables GetSourceTables {..} = do
schemaResponse <-
Tracing.runTraceTWithReporter Tracing.noReporter "resolve source"
. flip Agent.Client.runAgentClientT (Agent.Client.AgentClientContext logger _dcoUri manager (DC.Types.sourceTimeoutMicroseconds <$> timeout))
$ (Servant.Client.genericClient // API._schema) (Text.E.toTxt _gstSourceName) apiConfig
$ schemaGuard =<< (Servant.Client.genericClient // API._schema) (Text.E.toTxt _gstSourceName) apiConfig
let fullyQualifiedTableNames = fmap API._tiName $ API._srTables schemaResponse
pure $ EncJSON.encJFromJValue fullyQualifiedTableNames
@ -422,8 +425,14 @@ runGetTableInfo GetTableInfo {..} = do
schemaResponse <-
Tracing.runTraceTWithReporter Tracing.noReporter "resolve source"
. flip Agent.Client.runAgentClientT (Agent.Client.AgentClientContext logger _dcoUri manager (DC.Types.sourceTimeoutMicroseconds <$> timeout))
$ (Servant.Client.genericClient // API._schema) (Text.E.toTxt _gtiSourceName) apiConfig
$ schemaGuard =<< (Servant.Client.genericClient // API._schema) (Text.E.toTxt _gtiSourceName) apiConfig
let table = find ((== _gtiTableName) . API._tiName) $ API._srTables schemaResponse
pure $ EncJSON.encJFromJValue table
backend -> Error.throw500 ("Schema fetching is not supported for '" <> Text.E.toTxt backend <> "'")
schemaGuard :: MonadError QErr m => Union '[API.SchemaResponse, API.ErrorResponse] -> m API.SchemaResponse
schemaGuard = schemaCase defaultAction pure errorAction
where
defaultAction = throw400 DataConnectorError "Error resolving source schema"
errorAction e = throw400WithDetail DataConnectorError ("Error resolving source schema: " <> errorResponseSummary e) (_crDetails e)