chore: bootstrap independent git workflow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
13
crates/db-config/Cargo.toml
Normal file
13
crates/db-config/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "db-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
db-core.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
123
crates/db-config/src/lib.rs
Normal file
123
crates/db-config/src/lib.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use db_core::{ConnectionTarget, CoreError};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConnectionProfile {
|
||||
pub name: String,
|
||||
pub target: ConnectionTarget,
|
||||
pub password_env_var: Option<String>,
|
||||
}
|
||||
|
||||
impl ConnectionProfile {
|
||||
pub fn new(
|
||||
name: impl Into<String>,
|
||||
target: ConnectionTarget,
|
||||
password_env_var: Option<String>,
|
||||
) -> Result<Self, CoreError> {
|
||||
let profile = Self {
|
||||
name: name.into(),
|
||||
target,
|
||||
password_env_var,
|
||||
};
|
||||
|
||||
profile.validate()?;
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), CoreError> {
|
||||
if self.name.trim().is_empty() {
|
||||
return Err(CoreError::InvalidConnection("profile name is empty"));
|
||||
}
|
||||
|
||||
self.target.validate()
|
||||
}
|
||||
|
||||
pub fn redacted_summary(&self) -> String {
|
||||
match &self.password_env_var {
|
||||
Some(password_env_var) => format!(
|
||||
"{} via password env {}",
|
||||
self.target.redacted_endpoint(),
|
||||
password_env_var
|
||||
),
|
||||
None => self.target.redacted_endpoint(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_password(mut self, password: Option<String>) -> Self {
|
||||
self.target.password = password;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use db_core::{ConnectionTarget, ConnectionTransport, DatabaseKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_profile_name() {
|
||||
let target = ConnectionTarget::new(
|
||||
DatabaseKind::Sqlite,
|
||||
ConnectionTransport::File {
|
||||
path: "demo.sqlite".into(),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("target should be valid");
|
||||
|
||||
let result = ConnectionProfile::new(" ", target, None);
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(CoreError::InvalidConnection("profile name is empty"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_mentions_password_env_without_secret() {
|
||||
let target = ConnectionTarget::new(
|
||||
DatabaseKind::Postgres,
|
||||
ConnectionTransport::Tcp {
|
||||
host: String::from("127.0.0.1"),
|
||||
port: 55432,
|
||||
},
|
||||
Some(String::from("dbtool_demo")),
|
||||
Some(String::from("dbtool")),
|
||||
)
|
||||
.expect("target should be valid");
|
||||
let profile =
|
||||
ConnectionProfile::new("local", target, Some(String::from("DBTOOL_PASSWORD")))
|
||||
.expect("profile should be valid");
|
||||
|
||||
assert_eq!(
|
||||
profile.redacted_summary(),
|
||||
"postgres://dbtool@127.0.0.1:55432/dbtool_demo via password env DBTOOL_PASSWORD"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_password_without_changing_redacted_output() {
|
||||
let target = ConnectionTarget::new(
|
||||
DatabaseKind::Postgres,
|
||||
ConnectionTransport::Tcp {
|
||||
host: String::from("127.0.0.1"),
|
||||
port: 55432,
|
||||
},
|
||||
Some(String::from("dbtool_demo")),
|
||||
Some(String::from("dbtool")),
|
||||
)
|
||||
.expect("target should be valid");
|
||||
|
||||
let profile =
|
||||
ConnectionProfile::new("local", target, Some(String::from("DBTOOL_PASSWORD")))
|
||||
.expect("profile should be valid")
|
||||
.with_password(Some(String::from("secret")));
|
||||
|
||||
assert_eq!(profile.target.password.as_deref(), Some("secret"));
|
||||
assert_eq!(
|
||||
profile.redacted_summary(),
|
||||
"postgres://dbtool@127.0.0.1:55432/dbtool_demo via password env DBTOOL_PASSWORD"
|
||||
);
|
||||
}
|
||||
}
|
||||
9
crates/db-core/Cargo.toml
Normal file
9
crates/db-core/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "db-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
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"
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
15
crates/db-drivers/Cargo.toml
Normal file
15
crates/db-drivers/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "db-drivers"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
db-core.workspace = true
|
||||
mysql.workspace = true
|
||||
postgres.workspace = true
|
||||
rusqlite.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
1110
crates/db-drivers/src/lib.rs
Normal file
1110
crates/db-drivers/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user