动态生成带有嵌套属性的LINQ查询选择语句

7

目前我们有一个包,可以从字符串中动态生成linq select。它可以很好地处理平面属性,但不能处理嵌套字段,比如someObj.NestedObj.SomeField。

我们当前的代码在服务方法中按以下方式工作:

_context.Shipments
    .Where(s => s.Id == request.Id) // it does not matter just an example
    .Select(request.Fields)
    .ToPage(request); // ToPage extension comes from a nuget package

请求对象的参数"fields"只是一个字符串,其中包括逗号分隔的Shipment对象属性。

我对Shipment进行了一些重构,将一些字段分组到一个名为Address的新类中,并将其添加到Shipment中,如下所示:

// before refactoring
class Shipment {
    // other fields...
    public string SenderAddress;
    public string SenderCityName;
    public string SenderCityId;

    public string RecipientAddress;
    public string CityName;
    public string CityId;
}

// after refactoring
class Shipment {
   // other fields...
   public Address Sender;
   public Address Recipient;
}

class Address {
    public string AddressText;
    public string CityName;
    public string CityId;
}

为了当前数据库映射的缘故,我添加了相应的映射:

public class ShipmentMap : DataEntityTypeConfiguration<Shipment>
    {
        public ShipmentMap()
        {
            ToTable("Shipments");
            // other property mappings
            Property(s => s.Recipient.AddressText).HasMaxLength(1100).HasColumnName("RecipientAddress");
            Property(s => s.Recipient.CityName).HasMaxLength(100).HasColumnName("CityName");
            Property(s => s.Recipient.CityId).IsOptional().HasColumnName("CityId");

            Property(s => s.Sender.AddressText).HasMaxLength(1100).HasColumnName("SenderAddress");
            Property(s => s.Sender.CityName).HasMaxLength(100).HasColumnName("SenderCityName");
            Property(s => s.Sender.CityId).IsOptional().HasColumnName("SenderCityId");
        }
    }

DataEntityTypeConfiguration来自nuget包,如下:

  public abstract class DataEntityTypeConfiguration<T> : EntityTypeConfiguration<T> where T : class
  {
    protected virtual void PostInitialize();
  }

所以,我的问题是使用select(fields)时,当fields = "Recipient.CityId"时无法正常工作。
如何动态生成用于选择嵌套字段的linq?
我尝试了下面使用 LINQ:动态选择,但它不起作用。
// assume that request.Fields= "Recipient.CityId"

// in the service method
List<Shipment> x = _context.Shipments
    .Where(s => s.Id == request.Id)
    .Select(CreateNewStatement(request.Fields))
    .ToList();


 // I tried to generate select for linq here    
 Func<Shipment, Shipment> CreateNewStatement(string fields)
        {
            // input parameter "o"
            var xParameter = Expression.Parameter( typeof( Shipment ), "o" );

            // new statement "new Data()"
            var xNew = Expression.New( typeof( Shipment ) );

            // create initializers
            var bindings = fields.Split( ',' ).Select( o => o.Trim() )
                .Select(o =>
                {
                    string[] nestedProps = o.Split('.');
                    Expression mbr = xParameter;

                    foreach (var prop in nestedProps)
                        mbr = Expression.PropertyOrField(mbr, prop);

                    // property "Field1"
                    PropertyInfo mi = typeof( Shipment ).GetProperty( ((MemberExpression)mbr).Member.Name );
                    //
                    // original value "o.Field1"
                    var xOriginal = Expression.Property( xParameter, mi );

                    MemberBinding bnd = Expression.Bind( mi, xOriginal );
                    return bnd;
                });

            // initialization "new Data { Field1 = o.Field1, Field2 = o.Field2 }"
            var xInit = Expression.MemberInit( xNew, bindings );

            // expression "o => new Data { Field1 = o.Field1, Field2 = o.Field2 }"
            var lambda = Expression.Lambda<Func<Shipment,Shipment>>( xInit, xParameter );

            // compile to Func<Data, Data>
            return lambda.Compile();
        }

在循环结束后,mbr变成了CityId,且shipment上没有CityId字段,因此"mi"为null,导致抛出异常。我错过了什么?如何创建给定字符串的动态选择器,包含嵌套属性?
更新:
我找到了解决方案,并将其添加为答案,同时我创建了一个github gist作为解决方案。

https://gist.github.com/mstrYoda/663789375b0df23e2662a53bebaf2c7c


