Skip to content

Commit db1034b

Browse files
committed
feat: can support hasconversion()
resolves #10
1 parent 6b47110 commit db1034b

File tree

5 files changed

+204
-8
lines changed

5 files changed

+204
-8
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
namespace QueryKit.IntegrationTests.Tests;
2+
3+
using Configuration;
4+
using FluentAssertions;
5+
using Microsoft.EntityFrameworkCore;
6+
using SharedTestingHelper.Fakes;
7+
using WebApiTestProject.Database;
8+
using WebApiTestProject.Entities;
9+
using Xunit.Abstractions;
10+
11+
public class HasConversionTests(ITestOutputHelper testOutputHelper) : TestBase
12+
{
13+
[Fact]
14+
public async Task can_filter_by_email_with_has_conversion()
15+
{
16+
// Arrange
17+
var testingServiceScope = new TestingServiceScope();
18+
var faker = new Bogus.Faker();
19+
20+
var testEmail = faker.Internet.Email();
21+
var person = new FakeTestingPersonBuilder()
22+
.WithEmail(testEmail)
23+
.Build();
24+
var personTwo = new FakeTestingPersonBuilder().Build();
25+
26+
await testingServiceScope.InsertAsync(person, personTwo);
27+
28+
var input = $"""Email == "{testEmail}" """;
29+
var config = new QueryKitConfiguration(config =>
30+
{
31+
config.Property<TestingPerson>(x => x.Email).HasConversion<string>();
32+
});
33+
34+
// Act
35+
var queryablePeople = testingServiceScope.DbContext().People;
36+
var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config);
37+
var people = await appliedQueryable.ToListAsync();
38+
39+
// Assert
40+
people.Count.Should().Be(1);
41+
people[0].Id.Should().Be(person.Id);
42+
}
43+
}

QueryKit.UnitTests/FilterParserTests.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,4 +762,38 @@ public void can_throw_exception_when_invalid_enum_value()
762762
act.Should().Throw<ParsingException>()
763763
.WithMessage("There was a parsing failure, likely due to an invalid comparison or logical operator. You may also be missing double quotes surrounding a string or guid.*");
764764
}
765+
766+
[Fact]
767+
public void can_filter_with_has_conversion_configuration()
768+
{
769+
// Test that HasConversion<string>() configuration allows targeting the property directly
770+
// The Email property is configured with HasConversion to indicate it should be treated as a string
771+
// instead of accessing Email.Value. This test verifies the parsing works,
772+
// but the actual EF Core translation should be tested in integration tests.
773+
var input = """Email == "[email protected]" """;
774+
775+
var config = new QueryKitConfiguration(config =>
776+
{
777+
// Configure Email property to use HasConversion<string>()
778+
// This tells QueryKit that Email should be compared directly as a string
779+
// rather than requiring Email.Value access
780+
config.Property<TestingPerson>(x => x.Email).HasConversion<string>();
781+
});
782+
783+
var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
784+
785+
// The expression should be created successfully (not throw an exception)
786+
filterExpression.Should().NotBeNull();
787+
788+
// Let's see what the actual expression looks like
789+
var expressionString = filterExpression.ToString();
790+
791+
// Debug output - this should show us the actual expression
792+
Console.WriteLine($"Generated expression: {expressionString}");
793+
794+
// The expression should be created and contain the key elements
795+
expressionString.Should().NotBeNullOrEmpty();
796+
expressionString.Should().Contain("x.Email");
797+
expressionString.Should().Contain("[email protected]");
798+
}
765799
}

QueryKit/FilterParser.cs

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,21 @@ from closingBracket in Parse.Char(']')
222222
{ typeof(sbyte), value => sbyte.Parse(value, CultureInfo.InvariantCulture) },
223223
};
224224

225-
private static Expression CreateRightExpr(Expression leftExpr, string right, ComparisonOperator op)
225+
private static Expression CreateRightExpr(Expression leftExpr, string right, ComparisonOperator op,
226+
IQueryKitConfiguration? config = null, string? propertyPath = null)
226227
{
227228
var targetType = leftExpr.Type;
229+
230+
// Check if this property uses HasConversion and should use the conversion target type
231+
if (config?.PropertyMappings != null && !string.IsNullOrEmpty(propertyPath))
232+
{
233+
var propertyConfig = config.PropertyMappings.GetPropertyInfoByQueryName(propertyPath);
234+
if (propertyConfig?.UsesConversion == true && propertyConfig.ConversionTargetType != null)
235+
{
236+
targetType = propertyConfig.ConversionTargetType;
237+
}
238+
}
239+
228240
return CreateRightExprFromType(targetType, right, op);
229241
}
230242

