Skip to content

Commit 512f148

Browse files
committed
feat: can handle first name last name derivation
1 parent 8543e88 commit 512f148

12 files changed

Lines changed: 370 additions & 41 deletions

File tree

QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
namespace QueryKit.IntegrationTests.Tests;
22

3-
using System.Linq.Expressions;
43
using Bogus;
54
using Configuration;
65
using FluentAssertions;
@@ -12,9 +11,9 @@ namespace QueryKit.IntegrationTests.Tests;
1211
using WebApiTestProject.Database;
1312
using WebApiTestProject.Entities;
1413
using WebApiTestProject.Entities.Recipes;
15-
using WebApiTestProject.Features;
14+
using Xunit.Abstractions;
1615

17-
public class DatabaseFilteringTests : TestBase
16+
public class DatabaseFilteringTests(ITestOutputHelper testOutputHelper) : TestBase
1817
{
1918
[Fact]
2019
public async Task can_filter_by_string()
@@ -40,6 +39,121 @@ public async Task can_filter_by_string()
4039
people[0].Id.Should().Be(fakePersonOne.Id);
4140
}
4241

42+
[Fact]
43+
public async Task can_filter_by_boolean()
44+
{
45+
// Arrange
46+
var testingServiceScope = new TestingServiceScope();
47+
var faker = new Faker();
48+
var fakePersonOne = new FakeTestingPersonBuilder()
49+
.WithTitle(faker.Lorem.Sentence())
50+
.WithFavorite(true)
51+
.Build();
52+
var fakePersonTwo = new FakeTestingPersonBuilder()
53+
.WithTitle(faker.Lorem.Sentence())
54+
.WithFavorite(false)
55+
.Build();
56+
await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo);
57+
58+
var input = $"""{nameof(TestingPerson.Title)} == "{fakePersonOne.Title}" && {nameof(TestingPerson.Favorite)} == true""";
59+
60+
// Act
61+
var queryablePeople = testingServiceScope.DbContext().People;
62+
var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input);
63+
var people = await appliedQueryable.ToListAsync();
64+
65+
// Assert
66+
people.Count.Should().Be(1);
67+
people[0].Id.Should().Be(fakePersonOne.Id);
68+
}
69+
70+
[Fact]
71+
public async Task can_filter_by_combo_multi_value_pass()
72+
{
73+
// Arrange
74+
var testingServiceScope = new TestingServiceScope();
75+
var fakePersonOne = new FakeTestingPersonBuilder().Build();
76+
var fakePersonTwo = new FakeTestingPersonBuilder()
77+
.WithFirstName(fakePersonOne.FirstName)
78+
.Build();
79+
await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo);
80+
81+
var input = $"""fullname @=* "{fakePersonOne.FirstName} {fakePersonOne.LastName}" """;
82+
var config = new QueryKitConfiguration(config =>
83+
{
84+
config.DerivedProperty<TestingPerson>(tp => tp.FirstName + " " + tp.LastName).HasQueryName("fullname");
85+
});
86+
87+
// Act
88+
var queryablePeople = testingServiceScope.DbContext().People;
89+
var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config);
90+
var people = await appliedQueryable.ToListAsync();
91+
92+
// Assert
93+
people.Count.Should().Be(1);
94+
people[0].Id.Should().Be(fakePersonOne.Id);
95+
}
96+
97+
[Fact]
98+
public async Task can_filter_by_combo_complex()
99+
{
100+
// Arrange
101+
var testingServiceScope = new TestingServiceScope();
102+
var fakePersonOne = new FakeTestingPersonBuilder()
103+
.WithAge(8888)
104+
.Build();
105+
var fakePersonTwo = new FakeTestingPersonBuilder()
106+
.WithFirstName(fakePersonOne.FirstName)
107+
.Build();
108+
await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo);
109+
110+
var input = $"""(fullname @=* "{fakePersonOne.FirstName} {fakePersonOne.LastName}") && age >= {fakePersonOne.Age}""";
111+
var config = new QueryKitConfiguration(config =>
112+
{
113+
config.DerivedProperty<TestingPerson>(tp => tp.FirstName + " " + tp.LastName).HasQueryName("fullname");
114+
});
115+
116+
// Act
117+
var queryablePeople = testingServiceScope.DbContext().People;
118+
var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config);
119+
var people = await appliedQueryable.ToListAsync();
120+
121+
// Assert
122+
people.Count.Should().Be(1);
123+
people[0].Id.Should().Be(fakePersonOne.Id);
124+
}
125+
126+
[Fact]
127+
public async Task can_filter_by_combo()
128+
{
129+
// Arrange
130+
var testingServiceScope = new TestingServiceScope();
131+
var fakePersonOne = new FakeTestingPersonBuilder()
132+
.WithFirstName(Guid.NewGuid().ToString())
133+
.Build();
134+
var fakePersonTwo = new FakeTestingPersonBuilder().Build();
135+
await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo);
136+
137+
var input = $"""fullname @=* "{fakePersonOne.FirstName}" """;
138+
var config = new QueryKitConfiguration(config =>
139+
{
140+
config.DerivedProperty<TestingPerson>(tp => tp.FirstName + " " + tp.LastName).HasQueryName("fullname");
141+
});
142+
143+
// Act
144+
var queryablePeople = testingServiceScope.DbContext().People;
145+
var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config);
146+
var people = await appliedQueryable.ToListAsync();
147+
// var people = testingServiceScope.DbContext().People
148+
// // .Where(p => (p.FirstName + " " + p.LastName).ToLower().Contains(fakePersonOne.FirstName.ToLower()))
149+
// // .Where(x => ((x.FirstName + " ") + x.LastName).ToLower().Contains("ito".ToLower()))
150+
// .ToList();
151+
152+
// Assert
153+
people.Count.Should().Be(1);
154+
people[0].Id.Should().Be(fakePersonOne.Id);
155+
}
156+
43157
[Fact]
44158
public async Task can_filter_by_string_for_collection()
45159
{

QueryKit.WebApiTestProject/Entities/TestingPerson.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ namespace QueryKit.WebApiTestProject.Entities;
22

33
public class TestingPerson
44
{
5-
public string? Title { get; set; }
5+
public string? Title { get; set; } = default!;
6+
public string? FirstName { get; set; } = default!;
7+
public string? LastName { get; set; } = default!;
68
public int? Age { get; set; }
79
public BirthMonthEnum? BirthMonth { get; set; }
810
public decimal? Rating { get; set; }

QueryKit.WebApiTestProject/Migrations/20240416001404_BaseTestingMigration.Designer.cs renamed to QueryKit.WebApiTestProject/Migrations/20240501011638_BaseTestingMigration.Designer.cs

Lines changed: 9 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

QueryKit.WebApiTestProject/Migrations/20240416001404_BaseTestingMigration.cs renamed to QueryKit.WebApiTestProject/Migrations/20240501011638_BaseTestingMigration.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ protected override void Up(MigrationBuilder migrationBuilder)
2121
{
2222
id = table.Column<Guid>(type: "uuid", nullable: false),
2323
title = table.Column<string>(type: "text", nullable: true),
24+
first_name = table.Column<string>(type: "text", nullable: true),
25+
last_name = table.Column<string>(type: "text", nullable: true),
2426
age = table.Column<int>(type: "integer", nullable: true),
2527
birth_month = table.Column<int>(type: "integer", nullable: true),
2628
rating = table.Column<decimal>(type: "numeric", nullable: true),

QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,14 @@ protected override void BuildModel(ModelBuilder modelBuilder)
203203
.HasColumnType("boolean")
204204
.HasColumnName("favorite");
205205

206+
b.Property<string>("FirstName")
207+
.HasColumnType("text")
208+
.HasColumnName("first_name");
209+
210+
b.Property<string>("LastName")
211+
.HasColumnType("text")
212+
.HasColumnName("last_name");
213+
206214
b.Property<decimal?>("Rating")
207215
.HasColumnType("numeric")
208216
.HasColumnName("rating");

QueryKit/Configuration/QueryKitSettings.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,9 @@ public QueryKitPropertyMapping<TModel> Property<TModel>(Expression<Func<TModel,
4040
{
4141
return PropertyMappings.Property(propertySelector);
4242
}
43+
44+
public QueryKitPropertyMapping<TModel> DerivedProperty<TModel>(Expression<Func<TModel, object>>? propertySelector)
45+
{
46+
return PropertyMappings.DerivedProperty(propertySelector);
47+
}
4348
}

QueryKit/FilterParser.cs

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-

2-
namespace QueryKit;
1+
namespace QueryKit;
32

4-
using System.Collections;
53
using System.Globalization;
64
using System.Linq.Expressions;
75
using System.Reflection;
@@ -26,20 +24,34 @@ public static Expression<Func<T, bool>> ParseFilter<T>(string input, IQueryKitCo
2624
input = config?.PropertyMappings?.ReplaceAliasesWithPropertyPaths(input) ?? input;
2725

2826
var parameter = Expression.Parameter(typeof(T), "x");
29-
Expression expr;
27+
Expression expr;
3028
try
3129
{
3230
expr = ExprParser<T>(parameter, config).End().Parse(input);
31+
expr = ReplaceDerivedProperties(expr, config, parameter);
3332
}
34-
catch (InvalidOperationException e) {
33+
catch (InvalidOperationException e)
34+
{
3535
throw new ParsingException(e);
3636
}
3737
catch (ParseException e)
3838
{
3939
throw new ParsingException(e);
4040
}
41+
4142
return Expression.Lambda<Func<T, bool>>(expr, parameter);
4243
}
44+
45+
private static Expression ReplaceDerivedProperties(Expression expr, IQueryKitConfiguration? config, ParameterExpression parameter)
46+
{
47+
if (config?.PropertyMappings == null)
48+
{
49+
return expr;
50+
}
51+
52+
return new ParameterReplacer(parameter).Visit(expr);
53+
}
54+
4355

4456
private static readonly Parser<string> Identifier =
4557
from first in Parse.Letter.Once()
@@ -358,6 +370,20 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ
358370
var nullableCtor = rawType.GetConstructor(new[] {enumType});
359371
return Expression.New(nullableCtor, constant);
360372
}
373+
374+
// for some complex derived expressions
375+
if (targetType == typeof(object))
376+
{
377+
if (right == "null")
378+
{
379+
return Expression.Constant(null, typeof(object));
380+
}
381+
382+
if (bool.TryParse(right, out var boolVal))
383+
{
384+
return Expression.Constant(boolVal, typeof(bool));
385+
}
386+
}
361387

362388
throw new InvalidOperationException($"Unsupported value '{right}' for type '{targetType.Name}'");
363389
}
@@ -499,10 +525,17 @@ private static Parser<Expression> ComparisonExprParser<T>(ParameterExpression pa
499525
}
500526
catch(ArgumentException)
501527
{
528+
var derivedPropertyInfo = config?.PropertyMappings?.GetDerivedPropertyInfoByQueryName(fullPropPath);
529+
if (derivedPropertyInfo?.DerivedExpression != null)
530+
{
531+
return derivedPropertyInfo.DerivedExpression;
532+
}
533+
502534
if(config?.AllowUnknownProperties == true)
503535
{
504536
return Expression.Constant(true, typeof(bool));
505537
}
538+
506539
throw new UnknownFilterPropertyException(actualPropertyName);
507540
}
508541
});
@@ -516,7 +549,7 @@ private static Parser<Expression> ComparisonExprParser<T>(ParameterExpression pa
516549
return propertyExpression;
517550
});
518551
}
519-
552+
520553
private static Type? GetInnerGenericType(Type type)
521554
{
522555
if (!IsEnumerable(type))
@@ -549,3 +582,5 @@ private static Parser<Expression> OrExprParser<T>(ParameterExpression parameter,
549582
(op, left, right) => op.GetExpression<T>(left, right)
550583
);
551584
}
585+
586+

QueryKit/Operators/ComparisonOperator.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,12 @@ public override Expression GetExpression<T>(Expression left, Expression right, T
205205
Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes))
206206
);
207207
}
208+
209+
// for some complex derived expressions
210+
if (left.NodeType == ExpressionType.Convert)
211+
{
212+
left = Expression.Convert(left, typeof(bool));
213+
}
208214

209215
return Expression.Equal(left, right);
210216
}
@@ -231,6 +237,12 @@ public override Expression GetExpression<T>(Expression left, Expression right, T
231237
Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes))
232238
);
233239
}
240+
241+
// for some complex derived expressions
242+
if (left.NodeType == ExpressionType.Convert)
243+
{
244+
left = Expression.Convert(left, typeof(bool));
245+
}
234246

235247
return Expression.NotEqual(left, right);
236248
}

QueryKit/ParameterReplacer.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace QueryKit;
2+
3+
using System.Linq.Expressions;
4+
5+
internal class ParameterReplacer : ExpressionVisitor
6+
{
7+
private readonly ParameterExpression _newParameter;
8+
9+
public ParameterReplacer(ParameterExpression newParameter)
10+
{
11+
_newParameter = newParameter;
12+
}
13+
14+
protected override Expression VisitParameter(ParameterExpression node)
15+
{
16+
// Replace all parameters with the new parameter
17+
return _newParameter;
18+
}
19+
}

0 commit comments

Comments
 (0)