feat(grid): right-align numeric result columns

This commit is contained in:
gggaiitx 2026-07-27 18:54:54 +08:00 committed by GitHub
parent 66c768061a
commit e2b35944ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1082 additions and 109 deletions

View File

@ -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<ContextMenuItem[]>(() => {
: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],

View File

@ -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
</div>
</LightTooltip>
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div class="min-w-0 flex items-center gap-2 font-medium">
<component :is="numericColumnRightAlign ? AlignRight : AlignLeft" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>{{ t("grid.numericColumnAlign") }}</span>
</div>
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
<button
type="button"
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
:class="!numericColumnRightAlign ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="setNumericColumnRightAlign(false)"
>
{{ t("grid.numericColumnAlignLeft") }}
</button>
<button
type="button"
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
:class="numericColumnRightAlign ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="setNumericColumnRightAlign(true)"
>
{{ t("grid.numericColumnAlignRight") }}
</button>
</div>
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs" :class="{ 'opacity-60': !dataGridRef?.canToggleAllNullColumns }">
<span class="min-w-0 flex items-center gap-2 font-medium">
<EyeOff class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@ -1597,6 +1627,30 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
</div>
</LightTooltip>
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div class="min-w-0 flex items-center gap-2 font-medium">
<component :is="numericColumnRightAlign ? AlignRight : AlignLeft" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>{{ t("grid.numericColumnAlign") }}</span>
</div>
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
<button
type="button"
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
:class="!numericColumnRightAlign ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="setNumericColumnRightAlign(false)"
>
{{ t("grid.numericColumnAlignLeft") }}
</button>
<button
type="button"
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
:class="numericColumnRightAlign ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="setNumericColumnRightAlign(true)"
>
{{ t("grid.numericColumnAlignRight") }}
</button>
</div>
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs" :class="{ 'opacity-60': !dataGridRef?.canToggleAllNullColumns }">
<span class="min-w-0 flex items-center gap-2 font-medium">
<EyeOff class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />

View File

@ -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) {

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -1288,6 +1288,9 @@ export default withEnglishFallback({
},
cachedResultUnavailable: "キャッシュ結果が不足しているか互換性がありません。",
reexecuteQuery: "クエリを再実行",
numericColumnAlign: "数値列の配置",
numericColumnAlignLeft: "左揃え",
numericColumnAlignRight: "右揃え",
},
exportProgress: {
title: "テーブルデータをエクスポート中",

View File

@ -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",

View File

@ -1153,6 +1153,9 @@ export default withEnglishFallback({
searchModeHint: "选择 Ctrl+F 搜索时过滤匹配行,或保留全部行并高亮定位命中项。",
searchModeFilter: "过滤",
searchModeHighlight: "高亮",
numericColumnAlign: "数值列对齐",
numericColumnAlignLeft: "左对齐",
numericColumnAlignRight: "右对齐",
moreValues: "还有 {count} 个值,输入关键词继续缩小范围",
filterByValue: "筛选此值",
filterExcludeValue: "排除此值",

View File

@ -1288,6 +1288,9 @@ export default withEnglishFallback({
},
cachedResultUnavailable: "快取結果缺失或不相容。",
reexecuteQuery: "重新執行查詢",
numericColumnAlign: "數值列對齊",
numericColumnAlignLeft: "左對齊",
numericColumnAlignRight: "右對齊",
},
exportProgress: {
title: "匯出資料表資料",

View File

@ -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<string, string[]> = {
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<string | undefined>, 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"]);
});
});

View File

@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { isNumericColumnType, resolveResultColumnType } from "@/lib/dataGrid/dataGridColumnType";
describe("resolveResultColumnType", () => {
function tableTypes(entries: Record<string, string>): Map<string, string> {
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);
});
});

View File

