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
1 change: 1 addition & 0 deletions crates/db/tests/real_databases/mysql/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ mod core_flow;
mod data;
mod designer;
mod import_export;
mod sticky_session;
150 changes: 150 additions & 0 deletions crates/db/tests/real_databases/mysql/sticky_session.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//! Auto 模式粘性会话真机验证(issue #312)。
//!
//! 复刻编辑器管线的会话级调用序列:`create_session_direct` → 多次独立
//! `execute_session` → `close_session_direct`,验证 MySQL `SET @var` 跨执行
//! 存活且物理连接(CONNECTION_ID)不变。这是 db_view 粘性会话所依赖的
//! 底层契约:UI 层的 `run_auto_sql_text` 逐条复用同一 session_id 调用
//! `execute_session`,变量语义由此层保证。

use db::executor::{ExecOptions, QueryResult, SqlResult};
use db::manager::GlobalDbState;

use crate::real_databases::common::env::{mysql_config, skip_database};

#[tokio::test]
async fn mysql_real_sticky_session_keeps_user_variables_across_executions() {
let Some(config) = mysql_config() else {
skip_database(
"MySQL sticky session",
"ONETCLI_TEST_MYSQL_PASSWORD (and optionally host/port/user)",
);
return;
};

let mut state = GlobalDbState::new();
state.register_connection(config);
let connection_id = "navop-real-mysql".to_string();

// 1. 建会话(等价编辑器首次执行的懒建粘性会话)
let session_id = state
.create_session_direct(connection_id.clone(), None)
.await
.expect("sticky session should connect");

// 2. issue #312 原始场景:SET 语句与 SELECT 分开执行
let set_results = state
.execute_session(
session_id.clone(),
"SET @startdates = '2026-1-1', @enddates = '2026-2-1'".to_string(),
None,
)
.await
.expect("SET @var should execute");
assert_no_errors(&set_results);

// 3. 单独执行 SELECT:变量必须存活(修复前 per-execution session 会丢)
let select = session_query(&state, &session_id, "SELECT @startdates, @enddates").await;
assert_cell(&select, 0, 0, "2026-1-1");
assert_cell(&select, 0, 1, "2026-2-1");

// 4. 多次跨执行仍存活,且物理连接不变(粘性 = 同一条连接)
let identity_before = session_query(
&state,
&session_id,
"SELECT CAST(CONNECTION_ID() AS CHAR)",
)
.await;
let again = session_query(&state, &session_id, "SELECT @startdates").await;
assert_cell(&again, 0, 0, "2026-1-1");
let identity_after = session_query(
&state,
&session_id,
"SELECT CAST(CONNECTION_ID() AS CHAR)",
)
.await;
let (before, after) = (
cell_text(&identity_before, 0, 0),
cell_text(&identity_after, 0, 0),
);
assert_eq!(
before, after,
"sticky session must reuse the same physical connection"
);

// 5. issue 中的原始查询形态:变量参与 WHERE BETWEEN 过滤
// (子查询首行必须是 DATE,首行 int 会把整列钉成 int 类型导致比较失真)
let between = state
.execute_session(
session_id.clone(),
"SELECT COUNT(1) FROM (SELECT DATE'2026-01-10' AS create_date UNION SELECT DATE'2026-01-15') t \
WHERE create_date BETWEEN @startdates AND @enddates"
.to_string(),
Some(ExecOptions::default()),
)
.await
.expect("BETWEEN query should execute");
assert_no_errors(&between);
let SqlResult::Query(count) = &between[0] else {
panic!("BETWEEN query should return rows");
};
// 2026-01-10、2026-01-15 都落在 [2026-1-1, 2026-2-1] 内,两条都命中。
assert_cell(count, 0, 0, "2");

// 6. 会话关闭后再开新会话:变量不跨会话(语义等同重连)
state
.close_session_direct(&session_id)
.await
.expect("sticky session should close");
let fresh_session = state
.create_session_direct(connection_id, None)
.await
.expect("fresh session should connect");
let fresh = session_query(&state, &fresh_session, "SELECT @startdates").await;
assert_null(&fresh, 0, 0);
state
.close_session_direct(&fresh_session)
.await
.expect("fresh session should close");
}

fn assert_no_errors(results: &[SqlResult]) {
for result in results {
if let SqlResult::Error(error) = result {
panic!("session execution failed: {}", error.message);
}
}
}

async fn session_query(state: &GlobalDbState, session_id: &str, sql: &str) -> QueryResult {
let results = state
.execute_session(session_id.to_string(), sql.to_string(), None)
.await
.expect("session query should execute");
assert_no_errors(&results);
let SqlResult::Query(query) = results.into_iter().next().unwrap_or_else(|| {
panic!("session query should return a result: {sql}");
}) else {
panic!("session query should return rows: {sql}");
};
query
}

fn cell_text(result: &QueryResult, row: usize, column: usize) -> String {
let view = result.typed_view().expect("query result should be valid");
match view.cell(row, column).expect("cell should exist") {
db::executor::QueryCellRef::Text(value) => value.to_string(),
other => panic!("expected text at ({row},{column}), got {other:?}"),
}
}

fn assert_cell(result: &QueryResult, row: usize, column: usize, expected: &str) {
assert_eq!(cell_text(result, row, column), expected);
}

