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
|
.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:
|
||||||
|
|||||||
248
cli/app.rs
248
cli/app.rs
@@ -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
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)]
|
#![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
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