1use std::{fs, path::Path};
13
14use anyhow::{Context, Result};
15use fraiseql_core::schema::CompiledSchema;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum RefreshStrategy {
20 TriggerBased,
22 Scheduled,
24}
25
26impl RefreshStrategy {
27 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#[derive(Debug, Clone)]
48pub struct GenerateViewsConfig {
49 pub schema_path: String,
51 pub entity: String,
53 pub view: String,
55 pub refresh_strategy: RefreshStrategy,
57 pub output: Option<String>,
59 pub include_composition_views: bool,
61 pub include_monitoring: bool,
63 pub validate_only: bool,
65 pub verbose: bool,
67}
68
69pub 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 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 if config.verbose {
102 eprintln!(" ✓ Reading schema...");
103 }
104 let schema = CompiledSchema::from_json(&schema_json).context("Failed to parse schema.json")?;
105
106 if config.verbose {
108 eprintln!(" ✓ Validating entity...");
109 }
110 validate_entity(&schema, &config.entity)?;
111
112 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 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 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 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 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
180fn 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
190fn 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
208fn 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 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 sql.push_str(&format!("DROP VIEW IF EXISTS {view_name} CASCADE;\n\n"));
238
239 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 sql.push_str(&format!("CREATE VIEW {view_name} AS\n"));
253 sql.push_str("SELECT * FROM public.schema_placeholder;\n");
254 },
255 }
256
257 if include_composition_views {
259 sql.push_str("\n-- Composition views\n");
260 generate_composition_views(&mut sql, entity, view_name);
261 }
262
263 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
272fn 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
284fn 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
298fn 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
311fn 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 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 sql.push_str(&format!("CREATE VIEW {base_name}_count AS\n"));
326 sql.push_str("SELECT COUNT(*) as total FROM {};\n");
327}
328
329fn 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}