Préambule
NHibernate vient de passer en version 2.1, avec son lot de nouvelles fonctionnalités.
Lors d'un développement Web, il arrive couramment de mettre en place de la pagination et du tri sur les pages pour afficher les N éléments d'une série. Jusqu'à quelques centaines d'objets, cette pagination peut aisément s'effectuer côté couche Web, en coupant et triant une liste d'objets : on pourra s'aider de Linq to Objects pour ça, avec par exemple du Skip et du Take et des orderby. Le problème de cette technique est de ramener l'ensemble de sa sélection puis de la filtrer dans la couche de plus niveau, ce qui peut être assez désastreux lorsque l'on vient à avoir des collections de milliers d'objets : cela en devient très lent, et pour nos utilisateurs adorés, on se doit de trouver une solution.
Dans ce cas, une solution est de s'orienter sur une pagination côté base de données, qui s'avère bien plus efficace car ne sont ramenés uniquement les objets à afficher sur la page : si la pagination est de 50 éléments par page, on ramène 50 objets et non 7 000 pour n'en prendre que 50 si on utilisait la première méthode.
Voyons comment faire simplement en s'appuyant sur NHibernate, le dialect MSSQL 2005 / 2008. On utilisera aussi les Future pour gagner un peu plus en performances.
Pagination, ordre et désordre : les classes
Prenons le schéma suivant, dans la continuité de ce billet (utilise le composite-id pour du legacy) :
Je veux obtenir la liste des associations gérées par un responsable, on a une méthode qui ira nous les chercher :
/// <summary> /// Liste des Associations pour un responsable /// </summary> /// <param name="respid">id responsable</param> /// <param name="paginationOrderParameters">pagination et ordre</param> /// <returns>liste Assoc</returns> List<Assoc> GetAssocForAResponsable(int respid, PaginationOrderParameters paginationOrderParameters);
PaginationOrderParameters permettra de préciser quelle page et combien d'éléments il nous faut. Egalement, si l'on souhaite avoir un tri particulier, on pourra le préciser à l'aide d'une liste de propriétés à trier. Enfin, paginationOrderParameters sera garni (CountTotal) du nombre total d'éléments trouvés, hors pagination.
Cela donne la classe suivante :
public class PaginationOrderParameters { /// <summary> /// objet pagination /// </summary> public Pagination Pagination { get; set; } /// <summary> /// liste des propriétés éventuelles à trier /// </summary> public List<OrderField> OrderFields { get; set; } } public class Pagination { /// <summary> /// le nombre d'éléments d'une page /// </summary> public int PageSize { get; set; } /// <summary> /// la page courante, de 0 à n /// </summary> public int PageIndex { get; set; } /// <summary> /// nombre total d'éléments trouvés (count), hors pagination /// </summary> public int CountTotal { get; set; } } public class OrderField { /// <summary> /// true : asc, false : desc /// </summary> public bool Ascending { get; set; } /// <summary> /// la propriété touchée par le tri /// </summary> public string Field { get; set; } }
Code
Voyons le code de la méthode qui va interroger, avec NHibernate, la base. On utilisera les méthodes ci-après, elles feront tout le boulot ou presque à notre place. Le dialect MSSQL >= 2005 générera le SQL de pagination pour l'interrogation en base selon la syntaxe du serveur utilisé (SQLite : limit, offset, SQL Server : TOP, ROW_NUMBER).
- SetFirstResult : début des éléments à prendre, correspond à l'usage du ROW_NUMBER et au découpage par page. On s'appuie sur paginationOrderParameters : SetFirstResult(PageIndex * PageSize)
- SetMaxResults : correspond au TOP, on s'appuie là aussi sur paginationOrderParameters : SetMaxResults(PageSize)
- Order : pour le tri
NB : jusqu'au dialect MSSQL 2000, le framework utilisait uniquement le TOP du SQL, le reste étant réalisé par NH : TOP n puis découpe des éléments ramenés par NH. Avec les nouveaux dialects (MSSQL 2005 et 2008), NH utilise le ROW_NUMBER() qui permet d'effectuer de la pagination bien plus efficacement.
List<Assoc> IAssocManager.GetAssocForAResponsable(int respid, PaginationOrderParameters paginationOrderParameters) { var where = new [] { Restrictions.Eq("Responsable", respid) }; // subquery var assocCriteria = DetachedCriteria.For<ResponsableAssoc>().SetProjection(Projections.Distinct(Projections.Property("Assoc"))); foreach (var w in where) assocCriteria.Add(w); // les assocs var critabo = _laSession.CreateCriteria(typeof (Assoc), "assoc") .Add(Subqueries.PropertyIn("Id", assocCriteria)); // le nb. d'assocs var countabos = _laSession.CreateCriteria<Assoc>(); countabos.Add(Subqueries.PropertyIn("Id", assocCriteria)); countabos.SetProjection(Projections.RowCount()); // la pagination et le tri if (paginationOrderParameters != null) { // Tri if (paginationOrderParameters.OrderFields != null & paginationOrderParameters.OrderFields.Count > 0) { foreach (var orderField in paginationOrderParameters.OrderFields) { var myfield = orderField.Field.Split(new[] { '.' }); if (myfield.Length > 1) if (critabo.GetCriteriaByAlias(myfield[0]) == null) critabo.CreateAlias(myfield[0], myfield[0]); critabo.AddOrder(new Order(orderField.Field, orderField.Ascending)); } } // Pagination if (paginationOrderParameters.Pagination != null) critabo .SetFirstResult(paginationOrderParameters.Pagination.PageIndex < 0 ? 0 : paginationOrderParameters.Pagination.PageIndex * paginationOrderParameters.Pagination.PageSize) .SetMaxResults(paginationOrderParameters.Pagination.PageSize); if (paginationOrderParameters.Pagination != null) paginationOrderParameters.Pagination.CountTotal = countabos.FutureValue<int>().Value; } return critabo.Future<Assoc>().ToList(); }
Testons tout ça
[Test] public void GetAssocForAResponsable_with_pagination_page1_should_return_2assocs() { var paginationOrderParameters = new PaginationOrderParameters{Pagination =new Pagination { PageIndex = 0, PageSize = 2 }}; var results = assocmgr.GetAssocForAResponsable(1972, paginationOrderParameters); Assert.AreEqual(2, results.Count); Assert.AreEqual(3, paginationOrderParameters.Pagination.CountTotal); } [Test] public void GetAssocForAResponsable_with_pagination_page2_should_return_1assocs() { var paginationOrderParameters = new PaginationOrderParameters {Pagination =new Pagination{PageIndex = 1, PageSize = 2}}; var results = assocmgr.GetAssocForAResponsable(1972, paginationOrderParameters); Assert.AreEqual(1,results.Count); Assert.AreEqual(3,paginationOrderParameters.Pagination.CountTotal); } [Test] public void GetAssocForAResponsable_with_pagination_page2_should_return_1assoc_orderby_code_desc() { var paginationOrderParameters = new PaginationOrderParameters { Pagination = new Pagination { PageIndex = 1, PageSize = 2 }, OrderFields = new List<OrderField> {new OrderField{Field = "Code", Ascending = false}} }; var results = assocmgr.GetAssocForAResponsable(1972, paginationOrderParameters); Assert.AreEqual(1, results.Count); Assert.AreEqual("A01", results[0].Code); Assert.AreEqual(3, paginationOrderParameters.Pagination.CountTotal); } [Test] public void GetAssocForAResponsable_with_pagination_page1_should_return_1assoc_orderby_code_asc() { var paginationOrderParameters = new PaginationOrderParameters { Pagination = new Pagination { PageIndex = 0, PageSize = 2 }, OrderFields = new List<OrderField> { new OrderField { Field = "Code" } } }; var results = assocmgr.GetAssocForAResponsable(1972, paginationOrderParameters); Assert.AreEqual(2, results.Count); Assert.AreEqual("B9078", results[1].Code); Assert.AreEqual(3, paginationOrderParameters.Pagination.CountTotal); }
et hop ça fonctionne.
contexte Web
On pourra fabriquer un contrôle qui nous affichera la pagination côté utilisateur (avance, retour, affichage des n° pages) et gérera également le n° de page courant et le nombre de pages. Le nombre d'éléments, où tout est calculé à partir de celui-ci, est issu de paginationOrderParameters.Pagination.CountTotal, rempli lors de l'appel à la méthode.
On s'appuiera pour tout ça sur PagedDataSource.
Future et FutureValue
Les méthodes d'extension Future<T> ou FutureValue<T> permettent d'optimiser les allers et venues entre votre application et la base de données : plusieurs requêtes peuvent être envoyées en un seul appel, et idem pour le retour, tous les résultats seront ramenés en un seul retour. Dans notre exemple, cela va servir à ramener les associations ainsi que le nombre total en base en un seul aller-retour au lieu de 2.
En fait, cette fonctionnalité existait déjà dans les précédentes versions, plus connue sous le petit nom des MultiCriteria / MultiQueries, mais c'était moins bien fait. Je laisse Ayende l'expliquer bien mieux que moi.
Sources
Tout est dans le trunk , l'exemple se trouve sur AssocExemple.