chore: bootstrap independent git workflow
Some checks failed
release-smoke / macos-13 / x86_64-apple-darwin (push) Has been cancelled
release-smoke / ubuntu-latest / x86_64-unknown-linux-gnu (push) Has been cancelled
release-smoke / windows-latest / x86_64-pc-windows-msvc (push) Has been cancelled

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip CTO
2026-03-26 03:49:06 +00:00
commit 7424491944
60 changed files with 7793 additions and 0 deletions

384
crates/db-core/src/lib.rs Normal file
View 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"
))
);
}
}