Banco de Dados II — 2026.2

Módulo 4, parte 2
Catálogo e o primeiro SELECT

👨‍🏫 Prof. Gustavo Pinto 🏛️ UFPA 📅 22 de outubro de 2026 🕐 7h30 – 9h

De onde a gente parou

Na terça, o parser transformou texto em árvore:

SELECT * FROM aluno;
# vira
{"op": "SELECT", "colunas": ["*"], "tabela": "aluno", "where": None}

aluno existe? Tem quais colunas? Mora em que página?

A árvore não sabe, e o parser não deve saber. Quem sabe é o catálogo, e hoje ele vai para o disco. Ao fim da aula, o SELECT * roda.

A meta de hoje

$ ./minidb dados.db
CREATE TABLE aluno (id INT, matricula INT, nome CHAR(20));
OK
INSERT INTO aluno VALUES (1, 20260001, 'Ana');
OK 1
INSERT INTO aluno VALUES (2, 20260002, 'Bruno');
OK 1
^D
$ ./minidb dados.db                  # outro processo
SELECT * FROM aluno;
1|20260001|Ana
2|20260002|Bruno
(2 linhas)

O segundo processo não viu o CREATE TABLE. Ele sabe que aluno existe, e com quais colunas, porque leu isso do disco. É a demonstração 4 do minidb 0.0, consertada.

O catálogo

Catálogo é o conjunto de tabelas em que o banco guarda a descrição das outras tabelas.

O SQLite vai além: guarda o próprio texto do CREATE TABLE, e analisa de novo toda vez que abre o arquivo.

sqlite> SELECT name, rootpage, sql FROM sqlite_schema;
aluno|2|CREATE TABLE aluno (id INT, nome TEXT, nota INT)

O catálogo do minidb é uma tabela

registro do catálogo, 240 bytes, 17 por página

nome      CHAR(32)    "aluno"
primeira  INT         2                       // primeira página da tabela
raiz      INT         0                       // raiz do índice, 0 se não há (M5)
sql       CHAR(200)   "CREATE TABLE aluno (id INT, matricula INT, nome CHAR(20))"

O ovo e a galinha

O catálogo descreve todas as tabelas. Quem descreve o catálogo?

ESQUEMA_CATALOGO = Esquema([("nome", "CHAR", 32), ("primeira", "INT", 4),
                            ("raiz", "INT", 4), ("sql", "CHAR", 200)])
PAGINA_CATALOGO = 1                                # fixa, combinada no código

class Catalogo:
    def __init__(self, cache):
        self.tabelas = {}
        for nome, primeira, raiz, sql in varre(cache, PAGINA_CATALOGO, ESQUEMA_CATALOGO):
            colunas = analisa(sql)["colunas"]       # o parser, de novo
            self.tabelas[nome] = (primeira, Esquema(colunas))

Só duas coisas ficam no código: o esquema do próprio catálogo, e a página onde ele começa. Todo o resto vem do disco.

Várias tabelas no mesmo arquivo

dados.db
+-----------+-----------+-----------+-----------+-----------+
| página 0  | página 1  | página 2  | página 3  | página 4  |
| metadados | catálogo  | aluno     | curso     | aluno     |
+-----------+-----------+-----------+-----------+-----------+
                              |                       ^
                              +--- próxima = 4 -------+          // a 4 aponta para 0: fim

O heap do M1 supunha uma tabela só. Agora cada página sabe qual é a próxima da mesma tabela, e o cabeçalho de 16 bytes ainda tem espaço para isso:

byte 0-1    n_slots ocupados
byte 2-3    tamanho do registro
byte 4-7    número desta página
byte 8-11   próxima página da mesma tabela     // novo. 0 é o fim, porque a 0 nunca é dado
byte 12-15  reservado                          // o M7 ainda cabe aqui

Olho nu: o catálogo no hexdump

