web-dev-qa-db-fra.com

Ajouter dynamiquement de nouvelles expressions lambda pour créer un filtre

J'ai besoin de filtrer un ObjectSet pour obtenir les entités dont j'ai besoin en procédant comme suit:

query = this.ObjectSet.Where(x => x.TypeId == 3); // this is just an example;

Plus tard dans le code (et avant de lancer l'exécution différée), je filtre à nouveau la requête comme suit:

query = query.Where(<another lambda here ...>);

Cela fonctionne assez bien jusqu'à présent.

Voici mon problème:

Les entités contiennent une propriété DateFrom et une propriété DateTo, qui sont toutes deux de type DataTime. Ils représentent une période de temps .

J'ai besoin de filtrer les entités pour obtenir uniquement celles qui font partie d'une collection de périodes de temps . Les points de la collection ne sont pas nécessairement contigus, la logique de restitution des entités ressemble à cela:

entities.Where(x => x.DateFrom >= Period1.DateFrom and x.DateTo <= Period1.DateTo)
||
entities.Where(x => x.DateFrom >= Period2.DateFrom and x.DateTo <= Period2.DateTo)
||

... et ainsi de suite pour toutes les périodes de la collection.

J'ai essayé de faire ça:

foreach (var ratePeriod in ratePeriods)
{
    var period = ratePeriod;

    query = query.Where(de =>
        de.Date >= period.DateFrom && de.Date <= period.DateTo);
}

Mais une fois que l’exécution différée est lancée, elle est traduite en SQL exactement comme je le souhaite (un filtre pour chacune des périodes correspondant à autant de périodes dans la collection), MAIS, elle se traduit par des comparaisons AND au lieu de OR comparaisons, qui ne renvoie aucune entité, puisqu’une entité ne peut évidemment pas faire partie de plus d’une période.

J'ai besoin de créer une sorte de linq dynamique ici pour agréger les filtres de période.


Mettre à jour

Basé sur la réponse de hatten, j'ai ajouté le membre suivant:

private Expression<Func<T, bool>> CombineWithOr<T>(Expression<Func<T, bool>> firstExpression, Expression<Func<T, bool>> secondExpression)
{
    // Create a parameter to use for both of the expression bodies.
    var parameter = Expression.Parameter(typeof(T), "x");
    // Invoke each expression with the new parameter, and combine the expression bodies with OR.
    var resultBody = Expression.Or(Expression.Invoke(firstExpression, parameter), Expression.Invoke(secondExpression, parameter));
    // Combine the parameter with the resulting expression body to create a new lambda expression.
    return Expression.Lambda<Func<T, bool>>(resultBody, parameter);
}

Déclaré une nouvelle expression CombineWithOr:

Expression<Func<DocumentEntry, bool>> resultExpression = n => false;

Et utilisé dans mon itération de collection de périodes comme ceci:

foreach (var ratePeriod in ratePeriods)
{
    var period = ratePeriod;
    Expression<Func<DocumentEntry, bool>> expression = de => de.Date >= period.DateFrom && de.Date <= period.DateTo;
    resultExpression = this.CombineWithOr(resultExpression, expression);
}

var documentEntries = query.Where(resultExpression.Compile()).ToList();

J'ai regardé le résultat SQL et c'est comme si l'expression n'avait aucun effet. Le SQL résultant renvoie les filtres précédemment programmés, mais pas les filtres combinés. Pourquoi ?


Mise à jour 2

Je voulais essayer la suggestion de feO2x, alors j'ai réécrit ma requête de filtre comme ceci:

query = query.AsEnumerable()
    .Where(de => ratePeriods
        .Any(rp => rp.DateFrom <= de.Date && rp.DateTo >= de.Date))

Comme vous pouvez le constater, j’ai ajouté AsEnumerable() mais le compilateur m’a indiqué qu’il ne pouvait pas reconvertir IEnumerable en IQueryable. J’ai donc ajouté ToQueryable() à la fin de ma requête:

query = query.AsEnumerable()
    .Where(de => ratePeriods
        .Any(rp => rp.DateFrom <= de.Date && rp.DateTo >= de.Date))
            .ToQueryable();

Tout fonctionne bien. Je peux compiler le code et lancer cette requête. Cependant, cela ne correspond pas à mes besoins.

Lors du profilage du résultat SQL, je peux constater que le filtrage ne fait pas partie de la requête SQL car il filtre les dates en mémoire au cours du processus. Je suppose que vous êtes déjà au courant de cela et c'est ce que vous aviez l'intention de suggérer.

Votre suggestion fonctionne, MAIS, puisqu'elle récupère toutes les entités de la base de données (et qu'il en existe des milliers) avant de les filtrer en mémoire, il est très lent à récupérer cette énorme quantité de données.

Ce que je veux vraiment, c’est d’envoyer le filtrage de période dans le cadre de la requête SQL résultante, afin qu’il ne renvoie pas une quantité importante d’entités avant d’achever le processus de filtrage.

12
user2324540

Malgré les bonnes suggestions, je devais aller avec le LinqKit one. L'une des raisons est que je devrai répéter le même type d'agrégation de prédicats à de nombreux autres endroits du code. L'utilisation de LinqKit est la plus simple, sans oublier que je peux le faire en n'écrivant que quelques lignes de code.