fn assert_null(result: &QueryResult, row: usize, column: usize) {
let view = result.typed_view().expect("query result should be valid");
assert!(matches!(
view.cell(row, column),
Some(db::executor::QueryCellRef::Null)
));
}
4 changes: 4 additions & 0 deletions crates/db_view/locales/db_view.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1522,6 +1522,10 @@ Query:
en: "Failed to start transaction: %{error}"
zh-CN: "启动事务失败:%{error}"
zh-HK: "啟動交易失敗:%{error}"
auto_session_start_failed:
en: "Failed to start database session: %{error}"
zh-CN: "建立数据库会话失败:%{error}"
zh-HK: "建立資料庫工作階段失敗:%{error}"
transaction_control_failed:
en: Transaction command failed
zh-CN: 事务控制命令执行失败
Expand Down
130 changes: 130 additions & 0 deletions crates/db_view/src/auto_session.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//! Auto 模式粘性会话:每个 SQL 编辑器页签在 Auto 事务模式下复用同一条
//! 物理连接,使 `SET @var` 等连接级状态跨执行存活(issue #312)。
//!
//! 与 Manual 事务的 `ManualTransactionSession` 平行:复用 `SessionSqlRun`
//! 执行管线与 `execute_session` 的 pin 语义(`in_flight` 不归零,连接不会
//! 被 idle 回收),由编辑器持有引用并在 scope/连接/模式变化时释放。

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use crate::sql_editor_view::SqlExecutionScope;

/// Auto 模式编辑器持有的粘性会话。
#[derive(Clone, Debug)]
pub(crate) struct AutoExecutionSession {
session_id: String,
database: Option<String>,
schema: Option<String>,
/// 会话级连接已失效(断连/被回收)。置位后下次执行前丢弃重建。
connection_lost: Arc<AtomicBool>,
}

impl AutoExecutionSession {
pub(crate) fn new(
session_id: String,
database: Option<String>,
schema: Option<String>,
) -> Self {
Self {
session_id,
database,
schema,
connection_lost: Arc::new(AtomicBool::new(false)),
}
}

pub(crate) fn session_id(&self) -> &str {
&self.session_id
}

pub(crate) fn connection_lost(&self) -> &Arc<AtomicBool> {
&self.connection_lost
}

pub(crate) fn matches_scope(&self, scope: &SqlExecutionScope) -> bool {
self.database.as_deref() == scope.database.as_deref()
&& self.schema.as_deref() == scope.schema.as_deref()
}
}

/// Auto 执行前对粘性会话的处置决策。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AutoSessionAction {
/// 已有会话且 scope 匹配、连接健康:直接复用。
RunInstalledSession,
/// 没有会话(首次执行)或会话已失效:丢弃旧会话并新建。
StartSession,
}

/// 纯决策:installed 会话能否服务本次执行。
///
/// scope 不匹配时选择重建而不是报错(Auto 模式下用户随时切库,旧会话的
/// 连接级状态随之作废,静默重建符合“自动”语义)。
pub(crate) fn auto_session_action_for(
installed: Option<&AutoExecutionSession>,
scope: &SqlExecutionScope,
) -> AutoSessionAction {
match installed {
Some(session) if session.matches_scope(scope) && !session_is_dead(session) => {
AutoSessionAction::RunInstalledSession
}
_ => AutoSessionAction::StartSession,
}
}

fn session_is_dead(session: &AutoExecutionSession) -> bool {
session.connection_lost.load(Ordering::SeqCst)
}
#[cfg(test)]
mod tests {
use super::*;

fn scope(db: Option<&str>, schema: Option<&str>) -> SqlExecutionScope {
SqlExecutionScope::new(db.map(str::to_string), schema.map(str::to_string))
}

fn live_session(db: Option<&str>, schema: Option<&str>) -> AutoExecutionSession {
AutoExecutionSession::new(
"s1".to_string(),
db.map(str::to_string),
schema.map(str::to_string),
)
}

#[test]
fn no_session_starts() {
assert_eq!(
auto_session_action_for(None, &scope(Some("db"), None)),
AutoSessionAction::StartSession
);
}

#[test]
fn matching_session_reuses() {
let session = live_session(Some("db"), Some("sch"));
assert_eq!(
auto_session_action_for(Some(&session), &scope(Some("db"), Some("sch"))),
AutoSessionAction::RunInstalledSession
);
}

#[test]
fn scope_mismatch_rebuilds() {
let session = live_session(Some("db1"), None);
assert_eq!(
auto_session_action_for(Some(&session), &scope(Some("db2"), None)),
AutoSessionAction::StartSession
);
}

#[test]
fn lost_connection_rebuilds() {
let session = live_session(Some("db"), None);
session.connection_lost().store(true, Ordering::SeqCst);
assert_eq!(
auto_session_action_for(Some(&session), &scope(Some("db"), None)),
AutoSessionAction::StartSession
);
}
}
1 change: 1 addition & 0 deletions crates/db_view/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ rust_i18n::i18n!("locales", fallback = "en");

pub mod common;
pub mod compare;
mod auto_session;
pub mod connection_form_window;
pub mod database_objects_tab;
#[cfg(test)]
Expand Down
Loading
Loading