From 5ad69b99ad39b5a2067fdd14beb54c2614f7d9f5 Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Tue, 28 Apr 2026 23:37:35 -0700 Subject: [PATCH 01/18] chore(deps): bump HotChocolate to 16.0.0-rc.1.43 Bumps the HotChocolate family of packages from 16.0.0-p.7.68 (preview) to 16.0.0-rc.1.43 (release candidate). Affected packages: - HotChocolate - HotChocolate.AspNetCore - HotChocolate.AspNetCore.Authorization - HotChocolate.ModelContextProtocol - HotChocolate.Types.NodaTime - HotChocolate.Utilities.Introspection - HotChocolate.Transport.Http - HotChocolate.Diagnostics - CookieCrumble This commit is intentionally a single-line version bump; the API surface changes required to compile against rc.1.43 are split across the following commits in this series. Note: rc.1.43 is not yet cached on the internal feed (pkgs.dev.azure.com/sqldab/data_api_builder_build_packages). The NuGetAuthenticate@1 step in the build pipeline will trigger an authenticated upstream fetch on the first restore, after which the package will be permanently cached for all subsequent builds. --- src/Directory.Packages.props | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index dfd605cc8f..36361af5c9 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -13,15 +13,15 @@ - - - - - - - - - + + + + + + + + + From 3f557c2bbec10e5de287d56a541c2837f3ed2641 Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Tue, 28 Apr 2026 23:38:29 -0700 Subject: [PATCH 02/18] refactor(graphql): migrate scalar APIs to Hot Chocolate v16 Hot Chocolate v16 reshaped the scalar type API surface. This commit adopts the new pattern in DAB's custom and built-in scalar usages. Scalar override renames (Service.GraphQLBuilder/CustomScalars/SingleType.cs): ParseLiteral(IFloatValueLiteral) -> OnCoerceInputLiteral(IFloatValueLiteral) ParseValue(JsonElement) -> OnCoerceInputValue(JsonElement) ParseResult / FormatResult -> OnCoerceOutputValue(T, ResultElement) ParseValue(T runtime) -> OnValueToLiteral(T) The output methods now write into a HotChocolate.Text.Json.ResultElement buffer rather than returning a value, and OnValueToLiteral replaces the old "ValueToLiteral / ParseResult / ParseValue" trio with a single literal-producing override. Scalar invocation renames at call sites (GraphQLStoredProcedureBuilder.cs, Sql/SchemaConverter.cs): type.ParseValue(runtimeValue) -> type.ValueToLiteral(runtimeValue) type.ParseResult(runtimeValue) -> type.ValueToLiteral(runtimeValue) Type rename (GraphQLTypes/DefaultValueType.cs, GraphQLStoredProcedureBuilder.cs, Sql/SchemaConverter.cs): ByteArrayType -> Base64StringType The legacy ByteArrayType identifier is now [Obsolete] and the Base64-string-encoded byte-array scalar lives under Base64StringType. DateTime / DateTimeOffset boundary (GraphQLStoredProcedureBuilder.cs, Sql/SchemaConverter.cs): HC v16's DateTimeType.ValueToLiteral(...) accepts only DateTimeOffset. Inputs from config (System.DateTime) are converted at the boundary preserving DateTimeKind: - Unspecified -> DateTimeOffset(value, TimeSpan.Zero) - Local / Utc -> DateTimeOffset(value) The CLR-driven schema-converter path now parses datetime defaults as DateTimeOffset directly rather than via DateTime. These changes match the patterns proposed in PR #3136 (HC v16 milestone-11 migration) and continue to compile cleanly against rc.1.43. --- .../CustomScalars/SingleType.cs | 16 ++++++++++++---- .../GraphQLStoredProcedureBuilder.cs | 12 ++++++------ .../GraphQLTypes/DefaultValueType.cs | 2 +- .../Sql/SchemaConverter.cs | 13 +++++++------ 4 files changed, 26 insertions(+), 17 deletions(-) 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..2887746a2d 100644 --- a/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs +++ b/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs @@ -156,12 +156,12 @@ private static Tuple ConvertValueToGraphQLType(string defaul { Tuple valueNode = paramValueType switch { - UUID_TYPE => new(UUID_TYPE, new UuidType().ParseValue(Guid.Parse(defaultValueFromConfig))), + UUID_TYPE => new(UUID_TYPE, new UuidType().ValueToLiteral(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))), + SINGLE_TYPE => new(SINGLE_TYPE, new SingleType().ValueToLiteral(float.Parse(defaultValueFromConfig))), FLOAT_TYPE => new(FLOAT_TYPE, new FloatValueNode(double.Parse(defaultValueFromConfig))), DECIMAL_TYPE => new(DECIMAL_TYPE, new FloatValueNode(decimal.Parse(defaultValueFromConfig))), STRING_TYPE => new(STRING_TYPE, new StringValueNode(defaultValueFromConfig)), @@ -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..c047732b8c 100644 --- a/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs +++ b/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs @@ -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/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, From 8a7c33deb769542bfe8f9b01c43ee3fccb8dfab7 Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Tue, 28 Apr 2026 23:39:02 -0700 Subject: [PATCH 03/18] refactor(core): adopt Selection / SyntaxNodes / DurationType / ContextData Migrates DAB's Hot Chocolate runtime integration to v16's resolver and execution-pipeline contracts. Selection -> concrete type (Resolvers/CosmosQueryStructure.cs): ISelection -> Selection The ISelection interface was removed in HC v16; the runtime always returns the concrete Selection type. Selection.SyntaxNode -> SyntaxNodes[0].Node (Resolvers/CosmosQueryStructure.cs, Resolvers/Sql Query Structures/SqlQueryStructure.cs, Services/ExecutionHelper.cs) HC v16 introduces field-merging during query analysis, so a single Selection can now be backed by multiple syntactic occurrences in the query document. The shape changed from a single FieldNode (SyntaxNode) to an immutable list (SyntaxNodes) whose first entry is the canonical node for code that does not yet handle merged fields. DAB does not currently exercise merging, so .SyntaxNodes[0].Node is the equivalent canonical node and matches PR #3136's pattern. TimeSpanType -> DurationType (Services/ExecutionHelper.cs): The TimeSpanType scalar was removed; its replacement DurationType serializes values as ISO-8601 duration strings (e.g. "PT1H30M") rather than .NET's "1.06:00:00" round-trip format. The runtime-coercion path now parses with System.Xml.XmlConvert.ToTimeSpan, which is the ISO-8601-aware parser shipped in BCL. ByteArrayType -> Base64StringType (Services/ExecutionHelper.cs): Same scalar rename as the GraphQLBuilder commit; applied here in the runtime fieldValue coercion switch. OperationResult.WithContextData(...) -> direct property setter (Services/DetermineStatusCodeMiddleware.cs): context.Result = singleResult.WithContextData(contextData.ToImmutable()); is now singleResult.ContextData = contextData.ToImmutable(); OperationResult is mutable in v16, so the WithContextData fluent helper was removed; mutate ContextData directly. The middleware no longer needs to reassign context.Result since the existing instance reference now carries the change. --- src/Core/Resolvers/CosmosQueryStructure.cs | 6 +++--- .../Resolvers/Sql Query Structures/SqlQueryStructure.cs | 4 ++-- src/Core/Services/DetermineStatusCodeMiddleware.cs | 2 +- src/Core/Services/ExecutionHelper.cs | 7 ++++--- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/Core/Resolvers/CosmosQueryStructure.cs b/src/Core/Resolvers/CosmosQueryStructure.cs index 68d83557c0..9f04370e96 100644 --- a/src/Core/Resolvers/CosmosQueryStructure.cs +++ b/src/Core/Resolvers/CosmosQueryStructure.cs @@ -117,14 +117,14 @@ 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(); if (IsPaginated) { - FieldNode? fieldNode = ExtractQueryField(selection.SyntaxNode); + FieldNode? fieldNode = ExtractQueryField(selection.SyntaxNodes[0].Node); if (fieldNode is not null) { @@ -139,7 +139,7 @@ private void Init(IDictionary queryParams) } else { - Columns.AddRange(GenerateQueryColumns(selection.SyntaxNode.SelectionSet!, _context.Operation.Document, SourceAlias)); + Columns.AddRange(GenerateQueryColumns(selection.SyntaxNodes[0].Node.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..c1bf1f1f6c 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.SyntaxNodes[0].Node, // 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.SyntaxNodes[0].Node; 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..133f533001 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; @@ -211,11 +212,11 @@ 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(), + Base64StringType => fieldValue.GetBytesFromBase64(), 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 +509,7 @@ public static InputObjectType InputObjectTypeFromIInputField(IInputValueDefiniti { return GetParametersFromSchemaAndQueryFields( context.Selection.Field, - context.Selection.SyntaxNode, + context.Selection.SyntaxNodes[0].Node, context.Variables); } From b49d904a98d469eead055eab6af0d2c349fe87c3 Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Tue, 28 Apr 2026 23:39:19 -0700 Subject: [PATCH 04/18] chore(graphql): drop EnableOneOf option (default in HC v16) The OneOf input-object directive (graphql/graphql-spec#825) is permanently enabled in Hot Chocolate v16 and the SchemaOptions.EnableOneOf toggle was removed. Drops the now-obsolete .ModifyOptions(o => o.EnableOneOf = true) configuration call from the schema builder; behavior is unchanged. --- src/Core/Services/GraphQLSchemaCreator.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Core/Services/GraphQLSchemaCreator.cs b/src/Core/Services/GraphQLSchemaCreator.cs index 90e918c833..d0ac2ac1b1 100644 --- a/src/Core/Services/GraphQLSchemaCreator.cs +++ b/src/Core/Services/GraphQLSchemaCreator.cs @@ -122,8 +122,6 @@ 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))); } From 6ec7a4fcecc21f5a6d5b95e6c2ca7eb2e0a3c201 Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Tue, 28 Apr 2026 23:39:45 -0700 Subject: [PATCH 05/18] refactor(startup): adopt DateTimeOptions and per-request WithOptions delegate Updates the GraphQL server wire-up in Startup.cs for three v16 API changes: 1. DateTimeType options bag The bool-flag overload `new DateTimeType(disableFormatCheck: bool)` is now [Obsolete]. Replaced with `new DateTimeType(new DateTimeOptions { ValidateInputFormat = ... })`. Note the boolean polarity flipped: old: disableFormatCheck = !graphQLRuntimeOptions.EnableLegacyDateTimeScalar new: ValidateInputFormat = !graphQLRuntimeOptions.EnableLegacyDateTimeScalar ...so the resulting condition that controls validation is unchanged. 2. WithOptions accepts only Action The MapGraphQL().WithOptions(...) overload that took a literal GraphQLServerOptions instance was removed. Options are now configured per-request via a delegate: .WithOptions(options => options.Tool.Enable = IsUIEnabled(...)); This means options are evaluated lazily on each incoming request rather than captured eagerly at startup. The behavioral difference is exercised by ConfigurationTests.TestNoConfigReturnsServiceUnavailable; see the test commit later in this series. 3. Nitro mapping consolidated under GraphQL server options `endpoints.MapNitroApp().WithOptions(new GraphQLToolOptions { ... })` was removed; GraphQLToolOptions is no longer a public type. The Nitro / Banana Cake Pop IDE is now governed solely by GraphQLServerOptions.Tool.Enable on the GraphQL endpoint mapping. Dropped the explicit MapNitroApp block because Tool.Enable already covers the dev-mode disable case. --- src/Service/Startup.cs | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 70c162a078..5e39d2ac14 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -616,7 +616,7 @@ private void AddGraphQLService(IServiceCollection services, GraphQLRuntimeOption { IRequestExecutorBuilder server = services.AddGraphQLServer() .AddInstrumentation() - .AddType(new DateTimeType(disableFormatCheck: graphQLRuntimeOptions?.EnableLegacyDateTimeScalar ?? true)) + .AddType(new DateTimeType(new DateTimeOptions { ValidateInputFormat = !(graphQLRuntimeOptions?.EnableLegacyDateTimeScalar ?? true) })) .AddHttpRequestInterceptor() .ConfigureSchema((serviceProvider, schemaBuilder) => { @@ -847,23 +847,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 From 13f7ade312402cd455c726da30e13e02a79c7935 Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Tue, 28 Apr 2026 23:40:17 -0700 Subject: [PATCH 06/18] test: adopt v16 ResultDocument API and exception contract changes Updates two test files affected by Hot Chocolate v16 runtime API changes that altered observable behavior. MultiSourceQueryExecutionUnitTests.cs - OperationResult.Data shape: HC v16 changed `OperationResult.Data` from `IReadOnlyDictionary` to `OperationResultData?` (a struct wrapping a `ResultDocument`). The `Errors` property is also no longer nullable; it is an immutable list with an `IsEmpty` member. Old: Assert.IsNull(singleResult.Errors, ...); IReadOnlyDictionary data = singleResult.Data; data.TryGetValue(QUERY_NAME_1, out object queryNode1); var firstEntry = ((IReadOnlyDictionary)queryNode1) .FirstOrDefault(); Assert.AreEqual("db1", firstEntry.Value, ...); New: Assert.IsTrue(singleResult.Errors.IsEmpty, ...); ResultDocument document = (ResultDocument)singleResult.Data.Value.Value; document.Data.TryGetProperty(QUERY_NAME_1, out ResultElement queryNode1); ResultProperty firstEntry = queryNode1.EnumerateObject().FirstOrDefault(); Assert.AreEqual("db1", firstEntry.Value.GetString(), ...); The traversal is now: OperationResultData (struct) -> .Value (object, the ResultDocument) -> .Data (root ResultElement) -> .TryGetProperty -> .EnumerateObject -> ResultProperty -> .Value.GetString(). ConfigurationTests.cs - exception contract for "no runtime config": HC v16 resolves WithOptions(Action<>) per request rather than eagerly at startup, so the "no runtime config" condition that previously manifested as a 503 response (hosted-on-demand scenario) or ApplicationException (CLI startup scenario) now bubbles out of the request pipeline as DataApiBuilderException(HttpStatusCode.ServiceUnavailable). The catch block in TestNoConfigReturnsServiceUnavailable is split into two typed handlers so the assertion remains precise: catch (DataApiBuilderException dabException) Assert StatusCode == ServiceUnavailable. (Covers all three [DataRow] cases under HC v16.) catch (ApplicationException appException) Assert isUpdateableRuntimeConfig == false. Assert message matches the historical CLI startup message. (Preserved for symmetry; not exercised under HC v16 runtime behavior on the in-process test host but kept to avoid silently weakening the contract for legacy hosting variants.) Added a remarks block on the test summarizing the behavioral change. --- .../Configuration/ConfigurationTests.cs | 26 ++++++++++++++++--- .../MultiSourceQueryExecutionUnitTests.cs | 17 ++++++------ 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index b6607391b7..c2c1c6f2e3 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/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"); } /// From 01b82c971ffc0638a5e6cba062341bcea1772fb9 Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Tue, 28 Apr 2026 23:40:41 -0700 Subject: [PATCH 07/18] fix(test): mark files using nullable annotations explicitly Adds `#nullable enable annotations` to three test files that contain nullable reference type annotations (e.g. `string?`) but live in the Service.Tests project, which has `disable` set project-wide. Without an explicit annotation context the compiler emits CS8632 ("The annotation for nullable reference types should only be used in code within a '#nullable' annotations context"), which the repo-wide `True` setting promotes to a build error. These errors were introduced on main by an earlier change (PR #3476) and surfaced here only because the HC 16 upgrade pulled the build all the way through Service.Tests. The fix is intentionally narrow: "#nullable enable annotations" allows `?` syntax without enabling flow-sensitive null-state checking, so it does not cascade into a sea of unrelated nullable-state diagnostics in these legacy test files. Files affected: - Service.Tests/UnitTests/RequestParserUnitTests.cs - Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs - Service.Tests/Configuration/RuntimeConfigLoaderTests.cs This commit is independent of the HC 16 upgrade and could land separately on main. --- src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs | 2 ++ src/Service.Tests/UnitTests/RequestParserUnitTests.cs | 2 ++ src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs | 2 ++ 3 files changed, 6 insertions(+) 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/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; From 50ebc29601c5dfae93b61d00cf06717cf4049651 Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Wed, 29 Apr 2026 01:00:05 -0700 Subject: [PATCH 08/18] addressing comments --- src/Core/Resolvers/CosmosQueryStructure.cs | 5 ++- .../Sql Query Structures/SqlQueryStructure.cs | 4 +- src/Core/Services/ExecutionHelper.cs | 3 +- .../GraphQLStoredProcedureBuilder.cs | 14 +++--- .../SelectionExtensions.cs | 43 +++++++++++++++++++ 5 files changed, 56 insertions(+), 13 deletions(-) create mode 100644 src/Service.GraphQLBuilder/SelectionExtensions.cs diff --git a/src/Core/Resolvers/CosmosQueryStructure.cs b/src/Core/Resolvers/CosmosQueryStructure.cs index 9f04370e96..29c435d955 100644 --- a/src/Core/Resolvers/CosmosQueryStructure.cs +++ b/src/Core/Resolvers/CosmosQueryStructure.cs @@ -122,9 +122,10 @@ private void Init(IDictionary queryParams) IsPaginated = QueryBuilder.IsPaginationType(underlyingType); OrderByColumns = new(); + FieldNode selectionFieldNode = selection.RequireFieldNode(); if (IsPaginated) { - FieldNode? fieldNode = ExtractQueryField(selection.SyntaxNodes[0].Node); + FieldNode? fieldNode = ExtractQueryField(selectionFieldNode); if (fieldNode is not null) { @@ -139,7 +140,7 @@ private void Init(IDictionary queryParams) } else { - Columns.AddRange(GenerateQueryColumns(selection.SyntaxNodes[0].Node.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 c1bf1f1f6c..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.SyntaxNodes[0].Node, + 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.SyntaxNodes[0].Node; + FieldNode? queryField = _ctx.Selection.RequireFieldNode(); IOutputType outputType = schemaField.Type; _underlyingFieldType = outputType.NamedType(); diff --git a/src/Core/Services/ExecutionHelper.cs b/src/Core/Services/ExecutionHelper.cs index 133f533001..e90aa65b3c 100644 --- a/src/Core/Services/ExecutionHelper.cs +++ b/src/Core/Services/ExecutionHelper.cs @@ -19,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; @@ -509,7 +508,7 @@ public static InputObjectType InputObjectTypeFromIInputField(IInputValueDefiniti { return GetParametersFromSchemaAndQueryFields( context.Selection.Field, - context.Selection.SyntaxNodes[0].Node, + context.Selection.RequireFieldNode(), context.Variables); } diff --git a/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs b/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs index 2887746a2d..92d0298f10 100644 --- a/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs +++ b/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs @@ -157,13 +157,13 @@ private static Tuple ConvertValueToGraphQLType(string defaul Tuple valueNode = paramValueType switch { UUID_TYPE => new(UUID_TYPE, new UuidType().ValueToLiteral(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().ValueToLiteral(float.Parse(defaultValueFromConfig))), - FLOAT_TYPE => new(FLOAT_TYPE, new FloatValueNode(double.Parse(defaultValueFromConfig))), - DECIMAL_TYPE => new(DECIMAL_TYPE, new FloatValueNode(decimal.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 diff --git a/src/Service.GraphQLBuilder/SelectionExtensions.cs b/src/Service.GraphQLBuilder/SelectionExtensions.cs new file mode 100644 index 0000000000..a0da54b401 --- /dev/null +++ b/src/Service.GraphQLBuilder/SelectionExtensions.cs @@ -0,0 +1,43 @@ +// 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 list) 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 list were ever empty. In practice an executable selection always has at least + /// one syntax node, so an empty list 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) + { + FieldNode? fieldNode = selection.SyntaxNodes.FirstOrDefault()?.Node; + if (fieldNode is null) + { + throw new DataApiBuilderException( + message: $"GraphQL selection '{selection.ResponseName}' has no syntax node available.", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError); + } + + return fieldNode; + } + } +} From 234b24fc027c87d9394ff8835f755bb7ba5c39ef Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Wed, 29 Apr 2026 01:09:56 -0700 Subject: [PATCH 09/18] addressing comments --- src/Service.GraphQLBuilder/SelectionExtensions.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Service.GraphQLBuilder/SelectionExtensions.cs b/src/Service.GraphQLBuilder/SelectionExtensions.cs index a0da54b401..b246a567f5 100644 --- a/src/Service.GraphQLBuilder/SelectionExtensions.cs +++ b/src/Service.GraphQLBuilder/SelectionExtensions.cs @@ -19,17 +19,19 @@ public static class SelectionExtensions /// is available. /// /// - /// Hot Chocolate v16 introduced Selection.SyntaxNodes (a list) to support field-merging + /// 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 list were ever empty. In practice an executable selection always has at least - /// one syntax node, so an empty list is an invariant violation rather than a legitimate + /// 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) { - FieldNode? fieldNode = selection.SyntaxNodes.FirstOrDefault()?.Node; - if (fieldNode is null) + // 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.", @@ -37,7 +39,7 @@ public static FieldNode RequireFieldNode(this Selection selection) subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError); } - return fieldNode; + return syntaxNodes[0].Node; } } } From b8b5aa0f46f6e44c7a7596e7a44b6635306ed012 Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Wed, 29 Apr 2026 01:38:19 -0700 Subject: [PATCH 10/18] fix tests --- src/Core/Parsers/IntrospectionInterceptor.cs | 23 ++++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) 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(); } From 71855e79f651a3d6bb29ae30f93903c35a0c92eb Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Wed, 29 Apr 2026 02:51:56 -0700 Subject: [PATCH 11/18] fix more tests --- src/Core/Services/ExecutionHelper.cs | 9 +++++++-- .../GraphQLTypes/DefaultValueType.cs | 2 +- .../GraphQLTypes/SupportedTypes.cs | 8 +++++++- .../GraphQLMutationTests/GraphQLMutationTestBase.cs | 6 ++++-- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/Core/Services/ExecutionHelper.cs b/src/Core/Services/ExecutionHelper.cs index e90aa65b3c..5c16109857 100644 --- a/src/Core/Services/ExecutionHelper.cs +++ b/src/Core/Services/ExecutionHelper.cs @@ -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,7 +211,12 @@ 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, - Base64StringType => 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). + Base64StringType or ByteArrayType => fieldValue.GetBytesFromBase64(), BooleanType => fieldValue.GetBoolean(), // spec UrlType => new Uri(fieldValue.GetString()!), UuidType => fieldValue.GetGuid(), diff --git a/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs b/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs index c047732b8c..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(); 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.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()); } /// From 40c2a8e9c3f2b6bb2a0b9d83323927dd92ed529e Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Wed, 29 Apr 2026 03:07:11 -0700 Subject: [PATCH 12/18] lay GQL schema build in test --- src/Service/Program.cs | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/Service/Program.cs b/src/Service/Program.cs index d601f4f6b4..2d6607fef1 100644 --- a/src/Service/Program.cs +++ b/src/Service/Program.cs @@ -323,13 +323,50 @@ public static IWebHostBuilder CreateWebHostBuilder(string[] args) => AddConfigurationProviders(builder, args); DisableHttpsRedirectionIfNeeded(args); }) - .UseStartup(); + .UseStartup() + .ConfigureServices(RemoveHotChocolateEagerWarmup); // This is used for testing purposes only. The test web server takes in a // IWebHostBuilder, instead of a IHostBuilder. public static IWebHostBuilder CreateWebHostFromInMemoryUpdatableConfBuilder(string[] args) => WebHost.CreateDefaultBuilder(args) - .UseStartup(); + .UseStartup() + .ConfigureServices(RemoveHotChocolateEagerWarmup); + + /// + /// Removes Hot Chocolate v16's eager RequestExecutorWarmupService from the + /// service collection so the GraphQL schema is built lazily on the first request. + /// + /// + /// HC v16 added a hosted service (HotChocolate.AspNetCore.Warmup.RequestExecutorWarmupService) + /// that constructs the GraphQL schema during WebHost.StartAsync. The two + /// IWebHostBuilder entry points above are only used by callers that bring up a + /// TestServer before the runtime config is fully loaded (post-startup config + /// endpoint, hot-reload rewrites, configs with no entities, GraphQL-disabled configs). + /// In those cases the eager build sees an empty Query type and throws a + /// HotChocolate.SchemaException before the host can serve a request. Removing + /// the warmup hosted service restores HC v15's lazy-on-first-request semantics for + /// these entry points only. The production startup path ( + /// invoked from ) is unaffected and continues to fail fast on schema + /// errors at dab start. + /// + private static void RemoveHotChocolateEagerWarmup(IServiceCollection services) + { + // The implementation type is internal to HotChocolate.AspNetCore, so we match by + // full name rather than a symbolic type reference. + const string warmupServiceFullName = + "HotChocolate.AspNetCore.Warmup.RequestExecutorWarmupService"; + + for (int i = services.Count - 1; i >= 0; i--) + { + Type? implementationType = services[i].ImplementationType; + if (implementationType is not null + && implementationType.FullName == warmupServiceFullName) + { + services.RemoveAt(i); + } + } + } /// /// Adds the various configuration providers. From bb20141a7abf20fab7d378629c08623cc9fc34ef Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Wed, 29 Apr 2026 09:49:13 -0700 Subject: [PATCH 13/18] fix more test failures --- src/Core/Services/ExecutionHelper.cs | 5 +++ .../Queries/QueryBuilder.cs | 37 ++++++++++++++++- .../Queries/StandardQueryInputs.cs | 14 +++++-- src/Service/Program.cs | 41 +------------------ src/Service/Startup.cs | 20 +++++++++ 5 files changed, 74 insertions(+), 43 deletions(-) diff --git a/src/Core/Services/ExecutionHelper.cs b/src/Core/Services/ExecutionHelper.cs index 5c16109857..c91ac6c9aa 100644 --- a/src/Core/Services/ExecutionHelper.cs +++ b/src/Core/Services/ExecutionHelper.cs @@ -216,7 +216,12 @@ fieldValue.ValueKind is not (JsonValueKind.Undefined or JsonValueKind.Null)) // 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(), diff --git a/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs b/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs index a2cc63b2c2..ce47481b65 100644 --- a/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs +++ b/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs @@ -107,12 +107,47 @@ public static DocumentNode Build( List definitionNodes = new() { - new ObjectTypeDefinitionNode(location: null, new NameNode("Query"), description: null, new List(), new List(), queryFields), + // 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`, no entities + // configured yet, and similar OpenAPI-only / REST-only setups. + // Emit a hidden placeholder field so the schema is structurally valid; it is + // never returned to clients because GraphQL requests are rejected upstream when + // GraphQL is disabled, and it is shadowed by real fields whenever any entity + // contributes a query. + new ObjectTypeDefinitionNode( + location: null, + new NameNode("Query"), + description: null, + new List(), + new List(), + queryFields.Count > 0 ? queryFields : new List { BuildEmptySchemaPlaceholderField() }), }; definitionNodes.AddRange(returnTypes); return new(definitionNodes); } + /// + /// Builds a hidden placeholder field used to keep the GraphQL Query type valid when no + /// entity contributes any query field (e.g. GraphQL globally disabled, all entities have + /// graphql.enabled = false, or no entities configured). Without it, Hot Chocolate v16's + /// eager schema validation throws on an empty Query. + /// + private static FieldDefinitionNode BuildEmptySchemaPlaceholderField() + { + return new FieldDefinitionNode( + location: null, + new NameNode("_dab"), + 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()); + } + public static FieldDefinitionNode GenerateByPKQuery( ObjectTypeDefinitionNode objectTypeDefinitionNode, NameNode name, 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/Program.cs b/src/Service/Program.cs index 2d6607fef1..d601f4f6b4 100644 --- a/src/Service/Program.cs +++ b/src/Service/Program.cs @@ -323,50 +323,13 @@ public static IWebHostBuilder CreateWebHostBuilder(string[] args) => AddConfigurationProviders(builder, args); DisableHttpsRedirectionIfNeeded(args); }) - .UseStartup() - .ConfigureServices(RemoveHotChocolateEagerWarmup); + .UseStartup(); // This is used for testing purposes only. The test web server takes in a // IWebHostBuilder, instead of a IHostBuilder. public static IWebHostBuilder CreateWebHostFromInMemoryUpdatableConfBuilder(string[] args) => WebHost.CreateDefaultBuilder(args) - .UseStartup() - .ConfigureServices(RemoveHotChocolateEagerWarmup); - - /// - /// Removes Hot Chocolate v16's eager RequestExecutorWarmupService from the - /// service collection so the GraphQL schema is built lazily on the first request. - /// - /// - /// HC v16 added a hosted service (HotChocolate.AspNetCore.Warmup.RequestExecutorWarmupService) - /// that constructs the GraphQL schema during WebHost.StartAsync. The two - /// IWebHostBuilder entry points above are only used by callers that bring up a - /// TestServer before the runtime config is fully loaded (post-startup config - /// endpoint, hot-reload rewrites, configs with no entities, GraphQL-disabled configs). - /// In those cases the eager build sees an empty Query type and throws a - /// HotChocolate.SchemaException before the host can serve a request. Removing - /// the warmup hosted service restores HC v15's lazy-on-first-request semantics for - /// these entry points only. The production startup path ( - /// invoked from ) is unaffected and continues to fail fast on schema - /// errors at dab start. - /// - private static void RemoveHotChocolateEagerWarmup(IServiceCollection services) - { - // The implementation type is internal to HotChocolate.AspNetCore, so we match by - // full name rather than a symbolic type reference. - const string warmupServiceFullName = - "HotChocolate.AspNetCore.Warmup.RequestExecutorWarmupService"; - - for (int i = services.Count - 1; i >= 0; i--) - { - Type? implementationType = services[i].ImplementationType; - if (implementationType is not null - && implementationType.FullName == warmupServiceFullName) - { - services.RemoveAt(i); - } - } - } + .UseStartup(); /// /// Adds the various configuration providers. diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 5e39d2ac14..45120d27fe 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -620,6 +620,26 @@ private void AddGraphQLService(IServiceCollection services, GraphQLRuntimeOption .AddHttpRequestInterceptor() .ConfigureSchema((serviceProvider, schemaBuilder) => { + // Hot Chocolate v16 builds the GraphQL schema eagerly during host startup + // (RequestExecutorWarmupService). DAB supports starting without a runtime + // config and accepting one later via the /configuration endpoint or + // hot-reload, in which case the dependencies of GraphQLSchemaCreator are + // not yet constructible (RuntimeConfigProvider.GetConfig() throws + // "Runtime config isn't setup."). Detect this case using TryGetConfig and + // emit a minimal placeholder schema so HC's eager validation succeeds; the + // GRAPHQL_SCHEMA_EVICTION_ON_CONFIG_CHANGED hot-reload event will rebuild + // the executor against the real schema once the config is loaded. + RuntimeConfigProvider configProvider = serviceProvider.GetRootServiceProvider() + .GetRequiredService(); + if (!configProvider.TryGetConfig(out _)) + { + // Tolerate "no config yet" by registering a syntactically valid schema. + // The single field is internal and unreachable in normal operation. + schemaBuilder.AddDocumentFromString( + "type Query { _dab: String }"); + return; + } + // 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. From ff088bd08df26a371ddef583d02cf52aef474de0 Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Wed, 29 Apr 2026 15:01:35 -0700 Subject: [PATCH 14/18] fix more tests --- src/Core/Services/GraphQLSchemaCreator.cs | 69 +++++++++++++++++++ src/Directory.Packages.props | 3 - .../Queries/QueryBuilder.cs | 37 +--------- src/Service/Startup.cs | 8 ++- 4 files changed, 75 insertions(+), 42 deletions(-) diff --git a/src/Core/Services/GraphQLSchemaCreator.cs b/src/Core/Services/GraphQLSchemaCreator.cs index d0ac2ac1b1..5468cf5ae1 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() @@ -126,6 +135,66 @@ private ISchemaBuilder Parse( .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 EmptySchemaPlaceholderFieldName = "_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(EmptySchemaPlaceholderFieldName), + 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", EmptySchemaPlaceholderFieldName, _ => 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 36361af5c9..8195e57d78 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -16,12 +16,9 @@ - - - diff --git a/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs b/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs index ce47481b65..a2cc63b2c2 100644 --- a/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs +++ b/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs @@ -107,47 +107,12 @@ public static DocumentNode Build( List definitionNodes = new() { - // 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`, no entities - // configured yet, and similar OpenAPI-only / REST-only setups. - // Emit a hidden placeholder field so the schema is structurally valid; it is - // never returned to clients because GraphQL requests are rejected upstream when - // GraphQL is disabled, and it is shadowed by real fields whenever any entity - // contributes a query. - new ObjectTypeDefinitionNode( - location: null, - new NameNode("Query"), - description: null, - new List(), - new List(), - queryFields.Count > 0 ? queryFields : new List { BuildEmptySchemaPlaceholderField() }), + new ObjectTypeDefinitionNode(location: null, new NameNode("Query"), description: null, new List(), new List(), queryFields), }; definitionNodes.AddRange(returnTypes); return new(definitionNodes); } - /// - /// Builds a hidden placeholder field used to keep the GraphQL Query type valid when no - /// entity contributes any query field (e.g. GraphQL globally disabled, all entities have - /// graphql.enabled = false, or no entities configured). Without it, Hot Chocolate v16's - /// eager schema validation throws on an empty Query. - /// - private static FieldDefinitionNode BuildEmptySchemaPlaceholderField() - { - return new FieldDefinitionNode( - location: null, - new NameNode("_dab"), - 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()); - } - public static FieldDefinitionNode GenerateByPKQuery( ObjectTypeDefinitionNode objectTypeDefinitionNode, NameNode name, diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 45120d27fe..88b1a0e0d0 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -634,9 +634,11 @@ private void AddGraphQLService(IServiceCollection services, GraphQLRuntimeOption if (!configProvider.TryGetConfig(out _)) { // Tolerate "no config yet" by registering a syntactically valid schema. - // The single field is internal and unreachable in normal operation. - schemaBuilder.AddDocumentFromString( - "type Query { _dab: String }"); + // HC v16 also requires every field to have a resolver, so bind a no-op. + // The field is unreachable in normal operation because GraphQL requests + // are rejected upstream when no config is loaded. + schemaBuilder.AddDocumentFromString("type Query { _dab: String }"); + schemaBuilder.AddResolver("Query", "_dab", _ => null); return; } From eb37d9013540a967c77d309ece24d082418500ee Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Wed, 29 Apr 2026 15:11:52 -0700 Subject: [PATCH 15/18] more test fixes --- .../GraphQLSupportedTypesTestsBase.cs | 34 +++++++--- src/Service/Startup.cs | 67 +++++++++++++------ 2 files changed, 74 insertions(+), 27 deletions(-) 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/Startup.cs b/src/Service/Startup.cs index 88b1a0e0d0..8b407e4a4a 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -621,32 +621,46 @@ private void AddGraphQLService(IServiceCollection services, GraphQLRuntimeOption .ConfigureSchema((serviceProvider, schemaBuilder) => { // Hot Chocolate v16 builds the GraphQL schema eagerly during host startup - // (RequestExecutorWarmupService). DAB supports starting without a runtime - // config and accepting one later via the /configuration endpoint or - // hot-reload, in which case the dependencies of GraphQLSchemaCreator are - // not yet constructible (RuntimeConfigProvider.GetConfig() throws - // "Runtime config isn't setup."). Detect this case using TryGetConfig and - // emit a minimal placeholder schema so HC's eager validation succeeds; the - // GRAPHQL_SCHEMA_EVICTION_ON_CONFIG_CHANGED hot-reload event will rebuild - // the executor against the real schema once the config is loaded. + // (RequestExecutorWarmupService). DAB supports several scenarios where the + // schema cannot be built at startup: + // 1. Running without a runtime config (config supplied later via the + // /configuration endpoint or hot-reload). + // 2. Config validation failure (e.g. Application Insights enabled with no + // connection string) where Startup.PerformOnConfigChangeAsync logs the + // error and continues; metadata-provider initialization never runs, so + // GraphQLSchemaCreator's transitive dependencies (AuthorizationResolver + // etc.) cannot construct. + // In both cases, emit a minimal placeholder schema with a no-op resolver so + // HC's eager validation succeeds without bringing the host down. The + // GRAPHQL_SCHEMA_EVICTION_ON_CONFIG_CHANGED hot-reload event rebuilds the + // executor against the real schema once a usable config is loaded. RuntimeConfigProvider configProvider = serviceProvider.GetRootServiceProvider() .GetRequiredService(); if (!configProvider.TryGetConfig(out _)) { - // Tolerate "no config yet" by registering a syntactically valid schema. - // HC v16 also requires every field to have a resolver, so bind a no-op. - // The field is unreachable in normal operation because GraphQL requests - // are rejected upstream when no config is loaded. - schemaBuilder.AddDocumentFromString("type Query { _dab: String }"); - schemaBuilder.AddResolver("Query", "_dab", _ => null); + EmitPlaceholderSchema(schemaBuilder); return; } - // 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); + 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() @@ -892,6 +906,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. From d1f8950dcd2bec756313b18a45b1e747f5ed0c08 Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Wed, 29 Apr 2026 17:49:12 -0700 Subject: [PATCH 16/18] fixing tests --- src/Core/Services/GraphQLSchemaCreator.cs | 6 +++--- src/Service/Startup.cs | 26 ++++++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/Core/Services/GraphQLSchemaCreator.cs b/src/Core/Services/GraphQLSchemaCreator.cs index 5468cf5ae1..b635e5128d 100644 --- a/src/Core/Services/GraphQLSchemaCreator.cs +++ b/src/Core/Services/GraphQLSchemaCreator.cs @@ -139,7 +139,7 @@ private ISchemaBuilder Parse( /// 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 EmptySchemaPlaceholderFieldName = "_dab"; + private const string EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME = "_dab"; /// /// If the generated Query object type has no fields, append a hidden placeholder @@ -161,7 +161,7 @@ private static DocumentNode EnsureQueryHasAtLeastOneField(DocumentNode queryNode { FieldDefinitionNode placeholderField = new( location: null, - new NameNode(EmptySchemaPlaceholderFieldName), + 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."), @@ -189,7 +189,7 @@ private static DocumentNode EnsureQueryHasAtLeastOneField(DocumentNode queryNode // 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", EmptySchemaPlaceholderFieldName, _ => null); + sb.AddResolver("Query", EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME, _ => null); } return new DocumentNode(rewritten.ToImmutable()); diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 8b407e4a4a..240783863d 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -636,7 +636,18 @@ private void AddGraphQLService(IServiceCollection services, GraphQLRuntimeOption // executor against the real schema once a usable config is loaded. RuntimeConfigProvider configProvider = serviceProvider.GetRootServiceProvider() .GetRequiredService(); - if (!configProvider.TryGetConfig(out _)) + if (!configProvider.TryGetConfig(out RuntimeConfig? loadedConfig)) + { + EmitPlaceholderSchema(schemaBuilder); + return; + } + + // When GraphQL is globally disabled, requests are short-circuited to 404 by + // PathRewriteMiddleware before reaching Hot Chocolate. Skip building the real + // schema so HC's eager 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). + if (!loadedConfig.IsGraphQLEnabled) { EmitPlaceholderSchema(schemaBuilder); return; @@ -774,6 +785,19 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC runtimeConfigProvider.RuntimeConfigLoadedHandlers.Add(async (_, _) => { isRuntimeReady = await PerformOnConfigChangeAsync(app); + + // Hot Chocolate v16's eager schema warmup ran during host startup (before any + // runtime config existed) and cached the EmitPlaceholderSchema executor. Now + // that a real config has been hydrated, evict that cached executor so the + // next GraphQL request rebuilds the schema with the real entity types. Skipped + // when initialization failed because the schema could not be built anyway. + if (isRuntimeReady) + { + IRequestExecutorManager requestExecutorManager = + app.ApplicationServices.GetRequiredService(); + EvictGraphQLSchema(requestExecutorManager); + } + return isRuntimeReady; }); } From f21c7a8f1df9847481b6c4e066fd7e94c6bc90b2 Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Wed, 29 Apr 2026 23:39:33 -0700 Subject: [PATCH 17/18] fixing more tests --- src/Service/Startup.cs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 240783863d..64add21660 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -788,14 +788,27 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC // Hot Chocolate v16's eager schema warmup ran during host startup (before any // runtime config existed) and cached the EmitPlaceholderSchema executor. Now - // that a real config has been hydrated, evict that cached executor so the - // next GraphQL request rebuilds the schema with the real entity types. Skipped - // when initialization failed because the schema could not be built anyway. + // that a real config has been hydrated, evict that cached executor and force + // an immediate rebuild via GetExecutorAsync so the next GraphQL request hits + // the real schema. Without the synchronous rebuild, the test (or a fast + // client) can race the lazy rebuild and observe the stale placeholder, which + // returns BadRequest because it doesn't expose the entity query fields. + // Skipped when initialization failed because the schema could not be built. if (isRuntimeReady) { IRequestExecutorManager requestExecutorManager = app.ApplicationServices.GetRequiredService(); EvictGraphQLSchema(requestExecutorManager); + try + { + await requestExecutorManager.GetExecutorAsync(); + } + catch (Exception ex) + { + _logger.LogWarning( + exception: ex, + message: "Failed to eagerly rebuild GraphQL schema after late-config hydration. The schema will be rebuilt lazily on the next GraphQL request."); + } } return isRuntimeReady; From 45ef6133401675794a6f070ebf8e3acba470e505 Mon Sep 17 00:00:00 2001 From: aaronburtle Date: Thu, 30 Apr 2026 00:20:28 -0700 Subject: [PATCH 18/18] fixing more tests --- .../MySqlGQLSupportedTypesTests.cs | 30 +++++---- src/Service/Startup.cs | 67 ++++++------------- 2 files changed, 39 insertions(+), 58 deletions(-) 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/Startup.cs b/src/Service/Startup.cs index 64add21660..6c32fe00ab 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -615,25 +615,33 @@ 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(new DateTimeOptions { ValidateInputFormat = !(graphQLRuntimeOptions?.EnableLegacyDateTimeScalar ?? true) })) .AddHttpRequestInterceptor() .ConfigureSchema((serviceProvider, schemaBuilder) => { - // Hot Chocolate v16 builds the GraphQL schema eagerly during host startup - // (RequestExecutorWarmupService). DAB supports several scenarios where the - // schema cannot be built at startup: - // 1. Running without a runtime config (config supplied later via the - // /configuration endpoint or hot-reload). - // 2. Config validation failure (e.g. Application Insights enabled with no - // connection string) where Startup.PerformOnConfigChangeAsync logs the - // error and continues; metadata-provider initialization never runs, so - // GraphQLSchemaCreator's transitive dependencies (AuthorizationResolver - // etc.) cannot construct. - // In both cases, emit a minimal placeholder schema with a no-op resolver so - // HC's eager validation succeeds without bringing the host down. The - // GRAPHQL_SCHEMA_EVICTION_ON_CONFIG_CHANGED hot-reload event rebuilds the - // executor against the real schema once a usable config is loaded. + // 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)) @@ -642,11 +650,6 @@ private void AddGraphQLService(IServiceCollection services, GraphQLRuntimeOption return; } - // When GraphQL is globally disabled, requests are short-circuited to 404 by - // PathRewriteMiddleware before reaching Hot Chocolate. Skip building the real - // schema so HC's eager 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). if (!loadedConfig.IsGraphQLEnabled) { EmitPlaceholderSchema(schemaBuilder); @@ -785,32 +788,6 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC runtimeConfigProvider.RuntimeConfigLoadedHandlers.Add(async (_, _) => { isRuntimeReady = await PerformOnConfigChangeAsync(app); - - // Hot Chocolate v16's eager schema warmup ran during host startup (before any - // runtime config existed) and cached the EmitPlaceholderSchema executor. Now - // that a real config has been hydrated, evict that cached executor and force - // an immediate rebuild via GetExecutorAsync so the next GraphQL request hits - // the real schema. Without the synchronous rebuild, the test (or a fast - // client) can race the lazy rebuild and observe the stale placeholder, which - // returns BadRequest because it doesn't expose the entity query fields. - // Skipped when initialization failed because the schema could not be built. - if (isRuntimeReady) - { - IRequestExecutorManager requestExecutorManager = - app.ApplicationServices.GetRequiredService(); - EvictGraphQLSchema(requestExecutorManager); - try - { - await requestExecutorManager.GetExecutorAsync(); - } - catch (Exception ex) - { - _logger.LogWarning( - exception: ex, - message: "Failed to eagerly rebuild GraphQL schema after late-config hydration. The schema will be rebuilt lazily on the next GraphQL request."); - } - } - return isRuntimeReady; }); }