diff --git a/PTrampert.QueryObjects.Test/Attributes/InlineValueTests.cs b/PTrampert.QueryObjects.Test/Attributes/InlineValueTests.cs new file mode 100644 index 0000000..f8fe9c2 --- /dev/null +++ b/PTrampert.QueryObjects.Test/Attributes/InlineValueTests.cs @@ -0,0 +1,134 @@ +using PTrampert.QueryObjects.Attributes; +using PTrampert.QueryObjects.Internals; + +namespace PTrampert.QueryObjects.Test.Attributes; + +public class InlineValueTests +{ + private class InlineTestTarget + { + public Guid Id { get; init; } + public string Name { get; init; } = string.Empty; + public List Tags { get; init; } = new(); + } + + private class ParameterizedQuery + { + [EqualsQuery(nameof(InlineTestTarget.Id))] + public Guid? Id { get; set; } + + [StringContainsQuery(nameof(InlineTestTarget.Name))] + public string? NameContains { get; set; } + + [StringStartsWithQuery(nameof(InlineTestTarget.Name))] + public string? NameStartsWith { get; set; } + + [ContainsQuery(nameof(InlineTestTarget.Tags))] + public string? Tag { get; set; } + } + + private class AnyOfParameterizedQuery + { + [AnyOfQuery(nameof(InlineTestTarget.Name))] + public IEnumerable? Names { get; set; } + } + + private class AnyOfCollectionParameterizedQuery + { + [AnyOfQuery(nameof(InlineTestTarget.Tags))] + public IEnumerable? Tags { get; set; } + } + + private class InlinedQuery + { + [EqualsQuery(nameof(InlineTestTarget.Id), InlineValue = true)] + public Guid? Id { get; set; } + + [StringContainsQuery(nameof(InlineTestTarget.Name), InlineValue = true)] + public string? NameContains { get; set; } + } + + private static string BuildExpression(object query) => + new QueryExpressionBuilder().BuildQueryExpression(query).ToString(); + + [Test] + public void ByDefault_QueryValuesAreReferencedThroughTheQueryObject() + { + var query = new ParameterizedQuery + { + Id = Guid.NewGuid(), + NameContains = "app", + NameStartsWith = "a", + Tag = "fruit" + }; + + var expression = BuildExpression(query); + + const string q = "value(PTrampert.QueryObjects.Test.Attributes.InlineValueTests+ParameterizedQuery)"; + Assert.Multiple(() => + { + // The nullable query property is converted to the target property's type. + Assert.That(expression, Does.Contain($"Convert({q}.Id, Guid)")); + Assert.That(expression, Does.Contain($"Contains({q}.NameContains)")); + Assert.That(expression, Does.Contain($"StartsWith({q}.NameStartsWith)")); + Assert.That(expression, Does.Contain($"Param_0.Tags.Contains({q}.Tag)")); + Assert.That(expression, Does.Not.Contain("\"app\"")); + Assert.That(expression, Does.Not.Contain(query.Id.ToString())); + }); + } + + [Test] + public void ByDefault_AnyOfReferencesTheCollectionThroughTheQueryObject() + { + var query = new AnyOfParameterizedQuery { Names = ["apple", "banana"] }; + + var expression = BuildExpression(query); + + Assert.That(expression, Does.Contain( + "value(PTrampert.QueryObjects.Test.Attributes.InlineValueTests+AnyOfParameterizedQuery).Names.Contains(Param_0.Name)")); + } + + [Test] + public void ByDefault_AnyOfAgainstACollectionTargetReferencesTheCollectionThroughTheQueryObject() + { + var query = new AnyOfCollectionParameterizedQuery { Tags = ["fruit", "yellow"] }; + + var expression = BuildExpression(query); + + Assert.That(expression, Does.Contain( + "Param_0.Tags.Intersect(value(PTrampert.QueryObjects.Test.Attributes.InlineValueTests+AnyOfCollectionParameterizedQuery).Tags).Any()")); + } + + [Test] + public void WithInlineValue_QueryValuesAreEmbeddedAsConstants() + { + var id = Guid.NewGuid(); + var query = new InlinedQuery { Id = id, NameContains = "app" }; + + var expression = BuildExpression(query); + + Assert.Multiple(() => + { + Assert.That(expression, Does.Contain($"Param_0.Id == {id}")); + Assert.That(expression, Does.Contain("Contains(\"app\")")); + Assert.That(expression, Does.Not.Contain("value(PTrampert.QueryObjects.Test.Attributes.InlineValueTests+InlinedQuery)")); + }); + } + + [Test] + public void BothValueStrategies_ProduceTheSameResults() + { + var id = Guid.NewGuid(); + var data = new List + { + new() { Id = id, Name = "apple", Tags = ["fruit"] }, + new() { Id = Guid.NewGuid(), Name = "banana", Tags = ["fruit"] } + }; + + var parameterized = data.Where(new ParameterizedQuery { Id = id, NameContains = "app" }).ToList(); + var inlined = data.Where(new InlinedQuery { Id = id, NameContains = "app" }).ToList(); + + Assert.That(parameterized, Is.EqualTo(new[] { data[0] })); + Assert.That(inlined, Is.EqualTo(parameterized)); + } +} diff --git a/PTrampert.QueryObjects.Test/QueryExpressionBuilderTests.cs b/PTrampert.QueryObjects.Test/QueryExpressionBuilderTests.cs index e482d8c..7cb15ce 100644 --- a/PTrampert.QueryObjects.Test/QueryExpressionBuilderTests.cs +++ b/PTrampert.QueryObjects.Test/QueryExpressionBuilderTests.cs @@ -16,8 +16,15 @@ public void BuildQueryExpression_WithSimpleQueryObject_ReturnsCorrectExpression( }; var expression = new QueryExpressionBuilder().BuildQueryExpression(query); - - Assert.That(expression.ToString(), Is.EqualTo("Param_0 => ((((Param_0.IntProperty == 1) AndAlso (Param_0.AnotherProp > 1)) AndAlso (Param_0.AnotherProp < 4)) AndAlso (Param_0.StringProperty != \"Derp\"))")); + + // Query values are referenced as member accesses on the query object rather than inlined as + // constants, so that ORMs lift them into query parameters. + var q = query.ToString(); + Assert.That(expression.ToString(), Is.EqualTo( + $"Param_0 => ((((Param_0.IntProperty == {q}.IntProperty) " + + $"AndAlso (Param_0.AnotherProp > {q}.AnotherPropLowerLimit)) " + + $"AndAlso (Param_0.AnotherProp < {q}.AnotherPropUpperLimit)) " + + $"AndAlso (Param_0.StringProperty != {q}.StringProperty))")); } [Test] @@ -29,7 +36,9 @@ public void BuildQueryExpression_WithAdvancedQueryObject_ReturnsCorrectExpressio }; var expression = new QueryExpressionBuilder().BuildQueryExpression(query); - - Assert.That(expression.ToString(), Is.EqualTo("Param_0 => ((Param_0.IntProperty == 1) AndAlso Param_0.StringProperty.Contains(\"Derp\"))")); + + var q = query.ToString(); + Assert.That(expression.ToString(), Is.EqualTo( + $"Param_0 => ((Param_0.IntProperty == {q}.IntProperty) AndAlso Param_0.StringProperty.Contains(\"Derp\"))")); } -} \ No newline at end of file +} diff --git a/PTrampert.QueryObjects/Attributes/AnyOfQueryAttribute.cs b/PTrampert.QueryObjects/Attributes/AnyOfQueryAttribute.cs index a1c06cc..bc4e8d6 100644 --- a/PTrampert.QueryObjects/Attributes/AnyOfQueryAttribute.cs +++ b/PTrampert.QueryObjects/Attributes/AnyOfQueryAttribute.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Collections.Generic; using System.Linq.Expressions; using System.Reflection; using PTrampert.QueryObjects.Internals; @@ -36,16 +37,19 @@ PropertyInfo targetProperty && typeof(IEnumerable).IsAssignableFrom(targetElementType)) { targetElementType = targetElementType.GetCollectionElementType(); - return BuildCollectionExpression(queryValue, targetParameter, targetProperty, targetElementType); + return BuildCollectionExpression(queryObject, queryProperty, queryValue, targetParameter, targetProperty, targetElementType); } var containsMethod = targetElementType.GetContainsMethod(); - var constant = Expression.Constant(queryValue); + var value = BuildValueExpression(queryObject, queryProperty, queryValue, + typeof(IEnumerable<>).MakeGenericType(targetElementType)); var propertyAccess = Expression.Property(targetParameter, targetProperty); - return Expression.Call(containsMethod, constant, propertyAccess); + return Expression.Call(containsMethod, value, propertyAccess); } private Expression BuildCollectionExpression( + object queryObject, + PropertyInfo queryProperty, IEnumerable queryValue, ParameterExpression targetParameter, PropertyInfo targetProperty, @@ -54,9 +58,10 @@ Type targetElementType { var intersectMethod = targetElementType.GetIntersectMethod(); var anyMethod = targetElementType.GetAnyMethod(); - var constant = Expression.Constant(queryValue); + var value = BuildValueExpression(queryObject, queryProperty, queryValue, + typeof(IEnumerable<>).MakeGenericType(targetElementType)); var propertyAccess = Expression.Property(targetParameter, targetProperty); - var intersectCall = Expression.Call(intersectMethod, propertyAccess, constant); + var intersectCall = Expression.Call(intersectMethod, propertyAccess, value); var anyCall = Expression.Call(anyMethod, intersectCall); return anyCall; } diff --git a/PTrampert.QueryObjects/Attributes/ContainsQueryAttribute.cs b/PTrampert.QueryObjects/Attributes/ContainsQueryAttribute.cs index 16bd8bd..55b6b80 100644 --- a/PTrampert.QueryObjects/Attributes/ContainsQueryAttribute.cs +++ b/PTrampert.QueryObjects/Attributes/ContainsQueryAttribute.cs @@ -44,9 +44,9 @@ public override Expression BuildExpression(object queryObject, PropertyInfo quer } var elementType = targetProperty.PropertyType.GetCollectionElementType(); - var constant = Expression.Constant(queryValue); + var value = BuildValueExpression(queryObject, queryProperty, queryValue, elementType); var containsMethod = elementType.GetContainsMethod(); - return Expression.Call(containsMethod, Expression.Property(targetParameter, targetProperty), constant); + return Expression.Call(containsMethod, Expression.Property(targetParameter, targetProperty), value); } } } \ No newline at end of file diff --git a/PTrampert.QueryObjects/Attributes/QueryAttribute.cs b/PTrampert.QueryObjects/Attributes/QueryAttribute.cs index 455802a..29d2401 100644 --- a/PTrampert.QueryObjects/Attributes/QueryAttribute.cs +++ b/PTrampert.QueryObjects/Attributes/QueryAttribute.cs @@ -27,6 +27,54 @@ protected QueryAttribute(string targetProperty = null) TargetProperty = targetProperty; } + /// + /// Embed the query value into the expression tree as a literal constant instead of referencing it + /// through the query object. Defaults to false. + /// + /// + /// By default the query value is referenced as a member access on the query object, which is the same + /// shape the C# compiler emits for a captured variable. ORMs such as Entity Framework Core lift that + /// shape into a SQL parameter, so the generated SQL is identical no matter what value is supplied, which + /// keeps both the ORM's compiled query cache and the database's plan cache effective. + /// + /// A literal constant is inlined into the generated SQL, producing different SQL for every value. That is + /// occasionally what you want -- for example when a predicate runs over badly skewed data and you would + /// rather the database build a plan for the specific value than reuse a plan built for a previous one. + /// + public bool InlineValue { get; set; } + + /// + /// Builds the expression that supplies the query value to a comparison, honoring . + /// + /// The instance of the query object. + /// The PropertyInfo of the property holding the query value. + /// + /// The value of on . Only used when + /// is true. + /// + /// + /// The type the resulting expression must have, or null to use the declared type of the query property. + /// + /// The expression supplying the query value. + protected Expression BuildValueExpression(object queryObject, PropertyInfo queryProperty, object queryValue, + Type valueType = null) + { + if (InlineValue) + { + return valueType == null + ? Expression.Constant(queryValue) + : Expression.Constant(queryValue, valueType); + } + + Expression value = Expression.Property(Expression.Constant(queryObject), queryProperty); + if (valueType != null && !valueType.IsAssignableFrom(value.Type)) + { + value = Expression.Convert(value, valueType); + } + + return value; + } + /// /// Builds the query expression for this query property. /// diff --git a/PTrampert.QueryObjects/Attributes/SimpleComparisonQueryAttribute.cs b/PTrampert.QueryObjects/Attributes/SimpleComparisonQueryAttribute.cs index 986e68f..ced28f5 100644 --- a/PTrampert.QueryObjects/Attributes/SimpleComparisonQueryAttribute.cs +++ b/PTrampert.QueryObjects/Attributes/SimpleComparisonQueryAttribute.cs @@ -38,8 +38,8 @@ public override Expression BuildExpression(object queryObject, PropertyInfo quer var queryValue = queryProperty.GetValue(queryObject); if (IgnoreIfNull && queryValue == null) return null; - var constant = Expression.Constant(queryValue, targetProperty.PropertyType); - return ComparisonExpressionBuilder(Expression.Property(targetParameter, targetProperty), constant); + var value = BuildValueExpression(queryObject, queryProperty, queryValue, targetProperty.PropertyType); + return ComparisonExpressionBuilder(Expression.Property(targetParameter, targetProperty), value); } } } \ No newline at end of file diff --git a/PTrampert.QueryObjects/Attributes/StringContainsQueryAttribute.cs b/PTrampert.QueryObjects/Attributes/StringContainsQueryAttribute.cs index e7aa1f2..88f4a73 100644 --- a/PTrampert.QueryObjects/Attributes/StringContainsQueryAttribute.cs +++ b/PTrampert.QueryObjects/Attributes/StringContainsQueryAttribute.cs @@ -34,9 +34,9 @@ public override Expression BuildExpression(object queryObject, PropertyInfo quer var queryValue = queryProperty.GetValue(queryObject); if (queryValue == null) return IgnoreIfNull ? null : Expression.Constant(false); - var constant = Expression.Constant(queryValue); + var value = BuildValueExpression(queryObject, queryProperty, queryValue, typeof(string)); var containsMethod = typeof(string).GetMethod(nameof(string.Contains), [typeof(string)])!; - return Expression.Call(Expression.Property(targetParameter, targetProperty), containsMethod, constant); + return Expression.Call(Expression.Property(targetParameter, targetProperty), containsMethod, value); } } } \ No newline at end of file diff --git a/PTrampert.QueryObjects/Attributes/StringStartsWithQueryAttribute.cs b/PTrampert.QueryObjects/Attributes/StringStartsWithQueryAttribute.cs index 04ff374..db3912d 100644 --- a/PTrampert.QueryObjects/Attributes/StringStartsWithQueryAttribute.cs +++ b/PTrampert.QueryObjects/Attributes/StringStartsWithQueryAttribute.cs @@ -39,8 +39,8 @@ PropertyInfo targetProperty if (queryValue == null) return IgnoreIfNull ? null : Expression.Constant(false); - var constant = Expression.Constant(queryValue); + var value = BuildValueExpression(queryObject, queryProperty, queryValue, typeof(string)); var startsWithMethod = typeof(string).GetMethod(nameof(string.StartsWith), [typeof(string)])!; - return Expression.Call(Expression.Property(targetParameter, targetProperty), startsWithMethod, constant); + return Expression.Call(Expression.Property(targetParameter, targetProperty), startsWithMethod, value); } } \ No newline at end of file diff --git a/README.md b/README.md index ced1f12..354e093 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ PTrampert.QueryObjects is a .NET library that enables you to define query object - Attribute-based query object filtering - Supports custom query logic via `IQueryObject` - Works with any `IQueryable` (e.g. Entity Framework, MongoDB) or `IEnumerable` +- Emits query values as parameterizable member accesses rather than inline constants, so ORM and database query + caches stay effective (opt out per property with `InlineValue = true`) ## Example Usage diff --git a/docs/getting-started.md b/docs/getting-started.md index 980b3ec..e290514 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -50,6 +50,35 @@ var filteredUsers = dbContext.Users.Where(query); ``` +## Query Values and Parameterization + +Query values are not baked into the generated expression tree as literal constants. Instead, each attribute +emits a member access on the query object itself -- the same shape the C# compiler produces for a captured +variable: + +```csharp +// x => x.Name == queryObject.Name +``` + +ORMs such as Entity Framework Core lift that shape into a SQL parameter, so the SQL produced for a given query +object type is identical regardless of the values supplied. That keeps EF Core's compiled query cache and the +database's query plan cache effective. A literal constant, by contrast, is inlined into the SQL, producing +different SQL text for every value. + +If you need the value inlined instead -- for example when a predicate runs over badly skewed data and you would +rather the database build a plan for the specific value than reuse one built for a previous value -- set +`InlineValue` on the attribute: + +```csharp +public class UserQuery +{ + [EqualsQuery(InlineValue = true)] + public bool? IsDeleted { get; set; } +} +``` + +`InlineValue` is available on every query attribute and defaults to `false`. + ## Supported Query Attributes For a complete and up-to-date list of supported query attributes, please refer to the [API documentation for PTrampert.QueryObjects.Attributes](../api/PTrampert.QueryObjects.Attributes.yml).