Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 45 additions & 11 deletions src-tauri/src/database/mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,43 @@ impl ConnectionPool for MySQLPool {
}
}

/// Try to read a column value as String, falling back to UTF-8 decoded bytes
/// if the direct String conversion fails. This handles cases where mysql_async's
/// `FromValue<String>` fails on certain INFORMATION_SCHEMA virtual table columns
/// due to charset/collation differences between the virtual table encoding
/// and the connection's character set.
fn get_str(row: &Row, idx: usize) -> String {
row.get_opt::<String, _>(idx)
.and_then(|r| r.ok())
.or_else(|| {
row.get_opt::<Vec<u8>, _>(idx)
.and_then(|r| r.ok())
.map(|b| String::from_utf8_lossy(&b).to_string())
})
.unwrap_or_default()
}

fn get_str_by_name(row: &Row, name: &str) -> String {
row.get_opt::<String, _>(name)
.and_then(|r| r.ok())
.or_else(|| {
row.get_opt::<Vec<u8>, _>(name)
.and_then(|r| r.ok())
.map(|b| String::from_utf8_lossy(&b).to_string())
})
.unwrap_or_default()
}

fn get_opt_str(row: &Row, idx: usize) -> Option<String> {
row.get_opt::<String, _>(idx)
.and_then(|r| r.ok())
.or_else(|| {
row.get_opt::<Vec<u8>, _>(idx)
.and_then(|r| r.ok())
.map(|b| String::from_utf8_lossy(&b).to_string())
})
}

