From b027ca1702e19c2aa4169a66c1ee998819013ae1 Mon Sep 17 00:00:00 2001 From: PascalSenn Date: Sun, 5 Feb 2023 19:19:36 +0100 Subject: [PATCH] Adds struct support to filtering (#5760) --- .../Filters/Convention/FilterConvention.cs | 25 +- .../Expressions/FilterExpressionBuilder.cs | 38 ++- .../List/QueryableListOperationHandlerBase.cs | 9 +- .../Handlers/QueryableDefaultFieldHandler.cs | 84 +++++- .../FilterVisitorTestBase.cs | 3 - .../QueryableFilterVisitorObjectTests.cs | 35 +++ .../QueryableFilterVisitorStructTests.cs | 254 ++++++++++++++++++ .../SchemaCache.cs | 1 - ...rVisitorObjectTests.Create_ObjectNull.snap | 85 ++++++ ...reate_ObjectListShortEqual_Expression.snap | 43 +++ ...rVisitorStructTests.Create_ObjectNull.snap | 90 +++++++ ...jectNullableListShortEqual_Expression.snap | 43 +++ ...e_ObjectNullableShortEqual_Expression.snap | 44 +++ ...ts.Create_ObjectShortEqual_Expression.snap | 57 ++++ .../Data.Filters.Tests/FilterInputTypeTest.cs | 33 ++- ...putTypeTest.FilterInputType_Struct.graphql | 36 +++ 16 files changed, 828 insertions(+), 52 deletions(-) create mode 100644 src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorStructTests.cs create mode 100644 src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorObjectTests.Create_ObjectNull.snap create mode 100644 src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectListShortEqual_Expression.snap create mode 100644 src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectNull.snap create mode 100644 src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectNullableListShortEqual_Expression.snap create mode 100644 src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectNullableShortEqual_Expression.snap create mode 100644 src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectShortEqual_Expression.snap create mode 100644 src/HotChocolate/Data/test/Data.Filters.Tests/__snapshots__/FilterInputTypeTest.FilterInputType_Struct.graphql diff --git a/src/HotChocolate/Data/src/Data/Filters/Convention/FilterConvention.cs b/src/HotChocolate/Data/src/Data/Filters/Convention/FilterConvention.cs index 45ece4d9677..8c35899076d 100644 --- a/src/HotChocolate/Data/src/Data/Filters/Convention/FilterConvention.cs +++ b/src/HotChocolate/Data/src/Data/Filters/Convention/FilterConvention.cs @@ -138,6 +138,7 @@ public virtual string GetTypeName(Type runtimeType) runtimeType.GetGenericTypeDefinition() == typeof(EnumOperationFilterInputType<>)) { var genericName = _namingConventions.GetTypeName(runtimeType.GenericTypeArguments[0]); + return genericName + "OperationFilterInput"; } @@ -253,8 +254,8 @@ public string GetOperationName(int operation) IFilterInputTypeDescriptor descriptor) { if (_configs.TryGetValue( - typeReference, - out var configurations)) + typeReference, + out var configurations)) { foreach (var configure in configurations) { @@ -290,11 +291,13 @@ public bool IsOrAllowed() if (filterFieldHandler.CanHandle(context, typeDefinition, fieldDefinition)) { handler = filterFieldHandler; + return true; } } handler = null; + return false; } @@ -319,6 +322,7 @@ public bool IsOrAllowed() TryCreateFilterType(runtimeType.ElementType, out var elementType)) { type = typeof(ListFilterInputType<>).MakeGenericType(elementType); + return true; } } @@ -326,17 +330,26 @@ public bool IsOrAllowed() if (runtimeType.Type.IsEnum) { type = typeof(EnumOperationFilterInputType<>).MakeGenericType(runtimeType.Source); + + return true; + } + + if (runtimeType.Type is { IsValueType: true, IsPrimitive: false }) + { + type = typeof(FilterInputType<>).MakeGenericType(runtimeType.Type); + return true; } - if (runtimeType.Type.IsClass || - runtimeType.Type.IsInterface) + if (runtimeType.Type.IsClass || runtimeType.Type.IsInterface) { type = typeof(FilterInputType<>).MakeGenericType(runtimeType.Source); + return true; } type = null; + return false; } @@ -349,8 +362,8 @@ public bool IsOrAllowed() foreach (var extensionType in definition.ProviderExtensionsTypes) { if (serviceProvider.TryGetOrCreateService( - extensionType, - out var createdExtension)) + extensionType, + out var createdExtension)) { extensions.Add(createdExtension); } diff --git a/src/HotChocolate/Data/src/Data/Filters/Expressions/FilterExpressionBuilder.cs b/src/HotChocolate/Data/src/Data/Filters/Expressions/FilterExpressionBuilder.cs index 48cc43b81d5..dbdcd590f7c 100644 --- a/src/HotChocolate/Data/src/Data/Filters/Expressions/FilterExpressionBuilder.cs +++ b/src/HotChocolate/Data/src/Data/Filters/Expressions/FilterExpressionBuilder.cs @@ -63,10 +63,7 @@ public static Expression Not(Expression expression) return Expression.Call( typeof(Enumerable), nameof(Enumerable.Contains), - new Type[] - { - genericType - }, + new Type[] { genericType }, Expression.Constant(parsedValue), property); } @@ -124,9 +121,18 @@ public static Expression Not(Expression expression) public static Expression NotNull(Expression expression) => Expression.NotEqual(expression, _null); + public static Expression HasValue(Expression expression) + => Expression.IsTrue( + Expression.Property( + expression, + expression.Type.GetProperty(nameof(Nullable.HasValue))!)); + public static Expression NotNullAndAlso(Expression property, Expression condition) => Expression.AndAlso(NotNull(property), condition); + public static Expression HasValueAndAlso(Expression property, Expression condition) + => Expression.AndAlso(HasValue(property), condition); + public static Expression Any( Type type, Expression property, @@ -134,6 +140,7 @@ public static Expression NotNullAndAlso(Expression property, Expression conditio params ParameterExpression[] parameterExpression) { var lambda = Expression.Lambda(body, parameterExpression); + return Any(type, property, lambda); } @@ -143,11 +150,7 @@ public static Expression NotNullAndAlso(Expression property, Expression conditio LambdaExpression lambda) => Expression.Call( _anyWithParameter.MakeGenericMethod(type), - new Expression[] - { - property, - lambda - }); + new Expression[] { property, lambda }); public static Expression Any( Type type, @@ -155,10 +158,7 @@ public static Expression NotNullAndAlso(Expression property, Expression conditio { return Expression.Call( _anyMethod.MakeGenericMethod(type), - new Expression[] - { - property - }); + new Expression[] { property }); } public static Expression All( @@ -167,11 +167,7 @@ public static Expression NotNullAndAlso(Expression property, Expression conditio LambdaExpression lambda) => Expression.Call( _allMethod.MakeGenericMethod(type), - new Expression[] - { - property, - lambda - }); + new Expression[] { property, lambda }); public static Expression NotContains( Expression property, @@ -188,6 +184,7 @@ public static Expression NotNullAndAlso(Expression property, Expression conditio private static Expression CreateAndConvertParameter(object value) { Expression> lambda = () => (T)value; + return lambda.Body; } @@ -195,8 +192,5 @@ private static Expression CreateParameter(object? value, Type type) => (Expression)_createAndConvert .MakeGenericMethod(type) .Invoke(null, - new[] - { - value - })!; + new[] { value })!; } 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 b55b33b0b23..3d96722f4d9 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 @@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using HotChocolate.Configuration; -using HotChocolate.Internal; using HotChocolate.Language; using HotChocolate.Language.Visitors; @@ -47,6 +46,7 @@ public abstract class QueryableListOperationHandlerBase ErrorHelper.CreateNonNullError(field, node.Value, context)); action = SyntaxVisitor.Skip; + return true; } @@ -61,10 +61,12 @@ public abstract class QueryableListOperationHandlerBase context.AddScope(); action = SyntaxVisitor.Continue; + return true; } action = null; + return false; } @@ -90,15 +92,14 @@ public abstract class QueryableListOperationHandlerBase if (context.InMemory) { - expression = FilterExpressionBuilder.NotNullAndAlso( - instance, - expression); + expression = FilterExpressionBuilder.NotNullAndAlso(instance, expression); } context.GetLevel().Enqueue(expression); } action = SyntaxVisitor.Continue; + return true; } 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 df7d6327abb..c5d2553c2dd 100644 --- a/src/HotChocolate/Data/src/Data/Filters/Expressions/Handlers/QueryableDefaultFieldHandler.cs +++ b/src/HotChocolate/Data/src/Data/Filters/Expressions/Handlers/QueryableDefaultFieldHandler.cs @@ -1,9 +1,9 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Linq.Expressions; using System.Reflection; using HotChocolate.Configuration; +using HotChocolate.Internal; using HotChocolate.Language; using HotChocolate.Language.Visitors; @@ -43,12 +43,14 @@ public class QueryableDefaultFieldHandler ErrorHelper.CreateNonNullError(field, node.Value, context)); action = SyntaxVisitor.Skip; + return true; } if (field.RuntimeType is null) { action = null; + return false; } @@ -69,25 +71,37 @@ public class QueryableDefaultFieldHandler } else { + var instance = context.GetInstance(); + + // we need to check if the previous value was a nullable value type. if it is a nullable + // value type we cannot just chain the next expression to it. We have to first select + // ".Value". + // + // without this check we would chain "previous" directly to "current": previous.current + // with this check we chain "previous" via ".Value" to "current": previous.Value.current + if (context.TryGetPreviousRuntimeType(out var previousRuntimeType) && + previousRuntimeType.IsNullableValueType()) + { + var valueGetter = instance.Type.GetProperty(nameof(Nullable.Value)); + instance = Expression.Property(instance, valueGetter!); + } + nestedProperty = field.Member switch { - PropertyInfo propertyInfo => - Expression.Property(context.GetInstance(), propertyInfo), + PropertyInfo propertyInfo => Expression.Property(instance, propertyInfo), - MethodInfo methodInfo => - Expression.Call(context.GetInstance(), methodInfo), + MethodInfo methodInfo => Expression.Call(instance, methodInfo), - null => - throw ThrowHelper.QueryableFiltering_NoMemberDeclared(field), + null => throw ThrowHelper.QueryableFiltering_NoMemberDeclared(field), - _ => - throw ThrowHelper.QueryableFiltering_MemberInvalid(field.Member, field) + _ => throw ThrowHelper.QueryableFiltering_MemberInvalid(field.Member, field) }; } context.PushInstance(nestedProperty); context.RuntimeTypes.Push(field.RuntimeType); action = SyntaxVisitor.Continue; + return true; } @@ -100,6 +114,7 @@ public class QueryableDefaultFieldHandler if (field.RuntimeType is null) { action = null; + return false; } @@ -109,15 +124,27 @@ public class QueryableDefaultFieldHandler context.PopInstance(); context.RuntimeTypes.Pop(); - if (context.InMemory) + // when we are in a 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. + // + // reference types: + // previous.current > 10 ==> previous is not null && previous.current > 10 + // + // structs: + // previous.Value.current > 10 ==> previous is not null && previous.Value.current > 10 + // + if (context.InMemory && + context.TryGetPreviousRuntimeType(out var previousRuntimeType) && + (previousRuntimeType.IsNullableValueType() || !previousRuntimeType.IsValueType())) { - condition = FilterExpressionBuilder.NotNullAndAlso( - context.GetInstance(), - condition); + var peekedInstance = context.GetInstance(); + condition = FilterExpressionBuilder.NotNullAndAlso(peekedInstance, condition); } context.GetLevel().Enqueue(condition); action = SyntaxVisitor.Continue; + return true; } @@ -140,6 +167,7 @@ protected override Expression VisitParameter(ParameterExpression node) { return _replacement; } + return base.VisitParameter(node); } @@ -151,3 +179,33 @@ protected override Expression VisitParameter(ParameterExpression node) new ReplaceVariableExpressionVisitor(replacement, parameter).Visit(lambda); } } + +static file class LocalExtensions +{ + public static bool TryGetPreviousRuntimeType( + this QueryableFilterContext context, + [NotNullWhen(true)] out IExtendedType? runtimeType) + { + return context.RuntimeTypes.TryPeek(out runtimeType); + } + + public static bool IsNullableValueType(this IExtendedType type) + { + return type.GetTypeOrElementType() is { Type.IsValueType: true, IsNullable: true }; + } + + public static bool IsValueType(this IExtendedType type) + { + return type.GetTypeOrElementType() is { Type.IsValueType: true }; + } + + private static IExtendedType GetTypeOrElementType(this IExtendedType type) + { + while (type is { IsArrayOrList: true, ElementType: { } nextType }) + { + type = nextType; + } + + return type; + } +} diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/FilterVisitorTestBase.cs b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/FilterVisitorTestBase.cs index a6f661f8a9f..ef9636b881f 100644 --- a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/FilterVisitorTestBase.cs +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/FilterVisitorTestBase.cs @@ -4,7 +4,6 @@ using HotChocolate.Execution; using HotChocolate.Resolvers; using HotChocolate.Types; -using HotChocolate.Types.Relay; namespace HotChocolate.Data.Filters; @@ -14,7 +13,6 @@ public class FilterVisitorTestBase private Func> BuildResolver( params TResult[] results) - where TResult : class { return _ => results.AsQueryable(); } @@ -26,7 +24,6 @@ public class FilterVisitorTestBase FilterConvention? convention = null, bool withPaging = false, Action? configure = null) - where TEntity : class where T : FilterInputType { convention ??= new FilterConvention(x => x.AddDefaults().BindRuntimeType()); 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 abd9a02ead8..2db08e7990b 100644 --- a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorObjectTests.cs +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorObjectTests.cs @@ -136,6 +136,10 @@ public class QueryableFilterVisitorObjectTests : IClassFixture BarString = "testdtest", ObjectArray = null } + }, + new() + { + Foo = null } }; @@ -658,6 +662,37 @@ await Snapshot .MatchAsync(); } + [Fact] + public async Task Create_ObjectNull() + { + // arrange + var tester = _cache.CreateSchema(_barNullableEntities); + + // act + var res1 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery( + "{ root(where: { foo: { barEnum: { neq: BAR}}}) " + + "{ foo{ barEnum}}}") + .Create()); + var res2 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { foo: null}) { foo{ barEnum}}}") + .Create()); + var res3 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery( "{ root { foo{ barEnum}}}") + .Create()); + + // assert + await Snapshot + .Create() + .Add(res1, "selected") + .Add(res2, "null") + .Add(res3, "all") + .MatchAsync(); + } + public class Foo { public int Id { get; set; } diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorStructTests.cs b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorStructTests.cs new file mode 100644 index 00000000000..6505e6c9a5c --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorStructTests.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CookieCrumble; +using HotChocolate.Execution; +using HotChocolate.Types; + +namespace HotChocolate.Data.Filters.Expressions; + +public class QueryableFilterVisitorStructTests : IClassFixture +{ + private static readonly Bar[] _barEntities = + { + new() { Foo = new Foo { BarShort = 12, } }, + new() { Foo = new Foo { BarShort = 14, } }, + new() { Foo = new Foo { BarShort = 13, } } + }; + + private static readonly BarNullable[] _barNullableEntities = + { + new() + { + Foo = new FooNullable { BarShort = 12, }, + FooList = new[] { new FooNullable { BarShort = 13, } }, + FooNullableList = new FooNullable?[] { new FooNullable { BarShort = 13, } } + }, + new() + { + Foo = new FooNullable { BarShort = null, }, + FooList = new[] { new FooNullable { BarShort = null, } }, + FooNullableList = new FooNullable?[] { new FooNullable { BarShort = null, } } + }, + new() + { + Foo = new FooNullable { BarShort = 14, }, + FooList = new[] { new FooNullable { BarShort = 14, } }, + FooNullableList = new FooNullable?[] { new FooNullable { BarShort = 14, } } + }, + new() + { + Foo = new FooNullable { BarShort = 13, }, + FooList = new[] { new FooNullable { BarShort = 13, } }, + FooNullableList = + new FooNullable?[] { new FooNullable { BarShort = 13, }, null } + }, + new() { Foo = null, FooList = null, FooNullableList = null } + }; + + private readonly SchemaCache _cache; + + public QueryableFilterVisitorStructTests(SchemaCache cache) + { + _cache = cache; + } + + [Fact] + public async Task Create_ObjectShortEqual_Expression() + { + // arrange + var tester = _cache.CreateSchema(_barEntities); + + // act + var res1 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { foo: { barShort: { eq: 12}}}) { foo{ barShort}}}") + .Create()); + + var res2 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { foo: { barShort: { eq: 13}}}) { foo{ barShort}}}") + .Create()); + + var res3 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { foo: { barShort: { eq: null}}}) { foo{ barShort}}}") + .Create()); + + // assert + await Snapshot + .Create() + .Add(res1, "12") + .Add(res2, "13") + .Add(res3, "null") + .MatchAsync(); + } + + [Fact] + public async Task Create_ObjectNullableShortEqual_Expression() + { + // arrange + var tester = + _cache.CreateSchema(_barNullableEntities); + + // act + var res1 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { foo: { barShort: { eq: 12}}}) { foo{ barShort}}}") + .Create()); + + var res2 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { foo: { barShort: { eq: 13}}}) { foo{ barShort}}}") + .Create()); + + var res3 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { foo: { barShort: { eq: null}}}) { foo{ barShort}}}") + .Create()); + + // assert + await Snapshot + .Create() + .Add(res1, "12") + .Add(res2, "13") + .Add(res3, "null") + .MatchAsync(); + } + + [Fact] + public async Task Create_ObjectNull() + { + // arrange + var tester = + _cache.CreateSchema(_barNullableEntities); + + // act + var res1 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { foo: { barShort: { neq: 123}}}) { foo{ barShort}}}") + .Create()); + var res2 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { foo: null}) { foo{ barShort}}}") + .Create()); + var res3 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root { foo { barShort }}}") + .Create()); + + // assert + await Snapshot + .Create() + .Add(res1, "selected") + .Add(res2, "null") + .Add(res3, "all") + .MatchAsync(); + } + + [Fact] + public async Task Create_ObjectNullableListShortEqual_Expression() + { + // arrange + var tester = + _cache.CreateSchema(_barNullableEntities); + + // act + var res1 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery( + "{ root(where: { fooNullableList:{ some: { barShort: { eq: 12}}}}) { foo{ barShort}}}") + .Create()); + + var res2 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery( + "{ root(where: { fooNullableList:{ some: { barShort: { eq: 13}}}}) { foo{ barShort}}}") + .Create()); + + var res3 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery( + "{ root(where: { fooNullableList:{ some: { barShort: { eq: null}}}}) { foo{ barShort}}}") + .Create()); + + // assert + await Snapshot + .Create() + .Add(res1, "12") + .Add(res2, "13") + .Add(res3, "null") + .MatchAsync(); + } + + [Fact] + public async Task Create_ObjectListShortEqual_Expression() + { + // arrange + var tester = + _cache.CreateSchema(_barNullableEntities); + + // act + var res1 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery( + "{ root(where: { fooList:{ some: { barShort: { eq: 12}}}}) { foo{ barShort}}}") + .Create()); + + var res2 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery( + "{ root(where: { fooList:{ some: { barShort: { eq: 13}}}}) { foo{ barShort}}}") + .Create()); + + var res3 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery( + "{ root(where: { fooList:{ some: { barShort: { eq: null}}}}) { foo{ barShort}}}") + .Create()); + + // assert + await Snapshot + .Create() + .Add(res1, "12") + .Add(res2, "13") + .Add(res3, "null") + .MatchAsync(); + } + + public struct Foo + { + public short BarShort { get; set; } + } + + public struct FooNullable + { + public short? BarShort { get; set; } + } + + public struct Bar + { + public int Id { get; set; } + + public Foo Foo { get; set; } + } + + public struct BarNullable + { + public int Id { get; set; } + + public FooNullable? Foo { get; set; } + + public FooNullable?[]? FooNullableList { get; set; } + + public FooNullable[]? FooList { get; set; } + } + + public class BarFilterInput : FilterInputType + { + } + + public class BarNullableFilterInput : FilterInputType + { + } +} diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/SchemaCache.cs b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/SchemaCache.cs index 89e7f345325..f449bc05979 100644 --- a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/SchemaCache.cs +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/SchemaCache.cs @@ -14,7 +14,6 @@ public class SchemaCache T[] entities, bool withPaging = false, Action? configure = null) - where T : class where TType : FilterInputType { (Type, Type, T[] entites) key = (typeof(T), typeof(TType), entities); diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorObjectTests.Create_ObjectNull.snap b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorObjectTests.Create_ObjectNull.snap new file mode 100644 index 00000000000..7a01858ef3d --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorObjectTests.Create_ObjectNull.snap @@ -0,0 +1,85 @@ +selected +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barEnum": "BAZ" + } + }, + { + "foo": { + "barEnum": "QUX" + } + }, + { + "foo": { + "barEnum": "FOO" + } + } + ] + } +} +--------------- + +null +--------------- +{ + "errors": [ + { + "message": "The provided value for filter \u0060foo\u0060 of type FooNullableFilterInput is invalid. Null values are not supported.", + "locations": [ + { + "line": 1, + "column": 22 + } + ], + "path": [ + "root" + ], + "extensions": { + "code": "HC0026", + "expectedType": "FooNullableFilterInput!", + "filterType": "FooNullableFilterInput" + } + } + ], + "data": { + "root": [] + } +} +--------------- + +all +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barEnum": "BAR" + } + }, + { + "foo": { + "barEnum": "BAZ" + } + }, + { + "foo": { + "barEnum": "QUX" + } + }, + { + "foo": { + "barEnum": "FOO" + } + }, + { + "foo": null + } + ] + } +} +--------------- diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectListShortEqual_Expression.snap b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectListShortEqual_Expression.snap new file mode 100644 index 00000000000..48dcad01235 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectListShortEqual_Expression.snap @@ -0,0 +1,43 @@ +12 +--------------- +{ + "data": { + "root": [] + } +} +--------------- + +13 +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barShort": 12 + } + }, + { + "foo": { + "barShort": 13 + } + } + ] + } +} +--------------- + +null +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barShort": null + } + } + ] + } +} +--------------- diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectNull.snap b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectNull.snap new file mode 100644 index 00000000000..62e1affc363 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectNull.snap @@ -0,0 +1,90 @@ +selected +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barShort": 12 + } + }, + { + "foo": { + "barShort": null + } + }, + { + "foo": { + "barShort": 14 + } + }, + { + "foo": { + "barShort": 13 + } + } + ] + } +} +--------------- + +null +--------------- +{ + "errors": [ + { + "message": "The provided value for filter \u0060foo\u0060 of type FooNullableFilterInput is invalid. Null values are not supported.", + "locations": [ + { + "line": 1, + "column": 22 + } + ], + "path": [ + "root" + ], + "extensions": { + "code": "HC0026", + "expectedType": "FooNullableFilterInput!", + "filterType": "FooNullableFilterInput" + } + } + ], + "data": { + "root": [] + } +} +--------------- + +all +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barShort": 12 + } + }, + { + "foo": { + "barShort": null + } + }, + { + "foo": { + "barShort": 14 + } + }, + { + "foo": { + "barShort": 13 + } + }, + { + "foo": null + } + ] + } +} +--------------- diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectNullableListShortEqual_Expression.snap b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectNullableListShortEqual_Expression.snap new file mode 100644 index 00000000000..48dcad01235 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectNullableListShortEqual_Expression.snap @@ -0,0 +1,43 @@ +12 +--------------- +{ + "data": { + "root": [] + } +} +--------------- + +13 +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barShort": 12 + } + }, + { + "foo": { + "barShort": 13 + } + } + ] + } +} +--------------- + +null +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barShort": null + } + } + ] + } +} +--------------- diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectNullableShortEqual_Expression.snap b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectNullableShortEqual_Expression.snap new file mode 100644 index 00000000000..1df4c6d888b --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectNullableShortEqual_Expression.snap @@ -0,0 +1,44 @@ +12 +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barShort": 12 + } + } + ] + } +} +--------------- + +13 +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barShort": 13 + } + } + ] + } +} +--------------- + +null +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barShort": null + } + } + ] + } +} +--------------- diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectShortEqual_Expression.snap b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectShortEqual_Expression.snap new file mode 100644 index 00000000000..6e1f53b8d7e --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorStructTests.Create_ObjectShortEqual_Expression.snap @@ -0,0 +1,57 @@ +12 +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barShort": 12 + } + } + ] + } +} +--------------- + +13 +--------------- +{ + "data": { + "root": [ + { + "foo": { + "barShort": 13 + } + } + ] + } +} +--------------- + +null +--------------- +{ + "errors": [ + { + "message": "The provided value for filter \u0060eq\u0060 of type ShortOperationFilterInput is invalid. Null values are not supported.", + "locations": [ + { + "line": 1, + "column": 40 + } + ], + "path": [ + "root" + ], + "extensions": { + "code": "HC0026", + "expectedType": "Short!", + "filterType": "ShortOperationFilterInput" + } + } + ], + "data": { + "root": [] + } +} +--------------- diff --git a/src/HotChocolate/Data/test/Data.Filters.Tests/FilterInputTypeTest.cs b/src/HotChocolate/Data/test/Data.Filters.Tests/FilterInputTypeTest.cs index 781277aab65..c0844194e6e 100644 --- a/src/HotChocolate/Data/test/Data.Filters.Tests/FilterInputTypeTest.cs +++ b/src/HotChocolate/Data/test/Data.Filters.Tests/FilterInputTypeTest.cs @@ -45,6 +45,19 @@ public void FilterInputType_DynamicName_NonGeneric() schema.MatchSnapshot(); } + [Fact] + public void FilterInputType_Struct() + { + // arrange + // act + var schema = CreateSchema( + s => s + .AddType(new FilterInputType())); + + // assert + schema.MatchSnapshot(); + } + [Fact] public void FilterInput_AddDirectives_NameArgs() { @@ -82,7 +95,8 @@ public void FilterInput_AddDirectives_DirectiveNode() var schema = CreateSchema(s => s .AddDirectiveType() .AddType(new FilterInputType(d => d - .Directive(new DirectiveNode("foo")).Field(x => x.Bar)))); + .Directive(new DirectiveNode("foo")) + .Field(x => x.Bar)))); // assert schema.MatchSnapshot(); @@ -369,8 +383,7 @@ public void FilterInputType_Should_InfereType_When_ItIsAInterface() public class FooDirectiveType : DirectiveType { - protected override void Configure( - IDirectiveTypeDescriptor descriptor) + protected override void Configure(IDirectiveTypeDescriptor descriptor) { descriptor.Name("foo"); descriptor.Location(Types.DirectiveLocation.InputObject) @@ -408,6 +421,7 @@ public class Book public string? Title { get; set; } public int Pages { get; set; } + public int Chapters { get; set; } public int[] LinesPerPage { get; set; } = Array.Empty(); @@ -432,8 +446,11 @@ public class Author public class Address { public string? Street { get; set; } + public string? PostalCode { get; set; } + public string? City { get; set; } + public Country? Country { get; set; } } @@ -454,6 +471,7 @@ public class User public interface ITest { public string? Prop { get; set; } + public string? Prop2 { get; set; } } @@ -556,4 +574,13 @@ public class CustomHandler : IFilterFieldHandler throw new NotImplementedException(); } } + + public class FilterWithStruct + { + public ExampleValueType ValueType { get; set; } + + public ExampleValueType? ValueTypeNullable { get; set; } + } + + public record struct ExampleValueType(string Foo, string Bar); } diff --git a/src/HotChocolate/Data/test/Data.Filters.Tests/__snapshots__/FilterInputTypeTest.FilterInputType_Struct.graphql b/src/HotChocolate/Data/test/Data.Filters.Tests/__snapshots__/FilterInputTypeTest.FilterInputType_Struct.graphql new file mode 100644 index 00000000000..da4b50bb599 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.Tests/__snapshots__/FilterInputTypeTest.FilterInputType_Struct.graphql @@ -0,0 +1,36 @@ +schema { + query: Query +} + +type Query { + foo: String +} + +input ExampleValueTypeFilterInput { + and: [ExampleValueTypeFilterInput!] + or: [ExampleValueTypeFilterInput!] + foo: StringOperationFilterInput + bar: StringOperationFilterInput +} + +input FilterWithStructFilterInput { + and: [FilterWithStructFilterInput!] + or: [FilterWithStructFilterInput!] + valueType: ExampleValueTypeFilterInput + valueTypeNullable: ExampleValueTypeFilterInput +} + +input StringOperationFilterInput { + and: [StringOperationFilterInput!] + or: [StringOperationFilterInput!] + eq: String + neq: String + contains: String + ncontains: String + in: [String] + nin: [String] + startsWith: String + nstartsWith: String + endsWith: String + nendsWith: String +} \ No newline at end of file