No artigo PDO Avançado, Prepared Statements e Padrão Repository construímos um repositório com SQL escrito à mão. Isso funciona bem para queries simples, mas à medida que filtros dinâmicos aparecem — "buscar produtos por categoria, com faixa de preço opcional, com ou sem estoque" — o código vira uma série de if concatenando strings SQL, propenso a bugs e difícil de testar.
Query Builders resolvem isso: são objetos que constroem SQL programaticamente de forma fluente, segura e testável. E Migrations resolvem outro problema igualmente importante: como versionar o schema do banco de dados junto com o código, garantindo que qualquer desenvolvedor do time possa recriar o banco idêntico com um único comando.
Neste artigo construímos um Query Builder funcional do zero, entendemos como migrations funcionam internamente, e vemos os padrões que frameworks como Laravel e Phinx implementam.
Query Builder — construindo SQL dinamicamente
Um Query Builder deve produzir sempre SQL válido com parâmetros separados — nunca interpolar valores diretamente na query. A interface fluente (cada método retorna $this) permite encadear chamadas na ordem que fizer sentido para o código.
<?php
declare(strict_types=1);
class QueryBuilder
{
private string $tabela = '';
private array $selects = ['*'];
private array $joins = [];
private array $wheres = [];
private array $params = [];
private array $orderBys = [];
private ?int $limite = null;
private ?int $offset = null;
private array $groupBys = [];
private ?string $having = null;
// ── DSL fluente ──────────────────────────────────────────────────
public function from(string $tabela): static
{
$this->tabela = $tabela;
return $this;
}
public function select(string ...$colunas): static
{
$this->selects = $colunas;
return $this;
}
public function join(string $tabela, string $condicao, string $tipo = 'INNER'): static
{
$this->joins[] = strtoupper($tipo) . " JOIN {$tabela} ON {$condicao}";
return $this;
}
public function leftJoin(string $tabela, string $condicao): static
{
return $this->join($tabela, $condicao, 'LEFT');
}
public function where(string $condicao, mixed ...$valores): static
{
$this->wheres[] = $condicao;
array_push($this->params, ...$valores);
return $this;
}
// Adiciona WHERE apenas se o valor não for null — padrão "filtro opcional".
// O parâmetro de teste é mixed de propósito: filtro opcional também
// chega como número ou data, e com ?string mais strict_types isso daria TypeError.
public function whereIf(mixed $valor, string $condicao, mixed ...$params): static
{
if ($valor !== null && $valor !== '') {
return $this->where($condicao, ...$params);
}
return $this;
}
public function whereIn(string $coluna, array $valores): static
{
// Lista vazia não pode virar "sem filtro": o WHERE sumiria e a query
// devolveria a tabela inteira. Como IN () é erro de sintaxe, quem
// expressa "nenhum resultado" é a condição impossível.
if (empty($valores)) {
$this->wheres[] = '1 = 0';
return $this;
}
$placeholders = implode(', ', array_fill(0, count($valores), '?'));
$this->wheres[] = "{$coluna} IN ({$placeholders})";
array_push($this->params, ...$valores);
return $this;
}
public function orderBy(string $coluna, string $direcao = 'ASC'): static
{
$direcao = strtoupper($direcao) === 'DESC' ? 'DESC' : 'ASC';
$this->orderBys[] = "{$coluna} {$direcao}";
return $this;
}
public function groupBy(string ...$colunas): static
{
array_push($this->groupBys, ...$colunas);
return $this;
}
public function having(string $condicao): static
{
$this->having = $condicao;
return $this;
}
public function limit(int $limite): static
{
$this->limite = $limite;
return $this;
}
public function offset(int $offset): static
{
$this->offset = $offset;
return $this;
}
// ── Compilação ────────────────────────────────────────────────────
public function build(): array // retorna [$sql, $params]
{
if (empty($this->tabela)) {
throw new \LogicException("Tabela não definida — chame from() antes de build().");
}
$partes = [];
// SELECT
$partes[] = 'SELECT ' . implode(', ', $this->selects);
// FROM
$partes[] = 'FROM ' . $this->tabela;
// JOINs
if (!empty($this->joins)) {
$partes[] = implode("\n", $this->joins);
}
// WHERE
if (!empty($this->wheres)) {
$partes[] = 'WHERE ' . implode(' AND ', $this->wheres);
}
// GROUP BY
if (!empty($this->groupBys)) {
$partes[] = 'GROUP BY ' . implode(', ', $this->groupBys);
}
// HAVING
if ($this->having !== null) {
$partes[] = 'HAVING ' . $this->having;
}
// ORDER BY
if (!empty($this->orderBys)) {
$partes[] = 'ORDER BY ' . implode(', ', $this->orderBys);
}
// LIMIT / OFFSET
if ($this->limite !== null) {
$partes[] = 'LIMIT ' . $this->limite;
}
if ($this->offset !== null) {
$partes[] = 'OFFSET ' . $this->offset;
}
return [implode("\n", $partes), $this->params];
}
public function executar(\PDO $pdo): array
{
[$sql, $params] = $this->build();
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
// ── Métodos de conveniência para INSERT/UPDATE/DELETE ─────────────
public static function inserir(string $tabela, array $dados, \PDO $pdo): int
{
$colunas = implode(', ', array_keys($dados));
$placeholders = implode(', ', array_fill(0, count($dados), '?'));
$stmt = $pdo->prepare("INSERT INTO {$tabela} ({$colunas}) VALUES ({$placeholders})");
$stmt->execute(array_values($dados));
return (int) $pdo->lastInsertId();
}
public static function atualizar(string $tabela, array $dados, string $where, array $whereParams, \PDO $pdo): int
{
$sets = implode(', ', array_map(fn($col) => "{$col} = ?", array_keys($dados)));
$stmt = $pdo->prepare("UPDATE {$tabela} SET {$sets} WHERE {$where}");
$stmt->execute([...array_values($dados), ...$whereParams]);
return $stmt->rowCount();
}
}
Usando o Query Builder — filtros dinâmicos
O grande benefício do Query Builder aparece quando os filtros são opcionais. Sem ele, o código fica cheio de concatenações perigosas:
<?php
declare(strict_types=1);
// ── FILTROS OPCIONAIS — o padrão mais comum ───────────────────────────
function buscarProdutos(
\PDO $pdo,
?string $categoria = null,
?float $precoMin = null,
?float $precoMax = null,
?bool $apenasAtivos = true,
int $pagina = 1,
int $porPagina = 20,
): array {
$qb = (new QueryBuilder())
->from('produtos p')
->select('p.id', 'p.nome', 'p.preco', 'p.estoque', 'c.nome AS categoria')
->leftJoin('categorias c', 'c.id = p.categoria_id')
->orderBy('p.nome')
->limit($porPagina)
->offset(($pagina - 1) * $porPagina);
// Cada filtro só é adicionado se o valor for fornecido
if ($apenasAtivos) {
$qb->where('p.ativo = ?', 1);
}
if ($categoria !== null) {
$qb->where('c.slug = ?', $categoria);
}
if ($precoMin !== null) {
$qb->where('p.preco >= ?', $precoMin);
}
if ($precoMax !== null) {
$qb->where('p.preco <= ?', $precoMax);
}
return $qb->executar($pdo);
}
// Busca com todos os filtros
$resultado = buscarProdutos(
pdo: $pdo,
categoria: 'eletronicos',
precoMin: 100.0,
precoMax: 2000.0,
pagina: 2,
);
// Busca simples — sem filtros opcionais
$todos = buscarProdutos(pdo: $pdo, apenasAtivos: false);
// ── RELATÓRIO COM GROUP BY ─────────────────────────────────────────────
$vendasPorCategoria = (new QueryBuilder())
->from('pedido_itens pi')
->select(
'c.nome AS categoria',
'SUM(pi.quantidade) AS total_vendido',
'SUM(pi.quantidade * pi.preco) AS receita',
)
->join('produtos p', 'p.id = pi.produto_id')
->join('categorias c', 'c.id = p.categoria_id')
->join('pedidos pe', 'pe.id = pi.pedido_id')
->where('pe.criado_em >= ?', '2024-01-01')
->where('pe.status = ?', 'entregue')
->groupBy('c.id', 'c.nome')
->having('total_vendido > 0')
->orderBy('receita', 'DESC')
->executar($pdo);
// ── WHERE IN com lista dinâmica ────────────────────────────────────────
$ids = [1, 5, 12, 47, 99];
$produtosSelecionados = (new QueryBuilder())
->from('produtos')
->select('id', 'nome', 'preco')
->whereIn('id', $ids)
->orderBy('nome')
->executar($pdo);
Migrations — versionando o schema
Migrations são arquivos PHP que descrevem uma mudança no schema do banco de dados. Cada migration tem dois métodos: up() (aplica a mudança) e down() (desfaz). Uma tabela de controle no banco registra quais migrations já foram executadas, garantindo que cada migration rode apenas uma vez e na ordem correta.
<?php
declare(strict_types=1);
// ── ESTRUTURA BASE ────────────────────────────────────────────────────
abstract class Migration
{
public function __construct(protected \PDO $pdo) {}
abstract public function up(): void;
abstract public function down(): void;
protected function executar(string $sql): void
{
$this->pdo->exec($sql);
}
}
// ── MIGRATION 001 — criação da tabela usuarios ─────────────────────────
class Migration001CriarTabelaUsuarios extends Migration
{
public function up(): void
{
$this->executar("
CREATE TABLE IF NOT EXISTS usuarios (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(150) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
senha_hash VARCHAR(255) NOT NULL,
ativo TINYINT(1) NOT NULL DEFAULT 1,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
atualizado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_ativo (ativo)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
public function down(): void
{
$this->executar("DROP TABLE IF EXISTS usuarios");
}
}
// ── MIGRATION 002 — criação de produtos e categorias ──────────────────
class Migration002CriarProdutos extends Migration
{
public function up(): void
{
$this->executar("
CREATE TABLE IF NOT EXISTS categorias (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
$this->executar("
CREATE TABLE IF NOT EXISTS produtos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
categoria_id INT UNSIGNED NOT NULL,
nome VARCHAR(200) NOT NULL,
descricao TEXT,
preco DECIMAL(10,2) NOT NULL,
estoque INT UNSIGNED NOT NULL DEFAULT 0,
ativo TINYINT(1) NOT NULL DEFAULT 1,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (categoria_id) REFERENCES categorias(id) ON DELETE RESTRICT,
INDEX idx_categoria (categoria_id),
INDEX idx_ativo_preco (ativo, preco)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
public function down(): void
{
// Ordem inversa — respeita foreign keys
$this->executar("DROP TABLE IF EXISTS produtos");
$this->executar("DROP TABLE IF EXISTS categorias");
}
}
// ── MIGRATION 003 — adicionar coluna a tabela existente ──────────────
class Migration003AdicionarPerfilUsuario extends Migration
{
public function up(): void
{
$this->executar("
ALTER TABLE usuarios
ADD COLUMN perfil ENUM('usuario','admin','moderador') NOT NULL DEFAULT 'usuario' AFTER email,
ADD COLUMN ultimo_login DATETIME NULL AFTER perfil,
ADD INDEX idx_perfil (perfil)
");
}
public function down(): void
{
$this->executar("ALTER TABLE usuarios DROP INDEX idx_perfil");
$this->executar("ALTER TABLE usuarios DROP COLUMN ultimo_login");
$this->executar("ALTER TABLE usuarios DROP COLUMN perfil");
}
}
MigrationRunner — o executor de migrations
O runner é responsável por criar a tabela de controle, descobrir quais migrations ainda não rodaram, e executá-las em ordem. A transação em volta de cada migration só garante atomicidade de fato em bancos com DDL transacional, como PostgreSQL e SQLite. No MySQL, CREATE TABLE, ALTER TABLE e DROP TABLE provocam commit implícito: a transação morre no primeiro comando da migration e o que já foi criado permanece, mesmo que ela falhe adiante.
<?php
declare(strict_types=1);
class MigrationRunner
{
private const TABELA_CONTROLE = 'migrations';
public function __construct(private readonly \PDO $pdo) {}
public function inicializar(): void
{
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS " . self::TABELA_CONTROLE . " (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(255) NOT NULL UNIQUE,
executado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_migration (migration)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
}
/** @param class-string<Migration>[] $classes */
public function migrar(array $classes): void
{
$this->inicializar();
$executadas = $this->jaExecutadas();
foreach ($classes as $classe) {
if (in_array($classe, $executadas, true)) {
echo " ⏭ Já executada: {$classe}\n";
continue;
}
$this->pdo->beginTransaction();
try {
/** @var Migration $migration */
$migration = new $classe($this->pdo);
$migration->up();
$this->registrar($classe);
// No MySQL a DDL da migration já encerrou a transação por
// conta própria: sem o teste, commit() lançaria PDOException.
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
echo " ✅ Executada: {$classe}\n";
} catch (\Throwable $e) {
// Mesmo motivo: com a transação já fechada pela DDL, um
// rollBack() sem guarda trocaria o erro real da migration por
// "there is no active transaction".
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
echo " ❌ Falhou: {$classe} — " . $e->getMessage() . "\n";
throw $e;
}
}
}
/** @param class-string<Migration>[] $classes */
public function rollback(array $classes, int $passos = 1): void
{
$this->inicializar();
$executadas = $this->jaExecutadas();
$paraDesfazer = array_reverse(
array_filter($classes, fn($c) => in_array($c, $executadas, true))
);
foreach (array_slice($paraDesfazer, 0, $passos) as $classe) {
$this->pdo->beginTransaction();
try {
$migration = new $classe($this->pdo);
$migration->down();
$this->remover($classe);
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
echo " ↩ Revertida: {$classe}\n";
} catch (\Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
echo " ❌ Falhou ao reverter: {$classe} — " . $e->getMessage() . "\n";
throw $e;
}
}
}
private function jaExecutadas(): array
{
$stmt = $this->pdo->query(
"SELECT migration FROM " . self::TABELA_CONTROLE . " ORDER BY id"
);
return $stmt->fetchAll(\PDO::FETCH_COLUMN);
}
private function registrar(string $classe): void
{
$this->pdo->prepare(
"INSERT INTO " . self::TABELA_CONTROLE . " (migration) VALUES (?)"
)->execute([$classe]);
}
private function remover(string $classe): void
{
$this->pdo->prepare(
"DELETE FROM " . self::TABELA_CONTROLE . " WHERE migration = ?"
)->execute([$classe]);
}
}
// ── USO ───────────────────────────────────────────────────────────────
$runner = new MigrationRunner($pdo);
// Lista de migrations na ORDEM CORRETA
$migrations = [
Migration001CriarTabelaUsuarios::class,
Migration002CriarProdutos::class,
Migration003AdicionarPerfilUsuario::class,
];
echo "Executando migrations...\n";
$runner->migrar($migrations);
// Rollback do último passo
// $runner->rollback($migrations, passos: 1);
Seeders — populando dados iniciais
Seeders são migrations especiais que inserem dados de referência ou de teste. Não têm down() porque dados de seed geralmente não são removidos por rollback — são truncados manualmente.
<?php
declare(strict_types=1);
abstract class Seeder
{
public function __construct(protected \PDO $pdo) {}
abstract public function run(): void;
}
class CategoriasSeeder extends Seeder
{
public function run(): void
{
$stmt = $this->pdo->prepare(
"INSERT IGNORE INTO categorias (nome, slug) VALUES (?, ?)"
);
$categorias = [
['Eletrônicos', 'eletronicos'],
['Informática', 'informatica'],
['Periféricos', 'perifericos'],
['Acessórios', 'acessorios'],
];
foreach ($categorias as [$nome, $slug]) {
$stmt->execute([$nome, $slug]);
echo " 📦 Categoria: {$nome}\n";
}
}
}
class ProdutosSeeder extends Seeder
{
public function run(): void
{
// Busca IDs das categorias já criadas. FETCH_KEY_PAIR usa a PRIMEIRA
// coluna como chave, e a consulta abaixo é feita pelo slug: com
// "SELECT id, slug" o mapa sai invertido, nenhum slug é encontrado
// e o seeder não insere um produto sequer.
$categorias = $this->pdo->query(
"SELECT slug, id FROM categorias"
)->fetchAll(\PDO::FETCH_KEY_PAIR);
$stmt = $this->pdo->prepare("
INSERT IGNORE INTO produtos (categoria_id, nome, preco, estoque)
VALUES (:categoria_id, :nome, :preco, :estoque)
");
$produtos = [
['cat' => 'perifericos', 'nome' => 'Teclado Mecânico', 'preco' => 350.00, 'estoque' => 50],
['cat' => 'perifericos', 'nome' => 'Mouse Gamer', 'preco' => 180.00, 'estoque' => 80],
['cat' => 'informatica', 'nome' => 'SSD 1TB NVMe', 'preco' => 450.00, 'estoque' => 30],
['cat' => 'eletronicos', 'nome' => 'Monitor 27" 144Hz', 'preco' => 1800.00,'estoque' => 15],
];
foreach ($produtos as $p) {
$catId = $categorias[$p['cat']] ?? null;
if ($catId === null) continue;
$stmt->execute([
'categoria_id' => $catId,
'nome' => $p['nome'],
'preco' => $p['preco'],
'estoque' => $p['estoque'],
]);
echo " 🛍 Produto: {$p['nome']}\n";
}
}
}
// Executar seeders
echo "Populando banco de dados...\n";
(new CategoriasSeeder($pdo))->run();
(new ProdutosSeeder($pdo))->run();
echo "Concluído.\n";
Um Query Builder não existe para deixar o SQL bonito: existe para que filtro opcional pare de ser concatenação de string. O ganho fica mais claro na hora de testar — build() devolve a query e os parâmetros separados, e o teste confere os dois sem encostar no banco. Migrations fazem o mesmo pelo schema: em vez de um arquivo .sql que alguém precisa lembrar de rodar, uma lista ordenada que o runner compara com o que já foi aplicado.
A promessa de atomicidade, essa, tem letra miúda. Em PostgreSQL e SQLite a transação em volta da migration cumpre o que promete; no MySQL, o primeiro CREATE ou ALTER comita sozinho e leva a transação junto. É por isso que o runner testa inTransaction() antes de cada commit e de cada rollback: sem esse cuidado, a falha de uma migration chega ao desenvolvedor disfarçada de "there is no active transaction".
Fontes e leituras recomendadas
- PHP Manual — PDO::prepare: https://www.php.net/manual/pt_BR/pdo.prepare.php
- Phinx — PHP Migrations library: https://book.cakephp.org/phinx/0/en/index.html
- Laravel — Database Migrations: https://laravel.com/docs/migrations
- Laravel — Query Builder: https://laravel.com/docs/queries
- Doctrine DBAL — Schema Manager: https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/schema-manager.html
Exercícios
Exercício 1
Estenda o QueryBuilder com suporte a subqueries: método whereSubquery(string $coluna, string $operador, QueryBuilder $sub) que gera WHERE coluna OPERADOR (SELECT ...). Teste com WHERE id IN (SELECT produto_id FROM pedido_itens WHERE quantidade > 5).
Ver resposta
✓ Resposta: O detalhe que quebra na primeira tentativa é o contador de placeholders: se cada builder começar do :p0, o array merge da subquery sobrescreve o parâmetro do pai e a query executa com o valor errado — sem erro nenhum. Por isso a sequência é static.
<?php
declare(strict_types=1);
final class QueryBuilder
{
private string $tabela = '';
private array $colunas = ['*'];
private array $condicoes = [];
private array $params = [];
private array $ordenacao = [];
private ?int $limite = null;
private int $offset = 0;
// Contador estático: duas instâncias aninhadas não podem gerar :p0 as duas,
// senão os parâmetros da subquery sobrescrevem os do pai.
private static int $sequencia = 0;
public function from(string $tabela): static
{
$this->tabela = $this->identificador($tabela);
return $this;
}
public function select(string ...$colunas): static
{
if ($colunas !== []) {
$this->colunas = array_map($this->identificador(...), $colunas);
}
return $this;
}
public function where(string $coluna, string $operador, mixed $valor): static
{
$ph = ':p' . self::$sequencia++;
$this->condicoes[] = sprintf('%s %s %s',
$this->identificador($coluna), $this->operador($operador), $ph);
$this->params[$ph] = $valor;
return $this;
}
/**
* WHERE coluna OPERADOR (SELECT ...)
* Os parâmetros da subquery sobem para o builder de fora — quem executa
* é uma única chamada, com um único array de parâmetros.
*/
public function whereSubquery(string $coluna, string $operador, QueryBuilder $sub): static
{
[$sqlSub, $paramsSub] = $sub->build();
$this->condicoes[] = sprintf('%s %s (%s)',
$this->identificador($coluna), $this->operador($operador), $sqlSub);
$this->params = [...$this->params, ...$paramsSub];
return $this;
}
public function orderBy(string $coluna, string $direcao = 'ASC'): static
{
$this->ordenacao[] = $this->identificador($coluna) . ' '
. (strtoupper($direcao) === 'DESC' ? 'DESC' : 'ASC');
return $this;
}
public function limit(int $n): static
{
$this->limite = max(1, $n);
return $this;
}
/** @return array{0: string, 1: array<string, mixed>} */
public function build(): array
{
if ($this->tabela === '') {
throw new LogicException('Chame from() antes de build().');
}
$sql = 'SELECT ' . implode(', ', $this->colunas) . ' FROM ' . $this->tabela;
if ($this->condicoes !== []) {
$sql .= ' WHERE ' . implode(' AND ', $this->condicoes);
}
if ($this->ordenacao !== []) {
$sql .= ' ORDER BY ' . implode(', ', $this->ordenacao);
}
if ($this->limite !== null) {
$sql .= ' LIMIT ' . $this->limite;
}
if ($this->offset > 0) {
$sql .= ' OFFSET ' . $this->offset;
}
return [$sql, $this->params];
}
public function executar(\PDO $pdo): array
{
[$sql, $params] = $this->build();
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
private function identificador(string $nome): string
{
if ($nome !== '*' && !preg_match('/^[A-Za-z_]\w*(\.[A-Za-z_]\w*)?$/', $nome)) {
throw new InvalidArgumentException("Identificador inválido: {$nome}");
}
return $nome;
}
private function operador(string $op): string
{
$op = strtoupper(trim($op));
// Lista branca: operador nunca vem cru do usuário.
if (!in_array($op, ['=', '<>', '!=', '<', '<=', '>', '>=', 'LIKE', 'IN', 'NOT IN'], true)) {
throw new InvalidArgumentException("Operador inválido: {$op}");
}
return $op;
}
}
// ---------- Uso -----------------------------------------------------------
$sub = (new QueryBuilder())
->select('produto_id')
->from('pedido_itens')
->where('quantidade', '>', 5);
[$sql, $params] = (new QueryBuilder())
->select('id', 'nome', 'preco')
->from('produtos')
->whereSubquery('id', 'IN', $sub)
->orderBy('nome')
->build();
echo $sql, PHP_EOL;
// SELECT id, nome, preco FROM produtos
// WHERE id IN (SELECT produto_id FROM pedido_itens WHERE quantidade > :p0)
// ORDER BY nome ASC
print_r($params); // [':p0' => 5]
Exercício 2
Adicione paginação ao QueryBuilder: método paginar(int $pagina, int $porPagina): static e método contarTotal(\PDO $pdo): int que executa SELECT COUNT(*) FROM ... WHERE ... sem o LIMIT/OFFSET.
Ver resposta
✓ Resposta: A ordem importa: contarTotal() roda antes de paginar(). Invertido, o COUNT ainda daria certo (ele ignora o LIMIT), mas fica frágil — basta alguém acrescentar o LIMIT ao COUNT para o total virar 20 e a paginação sempre mostrar uma página só.
<?php
declare(strict_types=1);
final class QueryBuilder
{
// ... propriedades e métodos do exercício 1 ...
public function paginar(int $pagina, int $porPagina): static
{
$pagina = max(1, $pagina);
$porPagina = max(1, $porPagina);
$this->limite = $porPagina;
$this->offset = ($pagina - 1) * $porPagina;
return $this;
}
/**
* COUNT(*) com o MESMO WHERE, mas sem LIMIT/OFFSET/ORDER BY.
* É o total real de linhas que casam — o número que a paginação precisa.
*/
public function contarTotal(\PDO $pdo): int
{
$sql = 'SELECT COUNT(*) FROM ' . $this->tabela;
if ($this->condicoes !== []) {
$sql .= ' WHERE ' . implode(' AND ', $this->condicoes);
}
// ORDER BY num COUNT é trabalho jogado fora: o banco ordena para
// depois contar. Alguns bancos até recusam.
$stmt = $pdo->prepare($sql);
$stmt->execute($this->params);
return (int) $stmt->fetchColumn();
}
/** Resultado + metadados de paginação numa chamada. */
public function paginado(\PDO $pdo, int $pagina, int $porPagina): array
{
$total = $this->contarTotal($pdo); // antes do LIMIT
$linhas = $this->paginar($pagina, $porPagina)->executar($pdo);
$paginas = (int) ceil($total / $porPagina);
return [
'dados' => $linhas,
'meta' => [
'pagina' => $pagina,
'por_pagina' => $porPagina,
'total' => $total,
'paginas' => $paginas,
'tem_proxima' => $pagina < $paginas,
],
];
}
}
// ---------- Uso -----------------------------------------------------------
$resultado = (new QueryBuilder())
->select('id', 'nome', 'preco')
->from('produtos')
->where('ativo', '=', 1)
->orderBy('nome')
->paginado($pdo, pagina: 3, porPagina: 20);
printf("Página %d de %d — %d produtos no total%s",
$resultado['meta']['pagina'], $resultado['meta']['paginas'],
$resultado['meta']['total'], PHP_EOL);
// SELECT id, nome, preco FROM produtos WHERE ativo = :p0
// ORDER BY nome ASC LIMIT 20 OFFSET 40
// COUNT: SELECT COUNT(*) FROM produtos WHERE ativo = :p0
Exercício 3
Crie as migrations para um sistema de pedidos completo: Migration001Usuarios, Migration002Categorias, Migration003Produtos, Migration004Pedidos, Migration005PedidoItens. Cada uma com up() e down() corretos, respeitando a ordem de foreign keys.
Ver resposta
✓ Resposta: A escolha de ON DELETE é decisão de projeto, nunca padrão: CASCADE só onde o filho não faz sentido sem o pai (item de pedido), RESTRICT em vínculo de catálogo e autoria. CASCADE numa FK de produto apagaria histórico de vendas junto com o produto.
<?php
declare(strict_types=1);
interface MigrationInterface
{
public function up(\PDO $pdo): void;
public function down(\PDO $pdo): void;
}
// A numeração é a ordem de execução. FK só pode apontar para tabela que já
// existe — por isso usuários e categorias vêm antes de produtos e pedidos.
final class Migration001Usuarios implements MigrationInterface
{
public function up(\PDO $pdo): void
{
$pdo->exec(<<<SQL
CREATE TABLE IF NOT EXISTS usuarios (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
nome VARCHAR(120) NOT NULL,
email VARCHAR(180) NOT NULL UNIQUE,
senha_hash VARCHAR(255) NOT NULL,
papel ENUM('admin','cliente') NOT NULL DEFAULT 'cliente',
criado_em TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
}
public function down(\PDO $pdo): void
{
$pdo->exec('DROP TABLE IF EXISTS usuarios');
}
}
final class Migration002Categorias implements MigrationInterface
{
public function up(\PDO $pdo): void
{
$pdo->exec(<<<SQL
CREATE TABLE IF NOT EXISTS categorias (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(80) NOT NULL,
slug VARCHAR(80) NOT NULL UNIQUE,
criado_em TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
}
public function down(\PDO $pdo): void
{
$pdo->exec('DROP TABLE IF EXISTS categorias');
}
}
final class Migration003Produtos implements MigrationInterface
{
public function up(\PDO $pdo): void
{
$pdo->exec(<<<SQL
CREATE TABLE IF NOT EXISTS produtos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
categoria_id INT UNSIGNED NOT NULL,
nome VARCHAR(160) NOT NULL,
preco DECIMAL(10,2) NOT NULL,
estoque INT UNSIGNED NOT NULL DEFAULT 0,
ativo TINYINT(1) NOT NULL DEFAULT 1,
criado_em TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_produtos_categoria (categoria_id),
INDEX idx_produtos_ativo (ativo),
-- RESTRICT: apagar uma categoria NÃO pode levar os produtos
-- junto. Quem quiser excluir, move os produtos antes.
CONSTRAINT fk_produtos_categoria
FOREIGN KEY (categoria_id) REFERENCES categorias(id)
ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
}
public function down(\PDO $pdo): void
{
$pdo->exec('DROP TABLE IF EXISTS produtos');
}
}
final class Migration004Pedidos implements MigrationInterface
{
public function up(\PDO $pdo): void
{
$pdo->exec(<<<SQL
CREATE TABLE IF NOT EXISTS pedidos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
usuario_id INT UNSIGNED NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'rascunho',
total DECIMAL(10,2) NOT NULL DEFAULT 0,
criado_em TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_pedidos_usuario (usuario_id),
INDEX idx_pedidos_status (status),
CONSTRAINT fk_pedidos_usuario
FOREIGN KEY (usuario_id) REFERENCES usuarios(id)
ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
}
public function down(\PDO $pdo): void
{
$pdo->exec('DROP TABLE IF EXISTS pedidos');
}
}
final class Migration005PedidoItens implements MigrationInterface
{
public function up(\PDO $pdo): void
{
$pdo->exec(<<<SQL
CREATE TABLE IF NOT EXISTS pedido_itens (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
pedido_id INT UNSIGNED NOT NULL,
produto_id INT UNSIGNED NOT NULL,
quantidade INT UNSIGNED NOT NULL,
preco_unitario DECIMAL(10,2) NOT NULL,
UNIQUE KEY uk_pedido_produto (pedido_id, produto_id),
INDEX idx_itens_produto (produto_id),
-- CASCADE aqui é a exceção justificada: item de pedido não
-- existe sem o pedido. Já o produto usa RESTRICT — apagar um
-- produto não pode reescrever o histórico de vendas.
CONSTRAINT fk_itens_pedido
FOREIGN KEY (pedido_id) REFERENCES pedidos(id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_itens_produto
FOREIGN KEY (produto_id) REFERENCES produtos(id)
ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
}
public function down(\PDO $pdo): void
{
$pdo->exec('DROP TABLE IF EXISTS pedido_itens');
}
}
// O rollback é a ordem INVERSA: a tabela que aponta cai antes da apontada.
$migrations = [
new Migration001Usuarios(),
new Migration002Categorias(),
new Migration003Produtos(),
new Migration004Pedidos(),
new Migration005PedidoItens(),
];
foreach ($migrations as $m) { $m->up($pdo); }
foreach (array_reverse($migrations) as $m) { $m->down($pdo); }
Exercício 4
Implemente um SeederRunner que recebe uma lista de seeders e os executa em ordem, exibindo tempo de execução de cada um usando hrtime(true).
Ver resposta
✓ Resposta: hrtime(true) em vez de microtime(true) porque devolve inteiro em nanossegundos, sem o erro de arredondamento do float — e é monotônico: ajuste de relógio no meio da execução não produz tempo negativo.
<?php
declare(strict_types=1);
interface SeederInterface
{
public function executar(\PDO $pdo): int; // linhas inseridas
public function nome(): string;
}
final class CategoriaSeeder implements SeederInterface
{
public function executar(\PDO $pdo): int
{
$stmt = $pdo->prepare(
'INSERT INTO categorias (nome, slug) VALUES (:nome, :slug)
ON DUPLICATE KEY UPDATE nome = VALUES(nome)'
);
$categorias = [
['Periféricos', 'perifericos'],
['Monitores', 'monitores'],
['Notebooks', 'notebooks'],
];
foreach ($categorias as [$nome, $slug]) {
$stmt->execute([':nome' => $nome, ':slug' => $slug]);
}
return count($categorias);
}
public function nome(): string { return 'Categorias'; }
}
final class ProdutoSeeder implements SeederInterface
{
public function __construct(private readonly int $quantidade = 100) {}
public function executar(\PDO $pdo): int
{
$stmt = $pdo->prepare(
'INSERT INTO produtos (categoria_id, nome, preco, estoque)
VALUES (:cat, :nome, :preco, :estoque)'
);
// Uma transação para o lote inteiro: 100 commits separados são
// ordens de grandeza mais lentos que um commit só.
$pdo->beginTransaction();
try {
for ($i = 1; $i <= $this->quantidade; $i++) {
$stmt->execute([
':cat' => random_int(1, 3),
':nome' => "Produto de teste {$i}",
':preco' => random_int(1990, 499900) / 100,
':estoque' => random_int(0, 50),
]);
}
$pdo->commit();
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
return $this->quantidade;
}
public function nome(): string { return 'Produtos'; }
}
final class SeederRunner
{
/** @param SeederInterface[] $seeders */
public function __construct(private readonly array $seeders) {}
public function executar(\PDO $pdo): void
{
$totalGeral = 0;
$inicioGeral = hrtime(true);
echo str_repeat('─', 52), PHP_EOL;
printf("%-22s %10s %14s%s", 'SEEDER', 'LINHAS', 'TEMPO', PHP_EOL);
echo str_repeat('─', 52), PHP_EOL;
foreach ($this->seeders as $seeder) {
// hrtime(true) devolve nanossegundos em inteiro — imune à
// imprecisão de float do microtime().
$inicio = hrtime(true);
try {
$linhas = $seeder->executar($pdo);
} catch (\Throwable $e) {
printf("%-22s %10s %14s%s", $seeder->nome(), '—', 'FALHOU', PHP_EOL);
echo ' ↳ ', $e->getMessage(), PHP_EOL;
throw $e;
}
$ms = (hrtime(true) - $inicio) / 1_000_000;
$totalGeral += $linhas;
printf("%-22s %10d %11.2f ms%s", $seeder->nome(), $linhas, $ms, PHP_EOL);
}
echo str_repeat('─', 52), PHP_EOL;
printf("%-22s %10d %11.2f ms%s", 'TOTAL', $totalGeral,
(hrtime(true) - $inicioGeral) / 1_000_000, PHP_EOL);
}
}
(new SeederRunner([
new CategoriaSeeder(),
new ProdutoSeeder(500),
]))->executar($pdo);
// ────────────────────────────────────────────────────
// SEEDER LINHAS TEMPO
// ────────────────────────────────────────────────────
// Categorias 3 4.18 ms
// Produtos 500 182.44 ms
// ────────────────────────────────────────────────────
// TOTAL 503 186.79 ms
Exercício 5
Desafio: crie um comando de linha de comando php migrate.php [migrate|rollback|status]. O status lista todas as migrations com indicação visual de executadas (✅) e pendentes (⏳). Use $argv para ler o comando e $argc para validar os argumentos.
Ver resposta
✓ Resposta: O conceito de lote é o que faz o rollback ser útil: ele desfaz só a última leva, não o banco inteiro. E o exit(1) em argumento inválido não é detalhe — é o que faz o CI falhar em vez de seguir achando que migrou.
<?php
// migrate.php — uso: php migrate.php [migrate|rollback|status]
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
final class MigrationRunner
{
/** @param array<string, MigrationInterface> $migrations nome => instância */
public function __construct(
private readonly \PDO $pdo,
private readonly array $migrations,
) {
$this->criarTabelaDeControle();
}
private function criarTabelaDeControle(): void
{
// Sem esta tabela não há como saber o que já rodou — é ela que torna
// `migrate` seguro de repetir.
$this->pdo->exec(
'CREATE TABLE IF NOT EXISTS migrations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(190) NOT NULL UNIQUE,
lote INT UNSIGNED NOT NULL,
rodada_em TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)'
);
}
/** @return string[] */
private function executadas(): array
{
return $this->pdo->query('SELECT migration FROM migrations ORDER BY id')
->fetchAll(\PDO::FETCH_COLUMN);
}
private function proximoLote(): int
{
return 1 + (int) $this->pdo->query('SELECT COALESCE(MAX(lote), 0) FROM migrations')
->fetchColumn();
}
public function migrate(): int
{
$executadas = $this->executadas();
$pendentes = array_diff(array_keys($this->migrations), $executadas);
if ($pendentes === []) {
echo "Nada a fazer: todas as migrations já foram executadas.", PHP_EOL;
return 0;
}
$lote = $this->proximoLote();
$registrar = $this->pdo->prepare(
'INSERT INTO migrations (migration, lote) VALUES (:m, :lote)'
);
foreach ($pendentes as $nome) {
$inicio = hrtime(true);
$this->migrations[$nome]->up($this->pdo);
$registrar->execute([':m' => $nome, ':lote' => $lote]);
printf(" ✅ %-32s %8.2f ms%s", $nome,
(hrtime(true) - $inicio) / 1_000_000, PHP_EOL);
}
printf("%d migration(s) aplicada(s) no lote %d.%s", count($pendentes), $lote, PHP_EOL);
return count($pendentes);
}
/** Desfaz apenas o ÚLTIMO lote — não tudo. */
public function rollback(): int
{
$lote = (int) $this->pdo->query('SELECT COALESCE(MAX(lote), 0) FROM migrations')
->fetchColumn();
if ($lote === 0) {
echo 'Nada a reverter.', PHP_EOL;
return 0;
}
$stmt = $this->pdo->prepare(
'SELECT migration FROM migrations WHERE lote = :lote ORDER BY id DESC'
);
$stmt->execute([':lote' => $lote]);
$nomes = $stmt->fetchAll(\PDO::FETCH_COLUMN);
$remover = $this->pdo->prepare('DELETE FROM migrations WHERE migration = :m');
// Ordem inversa: a tabela que tem FK cai antes da que ela referencia.
foreach ($nomes as $nome) {
$this->migrations[$nome]->down($this->pdo);
$remover->execute([':m' => $nome]);
printf(" ↩️ %s revertida%s", $nome, PHP_EOL);
}
printf("Lote %d revertido (%d migration(s)).%s", $lote, count($nomes), PHP_EOL);
return count($nomes);
}
public function status(): void
{
$executadas = $this->executadas();
echo PHP_EOL, str_repeat('─', 54), PHP_EOL;
printf("%-4s %-34s %s%s", '', 'MIGRATION', 'SITUAÇÃO', PHP_EOL);
echo str_repeat('─', 54), PHP_EOL;
foreach (array_keys($this->migrations) as $nome) {
$rodou = in_array($nome, $executadas, true);
printf("%-4s %-34s %s%s",
$rodou ? '✅' : '⏳', $nome,
$rodou ? 'executada' : 'pendente', PHP_EOL);
}
$pendentes = count($this->migrations) - count($executadas);
echo str_repeat('─', 54), PHP_EOL;
printf("%d executada(s), %d pendente(s)%s", count($executadas), $pendentes, PHP_EOL);
}
}
// ---------- Leitura dos argumentos ---------------------------------------
// $argv[0] é sempre o nome do script; o comando é o $argv[1].
$comando = $argv[1] ?? null;
if ($argc < 2 || !in_array($comando, ['migrate', 'rollback', 'status'], true)) {
fwrite(STDERR, "Uso: php migrate.php [migrate|rollback|status]" . PHP_EOL);
exit(1); // código != 0: o shell e o CI entendem como falha
}
$runner = new MigrationRunner($pdo, [
'Migration001Usuarios' => new Migration001Usuarios(),
'Migration002Categorias' => new Migration002Categorias(),
'Migration003Produtos' => new Migration003Produtos(),
'Migration004Pedidos' => new Migration004Pedidos(),
'Migration005PedidoItens' => new Migration005PedidoItens(),
]);
match ($comando) {
'migrate' => $runner->migrate(),
'rollback' => $runner->rollback(),
'status' => $runner->status(),
};
exit(0);
// $ php migrate.php status
// ──────────────────────────────────────────────────────
// MIGRATION SITUAÇÃO
// ──────────────────────────────────────────────────────
// ✅ Migration001Usuarios executada
// ✅ Migration002Categorias executada
// ⏳ Migration003Produtos pendente