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 =
112 CompiledSchema::from_json(&schema_json, false).context("Failed to parse schema.json")?;
113
114 if config.verbose {
116 formatter.progress(" ok: Validating entity...");
117 }
118 let sql_source = resolve_entity_sql_source(&schema, &config.entity)?;
119
120 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 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 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 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 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
189fn 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
203pub(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
221pub(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 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 sql.push_str(&format!("DROP VIEW IF EXISTS {view_name} CASCADE;\n\n"));
253
254 #[allow(clippy::unreachable)]
258 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 if include_composition_views {
276 sql.push_str("\n-- Composition views\n");
277 generate_composition_views(&mut sql, entity, view_name);
278 }
279
280 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
289fn 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
301fn 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
315fn 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
328fn 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 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 sql.push_str(&format!("CREATE VIEW {base_name}_count AS\n"));
343 sql.push_str("SELECT COUNT(*) as total FROM {};\n");
344}
345
346fn 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}