diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 264e59e2f..2e5b94fb5 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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; @@ -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(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(() => {
{{ t("grid.totalRows", { count: result.rows.length }) }} - {{ - t("grid.totalRowCount", { count: totalRowCount }) - }} + {{ t("grid.totalRowCount", { count: displayedTotalRowCount }) }} + + {{ t("grid.totalRowCountLoading") }} + + (truncated) {{ t("grid.rowsAffected", { count: result.affected_rows }) }} diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index f0c206a41..c0ac8fe65 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -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)" diff --git a/apps/desktop/src/composables/useDataGridActions.ts b/apps/desktop/src/composables/useDataGridActions.ts index dd834ad47..1d93ac43d 100644 --- a/apps/desktop/src/composables/useDataGridActions.ts +++ b/apps/desktop/src/composables/useDataGridActions.ts @@ -88,6 +88,7 @@ export function useDataGridActions(activeTab: ComputedRef) resultBaseSql: tab.resultBaseSql ?? tab.sql, resultSortedSql: tab.resultSortedSql, preserveResultDuringExecution: true, + preserveTotalRowCountDuringExecution: true, }); return; } @@ -118,6 +119,7 @@ export function useDataGridActions(activeTab: ComputedRef) resultSortedSql: tab.resultSortedSql, pagination: { offset, limit, sessionId }, preserveResultDuringExecution: true, + preserveTotalRowCountDuringExecution: true, }); return; } @@ -161,6 +163,7 @@ export function useDataGridActions(activeTab: ComputedRef) resultBaseSql: baseSql, resultSortedSql: undefined, preserveResultDuringExecution: true, + preserveTotalRowCountDuringExecution: true, }); return; } @@ -183,6 +186,7 @@ export function useDataGridActions(activeTab: ComputedRef) resultBaseSql: baseSql, resultSortedSql: built.sql, preserveResultDuringExecution: true, + preserveTotalRowCountDuringExecution: true, }); } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 2d0342b9f..a8062513a 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 37e823595..f4811e163 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 414ad54a1..582194230 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 7749787da..e5051252e 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 7392618f9..76bd49744 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -399,6 +399,10 @@ export default { rows: "{count} 行", totalRows: "共 {count} 行", totalRowCount: "(总计 {count} 行)", + totalRowCountLoading: "(统计中...)", + calculateTotalRows: "统计总行数", + calculateTotalRowsInline: "(统计总行数)", + calculateTotalRowsFailed: "统计失败:{message}", rowsAffected: "影响 {count} 行", querySuccess: "查询执行成功", noRows: "暂无数据", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index f9442984a..3c998c0bb 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -399,6 +399,10 @@ export default { rows: "{count} 列", totalRows: "共 {count} 筆", totalRowCount: "(總計 {count} 筆)", + totalRowCountLoading: "(統計中...)", + calculateTotalRows: "統計總筆數", + calculateTotalRowsInline: "(統計總筆數)", + calculateTotalRowsFailed: "統計失敗:{message}", rowsAffected: "影響 {count} 筆", querySuccess: "查詢執行成功", noRows: "暫無資料", diff --git a/apps/desktop/src/lib/dataGridPagination.ts b/apps/desktop/src/lib/dataGridPagination.ts new file mode 100644 index 000000000..255c2371a --- /dev/null +++ b/apps/desktop/src/lib/dataGridPagination.ts @@ -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; +} diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index b2ca68962..d55e7d5cd 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -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 { diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 168da867f..72b25052f 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -360,6 +360,7 @@ export interface QueryTab { resultPageOffset?: number; resultCountSql?: string; resultTotalRowCount?: number; + resultTotalRowCountLoading?: boolean; resultSessionId?: string; resultAccessedAt?: number; pinned?: boolean; diff --git a/packages/app-tests/dataGridPagination.test.ts b/packages/app-tests/dataGridPagination.test.ts new file mode 100644 index 000000000..576daee7c --- /dev/null +++ b/packages/app-tests/dataGridPagination.test.ts @@ -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); +}); diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 300eb7d27..f97ae8021 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -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((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((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());