17

Agregaciones con MongoDB y Rails: Lectura técnica

138Puntos

hace 4 años

En el blogpost Introducción a Rails con MongoDB te explicaba como poder interactuar de forma básica entre Rails 6 y MongoDB, usando la el controlador y gema Mongoid. Ahora en este post vamos a revisar otros aspectos fundamentales en las consultas con esta tecnología.

Presunciones

Este blogpost asume lo siguiente:

  1. Que ya tienes configurado e instalado MongoDB de forma apropiada (puedes ver los procesos de instalación en el blogpost que te menciono arriba o en el curso de Ruby on Rails intermedio)
  2. Que ya tienes un proyecto de Ruby on Rails configurado y operando (puedes ver los procesos de configuración en el blogpost que te menciono arriba o en el curso de Ruby on Rails intermedio)
  3. Que trabajarás con la versión 6 de Rails y la versión 2.7 de Ruby (estas versiones no debería ser bloqueante, pero es la que usaremos en este post)
  4. Que trabajarás con la versión 7.1.1 de Mongoid
  5. Que ya tienes nociones de Ruby on Rails (puedes adquirirlas en el curso Introductorio de Ruby on Rails)

Introducción

MongoDB es una base de datos documental diseñada para escalar y desarrollar con facilidad, que sea un sistema con almacenamiento documental ofrece ventajas de diseño como:

  1. Todo documento puede ser modelado con una estructura flexible y dinámica
  2. Un documento puede tener internamente más documentos embebidos evitando el uso de JOINS de bases de datos tradicionales
  3. Es posible modelar en nuestras aplicaciones de una manera natural soluciones de herencia o polimorfismos

Habiendo mencionado esto, en este post revisaremos cómo desde Rails podemos crear relaciones embebidas y además hacer consultas usando mecanismos de agregación con la tecnología Aggregation Framework de MongoDB.

Creando nuestro modelos

En el directorio app/models crearemos tres modelos: Person (Persona), Phone (Teléfono) y Job (Trabajo), cada uno de ellos con los siguientes atributos y configuraciones:

# app/models/person.rbclassPersonincludeMongoid::DocumentincludeMongoid::Timestamps

  GENDERS = {
    female:1,
    male:2
  }

  field :name, type: String
  field :gender, type: Integer
end
# app/models/phone.rbclassPhoneincludeMongoid::DocumentincludeMongoid::Timestamps

  TYPES = {
    phone:1,
    cellphone:2
  }

  field :number, type: String
  field :type, type: Integer
end
# app/models/job.rbclassJobincludeMongoid::DocumentincludeMongoid::Timestamps

  TYPES = {
    remote:1,
    office:2
  }

  field :name, type: String
  field :type, type: Integer
end

Relaciones

Ahora que tenemos nuestros tres modelos, podemos empezar a hacer las relaciones de los mismos, normalmente en un esquema relacional básico, pensaríamos en una relación como la presentada en la figura 1. Un trabajo puede tener muchas personas, y una persona puede tener muchos teléfonos


Figura 1.

Sin embargo, MongoDB nos permite simplificar al menos una de esas dos relaciones, y aquí debes preguntarte lo siguiente: en esta representación ¿el teléfono es independiente de la persona? ¿tiene sentido que exista un registro de teléfono sin una persona? ¿un registro de teléfono puede pertenecer a varias personas al mismo tiempo?

Planteemos una situación muy simple en la que al menos el teléfono no tenga sentido sin una persona que lo posea, y que además un teléfono pueda pertenecer a una y sola una persona. Bajo estas condiciones, podemos aplicar entonces una relación propia de las bases de datos documentales y de MongoDB, llamada relación embebida, tal y como se muestra en la figura 2.


Figura 2.

Así, nuestros modelos quedarían con las siguientes relaciones:

# app/models/person.rbclassPersonincludeMongoid::DocumentincludeMongoid::Timestamps

  GENDERS = {
    female:1,
    male:2
  }

  field :name, type: String
  field :gender, type: Integer

  belongs_to :job# relacion de asociacion
  embeds_many :phones, cascade_callbacks:true# relacion embebidaend# app/models/phone.rbclassPhoneincludeMongoid::DocumentincludeMongoid::Timestamps

  TYPES = {
    phone:1,
    cellphone:2
  }

  field :number, type: String
  field :type, type: Integer

  embedded_in :person# relacion embebidaend# app/models/job.rbclassJobincludeMongoid::DocumentincludeMongoid::Timestamps

  TYPES = {
    remote:1,
    office:2
  }

  field :name, type: String
  field :type, type: Integer

  has_many :people# relacion de asociacionend

