fix(export): replay MySQL user variables before exporting
This commit is contained in:
1 parent
e89bda343f
commit
7394c07de9
3 files changed
+110
-3
No files matched your search
@@ -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
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user