Paginación usando cursores en GraphQL y Java

Escrito por el , actualizado el .
java planeta-codigo programacion
Enlace permanente Comentarios

GraphQL

El conjunto de datos de una entidad en algunos casos será grande, miles o cientos de miles de registros, y por tanto no es viable devolverlos todos en una misma consulta por lo que es necesario realizar paginación devolviéndolos en pequeños grupos. La paginación se puede implementar de varias formas, habitualmente con un parámetro que limite el número de elementos a incluir en la página y otro parámetro offset que deseche los primeros elementos hasta el primero deseado. Sin embargo, utilizar los parámetros limit y offset puede producir resultados inesperados si mientras la obtención de una página y la siguiente se insertan nuevos elementos anteriores al offset ocasionando que lo que sería por ejemplo el décimo elemento pase a ser el undécimo.

Si esta situación es importante se suelen utilizar cursores que utilizan un parámetro para indicar el número de elementos a incluir en la página pero en vez de un offset utilizan el identificativo de un registro a partir del cual devolver registros de modo que aunque se inserten registros el primer elemento de la página no cambiará.

En el caso de la paginación en GraphQL se proponen varias formas de implementar la paginación, una de ellas los cursores. En la documentación se explica la teoría, para implementarla es necesario crear un data fetcher o resolver que reciba los parámetros de limit para indicar el número de elementos a devolver en la página y after para indicar a partir de que elemento devolver elementos. También es necesario modificar el esquema de la API para tener en cuenta las nuevas estructuras de datos en las que se devuelven los resultados.

En este ejemplo de una librería para mostrar la paginación he añadido a los libros una lista de comentarios que será en la que soporte paginación. La definición del esquema queda de la siguiente forma siguiendo la especificación de Relay para lo cual se definen los tipos CommentsConnection, CommentEdge (usando generics no sería necesario implementar unas de estas clases por cada entidad paginable) y PageInfo. Los cursores son un dato opaco para el cliente pero que decodificado incluye el identificativo del comentario. La propiedad comments utiliza un resolver con parámetros que se usa para realizar la búsqueda y recuperar los elementos solicitados en la consulta.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
scalar Long

type Book {
    id: Long
    title: String
    author: Author
    isbn: String
    comments(after: String, limit: Long): CommentsConnection
}

type Comment {
    id: Long
    text: String
}

type Author {
    id: Long
    name: String
}

input BookFilter {
    title: String
}

type CommentsConnection {
    edges: [CommentEdge]
    pageInfo: PageInfo
}

type CommentEdge {
    node: Comment
    cursor: String
}

type PageInfo {
    startCursor: String
    endCursor: String
    hasNextPage: Boolean
}

type Query {
    books(filter: BookFilter): [Book]!
    book(id: Long): Book!
    authors: [Author]!
    author(id: Long): Author!
}

type Mutation {
    addBook(title: String, author: Long): Book
}

schema {
    query: Query
    mutation: Mutation
}
library.graphqls
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package io.github.picodotdev.blogbitix.graphql.type;

import io.github.picodotdev.blogbitix.graphql.type.CommentEdge;

import java.util.List;
import java.util.stream.Collectors;

public class CommentsConnection {

    private List<CommentEdge> edges;
    private PageInfo pageInfo;

    public CommentsConnection(List<Comment> edges, PageInfo pageInfo) {
        this.edges = edges.stream().map(o -> new CommentEdge(o)).collect(Collectors.toList());
        this.pageInfo = pageInfo;
    }

    public List<CommentEdge> getEdges() {
        return edges;
    }

    public PageInfo getPageInfo() {
        return pageInfo;
    }
}
CommentsConnection.java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package io.github.picodotdev.blogbitix.graphql.type;

import graphql.relay.Relay;

public class CommentEdge {

    private Comment comment;
    private String cursor;

    public CommentEdge(Comment comment) {
        this.comment = comment;
        this.cursor = toGlobalId(comment);
    }