Así, estamos conservando una relación tradicional entre Job y Person, usando has_many y belongs_to, pero para la relación entre Person y Phone usamos embeds_many y embedded_in y además estamos aplicando la opción cascade_callbacks: true para permitir que en actualizaciones y creaciones anidadas los callbacks se invoquen.

Información semilla

Habiendo diseñado nuestro modelo de datos, vamos a crear un conjunto de datos semilla en el archivo db/seeds.rb, haciendo uso de arreglos y un par de iteraciones. Y así tendríamos 2 trabajos, y 10 personas con sus respectivos teléfonos.

# db/seeds.rb
[
  ['developer', 1],
  ['sales', 2],
].each do |name, type|
  Job.create(name: name, type: type)
end

[
  ['rocio', 1, '555-5551', 'developer'],
  ['andrea', 1, '555-5552', 'sales'],
  ['ema', 1, '555-5553', 'developer'],
  ['valen', 1, '555-5554', 'sales'],
  ['cristina', 1, '555-5555', 'developer'],
  ['hector', 2, '555-5556', 'sales'],
  ['leon', 2, '555-5557', 'developer'],
  ['johan', 2, '555-5558', 'sales'],
  ['juan', 2, '555-5559', 'developer'],
  ['pedro', 2, '555-5550', 'sales'],
].eachdo |name, gender, phone, job|
  phone_type = job == 'developer' ? Phone::TYPES[:cellphone] : Phone::TYPES[:phone]
  Person.create(
    name: name,
    gender: gender,
    job: Job.find_by(name: name),
    phones: [
      { number: phone, type: phone_type }
    ]
  )
end

Y las vamos a ejecutar usando el siguiente comando en la raíz del proyecto

$ rails db:seed

Consultando información en relaciones embebidas

Vamos ahora a empezar a hacer consultas simples, por ejemplo, ¿cómo podemos acceder al teléfono de la primera persona, Rocio?

Accedamos a la consola de rails

$ rails console

Y ejecutamos el siguiente comando,

> Person.find_by(name: ‘rocio’).phones.first.number
#=> "555-5551"

De esta forma podemos acceder al número telefónico, ahora vamos a encontrar el ID de la persona con nombre Rocio, y vamos a buscar el mismo usuario por ese ID encontrado para luego obtener de nuevo su nombre

> id = Person.find_by(name: ‘rocio’).id
> person = Person.find(id)
> person.name#=> "rocio"

Bastante predecible el resultado ¿verdad?, bien, ahora seguiremos con la referencia de la persona en la variable person y vamos a obtener el ID del primer y único número de teléfono, para hacer una consulta similar y obtener desde el ID del teléfono la referencia al mismo.

> phone_id = person.phones.first.id
> phone = Phone.find(phone_id)
#=> Document(s) not found forclassPhonewithid(s) ...

Sorprendentemente, obtenemos un error que nos dice que no existe ningún registro de teléfono con el ID referenciado, ¿cómo es esto posible? Estamos haciendo lo mismo que hicimos con la persona…

Bien, el problema radica en la definición de la relación, y es que el ID del teléfono phone_id sólo tiene sentido dentro del documento Person y no por fuera de él, por esta razón no podemos buscar directamente desde el modelo Phone, sino más bien usando la referencia al documento o registro que lo contiene, ejecutando la consulta de la siguiente manera

> person.phones.find(phone_id).number
#=> “555-5551

Muy bien, habiendo entendido ese comportamiento, busquemos ahora a todas las personas del género masculino; para hacer esto podemos usar el método where que nos devolverá un resultado de tipo Mongoid::Criteria del que entre muchas otras cosas, podremos obtener la cantidad de registros que coincidan con esa consulta haciendo uso del método count

> criteria = Person.where(gender: Person::GENDERS[:male])
> criteria.count
#=> 5

Ahora, que tál si buscamos la cantidad de todas las personas del género masculino que además tengan teléfonos de tipo celular

> criteria = Person.where(gender:Person::GENDERS[:male], 'phones.type' => Phone::TYPES[:cellphone])
> criteria.count
#=> 2

Hemos entonces introducido un elemento de búsqueda muy particular… se trata del 'phones.type' => Phone::TYPES[:cellphone] donde estamos accediendo a la relación embebida phones y estamos concatenando la búsqueda del tipo de teléfono para obtener el resultado , genial ¿verdad?

Qué tal si ahora, buscamos la cantidad de personas que pertenezcan al género femenino y que tengan un trabajo remoto, qué tal si tratamos de usar el mismo mecanismo que usamos para el tipo de teléfono

> criteria = Job.where(type:Job::TYPES[:remote], ‘people.gender’ => Person::GENDERS[:female])
> criteria.count
#=> 0

