Skip to content

Commit 5c969a6

Browse files
committed
docs: clarity on conversion
1 parent 1a3af70 commit 5c969a6

File tree

2 files changed

+181
-7
lines changed

2 files changed

+181
-7
lines changed

QueryKit.IntegrationTests/Tests/GuidFilterBugTests.cs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,169 @@ public async Task can_filter_by_guid_id_with_custom_query_name_and_additional_fi
107107
people[0].FirstName.Should().Be("Target");
108108
people[0].LastName.Should().Be("Person");
109109
}
110+
111+
[Fact]
112+
public async Task guid_filter_with_inequality_and_complex_conditions_reproduces_ef_core_issue()
113+
{
114+
// This test specifically reproduces the scenario from the user's bug report:
115+
// status != "Finalized" && id == "guid-value"
116+
// Without the Trim('"') fix in FilterParser.cs:450, this could fail if GUIDs
117+
// with quotes somehow bypass the Sprache parser's quote stripping.
118+
119+
// Arrange
120+
var testingServiceScope = new TestingServiceScope();
121+
var faker = new Faker();
122+
123+
var targetId = Guid.Parse("ef123456-bcda-4321-9876-fedcba987654");
124+
var targetTitle = "NotFinalizedTitle";
125+
var targetPerson = new FakeTestingPersonBuilder()
126+
.WithId(targetId)
127+
.WithTitle(targetTitle)
128+
.WithFirstName("Target")
129+
.WithAge(30)
130+
.Build();
131+
132+
var finalizedPerson = new FakeTestingPersonBuilder()
133+
.WithTitle("Finalized")
134+
.WithFirstName("Finalized")
135+
.WithAge(40)
136+
.Build();
137+
138+
var otherPersonSameTitle = new FakeTestingPersonBuilder()
139+
.WithTitle(targetTitle)
140+
.WithFirstName("Other")
141+
.WithAge(50)
142+
.Build();
143+
144+
await testingServiceScope.InsertAsync(targetPerson, finalizedPerson, otherPersonSameTitle);
145+
146+
// Configure QueryKit similar to user's scenario with custom query names
147+
var config = new QueryKitConfiguration(config =>
148+
{
149+
config.Property<TestingPerson>(x => x.Title)
150+
.HasQueryName("status");
151+
config.Property<TestingPerson>(x => x.Id)
152+
.HasQueryName("id");
153+
});
154+
155+
// Filter mimicking user's exact scenario: status != "Finalized" && id == "{guid}"
156+
var input = $"""status != "Finalized" && id == "{targetId}" """;
157+
158+
// Act
159+
var queryablePeople = testingServiceScope.DbContext().People;
160+
var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config);
161+
162+
// Without the defensive Trim('"') in CreateRightExprFromType, this could throw:
163+
// System.InvalidOperationException: The LINQ expression could not be translated
164+
var people = await appliedQueryable.ToListAsync();
165+
166+
// Assert
167+
people.Count.Should().Be(1, "Should only return the target person (not finalized and matching ID)");
168+
people[0].Id.Should().Be(targetId);
169+
people[0].Title.Should().Be(targetTitle);
170+
people[0].FirstName.Should().Be("Target");
171+
}
172+
173+
[Fact]
174+
public async Task multiple_guid_filters_with_or_conditions_work_correctly()
175+
{
176+
// Test more complex GUID filtering scenarios that stress-test the parser
177+
178+
// Arrange
179+
var testingServiceScope = new TestingServiceScope();
180+
181+
var id1 = Guid.Parse("11111111-1111-1111-1111-111111111111");
182+
var id2 = Guid.Parse("22222222-2222-2222-2222-222222222222");
183+
var id3 = Guid.Parse("33333333-3333-3333-3333-333333333333");
184+
185+
var person1 = new FakeTestingPersonBuilder()
186+
.WithId(id1)
187+
.WithFirstName("Person1")
188+
.Build();
189+
190+
var person2 = new FakeTestingPersonBuilder()
191+
.WithId(id2)
192+
.WithFirstName("Person2")
193+
.Build();
194+
195+
var person3 = new FakeTestingPersonBuilder()
196+
.WithId(id3)
197+
.WithFirstName("Person3")
198+
.Build();
199+
200+
await testingServiceScope.InsertAsync(person1, person2, person3);
201+
202+
var config = new QueryKitConfiguration(config =>
203+
{
204+
config.Property<TestingPerson>(x => x.Id)
205+
.HasQueryName("id");
206+
});
207+
208+
// Complex filter with multiple GUID conditions
209+
var input = $"""(id == "{id1}" || id == "{id2}") && FirstName != "Person3" """;
210+
211+
// Act
212+
var queryablePeople = testingServiceScope.DbContext().People;
213+
var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config);
214+
var people = await appliedQueryable.ToListAsync();
215+
216+
// Assert
217+
people.Count.Should().Be(2);
218+
people.Should().Contain(p => p.Id == id1);
219+
people.Should().Contain(p => p.Id == id2);
220+
people.Should().NotContain(p => p.Id == id3);
221+
}
222+
223+
[Fact]
224+
public async Task can_filter_by_email_and_guid_id_with_custom_query_names()
225+
{
226+
// Arrange
227+
var testingServiceScope = new TestingServiceScope();
228+
var faker = new Faker();
229+
230+
var targetId = Guid.Parse("de9ffc39-cdec-6752-bf0f-df3509f2d8df");
231+
var targetEmail = faker.Internet.Email();
232+
var targetPerson = new FakeTestingPersonBuilder()
233+
.WithId(targetId)
234+
.WithEmail(targetEmail)
235+
.WithFirstName("TargetMatch")
236+
.Build();
237+
238+
var otherEmail = faker.Internet.Email();
239+
var otherPerson = new FakeTestingPersonBuilder()
240+
.WithEmail(otherEmail)
241+
.WithFirstName("Other")
242+
.Build();
243+
244+
var anotherPerson = new FakeTestingPersonBuilder()
245+
.WithEmail(faker.Internet.Email())
246+
.WithFirstName("Another")
247+
.Build();
248+
249+
await testingServiceScope.InsertAsync(targetPerson, otherPerson, anotherPerson);
250+
251+
// Configure QueryKit with custom query name for email and id
252+
var config = new QueryKitConfiguration(config =>
253+
{
254+
config.Property<TestingPerson>(x => x.Email)
255+
.HasQueryName("email")
256+
.HasConversion<string>();
257+
config.Property<TestingPerson>(x => x.Id)
258+
.HasQueryName("id");
259+
});
260+
261+
// Filter using both email (custom name) and id
262+
var input = $"""email == "{targetEmail}" && id == "{targetId}" """;
263+
264+
// Act
265+
var queryablePeople = testingServiceScope.DbContext().People;
266+
var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config);
267+
var people = await appliedQueryable.ToListAsync();
268+
269+
// Assert
270+
people.Count.Should().Be(1, "Should only return person matching both email and ID");
271+
people[0].Id.Should().Be(targetId);
272+
people[0].Email.Value.Should().Be(targetEmail);
273+
people[0].FirstName.Should().Be("TargetMatch");
274+
}
110275
}

