MongoDB nos permite gestionar datos documentales.

Introducción

https://gitlab.com/xtec/kotlin/mongodb

Entorno de trabajo

Crea la carpeta mongodb y entra en la carpeta:

> md mongodb
> cd mongodb

Ejecuta gradle init con los parámetros que se muestran a continuación para generar una aplicación Kotlin con el nombre mongodb:

gradle init --package mongodb --project-name mongodb --java-version 21 --type kotlin-application --dsl kotlin --test-framework kotlintest --no-split-project --no-incubating --overwrite

Modifica el fichero app/build.gradle.kts:

dependencies {
    // ...
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
    implementation("org.mongodb:mongodb-driver-kotlin-coroutine:5.3.0")
}

Modifica el fichero App.kt:

package mongodb

import com.mongodb.kotlin.client.coroutine.MongoClient
import kotlinx.coroutines.runBlocking
import org.bson.BsonInt64
import org.bson.Document

fun main() = runBlocking {

    val client = MongoClient.create("mongodb://localhost:27017")
    val database = client.getDatabase("pets")

    // Provem que tenim connexió
    database.runCommand(Document("ping", BsonInt64(1)))
    println("Successfully connected to MongoDB")


    client.close()
}

La conexión con nuestra base de datos puede dividirse en dos pasos:

  • Primero, creamos una instancia de MongoClient con Connection URI.
  • Y en segundo lugar, utilice el cliente para conectarse con la base de datos pets.

Ejecuta la aplicación.

Si no tienes arrancado la base de datos MongoDB al cabo de un rato tienes un error de tipo MongoTimeoutException:

