Ejecutar (re)importaciones de datos de forma óptima en Laravel con los Upsert de Eloquent
Una de las tareas más repetidas en las aplicaciones que hacemos en Calima es la de importar datos de terceros de forma periódica. En muchas ocasiones, estos datos tienen que sobrescribir a los actuales de la base de datos por si acaso hayan cambiado en el proveedor externo.
Por ejemplo, supongamos que queremos importar posts de blog de un sistema externo. Nuestro código podría ser algo así:
<?php
namespace App\Console\Commands;
use App\Http\Services\BlogPostDownloader;
use App\Models\BlogPost;
use Illuminate\Console\Command;
class DownloadPosts extends Command
{
protected $signature = 'download:posts';
protected $description = 'Downloads blog posts';
public function handle()
{
$downloader = new BlogPostDownloader();
$posts = $downloader->posts();
foreach ($posts as $post) {
// update the existing post or create a new one
$currentPost = BlogPost::updateOrCreate([
'slug' => $post->slug,
], [
'title' => $post->title,
'published_at' => $post->published_at,
'content' => $post->content,
]);
}
return 0;
}
}
Este código es expresivo y hace lo que tiene que hacer. Sin embargo, tiene un problema. Es posible que haga muchas queries a la base de datos. Una de las cosas que he aprendido a lo largo de los años es que el principal cuello de botella de nuestra aplicación será la base de datos, y tenemos que reducir al máximo las queries que se hagan. Si este comando lo ejecutamos cada 5min o cada hora, estamos haciendo potencialmente miles de queries cada vez.
¿Cómo podemos mejorar este código? Utilizando upserts.
Qué es un upsert
Un upsert es una instrucción SQL que permite ejecutar varios updateOrCreate de forma nativa en SQL, de forma que podemos crear o actualizar varios registros a la misma vez.
Usar upserts en Eloquent con Laravel
Por supuesto, esto está soportado nativamente en Laravel a través de Eloquent. El único requisito es que, a menos que estemos usando SQL Server, las columnas que definen la unicidad de un registro deben tener un unique index.
Por ejemplo, si la unicidad de un post fuera definido por las columnas slug y source_domain, en nuestra migración tendríamos algo así:
$table->unique(['slug', 'source_domain']);
En Eloquent, podemos hacer un upsert de la siguiente forma:
$data = [...];
$uniqueColumns = ['slug'];
$columnsToInsertOrUpdate = ['title', 'published_at', 'content'];
BlogPost::upsert($data, $uniqueColumns, $columnsToInsertOrUpdate);
Por lo que podríamos modificar nuestro ejemplo anterior de la siguiente forma:
<?php
namespace App\Console\Commands;
use App\Http\Services\BlogPostDownloader;
use App\Models\BlogPost;
use Illuminate\Console\Command;
class DownloadPosts extends Command
{
protected $signature = 'download:posts';
protected $description = 'Downloads blog posts';
public function handle()
{
$downloader = new BlogPostDownloader();
$posts = $downloader->posts();
BlogPost::upsert($posts, ['slug'], ['title', 'published_at', 'content']);
return 0;
}
}
Con esto se nos queda un código mucho más simple y óptimo, que ejecuta una única query SQL y es mucho, mucho más rápido que ir actualizando fila a fila en la base de datos.