Filtro, ordenamiento y paginacion

En esta sección nos centraremos en desarrollar la funcionalidad para un nuevo punto final GET /v1/movies, que devolverá los detalles de varias películas en un array JSON.

MethodURL PatternHandlerAction
GET/v1/healthcheckhealthcheckHandlerShow application information
GET/v1/movieslistMoviesHandlerShow the details of all movies
POST/v1/moviescreateMovieHandlerCreate a new movie
GET/v1/movies/:idshowMovieHandlerShow the details of a specific movie
PATCH/v1/movies/:idupdateMovieHandlerUpdate the details of a specific movie
DELETE/v1/movies/:iddeleteMovieHandlerDelete a specific movie

Desarrollaremos la funcionalidad para este punto final de manera incremental, comenzando por devolver datos de todas las películas y luego mejorándolo gradualmente para hacerlo más útil y fácil de usar mediante la adición de funcionalidades de filtrado, ordenamiento y paginación.

En esta sección aprenderás cómo:

  1. Devolver los detalles de múltiples recursos en una única respuesta JSON.
  2. Aceptar y aplicar parámetros de filtro opcionales para reducir el conjunto de datos devuelto.
  3. Implementar la búsqueda de texto completo en los campos de tu base de datos utilizando la funcionalidad incorporada de PostgreSQL.
  4. Aceptar y aplicar de manera segura parámetros de ordenamiento para cambiar el orden de los resultados en el conjunto de datos.
  5. Desarrollar un enfoque pragmático y reutilizable para admitir la paginación en conjuntos de datos grandes y devolver metadatos de paginación en tus respuestas JSON.

9.1 Parseando query string parameters

En los próximos capítulos, vamos a configurar el punto final GET /v1/movies para que un cliente pueda controlar qué registros de películas se devuelven mediante parámetros de cadena de consulta. Por ejemplo:

/v1/movies?title=godfather&genres=crime,drama&page=1&page_size=5&sort=-year

Si un cliente envía una cadena de consulta como esta, básicamente le está indicando a nuestra API: “por favor, devuelve los primeros 5 registros donde el nombre de la película incluya ‘El Padrino’ y los géneros incluyan crimen y drama, ordenados por año de lanzamiento de forma descendente”.

Nota: En el parámetro de ordenación, utilizaremos el carácter - para indicar el orden descendente. Por ejemplo, el parámetro sort=title implica una ordenación alfabética ascendente por título de película, mientras que sort=-title implica una ordenación descendente.

Lo primero que vamos a analizar es cómo analizar estos parámetros de cadena de consulta en nuestro código Go.

Como espero que recuerdes de Let’s Go, podemos recuperar los datos de la cadena de consulta de una solicitud llamando al método r.URL.Query(). Esto devuelve un tipo url.Values, que es básicamente un mapa que contiene los datos de la cadena de consulta.

Luego, podemos extraer valores de este mapa usando el método Get(), que devolverá el valor para una clave específica como tipo string, o la cadena vacía "" si no hay una clave coincidente en la cadena de consulta.

En nuestro caso, también necesitaremos realizar un procesamiento adicional de algunos de estos valores de la cadena de consulta. Específicamente:

El parámetro genres potencialmente contendrá múltiples valores separados por comas, como genres=crimen,drama. Querremos separar estos valores y almacenarlos en un slice []string.

Los parámetros page y page_size contendrán números, y querremos convertir estos valores de la cadena de consulta en tipos int de Go.

Ademas de esto:

  • Hay algunas validaciones que querremos aplicar a los valores de la cadena de consulta, como asegurarnos de que tanto “page” como “page_size” no sean números negativos.
  • También queremos que nuestra aplicación establezca algunos valores predeterminados sensatos en caso de que los parámetros como “page”, “page_size” y “sort” no sean proporcionados por el cliente.

9.1 Creando funciones de ayuda

Para ayudar con esto, vamos a crear tres nuevas funciones auxiliares: readString(), readInt() y readCSV(). Utilizaremos estas funciones auxiliares para extraer y analizar valores de la cadena de consulta, o devolver un valor predeterminado ‘fallback’ si es necesario.

Dirígete al archivo cmd/api/helpers.go y agrega el siguiente código:

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"

	"github.com/julienschmidt/httprouter"
	"github.com/nahuelev23/greenlight/internal/validator"
)
...


// The readString() helper returns a string value from the query string, or the provided
// default value if no matching key could be found.
func (app *application) readString(qs url.Values, key string, defaultValue string) string {
	// Extract the value for a given key from the query string. If no key exists this
	// will return the empty string "".
	s := qs.Get(key)
	// If no key exists (or the value is empty) then return the default value.
	if s == "" {
		return defaultValue
	}
	// Otherwise return the string.
	return s
}

// The readCSV() helper reads a string value from the query string and then splits it
// into a slice on the comma character. If no matching key could be found, it returns
// the provided default value.
func (app *application) readCSV(qs url.Values, key string, defaultValue []string) []string {
	// Extract the value from the query string.
	csv := qs.Get(key)
	// If no key exists (or the value is empty) then return the default value.
	if csv == "" {
		return defaultValue
	}
	// Otherwise parse the value into a []string slice and return it.
	return strings.Split(csv, ",")
}

// The readInt() helper reads a string value from the query string and converts it to an
// integer before returning. If no matching key could be found it returns the provided
// default value. If the value couldn't be converted to an integer, then we record an
// error message in the provided Validator instance.
func (app *application) readInt(qs url.Values, key string, defaultValue int, v *validator.Validator) int {
	// Extract the value from the query string.
	s := qs.Get(key)
	// If no key exists (or the value is empty) then return the default value.
	if s == "" {
		return defaultValue
	}
	// Try to convert the value to an int. If this fails, add an error message to the
	// validator instance and return the default value.
	i, err := strconv.Atoi(s)
	if err != nil {
		v.AddError(key, "must be an integer value")
		return defaultValue
	}
	// Otherwise, return the converted integer value.
	return i
}

9.1 Agregando el API handler y la ruta

A continuación, creemos un nuevo controlador listMoviesHandler para nuestro punto final GET /v1/movies. Por ahora, este controlador simplemente analizará la cadena de consulta de la solicitud utilizando las funciones auxiliares que acabamos de crear y luego mostrará el contenido en una respuesta HTTP.

Si estás siguiendo el desarrollo, continúa y crea el listMoviesHandler de la siguiente manera:

func (app *application) listMoviesHandler(w http.ResponseWriter, r *http.Request) {
	// To keep things consistent with our other handlers, we'll define an input struct
	// to hold the expected values from the request query string.
	var input struct {
		Title    string
		Genres   []string
		Page     int
		PageSize int
		Sort     string
	}
	// Initialize a new Validator instance.
	v := validator.New()
	// Call r.URL.Query() to get the url.Values map containing the query string data.
	qs := r.URL.Query()
	// Use our helpers to extract the title and genres query string values, falling back
	// to defaults of an empty string and an empty slice respectively if they are not
	// provided by the client.
	input.Title = app.readString(qs, "title", "")
	input.Genres = app.readCSV(qs, "genres", []string{})
	// Get the page and page_size query string values as integers. Notice that we set
	// the default page value to 1 and default page_size to 20, and that we pass the
	// validator instance as the final argument here.
	input.Page = app.readInt(qs, "page", 1, v)
	input.PageSize = app.readInt(qs, "page_size", 20, v)
	// Extract the sort query string value, falling back to "id" if it is not provided
	// by the client (which will imply a ascending sort on movie ID).
	input.Sort = app.readString(qs, "sort", "id")
	// Check the Validator instance for any errors and use the failedValidationResponse()
	// helper to send the client a response if necessary.
	if !v.Valid() {
		app.failedValidationResponse(w, r, v.Errors)
		return
	}
	// Dump the contents of the input struct in a HTTP response.
	fmt.Fprintf(w, "%+v\n", input)
}

