feat(grid): right-align numeric result columns
This commit is contained in:
1 parent
66c768061a
commit
e2b35944ce
35 files changed
+1082
-109
No files matched your search
@@ -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],
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1288,6 +1288,9 @@ export default withEnglishFallback({
|
||||
},
|
||||
cachedResultUnavailable: "キャッシュ結果が不足しているか互換性がありません。",
|
||||
reexecuteQuery: "クエリを再実行",
|
||||
numericColumnAlign: "数値列の配置",
|
||||
numericColumnAlignLeft: "左揃え",
|
||||
numericColumnAlignRight: "右揃え",
|
||||
},
|
||||
exportProgress: {
|
||||
title: "テーブルデータをエクスポート中",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1153,6 +1153,9 @@ export default withEnglishFallback({
|
||||
searchModeHint: "选择 Ctrl+F 搜索时过滤匹配行,或保留全部行并高亮定位命中项。",
|
||||
searchModeFilter: "过滤",
|
||||
searchModeHighlight: "高亮",
|
||||
numericColumnAlign: "数值列对齐",
|
||||
numericColumnAlignLeft: "左对齐",
|
||||
numericColumnAlignRight: "右对齐",
|
||||
moreValues: "还有 {count} 个值,输入关键词继续缩小范围",
|
||||
filterByValue: "筛选此值",
|
||||
filterExcludeValue: "排除此值",
|
||||
|
||||
@@ -1288,6 +1288,9 @@ export default withEnglishFallback({
|
||||
},
|
||||
cachedResultUnavailable: "快取結果缺失或不相容。",
|
||||
reexecuteQuery: "重新執行查詢",
|
||||
numericColumnAlign: "數值列對齊",
|
||||
numericColumnAlignLeft: "左對齊",
|
||||
numericColumnAlignRight: "右對齊",
|
||||
},
|
||||
exportProgress: {
|
||||
title: "匯出資料表資料",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -4675,6 +4675,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
keysetOptimizationEnabled: settings.queryExportKeysetOptimizationEnabled,
|
||||
clientSessionId,
|
||||
executionId: uuid(),
|
||||
numericColumnRightAlign: settings.numericColumnRightAlign,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user