PHP

Query Builder e Migrations Já leu

13 min de leitura

Query Builder e Migrations
No artigo anterior 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

No artigo anterior 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"
    public function whereIf(?string $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
    {
        if (empty($valores)) 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 — tudo dentro de transações para garantir atomicidade.

<?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);
                $this->pdo->commit();
                echo "  ✅ Executada: {$classe}\n";
            } catch (\Throwable $e) {
                $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);
                $this->pdo->commit();
                echo "  ↩  Revertida: {$classe}\n";
            } catch (\Throwable $e) {
                $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
        $categorias = $this->pdo->query(
            "SELECT id, slug 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";

Resumo do artigo

Conceito O que aprendemos
Query Builder Constrói SQL programaticamente — seguro, fluente, testável
whereIf() Adiciona cláusula WHERE apenas se o valor for fornecido
whereIn() Gera placeholders dinamicamente para listas de valores
build() Retorna [$sql, $params] — SQL e parâmetros sempre separados
Migration Arquivo PHP com up() e down() — versiona mudanças de schema
Tabela de controle Registra quais migrations já rodaram — garante idempotência
MigrationRunner Executa migrations pendentes em ordem, dentro de transações
Rollback Reverte as N últimas migrations executando down()
Seeder Popula dados iniciais ou de teste — separado das migrations

Exercício da semana

  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).

  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.

  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.

  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).

  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.


Referências

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

Mais em PHP

PostgreSQL com PHP
PostgreSQL com PHP

O MySQL é o banco de dados mais comum no ecossistema PHP, mas o PostgreSQL é...

Artigo 42 — Eloquent ORM com Laravel
Artigo 42 — Eloquent ORM com Laravel

Eloquent é o ORM do Laravel, e é um dos mais elegantes disponíveis em qualque...

Testes Automatizados com PHPUnit
Testes Automatizados com PHPUnit

Testes automatizados são o mecanismo que permite modificar código com confian...