Skip to content

newdigate/ExpressionMapper

Repository files navigation

ExpressionMapper

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.

Why

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.

Requirements

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.

Installation

dotnet add package Newdigate.ExpressionMapper

Quick start

Start 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.

How it works

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 on TDataRow. 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 as s => 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 (ObjectIdObjectId?) so the comparison resolves, mirroring how the C# compiler treats nullableMember == value.

The conversion delegate has the signature:

Func<Type, Type, ConstantExpression, Expression?>
// (sourceType, destinationType, constant) => converted expression, or null

Limitations

  • 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.

License

MIT © Nic Newdigate

About

.NET standard 2.1: C# expression translation between database and service layers

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages