From 1589a2422c51ba8ece9dbeec3c23dea9bac5a7e9 Mon Sep 17 00:00:00 2001 From: Glen Date: Tue, 13 Oct 2026 11:02:51 +0200 Subject: [PATCH 1/4] Fix empty filter objects failing with an unexpected error --- .../List/QueryableListOperationHandlerBase.cs | 9 +- .../Handlers/QueryableDefaultFieldHandler.cs | 12 +- .../Filters/Visitor/FilterVisitorBase`2.cs | 11 +- .../QueryableFilterVisitorListTests.cs | 40 +++ .../QueryableFilterVisitorObjectTests.cs | 43 +++ ...NoCondition_When_ElementFilterIsEmpty.snap | 299 ++++++++++++++++++ ...lyNoCondition_When_FieldFilterIsEmpty.snap | 95 ++++++ ..._ArrayAnyObjectStringEqual_Expression.snap | 9 +- ...Create_ArrayAnyStringEqual_Expression.snap | 9 +- 9 files changed, 516 insertions(+), 11 deletions(-) create mode 100644 src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorListTests.ExecuteAsync_Should_ApplyNoCondition_When_ElementFilterIsEmpty.snap create mode 100644 src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorObjectTests.ExecuteAsync_Should_ApplyNoCondition_When_FieldFilterIsEmpty.snap diff --git a/src/HotChocolate/Data/src/Data/Filters/Expressions/Handlers/List/QueryableListOperationHandlerBase.cs b/src/HotChocolate/Data/src/Data/Filters/Expressions/Handlers/List/QueryableListOperationHandlerBase.cs index ce8fa4d6751..85e52f70ca3 100644 --- a/src/HotChocolate/Data/src/Data/Filters/Expressions/Handlers/List/QueryableListOperationHandlerBase.cs +++ b/src/HotChocolate/Data/src/Data/Filters/Expressions/Handlers/List/QueryableListOperationHandlerBase.cs @@ -77,11 +77,14 @@ public override bool TryHandleLeave( [NotNullWhen(true)] out ISyntaxVisitorAction? action) { var runtimeType = context.RuntimeTypes.Pop(); + context.TryCreateLambda(out var lambda); - if (context.TryCreateLambda(out var lambda)) + context.Scopes.Pop(); + var instance = context.PopInstance(); + + // an empty element filter produces no lambda, so the list operation adds no condition. + if (lambda is not null) { - context.Scopes.Pop(); - var instance = context.PopInstance(); var expression = HandleListOperation( context, field, diff --git a/src/HotChocolate/Data/src/Data/Filters/Expressions/Handlers/QueryableDefaultFieldHandler.cs b/src/HotChocolate/Data/src/Data/Filters/Expressions/Handlers/QueryableDefaultFieldHandler.cs index 0e26568a270..505b9633b02 100644 --- a/src/HotChocolate/Data/src/Data/Filters/Expressions/Handlers/QueryableDefaultFieldHandler.cs +++ b/src/HotChocolate/Data/src/Data/Filters/Expressions/Handlers/QueryableDefaultFieldHandler.cs @@ -116,12 +116,18 @@ public override bool TryHandleLeave( return false; } - // Dequeue last - var condition = context.GetLevel().Dequeue(); - context.PopInstance(); context.RuntimeTypes.Pop(); + // Dequeue last + if (!context.GetLevel().TryDequeue(out var condition)) + { + // an empty filter object produces no condition, so the field adds none. + action = SyntaxVisitor.Continue; + + return true; + } + // When we are in an in-memory context, it is possible that we have null reference // exceptions. To avoid these exceptions, we need to add null checks to the chain. We always // wrap the field before in a null check. diff --git a/src/HotChocolate/Data/src/Data/Filters/Visitor/FilterVisitorBase`2.cs b/src/HotChocolate/Data/src/Data/Filters/Visitor/FilterVisitorBase`2.cs index 3e118a26783..2891c104b03 100644 --- a/src/HotChocolate/Data/src/Data/Filters/Visitor/FilterVisitorBase`2.cs +++ b/src/HotChocolate/Data/src/Data/Filters/Visitor/FilterVisitorBase`2.cs @@ -58,7 +58,16 @@ protected override ISyntaxVisitorAction Enter( if (context.Operations.Peek() is IFilterField field and not IOrField and not IAndField) { - return OnFieldEnter(context, field, node); + var action = OnFieldEnter(context, field, node); + + // a skipped field is never left, so the operation and type pushed on entering it are + // popped here. + if (action.Kind is SyntaxVisitorActionKind.Skip) + { + base.Leave(node, context); + } + + return action; } return Continue; diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorListTests.cs b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorListTests.cs index f66c7186a8a..d0d473faa90 100644 --- a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorListTests.cs +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorListTests.cs @@ -203,6 +203,46 @@ await Snapshot .MatchAsync(TestContext.Current.CancellationToken); } + [Fact] + public async Task ExecuteAsync_Should_ApplyNoCondition_When_ElementFilterIsEmpty() + { + // arrange + var tester = _cache.CreateSchema(s_fooEntities); + + // act + var res1 = await tester.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument("{ root(where: { fooNested: { some: { } } }) { fooNested { bar } } }") + .Build(), + TestContext.Current.CancellationToken); + var res2 = await tester.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument("{ root(where: { fooNested: { all: { } } }) { fooNested { bar } } }") + .Build(), + TestContext.Current.CancellationToken); + var res3 = await tester.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument("{ root(where: { fooNested: { none: { } } }) { fooNested { bar } } }") + .Build(), + TestContext.Current.CancellationToken); + var res4 = await tester.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument( + "{ root(where: { fooNested: { some: { bar: { } } } }) " + + "{ fooNested { bar } } }") + .Build(), + TestContext.Current.CancellationToken); + + // assert + await Snapshot + .Create() + .Add(res1, "some") + .Add(res2, "all") + .Add(res3, "none") + .Add(res4, "some with empty field") + .MatchAsync(TestContext.Current.CancellationToken); + } + public class Foo { public int Id { get; set; } diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorObjectTests.cs b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorObjectTests.cs index 6fd2162d018..7e47a3e2244 100644 --- a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorObjectTests.cs +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorObjectTests.cs @@ -734,6 +734,49 @@ await Snapshot .MatchAsync(TestContext.Current.CancellationToken); } + [Fact] + public async Task ExecuteAsync_Should_ApplyNoCondition_When_FieldFilterIsEmpty() + { + // arrange + var tester = _cache.CreateSchema( + s_barNullableEntities); + + // act + var res1 = await tester.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument("{ root(where: { foo: { } }) { foo { barShort } } }") + .Build(), + TestContext.Current.CancellationToken); + var res2 = await tester.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument("{ root(where: { foo: { barShort: { } } }) { foo { barShort } } }") + .Build(), + TestContext.Current.CancellationToken); + var res3 = await tester.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument( + "{ root(where: { foo: { barShort: { }, barBool: { eq: true } } }) " + + "{ foo { barShort } } }") + .Build(), + TestContext.Current.CancellationToken); + var res4 = await tester.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument( + "{ root(where: { foo: { barBool: { eq: true }, barShort: { } } }) " + + "{ foo { barShort } } }") + .Build(), + TestContext.Current.CancellationToken); + + // assert + await Snapshot + .Create() + .Add(res1, "empty object") + .Add(res2, "empty operations") + .Add(res3, "empty field first") + .Add(res4, "empty field last") + .MatchAsync(TestContext.Current.CancellationToken); + } + public class Foo { public int Id { get; set; } diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorListTests.ExecuteAsync_Should_ApplyNoCondition_When_ElementFilterIsEmpty.snap b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorListTests.ExecuteAsync_Should_ApplyNoCondition_When_ElementFilterIsEmpty.snap new file mode 100644 index 00000000000..1835882074a --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorListTests.ExecuteAsync_Should_ApplyNoCondition_When_ElementFilterIsEmpty.snap @@ -0,0 +1,299 @@ +some +--------------- +{ + "data": { + "root": [ + { + "fooNested": [ + { + "bar": "a" + }, + { + "bar": "a" + }, + { + "bar": "a" + } + ] + }, + { + "fooNested": [ + { + "bar": "c" + }, + { + "bar": "a" + }, + { + "bar": "a" + } + ] + }, + { + "fooNested": [ + { + "bar": "a" + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": [ + { + "bar": "c" + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": [ + { + "bar": null + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + } + ] + } +} +--------------- + +all +--------------- +{ + "data": { + "root": [ + { + "fooNested": [ + { + "bar": "a" + }, + { + "bar": "a" + }, + { + "bar": "a" + } + ] + }, + { + "fooNested": [ + { + "bar": "c" + }, + { + "bar": "a" + }, + { + "bar": "a" + } + ] + }, + { + "fooNested": [ + { + "bar": "a" + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": [ + { + "bar": "c" + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": [ + { + "bar": null + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + } + ] + } +} +--------------- + +none +--------------- +{ + "data": { + "root": [ + { + "fooNested": [ + { + "bar": "a" + }, + { + "bar": "a" + }, + { + "bar": "a" + } + ] + }, + { + "fooNested": [ + { + "bar": "c" + }, + { + "bar": "a" + }, + { + "bar": "a" + } + ] + }, + { + "fooNested": [ + { + "bar": "a" + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": [ + { + "bar": "c" + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": [ + { + "bar": null + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + } + ] + } +} +--------------- + +some with empty field +--------------- +{ + "data": { + "root": [ + { + "fooNested": [ + { + "bar": "a" + }, + { + "bar": "a" + }, + { + "bar": "a" + } + ] + }, + { + "fooNested": [ + { + "bar": "c" + }, + { + "bar": "a" + }, + { + "bar": "a" + } + ] + }, + { + "fooNested": [ + { + "bar": "a" + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": [ + { + "bar": "c" + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": [ + { + "bar": null + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + } + ] + } +} +--------------- diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorObjectTests.ExecuteAsync_Should_ApplyNoCondition_When_FieldFilterIsEmpty.snap b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorObjectTests.ExecuteAsync_Should_ApplyNoCondition_When_FieldFilterIsEmpty.snap new file mode 100644 index 00000000000..b5c25f31245 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorObjectTests.ExecuteAsync_Should_ApplyNoCondition_When_FieldFilterIsEmpty.snap @@ -0,0 +1,95 @@ +empty object +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barShort": 12 + } + }, + { + "foo": { + "barShort": null + } + }, + { + "foo": { + "barShort": 14 + } + }, + { + "foo": { + "barShort": 13 + } + }, + { + "foo": null + } + ] + } +} +--------------- + +empty operations +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barShort": 12 + } + }, + { + "foo": { + "barShort": null + } + }, + { + "foo": { + "barShort": 14 + } + }, + { + "foo": { + "barShort": 13 + } + }, + { + "foo": null + } + ] + } +} +--------------- + +empty field first +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barShort": 12 + } + } + ] + } +} +--------------- + +empty field last +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barShort": 12 + } + } + ] + } +} +--------------- diff --git a/src/HotChocolate/MongoDb/test/Data.MongoDb.Filters.Tests/__snapshots__/MongoDbFilterVisitorListTests.Create_ArrayAnyObjectStringEqual_Expression.snap b/src/HotChocolate/MongoDb/test/Data.MongoDb.Filters.Tests/__snapshots__/MongoDbFilterVisitorListTests.Create_ArrayAnyObjectStringEqual_Expression.snap index d3c4e3040c5..9dd9db438c9 100644 --- a/src/HotChocolate/MongoDb/test/Data.MongoDb.Filters.Tests/__snapshots__/MongoDbFilterVisitorListTests.Create_ArrayAnyObjectStringEqual_Expression.snap +++ b/src/HotChocolate/MongoDb/test/Data.MongoDb.Filters.Tests/__snapshots__/MongoDbFilterVisitorListTests.Create_ArrayAnyObjectStringEqual_Expression.snap @@ -104,10 +104,15 @@ null { "errors": [ { - "message": "Unexpected Execution Error", + "message": "The provided value for filter `all` of type FooNestedFilterInput is invalid. Null values are not supported.", "path": [ "root" - ] + ], + "extensions": { + "code": "HC0026", + "expectedType": "FooNestedFilterInput!", + "filterType": "FooNestedFilterInput" + } } ], "data": { diff --git a/src/HotChocolate/MongoDb/test/Data.MongoDb.Filters.Tests/__snapshots__/MongoDbFilterVisitorListTests.Create_ArrayAnyStringEqual_Expression.snap b/src/HotChocolate/MongoDb/test/Data.MongoDb.Filters.Tests/__snapshots__/MongoDbFilterVisitorListTests.Create_ArrayAnyStringEqual_Expression.snap index e5e5a620813..7bb550ad2aa 100644 --- a/src/HotChocolate/MongoDb/test/Data.MongoDb.Filters.Tests/__snapshots__/MongoDbFilterVisitorListTests.Create_ArrayAnyStringEqual_Expression.snap +++ b/src/HotChocolate/MongoDb/test/Data.MongoDb.Filters.Tests/__snapshots__/MongoDbFilterVisitorListTests.Create_ArrayAnyStringEqual_Expression.snap @@ -74,10 +74,15 @@ null { "errors": [ { - "message": "Unexpected Execution Error", + "message": "The provided value for filter `all` of type StringOperationFilterInput is invalid. Null values are not supported.", "path": [ "root" - ] + ], + "extensions": { + "code": "HC0026", + "expectedType": "StringOperationFilterInput!", + "filterType": "StringOperationFilterInput" + } } ], "data": { From 018e2ed05db3e55480a6dad0d06733e20eaad22a Mon Sep 17 00:00:00 2001 From: Glen Date: Tue, 13 Oct 2026 11:14:22 +0200 Subject: [PATCH 2/4] Fix empty MongoDB list filters failing with an unexpected error --- .../List/MongoDbListOperationHandlerBase.cs | 3 +- .../MongoDbFilterVisitorListTests.cs | 40 ++ ...NoCondition_When_ElementFilterIsEmpty.snap | 343 ++++++++++++++++++ 3 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 src/HotChocolate/MongoDb/test/Data.MongoDb.Filters.Tests/__snapshots__/MongoDbFilterVisitorListTests.ExecuteAsync_Should_ApplyNoCondition_When_ElementFilterIsEmpty.snap diff --git a/src/HotChocolate/MongoDb/src/Data/Filters/Handlers/List/MongoDbListOperationHandlerBase.cs b/src/HotChocolate/MongoDb/src/Data/Filters/Handlers/List/MongoDbListOperationHandlerBase.cs index a3701694500..f05038c606e 100644 --- a/src/HotChocolate/MongoDb/src/Data/Filters/Handlers/List/MongoDbListOperationHandlerBase.cs +++ b/src/HotChocolate/MongoDb/src/Data/Filters/Handlers/List/MongoDbListOperationHandlerBase.cs @@ -73,7 +73,8 @@ public override bool TryHandleLeave( { context.RuntimeTypes.Pop(); - if (context.Scopes.Pop() is MongoDbFilterScope scope) + // an empty element filter produces no condition, so the list operation adds none. + if (context.Scopes.Pop() is MongoDbFilterScope scope && scope.Level.Peek().Count > 0) { var path = context.GetMongoFilterScope().GetPath(); var combinedOperations = HandleListOperation( diff --git a/src/HotChocolate/MongoDb/test/Data.MongoDb.Filters.Tests/MongoDbFilterVisitorListTests.cs b/src/HotChocolate/MongoDb/test/Data.MongoDb.Filters.Tests/MongoDbFilterVisitorListTests.cs index 4ea5dc2c8ec..7cb6121695d 100644 --- a/src/HotChocolate/MongoDb/test/Data.MongoDb.Filters.Tests/MongoDbFilterVisitorListTests.cs +++ b/src/HotChocolate/MongoDb/test/Data.MongoDb.Filters.Tests/MongoDbFilterVisitorListTests.cs @@ -421,6 +421,46 @@ await Snapshot .MatchAsync(TestContext.Current.CancellationToken); } + [Fact] + public async Task ExecuteAsync_Should_ApplyNoCondition_When_ElementFilterIsEmpty() + { + // arrange + var tester = CreateSchema(s_fooEntities); + + // act + var res1 = await tester.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument("{ root(where: { fooNested: { some: { } } }) { fooNested { bar } } }") + .Build(), + TestContext.Current.CancellationToken); + var res2 = await tester.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument("{ root(where: { fooNested: { all: { } } }) { fooNested { bar } } }") + .Build(), + TestContext.Current.CancellationToken); + var res3 = await tester.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument("{ root(where: { fooNested: { none: { } } }) { fooNested { bar } } }") + .Build(), + TestContext.Current.CancellationToken); + var res4 = await tester.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument( + "{ root(where: { fooNested: { some: { bar: { } } } }) " + + "{ fooNested { bar } } }") + .Build(), + TestContext.Current.CancellationToken); + + // assert + await Snapshot + .Create() + .AddResult(res1, "some") + .AddResult(res2, "all") + .AddResult(res3, "none") + .AddResult(res4, "some with empty field") + .MatchAsync(TestContext.Current.CancellationToken); + } + public class Foo { [BsonId] diff --git a/src/HotChocolate/MongoDb/test/Data.MongoDb.Filters.Tests/__snapshots__/MongoDbFilterVisitorListTests.ExecuteAsync_Should_ApplyNoCondition_When_ElementFilterIsEmpty.snap b/src/HotChocolate/MongoDb/test/Data.MongoDb.Filters.Tests/__snapshots__/MongoDbFilterVisitorListTests.ExecuteAsync_Should_ApplyNoCondition_When_ElementFilterIsEmpty.snap new file mode 100644 index 00000000000..381680c2794 --- /dev/null +++ b/src/HotChocolate/MongoDb/test/Data.MongoDb.Filters.Tests/__snapshots__/MongoDbFilterVisitorListTests.ExecuteAsync_Should_ApplyNoCondition_When_ElementFilterIsEmpty.snap @@ -0,0 +1,343 @@ +some Result: +--------------- +{ + "data": { + "root": [ + { + "fooNested": [ + { + "bar": "a" + }, + { + "bar": "a" + }, + { + "bar": "a" + } + ] + }, + { + "fooNested": [ + { + "bar": "c" + }, + { + "bar": "a" + }, + { + "bar": "a" + } + ] + }, + { + "fooNested": [ + { + "bar": "a" + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": [ + { + "bar": "c" + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": [ + { + "bar": null + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": null + }, + { + "fooNested": [] + } + ] + } +} +--------------- + +some Query: +--------------- +find({ }) +--------------- + +all Result: +--------------- +{ + "data": { + "root": [ + { + "fooNested": [ + { + "bar": "a" + }, + { + "bar": "a" + }, + { + "bar": "a" + } + ] + }, + { + "fooNested": [ + { + "bar": "c" + }, + { + "bar": "a" + }, + { + "bar": "a" + } + ] + }, + { + "fooNested": [ + { + "bar": "a" + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": [ + { + "bar": "c" + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": [ + { + "bar": null + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": null + }, + { + "fooNested": [] + } + ] + } +} +--------------- + +all Query: +--------------- +find({ }) +--------------- + +none Result: +--------------- +{ + "data": { + "root": [ + { + "fooNested": [ + { + "bar": "a" + }, + { + "bar": "a" + }, + { + "bar": "a" + } + ] + }, + { + "fooNested": [ + { + "bar": "c" + }, + { + "bar": "a" + }, + { + "bar": "a" + } + ] + }, + { + "fooNested": [ + { + "bar": "a" + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": [ + { + "bar": "c" + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": [ + { + "bar": null + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": null + }, + { + "fooNested": [] + } + ] + } +} +--------------- + +none Query: +--------------- +find({ }) +--------------- + +some with empty field Result: +--------------- +{ + "data": { + "root": [ + { + "fooNested": [ + { + "bar": "a" + }, + { + "bar": "a" + }, + { + "bar": "a" + } + ] + }, + { + "fooNested": [ + { + "bar": "c" + }, + { + "bar": "a" + }, + { + "bar": "a" + } + ] + }, + { + "fooNested": [ + { + "bar": "a" + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": [ + { + "bar": "c" + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": [ + { + "bar": null + }, + { + "bar": "d" + }, + { + "bar": "b" + } + ] + }, + { + "fooNested": null + }, + { + "fooNested": [] + } + ] + } +} +--------------- + +some with empty field Query: +--------------- +find({ }) +--------------- From dbc0fd981f7ee71c17ad12677be62c3eff26ebbe Mon Sep 17 00:00:00 2001 From: Glen Date: Tue, 13 Oct 2026 11:27:19 +0200 Subject: [PATCH 3/4] Fix spatial distance filters without a comparison failing --- .../Handlers/QueryableSpatialMethodHandler.cs | 12 +++-- .../QueryableFilterVisitorDistanceTests.cs | 53 +++++++++++++++++++ ...ondition_When_DistanceHasNoComparison.snap | 43 +++++++++++++++ 3 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 src/HotChocolate/Spatial/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorDistanceTests.ExecuteAsync_Should_ApplyNoCondition_When_DistanceHasNoComparison.snap diff --git a/src/HotChocolate/Spatial/src/Data/Filters/Expressions/Handlers/QueryableSpatialMethodHandler.cs b/src/HotChocolate/Spatial/src/Data/Filters/Expressions/Handlers/QueryableSpatialMethodHandler.cs index b981df84825..5ed6b0b1b81 100644 --- a/src/HotChocolate/Spatial/src/Data/Filters/Expressions/Handlers/QueryableSpatialMethodHandler.cs +++ b/src/HotChocolate/Spatial/src/Data/Filters/Expressions/Handlers/QueryableSpatialMethodHandler.cs @@ -95,12 +95,18 @@ public override bool TryHandleLeave( ObjectFieldNode node, [NotNullWhen(true)] out ISyntaxVisitorAction? action) { - // Dequeue last - var condition = context.GetLevel().Dequeue(); - context.PopInstance(); context.RuntimeTypes.Pop(); + // Dequeue last + if (!context.GetLevel().TryDequeue(out var condition)) + { + // an operation without a comparison produces no condition, so it adds none. + action = SyntaxVisitor.Continue; + + return true; + } + if (context.InMemory) { condition = FilterExpressionBuilder.NotNullAndAlso( diff --git a/src/HotChocolate/Spatial/test/Data.Filters.SqlServer.Tests/QueryableFilterVisitorDistanceTests.cs b/src/HotChocolate/Spatial/test/Data.Filters.SqlServer.Tests/QueryableFilterVisitorDistanceTests.cs index 6324761e4f2..c2c3a80ee0c 100644 --- a/src/HotChocolate/Spatial/test/Data.Filters.SqlServer.Tests/QueryableFilterVisitorDistanceTests.cs +++ b/src/HotChocolate/Spatial/test/Data.Filters.SqlServer.Tests/QueryableFilterVisitorDistanceTests.cs @@ -98,6 +98,59 @@ await Snapshot .MatchAsync(TestContext.Current.CancellationToken); } + [Fact] + public async Task ExecuteAsync_Should_ApplyNoCondition_When_DistanceHasNoComparison() + { + // arrange + var tester = await CreateSchemaAsync(s_fooEntities); + + // act + var res1 = await tester.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument( + """ + { + root(where: { + bar: { + distance: { + geometry: { type: Point, coordinates: [1, 1] } + } + } + }) { + id + } + } + """) + .Build(), + TestContext.Current.CancellationToken); + var res2 = await tester.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument( + """ + { + root(where: { + bar: { + distance: { + geometry: { type: Point, coordinates: [1, 1] } + buffer: 1 + } + } + }) { + id + } + } + """) + .Build(), + TestContext.Current.CancellationToken); + + // assert + await Snapshot + .Create() + .AddResult(res1, "geometry") + .AddResult(res2, "geometry and buffer") + .MatchAsync(TestContext.Current.CancellationToken); + } + public class Foo { public int Id { get; set; } diff --git a/src/HotChocolate/Spatial/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorDistanceTests.ExecuteAsync_Should_ApplyNoCondition_When_DistanceHasNoComparison.snap b/src/HotChocolate/Spatial/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorDistanceTests.ExecuteAsync_Should_ApplyNoCondition_When_DistanceHasNoComparison.snap new file mode 100644 index 00000000000..77cc32f31d1 --- /dev/null +++ b/src/HotChocolate/Spatial/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorDistanceTests.ExecuteAsync_Should_ApplyNoCondition_When_DistanceHasNoComparison.snap @@ -0,0 +1,43 @@ +geometry Result: +--------------- +{ + "data": { + "root": [ + { + "id": 1 + }, + { + "id": 2 + } + ] + } +} +--------------- + +geometry SQL: +--------------- +SELECT d."Id", d."Bar" +FROM "Data" AS d +--------------- + +geometry and buffer Result: +--------------- +{ + "data": { + "root": [ + { + "id": 1 + }, + { + "id": 2 + } + ] + } +} +--------------- + +geometry and buffer SQL: +--------------- +SELECT d."Id", d."Bar" +FROM "Data" AS d +--------------- From c77cf053ea1b4026eb8c8a87f3278f9e3d0cdf82 Mon Sep 17 00:00:00 2001 From: Glen Date: Tue, 13 Oct 2026 11:40:23 +0200 Subject: [PATCH 4/4] Remove misleading dequeue comments from filter handlers --- .../Filters/Expressions/Handlers/QueryableDefaultFieldHandler.cs | 1 - .../Expressions/Handlers/QueryableSpatialMethodHandler.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/src/HotChocolate/Data/src/Data/Filters/Expressions/Handlers/QueryableDefaultFieldHandler.cs b/src/HotChocolate/Data/src/Data/Filters/Expressions/Handlers/QueryableDefaultFieldHandler.cs index 505b9633b02..ec93373a2c9 100644 --- a/src/HotChocolate/Data/src/Data/Filters/Expressions/Handlers/QueryableDefaultFieldHandler.cs +++ b/src/HotChocolate/Data/src/Data/Filters/Expressions/Handlers/QueryableDefaultFieldHandler.cs @@ -119,7 +119,6 @@ public override bool TryHandleLeave( context.PopInstance(); context.RuntimeTypes.Pop(); - // Dequeue last if (!context.GetLevel().TryDequeue(out var condition)) { // an empty filter object produces no condition, so the field adds none. diff --git a/src/HotChocolate/Spatial/src/Data/Filters/Expressions/Handlers/QueryableSpatialMethodHandler.cs b/src/HotChocolate/Spatial/src/Data/Filters/Expressions/Handlers/QueryableSpatialMethodHandler.cs index 5ed6b0b1b81..18cb8191b73 100644 --- a/src/HotChocolate/Spatial/src/Data/Filters/Expressions/Handlers/QueryableSpatialMethodHandler.cs +++ b/src/HotChocolate/Spatial/src/Data/Filters/Expressions/Handlers/QueryableSpatialMethodHandler.cs @@ -98,7 +98,6 @@ public override bool TryHandleLeave( context.PopInstance(); context.RuntimeTypes.Pop(); - // Dequeue last if (!context.GetLevel().TryDequeue(out var condition)) { // an operation without a comparison produces no condition, so it adds none.