mirror of
https://github.com/aljazceru/turso.git
synced 2025-12-29 14:04:22 +01:00
Merge 'Add tests explciitly for shell behavior, more cli options' from Preston Thorpe
This adds the initial collection of tests specifically for `limbo` shell/cli behavior, to separate the concern from testing the underlying dbms. Also adds `Echo` mode, and `quiet` mode cli argument for better experience piping output of limbo on the command line. After writing a ton of tests in `tcl`, I realized that there is no reason at all to write these particular tests in tcl anymore, so I rewrote them in python :) Shell tests were added to `make test` to run in CI Closes #487
This commit is contained in:
6
Makefile
6
Makefile
@@ -57,9 +57,13 @@ limbo-wasm:
|
||||
cargo build --package limbo-wasm --target wasm32-wasi
|
||||
.PHONY: limbo-wasm
|
||||
|
||||
test: limbo test-compat test-sqlite3
|
||||
test: limbo test-compat test-sqlite3 test-shell
|
||||
.PHONY: test
|
||||
|
||||
test-shell: limbo
|
||||
./testing/shelltests.py
|
||||
.PHONY: test-shell
|
||||
|
||||
test-compat:
|
||||
SQLITE_EXEC=$(SQLITE_EXEC) ./testing/all.test
|
||||
.PHONY: test-compat
|
||||
|
||||
232
cli/app.rs
232
cli/app.rs
@@ -18,14 +18,23 @@ use std::{
|
||||
#[command(name = "limbo")]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
pub struct Opts {
|
||||
#[clap(index = 1)]
|
||||
#[clap(index = 1, help = "SQLite database file", default_value = ":memory:")]
|
||||
pub database: Option<PathBuf>,
|
||||
#[clap(index = 2)]
|
||||
#[clap(index = 2, help = "Optional SQL command to execute")]
|
||||
pub sql: Option<String>,
|
||||
#[clap(short = 'm', long, default_value_t = OutputMode::Raw)]
|
||||
pub output_mode: OutputMode,
|
||||
#[clap(short, long, default_value = "")]
|
||||
pub output: String,
|
||||
#[clap(
|
||||
short,
|
||||
long,
|
||||
help = "don't display program information on start",
|
||||
default_value_t = false
|
||||
)]
|
||||
pub quiet: bool,
|
||||
#[clap(short, long, help = "Print commands before exection")]
|
||||
pub echo: bool,
|
||||
}
|
||||
|
||||
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)]
|
||||
@@ -65,6 +74,8 @@ pub enum Command {
|
||||
ShowInfo,
|
||||
/// Set the value of NULL to be printedin 'raw' mode
|
||||
NullValue,
|
||||
/// Toggle 'echo' mode to repeat commands before execution
|
||||
Echo,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
@@ -76,7 +87,7 @@ impl Command {
|
||||
| Self::Opcodes
|
||||
| Self::ShowInfo
|
||||
| Self::SetOutput => 0,
|
||||
Self::Open | Self::OutputMode | Self::Cwd | Self::NullValue => 1,
|
||||
Self::Open | Self::OutputMode | Self::Cwd | Self::Echo | Self::NullValue => 1,
|
||||
} + 1) // argv0
|
||||
}
|
||||
|
||||
@@ -87,11 +98,12 @@ impl Command {
|
||||
Self::Help => ".help",
|
||||
Self::Schema => ".schema ?<table>?",
|
||||
Self::Opcodes => ".opcodes",
|
||||
Self::OutputMode => ".mode <mode>",
|
||||
Self::OutputMode => ".mode raw|pretty",
|
||||
Self::SetOutput => ".output ?file?",
|
||||
Self::Cwd => ".cd <directory>",
|
||||
Self::ShowInfo => ".show",
|
||||
Self::NullValue => ".nullvalue <string>",
|
||||
Self::Echo => ".echo on|off",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +122,7 @@ impl FromStr for Command {
|
||||
".cd" => Ok(Self::Cwd),
|
||||
".show" => Ok(Self::ShowInfo),
|
||||
".nullvalue" => Ok(Self::NullValue),
|
||||
".echo" => Ok(Self::Echo),
|
||||
_ => Err("Unknown command".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -122,49 +135,105 @@ pub struct Limbo {
|
||||
io: Arc<dyn limbo_core::IO>,
|
||||
writer: Box<dyn Write>,
|
||||
conn: Rc<limbo_core::Connection>,
|
||||
pub interrupt_count: Arc<AtomicUsize>,
|
||||
input_buff: String,
|
||||
opts: Settings,
|
||||
}
|
||||
|
||||
pub struct Settings {
|
||||
output_filename: String,
|
||||
db_file: String,
|
||||
output_mode: OutputMode,
|
||||
is_stdout: bool,
|
||||
input_buff: String,
|
||||
null_value: String,
|
||||
output_mode: OutputMode,
|
||||
echo: bool,
|
||||
is_stdout: bool,
|
||||
}
|
||||
|
||||
impl From<&Opts> for Settings {
|
||||
fn from(opts: &Opts) -> Self {
|
||||
Self {
|
||||
null_value: String::new(),
|
||||
output_mode: opts.output_mode,
|
||||
echo: false,
|
||||
is_stdout: opts.output == "",
|
||||
output_filename: opts.output.clone(),
|
||||
db_file: opts
|
||||
.database
|
||||
.as_ref()
|
||||
.map_or(":memory:".to_string(), |p| p.to_string_lossy().to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Settings {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"Settings:\nOutput mode: {}\nDB: {}\nOutput: {}\nNull value: {}\nCWD: {}\nEcho: {}",
|
||||
self.output_mode,
|
||||
self.db_file,
|
||||
match self.is_stdout {
|
||||
true => "STDOUT",
|
||||
false => &self.output_filename,
|
||||
},
|
||||
self.null_value,
|
||||
std::env::current_dir().unwrap().display(),
|
||||
match self.echo {
|
||||
true => "on",
|
||||
false => "off",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Limbo {
|
||||
#[allow(clippy::arc_with_non_send_sync)]
|
||||
pub fn new(opts: &Opts) -> anyhow::Result<Self> {
|
||||
let io: Arc<dyn limbo_core::IO> = match opts.database {
|
||||
Some(ref path) if path.exists() => Arc::new(limbo_core::PlatformIO::new()?),
|
||||
_ => Arc::new(limbo_core::MemoryIO::new()?),
|
||||
};
|
||||
pub fn new() -> anyhow::Result<Self> {
|
||||
let opts = Opts::parse();
|
||||
let db_file = opts
|
||||
.database
|
||||
.as_ref()
|
||||
.map_or(":memory:".to_string(), |p| p.to_string_lossy().to_string());
|
||||
|
||||
let io = get_io(&db_file)?;
|
||||
let db = Database::open_file(io.clone(), &db_file)?;
|
||||
let conn = db.connect();
|
||||
let writer: Box<dyn Write> = if !opts.output.is_empty() {
|
||||
Box::new(std::fs::File::create(&opts.output)?)
|
||||
} else {
|
||||
Box::new(io::stdout())
|
||||
};
|
||||
let output = if opts.output.is_empty() {
|
||||
"STDOUT"
|
||||
} else {
|
||||
&opts.output
|
||||
};
|
||||
Ok(Self {
|
||||
let interrupt_count = Arc::new(AtomicUsize::new(0));
|
||||
{
|
||||
let interrupt_count: Arc<AtomicUsize> = Arc::clone(&interrupt_count);
|
||||
ctrlc::set_handler(move || {
|
||||
// Increment the interrupt count on Ctrl-C
|
||||
interrupt_count.fetch_add(1, Ordering::SeqCst);
|
||||
})
|
||||
.expect("Error setting Ctrl-C handler");
|
||||
}
|
||||
let mut app = Self {
|
||||
prompt: PROMPT.to_string(),
|
||||
io,
|
||||
writer,
|
||||
output_filename: output.to_string(),
|
||||
writer: get_writer(&opts.output),
|
||||
conn,
|
||||
db_file,
|
||||
output_mode: opts.output_mode,
|
||||
is_stdout: true,
|
||||
interrupt_count,
|
||||
input_buff: String::new(),
|
||||
null_value: String::new(),
|
||||
})
|
||||
opts: Settings::from(&opts),
|
||||
};
|
||||
if opts.sql.is_some() {
|
||||
app.handle_first_input(opts.sql.as_ref().unwrap());
|
||||
}
|
||||
if !opts.quiet {
|
||||
app.write_fmt(format_args!("Limbo v{}", env!("CARGO_PKG_VERSION")))?;
|
||||
app.writeln("Enter \".help\" for usage hints.")?;
|
||||
app.display_in_memory()?;
|
||||
}
|
||||
return Ok(app);
|
||||
}
|
||||
|
||||
fn handle_first_input(&mut self, cmd: &str) {
|
||||
if cmd.trim().starts_with('.') {
|
||||
self.handle_dot_command(&cmd);
|
||||
} else if let Err(e) = self.query(&cmd) {
|
||||
eprintln!("{}", e);
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
fn set_multiline_prompt(&mut self) {
|
||||
@@ -180,8 +249,8 @@ impl Limbo {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn display_in_memory(&mut self) -> std::io::Result<()> {
|
||||
if self.db_file == ":memory:" {
|
||||
fn display_in_memory(&mut self) -> std::io::Result<()> {
|
||||
if self.opts.db_file == ":memory:" {
|
||||
self.writeln("Connected to a transient in-memory database.")?;
|
||||
self.writeln("Use \".open FILENAME\" to reopen on a persistent database")?;
|
||||
}
|
||||
@@ -189,19 +258,8 @@ impl Limbo {
|
||||
}
|
||||
|
||||
fn show_info(&mut self) -> std::io::Result<()> {
|
||||
self.writeln("------------------------------\nCurrent settings:")?;
|
||||
let output = format!(
|
||||
"Output mode: {}\nDB: {}\nOutput: {}\nCWD: {}\nNull value: {}",
|
||||
self.output_mode,
|
||||
self.db_file,
|
||||
self.output_filename,
|
||||
std::env::current_dir().unwrap().display(),
|
||||
match self.null_value.is_empty() {
|
||||
true => "\'\'".to_string(),
|
||||
false => self.null_value.clone(),
|
||||
}
|
||||
);
|
||||
self.writeln(output)
|
||||
let opts = format!("{}", self.opts);
|
||||
self.writeln(opts)
|
||||
}
|
||||
|
||||
pub fn reset_input(&mut self) {
|
||||
@@ -213,6 +271,14 @@ impl Limbo {
|
||||
self.conn.close()
|
||||
}
|
||||
|
||||
fn toggle_echo(&mut self, arg: &str) {
|
||||
match arg.trim().to_lowercase().as_str() {
|
||||
"on" => self.opts.echo = true,
|
||||
"off" => self.opts.echo = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_db(&mut self, path: &str) -> anyhow::Result<()> {
|
||||
self.conn.close()?;
|
||||
match path {
|
||||
@@ -221,7 +287,7 @@ impl Limbo {
|
||||
self.io = Arc::clone(&io);
|
||||
let db = Database::open_file(self.io.clone(), path)?;
|
||||
self.conn = db.connect();
|
||||
self.db_file = ":memory:".to_string();
|
||||
self.opts.db_file = ":memory:".to_string();
|
||||
return Ok(());
|
||||
}
|
||||
path => {
|
||||
@@ -229,17 +295,23 @@ impl Limbo {
|
||||
self.io = Arc::clone(&io);
|
||||
let db = Database::open_file(self.io.clone(), path)?;
|
||||
self.conn = db.connect();
|
||||
self.db_file = path.to_string();
|
||||
self.opts.db_file = path.to_string();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_output_file(&mut self, path: &str) -> Result<(), String> {
|
||||
if path.is_empty() || path.trim().eq_ignore_ascii_case("stdout") {
|
||||
self.set_output_stdout();
|
||||
return Ok(());
|
||||
}
|
||||
match std::fs::File::create(path) {
|
||||
Ok(file) => {
|
||||
self.writer = Box::new(file);
|
||||
self.is_stdout = false;
|
||||
self.opts.is_stdout = false;
|
||||
self.opts.output_mode = OutputMode::Raw;
|
||||
self.opts.output_filename = path.to_string();
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -251,11 +323,16 @@ impl Limbo {
|
||||
fn set_output_stdout(&mut self) {
|
||||
let _ = self.writer.flush();
|
||||
self.writer = Box::new(io::stdout());
|
||||
self.is_stdout = true;
|
||||
self.opts.is_stdout = true;
|
||||
}
|
||||
|
||||
fn set_mode(&mut self, mode: OutputMode) {
|
||||
self.output_mode = mode;
|
||||
fn set_mode(&mut self, mode: OutputMode) -> Result<(), String> {
|
||||
if mode == OutputMode::Pretty && !self.opts.is_stdout {
|
||||
return Err("pretty output can only be written to a tty".to_string());
|
||||
} else {
|
||||
self.opts.output_mode = mode;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn write_fmt(&mut self, fmt: std::fmt::Arguments) -> io::Result<()> {
|
||||
@@ -276,7 +353,6 @@ impl Limbo {
|
||||
pub fn handle_input_line(
|
||||
&mut self,
|
||||
line: &str,
|
||||
interrupt_count: &Arc<AtomicUsize>,
|
||||
rl: &mut rustyline::DefaultEditor,
|
||||
) -> anyhow::Result<()> {
|
||||
if self.input_buff.is_empty() {
|
||||
@@ -286,18 +362,22 @@ impl Limbo {
|
||||
if line.starts_with('.') {
|
||||
self.handle_dot_command(line);
|
||||
rl.add_history_entry(line.to_owned())?;
|
||||
interrupt_count.store(0, Ordering::SeqCst);
|
||||
self.interrupt_count.store(0, Ordering::SeqCst);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if line.ends_with(';') {
|
||||
self.buffer_input(line);
|
||||
let buff = self.input_buff.clone();
|
||||
let echo = self.opts.echo;
|
||||
buff.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.for_each(|stmt| {
|
||||
if let Err(e) = self.query(stmt, interrupt_count) {
|
||||
if echo {
|
||||
let _ = self.writeln(stmt);
|
||||
}
|
||||
if let Err(e) = self.query(stmt) {
|
||||
let _ = self.writeln(e.to_string());
|
||||
}
|
||||
});
|
||||
@@ -307,7 +387,7 @@ impl Limbo {
|
||||
self.set_multiline_prompt();
|
||||
}
|
||||
rl.add_history_entry(line.to_owned())?;
|
||||
interrupt_count.store(0, Ordering::SeqCst);
|
||||
self.interrupt_count.store(0, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -355,11 +435,13 @@ impl Limbo {
|
||||
}
|
||||
}
|
||||
Command::NullValue => {
|
||||
self.null_value = args[1].to_string();
|
||||
self.opts.null_value = args[1].to_string();
|
||||
}
|
||||
Command::OutputMode => match OutputMode::from_str(args[1], true) {
|
||||
Ok(mode) => {
|
||||
self.set_mode(mode);
|
||||
if let Err(e) = self.set_mode(mode) {
|
||||
let _ = self.write_fmt(format_args!("Error: {}", e));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = self.writeln(e);
|
||||
@@ -374,6 +456,9 @@ impl Limbo {
|
||||
self.set_output_stdout();
|
||||
}
|
||||
}
|
||||
Command::Echo => {
|
||||
self.toggle_echo(args[1]);
|
||||
}
|
||||
Command::Cwd => {
|
||||
let _ = std::env::set_current_dir(args[1]);
|
||||
}
|
||||
@@ -392,11 +477,11 @@ impl Limbo {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query(&mut self, sql: &str, interrupt_count: &Arc<AtomicUsize>) -> anyhow::Result<()> {
|
||||
pub fn query(&mut self, sql: &str) -> anyhow::Result<()> {
|
||||
match self.conn.query(sql) {
|
||||
Ok(Some(ref mut rows)) => match self.output_mode {
|
||||
Ok(Some(ref mut rows)) => match self.opts.output_mode {
|
||||
OutputMode::Raw => loop {
|
||||
if interrupt_count.load(Ordering::SeqCst) > 0 {
|
||||
if self.interrupt_count.load(Ordering::SeqCst) > 0 {
|
||||
println!("Query interrupted.");
|
||||
return Ok(());
|
||||
}
|
||||
@@ -409,7 +494,7 @@ impl Limbo {
|
||||
}
|
||||
let _ = self.writer.write(
|
||||
match value {
|
||||
Value::Null => self.null_value.clone(),
|
||||
Value::Null => self.opts.null_value.clone(),
|
||||
Value::Integer(i) => format!("{}", i),
|
||||
Value::Float(f) => format!("{:?}", f),
|
||||
Value::Text(s) => s.to_string(),
|
||||
@@ -435,7 +520,7 @@ impl Limbo {
|
||||
}
|
||||
},
|
||||
OutputMode::Pretty => {
|
||||
if interrupt_count.load(Ordering::SeqCst) > 0 {
|
||||
if self.interrupt_count.load(Ordering::SeqCst) > 0 {
|
||||
println!("Query interrupted.");
|
||||
return Ok(());
|
||||
}
|
||||
@@ -447,7 +532,7 @@ impl Limbo {
|
||||
row.values
|
||||
.iter()
|
||||
.map(|value| match value {
|
||||
Value::Null => self.null_value.clone().cell(),
|
||||
Value::Null => self.opts.null_value.clone().cell(),
|
||||
Value::Integer(i) => i.to_string().cell(),
|
||||
Value::Float(f) => f.to_string().cell(),
|
||||
Value::Text(s) => s.cell(),
|
||||
@@ -538,6 +623,26 @@ impl Limbo {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_writer(output: &str) -> Box<dyn Write> {
|
||||
match output {
|
||||
"" => Box::new(io::stdout()),
|
||||
_ => match std::fs::File::create(output) {
|
||||
Ok(file) => Box::new(file),
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
Box::new(io::stdout())
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn get_io(db: &str) -> anyhow::Result<Arc<dyn limbo_core::IO>> {
|
||||
Ok(match db {
|
||||
":memory:" => Arc::new(limbo_core::MemoryIO::new()?),
|
||||
_ => Arc::new(limbo_core::PlatformIO::new()?),
|
||||
})
|
||||
}
|
||||
|
||||
const HELP_MSG: &str = r#"
|
||||
Limbo SQL Shell Help
|
||||
==============
|
||||
@@ -555,6 +660,7 @@ Special Commands:
|
||||
.opcodes Display all the opcodes defined by the virtual machine
|
||||
.cd <directory> Change the current working directory.
|
||||
.nullvalue <string> Set the value to be displayed for null values.
|
||||
.echo on|off Toggle echo mode to repeat commands before execution.
|
||||
.help Display this help message.
|
||||
|
||||
Usage Examples:
|
||||
|
||||
34
cli/main.rs
34
cli/main.rs
@@ -1,39 +1,13 @@
|
||||
mod app;
|
||||
mod opcodes_dictionary;
|
||||
|
||||
use clap::Parser;
|
||||
use rustyline::{error::ReadlineError, DefaultEditor};
|
||||
use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use std::sync::{atomic::Ordering, Arc};
|
||||
|
||||
#[allow(clippy::arc_with_non_send_sync)]
|
||||
fn main() -> anyhow::Result<()> {
|
||||
env_logger::init();
|
||||
let opts = app::Opts::parse();
|
||||
let mut app = app::Limbo::new(&opts)?;
|
||||
let interrupt_count = Arc::new(AtomicUsize::new(0));
|
||||
{
|
||||
let interrupt_count: Arc<AtomicUsize> = Arc::clone(&interrupt_count);
|
||||
ctrlc::set_handler(move || {
|
||||
// Increment the interrupt count on Ctrl-C
|
||||
interrupt_count.fetch_add(1, Ordering::SeqCst);
|
||||
})
|
||||
.expect("Error setting Ctrl-C handler");
|
||||
}
|
||||
|
||||
if let Some(sql) = opts.sql {
|
||||
if sql.trim().starts_with('.') {
|
||||
app.handle_dot_command(&sql);
|
||||
} else if let Err(e) = app.query(&sql, &interrupt_count) {
|
||||
eprintln!("{}", e);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
println!("Limbo v{}", env!("CARGO_PKG_VERSION"));
|
||||
println!("Enter \".help\" for usage hints.");
|
||||
let _ = app.display_in_memory();
|
||||
let mut app = app::Limbo::new()?;
|
||||
let mut rl = DefaultEditor::new()?;
|
||||
let home = dirs::home_dir().expect("Could not determine home directory");
|
||||
let history_file = home.join(".limbo_history");
|
||||
@@ -43,7 +17,7 @@ fn main() -> anyhow::Result<()> {
|
||||
loop {
|
||||
let readline = rl.readline(&app.prompt);
|
||||
match readline {
|
||||
Ok(line) => match app.handle_input_line(line.trim(), &interrupt_count, &mut rl) {
|
||||
Ok(line) => match app.handle_input_line(line.trim(), &mut rl) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("{}", e);
|
||||
@@ -51,7 +25,7 @@ fn main() -> anyhow::Result<()> {
|
||||
},
|
||||
Err(ReadlineError::Interrupted) => {
|
||||
// At prompt, increment interrupt count
|
||||
if interrupt_count.fetch_add(1, Ordering::SeqCst) >= 1 {
|
||||
if app.interrupt_count.fetch_add(1, Ordering::SeqCst) >= 1 {
|
||||
eprintln!("Interrupted. Exiting...");
|
||||
let _ = app.close_conn();
|
||||
break;
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env tclsh
|
||||
|
||||
set sqlite_exec [expr {[info exists env(SQLITE_EXEC)] ? $env(SQLITE_EXEC) : "sqlite3"}]
|
||||
|
||||
proc start_sqlite_repl {sqlite_exec init_commands} {
|
||||
set command [list $sqlite_exec ":memory:"]
|
||||
set pipe [open "|[join $command]" RDWR]
|
||||
puts $pipe $init_commands
|
||||
flush $pipe
|
||||
return $pipe
|
||||
}
|
||||
|
||||
proc execute_sql {pipe sql} {
|
||||
puts $pipe $sql
|
||||
flush $pipe
|
||||
puts $pipe "SELECT 'END_OF_RESULT';"
|
||||
flush $pipe
|
||||
set output ""
|
||||
while {[gets $pipe line] >= 0} {
|
||||
if {$line eq "END_OF_RESULT"} {
|
||||
break
|
||||
}
|
||||
append output "$line\n"
|
||||
}
|
||||
return [string trim $output]
|
||||
}
|
||||
|
||||
proc run_test {pipe sql expected_output} {
|
||||
set actual_output [execute_sql $pipe $sql]
|
||||
if {$actual_output ne $expected_output} {
|
||||
puts "Test FAILED: '$sql'"
|
||||
puts "returned '$actual_output'"
|
||||
puts "expected '$expected_output'"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
proc do_execsql_test {pipe test_name sql expected_output} {
|
||||
puts "Running test: $test_name"
|
||||
run_test $pipe $sql $expected_output
|
||||
}
|
||||
|
||||
set init_commands {
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
age INTEGER
|
||||
);
|
||||
CREATE TABLE products (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT,
|
||||
price INTEGER
|
||||
);
|
||||
INSERT INTO users (id, first_name, last_name, age) VALUES
|
||||
(1, 'Alice', 'Smith', 30), (2, 'Bob', 'Johnson', 25), (3, 'Charlie', 'Brown', 66), (4, 'David', 'Nichols', 70); INSERT INTO products (id, name, price) VALUES (1, 'Hat', 19.99), (2, 'Shirt', 29.99), (3, 'Shorts', 39.99), (4, 'Dress', 49.99);
|
||||
CREATE TABLE t(x1, x2, x3, x4);
|
||||
INSERT INTO t VALUES (zeroblob(1024 - 1), zeroblob(1024 - 2), zeroblob(1024 - 3), zeroblob(1024 - 4));
|
||||
}
|
||||
|
||||
set pipe [start_sqlite_repl $sqlite_exec $init_commands]
|
||||
|
||||
do_execsql_test $pipe schema {
|
||||
.schema
|
||||
} {CREATE TABLE users (id INTEGER PRIMARY KEY, first_name TEXT, last_name TEXT, age INTEGER);
|
||||
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price INTEGER);
|
||||
CREATE TABLE t (x1, x2, x3, x4);}
|
||||
|
||||
do_execsql_test $pipe "select-avg" {
|
||||
SELECT avg(age) FROM users;
|
||||
} {47.75}
|
||||
|
||||
do_execsql_test $pipe select-avg-text {
|
||||
SELECT avg(first_name) FROM users;
|
||||
} {0.0}
|
||||
|
||||
do_execsql_test $pipe select-sum {
|
||||
SELECT sum(age) FROM users;
|
||||
} {191}
|
||||
|
||||
do_execsql_test $pipe select-sum-text {
|
||||
SELECT sum(first_name) FROM users;
|
||||
} {0.0}
|
||||
|
||||
do_execsql_test $pipe select-total {
|
||||
SELECT total(age) FROM users;
|
||||
} {191.0}
|
||||
|
||||
do_execsql_test $pipe select-total-text {
|
||||
SELECT total(first_name) FROM users WHERE id < 3;
|
||||
} {0.0}
|
||||
|
||||
do_execsql_test $pipe select-limit {
|
||||
SELECT typeof(id) FROM users LIMIT 1;
|
||||
} {integer}
|
||||
|
||||
do_execsql_test $pipe select-count {
|
||||
SELECT count(id) FROM users;
|
||||
} {4}
|
||||
|
||||
do_execsql_test $pipe select-count {
|
||||
SELECT count(*) FROM users;
|
||||
} {4}
|
||||
|
||||
do_execsql_test $pipe select-count-constant-true {
|
||||
SELECT count(*) FROM users WHERE true;
|
||||
} {4}
|
||||
|
||||
do_execsql_test $pipe select-count-constant-false {
|
||||
SELECT count(*) FROM users WHERE false;
|
||||
} {0}
|
||||
|
||||
# **atrocious hack to test that we can open new connection
|
||||
do_execsql_test $pipe test-open-new-db {
|
||||
.open testing/testing.db
|
||||
} {}
|
||||
|
||||
# now grab random tests from other areas and make sure we are querying that database
|
||||
do_execsql_test $pipe schema-1 {
|
||||
.schema users
|
||||
} {CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
email TEXT,
|
||||
phone_number TEXT,
|
||||
address TEXT,
|
||||
city TEXT,
|
||||
state TEXT,
|
||||
zipcode TEXT,
|
||||
age INTEGER
|
||||
);
|
||||
CREATE INDEX age_idx on users (age);}
|
||||
|
||||
do_execsql_test $pipe cross-join {
|
||||
select * from users, products limit 1;
|
||||
} {1|Jamie|Foster|dylan00@example.com|496-522-9493|62375 Johnson Rest Suite 322|West Lauriestad|IL|35865|94|1|hat|79.0}
|
||||
|
||||
do_execsql_test $pipe left-join-self {
|
||||
select u1.first_name as user_name, u2.first_name as neighbor_name from users u1 left join users as u2 on u1.id = u2.id + 1 limit 2;
|
||||
} {Jamie|
|
||||
Cindy|Jamie}
|
||||
|
||||
do_execsql_test $pipe where-clause-eq-string {
|
||||
select count(1) from users where last_name = 'Rodriguez';
|
||||
} {61}
|
||||
|
||||
puts "All tests passed successfully."
|
||||
close $pipe
|
||||
exit 0
|
||||
239
testing/shelltests.py
Executable file
239
testing/shelltests.py
Executable file
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# Configuration
|
||||
sqlite_exec = "./target/debug/limbo"
|
||||
cwd = os.getcwd()
|
||||
|
||||
# Initial setup commands
|
||||
init_commands = """
|
||||
CREATE TABLE users (id INTEGER PRIMARY KEY, first_name TEXT, last_name TEXT, age INTEGER);
|
||||
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price INTEGER);
|
||||
INSERT INTO users (id, first_name, last_name, age) VALUES
|
||||
(1, 'Alice', 'Smith', 30), (2, 'Bob', 'Johnson', 25), (3, 'Charlie', 'Brown', 66), (4, 'David', 'Nichols', 70);
|
||||
INSERT INTO products (id, name, price) VALUES
|
||||
(1, 'Hat', 19.99), (2, 'Shirt', 29.99), (3, 'Shorts', 39.99), (4, 'Dress', 49.99);
|
||||
CREATE TABLE t (x1, x2, x3, x4);
|
||||
INSERT INTO t VALUES (zeroblob(1024 - 1), zeroblob(1024 - 2), zeroblob(1024 - 3), zeroblob(1024 - 4));
|
||||
"""
|
||||
|
||||
|
||||
def start_sqlite_repl(sqlite_exec, init_commands):
|
||||
# start limbo shell in quiet mode and pipe in init_commands
|
||||
pipe = subprocess.Popen(
|
||||
[sqlite_exec, "-q"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
bufsize=0,
|
||||
)
|
||||
if init_commands and pipe.stdin is not None:
|
||||
pipe.stdin.write(init_commands + "\n")
|
||||
pipe.stdin.flush()
|
||||
return pipe
|
||||
|
||||
|
||||
# get new pipe to limbo shell
|
||||
pipe = start_sqlite_repl(sqlite_exec, init_commands)
|
||||
|
||||
|
||||
def execute_sql(pipe, sql):
|
||||
write_to_pipe(sql + "\n")
|
||||
write_to_pipe("SELECT 'END_OF_RESULT';\n")
|
||||
|
||||
output = []
|
||||
while True:
|
||||
line = pipe.stdout.readline().strip()
|
||||
if line == "END_OF_RESULT":
|
||||
break
|
||||
output.append(line)
|
||||
return "\n".join(output).strip()
|
||||
|
||||
|
||||
def run_test(pipe, sql, expected_output):
|
||||
actual_output = execute_sql(pipe, sql)
|
||||
if actual_output != expected_output:
|
||||
print(f"Test FAILED: '{sql}'")
|
||||
print(f"Expected: {expected_output}")
|
||||
print(f"Returned: {actual_output}")
|
||||
exit(1)
|
||||
|
||||
|
||||
def do_execshell_test(pipe, test_name, sql, expected_output):
|
||||
print(f"Running test: {test_name}")
|
||||
run_test(pipe, sql, expected_output)
|
||||
|
||||
|
||||
def write_to_pipe(line):
|
||||
if pipe.stdin is None:
|
||||
print("Failed to start SQLite REPL")
|
||||
exit(1)
|
||||
pipe.stdin.write(line + "\n")
|
||||
pipe.stdin.flush()
|
||||
|
||||
|
||||
# Run tests
|
||||
do_execshell_test(pipe, "select-1", "SELECT 1;", "1")
|
||||
do_execshell_test(
|
||||
pipe,
|
||||
"schema-memory",
|
||||
".schema",
|
||||
"""CREATE TABLE users (id INTEGER PRIMARY KEY, first_name TEXT, last_name TEXT, age INTEGER);
|
||||
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price INTEGER);
|
||||
CREATE TABLE t (x1, x2, x3, x4);""",
|
||||
)
|
||||
do_execshell_test(pipe, "select-avg", "SELECT avg(age) FROM users;", "47.75")
|
||||
do_execshell_test(pipe, "select-sum", "SELECT sum(age) FROM users;", "191")
|
||||
|
||||
do_execshell_test(pipe, "mem-sum-zero", "SELECT sum(first_name) FROM users;", "0.0")
|
||||
do_execshell_test(pipe, "mem-total-age", "SELECT total(age) FROM users;", "191.0")
|
||||
do_execshell_test(
|
||||
pipe, "mem-typeof", "SELECT typeof(id) FROM users LIMIT 1;", "integer"
|
||||
)
|
||||
|
||||
# test we can open a different db file and can attach to it
|
||||
do_execshell_test(pipe, "file-schema-1", ".open testing/testing.db", "")
|
||||
|
||||
# test some random queries to ensure the proper schema
|
||||
do_execshell_test(
|
||||
pipe,
|
||||
"file-schema-1",
|
||||
".schema users",
|
||||
"""CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
email TEXT,
|
||||
phone_number TEXT,
|
||||
address TEXT,
|
||||
city TEXT,
|
||||
state TEXT,
|
||||
zipcode TEXT,
|
||||
age INTEGER
|
||||
);
|
||||
CREATE INDEX age_idx on users (age);""",
|
||||
)
|
||||
|
||||
do_execshell_test(pipe, "file-users-count", "select count(*) from users;", "10000")
|
||||
|
||||
do_execshell_test(
|
||||
pipe,
|
||||
"file-cross-join",
|
||||
"select * from users, products limit 1;",
|
||||
"1|Jamie|Foster|dylan00@example.com|496-522-9493|62375 Johnson Rest Suite 322|West Lauriestad|IL|35865|94|1|hat|79.0",
|
||||
)
|
||||
|
||||
do_execshell_test(
|
||||
pipe,
|
||||
"file-left-join-self",
|
||||
"select u1.first_name as user_name, u2.first_name as neighbor_name from users u1 left join users as u2 on u1.id = u2.id + 1 limit 2;",
|
||||
"Jamie|\nCindy|Jamie",
|
||||
)
|
||||
|
||||
do_execshell_test(
|
||||
pipe,
|
||||
"where-clause-eq-string",
|
||||
"select count(1) from users where last_name = 'Rodriguez';",
|
||||
"61",
|
||||
)
|
||||
|
||||
# test we can cd into a directory
|
||||
dir = "testing"
|
||||
outfile = "limbo_output.txt"
|
||||
|
||||
write_to_pipe(f".cd {dir}")
|
||||
|
||||
# test we can enable echo
|
||||
write_to_pipe(".echo on")
|
||||
|
||||
# Redirect output to a file in the new directory
|
||||
write_to_pipe(f".output {outfile}")
|
||||
|
||||
# make sure we cannot use pretty mode while outfile isnt a tty
|
||||
write_to_pipe(".mode pretty")
|
||||
|
||||
# this should print an error to the new outfile
|
||||
|
||||
write_to_pipe("SELECT 'TEST_ECHO';")
|
||||
write_to_pipe("")
|
||||
|
||||
write_to_pipe(".echo off")
|
||||
|
||||
# test we can set the null value
|
||||
write_to_pipe(".nullvalue LIMBO")
|
||||
|
||||
# print settings to evaluate in file
|
||||
write_to_pipe(".show")
|
||||
|
||||
# set output back to stdout
|
||||
write_to_pipe(".output stdout")
|
||||
|
||||
do_execshell_test(
|
||||
pipe,
|
||||
"test-switch-output-stdout",
|
||||
".show",
|
||||
f"""Settings:
|
||||
Output mode: raw
|
||||
DB: testing/testing.db
|
||||
Output: STDOUT
|
||||
Null value: LIMBO
|
||||
CWD: {cwd}/testing
|
||||
Echo: off""",
|
||||
)
|
||||
# test we can set the null value
|
||||
|
||||
write_to_pipe(".open :memory:")
|
||||
|
||||
do_execshell_test(
|
||||
pipe,
|
||||
"test-can-switch-back-to-in-memory",
|
||||
".schema users",
|
||||
"Error: Table 'users' not found.",
|
||||
)
|
||||
|
||||
do_execshell_test(pipe, "test-verify-null-value", "select NULL;", "LIMBO")
|
||||
|
||||
|
||||
# Verify the output file exists and contains expected content
|
||||
filepath = os.path.join(cwd, dir, outfile)
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
print("Test FAILED: Output file not created")
|
||||
exit(1)
|
||||
|
||||
with open(filepath, "r") as f:
|
||||
file_contents = f.read()
|
||||
|
||||
# verify command was echo'd as well as mode was unchanged
|
||||
expected_lines = {
|
||||
f"Output: {outfile}": "Can direct output to a file",
|
||||
"Output mode: raw": "Output mode doesn't change when redirected from stdout",
|
||||
"Error: pretty output can only be written to a tty": "No ansi characters printed to non-tty",
|
||||
"SELECT 'TEST_ECHO'": "Echo properly echoes the command",
|
||||
"TEST_ECHO": "Echo properly prints the result",
|
||||
"Null value: LIMBO": "Null value is set properly",
|
||||
f"CWD: {cwd}/testing": "Shell can change directory",
|
||||
"DB: testing/testing.db": "Shell can open a different db file",
|
||||
"Echo: off": "Echo can be toggled on and off",
|
||||
}
|
||||
|
||||
all_lines_found = True
|
||||
for line, value in expected_lines.items():
|
||||
if line not in file_contents:
|
||||
print(f"Test FAILED: Expected line not found in file: {line}")
|
||||
all_lines_found = False
|
||||
else:
|
||||
print(f"Testing that: {value}")
|
||||
|
||||
if all_lines_found:
|
||||
print("Test PASSED: File contains all expected lines")
|
||||
else:
|
||||
print(f"File contents:\n{file_contents}")
|
||||
exit(1)
|
||||
|
||||
# Cleanup
|
||||
os.remove(filepath)
|
||||
pipe.terminate()
|
||||
print("All shell tests passed successfully.")
|
||||
Reference in New Issue
Block a user