Luego, necesitamos crear la ruta GET /v1/movies en nuestro archivo cmd/api/routes.go, de la siguiente manera:

func (app *application) routes() http.Handler {
	router := httprouter.New()

	router.NotFound = http.HandlerFunc(app.notFoundResponse)

	router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)

	router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)

	// Add the route for the GET /v1/movies endpoint.
	router.HandlerFunc(http.MethodGet, "/v1/movies", app.listMoviesHandler)
	router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler)
	router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler)
	router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.updateMovieHandler)
	router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler)

	return app.recoverPanic(router)
}

¡Y con eso, ahora estamos listos para ver esto en acción! Adelante, intenta enviar una solicitud al punto final GET /v1/movies que contenga los parámetros de cadena de consulta esperados, como se muestra a continuación.

Importante: al utilizar curl para enviar una solicitud que contenga más de un parámetro de cadena de consulta, debes encerrar la URL entre comillas para que funcione correctamente.

$ curl "localhost:4000/v1/movies?title=godfather&genres=crime,drama&page=1&page_size=5&sort=year"
{Title:godfather Genres:[crime drama] Page:1 PageSize:5 Sort:year

Eso parece estar bien: podemos ver que los valores proporcionados en nuestra cadena de consulta se han analizado correctamente y se incluyen en la estructura de entrada. Si lo deseas, también puedes intentar hacer una solicitud sin parámetros en la cadena de consulta. En este caso, deberías ver que los valores en la estructura de entrada toman los valores predeterminados que especificamos en nuestro código listMoviesHandler. Así es como se vería:

$ curl localhost:4000/v1/movies
{Title: Genres:[] Page:1 PageSize:20 Sort:id}

9.1 Creando una estructura de filtros

Los parámetros de cadena de consulta page, page_size y sort son elementos que potencialmente querrás usar en otros puntos finales de tu API también. Entonces, para facilitar esto, rápidamente vamos a separarlos en una estructura reutilizable llamada Filters.

Si estás siguiendo estos pasos, adelante y crea un nuevo archivo llamado filters.go dentro del directorio internal/data.

$ touch internal/data/filters.go

Agregamos los siguiente

package data

type Filters struct {
	Page     int
	PageSize int
	Sort     string
}

Una vez que hayas terminado eso, regresa a tu listMoviesHandler y actualízalo para que utilice la nueva estructura Filters de la siguiente manera:

func (app *application) listMoviesHandler(w http.ResponseWriter, r *http.Request) {

// Embed the new Filters struct
  var input struct {
  		Title    string
  		Genres   []string
-  		Page     int
-  		PageSize int
-  		Sort     string
+     data.Filters
	}

	v := validator.New()

	qs := r.URL.Query()

	input.Title = app.readString(qs, "title", "")
	input.Genres = app.readCSV(qs, "genres", []string{})

	// Read the page and page_size query string values into the embedded struct.
	input.Filters.Page = app.readInt(qs, "page", 1, v)
	input.Filters.PageSize = app.readInt(qs, "page_size", 20, v)

	// Read the sort query string value into the embedded struct.
	input.Filters.Sort = app.readString(qs, "sort", "id")

	if !v.Valid() {
		app.failedValidationResponse(w, r, v.Errors)
		return
	}

	fmt.Fprintf(w, "%+v\n", input)
}

En este punto, deberías poder ejecutar la API nuevamente y todo debería seguir funcionando como antes.

9.2 Validando parametros del query string

Gracias al ayudante readInt() que creamos en el capítulo anterior, nuestra API debería estar devolviendo errores de validación si los parámetros de cadena de consulta page y page_size no contienen valores enteros. Adelante y pruébalo, si lo deseas:

$ curl "localhost:4000/v1/movies?page=abc&page_size=abc"
{
  "error": {
    "page": "debe ser un valor entero",
    "page_size": "debe ser un valor entero"
  }
}

Pero aún necesitamos realizar algunas comprobaciones adicionales en los valores de la cadena de consulta proporcionados por el cliente. En particular, queremos verificar que:

  • El valor de la página esté entre 1 y 10,000,000.
  • El valor de page_size esté entre 1 y 100.
  • El parámetro de ordenación (sort) contenga un valor conocido y admitido para nuestra tabla de películas. Específicamente, permitiremos “id”, “title”, “year”, “runtime”, “-id”, “-title”, “-year” o “-runtime”.

Para solucionar esto, abramos el archivo internal/data/filters.go y creemos una nueva función ValidateFilters() que realice estas verificaciones en los valores. Seguiremos el mismo patrón que utilizamos para la función ValidateMovie() anteriormente para hacer esto, de la siguiente manera:

package data

import "github.com/nahuelev23/greenlight/internal/validator"

// Add a SortSafelist field to hold the supported sort values.
type Filters struct {
	Page         int
	PageSize     int
	Sort         string
	SortSafelist []string
}

func ValidateFilters(v *validator.Validator, f Filters) {
	// Check that the page and page_size parameters contain sensible values.
	v.Check(f.Page > 0, "page", "must be greater than zero")
	v.Check(f.Page <= 10_000_000, "page", "must be a maximum of 10 million")
	v.Check(f.PageSize > 0, "page_size", "must be greater than zero")
	v.Check(f.PageSize <= 100, "page_size", "must be a maximum of 100")
	// Check that the sort parameter matches a value in the safelist.
	v.Check(validator.PermittedValue(f.Sort, f.SortSafelist...), "sort", "invalid sort value")
}

Luego, necesitamos actualizar nuestro listMoviesHandler para establecer los valores admitidos en el campo SortSafelist y, posteriormente, llamar a esta nueva función ValidateFilters().

func (app *application) listMoviesHandler(w http.ResponseWriter, r *http.Request) {

	// Embed the new Filters struct
	var input struct {
		Title  string
		Genres []string
		data.Filters
	}

	v := validator.New()

	qs := r.URL.Query()

	input.Title = app.readString(qs, "title", "")
	input.Genres = app.readCSV(qs, "genres", []string{})

	input.Filters.Page = app.readInt(qs, "page", 1, v)
	input.Filters.PageSize = app.readInt(qs, "page_size", 20, v)

	input.Filters.Sort = app.readString(qs, "sort", "id")
	// Add the supported sort values for this endpoint to the sort safelist.
	input.Filters.SortSafelist = []string{"id", "title", "year", "runtime", "-id", "-title", "-year", "-runtime"}

	// Execute the validation checks on the Filters struct and send a response
	// containing the errors if necessary.
	if data.ValidateFilters(v, input.Filters); !v.Valid() {
		app.failedValidationResponse(w, r, v.Errors)
		return

	}

	fmt.Fprintf(w, "%+v\n", input)
}

Si reinicias la API y intentas hacer una solicitud con algunos parámetros de página, page_size y sort no válidos, ahora deberías recibir una respuesta de error que contiene los mensajes de fallo de validación relevantes. Similar a esto:

$ curl "localhost:4000/v1/movies?page=-1&page_size=-1&sort=foo"
{
  "error": {
    "page": "must be greater than zero",
    "page_size": "must be greater than zero",
    "sort": "invalid sort value"
  }
}

9.3 Listando data

De acuerdo, continuemos y hagamos que nuestro punto final GET /v1/movies devuelva algunos datos reales. Por ahora, ignoraremos cualquier valor de cadena de consulta proporcionado por el cliente y devolveremos todos los registros de películas, ordenados por el ID de la película. Esto nos proporcionará una base sólida desde la cual podemos desarrollar funcionalidades más especializadas relacionadas con filtrado, ordenación y paginación.

Nuestro objetivo en este capítulo será hacer que el punto final devuelva una respuesta JSON que contenga una matriz de todas las películas, similar a esto:

{
  "movies": [
    {
      "id": 1,
      "title": "Moana",
      "year": 2015,
      "runtime": "107 mins",
      "genres": [
        "animation",
        "adventure"
      ],
      "version": 1
    },
    {
      "id": 2,
      "title": "Black Panther",
      "year": 2018,
      "runtime": "134 mins",
      "genres": [
        "sci-fi",
        "action",
        "adventure"
      ],
      "version": 2
    },
    ... etc.
  ]
}

9.3 Actualizando la aplicación

Para recuperar estos datos de nuestra base de datos PostgreSQL, creemos un nuevo método GetAll() en nuestro modelo de base de datos que ejecute la siguiente consulta SQL:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
ORDER BY id

Dado que esperamos que esta consulta SQL devuelva varios registros, necesitaremos ejecutarla utilizando el método QueryContext() de Go. Ya explicamos cómo funciona esto en detalle en Let’s Go, así que vayamos al código:

// Create a new GetAll() method which returns a slice of movies. Although we're not
// using them right now, we've set this up to accept the various filter parameters as
// arguments.
func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*Movie, error) {
	// Construct the SQL query to retrieve all movie records.
	query := `
	SELECT id, created_at, title, year, runtime, genres, version
	FROM movies
	ORDER BY id`
	// Create a context with a 3-second timeout.
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	// Use QueryContext() to execute the query. This returns a sql.Rows resultset
	// containing the result.
	rows, err := m.DB.QueryContext(ctx, query)
	if err != nil {
		return nil, err
	}
	// Importantly, defer a call to rows.Close() to ensure that the resultset is closed
	// before GetAll() returns.
	defer rows.Close()
	// Initialize an empty slice to hold the movie data.
	movies := []*Movie{}
	// Use rows.Next to iterate through the rows in the resultset.
	for rows.Next() {
		// Initialize an empty Movie struct to hold the data for an individual movie.
		var movie Movie
		// Scan the values from the row into the Movie struct. Again, note that we're
		// using the pq.Array() adapter on the genres field here.
		err := rows.Scan(
			&movie.ID,
			&movie.CreatedAt,
			&movie.Title,
			&movie.Year,
			&movie.Runtime,
			pq.Array(&movie.Genres),
			&movie.Version,
		)
		if err != nil {
			return nil, err
		}
		// Add the Movie struct to the slice.
		movies = append(movies, &movie)
	}
	// When the rows.Next() loop has finished, call rows.Err() to retrieve any error
	// that was encountered during the iteration.
	if err = rows.Err(); err != nil {
		return nil, err
	}
	// If everything went OK, then return the slice of movies.
	return movies, nil
}

