Spring Data MongoDB - Guía para la anotación @Aggregation

En esta guía detallada, aprenda todo lo que necesita saber sobre la anotación @Aggregation de Spring Data MongoDB, canalizaciones de agregación, argumentos posicionales y con nombre, así como clasificación y paginación, a través de un código práctico.

Introducción

MongoDB es una base de datos NoSQL basada en documentos que almacena datos en formato BSON (Binary JSON).

Al igual que con cualquier base de datos, habitualmente realizará llamadas para leer, escribir o actualizar los datos almacenados en el almacén de documentos. En muchos casos, recuperar datos no es tan simple como escribir una sola consulta (aunque las consultas pueden volverse bastante complejas).

Si desea obtener más información sobre cómo escribir consultas MongoDB con Spring Boot, lea nuestra [Spring Data MongoDB - Guía para la anotación @Query](/spring-data-mongodb-guia-para-la-anotación-de -consulta/)!

Con MongoDB, las agregaciones se utilizan para procesar muchos documentos y devolver algún resultado calculado. Esto se logra mediante la creación de un Pipeline de operaciones, donde cada operación toma un conjunto de documentos y los filtra según algunos criterios.

Spring Data MongoDB es el módulo de Spring que actúa como una interfaz entre una aplicación Spring Boot y MongoDB. Naturalmente, ofrece un conjunto de anotaciones que nos permiten "cambiar" funciones fácilmente, así como también permitirle al módulo saber cuándo debe encargarse de las cosas por nosotros.

La anotación @Aggregation se utiliza para anotar los métodos del Repositorio de Spring Boot e invocar un pipeline() de operaciones que proporciona a la anotación @Aggregation.

En esta guía, veremos cómo aprovechar la anotación @Aggregation para agregar los resultados en una base de datos MongoDB, qué son las canalizaciones de agregación, cómo usar argumentos de método posicional y con nombre para agregaciones dinámicas, así como cómo ordenar y paginar los resultados!

Modelo de dominio y repositorio

Comencemos con nuestro modelo de dominio y un repositorio simple. Crearemos una Propiedad, actuando como modelo para una propiedad inmobiliaria con un par de campos relevantes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Document(collection = "property")
public class Property {

    @Id
    private String id;
    @Field("price")
    private int price;
    @Field("area")
    private int area;
    @Field("property_type")
    private String propertyType;
    @Field("transaction_type")
    private String transactionType;
    
    // Constructor, getters, setters, toString()
    
}

Y con él, un MongoRepository asociado simple:

1
2
@Repository
public interface PropertyRepository extends MongoRepository<Property, String> {}

{.icon aria-hidden=“true”}

Recordatorio: MongoRepository es un PagingAndSortingRepository, que en última instancia es un CrudRepository.

A través de agregaciones, también puede, naturalmente, ordenar y paginar los resultados, aprovechando el hecho de que está extendiendo la interfaz PagingAndSortingRepository de Spring Data.

