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 = CompiledSchema::from_json(&schema_json).context("Failed to parse schema.json")?;
112
113    // 3. Validate entity exists in schema
114    if config.verbose {
115        formatter.progress("   ok: Validating entity...");
116    }
117    let sql_source = resolve_entity_sql_source(&schema, &config.entity)?;
118
119    // 4. Validate view name
120    if config.verbose {
121        formatter.progress("   ok: Validating view name...");
122    }
123    let view_type = validate_view_name(&config.view)?;
124
125    if config.verbose {
126        formatter.progress(&format!("   ok: View type: {view_type}"));
127    }
128
129    // 5. Generate SQL DDL
130    if config.verbose {
131        formatter.progress("   ok: Generating SQL DDL...");
132    }
133    let sql = generate_view_sql(
134        &config.entity,
135        &sql_source,
136        &config.view,
137        view_type,
138        config.refresh_strategy,
139        config.include_composition_views,
140        config.include_monitoring,
141    );
142
143    // 6. If validate-only, stop here
144    if config.validate_only {
145        println!("✓ View DDL is valid");
146        println!("  Entity: {}", config.entity);
147        println!("  View: {}", config.view);
148        println!("  Type: {view_type}");
149        println!("  Refresh strategy: {}", config.refresh_strategy);
150        println!("  Lines: {}", sql.lines().count());
151        return Ok(());
152    }
153
154    // 7. Write output
155    if config.verbose {
156        formatter.progress("   ok: Writing output...");
157    }
158    let output_path = config.output.unwrap_or_else(|| format!("{}.sql", config.view));
159
160    fs::write(&output_path, sql.clone()).context("Failed to write output file")?;
161
162    // 8. Success message
163    println!("✓ View DDL generated successfully");
164    println!("  Entity: {}", config.entity);
165    println!("  View: {}", config.view);
166    println!("  Type: {view_type}");
167    println!("  Output: {output_path}");
168    println!("  Lines: {}", sql.lines().count());
169
170    if config.include_composition_views {
171        println!("  ✓ Includes composition views");
172    }
173
174    if config.include_monitoring {
175        println!("  ✓ Includes monitoring functions");
176    }
177
178    if config.verbose {
179        formatter.progress("\nGenerated SQL preview (first 5 lines):");
180        for line in sql.lines().take(5) {
181            formatter.progress(&format!("  {line}"));
182        }
183    }
184
185    Ok(())
186}
187
188/// Look up an entity in the schema and return its SQL source table/view name.
189///
190/// # Errors
191///
192/// Returns an error if the entity does not exist in the schema.
193fn resolve_entity_sql_source(schema: &CompiledSchema, entity: &str) -> Result<String> {
194    if let Some(type_def) = schema.types.iter().find(|t| t.name == entity) {
195        Ok(type_def.sql_source.as_str().to_string())
196    } else {
197        let available = schema.types.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ");
198        anyhow::bail!("Entity '{entity}' not found in schema. Available types: {available}")
199    }
200}
201
202/// Determine view type from view name and validate naming convention
203///
204/// Valid prefixes:
205/// - `va_` - Vector Arrow view
206/// - `tv_` - Table Vector view
207/// - `ta_` - Table Arrow view
208fn validate_view_name(view_name: &str) -> Result<&'static str> {
209    if view_name.starts_with("va_") {
210        Ok("Vector Arrow (va_)")
211    } else if view_name.starts_with("tv_") {
212        Ok("Table Vector (tv_)")
213    } else if view_name.starts_with("ta_") {
214        Ok("Table Arrow (ta_)")
215    } else {
216        anyhow::bail!("Invalid view name '{view_name}'. Must start with va_, tv_, or ta_")
217    }
218}
219
220/// Generate SQL DDL for the view
221///
222/// # Arguments
223///
224/// * `entity` - Entity/type name from schema
225/// * `sql_source` - Actual SQL table/view name from the schema (e.g., `v_user`)
226/// * `view_name` - Full view name (e.g., "tv_user_profile")
227/// * `view_type` - View type string for documentation
228/// * `refresh_strategy` - How the view is kept up-to-date
229/// * `include_composition_views` - Whether to include helper views
230/// * `include_monitoring` - Whether to include monitoring functions
231fn generate_view_sql(
232    entity: &str,
233    sql_source: &str,
234    view_name: &str,
235    view_type: &str,
236    refresh_strategy: RefreshStrategy,
237    include_composition_views: bool,
238    include_monitoring: bool,
239) -> String {
240    let mut sql = String::new();
241
242    // Header
243    sql.push_str("-- Auto-generated Arrow view DDL\n");
244    sql.push_str(&format!("-- Entity: {entity}\n"));
245    sql.push_str(&format!("-- View: {view_name}\n"));
246    sql.push_str(&format!("-- Type: {view_type}\n"));
247    sql.push_str(&format!("-- Refresh strategy: {refresh_strategy}\n"));
248    sql.push_str("-- Generated by: fraiseql generate-views\n\n");
249
250    // Drop existing view if it exists
251    sql.push_str(&format!("DROP VIEW IF EXISTS {view_name} CASCADE;\n\n"));
252
253    // Main view definition.
254    // `validate_view_name` guarantees the prefix is one of va_/tv_/ta_ before
255    // this function is called, so the wildcard arm is unreachable.
256    match view_name.split('_').next() {
257        Some("va") => {
258            generate_vector_arrow_view(&mut sql, entity, sql_source, view_name);
259        },
260        Some("tv") => {
261            generate_table_vector_view(&mut sql, entity, sql_source, view_name);
262        },
263        Some("ta") => {
264            generate_table_arrow_view(&mut sql, entity, sql_source, view_name);
265        },
266        _ => unreachable!("view name validated by validate_view_name before generate_view_sql"),
267    }
268
269    // Composition views (optional)
270    if include_composition_views {
271        sql.push_str("\n-- Composition views\n");
272        generate_composition_views(&mut sql, entity, view_name);
273    }
274
275    // Monitoring functions (optional)
276    if include_monitoring {
277        sql.push_str("\n-- Monitoring functions\n");
278        generate_monitoring_functions(&mut sql, view_name);
279    }
280
281    sql
282}
283
284/// Generate a Vector Arrow (va_*) view for vector search and analytics
285fn generate_vector_arrow_view(sql: &mut String, entity: &str, sql_source: &str, view_name: &str) {
286    sql.push_str(&format!("CREATE VIEW {view_name} AS\n"));
287    sql.push_str("SELECT\n");
288    sql.push_str("    id,\n");
289    sql.push_str(&format!("    -- {entity} entity fields\n"));
290    sql.push_str("    created_at,\n");
291    sql.push_str("    updated_at\n");
292    sql.push_str(&format!("FROM {sql_source}\n"));
293    sql.push_str("WHERE archived_at IS NULL;\n");
294}
295
296/// Generate a Table Vector (tv_*) view for materialized table vectors
297fn generate_table_vector_view(sql: &mut String, entity: &str, sql_source: &str, view_name: &str) {
298    sql.push_str(&format!("CREATE MATERIALIZED VIEW {view_name} AS\n"));
299    sql.push_str("SELECT\n");
300    sql.push_str("    id,\n");
301    sql.push_str(&format!("    -- {entity} entity vector representation\n"));
302    sql.push_str("    CURRENT_TIMESTAMP as materialized_at\n");
303    sql.push_str(&format!("FROM {sql_source}\n"));
304    sql.push_str("WHERE archived_at IS NULL;\n");
305    sql.push('\n');
306    let base_name = view_name.trim_start_matches("tv_");
307    sql.push_str(&format!("CREATE INDEX idx_{base_name}_id ON {view_name} (id);\n"));
308}
309
310/// Generate a Table Arrow (ta_*) view for Arrow Flight streaming
311fn generate_table_arrow_view(sql: &mut String, entity: &str, sql_source: &str, view_name: &str) {
312    sql.push_str(&format!("CREATE VIEW {view_name} AS\n"));
313    sql.push_str("SELECT\n");
314    sql.push_str("    id,\n");
315    sql.push_str(&format!("    -- {entity} entity fields optimized for Arrow\n"));
316    sql.push_str("    created_at,\n");
317    sql.push_str("    updated_at\n");
318    sql.push_str(&format!("FROM {sql_source}\n"));
319    sql.push_str("WHERE archived_at IS NULL\n");
320    sql.push_str("ORDER BY id;\n");
321}
322
323/// Generate helper composition views
324fn generate_composition_views(sql: &mut String, _entity: &str, view_name: &str) {
325    let base_name = view_name
326        .trim_start_matches("va_")
327        .trim_start_matches("tv_")
328        .trim_start_matches("ta_");
329
330    // Recent items view
331    sql.push_str(&format!("CREATE VIEW {base_name}_recent AS\n"));
332    sql.push_str("SELECT * FROM {}\n");
333    sql.push_str("WHERE updated_at > NOW() - INTERVAL '7 days'\n");
334    sql.push_str("ORDER BY updated_at DESC;\n\n");
335
336    // Count view
337    sql.push_str(&format!("CREATE VIEW {base_name}_count AS\n"));
338    sql.push_str("SELECT COUNT(*) as total FROM {};\n");
339}
340
341/// Generate monitoring functions for the view
342fn generate_monitoring_functions(sql: &mut String, view_name: &str) {
343    let func_name = format!("monitor_{view_name}");
344
345    sql.push_str(&format!("CREATE OR REPLACE FUNCTION {func_name}()\n"));
346    sql.push_str("RETURNS TABLE (\n");
347    sql.push_str("    metric_name TEXT,\n");
348    sql.push_str("    metric_value BIGINT\n");
349    sql.push_str(") AS $$\n");
350    sql.push_str("BEGIN\n");
351    sql.push_str("    RETURN QUERY\n");
352    sql.push_str(&format!("    SELECT 'row_count'::TEXT, COUNT(*)::BIGINT FROM {view_name};\n"));
353    sql.push_str("END;\n");
354    sql.push_str("$$ LANGUAGE plpgsql IMMUTABLE;\n");
355}
356
357#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn test_refresh_strategy_from_str() {
364        assert_eq!(RefreshStrategy::parse("trigger-based").unwrap(), RefreshStrategy::TriggerBased);
365        assert_eq!(RefreshStrategy::parse("trigger").unwrap(), RefreshStrategy::TriggerBased);
366        assert_eq!(RefreshStrategy::parse("scheduled").unwrap(), RefreshStrategy::Scheduled);
367        assert!(
368            RefreshStrategy::parse("invalid").is_err(),
369            "expected Err for unknown refresh strategy"
370        );
371    }
372
373    #[test]
374    fn test_refresh_strategy_display() {
375        assert_eq!(RefreshStrategy::TriggerBased.to_string(), "trigger-based");
376        assert_eq!(RefreshStrategy::Scheduled.to_string(), "scheduled");
377    }
378
379    #[test]
380    fn test_validate_view_name_vector_arrow() {
381        assert_eq!(validate_view_name("va_user_embeddings").unwrap(), "Vector Arrow (va_)");
382    }
383
384    #[test]
385    fn test_validate_view_name_table_vector() {
386        assert_eq!(validate_view_name("tv_user_profile").unwrap(), "Table Vector (tv_)");
387    }
388
389    #[test]
390    fn test_validate_view_name_table_arrow() {
391        assert_eq!(validate_view_name("ta_orders").unwrap(), "Table Arrow (ta_)");
392    }
393
394    #[test]
395    fn test_validate_view_name_invalid() {
396        assert!(
397            validate_view_name("invalid_view").is_err(),
398            "expected Err for invalid_view prefix"
399        );
400        assert!(
401            validate_view_name("v_user").is_err(),
402            "expected Err for v_ prefix (not va_/tv_/ta_)"
403        );
404    }
405
406    #[test]
407    fn test_generate_view_sql_vector_arrow() {
408        let sql = generate_view_sql(
409            "User",
410            "v_user",
411            "va_user_embeddings",
412            "Vector Arrow (va_)",
413            RefreshStrategy::TriggerBased,
414            false,
415            false,
416        );
417
418        assert!(sql.contains("CREATE VIEW va_user_embeddings"));
419        assert!(sql.contains("Entity: User"));
420        assert!(sql.contains("Vector Arrow (va_)"));
421        assert!(sql.contains("trigger-based"));
422        assert!(
423            sql.contains("FROM v_user"),
424            "must use entity sql_source, not schema_placeholder"
425        );
426        assert!(!sql.contains("schema_placeholder"));
427    }
428
429    #[test]
430    fn test_generate_view_sql_table_vector() {
431        let sql = generate_view_sql(
432            "Order",
433            "v_order",
434            "tv_order_summary",
435            "Table Vector (tv_)",
436            RefreshStrategy::Scheduled,
437            false,
438            false,
439        );
440
441        assert!(sql.contains("CREATE MATERIALIZED VIEW tv_order_summary"));
442        assert!(sql.contains("Entity: Order"));
443        assert!(sql.contains("scheduled"));
444        assert!(
445            sql.contains("FROM v_order"),
446            "must use entity sql_source, not schema_placeholder"
447        );
448        assert!(!sql.contains("schema_placeholder"));
449    }
450
451    #[test]
452    fn test_generate_view_sql_with_composition_views() {
453        let sql = generate_view_sql(
454            "User",
455            "v_user",
456            "tv_user_profile",
457            "Table Vector (tv_)",
458            RefreshStrategy::TriggerBased,
459            true,
460            false,
461        );
462
463        assert!(sql.contains("Composition views"));
464        assert!(sql.contains("_recent"));
465        assert!(sql.contains("_count"));
466    }
467
468    #[test]
469    fn test_generate_view_sql_with_monitoring() {
470        let sql = generate_view_sql(
471            "User",
472            "v_user",
473            "tv_user_profile",
474            "Table Vector (tv_)",
475            RefreshStrategy::TriggerBased,
476            false,
477            true,
478        );
479
480        assert!(sql.contains("Monitoring functions"));
481        assert!(sql.contains("monitor_tv_user_profile"));
482        assert!(sql.contains("metric_name"));
483    }
484
485    #[test]
486    fn test_generate_view_sql_full_options() {
487        let sql = generate_view_sql(
488            "User",
489            "v_user",
490            "ta_users",
491            "Table Arrow (ta_)",
492            RefreshStrategy::TriggerBased,
493            true,
494            true,
495        );
496
497        assert!(sql.contains("Entity: User"));
498        assert!(sql.contains("View: ta_users"));
499        assert!(sql.contains("Composition views"));
500        assert!(sql.contains("Monitoring functions"));
501        assert!(!sql.contains("schema_placeholder"));
502    }
503
504    #[test]
505    fn test_generate_view_sql_uses_real_sql_source() {
506        // The generated DDL must reference the entity's real SQL source, not a
507        // placeholder. Executing a view with `schema_placeholder` would always
508        // fail at query time with a relation-not-found error.
509        let sql = generate_view_sql(
510            "Product",
511            "v_product_catalog",
512            "ta_products",
513            "Table Arrow (ta_)",
514            RefreshStrategy::TriggerBased,
515            false,
516            false,
517        );
518
519        assert!(
520            sql.contains("FROM v_product_catalog"),
521            "generated SQL must use the entity's sql_source"
522        );
523        assert!(!sql.contains("schema_placeholder"), "placeholder must not appear in output");
524    }
525}