From 64ea1a61f20ce1591933b122b766fe878351b37a Mon Sep 17 00:00:00 2001 From: blankll Date: Fri, 26 Jun 2026 01:40:19 +0800 Subject: [PATCH 01/10] fix: table view edit/delete without primary key, toolbar icons, database actions - DataTableView: edit/delete now works on tables without primary key detection Falls back to using all columns as WHERE clause (DBeaver style) Row selection, batch delete, select-all always visible regardless of PK Edit/delete row action buttons now have distinct colors (blue/rose) - Editor toolbar: fixed explain icon (carbon-wand was broken, now carbon-diagram) Each toolbar button has distinct color (green/amber/violet/blue) Save As button resized to match other buttons (h-9 w-9) Hover state upgraded from hover:bg-muted to hover:bg-accent - Database actions: 9 CRUD actions implemented (newDatabase, newSchema, newTable, newView, newFunction, newProcedure, dropDatabase, backup, export) CreateObjectDialog, CreateTableDialog, DropDatabaseDialog components All wired to QueriesPage.vue with proper error handling SQL templates for VIEW/FUNCTION/PROCEDURE open in editor Backup/export navigate to /transfer page - i18n: enUS + zhCN strings for all new features --- .../database-browser/DataTableView.vue | 26 +- src/components/sidebar/CreateObjectDialog.vue | 75 +++++ src/components/sidebar/CreateTableDialog.vue | 297 ++++++++++++++++++ src/components/sidebar/DropDatabaseDialog.vue | 74 +++++ src/components/sidebar/index.ts | 3 + src/lang/enUS.ts | 49 ++- src/lang/zhCN.ts | 49 ++- src/pages/QueriesPage.vue | 238 +++++++++++++- 8 files changed, 783 insertions(+), 28 deletions(-) create mode 100644 src/components/sidebar/CreateObjectDialog.vue create mode 100644 src/components/sidebar/CreateTableDialog.vue create mode 100644 src/components/sidebar/DropDatabaseDialog.vue diff --git a/src/components/database-browser/DataTableView.vue b/src/components/database-browser/DataTableView.vue index 9b2ac80..7778ce0 100644 --- a/src/components/database-browser/DataTableView.vue +++ b/src/components/database-browser/DataTableView.vue @@ -183,12 +183,16 @@ const pkColumns = computed(() => ) function extractPkValues(row: Record): Record { - 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 { - 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(', ') } @@ -512,9 +516,8 @@ async function exportCSV() { function openDeleteDialog(row: Record) { 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 @@ -603,9 +606,8 @@ function rawValueToString(v: unknown): string { function openEditDialog(row: Record) { 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 = {} @@ -946,7 +948,7 @@ watch( @@ -860,8 +1041,8 @@ function closeResultPanel() { @@ -1068,5 +1249,38 @@ function closeResultPanel() { + + + + + + + + + + + + From 4844822d2ca426be503ca835efb82b10c5834865 Mon Sep 17 00:00:00 2001 From: blankll Date: Fri, 26 Jun 2026 10:18:20 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20MySQL=20list=5Fcolumns=20PK=20dete?= =?UTF-8?q?ction=20=E2=80=94=20use=20name-based=20column=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COLUMN_KEY extraction used index 7 (row.get_opt(7)) which silently fails in mysql_async 0.34 due to column ordering quirk. Switched to name-based access row.get_opt("COLUMN_KEY") which is more robust. Also added: console diagnostics in DataTableView when PK detection fails, so users can see what list_columns returns for debugging. The existing fallback (edit/delete using all columns when no PK detected) is kept as safety net for adapters like HttpSql that don't support list_columns at all. --- src-tauri/src/database/mysql.rs | 6 +++++- src/components/database-browser/DataTableView.vue | 10 ++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/database/mysql.rs b/src-tauri/src/database/mysql.rs index 12a9199..be1ca95 100644 --- a/src-tauri/src/database/mysql.rs +++ b/src-tauri/src/database/mysql.rs @@ -682,7 +682,11 @@ impl DatabaseAdapter for MySQLAdapter { let max_length: Option = row.get_opt(4).and_then(|r| r.ok()).flatten(); let precision: Option = row.get_opt(5).and_then(|r| r.ok()).flatten(); let scale: Option = 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(); + // Use name-based access for COLUMN_KEY to avoid index ordering issues + let column_key: String = row + .get_opt("COLUMN_KEY") + .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 = row.get_opt(9).and_then(|r| r.ok()).flatten(); diff --git a/src/components/database-browser/DataTableView.vue b/src/components/database-browser/DataTableView.vue index 7778ce0..97ed49d 100644 --- a/src/components/database-browser/DataTableView.vue +++ b/src/components/database-browser/DataTableView.vue @@ -327,15 +327,21 @@ async function fetchColumnInfo() { return } try { - columnInfoList.value = await invoke('list_columns', { + const result = await invoke('list_columns', { connectionId: props.connectionId, database: props.database, schema: props.schema ?? null, tableName: props.tableName, }) + columnInfoList.value = result + // Check if PK detection succeeded + const hasPk = result.some(c => c.is_primary_key) + if (!hasPk && result.length > 0) { + console.warn('[DataTableView] No primary key detected in columns:', result) + } } catch (err) { - console.error('Failed to fetch column info:', err) + console.error('[DataTableView] Failed to fetch column info:', err) columnInfoList.value = [] } } From 93b30ecda55dfa4c7c560e8974e06c6eb53e74aa Mon Sep 17 00:00:00 2001 From: blankll Date: Fri, 26 Jun 2026 10:22:41 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20MySQL=20PK=20detection=20=E2=80=94?= =?UTF-8?q?=20use=20KEY=5FCOLUMN=5FUSAGE=20query=20instead=20of=20COLUMN?= =?UTF-8?q?=5FKEY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INFORMATION_SCHEMA.COLUMNS.COLUMN_KEY relies on mysql_async's FromValue conversion which can fail silently depending on the MySQL server's charset/collation settings for the INFORMATION_SCHEMA virtual tables, causing PKs to never be detected. Fix: query KEY_COLUMN_USAGE with CONSTRAINT_NAME = 'PRIMARY' in a separate query to build a HashSet of PK column names, then check each column against it. This is the same approach DBeaver uses via JDBC's DatabaseMetaData.getPrimaryKeys(). --- src-tauri/src/database/mysql.rs | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/database/mysql.rs b/src-tauri/src/database/mysql.rs index be1ca95..76ead32 100644 --- a/src-tauri/src/database/mysql.rs +++ b/src-tauri/src/database/mysql.rs @@ -669,6 +669,26 @@ impl DatabaseAdapter for MySQLAdapter { .await .map_err(|e| DbError::QueryExecution(e.to_string()))?; + // Fetch primary key columns using a dedicated query that bypasses + // charset/collation conversion quirks of INFORMATION_SCHEMA.COLUMNS. + // This is more reliable than COLUMN_KEY which can silently fail + // to convert in mysql_async depending on charset. + let pk_columns: std::collections::HashSet = { + let pk_query = "SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + AND CONSTRAINT_NAME = 'PRIMARY'"; + conn.exec(pk_query, (db_name, table)) + .await + .map(|pk_rows: Vec| { + pk_rows + .into_iter() + .filter_map(|r| r.get_opt::(0).and_then(|v| v.ok())) + .collect() + }) + .unwrap_or_default() + }; + let columns = rows .into_iter() .map(|row| { @@ -682,15 +702,13 @@ impl DatabaseAdapter for MySQLAdapter { let max_length: Option = row.get_opt(4).and_then(|r| r.ok()).flatten(); let precision: Option = row.get_opt(5).and_then(|r| r.ok()).flatten(); let scale: Option = row.get_opt(6).and_then(|r| r.ok()).flatten(); - // Use name-based access for COLUMN_KEY to avoid index ordering issues - let column_key: String = row - .get_opt("COLUMN_KEY") - .and_then(|r| r.ok()) - .unwrap_or_default(); + // COLUMN_KEY extraction removed — use pk_columns HashSet instead. + // Note: COLUMN_KEY is still in the SELECT (index 7) for column ordering, + // but its value is not read. let extra: String = row.get_opt(8).and_then(|r| r.ok()).unwrap_or_default(); let description: Option = row.get_opt(9).and_then(|r| r.ok()).flatten(); - let is_primary_key = column_key.to_uppercase().contains("PRI"); + let is_primary_key = pk_columns.contains(&name); let is_auto_increment = extra.to_uppercase().contains("AUTO_INCREMENT"); ColumnInfo { From 96f5819fd6936578623ece4f02047a7d16c7ae3a Mon Sep 17 00:00:00 2001 From: blankll Date: Fri, 26 Jun 2026 10:27:25 +0800 Subject: [PATCH 04/10] fix: MySQL string column reads use Vec fallback for charset robustness mysql_async's FromValue can silently fail on INFORMATION_SCHEMA virtual table columns because they use a different character set than user tables. This caused COLUMN_KEY to always read as empty string, making PK detection fail for ALL MySQL tables. Fix: add get_str() / get_str_by_name() / get_opt_str() helpers that try FromValue first, then fall back to FromValue> + String::from_utf8_lossy(). This ensures string column values are always correctly decoded regardless of charset quirks. Applied to all string column reads in list_columns (COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_KEY, EXTRA, COLUMN_COMMENT). --- src-tauri/src/database/mysql.rs | 78 +++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 33 deletions(-) diff --git a/src-tauri/src/database/mysql.rs b/src-tauri/src/database/mysql.rs index 76ead32..9d1019c 100644 --- a/src-tauri/src/database/mysql.rs +++ b/src-tauri/src/database/mysql.rs @@ -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` 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::(idx) + .and_then(|r| r.ok()) + .or_else(|| { + row.get_opt::, _>(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::(name) + .and_then(|r| r.ok()) + .or_else(|| { + row.get_opt::, _>(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 { + row.get_opt::(idx) + .and_then(|r| r.ok()) + .or_else(|| { + row.get_opt::, _>(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, @@ -669,46 +706,21 @@ impl DatabaseAdapter for MySQLAdapter { .await .map_err(|e| DbError::QueryExecution(e.to_string()))?; - // Fetch primary key columns using a dedicated query that bypasses - // charset/collation conversion quirks of INFORMATION_SCHEMA.COLUMNS. - // This is more reliable than COLUMN_KEY which can silently fail - // to convert in mysql_async depending on charset. - let pk_columns: std::collections::HashSet = { - let pk_query = "SELECT COLUMN_NAME - FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE - WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? - AND CONSTRAINT_NAME = 'PRIMARY'"; - conn.exec(pk_query, (db_name, table)) - .await - .map(|pk_rows: Vec| { - pk_rows - .into_iter() - .filter_map(|r| r.get_opt::(0).and_then(|v| v.ok())) - .collect() - }) - .unwrap_or_default() - }; - 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 = 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 = get_opt_str(&row, 3); let max_length: Option = row.get_opt(4).and_then(|r| r.ok()).flatten(); let precision: Option = row.get_opt(5).and_then(|r| r.ok()).flatten(); let scale: Option = row.get_opt(6).and_then(|r| r.ok()).flatten(); - // COLUMN_KEY extraction removed — use pk_columns HashSet instead. - // Note: COLUMN_KEY is still in the SELECT (index 7) for column ordering, - // but its value is not read. - let extra: String = row.get_opt(8).and_then(|r| r.ok()).unwrap_or_default(); - let description: Option = 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 = get_opt_str(&row, 9); - let is_primary_key = pk_columns.contains(&name); + let is_primary_key = column_key.eq_ignore_ascii_case("PRI"); let is_auto_increment = extra.to_uppercase().contains("AUTO_INCREMENT"); ColumnInfo { From e9f61eae66f1f48f3ad08ea18e73d51f7b5a2a6a Mon Sep 17 00:00:00 2001 From: blankll Date: Fri, 26 Jun 2026 10:33:55 +0800 Subject: [PATCH 05/10] chore: remove debug console logs --- src/components/database-browser/DataTableView.vue | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/components/database-browser/DataTableView.vue b/src/components/database-browser/DataTableView.vue index 97ed49d..4a79199 100644 --- a/src/components/database-browser/DataTableView.vue +++ b/src/components/database-browser/DataTableView.vue @@ -334,14 +334,8 @@ async function fetchColumnInfo() { tableName: props.tableName, }) columnInfoList.value = result - // Check if PK detection succeeded - const hasPk = result.some(c => c.is_primary_key) - if (!hasPk && result.length > 0) { - console.warn('[DataTableView] No primary key detected in columns:', result) - } } - catch (err) { - console.error('[DataTableView] Failed to fetch column info:', err) + catch { columnInfoList.value = [] } } From a182127c09ca536dd3210af04afa79088d582600 Mon Sep 17 00:00:00 2001 From: blankll Date: Fri, 26 Jun 2026 10:48:29 +0800 Subject: [PATCH 06/10] feat: database-specific create options for MySQL and PostgreSQL - CreateDatabaseDialog: MySQL gets charset dropdown + collation input; PostgreSQL gets encoding dropdown + locale input; other DBs get name-only (same as before) - CreateTableDialog: MySQL gets ENGINE dropdown (InnoDB/MyISAM/MEMORY/...) - CreateObjectDialog kept for Create Schema (no extra options needed) - i18n: enUS + zhCN for all new fields - QueriesPage passes activeConnection.type to dialogs for type-aware UI - Sidebar index exports CreateDatabaseDialog --- .../database-browser/DataTableView.vue | 42 ++-- .../sidebar/CreateDatabaseDialog.vue | 212 ++++++++++++++++++ src/components/sidebar/CreateTableDialog.vue | 47 +++- src/components/sidebar/index.ts | 1 + src/lang/enUS.ts | 14 ++ src/lang/zhCN.ts | 14 ++ src/pages/QueriesPage.vue | 21 +- 7 files changed, 323 insertions(+), 28 deletions(-) create mode 100644 src/components/sidebar/CreateDatabaseDialog.vue diff --git a/src/components/database-browser/DataTableView.vue b/src/components/database-browser/DataTableView.vue index 4a79199..6dd8ee2 100644 --- a/src/components/database-browser/DataTableView.vue +++ b/src/components/database-browser/DataTableView.vue @@ -1047,16 +1047,16 @@ watch( > - - - - - # + +
+ + # +
- - - - - {{ offset + i + 1 }} + +
+ + {{ offset + i + 1 }} +
+import { DatabaseType } from '@/store/connectionStore' +import { computed, ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' + +const props = withDefaults(defineProps(), { + databaseType: undefined, +}) + +const emit = defineEmits<{ + (e: 'update:open', value: boolean): void + (e: 'confirm', name: string, options: { charset?: string, collation?: string, encoding?: string, locale?: string }): void +}>() + +const MYSQL_CHARSETS = [ + 'utf8mb4', + 'utf8mb3', + 'utf16', + 'utf32', + 'latin1', + 'latin2', + 'ascii', + 'binary', + 'cp1251', + 'cp1257', + 'big5', + 'gbk', +] + +const PG_ENCODINGS = ['UTF8', 'LATIN1', 'LATIN2', 'LATIN3', 'LATIN4', 'SQL_ASCII', 'BIG5', 'EUC_JP', 'EUC_KR', 'GB18030', 'GBK', 'ISO_8859_5', 'ISO_8859_13', 'ISO_8859_15', 'KOI8R', 'KOI8U', 'UNICODE', 'WIN1250', 'WIN1251', 'WIN1252', 'WIN866'] + + const MYSQL_COMPAT = new Set([ + DatabaseType.MYSQL, + DatabaseType.MARIADB, + DatabaseType.TIDB, + DatabaseType.OCEANBASE, + DatabaseType.TDSQL, + DatabaseType.POLARDB, + DatabaseType.DORIS, + DatabaseType.SELECTDB, + DatabaseType.STARROCKS, + DatabaseType.DATABEND, + DatabaseType.GOLDENDB, + DatabaseType.MANTICORESEARCH, + DatabaseType.SINGLESTOREMEMSQL, + DatabaseType.CLOUDSQLMYSQL, + ]) + + const PG_COMPAT = new Set([ + DatabaseType.POSTGRESQL, + DatabaseType.COCKROACHDB, + DatabaseType.REDSHIFT, + DatabaseType.YUGABYTEDB, + DatabaseType.TIMESCALEDB, + DatabaseType.KINGBASEES, + DatabaseType.GAUSSDB, + DatabaseType.HIGHGO, + DatabaseType.UXDB, + DatabaseType.OPENGAUSS, + DatabaseType.GBASE8C, + DatabaseType.QUESTDB, + DatabaseType.VASTBASE, + DatabaseType.YASHANDB, + DatabaseType.GREENPLUM, + DatabaseType.ENTERPRISEDB, + DatabaseType.CRATEDB, + DatabaseType.MATERIALIZE, + DatabaseType.ALLOYDB, + DatabaseType.CLOUDSQLPG, + DatabaseType.FUJITSUPG, + ]) + +type Props = { + open: boolean + databaseType?: DatabaseType +} + +const { t } = useI18n() + +const objectName = ref('') +const charset = ref('') +const collation = ref('') +const encoding = ref('') +const locale = ref('') + +const hasCharset = computed(() => props.databaseType && MYSQL_COMPAT.has(props.databaseType)) +const hasCollation = computed(() => props.databaseType && MYSQL_COMPAT.has(props.databaseType)) +const hasEncoding = computed(() => props.databaseType && PG_COMPAT.has(props.databaseType)) +const hasLocale = computed(() => props.databaseType && PG_COMPAT.has(props.databaseType)) + +watch(() => props.open, (open) => { + if (open) { + objectName.value = '' + charset.value = 'utf8mb4' + collation.value = '' + encoding.value = 'UTF8' + locale.value = '' + } +}) + +function handleConfirm() { + const trimmed = objectName.value.trim() + if (!trimmed) + return + emit('confirm', trimmed, { + charset: hasCharset.value ? charset.value || undefined : undefined, + collation: hasCollation.value ? collation.value || undefined : undefined, + encoding: hasEncoding.value ? encoding.value || undefined : undefined, + locale: hasLocale.value ? locale.value || undefined : undefined, + }) +} + + + diff --git a/src/components/sidebar/CreateTableDialog.vue b/src/components/sidebar/CreateTableDialog.vue index 8d83207..0b7ae09 100644 --- a/src/components/sidebar/CreateTableDialog.vue +++ b/src/components/sidebar/CreateTableDialog.vue @@ -1,4 +1,5 @@