Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 232 additions & 0 deletions docs/design/HC16-upgrade.md
Original file line number Diff line number Diff line change
@@ -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<T>` 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<float>` 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<FieldSelectionNode>`. 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<T>()` 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.
23 changes: 14 additions & 9 deletions src/Core/Parsers/IntrospectionInterceptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using HotChocolate.AspNetCore;
using HotChocolate.Execution;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;

namespace Azure.DataApiBuilder.Core.Parsers
{
Expand All @@ -14,17 +15,18 @@ namespace Azure.DataApiBuilder.Core.Parsers
/// </summary>
public class IntrospectionInterceptor : DefaultHttpRequestInterceptor
{
private RuntimeConfigProvider _runtimeConfigProvider;

/// <summary>
/// Constructor injects RuntimeConfigProvider to allow
/// HotChocolate to attempt to retrieve the runtime config
/// when evaluating GraphQL requests.
/// Parameterless constructor.
/// </summary>
/// <param name="runtimeConfigProvider"></param>
public IntrospectionInterceptor(RuntimeConfigProvider runtimeConfigProvider)
/// <remarks>
/// Hot Chocolate v16 isolates schema services from the application's request services.
/// Resolving constructor-injected app singletons (e.g. <see cref="RuntimeConfigProvider"/>)
/// against the schema service provider therefore fails at executor session creation.
/// We instead resolve dependencies from <see cref="HttpContext.RequestServices"/> in
/// <see cref="OnCreateAsync"/>, where the application's request scope is in effect.
/// </remarks>
public IntrospectionInterceptor()
{
_runtimeConfigProvider = runtimeConfigProvider;
}

/// <summary>
Expand All @@ -51,7 +53,10 @@ public override ValueTask OnCreateAsync(
OperationRequestBuilder requestBuilder,
CancellationToken cancellationToken)
{
if (_runtimeConfigProvider.GetConfig().AllowIntrospection)
RuntimeConfigProvider runtimeConfigProvider =
context.RequestServices.GetRequiredService<RuntimeConfigProvider>();

if (runtimeConfigProvider.GetConfig().AllowIntrospection)
{
requestBuilder.AllowIntrospection();
}
Expand Down
7 changes: 4 additions & 3 deletions src/Core/Resolvers/CosmosQueryStructure.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,15 @@ private static IEnumerable<LabelledColumn> GenerateQueryColumns(SelectionSetNode
[MemberNotNull(nameof(OrderByColumns))]
private void Init(IDictionary<string, object?> queryParams)
{
ISelection selection = _context.Selection;
Selection selection = _context.Selection;
ObjectType underlyingType = selection.Field.Type.NamedType<ObjectType>();

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)
{
Expand All @@ -139,7 +140,7 @@ private void Init(IDictionary<string, object?> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<ObjectType>();
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Services/DetermineStatusCodeMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}
Expand Down
12 changes: 7 additions & 5 deletions src/Core/Services/ExecutionHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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(),
Expand All @@ -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()
};
Expand Down Expand Up @@ -508,7 +510,7 @@ public static InputObjectType InputObjectTypeFromIInputField(IInputValueDefiniti
{
return GetParametersFromSchemaAndQueryFields(
context.Selection.Field,
context.Selection.SyntaxNode,
context.Selection.RequireFieldNode(),
context.Variables);
}

Expand Down
Loading
Loading