Skip to content

Commit 81e54b7

Browse files
committed
feat: nested collections
resolves #69
1 parent de84e3f commit 81e54b7

File tree

2 files changed

+161
-6
lines changed

2 files changed

+161
-6
lines changed

QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ public async Task can_filter_by_guid_for_collection()
388388
recipes[0].Ingredients.First().Id.Should().Be(ingredient.Id);
389389
}
390390

391-
[Fact(Skip = "Can not handle nested collections yet.")]
391+
[Fact]
392392
public async Task can_filter_by_string_for_nested_collection()
393393
{
394394
// Arrange
@@ -413,7 +413,6 @@ public async Task can_filter_by_string_for_nested_collection()
413413
var input = $"""Ingredients.Preparations.Text == "{preparationOne.Text}" """;
414414
var config = new QueryKitConfiguration(settings =>
415415
{
416-
settings.Property<Recipe>(x => x.Ingredients.SelectMany(y => y.Preparations).Select(y => y.Text));
417416
});
418417

419418
// Act
@@ -426,6 +425,48 @@ public async Task can_filter_by_string_for_nested_collection()
426425
recipes[0].Id.Should().Be(fakeRecipeOne.Id);
427426
}
428427

428+
[Fact]
429+
public async Task can_filter_by_string_for_nested_collection_with_alias()
430+
{
431+
// Arrange
432+
var testingServiceScope = new TestingServiceScope();
433+
var faker = new Faker();
434+
var preparationOne = new FakeIngredientPreparation().Generate();
435+
var preparationTwo = new FakeIngredientPreparation().Generate();
436+
var fakeIngredientOne = new FakeIngredientBuilder()
437+
.WithPreparation(preparationOne)
438+
.Build();
439+
var fakeRecipeOne = new FakeRecipeBuilder().Build();
440+
fakeRecipeOne.AddIngredient(fakeIngredientOne);
441+
442+
var fakeIngredientTwo = new FakeIngredientBuilder()
443+
.WithName(faker.Lorem.Sentence())
444+
.WithPreparation(preparationTwo)
445+
.Build();
446+
var fakeRecipeTwo = new FakeRecipeBuilder().Build();
447+
fakeRecipeTwo.AddIngredient(fakeIngredientTwo);
448+
await testingServiceScope.InsertAsync(fakeRecipeOne, fakeRecipeTwo);
449+
450+
var input = $"""preparations == "{preparationOne.Text}" """;
451+
var config = new QueryKitConfiguration(settings =>
452+
{
453+
settings.Property<Recipe>(x => x.Ingredients
454+
.SelectMany(y => y.Preparations)
455+
.Select(y => y.Text))
456+
.HasQueryName("preparations");
457+
});
458+
459+
// Act
460+
var queryableRecipes = testingServiceScope.DbContext().Recipes;
461+
var appliedQueryable = queryableRecipes.ApplyQueryKitFilter(input, config);
462+
var recipes = await appliedQueryable.ToListAsync();
463+
464+
// Assert
465+
recipes.Count.Should().Be(1);
466+
recipes[0].Id.Should().Be(fakeRecipeOne.Id);
467+
}
468+
469+
429470
[Fact]
430471
public async Task can_filter_by_string_for_collection_does_not_contain()
431472
{

QueryKit/FilterParser.cs

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,13 @@ private static Parser<Expression> ComparisonExprParser<T>(ParameterExpression pa
434434
}
435435

436436
var rightExpr = CreateRightExpr(temp.leftExpr, temp.right, temp.op);
437+
438+
// Handle nested collection filtering
439+
if (temp.leftExpr is MethodCallExpression methodCall && IsNestedCollectionExpression(methodCall))
440+
{
441+
return CreateNestedCollectionFilterExpression<T>(methodCall, rightExpr, temp.op);
442+
}
443+
437444
return temp.op.GetExpression<T>(temp.leftExpr, rightExpr, config?.DbContextType);
438445
});
439446
}
@@ -469,8 +476,17 @@ private static Parser<Expression> ComparisonExprParser<T>(ParameterExpression pa
469476
var propertyInfoForMethod = GetPropertyInfo(genericArgType, propName);
470477
Expression lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod.Name);
471478

472-
var type = typeof(IEnumerable<>).MakeGenericType(propertyType);
473-
lambdaBody = Expression.Lambda(Expression.Convert(lambdaBody, type), innerParameter);
479+
// Ensure the lambda body returns IEnumerable<T> for SelectMany
480+
var expectedType = typeof(IEnumerable<>).MakeGenericType(propertyType);
481+
if (lambdaBody.Type != expectedType && !expectedType.IsAssignableFrom(lambdaBody.Type))
482+
{
483+
// Convert to IEnumerable<T> if needed (e.g., List<T> to IEnumerable<T>)
484+
lambdaBody = Expression.Convert(lambdaBody, expectedType);
485+
}
486+
487+
// Create lambda with the correct return type
488+
var lambdaType = typeof(Func<,>).MakeGenericType(genericArgType, expectedType);
489+
lambdaBody = Expression.Lambda(lambdaType, lambdaBody, innerParameter);
474490

