SQLite com PHP

SQLite com PHP

Um arquivo, zero configuração e uma busca textual com FTS5 que dispensa Elasticsearch em casos simples. Inclui os PRAGMAs que ninguém lembra de ligar, o trigger esquecido que faz a busca devolver resultado errado e onde o banco de teste em memória mente.
PHP

• • 29 min de leitura

SQLite é o banco de dados mais instalado do mundo — está presente em todos os smartphones Android e iPhone, em todos os navegadores, no sistema operacional macOS, em centenas de milhões de dispositivos embarcados. Mas na web com PHP, é frequentemente ignorado ou mal compreendido.

SQLite não é um banco de dados "de brinquedo". É uma escolha deliberada e poderosa para cenários específicos: aplicações de pequeno a médio porte com tráfego moderado, ferramentas de linha de comando, ambientes de teste, cache local, e qualquer situação onde você não queira (ou não possa) manter um servidor de banco de dados separado.

O que é o SQLite e como ele funciona

Ao contrário do MySQL e PostgreSQL, que são sistemas cliente-servidor, o SQLite é uma biblioteca que se incorpora diretamente à sua aplicação. O banco de dados inteiro é um único arquivo em disco. Não há servidor para iniciar, não há configuração de usuários e senhas, não há processo separado.

Isso traz consequências importantes:

Vantagens: zero configuração, portabilidade total (copie o arquivo e leve o banco junto), perfeito para desenvolvimento e testes, funciona em qualquer hospedagem sem dependências externas.

Limitações: escritas concorrentes são serialized (apenas uma escrita por vez, embora o WAL mode melhore isso), não é adequado para aplicações com alta concorrência de escrita, não suporta alguns tipos de dados avançados.

Criando e conectando ao banco

<?php
declare(strict_types=1);

