use anyhow::{Result, anyhow};
use clap::Args;
use futures::StreamExt;
use rustyline::error::ReadlineError;
use rustyline::validate::{ValidationContext, ValidationResult, Validator};
use rustyline::{Completer, Editor, Helper, Highlighter, Hinter};
use serde::Serialize;
use serde_json::Value as JsonValue;
use serde_json::ser::PrettyFormatter;
use surrealdb::engine::any::{self, connect};
use surrealdb::method::WithStats;
use surrealdb::opt::Config;
use surrealdb::{IndexedResults, Notification, Stats};
use surrealdb_core::cnf::CommonConfig;
use surrealdb_core::dbs::Capabilities as CoreCapabilities;
use surrealdb_types::{SurrealValue, ToSql, Value, object};
use crate::cli::abstraction::auth::{CredentialsBuilder, CredentialsLevel};
use crate::cli::abstraction::{
AuthArguments, DatabaseConnectionArguments, LevelSelectionArguments,
};
use crate::cnf::PKG_VERSION;
use crate::dbs::DbsCapabilities;
#[derive(Args, Debug)]
pub struct SqlCommandArguments {
#[command(flatten)]
conn: DatabaseConnectionArguments,
#[command(flatten)]
auth: AuthArguments,
#[command(flatten)]
level: LevelSelectionArguments,
#[arg(long)]
pretty: bool,
#[arg(long)]
json: bool,
#[arg(long)]
multi: bool,
#[arg(long, env = "SURREAL_HIDE_WELCOME")]
hide_welcome: bool,
#[command(flatten)]
#[command(next_help_heading = "Capabilities")]
capabilities: DbsCapabilities,
}
pub async fn init(
SqlCommandArguments {
auth: AuthArguments {
username,
password,
token,
auth_level,
},
conn: DatabaseConnectionArguments {
endpoint,
},
level: LevelSelectionArguments {
namespace,
database,
},
pretty,
json,
multi,
hide_welcome,
capabilities,
..
}: SqlCommandArguments,
) -> Result<()> {
let capabilities = capabilities.into_cli_capabilities();
let config = Config::new().capabilities(capabilities.clone().into());
let is_local = any::__into_endpoint(&endpoint)?.parse_kind()?.is_local();
let client = if username.is_some() && password.is_some() && !is_local {
debug!("Connecting to the database engine with authentication");
let creds = CredentialsBuilder::default()
.with_username(username.clone())
.with_password(password.clone())
.with_namespace(namespace.clone())
.with_database(database.clone());
let client = connect(endpoint).await?;
debug!("Signing in to the database engine at '{:?}' level", auth_level);
match auth_level {
CredentialsLevel::Root => client.signin(creds.root()?).await?,
CredentialsLevel::Namespace => client.signin(creds.namespace()?).await?,
CredentialsLevel::Database => client.signin(creds.database()?).await?,
};
client
} else if token.is_some() && !is_local {
let client = connect(endpoint).await?;
if let Some(token) = token {
client.authenticate(token).await?;
}
client
} else {
debug!("Connecting to the database engine without authentication");
connect((endpoint, config)).await?
};
let mut rl = Editor::new()?;
rl.set_helper(Some(InputValidator {
multi,
capabilities: &capabilities,
}));
let _ = rl.load_history("history.txt");
let is_not_empty = |s: &&str| !s.is_empty();
let namespace = namespace.as_deref().map(str::trim).filter(is_not_empty);
let database = database.as_deref().map(str::trim).filter(is_not_empty);
let (namespace, database) = match (namespace, database) {
(Some(namespace), Some(database)) => client.use_ns(namespace).use_db(database).await?,
(Some(namespace), None) => client.use_ns(namespace).await?,
(None, None) => client.use_defaults().await?,
_ => (None, None),
};
let mut prompt = if let Some(namespace) = &namespace {
if let Some(database) = &database {
format!("{namespace}/{database}> ")
} else {
format!("{namespace}> ")
}
} else {
"> ".to_owned()
};
if !hide_welcome {
let hints = [
(true, "Different statements within a query should be separated by a (;) semicolon."),
(
!multi,
"To create a multi-line query, end your lines with a (\\) backslash, and press enter.",
),
(true, "To exit, send a SIGTERM or press CTRL+C"),
]
.iter()
.filter(|(show, _)| *show)
.map(|(_, hint)| format!("# - {hint}"))
.collect::<Vec<String>>()
.join("\n");
eprintln!(
"
#
# Welcome to the SurrealDB SQL shell
#
# How to use this shell:
{hints}
#
# Consult https://surrealdb.com/docs/cli/sql for further instructions
#
# SurrealDB version: {}
#
",
*PKG_VERSION
);
}
loop {
let line = match rl.readline(&prompt) {
Ok(line) => {
let line = filter_line_continuations(&line);
if let Err(e) = rl.add_history_entry(line.as_str()) {
eprintln!("{e}");
}
line
}
Err(ReadlineError::Interrupted | ReadlineError::Eof) => {
break;
}
Err(e) => {
eprintln!("Error: {e:?}");
break;
}
};
if line.trim().is_empty() {
continue;
}
match surrealdb_core::syn::parse_with_capabilities(
&line,
&capabilities,
&CommonConfig::default(),
) {
Ok(mut query) => {
let init_length = query.num_statements();
let vars = query.get_let_statements();
for var in &vars {
query.add_param(var.clone());
}
query.add_param("session".to_string());
let (prompt_ns, _) = split_prompt(&prompt)?;
if namespace.is_none() && prompt_ns.is_empty() && database.is_some() {
eprintln!("Specify a namespace to use\n");
continue;
}
let mut result = client.query(query.to_sql()).with_stats().await;
let mut use_ns = None;
let mut use_db = None;
if let Ok(WithStats(res)) = &mut result {
if let Ok(Value::Object(obj)) = res.take(init_length + vars.len()) {
if let Some(Value::String(ns)) = obj.get("ns") {
use_ns = Some(ns.clone());
}
if let Some(Value::String(db)) = obj.get("db") {
use_db = Some(db.clone());
}
}
for (i, n) in vars.into_iter().enumerate() {
if let Result::<Value, _>::Ok(v) = res.take(init_length + i) {
let _ = client.set(n, v).await;
}
}
}
let result = process(pretty, json, result);
let result_is_error = result.is_err();
print(result);
if result_is_error {
continue;
}
if let Some(use_ns) = use_ns {
if let Some(use_db) = use_db {
if client.use_ns(use_ns.clone()).use_db(use_db.clone()).await.is_ok() {
prompt = format!("{use_ns}/{use_db}> ");
}
} else if client.use_ns(use_ns.clone()).await.is_ok() {
prompt = format!("{use_ns}> ");
}
}
}
Err(e) => {
eprintln!("{e}\n");
}
}
}
let _ = rl.save_history("history.txt");
Ok(())
}
fn pretty_print_json(value: &JsonValue) -> String {
let pretty_print = |value: &JsonValue| -> Result<String> {
let mut buf = Vec::new();
let mut serializer =
serde_json::Serializer::with_formatter(&mut buf, PrettyFormatter::with_indent(b"\t"));
value.serialize(&mut serializer)?;
Ok(String::from_utf8(buf)?)
};
match pretty_print(value) {
Ok(v) => v,
Err(_) => value.to_string(),
}
}
fn process(
pretty: bool,
json: bool,
res: surrealdb::Result<WithStats<IndexedResults>>,
) -> Result<String> {
let mut response = res?;
let num_statements = response.num_statements();
let mut vec = Vec::<(Stats, Value)>::with_capacity(num_statements);
for index in 0..num_statements {
let (stats, result) = response.take(index).ok_or_else(|| {
anyhow!("Expected some result for a query with index {index}, but found none")
})?;
let output = result.unwrap_or_else(|e| Value::String(e.to_string()));
vec.push((stats, output));
}
tokio::spawn(async move {
let mut stream = match response.into_inner().stream::<Value>(()) {
Ok(stream) => stream,
Err(error) => {
print(Err(error.into()));
return;
}
};
while let Some(result) = stream.next().await {
let Notification {
query_id,
action,
data,
..
} = match result {
Ok(notification) => notification,
Err(error) => {
print(Err(error.into()));
continue;
}
};
let message = match (json, pretty) {
(false, false) => {
let value = Value::Object(object! {
"id": Value::Uuid(query_id),
"action": action.into_value(),
"result": data,
});
value.to_sql()
}
(false, true) => format!(
"-- Notification (action: {action:?}, live query ID: {query_id})\n{}",
data.to_sql_pretty()
),
(true, false) => {
let value = Value::Object(object! {
"id": Value::Uuid(query_id),
"action": action.into_value(),
"result": data,
});
value.into_json_value().to_string()
}
(true, true) => {
let output = pretty_print_json(&data.into_json_value());
format!(
"-- Notification (action: {action:?}, live query ID: {query_id})\n{output:#}"
)
}
};
print(Ok(format!("\n{message}")));
}
});
Ok(match (json, pretty) {
(false, false) => vec.into_iter().map(|(_, x)| x).collect::<Value>().to_sql(),
(false, true) => vec
.into_iter()
.enumerate()
.map(|(index, (stats, value))| {
let query_num = index + 1;
let execution_time = stats.execution_time.unwrap_or_default();
format!(
"-- Query {query_num} (execution time: {execution_time:?})\n{:#}",
value.to_sql_pretty()
)
})
.collect::<Vec<String>>()
.join("\n"),
(true, false) => {
let value = Value::from_vec(vec.into_iter().map(|(_, x)| x).collect::<Vec<_>>());
serde_json::to_string(&value.into_json_value())?
}
(true, true) => vec
.into_iter()
.enumerate()
.map(|(index, (stats, value))| {
let output = pretty_print_json(&value.into_json_value());
let query_num = index + 1;
let execution_time = stats.execution_time.unwrap_or_default();
format!("-- Query {query_num} (execution time: {execution_time:?}\n{output:#}",)
})
.collect::<Vec<String>>()
.join("\n"),
})
}
fn print(result: Result<String>) {
match result {
Ok(v) => {
println!("{v}\n");
}
Err(e) => {
eprintln!("{e}\n");
}
}
}
#[derive(Completer, Helper, Highlighter, Hinter)]
struct InputValidator<'a> {
multi: bool,
capabilities: &'a CoreCapabilities,
}
#[expect(clippy::if_same_then_else)]
impl Validator for InputValidator<'_> {
fn validate(&self, ctx: &mut ValidationContext) -> rustyline::Result<ValidationResult> {
use ValidationResult::{Incomplete, Invalid, Valid};
let input = filter_line_continuations(ctx.input());
let input = input.trim();
let result = if self.multi && !input.ends_with(';') {
Incomplete } else if self.multi && input.is_empty() {
Incomplete } else if input.ends_with('\\') {
Incomplete } else if input.is_empty() {
Valid(None) } else {
match surrealdb_core::syn::parse_with_capabilities(
input,
self.capabilities,
&Default::default(),
) {
Err(e) => Invalid(Some(format!(" --< {e}"))),
_ => Valid(None),
}
};
Ok(result)
}
}
fn filter_line_continuations(line: &str) -> String {
line.replace("\\\n", "").replace("\\\r\n", "")
}
fn split_prompt(prompt: &str) -> Result<(&str, &str)> {
if let Some(sp) = prompt.split_once('>') {
let selection = sp.0;
Ok(selection.split_once('/').unwrap_or((selection, "")))
} else {
Err(anyhow!("Invalid prompt format"))
}
}