From ba58c902fddae87c5d1e3a9d21397e925a90228c Mon Sep 17 00:00:00 2001 From: Dhruv Shah Date: Sat, 8 Aug 2026 19:31:29 -0400 Subject: [PATCH] Fix database UI query issues --- src-tauri/src/drivers/mysql/mod.rs | 73 +++++++++++-------- src-tauri/src/drivers/mysql/tests.rs | 32 +++++++- src/components/modals/ErrorModal.tsx | 24 +++++- src/components/modals/QuickNavigatorModal.tsx | 33 +++------ tests/components/modals/ErrorModal.test.tsx | 30 ++++++++ 5 files changed, 134 insertions(+), 58 deletions(-) create mode 100644 tests/components/modals/ErrorModal.test.tsx diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index 66e2fd357..b99613e0d 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -746,26 +746,12 @@ pub async fn delete_record( .await } -pub async fn update_record( - params: &ConnectionParams, - table: &str, - pk_map: &HashMap, - col_name: &str, - new_val: serde_json::Value, +fn push_mysql_update_value( + qb: &mut sqlx::QueryBuilder<'_, sqlx::MySql>, + new_val: &serde_json::Value, + text: TextProto, max_blob_size: u64, -) -> Result { - let pool = get_mysql_pool(params).await?; - // Behind a prepared-statement-less bastion every value is inlined as an - // escaped literal instead of bound (see `force_text_protocol`). - let text = resolve_text_proto(&pool, params).await?; - let pk_pairs = build_mysql_pk_where(pk_map)?; - - let mut qb = sqlx::QueryBuilder::new(format!( - "UPDATE `{}` SET `{}` = ", - escape_identifier(table), - escape_identifier(col_name) - )); - +) -> Result<(), String> { match new_val { serde_json::Value::Number(n) => { if n.is_i64() { @@ -784,7 +770,7 @@ pub async fn update_record( if s == "__USE_DEFAULT__" { qb.push("DEFAULT"); } else if let Some(bytes) = - crate::drivers::common::decode_blob_wire_format(&s, max_blob_size) + crate::drivers::common::decode_blob_wire_format(s, max_blob_size) { // Blob wire format: decode to raw bytes so the DB stores binary data, // not the internal wire format string. @@ -793,17 +779,17 @@ pub async fn update_record( } else { qb.push_bind(bytes); } - } else if is_raw_sql_function(&s) { + } else if is_raw_sql_function(s) { qb.push(s); - } else if is_wkt_geometry(&s) { + } else if is_wkt_geometry(s) { qb.push("ST_GeomFromText("); if text.enabled { - qb.push(mysql_string_literal(&s, text.no_backslash_escapes)); + qb.push(mysql_string_literal(s, text.no_backslash_escapes)); } else { - qb.push_bind(s); + qb.push_bind(s.clone()); } qb.push(")"); - } else if let Some(n) = parse_unsafe_bigint_string(&s) { + } else if let Some(n) = parse_unsafe_bigint_string(s) { // Bigints outside JS safe range come back from the UI as strings // (see drivers::common::i64_to_json). Bind them as native i64 so // BIGINT columns receive the exact value. @@ -813,33 +799,56 @@ pub async fn update_record( qb.push_bind(n); } } else if text.enabled { - qb.push(mysql_string_literal(&s, text.no_backslash_escapes)); + qb.push(mysql_string_literal(s, text.no_backslash_escapes)); } else { - qb.push_bind(s); + qb.push_bind(s.clone()); } } serde_json::Value::Bool(b) => { if text.enabled { - qb.push(if b { "1" } else { "0" }); + qb.push(if *b { "1" } else { "0" }); } else { - qb.push_bind(b); + qb.push_bind(*b); } } serde_json::Value::Null => { qb.push("NULL"); } serde_json::Value::Object(_) | serde_json::Value::Array(_) => { - let json_str = serde_json::to_string(&new_val).map_err(|e| e.to_string())?; - qb.push("CAST("); + let json_str = serde_json::to_string(new_val).map_err(|e| e.to_string())?; if text.enabled { qb.push(mysql_string_literal(&json_str, text.no_backslash_escapes)); } else { qb.push_bind(json_str); } - qb.push(" AS JSON)"); } } + Ok(()) +} + +pub async fn update_record( + params: &ConnectionParams, + table: &str, + pk_map: &HashMap, + col_name: &str, + new_val: serde_json::Value, + max_blob_size: u64, +) -> Result { + let pool = get_mysql_pool(params).await?; + // Behind a prepared-statement-less bastion every value is inlined as an + // escaped literal instead of bound (see `force_text_protocol`). + let text = resolve_text_proto(&pool, params).await?; + let pk_pairs = build_mysql_pk_where(pk_map)?; + + let mut qb = sqlx::QueryBuilder::new(format!( + "UPDATE `{}` SET `{}` = ", + escape_identifier(table), + escape_identifier(col_name) + )); + + push_mysql_update_value(&mut qb, &new_val, text, max_blob_size)?; + qb.push(" WHERE "); let mut first = true; for (col, val) in &pk_pairs { diff --git a/src-tauri/src/drivers/mysql/tests.rs b/src-tauri/src/drivers/mysql/tests.rs index 4eaf13eb6..06a26b323 100644 --- a/src-tauri/src/drivers/mysql/tests.rs +++ b/src-tauri/src/drivers/mysql/tests.rs @@ -1,5 +1,5 @@ use super::build_mysql_pk_where; -use super::{is_text_protocol_stmt, MysqlDriver}; +use super::{is_text_protocol_stmt, push_mysql_update_value, MysqlDriver, TextProto}; use super::helpers::{inline_str_placeholders, mysql_bytes_literal, mysql_string_literal}; use crate::drivers::driver_trait::DatabaseDriver; use crate::models::{ConnectionParams, DatabaseSelection}; @@ -72,6 +72,36 @@ fn mysql_bytes_literal_hex_encodes() { assert_eq!(mysql_bytes_literal(b"AB"), "x'4142'"); } +#[test] +fn mysql_json_update_value_binds_without_json_cast() { + let mut qb = sqlx::QueryBuilder::::new("SET `payload` = "); + + push_mysql_update_value( + &mut qb, + &serde_json::json!({ "ok": true }), + TextProto::PREPARED, + 1024, + ) + .unwrap(); + + assert_eq!(qb.sql(), "SET `payload` = ?"); +} + +#[test] +fn mysql_json_update_value_inlines_without_json_cast_in_text_protocol() { + let mut qb = sqlx::QueryBuilder::::new("SET `payload` = "); + + push_mysql_update_value( + &mut qb, + &serde_json::json!({ "ok": true }), + TextProto::protocol_only(true), + 1024, + ) + .unwrap(); + + assert_eq!(qb.sql(), "SET `payload` = '{\\\"ok\\\":true}'"); +} + #[test] fn inline_str_placeholders_substitutes_in_order() { let sql = "WHERE table_schema = ? AND table_name = ?"; diff --git a/src/components/modals/ErrorModal.tsx b/src/components/modals/ErrorModal.tsx index 5d999d41f..24f19071d 100644 --- a/src/components/modals/ErrorModal.tsx +++ b/src/components/modals/ErrorModal.tsx @@ -1,6 +1,8 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { AlertTriangle, X } from "lucide-react"; +import { AlertTriangle, Check, Copy, X } from "lucide-react"; import { Modal } from "../ui/Modal"; +import { copyTextToClipboard } from "../../utils/clipboard"; interface ErrorModalProps { isOpen: boolean; @@ -10,6 +12,13 @@ interface ErrorModalProps { export const ErrorModal = ({ isOpen, onClose, message }: ErrorModalProps) => { const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await copyTextToClipboard(message); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + }; return ( @@ -32,10 +41,19 @@ export const ErrorModal = ({ isOpen, onClose, message }: ErrorModalProps) => {
-

{message}

+
+            {message}
+          
-
+
+