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:
Pekka Enberg
2024-12-16 10:24:11 +02:00
5 changed files with 417 additions and 244 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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;

View File

@@ -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
View 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.")