feat: display column comments in sidebar and data grid header (#37)

- Add comment field to ColumnInfo (MySQL COLUMN_COMMENT, PostgreSQL col_description)
- Show column comments in sidebar tree nodes
- Show column comments on hover in data grid header
- Default MySQL charset to utf8mb4 for proper CJK support
Closes #14
This commit is contained in:
skyler
2026-04-30 18:54:26 +08:00
committed by GitHub
parent 355164f20e
commit 9fa6ab063d
12 changed files with 34 additions and 13 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ fn duckdb_query_columns(con: &duckdb::Connection, table: &str) -> Result<Vec<db:
data_type: row.get::<_, String>(1)?,
is_nullable: row.get::<_, String>(2).unwrap_or_default() == "YES",
column_default: row.get::<_, Option<String>>(3)?,
extra: None,
extra: None, comment: None,
})
}).map_err(|e| e.to_string())?;
Ok(rows.filter_map(|r| r.ok()).collect())
+1 -1
View File
@@ -111,7 +111,7 @@ pub async fn get_columns(client: &ChClient, database: &str, table: &str) -> Resu
is_nullable,
column_default,
is_primary_key: is_pk,
extra: None,
extra: None, comment: None,
}
}).collect())
}
+1
View File
@@ -29,6 +29,7 @@ pub struct ColumnInfo {
pub column_default: Option<String>,
pub is_primary_key: bool,
pub extra: Option<String>,
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+2 -1
View File
@@ -149,7 +149,7 @@ pub async fn get_columns(
table: &str,
) -> Result<Vec<ColumnInfo>, String> {
let rows: Vec<MySqlRow> = sqlx::query(
"SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.EXTRA, \
"SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.EXTRA, c.COLUMN_COMMENT, \
CASE WHEN kcu.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK \
FROM information_schema.COLUMNS c \
LEFT JOIN information_schema.KEY_COLUMN_USAGE kcu \
@@ -175,6 +175,7 @@ pub async fn get_columns(
column_default: get_opt_str(row, "COLUMN_DEFAULT"),
is_primary_key: row.get::<i32, _>("IS_PK") == 1,
extra: get_opt_str(row, "EXTRA"),
comment: get_opt_str(row, "COLUMN_COMMENT").filter(|s| !s.is_empty()),
})
.collect())
}
+1 -1
View File
@@ -97,7 +97,7 @@ pub async fn get_columns(conn: &OracleClient, schema: &str, table: &str) -> Resu
data_type: row.get_string(1).unwrap_or("").to_string(),
is_nullable: row.get_string(2).unwrap_or("N") == "Y",
column_default: None,
extra: None,
extra: None, comment: None,
}
}).collect())
}
+3 -1
View File
@@ -71,7 +71,8 @@ pub async fn get_columns(
) -> Result<Vec<ColumnInfo>, String> {
let rows: Vec<PgRow> = sqlx::query(
"SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, \
CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN true ELSE false END AS is_pk \
CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN true ELSE false END AS is_pk, \
col_description((c.table_schema || '.' || c.table_name)::regclass, c.ordinal_position) AS column_comment \
FROM information_schema.columns c \
LEFT JOIN information_schema.key_column_usage kcu \
ON c.table_schema = kcu.table_schema \
@@ -99,6 +100,7 @@ pub async fn get_columns(
column_default: row.get::<Option<String>, _>("column_default"),
is_primary_key: row.get::<bool, _>("is_pk"),
extra: None,
comment: row.get::<Option<String>, _>("column_comment"),
})
.collect())
}
+1 -1
View File
@@ -52,7 +52,7 @@ pub async fn get_columns(pool: &SqlitePool, _schema: &str, table: &str) -> Resul
is_nullable: row.get::<i32, _>("notnull") == 0,
column_default: row.get::<Option<String>, _>("dflt_value"),
is_primary_key: row.get::<i32, _>("pk") > 0,
extra: None,
extra: None, comment: None,
})
.collect())
}
+1 -1
View File
@@ -111,7 +111,7 @@ pub async fn get_columns(client: &mut SqlServerClient, schema: &str, table: &str
is_nullable: row.get::<&str, _>(2).unwrap_or("NO") == "YES",
column_default: row.get::<&str, _>(3).map(|s| s.to_string()),
is_primary_key: row.get::<i32, _>(4).unwrap_or(0) == 1,
extra: None,
extra: None, comment: None,
}
}).collect())
}
+9 -6
View File
@@ -177,12 +177,15 @@ impl ConnectionConfig {
let value = self.url_params.as_deref().unwrap_or("").trim();
match self.db_type {
DatabaseType::Mysql => {
let base = "ssl-mode=preferred&charset=utf8mb4";
if value.is_empty() {
"ssl-mode=preferred".to_string()
base.to_string()
} else if value.contains("ssl-mode=") {
value.trim_start_matches('?').to_string()
let v = value.trim_start_matches('?');
if v.contains("charset=") { v.to_string() } else { format!("{v}&charset=utf8mb4") }
} else {
format!("ssl-mode=preferred&{}", value.trim_start_matches('?'))
let v = value.trim_start_matches('?');
if v.contains("charset=") { format!("ssl-mode=preferred&{v}") } else { format!("{base}&{v}") }
}
}
DatabaseType::Postgres => value.trim_start_matches('?').to_string(),
@@ -230,7 +233,7 @@ mod tests {
assert_eq!(
config.connection_url(),
"mysql://user%40tenant%23cluster:secret@10.1.2.3:2883?ssl-mode=preferred"
"mysql://user%40tenant%23cluster:secret@10.1.2.3:2883?ssl-mode=preferred&charset=utf8mb4"
);
}
@@ -240,7 +243,7 @@ mod tests {
assert_eq!(
config.connection_url(),
"mysql://root:p%40ss%3Aword%231@10.1.2.3:2883/db%2Fname?ssl-mode=preferred"
"mysql://root:p%40ss%3Aword%231@10.1.2.3:2883/db%2Fname?ssl-mode=preferred&charset=utf8mb4"
);
}
@@ -273,7 +276,7 @@ mod tests {
let url = config.redacted_connection_url();
assert_eq!(url, "mysql://10.1.2.3:2883/db%2Fname?ssl-mode=preferred");
assert_eq!(url, "mysql://10.1.2.3:2883/db%2Fname?ssl-mode=preferred&charset=utf8mb4");
assert!(!url.contains("user"));
assert!(!url.contains("p%40ss"));
assert!(!url.contains("p@ss"));
+11
View File
@@ -63,6 +63,16 @@ const columnTypeMap = computed(() => {
return map;
});
const columnCommentMap = computed(() => {
const map = new Map<string, string>();
if (props.tableMeta?.columns) {
for (const col of props.tableMeta.columns) {
if (col.comment) map.set(col.name, col.comment);
}
}
return map;
});
function shortTypeName(t: string): string {
const s = t.toLowerCase();
if (s === "character varying") return "varchar";
@@ -778,6 +788,7 @@ function escapeAndHighlightKeywords(s: string): string {
:key="col"
class="shrink-0 px-3 py-1.5 border-r border-border whitespace-nowrap cursor-pointer hover:bg-accent/50 select-none relative overflow-hidden"
:style="{ width: `var(--col-w-${colIdx})` }"
:title="columnCommentMap.get(col)"
@click="toggleSort(col)"
>
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
+2
View File
@@ -240,6 +240,7 @@ function disconnectConnection() {
const canExpand = !leafTypes.has(props.node.type);
const canPin = computed(() => pinnableTypes.has(props.node.type));
const isPinned = computed(() => props.node.pinned || connectionStore.isTreeNodePinned(props.node.id));
const columnComment = computed(() => props.node.type === "column" && props.node.meta && "comment" in props.node.meta ? (props.node.meta as any).comment : null);
const paddingLeft = `${props.depth * 16 + 8}px`;
const isConnected = computed(() =>
props.node.type === "connection" && !!props.node.connectionId && connectionStore.connectedIds.has(props.node.connectionId)
@@ -299,6 +300,7 @@ async function showMore() {
<component v-else :is="getIconInfo(node)?.icon || Database" class="w-3.5 h-3.5 shrink-0" :class="getIconInfo(node)?.colorClass" />
<span v-if="node.type === 'connection' && connectionColor" class="h-3 w-1.5 rounded-full shrink-0" :style="{ backgroundColor: connectionColor }" />
<span class="min-w-0 flex-1 truncate">{{ isGroupLabel(node) ? t(node.label) : node.label }}</span>
<span v-if="columnComment" class="truncate text-muted-foreground/60 text-[10px] max-w-[40%]">{{ columnComment }}</span>
<span v-if="node.type === 'connection' && node.connectionId && connectionStore.connectedIds.has(node.connectionId)" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
<button
v-if="canPin"
+1
View File
@@ -39,6 +39,7 @@ export interface ColumnInfo {
column_default: string | null;
is_primary_key: boolean;
extra: string | null;
comment?: string | null;
}
export interface IndexInfo {