fix(grid): refine total row counting

This commit is contained in:
t8y2 2026-06-06 11:13:47 +08:00
parent 67e4c5d881
commit fecefb1589
14 changed files with 526 additions and 76 deletions

View File

@ -158,6 +158,7 @@ import {
isToggleTransposeShortcut,
} from "@/lib/keyboardShortcuts";
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGridScrollGutter";
import { canGoNextDataGridPage } from "@/lib/dataGridPagination";
import { CANVAS_DATA_GRID_ROW_HEIGHT, drawCanvasDataGrid } from "@/lib/canvasDataGridRenderer";
import { dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGridSaveUi";
import {
@ -237,6 +238,7 @@ const props = defineProps<{
pageLimit?: number;
countSql?: string;
totalRowCount?: number;
totalRowCountLoading?: boolean;
loading?: boolean;
cacheKey?: string;
onExecuteSql?: (sql: string) => Promise<void>;
@ -1796,12 +1798,31 @@ watch(
},
{ immediate: true },
);
const canGoNextPage = computed(() => props.result.has_more === true || props.result.rows.length >= pageSize.value);
const canJumpLastPage = computed(() => canGoNextPage.value && (!!props.tableMeta || !!props.countSql));
const manualTotalRowCount = ref<number | undefined>(undefined);
const manualTotalRowCountLoading = ref(false);
const showTruncationWarning = computed(
() => props.result.truncated === true && typeof props.pageLimit !== "number" && props.result.has_more !== true,
);
const isResultsContext = computed(() => props.context === "results");
const displayedTotalRowCount = computed(() => props.totalRowCount ?? manualTotalRowCount.value);
const hasKnownTotalRowCount = computed(
() => typeof displayedTotalRowCount.value === "number" && displayedTotalRowCount.value >= 0,
);
const canGoNextPage = computed(() => {
return canGoNextDataGridPage({
hasMore: props.result.has_more,
rowCount: props.result.rows.length,
pageSize: pageSize.value,
pageOffset: props.pageOffset,
currentPage: currentPage.value,
totalRowCount: hasKnownTotalRowCount.value ? displayedTotalRowCount.value : undefined,
});
});
const canJumpLastPage = computed(() => canGoNextPage.value && (!!props.tableMeta || !!props.countSql));
const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value);
const canCalculateTotalRowCount = computed(
() => !isResultsContext.value && !!props.connectionId && (!!props.tableMeta || !!props.countSql),
);
const showQueryEditReadyBadge = computed(
() => isResultsContext.value && hasData.value && !!props.editable && !!props.tableMeta,
);
@ -1859,6 +1880,21 @@ function currentOrderBy(): string | undefined {
);
}
watch(
() => [
props.countSql ?? "",
props.tableMeta?.schema ?? "",
props.tableMeta?.tableName ?? "",
currentWhereInput() ?? "",
props.database ?? "",
props.connectionId ?? "",
props.result,
],
() => {
manualTotalRowCount.value = undefined;
},
);
function syncOrderByInputWithSort(column: string | null, direction: "asc" | "desc" | null) {
const nextOrderByInput = column && direction ? `${queryColumnRef(column)} ${direction.toUpperCase()}` : "";
orderByInput.value = nextOrderByInput;
@ -1915,20 +1951,11 @@ function applyCustomPageSize() {
async function lastPage() {
if (!props.connectionId) return;
let sql = props.countSql;
let schema = props.schema;
if (props.tableMeta) {
sql = await buildDataGridCountSql({
databaseType: props.databaseType,
schema: props.tableMeta.schema,
tableName: props.tableMeta.tableName,
whereInput: currentWhereInput(),
});
schema = props.tableMeta.schema;
}
const countTarget = await buildCurrentCountTarget();
const sql = countTarget?.sql;
if (!sql) return;
try {
const result = await api.executeQuery(props.connectionId, props.database ?? "", sql, schema);
const result = await api.executeQuery(props.connectionId, props.database ?? "", sql, countTarget.schema);
const total = Number(result.rows?.[0]?.[0] ?? 0);
if (total <= 0) return;
const lastPageNum = Math.ceil(total / pageSize.value);
@ -1941,6 +1968,43 @@ async function lastPage() {
}
}
async function buildCurrentCountTarget(): Promise<{ sql: string; schema?: string } | undefined> {
if (props.countSql) return { sql: props.countSql, schema: props.schema };
if (props.tableMeta) {
const sql = await buildDataGridCountSql({
databaseType: props.databaseType,
schema: props.tableMeta.schema,
tableName: props.tableMeta.tableName,
whereInput: currentWhereInput(),
});
return { sql, schema: props.context === "table-data" ? undefined : (props.tableMeta.schema ?? props.schema) };
}
return undefined;
}
async function calculateTotalRowCount() {
if (!props.connectionId || manualTotalRowCountLoading.value) return;
manualTotalRowCountLoading.value = true;
try {
const countTarget = await buildCurrentCountTarget();
if (!countTarget?.sql) return;
const result = await api.executeQuery(
props.connectionId,
props.database ?? "",
countTarget.sql,
countTarget.schema,
);
const total = Number(result.rows?.[0]?.[0] ?? 0);
if (Number.isFinite(total) && total >= 0) {
manualTotalRowCount.value = total;
}
} catch (e: any) {
toast(t("grid.calculateTotalRowsFailed", { message: e?.message || String(e) }), 5000);
} finally {
manualTotalRowCountLoading.value = false;
}
}
// --- Editing (composable) ---
interface RowItem {
@ -7649,9 +7713,23 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<span v-if="hasData" class="shrink-0">
{{ t("grid.totalRows", { count: result.rows.length }) }}
<span v-if="typeof totalRowCount === 'number' && totalRowCount > 0" class="text-muted-foreground/70">{{
t("grid.totalRowCount", { count: totalRowCount })
}}</span>
<span
v-if="typeof displayedTotalRowCount === 'number' && displayedTotalRowCount >= 0"
class="text-muted-foreground/70"
>{{ t("grid.totalRowCount", { count: displayedTotalRowCount }) }}</span
>
<span v-else-if="totalRowCountBusy" class="text-muted-foreground/70">
{{ t("grid.totalRowCountLoading") }}
</span>
<button
v-else-if="canCalculateTotalRowCount"
type="button"
class="text-muted-foreground/70 hover:text-foreground hover:underline underline-offset-2 disabled:pointer-events-none"
:disabled="manualTotalRowCountLoading"
@click="calculateTotalRowCount"
>
{{ t("grid.calculateTotalRowsInline") }}
</button>
</span>
<span v-if="showTruncationWarning" class="shrink-0 text-amber-500 text-xs">(truncated)</span>
<span v-if="!hasData" class="shrink-0">{{ t("grid.rowsAffected", { count: result.affected_rows }) }}</span>

View File

@ -566,6 +566,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
:page-limit="activeTab.resultPageLimit"
:count-sql="activeTab.resultCountSql"
:total-row-count="activeTab.resultTotalRowCount"
:total-row-count-loading="activeTab.resultTotalRowCountLoading"
:on-execute-sql="async (sql: string) => emit('executeSql', sql)"
:full-export-result="() => queryStore.fetchTabResultForExport(activeTab.id)"
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"

View File

@ -88,6 +88,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
resultBaseSql: tab.resultBaseSql ?? tab.sql,
resultSortedSql: tab.resultSortedSql,
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
});
return;
}
@ -118,6 +119,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
resultSortedSql: tab.resultSortedSql,
pagination: { offset, limit, sessionId },
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
});
return;
}
@ -161,6 +163,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
resultBaseSql: baseSql,
resultSortedSql: undefined,
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
});
return;
}
@ -183,6 +186,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
resultBaseSql: baseSql,
resultSortedSql: built.sql,
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
});
}

