feat: add dbtool tui live query workspace
- 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:
19
crates/db-app/Cargo.toml
Normal file
19
crates/db-app/Cargo.toml
Normal 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
812
crates/db-app/src/lib.rs
Normal 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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user