Acorde al resultado y a nuestros datos semilla, nuestra salida no debería ser cero, ¿qué pasó? ¿por qué no funciona?

Bien, la razón de este comportamiento es que la relación entre Job y Person no es embebida y lo que estamos tratando de hacer en esa consulta es ejecutar un tipo de JOIN entre dos colecciones, algo muy común en las bases de datos tradicionales, pero al estar en una base de datos documental, debemos implementar otros métodos, para este caso debemos usar el Aggregation Framework.

Aggregation Framework - MongoDB

Esta tecnología nos permite hacer operaciones de agregación en tres distintos ejes dentro de MongoDB

  1. Aggregation pipeline
  2. Funciones map-reduce
  3. Métodos de agregación de propósito único

En este blog post trabajaremos de forma básica con el primero, el aggregation pipeline, esta tecnología te permite ejecutar una serie de procesos que darán un resultado por etapas, en cada etapa podrás obtener un subconjunto o conjunto de datos derivados de un universo de datos inicial, y en cada nueva etapa podrás usar el resultado como conjunto de la etapa inmediatamente anterior.

En la figura 3 es presentada una estructura simplificada gráfica de cómo podría visualizarse el proceso de agregación por etapas.


Figura 3

En la etapa cero, tenemos todo el universo de datos en el contexto necesario, en la etapa uno, hemos filtrado los datos que tienen una característica en especial, en la etapa dos hemos tomado esos datos especiales y los hemos transformado, y finalmente, en la etapa tres hemos agrupado nuestra colección final de datos.

Si bien podríamos seguir agregando etapas tanto intermedias como finales, la figura 3 pretende mostrar el flujo que un conjunto de datos podría seguir a través de un proceso de operaciones y transformaciones, cada una de estas etapas (“stages”) se definen a través de un operador específico, podrás encontrar más información en estos enlaces:

Bien, retomando nuestro ejercicio de buscar la cantidad de personas que pertenezcan al género femenino y que tengan un trabajo remoto, debemos entonces ejecutar una agregación en cinco etapas, usando los operadores $match$lookup$unwind$project.

Para hacer esto, vamos sólo a modo de experimentación a crear un método de clase en el modelo Person que se llamará by_gender_and_job y tendrá el siguiente código

classPerson# ...defself.by_gender_and_job(gender, job_type)
    collection.aggregate(
      [
        { '$match' => { gender: gender } },
        { '$lookup' =>  { from: 'jobs', localField: 'job_id', foreignField: '_id', as: 'job' } },
        { '$unwind' => '$job'},
        { '$match' => { 'job.type' => job_type } },
        { '$project' => { name: '$name', job: '$job.name' } }
      ]
    )
  end

En este bloque de código hemos empezado a usar elementos de agregación haciendo uso del método collection.aggregate y a modo de arreglo hemos definido cada una de las cinco etapas, con los operadores que te mencionaba arriba.

La primera etapa $match es donde realizamos el filtro de todas las personas del género seleccionado

La segunda etapa $lookup ejecuta un left outer join entre colecciones de la misma base datos, que nos permitirá ejecutar filtros y búsquedas en las siguientes etapas, para este caso, estamos trayendo todos los Job que estén asociados desde la llave foránea

La tercera etapa $unwind no es tan necesaria en este ejercicio, sin embargo, nos ayuda a deconstruir un posible arreglo resultado de la etapa anterior, en nuestro caso, siempre será un resultado de un arreglo de un sólo elemento.

La cuarta etapa $match nos ayuda a filtrar el tipo de trabajo ya teniendo en cuenta la colección Job

Finalmente, la quinta etapa $project tampoco es tan necesaria en este ejercicio, sin embargo nos ayuda a transformar toda la información obtenida hasta esa etapa y reducirla a tan solo tres campos: ID, name y job

Al ejecutar en la consola de rails obtendremos los siguientes resultados:

> Person.by_gender_and_job(Person::GENDERS[:female], Job::TYPES[:remote]).count
#=> 3
> Person.by_gender_and_job(Person::GENDERS[:female], Job::TYPES[:remote]).to_a
#=> [{"_id"=>BSON::ObjectId('5f20ed35e64d1e316fba8924'), "name"=>"rocio", "job"=>"developer"}, {"_id"=>BSON::ObjectId('5f20ed35e64d1e316fba8928'), "name"=>"ema", "job"=>"developer"}, {"_id"=>BSON::ObjectId('5f20ed35e64d1e316fba892c'), "name"=>"cristina", "job"=>"developer"}]

De esta forma, hemos aplicado el concepto de agregación usando Rails y MongoDB.

Johan
Johan
johan.tique

138Puntos

hace 4 años

Todas sus entradas
Escribe tu comentario
+ 2