Skip to content

Commit

Permalink
Cherry pick #2134 and #2135 (CosmosDB: Query Filter by Id Variable su…
Browse files Browse the repository at this point in the history
…pport and String Array search) (#2147)

Note: these two PRs are ported together as #2135 depends on changes from
#2134. So if we ever need to revert, this joint commit will mean that we
can't put release branch in broken state.


# #2134 
## Why make this change?

ref. #1953

## What is this change?

Added variable support if query filter has ID

- Example REST and/or GraphQL request to demonstrate modifications
- Example of CLI usage to demonstrate modifications
# #2135 

## Why make this change?

ref.
#1897 (comment)

## What is this change?
Added `contains` and `notContains` support for string Array Type.

1. Data schema:
``` gql
type Planet @model {
    id : ID!,
    name : String,
    tags: [String!]
}
```

2. GraphQl :
```graphql
{
      planets(" + QueryBuilder.FILTER_FIELD_NAME + @" : {tags: { contains : ""tag1""}})
      {
          items {
              id
              name
          }
      }
}
```

4. Cosmos DB Query:
```sql
SELECT c.id, c.name FROM c where ARRAY_CONTAINS(c.tags, 'tag1')
```

5. GraphQl :
```graphql
{
    planets(" + QueryBuilder.FILTER_FIELD_NAME + @" : {tags: { notContains : ""tag3""}})
    {
        items {
            id
            name
        }
    }
}
```

7. Cosmos DB Query:
```sql
SELECT c.id, c.name FROM c where NOT ARRAY_CONTAINS(c.tags, 'tag3')
```

## How was this tested?

- [x] Integration Tests
- [ ] Unit Tests

---------

Co-authored-by: Sourabh Jain <sourabhjain@microsoft.com>
  • Loading branch information
seantleonard and sourabh1007 committed Apr 2, 2024
1 parent 8590aef commit 446f8b2
Show file tree
Hide file tree
Showing 5 changed files with 168 additions and 31 deletions.
72 changes: 54 additions & 18 deletions src/Core/Models/GraphQLFilterParsers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -234,17 +234,33 @@ public GQLFilterParser(RuntimeConfigProvider runtimeConfigProvider, IMetadataPro
}
else
{
bool isListType = false;
if (queryStructure is CosmosQueryStructure)
{
FieldDefinitionNode? fieldDefinitionNode = metadataProvider.GetSchemaGraphQLFieldFromFieldName(queryStructure.EntityName, name);
if (fieldDefinitionNode is null)
{
throw new DataApiBuilderException(
message: "Invalid filter object used as a nested field input value type.",
statusCode: HttpStatusCode.BadRequest,
subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest);
}

isListType = fieldDefinitionNode.Type.IsListType();
}

predicates.Push(
new PredicateOperand(
ParseScalarType(
ctx,
argumentSchema: filterArgumentObject.Fields[name],
backingColumnName,
subfields,
schemaName,
sourceName,
sourceAlias,
queryStructure.MakeDbConnectionParam)));
new PredicateOperand(
ParseScalarType(
ctx: ctx,
argumentSchema: filterArgumentObject.Fields[name],
name: backingColumnName,
fields: subfields,
schemaName: schemaName,
tableName: sourceName,
tableAlias: sourceAlias,
processLiterals: queryStructure.MakeDbConnectionParam,
isListType: isListType)));
}
}
}
Expand Down Expand Up @@ -451,7 +467,7 @@ public HttpContext GetHttpContextFromMiddlewareContext(IMiddlewareContext ctx)

/// <summary>
/// Calls the appropriate scalar type filter parser based on the type of
/// the fields
/// the fields.
/// </summary>
/// <param name="ctx">The GraphQL context, used to get the query variables</param>
/// <param name="argumentSchema">An IInputField object which describes the schema of the scalar input argument (e.g. IntFilterInput)</param>
Expand All @@ -461,6 +477,7 @@ public HttpContext GetHttpContextFromMiddlewareContext(IMiddlewareContext ctx)
/// <param name="tableName">The name of the table underlying the *FilterInput being processed</param>
/// <param name="tableAlias">The alias of the table underlying the *FilterInput being processed</param>
/// <param name="processLiterals">Parametrizes literals before they are written in string predicate operands</param>
/// <param name="isListType">Flag to give a hint about the node type. It is only applicable for CosmosDB</param>
private static Predicate ParseScalarType(
IMiddlewareContext ctx,
IInputField argumentSchema,
Expand All @@ -469,11 +486,12 @@ public HttpContext GetHttpContextFromMiddlewareContext(IMiddlewareContext ctx)
string schemaName,
string tableName,
string tableAlias,
Func<object, string?, string> processLiterals)
Func<object, string?, string> processLiterals,
bool isListType = false)
{
Column column = new(schemaName, tableName, columnName: name, tableAlias);

return FieldFilterParser.Parse(ctx, argumentSchema, column, fields, processLiterals);
return FieldFilterParser.Parse(ctx, argumentSchema, column, fields, processLiterals, isListType);
}

