1use std::{fs, path::Path};
13
14use anyhow::{Context, Result};
15use fraiseql_core::schema::CompiledSchema;
16
17use crate::output::OutputFormatter;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum RefreshStrategy {
23 TriggerBased,
25 Scheduled,
27}
28
29impl RefreshStrategy {
30 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#[derive(Debug, Clone)]
55pub struct GenerateViewsConfig {
56 pub schema_path: String,
58 pub entity: String,
60 pub view: String,
62 pub refresh_strategy: RefreshStrategy,
64 pub output: Option<String>,
66 pub include_composition_views: bool,
68 pub include_monitoring: bool,
70 pub validate_only: bool,
72 pub verbose: bool,
74}
75
76pub 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 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 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 if config.verbose {
115 formatter.progress(" ok: Validating entity...");
116 }
117 let sql_source = resolve_entity_sql_source(&schema, &config.entity)?;
118
119 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 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 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 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 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
188fn 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
202fn 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
220fn 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 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 sql.push_str(&format!("DROP VIEW IF EXISTS {view_name} CASCADE;\n\n"));
252
253 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 if include_composition_views {
271 sql.push_str("\n-- Composition views\n");
272 generate_composition_views(&mut sql, entity, view_name);
273 }
274
275 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
284fn 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
296fn 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
310fn 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
323fn 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 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 sql.push_str(&format!("CREATE VIEW {base_name}_count AS\n"));
338 sql.push_str("SELECT COUNT(*) as total FROM {};\n");
339}
340
341fn 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)] #[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 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}