A continuación, necesitamos adaptar el listMoviesHandler para que llame al nuevo método GetAll() para recuperar los datos de las películas y luego escribir estos datos como una respuesta JSON.

Si estás siguiendo el desarrollo, continúa y actualiza el controlador de la siguiente manera:

func (app *application) listMoviesHandler(w http.ResponseWriter, r *http.Request) {

	// Embed the new Filters struct
	var input struct {
		Title  string
		Genres []string
		data.Filters
	}

	v := validator.New()

	qs := r.URL.Query()

	input.Title = app.readString(qs, "title", "")
	input.Genres = app.readCSV(qs, "genres", []string{})

	input.Filters.Page = app.readInt(qs, "page", 1, v)
	input.Filters.PageSize = app.readInt(qs, "page_size", 20, v)

	input.Filters.Sort = app.readString(qs, "sort", "id")
	input.Filters.SortSafelist = []string{"id", "title", "year", "runtime", "-id", "-title", "-year", "-runtime"}

	if data.ValidateFilters(v, input.Filters); !v.Valid() {
		app.failedValidationResponse(w, r, v.Errors)
		return

	}

	// Call the GetAll() method to retrieve the movies, passing in the various filter
	// parameters.
	movies, err := app.models.Movies.GetAll(input.Title, input.Genres, input.Filters)
	if err != nil {
		app.serverErrorResponse(w, r, err)
		return
	}
	// Send a JSON response containing the movie data.
	err = app.writeJSON(w, http.StatusOK, envelope{"movies": movies}, nil)
	if err != nil {
		app.serverErrorResponse(w, r, err)
	}
}

Y ahora deberíamos estar listos para probar esto. Adelante, reinicia la API y cuando hagas una solicitud GET /v1/movies, deberías ver la matriz de películas devuelta por GetAll() representada como una matriz JSON. Similar a esto:

$ curl localhost:4000/v1/movies
{
  "movies": [
    {
      "id": 1,
      "title": "Moana",
      "year": 2016,
      "runtime": "107 mins",
      "genres": [
        "animation",
        "adventure"
      ],
      "version": 1
    },
    {
      "id": 2,
      "title": "Black Panther",
      "year": 2018,
      "runtime": "134 mins",
      "genres": [
        "sci-fi",
        "action",
        "adventure"
      ],
      "version": 2
    },
    {
      "id": 4,
      "title": "The Breakfast Club",
      "year": 1985,
      "runtime": "97 mins",
      "genres": [
        "comedy"
      ],
      "version": 5
    }
  ]
}

9.4 Filtrando listas

En este capítulo vamos a empezar a utilizar nuestros parámetros de cadena de consulta, para que los clientes puedan buscar películas con un título o género específico.

Específicamente, construiremos un filtro reductivo que permita a los clientes buscar según una coincidencia exacta sin distinguir mayúsculas y minúsculas para el título de la película y/o uno o más géneros de películas. Por ejemplo:

// List all movies.
/v1/movies
// List movies where the title is a case-insensitive exact match for &#39;black panther&#39;.
/v1/movies?title=black+panther
// List movies where the genres includes &#39;adventure&#39;.
/v1/movies?genres=adventure
// List movies where the title is a case-insensitive exact match for &#39;moana&#39; AND the
// genres include both &#39;animation&#39; AND &#39;adventure&#39;.
/v1/movies?title=moana&amp;genres=animation,adventure

Nota: El símbolo + en las cadenas de consulta anteriores es un carácter de espacio codificado para URL. Alternativamente, podrías usar %20 en su lugar… ambos funcionarán en el contexto de una cadena de consulta.

9.4 Filtrando dinamicamente en la consulta SQL

La parte más difícil de construir una función de filtrado dinámico como esta es la consulta SQL para recuperar los datos: necesitamos que funcione sin filtros, con filtros en ambos títulos y géneros, o con un filtro solo en uno de ellos.

Para lidiar con esto, una opción es construir dinámicamente la consulta SQL en tiempo de ejecución, con el SQL necesario para cada filtro concatenado o interpolado en la cláusula WHERE. Sin embargo, este enfoque puede hacer que tu código sea desordenado y difícil de entender, especialmente para consultas extensas que necesitan admitir muchas opciones de filtro.

