Introduction
De nombreuses applications Web impliquent l’affichage de listes d’éléments. Il peut s’agir de votre panier d’achats, des articles d’un blog, des factures de votre ERP ou des actions de l’Euro STOXX 600, par exemple. Pour des raisons de performances, l’application gérant l’affichage (le frontend) ne devrait pas effectuer le rendu de tous les éléments disponibles, car ils peuvent être potentiellement nombreux: le STOXX 600 est, par définition, toujours constitué de 600 éléments. Dans d’autres circonstances, c’est encore pire: le nombre de factures gérées par votre ERP est potentiellement illimité.
Une première étape consiste, pour le frontend, à paginer les résultats reçus: quel que soit le nombre de ces résultats, on les regroupe en « pages » de 20 entrées et on n’affiche jamais qu’une page à la fois. Des boutons permettent à l’utilisateur de choisir la page à afficher. Ainsi, le contenu affiché reste toujours d’une taille contenue.
L’étape suivante consiste à faire en sorte que le backend pagine lui aussi ses résultats: plutôt que de toujours retourner l’ensemble des éléments lorsque le frontend les lui demande, le backend n’en retourne qu’une page, et permet de demander n’importe quelle page. La charge entre le backend et le frontend, ainsi que le traitement réalisé par ce dernier, sont ainsi toujours contenus.
Dans cet article, nous nous intéressons à une implémentation d’une réponse paginée par le backend en utilisant Slick. Nous prendrons comme exemple la récupération d’employés appartenant à un département donné.
Plantons le décors
En Slick, les tables sont représentées par des classes en Scala. Créons celle nécessaire à notre exemple. (Celle relative aux départements est ici omise.)
case class Employee(id: EmployeeId, firstName: String, lastName: String, departmentId: DepartmentId)
class Employees(tag: Tag) extends Table[Employee](tag, "my_schema", "employee") {
def id = column[EmployeeId]("id", O.PrimaryKey)
def firstName = column[String]("first_name")
def lastName = column[String]("last_name")
def departmentId = column[DepartmentId]("department_id")
def department = foreignKey("employee_department_fk", departmentId, departments)(_.id, onUpdate=ForeignKeyAction.Restrict, onDelete=ForeignKeyAction.Cascade)
def * = (id, firstName, lastName, departmentId) <> (Employee.tupled, Employee.unapply)
}
type EmployeeId = UUID
val employees = TableQuery[Employees]
Sans pagination
Commençons par récupérer des valeurs sans nous préoccuper de la pagination: nous voulons récupérer toutes les valeurs, et les retourner toutes en une fois.
class EmployeeRepository(db: Database) {
def getByDepartmentId(departmentId: GroupId): Future[Seq[Employee]] = {
val query = employees.filter(_.departmentId === departmentId)
db.run(query.result)
}
}
Rien de bien sorcier jusqu’ici: nous créons une classe de type Repository qui sert à décrire l’ensemble des manières d’accéder à notre table. Et, en particulier, nous avons une méthode qui retourne tous les employés relevant d’un département dont l’identifiant est passé en paramètre. Le contrôleur n’a alors plus qu’à retourner une représentation JSON de la liste d’employés, mais cela sort du cadre de cet article.
Pagination
Nous entrons dans le vif du sujet, et souhaitons proposer une version paginée de la méthode que nous venons d’implémenter afin de ne retourner que les employés correspondant à une page donnée. Cela peut se faire en utilisant des méthodes qui font passer la table pour une simple collection Scala:
class EmployeeRepository(db: Database) {
private val resultsPerPage: Int = 20
def getByDepartmentId(departmentId: GroupId, page: Int): Future[Seq[Employee]] = {
val query = employees
.filter(_.departmentId === departmentId)
.drop(page-1 * resultsPerPage)
.take(resultsPerPage)
db.run(query.result)
}
}
La méthode drop permet d’ignorer les résultats correspondant aux pages précédant celle qui nous intéresse, tandis que la méthode take permet de ne retourner qu’un certain nombre de résultats.
C’est un début, mais c’est un peu sec: on souhaite généralement également connaître le nombre total d’enregistrement, ne serait-ce que pour indiquer à l’utilisateur combien de pages sont disponibles en tout. Ajoutons cette information, et stockons le résultat dans une case classe dédiée:
case class PaginatedResult[T](nbPages: Int, currentPage: Int, elements: Seq[T])
class EmployeeRepository(db: Database) {
private val resultsPerPage: Int = 20
def getByDepartmentId(departmentId: GroupId, page: Int): Future[PaginatedResult[Employee]] = {
val baseQuery = employees
.filter(_.departmentId === departmentId)
val pageQuery = baseQuery
.drop(page-1 * resultsPerPage)
.take(resultsPerPage)
val totalCount: Future[Int] = db.run(baseQuery.lenght.result)
val results: Future[Seq[Employee]] = db.run(pagequery.result)
(totalCount zip results) match {
case (count, elements) => {
val nbPages = Math.ceilDiv(count, resultsPerPage)
PaginatedResult(nbPages, page, elements)
}
}
}
À présent, la méthode une instance de PaginatedResult contenant les informations souhaitées. À bien y regarder, peu d’éléments sont propres aux employés: seule la requête de base est vraiment spécifique, le reste sera identique quelle que soit la nature des éléments retournés, ou même les conditions de filtrage. Rendons ce code un peu plus générique en introduisant un Paginator, dont le rôle est d’encapsuler ce comportement commun.
trait Paginator {
private val resultsPerPage: Int = 20
def paginate[T](baseQuery: Query[Table[T], T, Seq], db: Database, page: Int)(implicit ec: ExecutionContext): Future[PaginatedResult[T]] = {
val paginatedQuery = baseQuery.drop((page-1)*resultsPerPage).take(resultsPerPage)
val totalCount: Future[Int] = db.run(baseQuery.length.result)
val results: Future[Seq[T]] = db.run(paginatedQuery.result)
(totalCount zip results) map {
case (count, elements) => PaginatedResult(Math.ceilDiv(count, pageSize), page, elements)
}
}
}
Ce trait se moque de la requête définissant les éléments d’intérêt: elle se contente de retourner une vue paginée de cette requête.
Notez l’usage d’un trait: il peut être l’objet d’une extension de n’importe quelle classe, ce qui nous permet de l’utiliser dans nos Repository. Réécrivons EmployeeRepository pour en profiter:
class EmployeeRepository(db: Database) extends Paginator[Employee] {
def getByDepartmentId(departmentId: GroupId, page: Int): Future[PaginatedResult[Employee]] = {
val baseQuery = employees
.filter(_.departmentId === departmentId)
paginate(baseQuery, db, page)
}
Conclusion
La pagination est très fréquemment utilisée dès qu’il s’agit de retourner de grandes listes d’éléments provenant d’une base de données. Nous avons montré comment profiter de cette généricité en Slick, en créant une classe représentant une page de résultat, en créant un trait capable de paginer les résultats de n’importe quelle requête en base de données, puis en exploitant ce trait dans notre Repository d’employés.
Dorénavant, nous pouvons imaginer d’autres requêtes, aussi complexes soient-elles, et bénéficier d’une pagination de ses résultats en réutilisant simplement le trait implémenté.