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:
Daniel Chambers 2022-06-24 16:58:25 +10:00 committed by hasura-bot
parent 639555b349
commit 7ec5e79bd1
33 changed files with 1699 additions and 581 deletions

View File

@ -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,17 +220,20 @@ 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",
"table": "Artist",
"table_relationships": [],
"query": {
"where": {
"expressions": [],
"type": "and"
@ -239,13 +242,14 @@ and here is the resulting query payload:
"limit": null,
"offset": null,
"fields": {
"id": {
"ArtistId": {
"type": "column",
"column": "id"
"column": "ArtistId"
},
"name": {
"Name": {
"type": "column",
"column": "name"
"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,20 +432,33 @@ This will generate the following JSON query if `relationships` is set to `true`:
```json
{
"table": "Artist",
"table_relationships": [
{
"source_table": "Artist",
"relationships": {
"ArtistAlbums": {
"target_table": "Album",
"relationship_type": "array",
"column_mapping": {
"ArtistId": "ArtistId"
}
}
}
}
],
"query": {
"where": {
"expressions": [],
"type": "and"
},
"offset": null,
"from": "artists",
"order_by": [],
"limit": null,
"fields": {
"albums": {
"Albums": {
"type": "relationship",
"column_mapping": {
"id": "artist_id"
},
"relationship": "ArtistAlbums",
"query": {
"where": {
"expressions": [],
@ -434,29 +469,28 @@ This will generate the following JSON query if `relationships` is set to `true`:
"order_by": [],
"limit": null,
"fields": {
"title": {
"Title": {
"type": "column",
"column": "title"
"column": "Title"
}
}
}
},
"name": {
"Name": {
"type": "column",
"column": "name"
"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.

View File

@ -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', () => {

View File

@ -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);
};

View File

@ -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 {

View File

@ -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][]);
};

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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 <> "'."

View File

@ -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 =

View File

@ -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
}

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -4,6 +4,7 @@
module Harness.Backend.DataConnector
( setup,
teardown,
defaultBackendConfig,
defaultSourceMetadata,
)
where

View File

@ -1,7 +1,7 @@
{-# LANGUAGE QuasiQuotes #-}
-- | Query Tests for Data Connector Backend
module Test.DC.QuerySpec
module Test.DataConnector.QuerySpec
( spec,
)
where

View 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
|]