View File

@ -403,6 +403,10 @@ export default {
rows: "{count} rows",
totalRows: "Total {count} rows",
totalRowCount: "({count} total)",
totalRowCountLoading: "(counting...)",
calculateTotalRows: "Count total rows",
calculateTotalRowsInline: "(count total rows)",
calculateTotalRowsFailed: "Count failed: {message}",
rowsAffected: "{count} rows affected",
querySuccess: "Query executed successfully",
noRows: "No data",

View File

@ -396,6 +396,10 @@ export default {
rows: "{count} filas",
totalRows: "Total {count} filas",
totalRowCount: "({count} en total)",
totalRowCountLoading: "(contando...)",
calculateTotalRows: "Contar filas totales",
calculateTotalRowsInline: "(contar filas totales)",
calculateTotalRowsFailed: "Error al contar: {message}",
rowsAffected: "{count} filas afectadas",
querySuccess: "Consulta ejecutada exitosamente",
noRows: "Sin datos",

View File

@ -408,6 +408,10 @@ export default {
rows: "{count} righe",
totalRows: "Totale {count} righe",
totalRowCount: "({count} in totale)",
totalRowCountLoading: "(conteggio...)",
calculateTotalRows: "Conta righe totali",
calculateTotalRowsInline: "(conta righe totali)",
calculateTotalRowsFailed: "Conteggio non riuscito: {message}",
rowsAffected: "{count} righe interessate",
querySuccess: "Query eseguita con successo",
noRows: "Nessun dato",

View File

@ -148,11 +148,13 @@ export default {
mysqlTlsModeRequired: "Obrigatório",
mysqlTlsModeVerifyCa: "Verificar CA",
mysqlTlsModeVerifyIdentity: "Verificar Identidade",
mysqlCaCertHint: "Necessário para Verificar CA e Verificar Identidade quando o certificado do servidor usa uma CA privada.",
mysqlCaCertHint:
"Necessário para Verificar CA e Verificar Identidade quando o certificado do servidor usa uma CA privada.",
mysqlClientCert: "Autenticação do Cliente",
mysqlClientCertPlaceholder: "/caminho/para/client.crt",
mysqlClientKeyPlaceholder: "/caminho/para/client.key",
mysqlClientCertHint: "O certificado e a chave privada do cliente devem ser fornecidos juntos quando o MySQL exige mTLS.",
mysqlClientCertHint:
"O certificado e a chave privada do cliente devem ser fornecidos juntos quando o MySQL exige mTLS.",
mysqlClientCertBrowse: "Escolher certificado do cliente",
mysqlClientKeyBrowse: "Escolher chave privada do cliente",
postgresSslMode: "Modo TLS",
@ -269,13 +271,15 @@ export default {
jdbcUrl: "URL JDBC",
jdbcUrlPlaceholder: "jdbc:postgresql://localhost:5432/database",
jdbcDriverClass: "Classe do Driver (opcional)",
jdbcDriverClassPlaceholder: "A maioria dos drivers se registra automaticamente; use com.vendor.jdbc.Driver se necessário",
jdbcDriverClassPlaceholder:
"A maioria dos drivers se registra automaticamente; use com.vendor.jdbc.Driver se necessário",
jdbcDriverPaths: "JARs do Driver",
jdbcDriverSelectPlaceholder: "Escolher driver importado",
jdbcDriverPathsPlaceholder: "/caminho/para/driver.jar\n/caminho/para/outro-driver.jar",
jdbcDriverBrowse: "Escolher JAR do driver JDBC",
jdbcDocs: "Ver documentação JDBC",
jdbcPluginHint: "Instale primeiro o plugin JDBC do DBX e depois importe o JAR do driver JDBC do fornecedor do banco de dados.",
jdbcPluginHint:
"Instale primeiro o plugin JDBC do DBX e depois importe o JAR do driver JDBC do fornecedor do banco de dados.",
dmCompatHint: "Requer o driver ODBC DM8 instalado no seu sistema.",
dmDownload: "Baixar da Dameng",
mongoLegacyHint:
@ -403,6 +407,10 @@ export default {
rows: "{count} linhas",
totalRows: "Total de {count} linhas",
totalRowCount: "({count} no total)",
totalRowCountLoading: "(contando...)",
calculateTotalRows: "Contar total de linhas",
calculateTotalRowsInline: "(contar total de linhas)",
calculateTotalRowsFailed: "Falha ao contar: {message}",
rowsAffected: "{count} linhas afetadas",
querySuccess: "Consulta executada com sucesso",
noRows: "Sem dados",
@ -643,21 +651,27 @@ export default {
queryEditUnsupported: {
"not-select": "Apenas resultados de consultas SELECT podem ser editados diretamente.",
cte: "Consultas com WITH/CTE ainda não são editáveis. Use um SELECT simples de tabela única.",
"set-operation": "Resultados de UNION, INTERSECT ou EXCEPT não podem ser mapeados com segurança de volta às linhas de origem.",
"set-operation":
"Resultados de UNION, INTERSECT ou EXCEPT não podem ser mapeados com segurança de volta às linhas de origem.",
aggregation: "Resultados de DISTINCT, GROUP BY, HAVING e agregações não podem ser editados diretamente.",
"external-source":
"Arquivos externos e resultados de funções de tabela não podem ser gravados de volta diretamente. Importe-os para uma tabela do banco de dados antes de editar.",
"complex-source": "JOINs, múltiplas tabelas e subconsultas não podem ser mapeados com segurança para uma única linha de origem.",
"complex-source":
"JOINs, múltiplas tabelas e subconsultas não podem ser mapeados com segurança para uma única linha de origem.",
"computed-columns":
"Expressões computadas ou resultados de funções não podem ser gravados de volta. Selecione os nomes brutos das colunas em vez disso.",
"no-table": "Nenhuma tabela de origem editável foi detectada.",
"no-primary-key": "A tabela de destino não possui chave primária, portanto as linhas não podem ser atualizadas ou excluídas com segurança.",
"no-primary-key":
"A tabela de destino não possui chave primária, portanto as linhas não podem ser atualizadas ou excluídas com segurança.",
"primary-key-not-returned":
"O resultado está sem a coluna bruta da chave primária. Inclua-a pelo seu nome de coluna original.",
"aliased-columns": "As colunas do resultado usam aliases ou expressões. Selecione colunas editáveis pelos seus nomes originais.",
"metadata-unavailable": "O DBX não conseguiu carregar os metadados da tabela, portanto a edição do resultado está desabilitada.",
"aliased-columns":
"As colunas do resultado usam aliases ou expressões. Selecione colunas editáveis pelos seus nomes originais.",
"metadata-unavailable":
"O DBX não conseguiu carregar os metadados da tabela, portanto a edição do resultado está desabilitada.",
},
sortUnsupported: "Este SQL não suporta a ordenação do resultado completo. Tente novamente com uma única consulta SELECT.",
sortUnsupported:
"Este SQL não suporta a ordenação do resultado completo. Tente novamente com uma única consulta SELECT.",
truncatedHint: "Resultados truncados em {count} linhas. Use a paginação no rodapé ou ajuste as linhas por página.",
},
exportProgress: {
@ -746,14 +760,18 @@ export default {
sameName: "Mesmo Nome",
},
description: {
foreignKeyIncoming: "{target} aponta para o campo atual através de uma chave estrangeira. Esta é uma dependência verificada.",
foreignKeyOutgoing: "O campo atual referencia {target} através de uma chave estrangeira. Esta é uma dependência verificada.",
viewLikely: "A definição da visão menciona tanto a tabela quanto o campo de destino, geralmente indicando dependência de consulta.",
foreignKeyIncoming:
"{target} aponta para o campo atual através de uma chave estrangeira. Esta é uma dependência verificada.",
foreignKeyOutgoing:
"O campo atual referencia {target} através de uma chave estrangeira. Esta é uma dependência verificada.",
viewLikely:
"A definição da visão menciona tanto a tabela quanto o campo de destino, geralmente indicando dependência de consulta.",
viewPossible:
"A definição da visão menciona um campo de mesmo nome, mas não a tabela de destino, então precisa de confirmação.",
historyLikely:
"Uma instrução SQL histórica menciona tanto a tabela quanto o campo de destino. Use-a como contexto de análise de impacto.",
historyPossible: "Uma instrução SQL histórica menciona um campo de mesmo nome. Pode estar relacionado, mas precisa de contexto.",
historyPossible:
"Uma instrução SQL histórica menciona um campo de mesmo nome. Pode estar relacionado, mas precisa de contexto.",
sameName:
"Outra tabela possui um campo de mesmo nome. Isso pode compartilhar significado de negócio, mas não é uma dependência verificada do banco de dados.",
},
@ -873,7 +891,8 @@ export default {
fixWithAi: "Corrigir com AI",
truncated: "Contexto truncado",
contextSummary: "{database} · {tables} tabelas",
autoSqlBlocked: "O SQL gerado pela AI pareceu arriscado demais para execução automática. Revise-o manualmente antes de executar.",
autoSqlBlocked:
"O SQL gerado pela AI pareceu arriscado demais para execução automática. Revise-o manualmente antes de executar.",
agentSteps: {
generated: "SQL gerado",
noSql: "Nenhum SQL encontrado",
@ -970,7 +989,8 @@ export default {
structureDocYes: "Sim",
structureDocNo: "Não",
structureDocCopied: "Estrutura copiada",
structureDocCopyFallbackHint: "A cópia automática foi bloqueada. Clique em Copiar novamente ou selecione o conteúdo abaixo.",
structureDocCopyFallbackHint:
"A cópia automática foi bloqueada. Clique em Copiar novamente ou selecione o conteúdo abaixo.",
saveStructure: "Salvar SQL",
exportStructureCopied: "Estrutura copiada",
createTable: "Criar Tabela",
@ -1003,7 +1023,8 @@ export default {
batchDrop: "Remover selecionados ({count})",
executeProcedure: "Executar Procedimento",
confirmExecuteProcedureTitle: "Executar Procedimento",
confirmExecuteProcedureMessage: 'Executar o procedimento "{name}"? Você pode preencher ou ajustar os valores dos parâmetros primeiro.',
confirmExecuteProcedureMessage:
'Executar o procedimento "{name}"? Você pode preencher ou ajustar os valores dos parâmetros primeiro.',
loadingProcedureParameters: "Carregando parâmetros...",
procedureParametersUnavailable:
"Não foi possível carregar os metadados dos parâmetros. Você ainda pode editar o SQL abaixo e executá-lo.",
@ -1035,7 +1056,8 @@ export default {
confirmDropTriggerTitle: "Remover Gatilho",
confirmDropTableChildObjectMessage: 'Tem certeza de que deseja remover "{name}" de "{table}"?',
confirmBatchDropTitle: "Remover Objetos Selecionados",
confirmBatchDropMessage: "Tem certeza de que deseja remover {count} objetos selecionados? Esta ação não pode ser desfeita.",
confirmBatchDropMessage:
"Tem certeza de que deseja remover {count} objetos selecionados? Esta ação não pode ser desfeita.",
confirmDropProcedureTitle: "Remover Procedimento",
confirmDropProcedureMessage: 'Tem certeza de que deseja remover o procedimento "{name}"?',
confirmDropFunctionTitle: "Remover Função",
@ -1059,7 +1081,8 @@ export default {
'Tem certeza de que deseja remover o banco de dados "{name}"? Isso excluirá permanentemente o banco de dados e todos os seus dados.',
createDatabaseSuccess: 'Banco de dados "{name}" criado',
createDuckDbFileSuccess: 'Arquivo de banco de dados DuckDB "{name}" criado e anexado',
createDuckDbFileDesktopOnly: "A criação de arquivos de banco de dados DuckDB só está disponível no aplicativo desktop",
createDuckDbFileDesktopOnly:
"A criação de arquivos de banco de dados DuckDB só está disponível no aplicativo desktop",
dropDatabaseSuccess: 'Banco de dados "{name}" removido',
createDatabaseNamePlaceholder: "Nome do banco de dados",
createDatabaseCharset: "Conjunto de caracteres",
@ -1137,7 +1160,8 @@ export default {
dropSelected: "Remover selecionados",
clearSelection: "Limpar seleção",
confirmBatchDropTitle: "Remover tabelas selecionadas",
confirmBatchDropMessage: "Tem certeza de que deseja remover {count} tabelas selecionadas? Esta ação não pode ser desfeita.",
confirmBatchDropMessage:
"Tem certeza de que deseja remover {count} tabelas selecionadas? Esta ação não pode ser desfeita.",
batchDropSuccess: "{count} tabelas removidas",
},
structureEditor: {
@ -1389,7 +1413,8 @@ export default {
},
dangerDialog: {
title: "Operação perigosa",
message: "Esta instrução SQL pode modificar ou excluir dados de forma irreversível. Tem certeza de que deseja executá-la?",
message:
"Esta instrução SQL pode modificar ou excluir dados de forma irreversível. Tem certeza de que deseja executá-la?",
suppressFuturePrompts: "Não perguntar novamente para SQL perigoso",
deleteMessage: "Esta operação de exclusão pode ser irreversível. Continuar?",
deleteConfirm: "Confirmar exclusão",
@ -1537,7 +1562,8 @@ export default {
selectedTables: "{selected}/{total} selecionadas",
selectAllTables: "Selecionar tudo",
deselectAllTables: "Desmarcar tudo",
selectSourceTables: "Selecione primeiro a conexão e o banco de dados de origem, depois escolha as tabelas para comparar",
selectSourceTables:
"Selecione primeiro a conexão e o banco de dados de origem, depois escolha as tabelas para comparar",
noTables: "Nenhuma tabela disponível para comparação",
autoMatchHint: "O modo em lote corresponde automaticamente as tabelas de destino pelo mesmo nome",
matchedTables: "{matched}/{total} tabelas correspondidas",
@ -1620,7 +1646,8 @@ export default {
"Quando desativado, nenhum ícone é exibido, mas fechar a janela ainda oculta o DBX em segundo plano como antes.",
dataGridDisplay: "Exibição da grade de dados",
showColumnCommentsInHeader: "Mostrar comentários de coluna sob os nomes",
showColumnCommentsInHeaderDescription: "Exibir comentários de colunas da tabela diretamente abaixo dos nomes das colunas da grade.",
showColumnCommentsInHeaderDescription:
"Exibir comentários de colunas da tabela diretamente abaixo dos nomes das colunas da grade.",
compactColumnHeaderActions: "Ferramentas compactas no cabeçalho da coluna",
compactColumnHeaderActionsDescription:
"Mover as ferramentas de formatação e filtro local para um menu de mais opções para que os nomes das colunas tenham prioridade.",
@ -1675,7 +1702,8 @@ export default {
snippetsAddTitle: "Adicionar snippet",
snippetsEditTitle: "Editar snippet",
syncWebDavTitle: "Sincronização WebDAV",
syncWebDavDescription: "Envie ou restaure um snapshot do DBX a partir de um serviço de armazenamento compatível com WebDAV.",
syncWebDavDescription:
"Envie ou restaure um snapshot do DBX a partir de um serviço de armazenamento compatível com WebDAV.",
syncEndpoint: "URL do WebDAV",
syncUsername: "Nome de usuário",
syncPassword: "Senha",
@ -1703,7 +1731,8 @@ export default {
syncDownloadSuccess: "Baixados e aplicados {bytes} bytes de {path}.",
syncSecretsApplied: "Os segredos criptografados foram restaurados.",
syncSecretsSkipped: "Havia segredos criptografados, mas não foram restaurados.",
syncDownloadConfirm: "Baixar e aplicar o snapshot remoto do DBX? Os metadados locais e os SQL salvos serão substituídos.",
syncDownloadConfirm:
"Baixar e aplicar o snapshot remoto do DBX? Os metadados locais e os SQL salvos serão substituídos.",
apply: "Aplicar",
applyAndClose: "Aplicar e fechar",
reset: "Redefinir",
@ -1764,7 +1793,8 @@ export default {
jdbcDeleteSuccess: "Driver removido",
jdbcNoDrivers: "Nenhum driver JDBC importado ainda.",
mcpTitle: "Servidor MCP",
mcpDescription: "Verifique a instalação e a versão do Servidor MCP do DBX usado pelo Claude Code, Cursor e outros agentes.",
mcpDescription:
"Verifique a instalação e a versão do Servidor MCP do DBX usado pelo Claude Code, Cursor e outros agentes.",
mcpChecking: "Verificando",
mcpStatusUnknown: "Não verificado",
mcpStatusError: "Falha na verificação",
@ -1784,12 +1814,15 @@ export default {
mcpCodexConfig: "Configuração do Codex",
mcpCodexConfigPath: "O Codex pode usar ~/.codex/config.toml ou um .codex/config.toml no nível do projeto.",
mcpReadonlyMode: "Modo somente leitura",
mcpReadonlyModeDescription: "Adiciona DBX_MCP_ALLOW_WRITES=0 à configuração de exemplo para que a sessão MCP permaneça apenas para consultas.",
mcpReadonlyModeDescription:
"Adiciona DBX_MCP_ALLOW_WRITES=0 à configuração de exemplo para que a sessão MCP permaneça apenas para consultas.",
mcpAllowDangerous: "Permitir SQL perigoso",
mcpAllowDangerousDescription:
"Adiciona DBX_MCP_ALLOW_DANGEROUS_SQL=1 à configuração de exemplo para que instruções como DROP, TRUNCATE, ALTER e similares sejam permitidas.",
mcpDetectionTiming: "O DBX verifica automaticamente quando esta página é aberta; use Verificar novamente para atualizar.",
mcpNpmBoundary: "O DBX apenas verifica e explica o status do MCP; a instalação e as atualizações ainda são feitas pelo npm.",
mcpDetectionTiming:
"O DBX verifica automaticamente quando esta página é aberta; use Verificar novamente para atualizar.",
mcpNpmBoundary:
"O DBX apenas verifica e explica o status do MCP; a instalação e as atualizações ainda são feitas pelo npm.",
mcpRefresh: "Verificar novamente",
mcpGuide: "Guia do MCP",
aboutDescription: "Uma ferramenta de gerenciamento de banco de dados leve e de código aberto.",

View File

@ -399,6 +399,10 @@ export default {
rows: "{count} 行",
totalRows: "共 {count} 行",
totalRowCount: "(总计 {count} 行)",
totalRowCountLoading: "(统计中...",
calculateTotalRows: "统计总行数",
calculateTotalRowsInline: "(统计总行数)",
calculateTotalRowsFailed: "统计失败:{message}",
rowsAffected: "影响 {count} 行",
querySuccess: "查询执行成功",
noRows: "暂无数据",

View File

@ -399,6 +399,10 @@ export default {
rows: "{count} 列",
totalRows: "共 {count} 筆",
totalRowCount: "(總計 {count} 筆)",
totalRowCountLoading: "(統計中...",
calculateTotalRows: "統計總筆數",
calculateTotalRowsInline: "(統計總筆數)",
calculateTotalRowsFailed: "統計失敗:{message}",
rowsAffected: "影響 {count} 筆",
querySuccess: "查詢執行成功",
noRows: "暫無資料",

View File

@ -0,0 +1,24 @@
export interface CanGoNextDataGridPageOptions {
hasMore?: boolean;
rowCount: number;
pageSize: number;
pageOffset?: number;
currentPage?: number;
totalRowCount?: number;
}
export function canGoNextDataGridPage(options: CanGoNextDataGridPageOptions): boolean {
if (options.hasMore === true) return true;
const pageSize = Math.max(1, options.pageSize);
const totalRowCount = options.totalRowCount;
if (typeof totalRowCount === "number" && Number.isFinite(totalRowCount) && totalRowCount >= 0) {
const currentOffset =
typeof options.pageOffset === "number" && options.pageOffset >= 0
? options.pageOffset
: Math.max(0, (options.currentPage ?? 1) - 1) * pageSize;
return currentOffset + pageSize < totalRowCount;
}
return options.rowCount >= pageSize;
}

View File

@ -797,6 +797,83 @@ export const useQueryStore = defineStore("query", () => {
})();
}
function setQueryTotalRowCountIfCurrent(
tabId: string,
executionId: string,
result: QueryResult,
totalRowCount: number | undefined,
) {
const current = tabs.value.find((t) => t.id === tabId);
if (current?.mode !== "query") return;
if (current.executionId !== executionId && current.result !== result) return;
current.resultTotalRowCount = totalRowCount;
current.resultTotalRowCountLoading = false;
}
function countQueryTotalRowsInBackground(options: {
tabId: string;
connectionId: string;
database: string;
schema?: string;
countSql?: string;
result: QueryResult;
pageLimit?: number;
pageOffset?: number;
executionId: string;
traceId: string;
elapsed: () => string;
timeoutSecs: number;
}) {
const resultRowCount = options.result.rows.length;
if (!options.countSql || resultRowCount <= 0) {
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, undefined);
return;
}
const countSql = options.countSql;
if (typeof options.pageLimit === "number" && resultRowCount < options.pageLimit) {
setQueryTotalRowCountIfCurrent(
options.tabId,
options.executionId,
options.result,
(options.pageOffset ?? 0) + resultRowCount,
);
return;
}
void (async () => {
try {
console.info("[DBX][executeTabSql:count:start]", { traceId: options.traceId, elapsed: options.elapsed() });
const countResult = await api.executeQuery(
options.connectionId,
options.database,
countSql,
options.schema,
undefined,
{ timeoutSecs: options.timeoutSecs },
);
const total = Number(countResult.rows?.[0]?.[0] ?? 0);
if (!Number.isFinite(total) || total < 0) {
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, undefined);
return;
}
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, total);
console.info("[DBX][executeTabSql:count:done]", {
traceId: options.traceId,
total,
elapsed: options.elapsed(),
});
} catch (error) {
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, undefined);
console.warn("[DBX][executeTabSql:count:error]", {
traceId: options.traceId,
elapsed: options.elapsed(),
error,
});
}
})();
}
async function executeTabSql(
id: string,
sql: string,
@ -806,6 +883,7 @@ export const useQueryStore = defineStore("query", () => {
pagination?: { limit: number; offset: number; sessionId?: string };
mongoSafety?: MongoAggregateSafetyOptions;
preserveResultDuringExecution?: boolean;
preserveTotalRowCountDuringExecution?: boolean;
},
) {
const tab = tabs.value.find((t) => t.id === id);
@ -819,7 +897,10 @@ export const useQueryStore = defineStore("query", () => {
tab.isCancelling = false;
tab.executionId = executionId;
tab.lastExecutedSql = sql;
tab.resultTotalRowCount = undefined;
if (!options?.preserveTotalRowCountDuringExecution) {
tab.resultTotalRowCount = undefined;
}
tab.resultTotalRowCountLoading = false;
const previousResultSessionClose = closeResultSession(tab, options?.pagination?.sessionId);
if (!options?.preserveResultDuringExecution || !tab.result) {
clearResultPayload(tab);
@ -1095,7 +1176,27 @@ export const useQueryStore = defineStore("query", () => {
current.resultPageOffset = pageOffset;
current.resultCountSql = countSql;
current.resultSessionId = current.result?.session_id ?? undefined;
if (!options?.preserveTotalRowCountDuringExecution) {
current.resultTotalRowCount = undefined;
}
current.resultTotalRowCountLoading = current.mode === "query" && !!current.result && !!countSql;
touchResult(current);
if (current.mode === "query" && current.result) {
countQueryTotalRowsInBackground({
tabId: id,
connectionId: current.connectionId,
database: current.database,
schema: current.schema,
countSql,
result: current.result,
pageLimit,
pageOffset,
executionId,
traceId,
elapsed,
timeoutSecs: queryTimeoutSecs,
});
}
console.info("[DBX][executeTabSql:result:assigned]", {
traceId,
activeResultIndex: current.activeResultIndex,
@ -1104,35 +1205,6 @@ export const useQueryStore = defineStore("query", () => {
backendMs: current.result?.execution_time_ms,
elapsed: elapsed(),
});
if (countSql && current.result?.rows.length) {
// When the result set is smaller than the page size we already have
// all rows — compute the total directly instead of running COUNT(*).
const resultRowCount = current.result.rows.length;
if (pageLimit !== undefined && resultRowCount < pageLimit) {
current.resultTotalRowCount = (pageOffset ?? 0) + resultRowCount;
} else {
const capturedExecutionId = executionId;
const capturedTabId = id;
const capturedCountSql = countSql;
const capturedConnectionId = tab.connectionId;
const capturedDatabase = tab.database;
const capturedSchema = tab.schema;
api
.executeQuery(capturedConnectionId, capturedDatabase ?? "", capturedCountSql, capturedSchema)
.then((countResult) => {
const tabAfterCount = tabs.value.find((t) => t.id === capturedTabId);
if (tabAfterCount?.executionId === capturedExecutionId) {
const total = Number(countResult.rows?.[0]?.[0] ?? 0);
if (total > 0) {
tabAfterCount.resultTotalRowCount = total;
}
}
})
.catch(() => {
// COUNT query failed — silently ignore
});
}
}
if (current.mode === "query" && current.result)
analyzeQueryMetadataInBackground(id, queryBaseSql, current.result, traceId, elapsed);
} else {
@ -1160,6 +1232,8 @@ export const useQueryStore = defineStore("query", () => {
current.resultPageOffset = pageOffset;
current.resultCountSql = countSql;
current.resultSessionId = undefined;
current.resultTotalRowCount = undefined;
current.resultTotalRowCountLoading = false;
touchResult(current);
}
} finally {

View File

@ -360,6 +360,7 @@ export interface QueryTab {
resultPageOffset?: number;
resultCountSql?: string;
resultTotalRowCount?: number;
resultTotalRowCountLoading?: boolean;
resultSessionId?: string;
resultAccessedAt?: number;
pinned?: boolean;

View File

@ -0,0 +1,45 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { canGoNextDataGridPage } from "../../apps/desktop/src/lib/dataGridPagination.ts";
test("known total disables next page at the last exact page", () => {
assert.equal(
canGoNextDataGridPage({
rowCount: 1,
pageSize: 1,
pageOffset: 8,
totalRowCount: 9,
}),
false,
);
});
test("known total allows next page before the last page", () => {
assert.equal(
canGoNextDataGridPage({
rowCount: 1,
pageSize: 1,
pageOffset: 7,
totalRowCount: 9,
}),
true,
);
});
test("backend hasMore takes precedence over a stale known total", () => {
assert.equal(
canGoNextDataGridPage({
hasMore: true,
rowCount: 1,
pageSize: 1,
pageOffset: 8,
totalRowCount: 9,
}),
true,
);
});
test("unknown total falls back to full-page heuristic", () => {
assert.equal(canGoNextDataGridPage({ rowCount: 1, pageSize: 1 }), true);
assert.equal(canGoNextDataGridPage({ rowCount: 0, pageSize: 1 }), false);
});

View File

@ -1167,6 +1167,176 @@ test("query execution is scoped to the tab client session", async () => {
}
});
test("query execution keeps automatically counting total rows in the background", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
connectionStore.addEphemeralConnection(conn("conn-1"));
const tabId = store.createTab("conn-1", "db", "Query", "query", "public");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
let resolveCount: ((value: Response) => void) | undefined;
let countBody: any;
globalThis.fetch = (async (input, init) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
return new Response(
JSON.stringify({
sqlToExecute: "select id from users limit 100",
pageSql: "select id from users limit 100",
pageLimit: 100,
pageOffset: 0,
countSql: "select count(*) from users",
useAgentResultSession: false,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === "/api/query/execute-multi") {
return new Response(
JSON.stringify([
{
columns: ["id"],
rows: Array.from({ length: 100 }, (_, index) => [index + 1]),
affected_rows: 0,
execution_time_ms: 1,
},
]),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === "/api/query/execute") {
countBody = JSON.parse(String(init?.body ?? "{}"));
return new Promise<Response>((resolve) => {
resolveCount = resolve;
});
}
if (url === "/api/query/analyze-editability") {
return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
try {
await store.executeTabSql(tabId, "select id from users");
assert.equal(tab.executionId, undefined);
assert.equal(tab.resultTotalRowCount, undefined);
assert.equal(tab.resultTotalRowCountLoading, true);
assert.equal(countBody.sql, "select count(*) from users");
assert.equal(countBody.schema, "public");
resolveCount?.(
new Response(
JSON.stringify({
columns: ["count"],
rows: [[250]],
affected_rows: 0,
execution_time_ms: 1,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
await waitFor(() => tab.resultTotalRowCount === 250);
assert.equal(tab.resultTotalRowCountLoading, false);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("paginated query execution keeps the previous total while refreshing it in the background", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
connectionStore.addEphemeralConnection(conn("conn-1"));
const tabId = store.createTab("conn-1", "db", "Query", "query", "public");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
tab.resultTotalRowCount = 250;
let resolveCount: ((value: Response) => void) | undefined;
globalThis.fetch = (async (input) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
return new Response(
JSON.stringify({
sqlToExecute: "select id from users limit 100 offset 100",
pageSql: "select id from users limit 100 offset 100",
pageLimit: 100,
pageOffset: 100,
countSql: "select count(*) from users",
useAgentResultSession: false,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === "/api/query/execute-multi") {
return new Response(
JSON.stringify([
{
columns: ["id"],
rows: Array.from({ length: 100 }, (_, index) => [index + 101]),
affected_rows: 0,
execution_time_ms: 1,
},
]),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === "/api/query/execute") {
return new Promise<Response>((resolve) => {
resolveCount = resolve;
});
}
if (url === "/api/query/analyze-editability") {
return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
try {
await store.executeTabSql(tabId, "select id from users", {
pagination: { limit: 100, offset: 100 },
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
});
assert.equal(tab.resultTotalRowCount, 250);
assert.equal(tab.resultTotalRowCountLoading, true);
resolveCount?.(
new Response(
JSON.stringify({
columns: ["count"],
rows: [[275]],
affected_rows: 0,
execution_time_ms: 1,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
await waitFor(() => tab.resultTotalRowCount === 275);
assert.equal(tab.resultTotalRowCountLoading, false);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("multi statement execution shows the first result set by default", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());