Banco de Dados II — 2026.2

Módulo 4, parte 1
Do texto à árvore

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

Páginas, cache e árvore B+ estão prontos.
Quem já roda SELECT * FROM aluno?

Ninguém. Até aqui o minidb é uma biblioteca: quem chama insere e le_pagina é o código de teste de vocês, e não uma consulta.

Hoje o minidb aprende a ler. Ao fim do M4, ele recebe SQL pela entrada padrão, responde, e o primeiro SELECT roda de verdade.

A meta do M4

$ ./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 caminho de uma consulta

1

Tokenizador

Quebra o texto em tokens: palavra reservada, nome, número, texto, símbolo

2

Parser

Confere a ordem dos tokens contra a gramática e monta a árvore sintática

3

Análise semântica

A tabela existe? A coluna existe? O tipo bate? Quem responde é o catálogo

4

Execução

No M4, um if por comando, direto sobre a árvore. No M5, operadores

Hoje, as etapas 1 e 2. Na próxima aula, a 3 e a 4. Cada etapa tem o seu tipo de erro, e misturar as três primeiras é o defeito mais comum deste módulo.

Tokenizador

O primeiro comando que o minidb vai ler na vida:

CREATE TABLE aluno (id INT, matricula INT, nome CHAR(20));
CREATEpalavraTABLEpalavraalunonome(símboloidnomeINTpalavra,símbolomatriculanomeINTpalavra,símbolonomenomeCHARpalavra(símbolo20número)símbolo)símbolo;símboloFIMfim
  • Espaço some, e palavra reservada vira maiúscula: create e CREATE dão o mesmo token
  • O token leva tipo e valor. O 20 já sai como número, e não como o texto "20"
  • O tokenizador não sabe se a ordem faz sentido. TABLE CREATE ( ( passa sem reclamar

O tokenizador rodando à mão

nome CHAR(20),
0    5    10 13
ivêdecideemite
0nletra: lê nome, que não é reservada(NOME, "nome")
4espaçopulanada
5Cletra: lê CHAR, que está na lista(PALAVRA, "CHAR")
9(símbolo(SIMBOLO, "(")
102dígito: lê 20 e converte(NUMERO, 20)
12, 13) ,símbolo, símbolo(SIMBOLO, ")") (SIMBOLO, ",")
14fimacabou o texto(FIM, None)
[("NOME", "nome"), ("PALAVRA", "CHAR"), ("SIMBOLO", "("), ("NUMERO", 20),
 ("SIMBOLO", ")"), ("SIMBOLO", ","), ("FIM", None)]

A saída é uma lista de pares, em ordem. A ordem importa e há repetição, por isso não é um dicionário. O dicionário só aparece no parser.

Do tokenizador ao parser

texto
nome CHAR(20)
→tokeniza
lista de tokens
nomenomeCHARpalavra(símbolo20número)símboloFIMfim
→parser
pedaço da árvore
("nome", "CHAR", 20)

Parser

Os mesmos 13 tokens, em duas ordens. Só o primeiro é válido:

CREATE TABLE aluno (id INT, nome CHAR(20))
CREATE TABLE aluno (id INT nome, CHAR(20))

O tokenizador olha um token de cada vez, sem lembrar o que veio antes. Para ele, os dois são iguais. Quem olha a ordem é o parser.

O parser confere a ordem contra a gramática e monta a estrutura: que coluna tem que tipo, que valor pertence a que WHERE.

A gramática do minidb

comando := ( create | insert | select ) [ ; ]
create  := CREATE TABLE nome ( coluna { , coluna } )
coluna  := nome INT  |  nome CHAR ( número )
insert  := INSERT INTO nome VALUES ( valor { , valor } )
select  := SELECT ( * | nome { , nome } ) FROM nome [ WHERE nome = valor ]
valor   := número  |  texto

Como escrever um parser

regra
create := CREATE TABLE nome ( coluna { , coluna } )

vira
função create():
    espera CREATE
    espera TABLE
    tabela ← espera NOME
    espera (
    colunas ← [ coluna() ]
    enquanto aceita ,
        acrescenta coluna() em colunas
    espera )
    devolve {op: CREATE, tabela, colunas}
  • 1. Uma função por regra da gramática
  • 2. Duas operações sobre a lista de tokens: espera consome o token ou dá erro de sintaxe; aceita consome só se casar
  • 3. Traduza a regra pedaço por pedaço: obrigatório vira espera, [ ] vira se aceita, { } vira enquanto aceita, | vira se/senão olhando o próximo token
  • 4. Regra que cita outra regra chama a função dela: create chama coluna
  • 5. Cada função devolve um pedaço da árvore. No fim, espera FIM: se sobrou token, é erro

A árvore sintática

tabelacolunasCREATEalunolista(id, INT, 4)(matricula, INT, 4)(nome, CHAR, 20)← devolvido por create()cada tupla: uma chamada a coluna()
  • Os tokens viraram estrutura: o parser sabe que 20 é o tamanho de nome, e não uma coluna
  • CREATE, TABLE, parênteses e vírgulas somem. Serviram para o parser decidir, e acabou
{"op": "CREATE", "tabela": "aluno",
 "colunas": [("id", "INT", 4), ("matricula", "INT", 4), ("nome", "CHAR", 20)]}

A árvore é o que o parser devolve: cada função de regra monta o seu pedaço, e a de cima junta tudo. Em Python, um dicionário basta. Em C, uma struct.

Gramática, parser e árvore

Gramática: a especificação

Diz o que o parser aceita

Parser: a função

Recebe a lista de tokens, confere contra a gramática

Árvore sintática: o resultado

O que a função devolve

De ponta a ponta

SELECT nome FROM aluno WHERE id = 42

Tokenizador: nove tokens, contando o FIM

SELECTpalavranomenomeFROMpalavraalunonomeWHEREpalavraidnome=símbolo42númeroFIMfim
colunastabelawherecolunavalorSELECTnomealuno=id42

Parser: o 42 virou o valor do WHERE, e FROM e WHERE sumiram. A próxima aula executa árvores assim, começando pelo SELECT *. O WHERE fica para o M5.

Onde isso encaixa no minidb de vocês

Até o M3: quem chama é o teste

pager = Pager("dados.db")                # M1
cache = Cache(pager)                     # M2

esquema = Esquema([("id", "INT", 4),
                   ("matricula", "INT", 4)])
rid = insere(cache, primeira, esquema,
             [1, 20260001])

O esquema e os valores estão escritos à mão no código.

A partir do M4, o esquema e os valores saem da árvore. Nada do que vocês escreveram é jogado fora: o parser só troca quem fornece os argumentos.

Da árvore às funções que vocês já têm

A árvore dizO minidb chamaMódulo
"colunas": [("id", "INT", 4)]Esquema(colunas), o mesmo do lab01bM1
"valores": [1, 20260001]esquema.serializa(valores) e insereM1
"op": "SELECT"percorrer os registros, como no lab01bM1
por baixo de tudocache.fixa(n) e cache.solta(n)M2
"where": ("id", 42)busca na árvore B+M3, ligada no M5
"tabela": "aluno"em que página aluno começa? com quais colunas?quinta

A árvore traz o nome da tabela, mas o nome sozinho não diz onde ela mora. Quem sabe isso é o catálogo, assunto da próxima aula.

Exercício 1

INSERT INTO aluno VALUES (3, 20260003, 'Carla');

No minidb 0.0, Carla sem aspas funcionava. Por quê?

Gabarito

Gabarito do exercício 1

INSERTpalavraINTOpalavraalunonomeVALUESpalavra(símbolo3número,símbolo20260003número,símbolo'Carla'texto)símbolo;símboloFIMfim
tabelavaloresINSERTalunolista320260003'Carla'

aluno e os três valores ficam. INSERT, INTO, VALUES, os parênteses, as vírgulas e o ; somem.

Sem aspas, Carla vira um token NOME, e o parser diz esperava valor, veio 'Carla'. É erro de sintaxe. O minidb 0.0 não tinha tokens: tudo era texto, e por isso nada era errado.

A lição do módulo

O parser decide o que é comando e o que é dado. Concatenar string apaga essa fronteira, e é por isso que a injeção existe.

Quem implementou um parser não esquece mais essa distinção. É o motivo de segurança ser ensinada aqui, e não numa aula separada.

Leitura obrigatória de três segundos: xkcd 327, Exploits of a Mom.

Para ir além

Dúvidas?

Próxima aula: o catálogo, e o primeiro SELECT * de verdade. Antes disso, o exercício de hoje

📧 gpinto@ufpa.br

Exercício 2

Para trazer no papel à próxima aula.

O item 2 não tem resposta de uma linha. Tragam o argumento.

Apêndice

O tokenizador em código

def tokeniza(sql):
    tokens, i = [], 0
    while i < len(sql):
        c = sql[i]
        if c.isspace(): i += 1
        elif c.isalpha():                               # palavra reservada ou nome
            j = i
            while j < len(sql) and (sql[j].isalnum() or sql[j] == "_"): j += 1
            p = sql[i:j]
            tokens.append(("PALAVRA", p.upper()) if p.upper() in PALAVRAS else ("NOME", p))
            i = j
        elif c.isdigit(): ...                           # mesmo laço, com isdigit
        elif c == "'":                                  # texto vai até a próxima aspa
            j = sql.find("'", i + 1)
            if j < 0: raise ErroLexico(f"aspas sem fechamento na posição {i}")
            tokens.append(("TEXTO", sql[i + 1:j]))
            i = j + 1
        elif c in "(),;*=":
            tokens.append(("SIMBOLO", c)); i += 1
        else: raise ErroLexico(f"caractere inesperado {c!r} na posição {i}")
    return tokens + [("FIM", None)]
Apêndice

Descida recursiva: uma função por regra

def create(self):
    self.espera("PALAVRA", "CREATE")
    self.espera("PALAVRA", "TABLE")
    tabela = self.espera("NOME")
    self.espera("SIMBOLO", "(")
    colunas = [self.coluna()]
    while self.aceita("SIMBOLO", ","):
        colunas.append(self.coluna())
    self.espera("SIMBOLO", ")")
    return {"op": "CREATE", "tabela": tabela, "colunas": colunas}

def coluna(self):
    nome = self.espera("NOME")
    if self.aceita("PALAVRA", "INT"):
        return (nome, "INT", 4)
    self.espera("PALAVRA", "CHAR")
    self.espera("SIMBOLO", "(")
    n = self.espera("NUMERO")
    self.espera("SIMBOLO", ")")
    return (nome, "CHAR", n)
  • Palavra obrigatória vira espera
  • | e [ ] viram if
  • { } vira while
  • Regra que chama regra vira chamada de função: create chama coluna

espera consome o token ou levanta erro de sintaxe. aceita consome só se casar, e diz se casou. As duas estão no próximo slide, e o select logo depois.

Apêndice

O esqueleto do M4: o parser

class Parser:
    def __init__(self, tokens):
        self.tokens, self.i = tokens, 0
    def olha(self):
        return self.tokens[self.i]
    def aceita(self, tipo, valor):
        if self.tokens[self.i] == (tipo, valor):
            self.i += 1
            return True
        return False
    def espera(self, tipo, valor=None):
        t, v = self.tokens[self.i]
        if t != tipo or (valor is not None and v != valor):
            raise ErroSintatico(f"esperava {valor or tipo}, veio {v!r}")
        self.i += 1
        return v
    def valor(self):
        t, v = self.olha()
        if t in ("NUMERO", "TEXTO"):
            self.i += 1
            return v
        raise ErroSintatico(f"esperava valor, veio {v!r}")
Apêndice

O esqueleto do M4: o comando

def comando(self):
    _, v = self.olha()
    if v == "CREATE":   arvore = self.create()
    elif v == "INSERT": arvore = self.insert()     # mesmo molde do create, com valor
    elif v == "SELECT": arvore = self.select()
    else: raise ErroSintatico(f"comando desconhecido {v!r}")
    self.aceita("SIMBOLO", ";")
    self.espera("FIM")                            # sobrou token? é erro
    return arvore

def analisa(sql):                                 # o que o resto do minidb chama
    return Parser(tokeniza(sql)).comando()
Apêndice

O esqueleto do M4: o SELECT

def select(self):
    self.espera("PALAVRA", "SELECT")
    if self.aceita("SIMBOLO", "*"):
        colunas = ["*"]
    else:
        colunas = [self.espera("NOME")]
        while self.aceita("SIMBOLO", ","):
            colunas.append(self.espera("NOME"))
    self.espera("PALAVRA", "FROM")
    tabela = self.espera("NOME")
    where = None
    if self.aceita("PALAVRA", "WHERE"):
        coluna = self.espera("NOME")
        self.espera("SIMBOLO", "=")
        where = (coluna, self.valor())
    return {"op": "SELECT", "colunas": colunas, "tabela": tabela, "where": where}

O parser já aceita o WHERE. Quem vai executá-lo é o M5.

Apêndice

O esqueleto do M4: o INSERT

def insert(self):
    self.espera("PALAVRA", "INSERT")
    self.espera("PALAVRA", "INTO")
    tabela = self.espera("NOME")
    self.espera("PALAVRA", "VALUES")
    self.espera("SIMBOLO", "(")
    valores = [self.valor()]
    while self.aceita("SIMBOLO", ","):
        valores.append(self.valor())
    self.espera("SIMBOLO", ")")
    return {"op": "INSERT", "tabela": tabela, "valores": valores}

O mesmo molde do create, com valor no lugar de coluna. O parser não confere se o valor bate com o tipo da coluna: isso é semântica, e precisa do catálogo.

Apêndice

O programa: o laço principal

import sys

def executa(sql, catalogo, cache):
    arvore = analisa(sql)                    # M4, hoje
    ...                                      # M4, parte 2: catálogo, M1 e M2

pager = Pager(sys.argv[1])                   # M1
cache = Cache(pager)                         # M2
catalogo = Catalogo(cache)                   # M4, parte 2

for linha in sys.stdin:
    if linha.strip():
        print(executa(linha, catalogo, cache))

cache.descarrega()                           # M2: páginas sujas vão para o disco

É isso que transforma a biblioteca do M1 ao M3 num programa que lê SQL da entrada padrão.

Apêndice

Injeção de SQL em código

# concatenação: o texto do usuário vira gramática
sql = "SELECT * FROM aluno WHERE nome = '" + nome + "'"
banco.execute(sql)
# nome = x' OR '1'='1    →  devolve a tabela inteira

# parâmetro: a consulta é analisada antes, e o valor entra depois
banco.execute("SELECT * FROM aluno WHERE nome = ?", (nome,))
# nome = x' OR '1'='1    →  devolve nada

Para rodar: python3 injecao.py, em /assets/code/bd2-2026/. Só precisa do Python. Tente também python3 injecao.py "x' UNION SELECT 0, sql, 0 FROM sqlite_schema --".