sabiql 1.11.0

A fast, driver-less TUI for browsing and editing PostgreSQL databases
Documentation
use std::cell::RefCell;
use std::collections::HashSet;
use std::sync::Arc;

use color_eyre::eyre::Result;
use tokio::sync::mpsc;

use super::task::spawn_er_diagram_task;
use crate::app::cmd::completion_engine::CompletionEngine;
use crate::app::cmd::effect::Effect;
use crate::app::model::app_state::AppState;
use crate::app::ports::{ConfigWriter, ErDiagramExporter, ErLogWriter, MetadataProvider};
use crate::app::update::action::{
    Action, ErDiagramError, ErLogError, SmartErRefreshError, SmartErRefreshResult,
};
use crate::domain::ErTableInfo;
use crate::domain::er::{er_output_filename, fk_neighbors_of_seeds, fk_reachable_tables_multi};

pub async fn run(
    effect: Effect,
    action_tx: &mpsc::Sender<Action>,
    metadata_provider: &Arc<dyn MetadataProvider>,
    er_exporter: &Arc<dyn ErDiagramExporter>,
    config_writer: &Arc<dyn ConfigWriter>,
    er_log_writer: &Arc<dyn ErLogWriter>,
    state: &AppState,
    completion_engine: &RefCell<CompletionEngine>,
) -> Result<()> {
    match effect {
        Effect::GenerateErDiagramFromCache {
            total_tables,
            project_name,
            target_tables,
        } => {
            handle_generate_diagram(
                action_tx,
                er_exporter,
                config_writer,
                completion_engine,
                total_tables,
                project_name,
                target_tables,
            )
            .await
        }
        Effect::ExtractFkNeighbors { seed_tables } => {
            handle_extract_fk_neighbors(action_tx, completion_engine, seed_tables).await
        }
        Effect::WriteErFailureLog { failed_tables } => {
            handle_write_failure_log(
                action_tx,
                config_writer,
                er_log_writer,
                state,
                failed_tables,
            )
            .await
        }
        Effect::SmartErRefresh { dsn, run_id } => {
            handle_smart_refresh(
                action_tx,
                metadata_provider,
                state,
                completion_engine,
                dsn,
                run_id,
            );
            Ok(())
        }

        _ => unreachable!("er::run called with non-er effect"),
    }
}

async fn handle_generate_diagram(
    action_tx: &mpsc::Sender<Action>,
    er_exporter: &Arc<dyn ErDiagramExporter>,
    config_writer: &Arc<dyn ConfigWriter>,
    completion_engine: &RefCell<CompletionEngine>,
    total_tables: usize,
    project_name: String,
    target_tables: Vec<String>,
) -> Result<()> {
    let all_tables = collect_cached_er_tables(completion_engine);
    if all_tables.is_empty() {
        action_tx
            .send(Action::ErDiagramFailed(ErDiagramError::NoData(
                "No table data loaded yet".to_string(),
            )))
            .await
            .ok();
        return Ok(());
    }

    let total = all_tables.len();
    let filename = er_output_filename(&target_tables, total);
    let tables = if target_tables.is_empty() || target_tables.len() == total {
        all_tables
    } else {
        fk_reachable_tables_multi(&all_tables, &target_tables, 1)
    };

    if tables.is_empty() {
        action_tx
            .send(Action::ErDiagramFailed(ErDiagramError::NoData(
                "Selected tables not found in cached data".to_string(),
            )))
            .await
            .ok();
        return Ok(());
    }

    let cache_dir = config_writer.get_cache_dir(&project_name)?;
    spawn_er_diagram_task(
        Arc::clone(er_exporter),
        tables,
        total_tables,
        cache_dir,
        action_tx.clone(),
        filename,
    );
    Ok(())
}

async fn handle_extract_fk_neighbors(
    action_tx: &mpsc::Sender<Action>,
    completion_engine: &RefCell<CompletionEngine>,
    seed_tables: Vec<String>,
) -> Result<()> {
    let seed_set: HashSet<&str> = seed_tables.iter().map(String::as_str).collect();
    let (cached_seeds, cached_names) = collect_seed_and_cached_names(completion_engine, &seed_set);
    let neighbors = fk_neighbors_of_seeds(&cached_seeds, &seed_set, &cached_names);

    action_tx
        .send(Action::FkNeighborsDiscovered { tables: neighbors })
        .await
        .ok();
    Ok(())
}

