use crate::settings::Settings;
use anyhow::Result;
use std::path::PathBuf;
pub mod mssql;
pub mod mysql;
pub mod pg;
pub mod sqlite;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Engine {
Pg,
MySql,
Sqlite,
MsSql,
}
pub const ENGINES: [Engine; 4] = [Engine::Pg, Engine::MySql, Engine::Sqlite, Engine::MsSql];
impl Engine {
pub fn label(self) -> &'static str {
match self {
Engine::Pg => "postgres",
Engine::MySql => "mysql",
Engine::Sqlite => "sqlite",
Engine::MsSql => "sqlserver",
}
}
pub fn slug(self) -> &'static str {
match self {
Engine::Pg => "pg",
Engine::MySql => "my",
Engine::Sqlite => "lite",
Engine::MsSql => "ms",
}
}
pub fn idx(self) -> usize {
match self {
Engine::Pg => 0,
Engine::MySql => 1,
Engine::Sqlite => 2,
Engine::MsSql => 3,
}
}
pub fn from_slug(s: &str) -> Option<Engine> {
ENGINES
.into_iter()
.find(|e| e.slug() == s || e.label() == s)
}
pub fn client_argv(self, s: &Settings) -> Vec<String> {
let words: Vec<String> = match self {
Engine::Pg => &s.psql_command,
Engine::MySql => &s.mysql_command,
Engine::Sqlite => &s.sqlite_command,
Engine::MsSql => &s.sqlcmd_command,
}
.split_whitespace()
.map(str::to_string)
.collect();
if words.is_empty() {
vec![self.default_client().to_string()]
} else {
words
}
}
pub fn client_setting(self) -> &'static str {
match self {
Engine::Pg => "psql_command",
Engine::MySql => "mysql_command",
Engine::Sqlite => "sqlite_command",
Engine::MsSql => "sqlcmd_command",
}
}
pub fn default_client(self) -> &'static str {
match self {
Engine::Pg => "psql",
Engine::MySql => "mysql",
Engine::Sqlite => "sqlite3",
Engine::MsSql => "sqlcmd",
}
}
pub fn default_port(self) -> &'static str {
match self {
Engine::Pg => "5432",
Engine::MySql => "3306",
Engine::Sqlite => "",
Engine::MsSql => "1433",
}
}
pub fn store(self) -> PathBuf {
match self {
Engine::Pg => pg::service_path(),
Engine::MySql => mysql::cnf_path(),
Engine::Sqlite => sqlite::store_path(),
Engine::MsSql => mssql::store_path(),
}
}
pub fn query_flag(self) -> Option<&'static str> {
match self {
Engine::Pg => Some("-c"),
Engine::MySql => Some("-e"),
Engine::Sqlite => None,
Engine::MsSql => Some("-Q"),
}
}
pub fn hint(self) -> &'static str {
match self {
Engine::Pg => {
"\\l dbs · \\c <db> · \\dn schemas · \\dt tables · \\d <table> · \\i <file> · \\? help · \\q quit"
}
Engine::MySql => {
"show databases; · use <db>; · show tables; · desc <table>; · source <file> · \\q quit"
}
Engine::Sqlite => {
".databases · .tables · .schema <table> · .read <file> · .help · .quit"
}
Engine::MsSql => {
"select name from sys.databases; · use <db>; · select name from sys.tables; · :r <file> · exit"
}
}
}
pub fn networked(self) -> bool {
self != Engine::Sqlite
}
pub fn offers_read_only(self) -> bool {
matches!(self, Engine::Pg | Engine::Sqlite)
}
pub fn stores_password(self) -> bool {
matches!(self, Engine::Pg | Engine::MySql)
}
}
#[derive(Clone)]
pub struct Conn {
pub engine: Engine,
pub name: String,
pub host: String,
pub port: String,
pub database: String,
pub user: String,
pub extra: Vec<(String, String)>,
}
impl Conn {
pub fn key(&self) -> String {
format!("{}:{}", self.engine.slug(), self.name)
}
pub fn target(&self) -> String {
if self.engine == Engine::Sqlite {
return crate::ini::collapse_tilde(&self.database);
}
let host = if self.host.is_empty() {
"localhost".to_string()
} else {
self.host.clone()
};
let mut s = match self.user.is_empty() {
true => host,
false => format!("{}@{host}", self.user),
};
let port = self.port_or_default();
if !port.is_empty() {
s.push(':');
s.push_str(&port);
}
if !self.database.is_empty() {
s.push('/');
s.push_str(&self.database);
}
s
}
pub fn read_only(&self) -> bool {
match self.engine {
Engine::Pg => pg::read_only(&self.extra),
Engine::Sqlite => sqlite::read_only(&self.extra),
Engine::MySql | Engine::MsSql => false,
}
}
pub fn connect_env(&self, s: &Settings) -> Vec<(String, String)> {
match self.engine {
Engine::Pg if self.read_only() && !speaks_client_flags(self.engine, s) => {
vec![("PGOPTIONS".to_string(), pg::read_only_pgoptions())]
}
_ => Vec::new(),
}
}
pub fn port_or_default(&self) -> String {
if self.port.is_empty() {
self.engine.default_port().to_string()
} else {
self.port.clone()
}
}
pub fn connect_argv(&self, s: &Settings) -> Vec<String> {
self.connect_argv_db(s, None)
}
pub fn connect_argv_db(&self, s: &Settings, db: Option<&str>) -> Vec<String> {
let mut argv = self.engine.client_argv(s);
match self.engine {
Engine::Pg => argv.push(pg::conninfo(&self.name, db)),
Engine::MySql => {
argv.push(format!("--defaults-group-suffix={}", self.name));
if let Some(db) = db {
argv.push(format!("--database={db}"));
}
}
Engine::Sqlite => {
if self.read_only() {
if !speaks_client_flags(self.engine, s) {
argv = vec![self.engine.default_client().to_string()];
}
argv.push("-readonly".into());
}
argv.push(
crate::ini::expand_tilde(&self.database)
.to_string_lossy()
.into_owned(),
)
}
Engine::MsSql => argv.extend(mssql::flags(self, db)),
}
argv
}
pub fn url(&self) -> String {
match self.engine {
Engine::Sqlite => format!("sqlite://{}", self.database),
_ => {
let scheme = match self.engine {
Engine::Pg => "postgresql",
Engine::MsSql => "sqlserver",
_ => "mysql",
};
let host = if self.host.is_empty() {
"localhost"
} else {
&self.host
};
let user = match self.user.is_empty() {
true => String::new(),
false => format!("{}@", self.user),
};
let db = match self.database.is_empty() {
true => String::new(),
false => format!("/{}", self.database),
};
format!("{scheme}://{user}{host}:{}{db}", self.port_or_default())
}
}
}
}
pub struct NewConn {
pub engine: Engine,
pub name: String,
pub host: String,
pub port: String,
pub database: String,
pub user: String,
pub extra: Vec<(String, String)>,
}
pub fn list() -> Vec<Conn> {
let mut all = pg::list();
all.extend(mysql::list());
all.extend(sqlite::list());
all.extend(mssql::list());
all.sort_by(|a, b| (a.engine.idx(), a.name.as_str()).cmp(&(b.engine.idx(), b.name.as_str())));
all
}
fn find_among(all: Vec<Conn>, needle: &str) -> Result<Conn> {
if let Some((slug, name)) = needle.split_once(':')
&& let Some(engine) = Engine::from_slug(slug)
{
return all
.into_iter()
.find(|c| c.engine == engine && c.name == name)
.ok_or_else(|| anyhow::anyhow!("no {} connection named '{name}'", engine.label()));
}
let hits: Vec<Conn> = all.into_iter().filter(|c| c.name == needle).collect();
match hits.len() {
0 => anyhow::bail!("no saved connection named '{needle}' (try `esql ls`)"),
1 => Ok(hits.into_iter().next().unwrap()),
_ => {
let names: Vec<String> = hits.iter().map(|c| c.key()).collect();
anyhow::bail!(
"'{needle}' is defined for several engines: {}. Name one of them.",
names.join(", ")
)
}
}
}
pub fn find_target(needle: &str) -> Result<(Conn, Option<String>)> {
find_target_among(list(), needle)
}
fn find_target_among(all: Vec<Conn>, needle: &str) -> Result<(Conn, Option<String>)> {
match find_among(all.clone(), needle) {
Ok(c) => Ok((c, None)),
Err(e) if all.iter().any(|c| c.name == needle) => Err(e),
Err(e) => match needle.rsplit_once('/') {
Some((name, db)) if !name.is_empty() && !db.is_empty() => {
Ok((find_among(all, name)?, Some(db.to_string())))
}
_ => Err(e),
},
}
}
pub fn save(original: Option<&str>, nc: &NewConn) -> Result<()> {
match nc.engine {
Engine::Pg => pg::save(original, nc),
Engine::MySql => mysql::save(original, nc),
Engine::Sqlite => sqlite::save(original, nc),
Engine::MsSql => mssql::save(original, nc),
}
}
pub fn delete(c: &Conn) -> Result<()> {
match c.engine {
Engine::Pg => pg::delete(&c.name),
Engine::MySql => mysql::delete(&c.name),
Engine::Sqlite => sqlite::delete(&c.name),
Engine::MsSql => mssql::delete(&c.name),
}
}
pub fn client_installed(engine: Engine, s: &Settings) -> bool {
let argv = engine.client_argv(s);
on_path(&argv[0])
}
pub fn on_path(program: &str) -> bool {
if program.contains('/') {
return std::path::Path::new(program).is_file();
}
std::env::var_os("PATH")
.map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(program).is_file()))
.unwrap_or(false)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum PackageManager {
Apt,
Pacman,
Dnf,
Zypper,
Apk,
}
impl PackageManager {
const ALL: [PackageManager; 5] = [
PackageManager::Apt,
PackageManager::Pacman,
PackageManager::Dnf,
PackageManager::Zypper,
PackageManager::Apk,
];
fn program(self) -> &'static str {
match self {
PackageManager::Apt => "apt-get",
PackageManager::Pacman => "pacman",
PackageManager::Dnf => "dnf",
PackageManager::Zypper => "zypper",
PackageManager::Apk => "apk",
}
}
fn verb(self) -> &'static str {
match self {
PackageManager::Pacman => "-S",
PackageManager::Apk => "add",
_ => "install",
}
}
fn detect() -> Option<Self> {
Self::ALL.into_iter().find(|m| on_path(m.program()))
}
}
fn package(manager: PackageManager, engine: Engine) -> Option<&'static str> {
use Engine::{MsSql, MySql, Pg, Sqlite};
use PackageManager::{Apk, Apt, Dnf, Pacman, Zypper};
match (manager, engine) {
(Apt, Pg) => Some("postgresql-client"),
(Apt, MySql) => Some("mariadb-client"),
(Apt, Sqlite) => Some("sqlite3"),
(Pacman, Pg) => Some("postgresql"),
(Pacman, MySql) => Some("mariadb-clients"),
(Pacman, Sqlite) => Some("sqlite"),
(Dnf, Pg) => Some("postgresql"),
(Dnf, MySql) => Some("mariadb"),
(Dnf, Sqlite) => Some("sqlite"),
(Zypper, Pg) => Some("postgresql"),
(Zypper, MySql) => Some("mariadb-client"),
(Zypper, Sqlite) => Some("sqlite3"),
(Apk, Pg) => Some("postgresql-client"),
(Apk, MySql) => Some("mariadb-client"),
(Apk, Sqlite) => Some("sqlite"),
(Apt, MsSql) | (Pacman, MsSql) | (Dnf, MsSql) | (Zypper, MsSql) | (Apk, MsSql) => None,
}
}
pub fn install_argv(engine: Engine) -> Option<Vec<String>> {
let manager = PackageManager::detect()?;
let pkg = package(manager, engine)?;
Some(vec![
"sudo".to_string(),
manager.program().to_string(),
manager.verb().to_string(),
pkg.to_string(),
])
}
pub fn hint_line(engine: Engine, s: &Settings) -> String {
hint_line_from(engine, s, &crate::snippets::list())
}
pub fn speaks_client_flags(engine: Engine, s: &Settings) -> bool {
engine.client_argv(s).iter().any(|w| {
std::path::Path::new(w)
.file_name()
.is_some_and(|f| f == engine.default_client())
})
}
fn hint_line_from(engine: Engine, s: &Settings, snips: &[crate::snippets::Snippet]) -> String {
let base = engine.hint().to_string();
let is_psql = engine
.client_argv(s)
.first()
.and_then(|p| std::path::Path::new(p).file_name())
.is_some_and(|p| p == engine.default_client());
if engine != Engine::Pg || !is_psql {
return base;
}
let names: Vec<String> = snips.iter().map(|s| format!(":{}", s.name)).collect();
match names.is_empty() {
true => base,
false => format!("{base}\n {}", names.join(" · ")),
}
}
pub fn install_package(engine: Engine) -> Option<&'static str> {
package(PackageManager::detect()?, engine)
}
pub fn install_hint(engine: Engine) -> String {
match install_argv(engine) {
Some(argv) => argv.join(" "),
None => match install_note(engine) {
Some(note) => note.to_string(),
None => format!("install your distro's {} package", engine.default_client()),
},
}
}
pub fn install_note(engine: Engine) -> Option<&'static str> {
match engine {
Engine::MsSql => Some(
"sqlcmd is not in any distro's repos: get it from https://aka.ms/go-sqlcmd, or add Microsoft's repo for mssql-tools18 (which installs to /opt/mssql-tools18/bin, not on PATH)",
),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn conn(engine: Engine, name: &str) -> Conn {
Conn {
engine,
name: name.to_string(),
host: "db.example.com".into(),
port: "5432".into(),
database: "app".into(),
user: "me".into(),
extra: Vec::new(),
}
}
#[test]
fn key_is_slug_and_name_so_two_engines_may_share_a_name() {
assert_eq!(conn(Engine::Pg, "prod").key(), "pg:prod");
assert_eq!(conn(Engine::MySql, "prod").key(), "my:prod");
assert_eq!(conn(Engine::Sqlite, "prod").key(), "lite:prod");
assert_eq!(conn(Engine::MsSql, "prod").key(), "ms:prod");
}
#[test]
fn each_engine_is_opened_through_the_file_its_client_already_reads() {
let s = Settings::default();
assert_eq!(
conn(Engine::Pg, "prod").connect_argv(&s),
vec!["psql", "service=prod"]
);
assert_eq!(
conn(Engine::MySql, "prod").connect_argv(&s),
vec!["mysql", "--defaults-group-suffix=prod"]
);
}
#[test]
fn sqlite_is_handed_an_expanded_path_because_children_run_without_a_shell() {
let mut c = conn(Engine::Sqlite, "notes");
c.database = "~/notes.sqlite".into();
let argv = c.connect_argv(&Settings::default());
let home = dirs::home_dir().unwrap_or_default();
assert_eq!(argv[0], "sqlite3");
assert_eq!(argv[1], home.join("notes.sqlite").to_string_lossy());
assert!(
!argv[1].starts_with('~'),
"a literal ~ reaches sqlite3 as a directory name"
);
}
#[test]
fn sqlcmd_takes_host_and_port_separated_by_a_comma_not_a_colon() {
let argv = conn(Engine::MsSql, "prod").connect_argv(&Settings::default());
assert_eq!(
argv,
vec![
"sqlcmd",
"-S",
"db.example.com,5432",
"-d",
"app",
"-U",
"me"
]
);
assert!(!argv.iter().any(|a| a == "-P"));
}
#[test]
fn a_wrapper_client_with_its_own_arguments_survives_the_split() {
let s = Settings {
psql_command: "docker exec -it db psql".into(),
..Settings::default()
};
assert_eq!(
conn(Engine::Pg, "prod").connect_argv(&s),
vec!["docker", "exec", "-it", "db", "psql", "service=prod"]
);
}
#[test]
fn only_sql_server_refuses_to_store_a_password() {
assert!(Engine::Pg.stores_password());
assert!(Engine::MySql.stores_password());
assert!(!Engine::MsSql.stores_password());
}
#[test]
fn snippets_are_only_advertised_to_a_client_that_can_expand_them() {
let snips = vec![crate::snippets::Snippet {
name: "tables".into(),
sql: "select 1;".into(),
path: std::path::PathBuf::new(),
}];
let line = |cmd: &str| {
let s = Settings {
psql_command: cmd.into(),
..Default::default()
};
hint_line_from(Engine::Pg, &s, &snips)
};
assert!(line("psql").contains(":tables"));
assert!(line("/usr/bin/psql").contains(":tables"));
assert!(line("").contains(":tables"));
assert_eq!(line("pgcli"), Engine::Pg.hint());
assert_eq!(line("docker exec -it db psql"), Engine::Pg.hint());
let s = Settings::default();
for e in [Engine::MySql, Engine::Sqlite, Engine::MsSql] {
assert_eq!(hint_line_from(e, &s, &snips), e.hint());
}
}
#[test]
fn from_slug_accepts_both_the_slug_and_the_label() {
assert_eq!(Engine::from_slug("pg"), Some(Engine::Pg));
assert_eq!(Engine::from_slug("postgres"), Some(Engine::Pg));
assert_eq!(Engine::from_slug("nope"), None);
}
fn read_only(mut c: Conn) -> Conn {
match c.engine {
Engine::Pg => pg::set_read_only(&mut c.extra, true),
_ => c.extra.push(("readonly".into(), "yes".into())),
}
c
}
fn client(engine: Engine, command: &str) -> Settings {
let mut s = Settings::default();
s.set(engine.client_setting(), command);
s
}
#[test]
fn a_wrapper_around_psql_speaks_its_flags_and_pgcli_does_not() {
for speaks in ["psql", "/usr/bin/psql", "docker exec -it db psql"] {
assert!(
speaks_client_flags(Engine::Pg, &client(Engine::Pg, speaks)),
"`{speaks}` takes psql's -c"
);
}
for foreign in ["pgcli", "usql"] {
assert!(
!speaks_client_flags(Engine::Pg, &client(Engine::Pg, foreign)),
"`{foreign}` would be handed a flag it rejects"
);
}
}
#[test]
fn pgoptions_goes_to_a_client_other_than_psql_and_only_on_read_only() {
let ro = read_only(conn(Engine::Pg, "prod"));
let env = ro.connect_env(&client(Engine::Pg, "pgcli"));
assert!(
env.iter()
.any(|(k, v)| k == "PGOPTIONS" && v.contains("default_transaction_read_only=on")),
"pgcli drops the service's options, so the setting must travel in PGOPTIONS: {env:?}"
);
assert!(
ro.connect_env(&client(Engine::Pg, "psql")).is_empty(),
"psql applies the service's options itself"
);
assert!(
conn(Engine::Pg, "prod")
.connect_env(&client(Engine::Pg, "pgcli"))
.is_empty(),
"a writable connection gets nothing"
);
}
#[test]
fn a_read_only_sqlite_connection_runs_sqlite3_whatever_client_is_set() {
let mut c = read_only(conn(Engine::Sqlite, "notes"));
c.database = "/tmp/notes.db".into();
let argv = c.connect_argv(&client(Engine::Sqlite, "litecli"));
assert_eq!(
argv,
vec!["sqlite3", "-readonly", "/tmp/notes.db"],
"litecli rejects -readonly, so it must not be the one handed it"
);
let mut writable = conn(Engine::Sqlite, "notes");
writable.database = "/tmp/notes.db".into();
assert_eq!(
writable.connect_argv(&client(Engine::Sqlite, "litecli"))[0],
"litecli",
"a writable connection still opens in the configured client"
);
}
#[test]
fn an_ambiguous_name_is_refused_rather_than_split() {
let named = |engine, name: &str, host: &str| Conn {
host: host.into(),
..conn(engine, name)
};
let all = vec![
named(Engine::Pg, "team", "other.example"),
named(Engine::Pg, "team/app", "right.example"),
named(Engine::Sqlite, "team/app", ""),
];
assert!(
find_target_among(all.clone(), "team/app").is_err(),
"two engines own `team/app`, so it must not become `team` plus database `app`"
);
let (c, db) = find_target_among(all, "pg:team/app").expect("the engine settles it");
assert_eq!((c.host.as_str(), db), ("right.example", None));
let (c, db) = find_target_among(vec![conn(Engine::Pg, "prod")], "prod/reporting")
.expect("an unambiguous name still takes a database");
assert_eq!(
(c.name.as_str(), db.as_deref()),
("prod", Some("reporting"))
);
}
}