Exists support in filter expressions for Data Connector queries [GDW-133]

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/5904
GitOrigin-RevId: 6bad4c29a7d14d3881f9c57fe983d14cc41bdc4b
This commit is contained in:
Daniel Chambers 2022-09-20 13:59:47 +10:00 committed by hasura-bot
parent dcca0c6275
commit 04ae6abf78
42 changed files with 1755 additions and 574 deletions

View File

@ -369,10 +369,20 @@ Each node of this recursive expression structure is tagged with a `type` propert
| `and` | `expressions` | A conjunction of several subexpressions |
| `or` | `expressions` | A disjunction of several subexpressions |
| `not` | `expression` | The negation of a single subexpression |
| `exists` | `in_table`, `where` | Test if a row exists that matches the `where` subexpression in the specified table (`in_table`) |
| `binary_op` | `operator`, `column`, `value` | Test the specified `column` against a single `value` using a particular binary comparison `operator` |
| `binary_arr_op` | `operator`, `column`, `values` | Test the specified `column` against an array of `values` using a particular binary comparison `operator` |
| `unary_op` | `operator`, `column` | Test the specified `column` against a particular unary comparison `operator` |
The value of the `in_table` property of the `exists` expression is an object that describes which table to look for rows in. The object is tagged with a `type` property:
| type | Additional fields | Description |
|-------------|---------------------------------|
| `related` | `relationship` | The table is related to the current table via the relationship name specified in `relationship` (this means it should be joined to the current table via the relationship) |
| `unrelated` | `table` | The table specified by `table` is unrelated to the current table and therefore is not explicitly joined to the current table |
The "current table" during expression evaluation is the table specified by the closest ancestor `exists` expression, or if there is no `exists` ancestor, it is the table involved in the Query that the whole `where` Expression is from.
The available binary comparison operators that can be used against a single value in `binary_op` are:
| Binary comparison operator | Description |
@ -402,7 +412,7 @@ 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.
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 optionally a `path` to the table that contains the column. If the `path` property is missing/null or an empty array, then the column is on the current table. However, if the path is `["$"]`, then the column is on the table involved in the Query that the whole `where` expression is from. At this point in time, these are the only valid values of `path`.
Here is a simple example, which correponds to the predicate "`first_name` is John and `last_name` is Smith":
@ -414,7 +424,6 @@ Here is a simple example, which correponds to the predicate "`first_name` is Joh
"type": "binary_op",
"operator": "equal",
"column": {
"path": [],
"name": "first_name"
},
"value": {
@ -426,7 +435,6 @@ Here is a simple example, which correponds to the predicate "`first_name` is Joh
"type": "binary_op",
"operator": "equal",
"column": {
"path": [],
"name": "last_name"
},
"value": {
@ -442,24 +450,122 @@ Here's another example, which corresponds to the predicate "`first_name` is the
```json
{
"type": "and",
"expressions": [
{
"type": "binary_op",
"operator": "equal",
"column": {
"name": "first_name"
},
"value": {
"type": "column",
"column": {
"name": "last_name"
}
}
}
```
In this example, a person table is filtered by whether or not that person has any children 18 years of age or older:
```json
{
"type": "exists",
"in_table": {
"type": "related",
"relationship": "children"
},
"where": {
"type": "binary_op",
"operator": "greater_than_or_equal",
"column": {
"name": "age"
},
"value": {
"type": "scalar",
"value": 18
}
}
}
```
In this example, a person table is filtered by whether or not that person has any children that have the same first name as them:
```jsonc
{
"type": "exists",
"in_table": {
"type": "related",
"relationship": "children"
},
"where": {
"type": "binary_op",
"operator": "equal",
"column": {
"name": "first_name" // This column refers to the child's name
},
"value": {
"type": "column",
"column": {
"path": ["$"],
"name": "first_name" // This column refers to the parent's name
}
}
}
}
```
Exists expressions can be nested, but the `["$"]` path always refers to the query table. So in this example, a person table is filtered by whether or not that person has any children that have any friends that have the same first name as the parent:
```jsonc
{
"type": "exists",
"in_table": {
"type": "related",
"relationship": "children"
},
"where": {
"type": "exists",
"in_table": {
"type": "related",
"relationship": "friends"
},
"where": {
"type": "binary_op",
"operator": "equal",
"column": {
"path": [],
"name": "first_name"
"name": "first_name" // This column refers to the children's friend's name
},
"value": {
"type": "column",
"column": {
"path": [],
"name": "last_name"
"path": ["$"],
"name": "first_name" // This column refers to the parent's name
}
}
}
]
}
}
```
In this example, a table is filtered by whether or not an unrelated administrators table contains an admin called "superuser". Note that this means if the administrators table contains the "superuser" admin, then all rows of the table are returned, but if not, no rows are returned.
```json
{
"type": "exists",
"in_table": {
"type": "unrelated",
"table": ["administrators"]
},
"where": {
"type": "binary_op",
"operator": "equal",
"column": {
"name": "username"
},
"value": {
"type": "scalar",
"value": "superuser"
}
}
}
```
@ -682,7 +788,7 @@ POST /v1/metadata
}
```
Given this GraphQL query (where the `X-Hasura-Role` header is set to `user`):
Given this GraphQL query (where the `X-Hasura-Role` session variable is set to `user`):
```graphql
query getCustomer {
@ -742,17 +848,23 @@ We would get the following query request JSON:
"type": "and",
"expressions": [
{
"type": "binary_op",
"operator": "equal",
"column": {
"path": ["SupportRep"],
"name": "Country"
"type": "exists",
"in_table": {
"type": "related",
"relationship": "SupportRep"
},
"value": {
"type": "column",
"where": {
"type": "binary_op",
"operator": "equal",
"column": {
"path": [],
"name": "Country"
},
"value": {
"type": "column",
"column": {
"path": ["$"],
"name": "Country"
}
}
}
}
@ -762,7 +874,155 @@ We would get the following query request JSON:
}
```
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.
The key point of interest here is in the `where` field where we are comparing between columns. Our first expression is an `exists` expression that specifies a row must exist in the table related to the `Customer` table by the `SupportRep` relationship (ie. the `Employee` table). These rows must match a subexpression that compares the related `Employee`'s `Country` column 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. Then if any of the related rows (in this case, only one because it is an `object` relation) contain a `Country` that is equal to Customer row's `Country` the `binary_op` would evaluate to True. This would mean a row exists, so the `exists` evaluates to true, and we don't filter out the Customer row.
#### Filtering by Unrelated Tables
It is possible to filter a table by a predicate evaluated against a completely unrelated table. This can happen in Hasura GraphQL Engine when configuring permissions on a table.
In the following example, we are configuring HGE's metadata such that when the Customer table is queried by the employee role, the employee currently doing the query (as specified by the `X-Hasura-EmployeeId` session variable) must be an employee from the city of Calgary, otherwise no rows are returned.
```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"],
"select_permissions": [
{
"role": "employee",
"permission": {
"columns": [
"CustomerId",
"FirstName",
"LastName",
"Country",
"SupportRepId"
],
"filter": {
"_exists": {
"_table": ["Employee"],
"_where": {
"_and": [
{ "EmployeeId": { "_eq": "X-Hasura-EmployeeId" } },
{ "City": { "_eq": "Calgary" } }
]
}
}
}
}
}
]
},
{
"table": ["Employee"]
}
],
"configuration": {}
}
]
}
}
}
```
Given this GraphQL query (where the `X-Hasura-Role` session variable is set to `employee`, and the `X-Hasura-EmployeeId` session variable is set to `2`):
```graphql
query getCustomer {
Customer {
CustomerId
FirstName
LastName
Country
SupportRepId
}
}
```
We would get the following query request JSON:
```json
{
"table": ["Customer"],
"table_relationships": [],
"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": "exists",
"in_table": {
"type": "unrelated",
"table": ["Employee"]
},
"where": {
"type": "and",
"expressions": [
{
"type": "binary_op",
"operator": "equal",
"column": {
"name": "EmployeeId"
},
"value": {
"type": "scalar",
"value": 2
}
},
{
"type": "binary_op",
"operator": "equal",
"column": {
"name": "City"
},
"value": {
"type": "scalar",
"value": "Calgary"
}
}
]
}
}
}
}
```
The key part in this query is the `where` expression. The root expression in the where is an `exists` expression which specifies that at least one row must exist in the unrelated `["Employee"]` table that satisfies a subexpression. This subexpression asserts that the rows from the Employee table have both `EmployeeId` as `2` and `City` as `Calgary`. The columns referenced inside this subexpression don't have `path` properties, which means they refer the columns on the Employee table because that is the closest ancestor `exists` table.
#### Aggregates
HGE supports forming GraphQL queries that allow clients to aggregate over the data in their data sources. This type of query can be passed through to Data Connector agents as a part of the Query structure sent to `/query`.
@ -902,7 +1162,6 @@ The `nodes` part of the query ends up as standard `fields` in the `Query`, and t
"type": "binary_op",
"operator": "greater_than",
"column": {
"path": [],
"name": "Name"
},
"value": {
@ -1158,7 +1417,6 @@ For example, here's a query that retrieves artists ordered descending by the cou
"type": "binary_op",
"operator": "greater_than",
"column": {
"path": [],
"name": "Title"
},
"value": {

View File

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

View File

@ -225,6 +225,9 @@
},
"Capabilities": {
"properties": {
"comparisons": {
"$ref": "#/components/schemas/ComparisonCapabilities"
},
"explain": {
"$ref": "#/components/schemas/ExplainCapabilities"
},
@ -291,6 +294,27 @@
"type": "string"
},
"RelationshipCapabilities": {},
"CrossTableComparisonCapabilities": {
"nullable": true,
"properties": {
"supports_relations": {
"description": "Does the agent support comparisons that involve related tables (ie. joins)?",
"type": "boolean"
}
},
"required": [
"supports_relations"
],
"type": "object"
},
"ComparisonCapabilities": {
"properties": {
"cross_table": {
"$ref": "#/components/schemas/CrossTableComparisonCapabilities"
}
},
"type": "object"
},
"MetricsCapabilities": {},
"ExplainCapabilities": {},
"ConfigSchemaResponse": {
@ -1021,6 +1045,118 @@
}
]
},
"UnrelatedTable": {
"properties": {
"table": {
"$ref": "#/components/schemas/TableName"
},
"type": {
"enum": [
"unrelated"
],
"type": "string"
}
},
"required": [
"table",
"type"
],
"type": "object"
},
"RelatedTable": {
"properties": {
"relationship": {
"type": "string"
},
"type": {
"enum": [
"related"
],
"type": "string"
}
},
"required": [
"relationship",
"type"
],
"type": "object"
},
"ExistsInTable": {
"discriminator": {
"mapping": {
"related": "RelatedTable",
"unrelated": "UnrelatedTable"
},
"propertyName": "type"
},
"oneOf": [
{
"$ref": "#/components/schemas/UnrelatedTable"
},
{
"$ref": "#/components/schemas/RelatedTable"
}
]
},
"Expression": {
"discriminator": {
"mapping": {
"and": "AndExpression",
"binary_arr_op": "ApplyBinaryArrayComparisonOperator",
"binary_op": "ApplyBinaryComparisonOperator",
"exists": "ExistsExpression",
"not": "NotExpression",
"or": "OrExpression",
"unary_op": "ApplyUnaryComparisonOperator"
},
"propertyName": "type"
},
"oneOf": [
{
"$ref": "#/components/schemas/ExistsExpression"
},
{
"$ref": "#/components/schemas/ApplyBinaryArrayComparisonOperator"
},
{
"$ref": "#/components/schemas/OrExpression"
},
{
"$ref": "#/components/schemas/ApplyUnaryComparisonOperator"
},
{
"$ref": "#/components/schemas/ApplyBinaryComparisonOperator"
},
{
"$ref": "#/components/schemas/NotExpression"
},
{
"$ref": "#/components/schemas/AndExpression"
}
]
},
"ExistsExpression": {
"properties": {
"in_table": {
"$ref": "#/components/schemas/ExistsInTable"
},
"type": {
"enum": [
"exists"
],
"type": "string"
},
"where": {
"$ref": "#/components/schemas/Expression"
}
},
"required": [
"in_table",
"where",
"type"
],
"type": "object"
},
"BinaryArrayComparisonOperator": {
"additionalProperties": true,
"anyOf": [
@ -1042,7 +1178,8 @@
"type": "string"
},
"path": {
"description": "The relationship path from the current query table to the table that contains the specified column. Empty array means the current query table.",
"default": [],
"description": "The path to the table that contains the specified column. Missing or empty array means the current table. [\"$\"] means the query table. No other values are supported at this time.",
"items": {
"type": "string"
},
@ -1050,7 +1187,6 @@
}
},
"required": [
"path",
"name"
],
"type": "object"
@ -1084,39 +1220,6 @@
],
"type": "object"
},
"Expression": {
"discriminator": {
"mapping": {
"and": "AndExpression",
"binary_arr_op": "ApplyBinaryArrayComparisonOperator",
"binary_op": "ApplyBinaryComparisonOperator",
"not": "NotExpression",
"or": "OrExpression",
"unary_op": "ApplyUnaryComparisonOperator"
},
"propertyName": "type"
},
"oneOf": [
{
"$ref": "#/components/schemas/ApplyBinaryArrayComparisonOperator"
},
{
"$ref": "#/components/schemas/OrExpression"
},
{
"$ref": "#/components/schemas/ApplyUnaryComparisonOperator"
},
{
"$ref": "#/components/schemas/ApplyBinaryComparisonOperator"
},
{
"$ref": "#/components/schemas/NotExpression"
},
{
"$ref": "#/components/schemas/AndExpression"
}
]
},
"OrExpression": {
"properties": {
"expressions": {

View File

@ -16,10 +16,14 @@ export type { ColumnCountAggregate } from './models/ColumnCountAggregate';
export type { ColumnField } from './models/ColumnField';
export type { ColumnFieldValue } from './models/ColumnFieldValue';
export type { ColumnInfo } from './models/ColumnInfo';
export type { ComparisonCapabilities } from './models/ComparisonCapabilities';
export type { ComparisonColumn } from './models/ComparisonColumn';
export type { ComparisonValue } from './models/ComparisonValue';
export type { ConfigSchemaResponse } from './models/ConfigSchemaResponse';
export type { Constraint } from './models/Constraint';
export type { CrossTableComparisonCapabilities } from './models/CrossTableComparisonCapabilities';
export type { ExistsExpression } from './models/ExistsExpression';
export type { ExistsInTable } from './models/ExistsInTable';
export type { ExplainCapabilities } from './models/ExplainCapabilities';
export type { ExplainResponse } from './models/ExplainResponse';
export type { Expression } from './models/Expression';
@ -48,6 +52,7 @@ export type { Query } from './models/Query';
export type { QueryCapabilities } from './models/QueryCapabilities';
export type { QueryRequest } from './models/QueryRequest';
export type { QueryResponse } from './models/QueryResponse';
export type { RelatedTable } from './models/RelatedTable';
export type { Relationship } from './models/Relationship';
export type { RelationshipCapabilities } from './models/RelationshipCapabilities';
export type { RelationshipField } from './models/RelationshipField';
@ -65,3 +70,4 @@ export type { TableInfo } from './models/TableInfo';
export type { TableName } from './models/TableName';
export type { TableRelationships } from './models/TableRelationships';
export type { UnaryComparisonOperator } from './models/UnaryComparisonOperator';
export type { UnrelatedTable } from './models/UnrelatedTable';

View File

@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
import type { ComparisonCapabilities } from './ComparisonCapabilities';
import type { ExplainCapabilities } from './ExplainCapabilities';
import type { GraphQLTypeDefinitions } from './GraphQLTypeDefinitions';
import type { MetricsCapabilities } from './MetricsCapabilities';
@ -12,6 +13,7 @@ import type { ScalarTypesCapabilities } from './ScalarTypesCapabilities';
import type { SubscriptionCapabilities } from './SubscriptionCapabilities';
export type Capabilities = {
comparisons?: ComparisonCapabilities;
explain?: ExplainCapabilities;
graphqlSchema?: GraphQLTypeDefinitions;
metrics?: MetricsCapabilities;

View File

@ -0,0 +1,10 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { CrossTableComparisonCapabilities } from './CrossTableComparisonCapabilities';
export type ComparisonCapabilities = {
cross_table?: CrossTableComparisonCapabilities;
};

View File

@ -8,8 +8,8 @@ export type ComparisonColumn = {
*/
name: string;
/**
* The relationship path from the current query table to the table that contains the specified column. Empty array means the current query table.
* The path to the table that contains the specified column. Missing or empty array means the current table. ["$"] means the query table. No other values are supported at this time.
*/
path: Array<string>;
path?: Array<string>;
};

View File

@ -0,0 +1,11 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CrossTableComparisonCapabilities = {
/**
* Does the agent support comparisons that involve related tables (ie. joins)?
*/
supports_relations: boolean;
};

View File

@ -0,0 +1,13 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ExistsInTable } from './ExistsInTable';
import type { Expression } from './Expression';
export type ExistsExpression = {
in_table: ExistsInTable;
type: 'exists';
where: Expression;
};

View File

@ -0,0 +1,9 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { RelatedTable } from './RelatedTable';
import type { UnrelatedTable } from './UnrelatedTable';
export type ExistsInTable = (UnrelatedTable | RelatedTable);

View File

@ -6,8 +6,9 @@ import type { AndExpression } from './AndExpression';
import type { ApplyBinaryArrayComparisonOperator } from './ApplyBinaryArrayComparisonOperator';
import type { ApplyBinaryComparisonOperator } from './ApplyBinaryComparisonOperator';
import type { ApplyUnaryComparisonOperator } from './ApplyUnaryComparisonOperator';
import type { ExistsExpression } from './ExistsExpression';
import type { NotExpression } from './NotExpression';
import type { OrExpression } from './OrExpression';
export type Expression = (ApplyBinaryArrayComparisonOperator | OrExpression | ApplyUnaryComparisonOperator | ApplyBinaryComparisonOperator | NotExpression | AndExpression);
export type Expression = (ExistsExpression | ApplyBinaryArrayComparisonOperator | OrExpression | ApplyUnaryComparisonOperator | ApplyBinaryComparisonOperator | NotExpression | AndExpression);

View File

@ -0,0 +1,9 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type RelatedTable = {
relationship: string;
type: 'related';
};

View File

@ -0,0 +1,11 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { TableName } from './TableName';
export type UnrelatedTable = {
table: TableName;
type: 'unrelated';
};

View File

@ -24,7 +24,7 @@
},
"dc-api-types": {
"name": "@hasura/dc-api-types",
"version": "0.4.0",
"version": "0.5.0",
"license": "Apache-2.0",
"devDependencies": {
"@tsconfig/node16": "^1.0.3",
@ -631,7 +631,7 @@
"license": "Apache-2.0",
"dependencies": {
"@fastify/cors": "^7.0.0",
"@hasura/dc-api-types": "0.4.0",
"@hasura/dc-api-types": "0.5.0",
"fastify": "^3.29.0",
"mathjs": "^11.0.0",
"pino-pretty": "^8.0.0",
@ -1389,9 +1389,10 @@
"license": "Apache-2.0",
"dependencies": {
"@fastify/cors": "^8.1.0",
"@hasura/dc-api-types": "0.4.0",
"@hasura/dc-api-types": "0.5.0",
"fastify": "^4.4.0",
"fastify-metrics": "^9.2.1",
"nanoid": "^3.3.4",
"openapi3-ts": "^2.0.2",
"pino-pretty": "^8.1.0",
"sequelize": "^6.21.2",
@ -2315,6 +2316,17 @@
"version": "2.1.2",
"license": "MIT"
},
"sqlite/node_modules/nanoid": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"sqlite/node_modules/negotiator": {
"version": "0.6.3",
"license": "MIT",
@ -3115,7 +3127,7 @@
"version": "file:reference",
"requires": {
"@fastify/cors": "^7.0.0",
"@hasura/dc-api-types": "0.4.0",
"@hasura/dc-api-types": "0.5.0",
"@tsconfig/node16": "^1.0.3",
"@types/node": "^16.11.49",
"@types/xml2js": "^0.4.11",
@ -3606,13 +3618,14 @@
"version": "file:sqlite",
"requires": {
"@fastify/cors": "^8.1.0",
"@hasura/dc-api-types": "0.4.0",
"@hasura/dc-api-types": "0.5.0",
"@tsconfig/node16": "^1.0.3",
"@types/node": "^16.11.49",
"@types/sqlite3": "^3.1.8",
"@types/xml2js": "^0.4.11",
"fastify": "^4.4.0",
"fastify-metrics": "^9.2.1",
"nanoid": "^3.3.4",
"openapi3-ts": "^2.0.2",
"pino-pretty": "^8.1.0",
"sequelize": "^6.21.2",
@ -4240,6 +4253,11 @@
"ms": {
"version": "2.1.2"
},
"nanoid": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw=="
},
"negotiator": {
"version": "0.6.3",
"optional": true

View File

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

View File

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

View File

@ -19,6 +19,11 @@ const scalarTypes: ScalarTypesCapabilities = {
const capabilities: Capabilities = {
relationships: {},
comparisons: {
cross_table: {
supports_relations: true
}
},
graphqlSchema: schemaDoc,
scalarTypes: scalarTypes
}

View File

@ -1,5 +1,5 @@
import { QueryRequest, TableRelationships, Relationship, Query, Field, OrderBy, Expression, BinaryComparisonOperator, UnaryComparisonOperator, BinaryArrayComparisonOperator, ComparisonColumn, ComparisonValue, Aggregate, SingleColumnAggregate, ColumnCountAggregate, TableName, OrderByElement, OrderByRelation } from "@hasura/dc-api-types";
import { coerceUndefinedToNull, crossProduct, tableNameEquals, unreachable, zip } from "./util";
import { QueryRequest, TableRelationships, Relationship, Query, Field, OrderBy, Expression, BinaryComparisonOperator, UnaryComparisonOperator, BinaryArrayComparisonOperator, ComparisonColumn, ComparisonValue, Aggregate, SingleColumnAggregate, ColumnCountAggregate, TableName, OrderByElement, OrderByRelation, ExistsInTable, ExistsExpression } from "@hasura/dc-api-types";
import { coerceUndefinedToNull, filterIterable, mapIterable, reduceAndIterable, reduceOrIterable, skipIterable, tableNameEquals, takeIterable, unreachable } from "./util";
import * as math from "mathjs";
type RelationshipName = string
@ -95,7 +95,7 @@ const getUnaryComparisonOperatorEvaluator = (operator: UnaryComparisonOperator):
};
const prettyPrintComparisonColumn = (comparisonColumn: ComparisonColumn): string => {
return comparisonColumn.path.concat(comparisonColumn.name).map(p => `[${p}]`).join(".");
return (comparisonColumn.path ?? []).concat(comparisonColumn.name).map(p => `[${p}]`).join(".");
}
const prettyPrintComparisonValue = (comparisonValue: ComparisonValue): string => {
@ -107,7 +107,20 @@ const prettyPrintComparisonValue = (comparisonValue: ComparisonValue): string =>
default:
return unreachable(comparisonValue["type"]);
}
}
};
const prettyPrintTableName = (tableName: TableName): string => {
return tableName.map(t => `[${t}]`).join(".");
};
const prettyPrintExistsInTable = (existsInTable: ExistsInTable): string => {
switch (existsInTable.type) {
case "related":
return `RELATED TABLE VIA [${existsInTable.relationship}]`;
case "unrelated":
return `UNRELATED TABLE ${prettyPrintTableName(existsInTable.table)}`;
}
};
export const prettyPrintExpression = (e: Expression): string => {
switch (e.type) {
@ -121,32 +134,31 @@ export const prettyPrintExpression = (e: Expression): string => {
: "false";
case "not":
return `!(${prettyPrintExpression(e.expression)})`;
case "exists":
return `(EXISTS IN ${prettyPrintExistsInTable(e.in_table)} WHERE (${prettyPrintExpression(e.where)}))`
case "binary_op":
return `([${prettyPrintComparisonColumn(e.column)}] ${prettyPrintBinaryComparisonOperator(e.operator)} ${prettyPrintComparisonValue(e.value)})`;
return `(${prettyPrintComparisonColumn(e.column)} ${prettyPrintBinaryComparisonOperator(e.operator)} ${prettyPrintComparisonValue(e.value)})`;
case "binary_arr_op":
return `([${prettyPrintComparisonColumn(e.column)}] ${prettyPrintBinaryArrayComparisonOperator(e.operator)} (${e.values.join(", ")}))`;
return `(${prettyPrintComparisonColumn(e.column)} ${prettyPrintBinaryArrayComparisonOperator(e.operator)} (${e.values.join(", ")}))`;
case "unary_op":
return `([${prettyPrintComparisonColumn(e.column)}] ${prettyPrintUnaryComparisonOperator(e.operator)})`;
return `(${prettyPrintComparisonColumn(e.column)} ${prettyPrintUnaryComparisonOperator(e.operator)})`;
default:
return unreachable(e["type"]);
}
};
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,
getComparisonColumnValue: (comparisonColumn: ComparisonColumn, row: Record<string, ScalarValue>) => ScalarValue,
performExistsSubquery: (exists: ExistsExpression, row: Record<string, ScalarValue>) => boolean
) => (row: Record<string, ScalarValue>) => {
const makeFilterPredicate = (expression: Expression | null, getComparisonColumnValues: (comparisonColumn: ComparisonColumn, row: Record<string, ScalarValue>) => ScalarValue[]) => (row: Record<string, ScalarValue>) => {
const extractComparisonValueScalars = (comparisonValue: ComparisonValue): ScalarValue[] => {
const extractComparisonValueScalar = (comparisonValue: ComparisonValue): ScalarValue => {
switch (comparisonValue.type) {
case "column":
return getComparisonColumnValues(comparisonValue.column, row);
return getComparisonColumnValue(comparisonValue.column, row);
case "scalar":
return [comparisonValue.value];
return comparisonValue.value;
default:
return unreachable(comparisonValue["type"]);
}
@ -155,25 +167,23 @@ const makeFilterPredicate = (expression: Expression | null, getComparisonColumnV
const evaluate = (e: Expression): boolean => {
switch (e.type) {
case "and":
return e.expressions.map(evaluate).reduce((b1, b2) => b1 && b2, true);
return reduceAndIterable(mapIterable(e.expressions, evaluate));
case "or":
return e.expressions.map(evaluate).reduce((b1, b2) => b1 || b2, false);
return reduceOrIterable(mapIterable(e.expressions, evaluate));
case "not":
return !evaluate(e.expression);
case "exists":
return performExistsSubquery(e, row);
case "binary_op":
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));
const binOpColumnVal = getComparisonColumnValue(e.column, row);
const binOpComparisonVal = extractComparisonValueScalar(e.value);
return getBinaryComparisonOperatorEvaluator(e.operator)(binOpColumnVal, binOpComparisonVal);
case "binary_arr_op":
const inColumnVals = getComparisonColumnValues(e.column, row);
return inColumnVals.some(columnVal => getBinaryArrayComparisonOperatorEvaluator(e.operator)(columnVal, e.values));
const inColumnVal = getComparisonColumnValue(e.column, row);
return getBinaryArrayComparisonOperatorEvaluator(e.operator)(inColumnVal, e.values);
case "unary_op":
const unOpColumnVals = getComparisonColumnValues(e.column, row);
return unOpColumnVals.some(columnVal => getUnaryComparisonOperatorEvaluator(e.operator)(columnVal));
const unOpColumnVal = getComparisonColumnValue(e.column, row);
return getUnaryComparisonOperatorEvaluator(e.operator)(unOpColumnVal);
default:
return unreachable(e["type"]);
}
@ -181,6 +191,44 @@ const makeFilterPredicate = (expression: Expression | null, getComparisonColumnV
return expression ? evaluate(expression) : true;
};
const makePerformExistsSubquery = (
findRelationship: (relationshipName: RelationshipName) => Relationship,
performSubquery: (sourceRow: Record<string, ScalarValue>, tableName: TableName, query: Query) => QueryResponse
) => (
exists: ExistsExpression,
row: Record<string, ScalarValue>
): boolean => {
const [targetTable, joinExpression] = (() => {
switch (exists.in_table.type) {
case "related":
const relationship = findRelationship(exists.in_table.relationship);
const joinExpression = createFilterExpressionForRelationshipJoin(row, relationship);
return [relationship.target_table, joinExpression];
case "unrelated":
return [exists.in_table.table, undefined];
default:
return unreachable(exists.in_table["type"]);
}
})();
if (joinExpression === null)
return false;
const subquery: Query = {
aggregates: {
count: { type: "star_count" }
},
limit: 1, // We only need one row to exist to satisfy this expresion, this short circuits some filtering
where: joinExpression !== undefined
? { type: "and", expressions: [joinExpression, exists.where] } // Important: the join expression goes first to ensure short circuiting prevents unnecessary subqueries
: exists.where
};
const results = performSubquery(row, targetTable, subquery);
return (results.aggregates?.count ?? 0) > 0;
}
const buildQueryForPathedOrderByElement = (orderByElement: OrderByElement, orderByRelations: Record<RelationshipName, OrderByRelation>): Query => {
const [relationshipName, ...remainingPath] = orderByElement.target_path;
if (relationshipName === undefined) {
@ -317,10 +365,9 @@ const sortRows = (rows: Record<string, ScalarValue>[], orderBy: OrderBy, getOrde
})
.map(([row, _valueCache]) => row);
const paginateRows = (rows: Record<string, ScalarValue>[], offset: number | null, limit: number | null): Record<string, ScalarValue>[] => {
const start = offset ?? 0;
const end = limit ? start + limit : rows.length;
return rows.slice(start, end);
const paginateRows = (rows: Iterable<Record<string, ScalarValue>>, offset: number | null, limit: number | null): Iterable<Record<string, ScalarValue>> => {
const skipped = offset !== null ? skipIterable(rows, offset) : rows;
return limit !== null ? takeIterable(skipped, limit) : skipped;
};
const makeFindRelationship = (allTableRelationships: TableRelationships[], tableName: TableName) => (relationshipName: RelationshipName): Relationship => {
@ -368,60 +415,23 @@ const addRelationshipFilterToQuery = (row: Record<string, ScalarValue>, relation
const existingFilters = subquery.where ? [subquery.where] : []
return {
...subquery,
limit: relationship.relationship_type === "object" ? 1 : subquery.limit, // If it's an object relationship, we expect only one result to come back, so we can optimise the query by limiting the filtering stop after one row
where: { type: "and", expressions: [filterExpression, ...existingFilters] }
};
}
};
const buildFieldsForPathedComparisonColumn = (comparisonColumn: ComparisonColumn): Record<string, Field> => {
const [relationshipName, ...remainingPath] = comparisonColumn.path;
if (relationshipName === undefined) {
return {
[comparisonColumn.name]: { type: "column", column: comparisonColumn.name }
};
const makeGetComparisonColumnValue = (parentQueryRowChain: Record<string, ScalarValue>[]) => (comparisonColumn: ComparisonColumn, row: Record<string, ScalarValue>): ScalarValue => {
const path = comparisonColumn.path ?? [];
if (path.length === 0) {
return coerceUndefinedToNull(row[comparisonColumn.name]);
} else if (path.length === 1 && path[0] === "$") {
const queryRow = parentQueryRowChain.length === 0
? row
: parentQueryRowChain[0];
return coerceUndefinedToNull(queryRow[comparisonColumn.name]);
} else {
const innerComparisonColumn = { ...comparisonColumn, path: remainingPath };
return {
[relationshipName]: { type: "relationship", relationship: relationshipName, query: { fields: buildFieldsForPathedComparisonColumn(innerComparisonColumn) } }
};
}
};
const extractScalarValuesFromFieldPath = (fieldPath: string[], row: ProjectedRow): ScalarValue[] => {
const [fieldName, ...remainingPath] = fieldPath;
const fieldValue = row[fieldName];
if (remainingPath.length === 0) {
if (fieldValue === null || typeof fieldValue !== "object") {
return [fieldValue];
} else {
throw new Error("Field path did not end in a column field value");
}
} else {
if (fieldValue !== null && typeof fieldValue === "object") {
return (fieldValue.rows ?? []).flatMap(row => extractScalarValuesFromFieldPath(remainingPath, row));
} else {
throw new Error(`Found a column field value in the middle of a field path: ${fieldPath}`);
}
}
};
const makeGetComparisonColumnValues = (findRelationship: (relationshipName: RelationshipName) => Relationship, performQuery: (tableName: TableName, query: Query) => QueryResponse) => (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).rows ?? [];
const fieldPath = remainingPath.concat(comparisonColumn.name);
return rows.flatMap(row => extractScalarValuesFromFieldPath(fieldPath, row));
}
throw new Error(`Unsupported path on ComparisonColumn: ${prettyPrintComparisonColumn(comparisonColumn)}`);
}
};
@ -515,20 +525,24 @@ const calculateAggregates = (rows: Record<string, ScalarValue>[], aggregateReque
};
export const queryData = (getTable: (tableName: TableName) => Record<string, ScalarValue>[] | undefined, queryRequest: QueryRequest) => {
const performQuery = (tableName: TableName, query: Query): QueryResponse => {
const performQuery = (parentQueryRowChain: Record<string, ScalarValue>[], tableName: TableName, query: Query): QueryResponse => {
const rows = getTable(tableName);
if (rows === undefined) {
throw `${tableName} is not a valid table`;
}
const performSubquery = (sourceRow: Record<string, ScalarValue>, tableName: TableName, query: Query): QueryResponse => {
return performQuery([...parentQueryRowChain, sourceRow], tableName, query);
};
const findRelationship = makeFindRelationship(queryRequest.table_relationships, tableName);
const getComparisonColumnValues = makeGetComparisonColumnValues(findRelationship, performQuery);
const getOrderByElementValue = makeGetOrderByElementValue(findRelationship, performQuery);
const getComparisonColumnValue = makeGetComparisonColumnValue(parentQueryRowChain);
const performExistsSubquery = makePerformExistsSubquery(findRelationship, performSubquery);
const getOrderByElementValue = makeGetOrderByElementValue(findRelationship, performNewQuery);
const filteredRows = rows.filter(makeFilterPredicate(query.where ?? null, getComparisonColumnValues));
const sortedRows = query.order_by ? sortRows(filteredRows, query.order_by, getOrderByElementValue) : filteredRows;
const paginatedRows = paginateRows(sortedRows, query.offset ?? null, query.limit ?? null);
const filteredRows = filterIterable(rows, makeFilterPredicate(query.where ?? null, getComparisonColumnValue, performExistsSubquery));
const sortedRows = query.order_by ? sortRows(Array.from(filteredRows), query.order_by, getOrderByElementValue) : filteredRows;
const paginatedRows = Array.from(paginateRows(sortedRows, query.offset ?? null, query.limit ?? null));
const projectedRows = query.fields
? paginatedRows.map(projectRow(query.fields, findRelationship, performQuery))
? paginatedRows.map(projectRow(query.fields, findRelationship, performNewQuery))
: null;
const calculatedAggregates = query.aggregates
? calculateAggregates(paginatedRows, query.aggregates)
@ -538,12 +552,13 @@ export const queryData = (getTable: (tableName: TableName) => Record<string, Sca
rows: projectedRows,
}
}
const performNewQuery = (tableName: TableName, query: Query): QueryResponse => performQuery([], tableName, query);
return performQuery(queryRequest.table, queryRequest.query);
return performNewQuery(queryRequest.table, queryRequest.query);
};
const unknownOperator = (x: string): never => { throw new Error(`Unknown operator: ${x}`) };
const expectedString = (x: string): never => { throw new Error(`Expected string value but got ${x}`) };
const expectedNumber = (x: string): never => { throw new Error(`Expected number value but got ${x}`) };
const expectedNumber = (x: string): never => { throw new Error(`Expected number value but got ${x}`) };

View File

@ -13,9 +13,52 @@ export const zip = <T, U>(arr1: T[], arr2: U[]): [T, U][] => {
return newArray;
};
export const crossProduct = <T, U>(arr1: T[], arr2: U[]): [T, U][] => {
return arr1.flatMap(a1 => arr2.map<[T,U]>(a2 => [a1, a2]));
};
export function* mapIterable<T, U>(iterable: Iterable<T>, fn: (item: T) => U) {
for (const x of iterable) {
yield fn(x);
}
}
export function* filterIterable<T>(iterable: Iterable<T>, fn: (item: T) => boolean) {
for (const x of iterable) {
if (fn(x)) yield x;
}
}
export function* skipIterable<T>(iterable: Iterable<T>, count: number) {
let currentCount = 0;
for (const x of iterable) {
if (currentCount >= count) {
yield x;
} else {
currentCount++;
}
}
}
export function* takeIterable<T>(iterable: Iterable<T>, count: number) {
let currentCount = 0;
for (const x of iterable) {
if (currentCount >= count) return;
yield x;
currentCount++;
}
}
export const reduceAndIterable = (iterable: Iterable<boolean>): boolean => {
for (const x of iterable) {
if (x === false) return false;
}
return true;
}
export const reduceOrIterable = (iterable: Iterable<boolean>): boolean => {
for (const x of iterable) {
if (x === true) return true;
}
return false;
}
export const tableNameEquals = (tableName1: TableName) => (tableName2: TableName): boolean => {
if (tableName1.length !== tableName2.length)

View File

@ -10,9 +10,10 @@
"license": "Apache-2.0",
"dependencies": {
"@fastify/cors": "^8.1.0",
"@hasura/dc-api-types": "0.4.0",
"@hasura/dc-api-types": "0.5.0",
"fastify": "^4.4.0",
"fastify-metrics": "^9.2.1",
"nanoid": "^3.3.4",
"openapi3-ts": "^2.0.2",
"pino-pretty": "^8.1.0",
"sequelize": "^6.21.2",
@ -54,7 +55,7 @@
"license": "MIT"
},
"node_modules/@hasura/dc-api-types": {
"version": "0.4.0",
"version": "0.5.0",
"license": "Apache-2.0",
"devDependencies": {
"@tsconfig/node16": "^1.0.3",
@ -518,6 +519,17 @@
"version": "1.0.2",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/openapi3-ts": {
"version": "2.0.2",
"license": "MIT",

View File

@ -22,9 +22,10 @@
},
"dependencies": {
"@fastify/cors": "^8.1.0",
"@hasura/dc-api-types": "0.4.0",
"@hasura/dc-api-types": "0.5.0",
"fastify-metrics": "^9.2.1",
"fastify": "^4.4.0",
"nanoid": "^3.3.4",
"openapi3-ts": "^2.0.2",
"pino-pretty": "^8.1.0",
"sequelize": "^6.21.2",

View File

@ -9,6 +9,11 @@ export const capabilitiesResponse: CapabilitiesResponse = {
supportsPrimaryKeys: true
},
relationships: {},
comparisons: {
cross_table: {
supports_relations: true
}
},
explain: {},
... ( envToBool('METRICS') ? { metrics: {} } : {} )
},

View File

@ -19,10 +19,14 @@ import {
OrderDirection,
UnaryComparisonOperator,
ExplainResponse,
ExistsExpression,
} from "@hasura/dc-api-types";
import { customAlphabet } from "nanoid";
const SqlString = require('sqlstring-sqlite');
const nanoid = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789-_", 6);
/** Helper type for convenience. Uses the sqlstring-sqlite library, but should ideally use the function in sequalize.
*/
type Fields = Record<string, Field>
@ -82,102 +86,137 @@ function json_object(relationships: Array<TableRelationships>, fields: Fields, t
return tag('json_object', `JSON_OBJECT(${result})`);
}
function where_clause(relationships: Array<TableRelationships>, expression: Expression, tableName: TableName): string {
switch(expression.type) {
case "not":
const aNot = where_clause(relationships, expression.expression, tableName);
return `(NOT ${aNot})`;
case "and":
const aAnd = expression.expressions.flatMap(x => where_clause(relationships, x, tableName));
return aAnd.length > 0
? `(${aAnd.join(" AND ")})`
: "(1 = 1)" // true
case "or":
const aOr = expression.expressions.flatMap(x => where_clause(relationships, x, tableName));
return aOr.length > 0
? `(${aOr.join(" OR ")})`
: "(1 = 0)" // false
case "unary_op":
const uop = uop_op(expression.operator);
return expression.column.path.length < 1
? `(${escapeIdentifier(expression.column.name)} ${uop})`
: exists(relationships, expression.column, tableName, uop);
case "binary_op":
const bop = bop_op(expression.operator);
return expression.column.path.length < 1
? `${escapeIdentifier(expression.column.name)} ${bop} ${bop_val(expression.value, tableName)}`
: exists(relationships, expression.column, tableName, `${bop} ${bop_val(expression.value, tableName)}`);
case "binary_arr_op":
const bopA = bop_array(expression.operator);
return expression.column.path.length < 1
? `(${escapeIdentifier(expression.column.name)} ${bopA} (${expression.values.map(v => escapeString(v)).join(", ")}))`
: exists(relationships,expression.column,tableName, `${bopA} (${expression.values.map(v => escapeString(v)).join(", ")})`);
function where_clause(relationships: Array<TableRelationships>, expression: Expression, queryTableName: TableName): string {
// The query table doesn't have an alias, so we refer to it by name directly
const queryTableAlias = escapeTableName(queryTableName);
const generateWhere = (expression: Expression, currentTableName: TableName, currentTableAlias: string): string => {
switch(expression.type) {
case "not":
const aNot = generateWhere(expression.expression, currentTableName, currentTableAlias);
return `(NOT ${aNot})`;
case "and":
const aAnd = expression.expressions.flatMap(x => generateWhere(x, currentTableName, currentTableAlias));
return aAnd.length > 0
? `(${aAnd.join(" AND ")})`
: "(1 = 1)" // true
case "or":
const aOr = expression.expressions.flatMap(x => generateWhere(x, currentTableName, currentTableAlias));
return aOr.length > 0
? `(${aOr.join(" OR ")})`
: "(1 = 0)" // false
case "exists":
const joinInfo = calculateExistsJoinInfo(relationships, expression, currentTableName, currentTableAlias);
const subqueryWhere = generateWhere(expression.where, joinInfo.joinTableName, joinInfo.joinTableAlias);
const whereComparisons = [...joinInfo.joinComparisonFragments, subqueryWhere].join(" AND ");
return tag('exists',`EXISTS (SELECT 1 FROM ${escapeTableName(joinInfo.joinTableName)} AS ${joinInfo.joinTableAlias} WHERE ${whereComparisons})`);
case "unary_op":
const uop = uop_op(expression.operator);
const columnFragment = generateComparisonColumnFragment(expression.column, queryTableAlias, currentTableAlias);
return `(${columnFragment} ${uop})`;
case "binary_op":
const bopLhs = generateComparisonColumnFragment(expression.column, queryTableAlias, currentTableAlias);
const bop = bop_op(expression.operator);
const bopRhs = generateComparisonValueFragment(expression.value, queryTableAlias, currentTableAlias);
return `${bopLhs} ${bop} ${bopRhs}`;
case "binary_arr_op":
const bopALhs = generateComparisonColumnFragment(expression.column, queryTableAlias, currentTableAlias);
const bopA = bop_array(expression.operator);
const bopARhsValues = expression.values.map(v => escapeString(v)).join(", ");
return `(${bopALhs} ${bopA} (${bopARhsValues}))`;
default:
return unreachable(expression['type']);
}
};
return generateWhere(expression, queryTableName, queryTableAlias);
}
type ExistsJoinInfo = {
joinTableName: TableName,
joinTableAlias: string,
joinComparisonFragments: string[]
}
function calculateExistsJoinInfo(allTableRelationships: Array<TableRelationships>, exists: ExistsExpression, sourceTableName: TableName, sourceTableAlias: string): ExistsJoinInfo {
switch (exists.in_table.type) {
case "related":
const tableRelationships = find_table_relationship(allTableRelationships, sourceTableName);
const relationship = tableRelationships.relationships[exists.in_table.relationship];
const joinTableAlias = generateIdentifierAlias(extractRawTableName(relationship.target_table));
const joinComparisonFragments = omap(
relationship.column_mapping,
(sourceColumnName, targetColumnName) =>
`${sourceTableAlias}.${escapeIdentifier(sourceColumnName)} = ${joinTableAlias}.${escapeIdentifier(targetColumnName)}`
);
return {
joinTableName: relationship.target_table,
joinTableAlias,
joinComparisonFragments,
};
case "unrelated":
return {
joinTableName: exists.in_table.table,
joinTableAlias: generateIdentifierAlias(extractRawTableName(exists.in_table.table)),
joinComparisonFragments: []
};
default:
return unreachable(expression['type']);
return unreachable(exists.in_table["type"]);
}
}
function exists(ts: Array<TableRelationships>, c: ComparisonColumn, t: TableName, o: string): string {
// NOTE: An N suffix doesn't guarantee that conflicts are avoided.
const r = join_path(ts, t, c.path, 0);
const f = `FROM ${r.f.map(x => `${x.from} AS ${x.as}`).join(', ')}`;
return tag('exists',`EXISTS (SELECT 1 ${f} WHERE ${[...r.j, `${last(r.f).as}.${escapeIdentifier(c.name)} ${o}`].join(' AND ')})`);
}
/** Develops a from clause for an operation with a path - a relationship referenced column
*
* Artist [Albums] Title
* FROM Album Album_PATH_XX ...
* WHERE Album_PATH_XX.ArtistId = Artist.ArtistId
* Album_PATH_XX.Title IS NULL
*
* @param ts
* @param table
* @param path
* @returns the from clause for the EXISTS query
*/
function join_path(ts: TableRelationships[], table: TableName, path: Array<string>, level: number): {f: Array<{from: string, as: string}>, j: string[]} {
const r = find_table_relationship(ts, table);
if(path.length < 1) {
return {f: [], j: []};
} else if(r === null) {
throw new Error(`Couldn't find relationship ${ts}, ${table.join(".")} - This shouldn't happen.`);
function generateComparisonColumnFragment(comparisonColumn: ComparisonColumn, queryTableAlias: string, currentTableAlias: string): string {
const path = comparisonColumn.path ?? [];
if (path.length === 0) {
return `${currentTableAlias}.${escapeIdentifier(comparisonColumn.name)}`
} else if (path.length === 1 && path[0] === "$") {
return `${queryTableAlias}.${escapeIdentifier(comparisonColumn.name)}`
} else {
const x = r.relationships[path[0]];
const n = join_path(ts, x.target_table, path.slice(1), level+1);
const m =
omap(
x.column_mapping,
(sourceColumnName,targetColumnName) =>
`${depthQualifyIdentifier(level-1,extractRawTableName(table))}.${escapeIdentifier(sourceColumnName)} = ${depthQualifyIdentifier(level, extractRawTableName(x.target_table))}.${escapeIdentifier(targetColumnName)}`
)
.join(' AND ');
return {f: [{from: escapeTableName(x.target_table), as: depthQualifyIdentifier(level, extractRawTableName(x.target_table))}, ...n.f], j: [m, ...n.j]};
throw new Error(`Unsupported path on ComparisonColumn: ${[...path, comparisonColumn.name].join(".")}`);
}
}
function depthQualifyIdentifier(depth: number, identifier:string): string {
if(depth < 0) {
return escapeIdentifier(identifier);
} else {
return escapeIdentifier(`${identifier}_${depth}`);
function generateComparisonValueFragment(comparisonValue: ComparisonValue, queryTableAlias: string, currentTableAlias: string): string {
switch (comparisonValue.type) {
case "column":
return generateComparisonColumnFragment(comparisonValue.column, queryTableAlias, currentTableAlias);
case "scalar":
return escapeString(comparisonValue.value);
default:
return unreachable(comparisonValue["type"]);
}
}
function generateIdentifierAlias(identifier: string): string {
const randomSuffix = nanoid();
return escapeIdentifier(`${identifier}_${randomSuffix}`);
}
/**
*
* @param ts Array of Table Relationships
* @param t Table Name
* @returns Relationships matching table-name
*/
function find_table_relationship(ts: Array<TableRelationships>, t: TableName): TableRelationships | null {
function find_table_relationship(ts: Array<TableRelationships>, t: TableName): TableRelationships {
for(var i = 0; i < ts.length; i++) {
const r = ts[i];
if(tableNameEquals(r.source_table)(t)) {
return r;
}
}
return null;
throw new Error(`Couldn't find relationship ${ts}, ${t.join(".")} - This shouldn't happen.`);
}
function cast_aggregate_function(f: string): string {
@ -300,15 +339,6 @@ function relationship(ts: Array<TableRelationships>, r: Relationship, field: Rel
}
}
// TODO: There is a bug in this implementation where vals can reference columns with paths.
function bop_col(c: ComparisonColumn, t: TableName): string {
if(c.path.length < 1) {
return tag('bop_col', `${escapeTableName(t)}.${escapeIdentifier(c.name)}`);
} else {
throw new Error(`bop_col shouldn't be handling paths.`);
}
}
function bop_array(o: BinaryArrayComparisonOperator): string {
switch(o) {
case 'in': return tag('bop_array','IN');
@ -336,13 +366,6 @@ function uop_op(o: UnaryComparisonOperator): string {
return tag('uop_op',result);
}
function bop_val(v: ComparisonValue, t: TableName): string {
switch(v.type) {
case "column": return tag('bop_val', bop_col(v.column, t));
case "scalar": return tag('bop_val', escapeString(v.value));
}
}
function orderDirection(orderDirection: OrderDirection): string {
switch (orderDirection) {
case "asc":
@ -493,17 +516,17 @@ export async function queryData(config: Config, sqlLogger: SqlLogger, queryReque
}
/**
*
*
* Constructs a query as per the `POST /query` endpoint but prefixes it with `EXPLAIN QUERY PLAN` before execution.
*
*
* Formatted result lines are included under the `lines` field. An initial blank line is included to work around a display bug.
*
*
* NOTE: The Explain related items are included here since they are a small extension of Queries, and another module may be overkill.
*
* @param config
* @param sqlLogger
* @param queryRequest
* @returns
*
* @param config
* @param sqlLogger
* @param queryRequest
* @returns
*/
export async function explain(config: Config, sqlLogger: SqlLogger, queryRequest: QueryRequest): Promise<ExplainResponse> {
const db = connect(config, sqlLogger);

View File

@ -25,6 +25,9 @@ extra-source-files:
src-rsr/mssql_table_metadata.sql
src-rsr/catalog_versions.txt
src-rsr/catalog_version.txt
-- Test data used by Data Connector agent tests
src/tests-dc-api/Test/Data/ChinookData.xml.gz
src/tests-dc-api/Test/Data/schema-tables.json
source-repository head
@ -1406,6 +1409,7 @@ test-suite tests-dc-api
, Test.QuerySpec
, Test.QuerySpec.AggregatesSpec
, Test.QuerySpec.BasicSpec
, Test.QuerySpec.FilteringSpec
, Test.QuerySpec.OrderBySpec
, Test.QuerySpec.RelationshipsSpec
, Test.SchemaSpec

View File

@ -13,6 +13,8 @@ module Hasura.Backends.DataConnector.API.V0.Capabilities
ScalarTypesCapabilities (..),
GraphQLTypeDefinitions,
RelationshipCapabilities (..),
ComparisonCapabilities (..),
CrossTableComparisonCapabilities (..),
MetricsCapabilities (..),
ExplainCapabilities (..),
CapabilitiesResponse (..),
@ -59,6 +61,7 @@ data Capabilities = Capabilities
cScalarTypes :: Maybe ScalarTypesCapabilities,
cGraphQLTypeDefinitions :: Maybe GraphQLTypeDefinitions,
cRelationships :: Maybe RelationshipCapabilities,
cComparisons :: Maybe ComparisonCapabilities,
cMetrics :: Maybe MetricsCapabilities,
cExplain :: Maybe ExplainCapabilities
}
@ -67,7 +70,7 @@ data Capabilities = Capabilities
deriving (FromJSON, ToJSON, ToSchema) via Autodocodec Capabilities
emptyCapabilities :: Capabilities
emptyCapabilities = Capabilities Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing
emptyCapabilities = Capabilities Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing
instance HasCodec Capabilities where
codec =
@ -79,6 +82,7 @@ instance HasCodec Capabilities where
<*> optionalField "scalarTypes" "The agent's scalar types and their capabilities" .= cScalarTypes
<*> optionalField "graphqlSchema" "A GraphQL Schema Document describing the agent's scalar types and input object types for comparison operators" .= cGraphQLTypeDefinitions
<*> optionalField "relationships" "The agent's relationship capabilities" .= cRelationships
<*> optionalField "comparisons" "The agent's comparison capabilities" .= cComparisons
<*> optionalField "metrics" "The agent's metrics capabilities" .= cMetrics
<*> optionalField "explain" "The agent's explain capabilities" .= cExplain
@ -219,6 +223,30 @@ instance HasCodec GraphQLTypeDefinitions where
. toList
. gtdTypeDefinitions
data ComparisonCapabilities = ComparisonCapabilities
{_ccCrossTableComparisonCapabilities :: Maybe CrossTableComparisonCapabilities}
deriving stock (Eq, Ord, Show, Generic, Data)
deriving anyclass (NFData, Hashable)
deriving (FromJSON, ToJSON, ToSchema) via Autodocodec ComparisonCapabilities
instance HasCodec ComparisonCapabilities where
codec =
object "ComparisonCapabilities" $
ComparisonCapabilities
<$> optionalFieldOrNull "cross_table" "The agent supports comparisons that involve tables other than the one being queried" .= _ccCrossTableComparisonCapabilities
data CrossTableComparisonCapabilities = CrossTableComparisonCapabilities
{_ctccSupportsRelations :: Bool}
deriving stock (Eq, Ord, Show, Generic, Data)
deriving anyclass (NFData, Hashable)
deriving (FromJSON, ToJSON, ToSchema) via Autodocodec CrossTableComparisonCapabilities
instance HasCodec CrossTableComparisonCapabilities where
codec =
object "CrossTableComparisonCapabilities" $
CrossTableComparisonCapabilities
<$> requiredField "supports_relations" "Does the agent support comparisons that involve related tables (ie. joins)?" .= _ctccSupportsRelations
data MetricsCapabilities = MetricsCapabilities {}
deriving stock (Eq, Ord, Show, Generic, Data)
deriving anyclass (NFData, Hashable)

View File

@ -3,10 +3,12 @@
module Hasura.Backends.DataConnector.API.V0.Expression
( Expression (..),
ExistsInTable (..),
BinaryComparisonOperator (..),
BinaryArrayComparisonOperator (..),
UnaryComparisonOperator (..),
ComparisonColumn (..),
ColumnPath (..),
ComparisonValue (..),
)
where
@ -24,6 +26,7 @@ import Data.Tuple.Extra
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.Table qualified as API.V0
import Prelude
--------------------------------------------------------------------------------
@ -104,6 +107,7 @@ data Expression
= And [Expression]
| Or [Expression]
| Not Expression
| Exists ExistsInTable Expression
| ApplyBinaryComparisonOperator BinaryComparisonOperator ComparisonColumn ComparisonValue
| ApplyBinaryArrayComparisonOperator BinaryArrayComparisonOperator ComparisonColumn [Value]
| ApplyUnaryComparisonOperator UnaryComparisonOperator ComparisonColumn
@ -119,6 +123,10 @@ instance HasCodec Expression where
where
expressionsCodec = requiredField' "expressions"
expressionCodec = requiredField' "expression"
existsCodec =
(,)
<$> requiredField' "in_table" .= fst
<*> requiredField' "where" .= snd
binaryOperatorCodec =
(,,)
<$> requiredField' "operator" .= fst3
@ -137,6 +145,8 @@ instance HasCodec Expression where
And expressions -> ("and", mapToEncoder expressions expressionsCodec)
Or expressions -> ("or", mapToEncoder expressions expressionsCodec)
Not expression -> ("not", mapToEncoder expression expressionCodec)
Exists inTable where' ->
("exists", mapToEncoder (inTable, where') existsCodec)
ApplyBinaryComparisonOperator o c v ->
("binary_op", mapToEncoder (o, c, v) binaryOperatorCodec)
ApplyBinaryArrayComparisonOperator o c vs ->
@ -148,6 +158,11 @@ instance HasCodec Expression where
[ ("and", ("AndExpression", mapToDecoder And expressionsCodec)),
("or", ("OrExpression", mapToDecoder Or expressionsCodec)),
("not", ("NotExpression", mapToDecoder Not expressionCodec)),
( "exists",
( "ExistsExpression",
mapToDecoder (uncurry Exists) existsCodec
)
),
( "binary_op",
( "ApplyBinaryComparisonOperator",
mapToDecoder (uncurry3 ApplyBinaryComparisonOperator) binaryOperatorCodec
@ -165,10 +180,37 @@ instance HasCodec Expression where
)
]
-- | Which table should be subqueried to satisfy the 'Exists' expression
data ExistsInTable
= -- | The table is the one found by navigating the specified relationship
-- from the current table
RelatedTable API.V0.RelationshipName
| -- | The table is completely unrelated to the current table (ie no join
-- between the current table and the specified table should be performed
-- and the whole of the specified table would be subqueried)
UnrelatedTable API.V0.TableName
deriving stock (Data, Eq, Generic, Ord, Show)
deriving anyclass (Hashable, NFData)
deriving (FromJSON, ToJSON, ToSchema) via Autodocodec ExistsInTable
instance HasCodec ExistsInTable where
codec = named "ExistsInTable" . object "ExistsInTable" $ discriminatedUnionCodec "type" enc dec
where
relatedTableCodec = requiredField' "relationship"
unrelatedTableCodec = requiredField' "table"
enc = \case
RelatedTable relationship -> ("related", mapToEncoder relationship relatedTableCodec)
UnrelatedTable tableName -> ("unrelated", mapToEncoder tableName unrelatedTableCodec)
dec =
HashMap.fromList
[ ("related", ("RelatedTable", mapToDecoder RelatedTable relatedTableCodec)),
("unrelated", ("UnrelatedTable", mapToDecoder UnrelatedTable unrelatedTableCodec))
]
-- | 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 path to the table that contains the specified column.
_ccPath :: ColumnPath,
-- | The name of the column
_ccName :: API.V0.ColumnName
}
@ -180,9 +222,40 @@ 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
<$> optionalFieldWithOmittedDefault "path" CurrentTable "The path to the table that contains the specified column. Missing or empty array means the current table. [\"$\"] means the query table. No other values are supported at this time." .= _ccPath
<*> requiredField "name" "The name of the column" .= _ccName
-- | Describes what table a column is located on. This may either be the "current" table
-- (which would be query table, or the table specified by the closest ancestor 'Exists'
-- expression), or the query table (meaning the table being queried by the 'Query' which
-- the current 'Expression' is from)
--
-- This currently encodes to @[]@ or @["$"]@ in JSON. This format has been chosen to ensure
-- that if we want to extend the pathing to allow navigation of table relationships by
-- turning this type into a list of path components, we can do that without breaking the
-- JSON format. The JSON format also aligns with how HGE encodes this concept in @_ceq@ etc
-- operators in the permissions system.
data ColumnPath
= CurrentTable
| QueryTable
deriving stock (Eq, Ord, Show, Generic, Data)
deriving (FromJSON, ToJSON, ToSchema) via Autodocodec ColumnPath
deriving anyclass (Hashable, NFData)
instance HasCodec ColumnPath where
codec = bimapCodec decode encode codec
where
decode :: [Text] -> Either String ColumnPath
decode = \case
[] -> Right CurrentTable
["$"] -> Right QueryTable
_otherwise -> Left "Invalid ColumnPath"
encode :: ColumnPath -> [Text]
encode = \case
CurrentTable -> []
QueryTable -> ["$"]
-- | A serializable representation of comparison values used in comparisons inside 'Expression's.
data ComparisonValue
= -- | Allows a comparison to a column on the current table or another table

View File

@ -2,10 +2,12 @@
module Hasura.Backends.DataConnector.IR.Expression
( Expression (..),
ExistsInTable (..),
BinaryComparisonOperator (..),
BinaryArrayComparisonOperator (..),
UnaryComparisonOperator (..),
ComparisonColumn (..),
ColumnPath (..),
ComparisonValue (..),
)
where
@ -17,6 +19,7 @@ import Data.Aeson (FromJSON, ToJSON, Value)
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.Table qualified as IR.T
import Hasura.Incremental (Cacheable)
import Hasura.Prelude
import Witch qualified
@ -49,6 +52,9 @@ data Expression
--
-- cf. https://www.postgresql.org/docs/13/functions-logical.html
Not Expression
| -- | There must exist a row in the table specified by 'ExistsInTable' that
-- satisfies the 'Expression'
Exists ExistsInTable 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.
@ -70,6 +76,8 @@ instance Witch.From Expression API.Expression where
And exprs -> API.And $ Witch.from <$> exprs
Or exprs -> API.Or $ Witch.from <$> exprs
Not expr -> API.Not $ Witch.from expr
Exists inTable expr ->
API.Exists (Witch.from inTable) (Witch.from expr)
ApplyBinaryComparisonOperator op column value ->
API.ApplyBinaryComparisonOperator (Witch.from op) (Witch.from column) (Witch.from value)
ApplyUnaryComparisonOperator op column ->
@ -82,6 +90,8 @@ instance Witch.From API.Expression Expression where
API.And exprs -> And $ Witch.from <$> exprs
API.Or exprs -> Or $ Witch.from <$> exprs
API.Not expr -> Not $ Witch.from expr
API.Exists inTable expr ->
Exists (Witch.from inTable) (Witch.from expr)
API.ApplyBinaryComparisonOperator op column value ->
ApplyBinaryComparisonOperator (Witch.from op) (Witch.from column) (Witch.from value)
API.ApplyBinaryArrayComparisonOperator op column values ->
@ -89,6 +99,28 @@ instance Witch.From API.Expression Expression where
API.ApplyUnaryComparisonOperator op column ->
ApplyUnaryComparisonOperator (Witch.from op) (Witch.from column)
-- | Which table should be subqueried to satisfy the 'Exists' expression
data ExistsInTable
= -- | The table is the one found by navigating the specified relationship
-- from the current table
RelatedTable IR.R.RelationshipName
| -- | The table is completely unrelated to the current table (ie no join
-- between the current table and the specified table should be performed
-- and the whole of the specified table would be subqueried)
UnrelatedTable IR.T.Name
deriving stock (Data, Eq, Generic, Ord, Show)
deriving anyclass (Cacheable, FromJSON, Hashable, NFData, ToJSON)
instance Witch.From ExistsInTable API.ExistsInTable where
from = \case
RelatedTable relationshipName -> API.RelatedTable (Witch.from relationshipName)
UnrelatedTable tableName -> API.UnrelatedTable (Witch.from tableName)
instance Witch.From API.ExistsInTable ExistsInTable where
from = \case
API.RelatedTable relationshipName -> RelatedTable (Witch.from relationshipName)
API.UnrelatedTable tableName -> UnrelatedTable (Witch.from tableName)
--------------------------------------------------------------------------------
-- | Operators which are typically applied to two 'Expression's (via the
@ -155,7 +187,7 @@ instance Witch.From BinaryArrayComparisonOperator API.BinaryArrayComparisonOpera
from (CustomBinaryArrayComparisonOperator name) = API.CustomBinaryArrayComparisonOperator name
data ComparisonColumn = ComparisonColumn
{ _ccPath :: [IR.R.RelationshipName],
{ _ccPath :: ColumnPath,
_ccName :: IR.C.Name
}
deriving stock (Data, Eq, Generic, Ord, Show)
@ -164,17 +196,31 @@ data ComparisonColumn = ComparisonColumn
instance Witch.From ComparisonColumn API.ComparisonColumn where
from ComparisonColumn {..} =
API.ComparisonColumn
{ _ccPath = Witch.from <$> _ccPath,
{ _ccPath = Witch.from _ccPath,
_ccName = Witch.from _ccName
}
instance Witch.From API.ComparisonColumn ComparisonColumn where
from API.ComparisonColumn {..} =
ComparisonColumn
{ _ccPath = Witch.from <$> _ccPath,
{ _ccPath = Witch.from _ccPath,
_ccName = Witch.from _ccName
}
data ColumnPath
= CurrentTable
| QueryTable
deriving stock (Data, Eq, Generic, Ord, Show)
deriving anyclass (Cacheable, FromJSON, Hashable, NFData, ToJSON)
instance Witch.From ColumnPath API.ColumnPath where
from CurrentTable = API.CurrentTable
from QueryTable = API.QueryTable
instance Witch.From API.ColumnPath ColumnPath where
from API.CurrentTable = CurrentTable
from API.QueryTable = QueryTable
data ComparisonValue
= AnotherColumn ComparisonColumn
| ScalarValue Value

View File

@ -14,12 +14,14 @@ import Data.Aeson.Encoding qualified as JE
import Data.Aeson.Key qualified as K
import Data.Aeson.KeyMap (KeyMap)
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BL
import Data.HashMap.Strict qualified as HashMap
import Data.List.NonEmpty qualified as NE
import Data.Semigroup (Min (..))
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Text.Extended ((<>>))
import Data.Text.Extended ((<<>), (<>>))
import Hasura.Backends.DataConnector.API qualified as API
import Hasura.Backends.DataConnector.Adapter.Backend
import Hasura.Backends.DataConnector.Adapter.Types
@ -30,6 +32,7 @@ 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.Type qualified as IR.S
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
@ -38,10 +41,12 @@ import Hasura.RQL.IR.BoolExp
import Hasura.RQL.IR.OrderBy
import Hasura.RQL.IR.Select
import Hasura.RQL.IR.Value
import Hasura.RQL.Types.Backend (SessionVarType)
import Hasura.RQL.Types.Column
import Hasura.RQL.Types.Common
import Hasura.RQL.Types.Relationships.Local (RelInfo (..))
import Hasura.SQL.Backend
import Hasura.SQL.Types (CollectableType (..))
import Hasura.Session
import Witch qualified
@ -151,7 +156,7 @@ mkPlan session (SourceConfig {}) ir = do
case _saWhere (_asnArgs selectG) of
Just expr -> BoolAnd [expr, _tpFilter (_asnPerm selectG)]
Nothing -> _tpFilter (_asnPerm selectG)
whereClause <- translateBoolExpToExpression [] tableName whereClauseWithPermissions
whereClause <- translateBoolExpToExpression tableName whereClauseWithPermissions
orderBy <- traverse (translateOrderBy tableName) (_saOrderBy $ _asnArgs selectG)
pure
IR.Q.Query
@ -204,7 +209,7 @@ mkPlan session (SourceConfig {}) ir = do
(relationshipName, IR.R.Relationship {..}) <- recordTableRelationshipFromRelInfo sourceTableName relationshipInfo
(translatedOrderByElement, subOrderByRelations) <- translateOrderByElement _rTargetTable orderDirection (relationshipName : targetReversePath) orderByElement
targetTableWhereExp <- translateBoolExpToExpression [] _rTargetTable filterExp
targetTableWhereExp <- translateBoolExpToExpression _rTargetTable filterExp
let orderByRelations = HashMap.fromList [(relationshipName, IR.O.OrderByRelation targetTableWhereExp subOrderByRelations)]
pure (translatedOrderByElement, orderByRelations)
@ -224,7 +229,7 @@ mkPlan session (SourceConfig {}) ir = do
_obeOrderDirection = orderDirection
}
targetTableWhereExp <- translateBoolExpToExpression [] _rTargetTable filterExp
targetTableWhereExp <- translateBoolExpToExpression _rTargetTable filterExp
let orderByRelations = HashMap.fromList [(relationshipName, IR.O.OrderByRelation targetTableWhereExp mempty)]
pure (translatedOrderByElement, orderByRelations)
@ -293,7 +298,7 @@ mkPlan session (SourceConfig {}) ir = do
let targetTable = _aosTableFrom (_aarAnnSelect objRel)
let relationshipName = IR.R.mkRelationshipName $ _aarRelationshipName objRel
FieldsAndAggregates {..} <- translateAnnFields noPrefix targetTable (_aosFields (_aarAnnSelect objRel))
whereClause <- translateBoolExpToExpression [] targetTable (_aosTableFilter (_aarAnnSelect objRel))
whereClause <- translateBoolExpToExpression targetTable (_aosTableFilter (_aarAnnSelect objRel))
recordTableRelationship
sourceTableName
@ -424,41 +429,64 @@ mkPlan session (SourceConfig {}) ir = do
prepareLiterals (UVLiteral literal) = pure $ literal
prepareLiterals (UVParameter _ e) = pure (IR.S.ValueLiteral (cvValue e))
prepareLiterals UVSession = throw400 NotSupported "prepareLiterals: UVSession"
prepareLiterals (UVSessionVar _ v) =
case getSessionVariableValue v session of
Nothing -> throw400 NotSupported ("prepareLiterals: session var not found: " <>> v)
Just s -> pure (IR.S.ValueLiteral (J.String s))
prepareLiterals (UVSessionVar sessionVarType sessionVar) = do
textValue <-
getSessionVariableValue sessionVar session
`onNothing` throw400 NotSupported ("prepareLiterals: session var not found: " <>> sessionVar)
parseSessionVariable sessionVar sessionVarType textValue
parseSessionVariable :: SessionVariable -> SessionVarType 'DataConnector -> Text -> m IR.S.Literal
parseSessionVariable varName varType varValue = do
case varType of
CollectableTypeScalar scalarType ->
case scalarType of
IR.S.String -> pure . IR.S.ValueLiteral $ J.String varValue
IR.S.Number -> parseValue (IR.S.ValueLiteral . J.Number) "number value"
IR.S.Bool -> parseValue (IR.S.ValueLiteral . J.Bool) "boolean value"
IR.S.Custom customTypeName -> parseValue IR.S.ValueLiteral (customTypeName <> " JSON value")
CollectableTypeArray scalarType ->
case scalarType of
IR.S.String -> parseValue (IR.S.ArrayLiteral . fmap J.String) "JSON array of strings"
IR.S.Number -> parseValue (IR.S.ArrayLiteral . fmap J.Number) "JSON array of numbers"
IR.S.Bool -> parseValue (IR.S.ArrayLiteral . fmap J.Bool) "JSON array of booleans"
IR.S.Custom customTypeName -> parseValue IR.S.ArrayLiteral ("JSON array of " <> customTypeName <> " JSON values")
where
parseValue :: J.FromJSON a => (a -> IR.S.Literal) -> Text -> m IR.S.Literal
parseValue toLiteral description =
toLiteral <$> J.eitherDecodeStrict' valValueBS
`onLeft` (\err -> throw400 ParseFailed ("Expected " <> description <> " for session variable " <> varName <<> ". " <> T.pack err))
valValueBS :: BS.ByteString
valValueBS = TE.encodeUtf8 varValue
translateBoolExpToExpression ::
[IR.R.RelationshipName] ->
IR.T.Name ->
AnnBoolExp 'DataConnector (UnpreparedValue 'DataConnector) ->
CPS.WriterT IR.R.TableRelationships m (Maybe IR.E.Expression)
translateBoolExpToExpression columnRelationshipReversePath sourceTableName boolExp = do
removeAlwaysTrueExpression <$> translateBoolExp columnRelationshipReversePath sourceTableName boolExp
translateBoolExpToExpression sourceTableName boolExp = do
removeAlwaysTrueExpression <$> translateBoolExp sourceTableName boolExp
translateBoolExp ::
[IR.R.RelationshipName] ->
IR.T.Name ->
AnnBoolExp 'DataConnector (UnpreparedValue 'DataConnector) ->
CPS.WriterT IR.R.TableRelationships m IR.E.Expression
translateBoolExp columnRelationshipReversePath sourceTableName = \case
translateBoolExp sourceTableName = \case
BoolAnd xs ->
mkIfZeroOrMany IR.E.And . mapMaybe removeAlwaysTrueExpression <$> traverse (translateBoolExp columnRelationshipReversePath sourceTableName) xs
mkIfZeroOrMany IR.E.And . mapMaybe removeAlwaysTrueExpression <$> traverse (translateBoolExp sourceTableName) xs
BoolOr xs ->
mkIfZeroOrMany IR.E.Or . mapMaybe removeAlwaysFalseExpression <$> traverse (translateBoolExp columnRelationshipReversePath sourceTableName) xs
mkIfZeroOrMany IR.E.Or . mapMaybe removeAlwaysFalseExpression <$> traverse (translateBoolExp sourceTableName) xs
BoolNot x ->
IR.E.Not <$> (translateBoolExp columnRelationshipReversePath sourceTableName) x
IR.E.Not <$> (translateBoolExp sourceTableName) x
BoolField (AVColumn c xs) ->
lift $ mkIfZeroOrMany IR.E.And <$> traverse (translateOp columnRelationshipReversePath (ciColumn c)) xs
lift $ mkIfZeroOrMany IR.E.And <$> traverse (translateOp (ciColumn c)) xs
BoolField (AVRelationship relationshipInfo boolExp) -> do
(relationshipName, IR.R.Relationship {..}) <- recordTableRelationshipFromRelInfo sourceTableName relationshipInfo
translateBoolExp (relationshipName : columnRelationshipReversePath) _rTargetTable boolExp
BoolExists _ ->
lift $ throw400 NotSupported "The BoolExists expression type is not supported by the Data Connector backend"
IR.E.Exists (IR.E.RelatedTable relationshipName) <$> translateBoolExp _rTargetTable boolExp
BoolExists GExists {..} ->
IR.E.Exists (IR.E.UnrelatedTable _geTable) <$> translateBoolExp _geTable _geWhere
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.
-- just returns the singleton expression. This helps remove redundant 'IR.E.And' etcs from the expression.
mkIfZeroOrMany :: ([IR.E.Expression] -> IR.E.Expression) -> [IR.E.Expression] -> IR.E.Expression
mkIfZeroOrMany mk = \case
[singleExp] -> singleExp
@ -477,11 +505,10 @@ mkPlan session (SourceConfig {}) ir = do
other -> Just other
translateOp ::
[IR.R.RelationshipName] ->
IR.C.Name ->
OpExpG 'DataConnector (UnpreparedValue 'DataConnector) ->
m IR.E.Expression
translateOp columnRelationshipReversePath columnName opExp = do
translateOp columnName opExp = do
preparedOpExp <- traverse prepareLiterals $ opExp
case preparedOpExp of
AEQ _ (IR.S.ValueLiteral value) ->
@ -542,13 +569,13 @@ mkPlan session (SourceConfig {}) ir = do
pure $ IR.E.ApplyBinaryArrayComparisonOperator (IR.E.CustomBinaryArrayComparisonOperator _cboName) currentComparisonColumn array
where
currentComparisonColumn :: IR.E.ComparisonColumn
currentComparisonColumn = IR.E.ComparisonColumn (reverse columnRelationshipReversePath) columnName
currentComparisonColumn = IR.E.ComparisonColumn IR.E.CurrentTable columnName
mkApplyBinaryComparisonOperatorToAnotherColumn :: IR.E.BinaryComparisonOperator -> RootOrCurrentColumn 'DataConnector -> IR.E.Expression
mkApplyBinaryComparisonOperatorToAnotherColumn operator (RootOrCurrentColumn rootOrCurrent otherColumnName) =
let columnPath = case rootOrCurrent of
IsRoot -> []
IsCurrent -> (reverse columnRelationshipReversePath)
IsRoot -> IR.E.QueryTable
IsCurrent -> IR.E.CurrentTable
in IR.E.ApplyBinaryComparisonOperator operator currentComparisonColumn (IR.E.AnotherColumn $ IR.E.ComparisonColumn columnPath otherColumnName)
inOperator :: IR.S.Literal -> IR.E.Expression

View File

@ -117,6 +117,16 @@ genGraphQLTypeDefinitions =
genRelationshipCapabilities :: MonadGen m => m RelationshipCapabilities
genRelationshipCapabilities = pure RelationshipCapabilities {}
genComparisonCapabilities :: MonadGen m => m ComparisonCapabilities
genComparisonCapabilities =
ComparisonCapabilities
<$> Gen.maybe genCrossTableComparisonCapabilities
genCrossTableComparisonCapabilities :: MonadGen m => m CrossTableComparisonCapabilities
genCrossTableComparisonCapabilities =
CrossTableComparisonCapabilities
<$> Gen.bool
genMetricsCapabilities :: MonadGen m => m MetricsCapabilities
genMetricsCapabilities = pure MetricsCapabilities {}
@ -132,6 +142,7 @@ genCapabilities =
<*> Gen.maybe genScalarTypesCapabilities
<*> Gen.maybe genGraphQLTypeDefinitions
<*> Gen.maybe genRelationshipCapabilities
<*> Gen.maybe genComparisonCapabilities
<*> Gen.maybe genMetricsCapabilities
<*> Gen.maybe genExplainCapabilities

View File

@ -16,6 +16,7 @@ import Data.Aeson.QQ.Simple (aesonQQ)
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.TableSpec (genTableName)
import Hasura.Generator.Common (defaultRange, genArbitraryAlphaNumText)
import Hasura.Prelude
import Hedgehog
@ -66,16 +67,23 @@ spec = do
describe "ComparisonColumn" $ do
testToFromJSONToSchema
(ComparisonColumn [RelationshipName "table1", RelationshipName "table2"] (ColumnName "column_name"))
[aesonQQ|{"path": ["table1", "table2"], "name": "column_name"}|]
(ComparisonColumn QueryTable (ColumnName "column_name"))
[aesonQQ|{"path": ["$"], "name": "column_name"}|]
jsonOpenApiProperties genComparisonColumn
describe "ColumnPath" $ do
describe "QueryTable" $
testToFromJSONToSchema QueryTable [aesonQQ|["$"]|]
describe "CurrentTable" $
testToFromJSONToSchema CurrentTable [aesonQQ|[]|]
jsonOpenApiProperties genColumnPath
describe "ComparisonValue" $ do
describe "AnotherColumn" $
testToFromJSONToSchema
(AnotherColumn $ ComparisonColumn [] (ColumnName "my_column_name"))
[aesonQQ|{"type": "column", "column": {"path": [], "name": "my_column_name"}}|]
(AnotherColumn $ ComparisonColumn CurrentTable (ColumnName "my_column_name"))
[aesonQQ|{"type": "column", "column": {"name": "my_column_name"}}|]
describe "ScalarValue" $
testToFromJSONToSchema
(ScalarValue $ String "scalar value")
@ -83,8 +91,27 @@ spec = do
jsonOpenApiProperties genComparisonValue
describe "ExistsInTable" $ do
describe "RelatedTable" $
testToFromJSONToSchema
(RelatedTable (RelationshipName "my_relation"))
[aesonQQ|
{ "type": "related",
"relationship": "my_relation"
}
|]
describe "UnrelatedTable" $
testToFromJSONToSchema
(UnrelatedTable (TableName ["my_table_name"]))
[aesonQQ|
{ "type": "unrelated",
"table": ["my_table_name"]
}
|]
jsonOpenApiProperties genExistsInTable
describe "Expression" $ do
let comparisonColumn = ComparisonColumn [] (ColumnName "my_column_name")
let comparisonColumn = ComparisonColumn CurrentTable (ColumnName "my_column_name")
let scalarValue = ScalarValue $ String "scalar value"
let scalarValues = [String "scalar value"]
let unaryComparisonExpression = ApplyUnaryComparisonOperator IsNull comparisonColumn
@ -99,7 +126,7 @@ spec = do
{
"type": "unary_op",
"operator": "is_null",
"column": { "path": [], "name": "my_column_name" }
"column": { "name": "my_column_name" }
}
]
}
@ -115,7 +142,7 @@ spec = do
{
"type": "unary_op",
"operator": "is_null",
"column": { "path": [], "name": "my_column_name" }
"column": { "name": "my_column_name" }
}
]
}
@ -130,10 +157,29 @@ spec = do
"expression": {
"type": "unary_op",
"operator": "is_null",
"column": { "path": [], "name": "my_column_name" }
"column": { "name": "my_column_name" }
}
}
|]
describe "Exists" $ do
testToFromJSONToSchema
(Exists (RelatedTable (RelationshipName "relation")) unaryComparisonExpression)
[aesonQQ|
{
"type": "exists",
"in_table": {
"type": "related",
"relationship": "relation"
},
"where": {
"type": "unary_op",
"operator": "is_null",
"column": { "name": "my_column_name" }
}
}
|]
describe "BinaryComparisonOperator" $ do
testToFromJSONToSchema
(ApplyBinaryComparisonOperator Equal comparisonColumn scalarValue)
@ -141,7 +187,7 @@ spec = do
{
"type": "binary_op",
"operator": "equal",
"column": { "path": [], "name": "my_column_name" },
"column": { "name": "my_column_name" },
"value": {"type": "scalar", "value": "scalar value"}
}
|]
@ -153,7 +199,7 @@ spec = do
{
"type": "binary_arr_op",
"operator": "in",
"column": { "path": [], "name": "my_column_name" },
"column": { "name": "my_column_name" },
"values": ["scalar value"]
}
|]
@ -165,7 +211,7 @@ spec = do
{
"type": "unary_op",
"operator": "is_null",
"column": { "path": [], "name": "my_column_name" }
"column": { "name": "my_column_name" }
}
|]
@ -195,9 +241,13 @@ genUnaryComparisonOperator =
genComparisonColumn :: MonadGen m => m ComparisonColumn
genComparisonColumn =
ComparisonColumn
<$> Gen.list defaultRange genRelationshipName
<$> genColumnPath
<*> genColumnName
genColumnPath :: MonadGen m => m ColumnPath
genColumnPath =
Gen.element [CurrentTable, QueryTable]
genComparisonValue :: MonadGen m => m ComparisonValue
genComparisonValue =
Gen.choice
@ -205,6 +255,13 @@ genComparisonValue =
ScalarValue <$> genValue
]
genExistsInTable :: MonadGen m => m ExistsInTable
genExistsInTable =
Gen.choice
[ RelatedTable <$> genRelationshipName,
UnrelatedTable <$> genTableName
]
genExpression :: MonadGen m => m Expression
genExpression =
Gen.recursive
@ -215,7 +272,8 @@ genExpression =
]
[ And <$> genExpressions,
Or <$> genExpressions,
Not <$> genExpression
Not <$> genExpression,
Exists <$> genExistsInTable <*> genExpression
]
where
genExpressions = Gen.list defaultRange genExpression