Optaremos por una técnica diferente y utilizaremos una consulta SQL fija que se ve así:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (LOWER(title) = LOWER($1) OR $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY id

Esta consulta SQL está diseñada para que cada uno de los filtros se comporte como si fuera ‘opcional’. Por ejemplo, la condición (LOWER(title) = LOWER($1) OR $1 = '') se evaluará como verdadera si el parámetro de marcador de posición $1 es una coincidencia insensible a mayúsculas y minúsculas para el título de la película o si el parámetro de marcador de posición es igual a ”. Por lo tanto, esta condición de filtro será básicamente ‘omitida’ cuando el título de la película que se está buscando sea la cadena vacía "".

La condición (genres @> $2 OR $2 = '{}') funciona de la misma manera. El símbolo @> es el operador ‘contain’ para matrices en PostgreSQL, y esta condición devolverá verdadero si cada valor en el parámetro de marcador de posición $2 aparece en el campo de géneros de la base de datos o si el parámetro de marcador de posición contiene una matriz vacía.

Recordarás que anteriormente configuramos nuestro listMoviesHandler para que la cadena vacía "" y una lista vacía se utilicen como valores predeterminados para los parámetros de filtro de título y géneros:

input.Title = app.readString(qs, "title", "")
input.Genres = app.readCSV(qs, "genres", []string{})

Entonces, poniéndolo todo junto, significa que si un cliente no proporciona un parámetro de título en su cadena de consulta, entonces el valor para el marcador de posición $1 será la cadena vacía "", y la condición de filtro en la consulta SQL se evaluará como verdadera y actuará como si se hubiera “omitido”. Lo mismo ocurre con el parámetro de géneros.

Nota: PostgreSQL también ofrece una variedad de otros operadores y funciones útiles para arrays, que incluyen el operador && de ‘superposición’, el operador <@ de ‘contenencia’ y la función array_length(). Puedes encontrar una lista completa aquí.

Volvamos al archivo internal/data/movies.go y actualicemos el método GetAll() para utilizar esta nueva consulta. De la siguiente manera:

  func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*Movie, error) {
+  		// Update the SQL query to include the filter conditions.
+  		query := `
+  		SELECT id, created_at, title, year, runtime, genres, version
+  		FROM movies
+  		WHERE (LOWER(title) = LOWER($1) OR $1 = '')
+  		AND (genres @> $2 OR $2 = '{}')
+  		ORDER BY id`

  	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  	defer cancel()

+  	rows, err := m.DB.QueryContext(ctx, query,title,pq.Array(genres))
  	if err != nil {
  		return nil, err
  	}

  	defer rows.Close()

  	movies := []*Movie{}

  	for rows.Next() {

  		var movie Movie

  		err := rows.Scan(
  			&movie.ID,
  			&movie.CreatedAt,
  			&movie.Title,
  			&movie.Year,
  			&movie.Runtime,
  			pq.Array(&movie.Genres),
  			&movie.Version,
  		)
  		if err != nil {
  			return nil, err
  		}

  		movies = append(movies, &movie)
  	}

  	if err = rows.Err(); err != nil {
  		return nil, err
  	}

  	return movies, nil
  }

Ahora reiniciemos la aplicación y probemos esto, utilizando los ejemplos que proporcionamos al principio del capítulo. Si has estado siguiendo, las respuestas deberían lucir algo así:

$ curl "localhost:4000/v1/movies?title=black+panther"
{
	"movies": [
	{
		"id": 2,
		"title": "Black Panther",
		"year": 2018,
		"runtime": "134 mins",
		"genres": [
			"sci-fi",
			"action",
			"adventure"
		],
		"version": 2
		}
	]
}
$ curl "localhost:4000/v1/movies?genres=adventure"
{
  "movies": [
    {
      "id": 1,
      "title": "Moana",
      "year": 2015,
      "runtime": "107 mins",
      "genres": [
        "animation",
        "adventure"
      ],
      "version": 1
    },
    {
      "id": 2,
      "title": "Black Panther",
      "year": 2018,
      "runtime": "134 mins",
      "genres": [
        "sci-fi",
        "action",
        "adventure"
      ],
      "version": 2
    }
  ]
}
$ curl "localhost:4000/v1/movies?title=moana&genres=animation,adventure"
{
  "movies": [
    {
      "id": 1,
      "title": "Moana",
      "year": 2016,
      "runtime": "107 mins",
      "genres": [
        "animation",
        "adventure"
      ],
      "version": 1
    }
  ]
}

También puedes intentar realizar una solicitud con un filtro que no coincida con ningún registro. En este caso, deberías obtener una matriz JSON vacía en la respuesta, como se muestra a continuación:

$ curl "localhost:4000/v1/movies?genres=western"
{
  "movies": []
}

Esto se está desarrollando bien. Nuestro punto de conexión de la API ahora devuelve los registros de películas filtrados de manera adecuada, y tenemos un patrón que podemos ampliar fácilmente para incluir otras reglas de filtrado en el futuro (como un filtro en el año o la duración de la película) si así lo deseamos.

9.5 Busqueda Full-text

En este capítulo vamos a facilitar el uso de nuestro filtro de título de película adaptándolo para admitir coincidencias parciales en lugar de requerir una coincidencia con el título completo. Por ejemplo, si un cliente desea encontrar “The Breakfast Club”, podrá hacerlo con solo la cadena de consulta title=breakfast.

Hay varias formas diferentes en las que podríamos implementar esta característica en nuestro código, pero un método efectivo e intuitivo (desde el punto de vista del cliente) es aprovechar la funcionalidad de búsqueda de texto completo de PostgreSQL, que te permite realizar búsquedas de ‘lenguaje natural’ en campos de texto en tu base de datos.

La búsqueda de texto completo en PostgreSQL es una herramienta potente y altamente configurable, y explicar cómo funciona y las opciones disponibles en detalle podría llenar fácilmente todo un libro. Por lo tanto, mantendremos las explicaciones en este capítulo a un nivel alto y nos centraremos en la implementación práctica.

Para implementar una búsqueda de texto completo básica en nuestro campo de título, vamos a actualizar nuestra consulta SQL para que se vea así:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY id

Eso parece bastante complicado a primera vista, así que vamos a desglosarlo y explicar qué está sucediendo.

La función to_tsvector('simple', title) toma un título de película y lo divide en terminos. Especificamos la configuración simple, lo que significa que los terminos son simplemente versiones en minúsculas de las palabras en el título. Por ejemplo, el título de la película “The Breakfast Club” se dividiría en los terminos ‘breakfast’, ‘club’, ‘the’.

† Otras configuraciones ‘no simples’ pueden aplicar reglas adicionales a los terminos, como la eliminación de palabras comunes o la aplicación de derivación específica del idioma.

La función plainto_tsquery('simple', $1) toma un valor de búsqueda y lo convierte en un término de consulta formateado que la búsqueda de texto completo de PostgreSQL puede entender. Normaliza el valor de búsqueda (nuevamente utilizando la configuración simple), elimina cualquier carácter especial e inserta el operador and (&) entre las palabras. Como ejemplo, el valor de búsqueda “The Club” resultaría en el término de consulta ‘the’ & ‘club’.

El operador @@ es el operador de coincidencias. En nuestra declaración, lo estamos utilizando para verificar si el término de consulta generado coincide con los terminos. Para continuar con el ejemplo, el término de consulta ‘the’ & ‘club’ coincidirá con las filas que contengan ambos terminos ‘the’ y ‘club’. Hay muchas palabras especializadas en los párrafos anteriores, pero si lo ilustramos con un par de ejemplos, en realidad es muy intuitivo:

// Return all movies where the title includes the case-insensitive word &#39;panther&#39;.
/v1/movies?title=panther
// Return all movies where the title includes the case-insensitive words &#39;the&#39; and
// &#39;club&#39;.
/v1/movies?title=the+club

Sigamos adelante y pongamos esto en acción. Abre tu archivo internal/data/movies.go y actualiza el método GetAll() para utilizar la nueva consulta SQL de la siguiente manera:

package main