@ -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<void> {
export async function exportQueryResultXlsx(filePath: string, sheetName: string | undefined, columns: string[], columnTypes: string[], rows: readonly (readonly XlsxCellValue[])[], numericColumnRightAlign?: boolean): Promise<void> {
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<void> {
export async function exportQueryResultsXlsx(filePath: string, worksheets: readonly { sheetName?: string; columns: readonly string[]; columnTypes?: readonly string[]; rows: readonly (readonly XlsxCellValue[])[]; numericColumnRightAlign?: boolean }[]): Promise<void> {
const { buildXlsxWorkbookMulti } = await import("@/lib/export/xlsxExport");
const workbook = buildXlsxWorkbookMulti(worksheets);
const fileName = filePath.split(/[\\/]/).pop() || "export.xlsx";

View File

@ -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<TableExportProgress> {
@ -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<void> {
export async function exportQueryResultXlsx(filePath: string, sheetName: string | undefined, columns: string[], columnTypes: string[], rows: readonly (readonly XlsxCellValue[])[], numericColumnRightAlign?: boolean): Promise<void> {
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<void> {
export async function exportQueryResultsXlsx(filePath: string, worksheets: readonly { sheetName?: string; columns: readonly string[]; columnTypes?: readonly string[]; rows: readonly (readonly XlsxCellValue[])[]; numericColumnRightAlign?: boolean }[]): Promise<void> {
return invoke("export_query_results_xlsx", {
request: {
filePath,

View File

@ -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();

View File

@ -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<string, string>;
}
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<string, string> = 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);
}

View File

@ -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) => `<col min="${index + 1}" max="${index + 1}" width="${width}" customWidth="1"/>`).join("");
const headerXml = `<row r="1">${columns.map((column, index) => cellXml(column, 0, index, 1)).join("")}</row>`;
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 `<row r="${excelRowIndex}">${cells}</row>`;
})
.join("");
@ -237,7 +212,7 @@ function stylesXml(): string {
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>
<cellXfs count="4"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1"><alignment horizontal="left"/></xf></cellXfs>
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
</styleSheet>`;
}

View File

@ -4675,6 +4675,7 @@ export const useQueryStore = defineStore("query", () => {
keysetOptimizationEnabled: settings.queryExportKeysetOptimizationEnabled,
clientSessionId,
executionId: uuid(),
numericColumnRightAlign: settings.numericColumnRightAlign,
};
}

View File

@ -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<EditorSettings>, 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);

View File

@ -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())
}

View File

@ -86,6 +86,8 @@ pub struct QueryResultExportRequest {
pub execution_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub date_time_format: Option<String>,
#[serde(default)]
pub numeric_column_right_align: bool,
}
fn safe_postgres_temp_setup_sql(setup_sql: &[String]) -> Option<Vec<String>> {
@ -144,6 +146,7 @@ fn query_sql_worksheets(request: &QueryResultExportRequest) -> Vec<XlsxWorksheet
columns: vec!["SQL".to_string()],
column_types: Vec::new(),
rows: split_excel_cell_text(&request.sql).into_iter().map(|sql| vec![Value::String(sql)]).collect(),
numeric_column_right_align: false,
}]
}
@ -161,6 +164,7 @@ fn start_query_result_xlsx_workbook<W: Write + Seek>(
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,
}
}

View File

@ -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<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub date_time_format: Option<String>,
#[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");

View File

@ -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();

View File

@ -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<String>,
pub rows: Vec<Vec<Value>>,
#[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<W: Write + Seek> {
next_row_number: usize,
trailing_sheets: Vec<XlsxWorksheetData>,
date_time_format: Option<String>,
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::<String>();
format!("<row r=\"{row_number}\">{cells}</row>")
@ -99,13 +100,14 @@ fn write_zip_entry<W: Write + Seek>(zip: &mut zip::ZipWriter<W>, 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<W: Write + Seek>(
writer: W,
sheet_name: Option<&str>,
columns: &[String],
column_types: &[String],
) -> Result<StreamingXlsxWriter<W>, 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<W: Write + Seek
column_types: &[String],
trailing_sheets: &[XlsxWorksheetData],
) -> Result<StreamingXlsxWriter<W>, 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<W: Write + Seek>(
@ -126,12 +128,14 @@ pub(crate) fn start_streaming_xlsx_workbook_with_options<W: Write + Seek>(
column_types: &[String],
trailing_sheets: &[XlsxWorksheetData],
date_time_format: Option<&str>,
numeric_right_align: bool,
) -> Result<StreamingXlsxWriter<W>, 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::<Vec<_>>();
let sheet_names = normalize_unique_sheet_names(&all_sheets);
@ -173,6 +177,7 @@ pub(crate) fn start_streaming_xlsx_workbook_with_options<W: Write + Seek>(
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<W: Write + Seek> StreamingXlsxWriter<W> {
&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<usize> {
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::<f64>().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::<String>();
format!("<row r=\"{excel_row}\">{cells}</row>")
@ -634,7 +680,7 @@ fn styles_xml(date_time_format: Option<&str>) -> String {
"<fills count=\"2\"><fill><patternFill patternType=\"none\"/></fill><fill><patternFill patternType=\"gray125\"/></fill></fills>",
"<borders count=\"1\"><border><left/><right/><top/><bottom/><diagonal/></border></borders>",
"<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>",
"<cellXfs count=\"4\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/><xf numFmtId=\"0\" fontId=\"1\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyFont=\"1\"/><xf numFmtId=\"164\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/><xf numFmtId=\"165\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/></cellXfs>",
"<cellXfs count=\"6\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/><xf numFmtId=\"0\" fontId=\"1\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyFont=\"1\"/><xf numFmtId=\"164\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/><xf numFmtId=\"165\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyAlignment=\"1\"><alignment horizontal=\"right\"/></xf><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyAlignment=\"1\"><alignment horizontal=\"left\"/></xf></cellXfs>",
"<cellStyles count=\"1\"><cellStyle name=\"Normal\" xfId=\"0\" builtinId=\"0\"/></cellStyles>",
"</styleSheet>"
),
@ -697,12 +743,12 @@ pub fn build_xlsx_workbook_multi(sheets: &[XlsxWorksheetData]) -> Result<Vec<u8>
#[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("<c r=\"A2\"><v>1.00000</v></c>"));
assert!(sheet.contains("<c r=\"B2\"><v>2800.000000</v></c>"));
assert!(sheet.contains("<c r=\"A2\" s=\"5\"><v>1.00000</v></c>"));
assert!(sheet.contains("<c r=\"B2\" s=\"5\"><v>2800.000000</v></c>"));
assert!(sheet.contains("<c r=\"C2\" t=\"inlineStr\"><is><t>00123</t></is></c>"));
}
@ -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("<c r=\"B2\" s=\"3\"><v>45347.543229166666</v></c>"));
assert!(sheet.contains("<c r=\"C2\" t=\"inlineStr\"><is><t>2024-02-25</t></is></c>"));
assert!(sheet.contains("<c r=\"D2\" t=\"inlineStr\"><is><t>not-a-date</t></is></c>"));
assert!(sheet.contains("<c r=\"E2\"><v>2800.000000</v></c>"));
assert!(sheet.contains("<c r=\"E2\" s=\"5\"><v>2800.000000</v></c>"));
assert!(sheet.contains("<c r=\"F2\" t=\"inlineStr\"><is><t>2024-02-25T13:02:15+08:00</t></is></c>"));
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!("<c r=\"{reference}\"><v>{value}</v></c>")), "sheet={sheet}");
assert!(sheet.contains(&format!("<c r=\"{reference}\" s=\"5\"><v>{value}</v></c>")), "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("<c r=\"A2\"><v>42</v></c>"));
assert!(sheet.contains("<c r=\"B2\"><v>123.5</v></c>"));
assert!(sheet.contains("<c r=\"C2\"><v>2800.000000</v></c>"));
assert!(sheet.contains("<c r=\"A2\" s=\"5\"><v>42</v></c>"));
assert!(sheet.contains("<c r=\"B2\" s=\"5\"><v>123.5</v></c>"));
assert!(sheet.contains("<c r=\"C2\" s=\"5\"><v>2800.000000</v></c>"));
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#"<c r="A2" s="4"><v>1.5</v></c>"#), "sheet={sheet}");
// Text column B should NOT have right-align style s="4"
assert!(!sheet.contains(r#"<c r="B2" s="4""#));
}
#[test]
fn numeric_right_align_disabled_applies_left_align_style_5() {
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
sheet_name: Some("Disabled".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: false,
})
.expect("build workbook");
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
// Numeric column A should have left-align style (s="5"), not right-align (s="4"),
// to override Excel's default right alignment for number cells.
assert!(sheet.contains(r#"<c r="A2" s="5"><v>1.5</v></c>"#), "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<Value> = column_types.iter().map(|_| json!(1)).collect::<Vec<_>>();
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#"<c r="{col_letter}2" s="4"><v>1</v></c>"#);
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#"<c r="{last_letter}2" s="4""#)));
}
#[test]
fn numeric_type_classifier_matches_shared_backend_fixtures() {
let fixtures: Value =
serde_json::from_str(include_str!("../../../tests/fixtures/data-grid-numeric-column-types.json"))
.expect("parse numeric column type fixtures");
for fixture in fixtures["numeric"].as_array().expect("numeric fixtures") {
let column_type = fixture["type"].as_str().expect("numeric fixture type").to_string();
assert!(is_numeric_column_type(Some(&column_type)), "expected numeric backend type: {column_type}");
}
for fixture in fixtures["nonNumeric"].as_array().expect("non-numeric fixtures") {
let column_type = fixture["type"].as_str().expect("non-numeric fixture type").to_string();
assert!(!is_numeric_column_type(Some(&column_type)), "expected non-numeric backend type: {column_type}");
}
}
}

View File

@ -100,6 +100,7 @@ async fn live_clickhouse_query_result_export_xlsx_streams_random_order_query_onc
client_session_id: None,
execution_id: Some(format!("live-clickhouse-query-export-{suffix}")),
date_time_format: None,
numeric_column_right_align: false,
};
let done_seen = AtomicBool::new(false);
let result = export_query_result_core(&state, &request, None, |progress| {

View File

@ -168,6 +168,7 @@ async fn live_mysql_query_result_export_xlsx_streams_single_query_without_duplic
client_session_id: None,
execution_id: Some(format!("live-mysql-query-export-{suffix}")),
date_time_format: None,
numeric_column_right_align: false,
};
let done_seen = AtomicBool::new(false);
let result = export_query_result_core(&state, &request, None, |progress| {
@ -253,6 +254,7 @@ async fn live_mysql_xlsx_export_can_outlive_query_timeout_while_rows_keep_arrivi
client_session_id: None,
execution_id: Some(format!("live-mysql-query-export-timeout-{suffix}")),
date_time_format: None,
numeric_column_right_align: false,
};
let rows_exported = AtomicU64::new(0);
let done_seen = AtomicBool::new(false);

View File

@ -135,6 +135,7 @@ async fn live_postgres_query_result_export_uses_single_streamed_query() {
client_session_id: None,
execution_id: Some(format!("live-postgres-query-export-{suffix}")),
date_time_format: None,
numeric_column_right_align: false,
};
let done_seen = AtomicBool::new(false);
let result = export_query_result_core(&state, &request, None, |progress| {
@ -210,6 +211,7 @@ async fn live_postgres_query_result_xlsx_preserves_temporal_cell_types() {
client_session_id: Some(format!("live-postgres-xlsx-temporal-{suffix}")),
execution_id: Some(format!("live-postgres-xlsx-temporal-{suffix}")),
date_time_format: None,
numeric_column_right_align: false,
};
export_query_result_core(&state, &request, None, |_| {}).await.expect("export temporal XLSX");
@ -278,6 +280,7 @@ async fn live_postgres_truncated_batch_result_export_replays_safe_temp_setup() {
client_session_id: Some(format!("temp-export-csv-{short_suffix}")),
execution_id: Some(format!("temp-export-csv-{short_suffix}")),
date_time_format: None,
numeric_column_right_align: false,
};
let csv_rows = AtomicU64::new(0);
export_query_result_core(&state, &request, None, |progress| {
@ -349,6 +352,7 @@ async fn live_postgres_xlsx_export_can_outlive_query_timeout_while_rows_keep_arr
client_session_id: None,
execution_id: Some(format!("live-postgres-query-export-timeout-{suffix}")),
date_time_format: None,
numeric_column_right_align: false,
};
let rows_exported = AtomicU64::new(0);
let done_seen = AtomicBool::new(false);
@ -411,6 +415,7 @@ async fn live_postgres_stream_still_times_out_without_progress_and_recovers() {
client_session_id: None,
execution_id: Some(format!("live-postgres-query-export-stall-{suffix}")),
date_time_format: None,
numeric_column_right_align: false,
};
let started_at = Instant::now();
let result = export_query_result_core(&state, &request, None, |_| {}).await;

View File

@ -450,6 +450,7 @@ async fn live_sqlserver_query_result_export_streams_cte_query_to_csv() {
client_session_id: None,
execution_id: Some(format!("live-sqlserver-export-{suffix}")),
date_time_format: None,
numeric_column_right_align: false,
};
let done_seen = AtomicBool::new(false);
let result = export_query_result_core(&state, &request, None, |progress| {

View File

@ -110,6 +110,7 @@ async fn live_sqlserver_xlsx_export_can_outlive_query_timeout_while_rows_keep_ar
client_session_id: None,
execution_id: Some(format!("live-sqlserver-xlsx-{suffix}")),
date_time_format: None,
numeric_column_right_align: false,
};
let rows_exported = AtomicU64::new(0);
let done_seen = AtomicBool::new(false);

View File

@ -1,6 +1,7 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { test } from "vitest";
import { fitCanvasText, resolveCanvasDataGridRowFill } from "../../apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts";
import { canvasDataGridActionReservedWidth, fitCanvasText, resolveCanvasCellTextLayout, resolveCanvasDataGridRowFill } from "../../apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts";
import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, resolveDataGridPaintTheme } from "../../apps/desktop/src/lib/dataGrid/dataGridPaintTheme.ts";
function measureContext(charWidth = 1): CanvasRenderingContext2D {
@ -23,6 +24,33 @@ test("fitCanvasText truncates only when text exceeds the available cell width",
assert.equal(fitCanvasText(ctx, "1234567890", 8), "12345...");
});
test("fitCanvasText preserves the numeric suffix for right-aligned narrow cells", () => {
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" };

View File

@ -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<typeof import("../../apps/desktop/src/lib/backend/api")>();
// 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<string, string>, run: () => void | Promise<void>) {
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());

View File

@ -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();

View File

@ -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, /<c r="A2"><v>42<\/v><\/c>/);
assert.match(text, /<c r="B2"><v>123\.5<\/v><\/c>/);
assert.match(text, /<c r="C2"><v>987654\.321<\/v><\/c>/);
assert.match(text, /<c r="D2"><v>2800\.000000<\/v><\/c>/);
assert.match(text, /<c r="E2" t="inlineStr"><is><t>9007199254740992<\/t><\/is><\/c>/);
assert.match(text, /<c r="A2" s="3"><v>42<\/v><\/c>/);
assert.match(text, /<c r="B2" s="3"><v>123\.5<\/v><\/c>/);
assert.match(text, /<c r="C2" s="3"><v>987654\.321<\/v><\/c>/);
assert.match(text, /<c r="D2" s="3"><v>2800\.000000<\/v><\/c>/);
assert.match(text, /<c r="E2" t="inlineStr" s="3"><is><t>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, /<c r="A2" s="2"><v>1\.5<\/v><\/c>/);
// Text column B should NOT have right-align style
assert.doesNotMatch(text, /<c r="B2"[^>]* 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, /<c r="A2" s="3"><v>1\.5<\/v><\/c>/);
assert.doesNotMatch(text, /<c r="A2"[^>]* 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, /<c r="A2" s="2"><v>1\.5<\/v><\/c>/);
assert.doesNotMatch(text, /<c r="B2"[^>]* 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(`<c r="${ref}" s="2"><v>1</v></c>`), `expected right-align style for ${columnTypes[index]}`);
});
// Text column (last) must not receive the numeric right-align style.
assert.doesNotMatch(text, /<c r="P2"[^>]* 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(`<c r="${ref}" s="3"><v>1</v></c>`), `expected left-align style for ${columnTypes[index]}`);
});
assert.doesNotMatch(text, /s="2"/);
});

View File

@ -11,6 +11,8 @@ pub struct QueryResultXlsxExportRequest {
#[serde(default)]
pub column_types: Vec<String>,
pub rows: Vec<Vec<Value>>,
#[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

View File

@ -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)" }
]
}