corrige(dados-externos): chave primária sempre como lista na introspecção

- introspeccao: cast da PK para text[] e normalização do literal '{id}'.
- ExploradorDeDados: tolera PK não-array (cache/rollout) em vez de quebrar.
- Testes: unidade da introspecção + regressão de render com PK crua.
- Fragmento de release (corrigido, patch -> 1.19.3).
This commit is contained in:
root
2026-09-14 00:30:35 +02:00
parent c14862624d
commit 7f8ca93091
5 changed files with 85 additions and 19 deletions
@@ -0,0 +1,11 @@
---
impacto: nada_mudou
secao: corrigido
titulo: Explorar as tabelas do banco externo volta a funcionar (chave primária vinha em formato errado)
---
Ao abrir uma tabela do banco externo cuja chave primária é uma coluna comum, a tela podia cair num erro em vez de mostrar os dados. A causa era de formato: o sistema lia a chave primária como um texto `{id}` em vez de uma lista de colunas, e a grade nova — que usa a chave para identificar cada linha — tropeçava nisso.
A leitura da chave virou uma lista de verdade (e a tela passou a tolerar o formato antigo, para não quebrar durante a atualização). O mesmo conserto melhora a descrição das tabelas que o agente de IA recebe.
Nada muda na forma de usar: abrir as tabelas do banco externo e ler os dados continua igual, agora sem o erro.
@@ -15,18 +15,20 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const { TABELA, DESCRICAO, LINHAS } = vi.hoisted(() => {
const { TABELA, TABELA_PK_CRUA, DESCRICAO, LINHAS } = vi.hoisted(() => {
const DESCRICAO = "descricao muito longa ".repeat(6).trim();
const base = {
schema: "public",
tipo: "tabela" as const,
colunas: [],
estimativaLinhas: 3,
};
return {
DESCRICAO,
TABELA: {
schema: "public",
nome: "pedidos",
tipo: "tabela" as const,
colunas: [],
chavePrimaria: ["id"],
estimativaLinhas: 3,
},
TABELA: { ...base, nome: "pedidos", chavePrimaria: ["id"] },
// Reproduz o defeito de produção: a introspecção devolvia a PK como o
// literal cru `"{id}"` (string), e o cliente quebrava ao iterar.
TABELA_PK_CRUA: { ...base, nome: "legado", chavePrimaria: "{id}" as unknown as string[] },
LINHAS: [{ id: 1, descricao: DESCRICAO }],
};
});
@@ -35,7 +37,7 @@ vi.mock("@/hooks/i18n/useT", () => ({ useT: () => (chave: string) => chave }));
vi.mock("@/hooks/external-db/useCatalogoExterno", () => ({
useCatalogoExterno: () => ({
data: [TABELA],
data: [TABELA, TABELA_PK_CRUA],
isLoading: false,
isError: false,
isSuccess: true,
@@ -95,6 +97,12 @@ describe("ExploradorDeDados — visão compacta e redimensionável", () => {
expect(celulaLonga.className).not.toContain("whitespace-pre-wrap");
});
it("não quebra quando a PK chega crua (string) em vez de lista", async () => {
render(<ExploradorDeDados connectionId="conn-1" />);
await userEvent.click(screen.getByRole("button", { name: "legado" }));
expect(await screen.findByText(DESCRICAO)).toBeInTheDocument();
});
it("alarga a coluna pela seta do teclado na alça de redimensionamento", async () => {
await abrirGrade();
const antes = larguraDaColuna(1);
@@ -172,9 +172,20 @@ export function ExploradorDeDados({ connectionId }: Props) {
return larguras[coluna] ?? LARGURA_PADRAO;
}
/**
* PK como lista, sempre. A introspecção já garante `string[]`, mas o cliente
* não pode confiar no contrato de um dado externo — uma resposta antiga em
* cache ou um servidor em rollout pode trazer o literal `"{id}"`, e `.map`
* nele derrubava a tela inteira.
*/
function colunasPk(): string[] {
const pk = selecionada?.chavePrimaria;
return Array.isArray(pk) ? pk : [];
}
/** Chave estável da linha (pela PK quando existe; pelo índice quando não). */
function chaveDaLinha(linha: Record<string, unknown>, indice: number): string {
const pk = selecionada?.chavePrimaria ?? [];
const pk = colunasPk();
if (pk.length === 0) return `#${indice}`;
return pk.map((coluna) => celula(linha[coluna])).join("|");
}
@@ -402,7 +413,7 @@ export function ExploradorDeDados({ connectionId }: Props) {
<TableHeader>
<TableRow>
{colunas.map((coluna) => {
const ehPk = selecionada.chavePrimaria.includes(coluna);
const ehPk = colunasPk().includes(coluna);
const ordenadaAqui = ordem?.coluna === coluna;
return (
<TableHead
+13 -2
View File
@@ -11,7 +11,7 @@ interface LinhaCatalogo {
tipo_dado: string;
nulavel: string;
posicao: number;
chave_primaria: string[] | null;
chave_primaria: string[] | string | null;
estimativa: number;
}
@@ -37,13 +37,17 @@ const ROWS: LinhaCatalogo[] = [
{ schema: "public", nome: "pedidos", tipo: "BASE TABLE", coluna: "id", tipo_dado: "uuid", nulavel: "NO", posicao: 1, chave_primaria: ["id"], estimativa: 1234.5 },
{ schema: "public", nome: "pedidos", tipo: "BASE TABLE", coluna: "total", tipo_dado: "numeric", nulavel: "YES", posicao: 2, chave_primaria: ["id"], estimativa: 1234.5 },
{ schema: "vendas", nome: "resumo", tipo: "VIEW", coluna: "mes", tipo_dado: "text", nulavel: "YES", posicao: 1, chave_primaria: null, estimativa: -1 },
// O driver devolvia a PK como o literal cru `"{id}"` (array `name[]` que ele
// não parseia) — o contrato é `string[]` e a tela quebrava ao iterar.
{ schema: "public", nome: "legado", tipo: "BASE TABLE", coluna: "id", tipo_dado: "uuid", nulavel: "NO", posicao: 1, chave_primaria: "{id}", estimativa: 5 },
{ schema: "public", nome: "composta", tipo: "BASE TABLE", coluna: "org", tipo_dado: "uuid", nulavel: "NO", posicao: 1, chave_primaria: "{org,seq}", estimativa: 5 },
];
describe("introspecção ao vivo", () => {
it("agrupa colunas por tabela e mapeia o tipo", async () => {
const { pool } = poolFalso(ROWS);
const tabelas = await listarTabelas(pool);
expect(tabelas).toHaveLength(2);
expect(tabelas).toHaveLength(4);
const pedidos = tabelas.find((t) => t.nome === "pedidos");
expect(pedidos?.schema).toBe("public");
@@ -61,6 +65,13 @@ describe("introspecção ao vivo", () => {
expect(resumo?.estimativaLinhas).toBe(0);
});
it("a PK é SEMPRE string[] — o literal `{...}` do driver também vira array", async () => {
const { pool } = poolFalso(ROWS);
const tabelas = await listarTabelas(pool);
expect(tabelas.find((t) => t.nome === "legado")?.chavePrimaria).toEqual(["id"]);
expect(tabelas.find((t) => t.nome === "composta")?.chavePrimaria).toEqual(["org", "seq"]);
});
it("descreverTabela devolve a tabela pedida e `null` quando não existe", async () => {
const { pool } = poolFalso(ROWS);
await expect(descreverTabela(pool, "public", "pedidos")).resolves.toMatchObject({
+30 -5
View File
@@ -23,7 +23,12 @@ interface LinhaCatalogo {
tipo_dado: string;
nulavel: string;
posicao: number;
chave_primaria: string[] | null;
/**
* A PK chega como `text[]` (OID 1009, que o driver parseia) ou como o literal
* cru `"{id}"` quando o driver não conhece o OID do array de origem (`name[]`
* = 1003). O cast no SQL resolve, e `normalizarPk` blinda contra o literal.
*/
chave_primaria: string[] | string | null;
estimativa: string | number;
}
@@ -45,9 +50,9 @@ const SQL_CATALOGO = `
on n.nspname = c.table_schema
left join pg_catalog.pg_class cl
on cl.relname = c.table_name and cl.relnamespace = n.oid
left join (
select i.indrelid, array_agg(a.attname order by k.ord) as colunas
from pg_catalog.pg_index i
left join (
select i.indrelid, array_agg(a.attname order by k.ord)::text[] as colunas
from pg_catalog.pg_index i
cross join lateral unnest(i.indkey) with ordinality as k(attnum, ord)
join pg_catalog.pg_attribute a
on a.attrelid = i.indrelid and a.attnum = k.attnum
@@ -64,6 +69,26 @@ function tipoDe(t: string): TabelaExterna["tipo"] {
return "outro";
}
/**
* Garante que a PK seja SEMPRE `string[]`.
*
* O driver `pg` parseia `text[]`, mas não todo array do Postgres: quando a
* função devolvia `name[]`, o valor chegava como o literal cru (`"{id}"`) e o
* contrato `TabelaExterna.chavePrimaria: string[]` era violado em silêncio — a
* tela marcava "PK" por acidente (`.includes` funciona em string) e quebrava ao
* iterar. O cast no SQL já resolve na origem; isto é a rede.
*/
function normalizarPk(valor: string[] | string | null | undefined): string[] {
if (Array.isArray(valor)) return valor.map((v) => String(v));
if (typeof valor !== "string") return [];
const interno = valor.trim().replace(/^\{/, "").replace(/\}$/, "").trim();
if (!interno) return [];
return interno
.split(",")
.map((parte) => parte.trim().replace(/^"(.*)"$/, "$1"))
.filter(Boolean);
}
function agrupar(rows: LinhaCatalogo[]): TabelaExterna[] {
const mapa = new Map<string, TabelaExterna>();
for (const r of rows) {
@@ -75,7 +100,7 @@ function agrupar(rows: LinhaCatalogo[]): TabelaExterna[] {
nome: r.nome,
tipo: tipoDe(r.tipo),
colunas: [],
chavePrimaria: r.chave_primaria ?? [],
chavePrimaria: normalizarPk(r.chave_primaria),
estimativaLinhas: Math.max(0, Math.round(Number(r.estimativa) || 0)),
};
mapa.set(chave, tabela);