use std::{fs, path::Path};
use anyhow::{Context, Result};
use fraiseql_core::schema::CompiledSchema;
use crate::output::OutputFormatter;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RefreshStrategy {
TriggerBased,
Scheduled,
}
impl RefreshStrategy {
pub fn parse(s: &str) -> std::result::Result<Self, String> {
match s.to_lowercase().as_str() {
"trigger-based" | "trigger" => Ok(Self::TriggerBased),
"scheduled" => Ok(Self::Scheduled),
_ => Err(format!("Invalid refresh strategy '{s}', expected: trigger-based, scheduled")),
}
}
}
impl std::fmt::Display for RefreshStrategy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TriggerBased => write!(f, "trigger-based"),
Self::Scheduled => write!(f, "scheduled"),
}
}
}
#[derive(Debug, Clone)]
pub struct GenerateViewsConfig {
pub schema_path: String,
pub entity: String,
pub view: String,
pub refresh_strategy: RefreshStrategy,
pub output: Option<String>,
pub include_composition_views: bool,
pub include_monitoring: bool,
pub validate_only: bool,
pub verbose: bool,
}
pub async fn run(config: GenerateViewsConfig, formatter: &OutputFormatter) -> Result<()> {
if config.verbose {
formatter.progress("Generating views...");
formatter.progress(&format!(" Schema: {}", config.schema_path));
formatter.progress(&format!(" Entity: {}", config.entity));
formatter.progress(&format!(" View: {}", config.view));
formatter.progress(&format!(" Refresh strategy: {}", config.refresh_strategy));
}
let schema_path = Path::new(&config.schema_path);
if !schema_path.exists() {
anyhow::bail!("Schema file not found: {}", config.schema_path);
}
let schema_json = fs::read_to_string(schema_path).context("Failed to read schema.json")?;
if config.verbose {
formatter.progress(" ok: Reading schema...");
}
let schema =
CompiledSchema::from_json(&schema_json, false).context("Failed to parse schema.json")?;
if config.verbose {
formatter.progress(" ok: Validating entity...");
}
let sql_source = resolve_entity_sql_source(&schema, &config.entity)?;
if config.verbose {
formatter.progress(" ok: Validating view name...");
}
let view_type = validate_view_name(&config.view)?;
if config.verbose {
formatter.progress(&format!(" ok: View type: {view_type}"));
}
if config.verbose {
formatter.progress(" ok: Generating SQL DDL...");
}
let sql = generate_view_sql(
&config.entity,
&sql_source,
&config.view,
view_type,
config.refresh_strategy,
config.include_composition_views,
config.include_monitoring,
);
if config.validate_only {
validate_ddl_against_database(&sql).await?;
println!("✓ View DDL is valid (executed against PostgreSQL, rolled back)");
println!(" Entity: {}", config.entity);
println!(" View: {}", config.view);
println!(" Type: {view_type}");
println!(" Refresh strategy: {}", config.refresh_strategy);
println!(" Lines: {}", sql.lines().count());
return Ok(());
}
if config.verbose {
formatter.progress(" ok: Writing output...");
}
let output_path = config.output.unwrap_or_else(|| format!("{}.sql", config.view));
fs::write(&output_path, sql.clone()).context("Failed to write output file")?;
println!("✓ View DDL generated successfully");
println!(" Entity: {}", config.entity);
println!(" View: {}", config.view);
println!(" Type: {view_type}");
println!(" Output: {output_path}");
println!(" Lines: {}", sql.lines().count());
if config.include_composition_views {
println!(" ✓ Includes composition views");
}
if config.include_monitoring {
println!(" ✓ Includes monitoring functions");
}
if config.verbose {
formatter.progress("\nGenerated SQL preview (first 5 lines):");
for line in sql.lines().take(5) {
formatter.progress(&format!(" {line}"));
}
}
Ok(())
}
async fn validate_ddl_against_database(sql: &str) -> Result<()> {
let db_url = std::env::var("DATABASE_URL").map_err(|_| {
anyhow::anyhow!(
"--validate executes the generated DDL against PostgreSQL and needs DATABASE_URL \
(everything runs in a rolled-back transaction; a scratch database is fine)"
)
})?;
let (mut client, connection) = tokio_postgres::connect(&db_url, tokio_postgres::NoTls)
.await
.context("--validate: failed to connect to DATABASE_URL")?;
tokio::spawn(async move {
let _ = connection.await;
});
let txn = client.transaction().await.context("--validate: failed to open transaction")?;
txn.batch_execute(sql)
.await
.context("generated DDL was rejected by PostgreSQL — the file is NOT valid")?;
txn.rollback().await.context("--validate: rollback failed")?;
Ok(())
}
fn resolve_entity_sql_source(schema: &CompiledSchema, entity: &str) -> Result<String> {
if let Some(type_def) = schema.types.iter().find(|t| t.name == entity) {
Ok(type_def.sql_source.as_str().to_string())
} else {
let available = schema.types.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ");
anyhow::bail!("Entity '{entity}' not found in schema. Available types: {available}")
}
}
pub(crate) fn validate_view_name(view_name: &str) -> Result<&'static str> {
if view_name.starts_with("va_") {
Ok("Vector Arrow (va_)")
} else if view_name.starts_with("tv_") {
Ok("Table Vector (tv_)")
} else if view_name.starts_with("ta_") {
Ok("Table Arrow (ta_)")
} else {
anyhow::bail!("Invalid view name '{view_name}'. Must start with va_, tv_, or ta_")
}
}
pub(crate) fn generate_view_sql(
entity: &str,
sql_source: &str,
view_name: &str,
view_type: &str,
refresh_strategy: RefreshStrategy,
include_composition_views: bool,
include_monitoring: bool,
) -> String {
let mut sql = String::new();
sql.push_str("-- Auto-generated Arrow view DDL\n");
sql.push_str(&format!("-- Entity: {entity}\n"));
sql.push_str(&format!("-- View: {view_name}\n"));
sql.push_str(&format!("-- Type: {view_type}\n"));
sql.push_str(&format!("-- Refresh strategy: {refresh_strategy}\n"));
sql.push_str("-- Generated by: fraiseql generate-views\n\n");
if view_name.starts_with("tv_") {
sql.push_str(&format!("DROP MATERIALIZED VIEW IF EXISTS {view_name} CASCADE;\n\n"));
} else {
sql.push_str(&format!("DROP VIEW IF EXISTS {view_name} CASCADE;\n\n"));
}
#[allow(clippy::unreachable)]
match view_name.split('_').next() {
Some("va") => {
generate_vector_arrow_view(&mut sql, entity, sql_source, view_name);
},
Some("tv") => {
generate_table_vector_view(&mut sql, entity, sql_source, view_name);
},
Some("ta") => {
generate_table_arrow_view(&mut sql, entity, sql_source, view_name);
},
_ => unreachable!("view name validated by validate_view_name before generate_view_sql"),
}
if include_composition_views {
sql.push_str("\n-- Composition views\n");
generate_composition_views(&mut sql, view_name);
}
if include_monitoring {
sql.push_str("\n-- Monitoring functions\n");
generate_monitoring_functions(&mut sql, view_name);
}
sql
}
fn generate_vector_arrow_view(sql: &mut String, entity: &str, sql_source: &str, view_name: &str) {
sql.push_str(&format!("CREATE VIEW {view_name} AS\n"));
sql.push_str("SELECT\n");
sql.push_str(" id,\n");
sql.push_str(&format!(" -- {entity} entity fields\n"));
sql.push_str(" created_at,\n");
sql.push_str(" updated_at\n");
sql.push_str(&format!("FROM {sql_source}\n"));
sql.push_str("WHERE archived_at IS NULL;\n");
}
fn generate_table_vector_view(sql: &mut String, entity: &str, sql_source: &str, view_name: &str) {
sql.push_str(&format!("CREATE MATERIALIZED VIEW {view_name} AS\n"));
sql.push_str("SELECT\n");
sql.push_str(" id,\n");
sql.push_str(&format!(" -- {entity} entity vector representation\n"));
sql.push_str(" CURRENT_TIMESTAMP as materialized_at\n");
sql.push_str(&format!("FROM {sql_source}\n"));
sql.push_str("WHERE archived_at IS NULL;\n");
sql.push('\n');
let base_name = view_name.trim_start_matches("tv_");
sql.push_str(&format!("CREATE INDEX idx_{base_name}_id ON {view_name} (id);\n"));
}
fn generate_table_arrow_view(sql: &mut String, entity: &str, sql_source: &str, view_name: &str) {
sql.push_str(&format!("CREATE VIEW {view_name} AS\n"));
sql.push_str("SELECT\n");
sql.push_str(" id,\n");
sql.push_str(&format!(" -- {entity} entity fields optimized for Arrow\n"));
sql.push_str(" created_at,\n");
sql.push_str(" updated_at\n");
sql.push_str(&format!("FROM {sql_source}\n"));
sql.push_str("WHERE archived_at IS NULL\n");
sql.push_str("ORDER BY id;\n");
}
fn generate_composition_views(sql: &mut String, view_name: &str) {
let base_name = view_name
.trim_start_matches("va_")
.trim_start_matches("tv_")
.trim_start_matches("ta_");
let ts_column = if view_name.starts_with("tv_") {
"materialized_at"
} else {
"updated_at"
};
sql.push_str(&format!("CREATE VIEW {base_name}_recent AS\n"));
sql.push_str(&format!("SELECT * FROM {view_name}\n"));
sql.push_str(&format!("WHERE {ts_column} > NOW() - INTERVAL '7 days'\n"));
sql.push_str(&format!("ORDER BY {ts_column} DESC;\n\n"));
sql.push_str(&format!("CREATE VIEW {base_name}_count AS\n"));
sql.push_str(&format!("SELECT COUNT(*) as total FROM {view_name};\n"));
}
fn generate_monitoring_functions(sql: &mut String, view_name: &str) {
let func_name = format!("monitor_{view_name}");
sql.push_str(&format!("CREATE OR REPLACE FUNCTION {func_name}()\n"));
sql.push_str("RETURNS TABLE (\n");
sql.push_str(" metric_name TEXT,\n");
sql.push_str(" metric_value BIGINT\n");
sql.push_str(") AS $$\n");
sql.push_str("BEGIN\n");
sql.push_str(" RETURN QUERY\n");
sql.push_str(&format!(" SELECT 'row_count'::TEXT, COUNT(*)::BIGINT FROM {view_name};\n"));
sql.push_str("END;\n");
sql.push_str("$$ LANGUAGE plpgsql STABLE;\n");
}