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 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 {
println!("✓ View DDL is valid");
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(())
}
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");
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, entity, 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, _entity: &str, view_name: &str) {
let base_name = view_name
.trim_start_matches("va_")
.trim_start_matches("tv_")
.trim_start_matches("ta_");
sql.push_str(&format!("CREATE VIEW {base_name}_recent AS\n"));
sql.push_str("SELECT * FROM {}\n");
sql.push_str("WHERE updated_at > NOW() - INTERVAL '7 days'\n");
sql.push_str("ORDER BY updated_at DESC;\n\n");
sql.push_str(&format!("CREATE VIEW {base_name}_count AS\n"));
sql.push_str("SELECT COUNT(*) as total FROM {};\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 IMMUTABLE;\n");
}