/// <summary>
Expand Down Expand Up @@ -590,12 +608,14 @@ public static class FieldFilterParser
/// <param name="column">The table column targeted by the field</param>
/// <param name="fields">The subfields of the scalar field</param>
/// <param name="processLiterals">Parametrizes literals before they are written in string predicate operands</param>
/// <param name="isListType">Flag which gives a hint about the node type in the given schema. only for CosmosDB it can be of list type. Refer <a href=https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/array-contains>here</a>.</param>
public static Predicate Parse(
IMiddlewareContext ctx,
IInputField argumentSchema,
Column column,
List<ObjectFieldNode> fields,
Func<object, string?, string> processLiterals)
Func<object, string?, string> processLiterals,
bool isListType = false)
{
List<PredicateOperand> predicates = new();

Expand Down Expand Up @@ -637,12 +657,28 @@ public static class FieldFilterParser
op = PredicateOperation.GreaterThanOrEqual;
break;
case "contains":
op = PredicateOperation.LIKE;
value = $"%{EscapeLikeString((string)value)}%";
if (isListType)
{
op = PredicateOperation.ARRAY_CONTAINS;
}
else
{
op = PredicateOperation.LIKE;
value = $"%{EscapeLikeString((string)value)}%";
}

break;
case "notContains":
op = PredicateOperation.NOT_LIKE;
value = $"%{EscapeLikeString((string)value)}%";
if (isListType)
{
op = PredicateOperation.NOT_ARRAY_CONTAINS;
}
else
{
op = PredicateOperation.NOT_LIKE;
value = $"%{EscapeLikeString((string)value)}%";
}

break;
case "startsWith":
op = PredicateOperation.LIKE;
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Models/SqlQueryStructures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ public enum PredicateOperation
None,
Equal, GreaterThan, LessThan, GreaterThanOrEqual, LessThanOrEqual, NotEqual,
AND, OR, LIKE, NOT_LIKE,
IS, IS_NOT, EXISTS
IS, IS_NOT, EXISTS, ARRAY_CONTAINS, NOT_ARRAY_CONTAINS
}

/// <summary>
Expand Down
10 changes: 9 additions & 1 deletion src/Core/Resolvers/CosmosQueryBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ protected override string Build(PredicateOperation op)
return "";
case PredicateOperation.IS_NOT:
return "NOT";
case PredicateOperation.ARRAY_CONTAINS:
return "ARRAY_CONTAINS";
case PredicateOperation.NOT_ARRAY_CONTAINS:
return "NOT ARRAY_CONTAINS";
default:
throw new ArgumentException($"Cannot build unknown predicate operation {op}.");
}
Expand All @@ -125,7 +129,11 @@ protected override string Build(Predicate? predicate)
}

string predicateString;
if (ResolveOperand(predicate.Right).Equals(GQLFilterParser.NullStringValue))
if (predicate.Op == PredicateOperation.ARRAY_CONTAINS || predicate.Op == PredicateOperation.NOT_ARRAY_CONTAINS)
{
predicateString = $" {Build(predicate.Op)} ( {ResolveOperand(predicate.Left)}, {ResolveOperand(predicate.Right)})";
}
else if (ResolveOperand(predicate.Right).Equals(GQLFilterParser.NullStringValue))
{
predicateString = $" {Build(predicate.Op)} IS_NULL({ResolveOperand(predicate.Left)})";
}
Expand Down
48 changes: 39 additions & 9 deletions src/Core/Resolvers/CosmosQueryEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Azure.DataApiBuilder.Core.Services;
using Azure.DataApiBuilder.Core.Services.MetadataProviders;
using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries;
using Azure.DataApiBuilder.Service.Services;
using HotChocolate.Language;
using HotChocolate.Resolvers;
using Microsoft.AspNetCore.Mvc;
Expand Down Expand Up @@ -68,7 +69,7 @@ public class CosmosQueryEngine : IQueryEngine

