diff --git a/Cargo.lock b/Cargo.lock index 5eedc2e..4bd6937 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 e31dc82..57bf367 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 f17f2bf..585bd87 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 183420e..8af973b 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 0000000..fee4de2 --- /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 0000000..b790676 --- /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 545600d..985287c 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 0f4cd35..5916103 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 f5b07df..7fa2cde 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 0000000..0c93733 --- /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 0000000..90785c6 --- /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 0000000..4748487 --- /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 d16936d..d527770 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 b73e356..77bb887 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 14bc0df..1f70183 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 ab18ae4..ad506d2 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 7e9ba3d..477c951 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 aad511f..b1d8761 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 2462ff0..a969dae 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 f85fc40..10c4bc6 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 2d6e8e4..7655ab5 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 85a9536..9097205 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 41fc436..c52f6cd 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 ac55865..330cffc 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 2724029..dc1f97f 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 55e7748..0939865 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 3c5b113..841874c 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 ab1bb81..8838873 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 0a509b6..4fc1799 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 f61c15f..8d4cc59 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 64b4a70..9fa00a5 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 57218c1..c7c6597 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 e1fcbdf..1e55bc5 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(), } }