@@ -551,8 +563,15 @@ private static Parser<Expression> ComparisonExprParser<T>(ParameterExpression pa
551563

552564
if (temp.leftExpr.Type == typeof(Guid) || temp.leftExpr.Type == typeof(Guid?))
553565
{
566+
// Try to determine the property path for HasConversion support
567+
string? guidPropertyPath = null;
568+
if (temp.leftExpr is MemberExpression guidMemberExpr)
569+
{
570+
guidPropertyPath = GetPropertyPath(guidMemberExpr, parameter);
571+
}
572+
554573
var guidStringExpr = HandleGuidConversion(temp.leftExpr, temp.leftExpr.Type);
555-
return temp.op.GetExpression<T>(guidStringExpr, CreateRightExpr(temp.leftExpr, temp.right, temp.op),
574+
return temp.op.GetExpression<T>(guidStringExpr, CreateRightExpr(temp.leftExpr, temp.right, temp.op, config, guidPropertyPath),
556575
config?.DbContextType);
557576
}
558577

@@ -579,14 +598,39 @@ private static Parser<Expression> ComparisonExprParser<T>(ParameterExpression pa
579598
}
580599
}
581600

582-
var rightExpr = CreateRightExpr(temp.leftExpr, temp.right, temp.op);
601+
// Try to determine the property path for HasConversion support
602+
string? propertyPath = null;
603+
if (temp.leftExpr is MemberExpression memberExpr)
604+
{
605+
propertyPath = GetPropertyPath(memberExpr, parameter);
606+
}
607+
608+
var rightExpr = CreateRightExpr(temp.leftExpr, temp.right, temp.op, config, propertyPath);
583609

584610
// Handle nested collection filtering
585611
if (temp.leftExpr is MethodCallExpression methodCall && IsNestedCollectionExpression(methodCall))
586612
{
587613
return CreateNestedCollectionFilterExpression<T>(methodCall, rightExpr, temp.op);
588614
}
589615

616+
// Special handling for HasConversion properties
617+
if (config?.PropertyMappings != null && !string.IsNullOrEmpty(propertyPath))
618+
{
619+
var propertyConfig = config.PropertyMappings.GetPropertyInfoByQueryName(propertyPath);
620+
if (propertyConfig?.UsesConversion == true && temp.op.Operator() == "==")
621+
{
622+
// For HasConversion properties, use Object.Equals instead of Expression.Equal
623+
// This avoids the type compatibility check and lets EF Core handle the conversion
624+
var equalsMethod = typeof(object).GetMethod("Equals", new[] { typeof(object), typeof(object) });
625+
if (equalsMethod != null)
626+
{
627+
var leftAsObject = Expression.Convert(temp.leftExpr, typeof(object));
628+
var rightAsObject = Expression.Convert(rightExpr, typeof(object));
629+
return Expression.Call(equalsMethod, leftAsObject, rightAsObject);
630+
}
631+
}
632+
}
633+
590634
return temp.op.GetExpression<T>(temp.leftExpr, rightExpr, config?.DbContextType);
591635
});
592636

@@ -712,10 +756,52 @@ private static Parser<Expression> ComparisonExprParser<T>(ParameterExpression pa
712756
return Expression.Constant(true, typeof(bool));
713757
}
714758

759+
// Check if this property uses HasConversion
760+
var currentPropertyConfig = config?.PropertyMappings?.GetPropertyInfoByQueryName(fullPropPath);
761+
if (currentPropertyConfig?.UsesConversion == true)
762+
{
763+
// For HasConversion properties, return the property expression as-is
764+
// EF Core will handle the type conversion automatically when it translates the expression to SQL
765+
// The key is that the right-side value will be converted to match the property's conversion target type
766+
return propertyExpression;
767+
}
768+
769+
// Also check if this is a nested property where the parent has HasConversion configured
770+
if (propertyExpression is MemberExpression nestedMemberExpression &&
771+
nestedMemberExpression.Expression is MemberExpression parentExpression)
772+
{
773+
var parentPropertyPath = GetPropertyPath(parentExpression, parameter);
774+
var parentPropertyConfig = config?.PropertyMappings?.GetPropertyInfoByQueryName(parentPropertyPath);
775+
776+
if (parentPropertyConfig?.UsesConversion == true)
777+
{
778+
// Use the parent expression instead of the nested property
779+
return parentExpression;
780+
}
781+
}
782+
715783
return propertyExpression;
716784
});
717785
}
718786