foreach 循环中,您正在重复使用一个新值覆盖 mbr,但并没有使用它。您是有意这样做的吗? - Abion47
实际上,我尝试将问题中的一个解决方案与另一个解决方案 https://dev59.com/U2Mm5IYBdhLWcg3wZ-QX 结合起来。我看到被接受的答案在循环中执行相同的操作。 - Emre Savcı
3个回答

8
很高兴你找到了解决方案来解决你的问题。
这里提供一种更加通用的解决方案,可以处理不同的源类型和目标类型,只要基本属性名称和类型匹配(例如,Entity -> Dto等),以及多层嵌套。
public static Expression<Func<TSource, TTarget>> BuildSelector<TSource, TTarget>(string members) =>
    BuildSelector<TSource, TTarget>(members.Split(',').Select(m => m.Trim()));

public static Expression<Func<TSource, TTarget>> BuildSelector<TSource, TTarget>(IEnumerable<string> members)
{
    var parameter = Expression.Parameter(typeof(TSource), "e");
    var body = NewObject(typeof(TTarget), parameter, members.Select(m => m.Split('.')));
    return Expression.Lambda<Func<TSource, TTarget>>(body, parameter);
}

static Expression NewObject(Type targetType, Expression source, IEnumerable<string[]> memberPaths, int depth = 0)
{
    var bindings = new List<MemberBinding>();
    var target = Expression.Constant(null, targetType);
    foreach (var memberGroup in memberPaths.GroupBy(path => path[depth]))
    {
        var memberName = memberGroup.Key;
        var targetMember = Expression.PropertyOrField(target, memberName);
        var sourceMember = Expression.PropertyOrField(source, memberName);
        var childMembers = memberGroup.Where(path => depth + 1 < path.Length);
        var targetValue = !childMembers.Any() ? sourceMember :
            NewObject(targetMember.Type, sourceMember, childMembers, depth + 1);
        bindings.Add(Expression.Bind(targetMember.Member, targetValue));
    }
    return Expression.MemberInit(Expression.New(targetType), bindings);
}

前两种方法只是公开的高级帮助程序。实际工作由私有递归NewObject方法完成。它将当前级别属性分组,并为每个分组创建简单的赋值,例如如果它是最后一级,则为 PropertyN = source.Property1.Property2...PropertyN,否则递归 PropertyN = new TypeN {…}。
以下是示例用法,符合您示例中的表达式:
var test = BuildSelector<Shipment, Shipment>(
    "Recipient.CityName, Sender.CityId, Sender.CityName, ParcelUniqueId");

当你需要使用 Func 时,只需调用 Compile


1
很高兴看到另一种解决方案,我本来想借助递归调用将我的方案变得更加通用,就像你的一样。现在,我只对自己的方案进行了一些修改,并与你的合并了起来。非常感谢你。 - Emre Savcı
这太棒了,我现在的解决方案中有非常相似的东西。非常感谢您的发布。我遇到的一个问题是,我正在使用表达式来创建带有linq的SQL。当嵌套属性之一是集合时,我遇到了一个问题,因为它试图创建一个表达式类似于List<User>.Id...,这会失败,因为列表没有ID...是否有任何方法可以扩展此功能以在列表属性上创建表达式?相当于Database.Users.Id(返回id列表) - Mark McGookin
@MarkMcGookin 可能是可能的,现在不能确定。但这似乎是朝着AutoMapper / DynamicLINQ方向发展,你不觉得吗? - Ivan Stoev

3

最终我找到了解决方案,它可以正确生成类似Shipment.Sender.CityName这样的两级嵌套属性的lambda表达式。因此,任何需要相同功能的人都可以使用它。

希望对您有所帮助。

/* this comes from request
*  request.Fields = "Sender.CityId,Sender.CityName,Recipient.CityName,parcelUniqueId"
*/

// in the service method

var shipmentList = _context.Shipments.
                .OrderByDescending(s => s.Id)
                .Skip((request.Page -1) * request.PageSize)
                .Take(request.PageSize)
                .Select(new SelectLambdaBuilder<Shipment>().CreateNewStatement(request.Fields))
                .ToList();

public class SelectLambdaBuilder<T>
{
    // as a performence consideration I cached already computed type-properties
    private static Dictionary<Type, PropertyInfo[]> _typePropertyInfoMappings = new Dictionary<Type, PropertyInfo[]>();
    private readonly Type _typeOfBaseClass = typeof(T);

