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:
usingMongoDB.Bson;// Service-layer entity: what your application code works with.publicclassStudentRecord{publicstring?Id{get;set;}publicstring?Name{get;set;}}// Data-layer row: what the database driver works with. Here Id is an ObjectId, not a string.publicclassStudentRecordRow{publicObjectId?Id{get;set;}publicstring?Name{get;set;}}Supply a conversion delegate for the properties whose types differ, then translate a predicate:
usingSystem;usingSystem.Linq.Expressions;usingMongoDB.Bson;usingNewdigate.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.staticExpression?ConvertConstant(TypesourceType,TypedestinationType,ConstantExpressionconstant){if(sourceType==typeof(string)&&destinationType==typeof(ObjectId?))return(string?s)=>newObjectId(s);returnnull;}varmapper=newPredicateMapper<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