README.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -581,26 +581,29 @@ var config = new QueryKitConfiguration(config =>
581581
});
582582
```
583583

584-
Note, with EF core, your config might look like this:
584+
Note, with EF core, your QueryKit configuration depends on how you've configured the property:
585585

586586
```c#
587587
public sealed class PersonConfiguration : IEntityTypeConfiguration<SpecialPerson>
588588
{
589589
public void Configure(EntityTypeBuilder<SpecialPerson> builder)
590590
{
591591
builder.HasKey(x => x.Id);
592-
592+
593593
// Option 1 (as of .NET 8) - ComplexProperty
594+
// QueryKit: config.Property<SpecialPerson>(x => x.Email.Value).HasQueryName("email");
594595
builder.ComplexProperty(x => x.Email,
595596
x => x.Property(y => y.Value)
596-
.HasColumnName("email"));
597-
597+
.HasColumnName("email"));
598+
598599
// Option 2 - HasConversion (see HasConversion support below)
600+
// QueryKit: config.Property<SpecialPerson>(x => x.Email).HasQueryName("email").HasConversion<string>();
599601
builder.Property(x => x.Email)
600602
.HasConversion(x => x.Value, x => new EmailAddress(x))
601-
.HasColumnName("email");
602-
603+
.HasColumnName("email");
604+
603605
// Option 3 - OwnsOne
606+
// QueryKit: config.Property<SpecialPerson>(x => x.Email.Value).HasQueryName("email");
604607
builder.OwnsOne(x => x.Email, opts =>
605608
{
606609
opts.Property(x => x.Value).HasColumnName("email");
@@ -610,6 +613,10 @@ public sealed class PersonConfiguration : IEntityTypeConfiguration<SpecialPerson
610613
}
611614
```
612615

616+
**Key Distinction:**
617+
- **HasConversion**: Use `x => x.Email` in QueryKit (point to parent property)
618+
- **ComplexProperty/OwnsOne**: Use `x => x.Email.Value` in QueryKit (point to nested property)
619+
613620
### HasConversion Support
614621

615622
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:
@@ -623,7 +630,7 @@ builder.Property(x => x.Email)
623630
// QueryKit configuration for HasConversion properties
624631
var config = new QueryKitConfiguration(config =>
625632
{
626-
config.Property<SpecialPerson>(x => x.Email)
633+
config.Property<SpecialPerson>(x => x.Email) // Point to Email property, NOT Email.Value
627634
.HasQueryName("email")
628635
.HasConversion<string>(); // Specify the target type used in HasConversion
629636
});
@@ -637,6 +644,8 @@ var people = _dbContext.People
637644

638645
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.
639646

647+
> **Important:** When using `HasConversion` in EF Core, you MUST configure the property in QueryKit using `x => x.Email`, not `x => x.Email.Value`. The conversion is on the parent property, so pointing to the nested `.Value` property will cause EF Core translation errors. Use `x => x.Email.Value` only when using `ComplexProperty` or `OwnsOne` without HasConversion.
648+
640649
## Sorting
641650

642651
Sorting is a more simplistic flow. It's just an input with a comma delimited list of properties to sort by.

0 commit comments

Comments
 (0)