mirror of
https://github.com/tursodatabase/libsql.git
synced 2025-01-22 07:58:56 +00:00
1027 lines
37 KiB
Rust
1027 lines
37 KiB
Rust
use anyhow::Result;
|
|
use base64::{engine::general_purpose, Engine as _};
|
|
use clap::Parser;
|
|
use once_cell::sync::Lazy;
|
|
use rusqlite::ffi::{
|
|
sqlite3_changes64, sqlite3_db_config, sqlite3_total_changes64, SQLITE_DBCONFIG_DEFENSIVE,
|
|
SQLITE_DBCONFIG_DQS_DDL, SQLITE_DBCONFIG_DQS_DML, SQLITE_DBCONFIG_ENABLE_FKEY,
|
|
SQLITE_DBCONFIG_ENABLE_FTS3_TOKENIZER, SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION,
|
|
SQLITE_DBCONFIG_ENABLE_QPSG, SQLITE_DBCONFIG_ENABLE_TRIGGER, SQLITE_DBCONFIG_ENABLE_VIEW,
|
|
SQLITE_DBCONFIG_LEGACY_ALTER_TABLE, SQLITE_DBCONFIG_LEGACY_FILE_FORMAT,
|
|
SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE, SQLITE_DBCONFIG_RESET_DATABASE,
|
|
SQLITE_DBCONFIG_REVERSE_SCANORDER, SQLITE_DBCONFIG_STMT_SCANSTATUS,
|
|
SQLITE_DBCONFIG_TRIGGER_EQP, SQLITE_DBCONFIG_TRUSTED_SCHEMA, SQLITE_DBCONFIG_WRITABLE_SCHEMA,
|
|
};
|
|
use rusqlite::{types::ValueRef, Connection, LoadExtensionGuard, OpenFlags, Statement};
|
|
use rusqlite::{DatabaseName, Params};
|
|
use rustyline::completion::{Completer, Pair};
|
|
use rustyline::error::ReadlineError;
|
|
use rustyline::history::FileHistory;
|
|
use rustyline::{CompletionType, Config, Context, Editor};
|
|
use rustyline_derive::{Helper, Highlighter, Hinter, Validator};
|
|
use std::collections::BTreeMap;
|
|
use std::fmt;
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::exit;
|
|
use tabled::settings::Style;
|
|
use tabled::Table;
|
|
|
|
#[derive(Debug, Parser)]
|
|
#[command(name = "libsql")]
|
|
#[command(about = "libSQL client", long_about = None)]
|
|
struct Cli {
|
|
#[clap()]
|
|
db_path: Option<String>,
|
|
|
|
/// Print inputs before execution
|
|
#[arg(long, default_value = "false")]
|
|
echo: bool,
|
|
/// Refuse to open symbolic links to database files
|
|
#[arg(long = "nofollow", default_value = "false")]
|
|
no_follow: bool,
|
|
/// Run "COMMAND" before reading stdin
|
|
#[arg(long = "cmd", action = clap::ArgAction::Append)]
|
|
command: Option<Vec<String>>,
|
|
}
|
|
|
|
struct StrStatements {
|
|
value: String,
|
|
}
|
|
|
|
impl Iterator for StrStatements {
|
|
type Item = String;
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
let mut embedded = false;
|
|
let mut pos = 0;
|
|
for (index, char) in self.value.chars().enumerate() {
|
|
if char == '\'' {
|
|
embedded = !embedded;
|
|
continue;
|
|
}
|
|
if embedded || char != ';' {
|
|
continue;
|
|
}
|
|
let str_statement = self.value[pos..index + 1].to_string();
|
|
if str_statement.trim().starts_with(';') {
|
|
pos = index + 1;
|
|
continue;
|
|
}
|
|
self.value = self.value[index + 1..].to_string();
|
|
return Some(str_statement.trim().to_string());
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
fn get_str_statements(str: String) -> StrStatements {
|
|
StrStatements { value: str }
|
|
}
|
|
|
|
/// State information about the database connection is contained in an
|
|
/// instance of the following structure.
|
|
struct Shell {
|
|
/// The database
|
|
db: Connection,
|
|
/// Write results here
|
|
out: Out,
|
|
|
|
bail: bool,
|
|
changes: bool,
|
|
echo: bool,
|
|
eqp: bool,
|
|
explain: ExplainMode,
|
|
headers: bool,
|
|
mode: OutputMode,
|
|
null_value: String,
|
|
stats: StatsMode,
|
|
width: [usize; 5],
|
|
filename: PathBuf,
|
|
|
|
commands_before_repl: Option<Vec<String>>,
|
|
colseparator: String,
|
|
rowseparator: String,
|
|
main_prompt: String,
|
|
continuation_prompt: String,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
enum Out {
|
|
Stdout,
|
|
File(std::fs::File, PathBuf),
|
|
}
|
|
|
|
impl Write for Out {
|
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
|
match self {
|
|
Out::Stdout => std::io::stdout().write(buf),
|
|
Out::File(file, _) => file.write(buf),
|
|
}
|
|
}
|
|
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
match self {
|
|
Out::Stdout => std::io::stdout().flush(),
|
|
Out::File(file, _) => file.flush(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Out {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Out::Stdout => write!(f, "stdout"),
|
|
Out::File(_, path) => write!(f, "{}", path.display()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
enum ExplainMode {
|
|
Off,
|
|
On,
|
|
Auto,
|
|
}
|
|
|
|
impl fmt::Display for ExplainMode {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(
|
|
f,
|
|
"{}",
|
|
match *self {
|
|
ExplainMode::Off => "off",
|
|
ExplainMode::On => "on",
|
|
ExplainMode::Auto => "auto",
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug)]
|
|
enum OutputMode {
|
|
/// Columns/rows delimited by 0x1F and 0x1E
|
|
Ascii,
|
|
/// Tables using unicode box-drawing characters
|
|
Box,
|
|
/// Comma-separated values
|
|
Csv,
|
|
/// Output in columns. (see .width)
|
|
Column,
|
|
/// HTML <table> code
|
|
Html,
|
|
/// SQL insert statements for TABLE
|
|
Insert,
|
|
/// Results in a JSON array
|
|
Json,
|
|
/// One value per line
|
|
Line,
|
|
/// Values delimited by "|"
|
|
List,
|
|
/// Markdown table format
|
|
Markdown,
|
|
/// Escape answers as for SQL
|
|
Quote,
|
|
/// ASCII-art table
|
|
Table,
|
|
/// Tab-separated valeus
|
|
Tabs,
|
|
/// TCL list elements
|
|
Tcl,
|
|
}
|
|
|
|
impl fmt::Display for OutputMode {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "{}", format!("{:?}", self).to_lowercase())
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
enum StatsMode {
|
|
/// Turn off automatic stat display
|
|
Off,
|
|
/// Turn on automatic stat display
|
|
On,
|
|
/// Show statement stats
|
|
Stmt,
|
|
/// Show the virtual machine step count only
|
|
Vmstep,
|
|
}
|
|
|
|
impl fmt::Display for StatsMode {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(
|
|
f,
|
|
"{}",
|
|
match *self {
|
|
StatsMode::Off => "off",
|
|
StatsMode::On => "on",
|
|
StatsMode::Stmt => "stmt",
|
|
StatsMode::Vmstep => "vmstep",
|
|
}
|
|
)
|
|
}
|
|
}
|
|
impl Shell {
|
|
fn new(args: Cli) -> Result<Self> {
|
|
let connection = match args.db_path.as_deref() {
|
|
None | Some("") | Some(":memory:") => {
|
|
println!("Connected to a transient in-memory database.");
|
|
Connection::open_in_memory()?
|
|
}
|
|
Some(path) => {
|
|
let mut flags = OpenFlags::default();
|
|
if args.no_follow {
|
|
flags.insert(OpenFlags::SQLITE_OPEN_NOFOLLOW);
|
|
}
|
|
Connection::open_with_flags(path, flags)?
|
|
}
|
|
};
|
|
|
|
Ok(Self {
|
|
bail: false,
|
|
changes: false,
|
|
db: connection,
|
|
out: Out::Stdout,
|
|
echo: args.echo,
|
|
eqp: false,
|
|
explain: ExplainMode::Auto,
|
|
headers: true,
|
|
mode: OutputMode::Column,
|
|
stats: StatsMode::Off,
|
|
width: [0; 5],
|
|
null_value: String::new(),
|
|
filename: PathBuf::from(args.db_path.unwrap_or_else(|| ":memory:".to_string())),
|
|
commands_before_repl: args.command,
|
|
colseparator: String::from("|"),
|
|
rowseparator: String::from("\n"),
|
|
main_prompt: "libsql> ".to_string(),
|
|
continuation_prompt: " ...> ".to_string(),
|
|
})
|
|
}
|
|
|
|
fn parse_and_run_command(&mut self, line: &str) -> Result<()> {
|
|
// split line on whitespace, but not inside quotes.
|
|
let mut split = vec![];
|
|
for (i, chunk) in line.split_terminator(&['\'', '"']).enumerate() {
|
|
if i % 2 != 0 {
|
|
split.push(chunk);
|
|
} else {
|
|
split.extend(chunk.split_whitespace())
|
|
}
|
|
}
|
|
self.run_command(split[0], &split[1..])
|
|
}
|
|
|
|
fn run(mut self, rl: &mut Editor<ShellHelper, FileHistory>) -> Result<()> {
|
|
if let Some(commands) = self.commands_before_repl.take() {
|
|
for command in commands {
|
|
self.parse_and_run_command(&command)?;
|
|
}
|
|
}
|
|
|
|
let mut leftovers = String::new();
|
|
loop {
|
|
let prompt = if leftovers.is_empty() {
|
|
self.main_prompt.as_str()
|
|
} else {
|
|
self.continuation_prompt.as_str()
|
|
};
|
|
let readline = rl.readline(prompt);
|
|
match readline {
|
|
Ok(line) => {
|
|
let line = leftovers + line.trim_end();
|
|
if line.ends_with(';') || line.starts_with('.') {
|
|
leftovers = String::new();
|
|
} else {
|
|
leftovers = line + " ";
|
|
continue;
|
|
};
|
|
rl.add_history_entry(&line).ok();
|
|
if self.echo {
|
|
writeln!(self.out, "{}", line)?;
|
|
}
|
|
if line.starts_with('.') {
|
|
self.parse_and_run_command(&line)?;
|
|
} else {
|
|
for str_statement in get_str_statements(line) {
|
|
let table = self.run_statement(str_statement, (), false);
|
|
match table {
|
|
Ok(table) => {
|
|
if self.headers && table.count_rows() == 1
|
|
|| !self.headers && table.count_rows() == 0
|
|
{
|
|
continue;
|
|
}
|
|
writeln!(self.out, "{}", table)?;
|
|
if self.changes {
|
|
unsafe {
|
|
let db = self.db.handle();
|
|
let changes = sqlite3_changes64(db);
|
|
let total_changes = sqlite3_total_changes64(db);
|
|
writeln!(
|
|
self.out,
|
|
"changes: {} total_changes: {}",
|
|
changes, total_changes
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
println!("Error: {}", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(ReadlineError::Interrupted) => {
|
|
println!("^C");
|
|
leftovers.clear();
|
|
}
|
|
Err(ReadlineError::Eof) => {
|
|
println!("^D");
|
|
break;
|
|
}
|
|
Err(err) => {
|
|
println!("Error: {:?}", err);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_command(&mut self, command: &str, args: &[&str]) -> Result<()> {
|
|
let mut result = None;
|
|
match command {
|
|
".bail" => toggle_option(command, &mut self.bail, args),
|
|
".changes" => toggle_option(command, &mut self.changes, args),
|
|
".databases" => {
|
|
let statement = "pragma database_list;";
|
|
let mut stmt = self.db.prepare(statement)?;
|
|
let mut rows = stmt.query([])?;
|
|
while let Some(row) = rows.next()? {
|
|
let name = row.get::<_, String>(1)?;
|
|
let file = row.get::<_, String>(2)?;
|
|
let db_name = match name.as_str() {
|
|
"main" => DatabaseName::Main,
|
|
"temp" => DatabaseName::Temp,
|
|
s => DatabaseName::Attached(s),
|
|
};
|
|
let readonly = if self.db.is_readonly(db_name)? {
|
|
"r/o"
|
|
} else {
|
|
"r/w"
|
|
};
|
|
writeln!(self.out, "{}: {:?} {}", name, file, readonly)?;
|
|
}
|
|
}
|
|
".dbconfig" => {
|
|
static DBCONFIG: Lazy<BTreeMap<&str, i32>> = Lazy::new(|| {
|
|
[
|
|
("defensive", SQLITE_DBCONFIG_DEFENSIVE),
|
|
("dqs_ddl", SQLITE_DBCONFIG_DQS_DDL),
|
|
("dqs_dml", SQLITE_DBCONFIG_DQS_DML),
|
|
("enable_fkey", SQLITE_DBCONFIG_ENABLE_FKEY),
|
|
("enable_qpsg", SQLITE_DBCONFIG_ENABLE_QPSG),
|
|
("enable_trigger", SQLITE_DBCONFIG_ENABLE_TRIGGER),
|
|
("enable_view", SQLITE_DBCONFIG_ENABLE_VIEW),
|
|
("fts3_tokenizer", SQLITE_DBCONFIG_ENABLE_FTS3_TOKENIZER),
|
|
("legacy_alter_table", SQLITE_DBCONFIG_LEGACY_ALTER_TABLE),
|
|
("legacy_file_format", SQLITE_DBCONFIG_LEGACY_FILE_FORMAT),
|
|
("load_extension", SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION),
|
|
("no_ckpt_on_close", SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE),
|
|
("reset_database", SQLITE_DBCONFIG_RESET_DATABASE),
|
|
("reverse_scanorder", SQLITE_DBCONFIG_REVERSE_SCANORDER),
|
|
("stmt_scanstatus", SQLITE_DBCONFIG_STMT_SCANSTATUS),
|
|
("trigger_eqp", SQLITE_DBCONFIG_TRIGGER_EQP),
|
|
("trusted_schema", SQLITE_DBCONFIG_TRUSTED_SCHEMA),
|
|
("writable_schema", SQLITE_DBCONFIG_WRITABLE_SCHEMA),
|
|
]
|
|
.into_iter()
|
|
.collect()
|
|
});
|
|
let db = unsafe { self.db.handle() };
|
|
if args.is_empty() {
|
|
for (name, opt) in DBCONFIG.iter() {
|
|
let enabled = 0;
|
|
unsafe {
|
|
sqlite3_db_config(db, *opt, -1, &enabled);
|
|
}
|
|
writeln!(
|
|
self.out,
|
|
"{:>19} {}",
|
|
name,
|
|
if enabled == 0 { "off" } else { "on" }
|
|
)?;
|
|
}
|
|
} else {
|
|
match DBCONFIG.get(args[0]) {
|
|
Some(opt) => match args.len() {
|
|
1 => {
|
|
let enabled = 0;
|
|
unsafe {
|
|
sqlite3_db_config(db, *opt, -1, &enabled);
|
|
}
|
|
writeln!(
|
|
self.out,
|
|
"{:>19} {}",
|
|
args[0],
|
|
if enabled == 0 { "off" } else { "on" }
|
|
)?;
|
|
}
|
|
2 => {
|
|
let enabled = match args[1].to_lowercase().as_str() {
|
|
"on" | "true" | "yes" => true,
|
|
"off" | "false" | "no" => false,
|
|
arg => {
|
|
if arg.chars().all(|a| a.is_ascii_digit()) {
|
|
arg != "0"
|
|
} else {
|
|
println!("ERROR: Not a boolean value: \"{}\". Assuming \"no\"", arg);
|
|
false
|
|
}
|
|
}
|
|
};
|
|
unsafe {
|
|
sqlite3_db_config(db, *opt, enabled as i32, 0);
|
|
}
|
|
writeln!(
|
|
self.out,
|
|
"{:>19} {}",
|
|
args[0],
|
|
if enabled { "on" } else { "off" }
|
|
)?;
|
|
}
|
|
_ => println!("Usage: .dbconfig ?op? ?val? List or change sqlite3_db_config() options"),
|
|
},
|
|
None => {
|
|
println!("Error: unknown dbconfig {:?}\nEnter \".dbconfig\" with no arguments for a list", args[0]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
".echo" => toggle_option(command, &mut self.echo, args),
|
|
".exit" => {
|
|
if args.len() != 1 {
|
|
exit(0);
|
|
}
|
|
|
|
let mut chars = args[0].bytes();
|
|
let mut code = 0;
|
|
let neg = match chars.next() {
|
|
Some(b'-') => true,
|
|
Some(c) if c.is_ascii_digit() => {
|
|
code = code * 10 + (c - b'0') as i32;
|
|
false
|
|
}
|
|
_ => exit(0),
|
|
};
|
|
|
|
for c in chars {
|
|
if c.is_ascii_digit() {
|
|
code = code * 10 + (c - b'0') as i32;
|
|
} else {
|
|
exit(0);
|
|
}
|
|
}
|
|
if neg {
|
|
code = -code;
|
|
}
|
|
|
|
// exit code is in range [0, 255]
|
|
exit(code);
|
|
}
|
|
".headers" => {
|
|
if args.len() != 1 {
|
|
writeln!(self.out, "Usage: .headers on|off")?;
|
|
return Ok(());
|
|
}
|
|
match args[0].to_lowercase().as_str() {
|
|
"on" | "true" => self.headers = true,
|
|
"off" | "false" => self.headers = false,
|
|
txt => {
|
|
self.headers = false;
|
|
writeln!(
|
|
self.out,
|
|
"ERROR: Not a boolean value: \"{}\". Assuming \"no\"",
|
|
txt
|
|
)?
|
|
}
|
|
}
|
|
}
|
|
".help" => self.show_help(args),
|
|
".indexes" => result = Some(self.list_tables(args.first().copied(), true)),
|
|
".load" => {
|
|
let (filename, entrypoint) = match args.len() {
|
|
1 => (args[0], None),
|
|
2 => (args[0], Some(args[1])),
|
|
_ => {
|
|
writeln!(self.out, "Usage: .load FILENAME ?ENTRYPOINT?")?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
if let Err(e) = load_my_extension(&self.db, filename, entrypoint) {
|
|
println!("Error: {}", e);
|
|
}
|
|
}
|
|
".nullvalue" => {
|
|
if args.len() != 1 {
|
|
writeln!(self.out, "Usage: .nullvalue STRING")?;
|
|
return Ok(());
|
|
}
|
|
self.null_value = args[0].to_string();
|
|
}
|
|
".print" => {
|
|
writeln!(self.out, "{}", args.join(" "))?;
|
|
}
|
|
".prompt" => {
|
|
if !args.is_empty() {
|
|
self.main_prompt = args[0].to_string();
|
|
}
|
|
if args.len() > 1 {
|
|
self.continuation_prompt = args[1].to_string();
|
|
}
|
|
}
|
|
".quit" => exit(0),
|
|
".open" => {
|
|
// .open ?OPTIONS? ?FILE?
|
|
let mut filename = None;
|
|
let mut flags = OpenFlags::default();
|
|
for arg in args {
|
|
match *arg {
|
|
"--append" | "--deserialize" | "--hexdb" | "--maxsize" | "--zip" => {
|
|
println!("`{}` is not supported yet", arg);
|
|
return Ok(());
|
|
}
|
|
"--new" => flags.insert(OpenFlags::SQLITE_OPEN_CREATE),
|
|
"--nofollow" => flags.insert(OpenFlags::SQLITE_OPEN_NOFOLLOW),
|
|
"--readonly" => {
|
|
flags.remove(OpenFlags::SQLITE_OPEN_CREATE);
|
|
flags.remove(OpenFlags::SQLITE_OPEN_READ_WRITE);
|
|
flags.insert(OpenFlags::SQLITE_OPEN_READ_ONLY);
|
|
}
|
|
arg => {
|
|
if arg.starts_with('-') {
|
|
println!("unknown option: {}", arg);
|
|
return Ok(());
|
|
}
|
|
|
|
if filename.is_some() {
|
|
println!("extra argument: \"{}\"", arg);
|
|
return Ok(());
|
|
}
|
|
|
|
filename = Some(arg);
|
|
}
|
|
}
|
|
}
|
|
|
|
(self.filename, self.db) = match filename {
|
|
Some(path) => {
|
|
let db = match Connection::open_with_flags(path, flags) {
|
|
Ok(con) => con,
|
|
Err(e) => {
|
|
println!("Error: unable to open database \"{}\": {}\nNotice: using substitute in-memory database instead of \"{}\"", path, e, path);
|
|
return Ok(());
|
|
}
|
|
};
|
|
(path.into(), db)
|
|
}
|
|
None => {
|
|
let db = match Connection::open_in_memory() {
|
|
Ok(con) => con,
|
|
Err(_e) => {
|
|
println!("Error: unable to open database in memory");
|
|
return Ok(());
|
|
}
|
|
};
|
|
("".into(), db)
|
|
}
|
|
};
|
|
}
|
|
".read" => {
|
|
if args.len() != 1 {
|
|
writeln!(self.out, "Usage: .read FILE")?;
|
|
return Ok(());
|
|
}
|
|
|
|
let filename = args[0];
|
|
let reader = match std::fs::File::open(filename) {
|
|
Ok(file) => BufReader::new(file),
|
|
Err(_e) => {
|
|
println!("Error: cannot open \"{}\"", args[0]);
|
|
return Ok(());
|
|
}
|
|
};
|
|
for (i, line) in reader.lines().enumerate() {
|
|
let statement = line?;
|
|
match self.run_statement(statement, (), false) {
|
|
Ok(table) => {
|
|
if !table.is_empty() {
|
|
writeln!(self.out, "{}", table)?;
|
|
}
|
|
if self.changes {
|
|
unsafe {
|
|
let db = self.db.handle();
|
|
let changes = sqlite3_changes64(db);
|
|
let total_changes = sqlite3_total_changes64(db);
|
|
writeln!(
|
|
self.out,
|
|
"changes: {} total_changes: {}",
|
|
changes, total_changes
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
println!("Parse error near line {}: {}", i + 1, e);
|
|
if self.bail {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
".show" => {
|
|
if !args.is_empty() {
|
|
writeln!(self.out, "Usage: .show")?;
|
|
return Ok(());
|
|
}
|
|
|
|
let out_name = format!("{}", self.out);
|
|
write!(
|
|
self.out,
|
|
r#"{:>12}: {}
|
|
{:>12}: {}
|
|
{:>12}: {}
|
|
{:>12}: {}
|
|
{:>12}: {}
|
|
{:>12}: "{}"
|
|
{:>12}: {}
|
|
{:>12}: {:?}
|
|
{:>12}: {:?}
|
|
{:>12}: {}
|
|
{:>12}: {:?}
|
|
{:>12}: {}
|
|
"#,
|
|
"echo",
|
|
if self.echo { "on" } else { "off" },
|
|
"eqp",
|
|
if self.eqp { "on" } else { "off" },
|
|
"explain",
|
|
self.explain,
|
|
"headers",
|
|
if self.headers { "on" } else { "off" },
|
|
"mode",
|
|
self.mode,
|
|
"nullvalue",
|
|
self.null_value,
|
|
"output",
|
|
out_name,
|
|
"colseparator",
|
|
self.colseparator,
|
|
"rowseparator",
|
|
self.rowseparator,
|
|
"stats",
|
|
self.stats,
|
|
"width",
|
|
self.width,
|
|
"filename",
|
|
self.filename.display()
|
|
)?;
|
|
}
|
|
".tables" => result = Some(self.list_tables(args.first().copied(), false)),
|
|
_ => println!(
|
|
"Error: unknown command or invalid arguments: \"{}\". Enter \".help\" for help",
|
|
command
|
|
),
|
|
}
|
|
match result {
|
|
Some(Ok(mut table)) => {
|
|
if table.count_rows() != 0 {
|
|
table.with(Style::blank());
|
|
writeln!(self.out, "{}", table)?;
|
|
}
|
|
}
|
|
Some(Err(e)) => {
|
|
println!("Error: {e}");
|
|
}
|
|
None => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_statement<P>(&self, statement: String, params: P, is_command: bool) -> Result<Table>
|
|
where
|
|
P: Params,
|
|
{
|
|
let mut stmt: Statement<'_> = self.db.prepare(&statement)?;
|
|
// TODO: introduce paging for presenting large results, get rid of Vec
|
|
let rows: Vec<Vec<String>> = {
|
|
let column_count = stmt.column_count();
|
|
|
|
let rows = stmt.query_map(params, |row| {
|
|
let row = (0..column_count)
|
|
.map(|idx| self.format_value(row.get_ref(idx).unwrap()))
|
|
.collect::<Vec<String>>();
|
|
Ok(row)
|
|
})?;
|
|
|
|
let mut mapped_rows = vec![];
|
|
for row in rows.flatten() {
|
|
mapped_rows.push(row);
|
|
}
|
|
mapped_rows
|
|
};
|
|
|
|
let mut builder = tabled::builder::Builder::new();
|
|
// TODO: switch style based on mode.
|
|
let style = Style::ascii();
|
|
if self.headers && !is_command {
|
|
// if we use a SQL statement to execute a command, don't include the headers.
|
|
// affected commands: .tables
|
|
builder.set_header(stmt.column_names());
|
|
}
|
|
for row in rows {
|
|
builder.push_record(row);
|
|
}
|
|
let mut table = builder.build();
|
|
table.with(style);
|
|
Ok(table)
|
|
}
|
|
|
|
// helper functions
|
|
|
|
// Presents libSQL values in human-readable form
|
|
fn format_value(&self, v: ValueRef) -> String {
|
|
match v {
|
|
ValueRef::Null => self.null_value.clone(),
|
|
ValueRef::Integer(i) => format!("{i}"),
|
|
ValueRef::Real(r) => format!("{r}"),
|
|
ValueRef::Text(s) => std::str::from_utf8(s).unwrap().to_owned(),
|
|
ValueRef::Blob(b) => format!("0x{}", general_purpose::STANDARD_NO_PAD.encode(b)),
|
|
}
|
|
}
|
|
// COMMANDS
|
|
|
|
fn list_tables(&self, pattern: Option<&str>, is_index: bool) -> Result<Table> {
|
|
let mut statement =
|
|
String::from("SELECT name FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'");
|
|
if is_index {
|
|
statement.push_str("AND type='index' ")
|
|
} else {
|
|
statement.push_str("AND type IN ('table','view') ")
|
|
}
|
|
match pattern {
|
|
Some(p) => {
|
|
if is_index {
|
|
statement.push_str("AND tbl_name LIKE :name;");
|
|
} else {
|
|
statement.push_str("AND name NOT LIKE 'sqlite_%' AND name LIKE :name;");
|
|
}
|
|
self.run_statement(statement, &[(":name", p)], true)
|
|
}
|
|
None => {
|
|
statement.push(';');
|
|
self.run_statement(statement, (), true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO: implement `-all` option: print detailed flags for each command
|
|
// TODO: implement `?PATTERN?` : allow narrowing using prefix search.
|
|
fn show_help(&mut self, _args: &[&str]) {
|
|
let help = r#"
|
|
.auth ON|OFF Show authorizer callbacks
|
|
.backup ?DB? FILE Backup DB (default "main") to FILE
|
|
.bail on|off Stop after hitting an error. Default OFF
|
|
.binary on|off Turn binary output on or off. Default OFF
|
|
.cd DIRECTORY Change the working directory to DIRECTORY
|
|
.changes on|off Show number of rows changed by SQL
|
|
.check GLOB Fail if output since .testcase does not match
|
|
.clone NEWDB Clone data into NEWDB from the existing database
|
|
.connection [close] [#] Open or close an auxiliary database connection
|
|
.databases List names and files of attached databases
|
|
.dbconfig ?op? ?val? List or change sqlite3_db_config() options
|
|
.dbinfo ?DB? Show status information about the database
|
|
.dump ?OBJECTS? Render database content as SQL
|
|
.echo on|off Turn command echo on or off
|
|
.eqp on|off|full|... Enable or disable automatic EXPLAIN QUERY PLAN
|
|
.excel Display the output of next command in spreadsheet
|
|
.exit ?CODE? Exit this program with return-code CODE
|
|
.expert EXPERIMENTAL. Suggest indexes for queries
|
|
.explain ?on|off|auto? Change the EXPLAIN formatting mode. Default: auto
|
|
.filectrl CMD ... Run various sqlite3_file_control() operations
|
|
.fullschema ?--indent? Show schema and the content of sqlite_stat tables
|
|
.headers on|off Turn display of headers on or off
|
|
.help ?-all? ?PATTERN? Show help text for PATTERN
|
|
.import FILE TABLE Import data from FILE into TABLE
|
|
.imposter INDEX TABLE Create imposter table TABLE on index INDEX
|
|
.indexes ?TABLE? Show names of indexes
|
|
.limit ?LIMIT? ?VAL? Display or change the value of an SQLITE_LIMIT
|
|
.lint OPTIONS Report potential schema issues.
|
|
.load FILE ?ENTRY? Load an extension library
|
|
.log FILE|off Turn logging on or off. FILE can be stderr/stdout
|
|
.mode MODE ?TABLE? Set output mode
|
|
.nonce STRING Disable safe mode for one command if the nonce matches
|
|
.nullvalue STRING Use STRING in place of NULL values
|
|
.once ?OPTIONS? ?FILE? Output for the next SQL command only to FILE
|
|
.open ?OPTIONS? ?FILE? Close existing database and reopen FILE
|
|
.output ?FILE? Send output to FILE or stdout if FILE is omitted
|
|
.parameter CMD ... Manage SQL parameter bindings
|
|
.print STRING... Print literal STRING
|
|
.progress N Invoke progress handler after every N opcodes
|
|
.prompt MAIN CONTINUE Replace the standard prompts
|
|
.quit Exit this program
|
|
.read FILE Read input from FILE
|
|
.recover Recover as much data as possible from corrupt db.
|
|
.restore ?DB? FILE Restore content of DB (default "main") from FILE
|
|
.save FILE Write in-memory database into FILE
|
|
.scanstats on|off Turn sqlite3_stmt_scanstatus() metrics on or off
|
|
.schema ?PATTERN? Show the CREATE statements matching PATTERN
|
|
.selftest ?OPTIONS? Run tests defined in the SELFTEST table
|
|
.separator COL ?ROW? Change the column and row separators
|
|
.session ?NAME? CMD ... Create or control sessions
|
|
.sha3sum ... Compute a SHA3 hash of database content
|
|
.shell CMD ARGS... Run CMD ARGS... in a system shell
|
|
.show Show the current values for various settings
|
|
.stats ?ARG? Show stats or turn stats on or off
|
|
.system CMD ARGS... Run CMD ARGS... in a system shell
|
|
.tables ?TABLE? List names of tables matching LIKE pattern TABLE
|
|
.testcase NAME Begin redirecting output to 'testcase-out.txt'
|
|
.testctrl CMD ... Run various sqlite3_test_control() operations
|
|
.timeout MS Try opening locked tables for MS milliseconds
|
|
.timer on|off Turn SQL timer on or off
|
|
.trace ?OPTIONS? Output each SQL statement as it is run
|
|
.vfsinfo ?AUX? Information about the top-level VFS
|
|
.vfslist List all available VFSes
|
|
.vfsname ?AUX? Print the name of the VFS stack
|
|
.width NUM1 NUM2 ... Set minimum column widths for columnar output
|
|
"#;
|
|
_ = writeln!(self.out, "{}", help.trim());
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct ShellCompleter {}
|
|
|
|
impl ShellCompleter {
|
|
fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
fn complete(
|
|
&self,
|
|
line: &str,
|
|
_pos: usize,
|
|
_: &Context,
|
|
) -> Result<(usize, Vec<Pair>), ReadlineError> {
|
|
let mut pairs: Vec<Pair> = vec![];
|
|
let commands = vec![
|
|
".echo",
|
|
".headers",
|
|
".help",
|
|
".indexes",
|
|
".nullvalue",
|
|
".print",
|
|
".prompt",
|
|
".quit",
|
|
".show",
|
|
".tables",
|
|
];
|
|
for command in commands {
|
|
if command.starts_with(line) {
|
|
pairs.push(Pair {
|
|
display: command.to_string(),
|
|
replacement: command.to_string(),
|
|
})
|
|
}
|
|
}
|
|
Ok((0, pairs))
|
|
}
|
|
}
|
|
|
|
#[derive(Helper, Hinter, Validator, Highlighter)]
|
|
struct ShellHelper {
|
|
#[rustyline(Completer)]
|
|
completer: ShellCompleter,
|
|
}
|
|
|
|
impl Completer for ShellHelper {
|
|
type Candidate = Pair;
|
|
|
|
fn complete(
|
|
&self,
|
|
line: &str,
|
|
pos: usize,
|
|
ctx: &Context,
|
|
) -> Result<(usize, Vec<Pair>), ReadlineError> {
|
|
self.completer.complete(line, pos, ctx)
|
|
}
|
|
}
|
|
|
|
fn main() -> Result<()> {
|
|
tracing_subscriber::fmt::init();
|
|
let args = Cli::parse();
|
|
|
|
let config = Config::builder()
|
|
.history_ignore_space(true)
|
|
.completion_type(CompletionType::Circular)
|
|
.build();
|
|
let mut rl = Editor::with_config(config)?;
|
|
|
|
let helper = ShellHelper {
|
|
completer: ShellCompleter::new(),
|
|
};
|
|
rl.set_helper(Some(helper));
|
|
|
|
let mut history = home::home_dir().unwrap_or_default();
|
|
history.push(".libsql_history");
|
|
rl.load_history(history.as_path()).ok();
|
|
|
|
println!("libSQL version 0.2.0");
|
|
let shell = Shell::new(args)?;
|
|
let result = shell.run(&mut rl);
|
|
rl.save_history(history.as_path()).ok();
|
|
result
|
|
}
|
|
|
|
fn toggle_option(name: &str, value: &mut bool, args: &[&str]) {
|
|
if args.len() != 1 {
|
|
println!("Usage: {} on|off", name);
|
|
return;
|
|
}
|
|
|
|
match args[0].to_lowercase().as_str() {
|
|
"on" | "true" | "yes" => *value = true,
|
|
"off" | "false" | "no" => *value = false,
|
|
arg => {
|
|
// FIXME Run with `.bail '123"`, it should not be legal, but `split_terminator`
|
|
// return args as ["123"] which however work here.
|
|
// It's not a big problem, but it doesn't behave same as `sqlite`.
|
|
if arg.chars().all(|a| a.is_ascii_digit()) {
|
|
*value = arg != "0";
|
|
} else {
|
|
*value = false;
|
|
println!("ERROR: Not a boolean value: \"{}\". Assuming \"no\"", arg)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn load_my_extension<P: AsRef<Path>>(
|
|
conn: &Connection,
|
|
dylib_path: P,
|
|
entry_point: Option<&str>,
|
|
) -> rusqlite::Result<()> {
|
|
let _guard = unsafe { LoadExtensionGuard::new(conn)? };
|
|
unsafe { conn.load_extension(dylib_path, entry_point) }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_str_statements_iterator() {
|
|
let mut str_statements_iterator =
|
|
get_str_statements(String::from("SELECT ';' FROM test; SELECT * FROM test;;"));
|
|
assert_eq!(
|
|
str_statements_iterator.next(),
|
|
Some("SELECT ';' FROM test;".to_owned())
|
|
);
|
|
assert_eq!(
|
|
str_statements_iterator.next(),
|
|
Some("SELECT * FROM test;".to_owned())
|
|
);
|
|
assert_eq!(str_statements_iterator.next(), None);
|
|
|
|
let mut str_statements_iterator = get_str_statements(String::from(";;;"));
|
|
assert_eq!(str_statements_iterator.next(), None);
|
|
|
|
let mut str_statements_iterator = get_str_statements(String::from(" "));
|
|
assert_eq!(str_statements_iterator.next(), None);
|
|
|
|
let mut str_statements_iterator = get_str_statements(String::from(" ; ; ; "));
|
|
assert_eq!(str_statements_iterator.next(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_statement() {
|
|
let cli = Cli {
|
|
db_path: Some(":memory:".to_string()),
|
|
echo: false,
|
|
no_follow: false,
|
|
command: None,
|
|
};
|
|
let shell = Shell::new(cli).unwrap();
|
|
assert!(shell.headers);
|
|
let result = shell.run_statement(" ; ; ;".to_string(), [], false);
|
|
assert!(result.is_ok());
|
|
let table = result.unwrap();
|
|
assert_eq!(table.count_rows(), 1);
|
|
}
|
|
}
|