Skip to main content

fraiseql_cli/commands/
generate_views.rs

1//! Generate DDL for Arrow views (va_*, tv_*, ta_*)
2//!
3//! This command generates SQL DDL statements for creating Arrow-optimized views
4//! from a compiled FraiseQL schema. It supports multiple view types:
5//!
6//! - `va_*` (Vector Arrow views) - For vector search and analytics
7//! - `tv_*` (Table Vector views) - For materialized table vectors
8//! - `ta_*` (Table Arrow views) - For Arrow Flight table streaming
9//!
10//! The command validates the schema, entity, and view configuration before generation.
11
12use std::{fs, path::Path};
13
14use anyhow::{Context, Result};
15use fraiseql_core::schema::CompiledSchema;
16
17use crate::output::OutputFormatter;
18
19/// Refresh strategy for view updates
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum RefreshStrategy {
23    /// Update via database triggers on fact table changes
24    TriggerBased,
25    /// Update on a scheduled interval
26    Scheduled,
27}
28
29impl RefreshStrategy {
30    /// Parse from string
31    ///
32    /// # Errors
33    ///
34    /// Returns an error if the string does not match a known refresh strategy.
35    pub fn parse(s: &str) -> std::result::Result<Self, String> {
36        match s.to_lowercase().as_str() {
37            "trigger-based" | "trigger" => Ok(Self::TriggerBased),
38            "scheduled" => Ok(Self::Scheduled),
39            _ => Err(format!("Invalid refresh strategy '{s}', expected: trigger-based, scheduled")),
40        }
41    }
42}
43
44impl std::fmt::Display for RefreshStrategy {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Self::TriggerBased => write!(f, "trigger-based"),
48            Self::Scheduled => write!(f, "scheduled"),
49        }
50    }
51}
52
53/// Configuration for view generation
54#[derive(Debug, Clone)]
55pub struct GenerateViewsConfig {
56    /// Path to schema.json file
57    pub schema_path: String,
58    /// Entity name (e.g., "User", "Order")
59    pub entity: String,
60    /// View name (e.g., "tv_user_profile", "ta_orders")
61    pub view: String,
62    /// Refresh strategy for view updates
63    pub refresh_strategy: RefreshStrategy,
64    /// Output file path (or None for stdout)
65    pub output: Option<String>,
66    /// Include helper/composition views
67    pub include_composition_views: bool,
68    /// Include monitoring functions (performance tracking, etc.)
69    pub include_monitoring: bool,
70    /// Validate only, don't write file
71    pub validate_only: bool,
72    /// Show generation steps
73    pub verbose: bool,
74}
75
76/// Run the generate-views command
77///
78/// # Arguments
79///
80/// * `config` - Generation configuration
81///
82/// # Errors
83///
84/// Returns error if:
85/// - Schema file doesn't exist or can't be read
86/// - JSON parsing fails
87/// - Entity doesn't exist in schema
88/// - View name validation fails
89/// - Output file can't be written
90pub fn run(config: GenerateViewsConfig, formatter: &OutputFormatter) -> Result<()> {
91    if config.verbose {
92        formatter.progress("Generating views...");
93        formatter.progress(&format!("   Schema: {}", config.schema_path));
94        formatter.progress(&format!("   Entity: {}", config.entity));
95        formatter.progress(&format!("   View: {}", config.view));
96        formatter.progress(&format!("   Refresh strategy: {}", config.refresh_strategy));
97    }
98
99    // 1. Load schema
100    let schema_path = Path::new(&config.schema_path);
101    if !schema_path.exists() {
102        anyhow::bail!("Schema file not found: {}", config.schema_path);
103    }
104
105    let schema_json = fs::read_to_string(schema_path).context("Failed to read schema.json")?;
106
107    // 2. Parse compiled schema
108    if config.verbose {
109        formatter.progress("   ok: Reading schema...");
110    }
111    let schema =
112        CompiledSchema::from_json(&schema_json, false).context("Failed to parse schema.json")?;
113
114    // 3. Validate entity exists in schema
115    if config.verbose {
116        formatter.progress("   ok: Validating entity...");
117    }
118    let sql_source = resolve_entity_sql_source(&schema, &config.entity)?;
119
120    // 4. Validate view name
121    if config.verbose {
122        formatter.progress("   ok: Validating view name...");
123    }
124    let view_type = validate_view_name(&config.view)?;
125
126    if config.verbose {
127        formatter.progress(&format!("   ok: View type: {view_type}"));
128    }
129
130    // 5. Generate SQL DDL
131    if config.verbose {
132        formatter.progress("   ok: Generating SQL DDL...");
133    }
134    let sql = generate_view_sql(
135        &config.entity,
136        &sql_source,
137        &config.view,
138        view_type,
139        config.refresh_strategy,
140        config.include_composition_views,
141        config.include_monitoring,
142    );
143
144    // 6. If validate-only, stop here
145    if config.validate_only {
146        println!("✓ View DDL is valid");
147        println!("  Entity: {}", config.entity);
148        println!("  View: {}", config.view);
149        println!("  Type: {view_type}");
150        println!("  Refresh strategy: {}", config.refresh_strategy);
151        println!("  Lines: {}", sql.lines().count());
152        return Ok(());
153    }
154
155    // 7. Write output
156    if config.verbose {
157        formatter.progress("   ok: Writing output...");
158    }
159    let output_path = config.output.unwrap_or_else(|| format!("{}.sql", config.view));
160
161    fs::write(&output_path, sql.clone()).context("Failed to write output file")?;
162
163    // 8. Success message
164    println!("✓ View DDL generated successfully");
165    println!("  Entity: {}", config.entity);
166    println!("  View: {}", config.view);
167    println!("  Type: {view_type}");
168    println!("  Output: {output_path}");
169    println!("  Lines: {}", sql.lines().count());
170
171    if config.include_composition_views {
172        println!("  ✓ Includes composition views");
173    }
174
175    if config.include_monitoring {
176        println!("  ✓ Includes monitoring functions");
177    }
178
179    if config.verbose {
180        formatter.progress("\nGenerated SQL preview (first 5 lines):");
181        for line in sql.lines().take(5) {
182            formatter.progress(&format!("  {line}"));
183        }
184    }
185
186    Ok(())
187}
188
189/// Look up an entity in the schema and return its SQL source table/view name.
190///
191/// # Errors
192///
193/// Returns an error if the entity does not exist in the schema.
194fn resolve_entity_sql_source(schema: &CompiledSchema, entity: &str) -> Result<String> {
195    if let Some(type_def) = schema.types.iter().find(|t| t.name == entity) {
196        Ok(type_def.sql_source.as_str().to_string())
197    } else {
198        let available = schema.types.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ");
199        anyhow::bail!("Entity '{entity}' not found in schema. Available types: {available}")
200    }
201}
202
203/// Determine view type from view name and validate naming convention
204///
205/// Valid prefixes:
206/// - `va_` - Vector Arrow view
207/// - `tv_` - Table Vector view
208/// - `ta_` - Table Arrow view
209pub(crate) fn validate_view_name(view_name: &str) -> Result<&'static str> {
210    if view_name.starts_with("va_") {
211        Ok("Vector Arrow (va_)")
212    } else if view_name.starts_with("tv_") {
213        Ok("Table Vector (tv_)")
214    } else if view_name.starts_with("ta_") {
215        Ok("Table Arrow (ta_)")
216    } else {
217        anyhow::bail!("Invalid view name '{view_name}'. Must start with va_, tv_, or ta_")
218    }
219}
220
221/// Generate SQL DDL for the view
222///
223/// # Arguments
224///
225/// * `entity` - Entity/type name from schema
226/// * `sql_source` - Actual SQL table/view name from the schema (e.g., `v_user`)
227/// * `view_name` - Full view name (e.g., "tv_user_profile")
228/// * `view_type` - View type string for documentation
229/// * `refresh_strategy` - How the view is kept up-to-date
230/// * `include_composition_views` - Whether to include helper views
231/// * `include_monitoring` - Whether to include monitoring functions
232pub(crate) fn generate_view_sql(
233    entity: &str,
234    sql_source: &str,
235    view_name: &str,
236    view_type: &str,
237    refresh_strategy: RefreshStrategy,
238    include_composition_views: bool,
239    include_monitoring: bool,
240) -> String {
241    let mut sql = String::new();
242
243    // Header
244    sql.push_str("-- Auto-generated Arrow view DDL\n");
245    sql.push_str(&format!("-- Entity: {entity}\n"));
246    sql.push_str(&format!("-- View: {view_name}\n"));
247    sql.push_str(&format!("-- Type: {view_type}\n"));
248    sql.push_str(&format!("-- Refresh strategy: {refresh_strategy}\n"));
249    sql.push_str("-- Generated by: fraiseql generate-views\n\n");
250
251    // Drop existing view if it exists
252    sql.push_str(&format!("DROP VIEW IF EXISTS {view_name} CASCADE;\n\n"));
253
254    // Main view definition.
255    // `validate_view_name` guarantees the prefix is one of va_/tv_/ta_ before
256    // this function is called, so the wildcard arm is unreachable.
257    #[allow(clippy::unreachable)]
258    // Reason: `validate_view_name` (called upstream of this function) restricts
259    // the prefix to `va`/`tv`/`ta`; any other value would be a programmer
260    // error, not user input.
261    match view_name.split('_').next() {
262        Some("va") => {
263            generate_vector_arrow_view(&mut sql, entity, sql_source, view_name);
264        },
265        Some("tv") => {
266            generate_table_vector_view(&mut sql, entity, sql_source, view_name);
267        },
268        Some("ta") => {
269            generate_table_arrow_view(&mut sql, entity, sql_source, view_name);
270        },
271        _ => unreachable!("view name validated by validate_view_name before generate_view_sql"),
272    }
273
274    // Composition views (optional)
275    if include_composition_views {
276        sql.push_str("\n-- Composition views\n");
277        generate_composition_views(&mut sql, entity, view_name);
278    }
279
280    // Monitoring functions (optional)
281    if include_monitoring {
282        sql.push_str("\n-- Monitoring functions\n");
283        generate_monitoring_functions(&mut sql, view_name);
284    }
285
286    sql
287}
288
289/// Generate a Vector Arrow (va_*) view for vector search and analytics
290fn generate_vector_arrow_view(sql: &mut String, entity: &str, sql_source: &str, view_name: &str) {
291    sql.push_str(&format!("CREATE VIEW {view_name} AS\n"));
292    sql.push_str("SELECT\n");
293    sql.push_str("    id,\n");
294    sql.push_str(&format!("    -- {entity} entity fields\n"));
295    sql.push_str("    created_at,\n");
296    sql.push_str("    updated_at\n");
297    sql.push_str(&format!("FROM {sql_source}\n"));
298    sql.push_str("WHERE archived_at IS NULL;\n");
299}
300
301/// Generate a Table Vector (tv_*) view for materialized table vectors
302fn generate_table_vector_view(sql: &mut String, entity: &str, sql_source: &str, view_name: &str) {
303    sql.push_str(&format!("CREATE MATERIALIZED VIEW {view_name} AS\n"));
304    sql.push_str("SELECT\n");
305    sql.push_str("    id,\n");
306    sql.push_str(&format!("    -- {entity} entity vector representation\n"));
307    sql.push_str("    CURRENT_TIMESTAMP as materialized_at\n");
308    sql.push_str(&format!("FROM {sql_source}\n"));
309    sql.push_str("WHERE archived_at IS NULL;\n");
310    sql.push('\n');
311    let base_name = view_name.trim_start_matches("tv_");
312    sql.push_str(&format!("CREATE INDEX idx_{base_name}_id ON {view_name} (id);\n"));
313}
314
315/// Generate a Table Arrow (ta_*) view for Arrow Flight streaming
316fn generate_table_arrow_view(sql: &mut String, entity: &str, sql_source: &str, view_name: &str) {
317    sql.push_str(&format!("CREATE VIEW {view_name} AS\n"));
318    sql.push_str("SELECT\n");
319    sql.push_str("    id,\n");
320    sql.push_str(&format!("    -- {entity} entity fields optimized for Arrow\n"));
321    sql.push_str("    created_at,\n");
322    sql.push_str("    updated_at\n");
323    sql.push_str(&format!("FROM {sql_source}\n"));
324    sql.push_str("WHERE archived_at IS NULL\n");
325    sql.push_str("ORDER BY id;\n");
326}
327
328/// Generate helper composition views
329fn generate_composition_views(sql: &mut String, _entity: &str, view_name: &str) {
330    let base_name = view_name
331        .trim_start_matches("va_")
332        .trim_start_matches("tv_")
333        .trim_start_matches("ta_");
334
335    // Recent items view
336    sql.push_str(&format!("CREATE VIEW {base_name}_recent AS\n"));
337    sql.push_str("SELECT * FROM {}\n");
338    sql.push_str("WHERE updated_at > NOW() - INTERVAL '7 days'\n");
339    sql.push_str("ORDER BY updated_at DESC;\n\n");
340
341    // Count view
342    sql.push_str(&format!("CREATE VIEW {base_name}_count AS\n"));
343    sql.push_str("SELECT COUNT(*) as total FROM {};\n");
344}
345
346/// Generate monitoring functions for the view
347fn generate_monitoring_functions(sql: &mut String, view_name: &str) {
348    let func_name = format!("monitor_{view_name}");
349
350    sql.push_str(&format!("CREATE OR REPLACE FUNCTION {func_name}()\n"));
351    sql.push_str("RETURNS TABLE (\n");
352    sql.push_str("    metric_name TEXT,\n");
353    sql.push_str("    metric_value BIGINT\n");
354    sql.push_str(") AS $$\n");
355    sql.push_str("BEGIN\n");
356    sql.push_str("    RETURN QUERY\n");
357    sql.push_str(&format!("    SELECT 'row_count'::TEXT, COUNT(*)::BIGINT FROM {view_name};\n"));
358    sql.push_str("END;\n");
359    sql.push_str("$$ LANGUAGE plpgsql IMMUTABLE;\n");
360}