diff --git a/Cargo.lock b/Cargo.lock index cf5a2aca..8bf9cdb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,7 +306,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" dependencies = [ "borsh-derive", - "cfg_aliases", + "cfg_aliases 0.2.1", ] [[package]] @@ -413,6 +413,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -511,6 +517,15 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmake" version = "0.1.54" @@ -533,7 +548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" dependencies = [ "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.1", ] [[package]] @@ -545,7 +560,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.1", "windows-sys 0.59.0", ] @@ -849,6 +864,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "fallible-iterator" version = "0.2.0" @@ -1160,7 +1181,7 @@ dependencies = [ "console", "number_prefix", "portable-atomic", - "unicode-width", + "unicode-width 0.2.1", "web-time", ] @@ -1344,6 +1365,7 @@ dependencies = [ "regex", "rocksdb", "rust_decimal", + "rustyline", "serde", "serde-wasm-bindgen", "siphasher", @@ -1623,6 +1645,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -1639,7 +1673,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -1900,7 +1934,7 @@ dependencies = [ "inferno", "libc", "log", - "nix", + "nix 0.26.4", "once_cell", "smallvec", "spin", @@ -2387,6 +2421,25 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +[[package]] +name = "rustyline" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "clipboard-win", + "libc", + "log", + "memchr", + "nix 0.28.0", + "unicode-segmentation", + "unicode-width 0.1.14", + "utf8parse", + "windows-sys 0.52.0", +] + [[package]] name = "ryu" version = "1.0.20" @@ -3010,6 +3063,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 682946f6..0f17570d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,11 @@ name = "kite_sql" path = "src/bin/server.rs" required-features = ["net", "rocksdb"] +[[bin]] +name = "kitesql-shell" +path = "src/bin/shell.rs" +required-features = ["rocksdb"] + [lib] doctest = false crate-type = ["cdylib", "rlib"] @@ -91,6 +96,7 @@ rocksdb = { version = "0.23", optional = true } librocksdb-sys = { version = "0.17.1", optional = true } lmdb = { version = "0.8.0", optional = true } lmdb-sys = { version = "0.8.0", optional = true } +rustyline = { version = "14", default-features = false } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = { version = "0.2.106" } diff --git a/README.md b/README.md index a8bf0644..e196f540 100755 --- a/README.md +++ b/README.md @@ -145,6 +145,13 @@ fn main() -> Result<(), DatabaseError> { On native targets, `LMDB` shines when reads dominate, while `RocksDB` is usually the stronger choice when writes do. Checkpoint support and feature-gating details are documented in [docs/features.md](docs/features.md). +## Shell +- Run `cargo run --bin kitesql-shell` to open the local interactive shell. +- Use `cargo run --bin kitesql-shell -- --path ./tmp/kitesql-shell-data` to point to a custom RocksDB directory. +- Use `cargo run --bin kitesql-shell -- -e "select current_timestamp"` for a quick one-shot check. +- In interactive mode, end SQL statements with `;`; an empty line also executes the buffered statement. +- Supported metacommands include `.help`, `.quit`, `.tables`, `.views`, and `.schema `. + 👉**more examples** - [hello_world](examples/hello_world.rs) - [transaction](examples/transaction.rs) diff --git a/docs/features.md b/docs/features.md index b832269f..86c8df79 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,4 +1,34 @@ ## Features +### Shell + +Run the local interactive shell with: + +```bash +cargo run --bin kitesql-shell +``` + +Use a custom data directory: + +```bash +cargo run --bin kitesql-shell -- --path ./tmp/kitesql-shell-data +``` + +Run a one-shot SQL check: + +```bash +cargo run --bin kitesql-shell -- -e "select current_timestamp" +``` + +In interactive mode, end SQL statements with `;`; an empty line also executes the buffered statement. + +Built-in metacommands: + +- `.help` +- `.quit` +- `.tables` +- `.views` +- `.schema ` + ### PG Wire: run `cargo run --features="net"` to start service diff --git a/src/bin/shell.rs b/src/bin/shell.rs new file mode 100644 index 00000000..10b58f8a --- /dev/null +++ b/src/bin/shell.rs @@ -0,0 +1,560 @@ +// Copyright 2024 KipData/KiteSQL +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use comfy_table::{Cell, Table}; + use kite_sql::db::{BorrowResultIter, DBTransaction, DataBaseBuilder, Database}; + use kite_sql::errors::DatabaseError; + use kite_sql::storage::rocksdb::RocksStorage; + use rustyline::config::Configurer; + use rustyline::error::ReadlineError; + use rustyline::{Config, DefaultEditor}; + use std::env; + use std::io::{self, IsTerminal}; + use std::process::ExitCode; + use std::time::Instant; + + const DEFAULT_PATH: &str = "./kitesql_data"; + + const HELP: &str = "\ +kitesql-shell + +Usage: + kitesql-shell [--path PATH] [-e SQL] + +Options: + --path PATH RocksDB data directory (default: ./kitesql_data) + -e, --execute Execute one SQL statement or one metacommand and exit + -h, --help Show this help + +Metacommands: + .help Show help + .quit Exit shell + .tables Show tables + .views Show views + .schema NAME Describe a table or view + +Transaction commands: + BEGIN + COMMIT + ROLLBACK +"; + + #[derive(Debug, PartialEq, Eq)] + enum Mode { + Continue, + Exit, + } + + #[derive(Debug)] + struct Args { + path: String, + execute: Option, + } + + fn parse_args() -> Result { + parse_args_from(env::args().skip(1)) + } + + fn parse_args_from(args: I) -> Result + where + I: IntoIterator, + S: Into, + { + let mut path = DEFAULT_PATH.to_string(); + let mut execute = None; + let mut args = args.into_iter().map(Into::into); + + while let Some(arg) = args.next() { + match arg.as_str() { + "--path" => { + path = args + .next() + .ok_or_else(|| "--path expects a value".to_string())?; + } + "-e" | "--execute" => { + execute = Some( + args.next() + .ok_or_else(|| "--execute expects a value".to_string())?, + ); + } + "-h" | "--help" => { + println!("{HELP}"); + return Err(String::new()); + } + other => { + return Err(format!("unknown argument: {other}\n\n{HELP}")); + } + } + } + + Ok(Args { path, execute }) + } + + fn prompt(is_tx: bool, continuation: bool) -> &'static str { + match (is_tx, continuation) { + (false, false) => "kite> ", + (true, false) => "kite(tx)> ", + (false, true) => " ...> ", + (true, true) => " ...(tx)> ", + } + } + + fn should_execute_line(trimmed_line: &str) -> bool { + trimmed_line.is_empty() || trimmed_line.ends_with(';') + } + + enum Input { + Interactive(Box), + Plain { stdin: io::Stdin, line: String }, + } + + impl Input { + fn new(interactive: bool) -> Result { + if interactive { + let config = Config::builder() + .history_ignore_dups(true) + .map_err(readline_error)? + .build(); + let mut editor = DefaultEditor::with_config(config).map_err(readline_error)?; + editor.set_auto_add_history(false); + return Ok(Self::Interactive(Box::new(editor))); + } + + Ok(Self::Plain { + stdin: io::stdin(), + line: String::new(), + }) + } + + fn read_line(&mut self, prompt: &str) -> Result, DatabaseError> { + match self { + Self::Interactive(editor) => match editor.readline(prompt) { + Ok(line) => { + if !line.trim().is_empty() { + editor + .add_history_entry(line.as_str()) + .map_err(readline_error)?; + } + Ok(Some(format!("{line}\n"))) + } + Err(ReadlineError::Interrupted | ReadlineError::Eof) => Ok(None), + Err(err) => Err(readline_error(err)), + }, + Self::Plain { stdin, line } => { + line.clear(); + let bytes = stdin.read_line(line).map_err(DatabaseError::from)?; + if bytes == 0 { + Ok(None) + } else { + Ok(Some(line.clone())) + } + } + } + } + } + + fn readline_error(err: impl std::fmt::Display) -> DatabaseError { + DatabaseError::IO(io::Error::other(err.to_string())) + } + + fn print_table(mut iter: I) -> Result<(), DatabaseError> + where + I: BorrowResultIter, + { + let mut table = Table::new(); + let schema = iter.schema().clone(); + + if !schema.is_empty() { + let header = schema + .iter() + .map(|column| Cell::new(column.full_name())) + .collect::>(); + table.set_header(header); + } + + let mut row_count = 0usize; + while let Some(tuple) = iter.next_borrowed_tuple()? { + row_count += 1; + let row = tuple + .values + .iter() + .map(|value| Cell::new(format!("{value}"))) + .collect::>(); + table.add_row(row); + } + iter.done()?; + + if schema.is_empty() { + println!("OK"); + } else if row_count == 0 { + println!("{table}"); + println!("0 rows"); + } else { + println!("{table}"); + println!("{row_count} row{}", if row_count == 1 { "" } else { "s" }); + } + + Ok(()) + } + + fn run_sql<'a>( + database: &Database, + tx: &mut Option>, + sql: &str, + ) -> Result<(), DatabaseError> { + let started = Instant::now(); + if let Some(tx) = tx.as_mut() { + print_table(tx.run(sql)?)?; + } else { + print_table(database.run(sql)?)?; + } + eprintln!("elapsed: {:.2?}", started.elapsed()); + Ok(()) + } + + fn handle_meta_command<'a>( + command: &str, + database: &Database, + tx: &mut Option>, + ) -> Result { + let trimmed = command.trim(); + if trimmed.is_empty() { + return Ok(Mode::Continue); + } + + if trimmed.eq_ignore_ascii_case(".quit") || trimmed.eq_ignore_ascii_case(".exit") { + return Ok(Mode::Exit); + } + + if trimmed.eq_ignore_ascii_case(".help") { + println!("{HELP}"); + return Ok(Mode::Continue); + } + + if trimmed.eq_ignore_ascii_case(".tables") { + run_sql(database, tx, "show tables")?; + return Ok(Mode::Continue); + } + + if trimmed.eq_ignore_ascii_case(".views") { + run_sql(database, tx, "show views")?; + return Ok(Mode::Continue); + } + + if let Some(name) = trimmed.strip_prefix(".schema ") { + let name = name.trim(); + if name.is_empty() { + eprintln!(".schema expects a table or view name"); + } else { + run_sql(database, tx, &format!("describe {name}"))?; + } + return Ok(Mode::Continue); + } + + eprintln!("unknown metacommand: {trimmed}"); + Ok(Mode::Continue) + } + + fn handle_command<'a>( + command: &str, + database: &'a Database, + tx: &mut Option>, + ) -> Result { + let trimmed = command.trim(); + if trimmed.is_empty() { + return Ok(Mode::Continue); + } + + if trimmed.starts_with('.') { + return handle_meta_command(trimmed, database, tx); + } + + let normalized = trimmed.trim_end_matches(';').trim(); + + if normalized.eq_ignore_ascii_case("begin") + || normalized.eq_ignore_ascii_case("start transaction") + { + if tx.is_some() { + eprintln!("transaction already started"); + } else { + *tx = Some(database.new_transaction()?); + println!("OK"); + } + return Ok(Mode::Continue); + } + + if normalized.eq_ignore_ascii_case("commit") + || normalized.eq_ignore_ascii_case("commit work") + { + if let Some(transaction) = tx.take() { + transaction.commit()?; + println!("OK"); + } else { + eprintln!("no active transaction"); + } + return Ok(Mode::Continue); + } + + if normalized.eq_ignore_ascii_case("rollback") { + if tx.take().is_some() { + println!("OK"); + } else { + eprintln!("no active transaction"); + } + return Ok(Mode::Continue); + } + + run_sql(database, tx, trimmed)?; + Ok(Mode::Continue) + } + + fn repl(database: &Database, path: &str) -> Result<(), DatabaseError> { + let interactive = io::stdin().is_terminal() && io::stdout().is_terminal(); + let mut input = Input::new(interactive)?; + let mut tx = None; + let mut buffer = String::new(); + + if interactive { + println!("KiteSQL shell"); + println!("data path: {path}"); + println!("type .help for help, .quit to exit"); + } + + loop { + let Some(line) = input.read_line(prompt(tx.is_some(), !buffer.trim().is_empty()))? + else { + break; + }; + + let trimmed = line.trim(); + if buffer.is_empty() && trimmed.starts_with('.') { + match handle_command(trimmed, database, &mut tx) { + Ok(Mode::Continue) => {} + Ok(Mode::Exit) => break, + Err(err) => eprintln!("{err}"), + } + continue; + } + + buffer.push_str(&line); + + let should_execute = should_execute_line(trimmed); + if !should_execute { + continue; + } + + let command = buffer.trim(); + if command.is_empty() { + buffer.clear(); + continue; + } + + match handle_command(command, database, &mut tx) { + Ok(Mode::Continue) => {} + Ok(Mode::Exit) => break, + Err(err) => eprintln!("{err}"), + } + buffer.clear(); + } + + Ok(()) + } + + pub(super) fn run() -> ExitCode { + let args = match parse_args() { + Ok(args) => args, + Err(err) if err.is_empty() => return ExitCode::SUCCESS, + Err(err) => { + eprintln!("{err}"); + return ExitCode::from(2); + } + }; + + let database = match DataBaseBuilder::path(&args.path).build_rocksdb() { + Ok(database) => database, + Err(err) => { + eprintln!("{err}"); + return ExitCode::FAILURE; + } + }; + + if let Some(sql) = args.execute.as_deref() { + let mut tx = None; + return match handle_command(sql, &database, &mut tx) { + Ok(_) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("{err}"); + ExitCode::FAILURE + } + }; + } + + match repl(&database, &args.path) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("{err}"); + ExitCode::FAILURE + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + use tempfile::TempDir; + + fn test_database() -> (TempDir, Database) { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let database = DataBaseBuilder::path(temp_dir.path()) + .build_rocksdb() + .expect("failed to create test database"); + (temp_dir, database) + } + + #[test] + fn parse_args_uses_defaults() { + let args = parse_args_from(Vec::::new()).expect("args should parse"); + + assert_eq!(args.path, DEFAULT_PATH); + assert_eq!(args.execute, None); + } + + #[test] + fn parse_args_accepts_path_and_execute_sql() { + let args = parse_args_from(["--path", "/tmp/kite", "-e", "select 1;"]) + .expect("args should parse"); + + assert_eq!(args.path, "/tmp/kite"); + assert_eq!(args.execute.as_deref(), Some("select 1;")); + } + + #[test] + fn parse_args_rejects_missing_values_and_unknown_flags() { + assert_eq!( + parse_args_from(["--path"]).expect_err("--path should require a value"), + "--path expects a value" + ); + assert_eq!( + parse_args_from(["--execute"]).expect_err("--execute should require a value"), + "--execute expects a value" + ); + assert!(parse_args_from(["--unknown"]) + .expect_err("unknown flags should be rejected") + .starts_with("unknown argument: --unknown")); + } + + #[test] + fn prompt_reflects_transaction_and_continuation_state() { + assert_eq!(prompt(false, false), "kite> "); + assert_eq!(prompt(true, false), "kite(tx)> "); + assert_eq!(prompt(false, true), " ...> "); + assert_eq!(prompt(true, true), " ...(tx)> "); + } + + #[test] + fn should_execute_line_matches_repl_completion_rules() { + assert!(should_execute_line("")); + assert!(should_execute_line("select 1;")); + assert!(should_execute_line(" ;")); + assert!(!should_execute_line("select 1")); + assert!(!should_execute_line("select 1; -- comment")); + } + + #[test] + fn meta_command_exit_aliases_stop_the_shell() { + let (_temp_dir, database) = test_database(); + let mut tx = None; + + assert_eq!( + handle_meta_command(".quit", &database, &mut tx).expect(".quit should parse"), + Mode::Exit + ); + assert_eq!( + handle_meta_command(".exit", &database, &mut tx).expect(".exit should parse"), + Mode::Exit + ); + } + + #[test] + fn transaction_commands_update_shell_transaction_state() { + let (_temp_dir, database) = test_database(); + let mut tx = None; + + assert_eq!( + handle_command("BEGIN", &database, &mut tx).expect("begin should succeed"), + Mode::Continue + ); + assert!(tx.is_some()); + + assert_eq!( + handle_command("COMMIT", &database, &mut tx).expect("commit should succeed"), + Mode::Continue + ); + assert!(tx.is_none()); + + assert_eq!( + handle_command("start transaction", &database, &mut tx) + .expect("start transaction should succeed"), + Mode::Continue + ); + assert!(tx.is_some()); + + assert_eq!( + handle_command("ROLLBACK", &database, &mut tx).expect("rollback should succeed"), + Mode::Continue + ); + assert!(tx.is_none()); + } + + #[test] + fn sql_and_schema_metacommands_run_against_database() { + let (_temp_dir, database) = test_database(); + let mut tx = None; + + handle_command( + "create table users (id int primary key, name varchar);", + &database, + &mut tx, + ) + .expect("create table should succeed"); + handle_command("insert into users values (1, 'alice');", &database, &mut tx) + .expect("insert should succeed"); + + assert_eq!( + handle_meta_command(".tables", &database, &mut tx).expect(".tables should succeed"), + Mode::Continue + ); + assert_eq!( + handle_meta_command(".schema users", &database, &mut tx) + .expect(".schema should succeed"), + Mode::Continue + ); + assert_eq!( + handle_command("select * from users;", &database, &mut tx) + .expect("select should succeed"), + Mode::Continue + ); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn main() -> std::process::ExitCode { + native::run() +} + +#[cfg(target_arch = "wasm32")] +fn main() {}