async fn handle_write_failure_log(
    action_tx: &mpsc::Sender<Action>,
    config_writer: &Arc<dyn ConfigWriter>,
    er_log_writer: &Arc<dyn ErLogWriter>,
    state: &AppState,
    failed_tables: Vec<(String, String)>,
) -> Result<()> {
    match config_writer.get_cache_dir(&state.runtime.project_name) {
        Ok(cache_dir) => {
            let writer = Arc::clone(er_log_writer);
            let tx = action_tx.clone();
            tokio::task::spawn_blocking(move || {
                if let Err(e) = writer.write_er_failure_log(failed_tables, cache_dir) {
                    tx.blocking_send(Action::ErLogWriteFailed(ErLogError::Io(e.to_string())))
                        .ok();
                }
            });
        }
        Err(e) => {
            action_tx
                .send(Action::ErLogWriteFailed(ErLogError::Config(e.to_string())))
                .await
                .ok();
        }
    }
    Ok(())
}

fn handle_smart_refresh(
    action_tx: &mpsc::Sender<Action>,
    metadata_provider: &Arc<dyn MetadataProvider>,
    state: &AppState,
    completion_engine: &RefCell<CompletionEngine>,
    dsn: String,
    run_id: u64,
) {
    let provider = Arc::clone(metadata_provider);
    let tx = action_tx.clone();
    let old_signatures = state.er_preparation.last_signatures.clone();
    let cached_tables = collect_cached_table_names(completion_engine);

    tokio::spawn(async move {
        let new_metadata = match provider.fetch_metadata(&dsn).await {
            Ok(m) => m,
            Err(e) => {
                tx.send(Action::SmartErRefreshFailed(SmartErRefreshError {
                    run_id,
                    error: e,
                    new_metadata: None,
                }))
                .await
                .ok();
                return;
            }
        };

        let new_sigs_vec = match provider.fetch_table_signatures(&dsn).await {
            Ok(s) => s,
            Err(e) => {
                let new_metadata = Arc::new(new_metadata);
                tx.send(Action::SmartErRefreshFailed(SmartErRefreshError {
                    run_id,
                    error: e,
                    new_metadata: Some(Arc::clone(&new_metadata)),
                }))
                .await
                .ok();
                return;
            }
        };

        let new_signatures: std::collections::HashMap<String, String> = new_sigs_vec
            .iter()
            .map(|s| (s.qualified_name(), s.signature.clone()))
            .collect();

        let old_names: HashSet<&str> = old_signatures.keys().map(String::as_str).collect();
        let new_names: HashSet<&str> = new_signatures.keys().map(String::as_str).collect();

        let added_tables: Vec<String> = new_names
            .difference(&old_names)
            .map(ToString::to_string)
            .collect();
        let removed_tables: Vec<String> = old_names
            .difference(&new_names)
            .map(ToString::to_string)
            .collect();

        let stale_tables: Vec<String> = new_signatures
            .iter()
            .filter(|(name, sig)| {
                old_signatures
                    .get(name.as_str())
                    .is_some_and(|old_sig| old_sig != *sig)
            })
            .map(|(name, _)| name.clone())
            .collect();

        let missing_in_cache: Vec<String> = new_names
            .iter()
            .filter(|name| !cached_tables.contains(**name))
            .map(ToString::to_string)
            .collect();

        tx.send(Action::SmartErRefreshCompleted(SmartErRefreshResult {
            run_id,
            new_metadata: Arc::new(new_metadata),
            stale_tables,
            added_tables,
            removed_tables,
            missing_in_cache,
            new_signatures,
        }))
        .await
        .ok();
    });
}

fn collect_cached_er_tables(completion_engine: &RefCell<CompletionEngine>) -> Vec<ErTableInfo> {
    let engine = completion_engine.borrow();
    engine
        .table_details_iter()
        .map(|(name, table)| ErTableInfo::from_table(name, table))
        .collect()
}

fn collect_seed_and_cached_names(
    completion_engine: &RefCell<CompletionEngine>,
    seed_set: &HashSet<&str>,
) -> (Vec<ErTableInfo>, HashSet<String>) {
    let engine = completion_engine.borrow();
    let seeds = engine
        .table_details_iter()
        .filter(|(name, _)| seed_set.contains(name.as_str()))
        .map(|(name, table)| ErTableInfo::from_table(name, table))
        .collect();
    let all_cached = engine
        .table_details_iter()
        .map(|(name, _)| name.clone())
        .collect();
    (seeds, all_cached)
}

fn collect_cached_table_names(completion_engine: &RefCell<CompletionEngine>) -> HashSet<String> {
    let engine = completion_engine.borrow();
    engine
        .table_details_iter()
        .map(|(name, _)| name.clone())
        .collect()
}