Évitez les bugs de pagination et les bindings cassés avec SelectSub
Vous avez déjà galéré avec des sous-requêtes qui cassent votre pagination ? Passé des heures à déboguer des bindings SQL qui se mélangent ? Vu votre application ralentir à cause de requêtes de comptage qui incluent des agrégations inutiles ?
selectSub() est là pour vous sauver. Cette méthode du Query Builder de Laravel résout élégamment tous ces problèmes en une seule ligne de code. Et pourtant, beaucoup de développeurs ne la connaissent pas ou l'ignorent !
Voyons comment selectSub() peut transformer vos requêtes complexes en code propre et performant.
Le problème des sous-requêtes manuelles
Avant de découvrir selectSub(), la plupart des développeurs ajoutent des sous-requêtes comme ça :
1$subquery = Leave::query()2 ->selectRaw('COUNT(*)')3 ->whereColumn('leaves.user_id', 'users.id')4 ->whereDate('leaves.date_at', '>=', $startDate);5 6$users = User::query()7 ->selectRaw("({$subquery->toSql()}) as leaves_count")8 ->mergeBindings($subquery->getBindings())9 ->paginate(50);
Ça fonctionne ? Oui. C'est propre ? Non. Et surtout, ça peut casser à tout moment.
Problème #1 : Les bindings qui se mélangent
Le vrai problème apparaît quand votre sous-requête contient des bindings :
1$subquery = Order::query() 2 ->selectRaw('SUM(amount)') 3 ->whereColumn('orders.user_id', 'users.id') 4 ->where('status', '=', ?) // ← Binding de la sous-requête 5 ->whereDate('created_at', '>=', ?); // ← Autre binding 6 7$users = User::query() 8 ->where('status', 'active') // ← Binding de la requête principale 9 ->selectRaw("({$subquery->toSql()}) as total_spent")10 ->mergeBindings($subquery->getBindings()) // ← Bindings de la sous-requête11 ->paginate(50);
Que se passe-t-il ? Laravel génère une requête SQL avec les bindings dans le mauvais ordre :
1-- Ce que Laravel génère (INCORRECT)2SELECT (SELECT SUM(amount) FROM orders WHERE status = ? AND created_at >= ?) as total_spent3FROM users4WHERE status = ?5-- Bindings: ["completed", "2024-01-01", "active"]6-- Mais Laravel peut les mélanger !
Cela peut entraîner des erreurs SQL, des données incorrectes, ou pire, des risques d’injection SQL si les bindings ne sont pas correctement gérés.
Problème #2 : La pagination cassée
Le problème le plus fréquent avec les sous-requêtes manuelles : la pagination qui ne fonctionne plus.
Quand vous appelez paginate(), Laravel génère automatiquement une requête de comptage :
1$users = User::query()2 ->selectRaw("({$subquery->toSql()}) as leaves_count")3 ->paginate(50);
Sans selectSub(), Laravel génère la requête suivante :
1-- Requête de comptage générée automatiquement2SELECT COUNT(*) FROM (3 SELECT users.*,4 (SELECT COUNT(*) FROM leaves ...) as leaves_count5 FROM users6) as count_query;
Cette requête a plusieurs problèmes :
- La sous-requête s'exécute dans le COUNT (inutile et lent)
- Si votre sous-requête contient un
GROUP BY, ça peut planter - Le résultat du comptage peut être incorrect
Avec selectSub, Laravel exclut automatiquement la colonne du COUNT, ce qui donne un comptage rapide, correct et qui fonctionne toujours :
1-- Requête de comptage générée automatiquement2SELECT COUNT(*) FROM (3 SELECT users.* -- ← leaves_count est automatiquement exclu !4 FROM users5) as count_query;
Problème #3 : La maintenabilité en souffrance
Votre code devient vite illisible dès lors que vous multipliez les sous-requêtes nuisant à la maintenabilité de votre code :
1$subquery1 = Leave::query()->selectRaw('...'); 2$subquery2 = Room::query()->selectRaw('...'); 3$subquery3 = Shift::query()->selectRaw('...'); 4 5$users = User::query() 6 ->selectRaw("({$subquery1->toSql()}) as leaves_count") 7 ->mergeBindings($subquery1->getBindings()) 8 ->selectRaw("({$subquery2->toSql()}) as rooms_count") 9 ->mergeBindings($subquery2->getBindings())10 ->selectRaw("({$subquery3->toSql()}) as shifts_count")11 ->mergeBindings($subquery3->getBindings())12 ->paginate(50);
C'est verbeux, répétitif, et source d'erreurs. Un oubli de mergeBindings() et c'est le drame.
La solution : selectSub() en une ligne
selectSub() résout tous ces problèmes en une seule méthode :
1$subquery = Order::query()2 ->selectRaw('SUM(amount)')3 ->whereColumn('orders.user_id', 'users.id')4 ->where('status', 'completed')5 ->whereDate('created_at', '>=', now()->subMonth());6 7$users = User::query()8 ->selectSub($subquery, 'total_spent')9 ->paginate(50);
C'est tout, vraiment ! Voici ce que selectSub() fait automatiquement pour vous
- Gère les bindings : fusionne correctement les bindings de la sous-requête avec ceux de la requête principale
- Exclut du COUNT : retire automatiquement la colonne des requêtes de comptage pour la pagination
- Type safety : fonctionne avec des instances Query Builder, pas des chaînes SQL brutes
Exemple concret : statistiques utilisateur
Imaginons que vous voulez afficher une liste d'utilisateurs avec le nombre de commandes qu'ils ont passées ce mois-ci.
Sans selectSub()
1public function index(Request $request) 2{ 3 $startDate = now()->startOfMonth(); 4 $endDate = now()->endOfMonth(); 5 6 // Sous-requête avec bindings 7 $subquery = Order::query() 8 ->selectRaw('COUNT(*)') 9 ->whereColumn('orders.user_id', 'users.id')10 ->where('status', '=', ?) // Binding #111 ->whereDate('created_at', '>=', ?) // Binding #212 ->whereDate('created_at', '<=', ?); // Binding #313 14 $users = User::query()15 ->select('users.*')16 ->selectRaw("({$subquery->toSql()}) as orders_this_month")17 ->mergeBindings($subquery->getBindings())18 ->paginate(50);19 20 return UserResource::collection($users);21}
Problèmes :
- Si vous oubliez
mergeBindings(), les bindings sont perdus - La pagination peut être cassée (sous-requête dans le COUNT)
- Le code est verbeux et difficile à maintenir
- Risque d'erreur si vous modifiez la sous-requête plus tard
Avec selectSub()
1public function index(Request $request) 2{ 3 $users = User::query() 4 ->withOrdersThisMonth() // Scope qui utilise selectSub() 5 ->paginate(50); 6 7 return UserResource::collection($users); 8} 9 10// Dans le modèle User11public function scopeWithOrdersThisMonth(Builder $query): void12{13 $startDate = now()->startOfMonth();14 $endDate = now()->endOfMonth();15 16 $subquery = Order::query()17 ->selectRaw('COUNT(*)')18 ->whereColumn('orders.user_id', 'users.id')19 ->where('status', 'completed')20 ->whereDate('created_at', '>=', $startDate)21 ->whereDate('created_at', '<=', $endDate);22 23 // Une seule ligne, et tout fonctionne24 $query->selectSub($subquery, 'orders_this_month');25}
Avantages :
- Les bindings sont gérés automatiquement
- La pagination fonctionne toujours (colonne exclue du COUNT)
- Le code est propre et réutilisable
- Facile à tester et maintenir
Cas d'usage avancés
Multiples sous-requêtes
Vous pouvez chaîner plusieurs selectSub() sans problème :
1$users = User::query()2 ->selectSub($leavesSubquery, 'leaves_count')3 ->selectSub($roomsSubquery, 'rooms_count')4 ->selectSub($shiftsSubquery, 'shifts_count')5 ->paginate(50);
Chaque sous-requête gère ses propres bindings, et toutes sont exclues du COUNT automatiquement.
Sous-requêtes conditionnelles
Vous pouvez même ajouter des sous-requêtes conditionnellement :
1$query = User::query(); 2 3if ($request->has('include_leaves')) { 4 $query->selectSub($leavesSubquery, 'leaves_count'); 5} 6 7if ($request->has('include_rooms')) { 8 $query->selectSub($roomsSubquery, 'rooms_count'); 9}10 11$users = $query->paginate(50);
La pagination fonctionnera toujours, peu importe le nombre de sous-requêtes ajoutées.
Sous-requêtes avec relations
Vous pouvez utiliser selectSub() avec des relations Eloquent :
1$subquery = User::find(1)->orders()2 ->selectRaw('SUM(amount)')3 ->where('status', 'completed')4 ->whereDate('created_at', '>=', now()->subMonth())5 ->getQuery();6 7$users = User::query()8 ->selectSub($subquery, 'recent_orders_total')9 ->paginate(50);
Performance : selectSub() vs selectRaw()
Vous vous demandez peut-être si selectSub() a un impact sur les performances ?
Réponse courte : non. selectSub() génère exactement le même SQL que selectRaw() avec mergeBindings(), mais de manière plus sûre.
La vraie différence de performance vient de la pagination :
1# Avec selectRaw() - Requête de comptage lente2SELECT COUNT(*) FROM (3 SELECT users.*, (SELECT COUNT(*) FROM leaves ...) as leaves_count4 FROM users5) as count_query;
Temps d'exécution : ~500ms (sous-requête inutile dans le COUNT)
1# Avec selectSub() - Requête de comptage optimisée2SELECT COUNT(*) FROM (3 SELECT users.*4 FROM users5) as count_query;
Temps d'exécution : ~50ms (pas de sous-requête dans le COUNT)
Sur une liste paginée avec 1000 utilisateurs, la différence est énorme. selectSub() peut diviser le temps de réponse par 10.
Quand utiliser selectSub() ?
Même si votre sous-requête n'a pas de bindings, utilisez selectSub() dans tous les cas pour :
- La pagination : éviter les problèmes de comptage
- La maintenabilité : code plus propre et réutilisable
- La sécurité : gestion automatique des bindings si vous en ajoutez plus tard
- La cohérence : même approche partout dans votre codebase
Exception : Si vous avez vraiment besoin d'une requête SQL brute complexe qui ne peut pas être exprimée avec le Query Builder, utilisez selectRaw(). Mais c'est très rare.
Migration depuis selectRaw()
Si vous avez déjà du code avec selectRaw() et mergeBindings(), la migration est simple :
Avant :
1$subquery = Leave::query()->selectRaw('COUNT(*)');2 3$users = User::query()4 ->selectRaw("({$subquery->toSql()}) as leaves_count")5 ->mergeBindings($subquery->getBindings())6 ->paginate(50);
Après :
1$subquery = Leave::query()->selectRaw('COUNT(*)');2 3$users = User::query()4 ->selectSub($subquery, 'leaves_count')5 ->paginate(50);
C'est littéralement deux lignes en moins, et votre code est plus sûr.
Conclusion
selectSub() est une de ces méthodes Laravel qui paraît simple en surface mais qui résout des problèmes complexes :
- Sécurité : gestion automatique des bindings SQL
- Performance : exclusion automatique des colonnes dans les requêtes de comptage
- Maintenabilité : code plus propre et réutilisable
- Fiabilité : moins de bugs, moins de surprises
Une seule ligne de code pour éviter des heures de débogage. C'est ça, la magie de Laravel.
La prochaine fois que vous ajoutez une sous-requête à votre requête principale, pensez à selectSub(). Votre futur vous remerciera.
A lire
Autres articles de la même catégorie
Comment développer un paquet en local ?
Vous souhaitez ajouter une fonctionnalité à votre application via un paquet ? Voici un tour d’horizon de la phase de développement.
William Suppo
Eclaircir le Test-Driven Development (TDD) avec Laravel
Explorons le Test Driven Development (TDD) avec Laravel
Marc COLLET
La méthode Mikado à la rescousse de nos refactos !
Découvrons ensemble la méthode Mikado qui nous aide à refactoriser de manière itérative et logique notre code
William Suppo