fundaia 0.32.1

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
//! The rows inside a database component.
//!
//! Three commands over the same routes the *Datos* tab uses. Two read; one
//! writes, and it is the only command in this binary that changes somebody's
//! data rather than the platform's rows about it — which is why it takes
//! `--confirm` like `delete` does and has a `--preview` nothing else needs.

use std::io::Read;

use anyhow::{bail, Result};
use owo_colors::OwoColorize;

use crate::api::client::ApiClient;
use crate::api::models::{ExecutionOutcome, QueryOutcome};
use crate::exit::Refused;
use crate::output::{self, Format};
use crate::render::table::{truncate, Cell, Table};
use crate::render::theme;

/// Longer than this and a cell is a column of its own; the table stays readable.
const CELL_WIDTH: usize = 48;

pub async fn tables(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, service_reference).await?;
    let overview = client.data_overview(&service.id).await?;

    if format.answered_in_json(&overview) {
        return Ok(());
    }

    if !overview.status.reachable {
        return Err(unreachable(&service.name, overview.status.reason.as_deref()).into());
    }

    if overview.relations.is_empty() {
        println!(
            "`{}` está vacía: ninguna migración se ha ejecutado todavía contra ella.",
            service.name
        );
        return Ok(());
    }

    let mut table = Table::new(&["tabla", "tipo", "columnas", "filas"]);
    for relation in &overview.relations {
        table.push(vec![
            Cell::styled(relation.name.clone(), theme::strong()),
            Cell::styled(
                if relation.kind == "view" {
                    "vista"
                } else {
                    "tabla"
                },
                theme::muted(),
            ),
            Cell::plain(relation.column_count.to_string()),
            // "~", because it is the planner's estimate and not a count.
            Cell::plain(relation.estimated_rows.map_or_else(
                || "—".to_owned(),
                |rows| format!("~{}", rows.round() as u64),
            )),
        ]);
    }

    table.print();
    Ok(())
}

pub async fn query(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    sql: &str,
    limit: Option<u64>,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, service_reference).await?;
    let outcome = client
        .query_data(&service.id, &statement_from(sql)?, limit)
        .await?;

    if format.is_json() {
        output::emit(&outcome);
        return refused_or_ok(outcome.failure.as_deref());
    }

    if let Some(failure) = &outcome.failure {
        return Err(refused(failure).into());
    }

    print_rows(&outcome.columns, &outcome.rows);
    print_query_footer(&outcome);
    Ok(())
}

pub async fn run(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    sql: &str,
    preview: bool,
    confirmation: Option<&str>,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, service_reference).await?;

    // Checked here and again on the server: a preview needs no confirmation
    // because it changes nothing, and the real run needs the name because a
    // flag is something a script learns to pass unconditionally.
    if !preview
        && confirmation != Some(service.name.as_str())
        && confirmation != Some(service.slug.as_str())
    {
        bail!(
            "esto cambia los datos de `{}` y no se puede deshacer: pruébalo con --preview, y para ejecutarlo repite el nombre con --confirm {}",
            service.name,
            service.slug,
        );
    }

    let outcome = client
        .execute_sql(&service.id, &statement_from(sql)?, preview)
        .await?;

    if format.is_json() {
        output::emit(&outcome);
        return refused_or_ok(outcome.failure.as_deref());
    }

    if let Some(failure) = &outcome.failure {
        return Err(refused(failure).into());
    }

    print_execution(&outcome);
    Ok(())
}

/// The statement as typed, or stdin when it was `-`.
///
/// A migration is a file, and `fundaia data run p db - < grant.sql` is how a
/// file reaches a shell argument without the shell reading its quotes.
fn statement_from(argument: &str) -> Result<String> {
    if argument != "-" {
        return Ok(argument.to_owned());
    }

    let mut read = String::new();
    std::io::stdin().read_to_string(&mut read)?;
    Ok(read)
}

fn print_rows(columns: &[String], rows: &[Vec<Option<String>>]) {
    if rows.is_empty() {
        return;
    }

    let headers: Vec<&str> = columns.iter().map(String::as_str).collect();
    let mut table = Table::new(&headers);

    for row in rows {
        table.push(
            row.iter()
                .map(|cell| match cell {
                    // NULL is not an empty string, and a column that draws both
                    // the same answers the wrong question.
                    None => Cell::styled("NULL", theme::muted()),
                    Some(text) => Cell::plain(truncate(text, CELL_WIDTH)),
                })
                .collect(),
        );
    }

    table.print();
}

fn print_query_footer(outcome: &QueryOutcome) {
    let count = outcome.rows.len();
    let mut line = format!("{count} {}", if count == 1 { "fila" } else { "filas" });
    if outcome.has_more {
        line.push_str(&format!(
            " · hay más, se muestran las primeras {}",
            outcome.limit
        ));
    }
    println!("  {}", line.style(theme::muted()));
    print_truncations(outcome.cells_truncated, outcome.rows_dropped);
}

fn print_execution(outcome: &ExecutionOutcome) {
    let command = outcome.command.as_deref().unwrap_or("OK");
    let touched = match outcome.affected_rows {
        Some(1) => "1 fila".to_owned(),
        Some(rows) => format!("{rows} filas"),
        None => String::new(),
    };

    if outcome.committed {
        println!(
            "  {} {} {}",
            theme::CHECK.style(theme::success()),
            command.style(theme::strong()),
            touched.style(theme::muted()),
        );
    } else {
        println!(
            "  {} {} {} {}",
            "ensayo".style(theme::warning()),
            command.style(theme::strong()),
            touched.style(theme::muted()),
            "· deshecho, nada ha cambiado".style(theme::muted()),
        );
    }

    print_rows(&outcome.columns, &outcome.rows);
    print_truncations(outcome.cells_truncated, outcome.rows_dropped);
}

fn print_truncations(cells: bool, rows: bool) {
    if cells {
        println!(
            "  {}",
            "los valores muy largos se han cortado (acaban en …)".style(theme::muted())
        );
    }
    if rows {
        println!(
            "  {}",
            "la respuesta llegó a su tamaño máximo y quedaron filas fuera".style(theme::muted())
        );
    }
}

/// The database said no. Exit 4, the class of "the identical call fails identically".
fn refused(failure: &str) -> Refused {
    Refused::invalid(format!("la base de datos lo rechazó: {failure}"))
}

/// Under `--json` the outcome was already printed; the exit code still has to say.
fn refused_or_ok(failure: Option<&str>) -> Result<()> {
    match failure {
        Some(failure) => Err(refused(failure).into()),
        None => Ok(()),
    }
}

fn unreachable(service: &str, reason: Option<&str>) -> Refused {
    let words = match reason {
        Some("not-a-database") => "no es una base de datos que la plataforma sepa leer: solo los componentes PostgreSQL del catálogo",
        Some("never-deployed") => "todavía no se ha desplegado",
        Some("asleep") => "está durmiendo, y este comando no la despierta a propósito: arráncala con `fundaia deploy`",
        Some("stopped") => "no tiene el contenedor en marcha",
        Some("unconfigured") => "no tiene POSTGRES_USER, POSTGRES_PASSWORD y POSTGRES_DB en sus variables",
        _ => "no se puede leer ahora mismo",
    };
    Refused::invalid(format!("`{service}` {words}"))
}