From 5f02521d081186ff445de68f526af09030da8442 Mon Sep 17 00:00:00 2001 From: CK-7vn Date: Sun, 2 Feb 2025 19:15:39 -0500 Subject: [PATCH] cleanup shell tests and cli --- Makefile | 2 +- cli/app.rs | 248 +++---------------- cli/input.rs | 201 +++++++++++++++ cli/main.rs | 9 +- limbo_output.txt | 10 + testing/cli_tests/cli_test_cases.py | 262 ++++++++++++++++++++ testing/cli_tests/test_limbo_cli.py | 136 +++++++++++ testing/shelltests.py | 365 ---------------------------- 8 files changed, 647 insertions(+), 586 deletions(-) create mode 100644 cli/input.rs create mode 100644 limbo_output.txt create mode 100755 testing/cli_tests/cli_test_cases.py create mode 100755 testing/cli_tests/test_limbo_cli.py delete mode 100755 testing/shelltests.py diff --git a/Makefile b/Makefile index 0a2e1082d..715f9d706 100644 --- a/Makefile +++ b/Makefile @@ -71,7 +71,7 @@ test-extensions: limbo .PHONY: test-extensions test-shell: limbo - SQLITE_EXEC=$(SQLITE_EXEC) ./testing/shelltests.py + SQLITE_EXEC=$(SQLITE_EXEC) ./testing/cli_tests/cli_test_cases.py .PHONY: test-shell test-compat: diff --git a/cli/app.rs b/cli/app.rs index 4314ec2cb..0bcb3ba19 100644 --- a/cli/app.rs +++ b/cli/app.rs @@ -1,11 +1,13 @@ use crate::{ import::{ImportFile, IMPORT_HELP}, + input::{get_io, get_writer, DbLocation, Io, OutputMode, Settings, HELP_MSG}, opcodes_dictionary::OPCODE_DESCRIPTIONS, }; use comfy_table::{Attribute, Cell, CellAlignment, ContentArrangement, Row, Table}; use limbo_core::{Database, LimboError, Statement, StepResult, Value}; use clap::{Parser, ValueEnum}; +use rustyline::DefaultEditor; use std::{ io::{self, Write}, path::PathBuf, @@ -49,58 +51,6 @@ pub struct Opts { 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)] pub enum Command { /// Exit this program with return-code CODE @@ -153,7 +103,7 @@ impl Command { | Self::NullValue | Self::LoadExtension => 1, Self::Import => 2, - } // argv0 + } } fn usage(&self) -> &str { @@ -203,7 +153,7 @@ impl FromStr for Command { const PROMPT: &str = "limbo> "; -pub struct Limbo { +pub struct Limbo<'a> { pub prompt: String, io: Arc, writer: Box, @@ -211,58 +161,11 @@ pub struct Limbo { pub interrupt_count: Arc, input_buff: String, opts: Settings, + pub rl: &'a mut DefaultEditor, } -pub struct Settings { - output_filename: String, - 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 { +impl<'a> Limbo<'a> { + pub fn new(rl: &'a mut rustyline::DefaultEditor) -> anyhow::Result { let opts = Opts::parse(); let db_file = opts .database @@ -294,6 +197,7 @@ impl Limbo { interrupt_count, input_buff: String::new(), opts: Settings::from(&opts), + rl, }; if opts.sql.is_some() { app.handle_first_input(opts.sql.as_ref().unwrap()); @@ -443,19 +347,20 @@ impl Limbo { self.reset_input(); } - pub fn handle_input_line( - &mut self, - line: &str, - rl: &mut rustyline::DefaultEditor, - ) -> anyhow::Result<()> { + fn reset_line(&mut self, line: &str) -> rustyline::Result<()> { + self.rl.add_history_entry(line.to_owned())?; + self.interrupt_count.store(0, Ordering::SeqCst); + Ok(()) + } + + pub fn handle_input_line(&mut self, line: &str) -> anyhow::Result<()> { if self.input_buff.is_empty() { if line.is_empty() { return Ok(()); } if line.starts_with('.') { self.handle_dot_command(line); - rl.add_history_entry(line.to_owned())?; - self.interrupt_count.store(0, Ordering::SeqCst); + let _ = self.reset_line(line); return Ok(()); } } @@ -463,16 +368,25 @@ impl Limbo { if let Some(remaining) = line.split_once('\n') { let after_comment = remaining.1.trim(); if !after_comment.is_empty() { - rl.add_history_entry(after_comment.to_owned())?; - self.buffer_input(after_comment); - if after_comment.ends_with(';') { 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 { self.set_multiline_prompt(); + let _ = self.reset_line(line); + return Ok(()); } - self.interrupt_count.store(0, Ordering::SeqCst); - return Ok(()); } } return Ok(()); @@ -481,10 +395,9 @@ impl Limbo { if let Some(comment_pos) = line.find("--") { let before_comment = line[..comment_pos].trim(); if !before_comment.is_empty() { - return self.handle_input_line(before_comment, rl); + return self.handle_input_line(before_comment); } } - if line.ends_with(';') { self.buffer_input(line); let buff = self.input_buff.clone(); @@ -493,8 +406,7 @@ impl Limbo { self.buffer_input(format!("{}\n", line).as_str()); self.set_multiline_prompt(); } - rl.add_history_entry(line.to_owned())?; - self.interrupt_count.store(0, Ordering::SeqCst); + self.reset_line(line)?; Ok(()) } @@ -874,99 +786,3 @@ impl Limbo { self.reset_input(); } } - -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_location: DbLocation, io_choice: Io) -> anyhow::Result> { - 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 ? Exit this program with return-code CODE -.quit Stop interpreting input stream and exit. -.show Display current settings. -.open Open and connect to a database file. -.output Change the output mode. Available modes are 'raw' and 'pretty'. -.schema Show the schema of the specified table. -.tables List names of tables matching LIKE pattern TABLE -.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. -.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."#; diff --git a/cli/input.rs b/cli/input.rs new file mode 100644 index 000000000..125162854 --- /dev/null +++ b/cli/input.rs @@ -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 { + 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> { + 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 Open and connect to a database file. +.output Change the output mode. Available modes are 'raw' and 'pretty'. +.schema Show the schema of the specified table. +.tables List names of tables matching LIKE pattern TABLE +.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. +.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."#; diff --git a/cli/main.rs b/cli/main.rs index 566cb2d48..e59008a8b 100644 --- a/cli/main.rs +++ b/cli/main.rs @@ -1,6 +1,7 @@ #![allow(clippy::arc_with_non_send_sync)] mod app; mod import; +mod input; mod opcodes_dictionary; use rustyline::{error::ReadlineError, DefaultEditor}; @@ -8,17 +9,17 @@ use std::sync::atomic::Ordering; fn main() -> anyhow::Result<()> { env_logger::init(); - let mut app = app::Limbo::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 history_file = home.join(".limbo_history"); if history_file.exists() { - rl.load_history(history_file.as_path())?; + app.rl.load_history(history_file.as_path())?; } loop { - let readline = rl.readline(&app.prompt); + let readline = app.rl.readline(&app.prompt); match readline { - Ok(line) => match app.handle_input_line(line.trim(), &mut rl) { + Ok(line) => match app.handle_input_line(line.trim()) { Ok(_) => {} Err(e) => { eprintln!("{}", e); diff --git a/limbo_output.txt b/limbo_output.txt new file mode 100644 index 000000000..a15af0dc2 --- /dev/null +++ b/limbo_output.txt @@ -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 diff --git a/testing/cli_tests/cli_test_cases.py b/testing/cli_tests/cli_test_cases.py new file mode 100755 index 000000000..6eb1106f2 --- /dev/null +++ b/testing/cli_tests/cli_test_cases.py @@ -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") diff --git a/testing/cli_tests/test_limbo_cli.py b/testing/cli_tests/test_limbo_cli.py new file mode 100755 index 000000000..3fca1aa9c --- /dev/null +++ b/testing/cli_tests/test_limbo_cli.py @@ -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) diff --git a/testing/shelltests.py b/testing/shelltests.py deleted file mode 100755 index 81b0cd78f..000000000 --- a/testing/shelltests.py +++ /dev/null @@ -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.")