475491
return Expression.Call(selectMethod, member, lambdaBody);
476492
}
@@ -496,10 +512,13 @@ private static Parser<Expression> ComparisonExprParser<T>(ParameterExpression pa
496512
var innerGenericType = GetInnerGenericType(call.Method.ReturnType);
497513
var propertyInfoForMethod = GetPropertyInfo(innerGenericType, propName);
498514

499-
var linqMethod = IsEnumerable(innerGenericType) ? "SelectMany" : "Select";
515+
var propertyType = propertyInfoForMethod.PropertyType;
516+
var linqMethod = IsEnumerable(propertyType) ? "SelectMany" : "Select";
517+
var resultType = IsEnumerable(propertyType) ? propertyType.GetGenericArguments()[0] : propertyType;
518+
500519
var selectMethod = typeof(Enumerable).GetMethods()
501520
.First(m => m.Name == linqMethod && m.GetParameters().Length == 2)
502-
.MakeGenericMethod(innerGenericType, propertyInfoForMethod.PropertyType);
521+
.MakeGenericMethod(innerGenericType, resultType);
503522

504523
var innerParameter = Expression.Parameter(innerGenericType, "y");
505524
var lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod.Name);
@@ -601,6 +620,101 @@ private static Expression HandleGuidConversion(Expression expression, Type prope
601620

602621
return Expression.Call(selectMethod, expression, toStringLambda);
603622
}
623+
624+
private static bool IsNestedCollectionExpression(MethodCallExpression methodCall)
625+
{
626+
// Check if this is a nested SelectMany expression indicating nested collection navigation
627+
if (methodCall.Method.Name == "SelectMany" && methodCall.Arguments.Count == 2)
628+
{
629+
// Check if the source is also a SelectMany (indicating nesting)
630+
if (methodCall.Arguments[0] is MethodCallExpression sourceCall &&
631+
sourceCall.Method.Name == "SelectMany")
632+
{
633+
return true;
634+
}
635+
}
636+
return false;
637+
}
638+
639+
private static Expression CreateNestedCollectionFilterExpression<T>(MethodCallExpression methodCall, Expression rightExpr, ComparisonOperator op)
640+
{
641+
// For nested collection expressions like Ingredients.Preparations.Text
642+
// We need to unwind the SelectMany chain and create nested Any expressions
643+
// like: x.Ingredients.Any(i => i.Preparations.Any(p => p.Text == "value"))
644+
645+
var expressions = UnwindSelectManyChain(methodCall);
646+
if (expressions.Count < 2)
647+
{
648+
// Fallback to regular collection expression
649+
return op.GetExpression<T>(methodCall, rightExpr, null);
650+
}
651+
652+
// Build nested Any expressions from the inside out
653+
var currentExpression = expressions.Last();
654+
var currentParameter = Expression.Parameter(currentExpression.CollectionElementType, $"item{expressions.Count - 1}");
655+
var finalPropertyAccess = Expression.PropertyOrField(currentParameter, currentExpression.PropertyName);
656+
657+
// Create the innermost comparison
658+
var comparison = op.GetExpression<T>(finalPropertyAccess, rightExpr, null);
659+
var innerLambda = Expression.Lambda(comparison, currentParameter);
660+
661+
// Build the Any chain from inside out
662+
for (int i = expressions.Count - 2; i >= 0; i--)
663+
{
664+
var collectionInfo = expressions[i];
665+
var param = Expression.Parameter(collectionInfo.CollectionElementType, $"item{i}");
666+
var collectionAccess = Expression.PropertyOrField(param, collectionInfo.PropertyName);
667+
668+
// Create Any method call
669+
var anyMethod = typeof(Enumerable).GetMethods()
670+
.First(m => m.Name == "Any" && m.GetParameters().Length == 2)
671+
.MakeGenericMethod(collectionInfo.CollectionElementType);
672+
673+
var anyCall = Expression.Call(anyMethod, collectionAccess, innerLambda);
674+
innerLambda = Expression.Lambda(anyCall, param);
675+
}
676+
677+
return innerLambda.Body;
678+
}
679+
680+
private class CollectionInfo
681+
{
682+
public Type CollectionElementType { get; set; }
683+
public string PropertyName { get; set; }
684+
}
685+
686+
private static List<CollectionInfo> UnwindSelectManyChain(MethodCallExpression methodCall)
687+
{
688+
var result = new List<CollectionInfo>();
689+
var current = methodCall;
690+
691+
while (current != null && current.Method.Name == "SelectMany")
692+
{
693+
// Extract the property access from the lambda
694+
if (current.Arguments[1] is LambdaExpression lambda &&
695+
lambda.Body is MemberExpression member)
696+
{
697+
var elementType = current.Method.GetGenericArguments()[0];
698+
result.Insert(0, new CollectionInfo
699+
{
700+
CollectionElementType = elementType,
701+
PropertyName = member.Member.Name
702+
});
703+
}
704+
705+
// Move to the next level
706+
if (current.Arguments[0] is MethodCallExpression nextCall)
707+
{
708+
current = nextCall;
709+
}
710+
else
711+
{
712+
break;
713+
}
714+
}
715+
716+
return result;
717+
}
604718
}
605719

606720

0 commit comments

Comments
 (0)