Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions PTrampert.QueryObjects.Test/Attributes/InlineValueTests.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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<string>? Names { get; set; }
}

private class AnyOfCollectionParameterizedQuery
{
[AnyOfQuery(nameof(InlineTestTarget.Tags))]
public IEnumerable<string>? 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<InlineTestTarget>().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<InlineTestTarget>
{
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));
}
}
19 changes: 14 additions & 5 deletions PTrampert.QueryObjects.Test/QueryExpressionBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,15 @@ public void BuildQueryExpression_WithSimpleQueryObject_ReturnsCorrectExpression(
};

var expression = new QueryExpressionBuilder<TestTarget>().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]
Expand All @@ -29,7 +36,9 @@ public void BuildQueryExpression_WithAdvancedQueryObject_ReturnsCorrectExpressio
};

var expression = new QueryExpressionBuilder<TestTarget>().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\"))"));
}
}
}
15 changes: 10 additions & 5 deletions PTrampert.QueryObjects/Attributes/AnyOfQueryAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Reflection;
using PTrampert.QueryObjects.Internals;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions PTrampert.QueryObjects/Attributes/ContainsQueryAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
48 changes: 48 additions & 0 deletions PTrampert.QueryObjects/Attributes/QueryAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,54 @@ protected QueryAttribute(string targetProperty = null)
TargetProperty = targetProperty;
}

/// <summary>
/// Embed the query value into the expression tree as a literal constant instead of referencing it
/// through the query object. Defaults to false.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public bool InlineValue { get; set; }

/// <summary>
/// Builds the expression that supplies the query value to a comparison, honoring <see cref="InlineValue"/>.
/// </summary>
/// <param name="queryObject">The instance of the query object.</param>
/// <param name="queryProperty">The PropertyInfo of the property holding the query value.</param>
/// <param name="queryValue">
/// The value of <paramref name="queryProperty"/> on <paramref name="queryObject"/>. Only used when
/// <see cref="InlineValue"/> is true.
/// </param>
/// <param name="valueType">
/// The type the resulting expression must have, or null to use the declared type of the query property.
/// </param>
/// <returns>The expression supplying the query value.</returns>
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;
}

/// <summary>
/// Builds the query expression for this query property.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`
- Works with any `IQueryable<T>` (e.g. Entity Framework, MongoDB) or `IEnumerable<T>`
- 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

Expand Down
29 changes: 29 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading