ActiveRecord Import: Importando registros en lote en Ruby

Una de las razones más comunes por las cuales una aplicación puede volverse lenta tiene que ver con las operaciones con la Base de Datos.
Las consultas SQL pueden resultar muy costosas en términos de tiempos y esto puede llevar a una mala experiencia de usuario.

Importando libros

Imaginemos que tenemos que importar datos de libros desde un archivo csv. Ese csv lo sube el usuario y debemos importar todos los libros en la tabla books.

Seguramente nuestro código sea similar a lo siguiente:

CSV.foreach(file.path) do |row|
isbn, title, pages = row

Book.create!(isbn: isbn, title: title, pages: pages)
end

Esto resuelve lo pedido, aunque tiene algunos problemas.

En primer lugar, los libros se crean siempre, en lugar de actualizarlos si ya existen. Para corregir esto podemos modificar nuestro código para que quede de la siguiente forma:

CSV.foreach(file.path) do |row|
isbn, title, pages = row

book = Book.find_or_initialize_by(isbn: isbn)
book.title = title
book.pages = pages
book.save!
end

¡Listo! Ya resuelve lo pedido.

El problema ahora es que si el csv es muy grande estaríamos ejecutando un SELECT e INSERT|UPDATE por cada uno de los libros, lo que podría demorarse más de la cuenta.

Para evitar estos problemas podemos hacer una inserción/actualización en lote. Es decir, tratar de realizar todos los cambios en una sola operación SQL. ¡Sí! los manejadores de BD nos permiten realizar este tipo de operaciones 💪.

Para esto vamos a usar la gema Activerecord-Import, que nos permite realizar operaciones en lote con múltiples DBMS (MySQL, Postgresql, etc). Asi que veamos un ejemplo de cómo utilizarla.

Ejemplo con Activerecord-Import

Lo primero que debemos hacer es añadir la gema al Gemfile:

gem 'activerecord-import'

Luego de ejecutar bundle install podremos hacer uso del método import sobre nuestras clases que hereden de ActiveRecord.

Volviendo al ejemplo de los libros, podríamos hacer lo siguiente:

books = []
CSV.foreach(file.path) do |row|
isbn, title, pages = row

books << Book.new(isbn: isbn, title: title, pages: pages)

# También podrían ser hashes
# books << { isbn: isbn, title: title, pages: pages }
end

result = Book.import books

Con este código se ejecuta una sola consulta SQL e inserta todos los libros bajando nuestros tiempos considerablemente con respecto al primer ejemplo que vimos.

Peeeroo….tiene el problema de que inserta siempre los libros, sin tener en cuenta si ya existe 🤔.

Para solucionarlo debemos hacer uso de la opción on_duplicate_key_update.

result = Book.import books,
  on_duplicate_key_update: {
    conflict_target: [:isbn], # Columna que debe coincidir
    columns: [:title, :pages] # Columnas a actualizar
 }

De esta forma si existe un registro que coincide el isbn, en lugar de insertarlo, actualiza las columnas title y pages.

En result tendremos la cantidad de instancias que fallaron, el número de inserciones y los ids que se actualizaron/insertaron:

result.failed_instances
=> [#<Book title: "El Principito", pages: nil, created_at: nil, updated_at: nil>]
result.num_inserts
=> 145
result.ids
=> [1, 2, 10, ...]

Importar asociaciones

Además, por si fuera poco, la gema nos permite realizar inserciones de registros con asociaciones (has_many/has_one) a través de la opción recursive. Entonces podríamos tener:

books = []
10.times do |i|
book = Book.new(name: "book #{i}")
book.reviews.build(title: "Excellent")

books << book
end

Book.import books, recursive: true

Muchas opciones más

Así como existe recursive y on_duplicate_key_update hay muchísimas más opciones, así que te invito a buscar en la documentación de la gema.