mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
AVRelationship Support for Data Connectors [GDW-123]
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/4735 GitOrigin-RevId: f23965a6c7ea8a0e6b25dbf9d3faeccec0ef6ec3
This commit is contained in:
parent
639555b349
commit
7ec5e79bd1
@ -40,15 +40,15 @@ POST /v1/metadata
|
||||
"kind": "reference",
|
||||
"tables": [
|
||||
{
|
||||
"table": "albums",
|
||||
"table": "Album",
|
||||
"object_relationships": [
|
||||
{
|
||||
"name": "artist",
|
||||
"name": "Artist",
|
||||
"using": {
|
||||
"manual_configuration": {
|
||||
"remote_table": "artists",
|
||||
"remote_table": "Artist",
|
||||
"column_mapping": {
|
||||
"artist_id": "id"
|
||||
"ArtistId": "ArtistId"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -56,15 +56,15 @@ POST /v1/metadata
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "artists",
|
||||
"table": "Artist",
|
||||
"array_relationships": [
|
||||
{
|
||||
"name": "albums",
|
||||
"name": "Album",
|
||||
"using": {
|
||||
"manual_configuration": {
|
||||
"remote_table": "albums",
|
||||
"remote_table": "Album",
|
||||
"column_mapping": {
|
||||
"id": "artist_id"
|
||||
"ArtistId": "ArtistId"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -73,7 +73,7 @@ POST /v1/metadata
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"tables": [ "artists", "albums" ]
|
||||
"tables": [ "Artist", "Album" ]
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -220,32 +220,36 @@ The service logs queries from the request body in the console. Here is a simple
|
||||
|
||||
```graphql
|
||||
query {
|
||||
artists {
|
||||
id name
|
||||
Artist {
|
||||
ArtistId
|
||||
Name
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
and here is the resulting query payload:
|
||||
and here is the resulting query request payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"from": "artists",
|
||||
"where": {
|
||||
"expressions": [],
|
||||
"type": "and"
|
||||
},
|
||||
"order_by": [],
|
||||
"limit": null,
|
||||
"offset": null,
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "column",
|
||||
"column": "id"
|
||||
"table": "Artist",
|
||||
"table_relationships": [],
|
||||
"query": {
|
||||
"where": {
|
||||
"expressions": [],
|
||||
"type": "and"
|
||||
},
|
||||
"name": {
|
||||
"type": "column",
|
||||
"column": "name"
|
||||
"order_by": [],
|
||||
"limit": null,
|
||||
"offset": null,
|
||||
"fields": {
|
||||
"ArtistId": {
|
||||
"type": "column",
|
||||
"column": "ArtistId"
|
||||
},
|
||||
"Name": {
|
||||
"type": "column",
|
||||
"column": "Name"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -255,17 +259,19 @@ The implementation of the service is responsible for intepreting this data struc
|
||||
|
||||
Let's break down the request:
|
||||
|
||||
- The `from` field tells us which table to fetch the data from, namely the `artists` table.
|
||||
- The `where` field tells us that there is currently no (interesting) predicate being applied to the rows of the data set (just an empty conjunction, which ought to return every row).
|
||||
- The `order_by` field tells us that there is no particular ordering to use, and that we can return data in its natural order.
|
||||
- The `limit` and `offset` fields tell us that there is no pagination required.
|
||||
- The `fields` field tells us that we ought to return two fields per row (`id` and `name`), and that these fields should be fetched from the columns with the same names.
|
||||
- The `table` field tells us which table to fetch the data from, namely the `Artist` table.
|
||||
- The `table_relationships` field that lists any relationships used to join between tables in the query. This query does not use any relationships, so this is just an empty list here.
|
||||
- The `query` field contains further information about how to query the specified table:
|
||||
- The `where` field tells us that there is currently no (interesting) predicate being applied to the rows of the data set (just an empty conjunction, which ought to return every row).
|
||||
- The `order_by` field tells us that there is no particular ordering to use, and that we can return data in its natural order.
|
||||
- The `limit` and `offset` fields tell us that there is no pagination required.
|
||||
- The `fields` field tells us that we ought to return two fields per row (`ArtistId` and `Name`), and that these fields should be fetched from the columns with the same names.
|
||||
|
||||
#### Response Body Structure
|
||||
|
||||
The response body for a call to `POST /query` should always consist of a JSON array of JSON objects. Each of those JSON objects should contain all of the same keys as the `fields` property of the request body, even if the values of the corresponding fields may be `null`.
|
||||
|
||||
In the case of fields appearing with type `relationship`, this property should apply recursively: such fields should appear in the response body as a nested array of records, each with the appropriate fields described by the relationship field's `query` property.
|
||||
In the case of fields appearing with type `relationship`, this property should apply recursively: such fields should appear in the response body as a nested array/object of record(s) (depending on whether the relationship is an object or array relationship), each with the appropriate fields described by the relationship field's `query` property.
|
||||
|
||||
#### Pagination
|
||||
|
||||
@ -315,6 +321,8 @@ Values (as used in `value` in `binary_op` and the `values` array in `binary_arr_
|
||||
| `scalar` | `value` | A scalar `value` to compare against |
|
||||
| `column` | `column` | A `column` in the current table being queried to compare against |
|
||||
|
||||
Columns (as used in `column` fields in `binary_op`, `binary_arr_op`, `unary_op` and in `column`-typed Values) are specified as a column `name`, as well as a `path` to the table that contains the column. This path is an array of relationship names that starts from the table being queried (ie the table being queried by the query that this where expression is being specified in). An empty array means the column would be on the table being queried itself.
|
||||
|
||||
Here is a simple example, which correponds to the predicate "`first_name` is John and `last_name` is Smith":
|
||||
|
||||
```json
|
||||
@ -324,7 +332,10 @@ Here is a simple example, which correponds to the predicate "`first_name` is Joh
|
||||
{
|
||||
"type": "binary_op",
|
||||
"operator": "equal",
|
||||
"column": "first_name",
|
||||
"column": {
|
||||
"path": [],
|
||||
"name": "first_name"
|
||||
},
|
||||
"value": {
|
||||
"type": "scalar",
|
||||
"value": "John"
|
||||
@ -333,7 +344,10 @@ Here is a simple example, which correponds to the predicate "`first_name` is Joh
|
||||
{
|
||||
"type": "binary_op",
|
||||
"operator": "equal",
|
||||
"column": "last_name",
|
||||
"column": {
|
||||
"path": [],
|
||||
"name": "last_name"
|
||||
},
|
||||
"value": {
|
||||
"type": "scalar",
|
||||
"value": "John"
|
||||
@ -352,10 +366,16 @@ Here's another example, which corresponds to the predicate "`first_name` is the
|
||||
{
|
||||
"type": "binary_op",
|
||||
"operator": "equal",
|
||||
"column": "first_name",
|
||||
"column": {
|
||||
"path": [],
|
||||
"name": "first_name"
|
||||
},
|
||||
"value": {
|
||||
"type": "column",
|
||||
"column": "last_name"
|
||||
"column": {
|
||||
"path": [],
|
||||
"name": "last_name"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -389,18 +409,20 @@ If the call to `GET /schema` returns a `capabilities` record with the `relations
|
||||
|
||||
_Note_ :if the `relationships` field is set to `false` then `graphql-engine` will attempt to split queries into separate queries whenever relationships are involved. This is not always possible, depending on the structure of the query, but the fastest path to adding a data source as a Data Connector is to set `relationships` to `false` and to ignore this section. (This is currently not supported, but is intended to be in the future.)
|
||||
|
||||
Relationship fields are indicated by a `type` field containing the string `relationship`. Such fields will also include column_mapping` and `query` data.
|
||||
Relationship fields are indicated by a `type` field containing the string `relationship`. Such fields will also include the name of the relationship in a field called `relationship`. This name refers to a relationship that is specified on the top-level query request object in the `table_relationships` field.
|
||||
|
||||
`column_mapping` indicates the mapping from columns in the source table to columns in the related table. It is intended that the backend should execute the contained `query` and return the resulting record set as the value of this field, with the additional record-level predicate that any mapped columns should be equal in the context of the current record of the current table.
|
||||
This `table_relationships` is a list of tables, and for each table, a map of relationship name to relationship information. The information is an object that has a field `target_table` that specifies the name of the related table. It has a field called `relationship_type` that specified either an `object` (many to one) or an `array` (one to many) relationship. There is also a `column_mapping` field that indicates the mapping from columns in the source table to columns in the related table.
|
||||
|
||||
It is intended that the backend should execute the `query` contained in the relationship field and return the resulting record set as the value of this field, with the additional record-level predicate that any mapped columns should be equal in the context of the current record of the current table.
|
||||
|
||||
An example will illustrate this. Consider the following GraphQL query:
|
||||
|
||||
```graphql
|
||||
query {
|
||||
artists {
|
||||
name
|
||||
albums {
|
||||
title
|
||||
Artist {
|
||||
Name
|
||||
Albums {
|
||||
Title
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -410,53 +432,65 @@ This will generate the following JSON query if `relationships` is set to `true`:
|
||||
|
||||
```json
|
||||
{
|
||||
"where": {
|
||||
"expressions": [],
|
||||
"type": "and"
|
||||
},
|
||||
"offset": null,
|
||||
"from": "artists",
|
||||
"order_by": [],
|
||||
"limit": null,
|
||||
"fields": {
|
||||
"albums": {
|
||||
"type": "relationship",
|
||||
"column_mapping": {
|
||||
"id": "artist_id"
|
||||
},
|
||||
"query": {
|
||||
"where": {
|
||||
"expressions": [],
|
||||
"type": "and"
|
||||
},
|
||||
"offset": null,
|
||||
"from": "albums",
|
||||
"order_by": [],
|
||||
"limit": null,
|
||||
"fields": {
|
||||
"title": {
|
||||
"type": "column",
|
||||
"column": "title"
|
||||
"table": "Artist",
|
||||
"table_relationships": [
|
||||
{
|
||||
"source_table": "Artist",
|
||||
"relationships": {
|
||||
"ArtistAlbums": {
|
||||
"target_table": "Album",
|
||||
"relationship_type": "array",
|
||||
"column_mapping": {
|
||||
"ArtistId": "ArtistId"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"query": {
|
||||
"where": {
|
||||
"expressions": [],
|
||||
"type": "and"
|
||||
},
|
||||
"name": {
|
||||
"type": "column",
|
||||
"column": "name"
|
||||
"offset": null,
|
||||
"order_by": [],
|
||||
"limit": null,
|
||||
"fields": {
|
||||
"Albums": {
|
||||
"type": "relationship",
|
||||
"relationship": "ArtistAlbums",
|
||||
"query": {
|
||||
"where": {
|
||||
"expressions": [],
|
||||
"type": "and"
|
||||
},
|
||||
"offset": null,
|
||||
"from": "albums",
|
||||
"order_by": [],
|
||||
"limit": null,
|
||||
"fields": {
|
||||
"Title": {
|
||||
"type": "column",
|
||||
"column": "Title"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Name": {
|
||||
"type": "column",
|
||||
"column": "Name"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note the `albums` field in particular, which traverses the `artists` -> `albums` relationship:
|
||||
Note the `Albums` field in particular, which traverses the `Artists` -> `Albums` relationship, via the `ArtistAlbums` relationship:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "relationship",
|
||||
"column_mapping": {
|
||||
"id": "artist_id"
|
||||
},
|
||||
"relationship": "ArtistAlbums",
|
||||
"query": {
|
||||
"where": {
|
||||
"expressions": [],
|
||||
@ -467,19 +501,175 @@ Note the `albums` field in particular, which traverses the `artists` -> `albums`
|
||||
"order_by": [],
|
||||
"limit": null,
|
||||
"fields": {
|
||||
"title": {
|
||||
"Title": {
|
||||
"type": "column",
|
||||
"column": "title"
|
||||
"column": "Title"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `column_mapping` field indicates the column mapping for this relationship, namely that the album's `artist_id` must equal the artist's `id`.
|
||||
The top-level `table_relationships` can be looked up by starting from the source table (in this case `Artist`), locating the `ArtistAlbums` relationship under that table, then extracting the relationship information. This information includes the `target_table` field which indicates the table to be queried when following this relationship is the `Album` table. The `relationship_type` field indicates that this relationship is an `array` relationship (ie. that it will return zero to many Album rows per Artist row). The `column_mapping` field indicates the column mapping for this relationship, namely that the Artist's `ArtistId` must equal the Album's `ArtistId`.
|
||||
|
||||
The `query` field indicates the query that should be executed against the `albums` table, but we must remember to enforce the additional constraint between `artist_id` and `id`. That is, in the context of any single outer `artist` record, we should populate the `albums` field with the array of album records for which the `artist_id` field is equal to the outer record's `id` field.
|
||||
Back on the relationship field inside the query, there is another `query` field. This indicates the query that should be executed against the `Album` table, but we must remember to enforce the additional constraint between Artist's `ArtistId` and Album's `ArtistId`. That is, in the context of any single outer `Artist` record, we should populate the `Albums` field with the array of Album records for which the `ArtistId` field is equal to the outer record's `ArtistId` field.
|
||||
|
||||
#### Cross-Table Filtering
|
||||
It is possible to form queries that filter their results by comparing columns across tables via relationships. One way this can happen in Hasura GraphQL Engine is when configuring permissions on a table. It is possible to configure a filter on a table such that it joins to another table in order to compare some data in the filter expression.
|
||||
|
||||
The following metadata when used with HGE configures a `Customer` and `Employee` table, and sets up a select permission rule on `Customer` such that only customers that live in the same country as their SupportRep Employee would be visible to users in the `user` role:
|
||||
|
||||
```json
|
||||
POST /v1/metadata
|
||||
|
||||
{
|
||||
"type": "replace_metadata",
|
||||
"args": {
|
||||
"metadata": {
|
||||
"version": 3,
|
||||
"backend_configs": {
|
||||
"dataconnector": {
|
||||
"reference": {
|
||||
"uri": "http://localhost:8100/"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"name": "chinook",
|
||||
"kind": "reference",
|
||||
"tables": [
|
||||
{
|
||||
"table": "Customer",
|
||||
"object_relationships": [
|
||||
{
|
||||
"name": "SupportRep",
|
||||
"using": {
|
||||
"manual_configuration": {
|
||||
"remote_table": "Employee",
|
||||
"column_mapping": {
|
||||
"SupportRepId": "EmployeeId"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"select_permissions": [
|
||||
{
|
||||
"role": "user",
|
||||
"permission": {
|
||||
"columns": [
|
||||
"CustomerId",
|
||||
"FirstName",
|
||||
"LastName",
|
||||
"Country",
|
||||
"SupportRepId"
|
||||
],
|
||||
"filter": {
|
||||
"SupportRep": {
|
||||
"Country": {
|
||||
"_ceq": ["$","Country"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "Employee"
|
||||
}
|
||||
],
|
||||
"configuration": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Given this GraphQL query (where the `X-Hasura-Role` header is set to `user`):
|
||||
|
||||
```graphql
|
||||
query getCustomer {
|
||||
Customer {
|
||||
CustomerId
|
||||
FirstName
|
||||
LastName
|
||||
Country
|
||||
SupportRepId
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
We would get the following query request JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"table": "Customer",
|
||||
"table_relationships": [
|
||||
{
|
||||
"source_table": "Customer",
|
||||
"relationships": {
|
||||
"SupportRep": {
|
||||
"target_table": "Employee",
|
||||
"relationship_type": "object",
|
||||
"column_mapping": {
|
||||
"SupportRepId": "EmployeeId"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"query": {
|
||||
"fields": {
|
||||
"Country": {
|
||||
"type": "column",
|
||||
"column": "Country"
|
||||
},
|
||||
"CustomerId": {
|
||||
"type": "column",
|
||||
"column": "CustomerId"
|
||||
},
|
||||
"FirstName": {
|
||||
"type": "column",
|
||||
"column": "FirstName"
|
||||
},
|
||||
"LastName": {
|
||||
"type": "column",
|
||||
"column": "LastName"
|
||||
},
|
||||
"SupportRepId": {
|
||||
"type": "column",
|
||||
"column": "SupportRepId"
|
||||
}
|
||||
},
|
||||
"where": {
|
||||
"type": "and",
|
||||
"expressions": [
|
||||
{
|
||||
"type": "binary_op",
|
||||
"operator": "equal",
|
||||
"column": {
|
||||
"path": ["SupportRep"],
|
||||
"name": "Country"
|
||||
},
|
||||
"value": {
|
||||
"type": "column",
|
||||
"column": {
|
||||
"path": [],
|
||||
"name": "Country"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The key point of interest here is in the `where` field where we are comparing between columns. The first column's `path` is `["SupportRep"]` indicating that the `Country` column specified there is on the other side of the `Customer` table's `SupportRep` relationship (ie. to the `Employee` table). The related `Employee`'s `Country` column is being compared with `equal` to `Customer`'s `Country` column (as indicated by the `[]` path). So, in order to evaluate this condition, we'd need to join the `Employee` table using the `column_mapping` specified in the `SupportRep` relationship and if any of the related rows (in this case, only one because it is an `object` relation) contain a `Country` that is equal to Employee row's `Country`, then the `binary_op` evaluates to True and we don't filter out the row.
|
||||
|
||||
#### Type Definitions
|
||||
|
||||
The `Query` TypeScript type in the [reference implementation](./reference/src/types/query.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.
|
||||
The `QueryRequest` TypeScript type in the [reference implementation](./reference/src/types/query.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.
|
||||
|
@ -1,6 +1,6 @@
|
||||
import Fastify from 'fastify';
|
||||
import Fastify from 'fastify';
|
||||
import { SchemaResponse } from './types/schema';
|
||||
import { ProjectedRow, Query } from './types/query';
|
||||
import { ProjectedRow, QueryRequest } from './types/query';
|
||||
import { filterAvailableTables, getSchema, loadStaticData } from './data';
|
||||
import { queryData } from './query';
|
||||
import { getConfig } from './config';
|
||||
@ -21,11 +21,11 @@ server.get<{ Reply: SchemaResponse }>("/schema", async (request, _response) => {
|
||||
return getSchema(config);
|
||||
});
|
||||
|
||||
server.post<{ Body: Query, Reply: ProjectedRow[] }>("/query", async (request, _response) => {
|
||||
server.post<{ Body: QueryRequest, Reply: ProjectedRow[] }>("/query", async (request, _response) => {
|
||||
server.log.info({ headers: request.headers, query: request.body, }, "query.request");
|
||||
const config = getConfig(request);
|
||||
const data = filterAvailableTables(staticData, config);
|
||||
return queryData(data)(request.body);
|
||||
return queryData(data, request.body);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Expression, Fields, BinaryComparisonOperator, OrderBy, OrderType, ProjectedRow, Query, QueryResponse, RelType, RelationshipField, ScalarValue, UnaryComparisonOperator, ComparisonValue, BinaryArrayComparisonOperator } from "./types/query";
|
||||
import { coerceUndefinedToNull, unreachable } from "./util";
|
||||
import { Expression, Fields, BinaryComparisonOperator, OrderBy, OrderType, ProjectedRow, Query, QueryResponse, RelationshipType, ScalarValue, UnaryComparisonOperator, ComparisonValue, BinaryArrayComparisonOperator, QueryRequest, TableName, ComparisonColumn, TableRelationships, Relationship, RelationshipName } from "./types/query";
|
||||
import { coerceUndefinedToNull, crossProduct, unreachable, zip } from "./util";
|
||||
|
||||
type StaticData = {
|
||||
[tableName: string]: Record<string, ScalarValue>[]
|
||||
@ -56,10 +56,14 @@ const getUnaryComparisonOperatorEvaluator = (operator: UnaryComparisonOperator):
|
||||
};
|
||||
};
|
||||
|
||||
const prettyPrintScalarComparisonValue = (comparisonValue: ComparisonValue): string => {
|
||||
const prettyPrintComparisonColumn = (comparisonColumn: ComparisonColumn): string => {
|
||||
return comparisonColumn.path.concat(comparisonColumn.name).map(p => `[${p}]`).join(".");
|
||||
}
|
||||
|
||||
const prettyPrintComparisonValue = (comparisonValue: ComparisonValue): string => {
|
||||
switch (comparisonValue.type) {
|
||||
case "column":
|
||||
return `[${comparisonValue.column}]`;
|
||||
return prettyPrintComparisonColumn(comparisonValue.column);
|
||||
case "scalar":
|
||||
return comparisonValue.value === null ? "null" : comparisonValue.value.toString();
|
||||
default:
|
||||
@ -80,23 +84,31 @@ export const prettyPrintExpression = (e: Expression): string => {
|
||||
case "not":
|
||||
return `!(${prettyPrintExpression(e.expression)})`;
|
||||
case "binary_op":
|
||||
return `([${e.column}] ${prettyPrintBinaryComparisonOperator(e.operator)} ${prettyPrintScalarComparisonValue(e.value)})`;
|
||||
return `([${prettyPrintComparisonColumn(e.column)}] ${prettyPrintBinaryComparisonOperator(e.operator)} ${prettyPrintComparisonValue(e.value)})`;
|
||||
case "binary_arr_op":
|
||||
return `([${e.column}] ${prettyPrintBinaryArrayComparisonOperator(e.operator)} (${e.values.map(prettyPrintScalarComparisonValue).join(", ")}))`;
|
||||
return `([${prettyPrintComparisonColumn(e.column)}] ${prettyPrintBinaryArrayComparisonOperator(e.operator)} (${e.values.join(", ")}))`;
|
||||
case "unary_op":
|
||||
return `([${e.column}] ${prettyPrintUnaryComparisonOperator(e.operator)})`;
|
||||
return `([${prettyPrintComparisonColumn(e.column)}] ${prettyPrintUnaryComparisonOperator(e.operator)})`;
|
||||
default:
|
||||
return unreachable(e["type"]);
|
||||
}
|
||||
};
|
||||
|
||||
const makeFilterPredicate = (expression: Expression | null) => (row: Record<string, ScalarValue>) => {
|
||||
const extractScalarComparisonValue = (comparisonValue: ComparisonValue): ScalarValue => {
|
||||
const areComparingColumnsOnSameTable = (comparisonColumn: ComparisonColumn, comparisonValue: ComparisonValue): boolean => {
|
||||
if (comparisonValue.type === "scalar")
|
||||
return false;
|
||||
if (comparisonColumn.path.length !== comparisonValue.column.path.length)
|
||||
return false;
|
||||
return zip(comparisonColumn.path, comparisonValue.column.path).every(([p1, p2]) => p1 === p2);
|
||||
};
|
||||
|
||||
const makeFilterPredicate = (expression: Expression | null, getComparisonColumnValues: (comparisonColumn: ComparisonColumn, row: Record<string, ScalarValue>) => ScalarValue[]) => (row: Record<string, ScalarValue>) => {
|
||||
const extractComparisonValueScalars = (comparisonValue: ComparisonValue): ScalarValue[] => {
|
||||
switch (comparisonValue.type) {
|
||||
case "column":
|
||||
return coerceUndefinedToNull(row[comparisonValue.column]);
|
||||
return getComparisonColumnValues(comparisonValue.column, row);
|
||||
case "scalar":
|
||||
return comparisonValue.value;
|
||||
return [comparisonValue.value];
|
||||
default:
|
||||
return unreachable(comparisonValue["type"]);
|
||||
}
|
||||
@ -111,14 +123,19 @@ const makeFilterPredicate = (expression: Expression | null) => (row: Record<stri
|
||||
case "not":
|
||||
return !evaluate(e.expression);
|
||||
case "binary_op":
|
||||
const binOpColumnVal = coerceUndefinedToNull(row[e.column]);
|
||||
return getBinaryComparisonOperatorEvaluator(e.operator)(binOpColumnVal, extractScalarComparisonValue(e.value));
|
||||
const binOpColumnVals = getComparisonColumnValues(e.column, row);
|
||||
const binOpComparisonVals = extractComparisonValueScalars(e.value);
|
||||
const comparisonPairs =
|
||||
areComparingColumnsOnSameTable(e.column, e.value)
|
||||
? zip(binOpColumnVals, binOpComparisonVals)
|
||||
: crossProduct(binOpColumnVals, binOpComparisonVals);
|
||||
return comparisonPairs.some(([columnVal, comparisonVal]) => getBinaryComparisonOperatorEvaluator(e.operator)(columnVal, comparisonVal));
|
||||
case "binary_arr_op":
|
||||
const inColumnVal = coerceUndefinedToNull(row[e.column]);
|
||||
return getBinaryArrayComparisonOperatorEvaluator(e.operator)(inColumnVal, e.values.map(extractScalarComparisonValue));
|
||||
const inColumnVals = getComparisonColumnValues(e.column, row);
|
||||
return inColumnVals.some(columnVal => getBinaryArrayComparisonOperatorEvaluator(e.operator)(columnVal, e.values));
|
||||
case "unary_op":
|
||||
const unOpColumnVal = coerceUndefinedToNull(row[e.column]);
|
||||
return getUnaryComparisonOperatorEvaluator(e.operator)(unOpColumnVal);
|
||||
const unOpColumnVals = getComparisonColumnValues(e.column, row);
|
||||
return unOpColumnVals.some(columnVal => getUnaryComparisonOperatorEvaluator(e.operator)(columnVal));
|
||||
default:
|
||||
return unreachable(e["type"]);
|
||||
}
|
||||
@ -155,36 +172,111 @@ const paginateRows = (rows: Record<string, ScalarValue>[], offset: number | null
|
||||
return rows.slice(start, end);
|
||||
};
|
||||
|
||||
const createSubqueryForRelationshipField = (row: Record<string, ScalarValue>, field: RelationshipField): Query | null => {
|
||||
const columnMappings = Object.entries(field.column_mapping);
|
||||
const makeFindRelationship = (allTableRelationships: TableRelationships[], tableName: TableName) => (relationshipName: RelationshipName): Relationship => {
|
||||
const relationship = allTableRelationships.find(r => r.source_table === tableName)?.relationships?.[relationshipName];
|
||||
if (relationship === undefined)
|
||||
throw `No relationship named ${relationshipName} found for table ${tableName}`;
|
||||
else
|
||||
return relationship;
|
||||
};
|
||||
|
||||
const createFilterExpressionForRelationshipJoin = (row: Record<string, ScalarValue>, relationship: Relationship): Expression | null => {
|
||||
const columnMappings = Object.entries(relationship.column_mapping);
|
||||
const filterConditions: Expression[] = columnMappings
|
||||
.map(([fkName, pkName]): [string, ScalarValue] => [pkName, row[fkName]])
|
||||
.filter((x): x is [string, ScalarValue] => {
|
||||
const [_, fkVal] = x;
|
||||
return fkVal !== null;
|
||||
.map(([outerColumnName, innerColumnName]): [ScalarValue, string] => [row[outerColumnName], innerColumnName])
|
||||
.filter((x): x is [ScalarValue, string] => {
|
||||
const [outerValue, _] = x;
|
||||
return outerValue !== null;
|
||||
})
|
||||
.map(([pkName, fkVal]) => {
|
||||
.map(([outerValue, innerColumnName]) => {
|
||||
return {
|
||||
type: "binary_op",
|
||||
operator: BinaryComparisonOperator.Equal,
|
||||
column: pkName,
|
||||
value: { type: "scalar", value: fkVal }
|
||||
column: {
|
||||
path: [],
|
||||
name: innerColumnName,
|
||||
},
|
||||
value: { type: "scalar", value: outerValue }
|
||||
};
|
||||
});
|
||||
|
||||
// If we have no columns to join on, or if some of the FK columns in the row contained null, then we can't join
|
||||
if (columnMappings.length === 0 || filterConditions.length !== columnMappings.length) {
|
||||
return null;
|
||||
} else {
|
||||
const existingFilters = field.query.where ? [field.query.where] : []
|
||||
return { type: "and", expressions: filterConditions }
|
||||
}
|
||||
};
|
||||
|
||||
const addRelationshipFilterToQuery = (row: Record<string, ScalarValue>, relationship: Relationship, subquery: Query): Query | null => {
|
||||
const filterExpression = createFilterExpressionForRelationshipJoin(row, relationship);
|
||||
|
||||
// If we have no columns to join on, or if some of the FK columns in the row contained null, then we can't join
|
||||
if (filterExpression === null) {
|
||||
return null;
|
||||
} else {
|
||||
const existingFilters = subquery.where ? [subquery.where] : []
|
||||
return {
|
||||
...field.query,
|
||||
where: { type: "and", expressions: [...filterConditions, ...existingFilters] }
|
||||
...subquery,
|
||||
where: { type: "and", expressions: [filterExpression, ...existingFilters] }
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const projectRow = (fields: Fields, performQuery: (query: Query) => ProjectedRow[]) => (row: Record<string, ScalarValue>): ProjectedRow => {
|
||||
const buildFieldsForPathedComparisonColumn = (comparisonColumn: ComparisonColumn): Fields => {
|
||||
const [relationshipName, ...remainingPath] = comparisonColumn.path;
|
||||
if (relationshipName === undefined) {
|
||||
return {
|
||||
[comparisonColumn.name]: { type: "column", column: comparisonColumn.name }
|
||||
};
|
||||
} else {
|
||||
const innerComparisonColumn = { ...comparisonColumn, path: remainingPath };
|
||||
return {
|
||||
[relationshipName]: { type: "relationship", relationship: relationshipName, query: { fields: buildFieldsForPathedComparisonColumn(innerComparisonColumn) } }
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const extractScalarValuesFromFieldPath = (fieldPath: string[], value: ProjectedRow | ScalarValue | ProjectedRow[]): ScalarValue[] => {
|
||||
const [fieldName, ...remainingPath] = fieldPath;
|
||||
if (fieldName === undefined) {
|
||||
if (value !== null && typeof value === "object") { // Yes, the typeof of null and arrays is "object" 😑
|
||||
throw "Field path did not end in a ScalarValue";
|
||||
} else {
|
||||
return [value]; // ScalarValues
|
||||
}
|
||||
} else {
|
||||
if (value === null) { // This can occur with optional object relationships
|
||||
return [];
|
||||
} else if (Array.isArray(value)) {
|
||||
return value.flatMap(row => extractScalarValuesFromFieldPath(fieldPath, row));
|
||||
} else if (typeof value === "object") {
|
||||
return extractScalarValuesFromFieldPath(remainingPath, value[fieldName]);
|
||||
} else {
|
||||
throw `Found a ScalarValue in the middle of a field path: ${fieldPath}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const makeGetComparisonColumnValues = (findRelationship: (relationshipName: RelationshipName) => Relationship, performQuery: (tableName: TableName, query: Query) => ProjectedRow[]) => (comparisonColumn: ComparisonColumn, row: Record<string, ScalarValue>): ScalarValue[] => {
|
||||
const [relationshipName, ...remainingPath] = comparisonColumn.path;
|
||||
if (relationshipName === undefined) {
|
||||
return [coerceUndefinedToNull(row[comparisonColumn.name])];
|
||||
} else {
|
||||
const relationship = findRelationship(relationshipName);
|
||||
const query: Query = { fields: buildFieldsForPathedComparisonColumn({ ...comparisonColumn, path: remainingPath }) };
|
||||
const subquery = addRelationshipFilterToQuery(row, relationship, query);
|
||||
|
||||
if (subquery === null) {
|
||||
return [];
|
||||
} else {
|
||||
const rows = performQuery(relationship.target_table, subquery);
|
||||
const fieldPath = remainingPath.concat(comparisonColumn.name);
|
||||
return rows.flatMap(row => extractScalarValuesFromFieldPath(fieldPath, row));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const projectRow = (fields: Fields, findRelationship: (relationshipName: RelationshipName) => Relationship, performQuery: (tableName: TableName, query: Query) => ProjectedRow[]) => (row: Record<string, ScalarValue>): ProjectedRow => {
|
||||
const projectedRow: ProjectedRow = {};
|
||||
for (const [fieldName, field] of Object.entries(fields)) {
|
||||
|
||||
@ -194,18 +286,19 @@ const projectRow = (fields: Fields, performQuery: (query: Query) => ProjectedRow
|
||||
break;
|
||||
|
||||
case "relationship":
|
||||
const subquery = createSubqueryForRelationshipField(row, field);
|
||||
switch (field.relation_type) {
|
||||
case "object":
|
||||
projectedRow[fieldName] = subquery ? performQuery(subquery)[0] : null;
|
||||
const relationship = findRelationship(field.relationship);
|
||||
const subquery = addRelationshipFilterToQuery(row, relationship, field.query);
|
||||
switch (relationship.relationship_type) {
|
||||
case RelationshipType.Object:
|
||||
projectedRow[fieldName] = subquery ? coerceUndefinedToNull(performQuery(relationship.target_table, subquery)[0]) : null;
|
||||
break;
|
||||
|
||||
case "array":
|
||||
projectedRow[fieldName] = subquery ? performQuery(subquery) : [];
|
||||
case RelationshipType.Array:
|
||||
projectedRow[fieldName] = subquery ? performQuery(relationship.target_table, subquery) : [];
|
||||
break;
|
||||
|
||||
default:
|
||||
unreachable(field.relation_type);
|
||||
unreachable(relationship.relationship_type);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
@ -217,17 +310,20 @@ const projectRow = (fields: Fields, performQuery: (query: Query) => ProjectedRow
|
||||
return projectedRow;
|
||||
};
|
||||
|
||||
export const queryData = (staticData: StaticData) => {
|
||||
const performQuery = (query: Query): QueryResponse => {
|
||||
const rows = staticData[query.from];
|
||||
export const queryData = (staticData: StaticData, queryRequest: QueryRequest) => {
|
||||
const performQuery = (tableName: TableName, query: Query): QueryResponse => {
|
||||
const rows = staticData[tableName];
|
||||
if (rows === undefined) {
|
||||
throw `${query.from} is not a valid table`;
|
||||
throw `${tableName} is not a valid table`;
|
||||
}
|
||||
const filteredRows = rows.filter(makeFilterPredicate(query.where ?? null));
|
||||
const findRelationship = makeFindRelationship(queryRequest.table_relationships, tableName);
|
||||
const getComparisonColumnValues = makeGetComparisonColumnValues(findRelationship, performQuery);
|
||||
|
||||
const filteredRows = rows.filter(makeFilterPredicate(query.where ?? null, getComparisonColumnValues));
|
||||
const sortedRows = sortRows(filteredRows, query.order_by ?? []);
|
||||
const slicedRows = paginateRows(sortedRows, query.offset ?? null, query.limit ?? null);
|
||||
return slicedRows.map(projectRow(query.fields, performQuery));
|
||||
return slicedRows.map(projectRow(query.fields, findRelationship, performQuery));
|
||||
}
|
||||
|
||||
return performQuery;
|
||||
return performQuery(queryRequest.table, queryRequest.query);
|
||||
};
|
||||
|
@ -1,6 +1,34 @@
|
||||
export type QueryRequest = {
|
||||
table: TableName,
|
||||
table_relationships: TableRelationships[],
|
||||
query: Query,
|
||||
}
|
||||
|
||||
export type TableName = string
|
||||
|
||||
export type TableRelationships = {
|
||||
source_table: TableName,
|
||||
relationships: { [relationshipName: RelationshipName]: Relationship }
|
||||
}
|
||||
|
||||
export type Relationship = {
|
||||
target_table: TableName,
|
||||
relationship_type: RelationshipType,
|
||||
column_mapping: { [source: SourceColumnName]: TargetColumnName },
|
||||
}
|
||||
|
||||
export type SourceColumnName = ColumnName
|
||||
export type TargetColumnName = ColumnName
|
||||
|
||||
export type RelationshipName = string
|
||||
|
||||
export enum RelationshipType {
|
||||
Object = "object",
|
||||
Array = "array"
|
||||
}
|
||||
|
||||
export type Query = {
|
||||
fields: Fields,
|
||||
from: string,
|
||||
limit?: number | null,
|
||||
offset?: number | null,
|
||||
where?: Expression | null,
|
||||
@ -17,27 +45,26 @@ export type ColumnField = {
|
||||
column: ColumnName,
|
||||
}
|
||||
|
||||
export type PrimaryKey = ColumnName
|
||||
export type ForeignKey = ColumnName
|
||||
|
||||
export type RelType = "object" | "array"
|
||||
|
||||
export type RelationshipField = {
|
||||
type: "relationship",
|
||||
column_mapping: { [primaryKey: PrimaryKey]: ForeignKey },
|
||||
relation_type: RelType,
|
||||
relationship: RelationshipName
|
||||
query: Query,
|
||||
}
|
||||
|
||||
export type ScalarValue = string | number | boolean | null
|
||||
|
||||
export type ComparisonColumn = {
|
||||
path: RelationshipName[],
|
||||
name: ColumnName,
|
||||
}
|
||||
|
||||
export type ComparisonValue =
|
||||
| AnotherColumnComparisonValue
|
||||
| ScalarComparisonValue
|
||||
|
||||
export type AnotherColumnComparisonValue = {
|
||||
type: "column",
|
||||
column: ColumnName,
|
||||
column: ComparisonColumn,
|
||||
}
|
||||
|
||||
export type ScalarComparisonValue = {
|
||||
@ -71,21 +98,21 @@ export type NotExpression = {
|
||||
export type ApplyBinaryComparisonOperatorExpression = {
|
||||
type: "binary_op",
|
||||
operator: BinaryComparisonOperator,
|
||||
column: ColumnName,
|
||||
column: ComparisonColumn,
|
||||
value: ComparisonValue,
|
||||
}
|
||||
|
||||
export type ApplyBinaryArrayComparisonOperatorExpression = {
|
||||
type: "binary_arr_op",
|
||||
operator: BinaryArrayComparisonOperator,
|
||||
column: ColumnName,
|
||||
values: ComparisonValue[],
|
||||
column: ComparisonColumn,
|
||||
values: ScalarValue[],
|
||||
}
|
||||
|
||||
export type ApplyUnaryComparisonOperatorExpression = {
|
||||
type: "unary_op",
|
||||
operator: UnaryComparisonOperator,
|
||||
column: ColumnName,
|
||||
column: ComparisonColumn,
|
||||
}
|
||||
|
||||
export enum BinaryComparisonOperator {
|
||||
|
@ -1,3 +1,17 @@
|
||||
export const coerceUndefinedToNull = <T>(v: T | undefined): T | null => v === undefined ? null : v;
|
||||
export const coerceUndefinedToNull = <T>(v: T | undefined): T | null => v === undefined ? null : v;
|
||||
|
||||
export const unreachable = (x: never): never => { throw new Error(`Unreachable code reached! The types lied! 😭 Unexpected value: ${x}`) };
|
||||
;
|
||||
|
||||
export const zip = <T, U>(arr1: T[], arr2: U[]): [T,U][] => {
|
||||
const length = Math.min(arr1.length, arr2.length);
|
||||
const newArray = Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
newArray[i] = [arr1[i], arr2[i]];
|
||||
}
|
||||
return newArray;
|
||||
};
|
||||
|
||||
export const crossProduct = <T, U>(arr1: T[], arr2: U[]): [T,U][] => {
|
||||
return arr1.flatMap(a1 => arr2.map(a2 => [a1, a2]) as [T,U][]);
|
||||
};
|
||||
|
@ -365,13 +365,14 @@ library dc-api
|
||||
|
||||
exposed-modules: Autodocodec.Extended
|
||||
, Hasura.Backends.DataConnector.API
|
||||
, Hasura.Backends.DataConnector.API.V0.API
|
||||
, Hasura.Backends.DataConnector.API.V0
|
||||
, Hasura.Backends.DataConnector.API.V0.Capabilities
|
||||
, Hasura.Backends.DataConnector.API.V0.Column
|
||||
, Hasura.Backends.DataConnector.API.V0.ConfigSchema
|
||||
, Hasura.Backends.DataConnector.API.V0.Expression
|
||||
, Hasura.Backends.DataConnector.API.V0.OrderBy
|
||||
, Hasura.Backends.DataConnector.API.V0.Query
|
||||
, Hasura.Backends.DataConnector.API.V0.Relationships
|
||||
, Hasura.Backends.DataConnector.API.V0.Scalar.Type
|
||||
, Hasura.Backends.DataConnector.API.V0.Scalar.Value
|
||||
, Hasura.Backends.DataConnector.API.V0.Schema
|
||||
@ -571,6 +572,7 @@ library
|
||||
, Hasura.Backends.DataConnector.IR.Name
|
||||
, Hasura.Backends.DataConnector.IR.OrderBy
|
||||
, Hasura.Backends.DataConnector.IR.Query
|
||||
, Hasura.Backends.DataConnector.IR.Relationships
|
||||
, Hasura.Backends.DataConnector.IR.Scalar.Type
|
||||
, Hasura.Backends.DataConnector.IR.Scalar.Value
|
||||
, Hasura.Backends.DataConnector.IR.Table
|
||||
@ -975,6 +977,7 @@ test-suite graphql-engine-tests
|
||||
Hasura.Backends.DataConnector.API.V0.ExpressionSpec
|
||||
Hasura.Backends.DataConnector.API.V0.OrderBySpec
|
||||
Hasura.Backends.DataConnector.API.V0.QuerySpec
|
||||
Hasura.Backends.DataConnector.API.V0.RelationshipsSpec
|
||||
Hasura.Backends.DataConnector.API.V0.Scalar.TypeSpec
|
||||
Hasura.Backends.DataConnector.API.V0.Scalar.ValueSpec
|
||||
Hasura.Backends.DataConnector.API.V0.SchemaSpec
|
||||
@ -1161,7 +1164,8 @@ test-suite tests-hspec
|
||||
Test.BackendOnlyPermissionsSpec
|
||||
Test.ColumnPresetsSpec
|
||||
Test.CustomFieldNamesSpec
|
||||
Test.DC.QuerySpec
|
||||
Test.DataConnector.QuerySpec
|
||||
Test.DataConnector.SelectPermissionsSpec
|
||||
Test.DirectivesSpec
|
||||
Test.EventTriggersRunSQLSpec
|
||||
Test.HelloWorldSpec
|
||||
|
@ -17,7 +17,7 @@ import Data.Aeson qualified as J
|
||||
import Data.Data (Proxy (..))
|
||||
import Data.OpenApi (OpenApi)
|
||||
import Data.Text (Text)
|
||||
import Hasura.Backends.DataConnector.API.V0.API as V0
|
||||
import Hasura.Backends.DataConnector.API.V0 as V0
|
||||
import Servant.API
|
||||
import Servant.API.Generic
|
||||
import Servant.Client (Client, ClientM, client)
|
||||
@ -41,7 +41,7 @@ type QueryApi =
|
||||
"query"
|
||||
:> SourceNameHeader
|
||||
:> ConfigHeader
|
||||
:> ReqBody '[JSON] V0.Query
|
||||
:> ReqBody '[JSON] V0.QueryRequest
|
||||
:> Post '[JSON] V0.QueryResponse
|
||||
|
||||
type ConfigHeader = Header' '[Required, Strict] "X-Hasura-DataConnector-Config" V0.Config
|
||||
|
@ -1,11 +1,11 @@
|
||||
--
|
||||
module Hasura.Backends.DataConnector.API.V0.API
|
||||
module Hasura.Backends.DataConnector.API.V0
|
||||
( module Capabilities,
|
||||
module Column,
|
||||
module ConfigSchema,
|
||||
module Expression,
|
||||
module OrderBy,
|
||||
module Query,
|
||||
module Relationships,
|
||||
module Scalar.Type,
|
||||
module Scalar.Value,
|
||||
module Schema,
|
||||
@ -19,6 +19,7 @@ import Hasura.Backends.DataConnector.API.V0.ConfigSchema as ConfigSchema
|
||||
import Hasura.Backends.DataConnector.API.V0.Expression as Expression
|
||||
import Hasura.Backends.DataConnector.API.V0.OrderBy as OrderBy
|
||||
import Hasura.Backends.DataConnector.API.V0.Query as Query
|
||||
import Hasura.Backends.DataConnector.API.V0.Relationships as Relationships
|
||||
import Hasura.Backends.DataConnector.API.V0.Scalar.Type as Scalar.Type
|
||||
import Hasura.Backends.DataConnector.API.V0.Scalar.Value as Scalar.Value
|
||||
import Hasura.Backends.DataConnector.API.V0.Schema as Schema
|
@ -8,6 +8,7 @@ module Hasura.Backends.DataConnector.API.V0.Expression
|
||||
BinaryComparisonOperator (..),
|
||||
BinaryArrayComparisonOperator (..),
|
||||
UnaryComparisonOperator (..),
|
||||
ComparisonColumn (..),
|
||||
ComparisonValue (..),
|
||||
)
|
||||
where
|
||||
@ -22,6 +23,7 @@ import Data.Hashable (Hashable)
|
||||
import Data.OpenApi (ToSchema)
|
||||
import GHC.Generics (Generic)
|
||||
import Hasura.Backends.DataConnector.API.V0.Column qualified as API.V0
|
||||
import Hasura.Backends.DataConnector.API.V0.Relationships qualified as API.V0
|
||||
import Hasura.Backends.DataConnector.API.V0.Scalar.Value qualified as API.V0.Scalar
|
||||
import Prelude
|
||||
|
||||
@ -82,17 +84,34 @@ data Expression
|
||||
= And (ValueWrapper "expressions" [Expression])
|
||||
| Or (ValueWrapper "expressions" [Expression])
|
||||
| Not (ValueWrapper "expression" Expression)
|
||||
| ApplyBinaryComparisonOperator (ValueWrapper3 "operator" BinaryComparisonOperator "column" API.V0.ColumnName "value" ComparisonValue)
|
||||
| ApplyBinaryArrayComparisonOperator (ValueWrapper3 "operator" BinaryArrayComparisonOperator "column" API.V0.ColumnName "values" [ComparisonValue])
|
||||
| ApplyUnaryComparisonOperator (ValueWrapper2 "operator" UnaryComparisonOperator "column" API.V0.ColumnName)
|
||||
| ApplyBinaryComparisonOperator (ValueWrapper3 "operator" BinaryComparisonOperator "column" ComparisonColumn "value" ComparisonValue)
|
||||
| ApplyBinaryArrayComparisonOperator (ValueWrapper3 "operator" BinaryArrayComparisonOperator "column" ComparisonColumn "values" [API.V0.Scalar.Value])
|
||||
| ApplyUnaryComparisonOperator (ValueWrapper2 "operator" UnaryComparisonOperator "column" ComparisonColumn)
|
||||
deriving stock (Data, Eq, Generic, Ord, Show)
|
||||
deriving anyclass (Hashable, NFData)
|
||||
|
||||
-- | Specifies a particular column to use in a comparison via its path and name
|
||||
data ComparisonColumn = ComparisonColumn
|
||||
{ -- | The path of relationships from the current query table to the table that contains the column
|
||||
_ccPath :: [API.V0.RelationshipName],
|
||||
-- | The name of the column
|
||||
_ccName :: API.V0.ColumnName
|
||||
}
|
||||
deriving stock (Eq, Ord, Show, Generic, Data)
|
||||
deriving (FromJSON, ToJSON, ToSchema) via Autodocodec ComparisonColumn
|
||||
deriving anyclass (Hashable, NFData)
|
||||
|
||||
instance HasCodec ComparisonColumn where
|
||||
codec =
|
||||
object "ComparisonColumn" $
|
||||
ComparisonColumn
|
||||
<$> requiredField "path" "The relationship path from the current query table to the table that contains the specified column. Empty array means the current query table." .= _ccPath
|
||||
<*> requiredField "name" "The name of the column" .= _ccName
|
||||
|
||||
-- | A serializable representation of comparison values used in comparisons inside 'Expression's.
|
||||
data ComparisonValue
|
||||
= -- | Allows a comparison to a column on the current table
|
||||
-- TODO: joins in Expressions and then comparisons to other tables involved in those joins
|
||||
AnotherColumn (ValueWrapper "column" API.V0.ColumnName)
|
||||
= -- | Allows a comparison to a column on the current table or another table
|
||||
AnotherColumn (ValueWrapper "column" ComparisonColumn)
|
||||
| ScalarValue (ValueWrapper "value" API.V0.Scalar.Value)
|
||||
deriving stock (Data, Eq, Generic, Ord, Show)
|
||||
deriving anyclass (Hashable, NFData)
|
||||
|
@ -3,115 +3,95 @@
|
||||
{-# LANGUAGE StandaloneDeriving #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
|
||||
--
|
||||
module Hasura.Backends.DataConnector.API.V0.Query
|
||||
( Query (..),
|
||||
( QueryRequest (..),
|
||||
qrTable,
|
||||
qrTableRelationships,
|
||||
qrQuery,
|
||||
Query (..),
|
||||
qFields,
|
||||
qLimit,
|
||||
qOffset,
|
||||
qWhere,
|
||||
qOrderBy,
|
||||
Field (..),
|
||||
RelField (..),
|
||||
RelType (..),
|
||||
ForeignKey (..),
|
||||
PrimaryKey (..),
|
||||
RelationshipField (..),
|
||||
QueryResponse (..),
|
||||
)
|
||||
where
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
import Autodocodec.Extended
|
||||
import Autodocodec.OpenAPI ()
|
||||
import Control.DeepSeq (NFData)
|
||||
import Control.Lens.TH (makePrisms)
|
||||
import Data.Aeson (FromJSON, FromJSONKey, Object, ToJSON, ToJSONKey)
|
||||
import Control.Lens.TH (makeLenses, makePrisms)
|
||||
import Data.Aeson (FromJSON, Object, ToJSON)
|
||||
import Data.Aeson.KeyMap qualified as KM
|
||||
import Data.Data (Data)
|
||||
import Data.HashMap.Strict qualified as M
|
||||
import Data.Hashable (Hashable)
|
||||
import Data.List.NonEmpty (NonEmpty)
|
||||
import Data.OpenApi (ToSchema)
|
||||
import GHC.Generics (Generic)
|
||||
import Hasura.Backends.DataConnector.API.V0.Column qualified as API.V0
|
||||
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 Prelude
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- | A serializable request to retrieve strutured data from some
|
||||
-- source.
|
||||
data QueryRequest = QueryRequest
|
||||
{ _qrTable :: API.V0.TableName,
|
||||
_qrTableRelationships :: [API.V0.TableRelationships],
|
||||
_qrQuery :: Query
|
||||
}
|
||||
deriving stock (Eq, Ord, Show, Generic, Data)
|
||||
deriving (FromJSON, ToJSON, ToSchema) via Autodocodec QueryRequest
|
||||
|
||||
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
|
||||
|
||||
-- | The details of a query against a table
|
||||
data Query = Query
|
||||
{ fields :: KM.KeyMap Field,
|
||||
from :: API.V0.TableName,
|
||||
limit :: Maybe Int,
|
||||
offset :: Maybe Int,
|
||||
where_ :: Maybe API.V0.Expression,
|
||||
orderBy :: Maybe (NonEmpty API.V0.OrderBy)
|
||||
{ _qFields :: KM.KeyMap Field,
|
||||
_qLimit :: Maybe Int,
|
||||
_qOffset :: Maybe Int,
|
||||
_qWhere :: Maybe API.V0.Expression,
|
||||
_qOrderBy :: Maybe (NonEmpty API.V0.OrderBy)
|
||||
}
|
||||
deriving stock (Eq, Ord, Show, Generic, Data)
|
||||
deriving (FromJSON, ToJSON, ToSchema) via Autodocodec Query
|
||||
|
||||
instance HasCodec Query where
|
||||
codec =
|
||||
-- named "query" $
|
||||
object "Query" $
|
||||
named "Query" . object "Query" $
|
||||
Query
|
||||
<$> requiredField "fields" "Fields of the query" .= fields
|
||||
<*> requiredField "from" "Source table" .= from
|
||||
<*> optionalFieldOrNull "limit" "Optionally limit to N results" .= limit
|
||||
<*> optionalFieldOrNull "offset" "Optionally offset from the Nth result" .= offset
|
||||
<*> optionalFieldOrNull "where" "Optionally constrain the results to satisfy some predicate" .= where_
|
||||
<*> optionalFieldOrNull "order_by" "Optionally order the results by the value of one or more fields" .= orderBy
|
||||
<$> requiredField "fields" "Fields of the query" .= _qFields
|
||||
<*> 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
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
data RelType = ObjectRelationship | ArrayRelationship
|
||||
deriving stock (Eq, Ord, Show, Generic, Data)
|
||||
|
||||
instance HasCodec RelType where
|
||||
codec =
|
||||
named "RelType" $
|
||||
disjointStringConstCodec [(ObjectRelationship, "object"), (ArrayRelationship, "array")]
|
||||
|
||||
data RelField = RelField
|
||||
{ columnMapping :: M.HashMap PrimaryKey ForeignKey,
|
||||
relationType :: RelType,
|
||||
query :: Query
|
||||
data RelationshipField = RelationshipField
|
||||
{ _rfRelationship :: API.V0.RelationshipName,
|
||||
_rfQuery :: Query
|
||||
}
|
||||
deriving stock (Eq, Ord, Show, Generic, Data)
|
||||
|
||||
instance HasObjectCodec RelField where
|
||||
instance HasObjectCodec RelationshipField where
|
||||
objectCodec =
|
||||
RelField
|
||||
<$> requiredField "column_mapping" "Mapping from local fields to remote fields" .= columnMapping
|
||||
<*> requiredField "relation_type" "Relation Type" .= relationType
|
||||
<*> requiredField "query" "Relationship query" .= query
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
newtype PrimaryKey = PrimaryKey {unPrimaryKey :: API.V0.ColumnName}
|
||||
deriving stock (Data, Generic)
|
||||
deriving newtype (Eq, Hashable, Ord, Show, ToJSONKey, FromJSONKey)
|
||||
deriving (FromJSON, ToJSON, ToSchema) via Autodocodec PrimaryKey
|
||||
|
||||
instance HasCodec PrimaryKey where
|
||||
codec = dimapCodec PrimaryKey unPrimaryKey codec
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
newtype ForeignKey = ForeignKey {unForeignKey :: API.V0.ColumnName}
|
||||
deriving stock (Data, Generic)
|
||||
deriving newtype (Eq, Hashable, Ord, Show)
|
||||
deriving (FromJSON, ToJSON, ToSchema) via Autodocodec ForeignKey
|
||||
|
||||
instance HasCodec ForeignKey where
|
||||
codec = dimapCodec ForeignKey unForeignKey codec
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
RelationshipField
|
||||
<$> requiredField "relationship" "The name of the relationship to follow for the subquery" .= _rfRelationship
|
||||
<*> requiredField "query" "Relationship query" .= _rfQuery
|
||||
|
||||
-- | A serializable field targeted by a 'Query'.
|
||||
data Field
|
||||
= ColumnField (ValueWrapper "column" API.V0.ColumnName)
|
||||
| RelationshipField RelField
|
||||
| RelField RelationshipField
|
||||
deriving stock (Eq, Ord, Show, Generic, Data)
|
||||
|
||||
$(makePrisms ''Field)
|
||||
@ -121,7 +101,7 @@ instance HasCodec Field where
|
||||
named "Field" $
|
||||
sumTypeCodec
|
||||
[ TypeAlternative "ColumnField" "column" _ColumnField,
|
||||
TypeAlternative "RelationshipField" "relationship" _RelationshipField
|
||||
TypeAlternative "RelationshipField" "relationship" _RelField
|
||||
]
|
||||
|
||||
deriving via Autodocodec Field instance FromJSON Field
|
||||
@ -130,11 +110,11 @@ deriving via Autodocodec Field instance ToJSON Field
|
||||
|
||||
deriving via Autodocodec Field instance ToSchema Field
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Query Response
|
||||
|
||||
-- | The resolved query response provided by the 'POST /query'
|
||||
-- endpoint encoded as 'J.Value'.
|
||||
-- endpoint encoded as a list of JSON objects.
|
||||
newtype QueryResponse = QueryResponse {getQueryResponse :: [Object]}
|
||||
deriving newtype (Eq, Ord, Show, NFData)
|
||||
deriving (ToJSON, FromJSON, ToSchema) via Autodocodec [Object]
|
||||
|
||||
$(makeLenses ''QueryRequest)
|
||||
$(makeLenses ''Query)
|
||||
|
@ -0,0 +1,76 @@
|
||||
{-# LANGUAGE OverloadedLists #-}
|
||||
|
||||
module Hasura.Backends.DataConnector.API.V0.Relationships
|
||||
( TableRelationships (..),
|
||||
Relationship (..),
|
||||
RelationshipName (..),
|
||||
RelationshipType (..),
|
||||
SourceColumnName,
|
||||
TargetColumnName,
|
||||
)
|
||||
where
|
||||
|
||||
import Autodocodec.Extended
|
||||
import Autodocodec.OpenAPI ()
|
||||
import Control.DeepSeq (NFData)
|
||||
import Data.Aeson (FromJSON, FromJSONKey, ToJSON, ToJSONKey)
|
||||
import Data.Data (Data)
|
||||
import Data.HashMap.Strict qualified as M
|
||||
import Data.Hashable (Hashable)
|
||||
import Data.OpenApi (ToSchema)
|
||||
import Data.Text (Text)
|
||||
import GHC.Generics (Generic)
|
||||
import Hasura.Backends.DataConnector.API.V0.Column qualified as API.V0
|
||||
import Hasura.Backends.DataConnector.API.V0.Table qualified as API.V0
|
||||
import Prelude
|
||||
|
||||
data TableRelationships = TableRelationships
|
||||
{ _trSourceTable :: API.V0.TableName,
|
||||
_trRelationships :: M.HashMap RelationshipName Relationship
|
||||
}
|
||||
deriving stock (Eq, Ord, Show, Generic, Data)
|
||||
deriving (FromJSON, ToJSON, ToSchema) via Autodocodec TableRelationships
|
||||
|
||||
instance HasCodec TableRelationships where
|
||||
codec =
|
||||
object "TableRelationships" $
|
||||
TableRelationships
|
||||
<$> requiredField "source_table" "The name of the source table in the relationship" .= _trSourceTable
|
||||
<*> requiredField "relationships" "A map of relationships from the source table to target tables. The key of the map is the relationship name" .= _trRelationships
|
||||
|
||||
data Relationship = Relationship
|
||||
{ _rTargetTable :: API.V0.TableName,
|
||||
_rRelationshipType :: RelationshipType,
|
||||
_rColumnMapping :: M.HashMap SourceColumnName TargetColumnName
|
||||
}
|
||||
deriving stock (Eq, Ord, Show, Generic, Data)
|
||||
deriving (FromJSON, ToJSON, ToSchema) via Autodocodec Relationship
|
||||
|
||||
instance HasCodec Relationship where
|
||||
codec =
|
||||
object "Relationship" $
|
||||
Relationship
|
||||
<$> requiredField "target_table" "The name of the target table in the relationship" .= _rTargetTable
|
||||
<*> requiredField "relationship_type" "The type of the relationship" .= _rRelationshipType
|
||||
<*> requiredField "column_mapping" "A mapping between columns on the source table to columns on the target table" .= _rColumnMapping
|
||||
|
||||
newtype RelationshipName = RelationshipName {unRelationshipName :: Text}
|
||||
deriving stock (Data, Generic)
|
||||
deriving newtype (Eq, Hashable, Ord, Show, ToJSONKey, FromJSONKey, NFData)
|
||||
deriving (FromJSON, ToJSON, ToSchema) via Autodocodec Text
|
||||
|
||||
instance HasCodec RelationshipName where
|
||||
codec = dimapCodec RelationshipName unRelationshipName codec
|
||||
|
||||
data RelationshipType = ObjectRelationship | ArrayRelationship
|
||||
deriving stock (Eq, Ord, Show, Generic, Data, Enum, Bounded)
|
||||
deriving (FromJSON, ToJSON, ToSchema) via Autodocodec RelationshipType
|
||||
|
||||
instance HasCodec RelationshipType where
|
||||
codec =
|
||||
named "RelationshipType" $
|
||||
disjointStringConstCodec [(ObjectRelationship, "object"), (ArrayRelationship, "array")]
|
||||
|
||||
type SourceColumnName = API.V0.ColumnName
|
||||
|
||||
type TargetColumnName = API.V0.ColumnName
|
@ -31,29 +31,29 @@ import Hasura.Tracing qualified as Tracing
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
instance BackendExecute 'DataConnector where
|
||||
type PreparedQuery 'DataConnector = IR.Q.Query
|
||||
type PreparedQuery 'DataConnector = IR.Q.QueryRequest
|
||||
type MultiplexedQuery 'DataConnector = Void
|
||||
type ExecutionMonad 'DataConnector = Tracing.TraceT (ExceptT QErr IO)
|
||||
|
||||
mkDBQueryPlan UserInfo {..} sourceName sourceConfig ir = do
|
||||
query' <- DC.mkPlan _uiSession sourceConfig ir
|
||||
queryRequest <- DC.mkPlan _uiSession sourceConfig ir
|
||||
pure
|
||||
DBStepInfo
|
||||
{ dbsiSourceName = sourceName,
|
||||
dbsiSourceConfig = sourceConfig,
|
||||
dbsiPreparedQuery = Just query',
|
||||
dbsiAction = buildAction sourceName sourceConfig query'
|
||||
dbsiPreparedQuery = Just queryRequest,
|
||||
dbsiAction = buildAction sourceName sourceConfig queryRequest
|
||||
}
|
||||
|
||||
mkDBQueryExplain fieldName UserInfo {..} sourceName sourceConfig ir = do
|
||||
query' <- DC.mkPlan _uiSession sourceConfig ir
|
||||
queryRequest <- DC.mkPlan _uiSession sourceConfig ir
|
||||
pure $
|
||||
mkAnyBackend @'DataConnector
|
||||
DBStepInfo
|
||||
{ dbsiSourceName = sourceName,
|
||||
dbsiSourceConfig = sourceConfig,
|
||||
dbsiPreparedQuery = Just query',
|
||||
dbsiAction = pure . encJFromJValue . toExplainPlan fieldName $ query'
|
||||
dbsiPreparedQuery = Just queryRequest,
|
||||
dbsiAction = pure . encJFromJValue . toExplainPlan fieldName $ queryRequest
|
||||
}
|
||||
mkDBMutationPlan _ _ _ _ _ =
|
||||
throw400 NotSupported "mkDBMutationPlan: not implemented for the Data Connector backend."
|
||||
@ -66,19 +66,19 @@ instance BackendExecute 'DataConnector where
|
||||
mkSubscriptionExplain _ =
|
||||
throw400 NotSupported "mkSubscriptionExplain: not implemented for the Data Connector backend."
|
||||
|
||||
toExplainPlan :: GQL.RootFieldAlias -> IR.Q.Query -> ExplainPlan
|
||||
toExplainPlan fieldName query' =
|
||||
ExplainPlan fieldName (Just "") (Just [TE.decodeUtf8 $ BL.toStrict $ J.encode $ query'])
|
||||
toExplainPlan :: GQL.RootFieldAlias -> IR.Q.QueryRequest -> ExplainPlan
|
||||
toExplainPlan fieldName queryRequest =
|
||||
ExplainPlan fieldName (Just "") (Just [TE.decodeUtf8 $ BL.toStrict $ J.encode $ queryRequest])
|
||||
|
||||
buildAction :: RQL.SourceName -> DC.SourceConfig -> IR.Q.Query -> Tracing.TraceT (ExceptT QErr IO) EncJSON
|
||||
buildAction sourceName DC.SourceConfig {..} query = do
|
||||
buildAction :: RQL.SourceName -> DC.SourceConfig -> IR.Q.QueryRequest -> Tracing.TraceT (ExceptT QErr IO) EncJSON
|
||||
buildAction sourceName DC.SourceConfig {..} queryRequest = do
|
||||
-- NOTE: Should this check occur during query construction in 'mkPlan'?
|
||||
when (DC.queryHasRelations query && isNothing (API.cRelationships _scCapabilities)) $
|
||||
when (DC.queryHasRelations queryRequest && isNothing (API.cRelationships _scCapabilities)) $
|
||||
throw400 NotSupported "Agents must provide their own dataloader."
|
||||
API.Routes {..} <- liftIO $ client @(Tracing.TraceT (ExceptT QErr IO)) _scManager _scEndpoint
|
||||
case IR.queryToAPI query of
|
||||
Right queryRequest -> do
|
||||
queryResponse <- _query (toTxt sourceName) _scConfig queryRequest
|
||||
case IR.queryRequestToAPI queryRequest of
|
||||
Right queryRequest' -> do
|
||||
queryResponse <- _query (toTxt sourceName) _scConfig queryRequest'
|
||||
pure $ encJFromJValue queryResponse
|
||||
Left (IR.ExposedLiteral lit) ->
|
||||
throw500 $ "Invalid query constructed: Exposed IR Literal '" <> lit <> "'."
|
||||
|
@ -50,10 +50,10 @@ runDBQuery' ::
|
||||
Logger Hasura ->
|
||||
SourceConfig ->
|
||||
Tracing.TraceT (ExceptT QErr IO) a ->
|
||||
Maybe IR.Q.Query ->
|
||||
Maybe IR.Q.QueryRequest ->
|
||||
m (DiffTime, a)
|
||||
runDBQuery' requestId query fieldName _userInfo logger _sourceConfig action ir = do
|
||||
void $ HGL.logQueryLog logger $ mkQueryLog query fieldName ir requestId
|
||||
runDBQuery' requestId query fieldName _userInfo logger _sourceConfig action queryRequest = do
|
||||
void $ HGL.logQueryLog logger $ mkQueryLog query fieldName queryRequest requestId
|
||||
withElapsedTime
|
||||
. Tracing.trace ("Data Connector backend query for root field " <>> fieldName)
|
||||
. Tracing.interpTraceT (liftEitherM . liftIO . runExceptT)
|
||||
@ -62,7 +62,7 @@ runDBQuery' requestId query fieldName _userInfo logger _sourceConfig action ir =
|
||||
mkQueryLog ::
|
||||
GQLReqUnparsed ->
|
||||
RootFieldAlias ->
|
||||
Maybe IR.Q.Query ->
|
||||
Maybe IR.Q.QueryRequest ->
|
||||
RequestId ->
|
||||
HGL.QueryLog
|
||||
mkQueryLog gqlQuery fieldName maybeQuery requestId =
|
||||
|
@ -2,15 +2,14 @@
|
||||
|
||||
module Hasura.Backends.DataConnector.IR.Export
|
||||
( QueryError (..),
|
||||
queryToAPI,
|
||||
queryRequestToAPI,
|
||||
)
|
||||
where
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
import Autodocodec.Extended (ValueWrapper (..))
|
||||
import Data.Aeson (ToJSON)
|
||||
import Data.Aeson.KeyMap (fromHashMapText)
|
||||
import Data.HashMap.Strict qualified as M
|
||||
import Data.HashMap.Strict qualified as HashMap
|
||||
import Hasura.Backends.DataConnector.API qualified as API
|
||||
import Hasura.Backends.DataConnector.IR.Query qualified as IR.Q
|
||||
import Hasura.Prelude
|
||||
@ -22,26 +21,46 @@ data QueryError = ExposedLiteral Text
|
||||
deriving stock (Generic)
|
||||
deriving anyclass (ToJSON)
|
||||
|
||||
queryToAPI :: IR.Q.Query -> Either QueryError API.Query
|
||||
queryToAPI IR.Q.Query {..} = do
|
||||
fields' <- traverse fromField fields
|
||||
queryRequestToAPI :: IR.Q.QueryRequest -> Either QueryError API.QueryRequest
|
||||
queryRequestToAPI IR.Q.QueryRequest {..} = do
|
||||
query <- queryToAPI _qrQuery
|
||||
pure $
|
||||
API.Query
|
||||
{ fields = fromHashMapText fields',
|
||||
from = Witch.from from,
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
where_ = fmap Witch.from where_,
|
||||
orderBy = nonEmpty $ fmap Witch.from orderBy
|
||||
API.QueryRequest
|
||||
{ _qrTable = Witch.from _qrTable,
|
||||
_qrTableRelationships =
|
||||
( \(sourceTableName, relationships) ->
|
||||
API.TableRelationships
|
||||
{ _trSourceTable = Witch.from sourceTableName,
|
||||
_trRelationships = HashMap.mapKeys Witch.from $ Witch.from <$> relationships
|
||||
}
|
||||
)
|
||||
<$> HashMap.toList _qrTableRelationships,
|
||||
_qrQuery = query
|
||||
}
|
||||
|
||||
fromField :: IR.Q.Field -> Either QueryError API.Field
|
||||
fromField = \case
|
||||
IR.Q.Column contents -> Right $ Witch.from contents
|
||||
IR.Q.Relationship contents -> rcToAPI contents
|
||||
IR.Q.Literal lit -> Left $ ExposedLiteral lit
|
||||
queryToAPI :: IR.Q.Query -> Either QueryError API.Query
|
||||
queryToAPI IR.Q.Query {..} = do
|
||||
fields' <- traverse fieldToAPI _qFields
|
||||
pure $
|
||||
API.Query
|
||||
{ _qFields = fromHashMapText fields',
|
||||
_qLimit = _qLimit,
|
||||
_qOffset = _qOffset,
|
||||
_qWhere = fmap Witch.from _qWhere,
|
||||
_qOrderBy = nonEmpty $ fmap Witch.from _qOrderBy
|
||||
}
|
||||
|
||||
rcToAPI :: IR.Q.RelationshipContents -> Either QueryError API.Field
|
||||
rcToAPI (IR.Q.RelationshipContents joinCondition relType query) =
|
||||
let joinCondition' = M.mapKeys Witch.from $ fmap Witch.from joinCondition
|
||||
in fmap (API.RelationshipField . API.RelField joinCondition' (Witch.from relType)) $ queryToAPI query
|
||||
fieldToAPI :: IR.Q.Field -> Either QueryError API.Field
|
||||
fieldToAPI = \case
|
||||
IR.Q.ColumnField contents -> Right . API.ColumnField . ValueWrapper $ Witch.from contents
|
||||
IR.Q.RelField contents -> API.RelField <$> rcToAPI contents
|
||||
IR.Q.LiteralField lit -> Left $ ExposedLiteral lit
|
||||
|
||||
rcToAPI :: IR.Q.RelationshipField -> Either QueryError API.RelationshipField
|
||||
rcToAPI IR.Q.RelationshipField {..} = do
|
||||
query <- queryToAPI _rfQuery
|
||||
pure $
|
||||
API.RelationshipField
|
||||
{ _rfRelationship = Witch.from _rfRelationship,
|
||||
_rfQuery = query
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ module Hasura.Backends.DataConnector.IR.Expression
|
||||
BinaryComparisonOperator (..),
|
||||
BinaryArrayComparisonOperator (..),
|
||||
UnaryComparisonOperator (..),
|
||||
ComparisonColumn (..),
|
||||
ComparisonValue (..),
|
||||
)
|
||||
where
|
||||
@ -15,6 +16,7 @@ import Autodocodec.Extended (ValueWrapper (..), ValueWrapper2 (..), ValueWrapper
|
||||
import Data.Aeson (FromJSON, ToJSON)
|
||||
import Hasura.Backends.DataConnector.API qualified as API
|
||||
import Hasura.Backends.DataConnector.IR.Column qualified as IR.C
|
||||
import Hasura.Backends.DataConnector.IR.Relationships qualified as IR.R
|
||||
import Hasura.Backends.DataConnector.IR.Scalar.Value qualified as IR.S
|
||||
import Hasura.Incremental (Cacheable)
|
||||
import Hasura.Prelude
|
||||
@ -51,16 +53,16 @@ data Expression
|
||||
| -- | Apply a 'BinaryComparisonOperator' that compares a column to a 'ComparisonValue';
|
||||
-- the result of this application will return "true" or "false" depending on the
|
||||
-- 'BinaryComparisonOperator' that's being applied.
|
||||
ApplyBinaryComparisonOperator BinaryComparisonOperator IR.C.Name ComparisonValue
|
||||
ApplyBinaryComparisonOperator BinaryComparisonOperator ComparisonColumn ComparisonValue
|
||||
| -- | Apply a 'BinaryArrayComparisonOperator' that evaluates a column with the
|
||||
-- 'BinaryArrayComparisonOperator' against an array of 'ComparisonValue's.
|
||||
-- The result of this application will return "true" or "false" depending
|
||||
-- on the 'BinaryArrayComparisonOperator' that's being applied.
|
||||
ApplyBinaryArrayComparisonOperator BinaryArrayComparisonOperator IR.C.Name [ComparisonValue]
|
||||
ApplyBinaryArrayComparisonOperator BinaryArrayComparisonOperator ComparisonColumn [IR.S.Value]
|
||||
| -- | Apply a 'UnaryComparisonOperator' that evaluates a column with the
|
||||
-- 'UnaryComparisonOperator'; the result of this application will return "true" or
|
||||
-- "false" depending on the 'UnaryComparisonOperator' that's being applied.
|
||||
ApplyUnaryComparisonOperator UnaryComparisonOperator IR.C.Name
|
||||
ApplyUnaryComparisonOperator UnaryComparisonOperator ComparisonColumn
|
||||
deriving stock (Data, Eq, Generic, Ord, Show)
|
||||
deriving anyclass (Cacheable, FromJSON, Hashable, NFData, ToJSON)
|
||||
|
||||
@ -144,8 +146,29 @@ instance Witch.From API.BinaryArrayComparisonOperator BinaryArrayComparisonOpera
|
||||
instance Witch.From BinaryArrayComparisonOperator API.BinaryArrayComparisonOperator where
|
||||
from In = API.In
|
||||
|
||||
data ComparisonColumn = ComparisonColumn
|
||||
{ _ccPath :: [IR.R.RelationshipName],
|
||||
_ccName :: IR.C.Name
|
||||
}
|
||||
deriving stock (Data, Eq, Generic, Ord, Show)
|
||||
deriving anyclass (Cacheable, FromJSON, Hashable, NFData, ToJSON)
|
||||
|
||||
instance Witch.From ComparisonColumn API.ComparisonColumn where
|
||||
from ComparisonColumn {..} =
|
||||
API.ComparisonColumn
|
||||
{ _ccPath = Witch.from <$> _ccPath,
|
||||
_ccName = Witch.from _ccName
|
||||
}
|
||||
|
||||
instance Witch.From API.ComparisonColumn ComparisonColumn where
|
||||
from API.ComparisonColumn {..} =
|
||||
ComparisonColumn
|
||||
{ _ccPath = Witch.from <$> _ccPath,
|
||||
_ccName = Witch.from _ccName
|
||||
}
|
||||
|
||||
data ComparisonValue
|
||||
= AnotherColumn IR.C.Name
|
||||
= AnotherColumn ComparisonColumn
|
||||
| ScalarValue IR.S.Value
|
||||
deriving stock (Data, Eq, Generic, Ord, Show)
|
||||
deriving anyclass (Cacheable, FromJSON, Hashable, NFData, ToJSON)
|
||||
|
@ -50,6 +50,12 @@ instance Witch.From API.ColumnName (Name 'Column) where
|
||||
instance Witch.From (Name 'Column) API.ColumnName where
|
||||
from (Name n) = API.ColumnName n
|
||||
|
||||
instance Witch.From API.RelationshipName (Name 'Relationship) where
|
||||
from (API.RelationshipName n) = Name n
|
||||
|
||||
instance Witch.From (Name 'Relationship) API.RelationshipName where
|
||||
from (Name n) = API.RelationshipName n
|
||||
|
||||
-- | The "type" of "name" that the 'Name' type is meant to provide a textual
|
||||
-- representation for.
|
||||
--
|
||||
@ -59,3 +65,4 @@ data NameType
|
||||
= Column
|
||||
| Function
|
||||
| Table
|
||||
| Relationship
|
||||
|
@ -1,69 +1,51 @@
|
||||
module Hasura.Backends.DataConnector.IR.Query
|
||||
( Query (..),
|
||||
( QueryRequest (..),
|
||||
Query (..),
|
||||
Field (..),
|
||||
Cardinality (..),
|
||||
ColumnContents (..),
|
||||
RelationshipContents (..),
|
||||
RelType (..),
|
||||
PrimaryKey (..),
|
||||
ForeignKey (..),
|
||||
RelationshipField (..),
|
||||
)
|
||||
where
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
import Autodocodec.Extended (ValueWrapper (ValueWrapper))
|
||||
import Data.Aeson (ToJSON, ToJSONKey)
|
||||
import Data.Aeson (ToJSON)
|
||||
import Data.Aeson qualified as J
|
||||
import Hasura.Backends.DataConnector.API qualified as API
|
||||
import Hasura.Backends.DataConnector.IR.Column qualified as IR.C
|
||||
import Hasura.Backends.DataConnector.IR.Expression qualified as IR.E
|
||||
import Hasura.Backends.DataConnector.IR.OrderBy qualified as IR.O
|
||||
import Hasura.Backends.DataConnector.IR.Relationships qualified as IR.R
|
||||
import Hasura.Backends.DataConnector.IR.Table qualified as IR.T
|
||||
import Hasura.Prelude
|
||||
import Witch qualified
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- | An abstract request to retrieve structured data from some source.
|
||||
--
|
||||
-- NOTE: The 'ToJSON' instance is only intended for logging purposes.
|
||||
data Query = Query
|
||||
{ -- NOTE: We should clarify what the 'Text' key is supposed to indicate.
|
||||
fields :: HashMap Text Field,
|
||||
-- | Reference to the table these fields are in.
|
||||
from :: IR.T.Name,
|
||||
-- | Optionally limit to N results.
|
||||
limit :: Maybe Int,
|
||||
-- | Optionally offset from the Nth result.
|
||||
offset :: Maybe Int,
|
||||
-- | Optionally constrain the results to satisfy some predicate.
|
||||
where_ :: Maybe IR.E.Expression,
|
||||
-- | Optionally order the results by the value of one or more fields.
|
||||
orderBy :: [IR.O.OrderBy],
|
||||
-- | The cardinality of response we expect from the Agent's response.
|
||||
cardinality :: Cardinality
|
||||
data QueryRequest = QueryRequest
|
||||
{ _qrTable :: IR.T.Name,
|
||||
_qrTableRelationships :: IR.R.TableRelationships,
|
||||
_qrQuery :: Query
|
||||
}
|
||||
deriving stock (Data, Eq, Generic, Ord, Show)
|
||||
|
||||
instance ToJSON Query where
|
||||
-- NOTE: The 'ToJSON' instance is only intended for logging purposes.
|
||||
instance ToJSON QueryRequest where
|
||||
toJSON = J.genericToJSON J.defaultOptions
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- | This data structure keeps track of what cardinality of response we should
|
||||
-- send back to the client for a given query, which may be different from what
|
||||
-- we receive from the backend (which is always possibly-many records).
|
||||
data Cardinality
|
||||
= Many
|
||||
| OneOrZero
|
||||
-- | The details of a query against a table
|
||||
data Query = Query
|
||||
{ -- NOTE: We should clarify what the 'Text' key is supposed to indicate.
|
||||
_qFields :: HashMap Text Field,
|
||||
-- | Optionally limit to N results.
|
||||
_qLimit :: Maybe Int,
|
||||
-- | Optionally offset from the Nth result.
|
||||
_qOffset :: Maybe Int,
|
||||
-- | Optionally constrain the results to satisfy some predicate.
|
||||
_qWhere :: Maybe IR.E.Expression,
|
||||
-- | Optionally order the results by the value of one or more fields.
|
||||
_qOrderBy :: [IR.O.OrderBy]
|
||||
}
|
||||
deriving stock (Data, Eq, Generic, Ord, Show)
|
||||
|
||||
instance ToJSON Cardinality where
|
||||
-- NOTE: The 'ToJSON' instance is only intended for logging purposes.
|
||||
instance ToJSON Query where
|
||||
toJSON = J.genericToJSON J.defaultOptions
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- | The specific fields that are targeted by a 'Query'.
|
||||
--
|
||||
-- A field conceptually falls under one of the two following categories:
|
||||
@ -75,30 +57,14 @@ instance ToJSON Cardinality where
|
||||
-- provided by HGE and not the Agent.
|
||||
-- NOTE: The 'ToJSON' instance is only intended for logging purposes.
|
||||
data Field
|
||||
= Column ColumnContents
|
||||
| Relationship RelationshipContents
|
||||
| Literal Text
|
||||
= ColumnField IR.C.Name
|
||||
| RelField RelationshipField
|
||||
| LiteralField Text
|
||||
deriving stock (Data, Eq, Generic, Ord, Show)
|
||||
|
||||
instance ToJSON Field where
|
||||
toJSON = J.genericToJSON J.defaultOptions
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- | TODO
|
||||
--
|
||||
-- NOTE: The 'ToJSON' instance is only intended for logging purposes.
|
||||
newtype ColumnContents = ColumnContents
|
||||
{ column :: IR.C.Name
|
||||
}
|
||||
deriving stock (Data, Eq, Generic, Ord, Show)
|
||||
|
||||
instance ToJSON ColumnContents where
|
||||
toJSON = J.genericToJSON J.defaultOptions
|
||||
|
||||
instance Witch.From ColumnContents API.Field where
|
||||
from (ColumnContents name) = API.ColumnField $ ValueWrapper $ Witch.from name
|
||||
|
||||
-- | A relationship consists of the following components:
|
||||
-- - a sub-query, from the perspective that a relationship field will occur
|
||||
-- within a broader 'Query'
|
||||
@ -110,54 +76,11 @@ instance Witch.From ColumnContents API.Field where
|
||||
-- https://www.postgresql.org/docs/13/queries-table-expressions.html#QUERIES-FROM
|
||||
--
|
||||
-- NOTE: The 'ToJSON' instance is only intended for logging purposes.
|
||||
data RelationshipContents = RelationshipContents
|
||||
{ joinCondition :: HashMap PrimaryKey ForeignKey,
|
||||
relationType :: RelType,
|
||||
query :: Query
|
||||
data RelationshipField = RelationshipField
|
||||
{ _rfRelationship :: IR.R.RelationshipName,
|
||||
_rfQuery :: Query
|
||||
}
|
||||
deriving stock (Eq, Ord, Show, Generic, Data)
|
||||
|
||||
instance ToJSON RelationshipContents where
|
||||
instance ToJSON RelationshipField where
|
||||
toJSON = J.genericToJSON J.defaultOptions
|
||||
|
||||
-- | Relationships can either be Object (One-To-One) or Array (One-To-Many) type.
|
||||
data RelType = ObjectRelationship | ArrayRelationship
|
||||
deriving stock (Eq, Ord, Show, Generic, Data)
|
||||
|
||||
instance ToJSON RelType where
|
||||
toJSON = J.genericToJSON J.defaultOptions
|
||||
|
||||
instance Witch.From RelType API.RelType where
|
||||
from = \case
|
||||
ObjectRelationship -> API.ObjectRelationship
|
||||
ArrayRelationship -> API.ArrayRelationship
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- | TODO
|
||||
--
|
||||
-- NOTE: The 'ToJSON' instance is only intended for logging purposes.
|
||||
newtype PrimaryKey = PrimaryKey IR.C.Name
|
||||
deriving stock (Data, Generic)
|
||||
deriving newtype (Eq, Hashable, Ord, Show, ToJSON, ToJSONKey)
|
||||
|
||||
instance Witch.From API.PrimaryKey PrimaryKey where
|
||||
from (API.PrimaryKey key) = PrimaryKey (Witch.from key)
|
||||
|
||||
instance Witch.From PrimaryKey API.PrimaryKey where
|
||||
from (PrimaryKey key) = API.PrimaryKey (Witch.from key)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- | TODO
|
||||
--
|
||||
-- NOTE: The 'ToJSON' instance is only intended for logging purposes.
|
||||
newtype ForeignKey = ForeignKey IR.C.Name
|
||||
deriving stock (Data, Generic)
|
||||
deriving newtype (Eq, Hashable, Ord, Show, ToJSON, ToJSONKey)
|
||||
|
||||
instance Witch.From API.ForeignKey ForeignKey where
|
||||
from (API.ForeignKey key) = ForeignKey (Witch.from key)
|
||||
|
||||
instance Witch.From ForeignKey API.ForeignKey where
|
||||
from (ForeignKey key) = API.ForeignKey (Witch.from key)
|
||||
|
@ -0,0 +1,77 @@
|
||||
module Hasura.Backends.DataConnector.IR.Relationships
|
||||
( RelationshipName,
|
||||
mkRelationshipName,
|
||||
TableRelationships,
|
||||
Relationship (..),
|
||||
RelationshipType (..),
|
||||
SourceColumnName,
|
||||
TargetColumnName,
|
||||
)
|
||||
where
|
||||
|
||||
import Data.Aeson (ToJSON (..))
|
||||
import Data.Aeson qualified as J
|
||||
import Data.HashMap.Strict qualified as HashMap
|
||||
import Data.Text.Extended (toTxt)
|
||||
import Hasura.Backends.DataConnector.API qualified as API
|
||||
import Hasura.Backends.DataConnector.IR.Column qualified as IR.C
|
||||
import Hasura.Backends.DataConnector.IR.Name qualified as IR.N
|
||||
import Hasura.Backends.DataConnector.IR.Table qualified as IR.T
|
||||
import Hasura.Prelude
|
||||
import Hasura.RQL.Types.Common (RelName (..))
|
||||
import Witch qualified
|
||||
|
||||
type RelationshipName = IR.N.Name 'IR.N.Relationship
|
||||
|
||||
mkRelationshipName :: RelName -> RelationshipName
|
||||
mkRelationshipName relName = IR.N.Name @('IR.N.Relationship) $ toTxt relName
|
||||
|
||||
type SourceTableName = IR.T.Name
|
||||
|
||||
type TableRelationships = HashMap SourceTableName (HashMap RelationshipName Relationship)
|
||||
|
||||
data Relationship = Relationship
|
||||
{ _rTargetTable :: IR.T.Name,
|
||||
_rRelationshipType :: RelationshipType,
|
||||
_rColumnMapping :: HashMap SourceColumnName TargetColumnName
|
||||
}
|
||||
deriving stock (Data, Eq, Generic, Ord, Show)
|
||||
|
||||
instance ToJSON Relationship where
|
||||
toJSON = J.genericToJSON J.defaultOptions
|
||||
|
||||
instance Witch.From Relationship API.Relationship where
|
||||
from Relationship {..} =
|
||||
API.Relationship
|
||||
{ _rTargetTable = Witch.from _rTargetTable,
|
||||
_rRelationshipType = Witch.from _rRelationshipType,
|
||||
_rColumnMapping = HashMap.mapKeys Witch.from $ Witch.from <$> _rColumnMapping
|
||||
}
|
||||
|
||||
instance Witch.From API.Relationship Relationship where
|
||||
from API.Relationship {..} =
|
||||
Relationship
|
||||
{ _rTargetTable = Witch.from _rTargetTable,
|
||||
_rRelationshipType = Witch.from _rRelationshipType,
|
||||
_rColumnMapping = HashMap.mapKeys Witch.from $ Witch.from <$> _rColumnMapping
|
||||
}
|
||||
|
||||
data RelationshipType = ObjectRelationship | ArrayRelationship
|
||||
deriving stock (Eq, Ord, Show, Generic, Data)
|
||||
|
||||
instance ToJSON RelationshipType where
|
||||
toJSON = J.genericToJSON J.defaultOptions
|
||||
|
||||
instance Witch.From RelationshipType API.RelationshipType where
|
||||
from = \case
|
||||
ObjectRelationship -> API.ObjectRelationship
|
||||
ArrayRelationship -> API.ArrayRelationship
|
||||
|
||||
instance Witch.From API.RelationshipType RelationshipType where
|
||||
from = \case
|
||||
API.ObjectRelationship -> ObjectRelationship
|
||||
API.ArrayRelationship -> ArrayRelationship
|
||||
|
||||
type SourceColumnName = IR.C.Name
|
||||
|
||||
type TargetColumnName = IR.C.Name
|
@ -8,12 +8,12 @@ where
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
import Control.Monad.Trans.Writer.CPS qualified as CPS
|
||||
import Data.Aeson qualified as J
|
||||
import Data.ByteString.Lazy qualified as BL
|
||||
import Data.HashMap.Strict qualified as HashMap
|
||||
import Data.List.NonEmpty qualified as NE
|
||||
import Data.Semigroup (Any (..), Min (..))
|
||||
import Data.Text as T
|
||||
import Data.Semigroup (Min (..))
|
||||
import Data.Text.Encoding qualified as TE
|
||||
import Data.Text.Extended ((<>>))
|
||||
import Hasura.Backends.DataConnector.Adapter.Types
|
||||
@ -22,7 +22,9 @@ import Hasura.Backends.DataConnector.IR.Export qualified as IR
|
||||
import Hasura.Backends.DataConnector.IR.Expression qualified as IR.E
|
||||
import Hasura.Backends.DataConnector.IR.OrderBy qualified as IR.O
|
||||
import Hasura.Backends.DataConnector.IR.Query qualified as IR.Q
|
||||
import Hasura.Backends.DataConnector.IR.Relationships qualified as IR.R
|
||||
import Hasura.Backends.DataConnector.IR.Scalar.Value qualified as IR.S
|
||||
import Hasura.Backends.DataConnector.IR.Table qualified as IR.T
|
||||
import Hasura.Base.Error
|
||||
import Hasura.Prelude
|
||||
import Hasura.RQL.IR.BoolExp
|
||||
@ -31,6 +33,7 @@ import Hasura.RQL.IR.Select
|
||||
import Hasura.RQL.IR.Value
|
||||
import Hasura.RQL.Types.Column
|
||||
import Hasura.RQL.Types.Common
|
||||
import Hasura.RQL.Types.Relationships.Local (RelInfo (..))
|
||||
import Hasura.SQL.Backend
|
||||
import Hasura.Session
|
||||
|
||||
@ -43,15 +46,14 @@ data ResponseError
|
||||
| UnexpectedFields
|
||||
| ExpectedObject
|
||||
| ExpectedArray
|
||||
| UnexpectedResponseCardinality
|
||||
deriving (Show, Eq)
|
||||
|
||||
-- | Extract the 'IR.Q' from a 'Plan' and render it as 'Text'.
|
||||
--
|
||||
-- NOTE: This is for logging and debug purposes only.
|
||||
renderQuery :: IR.Q.Query -> Text
|
||||
renderQuery :: IR.Q.QueryRequest -> Text
|
||||
renderQuery =
|
||||
TE.decodeUtf8 . BL.toStrict . J.encode . IR.queryToAPI
|
||||
TE.decodeUtf8 . BL.toStrict . J.encode . IR.queryRequestToAPI
|
||||
|
||||
-- | Map a 'QueryDB 'DataConnector' term into a 'Plan'
|
||||
mkPlan ::
|
||||
@ -60,49 +62,67 @@ mkPlan ::
|
||||
SessionVariables ->
|
||||
SourceConfig ->
|
||||
QueryDB 'DataConnector Void (UnpreparedValue 'DataConnector) ->
|
||||
m IR.Q.Query
|
||||
m IR.Q.QueryRequest
|
||||
mkPlan session (SourceConfig {}) ir = translateQueryDB ir
|
||||
where
|
||||
translateQueryDB ::
|
||||
QueryDB 'DataConnector Void (UnpreparedValue 'DataConnector) ->
|
||||
m IR.Q.Query
|
||||
m IR.Q.QueryRequest
|
||||
translateQueryDB =
|
||||
\case
|
||||
QDBMultipleRows s -> translateAnnSelect IR.Q.Many s
|
||||
QDBSingleRow s -> translateAnnSelect IR.Q.OneOrZero s
|
||||
QDBMultipleRows s -> translateAnnSelectToQueryRequest s
|
||||
QDBSingleRow s -> translateAnnSelectToQueryRequest s
|
||||
QDBAggregation {} -> throw400 NotSupported "QDBAggregation: not supported"
|
||||
|
||||
translateAnnSelect ::
|
||||
IR.Q.Cardinality ->
|
||||
translateAnnSelectToQueryRequest ::
|
||||
AnnSimpleSelectG 'DataConnector Void (UnpreparedValue 'DataConnector) ->
|
||||
m IR.Q.Query
|
||||
translateAnnSelect card selectG = do
|
||||
tableName <- case _asnFrom selectG of
|
||||
m IR.Q.QueryRequest
|
||||
translateAnnSelectToQueryRequest selectG = do
|
||||
tableName <- extractTableName selectG
|
||||
(query, tableRelationships) <- CPS.runWriterT (translateAnnSelect tableName selectG)
|
||||
pure $
|
||||
IR.Q.QueryRequest
|
||||
{ _qrTable = tableName,
|
||||
_qrTableRelationships = tableRelationships,
|
||||
_qrQuery = query
|
||||
}
|
||||
|
||||
extractTableName :: AnnSimpleSelectG 'DataConnector Void v -> m IR.T.Name
|
||||
extractTableName selectG =
|
||||
case _asnFrom selectG of
|
||||
FromTable tn -> pure tn
|
||||
FromIdentifier _ -> throw400 NotSupported "AnnSelectG: FromIdentifier not supported"
|
||||
FromFunction {} -> throw400 NotSupported "AnnSelectG: FromFunction not supported"
|
||||
fields <- translateFields card (_asnFields selectG)
|
||||
|
||||
recordTableRelationship :: IR.T.Name -> IR.R.RelationshipName -> IR.R.Relationship -> CPS.WriterT IR.R.TableRelationships m ()
|
||||
recordTableRelationship sourceTableName relationshipName relationship =
|
||||
CPS.tell $ HashMap.singleton sourceTableName (HashMap.singleton relationshipName relationship)
|
||||
|
||||
translateAnnSelect ::
|
||||
IR.T.Name ->
|
||||
AnnSimpleSelectG 'DataConnector Void (UnpreparedValue 'DataConnector) ->
|
||||
CPS.WriterT IR.R.TableRelationships m IR.Q.Query
|
||||
translateAnnSelect tableName selectG = do
|
||||
fields <- translateFields tableName (_asnFields selectG)
|
||||
let whereClauseWithPermissions =
|
||||
case _saWhere (_asnArgs selectG) of
|
||||
Just expr -> BoolAnd [expr, _tpFilter (_asnPerm selectG)]
|
||||
Nothing -> _tpFilter (_asnPerm selectG)
|
||||
whereClause <- translateBoolExp whereClauseWithPermissions
|
||||
orderBy <- translateOrderBy (_saOrderBy $ _asnArgs selectG)
|
||||
whereClause <- translateBoolExp [] tableName whereClauseWithPermissions
|
||||
orderBy <- lift $ translateOrderBy (_saOrderBy $ _asnArgs selectG)
|
||||
pure
|
||||
IR.Q.Query
|
||||
{ from = tableName,
|
||||
fields = fields,
|
||||
limit =
|
||||
{ _qFields = fields,
|
||||
_qLimit =
|
||||
fmap getMin $
|
||||
foldMap
|
||||
(fmap Min)
|
||||
[ _saLimit (_asnArgs selectG),
|
||||
_tpLimit (_asnPerm selectG)
|
||||
],
|
||||
offset = fmap fromIntegral (_saOffset (_asnArgs selectG)),
|
||||
where_ = Just whereClause,
|
||||
orderBy = orderBy,
|
||||
cardinality = card
|
||||
_qOffset = fmap fromIntegral (_saOffset (_asnArgs selectG)),
|
||||
_qWhere = Just whereClause,
|
||||
_qOrderBy = orderBy
|
||||
}
|
||||
|
||||
translateOrderBy ::
|
||||
@ -127,11 +147,11 @@ mkPlan session (SourceConfig {}) ir = translateQueryDB ir
|
||||
throw400 NotSupported "translateOrderBy: AOCArrayAggregation unsupported in the Data Connector backend"
|
||||
|
||||
translateFields ::
|
||||
IR.Q.Cardinality ->
|
||||
IR.T.Name ->
|
||||
AnnFieldsG 'DataConnector Void (UnpreparedValue 'DataConnector) ->
|
||||
m (HashMap Text IR.Q.Field)
|
||||
translateFields cardinality fields = do
|
||||
translatedFields <- traverse (traverse (translateField cardinality)) fields
|
||||
CPS.WriterT IR.R.TableRelationships m (HashMap Text IR.Q.Field)
|
||||
translateFields sourceTableName fields = do
|
||||
translatedFields <- traverse (traverse (translateField sourceTableName)) fields
|
||||
pure $
|
||||
HashMap.fromList $
|
||||
mapMaybe
|
||||
@ -139,40 +159,60 @@ mkPlan session (SourceConfig {}) ir = translateQueryDB ir
|
||||
[(getFieldNameTxt f, field) | (f, field) <- translatedFields]
|
||||
|
||||
translateField ::
|
||||
IR.Q.Cardinality ->
|
||||
IR.T.Name ->
|
||||
AnnFieldG 'DataConnector Void (UnpreparedValue 'DataConnector) ->
|
||||
m (Maybe IR.Q.Field)
|
||||
translateField cardinality = \case
|
||||
CPS.WriterT IR.R.TableRelationships m (Maybe IR.Q.Field)
|
||||
translateField sourceTableName = \case
|
||||
AFColumn colField ->
|
||||
-- TODO: make sure certain fields in colField are not in use, since we don't
|
||||
-- support them
|
||||
pure $ Just $ IR.Q.Column (IR.Q.ColumnContents $ _acfColumn colField)
|
||||
pure . Just . IR.Q.ColumnField $ _acfColumn colField
|
||||
AFObjectRelation objRel -> do
|
||||
fields <- translateFields cardinality (_aosFields (_aarAnnSelect objRel))
|
||||
whereClause <- translateBoolExp (_aosTableFilter (_aarAnnSelect objRel))
|
||||
pure . Just . IR.Q.Relationship $
|
||||
IR.Q.RelationshipContents
|
||||
(HashMap.mapKeys IR.Q.PrimaryKey . fmap IR.Q.ForeignKey $ _aarColumnMapping objRel)
|
||||
IR.Q.ObjectRelationship
|
||||
let targetTable = _aosTableFrom (_aarAnnSelect objRel)
|
||||
let relationshipName = IR.R.mkRelationshipName $ _aarRelationshipName objRel
|
||||
fields <- translateFields targetTable (_aosFields (_aarAnnSelect objRel))
|
||||
whereClause <- translateBoolExp [] targetTable (_aosTableFilter (_aarAnnSelect objRel))
|
||||
|
||||
recordTableRelationship
|
||||
sourceTableName
|
||||
relationshipName
|
||||
IR.R.Relationship
|
||||
{ _rTargetTable = targetTable,
|
||||
_rRelationshipType = IR.R.ObjectRelationship,
|
||||
_rColumnMapping = _aarColumnMapping objRel
|
||||
}
|
||||
|
||||
pure . Just . IR.Q.RelField $
|
||||
IR.Q.RelationshipField
|
||||
relationshipName
|
||||
( IR.Q.Query
|
||||
{ fields = fields,
|
||||
from = _aosTableFrom (_aarAnnSelect objRel),
|
||||
where_ = Just whereClause,
|
||||
limit = Nothing,
|
||||
offset = Nothing,
|
||||
orderBy = [],
|
||||
cardinality = cardinality
|
||||
{ _qFields = fields,
|
||||
_qWhere = Just whereClause,
|
||||
_qLimit = Nothing,
|
||||
_qOffset = Nothing,
|
||||
_qOrderBy = []
|
||||
}
|
||||
)
|
||||
AFArrayRelation (ASSimple arrRel) -> do
|
||||
query <- translateAnnSelect IR.Q.Many (_aarAnnSelect arrRel)
|
||||
pure . Just . IR.Q.Relationship $
|
||||
IR.Q.RelationshipContents
|
||||
(HashMap.mapKeys IR.Q.PrimaryKey $ fmap IR.Q.ForeignKey $ _aarColumnMapping arrRel)
|
||||
IR.Q.ArrayRelationship
|
||||
targetTable <- lift $ extractTableName (_aarAnnSelect arrRel)
|
||||
query <- translateAnnSelect targetTable (_aarAnnSelect arrRel)
|
||||
let relationshipName = IR.R.mkRelationshipName $ _aarRelationshipName arrRel
|
||||
|
||||
recordTableRelationship
|
||||
sourceTableName
|
||||
relationshipName
|
||||
IR.R.Relationship
|
||||
{ _rTargetTable = targetTable,
|
||||
_rRelationshipType = IR.R.ArrayRelationship,
|
||||
_rColumnMapping = _aarColumnMapping arrRel
|
||||
}
|
||||
|
||||
pure . Just . IR.Q.RelField $
|
||||
IR.Q.RelationshipField
|
||||
relationshipName
|
||||
query
|
||||
AFArrayRelation (ASAggregate _) ->
|
||||
throw400 NotSupported "translateField: AFArrayRelation ASAggregate not supported"
|
||||
lift $ throw400 NotSupported "translateField: AFArrayRelation ASAggregate not supported"
|
||||
AFExpression _literal ->
|
||||
pure Nothing
|
||||
|
||||
@ -188,29 +228,50 @@ mkPlan session (SourceConfig {}) ir = translateQueryDB ir
|
||||
Just s -> pure (IR.S.ValueLiteral (IR.S.String s))
|
||||
|
||||
translateBoolExp ::
|
||||
[IR.R.RelationshipName] ->
|
||||
IR.T.Name ->
|
||||
AnnBoolExp 'DataConnector (UnpreparedValue 'DataConnector) ->
|
||||
m IR.E.Expression
|
||||
translateBoolExp = \case
|
||||
CPS.WriterT IR.R.TableRelationships m IR.E.Expression
|
||||
translateBoolExp columnRelationshipReversePath sourceTableName = \case
|
||||
BoolAnd xs ->
|
||||
IR.E.And <$> traverse (translateBoolExp) xs
|
||||
mkIfZeroOrMany IR.E.And <$> traverse (translateBoolExp columnRelationshipReversePath sourceTableName) xs
|
||||
BoolOr xs ->
|
||||
IR.E.Or <$> traverse (translateBoolExp) xs
|
||||
mkIfZeroOrMany IR.E.Or <$> traverse (translateBoolExp columnRelationshipReversePath sourceTableName) xs
|
||||
BoolNot x ->
|
||||
IR.E.Not <$> (translateBoolExp) x
|
||||
IR.E.Not <$> (translateBoolExp columnRelationshipReversePath sourceTableName) x
|
||||
BoolFld (AVColumn c xs) ->
|
||||
IR.E.And
|
||||
<$> sequence
|
||||
[translateOp (ciColumn c) x | x <- xs]
|
||||
BoolFld (AVRelationship _ _) ->
|
||||
throw400 NotSupported "The BoolFld AVRelationship expression type is not supported by the Data Connector backend"
|
||||
lift $ mkIfZeroOrMany IR.E.And <$> traverse (translateOp columnRelationshipReversePath (ciColumn c)) xs
|
||||
BoolFld (AVRelationship relationshipInfo boolExp) -> do
|
||||
let relationshipName = IR.R.mkRelationshipName $ riName relationshipInfo
|
||||
let targetTable = riRTable relationshipInfo
|
||||
let relationshipType = case riType relationshipInfo of
|
||||
ObjRel -> IR.R.ObjectRelationship
|
||||
ArrRel -> IR.R.ArrayRelationship
|
||||
recordTableRelationship
|
||||
sourceTableName
|
||||
relationshipName
|
||||
IR.R.Relationship
|
||||
{ _rTargetTable = targetTable,
|
||||
_rRelationshipType = relationshipType,
|
||||
_rColumnMapping = riMapping relationshipInfo
|
||||
}
|
||||
translateBoolExp (relationshipName : columnRelationshipReversePath) targetTable boolExp
|
||||
BoolExists _ ->
|
||||
throw400 NotSupported "The BoolExists expression type is not supported by the Data Connector backend"
|
||||
lift $ throw400 NotSupported "The BoolExists expression type is not supported by the Data Connector backend"
|
||||
where
|
||||
-- Makes an 'IR.E.Expression' like 'IR.E.And' if there is zero or many input expressions otherwise
|
||||
-- just returns the singleton expression. This helps remove redundant 'IE.E.And' etcs from the expression.
|
||||
mkIfZeroOrMany :: ([IR.E.Expression] -> IR.E.Expression) -> [IR.E.Expression] -> IR.E.Expression
|
||||
mkIfZeroOrMany mk = \case
|
||||
[singleExp] -> singleExp
|
||||
zeroOrManyExps -> mk zeroOrManyExps
|
||||
|
||||
translateOp ::
|
||||
[IR.R.RelationshipName] ->
|
||||
IR.C.Name ->
|
||||
OpExpG 'DataConnector (UnpreparedValue 'DataConnector) ->
|
||||
m IR.E.Expression
|
||||
translateOp columnName opExp = do
|
||||
translateOp columnRelationshipReversePath columnName opExp = do
|
||||
preparedOpExp <- traverse prepareLiterals $ opExp
|
||||
case preparedOpExp of
|
||||
AEQ _ (IR.S.ValueLiteral value) ->
|
||||
@ -238,9 +299,9 @@ mkPlan session (SourceConfig {}) ir = translateQueryDB ir
|
||||
ALTE (IR.S.ArrayLiteral _array) ->
|
||||
throw400 NotSupported "Array literals not supported for ALTE operator"
|
||||
ANISNULL ->
|
||||
pure $ IR.E.ApplyUnaryComparisonOperator IR.E.IsNull columnName
|
||||
pure $ IR.E.ApplyUnaryComparisonOperator IR.E.IsNull currentComparisonColumn
|
||||
ANISNOTNULL ->
|
||||
pure $ IR.E.Not (IR.E.ApplyUnaryComparisonOperator IR.E.IsNull columnName)
|
||||
pure $ IR.E.Not (IR.E.ApplyUnaryComparisonOperator IR.E.IsNull currentComparisonColumn)
|
||||
AIN literal -> pure $ inOperator literal
|
||||
ANIN literal -> pure . IR.E.Not $ inOperator literal
|
||||
CEQ rootOrCurrentColumn ->
|
||||
@ -262,25 +323,27 @@ mkPlan session (SourceConfig {}) ir = translateQueryDB ir
|
||||
ACast _literal ->
|
||||
throw400 NotSupported "The ACast operator is not supported by the Data Connector backend"
|
||||
where
|
||||
currentComparisonColumn :: IR.E.ComparisonColumn
|
||||
currentComparisonColumn = IR.E.ComparisonColumn (reverse columnRelationshipReversePath) columnName
|
||||
|
||||
mkApplyBinaryComparisonOperatorToAnotherColumn :: IR.E.BinaryComparisonOperator -> RootOrCurrentColumn 'DataConnector -> m IR.E.Expression
|
||||
mkApplyBinaryComparisonOperatorToAnotherColumn operator (RootOrCurrentColumn rootOrCurrent otherColumnName) = do
|
||||
case rootOrCurrent of
|
||||
IsRoot -> throw400 NotSupported "Comparing columns on the root table in a BoolExp is not supported by the Data Connector backend"
|
||||
IsCurrent -> pure $ IR.E.ApplyBinaryComparisonOperator operator columnName (IR.E.AnotherColumn otherColumnName)
|
||||
let columnPath = case rootOrCurrent of
|
||||
IsRoot -> []
|
||||
IsCurrent -> (reverse columnRelationshipReversePath)
|
||||
pure $ IR.E.ApplyBinaryComparisonOperator operator currentComparisonColumn (IR.E.AnotherColumn $ IR.E.ComparisonColumn columnPath otherColumnName)
|
||||
|
||||
inOperator :: IR.S.Literal -> IR.E.Expression
|
||||
inOperator literal =
|
||||
let values = case literal of
|
||||
IR.S.ArrayLiteral array -> IR.E.ScalarValue <$> array
|
||||
IR.S.ValueLiteral value -> [IR.E.ScalarValue value]
|
||||
in IR.E.ApplyBinaryArrayComparisonOperator IR.E.In columnName values
|
||||
IR.S.ArrayLiteral array -> array
|
||||
IR.S.ValueLiteral value -> [value]
|
||||
in IR.E.ApplyBinaryArrayComparisonOperator IR.E.In currentComparisonColumn values
|
||||
|
||||
mkApplyBinaryComparisonOperatorToScalar :: IR.E.BinaryComparisonOperator -> IR.S.Value -> IR.E.Expression
|
||||
mkApplyBinaryComparisonOperatorToScalar operator value =
|
||||
IR.E.ApplyBinaryComparisonOperator operator columnName (IR.E.ScalarValue value)
|
||||
IR.E.ApplyBinaryComparisonOperator operator currentComparisonColumn (IR.E.ScalarValue value)
|
||||
|
||||
-- | Validate if a 'IR.Q' contains any relationships.
|
||||
queryHasRelations :: IR.Q.Query -> Bool
|
||||
queryHasRelations IR.Q.Query {fields} = getAny $ flip foldMap fields \case
|
||||
IR.Q.Relationship _ -> Any True
|
||||
_ -> Any False
|
||||
queryHasRelations :: IR.Q.QueryRequest -> Bool
|
||||
queryHasRelations IR.Q.QueryRequest {..} = _qrTableRelationships /= mempty
|
||||
|
@ -4,7 +4,7 @@
|
||||
module Hasura.Backends.DataConnector.API.V0.ColumnSpec (spec, genColumnName, genColumnInfo) where
|
||||
|
||||
import Data.Aeson.QQ.Simple (aesonQQ)
|
||||
import Hasura.Backends.DataConnector.API.V0.API
|
||||
import Hasura.Backends.DataConnector.API.V0
|
||||
import Hasura.Backends.DataConnector.API.V0.Scalar.TypeSpec (genType)
|
||||
import Hasura.Prelude
|
||||
import Hedgehog
|
||||
|
@ -7,7 +7,7 @@ import Data.Aeson (toJSON)
|
||||
import Data.Aeson.QQ.Simple (aesonQQ)
|
||||
import Data.Data (Proxy (..))
|
||||
import Data.OpenApi (OpenApiItems (..), OpenApiType (..), Reference (..), Referenced (..), Schema (..), toSchema)
|
||||
import Hasura.Backends.DataConnector.API.V0.API
|
||||
import Hasura.Backends.DataConnector.API.V0
|
||||
import Hasura.Prelude
|
||||
import Test.Aeson.Utils (testToFromJSON)
|
||||
import Test.Hspec
|
||||
|
@ -13,8 +13,9 @@ where
|
||||
|
||||
import Autodocodec.Extended
|
||||
import Data.Aeson.QQ.Simple (aesonQQ)
|
||||
import Hasura.Backends.DataConnector.API.V0.API
|
||||
import Hasura.Backends.DataConnector.API.V0
|
||||
import Hasura.Backends.DataConnector.API.V0.ColumnSpec (genColumnName)
|
||||
import Hasura.Backends.DataConnector.API.V0.RelationshipsSpec (genRelationshipName)
|
||||
import Hasura.Backends.DataConnector.API.V0.Scalar.ValueSpec (genValue)
|
||||
import Hasura.Prelude
|
||||
import Hedgehog
|
||||
@ -54,11 +55,18 @@ spec = do
|
||||
testToFromJSONToSchema IsNull [aesonQQ|"is_null"|]
|
||||
jsonOpenApiProperties genUnaryComparisonOperator
|
||||
|
||||
describe "ComparisonColumn" $ do
|
||||
testToFromJSONToSchema
|
||||
(ComparisonColumn [RelationshipName "table1", RelationshipName "table2"] (ColumnName "column_name"))
|
||||
[aesonQQ|{"path": ["table1", "table2"], "name": "column_name"}|]
|
||||
|
||||
jsonOpenApiProperties genComparisonColumn
|
||||
|
||||
describe "ComparisonValue" $ do
|
||||
describe "AnotherColumn" $
|
||||
testToFromJSONToSchema
|
||||
(AnotherColumn $ ValueWrapper (ColumnName "my_column_name"))
|
||||
[aesonQQ|{"type": "column", "column": "my_column_name"}|]
|
||||
(AnotherColumn $ ValueWrapper (ComparisonColumn [] (ColumnName "my_column_name")))
|
||||
[aesonQQ|{"type": "column", "column": {"path": [], "name": "my_column_name"}}|]
|
||||
describe "ScalarValue" $
|
||||
testToFromJSONToSchema
|
||||
(ScalarValue . ValueWrapper $ String "scalar value")
|
||||
@ -67,10 +75,10 @@ spec = do
|
||||
jsonOpenApiProperties genComparisonValue
|
||||
|
||||
describe "Expression" $ do
|
||||
let columnName = ColumnName "my_column_name"
|
||||
let comparisonColumn = ComparisonColumn [] (ColumnName "my_column_name")
|
||||
let scalarValue = ScalarValue . ValueWrapper $ String "scalar value"
|
||||
let scalarValues = [ScalarValue . ValueWrapper $ String "scalar value"]
|
||||
let unaryComparisonExpression = ApplyUnaryComparisonOperator $ ValueWrapper2 IsNull columnName
|
||||
let scalarValues = [String "scalar value"]
|
||||
let unaryComparisonExpression = ApplyUnaryComparisonOperator $ ValueWrapper2 IsNull comparisonColumn
|
||||
|
||||
describe "And" $ do
|
||||
testToFromJSONToSchema
|
||||
@ -82,7 +90,7 @@ spec = do
|
||||
{
|
||||
"type": "unary_op",
|
||||
"operator": "is_null",
|
||||
"column": "my_column_name"
|
||||
"column": { "path": [], "name": "my_column_name" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -98,7 +106,7 @@ spec = do
|
||||
{
|
||||
"type": "unary_op",
|
||||
"operator": "is_null",
|
||||
"column": "my_column_name"
|
||||
"column": { "path": [], "name": "my_column_name" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -113,31 +121,31 @@ spec = do
|
||||
"expression": {
|
||||
"type": "unary_op",
|
||||
"operator": "is_null",
|
||||
"column": "my_column_name"
|
||||
"column": { "path": [], "name": "my_column_name" }
|
||||
}
|
||||
}
|
||||
|]
|
||||
describe "BinaryComparisonOperator" $ do
|
||||
testToFromJSONToSchema
|
||||
(ApplyBinaryComparisonOperator $ ValueWrapper3 Equal columnName scalarValue)
|
||||
(ApplyBinaryComparisonOperator $ ValueWrapper3 Equal comparisonColumn scalarValue)
|
||||
[aesonQQ|
|
||||
{
|
||||
"type": "binary_op",
|
||||
"operator": "equal",
|
||||
"column": "my_column_name",
|
||||
"column": { "path": [], "name": "my_column_name" },
|
||||
"value": {"type": "scalar", "value": "scalar value"}
|
||||
}
|
||||
|]
|
||||
|
||||
describe "BinaryArrayComparisonOperator" $ do
|
||||
testToFromJSONToSchema
|
||||
(ApplyBinaryArrayComparisonOperator $ ValueWrapper3 In columnName scalarValues)
|
||||
(ApplyBinaryArrayComparisonOperator $ ValueWrapper3 In comparisonColumn scalarValues)
|
||||
[aesonQQ|
|
||||
{
|
||||
"type": "binary_arr_op",
|
||||
"operator": "in",
|
||||
"column": "my_column_name",
|
||||
"values": [{"type": "scalar", "value": "scalar value"}]
|
||||
"column": { "path": [], "name": "my_column_name" },
|
||||
"values": ["scalar value"]
|
||||
}
|
||||
|]
|
||||
|
||||
@ -148,7 +156,7 @@ spec = do
|
||||
{
|
||||
"type": "unary_op",
|
||||
"operator": "is_null",
|
||||
"column": "my_column_name"
|
||||
"column": { "path": [], "name": "my_column_name" }
|
||||
}
|
||||
|]
|
||||
|
||||
@ -166,10 +174,16 @@ genBinaryArrayComparisonOperator = Gen.enumBounded
|
||||
genUnaryComparisonOperator :: MonadGen m => m UnaryComparisonOperator
|
||||
genUnaryComparisonOperator = Gen.enumBounded
|
||||
|
||||
genComparisonColumn :: MonadGen m => m ComparisonColumn
|
||||
genComparisonColumn =
|
||||
ComparisonColumn
|
||||
<$> Gen.list (linear 0 5) genRelationshipName
|
||||
<*> genColumnName
|
||||
|
||||
genComparisonValue :: MonadGen m => m ComparisonValue
|
||||
genComparisonValue =
|
||||
Gen.choice
|
||||
[ AnotherColumn <$> genValueWrapper genColumnName,
|
||||
[ AnotherColumn <$> genValueWrapper genComparisonColumn,
|
||||
ScalarValue <$> genValueWrapper genValue
|
||||
]
|
||||
|
||||
@ -179,9 +193,9 @@ genExpression =
|
||||
[ And <$> genValueWrapper genExpressions,
|
||||
Or <$> genValueWrapper genExpressions,
|
||||
Not <$> genValueWrapper genSmallExpression,
|
||||
ApplyBinaryComparisonOperator <$> genValueWrapper3 genBinaryComparisonOperator genColumnName genComparisonValue,
|
||||
ApplyBinaryArrayComparisonOperator <$> genValueWrapper3 genBinaryArrayComparisonOperator genColumnName (Gen.list (linear 0 1) genComparisonValue),
|
||||
ApplyUnaryComparisonOperator <$> genValueWrapper2 genUnaryComparisonOperator genColumnName
|
||||
ApplyBinaryComparisonOperator <$> genValueWrapper3 genBinaryComparisonOperator genComparisonColumn genComparisonValue,
|
||||
ApplyBinaryArrayComparisonOperator <$> genValueWrapper3 genBinaryArrayComparisonOperator genComparisonColumn (Gen.list (linear 0 1) genValue),
|
||||
ApplyUnaryComparisonOperator <$> genValueWrapper2 genUnaryComparisonOperator genComparisonColumn
|
||||
]
|
||||
where
|
||||
genExpressions = Gen.list (linear 0 1) genSmallExpression
|
||||
|
@ -4,7 +4,7 @@
|
||||
module Hasura.Backends.DataConnector.API.V0.OrderBySpec (spec, genOrderBy, genOrderType) where
|
||||
|
||||
import Data.Aeson.QQ.Simple (aesonQQ)
|
||||
import Hasura.Backends.DataConnector.API.V0.API
|
||||
import Hasura.Backends.DataConnector.API.V0
|
||||
import Hasura.Backends.DataConnector.API.V0.ColumnSpec (genColumnName)
|
||||
import Hasura.Prelude
|
||||
import Hedgehog
|
||||
|
@ -6,9 +6,7 @@ module Hasura.Backends.DataConnector.API.V0.QuerySpec (spec) where
|
||||
import Autodocodec.Extended
|
||||
import Data.Aeson.KeyMap qualified as KM
|
||||
import Data.Aeson.QQ.Simple (aesonQQ)
|
||||
import Data.HashMap.Strict qualified as Map
|
||||
import Hasura.Backends.DataConnector.API.V0.API
|
||||
import Hasura.Backends.DataConnector.API.V0.ColumnSpec (genColumnName)
|
||||
import Hasura.Backends.DataConnector.API.V0
|
||||
import Hasura.Prelude
|
||||
import Hedgehog
|
||||
import Hedgehog.Gen qualified as Gen
|
||||
@ -18,11 +16,6 @@ import Test.Hspec
|
||||
|
||||
spec :: Spec
|
||||
spec = do
|
||||
describe "PrimaryKey" $ do
|
||||
testToFromJSONToSchema (PrimaryKey $ ColumnName "my_primary_key") [aesonQQ|"my_primary_key"|]
|
||||
jsonOpenApiProperties genPrimaryKey
|
||||
describe "ForeignKey" $
|
||||
testToFromJSONToSchema (ForeignKey $ ColumnName "my_foreign_key") [aesonQQ|"my_foreign_key"|]
|
||||
describe "Field" $ do
|
||||
describe "ColumnField" $
|
||||
testToFromJSONToSchema
|
||||
@ -33,45 +26,53 @@ spec = do
|
||||
}
|
||||
|]
|
||||
describe "RelationshipField" $ do
|
||||
let fieldMapping = Map.fromList [(PrimaryKey $ ColumnName "id", ForeignKey $ ColumnName "my_foreign_id")]
|
||||
query = Query mempty (TableName "my_table_name") Nothing Nothing Nothing Nothing
|
||||
let query = Query mempty Nothing Nothing Nothing Nothing
|
||||
testToFromJSONToSchema
|
||||
(RelationshipField $ RelField fieldMapping ArrayRelationship query)
|
||||
(RelField $ RelationshipField (RelationshipName "a_relationship") query)
|
||||
[aesonQQ|
|
||||
{ "type": "relationship",
|
||||
"relation_type": "array",
|
||||
"column_mapping": {"id": "my_foreign_id"},
|
||||
"query": {"fields": {}, "from": "my_table_name"}
|
||||
"relationship": "a_relationship",
|
||||
"query": {"fields": {}}
|
||||
}
|
||||
|]
|
||||
describe "Query" $ do
|
||||
let query =
|
||||
Query
|
||||
{ fields = KM.fromList [("my_field_alias", ColumnField $ ValueWrapper $ ColumnName "my_field_name")],
|
||||
from = TableName "my_table_name",
|
||||
limit = Just 10,
|
||||
offset = Just 20,
|
||||
where_ = Just . And $ ValueWrapper [],
|
||||
orderBy = Just [OrderBy (ColumnName "my_column_name") Ascending]
|
||||
{ _qFields = KM.fromList [("my_field_alias", ColumnField $ ValueWrapper $ ColumnName "my_field_name")],
|
||||
_qLimit = Just 10,
|
||||
_qOffset = Just 20,
|
||||
_qWhere = Just . And $ ValueWrapper [],
|
||||
_qOrderBy = Just [OrderBy (ColumnName "my_column_name") Ascending]
|
||||
}
|
||||
testToFromJSONToSchema
|
||||
query
|
||||
[aesonQQ|
|
||||
{ "fields": {"my_field_alias": {"type": "column", "column": "my_field_name"}},
|
||||
"from": "my_table_name",
|
||||
"limit": 10,
|
||||
"offset": 20,
|
||||
"where": {"type": "and", "expressions": []},
|
||||
"order_by": [{"column": "my_column_name", "ordering": "asc"}]
|
||||
}
|
||||
|]
|
||||
describe "QueryRequest" $ do
|
||||
let queryRequest =
|
||||
QueryRequest
|
||||
{ _qrTable = TableName "my_table",
|
||||
_qrTableRelationships = [],
|
||||
_qrQuery = Query mempty Nothing Nothing Nothing Nothing
|
||||
}
|
||||
testToFromJSONToSchema
|
||||
queryRequest
|
||||
[aesonQQ|
|
||||
{ "table": "my_table",
|
||||
"table_relationships": [],
|
||||
"query": { "fields": {} } }
|
||||
|]
|
||||
|
||||
describe "QueryResponse" $ do
|
||||
testToFromJSONToSchema (QueryResponse []) [aesonQQ|[]|]
|
||||
jsonOpenApiProperties genQueryResponse
|
||||
|
||||
genPrimaryKey :: MonadGen m => m PrimaryKey
|
||||
genPrimaryKey = PrimaryKey <$> genColumnName
|
||||
|
||||
genQueryResponse :: MonadGen m => m QueryResponse
|
||||
genQueryResponse =
|
||||
QueryResponse <$> Gen.list (linear 0 5) genObject
|
||||
|
@ -0,0 +1,108 @@
|
||||
{-# LANGUAGE OverloadedLists #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Hasura.Backends.DataConnector.API.V0.RelationshipsSpec
|
||||
( spec,
|
||||
genRelationshipName,
|
||||
)
|
||||
where
|
||||
|
||||
import Data.Aeson.QQ.Simple (aesonQQ)
|
||||
import Data.HashMap.Strict qualified as HashMap
|
||||
import Hasura.Backends.DataConnector.API.V0
|
||||
import Hasura.Backends.DataConnector.API.V0.ColumnSpec (genColumnName)
|
||||
import Hasura.Backends.DataConnector.API.V0.TableSpec (genTableName)
|
||||
import Hasura.Prelude
|
||||
import Hedgehog
|
||||
import Hedgehog.Gen qualified as Gen
|
||||
import Hedgehog.Range
|
||||
import Test.Aeson.Utils
|
||||
import Test.Hspec
|
||||
|
||||
spec :: Spec
|
||||
spec = do
|
||||
describe "RelationshipName" $ do
|
||||
testToFromJSONToSchema (RelationshipName "relationship_name") [aesonQQ|"relationship_name"|]
|
||||
jsonOpenApiProperties genRelationshipName
|
||||
describe "RelationshipType" $ do
|
||||
describe "ObjectRelationship" $
|
||||
testToFromJSONToSchema ObjectRelationship [aesonQQ|"object"|]
|
||||
describe "ArrayRelationship" $
|
||||
testToFromJSONToSchema ArrayRelationship [aesonQQ|"array"|]
|
||||
jsonOpenApiProperties genRelationshipType
|
||||
describe "Relationship" $ do
|
||||
let relationship =
|
||||
Relationship
|
||||
{ _rTargetTable = TableName "target_table_name",
|
||||
_rRelationshipType = ObjectRelationship,
|
||||
_rColumnMapping = [(ColumnName "outer_column", ColumnName "inner_column")]
|
||||
}
|
||||
testToFromJSONToSchema
|
||||
relationship
|
||||
[aesonQQ|
|
||||
{ "target_table": "target_table_name",
|
||||
"relationship_type": "object",
|
||||
"column_mapping": {
|
||||
"outer_column": "inner_column"
|
||||
}
|
||||
}
|
||||
|]
|
||||
jsonOpenApiProperties genRelationship
|
||||
describe "TableRelationships" $ do
|
||||
let relationshipA =
|
||||
Relationship
|
||||
{ _rTargetTable = TableName "target_table_name_a",
|
||||
_rRelationshipType = ObjectRelationship,
|
||||
_rColumnMapping = [(ColumnName "outer_column_a", ColumnName "inner_column_a")]
|
||||
}
|
||||
let relationshipB =
|
||||
Relationship
|
||||
{ _rTargetTable = TableName "target_table_name_b",
|
||||
_rRelationshipType = ArrayRelationship,
|
||||
_rColumnMapping = [(ColumnName "outer_column_b", ColumnName "inner_column_b")]
|
||||
}
|
||||
let tableRelationships =
|
||||
TableRelationships
|
||||
{ _trSourceTable = TableName "source_table_name",
|
||||
_trRelationships =
|
||||
[ (RelationshipName "relationship_a", relationshipA),
|
||||
(RelationshipName "relationship_b", relationshipB)
|
||||
]
|
||||
}
|
||||
testToFromJSONToSchema
|
||||
tableRelationships
|
||||
[aesonQQ|
|
||||
{ "source_table": "source_table_name",
|
||||
"relationships": {
|
||||
"relationship_a": {
|
||||
"target_table": "target_table_name_a",
|
||||
"relationship_type": "object",
|
||||
"column_mapping": {
|
||||
"outer_column_a": "inner_column_a"
|
||||
}
|
||||
},
|
||||
"relationship_b": {
|
||||
"target_table": "target_table_name_b",
|
||||
"relationship_type": "array",
|
||||
"column_mapping": {
|
||||
"outer_column_b": "inner_column_b"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|]
|
||||
jsonOpenApiProperties genRelationship
|
||||
|
||||
genRelationshipName :: MonadGen m => m RelationshipName
|
||||
genRelationshipName =
|
||||
RelationshipName <$> Gen.text (linear 0 10) Gen.unicode
|
||||
|
||||
genRelationshipType :: MonadGen m => m RelationshipType
|
||||
genRelationshipType = Gen.enumBounded
|
||||
|
||||
genRelationship :: MonadGen m => m Relationship
|
||||
genRelationship =
|
||||
Relationship
|
||||
<$> genTableName
|
||||
<*> genRelationshipType
|
||||
<*> (HashMap.fromList <$> Gen.list (linear 0 5) ((,) <$> genColumnName <*> genColumnName))
|
@ -4,7 +4,7 @@
|
||||
module Hasura.Backends.DataConnector.API.V0.TableSpec (spec, genTableName, genTableInfo) where
|
||||
|
||||
import Data.Aeson.QQ.Simple (aesonQQ)
|
||||
import Hasura.Backends.DataConnector.API.V0.API
|
||||
import Hasura.Backends.DataConnector.API.V0
|
||||
import Hasura.Backends.DataConnector.API.V0.ColumnSpec (genColumnInfo)
|
||||
import Hasura.Prelude
|
||||
import Hedgehog
|
||||
|
@ -6,6 +6,7 @@ import Hasura.Backends.DataConnector.API.V0.ConfigSchemaSpec qualified as Config
|
||||
import Hasura.Backends.DataConnector.API.V0.ExpressionSpec qualified as ExpressionSpec
|
||||
import Hasura.Backends.DataConnector.API.V0.OrderBySpec qualified as OrderBySpec
|
||||
import Hasura.Backends.DataConnector.API.V0.QuerySpec qualified as QuerySpec
|
||||
import Hasura.Backends.DataConnector.API.V0.RelationshipsSpec qualified as RelationshipsSpec
|
||||
import Hasura.Backends.DataConnector.API.V0.Scalar.TypeSpec qualified as TypeSpec
|
||||
import Hasura.Backends.DataConnector.API.V0.Scalar.ValueSpec qualified as ValueSpec
|
||||
import Hasura.Backends.DataConnector.API.V0.SchemaSpec qualified as SchemaSpec
|
||||
@ -20,6 +21,7 @@ spec = do
|
||||
describe "Expression" ExpressionSpec.spec
|
||||
describe "OrderBy" OrderBySpec.spec
|
||||
describe "Query" QuerySpec.spec
|
||||
describe "Relationships" RelationshipsSpec.spec
|
||||
describe "Scalar.Type" TypeSpec.spec
|
||||
describe "Scalar.Value" ValueSpec.spec
|
||||
describe "Schema" SchemaSpec.spec
|
||||
|
@ -6,7 +6,11 @@ module Test.Data
|
||||
artistsAsJson,
|
||||
artistsAsJsonById,
|
||||
albumsAsJson,
|
||||
customersAsJson,
|
||||
employeesAsJson,
|
||||
employeesAsJsonById,
|
||||
sortBy,
|
||||
filterColumnsByQueryFields,
|
||||
)
|
||||
where
|
||||
|
||||
@ -28,7 +32,7 @@ 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 (TableInfo (..))
|
||||
import Hasura.Backends.DataConnector.API (Query, TableInfo (..), qFields)
|
||||
import Text.XML qualified as XML
|
||||
import Text.XML.Lens qualified as XML
|
||||
import Prelude
|
||||
@ -78,5 +82,21 @@ artistsAsJsonById =
|
||||
albumsAsJson :: [Object]
|
||||
albumsAsJson = sortBy "AlbumId" $ readTableFromXmlIntoJson "Album"
|
||||
|
||||
customersAsJson :: [Object]
|
||||
customersAsJson = sortBy "CustomerId" $ readTableFromXmlIntoJson "Customer"
|
||||
|
||||
employeesAsJson :: [Object]
|
||||
employeesAsJson = sortBy "EmployeeId" $ readTableFromXmlIntoJson "Employee"
|
||||
|
||||
employeesAsJsonById :: HashMap Scientific Object
|
||||
employeesAsJsonById =
|
||||
HashMap.fromList $ mapMaybe (\employee -> (,employee) <$> employee ^? ix "EmployeeId" . _Number) employeesAsJson
|
||||
|
||||
sortBy :: K.Key -> [Object] -> [Object]
|
||||
sortBy propName = sortOn (^? ix propName)
|
||||
|
||||
filterColumnsByQueryFields :: Query -> Object -> Object
|
||||
filterColumnsByQueryFields query =
|
||||
KM.filterWithKey (\key _value -> key `elem` columns)
|
||||
where
|
||||
columns = KM.keys $ query ^. qFields
|
||||
|
@ -1,7 +1,8 @@
|
||||
module Test.QuerySpec.BasicSpec (spec) where
|
||||
|
||||
import Autodocodec.Extended (ValueWrapper (..), ValueWrapper3 (ValueWrapper3))
|
||||
import Control.Lens (ix, (^?))
|
||||
import Control.Arrow ((>>>))
|
||||
import Control.Lens (ix, (%~), (&), (.~), (^?))
|
||||
import Data.Aeson.KeyMap qualified as KeyMap
|
||||
import Data.Aeson.Lens (AsNumber (_Number), AsPrimitive (_String))
|
||||
import Data.List (sortOn)
|
||||
@ -20,7 +21,7 @@ spec :: Client IO (NamedRoutes Routes) -> SourceName -> Config -> Spec
|
||||
spec api sourceName config = describe "Basic Queries" $ do
|
||||
describe "Column Fields" $ do
|
||||
it "can query for a list of artists" $ do
|
||||
let query = artistsQuery
|
||||
let query = artistsQueryRequest
|
||||
receivedArtists <- fmap (Data.sortBy "ArtistId" . getQueryResponse) $ (api // _query) sourceName config query
|
||||
|
||||
let expectedArtists = Data.artistsAsJson
|
||||
@ -28,7 +29,7 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
|
||||
it "can query for a list of albums with a subset of columns" $ do
|
||||
let fields = KeyMap.fromList [("ArtistId", columnField "ArtistId"), ("Title", columnField "Title")]
|
||||
let query = albumsQuery {fields}
|
||||
let query = albumsQueryRequest & qrQuery . qFields .~ fields
|
||||
receivedAlbums <- (Data.sortBy "Title" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
let filterToRequiredProperties =
|
||||
@ -39,7 +40,7 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
|
||||
it "can project columns into fields with different names" $ do
|
||||
let fields = KeyMap.fromList [("Artist_Id", columnField "ArtistId"), ("Artist_Name", columnField "Name")]
|
||||
let query = artistsQuery {fields}
|
||||
let query = artistsQueryRequest & qrQuery . qFields .~ fields
|
||||
receivedArtists <- (Data.sortBy "ArtistId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
let renameProperties =
|
||||
@ -56,9 +57,9 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
|
||||
describe "Limit & Offset" $ do
|
||||
it "can use limit and offset to paginate results" $ do
|
||||
let allQuery = artistsQuery
|
||||
let page1Query = artistsQuery {limit = Just 10, offset = Just 0}
|
||||
let page2Query = artistsQuery {limit = Just 10, offset = Just 10}
|
||||
let allQuery = artistsQueryRequest
|
||||
let page1Query = artistsQueryRequest & qrQuery %~ (qLimit .~ Just 10 >>> qOffset .~ Just 0)
|
||||
let page2Query = artistsQueryRequest & qrQuery %~ (qLimit .~ Just 10 >>> qOffset .~ Just 10)
|
||||
|
||||
allArtists <- getQueryResponse <$> (api // _query) sourceName config allQuery
|
||||
page1Artists <- getQueryResponse <$> (api // _query) sourceName config page1Query
|
||||
@ -70,7 +71,7 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
describe "Order By" $ do
|
||||
it "can use order by to order results in ascending order" $ do
|
||||
let orderBy = OrderBy (ColumnName "Title") Ascending :| []
|
||||
let query = albumsQuery {orderBy = Just orderBy}
|
||||
let query = albumsQueryRequest & qrQuery . qOrderBy .~ Just orderBy
|
||||
receivedAlbums <- getQueryResponse <$> (api // _query) sourceName config query
|
||||
|
||||
let expectedAlbums = sortOn (^? ix "Title") Data.albumsAsJson
|
||||
@ -78,7 +79,7 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
|
||||
it "can use order by to order results in descending order" $ do
|
||||
let orderBy = OrderBy (ColumnName "Title") Descending :| []
|
||||
let query = albumsQuery {orderBy = Just orderBy}
|
||||
let query = albumsQueryRequest & qrQuery . qOrderBy .~ Just orderBy
|
||||
receivedAlbums <- getQueryResponse <$> (api // _query) sourceName config query
|
||||
|
||||
let expectedAlbums = sortOn (Down . (^? ix "Title")) Data.albumsAsJson
|
||||
@ -86,7 +87,7 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
|
||||
it "can use multiple order bys to order results" $ do
|
||||
let orderBy = OrderBy (ColumnName "ArtistId") Ascending :| [OrderBy (ColumnName "Title") Descending]
|
||||
let query = albumsQuery {orderBy = Just orderBy}
|
||||
let query = albumsQueryRequest & qrQuery . qOrderBy .~ Just orderBy
|
||||
receivedAlbums <- getQueryResponse <$> (api // _query) sourceName config query
|
||||
|
||||
let expectedAlbums =
|
||||
@ -96,8 +97,8 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
|
||||
describe "Where" $ do
|
||||
it "can filter using an equality expression" $ do
|
||||
let where' = ApplyBinaryComparisonOperator (ValueWrapper3 Equal (ColumnName "AlbumId") (ScalarValue (ValueWrapper (Number 2))))
|
||||
let query = albumsQuery {where_ = Just where'}
|
||||
let where' = ApplyBinaryComparisonOperator (ValueWrapper3 Equal (localComparisonColumn "AlbumId") (ScalarValue (ValueWrapper (Number 2))))
|
||||
let query = albumsQueryRequest & qrQuery . qWhere .~ Just where'
|
||||
receivedAlbums <- (Data.sortBy "AlbumId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
let expectedAlbums =
|
||||
@ -106,8 +107,8 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
receivedAlbums `shouldBe` expectedAlbums
|
||||
|
||||
it "can filter using an inequality expression" $ do
|
||||
let where' = Not (ValueWrapper (ApplyBinaryComparisonOperator (ValueWrapper3 Equal (ColumnName "AlbumId") (ScalarValue (ValueWrapper (Number 2))))))
|
||||
let query = albumsQuery {where_ = Just where'}
|
||||
let where' = Not (ValueWrapper (ApplyBinaryComparisonOperator (ValueWrapper3 Equal (localComparisonColumn "AlbumId") (ScalarValue (ValueWrapper (Number 2))))))
|
||||
let query = albumsQueryRequest & qrQuery . qWhere .~ Just where'
|
||||
receivedAlbums <- (Data.sortBy "AlbumId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
let expectedAlbums =
|
||||
@ -116,8 +117,8 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
receivedAlbums `shouldBe` expectedAlbums
|
||||
|
||||
it "can filter using an in expression" $ do
|
||||
let where' = ApplyBinaryArrayComparisonOperator (ValueWrapper3 In (ColumnName "AlbumId") (ScalarValue . ValueWrapper <$> [Number 2, Number 3]))
|
||||
let query = albumsQuery {where_ = Just where'}
|
||||
let where' = ApplyBinaryArrayComparisonOperator (ValueWrapper3 In (localComparisonColumn "AlbumId") [Number 2, Number 3])
|
||||
let query = albumsQueryRequest & qrQuery . qWhere .~ Just where'
|
||||
receivedAlbums <- (Data.sortBy "AlbumId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
let expectedAlbums =
|
||||
@ -126,8 +127,8 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
receivedAlbums `shouldBe` expectedAlbums
|
||||
|
||||
it "can negate an in expression filter using a not expression" $ do
|
||||
let where' = Not (ValueWrapper (ApplyBinaryArrayComparisonOperator (ValueWrapper3 In (ColumnName "AlbumId") (ScalarValue . ValueWrapper <$> [Number 2, Number 3]))))
|
||||
let query = albumsQuery {where_ = Just where'}
|
||||
let where' = Not (ValueWrapper (ApplyBinaryArrayComparisonOperator (ValueWrapper3 In (localComparisonColumn "AlbumId") [Number 2, Number 3])))
|
||||
let query = albumsQueryRequest & qrQuery . qWhere .~ Just where'
|
||||
receivedAlbums <- (Data.sortBy "AlbumId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
let expectedAlbums =
|
||||
@ -136,10 +137,10 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
receivedAlbums `shouldBe` expectedAlbums
|
||||
|
||||
it "can combine filters using an and expression" $ do
|
||||
let where1 = ApplyBinaryComparisonOperator (ValueWrapper3 Equal (ColumnName "ArtistId") (ScalarValue (ValueWrapper (Number 58))))
|
||||
let where2 = ApplyBinaryComparisonOperator (ValueWrapper3 Equal (ColumnName "Title") (ScalarValue (ValueWrapper (String "Stormbringer"))))
|
||||
let where1 = ApplyBinaryComparisonOperator (ValueWrapper3 Equal (localComparisonColumn "ArtistId") (ScalarValue (ValueWrapper (Number 58))))
|
||||
let where2 = ApplyBinaryComparisonOperator (ValueWrapper3 Equal (localComparisonColumn "Title") (ScalarValue (ValueWrapper (String "Stormbringer"))))
|
||||
let where' = And (ValueWrapper [where1, where2])
|
||||
let query = albumsQuery {where_ = Just where'}
|
||||
let query = albumsQueryRequest & qrQuery . qWhere .~ Just where'
|
||||
receivedAlbums <- (Data.sortBy "AlbumId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
let expectedAlbums =
|
||||
@ -152,10 +153,10 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
receivedAlbums `shouldBe` expectedAlbums
|
||||
|
||||
it "can combine filters using an or expression" $ do
|
||||
let where1 = ApplyBinaryComparisonOperator (ValueWrapper3 Equal (ColumnName "AlbumId") (ScalarValue (ValueWrapper (Number 2))))
|
||||
let where2 = ApplyBinaryComparisonOperator (ValueWrapper3 Equal (ColumnName "AlbumId") (ScalarValue (ValueWrapper (Number 3))))
|
||||
let where1 = ApplyBinaryComparisonOperator (ValueWrapper3 Equal (localComparisonColumn "AlbumId") (ScalarValue (ValueWrapper (Number 2))))
|
||||
let where2 = ApplyBinaryComparisonOperator (ValueWrapper3 Equal (localComparisonColumn "AlbumId") (ScalarValue (ValueWrapper (Number 3))))
|
||||
let where' = Or (ValueWrapper [where1, where2])
|
||||
let query = albumsQuery {where_ = Just where'}
|
||||
let query = albumsQueryRequest & qrQuery . qWhere .~ Just where'
|
||||
receivedAlbums <- (Data.sortBy "AlbumId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
let expectedAlbums =
|
||||
@ -164,8 +165,8 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
receivedAlbums `shouldBe` expectedAlbums
|
||||
|
||||
it "can filter by applying the greater than operator" $ do
|
||||
let where' = ApplyBinaryComparisonOperator (ValueWrapper3 GreaterThan (ColumnName "AlbumId") (ScalarValue (ValueWrapper (Number 300))))
|
||||
let query = albumsQuery {where_ = Just where'}
|
||||
let where' = ApplyBinaryComparisonOperator (ValueWrapper3 GreaterThan (localComparisonColumn "AlbumId") (ScalarValue (ValueWrapper (Number 300))))
|
||||
let query = albumsQueryRequest & qrQuery . qWhere .~ Just where'
|
||||
receivedAlbums <- (Data.sortBy "AlbumId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
let expectedAlbums =
|
||||
@ -174,8 +175,8 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
receivedAlbums `shouldBe` expectedAlbums
|
||||
|
||||
it "can filter by applying the greater than or equal operator" $ do
|
||||
let where' = ApplyBinaryComparisonOperator (ValueWrapper3 GreaterThanOrEqual (ColumnName "AlbumId") (ScalarValue (ValueWrapper (Number 300))))
|
||||
let query = albumsQuery {where_ = Just where'}
|
||||
let where' = ApplyBinaryComparisonOperator (ValueWrapper3 GreaterThanOrEqual (localComparisonColumn "AlbumId") (ScalarValue (ValueWrapper (Number 300))))
|
||||
let query = albumsQueryRequest & qrQuery . qWhere .~ Just where'
|
||||
receivedAlbums <- (Data.sortBy "AlbumId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
let expectedAlbums =
|
||||
@ -184,8 +185,8 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
receivedAlbums `shouldBe` expectedAlbums
|
||||
|
||||
it "can filter by applying the less than operator" $ do
|
||||
let where' = ApplyBinaryComparisonOperator (ValueWrapper3 LessThan (ColumnName "AlbumId") (ScalarValue (ValueWrapper (Number 100))))
|
||||
let query = albumsQuery {where_ = Just where'}
|
||||
let where' = ApplyBinaryComparisonOperator (ValueWrapper3 LessThan (localComparisonColumn "AlbumId") (ScalarValue (ValueWrapper (Number 100))))
|
||||
let query = albumsQueryRequest & qrQuery . qWhere .~ Just where'
|
||||
receivedAlbums <- (Data.sortBy "AlbumId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
let expectedAlbums =
|
||||
@ -194,8 +195,8 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
receivedAlbums `shouldBe` expectedAlbums
|
||||
|
||||
it "can filter by applying the less than or equal operator" $ do
|
||||
let where' = ApplyBinaryComparisonOperator (ValueWrapper3 LessThanOrEqual (ColumnName "AlbumId") (ScalarValue (ValueWrapper (Number 100))))
|
||||
let query = albumsQuery {where_ = Just where'}
|
||||
let where' = ApplyBinaryComparisonOperator (ValueWrapper3 LessThanOrEqual (localComparisonColumn "AlbumId") (ScalarValue (ValueWrapper (Number 100))))
|
||||
let query = albumsQueryRequest & qrQuery . qWhere .~ Just where'
|
||||
receivedAlbums <- (Data.sortBy "AlbumId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
let expectedAlbums =
|
||||
@ -204,8 +205,8 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
receivedAlbums `shouldBe` expectedAlbums
|
||||
|
||||
it "can filter using a greater than operator with a column comparison" $ do
|
||||
let where' = ApplyBinaryComparisonOperator (ValueWrapper3 GreaterThan (ColumnName "AlbumId") (AnotherColumn (ValueWrapper (ColumnName "ArtistId"))))
|
||||
let query = albumsQuery {where_ = Just where'}
|
||||
let where' = ApplyBinaryComparisonOperator (ValueWrapper3 GreaterThan (localComparisonColumn "AlbumId") (AnotherColumn (ValueWrapper (localComparisonColumn "ArtistId"))))
|
||||
let query = albumsQueryRequest & qrQuery . qWhere .~ Just where'
|
||||
receivedAlbums <- (Data.sortBy "AlbumId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
let expectedAlbums =
|
||||
@ -213,17 +214,22 @@ spec api sourceName config = describe "Basic Queries" $ do
|
||||
|
||||
receivedAlbums `shouldBe` expectedAlbums
|
||||
|
||||
artistsQuery :: Query
|
||||
artistsQuery =
|
||||
artistsQueryRequest :: QueryRequest
|
||||
artistsQueryRequest =
|
||||
let fields = KeyMap.fromList [("ArtistId", columnField "ArtistId"), ("Name", columnField "Name")]
|
||||
tableName = TableName "Artist"
|
||||
in Query fields tableName Nothing Nothing Nothing Nothing
|
||||
query = Query fields Nothing Nothing Nothing Nothing
|
||||
in QueryRequest tableName [] query
|
||||
|
||||
albumsQuery :: Query
|
||||
albumsQuery =
|
||||
albumsQueryRequest :: QueryRequest
|
||||
albumsQueryRequest =
|
||||
let fields = KeyMap.fromList [("AlbumId", columnField "AlbumId"), ("ArtistId", columnField "ArtistId"), ("Title", columnField "Title")]
|
||||
tableName = TableName "Album"
|
||||
in Query fields tableName Nothing Nothing Nothing Nothing
|
||||
query = Query fields Nothing Nothing Nothing Nothing
|
||||
in QueryRequest tableName [] query
|
||||
|
||||
columnField :: Text -> Field
|
||||
columnField = ColumnField . ValueWrapper . ColumnName
|
||||
|
||||
localComparisonColumn :: Text -> ComparisonColumn
|
||||
localComparisonColumn columnName = ComparisonColumn [] $ ColumnName columnName
|
||||
|
@ -1,13 +1,14 @@
|
||||
module Test.QuerySpec.RelationshipsSpec (spec) where
|
||||
|
||||
import Autodocodec.Extended (ValueWrapper (..))
|
||||
import Control.Lens (ix, (^?))
|
||||
import Autodocodec.Extended (ValueWrapper (..), ValueWrapper3 (..))
|
||||
import Control.Lens (ix, (&), (.~), (^.), (^..), (^?))
|
||||
import Data.Aeson (Object, Value (..))
|
||||
import Data.Aeson qualified as J
|
||||
import Data.Aeson.KeyMap qualified as KeyMap
|
||||
import Data.Aeson.Lens (_Number)
|
||||
import Data.Aeson.Lens (_Array, _Number, _String)
|
||||
import Data.HashMap.Strict qualified as HashMap
|
||||
import Data.List.NonEmpty (NonEmpty (..))
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Text (Text)
|
||||
import Data.Vector qualified as Vector
|
||||
import Hasura.Backends.DataConnector.API
|
||||
@ -20,9 +21,9 @@ import Prelude
|
||||
|
||||
spec :: Client IO (NamedRoutes Routes) -> SourceName -> Config -> Spec
|
||||
spec api sourceName config = describe "Relationship Queries" $ do
|
||||
it "perform a many to one query by joining artist to albums" $ do
|
||||
it "perform an object relationship query by joining artist to albums" $ do
|
||||
let query = albumsWithArtistQuery id
|
||||
receivedAlbums <- (Data.sortBy "ArtistId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
receivedAlbums <- (Data.sortBy "AlbumId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
let joinInArtist (album :: Object) =
|
||||
let artist = (album ^? ix "ArtistId" . _Number) >>= \artistId -> Data.artistsAsJsonById ^? ix artistId
|
||||
@ -33,7 +34,7 @@ spec api sourceName config = describe "Relationship Queries" $ do
|
||||
let expectedAlbums = (removeArtistId . joinInArtist) <$> Data.albumsAsJson
|
||||
receivedAlbums `shouldBe` expectedAlbums
|
||||
|
||||
it "perform a one to many query by joining albums to artists" $ do
|
||||
it "perform an array relationship query by joining albums to artists" $ do
|
||||
let query = artistsWithAlbumsQuery id
|
||||
receivedArtists <- (Data.sortBy "ArtistId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
@ -47,49 +48,225 @@ spec api sourceName config = describe "Relationship Queries" $ do
|
||||
let expectedAlbums = joinInAlbums <$> Data.artistsAsJson
|
||||
receivedArtists `shouldBe` expectedAlbums
|
||||
|
||||
albumsWithArtistQuery :: (Query -> Query) -> Query
|
||||
it "perform an object relationship query by joining employee to customers and filter comparing columns across the object relationship" $ do
|
||||
-- Join Employee to Customers via SupportRep, and only get those customers that have a rep
|
||||
-- that is in the same country as the customer
|
||||
-- This sort of thing would come from a permissions filter on Customer that looks like:
|
||||
-- { SupportRep: { Country: { _ceq: [ "$", "Country" ] } } }
|
||||
let where' =
|
||||
ApplyBinaryComparisonOperator
|
||||
( ValueWrapper3
|
||||
Equal
|
||||
(comparisonColumn [supportRepRelationshipName] "Country")
|
||||
(AnotherColumn (ValueWrapper (comparisonColumn [] "Country")))
|
||||
)
|
||||
let query = customersWithSupportRepQuery id & qrQuery . qWhere .~ Just where'
|
||||
receivedCustomers <- (Data.sortBy "CustomerId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
let joinInSupportRep (customer :: Object) =
|
||||
let supportRep = (customer ^? ix "SupportRepId" . _Number) >>= \employeeId -> Data.employeesAsJsonById ^? ix employeeId
|
||||
supportRepPropVal = maybe J.Null (Object . Data.filterColumnsByQueryFields employeesQuery) supportRep
|
||||
in KeyMap.insert "SupportRep" supportRepPropVal customer
|
||||
|
||||
let filterCustomersBySupportRepCountry (customer :: Object) =
|
||||
let customerCountry = customer ^? ix "Country" . _String
|
||||
supportRepCountry = customer ^? ix "SupportRep" . ix "Country" . _String
|
||||
comparison = (==) <$> customerCountry <*> supportRepCountry
|
||||
in fromMaybe False comparison
|
||||
|
||||
let expectedCustomers = filter filterCustomersBySupportRepCountry $ Data.filterColumnsByQueryFields (query ^. qrQuery) . joinInSupportRep <$> Data.customersAsJson
|
||||
receivedCustomers `shouldBe` expectedCustomers
|
||||
|
||||
it "perform an array relationship query by joining customers to employees and filter comparing columns across the array relationship" $ do
|
||||
-- Join Customers to Employees via SupportRepForCustomers, and only get those employees that are reps for
|
||||
-- customers that are in the same country as the employee
|
||||
-- This sort of thing would come from a permissions filter on Employees that looks like:
|
||||
-- { SupportRepForCustomers: { Country: { _ceq: [ "$", "Country" ] } } }
|
||||
let where' =
|
||||
ApplyBinaryComparisonOperator
|
||||
( ValueWrapper3
|
||||
Equal
|
||||
(comparisonColumn [supportRepForCustomersRelationshipName] "Country")
|
||||
(AnotherColumn (ValueWrapper (comparisonColumn [] "Country")))
|
||||
)
|
||||
let query = employeesWithCustomersQuery id & qrQuery . qWhere .~ Just where'
|
||||
receivedEmployees <- (Data.sortBy "EmployeeId" . getQueryResponse) <$> (api // _query) sourceName config query
|
||||
|
||||
let joinInCustomers (employee :: Object) =
|
||||
let employeeId = employee ^? ix "EmployeeId" . _Number
|
||||
customerFilter employeeId' customer = customer ^? ix "SupportRepId" . _Number == Just employeeId'
|
||||
customers = maybe [] (\employeeId' -> filter (customerFilter employeeId') Data.customersAsJson) employeeId
|
||||
customers' = Object . Data.filterColumnsByQueryFields customersQuery <$> customers
|
||||
in KeyMap.insert "SupportRepForCustomers" (Array . Vector.fromList $ customers') employee
|
||||
|
||||
let filterEmployeesByCustomerCountry (employee :: Object) =
|
||||
let employeeCountry = employee ^? ix "Country" . _String
|
||||
customerCountries = employee ^.. ix "SupportRepForCustomers" . _Array . traverse . ix "Country" . _String
|
||||
in maybe False (`elem` customerCountries) employeeCountry
|
||||
|
||||
let expectedEmployees = filter filterEmployeesByCustomerCountry $ Data.filterColumnsByQueryFields (query ^. qrQuery) . joinInCustomers <$> Data.employeesAsJson
|
||||
receivedEmployees `shouldBe` expectedEmployees
|
||||
|
||||
albumsWithArtistQuery :: (Query -> Query) -> QueryRequest
|
||||
albumsWithArtistQuery modifySubquery =
|
||||
let joinFieldMapping =
|
||||
HashMap.fromList
|
||||
[ (PrimaryKey $ ColumnName "ArtistId", ForeignKey $ ColumnName "ArtistId")
|
||||
]
|
||||
artistsSubquery = modifySubquery artistsQuery
|
||||
let artistsSubquery = modifySubquery artistsQuery
|
||||
fields =
|
||||
KeyMap.fromList
|
||||
[ ("AlbumId", columnField "AlbumId"),
|
||||
("Title", columnField "Title"),
|
||||
("Artist", RelationshipField $ RelField joinFieldMapping ObjectRelationship artistsSubquery)
|
||||
("Artist", RelField $ RelationshipField artistRelationshipName artistsSubquery)
|
||||
]
|
||||
in albumsQuery {fields}
|
||||
query = albumsQuery {_qFields = fields}
|
||||
in QueryRequest albumsTableName [albumsTableRelationships] query
|
||||
|
||||
artistsWithAlbumsQuery :: (Query -> Query) -> Query
|
||||
artistsWithAlbumsQuery :: (Query -> Query) -> QueryRequest
|
||||
artistsWithAlbumsQuery modifySubquery =
|
||||
let joinFieldMapping =
|
||||
HashMap.fromList
|
||||
[ (PrimaryKey $ ColumnName "ArtistId", ForeignKey $ ColumnName "ArtistId")
|
||||
]
|
||||
albumFields = KeyMap.fromList [("AlbumId", columnField "AlbumId"), ("Title", columnField "Title")]
|
||||
let albumFields = KeyMap.fromList [("AlbumId", columnField "AlbumId"), ("Title", columnField "Title")]
|
||||
albumsSort = OrderBy (ColumnName "AlbumId") Ascending :| []
|
||||
albumsSubquery = modifySubquery (albumsQuery {fields = albumFields, orderBy = Just albumsSort})
|
||||
albumsSubquery = albumsQuery & qFields .~ albumFields & qOrderBy .~ Just albumsSort & modifySubquery
|
||||
fields =
|
||||
KeyMap.fromList
|
||||
[ ("ArtistId", columnField "ArtistId"),
|
||||
("Name", columnField "Name"),
|
||||
("Albums", RelationshipField $ RelField joinFieldMapping ArrayRelationship albumsSubquery)
|
||||
("Albums", RelField $ RelationshipField albumsRelationshipName albumsSubquery)
|
||||
]
|
||||
in artistsQuery {fields}
|
||||
query = artistsQuery {_qFields = fields}
|
||||
in QueryRequest artistsTableName [artistsTableRelationships] query
|
||||
|
||||
employeesWithCustomersQuery :: (Query -> Query) -> QueryRequest
|
||||
employeesWithCustomersQuery modifySubquery =
|
||||
let customersSort = OrderBy (ColumnName "CustomerId") Ascending :| []
|
||||
customersSubquery = customersQuery & qOrderBy .~ Just customersSort & modifySubquery
|
||||
fields =
|
||||
_qFields employeesQuery
|
||||
<> KeyMap.fromList
|
||||
[ ("SupportRepForCustomers", RelField $ RelationshipField supportRepForCustomersRelationshipName customersSubquery)
|
||||
]
|
||||
query = employeesQuery {_qFields = fields}
|
||||
in QueryRequest employeesTableName [employeesTableRelationships] query
|
||||
|
||||
customersWithSupportRepQuery :: (Query -> Query) -> QueryRequest
|
||||
customersWithSupportRepQuery modifySubquery =
|
||||
let supportRepSubquery = employeesQuery & modifySubquery
|
||||
fields =
|
||||
_qFields customersQuery
|
||||
<> KeyMap.fromList
|
||||
[ ("SupportRep", RelField $ RelationshipField supportRepRelationshipName supportRepSubquery)
|
||||
]
|
||||
query = customersQuery {_qFields = fields}
|
||||
in QueryRequest customersTableName [customersTableRelationships] query
|
||||
|
||||
artistsTableName :: TableName
|
||||
artistsTableName = TableName "Artist"
|
||||
|
||||
albumsTableName :: TableName
|
||||
albumsTableName = TableName "Album"
|
||||
|
||||
customersTableName :: TableName
|
||||
customersTableName = TableName "Customer"
|
||||
|
||||
employeesTableName :: TableName
|
||||
employeesTableName = TableName "Employee"
|
||||
|
||||
artistRelationshipName :: RelationshipName
|
||||
artistRelationshipName = RelationshipName "Artist"
|
||||
|
||||
albumsRelationshipName :: RelationshipName
|
||||
albumsRelationshipName = RelationshipName "Albums"
|
||||
|
||||
supportRepForCustomersRelationshipName :: RelationshipName
|
||||
supportRepForCustomersRelationshipName = RelationshipName "SupportRepForCustomers"
|
||||
|
||||
supportRepRelationshipName :: RelationshipName
|
||||
supportRepRelationshipName = RelationshipName "SupportRep"
|
||||
|
||||
artistsTableRelationships :: TableRelationships
|
||||
artistsTableRelationships =
|
||||
let joinFieldMapping =
|
||||
HashMap.fromList
|
||||
[ (ColumnName "ArtistId", ColumnName "ArtistId")
|
||||
]
|
||||
in TableRelationships
|
||||
artistsTableName
|
||||
( HashMap.fromList
|
||||
[ (albumsRelationshipName, Relationship albumsTableName ArrayRelationship joinFieldMapping)
|
||||
]
|
||||
)
|
||||
|
||||
albumsTableRelationships :: TableRelationships
|
||||
albumsTableRelationships =
|
||||
let joinFieldMapping =
|
||||
HashMap.fromList
|
||||
[ (ColumnName "ArtistId", ColumnName "ArtistId")
|
||||
]
|
||||
in TableRelationships
|
||||
albumsTableName
|
||||
( HashMap.fromList
|
||||
[ (artistRelationshipName, Relationship artistsTableName ObjectRelationship joinFieldMapping)
|
||||
]
|
||||
)
|
||||
|
||||
employeesTableRelationships :: TableRelationships
|
||||
employeesTableRelationships =
|
||||
let joinFieldMapping =
|
||||
HashMap.fromList
|
||||
[ (ColumnName "EmployeeId", ColumnName "SupportRepId")
|
||||
]
|
||||
in TableRelationships
|
||||
employeesTableName
|
||||
( HashMap.fromList
|
||||
[ (supportRepForCustomersRelationshipName, Relationship customersTableName ArrayRelationship joinFieldMapping)
|
||||
]
|
||||
)
|
||||
|
||||
customersTableRelationships :: TableRelationships
|
||||
customersTableRelationships =
|
||||
let joinFieldMapping =
|
||||
HashMap.fromList
|
||||
[ (ColumnName "SupportRepId", ColumnName "EmployeeId")
|
||||
]
|
||||
in TableRelationships
|
||||
customersTableName
|
||||
( HashMap.fromList
|
||||
[ (supportRepRelationshipName, Relationship employeesTableName ObjectRelationship joinFieldMapping)
|
||||
]
|
||||
)
|
||||
|
||||
artistsQuery :: Query
|
||||
artistsQuery =
|
||||
let fields = KeyMap.fromList [("ArtistId", columnField "ArtistId"), ("Name", columnField "Name")]
|
||||
tableName = TableName "Artist"
|
||||
in Query fields tableName Nothing Nothing Nothing Nothing
|
||||
in Query fields Nothing Nothing Nothing Nothing
|
||||
|
||||
albumsQuery :: Query
|
||||
albumsQuery =
|
||||
let fields = KeyMap.fromList [("AlbumId", columnField "AlbumId"), ("ArtistId", columnField "ArtistId"), ("Title", columnField "Title")]
|
||||
tableName = TableName "Album"
|
||||
in Query fields tableName Nothing Nothing Nothing Nothing
|
||||
in Query fields Nothing Nothing Nothing Nothing
|
||||
|
||||
customersQuery :: Query
|
||||
customersQuery =
|
||||
let fields =
|
||||
KeyMap.fromList
|
||||
[ ("CustomerId", columnField "CustomerId"),
|
||||
("FirstName", columnField "FirstName"),
|
||||
("LastName", columnField "LastName"),
|
||||
("Country", columnField "Country"),
|
||||
("SupportRepId", columnField "SupportRepId")
|
||||
]
|
||||
in Query fields Nothing Nothing Nothing Nothing
|
||||
|
||||
employeesQuery :: Query
|
||||
employeesQuery =
|
||||
let fields =
|
||||
KeyMap.fromList
|
||||
[ ("EmployeeId", columnField "EmployeeId"),
|
||||
("FirstName", columnField "FirstName"),
|
||||
("LastName", columnField "LastName"),
|
||||
("Country", columnField "Country")
|
||||
]
|
||||
in Query fields Nothing Nothing Nothing Nothing
|
||||
|
||||
columnField :: Text -> Field
|
||||
columnField = ColumnField . ValueWrapper . ColumnName
|
||||
|
||||
comparisonColumn :: [RelationshipName] -> Text -> ComparisonColumn
|
||||
comparisonColumn path columnName = ComparisonColumn path $ ColumnName columnName
|
||||
|
@ -4,6 +4,7 @@
|
||||
module Harness.Backend.DataConnector
|
||||
( setup,
|
||||
teardown,
|
||||
defaultBackendConfig,
|
||||
defaultSourceMetadata,
|
||||
)
|
||||
where
|
||||
|
@ -1,7 +1,7 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
-- | Query Tests for Data Connector Backend
|
||||
module Test.DC.QuerySpec
|
||||
module Test.DataConnector.QuerySpec
|
||||
( spec,
|
||||
)
|
||||
where
|
270
server/tests-hspec/Test/DataConnector/SelectPermissionsSpec.hs
Normal file
270
server/tests-hspec/Test/DataConnector/SelectPermissionsSpec.hs
Normal file
@ -0,0 +1,270 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
-- | Select Permissions Tests for Data Connector Backend
|
||||
module Test.DataConnector.SelectPermissionsSpec
|
||||
( spec,
|
||||
)
|
||||
where
|
||||
|
||||
import Data.Aeson (Value)
|
||||
import Data.ByteString (ByteString)
|
||||
import Harness.Backend.DataConnector (defaultBackendConfig)
|
||||
import Harness.Backend.DataConnector qualified as DataConnector
|
||||
import Harness.GraphqlEngine qualified as GraphqlEngine
|
||||
import Harness.Quoter.Graphql (graphql)
|
||||
import Harness.Quoter.Yaml (shouldReturnYaml, yaml)
|
||||
import Harness.Test.BackendType (BackendType (..), defaultBackendTypeString, defaultSource)
|
||||
import Harness.Test.Context qualified as Context
|
||||
import Harness.TestEnvironment (TestEnvironment)
|
||||
import Test.Hspec (SpecWith, describe, it)
|
||||
import Prelude
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Preamble
|
||||
|
||||
spec :: SpecWith TestEnvironment
|
||||
spec =
|
||||
Context.runWithLocalTestEnvironment
|
||||
[ Context.Context
|
||||
{ name = Context.Backend Context.DataConnector,
|
||||
mkLocalTestEnvironment = Context.noLocalTestEnvironment,
|
||||
setup = setupFixture,
|
||||
teardown = DataConnector.teardown,
|
||||
customOptions = Nothing
|
||||
}
|
||||
]
|
||||
tests
|
||||
|
||||
testRoleName :: ByteString
|
||||
testRoleName = "test-role"
|
||||
|
||||
sourceMetadata :: Value
|
||||
sourceMetadata =
|
||||
let source = defaultSource DataConnector
|
||||
backendType = defaultBackendTypeString DataConnector
|
||||
in [yaml|
|
||||
name : *source
|
||||
kind: *backendType
|
||||
tables:
|
||||
- table: Employee
|
||||
array_relationships:
|
||||
- name: SupportRepForCustomers
|
||||
using:
|
||||
manual_configuration:
|
||||
remote_table: Customer
|
||||
column_mapping:
|
||||
EmployeeId: SupportRepId
|
||||
select_permissions:
|
||||
- role: *testRoleName
|
||||
permission:
|
||||
columns:
|
||||
- EmployeeId
|
||||
- FirstName
|
||||
- LastName
|
||||
- Country
|
||||
filter:
|
||||
SupportRepForCustomers:
|
||||
Country:
|
||||
_ceq: [ "$", "Country" ]
|
||||
- table: Customer
|
||||
object_relationships:
|
||||
- name: SupportRep
|
||||
using:
|
||||
manual_configuration:
|
||||
remote_table: Employee
|
||||
column_mapping:
|
||||
SupportRepId: EmployeeId
|
||||
select_permissions:
|
||||
- role: *testRoleName
|
||||
permission:
|
||||
columns:
|
||||
- CustomerId
|
||||
- FirstName
|
||||
- LastName
|
||||
- Country
|
||||
- SupportRepId
|
||||
filter:
|
||||
SupportRep:
|
||||
Country:
|
||||
_ceq: [ "$", "Country" ]
|
||||
configuration: {}
|
||||
|]
|
||||
|
||||
setupFixture :: (TestEnvironment, ()) -> IO ()
|
||||
setupFixture (testEnvironment, _) = do
|
||||
-- Clear and reconfigure the metadata
|
||||
GraphqlEngine.setSource testEnvironment sourceMetadata (Just defaultBackendConfig)
|
||||
|
||||
tests :: Context.Options -> SpecWith (TestEnvironment, a)
|
||||
tests opts = describe "SelectPermissionsSpec" $ do
|
||||
it "permissions filter using _ceq that traverses an object relationship" $ \(testEnvironment, _) ->
|
||||
shouldReturnYaml
|
||||
opts
|
||||
( GraphqlEngine.postGraphqlWithHeaders
|
||||
testEnvironment
|
||||
[("X-Hasura-Role", testRoleName)]
|
||||
[graphql|
|
||||
query getEmployee {
|
||||
Employee {
|
||||
EmployeeId
|
||||
FirstName
|
||||
LastName
|
||||
Country
|
||||
}
|
||||
}
|
||||
|]
|
||||
)
|
||||
[yaml|
|
||||
data:
|
||||
Employee:
|
||||
- Country: Canada
|
||||
EmployeeId: 3
|
||||
FirstName: Jane
|
||||
LastName: Peacock
|
||||
- Country: Canada
|
||||
EmployeeId: 4
|
||||
FirstName: Margaret
|
||||
LastName: Park
|
||||
- Country: Canada
|
||||
EmployeeId: 5
|
||||
FirstName: Steve
|
||||
LastName: Johnson
|
||||
|]
|
||||
|
||||
it "permissions filter using _ceq that traverses an array relationship" $ \(testEnvironment, _) ->
|
||||
shouldReturnYaml
|
||||
opts
|
||||
( GraphqlEngine.postGraphqlWithHeaders
|
||||
testEnvironment
|
||||
[("X-Hasura-Role", testRoleName)]
|
||||
[graphql|
|
||||
query getCustomer {
|
||||
Customer {
|
||||
CustomerId
|
||||
FirstName
|
||||
LastName
|
||||
Country
|
||||
SupportRepId
|
||||
}
|
||||
}
|
||||
|]
|
||||
)
|
||||
[yaml|
|
||||
data:
|
||||
Customer:
|
||||
- Country: Canada
|
||||
CustomerId: 3
|
||||
FirstName: François
|
||||
LastName: Tremblay
|
||||
SupportRepId: 3
|
||||
- Country: Canada
|
||||
CustomerId: 14
|
||||
FirstName: Mark
|
||||
LastName: Philips
|
||||
SupportRepId: 5
|
||||
- Country: Canada
|
||||
CustomerId: 15
|
||||
FirstName: Jennifer
|
||||
LastName: Peterson
|
||||
SupportRepId: 3
|
||||
- Country: Canada
|
||||
CustomerId: 29
|
||||
FirstName: Robert
|
||||
LastName: Brown
|
||||
SupportRepId: 3
|
||||
- Country: Canada
|
||||
CustomerId: 30
|
||||
FirstName: Edward
|
||||
LastName: Francis
|
||||
SupportRepId: 3
|
||||
- Country: Canada
|
||||
CustomerId: 31
|
||||
FirstName: Martha
|
||||
LastName: Silk
|
||||
SupportRepId: 5
|
||||
- Country: Canada
|
||||
CustomerId: 32
|
||||
FirstName: Aaron
|
||||
LastName: Mitchell
|
||||
SupportRepId: 4
|
||||
- Country: Canada
|
||||
CustomerId: 33
|
||||
FirstName: Ellie
|
||||
LastName: Sullivan
|
||||
SupportRepId: 3
|
||||
|]
|
||||
|
||||
it "Query involving two tables with their own permissions filter" $ \(testEnvironment, _) ->
|
||||
shouldReturnYaml
|
||||
opts
|
||||
( GraphqlEngine.postGraphqlWithHeaders
|
||||
testEnvironment
|
||||
[("X-Hasura-Role", testRoleName)]
|
||||
[graphql|
|
||||
query getEmployee {
|
||||
Employee {
|
||||
EmployeeId
|
||||
FirstName
|
||||
LastName
|
||||
Country
|
||||
SupportRepForCustomers {
|
||||
CustomerId
|
||||
FirstName
|
||||
LastName
|
||||
Country
|
||||
}
|
||||
}
|
||||
}
|
||||
|]
|
||||
)
|
||||
[yaml|
|
||||
data:
|
||||
Employee:
|
||||
- Country: Canada
|
||||
EmployeeId: 3
|
||||
FirstName: Jane
|
||||
LastName: Peacock
|
||||
SupportRepForCustomers:
|
||||
- Country: Canada
|
||||
CustomerId: 3
|
||||
FirstName: François
|
||||
LastName: Tremblay
|
||||
- Country: Canada
|
||||
CustomerId: 15
|
||||
FirstName: Jennifer
|
||||
LastName: Peterson
|
||||
- Country: Canada
|
||||
CustomerId: 29
|
||||
FirstName: Robert
|
||||
LastName: Brown
|
||||
- Country: Canada
|
||||
CustomerId: 30
|
||||
FirstName: Edward
|
||||
LastName: Francis
|
||||
- Country: Canada
|
||||
CustomerId: 33
|
||||
FirstName: Ellie
|
||||
LastName: Sullivan
|
||||
- Country: Canada
|
||||
EmployeeId: 4
|
||||
FirstName: Margaret
|
||||
LastName: Park
|
||||
SupportRepForCustomers:
|
||||
- Country: Canada
|
||||
CustomerId: 32
|
||||
FirstName: Aaron
|
||||
LastName: Mitchell
|
||||
- Country: Canada
|
||||
EmployeeId: 5
|
||||
FirstName: Steve
|
||||
LastName: Johnson
|
||||
SupportRepForCustomers:
|
||||
- Country: Canada
|
||||
CustomerId: 14
|
||||
FirstName: Mark
|
||||
LastName: Philips
|
||||
- Country: Canada
|
||||
CustomerId: 31
|
||||
FirstName: Martha
|
||||
LastName: Silk
|
||||
|]
|
Loading…
Reference in New Issue
Block a user