feat(usable): integrate current dbtool implementation snapshot
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
use db_config::ConnectionProfile;
|
||||
use db_config::{
|
||||
ConfigError, ConnectionProfile, ConnectionProfileCatalog, ConnectionProfileStore,
|
||||
SessionSecretStore,
|
||||
};
|
||||
use db_core::{
|
||||
ColumnSummary, CoreError, ExportFormat, ExportRequest, InspectRequest, InspectResult,
|
||||
QueryRequest, QueryResult, QuerySource, SchemaSummary, TableSummary,
|
||||
QueryRequest, QueryResult, QuerySource, SchemaAvailability as CoreSchemaAvailability,
|
||||
SchemaSummary, TableSummary,
|
||||
};
|
||||
use db_drivers::{DriverError, DriverRegistry};
|
||||
use serde::Serialize;
|
||||
@@ -14,6 +18,10 @@ pub enum AppOperation {
|
||||
Inspect,
|
||||
Query,
|
||||
Export,
|
||||
LoadProfiles,
|
||||
SaveProfile,
|
||||
DeleteProfile,
|
||||
ActivateProfile,
|
||||
}
|
||||
|
||||
impl AppOperation {
|
||||
@@ -23,6 +31,10 @@ impl AppOperation {
|
||||
Self::Inspect => "inspect",
|
||||
Self::Query => "query",
|
||||
Self::Export => "export",
|
||||
Self::LoadProfiles => "load_profiles",
|
||||
Self::SaveProfile => "save_profile",
|
||||
Self::DeleteProfile => "delete_profile",
|
||||
Self::ActivateProfile => "activate_profile",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +44,10 @@ impl AppOperation {
|
||||
"inspect" => Self::Inspect,
|
||||
"query" => Self::Query,
|
||||
"export" => Self::Export,
|
||||
"load_profiles" => Self::LoadProfiles,
|
||||
"save_profile" => Self::SaveProfile,
|
||||
"delete_profile" => Self::DeleteProfile,
|
||||
"activate_profile" => Self::ActivateProfile,
|
||||
other => panic!("unsupported app operation: {other}"),
|
||||
}
|
||||
}
|
||||
@@ -115,6 +131,41 @@ impl ConnectResponse {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SecretSource {
|
||||
None,
|
||||
Session,
|
||||
EnvVar,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct StoredProfileSummary {
|
||||
pub target: ConnectionSummary,
|
||||
pub is_active: bool,
|
||||
pub secret_source: SecretSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct ProfileCatalogResponse {
|
||||
pub active_profile_name: Option<String>,
|
||||
pub profiles: Vec<StoredProfileSummary>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SessionSecretInput {
|
||||
Preserve,
|
||||
Set(String),
|
||||
Clear,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SaveProfileRequest {
|
||||
pub profile: ConnectionProfile,
|
||||
pub set_active: bool,
|
||||
pub session_secret: SessionSecretInput,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct InspectScope {
|
||||
pub schema: Option<String>,
|
||||
@@ -164,14 +215,37 @@ impl InspectResponse {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SchemaAvailability {
|
||||
Ready,
|
||||
Restricted,
|
||||
}
|
||||
|
||||
impl From<CoreSchemaAvailability> for SchemaAvailability {
|
||||
fn from(value: CoreSchemaAvailability) -> Self {
|
||||
match value {
|
||||
CoreSchemaAvailability::Ready => Self::Ready,
|
||||
CoreSchemaAvailability::Restricted => Self::Restricted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct SchemaItem {
|
||||
pub name: String,
|
||||
pub availability: SchemaAvailability,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
impl From<SchemaSummary> for SchemaItem {
|
||||
fn from(value: SchemaSummary) -> Self {
|
||||
Self { name: value.name }
|
||||
Self {
|
||||
name: value.name,
|
||||
availability: SchemaAvailability::from(value.availability),
|
||||
note: value.note,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +423,7 @@ pub enum AppErrorKind {
|
||||
Inspect,
|
||||
Query,
|
||||
Export,
|
||||
Config,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
@@ -397,6 +472,120 @@ pub fn connect(profile: &ConnectionProfile) -> Result<ConnectResponse, AppError>
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_profile_catalog(
|
||||
store: &ConnectionProfileStore,
|
||||
secrets: &SessionSecretStore,
|
||||
) -> Result<ProfileCatalogResponse, AppError> {
|
||||
let catalog = load_catalog(store, "load_profiles")?;
|
||||
Ok(profile_catalog_response(&catalog, secrets))
|
||||
}
|
||||
|
||||
pub fn save_profile(
|
||||
store: &ConnectionProfileStore,
|
||||
secrets: &mut SessionSecretStore,
|
||||
request: SaveProfileRequest,
|
||||
) -> Result<ProfileCatalogResponse, AppError> {
|
||||
validate_profile("save_profile", &request.profile)?;
|
||||
|
||||
let inline_secret = request.profile.target.password.clone();
|
||||
let mut catalog = load_catalog(store, "save_profile")?;
|
||||
catalog
|
||||
.upsert_profile(request.profile.clone())
|
||||
.map_err(|error| map_config_error("save_profile", Some(&request.profile), error))?;
|
||||
|
||||
if request.set_active {
|
||||
catalog
|
||||
.set_active_profile(Some(&request.profile.name))
|
||||
.map_err(|error| map_config_error("save_profile", Some(&request.profile), error))?;
|
||||
}
|
||||
|
||||
apply_session_secret_change(
|
||||
secrets,
|
||||
&request.profile,
|
||||
request.session_secret,
|
||||
inline_secret,
|
||||
)
|
||||
.map_err(|error| map_config_error("save_profile", Some(&request.profile), error))?;
|
||||
|
||||
save_catalog(store, "save_profile", Some(&request.profile), &catalog)?;
|
||||
Ok(profile_catalog_response(&catalog, secrets))
|
||||
}
|
||||
|
||||
pub fn delete_profile(
|
||||
store: &ConnectionProfileStore,
|
||||
secrets: &mut SessionSecretStore,
|
||||
profile_name: &str,
|
||||
) -> Result<ProfileCatalogResponse, AppError> {
|
||||
let mut catalog = load_catalog(store, "delete_profile")?;
|
||||
if catalog.remove_profile(profile_name).is_none() {
|
||||
return Err(map_config_error(
|
||||
"delete_profile",
|
||||
None,
|
||||
ConfigError::ProfileNotFound(profile_name.to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
secrets.remove(profile_name);
|
||||
save_catalog(store, "delete_profile", None, &catalog)?;
|
||||
|
||||
Ok(profile_catalog_response(&catalog, secrets))
|
||||
}
|
||||
|
||||
pub fn activate_profile(
|
||||
store: &ConnectionProfileStore,
|
||||
secrets: &SessionSecretStore,
|
||||
profile_name: &str,
|
||||
) -> Result<ProfileCatalogResponse, AppError> {
|
||||
let mut catalog = load_catalog(store, "activate_profile")?;
|
||||
catalog
|
||||
.set_active_profile(Some(profile_name))
|
||||
.map_err(|error| map_config_error("activate_profile", None, error))?;
|
||||
save_catalog(
|
||||
store,
|
||||
"activate_profile",
|
||||
catalog.profile(profile_name),
|
||||
&catalog,
|
||||
)?;
|
||||
|
||||
Ok(profile_catalog_response(&catalog, secrets))
|
||||
}
|
||||
|
||||
pub fn resolve_profile(
|
||||
store: &ConnectionProfileStore,
|
||||
secrets: &SessionSecretStore,
|
||||
profile_name: &str,
|
||||
) -> Result<ConnectionProfile, AppError> {
|
||||
let catalog = load_catalog(store, "load_profiles")?;
|
||||
let profile = catalog.profile(profile_name).ok_or_else(|| {
|
||||
map_config_error(
|
||||
"load_profiles",
|
||||
None,
|
||||
ConfigError::ProfileNotFound(profile_name.to_string()),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(secrets.materialize_profile(profile))
|
||||
}
|
||||
|
||||
pub fn resolve_active_profile(
|
||||
store: &ConnectionProfileStore,
|
||||
secrets: &SessionSecretStore,
|
||||
) -> Result<Option<ConnectionProfile>, AppError> {
|
||||
let catalog = load_catalog(store, "load_profiles")?;
|
||||
Ok(catalog
|
||||
.active_profile()
|
||||
.map(|profile| secrets.materialize_profile(profile)))
|
||||
}
|
||||
|
||||
pub fn connect_saved_profile(
|
||||
store: &ConnectionProfileStore,
|
||||
secrets: &SessionSecretStore,
|
||||
profile_name: &str,
|
||||
) -> Result<ConnectResponse, AppError> {
|
||||
let profile = resolve_profile(store, secrets, profile_name)?;
|
||||
connect(&profile)
|
||||
}
|
||||
|
||||
pub fn inspect(
|
||||
profile: &ConnectionProfile,
|
||||
request: &InspectRequest,
|
||||
@@ -570,6 +759,93 @@ fn map_driver_error(
|
||||
}
|
||||
}
|
||||
|
||||
fn load_catalog(
|
||||
store: &ConnectionProfileStore,
|
||||
operation: &'static str,
|
||||
) -> Result<ConnectionProfileCatalog, AppError> {
|
||||
store
|
||||
.load()
|
||||
.map_err(|error| map_config_error(operation, None, error))
|
||||
}
|
||||
|
||||
fn save_catalog(
|
||||
store: &ConnectionProfileStore,
|
||||
operation: &'static str,
|
||||
profile: Option<&ConnectionProfile>,
|
||||
catalog: &ConnectionProfileCatalog,
|
||||
) -> Result<(), AppError> {
|
||||
store
|
||||
.save(catalog)
|
||||
.map_err(|error| map_config_error(operation, profile, error))
|
||||
}
|
||||
|
||||
fn map_config_error(
|
||||
operation: &'static str,
|
||||
profile: Option<&ConnectionProfile>,
|
||||
error: ConfigError,
|
||||
) -> AppError {
|
||||
AppError {
|
||||
operation,
|
||||
kind: AppErrorKind::Config,
|
||||
message: error.to_string(),
|
||||
target: profile.map(ConnectionSummary::from),
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_catalog_response(
|
||||
catalog: &ConnectionProfileCatalog,
|
||||
secrets: &SessionSecretStore,
|
||||
) -> ProfileCatalogResponse {
|
||||
ProfileCatalogResponse {
|
||||
active_profile_name: catalog.active_profile.clone(),
|
||||
profiles: catalog
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|profile| StoredProfileSummary {
|
||||
target: ConnectionSummary::from(profile),
|
||||
is_active: catalog.active_profile.as_deref() == Some(profile.name.as_str()),
|
||||
secret_source: profile_secret_source(profile, secrets),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_secret_source(
|
||||
profile: &ConnectionProfile,
|
||||
secrets: &SessionSecretStore,
|
||||
) -> SecretSource {
|
||||
if secrets.contains(&profile.name) {
|
||||
SecretSource::Session
|
||||
} else if profile.password_env_var.is_some() {
|
||||
SecretSource::EnvVar
|
||||
} else {
|
||||
SecretSource::None
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_session_secret_change(
|
||||
secrets: &mut SessionSecretStore,
|
||||
profile: &ConnectionProfile,
|
||||
session_secret: SessionSecretInput,
|
||||
inline_secret: Option<String>,
|
||||
) -> Result<(), ConfigError> {
|
||||
match session_secret {
|
||||
SessionSecretInput::Preserve => {
|
||||
if let Some(secret) = inline_secret {
|
||||
secrets.set(profile.name.clone(), secret)?;
|
||||
}
|
||||
}
|
||||
SessionSecretInput::Set(secret) => {
|
||||
secrets.set(profile.name.clone(), secret)?;
|
||||
}
|
||||
SessionSecretInput::Clear => {
|
||||
secrets.remove(&profile.name);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn export_format_name(format: ExportFormat) -> &'static str {
|
||||
match format {
|
||||
ExportFormat::Csv => "csv",
|
||||
@@ -701,6 +977,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_item_preserves_restricted_availability_and_note() {
|
||||
let item = SchemaItem::from(SchemaSummary::restricted(
|
||||
"restricted_probe",
|
||||
"schema access is restricted: restricted_probe",
|
||||
));
|
||||
|
||||
assert_eq!(item.name, "restricted_probe");
|
||||
assert_eq!(item.availability, SchemaAvailability::Restricted);
|
||||
assert_eq!(
|
||||
item.note.as_deref(),
|
||||
Some("schema access is restricted: restricted_probe")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_response_uses_empty_state_for_empty_payload() {
|
||||
let response = InspectResponse {
|
||||
@@ -796,6 +1087,110 @@ mod tests {
|
||||
assert!(json.get("data").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_profile_persists_redacted_catalog_and_keeps_session_secret_only_in_memory() {
|
||||
let path = temp_store_path("dbtool-app-profile-save");
|
||||
let store = ConnectionProfileStore::new(&path);
|
||||
let mut secrets = SessionSecretStore::default();
|
||||
let profile = ConnectionProfile::new(
|
||||
"reporting-postgres",
|
||||
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"),
|
||||
Some(String::from("DBTOOL_PASSWORD")),
|
||||
)
|
||||
.expect("profile should be valid")
|
||||
.with_password(Some(String::from("super-secret")));
|
||||
|
||||
let response = save_profile(
|
||||
&store,
|
||||
&mut secrets,
|
||||
SaveProfileRequest {
|
||||
profile,
|
||||
set_active: true,
|
||||
session_secret: SessionSecretInput::Preserve,
|
||||
},
|
||||
)
|
||||
.expect("profile should save");
|
||||
let raw = fs::read_to_string(&path).expect("store file should exist");
|
||||
let resolved =
|
||||
resolve_active_profile(&store, &secrets).expect("active profile should resolve");
|
||||
|
||||
assert_eq!(
|
||||
response.active_profile_name.as_deref(),
|
||||
Some("reporting-postgres")
|
||||
);
|
||||
assert_eq!(response.profiles.len(), 1);
|
||||
assert_eq!(response.profiles[0].secret_source, SecretSource::Session);
|
||||
assert!(!raw.contains("super-secret"));
|
||||
assert!(!raw.contains("\"password\":"));
|
||||
assert_eq!(
|
||||
resolved
|
||||
.expect("active profile should exist")
|
||||
.target
|
||||
.password
|
||||
.as_deref(),
|
||||
Some("super-secret")
|
||||
);
|
||||
|
||||
fs::remove_file(path).expect("temporary store should be removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_saved_profile_uses_persisted_sqlite_target() {
|
||||
let db_path = temp_sqlite_db_path("dbtool-app-connect-saved");
|
||||
let connection =
|
||||
SqliteConnection::open(&db_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 store_path = temp_store_path("dbtool-app-profile-connect");
|
||||
let store = ConnectionProfileStore::new(&store_path);
|
||||
let mut catalog = ConnectionProfileCatalog::empty();
|
||||
catalog
|
||||
.upsert_profile(
|
||||
ConnectionProfile::new(
|
||||
"sqlite-local",
|
||||
ConnectionTarget::new(
|
||||
DatabaseKind::Sqlite,
|
||||
ConnectionTransport::File {
|
||||
path: db_path.clone(),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("target should be valid"),
|
||||
None,
|
||||
)
|
||||
.expect("profile should be valid"),
|
||||
)
|
||||
.expect("profile should be inserted");
|
||||
catalog
|
||||
.set_active_profile(Some("sqlite-local"))
|
||||
.expect("active profile should be set");
|
||||
store.save(&catalog).expect("catalog should save");
|
||||
|
||||
let response =
|
||||
connect_saved_profile(&store, &SessionSecretStore::default(), "sqlite-local")
|
||||
.expect("saved sqlite profile should connect");
|
||||
|
||||
assert_eq!(response.target.profile_name, "sqlite-local");
|
||||
assert_eq!(response.target.driver, "sqlite");
|
||||
assert_eq!(response.status, "connected");
|
||||
|
||||
fs::remove_file(store_path).expect("temporary store should be removed");
|
||||
fs::remove_file(db_path).expect("temporary sqlite database should be removed");
|
||||
}
|
||||
|
||||
fn connection_summary() -> ConnectionSummary {
|
||||
ConnectionSummary {
|
||||
profile_name: String::from("qa-demo"),
|
||||
@@ -812,4 +1207,12 @@ mod tests {
|
||||
.as_nanos();
|
||||
env::temp_dir().join(format!("{prefix}-{unique}.sqlite"))
|
||||
}
|
||||
|
||||
fn temp_store_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}.json"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user