chore: bootstrap independent git workflow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
384
crates/db-core/src/lib.rs
Normal file
384
crates/db-core/src/lib.rs
Normal file
@@ -0,0 +1,384 @@
|
||||
use std::path::PathBuf;
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
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)]
|
||||
pub enum ConnectionTransport {
|
||||
Tcp { host: String, port: u16 },
|
||||
File { path: PathBuf },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConnectionTarget {
|
||||
pub kind: DatabaseKind,
|
||||
pub transport: ConnectionTransport,
|
||||
pub database: Option<String>,
|
||||
pub username: Option<String>,
|
||||
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 struct SchemaSummary {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[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"
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user