Newdigate.ExpressionMapper translates LINQ predicate expressions from one type to
another — for example, rewriting an Expression<Func<TEntity, bool>> into an
Expression<Func<TDataRow, bool>>. In effect, it is AutoMapper for predicate expressions.
Status: work in progress. Contributions are welcome.
A service or controller usually wants to express queries in terms of its own domain
entities, without coupling itself to the data types that a particular database or driver
happens to use. A service entity might model an identifier as a string, for instance,
while the underlying MongoDB document stores it as an ObjectId.
ExpressionMapper lets the service write a predicate against its own entity type and translate it, at the data-access boundary, into the equivalent predicate over the data-layer type. The two types need not share a base class or interface: they are matched by property name, and any difference in property type is bridged by a small conversion delegate that you supply.
The package multi-targets .NET Standard 2.1 and .NET 9, so it can be consumed from
.NET Core 3.x, .NET 5 or later, and any other netstandard2.1-compatible runtime.
dotnet add package Newdigate.ExpressionMapperStart with two unrelated types that share property names but not property types:
using MongoDB.Bson;
// Service-layer entity: what your application code works with.
public class StudentRecord
{
public string? Id { get; set; }
public string? Name { get; set; }
}
// Data-layer row: what the database driver works with. Here Id is an ObjectId, not a string.
public class StudentRecordRow
{
public ObjectId? Id { get; set; }
public string? Name { get; set; }
}Supply a conversion delegate for the properties whose types differ, then translate a predicate:
using System;
using System.Linq.Expressions;
using MongoDB.Bson;
using Newdigate.ExpressionMapper;
// Bridge a string constant to an ObjectId. Return a conversion lambda and the mapper will
// inline it over the constant. Return null when no conversion is needed or available.
static Expression? ConvertConstant(Type sourceType, Type destinationType, ConstantExpression constant)
{
if (sourceType == typeof(string) && destinationType == typeof(ObjectId?))
return (string? s) => new ObjectId(s);
return null;
}
var mapper = new PredicateMapper<StudentRecord, StudentRecordRow>(ConvertConstant);
// A predicate written against the service entity:
Expression<Func<StudentRecord, bool>> entityPredicate =
e => e.Id == "10" && e.Name == "Issue";
// ...translated to the data-row type:
Expression<Func<StudentRecordRow, bool>>? rowPredicate =
mapper.GetMappedExpression(entityPredicate);
// rowPredicate is now equivalent to:
// e => e.Id == new ObjectId("10") && e.Name == "Issue"GetMappedExpression returns null if the source predicate cannot be translated (see
Limitations). Runnable versions of this example live in
tests/Newdigate.ExpressionMapper.Tests.
PredicateMapper<TEntity, TDataRow> walks the source predicate's expression tree and
rebuilds it against a fresh parameter of type TDataRow:
- Member access (
e.Id) is re-bound to the property of the same name onTDataRow. Only names are matched; the property types are not required to agree. - Constants (
"10") pass through unchanged when their type already matches the destination. When the types differ, the constant is handed to your conversion delegate. If the delegate returns a single-parameter lambda such ass => new ObjectId(s), the mapper inlines it — substituting the parameter with the original constant — so the result is a value (new ObjectId("10")) rather than a function. - Binary operators (
==,&&, …) are rebuilt from their mapped operands. A non-nullable converted value is automatically lifted to the member's nullable type (ObjectId→ObjectId?) so the comparison resolves, mirroring how the C# compiler treatsnullableMember == value.
The conversion delegate has the signature:
Func<Type, Type, ConstantExpression, Expression?>
// (sourceType, destinationType, constant) => converted expression, or null- Only member access, constant, and binary expression nodes are translated.
Method calls (
.Contains(...),.StartsWith(...)), unary operators, and collection operations are not yet supported. - Properties are matched by name only. A property that exists on the entity but not on
the data-row type causes the mapping to return
null. - Properties whose types differ require a matching conversion delegate; without one, the constant cannot be translated.
MIT © Nic Newdigate