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 async fn run(config: GenerateViewsConfig, formatter: &OutputFormatter) -> Result<()> {
93 if config.verbose {
94 formatter.progress("Generating views...");
95 formatter.progress(&format!(" Schema: {}", config.schema_path));
96 formatter.progress(&format!(" Entity: {}", config.entity));
97 formatter.progress(&format!(" View: {}", config.view));
98 formatter.progress(&format!(" Refresh strategy: {}", config.refresh_strategy));
99 }
100
101 let schema_path = Path::new(&config.schema_path);
103 if !schema_path.exists() {
104 anyhow::bail!("Schema file not found: {}", config.schema_path);
105 }
106
107 let schema_json = fs::read_to_string(schema_path).context("Failed to read schema.json")?;
108
109 if config.verbose {
111 formatter.progress(" ok: Reading schema...");
112 }
113 let schema =
114 CompiledSchema::from_json(&schema_json, false).context("Failed to parse schema.json")?;
115
116 if config.verbose {
118 formatter.progress(" ok: Validating entity...");
119 }
120 let sql_source = resolve_entity_sql_source(&schema, &config.entity)?;
121
122 if config.verbose {
124 formatter.progress(" ok: Validating view name...");
125 }
126 let view_type = validate_view_name(&config.view)?;
127
128 if config.verbose {
129 formatter.progress(&format!(" ok: View type: {view_type}"));
130 }
131
132 if config.verbose {
134 formatter.progress(" ok: Generating SQL DDL...");
135 }
136 let sql = generate_view_sql(
137 &config.entity,
138 &sql_source,
139 &config.view,
140 view_type,
141 config.refresh_strategy,
142 config.include_composition_views,
143 config.include_monitoring,
144 );
145
146 if config.validate_only {
150 validate_ddl_against_database(&sql).await?;
151 println!("✓ View DDL is valid (executed against PostgreSQL, rolled back)");
152 println!(" Entity: {}", config.entity);
153 println!(" View: {}", config.view);
154 println!(" Type: {view_type}");
155 println!(" Refresh strategy: {}", config.refresh_strategy);
156 println!(" Lines: {}", sql.lines().count());
157 return Ok(());
158 }
159
160 if config.verbose {
162 formatter.progress(" ok: Writing output...");
163 }
164 let output_path = config.output.unwrap_or_else(|| format!("{}.sql", config.view));
165
166 fs::write(&output_path, sql.clone()).context("Failed to write output file")?;
167
168 println!("✓ View DDL generated successfully");
170 println!(" Entity: {}", config.entity);
171 println!(" View: {}", config.view);
172 println!(" Type: {view_type}");
173 println!(" Output: {output_path}");
174 println!(" Lines: {}", sql.lines().count());
175
176 if config.include_composition_views {
177 println!(" ✓ Includes composition views");
178 }
179
180 if config.include_monitoring {
181 println!(" ✓ Includes monitoring functions");
182 }
183
184 if config.verbose {
185 formatter.progress("\nGenerated SQL preview (first 5 lines):");
186 for line in sql.lines().take(5) {
187 formatter.progress(&format!(" {line}"));
188 }
189 }
190
191 Ok(())
192}
193
194async fn validate_ddl_against_database(sql: &str) -> Result<()> {
206 let db_url = std::env::var("DATABASE_URL").map_err(|_| {
207 anyhow::anyhow!(
208 "--validate executes the generated DDL against PostgreSQL and needs DATABASE_URL \
209 (everything runs in a rolled-back transaction; a scratch database is fine)"
210 )
211 })?;
212 let pool = crate::connection::postgres_pool(
214 &db_url,
215 "--validate",
216 &fraiseql_db::postgres::PostgresTlsConfig::default(),
217 )
218 .await?;
219 let mut client = pool.get().await.context("--validate: failed to connect to DATABASE_URL")?;
220
221 let txn = client.transaction().await.context("--validate: failed to open transaction")?;
222 txn.batch_execute(sql)
223 .await
224 .context("generated DDL was rejected by PostgreSQL — the file is NOT valid")?;
225 txn.rollback().await.context("--validate: rollback failed")?;
226 Ok(())
227}
228
229fn resolve_entity_sql_source(schema: &CompiledSchema, entity: &str) -> Result<String> {
235 if let Some(type_def) = schema.types.iter().find(|t| t.name == entity) {
236 Ok(type_def.sql_source.as_str().to_string())
237 } else {
238 let available = schema.types.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ");
239 anyhow::bail!("Entity '{entity}' not found in schema. Available types: {available}")
240 }
241}
242
243pub(crate) fn validate_view_name(view_name: &str) -> Result<&'static str> {
250 if view_name.starts_with("va_") {
251 Ok("Vector Arrow (va_)")
252 } else if view_name.starts_with("tv_") {
253 Ok("Table Vector (tv_)")
254 } else if view_name.starts_with("ta_") {
255 Ok("Table Arrow (ta_)")
256 } else {
257 anyhow::bail!("Invalid view name '{view_name}'. Must start with va_, tv_, or ta_")
258 }
259}
260
261pub(crate) fn generate_view_sql(
273 entity: &str,
274 sql_source: &str,
275 view_name: &str,
276 view_type: &str,
277 refresh_strategy: RefreshStrategy,
278 include_composition_views: bool,
279 include_monitoring: bool,
280) -> String {
281 let mut sql = String::new();
282
283 sql.push_str("-- Auto-generated Arrow view DDL\n");
285 sql.push_str(&format!("-- Entity: {entity}\n"));
286 sql.push_str(&format!("-- View: {view_name}\n"));
287 sql.push_str(&format!("-- Type: {view_type}\n"));
288 sql.push_str(&format!("-- Refresh strategy: {refresh_strategy}\n"));
289 sql.push_str("-- Generated by: fraiseql generate-views\n\n");
290
291 if view_name.starts_with("tv_") {
295 sql.push_str(&format!("DROP MATERIALIZED VIEW IF EXISTS {view_name} CASCADE;\n\n"));
296 } else {
297 sql.push_str(&format!("DROP VIEW IF EXISTS {view_name} CASCADE;\n\n"));
298 }
299
300 #[allow(clippy::unreachable)]
304 match view_name.split('_').next() {
308 Some("va") => {
309 generate_vector_arrow_view(&mut sql, entity, sql_source, view_name);
310 },
311 Some("tv") => {
312 generate_table_vector_view(&mut sql, entity, sql_source, view_name);
313 },
314 Some("ta") => {
315 generate_table_arrow_view(&mut sql, entity, sql_source, view_name);
316 },
317 _ => unreachable!("view name validated by validate_view_name before generate_view_sql"),
318 }
319
320 if include_composition_views {
322 sql.push_str("\n-- Composition views\n");
323 generate_composition_views(&mut sql, view_name);
324 }
325
326 if include_monitoring {
328 sql.push_str("\n-- Monitoring functions\n");
329 generate_monitoring_functions(&mut sql, view_name);
330 }
331
332 sql
333}
334
335fn generate_vector_arrow_view(sql: &mut String, entity: &str, sql_source: &str, view_name: &str) {
337 sql.push_str(&format!("CREATE VIEW {view_name} AS\n"));
338 sql.push_str("SELECT\n");
339 sql.push_str(" id,\n");
340 sql.push_str(&format!(" -- {entity} entity fields\n"));
341 sql.push_str(" created_at,\n");
342 sql.push_str(" updated_at\n");
343 sql.push_str(&format!("FROM {sql_source}\n"));
344 sql.push_str("WHERE archived_at IS NULL;\n");
345}
346
347fn generate_table_vector_view(sql: &mut String, entity: &str, sql_source: &str, view_name: &str) {
349 sql.push_str(&format!("CREATE MATERIALIZED VIEW {view_name} AS\n"));
350 sql.push_str("SELECT\n");
351 sql.push_str(" id,\n");
352 sql.push_str(&format!(" -- {entity} entity vector representation\n"));
353 sql.push_str(" CURRENT_TIMESTAMP as materialized_at\n");
354 sql.push_str(&format!("FROM {sql_source}\n"));
355 sql.push_str("WHERE archived_at IS NULL;\n");
356 sql.push('\n');
357 let base_name = view_name.trim_start_matches("tv_");
358 sql.push_str(&format!("CREATE INDEX idx_{base_name}_id ON {view_name} (id);\n"));
359}
360
361fn generate_table_arrow_view(sql: &mut String, entity: &str, sql_source: &str, view_name: &str) {
363 sql.push_str(&format!("CREATE VIEW {view_name} AS\n"));
364 sql.push_str("SELECT\n");
365 sql.push_str(" id,\n");
366 sql.push_str(&format!(" -- {entity} entity fields optimized for Arrow\n"));
367 sql.push_str(" created_at,\n");
368 sql.push_str(" updated_at\n");
369 sql.push_str(&format!("FROM {sql_source}\n"));
370 sql.push_str("WHERE archived_at IS NULL\n");
371 sql.push_str("ORDER BY id;\n");
372}
373
374fn generate_composition_views(sql: &mut String, view_name: &str) {
382 let base_name = view_name
383 .trim_start_matches("va_")
384 .trim_start_matches("tv_")
385 .trim_start_matches("ta_");
386
387 let ts_column = if view_name.starts_with("tv_") {
390 "materialized_at"
391 } else {
392 "updated_at"
393 };
394
395 sql.push_str(&format!("CREATE VIEW {base_name}_recent AS\n"));
397 sql.push_str(&format!("SELECT * FROM {view_name}\n"));
398 sql.push_str(&format!("WHERE {ts_column} > NOW() - INTERVAL '7 days'\n"));
399 sql.push_str(&format!("ORDER BY {ts_column} DESC;\n\n"));
400
401 sql.push_str(&format!("CREATE VIEW {base_name}_count AS\n"));
403 sql.push_str(&format!("SELECT COUNT(*) as total FROM {view_name};\n"));
404}
405
406fn generate_monitoring_functions(sql: &mut String, view_name: &str) {
412 let func_name = format!("monitor_{view_name}");
413
414 sql.push_str(&format!("CREATE OR REPLACE FUNCTION {func_name}()\n"));
415 sql.push_str("RETURNS TABLE (\n");
416 sql.push_str(" metric_name TEXT,\n");
417 sql.push_str(" metric_value BIGINT\n");
418 sql.push_str(") AS $$\n");
419 sql.push_str("BEGIN\n");
420 sql.push_str(" RETURN QUERY\n");
421 sql.push_str(&format!(" SELECT 'row_count'::TEXT, COUNT(*)::BIGINT FROM {view_name};\n"));
422 sql.push_str("END;\n");
423 sql.push_str("$$ LANGUAGE plpgsql STABLE;\n");
424}