feat: add dbtool tui live query workspace
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

- add shared db-app layer for connect/inspect/query/export events
- add dbtool-tui workspace with sqlite-local live flow and QA docs
- include host-validated acceptance updates for CMP-36

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip CTO
2026-03-27 17:17:20 +00:00
parent 7424491944
commit 19aeb7784b
12 changed files with 5006 additions and 4 deletions

19
crates/db-app/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "db-app"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish = false
[dependencies]
db-config.workspace = true
db-core.workspace = true
db-drivers.workspace = true
serde = { version = "1.0.228", features = ["derive"] }
[dev-dependencies]
rusqlite.workspace = true
serde_json = "1.0.145"
[lints]
workspace = true

812
crates/db-app/src/lib.rs Normal file
View File

@@ -0,0 +1,812 @@
use db_config::ConnectionProfile;
use db_core::{
ColumnSummary, CoreError, ExportFormat, ExportRequest, InspectRequest, InspectResult,
QueryRequest, QueryResult, QuerySource, SchemaSummary, TableSummary,
};
use db_drivers::{DriverError, DriverRegistry};
use serde::Serialize;
use std::{error::Error, fmt, fs};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AppOperation {
Connect,
Inspect,
Query,
Export,
}
impl AppOperation {
pub fn as_str(self) -> &'static str {
match self {
Self::Connect => "connect",
Self::Inspect => "inspect",
Self::Query => "query",
Self::Export => "export",
}
}
fn from_name(name: &'static str) -> Self {
match name {
"connect" => Self::Connect,
"inspect" => Self::Inspect,
"query" => Self::Query,
"export" => Self::Export,
other => panic!("unsupported app operation: {other}"),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OperationState {
Running,
Success,
Empty,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AppEvent<T> {
pub operation: AppOperation,
pub state: OperationState,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<T>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<AppError>,
}
impl<T> AppEvent<T> {
pub fn running(operation: AppOperation) -> Self {
Self {
operation,
state: OperationState::Running,
data: None,
error: None,
}
}
pub fn finished(operation: AppOperation, state: OperationState, data: T) -> Self {
Self {
operation,
state,
data: Some(data),
error: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ConnectionSummary {
pub profile_name: String,
pub driver: String,
pub endpoint: String,
pub password_env_var: Option<String>,
}
impl From<&ConnectionProfile> for ConnectionSummary {
fn from(profile: &ConnectionProfile) -> Self {
Self {
profile_name: profile.name.clone(),
driver: profile.target.kind.as_str().to_string(),
endpoint: profile.target.redacted_endpoint(),
password_env_var: profile.password_env_var.clone(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ConnectResponse {
pub target: ConnectionSummary,
pub status: &'static str,
}
impl ConnectResponse {
pub fn operation(&self) -> AppOperation {
AppOperation::Connect
}
pub fn state(&self) -> OperationState {
OperationState::Success
}
pub fn into_event(self) -> AppEvent<Self> {
AppEvent::finished(self.operation(), self.state(), self)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct InspectScope {
pub schema: Option<String>,
pub table: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct InspectResponse {
pub target: ConnectionSummary,
pub scope: InspectScope,
pub payload: InspectPayload,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", content = "items", rename_all = "snake_case")]
pub enum InspectPayload {
Schemas(Vec<SchemaItem>),
Tables(Vec<TableItem>),
Columns(Vec<ColumnItem>),
}
impl InspectPayload {
pub fn is_empty(&self) -> bool {
match self {
Self::Schemas(items) => items.is_empty(),
Self::Tables(items) => items.is_empty(),
Self::Columns(items) => items.is_empty(),
}
}
}
impl InspectResponse {
pub fn operation(&self) -> AppOperation {
AppOperation::Inspect
}
pub fn state(&self) -> OperationState {
if self.payload.is_empty() {
OperationState::Empty
} else {
OperationState::Success
}
}
pub fn into_event(self) -> AppEvent<Self> {
AppEvent::finished(self.operation(), self.state(), self)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct SchemaItem {
pub name: String,
}
impl From<SchemaSummary> for SchemaItem {
fn from(value: SchemaSummary) -> Self {
Self { name: value.name }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct TableItem {
pub schema: String,
pub name: String,
pub kind: String,
}
impl From<TableSummary> for TableItem {
fn from(value: TableSummary) -> Self {
Self {
schema: value.schema,
name: value.name,
kind: value.kind,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ColumnItem {
pub name: String,
pub data_type: String,
pub nullable: bool,
pub primary_key: bool,
}
impl From<ColumnSummary> for ColumnItem {
fn from(value: ColumnSummary) -> Self {
Self {
name: value.name,
data_type: value.data_type,
nullable: value.nullable,
primary_key: value.primary_key,
}
}
}
impl From<InspectResult> for InspectPayload {
fn from(value: InspectResult) -> Self {
match value {
InspectResult::Schemas(items) => {
Self::Schemas(items.into_iter().map(SchemaItem::from).collect())
}
InspectResult::Tables(items) => {
Self::Tables(items.into_iter().map(TableItem::from).collect())
}
InspectResult::Columns(items) => {
Self::Columns(items.into_iter().map(ColumnItem::from).collect())
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct QuerySourceSummary {
pub kind: &'static str,
pub label: &'static str,
pub path: Option<String>,
}
impl From<&QuerySource> for QuerySourceSummary {
fn from(value: &QuerySource) -> Self {
match value {
QuerySource::Inline => Self {
kind: "inline",
label: value.label(),
path: None,
},
QuerySource::File(path) => Self {
kind: "file",
label: value.label(),
path: Some(path.display().to_string()),
},
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct QueryDataset {
pub columns: Vec<String>,
pub rows: Vec<Vec<String>>,
pub rows_affected: Option<u64>,
pub row_count: usize,
}
impl QueryDataset {
pub fn is_empty(&self) -> bool {
self.rows_affected.is_none() && self.row_count == 0
}
}
impl From<QueryResult> for QueryDataset {
fn from(value: QueryResult) -> Self {
Self {
row_count: value.rows.len(),
columns: value.columns,
rows: value.rows,
rows_affected: value.rows_affected,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct QueryResponse {
pub target: ConnectionSummary,
pub source: QuerySourceSummary,
pub result: QueryDataset,
}
impl QueryResponse {
pub fn operation(&self) -> AppOperation {
AppOperation::Query
}
pub fn state(&self) -> OperationState {
if self.result.is_empty() {
OperationState::Empty
} else {
OperationState::Success
}
}
pub fn into_event(self) -> AppEvent<Self> {
AppEvent::finished(self.operation(), self.state(), self)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ExportSummary {
pub format: &'static str,
pub output_path: String,
pub overwrite: bool,
}
impl From<&ExportRequest> for ExportSummary {
fn from(value: &ExportRequest) -> Self {
Self {
format: export_format_name(value.format),
output_path: value.output_path.display().to_string(),
overwrite: value.overwrite,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ExportResponse {
pub target: ConnectionSummary,
pub source: QuerySourceSummary,
pub export: ExportSummary,
pub row_count: usize,
}
impl ExportResponse {
pub fn operation(&self) -> AppOperation {
AppOperation::Export
}
pub fn state(&self) -> OperationState {
OperationState::Success
}
pub fn into_event(self) -> AppEvent<Self> {
AppEvent::finished(self.operation(), self.state(), self)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AppErrorKind {
Validation,
Connection,
Authentication,
Inspect,
Query,
Export,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AppError {
pub operation: &'static str,
pub kind: AppErrorKind,
pub message: String,
pub target: Option<ConnectionSummary>,
}
impl AppError {
pub fn operation_kind(&self) -> AppOperation {
AppOperation::from_name(self.operation)
}
pub fn state(&self) -> OperationState {
OperationState::Error
}
pub fn into_event(self) -> AppEvent<()> {
AppEvent {
operation: self.operation_kind(),
state: self.state(),
data: None,
error: Some(self),
}
}
}
impl fmt::Display for AppError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl Error for AppError {}
pub fn connect(profile: &ConnectionProfile) -> Result<ConnectResponse, AppError> {
validate_profile("connect", profile)?;
DriverRegistry::connect(&profile.target)
.map_err(|error| map_driver_error("connect", profile, error))?;
Ok(ConnectResponse {
target: ConnectionSummary::from(profile),
status: "connected",
})
}
pub fn inspect(
profile: &ConnectionProfile,
request: &InspectRequest,
) -> Result<InspectResponse, AppError> {
validate_profile("inspect", profile)?;
request
.validate()
.map_err(|error| map_core_error("inspect", Some(profile), error))?;
let payload = DriverRegistry::inspect(&profile.target, request)
.map(InspectPayload::from)
.map_err(|error| map_driver_error("inspect", profile, error))?;
Ok(InspectResponse {
target: ConnectionSummary::from(profile),
scope: InspectScope {
schema: request.schema.clone(),
table: request.table.clone(),
},
payload,
})
}
pub fn query(profile: &ConnectionProfile, request: &QueryRequest) -> Result<QueryResponse, AppError> {
validate_profile("query", profile)?;
request
.validate()
.map_err(|error| map_core_error("query", Some(profile), error))?;
let result = DriverRegistry::query(&profile.target, request)
.map(QueryDataset::from)
.map_err(|error| map_driver_error("query", profile, error))?;
Ok(QueryResponse {
target: ConnectionSummary::from(profile),
source: QuerySourceSummary::from(&request.source),
result,
})
}
pub fn export(
profile: &ConnectionProfile,
request: &QueryRequest,
export: &ExportRequest,
) -> Result<ExportResponse, AppError> {
validate_profile("export", profile)?;
request
.validate()
.map_err(|error| map_core_error("export", Some(profile), error))?;
export
.validate()
.map_err(|error| map_core_error("export", Some(profile), error))?;
validate_export_path(export).map_err(|message| AppError {
operation: "export",
kind: AppErrorKind::Export,
message,
target: Some(ConnectionSummary::from(profile)),
})?;
let query_response = query(profile, request)?;
validate_export_result(&query_response.result).map_err(|message| AppError {
operation: "export",
kind: AppErrorKind::Export,
message,
target: Some(ConnectionSummary::from(profile)),
})?;
let body = render_export_body(export.format, &query_response.result);
fs::write(&export.output_path, body).map_err(|error| AppError {
operation: "export",
kind: AppErrorKind::Export,
message: format!(
"failed to write export {}: {error}",
export.output_path.display()
),
target: Some(ConnectionSummary::from(profile)),
})?;
Ok(ExportResponse {
target: query_response.target,
source: query_response.source,
export: ExportSummary::from(export),
row_count: query_response.result.row_count,
})
}
pub fn validate_export_path(export: &ExportRequest) -> Result<(), String> {
if export.output_path.exists() && !export.overwrite {
return Err(format!(
"export request is invalid: output file {} already exists; pass --overwrite to replace it",
export.output_path.display()
));
}
if export.output_path.is_dir() {
return Err(format!(
"export request is invalid: output path {} is a directory; provide a file path",
export.output_path.display()
));
}
if let Some(parent) = export.output_path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
return Err(format!(
"export request is invalid: parent directory {} does not exist; create it first or choose another output path",
parent.display()
));
}
}
Ok(())
}
pub fn validate_export_result(result: &QueryDataset) -> Result<(), String> {
if result.rows_affected.is_some() {
return Err(String::from(
"export requires a row-returning query; the provided SQL completed without a result set",
));
}
Ok(())
}
pub fn render_export_body(format: ExportFormat, result: &QueryDataset) -> String {
match format {
ExportFormat::Csv => render_csv(result),
ExportFormat::Json => render_json(result),
}
}
fn validate_profile(operation: &'static str, profile: &ConnectionProfile) -> Result<(), AppError> {
profile
.validate()
.map_err(|error| map_core_error(operation, Some(profile), error))
}
fn map_core_error(
operation: &'static str,
profile: Option<&ConnectionProfile>,
error: CoreError,
) -> AppError {
AppError {
operation,
kind: AppErrorKind::Validation,
message: error.to_string(),
target: profile.map(ConnectionSummary::from),
}
}
fn map_driver_error(
operation: &'static str,
profile: &ConnectionProfile,
error: DriverError,
) -> AppError {
let kind = match &error {
DriverError::NotImplemented(_) => AppErrorKind::Validation,
DriverError::Connection(_) => AppErrorKind::Connection,
DriverError::Authentication(_) => AppErrorKind::Authentication,
DriverError::Inspect(_) => AppErrorKind::Inspect,
DriverError::Query(_) => AppErrorKind::Query,
};
AppError {
operation,
kind,
message: error.to_string(),
target: Some(ConnectionSummary::from(profile)),
}
}
fn export_format_name(format: ExportFormat) -> &'static str {
match format {
ExportFormat::Csv => "csv",
ExportFormat::Json => "json",
}
}
fn render_csv(result: &QueryDataset) -> String {
let mut lines = Vec::new();
lines.push(result.columns.join(","));
lines.extend(result.rows.iter().map(|row| {
row.iter()
.map(|value| escape_csv(value))
.collect::<Vec<_>>()
.join(",")
}));
if lines.is_empty() {
String::new()
} else {
format!("{}\n", lines.join("\n"))
}
}
fn render_json(result: &QueryDataset) -> String {
let rows = result
.rows
.iter()
.map(|row| {
let fields = result
.columns
.iter()
.zip(row.iter())
.map(|(column, value)| {
format!("\"{}\":\"{}\"", escape_json(column), escape_json(value))
})
.collect::<Vec<_>>()
.join(",");
format!(" {{{fields}}}")
})
.collect::<Vec<_>>()
.join(",\n");
if rows.is_empty() {
String::from("[]\n")
} else {
format!("[\n{rows}\n]\n")
}
}
fn escape_csv(value: &str) -> String {
if value.contains(',') || value.contains('"') || value.contains('\n') {
format!("\"{}\"", value.replace('"', "\"\""))
} else {
value.to_string()
}
}
fn escape_json(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
}
#[cfg(test)]
mod tests {
use super::*;
use db_core::{ConnectionTarget, ConnectionTransport, DatabaseKind};
use rusqlite::Connection as SqliteConnection;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{env, fs};
#[test]
fn sqlite_query_returns_structured_result() {
let path = temp_sqlite_db_path("dbtool-app-query");
let connection =
SqliteConnection::open(&path).expect("temporary sqlite database should open");
connection
.execute_batch(include_str!("../../../examples/fixtures/sqlite/init.sql"))
.expect("sqlite fixture should seed");
drop(connection);
let target = ConnectionTarget::new(
DatabaseKind::Sqlite,
ConnectionTransport::File { path: path.clone() },
None,
None,
)
.expect("sqlite target should be valid");
let profile =
ConnectionProfile::new("qa-demo", target, None).expect("profile should be valid");
let request = QueryRequest::new(
String::from("SELECT COUNT(*) AS count FROM tickets WHERE status = ?1"),
QuerySource::Inline,
vec![String::from("open")],
)
.expect("request should be valid");
let response = query(&profile, &request).expect("query should succeed");
assert_eq!(response.target.driver, "sqlite");
assert_eq!(response.source.kind, "inline");
assert_eq!(response.result.columns, vec![String::from("count")]);
assert_eq!(response.result.rows, vec![vec![String::from("3")]]);
assert_eq!(response.result.row_count, 1);
fs::remove_file(path).expect("temporary sqlite database should be removed");
}
#[test]
fn export_body_json_escapes_quotes_and_newlines() {
let body = render_export_body(
ExportFormat::Json,
&QueryDataset {
columns: vec![String::from("title"), String::from("notes")],
rows: vec![vec![
String::from("csv export mismatch"),
String::from("line one\n\"line two\""),
]],
rows_affected: None,
row_count: 1,
},
);
assert_eq!(
body,
"[\n {\"title\":\"csv export mismatch\",\"notes\":\"line one\\n\\\"line two\\\"\"}\n]\n"
);
}
#[test]
fn inspect_response_uses_empty_state_for_empty_payload() {
let response = InspectResponse {
target: connection_summary(),
scope: InspectScope {
schema: Some(String::from("main")),
table: None,
},
payload: InspectPayload::Tables(Vec::new()),
};
assert_eq!(response.state(), OperationState::Empty);
}
#[test]
fn query_response_distinguishes_empty_rows_from_command_results() {
let empty = QueryResponse {
target: connection_summary(),
source: QuerySourceSummary {
kind: "inline",
label: "inline SQL",
path: None,
},
result: QueryDataset {
columns: vec![String::from("count")],
rows: Vec::new(),
rows_affected: None,
row_count: 0,
},
};
let command = QueryResponse {
target: connection_summary(),
source: QuerySourceSummary {
kind: "inline",
label: "inline SQL",
path: None,
},
result: QueryDataset {
columns: Vec::new(),
rows: Vec::new(),
rows_affected: Some(2),
row_count: 0,
},
};
assert_eq!(empty.state(), OperationState::Empty);
assert_eq!(command.state(), OperationState::Success);
}
#[test]
fn query_response_event_serializes_operation_and_state() {
let event = QueryResponse {
target: connection_summary(),
source: QuerySourceSummary {
kind: "file",
label: "SQL file",
path: Some(String::from("queries/open.sql")),
},
result: QueryDataset {
columns: vec![String::from("count")],
rows: vec![vec![String::from("3")]],
rows_affected: None,
row_count: 1,
},
}
.into_event();
let json = serde_json::to_value(&event).expect("event should serialize");
assert_eq!(json["operation"], "query");
assert_eq!(json["state"], "success");
assert_eq!(json["data"]["source"]["kind"], "file");
assert_eq!(json["data"]["result"]["rows"][0][0], "3");
assert!(json.get("error").is_none());
}
#[test]
fn app_error_event_serializes_error_state() {
let event = AppError {
operation: "inspect",
kind: AppErrorKind::Inspect,
message: String::from("schema lookup failed"),
target: Some(connection_summary()),
}
.into_event();
let json = serde_json::to_value(&event).expect("error event should serialize");
assert_eq!(json["operation"], "inspect");
assert_eq!(json["state"], "error");
assert_eq!(json["error"]["kind"], "inspect");
assert_eq!(json["error"]["message"], "schema lookup failed");
assert!(json.get("data").is_none());
}
fn connection_summary() -> ConnectionSummary {
ConnectionSummary {
profile_name: String::from("qa-demo"),
driver: String::from("sqlite"),
endpoint: String::from("sqlite:///tmp/qa-demo.sqlite"),
password_env_var: None,
}
}
fn temp_sqlite_db_path(prefix: &str) -> std::path::PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos();
env::temp_dir().join(format!("{prefix}-{unique}.sqlite"))
}
}