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
73 changes: 41 additions & 32 deletions src-tauri/src/drivers/mysql/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -757,26 +757,12 @@ pub async fn delete_record(
.await
}

pub async fn update_record(
params: &ConnectionParams,
table: &str,
pk_map: &HashMap<String, serde_json::Value>,
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<u64, String> {
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() {
Expand All @@ -795,7 +781,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.
Expand All @@ -804,17 +790,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.
Expand All @@ -824,33 +810,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<String, serde_json::Value>,
col_name: &str,
new_val: serde_json::Value,
max_blob_size: u64,
) -> Result<u64, String> {
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 {
Expand Down
32 changes: 31 additions & 1 deletion src-tauri/src/drivers/mysql/tests.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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::<sqlx::MySql>::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::<sqlx::MySql>::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 = ?";
Expand Down
24 changes: 21 additions & 3 deletions src/components/ui/ErrorDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Check, ChevronDown, ChevronUp, Copy } from "lucide-react";
import type { TFunction } from "i18next";

interface ErrorDisplayProps {
Expand All @@ -9,15 +9,33 @@ interface ErrorDisplayProps {

export function ErrorDisplay({ error, t }: ErrorDisplayProps) {
const [showDetails, setShowDetails] = useState(false);
const [copied, setCopied] = useState(false);

const separatorIndex = error.indexOf("\n\n");
const hasDetails = separatorIndex !== -1 && separatorIndex < error.length - 2;
const brief = hasDetails ? error.slice(0, separatorIndex) : error;
const details = hasDetails ? error.slice(separatorIndex + 2) : "";

const handleCopy = async () => {
await navigator.clipboard.writeText(error);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
};

return (
<div className="p-4 text-red-400 font-mono text-sm bg-red-900/10 h-full overflow-auto">
<div className="whitespace-pre-wrap">Error: {brief}</div>
<div className="p-4 text-red-400 font-mono text-sm bg-red-900/10 h-full overflow-auto select-text">
<div className="flex items-start gap-3">
<div className="whitespace-pre-wrap flex-1 min-w-0">Error: {brief}</div>
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-1 rounded border border-red-400/30 px-2 py-1 text-xs text-red-300/80 hover:bg-red-400/10 hover:text-red-200 transition-colors select-none shrink-0"
title={t("common.copyError")}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? t("common.copied") : t("common.copy")}
</button>
</div>
{hasDetails && (
<>
<button
Expand Down
63 changes: 63 additions & 0 deletions tests/components/ui/ErrorDisplay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { TFunction } from "i18next";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { ErrorDisplay } from "../../../src/components/ui/ErrorDisplay";

const labels: Record<string, string> = {
"common.copy": "Copy",
"common.copied": "Copied!",
"common.copyError": "Copy error message",
"editor.hideErrorDetails": "Hide details",
"editor.showErrorDetails": "Show details",
};

const t = ((key: string) => labels[key] ?? key) as TFunction;

describe("ErrorDisplay", () => {
const writeText = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
Object.assign(navigator, {
clipboard: { writeText },
});
});

it("renders selectable error text and a copy button", () => {
render(<ErrorDisplay error="query failed" t={t} />);

expect(screen.getByText("Error: query failed")).toBeInTheDocument();
expect(screen.getByTitle("Copy error message")).toBeInTheDocument();
expect(screen.getByText("Error: query failed").closest(".select-text")).toBeTruthy();
});

it("copies the complete error including hidden details", async () => {
const error = "query failed\n\nstack trace line 1\nstack trace line 2";
render(<ErrorDisplay error={error} t={t} />);

fireEvent.click(screen.getByRole("button", { name: /copy/i }));

await waitFor(() => {
expect(writeText).toHaveBeenCalledWith(error);
});
expect(await screen.findByText("Copied!")).toBeInTheDocument();
});

it("toggles detailed error output", () => {
render(
<ErrorDisplay
error={"query failed\n\nserver detail line 1\nserver detail line 2"}
t={t}
/>,
);

expect(screen.queryByText(/server detail/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Show details" }));

expect(screen.getByText(/server detail line 1/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Hide details" }));

expect(screen.queryByText(/server detail/)).not.toBeInTheDocument();
});
});
Loading