diff --git a/Makefile b/Makefile index acb016c65..ee2f15e0d 100644 --- a/Makefile +++ b/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 diff --git a/cli/app.rs b/cli/app.rs index a80f611b5..c9f388af6 100644 --- a/cli/app.rs +++ b/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, - #[clap(index = 2)] + #[clap(index = 2, help = "Optional SQL command to execute")] pub sql: Option, #[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 ??", Self::Opcodes => ".opcodes", - Self::OutputMode => ".mode ", + Self::OutputMode => ".mode raw|pretty", Self::SetOutput => ".output ?file?", Self::Cwd => ".cd ", Self::ShowInfo => ".show", Self::NullValue => ".nullvalue ", + 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, writer: Box, conn: Rc, + pub interrupt_count: Arc, + 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 { - let io: Arc = 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 { + 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 = 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 = 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, 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) -> 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 { + 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> { + 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 Change the current working directory. .nullvalue 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: diff --git a/cli/main.rs b/cli/main.rs index c160607e6..3671d47c9 100644 --- a/cli/main.rs +++ b/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 = 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; diff --git a/testing/memory-repl.tcl b/testing/memory-repl.tcl deleted file mode 100755 index ad4fac7a0..000000000 --- a/testing/memory-repl.tcl +++ /dev/null @@ -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 diff --git a/testing/shelltests.py b/testing/shelltests.py new file mode 100755 index 000000000..22c9ed122 --- /dev/null +++ b/testing/shelltests.py @@ -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.")