From 0bdbf83c9767c05fb4abb9b5a8172da8a3a2eba5 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Thu, 2 Jul 2026 11:50:50 -0700 Subject: [PATCH 1/4] Allow read request for vector data type --- src/Core/Services/ExecutionHelper.cs | 134 ++++++++++++++++++--------- 1 file changed, 89 insertions(+), 45 deletions(-) diff --git a/src/Core/Services/ExecutionHelper.cs b/src/Core/Services/ExecutionHelper.cs index fc2e3e9da1..5ab002b4d6 100644 --- a/src/Core/Services/ExecutionHelper.cs +++ b/src/Core/Services/ExecutionHelper.cs @@ -188,56 +188,79 @@ fieldValue.ValueKind is not (JsonValueKind.Undefined or JsonValueKind.Null)) // The selection type can be a wrapper type like NonNullType or ListType. // To get the most inner type (aka the named type) we use our named type helper. ITypeDefinition namedType = context.Selection.Field.Type.NamedType(); - - // Each scalar in HotChocolate has a runtime type representation. - // In order to let scalar values flow through the GraphQL type completion - // efficiently we want the leaf types to match the runtime type. - // If that is not the case a value will go through the type converter to try to - // transform it into the runtime type. - // We also want to ensure here that we do not unnecessarily convert values to - // strings and then force the conversion to parse them. - try - { - return namedType switch - { - StringType => fieldValue.GetString(), // spec - UnsignedByteType => fieldValue.GetByte(), - ShortType => fieldValue.GetInt16(), - IntType => fieldValue.GetInt32(), // spec - LongType => fieldValue.GetInt64(), - FloatType => fieldValue.GetDouble(), // spec - SingleType => fieldValue.GetSingle(), - DecimalType => fieldValue.GetDecimal(), - DateTimeType => DateTimeOffset.TryParse(fieldValue.GetString()!, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal, out DateTimeOffset date) ? date : null, // for DW when datetime is null it will be in "" (double quotes) due to stringagg parsing and hence we need to ensure parsing is correct. - DateType => DateTimeOffset.TryParse(fieldValue.GetString()!, out DateTimeOffset date) ? date : null, - HotChocolate.Types.NodaTime.LocalTimeType => fieldValue.GetString()!.Equals("null", StringComparison.OrdinalIgnoreCase) ? null : LocalTimePattern.ExtendedIso.Parse(fieldValue.GetString()!).Value, - // HC v16 deprecated ByteArrayType in favor of Base64StringType; DAB-generated - // schemas now bind byte[] fields to Base64StringType (see BYTEARRAY_TYPE). - Base64StringType => fieldValue.GetBytesFromBase64(), - BooleanType => fieldValue.GetBoolean(), // spec - UrlType => new Uri(fieldValue.GetString()!), - UuidType => fieldValue.GetGuid(), - DurationType => XmlConvert.ToTimeSpan(fieldValue.GetString()!), - AnyType => fieldValue.ToString(), - _ => fieldValue.GetString() - }; - } - catch (Exception ex) when (ex is InvalidOperationException or FormatException) - { - // this usually means that database column type was changed since generating the GraphQL schema - // for e.g. System.FormatException - One of the identified items was in an invalid format - // System.InvalidOperationException - The requested operation requires an element of type 'Number', but the target element has type 'String'. - throw new DataApiBuilderException( - message: $"The {context.Selection.Field.Name} value could not be parsed for configured GraphQL data type {namedType.Name}", - statusCode: HttpStatusCode.Conflict, - subStatusCode: DataApiBuilderException.SubStatusCodes.GraphQLMapping, - ex); - } + return CoerceJsonLeafValueToRuntimeType(fieldValue, namedType, context.Selection.Field.Name); } return null; } + /// + /// Converts a single JSON scalar value into the CLR runtime type that matches the + /// provided GraphQL leaf (scalar/enum) type. + /// HotChocolate expects leaf values to already be materialized into their runtime + /// representation. If a raw reaches a scalar's result + /// coercion, HC fails with EXEC_INVALID_LEAF_VALUE (e.g. "Single cannot coerce the + /// runtime value of type System.Text.Json.JsonElement"). This helper is shared by the + /// single-leaf resolver and the leaf-list materialization (e.g. a SQL vector column + /// exposed as [Single]). + /// + /// The JSON scalar value to convert. + /// The named (inner) GraphQL leaf type of the field. + /// Field name used for error reporting. + /// The runtime value, or null when the JSON value is null/undefined. + private static object? CoerceJsonLeafValueToRuntimeType(JsonElement fieldValue, ITypeDefinition namedType, string fieldName) + { + if (fieldValue.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + return null; + } + + // Each scalar in HotChocolate has a runtime type representation. + // In order to let scalar values flow through the GraphQL type completion + // efficiently we want the leaf types to match the runtime type. + // If that is not the case a value will go through the type converter to try to + // transform it into the runtime type. + // We also want to ensure here that we do not unnecessarily convert values to + // strings and then force the conversion to parse them. + try + { + return namedType switch + { + StringType => fieldValue.GetString(), // spec + UnsignedByteType => fieldValue.GetByte(), + ShortType => fieldValue.GetInt16(), + IntType => fieldValue.GetInt32(), // spec + LongType => fieldValue.GetInt64(), + FloatType => fieldValue.GetDouble(), // spec + SingleType => fieldValue.GetSingle(), + DecimalType => fieldValue.GetDecimal(), + DateTimeType => DateTimeOffset.TryParse(fieldValue.GetString()!, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal, out DateTimeOffset date) ? date : null, // for DW when datetime is null it will be in "" (double quotes) due to stringagg parsing and hence we need to ensure parsing is correct. + DateType => DateTimeOffset.TryParse(fieldValue.GetString()!, out DateTimeOffset date) ? date : null, + HotChocolate.Types.NodaTime.LocalTimeType => fieldValue.GetString()!.Equals("null", StringComparison.OrdinalIgnoreCase) ? null : LocalTimePattern.ExtendedIso.Parse(fieldValue.GetString()!).Value, + // HC v16 deprecated ByteArrayType in favor of Base64StringType; DAB-generated + // schemas now bind byte[] fields to Base64StringType (see BYTEARRAY_TYPE). + Base64StringType => fieldValue.GetBytesFromBase64(), + BooleanType => fieldValue.GetBoolean(), // spec + UrlType => new Uri(fieldValue.GetString()!), + UuidType => fieldValue.GetGuid(), + DurationType => XmlConvert.ToTimeSpan(fieldValue.GetString()!), + AnyType => fieldValue.ToString(), + _ => fieldValue.GetString() + }; + } + catch (Exception ex) when (ex is InvalidOperationException or FormatException) + { + // this usually means that database column type was changed since generating the GraphQL schema + // for e.g. System.FormatException - One of the identified items was in an invalid format + // System.InvalidOperationException - The requested operation requires an element of type 'Number', but the target element has type 'String'. + throw new DataApiBuilderException( + message: $"The {fieldName} value could not be parsed for configured GraphQL data type {namedType.Name}", + statusCode: HttpStatusCode.Conflict, + subStatusCode: DataApiBuilderException.SubStatusCodes.GraphQLMapping, + ex); + } + } + /// /// Represents a pure resolver for an object field. /// This resolver extracts another json object from the parent json property. @@ -296,6 +319,27 @@ fieldValue.ValueKind is not (JsonValueKind.Undefined or JsonValueKind.Null)) IMetadata? metadata = GetMetadata(context); object result = queryEngine.ResolveList(listValue, context.Selection.Field, ref metadata); SetNewMetadataChildren(context, metadata); + + // When the list's element type is a leaf (scalar/enum) type - for example a SQL + // vector column surfaced as [Single], or a PostgreSQL int[]/string[] column - + // HotChocolate hands each element directly to the scalar's result coercion. + // ResolveList returns the elements as raw JsonElement instances, which scalar + // types cannot coerce (EXEC_INVALID_LEAF_VALUE). We therefore materialize each + // element into its runtime representation here. Lists of object types are left + // untouched because their leaf fields are resolved individually via ExecuteLeafField. + ITypeDefinition namedType = context.Selection.Field.Type.NamedType(); + if (namedType.IsLeafType() && result is List listElements) + { + string fieldName = context.Selection.Field.Name; + List runtimeValues = new(listElements.Count); + foreach (JsonElement element in listElements) + { + runtimeValues.Add(CoerceJsonLeafValueToRuntimeType(element, namedType, fieldName)); + } + + return runtimeValues; + } + return result; } From e7bf7bb4bac36b94383c7983508afe8746c00b1d Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Thu, 9 Jul 2026 11:54:38 -0700 Subject: [PATCH 2/4] Add support for vector data type in graphql --- .../Resolvers/MultipleCreateOrderHelper.cs | 2 +- .../SqlInsertQueryStructure.cs | 13 +++++++- src/Core/Resolvers/SqlMutationEngine.cs | 33 +++++++++++++------ .../CosmosSqlMetadataProvider.cs | 13 ++++++++ .../MetadataProviders/ISqlMetadataProvider.cs | 12 +++++++ .../MetadataProviders/SqlMetadataProvider.cs | 19 +++++++++++ .../MultipleMutationInputValidator.cs | 29 ++++++++-------- src/Core/Services/TypeHelper.cs | 10 ++++++ 8 files changed, 106 insertions(+), 25 deletions(-) diff --git a/src/Core/Resolvers/MultipleCreateOrderHelper.cs b/src/Core/Resolvers/MultipleCreateOrderHelper.cs index 83c2ebd003..26fe945f86 100644 --- a/src/Core/Resolvers/MultipleCreateOrderHelper.cs +++ b/src/Core/Resolvers/MultipleCreateOrderHelper.cs @@ -385,7 +385,7 @@ private static RelationshipFields GetRelationshipFieldsInSourceAndTarget( foreach (ObjectFieldNode field in fieldNodes) { Tuple fieldDetails = GraphQLUtils.GetFieldDetails(field.Value, context.Variables); - SyntaxKind fieldKind = fieldDetails.Item2; + SyntaxKind fieldKind = metadataProvider.TryGetUnderlyingFieldKind(entityName, field.Name.Value, out SyntaxKind arrayFieldKind) ? arrayFieldKind : fieldDetails.Item2; if (GraphQLUtils.IsScalarField(fieldKind) && metadataProvider.TryGetBackingColumn(entityName, field.Name.Value, out string? backingColumnName)) { backingColumnData.Add(backingColumnName, fieldDetails.Item1); diff --git a/src/Core/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs b/src/Core/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs index 17a59531b6..9e50e4cc2f 100644 --- a/src/Core/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs +++ b/src/Core/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Net; +using System.Text.Json; using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; @@ -9,6 +10,7 @@ using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.GraphQLBuilder.Mutations; +using HotChocolate.Language; using HotChocolate.Resolvers; using Microsoft.AspNetCore.Http; @@ -111,8 +113,17 @@ private void PopulateColumnsAndParams(string columnName, object? value) if (value is not null) { + // Array/vector columns (e.g. SQL Server 'vector') arrive as a List. + // Calling ToString() on a list/array only yields the CLR type name, so instead extract + // the underlying element values and serialize them into a JSON array string (e.g. "[1.5,2.5,3.5]"). + string stringifiedValue = value switch + { + IEnumerable valueNodes => JsonSerializer.Serialize(valueNodes.Select(TypeHelper.GetValue)), + _ => value.ToString()! + }; + paramName = MakeDbConnectionParam( - GetParamAsSystemType(value.ToString()!, columnName, GetColumnSystemType(columnName)), columnName); + GetParamAsSystemType(stringifiedValue, columnName, GetColumnSystemType(columnName)), columnName); } else { diff --git a/src/Core/Resolvers/SqlMutationEngine.cs b/src/Core/Resolvers/SqlMutationEngine.cs index 204d71d44f..58498ecd67 100644 --- a/src/Core/Resolvers/SqlMutationEngine.cs +++ b/src/Core/Resolvers/SqlMutationEngine.cs @@ -1088,7 +1088,7 @@ await queryExecutor.ExecuteQueryAsync( string rootFieldName = isMultipleInputType ? MULTIPLE_INPUT_ARGUEMENT_NAME : SINGLE_INPUT_ARGUEMENT_NAME; // Parse the hotchocolate input parameters into .net object types - object? parsedInputParams = GQLMultipleCreateArgumentToDictParams(context, rootFieldName, mutationInputParamsFromGQLContext); + object? parsedInputParams = GQLMultipleCreateArgumentToDictParams(context, rootFieldName, mutationInputParamsFromGQLContext, sqlMetadataProvider, entityName); if (parsedInputParams is null) { @@ -1758,14 +1758,16 @@ private static void PopulateCurrentAndLinkingEntityParams( internal static object? GQLMultipleCreateArgumentToDictParams( IMiddlewareContext context, string rootFieldName, - IDictionary mutationParameters) + IDictionary mutationParameters, + ISqlMetadataProvider metadataProvider, + string entityName) { if (mutationParameters.TryGetValue(rootFieldName, out object? inputParameters)) { ObjectField fieldSchema = context.Selection.Field; IInputValueDefinition itemsArgumentSchema = fieldSchema.Arguments[rootFieldName]; InputObjectType inputObjectType = ExecutionHelper.InputObjectTypeFromIInputField(itemsArgumentSchema); - return GQLMultipleCreateArgumentToDictParamsHelper(context, inputObjectType, inputParameters); + return GQLMultipleCreateArgumentToDictParamsHelper(context, inputObjectType, inputParameters, metadataProvider, entityName); } else { @@ -1813,7 +1815,9 @@ private static void PopulateCurrentAndLinkingEntityParams( internal static object? GQLMultipleCreateArgumentToDictParamsHelper( IMiddlewareContext context, InputObjectType inputObjectType, - object? inputParameters) + object? inputParameters, + ISqlMetadataProvider metadataProvider, + string entityName) { // This condition is met for input types that accept an array of values // where the mutation input field is 'items' such as @@ -1835,7 +1839,9 @@ private static void PopulateCurrentAndLinkingEntityParams( object? parsedInputFieldItem = GQLMultipleCreateArgumentToDictParamsHelper( context: context, inputObjectType: inputObjectType, - inputParameters: inputField.Value); + inputParameters: inputField.Value, + metadataProvider: metadataProvider, + entityName: entityName); if (parsedInputFieldItem is not null) { parsedInputFieldItems.Add((IDictionary)parsedInputFieldItem); @@ -1862,33 +1868,40 @@ private static void PopulateCurrentAndLinkingEntityParams( foreach (ObjectFieldNode inputFieldNode in inputFieldNodes) { string fieldName = inputFieldNode.Name.Value; + SyntaxKind fieldKind = metadataProvider.TryGetUnderlyingFieldKind(entityName, fieldName, out SyntaxKind arrayFieldKind) + ? arrayFieldKind : inputFieldNode.Value.Kind; + // For the mutation pointMultipleCreateExample (outlined in the method summary), // the following condition will evaluate to true for fields 'authors' and 'reviews'. // Fields 'authors'/'reviews' can again consist of combination of scalar and relationship fields. // So, the input object type for 'authors'/'reviews' is fetched and the same function is // invoked with the fetched input object type again to parse the input fields of 'authors'/'reviews'. - if (inputFieldNode.Value.Kind == SyntaxKind.ListValue) + if (fieldKind == SyntaxKind.ListValue) { parsedInputFields.Add( fieldName, GQLMultipleCreateArgumentToDictParamsHelper( context, GetInputObjectTypeForAField(fieldName, inputObjectType.Fields), - inputFieldNode.Value.Value)); + inputFieldNode.Value.Value, + metadataProvider, + entityName)); } // For the mutation pointMultipleCreateExample (outlined in the method summary), // the following condition will evaluate to true for fields 'publishers'. // Field 'publishers' can again consist of combination of scalar and relationship fields. // So, the input object type for 'publishers' is fetched and the same function is // invoked with the fetched input object type again to parse the input fields of 'publishers'. - else if (inputFieldNode.Value.Kind == SyntaxKind.ObjectValue) + else if (fieldKind == SyntaxKind.ObjectValue) { parsedInputFields.Add( fieldName, GQLMultipleCreateArgumentToDictParamsHelper( context, GetInputObjectTypeForAField(fieldName, inputObjectType.Fields), - inputFieldNode.Value.Value)); + inputFieldNode.Value.Value, + metadataProvider, + entityName)); } // The flow enters this block for all scalar input fields. else @@ -2366,7 +2379,7 @@ private void ProcessObjectFieldNodesForAuthZ( foreach (ObjectFieldNode field in fieldNodes) { Tuple fieldDetails = GraphQLUtils.GetFieldDetails(field.Value, context.Variables); - SyntaxKind underlyingFieldKind = fieldDetails.Item2; + SyntaxKind underlyingFieldKind = metadataProvider.TryGetUnderlyingFieldKind(entityName, field.Name.Value, out SyntaxKind arrayFieldKind) ? arrayFieldKind : fieldDetails.Item2; // For a column field, we do not have to recurse to process fields in the value - which is required for relationship fields. if (GraphQLUtils.IsScalarField(underlyingFieldKind) || underlyingFieldKind is SyntaxKind.NullValue) diff --git a/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs index b6d5dd0111..3413da5df7 100644 --- a/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs @@ -530,6 +530,19 @@ public bool TryGetBackingColumn(string entityName, string field, [NotNullWhen(tr return true; } + /// + /// Array types are not yet supported for Cosmos. Returns false. + /// + /// Name of the entity. + /// Name of the field. + /// The kind of the field in GraphQL. + /// False, as array types are not supported. + public bool TryGetUnderlyingFieldKind(string entityName, string fieldName, out SyntaxKind fieldKind) + { + fieldKind = 0; + return false; + } + public IReadOnlyDictionary GetEntityNamesAndDbObjects() { throw new NotImplementedException(); diff --git a/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs index 83989b645a..9312683b7d 100644 --- a/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs @@ -251,5 +251,17 @@ public bool TryGetFKDefinition( string referencedEntityName, [NotNullWhen(true)] out ForeignKeyDefinition? foreignKeyDefinition, bool isMToNRelationship) => throw new NotImplementedException(); + + /// + /// + /// + /// + /// + /// + /// + public bool TryGetUnderlyingFieldKind( + string entityName, + string fieldName, + [NotNullWhen(true)] out SyntaxKind fieldKind); } } diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index 7d9708bdb0..49b24597ea 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -405,6 +405,25 @@ public bool TryGetBackingFieldToExposedFieldMap(string entityName, [NotNullWhen( return false; } + /// + public bool TryGetUnderlyingFieldKind(string entityName, string fieldName, out SyntaxKind fieldKind) + { + if (TryGetBackingColumn(entityName, fieldName, out string? columnName)) + { + SourceDefinition sourceDefinition = GetSourceDefinition(entityName); + ColumnDefinition column = sourceDefinition.Columns[columnName]; + + // If the column is an array type, we need to get the syntax kind from the element system type. + if (column.IsArrayType && TypeHelper.TryGetSyntaxKindFromSystemType(column.ElementSystemType!, out fieldKind)) + { + return true; + } + } + + fieldKind = 0; + return false; + } + /// /// Log Primary key information. Function only called when not /// in a hosted scenario. Log relevant information about Primary keys diff --git a/src/Core/Services/MultipleMutationInputValidator.cs b/src/Core/Services/MultipleMutationInputValidator.cs index 946ae49951..ce37284bc6 100644 --- a/src/Core/Services/MultipleMutationInputValidator.cs +++ b/src/Core/Services/MultipleMutationInputValidator.cs @@ -241,21 +241,22 @@ private void ValidateObjectFieldNodes( foreach (ObjectFieldNode currentEntityInputFieldNode in currentEntityInputFieldNodes) { (IValueNode? fieldValue, SyntaxKind fieldKind) = GraphQLUtils.GetFieldDetails(currentEntityInputFieldNode.Value, context.Variables); + fieldKind = metadataProvider.TryGetUnderlyingFieldKind(entityName, currentEntityInputFieldNode.Name.Value, out SyntaxKind arrayFieldKind) ? arrayFieldKind : fieldKind; if (fieldKind is not SyntaxKind.NullValue && !GraphQLUtils.IsScalarField(fieldKind)) { // Process the relationship field. string relationshipName = currentEntityInputFieldNode.Name.Value; ProcessRelationshipField( - context: context, - metadataProvider: metadataProvider, - backingColumnData: backingColumnData, - derivableColumnsFromRequestBody: derivableColumnsFromRequestBody, - fieldsToSupplyToReferencingEntities: fieldsToSupplyToReferencingEntities, - fieldsToDeriveFromReferencedEntities: fieldsToDeriveFromReferencedEntities, - relationshipName: relationshipName, - relationshipFieldValue: fieldValue, - nestingLevel: nestingLevel, - multipleMutationEntityInputValidationContext: multipleMutationEntityInputValidationContext); + context: context, + metadataProvider: metadataProvider, + backingColumnData: backingColumnData, + derivableColumnsFromRequestBody: derivableColumnsFromRequestBody, + fieldsToSupplyToReferencingEntities: fieldsToSupplyToReferencingEntities, + fieldsToDeriveFromReferencedEntities: fieldsToDeriveFromReferencedEntities, + relationshipName: relationshipName, + relationshipFieldValue: fieldValue, + nestingLevel: nestingLevel, + multipleMutationEntityInputValidationContext: multipleMutationEntityInputValidationContext); } } @@ -286,7 +287,8 @@ private void ValidateObjectFieldNodes( currentEntityInputFields: currentEntityInputFieldNodes, fieldsToSupplyToReferencingEntities: fieldsToSupplyToReferencingEntities, fieldsToDeriveFromReferencedEntities: fieldsToDeriveFromReferencedEntities, - nestingLevel: nestingLevel); + nestingLevel: nestingLevel, + metadataProvider: metadataProvider); } /// @@ -565,13 +567,14 @@ private void ValidateRelationshipFields( IReadOnlyList currentEntityInputFields, Dictionary> fieldsToSupplyToReferencingEntities, Dictionary> fieldsToDeriveFromReferencedEntities, - int nestingLevel) + int nestingLevel, + ISqlMetadataProvider metadataProvider) { RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); foreach (ObjectFieldNode currentEntityInputField in currentEntityInputFields) { Tuple fieldDetails = GraphQLUtils.GetFieldDetails(currentEntityInputField.Value, context.Variables); - SyntaxKind fieldKind = fieldDetails.Item2; + SyntaxKind fieldKind = metadataProvider.TryGetUnderlyingFieldKind(entityName, currentEntityInputField.Name.Value, out SyntaxKind arrayFieldKind) ? arrayFieldKind : fieldDetails.Item2; // For non-scalar fields, i.e. relationship fields, we have to recurse to process fields in the relationship field - // which represents input data for a related entity. diff --git a/src/Core/Services/TypeHelper.cs b/src/Core/Services/TypeHelper.cs index db47fe6ce9..fcd7fed1af 100644 --- a/src/Core/Services/TypeHelper.cs +++ b/src/Core/Services/TypeHelper.cs @@ -127,6 +127,11 @@ public static class TypeHelper [SqlDbType.DateTimeOffset] = DbType.DateTimeOffset }; + private static Dictionary _systemTypeToFieldKind = new() + { + [typeof(float)] = SyntaxKind.FloatValue, + }; + /// /// Given the system type, returns the corresponding primitive type kind. /// @@ -309,6 +314,11 @@ public static bool TryGetDbTypeFromSqlDbDateTimeType(SqlDbType sqlDbType, [NotNu return _sqlDbDateTimeTypeToDbType.TryGetValue(sqlDbType, out dbType); } + public static bool TryGetSyntaxKindFromSystemType(Type systemType, [NotNullWhen(true)] out SyntaxKind syntaxKind) + { + return _systemTypeToFieldKind.TryGetValue(systemType, out syntaxKind); + } + /// /// This function identifies the value type and converts that data in return. /// From 35ca2ecf72fd776b03ed5bd6f77d0252e5a49fca Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Fri, 10 Jul 2026 11:33:32 -0700 Subject: [PATCH 3/4] Add support to update tests --- config-generators/mssql-commands.txt | 2 +- .../BaseSqlQueryStructure.cs | 17 +++++++++++++++++ .../SqlInsertQueryStructure.cs | 12 ++---------- .../SqlUpdateQueryStructure.cs | 3 ++- src/Service.Tests/dab-config.MsSql.json | 2 +- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/config-generators/mssql-commands.txt b/config-generators/mssql-commands.txt index 99e138b846..5dbb7da8b3 100644 --- a/config-generators/mssql-commands.txt +++ b/config-generators/mssql-commands.txt @@ -16,7 +16,7 @@ add Broker --config "dab-config.MsSql.json" --source brokers --permissions "anon add WebsiteUser --config "dab-config.MsSql.json" --source website_users --permissions "anonymous:create,read,delete,update" add WebsiteUser_MM --config "dab-config.MsSql.json" --source website_users_mm --graphql "websiteuser_mm:websiteusers_mm" --permissions "anonymous:*" add SupportedType --config "dab-config.MsSql.json" --source type_table --permissions "anonymous:create,read,delete,update" -add VectorType --config "dab-config.MsSql.json" --source vector_type_table --rest true --graphql false --permissions "anonymous:create,read,delete,update" +add VectorType --config "dab-config.MsSql.json" --source vector_type_table --rest true --graphql true --permissions "anonymous:create,read,delete,update" update VectorType --config "dab-config.MsSql.json" --permissions "authenticated:create,read,delete,update" add stocks_price --config "dab-config.MsSql.json" --source stocks_price --permissions "authenticated:create,read,update,delete" update stocks_price --config "dab-config.MsSql.json" --permissions "anonymous:read" diff --git a/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs b/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs index c6593047a3..51326f5d40 100644 --- a/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs +++ b/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs @@ -699,5 +699,22 @@ protected object GetParamAsSystemType(string fieldValue, string fieldName, Type } } + + /// + /// Transforms the value of an object as a string. + /// Array/vector columns (e.g. SQL Server 'vector') arrive as a List. + /// Calling ToString() on a list/array only yields the CLR type name, so instead extract + /// the underlying element values and serialize them into a JSON array string (e.g. "[1.5,2.5,3.5]"). + /// + /// The value to be transformed into a string. + /// A string representation of the value. + protected static string GetStringifiedValue(object value) + { + return value switch + { + IEnumerable valueNodes => JsonSerializer.Serialize(valueNodes.Select(TypeHelper.GetValue)), + _ => value.ToString()! + }; + } } } diff --git a/src/Core/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs b/src/Core/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs index 9e50e4cc2f..686c945c99 100644 --- a/src/Core/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs +++ b/src/Core/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs @@ -113,17 +113,9 @@ private void PopulateColumnsAndParams(string columnName, object? value) if (value is not null) { - // Array/vector columns (e.g. SQL Server 'vector') arrive as a List. - // Calling ToString() on a list/array only yields the CLR type name, so instead extract - // the underlying element values and serialize them into a JSON array string (e.g. "[1.5,2.5,3.5]"). - string stringifiedValue = value switch - { - IEnumerable valueNodes => JsonSerializer.Serialize(valueNodes.Select(TypeHelper.GetValue)), - _ => value.ToString()! - }; - + string stringValue = GetStringifiedValue(value); paramName = MakeDbConnectionParam( - GetParamAsSystemType(stringifiedValue, columnName, GetColumnSystemType(columnName)), columnName); + GetParamAsSystemType(stringValue, columnName, GetColumnSystemType(columnName)), columnName); } else { diff --git a/src/Core/Resolvers/Sql Query Structures/SqlUpdateQueryStructure.cs b/src/Core/Resolvers/Sql Query Structures/SqlUpdateQueryStructure.cs index 2fffa19ef5..7d0c7df7f4 100644 --- a/src/Core/Resolvers/Sql Query Structures/SqlUpdateQueryStructure.cs +++ b/src/Core/Resolvers/Sql Query Structures/SqlUpdateQueryStructure.cs @@ -183,11 +183,12 @@ private Predicate CreatePredicateForParam(KeyValuePair param) } else { + string stringValue = GetStringifiedValue(param.Value); predicate = new( new PredicateOperand( new Column(tableSchema: DatabaseObject.SchemaName, tableName: DatabaseObject.Name, backingColumn)), PredicateOperation.Equal, - new PredicateOperand($"{MakeDbConnectionParam(GetParamAsSystemType(param.Value.ToString()!, backingColumn, GetColumnSystemType(backingColumn)), backingColumn)}")); + new PredicateOperand($"{MakeDbConnectionParam(GetParamAsSystemType(stringValue, backingColumn, GetColumnSystemType(backingColumn)), backingColumn)}")); } return predicate; diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 4de4f52b5f..ca32f24e36 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -1808,7 +1808,7 @@ "type": "table" }, "graphql": { - "enabled": false, + "enabled": true, "type": { "singular": "VectorType", "plural": "VectorTypes" From b2f87b594ac6ac4e155bb21787215ea53b4037e2 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Fri, 10 Jul 2026 11:35:04 -0700 Subject: [PATCH 4/4] Add tests for graphql --- .../MsSqlGraphQLVectorTypesTests.cs | 274 ++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLVectorTypesTests.cs diff --git a/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLVectorTypesTests.cs b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLVectorTypesTests.cs new file mode 100644 index 0000000000..5fba046ddb --- /dev/null +++ b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLVectorTypesTests.cs @@ -0,0 +1,274 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.SqlTests.GraphQLQueryTests +{ + /// + /// Tests for SQL Server vector column support via GraphQL (read and write). + /// Verifies that vector columns are exposed as GraphQL lists of Single values, that they can be + /// queried (list and by primary key) and mutated (create/update/delete), and that the values that + /// are read from and written to the vector_type_table round-trip correctly. + /// This complements the REST coverage in + /// . + /// NOTE: The vector data type requires SQL Server 2025 / Azure SQL. + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class MsSqlGraphQLVectorTypesTests : SqlTestBase + { + /// + /// Tolerance used when comparing the single-precision components of a vector, + /// since vector(N) stores 32-bit floats which may not round-trip exactly through JSON. + /// + private const double VECTOR_COMPONENT_DELTA = 0.0001; + + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + #region Read Tests + + /// + /// Query the vector column by primary key and verify the returned list matches the values seeded + /// in the database. + /// + [DataTestMethod] + [DataRow(1, new[] { 0.5f, 0.25f, 0.75f }, DisplayName = "Query vector data type by primary key")] + [DataRow(2, new[] { 1.5f, -2.5f, 3.5f }, DisplayName = "Query vector data type with negative components")] + [DataRow(3, null, DisplayName = "Query vector data type with null vector")] + public async Task QueryVectorTypeByPk(int id, float[] expectedValues) + { + string graphQLQueryName = "vectorType_by_pk"; + string graphQLQuery = @"{ + vectorType_by_pk(id: " + id + @") { + id + vector_data + } + }"; + + JsonElement result = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + + Assert.AreEqual(id, result.GetProperty("id").GetInt32()); + AssertVectorEquals(result.GetProperty("vector_data"), expectedValues); + } + + /// + /// Query the list of vector records and verify the first record (ordered by primary key) matches + /// the seeded value. + /// + [TestMethod] + public async Task QueryVectorTypeList() + { + string graphQLQueryName = "vectorTypes"; + string graphQLQuery = @"{ + vectorTypes(orderBy: { id: ASC }) { + items { + id + vector_data + } + } + }"; + + JsonElement result = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + + JsonElement items = result.GetProperty("items"); + Assert.IsTrue(items.GetArrayLength() >= 1, "Expected at least one vector record."); + + JsonElement first = items[0]; + Assert.AreEqual(1, first.GetProperty("id").GetInt32()); + AssertVectorEquals(first.GetProperty("vector_data"), new[] { 0.5f, 0.25f, 0.75f }); + } + + /// + /// Query the maximum-dimension vector column and verify the returned list has the expected number + /// of components and the correct values. + /// + [TestMethod] + public async Task QueryVectorTypeWithMaxDimensions() + { + string graphQLQueryName = "vectorType_by_pk"; + string graphQLQuery = @"{ + vectorType_by_pk(id: 7) { + id + vector_data_max + } + }"; + + JsonElement result = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + + Assert.AreEqual(7, result.GetProperty("id").GetInt32()); + + JsonElement maxVector = result.GetProperty("vector_data_max"); + Assert.AreEqual(JsonValueKind.Array, maxVector.ValueKind, "Expected the maximum-dimension vector to be a list."); + Assert.AreEqual(1998, maxVector.GetArrayLength(), "Expected the maximum-dimension vector to have 1998 components."); + + int i = 1; + foreach (JsonElement vectorVal in maxVector.EnumerateArray()) + { + Assert.AreEqual(i, vectorVal.GetDouble(), VECTOR_COMPONENT_DELTA); + i++; + } + } + + #endregion + + #region Write Tests + + /// + /// Insert a new record with a vector value via a create mutation and verify the persisted value is + /// returned as a list and can be read back correctly. The record is deleted afterwards to keep the + /// table clean. + /// + [DataTestMethod] + [DataRow("[0.125, 0.25, 0.5]", new[] { 0.125f, 0.25f, 0.5f }, DisplayName = "Insert valid vector")] + [DataRow("null", null, DisplayName = "Insert valid null vector")] + [DataRow("[5e-1, 2.5e-1, 7.5e-1]", new[] { 0.5f, 0.25f, 0.75f }, DisplayName = "Insert valid vector with scientific notation")] + public async Task InsertVectorType(string vectorLiteral, float[] expectedValue) + { + string createMutationName = "createVectorType"; + string createMutation = @"mutation { + createVectorType(item: { vector_data: " + vectorLiteral + @" }) { + id + vector_data + } + }"; + + JsonElement created = await ExecuteGraphQLRequestAsync(createMutation, createMutationName, isAuthenticated: false); + + int newId = created.GetProperty("id").GetInt32(); + AssertVectorEquals(created.GetProperty("vector_data"), expectedValue); + + // Confirm the value was persisted by reading it back. + JsonElement readBack = await GetRecordByPkAsync(newId); + AssertVectorEquals(readBack.GetProperty("vector_data"), expectedValue); + + await DeleteVectorTypeAsync(newId); + } + + /// + /// Insert an invalid vector (too many dimensions) via a create mutation and verify the mutation + /// fails with a GraphQL error rather than persisting bad data. + /// + [TestMethod] + public async Task InsertInvalidVectorTypeFails() + { + string createMutationName = "createVectorType"; + string createMutation = @"mutation { + createVectorType(item: { vector_data: [1.25, 2.25, 3.25, 4.25] }) { + id + vector_data + } + }"; + + JsonElement result = await ExecuteGraphQLRequestAsync(createMutation, createMutationName, isAuthenticated: false, expectsError: true); + + SqlTestHelper.TestForErrorInGraphQLResponse(result.ToString()); + } + + /// + /// Update an existing record's vector value via an update mutation and verify the new value is + /// persisted, then restore the original value. + /// + [TestMethod] + public async Task UpdateVectorType() + { + string updateMutationName = "updateVectorType"; + + // Change vector value. + float[] expected = new[] { 9.5f, 8.5f, 7.5f }; + string updateMutation = @"mutation { + updateVectorType(id: 4, item: { vector_data: [9.5, 8.5, 7.5] }) { + id + vector_data + } + }"; + + JsonElement updated = await ExecuteGraphQLRequestAsync(updateMutation, updateMutationName, isAuthenticated: false); + Assert.AreEqual(4, updated.GetProperty("id").GetInt32()); + AssertVectorEquals(updated.GetProperty("vector_data"), expected); + + JsonElement readBack = await GetRecordByPkAsync(4); + AssertVectorEquals(readBack.GetProperty("vector_data"), expected); + + // Restore vector value to original. + float[] original = new[] { 1.0f, 2.0f, 3.0f }; + string restoreMutation = @"mutation { + updateVectorType(id: 4, item: { vector_data: [1.0, 2.0, 3.0] }) { + id + vector_data + } + }"; + + JsonElement restored = await ExecuteGraphQLRequestAsync(restoreMutation, updateMutationName, isAuthenticated: false); + AssertVectorEquals(restored.GetProperty("vector_data"), original); + } + + #endregion + + #region Helpers + + /// + /// Fetches a single VectorType record by its primary key via GraphQL and returns the record element. + /// + private async Task GetRecordByPkAsync(int id) + { + string graphQLQueryName = "vectorType_by_pk"; + string graphQLQuery = @"{ + vectorType_by_pk(id: " + id + @") { + id + vector_data + } + }"; + + JsonElement result = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + return result.Clone(); + } + + /// + /// Deletes a VectorType record by its primary key via a delete mutation. + /// + private async Task DeleteVectorTypeAsync(int id) + { + string deleteMutationName = "deleteVectorType"; + string deleteMutation = @"mutation { + deleteVectorType(id: " + id + @") { + id + } + }"; + + JsonElement result = await ExecuteGraphQLRequestAsync(deleteMutation, deleteMutationName, isAuthenticated: false); + Assert.AreEqual(id, result.GetProperty("id").GetInt32()); + } + + /// + /// Asserts that the given JSON element is a list whose components match the expected vector within + /// . + /// + private static void AssertVectorEquals(JsonElement actual, float[] expected) + { + if (expected == null) + { + Assert.AreEqual(JsonValueKind.Null, actual.ValueKind, "Expected a null vector, but got a non-null value."); + return; + } + + Assert.AreEqual(JsonValueKind.Array, actual.ValueKind, "Expected the vector to be serialized as a list."); + Assert.AreEqual(expected.Length, actual.GetArrayLength(), "Vector dimension mismatch."); + + int i = 0; + foreach (JsonElement element in actual.EnumerateArray()) + { + Assert.AreEqual(expected[i], element.GetDouble(), VECTOR_COMPONENT_DELTA, $"Vector component mismatch at index {i}."); + i++; + } + } + + #endregion + } +}