From b490f35b7461162b5afb321041b2a9b5315c1a3f Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Wed, 11 Mar 2026 20:24:11 -0700 Subject: [PATCH 1/4] Configure SQL parameter length based on column metadata for LIKE operations - Add Length property to DbConnectionParam and ColumnDefinition - Add GetLengthForParam to SourceDefinition for parameter length lookup - Pass lengthOverride flag through filter parsing chain for contains/startsWith/endsWith - Set SqlParameter.Size for VarChar/NVarChar/Char/NChar types in MsSqlQueryExecutor - Prevents implicit varchar(max) truncation when using LIKE with parameterized queries - Update ColumnDefinition field count assertion in serialization tests --- .../DatabasePrimitives/DatabaseObject.cs | 11 +++++++++ src/Core/Models/DbConnectionParam.cs | 6 ++++- src/Core/Models/GraphQLFilterParsers.cs | 23 +++++++++++++------ src/Core/Resolvers/BaseQueryStructure.cs | 5 ++-- src/Core/Resolvers/CosmosQueryStructure.cs | 2 +- src/Core/Resolvers/MsSqlQueryExecutor.cs | 10 +++++++- .../SerializationDeserializationTests.cs | 2 +- 7 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/Config/DatabasePrimitives/DatabaseObject.cs b/src/Config/DatabasePrimitives/DatabaseObject.cs index be1eff45ba..9548eda1ba 100644 --- a/src/Config/DatabasePrimitives/DatabaseObject.cs +++ b/src/Config/DatabasePrimitives/DatabaseObject.cs @@ -235,6 +235,16 @@ public bool IsAnyColumnNullable(List columnsToCheck) return null; } + + public virtual int? GetLengthForParam(string paramName) + { + if (Columns.TryGetValue(paramName, out ColumnDefinition? columnDefinition)) + { + return columnDefinition.Length; + } + + return null; + } } /// @@ -270,6 +280,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() { } diff --git a/src/Core/Models/DbConnectionParam.cs b/src/Core/Models/DbConnectionParam.cs index 9426f8fd49..0c2c54a5e0 100644 --- a/src/Core/Models/DbConnectionParam.cs +++ b/src/Core/Models/DbConnectionParam.cs @@ -10,11 +10,12 @@ namespace Azure.DataApiBuilder.Core.Models; /// 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; } /// @@ -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; } } diff --git a/src/Core/Models/GraphQLFilterParsers.cs b/src/Core/Models/GraphQLFilterParsers.cs index 90deb884b3..9c21a89686 100644 --- a/src/Core/Models/GraphQLFilterParsers.cs +++ b/src/Core/Models/GraphQLFilterParsers.cs @@ -483,7 +483,7 @@ private static Predicate ParseScalarType( string schemaName, string tableName, string tableAlias, - Func processLiterals, + Func processLiterals, bool isListType = false) { Column column = new(schemaName, tableName, columnName: fieldName, tableAlias); @@ -611,7 +611,7 @@ public static Predicate Parse( IInputValueDefinition argumentSchema, Column column, List fields, - Func processLiterals, + Func processLiterals, bool isListType = false) { List predicates = new(); @@ -626,6 +626,7 @@ public static Predicate Parse( variables: ctx.Variables); bool processLiteral = true; + bool lengthOverride = false; if (value is null) { @@ -671,6 +672,7 @@ public static Predicate Parse( { op = PredicateOperation.LIKE; value = $"%{EscapeLikeString((string)value)}%"; + lengthOverride = true; } break; @@ -683,16 +685,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; @@ -707,7 +712,7 @@ public static Predicate Parse( predicates.Push(new PredicateOperand(new Predicate( new PredicateOperand(column), op, - GenerateRightOperand(ctx, argumentObject, name, processLiterals, value, processLiteral) // right operand + GenerateRightOperand(ctx, argumentObject, name, column, processLiterals, value, processLiteral, lengthOverride) ))); } @@ -758,17 +763,21 @@ public static Predicate Parse( /// The GraphQL middleware context, used to resolve variable values. /// The input object type describing the argument schema. /// The name of the filter operation (e.g., "eq", "in"). + /// The target column, used to derive parameter type/size metadata. /// A function to encode or parameterize literal values for database queries. /// The value to be used as the right operand in the predicate. /// Indicates whether to process the value as a literal using processLiterals, or use its string representation directly. + /// When true, indicates the parameter length should not be constrained to the column length (used for LIKE operations). /// A representing the right operand for the predicate. private static PredicateOperand GenerateRightOperand( IMiddlewareContext ctx, InputObjectType argumentObject, string operationName, - Func processLiterals, + Column column, + Func processLiterals, object value, - bool processLiteral) + bool processLiteral, + bool lengthOverride) { if (operationName.Equals("in", StringComparison.OrdinalIgnoreCase)) { @@ -778,13 +787,13 @@ private static PredicateOperand GenerateRightOperand( argumentObject.Fields[operationName], ctx.Variables)) .Where(inValue => inValue is not null) - .Select(inValue => processLiterals(inValue!, null)) + .Select(inValue => processLiterals(inValue!, column.ColumnName, false)) .ToList(); return new PredicateOperand("(" + string.Join(", ", encodedParams) + ")"); } - return new PredicateOperand(processLiteral ? processLiterals(value, null) : value.ToString()); + return new PredicateOperand(processLiteral ? $"{processLiterals(value, column.ColumnName, lengthOverride)}" : value.ToString()); } private static string EscapeLikeString(string input) diff --git a/src/Core/Resolvers/BaseQueryStructure.cs b/src/Core/Resolvers/BaseQueryStructure.cs index 7f5564f831..67bab5258a 100644 --- a/src/Core/Resolvers/BaseQueryStructure.cs +++ b/src/Core/Resolvers/BaseQueryStructure.cs @@ -116,7 +116,7 @@ public BaseQueryStructure( /// /// Value to be assigned to parameter, which can be null for nullable columns. /// The name of the parameter - backing column name for table/views or parameter name for stored procedures. - 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)) @@ -124,7 +124,8 @@ public virtual string MakeDbConnectionParam(object? value, string? paramName = n Parameters.Add(encodedParamName, new(value, dbType: GetUnderlyingSourceDefinition().GetDbTypeForParam(paramName), - sqlDbType: GetUnderlyingSourceDefinition().GetSqlDbTypeForParam(paramName))); + sqlDbType: GetUnderlyingSourceDefinition().GetSqlDbTypeForParam(paramName), + length: lengthOverride ? -1 : GetUnderlyingSourceDefinition().GetLengthForParam(paramName))); } else { diff --git a/src/Core/Resolvers/CosmosQueryStructure.cs b/src/Core/Resolvers/CosmosQueryStructure.cs index 06558297d8..68d83557c0 100644 --- a/src/Core/Resolvers/CosmosQueryStructure.cs +++ b/src/Core/Resolvers/CosmosQueryStructure.cs @@ -68,7 +68,7 @@ public CosmosQueryStructure( } /// - 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)); diff --git a/src/Core/Resolvers/MsSqlQueryExecutor.cs b/src/Core/Resolvers/MsSqlQueryExecutor.cs index 368e5d6b00..52e3093a48 100644 --- a/src/Core/Resolvers/MsSqlQueryExecutor.cs +++ b/src/Core/Resolvers/MsSqlQueryExecutor.cs @@ -624,8 +624,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); } } diff --git a/src/Service.Tests/UnitTests/SerializationDeserializationTests.cs b/src/Service.Tests/UnitTests/SerializationDeserializationTests.cs index 12ae3ee993..2ecbac42af 100644 --- a/src/Service.Tests/UnitTests/SerializationDeserializationTests.cs +++ b/src/Service.Tests/UnitTests/SerializationDeserializationTests.cs @@ -526,7 +526,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); From 472ba5c82e23a6ef87b91e66e5999d5fa6ab1e4a Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Wed, 11 Mar 2026 21:01:39 -0700 Subject: [PATCH 2/4] Fix: Only set SqlParameter.Size when Length is explicitly provided Session context parameters (used by sp_set_session_context) are string values that get SqlDbType.NVarChar inferred by default. Previously, the Size was set to -1 (varchar(max)) for all NVarChar parameters when no explicit Length was configured, causing sp_set_session_context to fail with 'An invalid parameter or option was specified'. Now Size is only set when DbConnectionParam.Length is explicitly non-null, which occurs only for query parameters where column metadata provides a length or when lengthOverride is set for LIKE operations. --- src/Core/Resolvers/MsSqlQueryExecutor.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Core/Resolvers/MsSqlQueryExecutor.cs b/src/Core/Resolvers/MsSqlQueryExecutor.cs index 52e3093a48..7037f6aa3a 100644 --- a/src/Core/Resolvers/MsSqlQueryExecutor.cs +++ b/src/Core/Resolvers/MsSqlQueryExecutor.cs @@ -628,10 +628,11 @@ public override SqlCommand PrepareDbCommand( 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) + //if sqldbtype is varchar, nvarchar then set the length when explicitly provided + if (parameter.SqlDbType is SqlDbType.VarChar or SqlDbType.NVarChar or SqlDbType.Char or SqlDbType.NChar + && parameterEntry.Value?.Length is not null) { - parameter.Size = parameterEntry.Value?.Length ?? -1; + parameter.Size = parameterEntry.Value.Length.Value; } cmd.Parameters.Add(parameter); From 5cf3f3ac8651b9e9be37bd5bf0fe1f1616b3df22 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Wed, 11 Mar 2026 21:12:11 -0700 Subject: [PATCH 3/4] Populate ColumnDefinition.Length from database schema metadata Read ColumnSize from the schema DataTable during column introspection so that SqlParameter.Size is set to match the actual column length for string types (varchar, nvarchar, char, nchar). This completes the parameter length feature by wiring up the metadata source. --- src/Core/Services/MetadataProviders/SqlMetadataProvider.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index 6aa2712468..ddf7ea7727 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -1471,7 +1471,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 = (int)columnInfoFromAdapter["ColumnSize"] }; // Tests may try to add the same column simultaneously From 4a895ee6f6ce40f33f99afeb8fef4e83160dd3ba Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Wed, 11 Mar 2026 23:36:31 -0700 Subject: [PATCH 4/4] Revert: Do not populate ColumnDefinition.Length from schema metadata Populating Length from ColumnSize caused parameter.Size to be set for ALL string parameters (eq/neq), not just LIKE operations. This caused SQL Server to truncate parameter values to column length, breaking equality filters where the parameter value exceeds the column size. --- src/Core/Services/MetadataProviders/SqlMetadataProvider.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index ddf7ea7727..6aa2712468 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -1471,8 +1471,7 @@ 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"], - Length = (int)columnInfoFromAdapter["ColumnSize"] + IsReadOnly = (bool)columnInfoFromAdapter["IsAutoIncrement"] }; // Tests may try to add the same column simultaneously