    public Comment getNode() {
        return comment;
    }

    public String getCursor() {
        return cursor;
    }

    public static String toGlobalId(Comment comment) {
        return new Relay().toGlobalId(Comment.class.getName(), comment.getId().toString());
    }

    public static Long fromGlobalId(String cursor) {
        String id = new Relay().fromGlobalId(cursor).getId();
        return Long.parseLong(id);
    }
}
CommentEdge.java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package io.github.picodotdev.blogbitix.graphql.type;

public class PageInfo {

    private String startCursor;
    private String endCursor;
    private boolean hasNextPage;

    public PageInfo(String startCursor, String endCursor, boolean hasNextPage) {
        this.startCursor = startCursor;
        this.endCursor = endCursor;
        this.hasNextPage = hasNextPage;
    }

    public String getStartCursor() {
        return startCursor;
    }

    public String getEndCursor() {
        return endCursor;
    }

    public boolean isHasNextPage() {
        return hasNextPage;
    }
}
PageInfo.java

En el caso del ejemplo los datos se almacenan en unas listas creadas al iniciar la aplicación y la paginación y la obtención de los datos de la página se realiza usando streams y con código Java para implementar la lógica según los parámetros de la paginación en el método findComments. Si los datos estuvieran almacenados en una base de datos relacional o NoSQL se usarían las facilidades de sus lenguajes u operaciones de consulta como sería generar la sentencia SQL apropiada.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package io.github.picodotdev.blogbitix.graphql.repository;

...

public class LibraryRepository {

    private long sequence;
    private List<Book> books;
    private List<Comment> comments;
    private List<Author> authors;
    private List<Magazine> magazines;

    public LibraryRepository() {
        this.sequence = 0l;
        this.books = new ArrayList<>();
        this.comments = new ArrayList<>();
        this.authors = new ArrayList<>();
        this.magazines = new ArrayList<>();

        Author a1 = new Author(nextId(), "Philip K. Dick");
        Author a2 = new Author(nextId(), "George R. R. Martin");
        Author a3 = new Author(nextId(), "Umberto Eco");
        Author a4 = new Author(nextId(), "Andreas Eschbach");
        Author a5 = new Author(nextId(), "Ernest Cline");
        Author a6 = new Author(nextId(), "Anónimo");

        this.authors.addAll(List.of(a1, a2, a3, a4, a5, a6));

        LongStream.range(1, 10).forEach(i -> this.comments.add(new Comment(i,"Comment " + i)));

        this.books.addAll(
            List.of(
                new Book(nextId(), "Ojo en el cielo", a1, LocalDate.of(1957, 1, 1), this.comments),
                new Book(nextId(), "Muerte de la luz", a2, LocalDate.of(1977, 1, 1), this.comments),
                new Book(nextId(), "El nombre de la rosa", a3, LocalDate.of(1980, 1, 1), this.comments),
                new Book(nextId(), "Los tejedores de cabellos", a4, LocalDate.of(1995, 1, 1), this.comments),
                new Book(nextId(), "Ready Player One", a5, LocalDate.of(2011, 1, 1), this.comments)
            )
        );

        this.magazines.addAll(
            List.of(
                new Magazine(nextId(), "Muy interesante", 65L),
                new Magazine(nextId(), "PC Actual", 90L)
            )
        );
    }

    ...

    public List<Comment> findComments(Long idBook, Long idAfter, Long limit) {
        Book book = findBook(idBook);
        Stream<Comment> stream = book.getComments().stream();
        if (idAfter != null) {
            stream = stream.dropWhile(b -> idAfter != null && !b.getId().equals(idAfter)).skip(1);
        }
        if (limit != null) {
            stream = stream.limit(limit);
        }
        return stream.collect(Collectors.toList());
    }

    ...

    private long nextId() {
        return ++sequence;
    }
}
LibraryRepository.java