CosmosClient client = _clientProvider.Clients[dataSourceName];
Container container = client.GetDatabase(structure.Database).GetContainer(structure.Container);
(string idValue, string partitionKeyValue) = await GetIdAndPartitionKey(parameters, container, structure, metadataStoreProvider);
(string idValue, string partitionKeyValue) = await GetIdAndPartitionKey(context, parameters, container, structure, metadataStoreProvider);

foreach (KeyValuePair<string, DbConnectionParam> parameterEntry in structure.Parameters)
{
Expand Down Expand Up @@ -271,7 +272,22 @@ private static async Task<string> GetPartitionKeyPath(Container container, ISqlM
}

#nullable enable
private static async Task<(string? idValue, string? partitionKeyValue)> GetIdAndPartitionKey(IDictionary<string, object?> parameters, Container container, CosmosQueryStructure structure, ISqlMetadataProvider metadataStoreProvider)

/// <summary>
/// Resolve partition key and id value from input parameters.
/// </summary>
/// <param name="context">Provide the information about variables and filters</param>
/// <param name="parameters">Contains argument information such as id, filter</param>
/// <param name="container">Container instance to get the container properties such as partition path</param>
/// <param name="structure">Fallback to get partition path information</param>
/// <param name="metadataStoreProvider">Set partition key path, fetched from container properties</param>
/// <returns></returns>
private static async Task<(string? idValue, string? partitionKeyValue)> GetIdAndPartitionKey(
IMiddlewareContext context,
IDictionary<string, object?> parameters,
Container container,
CosmosQueryStructure structure,
ISqlMetadataProvider metadataStoreProvider)
{
string? partitionKeyValue = null, idValue = null;
string partitionKeyPath = await GetPartitionKeyPath(container, metadataStoreProvider);
Expand All @@ -287,8 +303,8 @@ private static async Task<(string? idValue, string? partitionKeyValue)> GetIdAnd
else if (parameterEntry.Key == QueryBuilder.FILTER_FIELD_NAME)
{
// Mapping partitionKey and id value from filter object if filter keyword exists in args
partitionKeyValue = GetPartitionKeyValue(partitionKeyPath, parameterEntry.Value);
idValue = GetIdValue(parameterEntry.Value);
partitionKeyValue = GetPartitionKeyValue(context, partitionKeyPath, parameterEntry.Value);
idValue = GetIdValue(context, parameterEntry.Value);
}
}

Expand All @@ -310,7 +326,7 @@ private static async Task<(string? idValue, string? partitionKeyValue)> GetIdAnd
/// <param name="parameter"></param>
/// <returns></returns>
#nullable enable
private static string? GetPartitionKeyValue(string? partitionKeyPath, object? parameter)
private static string? GetPartitionKeyValue(IMiddlewareContext context, string? partitionKeyPath, object? parameter)
{
if (parameter is null || partitionKeyPath is null)
{
Expand All @@ -324,7 +340,10 @@ private static async Task<(string? idValue, string? partitionKeyValue)> GetIdAnd
if (partitionKeyPath == string.Empty
&& string.Equals(item.Name.Value, "eq", StringComparison.OrdinalIgnoreCase))
{
return item.Value.Value?.ToString();
return ExecutionHelper.ExtractValueFromIValueNode(
item.Value,
context.Selection.Field.Arguments[QueryBuilder.FILTER_FIELD_NAME],
context.Variables)?.ToString();
}

if (partitionKeyPath != string.Empty
Expand All @@ -333,7 +352,7 @@ private static async Task<(string? idValue, string? partitionKeyValue)> GetIdAnd
// Recursion to mapping next inner object
int index = partitionKeyPath.IndexOf(currentEntity);
string newPartitionKeyPath = partitionKeyPath[(index + currentEntity.Length)..partitionKeyPath.Length];
return GetPartitionKeyValue(newPartitionKeyPath, item.Value.Value);
return GetPartitionKeyValue(context, newPartitionKeyPath, item.Value.Value);
}
}

Expand All @@ -345,7 +364,7 @@ private static async Task<(string? idValue, string? partitionKeyValue)> GetIdAnd
/// </summary>
/// <param name="parameter"></param>
/// <returns></returns>
private static string? GetIdValue(object? parameter)
private static string? GetIdValue(IMiddlewareContext context, object? parameter)
{
if (parameter != null)
{
Expand All @@ -354,7 +373,18 @@ private static async Task<(string? idValue, string? partitionKeyValue)> GetIdAnd
if (string.Equals(item.Name.Value, "id", StringComparison.OrdinalIgnoreCase))
{
IList<ObjectFieldNode>? idValueObj = (IList<ObjectFieldNode>?)item.Value.Value;
return idValueObj?.FirstOrDefault(x => x.Name.Value == "eq")?.Value?.Value?.ToString();

ObjectFieldNode? itemToResolve = idValueObj?.FirstOrDefault(x => x.Name.Value == "eq");
if (itemToResolve is null)
{
return null;
}

return ExecutionHelper.ExtractValueFromIValueNode(
itemToResolve.Value,
context.Selection.Field.Arguments[QueryBuilder.FILTER_FIELD_NAME],
context.Variables)?
.ToString();
}
}
}
Expand Down
67 changes: 65 additions & 2 deletions src/Service.Tests/CosmosTests/QueryFilterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -262,9 +262,10 @@ public async Task TestStringMultiFiltersOnArrayTypeWithOrCondition()
await ExecuteAndValidateResult(_graphQLQueryName, gqlQuery, dbQueryWithJoin);
}

private async Task ExecuteAndValidateResult(string graphQLQueryName, string gqlQuery, string dbQuery)
private async Task ExecuteAndValidateResult(string graphQLQueryName, string gqlQuery, string dbQuery, Dictionary<string, object> variables = null)
{
JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQueryName, query: gqlQuery);
string authToken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: AuthorizationType.Authenticated.ToString());
JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQueryName, query: gqlQuery, authToken: authToken, variables: variables);
JsonDocument expected = await ExecuteCosmosRequestAsync(dbQuery, _pageSize, null, _containerName);
ValidateResults(actual.GetProperty("items"), expected.RootElement);
}
Expand Down Expand Up @@ -1090,6 +1091,68 @@ public async Task TestQueryFilterFieldAuth_ExcludeTakesPredecence()
Assert.IsTrue(errorMessage.Contains(DataApiBuilderException.GRAPHQL_FILTER_FIELD_AUTHZ_FAILURE));

}

