diff --git a/Makefile b/Makefile index 35a2cf917..ee2f15e0d 100644 --- a/Makefile +++ b/Makefile @@ -57,11 +57,11 @@ 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/memory-repl.tcl + ./testing/shelltests.py .PHONY: test-shell test-compat: diff --git a/cli/app.rs b/cli/app.rs index 57b03ea37..c9f388af6 100644 --- a/cli/app.rs +++ b/cli/app.rs @@ -18,9 +18,9 @@ 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, @@ -190,21 +190,14 @@ impl Limbo { #[allow(clippy::arc_with_non_send_sync)] pub fn new() -> anyhow::Result { let opts = Opts::parse(); - let io: Arc = match opts.database { - Some(ref path) if path.exists() => Arc::new(limbo_core::PlatformIO::new()?), - _ => Arc::new(limbo_core::MemoryIO::new()?), - }; 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 interrupt_count = Arc::new(AtomicUsize::new(0)); { let interrupt_count: Arc = Arc::clone(&interrupt_count); @@ -217,7 +210,7 @@ impl Limbo { let mut app = Self { prompt: PROMPT.to_string(), io, - writer, + writer: get_writer(&opts.output), conn, interrupt_count, input_buff: String::new(), @@ -309,7 +302,7 @@ impl Limbo { } fn set_output_file(&mut self, path: &str) -> Result<(), String> { - if path.is_empty() || path.eq_ignore_ascii_case("stdout") { + if path.is_empty() || path.trim().eq_ignore_ascii_case("stdout") { self.set_output_stdout(); return Ok(()); } @@ -317,6 +310,7 @@ impl Limbo { Ok(file) => { self.writer = Box::new(file); self.opts.is_stdout = false; + self.opts.output_mode = OutputMode::Raw; self.opts.output_filename = path.to_string(); return Ok(()); } @@ -332,8 +326,13 @@ impl Limbo { self.opts.is_stdout = true; } - fn set_mode(&mut self, mode: OutputMode) { - self.opts.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<()> { @@ -440,7 +439,9 @@ impl Limbo { } 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); @@ -622,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 ============== diff --git a/testing/memory-repl.tcl b/testing/memory-repl.tcl deleted file mode 100755 index 99ac4013c..000000000 --- a/testing/memory-repl.tcl +++ /dev/null @@ -1,222 +0,0 @@ -#!/usr/bin/env tclsh - -set sqlite_exec "./target/debug/limbo" -set cwd [pwd] - -proc start_sqlite_repl {sqlite_exec init_commands} { - set command [list $sqlite_exec -q] - set pipe [open "|[join $command]" RDWR] - puts $pipe $init_commands - flush $pipe - fconfigure $pipe -buffering none -blocking 0 -translation binary - puts [fconfigure $pipe] - return $pipe -} - -proc execute_sql {pipe sql} { - puts $pipe $sql - flush $pipe - puts $pipe "SELECT 'END_OF_RESULT';" - flush $pipe - set output "" - while {true} { - if {[gets $pipe line] >= 0} { - if {$line eq "END_OF_RESULT"} { - break - } - append output "$line\n" - } elseif {[eof $pipe]} { - puts "EOF reached." - break - } - } - 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 "expected '$expected_output'" - puts "returned '$actual_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 select-1 { - SELECT 1; -} {1} - -do_execsql_test $pipe schema-1 { -.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} - - -# test that we can open new connection -puts $pipe ".open testing/testing.db" -flush $pipe - -# run a few random tests to be sure we are connected to right db -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} - - -# Test the null value can be set/unset - -puts $pipe ".nullvalue limbo" - -do_execsql_test $pipe test-select-nullvalue { -SELECT NULL; -} {limbo} - -do_execsql_test $pipe test-set-nullvalue { -.nullvalue '' -} {} - -do_execsql_test $pipe test-set-nullvalue-back { -SELECT NULL; -} {''} - - -# Test that the .show command demonstrates which db is open -do_execsql_test $pipe test-show { -.show -} [subst {Settings: -Output mode: raw -DB: testing/testing.db -Output: STDOUT -Null value: '' -CWD: $cwd -Echo: off}] - - -# Set up the output file name -set output_file "limbo_output.txt" - -puts $pipe ".output $output_file" -flush $pipe - -# Run the .show command to capture its output -puts $pipe ".show" -flush $pipe - -# Stop redirecting output to the file -puts $pipe ".output" -flush $pipe - -do_execsql_test $pipe test-set-outputfile-stdout { -SELECT 1; -} {1} - -# Check if the output file exists -if {![file exists $output_file]} { - puts "Test FAILED: output file not created" - exit 1 -} - -set file_contents [read [open $output_file]] - -set expected_line "Output: $output_file" -if {[string first $expected_line $file_contents] == -1} { - puts "Test FAILED: Expected line not found in file" - puts "Expected: $expected_line" - puts "File contents:\n$file_contents" - exit 1 -} else { - puts "Test PASSED: File contains the expected line" -} - -file delete -force $output_file - -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.")