func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*Movie, error) {
	// Use full-text search for the title filter.
	query := `
		SELECT id, created_at, title, year, runtime, genres, version
		FROM movies
		WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '')
		AND (genres @> $2 OR $2 = '{}')
		ORDER BY id`

...
}

Si estás siguiendo estos pasos, reinicia la aplicación e intenta realizar algunas solicitudes con diferentes valores para el título de la película. Deberías encontrar que las búsquedas parciales ahora funcionan según lo describimos anteriormente.

$ curl "localhost:4000/v1/movies?title=panther"
{
  "movies": [
    {
      "id": 2,
      "title": "Black Panther",
      "year": 2018,
      "runtime": "134 mins",
      "genres": [
        "sci-fi",
        "action",
        "adventure"
      ],
      "version": 2
    }
  ]
}

$ curl "localhost:4000/v1/movies?title=the+club"
{
  "movies": [
    {
      "id": 4,
      "title": "The Breakfast Club",
      "year": 1985,
      "runtime": "97 mins",
      "genres": [
        "comedy"
      ],
      "version": 5
    }
  ]
}

9.5 Agregando indices

Para mantener un rendimiento rápido de nuestra consulta SQL a medida que crece el conjunto de datos, tiene sentido utilizar índices para evitar exploraciones completas de la tabla y evitar generar los terminos para el campo de título cada vez que se ejecuta la consulta.

Nota: Si no estás familiarizado con el concepto de índices en bases de datos SQL, la documentación oficial de PostgreSQL proporciona una buena introducción y un resumen de diferentes tipos de índices. Recomiendo leerlos rápidamente para darte una visión general antes de continuar.

En nuestro caso, tiene sentido crear índices GIN tanto en el campo de géneros como en los lexemas generados por to_tsvector(), ambos utilizados en la cláusula WHERE de nuestra consulta SQL.

Si estás siguiendo estos pasos, adelante y crea un nuevo par de archivos de migración:

migrate create -seq -ext .sql -dir ./migrations add_movies_indexes

Luego, agrega las siguientes declaraciones a los archivos de migración ‘up’ y ‘down’ para crear y eliminar los índices necesarios:

CREATE INDEX IF NOT EXISTS movies_title_idx ON movies USING GIN (to_tsvector('simple', title));
CREATE INDEX IF NOT EXISTS movies_genres_idx ON movies USING GIN (genres);
DROP INDEX IF EXISTS movies_title_idx;
DROP INDEX IF EXISTS movies_genres_idx;

Una vez hecho esto, deberías poder ejecutar la migración ‘up’ para agregar los índices a tu base de datos:

$ migrate -path ./migrations -database $GREENLIGHT_DB_DSN up
3/u add_movies_indexes (38.638777ms)

9.5 Informacion adicional

9.5 Configuración no sencilla y más información

Como se mencionó anteriormente, también puedes utilizar una configuración específica del idioma para búsquedas de texto completo en lugar de la configuración simple que estamos utilizando actualmente. Cuando creas lexemas o términos de consulta utilizando una configuración específica del idioma, se eliminarán las palabras comunes para ese idioma y se realizará la derivación de palabras.

Entonces, por ejemplo, si utilizas la configuración en inglés, los lexemas generados para “One Flew Over the Cuckoo’s Nest” serían ‘cuckoo’ ‘flew’ ‘nest’ ‘one’. O con la configuración en español, los lexemas para “Los lunes al sol” serían ‘lun’ ‘sol’.

Puedes obtener una lista de todas las configuraciones disponibles ejecutando el meta-comando \dF en PostgreSQL:

postgres=# \dF
List of text search configurations
Schema      | Name    | Description
------------+---------+---------------------------------------
pg_catalog  | arabic  | configuration for arabic language
pg_catalog  | danish  | configuration for danish language
pg_catalog  | dutch   | configuration for dutch language
pg_catalog  | english | configuration for english language
...

