From bb2dac86292f6223938375e1f78ccc6aa388346d Mon Sep 17 00:00:00 2001 From: brian moore Date: Thu, 19 Feb 2026 20:50:47 -0500 Subject: [PATCH 1/2] bm/cleanup-cleanup --- crates/ff-analysis/src/pass/expr_utils.rs | 11 +- .../src/pass/plan_unused_columns.rs | 19 +-- crates/ff-analysis/src/schema.rs | 2 - crates/ff-cli/src/commands/compile.rs | 77 ++++++----- crates/ff-cli/src/commands/docs/generate.rs | 121 ++++++++---------- crates/ff-cli/src/commands/parse.rs | 10 +- crates/ff-cli/src/commands/run/compile.rs | 36 +++--- crates/ff-cli/src/commands/run/execute.rs | 34 ++--- crates/ff-core/src/dag.rs | 15 +-- crates/ff-core/src/project/loading.rs | 53 ++++---- crates/ff-meta/src/lib.rs | 1 + crates/ff-sql/src/dialect.rs | 2 +- crates/ff-sql/src/inline.rs | 2 +- crates/ff-sql/src/lineage.rs | 56 +++++--- 14 files changed, 228 insertions(+), 211 deletions(-) diff --git a/crates/ff-analysis/src/pass/expr_utils.rs b/crates/ff-analysis/src/pass/expr_utils.rs index 7776ee10..2addb8ed 100644 --- a/crates/ff-analysis/src/pass/expr_utils.rs +++ b/crates/ff-analysis/src/pass/expr_utils.rs @@ -35,16 +35,7 @@ where Expr::Cast(cast) => walk_expr_columns(&cast.expr, collector), Expr::TryCast(try_cast) => walk_expr_columns(&try_cast.expr, collector), Expr::Case(case) => { - if let Some(ref operand) = case.expr { - walk_expr_columns(operand, collector); - } - for (when, then) in &case.when_then_expr { - walk_expr_columns(when, collector); - walk_expr_columns(then, collector); - } - if let Some(ref else_expr) = case.else_expr { - walk_expr_columns(else_expr, collector); - } + for_each_case_subexpr(case, |e| walk_expr_columns(e, collector)); } Expr::IsNull(inner) | Expr::IsNotNull(inner) | Expr::Not(inner) | Expr::Negative(inner) => { walk_expr_columns(inner, collector); diff --git a/crates/ff-analysis/src/pass/plan_unused_columns.rs b/crates/ff-analysis/src/pass/plan_unused_columns.rs index 8c6e2930..f7a0ff6d 100644 --- a/crates/ff-analysis/src/pass/plan_unused_columns.rs +++ b/crates/ff-analysis/src/pass/plan_unused_columns.rs @@ -113,13 +113,18 @@ fn collect_consumed_columns( consumed } +/// Collect lowercased column refs from a slice of expressions. +fn collect_refs_from_exprs(exprs: &[Expr], consumed: &mut HashSet) { + for expr in exprs { + collect_column_refs_lowercase(expr, consumed); + } +} + /// Walk a LogicalPlan tree and collect referenced column names (lowercased) fn collect_column_refs_from_plan(plan: &LogicalPlan, consumed: &mut HashSet) { match plan { LogicalPlan::Projection(proj) => { - for expr in &proj.expr { - collect_column_refs_lowercase(expr, consumed); - } + collect_refs_from_exprs(&proj.expr, consumed); collect_column_refs_from_plan(&proj.input, consumed); } LogicalPlan::Filter(filter) => { @@ -132,12 +137,8 @@ fn collect_column_refs_from_plan(plan: &LogicalPlan, consumed: &mut HashSet { - for expr in &agg.group_expr { - collect_column_refs_lowercase(expr, consumed); - } - for expr in &agg.aggr_expr { - collect_column_refs_lowercase(expr, consumed); - } + collect_refs_from_exprs(&agg.group_expr, consumed); + collect_refs_from_exprs(&agg.aggr_expr, consumed); collect_column_refs_from_plan(&agg.input, consumed); } LogicalPlan::Sort(sort) => { diff --git a/crates/ff-analysis/src/schema.rs b/crates/ff-analysis/src/schema.rs index c2228a5e..9d172e5b 100644 --- a/crates/ff-analysis/src/schema.rs +++ b/crates/ff-analysis/src/schema.rs @@ -47,7 +47,6 @@ impl RelSchema { /// If `source_table` metadata is available, filters by it first. /// Falls back to column-name-only lookup when table info is missing. pub fn find_qualified(&self, table: &str, column: &str) -> Option<&TypedColumn> { - // Try to find a column that matches both table and name let qualified_match = self.columns.iter().find(|c| { c.name.eq_ignore_ascii_case(column) && c.source_table @@ -55,7 +54,6 @@ impl RelSchema { .is_some_and(|t| t.eq_ignore_ascii_case(table)) }); - // Fall back to column-name-only if no qualified match qualified_match.or_else(|| self.find_column(column)) } diff --git a/crates/ff-cli/src/commands/compile.rs b/crates/ff-cli/src/commands/compile.rs index 7b15b308..742a4b10 100644 --- a/crates/ff-cli/src/commands/compile.rs +++ b/crates/ff-cli/src/commands/compile.rs @@ -654,6 +654,49 @@ fn compute_compiled_path( Ok(output_dir.join(filename)) } +/// Populate meta database rows for a single successfully compiled model. +fn populate_single_model_compile( + conn: &ff_meta::DuckDbConnection, + project: &Project, + result: &ModelCompileResult, + model_id: i64, + dependencies: &HashMap>, + model_id_map: &HashMap, +) -> ff_meta::MetaResult<()> { + let compiled_sql = project + .get_model(&result.model) + .and_then(|m| m.compiled_sql.as_deref()) + .unwrap_or(""); + let compiled_path = result.output_path.as_deref().unwrap_or(""); + let checksum = ff_core::compute_checksum(compiled_sql); + + ff_meta::populate::compilation::update_model_compiled( + conn, + model_id, + compiled_sql, + compiled_path, + &checksum, + )?; + + if let Some(deps) = dependencies.get(&result.model) { + let dep_ids: Vec = deps + .iter() + .filter_map(|d| model_id_map.get(d.as_str()).copied()) + .collect(); + ff_meta::populate::compilation::populate_dependencies(conn, model_id, &dep_ids)?; + } + + let ext_deps: Vec<&str> = project + .get_model(&result.model) + .map(|m| m.external_deps.iter().map(|t| t.as_ref()).collect()) + .unwrap_or_default(); + if !ext_deps.is_empty() { + ff_meta::populate::compilation::populate_external_dependencies(conn, model_id, &ext_deps)?; + } + + Ok(()) +} + /// Populate the meta database with compile-phase data (non-fatal). fn populate_meta_compile( project: &Project, @@ -679,38 +722,14 @@ fn populate_meta_compile( let Some(&model_id) = model_id_map.get(result.model.as_str()) else { continue; }; - let compiled_sql = project - .get_model(&result.model) - .and_then(|m| m.compiled_sql.as_deref()) - .unwrap_or(""); - let compiled_path = result.output_path.as_deref().unwrap_or(""); - let checksum = ff_core::compute_checksum(compiled_sql); - - ff_meta::populate::compilation::update_model_compiled( + populate_single_model_compile( conn, + project, + result, model_id, - compiled_sql, - compiled_path, - &checksum, + dependencies, + &model_id_map, )?; - - if let Some(deps) = dependencies.get(&result.model) { - let dep_ids: Vec = deps - .iter() - .filter_map(|d| model_id_map.get(d.as_str()).copied()) - .collect(); - ff_meta::populate::compilation::populate_dependencies(conn, model_id, &dep_ids)?; - } - - let ext_deps: Vec<&str> = project - .get_model(&result.model) - .map(|m| m.external_deps.iter().map(|t| t.as_ref()).collect()) - .unwrap_or_default(); - if !ext_deps.is_empty() { - ff_meta::populate::compilation::populate_external_dependencies( - conn, model_id, &ext_deps, - )?; - } } Ok(()) }); diff --git a/crates/ff-cli/src/commands/docs/generate.rs b/crates/ff-cli/src/commands/docs/generate.rs index fe84be05..9111ac1c 100644 --- a/crates/ff-cli/src/commands/docs/generate.rs +++ b/crates/ff-cli/src/commands/docs/generate.rs @@ -914,75 +914,66 @@ fn generate_lineage_dot(project: &Project) -> String { } } - dot.push_str( - "\n // Model nodes (blue for views, green for tables, gold for incremental, gray for ephemeral)\n", - ); - if let Some(ref manifest) = manifest { - for (name, model) in &manifest.models { - let color = match model.materialized { - ff_core::config::Materialization::Table => COLOR_TABLE, - ff_core::config::Materialization::View => COLOR_VIEW, - ff_core::config::Materialization::Incremental => COLOR_INCREMENTAL, - ff_core::config::Materialization::Ephemeral => COLOR_EPHEMERAL, - }; - dot.push_str(&format!( - " \"{}\" [label=\"{}\" fillcolor=\"{}\"];\n", - name, name, color - )); - } - } else { - for (name, model) in &project.models { - let mat = model.materialization(project.config.materialization); - let color = match mat { - ff_core::config::Materialization::Table => COLOR_TABLE, - ff_core::config::Materialization::View => COLOR_VIEW, - ff_core::config::Materialization::Incremental => COLOR_INCREMENTAL, - ff_core::config::Materialization::Ephemeral => COLOR_EPHEMERAL, - }; - dot.push_str(&format!( - " \"{}\" [label=\"{}\" fillcolor=\"{}\"];\n", - name, name, color - )); - } + dot.push('\n'); + + // Collect model info from manifest (preferred) or project (fallback) + struct ModelDotInfo<'a> { + name: &'a str, + materialization: ff_core::config::Materialization, + depends_on: Vec<&'a str>, + external_deps: Vec<&'a str>, } + let model_infos: Vec> = if let Some(ref manifest) = manifest { + manifest + .models + .iter() + .map(|(name, model)| ModelDotInfo { + name: name.as_str(), + materialization: model.materialized, + depends_on: model.depends_on.iter().map(|d| d.as_str()).collect(), + external_deps: model.external_deps.iter().map(|e| e.as_str()).collect(), + }) + .collect() + } else { + project + .models + .iter() + .map(|(name, model)| ModelDotInfo { + name: name.as_str(), + materialization: model.materialization(project.config.materialization), + depends_on: model.depends_on.iter().map(|d| d.as_str()).collect(), + external_deps: model.external_deps.iter().map(|e| e.as_ref()).collect(), + }) + .collect() + }; - dot.push_str("\n // Dependencies (edges)\n"); - if let Some(ref manifest) = manifest { - // Use manifest for accurate dependencies - for (name, model) in &manifest.models { - for dep in &model.depends_on { - dot.push_str(&format!(" \"{}\" -> \"{}\";\n", dep, name)); - } - - for ext in &model.external_deps { - let source_node = project - .sources - .iter() - .flat_map(|s| s.tables.iter().map(move |t| (s, t))) - .find(|(_, t)| *ext == t.name) - .map(|(s, t)| format!("{}_{}", s.name, t.name)) - .unwrap_or_else(|| ext.to_string()); + for info in &model_infos { + let color = match info.materialization { + ff_core::config::Materialization::Table => COLOR_TABLE, + ff_core::config::Materialization::View => COLOR_VIEW, + ff_core::config::Materialization::Incremental => COLOR_INCREMENTAL, + ff_core::config::Materialization::Ephemeral => COLOR_EPHEMERAL, + }; + dot.push_str(&format!( + " \"{}\" [label=\"{}\" fillcolor=\"{}\"];\n", + info.name, info.name, color + )); + } - dot.push_str(&format!(" \"{}\" -> \"{}\";\n", source_node, name)); - } + dot.push('\n'); + for info in &model_infos { + for dep in &info.depends_on { + dot.push_str(&format!(" \"{}\" -> \"{}\";\n", dep, info.name)); } - } else { - // Fall back to project model info (may be incomplete) - for (name, model) in &project.models { - for dep in &model.depends_on { - dot.push_str(&format!(" \"{}\" -> \"{}\";\n", dep, name)); - } - for ext in &model.external_deps { - let source_node = project - .sources - .iter() - .flat_map(|s| s.tables.iter().map(move |t| (s, t))) - .find(|(_, t)| *ext == t.name) - .map(|(s, t)| format!("{}_{}", s.name, t.name)) - .unwrap_or_else(|| ext.to_string()); - - dot.push_str(&format!(" \"{}\" -> \"{}\";\n", source_node, name)); - } + for ext in &info.external_deps { + let source_node = project + .sources + .iter() + .flat_map(|s| s.tables.iter().map(move |t| (s, t))) + .find(|(_, t)| *ext == t.name) + .map(|(s, t)| format!("{}_{}", s.name, t.name)) + .unwrap_or_else(|| ext.to_string()); + dot.push_str(&format!(" \"{}\" -> \"{}\";\n", source_node, info.name)); } } diff --git a/crates/ff-cli/src/commands/parse.rs b/crates/ff-cli/src/commands/parse.rs index 4687bca8..d4f59467 100644 --- a/crates/ff-cli/src/commands/parse.rs +++ b/crates/ff-cli/src/commands/parse.rs @@ -44,12 +44,10 @@ pub(crate) async fn execute(args: &ParseArgs, global: &GlobalArgs) -> Result<()> .with_context(|| format!("Failed to parse SQL for model: {}", name))?; let deps = extract_dependencies(&statements); + let all_tables: Vec = deps.iter().cloned().collect(); - let (model_deps, ext_deps) = ff_sql::extractor::categorize_dependencies( - deps.clone(), - &known_models, - &external_tables, - ); + let (model_deps, ext_deps) = + ff_sql::extractor::categorize_dependencies(deps, &known_models, &external_tables); dep_map.insert(name.clone(), model_deps.clone()); @@ -58,7 +56,7 @@ pub(crate) async fn execute(args: &ParseArgs, global: &GlobalArgs) -> Result<()> path: model.path.display().to_string(), model_dependencies: model_deps, external_dependencies: ext_deps, - all_tables: deps.into_iter().collect(), + all_tables, }); } diff --git a/crates/ff-cli/src/commands/run/compile.rs b/crates/ff-cli/src/commands/run/compile.rs index a10d42bc..1c5c99bc 100644 --- a/crates/ff-cli/src/commands/run/compile.rs +++ b/crates/ff-cli/src/commands/run/compile.rs @@ -342,24 +342,24 @@ fn resolve_deferred_dependencies( // Also check for transitive dependencies of deferred models let mut to_check: Vec = deferred_models.iter().cloned().collect(); while let Some(model_name) = to_check.pop() { - if let Some(manifest_model) = deferred_manifest.get_model(&model_name) { - for dep in &manifest_model.depends_on { - let dep_str = dep.as_str(); - if !selected_set.contains(dep_str) - && !deferred_models.contains(dep_str) - && deferred_manifest.get_model(dep_str).is_some() - { - deferred_models.insert(dep_str.to_string()); - to_check.push(dep_str.to_string()); - if global.verbose { - eprintln!( - "[verbose] Deferring {} to production manifest (transitive)", - dep - ); - } - } - // Note: Don't fail on transitive deps missing from manifest - // They might be external tables or already executed + let Some(manifest_model) = deferred_manifest.get_model(&model_name) else { + continue; + }; + for dep in &manifest_model.depends_on { + let dep_str = dep.as_str(); + if selected_set.contains(dep_str) + || deferred_models.contains(dep_str) + || deferred_manifest.get_model(dep_str).is_none() + { + continue; + } + deferred_models.insert(dep_str.to_string()); + to_check.push(dep_str.to_string()); + if global.verbose { + eprintln!( + "[verbose] Deferring {} to production manifest (transitive)", + dep + ); } } } diff --git a/crates/ff-cli/src/commands/run/execute.rs b/crates/ff-cli/src/commands/run/execute.rs index 86acbbb3..8b662258 100644 --- a/crates/ff-cli/src/commands/run/execute.rs +++ b/crates/ff-cli/src/commands/run/execute.rs @@ -553,7 +553,6 @@ async fn execute_models_sequential( } else { success_count += 1; - // Try to get row count for state tracking (non-blocking) let qualified_name = build_qualified_name(compiled.schema.as_deref(), name); let row_count = match ctx .db @@ -608,19 +607,19 @@ struct ParallelExecutionState { /// Prepare a model for parallel execution. /// -/// Returns `None` if the model should be skipped (missing or ephemeral). +/// Returns `false` if the model should be skipped (missing or ephemeral). /// Ephemeral models record a success result as a side effect. fn prepare_level_model( name: &str, compiled_models: &HashMap, state: &Arc, -) -> Option> { +) -> bool { let Some(compiled) = compiled_models.get(name) else { eprintln!( "[warn] Model '{}' missing from compiled_models, skipping", name ); - return None; + return false; }; if compiled.materialization == Materialization::Ephemeral { @@ -633,17 +632,19 @@ fn prepare_level_model( error: None, }); recover_mutex(&state.completed).insert(name.to_string()); - return None; + return false; } - Some(Arc::new(compiled.clone())) + true } /// Async task body for executing a single model in parallel mode. +/// +/// Borrows the compiled model from `state.all_compiled_models` instead of +/// receiving a cloned `Arc`, avoiding a deep copy per model. async fn execute_model_task( db: Arc, name: String, - compiled: Arc, state: Arc, ) { let Ok(_permit) = state.semaphore.acquire().await else { @@ -654,11 +655,15 @@ async fn execute_model_task( return; } + let Some(compiled) = state.all_compiled_models.get(&name) else { + return; + }; + let model_result = if compiled.is_python { super::python::run_python_model( &db, &name, - &compiled, + compiled, &state.all_compiled_models, state.db_path.as_deref().unwrap_or(":memory:"), ) @@ -667,7 +672,7 @@ async fn execute_model_task( run_single_model( &db, &name, - &compiled, + compiled, state.full_refresh, state.wap_schema.as_deref(), ) @@ -764,17 +769,12 @@ async fn execute_models_parallel( break; } - let Some(compiled) = prepare_level_model(name, &ctx.compiled_models, &state) else { + if !prepare_level_model(name, &ctx.compiled_models, &state) { continue; - }; + } let db = Arc::clone(ctx.db); - set.spawn(execute_model_task( - db, - name.clone(), - compiled, - Arc::clone(&state), - )); + set.spawn(execute_model_task(db, name.clone(), Arc::clone(&state))); } while let Some(res) = set.join_next().await { diff --git a/crates/ff-core/src/dag.rs b/crates/ff-core/src/dag.rs index e979f44a..8af3842e 100644 --- a/crates/ff-core/src/dag.rs +++ b/crates/ff-core/src/dag.rs @@ -29,15 +29,14 @@ impl ModelDag { /// Add a model to the DAG pub fn add_model(&mut self, name: &str) -> CoreResult { if let Some(&idx) = self.node_map.get(name) { - Ok(idx) - } else { - let model_name = ModelName::try_new(name).ok_or_else(|| CoreError::EmptyName { - context: "model name in DAG".into(), - })?; - let idx = self.graph.add_node(model_name.clone()); - self.node_map.insert(model_name, idx); - Ok(idx) + return Ok(idx); } + let model_name = ModelName::try_new(name).ok_or_else(|| CoreError::EmptyName { + context: "model name in DAG".into(), + })?; + let idx = self.graph.add_node(model_name.clone()); + self.node_map.insert(model_name, idx); + Ok(idx) } /// Add a dependency edge (from depends on to) diff --git a/crates/ff-core/src/project/loading.rs b/crates/ff-core/src/project/loading.rs index 64347761..db9ea046 100644 --- a/crates/ff-core/src/project/loading.rs +++ b/crates/ff-core/src/project/loading.rs @@ -40,20 +40,23 @@ where let path = entry.path(); if path.is_dir() { discover_yaml_recursive(&path, items, probe, load)?; - } else if path.extension().is_some_and(|e| e == "yml" || e == "yaml") { - let content = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(e) => { - log::warn!("Cannot read {}: {}", path.display(), e); - continue; - } - }; - if !probe(&content) { + continue; + } + if !path.extension().is_some_and(|e| e == "yml" || e == "yaml") { + continue; + } + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + log::warn!("Cannot read {}: {}", path.display(), e); continue; } - let item = load(&path)?; - items.push(item); + }; + if !probe(&content) { + continue; } + let item = load(&path)?; + items.push(item); } Ok(()) } @@ -87,23 +90,17 @@ fn categorize_dir_files(dir: &Path) -> CoreResult { .filter(|p| p.is_file() && !is_hidden_file(p)) .collect(); - let sql = all_visible - .iter() - .filter(|p| p.extension().is_some_and(|e| e == "sql")) - .cloned() - .collect(); - - let csv = all_visible - .iter() - .filter(|p| p.extension().is_some_and(|e| e == "csv")) - .cloned() - .collect(); - - let py = all_visible - .iter() - .filter(|p| p.extension().is_some_and(|e| e == "py")) - .cloned() - .collect(); + let mut sql = Vec::new(); + let mut csv = Vec::new(); + let mut py = Vec::new(); + for p in &all_visible { + match file_extension_str(p) { + "sql" => sql.push(p.clone()), + "csv" => csv.push(p.clone()), + "py" => py.push(p.clone()), + _ => {} + } + } Ok(CategorizedFiles { all_visible, diff --git a/crates/ff-meta/src/lib.rs b/crates/ff-meta/src/lib.rs index fc0c7156..bbd40803 100644 --- a/crates/ff-meta/src/lib.rs +++ b/crates/ff-meta/src/lib.rs @@ -15,5 +15,6 @@ pub(crate) mod row_helpers; pub mod rules; pub use connection::MetaDb; +pub use duckdb::Connection as DuckDbConnection; pub use error::{MetaError, MetaResult}; pub use manifest::Manifest; diff --git a/crates/ff-sql/src/dialect.rs b/crates/ff-sql/src/dialect.rs index ef89b0ee..73745df2 100644 --- a/crates/ff-sql/src/dialect.rs +++ b/crates/ff-sql/src/dialect.rs @@ -91,7 +91,7 @@ impl ResolvedIdent { /// Debug-asserts that `parts` is non-empty. Every call-site in production /// passes at least one part (from `resolve_object_name`). pub fn from_parts(parts: Vec) -> Self { - debug_assert!( + assert!( !parts.is_empty(), "ResolvedIdent requires at least one part" ); diff --git a/crates/ff-sql/src/inline.rs b/crates/ff-sql/src/inline.rs index 3f44b86a..c51c5b44 100644 --- a/crates/ff-sql/src/inline.rs +++ b/crates/ff-sql/src/inline.rs @@ -237,7 +237,7 @@ fn collect_ephemeral_recursive( ); continue; }; - let dep = dep.clone(); + let dep = dep.to_string(); ephemeral_sql.insert(dep.clone(), sql); order.push(dep); } diff --git a/crates/ff-sql/src/lineage.rs b/crates/ff-sql/src/lineage.rs index 3d781e1d..4034d42f 100644 --- a/crates/ff-sql/src/lineage.rs +++ b/crates/ff-sql/src/lineage.rs @@ -345,13 +345,17 @@ impl ProjectLineage { let mut dot = String::from("digraph lineage {\n rankdir=LR;\n node [shape=record];\n\n"); for (name, lineage) in &self.models { - let cols: Vec<&str> = lineage + let cols: Vec = lineage .columns .iter() - .map(|c| c.output_column.as_str()) + .map(|c| dot_escape(&c.output_column)) .collect(); - let label = format!("{}|{}", name, cols.join("\\l")); - dot.push_str(&format!(" \"{}\" [label=\"{{{}}}\"];\n", name, label)); + let escaped_name = dot_escape(name); + let label = format!("{}|{}", escaped_name, cols.join("\\l")); + dot.push_str(&format!( + " \"{}\" [label=\"{{{}}}\"];\n", + escaped_name, label + )); } dot.push('\n'); @@ -364,7 +368,11 @@ impl ProjectLineage { }; dot.push_str(&format!( " \"{}\":\"{}\" -> \"{}\":\"{}\"{};\n", - edge.source_model, edge.source_column, edge.target_model, edge.target_column, style + dot_escape(&edge.source_model), + dot_escape(&edge.source_column), + dot_escape(&edge.target_model), + dot_escape(&edge.target_column), + style )); } @@ -373,6 +381,17 @@ impl ProjectLineage { } } +/// Escape DOT/Graphviz special characters in a string used inside labels or IDs. +fn dot_escape(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('{', "\\{") + .replace('}', "\\}") + .replace('<', "\\<") + .replace('>', "\\>") + .replace('|', "\\|") +} + fn resolve_single_edge( target_model: &str, lineage: &ModelLineage, @@ -453,6 +472,20 @@ fn extract_lineage_from_set_expr(set_expr: &SetExpr, lineage: &mut ModelLineage) } } +/// Build lineage for a `table.*` qualified wildcard. +fn extract_qualified_wildcard_lineage(kind: &SelectItemQualifiedWildcardKind) -> ColumnLineage { + let table_name = match kind { + SelectItemQualifiedWildcardKind::ObjectName(name) => crate::object_name_to_string(name), + SelectItemQualifiedWildcardKind::Expr(expr) => format!("{expr}"), + }; + let mut col_lineage = ColumnLineage::new(&format!("{}.*", table_name)); + col_lineage.expr_type = ExprType::Wildcard; + col_lineage + .source_columns + .insert(ColumnRef::qualified(&table_name, "*")); + col_lineage +} + /// Extract lineage from a SELECT clause fn extract_lineage_from_select(select: &Select, lineage: &mut ModelLineage) { for table in &select.from { @@ -471,18 +504,7 @@ fn extract_lineage_from_select(select: &Select, lineage: &mut ModelLineage) { lineage.add_column(col_lineage); } SelectItem::QualifiedWildcard(kind, _) => { - let table_name = match kind { - SelectItemQualifiedWildcardKind::ObjectName(name) => { - crate::object_name_to_string(name) - } - SelectItemQualifiedWildcardKind::Expr(expr) => format!("{expr}"), - }; - let mut col_lineage = ColumnLineage::new(&format!("{}.*", table_name)); - col_lineage.expr_type = ExprType::Wildcard; - col_lineage - .source_columns - .insert(ColumnRef::qualified(&table_name, "*")); - lineage.add_column(col_lineage); + lineage.add_column(extract_qualified_wildcard_lineage(kind)); } SelectItem::Wildcard(_) => { let mut col_lineage = ColumnLineage::new("*"); From aa0461d21f1da4502b682cc4251c3b2e725d5d34 Mon Sep 17 00:00:00 2001 From: brian moore Date: Sun, 22 Feb 2026 19:57:34 -0500 Subject: [PATCH 2/2] bm/cleanup-cleanup --- Cargo.lock | 224 +++++++++++++++++- Cargo.toml | 3 + crates/ff-cli/Cargo.toml | 2 + crates/ff-cli/src/cli.rs | 27 +++ crates/ff-cli/src/commands/fmt.rs | 97 ++++++++ crates/ff-cli/src/commands/format_helpers.rs | 105 ++++++++ crates/ff-cli/src/commands/init.rs | 4 + crates/ff-cli/src/commands/mod.rs | 2 + crates/ff-cli/src/main.rs | 5 +- .../fixtures/fmt_project/featherflow.yml | 15 ++ .../models/ugly_model/ugly_model.sql | 1 + .../models/ugly_model/ugly_model.yml | 10 + .../nodes/dim_customers/dim_customers.sql | 31 ++- .../nodes/dim_products/dim_products.sql | 28 +-- .../dim_products_extended.sql | 27 ++- .../nodes/fct_orders/fct_orders.sql | 30 ++- .../nodes/int_all_orders/int_all_orders.sql | 26 +- .../int_customer_metrics.sql | 19 +- .../int_customer_ranking.sql | 13 +- .../int_high_value_orders.sql | 20 +- .../int_orders_enriched.sql | 22 +- .../order_volume_by_status.sql | 10 +- .../rpt_customer_orders.sql | 18 +- .../rpt_order_volume/rpt_order_volume.sql | 9 +- .../nodes/safe_divide/safe_divide.sql | 2 +- .../nodes/stg_customers/stg_customers.sql | 14 +- .../nodes/stg_orders/stg_orders.sql | 13 +- .../nodes/stg_payments/stg_payments.sql | 9 +- .../stg_payments_star/stg_payments_star.sql | 4 +- .../nodes/stg_products/stg_products.sql | 12 +- crates/ff-core/src/config.rs | 33 +++ crates/ff-core/src/config_test.rs | 32 +++ crates/ff-meta/src/populate/populate_test.rs | 1 + 33 files changed, 695 insertions(+), 173 deletions(-) create mode 100644 crates/ff-cli/src/commands/fmt.rs create mode 100644 crates/ff-cli/src/commands/format_helpers.rs create mode 100644 crates/ff-cli/tests/fixtures/fmt_project/featherflow.yml create mode 100644 crates/ff-cli/tests/fixtures/fmt_project/models/ugly_model/ugly_model.sql create mode 100644 crates/ff-cli/tests/fixtures/fmt_project/models/ugly_model/ugly_model.yml diff --git a/Cargo.lock b/Cargo.lock index 5eedc2e9..4bd69374 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -678,6 +678,16 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -718,6 +728,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.56" @@ -822,6 +841,20 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.36" @@ -839,6 +872,19 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "console" version = "0.16.2" @@ -1073,6 +1119,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1181,7 +1248,7 @@ dependencies = [ "ff-sql", "ff-test", "futures", - "indicatif", + "indicatif 0.18.4", "log", "mime_guess", "minijinja", @@ -1190,6 +1257,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sqlfmt", "tempfile", "tokio", "tower-http", @@ -1487,6 +1555,25 @@ dependencies = [ "wasip3", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "half" version = "2.7.1" @@ -1794,13 +1881,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console 0.15.11", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "indicatif" version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" dependencies = [ - "console", + "console 0.16.2", "portable-atomic", "unicode-width", "unit-prefix", @@ -2168,6 +2268,12 @@ dependencies = [ "libm", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "object" version = "0.37.3" @@ -2200,6 +2306,12 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2329,7 +2441,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit", + "toml_edit 0.23.10+spec-1.0.0", ] [[package]] @@ -2544,6 +2656,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.12.3" @@ -2892,6 +3015,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2982,6 +3114,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "1.0.2" @@ -3010,6 +3148,28 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "sqlfmt" +version = "0.3.0" +source = "git+https://github.com/datastx/sqlfmt-rust.git?tag=v0.3.0#c031be840ea34e970b09699cc6df852b9cd4c78b" +dependencies = [ + "anyhow", + "clap", + "compact_str", + "dirs", + "glob", + "globset", + "indicatif 0.17.11", + "memchr", + "serde", + "similar", + "smallvec", + "termcolor", + "thiserror", + "tokio", + "toml", +] + [[package]] name = "sqlparser" version = "0.59.0" @@ -3072,6 +3232,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -3177,6 +3343,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -3281,6 +3456,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -3290,6 +3486,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + [[package]] name = "toml_edit" version = "0.23.10+spec-1.0.0" @@ -3297,7 +3507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow", ] @@ -3311,6 +3521,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index e31dc82d..57bf367b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,9 @@ open = "5" # Logging log = "0.4" +# SQL formatting W/ Jinja support +sqlfmt = { git = "https://github.com/datastx/sqlfmt-rust.git", tag = "v0.3.0" } + # Utilities petgraph = "0.8" chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/ff-cli/Cargo.toml b/crates/ff-cli/Cargo.toml index f17f2bf6..585bd876 100644 --- a/crates/ff-cli/Cargo.toml +++ b/crates/ff-cli/Cargo.toml @@ -33,6 +33,8 @@ rust-embed = { version = "8", optional = true } mime_guess = { version = "2", optional = true } open = { version = "5", optional = true } +sqlfmt.workspace = true + ff-core = { path = "../ff-core" } ff-sql = { path = "../ff-sql" } ff-jinja = { path = "../ff-jinja" } diff --git a/crates/ff-cli/src/cli.rs b/crates/ff-cli/src/cli.rs index 183420eb..8af973b4 100644 --- a/crates/ff-cli/src/cli.rs +++ b/crates/ff-cli/src/cli.rs @@ -90,6 +90,9 @@ pub(crate) enum Commands { /// Query and export the meta database Meta(MetaArgs), + + /// Format SQL source files with sqlfmt + Fmt(FmtArgs), } /// Arguments for the parse command @@ -682,6 +685,30 @@ pub(crate) struct MetaExportArgs { pub output: Option, } +/// Arguments for the fmt command +#[derive(Args, Debug)] +pub(crate) struct FmtArgs { + /// Node selector (names, +node, node+, N+node, node+N, tag:X, path:X) + #[arg(short = 'n', long)] + pub nodes: Option, + + /// Check formatting without modifying files (exit 1 if unformatted) + #[arg(long)] + pub check: bool, + + /// Show diff of formatting changes + #[arg(long)] + pub diff: bool, + + /// Override max line length + #[arg(long)] + pub line_length: Option, + + /// Disable Jinja formatting + #[arg(long)] + pub no_jinjafmt: bool, +} + #[cfg(test)] #[path = "cli_test.rs"] mod tests; diff --git a/crates/ff-cli/src/commands/fmt.rs b/crates/ff-cli/src/commands/fmt.rs new file mode 100644 index 00000000..fee4de27 --- /dev/null +++ b/crates/ff-cli/src/commands/fmt.rs @@ -0,0 +1,97 @@ +//! Format command implementation — format SQL source files with sqlfmt. + +use anyhow::Result; +use std::path::PathBuf; + +use crate::cli::{FmtArgs, GlobalArgs}; +use crate::commands::common::load_project; +use crate::commands::format_helpers; + +/// Execute the fmt command. +pub(crate) async fn execute(args: &FmtArgs, global: &GlobalArgs) -> Result<()> { + let project = load_project(global)?; + + // Collect all SQL file paths: models + functions + let mut sql_files: Vec = Vec::new(); + + // Model SQL files + for model in project.models.values() { + if model.path.exists() { + sql_files.push(model.path.clone()); + } + } + + // Function SQL files + for func in &project.functions { + if func.sql_path.exists() { + sql_files.push(func.sql_path.clone()); + } + } + + // Filter by node selector if provided + if let Some(ref nodes_arg) = args.nodes { + let (_, dag) = crate::commands::common::build_project_dag(&project)?; + let selected = + crate::commands::common::resolve_nodes(&project, &dag, &Some(nodes_arg.clone()))?; + let selected_set: std::collections::HashSet = selected.into_iter().collect(); + + sql_files.retain(|path| { + // Match model SQL files by model name + for (name, model) in &project.models { + if model.path == *path && selected_set.contains(name.as_str()) { + return true; + } + } + // Match function SQL files by function name + for func in &project.functions { + if func.sql_path == *path && selected_set.contains(func.name.as_str()) { + return true; + } + } + false + }); + } + + if sql_files.is_empty() { + println!("No SQL files to format."); + return Ok(()); + } + + // Build mode from config + CLI overrides + let mut format_config = project.config.format.clone(); + if let Some(ll) = args.line_length { + format_config.line_length = ll; + } + if args.no_jinjafmt { + format_config.no_jinjafmt = true; + } + + let mut mode = format_helpers::build_sqlfmt_mode(&format_config, project.config.dialect); + mode.check = args.check; + mode.diff = args.diff; + + if global.verbose { + eprintln!( + "[verbose] Formatting {} SQL files (line_length={}, dialect={}, check={})", + sql_files.len(), + mode.line_length, + mode.dialect_name, + mode.check, + ); + } + + let report = format_helpers::format_files(&sql_files, &mode).await; + + report.print_errors(); + println!("{}", report.summary()); + + if args.check && report.has_changes() { + return Err(crate::commands::common::ExitCode(1).into()); + } + + if report.has_errors() { + return Err(crate::commands::common::ExitCode(1).into()); + } + + Ok(()) +} diff --git a/crates/ff-cli/src/commands/format_helpers.rs b/crates/ff-cli/src/commands/format_helpers.rs new file mode 100644 index 00000000..b790676b --- /dev/null +++ b/crates/ff-cli/src/commands/format_helpers.rs @@ -0,0 +1,105 @@ +//! Shared formatting utilities wrapping the sqlfmt library. +//! +//! Formatting is a standalone CI / developer tool (`ff fmt`). It never +//! runs automatically during `ff compile` or `ff run` — those pipelines +//! must produce byte-identical SQL regardless of format settings so that +//! formatting can never break execution. + +use ff_core::config::{Dialect, FormatConfig}; +use sqlfmt::report::Report; +use sqlfmt::Mode; +use std::path::PathBuf; + +/// Build a sqlfmt [`Mode`] from Featherflow's [`FormatConfig`] and [`Dialect`]. +pub(crate) fn build_sqlfmt_mode(config: &FormatConfig, dialect: Dialect) -> Mode { + let dialect_name = match dialect { + Dialect::DuckDb => "duckdb".to_string(), + Dialect::Snowflake => "polyglot".to_string(), + }; + + Mode { + line_length: config.line_length, + dialect_name, + no_jinjafmt: config.no_jinjafmt, + // Quiet library-level output; the CLI handles its own reporting. + quiet: true, + no_progressbar: true, + // Sensible defaults for library usage + check: false, + diff: false, + fast: false, + exclude: Vec::new(), + encoding: "utf-8".to_string(), + verbose: false, + no_color: true, + force_color: false, + threads: 0, + single_process: false, + reset_cache: false, + } +} + +/// Run sqlfmt on a list of files, returning the report. +pub(crate) async fn format_files(files: &[PathBuf], mode: &Mode) -> Report { + sqlfmt::run(files, mode).await +} + +#[cfg(test)] +mod tests { + use super::*; + use ff_core::config::{Dialect, FormatConfig}; + + #[test] + fn test_build_mode_duckdb_dialect() { + let config = FormatConfig::default(); + let mode = build_sqlfmt_mode(&config, Dialect::DuckDb); + assert_eq!(mode.dialect_name, "duckdb"); + assert_eq!(mode.line_length, 88); + assert!(!mode.no_jinjafmt); + assert!(mode.quiet); + } + + #[test] + fn test_build_mode_snowflake_dialect() { + let config = FormatConfig::default(); + let mode = build_sqlfmt_mode(&config, Dialect::Snowflake); + assert_eq!(mode.dialect_name, "polyglot"); + } + + #[test] + fn test_build_mode_custom_line_length() { + let config = FormatConfig { + line_length: 120, + ..Default::default() + }; + let mode = build_sqlfmt_mode(&config, Dialect::DuckDb); + assert_eq!(mode.line_length, 120); + } + + #[test] + fn test_build_mode_no_jinjafmt() { + let config = FormatConfig { + no_jinjafmt: true, + ..Default::default() + }; + let mode = build_sqlfmt_mode(&config, Dialect::DuckDb); + assert!(mode.no_jinjafmt); + } + + #[test] + fn test_format_string_basic() { + let mode = build_sqlfmt_mode(&FormatConfig::default(), Dialect::DuckDb); + let result = sqlfmt::format_string("select 1", &mode); + assert!(result.is_ok()); + assert!(!result.unwrap().is_empty()); + } + + #[test] + fn test_jinja_preservation() { + let mode = build_sqlfmt_mode(&FormatConfig::default(), Dialect::DuckDb); + let sql = "select {{ config() }}, id from orders"; + let result = sqlfmt::format_string(sql, &mode).unwrap(); + // The Jinja expression should survive formatting + assert!(result.contains("{{ config() }}") || result.contains("{{config()}}")); + } +} diff --git a/crates/ff-cli/src/commands/init.rs b/crates/ff-cli/src/commands/init.rs index 545600d5..985287ca 100644 --- a/crates/ff-cli/src/commands/init.rs +++ b/crates/ff-cli/src/commands/init.rs @@ -74,6 +74,10 @@ vars: # severity_overrides: # A020: warning # promote unused columns from info to warning # A032: off # disable cross join diagnostics + +# format: # defaults for `ff fmt` +# line_length: 88 # max line length for formatted SQL +# no_jinjafmt: false # disable Jinja formatting "#, name = safe_name, db_path = safe_db_path, diff --git a/crates/ff-cli/src/commands/mod.rs b/crates/ff-cli/src/commands/mod.rs index 0f4cd355..5916103e 100644 --- a/crates/ff-cli/src/commands/mod.rs +++ b/crates/ff-cli/src/commands/mod.rs @@ -6,6 +6,8 @@ pub(crate) mod clean; pub(crate) mod common; pub(crate) mod compile; pub(crate) mod docs; +pub(crate) mod fmt; +pub(crate) mod format_helpers; pub(crate) mod function; pub(crate) mod init; pub(crate) mod lineage; diff --git a/crates/ff-cli/src/main.rs b/crates/ff-cli/src/main.rs index f5b07dfc..7fa2cdeb 100644 --- a/crates/ff-cli/src/main.rs +++ b/crates/ff-cli/src/main.rs @@ -8,8 +8,8 @@ mod commands; use cli::Cli; use commands::{ - analyze, build, clean, compile, docs, function, init, lineage, ls, meta, parse, rules, run, - run_operation, seed, test, validate, + analyze, build, clean, compile, docs, fmt, function, init, lineage, ls, meta, parse, rules, + run, run_operation, seed, test, validate, }; #[tokio::main] @@ -34,6 +34,7 @@ async fn main() { cli::Commands::Build(args) => build::execute(args, &cli.global).await, cli::Commands::Rules(args) => rules::execute(args, &cli.global).await, cli::Commands::Meta(args) => meta::execute(args, &cli.global).await, + cli::Commands::Fmt(args) => fmt::execute(args, &cli.global).await, }; if let Err(err) = result { diff --git a/crates/ff-cli/tests/fixtures/fmt_project/featherflow.yml b/crates/ff-cli/tests/fixtures/fmt_project/featherflow.yml new file mode 100644 index 00000000..0c93733a --- /dev/null +++ b/crates/ff-cli/tests/fixtures/fmt_project/featherflow.yml @@ -0,0 +1,15 @@ +name: fmt_project +version: "1.0.0" + +model_paths: ["models"] +target_path: "target" + +materialization: view +dialect: duckdb + +database: + type: duckdb + path: ":memory:" + +format: + line_length: 88 diff --git a/crates/ff-cli/tests/fixtures/fmt_project/models/ugly_model/ugly_model.sql b/crates/ff-cli/tests/fixtures/fmt_project/models/ugly_model/ugly_model.sql new file mode 100644 index 00000000..90785c62 --- /dev/null +++ b/crates/ff-cli/tests/fixtures/fmt_project/models/ugly_model/ugly_model.sql @@ -0,0 +1 @@ +select id,name, created_at from raw_example where id is not null diff --git a/crates/ff-cli/tests/fixtures/fmt_project/models/ugly_model/ugly_model.yml b/crates/ff-cli/tests/fixtures/fmt_project/models/ugly_model/ugly_model.yml new file mode 100644 index 00000000..4748487a --- /dev/null +++ b/crates/ff-cli/tests/fixtures/fmt_project/models/ugly_model/ugly_model.yml @@ -0,0 +1,10 @@ +version: 1 +description: "Intentionally ugly model for format testing" + +columns: + - name: id + type: INTEGER + - name: name + type: VARCHAR + - name: created_at + type: TIMESTAMP diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/dim_customers/dim_customers.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/dim_customers/dim_customers.sql index d16936df..d5277707 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/dim_customers/dim_customers.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/dim_customers/dim_customers.sql @@ -1,6 +1,13 @@ -{{ config(materialized='table', schema='analytics', wap='true', post_hook="INSERT INTO hook_log (model, hook_type) VALUES ('dim_customers', 'post')") }} +{{ + config( + materialized="table", + schema="analytics", + wap="true", + post_hook="INSERT INTO hook_log (model, hook_type) VALUES ('dim_customers', 'post')", + ) +}} -SELECT +select m.customer_id, c.customer_name, c.email, @@ -8,12 +15,14 @@ SELECT m.total_orders, m.lifetime_value, m.last_order_date, - CASE - WHEN m.lifetime_value >= 1000 THEN 'platinum' - WHEN m.lifetime_value >= 500 THEN 'gold' - WHEN m.lifetime_value >= 100 THEN 'silver' - ELSE 'bronze' - END AS computed_tier -FROM int_customer_metrics m -INNER JOIN stg_customers c - ON m.customer_id = c.customer_id + case + when m.lifetime_value >= 1000 + then 'platinum' + when m.lifetime_value >= 500 + then 'gold' + when m.lifetime_value >= 100 + then 'silver' + else 'bronze' + end as computed_tier +from int_customer_metrics m +inner join stg_customers c on m.customer_id = c.customer_id diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/dim_products/dim_products.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/dim_products/dim_products.sql index b73e3567..77bb887e 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/dim_products/dim_products.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/dim_products/dim_products.sql @@ -1,19 +1,19 @@ -{{ config(materialized='table', schema='analytics') }} +{{ config(materialized="table", schema="analytics") }} -SELECT +select product_id, product_name, category, price, - CASE - WHEN category = 'electronics' THEN 'high_value' - WHEN category = 'tools' THEN 'medium_value' - ELSE 'standard' - END AS category_group, - CASE - WHEN price > 100 THEN 'premium' - WHEN price > 25 THEN 'standard' - ELSE 'budget' - END AS price_tier -FROM stg_products -WHERE active = true + case + when category = 'electronics' + then 'high_value' + when category = 'tools' + then 'medium_value' + else 'standard' + end as category_group, + case + when price > 100 then 'premium' when price > 25 then 'standard' else 'budget' + end as price_tier +from stg_products +where active = true diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/dim_products_extended/dim_products_extended.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/dim_products_extended/dim_products_extended.sql index 14bc0df5..1f701832 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/dim_products_extended/dim_products_extended.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/dim_products_extended/dim_products_extended.sql @@ -1,18 +1,19 @@ -{{ config(materialized='table', schema='analytics') }} +{{ config(materialized="table", schema="analytics") }} -SELECT DISTINCT +select distinct product_id, product_name, category, price, - CAST(product_id * 10 AS BIGINT) AS id_scaled, - CASE - WHEN category = 'electronics' THEN - CASE - WHEN price > 100 THEN 'premium_electronics' - ELSE 'standard_electronics' - END - WHEN category = 'tools' THEN 'tools' - ELSE 'other' - END AS detailed_category -FROM stg_products + cast(product_id * 10 as bigint) as id_scaled, + case + when category = 'electronics' + then + case + when price > 100 then 'premium_electronics' else 'standard_electronics' + end + when category = 'tools' + then 'tools' + else 'other' + end as detailed_category +from stg_products diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/fct_orders/fct_orders.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/fct_orders/fct_orders.sql index ab18ae40..ad506d26 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/fct_orders/fct_orders.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/fct_orders/fct_orders.sql @@ -1,25 +1,23 @@ -{{ config( - materialized='table', - wap='true', - pre_hook="CREATE TABLE IF NOT EXISTS hook_log (model VARCHAR, hook_type VARCHAR, ts TIMESTAMP DEFAULT current_timestamp)", - post_hook=[ - "INSERT INTO hook_log (model, hook_type) VALUES ('fct_orders', 'post')", - "INSERT INTO hook_log (model, hook_type) VALUES ('fct_orders', 'post_2')" - ] -) }} +{{ + config( + materialized="table", + wap="true", + pre_hook="CREATE TABLE IF NOT EXISTS hook_log (model VARCHAR, hook_type VARCHAR, ts TIMESTAMP DEFAULT current_timestamp)", + post_hook=["INSERT INTO hook_log (model, hook_type) VALUES ('fct_orders', 'post')", "INSERT INTO hook_log (model, hook_type) VALUES ('fct_orders', 'post_2')"], + ) +}} -SELECT +select e.order_id, e.customer_id, c.customer_name, c.customer_tier, e.order_date, - e.order_amount AS amount, + e.order_amount as amount, e.status, e.payment_total, e.payment_count, - e.order_amount - e.payment_total AS balance_due, - safe_divide(e.payment_total, e.order_amount) AS payment_ratio -FROM int_orders_enriched e -INNER JOIN stg_customers c - ON e.customer_id = c.customer_id + e.order_amount - e.payment_total as balance_due, + safe_divide(e.payment_total, e.order_amount) as payment_ratio +from int_orders_enriched e +inner join stg_customers c on e.customer_id = c.customer_id diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/int_all_orders/int_all_orders.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/int_all_orders/int_all_orders.sql index 7e9ba3d3..477c9510 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/int_all_orders/int_all_orders.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/int_all_orders/int_all_orders.sql @@ -1,23 +1,17 @@ -{{ config(materialized='view', schema='intermediate') }} +{{ config(materialized="view", schema="intermediate") }} -SELECT - order_id, - customer_id, - order_date, - order_amount, - status, - 'enriched' AS source -FROM int_orders_enriched -WHERE status = 'completed' +select order_id, customer_id, order_date, order_amount, status, 'enriched' as source +from int_orders_enriched +where status = 'completed' -UNION ALL +union all -SELECT +select order_id, customer_id, order_date, - amount AS order_amount, + amount as order_amount, status, - 'staging' AS source -FROM stg_orders -WHERE status = 'pending' + 'staging' as source +from stg_orders +where status = 'pending' diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/int_customer_metrics/int_customer_metrics.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/int_customer_metrics/int_customer_metrics.sql index aad511fc..b1d87610 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/int_customer_metrics/int_customer_metrics.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/int_customer_metrics/int_customer_metrics.sql @@ -1,14 +1,11 @@ -{{ config(materialized='view', schema='intermediate') }} +{{ config(materialized="view", schema="intermediate") }} -SELECT +select c.customer_id, c.customer_name, - COUNT(o.order_id) AS total_orders, - COALESCE(SUM(o.amount), 0) AS lifetime_value, - MAX(o.order_date) AS last_order_date -FROM stg_customers c -INNER JOIN stg_orders o - ON c.customer_id = o.customer_id -GROUP BY - c.customer_id, - c.customer_name + count(o.order_id) as total_orders, + coalesce(sum(o.amount), 0) as lifetime_value, + max(o.order_date) as last_order_date +from stg_customers c +inner join stg_orders o on c.customer_id = o.customer_id +group by c.customer_id, c.customer_name diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/int_customer_ranking/int_customer_ranking.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/int_customer_ranking/int_customer_ranking.sql index 2462ff0e..a969daef 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/int_customer_ranking/int_customer_ranking.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/int_customer_ranking/int_customer_ranking.sql @@ -1,11 +1,10 @@ -{{ config(materialized='view', schema='intermediate') }} +{{ config(materialized="view", schema="intermediate") }} -SELECT +select c.customer_id, c.customer_name, m.lifetime_value, - COALESCE(m.lifetime_value, 0) AS value_or_zero, - NULLIF(m.total_orders, 0) AS nonzero_orders -FROM stg_customers c -INNER JOIN int_customer_metrics m - ON c.customer_id = m.customer_id + coalesce(m.lifetime_value, 0) as value_or_zero, + nullif(m.total_orders, 0) as nonzero_orders +from stg_customers c +inner join int_customer_metrics m on c.customer_id = m.customer_id diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/int_high_value_orders/int_high_value_orders.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/int_high_value_orders/int_high_value_orders.sql index f85fc404..10c4bc68 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/int_high_value_orders/int_high_value_orders.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/int_high_value_orders/int_high_value_orders.sql @@ -1,12 +1,12 @@ -{{ config(materialized='view', schema='intermediate') }} +{{ config(materialized="view", schema="intermediate") }} -SELECT +select o.customer_id, - COUNT(o.order_id) AS order_count, - SUM(o.amount) AS total_amount, - MIN(o.amount) AS min_order, - MAX(o.amount) AS max_order, - AVG(o.amount) AS avg_order -FROM stg_orders o -GROUP BY o.customer_id -HAVING SUM(o.amount) > 100 + count(o.order_id) as order_count, + sum(o.amount) as total_amount, + min(o.amount) as min_order, + max(o.amount) as max_order, + avg(o.amount) as avg_order +from stg_orders o +group by o.customer_id +having sum(o.amount) > 100 diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/int_orders_enriched/int_orders_enriched.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/int_orders_enriched/int_orders_enriched.sql index 2d6e8e4e..7655ab5e 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/int_orders_enriched/int_orders_enriched.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/int_orders_enriched/int_orders_enriched.sql @@ -1,19 +1,13 @@ -{{ config(materialized='view', schema='intermediate') }} +{{ config(materialized="view", schema="intermediate") }} -SELECT +select o.order_id, o.customer_id, o.order_date, - o.amount AS order_amount, + o.amount as order_amount, o.status, - COALESCE(SUM(p.amount), 0) AS payment_total, - COUNT(p.payment_id) AS payment_count -FROM stg_orders o -INNER JOIN stg_payments p - ON o.order_id = p.order_id -GROUP BY - o.order_id, - o.customer_id, - o.order_date, - o.amount, - o.status + coalesce(sum(p.amount), 0) as payment_total, + count(p.payment_id) as payment_count +from stg_orders o +inner join stg_payments p on o.order_id = p.order_id +group by o.order_id, o.customer_id, o.order_date, o.amount, o.status diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/order_volume_by_status/order_volume_by_status.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/order_volume_by_status/order_volume_by_status.sql index 85a95366..9097205c 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/order_volume_by_status/order_volume_by_status.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/order_volume_by_status/order_volume_by_status.sql @@ -1,7 +1,3 @@ -SELECT status, order_count -FROM ( - SELECT status, COUNT(*) AS order_count - FROM fct_orders - GROUP BY status -) -WHERE order_count >= min_count +select status, order_count +from (select status, count(*) as order_count from fct_orders group by status) +where order_count >= min_count diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/rpt_customer_orders/rpt_customer_orders.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/rpt_customer_orders/rpt_customer_orders.sql index 41fc436b..c52f6cdd 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/rpt_customer_orders/rpt_customer_orders.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/rpt_customer_orders/rpt_customer_orders.sql @@ -1,17 +1,15 @@ -{{ config(materialized='table', schema='reports') }} +{{ config(materialized="table", schema="reports") }} -SELECT +select c.customer_id, c.customer_name, c.email, e.order_id, e.order_amount, e.payment_total, - (e.order_amount - e.payment_total) * 1.1 AS balance_with_fee, - e.order_amount + e.payment_total + e.payment_count AS combined_metric -FROM stg_customers c -INNER JOIN int_orders_enriched e - ON c.customer_id = e.customer_id -INNER JOIN stg_orders o - ON e.order_id = o.order_id -WHERE e.order_amount BETWEEN o.amount AND o.amount + (e.order_amount - e.payment_total) * 1.1 as balance_with_fee, + e.order_amount + e.payment_total + e.payment_count as combined_metric +from stg_customers c +inner join int_orders_enriched e on c.customer_id = e.customer_id +inner join stg_orders o on e.order_id = o.order_id +where e.order_amount between o.amount and o.amount diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/rpt_order_volume/rpt_order_volume.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/rpt_order_volume/rpt_order_volume.sql index ac55865e..330cffce 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/rpt_order_volume/rpt_order_volume.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/rpt_order_volume/rpt_order_volume.sql @@ -1,7 +1,4 @@ -{{ config(materialized='table', schema='reports') }} +{{ config(materialized="table", schema="reports") }} -SELECT - status, - order_count, - safe_divide(order_count, 100) AS pct_of_hundred -FROM order_volume_by_status({{ var("min_order_count") }}) +select status, order_count, safe_divide(order_count, 100) as pct_of_hundred +from order_volume_by_status({{ var("min_order_count") }}) diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/safe_divide/safe_divide.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/safe_divide/safe_divide.sql index 27240292..dc1f97f4 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/safe_divide/safe_divide.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/safe_divide/safe_divide.sql @@ -1 +1 @@ -CASE WHEN denominator = 0 THEN NULL ELSE numerator / denominator END +case when denominator = 0 then null else numerator / denominator end diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_customers/stg_customers.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_customers/stg_customers.sql index 55e77487..09398656 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_customers/stg_customers.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_customers/stg_customers.sql @@ -1,9 +1,9 @@ -{{ config(materialized='view', schema='staging') }} +{{ config(materialized="view", schema="staging") }} -SELECT - id AS customer_id, - name AS customer_name, +select + id as customer_id, + name as customer_name, email, - created_at AS signup_date, - tier AS customer_tier -FROM raw_customers + created_at as signup_date, + tier as customer_tier +from raw_customers diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_orders/stg_orders.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_orders/stg_orders.sql index 3c5b113f..841874ca 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_orders/stg_orders.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_orders/stg_orders.sql @@ -1,10 +1,5 @@ -{{ config(materialized='view', schema='staging') }} +{{ config(materialized="view", schema="staging") }} -SELECT - id AS order_id, - user_id AS customer_id, - created_at AS order_date, - amount, - status -FROM raw_orders -WHERE created_at >= '{{ var("start_date") }}' +select id as order_id, user_id as customer_id, created_at as order_date, amount, status +from raw_orders +where created_at >= '{{ var("start_date") }}' diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_payments/stg_payments.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_payments/stg_payments.sql index ab1bb81e..88388732 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_payments/stg_payments.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_payments/stg_payments.sql @@ -1,7 +1,4 @@ -{{ config(materialized='view', schema='staging') }} +{{ config(materialized="view", schema="staging") }} -SELECT - id AS payment_id, - order_id, - {{ cents_to_dollars('amount') }} AS amount -FROM raw_payments +select id as payment_id, order_id, {{ cents_to_dollars("amount") }} as amount +from raw_payments diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_payments_star/stg_payments_star.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_payments_star/stg_payments_star.sql index 0a509b6b..4fc1799d 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_payments_star/stg_payments_star.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_payments_star/stg_payments_star.sql @@ -1,3 +1 @@ -{{ config(materialized='view', schema='staging') }} - -SELECT * FROM raw_payments +{{ config(materialized="view", schema="staging") }} select * from raw_payments diff --git a/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_products/stg_products.sql b/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_products/stg_products.sql index f61c15f8..8d4cc59f 100644 --- a/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_products/stg_products.sql +++ b/crates/ff-cli/tests/fixtures/sample_project/nodes/stg_products/stg_products.sql @@ -1,9 +1,9 @@ -{{ config(materialized='view', schema='staging') }} +{{ config(materialized="view", schema="staging") }} -SELECT - id AS product_id, - name AS product_name, +select + id as product_id, + name as product_name, category, - CAST(price AS DECIMAL(10,2)) AS price, + cast(price as decimal(10, 2)) as price, active -FROM raw_products +from raw_products diff --git a/crates/ff-core/src/config.rs b/crates/ff-core/src/config.rs index 64b4a703..9fa00a5f 100644 --- a/crates/ff-core/src/config.rs +++ b/crates/ff-core/src/config.rs @@ -115,6 +115,39 @@ pub struct Config { /// Documentation enforcement settings #[serde(default)] pub documentation: DocumentationConfig, + + /// SQL formatting configuration + #[serde(default)] + pub format: FormatConfig, +} + +/// SQL formatting configuration for `ff fmt`. +/// +/// These settings provide project-level defaults for `ff fmt`. +/// Formatting never runs automatically during compile or run — it is +/// a standalone CI / developer tool, like `rustfmt` or `black`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FormatConfig { + /// Maximum line length for formatted SQL (default: 88) + #[serde(default = "default_format_line_length")] + pub line_length: usize, + + /// Disable Jinja formatting within SQL files (default: false) + #[serde(default)] + pub no_jinjafmt: bool, +} + +impl Default for FormatConfig { + fn default() -> Self { + Self { + line_length: default_format_line_length(), + no_jinjafmt: false, + } + } +} + +fn default_format_line_length() -> usize { + 88 } /// Target-specific configuration overrides diff --git a/crates/ff-core/src/config_test.rs b/crates/ff-core/src/config_test.rs index 57218c1f..c7c65978 100644 --- a/crates/ff-core/src/config_test.rs +++ b/crates/ff-core/src/config_test.rs @@ -429,6 +429,38 @@ fn test_node_paths_default_empty() { assert!(!config.uses_node_paths()); } +#[test] +fn test_format_config_default() { + let config: Config = serde_yaml::from_str("name: test").unwrap(); + assert_eq!(config.format.line_length, 88); + assert!(!config.format.no_jinjafmt); +} + +#[test] +fn test_format_config_custom() { + let yaml = r#" +name: test_project +format: + line_length: 120 + no_jinjafmt: true +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.format.line_length, 120); + assert!(config.format.no_jinjafmt); +} + +#[test] +fn test_format_config_partial() { + let yaml = r#" +name: test_project +format: + line_length: 100 +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.format.line_length, 100); + assert!(!config.format.no_jinjafmt); // default +} + #[test] fn test_node_paths_with_model_paths_both_accepted() { let yaml = r#" diff --git a/crates/ff-meta/src/populate/populate_test.rs b/crates/ff-meta/src/populate/populate_test.rs index e1fcbdfa..1e55bc51 100644 --- a/crates/ff-meta/src/populate/populate_test.rs +++ b/crates/ff-meta/src/populate/populate_test.rs @@ -43,6 +43,7 @@ fn test_config() -> Config { documentation: Default::default(), query_comment: Default::default(), rules: None, + format: Default::default(), } }