Skip to main content

Acesso a banco

Quando usar: quando o módulo guarda estado. O serviço recebe um Db (o pool) e, dentro de uma transação, um Tx. Não há ORM, não há @Transactional, e não há SQL gerado por biblioteca: as instruções ficam no store, escritas à mão.

O que você liga​

O banco existe no serviço quando há um bean Migrator — é ele o gatilho. Com ele, a auto-configuração do SDK entrega:

BeanO que é
DataSourceO pool (HikariCP) sobre o slot db-runtime; a senha é relida do arquivo a cada conexão (rotação do Secret vale sem restart).
DbO pool visto como Sql (autocommit).
TxA transação, dentro de db.inTx(ctx, tx -> …).
checks db e schemaEntram no /ready: o schema é o ledger de migrations.

nnp/service.yaml tem de declarar o banco:

database: {engine: mysql, migrations: {mode: expand-only}}

Ler e escrever​

java/src/main/java/.../store/ItemStore.java:

/** O INSERT do item. Nome repetido no tenant: 409, com o nome em `details`. */
public static void create(Ctx ctx, Sql q, Item it) {
try {
q.exec(
ctx,
INSERT,
it.tenantId(), it.id(), it.name(), it.description(), it.createdBy(), it.createdReleaseId(),
SqlUtil.time(it.createdAt()), SqlUtil.time(it.updatedAt()), Scan.NONE);
} catch (DbException e) {
if (SqlUtil.is(e, SqlUtil.ER_DUP_ENTRY)) {
throw ApiError.of(Code.CONFLICT, Scan.REASON_ITEM_NAME_TAKEN, "Já existe um item com este nome.")
.withDetail("name", it.name());
}
throw e;
}
}

As quatro regras do store:

  • A função recebe o Sql, não o Db: assim ela serve no pool (autocommit) e dentro da transação sem saber a diferença.
  • O tenant é sempre um parâmetro e sempre o primeiro do WHERE. Nenhuma consulta é "do banco inteiro".
  • Tempo é texto, nos dois sentidos: SqlUtil.time(instante) na escrita e CAST(col AS CHAR) + Row.time(col) na leitura. Um Instant como argumento é recusado pelo SDK — de propósito, para não depender do fuso do driver.
  • Erro do banco é traduzido para o Error Model quando tem significado de negócio (a chave única acima); o resto sobe, e o runtime decide.

Leitura com trava, dentro da transação da mutação:

/** O item do tenant. Com `forUpdate`, trava a linha — use dentro de `db.inTx`. */
public static Item get(Ctx ctx, Sql q, String tenantId, String id, boolean forUpdate) {
return q.queryOne(ctx, SELECT_ONE + (forUpdate ? " FOR UPDATE" : ""), ItemStore::scan, tenantId, id)
.orElseThrow(ItemStore::notFound);
}
// A mutação: ler (travando), decidir e escrever na MESMA transação.
db.inTx(ctx, tx -> {
Item it = ItemStore.get(ctx, tx, tenant, id, true);
if (service.scanBusy(ctx, it)) {
throw SampleService.scanInProgress(it);
}
ItemStore.delete(ctx, tx, tenant, id);
return null;
});

throw dentro do inTx desfaz tudo: é assim que uma recusa de negócio não deixa escrita pela metade.

Paginação por cursor, não por OFFSET​

Página com LIMIT/OFFSET repete e pula item quando algo é criado no meio. O módulo pagina por chave (created_at + item_id, exatamente a chave do índice):

