Skip to content

Commit eb4838d

Browse files
committed
db 실행 버그 수정
1 parent a002e76 commit eb4838d

11 files changed

Lines changed: 1797 additions & 116 deletions

scripts/verify_mysql_cli.py

Lines changed: 474 additions & 0 deletions
Large diffs are not rendered by default.

scripts/verify_oracle_cli.py

Lines changed: 565 additions & 0 deletions
Large diffs are not rendered by default.

src/bin/mysql_fixture_snapshot.rs

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
#![allow(clippy::cargo, clippy::pedantic)]
2+
3+
use fltk::{app, input::IntInput};
4+
use serde::{Deserialize, Serialize};
5+
use space_query::db::{ConnectionInfo, DatabaseConnection, DatabaseType};
6+
use space_query::ui::sql_editor::{QueryProgress, SqlEditorWidget};
7+
use std::collections::HashMap;
8+
use std::env;
9+
use std::path::Path;
10+
use std::sync::atomic::{AtomicBool, Ordering};
11+
use std::sync::{Arc, Mutex};
12+
use std::time::{Duration, Instant};
13+
14+
#[derive(Debug, Serialize, Deserialize)]
15+
struct GridSnapshot {
16+
sql: String,
17+
columns: Vec<String>,
18+
rows: Vec<Vec<String>>,
19+
}
20+
21+
#[derive(Debug, Serialize, Deserialize)]
22+
struct RunSnapshot {
23+
failures: Vec<String>,
24+
grids: Vec<GridSnapshot>,
25+
}
26+
27+
fn database_type(value: &str) -> Result<DatabaseType, String> {
28+
match value.to_ascii_lowercase().as_str() {
29+
"mysql" => Ok(DatabaseType::MySQL),
30+
"mariadb" => Ok(DatabaseType::MariaDB),
31+
other => Err(format!("unknown MySQL-family database type `{other}`")),
32+
}
33+
}
34+
35+
fn connection_info(db_type: DatabaseType) -> ConnectionInfo {
36+
let host = env::var("SPACE_QUERY_TEST_MYSQL_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
37+
let port = env::var("SPACE_QUERY_TEST_MYSQL_PORT")
38+
.ok()
39+
.and_then(|value| value.parse::<u16>().ok())
40+
.unwrap_or(3306);
41+
let database = env::var("SPACE_QUERY_TEST_MYSQL_DATABASE")
42+
.unwrap_or_else(|_| "query_tool_test".to_string());
43+
let username = env::var("SPACE_QUERY_TEST_MYSQL_USER").unwrap_or_else(|_| "root".to_string());
44+
let password =
45+
env::var("SPACE_QUERY_TEST_MYSQL_PASSWORD").unwrap_or_else(|_| "password".to_string());
46+
ConnectionInfo::new_with_type(
47+
"MYSQL_FIXTURE_SNAPSHOT",
48+
&username,
49+
&password,
50+
&host,
51+
port,
52+
&database,
53+
db_type,
54+
)
55+
}
56+
57+
fn progress_inner(event: &QueryProgress) -> &QueryProgress {
58+
match event {
59+
QueryProgress::Operation { progress, .. } => progress_inner(progress),
60+
other => other,
61+
}
62+
}
63+
64+
fn run_script(db_type: DatabaseType, sql: &str) -> Result<Vec<QueryProgress>, String> {
65+
let mut connection = DatabaseConnection::new();
66+
connection.set_auto_commit(true)?;
67+
connection.connect(connection_info(db_type))?;
68+
let shared_connection = Arc::new(Mutex::new(connection));
69+
let timeout_input = IntInput::default();
70+
let mut widget = SqlEditorWidget::new(Arc::clone(&shared_connection), timeout_input);
71+
let progress = Arc::new(Mutex::new(Vec::new()));
72+
let done = Arc::new(AtomicBool::new(false));
73+
{
74+
let progress = Arc::clone(&progress);
75+
let done = Arc::clone(&done);
76+
widget.set_progress_callback(move |event| {
77+
if matches!(progress_inner(&event), QueryProgress::BatchFinished) {
78+
done.store(true, Ordering::SeqCst);
79+
}
80+
progress
81+
.lock()
82+
.unwrap_or_else(|poisoned| poisoned.into_inner())
83+
.push(event);
84+
});
85+
}
86+
87+
widget.execute_script_for_harness(sql);
88+
let timeout_secs = env::var("MYSQL_FIXTURE_TIMEOUT_SECS")
89+
.ok()
90+
.and_then(|value| value.parse::<u64>().ok())
91+
.filter(|value| *value > 0)
92+
.unwrap_or(600);
93+
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
94+
while !done.load(Ordering::SeqCst) && Instant::now() < deadline {
95+
if !app::wait() {
96+
app::check();
97+
std::thread::sleep(Duration::from_millis(10));
98+
}
99+
}
100+
if !done.load(Ordering::SeqCst) {
101+
return Err(format!(
102+
"{db_type:?} script execution timed out after {timeout_secs}s"
103+
));
104+
}
105+
let drain_deadline = Instant::now() + Duration::from_millis(750);
106+
while Instant::now() < drain_deadline {
107+
if !app::wait() {
108+
app::check();
109+
std::thread::sleep(Duration::from_millis(10));
110+
}
111+
}
112+
let events = progress
113+
.lock()
114+
.unwrap_or_else(|poisoned| poisoned.into_inner())
115+
.clone();
116+
Ok(events)
117+
}
118+
119+
fn failures(progress: &[QueryProgress]) -> Vec<String> {
120+
progress
121+
.iter()
122+
.filter_map(|event| match progress_inner(event) {
123+
QueryProgress::StatementFinished { index, result, .. } if !result.success => Some(
124+
format!("#{index}: {} => {}", result.sql.trim(), result.message),
125+
),
126+
QueryProgress::WorkerPanicked { message } => {
127+
Some(format!("worker panicked: {message}"))
128+
}
129+
_ => None,
130+
})
131+
.collect()
132+
}
133+
134+
fn grids(progress: &[QueryProgress]) -> Vec<GridSnapshot> {
135+
let mut columns_by_index = HashMap::<usize, Vec<String>>::new();
136+
let mut rows_by_index = HashMap::<usize, Vec<Vec<String>>>::new();
137+
let mut snapshots = Vec::new();
138+
for event in progress {
139+
match progress_inner(event) {
140+
QueryProgress::SelectStart { index, columns, .. } => {
141+
columns_by_index.insert(*index, columns.clone());
142+
}
143+
QueryProgress::Rows { index, rows } => {
144+
rows_by_index
145+
.entry(*index)
146+
.or_default()
147+
.extend(rows.iter().cloned());
148+
}
149+
QueryProgress::StatementFinished { index, result, .. }
150+
if result.success && result.is_select =>
151+
{
152+
let columns = columns_by_index.remove(index).unwrap_or_else(|| {
153+
result
154+
.columns
155+
.iter()
156+
.map(|column| column.name.clone())
157+
.collect()
158+
});
159+
let rows = rows_by_index
160+
.remove(index)
161+
.unwrap_or_else(|| result.rows.clone());
162+
snapshots.push(GridSnapshot {
163+
sql: result.sql.trim().to_string(),
164+
columns,
165+
rows,
166+
});
167+
}
168+
_ => {}
169+
}
170+
}
171+
snapshots
172+
}
173+
174+
fn snapshot(db_type: DatabaseType, path: &Path) -> Result<RunSnapshot, String> {
175+
let sql = std::fs::read_to_string(path)
176+
.map_err(|err| format!("failed to read {}: {err}", path.display()))?;
177+
let progress = run_script(db_type, &sql)?;
178+
Ok(RunSnapshot {
179+
failures: failures(&progress),
180+
grids: grids(&progress),
181+
})
182+
}
183+
184+
fn main() {
185+
let _app = app::App::default();
186+
let args = env::args().skip(1).collect::<Vec<_>>();
187+
if args.len() != 2 && args.len() != 3 {
188+
eprintln!("usage: mysql_fixture_snapshot <mysql|mariadb> <path> [out-json]");
189+
std::process::exit(2);
190+
}
191+
let db_type = match database_type(&args[0]) {
192+
Ok(db_type) => db_type,
193+
Err(err) => {
194+
eprintln!("{err}");
195+
std::process::exit(2);
196+
}
197+
};
198+
match snapshot(db_type, Path::new(&args[1])) {
199+
Ok(snapshot) => {
200+
let json = match serde_json::to_string(&snapshot) {
201+
Ok(json) => json,
202+
Err(err) => {
203+
eprintln!("failed to serialize snapshot: {err}");
204+
std::process::exit(1);
205+
}
206+
};
207+
if let Some(path) = args.get(2) {
208+
if let Err(err) = std::fs::write(path, json) {
209+
eprintln!("failed to write snapshot {path}: {err}");
210+
std::process::exit(1);
211+
}
212+
} else {
213+
println!("{json}");
214+
}
215+
}
216+
Err(err) => {
217+
eprintln!("{err}");
218+
std::process::exit(1);
219+
}
220+
}
221+
}

src/db/query/execution_backend.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,37 @@ fn classify_mysql_result_kind(db_type: DatabaseType, sql: &str) -> StatementResu
159159
if analysis.classify_for_db_type(db_type).is_select_like() {
160160
return StatementResultKind::Select;
161161
}
162+
let is_mariadb_compound_statement = match db_type {
163+
DatabaseType::MariaDB => analysis.starts_with_words(&["BEGIN", "NOT", "ATOMIC"]),
164+
DatabaseType::MySQL | DatabaseType::Oracle => false,
165+
};
166+
if is_mariadb_compound_statement {
167+
return StatementResultKind::Call;
168+
}
169+
if analysis.starts_with_words(&["ALTER", "TABLE"])
170+
&& analysis.words().windows(2).any(|words| {
171+
matches!(
172+
words,
173+
[first, second] if first == "ANALYZE" && second == "PARTITION"
174+
)
175+
})
176+
{
177+
return StatementResultKind::Dml;
178+
}
162179

163180
match QueryExecutor::leading_keyword(&display_sql).as_deref() {
164181
Some("INSERT") | Some("UPDATE") | Some("DELETE") | Some("REPLACE") | Some("WITH") => {
165182
StatementResultKind::Dml
166183
}
184+
// These MySQL-family statements may or may not return columns based on
185+
// their target. The DML execution route materializes optional result
186+
// sets without inventing a grid for OPEN/CLOSE or non-query payloads.
187+
Some("EXECUTE") | Some("HANDLER") | Some("HELP") | Some("CACHE") => {
188+
StatementResultKind::Dml
189+
}
190+
Some("LOAD") if analysis.starts_with_words(&["LOAD", "INDEX", "INTO", "CACHE"]) => {
191+
StatementResultKind::Dml
192+
}
167193
Some("COMMIT") => StatementResultKind::Commit,
168194
Some("ROLLBACK") => StatementResultKind::Rollback,
169195
Some("USE") => StatementResultKind::Use,
@@ -239,6 +265,45 @@ mod tests {
239265
assert_eq!(profile.session_kind, SqlKind::SelectLike);
240266
}
241267

268+
#[test]
269+
fn mysql_profile_materializes_result_set_optional_statements() {
270+
for sql in [
271+
"EXECUTE prepared_query",
272+
"HANDLER source_rows READ FIRST",
273+
"HELP 'SELECT'",
274+
] {
275+
let profile = statement_execution_profile_for_db_type(DatabaseType::MariaDB, sql);
276+
assert_eq!(
277+
profile.result_kind,
278+
StatementResultKind::Dml,
279+
"unexpected profile for {sql}"
280+
);
281+
}
282+
}
283+
284+
#[test]
285+
fn mariadb_profile_materializes_compound_and_admin_result_sets() {
286+
let cases = [
287+
("BEGIN NOT ATOMIC SELECT 1; END", StatementResultKind::Call),
288+
(
289+
"ALTER TABLE t ANALYZE PARTITION p0",
290+
StatementResultKind::Dml,
291+
),
292+
(
293+
"CACHE INDEX t KEY (PRIMARY) IN cache",
294+
StatementResultKind::Dml,
295+
),
296+
("LOAD INDEX INTO CACHE t", StatementResultKind::Dml),
297+
];
298+
for (sql, expected) in cases {
299+
let profile = statement_execution_profile_for_db_type(DatabaseType::MariaDB, sql);
300+
assert_eq!(
301+
profile.result_kind, expected,
302+
"unexpected profile for {sql}"
303+
);
304+
}
305+
}
306+
242307
#[test]
243308
fn mysql_timeout_profile_skips_wrapper_for_session_timeout_set() {
244309
let timeout = Some(Duration::from_secs(5));

src/db/query/mysql_executor.rs

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -410,15 +410,19 @@ impl MysqlExecutor {
410410
timeout: Option<Duration>,
411411
db_type: DatabaseType,
412412
) -> Result<Option<MysqlSessionTimeoutRestore>, MysqlSessionTimeoutApplyError> {
413-
let restore = if timeout.is_some() {
414-
Some(
415-
Self::capture_session_timeout_restore(conn, db_type)
416-
.map_err(|err| MysqlSessionTimeoutApplyError::new(err, None))?,
417-
)
418-
} else {
419-
None
413+
let Some(timeout) = timeout else {
414+
return Ok(None);
420415
};
421-
Self::apply_session_timeout_with_captured_restore_for_db(conn, timeout, db_type, restore)
416+
let restore = Some(
417+
Self::capture_session_timeout_restore(conn, db_type)
418+
.map_err(|err| MysqlSessionTimeoutApplyError::new(err, None))?,
419+
);
420+
Self::apply_session_timeout_with_captured_restore_for_db(
421+
conn,
422+
Some(timeout),
423+
db_type,
424+
restore,
425+
)
422426
}
423427

424428
fn apply_session_timeout_with_captured_restore_for_db<C: Queryable>(
@@ -505,8 +509,10 @@ impl MysqlExecutor {
505509
let analysis =
506510
crate::db::sql_classification::SqlStatementAnalysis::new_for_db_type(db_type, sql);
507511
let words = analysis.words();
508-
let Some(select_index) = Self::result_producing_select_word_index(words) else {
509-
return false;
512+
let select_index = match Self::result_producing_select_word_index(words) {
513+
Some(index) => index,
514+
None if sql.trim_start().starts_with('(') => 0,
515+
None => return false,
510516
};
511517

512518
words
@@ -519,6 +525,7 @@ impl MysqlExecutor {
519525
match words.first().map(String::as_str) {
520526
Some("SELECT") => Some(0),
521527
Some("WITH") => words.iter().position(|word| word == "SELECT"),
528+
Some("TABLE" | "VALUES") => Some(0),
522529
_ => None,
523530
}
524531
}
@@ -3089,6 +3096,21 @@ mod tests {
30893096
);
30903097
}
30913098

3099+
#[test]
3100+
fn mysql_disabled_timeout_does_not_modify_the_session() {
3101+
let mut conn = RecordingQueryable::default();
3102+
3103+
let restore = MysqlExecutor::apply_session_timeout_with_restore_for_db(
3104+
&mut conn,
3105+
None,
3106+
DatabaseType::MySQL,
3107+
)
3108+
.expect("disabled timeout should not require session SQL");
3109+
3110+
assert!(restore.is_none());
3111+
assert!(conn.statements.is_empty());
3112+
}
3113+
30923114
#[test]
30933115
fn mysql_timeout_provider_keeps_mariadb_query_timeout_separate() {
30943116
assert_eq!(
@@ -4064,6 +4086,9 @@ mod tests {
40644086
SELECT value FROM nested INTO @cte_value FOR UPDATE",
40654087
"WITH recent AS (SELECT 1 AS id)
40664088
SELECT id FROM recent UNION ALL SELECT id + 1 FROM recent INTO OUTFILE '/tmp/recent.tsv'",
4089+
"(TABLE source_rows ORDER BY id LIMIT 2)
4090+
UNION ALL (VALUES ROW(99, 'sentinel'))
4091+
ORDER BY id LIMIT 1 INTO @picked_id, @picked_text",
40674092
"/*!80000 SELECT COUNT(*) INTO @cnt FROM orders */",
40684093
] {
40694094
assert!(

0 commit comments

Comments
 (0)