Los comentarios se obtienen usando un data fetcher o resolver que si existe para una determinada propiedad tiene precedencia sobre el data fetcher por defecto que en Java obtiene el dato usando la convención de los java beans. En este caso es el resolver BookResolver siendo el método getComments el encargado de recuperar los datos de la propiedad comments cuando se solicite en una consulta de GraphQL, recibe los datos de paginación y delega la búsqueda en el repositorio para que haga la consulta apropiada.

La clase repositorio abstrae al resolver de como o donde están guardados los datos, de esta forma se podría pasar de guardarlos en una base de datos PostgreSQL a una base de datos MongoDB sin que el resolver necesite ninguna modificación, también se podría optar por guardar los libros en una base de datos relacional y los comentarios en una base de datos MongoDB. El resolver se encarga de crear las instancias de objetos necesarios de los tipos CommentsConnection, CommentEdge y PageInfo para adaptarlos a las estructuras de datos apropiadas según la especificación de Relay en el servicio de GraphQL.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package io.github.picodotdev.blogbitix.graphql.resolver;

...

public class BookResolver implements GraphQLResolver<Book> {

    private LibraryRepository libraryRespository;

    public BookResolver(LibraryRepository libraryRespository) {
        this.libraryRespository = libraryRespository;
    }

    public String getIsbn(Book book) throws InterruptedException {
        System.out.printf("Getting ISBN %d...", book.getId());
        Thread.sleep(3000);
        System.out.printf("ok%n");
        return UUID.randomUUID().toString();
    }

    ...

    public CommentsConnection getComments(Book book, String after, Long limit) {
        Long idAfter = null;
        Long limitPlusOne = null;

        if (after != null) {
            idAfter = CommentEdge.fromGlobalId(after);
        }
        if (limit != null && limit < Long.MAX_VALUE) {
            limitPlusOne = limit + 1;
        }

        List<Comment> commentsPlusOne = libraryRespository.findComments(book.getId(), idAfter, limitPlusOne);
        Stream<Comment> stream = commentsPlusOne.stream();
        if (limit != null) {
            stream = stream.limit(limit);
        }
        List<Comment> comments = stream.collect(Collectors.toList());

        Comment firstComment = (!comments.isEmpty()) ? comments.get(0) : null;
        Comment lastComment = (!comments.isEmpty()) ? comments.get(comments.size() - 1) : null;

        String startCursor = (firstComment != null) ? CommentEdge.toGlobalId(firstComment) : null;
        String endCursor = (lastComment != null) ? CommentEdge.toGlobalId(lastComment) : null;

        boolean hasNextPage = commentsPlusOne.size() > comments.size();

        return new CommentsConnection(comments, new PageInfo(startCursor, endCursor, hasNextPage));
    }

    ...
}
BookResolver.java

Una vez implementada la paginación en los comentarios con la siguientes consultas se obtiene un libro usando una consulta con un filtro todos sus comentarios, los 3 primeros comentarios usando el parámetro limit y los siguientes tres comentarios a partir del tercero usando los parámetros limit y after. Obteniendo como respuesta un libro con únicamente los comentarios deseados. Cada elemento en el resultado contiene los datos solicitados junto con el valor del cursor que identifica al comentario además de incluir una estructura de datos pageInfo con información sobre la paginación.