Y si quisieras usar la configuración en inglés para buscar nuestras películas, podrías actualizar la consulta SQL de la siguiente manera:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (to_tsvector('english', title) @@ plainto_tsquery('english', $1) OR $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY id

Si deseas aprender más sobre la búsqueda de texto completo en PostgreSQL, leer esta publicación de blog es un buen próximo paso, y la documentación oficial también es excelente.

9.5 Usando STRPOS y ILIKE

Si no deseas utilizar la búsqueda de texto completo para la búsqueda parcial del título de la película, algunas alternativas son la función STRPOS() de PostgreSQL y el operador ILIKE.

La función STRPOS() de PostgreSQL te permite verificar la existencia de una subcadena en un campo específico de la base de datos. Podríamos usarla en nuestra consulta SQL de la siguiente manera:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (STRPOS(LOWER(title), LOWER($1)) > 0 OR $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY id;

Desde la perspectiva del cliente, el inconveniente de esto es que puede devolver algunos resultados no intuitivos. Por ejemplo, buscar title=the devolvería tanto The Breakfast Club como Black Panther en nuestro conjunto de datos.

Desde la perspectiva del servidor, tampoco es ideal para conjuntos de datos grandes. Dado que no hay una manera efectiva de indexar el campo de título para verificar si se cumple la condición STRPOS(), significa que la consulta podría potencialmente requerir una exploración completa de la tabla cada vez que se ejecuta.

Otra opción es el operador ILIKE, que te permite encontrar filas que coincidan con un patrón específico (sin distinguir mayúsculas y minúsculas). Podríamos usarlo en nuestra consulta SQL de la siguiente manera:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (title ILIKE $1 OR $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY id;

Este enfoque sería mejor desde el punto de vista del servidor porque es posible crear un índice en el campo de título utilizando la extensión pg_trgm y un índice GIN (para obtener detalles, consulta este post. Desde el lado del cliente, posiblemente sea mejor que el enfoque STRPOS(), ya que pueden controlar el comportamiento de coincidencia prefijando/sufijando el término de búsqueda con un carácter comodín % (que deberá escaparse a %25 en la cadena de consulta de la URL). Por ejemplo, para buscar películas con un título que comienza con “the”, un cliente podría enviar el parámetro de cadena de consulta title=the%25.

9.6 Ordenando listas

Ahora actualicemos la lógica para nuestro endpoint GET /v1/movies para que el cliente pueda controlar cómo se ordenan las películas en la respuesta JSON.

Como explicamos brevemente anteriormente, queremos permitir que el cliente controle el orden de clasificación mediante un parámetro de cadena de consulta en el formato sort={-}{nombre_del_campo}, donde el carácter opcional - se utiliza para indicar un orden de clasificación descendente. Por ejemplo:

// Ordenar las películas por el campo de título en orden alfabético ascendente.
/v1/movies?sort=title
// Ordenar las películas por el campo de año en orden numérico descendente.
/v1/movies?sort=-year

Entre bastidores, querremos traducir esto en una cláusula ORDER BY en nuestra consulta SQL, de modo que un parámetro de cadena de consulta como sort=-year resultaría en una consulta SQL como esta:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (STRPOS(LOWER(title), LOWER($1)) > 0 OR $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY year DESC;

La dificultad aquí es que los valores para la cláusula ORDER BY deberán generarse en tiempo de ejecución según los valores de la cadena de consulta del cliente. Idealmente, usaríamos parámetros de marcador de posición para insertar estos valores dinámicos en nuestra consulta, pero desafortunadamente no es posible utilizar parámetros de marcador de posición para nombres de columna o palabras clave SQL (incluyendo ASC y DESC).

Entonces, en su lugar, necesitaremos interpolar estos valores dinámicos en nuestra consulta usando fmt.Sprintf(), asegurándonos de que los valores se verifiquen contra una lista blanca estricta primero para evitar un ataque de inyección SQL.

Cuando trabajas con PostgreSQL, también es importante tener en cuenta que el orden de las filas devueltas solo está garantizado por las reglas que impone tu cláusula ORDER BY. Según la documentación oficial:

Si no se elige una clasificación, las filas se devolverán en un orden no especificado. El orden real en ese caso dependerá de los tipos de planes de escaneo y unión y del orden en el disco, pero no se debe confiar en él. Un orden de salida particular solo puede garantizarse si se elige explícitamente el paso de clasificación.

De manera similar, en nuestra base de datos, varias películas tendrán el mismo valor de año. Si ordenamos según la columna de año, entonces las películas están garantizadas a ordenarse por año, pero las películas para un año en particular podrían aparecer en cualquier orden en cualquier momento.

Este punto es especialmente importante en el contexto de un punto final que proporciona paginación. Necesitamos asegurarnos de que el orden de las películas sea perfectamente consistente entre las solicitudes para evitar que los elementos en la lista “salten” entre las páginas.

Afortunadamente, garantizar el orden es simple: solo necesitamos asegurarnos de que la cláusula ORDER BY siempre incluya una columna de clave principal (o cualquier otra columna con una restricción única). Entonces, en nuestro caso, podemos aplicar una clasificación secundaria en la columna id para garantizar un orden siempre consistente. Así:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (STRPOS(LOWER(title), LOWER($1)) > 0 OR $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY year DESC, id ASC;

9.6 Implementando el ordenamiento

Para hacer que la clasificación dinámica funcione, comencemos actualizando nuestra estructura Filters para incluir algunos ayudantes sortColumn() y sortDirection() que transformen un valor de cadena de consulta (como -year) en valores que podemos usar en nuestra consulta SQL.

package data

import (
    "strings" // Nuevo import
    "greenlight.nahueldev23.net/internal/validator"
)

type Filters struct {
    Page       int
    PageSize   int
    Sort       string
    SortSafelist []string
}

// Comprueba que el campo Sort proporcionado por el cliente coincida con una de las entradas de nuestra lista blanca
// y si lo hace, extrae el nombre de la columna del campo Sort eliminando el carácter de guion inicial
// (si existe).
func (f Filters) sortColumn() string {
    for _, safeValue := range f.SortSafelist {
        if f.Sort == safeValue {
            return strings.TrimPrefix(f.Sort, "-")
        }
    }
    panic("parámetro de ordenación no seguro: " + f.Sort)
}

// Devuelve la dirección de ordenación ("ASC" o "DESC") dependiendo del carácter de prefijo del
// campo Sort.
func (f Filters) sortDirection() string {
    if strings.HasPrefix(f.Sort, "-") {
        return "DESC"
    }
    return "ASC"
}

Observa que la función sortColumn() está construida de tal manera que generará un panic si el valor Sort proporcionado por el cliente no coincide con una de las entradas en nuestra lista blanca. En teoría, esto no debería suceder: el valor Sort debería haber sido verificado previamente al llamar a la función ValidateFilters(). Sin embargo, esto es una medida de seguridad sensata para evitar que ocurra un ataque de inyección SQL.

Ahora actualicemos nuestro archivo internal/data/movies.go para llamar a esos métodos e interpolar los valores de retorno en la cláusula ORDER BY de nuestra consulta SQL. Así:

func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*Movie, error) {
	// Add an ORDER BY clause and interpolate the sort column and direction. Importantly
	// notice that we also include a secondary sort on the movie ID to ensure a
	// consistent ordering.
	query := fmt.Sprintf(`
		SELECT id, created_at, title, year, runtime, genres, version
		FROM movies
		WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '')
		AND (genres @> $2 OR $2 = '{}')
		ORDER BY %s %s, id ASC`, filters.sortColumn(), filters.sortDirection())

		// Nothing else below needs to change.
...
}

Y una vez hecho esto, deberíamos estar listos para probar esto. Reinicia la aplicación y, como ejemplo, intenta hacer una solicitud de películas ordenadas por título descendente. Deberías obtener una respuesta que se vea así:

$ curl "localhost:4000/v1/movies?sort=-title"
{
  "movies": [
    {
      "id": 4,
      "title": "The Breakfast Club",
      "year": 1985,
      "runtime": "97 mins",
      "genres": [
        "comedy"
      ],
      "version": 5
    },
    {
      "id": 1,
      "title": "Moana",
      "year": 2016,
      "runtime": "107 mins",
      "genres": [
        "animation",
        "adventure"
      ],
      "version": 1
    },
    {
      "id": 2,
      "title": "Black Panther",
      "year": 2018,
      "runtime": "134 mins",
      "genres": [
        "sci-fi",
        "action",
        "adventure"
      ],
      "version": 2
    }
  ]
}

En cambio, usar un parámetro de orden descendente por tiempo de ejecución debería producir una respuesta en un orden totalmente diferente. Algo similar a esto:

$ curl "localhost:4000/v1/movies?sort=-runtime"
{
  "movies": [
    {
      "id": 2,
      "title": "Black Panther",
      "year": 2018,
      "runtime": "134 mins",
      "genres": [
        "sci-fi",
        "action",
        "adventure"
      ],
      "version": 2
    },
    {
      "id": 1,
      "title": "Moana",
      "year": 2016,
      "runtime": "107 mins",
      "genres": [
        "animation",
        "adventure"
      ],
      "version": 1
    },
    {
      "id": 4,
      "title": "The Breakfast Club",
      "year": 1985,
      "runtime": "97 mins",
      "genres": [
        "comedy"
      ],
      "version": 5
    }
  ]
}

9.7 Paginando listas

Si tienes un punto final que devuelve una lista con cientos o miles de registros, entonces, por razones de rendimiento o usabilidad, es posible que desees implementar alguna forma de paginación en el punto final, de modo que solo devuelva un subconjunto de los registros en una sola respuesta HTTP.

Para ayudar a demostrar cómo hacer esto, en este capítulo vamos a actualizar el punto final GET /v1/movies para que admita el concepto de ‘páginas’ y un cliente pueda solicitar una ‘página’ específica de la lista de películas utilizando nuestros parámetros de cadena de consulta page y page_size. Por ejemplo:

// Return the 5 records on page 1 (records 1-5 in the dataset)
/v1/movies?page=1&amp;page_size=5
// Return the next 5 records on page 2 (records 6-10 in the dataset)
/v1/movies?page=2&amp;page_size=5
// Return the next 5 records on page 3 (records 11-15 in the dataset)
/v1/movies?page=3&amp;page_size=5

Básicamente, cambiar el parámetro page_size alterará la cantidad de películas que se muestran en cada ‘página’, y aumentar el parámetro page en uno te mostrará la siguiente ‘página’ de películas en la lista.

9.7 Las clausulas LIMIT y OFFSET

Entre bastidores, la forma más sencilla de admitir este estilo de paginación es agregando las cláusulas LIMIT y OFFSET a nuestra consulta SQL.

La cláusula LIMIT te permite establecer el número máximo de registros que debe devolver una consulta SQL, y OFFSET te permite ‘saltar’ un número específico de filas antes de comenzar a devolver registros desde la consulta.

Dentro de nuestra aplicación, solo necesitaremos traducir los valores de page y page_size proporcionados por el cliente a los valores LIMIT y OFFSET apropiados para nuestra consulta SQL. Las operaciones matemáticas son bastante sencillas:

LIMIT = page_size
OFFSET = (page - 1) * page_size

O para dar un ejemplo concreto, si un cliente realiza la siguiente solicitud:

/v1/movies?page_size=5&amp;page=3

Necesitaríamos ‘traducir’ esto en la siguiente consulta SQL:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY %s %s, id ASC
LIMIT 5 OFFSET 10

Comencemos agregando algunos métodos auxiliares a nuestra estructura Filters para calcular los valores apropiados de LIMIT y OFFSET.

Si estás siguiendo los pasos, actualiza el archivo internal/data/filters.go de la siguiente manera:

package data

type Filters struct {
	Page         int
	PageSize     int
	Sort         string
	SortSafelist []string
}

...
func (f Filters) limit() int {
	return f.PageSize
}
func (f Filters) offset() int {
	return (f.Page - 1) * f.PageSize
}

Nota: En el método offset() hay un riesgo teórico de desbordamiento de enteros, ya que estamos multiplicando dos valores int. Sin embargo, esto se mitiga mediante las reglas de validación que creamos en nuestra función ValidateFilters(), donde impusimos valores máximos de page_size=100 y page=10000000 (10 millones). Esto significa que el valor devuelto por offset() nunca debería acercarse al desbordamiento.

9.7 Actualizando el modelo de base de datos

Como última etapa en este proceso, necesitamos actualizar el método GetAll() del modelo de nuestra base de datos para agregar las cláusulas LIMIT y OFFSET adecuadas a la consulta SQL.

package data

import (
	"context"
	"fmt"
	"time"

	"github.com/lib/pq"
)

func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*Movie, error) {
	// Update the SQL query to include the LIMIT and OFFSET clauses with placeholder
	// parameter values.
	query := fmt.Sprintf(`
		SELECT id, created_at, title, year, runtime, genres, version
		FROM movies
		WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '')
		AND (genres @> $2 OR $2 = '{}')
		ORDER BY %s %s, id ASC
		LIMIT $3 OFFSET $4`, filters.sortColumn(), filters.sortDirection())

	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	// As our SQL query now has quite a few placeholder parameters, let's collect the
	// values for the placeholders in a slice. Notice here how we call the limit() and
	// offset() methods on the Filters struct to get the appropriate values for the
	// LIMIT and OFFSET clauses.
	args := []interface{}{title, pq.Array(genres), filters.limit(), filters.offset()}

	// And then pass the args slice to QueryContext() as a variadic parameter.
	rows, err := m.DB.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}

	// Nothing else below needs to change.
	// ...
}

Una vez hecho esto, deberíamos estar listos para probarlo. Reinicia el servidor y luego realiza la siguiente solicitud con el parámetro page_size=2:

$ curl "localhost:4000/v1/movies?page_size=2"
{
  "movies": [
    {
      "id": 1,
      "title": "Moana",
      "year": 2016,
      "runtime": "107 mins",
      "genres": ["animation", "adventure"],
      "version": 1
    },
    {
      "id": 2,
      "title": "Black Panther",
      "year": 2018,
      "runtime": "134 mins",
      "genres": ["sci-fi", "action", "adventure"],
      "version": 2
    }
  ]
}

Eso parece bien. Nuestro punto final ahora está devolviendo solo los dos primeros registros de películas de nuestra base de datos (usando el orden de clasificación predeterminado de ID de película ascendente).

Ahora intentemos solicitar la segunda página de resultados. Si has estado siguiendo, esta página debería incluir el único registro restante en nuestro sistema, así:

# IMPORTANTE: Esta URL debe estar rodeada de comillas dobles para funcionar correctamente.
$ curl "localhost:4000/v1/movies?page_size=2&page=2"
{
  "movies": [
    {
      "id": 4,
      "title": "The Breakfast Club",
      "year": 1985,
      "runtime": "97 mins",
      "genres": ["comedy"],
      "version": 5
    }
  ]
}

Si intentas solicitar la tercera página, deberías obtener una matriz JSON vacía en la respuesta, algo así:

$ curl "localhost:4000/v1/movies?page_size=2&page=3"
{
  "movies": []
}

9.8 Devolviendo los metadatos de la paginacion

En este punto, la paginación en nuestro endpoint GET /v1/movies funciona muy bien, pero sería aún mejor si pudiéramos incluir algunos metadatos adicionales junto con la respuesta. Información como los números de página actual y última, y el número total de registros disponibles, ayudaría a proporcionar al cliente contexto sobre la respuesta y facilitaría la navegación entre las páginas. En este capítulo mejoraremos la respuesta para que incluya metadatos de paginación adicionales, similar a esto:

{
  &quot;metadata&quot;: {
    &quot;current_page&quot;: 1,
    &quot;page_size&quot;: 20,
    &quot;first_page&quot;: 1,
    &quot;last_page&quot;: 42,
    &quot;total_records&quot;: 832
  },
  &quot;movies&quot;: [
    {
      &quot;id&quot;: 1,
      &quot;title&quot;: &quot;Moana&quot;,
      &quot;year&quot;: 2015,
      &quot;runtime&quot;: &quot;107 mins&quot;,
      &quot;genres&quot;: [
        &quot;animation&quot;,
        &quot;adventure&quot;
      ],
      &quot;version&quot;: 1
    },
    // ... Otras películas ...
  ]
}

9.8 Calculando el total de registros

La parte desafiante de hacer esto es generar la cifra de “total_records”. Queremos que esto refleje el número total de registros disponibles dados los filtros de título y géneros que se aplican, no el total absoluto de registros en la tabla de películas.

Una forma elegante de hacer esto es adaptar nuestra consulta SQL existente para incluir una función de ventana que cuente el número total de filas filtradas, de la siguiente manera:

SELECT
  count(*) OVER(),
  id,
  created_at,
  title,
  year,
  runtime,
  genres,
  version
FROM
  movies
WHERE
  (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '')
  AND (genres @> $2 OR $2 = '{}')
ORDER BY
  %s %s, id ASC
LIMIT
  $3 OFFSET $4;

La inclusión de la expresión count(*) OVER() al principio de la consulta dará como resultado que el recuento de registros filtrados se incluya como el primer valor en cada fila. Algo así:

 count | id |      created_at       |        title         | year | runtime |         genres          | version
-------+----+------------------------+----------------------+------+---------+------------------------+---------
     3 |  1 | 2020-11-27 17:17:25+01 | Moana                | 2015 |     107 | {animation,adventure}  |       1
     3 |  2 | 2020-11-27 18:01:45+01 | Black Panther        | 2018 |     134 | {sci-fi,action,adventure}|       2
     3 |  4 | 2020-11-27 18:02:20+01 | The Breakfast Club   | 1985 |      97 | {comedy,drama}          |       6

Cuando PostgreSQL ejecuta esta consulta SQL, la secuencia de eventos (muy simplificada) se desarrolla aproximadamente de la siguiente manera:

  1. La cláusula WHERE se utiliza para filtrar los datos en la tabla de movies y obtener las filas que cumplen con los criterios.
  2. Se aplica la función de ventana count(*) OVER(), que cuenta todas las filas que cumplen con los criterios.
  3. Se aplican las reglas ORDER BY y se ordenan las filas que cumplen con los criterios.
  4. Se aplican las reglas LIMIT y OFFSET, y se devuelve el subconjunto adecuado de filas ordenadas que cumplen con los criterios.

9.8 Actualizando el codigo

Con esa breve explicación fuera del camino, pongámonos en marcha. Comenzaremos actualizando el archivo internal/data/filters.go para definir una nueva estructura Metadata que contenga los metadatos de paginación, junto con un helper para calcular los valores. De la siguiente manera:

package data

import (
	"math" // Nuevo import
	"strings"
	"nahueldev23/internal/validator"
)

// ...

// Define una nueva estructura Metadata para contener los metadatos de paginación.
type Metadata struct {
	CurrentPage   int `json:"current_page,omitempty"`
	PageSize      int `json:"page_size,omitempty"`
	FirstPage     int `json:"first_page,omitempty"`
	LastPage      int `json:"last_page,omitempty"`
	TotalRecords  int `json:"total_records,omitempty"`
}

// La función calculateMetadata() calcula los valores apropiados de metadatos de paginación
// dados el número total de registros, la página actual y el tamaño de la página. Ten en cuenta
// que el valor de la última página se calcula usando la función math.Ceil(), que redondea
// hacia arriba un número flotante al entero más cercano. Así que, por ejemplo, si hay 12 registros
// en total y un tamaño de página de 5, el valor de la última página sería math.Ceil(12/5) = 3.
func calculateMetadata(totalRecords, page, pageSize int) Metadata {
	if totalRecords == 0 {
		// Ten en cuenta que devolvemos una estructura Metadata vacía si no hay registros.
		return Metadata{}
	}
	return Metadata{
		CurrentPage:  page,
		PageSize:     pageSize,
		FirstPage:    1,
		LastPage:     int(math.Ceil(float64(totalRecords) / float64(pageSize))),
		TotalRecords: totalRecords,
	}
}

Luego necesitamos regresar a nuestro método GetAll() y actualizarlo para usar nuestra nueva consulta SQL (con la función de ventana) para obtener el recuento total de registros. Luego, si todo funciona correctamente, utilizaremos la función calculateMetadata() para generar los metadatos de paginación y devolverlos junto con los datos de las películas.

Adelante, actualiza la función GetAll() de la siguiente manera:

package data

import (
	"context"
	"fmt"
	"time"

	"github.com/lib/pq"
)

// Actualiza la firma de la función para devolver una estructura Metadata.
func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*Movie, Metadata, error) {
	// Actualiza la consulta SQL para incluir la función de ventana que cuenta el total de registros (filtrados).
	query := fmt.Sprintf(`
SELECT count(*) OVER(), id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY %s %s, id ASC
LIMIT $3 OFFSET $4`, filters.sortColumn(), filters.sortDirection())

	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	args := []interface{}{title, pq.Array(genres), filters.limit(), filters.offset()}
	rows, err := m.DB.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, Metadata{}, err // Actualiza esto para devolver una estructura Metadata vacía.
	}
	defer rows.Close()

	// Declara una variable totalRecords.
	totalRecords := 0
	movies := []*Movie{}

	for rows.Next() {
		var movie Movie
		// Escanea el recuento de la función de ventana en totalRecords.
		err := rows.Scan(
			&totalRecords,
			&movie.ID,
			&movie.CreatedAt,
			&movie.Title,
			&movie.Year,
			&movie.Runtime,
			pq.Array(&movie.Genres),
			&movie.Version,
		)
		if err != nil {
			return nil, Metadata{}, err // Actualiza esto para devolver una estructura Metadata vacía.
		}
		movies = append(movies, &movie)
	}

	if err = rows.Err(); err != nil {
		return nil, Metadata{}, err // Actualiza esto para devolver una estructura Metadata vacía.
	}

	// Genera una estructura Metadata, pasando el recuento total de registros y los parámetros de paginación desde el cliente.
	metadata := calculateMetadata(totalRecords, filters.Page, filters.PageSize)

	// Incluye la estructura de metadatos al devolver.
	return movies, metadata, nil
}

Finalmente, necesitamos actualizar nuestro manejador listMoviesHandler para recibir la estructura Metadata devuelta por GetAll() e incluir la información en la respuesta JSON para el cliente. Algo así:

func (app *application) listMoviesHandler(w http.ResponseWriter, r *http.Request) {
	var input struct {
		Title  string
		Genres []string
		data.Filters
	}
	v := validator.New()
	qs := r.URL.Query()
	input.Title = app.readString(qs, "title", "")
	input.Genres = app.readCSV(qs, "genres", []string{})
	input.Filters.Page = app.readInt(qs, "page", 1, v)
	input.Filters.PageSize = app.readInt(qs, "page_size", 20, v)
	input.Filters.Sort = app.readString(qs, "sort", "id")
	input.Filters.SortSafelist = []string{"id", "title", "year", "runtime", "-id", "-title", "-year", "-runtime"}
	if data.ValidateFilters(v, input.Filters); !v.Valid() {
		app.failedValidationResponse(w, r, v.Errors)
		return
	}
	// Accept the metadata struct as a return value.
	movies, metadata, err := app.models.Movies.GetAll(input.Title, input.Genres, input.Filters)
	if err != nil {
		app.serverErrorResponse(w, r, err)
		return
	}
	// Include the metadata in the response envelope.
	err = app.writeJSON(w, http.StatusOK, envelope{"movies": movies, "metadata": metadata}, nil)
	if err != nil {
		app.serverErrorResponse(w, r, err)
	}
}

Siéntete libre de reiniciar la API y probar esta nueva funcionalidad realizando algunas solicitudes diferentes al punto final GET /v1/movies. Deberías encontrar que los metadatos de paginación correctos ahora se incluyen en la respuesta. Por ejemplo:

curl "localhost:4000/v1/movies?page=1&page_size=2"
{
  "metadata": {
    "current_page": 1,
    "page_size": 2,
    "first_page": 1,
    "last_page": 2,
    "total_records": 3
  },
  "movies": [
    {
      "id": 1,
      "title": "Moana",
      "year": 2015,
      "runtime": "107 mins",
      "genres": [
        "animation",
        "adventure"
      ],
      "version": 1
    },
    {
      "id": 2,
      "title": "Black Panther",
      "year": 2018,
      "runtime": "134 mins",
      "genres": [
        "sci-fi",
        "action",
        "adventure"
      ],
      "version": 2
    }
  ]
}

Y si intentas hacer una solicitud con un filtro aplicado, deberías ver que el valor de last_page y el recuento de total_records cambian para reflejar los filtros aplicados. Por ejemplo, al solicitar solo películas con el género “adventure”, podemos ver que el recuento total de total_records disminuye a 2:

$ curl localhost:4000/v1/movies?genres=adventure
{
  "metadata": {
    "current_page": 1,
    "page_size": 20,
    "first_page": 1,
    "last_page": 1,
    "total_records": 2
  },
  "movies": [
    {
      "id": 1,
      "title": "Moana",
      "year": 2015,
      "runtime": "107 mins",
      "genres": [
        "animation",
        "adventure"
      ],
      "version": 1
    },
    {
      "id": 2,
      "title": "Black Panther",
      "year": 2018,
      "runtime": "134 mins",
      "genres": [
        "sci-fi",
        "action",
        "adventure"
      ],
      "version": 2
    }
  ]
}

Finalmente, si realizas una solicitud con un valor de página demasiado alto, deberías recibir una respuesta con un objeto de metadatos vacío y un array de películas vacío, como este:

$ curl localhost:4000/v1/movies?page=100
{
	"metadata": {},
	"movies": []
}

En los últimos capítulos, hemos tenido que realizar mucho trabajo en el punto final GET /v1/movies. Pero el resultado final es realmente potente. El cliente ahora tiene un gran control sobre lo que contiene su respuesta, con soporte para filtrado, paginación y ordenación.

Con la estructura Filters que hemos creado, también tenemos algo que podemos incorporar fácilmente en cualquier otro punto final que necesite funcionalidades de paginación y ordenación en el futuro. Y si retrocedes y observas el código final que hemos escrito en el manejador listMovieHandler y en el método GetAll() de nuestro modelo de base de datos, no hay tanto más código que en las primeras iteraciones del punto final.

Post Relacionados