Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/Config/DatabasePrimitives/DatabaseObject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,16 @@ public bool IsAnyColumnNullable(List<string> columnsToCheck)

return null;
}

public virtual int? GetLengthForParam(string paramName)
{
if (Columns.TryGetValue(paramName, out ColumnDefinition? columnDefinition))
{
return columnDefinition.Length;
}

return null;
}
}

/// <summary>
Expand Down Expand Up @@ -264,6 +274,7 @@ public class ColumnDefinition
public bool IsNullable { get; set; }
public bool IsReadOnly { get; set; }
public object? DefaultValue { get; set; }
public int? Length { get; set; }

public ColumnDefinition() { }

Expand Down
6 changes: 5 additions & 1 deletion src/Core/Models/DbConnectionParam.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ namespace Azure.DataApiBuilder.Core.Models;
/// </summary>
public class DbConnectionParam
{
public DbConnectionParam(object? value, DbType? dbType = null, SqlDbType? sqlDbType = null)
public DbConnectionParam(object? value, DbType? dbType = null, SqlDbType? sqlDbType = null, int? length = null)
{
Value = value;
DbType = dbType;
SqlDbType = sqlDbType;
Length = length;
}

/// <summary>
Expand All @@ -31,4 +32,7 @@ public DbConnectionParam(object? value, DbType? dbType = null, SqlDbType? sqlDbT
// This is being made nullable
// because it's not populated for DB's other than MSSQL.
public SqlDbType? SqlDbType { get; set; }

// Nullable integer parameter representing length. nullable for back compatibility and for where its not needed
public int? Length { get; set; }
}
98 changes: 94 additions & 4 deletions src/Core/Models/GraphQLFilterParsers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ private static Predicate ParseScalarType(
string schemaName,
string tableName,
string tableAlias,
Func<object, string?, string> processLiterals,
Func<object, string?, bool, string> processLiterals,
bool isListType = false)
{
Column column = new(schemaName, tableName, columnName: fieldName, tableAlias);
Expand Down Expand Up @@ -614,7 +614,7 @@ public static Predicate Parse(
IInputField argumentSchema,
Column column,
List<ObjectFieldNode> fields,
Func<object, string?, string> processLiterals,
Func<object, string?, bool, string> processLiterals,
bool isListType = false)
{
List<PredicateOperand> predicates = new();
Expand All @@ -635,6 +635,8 @@ public static Predicate Parse(
continue;
}

bool lengthOverride = false;

PredicateOperation op;
switch (name)
{
Expand All @@ -655,6 +657,15 @@ public static Predicate Parse(
break;
case "gte":
op = PredicateOperation.GreaterThanOrEqual;
break;
case "in":
op = PredicateOperation.IN;
value = PreprocessInOperatorValues(value);
if (value == null) // nothing to process and returns empty result set
{
continue;
}

break;
case "contains":
if (isListType)
Expand All @@ -665,6 +676,7 @@ public static Predicate Parse(
{
op = PredicateOperation.LIKE;
value = $"%{EscapeLikeString((string)value)}%";
lengthOverride = true;
}

break;
Expand All @@ -677,16 +689,19 @@ public static Predicate Parse(
{
op = PredicateOperation.NOT_LIKE;
value = $"%{EscapeLikeString((string)value)}%";
lengthOverride = true;
}

break;
case "startsWith":
op = PredicateOperation.LIKE;
value = $"{EscapeLikeString((string)value)}%";
lengthOverride = true;
break;
case "endsWith":
op = PredicateOperation.LIKE;
value = $"%{EscapeLikeString((string)value)}";
lengthOverride = true;
break;
case "isNull":
processLiteral = false;
Expand All @@ -701,13 +716,88 @@ public static Predicate Parse(
predicates.Push(new PredicateOperand(new Predicate(
new PredicateOperand(column),
op,
new PredicateOperand(processLiteral ? $"{processLiterals(value, column.ColumnName)}" : value.ToString()))
));
GenerateRightOperand(ctx, argumentObject, name, column, processLiterals, value, processLiteral, lengthOverride)
)));
}

return GQLFilterParser.MakeChainPredicate(predicates, PredicateOperation.AND);
}

/// <summary>
/// Preprocesses and validates the values provided for the IN operator in a GraphQL filter.
/// </summary>
/// <param name="value">The raw value extracted from the GraphQL filter argument, expected to be a list of <see cref="IValueNode"/>.</param>
/// <returns>
/// A filtered list of <see cref="IValueNode"/> with non-null values, or null if the list is empty.
/// </returns>
/// <exception cref="DataApiBuilderException">
/// Thrown if the input is not a list of <see cref="IValueNode"/> or if the list contains more than 100 items.
/// </exception>
private static object? PreprocessInOperatorValues(object value)
{
if (value is not List<IValueNode> inValues)
{
throw new DataApiBuilderException(
message: "Bad syntax: Invalid IN operator type value",
statusCode: HttpStatusCode.BadRequest,
subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest);
}
else if (inValues.Count > 100)
{
throw new DataApiBuilderException(
message: "IN operator filter object cannot process more than 100 values at a time.",
statusCode: HttpStatusCode.BadRequest,
subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest);
}

// does not match any rows even for values NULL because SQL engine is completely ignoring it
List<IValueNode> filteredNodes = inValues.Where(node => node.Value != null).ToList();
return filteredNodes.Count == 0 ? null : filteredNodes;
}

/// <summary>
/// Generates the right operand for a predicate based on the operation name and value.
/// For the "in" operation, it extracts and encodes each value in the list using the provided processLiterals function,
/// and returns a comma-separated string representation suitable for use in a SQL IN clause.
/// For other operations, it either processes the literal value or returns its string representation,
/// depending on the processLiteral flag.
/// </summary>
/// <param name="ctx">The GraphQL middleware context, used to resolve variable values.</param>
/// <param name="argumentObject">The input object type describing the argument schema.</param>
/// <param name="operationName">The name of the filter operation (e.g., "eq", "in").</param>
/// <param name="column">The target column, used to derive parameter type/size metadata.</param>
/// <param name="processLiterals">A function to encode or parameterize literal values for database queries.</param>
/// <param name="value">The value to be used as the right operand in the predicate.</param>
/// <param name="processLiteral">Indicates whether to process the value as a literal using processLiterals, or use its string representation directly.</param>
/// <param name="lengthOverride">When true, indicates the parameter length should not be constrained to the column length (used for LIKE operations).</param>
/// <returns>A <see cref="PredicateOperand"/> representing the right operand for the predicate.</returns>
private static PredicateOperand GenerateRightOperand(
IMiddlewareContext ctx,
InputObjectType argumentObject,
string operationName,
Column column,
Func<object, string?, bool, string> processLiterals,
object value,
bool processLiteral,
bool lengthOverride)
{
if (operationName.Equals("in", StringComparison.OrdinalIgnoreCase))
{
List<string> encodedParams = ((List<IValueNode>)value)
.Select(listValue => ExecutionHelper.ExtractValueFromIValueNode(
listValue,
argumentObject.Fields[operationName],
ctx.Variables))
.Where(inValue => inValue is not null)
.Select(inValue => processLiterals(inValue!, column.ColumnName, false))
.ToList();

return new PredicateOperand("(" + string.Join(", ", encodedParams) + ")");
}

return new PredicateOperand(processLiteral ? processLiterals(value, column.ColumnName, lengthOverride) : value.ToString());
}

private static string EscapeLikeString(string input)
{
input = input.Replace(@"\", @"\\");
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 @@ -177,7 +177,7 @@ public enum PredicateOperation
None,
Equal, GreaterThan, LessThan, GreaterThanOrEqual, LessThanOrEqual, NotEqual,
AND, OR, LIKE, NOT_LIKE,
IS, IS_NOT, EXISTS, ARRAY_CONTAINS, NOT_ARRAY_CONTAINS
IS, IS_NOT, EXISTS, ARRAY_CONTAINS, NOT_ARRAY_CONTAINS, IN
}

/// <summary>
Expand Down
5 changes: 3 additions & 2 deletions src/Core/Resolvers/BaseQueryStructure.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,16 @@ public BaseQueryStructure(
/// </summary>
/// <param name="value">Value to be assigned to parameter, which can be null for nullable columns.</param>
/// <param name="paramName"> The name of the parameter - backing column name for table/views or parameter name for stored procedures.</param>
public virtual string MakeDbConnectionParam(object? value, string? paramName = null)
public virtual string MakeDbConnectionParam(object? value, string? paramName = null, bool lengthOverride = false)
{
string encodedParamName = GetEncodedParamName(Counter.Next());
if (!string.IsNullOrEmpty(paramName))
{
Parameters.Add(encodedParamName,
new(value,
dbType: GetUnderlyingSourceDefinition().GetDbTypeForParam(paramName),
sqlDbType: GetUnderlyingSourceDefinition().GetSqlDbTypeForParam(paramName)));
sqlDbType: GetUnderlyingSourceDefinition().GetSqlDbTypeForParam(paramName),
length: lengthOverride ? -1 : GetUnderlyingSourceDefinition().GetLengthForParam(paramName)));
}
else
{
Expand Down
2 changes: 2 additions & 0 deletions src/Core/Resolvers/BaseSqlQueryBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,8 @@ protected virtual string Build(PredicateOperation op)
return "IS NOT";
case PredicateOperation.EXISTS:
return "EXISTS";
case PredicateOperation.IN:
return "IN";
default:
throw new ArgumentException($"Cannot build unknown predicate operation {op}.");
}
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Resolvers/CosmosQueryStructure.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public CosmosQueryStructure(
}

/// <inheritdoc/>
public override string MakeDbConnectionParam(object? value, string? columnName = null)
public override string MakeDbConnectionParam(object? value, string? columnName = null, bool lengthOverride = false)
{
string encodedParamName = $"{PARAM_NAME_PREFIX}param{Counter.Next()}";
Parameters.Add(encodedParamName, new(value));
Expand Down
10 changes: 9 additions & 1 deletion src/Core/Resolvers/MsSqlQueryExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,16 @@ public override SqlCommand PrepareDbCommand(
{
SqlParameter parameter = cmd.CreateParameter();
parameter.ParameterName = parameterEntry.Key;
parameter.Value = parameterEntry.Value.Value ?? DBNull.Value;
parameter.Value = parameterEntry.Value?.Value ?? DBNull.Value;

PopulateDbTypeForParameter(parameterEntry, parameter);

//if sqldbtype is varchar, nvarchar then set the length
if (parameter.SqlDbType is SqlDbType.VarChar or SqlDbType.NVarChar or SqlDbType.Char or SqlDbType.NChar)
{
parameter.Size = parameterEntry.Value?.Length ?? -1;
}

cmd.Parameters.Add(parameter);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1321,7 +1321,8 @@ private async Task PopulateSourceDefinitionAsync(
SystemType = (Type)columnInfoFromAdapter["DataType"],
// An auto-increment column is also considered as a read-only column. For other types of read-only columns,
// the flag is populated later via PopulateColumnDefinitionsWithReadOnlyFlag() method.
IsReadOnly = (bool)columnInfoFromAdapter["IsAutoIncrement"]
IsReadOnly = (bool)columnInfoFromAdapter["IsAutoIncrement"],
Length = GetDatabaseType() is DatabaseType.MSSQL ? (int)columnInfoFromAdapter["ColumnSize"] : null
};

// Tests may try to add the same column simultaneously
Expand Down
3 changes: 2 additions & 1 deletion src/Service.Tests/DatabaseSchema-MsSql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,8 @@ VALUES (1, 'Awesome book', 1234),
(17, 'CONN%_CONN', 1234),
(18, '[Special Book]', 1234),
(19, 'ME\YOU', 1234),
(20, 'C:\\LIFE', 1234);
(20, 'C:\\LIFE', 1234),
(21, '', 1234);
SET IDENTITY_INSERT books OFF

SET IDENTITY_INSERT books_mm ON
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ public async Task RequestMaxUsingNegativeOne()
{
""id"": 20,
""title"": ""C:\\\\LIFE""
},
{
""id"": 21,
""title"": """"
}
],
""endCursor"": null,
Expand Down Expand Up @@ -277,6 +281,10 @@ public async Task RequestNoParamFullConnection()
{
""id"": 20,
""title"": ""C:\\\\LIFE""
},
{
""id"": 21,
""title"": """"
}
],
""endCursor"": null,
Expand Down Expand Up @@ -441,7 +449,7 @@ public async Task RequestNestedPaginationQueries()
title
publishers {
name
books(first: 2, after:""" + after + @"""){
books(first: 2, after:""" + after + @"""){
items {
id
title
Expand Down Expand Up @@ -682,8 +690,7 @@ public async Task RequestDeeplyNestedPaginationQueries()
}
],
""hasNextPage"": true,
""endCursor"": """
+ SqlPaginationUtil.Base64Encode($"[{{\"EntityName\":\"Book\",\"FieldName\":\"id\",\"FieldValue\":3,\"Direction\":0}}]") + @"""
""endCursor"": """ + SqlPaginationUtil.Base64Encode($"[{{\"EntityName\":\"Book\",\"FieldName\":\"id\",\"FieldValue\":3,\"Direction\":0}}]") + @"""
}
}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,35 @@ SELECT TOP 1 content FROM reviews
await QueryWithMultipleColumnPrimaryKey(msSqlQuery);
}

/// <sumary>
/// Test if filter param successfully filters when string filter
/// </summary>
[TestMethod]
public virtual async Task TestFilterParamForStringFilter()
{
string graphQLQueryName = "books";
string graphQLQuery = @"{
books( " + Service.GraphQLBuilder.Queries.QueryBuilder.FILTER_FIELD_NAME + @":{ title: {eq:""Awesome book""}}) {
items {
id
title
}
}
}";

string expected = @"
[
{
""id"": 1,
""title"": ""Awesome book""
}
]";

JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false);

SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.GetProperty("items").ToString());
}

[TestMethod]
public async Task QueryWithNullableForeignKey()
{
Expand Down Expand Up @@ -421,8 +450,8 @@ public async Task TestStoredProcedureQueryWithNoDefaultInConfig()
public async Task TestSupportForAggregationsWithAliases()
{
string msSqlQuery = @"
SELECT
MAX(categoryid) AS max,
SELECT
MAX(categoryid) AS max,
MAX(price) AS max_price,
MIN(price) AS min_price,
AVG(price) AS avg_price,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ private static void VerifyColumnDefinitionSerializationDeserialization(ColumnDef
{
// test number of properties/fields defined in Column Definition
int fields = typeof(ColumnDefinition).GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).Length;
Assert.AreEqual(fields, 8);
Assert.AreEqual(fields, 9);

// test values
expectedColumnDefinition.Equals(deserializedColumnDefinition);
Expand Down
Loading