Voici comment j'ai résolu mon problème avec LinqKit:

var predicate = PredicateBuilder.False<Document>();
foreach (var submittedPeriod in submittedPeriods)
{
    var period = period;
    predicate = predicate.Or(d =>
        d.Date >= period.DateFrom && d.Date <= period.DateTo);
}

Et je lance l'exécution différée (notez que j'appelle AsExpandable() juste avant):

var documents = this.ObjectSet.AsExpandable().Where(predicate).ToList();

J'ai examiné le résultat SQL et il a bien traduit mes prédicats en SQL.

8
user2324540

Vous pouvez utiliser une méthode comme celle-ci:

Expression<Func<T, bool>> CombineWithOr<T>(Expression<Func<T, bool>> firstExpression, Expression<Func<T, bool>> secondExpression)
{
    // Create a parameter to use for both of the expression bodies.
    var parameter = Expression.Parameter(typeof(T), "x");
    // Invoke each expression with the new parameter, and combine the expression bodies with OR.
    var resultBody = Expression.Or(Expression.Invoke(firstExpression, parameter), Expression.Invoke(secondExpression, parameter));
    // Combine the parameter with the resulting expression body to create a new lambda expression.
    return Expression.Lambda<Func<T, bool>>(resultBody, parameter);
}

Et alors:

Expression<Func<T, bool>> resultExpression = n => false; // Always false, so that it won't affect the OR.
foreach (var ratePeriod in ratePeriods)
{
    var period = ratePeriod;
    Expression<Func<T, bool>> expression = (de => de.Date >= period.DateFrom && de.Date <= period.DateTo);
    resultExpression = CombineWithOr(resultExpression, expression);
}

// Don't forget to compile the expression in the end.
query = query.Where(resultExpression.Compile());

Pour plus d'informations, vous pouvez vérifier les éléments suivants:

Combinaison de deux expressions (Expression <Func <T, bool >>)

http://www.albahari.com/nutshell/predicatebuilder.aspx

