Em julho o Aaron Patterson escreveu sobre detectar full table scans com SQLite. O pulo do gato é que você não precisa de EXPLAIN QUERY PLAN pra isso. O SQLite já mantém um contador por statement de quantas linhas ele percorreu durante um scan, e dá pra ler esse número depois que a query roda. Se for maior que zero, aquele statement escaneou.

Um dia depois, o Kevin Gibbons abriu uma issue no nodejs/node pedindo a mesma coisa no node:sqlite, citando o post. Não tinha como chegar no sqlite3_stmt_status() a partir do JavaScript. Eu peguei a issue, e entrou hoje. Dois métodos no StatementSync:

statement.stat(counter)   // lê um contador
statement.resetStats()    // zera todos

O teste de scan

Mesmo formato do exemplo em Ruby do Aaron. Mil usuários, uma query numa coluna sem índice:

import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)');

const insert = db.prepare('INSERT INTO users (name, age) VALUES (?, ?)');
for (let i = 0; i < 1000; i++) {
  insert.run(`user-${i}`, i % 80);
}

const stmt = db.prepare('SELECT * FROM users WHERE age = ?');

function query(age) {
  const rows = stmt.all(age);
  console.log('fullscanStep:', stmt.stat('fullscanStep'));
  stmt.resetStats();
  return rows;
}

query(30);
db.exec('CREATE INDEX users_age_idx ON users (age)');
query(30);
fullscanStep: 999
fullscanStep: 0

999 antes do índice, 0 depois. O vmStep também cai, de 3059 pra 101, com exatamente o mesmo result set.

Repare na chamada de resetStats(). Os contadores são cumulativos pelo tempo de vida do prepared statement. Ou seja, se você reusa o statement num loop de requisições - que é justamente o motivo de preparar ele - precisa zerar entre as medições, senão está lendo um total acumulado.

Os contadores

O stat() recebe um nome e devolve um número:

Nome O que conta
fullscanStep Linhas percorridas durante um full table scan
sort Operações de ordenação executadas
autoindex Linhas inseridas em índices transientes que o SQLite criou pra acelerar um join
vmStep Operações da máquina virtual executadas
reprepare Re-prepares automáticos depois de uma mudança de schema
run Ciclos de execução iniciados
filterMiss Resultados de Bloom filter que ainda exigiram o passo do join
filterHit Passos de join pulados porque um Bloom filter retornou não-encontrado
memused Bytes de heap aproximados que o statement segura

filterMiss e filterHit precisam do SQLite 3.38.0 ou mais novo. O Node embarca uma versão recente, então isso só te pega se você compilou com --shared-sqlite contra algo antigo. Nesse caso os nomes lançam ERR_INVALID_ARG_VALUE.

O memused é o esquisito da lista. Ele reporta uso atual em vez de uma contagem acumulada, então o SQLite ignora a flag de reset pra ele e o resetStats() não mexe nesse valor.

Um bom uso pra isso

O Aaron cogitou plugar isso no Rails pra avisar ou estourar erro em test e development. Mesma ideia aqui, e é barato - o stat() lê um inteiro que o SQLite já mantém. O guardrail inteiro cabe em seis linhas:

import assert from 'node:assert';

function assertNoScan(stmt, ...params) {
  stmt.resetStats();
  const rows = stmt.all(...params);
  assert.strictEqual(
    stmt.stat('fullscanStep'), 0,
    `full table scan em: ${stmt.sourceSQL}`,
  );
  return rows;
}

Passe o statement SELECT * FROM users WHERE age = ? lá de cima por essa função antes do índice existir e ela quebra com 999 !== 0, imprimindo o SQL culpado.

Isso pesa mais agora que muito SQL sai da mão de um agente. Um modelo que emite WHERE age = ? não sabe se age tem índice, e nada na saída dele marca o chute.

O code review também deixa passar, porque a query está correta. O índice só passa a sustentar peso em produção, meses depois. O fullscanStep transforma isso numa asserção que a CI consegue quebrar.

Uma tabela indexada de cinco linhas ainda reporta zero, então fixture pequena não dá alarme falso. E os contadores são por statement, então você habilita query a query - o que é justamente o que você quer, já que um monte de query deve escanear mesmo.

Está na main, então sai na próxima release. Se você plugar a asserção num helper de teste, quero saber como foi.

Por hoje é só.