Exception in thread "main" com.mongodb.MongoTimeoutException: Timed out while waiting for a server that matches ReadPreferenceServerSelector{readPreference=primary}. Client view of cluster state is {type=UNKNOWN, servers=[{address=localhost:27017, type=UNKNOWN, state=CONNECTING, exception={com.mongodb.MongoSocketOpenException: Exception opening socket}, caused by {java.io.IOException: The remote computer refused the network connection}}]
...

...

Arranca la base de datos local tal y como se explica en MongoDB - Fonaments y verifica que el código funciona correctamente

> mogodb

Insert

Crea una clase de datos Dog:

data class Dog(
    @BsonId
    val id: ObjectId,
    val name: String
)

La anotación @BsonId representa la identidad única o _id del documento.

A continuación inserta un perro con el método insertOne():

fun main() = runBlocking {
    // ...

    val collection = database.getCollection<Dog>("dogs")

    val result = collection.insertOne(Dog(ObjectId(),"Trufa"))
    println(result.insertedId)
}

Dado que la colección dogs todavía no existe, el servidor la crea automáticamente.

Si la inserción es correcta, insertOne() devuelve una instancia de InsertOneResult que te permite recuperar información como el campo _id del documento que has insertado con insertId.

Si su operación de inserción falla, el controlador genera una excepción.

A continuación inserta varios perros en una sola operación con el método insertMany().

fun main() = runBlocking {
    // ...

    val collection = database.getCollection<Dog>("dogs")
    collection.drop()

    val dogs = listOf(Dog(ObjectId(), "Trufa"), Dog(ObjectId(), "Ketzu"),
                      Dog(ObjectId(), "Marilyn"))

    val result = collection.insertMany(dogs)
    println(result.insertedIds)

}

Para evitar tener documentos duplicados, primero eliminamos la colección.

Después de una inserción correcta, insertMany() devuelve una instancia de InsertManyResult.

Find

Modifica la clase de datos Dog para incluir la raza del perro:

data class Dog(
    @BsonId
    val id: ObjectId,
    val name: String,
    val breed: String?
)

El método find() te permite buscar documentos en una colección.

Si no incluyes ningún filtro, el método find() devuelve un cursor que itera todos los elementos de la colección:

// ...

    collection.insertMany(
        listOf(
            Dog(ObjectId(), "Trufa", "Rough Collie"),
            Dog(ObjectId(), "Ketzu", "Shiba Inu"),
            Dog(ObjectId(), "Marilyn", null),
            Dog(ObjectId(), "Duc", "Rough Collie"),
            Dog(ObjectId(), "Milo", "Border Collie"),
        )   
    ).also { println(it.insertedIds)}

    collection.find<Dog>().collect { println(it) }

Puedes ver que todos los perros se han guardado en la base de datos:

Successfully connected to MongoDB
{0=BsonObjectId{value=67853f94ab522d5b4fa362b2}, 1=BsonObjectId{value=67853f94ab522d5b4fa362b3}, 2=BsonObjectId{value=67853f94ab522d5b4fa362b4}, 3=BsonObjectId{value=67853f94ab522d5b4fa362b5}, 4=BsonObjectId{value=67853f94ab522d5b4fa362b6}}
Dog(id=67853f94ab522d5b4fa362b2, name=Trufa, breed=Rough Collie)
Dog(id=67853f94ab522d5b4fa362b3, name=Ketzu, breed=Shiba Inu)
Dog(id=67853f94ab522d5b4fa362b4, name=Marilyn, breed=null)
Dog(id=67853f94ab522d5b4fa362b5, name=Duc, breed=Rough Collie)
Dog(id=67853f94ab522d5b4fa362b6, name=Milo, breed=Border Collie)

El método find() devuelve una instancia de FindFlow, una clase que ofrece varios métodos para acceder, organizar y recorrer los resultados tales como first() y firstOrNull().

El método firstOrNull() devuelve el primer documento de los resultados recuperados o null si no hay resultados.

import kotlinx.coroutines.flow.firstOrNull

// ...

    val dog = collection.find<Dog>(eq(Dog::name.name,"Lassie")).firstOrNull()
    println(dog?.name)

El método first() devuelve el primer documento o lanza una excepción NoSuchElementException si ningún documento coincide con la consulta.

import com.mongodb.client.model.Filters.eq
import kotlinx.coroutines.flow.first

// ...

    val dog = collection.find<Dog>(eq(Dog::name.name,"Lassie")).first()
    println(dog.name)

Filter

Los filtros son operaciones utilizadas para limitar los resultados de una consulta en función de condiciones especificadas.

La clase Filters proporciona métodos estáticos para todos los operadores de consulta de MongoDB. Cada método devuelve una instancia del tipo BSON, que puede pasar a cualquier método que espere un filtro de consulta.

import com.mongodb.client.model.Filters.*

Por ejemplo, puedes buscar todos los perros de raza "Rough Collie" con el operador eq:

    collection.find<Dog>(eq(Dog::breed.name, "Rough Collie")).collect { print("${it.name} ") }

O todos los perros de raza "Rough Collie" o "BorderCollie" con el operador eq:

    collection.find<Dog>(
        or(
            listOf(
                eq(Dog::breed.name, "Rough Collie"), eq(Dog::breed.name, "Border Collie")
            )
        )
    ).collect { print("${it.name} ") } // Milo Trufa Duc Lassie 

Aunque este ejemplo sería mejor con el operador in puesto que estamos filtrando la misma propiedad:

 collection.find<Dog>(
        `in`(Dog::breed.name, listOf("Rough Collie", "Border Collie"))
    ).collect { print("${it.name} ") } // Milo Trufa Duc Lassie

Paginació

Con los operadores limit y skip puedes añadir paginación a los resultados:

    // ...
    
    collection.find<Dog>().limit(2).collect { print("${it.name} ") }
    collection.find<Dog>().limit(2).skip(2).collect { print("${it.name} ") }

    // Trufa Ketzu Marilyn Duc 

Pero con este enfoque, a menudo, el tiempo de respuesta de la consulta aumenta con el valor de offset.

Para superarlo, podemos beneficiarnos creando un Index, como se muestra a continuación:

    // ...

    val collection = database.getCollection<Dog>("dogs")
    collection.drop()
    collection.createIndex(
        keys = Indexes.ascending(Dog::breed.name)
    )


    collection.insertMany(
        listOf(
            Dog(ObjectId(), "Trufa", "Rough Collie"),
            Dog(ObjectId(), "Ketzu", "Shiba Inu"),
            Dog(ObjectId(), "Marilyn", null),
            Dog(ObjectId(), "Duc", "Rough Collie"),
            Dog(ObjectId(), "Milo", "Border Collie"),
            Dog(ObjectId(), "Lassie", "Rough Collie"),
        )
    )

    collection.find<Dog>(eq(Dog::breed.name, "Rough Collie")).limit(2).collect { print("${it.name} ") }
    collection.find<Dog>(eq(Dog::breed.name, "Rough Collie")).limit(2).skip(2).collect { print("${it.name} ") }

    // Trufa Duc Lassie

Update

Modifica la clase Dog para que un perro pueda tener un propietario:

data class Dog(
    @BsonId
    val id: ObjectId,
    val name: String,
    val breed: String?,
    val owner: Owner?
)

data class Owner(
    val name: String,
    val emai: String?
)

Si tenemos este conjunto de documentos en la base de datos:

    collection.insertMany(
        listOf(
            Dog(ObjectId(), "Trufa", "Rough Collie", Owner(name = "David", email = "david@gmail.com")),
            Dog(ObjectId(), "Ketzu", "Shiba Inu", Owner(name = "Laura", null)),
            Dog(ObjectId(), "Marilyn", null, Owner(name = "Roser", "roser@gmail.com")),
            Dog(ObjectId(), "Duc", "Rough Collie", null),
            Dog(ObjectId(), "Milo", "Border Collie", null),
            Dog(ObjectId(), "Lassie", "Rough Collie", Owner(name = "Miquel", null)),
        )
    )

Y has encontrado un propietario para "Duc", puedes actualizar el documento correspondiente con updateOne.

    collection.updateOne(
        filter = eq(Dog::name.name, "Duc"),
        update = set(Dog::owner.name, Owner(name = "David", "david@gmail.com"))
    ).also { println(it) }

    collection.find<Dog>(eq(Dog::name.name,"Duc")).first().also { print(it) }

La clase Updates proporciona métodos estáticos para todos los operadores de consulta de MongoDB.

import com.mongodb.client.model.Updates.*

El resultado es el esperado:

Successfully connected to MongoDB
AcknowledgedUpdateResult{matchedCount=1, modifiedCount=1, upsertedId=null}
Dog(id=67858d99749e816534cc68db, name=Duc, breed=Rough Collie, owner=Owner(name=David, email=david@gmail.com)

Ahora "Trufa" y "Duc" son del mismo propietario.

Imagínate que "David" debe cambiar su email.

Por estos casos está el operador updateMany.

Primera crea un filtro que seleccione todos los perros de "David" y verifica que funciona:

    collection.find<Dog>(eq("owner.name","David")).collect{ print("${it.name} ")}

Y ya puedes actualizar los datos:

    collection.updateMany(
        filter = eq("owner.name", "David"),
        update = set("owner.email", "david@xtec.dev")
    )

    collection.find<Dog>(eq("owner.name", "David")).collect { println(it) }

Delete

Para borrar un solo documento puedes utilizar findOneAndDelete en lugar de deleteOne con la ventaja adicional de que también devuelve el documento suprimido como salida.

    collection.findOneAndDelete(eq(Dog::name.name,"Milo")).also { print(it) }

Para eliminar varios documentos, puedes utilizar deleteMany:

    collection.deleteMany(eq(Dog::breed.name, "Rough Collie"))

    collection.find<Dog>().collect { print("${it.name} ")} // Ketzu Marilyn 

TODO