From c64ed0f642c37321d6da158dd75737998f015d3b Mon Sep 17 00:00:00 2001 From: kould Date: Thu, 30 Apr 2026 11:13:14 +0800 Subject: [PATCH 1/3] feat(shell): add interactive line editing history --- Cargo.lock | 71 ++++++++- Cargo.toml | 6 + README.md | 7 + docs/features.md | 30 ++++ src/bin/shell.rs | 393 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 501 insertions(+), 6 deletions(-) create mode 100644 src/bin/shell.rs 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..13ddb132 --- /dev/null +++ b/src/bin/shell.rs @@ -0,0 +1,393 @@ +// 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. + +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 +"; + +enum Mode { + Continue, + Exit, +} + +struct Args { + path: String, + execute: Option, +} + +fn parse_args() -> Result { + let mut path = DEFAULT_PATH.to_string(); + let mut execute = None; + let mut args = env::args().skip(1); + + 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)> ", + } +} + +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 = trimmed.is_empty() || trimmed.ends_with(';'); + 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(()) +} + +fn main() -> 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 + } + } +} From 8145927a77032f9a9544d3b95663f7587ed3ce37 Mon Sep 17 00:00:00 2001 From: kould Date: Sun, 3 May 2026 16:32:16 +0800 Subject: [PATCH 2/3] fix(shell): skip native shell on wasm target --- src/bin/shell.rs | 590 ++++++++++++++++++++++++----------------------- 1 file changed, 302 insertions(+), 288 deletions(-) diff --git a/src/bin/shell.rs b/src/bin/shell.rs index 13ddb132..3bfc6967 100644 --- a/src/bin/shell.rs +++ b/src/bin/shell.rs @@ -12,21 +12,23 @@ // See the License for the specific language governing permissions and // limitations under the License. -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 = "\ +#[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: @@ -50,344 +52,356 @@ Transaction commands: ROLLBACK "; -enum Mode { - Continue, - Exit, -} + enum Mode { + Continue, + Exit, + } -struct Args { - path: String, - execute: Option, -} + struct Args { + path: String, + execute: Option, + } -fn parse_args() -> Result { - let mut path = DEFAULT_PATH.to_string(); - let mut execute = None; - let mut args = env::args().skip(1); - - 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}")); + fn parse_args() -> Result { + let mut path = DEFAULT_PATH.to_string(); + let mut execute = None; + let mut args = env::args().skip(1); + + 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 }) } - 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 prompt(is_tx: bool, continuation: bool) -> &'static str { - match (is_tx, continuation) { - (false, false) => "kite> ", - (true, false) => "kite(tx)> ", - (false, true) => " ...> ", - (true, true) => " ...(tx)> ", + enum Input { + Interactive(Box), + Plain { stdin: io::Stdin, line: String }, } -} -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))); + } -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(), + }) } - 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)?; + 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())) } - 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); + fn readline_error(err: impl std::fmt::Display) -> DatabaseError { + DatabaseError::IO(io::Error::other(err.to_string())) } - 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" }); - } + 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); + } - Ok(()) -} + 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()?; -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(()) -} + 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" }); + } -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); + Ok(()) } - if trimmed.eq_ignore_ascii_case(".quit") || trimmed.eq_ignore_ascii_case(".exit") { - return Ok(Mode::Exit); + 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(()) } - if trimmed.eq_ignore_ascii_case(".help") { - println!("{HELP}"); - return Ok(Mode::Continue); - } + 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(".tables") { - run_sql(database, tx, "show tables")?; - 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(".views") { - run_sql(database, tx, "show views")?; - return Ok(Mode::Continue); - } + if trimmed.eq_ignore_ascii_case(".help") { + println!("{HELP}"); + 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}"))?; + if trimmed.eq_ignore_ascii_case(".tables") { + run_sql(database, tx, "show tables")?; + return Ok(Mode::Continue); } - return Ok(Mode::Continue); - } - eprintln!("unknown metacommand: {trimmed}"); - Ok(Mode::Continue) -} + if trimmed.eq_ignore_ascii_case(".views") { + run_sql(database, tx, "show views")?; + return 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 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); + } - if trimmed.starts_with('.') { - return handle_meta_command(trimmed, database, tx); + eprintln!("unknown metacommand: {trimmed}"); + Ok(Mode::Continue) } - let normalized = trimmed.trim_end_matches(';').trim(); + 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 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"); + if trimmed.starts_with('.') { + return handle_meta_command(trimmed, database, tx); } - 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"); + 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); } - return Ok(Mode::Continue); - } - if normalized.eq_ignore_ascii_case("rollback") { - if tx.take().is_some() { - println!("OK"); - } else { - eprintln!("no active transaction"); + 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); } - return Ok(Mode::Continue); + + run_sql(database, tx, trimmed)?; + 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(); -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"); + } - 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; + } - loop { - let Some(line) = input.read_line(prompt(tx.is_some(), !buffer.trim().is_empty()))? else { - break; - }; + buffer.push_str(&line); + + let should_execute = trimmed.is_empty() || trimmed.ends_with(';'); + if !should_execute { + continue; + } - let trimmed = line.trim(); - if buffer.is_empty() && trimmed.starts_with('.') { - match handle_command(trimmed, database, &mut tx) { + 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}"), } - continue; - } - - buffer.push_str(&line); - - let should_execute = trimmed.is_empty() || trimmed.ends_with(';'); - 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(()) } - 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); + } + }; -fn main() -> 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; + } + }; - 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 + } + }; } - }; - if let Some(sql) = args.execute.as_deref() { - let mut tx = None; - return match handle_command(sql, &database, &mut tx) { - Ok(_) => ExitCode::SUCCESS, + match repl(&database, &args.path) { + Ok(()) => ExitCode::SUCCESS, Err(err) => { eprintln!("{err}"); ExitCode::FAILURE } - }; - } - - match repl(&database, &args.path) { - Ok(()) => ExitCode::SUCCESS, - Err(err) => { - eprintln!("{err}"); - ExitCode::FAILURE } } } + +#[cfg(not(target_arch = "wasm32"))] +fn main() -> std::process::ExitCode { + native::run() +} + +#[cfg(target_arch = "wasm32")] +fn main() {} From c451380633112a0fde46caa0236998fd4c94143e Mon Sep 17 00:00:00 2001 From: kould Date: Sun, 3 May 2026 16:35:32 +0800 Subject: [PATCH 3/3] test(shell): cover interactive command handling --- src/bin/shell.rs | 157 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 155 insertions(+), 2 deletions(-) diff --git a/src/bin/shell.rs b/src/bin/shell.rs index 3bfc6967..10b58f8a 100644 --- a/src/bin/shell.rs +++ b/src/bin/shell.rs @@ -52,20 +52,30 @@ Transaction commands: 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 = env::args().skip(1); + let mut args = args.into_iter().map(Into::into); while let Some(arg) = args.next() { match arg.as_str() { @@ -102,6 +112,10 @@ Transaction commands: } } + 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 }, @@ -337,7 +351,7 @@ Transaction commands: buffer.push_str(&line); - let should_execute = trimmed.is_empty() || trimmed.ends_with(';'); + let should_execute = should_execute_line(trimmed); if !should_execute { continue; } @@ -396,6 +410,145 @@ Transaction commands: } } } + + #[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"))]