From ff54ea26d51d28f6c764063be5e51746af656cd8 Mon Sep 17 00:00:00 2001 From: aaronburtle <93220300+aaronburtle@users.noreply.github.com> Date: Fri, 15 May 2026 19:55:09 -0700 Subject: [PATCH] Upgrade Hot Chocolate to version 16.0.0 (#3480) ## Why make this change? Closes #3448 Hot Chocolate has released stable v16.0.0 and we upgrade DAB to that version. ## What is this change? ### Update deps Bump 6 HC packages to 16.0.0 in Directory.Packages.props: HotChocolate, HotChocolate.AspNetCore, HotChocolate.AspNetCore.Authorization, HotChocolate.Types.NodaTime, HotChocolate.Utilities.Introspection, HotChocolate.Diagnostics. ### Breaking changes (GraphQL schema) HC v16 forces / motivates two schema-type renames. Wire formats and runtime values are unchanged for both. REST and CLI surface area is unchanged. Scalar: Byte -> UnsignedByte Filter input: ByteFilterInput -> UnsignedByteFilterInput HC v16 split the legacy `ByteType` into a signed `ByteType` (sbyte, -128..127) and a new `UnsignedByteType` (byte, 0..255). SQL Server `tinyint` is unsigned, so DAB must bind to `UnsignedByteType`. Scalar: ByteArray -> Base64String Filter input: ByteArrayFilterInput -> Base64StringFilterInput HC v16 marked `ByteArrayType` as [Obsolete] in favor of `Base64StringType`. Both serialize byte[] as a base64-encoded JSON string (identical wire format), but the GraphQL scalar name is now `Base64String`. DAB targets the new name so the generated schema does not depend on a deprecated scalar. Inline literal filters continue to work for both renames. Clients that declare typed variables of `ByteFilterInput` or `ByteArrayFilterInput`, or that regenerate code from the schema (graphql-codegen, Strawberry Shake, etc.), must update the type names. ### Refactor scalar APIs v16 renamed scalar overrides (`ParseLiteral`/`ParseValue` -> `OnCoerceInputLiteral`/`OnCoerceInputValue`/`OnCoerceOutputValue`/ `OnValueToLiteral`) and call-site helpers (`ParseValue`/`ParseResult` -> `ValueToLiteral`). `DateTimeType.ValueToLiteral` only accepts `DateTimeOffset`, so we convert at the boundary. HC v16 also elides trailing zero fractional seconds on DateTime output (`...:54.000Z` -> `...:54Z`), which surfaced in several test assertion updates. ### Refactor resolver/execution APIs `ISelection` was removed (use concrete `Selection`); `Selection.SyntaxNode` became a `ReadOnlySpan` (`SyntaxNodes`) to support field-merging; `TimeSpanType` was replaced by `DurationType` (ISO-8601, parsed via `XmlConvert.ToTimeSpan`); `OperationResult.WithContextData` is gone (set `singleResult.ContextData` directly). ### Refactor request interceptor and status middleware `IntrospectionInterceptor` now has a no-arg constructor (schema services are isolated from request services in v16) and implements the new four-arg `OnCreateAsync(HttpContext, IRequestExecutor, OperationRequestBuilder, CancellationToken)` signature; `RuntimeConfigProvider` is resolved from `context.RequestServices` at request time. `DetermineStatusCodeMiddleware` was updated for the unified `OperationResult` type. Status codes are now propagated via context-data (`ExecutionContextData.HttpStatusCode`) using an `ImmutableDictionary` builder rather than mutating the response directly. ### Adopt lazy schema initialization (LazyInitialization = true) HC v16 builds the schema eagerly during host startup by default. DAB supports a "hosted" scenario where the runtime config is supplied after the host starts (POST `/configuration`), so eager construction races with our placeholder fallback. We opt back into the v15 default (`options.LazyInitialization = true`) so the schema is built on the first GraphQL request, by which time the runtime config and metadata provider are ready. ### Drop ModifyOptions(o => o.EnableOneOf = true) OneOf is on by default in v16. ### Refactor startup `DateTimeType(disableFormatCheck: bool)` is obsolete (replaced by `DateTimeOptions { ValidateInputFormat = ... }`, polarity flipped); `WithOptions` only accepts `Action` (per-request); `MapNitroApp().WithOptions(GraphQLToolOptions)` was removed. Nitro is now served by the same `/graphql` endpoint and is governed by `options.Tool.Enable` on the per-request server options. ### Harden Selection.SyntaxNodes access HC v16's `Selection.SyntaxNodes` is a `ReadOnlySpan`, so direct `SyntaxNodes[0].Node` indexing would surface as `IndexOutOfRangeException` at request time if the span were ever empty. Added `Selection.RequireFieldNode()` in `SelectionExtensions.cs` (`IsEmpty` check, return `SyntaxNodes[0].Node`, throw targeted `DataApiBuilderException` otherwise). Adopted at all call sites in `SqlQueryStructure`, `CosmosQueryStructure`, and `ExecutionHelper`. ### Test updates Adopt v16 `OperationResultData` / `ResultDocument` / `ResultElement` / `ResultProperty` traversal in `MultiSourceQueryExecutionUnitTests`. Update `TestNoConfigReturnsServiceUnavailable` to recognize that lazy `WithOptions` resolution surfaces "no runtime config" as `DataApiBuilderException(ServiceUnavailable)` rather than a 503 response or `ApplicationException`. Update DateTime literal assertions and filter input name references in affected test files. Decouple the GraphQL-scalar name from the test database column name in `GraphQLSupportedTypesTestsBase` and the My/Pg test classes so the `Byte`/`UnsignedByte` and `ByteArray`/`Base64String` renames do not require renaming `byte_types`/`bytearray_types` columns. ## How was this tested? Run against current test suite. --------- Co-authored-by: Souvik Ghosh --- docs/design/HC16-upgrade.md | 232 ++++++++++++++++++ src/Core/Parsers/IntrospectionInterceptor.cs | 23 +- src/Core/Resolvers/CosmosQueryStructure.cs | 7 +- .../Sql Query Structures/SqlQueryStructure.cs | 4 +- .../Services/DetermineStatusCodeMiddleware.cs | 2 +- src/Core/Services/ExecutionHelper.cs | 12 +- src/Core/Services/GraphQLSchemaCreator.cs | 84 ++++++- src/Directory.Packages.props | 15 +- .../CustomScalars/SingleType.cs | 16 +- .../GraphQLStoredProcedureBuilder.cs | 24 +- .../GraphQLTypes/DefaultValueType.cs | 4 +- .../GraphQLTypes/SupportedTypes.cs | 14 +- .../Queries/StandardQueryInputs.cs | 24 +- .../SelectionExtensions.cs | 45 ++++ .../Sql/SchemaConverter.cs | 13 +- .../Configuration/ConfigurationTests.cs | 26 +- .../GraphQLBuilder/MultiSourceBuilderTests.cs | 126 ++++++++++ .../GraphQLMutationTestBase.cs | 6 +- .../GraphQLSupportedTypesTestsBase.cs | 39 ++- .../MySqlGQLSupportedTypesTests.cs | 32 +-- .../PostgreSqlGQLSupportedTypesTests.cs | 2 +- .../MultiSourceQueryExecutionUnitTests.cs | 17 +- src/Service/Startup.cs | 81 ++++-- 23 files changed, 725 insertions(+), 123 deletions(-) create mode 100644 docs/design/HC16-upgrade.md create mode 100644 src/Service.GraphQLBuilder/SelectionExtensions.cs diff --git a/docs/design/HC16-upgrade.md b/docs/design/HC16-upgrade.md new file mode 100644 index 0000000000..a77d8149bb --- /dev/null +++ b/docs/design/HC16-upgrade.md @@ -0,0 +1,232 @@ +# Hot Chocolate v16 upgrade + +This is a high-level companion to PR #3480. The goal here is to give a reader +the *why* behind each non-obvious code change without forcing them to diff Hot +Chocolate v13/v14 against v16 themselves. For the line-level changes, read the +PR. + +--- + +## 1. Package bump + +Every `HotChocolate.*` package in `src/Directory.Packages.props` moves to +`16.0.0`. No DAB code is "improved" by the upgrade in isolation — the rest of +this doc explains the API changes that the bump *forced* us to make. + +--- + +## 2. Scalar API rename + +HC v16 renamed the scalar override methods. Anything that derived from +`ScalarType` and overrode parsing/serialization had to change names. + +| v13 / v14 | v16 | +| ----------------------------- | ---------------------------------------------------------------------------- | +| `ParseLiteral(IValueNode)` | `OnCoerceInputLiteral(IValueNode)` | +| `ParseValue(object)` | `OnCoerceInputValue(object)` | +| `Serialize(object)` | `OnCoerceOutputValue(object)` *(plus `OnValueToLiteral` for literal output)* | + +Caller-side helpers also moved: `ParseValue` / `ParseResult` became +`ValueToLiteral`. DAB only owns one custom scalar — `SingleType` in +`src/Service.GraphQLBuilder/CustomScalars/` — which now inherits from +`FloatTypeBase` and uses the new method names. + +--- + +## 3. The `Byte` scalar got split (user-visible) + +HC v16 split the legacy `ByteType` into two scalars: + +- `ByteType` — runtime `sbyte`, range -128..127 (now signed) +- `UnsignedByteType` — runtime `byte`, range 0..255 + +SQL Server `tinyint` is unsigned (0..255), so the only correct binding for +DAB is `UnsignedByteType`. That means the GraphQL **schema type names** change: + +- The scalar exposed in generated schemas: `Byte` → `UnsignedByte` +- The filter input type: `ByteFilterInput` → `UnsignedByteFilterInput` + +This is a **breaking change for clients** that hard-code those names in +GraphQL queries or generated client bindings. The runtime values returned to +clients are unchanged. + +--- + +## 4. `ByteArray` scalar replaced by `Base64String` (user-visible) + +HC v16 marked the legacy `ByteArrayType` as `[Obsolete]` in favor of the new +`Base64StringType`. The two scalars serialize `byte[]` identically — both +emit a base64-encoded JSON string — but the GraphQL scalar **name** changed: + +- The scalar exposed in generated schemas: `ByteArray` → `Base64String` +- The filter input type: `ByteArrayFilterInput` → `Base64StringFilterInput` + +DAB targets the new name so the generated schema does not depend on a +deprecated scalar. Because the wire format is unchanged, inline literal +filters and round-tripped byte[] values keep working. Clients that declare +typed variables of `ByteArrayFilterInput`, or that regenerate code from the +schema (graphql-codegen, Strawberry Shake, Apollo codegen, etc.), must +update the type names. + +The internal C# constant `BYTEARRAY_TYPE` (in `SupportedHotChocolateTypes`) +is kept under its original name to minimize churn at call sites; only its +value changed from `"ByteArray"` to `"Base64String"`. Two test classes that +were using `BYTEARRAY_TYPE.ToLowerInvariant()` as a column-name root were +updated to use the literal column-name string `"bytearray"`, since the +database column is not being renamed alongside the GraphQL scalar. + +--- + +## 5. `TimeSpan` no longer has a dedicated scalar + +`TimeSpanType` was removed in v16 in favor of `DurationType` (ISO-8601 on the +wire, parsed via `XmlConvert.ToTimeSpan`). DAB does not bind any column type +to `Duration` today — SQL `time` rides on `TimeOnly` → NodaTime's +`LocalTimeType` — so the `DurationType` arm in `ExecutionHelper.ExecuteLeafField` +is a defensive fallback only. It is symmetric with HC's own serialization, +because HC's `DurationType` produces ISO-8601 on output. + +--- + +## 6. `ISelection` and `Selection.SyntaxNode` changed + +Two related changes: + +1. `ISelection` was removed; downstream code uses the concrete `Selection`. +2. `Selection.SyntaxNode` (a single `FieldNode`) became + `Selection.SyntaxNodes` — a `ReadOnlySpan`. The span + shape was introduced so HC can represent **field merging** (multiple syntax + nodes that resolve to the same selection). + +For DAB's purposes, every executable selection always has at least one syntax +node — an empty span is an invariant violation. We added a single helper +to centralize that invariant and convert "empty span" into a targeted +`DataApiBuilderException` rather than an `IndexOutOfRangeException` at the +call site: + +```csharp +// src/Service.GraphQLBuilder/SelectionExtensions.cs +public static FieldNode RequireFieldNode(this Selection selection) +``` + +All previous call sites that did `Selection.SyntaxNode` now do +`selection.RequireFieldNode()`. + +--- + +## 7. `OperationResult.WithContextData` removed + +The old fluent `result.WithContextData(...)` is gone. To set context data on +the result you now set `singleResult.ContextData` directly. DAB uses an +`ImmutableDictionary` builder in `DetermineStatusCodeMiddleware` to set +`ExecutionContextData.HttpStatusCode`. + +--- + +## 8. `EnableOneOf` is on by default + +We previously had `ModifyOptions(o => o.EnableOneOf = true)`. v16 enables +`@oneOf` by default, so the explicit call is gone. Equivalent behavior, less +code. + +--- + +## 9. `DateTimeType` configuration + +`new DateTimeType(disableFormatCheck: true)` is obsolete. The replacement is +`new DateTimeType(new DateTimeOptions { ValidateInputFormat = ... })` and the +polarity is **flipped**: `ValidateInputFormat = true` means strict ISO-8601, +which corresponds to the old `disableFormatCheck = false`. + +DAB exposes this via `graphql.enable-legacy-datetime-scalar`. When the flag is +`true` (the historical default that preserves the v13 lenient parser), we pass +`ValidateInputFormat = false` so existing clients with looser DateTime +literals do not break. + +There is also a **wire-format change** in HC v16 worth noting because it +surfaced in many tests: HC v16's `DateTimeType` elides trailing zero +fractional seconds on output. `1999-01-08T10:23:54.000Z` is now emitted as +`1999-01-08T10:23:54Z`. The PR updated affected test assertions. + +--- + +## 10. `WithOptions` takes a delegate, and `MapNitroApp` is gone + +The endpoint-mapping API moved from an options object to a per-request delegate: + +```csharp +// v13 / v14 +endpoints.MapGraphQL().WithOptions(new GraphQLServerOptions { ... }); +endpoints.MapNitroApp().WithOptions(new GraphQLToolOptions { ... }); +``` + +```csharp +// v16 +endpoints.MapGraphQL().WithOptions(options => +{ + options.Tool.Enable = IsUIEnabled(runtimeConfig, env); +}); +``` + +`MapNitroApp()` was removed entirely. Nitro is now served by the **same** +`/graphql` endpoint and is gated by `options.Tool.Enable` on the per-request +`GraphQLServerOptions`. End-user behavior is preserved: the IDE renders in +development, is hidden in production. + +--- + +## 11. Lazy schema initialization (DAB-specific) + +Most important DAB-specific change. HC v16 builds the schema **eagerly** at +host startup by default. DAB has a "hosted" mode where the runtime config is +supplied **after** the host starts — POST `/configuration`. There is no +config at startup, so eager schema construction either: + +- builds an empty placeholder schema and serves it indefinitely (because HC + keeps the warm placeholder executor in the background while the real + schema's warmup runs after `/configuration` is hit), or +- fails outright if the placeholder fallback is removed. + +Setting `options.LazyInitialization = true` (the v15 default) defers schema +construction to the first GraphQL request. By that point the runtime config +is loaded for both file-based startup and the POST `/configuration` path, so +the schema is built from the right metadata. + +**Do not remove this flag without re-validating the hosted scenario.** + +--- + +## 12. `IntrospectionInterceptor` and service-scope isolation + +HC v16 isolates schema-level services from request-level services. The +practical implication for DAB is that any singleton that used to be reached +via constructor DI on a schema-scoped class (like +`IntrospectionInterceptor`) is no longer automatically available. + +`IntrospectionInterceptor` now has a no-arg constructor and resolves its +dependencies from `context.RequestServices` inside the new four-arg +`OnCreateAsync(HttpContext, IRequestExecutor, OperationRequestBuilder, CancellationToken)` +override. The same pattern applies inside `ConfigureSchema(...)` in +`Startup.cs`, where DAB calls +`serviceProvider.GetRootServiceProvider().GetRequiredService()` for +application-level services like `RuntimeConfigProvider` and +`GraphQLSchemaCreator`. + +--- + +## 13. Test-only changes + +Aside from the schema-name renames and DateTime literal updates, the +notable test changes are: + +- `MultiSourceQueryExecutionUnitTests` was updated for the v16 + `OperationResultData` / `ResultDocument` / `ResultElement` / + `ResultProperty` traversal API. +- `TestNoConfigReturnsServiceUnavailable` now recognizes that lazy + `WithOptions` resolution surfaces "no runtime config" as + `DataApiBuilderException(ServiceUnavailable)` rather than a 503 response + or `ApplicationException`. +- `GetTestFieldName` in `GraphQLSupportedTypesTestsBase` special-cases both + `BYTE_TYPE` (`UnsignedByte` → `byte_types`) and `BYTEARRAY_TYPE` + (`Base64String` → `bytearray_types`) because the GraphQL scalar name no + longer matches the test database column-name root. diff --git a/src/Core/Parsers/IntrospectionInterceptor.cs b/src/Core/Parsers/IntrospectionInterceptor.cs index 98c458e6a9..53b6711cf9 100644 --- a/src/Core/Parsers/IntrospectionInterceptor.cs +++ b/src/Core/Parsers/IntrospectionInterceptor.cs @@ -5,6 +5,7 @@ using HotChocolate.AspNetCore; using HotChocolate.Execution; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; namespace Azure.DataApiBuilder.Core.Parsers { @@ -14,17 +15,18 @@ namespace Azure.DataApiBuilder.Core.Parsers /// public class IntrospectionInterceptor : DefaultHttpRequestInterceptor { - private RuntimeConfigProvider _runtimeConfigProvider; - /// - /// Constructor injects RuntimeConfigProvider to allow - /// HotChocolate to attempt to retrieve the runtime config - /// when evaluating GraphQL requests. + /// Parameterless constructor. /// - /// - public IntrospectionInterceptor(RuntimeConfigProvider runtimeConfigProvider) + /// + /// Hot Chocolate v16 isolates schema services from the application's request services. + /// Resolving constructor-injected app singletons (e.g. ) + /// against the schema service provider therefore fails at executor session creation. + /// We instead resolve dependencies from in + /// , where the application's request scope is in effect. + /// + public IntrospectionInterceptor() { - _runtimeConfigProvider = runtimeConfigProvider; } /// @@ -51,7 +53,10 @@ public override ValueTask OnCreateAsync( OperationRequestBuilder requestBuilder, CancellationToken cancellationToken) { - if (_runtimeConfigProvider.GetConfig().AllowIntrospection) + RuntimeConfigProvider runtimeConfigProvider = + context.RequestServices.GetRequiredService(); + + if (runtimeConfigProvider.GetConfig().AllowIntrospection) { requestBuilder.AllowIntrospection(); } diff --git a/src/Core/Resolvers/CosmosQueryStructure.cs b/src/Core/Resolvers/CosmosQueryStructure.cs index 68d83557c0..29c435d955 100644 --- a/src/Core/Resolvers/CosmosQueryStructure.cs +++ b/src/Core/Resolvers/CosmosQueryStructure.cs @@ -117,14 +117,15 @@ private static IEnumerable GenerateQueryColumns(SelectionSetNode [MemberNotNull(nameof(OrderByColumns))] private void Init(IDictionary queryParams) { - ISelection selection = _context.Selection; + Selection selection = _context.Selection; ObjectType underlyingType = selection.Field.Type.NamedType(); IsPaginated = QueryBuilder.IsPaginationType(underlyingType); OrderByColumns = new(); + FieldNode selectionFieldNode = selection.RequireFieldNode(); if (IsPaginated) { - FieldNode? fieldNode = ExtractQueryField(selection.SyntaxNode); + FieldNode? fieldNode = ExtractQueryField(selectionFieldNode); if (fieldNode is not null) { @@ -139,7 +140,7 @@ private void Init(IDictionary queryParams) } else { - Columns.AddRange(GenerateQueryColumns(selection.SyntaxNode.SelectionSet!, _context.Operation.Document, SourceAlias)); + Columns.AddRange(GenerateQueryColumns(selectionFieldNode.SelectionSet!, _context.Operation.Document, SourceAlias)); string typeName = GraphQLUtils.TryExtractGraphQLFieldModelName(underlyingType.Directives, out string? modelName) ? modelName : underlyingType.Name; diff --git a/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs b/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs index 54175f708e..430b58c54f 100644 --- a/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs +++ b/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs @@ -126,7 +126,7 @@ public SqlQueryStructure( sqlMetadataProvider, authorizationResolver, ctx.Selection.Field, - ctx.Selection.SyntaxNode, + ctx.Selection.RequireFieldNode(), // The outermost query is where we start, so this can define // create the IncrementingInteger that will be shared between // all subqueries in this query. @@ -173,7 +173,7 @@ public SqlQueryStructure( IsMultipleCreateOperation = isMultipleCreateOperation; ObjectField schemaField = _ctx.Selection.Field; - FieldNode? queryField = _ctx.Selection.SyntaxNode; + FieldNode? queryField = _ctx.Selection.RequireFieldNode(); IOutputType outputType = schemaField.Type; _underlyingFieldType = outputType.NamedType(); diff --git a/src/Core/Services/DetermineStatusCodeMiddleware.cs b/src/Core/Services/DetermineStatusCodeMiddleware.cs index dcddc62971..a0a9a88490 100644 --- a/src/Core/Services/DetermineStatusCodeMiddleware.cs +++ b/src/Core/Services/DetermineStatusCodeMiddleware.cs @@ -35,7 +35,7 @@ public async ValueTask InvokeAsync(RequestContext context) } contextData[ExecutionContextData.HttpStatusCode] = HttpStatusCode.BadRequest; - context.Result = singleResult.WithContextData(contextData.ToImmutable()); + singleResult.ContextData = contextData.ToImmutable(); } } } diff --git a/src/Core/Services/ExecutionHelper.cs b/src/Core/Services/ExecutionHelper.cs index 79bdc6af9c..fc2e3e9da1 100644 --- a/src/Core/Services/ExecutionHelper.cs +++ b/src/Core/Services/ExecutionHelper.cs @@ -6,6 +6,7 @@ using System.Net; using System.Text; using System.Text.Json; +using System.Xml; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Core.Models; @@ -18,7 +19,6 @@ using Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes; using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries; using HotChocolate.Execution; -using HotChocolate.Execution.Processing; using HotChocolate.Language; using HotChocolate.Resolvers; using NodaTime.Text; @@ -201,7 +201,7 @@ fieldValue.ValueKind is not (JsonValueKind.Undefined or JsonValueKind.Null)) return namedType switch { StringType => fieldValue.GetString(), // spec - ByteType => fieldValue.GetByte(), + UnsignedByteType => fieldValue.GetByte(), ShortType => fieldValue.GetInt16(), IntType => fieldValue.GetInt32(), // spec LongType => fieldValue.GetInt64(), @@ -211,11 +211,13 @@ fieldValue.ValueKind is not (JsonValueKind.Undefined or JsonValueKind.Null)) DateTimeType => DateTimeOffset.TryParse(fieldValue.GetString()!, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal, out DateTimeOffset date) ? date : null, // for DW when datetime is null it will be in "" (double quotes) due to stringagg parsing and hence we need to ensure parsing is correct. DateType => DateTimeOffset.TryParse(fieldValue.GetString()!, out DateTimeOffset date) ? date : null, HotChocolate.Types.NodaTime.LocalTimeType => fieldValue.GetString()!.Equals("null", StringComparison.OrdinalIgnoreCase) ? null : LocalTimePattern.ExtendedIso.Parse(fieldValue.GetString()!).Value, - ByteArrayType => fieldValue.GetBytesFromBase64(), + // HC v16 deprecated ByteArrayType in favor of Base64StringType; DAB-generated + // schemas now bind byte[] fields to Base64StringType (see BYTEARRAY_TYPE). + Base64StringType => fieldValue.GetBytesFromBase64(), BooleanType => fieldValue.GetBoolean(), // spec UrlType => new Uri(fieldValue.GetString()!), UuidType => fieldValue.GetGuid(), - TimeSpanType => TimeSpan.Parse(fieldValue.GetString()!), + DurationType => XmlConvert.ToTimeSpan(fieldValue.GetString()!), AnyType => fieldValue.ToString(), _ => fieldValue.GetString() }; @@ -508,7 +510,7 @@ public static InputObjectType InputObjectTypeFromIInputField(IInputValueDefiniti { return GetParametersFromSchemaAndQueryFields( context.Selection.Field, - context.Selection.SyntaxNode, + context.Selection.RequireFieldNode(), context.Variables); } diff --git a/src/Core/Services/GraphQLSchemaCreator.cs b/src/Core/Services/GraphQLSchemaCreator.cs index 90e918c833..4e063f08a7 100644 --- a/src/Core/Services/GraphQLSchemaCreator.cs +++ b/src/Core/Services/GraphQLSchemaCreator.cs @@ -105,6 +105,15 @@ private ISchemaBuilder Parse( // Generate the Query and the Mutation Node. (DocumentNode queryNode, DocumentNode mutationNode) = GenerateQueryAndMutationNodes(root, inputTypes); + // Hot Chocolate v16 validates schemas eagerly during host startup + // (RequestExecutorWarmupService) and rejects an empty Query type with + // "The object type `Query` has to at least define one field in order to be valid." + // This can occur in valid runtime configurations: GraphQL globally disabled, + // every entity opting out of GraphQL via `graphql.enabled = false`, or no entities + // configured. Inject a hidden placeholder field with a no-op resolver so the schema + // is structurally valid; HC v16 also rejects fields without resolvers. + queryNode = EnsureQueryHasAtLeastOneField(queryNode, sb); + return sb .AddDocument(root) .AddAuthorizeDirectiveType() @@ -122,12 +131,83 @@ private ISchemaBuilder Parse( .AddDocument(queryNode) // Generate the GraphQL mutations from the provided objects .AddDocument(mutationNode) - // Enable the OneOf directive (https://github.com/graphql/graphql-spec/pull/825) to support the DefaultValue type - .ModifyOptions(o => o.EnableOneOf = true) // Adds our type interceptor that will create the resolvers. .TryAddTypeInterceptor(new ResolverTypeInterceptor(new ExecutionHelper(_queryEngineFactory, _mutationEngineFactory, _runtimeConfigProvider))); } + /// + /// Name of the hidden placeholder field added to Query when no entity contributes + /// a query field, used to keep the schema valid for HC v16's eager validation. + /// + internal const string EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME = "_dab"; + + /// + /// If the generated Query object type has no fields, append a hidden placeholder + /// field and register a null-returning resolver for it. The placeholder is shadowed in + /// any configuration that produces real query fields, so it is only visible in + /// otherwise-empty schemas (GraphQL globally disabled, all entities opting out, + /// no entities configured). + /// + /// + /// Marked internal rather than private so the test project (granted access + /// via InternalsVisibleTo in SqlMetadataProvider.cs) can exercise the rewrite + /// logic directly without spinning up the full schema builder pipeline. + /// + internal static DocumentNode EnsureQueryHasAtLeastOneField(DocumentNode queryNode, ISchemaBuilder sb) + { + // Locate the empty Query definition, if any. HotChocolate's DocumentNode exposes + // Definitions as an ordered IReadOnlyList with no by-name index, so this is the + // cheapest available lookup. Common case: Query has fields and we return unchanged + // without allocating a new definitions list. + int emptyQueryIndex = -1; + for (int i = 0; i < queryNode.Definitions.Count; i++) + { + if (queryNode.Definitions[i] is ObjectTypeDefinitionNode { Name.Value: "Query", Fields.Count: 0 }) + { + emptyQueryIndex = i; + break; + } + } + + if (emptyQueryIndex < 0) + { + return queryNode; + } + + ObjectTypeDefinitionNode emptyQuery = (ObjectTypeDefinitionNode)queryNode.Definitions[emptyQueryIndex]; + + FieldDefinitionNode placeholderField = new( + location: null, + new NameNode(EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME), + new StringValueNode( + "Internal placeholder; only present when no entity contributes a query field. " + + "Always returns null and is never reachable in normal operation."), + arguments: new List(), + type: new NamedTypeNode(new NameNode("String")), + directives: new List()); + + ObjectTypeDefinitionNode rewrittenQuery = new( + emptyQuery.Location, + emptyQuery.Name, + emptyQuery.Description, + emptyQuery.Directives, + emptyQuery.Interfaces, + new List { placeholderField }); + + // HC v16 requires every field to have a resolver; bind a no-op that always + // returns null. The field is unreachable in normal operation because callers + // for empty-Query configurations never issue GraphQL requests. + sb.AddResolver("Query", EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME, _ => null); + + IDefinitionNode[] newDefinitions = new IDefinitionNode[queryNode.Definitions.Count]; + for (int i = 0; i < queryNode.Definitions.Count; i++) + { + newDefinitions[i] = i == emptyQueryIndex ? rewrittenQuery : queryNode.Definitions[i]; + } + + return new DocumentNode(newDefinitions); + } + /// /// 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 c7ada5f8a7..8bb02bc13c 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -13,15 +13,12 @@ - - - - - - - - - + + + + + + diff --git a/src/Service.GraphQLBuilder/CustomScalars/SingleType.cs b/src/Service.GraphQLBuilder/CustomScalars/SingleType.cs index e7d4be690a..8d85ccb39f 100644 --- a/src/Service.GraphQLBuilder/CustomScalars/SingleType.cs +++ b/src/Service.GraphQLBuilder/CustomScalars/SingleType.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json; using HotChocolate.Language; +using HotChocolate.Text.Json; using HotChocolate.Types; namespace Azure.DataApiBuilder.Service.GraphQLBuilder.CustomScalars @@ -48,10 +50,16 @@ public SingleType( Description = description; } - protected override float ParseLiteral(IFloatValueLiteral valueSyntax) => - valueSyntax.ToSingle(); + protected override float OnCoerceInputLiteral(IFloatValueLiteral valueLiteral) + => valueLiteral.ToSingle(); - protected override FloatValueNode ParseValue(float runtimeValue) => - new(runtimeValue); + protected override float OnCoerceInputValue(JsonElement inputValue) + => inputValue.GetSingle(); + + protected override void OnCoerceOutputValue(float runtimeValue, ResultElement resultValue) + => resultValue.SetNumberValue(runtimeValue); + + protected override IValueNode OnValueToLiteral(float runtimeValue) + => new FloatValueNode(runtimeValue); } } diff --git a/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs b/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs index 8aca57421c..92d0298f10 100644 --- a/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs +++ b/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs @@ -156,14 +156,14 @@ private static Tuple ConvertValueToGraphQLType(string defaul { Tuple valueNode = paramValueType switch { - UUID_TYPE => new(UUID_TYPE, new UuidType().ParseValue(Guid.Parse(defaultValueFromConfig))), - BYTE_TYPE => new(BYTE_TYPE, new IntValueNode(byte.Parse(defaultValueFromConfig))), - SHORT_TYPE => new(SHORT_TYPE, new IntValueNode(short.Parse(defaultValueFromConfig))), - INT_TYPE => new(INT_TYPE, new IntValueNode(int.Parse(defaultValueFromConfig))), - LONG_TYPE => new(LONG_TYPE, new IntValueNode(long.Parse(defaultValueFromConfig))), - SINGLE_TYPE => new(SINGLE_TYPE, new SingleType().ParseValue(float.Parse(defaultValueFromConfig))), - FLOAT_TYPE => new(FLOAT_TYPE, new FloatValueNode(double.Parse(defaultValueFromConfig))), - DECIMAL_TYPE => new(DECIMAL_TYPE, new FloatValueNode(decimal.Parse(defaultValueFromConfig))), + UUID_TYPE => new(UUID_TYPE, new UuidType().ValueToLiteral(Guid.Parse(defaultValueFromConfig))), + BYTE_TYPE => new(BYTE_TYPE, new IntValueNode(byte.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))), + SHORT_TYPE => new(SHORT_TYPE, new IntValueNode(short.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))), + INT_TYPE => new(INT_TYPE, new IntValueNode(int.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))), + LONG_TYPE => new(LONG_TYPE, new IntValueNode(long.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))), + SINGLE_TYPE => new(SINGLE_TYPE, new SingleType().ValueToLiteral(float.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))), + FLOAT_TYPE => new(FLOAT_TYPE, new FloatValueNode(double.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))), + DECIMAL_TYPE => new(DECIMAL_TYPE, new FloatValueNode(decimal.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))), STRING_TYPE => new(STRING_TYPE, new StringValueNode(defaultValueFromConfig)), BOOLEAN_TYPE => new(BOOLEAN_TYPE, new BooleanValueNode( defaultValueFromConfig switch @@ -174,10 +174,10 @@ var s when s.Equals("true", StringComparison.OrdinalIgnoreCase) => true, var s when s.Equals("false", StringComparison.OrdinalIgnoreCase) => false, _ => throw new FormatException($"String '{defaultValueFromConfig}' was not recognized as a valid Boolean.") })), - DATETIME_TYPE => new(DATETIME_TYPE, new DateTimeType().ParseResult( - DateTime.Parse(defaultValueFromConfig, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal))), - BYTEARRAY_TYPE => new(BYTEARRAY_TYPE, new ByteArrayType().ParseValue(Convert.FromBase64String(defaultValueFromConfig))), - LOCALTIME_TYPE => new(LOCALTIME_TYPE, new HotChocolate.Types.NodaTime.LocalTimeType().ParseResult(LocalTimePattern.ExtendedIso.Parse(defaultValueFromConfig).Value)), + DATETIME_TYPE => new(DATETIME_TYPE, new DateTimeType().ValueToLiteral( + DateTimeOffset.Parse(defaultValueFromConfig, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal))), + BYTEARRAY_TYPE => new(BYTEARRAY_TYPE, new Base64StringType().ValueToLiteral(Convert.FromBase64String(defaultValueFromConfig))), + LOCALTIME_TYPE => new(LOCALTIME_TYPE, new HotChocolate.Types.NodaTime.LocalTimeType().ValueToLiteral(LocalTimePattern.ExtendedIso.Parse(defaultValueFromConfig).Value)), _ => throw new NotSupportedException(message: $"The {defaultValueFromConfig} parameter's value type [{paramValueType}] is not supported.") }; diff --git a/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs b/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs index a8b058fa6a..7f10d1c290 100644 --- a/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs +++ b/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs @@ -13,7 +13,7 @@ protected override void Configure(IInputObjectTypeDescriptor descriptor) { descriptor.Name("DefaultValue"); descriptor.OneOf(); - descriptor.Field(BYTE_TYPE).Type(); + descriptor.Field(BYTE_TYPE).Type(); descriptor.Field(SHORT_TYPE).Type(); descriptor.Field(INT_TYPE).Type(); descriptor.Field(LONG_TYPE).Type(); @@ -23,7 +23,7 @@ protected override void Configure(IInputObjectTypeDescriptor descriptor) descriptor.Field(FLOAT_TYPE).Type(); descriptor.Field(DECIMAL_TYPE).Type(); descriptor.Field(DATETIME_TYPE).Type(); - descriptor.Field(BYTEARRAY_TYPE).Type(); + descriptor.Field(BYTEARRAY_TYPE).Type(); descriptor.Field(LOCALTIME_TYPE).Type(); } } diff --git a/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs b/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs index 88d29a6e3f..c66c64a18d 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"; @@ -20,7 +26,11 @@ public static class SupportedHotChocolateTypes public const string DECIMAL_TYPE = "Decimal"; public const string STRING_TYPE = "String"; public const string BOOLEAN_TYPE = "Boolean"; - public const string BYTEARRAY_TYPE = "ByteArray"; + // HC v16 marked the legacy ByteArrayType ([Obsolete]) in favor of Base64StringType. + // Both serialize byte[] as a base64-encoded JSON string (identical wire format), but + // the GraphQL scalar name is now "Base64String" (was "ByteArray"). DAB targets the + // new name so the generated schema does not depend on a deprecated scalar. + public const string BYTEARRAY_TYPE = "Base64String"; public const string DATETIME_TYPE = "DateTime"; public const string DATETIMEOFFSET_TYPE = "DateTimeOffset"; public const string LOCALTIME_TYPE = "LocalTime"; diff --git a/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs b/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs index aa6423d55d..539a0b356e 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); @@ -81,11 +87,11 @@ private static InputObjectTypeDefinitionNode StringInputType() => private static InputObjectTypeDefinitionNode DateTimeInputType() => CreateComparableFilter("DateTimeFilterInput", "Input type for adding DateTime filters", _dateTime); - public static InputObjectTypeDefinitionNode ByteArrayInputType() => + public static InputObjectTypeDefinitionNode Base64StringInputType() => new( location: null, - new NameNode("ByteArrayFilterInput"), - new StringValueNode("Input type for adding ByteArray filters"), + new NameNode("Base64StringFilterInput"), + new StringValueNode("Input type for adding Base64String filters"), [], [ new(null, _isNull, _isNullDescription, _boolean, null, []), @@ -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()); @@ -199,7 +207,9 @@ private StandardQueryInputs() AddInputType(ScalarNames.Boolean, BooleanInputType()); AddInputType(ScalarNames.String, StringInputType()); AddInputType(ScalarNames.DateTime, DateTimeInputType()); - AddInputType(ScalarNames.ByteArray, ByteArrayInputType()); + // Register under BYTEARRAY_TYPE ("Base64String" with HC v16) so InputTypeBuilder's + // lookup by the field's GraphQL type name resolves correctly. + AddInputType(BYTEARRAY_TYPE, Base64StringInputType()); AddInputType(ScalarNames.LocalTime, LocalTimeInputType()); void AddInputType(string inputTypeName, InputObjectTypeDefinitionNode inputType) diff --git a/src/Service.GraphQLBuilder/SelectionExtensions.cs b/src/Service.GraphQLBuilder/SelectionExtensions.cs new file mode 100644 index 0000000000..b246a567f5 --- /dev/null +++ b/src/Service.GraphQLBuilder/SelectionExtensions.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.DataApiBuilder.Service.Exceptions; +using HotChocolate.Execution.Processing; +using HotChocolate.Language; + +namespace Azure.DataApiBuilder.Service.GraphQLBuilder +{ + /// + /// Extension methods over Hot Chocolate's type. + /// + public static class SelectionExtensions + { + /// + /// Returns the first backing the given , + /// failing fast with a targeted when no syntax node + /// is available. + /// + /// + /// Hot Chocolate v16 introduced Selection.SyntaxNodes (a span) to support field-merging + /// across multiple selection-set occurrences of the same field. Indexing directly with + /// SyntaxNodes[0] would surface as an at request + /// time if the span were ever empty. In practice an executable selection always has at least + /// one syntax node, so an empty span is an invariant violation rather than a legitimate + /// "no field" signal — surface it as a clear DAB error. + /// + public static FieldNode RequireFieldNode(this Selection selection) + { + // SyntaxNodes is a ReadOnlySpan, so LINQ helpers (e.g. FirstOrDefault) + // are not available; check IsEmpty before indexing. + ReadOnlySpan syntaxNodes = selection.SyntaxNodes; + if (syntaxNodes.IsEmpty) + { + throw new DataApiBuilderException( + message: $"GraphQL selection '{selection.ResponseName}' has no syntax node available.", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError); + } + + return syntaxNodes[0].Node; + } + } +} diff --git a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs index 76057a76dc..9a65d3461d 100644 --- a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs +++ b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs @@ -582,16 +582,17 @@ public static IValueNode CreateValueNodeFromDbObjectMetadata(object metadataValu short value => new ObjectValueNode(new ObjectFieldNode(SHORT_TYPE, new IntValueNode(value))), int value => new ObjectValueNode(new ObjectFieldNode(INT_TYPE, value)), long value => new ObjectValueNode(new ObjectFieldNode(LONG_TYPE, new IntValueNode(value))), - Guid value => new ObjectValueNode(new ObjectFieldNode(UUID_TYPE, new UuidType().ParseValue(value))), + Guid value => new ObjectValueNode(new ObjectFieldNode(UUID_TYPE, new UuidType().ValueToLiteral(value))), string value => new ObjectValueNode(new ObjectFieldNode(STRING_TYPE, value)), bool value => new ObjectValueNode(new ObjectFieldNode(BOOLEAN_TYPE, value)), - float value => new ObjectValueNode(new ObjectFieldNode(SINGLE_TYPE, new SingleType().ParseValue(value))), + float value => new ObjectValueNode(new ObjectFieldNode(SINGLE_TYPE, new SingleType().ValueToLiteral(value))), double value => new ObjectValueNode(new ObjectFieldNode(FLOAT_TYPE, value)), decimal value => new ObjectValueNode(new ObjectFieldNode(DECIMAL_TYPE, new FloatValueNode(value))), - DateTimeOffset value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ParseValue(value))), - DateTime value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ParseResult(value))), - byte[] value => new ObjectValueNode(new ObjectFieldNode(BYTEARRAY_TYPE, new ByteArrayType().ParseValue(value))), - TimeOnly value => new ObjectValueNode(new ObjectFieldNode(LOCALTIME_TYPE, new HotChocolate.Types.NodaTime.LocalTimeType().ParseResult(value))), + DateTimeOffset value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ValueToLiteral(value))), + DateTime value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ValueToLiteral( + value.Kind == DateTimeKind.Unspecified ? new DateTimeOffset(value, TimeSpan.Zero) : new DateTimeOffset(value)))), + byte[] value => new ObjectValueNode(new ObjectFieldNode(BYTEARRAY_TYPE, new Base64StringType().ValueToLiteral(value))), + TimeOnly value => new ObjectValueNode(new ObjectFieldNode(LOCALTIME_TYPE, new HotChocolate.Types.NodaTime.LocalTimeType().ValueToLiteral(value))), _ => throw new DataApiBuilderException( message: $"The type {metadataValue.GetType()} is not supported as a GraphQL default value", statusCode: HttpStatusCode.InternalServerError, diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 64e80141a0..203de98aef 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/GraphQLBuilder/MultiSourceBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/MultiSourceBuilderTests.cs index 67bfa8d2fe..8cfeb36e61 100644 --- a/src/Service.Tests/GraphQLBuilder/MultiSourceBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/MultiSourceBuilderTests.cs @@ -5,6 +5,7 @@ using System.IO; using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; +using System.Linq; using System.Threading.Tasks; using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config; @@ -12,6 +13,7 @@ using Azure.DataApiBuilder.Core.Resolvers.Factories; using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Core.Services.MetadataProviders; +using HotChocolate; using HotChocolate.Language; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -66,5 +68,129 @@ public async Task CosmosSchemaBuilderTestAsync() // 11 input types generated for the 3 entity types in the schema.gql. IntFilter,StringFilter etc should not be duplicated. Assert.AreEqual(13, root.Definitions.Count, $"{nameof(DocumentNode)}:Root is invalid. root definitions count does not match expected count."); } + + /// + /// Validates that injects a + /// placeholder field into Query when it has no fields. This guards the empty-schema + /// scenarios (GraphQL globally disabled, all entities opting out, no entities configured) + /// against HC v16's eager schema validation, which rejects any Query type with zero fields. + /// + [TestMethod] + public void EnsureQueryHasAtLeastOneField_InjectsPlaceholder_WhenQueryIsEmpty() + { + // Arrange: a document with an empty Query and an unrelated Book type alongside it. + // Including the unrelated type confirms the rewrite preserves the rest of the document + // without dropping definitions that come after Query. + ObjectTypeDefinitionNode emptyQuery = new( + location: null, + name: new NameNode("Query"), + description: null, + directives: new List(), + interfaces: new List(), + fields: new List()); + + ObjectTypeDefinitionNode bookType = new( + location: null, + name: new NameNode("Book"), + description: null, + directives: new List(), + interfaces: new List(), + fields: new List + { + new( + location: null, + name: new NameNode("id"), + description: null, + arguments: new List(), + type: new NamedTypeNode(new NameNode("Int")), + directives: new List()) + }); + + DocumentNode input = new(new IDefinitionNode[] { emptyQuery, bookType }); + ISchemaBuilder schemaBuilder = SchemaBuilder.New(); + + // Act + DocumentNode result = GraphQLSchemaCreator.EnsureQueryHasAtLeastOneField(input, schemaBuilder); + + // Assert: a new document is returned (not the same reference) with the same count of + // definitions, ordering preserved, and Query now contains exactly the placeholder field. + Assert.AreNotSame(input, result, "Expected a new DocumentNode when injection occurs."); + Assert.AreEqual(2, result.Definitions.Count, "Definition count should be preserved."); + + ObjectTypeDefinitionNode resultQuery = (ObjectTypeDefinitionNode)result.Definitions[0]; + Assert.AreEqual("Query", resultQuery.Name.Value); + Assert.AreEqual(1, resultQuery.Fields.Count, "Query should have the placeholder field."); + Assert.AreEqual( + GraphQLSchemaCreator.EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME, + resultQuery.Fields[0].Name.Value, + "Placeholder field name should match the documented constant."); + Assert.AreEqual("String", ((NamedTypeNode)resultQuery.Fields[0].Type).Name.Value); + + ObjectTypeDefinitionNode resultBook = (ObjectTypeDefinitionNode)result.Definitions[1]; + Assert.AreEqual("Book", resultBook.Name.Value, "Non-Query definitions should be preserved unchanged."); + Assert.AreSame(bookType, resultBook, "Non-Query definitions should be passed through by reference."); + } + + /// + /// Validates that returns the + /// original DocumentNode by reference (no allocation, no copy) when Query + /// already contains at least one field. This is the common-case fast path. + /// + [TestMethod] + public void EnsureQueryHasAtLeastOneField_ReturnsOriginal_WhenQueryHasFields() + { + ObjectTypeDefinitionNode populatedQuery = new( + location: null, + name: new NameNode("Query"), + description: null, + directives: new List(), + interfaces: new List(), + fields: new List + { + new( + location: null, + name: new NameNode("books"), + description: null, + arguments: new List(), + type: new NamedTypeNode(new NameNode("String")), + directives: new List()) + }); + + DocumentNode input = new(new IDefinitionNode[] { populatedQuery }); + ISchemaBuilder schemaBuilder = SchemaBuilder.New(); + + DocumentNode result = GraphQLSchemaCreator.EnsureQueryHasAtLeastOneField(input, schemaBuilder); + + Assert.AreSame(input, result, "Expected the original DocumentNode to be returned unchanged."); + Assert.IsFalse( + result.Definitions.OfType() + .First(d => d.Name.Value == "Query") + .Fields.Any(f => f.Name.Value == GraphQLSchemaCreator.EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME), + "Placeholder field should not be added when Query already has real fields."); + } + + /// + /// Validates that is a no-op + /// when the input document has no Query definition at all. This is a defensive edge + /// case (the production caller always builds Query) but exercises the early-return path. + /// + [TestMethod] + public void EnsureQueryHasAtLeastOneField_ReturnsOriginal_WhenNoQueryDefinitionPresent() + { + ObjectTypeDefinitionNode bookType = new( + location: null, + name: new NameNode("Book"), + description: null, + directives: new List(), + interfaces: new List(), + fields: new List()); + + DocumentNode input = new(new IDefinitionNode[] { bookType }); + ISchemaBuilder schemaBuilder = SchemaBuilder.New(); + + DocumentNode result = GraphQLSchemaCreator.EnsureQueryHasAtLeastOneField(input, schemaBuilder); + + Assert.AreSame(input, result, "Expected the original DocumentNode when no Query is present."); + } } } diff --git a/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs b/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs index 745d5eade3..bb07362a7f 100644 --- a/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs @@ -58,7 +58,7 @@ public async Task InsertMutation(string dbQuery) /// "default_string_with_paranthesis": "()", /// "default_function_string_with_paranthesis": "NOW()", /// "default_integer": 100, - /// "default_date_string": "1999-01-08T10:23:54.000Z" + /// "default_date_string": "1999-01-08T10:23:54Z" /// } /// public virtual async Task InsertMutationWithDefaultBuiltInFunctions(string dbQuery) @@ -95,7 +95,9 @@ public virtual async Task InsertMutationWithDefaultBuiltInFunctions(string dbQue Assert.AreEqual("()", result.GetProperty("default_string_with_parenthesis").GetString()); Assert.AreEqual("NOW()", result.GetProperty("default_function_string_with_parenthesis").GetString()); Assert.AreEqual(100, result.GetProperty("default_integer").GetInt32()); - Assert.AreEqual("1999-01-08T10:23:54.000Z", result.GetProperty("default_date_string").GetString()); + // HC v16 DateTime scalar elides trailing zero fractional seconds in ISO-8601 output + // ("1999-01-08T10:23:54.000Z" → "1999-01-08T10:23:54Z"). + Assert.AreEqual("1999-01-08T10:23:54Z", result.GetProperty("default_date_string").GetString()); } /// diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs index 6f1b498f1e..07b8a9af79 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,29 @@ 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, with two exceptions where the GraphQL scalar name + /// no longer matches the column-name root after the HC v16 upgrade: + /// - is "UnsignedByte" but the column is byte_types. + /// - is "Base64String" but the column is bytearray_types. + /// + protected static string GetTestFieldName(string typeName) + { + if (typeName == BYTE_TYPE) + { + return "byte_types"; + } + + if (typeName == BYTEARRAY_TYPE) + { + return "bytearray_types"; + } + + return $"{typeName.ToLowerInvariant()}_types"; + } + protected abstract string MakeQueryOnTypeTable( List queryFields, string filterValue = "1", diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs index 49b062c55e..d2499a62cf 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) { @@ -229,7 +233,7 @@ private static string ProperlyFormatTypeTableColumn(string columnName) { return $"cast({columnName} is true as json)"; } - else if (columnName.Contains(BYTEARRAY_TYPE.ToLowerInvariant())) + else if (columnName.Contains("bytearray")) { return $"to_base64({columnName})"; } diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs index 8b6e399d45..b9459668d7 100644 --- a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs @@ -132,7 +132,7 @@ protected override bool IsSupportedType(string type) /// private static string ProperlyFormatTypeTableColumn(string columnName) { - if (columnName.Contains(BYTEARRAY_TYPE.ToLowerInvariant())) + if (columnName.Contains("bytearray")) { return $"encode({columnName}, 'base64')"; } diff --git a/src/Service.Tests/UnitTests/MultiSourceQueryExecutionUnitTests.cs b/src/Service.Tests/UnitTests/MultiSourceQueryExecutionUnitTests.cs index e06e140328..7c2ee2cecd 100644 --- a/src/Service.Tests/UnitTests/MultiSourceQueryExecutionUnitTests.cs +++ b/src/Service.Tests/UnitTests/MultiSourceQueryExecutionUnitTests.cs @@ -26,6 +26,7 @@ using HotChocolate; using HotChocolate.Execution; using HotChocolate.Resolvers; +using HotChocolate.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -118,18 +119,18 @@ public async Task TestMultiSourceQuery() Assert.AreEqual(1, cosmosQueryEngine.Invocations.Count, "Cosmos query engine should be invoked for multi-source query as an entity belongs to cosmos db."); OperationResult singleResult = result.ExpectOperationResult(); - Assert.IsNull(singleResult.Errors, "There should be no errors in processing of multisource query."); + Assert.IsTrue(singleResult.Errors.IsEmpty, "There should be no errors in processing of multisource query."); Assert.IsNotNull(singleResult.Data, "Data should be returned for multisource query."); - IReadOnlyDictionary data = singleResult.Data; - Assert.IsTrue(data.TryGetValue(QUERY_NAME_1, out object queryNode1), $"Query node for {QUERY_NAME_1} should have data populated."); - Assert.IsTrue(data.TryGetValue(QUERY_NAME_2, out object queryNode2), $"Query node for {QUERY_NAME_2} should have data populated."); + ResultDocument document = (ResultDocument)singleResult.Data.Value.Value; + Assert.IsTrue(document.Data.TryGetProperty(QUERY_NAME_1, out ResultElement queryNode1), $"Query node for {QUERY_NAME_1} should have data populated."); + Assert.IsTrue(document.Data.TryGetProperty(QUERY_NAME_2, out ResultElement queryNode2), $"Query node for {QUERY_NAME_2} should have data populated."); - KeyValuePair firstEntryMap1 = ((IReadOnlyDictionary)queryNode1).FirstOrDefault(); - KeyValuePair firstEntryMap2 = ((IReadOnlyDictionary)queryNode2).FirstOrDefault(); + ResultProperty firstEntryMap1 = queryNode1.EnumerateObject().FirstOrDefault(); + ResultProperty firstEntryMap2 = queryNode2.EnumerateObject().FirstOrDefault(); // validate that the data returned for the queries we did matches the moq data we set up for the respective query engines. - Assert.AreEqual("db1", firstEntryMap1.Value, $"Data returned for {QUERY_NAME_1} is incorrect for multi-source query"); - Assert.AreEqual("db2", firstEntryMap2.Value, $"Data returned for {QUERY_NAME_2} is incorrect for multi-source query"); + Assert.AreEqual("db1", firstEntryMap1.Value.GetString(), $"Data returned for {QUERY_NAME_1} is incorrect for multi-source query"); + Assert.AreEqual("db2", firstEntryMap2.Value.GetString(), $"Data returned for {QUERY_NAME_2} is incorrect for multi-source query"); } /// diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 841a34483c..f4871b0316 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -614,16 +614,50 @@ private void ConfigureResponseCompression(IServiceCollection services, RuntimeCo private void AddGraphQLService(IServiceCollection services, GraphQLRuntimeOptions? graphQLRuntimeOptions) { IRequestExecutorBuilder server = services.AddGraphQLServer() + // Defer schema construction to the first GraphQL request so the runtime config + // is available for both file-based and POST /configuration (hosted) scenarios. + // See docs/design/HC16-upgrade.md for the full rationale. + .ModifyOptions(options => options.LazyInitialization = true) .AddInstrumentation() - .AddType(new DateTimeType(disableFormatCheck: graphQLRuntimeOptions?.EnableLegacyDateTimeScalar ?? true)) + .AddType(new DateTimeType(new DateTimeOptions { ValidateInputFormat = !(graphQLRuntimeOptions?.EnableLegacyDateTimeScalar ?? true) })) .AddHttpRequestInterceptor() .ConfigureSchema((serviceProvider, schemaBuilder) => { - // The GraphQLSchemaCreator is an application service that is not available on - // the schema specific service provider, this means we have to get it with - // the GetRootServiceProvider helper. - GraphQLSchemaCreator graphQLService = serviceProvider.GetRootServiceProvider().GetRequiredService(); - graphQLService.InitializeSchemaAndResolvers(schemaBuilder); + // Runs on first GraphQL request (LazyInitialization). The placeholder + // fallback covers GraphQL-disabled configs and schema-construction failures. + RuntimeConfigProvider configProvider = serviceProvider.GetRootServiceProvider() + .GetRequiredService(); + if (!configProvider.TryGetConfig(out RuntimeConfig? loadedConfig)) + { + EmitPlaceholderSchema(schemaBuilder); + return; + } + + if (!loadedConfig.IsGraphQLEnabled) + { + EmitPlaceholderSchema(schemaBuilder); + return; + } + + try + { + // The GraphQLSchemaCreator is an application service that is not available on + // the schema specific service provider, this means we have to get it with + // the GetRootServiceProvider helper. + GraphQLSchemaCreator graphQLService = serviceProvider.GetRootServiceProvider().GetRequiredService(); + graphQLService.InitializeSchemaAndResolvers(schemaBuilder); + } + catch (Exception ex) + { + // Schema construction failed (e.g. metadata provider was not initialized + // due to a config validation error). Fall back to the placeholder schema + // so the host can still start; the underlying error will have already + // been surfaced by Startup.PerformOnConfigChangeAsync. + _logger.LogWarning( + exception: ex, + message: "Failed to build GraphQL schema; emitting placeholder schema. The error will surface on first GraphQL request and is typically caused by a runtime config that failed validation."); + EmitPlaceholderSchema(schemaBuilder); + } }) .AddHttpRequestInterceptor() .AddAuthorizationHandler() @@ -872,23 +906,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 @@ -907,6 +929,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.