Query Builder e Migrations

Query Builder e Migrations

Construir o builder do zero deixa claro por que filtro opcional não se resolve com if concatenando string, e por que separar SQL de parâmetros em build() é o que torna a query testável. Do lado do schema, migrations versionadas — com o limite real da transação no MySQL.
PHP

• • 28 min de leitura

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

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
Comentários

Mais em PHP

Estruturas de Repetição
Estruturas de Repetição

Repetir é onde o PHP oferece mais caminhos para o mesmo destino: for quando o…

Autenticação JWT
Autenticação JWT

Assinar e conferir o token à mão para enxergar o mecanismo, depois passar para…

Upload de Arquivos
Upload de Arquivos

Validar pelo conteúdo e não pelo que o navegador declara, gerar o nome no…