441 lines
11 KiB
Rust
441 lines
11 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
use std::{error::Error, fmt};
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DatabaseKind {
|
|
Postgres,
|
|
Mysql,
|
|
Sqlite,
|
|
}
|
|
|
|
impl DatabaseKind {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Postgres => "postgres",
|
|
Self::Mysql => "mysql",
|
|
Self::Sqlite => "sqlite",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
pub enum ConnectionTransport {
|
|
Tcp { host: String, port: u16 },
|
|
File { path: PathBuf },
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ConnectionTarget {
|
|
pub kind: DatabaseKind,
|
|
pub transport: ConnectionTransport,
|
|
pub database: Option<String>,
|
|
pub username: Option<String>,
|
|
#[serde(skip, default)]
|
|
pub password: Option<String>,
|
|
}
|
|
|
|
impl ConnectionTarget {
|
|
pub fn new(
|
|
kind: DatabaseKind,
|
|
transport: ConnectionTransport,
|
|
database: Option<String>,
|
|
username: Option<String>,
|
|
) -> Result<Self, CoreError> {
|
|
let target = Self {
|
|
kind,
|
|
transport,
|
|
database,
|
|
username,
|
|
password: None,
|
|
};
|
|
|
|
target.validate()?;
|
|
Ok(target)
|
|
}
|
|
|
|
pub fn validate(&self) -> Result<(), CoreError> {
|
|
match (&self.kind, &self.transport) {
|
|
(DatabaseKind::Sqlite, ConnectionTransport::File { path }) => {
|
|
if path.as_os_str().is_empty() {
|
|
return Err(CoreError::InvalidConnection("sqlite path is empty"));
|
|
}
|
|
}
|
|
(DatabaseKind::Sqlite, ConnectionTransport::Tcp { .. }) => {
|
|
return Err(CoreError::InvalidConnection(
|
|
"sqlite must use a filesystem path",
|
|
));
|
|
}
|
|
(_, ConnectionTransport::File { .. }) => {
|
|
return Err(CoreError::InvalidConnection(
|
|
"server databases must use host and port",
|
|
));
|
|
}
|
|
(_, ConnectionTransport::Tcp { host, port }) => {
|
|
if host.trim().is_empty() {
|
|
return Err(CoreError::InvalidConnection("host is empty"));
|
|
}
|
|
if *port == 0 {
|
|
return Err(CoreError::InvalidConnection("port must be non-zero"));
|
|
}
|
|
}
|
|
}
|
|
|
|
if self.kind != DatabaseKind::Sqlite
|
|
&& self
|
|
.database
|
|
.as_deref()
|
|
.is_none_or(|database| database.trim().is_empty())
|
|
{
|
|
return Err(CoreError::InvalidConnection(
|
|
"server databases require a database name",
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn redacted_endpoint(&self) -> String {
|
|
match &self.transport {
|
|
ConnectionTransport::Tcp { host, port } => {
|
|
let database = self.database.as_deref().unwrap_or("<none>");
|
|
match self.username.as_deref() {
|
|
Some(username) => {
|
|
format!(
|
|
"{}://{}@{}:{}/{}",
|
|
self.kind.as_str(),
|
|
username,
|
|
host,
|
|
port,
|
|
database
|
|
)
|
|
}
|
|
None => format!("{}://{}:{}/{}", self.kind.as_str(), host, port, database),
|
|
}
|
|
}
|
|
ConnectionTransport::File { path } => {
|
|
format!("sqlite://{}", path.display())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct InspectRequest {
|
|
pub schema: Option<String>,
|
|
pub table: Option<String>,
|
|
}
|
|
|
|
impl InspectRequest {
|
|
pub fn new(schema: Option<String>, table: Option<String>) -> Result<Self, CoreError> {
|
|
let request = Self { schema, table };
|
|
request.validate()?;
|
|
Ok(request)
|
|
}
|
|
|
|
pub fn validate(&self) -> Result<(), CoreError> {
|
|
if self.table.is_some() && self.schema.is_none() {
|
|
return Err(CoreError::InvalidInspect(
|
|
"table inspection requires --schema",
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum SchemaAvailability {
|
|
Ready,
|
|
Restricted,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct SchemaSummary {
|
|
pub name: String,
|
|
pub availability: SchemaAvailability,
|
|
pub note: Option<String>,
|
|
}
|
|
|
|
impl SchemaSummary {
|
|
pub fn ready(name: impl Into<String>) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
availability: SchemaAvailability::Ready,
|
|
note: None,
|
|
}
|
|
}
|
|
|
|
pub fn restricted(name: impl Into<String>, note: impl Into<String>) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
availability: SchemaAvailability::Restricted,
|
|
note: Some(note.into()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct TableSummary {
|
|
pub schema: String,
|
|
pub name: String,
|
|
pub kind: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct ColumnSummary {
|
|
pub name: String,
|
|
pub data_type: String,
|
|
pub nullable: bool,
|
|
pub primary_key: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum InspectResult {
|
|
Schemas(Vec<SchemaSummary>),
|
|
Tables(Vec<TableSummary>),
|
|
Columns(Vec<ColumnSummary>),
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum QuerySource {
|
|
Inline,
|
|
File(PathBuf),
|
|
}
|
|
|
|
impl QuerySource {
|
|
pub fn label(&self) -> &'static str {
|
|
match self {
|
|
Self::Inline => "inline SQL",
|
|
Self::File(_) => "SQL file",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct QueryRequest {
|
|
pub sql: String,
|
|
pub source: QuerySource,
|
|
pub parameters: Vec<String>,
|
|
}
|
|
|
|
impl QueryRequest {
|
|
pub fn new(
|
|
sql: String,
|
|
source: QuerySource,
|
|
parameters: Vec<String>,
|
|
) -> Result<Self, CoreError> {
|
|
let request = Self {
|
|
sql,
|
|
source,
|
|
parameters,
|
|
};
|
|
request.validate()?;
|
|
Ok(request)
|
|
}
|
|
|
|
pub fn validate(&self) -> Result<(), CoreError> {
|
|
if self.sql.trim().is_empty() {
|
|
return Err(CoreError::EmptyQuery);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct QueryResult {
|
|
pub columns: Vec<String>,
|
|
pub rows: Vec<Vec<String>>,
|
|
pub rows_affected: Option<u64>,
|
|
}
|
|
|
|
impl QueryResult {
|
|
pub fn rows(columns: Vec<String>, rows: Vec<Vec<String>>) -> Self {
|
|
Self {
|
|
columns,
|
|
rows,
|
|
rows_affected: None,
|
|
}
|
|
}
|
|
|
|
pub fn command(rows_affected: u64) -> Self {
|
|
Self {
|
|
columns: Vec::new(),
|
|
rows: Vec::new(),
|
|
rows_affected: Some(rows_affected),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum ExportFormat {
|
|
Csv,
|
|
Json,
|
|
}
|
|
|
|
impl ExportFormat {
|
|
pub fn file_extension(self) -> &'static str {
|
|
match self {
|
|
Self::Csv => "csv",
|
|
Self::Json => "json",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct ExportRequest {
|
|
pub format: ExportFormat,
|
|
pub output_path: PathBuf,
|
|
pub overwrite: bool,
|
|
}
|
|
|
|
impl ExportRequest {
|
|
pub fn new(
|
|
format: ExportFormat,
|
|
output_path: PathBuf,
|
|
overwrite: bool,
|
|
) -> Result<Self, CoreError> {
|
|
let request = Self {
|
|
format,
|
|
output_path,
|
|
overwrite,
|
|
};
|
|
|
|
request.validate()?;
|
|
Ok(request)
|
|
}
|
|
|
|
pub fn validate(&self) -> Result<(), CoreError> {
|
|
if self.output_path.file_name().is_none() {
|
|
return Err(CoreError::InvalidExport(
|
|
"output path must include a filename",
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum CoreError {
|
|
InvalidConnection(&'static str),
|
|
InvalidInspect(&'static str),
|
|
InvalidQuery(&'static str),
|
|
EmptyQuery,
|
|
InvalidExport(&'static str),
|
|
}
|
|
|
|
impl fmt::Display for CoreError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::InvalidConnection(message) => {
|
|
write!(formatter, "connection target is invalid: {message}")
|
|
}
|
|
Self::InvalidInspect(message) => {
|
|
write!(formatter, "inspect request is invalid: {message}")
|
|
}
|
|
Self::InvalidQuery(message) => {
|
|
write!(formatter, "query request is invalid: {message}")
|
|
}
|
|
Self::EmptyQuery => write!(formatter, "query text is empty"),
|
|
Self::InvalidExport(message) => {
|
|
write!(formatter, "export request is invalid: {message}")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Error for CoreError {}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn rejects_server_target_without_database() {
|
|
let result = ConnectionTarget::new(
|
|
DatabaseKind::Postgres,
|
|
ConnectionTransport::Tcp {
|
|
host: String::from("127.0.0.1"),
|
|
port: 5432,
|
|
},
|
|
None,
|
|
Some(String::from("dbtool")),
|
|
);
|
|
|
|
assert_eq!(
|
|
result,
|
|
Err(CoreError::InvalidConnection(
|
|
"server databases require a database name"
|
|
))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn redacts_target_without_password() {
|
|
let target = ConnectionTarget::new(
|
|
DatabaseKind::Mysql,
|
|
ConnectionTransport::Tcp {
|
|
host: String::from("db.internal"),
|
|
port: 3306,
|
|
},
|
|
Some(String::from("qa_demo")),
|
|
Some(String::from("dbtool")),
|
|
)
|
|
.expect("target should be valid");
|
|
|
|
assert_eq!(
|
|
target.redacted_endpoint(),
|
|
"mysql://dbtool@db.internal:3306/qa_demo"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_empty_query() {
|
|
let result = QueryRequest::new(String::from(" "), QuerySource::Inline, Vec::new());
|
|
|
|
assert_eq!(result, Err(CoreError::EmptyQuery));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_table_without_schema() {
|
|
let result = InspectRequest::new(None, Some(String::from("accounts")));
|
|
|
|
assert_eq!(
|
|
result,
|
|
Err(CoreError::InvalidInspect(
|
|
"table inspection requires --schema"
|
|
))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn connection_target_serialization_omits_password() {
|
|
let mut target = ConnectionTarget::new(
|
|
DatabaseKind::Postgres,
|
|
ConnectionTransport::Tcp {
|
|
host: String::from("127.0.0.1"),
|
|
port: 5432,
|
|
},
|
|
Some(String::from("qa_demo")),
|
|
Some(String::from("dbtool")),
|
|
)
|
|
.expect("target should be valid");
|
|
target.password = Some(String::from("secret"));
|
|
|
|
let json = serde_json::to_string(&target).expect("target should serialize");
|
|
|
|
assert!(!json.contains("secret"));
|
|
assert!(!json.contains("password"));
|
|
|
|
let restored: ConnectionTarget =
|
|
serde_json::from_str(&json).expect("target should deserialize");
|
|
|
|
assert_eq!(restored.password, None);
|
|
assert_eq!(restored.kind, DatabaseKind::Postgres);
|
|
}
|
|
}
|