limbo/simulator/plan_to_test.rs
Jussi Saurio f58a839293 sim: add binary tool that converts plan.sql to rust test file
prompt to opus 4.5:

Create a parser that generates a Rust integration test a lá test_transactions.rs
from files like .bugbase/17016242926153013649/plan.sql

the "-- <integer>" means connection number, so as many connections should be created
as there are distinct suffixes like that at the end of a line. lines BEGINNING with --
 eg "-- begin testing 'ReadYourUpdatesBack'" can be ignored.

the end result should be that the parser tool prints out a valid rust file containing
the necessary imports and a test function containing the statements (modeled after
test_transactions.rs tests).

this should be a lightweight script or CLI tool in the simulator directory where you
can give it a sql path and it will produce that test.
2025-12-05 22:40:41 +02:00

198 lines
6.4 KiB
Rust
Executable file

//! SQL Plan to Rust Integration Test Generator
//!
//! Usage: plan_to_test <path-to-plan.sql> > test_name.rs
//!
//! Parses SQL plan files from the simulator and generates a Rust integration test file.
//! Lines ending with "-- <number>" indicate which connection should execute that statement.
//! Lines beginning with "--" are comments and are ignored (except FAULT commands).
use regex::Regex;
use std::collections::BTreeSet;
use std::env;
use std::fs;
use std::path::Path;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() != 2 {
eprintln!(
"Usage: {} <path-to-plan.sql> > output_test_name.rs",
args[0]
);
std::process::exit(1);
}
let sql_path = &args[1];
let content = fs::read_to_string(sql_path).unwrap_or_else(|e| {
eprintln!("Failed to read SQL file: {e}");
std::process::exit(1);
});
let test_code = generate_test(&content, sql_path);
println!("{test_code}");
}
#[derive(Debug)]
struct SqlStatement {
sql: String,
connection: u32,
}
#[derive(Debug)]
enum PlanAction {
Sql(SqlStatement),
ReopenDatabase,
Disconnect { connection: u32 },
}
fn parse_plan(content: &str) -> (Vec<PlanAction>, BTreeSet<u32>) {
let mut actions = Vec::new();
let mut connections = BTreeSet::new();
// Regex to match "-- <number>" at end of line
let conn_suffix_re = Regex::new(r";\s*--\s*(\d+)\s*$").unwrap();
// Regex to match FAULT lines
let fault_re = Regex::new(r"^--\s*FAULT\s+'([^']+)';\s*--\s*(\d+)").unwrap();
for line in content.lines() {
let trimmed = line.trim();
// Skip empty lines
if trimmed.is_empty() {
continue;
}
// Check for FAULT commands first
if let Some(caps) = fault_re.captures(trimmed) {
let fault_type = caps.get(1).unwrap().as_str();
let conn_num: u32 = caps.get(2).unwrap().as_str().parse().unwrap();
connections.insert(conn_num);
match fault_type {
"REOPEN_DATABASE" => {
actions.push(PlanAction::ReopenDatabase);
}
"DISCONNECT" => {
actions.push(PlanAction::Disconnect {
connection: conn_num,
});
}
_ => {
// Unknown fault type, skip
}
}
continue;
}
// Skip lines that begin with "--" (pure comments)
if trimmed.starts_with("--") {
continue;
}
// Try to find connection suffix
if let Some(caps) = conn_suffix_re.captures(trimmed) {
let conn_num: u32 = caps.get(1).unwrap().as_str().parse().unwrap();
connections.insert(conn_num);
// Extract SQL without the comment suffix
let sql_end = caps.get(0).unwrap().start();
let sql = trimmed[..sql_end + 1].trim().to_string(); // +1 to include the semicolon
// Skip lines marked as FAULTY QUERY
if trimmed.contains("FAULTY QUERY") {
continue;
}
actions.push(PlanAction::Sql(SqlStatement {
sql,
connection: conn_num,
}));
}
}
(actions, connections)
}
fn escape_sql_string(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
}
fn generate_test(content: &str, sql_path: &str) -> String {
let (actions, connections) = parse_plan(content);
let path = Path::new(sql_path);
let test_name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("generated_test");
// Derive a test name from the path if it contains bugbase ID
let test_fn_name = if sql_path.contains(".bugbase/") {
let parts: Vec<&str> = sql_path.split('/').collect();
if let Some(pos) = parts.iter().position(|&p| p == ".bugbase") {
if pos + 1 < parts.len() {
format!("test_bugbase_{}", parts[pos + 1])
} else {
format!("test_{test_name}")
}
} else {
format!("test_{test_name}")
}
} else {
format!("test_{test_name}")
};
let num_connections = connections.iter().max().map(|&m| m + 1).unwrap_or(1);
let mut code = String::new();
// Imports
code.push_str("use std::sync::Arc;\n\n");
code.push_str("use turso_core::Connection;\n\n");
code.push_str("use crate::common::TempDatabase;\n\n");
// Test function
code.push_str("#[turso_macros::test]\n");
code.push_str(&format!("fn {test_fn_name}(tmp_db: TempDatabase) {{\n"));
// Create connections
code.push_str(" let mut connections: Vec<Arc<Connection>> = Vec::new();\n");
code.push_str(&format!(" for _ in 0..{num_connections} {{\n"));
code.push_str(" connections.push(tmp_db.connect_limbo());\n");
code.push_str(" }\n\n");
// Execute each statement
for action in &actions {
match action {
PlanAction::Sql(stmt) => {
let escaped_sql = escape_sql_string(&stmt.sql);
let connection = stmt.connection;
code.push_str(&format!(" // Connection {connection}\n"));
code.push_str(&format!(
" let _ = connections[{connection}].execute(\"{escaped_sql}\");\n\n"
));
}
PlanAction::ReopenDatabase => {
code.push_str(" // REOPEN_DATABASE - reopen database for all connections\n");
code.push_str(" drop(connections);\n");
code.push_str(" let tmp_db = TempDatabase::new_with_existent(&tmp_db.path);\n");
code.push_str(" let mut connections: Vec<Arc<Connection>> = Vec::new();\n");
code.push_str(&format!(" for _ in 0..{num_connections} {{\n"));
code.push_str(" connections.push(tmp_db.connect_limbo());\n");
code.push_str(" }\n\n");
}
PlanAction::Disconnect { connection } => {
code.push_str(&format!(" // DISCONNECT connection {connection}\n"));
code.push_str(&format!(
" drop(std::mem::replace(&mut connections[{connection}], tmp_db.connect_limbo()));\n\n"
));
}
}
}
code.push_str("}\n");
code
}