public static Page list(Ctx ctx, Sql q, String tenantId, int limit, byte[] after) {
List<Object> args = new ArrayList<>();
args.add(tenantId);
String where = "tenant_id = ?";
if (after.length > 0) {
Cursor c = Cursor.decode(after);
where += " AND (created_at < ? OR (created_at = ? AND item_id < ?))";
args.add(SqlUtil.time(c.createdAt()));
args.add(SqlUtil.time(c.createdAt()));
args.add(c.id());
}
args.add(limit + 1);
List<Item> items =
q.query(ctx, SELECT_PAGE + where + " ORDER BY created_at DESC, item_id DESC LIMIT ?",
ItemStore::scan, args.toArray());
String next = "";
if (items.size() > limit) {
Item last = items.get(limit - 1);
items = List.copyOf(items.subList(0, limit));
next = Cursor.encode(last.createdAt(), last.id());
}
return new Page(items, next);
}

Três detalhes que fazem a página ser correta:

  • Lê limit + 1 e devolve limit: saber se há próxima página não custa uma segunda consulta.
  • O cursor é a chave do último item devolvido, não do primeiro da próxima.
  • Cursor que o módulo não emitiu é 400 (invalid_cursor), nunca 500: Cursor.decode valida antes de usar.

"Linhas afetadas" é "linhas mudadas"​

A conexão abre com useAffectedRows=true. Um UPDATE que não muda nada devolve 0 — o que é a base da posse da varredura:

public static boolean markScanRunning(Ctx ctx, Sql q, String tenantId, String id, String actionId, Instant now) {
long n = q.exec(ctx, SET_RUNNING, Scan.RUNNING, SqlUtil.time(now), tenantId, id, actionId, Scan.PENDING, Scan.RUNNING);
if (n == 1) {
return true;
}
Optional<Optional<String>> cur =
q.queryOne(ctx, SCAN_STATUS_OF, r -> Optional.ofNullable(r.string(1)), tenantId, id, actionId);
return cur.isPresent() && Scan.RUNNING.equals(cur.get().orElse(""));
}

Zero linhas não significa "não é meu": pode significar "já estava em running". Por isso a segunda consulta, que confirma a posse.

Como testar​

MySQL de verdade, banco criado e apagado pela própria suíte (java/src/test/java/.../store/ItemStoreMysqlTest.java):

@BeforeAll
static void setUp() throws Exception {
fixture = MysqlFixture.create("items"); // pula sem NNC_TEST_MYSQL_DSN; nunca finge com H2
db = fixture.db();
}

@Test
void nameIsUniquePerTenantIgnoringCaseAndAccent() {
ItemStore.create(CTX, db, item("t1", "Ação", T0));
assertThatThrownBy(() -> ItemStore.create(CTX, db, item("t1", "acao", T0)))
.isInstanceOfSatisfying(ApiError.class, e -> assertThat(e.reason()).isEqualTo(Scan.REASON_ITEM_NAME_TAKEN));
ItemStore.create(CTX, db, item("t2", "ação", T0)); // outro tenant: outro item
}

Sem NNC_TEST_MYSQL_DSN o teste é pulado e diz isso. Um banco em memória provaria o SQL, não o MySQL: collation ai_ci, chave única, coluna JSON e contagem de linhas são o que está sendo testado.

Erros comuns​

  • Montar SQL com o que veio do cliente — nome de coluna, ORDER BY, LIMIT nunca vêm do pedido; os valores vão como parâmetro (?).
  • Passar Instant como argumento — o SDK recusa. Tempo entra e sai como texto.
  • Esquecer o tenant_id no WHERE — vazamento entre clientes. Toda consulta do domínio tem o tenant; o índice começa por ele.
  • Usar o pool onde precisa de transação — duas escritas sem inTx podem ficar pela metade.
  • Fazer chamada de rede dentro da transação — a transação segura linha e conexão; chame o outro serviço fora dela (veja Integração com outro serviço).
  • SELECT * — a lista de colunas é explícita e CAST(col AS CHAR) nas de tempo; a ordem das colunas é o contrato do mapeador.
  • LIMIT sem ORDER BY estável — sem a segunda coluna na ordenação, itens com o mesmo instante trocam de lugar entre páginas.
  • Devolver a exceção do banco ao cliente — traduza para o Error Model (veja Erros).