Edit: La ligne Expression<Func<DocumentEntry, bool>> resultExpression = n => false; est juste un espace réservé. La méthode CombineWithOr requiert la combinaison de deux méthodes si vous écrivez la boucle Expression<Func<DocumentEntry, bool>> resultExpression;', you can't use it in the call toCombineWithOrfor the first time in yourforeach`. C'est juste comme le code suivant:

int resultOfMultiplications = 1;
for (int i = 0; i < 10; i++)
    resultOfMultiplications = resultOfMultiplications * i;

S'il n'y a rien dans resultOfMultiplications pour commencer, vous ne pouvez pas l'utiliser dans votre boucle.

Quant à savoir pourquoi le lambda est n => false. Parce que cela n'a aucun effet dans une instruction OR. Par exemple, false OR someExpression OR someExpression est égal à someExpression OR someExpression. Cette false n'a aucun effet.

4
hattenn

Que diriez-vous de ce code:

var targets = query.Where(de => 
    ratePeriods.Any(period => 
        de.Date >= period.DateFrom && de.Date <= period.DateTo));

J'utilise l'opérateur LINQ Any pour déterminer s'il existe une période de tarification conforme à de.Date. Bien que je ne sois pas tout à fait sûr de savoir comment cela est traduit en instructions SQL efficaces par entité. Si vous pouviez publier le résultat SQL, ce serait très intéressant pour moi.

J'espère que cela t'aides.

UPDATE après la réponse de hattenn:

Je ne pense pas que la solution de hattenn fonctionnerait, car Entity Framework utilise des expressions LINQ pour produire le SQL ou le DML exécuté sur la base de données. Par conséquent, Entity Framework s'appuie sur l'interface IQueryable<T> plutôt que sur IEnumerable<T>. Maintenant, les opérateurs LINQ par défaut (tels que Where, Any, OrderBy, FirstOrDefault, etc.) sont implémentés sur les deux interfaces. Il est donc parfois difficile de voir la différence. La principale différence entre ces interfaces réside dans le fait que, dans le cas des méthodes d’extension IEnumerable<T>, les énumérables renvoyés sont mis à jour en permanence, sans effets secondaires, tandis que dans le cas de IQueryable<T>, l’expression réelle est recomposée, ce qui n’est pas exempt d’effets l’arbre d’expression qui est finalement utilisé pour créer la requête SQL).

Maintenant Entity Framework prend en charge les ca. 50 opérateurs de requête standard de LINQ, mais si vous écrivez vos propres méthodes manipulant un IQueryable<T> (comme la méthode de hatenn), cela entraînerait un arbre d'expression qu'Entity Framework pourrait ne pas être en mesure d'analyser car il ne connaît tout simplement pas la nouvelle extension. méthode. Cela peut être la raison pour laquelle vous ne pouvez pas voir les filtres combinés après les avoir composés (bien que je m'attende à une exception).

Quand la solution avec l'opérateur Any fonctionne-t-elle:

Dans les commentaires, vous avez indiqué avoir rencontré un System.NotSupportedException: impossible de créer une valeur constante de type 'RatePeriod'. Seuls les types primitifs ou les types d'énumération sont pris en charge dans ce contexte. C'est le cas lorsque les objets RatePeriod sont des objets en mémoire qui ne sont pas suivis par Entity Framework ObjectContext ou DbContext. J'ai créé une petite solution de test qui peut être téléchargée à partir de cet emplacement: https://dl.dropboxusercontent.com/u/14810011/LinqToEntitiesOrOperator.Zip

J'ai utilisé Visual Studio 2012 avec LocalDB et Entity Framework 5. Pour voir les résultats, ouvrez la classe LinqToEntitiesOrOperatorTest, puis ouvrez Test Explorer, générez la solution et exécutez tous les tests. Vous reconnaîtrez que ComplexOrOperatorTestWithInMemoryObjects va échouer, tous les autres doivent réussir.

Le contexte que j'ai utilisé ressemble à ceci:

public class DatabaseContext : DbContext
{
    public DbSet<Post> Posts { get; set; }
    public DbSet<RatePeriod> RatePeriods { get; set; }
}
public class Post
{
    public int ID { get; set; }
    public DateTime PostDate { get; set; }
}
public class RatePeriod
{
    public int ID { get; set; }
    public DateTime From { get; set; }
    public DateTime To { get; set; }
}

Eh bien, c'est aussi simple que cela devient :-). Dans le projet de test, il existe deux méthodes de tests unitaires importantes:

    [TestMethod]
    public void ComplexOrOperatorDBTest()
    {
        var allAffectedPosts =
            DatabaseContext.Posts.Where(
                post =>
                DatabaseContext.RatePeriods.Any(period => period.From < post.PostDate && period.To > post.PostDate));

        Assert.AreEqual(3, allAffectedPosts.Count());
    }

    [TestMethod]
    public void ComplexOrOperatorTestWithInMemoryObjects()
    {
        var inMemoryRatePeriods = new List<RatePeriod>
            {
                new RatePeriod {ID = 1000, From = new DateTime(2002, 01, 01), To = new DateTime(2006, 01, 01)},
                new RatePeriod {ID = 1001, From = new DateTime(1963, 01, 01), To = new DateTime(1967, 01, 01)}
            };

        var allAffectedPosts =
            DatabaseContext.Posts.Where(
                post => inMemoryRatePeriods.Any(period => period.From < post.PostDate && period.To > post.PostDate));
        Assert.AreEqual(3, allAffectedPosts.Count());
    }

Notez que la première méthode réussit tandis que la seconde échoue avec l'exception mentionnée ci-dessus, bien que les deux méthodes fassent exactement la même chose, sauf que dans le second cas, j'ai créé des objets de période de taux en mémoire que la DatabaseContext ne connaît pas.

Que pouvez-vous faire pour résoudre ce problème?

  1. Vos objets RatePeriod résident-ils respectivement dans la même ObjectContext ou DbContext? Ensuite, utilisez-les comme dans le premier test unitaire mentionné ci-dessus.

  2. Sinon, pouvez-vous charger tous vos messages à la fois ou cela entraînerait-il une OutOfMemoryException? Sinon, vous pouvez utiliser le code suivant. Notez l'appel AsEnumerable() qui entraîne l'utilisation de l'opérateur Where par rapport à l'interface IEnumerable<T> au lieu de IQueryable<T>. En pratique, toutes les publications sont chargées en mémoire, puis filtrées:

    [TestMethod]
    public void CorrectComplexOrOperatorTestWithInMemoryObjects()
    {
        var inMemoryRatePeriods = new List<RatePeriod>
            {
                new RatePeriod {ID = 1000, From = new DateTime(2002, 01, 01), To = new DateTime(2006, 01, 01)},
                new RatePeriod {ID = 1001, From = new DateTime(1963, 01, 01), To = new DateTime(1967, 01, 01)}
            };
    
        var allAffectedPosts =
            DatabaseContext.Posts.AsEnumerable()
                           .Where(
                               post =>
                               inMemoryRatePeriods.Any(
                                   period => period.From < post.PostDate && period.To > post.PostDate));
        Assert.AreEqual(3, allAffectedPosts.Count());
    }
    
  3. Si la deuxième solution n'est pas possible, je vous recommande alors d'écrire une procédure stockée TSQL dans laquelle vous passez dans vos périodes de tarification et qui constitue l'instruction SQL correcte. Cette solution est aussi la plus performante.

1
feO2x

Quoi qu'il en soit, je pense que la création dynamique de requêtes LINQ n'était pas aussi simple que je le pensais. Essayez d’utiliser Entity SQL, comme indiqué ci-dessous:

var filters = new List<string>();
foreach (var ratePeriod in ratePeriods)
{
    filters.Add(string.Format("(it.Date >= {0} AND it.Date <= {1})", ratePeriod.DateFrom, ratePeriod.DateTo));
}

var filter = string.Join(" OR ", filters);
var result = query.Where(filter);

Cela n’est peut-être pas tout à fait correct (je ne l’ai pas essayé), mais cela devrait ressembler à cela.

0
hattenn