/// MySQL database adapter.
pub struct MySQLAdapter {
pub(crate) config: ConnectionConfig,
Expand Down Expand Up @@ -672,21 +709,18 @@ impl DatabaseAdapter for MySQLAdapter {
let columns = rows
.into_iter()
.map(|row| {
let name: String = row.get_opt(0).and_then(|r| r.ok()).unwrap_or_default();
let data_type: String = row.get_opt(1).and_then(|r| r.ok()).unwrap_or_default();
let is_nullable: String = row
.get_opt(2)
.and_then(|r| r.ok())
.unwrap_or_else(|| "YES".to_string());
let default_value: Option<String> = row.get_opt(3).and_then(|r| r.ok()).flatten();
let name: String = get_str(&row, 0);
let data_type: String = get_str(&row, 1);
let is_nullable: String = get_str(&row, 2);
let default_value: Option<String> = get_opt_str(&row, 3);
let max_length: Option<u32> = row.get_opt(4).and_then(|r| r.ok()).flatten();
let precision: Option<u32> = row.get_opt(5).and_then(|r| r.ok()).flatten();
let scale: Option<u32> = row.get_opt(6).and_then(|r| r.ok()).flatten();
let column_key: String = row.get_opt(7).and_then(|r| r.ok()).unwrap_or_default();
let extra: String = row.get_opt(8).and_then(|r| r.ok()).unwrap_or_default();
let description: Option<String> = row.get_opt(9).and_then(|r| r.ok()).flatten();
let column_key: String = get_str_by_name(&row, "COLUMN_KEY");
let extra: String = get_str(&row, 8);
let description: Option<String> = get_opt_str(&row, 9);

let is_primary_key = column_key.to_uppercase().contains("PRI");
let is_primary_key = column_key.eq_ignore_ascii_case("PRI");
let is_auto_increment = extra.to_uppercase().contains("AUTO_INCREMENT");

ColumnInfo {
Expand Down
70 changes: 36 additions & 34 deletions src/components/database-browser/DataTableView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -183,12 +183,16 @@ const pkColumns = computed(() =>
)

function extractPkValues(row: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(pkColumns.value.map((col: string) => [col, row[col] ?? null]))
const cols = pkColumns.value.length > 0 ? pkColumns.value : (data.value?.columns ?? [])
return Object.fromEntries(cols.map((col: string) => [col, row[col] ?? null]))
}

function formatPkSummary(row: Record<string, unknown>): string {
return pkColumns.value.length > 0
? pkColumns.value.map((col: string) => `${col}: ${formatTableValue(row[col])}`).join(', ')
const cols = pkColumns.value.length > 0
? pkColumns.value
: (data.value?.columns ?? []).slice(0, 3)
return cols.length > 0
? cols.map((col: string) => `${col}: ${formatTableValue(row[col])}`).join(', ')
: Object.entries(row).slice(0, 2).map(([k, v]) => `${k}: ${formatTableValue(v)}`).join(', ')
}

Expand Down Expand Up @@ -323,15 +327,15 @@ async function fetchColumnInfo() {
return
}
try {
columnInfoList.value = await invoke<ColumnTypeInfo[]>('list_columns', {
const result = await invoke<ColumnTypeInfo[]>('list_columns', {
connectionId: props.connectionId,
database: props.database,
schema: props.schema ?? null,
tableName: props.tableName,
})
columnInfoList.value = result
}
catch (err) {
console.error('Failed to fetch column info:', err)
catch {
columnInfoList.value = []
}
}
Expand Down Expand Up @@ -512,9 +516,8 @@ async function exportCSV() {
function openDeleteDialog(row: Record<string, unknown>) {
if (pkColumns.value.length === 0) {
toast.warning(t('components.dataTableView.notifications.noPrimaryKey'), {
description: t('components.dataTableView.notifications.noPrimaryKeyDesc'),
description: t('components.dataTableView.notifications.fallbackAllColumns'),
})
return
}
deletingRow.value = row
deleteDialogOpen.value = true
Expand Down Expand Up @@ -603,9 +606,8 @@ function rawValueToString(v: unknown): string {
function openEditDialog(row: Record<string, unknown>) {
if (pkColumns.value.length === 0) {
toast.warning(t('components.dataTableView.notifications.noPrimaryKey'), {
description: t('components.dataTableView.notifications.noPrimaryKeyDesc'),
description: t('components.dataTableView.notifications.fallbackAllColumns'),
})
return
}
editingRow.value = row
editErrors.value = {}
Expand Down Expand Up @@ -946,7 +948,7 @@ watch(

<!-- Delete Selected button -->
<Button
v-if="selectedRows.size > 0 && pkColumns.length > 0"
v-if="selectedRows.size > 0"
variant="destructive"
size="sm"
class="text-xs flex-shrink-0 h-7"
Expand Down Expand Up @@ -1045,16 +1047,16 @@ watch(
>
<thead>
<tr class="border-b">
<th v-if="pkColumns.length > 0" class="data-table-header text-center w-10">
<input
type="checkbox"
class="h-3 w-3 cursor-pointer"
:checked="allRowsSelected"
@change="toggleSelectAll"
>
</th>
<th class="data-table-header text-center w-10">
#
<th class="data-table-header text-center w-16">
<div class="flex gap-1 items-center justify-center">
<input
type="checkbox"
class="h-3 w-3 cursor-pointer"
:checked="allRowsSelected"
@change="toggleSelectAll"
>
<span class="text-xs text-muted-foreground font-normal">#</span>
</div>
</th>
<th
v-for="col in visibleColumns"
Expand All @@ -1080,16 +1082,16 @@ watch(
class="border-b hover:bg-muted/50"
:class="{ 'bg-muted/30': selectedRows.has(i) }"
>
<td v-if="pkColumns.length > 0" class="text-xs px-3 py-1.5 text-center w-10">
<input
type="checkbox"
class="h-3 w-3 cursor-pointer"
:checked="selectedRows.has(i)"
@change="toggleRowSelection(i)"
>
</td>
<td class="text-xs text-muted-foreground px-3 py-1.5 text-center w-10">
{{ offset + i + 1 }}
<td class="text-xs px-2 py-1.5 text-center w-16">
<div class="flex gap-1 items-center justify-center">
<input
type="checkbox"
class="h-3 w-3 cursor-pointer"
:checked="selectedRows.has(i)"
@change="toggleRowSelection(i)"
>
<span class="text-muted-foreground tabular-nums">{{ offset + i + 1 }}</span>
</div>
</td>
<td
v-for="col in visibleColumns"
Expand All @@ -1107,7 +1109,7 @@ watch(
<Button
variant="ghost"
size="icon"
class="text-foreground h-6 w-6"
class="text-blue-600 h-6 w-6 dark:text-blue-400"
:title="t('components.dataTableView.editRow')"
@click.stop="openEditDialog(row)"
>
Expand All @@ -1117,7 +1119,7 @@ watch(
<Button
variant="ghost"
size="icon"
class="text-foreground h-6 w-6 hover:text-destructive"
class="text-rose-500 h-6 w-6 dark:text-rose-400 hover:text-red-600 dark:hover:text-red-400"
:title="t('components.dataTableView.deleteRow')"
@click.stop="openDeleteDialog(row)"
>
Expand Down Expand Up @@ -1400,7 +1402,7 @@ watch(

/* In body rows the actions cell background should match the row hover state */
tr:hover .data-table-actions-col {
background-color: hsl(var(--muted) / 0.5);
background-color: hsl(var(--muted));
}

.col-header-cell {
Expand Down
Loading
Loading