Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
93df4e8
Fix cost analysis for empty selection sets
michaelstaib Sep 1, 2026
a256fd9
Fix empty subscription root validation
michaelstaib Sep 1, 2026
c25d3cc
Fix validation snapshot delimiters
michaelstaib Sep 1, 2026
fb86950
Reject empty fragment selection sets
michaelstaib Sep 1, 2026
515ccb4
Add empty selection set option
michaelstaib Sep 1, 2026
b4d17bf
Clean up empty selection validation snapshots
michaelstaib Sep 1, 2026
1e3236b
Add empty selection execution coverage
michaelstaib Sep 1, 2026
c99ec4a
Add Fusion empty selection set option
michaelstaib Sep 1, 2026
56486f6
Clarify empty selection set option docs
michaelstaib Sep 2, 2026
214815e
Document empty selection set options
michaelstaib Sep 2, 2026
24e2b6a
Fix request validation options guidance
michaelstaib Sep 2, 2026
cd7c7d9
Merge branch 'main' into mst/empty-selectionsets
michaelstaib Sep 2, 2026
c623f3f
Fix empty selection set validation options
michaelstaib Sep 7, 2026
81f7461
Update SchemaOptions snapshot
michaelstaib Sep 7, 2026
575284a
Correct empty selection set option guidance
michaelstaib Sep 7, 2026
51cdd42
Make comment more clear
michaelstaib Sep 7, 2026
14cbd42
Add empty selection set execution coverage
michaelstaib Sep 7, 2026
52348ca
test(fusion): cover empty selection set gateway shapes
michaelstaib Sep 7, 2026
d111b5c
Update empty selection set compiler coverage
michaelstaib Sep 7, 2026
74a4388
polish
michaelstaib Sep 7, 2026
e76db41
Merge branch 'main' into mst/empty-selectionsets
michaelstaib Sep 7, 2026
6661fe3
Restore CostContext initialization
michaelstaib Sep 7, 2026
d37c54a
polish
michaelstaib Sep 7, 2026
af30400
polish
michaelstaib Sep 7, 2026
c5eadec
polish
michaelstaib Sep 7, 2026
68f4a74
Clarify summary for EnableEmptySelectionSets property
michaelstaib Sep 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -445,11 +445,14 @@ private static void BuildDocumentValidator(
serviceCollection.AddSingleton(sp =>
{
var rootServices = sp.GetRootServiceProvider();
// The document validator is resolved after the schema is assigned to LazySchema.
var options = sp.GetRequiredService<ISchemaDefinition>().GetOptions();

var builder =
DocumentValidatorBuilder.New()
.SetServices(rootServices)
.AddDefaultRules();
.AddDefaultRules()
.ModifyOptions(o => o.EnableEmptySelectionSets = options.EnableEmptySelectionSets);

foreach (var hook in hooks)
{
Expand Down
6 changes: 6 additions & 0 deletions src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ public interface IReadOnlySchemaOptions
/// </summary>
bool EnableStream { get; }

/// <summary>
/// Enables empty selection sets according to GraphQL specification PR 1227; defaults to <c>false</c>.
/// When disabled, an empty selection set is a validation error, and an empty subscription root is invalid in either state.
/// </summary>
bool EnableEmptySelectionSets { get; }

/// <summary>
/// Specified if the leading I shall be stripped from the interface name.
/// </summary>
Expand Down
4 changes: 4 additions & 0 deletions src/HotChocolate/Core/src/Types/SchemaOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ public FieldBindingFlags DefaultFieldBindingFlags
/// <inheritdoc cref="IReadOnlySchemaOptions.EnableStream"/>
public bool EnableStream { get; set; }

/// <inheritdoc cref="IReadOnlySchemaOptions.EnableEmptySelectionSets"/>
public bool EnableEmptySelectionSets { get; set; }

/// <inheritdoc cref="IReadOnlySchemaOptions.StripLeadingIFromInterface"/>
public bool StripLeadingIFromInterface { get; set; }

Expand Down Expand Up @@ -238,6 +241,7 @@ public static SchemaOptions FromOptions(IReadOnlySchemaOptions options)
EnableFlagEnums = options.EnableFlagEnums,
EnableDefer = options.EnableDefer,
EnableStream = options.EnableStream,
EnableEmptySelectionSets = options.EnableEmptySelectionSets,
DefaultFieldBindingFlags = options.DefaultFieldBindingFlags,
StripLeadingIFromInterface = options.StripLeadingIFromInterface,
EnableTag = options.EnableTag,
Expand Down
13 changes: 13 additions & 0 deletions src/HotChocolate/Core/src/Validation/ErrorHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,19 @@ public IError NoSelectionOnRootType(OperationDefinitionNode node, IType fieldTyp
.Build();
}

public IError NoSelectionOnFragment(SelectionSetNode node, IType typeCondition)
{
return ErrorBuilder.New()
.SetMessage(
Resources.ErrorHelper_NoSelectionOnFragment,
typeCondition.NamedType().Name)
.AddLocation(node)
.SetPath(context.CreateErrorPath())
.SetExtension("type", typeCondition.NamedType().Name)
.SpecifiedBy("sec-Field-Selections")
.Build();
}

public IError FieldIsRequiredButNull(ISyntaxNode node, string fieldName)
{
return ErrorBuilder.New()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ public static DocumentValidatorBuilder AddFieldRules(
{
return builder
.AddRule<FieldSelectionsRule>()
.AddRule<LeafFieldSelectionsRule>()
.AddRule((_, o) => new LeafFieldSelectionsRule(o.EnableEmptySelectionSets))
.AddRule((_, o) => new OverlappingFieldsCanBeMergedRule(o.MaxAllowedFieldMergeComparisons));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ public sealed class ValidationOptions
, IErrorOptionsAccessor
, IIntrospectionOptionsAccessor
{
/// <summary>
/// Specifies whether empty selection sets are valid. Defaults to <c>false</c>.
/// </summary>
public bool EnableEmptySelectionSets { get; set; }

/// <summary>
/// Gets the maximum allowed depth of a query. The default value is
/// <see langword="null"/>. The minimum allowed value is <c>1</c>.
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@
<data name="ErrorHelper_NoSelectionOnCompositeField" xml:space="preserve">
<value>Field "{0}" of type "{1}" must have a selection of subfields. Did you mean "{0} {{ ... }}"?</value>
</data>
<data name="ErrorHelper_NoSelectionOnFragment" xml:space="preserve">
<value>The fragment on type "{0}" has an empty selection set. Fragments without selections are disallowed.</value>
</data>
<data name="ErrorHelper_NoSelectionOnRootType" xml:space="preserve">
<value>Operation `{0}` has an empty selection set. Root types without selections are disallowed.</value>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ namespace HotChocolate.Validation.Rules;

internal sealed class LeafFieldSelectionsRule : IDocumentValidatorRule
{
private readonly bool _enableEmptySelectionSets;

public LeafFieldSelectionsRule(bool enableEmptySelectionSets = false)
{
_enableEmptySelectionSets = enableEmptySelectionSets;
}

public ushort Priority => ushort.MaxValue;

public bool IsCacheable => true;
Expand All @@ -22,10 +29,13 @@ public void Validate(DocumentValidatorContext context, DocumentNode document)
{
if (operationDef.SelectionSet.Selections.Count == 0)
{
context.ReportError(
context.NoSelectionOnRootType(
operationDef,
rootType));
if (!_enableEmptySelectionSets || operationDef.Operation == OperationType.Subscription)
{
context.ReportError(
context.NoSelectionOnRootType(
operationDef,
rootType));
}

continue;
}
Expand All @@ -40,6 +50,16 @@ private void ValidateSelectionSet(
SelectionSetNode selectionSet,
IType type)
{
if (selectionSet.Selections.Count == 0)
{
if (!_enableEmptySelectionSets)
{
context.ReportError(context.NoSelectionOnFragment(selectionSet, type));
}

return;
}

foreach (var selection in selectionSet.Selections)
{
if (selection is FieldNode field)
Expand Down Expand Up @@ -101,11 +121,19 @@ private void ValidateField(
}
else
{
if (field.SelectionSet is null or { Selections.Count: 0 })
if (field.SelectionSet is null)
{
context.ReportError(
context.NoSelectionOnCompositeField(field, complex, fieldDef.Type));
}
else if (field.SelectionSet.Selections.Count == 0)
{
if (!_enableEmptySelectionSets)
{
context.ReportError(
context.NoSelectionOnCompositeField(field, complex, fieldDef.Type));
}
}
else
{
ValidateSelectionSet(context, field.SelectionSet, fieldDef.Type);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ namespace HotChocolate.Validation.Rules;
///
/// AND
///
/// Subscription operations must have exactly one root field.
/// Subscription operations must have exactly one root field, including an empty selection set.
///
/// https://spec.graphql.org/September2025/#sec-Single-Root-Field
///
Expand Down Expand Up @@ -113,7 +113,7 @@ protected override ISyntaxVisitorAction Leave(

if (node.Operation == OperationType.Subscription)
{
if (responseNames.Count > 1)
if (responseNames.Count != 1)
{
context.ReportError(context.SubscriptionSingleRootField(node));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,161 @@ public async Task ModifyOptions_ValidatePipelineOrder_False()
Assert.False(interceptor.Options.ValidatePipelineOrder);
}

[Fact]
public async Task ModifyOptions_EnableEmptySelectionSets_ExecutesEmptySelectionSets()
{
// arrange
var executor =
await new ServiceCollection()
.AddGraphQLServer()
.AddQueryType<Query>()
.ModifyOptions(o => o.EnableEmptySelectionSets = true)
.BuildRequestExecutorAsync(cancellationToken: TestContext.Current.CancellationToken);

// act
var rootResult = await executor.ExecuteAsync("{ }", TestContext.Current.CancellationToken);
var objectResult = await executor.ExecuteAsync("{ hero { } }", TestContext.Current.CancellationToken);

// assert
rootResult.MatchInlineSnapshot(
"""
{
"data": {}
}
""");
objectResult.MatchInlineSnapshot(
"""
{
"data": {
"hero": {}
}
}
""");
}

[Fact]
public async Task ConfigureSchema_Should_ExecuteEmptySelectionSets_When_Enabled()
{
// arrange
var executor =
await new ServiceCollection()
.AddGraphQLServer()
.AddQueryType<Query>()
.ConfigureSchema(b => b.ModifyOptions(o => o.EnableEmptySelectionSets = true))
.BuildRequestExecutorAsync(cancellationToken: TestContext.Current.CancellationToken);

// act
var result = await executor.ExecuteAsync("{ }", TestContext.Current.CancellationToken);

// assert
result.MatchInlineSnapshot(
"""
{
"data": {}
}
""");
}

[Fact]
public async Task MakeExecutable_Should_ExecuteEmptySelectionSets_When_SchemaIsPrebuilt()
{
// arrange
var schema =
SchemaBuilder.New()
.ModifyOptions(o => o.EnableEmptySelectionSets = true)
.AddQueryType<Query>()
.Create();
var executor = schema.MakeExecutable();

// act
var result = await executor.ExecuteAsync("{ }", TestContext.Current.CancellationToken);

// assert
result.MatchInlineSnapshot(
"""
{
"data": {}
}
""");
}

[Fact]
public async Task ConfigureValidation_Should_RejectEmptySelectionSets_When_Disabled()
{
// arrange
var executor =
await new ServiceCollection()
.AddGraphQLServer()
.AddQueryType<Query>()
.ModifyOptions(o => o.EnableEmptySelectionSets = true)
.ConfigureValidation((_, b) =>
b.ModifyOptions(o => o.EnableEmptySelectionSets = false))
.BuildRequestExecutorAsync(cancellationToken: TestContext.Current.CancellationToken);

// act
var result = await executor.ExecuteAsync("{ }", TestContext.Current.CancellationToken);

// assert
result.MatchInlineSnapshot(
"""
{
"errors": [
{
"message": "Operation `Unnamed` has an empty selection set. Root types without selections are disallowed.",
"locations": [
{
"line": 1,
"column": 1
}
],
"extensions": {
"operation": "Unnamed",
"type": "Query",
"specifiedBy": "https://spec.graphql.org/September2025/#sec-Field-Selections"
}
}
]
}
""");
}

[Fact]
public async Task ExecuteAsync_Should_RejectEmptySelectionSets_When_Disabled()
{
// arrange
var executor =
await new ServiceCollection()
.AddGraphQLServer()
.AddQueryType<Query>()
.BuildRequestExecutorAsync(cancellationToken: TestContext.Current.CancellationToken);

// act
var result = await executor.ExecuteAsync("{ }", TestContext.Current.CancellationToken);

// assert
result.MatchInlineSnapshot(
"""
{
"errors": [
{
"message": "Operation `Unnamed` has an empty selection set. Root types without selections are disallowed.",
"locations": [
{
"line": 1,
"column": 1
}
],
"extensions": {
"operation": "Unnamed",
"type": "Query",
"specifiedBy": "https://spec.graphql.org/September2025/#sec-Field-Selections"
}
}
]
}
""");
}

private sealed class OptionsInterceptor : TypeInterceptor
{
public IReadOnlySchemaOptions Options { get; private set; } = null!;
Expand All @@ -36,5 +191,12 @@ internal override void OnBeforeCreateSchemaInternal(
public class Query
{
public string Abc() => "abc";

public Hero Hero() => new();
}

public class Hero
{
public string Name => "Luke";
}
}
Loading
Loading