La méthode createOrFirst

Publié le 4 octobre 2023 par Mathieu De Gracia
Couverture de l'article La méthode createOrFirst

Depuis sa version 10.20, Laravel intègre une méthode dénommée createOrFirst pouvant sembler redondante avec l'historique méthode firstOrCreate.

La raison d'être de cette nouvelle méthode provient d'une problématique que peut parfois rencontrer la méthode firstOrCreate dans des situations de concurrence.

Voyons ensemble quelles sont les particularités de cette méthode et en quoi elle se distingue de la méthode firstOrCreate !

Le problème du firstOrCreate

Comme son nom l'indique, un firstOrCreate fonctionne en deux étapes.

Tout d'abord, la méthode vérifie si une ligne spécifique existe déjà dans votre base de données, ensuite, si cette ligne n'est pas trouvée, la méthode procède à son insertion avant de vous la renvoyer.

Cependant, une problématique peut surgir pendant l'intervalle de temps entre l'exécution de ces deux étapes.

Si la méthode contenant l'appel au firstOrCreate est fortement sollicitée et que des lignes similaires sont envoyées dans un court laps de temps, une situation de concurrence peut se produire lors de l'insertion.

Dans notre exemple, la requête 1 n'ayant pas eu le temps de finaliser son insertion avant la réception de la requête 2, Laravel considérera que la ligne est absente et procédera à une nouvelle insertion.

Cette situation de concurrence pourrait entraîner l'insertion d'un doublon ou provoquer une erreur d'unicité au niveau de votre base de données.

Testons le createOrFirst

Afin de tester cette nouvelle méthode createOrFirst, commençons par créer la migration suivante :

1Schema::create('posts', function (Blueprint $table) {
2 $table->id();
3 $table->string('title')->unique();
4});

php artisan make:migration posts

Ainsi que le model associé :

1class Post extends Model
2{
3 public $timestamps = false;
4 protected $fillable = ['title'];
5}

php artisan make:model Post

Nous pouvons des lors commencer à tester la méthode createOrFirst à l'aide du code suivant, nous utiliserons les méthodes de debug SQL afin de visualiser les requêtes exécutées par le framework :

1DB::connection()->enableQueryLog();
2 
3Post::createOrFirst([
4 'title' => 'abc',
5]);
6 
7$queries = DB::getQueryLog();
8 
9dd($queries);

En exécutant ce code, aucun poste intitulé "abc" ne se trouvant en base de données, la variable $queries contiendra les informations suivantes :

1array:1 [
2 0 => array:3 [
3 "query" => "insert into `posts` (`title`) values (?)"
4 "bindings" => array:1 [
5 0 => "abc"
6 ]
7 "time" => 0.34
8 ]
9]

On voit ici que le framework effectue directement une insertion sans vérifier au préalable l'existence d'un post équivalent, ce qui est l'inverse du comportement d'une méthode firstOrCreate.

Exécutons à nouveau le code précédent, la contenu de la variable $queries sera quelque peu différent :

1array:1 [
2 0 => array:3 [
3 "query" => "select * from `posts` where (`title` = ?) limit 1"
4 "bindings" => array:1 [
5 0 => "abc"
6 ]
7 "time" => 0.36
8 ]
9]

Lors de cette seconde exécution, nous remarquons que seule une requête "select" fut exécutée sans la moindre trace de l'insertion, ce comportement était attendu car la post "abc" existait déjà dans notre base de données.

Voyons désormais comment le framework opère pour passer automatiquement d'un "create" vers un "first".

Anatomie du createOrFirst

Le code source de la méthode createOrFirst se trouve dans la class Builder de votre vendor et se décompose de la manière suivante :

1public function createOrFirst(array $attributes = [], array $values = [])
2{
3 try {
4 return $this->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, $values)));
5 } catch (UniqueConstraintViolationException $e) {
6 return $this->useWritePdo()->where($attributes)->first() ?? throw $e;
7 }
8}

les relations BelongsToMany & HasOneOrMany possèdent leur propre version de la méthode.

En examinant ces lignes, on remarque que Laravel est désormais capable de gérer une UniqueConstraintViolationException à la création d'une nouvelle ligne depuis sa méthode createOrFirst.

Au fin fond de votre vendor, vous trouverez une classe appelée Connection contenant une méthode nommée runQueryCallback, cette dernière est chargée d'exécuter les requêtes SQL de votre application.

Depuis Laravel 10, lorsque runQueryCallback détectera une erreur de contrainte d'unicité, la méthode déclenchera une UniqueConstraintViolationException entrainant la transition de la méthode createOrFirst que nous avons vu un peu plus haut d'un "create" vers un "first".

1protected function runQueryCallback($query, $bindings, Closure $callback)
2{
3 try {
4 return $callback($query, $bindings);
5 }
6 
7 catch (Exception $e) {
8 if ($this->isUniqueConstraintError($e)) {
9 throw new UniqueConstraintViolationException(
10 $this->getName(), $query, $this->prepareBindings($bindings), $e
11 );
12 }
13 
14 throw new QueryException(
15 $this->getName(), $query, $this->prepareBindings($bindings), $e
16 );
17 }
18}

Point extrêmement important si vous souhaitez utiliser la méthode createOrFirst, il est essentiel d'avoir configuré au préalable une contrainte "unique" dans la migration de la table en question :

1Schema::create('posts', function (Blueprint $table) {
2 $table->id();
3 $table->string('title')->unique();
4});

Dans le cas contraire, Laravel sera incapable de lever l'exception au moment opportun !

Cas concret

Lors d'une expérience professionnelle passée j'ai eu l'occasion de travailler sur un APM (Application performance monitoring), cette application avait pour responsabilité de récupérer les erreurs d'autres applications à travers une API d'une manière similaire à Sentry.

Cette application utilisait le fameux firstOrCreate pour sauvegarder les erreurs que nous recevions depuis l'API, ce point d'entrée était fortement sollicité et il n'était pas rare de recevoir plusieurs dizaines d'erreurs par seconde ... provoquant régulièrement des plantages dus aux contraintes d'unicités que nous avons abordées plus haut.

Le code en question était similaire à l'exemple suivant :

1try {
2 
3 /**
4 * This method is widely used.
5 * At times, firstOrCreate is not fast enough
6 * and causes duplicity errors.
7 */
8 return Issue::firstOrCreate([
9 'class' => $attributes['class'],
10 'message' => $attributes['message'],
11 ]);
12 
13} catch (QueryException $e) {
14 
15 /**
16 * QueryException probably caused by a duplicate issue.
17 */
18 return Issue::where('class', $attributes['class'])
19 ->where('message', $attributes['message'])
20 ->firstOrFail();
21}

Pour contrer cette problématique, nous avions mis en place la détection d'une QueryException qui était, dans la plupart des cas, vraisemblablement provoquée par une contrainte d'unicité.

L'utilisation d'un createOrFirst nous aurait permis de réduire le code précédant ... en ces quelques lignes :

1return Issue::createOrFirst([
2 'class' => $attributes['class'],
3 'message' => $attributes['message'],
4]);
Mathieu De Gracia avatar
Mathieu De Gracia
Des fois, mon chat code à ma place 🐱

A lire

Autres articles de la même catégorie