diff --git a/src/HotChocolate/Data/src/Data/DataResources.Designer.cs b/src/HotChocolate/Data/src/Data/DataResources.Designer.cs index 02c68180dd2..152714f977f 100644 --- a/src/HotChocolate/Data/src/Data/DataResources.Designer.cs +++ b/src/HotChocolate/Data/src/Data/DataResources.Designer.cs @@ -489,6 +489,18 @@ internal class DataResources { } } + internal static string QueryableFiltering_ExpressionParameterInvalid { + get { + return ResourceManager.GetString("QueryableFiltering_ExpressionParameterInvalid", resourceCulture); + } + } + + internal static string QueryableFilterProvider_ExpressionParameterInvalid { + get { + return ResourceManager.GetString("QueryableFilterProvider_ExpressionParameterInvalid", resourceCulture); + } + } + internal static string QueryableFiltering_NoMemberDeclared { get { return ResourceManager.GetString("QueryableFiltering_NoMemberDeclared", resourceCulture); diff --git a/src/HotChocolate/Data/src/Data/DataResources.resx b/src/HotChocolate/Data/src/Data/DataResources.resx index 9d570c8a817..f6be4b1f5cc 100644 --- a/src/HotChocolate/Data/src/Data/DataResources.resx +++ b/src/HotChocolate/Data/src/Data/DataResources.resx @@ -245,6 +245,12 @@ Filtering needs a member of type PropertyInfo or MethodInfo but received {0}! This error occured on {1}.{2}: {3} + + Filtering needs a expression with exactly one parameter of type {0}! This error occured on {1}.{2}: {3} + + + Filtering needs a expression with exactly one parameter of type {0}! Check the expression on field {1}. + Filtering needs a member of type PropertyInfo or MethodInfo no member was found! This error occured on {0}.{1}: {2} diff --git a/src/HotChocolate/Data/src/Data/Filters/Convention/FilterConvention.cs b/src/HotChocolate/Data/src/Data/Filters/Convention/FilterConvention.cs index 57fcbfbe133..62cba581747 100644 --- a/src/HotChocolate/Data/src/Data/Filters/Convention/FilterConvention.cs +++ b/src/HotChocolate/Data/src/Data/Filters/Convention/FilterConvention.cs @@ -330,6 +330,12 @@ public bool IsOrAllowed() return false; } + public IFilterMetadata? CreateMetaData( + ITypeCompletionContext context, + IFilterInputTypeDefinition typeDefinition, + IFilterFieldDefinition fieldDefinition) + => _provider.CreateMetaData(context, typeDefinition, fieldDefinition); + private bool TryCreateFilterType( IExtendedType runtimeType, [NotNullWhen(true)] out Type? type) diff --git a/src/HotChocolate/Data/src/Data/Filters/Convention/FilterTypeInterceptor.cs b/src/HotChocolate/Data/src/Data/Filters/Convention/FilterTypeInterceptor.cs index 68be4b3d6c5..0f54d0141e0 100644 --- a/src/HotChocolate/Data/src/Data/Filters/Convention/FilterTypeInterceptor.cs +++ b/src/HotChocolate/Data/src/Data/Filters/Convention/FilterTypeInterceptor.cs @@ -204,8 +204,12 @@ TypeSystemObjectBase Factory(IDescriptorContext _) out IFilterFieldHandler? handler)) { filterFieldDefinition.Handler = handler; - } - else + } + + filterFieldDefinition.Metadata = + convention.CreateMetaData(completionContext, def, filterFieldDefinition); + + if (filterFieldDefinition.Handler is null) { throw ThrowHelper .FilterInterceptor_NoHandlerFoundForField(def, filterFieldDefinition); diff --git a/src/HotChocolate/Data/src/Data/Filters/Convention/IFilterConvention.cs b/src/HotChocolate/Data/src/Data/Filters/Convention/IFilterConvention.cs index e68fcce0b9e..e469b5e1168 100644 --- a/src/HotChocolate/Data/src/Data/Filters/Convention/IFilterConvention.cs +++ b/src/HotChocolate/Data/src/Data/Filters/Convention/IFilterConvention.cs @@ -135,6 +135,14 @@ public interface IFilterConvention : IConvention IFilterFieldDefinition fieldDefinition, [NotNullWhen(true)] out IFilterFieldHandler? handler); + /// + /// Creates metadata for a field that the provider can pick up an use for the translation + /// + IFilterMetadata? CreateMetaData( + ITypeCompletionContext context, + IFilterInputTypeDefinition typeDefinition, + IFilterFieldDefinition fieldDefinition); + /// /// Creates a middleware that represents the filter execution logic /// for the specified entity type. diff --git a/src/HotChocolate/Data/src/Data/Filters/Expressions/ExpressionFilterMetadata.cs b/src/HotChocolate/Data/src/Data/Filters/Expressions/ExpressionFilterMetadata.cs new file mode 100644 index 00000000000..5aab3770f5b --- /dev/null +++ b/src/HotChocolate/Data/src/Data/Filters/Expressions/ExpressionFilterMetadata.cs @@ -0,0 +1,16 @@ +using System.Linq.Expressions; + +namespace HotChocolate.Data.Filters.Expressions; + +/// +/// Defines meta that for a filter field that the provider can use to build the database query +/// +public class ExpressionFilterMetadata : IFilterMetadata +{ + public ExpressionFilterMetadata(Expression? expression) + { + Expression = expression; + } + + public Expression? Expression { get; } +} 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 9149d20abb6..464f758a883 100644 --- a/src/HotChocolate/Data/src/Data/Filters/Expressions/Handlers/QueryableDefaultFieldHandler.cs +++ b/src/HotChocolate/Data/src/Data/Filters/Expressions/Handlers/QueryableDefaultFieldHandler.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Linq.Expressions; using System.Reflection; using HotChocolate.Configuration; @@ -28,7 +29,7 @@ public class QueryableDefaultFieldHandler IFilterInputTypeDefinition typeDefinition, IFilterFieldDefinition fieldDefinition) => fieldDefinition is not FilterOperationFieldDefinition && - fieldDefinition.Member is not null; + (fieldDefinition.Member is not null || fieldDefinition.Expression is not null); public override bool TryHandleEnter( QueryableFilterContext context, @@ -51,20 +52,38 @@ public class QueryableDefaultFieldHandler return false; } - Expression nestedProperty = field.Member switch + Expression nestedProperty; + if (field.Metadata is ExpressionFilterMetadata { Expression: LambdaExpression expression }) { - PropertyInfo propertyInfo => - Expression.Property(context.GetInstance(), propertyInfo), + if (expression.Parameters.Count != 1 || + expression.Parameters[0].Type != context.RuntimeTypes.Peek()!.Source) + { + throw ThrowHelper.QueryableFiltering_ExpressionParameterInvalid( + field.RuntimeType.Source, + field); + } - MethodInfo methodInfo => - Expression.Call(context.GetInstance(), methodInfo), + nestedProperty = ReplaceVariableExpressionVisitor + .ReplaceParameter(expression, expression.Parameters[0], context.GetInstance()) + .Body; + } + else + { + nestedProperty = field.Member switch + { + PropertyInfo propertyInfo => + Expression.Property(context.GetInstance(), propertyInfo), - null => - throw ThrowHelper.QueryableFiltering_NoMemberDeclared(field), + MethodInfo methodInfo => + Expression.Call(context.GetInstance(), methodInfo), - _ => - throw ThrowHelper.QueryableFiltering_MemberInvalid(field.Member, field) - }; + null => + throw ThrowHelper.QueryableFiltering_NoMemberDeclared(field), + + _ => + throw ThrowHelper.QueryableFiltering_MemberInvalid(field.Member, field) + }; + } context.PushInstance(nestedProperty); context.RuntimeTypes.Push(field.RuntimeType); @@ -101,4 +120,34 @@ public class QueryableDefaultFieldHandler action = SyntaxVisitor.Continue; return true; } + + private sealed class ReplaceVariableExpressionVisitor : ExpressionVisitor + { + private readonly Expression _replacement; + private readonly ParameterExpression _parameter; + + public ReplaceVariableExpressionVisitor( + Expression replacement, + ParameterExpression parameter) + { + _replacement = replacement; + _parameter = parameter; + } + + protected override Expression VisitParameter(ParameterExpression node) + { + if (node == _parameter) + { + return _replacement; + } + return base.VisitParameter(node); + } + + public static LambdaExpression ReplaceParameter( + LambdaExpression lambda, + ParameterExpression parameter, + Expression replacement) + => (LambdaExpression) + new ReplaceVariableExpressionVisitor(replacement, parameter).Visit(lambda); + } } diff --git a/src/HotChocolate/Data/src/Data/Filters/Expressions/QueryableFilterProvider.cs b/src/HotChocolate/Data/src/Data/Filters/Expressions/QueryableFilterProvider.cs index 69b6e553168..9ba5122a6f0 100644 --- a/src/HotChocolate/Data/src/Data/Filters/Expressions/QueryableFilterProvider.cs +++ b/src/HotChocolate/Data/src/Data/Filters/Expressions/QueryableFilterProvider.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Linq.Expressions; using System.Threading.Tasks; +using HotChocolate.Configuration; using HotChocolate.Language; using HotChocolate.Resolvers; using HotChocolate.Types; @@ -150,4 +151,27 @@ private static ApplyFiltering CreateApplicator(NameString argumentN contextData[ContextVisitFilterArgumentKey] = argumentKey; contextData[ContextArgumentNameKey] = argumentName; } + + public override IFilterMetadata? CreateMetaData( + ITypeCompletionContext context, + IFilterInputTypeDefinition typeDefinition, + IFilterFieldDefinition fieldDefinition) + { + if (fieldDefinition.Expression is not null) + { + if (fieldDefinition.Expression is not LambdaExpression lambda || + lambda.Parameters.Count != 1 || + lambda.Parameters[0].Type != typeDefinition.EntityType) + { + throw ThrowHelper.QueryableFilterProvider_ExpressionParameterInvalid( + context.Type, + typeDefinition, + fieldDefinition); + } + + return new ExpressionFilterMetadata(fieldDefinition.Expression); + } + + return null; + } } diff --git a/src/HotChocolate/Data/src/Data/Filters/Fields/FilterField.cs b/src/HotChocolate/Data/src/Data/Filters/Fields/FilterField.cs index 39fc9aafcb3..39e4701b03b 100644 --- a/src/HotChocolate/Data/src/Data/Filters/Fields/FilterField.cs +++ b/src/HotChocolate/Data/src/Data/Filters/Fields/FilterField.cs @@ -20,6 +20,7 @@ internal FilterField(FilterFieldDefinition definition, int index) { Member = definition.Member; Handler = definition.Handler!; + Metadata = definition.Metadata; } public new FilterInputType DeclaringType => (FilterInputType)base.DeclaringType; @@ -32,6 +33,8 @@ internal FilterField(FilterFieldDefinition definition, int index) public IFilterFieldHandler Handler { get; } + public IFilterMetadata? Metadata { get; } + protected override void OnCompleteField( ITypeCompletionContext context, ITypeSystemMember declaringMember, @@ -43,5 +46,9 @@ internal FilterField(FilterFieldDefinition definition, int index) { RuntimeType = context.TypeInspector.GetReturnType(Member, ignoreAttributes: true); } + else if (base.RuntimeType is { } runtimeType) + { + RuntimeType = context.TypeInspector.GetType(runtimeType); + } } } diff --git a/src/HotChocolate/Data/src/Data/Filters/Fields/IFilterField.cs b/src/HotChocolate/Data/src/Data/Filters/Fields/IFilterField.cs index a97a515a8d2..397b34069b7 100644 --- a/src/HotChocolate/Data/src/Data/Filters/Fields/IFilterField.cs +++ b/src/HotChocolate/Data/src/Data/Filters/Fields/IFilterField.cs @@ -16,4 +16,6 @@ public interface IFilterField : IInputField new IExtendedType? RuntimeType { get; } IFilterFieldHandler Handler { get; } + + IFilterMetadata? Metadata { get; } } diff --git a/src/HotChocolate/Data/src/Data/Filters/FilterFieldDefinition.cs b/src/HotChocolate/Data/src/Data/Filters/FilterFieldDefinition.cs index 52357629853..4deea788e5c 100644 --- a/src/HotChocolate/Data/src/Data/Filters/FilterFieldDefinition.cs +++ b/src/HotChocolate/Data/src/Data/Filters/FilterFieldDefinition.cs @@ -1,6 +1,8 @@ +using System.Linq.Expressions; using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Reflection; +using HotChocolate.Internal; using HotChocolate.Types; using HotChocolate.Types.Descriptors; using HotChocolate.Types.Descriptors.Definitions; @@ -18,7 +20,11 @@ public class FilterFieldDefinition public IFilterFieldHandler? Handler { get; set; } - public string? Scope { get; set; } + public Expression? Expression { get; set; } + + internal IFilterMetadata? Metadata { get; set; } + + public string? Scope { get; set; } public List AllowedOperations => _allowedOperations ??= new List(); @@ -65,5 +71,5 @@ internal void MergeInto(FilterFieldDefinition target) { target.CreateFieldTypeDefinition = CreateFieldTypeDefinition; } - } + } } diff --git a/src/HotChocolate/Data/src/Data/Filters/FilterFieldDescriptor.cs b/src/HotChocolate/Data/src/Data/Filters/FilterFieldDescriptor.cs index 12f7317f6f7..70a7ee21a19 100644 --- a/src/HotChocolate/Data/src/Data/Filters/FilterFieldDescriptor.cs +++ b/src/HotChocolate/Data/src/Data/Filters/FilterFieldDescriptor.cs @@ -1,4 +1,5 @@ using System; +using System.Linq.Expressions; using System.Reflection; using HotChocolate.Language; using HotChocolate.Types; @@ -38,6 +39,23 @@ public class FilterFieldDescriptor Definition.Scope = scope; } + protected FilterFieldDescriptor( + IDescriptorContext context, + string? scope, + Expression expression) + : base(context) + { + IFilterConvention convention = context.GetFilterConvention(scope); + + Definition.Expression = expression; + Definition.Scope = scope; + if (Definition.Expression is LambdaExpression lambda) + { + Definition.Type = convention.GetFieldType(lambda.ReturnType); + Definition.RuntimeType = lambda.ReturnType; + } + } + protected internal FilterFieldDescriptor( IDescriptorContext context, string? scope) @@ -171,4 +189,10 @@ public new IFilterFieldDescriptor Directive() NameString fieldName, string? scope) => new FilterFieldDescriptor(context, scope, fieldName); + + internal static FilterFieldDescriptor New( + IDescriptorContext context, + string? scope, + Expression expression) => + new FilterFieldDescriptor(context, scope, expression); } diff --git a/src/HotChocolate/Data/src/Data/Filters/FilterInputTypeDescriptor`1.cs b/src/HotChocolate/Data/src/Data/Filters/FilterInputTypeDescriptor`1.cs index bea6d4d4be8..a4cded82f09 100644 --- a/src/HotChocolate/Data/src/Data/Filters/FilterInputTypeDescriptor`1.cs +++ b/src/HotChocolate/Data/src/Data/Filters/FilterInputTypeDescriptor`1.cs @@ -99,23 +99,31 @@ public new IFilterInputTypeDescriptor BindFieldsImplicitly() /// public IFilterFieldDescriptor Field(Expression> propertyOrMember) { - if (propertyOrMember.ExtractMember() is PropertyInfo m) + switch (propertyOrMember.TryExtractMember()) { - FilterFieldDescriptor? fieldDescriptor = - Fields.FirstOrDefault(t => t.Definition.Member == m); - - if (fieldDescriptor is null) - { - fieldDescriptor = FilterFieldDescriptor.New(Context, Definition.Scope, m); + case PropertyInfo m: + FilterFieldDescriptor? fieldDescriptor = + Fields.FirstOrDefault(t => t.Definition.Member == m); + + if (fieldDescriptor is null) + { + fieldDescriptor = FilterFieldDescriptor.New(Context, Definition.Scope, m); + Fields.Add(fieldDescriptor); + } + + return fieldDescriptor; + + case MethodInfo m: + throw new ArgumentException( + FilterInputTypeDescriptor_Field_OnlyProperties, + nameof(propertyOrMember)); + + default: + fieldDescriptor = FilterFieldDescriptor + .New(Context, Definition.Scope, propertyOrMember); Fields.Add(fieldDescriptor); - } - - return fieldDescriptor; + return fieldDescriptor; } - - throw new ArgumentException( - FilterInputTypeDescriptor_Field_OnlyProperties, - nameof(propertyOrMember)); } /// diff --git a/src/HotChocolate/Data/src/Data/Filters/IFilterFieldDefinition.cs b/src/HotChocolate/Data/src/Data/Filters/IFilterFieldDefinition.cs index 33771edd42e..cbe5baa51f8 100644 --- a/src/HotChocolate/Data/src/Data/Filters/IFilterFieldDefinition.cs +++ b/src/HotChocolate/Data/src/Data/Filters/IFilterFieldDefinition.cs @@ -1,14 +1,23 @@ +using System.Linq.Expressions; using System.Collections.Generic; using System.Reflection; +using HotChocolate.Types; +using HotChocolate.Types.Descriptors.Definitions; namespace HotChocolate.Data.Filters; public interface IFilterFieldDefinition + : IDefinition + , IHasDirectiveDefinition + , IHasIgnore + , IHasScope { MemberInfo? Member { get; } IFilterFieldHandler? Handler { get; } + Expression? Expression { get; } + string? Scope { get; } List AllowedOperations { get; } diff --git a/src/HotChocolate/Data/src/Data/Filters/IFilterMetaData.cs b/src/HotChocolate/Data/src/Data/Filters/IFilterMetaData.cs new file mode 100644 index 00000000000..8cf505fe6bf --- /dev/null +++ b/src/HotChocolate/Data/src/Data/Filters/IFilterMetaData.cs @@ -0,0 +1,10 @@ +namespace HotChocolate.Data.Filters; + +/// +/// Defines meta that for a filter field that the provider can use to build the database query +/// +/// +#pragma warning disable CA1040 // Avoid empty interfaces +public interface IFilterMetadata +{ +} diff --git a/src/HotChocolate/Data/src/Data/Filters/Visitor/FilterProvider.cs b/src/HotChocolate/Data/src/Data/Filters/Visitor/FilterProvider.cs index 96531342af5..115b441a785 100644 --- a/src/HotChocolate/Data/src/Data/Filters/Visitor/FilterProvider.cs +++ b/src/HotChocolate/Data/src/Data/Filters/Visitor/FilterProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using HotChocolate.Configuration; using HotChocolate.Resolvers; using HotChocolate.Types; using HotChocolate.Types.Descriptors; @@ -152,4 +153,10 @@ protected internal override void Complete(IConventionContext context) IObjectFieldDescriptor descriptor) { } + + public virtual IFilterMetadata? CreateMetaData( + ITypeCompletionContext context, + IFilterInputTypeDefinition typeDefinition, + IFilterFieldDefinition fieldDefinition) + => null; } diff --git a/src/HotChocolate/Data/src/Data/Filters/Visitor/IFilterProvider.cs b/src/HotChocolate/Data/src/Data/Filters/Visitor/IFilterProvider.cs index 9ffe5e4b9b0..abbd6f3f482 100644 --- a/src/HotChocolate/Data/src/Data/Filters/Visitor/IFilterProvider.cs +++ b/src/HotChocolate/Data/src/Data/Filters/Visitor/IFilterProvider.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using HotChocolate.Configuration; using HotChocolate.Resolvers; using HotChocolate.Types; @@ -33,4 +34,12 @@ public interface IFilterProvider /// data to the field. /// void ConfigureField(NameString argumentName, IObjectFieldDescriptor descriptor); + + /// + /// Creates metadata for a field that the provider can pick up an use for the translation + /// + IFilterMetadata? CreateMetaData( + ITypeCompletionContext context, + IFilterInputTypeDefinition typeDefinition, + IFilterFieldDefinition fieldDefinition); } diff --git a/src/HotChocolate/Data/src/Data/HotChocolate.Data.csproj b/src/HotChocolate/Data/src/Data/HotChocolate.Data.csproj index 5950987d6f6..dcc45c5d7c6 100644 --- a/src/HotChocolate/Data/src/Data/HotChocolate.Data.csproj +++ b/src/HotChocolate/Data/src/Data/HotChocolate.Data.csproj @@ -29,6 +29,11 @@ + + + + + True diff --git a/src/HotChocolate/Data/src/Data/ThrowHelper.cs b/src/HotChocolate/Data/src/Data/ThrowHelper.cs index 5bdc2b42bb6..facff875290 100644 --- a/src/HotChocolate/Data/src/Data/ThrowHelper.cs +++ b/src/HotChocolate/Data/src/Data/ThrowHelper.cs @@ -449,6 +449,30 @@ internal static class ThrowHelper field.Name, field.Type.Print())); + public static InvalidOperationException QueryableFiltering_ExpressionParameterInvalid( + Type type, + IFilterField field) => + new(string.Format( + CultureInfo.CurrentCulture, + DataResources.QueryableFiltering_ExpressionParameterInvalid, + type.FullName, + field.DeclaringType.Print(), + field.Name, + field.Type.Print())); + + public static SchemaException QueryableFilterProvider_ExpressionParameterInvalid( + ITypeSystemObject type, + IFilterInputTypeDefinition typeDefinition, + IFilterFieldDefinition field) => + new(SchemaErrorBuilder + .New() + .SetMessage( + DataResources.QueryableFilterProvider_ExpressionParameterInvalid, + typeDefinition.EntityType?.FullName, + field.Name) + .SetTypeSystemObject(type) + .Build()); + public static InvalidOperationException QueryableFiltering_NoMemberDeclared(IFilterField field) => new(string.Format( diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorComparableTests.cs b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorComparableTests.cs index 7ae896bfc15..658b387987a 100644 --- a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorComparableTests.cs +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorComparableTests.cs @@ -160,7 +160,6 @@ public async Task Create_ShortNotGreaterThan_Expression() res4.MatchSnapshot("null"); } - [Fact] public async Task Create_ShortGreaterThanOrEquals_Expression() { diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorExpressionTests.cs b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorExpressionTests.cs new file mode 100644 index 00000000000..e860e0c0a40 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/QueryableFilterVisitorExpressionTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using HotChocolate.Execution; +using HotChocolate.Execution.Configuration; +using HotChocolate.Tests; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; +using Snapshooter.Xunit; +using Xunit; + +namespace HotChocolate.Data.Filters; + +public class QueryableFilterVisitorExpressionTests : IClassFixture +{ + private static readonly Foo[] _fooEntities = + { + new Foo { Name = "Foo", LastName = "Galoo", Bars = new Bar[]{ new Bar { Value="A"} } }, + new Foo { Name = "Sam", LastName = "Sampleman", Bars = Array.Empty() } + }; + + private readonly SchemaCache _cache; + + public QueryableFilterVisitorExpressionTests(SchemaCache cache) + { + _cache = cache; + } + + [Fact] + public async Task Create_StringConcatExpression() + { + // arrange + IRequestExecutor? tester = _cache.CreateSchema(_fooEntities); + + // act + // assert + IExecutionResult? res1 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { displayName: { eq: \"Sam Sampleman\"}}){ name lastName}}") + .Create()); + + res1.MatchSnapshot("Sam_Sampleman"); + + IExecutionResult? res2 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { displayName: { eq: \"NoMatch\"}}){ name lastName}}") + .Create()); + + res2.MatchSnapshot("NoMatch"); + + IExecutionResult? res3 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { displayName: { eq: null}}){ name lastName}}") + .Create()); + + res3.MatchSnapshot("null"); + } + + [Fact] + public async Task Expression_WithMoreThanOneParameter_ThrowsException() + { + // arrange + IRequestExecutorBuilder builder = new ServiceCollection() + .AddGraphQL() + .AddQueryType(x => x + .Name("Query") + .Field("Foo") + .Resolve(Array.Empty()) + .UseFiltering()) + .AddType(new FilterInputType(x => x + .Field(x => x.LastName) + .Extend() + .OnBeforeCreate(x => x.Expression = (Foo x, string bar) => x.LastName == bar))) + .AddFiltering(); + + // act + async Task Call() => await builder.BuildRequestExecutorAsync(); + + // assert + SchemaException ex = await Assert.ThrowsAsync(Call); + ex.Errors.Single().Message.MatchSnapshot(); + } + + [Fact] + public async Task Create_CollectionLengthExpression() + { + // arrange + IRequestExecutor? tester = _cache.CreateSchema(_fooEntities); + + // act + // assert + IExecutionResult? res1 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { barLength: { eq: 1}}){ name lastName}}") + .Create()); + + res1.MatchSnapshot("1"); + + IExecutionResult? res2 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { barLength: { eq: 0}}){ name lastName}}") + .Create()); + + res2.MatchSnapshot("0"); + + IExecutionResult? res3 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { barLength: { eq: null}}){ name lastName}}") + .Create()); + + res3.MatchSnapshot("null"); + } + + public class Foo + { + public int Id { get; set; } + + public string? Name { get; set; } + + public string? LastName { get; set; } + + public ICollection? Bars { get; set; } + } + + public class Bar + { + public int Id { get; set; } + + public string? Value { get; set; } + } + + public class FooFilterInputType : FilterInputType + { + protected override void Configure(IFilterInputTypeDescriptor descriptor) + { + descriptor.Field(x => x.Name + " " + x.LastName).Name("displayName"); + descriptor.Field(x => x.Bars!.Count).Name("barLength"); + } + } +} diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_0.snap b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_0.snap new file mode 100644 index 00000000000..51e534ab0a6 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_0.snap @@ -0,0 +1,10 @@ +{ + "data": { + "root": [ + { + "name": "Sam", + "lastName": "Sampleman" + } + ] + } +} diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_1.snap b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_1.snap new file mode 100644 index 00000000000..879f6888343 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_1.snap @@ -0,0 +1,10 @@ +{ + "data": { + "root": [ + { + "name": "Foo", + "lastName": "Galoo" + } + ] + } +} diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_null.snap b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_null.snap new file mode 100644 index 00000000000..d3e5801b9f1 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_null.snap @@ -0,0 +1,24 @@ +{ + "errors": [ + { + "message": "The provided value for filter \u0060eq\u0060 of type ComparableInt32OperationFilterInput is invalid. Null values are not supported.", + "locations": [ + { + "line": 1, + "column": 34 + } + ], + "path": [ + "root" + ], + "extensions": { + "code": "HC0026", + "expectedType": "Int!", + "filterType": "ComparableInt32OperationFilterInput" + } + } + ], + "data": { + "root": [] + } +} diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_NoMatch.snap b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_NoMatch.snap new file mode 100644 index 00000000000..c3908564f27 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_NoMatch.snap @@ -0,0 +1,5 @@ +{ + "data": { + "root": [] + } +} diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_Sam_Sampleman.snap b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_Sam_Sampleman.snap new file mode 100644 index 00000000000..51e534ab0a6 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_Sam_Sampleman.snap @@ -0,0 +1,10 @@ +{ + "data": { + "root": [ + { + "name": "Sam", + "lastName": "Sampleman" + } + ] + } +} diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_null.snap b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_null.snap new file mode 100644 index 00000000000..c3908564f27 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_null.snap @@ -0,0 +1,5 @@ +{ + "data": { + "root": [] + } +} diff --git a/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Expression_WithMoreThanOneParameter_ThrowsException.snap b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Expression_WithMoreThanOneParameter_ThrowsException.snap new file mode 100644 index 00000000000..4811b7f8b49 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.InMemory.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Expression_WithMoreThanOneParameter_ThrowsException.snap @@ -0,0 +1 @@ +Filtering needs a expression with exactly one parameter of type HotChocolate.Data.Filters.QueryableFilterVisitorExpressionTests+Foo! Check the expression on field lastName. diff --git a/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/QueryableFilterVisitorExpressionTests.cs b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/QueryableFilterVisitorExpressionTests.cs new file mode 100644 index 00000000000..912ad39da43 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/QueryableFilterVisitorExpressionTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using HotChocolate.Execution; +using Xunit; + +namespace HotChocolate.Data.Filters; + +public class QueryableFilterVisitorExpressionTests : IClassFixture +{ + private static readonly Foo[] _fooEntities = + { + new Foo { Name = "Foo", LastName = "Galoo", Bars = new Bar[]{ new Bar { Value="A"} } }, + new Foo { Name = "Sam", LastName = "Sampleman", Bars = Array.Empty() } + }; + + private readonly SchemaCache _cache; + + public QueryableFilterVisitorExpressionTests(SchemaCache cache) + { + _cache = cache; + } + + [Fact] + public async Task Create_StringConcatExpression() + { + // arrange + IRequestExecutor? tester = _cache.CreateSchema(_fooEntities); + + // act + // assert + IExecutionResult? res1 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { displayName: { eq: \"Sam Sampleman\"}}){ name lastName}}") + .Create()); + + res1.MatchSqlSnapshot("Sam_Sampleman"); + + IExecutionResult? res2 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { displayName: { eq: \"NoMatch\"}}){ name lastName}}") + .Create()); + + res2.MatchSqlSnapshot("NoMatch"); + + IExecutionResult? res3 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { displayName: { eq: null}}){ name lastName}}") + .Create()); + + res3.MatchSqlSnapshot("null"); + } + + [Fact] + public async Task Create_CollectionLengthExpression() + { + // arrange + IRequestExecutor? tester = _cache.CreateSchema(_fooEntities); + + // act + // assert + IExecutionResult? res1 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { barLength: { eq: 1}}){ name lastName}}") + .Create()); + + res1.MatchSqlSnapshot("1"); + + IExecutionResult? res2 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { barLength: { eq: 0}}){ name lastName}}") + .Create()); + + res2.MatchSqlSnapshot("0"); + + IExecutionResult? res3 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery("{ root(where: { barLength: { eq: null}}){ name lastName}}") + .Create()); + + res3.MatchSqlSnapshot("null"); + } + + public class Foo + { + public int Id { get; set; } + + public string? Name { get; set; } + + public string? LastName { get; set; } + + public ICollection? Bars { get; set; } + } + + public class Bar + { + public int Id { get; set; } + + public string? Value { get; set; } + } + + public class FooFilterInputType : FilterInputType + { + protected override void Configure(IFilterInputTypeDescriptor descriptor) + { + descriptor.Field(x => x.Name + " " + x.LastName).Name("displayName"); + descriptor.Field(x => x.Bars!.Count).Name("barLength"); + } + } +} diff --git a/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_0_NET6_0.snap b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_0_NET6_0.snap new file mode 100644 index 00000000000..51e534ab0a6 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_0_NET6_0.snap @@ -0,0 +1,10 @@ +{ + "data": { + "root": [ + { + "name": "Sam", + "lastName": "Sampleman" + } + ] + } +} diff --git a/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_0_sql_NET6_0.snap b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_0_sql_NET6_0.snap new file mode 100644 index 00000000000..f9bd15543de --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_0_sql_NET6_0.snap @@ -0,0 +1,6 @@ +SELECT "d"."Id", "d"."LastName", "d"."Name" +FROM "Data" AS "d" +WHERE ( + SELECT COUNT(*) + FROM "Bar" AS "b" + WHERE ("d"."Id" <> NULL) AND ("d"."Id" = "b"."FooId")) = @__p_0 diff --git a/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_1_NET6_0.snap b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_1_NET6_0.snap new file mode 100644 index 00000000000..879f6888343 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_1_NET6_0.snap @@ -0,0 +1,10 @@ +{ + "data": { + "root": [ + { + "name": "Foo", + "lastName": "Galoo" + } + ] + } +} diff --git a/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_1_sql_NET6_0.snap b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_1_sql_NET6_0.snap new file mode 100644 index 00000000000..f9bd15543de --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_1_sql_NET6_0.snap @@ -0,0 +1,6 @@ +SELECT "d"."Id", "d"."LastName", "d"."Name" +FROM "Data" AS "d" +WHERE ( + SELECT COUNT(*) + FROM "Bar" AS "b" + WHERE ("d"."Id" <> NULL) AND ("d"."Id" = "b"."FooId")) = @__p_0 diff --git a/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_null_NET6_0.snap b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_null_NET6_0.snap new file mode 100644 index 00000000000..d3e5801b9f1 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_CollectionLengthExpression_null_NET6_0.snap @@ -0,0 +1,24 @@ +{ + "errors": [ + { + "message": "The provided value for filter \u0060eq\u0060 of type ComparableInt32OperationFilterInput is invalid. Null values are not supported.", + "locations": [ + { + "line": 1, + "column": 34 + } + ], + "path": [ + "root" + ], + "extensions": { + "code": "HC0026", + "expectedType": "Int!", + "filterType": "ComparableInt32OperationFilterInput" + } + } + ], + "data": { + "root": [] + } +} diff --git a/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_NoMatch_NET6_0.snap b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_NoMatch_NET6_0.snap new file mode 100644 index 00000000000..c3908564f27 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_NoMatch_NET6_0.snap @@ -0,0 +1,5 @@ +{ + "data": { + "root": [] + } +} diff --git a/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_NoMatch_sql_NET6_0.snap b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_NoMatch_sql_NET6_0.snap new file mode 100644 index 00000000000..97372ebb8a2 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_NoMatch_sql_NET6_0.snap @@ -0,0 +1,3 @@ +SELECT "d"."Id", "d"."LastName", "d"."Name" +FROM "Data" AS "d" +WHERE (("d"."Name" || ' ') || "d"."LastName") = @__p_0 diff --git a/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_Sam_Sampleman_NET6_0.snap b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_Sam_Sampleman_NET6_0.snap new file mode 100644 index 00000000000..51e534ab0a6 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_Sam_Sampleman_NET6_0.snap @@ -0,0 +1,10 @@ +{ + "data": { + "root": [ + { + "name": "Sam", + "lastName": "Sampleman" + } + ] + } +} diff --git a/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_Sam_Sampleman_sql_NET6_0.snap b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_Sam_Sampleman_sql_NET6_0.snap new file mode 100644 index 00000000000..97372ebb8a2 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_Sam_Sampleman_sql_NET6_0.snap @@ -0,0 +1,3 @@ +SELECT "d"."Id", "d"."LastName", "d"."Name" +FROM "Data" AS "d" +WHERE (("d"."Name" || ' ') || "d"."LastName") = @__p_0 diff --git a/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_null_NET6_0.snap b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_null_NET6_0.snap new file mode 100644 index 00000000000..c3908564f27 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_null_NET6_0.snap @@ -0,0 +1,5 @@ +{ + "data": { + "root": [] + } +} diff --git a/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_null_sql_NET6_0.snap b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_null_sql_NET6_0.snap new file mode 100644 index 00000000000..97372ebb8a2 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/__snapshots__/QueryableFilterVisitorExpressionTests.Create_StringConcatExpression_null_sql_NET6_0.snap @@ -0,0 +1,3 @@ +SELECT "d"."Id", "d"."LastName", "d"."Name" +FROM "Data" AS "d" +WHERE (("d"."Name" || ' ') || "d"."LastName") = @__p_0 diff --git a/src/HotChocolate/Data/test/Data.Filters.Tests/FilterConventionExtensionsTests.cs b/src/HotChocolate/Data/test/Data.Filters.Tests/FilterConventionExtensionsTests.cs index f25c4eafac4..bed79adcffb 100644 --- a/src/HotChocolate/Data/test/Data.Filters.Tests/FilterConventionExtensionsTests.cs +++ b/src/HotChocolate/Data/test/Data.Filters.Tests/FilterConventionExtensionsTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using HotChocolate.Configuration; using HotChocolate.Data.Filters; using HotChocolate.Data.Filters.Expressions; using HotChocolate.Resolvers; @@ -269,6 +270,11 @@ public void ConfigureField(NameString argumentName, IObjectFieldDescriptor descr { throw new NotImplementedException(); } + + public IFilterMetadata? CreateMetaData(ITypeCompletionContext context, IFilterInputTypeDefinition typeDefinition, IFilterFieldDefinition fieldDefinition) + { + return null; + } } private sealed class MockFilterConvention : FilterConvention