feat(grid): show column data types in result header (#669) (#820)

The data grid header only showed column names, and column types were
available only via tableMeta (open-table view). Arbitrary query results
(e.g. `select * from pg_depend`) therefore showed no type at all, which
is exactly the case the reporter hit.

Backend: add `column_types` to QueryResult (serde-default, backward
compatible) and populate it for the native drivers where the type is
readily available — PostgreSQL, MySQL, SQL Server, ClickHouse. Other
drivers leave it empty for now (no behavior change); schemaless stores
(Mongo/Redis/ES) have no column types.

Frontend: render a type row under each column name in the grid header,
color-coded by type. The type is resolved from tableMeta first (richer,
includes precision) and falls back to the query result's column_types by
index. Add a `showColumnTypesInHeader` setting (default on) and keep the
msgpack tab-result cache compatible. The source-selection logic is
extracted to lib/dataGridColumnType.ts with unit tests.

Co-authored-by: vrustx <vrustx@vrustxdeMac-mini.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
vrustx 2026-06-07 22:45:46 +08:00 committed by GitHub
parent 3b457f7803
commit 54de28d583
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 284 additions and 2 deletions

View File

@ -116,6 +116,7 @@ const editAppLayout = ref(settingsStore.editorSettings.appLayout);
const editShowTrayIcon = ref(settingsStore.desktopSettings.show_tray_icon);
const editIconTheme = ref<DesktopIconTheme>(settingsStore.desktopSettings.icon_theme);
const editShowColumnCommentsInHeader = ref(settingsStore.editorSettings.showColumnCommentsInHeader);
const editShowColumnTypesInHeader = ref(settingsStore.editorSettings.showColumnTypesInHeader);
const editCompactColumnHeaderActions = ref(settingsStore.editorSettings.compactColumnHeaderActions);
const editRedisScanPageSize = ref(settingsStore.editorSettings.redisScanPageSize);
const editShortcuts = ref(normalizeShortcutSettings(settingsStore.editorSettings.shortcuts));
@ -273,6 +274,7 @@ watch(
editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon;
editIconTheme.value = settingsStore.desktopSettings.icon_theme;
editShowColumnCommentsInHeader.value = settingsStore.editorSettings.showColumnCommentsInHeader;
editShowColumnTypesInHeader.value = settingsStore.editorSettings.showColumnTypesInHeader;
editCompactColumnHeaderActions.value = settingsStore.editorSettings.compactColumnHeaderActions;
editRedisScanPageSize.value = settingsStore.editorSettings.redisScanPageSize;
editShortcuts.value = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);
@ -320,6 +322,7 @@ function hasChanges(): boolean {
editShowTrayIcon.value !== settingsStore.desktopSettings.show_tray_icon ||
editIconTheme.value !== settingsStore.desktopSettings.icon_theme ||
editShowColumnCommentsInHeader.value !== settingsStore.editorSettings.showColumnCommentsInHeader ||
editShowColumnTypesInHeader.value !== settingsStore.editorSettings.showColumnTypesInHeader ||
editCompactColumnHeaderActions.value !== settingsStore.editorSettings.compactColumnHeaderActions ||
editRedisScanPageSize.value !== settingsStore.editorSettings.redisScanPageSize ||
JSON.stringify(editShortcuts.value) !== JSON.stringify(settingsStore.editorSettings.shortcuts) ||
@ -354,6 +357,7 @@ async function persistSettings() {
confirmDangerousSqlExecution: editConfirmDangerousSqlExecution.value,
appLayout: editAppLayout.value,
showColumnCommentsInHeader: editShowColumnCommentsInHeader.value,
showColumnTypesInHeader: editShowColumnTypesInHeader.value,
compactColumnHeaderActions: editCompactColumnHeaderActions.value,
redisScanPageSize: editRedisScanPageSize.value,
shortcuts: editShortcuts.value,
@ -401,6 +405,7 @@ function resetDefaults() {
editShowTrayIcon.value = DEFAULT_DESKTOP_SETTINGS.show_tray_icon;
editIconTheme.value = DEFAULT_DESKTOP_SETTINGS.icon_theme;
editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader;
editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader;
editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions;
editRedisScanPageSize.value = DEFAULT_EDITOR_SETTINGS.redisScanPageSize;
editShortcuts.value = normalizeShortcutSettings(DEFAULT_EDITOR_SETTINGS.shortcuts);
@ -1569,6 +1574,17 @@ watch(
</div>
<Switch id="show-column-comments-in-header" v-model="editShowColumnCommentsInHeader" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="show-column-types-in-header">
{{ t("settings.showColumnTypesInHeader") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.showColumnTypesInHeaderDescription") }}
</p>
</div>
<Switch id="show-column-types-in-header" v-model="editShowColumnTypesInHeader" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="compact-column-header-actions">

View File

@ -83,6 +83,7 @@ import { createColumnDrafts } from "@/lib/tableStructureEditorState";
import type { BuildSingleColumnAlterSqlOptions } from "@/lib/tableStructureEditorSql";
import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql";
import { uuid } from "@/lib/utils";
import { resolveHeaderColumnType } from "@/lib/dataGridColumnType";
import {
canEditExistingTableRows,
hiveTablePropertiesIndicateTransactional,
@ -341,6 +342,7 @@ const columnCommentMap = computed(() => {
return map;
});
const showColumnCommentsInHeader = computed(() => settingsStore.editorSettings.showColumnCommentsInHeader);
const showColumnTypesInHeader = computed(() => settingsStore.editorSettings.showColumnTypesInHeader);
const compactColumnHeaderActions = computed(() => settingsStore.editorSettings.compactColumnHeaderActions);
const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode);
@ -349,6 +351,16 @@ function headerColumnComment(column: string): string {
return columnCommentMap.value.get(column) || "";
}
function headerColumnType(column: string, actualColIdx: number): string {
if (!showColumnTypesInHeader.value) return "";
const resolved = resolveHeaderColumnType({
tableColumnType: columnTypeMap.value.get(column),
resultColumnTypes: props.result.column_types,
actualColIdx,
});
return resolved ? shortTypeName(resolved) : "";
}
function shortTypeName(t: string): string {
const s = t.toLowerCase();
if (s === "character varying") return "varchar";
@ -6505,6 +6517,14 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<span class="min-w-0 truncate leading-4">
{{ col.name }}
</span>
<span
v-if="headerColumnType(col.name, col.actualColIdx)"
class="min-w-0 truncate text-[10px] font-normal leading-3"
:class="typeColorClass(headerColumnType(col.name, col.actualColIdx))"
:title="headerColumnType(col.name, col.actualColIdx)"
>
{{ headerColumnType(col.name, col.actualColIdx) }}
</span>
<span
v-if="headerColumnComment(col.name)"
class="min-w-0 truncate text-[10px] font-normal leading-3 text-muted-foreground"

View File

@ -1643,6 +1643,8 @@ export default {
dataGridDisplay: "Data grid display",
showColumnCommentsInHeader: "Show column comments under names",
showColumnCommentsInHeaderDescription: "Display table column comments directly below grid column names.",
showColumnTypesInHeader: "Show column types under names",
showColumnTypesInHeaderDescription: "Display each column's data type directly below grid column names.",
compactColumnHeaderActions: "Compact column header tools",
compactColumnHeaderActionsDescription:
"Move formatter and local filter tools into a more menu so column names get priority.",

View File

@ -1530,6 +1530,9 @@ export default {
showColumnCommentsInHeader: "Mostrar comentarios bajo los nombres",
showColumnCommentsInHeaderDescription:
"Muestra los comentarios de columnas directamente debajo del nombre de la columna.",
showColumnTypesInHeader: "Mostrar tipos de columna bajo los nombres",
showColumnTypesInHeaderDescription:
"Muestra el tipo de dato de cada columna directamente debajo del nombre de la columna.",
compactColumnHeaderActions: "Compactar herramientas del encabezado",
compactColumnHeaderActionsDescription:
"Mueve formatear y filtrar a un menú de más acciones para priorizar el nombre de columna.",

View File

@ -1673,6 +1673,9 @@ export default {
showColumnCommentsInHeader: "Mostra i commenti delle colonne sotto i nomi",
showColumnCommentsInHeaderDescription:
"Visualizza i commenti delle colonne della tabella direttamente sotto i nomi delle colonne nella griglia.",
showColumnTypesInHeader: "Mostra i tipi di colonna sotto i nomi",
showColumnTypesInHeaderDescription:
"Visualizza il tipo di dato di ciascuna colonna direttamente sotto i nomi delle colonne nella griglia.",
compactColumnHeaderActions: "Strumenti intestazione colonna compatti",
compactColumnHeaderActionsDescription:
"Sposta gli strumenti del formattatore e dei filtri locali in un menu 'altro' in modo che i nomi delle colonne abbiano la priorità.",

View File

@ -1663,6 +1663,9 @@ export default {
showColumnCommentsInHeader: "Mostrar comentários de coluna sob os nomes",
showColumnCommentsInHeaderDescription:
"Exibir comentários de colunas da tabela diretamente abaixo dos nomes das colunas da grade.",
showColumnTypesInHeader: "Mostrar tipos de coluna sob os nomes",
showColumnTypesInHeaderDescription:
"Exibir o tipo de dado de cada coluna diretamente abaixo dos nomes das colunas da grade.",
compactColumnHeaderActions: "Ferramentas compactas no cabeçalho da coluna",
compactColumnHeaderActionsDescription:
"Mover as ferramentas de formatação e filtro local para um menu de mais opções para que os nomes das colunas tenham prioridade.",

View File

@ -1612,6 +1612,8 @@ export default {
dataGridDisplay: "数据表格显示",
showColumnCommentsInHeader: "在字段名下方显示注释",
showColumnCommentsInHeaderDescription: "把表字段注释直接显示在结果表头字段名下方。",
showColumnTypesInHeader: "在字段名下方显示数据类型",
showColumnTypesInHeaderDescription: "把每个字段的数据类型直接显示在结果表头字段名下方。",
compactColumnHeaderActions: "收起字段表头工具",
compactColumnHeaderActionsDescription: "将格式化、本地筛选收进更多菜单,优先显示字段名称。",
sidebarActivation: "侧边栏打开方式",

View File

@ -1586,6 +1586,8 @@ export default {
dataGridDisplay: "資料表格顯示",
showColumnCommentsInHeader: "在欄位名稱下方顯示註解",
showColumnCommentsInHeaderDescription: "直接在資料表格欄位名稱下方顯示資料表欄位註解。",
showColumnTypesInHeader: "在欄位名稱下方顯示資料類型",
showColumnTypesInHeaderDescription: "直接在資料表格欄位名稱下方顯示每個欄位的資料類型。",
compactColumnHeaderActions: "收起欄位表頭工具",
compactColumnHeaderActionsDescription: "將格式設定和本機篩選工具移到更多選單,讓欄位名稱優先顯示。",
sidebarActivation: "側邊欄開啟方式",

View File

@ -0,0 +1,33 @@
/**
* Resolve the data type to display in a data-grid column header.
*
* Two sources can supply a column's type:
* - Table metadata (only when a table is open): matched **by column name**,
* richer because it carries precision/scale. Preferred.
* - `QueryResult.column_types` (any query): parallel to `result.columns`, so
* it must be read **by index**. Used as a fallback for arbitrary queries
* that have no table metadata (e.g. `select * from pg_depend`).
*
* Returns `undefined` when neither source has a non-empty type, so callers can
* simply hide the type row.
*/
export interface HeaderColumnTypeSources {
/** Type from table metadata for this column (looked up by name), if any. */
tableColumnType?: string;
/** `QueryResult.column_types`, parallel to `result.columns` (by index). */
resultColumnTypes?: readonly string[];
/** Index of the column within `result.columns`. */
actualColIdx: number;
}
export function resolveHeaderColumnType({
tableColumnType,
resultColumnTypes,
actualColIdx,
}: HeaderColumnTypeSources): string | undefined {
const fromMeta = tableColumnType?.trim();
if (fromMeta) return fromMeta;
const fromResult = resultColumnTypes?.[actualColIdx]?.trim();
return fromResult ? fromResult : undefined;
}

View File

@ -29,6 +29,7 @@ export interface TabResultSnapshot {
interface ColumnarQueryResult {
columns: string[];
column_types?: string[];
columnValues: CellValue[][];
rowCount: number;
affected_rows: number;
@ -111,6 +112,7 @@ function stripSessionIds(result: QueryResult | undefined): QueryResult | undefin
if (!result) return undefined;
return {
columns: [...result.columns],
column_types: result.column_types ? [...result.column_types] : undefined,
rows: result.rows.map((row) => [...row]),
affected_rows: result.affected_rows,
execution_time_ms: result.execution_time_ms,
@ -129,6 +131,7 @@ function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult
const columnValues = result.columns.map((_, colIndex) => result.rows.map((row) => row[colIndex] ?? null));
return removeUndefinedFields({
columns: [...result.columns],
column_types: result.column_types ? [...result.column_types] : undefined,
columnValues,
rowCount: result.rows.length,
affected_rows: result.affected_rows,
@ -145,6 +148,7 @@ function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResul
);
return {
columns: [...result.columns],
column_types: result.column_types ? [...result.column_types] : undefined,
rows,
affected_rows: result.affected_rows,
execution_time_ms: result.execution_time_ms,

View File

@ -246,6 +246,7 @@ export interface EditorSettings {
redisScanPageSize: number;
mongoViewMode: "document" | "table";
showColumnCommentsInHeader: boolean;
showColumnTypesInHeader: boolean;
compactColumnHeaderActions: boolean;
dataGridRenderMode: DataGridRenderMode;
structureEditorDensity: StructureEditorDensity;
@ -311,6 +312,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
redisScanPageSize: 1000,
mongoViewMode: "document",
showColumnCommentsInHeader: false,
showColumnTypesInHeader: true,
compactColumnHeaderActions: true,
dataGridRenderMode: "canvas",
structureEditorDensity: "compact",
@ -466,6 +468,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
mongoViewMode: settings.mongoViewMode === "table" ? "table" : DEFAULT_EDITOR_SETTINGS.mongoViewMode,
showColumnCommentsInHeader:
settings.showColumnCommentsInHeader ?? DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader,
showColumnTypesInHeader: settings.showColumnTypesInHeader ?? DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader,
compactColumnHeaderActions:
settings.compactColumnHeaderActions ?? DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions,
dataGridRenderMode: normalizeDataGridRenderMode(settings.dataGridRenderMode),
@ -648,6 +651,8 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.mongoViewMode !== undefined) editorSettings.value.mongoViewMode = partial.mongoViewMode;
if (partial.showColumnCommentsInHeader !== undefined)
editorSettings.value.showColumnCommentsInHeader = partial.showColumnCommentsInHeader;
if (partial.showColumnTypesInHeader !== undefined)
editorSettings.value.showColumnTypesInHeader = partial.showColumnTypesInHeader;
if (partial.compactColumnHeaderActions !== undefined)
editorSettings.value.compactColumnHeaderActions = partial.compactColumnHeaderActions;
if (partial.dataGridRenderMode !== undefined)

View File

@ -236,6 +236,12 @@ export interface TriggerInfo {
export interface QueryResult {
columns: string[];
/**
* Database type name for each column, parallel to `columns`. Optional and may
* be shorter/empty when a driver cannot supply types (schemaless stores,
* fallback query paths, older backends). Consumers must tolerate gaps.
*/
column_types?: string[];
rows: (string | number | boolean | null)[][];
affected_rows: number;
execution_time_ms: number;

View File

@ -148,13 +148,23 @@ fn query_result_row_limit(max_rows: Option<usize>) -> usize {
fn limited_query_result(result: ChJsonResult, execution_time_ms: u128, max_rows: Option<usize>) -> QueryResult {
let columns: Vec<String> = result.meta.iter().map(|c| c.name.clone()).collect();
let column_types: Vec<String> = result.meta.iter().map(|c| c._type.clone()).collect();
let mut rows = result.data;
let row_limit = query_result_row_limit(max_rows);
let truncated = rows.len() > row_limit;
if truncated {
rows.truncate(row_limit);
}
QueryResult { columns, rows, affected_rows: 0, execution_time_ms, truncated, session_id: None, has_more: false }
QueryResult {
columns,
column_types,
rows,
affected_rows: 0,
execution_time_ms,
truncated,
session_id: None,
has_more: false,
}
}
pub async fn test_connection(client: &ChClient, timeout: Duration) -> Result<(), String> {
@ -259,6 +269,7 @@ pub async fn execute_query_with_max_rows(
}
Ok(QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),

View File

@ -421,6 +421,7 @@ pub async fn execute_rest_query(client: &EsClient, input: &str) -> Result<crate:
Ok(crate::types::QueryResult {
columns: all_keys,
column_types: Vec::new(),
rows,
affected_rows: total,
execution_time_ms: start.elapsed().as_millis(),
@ -434,6 +435,7 @@ pub async fn execute_rest_query(client: &EsClient, input: &str) -> Result<crate:
let row_count = rows.len() as u64;
Ok(crate::types::QueryResult {
columns,
column_types: Vec::new(),
rows,
affected_rows: row_count,
execution_time_ms: start.elapsed().as_millis(),
@ -445,6 +447,7 @@ pub async fn execute_rest_query(client: &EsClient, input: &str) -> Result<crate:
let pretty = serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string());
Ok(crate::types::QueryResult {
columns: vec!["status".to_string(), "response".to_string()],
column_types: Vec::new(),
rows: vec![vec![serde_json::Value::Number(status.into()), serde_json::Value::String(pretty)]],
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
@ -457,6 +460,7 @@ pub async fn execute_rest_query(client: &EsClient, input: &str) -> Result<crate:
let pretty = serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string());
Ok(crate::types::QueryResult {
columns: vec!["status".to_string(), "response".to_string()],
column_types: Vec::new(),
rows: vec![vec![serde_json::Value::Number(status.into()), serde_json::Value::String(pretty)]],
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),

View File

@ -187,6 +187,42 @@ fn mysql_bytes_to_json(bytes: Vec<u8>, column: &mysql_async::Column) -> serde_js
serde_json::Value::String(String::from_utf8_lossy(&bytes).to_string())
}
/// Map a MySQL column to a user-facing type name for the result-grid header.
/// Returns the bare lowercase type name (no length/precision/signedness), which
/// is enough for display; unknown variants fall back to a lowercased debug name.
fn mysql_column_type_name(ty: ColumnType) -> String {
use mysql_async::consts::ColumnType::*;
match ty {
MYSQL_TYPE_TINY => "tinyint",
MYSQL_TYPE_SHORT => "smallint",
MYSQL_TYPE_INT24 => "mediumint",
MYSQL_TYPE_LONG => "int",
MYSQL_TYPE_LONGLONG => "bigint",
MYSQL_TYPE_FLOAT => "float",
MYSQL_TYPE_DOUBLE => "double",
MYSQL_TYPE_DECIMAL | MYSQL_TYPE_NEWDECIMAL => "decimal",
MYSQL_TYPE_BIT => "bit",
MYSQL_TYPE_YEAR => "year",
MYSQL_TYPE_DATE | MYSQL_TYPE_NEWDATE => "date",
MYSQL_TYPE_TIME | MYSQL_TYPE_TIME2 => "time",
MYSQL_TYPE_DATETIME | MYSQL_TYPE_DATETIME2 => "datetime",
MYSQL_TYPE_TIMESTAMP | MYSQL_TYPE_TIMESTAMP2 => "timestamp",
MYSQL_TYPE_JSON => "json",
MYSQL_TYPE_ENUM => "enum",
MYSQL_TYPE_SET => "set",
MYSQL_TYPE_TINY_BLOB => "tinyblob",
MYSQL_TYPE_MEDIUM_BLOB => "mediumblob",
MYSQL_TYPE_LONG_BLOB => "longblob",
MYSQL_TYPE_BLOB => "blob",
MYSQL_TYPE_VARCHAR | MYSQL_TYPE_VAR_STRING => "varchar",
MYSQL_TYPE_STRING => "char",
MYSQL_TYPE_GEOMETRY => "geometry",
MYSQL_TYPE_NULL => "null",
other => return format!("{:?}", other).to_lowercase(),
}
.to_string()
}
fn mysql_value_to_json(row: &mysql_async::Row, idx: usize) -> serde_json::Value {
let Some(column) = row.columns_ref().get(idx) else {
return serde_json::Value::Null;
@ -1251,6 +1287,8 @@ async fn execute_result_set_with_text_protocol_on_conn(
) -> Result<QueryResult, String> {
let mut result = conn.query_iter(sql).await.map_err(|e| e.to_string())?;
let columns: Vec<String> = result.columns_ref().iter().map(|c| c.name_str().to_string()).collect();
let column_types: Vec<String> =
result.columns_ref().iter().map(|c| mysql_column_type_name(c.column_type())).collect();
let mut result_rows: Vec<Vec<serde_json::Value>> = Vec::new();
let mut stream = result
@ -1275,6 +1313,7 @@ async fn execute_result_set_with_text_protocol_on_conn(
Ok(QueryResult {
columns,
column_types,
rows: result_rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
@ -1292,6 +1331,8 @@ async fn execute_result_set_with_prepared_protocol_on_conn(
) -> Result<QueryResult, String> {
let mut result = conn.exec_iter(sql, ()).await.map_err(|e| e.to_string())?;
let columns: Vec<String> = result.columns_ref().iter().map(|c| c.name_str().to_string()).collect();
let column_types: Vec<String> =
result.columns_ref().iter().map(|c| mysql_column_type_name(c.column_type())).collect();
let mut result_rows: Vec<Vec<serde_json::Value>> = Vec::new();
let mut stream = result
@ -1316,6 +1357,7 @@ async fn execute_result_set_with_prepared_protocol_on_conn(
Ok(QueryResult {
columns,
column_types,
rows: result_rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
@ -1378,6 +1420,7 @@ pub async fn execute_query_on_conn_with_max_rows(
Ok(QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows,
execution_time_ms: start.elapsed().as_millis(),
@ -1566,6 +1609,21 @@ mod tests {
use super::*;
use mysql_async::consts::ColumnFlags;
#[test]
fn mysql_column_type_names_map_to_friendly_names() {
use mysql_async::consts::ColumnType::*;
assert_eq!(mysql_column_type_name(MYSQL_TYPE_TINY), "tinyint");
assert_eq!(mysql_column_type_name(MYSQL_TYPE_LONG), "int");
assert_eq!(mysql_column_type_name(MYSQL_TYPE_LONGLONG), "bigint");
assert_eq!(mysql_column_type_name(MYSQL_TYPE_NEWDECIMAL), "decimal");
assert_eq!(mysql_column_type_name(MYSQL_TYPE_VARCHAR), "varchar");
assert_eq!(mysql_column_type_name(MYSQL_TYPE_VAR_STRING), "varchar");
assert_eq!(mysql_column_type_name(MYSQL_TYPE_STRING), "char");
assert_eq!(mysql_column_type_name(MYSQL_TYPE_DATETIME), "datetime");
assert_eq!(mysql_column_type_name(MYSQL_TYPE_JSON), "json");
assert_eq!(mysql_column_type_name(MYSQL_TYPE_BLOB), "blob");
}
#[test]
fn mysql_with_queries_are_treated_as_result_sets() {
let sql = "WITH RECURSIVE org_tree AS (SELECT 1 AS id) SELECT id FROM org_tree";

View File

@ -692,6 +692,7 @@ async fn execute_select_prepared(
truncated,
session_id: None,
has_more: false,
column_types,
})
}
@ -741,6 +742,7 @@ async fn execute_select_text(
truncated,
session_id: None,
has_more: false,
column_types: Vec::new(),
})
}
@ -1434,6 +1436,7 @@ pub async fn execute_query_with_max_rows(
truncated: false,
session_id: None,
has_more: false,
column_types: Vec::new(),
})
}
}
@ -1519,6 +1522,7 @@ async fn execute_query_with_max_rows_inner(
truncated: false,
session_id: None,
has_more: false,
column_types: Vec::new(),
})
}
}

View File

@ -293,6 +293,7 @@ pub async fn execute_query_with_max_rows(
let affected_rows = result.rows_affected.unwrap_or(0);
Ok(QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows,
execution_time_ms: start.elapsed().as_millis(),
@ -340,6 +341,7 @@ fn query_result_from_rqlite_result(
}
QueryResult {
columns: result.columns,
column_types: Vec::new(),
rows: result.values,
affected_rows: 0,
execution_time_ms,

View File

@ -637,6 +637,7 @@ fn execute_query_blocking(pool: &SqliteHandle, sql: &str, max_rows: Option<usize
Ok(QueryResult {
columns,
column_types: Vec::new(),
rows: result_rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
@ -648,6 +649,7 @@ fn execute_query_blocking(pool: &SqliteHandle, sql: &str, max_rows: Option<usize
conn.execute_batch(sql).map_err(|e| e.to_string())?;
Ok(QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows: conn.changes(),
execution_time_ms: start.elapsed().as_millis(),

View File

@ -98,6 +98,17 @@ fn columns_from_metadata(metadata: &tiberius::ResultMetadata) -> Vec<String> {
metadata.columns().iter().map(|c| c.name().to_string()).collect()
}
/// Map a tiberius column to a user-facing type name for the result-grid header.
/// Uses the TDS column-type debug name lowercased; good enough for display, with
/// no risk of mismatching the enum variants across tiberius versions.
fn sqlserver_column_type_name(column: &tiberius::Column) -> String {
format!("{:?}", column.column_type()).to_lowercase()
}
fn column_types_from_metadata(metadata: &tiberius::ResultMetadata) -> Vec<String> {
metadata.columns().iter().map(sqlserver_column_type_name).collect()
}
async fn collect_first_result_limited(
mut stream: QueryStream<'_>,
start: Instant,
@ -105,6 +116,7 @@ async fn collect_first_result_limited(
) -> Result<QueryResult, String> {
let row_limit = query_result_row_limit(max_rows);
let mut columns: Vec<String> = vec![];
let mut column_types: Vec<String> = vec![];
let mut rows: Vec<Vec<serde_json::Value>> = Vec::new();
let mut truncated = false;
@ -112,6 +124,7 @@ async fn collect_first_result_limited(
match item {
QueryItem::Metadata(metadata) if metadata.result_index() == 0 => {
columns = columns_from_metadata(&metadata);
column_types = column_types_from_metadata(&metadata);
}
QueryItem::Metadata(_) => {}
QueryItem::Row(row) if row.result_index() == 0 => {
@ -127,6 +140,7 @@ async fn collect_first_result_limited(
Ok(QueryResult {
columns,
column_types,
rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
@ -138,6 +152,7 @@ async fn collect_first_result_limited(
struct SqlServerResultSet {
columns: Vec<String>,
column_types: Vec<String>,
rows: Vec<Vec<serde_json::Value>>,
truncated: bool,
}
@ -467,6 +482,7 @@ fn push_sqlserver_result_set(results: &mut Vec<QueryResult>, result: Option<SqlS
}
results.push(QueryResult {
columns: result.columns,
column_types: result.column_types,
rows: result.rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
@ -492,6 +508,7 @@ async fn collect_result_sets_limited(
push_sqlserver_result_set(&mut results, current.take(), start);
current = Some(SqlServerResultSet {
columns: columns_from_metadata(&metadata),
column_types: column_types_from_metadata(&metadata),
rows: Vec::new(),
truncated: false,
});
@ -499,6 +516,7 @@ async fn collect_result_sets_limited(
QueryItem::Row(row) => {
let result = current.get_or_insert_with(|| SqlServerResultSet {
columns: row.columns().iter().map(|c| c.name().to_string()).collect(),
column_types: row.columns().iter().map(sqlserver_column_type_name).collect(),
rows: Vec::new(),
truncated: false,
});
@ -922,6 +940,7 @@ pub async fn execute_query_with_max_rows(
let _ = sqlserver_driver_result(collect_result_sets_limited(stream, start, max_rows)).await?;
Ok(QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
@ -933,6 +952,7 @@ pub async fn execute_query_with_max_rows(
let result = sqlserver_driver_result(client.execute(sql, &[])).await?;
Ok(QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows: result.rows_affected().iter().sum::<u64>(),
execution_time_ms: start.elapsed().as_millis(),
@ -967,6 +987,7 @@ pub async fn execute_batch_with_max_rows(
if results.is_empty() {
results.push(QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
@ -1262,6 +1283,7 @@ mod tests {
&mut results,
Some(SqlServerResultSet {
columns: vec!["id".to_string(), "name".to_string()],
column_types: vec![],
rows: vec![],
truncated: false,
}),
@ -1278,7 +1300,7 @@ mod tests {
let mut results = Vec::new();
super::push_sqlserver_result_set(
&mut results,
Some(SqlServerResultSet { columns: vec![], rows: vec![], truncated: false }),
Some(SqlServerResultSet { columns: vec![], column_types: vec![], rows: vec![], truncated: false }),
Instant::now(),
);

View File

@ -234,6 +234,7 @@ pub fn duckdb_execute_with_max_rows(
}
Ok(db::QueryResult {
columns,
column_types: Vec::new(),
rows: result_rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
@ -245,6 +246,7 @@ pub fn duckdb_execute_with_max_rows(
let affected = con.execute(sql, []).map_err(|e| e.to_string())?;
Ok(db::QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows: affected as u64,
execution_time_ms: start.elapsed().as_millis(),
@ -948,6 +950,7 @@ async fn execute_multi_mysql(
fn error_query_result(message: String) -> db::QueryResult {
db::QueryResult {
columns: vec!["Error".to_string()],
column_types: Vec::new(),
rows: vec![vec![serde_json::Value::String(message)]],
affected_rows: 0,
execution_time_ms: 0,
@ -972,6 +975,7 @@ async fn execute_multi_sqlserver(
if is_canceled(&cancel_token) {
all_results.push(db::QueryResult {
columns: vec!["Error".to_string()],
column_types: Vec::new(),
rows: vec![vec![serde_json::Value::String(canceled_error())]],
affected_rows: 0,
execution_time_ms: 0,
@ -1004,6 +1008,7 @@ async fn execute_multi_sqlserver(
Err(e) => {
all_results.push(db::QueryResult {
columns: vec!["Error".to_string()],
column_types: Vec::new(),
rows: vec![vec![serde_json::Value::String(e)]],
affected_rows: 0,
execution_time_ms: 0,
@ -1018,6 +1023,7 @@ async fn execute_multi_sqlserver(
if all_results.is_empty() {
all_results.push(db::QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows: 0,
execution_time_ms: 0,
@ -1081,6 +1087,7 @@ pub async fn execute_statements(
Ok(db::QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows: total_affected,
execution_time_ms: start.elapsed().as_millis(),
@ -1183,6 +1190,7 @@ async fn exec_tx_pg_inner(
match tx_result {
Ok(total_affected) => Ok(db::QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows: total_affected,
execution_time_ms: start.elapsed().as_millis(),
@ -1230,6 +1238,7 @@ async fn exec_tx_mysql_inner(
conn.query_drop("COMMIT").await.map_err(|e| format!("COMMIT failed: {}", e))?;
Ok(db::QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows: total_affected,
execution_time_ms: start.elapsed().as_millis(),
@ -1261,6 +1270,7 @@ async fn exec_tx_sqlite_inner(
conn.execute_batch("COMMIT").map_err(|e| format!("COMMIT failed: {}", e))?;
Ok(db::QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows: total_affected,
execution_time_ms: start.elapsed().as_millis(),
@ -1340,6 +1350,7 @@ async fn exec_tx_explicit_inner(
Ok(db::QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows: total_affected,
execution_time_ms: start.elapsed().as_millis(),
@ -1381,6 +1392,7 @@ async fn exec_tx_none_inner(
Ok(db::QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows: total_affected,
execution_time_ms: start.elapsed().as_millis(),
@ -1404,6 +1416,7 @@ mod tests {
tokio::time::sleep(Duration::from_secs(30)).await;
Ok(db::QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows: 0,
execution_time_ms: 0,
@ -1423,6 +1436,7 @@ mod tests {
tokio::time::sleep(Duration::from_secs(1)).await;
Ok(db::QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows: 0,
execution_time_ms: 0,
@ -1721,6 +1735,7 @@ mod tests {
fn query_results_convert_unsafe_json_integers_to_strings_for_js() {
let result = db::QueryResult {
columns: vec!["id".to_string(), "nested".to_string()],
column_types: Vec::new(),
rows: vec![vec![
serde_json::json!(2_041_797_190_226_354_178_i64),
serde_json::json!([1, 2_041_797_190_226_354_178_i64]),

View File

@ -1712,6 +1712,7 @@ pub async fn execute_on_pool_with_max_rows(
}
Ok(db::QueryResult {
columns,
column_types: Vec::new(),
rows: result_rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
@ -1723,6 +1724,7 @@ pub async fn execute_on_pool_with_max_rows(
let affected = con.execute(&sql, []).map_err(|e| e.to_string())?;
Ok(db::QueryResult {
columns: vec![],
column_types: Vec::new(),
rows: vec![],
affected_rows: affected as u64,
execution_time_ms: start.elapsed().as_millis(),

View File

@ -61,6 +61,11 @@ pub struct ColumnInfo {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryResult {
pub columns: Vec<String>,
/// Database type name for each column, parallel to `columns`. May be empty
/// when a driver cannot supply types (e.g. schemaless stores or fallback
/// query paths); consumers must tolerate a shorter/empty vector.
#[serde(default)]
pub column_types: Vec<String>,
pub rows: Vec<Vec<serde_json::Value>>,
pub affected_rows: u64,
pub execution_time_ms: u128,

View File

@ -0,0 +1,58 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { resolveHeaderColumnType } from "../../apps/desktop/src/lib/dataGridColumnType.ts";
test("prefers table-metadata type over the result type", () => {
const type = resolveHeaderColumnType({
tableColumnType: "numeric(20,6)",
resultColumnTypes: ["int4"],
actualColIdx: 0,
});
assert.equal(type, "numeric(20,6)");
});
test("falls back to the result type at the column index when no table meta", () => {
const type = resolveHeaderColumnType({
tableColumnType: undefined,
resultColumnTypes: ["oid", "char", "bigint"],
actualColIdx: 1,
});
assert.equal(type, "char");
});
test("uses actualColIdx (by index), not column order assumptions", () => {
// The third result column should resolve to the third type, regardless of
// any name-based reordering elsewhere.
const type = resolveHeaderColumnType({
resultColumnTypes: ["a_type", "b_type", "c_type"],
actualColIdx: 2,
});
assert.equal(type, "c_type");
});
test("returns undefined when the result type index is out of range", () => {
const type = resolveHeaderColumnType({
resultColumnTypes: ["int4"],
actualColIdx: 5,
});
assert.equal(type, undefined);
});
test("returns undefined when neither source has a type", () => {
assert.equal(resolveHeaderColumnType({ actualColIdx: 0 }), undefined);
assert.equal(resolveHeaderColumnType({ resultColumnTypes: [], actualColIdx: 0 }), undefined);
});
test("treats blank/whitespace types as absent and falls through", () => {
const type = resolveHeaderColumnType({
tableColumnType: " ",
resultColumnTypes: ["text"],
actualColIdx: 0,
});
assert.equal(type, "text");
assert.equal(
resolveHeaderColumnType({ tableColumnType: "", resultColumnTypes: [""], actualColIdx: 0 }),
undefined,
);
});