Con el valor del cursor indicado en pageInfo en la propiedad endCursor se podría obtener la siguiente página de comentarios realizando otra consulta e indicándolo en el parámetro after.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
$ curl -XPOST -H "Content-Type: application/json" -d '{"query": "query Books{books(filter:{title:\"^Ready.*\"}){title comments{edges{node{text}cursor} pageInfo{startCursor endCursor hasNextPage}}}}"}' http://localhost:8080/graphql
{
  "data": {
    "books": [
      {
        "title": "Ready Player One",
        "comments": {
          "edges": [
            {
              "node": {
                "text": "Comment 1"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDox"
            },
            {
              "node": {
                "text": "Comment 2"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoy"
            },
            {
              "node": {
                "text": "Comment 3"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoz"
            },
            {
              "node": {
                "text": "Comment 4"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDo0"
            },
            {
              "node": {
                "text": "Comment 5"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDo1"
            },
            {
              "node": {
                "text": "Comment 6"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDo2"
            },
            {
              "node": {
                "text": "Comment 7"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDo3"
            },
            {
              "node": {
                "text": "Comment 8"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDo4"
            },
            {
              "node": {
                "text": "Comment 9"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDo5"
            }
          ],
          "pageInfo": {
            "startCursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDox",
            "endCursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDo5",
            "hasNextPage": false
          }
        }
      }
    ]
  }
}
curl-1.sh
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

$ curl -XPOST -H "Content-Type: application/json" -d '{"query": "query Books{books(filter:{title:\"^Ready.*\"}){title comments(limit:3){edges{node{text}cursor} pageInfo{startCursor endCursor hasNextPage}}}}"}' http://localhost:8080/graphql
{
  "data": {
    "books": [
      {
        "title": "Ready Player One",
        "comments": {
          "edges": [
            {
              "node": {
                "text": "Comment 1"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDox"
            },
            {
              "node": {
                "text": "Comment 2"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoy"
            },
            {
              "node": {
                "text": "Comment 3"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoz"
            }
          ],
          "pageInfo": {
            "startCursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDox",
            "endCursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoz",
            "hasNextPage": true
          }
        }
      }
    ]
  }
}
curl-2.sh
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
$ curl -XPOST -H "Content-Type: application/json" -d '{"query": "query Books{books(filter:{title:\"^Ready.*\"}){title comments(limit:3,after:\"aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoz\"){edges{node{text}cursor} pageInfo{startCursor endCursor hasNextPage}}}}"}' http://localhost:8080/graphql
{
  "data": {
    "books": [
      {
        "title": "Ready Player One",
        "comments": {
          "edges": [
            {
              "node": {
                "text": "Comment 4"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDo0"
            },
            {
              "node": {
                "text": "Comment 5"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDo1"
            },
            {
              "node": {
                "text": "Comment 6"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDo2"
            }
          ],
          "pageInfo": {
            "startCursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDo0",
            "endCursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDo2",
            "hasNextPage": true
          }
        }
      }
    ]
  }
}
curl-3.sh

Los cursores tiene la ventaja de que son opacos por lo que se evita que los clientes dependan de identificativos y podrían cambiarse sin que los clientes necesitasen modificaciones. Otra ventaja es que la especificación de Relay propone un marco y unas convenciones para estandarizar la paginación. Sin embargo, esta solo es una forma de hacer paginación y es perfectamente posible usar cualquier otra para adaptarla a las necesidades que haya por ejemplo añadiendo más datos a pageInfo o con más u otros parámetros para realizar la consulta de paginación.

Pero… en este ejemplo por cada libro que se devuelve como resultado en la consulta se realiza una búsqueda de los comentarios ya que las propiedades de resultado en la consulta se recuperan una a una. Para la mayoría de propiedades esto no es problema ya que son propiedades que están en un objeto que no realizan consultas a una base de datos pero en el caso de los comentarios sí. Si se devolviesen muchos libros se realizaría una consulta para cada uno de ellos lo que no es eficiente. Si se devolviesen 500 libros y sus comentarios se realizarían 500 consultas para obtener los comentarios de cada libro, usando una base de datos relacional serían 1+500 consultas SQL por cada petición a GraphQL, 1 para obtener los libros y 500 para los comentarios. Como solución a este problema está la funcionalidad de batching de GraphQL que permite obtener todos los comentarios de los libros en una única consulta, será tema para otro de los siguientes artículos de esta serie sobre GraphQL.

Terminal

El código fuente completo del ejemplo puedes descargarlo del repositorio de ejemplos de Blog Bitix alojado en GitHub y probarlo en tu equipo ejecutando siguiente comando:
./gradlew run


Comparte el artículo: