mirror of
https://github.com/aljazceru/turso.git
synced 2025-12-18 09:04:19 +01:00
cleanup shell tests and cli
This commit is contained in:
2
Makefile
2
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:
|
||||
|
||||
248
cli/app.rs
248
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<dyn limbo_core::IO>,
|
||||
writer: Box<dyn Write>,
|
||||
@@ -211,58 +161,11 @@ pub struct Limbo {
|
||||
pub interrupt_count: Arc<AtomicUsize>,
|
||||
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<Self> {
|
||||
impl<'a> Limbo<'a> {
|
||||
pub fn new(rl: &'a mut rustyline::DefaultEditor) -> anyhow::Result<Self> {
|
||||
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<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
201
cli/input.rs
Normal 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."#;
|
||||
@@ -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);
|
||||
|
||||
10
limbo_output.txt
Normal file
10
limbo_output.txt
Normal 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
|
||||
262
testing/cli_tests/cli_test_cases.py
Executable file
262
testing/cli_tests/cli_test_cases.py
Executable 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")
|
||||
136
testing/cli_tests/test_limbo_cli.py
Executable file
136
testing/cli_tests/test_limbo_cli.py
Executable 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)
|
||||
@@ -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.")
|
||||
Reference in New Issue
Block a user