$ hexdump -C -s 4096 -n 96 dados.db
00001000  01 00 f0 00 01 00 00 00  00 00 00 00 00 00 00 00  |................|
00001010  61 6c 75 6e 6f 00 00 00  00 00 00 00 00 00 00 00  |aluno...........|
00001020  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00001030  02 00 00 00 00 00 00 00  43 52 45 41 54 45 20 54  |........CREATE T|
00001040  41 42 4c 45 20 61 6c 75  6e 6f 20 28 69 64 20 49  |ABLE aluno (id I|
00001050  4e 54 2c 20 6d 61 74 72  69 63 75 6c 61 20 49 4e  |NT, matricula IN|

Varrer uma tabela

def varre(cache, primeira, esquema):
    n = primeira
    while n != 0:
        buf = cache.fixa(n)
        try:
            n_slots, tam, _, proxima = struct.unpack_from("<HHII", buf, 0)
            for s in range(n_slots):
                yield esquema.desserializa(buf, TAM_CABECALHO + s * tam)
        finally:
            cache.solta(n)
        n = proxima

Executar sem executor

def executa(sql, catalogo, cache):
    arvore = analisa(sql)                                   # tokenizador e parser
    if arvore["op"] == "CREATE":
        catalogo.cria(arvore["tabela"], arvore["colunas"], sql)
        return "OK"
    primeira, esquema = catalogo.busca(arvore["tabela"])    # semântica: a tabela existe?
    if arvore["op"] == "INSERT":
        confere_tipos(esquema, arvore["valores"])           # semântica: os tipos batem?
        insere(cache, primeira, esquema, arvore["valores"])
        return "OK 1"
    linhas = ["|".join(map(str, r)) for r in varre(cache, primeira, esquema)]
    n = len(linhas)
    return "\n".join(linhas + [f"({n} linha{'s' * (n != 1)})"])

É um interpretador de árvore: olha o op e faz. Funciona para SELECT *. No M5 isso vira uma pilha de operadores, e é ali que entram o WHERE e o índice.

Onde isso entra no minidb

SELECT * FROM aluno;

M4  tokenizador          5 tokens, mais o FIM
M4  parser               {"op": "SELECT", "colunas": ["*"], "tabela": "aluno", ...}
M4  catálogo             aluno começa na página 2, registro de 28 bytes
M2  cache de páginas     entrega as páginas da corrente, da memória ou do disco
M1  página e arquivo     lê 4096 bytes no deslocamento certo
M4  execução            desserializa cada slot e imprime 1|20260001|Ana

M3  árvore B+            ainda parada. Quem decide usar o índice é o M5

O parser não sabe o que é página, e o cache não sabe o que é SQL. Cada módulo só usa o que vem antes dele.

O catálogo também é tabela, e também vaza

$ python3 injecao.py "x' UNION SELECT 0, sql, 0 FROM sqlite_schema --"
concatenado: SELECT * FROM aluno WHERE nome = 'x' UNION SELECT 0, sql, 0 FROM sqlite_schema --'
  devolveu: [(0, 'CREATE TABLE aluno (id INT, nome TEXT, nota INT)', 0)]
  • O -- transforma a aspa que sobrou em comentário
  • O UNION junta uma consulta inteira, escolhida pelo atacante
  • Como o catálogo é consultável com SQL, o primeiro passo do ataque é ler o catálogo e descobrir o nome das outras tabelas

O que temos, e o que falta

Pronto

M1 a M3
  • Registros gravados em páginas
  • Páginas lidas pelo cache
  • Árvore B+ com busca e inserção

Agora

M4, entrega em 27/10
  • Tokenizador e parser dos três comandos
  • Catálogo gravado na página 1
  • SELECT * de ponta a ponta

Depois

M5 a M7
  • WHERE, índice e operadores
  • Transações e log
  • Sobreviver ao kill -9

No laboratório: primeiro o tokenizador e o parser, depois o catálogo e o SELECT *. No NOTES.md, digam onde o catálogo mora e por quê.

Para ir além

Dúvidas?

Próximo: laboratório do M4. Antes disso, o exercício de hoje

📧 gpinto@ufpa.br

Exercício de hoje

Para trazer no papel ao laboratório.

O item 2 é a conta do lab01, com o catálogo no lugar do registro.

Apêndice

O esqueleto do M4: o catálogo

def nova_pagina(cache, tam_reg):
    n = cache.pager.aloca()
    buf = cache.fixa(n)
    struct.pack_into("<HHII", buf, 0, 0, tam_reg, n, 0)   # 0 slots, sem próxima
    cache.solta(n, sujou=True)
    return n

class Catalogo:                                         # continua o __init__ da aula
    def cria(self, nome, colunas, sql):
        if nome in self.tabelas:
            raise ErroSemantico(f"tabela {nome} já existe")
        esquema = Esquema(colunas)
        primeira = nova_pagina(self.cache, esquema.tamanho)
        insere(self.cache, PAGINA_CATALOGO, ESQUEMA_CATALOGO, [nome, primeira, 0, sql])
        self.tabelas[nome] = (primeira, esquema)

    def busca(self, nome):
        if nome not in self.tabelas:
            raise ErroSemantico(f"tabela {nome} não existe")
        return self.tabelas[nome]
Apêndice

O esqueleto do M4: inserir seguindo a corrente

def insere(cache, primeira, esquema, valores):
    reg = esquema.serializa(valores)
    n = primeira
    while True:
        buf = cache.fixa(n)
        n_slots, tam, num, proxima = struct.unpack_from("<HHII", buf, 0)
        if TAM_CABECALHO + (n_slots + 1) * tam <= TAM_PAGINA:     # cabe aqui
            off = TAM_CABECALHO + n_slots * tam
            buf[off:off + tam] = reg
            struct.pack_into("<H", buf, 0, n_slots + 1)
            cache.solta(n, sujou=True)
            return (n, n_slots)                                   # o RID
        if proxima == 0:                                          # fim da corrente
            proxima = nova_pagina(cache, tam)
            struct.pack_into("<I", buf, 8, proxima)               # bytes 8-11
            cache.solta(n, sujou=True)
        else:
            cache.solta(n)
        n = proxima

Percorrer a corrente a cada inserção custa caro em tabela grande. Guardar a última página no catálogo resolve. Decisão para o NOTES.md.

Apêndice

O esqueleto do M4: a semântica do INSERT

def confere_tipos(esquema, valores):
    if len(valores) != len(esquema.colunas):
        raise ErroSemantico(f"esperava {len(esquema.colunas)} valores, vieram {len(valores)}")
    for (nome, tipo, n), v in zip(esquema.colunas, valores):
        if tipo == "INT" and not isinstance(v, int):
            raise ErroSemantico(f"coluna {nome} é INT, veio {v!r}")
        if tipo == "CHAR" and not (isinstance(v, str) and len(v.encode()) <= n):
            raise ErroSemantico(f"coluna {nome} é CHAR({n}), veio {v!r}")

Fica fora do parser: ele só sabe que veio um número ou um texto, e não qual coluna espera o quê.