diff --git a/src/Core/Parsers/IntrospectionInterceptor.cs b/src/Core/Parsers/IntrospectionInterceptor.cs
index 98c458e6a9..53b6711cf9 100644
--- a/src/Core/Parsers/IntrospectionInterceptor.cs
+++ b/src/Core/Parsers/IntrospectionInterceptor.cs
@@ -5,6 +5,7 @@
using HotChocolate.AspNetCore;
using HotChocolate.Execution;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
namespace Azure.DataApiBuilder.Core.Parsers
{
@@ -14,17 +15,18 @@ namespace Azure.DataApiBuilder.Core.Parsers
///
public class IntrospectionInterceptor : DefaultHttpRequestInterceptor
{
- private RuntimeConfigProvider _runtimeConfigProvider;
-
///
- /// Constructor injects RuntimeConfigProvider to allow
- /// HotChocolate to attempt to retrieve the runtime config
- /// when evaluating GraphQL requests.
+ /// Parameterless constructor.
///
- ///
- public IntrospectionInterceptor(RuntimeConfigProvider runtimeConfigProvider)
+ ///
+ /// Hot Chocolate v16 isolates schema services from the application's request services.
+ /// Resolving constructor-injected app singletons (e.g. )
+ /// against the schema service provider therefore fails at executor session creation.
+ /// We instead resolve dependencies from in
+ /// , where the application's request scope is in effect.
+ ///
+ public IntrospectionInterceptor()
{
- _runtimeConfigProvider = runtimeConfigProvider;
}
///
@@ -51,7 +53,10 @@ public override ValueTask OnCreateAsync(
OperationRequestBuilder requestBuilder,
CancellationToken cancellationToken)
{
- if (_runtimeConfigProvider.GetConfig().AllowIntrospection)
+ RuntimeConfigProvider runtimeConfigProvider =
+ context.RequestServices.GetRequiredService();
+
+ if (runtimeConfigProvider.GetConfig().AllowIntrospection)
{
requestBuilder.AllowIntrospection();
}
diff --git a/src/Core/Resolvers/CosmosQueryStructure.cs b/src/Core/Resolvers/CosmosQueryStructure.cs
index 68d83557c0..29c435d955 100644
--- a/src/Core/Resolvers/CosmosQueryStructure.cs
+++ b/src/Core/Resolvers/CosmosQueryStructure.cs
@@ -117,14 +117,15 @@ private static IEnumerable GenerateQueryColumns(SelectionSetNode
[MemberNotNull(nameof(OrderByColumns))]
private void Init(IDictionary queryParams)
{
- ISelection selection = _context.Selection;
+ Selection selection = _context.Selection;
ObjectType underlyingType = selection.Field.Type.NamedType();
IsPaginated = QueryBuilder.IsPaginationType(underlyingType);
OrderByColumns = new();
+ FieldNode selectionFieldNode = selection.RequireFieldNode();
if (IsPaginated)
{
- FieldNode? fieldNode = ExtractQueryField(selection.SyntaxNode);
+ FieldNode? fieldNode = ExtractQueryField(selectionFieldNode);
if (fieldNode is not null)
{
@@ -139,7 +140,7 @@ private void Init(IDictionary queryParams)
}
else
{
- Columns.AddRange(GenerateQueryColumns(selection.SyntaxNode.SelectionSet!, _context.Operation.Document, SourceAlias));
+ Columns.AddRange(GenerateQueryColumns(selectionFieldNode.SelectionSet!, _context.Operation.Document, SourceAlias));
string typeName = GraphQLUtils.TryExtractGraphQLFieldModelName(underlyingType.Directives, out string? modelName) ?
modelName :
underlyingType.Name;
diff --git a/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs b/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs
index 0370b44b83..210cb5fef5 100644
--- a/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs
+++ b/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs
@@ -126,7 +126,7 @@ public SqlQueryStructure(
sqlMetadataProvider,
authorizationResolver,
ctx.Selection.Field,
- ctx.Selection.SyntaxNode,
+ ctx.Selection.RequireFieldNode(),
// The outermost query is where we start, so this can define
// create the IncrementingInteger that will be shared between
// all subqueries in this query.
@@ -173,7 +173,7 @@ public SqlQueryStructure(
IsMultipleCreateOperation = isMultipleCreateOperation;
ObjectField schemaField = _ctx.Selection.Field;
- FieldNode? queryField = _ctx.Selection.SyntaxNode;
+ FieldNode? queryField = _ctx.Selection.RequireFieldNode();
IOutputType outputType = schemaField.Type;
_underlyingFieldType = outputType.NamedType();
diff --git a/src/Core/Services/DetermineStatusCodeMiddleware.cs b/src/Core/Services/DetermineStatusCodeMiddleware.cs
index dcddc62971..a0a9a88490 100644
--- a/src/Core/Services/DetermineStatusCodeMiddleware.cs
+++ b/src/Core/Services/DetermineStatusCodeMiddleware.cs
@@ -35,7 +35,7 @@ public async ValueTask InvokeAsync(RequestContext context)
}
contextData[ExecutionContextData.HttpStatusCode] = HttpStatusCode.BadRequest;
- context.Result = singleResult.WithContextData(contextData.ToImmutable());
+ singleResult.ContextData = contextData.ToImmutable();
}
}
}
diff --git a/src/Core/Services/ExecutionHelper.cs b/src/Core/Services/ExecutionHelper.cs
index 79bdc6af9c..c91ac6c9aa 100644
--- a/src/Core/Services/ExecutionHelper.cs
+++ b/src/Core/Services/ExecutionHelper.cs
@@ -6,6 +6,7 @@
using System.Net;
using System.Text;
using System.Text.Json;
+using System.Xml;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.Configurations;
using Azure.DataApiBuilder.Core.Models;
@@ -18,7 +19,6 @@
using Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes;
using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries;
using HotChocolate.Execution;
-using HotChocolate.Execution.Processing;
using HotChocolate.Language;
using HotChocolate.Resolvers;
using NodaTime.Text;
@@ -201,7 +201,7 @@ fieldValue.ValueKind is not (JsonValueKind.Undefined or JsonValueKind.Null))
return namedType switch
{
StringType => fieldValue.GetString(), // spec
- ByteType => fieldValue.GetByte(),
+ UnsignedByteType => fieldValue.GetByte(),
ShortType => fieldValue.GetInt16(),
IntType => fieldValue.GetInt32(), // spec
LongType => fieldValue.GetInt64(),
@@ -211,11 +211,21 @@ fieldValue.ValueKind is not (JsonValueKind.Undefined or JsonValueKind.Null))
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,
- ByteArrayType => fieldValue.GetBytesFromBase64(),
+ // HC v16 ships both ByteArrayType (legacy, GraphQL name "ByteArray", runtime byte[])
+ // and Base64StringType (new, GraphQL name "Base64String", runtime byte[]). DAB's
+ // generated schemas still use the GraphQL name "ByteArray", so HC binds entity
+ // fields to ByteArrayType. We accept either type here so DAB also tolerates
+ // schemas that bind to Base64StringType (e.g. via DefaultValueType).
+ // CS0618: ByteArrayType is [Obsolete] in HC v16 in favor of Base64StringType,
+ // but we still need to pattern-match it because it remains the type bound to
+ // the GraphQL name "ByteArray" that DAB-generated schemas continue to use.
+#pragma warning disable CS0618
+ Base64StringType or ByteArrayType => fieldValue.GetBytesFromBase64(),
+#pragma warning restore CS0618
BooleanType => fieldValue.GetBoolean(), // spec
UrlType => new Uri(fieldValue.GetString()!),
UuidType => fieldValue.GetGuid(),
- TimeSpanType => TimeSpan.Parse(fieldValue.GetString()!),
+ DurationType => XmlConvert.ToTimeSpan(fieldValue.GetString()!),
AnyType => fieldValue.ToString(),
_ => fieldValue.GetString()
};
@@ -508,7 +518,7 @@ public static InputObjectType InputObjectTypeFromIInputField(IInputValueDefiniti
{
return GetParametersFromSchemaAndQueryFields(
context.Selection.Field,
- context.Selection.SyntaxNode,
+ context.Selection.RequireFieldNode(),
context.Variables);
}
diff --git a/src/Core/Services/GraphQLSchemaCreator.cs b/src/Core/Services/GraphQLSchemaCreator.cs
index 90e918c833..b635e5128d 100644
--- a/src/Core/Services/GraphQLSchemaCreator.cs
+++ b/src/Core/Services/GraphQLSchemaCreator.cs
@@ -105,6 +105,15 @@ private ISchemaBuilder Parse(
// Generate the Query and the Mutation Node.
(DocumentNode queryNode, DocumentNode mutationNode) = GenerateQueryAndMutationNodes(root, inputTypes);
+ // Hot Chocolate v16 validates schemas eagerly during host startup
+ // (RequestExecutorWarmupService) and rejects an empty Query type with
+ // "The object type `Query` has to at least define one field in order to be valid."
+ // This can occur in valid runtime configurations: GraphQL globally disabled,
+ // every entity opting out of GraphQL via `graphql.enabled = false`, or no entities
+ // configured. Inject a hidden placeholder field with a no-op resolver so the schema
+ // is structurally valid; HC v16 also rejects fields without resolvers.
+ queryNode = EnsureQueryHasAtLeastOneField(queryNode, sb);
+
return sb
.AddDocument(root)
.AddAuthorizeDirectiveType()
@@ -122,12 +131,70 @@ private ISchemaBuilder Parse(
.AddDocument(queryNode)
// Generate the GraphQL mutations from the provided objects
.AddDocument(mutationNode)
- // Enable the OneOf directive (https://github.com/graphql/graphql-spec/pull/825) to support the DefaultValue type
- .ModifyOptions(o => o.EnableOneOf = true)
// Adds our type interceptor that will create the resolvers.
.TryAddTypeInterceptor(new ResolverTypeInterceptor(new ExecutionHelper(_queryEngineFactory, _mutationEngineFactory, _runtimeConfigProvider)));
}
+ ///
+ /// Name of the hidden placeholder field added to Query when no entity contributes
+ /// a query field, used to keep the schema valid for HC v16's eager validation.
+ ///
+ private const string EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME = "_dab";
+
+ ///
+ /// If the generated Query object type has no fields, append a hidden placeholder
+ /// field and register a null-returning resolver for it. The placeholder is shadowed in
+ /// any configuration that produces real query fields, so it is only visible in
+ /// otherwise-empty schemas (GraphQL globally disabled, all entities opting out,
+ /// no entities configured).
+ ///
+ private static DocumentNode EnsureQueryHasAtLeastOneField(DocumentNode queryNode, ISchemaBuilder sb)
+ {
+ ImmutableArray.Builder rewritten = ImmutableArray.CreateBuilder(queryNode.Definitions.Count);
+ bool placeholderInjected = false;
+
+ foreach (IDefinitionNode definition in queryNode.Definitions)
+ {
+ if (definition is ObjectTypeDefinitionNode objectType
+ && objectType.Name.Value == "Query"
+ && objectType.Fields.Count == 0)
+ {
+ FieldDefinitionNode placeholderField = new(
+ location: null,
+ new NameNode(EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME),
+ new StringValueNode(
+ "Internal placeholder; only present when no entity contributes a query field. "
+ + "Always returns null and is never reachable in normal operation."),
+ arguments: new List(),
+ type: new NamedTypeNode(new NameNode("String")),
+ directives: new List());
+
+ rewritten.Add(new ObjectTypeDefinitionNode(
+ objectType.Location,
+ objectType.Name,
+ objectType.Description,
+ objectType.Directives,
+ objectType.Interfaces,
+ new List { placeholderField }));
+ placeholderInjected = true;
+ }
+ else
+ {
+ rewritten.Add(definition);
+ }
+ }
+
+ if (placeholderInjected)
+ {
+ // HC v16 requires every field to have a resolver; bind a no-op that always
+ // returns null. The field is unreachable in normal operation because callers
+ // for empty-Query configurations never issue GraphQL requests.
+ sb.AddResolver("Query", EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME, _ => null);
+ }
+
+ return new DocumentNode(rewritten.ToImmutable());
+ }
+
///
/// Generate the GraphQL schema query and mutation nodes from the provided database.
///
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index dfd605cc8f..8195e57d78 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -13,15 +13,12 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/src/Service.GraphQLBuilder/CustomScalars/SingleType.cs b/src/Service.GraphQLBuilder/CustomScalars/SingleType.cs
index e7d4be690a..8d85ccb39f 100644
--- a/src/Service.GraphQLBuilder/CustomScalars/SingleType.cs
+++ b/src/Service.GraphQLBuilder/CustomScalars/SingleType.cs
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+using System.Text.Json;
using HotChocolate.Language;
+using HotChocolate.Text.Json;
using HotChocolate.Types;
namespace Azure.DataApiBuilder.Service.GraphQLBuilder.CustomScalars
@@ -48,10 +50,16 @@ public SingleType(
Description = description;
}
- protected override float ParseLiteral(IFloatValueLiteral valueSyntax) =>
- valueSyntax.ToSingle();
+ protected override float OnCoerceInputLiteral(IFloatValueLiteral valueLiteral)
+ => valueLiteral.ToSingle();
- protected override FloatValueNode ParseValue(float runtimeValue) =>
- new(runtimeValue);
+ protected override float OnCoerceInputValue(JsonElement inputValue)
+ => inputValue.GetSingle();
+
+ protected override void OnCoerceOutputValue(float runtimeValue, ResultElement resultValue)
+ => resultValue.SetNumberValue(runtimeValue);
+
+ protected override IValueNode OnValueToLiteral(float runtimeValue)
+ => new FloatValueNode(runtimeValue);
}
}
diff --git a/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs b/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs
index 8aca57421c..92d0298f10 100644
--- a/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs
+++ b/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs
@@ -156,14 +156,14 @@ private static Tuple ConvertValueToGraphQLType(string defaul
{
Tuple valueNode = paramValueType switch
{
- UUID_TYPE => new(UUID_TYPE, new UuidType().ParseValue(Guid.Parse(defaultValueFromConfig))),
- BYTE_TYPE => new(BYTE_TYPE, new IntValueNode(byte.Parse(defaultValueFromConfig))),
- SHORT_TYPE => new(SHORT_TYPE, new IntValueNode(short.Parse(defaultValueFromConfig))),
- INT_TYPE => new(INT_TYPE, new IntValueNode(int.Parse(defaultValueFromConfig))),
- LONG_TYPE => new(LONG_TYPE, new IntValueNode(long.Parse(defaultValueFromConfig))),
- SINGLE_TYPE => new(SINGLE_TYPE, new SingleType().ParseValue(float.Parse(defaultValueFromConfig))),
- FLOAT_TYPE => new(FLOAT_TYPE, new FloatValueNode(double.Parse(defaultValueFromConfig))),
- DECIMAL_TYPE => new(DECIMAL_TYPE, new FloatValueNode(decimal.Parse(defaultValueFromConfig))),
+ UUID_TYPE => new(UUID_TYPE, new UuidType().ValueToLiteral(Guid.Parse(defaultValueFromConfig))),
+ BYTE_TYPE => new(BYTE_TYPE, new IntValueNode(byte.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))),
+ SHORT_TYPE => new(SHORT_TYPE, new IntValueNode(short.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))),
+ INT_TYPE => new(INT_TYPE, new IntValueNode(int.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))),
+ LONG_TYPE => new(LONG_TYPE, new IntValueNode(long.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))),
+ SINGLE_TYPE => new(SINGLE_TYPE, new SingleType().ValueToLiteral(float.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))),
+ FLOAT_TYPE => new(FLOAT_TYPE, new FloatValueNode(double.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))),
+ DECIMAL_TYPE => new(DECIMAL_TYPE, new FloatValueNode(decimal.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))),
STRING_TYPE => new(STRING_TYPE, new StringValueNode(defaultValueFromConfig)),
BOOLEAN_TYPE => new(BOOLEAN_TYPE, new BooleanValueNode(
defaultValueFromConfig switch
@@ -174,10 +174,10 @@ var s when s.Equals("true", StringComparison.OrdinalIgnoreCase) => true,
var s when s.Equals("false", StringComparison.OrdinalIgnoreCase) => false,
_ => throw new FormatException($"String '{defaultValueFromConfig}' was not recognized as a valid Boolean.")
})),
- DATETIME_TYPE => new(DATETIME_TYPE, new DateTimeType().ParseResult(
- DateTime.Parse(defaultValueFromConfig, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal))),
- BYTEARRAY_TYPE => new(BYTEARRAY_TYPE, new ByteArrayType().ParseValue(Convert.FromBase64String(defaultValueFromConfig))),
- LOCALTIME_TYPE => new(LOCALTIME_TYPE, new HotChocolate.Types.NodaTime.LocalTimeType().ParseResult(LocalTimePattern.ExtendedIso.Parse(defaultValueFromConfig).Value)),
+ DATETIME_TYPE => new(DATETIME_TYPE, new DateTimeType().ValueToLiteral(
+ DateTimeOffset.Parse(defaultValueFromConfig, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal))),
+ BYTEARRAY_TYPE => new(BYTEARRAY_TYPE, new Base64StringType().ValueToLiteral(Convert.FromBase64String(defaultValueFromConfig))),
+ LOCALTIME_TYPE => new(LOCALTIME_TYPE, new HotChocolate.Types.NodaTime.LocalTimeType().ValueToLiteral(LocalTimePattern.ExtendedIso.Parse(defaultValueFromConfig).Value)),
_ => throw new NotSupportedException(message: $"The {defaultValueFromConfig} parameter's value type [{paramValueType}] is not supported.")
};
diff --git a/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs b/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs
index a8b058fa6a..7f10d1c290 100644
--- a/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs
+++ b/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs
@@ -13,7 +13,7 @@ protected override void Configure(IInputObjectTypeDescriptor descriptor)
{
descriptor.Name("DefaultValue");
descriptor.OneOf();
- descriptor.Field(BYTE_TYPE).Type();
+ descriptor.Field(BYTE_TYPE).Type();
descriptor.Field(SHORT_TYPE).Type();
descriptor.Field(INT_TYPE).Type();
descriptor.Field(LONG_TYPE).Type();
@@ -23,7 +23,7 @@ protected override void Configure(IInputObjectTypeDescriptor descriptor)
descriptor.Field(FLOAT_TYPE).Type();
descriptor.Field(DECIMAL_TYPE).Type();
descriptor.Field(DATETIME_TYPE).Type();
- descriptor.Field(BYTEARRAY_TYPE).Type();
+ descriptor.Field(BYTEARRAY_TYPE).Type();
descriptor.Field(LOCALTIME_TYPE).Type();
}
}
diff --git a/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs b/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs
index 88d29a6e3f..242bc7080e 100644
--- a/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs
+++ b/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs
@@ -11,7 +11,13 @@ namespace Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes
public static class SupportedHotChocolateTypes
{
public const string UUID_TYPE = "UUID";
- public const string BYTE_TYPE = "Byte";
+ // HC v16 split the legacy Byte scalar into:
+ // - ByteType (runtime: sbyte, range -128..127)
+ // - UnsignedByteType (runtime: byte, range 0..255)
+ // SQL Server's tinyint maps to .NET byte (0..255), so DAB targets UnsignedByte.
+ // The GraphQL type name visible in the generated schema therefore changed from
+ // "Byte" to "UnsignedByte" with the HC v16 upgrade.
+ public const string BYTE_TYPE = "UnsignedByte";
public const string SHORT_TYPE = "Short";
public const string INT_TYPE = "Int";
public const string LONG_TYPE = "Long";
diff --git a/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs b/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs
index aa6423d55d..a0b3023a38 100644
--- a/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs
+++ b/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs
@@ -11,7 +11,11 @@ public sealed class StandardQueryInputs
{
private static readonly ITypeNode _id = new NamedTypeNode(ScalarNames.ID);
private static readonly ITypeNode _boolean = new NamedTypeNode(ScalarNames.Boolean);
- private static readonly ITypeNode _byte = new NamedTypeNode(ScalarNames.Byte);
+ // BYTE_TYPE is "UnsignedByte" after the HC v16 upgrade (HC v16 split ByteType
+ // into a signed-byte ByteType and a new UnsignedByteType for byte 0..255). DAB's
+ // SQL tinyint columns map to UnsignedByte, so the filter input must reference the
+ // same scalar that schema fields are typed as.
+ private static readonly ITypeNode _byte = new NamedTypeNode(BYTE_TYPE);
private static readonly ITypeNode _short = new NamedTypeNode(ScalarNames.Short);
private static readonly ITypeNode _int = new NamedTypeNode(ScalarNames.Int);
private static readonly ITypeNode _long = new NamedTypeNode(ScalarNames.Long);
@@ -55,7 +59,9 @@ private static InputObjectTypeDefinitionNode BooleanInputType() =>
CreateSimpleEqualsFilter("BooleanFilterInput", "Input type for adding Boolean filters", _boolean);
private static InputObjectTypeDefinitionNode ByteInputType() =>
- CreateComparableFilter("ByteFilterInput", "Input type for adding Byte filters", _byte);
+ // Filter input type name follows the $"{TypeName}FilterInput" convention used by
+ // GetCommonFilterInputType, so this is "UnsignedByteFilterInput" with HC v16.
+ CreateComparableFilter($"{BYTE_TYPE}FilterInput", $"Input type for adding {BYTE_TYPE} filters", _byte);
private static InputObjectTypeDefinitionNode ShortInputType() =>
CreateComparableFilter("ShortFilterInput", "Input type for adding Short filters", _short);
@@ -189,7 +195,9 @@ private StandardQueryInputs()
{
AddInputType(ScalarNames.ID, IdInputType());
AddInputType(ScalarNames.UUID, UuidInputType());
- AddInputType(ScalarNames.Byte, ByteInputType());
+ // Register under BYTE_TYPE ("UnsignedByte" with HC v16) so InputTypeBuilder's
+ // lookup by the field's GraphQL type name resolves correctly.
+ AddInputType(BYTE_TYPE, ByteInputType());
AddInputType(ScalarNames.Short, ShortInputType());
AddInputType(ScalarNames.Int, IntInputType());
AddInputType(ScalarNames.Long, LongInputType());
diff --git a/src/Service.GraphQLBuilder/SelectionExtensions.cs b/src/Service.GraphQLBuilder/SelectionExtensions.cs
new file mode 100644
index 0000000000..b246a567f5
--- /dev/null
+++ b/src/Service.GraphQLBuilder/SelectionExtensions.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Net;
+using Azure.DataApiBuilder.Service.Exceptions;
+using HotChocolate.Execution.Processing;
+using HotChocolate.Language;
+
+namespace Azure.DataApiBuilder.Service.GraphQLBuilder
+{
+ ///
+ /// Extension methods over Hot Chocolate's type.
+ ///
+ public static class SelectionExtensions
+ {
+ ///
+ /// Returns the first backing the given ,
+ /// failing fast with a targeted when no syntax node
+ /// is available.
+ ///
+ ///
+ /// Hot Chocolate v16 introduced Selection.SyntaxNodes (a span) to support field-merging
+ /// across multiple selection-set occurrences of the same field. Indexing directly with
+ /// SyntaxNodes[0] would surface as an at request
+ /// time if the span were ever empty. In practice an executable selection always has at least
+ /// one syntax node, so an empty span is an invariant violation rather than a legitimate
+ /// "no field" signal — surface it as a clear DAB error.
+ ///
+ public static FieldNode RequireFieldNode(this Selection selection)
+ {
+ // SyntaxNodes is a ReadOnlySpan, so LINQ helpers (e.g. FirstOrDefault)
+ // are not available; check IsEmpty before indexing.
+ ReadOnlySpan syntaxNodes = selection.SyntaxNodes;
+ if (syntaxNodes.IsEmpty)
+ {
+ throw new DataApiBuilderException(
+ message: $"GraphQL selection '{selection.ResponseName}' has no syntax node available.",
+ statusCode: HttpStatusCode.InternalServerError,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
+ }
+
+ return syntaxNodes[0].Node;
+ }
+ }
+}
diff --git a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs
index 76057a76dc..9a65d3461d 100644
--- a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs
+++ b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs
@@ -582,16 +582,17 @@ public static IValueNode CreateValueNodeFromDbObjectMetadata(object metadataValu
short value => new ObjectValueNode(new ObjectFieldNode(SHORT_TYPE, new IntValueNode(value))),
int value => new ObjectValueNode(new ObjectFieldNode(INT_TYPE, value)),
long value => new ObjectValueNode(new ObjectFieldNode(LONG_TYPE, new IntValueNode(value))),
- Guid value => new ObjectValueNode(new ObjectFieldNode(UUID_TYPE, new UuidType().ParseValue(value))),
+ Guid value => new ObjectValueNode(new ObjectFieldNode(UUID_TYPE, new UuidType().ValueToLiteral(value))),
string value => new ObjectValueNode(new ObjectFieldNode(STRING_TYPE, value)),
bool value => new ObjectValueNode(new ObjectFieldNode(BOOLEAN_TYPE, value)),
- float value => new ObjectValueNode(new ObjectFieldNode(SINGLE_TYPE, new SingleType().ParseValue(value))),
+ float value => new ObjectValueNode(new ObjectFieldNode(SINGLE_TYPE, new SingleType().ValueToLiteral(value))),
double value => new ObjectValueNode(new ObjectFieldNode(FLOAT_TYPE, value)),
decimal value => new ObjectValueNode(new ObjectFieldNode(DECIMAL_TYPE, new FloatValueNode(value))),
- DateTimeOffset value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ParseValue(value))),
- DateTime value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ParseResult(value))),
- byte[] value => new ObjectValueNode(new ObjectFieldNode(BYTEARRAY_TYPE, new ByteArrayType().ParseValue(value))),
- TimeOnly value => new ObjectValueNode(new ObjectFieldNode(LOCALTIME_TYPE, new HotChocolate.Types.NodaTime.LocalTimeType().ParseResult(value))),
+ DateTimeOffset value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ValueToLiteral(value))),
+ DateTime value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ValueToLiteral(
+ value.Kind == DateTimeKind.Unspecified ? new DateTimeOffset(value, TimeSpan.Zero) : new DateTimeOffset(value)))),
+ byte[] value => new ObjectValueNode(new ObjectFieldNode(BYTEARRAY_TYPE, new Base64StringType().ValueToLiteral(value))),
+ TimeOnly value => new ObjectValueNode(new ObjectFieldNode(LOCALTIME_TYPE, new HotChocolate.Types.NodaTime.LocalTimeType().ValueToLiteral(value))),
_ => throw new DataApiBuilderException(
message: $"The type {metadataValue.GetType()} is not supported as a GraphQL default value",
statusCode: HttpStatusCode.InternalServerError,
diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs
index be2dcca84f..148344dad5 100644
--- a/src/Service.Tests/Configuration/ConfigurationTests.cs
+++ b/src/Service.Tests/Configuration/ConfigurationTests.cs
@@ -741,6 +741,13 @@ public void CleanupAfterEachTest()
/// But if invalid config is provided during startup, ApplicationException is thrown
/// and application exits.
///
+ ///
+ /// As of Hot Chocolate 16, the GraphQL middleware resolves WithOptions per request via an
+ /// Action<GraphQLServerOptions>, so the "no runtime config" condition surfaces as a
+ /// with bubbling
+ /// out of the request pipeline rather than as a synchronous 503 response. The assertions below treat
+ /// that as semantically equivalent to the original 503 / contract.
+ ///
[DataTestMethod]
[DataRow(new string[] { }, true, DisplayName = "No config returns 503 - config file flag absent")]
[DataRow(new string[] { "--ConfigFileName=" }, true, DisplayName = "No config returns 503 - empty config file option")]
@@ -767,13 +774,24 @@ public async Task TestNoConfigReturnsServiceUnavailable(
HttpResponseMessage result = await httpClient.GetAsync("/graphql");
Assert.AreEqual(HttpStatusCode.ServiceUnavailable, result.StatusCode);
}
- catch (Exception e)
+ catch (DataApiBuilderException dabException)
{
- Assert.IsFalse(isUpdateableRuntimeConfig);
- Assert.AreEqual(typeof(ApplicationException), e.GetType());
+ // Hot Chocolate 16+: the absence of a runtime config bubbles out of the GraphQL pipeline
+ // as DataApiBuilderException(ServiceUnavailable). This is semantically equivalent to the
+ // pre-HC16 503 response (hosting case) or ApplicationException (CLI case).
+ Assert.AreEqual(
+ HttpStatusCode.ServiceUnavailable,
+ dabException.StatusCode,
+ $"Expected ServiceUnavailable status when runtime config is missing, got: {dabException.Message}");
+ }
+ catch (ApplicationException appException)
+ {
+ Assert.IsFalse(
+ isUpdateableRuntimeConfig,
+ "ApplicationException should only be thrown in the non-updateable (CLI startup) scenario.");
Assert.AreEqual(
$"Could not initialize the engine with the runtime config file: {DEFAULT_CONFIG_FILE_NAME}",
- e.Message);
+ appException.Message);
}
finally
{
diff --git a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs
index 3ff9e58531..6c1e76f715 100644
--- a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs
+++ b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+#nullable enable annotations
+
using System;
using System.Collections.Generic;
using System.IO;
diff --git a/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs b/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs
index 745d5eade3..bb07362a7f 100644
--- a/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs
+++ b/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs
@@ -58,7 +58,7 @@ public async Task InsertMutation(string dbQuery)
/// "default_string_with_paranthesis": "()",
/// "default_function_string_with_paranthesis": "NOW()",
/// "default_integer": 100,
- /// "default_date_string": "1999-01-08T10:23:54.000Z"
+ /// "default_date_string": "1999-01-08T10:23:54Z"
/// }
///
public virtual async Task InsertMutationWithDefaultBuiltInFunctions(string dbQuery)
@@ -95,7 +95,9 @@ public virtual async Task InsertMutationWithDefaultBuiltInFunctions(string dbQue
Assert.AreEqual("()", result.GetProperty("default_string_with_parenthesis").GetString());
Assert.AreEqual("NOW()", result.GetProperty("default_function_string_with_parenthesis").GetString());
Assert.AreEqual(100, result.GetProperty("default_integer").GetInt32());
- Assert.AreEqual("1999-01-08T10:23:54.000Z", result.GetProperty("default_date_string").GetString());
+ // HC v16 DateTime scalar elides trailing zero fractional seconds in ISO-8601 output
+ // ("1999-01-08T10:23:54.000Z" → "1999-01-08T10:23:54Z").
+ Assert.AreEqual("1999-01-08T10:23:54Z", result.GetProperty("default_date_string").GetString());
}
///
diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs
index 6f1b498f1e..2d2ee75e0d 100644
--- a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs
+++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs
@@ -95,7 +95,7 @@ public async Task QueryTypeColumn(string type, int id)
Assert.Inconclusive("Type not supported");
}
- string field = $"{type.ToLowerInvariant()}_types";
+ string field = GetTestFieldName(type);
string graphQLQueryName = "supportedType_by_pk";
string gqlQuery = "{ supportedType_by_pk(typeid: " + id + ") { typeid, " + field + " } }";
@@ -159,7 +159,7 @@ public async Task QueryTypeColumnFilterAndOrderBy(string type, string filterOper
Assert.Inconclusive("Type not supported");
}
- string field = $"{type.ToLowerInvariant()}_types";
+ string field = GetTestFieldName(type);
string graphQLQueryName = "supportedTypes";
string gqlQuery = @"{
supportedTypes(first: 100 orderBy: { typeid: ASC } filter: { " + field + ": {" + filterOperator + ": " + gqlValue + @"} }) {
@@ -403,7 +403,7 @@ public async Task InsertIntoTypeColumn(string type, string value)
Assert.Inconclusive("Type not supported");
}
- string field = $"{type.ToLowerInvariant()}_types";
+ string field = GetTestFieldName(type);
string graphQLMutationName = "createSupportedType";
string gqlMutation = "mutation{ createSupportedType (item: {" + field + ": " + value + " }){ typeid, " + field + " } }";
@@ -434,7 +434,7 @@ public async Task InsertInvalidTimeIntoTimeTypeColumn(string type, string value)
Assert.Inconclusive("Type not supported");
}
- string field = $"{type.ToLowerInvariant()}_types";
+ string field = GetTestFieldName(type);
string graphQLQueryName = "createSupportedType";
string gqlQuery = "mutation{ createSupportedType (item: {" + field + ": " + value + " }){ typeid, " + field + " } }";
@@ -471,7 +471,7 @@ public async Task InsertIntoTypeColumnWithArgument(string type, object value)
Assert.Inconclusive("Type not supported");
}
- string field = $"{type.ToLowerInvariant()}_types";
+ string field = GetTestFieldName(type);
string graphQLQueryName = "createSupportedType";
string gqlQuery = "mutation($param: " + TypeNameToGraphQLType(type) + "){ createSupportedType (item: {" + field + ": $param }){ typeid, " + field + " } }";
@@ -537,7 +537,7 @@ public async Task UpdateTypeColumn(string type, string value)
Assert.Inconclusive("Type not supported");
}
- string field = $"{type.ToLowerInvariant()}_types";
+ string field = GetTestFieldName(type);
string graphQLQueryName = "updateSupportedType";
string gqlQuery = "mutation{ updateSupportedType (typeid: 1, item: {" + field + ": " + value + " }){ typeid " + field + " } }";
@@ -577,7 +577,7 @@ public async Task UpdateTypeColumnWithArgument(string type, object value)
Assert.Inconclusive("Type not supported");
}
- string field = $"{type.ToLowerInvariant()}_types";
+ string field = GetTestFieldName(type);
string graphQLQueryName = "updateSupportedType";
string gqlQuery = "mutation($param: " + TypeNameToGraphQLType(type) + "){ updateSupportedType (typeid: 1, item: {" + field + ": $param }){ typeid, " + field + " } }";
@@ -660,7 +660,7 @@ private static void CompareUuidResults(string actual, string expected)
///
private static void CompareFloatResults(string floatType, string actual, string expected)
{
- string fieldName = $"{floatType.ToLowerInvariant()}_types";
+ string fieldName = GetTestFieldName(floatType);
using JsonDocument actualJsonDoc = JsonDocument.Parse(actual);
using JsonDocument expectedJsonDoc = JsonDocument.Parse(expected);
@@ -871,6 +871,24 @@ private static string TypeNameToGraphQLType(string typeName)
};
}
+ ///
+ /// Maps a DAB GraphQL scalar type name to the column-name suffix used in the
+ /// supported-types test table (and corresponding GraphQL field). The convention is
+ /// {lower(typeName)}_types, except for which is
+ /// "UnsignedByte" after the HC v16 upgrade. SQL Server's tinyint column and
+ /// the corresponding GraphQL field are still named byte_types, so map
+ /// UnsignedByte back to byte for the column-name lookup.
+ ///
+ protected static string GetTestFieldName(string typeName)
+ {
+ if (typeName == BYTE_TYPE)
+ {
+ return "byte_types";
+ }
+
+ return $"{typeName.ToLowerInvariant()}_types";
+ }
+
protected abstract string MakeQueryOnTypeTable(
List queryFields,
string filterValue = "1",
diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs
index 49b062c55e..e4d304fed8 100644
--- a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs
+++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs
@@ -55,19 +55,21 @@ public static async Task SetupAsync(TestContext context)
/// Unescaped string used as value for GraphQL input field datetime_types
/// Expected result the HotChocolate returns from resolving database response.
// Date and time
- [DataRow("1000-01-01 00:00:00", "1000-01-01T00:00:00.000Z", DisplayName = "Datetime value separated by space.")]
- [DataRow("9999-12-31T23:59:59", "9999-12-31T23:59:59.000Z", DisplayName = "Datetime value separated by T.")]
- [DataRow("9999-12-31 23:59:59Z", "9999-12-31T23:59:59.000Z", DisplayName = "Datetime value specified with UTC offset Z as resolved by HotChocolate.")]
- [DataRow("9999-12-31 23:59:59+00:00", "9999-12-31T23:59:59.000Z", DisplayName = "Datetime value specified with UTC offset with no datetime change when stored in db.")]
- [DataRow("9999-12-31 23:59:59+03:00", "9999-12-31T20:59:59.000Z", DisplayName = "Timezone offset UTC+03:00 accepted by MySQL because UTC value is in supported datetime range.")]
- [DataRow("9999-12-31 20:59:59-03:00", "9999-12-31T23:59:59.000Z", DisplayName = "Timezone offset UTC-03:00 accepted by MySQL because UTC value is in supported datetime range.")]
+ // Note: HC v16's DateTime scalar elides trailing zero fractional seconds in ISO-8601
+ // output (e.g. "1000-01-01T00:00:00.000Z" → "1000-01-01T00:00:00Z").
+ [DataRow("1000-01-01 00:00:00", "1000-01-01T00:00:00Z", DisplayName = "Datetime value separated by space.")]
+ [DataRow("9999-12-31T23:59:59", "9999-12-31T23:59:59Z", DisplayName = "Datetime value separated by T.")]
+ [DataRow("9999-12-31 23:59:59Z", "9999-12-31T23:59:59Z", DisplayName = "Datetime value specified with UTC offset Z as resolved by HotChocolate.")]
+ [DataRow("9999-12-31 23:59:59+00:00", "9999-12-31T23:59:59Z", DisplayName = "Datetime value specified with UTC offset with no datetime change when stored in db.")]
+ [DataRow("9999-12-31 23:59:59+03:00", "9999-12-31T20:59:59Z", DisplayName = "Timezone offset UTC+03:00 accepted by MySQL because UTC value is in supported datetime range.")]
+ [DataRow("9999-12-31 20:59:59-03:00", "9999-12-31T23:59:59Z", DisplayName = "Timezone offset UTC-03:00 accepted by MySQL because UTC value is in supported datetime range.")]
// Fractional seconds rounded up/down when mysql column datetime doesn't specify fractional seconds
// e.g. column not defined as datetime({1-6})
- [DataRow("9999-12-31 23:59:59.499999", "9999-12-31T23:59:59.000Z", DisplayName = "Fractional seconds rounded down because fractional seconds are passed to column with datatype datetime(0).")]
- [DataRow("2024-12-31 23:59:59.999999", "2025-01-01T00:00:00.000Z", DisplayName = "Fractional seconds rounded up because fractional seconds are passed to column with datatype datetime(0).")]
+ [DataRow("9999-12-31 23:59:59.499999", "9999-12-31T23:59:59Z", DisplayName = "Fractional seconds rounded down because fractional seconds are passed to column with datatype datetime(0).")]
+ [DataRow("2024-12-31 23:59:59.999999", "2025-01-01T00:00:00Z", DisplayName = "Fractional seconds rounded up because fractional seconds are passed to column with datatype datetime(0).")]
// Only date
- [DataRow("9999-12-31", "9999-12-31T00:00:00.000Z", DisplayName = "Max date for datetime column stored with zeroed out time.")]
- [DataRow("1000-01-01", "1000-01-01T00:00:00.000Z", DisplayName = "Min date for datetime column stored with zeroed out time.")]
+ [DataRow("9999-12-31", "9999-12-31T00:00:00Z", DisplayName = "Max date for datetime column stored with zeroed out time.")]
+ [DataRow("1000-01-01", "1000-01-01T00:00:00Z", DisplayName = "Min date for datetime column stored with zeroed out time.")]
[DataTestMethod]
public async Task InsertMutationInput_DateTimeTypes_ValidRange_ReturnsExpectedValues(string dateTimeGraphQLInput, string expectedResult)
{
@@ -98,9 +100,11 @@ public async Task InsertMutationInput_DateTimeTypes_ValidRange_ReturnsExpectedVa
///
/// Unescaped string used as value for GraphQL input field datetime_types
/// Expected result the HotChocolate returns from resolving database response.
- [DataRow("23:59:59.499999", "23:59:59.000Z", DisplayName = "hh:mm::ss.ffffff for datetime column stored with zeroed out date and rounded down fractional seconds.")]
- [DataRow("23:59:59", "23:59:59.000Z", DisplayName = "hh:mm:ss for datetime column stored with zeroed out date.")]
- [DataRow("23:59", "23:59:00.000Z", DisplayName = "hh:mm for datetime column stored with zeroed out date and seconds.")]
+ // Note: HC v16's DateTime scalar elides trailing zero fractional seconds in ISO-8601
+ // output (e.g. "23:59:59.000Z" → "23:59:59Z").
+ [DataRow("23:59:59.499999", "23:59:59Z", DisplayName = "hh:mm::ss.ffffff for datetime column stored with zeroed out date and rounded down fractional seconds.")]
+ [DataRow("23:59:59", "23:59:59Z", DisplayName = "hh:mm:ss for datetime column stored with zeroed out date.")]
+ [DataRow("23:59", "23:59:00Z", DisplayName = "hh:mm for datetime column stored with zeroed out date and seconds.")]
[DataTestMethod]
public async Task InsertMutationInput_DateTimeTypes_TimeOnly_ValidRange_ReturnsExpectedValues(string dateTimeGraphQLInput, string expectedResult)
{
diff --git a/src/Service.Tests/UnitTests/MultiSourceQueryExecutionUnitTests.cs b/src/Service.Tests/UnitTests/MultiSourceQueryExecutionUnitTests.cs
index e06e140328..7c2ee2cecd 100644
--- a/src/Service.Tests/UnitTests/MultiSourceQueryExecutionUnitTests.cs
+++ b/src/Service.Tests/UnitTests/MultiSourceQueryExecutionUnitTests.cs
@@ -26,6 +26,7 @@
using HotChocolate;
using HotChocolate.Execution;
using HotChocolate.Resolvers;
+using HotChocolate.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -118,18 +119,18 @@ public async Task TestMultiSourceQuery()
Assert.AreEqual(1, cosmosQueryEngine.Invocations.Count, "Cosmos query engine should be invoked for multi-source query as an entity belongs to cosmos db.");
OperationResult singleResult = result.ExpectOperationResult();
- Assert.IsNull(singleResult.Errors, "There should be no errors in processing of multisource query.");
+ Assert.IsTrue(singleResult.Errors.IsEmpty, "There should be no errors in processing of multisource query.");
Assert.IsNotNull(singleResult.Data, "Data should be returned for multisource query.");
- IReadOnlyDictionary data = singleResult.Data;
- Assert.IsTrue(data.TryGetValue(QUERY_NAME_1, out object queryNode1), $"Query node for {QUERY_NAME_1} should have data populated.");
- Assert.IsTrue(data.TryGetValue(QUERY_NAME_2, out object queryNode2), $"Query node for {QUERY_NAME_2} should have data populated.");
+ ResultDocument document = (ResultDocument)singleResult.Data.Value.Value;
+ Assert.IsTrue(document.Data.TryGetProperty(QUERY_NAME_1, out ResultElement queryNode1), $"Query node for {QUERY_NAME_1} should have data populated.");
+ Assert.IsTrue(document.Data.TryGetProperty(QUERY_NAME_2, out ResultElement queryNode2), $"Query node for {QUERY_NAME_2} should have data populated.");
- KeyValuePair firstEntryMap1 = ((IReadOnlyDictionary)queryNode1).FirstOrDefault();
- KeyValuePair firstEntryMap2 = ((IReadOnlyDictionary)queryNode2).FirstOrDefault();
+ ResultProperty firstEntryMap1 = queryNode1.EnumerateObject().FirstOrDefault();
+ ResultProperty firstEntryMap2 = queryNode2.EnumerateObject().FirstOrDefault();
// validate that the data returned for the queries we did matches the moq data we set up for the respective query engines.
- Assert.AreEqual("db1", firstEntryMap1.Value, $"Data returned for {QUERY_NAME_1} is incorrect for multi-source query");
- Assert.AreEqual("db2", firstEntryMap2.Value, $"Data returned for {QUERY_NAME_2} is incorrect for multi-source query");
+ Assert.AreEqual("db1", firstEntryMap1.Value.GetString(), $"Data returned for {QUERY_NAME_1} is incorrect for multi-source query");
+ Assert.AreEqual("db2", firstEntryMap2.Value.GetString(), $"Data returned for {QUERY_NAME_2} is incorrect for multi-source query");
}
///
diff --git a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs
index 4da3266271..65bc1778e6 100644
--- a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs
+++ b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+#nullable enable annotations
+
using Azure.DataApiBuilder.Core.Parsers;
using Microsoft.VisualStudio.TestTools.UnitTesting;
diff --git a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs
index fa2e0e33f6..f30c9f56f5 100644
--- a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs
+++ b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+#nullable enable annotations
+
using System;
using System.Collections.Generic;
using System.Data;
diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs
index 70c162a078..6c32fe00ab 100644
--- a/src/Service/Startup.cs
+++ b/src/Service/Startup.cs
@@ -615,16 +615,66 @@ private void ConfigureResponseCompression(IServiceCollection services, RuntimeCo
private void AddGraphQLService(IServiceCollection services, GraphQLRuntimeOptions? graphQLRuntimeOptions)
{
IRequestExecutorBuilder server = services.AddGraphQLServer()
+ // Hot Chocolate v16 builds the GraphQL schema eagerly during host startup. DAB
+ // supports a "hosted" scenario where the runtime config is supplied after the
+ // host starts (POST /configuration), so the schema cannot exist at startup. Eager
+ // initialization plus our placeholder-schema fallback also creates a race on
+ // late-config hydration: HC keeps serving the warm placeholder executor in the
+ // background while the new schema's warmup runs, so the first GraphQL request
+ // after hydration validates against the placeholder and returns BadRequest.
+ // Opt into lazy initialization (the v15 default) so the schema is constructed on
+ // first request, by which time the runtime config is loaded and the metadata
+ // provider has been initialized in PerformOnConfigChangeAsync.
+ .ModifyOptions(options => options.LazyInitialization = true)
.AddInstrumentation()
- .AddType(new DateTimeType(disableFormatCheck: graphQLRuntimeOptions?.EnableLegacyDateTimeScalar ?? true))
+ .AddType(new DateTimeType(new DateTimeOptions { ValidateInputFormat = !(graphQLRuntimeOptions?.EnableLegacyDateTimeScalar ?? true) }))
.AddHttpRequestInterceptor()
.ConfigureSchema((serviceProvider, schemaBuilder) =>
{
- // The GraphQLSchemaCreator is an application service that is not available on
- // the schema specific service provider, this means we have to get it with
- // the GetRootServiceProvider helper.
- GraphQLSchemaCreator graphQLService = serviceProvider.GetRootServiceProvider().GetRequiredService();
- graphQLService.InitializeSchemaAndResolvers(schemaBuilder);
+ // With LazyInitialization, ConfigureSchema runs on the first GraphQL request,
+ // not at host startup. By that point the runtime config is loaded for both
+ // the file-based and POST /configuration scenarios. The placeholder fallback
+ // remains as a safety net for two edge cases:
+ // 1. GraphQL globally disabled - requests are short-circuited to 404 by
+ // PathRewriteMiddleware, but if a request slips through, emit a minimal
+ // schema so HC validation does not fail on entity metadata that is
+ // intentionally never exposed (e.g. column names colliding with
+ // reserved GraphQL identifiers like the leading double-underscore).
+ // 2. Schema construction failure (e.g. config validation error preventing
+ // metadata-provider initialization).
+ RuntimeConfigProvider configProvider = serviceProvider.GetRootServiceProvider()
+ .GetRequiredService();
+ if (!configProvider.TryGetConfig(out RuntimeConfig? loadedConfig))
+ {
+ EmitPlaceholderSchema(schemaBuilder);
+ return;
+ }
+
+ if (!loadedConfig.IsGraphQLEnabled)
+ {
+ EmitPlaceholderSchema(schemaBuilder);
+ return;
+ }
+
+ try
+ {
+ // The GraphQLSchemaCreator is an application service that is not available on
+ // the schema specific service provider, this means we have to get it with
+ // the GetRootServiceProvider helper.
+ GraphQLSchemaCreator graphQLService = serviceProvider.GetRootServiceProvider().GetRequiredService();
+ graphQLService.InitializeSchemaAndResolvers(schemaBuilder);
+ }
+ catch (Exception ex)
+ {
+ // Schema construction failed (e.g. metadata provider was not initialized
+ // due to a config validation error). Fall back to the placeholder schema
+ // so the host can still start; the underlying error will have already
+ // been surfaced by Startup.PerformOnConfigChangeAsync.
+ _logger.LogWarning(
+ exception: ex,
+ message: "Failed to build GraphQL schema; emitting placeholder schema. The error will surface on first GraphQL request and is typically caused by a runtime config that failed validation.");
+ EmitPlaceholderSchema(schemaBuilder);
+ }
})
.AddHttpRequestInterceptor()
.AddAuthorizationHandler()
@@ -847,23 +897,11 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC
endpoints
.MapGraphQL()
- .WithOptions(new GraphQLServerOptions
+ .WithOptions(options =>
{
- Tool = {
- // Determines if accessing the endpoint from a browser
- // will load the GraphQL Banana Cake Pop IDE.
- Enable = IsUIEnabled(runtimeConfig, env)
- }
- });
-
- // In development mode, Nitro is enabled at /graphql endpoint by default.
- // Need to disable mapping Nitro explicitly as well to avoid ability to query
- // at an additional endpoint: /graphql/ui.
- endpoints
- .MapNitroApp()
- .WithOptions(new GraphQLToolOptions
- {
- Enable = false
+ // Determines if accessing the endpoint from a browser
+ // will load the GraphQL Banana Cake Pop / Nitro IDE.
+ options.Tool.Enable = IsUIEnabled(runtimeConfig, env);
});
endpoints.MapHealthChecks("/", new HealthCheckOptions
@@ -882,6 +920,21 @@ private static void EvictGraphQLSchema(IRequestExecutorManager requestExecutorRe
requestExecutorResolver.EvictExecutor();
}
+ ///
+ /// Registers a minimal valid GraphQL schema (a single placeholder field with a no-op
+ /// resolver) on the supplied . Used to satisfy Hot
+ /// Chocolate v16's eager schema validation when the real schema cannot be constructed
+ /// at startup (no runtime config loaded yet, or a config validation failure prevented
+ /// metadata provider initialization). The placeholder is unreachable in normal
+ /// operation: it is shadowed once a real config is hot-reloaded via the
+ /// GRAPHQL_SCHEMA_EVICTION_ON_CONFIG_CHANGED event.
+ ///
+ private static void EmitPlaceholderSchema(ISchemaBuilder schemaBuilder)
+ {
+ schemaBuilder.AddDocumentFromString("type Query { _dab: String }");
+ schemaBuilder.AddResolver("Query", "_dab", _ => null);
+ }
+
///
/// If LogLevel is NOT overridden by CLI, attempts to find the
/// minimum log level based on host.mode in the runtime config if available.