Comprensión de la anotación @Aggregation {#comprensión de la anotación de agregación}

La anotación @Aggregation se aplica a nivel de método, dentro de un @Repository. La anotación acepta una “tubería”: una matriz de cadenas, donde cada cadena representa una etapa en la tubería (operación a ejecutar). Cada siguiente etapa opera sobre los resultados de la anterior.

Existen varias etapas que le permiten realizar una amplia variedad de operaciones. Algunos de los más utilizados son:

  • $coincidencia - Filtra documentos en función de si su campo coincide con un predicado dado.
  • $count - Devuelve el recuento de los documentos que quedan en la canalización.
  • $limit: limita el número de (secciones) de documentos devueltos, comenzando al principio del conjunto y acercándose al límite.
  • $sample - Muestrea aleatoriamente un número determinado de documentos de un conjunto.
  • $sort - Ordena los documentos dado un campo y orden de clasificación.
  • $merge - Escribe los documentos en la canalización en una colección.

Algunas de estas son operaciones de terminal (aplicadas al final), como $merge. La clasificación también debe realizarse después de que el resto del filtrado ya haya finalizado.

En nuestro caso, para agregar un @Aggregation a nuestro repositorio, solo tendríamos que agregar un método y anotarlo:

1
2
3
4
5
6
@Aggregation(pipeline = {
        "Operation/Stage 1...",
        "Operation/Stage 2...",
        "Operation/Stage 3...",
})
List<Property> someMethod();

O bien, puede mantenerlos en línea:

1
2
@Aggregation(pipeline = {"Operation/Stage 1...", "Operation/Stage 2...", "Operation/Stage 3..."})
List<Property> someMethod();

Dependiendo de la cantidad de etapas que tenga, la última opción puede volverse ilegible con bastante rapidez. En general, ayuda dividir las etapas en nuevas líneas para mejorar la legibilidad.

Dicho esto, ¡agreguemos algunas operaciones a la canalización! Busquemos, por ejemplo, propiedades que tengan un campo que coincida con un valor dado, como propiedades cuyo transactionType sea igual a "For Sale":

1
2
3
4
@Aggregation(pipeline = {
    "{'$match':{'transaction_type':'For Sale'}",
})
List<Property> findPropertiesForSale();

Sin embargo, tener un solo partido como este supera el punto de agregación. Agreguemos algunas condiciones más coincidentes. No olvide que puede proporcionar cualquier cantidad de condiciones coincidentes aquí, incluidos selectores/operadores como $gt para filtrar aún más:

1
2
3
4
@Aggregation(pipeline = {
    "{'$match':{'transaction_type':'For Sale', 'price' : {$gt : 100000}}",
})
List<Property> findExpensivePropertiesForSale();

¡Ahora, estaríamos buscando propiedades que coincidan con el tipo_de_transacción, pero también tengan un precio mayor que ($gt) 100000! Incluso con dos de estos, tener solo una etapa $match no tiene por qué garantizar una @Aggregation, aunque sigue siendo una forma completamente válida de obtener resultados basados ​​en múltiples condiciones.

Además, no es divertido cuando se trata de valores fijos. ¿Quién puede decir que esta es una propiedad costosa? Sería mucho más útil poder proporcionar una marca más baja a la llamada al método y usarla con el operador $gt en su lugar.

Aquí es donde entran en juego los parámetros de método con nombre y posicionales.

Referencia a parámetros de método posicional y con nombre {#referencia a parámetros de método posicional y con nombre}

Rara vez tratamos solo con números estáticos, ya que, bueno, no son flexibles. Queremos ofrecer flexibilidad tanto a los usuarios finales como también a los desarrolladores. En el ejemplo anterior, hemos usado dos valores fijos: 'En venta' y 100000. En esta sección, reemplazaremos esos dos con parámetros de método posicionales y con nombre, ¡y los proporcionaremos a través de los parámetros del método!

El uso de argumentos posicionales o con nombre no cambia el código funcionalmente y, por lo general, depende del ingeniero/equipo decidir qué opción elegir, en función de sus preferencias. Vale la pena ser consistente con un tipo, una vez que lo hayas elegido:

1
2
3
4
5
6
7
8
9
@Aggregation(pipeline = {
        "{'$match':{'transaction_type': ?0, 'price' : {$gt : ?1}}",
})
List<Property> findPropertiesByTransactionTypeAndPriceGTPositional(String transactionType, int price);

@Aggregation(pipeline = {
        "{'$match':{'transaction_type': #{#transactionType}, 'price' : {$gt : #{#price}}}",
})
List<Property> findPropertiesByTransactionTypeAndPriceGTNamed(@Param("transactionType") String transactionType, @Param("price") int price);

El primero es más conciso, pero requiere que haga cumplir el orden de los argumentos que ingresan. Además, si el campo en la base de datos en sí no es indicativo del tipo/valor esperado (que es un mal diseño, pero a veces es fuera de su control): el uso de argumentos posicionales puede aumentar la confusión, ya que hay una pequeña cantidad de ambigüedad en cuanto al valor que puede esperar.

Este último es, sin duda, más detallado, pero le permite mezclar el orden de los parámetros. No hay necesidad de hacer cumplir su posición, ya que la anotación @Param las hace coincidir con las expresiones SpEL, vinculándolas al nombre en la canalización de la operación.

No hay una opción objetivamente mejor aquí, ni una que sea ampliamente aceptada en la industria. Elige el que te sientas más cómodo contigo mismo.

{.icon aria-hidden=“true”}

Consejo: Si ha activado DEBUG como su nivel de registro, podrá ver la consulta que se envió a Mongo en los registros. Puede copiar y pegar esa consulta en MongoDB Atlas para verificar si la consulta devuelve los resultados correctos allí y verificar si accidentalmente arruinó las posiciones. Lo más probable es que su consulta esté bien, pero acaba de mezclar las posiciones, por lo que el resultado está vacío.

Ahora, puede proporcionar valores a las llamadas de método, ¡y se usarán dinámicamente en @Aggregation! Esto le permite reutilizar los mismos métodos para varias llamadas, como, por ejemplo, obtener propiedades activas. Esta será una llamada común, por lo que ya sea que recupere 5, 10 o 100 de ellos, puede reutilizar el mismo método.

Cuando se trata de corpus de datos más grandes, también vale la pena considerar la clasificación y la paginación. No se debe esperar que los usuarios finales clasifiquen los datos por sí mismos.

Clasificación y paginación

Por lo general, la clasificación se realiza al final, ya que la clasificación previa podría terminar siendo redundante. Puede aplicar métodos de ordenación después de que se realice la agregación o durante la agregación.

Aquí, exploraremos la posibilidad de aplicar métodos de clasificación dentro de la propia agregación. Tomaremos muestras de una cantidad de propiedades y las ordenaremos, por ejemplo, por área. Puede ser cualquier otro campo, como precio, fecha de publicación, patrocinado, etc. La operación $sort acepta un campo para ordenar, así como el orden (donde 1 es ascendente). y -1 es descendente).

1
2
3
4
5
6
@Aggregation(pipeline = {
        "{'$match':{'transaction_type':?0, 'price': {$gt: ?1} }}",
        "{'$sample':{size:?2}}",
        "{'$sort':{'area':-1}}"
})
List<Property> findPropertiesByTransactionTypeAndPriceGT(String transactionType, int price, int sampleSize);

Aquí, hemos ordenado las propiedades por área, en orden descendente, lo que significa que las propiedades con el área más grande aparecerán primero en la clasificación. El tipo de transacción, el precio y el tamaño de la muestra son variables y se pueden configurar de forma dinámica.

Si desea incorporar Paginación en esto, se aplica el enfoque de paginación estándar de Spring Boot: simplemente agregue un Pageable pageable a la definición del método y llame:

1
2
3
4
5
6
@Aggregation(pipeline = {
        "{'$match':{'transaction_type':?0, 'price': {$gt: ?1} }}",
        "{'$sample':{size:?2}}",
        "{'$sort':{'area':-1}}"
})
Iterable<Property> findPropertiesByTransactionTypeAndPriceGTPageable(String transactionType, int price, int sampleSize, Pageable pageable);

Al llamar al método desde un controlador, querrás construir un objeto Pageable para pasar:

1
2
3
4
5
int page = 1;
int size = 5;

Pageable pageable = new PageRequest.of(page, size);
Page<Property> = propertyRepository.findPropertiesByTransactionTypeAndPriceGTPageable("For Sale", 100000, 5, pageable);

La Página sería la segunda página (índice 1), con 5 resultados.

{.icon aria-hidden=“true”}

Nota: Dado que ya hemos ordenado las propiedades en la agregación, no es necesario incluir ninguna configuración de clasificación adicional allí. Alternativamente, puede omitir $ sort en la agregación y ordenarlo a través de la instancia Pageable.

Creación de una API REST

Activemos rápidamente una API REST que exponga los resultados de estos métodos a un usuario final y enviemos una solicitud curl para validar los resultados, comenzando con el controlador con un repositorio autocableado:

1
2
3
4
5
6
@RestController
public class HomeController {
    @Autowired
    private PropertyRepository propertyRepository;
    
}

If you'd like to read more about the @RestController and @Autowired annotations, read out Anotaciones @Controller y @RestController en Spring Boot and Sección @Autowired en Spring Annotations: Core Framework Annotations!

En primer lugar, querremos agregar algunas propiedades a la base de datos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@GetMapping("/addProperties")
public ResponseEntity addProperies() {

    List<Property> propertyList = List.of(
            new Property(100000, 45, "Apartment", "For Sale"),
            new Property(65000, 48, "Apartment", "For Sale"),
            new Property(280000, 75, "Apartment", "For Sale"),
            new Property(452000, 110, "House", "For Sale"),
            new Property(400000, 125, "House", "For Rent"),
            new Property(125000, 100, "Apartment", "For Sale"),
            new Property(95000, 70, "House", "For Rent"),
            new Property(35000, 25, "Apartment", "For Sale")
    );

    for (Property property : propertyList) {
        propertyRepository.save(property);
    }

    return ResponseEntity.ok().body(propertyList);
}

Ahora, enrollemos una solicitud a este punto final para agregar las propiedades a la base de datos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ curl localhost:8080/addProperties

[ {
  "id" : "61dedea6799b5758bb857292",
  "price" : 100000,
  "area" : 45,
  "propertyType" : "Apartment",
  "transactionType" : "For Sale"
}, {
  "id" : "61dedea6799b5758bb857293",
  "price" : 65000,
  "area" : 48,
  "propertyType" : "Apartment",
  "transactionType" : "For Sale"
},
...

{.icon aria-hidden=“true”}

Nota: Para obtener una respuesta con letra bonita, recuerde cambiar INDENT_OUTPUT de Jackson a true en su application.properties.

Y ahora, definamos un punto final /getProperties, que llama a uno de los métodos PropertyRepository que realiza una agregación:

1
2
3
4
5
6
@GetMapping("/getProperties")
public ResponseEntity home() {
    return ResponseEntity
    .ok()
    .body(propertyRepository.findPropertiesByTransactionTypeAndPriceGT("For Sale", 100000, 5));
}

Esto debería devolver hasta 5 propiedades seleccionadas al azar de un conjunto de propiedades que están a la venta, con un precio superior a 100k, ordenadas por su área. Si no hay 5 muestras para elegir, se devuelven todas las propiedades de ajuste:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
$ curl localhost:8080/getProperties

[ {
  "id" : "61dedea6799b5758bb857295",
  "price" : 452000,
  "area" : 110,
  "propertyType" : "House",
  "transactionType" : "For Sale"
}, {
  "id" : "61dedea6799b5758bb857297",
  "price" : 125000,
  "area" : 100,
  "propertyType" : "Apartment",
  "transactionType" : "For Sale"
}, {
  "id" : "61dedea6799b5758bb857294",
  "price" : 280000,
  "area" : 75,
  "propertyType" : "Apartment",
  "transactionType" : "For Sale"
} ]

¡Funciona de maravilla!

Conclusión

En esta guía, hemos repasado la anotación @Aggregation en el módulo Spring Data MongoDB. Hemos cubierto qué son las agregaciones, cuándo se pueden usar y en qué se diferencian de las consultas regulares.

Hemos revisado algunas de las operaciones comunes en una canalización de agregación, antes de escribir nuestras propias canalizaciones con argumentos estáticos y dinámicos. Hemos explorado los parámetros posicionales y con nombre para las agregaciones y, finalmente, creamos una API REST simple para entregar los resultados.