// O banco é criado automaticamente se não existir
// Use :memory: para um banco em memória (ideal para testes)
$pdo = new PDO('sqlite:banco.db', options: [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

// Ativar WAL mode: melhora performance de leitura concorrente
$pdo->exec('PRAGMA journal_mode = WAL');

// Ativar foreign keys (desativado por padrão no SQLite!)
$pdo->exec('PRAGMA foreign_keys = ON');

// Ajustar tamanho do cache
$pdo->exec('PRAGMA cache_size = -64000'); // 64MB

echo "SQLite " . $pdo->query('SELECT sqlite_version()')->fetchColumn() . "\n";

O detalhe do PRAGMA foreign_keys = ON é crítico e frequentemente esquecido. Por padrão, o SQLite não verifica integridade referencial — chaves estrangeiras são aceitas mas não validadas. Sempre ative esse pragma na abertura da conexão.

Schema e tipos de dados

O SQLite usa um sistema de tipos chamado "type affinity" — diferente de outros bancos, ele é bastante flexível (você pode inserir um texto numa coluna INTEGER). Na prática, use os tipos normalmente e o SQLite fará o melhor:

<?php

$pdo->exec("
    CREATE TABLE IF NOT EXISTS notas (
        id          INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo      TEXT    NOT NULL,
        conteudo    TEXT,
        criada_em   TEXT    NOT NULL DEFAULT (datetime('now')),
        atualizada_em TEXT,
        arquivada   INTEGER NOT NULL DEFAULT 0  -- 0 = false, 1 = true
    )
");

$pdo->exec("
    CREATE TABLE IF NOT EXISTS tags (
        id   INTEGER PRIMARY KEY AUTOINCREMENT,
        nome TEXT NOT NULL UNIQUE
    )
");

$pdo->exec("
    CREATE TABLE IF NOT EXISTS notas_tags (
        nota_id INTEGER NOT NULL REFERENCES notas(id) ON DELETE CASCADE,
        tag_id  INTEGER NOT NULL REFERENCES tags(id)  ON DELETE CASCADE,
        PRIMARY KEY (nota_id, tag_id)
    )
");

// Índice para busca por texto
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_notas_titulo ON notas(titulo)");

Full-Text Search — busca de texto com FTS5

Uma das joias do SQLite é o módulo FTS5 (Full-Text Search version 5), que permite busca textual eficiente sem precisar de Elasticsearch ou Typesense para casos simples:

<?php

// Criando tabela virtual de FTS
$pdo->exec("
    CREATE VIRTUAL TABLE IF NOT EXISTS notas_fts
    USING fts5(titulo, conteudo, content='notas', content_rowid='id')
");

// Trigger para manter o índice atualizado
$pdo->exec("
    CREATE TRIGGER IF NOT EXISTS notas_fts_insert
    AFTER INSERT ON notas BEGIN
        INSERT INTO notas_fts(rowid, titulo, conteudo)
        VALUES (new.id, new.titulo, new.conteudo);
    END
");

$pdo->exec("
    CREATE TRIGGER IF NOT EXISTS notas_fts_delete
    AFTER DELETE ON notas BEGIN
        INSERT INTO notas_fts(notas_fts, rowid, titulo, conteudo)
        VALUES ('delete', old.id, old.titulo, old.conteudo);
    END
");

// O terceiro trigger é o mais esquecido, e o que estraga a busca em
// silêncio. Sem ele, editar uma nota deixa o índice preso ao texto antigo:
// a busca passa a devolver a nota por uma palavra que ela não contém mais e
// deixa de encontrá-la pelo texto novo. Nada falha, nada aparece no log.
// Em tabela de conteúdo externo, atualizar é apagar e inserir de novo.
$pdo->exec("
    CREATE TRIGGER IF NOT EXISTS notas_fts_update
    AFTER UPDATE ON notas BEGIN
        INSERT INTO notas_fts(notas_fts, rowid, titulo, conteudo)
        VALUES ('delete', old.id, old.titulo, old.conteudo);
        INSERT INTO notas_fts(rowid, titulo, conteudo)
        VALUES (new.id, new.titulo, new.conteudo);
    END
");

// Inserindo e buscando
$pdo->prepare("INSERT INTO notas (titulo, conteudo) VALUES (?, ?)")
    ->execute(['PHP e SQLite', 'SQLite é excelente para aplicações PHP de médio porte']);

$pdo->prepare("INSERT INTO notas (titulo, conteudo) VALUES (?, ?)")
    ->execute(['PostgreSQL avançado', 'JSONB e arrays nativos tornam o Postgres poderoso']);

// Busca FTS com ranking por relevância
$stmt = $pdo->prepare("
    SELECT n.id, n.titulo, n.conteudo,
           bm25(notas_fts) AS relevancia
    FROM notas_fts
    JOIN notas n ON n.id = notas_fts.rowid
    WHERE notas_fts MATCH :termo
    ORDER BY relevancia
    LIMIT 10
");
$stmt->execute([':termo' => 'PHP SQLite']);
$resultados = $stmt->fetchAll();

SQLite para testes — o caso de uso mais valioso

O uso mais poderoso do SQLite em projetos profissionais é como banco de dados para testes automatizados. Em vez de criar um banco MySQL real para os testes (lento, com estado compartilhado entre execuções), use SQLite em memória:

<?php

// Em um TestCase base (PHPUnit)
abstract class DatabaseTestCase extends TestCase
{
    protected PDO $pdo;

    protected function setUp(): void
    {
        // Banco em memória: criado do zero a cada teste, destruído ao final
        $this->pdo = new PDO('sqlite::memory:', options: [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]);
        $this->pdo->exec('PRAGMA foreign_keys = ON');
        $this->migrarSchema();
        $this->inserirDadosDeFixture();
    }

    private function migrarSchema(): void
    {
        // Um schema escrito para MySQL NÃO roda aqui: AUTO_INCREMENT, ENGINE,
        // ENUM, TINYINT(1) e as crases não existem no SQLite, e o arquivo morre
        // logo na primeira linha ("near SET: syntax error"). Então guarde um
        // schema próprio para os testes — e saiba que isso é o preço desta
        // técnica: o que roda contra SQLite não prova nada sobre o dialeto do
        // banco de produção. Para o que depende de SQL específico, o teste
        // tem de rodar contra o banco de verdade.
        $sql = file_get_contents(__DIR__ . '/schema.sqlite.sql');
        $this->pdo->exec($sql);
    }

    private function inserirDadosDeFixture(): void
    {
        $this->pdo->exec("
            INSERT INTO usuarios (nome, email) VALUES
            ('Alice', 'alice@teste.com'),
            ('Bob', 'bob@teste.com')
        ");
    }
}

class UsuarioRepositorioTest extends DatabaseTestCase
{
    public function test_buscar_por_email_retorna_usuario_existente(): void
    {
        $repo = new UsuarioRepositorio($this->pdo);
        $usuario = $repo->buscarPorEmail('alice@teste.com');

        $this->assertNotNull($usuario);
        $this->assertSame('Alice', $usuario->nome);
    }

    public function test_buscar_por_email_retorna_null_para_inexistente(): void
    {
        $repo = new UsuarioRepositorio($this->pdo);
        $usuario = $repo->buscarPorEmail('inexistente@teste.com');

        $this->assertNull($usuario);
    }
}

Cada teste começa com um banco limpo, sem afetar outros testes. Muito mais rápido que MySQL ou PostgreSQL em memória real.

A pergunta que decide o uso do SQLite não é o tamanho do banco, é o perfil de escrita. Leitura ele aguenta em volume que surpreende; o limite aparece quando vários processos querem gravar ao mesmo tempo, porque a escrita é serializada — e nem o WAL muda isso, ele só impede que uma escrita trave as leituras. Um site de conteúdo, uma ferramenta de linha de comando ou o cache local de uma aplicação cabem bem; uma fila de pedidos com dez workers gravando, não.

O uso mais subestimado continua sendo o banco de teste em memória, e vale saber onde ele mente. O schema do seu projeto provavelmente não roda aqui sem tradução, e o que passa contra SQLite não prova nada sobre o dialeto do banco de produção. O ganho é de velocidade e isolamento — cada teste começa com um banco limpo —, não de fidelidade. Para o que depende de SQL específico, o teste precisa rodar no banco de verdade.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Crie uma aplicação de notas pessoais com SQLite: CRUD completo (criar, listar, editar, arquivar, deletar) com tags. Use PRAGMA foreign_keys = ON e WAL mode.

Ver resposta

✓ Resposta: Os quatro PRAGMAs no início não são enfeite: foreign_keys precisa ser ligado em toda conexão (é o erro mais comum com SQLite), o WAL evita que uma escrita trave as leituras, e o busy_timeout troca "database is locked" por uma espera curta.

<?php

declare(strict_types=1);

final class BancoNotas
{
    public static function abrir(string $caminho = __DIR__ . '/notas.db'): PDO
    {
        $pdo = new PDO('sqlite:' . $caminho, null, null, [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]);

        // ⚠️ O SQLite IGNORA foreign keys por padrão. Sem esta linha, as
        // FKs do schema são pura decoração — e por conexão, não por banco.
        $pdo->exec('PRAGMA foreign_keys = ON');

        // WAL: leitores não bloqueiam o escritor e vice-versa. Em modo
        // journal padrão, uma escrita trava todas as leituras.
        $pdo->exec('PRAGMA journal_mode = WAL');

        // NORMAL com WAL é seguro e bem mais rápido que FULL.
        $pdo->exec('PRAGMA synchronous = NORMAL');

        // Espera até 5 s se o banco estiver travado, em vez de falhar já.
        $pdo->exec('PRAGMA busy_timeout = 5000');

        self::migrar($pdo);

        return $pdo;
    }

    private static function migrar(PDO $pdo): void
    {
        $pdo->exec(<<<SQL
            CREATE TABLE IF NOT EXISTS notas (
                id           INTEGER PRIMARY KEY AUTOINCREMENT,
                titulo       TEXT NOT NULL,
                conteudo     TEXT NOT NULL DEFAULT '',
                arquivada    INTEGER NOT NULL DEFAULT 0 CHECK (arquivada IN (0,1)),
                criada_em    TEXT NOT NULL DEFAULT (datetime('now')),
                atualizada_em TEXT NOT NULL DEFAULT (datetime('now'))
            )
        SQL);

        $pdo->exec('CREATE TABLE IF NOT EXISTS tags (
            id   INTEGER PRIMARY KEY AUTOINCREMENT,
            nome TEXT NOT NULL UNIQUE COLLATE NOCASE
        )');

        $pdo->exec(<<<SQL
            CREATE TABLE IF NOT EXISTS nota_tags (
                nota_id INTEGER NOT NULL,
                tag_id  INTEGER NOT NULL,
                PRIMARY KEY (nota_id, tag_id),
                -- CASCADE aqui é correto: o vínculo não existe sem a nota.
                FOREIGN KEY (nota_id) REFERENCES notas(id) ON DELETE CASCADE,
                FOREIGN KEY (tag_id)  REFERENCES tags(id)  ON DELETE CASCADE
            )
        SQL);

        $pdo->exec('CREATE INDEX IF NOT EXISTS idx_notas_arquivada ON notas(arquivada)');
    }
}

final class NotaRepositorio
{
    public function __construct(private readonly PDO $pdo) {}

    public function criar(string $titulo, string $conteudo, array $tags = []): int
    {
        $this->pdo->beginTransaction();

        try {
            $stmt = $this->pdo->prepare(
                'INSERT INTO notas (titulo, conteudo) VALUES (:t, :c)'
            );
            $stmt->execute([':t' => trim($titulo), ':c' => $conteudo]);

            $id = (int) $this->pdo->lastInsertId();

            $this->sincronizarTags($id, $tags);

            $this->pdo->commit();

        } catch (Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }

        return $id;
    }

    /** @return array{id:int,titulo:string,conteudo:string,tags:string[]}|null */
    public function buscar(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM notas WHERE id = :id');
        $stmt->execute([':id' => $id]);

        $nota = $stmt->fetch();

        if ($nota === false) {
            return null;
        }

        $nota['tags'] = $this->tagsDe($id);

        return $nota;
    }

    public function listar(bool $incluirArquivadas = false, ?string $tag = null): array
    {
        $sql = 'SELECT n.*, GROUP_CONCAT(t.nome) AS tags
                  FROM notas n
                  LEFT JOIN nota_tags nt ON nt.nota_id = n.id
                  LEFT JOIN tags t ON t.id = nt.tag_id';

        $condicoes = [];
        $bind = [];

        if (!$incluirArquivadas) {
            $condicoes[] = 'n.arquivada = 0';
        }

        if ($tag !== null) {
            $condicoes[] = 'n.id IN (SELECT nt2.nota_id FROM nota_tags nt2
                                       JOIN tags t2 ON t2.id = nt2.tag_id
                                      WHERE t2.nome = :tag)';
            $bind[':tag'] = $tag;
        }

        if ($condicoes !== []) {
            $sql .= ' WHERE ' . implode(' AND ', $condicoes);
        }

        $sql .= ' GROUP BY n.id ORDER BY n.atualizada_em DESC';

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($bind);

        return array_map(static function (array $l): array {
            $l['tags'] = $l['tags'] !== null ? explode(',', $l['tags']) : [];
            return $l;
        }, $stmt->fetchAll());
    }

    public function editar(int $id, string $titulo, string $conteudo, array $tags = []): bool
    {
        $this->pdo->beginTransaction();

        try {
            $stmt = $this->pdo->prepare(
                "UPDATE notas SET titulo = :t, conteudo = :c,
                        atualizada_em = datetime('now')
                  WHERE id = :id"
            );
            $stmt->execute([':t' => trim($titulo), ':c' => $conteudo, ':id' => $id]);

            $alterou = $stmt->rowCount() > 0;

            if ($alterou) {
                $this->sincronizarTags($id, $tags);
            }

            $this->pdo->commit();

        } catch (Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }

        return $alterou;
    }

    public function arquivar(int $id, bool $arquivar = true): bool
    {
        $stmt = $this->pdo->prepare(
            "UPDATE notas SET arquivada = :a, atualizada_em = datetime('now') WHERE id = :id"
        );
        $stmt->execute([':a' => $arquivar ? 1 : 0, ':id' => $id]);

        return $stmt->rowCount() > 0;
    }

    public function deletar(int $id): bool
    {
        // As linhas de nota_tags saem sozinhas pelo ON DELETE CASCADE —
        // desde que o PRAGMA foreign_keys esteja ligado.
        $stmt = $this->pdo->prepare('DELETE FROM notas WHERE id = :id');
        $stmt->execute([':id' => $id]);

        return $stmt->rowCount() > 0;
    }

    private function sincronizarTags(int $notaId, array $tags): void
    {
        $this->pdo->prepare('DELETE FROM nota_tags WHERE nota_id = :id')
                  ->execute([':id' => $notaId]);

        $inserirTag = $this->pdo->prepare(
            'INSERT INTO tags (nome) VALUES (:nome) ON CONFLICT(nome) DO NOTHING'
        );
        $buscarTag = $this->pdo->prepare('SELECT id FROM tags WHERE nome = :nome');
        $vincular  = $this->pdo->prepare(
            'INSERT INTO nota_tags (nota_id, tag_id) VALUES (:n, :t)'
        );

        foreach (array_unique(array_map('trim', $tags)) as $nome) {
            if ($nome === '') {
                continue;
            }

            $inserirTag->execute([':nome' => $nome]);
            $buscarTag->execute([':nome' => $nome]);

            $vincular->execute([':n' => $notaId, ':t' => (int) $buscarTag->fetchColumn()]);
        }
    }

    private function tagsDe(int $notaId): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT t.nome FROM tags t JOIN nota_tags nt ON nt.tag_id = t.id
              WHERE nt.nota_id = :id ORDER BY t.nome'
        );
        $stmt->execute([':id' => $notaId]);

        return $stmt->fetchAll(PDO::FETCH_COLUMN);
    }
}

$repo = new NotaRepositorio(BancoNotas::abrir());

$id = $repo->criar('Comprar café', 'Grão, torra média.', ['casa', 'compras']);
$repo->editar($id, 'Comprar café especial', 'Grão, torra média, 500g.', ['casa']);
$repo->arquivar($id);

print_r($repo->listar(incluirArquivadas: true));

Exercício 2

Implemente FTS5 na tabela de notas. Escreva uma função buscar(string $termo): array que use MATCH e retorne resultados ordenados por relevância (bm25).

Ver resposta

✓ Resposta: Três armadilhas do FTS5: o índice externo não se atualiza sem os triggers; bm25() é negativo e ordena ascendente; e texto do usuário nunca vai cru para o MATCH — um simples hífen ou aspas quebra a consulta. O remove_diacritics 2 é o que faz "cafe" achar "café".

<?php

declare(strict_types=1);

final class BuscaNotas
{
    public function __construct(private readonly PDO $pdo)
    {
        $this->criarIndice();
    }

    private function criarIndice(): void
    {
        // content='notas' cria um índice EXTERNO: o FTS5 não duplica o texto,
        // só guarda o índice invertido e aponta para a tabela original.
        // content_rowid liga o rowid do índice ao id da nota.
        $this->pdo->exec(<<<SQL
            CREATE VIRTUAL TABLE IF NOT EXISTS notas_fts USING fts5(
                titulo,
                conteudo,
                content='notas',
                content_rowid='id',
                tokenize="unicode61 remove_diacritics 2"
            )
        SQL);

        // Índice externo NÃO se atualiza sozinho: os triggers é que mantêm
        // os dois em sincronia. Sem eles, a busca vai devolver lixo.
        $this->pdo->exec(<<<SQL
            CREATE TRIGGER IF NOT EXISTS notas_ai AFTER INSERT ON notas BEGIN
                INSERT INTO notas_fts (rowid, titulo, conteudo)
                VALUES (new.id, new.titulo, new.conteudo);
            END
        SQL);

        $this->pdo->exec(<<<SQL
            CREATE TRIGGER IF NOT EXISTS notas_ad AFTER DELETE ON notas BEGIN
                INSERT INTO notas_fts (notas_fts, rowid, titulo, conteudo)
                VALUES ('delete', old.id, old.titulo, old.conteudo);
            END
        SQL);

        $this->pdo->exec(<<<SQL
            CREATE TRIGGER IF NOT EXISTS notas_au AFTER UPDATE ON notas BEGIN
                INSERT INTO notas_fts (notas_fts, rowid, titulo, conteudo)
                VALUES ('delete', old.id, old.titulo, old.conteudo);
                INSERT INTO notas_fts (rowid, titulo, conteudo)
                VALUES (new.id, new.titulo, new.conteudo);
            END
        SQL);
    }

    /** Reconstrói o índice do zero — útil depois de importar em massa. */
    public function reindexar(): void
    {
        $this->pdo->exec("INSERT INTO notas_fts (notas_fts) VALUES ('rebuild')");
    }

    /**
     * bm25() devolve valor NEGATIVO: quanto MENOR, mais relevante. Por isso
     * o ORDER BY é ascendente — ordenar DESC traz os piores primeiro.
     * Os pesos (2.0, 1.0) dizem que acerto no título vale o dobro.
     */
    public function buscar(string $termo, int $limite = 20): array
    {
        $consulta = $this->prepararConsulta($termo);

        if ($consulta === '') {
            return [];
        }

        $stmt = $this->pdo->prepare(<<<SQL
            SELECT n.id,
                   n.titulo,
                   n.arquivada,
                   -- snippet: trecho com o termo destacado
                   snippet(notas_fts, 1, '<mark>', '</mark>', '…', 24) AS trecho,
                   bm25(notas_fts, 2.0, 1.0) AS relevancia
              FROM notas_fts
              JOIN notas n ON n.id = notas_fts.rowid
             WHERE notas_fts MATCH :termo
               AND n.arquivada = 0
             ORDER BY relevancia
             LIMIT :limite
        SQL);

        $stmt->bindValue(':termo', $consulta);
        $stmt->bindValue(':limite', $limite, PDO::PARAM_INT);
        $stmt->execute();

        return $stmt->fetchAll();
    }

    /**
     * O texto do usuário NÃO pode ir cru para o MATCH: aspas, hífens e
     * operadores do FTS5 (AND, OR, NOT, NEAR) causam erro de sintaxe ou
     * mudam a semântica da busca.
     */
    private function prepararConsulta(string $termo): string
    {
        $palavras = preg_split('/\s+/u', trim($termo), -1, PREG_SPLIT_NO_EMPTY) ?: [];

        $limpas = [];

        foreach ($palavras as $palavra) {
            $palavra = preg_replace('/["\'()*:^-]/u', '', $palavra);

            if (mb_strlen($palavra) >= 2) {
                // Aspas duplas tratam o termo como literal.
                $limpas[] = '"' . $palavra . '"';
            }
        }

        return implode(' ', $limpas);
    }

    /** Busca por prefixo: "prog" encontra "programação". */
    public function autocompletar(string $prefixo, int $limite = 10): array
    {
        $prefixo = preg_replace('/[^\p{L}\p{N}]/u', '', $prefixo);

        if (mb_strlen($prefixo) < 2) {
            return [];
        }

        $stmt = $this->pdo->prepare(
            'SELECT n.id, n.titulo FROM notas_fts JOIN notas n ON n.id = notas_fts.rowid
              WHERE notas_fts MATCH :termo ORDER BY bm25(notas_fts) LIMIT :limite'
        );

        $stmt->bindValue(':termo', '"' . $prefixo . '"*');   // o * é o prefixo
        $stmt->bindValue(':limite', $limite, PDO::PARAM_INT);
        $stmt->execute();

        return $stmt->fetchAll();
    }
}

$busca = new BuscaNotas($pdo);

print_r($busca->buscar('café'));       // acha "cafe" também (remove_diacritics)
print_r($busca->buscar('café torra'));  // as duas palavras
print_r($busca->autocompletar('prog'));

Exercício 3

Implemente a mesma aplicação usando MySQL (ou PostgreSQL). Crie uma interface NotaRepositorioInterface que ambas as implementações satisfaçam. Escreva testes que rodem contra SQLite :memory: mas garantam que o código funciona.

Ver resposta

✓ Resposta: O teste de contrato compartilhado é o que descobre as diferenças silenciosas entre bancos — e há várias: SQLite não tem boolean (daí o normalizar()), rowCount() no MySQL conta linhas alteradas e não encontradas, e o AUTOINCREMENT se comporta diferente após DELETE.

<?php

declare(strict_types=1);

interface NotaRepositorioInterface
{
    public function criar(string $titulo, string $conteudo, array $tags = []): int;

    public function buscar(int $id): ?array;

    /** @return array<int, array> */
    public function listar(bool $incluirArquivadas = false): array;

    public function editar(int $id, string $titulo, string $conteudo): bool;

    public function arquivar(int $id, bool $arquivar = true): bool;

    public function deletar(int $id): bool;
}

// ---------------------------------------- SQLite
final class NotaRepositorioSqlite implements NotaRepositorioInterface
{
    public function __construct(private readonly PDO $pdo) {}

    public function criar(string $titulo, string $conteudo, array $tags = []): int
    {
        $stmt = $this->pdo->prepare('INSERT INTO notas (titulo, conteudo) VALUES (:t, :c)');
        $stmt->execute([':t' => $titulo, ':c' => $conteudo]);

        return (int) $this->pdo->lastInsertId();
    }

    public function buscar(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM notas WHERE id = :id');
        $stmt->execute([':id' => $id]);

        $nota = $stmt->fetch();

        // SQLite não tem boolean: normalizar aqui é o que permite ao teste
        // ser idêntico para os dois bancos.
        return $nota === false ? null : $this->normalizar($nota);
    }

    public function listar(bool $incluirArquivadas = false): array
    {
        $sql = 'SELECT * FROM notas' . ($incluirArquivadas ? '' : ' WHERE arquivada = 0')
             . ' ORDER BY id DESC';

        return array_map($this->normalizar(...), $this->pdo->query($sql)->fetchAll());
    }

    public function editar(int $id, string $titulo, string $conteudo): bool
    {
        $stmt = $this->pdo->prepare(
            "UPDATE notas SET titulo = :t, conteudo = :c,
                    atualizada_em = datetime('now') WHERE id = :id"
        );
        $stmt->execute([':t' => $titulo, ':c' => $conteudo, ':id' => $id]);

        return $stmt->rowCount() > 0;
    }

    public function arquivar(int $id, bool $arquivar = true): bool
    {
        $stmt = $this->pdo->prepare('UPDATE notas SET arquivada = :a WHERE id = :id');
        $stmt->execute([':a' => $arquivar ? 1 : 0, ':id' => $id]);

        return $stmt->rowCount() > 0;
    }

    public function deletar(int $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM notas WHERE id = :id');
        $stmt->execute([':id' => $id]);

        return $stmt->rowCount() > 0;
    }

    private function normalizar(array $nota): array
    {
        $nota['id'] = (int) $nota['id'];
        $nota['arquivada'] = (bool) $nota['arquivada'];

        return $nota;
    }
}

// ---------------------------------------- MySQL
final class NotaRepositorioMysql implements NotaRepositorioInterface
{
    public function __construct(private readonly PDO $pdo) {}

    public function criar(string $titulo, string $conteudo, array $tags = []): int
    {
        $stmt = $this->pdo->prepare('INSERT INTO notas (titulo, conteudo) VALUES (:t, :c)');
        $stmt->execute([':t' => $titulo, ':c' => $conteudo]);

        return (int) $this->pdo->lastInsertId();
    }

    public function buscar(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM notas WHERE id = :id');
        $stmt->execute([':id' => $id]);

        $nota = $stmt->fetch();

        return $nota === false ? null : $this->normalizar($nota);
    }

    public function listar(bool $incluirArquivadas = false): array
    {
        $sql = 'SELECT * FROM notas' . ($incluirArquivadas ? '' : ' WHERE arquivada = 0')
             . ' ORDER BY id DESC';

        return array_map($this->normalizar(...), $this->pdo->query($sql)->fetchAll());
    }

    public function editar(int $id, string $titulo, string $conteudo): bool
    {
        $stmt = $this->pdo->prepare(
            'UPDATE notas SET titulo = :t, conteudo = :c, atualizada_em = NOW() WHERE id = :id'
        );
        $stmt->execute([':t' => $titulo, ':c' => $conteudo, ':id' => $id]);

        // ⚠️ No MySQL, rowCount() conta linhas ALTERADAS. Salvar o mesmo
        // conteúdo devolve 0 — por isso o teste não pode assumir que
        // "editar com os mesmos valores" retorna true.
        return $stmt->rowCount() > 0;
    }

    public function arquivar(int $id, bool $arquivar = true): bool
    {
        $stmt = $this->pdo->prepare('UPDATE notas SET arquivada = :a WHERE id = :id');
        $stmt->execute([':a' => $arquivar ? 1 : 0, ':id' => $id]);

        return $stmt->rowCount() > 0;
    }

    public function deletar(int $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM notas WHERE id = :id');
        $stmt->execute([':id' => $id]);

        return $stmt->rowCount() > 0;
    }

    private function normalizar(array $nota): array
    {
        $nota['id'] = (int) $nota['id'];
        $nota['arquivada'] = (bool) $nota['arquivada'];

        return $nota;
    }
}

// ---------------------------------------- Tests/NotaRepositorioTest.php
declare(strict_types=1);

namespace Tests;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

/**
 * Um caso de teste abstrato, duas subclasses concretas: o MESMO conjunto de
 * asserções roda contra os dois bancos. É o teste de contrato — se o SQLite
 * passa e o MySQL não, a diferença está exposta.
 */
abstract class NotaRepositorioTestCase extends TestCase
{
    abstract protected function criarRepositorio(): NotaRepositorioInterface;

    private NotaRepositorioInterface $repo;

    protected function setUp(): void
    {
        $this->repo = $this->criarRepositorio();
    }

    #[Test]
    public function criarDevolveIdPositivo(): void
    {
        self::assertGreaterThan(0, $this->repo->criar('Título', 'Conteúdo'));
    }

    #[Test]
    public function buscarDevolveANotaCriada(): void
    {
        $id = $this->repo->criar('Comprar café', 'Torra média');
        $nota = $this->repo->buscar($id);

        self::assertNotNull($nota);
        self::assertSame('Comprar café', $nota['titulo']);
        self::assertFalse($nota['arquivada']);
    }

    #[Test]
    public function buscarInexistenteDevolveNull(): void
    {
        self::assertNull($this->repo->buscar(999_999));
    }

    #[Test]
    public function arquivadaSaiDaListagemPadrao(): void
    {
        $id = $this->repo->criar('Nota', 'x');
        $this->repo->arquivar($id);

        self::assertCount(0, $this->repo->listar());
        self::assertCount(1, $this->repo->listar(incluirArquivadas: true));
    }

    #[Test]
    public function editarAlteraOsCampos(): void
    {
        $id = $this->repo->criar('Antigo', 'a');

        self::assertTrue($this->repo->editar($id, 'Novo', 'b'));
        self::assertSame('Novo', $this->repo->buscar($id)['titulo']);
    }

    #[Test]
    public function deletarRemoveDeVez(): void
    {
        $id = $this->repo->criar('Some', 'x');

        self::assertTrue($this->repo->deletar($id));
        self::assertNull($this->repo->buscar($id));
        self::assertFalse($this->repo->deletar($id));   // já não existe
    }
}

final class NotaRepositorioSqliteTest extends NotaRepositorioTestCase
{
    protected function criarRepositorio(): NotaRepositorioInterface
    {
        // :memory: — rápido, isolado, sem arquivo para limpar.
        $pdo = new \PDO('sqlite::memory:', null, null,
                        [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
        $pdo->exec('PRAGMA foreign_keys = ON');
        $pdo->exec("CREATE TABLE notas (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            titulo TEXT NOT NULL, conteudo TEXT NOT NULL DEFAULT '',
            arquivada INTEGER NOT NULL DEFAULT 0,
            criada_em TEXT DEFAULT (datetime('now')),
            atualizada_em TEXT DEFAULT (datetime('now')))");

        return new NotaRepositorioSqlite($pdo);
    }
}

final class NotaRepositorioMysqlTest extends NotaRepositorioTestCase
{
    protected function criarRepositorio(): NotaRepositorioInterface
    {
        if (!getenv('MYSQL_TEST_DSN')) {
            self::markTestSkipped('MYSQL_TEST_DSN não configurado.');
        }

        $pdo = new \PDO(getenv('MYSQL_TEST_DSN'), getenv('MYSQL_TEST_USER'),
                        getenv('MYSQL_TEST_PASS'),
                        [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);

        $pdo->exec('DROP TABLE IF EXISTS notas');
        $pdo->exec('CREATE TABLE notas (
            id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            titulo VARCHAR(200) NOT NULL, conteudo TEXT NOT NULL,
            arquivada TINYINT(1) NOT NULL DEFAULT 0,
            criada_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            atualizada_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB');

        return new NotaRepositorioMysql($pdo);
    }
}

Exercício 4

Desafio: implemente backup automático do SQLite: uma função backup(string $destinoPath): void que usa a API de backup do SQLite via $pdo->sqliteCreateFunction() ou via linha de comando .dump.

Ver resposta

✓ Resposta: Nunca faça backup de SQLite com copy() puro: em modo WAL as transações recentes vivem no arquivo -wal, e você copiaria um banco desatualizado — ou corrompido, se houver escrita no meio. VACUUM INTO resolve, e o integrity_check no fim garante que o backup abre.

<?php

declare(strict_types=1);

final class BackupSqlite
{
    public function __construct(
        private readonly PDO $pdo,
        private readonly string $caminhoBanco,
    ) {}

    /**
     * Copiar o arquivo .db com copy() é a forma ERRADA: se houver escrita
     * em andamento, você leva um banco corrompido — e em modo WAL o arquivo
     * principal nem contém as transações recentes (elas estão no -wal).
     *
     * Este método usa VACUUM INTO, que produz um banco íntegro e compactado
     * SEM travar a aplicação. Exige SQLite 3.27+ (2019).
     */
    public function backup(string $destinoPath): void
    {
        $destino = $this->prepararDestino($destinoPath);

        if (version_compare($this->versaoSqlite(), '3.27.0', '>=')) {
            // VACUUM INTO não aceita placeholder: o caminho é validado à mão.
            $this->pdo->exec("VACUUM INTO " . $this->pdo->quote($destino));
        } else {
            $this->backupPorCopiaSegura($destino);
        }

        $this->verificar($destino);
    }

    /** Caminho para SQLite antigo: checkpoint + cópia dentro de transação. */
    private function backupPorCopiaSegura(string $destino): void
    {
        // TRUNCATE força tudo do -wal para o arquivo principal e zera o WAL.
        $this->pdo->exec('PRAGMA wal_checkpoint(TRUNCATE)');

        // A transação exclusiva impede escrita durante a cópia.
        $this->pdo->exec('BEGIN IMMEDIATE');

        try {
            if (!copy($this->caminhoBanco, $destino)) {
                throw new RuntimeException("Falha ao copiar para {$destino}.");
            }
        } finally {
            $this->pdo->exec('COMMIT');
        }
    }

    /** Backup em SQL puro — portátil, versionável, mas bem maior. */
    public function backupSql(string $destinoPath): void
    {
        $destino = $this->prepararDestino($destinoPath);
        $handle = fopen($destino, 'wb');

        fwrite($handle, "PRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\n");

        $tabelas = $this->pdo->query(
            "SELECT name, sql FROM sqlite_master
              WHERE type='table' AND name NOT LIKE 'sqlite_%'"
        )->fetchAll(PDO::FETCH_ASSOC);

        foreach ($tabelas as $tabela) {
            fwrite($handle, $tabela['sql'] . ";\n");

            $linhas = $this->pdo->query("SELECT * FROM \"{$tabela['name']}\"");

            foreach ($linhas as $linha) {
                $valores = array_map(
                    fn($v): string => $v === null ? 'NULL' : $this->pdo->quote((string) $v),
                    $linha,
                );

                fwrite($handle, sprintf("INSERT INTO \"%s\" VALUES (%s);\n",
                    $tabela['name'], implode(',', $valores)));
            }
        }

        // Índices e triggers vêm depois dos dados: inserir com índice ativo
        // é bem mais lento.
        foreach ($this->pdo->query(
            "SELECT sql FROM sqlite_master WHERE type IN ('index','trigger') AND sql IS NOT NULL"
        ) as $objeto) {
            fwrite($handle, $objeto['sql'] . ";\n");
        }

        fwrite($handle, "COMMIT;\nPRAGMA foreign_keys=ON;\n");
        fclose($handle);
    }

    /** Rotação: mantém os N backups mais recentes. */
    public function rotacionar(string $diretorio, int $manter = 7): int
    {
        $arquivos = glob(rtrim($diretorio, '/') . '/backup-*.db') ?: [];

        usort($arquivos, static fn(string $a, string $b): int => filemtime($b) <=> filemtime($a));

        $removidos = 0;

        foreach (array_slice($arquivos, $manter) as $antigo) {
            if (unlink($antigo)) {
                $removidos++;
            }
        }

        return $removidos;
    }

    /** Backup que não abre é papel de parede: sempre verifique. */
    private function verificar(string $destino): void
    {
        if (!is_file($destino) || filesize($destino) === 0) {
            throw new RuntimeException("Backup vazio ou inexistente: {$destino}");
        }

        $copia = new PDO('sqlite:' . $destino, null, null,
                         [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);

        $resultado = $copia->query('PRAGMA integrity_check')->fetchColumn();

        if ($resultado !== 'ok') {
            throw new RuntimeException("Backup corrompido: {$resultado}");
        }
    }

    private function prepararDestino(string $caminho): string
    {
        $diretorio = dirname($caminho);

        if (!is_dir($diretorio) && !mkdir($diretorio, 0700, true)) {
            throw new RuntimeException("Não foi possível criar {$diretorio}.");
        }

        if (is_file($caminho)) {
            // VACUUM INTO recusa sobrescrever arquivo existente.
            unlink($caminho);
        }

        return $caminho;
    }

    private function versaoSqlite(): string
    {
        return (string) $this->pdo->query('SELECT sqlite_version()')->fetchColumn();
    }
}

$backup = new BackupSqlite($pdo, __DIR__ . '/notas.db');

$arquivo = __DIR__ . '/backups/backup-' . date('Ymd-His') . '.db';

$backup->backup($arquivo);
$backup->rotacionar(__DIR__ . '/backups', manter: 7);

printf("Backup em %s (%.1f KB)%s", $arquivo, filesize($arquivo) / 1024, PHP_EOL);

// 0 3 * * *  php /caminho/backup.php

Exercício 5

O artigo menciona de passagem que o SQLite aceita texto numa coluna INTEGER. Transforme isso em experimento: grave 'três' e '42' na mesma coluna INTEGER e use typeof() para descobrir o que o banco guardou em cada caso. Depois recrie a tabela como STRICT e observe a diferença. Por fim, escreva a migração de uma tabela para a outra — e responda o que acontece com os dados que já estavam sujos.

Ver resposta

✓ Resposta: O tipo declarado numa tabela comum é uma sugestão, e o typeof() mostra as duas metades disso: 'três' fica guardado como text dentro da coluna INTEGER, enquanto '42' é convertido em silêncio para integer — ou seja, o banco às vezes converte e às vezes não, e você só descobre qual foi consultando linha a linha. Desde a versão 3.37, de 2021, a resposta do próprio SQLite para isso é a tabela STRICT, que recusa com cannot store TEXT value in INTEGER column. A parte que costuma surpreender é a migração: não existe ALTER TABLE que torne uma tabela STRICT, então o caminho é criar a nova, copiar com CAST e trocar os nomes — e o CAST('três' AS INTEGER) devolve 0, sem erro e sem aviso. Rode um SELECT por typeof() antes de migrar: o que estiver fora do tipo vira zero calado.

<?php
declare(strict_types=1);

$pdo = new PDO('sqlite::memory:', options: [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

// ── 1. Tabela comum: o tipo declarado é uma SUGESTÃO ───────────────────
$pdo->exec('CREATE TABLE medidas (id INTEGER PRIMARY KEY, qtd INTEGER, preco REAL)');

$inserir = $pdo->prepare('INSERT INTO medidas (qtd, preco) VALUES (?, ?)');
$inserir->execute(['três', 'caro']);   // texto puro: fica texto
$inserir->execute(['42', '9.90']);     // texto numérico: vira número

// typeof() revela o que o SQLite REALMENTE guardou, coluna por coluna
foreach ($pdo->query('SELECT qtd, typeof(qtd) AS tq, preco, typeof(preco) AS tp FROM medidas') as $l) {
    printf(
        "  qtd=%-8s %-8s | preco=%-8s %s\n",
        var_export($l['qtd'], true), "({$l['tq']})",
        var_export($l['preco'], true), "({$l['tp']})"
    );
}

// ── 2. A mesma tabela como STRICT (SQLite 3.37+, de 2021) ─────────────
$pdo->exec('CREATE TABLE medidas_strict (
    id    INTEGER PRIMARY KEY,
    qtd   INTEGER NOT NULL,
    preco REAL    NOT NULL
) STRICT');

try {
    $pdo->prepare('INSERT INTO medidas_strict (qtd, preco) VALUES (?, ?)')
        ->execute(['três', 'caro']);
    echo "  STRICT aceitou texto — versão do SQLite anterior à 3.37?\n";
} catch (PDOException $e) {
    echo "  STRICT recusou: " . $e->getMessage() . "\n";
}

// ── 3. Migrar sem perder dados: o SQLite não converte tabela em STRICT ─
// com ALTER. O caminho é criar a nova, copiar com CAST e trocar os nomes.
$pdo->exec('INSERT INTO medidas_strict (qtd, preco)
            SELECT CAST(qtd AS INTEGER), CAST(preco AS REAL) FROM medidas');

foreach ($pdo->query('SELECT qtd, typeof(qtd) AS t, preco FROM medidas_strict') as $l) {
    printf("  migrado: qtd=%-3s (%s)  preco=%s\n", $l['qtd'], $l['t'], $l['preco']);
}
Comentários

Mais em PHP

Upload de Arquivos
Upload de Arquivos

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

Arrays em Profundidade
Arrays em Profundidade

O array do PHP é uma estrutura só que faz o papel de lista, mapa e registro —…

Tratamento de Erros e Exceções
Tratamento de Erros e Exceções

O PHP tem dois sistemas para lidar com o que dá errado: os níveis de erro…