/// <summary>
/// Tests that the field level query filter work with list type for 'contains' operator
/// </summary>
[TestMethod]
public async Task TestQueryFilterContains_WithStringArray()
{
string gqlQuery = @"{
planets(" + QueryBuilder.FILTER_FIELD_NAME + @" : {tags: { contains : ""tag1""}})
{
items {
id
name
}
}
}";

string dbQuery = $"SELECT c.id, c.name FROM c where ARRAY_CONTAINS(c.tags, 'tag1')";
await ExecuteAndValidateResult(_graphQLQueryName, gqlQuery, dbQuery);
}

/// <summary>
/// Tests that the field level query filter work with list type for 'notcontains' operator.
/// </summary>
[TestMethod]
public async Task TestQueryFilterNotContains_WithStringArray()
{
string gqlQuery = @"{
planets(" + QueryBuilder.FILTER_FIELD_NAME + @" : {tags: { notContains : ""tag3""}})
{
items {
id
name
}
}
}";

string dbQuery = $"SELECT c.id, c.name FROM c where NOT ARRAY_CONTAINS(c.tags, 'tag3')";
await ExecuteAndValidateResult(_graphQLQueryName, gqlQuery, dbQuery);
}

/// <summary>
/// Tests that the pk level query filter is working with variables.
/// </summary>
[TestMethod]
public async Task TestQueryIdFilterField_WithVariables()
{
string gqlQuery = @"
query ($id: ID) {
planets(" + QueryBuilder.FILTER_FIELD_NAME + @" : {id: {eq : $id}})
{
items {
id
name
}
}
}
";

string dbQuery = $"SELECT c.id, c.name FROM c where c.id = \"{_idList[0]}\"";
await ExecuteAndValidateResult(_graphQLQueryName, gqlQuery, dbQuery, variables: new() { { "id", _idList[0] } });
}
#endregion

[TestCleanup]
Expand Down

0 comments on commit 446f8b2

Please sign in to comment.