Skip to main content

fraiseql_cli/commands/setup/
mod.rs

1//! `fraiseql setup` - Install FraiseQL helpers to a PostgreSQL database.
2//!
3//! Installs SQL helper functions (`fraiseql.mutation_ok`, `fraiseql.mutation_err`,
4//! etc.) to the target database. These helpers reduce boilerplate when writing
5//! mutation functions under the v2.2.0 protocol.
6
7use anyhow::{Context, Result};
8use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
9use tokio_postgres::NoTls;
10use tracing::info;
11
12use crate::output::OutputFormatter;
13
14/// SQL helper library version (must match sql/helpers/mutation_response.sql)
15const HELPERS_VERSION: &str = "2.2.0";
16
17/// The SQL helper library content embedded as a const
18const MUTATION_RESPONSE_SQL: &str = include_str!("../../../sql/helpers/mutation_response.sql");
19
20/// Run the setup command to install helpers to a database.
21///
22/// # Errors
23///
24/// Returns an error if database connection fails, SQL execution fails,
25/// or the URL cannot be resolved.
26pub async fn run(
27    database_url: Option<&str>,
28    dry_run: bool,
29    formatter: &OutputFormatter,
30) -> Result<()> {
31    if dry_run {
32        // For dry-run, use provided URL or a placeholder
33        let db_url = database_url.unwrap_or("postgres://user:pass@localhost/db");
34        print_dry_run(db_url, formatter);
35        return Ok(());
36    }
37
38    // Resolve database URL for actual execution
39    let db_url = super::migrate::resolve_database_url(database_url)
40        .context("Failed to resolve database URL")?;
41
42    formatter.progress(&format!(
43        "🔧 Installing FraiseQL mutation helpers (v{}) to database...",
44        HELPERS_VERSION
45    ));
46
47    // Connect to database and get a pool
48    let pool = connect_to_database(&db_url).await.context("Failed to connect to database")?;
49
50    // Apply the SQL helpers
51    apply_helpers(&pool, formatter).await.context("Failed to apply helpers")?;
52
53    // Report success
54    formatter.progress(&format!(
55        "✅ FraiseQL mutation helpers v{} installed successfully",
56        HELPERS_VERSION
57    ));
58
59    formatter.progress("Installed functions:");
60    formatter.progress("  - fraiseql.library_version()");
61    formatter.progress("  - fraiseql.mutation_ok(...)");
62    formatter.progress("  - fraiseql.mutation_err(...)");
63
64    Ok(())
65}
66
67/// Print what would be done (dry run mode)
68fn print_dry_run(db_url: &str, formatter: &OutputFormatter) {
69    formatter.progress("📋 DRY RUN MODE (no changes will be made)");
70    formatter.progress("");
71    formatter.progress(&format!("Database URL: {}", mask_password(db_url)));
72    formatter.progress("");
73    formatter.progress("The following SQL will be executed:");
74    formatter.progress("");
75    formatter.progress(MUTATION_RESPONSE_SQL);
76    formatter.progress("");
77    formatter.progress("To apply these changes, run without --dry-run:");
78    formatter.progress(&format!("  fraiseql setup --database '{}'", mask_password(db_url)));
79}
80
81/// Mask sensitive parts of database URL for display
82fn mask_password(url: &str) -> String {
83    if let Some(at_pos) = url.rfind('@') {
84        if let Some(colon_pos) = url[..at_pos].rfind(':') {
85            let before = &url[..=colon_pos];
86            let after = &url[at_pos..];
87            format!("{}***{}", before, after)
88        } else {
89            url.to_string()
90        }
91    } else {
92        url.to_string()
93    }
94}
95
96/// Connect to the database using a deadpool connection pool
97async fn connect_to_database(db_url: &str) -> Result<deadpool_postgres::Pool> {
98    // Create deadpool config
99    let mut cfg = Config::new();
100    cfg.url = Some(db_url.to_string());
101    cfg.manager = Some(ManagerConfig {
102        recycling_method: RecyclingMethod::Fast,
103    });
104    cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
105
106    // Create connection pool
107    let pool = cfg
108        .create_pool(Some(Runtime::Tokio1), NoTls)
109        .context("Failed to create database pool")?;
110
111    // Test connection
112    let _client = pool.get().await.context("Failed to acquire database connection")?;
113
114    info!("Connected to database");
115
116    Ok(pool)
117}
118
119/// Apply the SQL helpers to the database
120async fn apply_helpers(pool: &deadpool_postgres::Pool, formatter: &OutputFormatter) -> Result<()> {
121    formatter.progress("📝 Applying SQL helpers...");
122
123    // Get a client from the pool
124    let client = pool.get().await.context("Failed to acquire database connection")?;
125
126    // Execute the SQL library
127    // Note: The SQL contains multiple statements, so we split on semicolons and execute
128    // individually This is a simplified approach; for production, use something like
129    // sqlparser-rs
130    for statement in MUTATION_RESPONSE_SQL.split(';') {
131        let trimmed = statement.trim();
132        if trimmed.is_empty() || trimmed.starts_with("--") {
133            continue;
134        }
135
136        client.execute(trimmed, &[]).await.with_context(|| {
137            format!("Failed to execute SQL: {}", trimmed.lines().next().unwrap_or(""))
138        })?;
139    }
140
141    formatter.progress("✓ SQL helpers applied");
142
143    // Verify installation
144    let version: String = client
145        .query_one("SELECT fraiseql.library_version() AS version", &[])
146        .await
147        .context("Failed to verify helper installation")?
148        .get("version");
149
150    if version == HELPERS_VERSION {
151        info!("Helper version verified: {}", version);
152    } else {
153        // This is a soft warning, not a hard failure
154        formatter.progress(&format!(
155            "⚠️  Version mismatch: expected {}, got {}",
156            HELPERS_VERSION, version
157        ));
158    }
159
160    Ok(())
161}
162
163#[cfg(test)]
164mod tests;