cleanup shell tests and cli

This commit is contained in:
CK-7vn
2025-02-02 19:15:39 -05:00
parent 248a369afc
commit 5f02521d08
8 changed files with 647 additions and 586 deletions

View File

@@ -71,7 +71,7 @@ test-extensions: limbo
.PHONY: test-extensions .PHONY: test-extensions
test-shell: limbo test-shell: limbo
SQLITE_EXEC=$(SQLITE_EXEC) ./testing/shelltests.py SQLITE_EXEC=$(SQLITE_EXEC) ./testing/cli_tests/cli_test_cases.py
.PHONY: test-shell .PHONY: test-shell
test-compat: test-compat:

View File

@@ -1,11 +1,13 @@
use crate::{ use crate::{
import::{ImportFile, IMPORT_HELP}, import::{ImportFile, IMPORT_HELP},
input::{get_io, get_writer, DbLocation, Io, OutputMode, Settings, HELP_MSG},
opcodes_dictionary::OPCODE_DESCRIPTIONS, opcodes_dictionary::OPCODE_DESCRIPTIONS,
}; };
use comfy_table::{Attribute, Cell, CellAlignment, ContentArrangement, Row, Table}; use comfy_table::{Attribute, Cell, CellAlignment, ContentArrangement, Row, Table};
use limbo_core::{Database, LimboError, Statement, StepResult, Value}; use limbo_core::{Database, LimboError, Statement, StepResult, Value};
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
use rustyline::DefaultEditor;
use std::{ use std::{
io::{self, Write}, io::{self, Write},
path::PathBuf, path::PathBuf,
@@ -49,58 +51,6 @@ pub struct Opts {
pub io: Io, pub io: Io,
} }
#[derive(Copy, Clone)]
pub enum DbLocation {
Memory,
Path,
}
#[derive(Copy, Clone, ValueEnum)]
pub enum Io {
Syscall,
#[cfg(all(target_os = "linux", feature = "io_uring"))]
IoUring,
}
impl Default for Io {
/// Custom Default impl with cfg! macro, to provide compile-time default to Clap based on platform
/// The cfg! could be elided, but Clippy complains
/// The default value can still be overridden with the Clap argument
fn default() -> Self {
match cfg!(all(target_os = "linux", feature = "io_uring")) {
true => {
#[cfg(all(target_os = "linux", feature = "io_uring"))]
{
Io::IoUring
}
#[cfg(any(
not(target_os = "linux"),
all(target_os = "linux", not(feature = "io_uring"))
))]
{
Io::Syscall
}
}
false => Io::Syscall,
}
}
}
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)]
pub enum OutputMode {
Raw,
Pretty,
}
impl std::fmt::Display for OutputMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.to_possible_value()
.expect("no values are skipped")
.get_name()
.fmt(f)
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Command { pub enum Command {
/// Exit this program with return-code CODE /// Exit this program with return-code CODE
@@ -153,7 +103,7 @@ impl Command {
| Self::NullValue | Self::NullValue
| Self::LoadExtension => 1, | Self::LoadExtension => 1,
Self::Import => 2, Self::Import => 2,
} // argv0 }
} }
fn usage(&self) -> &str { fn usage(&self) -> &str {
@@ -203,7 +153,7 @@ impl FromStr for Command {
const PROMPT: &str = "limbo> "; const PROMPT: &str = "limbo> ";
pub struct Limbo { pub struct Limbo<'a> {
pub prompt: String, pub prompt: String,
io: Arc<dyn limbo_core::IO>, io: Arc<dyn limbo_core::IO>,
writer: Box<dyn Write>, writer: Box<dyn Write>,
@@ -211,58 +161,11 @@ pub struct Limbo {
pub interrupt_count: Arc<AtomicUsize>, pub interrupt_count: Arc<AtomicUsize>,
input_buff: String, input_buff: String,
opts: Settings, opts: Settings,
pub rl: &'a mut DefaultEditor,
} }
pub struct Settings { impl<'a> Limbo<'a> {
output_filename: String, pub fn new(rl: &'a mut rustyline::DefaultEditor) -> anyhow::Result<Self> {
db_file: String,
null_value: String,
output_mode: OutputMode,
echo: bool,
is_stdout: bool,
io: Io,
}
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.is_empty(),
output_filename: opts.output.clone(),
db_file: opts
.database
.as_ref()
.map_or(":memory:".to_string(), |p| p.to_string_lossy().to_string()),
io: opts.io,
}
}
}
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 {
pub fn new() -> anyhow::Result<Self> {
let opts = Opts::parse(); let opts = Opts::parse();
let db_file = opts let db_file = opts
.database .database
@@ -294,6 +197,7 @@ impl Limbo {
interrupt_count, interrupt_count,
input_buff: String::new(), input_buff: String::new(),
opts: Settings::from(&opts), opts: Settings::from(&opts),
rl,
}; };
if opts.sql.is_some() { if opts.sql.is_some() {
app.handle_first_input(opts.sql.as_ref().unwrap()); app.handle_first_input(opts.sql.as_ref().unwrap());
@@ -443,19 +347,20 @@ impl Limbo {
self.reset_input(); self.reset_input();
} }
pub fn handle_input_line( fn reset_line(&mut self, line: &str) -> rustyline::Result<()> {
&mut self, self.rl.add_history_entry(line.to_owned())?;
line: &str, self.interrupt_count.store(0, Ordering::SeqCst);
rl: &mut rustyline::DefaultEditor, Ok(())
) -> anyhow::Result<()> { }
pub fn handle_input_line(&mut self, line: &str) -> anyhow::Result<()> {
if self.input_buff.is_empty() { if self.input_buff.is_empty() {
if line.is_empty() { if line.is_empty() {
return Ok(()); return Ok(());
} }
if line.starts_with('.') { if line.starts_with('.') {
self.handle_dot_command(line); self.handle_dot_command(line);
rl.add_history_entry(line.to_owned())?; let _ = self.reset_line(line);
self.interrupt_count.store(0, Ordering::SeqCst);
return Ok(()); return Ok(());
} }
} }
@@ -463,16 +368,25 @@ impl Limbo {
if let Some(remaining) = line.split_once('\n') { if let Some(remaining) = line.split_once('\n') {
let after_comment = remaining.1.trim(); let after_comment = remaining.1.trim();
if !after_comment.is_empty() { if !after_comment.is_empty() {
rl.add_history_entry(after_comment.to_owned())?;
self.buffer_input(after_comment);
if after_comment.ends_with(';') { if after_comment.ends_with(';') {
self.run_query(after_comment); self.run_query(after_comment);
if self.opts.echo {
let _ = self.writeln(after_comment);
}
let conn = self.conn.clone();
let runner = conn.query_runner(after_comment.as_bytes());
for output in runner {
if let Err(e) = self.print_query_result(after_comment, output) {
let _ = self.writeln(e.to_string());
}
}
self.reset_input();
return self.handle_input_line(after_comment);
} else { } else {
self.set_multiline_prompt(); self.set_multiline_prompt();
let _ = self.reset_line(line);
return Ok(());
} }
self.interrupt_count.store(0, Ordering::SeqCst);
return Ok(());
} }
} }
return Ok(()); return Ok(());
@@ -481,10 +395,9 @@ impl Limbo {
if let Some(comment_pos) = line.find("--") { if let Some(comment_pos) = line.find("--") {
let before_comment = line[..comment_pos].trim(); let before_comment = line[..comment_pos].trim();
if !before_comment.is_empty() { if !before_comment.is_empty() {
return self.handle_input_line(before_comment, rl); return self.handle_input_line(before_comment);
} }
} }
if line.ends_with(';') { if line.ends_with(';') {
self.buffer_input(line); self.buffer_input(line);
let buff = self.input_buff.clone(); let buff = self.input_buff.clone();
@@ -493,8 +406,7 @@ impl Limbo {
self.buffer_input(format!("{}\n", line).as_str()); self.buffer_input(format!("{}\n", line).as_str());
self.set_multiline_prompt(); self.set_multiline_prompt();
} }
rl.add_history_entry(line.to_owned())?; self.reset_line(line)?;
self.interrupt_count.store(0, Ordering::SeqCst);
Ok(()) Ok(())
} }
@@ -874,99 +786,3 @@ impl Limbo {
self.reset_input(); self.reset_input();
} }
} }
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_location: DbLocation, io_choice: Io) -> anyhow::Result<Arc<dyn limbo_core::IO>> {
Ok(match db_location {
DbLocation::Memory => Arc::new(limbo_core::MemoryIO::new()?),
DbLocation::Path => {
match io_choice {
Io::Syscall => {
// We are building for Linux/macOS and syscall backend has been selected
#[cfg(target_family = "unix")]
{
Arc::new(limbo_core::UnixIO::new()?)
}
// We are not building for Linux/macOS and syscall backend has been selected
#[cfg(not(target_family = "unix"))]
{
Arc::new(limbo_core::PlatformIO::new()?)
}
}
// We are building for Linux and io_uring backend has been selected
#[cfg(all(target_os = "linux", feature = "io_uring"))]
Io::IoUring => Arc::new(limbo_core::UringIO::new()?),
}
}
})
}
const HELP_MSG: &str = r#"
Limbo SQL Shell Help
==============
Welcome to the Limbo SQL Shell! You can execute any standard SQL command here.
In addition to standard SQL commands, the following special commands are available:
Special Commands:
-----------------
.exit ?<CODE> Exit this program with return-code CODE
.quit Stop interpreting input stream and exit.
.show Display current settings.
.open <database_file> Open and connect to a database file.
.output <mode> Change the output mode. Available modes are 'raw' and 'pretty'.
.schema <table_name> Show the schema of the specified table.
.tables <pattern> List names of tables matching LIKE pattern TABLE
.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.
.import --csv FILE TABLE Import csv data from FILE into TABLE
.help Display this help message.
Usage Examples:
---------------
1. To quit the Limbo SQL Shell:
.quit
2. To open a database file at path './employees.db':
.open employees.db
3. To view the schema of a table named 'employees':
.schema employees
4. To list all tables:
.tables
5. To list all available SQL opcodes:
.opcodes
6. To change the current output mode to 'pretty':
.mode pretty
7. Send output to STDOUT if no file is specified:
.output
8. To change the current working directory to '/tmp':
.cd /tmp
9. Show the current values of settings:
.show
10. To import csv file 'sample.csv' into 'csv_table' table:
.import --csv sample.csv csv_table
Note:
- All SQL commands must end with a semicolon (;).
- Special commands do not require a semicolon."#;

201
cli/input.rs Normal file
View File

@@ -0,0 +1,201 @@
use crate::app::Opts;
use clap::ValueEnum;
use std::{
io::{self, Write},
sync::Arc,
};
#[derive(Copy, Clone)]
pub enum DbLocation {
Memory,
Path,
}
#[derive(Copy, Clone, ValueEnum)]
pub enum Io {
Syscall,
#[cfg(all(target_os = "linux", feature = "io_uring"))]
IoUring,
}
impl Default for Io {
/// Custom Default impl with cfg! macro, to provide compile-time default to Clap based on platform
/// The cfg! could be elided, but Clippy complains
/// The default value can still be overridden with the Clap argument
fn default() -> Self {
match cfg!(all(target_os = "linux", feature = "io_uring")) {
true => {
#[cfg(all(target_os = "linux", feature = "io_uring"))]
{
Io::IoUring
}
#[cfg(any(
not(target_os = "linux"),
all(target_os = "linux", not(feature = "io_uring"))
))]
{
Io::Syscall
}
}
false => Io::Syscall,
}
}
}
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)]
pub enum OutputMode {
Raw,
Pretty,
}
impl std::fmt::Display for OutputMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.to_possible_value()
.expect("no values are skipped")
.get_name()
.fmt(f)
}
}
pub struct Settings {
pub output_filename: String,
pub db_file: String,
pub null_value: String,
pub output_mode: OutputMode,
pub echo: bool,
pub is_stdout: bool,
pub io: Io,
}
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.is_empty(),
output_filename: opts.output.clone(),
db_file: opts
.database
.as_ref()
.map_or(":memory:".to_string(), |p| p.to_string_lossy().to_string()),
io: opts.io,
}
}
}
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",
}
)
}
}
pub 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())
}
},
}
}
pub fn get_io(db_location: DbLocation, io_choice: Io) -> anyhow::Result<Arc<dyn limbo_core::IO>> {
Ok(match db_location {
DbLocation::Memory => Arc::new(limbo_core::MemoryIO::new()?),
DbLocation::Path => {
match io_choice {
Io::Syscall => {
// We are building for Linux/macOS and syscall backend has been selected
#[cfg(target_family = "unix")]
{
Arc::new(limbo_core::UnixIO::new()?)
}
// We are not building for Linux/macOS and syscall backend has been selected
#[cfg(not(target_family = "unix"))]
{
Arc::new(limbo_core::PlatformIO::new()?)
}
}
// We are building for Linux and io_uring backend has been selected
#[cfg(all(target_os = "linux", feature = "io_uring"))]
Io::IoUring => Arc::new(limbo_core::UringIO::new()?),
}
}
})
}
pub const HELP_MSG: &str = r#"
Limbo SQL Shell Help
==============
Welcome to the Limbo SQL Shell! You can execute any standard SQL command here.
In addition to standard SQL commands, the following special commands are available:
Special Commands:
-----------------
.quit Stop interpreting input stream and exit.
.show Display current settings.
.open <database_file> Open and connect to a database file.
.output <mode> Change the output mode. Available modes are 'raw' and 'pretty'.
.schema <table_name> Show the schema of the specified table.
.tables <pattern> List names of tables matching LIKE pattern TABLE
.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.
.import --csv FILE TABLE Import csv data from FILE into TABLE
.help Display this help message.
Usage Examples:
---------------
1. To quit the Limbo SQL Shell:
.quit
2. To open a database file at path './employees.db':
.open employees.db
3. To view the schema of a table named 'employees':
.schema employees
4. To list all tables:
.tables
5. To list all available SQL opcodes:
.opcodes
6. To change the current output mode to 'pretty':
.mode pretty
7. Send output to STDOUT if no file is specified:
.output
8. To change the current working directory to '/tmp':
.cd /tmp
9. Show the current values of settings:
.show
10. To import csv file 'sample.csv' into 'csv_table' table:
.import --csv sample.csv csv_table
Note:
- All SQL commands must end with a semicolon (;).
- Special commands do not require a semicolon."#;

View File

@@ -1,6 +1,7 @@
#![allow(clippy::arc_with_non_send_sync)] #![allow(clippy::arc_with_non_send_sync)]
mod app; mod app;
mod import; mod import;
mod input;
mod opcodes_dictionary; mod opcodes_dictionary;
use rustyline::{error::ReadlineError, DefaultEditor}; use rustyline::{error::ReadlineError, DefaultEditor};
@@ -8,17 +9,17 @@ use std::sync::atomic::Ordering;
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
env_logger::init(); env_logger::init();
let mut app = app::Limbo::new()?;
let mut rl = DefaultEditor::new()?; let mut rl = DefaultEditor::new()?;
let mut app = app::Limbo::new(&mut rl)?;
let home = dirs::home_dir().expect("Could not determine home directory"); let home = dirs::home_dir().expect("Could not determine home directory");
let history_file = home.join(".limbo_history"); let history_file = home.join(".limbo_history");
if history_file.exists() { if history_file.exists() {
rl.load_history(history_file.as_path())?; app.rl.load_history(history_file.as_path())?;
} }
loop { loop {
let readline = rl.readline(&app.prompt); let readline = app.rl.readline(&app.prompt);
match readline { match readline {
Ok(line) => match app.handle_input_line(line.trim(), &mut rl) { Ok(line) => match app.handle_input_line(line.trim()) {
Ok(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
eprintln!("{}", e); eprintln!("{}", e);

10
limbo_output.txt Normal file
View File

@@ -0,0 +1,10 @@
Error: pretty output can only be written to a tty
SELECT 'TEST_ECHO';
TEST_ECHO
Settings:
Output mode: raw
DB: testing/testing.db
Output: limbo_output.txt
Null value: LIMBO
CWD: /home/krobichaud/Projects/open_source_projects/limbo
Echo: off

View File

@@ -0,0 +1,262 @@
#!/usr/bin/env python3
from test_limbo_cli import TestLimboShell
from pathlib import Path
import time
import os
def test_basic_queries():
shell = TestLimboShell()
shell.run_test("select-1", "SELECT 1;", "1")
shell.run_test("select-avg", "SELECT avg(age) FROM users;", "47.75")
shell.run_test("select-sum", "SELECT sum(age) FROM users;", "191")
shell.run_test("mem-sum-zero", "SELECT sum(first_name) FROM users;", "0.0")
shell.run_test("mem-total-age", "SELECT total(age) FROM users;", "191.0")
shell.run_test("mem-typeof", "SELECT typeof(id) FROM users LIMIT 1;", "integer")
shell.quit()
def test_schema_operations():
shell = TestLimboShell(init_blobs_table=True)
expected = (
"CREATE TABLE users (id INTEGER PRIMARY KEY, first_name TEXT, last_name TEXT, age INTEGER);\n"
"CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price INTEGER);\n"
"CREATE TABLE t (x1, x2, x3, x4);"
)
shell.run_test("schema-memory", ".schema", expected)
shell.quit()
def test_file_operations():
shell = TestLimboShell()
shell.run_test("file-open", ".open testing/testing.db", "")
shell.run_test("file-users-count", "select count(*) from users;", "10000")
shell.quit()
shell = TestLimboShell()
shell.run_test("file-schema-1", ".open testing/testing.db", "")
expected_user_schema = (
"CREATE TABLE users (\n"
"id INTEGER PRIMARY KEY,\n"
"first_name TEXT,\n"
"last_name TEXT,\n"
"email TEXT,\n"
"phone_number TEXT,\n"
"address TEXT,\n"
"city TEXT,\n"
"state TEXT,\n"
"zipcode TEXT,\n"
"age INTEGER\n"
");\n"
"CREATE INDEX age_idx on users (age);"
)
shell.run_test("file-schema-users", ".schema users", expected_user_schema)
shell.quit()
def test_joins():
shell = TestLimboShell()
shell.run_test("open-file", ".open testing/testing.db", "")
shell.run_test("verify-tables", ".tables", "products users")
shell.run_test(
"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",
)
shell.quit()
def test_left_join_self():
shell = TestLimboShell(
init_commands="""
.open testing/testing.db
"""
)
shell.run_test(
"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",
)
shell.quit()
def test_where_clauses():
shell = TestLimboShell()
shell.run_test("open-testing-db-file", ".open testing/testing.db", "")
shell.run_test(
"where-clause-eq-string",
"select count(1) from users where last_name = 'Rodriguez';",
"61",
)
shell.quit()
def test_switch_back_to_in_memory():
shell = TestLimboShell()
# First, open the file-based DB.
shell.run_test("open-testing-db-file", ".open testing/testing.db", "")
# Then switch back to :memory:
shell.run_test("switch-back", ".open :memory:", "")
shell.run_test(
"schema-in-memory", ".schema users", "-- Error: Table 'users' not found."
)
shell.quit()
def test_verify_null_value():
shell = TestLimboShell()
shell.run_test("verify-null", "select NULL;", "LIMBO")
shell.quit()
def verify_output_file(filepath: Path, expected_lines: dict) -> None:
with open(filepath, "r") as f:
contents = f.read()
for line, description in expected_lines.items():
assert line in contents, f"Missing: {description}"
def test_output_file():
shell = TestLimboShell()
output_filename = "limbo_output.txt"
output_file = shell.config.test_dir / shell.config.py_folder / output_filename
shell.execute_dot(".open testing/testing.db")
shell.execute_dot(f".cd {shell.config.test_dir}/{shell.config.py_folder}")
shell.execute_dot(".echo on")
shell.execute_dot(f".output {output_filename}")
shell.execute_dot(f".cd {shell.config.test_dir}/{shell.config.py_folder}")
shell.execute_dot(".mode pretty")
shell.execute_dot("SELECT 'TEST_ECHO';")
shell.execute_dot("")
shell.execute_dot(".echo off")
shell.execute_dot(".nullvalue LIMBO")
shell.execute_dot(".show")
shell.execute_dot(".output stdout")
time.sleep(3)
with open(output_file, "r") as f:
contents = f.read()
expected_lines = {
f"Output: {output_filename}": "Can direct output to a file",
"Output mode: raw": "Output mode remains raw when output is redirected",
"Error: pretty output can only be written to a tty": "Error message for pretty mode",
"SELECT 'TEST_ECHO'": "Echoed command",
"TEST_ECHO": "Echoed result",
"Null value: LIMBO": "Null value setting",
f"CWD: {shell.config.cwd}/{shell.config.test_dir}": "Working directory changed",
"DB: testing/testing.db": "File database opened",
"Echo: off": "Echo turned off",
}
for line, _ in expected_lines.items():
assert line in contents, f"Expected line not found in file: {line}"
# Clean up
os.remove(output_file)
def test_multi_line_single_line_comments_succession():
shell = TestLimboShell()
comments = """-- First of the comments
-- Second line of the comments
SELECT 2;"""
shell.run_test("multi-line-single-line-comments", comments, "2")
shell.quit()
def test_comments():
shell = TestLimboShell()
shell.run_test("single-line-comment", "-- this is a comment\nSELECT 1;", "1")
shell.run_test(
"multi-line-comments", "-- First comment\n-- Second comment\nSELECT 2;", "2"
)
shell.run_test("block-comment", "/*\nMulti-line block comment\n*/\nSELECT 3;", "3")
shell.run_test(
"inline-comments",
"SELECT id, -- comment here\nfirst_name FROM users LIMIT 1;",
"1|Alice",
)
shell.quit()
def test_import_csv():
shell = TestLimboShell()
shell.run_test("memory-db", ".open :memory:", "")
shell.run_test(
"create-csv-table", "CREATE TABLE csv_table (c1 INT, c2 REAL, c3 String);", ""
)
shell.run_test(
"import-csv-no-options",
".import --csv ./testing/test_files/test.csv csv_table",
"",
)
shell.run_test(
"verify-csv-no-options",
"select * from csv_table;",
"1|2.0|String'1\n3|4.0|String2",
)
shell.quit()
def test_import_csv_verbose():
shell = TestLimboShell()
shell.run_test("open-memory", ".open :memory:", "")
shell.run_test(
"create-csv-table", "CREATE TABLE csv_table (c1 INT, c2 REAL, c3 String);", ""
)
shell.run_test(
"import-csv-verbose",
".import --csv -v ./testing/test_files/test.csv csv_table",
"Added 2 rows with 0 errors using 2 lines of input",
)
shell.run_test(
"verify-csv-verbose",
"select * from csv_table;",
"1|2.0|String'1\n3|4.0|String2",
)
shell.quit()
def test_import_csv_skip():
shell = TestLimboShell()
shell.run_test("open-memory", ".open :memory:", "")
shell.run_test(
"create-csv-table", "CREATE TABLE csv_table (c1 INT, c2 REAL, c3 String);", ""
)
shell.run_test(
"import-csv-skip",
".import --csv --skip 1 ./testing/test_files/test.csv csv_table",
"",
)
shell.run_test("verify-csv-skip", "select * from csv_table;", "3|4.0|String2")
shell.quit()
def test_table_patterns():
shell = TestLimboShell()
shell.run_test("tables-pattern", ".tables us%", "users")
shell.quit()
if __name__ == "__main__":
print("Running all Limbo CLI tests...")
test_basic_queries()
test_schema_operations()
test_file_operations()
test_joins()
test_left_join_self()
test_where_clauses()
test_switch_back_to_in_memory()
test_verify_null_value()
test_output_file()
test_multi_line_single_line_comments_succession()
test_comments()
test_import_csv()
test_import_csv_verbose()
test_import_csv_skip()
test_table_patterns()
print("All tests have passed")

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
import os
import select
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
PIPE_BUF = 4096
@dataclass
class ShellConfig:
sqlite_exec: str = os.getenv("LIMBO_TARGET", "./target/debug/limbo")
sqlite_flags: List[str] = field(
default_factory=lambda: os.getenv("SQLITE_FLAGS", "-q").split()
)
cwd = os.getcwd()
test_dir: Path = field(default_factory=lambda: Path("testing"))
py_folder: Path = field(default_factory=lambda: Path("cli_tests"))
test_files: Path = field(default_factory=lambda: Path("test_files"))
class LimboShell:
def __init__(self, config: ShellConfig, init_commands: Optional[str] = None):
self.config = config
self.pipe = self._start_repl(init_commands)
def _start_repl(self, init_commands: Optional[str]) -> subprocess.Popen:
pipe = subprocess.Popen(
[self.config.sqlite_exec, *self.config.sqlite_flags],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)
if init_commands and pipe.stdin is not None:
pipe.stdin.write((init_commands + "\n").encode())
pipe.stdin.flush()
return pipe
def get_test_filepath(self) -> Path:
return self.config.test_dir / "limbo_output.txt"
def execute(self, sql: str) -> str:
end_marker = "END_OF_RESULT"
self._write_to_pipe(sql)
# If we're redirecting output, return so test's don't hang
if sql.strip().startswith(".output"):
return ""
self._write_to_pipe(f"SELECT '{end_marker}';")
output = ""
while True:
ready, _, errors = select.select(
[self.pipe.stdout, self.pipe.stderr],
[],
[self.pipe.stdout, self.pipe.stderr],
)
ready_or_errors = set(ready + errors)
if self.pipe.stderr in ready_or_errors:
self._handle_error()
if self.pipe.stdout in ready_or_errors:
fragment = self.pipe.stdout.read(PIPE_BUF).decode()
output += fragment
if output.rstrip().endswith(end_marker):
return self._clean_output(output, end_marker)
def _write_to_pipe(self, command: str) -> None:
if not self.pipe.stdin:
raise RuntimeError("Failed to start Limbo REPL")
self.pipe.stdin.write((command + "\n").encode())
self.pipe.stdin.flush()
def _handle_error(self) -> None:
while True:
ready, _, errors = select.select(
[self.pipe.stderr], [], [self.pipe.stderr], 0
)
if not (ready + errors):
break
error_output = self.pipe.stderr.read(PIPE_BUF).decode()
print(error_output, end="")
raise RuntimeError("Error encountered in Limbo shell.")
@staticmethod
def _clean_output(output: str, marker: str) -> str:
output = output.rstrip().removesuffix(marker)
lines = [line.strip() for line in output.split("\n") if line]
return "\n".join(lines)
def quit(self) -> None:
self._write_to_pipe(".quit")
self.pipe.terminate()
class TestLimboShell:
def __init__(
self, init_commands: Optional[str] = None, init_blobs_table: bool = False
):
self.config = ShellConfig()
if init_commands is None:
# Default initialization
init_commands = """
.open :memory:
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 VALUES (1, 'Alice', 'Smith', 30), (2, 'Bob', 'Johnson', 25),
(3, 'Charlie', 'Brown', 66), (4, 'David', 'Nichols', 70);
INSERT INTO products VALUES (1, 'Hat', 19.99), (2, 'Shirt', 29.99),
(3, 'Shorts', 39.99), (4, 'Dress', 49.99);
"""
if init_blobs_table:
init_commands += """
CREATE TABLE t (x1, x2, x3, x4);
INSERT INTO t VALUES (zeroblob(1024 - 1), zeroblob(1024 - 2), zeroblob(1024 - 3), zeroblob(1024 - 4));"""
init_commands += "\n.nullvalue LIMBO"
self.shell = LimboShell(self.config, init_commands)
def quit(self):
self.shell.quit()
def run_test(self, name: str, sql: str, expected: str) -> None:
print(f"Running test: {name}")
actual = self.shell.execute(sql)
assert actual == expected, (
f"Test failed: {name}\n"
f"SQL: {sql}\n"
f"Expected:\n{repr(expected)}\n"
f"Actual:\n{repr(actual)}"
)
def execute_dot(self, dot_command: str) -> None:
self.shell._write_to_pipe(dot_command)

View File

@@ -1,365 +0,0 @@
#!/usr/bin/env python3
import os
import select
import subprocess
# Configuration
sqlite_exec = os.getenv("SQLITE_EXEC", "./target/debug/limbo")
sqlite_flags = os.getenv("SQLITE_FLAGS", "-q").split(" ")
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
# we cannot use Popen text mode as it is not compatible with non blocking reads
# via select and we will be not able to poll for errors
pipe = subprocess.Popen(
[sqlite_exec, *sqlite_flags],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)
if init_commands and pipe.stdin is not None:
init_as_bytes = (init_commands + "\n").encode()
pipe.stdin.write(init_as_bytes)
pipe.stdin.flush()
return pipe
# get new pipe to limbo shell
pipe = start_sqlite_repl(sqlite_exec, init_commands)
def execute_sql(pipe, sql):
end_suffix = "END_OF_RESULT"
write_to_pipe(sql)
write_to_pipe(f"SELECT '{end_suffix}';\n")
stdout = pipe.stdout
stderr = pipe.stderr
output = ""
while True:
ready_to_read, _, error_in_pipe = select.select(
[stdout, stderr], [], [stdout, stderr]
)
ready_to_read_or_err = set(ready_to_read + error_in_pipe)
if stderr in ready_to_read_or_err:
exit_on_error(stderr)
if stdout in ready_to_read_or_err:
fragment = stdout.read(select.PIPE_BUF)
output += fragment.decode()
if output.rstrip().endswith(end_suffix):
output = output.rstrip().removesuffix(end_suffix)
break
output = strip_each_line(output)
return output
def strip_each_line(lines: str) -> str:
lines = lines.split("\n")
lines = [line.strip() for line in lines if line != ""]
return "\n".join(lines)
def exit_on_error(stderr):
while True:
ready_to_read, _, have_error = select.select([stderr], [], [stderr], 0)
if not (ready_to_read + have_error):
break
error_line = stderr.read(select.PIPE_BUF).decode()
print(error_line, end="")
exit(2)
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)
encoded_line = (line + "\n").encode()
pipe.stdin.write(encoded_line)
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""",
)
do_execshell_test(pipe, "test-show-tables", ".tables", "products users")
do_execshell_test(pipe, "test-show-tables-with-pattern", ".tables us%", "users")
# 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")
# test import csv
csv_file = "./test_files/test.csv"
write_to_pipe(".open :memory:")
def test_import_csv(
test_name: str, options: str, import_output: str, table_output: str
):
csv_table_name = f"csv_table_{test_name}"
write_to_pipe(f"CREATE TABLE {csv_table_name} (c1 INT, c2 REAL, c3 String);")
do_execshell_test(
pipe,
f"test-import-csv-{test_name}",
f".import {options} {csv_file} {csv_table_name}",
import_output,
)
do_execshell_test(
pipe,
f"test-import-csv-{test_name}-output",
f"select * from {csv_table_name};",
table_output,
)
test_import_csv("no_options", "--csv", "", "1|2.0|String'1\n3|4.0|String2")
test_import_csv(
"verbose",
"--csv -v",
"Added 2 rows with 0 errors using 2 lines of input",
"1|2.0|String'1\n3|4.0|String2",
)
test_import_csv("skip", "--csv --skip 1", "", "3|4.0|String2")
# 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)
do_execshell_test(
pipe,
"test-single-line-comment",
"-- this is a comment\nSELECT 1;",
"1",
)
do_execshell_test(
pipe,
"test-multi-line-single-line-comments-in-succession",
"""-- First of the comments
-- Second line of the comments
SELECT 2;""",
"2",
)
do_execshell_test(
pipe,
"test-multi-line-comments",
"""/*
This is a multi-line comment
*/
SELECT 3;""",
"3",
)
# readd some data to test inline comments
write_to_pipe("""
CREATE TABLE users (id INTEGER PRIMARY KEY, first_name TEXT, last_name TEXT, age 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);
""")
do_execshell_test(
pipe,
"test-inline-comments",
"""SELECT id, -- this is a comment until newline
first_name
FROM users
LIMIT 1; """,
"1|Alice",
)
do_execshell_test(
pipe,
"test-multiple-inline-comments",
"""SELECT id, --first inline
--second inline
first_name
--third inline
FROM users
LIMIT 1; """,
"1|Alice",
)
# Cleanup
os.remove(filepath)
pipe.terminate()
print("All shell tests passed successfully.")