mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-09-11 10:46:25 +03:00
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:
parent
dcca0c6275
commit
04ae6abf78
@ -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": {
|
||||
|
@ -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",
|
||||
|
@ -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": {
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
|
10
dc-agents/dc-api-types/src/models/ComparisonCapabilities.ts
Normal file
10
dc-agents/dc-api-types/src/models/ComparisonCapabilities.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
import type { CrossTableComparisonCapabilities } from './CrossTableComparisonCapabilities';
|
||||
|
||||
export type ComparisonCapabilities = {
|
||||
cross_table?: CrossTableComparisonCapabilities;
|
||||
};
|
||||
|
@ -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>;
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
};
|
||||
|
13
dc-agents/dc-api-types/src/models/ExistsExpression.ts
Normal file
13
dc-agents/dc-api-types/src/models/ExistsExpression.ts
Normal 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;
|
||||
};
|
||||
|
9
dc-agents/dc-api-types/src/models/ExistsInTable.ts
Normal file
9
dc-agents/dc-api-types/src/models/ExistsInTable.ts
Normal 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);
|
||||
|
@ -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);
|
||||
|
||||
|
9
dc-agents/dc-api-types/src/models/RelatedTable.ts
Normal file
9
dc-agents/dc-api-types/src/models/RelatedTable.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
export type RelatedTable = {
|
||||
relationship: string;
|
||||
type: 'related';
|
||||
};
|
||||
|
11
dc-agents/dc-api-types/src/models/UnrelatedTable.ts
Normal file
11
dc-agents/dc-api-types/src/models/UnrelatedTable.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
import type { TableName } from './TableName';
|
||||
|
||||
export type UnrelatedTable = {
|
||||
table: TableName;
|
||||
type: 'unrelated';
|
||||
};
|
||||
|
28
dc-agents/package-lock.json
generated
28
dc-agents/package-lock.json
generated
@ -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
|
||||
|
4
dc-agents/reference/package-lock.json
generated
4
dc-agents/reference/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -19,6 +19,11 @@ const scalarTypes: ScalarTypesCapabilities = {
|
||||
|
||||
const capabilities: Capabilities = {
|
||||
relationships: {},
|
||||
comparisons: {
|
||||
cross_table: {
|
||||
supports_relations: true
|
||||
}
|
||||
},
|
||||
graphqlSchema: schemaDoc,
|
||||
scalarTypes: scalarTypes
|
||||
}
|
||||
|
@ -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}`) };
|
||||
|
@ -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)
|
||||
|
16
dc-agents/sqlite/package-lock.json
generated
16
dc-agents/sqlite/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -9,6 +9,11 @@ export const capabilitiesResponse: CapabilitiesResponse = {
|
||||
supportsPrimaryKeys: true
|
||||
},
|
||||
relationships: {},
|
||||
comparisons: {
|
||||
cross_table: {
|
||||
supports_relations: true
|
||||
}
|
||||
},
|
||||
explain: {},
|
||||
... ( envToBool('METRICS') ? { metrics: {} } : {} )
|
||||
},
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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 =
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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")]
|
||||
|
319
server/tests-dc-api/Test/QuerySpec/FilteringSpec.hs
Normal file
319
server/tests-dc-api/Test/QuerySpec/FilteringSpec.hs
Normal 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
|
@ -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 =
|
||||
|
@ -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 =
|
||||
|
@ -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 {}
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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: []
|
||||
|]
|
||||
|
Loading…
Reference in New Issue
Block a user