Commandes de requête et / ou spécifications bien conçues

90

Je cherche depuis un certain temps une bonne solution aux problèmes présentés par le modèle typique de référentiel (liste croissante de méthodes pour les requêtes spécialisées, etc. voir: http://ayende.com/blog/3955/repository- est-le-nouveau-singleton ).

J'aime vraiment l'idée d'utiliser les requêtes de commande, en particulier via l'utilisation du modèle de spécification. Cependant, mon problème avec la spécification est qu'elle ne concerne que les critères de sélections simples (essentiellement, la clause where), et ne traite pas des autres problèmes de requêtes, tels que la jonction, le regroupement, la sélection de sous-ensembles ou la projection, etc. fondamentalement, tous les obstacles supplémentaires que de nombreuses requêtes doivent parcourir pour obtenir le bon ensemble de données.

(note: j'utilise le terme «commande» comme dans le modèle de commande, également connu sous le nom d'objets de requête. Je ne parle pas de commande comme dans la séparation commande / requête où il y a une distinction entre les requêtes et les commandes (mise à jour, suppression, insérer))

Je recherche donc des alternatives qui encapsulent toute la requête, mais toujours suffisamment flexibles pour ne pas simplement échanger des référentiels spaghetti pour une explosion de classes de commande.

J'ai utilisé, par exemple, Linqspecs, et bien que je trouve une certaine valeur à pouvoir attribuer des noms significatifs aux critères de sélection, ce n'est tout simplement pas suffisant. Peut-être suis-je à la recherche d'une solution mixte qui combine plusieurs approches.

Je suis à la recherche de solutions que d'autres pourraient avoir développées pour résoudre ce problème ou pour résoudre un problème différent, tout en satisfaisant à ces exigences. Dans l'article lié, Ayende suggère d'utiliser directement le contexte nHibernate, mais je pense que cela complique grandement votre couche métier car elle doit désormais contenir des informations de requête.

J'offrirai une prime à ce sujet, dès que la période d'attente sera écoulée. Alors, s'il vous plaît, rendez vos solutions dignes de récompense, avec de bonnes explications et je sélectionnerai la meilleure solution et je voterai pour les finalistes.

REMARQUE: je recherche quelque chose qui est basé sur ORM. Il n'est pas nécessaire que ce soit EF ou nHibernate explicitement, mais ce sont les plus courants et conviendraient le mieux. S'il peut être facilement adapté à d'autres ORM, ce serait un bonus. La compatibilité Linq serait également bien.

MISE À JOUR: Je suis vraiment surpris qu'il n'y ait pas beaucoup de bonnes suggestions ici. Il semble que les gens sont soit totalement CQRS, soit complètement dans le camp du référentiel. La plupart de mes applications ne sont pas assez complexes pour justifier le CQRS (quelque chose avec la plupart des défenseurs du CQRS disent volontiers que vous ne devriez pas l'utiliser pour).

MISE À JOUR: Il semble y avoir un peu de confusion ici. Je ne recherche pas une nouvelle technologie d'accès aux données, mais plutôt une interface raisonnablement bien conçue entre l'entreprise et les données.

Idéalement, ce que je recherche, c'est une sorte de croisement entre les objets de requête, le modèle de spécification et le référentiel. Comme je l'ai dit ci-dessus, le modèle de spécification ne traite que de l'aspect de la clause where, et non des autres aspects de la requête, tels que les jointures, les sous-sélections, etc. Les référentiels traitent l'ensemble de la requête, mais deviennent incontrôlables après un certain temps . Les objets de requête traitent également l'ensemble de la requête, mais je ne veux pas simplement remplacer les référentiels par des explosions d'objets de requête.

Erik Funkenbusch
la source
5
Question fantastique. Moi aussi, j'aimerais voir quelles personnes ont plus d'expérience que je ne le suggère. Je travaille actuellement sur une base de code où le référentiel générique contient également des surcharges pour les objets Command ou Query, dont la structure est similaire à ce qu'Ayende décrit dans son blog. PS: Cela pourrait également attirer l'attention sur programmers.SE.
Simon Whitehead
Pourquoi ne pas simplement utiliser un référentiel qui expose IQueryable si la dépendance à LINQ ne vous dérange pas? Une approche courante est un référentiel générique, puis, lorsque vous avez besoin d'une logique réutilisable ci-dessus, vous créez un type de référentiel dérivé avec vos méthodes supplémentaires.
devdigital
@devdigital - La dépendance sur Linq n'est pas la même chose que la dépendance sur l'implémentation des données. Je voudrais utiliser Linq pour les objets, afin de pouvoir trier ou exécuter d'autres fonctions de couche de gestion. Mais cela ne signifie pas que je veux des dépendances sur l'implémentation du modèle de données. Ce dont je parle vraiment ici, c'est l'interface couche / niveau. Par exemple, je veux pouvoir modifier une requête et ne pas avoir à la modifier à 200 endroits, ce qui se passe si vous insérez IQueryable directement dans le modèle commercial.
Erik Funkenbusch
1
@devdigital - qui ne fait que déplacer les problèmes avec un référentiel dans votre couche métier. Vous ne faites que mélanger le problème.
Erik Funkenbusch

Réponses:

94

Avertissement: comme il n'y a pas encore de bonnes réponses, j'ai décidé de publier une partie d'un excellent article de blog que j'ai lu il y a quelque temps, copié presque textuellement. Vous pouvez trouver le billet de blog complet ici . Alors voilà:


Nous pouvons définir les deux interfaces suivantes:

public interface IQuery<TResult>
{
}

public interface IQueryHandler<TQuery, TResult> where TQuery : IQuery<TResult>
{
    TResult Handle(TQuery query);
}

Le IQuery<TResult>spécifie un message qui définit une requête spécifique avec les données qu'il renvoie à l'aide du TResulttype générique. Avec l'interface précédemment définie, nous pouvons définir un message de requête comme celui-ci:

public class FindUsersBySearchTextQuery : IQuery<User[]>
{
    public string SearchText { get; set; }
    public bool IncludeInactiveUsers { get; set; }
}

Cette classe définit une opération de requête avec deux paramètres, qui aboutira à un tableau d' Userobjets. La classe qui gère ce message peut être définie comme suit:

public class FindUsersBySearchTextQueryHandler
    : IQueryHandler<FindUsersBySearchTextQuery, User[]>
{
    private readonly NorthwindUnitOfWork db;

    public FindUsersBySearchTextQueryHandler(NorthwindUnitOfWork db)
    {
        this.db = db;
    }

    public User[] Handle(FindUsersBySearchTextQuery query)
    {
        return db.Users.Where(x => x.Name.Contains(query.SearchText)).ToArray();
    }
}

Nous pouvons maintenant laisser les consommateurs dépendre de l' IQueryHandlerinterface générique :

public class UserController : Controller
{
    IQueryHandler<FindUsersBySearchTextQuery, User[]> findUsersBySearchTextHandler;

    public UserController(
        IQueryHandler<FindUsersBySearchTextQuery, User[]> findUsersBySearchTextHandler)
    {
        this.findUsersBySearchTextHandler = findUsersBySearchTextHandler;
    }

    public View SearchUsers(string searchString)
    {
        var query = new FindUsersBySearchTextQuery
        {
            SearchText = searchString,
            IncludeInactiveUsers = false
        };

        User[] users = this.findUsersBySearchTextHandler.Handle(query);    
        return View(users);
    }
}

Immédiatement, ce modèle nous donne beaucoup de flexibilité, car nous pouvons maintenant décider quoi injecter dans le UserController. Nous pouvons injecter une implémentation complètement différente, ou une implémentation qui encapsule l'implémentation réelle, sans avoir à modifier le UserController(et tous les autres consommateurs de cette interface).

L' IQuery<TResult>interface nous donne un support lors de la compilation lors de la spécification ou de l'injection IQueryHandlersdans notre code. Lorsque nous modifions le FindUsersBySearchTextQuerypour retourner à la UserInfo[]place (en implémentant IQuery<UserInfo[]>), la UserControllercompilation échouera, car la contrainte de type générique sur IQueryHandler<TQuery, TResult>ne pourra pas être mappée FindUsersBySearchTextQueryàUser[] .

Injecter le IQueryHandlerCependant, interface dans un consommateur présente des problèmes moins évidents qui doivent encore être résolus. Le nombre de dépendances de nos consommateurs peut devenir trop important et entraîner une surinjection du constructeur - lorsqu'un constructeur prend trop d'arguments. Le nombre de requêtes qu'une classe exécute peut changer fréquemment, ce qui nécessiterait des changements constants dans le nombre d'arguments du constructeur.

Nous pouvons résoudre le problème d'avoir à en injecter trop IQueryHandlersavec une couche supplémentaire d'abstraction. Nous créons un médiateur qui se situe entre les consommateurs et les gestionnaires de requêtes:

public interface IQueryProcessor
{
    TResult Process<TResult>(IQuery<TResult> query);
}

Le IQueryProcessorest une interface non générique avec une méthode générique. Comme vous pouvez le voir dans la définition de l'interface, le IQueryProcessordépend de l' IQuery<TResult>interface. Cela nous permet d'avoir une prise en charge du temps de compilation chez nos consommateurs qui dépendent du IQueryProcessor. Réécrivons le UserControllerpour utiliser le nouveau IQueryProcessor:

public class UserController : Controller
{
    private IQueryProcessor queryProcessor;

    public UserController(IQueryProcessor queryProcessor)
    {
        this.queryProcessor = queryProcessor;
    }

    public View SearchUsers(string searchString)
    {
        var query = new FindUsersBySearchTextQuery
        {
            SearchText = searchString,
            IncludeInactiveUsers = false
        };

        // Note how we omit the generic type argument,
        // but still have type safety.
        User[] users = this.queryProcessor.Process(query);

        return this.View(users);
    }
}

Le UserControllerdépend maintenant d'un IQueryProcessorqui peut gérer toutes nos requêtes. La méthode UserControllers SearchUsersappelle la IQueryProcessor.Processméthode en passant un objet de requête initialisé. Puisque l' interface FindUsersBySearchTextQueryimplémente l' IQuery<User[]>interface, nous pouvons la passer à la Execute<TResult>(IQuery<TResult> query)méthode générique . Grâce à l'inférence de type C #, le compilateur est capable de déterminer le type générique et cela nous évite d'avoir à énoncer explicitement le type. Le type de retour duProcess méthode est également connu.

Il est désormais de la responsabilité de la mise en œuvre de la IQueryProcessorrecherche du droit IQueryHandler. Cela nécessite un typage dynamique, et éventuellement l'utilisation d'un framework d'injection de dépendances, et peut tout être fait avec seulement quelques lignes de code:

sealed class QueryProcessor : IQueryProcessor
{
    private readonly Container container;

    public QueryProcessor(Container container)
    {
        this.container = container;
    }

    [DebuggerStepThrough]
    public TResult Process<TResult>(IQuery<TResult> query)
    {
        var handlerType = typeof(IQueryHandler<,>)
            .MakeGenericType(query.GetType(), typeof(TResult));

        dynamic handler = container.GetInstance(handlerType);

        return handler.Handle((dynamic)query);
    }
}

La QueryProcessorclasse construit un IQueryHandler<TQuery, TResult>type spécifique en fonction du type de l'instance de requête fournie. Ce type est utilisé pour demander à la classe de conteneur fournie d'obtenir une instance de ce type. Malheureusement, nous devons appeler la Handleméthode en utilisant la réflexion (en utilisant le mot-clé dymamic C # 4.0 dans ce cas), car à ce stade, il est impossible de convertir l'instance du gestionnaire, car l' TQueryargument générique n'est pas disponible au moment de la compilation. Cependant, à moins que la Handleméthode ne soit renommée ou obtienne d'autres arguments, cet appel n'échouera jamais et si vous le souhaitez, il est très facile d'écrire un test unitaire pour cette classe. Utiliser la réflexion donnera une légère baisse, mais il n'y a pas vraiment à s'inquiéter.


Pour répondre à l'une de vos préoccupations:

Je recherche donc des alternatives qui encapsulent toute la requête, mais toujours suffisamment flexibles pour ne pas simplement échanger des référentiels spaghetti pour une explosion de classes de commande.

Une conséquence de l'utilisation de cette conception est qu'il y aura beaucoup de petites classes dans le système, mais avoir beaucoup de petites classes / focalisées (avec des noms clairs) est une bonne chose. Cette approche est clairement bien meilleure que d'avoir de nombreuses surcharges avec des paramètres différents pour la même méthode dans un référentiel, car vous pouvez les regrouper dans une classe de requête. Ainsi, vous obtenez toujours beaucoup moins de classes de requêtes que de méthodes dans un référentiel.

david.s
la source
2
On dirait que vous avez reçu le prix. J'aime les concepts, j'espérais juste que quelqu'un présente quelque chose de vraiment différent. Félicitations.
Erik Funkenbusch
1
@FuriCuri, une seule classe a-t-elle vraiment besoin de 5 requêtes? Peut-être pourriez-vous considérer cela comme une classe avec trop de responsabilités. Sinon, si les requêtes sont agrégées, elles devraient peut-être être une seule requête. Ce ne sont que des suggestions, bien sûr.
Sam
1
@stakx Vous avez parfaitement raison de dire que dans mon exemple initial, le TResultparamètre générique de l' IQueryinterface n'est pas utile. Cependant, dans ma réponse mise à jour, le TResultparamètre est utilisé par la Processméthode du IQueryProcessorpour résoudre le IQueryHandlerà l'exécution.
david.s
1
J'ai aussi un blog avec une implémentation très similaire qui me fait penser que je suis sur la bonne voie, c'est le lien jupaol.blogspot.mx/2012/11/… et je l'utilise depuis un moment dans les applications PROD, mais j'ai eu un problème avec cette approche. Chaînage et réutilisation des requêtes Disons que j'ai plusieurs petites requêtes qui doivent être combinées pour créer des requêtes plus complexes, j'ai fini par dupliquer le code mais je recherche une approche moch meilleure et plus propre. Des idées?
Jupaol
4
@Cemre J'ai fini par encapsuler mes requêtes dans les méthodes d'extension retournant IQueryableet en m'assurant de ne pas énumérer la collection, puis à partir de la QueryHandlerchaîne que je viens d'appeler / chaîner les requêtes. Cela m'a donné la flexibilité de tester mes requêtes et de les chaîner. J'ai un service d'application en plus de mon QueryHandler, et mon contrôleur est chargé de parler directement avec le service au lieu du gestionnaire
Jupaol
4

Ma façon de gérer cela est en fait simpliste et indépendante de l'ORM. Mon point de vue pour un référentiel est le suivant: le travail du référentiel est de fournir à l'application le modèle requis pour le contexte, de sorte que l'application demande simplement au dépôt ce qu'il veut mais ne lui dit pas comment l' obtenir.

Je fournis la méthode du référentiel avec un critère (oui, style DDD), qui sera utilisé par le référentiel pour créer la requête (ou tout ce qui est nécessaire - il peut s'agir d'une demande de service Web). Les jointures et les groupes à mon avis sont des détails sur le comment, pas le quoi et un critère ne devrait être que la base pour construire une clause where.

Modèle = l'objet final ou la structure de données dont l'application a besoin.

public class MyCriteria
{
   public Guid Id {get;set;}
   public string Name {get;set;}
    //etc
 }

 public interface Repository
  {
       MyModel GetModel(Expression<Func<MyCriteria,bool>> criteria);
   }

Vous pouvez probablement utiliser les critères ORM (Nhibernate) directement si vous le souhaitez. L'implémentation du référentiel doit savoir comment utiliser les critères avec le stockage sous-jacent ou DAO.

Je ne connais pas votre domaine et les exigences du modèle, mais il serait étrange que le meilleur moyen soit que l'application crée la requête elle-même. Le modèle change tellement que vous ne pouvez pas définir quelque chose de stable?

Cette solution nécessite clairement du code supplémentaire, mais elle ne couple pas le reste du à un ORM ou à tout ce que vous utilisez pour accéder au stockage. Le référentiel fait son travail pour agir comme une façade et l'OMI il est propre et le code de `` traduction des critères '' est réutilisable

MikeSW
la source
Cela ne résout pas les problèmes de croissance des référentiels et de disposer d'une liste toujours croissante de méthodes pour renvoyer divers types de données. Je comprends que vous ne voyez pas de problème avec cela (beaucoup de gens ne le voient pas), mais d'autres le voient différemment (je suggère de lire l'article auquel j'ai lié, il y a beaucoup d'autres personnes avec des opinions similaires).
Erik Funkenbusch
1
J'aborde la question, car les critères rendent inutiles de nombreuses méthodes. Bien sûr, pas de tous, je ne peux pas dire grand-chose sans rien savoir de la teinte dont vous avez besoin. Je suis convaincu que vous voulez interroger directement la base de données, donc probablement un référentiel est juste sur le chemin. Si vous avez besoin de travailler directement avec le système relationnel, allez-y directement, pas besoin de référentiel. Et comme note, c'est ennuyeux de voir combien de personnes citent Ayende avec ce post. Je ne suis pas d'accord avec cela et je pense que de nombreux développeurs utilisent simplement le modèle dans le mauvais sens.
MikeSW
1
Cela peut réduire quelque peu le problème, mais étant donné une application suffisamment grande, cela créera toujours des dépôts monstres. Je ne suis pas d'accord avec la solution d'Ayende d'utiliser nHibernate directement dans la logique principale, mais je suis d'accord avec lui sur l'absurdité de la croissance incontrôlée du référentiel. Je ne souhaite pas interroger directement la base de données, mais je ne souhaite pas non plus simplement déplacer le problème d'un référentiel vers une explosion d'objets de requête.
Erik Funkenbusch
2

J'ai fait ceci, soutenu ceci et annulé ceci.

Le problème majeur est le suivant: peu importe comment vous le faites, l'abstraction ajoutée ne vous gagne pas en indépendance. Il fuira par définition. En substance, vous inventez une couche entière juste pour rendre votre code mignon ... mais cela ne réduit pas la maintenance, n'améliore pas la lisibilité ou ne vous procure aucun type d'agnosticisme de modèle.

La partie amusante est que vous avez répondu à votre propre question en réponse à la réponse d'Olivier: "il s'agit essentiellement de dupliquer la fonctionnalité de Linq sans tous les avantages que vous obtenez de Linq".

Demandez-vous: comment cela pourrait-il ne pas être?

Stu
la source
Eh bien, j'ai certainement rencontré les problèmes d'intégration de Linq dans votre couche métier. C'est très puissant, mais lorsque nous modifions les modèles de données, c'est un cauchemar. Les choses sont améliorées avec les référentiels, car je peux apporter les modifications dans un endroit localisé sans trop affecter la couche de gestion (sauf lorsque vous devez également modifier la couche de gestion pour prendre en charge les modifications). Mais, les référentiels deviennent ces couches gonflées qui violent massivement SRP. Je comprends votre argument, mais cela ne résout pas vraiment les problèmes non plus.
Erik Funkenbusch
Si votre couche de données utilise LINQ et que les modifications du modèle de données nécessitent des modifications de votre couche de gestion ... vous ne superposez pas correctement.
Stu
Je pensais que vous disiez que vous n’ajoutiez plus cette couche. Lorsque vous dites que l'abstraction ajoutée ne vous rapporte rien, cela signifie que vous êtes d'accord avec Ayende sur le passage de la session nHibernate (ou du contexte EF) directement dans votre couche métier.
Erik Funkenbusch
1

Vous pouvez utiliser une interface fluide. L'idée de base est que les méthodes d'une classe retournent l'instance actuelle cette même classe après avoir effectué une action. Cela vous permet d'enchaîner les appels de méthode.

En créant une hiérarchie de classes appropriée, vous pouvez créer un flux logique de méthodes accessibles.

public class FinalQuery
{
    protected string _table;
    protected string[] _selectFields;
    protected string _where;
    protected string[] _groupBy;
    protected string _having;
    protected string[] _orderByDescending;
    protected string[] _orderBy;

    protected FinalQuery()
    {
    }

    public override string ToString()
    {
        var sb = new StringBuilder("SELECT ");
        AppendFields(sb, _selectFields);
        sb.AppendLine();

        sb.Append("FROM ");
        sb.Append("[").Append(_table).AppendLine("]");

        if (_where != null) {
            sb.Append("WHERE").AppendLine(_where);
        }

        if (_groupBy != null) {
            sb.Append("GROUP BY ");
            AppendFields(sb, _groupBy);
            sb.AppendLine();
        }

        if (_having != null) {
            sb.Append("HAVING").AppendLine(_having);
        }

        if (_orderBy != null) {
            sb.Append("ORDER BY ");
            AppendFields(sb, _orderBy);
            sb.AppendLine();
        } else if (_orderByDescending != null) {
            sb.Append("ORDER BY ");
            AppendFields(sb, _orderByDescending);
            sb.Append(" DESC").AppendLine();
        }

        return sb.ToString();
    }

    private static void AppendFields(StringBuilder sb, string[] fields)
    {
        foreach (string field in fields) {
            sb.Append(field).Append(", ");
        }
        sb.Length -= 2;
    }
}

public class GroupedQuery : FinalQuery
{
    protected GroupedQuery()
    {
    }

    public GroupedQuery Having(string condition)
    {
        if (_groupBy == null) {
            throw new InvalidOperationException("HAVING clause without GROUP BY clause");
        }
        if (_having == null) {
            _having = " (" + condition + ")";
        } else {
            _having += " AND (" + condition + ")";
        }
        return this;
    }

    public FinalQuery OrderBy(params string[] fields)
    {
        _orderBy = fields;
        return this;
    }

    public FinalQuery OrderByDescending(params string[] fields)
    {
        _orderByDescending = fields;
        return this;
    }
}

public class Query : GroupedQuery
{
    public Query(string table, params string[] selectFields)
    {
        _table = table;
        _selectFields = selectFields;
    }

    public Query Where(string condition)
    {
        if (_where == null) {
            _where = " (" + condition + ")";
        } else {
            _where += " AND (" + condition + ")";
        }
        return this;
    }

    public GroupedQuery GroupBy(params string[] fields)
    {
        _groupBy = fields;
        return this;
    }
}

Tu l'appellerais comme ça

string query = new Query("myTable", "name", "SUM(amount) AS total")
    .Where("name LIKE 'A%'")
    .GroupBy("name")
    .Having("COUNT(*) > 2")
    .OrderBy("name")
    .ToString();

Vous ne pouvez créer qu'une nouvelle instance de Query. Les autres classes ont un constructeur protégé. Le but de la hiérarchie est de "désactiver" les méthodes. Par exemple, la GroupByméthode retourne a GroupedQueryqui est la classe de base de Queryet n'a pas de Whereméthode (la méthode where est déclarée dans Query). Par conséquent, il n'est pas possible d'appeler Whereaprès GroupBy.

Ce n'est cependant pas parfait. Avec cette hiérarchie de classes, vous pouvez successivement masquer les membres, mais pas en afficher de nouveaux. Par conséquent Havinglève une exception lorsqu'elle est appelée auparavant GroupBy.

Notez qu'il est possible d'appeler Whereplusieurs fois. Cela ajoute de nouvelles conditions avec un ANDaux conditions existantes. Cela facilite la construction de filtres par programmation à partir de conditions uniques. La même chose est possible avec Having.

Les méthodes acceptant les listes de champs ont un paramètre params string[] fields. Il vous permet de transmettre des noms de champ uniques ou un tableau de chaînes.


Les interfaces Fluent sont très flexibles et ne vous obligent pas à créer beaucoup de surcharges de méthodes avec différentes combinaisons de paramètres. Mon exemple fonctionne avec des chaînes, mais l'approche peut être étendue à d'autres types. Vous pouvez également déclarer des méthodes prédéfinies pour des cas spéciaux ou des méthodes acceptant des types personnalisés. Vous pouvez également ajouter des méthodes comme ExecuteReaderou ExceuteScalar<T>. Cela vous permettrait de définir des requêtes comme celle-ci

var reader = new Query<Employee>(new MonthlyReportFields{ IncludeSalary = true })
    .Where(new CurrentMonthCondition())
    .Where(new DivisionCondition{ DivisionType = DivisionType.Production})
    .OrderBy(new StandardMonthlyReportSorting())
    .ExecuteReader();

Même les commandes SQL construites de cette manière peuvent avoir des paramètres de commande et ainsi éviter les problèmes d'injection SQL et en même temps permettre aux commandes d'être mises en cache par le serveur de base de données. Ce n'est pas un remplacement pour un mappeur O / R mais peut aider dans les situations où vous créeriez les commandes en utilisant une simple concaténation de chaînes autrement.

Olivier Jacot-Descombes
la source
3
Hmm .. Intéressant, mais votre solution semble avoir des problèmes avec les possibilités d'injection SQL, et ne crée pas vraiment d'instructions préparées pour une exécution pré-compilée (donc fonctionnant plus lentement). Il pourrait probablement être adapté pour résoudre ces problèmes, mais nous sommes alors coincés avec les résultats de l'ensemble de données sûrs non de type et autres. Je préférerais une solution basée sur ORM, et peut-être devrais-je le spécifier explicitement. Il s'agit essentiellement de dupliquer les fonctionnalités de Linq sans tous les avantages que vous obtenez de Linq.
Erik Funkenbusch
Je suis conscient de ces problèmes. Ceci est juste une solution rapide et sale, montrant comment une interface fluide peut être construite. Dans une solution du monde réel, vous «feriez» probablement votre approche existante dans une interface fluide adaptée à vos besoins.
Olivier Jacot-Descombes