From 465e769a23b141df293146725200a8ed65ea4914 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Tue, 7 Apr 2026 06:51:49 +0000 Subject: [PATCH 1/9] [Fusion] Add defer support --- .vscode/mcp.json | 3 +- dotnet-install.sh | 1887 +++++++++++++++++ global.json | 2 +- ...HotChocolate.Execution.Abstractions.csproj | 1 + .../Completion/CompositeSchemaBuilder.cs | 32 + .../Execution/ExecutionState.cs | 2 +- .../Execution/FusionOperationInfo.cs | 2 +- .../Execution/Nodes/DeferredExecutionGroup.cs | 88 + .../Execution/Nodes/Operation.cs | 7 +- .../Execution/Nodes/OperationCompiler.cs | 68 +- .../Execution/Nodes/OperationPlan.cs | 24 +- .../Execution/Nodes/Selection.cs | 7 +- .../Execution/Nodes/SelectionSet.cs | 6 +- .../Execution/OperationPlanExecutor.cs | 324 +++ .../Pipeline/OperationExecutionMiddleware.cs | 20 + .../Planning/DeferOperationRewriter.cs | 499 +++++ .../OperationPlanner.BuildExecutionTree.cs | 83 +- .../Planning/OperationPlanner.Defer.cs | 187 ++ .../Planning/OperationPlanner.cs | 58 +- .../Fusion.AspNetCore.Tests/DeferTests.cs | 616 ++++++ ....SerializeAs_Will_Be_In_The_Schema.graphql | 2 + ...ializeAs_Will_Not_Be_In_The_Schema.graphql | 2 + .../Planning/DeferPlannerTests.cs | 606 ++++++ ...kupTests.Require_Inaccessible_Data.graphql | 2 + 24 files changed, 4501 insertions(+), 27 deletions(-) create mode 100755 dotnet-install.sh create mode 100644 src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferredExecutionGroup.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs create mode 100644 src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs create mode 100644 src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/DeferPlannerTests.cs diff --git a/.vscode/mcp.json b/.vscode/mcp.json index a5c00c1a318..d1632164fa7 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -6,8 +6,9 @@ }, "nuget": { "type": "stdio", - "command": "dnx", + "command": "dotnet", "args": [ + "dnx", "NuGet.Mcp.Server", "--source", "https://api.nuget.org/v3/index.json", diff --git a/dotnet-install.sh b/dotnet-install.sh new file mode 100755 index 00000000000..c44294628d1 --- /dev/null +++ b/dotnet-install.sh @@ -0,0 +1,1887 @@ +#!/usr/bin/env bash +# Copyright (c) .NET Foundation and contributors. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +# Stop script on NZEC +set -e +# Stop script if unbound variable found (use ${var:-} if intentional) +set -u +# By default cmd1 | cmd2 returns exit code of cmd2 regardless of cmd1 success +# This is causing it to fail +set -o pipefail + +# Use in the the functions: eval $invocation +invocation='say_verbose "Calling: ${yellow:-}${FUNCNAME[0]} ${green:-}$*${normal:-}"' + +# standard output may be used as a return value in the functions +# we need a way to write text on the screen in the functions so that +# it won't interfere with the return value. +# Exposing stream 3 as a pipe to standard output of the script itself +exec 3>&1 + +# Setup some colors to use. These need to work in fairly limited shells, like the Ubuntu Docker container where there are only 8 colors. +# See if stdout is a terminal +if [ -t 1 ] && command -v tput > /dev/null; then + # see if it supports colors + ncolors=$(tput colors || echo 0) + if [ -n "$ncolors" ] && [ $ncolors -ge 8 ]; then + bold="$(tput bold || echo)" + normal="$(tput sgr0 || echo)" + black="$(tput setaf 0 || echo)" + red="$(tput setaf 1 || echo)" + green="$(tput setaf 2 || echo)" + yellow="$(tput setaf 3 || echo)" + blue="$(tput setaf 4 || echo)" + magenta="$(tput setaf 5 || echo)" + cyan="$(tput setaf 6 || echo)" + white="$(tput setaf 7 || echo)" + fi +fi + +say_warning() { + printf "%b\n" "${yellow:-}dotnet_install: Warning: $1${normal:-}" >&3 +} + +say_err() { + printf "%b\n" "${red:-}dotnet_install: Error: $1${normal:-}" >&2 +} + +say() { + # using stream 3 (defined in the beginning) to not interfere with stdout of functions + # which may be used as return value + printf "%b\n" "${cyan:-}dotnet-install:${normal:-} $1" >&3 +} + +say_verbose() { + if [ "$verbose" = true ]; then + say "$1" + fi +} + +# This platform list is finite - if the SDK/Runtime has supported Linux distribution-specific assets, +# then and only then should the Linux distribution appear in this list. +# Adding a Linux distribution to this list does not imply distribution-specific support. +get_legacy_os_name_from_platform() { + eval $invocation + + platform="$1" + case "$platform" in + "centos.7") + echo "centos" + return 0 + ;; + "debian.8") + echo "debian" + return 0 + ;; + "debian.9") + echo "debian.9" + return 0 + ;; + "fedora.23") + echo "fedora.23" + return 0 + ;; + "fedora.24") + echo "fedora.24" + return 0 + ;; + "fedora.27") + echo "fedora.27" + return 0 + ;; + "fedora.28") + echo "fedora.28" + return 0 + ;; + "opensuse.13.2") + echo "opensuse.13.2" + return 0 + ;; + "opensuse.42.1") + echo "opensuse.42.1" + return 0 + ;; + "opensuse.42.3") + echo "opensuse.42.3" + return 0 + ;; + "rhel.7"*) + echo "rhel" + return 0 + ;; + "ubuntu.14.04") + echo "ubuntu" + return 0 + ;; + "ubuntu.16.04") + echo "ubuntu.16.04" + return 0 + ;; + "ubuntu.16.10") + echo "ubuntu.16.10" + return 0 + ;; + "ubuntu.18.04") + echo "ubuntu.18.04" + return 0 + ;; + "alpine.3.4.3") + echo "alpine" + return 0 + ;; + esac + return 1 +} + +get_legacy_os_name() { + eval $invocation + + local uname=$(uname) + if [ "$uname" = "Darwin" ]; then + echo "osx" + return 0 + elif [ -n "$runtime_id" ]; then + echo $(get_legacy_os_name_from_platform "${runtime_id%-*}" || echo "${runtime_id%-*}") + return 0 + else + if [ -e /etc/os-release ]; then + . /etc/os-release + os=$(get_legacy_os_name_from_platform "$ID${VERSION_ID:+.${VERSION_ID}}" || echo "") + if [ -n "$os" ]; then + echo "$os" + return 0 + fi + fi + fi + + say_verbose "Distribution specific OS name and version could not be detected: UName = $uname" + return 1 +} + +get_linux_platform_name() { + eval $invocation + + if [ -n "$runtime_id" ]; then + echo "${runtime_id%-*}" + return 0 + else + if [ -e /etc/os-release ]; then + . /etc/os-release + echo "$ID${VERSION_ID:+.${VERSION_ID}}" + return 0 + elif [ -e /etc/redhat-release ]; then + local redhatRelease=$(&1 || true) | grep -q musl +} + +get_current_os_name() { + eval $invocation + + local uname=$(uname) + if [ "$uname" = "Darwin" ]; then + echo "osx" + return 0 + elif [ "$uname" = "FreeBSD" ]; then + echo "freebsd" + return 0 + elif [ "$uname" = "Linux" ]; then + local linux_platform_name="" + linux_platform_name="$(get_linux_platform_name)" || true + + if [ "$linux_platform_name" = "rhel.6" ]; then + echo $linux_platform_name + return 0 + elif is_musl_based_distro; then + echo "linux-musl" + return 0 + elif [ "$linux_platform_name" = "linux-musl" ]; then + echo "linux-musl" + return 0 + else + echo "linux" + return 0 + fi + fi + + say_err "OS name could not be detected: UName = $uname" + return 1 +} + +machine_has() { + eval $invocation + + command -v "$1" > /dev/null 2>&1 + return $? +} + +check_min_reqs() { + local hasMinimum=false + if machine_has "curl"; then + hasMinimum=true + elif machine_has "wget"; then + hasMinimum=true + fi + + if [ "$hasMinimum" = "false" ]; then + say_err "curl (recommended) or wget are required to download dotnet. Install missing prerequisite to proceed." + return 1 + fi + return 0 +} + +# args: +# input - $1 +to_lowercase() { + #eval $invocation + + echo "$1" | tr '[:upper:]' '[:lower:]' + return 0 +} + +# args: +# input - $1 +remove_trailing_slash() { + #eval $invocation + + local input="${1:-}" + echo "${input%/}" + return 0 +} + +# args: +# input - $1 +remove_beginning_slash() { + #eval $invocation + + local input="${1:-}" + echo "${input#/}" + return 0 +} + +# args: +# root_path - $1 +# child_path - $2 - this parameter can be empty +combine_paths() { + eval $invocation + + # TODO: Consider making it work with any number of paths. For now: + if [ ! -z "${3:-}" ]; then + say_err "combine_paths: Function takes two parameters." + return 1 + fi + + local root_path="$(remove_trailing_slash "$1")" + local child_path="$(remove_beginning_slash "${2:-}")" + say_verbose "combine_paths: root_path=$root_path" + say_verbose "combine_paths: child_path=$child_path" + echo "$root_path/$child_path" + return 0 +} + +get_machine_architecture() { + eval $invocation + + if command -v uname > /dev/null; then + CPUName=$(uname -m) + case $CPUName in + armv1*|armv2*|armv3*|armv4*|armv5*|armv6*) + echo "armv6-or-below" + return 0 + ;; + armv*l) + echo "arm" + return 0 + ;; + aarch64|arm64) + if [ "$(getconf LONG_BIT)" -lt 64 ]; then + # This is 32-bit OS running on 64-bit CPU (for example Raspberry Pi OS) + echo "arm" + return 0 + fi + echo "arm64" + return 0 + ;; + s390x) + echo "s390x" + return 0 + ;; + ppc64le) + echo "ppc64le" + return 0 + ;; + loongarch64) + echo "loongarch64" + return 0 + ;; + riscv64) + echo "riscv64" + return 0 + ;; + powerpc|ppc) + echo "ppc" + return 0 + ;; + esac + fi + + # Always default to 'x64' + echo "x64" + return 0 +} + +# args: +# architecture - $1 +get_normalized_architecture_from_architecture() { + eval $invocation + + local architecture="$(to_lowercase "$1")" + + if [[ $architecture == \ ]]; then + machine_architecture="$(get_machine_architecture)" + if [[ "$machine_architecture" == "armv6-or-below" ]]; then + say_err "Architecture \`$machine_architecture\` not supported. If you think this is a bug, report it at https://github.com/dotnet/install-scripts/issues" + return 1 + fi + + echo $machine_architecture + return 0 + fi + + case "$architecture" in + amd64|x64) + echo "x64" + return 0 + ;; + arm) + echo "arm" + return 0 + ;; + arm64) + echo "arm64" + return 0 + ;; + s390x) + echo "s390x" + return 0 + ;; + ppc64le) + echo "ppc64le" + return 0 + ;; + loongarch64) + echo "loongarch64" + return 0 + ;; + esac + + say_err "Architecture \`$architecture\` not supported. If you think this is a bug, report it at https://github.com/dotnet/install-scripts/issues" + return 1 +} + +# args: +# version - $1 +# channel - $2 +# architecture - $3 +get_normalized_architecture_for_specific_sdk_version() { + eval $invocation + + local is_version_support_arm64="$(is_arm64_supported "$1")" + local is_channel_support_arm64="$(is_arm64_supported "$2")" + local architecture="$3"; + local osname="$(get_current_os_name)" + + if [ "$osname" == "osx" ] && [ "$architecture" == "arm64" ] && { [ "$is_version_support_arm64" = false ] || [ "$is_channel_support_arm64" = false ]; }; then + #check if rosetta is installed + if [ "$(/usr/bin/pgrep oahd >/dev/null 2>&1;echo $?)" -eq 0 ]; then + say_verbose "Changing user architecture from '$architecture' to 'x64' because .NET SDKs prior to version 6.0 do not support arm64." + echo "x64" + return 0; + else + say_err "Architecture \`$architecture\` is not supported for .NET SDK version \`$version\`. Please install Rosetta to allow emulation of the \`$architecture\` .NET SDK on this platform" + return 1 + fi + fi + + echo "$architecture" + return 0 +} + +# args: +# version or channel - $1 +is_arm64_supported() { + # Extract the major version by splitting on the dot + major_version="${1%%.*}" + + # Check if the major version is a valid number and less than 6 + case "$major_version" in + [0-9]*) + if [ "$major_version" -lt 6 ]; then + echo false + return 0 + fi + ;; + esac + + echo true + return 0 +} + +# args: +# user_defined_os - $1 +get_normalized_os() { + eval $invocation + + local osname="$(to_lowercase "$1")" + if [ ! -z "$osname" ]; then + case "$osname" in + osx | freebsd | rhel.6 | linux-musl | linux) + echo "$osname" + return 0 + ;; + macos) + osname='osx' + echo "$osname" + return 0 + ;; + *) + say_err "'$user_defined_os' is not a supported value for --os option, supported values are: osx, macos, linux, linux-musl, freebsd, rhel.6. If you think this is a bug, report it at https://github.com/dotnet/install-scripts/issues." + return 1 + ;; + esac + else + osname="$(get_current_os_name)" || return 1 + fi + echo "$osname" + return 0 +} + +# args: +# quality - $1 +get_normalized_quality() { + eval $invocation + + local quality="$(to_lowercase "$1")" + if [ ! -z "$quality" ]; then + case "$quality" in + daily | preview) + echo "$quality" + return 0 + ;; + ga) + #ga quality is available without specifying quality, so normalizing it to empty + return 0 + ;; + *) + say_err "'$quality' is not a supported value for --quality option. Supported values are: daily, preview, ga. If you think this is a bug, report it at https://github.com/dotnet/install-scripts/issues." + return 1 + ;; + esac + fi + return 0 +} + +# args: +# channel - $1 +get_normalized_channel() { + eval $invocation + + local channel="$(to_lowercase "$1")" + + if [[ $channel == current ]]; then + say_warning 'Value "Current" is deprecated for -Channel option. Use "STS" instead.' + fi + + if [[ $channel == release/* ]]; then + say_warning 'Using branch name with -Channel option is no longer supported with newer releases. Use -Quality option with a channel in X.Y format instead.'; + fi + + if [ ! -z "$channel" ]; then + case "$channel" in + lts) + echo "LTS" + return 0 + ;; + sts) + echo "STS" + return 0 + ;; + current) + echo "STS" + return 0 + ;; + *) + echo "$channel" + return 0 + ;; + esac + fi + + return 0 +} + +# args: +# runtime - $1 +get_normalized_product() { + eval $invocation + + local product="" + local runtime="$(to_lowercase "$1")" + if [[ "$runtime" == "dotnet" ]]; then + product="dotnet-runtime" + elif [[ "$runtime" == "aspnetcore" ]]; then + product="aspnetcore-runtime" + elif [ -z "$runtime" ]; then + product="dotnet-sdk" + fi + echo "$product" + return 0 +} + +# The version text returned from the feeds is a 1-line or 2-line string: +# For the SDK and the dotnet runtime (2 lines): +# Line 1: # commit_hash +# Line 2: # 4-part version +# For the aspnetcore runtime (1 line): +# Line 1: # 4-part version + +# args: +# version_text - stdin +get_version_from_latestversion_file_content() { + eval $invocation + + cat | tail -n 1 | sed 's/\r$//' + return 0 +} + +# args: +# install_root - $1 +# relative_path_to_package - $2 +# specific_version - $3 +is_dotnet_package_installed() { + eval $invocation + + local install_root="$1" + local relative_path_to_package="$2" + local specific_version="${3//[$'\t\r\n']}" + + local dotnet_package_path="$(combine_paths "$(combine_paths "$install_root" "$relative_path_to_package")" "$specific_version")" + say_verbose "is_dotnet_package_installed: dotnet_package_path=$dotnet_package_path" + + if [ -d "$dotnet_package_path" ]; then + return 0 + else + return 1 + fi +} + +# args: +# downloaded file - $1 +# remote_file_size - $2 +validate_remote_local_file_sizes() +{ + eval $invocation + + local downloaded_file="$1" + local remote_file_size="$2" + local file_size='' + + if [[ "$OSTYPE" == "linux-gnu"* ]]; then + file_size="$(stat -c '%s' "$downloaded_file")" + elif [[ "$OSTYPE" == "darwin"* ]]; then + # hardcode in order to avoid conflicts with GNU stat + file_size="$(/usr/bin/stat -f '%z' "$downloaded_file")" + fi + + if [ -n "$file_size" ]; then + say "Downloaded file size is $file_size bytes." + + if [ -n "$remote_file_size" ] && [ -n "$file_size" ]; then + if [ "$remote_file_size" -ne "$file_size" ]; then + say "The remote and local file sizes are not equal. The remote file size is $remote_file_size bytes and the local size is $file_size bytes. The local package may be corrupted." + else + say "The remote and local file sizes are equal." + fi + fi + + else + say "Either downloaded or local package size can not be measured. One of them may be corrupted." + fi +} + +# args: +# azure_feed - $1 +# channel - $2 +# normalized_architecture - $3 +get_version_from_latestversion_file() { + eval $invocation + + local azure_feed="$1" + local channel="$2" + local normalized_architecture="$3" + + local version_file_url=null + if [[ "$runtime" == "dotnet" ]]; then + version_file_url="$azure_feed/Runtime/$channel/latest.version" + elif [[ "$runtime" == "aspnetcore" ]]; then + version_file_url="$azure_feed/aspnetcore/Runtime/$channel/latest.version" + elif [ -z "$runtime" ]; then + version_file_url="$azure_feed/Sdk/$channel/latest.version" + else + say_err "Invalid value for \$runtime" + return 1 + fi + say_verbose "get_version_from_latestversion_file: latest url: $version_file_url" + + download "$version_file_url" || return $? + return 0 +} + +# args: +# json_file - $1 +parse_globaljson_file_for_version() { + eval $invocation + + local json_file="$1" + if [ ! -f "$json_file" ]; then + say_err "Unable to find \`$json_file\`" + return 1 + fi + + sdk_section=$(cat "$json_file" | tr -d "\r" | awk '/"sdk"/,/}/') + if [ -z "$sdk_section" ]; then + say_err "Unable to parse the SDK node in \`$json_file\`" + return 1 + fi + + sdk_list=$(echo $sdk_section | awk -F"[{}]" '{print $2}') + sdk_list=${sdk_list//[\" ]/} + sdk_list=${sdk_list//,/$'\n'} + + local version_info="" + while read -r line; do + IFS=: + while read -r key value; do + if [[ "$key" == "version" ]]; then + version_info=$value + fi + done <<< "$line" + done <<< "$sdk_list" + if [ -z "$version_info" ]; then + say_err "Unable to find the SDK:version node in \`$json_file\`" + return 1 + fi + + unset IFS; + echo "$version_info" + return 0 +} + +# args: +# azure_feed - $1 +# channel - $2 +# normalized_architecture - $3 +# version - $4 +# json_file - $5 +get_specific_version_from_version() { + eval $invocation + + local azure_feed="$1" + local channel="$2" + local normalized_architecture="$3" + local version="$(to_lowercase "$4")" + local json_file="$5" + + if [ -z "$json_file" ]; then + if [[ "$version" == "latest" ]]; then + local version_info + version_info="$(get_version_from_latestversion_file "$azure_feed" "$channel" "$normalized_architecture" false)" || return 1 + say_verbose "get_specific_version_from_version: version_info=$version_info" + echo "$version_info" | get_version_from_latestversion_file_content + return 0 + else + echo "$version" + return 0 + fi + else + local version_info + version_info="$(parse_globaljson_file_for_version "$json_file")" || return 1 + echo "$version_info" + return 0 + fi +} + +# args: +# azure_feed - $1 +# channel - $2 +# normalized_architecture - $3 +# specific_version - $4 +# normalized_os - $5 +construct_download_link() { + eval $invocation + + local azure_feed="$1" + local channel="$2" + local normalized_architecture="$3" + local specific_version="${4//[$'\t\r\n']}" + local specific_product_version="$(get_specific_product_version "$1" "$4")" + local osname="$5" + + local download_link=null + if [[ "$runtime" == "dotnet" ]]; then + download_link="$azure_feed/Runtime/$specific_version/dotnet-runtime-$specific_product_version-$osname-$normalized_architecture.tar.gz" + elif [[ "$runtime" == "aspnetcore" ]]; then + download_link="$azure_feed/aspnetcore/Runtime/$specific_version/aspnetcore-runtime-$specific_product_version-$osname-$normalized_architecture.tar.gz" + elif [ -z "$runtime" ]; then + download_link="$azure_feed/Sdk/$specific_version/dotnet-sdk-$specific_product_version-$osname-$normalized_architecture.tar.gz" + else + return 1 + fi + + echo "$download_link" + return 0 +} + +# args: +# azure_feed - $1 +# specific_version - $2 +# download link - $3 (optional) +get_specific_product_version() { + # If we find a 'productVersion.txt' at the root of any folder, we'll use its contents + # to resolve the version of what's in the folder, superseding the specified version. + # if 'productVersion.txt' is missing but download link is already available, product version will be taken from download link + eval $invocation + + local azure_feed="$1" + local specific_version="${2//[$'\t\r\n']}" + local package_download_link="" + if [ $# -gt 2 ]; then + local package_download_link="$3" + fi + local specific_product_version=null + + # Try to get the version number, using the productVersion.txt file located next to the installer file. + local download_links=($(get_specific_product_version_url "$azure_feed" "$specific_version" true "$package_download_link") + $(get_specific_product_version_url "$azure_feed" "$specific_version" false "$package_download_link")) + + for download_link in "${download_links[@]}" + do + say_verbose "Checking for the existence of $download_link" + + if machine_has "curl" + then + if ! specific_product_version=$(curl -sL --fail "${download_link}${feed_credential}" 2>&1); then + continue + else + echo "${specific_product_version//[$'\t\r\n']}" + return 0 + fi + + elif machine_has "wget" + then + specific_product_version=$(wget -qO- "${download_link}${feed_credential}" 2>&1) + if [ $? = 0 ]; then + echo "${specific_product_version//[$'\t\r\n']}" + return 0 + fi + fi + done + + # Getting the version number with productVersion.txt has failed. Try parsing the download link for a version number. + say_verbose "Failed to get the version using productVersion.txt file. Download link will be parsed instead." + specific_product_version="$(get_product_specific_version_from_download_link "$package_download_link" "$specific_version")" + echo "${specific_product_version//[$'\t\r\n']}" + return 0 +} + +# args: +# azure_feed - $1 +# specific_version - $2 +# is_flattened - $3 +# download link - $4 (optional) +get_specific_product_version_url() { + eval $invocation + + local azure_feed="$1" + local specific_version="$2" + local is_flattened="$3" + local package_download_link="" + if [ $# -gt 3 ]; then + local package_download_link="$4" + fi + + local pvFileName="productVersion.txt" + if [ "$is_flattened" = true ]; then + if [ -z "$runtime" ]; then + pvFileName="sdk-productVersion.txt" + elif [[ "$runtime" == "dotnet" ]]; then + pvFileName="runtime-productVersion.txt" + else + pvFileName="$runtime-productVersion.txt" + fi + fi + + local download_link=null + + if [ -z "$package_download_link" ]; then + if [[ "$runtime" == "dotnet" ]]; then + download_link="$azure_feed/Runtime/$specific_version/${pvFileName}" + elif [[ "$runtime" == "aspnetcore" ]]; then + download_link="$azure_feed/aspnetcore/Runtime/$specific_version/${pvFileName}" + elif [ -z "$runtime" ]; then + download_link="$azure_feed/Sdk/$specific_version/${pvFileName}" + else + return 1 + fi + else + download_link="${package_download_link%/*}/${pvFileName}" + fi + + say_verbose "Constructed productVersion link: $download_link" + echo "$download_link" + return 0 +} + +# args: +# download link - $1 +# specific version - $2 +get_product_specific_version_from_download_link() +{ + eval $invocation + + local download_link="$1" + local specific_version="$2" + local specific_product_version="" + + if [ -z "$download_link" ]; then + echo "$specific_version" + return 0 + fi + + #get filename + filename="${download_link##*/}" + + #product specific version follows the product name + #for filename 'dotnet-sdk-3.1.404-linux-x64.tar.gz': the product version is 3.1.404 + IFS='-' + read -ra filename_elems <<< "$filename" + count=${#filename_elems[@]} + if [[ "$count" -gt 2 ]]; then + specific_product_version="${filename_elems[2]}" + else + specific_product_version=$specific_version + fi + unset IFS; + echo "$specific_product_version" + return 0 +} + +# args: +# azure_feed - $1 +# channel - $2 +# normalized_architecture - $3 +# specific_version - $4 +construct_legacy_download_link() { + eval $invocation + + local azure_feed="$1" + local channel="$2" + local normalized_architecture="$3" + local specific_version="${4//[$'\t\r\n']}" + + local distro_specific_osname + distro_specific_osname="$(get_legacy_os_name)" || return 1 + + local legacy_download_link=null + if [[ "$runtime" == "dotnet" ]]; then + legacy_download_link="$azure_feed/Runtime/$specific_version/dotnet-$distro_specific_osname-$normalized_architecture.$specific_version.tar.gz" + elif [ -z "$runtime" ]; then + legacy_download_link="$azure_feed/Sdk/$specific_version/dotnet-dev-$distro_specific_osname-$normalized_architecture.$specific_version.tar.gz" + else + return 1 + fi + + echo "$legacy_download_link" + return 0 +} + +get_user_install_path() { + eval $invocation + + if [ ! -z "${DOTNET_INSTALL_DIR:-}" ]; then + echo "$DOTNET_INSTALL_DIR" + else + echo "$HOME/.dotnet" + fi + return 0 +} + +# args: +# install_dir - $1 +resolve_installation_path() { + eval $invocation + + local install_dir=$1 + if [ "$install_dir" = "" ]; then + local user_install_path="$(get_user_install_path)" + say_verbose "resolve_installation_path: user_install_path=$user_install_path" + echo "$user_install_path" + return 0 + fi + + echo "$install_dir" + return 0 +} + +# args: +# relative_or_absolute_path - $1 +get_absolute_path() { + eval $invocation + + local relative_or_absolute_path=$1 + echo "$(cd "$(dirname "$1")" && pwd -P)/$(basename "$1")" + return 0 +} + +# args: +# override - $1 (boolean, true or false) +get_cp_options() { + eval $invocation + + local override="$1" + local override_switch="" + + if [ "$override" = false ]; then + override_switch="-n" + + # create temporary files to check if 'cp -u' is supported + tmp_dir="$(mktemp -d)" + tmp_file="$tmp_dir/testfile" + tmp_file2="$tmp_dir/testfile2" + + touch "$tmp_file" + + # use -u instead of -n if it's available + if cp -u "$tmp_file" "$tmp_file2" 2>/dev/null; then + override_switch="-u" + fi + + # clean up + rm -f "$tmp_file" "$tmp_file2" + rm -rf "$tmp_dir" + fi + + echo "$override_switch" +} + +# args: +# input_files - stdin +# root_path - $1 +# out_path - $2 +# override - $3 +copy_files_or_dirs_from_list() { + eval $invocation + + local root_path="$(remove_trailing_slash "$1")" + local out_path="$(remove_trailing_slash "$2")" + local override="$3" + local override_switch="$(get_cp_options "$override")" + + cat | uniq | while read -r file_path; do + local path="$(remove_beginning_slash "${file_path#$root_path}")" + local target="$out_path/$path" + if [ "$override" = true ] || (! ([ -d "$target" ] || [ -e "$target" ])); then + mkdir -p "$out_path/$(dirname "$path")" + if [ -d "$target" ]; then + rm -rf "$target" + fi + cp -R $override_switch "$root_path/$path" "$target" + fi + done +} + +# args: +# zip_uri - $1 +get_remote_file_size() { + local zip_uri="$1" + + if machine_has "curl"; then + file_size=$(curl -sI "$zip_uri" | grep -i content-length | awk '{ num = $2 + 0; print num }') + elif machine_has "wget"; then + file_size=$(wget --spider --server-response -O /dev/null "$zip_uri" 2>&1 | grep -i 'Content-Length:' | awk '{ num = $2 + 0; print num }') + else + say "Neither curl nor wget is available on this system." + return + fi + + if [ -n "$file_size" ]; then + say "Remote file $zip_uri size is $file_size bytes." + echo "$file_size" + else + say_verbose "Content-Length header was not extracted for $zip_uri." + echo "" + fi +} + +# args: +# zip_path - $1 +# out_path - $2 +# remote_file_size - $3 +extract_dotnet_package() { + eval $invocation + + local zip_path="$1" + local out_path="$2" + local remote_file_size="$3" + + local temp_out_path="$(mktemp -d "$temporary_file_template")" + + local failed=false + tar -xzf "$zip_path" -C "$temp_out_path" > /dev/null || failed=true + + local folders_with_version_regex='^.*/[0-9]+\.[0-9]+[^/]+/' + find "$temp_out_path" -type f | grep -Eo "$folders_with_version_regex" | sort | copy_files_or_dirs_from_list "$temp_out_path" "$out_path" false + find "$temp_out_path" -type f | grep -Ev "$folders_with_version_regex" | copy_files_or_dirs_from_list "$temp_out_path" "$out_path" "$override_non_versioned_files" + + validate_remote_local_file_sizes "$zip_path" "$remote_file_size" + + rm -rf "$temp_out_path" + if [ -z ${keep_zip+x} ]; then + rm -f "$zip_path" && say_verbose "Temporary archive file $zip_path was removed" + fi + + if [ "$failed" = true ]; then + say_err "Extraction failed" + return 1 + fi + return 0 +} + +# args: +# remote_path - $1 +# disable_feed_credential - $2 +get_http_header() +{ + eval $invocation + local remote_path="$1" + local disable_feed_credential="$2" + + local failed=false + local response + if machine_has "curl"; then + get_http_header_curl $remote_path $disable_feed_credential || failed=true + elif machine_has "wget"; then + get_http_header_wget $remote_path $disable_feed_credential || failed=true + else + failed=true + fi + if [ "$failed" = true ]; then + say_verbose "Failed to get HTTP header: '$remote_path'." + return 1 + fi + return 0 +} + +# args: +# remote_path - $1 +# disable_feed_credential - $2 +get_http_header_curl() { + eval $invocation + local remote_path="$1" + local disable_feed_credential="$2" + + remote_path_with_credential="$remote_path" + if [ "$disable_feed_credential" = false ]; then + remote_path_with_credential+="$feed_credential" + fi + + curl_options="-I -sSL --retry 5 --retry-delay 2 --connect-timeout 15 " + curl $curl_options "$remote_path_with_credential" 2>&1 || return 1 + return 0 +} + +# args: +# remote_path - $1 +# disable_feed_credential - $2 +get_http_header_wget() { + eval $invocation + local remote_path="$1" + local disable_feed_credential="$2" + local wget_options="-q -S --spider --tries 5 " + + local wget_options_extra='' + + # Test for options that aren't supported on all wget implementations. + if [[ $(wget -h 2>&1 | grep -E 'waitretry|connect-timeout') ]]; then + wget_options_extra="--waitretry 2 --connect-timeout 15 " + else + say "wget extra options are unavailable for this environment" + fi + + remote_path_with_credential="$remote_path" + if [ "$disable_feed_credential" = false ]; then + remote_path_with_credential+="$feed_credential" + fi + + wget $wget_options $wget_options_extra "$remote_path_with_credential" 2>&1 + + return $? +} + +# args: +# remote_path - $1 +# [out_path] - $2 - stdout if not provided +download() { + eval $invocation + + local remote_path="$1" + local out_path="${2:-}" + + if [[ "$remote_path" != "http"* ]]; then + cp "$remote_path" "$out_path" + return $? + fi + + local failed=false + local attempts=0 + while [ $attempts -lt 3 ]; do + attempts=$((attempts+1)) + failed=false + if machine_has "curl"; then + downloadcurl "$remote_path" "$out_path" || failed=true + elif machine_has "wget"; then + downloadwget "$remote_path" "$out_path" || failed=true + else + say_err "Missing dependency: neither curl nor wget was found." + exit 1 + fi + + if [ "$failed" = false ] || [ $attempts -ge 3 ] || { [ -n "${http_code-}" ] && [ "${http_code}" = "404" ]; }; then + break + fi + + say "Download attempt #$attempts has failed: ${http_code-} ${download_error_msg-}" + say "Attempt #$((attempts+1)) will start in $((attempts*10)) seconds." + sleep $((attempts*10)) + done + + if [ "$failed" = true ]; then + say_verbose "Download failed: $remote_path" + return 1 + fi + return 0 +} + +# Updates global variables $http_code and $download_error_msg +downloadcurl() { + eval $invocation + unset http_code + unset download_error_msg + local remote_path="$1" + local out_path="${2:-}" + # Append feed_credential as late as possible before calling curl to avoid logging feed_credential + # Avoid passing URI with credentials to functions: note, most of them echoing parameters of invocation in verbose output. + local remote_path_with_credential="${remote_path}${feed_credential}" + local curl_options="--retry 20 --retry-delay 2 --connect-timeout 15 -sSL -f --create-dirs " + local curl_exit_code=0; + if [ -z "$out_path" ]; then + curl_output=$(curl $curl_options "$remote_path_with_credential" 2>&1) + curl_exit_code=$? + echo "$curl_output" + else + curl_output=$(curl $curl_options -o "$out_path" "$remote_path_with_credential" 2>&1) + curl_exit_code=$? + fi + + # Regression in curl causes curl with --retry to return a 0 exit code even when it fails to download a file - https://github.com/curl/curl/issues/17554 + if [ $curl_exit_code -eq 0 ] && echo "$curl_output" | grep -q "^curl: ([0-9]*) "; then + curl_exit_code=$(echo "$curl_output" | sed 's/curl: (\([0-9]*\)).*/\1/') + fi + + if [ $curl_exit_code -gt 0 ]; then + download_error_msg="Unable to download $remote_path." + # Check for curl timeout codes + if [[ $curl_exit_code == 7 || $curl_exit_code == 28 ]]; then + download_error_msg+=" Failed to reach the server: connection timeout." + else + local disable_feed_credential=false + local response=$(get_http_header_curl $remote_path $disable_feed_credential) + http_code=$( echo "$response" | awk '/^HTTP/{print $2}' | tail -1 ) + if [[ ! -z $http_code && $http_code != 2* ]]; then + download_error_msg+=" Returned HTTP status code: $http_code." + fi + fi + say_verbose "$download_error_msg" + return 1 + fi + return 0 +} + + +# Updates global variables $http_code and $download_error_msg +downloadwget() { + eval $invocation + unset http_code + unset download_error_msg + local remote_path="$1" + local out_path="${2:-}" + # Append feed_credential as late as possible before calling wget to avoid logging feed_credential + local remote_path_with_credential="${remote_path}${feed_credential}" + local wget_options="--tries 20 " + + local wget_options_extra='' + local wget_result='' + + # Test for options that aren't supported on all wget implementations. + if [[ $(wget -h 2>&1 | grep -E 'waitretry|connect-timeout') ]]; then + wget_options_extra="--waitretry 2 --connect-timeout 15 " + else + say "wget extra options are unavailable for this environment" + fi + + if [ -z "$out_path" ]; then + wget -q $wget_options $wget_options_extra -O - "$remote_path_with_credential" 2>&1 + wget_result=$? + else + wget $wget_options $wget_options_extra -O "$out_path" "$remote_path_with_credential" 2>&1 + wget_result=$? + fi + + if [[ $wget_result != 0 ]]; then + local disable_feed_credential=false + local response=$(get_http_header_wget $remote_path $disable_feed_credential) + http_code=$( echo "$response" | awk '/^ HTTP/{print $2}' | tail -1 ) + download_error_msg="Unable to download $remote_path." + if [[ ! -z $http_code && $http_code != 2* ]]; then + download_error_msg+=" Returned HTTP status code: $http_code." + # wget exit code 4 stands for network-issue + elif [[ $wget_result == 4 ]]; then + download_error_msg+=" Failed to reach the server: connection timeout." + fi + say_verbose "$download_error_msg" + return 1 + fi + + return 0 +} + +get_download_link_from_aka_ms() { + eval $invocation + + #quality is not supported for LTS or STS channel + #STS maps to current + if [[ ! -z "$normalized_quality" && ("$normalized_channel" == "LTS" || "$normalized_channel" == "STS") ]]; then + normalized_quality="" + say_warning "Specifying quality for STS or LTS channel is not supported, the quality will be ignored." + fi + + say_verbose "Retrieving primary payload URL from aka.ms for channel: '$normalized_channel', quality: '$normalized_quality', product: '$normalized_product', os: '$normalized_os', architecture: '$normalized_architecture'." + + #construct aka.ms link + aka_ms_link="https://aka.ms/dotnet" + if [ "$internal" = true ]; then + aka_ms_link="$aka_ms_link/internal" + fi + aka_ms_link="$aka_ms_link/$normalized_channel" + if [[ ! -z "$normalized_quality" ]]; then + aka_ms_link="$aka_ms_link/$normalized_quality" + fi + aka_ms_link="$aka_ms_link/$normalized_product-$normalized_os-$normalized_architecture.tar.gz" + say_verbose "Constructed aka.ms link: '$aka_ms_link'." + + #get HTTP response + #do not pass credentials as a part of the $aka_ms_link and do not apply credentials in the get_http_header function + #otherwise the redirect link would have credentials as well + #it would result in applying credentials twice to the resulting link and thus breaking it, and in echoing credentials to the output as a part of redirect link + disable_feed_credential=true + response="$(get_http_header $aka_ms_link $disable_feed_credential)" + + say_verbose "Received response: $response" + # Get results of all the redirects. + http_codes=$( echo "$response" | awk '$1 ~ /^HTTP/ {print $2}' ) + # Allow intermediate 301 redirects and tolerate proxy-injected 200s + broken_redirects=$( echo "$http_codes" | sed '$d' | grep -vE '^(301|200)$' ) + # The response may end without final code 2xx/4xx/5xx somehow, e.g. network restrictions on www.bing.com causes redirecting to bing.com fails with connection refused. + # In this case it should not exclude the last. + last_http_code=$( echo "$http_codes" | tail -n 1 ) + if ! [[ $last_http_code =~ ^(2|4|5)[0-9][0-9]$ ]]; then + broken_redirects=$( echo "$http_codes" | grep -vE '^(301|200)$' ) + fi + + # All HTTP codes are 301 (Moved Permanently), the redirect link exists. + if [[ -z "$broken_redirects" ]]; then + aka_ms_download_link=$( echo "$response" | awk '$1 ~ /^Location/{print $2}' | tail -1 | tr -d '\r') + + if [[ -z "$aka_ms_download_link" ]]; then + say_verbose "The aka.ms link '$aka_ms_link' is not valid: failed to get redirect location." + return 1 + fi + + say_verbose "The redirect location retrieved: '$aka_ms_download_link'." + return 0 + else + say_verbose "The aka.ms link '$aka_ms_link' is not valid: received HTTP code: $(echo "$broken_redirects" | paste -sd "," -)." + return 1 + fi +} + +get_feeds_to_use() +{ + feeds=( + "https://builds.dotnet.microsoft.com/dotnet" + "https://ci.dot.net/public" + ) + + if [[ -n "$azure_feed" ]]; then + feeds=("$azure_feed") + fi + + if [[ -n "$uncached_feed" ]]; then + feeds=("$uncached_feed") + fi +} + +# THIS FUNCTION MAY EXIT (if the determined version is already installed). +generate_download_links() { + + download_links=() + specific_versions=() + effective_versions=() + link_types=() + + # If generate_akams_links returns false, no fallback to old links. Just terminate. + # This function may also 'exit' (if the determined version is already installed). + generate_akams_links || return + + # Check other feeds only if we haven't been able to find an aka.ms link. + if [[ "${#download_links[@]}" -lt 1 ]]; then + for feed in ${feeds[@]} + do + # generate_regular_links may also 'exit' (if the determined version is already installed). + generate_regular_links $feed || return + done + fi + + if [[ "${#download_links[@]}" -eq 0 ]]; then + say_err "Failed to resolve the exact version number." + return 1 + fi + + say_verbose "Generated ${#download_links[@]} links." + for link_index in ${!download_links[@]} + do + say_verbose "Link $link_index: ${link_types[$link_index]}, ${effective_versions[$link_index]}, ${download_links[$link_index]}" + done +} + +# THIS FUNCTION MAY EXIT (if the determined version is already installed). +generate_akams_links() { + local valid_aka_ms_link=true; + + normalized_version="$(to_lowercase "$version")" + if [[ "$normalized_version" != "latest" ]] && [ -n "$normalized_quality" ]; then + say_err "Quality and Version options are not allowed to be specified simultaneously. See https://learn.microsoft.com/dotnet/core/tools/dotnet-install-script#options for details." + return 1 + fi + + if [[ -n "$json_file" || "$normalized_version" != "latest" ]]; then + # aka.ms links are not needed when exact version is specified via command or json file + return + fi + + get_download_link_from_aka_ms || valid_aka_ms_link=false + + if [[ "$valid_aka_ms_link" == true ]]; then + say_verbose "Retrieved primary payload URL from aka.ms link: '$aka_ms_download_link'." + say_verbose "Downloading using legacy url will not be attempted." + + download_link=$aka_ms_download_link + + #get version from the path + IFS='/' + read -ra pathElems <<< "$download_link" + count=${#pathElems[@]} + specific_version="${pathElems[count-2]}" + unset IFS; + say_verbose "Version: '$specific_version'." + + #Retrieve effective version + effective_version="$(get_specific_product_version "$azure_feed" "$specific_version" "$download_link")" + + # Add link info to arrays + download_links+=($download_link) + specific_versions+=($specific_version) + effective_versions+=($effective_version) + link_types+=("aka.ms") + + # Check if the SDK version is already installed. + if [[ "$dry_run" != true ]] && is_dotnet_package_installed "$install_root" "$asset_relative_path" "$effective_version"; then + say "$asset_name with version '$effective_version' is already installed." + exit 0 + fi + + return 0 + fi + + # if quality is specified - exit with error - there is no fallback approach + if [ ! -z "$normalized_quality" ]; then + say_err "Failed to locate the latest version in the channel '$normalized_channel' with '$normalized_quality' quality for '$normalized_product', os: '$normalized_os', architecture: '$normalized_architecture'." + say_err "Refer to: https://aka.ms/dotnet-os-lifecycle for information on .NET Core support." + return 1 + fi + say_verbose "Falling back to latest.version file approach." +} + +# THIS FUNCTION MAY EXIT (if the determined version is already installed) +# args: +# feed - $1 +generate_regular_links() { + local feed="$1" + local valid_legacy_download_link=true + + specific_version=$(get_specific_version_from_version "$feed" "$channel" "$normalized_architecture" "$version" "$json_file") || specific_version='0' + + if [[ "$specific_version" == '0' ]]; then + say_verbose "Failed to resolve the specific version number using feed '$feed'" + return + fi + + effective_version="$(get_specific_product_version "$feed" "$specific_version")" + say_verbose "specific_version=$specific_version" + + download_link="$(construct_download_link "$feed" "$channel" "$normalized_architecture" "$specific_version" "$normalized_os")" + say_verbose "Constructed primary named payload URL: $download_link" + + # Add link info to arrays + download_links+=($download_link) + specific_versions+=($specific_version) + effective_versions+=($effective_version) + link_types+=("primary") + + legacy_download_link="$(construct_legacy_download_link "$feed" "$channel" "$normalized_architecture" "$specific_version")" || valid_legacy_download_link=false + + if [ "$valid_legacy_download_link" = true ]; then + say_verbose "Constructed legacy named payload URL: $legacy_download_link" + + download_links+=($legacy_download_link) + specific_versions+=($specific_version) + effective_versions+=($effective_version) + link_types+=("legacy") + else + legacy_download_link="" + say_verbose "Could not construct a legacy_download_link; omitting..." + fi + + # Check if the SDK version is already installed. + if [[ "$dry_run" != true ]] && is_dotnet_package_installed "$install_root" "$asset_relative_path" "$effective_version"; then + say "$asset_name with version '$effective_version' is already installed." + exit 0 + fi +} + +print_dry_run() { + + say "Payload URLs:" + + for link_index in "${!download_links[@]}" + do + say "URL #$link_index - ${link_types[$link_index]}: ${download_links[$link_index]}" + done + + resolved_version=${specific_versions[0]} + repeatable_command="./$script_name --version "\""$resolved_version"\"" --install-dir "\""$install_root"\"" --architecture "\""$normalized_architecture"\"" --os "\""$normalized_os"\""" + + if [ ! -z "$normalized_quality" ]; then + repeatable_command+=" --quality "\""$normalized_quality"\""" + fi + + if [[ "$runtime" == "dotnet" ]]; then + repeatable_command+=" --runtime "\""dotnet"\""" + elif [[ "$runtime" == "aspnetcore" ]]; then + repeatable_command+=" --runtime "\""aspnetcore"\""" + fi + + repeatable_command+="$non_dynamic_parameters" + + if [ -n "$feed_credential" ]; then + repeatable_command+=" --feed-credential "\"""\""" + fi + + say "Repeatable invocation: $repeatable_command" +} + +calculate_vars() { + eval $invocation + + script_name=$(basename "$0") + normalized_architecture="$(get_normalized_architecture_from_architecture "$architecture")" + say_verbose "Normalized architecture: '$normalized_architecture'." + normalized_os="$(get_normalized_os "$user_defined_os")" + say_verbose "Normalized OS: '$normalized_os'." + normalized_quality="$(get_normalized_quality "$quality")" + say_verbose "Normalized quality: '$normalized_quality'." + normalized_channel="$(get_normalized_channel "$channel")" + say_verbose "Normalized channel: '$normalized_channel'." + normalized_product="$(get_normalized_product "$runtime")" + say_verbose "Normalized product: '$normalized_product'." + install_root="$(resolve_installation_path "$install_dir")" + say_verbose "InstallRoot: '$install_root'." + + normalized_architecture="$(get_normalized_architecture_for_specific_sdk_version "$version" "$normalized_channel" "$normalized_architecture")" + + if [[ "$runtime" == "dotnet" ]]; then + asset_relative_path="shared/Microsoft.NETCore.App" + asset_name=".NET Core Runtime" + elif [[ "$runtime" == "aspnetcore" ]]; then + asset_relative_path="shared/Microsoft.AspNetCore.App" + asset_name="ASP.NET Core Runtime" + elif [ -z "$runtime" ]; then + asset_relative_path="sdk" + asset_name=".NET Core SDK" + fi + + get_feeds_to_use +} + +install_dotnet() { + eval $invocation + local download_failed=false + local download_completed=false + local remote_file_size=0 + + mkdir -p "$install_root" + zip_path="${zip_path:-$(mktemp "$temporary_file_template")}" + say_verbose "Archive path: $zip_path" + + for link_index in "${!download_links[@]}" + do + download_link="${download_links[$link_index]}" + specific_version="${specific_versions[$link_index]}" + effective_version="${effective_versions[$link_index]}" + link_type="${link_types[$link_index]}" + + say "Attempting to download using $link_type link $download_link" + + # The download function will set variables $http_code and $download_error_msg in case of failure. + download_failed=false + download "$download_link" "$zip_path" 2>&1 || download_failed=true + + if [ "$download_failed" = true ]; then + case ${http_code-} in + 404) + say "The resource at $link_type link '$download_link' is not available." + ;; + *) + say "Failed to download $link_type link '$download_link': ${http_code-} ${download_error_msg-}" + ;; + esac + rm -f "$zip_path" 2>&1 && say_verbose "Temporary archive file $zip_path was removed" + else + download_completed=true + break + fi + done + + if [[ "$download_completed" == false ]]; then + say_err "Could not find \`$asset_name\` with version = $specific_version" + say_err "Refer to: https://aka.ms/dotnet-os-lifecycle for information on .NET Core support" + return 1 + fi + + remote_file_size="$(get_remote_file_size "$download_link")" + + say "Extracting archive from $download_link" + extract_dotnet_package "$zip_path" "$install_root" "$remote_file_size" || return 1 + + # Check if the SDK version is installed; if not, fail the installation. + # if the version contains "RTM" or "servicing"; check if a 'release-type' SDK version is installed. + if [[ $specific_version == *"rtm"* || $specific_version == *"servicing"* ]]; then + IFS='-' + read -ra verArr <<< "$specific_version" + release_version="${verArr[0]}" + unset IFS; + say_verbose "Checking installation: version = $release_version" + if is_dotnet_package_installed "$install_root" "$asset_relative_path" "$release_version"; then + say "Installed version is $effective_version" + return 0 + fi + fi + + # Check if the standard SDK version is installed. + say_verbose "Checking installation: version = $effective_version" + if is_dotnet_package_installed "$install_root" "$asset_relative_path" "$effective_version"; then + say "Installed version is $effective_version" + return 0 + fi + + # Version verification failed. More likely something is wrong either with the downloaded content or with the verification algorithm. + say_err "Failed to verify the version of installed \`$asset_name\`.\nInstallation source: $download_link.\nInstallation location: $install_root.\nReport the bug at https://github.com/dotnet/install-scripts/issues." + say_err "\`$asset_name\` with version = $effective_version failed to install with an error." + return 1 +} + +args=("$@") + +local_version_file_relative_path="/.version" +bin_folder_relative_path="" +temporary_file_template="${TMPDIR:-/tmp}/dotnet.XXXXXXXXX" + +channel="LTS" +version="Latest" +json_file="" +install_dir="" +architecture="" +dry_run=false +no_path=false +azure_feed="" +uncached_feed="" +feed_credential="" +verbose=false +runtime="" +runtime_id="" +quality="" +internal=false +override_non_versioned_files=true +non_dynamic_parameters="" +user_defined_os="" + +while [ $# -ne 0 ] +do + name="$1" + case "$name" in + -c|--channel|-[Cc]hannel) + shift + channel="$1" + ;; + -v|--version|-[Vv]ersion) + shift + version="$1" + ;; + -q|--quality|-[Qq]uality) + shift + quality="$1" + ;; + --internal|-[Ii]nternal) + internal=true + non_dynamic_parameters+=" $name" + ;; + -i|--install-dir|-[Ii]nstall[Dd]ir) + shift + install_dir="$1" + ;; + --arch|--architecture|-[Aa]rch|-[Aa]rchitecture) + shift + architecture="$1" + ;; + --os|-[Oo][SS]) + shift + user_defined_os="$1" + ;; + --shared-runtime|-[Ss]hared[Rr]untime) + say_warning "The --shared-runtime flag is obsolete and may be removed in a future version of this script. The recommended usage is to specify '--runtime dotnet'." + if [ -z "$runtime" ]; then + runtime="dotnet" + fi + ;; + --runtime|-[Rr]untime) + shift + runtime="$1" + if [[ "$runtime" != "dotnet" ]] && [[ "$runtime" != "aspnetcore" ]]; then + say_err "Unsupported value for --runtime: '$1'. Valid values are 'dotnet' and 'aspnetcore'." + if [[ "$runtime" == "windowsdesktop" ]]; then + say_err "WindowsDesktop archives are manufactured for Windows platforms only." + fi + exit 1 + fi + ;; + --dry-run|-[Dd]ry[Rr]un) + dry_run=true + ;; + --no-path|-[Nn]o[Pp]ath) + no_path=true + non_dynamic_parameters+=" $name" + ;; + --verbose|-[Vv]erbose) + verbose=true + non_dynamic_parameters+=" $name" + ;; + --azure-feed|-[Aa]zure[Ff]eed) + shift + azure_feed="$1" + non_dynamic_parameters+=" $name "\""$1"\""" + ;; + --uncached-feed|-[Uu]ncached[Ff]eed) + shift + uncached_feed="$1" + non_dynamic_parameters+=" $name "\""$1"\""" + ;; + --feed-credential|-[Ff]eed[Cc]redential) + shift + feed_credential="$1" + #feed_credential should start with "?", for it to be added to the end of the link. + #adding "?" at the beginning of the feed_credential if needed. + [[ -z "$(echo $feed_credential)" ]] || [[ $feed_credential == \?* ]] || feed_credential="?$feed_credential" + ;; + --runtime-id|-[Rr]untime[Ii]d) + shift + runtime_id="$1" + non_dynamic_parameters+=" $name "\""$1"\""" + say_warning "Use of --runtime-id is obsolete and should be limited to the versions below 2.1. To override architecture, use --architecture option instead. To override OS, use --os option instead." + ;; + --jsonfile|-[Jj][Ss]on[Ff]ile) + shift + json_file="$1" + ;; + --skip-non-versioned-files|-[Ss]kip[Nn]on[Vv]ersioned[Ff]iles) + override_non_versioned_files=false + non_dynamic_parameters+=" $name" + ;; + --keep-zip|-[Kk]eep[Zz]ip) + keep_zip=true + non_dynamic_parameters+=" $name" + ;; + --zip-path|-[Zz]ip[Pp]ath) + shift + zip_path="$1" + ;; + -?|--?|-h|--help|-[Hh]elp) + script_name="dotnet-install.sh" + echo ".NET Tools Installer" + echo "Usage:" + echo " # Install a .NET SDK of a given Quality from a given Channel" + echo " $script_name [-c|--channel ] [-q|--quality ]" + echo " # Install a .NET SDK of a specific public version" + echo " $script_name [-v|--version ]" + echo " $script_name -h|-?|--help" + echo "" + echo "$script_name is a simple command line interface for obtaining dotnet cli." + echo " Note that the intended use of this script is for Continuous Integration (CI) scenarios, where:" + echo " - The SDK needs to be installed without user interaction and without admin rights." + echo " - The SDK installation doesn't need to persist across multiple CI runs." + echo " To set up a development environment or to run apps, use installers rather than this script. Visit https://dotnet.microsoft.com/download to get the installer." + echo "" + echo "Options:" + echo " -c,--channel Download from the channel specified, Defaults to \`$channel\`." + echo " -Channel" + echo " Possible values:" + echo " - STS - the most recent Standard Term Support release" + echo " - LTS - the most recent Long Term Support release" + echo " - 2-part version in a format A.B - represents a specific release" + echo " examples: 2.0; 1.0" + echo " - 3-part version in a format A.B.Cxx - represents a specific SDK release" + echo " examples: 5.0.1xx, 5.0.2xx." + echo " Supported since 5.0 release" + echo " Warning: Value 'Current' is deprecated for the Channel parameter. Use 'STS' instead." + echo " Note: The version parameter overrides the channel parameter when any version other than 'latest' is used." + echo " -v,--version Use specific VERSION, Defaults to \`$version\`." + echo " -Version" + echo " Possible values:" + echo " - latest - the latest build on specific channel" + echo " - 3-part version in a format A.B.C - represents specific version of build" + echo " examples: 2.0.0-preview2-006120; 1.1.0" + echo " -q,--quality Download the latest build of specified quality in the channel." + echo " -Quality" + echo " The possible values are: daily, preview, GA." + echo " Works only in combination with channel. Not applicable for STS and LTS channels and will be ignored if those channels are used." + echo " Supported since 5.0 release." + echo " Note: The version parameter overrides the channel parameter when any version other than 'latest' is used, and therefore overrides the quality." + echo " --internal,-Internal Download internal builds. Requires providing credentials via --feed-credential parameter." + echo " --feed-credential Token to access Azure feed. Used as a query string to append to the Azure feed." + echo " -FeedCredential This parameter typically is not specified." + echo " -i,--install-dir Install under specified location (see Install Location below)" + echo " -InstallDir" + echo " --architecture Architecture of dotnet binaries to be installed, Defaults to \`$architecture\`." + echo " --arch,-Architecture,-Arch" + echo " Possible values: x64, arm, arm64, s390x, ppc64le and loongarch64" + echo " --os Specifies operating system to be used when selecting the installer." + echo " Overrides the OS determination approach used by the script. Supported values: osx, linux, linux-musl, freebsd, rhel.6." + echo " In case any other value is provided, the platform will be determined by the script based on machine configuration." + echo " Not supported for legacy links. Use --runtime-id to specify platform for legacy links." + echo " Refer to: https://aka.ms/dotnet-os-lifecycle for more information." + echo " --runtime Installs a shared runtime only, without the SDK." + echo " -Runtime" + echo " Possible values:" + echo " - dotnet - the Microsoft.NETCore.App shared runtime" + echo " - aspnetcore - the Microsoft.AspNetCore.App shared runtime" + echo " --dry-run,-DryRun Do not perform installation. Display download link." + echo " --no-path, -NoPath Do not set PATH for the current process." + echo " --verbose,-Verbose Display diagnostics information." + echo " --azure-feed,-AzureFeed For internal use only." + echo " Allows using a different storage to download SDK archives from." + echo " --uncached-feed,-UncachedFeed For internal use only." + echo " Allows using a different storage to download SDK archives from." + echo " --skip-non-versioned-files Skips non-versioned files if they already exist, such as the dotnet executable." + echo " -SkipNonVersionedFiles" + echo " --jsonfile Determines the SDK version from a user specified global.json file." + echo " Note: global.json must have a value for 'SDK:Version'" + echo " --keep-zip,-KeepZip If set, downloaded file is kept." + echo " --zip-path, -ZipPath If set, downloaded file is stored at the specified path." + echo " -?,--?,-h,--help,-Help Shows this help message" + echo "" + echo "Install Location:" + echo " Location is chosen in following order:" + echo " - --install-dir option" + echo " - Environmental variable DOTNET_INSTALL_DIR" + echo " - $HOME/.dotnet" + exit 0 + ;; + *) + say_err "Unknown argument \`$name\`" + exit 1 + ;; + esac + + shift +done + +say_verbose "Note that the intended use of this script is for Continuous Integration (CI) scenarios, where:" +say_verbose "- The SDK needs to be installed without user interaction and without admin rights." +say_verbose "- The SDK installation doesn't need to persist across multiple CI runs." +say_verbose "To set up a development environment or to run apps, use installers rather than this script. Visit https://dotnet.microsoft.com/download to get the installer.\n" + +if [ "$internal" = true ] && [ -z "$(echo $feed_credential)" ]; then + message="Provide credentials via --feed-credential parameter." + if [ "$dry_run" = true ]; then + say_warning "$message" + else + say_err "$message" + exit 1 + fi +fi + +check_min_reqs +calculate_vars +# generate_regular_links call below will 'exit' if the determined version is already installed. +generate_download_links + +if [[ "$dry_run" = true ]]; then + print_dry_run + exit 0 +fi + +install_dotnet + +bin_path="$(get_absolute_path "$(combine_paths "$install_root" "$bin_folder_relative_path")")" +if [ "$no_path" = false ]; then + say "Adding to current process PATH: \`$bin_path\`. Note: This change will be visible only when sourcing script." + export PATH="$bin_path":"$PATH" +else + say "Binaries of dotnet can be found in $bin_path" +fi + +say "Note that the script does not resolve dependencies during installation." +say "To check the list of dependencies, go to https://learn.microsoft.com/dotnet/core/install, select your operating system and check the \"Dependencies\" section." +say "Installation finished successfully." diff --git a/global.json b/global.json index bb62055921f..06316314750 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.102", + "version": "10.0.201", "rollForward": "latestMinor" } } diff --git a/src/HotChocolate/Core/src/Execution.Abstractions/HotChocolate.Execution.Abstractions.csproj b/src/HotChocolate/Core/src/Execution.Abstractions/HotChocolate.Execution.Abstractions.csproj index 78c3a8dee0b..6b706af7cbe 100644 --- a/src/HotChocolate/Core/src/Execution.Abstractions/HotChocolate.Execution.Abstractions.csproj +++ b/src/HotChocolate/Core/src/Execution.Abstractions/HotChocolate.Execution.Abstractions.csproj @@ -17,6 +17,7 @@ + diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs index 2db769da056..6acf398580e 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs @@ -130,6 +130,38 @@ private static CompositeSchemaBuilderContext CreateTypes( } } + // Register the @defer directive so the gateway's validation accepts it. + // The gateway manages @defer itself (it does not pass it to subgraphs). + if (!directiveDefinitions.ContainsKey(DirectiveNames.Defer.Name)) + { + var deferDirectiveNode = new DirectiveDefinitionNode( + null, + new HotChocolate.Language.NameNode(DirectiveNames.Defer.Name), + null, + false, + new[] + { + new InputValueDefinitionNode( + null, + new HotChocolate.Language.NameNode(DirectiveNames.Defer.Arguments.If), + null, + new NamedTypeNode("Boolean"), + new BooleanValueNode(true), + []), + new InputValueDefinitionNode( + null, + new HotChocolate.Language.NameNode(DirectiveNames.Defer.Arguments.Label), + null, + new NamedTypeNode("String"), + null, + []) + }, + new HotChocolate.Language.NameNode[] { new("INLINE_FRAGMENT"), new("FRAGMENT_SPREAD") }); + + directiveTypes.Add(CreateDirectiveType(deferDirectiveNode)); + directiveDefinitions.Add(DirectiveNames.Defer.Name, deferDirectiveNode); + } + features ??= new FeatureCollection(); return new CompositeSchemaBuilderContext( diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ExecutionState.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ExecutionState.cs index fd6b5ac9475..36db6a61d06 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ExecutionState.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ExecutionState.cs @@ -521,7 +521,7 @@ private static bool ContainsDependent( }; } - private void AddToBacklog(ExecutionNode node) + internal void AddToBacklog(ExecutionNode node) { var nodeId = node.Id; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOperationInfo.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOperationInfo.cs index 010222210f1..779219f6da4 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOperationInfo.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOperationInfo.cs @@ -9,7 +9,7 @@ internal sealed class FusionOperationInfo : RequestFeature public OperationPlan? OperationPlan { get; set; } - protected override void Reset() + protected internal override void Reset() { OperationId = null; OperationPlan = null; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferredExecutionGroup.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferredExecutionGroup.cs new file mode 100644 index 00000000000..8e62f4f83d3 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferredExecutionGroup.cs @@ -0,0 +1,88 @@ +using System.Collections.Immutable; +using HotChocolate.Execution; + +namespace HotChocolate.Fusion.Execution.Nodes; + +/// +/// Represents a group of execution nodes that correspond to a single @defer fragment. +/// The gateway executes these nodes after the initial (non-deferred) result has been sent +/// and streams the result back as an incremental payload. +/// +public sealed class DeferredExecutionGroup +{ + /// + /// Initializes a new instance of . + /// + /// A unique identifier for this deferred payload, used in pending and completed entries. + /// The optional label from @defer(label: "..."). + /// The path in the result tree where deferred data will be inserted. + /// The variable name from @defer(if: $var), or null if unconditional. + /// The parent deferred group for nested @defer, or null for top-level. + /// The compiled operation for this deferred group's result mapping. + /// The root execution nodes that serve as entry points for this deferred group. + /// All execution nodes belonging to this deferred group. + public DeferredExecutionGroup( + int deferId, + string? label, + SelectionPath path, + string? ifVariable, + DeferredExecutionGroup? parent, + Operation operation, + ImmutableArray rootNodes, + ImmutableArray allNodes) + { + DeferId = deferId; + Label = label; + Path = path; + IfVariable = ifVariable; + Parent = parent; + Operation = operation; + RootNodes = rootNodes; + AllNodes = allNodes; + } + + /// + /// Gets the unique identifier for this deferred payload. + /// This ID is used in pending, incremental, and completed response entries. + /// + public int DeferId { get; } + + /// + /// Gets the optional label from @defer(label: "..."). + /// + public string? Label { get; } + + /// + /// Gets the path in the result tree where deferred data will be inserted. + /// + public SelectionPath Path { get; } + + /// + /// Gets the variable name from @defer(if: $var), + /// or null if this defer is unconditional. + /// + public string? IfVariable { get; } + + /// + /// Gets the parent deferred group for nested @defer, + /// or null for top-level deferred groups. + /// + public DeferredExecutionGroup? Parent { get; } + + /// + /// Gets the compiled operation for this deferred group. + /// This is a standalone operation compiled from the deferred fragment's AST, + /// used for result mapping during execution. + /// + public Operation Operation { get; } + + /// + /// Gets the root execution nodes that serve as entry points for this deferred group. + /// + public ImmutableArray RootNodes { get; } + + /// + /// Gets all execution nodes belonging to this deferred group. + /// + public ImmutableArray AllNodes { get; } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs index ec1196b1b0f..aafd67667c1 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs @@ -21,6 +21,7 @@ public sealed class Operation : IOperation private readonly OperationCompiler _compiler; private readonly IncludeConditionCollection _includeConditions; private readonly OperationFeatureCollection _features; + private readonly bool _hasIncrementalParts; private object[] _elementsById; private int _lastId; @@ -34,7 +35,8 @@ internal Operation( OperationCompiler compiler, IncludeConditionCollection includeConditions, int lastId, - object[] elementsById) + object[] elementsById, + bool hasIncrementalParts) { ArgumentException.ThrowIfNullOrWhiteSpace(id); ArgumentException.ThrowIfNullOrWhiteSpace(hash); @@ -56,6 +58,7 @@ internal Operation( _includeConditions = includeConditions; _lastId = lastId; _elementsById = elementsById; + _hasIncrementalParts = hasIncrementalParts; _features = new OperationFeatureCollection(); rootSelectionSet.Seal(this); @@ -105,7 +108,7 @@ ISelectionSet IOperation.RootSelectionSet /// public IFeatureCollection Features => _features; - public bool HasIncrementalParts => throw new NotImplementedException(); + public bool HasIncrementalParts => _hasIncrementalParts; /// /// Gets the selection set for the specified diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs index 1970fcac583..e4421a51df3 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs @@ -63,6 +63,8 @@ public Operation Compile(string id, string hash, OperationDefinitionNode operati fields, includeConditions); + var hasIncrementalParts = HasDeferDirective(operationDefinition); + var selectionSet = BuildSelectionSet( fields, rootType, @@ -81,7 +83,8 @@ public Operation Compile(string id, string hash, OperationDefinitionNode operati this, includeConditions, lastId, - compilationContext.ElementsById); // Pass the populated array + compilationContext.ElementsById, + hasIncrementalParts); } finally { @@ -396,6 +399,69 @@ private static bool IsInternal(FieldNode fieldNode) return false; } + private static bool HasDeferDirective(OperationDefinitionNode operation) + { + return DeferDetectionVisitor.Instance.HasDefer(operation); + } + + private sealed class DeferDetectionVisitor : SyntaxWalker + { + public static readonly DeferDetectionVisitor Instance = new(); + + public bool HasDefer(OperationDefinitionNode operation) + { + var context = new Context(); + Visit(operation, context); + return context.Found; + } + + protected override ISyntaxVisitorAction Enter( + InlineFragmentNode node, + Context context) + { + if (HasDeferDirectiveOnNode(node.Directives)) + { + context.Found = true; + return Break; + } + + return base.Enter(node, context); + } + + protected override ISyntaxVisitorAction Enter( + FragmentSpreadNode node, + Context context) + { + if (HasDeferDirectiveOnNode(node.Directives)) + { + context.Found = true; + return Break; + } + + return base.Enter(node, context); + } + + private static bool HasDeferDirectiveOnNode(IReadOnlyList directives) + { + for (var i = 0; i < directives.Count; i++) + { + if (directives[i].Name.Value.Equals( + DirectiveNames.Defer.Name, + StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + internal sealed class Context + { + public bool Found; + } + } + private class IncludeConditionVisitor : SyntaxWalker { public static readonly IncludeConditionVisitor Instance = new(); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationPlan.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationPlan.cs index 63bad9f78f2..0892e9e5ea5 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationPlan.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationPlan.cs @@ -21,7 +21,8 @@ private OperationPlan( ImmutableArray rootNodes, ImmutableArray allNodes, int searchSpace, - int expandedNodes) + int expandedNodes, + ImmutableArray deferredGroups = default) { Id = id; Operation = operation; @@ -29,6 +30,7 @@ private OperationPlan( AllNodes = allNodes; SearchSpace = searchSpace; ExpandedNodes = expandedNodes; + DeferredGroups = deferredGroups.IsDefault ? [] : deferredGroups; _nodesById = CreateNodeLookup(allNodes); MaxNodeId = _nodesById.Length > 0 ? _nodesById.Length - 1 : 0; } @@ -74,6 +76,14 @@ public IReadOnlyList VariableDefinitions /// public int ExpandedNodes { get; } + /// + /// Gets the deferred execution groups for this plan. + /// Each group corresponds to one @defer fragment and contains the execution nodes + /// that must run after the initial result is sent. + /// Empty if the operation has no @defer directives. + /// + public ImmutableArray DeferredGroups { get; } + /// /// Gets the maximum node identifier across all nodes in this plan. /// @@ -133,6 +143,7 @@ public ExecutionNode GetExecutionNode(IOperationPlanNode planNode) /// All execution nodes in the plan. /// A number specifying how many possible plans were considered during planning. /// The number of expanded nodes during planner search. + /// The deferred execution groups for @defer support. /// A new instance. /// Thrown when is null or empty. /// Thrown when is null. @@ -143,14 +154,15 @@ public static OperationPlan Create( ImmutableArray rootNodes, ImmutableArray allNodes, int searchSpace, - int expandedNodes) + int expandedNodes, + ImmutableArray deferredGroups = default) { ArgumentException.ThrowIfNullOrEmpty(id); ArgumentNullException.ThrowIfNull(operation); ArgumentOutOfRangeException.ThrowIfLessThan(rootNodes.Length, 0); ArgumentOutOfRangeException.ThrowIfLessThan(allNodes.Length, 0); - return new OperationPlan(id, operation, rootNodes, allNodes, searchSpace, expandedNodes); + return new OperationPlan(id, operation, rootNodes, allNodes, searchSpace, expandedNodes, deferredGroups); } /// @@ -162,6 +174,7 @@ public static OperationPlan Create( /// All execution nodes in the plan. /// A number specifying how many possible plans were considered during planning. /// The number of expanded nodes during planner search. + /// The deferred execution groups for @defer support. /// A new instance with a content-based identifier. /// Thrown when is null. /// Thrown when node arrays have negative length. @@ -170,7 +183,8 @@ public static OperationPlan Create( ImmutableArray rootNodes, ImmutableArray allNodes, int searchSpace, - int expandedNodes) + int expandedNodes, + ImmutableArray deferredGroups = default) { ArgumentNullException.ThrowIfNull(operation); ArgumentOutOfRangeException.ThrowIfLessThan(rootNodes.Length, 0); @@ -192,7 +206,7 @@ public static OperationPlan Create( var id = Convert.ToHexString(buffer.WrittenSpan[^32..]).ToLowerInvariant(); #endif - return new OperationPlan(id, operation, rootNodes, allNodes, searchSpace, expandedNodes); + return new OperationPlan(id, operation, rootNodes, allNodes, searchSpace, expandedNodes, deferredGroups); } private static ExecutionNode?[] CreateNodeLookup(ImmutableArray allNodes) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs index 728dc015d4b..b714447527a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs @@ -13,6 +13,7 @@ public sealed class Selection : ISelection private readonly FieldSelectionNode[] _syntaxNodes; private readonly ulong[] _includeFlags; private readonly byte[] _utf8ResponseName; + private readonly ulong _deferMask; private Flags _flags; public Selection( @@ -21,7 +22,8 @@ public Selection( IOutputFieldDefinition field, FieldSelectionNode[] syntaxNodes, ulong[] includeFlags, - bool isInternal) + bool isInternal, + ulong deferMask = 0) { ArgumentNullException.ThrowIfNull(field); @@ -37,6 +39,7 @@ public Selection( Field = field; _syntaxNodes = syntaxNodes; _includeFlags = includeFlags; + _deferMask = deferMask; _flags = isInternal ? Flags.Internal : Flags.None; if (field.Type.NamedType().IsLeafType()) @@ -162,7 +165,7 @@ internal void Seal(SelectionSet selectionSet) public bool IsDeferred(ulong deferFlags) { - throw new NotImplementedException(); + return (_deferMask & deferFlags) != 0; } [Flags] diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs index 8f8e08a96e2..243c0a88c36 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs @@ -16,9 +16,10 @@ public sealed class SelectionSet : ISelectionSet private readonly Selection[] _selections; private readonly FrozenDictionary _responseNameLookup; private readonly SelectionLookup _utf8ResponseNameLookup; + private readonly bool _hasIncrementalParts; private bool _isSealed; - public SelectionSet(int id, IObjectTypeDefinition type, Selection[] selections, bool isConditional) + public SelectionSet(int id, IObjectTypeDefinition type, Selection[] selections, bool isConditional, bool hasIncrementalParts = false) { ArgumentNullException.ThrowIfNull(selections); @@ -30,6 +31,7 @@ public SelectionSet(int id, IObjectTypeDefinition type, Selection[] selections, Id = id; Type = type; IsConditional = isConditional; + _hasIncrementalParts = hasIncrementalParts; _selections = selections; _responseNameLookup = _selections.ToFrozenDictionary(t => t.ResponseName); _utf8ResponseNameLookup = SelectionLookup.Create(this); @@ -62,7 +64,7 @@ public SelectionSet(int id, IObjectTypeDefinition type, Selection[] selections, /// public ReadOnlySpan Selections => _selections; - public bool HasIncrementalParts => throw new NotImplementedException(); + public bool HasIncrementalParts => _hasIncrementalParts; IEnumerable ISelectionSet.GetSelections() => _selections; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs index c788b30c214..fc0988e5f12 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Runtime.CompilerServices; +using System.Threading.Channels; using HotChocolate.Execution; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Language; @@ -46,6 +47,329 @@ public async Task ExecuteAsync( return context.Complete(); } + public async Task ExecuteWithDeferAsync( + RequestContext requestContext, + IVariableValueCollection variables, + OperationPlan operationPlan, + CancellationToken cancellationToken) + { + // Execute the main (non-deferred) plan nodes first. + var executionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + OperationPlanContext? context = null; + + try + { + context = requestContext.Schema.Services.GetRequiredService().Rent(); + context.Initialize(requestContext, variables, operationPlan, executionCts); + + context.Begin(); + + switch (operationPlan.Operation.Definition.Operation) + { + case OperationType.Query: + await ExecuteQueryAsync(context, operationPlan, executionCts.Token); + break; + + case OperationType.Mutation: + await ExecuteMutationAsync(context, operationPlan, executionCts.Token); + break; + + default: + throw new InvalidOperationException("Only queries and mutations can use @defer."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Build the initial result + var initialResult = context.Complete(); + + // Annotate the initial result with pending entries for each deferred group + var deferredGroups = operationPlan.DeferredGroups; + var pendingResults = ImmutableList.CreateBuilder(); + + foreach (var group in deferredGroups) + { + // Skip nested groups — they'll be announced when their parent completes + if (group.Parent is not null) + { + continue; + } + + // If the defer is conditional, evaluate the condition + if (group.IfVariable is not null) + { + if (!variables.TryGetValue(group.IfVariable, out var boolValue)) + { + throw new InvalidOperationException( + $"The variable {group.IfVariable} has an invalid value."); + } + + if (!boolValue.Value) + { + continue; + } + } + + pendingResults.Add(new PendingResult( + group.DeferId, + BuildPath(group.Path), + group.Label)); + } + + initialResult.HasNext = pendingResults.Count > 0; + initialResult.Pending = pendingResults.ToImmutable(); + + if (pendingResults.Count == 0) + { + // No active deferred groups (all conditions were false) + executionCts.Dispose(); + await context.DisposeAsync(); + return initialResult; + } + + // Return a ResponseStream that yields the initial result then deferred results + var stream = new ResponseStream( + () => CreateDeferredStream( + requestContext, + variables, + operationPlan, + initialResult, + cancellationToken), + ExecutionResultKind.DeferredResult); + + stream.RegisterForCleanup(context); + stream.RegisterForCleanup(executionCts); + return stream; + } + catch (Exception) + { + executionCts.Dispose(); + + if (context is { } c) + { + await c.DisposeAsync(); + } + + throw; + } + } + + private static async IAsyncEnumerable CreateDeferredStream( + RequestContext requestContext, + IVariableValueCollection variables, + OperationPlan operationPlan, + OperationResult initialResult, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + // Yield the initial result first + yield return initialResult; + + var deferredGroups = operationPlan.DeferredGroups; + + // Filter to only active groups (where @defer(if:) evaluates to true) + var activeGroups = new List(); + foreach (var group in deferredGroups) + { + if (group.IfVariable is not null) + { + if (!variables.TryGetValue(group.IfVariable, out var boolValue)) + { + throw new InvalidOperationException( + $"The variable {group.IfVariable} has an invalid value."); + } + + if (!boolValue.Value) + { + continue; + } + } + + // Only top-level groups start immediately; nested groups start when parent completes + if (group.Parent is null) + { + activeGroups.Add(group); + } + } + + // Execute all top-level deferred groups in parallel using a channel + var channel = Channel.CreateUnbounded<(DeferredExecutionGroup Group, OperationResult? Result, Exception? Error)>(); + var pendingCount = activeGroups.Count; + + foreach (var group in activeGroups) + { + _ = ExecuteDeferredGroupInBackground( + requestContext, variables, operationPlan, group, channel.Writer, cancellationToken); + } + + // Yield results as they complete + while (pendingCount > 0 && !cancellationToken.IsCancellationRequested) + { + var (group, result, error) = await channel.Reader.ReadAsync(cancellationToken); + pendingCount--; + + // Check if this group has children that should now start + var childGroups = new List(); + foreach (var candidate in deferredGroups) + { + if (candidate.Parent?.DeferId == group.DeferId) + { + childGroups.Add(candidate); + pendingCount++; + _ = ExecuteDeferredGroupInBackground( + requestContext, variables, operationPlan, candidate, channel.Writer, cancellationToken); + } + } + + // Build the incremental payload following the GraphQL incremental delivery spec: + // - Deferred data goes in `incremental` array (not top-level `data`) + // - `completed` signals the defer is done + // - `hasNext` indicates if more payloads follow + var isLast = pendingCount == 0; + OperationResult payload; + + if (error is not null) + { + var errorObj = ErrorBuilder.New() + .SetMessage(error.Message) + .Build(); + payload = OperationResult.FromError(errorObj); + payload.Completed = [new CompletedResult(group.DeferId, [errorObj])]; + } + else if (result is not null) + { + payload = result; + + // Wrap the deferred result's data in IncrementalObjectResult + // and clear top-level data/errors (per spec, subsequent payloads + // use `incremental` array, not root `data`). + if (result.Data.HasValue && !result.Data.Value.IsValueNull) + { + payload.Incremental = + [ + new IncrementalObjectResult( + group.DeferId, + result.Errors.Count > 0 ? result.Errors : null, + data: result.Data) + ]; + payload.Completed = [new CompletedResult(group.DeferId)]; + } + else + { + payload.Completed = [new CompletedResult(group.DeferId, result.Errors)]; + } + + // Per spec: subsequent payloads use `incremental` array, not root `data`. + // We clear top-level data/errors so the formatter only renders + // incremental delivery fields (incremental, completed, hasNext, pending). + // The IncrementalDataFeature must be set first (via Incremental/Completed above) + // so the Errors setter validation passes. + payload.Data = null; + if (payload.Errors.Count > 0) + { + payload.Errors = []; + } + } + else + { + // Empty deferred group — all fields may have been conditional and excluded. + // Report a successful completion with no data. + // We use FromError to create a valid OperationResult, then clear + // top-level errors since this is a successful completion. + var placeholder = ErrorBuilder.New() + .SetMessage("placeholder") + .Build(); + payload = OperationResult.FromError(placeholder); + payload.Completed = [new CompletedResult(group.DeferId)]; + payload.Data = null; + payload.Errors = []; + } + + // Announce child pending results + if (childGroups.Count > 0) + { + var childPending = ImmutableList.CreateBuilder(); + foreach (var child in childGroups) + { + childPending.Add(new PendingResult( + child.DeferId, + BuildPath(child.Path), + child.Label)); + } + payload.Pending = childPending.ToImmutable(); + } + + payload.HasNext = !isLast; + yield return payload; + } + } + + private static async Task ExecuteDeferredGroupInBackground( + RequestContext requestContext, + IVariableValueCollection variables, + OperationPlan operationPlan, + DeferredExecutionGroup group, + ChannelWriter<(DeferredExecutionGroup, OperationResult?, Exception?)> writer, + CancellationToken cancellationToken) + { + try + { + if (group.AllNodes.IsEmpty) + { + await writer.WriteAsync((group, null, null), cancellationToken); + return; + } + + // Create a mini OperationPlan for the deferred group using the group's + // own compiled Operation for correct result mapping. + var deferPlan = OperationPlan.Create( + operationPlan.Id + "#defer_" + group.DeferId, + group.Operation, + group.RootNodes, + group.AllNodes, + 0, + 0); + + using var executionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + await using var context = requestContext.Schema.Services + .GetRequiredService().Rent(); + context.Initialize(requestContext, variables, deferPlan, executionCts); + + context.Begin(); + + await ExecuteQueryAsync(context, deferPlan, executionCts.Token); + + var deferredResult = context.Complete(); + await writer.WriteAsync((group, deferredResult, null), cancellationToken); + } + catch (OperationCanceledException) + { + // Write a cancellation result so the consumer doesn't hang + await writer.WriteAsync((group, null, null), CancellationToken.None); + } + catch (Exception ex) + { + await writer.WriteAsync((group, null, ex), CancellationToken.None); + } + } + + private static Path BuildPath(SelectionPath selectionPath) + { + var path = Path.Root; + + for (var i = 0; i < selectionPath.Length; i++) + { + var segment = selectionPath[i]; + + if (segment.Kind is SelectionPathSegmentKind.Field) + { + path = path.Append(segment.Name); + } + } + + return path; + } + public async Task SubscribeAsync( RequestContext requestContext, OperationPlan operationPlan, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Pipeline/OperationExecutionMiddleware.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Pipeline/OperationExecutionMiddleware.cs index a6595cb8a26..d6bdfbc1e6b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Pipeline/OperationExecutionMiddleware.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Pipeline/OperationExecutionMiddleware.cs @@ -52,6 +52,18 @@ public async ValueTask InvokeAsync( { if (context.VariableValues.Length > 1) { + if (!operationPlan.DeferredGroups.IsEmpty) + { + var error = ErrorBuilder.New() + .SetMessage("Variable batching is not supported with @defer.") + .Build(); + + _diagnosticEvents.RequestError(context, error); + + context.Result = OperationResult.FromError(error); + return; + } + var variableValues = ImmutableCollectionsMarshal.AsArray(context.VariableValues).AsSpan(); var tasks = new Task[variableValues.Length]; @@ -67,6 +79,14 @@ public async ValueTask InvokeAsync( var results = ImmutableList.CreateRange(await Task.WhenAll(tasks)); context.Result = new OperationResultBatch(results); } + else if (!operationPlan.DeferredGroups.IsEmpty) + { + context.Result = await _planExecutor.ExecuteWithDeferAsync( + context, + context.VariableValues[0], + operationPlan, + cancellationToken); + } else { context.Result = await _planExecutor.ExecuteAsync( diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs new file mode 100644 index 00000000000..98b20a92a7d --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs @@ -0,0 +1,499 @@ +using System.Collections.Immutable; +using HotChocolate.Language; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Planning; + +/// +/// Splits an operation with @defer directives into a main operation +/// (without deferred inline fragments) and one or more deferred fragment operations. +/// +internal sealed class DeferOperationRewriter +{ + private int _nextDeferId; + + /// + /// Fast check whether the operation contains any @defer directives. + /// Used to avoid the full split for non-deferred operations (the common case). + /// + public static bool HasDeferDirective(OperationDefinitionNode operation) + { + return HasDeferInSelectionSet(operation.SelectionSet); + + static bool HasDeferInSelectionSet(SelectionSetNode selectionSet) + { + for (var i = 0; i < selectionSet.Selections.Count; i++) + { + var selection = selectionSet.Selections[i]; + + if (selection is InlineFragmentNode inlineFragment) + { + if (HasDeferDirective(inlineFragment)) + { + return true; + } + + if (HasDeferInSelectionSet(inlineFragment.SelectionSet)) + { + return true; + } + } + else if (selection is FragmentSpreadNode fragmentSpread) + { + if (HasDeferDirectiveOnSpread(fragmentSpread)) + { + return true; + } + } + else if (selection is FieldNode { SelectionSet: not null } field) + { + if (HasDeferInSelectionSet(field.SelectionSet)) + { + return true; + } + } + } + + return false; + } + + static bool HasDeferDirectiveOnSpread(FragmentSpreadNode node) + { + for (var i = 0; i < node.Directives.Count; i++) + { + if (node.Directives[i].Name.Value.Equals( + DirectiveNames.Defer.Name, + StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + } + + /// + /// Splits the given operation at @defer boundaries. + /// + /// The operation definition that may contain @defer directives. + /// + /// A result containing the stripped main operation and a list of deferred fragment descriptors. + /// + public DeferSplitResult Split(OperationDefinitionNode operation) + { + _nextDeferId = 0; + var deferredFragments = ImmutableArray.CreateBuilder(); + + var mainSelectionSet = StripDeferFragments( + operation.SelectionSet, + [], + deferredFragments, + operation, + parentDeferFragment: null); + + var mainOperation = operation.WithSelectionSet(mainSelectionSet); + + return new DeferSplitResult(mainOperation, deferredFragments.ToImmutable()); + } + + /// + /// Recursively walks selection sets, removes @defer inline fragments, + /// and creates deferred operations for each. + /// + private SelectionSetNode StripDeferFragments( + SelectionSetNode selectionSet, + ImmutableArray parentPath, + ImmutableArray.Builder deferredFragments, + OperationDefinitionNode rootOperation, + DeferredFragmentDescriptor? parentDeferFragment) + { + var selections = new List(selectionSet.Selections.Count); + var modified = false; + + for (var i = 0; i < selectionSet.Selections.Count; i++) + { + var selection = selectionSet.Selections[i]; + + if (selection is InlineFragmentNode inlineFragment && HasDeferDirective(inlineFragment)) + { + var (label, ifVariable) = ExtractDeferArgs(inlineFragment); + + // If @defer(if: false) literal, keep the fragment inline (don't defer) + if (ShouldSkipDefer(inlineFragment)) + { + var stripped = StripDeferDirective(inlineFragment); + var newInnerSelectionSet = StripDeferFragments( + stripped.SelectionSet, + parentPath, + deferredFragments, + rootOperation, + parentDeferFragment); + + if (!ReferenceEquals(newInnerSelectionSet, stripped.SelectionSet)) + { + stripped = stripped.WithSelectionSet(newInnerSelectionSet); + } + + selections.Add(stripped); + modified = true; + continue; + } + + var deferId = _nextDeferId++; + + // Create the descriptor first so nested defers can reference it as parent. + // We'll set the operation after stripping nested defers. + var descriptor = new DeferredFragmentDescriptor( + deferId, + label, + ifVariable, + parentPath, + null!, // placeholder — set below + parentDeferFragment); + + deferredFragments.Add(descriptor); + + // Recurse to strip nested @defer from the deferred fragment + var strippedInnerSelectionSet = StripDeferFragments( + inlineFragment.SelectionSet, + parentPath, + deferredFragments, + rootOperation, + parentDeferFragment: descriptor); + + // Build the deferred operation using the STRIPPED inner selections + var strippedFragment = StripDeferDirective(inlineFragment); + if (!ReferenceEquals(strippedInnerSelectionSet, inlineFragment.SelectionSet)) + { + strippedFragment = strippedFragment.WithSelectionSet(strippedInnerSelectionSet); + } + + var deferredOperation = BuildDeferredOperation( + rootOperation, + parentPath, + strippedFragment); + + // Now set the real operation on the descriptor + descriptor.Operation = deferredOperation; + + modified = true; + // Don't add this fragment to the main operation selections + continue; + } + + if (selection is FieldNode fieldNode && fieldNode.SelectionSet is not null) + { + // Recurse into child selection sets to find nested @defer + var childPath = parentPath.Add( + new FieldPathSegment(fieldNode.Name.Value, fieldNode.Alias?.Value)); + + var newChildSelectionSet = StripDeferFragments( + fieldNode.SelectionSet, + childPath, + deferredFragments, + rootOperation, + parentDeferFragment); + + if (!ReferenceEquals(newChildSelectionSet, fieldNode.SelectionSet)) + { + fieldNode = fieldNode.WithSelectionSet(newChildSelectionSet); + modified = true; + } + + selections.Add(fieldNode); + } + else if (selection is InlineFragmentNode nonDeferInlineFragment + && nonDeferInlineFragment.SelectionSet is not null) + { + // Recurse into non-defer inline fragments + var newInnerSelectionSet = StripDeferFragments( + nonDeferInlineFragment.SelectionSet, + parentPath, + deferredFragments, + rootOperation, + parentDeferFragment); + + if (!ReferenceEquals(newInnerSelectionSet, nonDeferInlineFragment.SelectionSet)) + { + nonDeferInlineFragment = nonDeferInlineFragment.WithSelectionSet(newInnerSelectionSet); + modified = true; + } + + selections.Add(nonDeferInlineFragment); + } + else + { + selections.Add(selection); + } + } + + if (!modified) + { + return selectionSet; + } + + // If removing deferred fragments left the selection set empty, add __typename + if (selections.Count == 0) + { + selections.Add(new FieldNode("__typename")); + } + + return new SelectionSetNode(selections); + } + + /// + /// Builds a standalone operation for a deferred fragment by wrapping + /// the deferred selections in the parent field path. + /// + private static OperationDefinitionNode BuildDeferredOperation( + OperationDefinitionNode rootOperation, + ImmutableArray parentPath, + InlineFragmentNode deferredFragment) + { + // Start with the deferred fragment's selection set + SelectionSetNode currentSelectionSet = deferredFragment.SelectionSet; + + // If the inline fragment has a type condition, wrap selections in the type condition + if (deferredFragment.TypeCondition is not null) + { + currentSelectionSet = new SelectionSetNode( + new ISelectionNode[] + { + new InlineFragmentNode( + null, + deferredFragment.TypeCondition, + deferredFragment.Directives.Count > 0 + ? deferredFragment.Directives + : [], + currentSelectionSet) + }); + } + + // Walk the path from innermost to outermost, wrapping in parent fields. + // We need to reconstruct the field nodes with their arguments to reach the deferred data. + // For this, we look up the original fields in the root operation's AST. + var pathFields = ResolvePathFields(rootOperation.SelectionSet, parentPath); + + for (var i = pathFields.Length - 1; i >= 0; i--) + { + var pathField = pathFields[i]; + var wrappingField = new FieldNode( + null, + pathField.Name, + pathField.Alias, + pathField.Directives, + pathField.Arguments, + currentSelectionSet); + currentSelectionSet = new SelectionSetNode(new ISelectionNode[] { wrappingField }); + } + + return rootOperation + .WithOperation(OperationType.Query) + .WithDirectives([]) + .WithSelectionSet(currentSelectionSet); + } + + /// + /// Resolves the FieldNode at each segment of the path by walking the operation's AST. + /// + private static ImmutableArray ResolvePathFields( + SelectionSetNode selectionSet, + ImmutableArray path) + { + var result = ImmutableArray.CreateBuilder(path.Length); + var current = selectionSet; + + for (var i = 0; i < path.Length; i++) + { + var segment = path[i]; + var field = FindField(current, segment); + + if (field is null) + { + break; + } + + result.Add(field); + + if (field.SelectionSet is not null) + { + current = field.SelectionSet; + } + } + + return result.ToImmutable(); + } + + private static FieldNode? FindField(SelectionSetNode selectionSet, FieldPathSegment segment) + { + for (var i = 0; i < selectionSet.Selections.Count; i++) + { + if (selectionSet.Selections[i] is FieldNode field) + { + var responseName = field.Alias?.Value ?? field.Name.Value; + + if (responseName.Equals(segment.ResponseName, StringComparison.Ordinal)) + { + return field; + } + } + + // Also look inside non-defer inline fragments + if (selectionSet.Selections[i] is InlineFragmentNode inlineFragment + && !HasDeferDirective(inlineFragment)) + { + var found = FindField(inlineFragment.SelectionSet, segment); + + if (found is not null) + { + return found; + } + } + } + + return null; + } + + private static InlineFragmentNode StripDeferDirective(InlineFragmentNode node) + { + var directives = new List(node.Directives.Count); + + for (var i = 0; i < node.Directives.Count; i++) + { + if (!node.Directives[i].Name.Value.Equals( + DirectiveNames.Defer.Name, + StringComparison.Ordinal)) + { + directives.Add(node.Directives[i]); + } + } + + return node.WithDirectives(directives); + } + + private static bool HasDeferDirective(InlineFragmentNode node) + { + for (var i = 0; i < node.Directives.Count; i++) + { + if (node.Directives[i].Name.Value.Equals( + DirectiveNames.Defer.Name, + StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static bool ShouldSkipDefer(InlineFragmentNode node) + { + for (var i = 0; i < node.Directives.Count; i++) + { + var directive = node.Directives[i]; + + if (!directive.Name.Value.Equals( + DirectiveNames.Defer.Name, + StringComparison.Ordinal)) + { + continue; + } + + for (var j = 0; j < directive.Arguments.Count; j++) + { + var arg = directive.Arguments[j]; + + if (arg.Name.Value.Equals("if", StringComparison.Ordinal) + && arg.Value is BooleanValueNode { Value: false }) + { + return true; + } + } + } + + return false; + } + + private static (string? Label, string? IfVariable) ExtractDeferArgs(InlineFragmentNode node) + { + string? label = null; + string? ifVariable = null; + + for (var i = 0; i < node.Directives.Count; i++) + { + var directive = node.Directives[i]; + + if (!directive.Name.Value.Equals( + DirectiveNames.Defer.Name, + StringComparison.Ordinal)) + { + continue; + } + + for (var j = 0; j < directive.Arguments.Count; j++) + { + var arg = directive.Arguments[j]; + + if (arg.Name.Value.Equals("label", StringComparison.Ordinal) + && arg.Value is StringValueNode labelValue) + { + label = labelValue.Value; + } + else if (arg.Name.Value.Equals("if", StringComparison.Ordinal) + && arg.Value is VariableNode variableNode) + { + ifVariable = variableNode.Name.Value; + } + } + + break; + } + + return (label, ifVariable); + } +} + +/// +/// The result of splitting an operation at @defer boundaries. +/// +internal readonly record struct DeferSplitResult( + OperationDefinitionNode MainOperation, + ImmutableArray DeferredFragments); + +/// +/// Describes a single @defer fragment extracted from an operation. +/// +internal sealed class DeferredFragmentDescriptor +{ + public DeferredFragmentDescriptor( + int deferId, + string? label, + string? ifVariable, + ImmutableArray path, + OperationDefinitionNode operation, + DeferredFragmentDescriptor? parent) + { + DeferId = deferId; + Label = label; + IfVariable = ifVariable; + Path = path; + Operation = operation; + Parent = parent; + } + + public int DeferId { get; } + public string? Label { get; } + public string? IfVariable { get; } + public ImmutableArray Path { get; } + public OperationDefinitionNode Operation { get; internal set; } + public DeferredFragmentDescriptor? Parent { get; } +} + +/// +/// A segment of a field path, identifying a field by its name and optional alias. +/// +internal readonly record struct FieldPathSegment(string FieldName, string? Alias) +{ + public string ResponseName => Alias ?? FieldName; +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs index e4710ba2ece..d65067cc46a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs @@ -24,7 +24,8 @@ private OperationPlan BuildExecutionPlan( OperationDefinitionNode operationDefinition, ImmutableList planSteps, int searchSpace, - int expandedNodes) + int expandedNodes, + ImmutableArray deferredGroups = default) { if (operation.IsIntrospectionOnly()) { @@ -73,7 +74,7 @@ private OperationPlan BuildExecutionPlan( node.Seal(); } - return OperationPlan.Create(operation, rootNodes, allNodes, searchSpace, expandedNodes); + return OperationPlan.Create(operation, rootNodes, allNodes, searchSpace, expandedNodes, deferredGroups); } private static ImmutableList TransformPlanSteps( @@ -122,6 +123,10 @@ private static ImmutableList TransformPlanSteps( operationPlanStep = updated; } + // Strip @defer directives from subgraph operations — the gateway + // manages deferral itself and subgraphs should not see @defer. + operationPlanStep = StripDeferDirectivesFromStep(operationPlanStep); + // Attach variable definitions so the operation is syntactically valid // when sent to the downstream service. updatedPlanSteps = updatedPlanSteps.Replace( @@ -159,6 +164,80 @@ static OperationPlanStep RemoveEmptySelectionSets(OperationPlanStep step) : step with { Definition = updatedDefinition }; } + static OperationPlanStep StripDeferDirectivesFromStep(OperationPlanStep step) + { + var updated = StripDeferFromSelectionSet(step.Definition.SelectionSet); + + if (ReferenceEquals(updated, step.Definition.SelectionSet)) + { + return step; + } + + return step with { Definition = step.Definition.WithSelectionSet(updated) }; + } + + static SelectionSetNode StripDeferFromSelectionSet(SelectionSetNode selectionSet) + { + List? rewritten = null; + + for (var i = 0; i < selectionSet.Selections.Count; i++) + { + var selection = selectionSet.Selections[i]; + + if (selection is InlineFragmentNode inlineFragment) + { + var strippedDirectives = StripDeferDirective(inlineFragment.Directives); + var strippedInner = StripDeferFromSelectionSet(inlineFragment.SelectionSet); + + if (!ReferenceEquals(strippedDirectives, inlineFragment.Directives) + || !ReferenceEquals(strippedInner, inlineFragment.SelectionSet)) + { + rewritten ??= new List(selectionSet.Selections); + rewritten[i] = inlineFragment + .WithDirectives(strippedDirectives) + .WithSelectionSet(strippedInner); + } + } + else if (selection is FieldNode { SelectionSet: not null } field) + { + var strippedInner = StripDeferFromSelectionSet(field.SelectionSet); + + if (!ReferenceEquals(strippedInner, field.SelectionSet)) + { + rewritten ??= new List(selectionSet.Selections); + rewritten[i] = field.WithSelectionSet(strippedInner); + } + } + } + + return rewritten is null ? selectionSet : new SelectionSetNode(rewritten); + } + + static IReadOnlyList StripDeferDirective(IReadOnlyList directives) + { + for (var i = 0; i < directives.Count; i++) + { + if (directives[i].Name.Value.Equals( + DirectiveNames.Defer.Name, + StringComparison.Ordinal)) + { + var result = new List(directives.Count - 1); + + for (var j = 0; j < directives.Count; j++) + { + if (j != i) + { + result.Add(directives[j]); + } + } + + return result; + } + } + + return directives; + } + static OperationPlanStep AddVariableDefinitions( OperationPlanStep step, ForwardVariableRewriter.Context forwardVariableContext) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs new file mode 100644 index 00000000000..34e19175b45 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs @@ -0,0 +1,187 @@ +using System.Collections.Immutable; +using HotChocolate.Execution; +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Language; + +namespace HotChocolate.Fusion.Planning; + +public sealed partial class OperationPlanner +{ + /// + /// Splits the operation at @defer boundaries, plans each deferred fragment + /// independently, and builds s. + /// + private ImmutableArray PlanDeferredGroups( + string id, + string hash, + string shortHash, + DeferSplitResult splitResult, + bool emitPlannerEvents, + CancellationToken cancellationToken) + { + if (splitResult.DeferredFragments.IsEmpty) + { + return []; + } + + var groups = ImmutableArray.CreateBuilder(splitResult.DeferredFragments.Length); + + // Map from DeferredFragmentDescriptor to DeferredExecutionGroup for parent lookups + var descriptorToGroup = new Dictionary(); + + foreach (var descriptor in splitResult.DeferredFragments) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Plan the deferred fragment as an independent query + var (deferredPlanSteps, deferredInternalOp) = PlanDeferredFragment( + id, + descriptor, + emitPlannerEvents, + cancellationToken); + + // Build execution nodes for this deferred group + var (rootNodes, allNodes) = BuildDeferredExecutionNodes( + deferredInternalOp ?? descriptor.Operation, + deferredPlanSteps); + + // Compile a standalone Operation for this deferred group's result mapping. + var compiledOp = deferredInternalOp ?? descriptor.Operation; + compiledOp = AddTypeNameToAbstractSelections( + compiledOp, + _schema.GetOperationType(compiledOp.Operation)); + var deferredOperation = _operationCompiler.Compile( + id + "#defer_" + descriptor.DeferId, + hash + "#defer_" + descriptor.DeferId, + compiledOp); + + // Convert the field path to a SelectionPath + var path = BuildSelectionPath(descriptor.Path); + + // Look up parent group + DeferredExecutionGroup? parentGroup = null; + if (descriptor.Parent is not null) + { + descriptorToGroup.TryGetValue(descriptor.Parent.DeferId, out parentGroup); + } + + var group = new DeferredExecutionGroup( + descriptor.DeferId, + descriptor.Label, + path, + descriptor.IfVariable, + parentGroup, + deferredOperation, + rootNodes, + allNodes); + + descriptorToGroup[descriptor.DeferId] = group; + groups.Add(group); + } + + return groups.ToImmutable(); + } + + /// + /// Plans a single deferred fragment using the A* planner. + /// + private (ImmutableList Steps, OperationDefinitionNode? InternalOp) PlanDeferredFragment( + string operationId, + DeferredFragmentDescriptor descriptor, + bool emitPlannerEvents, + CancellationToken cancellationToken) + { + var deferredOperation = descriptor.Operation; + + var index = SelectionSetIndexer.Create(deferredOperation); + + var (node, selectionSet) = CreateQueryPlanBase(deferredOperation, "defer", index); + + if (node.Backlog.IsEmpty) + { + return ([], null); + } + + var possiblePlans = new PlanQueue(_schema); + + foreach (var (schemaName, resolutionCost) in _schema.GetPossibleSchemas(selectionSet)) + { + possiblePlans.Enqueue( + node with + { + SchemaName = schemaName, + ResolutionCost = resolutionCost + }); + } + + if (possiblePlans.Count < 1) + { + possiblePlans.Enqueue(node); + } + + var plan = Plan(operationId + "#defer_" + descriptor.DeferId, possiblePlans, emitPlannerEvents, cancellationToken); + + if (!plan.HasValue) + { + return ([], null); + } + + return (plan.Value.Steps, plan.Value.InternalOperationDefinition); + } + + /// + /// Builds execution nodes for a deferred fragment's plan steps. + /// + private (ImmutableArray RootNodes, ImmutableArray AllNodes) BuildDeferredExecutionNodes( + OperationDefinitionNode deferredOperation, + ImmutableList planSteps) + { + if (planSteps.Count == 0) + { + return ([], []); + } + + var ctx = new ExecutionPlanBuildContext(); + var hasVariables = deferredOperation.VariableDefinitions.Count > 0; + + planSteps = TransformPlanSteps(planSteps, deferredOperation); + IndexDependencies(planSteps, ctx); + BuildExecutionNodes(planSteps, ctx, _schema, hasVariables); + MergeAndBatchOperations(ctx, _options.EnableRequestGrouping, _options.MergePolicy); + WireExecutionDependencies(ctx); + + var rootNodes = planSteps + .Where(t => !ctx.DependenciesByStepId.ContainsKey(t.Id) && ctx.ExecutionNodes.ContainsKey(t.Id)) + .Select(t => ctx.ExecutionNodes[t.Id]) + .ToImmutableArray(); + + var allNodes = ctx.ExecutionNodes + .OrderBy(t => t.Key) + .Select(t => t.Value) + .ToImmutableArray(); + + foreach (var node in allNodes) + { + node.Seal(); + } + + return (rootNodes, allNodes); + } + + private static SelectionPath BuildSelectionPath(ImmutableArray path) + { + if (path.IsEmpty) + { + return SelectionPath.Root; + } + + var builder = SelectionPath.Root; + + for (var i = 0; i < path.Length; i++) + { + builder = builder.AppendField(path[i].ResponseName); + } + + return builder; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs index ab4eb45402d..6706374eb83 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -91,21 +91,44 @@ public OperationPlan CreatePlan( try { + // Check for @defer directives before planning. If present, we split the + // operation into a main (non-deferred) part and deferred fragment groups. + // The main operation is planned without the deferred selections, and each + // deferred fragment is planned independently. + // + // PERF: For non-deferred operations (the common case), the only overhead is + // the HasDeferDirective check which does a fast AST walk looking for @defer. + ImmutableArray deferredGroups = default; + DeferSplitResult? deferSplit = null; + var mainOperationDefinition = operationDefinition; + + if (DeferOperationRewriter.HasDeferDirective(operationDefinition)) + { + var rewriter = new DeferOperationRewriter(); + var splitResult = rewriter.Split(operationDefinition); + + if (!splitResult.DeferredFragments.IsEmpty) + { + deferSplit = splitResult; + mainOperationDefinition = splitResult.MainOperation; + } + } + // We first need to create an index to keep track of the logical selections // sets before we can branch them. This allows us to inline requirements later // into the right place. - var index = SelectionSetIndexer.Create(operationDefinition); + var index = SelectionSetIndexer.Create(mainOperationDefinition); // Next, we create the seed plan with a set of initial work items exploring the root selection set. - var (node, selectionSet) = operationDefinition.Operation switch + var (node, selectionSet) = mainOperationDefinition.Operation switch { - OperationType.Query => CreateQueryPlanBase(operationDefinition, shortHash, index), - OperationType.Mutation => CreateMutationPlanBase(operationDefinition, shortHash, index), - OperationType.Subscription => CreateSubscriptionPlanBase(operationDefinition, shortHash, index), + OperationType.Query => CreateQueryPlanBase(mainOperationDefinition, shortHash, index), + OperationType.Mutation => CreateMutationPlanBase(mainOperationDefinition, shortHash, index), + OperationType.Subscription => CreateSubscriptionPlanBase(mainOperationDefinition, shortHash, index), _ => throw new ArgumentOutOfRangeException() }; - var internalOperationDefinition = operationDefinition; + var internalOperationDefinition = mainOperationDefinition; ImmutableList planSteps = []; // The backlog is only empty for pure introspection queries, which the @@ -150,16 +173,33 @@ node with internalOperationDefinition = AddTypeNameToAbstractSelections( internalOperationDefinition, - _schema.GetOperationType(operationDefinition.Operation)); + _schema.GetOperationType(mainOperationDefinition.Operation)); } + // Always compile from the planner's internal definition — for defer, + // this is the stripped main operation (without deferred fragments), + // which ensures the result mapper only includes non-deferred fields. var operation = _operationCompiler.Compile(id, hash, internalOperationDefinition); + + // Plan deferred groups if @defer was detected. + if (deferSplit.HasValue) + { + deferredGroups = PlanDeferredGroups( + id, + hash, + shortHash, + deferSplit.Value, + eventSourceEnabled, + cancellationToken); + } + var operationPlan = BuildExecutionPlan( operation, - operationDefinition, + mainOperationDefinition, planSteps, searchSpace, - expandedNodes); + expandedNodes, + deferredGroups); if (eventSourceEnabled) { diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs new file mode 100644 index 00000000000..389173e8f66 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs @@ -0,0 +1,616 @@ +using System.Text; +using System.Text.Json; +using HotChocolate.Transport; +using HotChocolate.Transport.Http; + +namespace HotChocolate.Fusion; + +public class DeferTests : FusionTestBase +{ + [Fact] + public async Task Defer_Single_Fragment_Returns_Incremental_Response() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + """ + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + email: String! + } + """); + + using var server2 = CreateSourceSchema( + "B", + """ + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + title: String! + body: String! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query GetUser { + user(id: "1") { + name + ... @defer(label: "reviews") { + reviews { + title + body + } + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert — parse the raw multipart body to verify the incremental delivery format. + // The transport OperationResult parser drops incremental fields, so we parse raw JSON. + var rawBody = await result.HttpResponseMessage.Content.ReadAsStringAsync(); + var payloads = rawBody + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => JsonDocument.Parse(line)) + .ToList(); + + Assert.Equal(2, payloads.Count); + + // --- Initial payload --- + var initial = payloads[0].RootElement; + + // Has data with user.name + Assert.True(initial.TryGetProperty("data", out var data)); + Assert.Equal("User: VXNlcjox", data.GetProperty("user").GetProperty("name").GetString()); + + // Has pending announcing the deferred group + Assert.True(initial.TryGetProperty("pending", out var pending)); + Assert.Equal(1, pending.GetArrayLength()); + Assert.Equal("0", pending[0].GetProperty("id").GetString()); + Assert.Equal("reviews", pending[0].GetProperty("label").GetString()); + + // Has hasNext = true + Assert.True(initial.GetProperty("hasNext").GetBoolean()); + + // --- Deferred payload --- + var deferred = payloads[1].RootElement; + + // No top-level data + Assert.False(deferred.TryGetProperty("data", out _)); + + // Has incremental with the deferred reviews + Assert.True(deferred.TryGetProperty("incremental", out var incremental)); + Assert.Equal(1, incremental.GetArrayLength()); + Assert.Equal("0", incremental[0].GetProperty("id").GetString()); + + var incrementalData = incremental[0].GetProperty("data"); + var reviews = incrementalData.GetProperty("user").GetProperty("reviews"); + Assert.Equal(3, reviews.GetArrayLength()); + + // Has completed + Assert.True(deferred.TryGetProperty("completed", out var completed)); + Assert.Equal(1, completed.GetArrayLength()); + Assert.Equal("0", completed[0].GetProperty("id").GetString()); + + // Has hasNext = false (last payload) + Assert.False(deferred.GetProperty("hasNext").GetBoolean()); + + foreach (var doc in payloads) + { + doc.Dispose(); + } + } + + [Fact] + public async Task Defer_IfFalse_Variable_Should_Return_NonStreamed_Result() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + """ + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + email: String! + } + """); + + using var server2 = CreateSourceSchema( + "B", + """ + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + title: String! + body: String! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + query: """ + query GetUser($shouldDefer: Boolean!) { + user(id: "1") { + name + ... @defer(if: $shouldDefer, label: "reviews") { + reviews { + title + body + } + } + } + } + """, + variables: new Dictionary { ["shouldDefer"] = false }); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert — when @defer(if: false), the plan still has DeferredGroups but the runtime + // evaluates the condition and skips them. With all groups skipped, the executor + // returns a plain OperationResult (not a ResponseStream), so the response is a + // single JSON payload with hasNext = false and no incremental delivery. + // + // Note: The deferred fields (reviews) are separated during planning, so they + // will NOT be in the initial result — @defer(if: false) currently does not + // re-inline them. This is consistent with the incremental delivery spec: + // the gateway simply returns hasNext = false with no pending groups. + var rawBody = await result.HttpResponseMessage.Content.ReadAsStringAsync(); + var payloads = rawBody + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => JsonDocument.Parse(line)) + .ToList(); + + Assert.Single(payloads); + + var initial = payloads[0].RootElement; + + // Has data with user.name + Assert.True(initial.TryGetProperty("data", out var data)); + Assert.Equal("User: VXNlcjox", data.GetProperty("user").GetProperty("name").GetString()); + + // hasNext should be false — no deferred groups are active + Assert.True(initial.TryGetProperty("hasNext", out var hasNext)); + Assert.False(hasNext.GetBoolean()); + + // No pending or incremental entries since all deferred groups were skipped + Assert.False(initial.TryGetProperty("pending", out _)); + Assert.False(initial.TryGetProperty("incremental", out _)); + + foreach (var doc in payloads) + { + doc.Dispose(); + } + } + + [Fact] + public async Task Defer_Nested_Should_Return_Incremental_Response_In_Order() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + """ + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """); + + using var server2 = CreateSourceSchema( + "B", + """ + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + address: String! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query { + user(id: "1") { + name + ... @defer(label: "outer") { + email + ... @defer(label: "inner") { + address + } + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert — nested @defer produces multiple incremental payloads. + // The initial payload has name; subsequent payloads deliver email then address. + var rawBody = await result.HttpResponseMessage.Content.ReadAsStringAsync(); + var payloads = rawBody + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => JsonDocument.Parse(line)) + .ToList(); + + // At minimum: initial payload + outer deferred + inner deferred + Assert.True(payloads.Count >= 3, $"Expected at least 3 payloads but got {payloads.Count}"); + + // --- Initial payload --- + var initial = payloads[0].RootElement; + Assert.True(initial.TryGetProperty("data", out var data)); + Assert.Equal("User: VXNlcjox", data.GetProperty("user").GetProperty("name").GetString()); + Assert.True(initial.GetProperty("hasNext").GetBoolean()); + + // --- Last payload --- + var last = payloads[^1].RootElement; + Assert.False(last.GetProperty("hasNext").GetBoolean()); + + foreach (var doc in payloads) + { + doc.Dispose(); + } + } + + [Fact] + public async Task Defer_With_Error_In_Deferred_Fragment_Should_Return_Error_In_Incremental_Payload() + { + // arrange — source B's email field is annotated with @error, which causes the + // mock resolver to throw a GraphQLException. The gateway should propagate that + // error in the deferred incremental payload. + using var server1 = CreateSourceSchema( + "A", + """ + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """); + + using var server2 = CreateSourceSchema( + "B", + """ + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! @error + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query GetUser { + user(id: "1") { + name + ... @defer(label: "email") { + email + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + var rawBody = await result.HttpResponseMessage.Content.ReadAsStringAsync(); + var payloads = rawBody + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => JsonDocument.Parse(line)) + .ToList(); + + Assert.Equal(2, payloads.Count); + + // --- Initial payload --- + var initial = payloads[0].RootElement; + Assert.True(initial.TryGetProperty("data", out var data)); + Assert.Equal("User: VXNlcjox", data.GetProperty("user").GetProperty("name").GetString()); + Assert.True(initial.GetProperty("hasNext").GetBoolean()); + + // --- Deferred payload should contain error information --- + var deferred = payloads[1].RootElement; + Assert.False(deferred.GetProperty("hasNext").GetBoolean()); + + // The deferred payload should have completed with errors, or have errors + // in the incremental entry. Check both patterns. + var hasErrors = deferred.TryGetProperty("errors", out var topErrors) && topErrors.GetArrayLength() > 0; + var hasCompletedWithErrors = false; + var hasIncrementalErrors = false; + + if (deferred.TryGetProperty("completed", out var completed)) + { + foreach (var entry in completed.EnumerateArray()) + { + if (entry.TryGetProperty("errors", out var completedErrors) + && completedErrors.GetArrayLength() > 0) + { + hasCompletedWithErrors = true; + } + } + } + + if (deferred.TryGetProperty("incremental", out var incremental)) + { + foreach (var entry in incremental.EnumerateArray()) + { + if (entry.TryGetProperty("errors", out var incrementalErrors) + && incrementalErrors.GetArrayLength() > 0) + { + hasIncrementalErrors = true; + } + } + } + + Assert.True( + hasErrors || hasCompletedWithErrors || hasIncrementalErrors, + "Expected the deferred payload to contain an error from the source schema's @error directive."); + + foreach (var doc in payloads) + { + doc.Dispose(); + } + } + + [Fact(Skip = "Requires validation of @skip/@include interaction with @defer at the planning level")] + public async Task Defer_With_Skip_Directive_Should_Skip_Deferred_Fragment() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + """ + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + email: String! + } + """); + + using var server2 = CreateSourceSchema( + "B", + """ + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + title: String! + body: String! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query GetUser { + user(id: "1") { + name + ... @defer(label: "reviews") @include(if: false) { + reviews { + title + body + } + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert — with @include(if: false), the deferred fragment should be entirely + // removed during planning, resulting in a single non-incremental response. + var rawBody = await result.HttpResponseMessage.Content.ReadAsStringAsync(); + var payloads = rawBody + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => JsonDocument.Parse(line)) + .ToList(); + + Assert.Single(payloads); + + var initial = payloads[0].RootElement; + Assert.True(initial.TryGetProperty("data", out var data)); + Assert.Equal("User: VXNlcjox", data.GetProperty("user").GetProperty("name").GetString()); + + // Reviews should NOT be in the response since the fragment was skipped + Assert.False(data.GetProperty("user").TryGetProperty("reviews", out _)); + + // No incremental delivery + Assert.False(initial.TryGetProperty("pending", out _)); + Assert.False(initial.TryGetProperty("incremental", out _)); + + foreach (var doc in payloads) + { + doc.Dispose(); + } + } + + [Fact(Skip = "Known limitation: @defer on mutations forces Query operation type in deferred plan")] + public async Task Defer_On_Mutation_Result_Should_Return_Incremental_Response() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + """ + type Query { + productById(id: ID!): Product @lookup + } + + type Mutation { + createProduct(name: String!): Product! + } + + type Product @key(fields: "id") { + id: ID! + name: String! + } + """); + + using var server2 = CreateSourceSchema( + "B", + """ + type Query { + productById(id: ID!): Product @lookup + } + + type Product @key(fields: "id") { + id: ID! + price: Float! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + mutation { + createProduct(name: "Widget") { + name + ... @defer(label: "pricing") { + price + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert — initial payload should have the mutation result with name, + // deferred payload should deliver the price from source B. + var rawBody = await result.HttpResponseMessage.Content.ReadAsStringAsync(); + var payloads = rawBody + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => JsonDocument.Parse(line)) + .ToList(); + + Assert.Equal(2, payloads.Count); + + // --- Initial payload --- + var initial = payloads[0].RootElement; + Assert.True(initial.TryGetProperty("data", out var data)); + Assert.True(data.TryGetProperty("createProduct", out var product)); + Assert.Equal("Product: UHJvZHVjdDox", product.GetProperty("name").GetString()); + Assert.True(initial.GetProperty("hasNext").GetBoolean()); + + // --- Deferred payload --- + var deferred = payloads[1].RootElement; + Assert.True(deferred.TryGetProperty("incremental", out var incremental)); + Assert.Equal(1, incremental.GetArrayLength()); + + var incrementalData = incremental[0].GetProperty("data"); + Assert.True( + incrementalData.GetProperty("createProduct").TryGetProperty("price", out _)); + + Assert.False(deferred.GetProperty("hasNext").GetBoolean()); + + foreach (var doc in payloads) + { + doc.Dispose(); + } + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/__snapshots__/SerializeAsTests.SerializeAs_Will_Be_In_The_Schema.graphql b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/__snapshots__/SerializeAsTests.SerializeAs_Will_Be_In_The_Schema.graphql index 80934e8675e..9de842442c8 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/__snapshots__/SerializeAsTests.SerializeAs_Will_Be_In_The_Schema.graphql +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/__snapshots__/SerializeAsTests.SerializeAs_Will_Be_In_The_Schema.graphql @@ -97,4 +97,6 @@ enum ScalarSerializationType { scalar Custom @serializeAs(type: STRING) +directive @defer(if: Boolean = true label: String) on FRAGMENT_SPREAD | INLINE_FRAGMENT + directive @serializeAs(pattern: String type: [ScalarSerializationType!]!) on SCALAR diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/__snapshots__/SerializeAsTests.SerializeAs_Will_Not_Be_In_The_Schema.graphql b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/__snapshots__/SerializeAsTests.SerializeAs_Will_Not_Be_In_The_Schema.graphql index d8dab884c12..f93397c77d6 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/__snapshots__/SerializeAsTests.SerializeAs_Will_Not_Be_In_The_Schema.graphql +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/__snapshots__/SerializeAsTests.SerializeAs_Will_Not_Be_In_The_Schema.graphql @@ -95,3 +95,5 @@ enum ScalarSerializationType { } scalar Custom + +directive @defer(if: Boolean = true label: String) on FRAGMENT_SPREAD | INLINE_FRAGMENT diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/DeferPlannerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/DeferPlannerTests.cs new file mode 100644 index 00000000000..8bb89e41292 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/DeferPlannerTests.cs @@ -0,0 +1,606 @@ +namespace HotChocolate.Fusion.Planning; + +public class DeferPlannerTests : FusionTestBase +{ + [Fact] + public void Defer_SingleFragment_ProducesDeferredGroup() + { + // arrange + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer { + email + } + } + } + """); + + // assert + Assert.Single(plan.DeferredGroups); + + var group = plan.DeferredGroups[0]; + Assert.Equal(0, group.DeferId); + Assert.Null(group.Label); + Assert.Equal("$.user", group.Path.ToString()); + } + + [Fact] + public void Defer_MultipleFragments_ProducesMultipleDeferredGroups() + { + // arrange + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + profile: Profile! + } + + type Profile { + bio: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer(label: "emailDefer") { + email + } + profile { + ... @defer(label: "bioDefer") { + bio + } + } + } + } + """); + + // assert + Assert.Equal(2, plan.DeferredGroups.Length); + + var emailGroup = plan.DeferredGroups.First(g => g.Label == "emailDefer"); + var bioGroup = plan.DeferredGroups.First(g => g.Label == "bioDefer"); + + Assert.NotNull(emailGroup); + Assert.NotNull(bioGroup); + Assert.NotEqual(emailGroup.DeferId, bioGroup.DeferId); + } + + [Fact] + public void Defer_WithLabel_LabelIsPropagated() + { + // arrange + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer(label: "myLabel") { + email + } + } + } + """); + + // assert + Assert.Single(plan.DeferredGroups); + Assert.Equal("myLabel", plan.DeferredGroups[0].Label); + } + + [Fact] + public void Defer_OperationHasIncrementalParts() + { + // arrange + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer { + email + } + } + } + """); + + // assert + Assert.False(plan.DeferredGroups.IsEmpty); + } + + [Fact] + public void Defer_NoDefer_NoDeferredGroups() + { + // arrange + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + email + } + } + """); + + // assert + Assert.True(plan.DeferredGroups.IsEmpty); + Assert.False(plan.Operation.HasIncrementalParts); + } + + [Fact] + public void Defer_ConditionalVariable_IfVariableRecorded() + { + // arrange + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query ($shouldDefer: Boolean!) { + user(id: "1") { + name + ... @defer(if: $shouldDefer) { + email + } + } + } + """); + + // assert + Assert.Single(plan.DeferredGroups); + Assert.Equal("shouldDefer", plan.DeferredGroups[0].IfVariable); + } + + [Fact] + public void Defer_MainPlanStillExecutes() + { + // arrange + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer { + email + } + } + } + """); + + // assert + Assert.NotEmpty(plan.RootNodes); + Assert.NotEmpty(plan.AllNodes); + + // The deferred group should also have its own execution nodes + var deferredGroup = plan.DeferredGroups[0]; + Assert.False(deferredGroup.RootNodes.IsEmpty); + Assert.False(deferredGroup.AllNodes.IsEmpty); + Assert.Null(deferredGroup.Parent); + } + + [Fact] + public void Defer_IfFalseLiteral_Should_ProduceNoDeferredGroups() + { + // arrange + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer(if: false) { + email + } + } + } + """); + + // assert + Assert.True(plan.DeferredGroups.IsEmpty); + } + + [Fact] + public void Defer_IfTrueLiteral_Should_ProduceDeferredGroup() + { + // arrange + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer(if: true) { + email + } + } + } + """); + + // assert + Assert.Single(plan.DeferredGroups); + Assert.Null(plan.DeferredGroups[0].IfVariable); + } + + [Fact] + public void Defer_NestedDefer_Should_ProduceParentChildRelationship() + { + // arrange + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + address: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer(label: "outer") { + email + ... @defer(label: "inner") { + address + } + } + } + } + """); + + // assert + Assert.Equal(2, plan.DeferredGroups.Length); + + var outerGroup = plan.DeferredGroups.First(g => g.Label == "outer"); + var innerGroup = plan.DeferredGroups.First(g => g.Label == "inner"); + + Assert.Null(outerGroup.Parent); + Assert.NotNull(innerGroup.Parent); + Assert.Equal(outerGroup.DeferId, innerGroup.Parent.DeferId); + } + + [Fact] + public void Defer_WithIncludeDirective_Should_ProduceDeferredGroup() + { + // arrange + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer @include(if: true) { + email + } + } + } + """); + + // assert + Assert.Single(plan.DeferredGroups); + } + + [Fact(Skip = "Known bug: BuildDeferredOperation forces OperationType.Query, causing KeyNotFoundException for mutation fields")] + public void Defer_OnMutationResult_Should_ProduceDeferredGroup() + { + // arrange + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type Mutation { + createUser(name: String!): User! + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + mutation { + createUser(name: "test") { + name + ... @defer { + email + } + } + } + """); + + // assert + Assert.Single(plan.DeferredGroups); + + var group = plan.DeferredGroups[0]; + Assert.False(group.RootNodes.IsEmpty); + Assert.False(group.AllNodes.IsEmpty); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/LookupTests.Require_Inaccessible_Data.graphql b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/LookupTests.Require_Inaccessible_Data.graphql index f4078625107..d960b4a6c11 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/LookupTests.Require_Inaccessible_Data.graphql +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/LookupTests.Require_Inaccessible_Data.graphql @@ -15,3 +15,5 @@ type Product { id: Int! name: String! } + +directive @defer(if: Boolean = true label: String) on FRAGMENT_SPREAD | INLINE_FRAGMENT From 489e2345f5ea46954c09a68a91b76bd34f665625 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 8 Apr 2026 12:37:09 +0000 Subject: [PATCH 2/9] edits --- .../Execution/Nodes/OperationPlan.cs | 22 +- .../JsonOperationPlanFormatter.cs | 48 ++++ .../Serialization/JsonOperationPlanParser.cs | 59 +++++ .../YamlOperationPlanFormatter.cs | 86 +++++- .../Execution/OperationPlanExecutor.cs | 1 + .../Planning/DeferOperationRewriter.cs | 23 +- .../OperationPlanner.BuildExecutionTree.cs | 6 +- .../Planning/OperationPlanner.cs | 2 +- .../Fusion.AspNetCore.Tests/DeferTests.cs | 230 +++++----------- ...able_Should_Return_NonStreamed_Result.yaml | 246 ++++++++++++++++++ ...ariable_Should_Return_Streamed_Result.yaml | 241 +++++++++++++++++ ..._Return_Incremental_Response_In_Order.yaml | 210 +++++++++++++++ ...Fragment_Returns_Incremental_Response.yaml | 184 +++++++++++++ ...d_Return_Error_In_Incremental_Payload.yaml | 163 ++++++++++++ 14 files changed, 1329 insertions(+), 192 deletions(-) create mode 100644 src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfFalse_Variable_Should_Return_NonStreamed_Result.yaml create mode 100644 src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfTrue_Variable_Should_Return_Streamed_Result.yaml create mode 100644 src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Should_Return_Incremental_Response_In_Order.yaml create mode 100644 src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Single_Fragment_Returns_Incremental_Response.yaml create mode 100644 src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Error_In_Deferred_Fragment_Should_Return_Error_In_Incremental_Payload.yaml diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationPlan.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationPlan.cs index 0892e9e5ea5..5e836df2250 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationPlan.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationPlan.cs @@ -20,9 +20,9 @@ private OperationPlan( Operation operation, ImmutableArray rootNodes, ImmutableArray allNodes, + ImmutableArray deferredGroups, int searchSpace, - int expandedNodes, - ImmutableArray deferredGroups = default) + int expandedNodes) { Id = id; Operation = operation; @@ -30,7 +30,7 @@ private OperationPlan( AllNodes = allNodes; SearchSpace = searchSpace; ExpandedNodes = expandedNodes; - DeferredGroups = deferredGroups.IsDefault ? [] : deferredGroups; + DeferredGroups = deferredGroups; _nodesById = CreateNodeLookup(allNodes); MaxNodeId = _nodesById.Length > 0 ? _nodesById.Length - 1 : 0; } @@ -141,9 +141,9 @@ public ExecutionNode GetExecutionNode(IOperationPlanNode planNode) /// The GraphQL operation. /// The root execution nodes. /// All execution nodes in the plan. + /// The deferred execution groups for @defer support. /// A number specifying how many possible plans were considered during planning. /// The number of expanded nodes during planner search. - /// The deferred execution groups for @defer support. /// A new instance. /// Thrown when is null or empty. /// Thrown when is null. @@ -153,16 +153,16 @@ public static OperationPlan Create( Operation operation, ImmutableArray rootNodes, ImmutableArray allNodes, + ImmutableArray deferredGroups, int searchSpace, - int expandedNodes, - ImmutableArray deferredGroups = default) + int expandedNodes) { ArgumentException.ThrowIfNullOrEmpty(id); ArgumentNullException.ThrowIfNull(operation); ArgumentOutOfRangeException.ThrowIfLessThan(rootNodes.Length, 0); ArgumentOutOfRangeException.ThrowIfLessThan(allNodes.Length, 0); - return new OperationPlan(id, operation, rootNodes, allNodes, searchSpace, expandedNodes, deferredGroups); + return new OperationPlan(id, operation, rootNodes, allNodes, deferredGroups, searchSpace, expandedNodes); } /// @@ -172,9 +172,9 @@ public static OperationPlan Create( /// The GraphQL operation. /// The root execution nodes. /// All execution nodes in the plan. + /// The deferred execution groups for @defer support. /// A number specifying how many possible plans were considered during planning. /// The number of expanded nodes during planner search. - /// The deferred execution groups for @defer support. /// A new instance with a content-based identifier. /// Thrown when is null. /// Thrown when node arrays have negative length. @@ -182,9 +182,9 @@ public static OperationPlan Create( Operation operation, ImmutableArray rootNodes, ImmutableArray allNodes, + ImmutableArray deferredGroups, int searchSpace, - int expandedNodes, - ImmutableArray deferredGroups = default) + int expandedNodes) { ArgumentNullException.ThrowIfNull(operation); ArgumentOutOfRangeException.ThrowIfLessThan(rootNodes.Length, 0); @@ -206,7 +206,7 @@ public static OperationPlan Create( var id = Convert.ToHexString(buffer.WrittenSpan[^32..]).ToLowerInvariant(); #endif - return new OperationPlan(id, operation, rootNodes, allNodes, searchSpace, expandedNodes, deferredGroups); + return new OperationPlan(id, operation, rootNodes, allNodes, deferredGroups, searchSpace, expandedNodes); } private static ExecutionNode?[] CreateNodeLookup(ImmutableArray allNodes) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs index 03094025086..67d329204b1 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs @@ -83,6 +83,54 @@ public void Format(IBufferWriter writer, OperationPlan plan, OperationPlan jsonWriter.WritePropertyName("nodes"); WriteNodes(jsonWriter, plan.Operation, plan.AllNodes, trace); + if (!plan.DeferredGroups.IsDefaultOrEmpty) + { + jsonWriter.WritePropertyName("deferredGroups"); + jsonWriter.WriteStartArray(); + + foreach (var group in plan.DeferredGroups) + { + jsonWriter.WriteStartObject(); + + jsonWriter.WritePropertyName("deferId"); + jsonWriter.WriteNumberValue(group.DeferId); + + if (group.Label is not null) + { + jsonWriter.WritePropertyName("label"); + jsonWriter.WriteStringValue(group.Label); + } + + jsonWriter.WritePropertyName("path"); + jsonWriter.WriteStringValue(group.Path.ToString()); + + if (group.IfVariable is not null) + { + jsonWriter.WritePropertyName("ifVariable"); + jsonWriter.WriteStringValue("$" + group.IfVariable); + } + + if (group.Parent is not null) + { + jsonWriter.WritePropertyName("parentId"); + jsonWriter.WriteNumberValue(group.Parent.DeferId); + } + + jsonWriter.WritePropertyName("operation"); + WriteOperation(jsonWriter, group.Operation); + + if (!group.AllNodes.IsDefaultOrEmpty) + { + jsonWriter.WritePropertyName("nodes"); + WriteNodes(jsonWriter, group.Operation, group.AllNodes, null); + } + + jsonWriter.WriteEndObject(); + } + + jsonWriter.WriteEndArray(); + } + jsonWriter.WriteEndObject(); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs index 4cd9e7bec58..60071ad0369 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs @@ -52,6 +52,64 @@ public override OperationPlan Parse(ReadOnlyMemory planSourceText) var nodes = ParseNodes(rootElement.GetProperty("nodes"), operation); + var deferredGroups = ImmutableArray.Empty; + + if (rootElement.TryGetProperty("deferredGroups", out var deferredGroupsElement)) + { + var groupBuilder = ImmutableArray.CreateBuilder(); + var groupMap = new Dictionary(); + + foreach (var groupElement in deferredGroupsElement.EnumerateArray()) + { + var deferId = groupElement.GetProperty("deferId").GetInt32(); + + string? label = null; + if (groupElement.TryGetProperty("label", out var labelElement)) + { + label = labelElement.GetString(); + } + + var path = SelectionPath.Parse(groupElement.GetProperty("path").GetString()!); + + string? ifVariable = null; + if (groupElement.TryGetProperty("ifVariable", out var ifVarElement)) + { + ifVariable = ifVarElement.GetString()!.TrimStart('$'); + } + + DeferredExecutionGroup? parent = null; + if (groupElement.TryGetProperty("parentId", out var parentIdElement)) + { + groupMap.TryGetValue(parentIdElement.GetInt32(), out parent); + } + + var groupOperation = ParseOperation(groupElement.GetProperty("operation")); + + var groupNodes = groupElement.TryGetProperty("nodes", out var groupNodesElement) + ? ParseNodes(groupNodesElement, groupOperation) + : ImmutableArray.Empty; + + var rootGroupNodes = groupNodes + .Where(n => n.Dependencies.Length == 0 && n.OptionalDependencies.Length == 0) + .ToImmutableArray(); + + var group = new DeferredExecutionGroup( + deferId, + label, + path, + ifVariable, + parent, + groupOperation, + rootGroupNodes, + groupNodes); + + groupBuilder.Add(group); + groupMap[deferId] = group; + } + + deferredGroups = groupBuilder.ToImmutable(); + } + // Root nodes are the entry points of the execution plan. A node is a // root when it has no dependencies at all, meaning the executor can // start it immediately without waiting for other nodes to finish. @@ -60,6 +118,7 @@ public override OperationPlan Parse(ReadOnlyMemory planSourceText) operation, [.. nodes.Where(n => n.Dependencies.Length == 0 && n.OptionalDependencies.Length == 0)], nodes, + deferredGroups, searchSpace, expandedNodes); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/YamlOperationPlanFormatter.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/YamlOperationPlanFormatter.cs index d3e2d16dbf8..1c4acfee4ee 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/YamlOperationPlanFormatter.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/YamlOperationPlanFormatter.cs @@ -25,29 +25,87 @@ public override string Format(OperationPlan plan, OperationPlanTrace? trace = nu ExecutionNodeTrace? nodeTrace = null; trace?.Nodes.TryGetValue(node.Id, out nodeTrace); - switch (node) - { - case OperationExecutionNode operationNode: - WriteOperationNode(operationNode, nodeTrace, writer); - break; + WriteNode(node, nodeTrace, writer); + } - case OperationBatchExecutionNode batchNode: - WriteBatchExecutionNode(batchNode, nodeTrace, writer); - break; + writer.Unindent(); - case IntrospectionExecutionNode introspectionNode: - WriteIntrospectionNode(introspectionNode, nodeTrace, writer); - break; + if (!plan.DeferredGroups.IsDefaultOrEmpty) + { + writer.WriteLine("deferredGroups:"); + writer.Indent(); - case NodeFieldExecutionNode nodeExecutionNode: - WriteNodeFieldNode(nodeExecutionNode, nodeTrace, writer); - break; + foreach (var group in plan.DeferredGroups) + { + WriteDeferredGroup(group, writer); } + + writer.Unindent(); } return sb.ToString(); } + private static void WriteNode(ExecutionNode node, ExecutionNodeTrace? nodeTrace, CodeWriter writer) + { + switch (node) + { + case OperationExecutionNode operationNode: + WriteOperationNode(operationNode, nodeTrace, writer); + break; + + case OperationBatchExecutionNode batchNode: + WriteBatchExecutionNode(batchNode, nodeTrace, writer); + break; + + case IntrospectionExecutionNode introspectionNode: + WriteIntrospectionNode(introspectionNode, nodeTrace, writer); + break; + + case NodeFieldExecutionNode nodeExecutionNode: + WriteNodeFieldNode(nodeExecutionNode, nodeTrace, writer); + break; + } + } + + private static void WriteDeferredGroup(DeferredExecutionGroup group, CodeWriter writer) + { + writer.WriteLine("- deferId: {0}", group.DeferId); + writer.Indent(); + + if (group.Label is not null) + { + writer.WriteLine("label: {0}", group.Label); + } + + writer.WriteLine("path: {0}", group.Path.ToString()); + + if (group.IfVariable is not null) + { + writer.WriteLine("ifVariable: ${0}", group.IfVariable); + } + + if (group.Parent is not null) + { + writer.WriteLine("parentId: {0}", group.Parent.DeferId); + } + + if (!group.AllNodes.IsDefaultOrEmpty) + { + writer.WriteLine("nodes:"); + writer.Indent(); + + foreach (var node in group.AllNodes) + { + WriteNode(node, nodeTrace: null, writer); + } + + writer.Unindent(); + } + + writer.Unindent(); + } + private static void WriteOperation( OperationPlan plan, OperationPlanTrace? trace, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs index fc0988e5f12..58c2bcf78f8 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs @@ -326,6 +326,7 @@ private static async Task ExecuteDeferredGroupInBackground( group.Operation, group.RootNodes, group.AllNodes, + [], 0, 0); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs index 98b20a92a7d..5f29c6d2dbf 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs @@ -177,8 +177,29 @@ private SelectionSetNode StripDeferFragments( // Now set the real operation on the descriptor descriptor.Operation = deferredOperation; + // If conditional (@defer(if: $variable)), also keep the fragment + // inline in the main operation but with @skip(if: $variable). + // When defer is active (variable=true), @skip removes the fields + // from the main response. When defer is inactive (variable=false), + // @skip doesn't apply and the fields are fetched inline. + if (ifVariable is not null) + { + var skipDirective = new DirectiveNode( + null, + new NameNode("skip"), + [ + new ArgumentNode( + null, + new NameNode("if"), + new VariableNode(new NameNode(ifVariable))) + ]); + + selections.Add( + strippedFragment.WithDirectives( + [..strippedFragment.Directives, skipDirective])); + } + modified = true; - // Don't add this fragment to the main operation selections continue; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs index d65067cc46a..f4be0d5beeb 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs @@ -25,7 +25,7 @@ private OperationPlan BuildExecutionPlan( ImmutableList planSteps, int searchSpace, int expandedNodes, - ImmutableArray deferredGroups = default) + ImmutableArray deferredGroups) { if (operation.IsIntrospectionOnly()) { @@ -37,7 +37,7 @@ private OperationPlan BuildExecutionPlan( var nodes = ImmutableArray.Create(introspectionNode); - return OperationPlan.Create(operation, nodes, nodes, searchSpace, expandedNodes); + return OperationPlan.Create(operation, nodes, nodes, [], searchSpace, expandedNodes); } var ctx = new ExecutionPlanBuildContext(); @@ -74,7 +74,7 @@ private OperationPlan BuildExecutionPlan( node.Seal(); } - return OperationPlan.Create(operation, rootNodes, allNodes, searchSpace, expandedNodes, deferredGroups); + return OperationPlan.Create(operation, rootNodes, allNodes, deferredGroups, searchSpace, expandedNodes); } private static ImmutableList TransformPlanSteps( diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs index 6706374eb83..ab44b02ef5c 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -98,7 +98,7 @@ public OperationPlan CreatePlan( // // PERF: For non-deferred operations (the common case), the only overhead is // the HasDeferDirective check which does a fast AST walk looking for @defer. - ImmutableArray deferredGroups = default; + ImmutableArray deferredGroups = []; DeferSplitResult? deferSplit = null; var mainOperationDefinition = operationDefinition; diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs index 389173e8f66..3c484c33618 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs @@ -1,4 +1,3 @@ -using System.Text; using System.Text.Json; using HotChocolate.Transport; using HotChocolate.Transport.Http; @@ -71,63 +70,81 @@ ... @defer(label: "reviews") { request, new Uri("http://localhost:5000/graphql")); - // assert — parse the raw multipart body to verify the incremental delivery format. - // The transport OperationResult parser drops incremental fields, so we parse raw JSON. - var rawBody = await result.HttpResponseMessage.Content.ReadAsStringAsync(); - var payloads = rawBody - .Split('\n', StringSplitOptions.RemoveEmptyEntries) - .Select(line => JsonDocument.Parse(line)) - .ToList(); - - Assert.Equal(2, payloads.Count); - - // --- Initial payload --- - var initial = payloads[0].RootElement; + // assert + await MatchSnapshotAsync(gateway, request, result); + } - // Has data with user.name - Assert.True(initial.TryGetProperty("data", out var data)); - Assert.Equal("User: VXNlcjox", data.GetProperty("user").GetProperty("name").GetString()); + [Fact] + public async Task Defer_IfFalse_Variable_Should_Return_NonStreamed_Result() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + """ + type Query { + user(id: ID!): User @lookup + } - // Has pending announcing the deferred group - Assert.True(initial.TryGetProperty("pending", out var pending)); - Assert.Equal(1, pending.GetArrayLength()); - Assert.Equal("0", pending[0].GetProperty("id").GetString()); - Assert.Equal("reviews", pending[0].GetProperty("label").GetString()); + type User @key(fields: "id") { + id: ID! + name: String! + email: String! + } + """); - // Has hasNext = true - Assert.True(initial.GetProperty("hasNext").GetBoolean()); + using var server2 = CreateSourceSchema( + "B", + """ + type Query { + userById(id: ID!): User @lookup + } - // --- Deferred payload --- - var deferred = payloads[1].RootElement; + type User @key(fields: "id") { + id: ID! + reviews: [Review!]! + } - // No top-level data - Assert.False(deferred.TryGetProperty("data", out _)); + type Review { + title: String! + body: String! + } + """); - // Has incremental with the deferred reviews - Assert.True(deferred.TryGetProperty("incremental", out var incremental)); - Assert.Equal(1, incremental.GetArrayLength()); - Assert.Equal("0", incremental[0].GetProperty("id").GetString()); + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2) + ]); - var incrementalData = incremental[0].GetProperty("data"); - var reviews = incrementalData.GetProperty("user").GetProperty("reviews"); - Assert.Equal(3, reviews.GetArrayLength()); + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); - // Has completed - Assert.True(deferred.TryGetProperty("completed", out var completed)); - Assert.Equal(1, completed.GetArrayLength()); - Assert.Equal("0", completed[0].GetProperty("id").GetString()); + var request = new OperationRequest( + query: """ + query GetUser($shouldDefer: Boolean!) { + user(id: "1") { + name + ... @defer(if: $shouldDefer, label: "reviews") { + reviews { + title + body + } + } + } + } + """, + variables: new Dictionary { ["shouldDefer"] = false }); - // Has hasNext = false (last payload) - Assert.False(deferred.GetProperty("hasNext").GetBoolean()); + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); - foreach (var doc in payloads) - { - doc.Dispose(); - } + // assert + await MatchSnapshotAsync(gateway, request, result); } [Fact] - public async Task Defer_IfFalse_Variable_Should_Return_NonStreamed_Result() + public async Task Defer_IfTrue_Variable_Should_Return_Streamed_Result() { // arrange using var server1 = CreateSourceSchema( @@ -185,47 +202,14 @@ ... @defer(if: $shouldDefer, label: "reviews") { } } """, - variables: new Dictionary { ["shouldDefer"] = false }); + variables: new Dictionary { ["shouldDefer"] = true }); using var result = await client.PostAsync( request, new Uri("http://localhost:5000/graphql")); - // assert — when @defer(if: false), the plan still has DeferredGroups but the runtime - // evaluates the condition and skips them. With all groups skipped, the executor - // returns a plain OperationResult (not a ResponseStream), so the response is a - // single JSON payload with hasNext = false and no incremental delivery. - // - // Note: The deferred fields (reviews) are separated during planning, so they - // will NOT be in the initial result — @defer(if: false) currently does not - // re-inline them. This is consistent with the incremental delivery spec: - // the gateway simply returns hasNext = false with no pending groups. - var rawBody = await result.HttpResponseMessage.Content.ReadAsStringAsync(); - var payloads = rawBody - .Split('\n', StringSplitOptions.RemoveEmptyEntries) - .Select(line => JsonDocument.Parse(line)) - .ToList(); - - Assert.Single(payloads); - - var initial = payloads[0].RootElement; - - // Has data with user.name - Assert.True(initial.TryGetProperty("data", out var data)); - Assert.Equal("User: VXNlcjox", data.GetProperty("user").GetProperty("name").GetString()); - - // hasNext should be false — no deferred groups are active - Assert.True(initial.TryGetProperty("hasNext", out var hasNext)); - Assert.False(hasNext.GetBoolean()); - - // No pending or incremental entries since all deferred groups were skipped - Assert.False(initial.TryGetProperty("pending", out _)); - Assert.False(initial.TryGetProperty("incremental", out _)); - - foreach (var doc in payloads) - { - doc.Dispose(); - } + // assert + await MatchSnapshotAsync(gateway, request, result); } [Fact] @@ -287,31 +271,8 @@ ... @defer(label: "inner") { request, new Uri("http://localhost:5000/graphql")); - // assert — nested @defer produces multiple incremental payloads. - // The initial payload has name; subsequent payloads deliver email then address. - var rawBody = await result.HttpResponseMessage.Content.ReadAsStringAsync(); - var payloads = rawBody - .Split('\n', StringSplitOptions.RemoveEmptyEntries) - .Select(line => JsonDocument.Parse(line)) - .ToList(); - - // At minimum: initial payload + outer deferred + inner deferred - Assert.True(payloads.Count >= 3, $"Expected at least 3 payloads but got {payloads.Count}"); - - // --- Initial payload --- - var initial = payloads[0].RootElement; - Assert.True(initial.TryGetProperty("data", out var data)); - Assert.Equal("User: VXNlcjox", data.GetProperty("user").GetProperty("name").GetString()); - Assert.True(initial.GetProperty("hasNext").GetBoolean()); - - // --- Last payload --- - var last = payloads[^1].RootElement; - Assert.False(last.GetProperty("hasNext").GetBoolean()); - - foreach (var doc in payloads) - { - doc.Dispose(); - } + // assert + await MatchSnapshotAsync(gateway, request, result); } [Fact] @@ -372,62 +333,7 @@ ... @defer(label: "email") { new Uri("http://localhost:5000/graphql")); // assert - var rawBody = await result.HttpResponseMessage.Content.ReadAsStringAsync(); - var payloads = rawBody - .Split('\n', StringSplitOptions.RemoveEmptyEntries) - .Select(line => JsonDocument.Parse(line)) - .ToList(); - - Assert.Equal(2, payloads.Count); - - // --- Initial payload --- - var initial = payloads[0].RootElement; - Assert.True(initial.TryGetProperty("data", out var data)); - Assert.Equal("User: VXNlcjox", data.GetProperty("user").GetProperty("name").GetString()); - Assert.True(initial.GetProperty("hasNext").GetBoolean()); - - // --- Deferred payload should contain error information --- - var deferred = payloads[1].RootElement; - Assert.False(deferred.GetProperty("hasNext").GetBoolean()); - - // The deferred payload should have completed with errors, or have errors - // in the incremental entry. Check both patterns. - var hasErrors = deferred.TryGetProperty("errors", out var topErrors) && topErrors.GetArrayLength() > 0; - var hasCompletedWithErrors = false; - var hasIncrementalErrors = false; - - if (deferred.TryGetProperty("completed", out var completed)) - { - foreach (var entry in completed.EnumerateArray()) - { - if (entry.TryGetProperty("errors", out var completedErrors) - && completedErrors.GetArrayLength() > 0) - { - hasCompletedWithErrors = true; - } - } - } - - if (deferred.TryGetProperty("incremental", out var incremental)) - { - foreach (var entry in incremental.EnumerateArray()) - { - if (entry.TryGetProperty("errors", out var incrementalErrors) - && incrementalErrors.GetArrayLength() > 0) - { - hasIncrementalErrors = true; - } - } - } - - Assert.True( - hasErrors || hasCompletedWithErrors || hasIncrementalErrors, - "Expected the deferred payload to contain an error from the source schema's @error directive."); - - foreach (var doc in payloads) - { - doc.Dispose(); - } + await MatchSnapshotAsync(gateway, request, result); } [Fact(Skip = "Requires validation of @skip/@include interaction with @defer at the planning level")] diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfFalse_Variable_Should_Return_NonStreamed_Result.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfFalse_Variable_Should_Return_NonStreamed_Result.yaml new file mode 100644 index 00000000000..a9e4355126e --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfFalse_Variable_Should_Return_NonStreamed_Result.yaml @@ -0,0 +1,246 @@ +title: Defer_IfFalse_Variable_Should_Return_NonStreamed_Result +request: + document: | + query GetUser( + $shouldDefer: Boolean! + ) { + user(id: "1") { + name + ... @defer(if: $shouldDefer, label: "reviews") { + reviews { + title + body + } + } + } + } + variables: | + { + "shouldDefer": false + } +response: + body: | + { + "data": { + "user": { + "name": "User: VXNlcjox", + "reviews": [ + { + "title": "Review: UmV2aWV3OjE=", + "body": "Review: UmV2aWV3OjE=" + }, + { + "title": "Review: UmV2aWV3OjI=", + "body": "Review: UmV2aWV3OjI=" + }, + { + "title": "Review: UmV2aWV3OjM=", + "body": "Review: UmV2aWV3OjM=" + } + ] + } + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + email: String! + } + interactions: + - request: + document: | + query GetUser_033679a6_1( + $shouldDefer: Boolean! + ) { + user(id: "1") { + name + ... @skip(if: $shouldDefer) { + id + } + } + } + variables: | + { + "shouldDefer": false + } + response: + results: + - | + { + "data": { + "user": { + "name": "User: VXNlcjox", + "id": "VXNlcjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup + } + + type Review { + title: String! + body: String! + } + + type User @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + interactions: + - request: + document: | + query GetUser_033679a6_2( + $__fusion_1_id: ID! + ) { + userById(id: $__fusion_1_id) { + reviews { + title + body + } + } + } + variables: | + { + "__fusion_1_id": "VXNlcjox" + } + response: + results: + - | + { + "data": { + "userById": { + "reviews": [ + { + "title": "Review: UmV2aWV3OjE=", + "body": "Review: UmV2aWV3OjE=" + }, + { + "title": "Review: UmV2aWV3OjI=", + "body": "Review: UmV2aWV3OjI=" + }, + { + "title": "Review: UmV2aWV3OjM=", + "body": "Review: UmV2aWV3OjM=" + } + ] + } + } + } +operationPlan: + operation: + - document: | + query GetUser( + $shouldDefer: Boolean! + ) { + user(id: "1") { + name + ... @skip(if: $shouldDefer) { + reviews { + title + body + } + id @fusion__requirement + } + } + } + name: GetUser + hash: 033679a6deb9150ce9d5cd47bd725497 + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query GetUser_033679a6_1( + $shouldDefer: Boolean! + ) { + user(id: "1") { + name + ... @skip(if: $shouldDefer) { + id + } + } + } + forwardedVariables: + - shouldDefer + - id: 2 + type: Operation + schema: B + operation: | + query GetUser_033679a6_2( + $__fusion_1_id: ID! + ) { + userById(id: $__fusion_1_id) { + reviews { + title + body + } + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + conditions: + - variable: $shouldDefer + passingValue: false + dependencies: + - id: 1 + deferredGroups: + - deferId: 0 + label: reviews + path: $.user + ifVariable: $shouldDefer + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query GetUser_defer_1 { + user(id: "1") { + id + } + } + - id: 2 + type: Operation + schema: B + operation: | + query GetUser_defer_2( + $__fusion_1_id: ID! + ) { + userById(id: $__fusion_1_id) { + reviews { + title + body + } + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfTrue_Variable_Should_Return_Streamed_Result.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfTrue_Variable_Should_Return_Streamed_Result.yaml new file mode 100644 index 00000000000..4d87f12ceb8 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfTrue_Variable_Should_Return_Streamed_Result.yaml @@ -0,0 +1,241 @@ +title: Defer_IfTrue_Variable_Should_Return_Streamed_Result +request: + document: | + query GetUser( + $shouldDefer: Boolean! + ) { + user(id: "1") { + name + ... @defer(if: $shouldDefer, label: "reviews") { + reviews { + title + body + } + } + } + } + variables: | + { + "shouldDefer": true + } +responseStream: + - body: | + { + "data": { + "user": { + "name": "User: VXNlcjox" + } + } + } + - body: | + {} +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + email: String! + } + interactions: + - request: + document: | + query GetUser_033679a6_1( + $shouldDefer: Boolean! + ) { + user(id: "1") { + name + ... @skip(if: $shouldDefer) { + id + } + } + } + variables: | + { + "shouldDefer": true + } + response: + results: + - | + { + "data": { + "user": { + "name": "User: VXNlcjox" + } + } + } + - | + { + "data": { + "user": { + "id": "VXNlcjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup + } + + type Review { + title: String! + body: String! + } + + type User @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + interactions: + - request: + document: | + query GetUser_defer_2( + $__fusion_1_id: ID! + ) { + userById(id: $__fusion_1_id) { + reviews { + title + body + } + } + } + variables: | + { + "__fusion_1_id": "VXNlcjox" + } + response: + results: + - | + { + "data": { + "userById": { + "reviews": [ + { + "title": "Review: UmV2aWV3OjE=", + "body": "Review: UmV2aWV3OjE=" + }, + { + "title": "Review: UmV2aWV3OjI=", + "body": "Review: UmV2aWV3OjI=" + }, + { + "title": "Review: UmV2aWV3OjM=", + "body": "Review: UmV2aWV3OjM=" + } + ] + } + } + } +operationPlan: + operation: + - document: | + query GetUser( + $shouldDefer: Boolean! + ) { + user(id: "1") { + name + ... @skip(if: $shouldDefer) { + reviews { + title + body + } + id @fusion__requirement + } + } + } + name: GetUser + hash: 033679a6deb9150ce9d5cd47bd725497 + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query GetUser_033679a6_1( + $shouldDefer: Boolean! + ) { + user(id: "1") { + name + ... @skip(if: $shouldDefer) { + id + } + } + } + forwardedVariables: + - shouldDefer + - id: 2 + type: Operation + schema: B + operation: | + query GetUser_033679a6_2( + $__fusion_1_id: ID! + ) { + userById(id: $__fusion_1_id) { + reviews { + title + body + } + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + conditions: + - variable: $shouldDefer + passingValue: false + dependencies: + - id: 1 + deferredGroups: + - deferId: 0 + label: reviews + path: $.user + ifVariable: $shouldDefer + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query GetUser_defer_1 { + user(id: "1") { + id + } + } + - id: 2 + type: Operation + schema: B + operation: | + query GetUser_defer_2( + $__fusion_1_id: ID! + ) { + userById(id: $__fusion_1_id) { + reviews { + title + body + } + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Should_Return_Incremental_Response_In_Order.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Should_Return_Incremental_Response_In_Order.yaml new file mode 100644 index 00000000000..55baa678287 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Should_Return_Incremental_Response_In_Order.yaml @@ -0,0 +1,210 @@ +title: Defer_Nested_Should_Return_Incremental_Response_In_Order +request: + document: | + { + user(id: "1") { + name + ... @defer(label: "outer") { + email + ... @defer(label: "inner") { + address + } + } + } + } +responseStream: + - body: | + { + "data": { + "user": { + "name": "User: VXNlcjox" + } + } + } + - body: | + {} + - body: | + {} +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + interactions: + - request: + document: | + query Op_d1db12b9_1 { + user(id: "1") { + name + } + } + response: + results: + - | + { + "data": { + "user": { + "name": "User: VXNlcjox" + } + } + } + - | + { + "data": { + "user": { + "id": "VXNlcjox" + } + } + } + - | + { + "data": { + "user": { + "id": "VXNlcjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + address: String! + } + interactions: + - request: + document: | + query Op_defer_2( + $__fusion_1_id: ID! + ) { + userById(id: $__fusion_1_id) { + email + } + } + variables: | + { + "__fusion_1_id": "VXNlcjox" + } + response: + results: + - | + { + "data": { + "userById": { + "email": "User: VXNlcjox" + } + } + } + - | + { + "data": { + "userById": { + "address": "User: VXNlcjox" + } + } + } +operationPlan: + operation: + - document: | + { + user(id: "1") { + name + } + } + hash: d1db12b9a5735063f8b816612efd3c53 + searchSpace: 1 + expandedNodes: 1 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_d1db12b9_1 { + user(id: "1") { + name + } + } + deferredGroups: + - deferId: 0 + label: outer + path: $.user + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_defer_1 { + user(id: "1") { + id + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_defer_2( + $__fusion_1_id: ID! + ) { + userById(id: $__fusion_1_id) { + email + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 + - deferId: 1 + label: inner + path: $.user + parentId: 0 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_defer_1 { + user(id: "1") { + id + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_defer_2( + $__fusion_1_id: ID! + ) { + userById(id: $__fusion_1_id) { + address + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Single_Fragment_Returns_Incremental_Response.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Single_Fragment_Returns_Incremental_Response.yaml new file mode 100644 index 00000000000..ecccdd082f1 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Single_Fragment_Returns_Incremental_Response.yaml @@ -0,0 +1,184 @@ +title: Defer_Single_Fragment_Returns_Incremental_Response +request: + document: | + query GetUser { + user(id: "1") { + name + ... @defer(label: "reviews") { + reviews { + title + body + } + } + } + } +responseStream: + - body: | + { + "data": { + "user": { + "name": "User: VXNlcjox" + } + } + } + - body: | + {} +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + email: String! + } + interactions: + - request: + document: | + query GetUser_9d7b4d57_1 { + user(id: "1") { + name + } + } + response: + results: + - | + { + "data": { + "user": { + "name": "User: VXNlcjox" + } + } + } + - | + { + "data": { + "user": { + "id": "VXNlcjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup + } + + type Review { + title: String! + body: String! + } + + type User @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + interactions: + - request: + document: | + query GetUser_defer_2( + $__fusion_1_id: ID! + ) { + userById(id: $__fusion_1_id) { + reviews { + title + body + } + } + } + variables: | + { + "__fusion_1_id": "VXNlcjox" + } + response: + results: + - | + { + "data": { + "userById": { + "reviews": [ + { + "title": "Review: UmV2aWV3OjE=", + "body": "Review: UmV2aWV3OjE=" + }, + { + "title": "Review: UmV2aWV3OjI=", + "body": "Review: UmV2aWV3OjI=" + }, + { + "title": "Review: UmV2aWV3OjM=", + "body": "Review: UmV2aWV3OjM=" + } + ] + } + } + } +operationPlan: + operation: + - document: | + query GetUser { + user(id: "1") { + name + } + } + name: GetUser + hash: 9d7b4d57fdf8672e3b4b1975babbc7a0 + searchSpace: 1 + expandedNodes: 1 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query GetUser_9d7b4d57_1 { + user(id: "1") { + name + } + } + deferredGroups: + - deferId: 0 + label: reviews + path: $.user + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query GetUser_defer_1 { + user(id: "1") { + id + } + } + - id: 2 + type: Operation + schema: B + operation: | + query GetUser_defer_2( + $__fusion_1_id: ID! + ) { + userById(id: $__fusion_1_id) { + reviews { + title + body + } + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Error_In_Deferred_Fragment_Should_Return_Error_In_Incremental_Payload.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Error_In_Deferred_Fragment_Should_Return_Error_In_Incremental_Payload.yaml new file mode 100644 index 00000000000..52e773d72b7 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Error_In_Deferred_Fragment_Should_Return_Error_In_Incremental_Payload.yaml @@ -0,0 +1,163 @@ +title: Defer_With_Error_In_Deferred_Fragment_Should_Return_Error_In_Incremental_Payload +request: + document: | + query GetUser { + user(id: "1") { + name + ... @defer(label: "email") { + email + } + } + } +responseStream: + - body: | + { + "data": { + "user": { + "name": "User: VXNlcjox" + } + } + } + - body: | + {} +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + interactions: + - request: + document: | + query GetUser_28690061_1 { + user(id: "1") { + name + } + } + response: + results: + - | + { + "data": { + "user": { + "name": "User: VXNlcjox" + } + } + } + - | + { + "data": { + "user": { + "id": "VXNlcjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! @error + } + interactions: + - request: + document: | + query GetUser_defer_2( + $__fusion_1_id: ID! + ) { + userById(id: $__fusion_1_id) { + email + } + } + variables: | + { + "__fusion_1_id": "VXNlcjox" + } + response: + results: + - | + { + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "userById", + "email" + ] + } + ], + "data": { + "userById": null + } + } +operationPlan: + operation: + - document: | + query GetUser { + user(id: "1") { + name + } + } + name: GetUser + hash: 286900619fe56222f78d6a6c45482586 + searchSpace: 1 + expandedNodes: 1 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query GetUser_28690061_1 { + user(id: "1") { + name + } + } + deferredGroups: + - deferId: 0 + label: email + path: $.user + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query GetUser_defer_1 { + user(id: "1") { + id + } + } + - id: 2 + type: Operation + schema: B + operation: | + query GetUser_defer_2( + $__fusion_1_id: ID! + ) { + userById(id: $__fusion_1_id) { + email + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 From b0ced3ff9c9bf3c5e1b6e40691afa67228058981 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 8 Apr 2026 14:12:31 +0000 Subject: [PATCH 3/9] Add defer opt-out --- .../Completion/CompositeSchemaBuilder.cs | 10 +++++----- .../FusionSchemaOptions.cs | 5 +++++ .../IFusionSchemaOptions.cs | 7 +++++++ .../Execution/FusionOptions.cs | 20 ++++++++++++++++++- .../Execution/FusionRequestExecutorManager.cs | 15 ++++++++------ .../Planning/OperationPlanner.cs | 2 +- .../Planning/OperationPlannerOptions.cs | 14 +++++++++++++ 7 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs index 6acf398580e..17f1ba28d42 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs @@ -132,25 +132,25 @@ private static CompositeSchemaBuilderContext CreateTypes( // Register the @defer directive so the gateway's validation accepts it. // The gateway manages @defer itself (it does not pass it to subgraphs). - if (!directiveDefinitions.ContainsKey(DirectiveNames.Defer.Name)) + if (options.EnableDefer && !directiveDefinitions.ContainsKey(Defer.Name)) { var deferDirectiveNode = new DirectiveDefinitionNode( null, - new HotChocolate.Language.NameNode(DirectiveNames.Defer.Name), + new HotChocolate.Language.NameNode(Defer.Name), null, false, new[] { new InputValueDefinitionNode( null, - new HotChocolate.Language.NameNode(DirectiveNames.Defer.Arguments.If), + new HotChocolate.Language.NameNode(Defer.Arguments.If), null, new NamedTypeNode("Boolean"), new BooleanValueNode(true), []), new InputValueDefinitionNode( null, - new HotChocolate.Language.NameNode(DirectiveNames.Defer.Arguments.Label), + new HotChocolate.Language.NameNode(Defer.Arguments.Label), null, new NamedTypeNode("String"), null, @@ -159,7 +159,7 @@ private static CompositeSchemaBuilderContext CreateTypes( new HotChocolate.Language.NameNode[] { new("INLINE_FRAGMENT"), new("FRAGMENT_SPREAD") }); directiveTypes.Add(CreateDirectiveType(deferDirectiveNode)); - directiveDefinitions.Add(DirectiveNames.Defer.Name, deferDirectiveNode); + directiveDefinitions.Add(Defer.Name, deferDirectiveNode); } features ??= new FeatureCollection(); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaOptions.cs index 3641df666ed..054e0f3a2d1 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaOptions.cs @@ -4,6 +4,10 @@ internal struct FusionSchemaOptions : IFusionSchemaOptions { public bool ApplySerializeAsToScalars { get; private set; } + public bool EnableDefer { get; private set; } = true; + + public FusionSchemaOptions() { } + public static FusionSchemaOptions From(IFusionSchemaOptions? options) { var copy = new FusionSchemaOptions(); @@ -11,6 +15,7 @@ public static FusionSchemaOptions From(IFusionSchemaOptions? options) if (options is not null) { copy.ApplySerializeAsToScalars = options.ApplySerializeAsToScalars; + copy.EnableDefer = options.EnableDefer; } return copy; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/IFusionSchemaOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/IFusionSchemaOptions.cs index e7a425511e9..195adf2b16d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/IFusionSchemaOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/IFusionSchemaOptions.cs @@ -9,4 +9,11 @@ public interface IFusionSchemaOptions /// Applies the @serializeAs directive to scalar types that specify a serialization format. /// bool ApplySerializeAsToScalars { get; } + + /// + /// Gets whether @defer is enabled. + /// When false, the @defer directive is not exposed in the schema + /// and deferred execution is disabled. + /// + bool EnableDefer { get; } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOptions.cs index 7eaca8831dd..18dd5d872a8 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOptions.cs @@ -167,6 +167,23 @@ public bool ApplySerializeAsToScalars } } + /// + /// Gets or sets whether @defer is enabled. + /// When false, the @defer directive is not exposed in the schema + /// and deferred execution is disabled. + /// true by default. + /// + public bool EnableDefer + { + get; + set + { + ExpectMutableOptions(); + + field = value; + } + } = true; + /// /// Clones the options into a new mutable instance. /// @@ -185,7 +202,8 @@ public FusionOptions Clone() DefaultErrorHandlingMode = DefaultErrorHandlingMode, LazyInitialization = LazyInitialization, NodeIdSerializerFormat = NodeIdSerializerFormat, - ApplySerializeAsToScalars = ApplySerializeAsToScalars + ApplySerializeAsToScalars = ApplySerializeAsToScalars, + EnableDefer = EnableDefer }; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs index e7218506abb..570de301621 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs @@ -177,7 +177,7 @@ private FusionRequestExecutor CreateRequestExecutor( var options = CreateOptions(setup); var requestOptions = CreateRequestOptions(setup); - var plannerOptions = CreatePlannerOptions(setup); + var plannerOptions = CreatePlannerOptions(setup, options); var parserOptions = CreateParserOptions(setup); var clientConfigurations = CreateClientConfigurations(setup, configuration.Settings.Document); var features = CreateSchemaFeatures( @@ -261,18 +261,21 @@ private static FusionRequestOptions CreateRequestOptions(FusionGatewaySetup setu return options; } - private static OperationPlannerOptions CreatePlannerOptions(FusionGatewaySetup setup) + private static OperationPlannerOptions CreatePlannerOptions(FusionGatewaySetup setup, FusionOptions options) { - var options = new OperationPlannerOptions(); + var plannerOptions = new OperationPlannerOptions + { + EnableDefer = options.EnableDefer + }; foreach (var configure in setup.PlannerOptionsModifiers) { - configure.Invoke(options); + configure.Invoke(plannerOptions); } - options.MakeReadOnly(); + plannerOptions.MakeReadOnly(); - return options; + return plannerOptions; } private static ParserOptions CreateParserOptions(FusionGatewaySetup setup) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs index ab44b02ef5c..8de190ea6cc 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -102,7 +102,7 @@ public OperationPlan CreatePlan( DeferSplitResult? deferSplit = null; var mainOperationDefinition = operationDefinition; - if (DeferOperationRewriter.HasDeferDirective(operationDefinition)) + if (_options.EnableDefer && DeferOperationRewriter.HasDeferDirective(operationDefinition)) { var rewriter = new DeferOperationRewriter(); var splitResult = rewriter.Split(operationDefinition); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlannerOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlannerOptions.cs index 8ceb5f9678d..7e783d96a0a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlannerOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlannerOptions.cs @@ -57,6 +57,20 @@ public bool EnableRequestGrouping } } = true; + /// + /// Gets or sets whether @defer support is enabled in the planner. + /// When disabled, the planner skips defer processing entirely. + /// + public bool EnableDefer + { + get; + set + { + ExpectMutableOptions(); + field = value; + } + } = true; + /// /// Gets or sets how aggressively structurally-identical operations are merged /// to reduce downstream request count. Cycle safety is always enforced regardless From ba97955fd46d8202b35dde10991fb6983a2b4b7f Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 12 Apr 2026 06:49:38 +0000 Subject: [PATCH 4/9] polish --- .../Execution/Nodes/DeferredExecutionGroup.cs | 32 +++-- .../Execution/Nodes/Operation.cs | 6 +- .../Execution/Nodes/OperationCompiler.cs | 12 +- .../Execution/Nodes/Selection.cs | 5 +- .../JsonOperationPlanFormatter.cs | 110 ++++++++++-------- .../Serialization/JsonOperationPlanParser.cs | 2 +- ..._Return_Incremental_Response_In_Order.yaml | 5 +- 7 files changed, 99 insertions(+), 73 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferredExecutionGroup.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferredExecutionGroup.cs index 8e62f4f83d3..0d169e30e5a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferredExecutionGroup.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferredExecutionGroup.cs @@ -13,14 +13,30 @@ public sealed class DeferredExecutionGroup /// /// Initializes a new instance of . /// - /// A unique identifier for this deferred payload, used in pending and completed entries. - /// The optional label from @defer(label: "..."). - /// The path in the result tree where deferred data will be inserted. - /// The variable name from @defer(if: $var), or null if unconditional. - /// The parent deferred group for nested @defer, or null for top-level. - /// The compiled operation for this deferred group's result mapping. - /// The root execution nodes that serve as entry points for this deferred group. - /// All execution nodes belonging to this deferred group. + /// + /// A unique identifier for this deferred payload, used in pending and completed entries. + /// + /// + /// The optional label from @defer(label: "..."). + /// + /// + /// The path in the result tree where deferred data will be inserted. + /// + /// + /// The variable name from @defer(if: $var), or null if unconditional. + /// + /// + /// The parent deferred group for nested @defer, or null for top-level. + /// + /// + /// The compiled operation for this deferred group's result mapping. + /// + /// + /// The root execution nodes that serve as entry points for this deferred group. + /// + /// + /// All execution nodes belonging to this deferred group. + /// public DeferredExecutionGroup( int deferId, string? label, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs index aafd67667c1..2629e918883 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs @@ -34,9 +34,9 @@ internal Operation( SelectionSet rootSelectionSet, OperationCompiler compiler, IncludeConditionCollection includeConditions, + bool hasIncrementalParts, int lastId, - object[] elementsById, - bool hasIncrementalParts) + object[] elementsById) { ArgumentException.ThrowIfNullOrWhiteSpace(id); ArgumentException.ThrowIfNullOrWhiteSpace(hash); @@ -56,9 +56,9 @@ internal Operation( RootSelectionSet = rootSelectionSet; _compiler = compiler; _includeConditions = includeConditions; + _hasIncrementalParts = hasIncrementalParts; _lastId = lastId; _elementsById = elementsById; - _hasIncrementalParts = hasIncrementalParts; _features = new OperationFeatureCollection(); rootSelectionSet.Seal(this); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs index e4421a51df3..4fcebd9578e 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs @@ -82,9 +82,9 @@ public Operation Compile(string id, string hash, OperationDefinitionNode operati selectionSet, this, includeConditions, + hasIncrementalParts, lastId, - compilationContext.ElementsById, - hasIncrementalParts); + compilationContext.ElementsById); } finally { @@ -400,9 +400,7 @@ private static bool IsInternal(FieldNode fieldNode) } private static bool HasDeferDirective(OperationDefinitionNode operation) - { - return DeferDetectionVisitor.Instance.HasDefer(operation); - } + => DeferDetectionVisitor.Instance.HasDefer(operation); private sealed class DeferDetectionVisitor : SyntaxWalker { @@ -445,9 +443,7 @@ private static bool HasDeferDirectiveOnNode(IReadOnlyList directi { for (var i = 0; i < directives.Count; i++) { - if (directives[i].Name.Value.Equals( - DirectiveNames.Defer.Name, - StringComparison.Ordinal)) + if (directives[i].Name.Value.Equals(DirectiveNames.Defer.Name, StringComparison.Ordinal)) { return true; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs index b714447527a..8698f179ec7 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs @@ -163,10 +163,7 @@ internal void Seal(SelectionSet selectionSet) DeclaringSelectionSet = selectionSet; } - public bool IsDeferred(ulong deferFlags) - { - return (_deferMask & deferFlags) != 0; - } + public bool IsDeferred(ulong deferFlags) => (_deferMask & deferFlags) != 0; [Flags] private enum Flags diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs index 67d329204b1..2c9a42617af 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs @@ -83,53 +83,7 @@ public void Format(IBufferWriter writer, OperationPlan plan, OperationPlan jsonWriter.WritePropertyName("nodes"); WriteNodes(jsonWriter, plan.Operation, plan.AllNodes, trace); - if (!plan.DeferredGroups.IsDefaultOrEmpty) - { - jsonWriter.WritePropertyName("deferredGroups"); - jsonWriter.WriteStartArray(); - - foreach (var group in plan.DeferredGroups) - { - jsonWriter.WriteStartObject(); - - jsonWriter.WritePropertyName("deferId"); - jsonWriter.WriteNumberValue(group.DeferId); - - if (group.Label is not null) - { - jsonWriter.WritePropertyName("label"); - jsonWriter.WriteStringValue(group.Label); - } - - jsonWriter.WritePropertyName("path"); - jsonWriter.WriteStringValue(group.Path.ToString()); - - if (group.IfVariable is not null) - { - jsonWriter.WritePropertyName("ifVariable"); - jsonWriter.WriteStringValue("$" + group.IfVariable); - } - - if (group.Parent is not null) - { - jsonWriter.WritePropertyName("parentId"); - jsonWriter.WriteNumberValue(group.Parent.DeferId); - } - - jsonWriter.WritePropertyName("operation"); - WriteOperation(jsonWriter, group.Operation); - - if (!group.AllNodes.IsDefaultOrEmpty) - { - jsonWriter.WritePropertyName("nodes"); - WriteNodes(jsonWriter, group.Operation, group.AllNodes, null); - } - - jsonWriter.WriteEndObject(); - } - - jsonWriter.WriteEndArray(); - } + WriteDeferredGroups(jsonWriter, plan.DeferredGroups); jsonWriter.WriteEndObject(); } @@ -214,6 +168,68 @@ private static void WriteNodes( jsonWriter.WriteEndArray(); } + private static void WriteDeferredGroups( + JsonWriter jsonWriter, + ImmutableArray deferredGroups) + { + if (deferredGroups.IsDefaultOrEmpty) + { + return; + } + + jsonWriter.WritePropertyName("deferredGroups"); + jsonWriter.WriteStartArray(); + + foreach (var group in deferredGroups) + { + WriteDeferredGroup(jsonWriter, group); + } + + jsonWriter.WriteEndArray(); + } + + private static void WriteDeferredGroup( + JsonWriter jsonWriter, + DeferredExecutionGroup group) + { + jsonWriter.WriteStartObject(); + + jsonWriter.WritePropertyName("deferId"); + jsonWriter.WriteNumberValue(group.DeferId); + + if (group.Label is not null) + { + jsonWriter.WritePropertyName("label"); + jsonWriter.WriteStringValue(group.Label); + } + + jsonWriter.WritePropertyName("path"); + jsonWriter.WriteStringValue(group.Path.ToString()); + + if (group.IfVariable is not null) + { + jsonWriter.WritePropertyName("ifVariable"); + jsonWriter.WriteStringValue("$" + group.IfVariable); + } + + if (group.Parent is not null) + { + jsonWriter.WritePropertyName("parentId"); + jsonWriter.WriteNumberValue(group.Parent.DeferId); + } + + jsonWriter.WritePropertyName("operation"); + WriteOperation(jsonWriter, group.Operation); + + if (!group.AllNodes.IsDefaultOrEmpty) + { + jsonWriter.WritePropertyName("nodes"); + WriteNodes(jsonWriter, group.Operation, group.AllNodes, null); + } + + jsonWriter.WriteEndObject(); + } + private static void WriteOperationNode( JsonWriter jsonWriter, Operation operation, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs index 60071ad0369..8ea3bc8515e 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs @@ -87,7 +87,7 @@ public override OperationPlan Parse(ReadOnlyMemory planSourceText) var groupNodes = groupElement.TryGetProperty("nodes", out var groupNodesElement) ? ParseNodes(groupNodesElement, groupOperation) - : ImmutableArray.Empty; + : []; var rootGroupNodes = groupNodes .Where(n => n.Dependencies.Length == 0 && n.OptionalDependencies.Length == 0) diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Should_Return_Incremental_Response_In_Order.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Should_Return_Incremental_Response_In_Order.yaml index ff039f7e595..c7df93a4066 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Should_Return_Incremental_Response_In_Order.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Should_Return_Incremental_Response_In_Order.yaml @@ -143,9 +143,10 @@ operationPlan: } } deferredGroups: - - deferId: 0 + - id: 0 label: outer path: $.user + parentNodeId: 1 nodes: - id: 1 type: Operation @@ -173,7 +174,7 @@ operationPlan: id dependencies: - id: 1 - - deferId: 1 + - id: 1 label: inner path: $.user parentId: 0 From dd907d68c6aed08c636869331babcf7d93b58ee1 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 12 Apr 2026 06:52:50 +0000 Subject: [PATCH 5/9] Align hc and fusion --- .../Execution/Nodes/DeferCondition.cs | 105 ++++++++++++++ .../Nodes/DeferConditionCollection.cs | 50 +++++++ .../Execution/Nodes/DeferUsage.cs | 23 +++ .../Execution/Nodes/FieldSelectionNode.cs | 11 +- .../Execution/Nodes/Operation.cs | 32 +++++ .../Execution/Nodes/OperationCompiler.cs | 134 ++++++++++++++++-- .../Execution/OperationPlanContext.Pooling.cs | 3 + .../Execution/OperationPlanContext.cs | 5 + .../Results/FetchResultStore.Pooling.cs | 6 +- .../Execution/Results/FetchResultStore.cs | 1 + 10 files changed, 357 insertions(+), 13 deletions(-) create mode 100644 src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferCondition.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferConditionCollection.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferUsage.cs diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferCondition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferCondition.cs new file mode 100644 index 00000000000..445a3e3dd28 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferCondition.cs @@ -0,0 +1,105 @@ +using System.Diagnostics.CodeAnalysis; +using HotChocolate.Execution; +using HotChocolate.Language; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Execution.Nodes; + +internal readonly struct DeferCondition(string? ifVariableName) : IEquatable +{ + public string? IfVariableName => ifVariableName; + + public bool IsDeferred(IVariableValueCollection variableValues) + { + if (ifVariableName is not null) + { + if (!variableValues.TryGetValue(ifVariableName, out var value)) + { + throw new InvalidOperationException($"The variable {ifVariableName} has an invalid value."); + } + + if (!value.Value) + { + return false; + } + } + + return true; + } + + public bool Equals(DeferCondition other) + => string.Equals(ifVariableName, other.IfVariableName, StringComparison.Ordinal); + + public override bool Equals([NotNullWhen(true)] object? obj) + => obj is DeferCondition other && Equals(other); + + public override int GetHashCode() + => HashCode.Combine(ifVariableName); + + public static bool TryCreate(InlineFragmentNode inlineFragment, out DeferCondition deferCondition) + => TryCreate(inlineFragment.Directives, out deferCondition); + + public static bool TryCreate(FragmentSpreadNode fragmentSpread, out DeferCondition deferCondition) + => TryCreate(fragmentSpread.Directives, out deferCondition); + + private static bool TryCreate(IReadOnlyList directives, out DeferCondition deferCondition) + { + if (directives.Count == 0) + { + deferCondition = default; + return false; + } + + for (var i = 0; i < directives.Count; i++) + { + var directive = directives[i]; + + if (!directive.Name.Value.Equals(DirectiveNames.Defer.Name, StringComparison.Ordinal)) + { + continue; + } + + // @defer with no arguments is unconditionally deferred. + if (directive.Arguments.Count == 0) + { + deferCondition = new DeferCondition(null); + return true; + } + + for (var j = 0; j < directive.Arguments.Count; j++) + { + var argument = directive.Arguments[j]; + + if (!argument.Name.Value.Equals(DirectiveNames.Defer.Arguments.If, StringComparison.Ordinal)) + { + continue; + } + + switch (argument.Value) + { + // @defer(if: $variable) - conditionally deferred at runtime. + case VariableNode variable: + deferCondition = new DeferCondition(variable.Name.Value); + return true; + + // @defer(if: true) - unconditionally deferred. + case BooleanValueNode { Value: true }: + deferCondition = new DeferCondition(null); + return true; + + // @defer(if: false) - statically not deferred, no condition needed. + case BooleanValueNode { Value: false }: + deferCondition = default; + return false; + } + } + + // @defer directive found but no `if` argument matched - unconditionally deferred. + deferCondition = new DeferCondition(null); + return true; + } + + deferCondition = default; + return false; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferConditionCollection.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferConditionCollection.cs new file mode 100644 index 00000000000..01bf8689c2a --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferConditionCollection.cs @@ -0,0 +1,50 @@ +using System.Collections; + +namespace HotChocolate.Fusion.Execution.Nodes; + +internal sealed class DeferConditionCollection : ICollection +{ + private readonly OrderedDictionary _dictionary = []; + + public DeferCondition this[int index] + => _dictionary.GetAt(index).Key; + + public int Count => _dictionary.Count; + + public bool IsReadOnly => false; + + public bool Add(DeferCondition item) + { + if (_dictionary.Count == 64) + { + throw new InvalidOperationException( + "The maximum number of defer conditions has been reached."); + } + + return _dictionary.TryAdd(item, _dictionary.Count); + } + + void ICollection.Add(DeferCondition item) + => Add(item); + + public bool Remove(DeferCondition item) + => throw new InvalidOperationException("This is an add only collection."); + + void ICollection.Clear() + => throw new InvalidOperationException("This is an add only collection."); + + public bool Contains(DeferCondition item) + => _dictionary.ContainsKey(item); + + public int IndexOf(DeferCondition item) + => _dictionary.GetValueOrDefault(item, -1); + + public void CopyTo(DeferCondition[] array, int arrayIndex) + => _dictionary.Keys.CopyTo(array, arrayIndex); + + public IEnumerator GetEnumerator() + => _dictionary.Keys.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferUsage.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferUsage.cs new file mode 100644 index 00000000000..79ec2ce211f --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferUsage.cs @@ -0,0 +1,23 @@ +namespace HotChocolate.Fusion.Execution.Nodes; + +/// +/// Represents a usage of the @defer directive encountered during operation compilation. +/// Forms a parent chain to model nested defer scopes. +/// +/// +/// The optional label from @defer(label: "..."), used to identify the deferred +/// payload in the incremental delivery response. +/// +/// +/// The parent defer usage when this @defer is nested inside another deferred fragment, +/// or null if this is a top-level defer. +/// +/// +/// The index into the for the if condition +/// associated with this defer directive. This index maps to a bit position in the +/// runtime defer flags bitmask. +/// +public sealed record DeferUsage( + string? Label, + DeferUsage? Parent, + byte DeferConditionIndex); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/FieldSelectionNode.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/FieldSelectionNode.cs index 8c7549306cd..29ca8d9724f 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/FieldSelectionNode.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/FieldSelectionNode.cs @@ -3,7 +3,7 @@ namespace HotChocolate.Fusion.Execution.Nodes; /// -/// Represents a field selection node with its path include flags. +/// Represents a field selection node with its path include flags and defer usage. /// /// /// The syntax node that represents the field selection. @@ -11,4 +11,11 @@ namespace HotChocolate.Fusion.Execution.Nodes; /// /// The flags that must be all set for this selection to be included. /// -public sealed record FieldSelectionNode(FieldNode Node, ulong PathIncludeFlags); +/// +/// The defer usage context this field was collected under, or null if the field +/// is not inside a deferred fragment. +/// +public sealed record FieldSelectionNode( + FieldNode Node, + ulong PathIncludeFlags, + DeferUsage? DeferUsage = null); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs index 2629e918883..9ddb65bb437 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs @@ -20,6 +20,7 @@ public sealed class Operation : IOperation private readonly ConcurrentDictionary<(int, string), SelectionSet> _selectionSets = []; private readonly OperationCompiler _compiler; private readonly IncludeConditionCollection _includeConditions; + private readonly DeferConditionCollection _deferConditions; private readonly OperationFeatureCollection _features; private readonly bool _hasIncrementalParts; private object[] _elementsById; @@ -34,6 +35,7 @@ internal Operation( SelectionSet rootSelectionSet, OperationCompiler compiler, IncludeConditionCollection includeConditions, + DeferConditionCollection deferConditions, bool hasIncrementalParts, int lastId, object[] elementsById) @@ -46,6 +48,7 @@ internal Operation( ArgumentNullException.ThrowIfNull(rootSelectionSet); ArgumentNullException.ThrowIfNull(compiler); ArgumentNullException.ThrowIfNull(includeConditions); + ArgumentNullException.ThrowIfNull(deferConditions); ArgumentNullException.ThrowIfNull(elementsById); Id = id; @@ -56,6 +59,7 @@ internal Operation( RootSelectionSet = rootSelectionSet; _compiler = compiler; _includeConditions = includeConditions; + _deferConditions = deferConditions; _hasIncrementalParts = hasIncrementalParts; _lastId = lastId; _elementsById = elementsById; @@ -168,6 +172,7 @@ public SelectionSet GetSelectionSet(Selection selection, IObjectTypeDefinition t selection, (FusionObjectTypeDefinition)typeContext, _includeConditions, + _deferConditions, ref _elementsById, ref _lastId); selectionSet.Seal(this); @@ -240,6 +245,33 @@ public ulong CreateIncludeFlags(IVariableValueCollection variables) return includeFlags; } + /// + /// Creates the defer flags for the specified variable values. + /// + /// + /// The variable values. + /// + /// + /// Returns the defer flags for the specified variable values. + /// + public ulong CreateDeferFlags(IVariableValueCollection variables) + { + var index = 0; + var deferFlags = 0ul; + + foreach (var deferCondition in _deferConditions) + { + if (deferCondition.IsDeferred(variables)) + { + deferFlags |= 1ul << index; + } + + index++; + } + + return deferFlags; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] internal Selection GetSelectionById(int id) => Unsafe.As(Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(_elementsById), id)); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs index 4fcebd9578e..d5d2691432f 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs @@ -45,7 +45,9 @@ public Operation Compile(string id, string hash, OperationDefinitionNode operati operationDefinition = (OperationDefinitionNode)document.Definitions[0]; var includeConditions = new IncludeConditionCollection(); + var deferConditions = new DeferConditionCollection(); IncludeConditionVisitor.Instance.Visit(operationDefinition, includeConditions); + DeferConditionVisitor.Instance.Visit(operationDefinition, deferConditions); var fields = _fieldsPool.Get(); var compilationContext = new CompilationContext(s_objectArrayPool.Rent(128)); @@ -61,7 +63,9 @@ public Operation Compile(string id, string hash, OperationDefinitionNode operati operationDefinition.SelectionSet.Selections, rootType, fields, - includeConditions); + includeConditions, + deferConditions, + parentDeferUsage: null); var hasIncrementalParts = HasDeferDirective(operationDefinition); @@ -82,6 +86,7 @@ public Operation Compile(string id, string hash, OperationDefinitionNode operati selectionSet, this, includeConditions, + deferConditions, hasIncrementalParts, lastId, compilationContext.ElementsById); @@ -96,6 +101,7 @@ internal SelectionSet CompileSelectionSet( Selection selection, FusionObjectTypeDefinition objectType, IncludeConditionCollection includeConditions, + DeferConditionCollection deferConditions, ref object[] elementsById, ref int lastId) { @@ -113,7 +119,9 @@ internal SelectionSet CompileSelectionSet( first.Node.SelectionSet!.Selections, objectType, fields, - includeConditions); + includeConditions, + deferConditions, + parentDeferUsage: first.DeferUsage); if (nodes.Length > 1) { @@ -126,7 +134,9 @@ internal SelectionSet CompileSelectionSet( node.Node.SelectionSet!.Selections, objectType, fields, - includeConditions); + includeConditions, + deferConditions, + parentDeferUsage: nodes[i].DeferUsage); } } @@ -146,7 +156,9 @@ private void CollectFields( IReadOnlyList selections, IObjectTypeDefinition typeContext, OrderedDictionary> fields, - IncludeConditionCollection includeConditions) + IncludeConditionCollection includeConditions, + DeferConditionCollection deferConditions, + DeferUsage? parentDeferUsage) { for (var i = 0; i < selections.Count; i++) { @@ -169,10 +181,9 @@ private void CollectFields( pathIncludeFlags |= 1ul << index; } - nodes.Add(new FieldSelectionNode(fieldNode, pathIncludeFlags)); + nodes.Add(new FieldSelectionNode(fieldNode, pathIncludeFlags, parentDeferUsage)); } - - if (selection is InlineFragmentNode inlineFragmentNode + else if (selection is InlineFragmentNode inlineFragmentNode && DoesTypeApply(inlineFragmentNode.TypeCondition, typeContext)) { var pathIncludeFlags = parentIncludeFlags; @@ -183,12 +194,24 @@ private void CollectFields( pathIncludeFlags |= 1ul << index; } + var newDeferUsage = parentDeferUsage; + + if (DeferCondition.TryCreate(inlineFragmentNode, out var deferCondition)) + { + deferConditions.Add(deferCondition); + var deferIndex = deferConditions.IndexOf(deferCondition); + var label = GetDeferLabel(inlineFragmentNode); + newDeferUsage = new DeferUsage(label, parentDeferUsage, (byte)deferIndex); + } + CollectFields( pathIncludeFlags, inlineFragmentNode.SelectionSet.Selections, typeContext, fields, - includeConditions); + includeConditions, + deferConditions, + newDeferUsage); } } } @@ -203,20 +226,28 @@ private SelectionSet BuildSelectionSet( var selections = new Selection[fieldMap.Count]; var isConditional = false; var includeFlags = new List(); + var deferUsages = new List(); var selectionSetId = ++lastId; foreach (var (responseName, nodes) in fieldMap) { includeFlags.Clear(); + deferUsages.Clear(); var first = nodes[0]; var isInternal = IsInternal(first.Node); + var hasNonDeferredNode = first.DeferUsage is null; if (first.PathIncludeFlags > 0) { includeFlags.Add(first.PathIncludeFlags); } + if (first.DeferUsage is not null) + { + deferUsages.Add(first.DeferUsage); + } + if (nodes.Count > 1) { for (var j = 1; j < nodes.Count; j++) @@ -234,6 +265,15 @@ private SelectionSet BuildSelectionSet( includeFlags.Add(next.PathIncludeFlags); } + if (next.DeferUsage is null) + { + hasNonDeferredNode = true; + } + else if (!hasNonDeferredNode) + { + deferUsages.Add(next.DeferUsage); + } + if (isInternal) { isInternal = IsInternal(next.Node); @@ -246,6 +286,36 @@ private SelectionSet BuildSelectionSet( CollapseIncludeFlags(includeFlags); } + // If any field node is not inside a deferred fragment, the selection + // is not deferred — it must be included in the initial response. + ulong deferMask = 0; + + if (!hasNonDeferredNode && deferUsages.Count > 0) + { + // Remove child defer usages when their parent is also in the set. + // A field should be delivered with the outermost (earliest) defer + // that contains it. + for (var j = deferUsages.Count - 1; j >= 0; j--) + { + var parent = deferUsages[j].Parent; + while (parent is not null) + { + if (deferUsages.Contains(parent)) + { + deferUsages.RemoveAt(j); + break; + } + + parent = parent.Parent; + } + } + + foreach (var usage in deferUsages) + { + deferMask |= 1ul << usage.DeferConditionIndex; + } + } + IOutputFieldDefinition field = first.Node.Name.Value.Equals(IntrospectionFieldNames.TypeName) ? _typeNameField : typeContext.Fields.GetField(first.Node.Name.Value, allowInaccessibleFields: true); @@ -256,7 +326,8 @@ private SelectionSet BuildSelectionSet( field, nodes.ToArray(), includeFlags.ToArray(), - isInternal); + isInternal, + deferMask); // Register the selection in the elements array compilationContext.Register(selection, selection.Id); @@ -458,6 +529,34 @@ internal sealed class Context } } + private static string? GetDeferLabel(InlineFragmentNode node) + { + for (var i = 0; i < node.Directives.Count; i++) + { + var directive = node.Directives[i]; + + if (!directive.Name.Value.Equals(DirectiveNames.Defer.Name, StringComparison.Ordinal)) + { + continue; + } + + for (var j = 0; j < directive.Arguments.Count; j++) + { + var arg = directive.Arguments[j]; + + if (arg.Name.Value.Equals(DirectiveNames.Defer.Arguments.Label, StringComparison.Ordinal) + && arg.Value is StringValueNode labelValue) + { + return labelValue.Value; + } + } + + return null; + } + + return null; + } + private class IncludeConditionVisitor : SyntaxWalker { public static readonly IncludeConditionVisitor Instance = new(); @@ -487,6 +586,23 @@ protected override ISyntaxVisitorAction Enter( } } + private class DeferConditionVisitor : SyntaxWalker + { + public static readonly DeferConditionVisitor Instance = new(); + + protected override ISyntaxVisitorAction Enter( + InlineFragmentNode node, + DeferConditionCollection context) + { + if (DeferCondition.TryCreate(node, out var condition)) + { + context.Add(condition); + } + + return base.Enter(node, context); + } + } + private class CompilationContext(object[] elementsById) { private object[] _elementsById = elementsById; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.Pooling.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.Pooling.cs index d2d1c9eb509..42a1139dabc 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.Pooling.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.Pooling.cs @@ -35,6 +35,7 @@ internal void Initialize( Variables = variables; OperationPlan = operationPlan; IncludeFlags = operationPlan.Operation.CreateIncludeFlags(variables); + DeferFlags = operationPlan.Operation.CreateDeferFlags(variables); _collectTelemetry = requestContext.CollectOperationPlanTelemetry(); _clientScope = requestContext.CreateClientScope(); @@ -44,6 +45,7 @@ internal void Initialize( operationPlan.Operation, requestContext.ErrorHandlingMode(), IncludeFlags, + DeferFlags, requestContext.Schema.GetOptions().PathSegmentLocalPoolCapacity); _executionState.Initialize(_collectTelemetry, cancellationTokenSource); @@ -75,6 +77,7 @@ internal void Clean() RequestContext = default!; Variables = default!; OperationPlan = default!; + DeferFlags = 0; _clientScope = default!; Traces = #if NET10_0_OR_GREATER diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs index be0608a228d..816327c5aa6 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs @@ -79,6 +79,11 @@ internal bool IsNodeSkipped(int nodeId) /// public ulong IncludeFlags { get; private set; } + /// + /// Gets the evaluated defer flags derived from @defer directives. + /// + public ulong DeferFlags { get; private set; } + /// /// Gets a value indicating whether operation plan telemetry is being collected for this request. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs index 8d7a62d516d..82d245c110b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs @@ -17,6 +17,7 @@ public void Initialize( Operation operation, ErrorHandlingMode errorHandlingMode, ulong includeFlags, + ulong deferFlags, int pathSegmentLocalPoolCapacity) { ArgumentNullException.ThrowIfNull(schema); @@ -27,10 +28,11 @@ public void Initialize( _operation = operation; _errorHandlingMode = errorHandlingMode; _includeFlags = includeFlags; + _deferFlags = deferFlags; _disposed = false; _pathPool ??= new PathSegmentLocalPool(pathSegmentLocalPoolCapacity); - _result = new CompositeResultDocument(operation, includeFlags, _pathPool); + _result = new CompositeResultDocument(operation, includeFlags, deferFlags, _pathPool); _valueCompletion = new ValueCompletion( this, @@ -46,7 +48,7 @@ public void Reset() { ObjectDisposedException.ThrowIf(_disposed, this); - _result = new CompositeResultDocument(_operation, _includeFlags, _pathPool); + _result = new CompositeResultDocument(_operation, _includeFlags, _deferFlags, _pathPool); _errors?.Clear(); _pocketedErrors?.Clear(); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs index 055387bc0a7..25ea6933051 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -36,6 +36,7 @@ internal sealed partial class FetchResultStore : IDisposable private Operation _operation = default!; private ErrorHandlingMode _errorHandlingMode; private ulong _includeFlags; + private ulong _deferFlags; private CompositeResultElement[] _collectTargetA = ArrayPool.Shared.Rent(64); private CompositeResultElement[] _collectTargetB = ArrayPool.Shared.Rent(64); private CompositeResultElement[] _collectTargetCombined = ArrayPool.Shared.Rent(64); From b4a85b1702b2becc6283977efc3504fd756052f6 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 12 Apr 2026 10:44:05 +0000 Subject: [PATCH 6/9] update agent mds --- AGENTS.md | 1 + CLAUDE.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index a43d5e101e1..12bf1b73fcc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,7 @@ dotnet test src/HotChocolate/Fusion - Use test naming format: `Method_Should_Outcome_When_Condition`. - Do not write vacuous assertions (`Assert.NotNull` alone is not a complete test). - If a test requires excessive stubs and reflection, use a more appropriate test tier. +- Do not use em dash style sentences in docs, comments, or XML documentation. Use commas, periods, parentheses, or colons instead. ### Testing diff --git a/CLAUDE.md b/CLAUDE.md index f2a1c6394d3..75fe14b74bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,11 +47,12 @@ dotnet test src/HotChocolate/Fusion ### C# / .NET -- Always use curly braces for loops and conditionals — no exceptions +- Always use curly braces for loops and conditionals, no exceptions - File-scoped namespaces, 4-space indent - Test naming: `Method_Should_Outcome_When_Condition` - No vacuous assertions (`Assert.NotNull` alone is not a test) - If you need 8 stubs + reflection, you're at the wrong test tier +- Do not use em dash style sentences in docs, comments, or XML documentation. Use commas, periods, parentheses, or colons instead. ### Testing From 8c8ddcea73a9652830986c1d10830457e1e6a2f7 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 05:39:56 +0000 Subject: [PATCH 7/9] edits --- .../Execution/Nodes/SelectionSet.cs | 7 +++- .../Planning/DeferOperationRewriter.cs | 33 +++++++++++++++++++ .../Planning/OperationPlanner.cs | 2 +- .../Planning/OperationPlannerOptions.cs | 15 +++++++++ .../Text/Json/CompositeResultDocument.cs | 5 ++- 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs index 243c0a88c36..368eca8cee4 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs @@ -19,7 +19,12 @@ public sealed class SelectionSet : ISelectionSet private readonly bool _hasIncrementalParts; private bool _isSealed; - public SelectionSet(int id, IObjectTypeDefinition type, Selection[] selections, bool isConditional, bool hasIncrementalParts = false) + public SelectionSet( + int id, + IObjectTypeDefinition type, + Selection[] selections, + bool isConditional, + bool hasIncrementalParts) { ArgumentNullException.ThrowIfNull(selections); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs index 5f29c6d2dbf..a596b1060b4 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs @@ -11,6 +11,12 @@ namespace HotChocolate.Fusion.Planning; internal sealed class DeferOperationRewriter { private int _nextDeferId; + private readonly bool _inlineUnlabeledNestedDefers; + + internal DeferOperationRewriter(bool inlineUnlabeledNestedDefers = true) + { + _inlineUnlabeledNestedDefers = inlineUnlabeledNestedDefers; + } /// /// Fast check whether the operation contains any @defer directives. @@ -140,6 +146,33 @@ private SelectionSetNode StripDeferFragments( continue; } + // If inlining unlabeled nested defers, treat an unlabeled @defer inside + // a parent defer the same as @defer(if: false) — keep it inline. + // Only inline when the condition matches the parent (or has no condition), + // otherwise the conditional semantics would be lost. + if (_inlineUnlabeledNestedDefers + && label is null + && parentDeferFragment is not null + && (ifVariable is null || ifVariable == parentDeferFragment.IfVariable)) + { + var stripped = StripDeferDirective(inlineFragment); + var newInnerSelectionSet = StripDeferFragments( + stripped.SelectionSet, + parentPath, + deferredFragments, + rootOperation, + parentDeferFragment); + + if (!ReferenceEquals(newInnerSelectionSet, stripped.SelectionSet)) + { + stripped = stripped.WithSelectionSet(newInnerSelectionSet); + } + + selections.Add(stripped); + modified = true; + continue; + } + var deferId = _nextDeferId++; // Create the descriptor first so nested defers can reference it as parent. diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs index 935580f06a7..1a606f84166 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -104,7 +104,7 @@ public OperationPlan CreatePlan( if (_options.EnableDefer && DeferOperationRewriter.HasDeferDirective(operationDefinition)) { - var rewriter = new DeferOperationRewriter(); + var rewriter = new DeferOperationRewriter(_options.InlineUnlabeledDeferFragments); var splitResult = rewriter.Split(operationDefinition); if (!splitResult.DeferredFragments.IsEmpty) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlannerOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlannerOptions.cs index 7e783d96a0a..e915566f543 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlannerOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlannerOptions.cs @@ -71,6 +71,21 @@ public bool EnableDefer } } = true; + /// + /// When enabled, nested @defer fragments without a label are inlined + /// into their parent deferred group instead of producing a separate group. + /// This reduces incremental delivery overhead at the cost of less granular streaming. + /// + public bool InlineUnlabeledDeferFragments + { + get; + set + { + ExpectMutableOptions(); + field = value; + } + } = true; + /// /// Gets or sets how aggressively structurally-identical operations are merged /// to reduce downstream request count. Cycle safety is always enforced regardless diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs index 314461f23cd..6350184db82 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs @@ -13,6 +13,7 @@ public sealed partial class CompositeResultDocument : IDisposable private readonly List _sources = []; private readonly Operation _operation; private readonly ulong _includeFlags; + private readonly ulong _deferFlags; private readonly PathSegmentLocalPool? _pathPool; internal MetaDb _metaDb; private bool _disposed; @@ -20,11 +21,13 @@ public sealed partial class CompositeResultDocument : IDisposable internal CompositeResultDocument( Operation operation, ulong includeFlags, + ulong deferFlags = 0, PathSegmentLocalPool? pathPool = null) { _metaDb = MetaDb.CreateForEstimatedRows(Cursor.RowsPerChunk * 8); _operation = operation; _includeFlags = includeFlags; + _deferFlags = deferFlags; _pathPool = pathPool; Data = CreateObject(Cursor.Zero, operation.RootSelectionSet); @@ -492,7 +495,7 @@ private void WriteEmptyProperty(Cursor parent, Selection selection) flags = ElementFlags.IsInternal; } - if (!selection.IsIncluded(_includeFlags)) + if (!selection.IsIncluded(_includeFlags) || selection.IsDeferred(_deferFlags)) { flags |= ElementFlags.IsExcluded; } From eed31fd0953cf1011d426947b9175e09abc286e6 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 05:41:18 +0000 Subject: [PATCH 8/9] edits --- .../Fusion.Execution/Execution/Nodes/OperationCompiler.cs | 5 ++++- .../src/Fusion.Execution/Execution/Nodes/SelectionSet.cs | 8 +++++--- .../Nodes/Serialization/JsonOperationPlanFormatter.cs | 2 +- .../Nodes/Serialization/JsonOperationPlanParser.cs | 2 +- .../Nodes/Serialization/YamlOperationPlanFormatter.cs | 2 +- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs index d5d2691432f..2b4982438c6 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs @@ -225,6 +225,7 @@ private SelectionSet BuildSelectionSet( var i = 0; var selections = new Selection[fieldMap.Count]; var isConditional = false; + var hasIncrementalParts = false; var includeFlags = new List(); var deferUsages = new List(); var selectionSetId = ++lastId; @@ -314,6 +315,8 @@ private SelectionSet BuildSelectionSet( { deferMask |= 1ul << usage.DeferConditionIndex; } + + hasIncrementalParts = true; } IOutputFieldDefinition field = first.Node.Name.Value.Equals(IntrospectionFieldNames.TypeName) @@ -339,7 +342,7 @@ private SelectionSet BuildSelectionSet( } } - return new SelectionSet(selectionSetId, typeContext, selections, isConditional); + return new SelectionSet(selectionSetId, typeContext, selections, isConditional, hasIncrementalParts); } private static void CollapseIncludeFlags(List includeFlags) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs index 368eca8cee4..48f2154c27c 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs @@ -16,7 +16,6 @@ public sealed class SelectionSet : ISelectionSet private readonly Selection[] _selections; private readonly FrozenDictionary _responseNameLookup; private readonly SelectionLookup _utf8ResponseNameLookup; - private readonly bool _hasIncrementalParts; private bool _isSealed; public SelectionSet( @@ -36,7 +35,7 @@ public SelectionSet( Id = id; Type = type; IsConditional = isConditional; - _hasIncrementalParts = hasIncrementalParts; + HasIncrementalParts = hasIncrementalParts; _selections = selections; _responseNameLookup = _selections.ToFrozenDictionary(t => t.ResponseName); _utf8ResponseNameLookup = SelectionLookup.Create(this); @@ -69,7 +68,10 @@ public SelectionSet( /// public ReadOnlySpan Selections => _selections; - public bool HasIncrementalParts => _hasIncrementalParts; + /// + /// Gets a value indicating whether the selection set contains deferred selections. + /// + public bool HasIncrementalParts { get; } IEnumerable ISelectionSet.GetSelections() => _selections; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs index 2c9a42617af..87ae3b693f2 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs @@ -194,7 +194,7 @@ private static void WriteDeferredGroup( { jsonWriter.WriteStartObject(); - jsonWriter.WritePropertyName("deferId"); + jsonWriter.WritePropertyName("id"); jsonWriter.WriteNumberValue(group.DeferId); if (group.Label is not null) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs index 8ea3bc8515e..1a2e7f700c5 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs @@ -61,7 +61,7 @@ public override OperationPlan Parse(ReadOnlyMemory planSourceText) foreach (var groupElement in deferredGroupsElement.EnumerateArray()) { - var deferId = groupElement.GetProperty("deferId").GetInt32(); + var deferId = groupElement.GetProperty("id").GetInt32(); string? label = null; if (groupElement.TryGetProperty("label", out var labelElement)) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/YamlOperationPlanFormatter.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/YamlOperationPlanFormatter.cs index 1c4acfee4ee..b648778ab87 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/YamlOperationPlanFormatter.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/YamlOperationPlanFormatter.cs @@ -70,7 +70,7 @@ private static void WriteNode(ExecutionNode node, ExecutionNodeTrace? nodeTrace, private static void WriteDeferredGroup(DeferredExecutionGroup group, CodeWriter writer) { - writer.WriteLine("- deferId: {0}", group.DeferId); + writer.WriteLine("- id: {0}", group.DeferId); writer.Indent(); if (group.Label is not null) From a331a92d4d6f46e002e7af658f753e553a42e539 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 05:41:46 +0000 Subject: [PATCH 9/9] edits --- .../Fusion.AspNetCore.Tests/DeferTests.cs | 131 +++++++++++ ...able_Should_Return_NonStreamed_Result.yaml | 2 +- ...ariable_Should_Return_Streamed_Result.yaml | 2 +- ..._Return_Incremental_Response_In_Order.yaml | 1 - ...er_Should_Return_Incremental_Response.yaml | 197 +++++++++++++++++ ...Fragment_Returns_Incremental_Response.yaml | 2 +- ...Overlapping_Fields_Should_Deduplicate.yaml | 208 ++++++++++++++++++ ...d_Return_Error_In_Incremental_Payload.yaml | 2 +- 8 files changed, 540 insertions(+), 5 deletions(-) create mode 100644 src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Without_Label_On_Inner_Should_Return_Incremental_Response.yaml create mode 100644 src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Two_Siblings_With_Overlapping_Fields_Should_Deduplicate.yaml diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs index 3c484c33618..a8a920c009f 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs @@ -275,6 +275,137 @@ ... @defer(label: "inner") { await MatchSnapshotAsync(gateway, request, result); } + [Fact] + public async Task Defer_Nested_Without_Label_On_Inner_Should_Return_Incremental_Response() + { + // arrange — outer @defer has a label, inner @defer has no label. + // Per spec, label is optional; the pending entry for the inner defer + // should omit the label field. + using var server1 = CreateSourceSchema( + "A", + """ + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """); + + using var server2 = CreateSourceSchema( + "B", + """ + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + address: String! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query { + user(id: "1") { + name + ... @defer(label: "outer") { + email + ... @defer { + address + } + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Defer_Two_Siblings_With_Overlapping_Fields_Should_Deduplicate() + { + // arrange — two sibling @defer fragments both select the same field (email). + // Per spec, if a field appears in multiple deferred fragments, it should be + // delivered with the earliest completing group and not duplicated. + using var server1 = CreateSourceSchema( + "A", + """ + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """); + + using var server2 = CreateSourceSchema( + "B", + """ + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + address: String! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query { + user(id: "1") { + name + ... @defer(label: "contact") { + email + } + ... @defer(label: "location") { + email + address + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + [Fact] public async Task Defer_With_Error_In_Deferred_Fragment_Should_Return_Error_In_Incremental_Payload() { diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfFalse_Variable_Should_Return_NonStreamed_Result.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfFalse_Variable_Should_Return_NonStreamed_Result.yaml index 06598bedec4..6b7f38555e3 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfFalse_Variable_Should_Return_NonStreamed_Result.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfFalse_Variable_Should_Return_NonStreamed_Result.yaml @@ -198,7 +198,7 @@ operationPlan: dependencies: - id: 1 deferredGroups: - - deferId: 0 + - id: 0 label: reviews path: $.user ifVariable: $shouldDefer diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfTrue_Variable_Should_Return_Streamed_Result.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfTrue_Variable_Should_Return_Streamed_Result.yaml index 20e9ca52ab9..4e9ca1d0661 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfTrue_Variable_Should_Return_Streamed_Result.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfTrue_Variable_Should_Return_Streamed_Result.yaml @@ -193,7 +193,7 @@ operationPlan: dependencies: - id: 1 deferredGroups: - - deferId: 0 + - id: 0 label: reviews path: $.user ifVariable: $shouldDefer diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Should_Return_Incremental_Response_In_Order.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Should_Return_Incremental_Response_In_Order.yaml index c7df93a4066..78352dbc7bc 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Should_Return_Incremental_Response_In_Order.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Should_Return_Incremental_Response_In_Order.yaml @@ -146,7 +146,6 @@ operationPlan: - id: 0 label: outer path: $.user - parentNodeId: 1 nodes: - id: 1 type: Operation diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Without_Label_On_Inner_Should_Return_Incremental_Response.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Without_Label_On_Inner_Should_Return_Incremental_Response.yaml new file mode 100644 index 00000000000..8360e065772 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Without_Label_On_Inner_Should_Return_Incremental_Response.yaml @@ -0,0 +1,197 @@ +title: Defer_Nested_Without_Label_On_Inner_Should_Return_Incremental_Response +request: + document: | + { + user(id: "1") { + name + ... @defer(label: "outer") { + email + ... @defer { + address + } + } + } + } +responseStream: + - body: | + { + "data": { + "user": { + "name": "User: VXNlcjox" + } + } + } + - body: | + {} +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_f58e5f87_1 { + user(id: "1") { + name + } + } + response: + results: + - | + { + "data": { + "user": { + "name": "User: VXNlcjox" + } + } + } + - | + { + "data": { + "user": { + "id": "VXNlcjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + address: String! + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + kind: OperationBatch + items: + - document: | + query Op_defer_2($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + email + } + } + variables: | + { + "__fusion_1_id": "VXNlcjox" + } + - document: | + query Op_defer_3($__fusion_2_id: ID!) { + userById(id: $__fusion_2_id) { + address + } + } + variables: | + { + "__fusion_2_id": "VXNlcjox" + } + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "userById": { + "email": "User: VXNlcjox" + } + } + } + - | + { + "data": { + "userById": { + "address": "User: VXNlcjox" + } + } + } +operationPlan: + operation: + - document: | + { + user(id: "1") { + name + } + } + hash: f58e5f87c8862b9cc4c515a1fb51b598 + searchSpace: 1 + expandedNodes: 1 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_f58e5f87_1 { + user(id: "1") { + name + } + } + deferredGroups: + - id: 0 + label: outer + path: $.user + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_defer_1 { + user(id: "1") { + id + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_defer_2($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + email + } + } + source: $.userById + target: $.user + batchingGroupId: 2 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 3 + type: Operation + schema: B + operation: | + query Op_defer_3($__fusion_2_id: ID!) { + userById(id: $__fusion_2_id) { + address + } + } + source: $.userById + target: $.user + batchingGroupId: 2 + requirements: + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Single_Fragment_Returns_Incremental_Response.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Single_Fragment_Returns_Incremental_Response.yaml index ab6a4c398cb..f43060fd45a 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Single_Fragment_Returns_Incremental_Response.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Single_Fragment_Returns_Incremental_Response.yaml @@ -147,7 +147,7 @@ operationPlan: } } deferredGroups: - - deferId: 0 + - id: 0 label: reviews path: $.user nodes: diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Two_Siblings_With_Overlapping_Fields_Should_Deduplicate.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Two_Siblings_With_Overlapping_Fields_Should_Deduplicate.yaml new file mode 100644 index 00000000000..80478c344cb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Two_Siblings_With_Overlapping_Fields_Should_Deduplicate.yaml @@ -0,0 +1,208 @@ +title: Defer_Two_Siblings_With_Overlapping_Fields_Should_Deduplicate +request: + document: | + { + user(id: "1") { + name + ... @defer(label: "contact") { + email + } + ... @defer(label: "location") { + email + address + } + } + } +responseStream: + - body: | + { + "data": { + "user": { + "name": "User: VXNlcjox" + } + } + } + - body: | + {} + - body: | + {} +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_29ccee0f_1 { + user(id: "1") { + name + } + } + response: + results: + - | + { + "data": { + "user": { + "name": "User: VXNlcjox" + } + } + } + - | + { + "data": { + "user": { + "id": "VXNlcjox" + } + } + } + - | + { + "data": { + "user": { + "id": "VXNlcjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + address: String! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_defer_2($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + email + } + } + variables: | + { + "__fusion_1_id": "VXNlcjox" + } + response: + results: + - | + { + "data": { + "userById": { + "email": "User: VXNlcjox" + } + } + } + - | + { + "data": { + "userById": { + "email": "User: VXNlcjox", + "address": "User: VXNlcjox" + } + } + } +operationPlan: + operation: + - document: | + { + user(id: "1") { + name + } + } + hash: 29ccee0fa1c82a6cb002b417ee776893 + searchSpace: 1 + expandedNodes: 1 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_29ccee0f_1 { + user(id: "1") { + name + } + } + deferredGroups: + - id: 0 + label: contact + path: $.user + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_defer_1 { + user(id: "1") { + id + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_defer_2($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + email + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 1 + label: location + path: $.user + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_defer_1 { + user(id: "1") { + id + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_defer_2($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + email + address + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Error_In_Deferred_Fragment_Should_Return_Error_In_Incremental_Payload.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Error_In_Deferred_Fragment_Should_Return_Error_In_Incremental_Payload.yaml index 4cc82da686e..0376c55316a 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Error_In_Deferred_Fragment_Should_Return_Error_In_Incremental_Payload.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Error_In_Deferred_Fragment_Should_Return_Error_In_Incremental_Payload.yaml @@ -129,7 +129,7 @@ operationPlan: } } deferredGroups: - - deferId: 0 + - id: 0 label: email path: $.user nodes: