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
17/// Refresh strategy for view updates
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum RefreshStrategy {
20    /// Update via database triggers on fact table changes
21    TriggerBased,
22    /// Update on a scheduled interval
23    Scheduled,
24}
25
26impl RefreshStrategy {
27    /// Parse from string
28    pub fn parse(s: &str) -> std::result::Result<Self, String> {
29        match s.to_lowercase().as_str() {
30            "trigger-based" | "trigger" => Ok(Self::TriggerBased),
31            "scheduled" => Ok(Self::Scheduled),
32            _ => Err(format!("Invalid refresh strategy '{s}', expected: trigger-based, scheduled")),
33        }
34    }
35}
36
37impl std::fmt::Display for RefreshStrategy {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            Self::TriggerBased => write!(f, "trigger-based"),
41            Self::Scheduled => write!(f, "scheduled"),
42        }
43    }
44}
45
46/// Configuration for view generation
47#[derive(Debug, Clone)]
48pub struct GenerateViewsConfig {
49    /// Path to schema.json file
50    pub schema_path: String,
51    /// Entity name (e.g., "User", "Order")
52    pub entity: String,
53    /// View name (e.g., "tv_user_profile", "ta_orders")
54    pub view: String,
55    /// Refresh strategy for view updates
56    pub refresh_strategy: RefreshStrategy,
57    /// Output file path (or None for stdout)
58    pub output: Option<String>,
59    /// Include helper/composition views
60    pub include_composition_views: bool,
61    /// Include monitoring functions (performance tracking, etc.)
62    pub include_monitoring: bool,
63    /// Validate only, don't write file
64    pub validate_only: bool,
65    /// Show generation steps
66    pub verbose: bool,
67}
68
69/// Run the generate-views command
70///
71/// # Arguments
72///
73/// * `config` - Generation configuration
74///
75/// # Errors
76///
77/// Returns error if:
78/// - Schema file doesn't exist or can't be read
79/// - JSON parsing fails
80/// - Entity doesn't exist in schema
81/// - View name validation fails
82/// - Output file can't be written
83pub fn run(config: GenerateViewsConfig) -> Result<()> {
84    if config.verbose {
85        eprintln!("📋 Generating views...");
86        eprintln!("   Schema: {}", config.schema_path);
87        eprintln!("   Entity: {}", config.entity);
88        eprintln!("   View: {}", config.view);
89        eprintln!("   Refresh strategy: {}", config.refresh_strategy);
90    }
91
92    // 1. Load schema
93    let schema_path = Path::new(&config.schema_path);
94    if !schema_path.exists() {
95        anyhow::bail!("Schema file not found: {}", config.schema_path);
96    }
97
98    let schema_json = fs::read_to_string(schema_path).context("Failed to read schema.json")?;
99
100    // 2. Parse compiled schema
101    if config.verbose {
102        eprintln!("   ✓ Reading schema...");
103    }
104    let schema = CompiledSchema::from_json(&schema_json).context("Failed to parse schema.json")?;
105
106    // 3. Validate entity exists in schema
107    if config.verbose {
108        eprintln!("   ✓ Validating entity...");
109    }
110    validate_entity(&schema, &config.entity)?;
111
112    // 4. Validate view name
113    if config.verbose {
114        eprintln!("   ✓ Validating view name...");
115    }
116    let view_type = validate_view_name(&config.view)?;
117
118    if config.verbose {
119        eprintln!("   ✓ View type: {view_type}");
120    }
121
122    // 5. Generate SQL DDL
123    if config.verbose {
124        eprintln!("   ✓ Generating SQL DDL...");
125    }
126    let sql = generate_view_sql(
127        &config.entity,
128        &config.view,
129        view_type,
130        config.refresh_strategy,
131        config.include_composition_views,
132        config.include_monitoring,
133    );
134
135    // 6. If validate-only, stop here
136    if config.validate_only {
137        println!("✓ View DDL is valid");
138        println!("  Entity: {}", config.entity);
139        println!("  View: {}", config.view);
140        println!("  Type: {view_type}");
141        println!("  Refresh strategy: {}", config.refresh_strategy);
142        println!("  Lines: {}", sql.lines().count());
143        return Ok(());
144    }
145
146    // 7. Write output
147    if config.verbose {
148        eprintln!("   ✓ Writing output...");
149    }
150    let output_path = config.output.unwrap_or_else(|| format!("{}.sql", config.view));
151
152    fs::write(&output_path, sql.clone()).context("Failed to write output file")?;
153
154    // 8. Success message
155    println!("✓ View DDL generated successfully");
156    println!("  Entity: {}", config.entity);
157    println!("  View: {}", config.view);
158    println!("  Type: {view_type}");
159    println!("  Output: {output_path}");
160    println!("  Lines: {}", sql.lines().count());
161
162    if config.include_composition_views {
163        println!("  ✓ Includes composition views");
164    }
165
166    if config.include_monitoring {
167        println!("  ✓ Includes monitoring functions");
168    }
169
170    if config.verbose {
171        eprintln!("\nGenerated SQL preview (first 5 lines):");
172        for line in sql.lines().take(5) {
173            eprintln!("  {line}");
174        }
175    }
176
177    Ok(())
178}
179
180/// Validate that entity exists in the schema
181fn validate_entity(schema: &CompiledSchema, entity: &str) -> Result<()> {
182    if schema.types.iter().any(|t| t.name == entity) {
183        Ok(())
184    } else {
185        let available = schema.types.iter().map(|t| t.name.clone()).collect::<Vec<_>>().join(", ");
186        anyhow::bail!("Entity '{entity}' not found in schema. Available types: {available}")
187    }
188}
189
190/// Determine view type from view name and validate naming convention
191///
192/// Valid prefixes:
193/// - `va_` - Vector Arrow view
194/// - `tv_` - Table Vector view
195/// - `ta_` - Table Arrow view
196fn validate_view_name(view_name: &str) -> Result<&'static str> {
197    if view_name.starts_with("va_") {
198        Ok("Vector Arrow (va_)")
199    } else if view_name.starts_with("tv_") {
200        Ok("Table Vector (tv_)")
201    } else if view_name.starts_with("ta_") {
202        Ok("Table Arrow (ta_)")
203    } else {
204        anyhow::bail!("Invalid view name '{view_name}'. Must start with va_, tv_, or ta_")
205    }
206}
207
208/// Generate SQL DDL for the view
209///
210/// # Arguments
211///
212/// * `entity` - Entity/type name from schema
213/// * `view_name` - Full view name (e.g., "tv_user_profile")
214/// * `view_type` - View type string for documentation
215/// * `refresh_strategy` - How the view is kept up-to-date
216/// * `include_composition_views` - Whether to include helper views
217/// * `include_monitoring` - Whether to include monitoring functions
218fn generate_view_sql(
219    entity: &str,
220    view_name: &str,
221    view_type: &str,
222    refresh_strategy: RefreshStrategy,
223    include_composition_views: bool,
224    include_monitoring: bool,
225) -> String {
226    let mut sql = String::new();
227
228    // Header
229    sql.push_str("-- Auto-generated Arrow view DDL\n");
230    sql.push_str(&format!("-- Entity: {entity}\n"));
231    sql.push_str(&format!("-- View: {view_name}\n"));
232    sql.push_str(&format!("-- Type: {view_type}\n"));
233    sql.push_str(&format!("-- Refresh strategy: {refresh_strategy}\n"));
234    sql.push_str("-- Generated by: fraiseql generate-views\n\n");
235
236    // Drop existing view if it exists
237    sql.push_str(&format!("DROP VIEW IF EXISTS {view_name} CASCADE;\n\n"));
238
239    // Main view definition
240    match view_name.split('_').next() {
241        Some("va") => {
242            generate_vector_arrow_view(&mut sql, entity, view_name);
243        },
244        Some("tv") => {
245            generate_table_vector_view(&mut sql, entity, view_name);
246        },
247        Some("ta") => {
248            generate_table_arrow_view(&mut sql, entity, view_name);
249        },
250        _ => {
251            // Fallback: generate a basic view
252            sql.push_str(&format!("CREATE VIEW {view_name} AS\n"));
253            sql.push_str("SELECT * FROM public.schema_placeholder;\n");
254        },
255    }
256
257    // Composition views (optional)
258    if include_composition_views {
259        sql.push_str("\n-- Composition views\n");
260        generate_composition_views(&mut sql, entity, view_name);
261    }
262
263    // Monitoring functions (optional)
264    if include_monitoring {
265        sql.push_str("\n-- Monitoring functions\n");
266        generate_monitoring_functions(&mut sql, view_name);
267    }
268
269    sql
270}
271
272/// Generate a Vector Arrow (va_*) view for vector search and analytics
273fn generate_vector_arrow_view(sql: &mut String, entity: &str, view_name: &str) {
274    sql.push_str(&format!("CREATE VIEW {view_name} AS\n"));
275    sql.push_str("SELECT\n");
276    sql.push_str("    id,\n");
277    sql.push_str(&format!("    -- {entity} entity fields\n"));
278    sql.push_str("    created_at,\n");
279    sql.push_str("    updated_at\n");
280    sql.push_str("FROM public.schema_placeholder\n");
281    sql.push_str("WHERE archived_at IS NULL;\n");
282}
283
284/// Generate a Table Vector (tv_*) view for materialized table vectors
285fn generate_table_vector_view(sql: &mut String, entity: &str, view_name: &str) {
286    sql.push_str(&format!("CREATE MATERIALIZED VIEW {view_name} AS\n"));
287    sql.push_str("SELECT\n");
288    sql.push_str("    id,\n");
289    sql.push_str(&format!("    -- {entity} entity vector representation\n"));
290    sql.push_str("    CURRENT_TIMESTAMP as materialized_at\n");
291    sql.push_str("FROM public.schema_placeholder\n");
292    sql.push_str("WHERE archived_at IS NULL;\n");
293    sql.push('\n');
294    let base_name = view_name.trim_start_matches("tv_");
295    sql.push_str(&format!("CREATE INDEX idx_{base_name}_id ON {view_name} (id);\n"));
296}
297
298/// Generate a Table Arrow (ta_*) view for Arrow Flight streaming
299fn generate_table_arrow_view(sql: &mut String, entity: &str, view_name: &str) {
300    sql.push_str(&format!("CREATE VIEW {view_name} AS\n"));
301    sql.push_str("SELECT\n");
302    sql.push_str("    id,\n");
303    sql.push_str(&format!("    -- {entity} entity fields optimized for Arrow\n"));
304    sql.push_str("    created_at,\n");
305    sql.push_str("    updated_at\n");
306    sql.push_str("FROM public.schema_placeholder\n");
307    sql.push_str("WHERE archived_at IS NULL\n");
308    sql.push_str("ORDER BY id;\n");
309}
310
311/// Generate helper composition views
312fn generate_composition_views(sql: &mut String, _entity: &str, view_name: &str) {
313    let base_name = view_name
314        .trim_start_matches("va_")
315        .trim_start_matches("tv_")
316        .trim_start_matches("ta_");
317
318    // Recent items view
319    sql.push_str(&format!("CREATE VIEW {base_name}_recent AS\n"));
320    sql.push_str("SELECT * FROM {}\n");
321    sql.push_str("WHERE updated_at > NOW() - INTERVAL '7 days'\n");
322    sql.push_str("ORDER BY updated_at DESC;\n\n");
323
324    // Count view
325    sql.push_str(&format!("CREATE VIEW {base_name}_count AS\n"));
326    sql.push_str("SELECT COUNT(*) as total FROM {};\n");
327}
328
329/// Generate monitoring functions for the view
330fn generate_monitoring_functions(sql: &mut String, view_name: &str) {
331    let func_name = format!("monitor_{view_name}");
332
333    sql.push_str(&format!("CREATE OR REPLACE FUNCTION {func_name}()\n"));
334    sql.push_str("RETURNS TABLE (\n");
335    sql.push_str("    metric_name TEXT,\n");
336    sql.push_str("    metric_value BIGINT\n");
337    sql.push_str(") AS $$\n");
338    sql.push_str("BEGIN\n");
339    sql.push_str("    RETURN QUERY\n");
340    sql.push_str(&format!("    SELECT 'row_count'::TEXT, COUNT(*)::BIGINT FROM {view_name};\n"));
341    sql.push_str("END;\n");
342    sql.push_str("$$ LANGUAGE plpgsql IMMUTABLE;\n");
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn test_refresh_strategy_from_str() {
351        assert_eq!(RefreshStrategy::parse("trigger-based").unwrap(), RefreshStrategy::TriggerBased);
352        assert_eq!(RefreshStrategy::parse("trigger").unwrap(), RefreshStrategy::TriggerBased);
353        assert_eq!(RefreshStrategy::parse("scheduled").unwrap(), RefreshStrategy::Scheduled);
354        assert!(RefreshStrategy::parse("invalid").is_err());
355    }
356
357    #[test]
358    fn test_refresh_strategy_display() {
359        assert_eq!(RefreshStrategy::TriggerBased.to_string(), "trigger-based");
360        assert_eq!(RefreshStrategy::Scheduled.to_string(), "scheduled");
361    }
362
363    #[test]
364    fn test_validate_view_name_vector_arrow() {
365        assert_eq!(validate_view_name("va_user_embeddings").unwrap(), "Vector Arrow (va_)");
366    }
367
368    #[test]
369    fn test_validate_view_name_table_vector() {
370        assert_eq!(validate_view_name("tv_user_profile").unwrap(), "Table Vector (tv_)");
371    }
372
373    #[test]
374    fn test_validate_view_name_table_arrow() {
375        assert_eq!(validate_view_name("ta_orders").unwrap(), "Table Arrow (ta_)");
376    }
377
378    #[test]
379    fn test_validate_view_name_invalid() {
380        assert!(validate_view_name("invalid_view").is_err());
381        assert!(validate_view_name("v_user").is_err());
382    }
383
384    #[test]
385    fn test_generate_view_sql_vector_arrow() {
386        let sql = generate_view_sql(
387            "User",
388            "va_user_embeddings",
389            "Vector Arrow (va_)",
390            RefreshStrategy::TriggerBased,
391            false,
392            false,
393        );
394
395        assert!(sql.contains("CREATE VIEW va_user_embeddings"));
396        assert!(sql.contains("Entity: User"));
397        assert!(sql.contains("Vector Arrow (va_)"));
398        assert!(sql.contains("trigger-based"));
399    }
400
401    #[test]
402    fn test_generate_view_sql_table_vector() {
403        let sql = generate_view_sql(
404            "Order",
405            "tv_order_summary",
406            "Table Vector (tv_)",
407            RefreshStrategy::Scheduled,
408            false,
409            false,
410        );
411
412        assert!(sql.contains("CREATE MATERIALIZED VIEW tv_order_summary"));
413        assert!(sql.contains("Entity: Order"));
414        assert!(sql.contains("scheduled"));
415    }
416
417    #[test]
418    fn test_generate_view_sql_with_composition_views() {
419        let sql = generate_view_sql(
420            "User",
421            "tv_user_profile",
422            "Table Vector (tv_)",
423            RefreshStrategy::TriggerBased,
424            true,
425            false,
426        );
427
428        assert!(sql.contains("Composition views"));
429        assert!(sql.contains("_recent"));
430        assert!(sql.contains("_count"));
431    }
432
433    #[test]
434    fn test_generate_view_sql_with_monitoring() {
435        let sql = generate_view_sql(
436            "User",
437            "tv_user_profile",
438            "Table Vector (tv_)",
439            RefreshStrategy::TriggerBased,
440            false,
441            true,
442        );
443
444        assert!(sql.contains("Monitoring functions"));
445        assert!(sql.contains("monitor_tv_user_profile"));
446        assert!(sql.contains("metric_name"));
447    }
448
449    #[test]
450    fn test_generate_view_sql_full_options() {
451        let sql = generate_view_sql(
452            "User",
453            "ta_users",
454            "Table Arrow (ta_)",
455            RefreshStrategy::TriggerBased,
456            true,
457            true,
458        );
459
460        assert!(sql.contains("Entity: User"));
461        assert!(sql.contains("View: ta_users"));
462        assert!(sql.contains("Composition views"));
463        assert!(sql.contains("Monitoring functions"));
464    }
465}