Introduction
Joins combine rows from multiple tables, and aggregations compute summary values. A query builder that supports both allows complex reporting and data retrieval through the same fluent interface.
Key Concepts
- JOIN: Combines rows from two tables based on a related column.
- LEFT JOIN: Returns all rows from the left table, with NULLs for non-matching right-table rows.
- Aggregation: Functions like COUNT, SUM, AVG, MIN, MAX that compute a single value from multiple rows.
Real World Context
An admin dashboard showing "users with their order counts and total spend" requires a LEFT JOIN (to include users with zero orders) combined with GROUP BY and aggregate functions. A good query builder makes this readable.
Deep Dive
Adding JOIN support to the query builder:
php<?php class QueryBuilder { // ... existing properties ... private array $joins = []; private ?string $groupBy = null; private ?string $having = null; public function join(string $table, string $first, string $op, string $second): self { $clone = clone $this; $clone->joins[] = "JOIN $table ON $first $op $second"; return $clone; } public function leftJoin(string $table, string $first, string $op, string $second): self { $clone = clone $this; $clone->joins[] = "LEFT JOIN $table ON $first $op $second"; return $clone; } public function groupBy(string ...$columns): self { $clone = clone $this; $clone->groupBy = implode(', ', $columns); return $clone; } public function having(string $condition, mixed $value): self { $clone = clone $this; $placeholder = ':having_0'; $clone->having = "$condition $placeholder"; $clone->bindings[$placeholder] = $value; return $clone; } public function count(): int { $clone = clone $this; $clone->columns = ['COUNT(*) as cnt']; $clone->orderBy = []; $clone->limit = null; $result = $clone->first(); return (int) ($result['cnt'] ?? 0); } public function sum(string $column): float { $clone = clone $this; $clone->columns = ["SUM($column) as total"]; $clone->orderBy = []; $result = $clone->first(); return (float) ($result['total'] ?? 0); } }
Using joins with aggregations:
php<?php $qb = new QueryBuilder($pdo); // Users with order statistics $stats = $qb ->table('users u') ->select('u.id', 'u.name', 'COUNT(o.id) as order_count', 'COALESCE(SUM(o.total), 0) as total_spent') ->leftJoin('orders o', 'u.id', '=', 'o.user_id') ->where('u.status', 'active') ->groupBy('u.id', 'u.name') ->orderBy('total_spent', 'DESC') ->limit(10) ->get();
The generated SQL is a single efficient query that would otherwise be an N+1 problem.
Self-joins for hierarchical data:
php<?php $categories = $qb ->table('categories c') ->select('c.id', 'c.name', 'p.name as parent_name') ->leftJoin('categories p', 'c.parent_id', '=', 'p.id') ->orderBy('c.name') ->get();
With PHP 8.5's pipe operator, post-processing aggregation results becomes fluid:
php<?php $topSpenders = $qb ->table('users u') ->select('u.name', 'SUM(o.total) as spent') ->join('orders o', 'u.id', '=', 'o.user_id') ->groupBy('u.id', 'u.name') ->orderBy('spent', 'DESC') ->limit(5) ->get() |> (fn($rows) => array_column($rows, 'name')); // Returns just the names of top 5 spenders
Common Pitfalls
- Using JOIN instead of LEFT JOIN for optional relationships — A regular JOIN excludes parent rows that have no matching child rows, silently dropping data from results.
- Forgetting GROUP BY with aggregations — Selecting non-aggregated columns alongside COUNT/SUM without GROUP BY produces undefined behavior in strict SQL mode.
Best Practices
- Always use LEFT JOIN for optional relationships — Use INNER JOIN only when you specifically want to exclude unmatched rows.
- Use COALESCE with SUM —
SUM()returns NULL when no rows match.COALESCE(SUM(col), 0)returns zero instead, preventing null propagation.
Summary
- JOIN and LEFT JOIN combine data from multiple tables in a single query.
- Aggregation functions (COUNT, SUM, AVG) compute summary statistics with GROUP BY.
- Use LEFT JOIN for optional relationships and COALESCE to handle NULL aggregates.
- PHP 8.5's pipe operator streamlines post-query result transformations.
Code Examples
php
<?php
declare(strict_types=1);
$qb = new QueryBuilder($pdo);
// Dashboard: users with order stats
$dashboard = $qb
->table('users u')
->select(
'u.id',
'u.name',
'u.email',
'COUNT(o.id) as order_count',
'COALESCE(SUM(o.total), 0) as total_spent',
'MAX(o.created_at) as last_order'
)
->leftJoin('orders o', 'u.id', '=', 'o.user_id')
->where('u.status', 'active')
->groupBy('u.id', 'u.name', 'u.email')
->orderBy('total_spent', 'DESC')
->limit(20)
->get();
?>