use graphitesql::{Connection, QueryResult, Value};
use std::fs::File;
use std::io::{self, BufRead, BufReader, IsTerminal, Write};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mode {
List,
Csv,
Column,
Line,
Quote,
Insert,
Json,
Markdown,
Box,
Table,
Html,
Tcl,
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let (path, scripts) = match args.split_first() {
None => (String::from(":memory:"), &[][..]),
Some((db, rest)) => (db.clone(), rest),
};
let mut conn = match open(&path) {
Ok(c) => c,
Err(e) => {
eprintln!("Error: unable to open {path:?}: {e}");
std::process::exit(1);
}
};
let mut shell = Shell::new();
shell.filename = path.clone();
if !scripts.is_empty() {
for sql in scripts {
if sql.trim_start().starts_with('.') {
if shell.dot_command(&mut conn, sql.trim()) {
return; }
continue;
}
if let Err((e, stmt, _line)) = shell.run_sql_batch(&mut conn, sql, 1) {
eprintln!("{}", render_cli_error(&stmt, &e));
std::process::exit(1);
}
}
if shell.had_error {
std::process::exit(1);
}
return;
}
shell.repl(&mut conn, &path);
}
fn open(path: &str) -> graphitesql::Result<Connection> {
if path.is_empty() || path == ":memory:" {
Connection::open_memory()
} else if std::path::Path::new(path).exists() {
Connection::open(path)
} else {
Connection::create(path)
}
}
enum Sink {
Stdout,
File(File),
}
impl Write for Sink {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self {
Sink::Stdout => io::stdout().write(buf),
Sink::File(f) => f.write(buf),
}
}
fn flush(&mut self) -> io::Result<()> {
match self {
Sink::Stdout => io::stdout().flush(),
Sink::File(f) => f.flush(),
}
}
}
struct Shell {
headers: bool,
header_set: bool,
mode: Mode,
col_sep: String,
row_sep: String,
null_value: String,
insert_table: String,
echo: bool,
count_changes: bool,
bail: bool,
had_error: bool,
total_changes: u64,
last_changes: u64,
out: Sink,
once: bool,
mode_name: String,
out_name: String,
filename: String,
}
impl Shell {
fn new() -> Self {
Shell {
headers: false,
header_set: false,
mode: Mode::List,
col_sep: String::from("|"),
row_sep: String::from("\n"),
null_value: String::new(),
insert_table: String::from("table"),
echo: false,
count_changes: false,
bail: false,
had_error: false,
total_changes: 0,
last_changes: 0,
out: Sink::Stdout,
once: false,
mode_name: String::from("list"),
out_name: String::from("stdout"),
filename: String::from(":memory:"),
}
}
fn repl(&mut self, conn: &mut Connection, path: &str) {
let interactive = io::stdin().is_terminal();
if interactive {
eprintln!("graphitesql shell — connected to {path}");
eprintln!(
"Enter SQL statements ending in ';'. \".help\" for commands, \".quit\" to exit."
);
}
let stdin = io::stdin();
let mut buffer = String::new();
let mut input_line = 0usize;
let mut group_start_line = 1usize;
loop {
if interactive {
let prompt = if buffer.is_empty() {
"graphitesql> "
} else {
" ...> "
};
print!("{prompt}");
let _ = io::stdout().flush();
}
let mut line = String::new();
match stdin.lock().read_line(&mut line) {
Ok(0) => break, Ok(_) => {}
Err(e) => {
eprintln!("Error reading input: {e}");
break;
}
}
input_line += 1;
let trimmed = line.trim();
if buffer.is_empty() && trimmed.starts_with('.') {
if self.echo {
let _ = writeln!(io::stdout(), "{trimmed}");
}
if self.dot_command(conn, trimmed) {
break;
}
continue;
}
if buffer.is_empty() {
group_start_line = input_line;
}
buffer.push_str(&line);
if input_is_complete(&buffer) {
let sql = std::mem::take(&mut buffer);
self.run_group(conn, &sql, group_start_line);
}
}
if has_sql_content(&buffer) {
self.run_group(conn, &buffer, group_start_line);
}
if !interactive && self.had_error {
std::process::exit(1);
}
}
fn feed_reader(&mut self, conn: &mut Connection, reader: &mut impl BufRead) -> bool {
let mut buffer = String::new();
let mut input_line = 0usize;
let mut group_start_line = 1usize;
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {}
Err(e) => {
eprintln!("Error reading input: {e}");
break;
}
}
input_line += 1;
let trimmed = line.trim();
if buffer.is_empty() && trimmed.starts_with('.') {
if self.echo {
let _ = writeln!(io::stdout(), "{trimmed}");
}
if self.dot_command(conn, trimmed) {
return true;
}
continue;
}
if buffer.is_empty() {
group_start_line = input_line;
}
buffer.push_str(&line);
if input_is_complete(&buffer) {
let sql = std::mem::take(&mut buffer);
self.run_group(conn, &sql, group_start_line);
}
}
if has_sql_content(&buffer) {
self.run_group(conn, &buffer, group_start_line);
}
false
}
fn run_group(&mut self, conn: &mut Connection, sql: &str, start_line: usize) {
if self.echo {
let mut out = io::stdout();
let _ = writeln!(out, "{}", sql.trim_end_matches('\n'));
}
if let Err((e, stmt, line)) = self.run_sql_batch(conn, sql, start_line) {
eprintln!("{}", render_script_error(&stmt, &e, line));
self.had_error = true;
if self.bail {
std::process::exit(1);
}
}
if self.count_changes {
let mut out = io::stdout();
let _ = writeln!(
out,
"changes: {} total_changes: {}",
self.last_changes, self.total_changes
);
}
if self.once {
self.out = Sink::Stdout;
self.once = false;
}
}
#[allow(clippy::result_large_err)]
fn run_sql_batch(
&mut self,
conn: &mut Connection,
sql: &str,
start_line: usize,
) -> Result<(), (graphitesql::Error, String, usize)> {
let mut search_from = 0usize;
for stmt_raw in split_statements(sql) {
let stmt = stmt_raw.trim();
if !has_sql_content(stmt) {
continue;
}
let off = sql[search_from..]
.find(stmt)
.map(|p| search_from + p)
.unwrap_or(search_from);
search_from = off + stmt.len();
let line = start_line + sql[..off].bytes().filter(|&b| b == b'\n').count();
if let Err(e) = self.run_one(conn, stmt) {
return Err((e, stmt.to_string(), line));
}
}
Ok(())
}
fn run_one(&mut self, conn: &mut Connection, sql: &str) -> graphitesql::Result<()> {
if returns_rows(sql) && !is_pragma_setter(sql) {
match conn.query(sql) {
Ok(result) if is_explain_query_plan(sql) => self.print_eqp_tree(&result),
Ok(result) => self.print_result(&result),
Err(graphitesql::Error::Unsupported(m)) if m.contains("use execute()") => {
if has_returning(sql) {
let result = conn
.execute_returning(sql, &graphitesql::exec::eval::Params::default())?;
self.print_result(&result);
} else {
self.record_changes(conn.execute(sql)?);
}
}
Err(e) => return Err(e),
}
} else if has_returning(sql) {
let result =
conn.execute_returning(sql, &graphitesql::exec::eval::Params::default())?;
self.record_changes(result.rows.len());
self.print_result(&result);
} else {
match conn.execute(sql) {
Ok(n) => {
if is_dml(sql) {
self.record_changes(n);
}
if let Some(getter) = pragma_setter_result_query(sql)
&& let Ok(result) = conn.query(&getter)
{
self.print_result(&result);
}
}
Err(graphitesql::Error::Unsupported(m)) if m.contains("use query()") => {
let result = conn.query(sql)?;
self.print_result(&result);
}
Err(e) => return Err(e),
}
}
Ok(())
}
fn record_changes(&mut self, n: usize) {
self.last_changes = n as u64;
self.total_changes += n as u64;
}
fn print_result(&mut self, result: &QueryResult) {
if result.rows.is_empty() {
return;
}
match self.mode {
Mode::List => self.print_list(result),
Mode::Csv => self.print_csv(result),
Mode::Column => self.print_column(result),
Mode::Line => self.print_line(result),
Mode::Quote => self.print_quote(result),
Mode::Insert => self.print_insert(result),
Mode::Json => self.print_json(result),
Mode::Markdown => self.print_markdown(result),
Mode::Box => self.print_boxed(result, BOX_CHARS),
Mode::Table => self.print_boxed(result, TABLE_CHARS),
Mode::Html => self.print_html(result),
Mode::Tcl => self.print_tcl(result),
}
}
fn print_tcl(&mut self, result: &QueryResult) {
if result.rows.is_empty() || result.columns.is_empty() {
return;
}
let ncol = result.columns.len();
let col_sep = self.col_sep.clone();
let row_sep = self.row_sep.clone();
let mut out: Vec<u8> = Vec::new();
if self.headers {
for (i, c) in result.columns.iter().enumerate() {
if i > 0 {
out.extend_from_slice(col_sep.as_bytes());
}
tcl_string(c.as_bytes(), &mut out);
}
out.extend_from_slice(row_sep.as_bytes());
}
for r in &result.rows {
for i in 0..ncol {
if i > 0 {
out.extend_from_slice(col_sep.as_bytes());
}
let mut bytes: Vec<u8> = Vec::new();
match r.get(i) {
Some(Value::Null) | None => bytes.extend_from_slice(self.null_value.as_bytes()),
Some(v) => render_text_cell(v, &mut bytes),
}
tcl_string(&bytes, &mut out);
}
out.extend_from_slice(row_sep.as_bytes());
}
let _ = self.out.write_all(&out);
}
fn print_html(&mut self, result: &QueryResult) {
if result.rows.is_empty() || result.columns.is_empty() {
return;
}
let mut out = String::new();
let row = |out: &mut String, cells: &[String], tag: &str| {
out.push_str("<TR>");
for (i, c) in cells.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push('<');
out.push_str(tag);
out.push('>');
html_escape(c, out);
out.push_str("</");
out.push_str(tag);
out.push('>');
}
out.push_str("\n</TR>\n");
};
if self.headers {
row(&mut out, &result.columns, "TH");
}
let ncol = result.columns.len();
for r in &result.rows {
let cells: Vec<String> = (0..ncol)
.map(|i| match r.get(i) {
Some(Value::Null) | None => self.null_value.clone(),
Some(v) => display_cell(v),
})
.collect();
row(&mut out, &cells, "TD");
}
let _ = self.out.write_all(out.as_bytes());
}
fn boxed_cells(&self, result: &QueryResult) -> (Vec<Vec<String>>, Vec<usize>) {
let ncol = result.columns.len();
let cells: Vec<Vec<String>> = result
.rows
.iter()
.map(|row| {
(0..ncol)
.map(|i| match row.get(i) {
Some(Value::Null) | None => self.null_value.clone(),
Some(v) => escape_display_str(&display_cell(v)),
})
.collect()
})
.collect();
let mut width: Vec<usize> = result.columns.iter().map(|c| c.chars().count()).collect();
for row in &cells {
for (i, c) in row.iter().enumerate() {
width[i] = width[i].max(c.chars().count());
}
}
(cells, width)
}
fn print_markdown(&mut self, result: &QueryResult) {
if result.rows.is_empty() || result.columns.is_empty() {
return;
}
let (cells, width) = self.boxed_cells(result);
let mut out = String::new();
let row = |out: &mut String, cols: &[String], center: bool| {
out.push('|');
for (i, c) in cols.iter().enumerate() {
out.push(' ');
if center {
pad_center(c, width[i], out);
} else {
pad_str(c, width[i], out);
}
out.push_str(" |");
}
out.push('\n');
};
row(&mut out, &result.columns, true);
out.push('|');
for &w in &width {
for _ in 0..w + 2 {
out.push('-');
}
out.push('|');
}
out.push('\n');
for r in &cells {
row(&mut out, r, false);
}
let _ = self.out.write_all(out.as_bytes());
}
fn print_boxed(&mut self, result: &QueryResult, c: BoxChars) {
if result.rows.is_empty() || result.columns.is_empty() {
return;
}
let (cells, width) = self.boxed_cells(result);
let mut out = String::new();
let border = |out: &mut String, l: char, mid: char, r: char| {
out.push(l);
for (i, &w) in width.iter().enumerate() {
for _ in 0..w + 2 {
out.push(c.horiz);
}
out.push(if i == width.len() - 1 { r } else { mid });
}
out.push('\n');
};
let data_row = |out: &mut String, cols: &[String], center: bool| {
out.push(c.vert);
for (i, cell) in cols.iter().enumerate() {
out.push(' ');
if center {
pad_center(cell, width[i], out);
} else {
pad_str(cell, width[i], out);
}
out.push(' ');
out.push(c.vert);
}
out.push('\n');
};
border(&mut out, c.tl, c.tm, c.tr);
data_row(&mut out, &result.columns, true);
border(&mut out, c.ml, c.mm, c.mr);
for row in &cells {
data_row(&mut out, row, false);
}
border(&mut out, c.bl, c.bm, c.br);
let _ = self.out.write_all(out.as_bytes());
}
fn print_list(&mut self, result: &QueryResult) {
let escape = self.mode_name != "ascii";
let mut line: Vec<u8> = Vec::new();
let emit = |line: &mut Vec<u8>, raw: &[u8]| {
if escape {
push_display_escaped(raw, line);
} else {
line.extend_from_slice(raw);
}
};
if self.headers {
for (i, c) in result.columns.iter().enumerate() {
if i > 0 {
line.extend_from_slice(self.col_sep.as_bytes());
}
emit(&mut line, c.as_bytes());
}
line.extend_from_slice(self.row_sep.as_bytes());
}
for row in &result.rows {
for (i, v) in row.iter().enumerate() {
if i > 0 {
line.extend_from_slice(self.col_sep.as_bytes());
}
let mut cell = Vec::new();
render_list_cell(v, &self.null_value, &mut cell);
emit(&mut line, &cell);
}
line.extend_from_slice(self.row_sep.as_bytes());
}
let _ = self.out.write_all(&line);
}
fn print_csv(&mut self, result: &QueryResult) {
let mut buf: Vec<u8> = Vec::new();
if self.headers {
for (i, c) in result.columns.iter().enumerate() {
if i > 0 {
buf.extend_from_slice(self.col_sep.as_bytes());
}
csv_field(c.as_bytes(), &self.col_sep, &mut buf);
}
buf.extend_from_slice(self.row_sep.as_bytes());
}
for row in &result.rows {
for (i, v) in row.iter().enumerate() {
if i > 0 {
buf.extend_from_slice(self.col_sep.as_bytes());
}
match v {
Value::Null => buf.extend_from_slice(self.null_value.as_bytes()),
_ => {
let mut cell = Vec::new();
render_text_cell(v, &mut cell);
csv_field(&cell, &self.col_sep, &mut buf);
}
}
}
buf.extend_from_slice(self.row_sep.as_bytes());
}
let _ = self.out.write_all(&buf);
}
fn print_column(&mut self, result: &QueryResult) {
let ncol = result.columns.len();
if ncol == 0 {
return;
}
let cells: Vec<Vec<String>> = result
.rows
.iter()
.map(|row| {
(0..ncol)
.map(|i| match row.get(i) {
Some(Value::Null) | None => self.null_value.clone(),
Some(v) => escape_display_str(&display_cell(v)),
})
.collect()
})
.collect();
let mut width: Vec<usize> = result.columns.iter().map(|c| c.chars().count()).collect();
for row in &cells {
for (i, c) in row.iter().enumerate() {
let w = c.chars().count();
if w > width[i] {
width[i] = w;
}
}
}
let mut out: Vec<u8> = Vec::new();
if self.headers {
for (i, c) in result.columns.iter().enumerate() {
pad_to(c, width[i], &mut out);
out.extend_from_slice(if i == ncol - 1 { b"\n" } else { b" " });
}
for (i, &w) in width.iter().enumerate().take(ncol) {
out.extend(std::iter::repeat_n(b'-', w));
out.extend_from_slice(if i == ncol - 1 { b"\n" } else { b" " });
}
}
for row in &cells {
for (i, c) in row.iter().enumerate() {
pad_to(c, width[i], &mut out);
out.extend_from_slice(if i == ncol - 1 { b"\n" } else { b" " });
}
}
let _ = self.out.write_all(&out);
}
fn print_line(&mut self, result: &QueryResult) {
let width = result
.columns
.iter()
.map(|c| c.chars().count())
.max()
.unwrap_or(0)
.max(5);
let mut out: Vec<u8> = Vec::new();
for (r, row) in result.rows.iter().enumerate() {
if r > 0 {
out.push(b'\n');
}
for (i, name) in result.columns.iter().enumerate() {
let pad = width.saturating_sub(name.chars().count());
out.extend(std::iter::repeat_n(b' ', pad));
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(b" = ");
match row.get(i) {
Some(Value::Null) | None => {
push_display_escaped(self.null_value.as_bytes(), &mut out)
}
Some(v) => {
let mut cell = Vec::new();
render_text_cell(v, &mut cell);
push_display_escaped(&cell, &mut out);
}
}
out.push(b'\n');
}
}
let _ = self.out.write_all(&out);
}
fn print_quote(&mut self, result: &QueryResult) {
let mut out: Vec<u8> = Vec::new();
if self.headers {
for (i, c) in result.columns.iter().enumerate() {
if i > 0 {
out.extend_from_slice(self.col_sep.as_bytes());
}
let mut s = String::new();
quote_text(c, &mut s);
out.extend_from_slice(s.as_bytes());
}
out.extend_from_slice(self.row_sep.as_bytes());
}
for row in &result.rows {
for (i, v) in row.iter().enumerate() {
if i > 0 {
out.extend_from_slice(self.col_sep.as_bytes());
}
let mut s = String::new();
quote_value_with(v, real_inf, &mut s);
out.extend_from_slice(s.as_bytes());
}
out.extend_from_slice(self.row_sep.as_bytes());
}
let _ = self.out.write_all(&out);
}
fn print_insert(&mut self, result: &QueryResult) {
let mut out: Vec<u8> = Vec::new();
let table = graphitesql::sql::print::ident_smart(&self.insert_table);
let collist = if self.headers {
let cols = result
.columns
.iter()
.map(|c| graphitesql::sql::print::ident_smart(c))
.collect::<Vec<_>>()
.join(",");
format!("({cols})")
} else {
String::new()
};
for row in &result.rows {
let mut line = format!("INSERT INTO {table}{collist} VALUES(");
for (i, v) in row.iter().enumerate() {
if i > 0 {
line.push(',');
}
quote_value_with(v, real_sentinel, &mut line);
}
line.push_str(");\n");
out.extend_from_slice(line.as_bytes());
}
let _ = self.out.write_all(&out);
}
fn print_json(&mut self, result: &QueryResult) {
let mut out: Vec<u8> = Vec::new();
out.push(b'[');
for (r, row) in result.rows.iter().enumerate() {
if r == 0 {
out.push(b'{');
} else {
out.extend_from_slice(b",\n{");
}
for (i, v) in row.iter().enumerate() {
let name = result.columns.get(i).map(String::as_str).unwrap_or("");
json_string(name.as_bytes(), &mut out);
out.push(b':');
json_value(v, &mut out);
if i + 1 < row.len() {
out.push(b',');
}
}
out.push(b'}');
}
out.extend_from_slice(b"]\n");
let _ = self.out.write_all(&out);
}
fn print_eqp_tree(&mut self, result: &QueryResult) {
let nodes: Vec<(i64, i64, String)> = result
.rows
.iter()
.filter_map(|r| {
let id = match r.first() {
Some(Value::Integer(i)) => *i,
_ => return None,
};
let parent = match r.get(1) {
Some(Value::Integer(i)) => *i,
_ => return None,
};
let detail = match r.last() {
Some(Value::Text(s)) => String::from(s.as_str()),
_ => String::new(),
};
Some((id, parent, detail))
})
.collect();
let mut out: Vec<u8> = Vec::new();
let _ = writeln!(out, "QUERY PLAN");
render_eqp(&mut out, &nodes, 0, "");
let _ = self.out.write_all(&out);
}
fn dot_command(&mut self, conn: &mut Connection, line: &str) -> bool {
let args = tokenize_dot(line);
let cmd = args.first().map(String::as_str).unwrap_or("");
let arg = args.get(1).map(String::as_str);
match cmd {
".quit" | ".exit" => return true,
".help" => print_help(),
".tables" => {
let mut sql = String::from(
"SELECT name FROM sqlite_master \
WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%'",
);
if let Some(pat) = arg {
sql.push_str(&format!(" AND name LIKE '{}'", pat.replace('\'', "''")));
}
print_columnar(collect_names(conn, &sql));
}
".indexes" | ".indices" => {
let mut sql = String::from("SELECT name FROM sqlite_master WHERE type='index'");
if let Some(pat) = arg {
sql.push_str(&format!(" AND tbl_name LIKE '{}'", pat.replace('\'', "''")));
}
print_columnar(collect_names(conn, &sql));
}
".schema" => {
use graphitesql::schema::ObjectType;
for obj in conn.schema().objects() {
if arg.is_none_or(|name| name == obj.name)
&& let Some(sql) = &obj.sql
{
let base = schema_create_line(sql);
if obj.obj_type == ObjectType::View {
println!(
"{base}\n/* {}({}) */;",
obj.name,
view_columns(conn, &obj.name)
);
} else {
println!("{base};");
}
}
}
}
".headers" => match arg {
Some(a) => {
self.headers = boolean_value(a);
self.header_set = true;
}
None => eprintln!("Usage: .headers on|off"),
},
".mode" => self.set_mode(&args),
".separator" => match (args.get(1), args.get(2)) {
(Some(col), row) => {
self.col_sep = col.clone();
if let Some(r) = row {
self.row_sep = r.clone();
}
}
_ => eprintln!("Usage: .separator COL ?ROW?"),
},
".nullvalue" => match arg {
Some(v) => self.null_value = v.to_string(),
None => eprintln!("Usage: .nullvalue STRING"),
},
".echo" => match arg {
Some(a) => self.echo = boolean_value(a),
None => eprintln!("Usage: .echo on|off"),
},
".changes" => match arg {
Some(a) => self.count_changes = boolean_value(a),
None => eprintln!("Usage: .changes on|off"),
},
".bail" => match arg {
Some(a) => self.bail = boolean_value(a),
None => eprintln!("Usage: .bail on|off"),
},
".output" | ".once" => self.set_output(cmd, &args),
".databases" => {
if let Ok(r) = conn.query("PRAGMA database_list") {
for row in &r.rows {
let name = match row.get(1) {
Some(Value::Text(s)) => s.as_str(),
_ => "",
};
let file = match row.get(2) {
Some(Value::Text(s)) if !s.is_empty() => s.as_str(),
_ => "\"\"",
};
println!("{name}: {file} r/w");
}
}
}
".dump" => dump_database(conn),
".import" => self.import(conn, &args),
".read" => {
match arg {
None => eprintln!("Usage: .read FILE"),
Some(file) => match File::open(file) {
Ok(f) => {
let mut r = BufReader::new(f);
if self.feed_reader(conn, &mut r) {
return true; }
}
Err(_) => eprintln!("Error: cannot open \"{file}\""),
},
}
}
".print" => {
println!("{}", args[1..].join(" "));
}
".show" => self.show_settings(),
".backup" | ".save" => {
match args.get(1..).filter(|a| !a.is_empty()) {
None => eprintln!("Usage: .backup ?DB? FILE"),
Some(rest) => {
let file = rest.last().unwrap();
match conn.serialize() {
Ok(bytes) => {
if let Err(e) = std::fs::write(file, &bytes) {
eprintln!("Error: cannot write \"{file}\": {e}");
}
}
Err(e) => eprintln!("Error: {e}"),
}
}
}
}
other => eprintln!("Unknown command: {other}. Try \".help\"."),
}
false
}
fn set_mode(&mut self, args: &[String]) {
let mut positional = args[1..].iter().filter(|a| !a.starts_with('-'));
let Some(name) = positional.next() else {
return;
};
let tabname = positional.next();
let m = name.to_ascii_lowercase();
let matches = |full: &str| !m.is_empty() && full.starts_with(m.as_str());
if matches("list") {
self.mode = Mode::List;
self.mode_name = String::from("list");
self.col_sep = String::from("|");
self.row_sep = String::from("\n");
} else if matches("csv") {
self.mode = Mode::Csv;
self.mode_name = String::from("csv");
self.col_sep = String::from(",");
self.row_sep = String::from("\r\n");
} else if matches("columns") {
self.mode = Mode::Column;
self.mode_name = String::from("column");
if !self.header_set {
self.headers = true;
}
self.row_sep = String::from("\n");
} else if matches("lines") {
self.mode = Mode::Line;
self.mode_name = String::from("line");
self.row_sep = String::from("\n");
} else if matches("tabs") {
self.mode = Mode::List;
self.mode_name = String::from("list");
self.col_sep = String::from("\t");
} else if matches("quote") {
self.mode = Mode::Quote;
self.mode_name = String::from("quote");
self.col_sep = String::from(",");
self.row_sep = String::from("\n");
} else if matches("insert") {
self.mode = Mode::Insert;
self.mode_name = String::from("insert");
self.insert_table = tabname.cloned().unwrap_or_else(|| String::from("table"));
} else if matches("json") {
self.mode = Mode::Json;
self.mode_name = String::from("json");
} else if matches("markdown") {
self.mode = Mode::Markdown;
self.mode_name = String::from("markdown");
self.row_sep = String::from("\n");
} else if matches("box") {
self.mode = Mode::Box;
self.mode_name = String::from("box");
self.row_sep = String::from("\n");
} else if matches("table") {
self.mode = Mode::Table;
self.mode_name = String::from("table");
self.row_sep = String::from("\n");
} else if matches("html") {
self.mode = Mode::Html;
self.mode_name = String::from("html");
} else if matches("tcl") {
self.mode = Mode::Tcl;
self.mode_name = String::from("tcl");
self.col_sep = String::from(" ");
self.row_sep = String::from("\n");
} else if matches("ascii") {
self.mode = Mode::List;
self.mode_name = String::from("ascii");
self.col_sep = String::from("\x1f");
self.row_sep = String::from("\x1e");
} else {
eprintln!(
"Error: mode should be one of: ascii box column csv html insert \
json line list markdown qbox quote table tabs tcl"
);
}
}
fn set_output(&mut self, cmd: &str, args: &[String]) {
let once = cmd == ".once";
let target = args[1..].iter().find(|a| !a.starts_with('-'));
match target.map(String::as_str) {
None | Some("stdout") => {
self.out = Sink::Stdout;
self.out_name = String::from("stdout");
self.once = false;
}
Some("off") => {
match File::create("/dev/null") {
Ok(f) => self.out = Sink::File(f),
Err(_) => self.out = Sink::Stdout,
}
self.out_name = String::from("stdout");
self.once = false;
}
Some(file) => match File::create(file) {
Ok(f) => {
self.out = Sink::File(f);
self.out_name = file.to_string();
self.once = once;
}
Err(e) => {
eprintln!("Error: cannot open \"{file}\": {e}");
self.out = Sink::Stdout;
self.out_name = String::from("stdout");
self.once = false;
}
},
}
}
fn show_settings(&mut self) {
let modestr = match self.mode_name.as_str() {
m @ ("column" | "markdown" | "box" | "table") => {
format!("{m} --wrap 60 --wordwrap off --noquote")
}
m => m.to_string(),
};
let q = |s: &str| {
let mut v = Vec::new();
tcl_string(s.as_bytes(), &mut v);
String::from_utf8_lossy(&v).into_owned()
};
let onoff = |b: bool| if b { "on" } else { "off" };
let lines = [
format!("{:>12}: {}", "echo", onoff(self.echo)),
format!("{:>12}: {}", "eqp", "off"),
format!("{:>12}: {}", "explain", "auto"),
format!("{:>12}: {}", "headers", onoff(self.headers)),
format!("{:>12}: {}", "mode", modestr),
format!("{:>12}: {}", "nullvalue", q(&self.null_value)),
format!("{:>12}: {}", "output", self.out_name),
format!("{:>12}: {}", "colseparator", q(&self.col_sep)),
format!("{:>12}: {}", "rowseparator", q(&self.row_sep)),
format!("{:>12}: {}", "stats", "off"),
format!("{:>12}: {}", "width", ""),
format!("{:>12}: {}", "filename", self.filename),
];
let mut buf = String::new();
for l in &lines {
buf.push_str(l);
buf.push('\n');
}
let _ = self.out.write_all(buf.as_bytes());
}
fn import(&mut self, conn: &mut Connection, args: &[String]) {
let mut file: Option<&str> = None;
let mut table: Option<&str> = None;
let mut col_sep = self.col_sep.clone();
let mut row_sep = self.row_sep.clone();
let mut skip = 0usize;
let mut i = 1;
while i < args.len() {
let z = args[i].as_str();
let opt = z.strip_prefix("--").or_else(|| z.strip_prefix('-'));
match opt {
Some("csv") => {
col_sep = String::from(",");
row_sep = String::from("\n");
}
Some("skip") if i + 1 < args.len() => {
skip = args[i + 1].parse().unwrap_or(0);
i += 1;
}
Some(o) if z.starts_with('-') && !o.is_empty() => {
}
_ => {
if file.is_none() {
file = Some(z);
} else if table.is_none() {
table = Some(z);
}
}
}
i += 1;
}
let (Some(file), Some(table)) = (file, table) else {
eprintln!(
"ERROR: missing {} argument. Usage:\n.import FILE TABLE",
if file.is_none() { "FILE" } else { "TABLE" }
);
return;
};
if self.mode == Mode::Csv && row_sep == "\r\n" {
self.row_sep = String::from("\n");
row_sep = String::from("\n");
}
let col_sep = col_sep.bytes().next().unwrap_or(b',');
let row_sep = row_sep.bytes().next_back().unwrap_or(b'\n');
let data = match std::fs::read(file) {
Ok(d) => d,
Err(_) => {
eprintln!("Error: cannot open \"{file}\"");
return;
}
};
let mut reader = CsvReader::new(&data, col_sep, row_sep);
for _ in 0..skip {
while reader.next_field().is_some() && reader.term == Term::Col {}
}
let exists = conn
.query(&format!(
"SELECT count(*) FROM pragma_table_info('{}')",
table.replace('\'', "''")
))
.ok()
.and_then(|r| match r.rows.first().and_then(|row| row.first()) {
Some(Value::Integer(n)) => Some(*n > 0),
_ => None,
})
.unwrap_or(false);
if !exists {
let mut cols: Vec<String> = Vec::new();
while let Some(f) = reader.next_field() {
cols.push(String::from_utf8_lossy(&f).into_owned());
if reader.term != Term::Col {
break;
}
}
if cols.is_empty() {
eprintln!("{file}: empty file");
return;
}
let coldefs = cols
.iter()
.map(|c| quote_ident_dq(c))
.collect::<Vec<_>>()
.join(",");
let create = format!("CREATE TABLE {}({coldefs})", quote_ident_dq(table));
if let Err(e) = conn.execute(&create) {
eprintln!("{create} failed:\n{e}");
return;
}
}
let ncol = conn
.query(&format!(
"SELECT count(*) FROM pragma_table_info('{}')",
table.replace('\'', "''")
))
.ok()
.and_then(|r| match r.rows.first().and_then(|row| row.first()) {
Some(Value::Integer(n)) => Some(*n as usize),
_ => None,
})
.unwrap_or(0);
if ncol == 0 {
return;
}
let qtable = quote_ident_dq(table);
loop {
let start_line = reader.line;
let mut fields: Vec<Option<Vec<u8>>> = Vec::with_capacity(ncol);
let mut got = 0usize;
let mut eof = false;
while got < ncol {
match reader.next_field() {
None => {
if got == 0 {
eof = true;
} else if got == ncol - 1 {
fields.push(Some(Vec::new()));
got += 1;
}
break;
}
Some(f) => {
fields.push(Some(f));
got += 1;
if got < ncol && reader.term != Term::Col {
eprintln!(
"{file}:{start_line}: expected {ncol} columns but found {got} - filling the rest with NULL"
);
while fields.len() < ncol {
fields.push(None);
}
got = ncol;
break;
}
}
}
}
if eof {
break;
}
if fields.is_empty() {
if reader.term == Term::Eof {
break;
}
continue;
}
if reader.term == Term::Col {
let mut extra = got;
loop {
reader.next_field();
extra += 1;
if reader.term != Term::Col {
break;
}
}
eprintln!(
"{file}:{start_line}: expected {ncol} columns but found {extra} - extras ignored"
);
}
if fields.len() >= ncol {
let mut sql = format!("INSERT INTO {qtable} VALUES(");
for (j, f) in fields.iter().take(ncol).enumerate() {
if j > 0 {
sql.push(',');
}
match f {
None => sql.push_str("NULL"),
Some(bytes) => {
sql.push('\'');
sql.push_str(&String::from_utf8_lossy(bytes).replace('\'', "''"));
sql.push('\'');
}
}
}
sql.push(')');
if let Err(e) = conn.execute(&sql) {
eprintln!("{file}:{start_line}: INSERT failed: {e}");
}
}
if reader.term == Term::Eof {
break;
}
}
}
}
fn print_help() {
eprintln!(".help Show this message");
eprintln!(".tables [LIKE] List table and view names");
eprintln!(".indexes [LIKE] List index names");
eprintln!(".schema [TABLE] Show CREATE statements");
eprintln!(".databases List attached databases");
eprintln!(".dump Dump the database as SQL text");
eprintln!(".import FILE TABLE Import CSV data from FILE into TABLE");
eprintln!(".read FILE Execute SQL from FILE");
eprintln!(".mode MODE ?TABLE? Set output mode (list csv column line tabs quote insert json)");
eprintln!(".separator COL ?ROW? Set column (and row) separators");
eprintln!(".nullvalue STRING Set the string printed for NULL values");
eprintln!(".output ?FILE? Redirect output to FILE (or back to stdout)");
eprintln!(".once FILE Redirect the next query's output to FILE");
eprintln!(".echo on|off Echo each SQL statement before running it");
eprintln!(".changes on|off Show the number of rows changed by each statement");
eprintln!(".headers on|off Toggle column headers (default off)");
eprintln!(".quit / .exit Exit the shell");
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Term {
Col,
Row,
Eof,
}
struct CsvReader<'a> {
data: &'a [u8],
pos: usize,
col_sep: u8,
row_sep: u8,
term: Term,
line: usize,
}
impl<'a> CsvReader<'a> {
fn new(data: &'a [u8], col_sep: u8, row_sep: u8) -> Self {
CsvReader {
data,
pos: 0,
col_sep,
row_sep,
term: Term::Eof,
line: 1,
}
}
fn next_field(&mut self) -> Option<Vec<u8>> {
if self.pos >= self.data.len() {
self.term = Term::Eof;
return None;
}
let mut out = Vec::new();
let c = self.data[self.pos];
if c == b'"' {
self.pos += 1; loop {
if self.pos >= self.data.len() {
self.term = Term::Eof;
break;
}
let ch = self.data[self.pos];
self.pos += 1;
if ch == b'"' {
if self.pos < self.data.len() && self.data[self.pos] == b'"' {
out.push(b'"');
self.pos += 1;
continue;
}
if self.pos >= self.data.len() {
self.term = Term::Eof;
} else {
let t = self.data[self.pos];
if t == self.col_sep {
self.pos += 1;
self.term = Term::Col;
} else if t == self.row_sep {
self.pos += 1;
self.line += 1;
self.term = Term::Row;
} else if t == b'\r'
&& self.pos + 1 < self.data.len()
&& self.data[self.pos + 1] == self.row_sep
{
self.pos += 2;
self.line += 1;
self.term = Term::Row;
} else {
self.pos += 1;
self.term = Term::Row;
}
}
break;
}
if ch == self.row_sep {
self.line += 1;
}
out.push(ch);
}
} else {
loop {
if self.pos >= self.data.len() {
self.term = Term::Eof;
break;
}
let ch = self.data[self.pos];
if ch == self.col_sep {
self.pos += 1;
self.term = Term::Col;
break;
}
if ch == self.row_sep {
self.pos += 1;
self.line += 1;
self.term = Term::Row;
if out.last() == Some(&b'\r') {
out.pop();
}
break;
}
out.push(ch);
self.pos += 1;
}
}
Some(out)
}
}
fn tokenize_dot(line: &str) -> Vec<String> {
let bytes = line.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
let mut tok = Vec::new();
let delim = bytes[i];
if delim == b'\'' || delim == b'"' {
i += 1;
while i < bytes.len() && bytes[i] != delim {
if delim == b'"' && bytes[i] == b'\\' && i + 1 < bytes.len() {
i += 1;
tok.push(unescape_byte(bytes[i]));
} else {
tok.push(bytes[i]);
}
i += 1;
}
if i < bytes.len() {
i += 1; }
} else {
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
tok.push(bytes[i]);
i += 1;
}
}
out.push(String::from_utf8_lossy(&tok).into_owned());
}
out
}
fn unescape_byte(c: u8) -> u8 {
match c {
b'a' => 0x07,
b'b' => 0x08,
b't' => b'\t',
b'n' => b'\n',
b'v' => 0x0b,
b'f' => 0x0c,
b'r' => b'\r',
other => other,
}
}
fn boolean_value(s: &str) -> bool {
if let Ok(n) = s.parse::<i64>() {
return n != 0;
}
match s.to_ascii_lowercase().as_str() {
"on" | "yes" => true,
"off" | "no" => false,
_ => {
eprintln!("ERROR: Not a boolean value: \"{s}\". Assuming \"no\".");
false
}
}
}
fn dump_database(conn: &Connection) {
println!("PRAGMA foreign_keys=OFF;");
println!("BEGIN TRANSACTION;");
for obj in conn.schema().objects() {
if obj.obj_type != graphitesql::schema::ObjectType::Table {
continue;
}
if obj.name.starts_with("sqlite_") {
continue;
}
let Some(sql) = &obj.sql else { continue };
println!("{};", schema_create_line(sql));
let xinfo = format!(
"SELECT name FROM pragma_table_xinfo('{}') WHERE hidden NOT IN (2,3)",
obj.name.replace('\'', "''")
);
let col_names: Vec<String> = match conn.query(&xinfo) {
Ok(r) => r
.rows
.iter()
.filter_map(|row| match row.first() {
Some(Value::Text(s)) => Some(String::from(s.as_str())),
_ => None,
})
.collect(),
Err(_) => continue,
};
if col_names.is_empty() {
continue;
}
let select_list = col_names
.iter()
.map(|c| format!("\"{}\"", c.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(",");
let sel = format!(
"SELECT {select_list} FROM \"{}\"",
obj.name.replace('"', "\"\"")
);
if let Ok(result) = conn.query(&sel) {
for row in &result.rows {
let mut line = format!("INSERT INTO {} VALUES(", quote_ident_if_needed(&obj.name));
for (i, v) in row.iter().enumerate() {
if i > 0 {
line.push(',');
}
dump_value_into(v, &mut line);
}
line.push_str(");");
println!("{line}");
}
}
}
if conn.schema().table("sqlite_sequence").is_some()
&& let Ok(result) = conn.query("SELECT name, seq FROM sqlite_sequence")
{
for row in &result.rows {
let mut line = String::from("INSERT INTO sqlite_sequence VALUES(");
for (i, v) in row.iter().enumerate() {
if i > 0 {
line.push(',');
}
dump_value_into(v, &mut line);
}
line.push_str(");");
println!("{line}");
}
}
use graphitesql::schema::ObjectType;
let type_rank = |t: ObjectType| match t {
ObjectType::View => 0,
ObjectType::Trigger => 1,
ObjectType::Index => 2,
ObjectType::Table => 3,
};
let mut rest: Vec<_> = conn
.schema()
.objects()
.iter()
.filter(|o| !matches!(o.obj_type, ObjectType::Table) && !o.name.starts_with("sqlite_"))
.collect();
rest.sort_by_key(|o| type_rank(o.obj_type));
for obj in rest {
if let Some(sql) = &obj.sql {
println!("{sql};");
}
}
println!("COMMIT;");
}
fn collect_names(conn: &Connection, sql: &str) -> Vec<String> {
match conn.query(sql) {
Ok(r) => r
.rows
.iter()
.filter_map(|row| match row.first() {
Some(Value::Text(s)) => Some(String::from(s.as_str())),
_ => None,
})
.collect(),
Err(_) => Vec::new(),
}
}
fn print_columnar(mut names: Vec<String>) {
if names.is_empty() {
return;
}
names.sort();
let maxlen = names.iter().map(String::len).max().unwrap_or(0);
let n_col = (80 / (maxlen + 2)).max(1);
let n_row = names.len().div_ceil(n_col);
for i in 0..n_row {
let mut line = String::new();
let mut j = i;
while j < names.len() {
if j >= n_row {
line.push_str(" ");
}
line.push_str(&format!("{:<maxlen$}", names[j]));
j += n_row;
}
println!("{line}");
}
}
fn view_columns(conn: &Connection, name: &str) -> String {
conn.query(&format!(
"SELECT name FROM pragma_table_info('{}')",
name.replace('\'', "''")
))
.map(|r| {
r.rows
.iter()
.filter_map(|row| match row.first() {
Some(Value::Text(s)) => Some(String::from(s.as_str())),
_ => None,
})
.collect::<Vec<_>>()
.join(",")
})
.unwrap_or_default()
}
fn schema_create_line(sql: &str) -> String {
let after = sql.strip_prefix("CREATE TABLE ");
match after {
Some(rest) if rest.starts_with('"') || rest.starts_with('\'') => {
format!("CREATE TABLE IF NOT EXISTS {rest}")
}
_ => sql.to_string(),
}
}
fn quote_ident_if_needed(name: &str) -> String {
let plain = !name.is_empty()
&& !name.as_bytes()[0].is_ascii_digit()
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
if plain {
name.to_string()
} else {
format!("\"{}\"", name.replace('"', "\"\""))
}
}
fn quote_ident_dq(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
fn dump_value_into(v: &Value, out: &mut String) {
match v {
Value::Null => out.push_str("NULL"),
Value::Integer(i) => out.push_str(&i.to_string()),
Value::Real(r) if r.is_finite() => out.push_str(&graphitesql::util::fpdecode::format(
*r,
20,
graphitesql::util::fpdecode::XType::Generic,
true,
false,
)),
Value::Real(r) => out.push_str(if *r < 0.0 { "-9.0e+999" } else { "9.0e+999" }),
Value::Text(s) => {
out.push('\'');
out.push_str(&s.replace('\'', "''"));
out.push('\'');
}
Value::Blob(b) => {
out.push_str("X'");
for byte in b {
out.push_str(&format!("{byte:02x}"));
}
out.push('\'');
}
}
}
fn real_g(r: f64) -> String {
graphitesql::util::fpdecode::format(
r,
20,
graphitesql::util::fpdecode::XType::Generic,
true,
false,
)
}
fn real_sentinel(r: f64) -> String {
if r.is_finite() {
real_g(r)
} else if r < 0.0 {
String::from("-9.0e+999")
} else {
String::from("9.0e+999")
}
}
fn real_inf(r: f64) -> String {
if r.is_finite() {
real_g(r)
} else if r < 0.0 {
String::from("-Inf")
} else {
String::from("Inf")
}
}
fn quote_value_with(v: &Value, real: fn(f64) -> String, out: &mut String) {
match v {
Value::Null => out.push_str("NULL"),
Value::Integer(i) => out.push_str(&i.to_string()),
Value::Real(r) => out.push_str(&real(*r)),
Value::Text(s) => quote_text(s, out),
Value::Blob(b) => {
out.push_str("X'");
for byte in b {
out.push_str(&format!("{byte:02x}"));
}
out.push('\'');
}
}
}
fn quote_text(s: &str, out: &mut String) {
out.push('\'');
out.push_str(&s.replace('\'', "''"));
out.push('\'');
}
fn display_cell(v: &Value) -> String {
match v {
Value::Null => String::new(),
Value::Integer(i) => i.to_string(),
Value::Real(r) => graphitesql::exec::eval::format_real(*r),
Value::Text(s) => {
let b = s.as_bytes();
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
String::from_utf8_lossy(&b[..end]).into_owned()
}
Value::Blob(b) => {
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
String::from_utf8_lossy(&b[..end]).into_owned()
}
}
}
fn pad_to(s: &str, width: usize, out: &mut Vec<u8>) {
out.extend_from_slice(s.as_bytes());
let n = s.chars().count();
out.extend(std::iter::repeat_n(b' ', width.saturating_sub(n)));
}
fn push_display_escaped(bytes: &[u8], out: &mut Vec<u8>) {
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == 0 {
break;
}
let is_crlf_cr = c == 0x0d && bytes.get(i + 1) == Some(&0x0a);
if c <= 0x1f && c != b'\t' && c != b'\n' && !is_crlf_cr {
out.push(b'^');
out.push(0x40 + c);
} else {
out.push(c);
}
i += 1;
}
}
fn escape_display_str(s: &str) -> String {
let mut out = Vec::new();
push_display_escaped(s.as_bytes(), &mut out);
String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
}
fn pad_str(s: &str, width: usize, out: &mut String) {
out.push_str(s);
let n = s.chars().count();
for _ in 0..width.saturating_sub(n) {
out.push(' ');
}
}
fn pad_center(s: &str, width: usize, out: &mut String) {
let n = s.chars().count();
let pad = width.saturating_sub(n);
let left = pad / 2;
for _ in 0..left {
out.push(' ');
}
out.push_str(s);
for _ in 0..pad - left {
out.push(' ');
}
}
fn tcl_string(z: &[u8], out: &mut Vec<u8>) {
out.push(b'"');
let mut i = 0;
while i < z.len() {
let c = z[i];
match c {
b'"' => {
out.extend_from_slice(b"\\\"");
i += 1;
}
b'\\' => {
out.extend_from_slice(b"\\\\");
i += 1;
}
b'\t' => {
out.extend_from_slice(b"\\t");
i += 1;
}
b'\n' => {
out.extend_from_slice(b"\\n");
i += 1;
}
b'\r' => {
out.extend_from_slice(b"\\r");
i += 1;
}
0x0c => {
out.extend_from_slice(b"\\f");
i += 1;
}
0x20..=0x7e => {
out.push(c);
i += 1;
}
0x00..=0x1f => {
out.extend_from_slice(format!("\\{c:03o}").as_bytes());
i += 1;
}
_ => {
match utf8_seq_len(z, i) {
Some(len) => {
out.extend_from_slice(&z[i..i + len]);
i += len;
}
None => {
out.extend_from_slice(format!("\\{c:03o}").as_bytes());
i += 1;
}
}
}
}
}
out.push(b'"');
}
fn html_escape(s: &str, out: &mut String) {
for ch in s.chars() {
match ch {
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'&' => out.push_str("&"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
}
#[derive(Clone, Copy)]
struct BoxChars {
tl: char,
tm: char,
tr: char,
ml: char,
mm: char,
mr: char,
bl: char,
bm: char,
br: char,
horiz: char,
vert: char,
}
const BOX_CHARS: BoxChars = BoxChars {
tl: '┌',
tm: '┬',
tr: '┐',
ml: '├',
mm: '┼',
mr: '┤',
bl: '└',
bm: '┴',
br: '┘',
horiz: '─',
vert: '│',
};
const TABLE_CHARS: BoxChars = BoxChars {
tl: '+',
tm: '+',
tr: '+',
ml: '+',
mm: '+',
mr: '+',
bl: '+',
bm: '+',
br: '+',
horiz: '-',
vert: '|',
};
fn render_text_cell(v: &Value, out: &mut Vec<u8>) {
match v {
Value::Null => {}
Value::Integer(i) => out.extend_from_slice(i.to_string().as_bytes()),
Value::Real(r) => {
out.extend_from_slice(graphitesql::exec::eval::format_real(*r).as_bytes())
}
Value::Text(s) => {
let b = s.as_bytes();
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
out.extend_from_slice(&b[..end]);
}
Value::Blob(b) => {
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
out.extend_from_slice(&b[..end]);
}
}
}
fn render_list_cell(v: &Value, null_value: &str, out: &mut Vec<u8>) {
match v {
Value::Null => out.extend_from_slice(null_value.as_bytes()),
_ => render_text_cell(v, out),
}
}
fn csv_field(field: &[u8], col_sep: &str, out: &mut Vec<u8>) {
let needs = field.is_empty()
|| field.iter().any(|&b| csv_needs_quote(b))
|| (!col_sep.is_empty() && contains(field, col_sep.as_bytes()));
if needs {
out.push(b'"');
for &b in field {
if b == b'"' {
out.push(b'"');
}
out.push(b);
}
out.push(b'"');
} else {
out.extend_from_slice(field);
}
}
fn csv_needs_quote(b: u8) -> bool {
b <= 0x20 || b == b'"' || b == b'\'' || b >= 0x7f
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || needle.len() > haystack.len() {
return false;
}
haystack.windows(needle.len()).any(|w| w == needle)
}
fn json_string(z: &[u8], out: &mut Vec<u8>) {
out.push(b'"');
let mut i = 0;
while i < z.len() {
let c = z[i];
match c {
b'"' => {
out.extend_from_slice(b"\\\"");
i += 1;
}
b'\\' => {
out.extend_from_slice(b"\\\\");
i += 1;
}
0x08 => {
out.extend_from_slice(b"\\b");
i += 1;
}
0x0c => {
out.extend_from_slice(b"\\f");
i += 1;
}
b'\n' => {
out.extend_from_slice(b"\\n");
i += 1;
}
b'\r' => {
out.extend_from_slice(b"\\r");
i += 1;
}
b'\t' => {
out.extend_from_slice(b"\\t");
i += 1;
}
0x00..=0x1f => {
out.extend_from_slice(format!("\\u{c:04x}").as_bytes());
i += 1;
}
0x20..=0x7e => {
out.push(c);
i += 1;
}
_ => {
match utf8_seq_len(z, i) {
Some(len) => {
out.extend_from_slice(&z[i..i + len]);
i += len;
}
None => {
out.extend_from_slice(format!("\\u{c:04x}").as_bytes());
i += 1;
}
}
}
}
}
out.push(b'"');
}
fn utf8_seq_len(z: &[u8], i: usize) -> Option<usize> {
let c = z[i];
let len = match c {
0xc2..=0xdf => 2,
0xe0..=0xef => 3,
0xf0..=0xf4 => 4,
_ => return None,
};
if i + len > z.len() {
return None;
}
if z[i + 1..i + len]
.iter()
.all(|&b| (0x80..=0xbf).contains(&b))
{
std::str::from_utf8(&z[i..i + len]).ok().map(|_| len)
} else {
None
}
}
fn json_value(v: &Value, out: &mut Vec<u8>) {
match v {
Value::Null => out.extend_from_slice(b"null"),
Value::Integer(i) => out.extend_from_slice(i.to_string().as_bytes()),
Value::Real(r) => out.extend_from_slice(real_sentinel(*r).as_bytes()),
Value::Text(s) => json_string(s.as_bytes(), out),
Value::Blob(b) => json_string(b, out),
}
}
fn render_eqp(out: &mut dyn Write, nodes: &[(i64, i64, String)], parent: i64, prefix: &str) {
let children: Vec<&(i64, i64, String)> =
nodes.iter().filter(|(_, p, _)| *p == parent).collect();
let last_i = children.len().wrapping_sub(1);
for (i, (id, _, detail)) in children.iter().enumerate() {
let last = i == last_i;
let connector = if last { "`--" } else { "|--" };
let _ = writeln!(out, "{prefix}{connector}{detail}");
let child_prefix = format!("{prefix}{}", if last { " " } else { "| " });
render_eqp(out, nodes, *id, &child_prefix);
}
}
fn returns_rows(sql: &str) -> bool {
let word = sql
.trim_start()
.split(|c: char| !c.is_ascii_alphabetic())
.find(|w| !w.is_empty())
.unwrap_or("")
.to_ascii_uppercase();
matches!(
word.as_str(),
"SELECT" | "PRAGMA" | "WITH" | "VALUES" | "EXPLAIN"
)
}
fn is_explain_query_plan(sql: &str) -> bool {
let mut words = sql.split_whitespace();
words
.next()
.is_some_and(|w| w.eq_ignore_ascii_case("explain"))
&& words
.next()
.is_some_and(|w| w.eq_ignore_ascii_case("query"))
&& words.next().is_some_and(|w| w.eq_ignore_ascii_case("plan"))
}
fn is_dml(sql: &str) -> bool {
let lead = sql
.trim_start()
.split(|c: char| !c.is_ascii_alphabetic())
.find(|w| !w.is_empty())
.unwrap_or("")
.to_ascii_uppercase();
matches!(
lead.as_str(),
"INSERT" | "UPDATE" | "DELETE" | "REPLACE" | "WITH"
)
}
fn has_returning(sql: &str) -> bool {
let lead = sql
.trim_start()
.split(|c: char| !c.is_ascii_alphabetic())
.find(|w| !w.is_empty())
.unwrap_or("")
.to_ascii_uppercase();
if !matches!(
lead.as_str(),
"INSERT" | "UPDATE" | "DELETE" | "REPLACE" | "WITH"
) {
return false;
}
let mut in_str = false;
let mut word = String::new();
let mut chars = sql.chars().peekable();
while let Some(c) = chars.next() {
if in_str {
if c == '\'' {
if chars.peek() == Some(&'\'') {
chars.next();
} else {
in_str = false;
}
}
continue;
}
if c == '\'' {
in_str = true;
} else if c.is_alphabetic() || c == '_' {
word.push(c);
} else {
if word.eq_ignore_ascii_case("returning") {
return true;
}
word.clear();
}
}
word.eq_ignore_ascii_case("returning")
}
fn raw_error_message(e: &graphitesql::Error) -> String {
use graphitesql::Error as E;
match e {
E::Error(m)
| E::ErrorAt(m, _)
| E::Corrupt(m)
| E::Io(m)
| E::CantOpen(m)
| E::Constraint(m)
| E::Parse(m)
| E::ParseAt(m, _) => m.clone(),
E::Busy => String::from("database is locked"),
E::Unsupported(m) => String::from(*m),
other => format!("{other}"),
}
}
fn is_prepare_error(e: &graphitesql::Error, msg: &str, sql: &str) -> bool {
use graphitesql::Error as E;
match e {
E::Parse(_) | E::ParseAt(..) => true,
E::Constraint(_) | E::Busy | E::Corrupt(_) | E::Io(_) | E::CantOpen(_) => false,
E::Error(_) | E::ErrorAt(..) => {
if msg.starts_with("no such module") {
return !sql.to_ascii_uppercase().contains("VIRTUAL TABLE");
}
const PREPARE_PREFIXES: &[&str] = &[
"no such column",
"no such table",
"no such function",
"no such collation sequence",
"no such index",
"no such view",
"no such trigger",
"ambiguous column name",
"misuse of aggregate function",
"misuse of window function",
"wrong number of arguments to function",
"too many arguments on",
"duplicate column name",
"row value misused",
"aggregate functions are not allowed",
"HAVING clause on a non-aggregate query",
"SELECTs to the left and right of", "all VALUES must have the same number of terms",
"cannot join using column",
"unable to identify the object to be reindexed",
"cannot drop ", "use DROP ", "sub-select returns",
"table ", "there is already ",
"unknown table option",
"unknown database",
"1st ORDER BY term",
"ORDER BY term out of range",
"default value of column",
"object name reserved for internal use",
"foreign key on ",
"number of columns in foreign key",
"parameters prohibited",
"no query solution",
"first argument to \"generate_series()\"",
"second argument to likelihood()",
"missing datatype for",
"unknown datatype for", "AUTOINCREMENT", "cannot use DEFAULT on a generated column",
"cannot use window functions in recursive",
"generated columns cannot",
"must have at least one non-generated column",
"Cannot add a UNIQUE",
"Cannot add a PRIMARY KEY",
"unsupported frame specification",
"cannot create",
"conflicting ON CONFLICT",
"the NATURAL keyword",
"a JOIN clause is required",
"USING clause",
"cannot have more than one primary key",
"has more than one primary key",
"PRIMARY KEY missing on table",
"the \".\" operator prohibited",
"cannot modify ",
"ON CONFLICT clause does not match",
"RANGE with offset",
"GROUPS with offset",
];
const PREPARE_CONTAINS: &[&str] = &[
"ORDER BY term out of range",
"GROUP BY term out of range",
"may not be used as a window function",
];
PREPARE_PREFIXES.iter().any(|p| msg.starts_with(p))
|| PREPARE_CONTAINS.iter().any(|p| msg.contains(p))
|| msg.ends_with(" already exists")
}
_ => false,
}
}
fn error_offending_token(msg: &str) -> Option<&str> {
if let Some(rest) = msg
.strip_prefix("near \"")
.or_else(|| msg.strip_prefix("unrecognized token: \""))
{
return rest.split('"').next().filter(|t| !t.is_empty());
}
for pre in [
"misuse of aggregate function ",
"misuse of window function ",
"wrong number of arguments to function ",
] {
if let Some(rest) = msg.strip_prefix(pre) {
return rest.split('(').next().filter(|t| !t.is_empty());
}
}
if let Some(rest) = msg.strip_prefix("second argument to ") {
let name = rest.split('(').next().unwrap_or(rest);
return (!name.is_empty() && !name.contains(' ')).then_some(name);
}
for pre in [
"no such column: ",
"no such function: ",
"ambiguous column name: ",
] {
if let Some(rest) = msg.strip_prefix(pre) {
let tok = rest.split(" - ").next().unwrap_or(rest);
let ok = !tok.is_empty() && (tok.starts_with('"') || !tok.contains(' '));
return ok.then_some(tok);
}
}
for kind in ["table ", "view ", "trigger "] {
if let Some(rest) = msg.strip_prefix(kind)
&& let Some(name) = rest.strip_suffix(" already exists")
{
return (!name.is_empty() && !name.contains(' ')).then_some(name);
}
}
None
}
fn locate_offending(sql: &str, tok: &str) -> Option<usize> {
locate_token(sql, tok).or_else(|| {
tok.strip_prefix('"')
.and_then(|t| t.strip_suffix('"'))
.filter(|t| !t.is_empty())
.and_then(|inner| locate_token(sql, inner))
})
}
fn locate_token(sql: &str, token: &str) -> Option<usize> {
if token.starts_with('\'') {
return sql.find(token);
}
let b = sql.as_bytes();
let mut i = 0;
while i < b.len() {
match b[i] {
b'\'' => {
i += 1;
while i < b.len() {
if b[i] == b'\'' {
if i + 1 < b.len() && b[i + 1] == b'\'' {
i += 2;
continue;
}
i += 1;
break;
}
i += 1;
}
}
b'-' if i + 1 < b.len() && b[i + 1] == b'-' => {
while i < b.len() && b[i] != b'\n' {
i += 1;
}
}
b'/' if i + 1 < b.len() && b[i + 1] == b'*' => {
i += 2;
while i + 1 < b.len() && !(b[i] == b'*' && b[i + 1] == b'/') {
i += 1;
}
i += 2;
}
_ => {
if sql[i..].starts_with(token) {
return Some(i);
}
i += 1;
}
}
}
None
}
fn caret_block(src: &str, off: usize) -> String {
let mut start = 0usize;
let mut ioff = off;
while ioff > 50 {
let ch_len = src[start..].chars().next().map_or(1, |c| c.len_utf8());
start += ch_len;
ioff -= 1;
}
let tail = src[start..].trim_end_matches(['\n', '\r']);
let mut end = tail.len().min(78);
while end < tail.len() && !tail.is_char_boundary(end) {
end -= 1;
}
let shown = &tail[..end];
let caret = if ioff < 25 {
format!("{}^--- error here", " ".repeat(2 + ioff))
} else {
format!("{}error here ---^", " ".repeat(2 + ioff - 14))
};
format!(" {shown}\n{caret}")
}
fn render_cli_error(sql: &str, e: &graphitesql::Error) -> String {
let msg = raw_error_message(e);
if is_prepare_error(e, &msg, sql) {
let off = e
.parse_offset()
.filter(|&o| o <= sql.len())
.or_else(|| error_offending_token(&msg).and_then(|t| locate_offending(sql, t)));
if let Some(off) = off {
return format!("Error: in prepare, {msg}\n{}", caret_block(sql, off));
}
return format!("Error: in prepare, {msg}");
}
let code = e.code();
if code != 1 {
format!("Error: stepping, {msg} ({code})")
} else {
format!("Error: stepping, {msg}")
}
}
fn collapse_ws(s: &str) -> String {
let mut out = String::new();
let mut prev_ws = false;
for c in s.trim().chars() {
if c.is_whitespace() {
if !prev_ws {
out.push(' ');
prev_ws = true;
}
} else {
out.push(c);
prev_ws = false;
}
}
out
}
fn render_script_error(sql: &str, e: &graphitesql::Error, line: usize) -> String {
let msg = raw_error_message(e);
let flat = collapse_ws(sql);
if is_prepare_error(e, &msg, sql) {
let off = e
.parse_offset()
.filter(|_| flat == sql)
.or_else(|| error_offending_token(&msg).and_then(|t| locate_offending(&flat, t)));
if let Some(off) = off {
return format!(
"Parse error near line {line}: {msg}\n{}",
caret_block(&flat, off)
);
}
return format!("Parse error near line {line}: {msg}");
}
let code = e.code();
if code != 1 {
format!("Runtime error near line {line}: {msg} ({code})")
} else {
format!("Runtime error near line {line}: {msg}")
}
}
fn is_pragma_setter(sql: &str) -> bool {
let rest = sql.trim_start();
let mut words = rest.split(|c: char| !c.is_ascii_alphabetic());
let first = words.find(|w| !w.is_empty()).unwrap_or("");
if !first.eq_ignore_ascii_case("PRAGMA") || !sql.contains('=') {
return false;
}
let target = rest[first.len()..]
.split(['=', '('])
.next()
.unwrap_or("")
.trim();
let name = target.rsplit('.').next().unwrap_or(target).trim();
!matches!(
name.to_ascii_lowercase().as_str(),
"table_info"
| "table_xinfo"
| "table_list"
| "index_list"
| "index_info"
| "index_xinfo"
| "foreign_key_list"
| "foreign_key_check"
)
}
fn pragma_setter_result_query(sql: &str) -> Option<String> {
let rest = sql.trim_start();
if rest.len() < 6 || !rest[..6].eq_ignore_ascii_case("pragma") {
return None;
}
let target = rest[6..].split('=').next()?.trim();
let name = target.rsplit('.').next().unwrap_or(target).trim();
if matches!(
name.to_ascii_lowercase().as_str(),
"journal_mode"
| "busy_timeout"
| "threads"
| "secure_delete"
| "soft_heap_limit"
| "wal_autocheckpoint"
| "journal_size_limit"
| "analysis_limit"
) {
Some(format!("PRAGMA {target}"))
} else {
None
}
}
fn has_sql_content(s: &str) -> bool {
let b = s.as_bytes();
let mut i = 0;
while i < b.len() {
match b[i] {
b' ' | b'\t' | b'\r' | b'\n' | 0x0c | b';' => i += 1,
b'-' if b.get(i + 1) == Some(&b'-') => {
i += 2;
while i < b.len() && b[i] != b'\n' {
i += 1;
}
}
b'/' if b.get(i + 1) == Some(&b'*') => {
i += 2;
while i + 1 < b.len() && !(b[i] == b'*' && b[i + 1] == b'/') {
i += 1;
}
i += 2;
}
_ => return true,
}
}
false
}
fn split_statements(sql: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut in_str = false;
let mut depth: u32 = 0;
let mut word = String::new();
let mut has_content = false;
let mut chars = sql.chars().peekable();
let flush_word = |word: &mut String, has_content: bool, depth: &mut u32| {
match word.to_ascii_uppercase().as_str() {
"BEGIN" => {
if has_content {
*depth += 1;
}
}
"CASE" => *depth += 1,
"END" => *depth = depth.saturating_sub(1),
_ => {}
}
word.clear();
};
while let Some(c) = chars.next() {
if in_str {
cur.push(c);
if c == '\'' {
if chars.peek() == Some(&'\'') {
cur.push(chars.next().unwrap());
} else {
in_str = false;
}
}
continue;
}
if c == '-' && chars.peek() == Some(&'-') {
if !word.is_empty() {
flush_word(&mut word, has_content, &mut depth);
has_content = true;
}
cur.push(c);
while let Some(&n) = chars.peek() {
cur.push(chars.next().unwrap());
if n == '\n' {
break;
}
}
continue;
}
if c == '/' && chars.peek() == Some(&'*') {
if !word.is_empty() {
flush_word(&mut word, has_content, &mut depth);
has_content = true;
}
cur.push(c);
cur.push(chars.next().unwrap()); while let Some(cc) = chars.next() {
cur.push(cc);
if cc == '*' && chars.peek() == Some(&'/') {
cur.push(chars.next().unwrap());
break;
}
}
continue;
}
if c.is_alphabetic() || c == '_' {
word.push(c);
cur.push(c);
continue;
}
if !word.is_empty() {
flush_word(&mut word, has_content, &mut depth);
has_content = true;
}
match c {
'\'' => {
in_str = true;
has_content = true;
cur.push(c);
}
';' if depth == 0 => {
if !cur.trim().is_empty() {
cur.push(';');
}
out.push(std::mem::take(&mut cur));
has_content = false; }
c if c.is_whitespace() => cur.push(c),
_ => {
has_content = true;
cur.push(c);
}
}
}
if !word.is_empty() {
flush_word(&mut word, has_content, &mut depth);
}
if !cur.trim().is_empty() {
out.push(cur);
}
out
}
fn input_is_complete(buffer: &str) -> bool {
if !buffer.trim_end().ends_with(';') {
return false;
}
let mut in_str = false;
let mut depth: u32 = 0;
let mut has_content = false;
let mut word = String::new();
let mut chars = buffer.chars().peekable();
let classify = |word: &mut String, has_content: bool, depth: &mut u32| {
match word.to_ascii_uppercase().as_str() {
"BEGIN" => {
if has_content {
*depth += 1;
}
}
"CASE" => *depth += 1,
"END" => *depth = depth.saturating_sub(1),
_ => {}
}
word.clear();
};
while let Some(c) = chars.next() {
if in_str {
if c == '\'' {
if chars.peek() == Some(&'\'') {
chars.next();
} else {
in_str = false;
}
}
continue;
}
if c == '-' && chars.peek() == Some(&'-') {
if !word.is_empty() {
classify(&mut word, has_content, &mut depth);
has_content = true;
}
for n in chars.by_ref() {
if n == '\n' {
break;
}
}
continue;
}
if c == '/' && chars.peek() == Some(&'*') {
if !word.is_empty() {
classify(&mut word, has_content, &mut depth);
has_content = true;
}
chars.next(); while let Some(cc) = chars.next() {
if cc == '*' && chars.peek() == Some(&'/') {
chars.next();
break;
}
}
continue;
}
if c.is_alphabetic() || c == '_' {
word.push(c);
continue;
}
if !word.is_empty() {
classify(&mut word, has_content, &mut depth);
has_content = true;
}
match c {
'\'' => {
in_str = true;
has_content = true;
}
';' if depth == 0 => has_content = false,
c if c.is_whitespace() => {}
_ => has_content = true,
}
}
if !word.is_empty() {
classify(&mut word, has_content, &mut depth);
}
depth == 0
}