From e2b35944cee1daaea33abc9daa98f2d94dd5b5a3 Mon Sep 17 00:00:00 2001 From: gggaiitx <62124152+gggaiitx@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:54:54 +0800 Subject: [PATCH] feat(grid): right-align numeric result columns --- apps/desktop/src/components/grid/DataGrid.vue | 37 ++- .../src/components/layout/ContentArea.vue | 56 ++++- .../src/composables/useDataGridExport.ts | 9 +- apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + .../__tests__/dataGrid/columnAlign.spec.ts | 219 ++++++++++++++++++ .../visibleColumnTypesPriority.spec.ts | 117 ++++++++++ apps/desktop/src/lib/backend/http.ts | 5 +- apps/desktop/src/lib/backend/tauri.ts | 7 +- .../lib/dataGrid/canvasDataGridRenderer.ts | 45 +++- .../src/lib/dataGrid/dataGridColumnType.ts | 107 +++++++++ apps/desktop/src/lib/export/xlsxExport.ts | 53 ++--- apps/desktop/src/stores/queryStore.ts | 1 + apps/desktop/src/stores/settingsStore.ts | 4 + .../dbx-core/examples/table_import_bench.rs | 1 + crates/dbx-core/src/query_result_export.rs | 5 + crates/dbx-core/src/table_export.rs | 18 +- crates/dbx-core/src/table_import.rs | 7 + crates/dbx-core/src/xlsx_export.rs | 209 ++++++++++++++--- .../live_clickhouse_query_result_export.rs | 1 + crates/dbx-core/tests/live_mysql57.rs | 2 + .../live_postgres_query_result_export.rs | 5 + .../tests/live_sqlserver_completion.rs | 1 + .../live_sqlserver_query_result_export.rs | 1 + .../app-tests/canvasDataGridRenderer.test.ts | 30 ++- packages/app-tests/settingsStore.test.ts | 55 ++++- packages/app-tests/useDataGridExport.test.ts | 33 ++- packages/app-tests/xlsxExport.test.ts | 113 ++++++++- src-tauri/src/commands/xlsx_export.rs | 12 +- .../data-grid-numeric-column-types.json | 17 ++ 35 files changed, 1082 insertions(+), 109 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/columnAlign.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/visibleColumnTypesPriority.spec.ts create mode 100644 tests/fixtures/data-grid-numeric-column-types.json diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 407f5fc6a..713fcd3f2 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -78,7 +78,7 @@ import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/table import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; import { uuid } from "@/lib/common/utils"; import { generateCellValues, type CellValueGenerationKind } from "@/lib/dataGrid/cellValueGeneration"; -import { compactHeaderColumnType, resolveHeaderColumnType } from "@/lib/dataGrid/dataGridColumnType"; +import { compactHeaderColumnType, isNumericColumnType, resolveHeaderColumnType, resolveResultColumnType } from "@/lib/dataGrid/dataGridColumnType"; import { canDeleteExistingTdengineRows, canEditExistingTableRows, @@ -138,7 +138,7 @@ import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid import { canGoNextDataGridPage, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal } from "@/lib/dataGrid/dataGridPagination"; import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions"; import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll"; -import { CANVAS_DATA_GRID_ROW_HEIGHT, dataGridSearchMatchKey, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer"; +import { CANVAS_DATA_GRID_ROW_HEIGHT, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer"; import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG } from "@/lib/dataGrid/dataGridPaintTheme"; import { createRowLowerTextCache } from "@/lib/dataGrid/dataGridRowLowerText"; import { dataGridPreviewLabelKey, dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi"; @@ -1757,14 +1757,23 @@ const tableColumnTypesByName = computed(() => { return map; }); const visibleColumnTypes = computed(() => - visibleColumnIndexes.value.map((index) => { - const resultColumn = props.result.columns[index]?.toLocaleLowerCase(); - const sourceColumn = props.sourceColumns?.[index]?.toLocaleLowerCase(); - return (sourceColumn ? tableColumnTypesByName.value.get(sourceColumn) : undefined) || (resultColumn ? tableColumnTypesByName.value.get(resultColumn) : undefined) || props.result.column_types?.[index]; - }), + visibleColumnIndexes.value.map((index) => + resolveResultColumnType({ + resultColumnType: props.result.column_types?.[index], + resultColumnName: props.result.columns[index]?.toLocaleLowerCase(), + sourceColumnName: props.sourceColumns?.[index]?.toLocaleLowerCase(), + tableColumnTypesByName: tableColumnTypesByName.value, + }), + ), ); const visibleColumnCount = computed(() => visibleColumnIndexes.value.length); +const numericColumnRightAlign = computed(() => (settingsStore.editorSettings.numericColumnRightAlign ?? true) && !showTranspose.value); +const columnAligns = computed<("left" | "right")[]>(() => { + if (!numericColumnRightAlign.value) return []; + return visibleColumnTypes.value.map((type) => (isNumericColumnType(type) ? "right" : "left")); +}); + /** Preview actions from the result preview registry for the current result. */ const previewActions = computed(() => { if (!props.result) return []; @@ -4952,6 +4961,16 @@ const canvasDetailButtonStyle = computed(() => { }; }); +const canvasRightAlignedActionCell = computed(() => { + const cell = canvasDetailButtonCell.value; + if (!cell || columnAligns.value[cell.visibleColIdx] !== "right") return null; + return { + rowIndex: cell.rowIndex, + visibleColIdx: cell.visibleColIdx, + reservedWidth: canvasDataGridActionReservedWidth(cell.canQuickDownload), + }; +}); + function drawCanvasGrid() { const canvas = canvasRef.value; const scroller = canvasScrollerElement(); @@ -4988,6 +5007,8 @@ function drawCanvasGrid() { pageSize: pageSize.value, currentPage: currentPage.value, frozenColumnCount: frozenColumnCount.value, + columnAligns: columnAligns.value, + rightAlignedActionCell: canvasRightAlignedActionCell.value, }); } @@ -5001,6 +5022,7 @@ watch( { immediate: true }, ); watch(showDataGridTopbar, () => nextTick(observeDataGridTopbarWidth), { immediate: true }); +watch(columnAligns, () => scheduleCanvasDraw()); watch( [ displayRowRefs, @@ -8630,6 +8652,7 @@ const gridContextMenuItems = computed(() => { :class="{ 'data-grid-cell--frozen': col.visibleColIdx < frozenColumnCount, 'data-grid-cell--frozen-separator': frozenColumnCount > 0 && col.visibleColIdx === frozenColumnCount - 1, + 'text-right': columnAligns[col.visibleColIdx] === 'right', 'text-muted-foreground italic': isNull(item.data[col.actualColIdx]), 'bg-yellow-500/10 cell-dirty': item.isDirtyCol[col.actualColIdx], 'cell-selected': cellIsSelected(item.displayIndex, col.visibleColIdx) && !item.isDirtyCol[col.actualColIdx], diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 9775d3bf9..409d30741 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -5,7 +5,7 @@ import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog"; import { canReloadUnavailableDataTab } from "@/lib/table/tableDataRefresh"; import type { CSSProperties } from "vue"; import { useI18n } from "vue-i18n"; -import { Check, Columns3, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert, PanelsTopLeft } from "@lucide/vue"; +import { Check, Columns3, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert, AlignLeft, AlignRight, PanelsTopLeft } from "@lucide/vue"; import { Splitpanes, Pane } from "splitpanes"; import "splitpanes/dist/splitpanes.css"; import { Button } from "@/components/ui/button"; @@ -264,6 +264,12 @@ function setTableFontSize(value: number) { settingsStore.updateEditorSettings({ tableFontSize: value }); } +const numericColumnRightAlign = computed(() => settingsStore.editorSettings.numericColumnRightAlign ?? true); + +function setNumericColumnRightAlign(value: boolean) { + settingsStore.updateEditorSettings({ numericColumnRightAlign: value }); +} + function decreaseTableFontSize() { setTableFontSize(tableFontSize.value - 1); } @@ -1180,6 +1186,30 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand +
+
+ + {{ t("grid.numericColumnAlign") }} +
+
+ + +
+
@@ -1597,6 +1627,30 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
+
+
+ + {{ t("grid.numericColumnAlign") }} +
+
+ + +
+
diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index a784944e1..f75b3910e 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -263,8 +263,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) { async function writeXlsxResult(outputPath: string, result: { columns: string[]; columnTypes: string[]; rows: CellValue[][] }, includeSqlSheet: boolean) { const sqlWorksheet = includeSqlSheet ? buildXlsxSqlWorksheet([{ sql: currentExportSql() || "" }]) : undefined; + const rightAlign = useSettingsStore().editorSettings.numericColumnRightAlign; if (!sqlWorksheet) { - await api.exportQueryResultXlsx(outputPath, currentXlsxSheetName(), result.columns, result.columnTypes, result.rows); + await api.exportQueryResultXlsx(outputPath, currentXlsxSheetName(), result.columns, result.columnTypes, result.rows, rightAlign); return; } await api.exportQueryResultsXlsx(outputPath, [ @@ -273,6 +274,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { columns: result.columns, columnTypes: result.columnTypes, rows: result.rows, + numericColumnRightAlign: rightAlign, }, sqlWorksheet, ]); @@ -830,11 +832,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } const exportPattern = useSettingsStore().editorSettings.globalDateTimeExportFormat; + const rightAlign = useSettingsStore().editorSettings.numericColumnRightAlign; const worksheets = sheets.map((sheet) => ({ sheetName: sheet.sheetName, columns: sheet.result.columns, columnTypes: sheet.result.column_types ?? [], rows: formatTemporalRowsForExport(sheet.result.rows, sheet.result.column_types ?? [], exportPattern), + numericColumnRightAlign: rightAlign, })); const sqlWorksheet = includeSqlSheet ? buildXlsxSqlWorksheet(sheets.map((sheet) => ({ resultName: sheet.sheetName, sql: sheet.sql || sheet.result.sourceStatement || "" }))) : undefined; await api.exportQueryResultsXlsx(outputPath, sqlWorksheet ? [...worksheets, sqlWorksheet] : worksheets); @@ -915,6 +919,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { batchSize: exportBatchSize.value, rowLimit, dateTimeFormat: editorSettings.globalDateTimeExportFormat || undefined, + numericColumnRightAlign: editorSettings.numericColumnRightAlign ?? true, }, (progress) => { if (exportProgressState) { @@ -962,7 +967,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { const exportId = uuid(); const baseRequest = await queryResultExportRequest({ exportId, filePath: outputPath, format, includeSqlSheet }); - const request = baseRequest ? { ...baseRequest, dateTimeFormat: useSettingsStore().editorSettings.globalDateTimeExportFormat || undefined } : undefined; + const request = baseRequest ? { ...baseRequest, dateTimeFormat: useSettingsStore().editorSettings.globalDateTimeExportFormat || undefined, numericColumnRightAlign: useSettingsStore().editorSettings.numericColumnRightAlign ?? true } : undefined; if (!request) throw new Error("Unable to build query result export request"); if (exportProgressState) { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 57c8dcf46..43157cb14 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1152,6 +1152,9 @@ export default { searchModeHint: "Choose whether Ctrl+F filters matching rows or keeps all rows visible and highlights matches.", searchModeFilter: "Filter", searchModeHighlight: "Highlight", + numericColumnAlign: "Numeric Column Align", + numericColumnAlignLeft: "Left", + numericColumnAlignRight: "Right", moreValues: "{count} more values, keep typing to narrow results", filterByValue: "Filter by This Value", filterExcludeValue: "Exclude This Value", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 8e50c1e39..7a629c026 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1289,6 +1289,9 @@ export default withEnglishFallback({ }, cachedResultUnavailable: "Resultado en caché faltante o incompatible.", reexecuteQuery: "Volver a ejecutar consulta", + numericColumnAlign: "Alineación de columna numérica", + numericColumnAlignLeft: "Alineación izquierda", + numericColumnAlignRight: "Alineación derecha", }, exportProgress: { title: "Exportando datos de la tabla", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 461679dfd..bc419bfb6 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1287,6 +1287,9 @@ export default withEnglishFallback({ }, cachedResultUnavailable: "Risultato in cache mancante o non compatibile.", reexecuteQuery: "Esegui di nuovo la query", + numericColumnAlign: "Allineamento colonna numerica", + numericColumnAlignLeft: "Allineamento a sinistra", + numericColumnAlignRight: "Allineamento a destra", }, exportProgress: { title: "Esportazione Dati Tabella", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 608cdf2a6..247260e0e 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1288,6 +1288,9 @@ export default withEnglishFallback({ }, cachedResultUnavailable: "キャッシュ結果が不足しているか互換性がありません。", reexecuteQuery: "クエリを再実行", + numericColumnAlign: "数値列の配置", + numericColumnAlignLeft: "左揃え", + numericColumnAlignRight: "右揃え", }, exportProgress: { title: "テーブルデータをエクスポート中", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 3e963b1de..b787b488b 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1289,6 +1289,9 @@ export default withEnglishFallback({ }, cachedResultUnavailable: "Resultado em cache ausente ou incompatível.", reexecuteQuery: "Reexecutar consulta", + numericColumnAlign: "Alinhamento de colunas numéricas", + numericColumnAlignLeft: "Alinhamento à esquerda", + numericColumnAlignRight: "Alinhamento à direita", }, exportProgress: { title: "Exportando Dados da Tabela", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 2f1332ad3..fd0148534 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1153,6 +1153,9 @@ export default withEnglishFallback({ searchModeHint: "选择 Ctrl+F 搜索时过滤匹配行,或保留全部行并高亮定位命中项。", searchModeFilter: "过滤", searchModeHighlight: "高亮", + numericColumnAlign: "数值列对齐", + numericColumnAlignLeft: "左对齐", + numericColumnAlignRight: "右对齐", moreValues: "还有 {count} 个值,输入关键词继续缩小范围", filterByValue: "筛选此值", filterExcludeValue: "排除此值", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 77c1a2ee8..ec66a0d09 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1288,6 +1288,9 @@ export default withEnglishFallback({ }, cachedResultUnavailable: "快取結果缺失或不相容。", reexecuteQuery: "重新執行查詢", + numericColumnAlign: "數值列對齊", + numericColumnAlignLeft: "左對齊", + numericColumnAlignRight: "右對齊", }, exportProgress: { title: "匯出資料表資料", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/columnAlign.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/columnAlign.spec.ts new file mode 100644 index 000000000..74d78aa07 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/columnAlign.spec.ts @@ -0,0 +1,219 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { isNumericColumnType } from "@/lib/dataGrid/dataGridColumnType"; + +interface NumericColumnTypeFixture { + backend: string; + type: string; +} + +interface NumericColumnTypeFixtures { + numeric: NumericColumnTypeFixture[]; + nonNumeric: NumericColumnTypeFixture[]; +} + +const actualBackendTypeFixtures = JSON.parse(readFileSync(new URL("../../../../../../tests/fixtures/data-grid-numeric-column-types.json", import.meta.url), "utf8")) as NumericColumnTypeFixtures; + +describe("isNumericColumnType", () => { + it("recognizes core numeric types", () => { + expect(isNumericColumnType("int")).toBe(true); + expect(isNumericColumnType("integer")).toBe(true); + expect(isNumericColumnType("bigint")).toBe(true); + expect(isNumericColumnType("smallint")).toBe(true); + expect(isNumericColumnType("decimal")).toBe(true); + expect(isNumericColumnType("numeric")).toBe(true); + expect(isNumericColumnType("number")).toBe(true); + expect(isNumericColumnType("float")).toBe(true); + expect(isNumericColumnType("double")).toBe(true); + expect(isNumericColumnType("real")).toBe(true); + expect(isNumericColumnType("money")).toBe(true); + expect(isNumericColumnType("smallmoney")).toBe(true); + }); + + it("recognizes types with precision/scale suffix", () => { + expect(isNumericColumnType("decimal(10,2)")).toBe(true); + expect(isNumericColumnType("numeric(18,6)")).toBe(true); + expect(isNumericColumnType("int(11)")).toBe(true); + expect(isNumericColumnType("bigint(20)")).toBe(true); + expect(isNumericColumnType("float(53)")).toBe(true); + }); + + it("recognizes serial, int aliases, and Oracle types", () => { + expect(isNumericColumnType("serial")).toBe(true); + expect(isNumericColumnType("bigserial")).toBe(true); + expect(isNumericColumnType("int2")).toBe(true); + expect(isNumericColumnType("int4")).toBe(true); + expect(isNumericColumnType("int8")).toBe(true); + expect(isNumericColumnType("binary_float")).toBe(true); + expect(isNumericColumnType("binary_double")).toBe(true); + }); + + it("recognizes unsigned and ClickHouse types", () => { + expect(isNumericColumnType("uint8")).toBe(true); + expect(isNumericColumnType("uint64")).toBe(true); + expect(isNumericColumnType("float32")).toBe(true); + expect(isNumericColumnType("float64")).toBe(true); + }); + + it("recognizes ClickHouse big integer and decimal types", () => { + expect(isNumericColumnType("Int128")).toBe(true); + expect(isNumericColumnType("Int256")).toBe(true); + expect(isNumericColumnType("UInt128")).toBe(true); + expect(isNumericColumnType("UInt256")).toBe(true); + expect(isNumericColumnType("Decimal32")).toBe(true); + expect(isNumericColumnType("Decimal64")).toBe(true); + expect(isNumericColumnType("Decimal128")).toBe(true); + expect(isNumericColumnType("Decimal256")).toBe(true); + expect(isNumericColumnType("Float16")).toBe(true); + }); + + it("recognizes SQL Server internal type names", () => { + expect(isNumericColumnType("decimaln")).toBe(true); + expect(isNumericColumnType("numericn")).toBe(true); + expect(isNumericColumnType("intn")).toBe(true); + expect(isNumericColumnType("floatn")).toBe(true); + expect(isNumericColumnType("moneyn")).toBe(true); + expect(isNumericColumnType("smallmoneyn")).toBe(true); + }); + + it("rejects non-numeric types", () => { + expect(isNumericColumnType("varchar")).toBe(false); + expect(isNumericColumnType("text")).toBe(false); + expect(isNumericColumnType("date")).toBe(false); + expect(isNumericColumnType("timestamp")).toBe(false); + expect(isNumericColumnType("boolean")).toBe(false); + expect(isNumericColumnType("blob")).toBe(false); + expect(isNumericColumnType("json")).toBe(false); + expect(isNumericColumnType("uuid")).toBe(false); + }); + + it("handles edge cases", () => { + expect(isNumericColumnType(undefined)).toBe(false); + expect(isNumericColumnType("")).toBe(false); + expect(isNumericColumnType("DECIMAL")).toBe(true); // case-insensitive + expect(isNumericColumnType(" decimal(10,2) ")).toBe(true); // whitespace tolerant + }); + + it("recognizes dec and fixed aliases", () => { + expect(isNumericColumnType("dec")).toBe(true); + expect(isNumericColumnType("fixed")).toBe(true); + }); + + it("covers the full cross-database numeric whitelist used by the alignment classifier", () => { + // Cross-database coverage matrix. Every entry here MUST stay in sync with + // the Rust classifier in crates/dbx-core/src/xlsx_export.rs so that the + // grid, the front-end XLSX exporter and the Rust XLSX exporter agree. + const numericTypesByDatabase: Record = { + mysql: ["tinyint", "smallint", "mediumint", "int", "integer", "bigint", "float", "double", "decimal", "dec", "fixed"], + postgres: ["smallint", "integer", "bigint", "serial", "smallserial", "bigserial", "int2", "int4", "int8", "real", "double precision", "money", "numeric"], + oracle: ["number", "binary_float", "binary_double", "float"], + dameng: ["number", "binary_float", "binary_double"], + "sql-server": ["int", "bigint", "smallint", "tinyint", "decimal", "numeric", "money", "smallmoney", "float", "real", "intn", "decimaln", "numericn", "floatn", "moneyn", "smallmoneyn"], + clickhouse: ["Int8", "Int16", "Int32", "Int64", "Int128", "Int256", "UInt8", "UInt16", "UInt32", "UInt64", "UInt128", "UInt256", "Float32", "Float64", "Decimal32", "Decimal64", "Decimal128", "Decimal256"], + sqlite: ["integer", "real", "numeric", "decimal"], + hana: ["tinyint", "smallint", "integer", "bigint", "decimal", "real", "double"], + }; + + for (const types of Object.values(numericTypesByDatabase)) { + for (const type of types) { + expect(isNumericColumnType(type)).toBe(true); + } + } + }); + + it("classifies actual backend type names from the shared fixture", () => { + for (const fixture of actualBackendTypeFixtures.numeric) { + expect(isNumericColumnType(fixture.type), `${fixture.backend}: ${fixture.type}`).toBe(true); + } + for (const fixture of actualBackendTypeFixtures.nonNumeric) { + expect(isNumericColumnType(fixture.type), `${fixture.backend}: ${fixture.type}`).toBe(false); + } + }); + + it("keeps text/date/binary/json types left-aligned", () => { + // Sanity check that non-numeric types stay left-aligned across databases. + const nonNumericTypes = [ + "varchar(255)", + "text", + "char(10)", + "nvarchar(100)", + "nchar(10)", + "clob", + "blob", + "binary", + "varbinary", + "bytea", + "date", + "datetime", + "datetime2", + "datetimeoffset", + "timestamp", + "timestamptz", + "time", + "boolean", + "bool", + "bit", + "json", + "jsonb", + "uuid", + "enum('a','b')", + "inet", + "cidr", + "macaddr", + "xml", + "geometry", + "geography", + "hierarchyid", + "sql_variant", + ]; + for (const type of nonNumericTypes) { + expect(isNumericColumnType(type)).toBe(false); + } + }); + + it("strips precision, scale and array suffixes before classification", () => { + expect(isNumericColumnType("decimal(18, 4)")).toBe(true); + expect(isNumericColumnType("numeric(38)")).toBe(true); + expect(isNumericColumnType("int(11) unsigned")).toBe(true); + expect(isNumericColumnType("bigint(20)")).toBe(true); + expect(isNumericColumnType("float(53)")).toBe(true); + expect(isNumericColumnType("Decimal128(18, 2)")).toBe(true); + expect(isNumericColumnType("varchar(255)")).toBe(false); + expect(isNumericColumnType("decimal[]")).toBe(true); + }); +}); + +describe("columnAligns derivation (mirrors DataGrid.vue)", () => { + // Replicates the columnAligns computed property in DataGrid.vue: + // - empty array when numeric right alignment is disabled (or transpose on) + // - "right" for numeric types, "left" otherwise + function deriveColumnAligns(visibleColumnTypes: Array, numericRightAlign: boolean): Array<"left" | "right"> { + if (!numericRightAlign) return []; + return visibleColumnTypes.map((type) => (isNumericColumnType(type) ? "right" : "left")); + } + + it("returns an empty array when numeric right alignment is disabled", () => { + expect(deriveColumnAligns(["int", "varchar"], false)).toEqual([]); + }); + + it("right-aligns numeric columns and left-aligns the rest", () => { + expect(deriveColumnAligns(["int", "varchar", "decimal(10,2)", "date", "bigint"], true)).toEqual(["right", "left", "right", "left", "right"]); + }); + + it("handles undefined types as left-aligned", () => { + expect(deriveColumnAligns([undefined, "int", undefined], true)).toEqual(["left", "right", "left"]); + }); + + it("keeps every column left-aligned when only text types are present", () => { + expect(deriveColumnAligns(["varchar", "text", "json"], true)).toEqual(["left", "left", "left"]); + }); + + it("keeps every column right-aligned when only numeric types are present", () => { + expect(deriveColumnAligns(["int", "decimal", "bigint"], true)).toEqual(["right", "right", "right"]); + }); + + it("treats cross-database numeric types consistently with the classifier", () => { + const aligns = deriveColumnAligns(["Int128", "UInt256", "Decimal128(18, 2)", "BINARY_FLOAT", "BINARY_DOUBLE", "decimaln", "varchar"], true); + expect(aligns).toEqual(["right", "right", "right", "right", "right", "right", "left"]); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/visibleColumnTypesPriority.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/visibleColumnTypesPriority.spec.ts new file mode 100644 index 000000000..5e13056af --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/visibleColumnTypesPriority.spec.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { isNumericColumnType, resolveResultColumnType } from "@/lib/dataGrid/dataGridColumnType"; + +describe("resolveResultColumnType", () => { + function tableTypes(entries: Record): Map { + return new Map(Object.entries(entries)); + } + + it("prefers ResultSet column_types over table metadata", () => { + // SELECT CAST(amount AS TEXT) AS amount — the column is no longer numeric + // even though the underlying source column is `decimal(10,2)`. + const type = resolveResultColumnType({ + resultColumnType: "text", + resultColumnName: "amount", + sourceColumnName: "amount", + tableColumnTypesByName: tableTypes({ amount: "decimal(10,2)" }), + }); + expect(type).toBe("text"); + expect(isNumericColumnType(type)).toBe(false); + }); + + it("falls back to source column metadata when ResultSet omits the type", () => { + const type = resolveResultColumnType({ + resultColumnType: undefined, + resultColumnName: "amount", + sourceColumnName: "amount", + tableColumnTypesByName: tableTypes({ amount: "decimal(10,2)" }), + }); + expect(type).toBe("decimal(10,2)"); + expect(isNumericColumnType(type)).toBe(true); + }); + + it("falls back to result column name when source column name is unavailable", () => { + const type = resolveResultColumnType({ + resultColumnType: undefined, + resultColumnName: "amount", + sourceColumnName: undefined, + tableColumnTypesByName: tableTypes({ amount: "decimal(10,2)" }), + }); + expect(type).toBe("decimal(10,2)"); + }); + + it("returns undefined when no source provides a type", () => { + expect( + resolveResultColumnType({ + resultColumnType: undefined, + resultColumnName: "unknown", + sourceColumnName: "unknown", + tableColumnTypesByName: tableTypes({ amount: "decimal(10,2)" }), + }), + ).toBeUndefined(); + }); + + it("treats whitespace-only ResultSet types as missing and falls back to metadata", () => { + const type = resolveResultColumnType({ + resultColumnType: " ", + resultColumnName: "amount", + sourceColumnName: "amount", + tableColumnTypesByName: tableTypes({ amount: "int" }), + }); + expect(type).toBe("int"); + }); + + it("ignores whitespace-only table metadata entries", () => { + const type = resolveResultColumnType({ + resultColumnType: undefined, + resultColumnName: "amount", + sourceColumnName: "amount", + tableColumnTypesByName: tableTypes({ amount: " " }), + }); + expect(type).toBeUndefined(); + }); + + it("prefers the source column name over the result column name when both differ", () => { + // Source column `total_price` aliased as `total` — when no result type is + // supplied, we should resolve via the source column name first. + const type = resolveResultColumnType({ + resultColumnType: undefined, + resultColumnName: "total", + sourceColumnName: "total_price", + tableColumnTypesByName: tableTypes({ total: "varchar(20)", total_price: "decimal(18,4)" }), + }); + expect(type).toBe("decimal(18,4)"); + }); + + it("handles missing tableColumnTypesByName gracefully", () => { + expect( + resolveResultColumnType({ + resultColumnType: undefined, + resultColumnName: "amount", + sourceColumnName: "amount", + }), + ).toBeUndefined(); + }); + + it("drives right alignment only when the ResultSet reports a numeric type", () => { + // SELECT CAST(amount AS TEXT) AS amount must NOT right-align even though + // the source column is numeric. + const castToText = resolveResultColumnType({ + resultColumnType: "text", + resultColumnName: "amount", + sourceColumnName: "amount", + tableColumnTypesByName: tableTypes({ amount: "decimal(10,2)" }), + }); + expect(isNumericColumnType(castToText)).toBe(false); + + // SELECT CAST(label AS INTEGER) AS label must right-align even though the + // source column is text. + const castToInteger = resolveResultColumnType({ + resultColumnType: "integer", + resultColumnName: "label", + sourceColumnName: "label", + tableColumnTypesByName: tableTypes({ label: "varchar(50)" }), + }); + expect(isNumericColumnType(castToInteger)).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 4218a42b3..67ba3d061 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -1894,13 +1894,14 @@ function downloadTextFile(filePath: string, fallbackFileName: string, content: s URL.revokeObjectURL(url); } -export async function exportQueryResultXlsx(filePath: string, sheetName: string | undefined, columns: string[], columnTypes: string[], rows: readonly (readonly XlsxCellValue[])[]): Promise { +export async function exportQueryResultXlsx(filePath: string, sheetName: string | undefined, columns: string[], columnTypes: string[], rows: readonly (readonly XlsxCellValue[])[], numericColumnRightAlign?: boolean): Promise { const { buildXlsxWorkbook } = await import("@/lib/export/xlsxExport"); const workbook = buildXlsxWorkbook({ sheetName: sheetName || "Export", columns, columnTypes, rows, + numericColumnRightAlign, }); const fileName = filePath.split(/[\\/]/).pop() || "export.xlsx"; const blob = new Blob([new Uint8Array(workbook)], { @@ -1914,7 +1915,7 @@ export async function exportQueryResultXlsx(filePath: string, sheetName: string URL.revokeObjectURL(url); } -export async function exportQueryResultsXlsx(filePath: string, worksheets: readonly { sheetName?: string; columns: readonly string[]; columnTypes?: readonly string[]; rows: readonly (readonly XlsxCellValue[])[] }[]): Promise { +export async function exportQueryResultsXlsx(filePath: string, worksheets: readonly { sheetName?: string; columns: readonly string[]; columnTypes?: readonly string[]; rows: readonly (readonly XlsxCellValue[])[]; numericColumnRightAlign?: boolean }[]): Promise { const { buildXlsxWorkbookMulti } = await import("@/lib/export/xlsxExport"); const workbook = buildXlsxWorkbookMulti(worksheets); const fileName = filePath.split(/[\\/]/).pop() || "export.xlsx"; diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 64e13b457..1f0c6e84d 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -2709,6 +2709,7 @@ export interface TableExportRequest { batchSize?: number; rowLimit?: number | null; dateTimeFormat?: string; + numericColumnRightAlign?: boolean; } export interface TableCsvExportOptions { @@ -2752,6 +2753,7 @@ export interface QueryResultExportRequest { clientSessionId?: string; executionId?: string; dateTimeFormat?: string; + numericColumnRightAlign?: boolean; } export async function startTableExport(request: TableExportRequest, onProgress: (progress: TableExportProgress) => void): Promise { @@ -2883,7 +2885,7 @@ export async function exportTableDataCsv(options: TableCsvExportOptions): Promis return invoke("export_table_data_csv", { request: options }); } -export async function exportQueryResultXlsx(filePath: string, sheetName: string | undefined, columns: string[], columnTypes: string[], rows: readonly (readonly XlsxCellValue[])[]): Promise { +export async function exportQueryResultXlsx(filePath: string, sheetName: string | undefined, columns: string[], columnTypes: string[], rows: readonly (readonly XlsxCellValue[])[], numericColumnRightAlign?: boolean): Promise { return invoke("export_query_result_xlsx", { request: { filePath, @@ -2891,11 +2893,12 @@ export async function exportQueryResultXlsx(filePath: string, sheetName: string columns, columnTypes, rows, + numericColumnRightAlign, }, }); } -export async function exportQueryResultsXlsx(filePath: string, worksheets: readonly { sheetName?: string; columns: readonly string[]; columnTypes?: readonly string[]; rows: readonly (readonly XlsxCellValue[])[] }[]): Promise { +export async function exportQueryResultsXlsx(filePath: string, worksheets: readonly { sheetName?: string; columns: readonly string[]; columnTypes?: readonly string[]; rows: readonly (readonly XlsxCellValue[])[]; numericColumnRightAlign?: boolean }[]): Promise { return invoke("export_query_results_xlsx", { request: { filePath, diff --git a/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts b/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts index 72b088e6d..d43cd9345 100644 --- a/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts +++ b/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts @@ -25,6 +25,10 @@ export interface CanvasEditingCell { col: number; } +export interface CanvasRightAlignedActionCell extends CanvasHoverCell { + reservedWidth: number; +} + /** 搜索匹配的数值 key:列头匹配 displayRow 为 -1。相比字符串拼接 key, * 每次按键构建 matchSet、每帧对可见单元格查询都零字符串分配。 * ponytail: 列数上限 65536,网格列数远达不到 */ @@ -69,6 +73,8 @@ export interface DrawCanvasDataGridOptions { pageSize: number; currentPage: number; frozenColumnCount?: number; + columnAligns?: readonly ("left" | "right")[]; + rightAlignedActionCell?: CanvasRightAlignedActionCell | null; } type NumericCanvasContext = CanvasRenderingContext2D & { @@ -111,10 +117,10 @@ export function clearFitCanvasTextCache(): void { fitCanvasTextCache.clear(); } -export function fitCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string { +export function fitCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number, align: "left" | "right" = "left"): string { if (maxWidth <= 0) return ""; const font = ctx.font; - const cacheKey = `${font}|${text}|${maxWidth}`; + const cacheKey = `${font}|${text}|${maxWidth}|${align}`; const cached = fitCanvasTextCache.get(cacheKey); if (cached !== undefined) return cached; if (ctx.measureText(text).width <= maxWidth) { @@ -128,15 +134,28 @@ export function fitCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWi let high = text.length; while (low < high) { const mid = Math.ceil((low + high) / 2); - if (ctx.measureText(text.slice(0, mid)).width + ellipsisWidth <= maxWidth) low = mid; + const candidate = align === "right" ? text.slice(text.length - mid) : text.slice(0, mid); + if (ctx.measureText(candidate).width + ellipsisWidth <= maxWidth) low = mid; else high = mid - 1; } - const result = text.slice(0, low) + ellipsis; + const result = align === "right" ? ellipsis + text.slice(text.length - low) : text.slice(0, low) + ellipsis; if (fitCanvasTextCache.size >= FIT_CANVAS_TEXT_CACHE_MAX) fitCanvasTextCache.clear(); fitCanvasTextCache.set(cacheKey, result); return result; } +export function canvasDataGridActionReservedWidth(canQuickDownload: boolean): number { + return (canQuickDownload ? 44 : 22) + 6; +} + +export function resolveCanvasCellTextLayout(options: { drawX: number; colWidth: number; dpr: number; isRightAlign: boolean; reservedWidth?: number }): { textAnchorX: number; maxWidth: number } { + const reservedWidth = options.isRightAlign ? Math.max(0, options.reservedWidth ?? 0) : 0; + return { + textAnchorX: alignCanvasPixel(options.isRightAlign ? options.drawX + options.colWidth - 12 - reservedWidth : options.drawX + 12, options.dpr), + maxWidth: Math.max(0, options.colWidth - 24 - reservedWidth), + }; +} + function canvasFont(style: { family: string; sizePx: number; style?: string; weight?: string | number; lineHeight?: string }): string { const fontStyle = style.style && style.style !== "normal" ? `${style.style} ` : ""; const fontWeight = style.weight && style.weight !== "400" && style.weight !== "normal" ? `${style.weight} ` : ""; @@ -251,6 +270,8 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) { pageSize, currentPage, frozenColumnCount = 0, + columnAligns, + rightAlignedActionCell, } = options; const dpr = Math.max(1, options.pixelRatio ?? window.devicePixelRatio ?? 1); const pixelWidth = Math.max(1, Math.ceil(width * dpr)); @@ -408,23 +429,25 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) { ctx.rect(clippedX, y, Math.min(cellPaintWidth, width - clippedX), CANVAS_DATA_GRID_ROW_HEIGHT); ctx.clip(); const value = item.data[actualColIdx]; - ctx.textAlign = "left"; + const isRightAlign = columnAligns?.[visibleColIdx] === "right"; + ctx.textAlign = isRightAlign ? "right" : "left"; ctx.fillStyle = value === null ? theme.mutedForeground : theme.foreground; ctx.font = value === null ? italicFont : tabularFont; setCanvasNumericVariant(ctx, value === null ? "normal" : "tabular-nums"); - const textLeft = alignCanvasPixel(drawX + 12, dpr); - const cellMaxWidth = Math.max(0, colWidth - 24); + const reservedWidth = rightAlignedActionCell?.rowIndex === item.displayIndex && rightAlignedActionCell.visibleColIdx === visibleColIdx ? rightAlignedActionCell.reservedWidth : 0; + const { textAnchorX, maxWidth: cellMaxWidth } = resolveCanvasCellTextLayout({ drawX, colWidth, dpr, isRightAlign, reservedWidth }); const isEditingThisCell = editingCell?.rowId === item.id && editingCell.col === actualColIdx; const rawDisplayText = item.isDraft && value === null ? (draftCellPlaceholder ?? "") : formatCell(value, actualColIdx); const displayText = isEditingThisCell ? "" : firstLineCellDisplayValue(rawDisplayText); - const text = isEditingThisCell ? displayText : fitCanvasText(ctx, displayText, cellMaxWidth); - ctx.fillText(text, textLeft, textY); + const text = isEditingThisCell ? displayText : fitCanvasText(ctx, displayText, cellMaxWidth, isRightAlign ? "right" : "left"); + ctx.fillText(text, textAnchorX, textY); if (item.isDeleted && text) { const textWidth = ctx.measureText(text).width; + const lineStartX = isRightAlign ? textAnchorX - textWidth : textAnchorX; ctx.strokeStyle = theme.foreground; ctx.beginPath(); - ctx.moveTo(textLeft, textY); - ctx.lineTo(alignCanvasPixel(textLeft + textWidth, dpr), textY); + ctx.moveTo(lineStartX, textY); + ctx.lineTo(alignCanvasPixel(lineStartX + textWidth, dpr), textY); ctx.stroke(); } ctx.restore(); diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnType.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnType.ts index 43da9b480..e754c3afb 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridColumnType.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridColumnType.ts @@ -31,3 +31,110 @@ export function resolveHeaderColumnType({ tableColumnType, resultColumnTypes, ac export function compactHeaderColumnType(dataType: string): string { return /^enum\s*\(/i.test(dataType.trim()) ? "enum" : dataType; } + +/** + * Resolve the data type used to drive per-column alignment and other + * type-driven rendering in the query-result grid. + * + * Unlike {@link resolveHeaderColumnType}, the **ResultSet `column_types` wins + * over table metadata** for alignment purposes. Table metadata is matched by + * the source column name and reflects the underlying column declaration, so + * relying on it for alignment produces wrong results when the query casts the + * value to a different type — e.g. `SELECT CAST(amount AS TEXT) AS amount` + * would still look numeric and be right-aligned. The actual ResultSet type + * (`text`) reflects what the user sees and must take precedence. Table + * metadata is only consulted when the ResultSet does not supply a non-empty + * type for that index. + */ +export interface ResultColumnTypeResolution { + /** Type reported by the ResultSet for this column (by index). */ + resultColumnType?: string; + /** Lower-cased name of the column in the ResultSet. */ + resultColumnName?: string; + /** Lower-cased name of the underlying source column, when known. */ + sourceColumnName?: string; + /** Map of lower-cased column name -> table metadata type. */ + tableColumnTypesByName?: ReadonlyMap; +} + +export function resolveResultColumnType({ resultColumnType, resultColumnName, sourceColumnName, tableColumnTypesByName }: ResultColumnTypeResolution): string | undefined { + const fromResult = resultColumnType?.trim(); + if (fromResult) return fromResult; + + const lookup = tableColumnTypesByName ?? EMPTY_STRING_MAP; + const fromSource = sourceColumnName ? lookup.get(sourceColumnName) : undefined; + if (fromSource && fromSource.trim()) return fromSource; + const fromResultName = resultColumnName ? lookup.get(resultColumnName) : undefined; + return fromResultName && fromResultName.trim() ? fromResultName : undefined; +} + +const EMPTY_STRING_MAP: ReadonlyMap = new Map(); +const TRANSPARENT_NUMERIC_TYPE_WRAPPERS = new Set(["nullable", "lowcardinality"]); + +const NUMERIC_COLUMN_TYPE_BASES = new Set([ + "tinyint", + "smallint", + "mediumint", + "int", + "integer", + "bigint", + "serial", + "smallserial", + "bigserial", + "int2", + "int4", + "int8", + "int1", + "int16", + "int32", + "int64", + "int128", + "int256", + "intn", + "uint", + "uint8", + "uint16", + "uint32", + "uint64", + "uint128", + "uint256", + "float", + "float4", + "float8", + "float16", + "float32", + "float64", + "floatn", + "real", + "double", + "decimal", + "decimal32", + "decimal64", + "decimal128", + "decimal256", + "decimaln", + "numeric", + "numericn", + "number", + "dec", + "fixed", + "money", + "money4", + "moneyn", + "smallmoney", + "smallmoneyn", + "binary_float", + "binary_double", +]); + +export function isNumericColumnType(dataType: string | undefined): boolean { + if (!dataType) return false; + let normalized = dataType.trim().toLowerCase(); + while (normalized.endsWith(")")) { + const openIndex = normalized.indexOf("("); + if (openIndex <= 0 || !TRANSPARENT_NUMERIC_TYPE_WRAPPERS.has(normalized.slice(0, openIndex).trim())) break; + normalized = normalized.slice(openIndex + 1, -1).trim(); + } + const base = normalized.split(/[\s([]/, 1)[0]; + return NUMERIC_COLUMN_TYPE_BASES.has(base); +} diff --git a/apps/desktop/src/lib/export/xlsxExport.ts b/apps/desktop/src/lib/export/xlsxExport.ts index 52f92b092..235844261 100644 --- a/apps/desktop/src/lib/export/xlsxExport.ts +++ b/apps/desktop/src/lib/export/xlsxExport.ts @@ -1,3 +1,5 @@ +import { isNumericColumnType } from "@/lib/dataGrid/dataGridColumnType"; + export type XlsxCellValue = string | number | boolean | null | undefined; export interface XlsxWorksheetData { @@ -5,6 +7,7 @@ export interface XlsxWorksheetData { columns: readonly string[]; columnTypes?: readonly string[]; rows: readonly (readonly XlsxCellValue[])[]; + numericColumnRightAlign?: boolean; } type ZipEntry = { @@ -104,43 +107,6 @@ function estimateColumnWidths(columns: readonly string[], rows: readonly (readon }); } -function isNumericColumnType(columnType?: string): boolean { - const base = (columnType || "") - .trim() - .toLowerCase() - .split(/[\s([]/, 1)[0]; - return new Set([ - "bit", - "tinyint", - "smallint", - "mediumint", - "int", - "integer", - "bigint", - "int2", - "int4", - "int8", - "uint8", - "uint16", - "uint32", - "uint64", - "uint128", - "uint256", - "float", - "float4", - "float8", - "float32", - "float64", - "real", - "double", - "decimal", - "numeric", - "number", - "money", - "smallmoney", - ]).has(base); -} - function safeExcelNumber(value: string): string | undefined { const trimmed = value.trim(); if (!trimmed || !Number.isFinite(Number(trimmed))) return undefined; @@ -148,6 +114,14 @@ function safeExcelNumber(value: string): string | undefined { return significantDigits <= 15 ? trimmed : undefined; } +const NUMERIC_RIGHT_ALIGN_STYLE_INDEX = 2; +const NUMERIC_LEFT_ALIGN_STYLE_INDEX = 3; + +function numericColumnStyle(columnType?: string, enabled = true): number | undefined { + if (!isNumericColumnType(columnType)) return undefined; + return enabled ? NUMERIC_RIGHT_ALIGN_STYLE_INDEX : NUMERIC_LEFT_ALIGN_STYLE_INDEX; +} + function cellXml(value: XlsxCellValue, rowIndex: number, colIndex: number, style?: number, columnType?: string): string { const ref = cellRef(rowIndex, colIndex); const styleAttr = style == null ? "" : ` s="${style}"`; @@ -173,12 +147,13 @@ function worksheetXml(data: XlsxWorksheetData): string { const totalRows = rows.length + 1; const range = sheetRange(columns.length, totalRows); const widths = estimateColumnWidths(columns, rows); + const rightAlignEnabled = data.numericColumnRightAlign !== false; const colsXml = widths.map((width, index) => ``).join(""); const headerXml = `${columns.map((column, index) => cellXml(column, 0, index, 1)).join("")}`; const bodyXml = rows .map((row, rowIndex) => { const excelRowIndex = rowIndex + 2; - const cells = columns.map((_, colIndex) => cellXml(row[colIndex], excelRowIndex - 1, colIndex, undefined, data.columnTypes?.[colIndex])).join(""); + const cells = columns.map((_, colIndex) => cellXml(row[colIndex], excelRowIndex - 1, colIndex, numericColumnStyle(data.columnTypes?.[colIndex], rightAlignEnabled), data.columnTypes?.[colIndex])).join(""); return `${cells}`; }) .join(""); @@ -237,7 +212,7 @@ function stylesXml(): string { - + `; } diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 38fc73b79..65376c1ee 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -4675,6 +4675,7 @@ export const useQueryStore = defineStore("query", () => { keysetOptimizationEnabled: settings.queryExportKeysetOptimizationEnabled, clientSessionId, executionId: uuid(), + numericColumnRightAlign: settings.numericColumnRightAlign, }; } diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index cf950257c..3e279ae5a 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -427,6 +427,7 @@ export interface EditorSettings { dataGridAutoTransposeSingleRow: boolean; dataGridMultiRowTranspose: boolean; dataGridHideNullColumns: boolean; + numericColumnRightAlign: boolean; tableFontFamily: string; tableFontSize: number; structureEditorDensity: StructureEditorDensity; @@ -594,6 +595,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { dataGridAutoTransposeSingleRow: false, dataGridMultiRowTranspose: false, dataGridHideNullColumns: false, + numericColumnRightAlign: true, tableFontFamily: DEFAULT_DATA_GRID_FONT_FAMILY, tableFontSize: TABLE_FONT_SIZE_DEFAULT, structureEditorDensity: "compact", @@ -880,6 +882,7 @@ export function normalizeEditorSettings(settings: Partial, exist dataGridAutoTransposeSingleRow: settings.dataGridAutoTransposeSingleRow === true, dataGridMultiRowTranspose: settings.dataGridMultiRowTranspose === true, dataGridHideNullColumns: settings.dataGridHideNullColumns === true, + numericColumnRightAlign: typeof settings.numericColumnRightAlign === "boolean" ? settings.numericColumnRightAlign : DEFAULT_EDITOR_SETTINGS.numericColumnRightAlign, tableFontFamily: normalizeFontFamily(settings.tableFontFamily, DEFAULT_EDITOR_SETTINGS.tableFontFamily), tableFontSize: normalizeTableFontSize(settings.tableFontSize), structureEditorDensity: normalizeStructureEditorDensity(settings.structureEditorDensity), @@ -1255,6 +1258,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.dataGridAutoTransposeSingleRow !== undefined) editorSettings.value.dataGridAutoTransposeSingleRow = partial.dataGridAutoTransposeSingleRow === true; if (partial.dataGridMultiRowTranspose !== undefined) editorSettings.value.dataGridMultiRowTranspose = partial.dataGridMultiRowTranspose === true; if (partial.dataGridHideNullColumns !== undefined) editorSettings.value.dataGridHideNullColumns = partial.dataGridHideNullColumns === true; + if (partial.numericColumnRightAlign !== undefined) editorSettings.value.numericColumnRightAlign = partial.numericColumnRightAlign === true; if (partial.tableFontFamily !== undefined) editorSettings.value.tableFontFamily = normalizeFontFamily(partial.tableFontFamily, DEFAULT_EDITOR_SETTINGS.tableFontFamily); if (partial.tableFontSize !== undefined) editorSettings.value.tableFontSize = normalizeTableFontSize(partial.tableFontSize); if (partial.structureEditorDensity !== undefined) editorSettings.value.structureEditorDensity = normalizeStructureEditorDensity(partial.structureEditorDensity); diff --git a/crates/dbx-core/examples/table_import_bench.rs b/crates/dbx-core/examples/table_import_bench.rs index da4146b2f..cfeab5224 100644 --- a/crates/dbx-core/examples/table_import_bench.rs +++ b/crates/dbx-core/examples/table_import_bench.rs @@ -100,6 +100,7 @@ fn write_xlsx(path: &Path, row_count: usize, column_count: usize) -> Result<(), columns: columns(column_count), column_types: vec![], rows: (0..row_count).map(|row_index| row(row_index, column_count)).collect(), + numeric_column_right_align: false, })?; fs::write(path, workbook).map_err(|error| error.to_string()) } diff --git a/crates/dbx-core/src/query_result_export.rs b/crates/dbx-core/src/query_result_export.rs index a8e7ab99d..bbb772018 100644 --- a/crates/dbx-core/src/query_result_export.rs +++ b/crates/dbx-core/src/query_result_export.rs @@ -86,6 +86,8 @@ pub struct QueryResultExportRequest { pub execution_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub date_time_format: Option, + #[serde(default)] + pub numeric_column_right_align: bool, } fn safe_postgres_temp_setup_sql(setup_sql: &[String]) -> Option> { @@ -144,6 +146,7 @@ fn query_sql_worksheets(request: &QueryResultExportRequest) -> Vec( column_types, &trailing_sheets, request.date_time_format.as_deref(), + request.numeric_column_right_align, ) } @@ -1518,6 +1522,7 @@ mod tests { client_session_id: None, execution_id: None, date_time_format: None, + numeric_column_right_align: false, } } diff --git a/crates/dbx-core/src/table_export.rs b/crates/dbx-core/src/table_export.rs index 097b80e24..f063a0591 100644 --- a/crates/dbx-core/src/table_export.rs +++ b/crates/dbx-core/src/table_export.rs @@ -21,7 +21,7 @@ use crate::transfer::{ pagination_sql_with_filter_order, qualified_table, quote_identifier, }; use crate::types::QueryResult; -use crate::xlsx_export::{finish_streaming_xlsx_workbook, start_streaming_xlsx_workbook}; +use crate::xlsx_export::{finish_streaming_xlsx_workbook, start_streaming_xlsx_workbook_with_options}; const DEFAULT_BATCH_SIZE: usize = 10_000; const SQL_INSERT_BATCH_SIZE: usize = 100; @@ -59,6 +59,8 @@ pub struct TableExportRequest { pub row_limit: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub date_time_format: Option, + #[serde(default)] + pub numeric_column_right_align: bool, } #[derive(Debug, Clone, Serialize)] @@ -763,11 +765,14 @@ async fn try_export_native_table_stream( let xlsx_column_types = export_column_types(request); let xlsx_file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to create XLSX file: {e}"))?; - let mut writer = start_streaming_xlsx_workbook( + let mut writer = start_streaming_xlsx_workbook_with_options( BufWriter::new(xlsx_file), Some(&request.table_name), col_names, &xlsx_column_types, + &[], + request.date_time_format.as_deref(), + request.numeric_column_right_align, )?; let result = stream_native_table_rows( state, @@ -1387,11 +1392,14 @@ async fn export_table_data_core_inner( // sharing a file descriptor between two independent buffers. let xlsx_file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to create XLSX file: {e}"))?; - let mut writer = start_streaming_xlsx_workbook( + let mut writer = start_streaming_xlsx_workbook_with_options( BufWriter::new(xlsx_file), Some(&request.table_name), &col_names, &xlsx_column_types, + &[], + request.date_time_format.as_deref(), + request.numeric_column_right_align, )?; loop { @@ -1869,6 +1877,7 @@ mod tests { batch_size: Some(batch_size), row_limit, date_time_format: None, + numeric_column_right_align: false, }; ExternalDriverExportFixture { state, request, calls, output, dir } @@ -2029,6 +2038,7 @@ mod tests { batch_size: Some(500), row_limit: Some(1000), date_time_format: None, + numeric_column_right_align: false, }; let sql = table_cursor_sql( @@ -2077,6 +2087,7 @@ mod tests { batch_size: Some(100), row_limit: None, date_time_format: None, + numeric_column_right_align: false, }; let sql = table_cursor_sql(&request, &DatabaseType::Oracle, &columns, &primary_keys); assert_eq!(sql, "SELECT \"ID\", \"NAME\" FROM \"APP\".\"USERS\""); @@ -2395,6 +2406,7 @@ mod tests { vec![json!(2), json!("Bob"), json!(82000)], vec![json!(3), Value::Null, json!(0)], ], + numeric_column_right_align: false, }; let workbook = build_xlsx_workbook(&data).expect("XLSX build should succeed"); diff --git a/crates/dbx-core/src/table_import.rs b/crates/dbx-core/src/table_import.rs index 542884705..326e14c6f 100644 --- a/crates/dbx-core/src/table_import.rs +++ b/crates/dbx-core/src/table_import.rs @@ -5860,12 +5860,14 @@ mod tests { columns: vec!["id".to_string()], column_types: vec![], rows: vec![vec![serde_json::json!(1)]], + numeric_column_right_align: false, }, XlsxWorksheetData { sheet_name: Some("Second".to_string()), columns: vec!["name".to_string()], column_types: vec![], rows: vec![vec![serde_json::json!("Ada")]], + numeric_column_right_align: false, }, ]) .unwrap(); @@ -5902,12 +5904,14 @@ mod tests { columns: vec!["id".to_string()], column_types: vec![], rows: vec![vec![serde_json::json!(1)]], + numeric_column_right_align: false, }, XlsxWorksheetData { sheet_name: Some("Second".to_string()), columns: vec!["name".to_string()], column_types: vec![], rows: vec![vec![serde_json::json!("Ada")], vec![serde_json::json!("Grace")]], + numeric_column_right_align: false, }, ]) .unwrap(); @@ -6052,6 +6056,7 @@ mod tests { vec![serde_json::json!(1), serde_json::json!("Ada")], vec![serde_json::json!(2), serde_json::json!("Grace")], ], + numeric_column_right_align: false, }]) .unwrap(); std::fs::write(&path, workbook).unwrap(); @@ -6409,6 +6414,7 @@ mod tests { vec![serde_json::json!(2), serde_json::json!("Grace")], vec![serde_json::json!("summary"), serde_json::json!(2)], ], + numeric_column_right_align: false, }]) .unwrap(); std::fs::write(&path, workbook).unwrap(); @@ -6832,6 +6838,7 @@ mod tests { vec![serde_json::json!(2), serde_json::json!("Grace")], vec![serde_json::json!("summary"), serde_json::json!(2)], ], + numeric_column_right_align: false, }]) .unwrap(); std::fs::write(&path, workbook).unwrap(); diff --git a/crates/dbx-core/src/xlsx_export.rs b/crates/dbx-core/src/xlsx_export.rs index 8b3ba2954..ea78ef5d9 100644 --- a/crates/dbx-core/src/xlsx_export.rs +++ b/crates/dbx-core/src/xlsx_export.rs @@ -6,6 +6,8 @@ use crate::temporal_format::{excel_temporal_serial, ExcelTemporalKind}; const XLSX_DATE_STYLE: usize = 2; const XLSX_DATETIME_STYLE: usize = 3; +const NUMERIC_RIGHT_ALIGN_STYLE: usize = 4; +const NUMERIC_LEFT_ALIGN_STYLE: usize = 5; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -15,6 +17,8 @@ pub struct XlsxWorksheetData { #[serde(default)] pub column_types: Vec, pub rows: Vec>, + #[serde(default)] + pub numeric_column_right_align: bool, } /// Streaming XLSX writer that incrementally writes rows to a ZIP-backed @@ -27,6 +31,7 @@ pub struct StreamingXlsxWriter { next_row_number: usize, trailing_sheets: Vec, date_time_format: Option, + numeric_right_align: bool, } /// Estimate column widths from header names only (used by the streaming path @@ -65,19 +70,15 @@ fn data_row_xml_with_date_time_format( column_types: &[String], row: &[Value], date_time_format: Option<&str>, + numeric_right_align: bool, ) -> String { let cells = columns .iter() .enumerate() .map(|(col_index, _)| { - typed_cell_xml( - row.get(col_index), - column_types.get(col_index), - row_number - 1, - col_index, - None, - date_time_format, - ) + let col_type = column_types.get(col_index); + let align_style = numeric_column_style(col_type, numeric_right_align); + typed_cell_xml(row.get(col_index), col_type, row_number - 1, col_index, align_style, date_time_format) }) .collect::(); format!("{cells}") @@ -99,13 +100,14 @@ fn write_zip_entry(zip: &mut zip::ZipWriter, path: &str, con /// column widths (estimated from header names) and the header row are written /// immediately. Callers then feed data rows via [`StreamingXlsxWriter::write_row`] /// and finalize with [`StreamingXlsxWriter::finish`]. +#[cfg(test)] pub(crate) fn start_streaming_xlsx_workbook( writer: W, sheet_name: Option<&str>, columns: &[String], column_types: &[String], ) -> Result, String> { - start_streaming_xlsx_workbook_with_options(writer, sheet_name, columns, column_types, &[], None) + start_streaming_xlsx_workbook_with_options(writer, sheet_name, columns, column_types, &[], None, false) } #[cfg(test)] @@ -116,7 +118,7 @@ pub(crate) fn start_streaming_xlsx_workbook_with_trailing_sheets Result, String> { - start_streaming_xlsx_workbook_with_options(writer, sheet_name, columns, column_types, trailing_sheets, None) + start_streaming_xlsx_workbook_with_options(writer, sheet_name, columns, column_types, trailing_sheets, None, false) } pub(crate) fn start_streaming_xlsx_workbook_with_options( @@ -126,12 +128,14 @@ pub(crate) fn start_streaming_xlsx_workbook_with_options( column_types: &[String], trailing_sheets: &[XlsxWorksheetData], date_time_format: Option<&str>, + numeric_right_align: bool, ) -> Result, String> { let primary_sheet = XlsxWorksheetData { sheet_name: sheet_name.map(str::to_string), columns: columns.to_vec(), column_types: column_types.to_vec(), rows: Vec::new(), + numeric_column_right_align: numeric_right_align, }; let all_sheets = std::iter::once(primary_sheet).chain(trailing_sheets.iter().cloned()).collect::>(); let sheet_names = normalize_unique_sheet_names(&all_sheets); @@ -173,6 +177,7 @@ pub(crate) fn start_streaming_xlsx_workbook_with_options( next_row_number: 2, trailing_sheets: trailing_sheets.to_vec(), date_time_format: date_time_format.map(str::to_string), + numeric_right_align, }) } @@ -187,6 +192,7 @@ impl StreamingXlsxWriter { &self.column_types, row, self.date_time_format.as_deref(), + self.numeric_right_align, ) .as_bytes(), ) @@ -326,20 +332,39 @@ fn cell_xml(value: Option<&Value>, row_index: usize, col_index: usize, style: Op } fn is_numeric_column_type(column_type: Option<&String>) -> bool { - let normalized = column_type.map(|value| value.trim().to_ascii_lowercase()).unwrap_or_default(); + let mut normalized = column_type.map(|value| value.trim().to_ascii_lowercase()).unwrap_or_default(); + while normalized.ends_with(')') { + let Some(open_index) = normalized.find('(') else { + break; + }; + if !matches!(normalized[..open_index].trim(), "nullable" | "lowcardinality") { + break; + } + normalized = normalized[open_index + 1..normalized.len() - 1].trim().to_string(); + } let base = normalized.split(['(', ' ', '[']).next().unwrap_or_default(); matches!( base, - "bit" - | "tinyint" + "tinyint" | "smallint" | "mediumint" | "int" | "integer" | "bigint" + | "serial" + | "smallserial" + | "bigserial" | "int2" | "int4" | "int8" + | "int1" + | "int16" + | "int32" + | "int64" + | "int128" + | "int256" + | "intn" + | "uint" | "uint8" | "uint16" | "uint32" @@ -349,18 +374,44 @@ fn is_numeric_column_type(column_type: Option<&String>) -> bool { | "float" | "float4" | "float8" + | "float16" | "float32" | "float64" + | "floatn" | "real" | "double" | "decimal" + | "decimal32" + | "decimal64" + | "decimal128" + | "decimal256" + | "decimaln" | "numeric" + | "numericn" | "number" + | "dec" + | "fixed" | "money" + | "money4" + | "moneyn" | "smallmoney" + | "smallmoneyn" + | "binary_float" + | "binary_double" ) } +fn numeric_column_style(column_type: Option<&String>, enabled: bool) -> Option { + if !is_numeric_column_type(column_type) { + return None; + } + if enabled { + Some(NUMERIC_RIGHT_ALIGN_STYLE) + } else { + Some(NUMERIC_LEFT_ALIGN_STYLE) + } +} + fn safe_excel_number(value: &str) -> Option<&str> { let trimmed = value.trim(); if trimmed.is_empty() || trimmed.parse::().ok().is_none_or(|number| !number.is_finite()) { @@ -444,14 +495,9 @@ fn worksheet_xml(data: &XlsxWorksheetData) -> String { .iter() .enumerate() .map(|(col_index, _)| { - typed_cell_xml( - row.get(col_index), - data.column_types.get(col_index), - excel_row - 1, - col_index, - None, - None, - ) + let col_type = data.column_types.get(col_index); + let align_style = numeric_column_style(col_type, data.numeric_column_right_align); + typed_cell_xml(row.get(col_index), col_type, excel_row - 1, col_index, align_style, None) }) .collect::(); format!("{cells}") @@ -634,7 +680,7 @@ fn styles_xml(date_time_format: Option<&str>) -> String { "", "", "", - "", + "", "", "" ), @@ -697,12 +743,12 @@ pub fn build_xlsx_workbook_multi(sheets: &[XlsxWorksheetData]) -> Result #[cfg(test)] mod tests { use super::{ - build_xlsx_workbook, build_xlsx_workbook_multi, start_streaming_xlsx_workbook, + build_xlsx_workbook, build_xlsx_workbook_multi, is_numeric_column_type, start_streaming_xlsx_workbook, start_streaming_xlsx_workbook_with_options, start_streaming_xlsx_workbook_with_trailing_sheets, XlsxWorksheetData, }; use calamine::{open_workbook_auto, Reader}; - use serde_json::json; + use serde_json::{json, Value}; use std::fs; use std::io::Read; @@ -737,6 +783,7 @@ mod tests { columns: vec!["id".to_string(), "name".to_string(), "active".to_string()], column_types: vec![], rows: vec![vec![json!(1), json!("Ada & Bob"), json!(true)], vec![json!(2), json!(null), json!(false)]], + numeric_column_right_align: false, }) .expect("build workbook"); @@ -761,12 +808,13 @@ mod tests { columns: vec!["quantity".to_string(), "amount".to_string(), "code".to_string()], column_types: vec!["decimal(10,5)".to_string(), "numeric".to_string(), "varchar".to_string()], rows: vec![vec![json!("1.00000"), json!("2800.000000"), json!("00123")]], + numeric_column_right_align: false, }) .expect("build workbook"); let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml"); - assert!(sheet.contains("1.00000")); - assert!(sheet.contains("2800.000000")); + assert!(sheet.contains("1.00000")); + assert!(sheet.contains("2800.000000")); assert!(sheet.contains("00123")); } @@ -798,6 +846,7 @@ mod tests { json!("2800.000000"), json!("2024-02-25T13:02:15+08:00"), ]], + numeric_column_right_align: false, }) .expect("build workbook"); @@ -807,7 +856,7 @@ mod tests { assert!(sheet.contains("45347.543229166666")); assert!(sheet.contains("2024-02-25")); assert!(sheet.contains("not-a-date")); - assert!(sheet.contains("2800.000000")); + assert!(sheet.contains("2800.000000")); assert!(sheet.contains("2024-02-25T13:02:15+08:00")); assert!(styles.contains("numFmtId=\"164\" formatCode=\"yyyy-mm-dd\"")); assert!(styles.contains("numFmtId=\"165\" formatCode=\"yyyy-mm-dd hh:mm:ss\"")); @@ -847,6 +896,7 @@ mod tests { json!("987654.321"), json!("2800.000000"), ]], + numeric_column_right_align: false, }) .expect("build workbook"); @@ -861,7 +911,7 @@ mod tests { ("G2", "987654.321"), ("H2", "2800.000000"), ] { - assert!(sheet.contains(&format!("{value}")), "sheet={sheet}"); + assert!(sheet.contains(&format!("{value}")), "sheet={sheet}"); } } @@ -872,6 +922,7 @@ mod tests { columns: vec!["large_id".to_string(), "precise_amount".to_string()], column_types: vec!["bigint".to_string(), "decimal(30,10)".to_string()], rows: vec![vec![json!("9223372036854775807"), json!("123456789012345.6789000000")]], + numeric_column_right_align: false, }) .expect("build workbook"); @@ -887,6 +938,7 @@ mod tests { columns: vec!["value".to_string()], column_types: vec![], rows: vec![vec![json!("ok")]], + numeric_column_right_align: false, }) .expect("build workbook"); let workbook_xml = read_zip_entry(&workbook, "xl/workbook.xml"); @@ -902,12 +954,14 @@ mod tests { columns: vec!["id".to_string()], column_types: vec![], rows: vec![vec![json!(1)]], + numeric_column_right_align: false, }, XlsxWorksheetData { sheet_name: Some("Result 2".to_string()), columns: vec!["name".to_string()], column_types: vec![], rows: vec![vec![json!("Ada")]], + numeric_column_right_align: false, }, ]) .expect("build multi-sheet workbook"); @@ -959,9 +1013,9 @@ mod tests { let bytes = fs::read(&path).expect("read workbook"); let sheet = read_zip_entry(&bytes, "xl/worksheets/sheet1.xml"); - assert!(sheet.contains("42")); - assert!(sheet.contains("123.5")); - assert!(sheet.contains("2800.000000")); + assert!(sheet.contains("42")); + assert!(sheet.contains("123.5")); + assert!(sheet.contains("2800.000000")); let _ = fs::remove_file(&path); } @@ -979,6 +1033,7 @@ mod tests { &column_types, &[], Some("YYYY/MM/DD HH:mm:ss.SSS"), + false, ) .expect("start workbook"); writer.write_row(&[json!("2024/02/25 13:02:15.125")]).expect("write temporal row"); @@ -1003,6 +1058,7 @@ mod tests { columns: vec!["SQL".to_string()], column_types: vec![], rows: vec![vec![json!("SELECT id, name FROM users")]], + numeric_column_right_align: false, }; let mut writer = start_streaming_xlsx_workbook_with_trailing_sheets( file, @@ -1024,4 +1080,95 @@ mod tests { assert_eq!(sql.get_value((1, 0)), Some(&calamine::Data::String("SELECT id, name FROM users".to_string()))); let _ = fs::remove_file(path); } + + #[test] + fn numeric_right_align_enabled_applies_style_4() { + let workbook = build_xlsx_workbook(&XlsxWorksheetData { + sheet_name: Some("Aligned".to_string()), + columns: vec!["amount".to_string(), "label".to_string()], + column_types: vec!["decimal(10,2)".to_string(), "varchar(50)".to_string()], + rows: vec![vec![json!(1.5), json!("row")]], + numeric_column_right_align: true, + }) + .expect("build workbook"); + let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml"); + assert!(sheet.contains(r#"1.5"#), "sheet={sheet}"); + // Text column B should NOT have right-align style s="4" + assert!(!sheet.contains(r#"1.5"#), "sheet={sheet}"); + assert!(!sheet.contains(r#"s="4""#)); + } + + #[test] + fn numeric_right_align_applies_across_database_numeric_types() { + // Ensures the Rust classifier covers the same cross-database numeric + // types as the frontend isNumericColumnType (ClickHouse wide integers, + // Oracle/Dameng binary floats, SQL Server internal type names, etc.). + let column_types = vec![ + "Int16".to_string(), + "Int32".to_string(), + "Int64".to_string(), + "Int128".to_string(), + "UInt256".to_string(), + "Decimal128(18, 2)".to_string(), + "Float16".to_string(), + "BINARY_FLOAT".to_string(), + "BINARY_DOUBLE".to_string(), + "decimaln".to_string(), + "numericn".to_string(), + "intn".to_string(), + "floatn".to_string(), + "moneyn".to_string(), + "smallmoneyn".to_string(), + "varchar(50)".to_string(), + ]; + let row: Vec = column_types.iter().map(|_| json!(1)).collect::>(); + let workbook = build_xlsx_workbook(&XlsxWorksheetData { + sheet_name: Some("CrossDb".to_string()), + columns: column_types.iter().map(|t| t.to_lowercase()).collect(), + column_types: column_types.clone(), + rows: vec![row], + numeric_column_right_align: true, + }) + .expect("build workbook"); + let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml"); + for (index, column_type) in column_types.iter().take(column_types.len() - 1).enumerate() { + let col_letter = (b'A' + index as u8) as char; + let cell = format!(r#"1"#); + assert!(sheet.contains(&cell), "missing right-align style for {column_type} (cell={cell})"); + } + // Text column (last) must not receive the numeric right-align style. + let last_letter = (b'A' + column_types.len() as u8 - 1) as char; + assert!(!sheet.contains(&format!(r#" { + const ctx = measureContext(); + + assert.equal(fitCanvasText(ctx, "1234567890", 8, "right"), "...67890"); +}); + +test("canvas text layout reserves hover actions only for right-aligned cells", () => { + assert.deepEqual(resolveCanvasCellTextLayout({ drawX: 100, colWidth: 80, dpr: 1, isRightAlign: true, reservedWidth: 28 }), { + textAnchorX: 140, + maxWidth: 28, + }); + assert.deepEqual(resolveCanvasCellTextLayout({ drawX: 100, colWidth: 80, dpr: 1, isRightAlign: false, reservedWidth: 28 }), { + textAnchorX: 112, + maxWidth: 56, + }); + assert.equal(canvasDataGridActionReservedWidth(false), 28); + assert.equal(canvasDataGridActionReservedWidth(true), 50); +}); + +test("DataGrid forwards hover action reservation only for right-aligned canvas cells", () => { + const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + + assert.match(source, /columnAligns\.value\[cell\.visibleColIdx\] !== "right"/); + assert.match(source, /reservedWidth: canvasDataGridActionReservedWidth\(cell\.canQuickDownload\)/); + assert.match(source, /rightAlignedActionCell: canvasRightAlignedActionCell\.value/); +}); + test("canvas row fill keeps frozen and scrolling regions on the same selection surface", () => { const theme = { cellActive: "active-blue", cellSelected: "selected-blue" }; diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index 9be26e3bb..055ce0a79 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -1,4 +1,4 @@ -import { test, vi } from "vitest"; +import { beforeEach, test, vi } from "vitest"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { createPinia, setActivePinia } from "pinia"; @@ -8,8 +8,26 @@ import { DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, SYSTEM_UI_FONT_F import { tableOpenPageLimit } from "../../apps/desktop/src/lib/table/tableOpenPageLimit.ts"; import { AI_PROVIDER_PRESETS, DEFAULT_EDITOR_SETTINGS, EXECUTE_MODE_CURRENT_DEFAULT_VERSION, normalizeAiConfig, normalizeEditorSettings, useSettingsStore } from "../../apps/desktop/src/stores/settingsStore.ts"; +const saveEditorSettingsMock = vi.hoisted(() => vi.fn()); +vi.mock("../../apps/desktop/src/lib/backend/api", async (importOriginal) => { + const actual = await importOriginal(); + // Wrap the real saveEditorSettings so existing localStorage-based tests + // keep working, while allowing new tests to assert on the persisted payload. + saveEditorSettingsMock.mockImplementation(async (settings: unknown) => { + await actual.saveEditorSettings(settings); + }); + return { + ...actual, + saveEditorSettings: saveEditorSettingsMock, + }; +}); + const OLD_FONT_SIZE_KEY = "dbx-query-editor-font-size"; +beforeEach(() => { + saveEditorSettingsMock.mockClear(); +}); + async function withMockLocalStorage(initial: Record, run: () => void | Promise) { const previousDescriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); const values = new Map(Object.entries(initial)); @@ -59,6 +77,41 @@ test("normalizes the dedicated default row limit for table opens", () => { assert.equal(tableOpenPageLimit(0), 100); }); +test("numericColumnRightAlign defaults to true and round-trips through normalizeEditorSettings", () => { + assert.equal(DEFAULT_EDITOR_SETTINGS.numericColumnRightAlign, true); + assert.equal(normalizeEditorSettings({}).numericColumnRightAlign, true); + assert.equal(normalizeEditorSettings({ numericColumnRightAlign: false }).numericColumnRightAlign, false); + assert.equal(normalizeEditorSettings({ numericColumnRightAlign: true }).numericColumnRightAlign, true); + // Non-boolean values fall back to the default. + assert.equal(normalizeEditorSettings({ numericColumnRightAlign: undefined }).numericColumnRightAlign, true); + assert.equal(normalizeEditorSettings({ numericColumnRightAlign: "false" as unknown as boolean }).numericColumnRightAlign, true); +}); + +test("updateEditorSettings persists numericColumnRightAlign toggles", async () => { + await withMockLocalStorage({}, async () => { + setActivePinia(createPinia()); + const store = useSettingsStore(); + await store.initEditorSettings(); + assert.equal(store.editorSettings.numericColumnRightAlign, true); + + store.updateEditorSettings({ numericColumnRightAlign: false }); + assert.equal(store.editorSettings.numericColumnRightAlign, false); + // updateEditorSettings schedules a background save via api.saveEditorSettings + // with a snapshot of the current settings. + await vi.waitFor(() => { + const lastCall = saveEditorSettingsMock.mock.calls.at(-1)?.[0] as { numericColumnRightAlign?: boolean } | undefined; + assert.equal(lastCall?.numericColumnRightAlign, false); + }); + + store.updateEditorSettings({ numericColumnRightAlign: true }); + assert.equal(store.editorSettings.numericColumnRightAlign, true); + await vi.waitFor(() => { + const lastCall = saveEditorSettingsMock.mock.calls.at(-1)?.[0] as { numericColumnRightAlign?: boolean } | undefined; + assert.equal(lastCall?.numericColumnRightAlign, true); + }); + }); +}); + test("migrates legacy execute-all settings to current once and preserves later explicit choices", async () => { await withMockLocalStorage({ "dbx-app-state:editor_settings": JSON.stringify({ executeMode: "all" }) }, async () => { setActivePinia(createPinia()); diff --git a/packages/app-tests/useDataGridExport.test.ts b/packages/app-tests/useDataGridExport.test.ts index 14ab303ea..2ac12bbb6 100644 --- a/packages/app-tests/useDataGridExport.test.ts +++ b/packages/app-tests/useDataGridExport.test.ts @@ -391,7 +391,7 @@ test("complete local query result XLSX export does not re-execute the query", as assert.equal(fullExportResult.mock.calls.length, 0); assert.equal(queryResultExportRequest.mock.calls.length, 0); assert.equal(apiMock.startQueryResultExport.mock.calls.length, 0); - assert.deepEqual(apiMock.exportQueryResultXlsx.mock.calls[0]?.slice(1), ["Export", ["id", "name"], ["int4", "text"], completeLocalResult.rows]); + assert.deepEqual(apiMock.exportQueryResultXlsx.mock.calls[0]?.slice(1, 5), ["Export", ["id", "name"], ["int4", "text"], completeLocalResult.rows]); }); test("MySQL joined query SQL export keeps result aliases instead of source column names", async () => { @@ -474,7 +474,7 @@ test("complete local query result export removes only internal hidden columns", await composable.exportXlsx(); - assert.deepEqual(apiMock.exportQueryResultXlsx.mock.calls[0]?.slice(1), [ + assert.deepEqual(apiMock.exportQueryResultXlsx.mock.calls[0]?.slice(1, 5), [ "Export", ["id", "name"], ["int4", "text"], @@ -651,6 +651,35 @@ test("selected query result XLSX export uses the current source label as the she assert.deepEqual(apiMock.exportQueryResultXlsx.mock.calls[0][4], [[1, "Ada"]]); }); +test("selected query result XLSX export forwards the numericColumnRightAlign setting to the backend", async () => { + const settingsStore = useSettingsStore(); + settingsStore.updateEditorSettings({ numericColumnRightAlign: false }); + const { composable } = buildExportHarness({ columnTypes: ["bigint(20)", "varchar(64)"] }); + + await composable.exportXlsx([1]); + + assert.equal(apiMock.exportQueryResultXlsx.mock.calls.length, 1); + // Argument 5 is `numericColumnRightAlign`, and must reflect the persisted + // setting rather than always defaulting to true. + assert.equal(apiMock.exportQueryResultXlsx.mock.calls[0][5], false); + + settingsStore.updateEditorSettings({ numericColumnRightAlign: true }); + await composable.exportXlsx([1]); + assert.equal(apiMock.exportQueryResultXlsx.mock.calls[1][5], true); +}); + +test("streaming query result XLSX export carries numericColumnRightAlign in the backend request", async () => { + const settingsStore = useSettingsStore(); + settingsStore.updateEditorSettings({ numericColumnRightAlign: false }); + const { composable, queryResultExportRequest } = buildExportHarness(); + + await composable.exportXlsxWithSql(); + + assert.equal(queryResultExportRequest.mock.calls.length, 1); + assert.equal(apiMock.startQueryResultExport.mock.calls.length, 1); + assert.equal(apiMock.startQueryResultExport.mock.calls[0][0].numericColumnRightAlign, false); +}); + test("streaming XLSX with SQL marks the backend request as opt in", async () => { const { composable, queryResultExportRequest } = buildExportHarness(); diff --git a/packages/app-tests/xlsxExport.test.ts b/packages/app-tests/xlsxExport.test.ts index 08c563ea9..e5b1f6f6e 100644 --- a/packages/app-tests/xlsxExport.test.ts +++ b/packages/app-tests/xlsxExport.test.ts @@ -41,14 +41,15 @@ test("writes MySQL 5.7 numeric strings as numeric cells", () => { columns: ["nullable_int", "float_value", "double_value", "decimal_value", "bigint_high_precision"], columnTypes: ["int(11)", "float", "double", "decimal(18,6)", "bigint(20)"], rows: [["42", "123.5", "987654.321", "2800.000000", "9007199254740992"]], + numericColumnRightAlign: false, }); const text = new TextDecoder().decode(workbook); - assert.match(text, /42<\/v><\/c>/); - assert.match(text, /123\.5<\/v><\/c>/); - assert.match(text, /987654\.321<\/v><\/c>/); - assert.match(text, /2800\.000000<\/v><\/c>/); - assert.match(text, /9007199254740992<\/t><\/is><\/c>/); + assert.match(text, /42<\/v><\/c>/); + assert.match(text, /123\.5<\/v><\/c>/); + assert.match(text, /987654\.321<\/v><\/c>/); + assert.match(text, /2800\.000000<\/v><\/c>/); + assert.match(text, /9007199254740992<\/t><\/is><\/c>/); }); test("builds a result workbook with a separate SQL worksheet", () => { @@ -81,3 +82,105 @@ test("maps multiple result statements and splits SQL at the Excel cell limit", ( assert.equal(longSqlRows[1][1], "😀tail"); assert.equal(longSqlRows.map((row) => row[1]).join(""), longSql); }); + +test("numericColumnRightAlign: true applies right-align style to numeric columns", () => { + const workbook = buildXlsxWorkbook({ + sheetName: "Aligned", + columns: ["amount", "label"], + columnTypes: ["decimal(10,2)", "varchar(50)"], + rows: [[1.5, "row"]], + numericColumnRightAlign: true, + }); + const text = new TextDecoder().decode(workbook); + // Numeric column A should have right-align style (s="2") + assert.match(text, /1\.5<\/v><\/c>/); + // Text column B should NOT have right-align style + assert.doesNotMatch(text, /]* s="2"/); +}); + +test("numericColumnRightAlign: false applies left-align style to numeric columns", () => { + const workbook = buildXlsxWorkbook({ + sheetName: "Disabled", + columns: ["amount", "label"], + columnTypes: ["decimal(10,2)", "varchar(50)"], + rows: [[1.5, "row"]], + numericColumnRightAlign: false, + }); + const text = new TextDecoder().decode(workbook); + // Numeric column should have left-align style (s="3"), not right-align (s="2") + assert.match(text, /1\.5<\/v><\/c>/); + assert.doesNotMatch(text, /]* s="2"/); +}); + +test("numericColumnRightAlign defaults to true when omitted", () => { + // Backwards compatibility: existing callers that do not pass the flag must + // keep producing right-aligned numeric cells. + const workbook = buildXlsxWorkbook({ + sheetName: "Default", + columns: ["amount", "label"], + columnTypes: ["decimal(10,2)", "varchar(50)"], + rows: [[1.5, "row"]], + }); + const text = new TextDecoder().decode(workbook); + assert.match(text, /1\.5<\/v><\/c>/); + assert.doesNotMatch(text, /]* s="2"/); +}); + +test("numeric right-align style is applied consistently across cross-database numeric types", () => { + // Ensures the front-end XLSX classifier covers the same cross-database + // numeric types as the Rust classifier and the grid (ClickHouse wide + // integers, Oracle/Dameng binary floats, SQL Server internal names, etc.). + const columnTypes = [ + "Int16", + "Int32", + "Int64", + "Int128", + "UInt256", + "Decimal128(18, 2)", + "Float16", + "BINARY_FLOAT", + "BINARY_DOUBLE", + "decimaln", + "numericn", + "intn", + "floatn", + "moneyn", + "smallmoneyn", + "varchar(50)", + ]; + const workbook = buildXlsxWorkbook({ + sheetName: "CrossDb", + columns: columnTypes.map((t) => t.toLowerCase()), + columnTypes, + rows: [columnTypes.map(() => 1)], + numericColumnRightAlign: true, + }); + const text = new TextDecoder().decode(workbook); + const letters = "ABCDEFGHIJKLMNOP"; + columnTypes.slice(0, -1).forEach((_, index) => { + const ref = `${letters[index]}2`; + assert.match(text, new RegExp(`1`), `expected right-align style for ${columnTypes[index]}`); + }); + // Text column (last) must not receive the numeric right-align style. + assert.doesNotMatch(text, /]* s="2"/); +}); + +test("numeric right-align disabled applies left-align style across cross-database numeric types", () => { + const columnTypes = ["Int16", "Int64", "Int128", "Decimal128(18, 2)", "BINARY_FLOAT", "decimaln", "varchar(50)"]; + const workbook = buildXlsxWorkbook({ + sheetName: "CrossDbDisabled", + columns: columnTypes.map((t) => t.toLowerCase()), + columnTypes, + rows: [columnTypes.map(() => 1)], + numericColumnRightAlign: false, + }); + const text = new TextDecoder().decode(workbook); + // All numeric columns must use left-align (s="3") to override Excel's + // default right alignment for number cells. + const letters = "ABCDEFG"; + columnTypes.slice(0, -1).forEach((_, index) => { + const ref = `${letters[index]}2`; + assert.match(text, new RegExp(`1`), `expected left-align style for ${columnTypes[index]}`); + }); + assert.doesNotMatch(text, /s="2"/); +}); diff --git a/src-tauri/src/commands/xlsx_export.rs b/src-tauri/src/commands/xlsx_export.rs index a7e52c9b2..9fc862bb3 100644 --- a/src-tauri/src/commands/xlsx_export.rs +++ b/src-tauri/src/commands/xlsx_export.rs @@ -11,6 +11,8 @@ pub struct QueryResultXlsxExportRequest { #[serde(default)] pub column_types: Vec, pub rows: Vec>, + #[serde(default)] + pub numeric_column_right_align: bool, } #[derive(Debug, Clone, Deserialize)] @@ -23,12 +25,18 @@ pub struct QueryResultsXlsxExportRequest { #[tauri::command] pub async fn export_query_result_xlsx(request: QueryResultXlsxExportRequest) -> Result<(), String> { tauri::async_runtime::spawn_blocking(move || { - let workbook = build_xlsx_workbook(&XlsxWorksheetData { + let mut data = XlsxWorksheetData { sheet_name: request.sheet_name, columns: request.columns, column_types: request.column_types, rows: request.rows, - })?; + numeric_column_right_align: request.numeric_column_right_align, + }; + // Ensure consistency: if the feature is disabled, clear the flag. + if !data.numeric_column_right_align { + data.numeric_column_right_align = false; + } + let workbook = build_xlsx_workbook(&data)?; std::fs::write(&request.file_path, workbook).map_err(|err| err.to_string()) }) .await diff --git a/tests/fixtures/data-grid-numeric-column-types.json b/tests/fixtures/data-grid-numeric-column-types.json new file mode 100644 index 000000000..fa455d030 --- /dev/null +++ b/tests/fixtures/data-grid-numeric-column-types.json @@ -0,0 +1,17 @@ +{ + "numeric": [ + { "backend": "sqlserver-native", "type": "int1" }, + { "backend": "sqlserver-native", "type": "money4" }, + { "backend": "sqlserver-native", "type": "intn" }, + { "backend": "sqlserver-native", "type": "decimaln" }, + { "backend": "clickhouse-http", "type": "Nullable(Int64)" }, + { "backend": "clickhouse-http", "type": "LowCardinality(UInt32)" }, + { "backend": "clickhouse-http", "type": "Nullable(LowCardinality(Decimal(18, 2)))" } + ], + "nonNumeric": [ + { "backend": "sqlserver-native", "type": "bit" }, + { "backend": "clickhouse-http", "type": "Nullable(DateTime64(3))" }, + { "backend": "clickhouse-http", "type": "LowCardinality(String)" }, + { "backend": "clickhouse-http", "type": "Array(Int64)" } + ] +}