Skip to main content

mz_deploy/project/syntax/
profile_files.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Profile-specific file override resolution.
11//!
12//! Files can be named `name#<profile>.sql` to override `name.sql` when a
13//! particular profile is active. The `#` delimiter is split on the **last**
14//! occurrence (`rsplit_once`).
15//!
16//! ## Resolution Algorithm
17//!
18//! 1. **Parse** each file stem via [`parse_file_stem`] using `rsplit_once('#')`
19//!    to separate `(object_name, profile)`. Files without `#` (or with an
20//!    empty object/profile part) are treated as the default (no profile).
21//! 2. **Group** files by object name into [`ObjectFiles`], recording the default
22//!    file and any profile-specific overrides. Duplicates within the same
23//!    group (e.g., two defaults, or two overrides for the same profile) are
24//!    rejected with `LoadError::DuplicateProfileObject`.
25//!
26//! Callers select the active variant themselves by checking
27//! `overrides.get(profile).or(default.as_ref())`.
28//!
29//! **Key Insight:** `#` cannot appear in a SQL identifier, so a well-formed
30//! variant filename contains exactly one `#`. This lets object names freely
31//! contain underscores: `my_pg_conn#staging` → `("my_pg_conn", "staging")`.
32
33use crate::project::error::LoadError;
34use std::collections::BTreeMap;
35use std::path::{Path, PathBuf};
36
37/// Split a file stem into `(object_name, optional_profile)`.
38///
39/// Uses `rsplit_once('#')` so that `pg_conn#staging` → `("pg_conn", Some("staging"))`.
40/// Returns `(stem, None)` if no valid split exists (empty parts).
41pub(crate) fn parse_file_stem(stem: &str) -> (&str, Option<&str>) {
42    if let Some((object_name, profile)) = stem.rsplit_once('#') {
43        if !object_name.is_empty() && !profile.is_empty() {
44            return (object_name, Some(profile));
45        }
46    }
47    (stem, None)
48}
49
50/// All files for a single object name, grouped by profile.
51#[derive(Debug, Clone)]
52pub(crate) struct ObjectFiles {
53    /// The object name (without profile suffix)
54    pub name: String,
55    /// The default file (no profile suffix), if any
56    pub default: Option<PathBuf>,
57    /// Profile-specific override files, keyed by profile name
58    pub overrides: BTreeMap<String, PathBuf>,
59}
60
61/// Collect all `.sql` files from a directory grouped by object name without resolving.
62///
63/// Returns all variants (default + all profile overrides) for each object.
64/// This is used to load and validate all profile variants before resolving.
65pub(crate) fn collect_all_sql_files(directory: &Path) -> Result<Vec<ObjectFiles>, LoadError> {
66    let entries: Vec<_> = std::fs::read_dir(directory)
67        .map_err(|e| LoadError::DirectoryReadFailed {
68            path: directory.to_path_buf(),
69            source: e,
70        })?
71        .collect::<Result<Vec<_>, _>>()
72        .map_err(|e| LoadError::EntryReadFailed {
73            directory: directory.to_path_buf(),
74            source: e,
75        })?;
76
77    let mut groups: BTreeMap<String, ObjectFiles> = BTreeMap::new();
78
79    for entry in entries {
80        let path = entry.path();
81
82        if path.extension().and_then(|e| e.to_str()) != Some("sql") {
83            continue;
84        }
85
86        let file_stem = path
87            .file_stem()
88            .and_then(|s| s.to_str())
89            .ok_or_else(|| LoadError::InvalidFileName { path: path.clone() })?
90            .to_string();
91
92        let (object_name, file_profile) = parse_file_stem(&file_stem);
93        let group = groups
94            .entry(object_name.to_string())
95            .or_insert_with(|| ObjectFiles {
96                name: object_name.to_string(),
97                default: None,
98                overrides: BTreeMap::new(),
99            });
100
101        match file_profile {
102            None => {
103                if let Some(existing) = &group.default {
104                    return Err(LoadError::DuplicateProfileObject {
105                        name: object_name.to_string(),
106                        profile: "default".to_string(),
107                        path1: existing.clone(),
108                        path2: path,
109                    });
110                }
111                group.default = Some(path);
112            }
113            Some(p) => {
114                if let Some(existing) = group.overrides.get(p) {
115                    return Err(LoadError::DuplicateProfileObject {
116                        name: object_name.to_string(),
117                        profile: p.to_string(),
118                        path1: existing.clone(),
119                        path2: path,
120                    });
121                }
122                group.overrides.insert(p.to_string(), path);
123            }
124        }
125    }
126
127    Ok(groups.into_values().collect())
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    // --- parse_file_stem tests ---
135
136    #[mz_ore::test]
137    fn test_parse_no_delimiter() {
138        assert_eq!(parse_file_stem("pg_conn"), ("pg_conn", None));
139    }
140
141    #[mz_ore::test]
142    fn test_parse_with_profile() {
143        assert_eq!(
144            parse_file_stem("pg_conn#staging"),
145            ("pg_conn", Some("staging"))
146        );
147    }
148
149    #[mz_ore::test]
150    fn test_parse_object_name_with_underscores() {
151        // Underscores in the object name are preserved; only the `#`
152        // separates the profile.
153        assert_eq!(
154            parse_file_stem("stg_stripe__payments#staging"),
155            ("stg_stripe__payments", Some("staging"))
156        );
157    }
158
159    #[mz_ore::test]
160    fn test_parse_empty_profile() {
161        // "pg_conn#" → empty profile part, treated as plain name
162        assert_eq!(parse_file_stem("pg_conn#"), ("pg_conn#", None));
163    }
164
165    #[mz_ore::test]
166    fn test_parse_empty_object_name() {
167        // "#staging" → empty object name, treated as plain name
168        assert_eq!(parse_file_stem("#staging"), ("#staging", None));
169    }
170
171    #[mz_ore::test]
172    fn test_parse_no_underscores() {
173        assert_eq!(parse_file_stem("simple"), ("simple", None));
174    }
175
176    #[mz_ore::test]
177    fn test_parse_single_underscore() {
178        assert_eq!(parse_file_stem("my_table"), ("my_table", None));
179    }
180
181    // --- collect_all_sql_files tests ---
182
183    #[mz_ore::test]
184    fn test_collect_all_sql_files_basic() {
185        let dir = tempfile::TempDir::new().unwrap();
186        std::fs::write(dir.path().join("conn.sql"), "SELECT 1;").unwrap();
187        std::fs::write(dir.path().join("conn#staging.sql"), "SELECT 2;").unwrap();
188        std::fs::write(dir.path().join("conn#prod.sql"), "SELECT 3;").unwrap();
189        std::fs::write(dir.path().join("table.sql"), "SELECT 4;").unwrap();
190
191        let result = collect_all_sql_files(dir.path()).unwrap();
192        assert_eq!(result.len(), 2);
193
194        let conn = result.iter().find(|f| f.name == "conn").unwrap();
195        assert!(conn.default.is_some());
196        assert_eq!(conn.overrides.len(), 2);
197        assert!(conn.overrides.contains_key("staging"));
198        assert!(conn.overrides.contains_key("prod"));
199
200        let table = result.iter().find(|f| f.name == "table").unwrap();
201        assert!(table.default.is_some());
202        assert!(table.overrides.is_empty());
203    }
204
205    #[mz_ore::test]
206    fn test_collect_all_sql_files_override_only() {
207        let dir = tempfile::TempDir::new().unwrap();
208        std::fs::write(dir.path().join("secret#staging.sql"), "SELECT 1;").unwrap();
209
210        let result = collect_all_sql_files(dir.path()).unwrap();
211        assert_eq!(result.len(), 1);
212        assert_eq!(result[0].name, "secret");
213        assert!(result[0].default.is_none());
214        assert_eq!(result[0].overrides.len(), 1);
215    }
216
217    #[mz_ore::test]
218    fn test_collect_all_sql_files_duplicate_override_errors() {
219        // The filesystem guarantees unique filenames within a directory, so
220        // the `DuplicateProfileObject` branch inside `collect_all_sql_files`
221        // is unreachable from real inputs. This test just confirms a basic
222        // call succeeds.
223        let dir = tempfile::TempDir::new().unwrap();
224        std::fs::write(dir.path().join("conn.sql"), "SELECT 1;").unwrap();
225        let result = collect_all_sql_files(dir.path()).unwrap();
226        assert_eq!(result.len(), 1);
227    }
228
229    #[mz_ore::test]
230    fn test_collect_all_sql_files_ignores_non_sql() {
231        let dir = tempfile::TempDir::new().unwrap();
232        std::fs::write(dir.path().join("conn.sql"), "SELECT 1;").unwrap();
233        std::fs::write(dir.path().join("readme.md"), "hello").unwrap();
234
235        let result = collect_all_sql_files(dir.path()).unwrap();
236        assert_eq!(result.len(), 1);
237    }
238}