View File

@ -44,6 +44,11 @@ module Test.Data
invoiceLinesRelationshipName,
mediaTypeRelationshipName,
albumRelationshipName,
genreRelationshipName,
-- = Genres table
genresTableName,
genresRows,
genresTableRelationships,
-- = Utilities
emptyQuery,
sortBy,
@ -59,8 +64,8 @@ module Test.Data
_ColumnFieldString,
_ColumnFieldBoolean,
columnField,
comparisonColumn,
localComparisonColumn,
queryComparisonColumn,
currentComparisonColumn,
orderByColumn,
)
where
@ -265,12 +270,14 @@ tracksTableRelationships =
let invoiceLinesJoinFieldMapping = HashMap.fromList [(API.ColumnName "TrackId", API.ColumnName "TrackId")]
mediaTypeJoinFieldMapping = HashMap.fromList [(API.ColumnName "MediaTypeId", API.ColumnName "MediaTypeId")]
albumJoinFieldMapping = HashMap.fromList [(API.ColumnName "AlbumId", API.ColumnName "AlbumId")]
genreJoinFieldMapping = HashMap.fromList [(API.ColumnName "GenreId", API.ColumnName "GenreId")]
in API.TableRelationships
tracksTableName
( HashMap.fromList
[ (invoiceLinesRelationshipName, API.Relationship invoiceLinesTableName API.ArrayRelationship invoiceLinesJoinFieldMapping),
(mediaTypeRelationshipName, API.Relationship mediaTypesTableName API.ObjectRelationship mediaTypeJoinFieldMapping),
(albumRelationshipName, API.Relationship albumsTableName API.ObjectRelationship albumJoinFieldMapping)
(albumRelationshipName, API.Relationship albumsTableName API.ObjectRelationship albumJoinFieldMapping),
(genreRelationshipName, API.Relationship genresTableName API.ObjectRelationship genreJoinFieldMapping)
]
)
@ -283,6 +290,25 @@ mediaTypeRelationshipName = API.RelationshipName "MediaType"
albumRelationshipName :: API.RelationshipName
albumRelationshipName = API.RelationshipName "Album"
genreRelationshipName :: API.RelationshipName
genreRelationshipName = API.RelationshipName "Genre"
genresTableName :: API.TableName
genresTableName = mkTableName "Genre"
genresRows :: [KeyMap API.FieldValue]
genresRows = sortBy "GenreId" $ readTableFromXmlIntoRows genresTableName
genresTableRelationships :: API.TableRelationships
genresTableRelationships =
let joinFieldMapping = HashMap.fromList [(API.ColumnName "GenreId", API.ColumnName "GenreId")]
in API.TableRelationships
genresTableName
( HashMap.fromList
[ (tracksRelationshipName, API.Relationship tracksTableName API.ArrayRelationship joinFieldMapping)
]
)
emptyQuery :: API.Query
emptyQuery = API.Query Nothing Nothing Nothing Nothing Nothing Nothing
@ -339,11 +365,11 @@ _ColumnFieldBoolean = API._ColumnFieldValue . _Bool
columnField :: Text -> API.Field
columnField = API.ColumnField . API.ColumnName
comparisonColumn :: [API.RelationshipName] -> Text -> API.ComparisonColumn
comparisonColumn path columnName = API.ComparisonColumn path $ API.ColumnName columnName
queryComparisonColumn :: Text -> API.ComparisonColumn
queryComparisonColumn columnName = API.ComparisonColumn API.QueryTable $ API.ColumnName columnName
localComparisonColumn :: Text -> API.ComparisonColumn
localComparisonColumn columnName = comparisonColumn [] columnName
currentComparisonColumn :: Text -> API.ComparisonColumn
currentComparisonColumn columnName = API.ComparisonColumn API.CurrentTable $ API.ColumnName columnName
orderByColumn :: [API.RelationshipName] -> Text -> API.OrderDirection -> API.OrderByElement
orderByColumn targetPath columnName orderDirection =

