fix(export): replay MySQL user variables before exporting

This commit is contained in:
Loc Nguyen authored and GitHub committed 2026-10-23 01:56:04 +08:00
1 parent e89bda343f
commit 7394c07de9
3 files changed
+110 -3

No files matched your search

+4 -1
View File
@@ -10756,7 +10756,10 @@ export const useQueryStore = defineStore("query", () => {
const queryBaseSql = effectiveDbType === "mysql" ? stripMysqlClientDisplayCommand(queryResultBaseSql(tab)) : queryResultBaseSql(tab);
const resultStatementIndex = tab.result.statement_index;
const batchSql = tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql;
const batchStatements = effectiveDbType === "postgres" && tab.result.truncated === true && Number.isInteger(resultStatementIndex) && resultStatementIndex! > 0 ? splitSqlStatementRanges(batchSql, effectiveDbType) : [];
// The export runs on its own session, so replay earlier statements of the batch: PostgreSQL
// temp tables, MySQL user variables (#13111). The backend keeps only side-effect-free ones.
const replaysBatchSetup = effectiveDbType === "postgres" || effectiveDbType === "mysql";
const batchStatements = replaysBatchSetup && tab.result.truncated === true && Number.isInteger(resultStatementIndex) && resultStatementIndex! > 0 ? splitSqlStatementRanges(batchSql, effectiveDbType) : [];
const setupSql = batchStatements[resultStatementIndex!]?.sql === tab.result.sourceStatement ? batchStatements.slice(0, resultStatementIndex).map((statement) => statement.sql) : undefined;
const rowLimit = settings.exportRowLimitEnabled ? settings.exportRowLimit : null;
const totalRows = typeof tab.resultTotalRowCount === "number" ? (rowLimit === null ? tab.resultTotalRowCount : Math.min(tab.resultTotalRowCount, rowLimit)) : null;
@@ -36,11 +36,13 @@ use crate::types::SpatialColumn;
use crate::xlsx_export::{
finish_streaming_xlsx_workbook, start_streaming_xlsx_workbook_with_options, StreamingXlsxWriter, XlsxWorksheetData,
};
use mysql_async::prelude::Queryable;
use serde_json::Value;
use sqlparser::ast::{
GroupByExpr, ObjectName, ObjectNamePart, ObjectType, OrderByKind, SelectItem, SetExpr, Statement, TableFactor,
Expr, GroupByExpr, ObjectName, ObjectNamePart, ObjectType, OrderByKind, SelectItem, Set, SetExpr, Statement,
TableFactor,
};
use sqlparser::dialect::{GenericDialect, PostgreSqlDialect};
use sqlparser::dialect::{GenericDialect, MySqlDialect, PostgreSqlDialect};
use sqlparser::parser::Parser;
use tokio_util::sync::CancellationToken;
@@ -234,6 +236,43 @@ fn safe_postgres_temp_setup_sql(setup_sql: &[String]) -> Option<Vec<String>> {
Some(setup_sql.to_vec())
}
/// MySQL setup statements an export may replay: only `SET @user_var = <literal>`.
///
/// Exports run on a dedicated session, so user variables set earlier in the same batch
/// (`SET @id = '123'; SELECT ... WHERE id = @id`) are NULL there and the export comes back
/// empty (#13111). Literal user-variable assignments have no side effects and do not depend
/// on the statements skipped around them, so they are the only ones replayed.
fn mysql_user_variable_setup_sql(setup_sql: &[String]) -> Vec<String> {
let is_user_variable = |name: &ObjectName| {
let name = name.to_string();
name.starts_with('@') && !name.starts_with("@@")
};
let is_literal = |expr: &Expr| match expr {
Expr::Value(_) => true,
Expr::UnaryOp { expr, .. } => matches!(expr.as_ref(), Expr::Value(_)),
_ => false,
};
let dialect = MySqlDialect {};
setup_sql
.iter()
.filter(|sql| {
let Ok(statements) = Parser::parse_sql(&dialect, sql) else {
return false;
};
match statements.as_slice() {
[Statement::Set(Set::SingleAssignment { scope: None, hivevar: false, variable, values })] => {
is_user_variable(variable) && values.len() == 1 && is_literal(&values[0])
}
[Statement::Set(Set::MultipleAssignments { assignments })] => assignments.iter().all(|assignment| {
assignment.scope.is_none() && is_user_variable(&assignment.name) && is_literal(&assignment.value)
}),
_ => false,
}
})
.cloned()
.collect()
}
fn split_excel_cell_text(value: &str) -> Vec<String> {
let mut chunks = Vec::new();
let mut current = String::new();
@@ -1619,6 +1658,11 @@ async fn try_export_mysql_query_result_stream(
cancel_token.as_ref(),
)
.await?;
for setup in mysql_user_variable_setup_sql(&request.setup_sql) {
conn.query_drop(setup)
.await
.map_err(|error| format!("Failed to restore session variables for export: {error}"))?;
}
let mysql_connection_id = conn.id();
let kill_opts = conn.opts().clone();
if let Some(execution_id) = request.execution_id.clone() {
@@ -2581,6 +2625,24 @@ mod tests {
);
}
#[test]
fn mysql_setup_replays_only_literal_user_variable_assignments() {
let setup = vec![
"SET @ID = '123'".to_string(),
"SET @a = 1, @b = -2".to_string(),
"UPDATE orders SET status = 'x'".to_string(),
"SET @@session.sql_mode = ''".to_string(),
"SET SESSION wait_timeout = 10".to_string(),
"SET @n = (SELECT COUNT(*) FROM orders)".to_string(),
"SET @f = my_function()".to_string(),
];
assert_eq!(
mysql_user_variable_setup_sql(&setup),
vec!["SET @ID = '123'".to_string(), "SET @a = 1, @b = -2".to_string()]
);
assert!(mysql_user_variable_setup_sql(&[]).is_empty());
}
#[test]
fn postgres_temp_setup_accepts_only_session_local_table_operations() {
let safe = vec![
+42
View File
@@ -7504,6 +7504,48 @@ test("buildQueryResultExportRequest includes PostgreSQL batch setup for a trunca
}
});
test("buildQueryResultExportRequest includes MySQL batch setup so user variables reach the export session (#13111)", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
connectionStore.addEphemeralConnection({ ...conn("conn-mysql"), db_type: "mysql", port: 3306 });
const tabId = store.createTab("conn-mysql", "lishi", "Query");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
const batchSql = ["SET @ID = '123'", "SELECT * FROM NewTable nt WHERE ID = @ID"].join(";\n");
tab.lastExecutedSql = batchSql;
tab.resultBaseSql = batchSql;
tab.result = {
columns: ["ID"],
rows: [["123"]],
affected_rows: 0,
execution_time_ms: 1,
statement_index: 1,
sourceStatement: "SELECT * FROM NewTable nt WHERE ID = @ID",
truncated: true,
has_more: false,
};
globalThis.fetch = withConnectionHealthMock(async () => new Response("unexpected request", { status: 500 }));
try {
const request = await store.buildQueryResultExportRequest(tabId, {
exportId: "export-mysql-vars",
filePath: "C:\\tmp\\new-table.xlsx",
format: "xlsx",
});
assert.equal(request?.sql, "SELECT * FROM NewTable nt WHERE ID = @ID");
assert.deepEqual(request?.setupSql, ["SET @ID = '123'"]);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("buildQueryResultExportRequest caps progress total when export row limit is enabled", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());