787+
private static string GetPropertyPath(MemberExpression memberExpression, ParameterExpression parameter)
788+
{
789+
var parts = new List<string>();
790+
var current = memberExpression;
791+
792+
while (current != null)
793+
{
794+
parts.Insert(0, current.Member.Name);
795+
796+
if (current.Expression == parameter)
797+
break;
798+
799+
current = current.Expression as MemberExpression;
800+
}
801+
802+
return string.Join(".", parts);
803+
}
804+
719805
private static Type? GetInnerGenericType(Type type)
720806
{
721807
if (!IsEnumerable(type))

QueryKit/QueryKitPropertyMappings.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,13 @@ public QueryKitPropertyMapping<TModel> HasQueryName(string queryName)
406406
_propertyInfo.QueryName = queryName;
407407
return this;
408408
}
409+
410+
public QueryKitPropertyMapping<TModel> HasConversion<TTarget>()
411+
{
412+
_propertyInfo.UsesConversion = true;
413+
_propertyInfo.ConversionTargetType = typeof(TTarget);
414+
return this;
415+
}
409416
}
410417

411418
public class QueryKitCustomOperationMapping<TModel>
@@ -439,4 +446,6 @@ public class QueryKitPropertyInfo
439446
internal Expression DerivedExpression { get; set; }
440447
internal Expression<Func<object, ComparisonOperator, object, bool>>? CustomOperation { get; set; }
441448
internal Type? CustomOperationEntityType { get; set; }
449+
internal bool UsesConversion { get; set; }
450+
internal Type? ConversionTargetType { get; set; }
442451
}

README.md

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -590,17 +590,17 @@ public sealed class PersonConfiguration : IEntityTypeConfiguration<SpecialPerson
590590
{
591591
builder.HasKey(x => x.Id);
592592

593-
// Option 1 (as of .NET 8)
593+
// Option 1 (as of .NET 8) - ComplexProperty
594594
builder.ComplexProperty(x => x.Email,
595595
x => x.Property(y => y.Value)
596596
.HasColumnName("email"));
597597

598-
// Option 2
598+
// Option 2 - HasConversion (see HasConversion support below)
599599
builder.Property(x => x.Email)
600600
.HasConversion(x => x.Value, x => new EmailAddress(x))
601601
.HasColumnName("email");
602602

603-
// Option 3
603+
// Option 3 - OwnsOne
604604
builder.OwnsOne(x => x.Email, opts =>
605605
{
606606
opts.Property(x => x.Value).HasColumnName("email");
@@ -610,8 +610,32 @@ public sealed class PersonConfiguration : IEntityTypeConfiguration<SpecialPerson
610610
}
611611
```
612612

613-
> **Warning**
614-
> EF properties configured with `HasConversion` are not supported at this time
613+
### HasConversion Support
614+
615+
For properties configured with EF Core's `HasConversion`, QueryKit provides special support that allows you to filter against the property directly without needing to access nested values. Use the `HasConversion<TTarget>()` configuration method:
616+
617+
```c#
618+
// EF configuration with HasConversion
619+
builder.Property(x => x.Email)
620+
.HasConversion(x => x.Value, x => new EmailAddress(x))
621+
.HasColumnName("email");
622+
623+
// QueryKit configuration for HasConversion properties
624+
var config = new QueryKitConfiguration(config =>
625+
{
626+
config.Property<SpecialPerson>(x => x.Email)
627+
.HasQueryName("email")
628+
.HasConversion<string>(); // Specify the target type used in HasConversion
629+
});
630+
631+
// Now you can filter directly against the property:
632+
var input = """email == "[email protected]" """;
633+
var people = _dbContext.People
634+
.ApplyQueryKitFilter(input, config)
635+
.ToList();
636+
```
637+
638+
This allows you to use `Email == "value"` syntax instead of `Email.Value == "value"` when the property is configured with HasConversion in EF Core. The `HasConversion<TTarget>()` method tells QueryKit what the conversion target type is so it can handle the type conversion properly.
615639

616640
## Sorting
617641

0 commit comments

Comments
 (0)