    private Dictionary<string, List<string>> GetFieldMapping(string fields)
    {
        var selectedFieldsMap = new Dictionary<string, List<string>>();

        foreach (var s in fields.Split(','))
        {
            var nestedFields = s.Split('.').Select(f => f.Trim()).ToArray();
            var nestedValue = nestedFields.Length > 1 ? nestedFields[1] : null;

            if (selectedFieldsMap.Keys.Any(key => key == nestedFields[0]))
            {
                selectedFieldsMap[nestedFields[0]].Add(nestedValue);
            }
            else
            {
                selectedFieldsMap.Add(nestedFields[0], new List<string> { nestedValue });
            }
        }

        return selectedFieldsMap;
    }

    public Func<T, T> CreateNewStatement(string fields)
    {
        ParameterExpression xParameter = Expression.Parameter(_typeOfBaseClass, "s");
        NewExpression xNew = Expression.New(_typeOfBaseClass);

        var selectFields = GetFieldMapping(fields);

        var shpNestedPropertyBindings = new List<MemberAssignment>();
        foreach (var keyValuePair in selectFields)
        {
            PropertyInfo[] propertyInfos;
            if (!_typePropertyInfoMappings.TryGetValue(_typeOfBaseClass, out propertyInfos))
            {
                var properties = _typeOfBaseClass.GetProperties();
                propertyInfos = properties;
                _typePropertyInfoMappings.Add(_typeOfBaseClass, properties);
            }

            var propertyType = propertyInfos
                .FirstOrDefault(p => p.Name.ToLowerInvariant().Equals(keyValuePair.Key.ToLowerInvariant()))
                .PropertyType;

            if (propertyType.IsClass)
            {
                PropertyInfo objClassPropInfo = _typeOfBaseClass.GetProperty(keyValuePair.Key);
                MemberExpression objNestedMemberExpression = Expression.Property(xParameter, objClassPropInfo);

                NewExpression innerObjNew = Expression.New(propertyType);

                var nestedBindings = keyValuePair.Value.Select(v =>
                {
                    PropertyInfo nestedObjPropInfo = propertyType.GetProperty(v);

                    MemberExpression nestedOrigin2 = Expression.Property(objNestedMemberExpression, nestedObjPropInfo);
                    var binding2 = Expression.Bind(nestedObjPropInfo, nestedOrigin2);

                    return binding2;
                });

                MemberInitExpression nestedInit = Expression.MemberInit(innerObjNew, nestedBindings);
                shpNestedPropertyBindings.Add(Expression.Bind(objClassPropInfo, nestedInit));
            }
            else
            {
                Expression mbr = xParameter;
                mbr = Expression.PropertyOrField(mbr, keyValuePair.Key);

                PropertyInfo mi = _typeOfBaseClass.GetProperty( ((MemberExpression)mbr).Member.Name );

                var xOriginal = Expression.Property(xParameter, mi);

                shpNestedPropertyBindings.Add(Expression.Bind(mi, xOriginal));
            }
        }

        var xInit = Expression.MemberInit(xNew, shpNestedPropertyBindings);
        var lambda = Expression.Lambda<Func<T,T>>( xInit, xParameter );

        return lambda.Compile();
    }

它将 lambda 编译如下:

s => new Shipment {
    Recipient = new Address {
        CityName = s.Recipient.CityName
    },
    Sender = new Address {
        CityId = s.Sender.CityId,
        CityName = s.Sender.CityName
    },
    ParcelUniqueId = s.ParcelUniqueId
}

我分享一些来自调试的截图: enter image description here enter image description here

我无法确定这是否保持对象形状,以便 in.child.field 映射到 out.child.field - Trevortni

0

我相信你的问题出在这一段代码中:

string[] nestedProps = o.Split('.');
Expression mbr = xParameter;

foreach (var prop in nestedProps)
    mbr = Expression.PropertyOrField(mbr, prop);

// property "Field1"
PropertyInfo mi = typeof( Shipment ).GetProperty( ((MemberExpression)mbr).Member.Name );

foreach循环会重复将值分配给mbr,然后覆盖它,这意味着它的最终值将是nestedProps中最后一个值的表达式等效物。假设输入字符串为"Recipient.CityId",则mbr将成为CityId的表达式。然后,您尝试在Shipment类型上执行GetProperty,寻找名称为CityId的属性,但当然不存在(CityIdAddress的属性)。

我不确定该如何建议解决问题,因为我不确定您最终想要什么。


我知道最终它会变成CityId。我想生成类似于.Select(s => new { s.Recipient.CityId })的linq选择,参数字段可以动态更改。 - Emre Savcı
我添加了解决我的问题的答案。我希望它能帮助你理解我试图解决的问题。感谢你的帮助。 - Emre Savcı

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接