View File

@ -2,12 +2,13 @@ module Test.QuerySpec (spec) where
import Control.Monad (when)
import Data.Maybe (isJust)
import Hasura.Backends.DataConnector.API (Capabilities (..), Config, Routes (..), SourceName)
import Hasura.Backends.DataConnector.API (Capabilities (..), ComparisonCapabilities (_ccCrossTableComparisonCapabilities), Config, Routes (..), SourceName)
import Servant.API (NamedRoutes)
import Servant.Client (Client)
import Test.Hspec
import Test.QuerySpec.AggregatesSpec qualified
import Test.QuerySpec.BasicSpec qualified
import Test.QuerySpec.FilteringSpec qualified
import Test.QuerySpec.OrderBySpec qualified
import Test.QuerySpec.RelationshipsSpec qualified
import Prelude
@ -16,7 +17,8 @@ spec :: Client IO (NamedRoutes Routes) -> SourceName -> Config -> Capabilities -
spec api sourceName config capabilities@Capabilities {..} = do
describe "query API" do
Test.QuerySpec.BasicSpec.spec api sourceName config
Test.QuerySpec.FilteringSpec.spec api sourceName config cComparisons
Test.QuerySpec.OrderBySpec.spec api sourceName config capabilities
when (isJust cRelationships) $
Test.QuerySpec.RelationshipsSpec.spec api sourceName config
Test.QuerySpec.RelationshipsSpec.spec api sourceName config (cComparisons >>= _ccCrossTableComparisonCapabilities)
Test.QuerySpec.AggregatesSpec.spec api sourceName config cRelationships

View File

@ -35,7 +35,7 @@ spec api sourceName config relationshipCapabilities = describe "Aggregate Querie
Data.responseRows response `rowsShouldBe` []
it "counts all rows, after applying filters" $ do
let where' = ApplyBinaryComparisonOperator Equal (Data.localComparisonColumn "BillingCity") (ScalarValue (String "Oslo"))
let where' = ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "BillingCity") (ScalarValue (String "Oslo"))
let aggregates = KeyMap.fromList [("count_all", StarCount)]
let queryRequest = invoicesQueryRequest aggregates & qrQuery . qWhere ?~ where'
response <- (api // _query) sourceName config queryRequest
@ -70,7 +70,7 @@ spec api sourceName config relationshipCapabilities = describe "Aggregate Querie
Data.responseRows response `rowsShouldBe` []
it "can count all rows with non-null values in a column, after applying pagination and filtering" $ do
let where' = ApplyBinaryComparisonOperator GreaterThanOrEqual (Data.localComparisonColumn "InvoiceId") (ScalarValue (Number 380))
let where' = ApplyBinaryComparisonOperator GreaterThanOrEqual (Data.currentComparisonColumn "InvoiceId") (ScalarValue (Number 380))
let aggregates = KeyMap.fromList [("count_cols", ColumnCount $ ColumnCountAggregate (ColumnName "BillingState") False)]
let queryRequest = invoicesQueryRequest aggregates & qrQuery %~ (qLimit ?~ 20 >>> qWhere ?~ where')
response <- (api // _query) sourceName config queryRequest
@ -99,7 +99,7 @@ spec api sourceName config relationshipCapabilities = describe "Aggregate Querie
Data.responseRows response `rowsShouldBe` []
it "can count all rows with distinct non-null values in a column, after applying pagination and filtering" $ do
let where' = ApplyBinaryComparisonOperator GreaterThanOrEqual (Data.localComparisonColumn "InvoiceId") (ScalarValue (Number 380))
let where' = ApplyBinaryComparisonOperator GreaterThanOrEqual (Data.currentComparisonColumn "InvoiceId") (ScalarValue (Number 380))
let aggregates = KeyMap.fromList [("count_cols", ColumnCount $ ColumnCountAggregate (ColumnName "BillingState") True)]
let queryRequest = invoicesQueryRequest aggregates & qrQuery %~ (qLimit ?~ 20 >>> qWhere ?~ where')
response <- (api // _query) sourceName config queryRequest
@ -130,7 +130,7 @@ spec api sourceName config relationshipCapabilities = describe "Aggregate Querie
Data.responseRows response `rowsShouldBe` []
it "can get the max total from all rows, after applying pagination, filtering and ordering" $ do
let where' = ApplyBinaryComparisonOperator Equal (Data.localComparisonColumn "BillingCountry") (ScalarValue (String "USA"))
let where' = ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "BillingCountry") (ScalarValue (String "USA"))
let orderBy = OrderBy mempty $ Data.orderByColumn [] "BillingPostalCode" Descending :| [Data.orderByColumn [] "InvoiceId" Ascending]
let aggregates = KeyMap.fromList [("max", SingleColumn $ SingleColumnAggregate Max (ColumnName "Total"))]
let queryRequest = invoicesQueryRequest aggregates & qrQuery %~ (qLimit ?~ 20 >>> qWhere ?~ where' >>> qOrderBy ?~ orderBy)
@ -169,7 +169,7 @@ spec api sourceName config relationshipCapabilities = describe "Aggregate Querie
Data.responseRows response `rowsShouldBe` []
it "aggregates over empty row lists results in nulls" $ do
let where' = ApplyBinaryComparisonOperator LessThan (Data.localComparisonColumn "ArtistId") (ScalarValue (Number 0))
let where' = ApplyBinaryComparisonOperator LessThan (Data.currentComparisonColumn "ArtistId") (ScalarValue (Number 0))
let aggregates = KeyMap.fromList [("min", SingleColumn $ SingleColumnAggregate Min (ColumnName "Name"))]
let queryRequest = artistsQueryRequest aggregates & qrQuery . qWhere ?~ where'
response <- (api // _query) sourceName config queryRequest
@ -231,7 +231,7 @@ spec api sourceName config relationshipCapabilities = describe "Aggregate Querie
[ ("InvoiceId", Data.columnField "InvoiceId"),
("BillingCountry", Data.columnField "BillingCountry")
]
let where' = ApplyBinaryComparisonOperator Equal (Data.localComparisonColumn "BillingCountry") (ScalarValue (String "Canada"))
let where' = ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "BillingCountry") (ScalarValue (String "Canada"))
let orderBy = OrderBy mempty $ Data.orderByColumn [] "BillingAddress" Ascending :| [Data.orderByColumn [] "InvoiceId" Ascending]
let aggregates = KeyMap.fromList [("min", SingleColumn $ SingleColumnAggregate Min (ColumnName "Total"))]
let queryRequest = invoicesQueryRequest aggregates & qrQuery %~ (qFields ?~ fields >>> qLimit ?~ 30 >>> qWhere ?~ where' >>> qOrderBy ?~ orderBy)
@ -419,7 +419,7 @@ deeplyNestedArtistsQuery =
("nodes_InvoiceLines_aggregate", RelField $ RelationshipField Data.invoiceLinesRelationshipName invoiceLinesSubquery)
]
tracksAggregates = KeyMap.fromList [("aggregate_count", StarCount)]
tracksWhere = ApplyBinaryComparisonOperator LessThan (Data.localComparisonColumn "Milliseconds") (ScalarValue $ Number 300000)
tracksWhere = ApplyBinaryComparisonOperator LessThan (Data.currentComparisonColumn "Milliseconds") (ScalarValue $ Number 300000)
tracksOrderBy = OrderBy mempty $ Data.orderByColumn [] "Name" Descending :| []
tracksSubquery = Query (Just tracksFields) (Just tracksAggregates) Nothing Nothing (Just tracksWhere) (Just tracksOrderBy)
albumsFields =
@ -435,8 +435,8 @@ deeplyNestedArtistsQuery =
]
artistWhere =
And
[ ApplyBinaryComparisonOperator GreaterThan (Data.localComparisonColumn "Name") (ScalarValue $ String "A"),
ApplyBinaryComparisonOperator LessThan (Data.localComparisonColumn "Name") (ScalarValue $ String "B")
[ ApplyBinaryComparisonOperator GreaterThan (Data.currentComparisonColumn "Name") (ScalarValue $ String "A"),
ApplyBinaryComparisonOperator LessThan (Data.currentComparisonColumn "Name") (ScalarValue $ String "B")
]
artistOrderBy = OrderBy mempty $ Data.orderByColumn [] "Name" Descending :| []
artistQuery = Query (Just artistFields) Nothing (Just 3) (Just 1) (Just artistWhere) (Just artistOrderBy)

View File

@ -1,8 +1,7 @@
module Test.QuerySpec.BasicSpec (spec) where
import Control.Arrow ((>>>))
import Control.Lens (ix, (%~), (&), (?~), (^?))
import Data.Aeson (Value (..))
import Control.Lens ((%~), (&), (?~))
import Data.Aeson.KeyMap qualified as KeyMap
import Hasura.Backends.DataConnector.API
import Servant.API (NamedRoutes)
@ -66,152 +65,6 @@ spec api sourceName config = describe "Basic Queries" $ do
page1Artists `rowsShouldBe` take 10 allArtists
page2Artists `rowsShouldBe` take 10 (drop 10 allArtists)
describe "Where" $ do
it "can filter using an equality expression" $ do
let where' = ApplyBinaryComparisonOperator Equal (Data.localComparisonColumn "AlbumId") (ScalarValue (Number 2))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter ((== Just 2) . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can filter using an inequality expression" $ do
let where' = Not (ApplyBinaryComparisonOperator Equal (Data.localComparisonColumn "AlbumId") (ScalarValue (Number 2)))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter ((/= Just 2) . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can filter using an in expression" $ do
let where' = ApplyBinaryArrayComparisonOperator In (Data.localComparisonColumn "AlbumId") [Number 2, Number 3]
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter (flip elem [Just 2, Just 3] . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can negate an in expression filter using a not expression" $ do
let where' = Not (ApplyBinaryArrayComparisonOperator In (Data.localComparisonColumn "AlbumId") [Number 2, Number 3])
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter (flip notElem [Just 2, Just 3] . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can combine filters using an and expression" $ do
let where1 = ApplyBinaryComparisonOperator Equal (Data.localComparisonColumn "ArtistId") (ScalarValue (Number 58))
let where2 = ApplyBinaryComparisonOperator Equal (Data.localComparisonColumn "Title") (ScalarValue (String "Stormbringer"))
let where' = And [where1, where2]
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter
( \album ->
(album ^? ix "ArtistId" . Data._ColumnFieldNumber == Just 58) && (album ^? ix "Title" . Data._ColumnFieldString == Just "Stormbringer")
)
Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "treats an empty and expression as 'true'" $ do
let where' = And []
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
Data.responseRows receivedAlbums `rowsShouldBe` Data.albumsRows
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can combine filters using an or expression" $ do
let where1 = ApplyBinaryComparisonOperator Equal (Data.localComparisonColumn "AlbumId") (ScalarValue (Number 2))
let where2 = ApplyBinaryComparisonOperator Equal (Data.localComparisonColumn "AlbumId") (ScalarValue (Number 3))
let where' = Or [where1, where2]
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter (flip elem [Just 2, Just 3] . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "treats an empty or expression as 'false'" $ do
let where' = Or []
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- (api // _query) sourceName config query
Data.responseRows receivedAlbums `rowsShouldBe` []
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can filter by applying the greater than operator" $ do
let where' = ApplyBinaryComparisonOperator GreaterThan (Data.localComparisonColumn "AlbumId") (ScalarValue (Number 300))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter ((> Just 300) . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can filter by applying the greater than or equal operator" $ do
let where' = ApplyBinaryComparisonOperator GreaterThanOrEqual (Data.localComparisonColumn "AlbumId") (ScalarValue (Number 300))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter ((>= Just 300) . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can filter by applying the less than operator" $ do
let where' = ApplyBinaryComparisonOperator LessThan (Data.localComparisonColumn "AlbumId") (ScalarValue (Number 100))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter ((< Just 100) . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can filter by applying the less than or equal operator" $ do
let where' = ApplyBinaryComparisonOperator LessThanOrEqual (Data.localComparisonColumn "AlbumId") (ScalarValue (Number 100))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter ((<= Just 100) . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can filter using a greater than operator with a column comparison" $ do
let where' = ApplyBinaryComparisonOperator GreaterThan (Data.localComparisonColumn "AlbumId") (AnotherColumn (Data.localComparisonColumn "ArtistId"))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter (\album -> (album ^? ix "AlbumId" . Data._ColumnFieldNumber) > (album ^? ix "ArtistId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
artistsQueryRequest :: QueryRequest
artistsQueryRequest =
let fields = KeyMap.fromList [("ArtistId", Data.columnField "ArtistId"), ("Name", Data.columnField "Name")]

View File

@ -0,0 +1,319 @@
module Test.QuerySpec.FilteringSpec (spec) where
import Control.Lens (ix, (&), (.~), (<&>), (?~), (^?))
import Control.Monad (when)
import Data.Aeson (Value (..))
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Foldable (find)
import Data.HashMap.Strict qualified as HashMap
import Data.HashSet qualified as HashSet
import Data.List (sortOn)
import Data.Maybe (isJust, mapMaybe)
import Hasura.Backends.DataConnector.API
import Servant.API (NamedRoutes)
import Servant.Client (Client, (//))
import Test.Data qualified as Data
import Test.Expectations (jsonShouldBe, rowsShouldBe)
import Test.Hspec (Spec, describe, it)
import Prelude
spec :: Client IO (NamedRoutes Routes) -> SourceName -> Config -> Maybe ComparisonCapabilities -> Spec
spec api sourceName config comparisonCapabilities = describe "Filtering in Queries" $ do
it "can filter using an equality expression" $ do
let where' = ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "AlbumId") (ScalarValue (Number 2))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter ((== Just 2) . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can filter using an inequality expression" $ do
let where' = Not (ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "AlbumId") (ScalarValue (Number 2)))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter ((/= Just 2) . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can filter using an in expression" $ do
let where' = ApplyBinaryArrayComparisonOperator In (Data.currentComparisonColumn "AlbumId") [Number 2, Number 3]
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter (flip elem [Just 2, Just 3] . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can negate an in expression filter using a not expression" $ do
let where' = Not (ApplyBinaryArrayComparisonOperator In (Data.currentComparisonColumn "AlbumId") [Number 2, Number 3])
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter (flip notElem [Just 2, Just 3] . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can combine filters using an and expression" $ do
let where1 = ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "ArtistId") (ScalarValue (Number 58))
let where2 = ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "Title") (ScalarValue (String "Stormbringer"))
let where' = And [where1, where2]
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter
( \album ->
(album ^? ix "ArtistId" . Data._ColumnFieldNumber == Just 58) && (album ^? ix "Title" . Data._ColumnFieldString == Just "Stormbringer")
)
Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "treats an empty and expression as 'true'" $ do
let where' = And []
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
Data.responseRows receivedAlbums `rowsShouldBe` Data.albumsRows
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can combine filters using an or expression" $ do
let where1 = ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "AlbumId") (ScalarValue (Number 2))
let where2 = ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "AlbumId") (ScalarValue (Number 3))
let where' = Or [where1, where2]
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter (flip elem [Just 2, Just 3] . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "treats an empty or expression as 'false'" $ do
let where' = Or []
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- (api // _query) sourceName config query
Data.responseRows receivedAlbums `rowsShouldBe` []
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can filter by applying the greater than operator" $ do
let where' = ApplyBinaryComparisonOperator GreaterThan (Data.currentComparisonColumn "AlbumId") (ScalarValue (Number 300))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter ((> Just 300) . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can filter by applying the greater than or equal operator" $ do
let where' = ApplyBinaryComparisonOperator GreaterThanOrEqual (Data.currentComparisonColumn "AlbumId") (ScalarValue (Number 300))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter ((>= Just 300) . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can filter by applying the less than operator" $ do
let where' = ApplyBinaryComparisonOperator LessThan (Data.currentComparisonColumn "AlbumId") (ScalarValue (Number 100))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter ((< Just 100) . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can filter by applying the less than or equal operator" $ do
let where' = ApplyBinaryComparisonOperator LessThanOrEqual (Data.currentComparisonColumn "AlbumId") (ScalarValue (Number 100))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter ((<= Just 100) . (^? ix "AlbumId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can filter using a greater than operator with a column comparison" $ do
let where' = ApplyBinaryComparisonOperator GreaterThan (Data.currentComparisonColumn "AlbumId") (AnotherColumn (Data.currentComparisonColumn "ArtistId"))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums =
filter (\album -> (album ^? ix "AlbumId" . Data._ColumnFieldNumber) > (album ^? ix "ArtistId" . Data._ColumnFieldNumber)) Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
when (isJust $ comparisonCapabilities >>= _ccCrossTableComparisonCapabilities) $
describe "Comparisons in unrelated tables" $ do
describe "can filter with a condition that requires that matching rows exist in another unrelated table" $ do
describe "compare against a single column" $ do
it "returns all rows if matching rows exist" $ do
let where' =
Exists (UnrelatedTable Data.employeesTableName) $
ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "EmployeeId") (ScalarValue (Number 1))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums = Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "returns no rows if matching rows do not exist" $ do
let where' =
Exists (UnrelatedTable Data.employeesTableName) $
ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "EmployeeId") (ScalarValue (Number 0))
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
Data.responseRows receivedAlbums `rowsShouldBe` []
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
describe "compare against multiple columns" $ do
it "returns all rows if matching rows exist" $ do
let where' =
Exists (UnrelatedTable Data.employeesTableName) $
And
[ ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "EmployeeId") (ScalarValue (Number 1)),
ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "City") (ScalarValue (String "Edmonton"))
]
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let expectedAlbums = Data.albumsRows
Data.responseRows receivedAlbums `rowsShouldBe` expectedAlbums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "returns no rows if matching rows do not exist" $ do
let where' =
Exists (UnrelatedTable Data.employeesTableName) $
And
[ ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "EmployeeId") (ScalarValue (Number 1)),
ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "City") (ScalarValue (String "Calgary"))
]
let query = albumsQueryRequest & qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
Data.responseRows receivedAlbums `rowsShouldBe` []
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
when ((comparisonCapabilities >>= _ccCrossTableComparisonCapabilities <&> _ctccSupportsRelations) == Just True) $
describe "Comparisons in related tables" $ do
it "can filter by comparing against rows in a related table" $ do
let where' =
Exists (RelatedTable Data.artistRelationshipName) $
ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "Name") (ScalarValue (String "AC/DC"))
let query =
albumsQueryRequest
& qrTableRelationships .~ [Data.onlyKeepRelationships [Data.artistRelationshipName] Data.albumsTableRelationships]
& qrQuery . qWhere ?~ where'
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
let artistId =
Data.artistsRows
& find (\artist -> (artist ^? ix "Name" . Data._ColumnFieldString) == Just "AC/DC")
>>= (^? ix "ArtistId" . Data._ColumnFieldNumber)
let albums =
Data.albumsRows
& filter (\album -> (album ^? ix "ArtistId" . Data._ColumnFieldNumber) == artistId)
& sortOn (^? ix "AlbumId")
Data.responseRows receivedAlbums `rowsShouldBe` albums
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can filter by comparing against rows in a deeply related table" $ do
let where' =
Exists (RelatedTable Data.albumsRelationshipName) . Exists (RelatedTable Data.tracksRelationshipName) . Exists (RelatedTable Data.genreRelationshipName) $
ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "Name") (ScalarValue (String "Metal"))
let query =
artistsQueryRequest
& qrTableRelationships
.~ [ Data.onlyKeepRelationships [Data.albumsRelationshipName] Data.artistsTableRelationships,
Data.onlyKeepRelationships [Data.tracksRelationshipName] Data.albumsTableRelationships,
Data.onlyKeepRelationships [Data.genreRelationshipName] Data.tracksTableRelationships
]
& qrQuery . qWhere ?~ where'
receivedArtists <- Data.sortResponseRowsBy "ArtistId" <$> (api // _query) sourceName config query
let genreId =
Data.genresRows
& find (\genre -> (genre ^? ix "Name" . Data._ColumnFieldString) == Just "Metal")
>>= (^? ix "GenreId" . Data._ColumnFieldNumber)
let albumIds =
Data.tracksRows
& filter (\track -> (track ^? ix "GenreId" . Data._ColumnFieldNumber) == genreId)
& map (\track -> (track ^? ix "AlbumId" . Data._ColumnFieldNumber))
& HashSet.fromList
let artists =
Data.albumsRows
& filter (\album -> HashSet.member (album ^? ix "AlbumId" . Data._ColumnFieldNumber) albumIds)
& mapMaybe (\album -> album ^? ix "ArtistId" . Data._ColumnFieldNumber)
& HashSet.fromList
& HashSet.toList
& mapMaybe (\artistId -> HashMap.lookup artistId Data.artistsRowsById)
& sortOn (^? ix "ArtistId")
Data.responseRows receivedArtists `rowsShouldBe` artists
_qrAggregates receivedArtists `jsonShouldBe` Nothing
it "can filter by comparing against multiple columns in a related table" $ do
let where' =
Exists (RelatedTable Data.albumsRelationshipName) $
And
[ ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "AlbumId") (ScalarValue (Number 1)),
ApplyBinaryComparisonOperator Equal (Data.currentComparisonColumn "Title") (ScalarValue (String "Let There Be Rock"))
]
let query =
artistsQueryRequest
& qrTableRelationships .~ [Data.onlyKeepRelationships [Data.albumsRelationshipName] Data.artistsTableRelationships]
& qrQuery . qWhere ?~ where'
receivedArtists <- Data.sortResponseRowsBy "ArtistId" <$> (api // _query) sourceName config query
let albums =
Data.albumsRows
& filter (\album -> (album ^? ix "AlbumId" . Data._ColumnFieldNumber) == Just 1 && (album ^? ix "Title" . Data._ColumnFieldString) == Just "Let There Be Rock")
let artists =
Data.artistsRows
& filter (\artist -> isJust $ find (\album -> (album ^? ix "ArtistId" . Data._ColumnFieldNumber) == (artist ^? ix "ArtistId" . Data._ColumnFieldNumber)) albums)
& sortOn (^? ix "ArtistId")
Data.responseRows receivedArtists `rowsShouldBe` artists
_qrAggregates receivedArtists `jsonShouldBe` Nothing
artistsQueryRequest :: QueryRequest
artistsQueryRequest =
let fields = KeyMap.fromList [("ArtistId", Data.columnField "ArtistId"), ("Name", Data.columnField "Name")]
query = Data.emptyQuery & qFields ?~ fields
in QueryRequest Data.artistsTableName [] query
albumsQueryRequest :: QueryRequest
albumsQueryRequest =
let fields = KeyMap.fromList [("AlbumId", Data.columnField "AlbumId"), ("ArtistId", Data.columnField "ArtistId"), ("Title", Data.columnField "Title")]
query = Data.emptyQuery & qFields ?~ fields
in QueryRequest Data.albumsTableName [] query

View File

@ -77,7 +77,7 @@ orderByWithRelationshipsSpec api sourceName config = describe "involving relatio
_qrAggregates receivedAlbums `jsonShouldBe` Nothing
it "can order results by a column in a related table where the related table is filtered" $ do
let artistTableFilter = ApplyBinaryComparisonOperator GreaterThan (Data.localComparisonColumn "Name") (ScalarValue $ String "N")
let artistTableFilter = ApplyBinaryComparisonOperator GreaterThan (Data.currentComparisonColumn "Name") (ScalarValue $ String "N")
let orderByRelations = HashMap.fromList [(Data.artistRelationshipName, OrderByRelation (Just artistTableFilter) mempty)]
let orderBy = OrderBy orderByRelations $ Data.orderByColumn [Data.artistRelationshipName] "Name" Ascending :| []
let query =
@ -171,7 +171,7 @@ orderByWithRelationshipsSpec api sourceName config = describe "involving relatio
_qrAggregates receivedArtists `jsonShouldBe` Nothing
it "can order results by an aggregate of a related table where the related table is filtered" $ do
let albumTableFilter = ApplyBinaryComparisonOperator GreaterThan (Data.localComparisonColumn "Title") (ScalarValue $ String "N")
let albumTableFilter = ApplyBinaryComparisonOperator GreaterThan (Data.currentComparisonColumn "Title") (ScalarValue $ String "N")
let orderByRelations = HashMap.fromList [(Data.albumsRelationshipName, OrderByRelation (Just albumTableFilter) mempty)]
let orderBy = OrderBy orderByRelations $ OrderByElement [Data.albumsRelationshipName] OrderByStarCountAggregate Descending :| []
let query =

View File

@ -1,9 +1,13 @@
module Test.QuerySpec.RelationshipsSpec (spec) where
import Control.Arrow ((>>>))
import Control.Lens (Traversal', ix, (&), (?~), (^.), (^..), (^?), _Just)
import Control.Monad (when)
import Data.Aeson.KeyMap (KeyMap)
import Data.Aeson.KeyMap qualified as KeyMap
import Data.List (sortOn)
import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NonEmpty
import Data.Maybe (maybeToList)
import Hasura.Backends.DataConnector.API
import Servant.API (NamedRoutes)
@ -13,8 +17,8 @@ import Test.Expectations (jsonShouldBe, rowsShouldBe)
import Test.Hspec (Spec, describe, it)
import Prelude
spec :: Client IO (NamedRoutes Routes) -> SourceName -> Config -> Spec
spec api sourceName config = describe "Relationship Queries" $ do
spec :: Client IO (NamedRoutes Routes) -> SourceName -> Config -> Maybe CrossTableComparisonCapabilities -> Spec
spec api sourceName config crossTableComparisonCapabilities = describe "Relationship Queries" $ do
it "perform an object relationship query by joining artist to albums" $ do
let query = albumsWithArtistQuery id
receivedAlbums <- Data.sortResponseRowsBy "AlbumId" <$> (api // _query) sourceName config query
@ -44,103 +48,125 @@ spec api sourceName config = describe "Relationship Queries" $ do
Data.responseRows receivedArtists `rowsShouldBe` expectedAlbums
_qrAggregates receivedArtists `jsonShouldBe` Nothing
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
Equal
(Data.comparisonColumn [Data.supportRepRelationshipName] "Country")
(AnotherColumn (Data.localComparisonColumn "Country"))
let query = customersWithSupportRepQuery id & qrQuery . qWhere ?~ where'
receivedCustomers <- Data.sortResponseRowsBy "CustomerId" <$> (api // _query) sourceName config query
it "perform an array relationship query by joining albums to artists with pagination of albums" $ do
let albumsOrdering = OrderBy mempty $ NonEmpty.fromList [OrderByElement [] (OrderByColumn $ ColumnName "AlbumId") Ascending]
let query = artistsWithAlbumsQuery (qOffset ?~ 1 >>> qLimit ?~ 2 >>> qOrderBy ?~ albumsOrdering)
receivedArtists <- Data.sortResponseRowsBy "ArtistId" <$> (api // _query) sourceName config query
let joinInSupportRep (customer :: KeyMap FieldValue) =
let supportRep = (customer ^? ix "SupportRepId" . Data._ColumnFieldNumber) >>= \employeeId -> Data.employeesRowsById ^? ix employeeId
supportRepPropVal = maybeToList $ Data.filterColumnsByQueryFields employeesQuery <$> supportRep
in KeyMap.insert "SupportRep" (mkSubqueryResponse supportRepPropVal) customer
let joinInAlbums (artist :: KeyMap FieldValue) = do
let artistId = artist ^? ix "ArtistId" . Data._ColumnFieldNumber
albumFilter artistId' album = album ^? ix "ArtistId" . Data._ColumnFieldNumber == Just artistId'
albums = maybe [] (\artistId' -> filter (albumFilter artistId') Data.albumsRows) artistId
paginatedAlbums = albums & sortOn (^? ix "ArtistId") & drop 1 & take 2
paginatedAlbums' = KeyMap.delete "ArtistId" <$> paginatedAlbums
in KeyMap.insert "Albums" (mkSubqueryResponse paginatedAlbums') artist
let filterCustomersBySupportRepCountry (customer :: KeyMap FieldValue) =
let customerCountry = customer ^? ix "Country" . Data._ColumnFieldString
supportRepCountry = customer ^.. ix "SupportRep" . subqueryRows . ix "Country" . Data._ColumnFieldString
in maybe False (`elem` supportRepCountry) customerCountry
let expectedAlbums = joinInAlbums <$> Data.artistsRows
Data.responseRows receivedArtists `rowsShouldBe` expectedAlbums
_qrAggregates receivedArtists `jsonShouldBe` Nothing
let expectedCustomers = filter filterCustomersBySupportRepCountry $ Data.filterColumnsByQueryFields (query ^. qrQuery) . joinInSupportRep <$> Data.customersRows
Data.responseRows receivedCustomers `rowsShouldBe` expectedCustomers
_qrAggregates receivedCustomers `jsonShouldBe` Nothing
when ((_ctccSupportsRelations <$> crossTableComparisonCapabilities) == Just True) $
describe "Cross related table comparisons" $ do
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' =
Exists (RelatedTable Data.supportRepRelationshipName) $
ApplyBinaryComparisonOperator
Equal
(Data.currentComparisonColumn "Country")
(AnotherColumn (Data.queryComparisonColumn "Country"))
let query = customersWithSupportRepQuery id & qrQuery . qWhere ?~ where'
receivedCustomers <- Data.sortResponseRowsBy "CustomerId" <$> (api // _query) sourceName config query
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
Equal
(Data.comparisonColumn [Data.supportRepForCustomersRelationshipName] "Country")
(AnotherColumn (Data.localComparisonColumn "Country"))
let query = employeesWithCustomersQuery id & qrQuery . qWhere ?~ where'
receivedEmployees <- Data.sortResponseRowsBy "EmployeeId" <$> (api // _query) sourceName config query
let joinInSupportRep (customer :: KeyMap FieldValue) =
let supportRep = (customer ^? ix "SupportRepId" . Data._ColumnFieldNumber) >>= \employeeId -> Data.employeesRowsById ^? ix employeeId
supportRepPropVal = maybeToList $ Data.filterColumnsByQueryFields employeesQuery <$> supportRep
in KeyMap.insert "SupportRep" (mkSubqueryResponse supportRepPropVal) customer
let joinInCustomers (employee :: KeyMap FieldValue) =
let employeeId = employee ^? ix "EmployeeId" . Data._ColumnFieldNumber
customerFilter employeeId' customer = customer ^? ix "SupportRepId" . Data._ColumnFieldNumber == Just employeeId'
customers = maybe [] (\employeeId' -> filter (customerFilter employeeId') Data.customersRows) employeeId
customers' = Data.filterColumnsByQueryFields customersQuery <$> customers
in KeyMap.insert "SupportRepForCustomers" (mkSubqueryResponse customers') employee
let filterCustomersBySupportRepCountry (customer :: KeyMap FieldValue) =
let customerCountry = customer ^? ix "Country" . Data._ColumnFieldString
supportRepCountry = customer ^.. ix "SupportRep" . subqueryRows . ix "Country" . Data._ColumnFieldString
in maybe False (`elem` supportRepCountry) customerCountry
let filterEmployeesByCustomerCountry (employee :: KeyMap FieldValue) =
let employeeCountry = employee ^? ix "Country" . Data._ColumnFieldString
customerCountries = employee ^.. ix "SupportRepForCustomers" . subqueryRows . ix "Country" . Data._ColumnFieldString
in maybe False (`elem` customerCountries) employeeCountry
let expectedCustomers = filter filterCustomersBySupportRepCountry $ Data.filterColumnsByQueryFields (query ^. qrQuery) . joinInSupportRep <$> Data.customersRows
Data.responseRows receivedCustomers `rowsShouldBe` expectedCustomers
_qrAggregates receivedCustomers `jsonShouldBe` Nothing
let expectedEmployees = filter filterEmployeesByCustomerCountry $ Data.filterColumnsByQueryFields (query ^. qrQuery) . joinInCustomers <$> Data.employeesRows
Data.responseRows receivedEmployees `rowsShouldBe` expectedEmployees
_qrAggregates receivedEmployees `jsonShouldBe` Nothing
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' =
Exists (RelatedTable Data.supportRepForCustomersRelationshipName) $
ApplyBinaryComparisonOperator
Equal
(Data.currentComparisonColumn "Country")
(AnotherColumn (Data.queryComparisonColumn "Country"))
let query = employeesWithCustomersQuery id & qrQuery . qWhere ?~ where'
receivedEmployees <- Data.sortResponseRowsBy "EmployeeId" <$> (api // _query) sourceName config query
it "perform an object relationship query by joining employee to customers but filter employees by comparing columns on the employee" $ do
-- Join Employee to Customers via SupportRep, and only get those customers that have a rep
-- However, the Employee table is filtered with a permission rule that compares columns on that table.
-- This Employee table permissions filter would look like:
-- { FirstName: { _cgt: ["LastName"] } }
let customersWhere =
And
[ ( ApplyBinaryComparisonOperator
GreaterThan
(Data.comparisonColumn [Data.supportRepRelationshipName] "FirstName")
(AnotherColumn (Data.comparisonColumn [Data.supportRepRelationshipName] "LastName"))
),
(Not (ApplyUnaryComparisonOperator IsNull (Data.comparisonColumn [Data.supportRepRelationshipName] "EmployeeId")))
]
let joinInCustomers (employee :: KeyMap FieldValue) =
let employeeId = employee ^? ix "EmployeeId" . Data._ColumnFieldNumber
customerFilter employeeId' customer = customer ^? ix "SupportRepId" . Data._ColumnFieldNumber == Just employeeId'
customers = maybe [] (\employeeId' -> filter (customerFilter employeeId') Data.customersRows) employeeId
customers' = Data.filterColumnsByQueryFields customersQuery <$> customers
in KeyMap.insert "SupportRepForCustomers" (mkSubqueryResponse customers') employee
let employeesWhere =
ApplyBinaryComparisonOperator
GreaterThan
(Data.localComparisonColumn "FirstName")
(AnotherColumn (Data.localComparisonColumn "LastName"))
let filterEmployeesByCustomerCountry (employee :: KeyMap FieldValue) =
let employeeCountry = employee ^? ix "Country" . Data._ColumnFieldString
customerCountries = employee ^.. ix "SupportRepForCustomers" . subqueryRows . ix "Country" . Data._ColumnFieldString
in maybe False (`elem` customerCountries) employeeCountry
let query = customersWithSupportRepQuery (\q -> q & qWhere ?~ employeesWhere) & qrQuery . qWhere ?~ customersWhere
receivedCustomers <- Data.sortResponseRowsBy "CustomerId" <$> (api // _query) sourceName config query
let expectedEmployees = filter filterEmployeesByCustomerCountry $ Data.filterColumnsByQueryFields (query ^. qrQuery) . joinInCustomers <$> Data.employeesRows
Data.responseRows receivedEmployees `rowsShouldBe` expectedEmployees
_qrAggregates receivedEmployees `jsonShouldBe` Nothing
let joinInSupportRep (customer :: KeyMap FieldValue) =
let supportRep = do
employeeId <- (customer ^? ix "SupportRepId" . Data._ColumnFieldNumber)
employee <- Data.employeesRowsById ^? ix employeeId
firstName <- employee ^? ix "FirstName"
lastName <- employee ^? ix "LastName"
if firstName > lastName then pure employee else Nothing
supportRepPropVal = maybeToList $ Data.filterColumnsByQueryFields employeesQuery <$> supportRep
in KeyMap.insert "SupportRep" (mkSubqueryResponse supportRepPropVal) customer
it "perform an object relationship query by joining employee to customers but filter employees by comparing columns on the employee" $ do
-- Join Employee to Customers via SupportRep, and only get those customers that have a rep
-- However, the Employee table is filtered with a permission rule that compares columns on that table.
-- This Employee table permissions filter would look like:
-- { FirstName: { _cgt: ["LastName"] } }
let customersWhere =
Exists (RelatedTable Data.supportRepRelationshipName) $
And
[ ( ApplyBinaryComparisonOperator
GreaterThan
(Data.currentComparisonColumn "FirstName")
(AnotherColumn (Data.currentComparisonColumn "LastName"))
),
(Not (ApplyUnaryComparisonOperator IsNull (Data.currentComparisonColumn "EmployeeId")))
]
let filterCustomersBySupportRepExistence (customer :: KeyMap FieldValue) =
let supportRep = customer ^.. ix "SupportRep" . subqueryRows
in not (null supportRep)
let employeesWhere =
ApplyBinaryComparisonOperator
GreaterThan
(Data.currentComparisonColumn "FirstName")
(AnotherColumn (Data.currentComparisonColumn "LastName"))
let expectedCustomers = filter filterCustomersBySupportRepExistence $ Data.filterColumnsByQueryFields (query ^. qrQuery) . joinInSupportRep <$> Data.customersRows
Data.responseRows receivedCustomers `rowsShouldBe` expectedCustomers
_qrAggregates receivedCustomers `jsonShouldBe` Nothing
let query = customersWithSupportRepQuery (\q -> q & qWhere ?~ employeesWhere) & qrQuery . qWhere ?~ customersWhere
receivedCustomers <- Data.sortResponseRowsBy "CustomerId" <$> (api // _query) sourceName config query
let joinInSupportRep (customer :: KeyMap FieldValue) =
let supportRep = do
employeeId <- (customer ^? ix "SupportRepId" . Data._ColumnFieldNumber)
employee <- Data.employeesRowsById ^? ix employeeId
firstName <- employee ^? ix "FirstName"
lastName <- employee ^? ix "LastName"
if firstName > lastName then pure employee else Nothing
supportRepPropVal = maybeToList $ Data.filterColumnsByQueryFields employeesQuery <$> supportRep
in KeyMap.insert "SupportRep" (mkSubqueryResponse supportRepPropVal) customer
let filterCustomersBySupportRepExistence (customer :: KeyMap FieldValue) =
let supportRep = customer ^.. ix "SupportRep" . subqueryRows
in not (null supportRep)
let expectedCustomers = filter filterCustomersBySupportRepExistence $ Data.filterColumnsByQueryFields (query ^. qrQuery) . joinInSupportRep <$> Data.customersRows
Data.responseRows receivedCustomers `rowsShouldBe` expectedCustomers
_qrAggregates receivedCustomers `jsonShouldBe` Nothing
albumsWithArtistQuery :: (Query -> Query) -> QueryRequest
albumsWithArtistQuery modifySubquery =

View File

@ -39,6 +39,11 @@ capabilities =
API.cScalarTypes = Nothing,
API.cGraphQLTypeDefinitions = Nothing,
API.cRelationships = Just API.RelationshipCapabilities {},
API.cComparisons =
Just
API.ComparisonCapabilities
{ API._ccCrossTableComparisonCapabilities = Just API.CrossTableComparisonCapabilities {API._ctccSupportsRelations = True}
},
API.cMetrics = Just API.MetricsCapabilities {},
API.cExplain = Just API.ExplainCapabilities {}
},

View File

@ -52,6 +52,9 @@ defaultBackendCapabilities = \case
Just
[yaml|
relationships: {}
comparisons:
cross_table:
supports_relations: true
explain: {}
metrics: {}
queries:
@ -67,6 +70,9 @@ defaultBackendCapabilities = \case
same_day_as: DateTime
}
relationships: {}
comparisons:
cross_table:
supports_relations: true
scalarTypes:
DateTime:
comparisonType: DateTimeComparisons

View File

@ -10,6 +10,7 @@ where
import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString (ByteString)
import Data.List.NonEmpty qualified as NE
import Harness.Backend.DataConnector (TestCase (..))
import Harness.Backend.DataConnector qualified as DataConnector
@ -37,6 +38,9 @@ spec =
)
tests
testRoleName :: ByteString
testRoleName = "test-role"
sourceMetadata :: Aeson.Value
sourceMetadata =
let source = defaultSource DataConnectorMock
@ -81,6 +85,19 @@ sourceMetadata =
remote_table: [Album]
column_mapping:
ArtistId: ArtistId
- table: [Employee]
- table: [Customer]
select_permissions:
- role: *testRoleName
permission:
columns:
- CustomerId
filter:
_exists:
_table: [Employee]
_where:
EmployeeId:
_eq: X-Hasura-EmployeeId
configuration: {}
|]
@ -203,5 +220,69 @@ tests opts = do
)
}
it "works with an exists-based permissions filter" $
DataConnector.runMockedTest opts $
let required =
DataConnector.TestCaseRequired
{ _givenRequired =
let albums =
[ [ ("CustomerId", API.mkColumnFieldValue $ Aeson.Number 1)
],
[ ("CustomerId", API.mkColumnFieldValue $ Aeson.Number 2)
],
[ ("CustomerId", API.mkColumnFieldValue $ Aeson.Number 3)
]
]
in DataConnector.chinookMock {DataConnector._queryResponse = \_ -> rowsResponse albums},
_whenRequestRequired =
[graphql|
query getCustomers {
Customer {
CustomerId
}
}
|],
_thenRequired =
[yaml|
data:
Customer:
- CustomerId: 1
- CustomerId: 2
- CustomerId: 3
|]
}
in (DataConnector.defaultTestCase required)
{ _whenRequestHeaders =
[ ("X-Hasura-Role", testRoleName),
("X-Hasura-EmployeeId", "1")
],
_whenQuery =
Just
( API.QueryRequest
{ _qrTable = API.TableName ("Customer" :| []),
_qrTableRelationships = [],
_qrQuery =
API.Query
{ _qFields =
Just $
KM.fromList
[ ("CustomerId", API.ColumnField (API.ColumnName "CustomerId"))
],
_qAggregates = Nothing,
_qLimit = Nothing,
_qOffset = Nothing,
_qWhere =
Just $
API.Exists (API.UnrelatedTable $ API.TableName ("Employee" :| [])) $
API.ApplyBinaryComparisonOperator
API.Equal
(API.ComparisonColumn API.CurrentTable (API.ColumnName "EmployeeId"))
(API.ScalarValue (Aeson.Number 1)),
_qOrderBy = Nothing
}
}
)
}
rowsResponse :: [[(Aeson.Key, API.FieldValue)]] -> API.QueryResponse
rowsResponse rows = API.QueryResponse (Just $ KM.fromList <$> rows) Nothing

View File

@ -476,10 +476,11 @@ tests opts = do
_qOffset = Nothing,
_qWhere =
Just $
API.ApplyBinaryComparisonOperator
API.Equal
(API.ComparisonColumn [API.RelationshipName "SupportRepForCustomers"] (API.ColumnName "Country"))
(API.AnotherColumn (API.ComparisonColumn [] (API.ColumnName "Country"))),
API.Exists (API.RelatedTable $ API.RelationshipName "SupportRepForCustomers") $
API.ApplyBinaryComparisonOperator
API.Equal
(API.ComparisonColumn API.CurrentTable (API.ColumnName "Country"))
(API.AnotherColumn (API.ComparisonColumn API.QueryTable (API.ColumnName "Country"))),
_qOrderBy =
Just $
API.OrderBy
@ -487,10 +488,11 @@ tests opts = do
[ ( API.RelationshipName "SupportRepForCustomers",
API.OrderByRelation
( Just $
API.ApplyBinaryComparisonOperator
API.Equal
(API.ComparisonColumn [API.RelationshipName "SupportRep"] (API.ColumnName "Country"))
(API.AnotherColumn (API.ComparisonColumn [] (API.ColumnName "Country")))
API.Exists (API.RelatedTable $ API.RelationshipName "SupportRep") $
API.ApplyBinaryComparisonOperator
API.Equal
(API.ComparisonColumn API.CurrentTable (API.ColumnName "Country"))
(API.AnotherColumn (API.ComparisonColumn API.QueryTable (API.ColumnName "Country")))
)
mempty
)

View File

@ -88,6 +88,20 @@ sourceMetadata backendType config =
SupportRep:
Country:
_ceq: [ "$", "Country" ]
- table: [Album]
select_permissions:
- role: *testRoleName
permission:
columns:
- AlbumId
- Title
- ArtistId
filter:
_exists:
_table: [Customer]
_where:
CustomerId:
_eq: X-Hasura-CustomerId
configuration: *config
|]
@ -318,3 +332,48 @@ tests opts = describe "SelectPermissionsSpec" $ do
- Country: Canada
CustomerId: 32
|]
it "Query that allows access to a table using an exists-based permissions filter" $ \(testEnvironment, _) -> do
shouldReturnYaml
opts
( GraphqlEngine.postGraphqlWithHeaders
testEnvironment
[ ("X-Hasura-Role", testRoleName),
("X-Hasura-CustomerId", "1")
]
[graphql|
query getAlbums {
Album(order_by: {AlbumId: asc}, limit: 3) {
AlbumId
}
}
|]
)
[yaml|
data:
Album:
- AlbumId: 1
- AlbumId: 2
- AlbumId: 3
|]
it "Query that disallows access to a table using an exists-based permissions filter" $ \(testEnvironment, _) -> do
shouldReturnYaml
opts
( GraphqlEngine.postGraphqlWithHeaders
testEnvironment
[ ("X-Hasura-Role", testRoleName),
("X-Hasura-CustomerId", "0")
]
[graphql|
query getAlbums {
Album(order_by: {AlbumId: asc}, limit: 3) {
AlbumId
}
}
|]
)
[yaml|
data:
Album: []
|]