Skip to main content

pgevolve_core/lint/profile/
custom.rs

1//! Custom layout profile loaded from a TOML file. Spec ยง12 / Phase 10 Task 10.8.
2//!
3//! A custom profile is a list of [`PathPattern`]s. Each pattern is a regex
4//! (with optional named captures `schema`, `kind`, `name`) plus a list of
5//! [`Assertion`]s that constrain how the captured fragments must relate to
6//! the matched object's qname.
7//!
8//! For each object, the engine finds the first pattern whose regex matches
9//! its file path (relative to `schema_dir`). If no pattern matches, the
10//! object gets an `unmatched_path` finding. If a pattern matches but an
11//! assertion fails, each failed assertion produces a finding.
12
13use std::collections::BTreeMap;
14use std::path::Path;
15
16use regex::Regex;
17use serde::Deserialize;
18
19use crate::lint::finding::Finding;
20use crate::lint::source_tree::{ObjectKey, SourceTree};
21
22/// One custom profile loaded from disk.
23#[derive(Debug, Clone, Deserialize)]
24pub struct CustomProfile {
25    /// Ordered list of path patterns. First match wins.
26    pub patterns: Vec<PathPattern>,
27}
28
29/// One path pattern. Compiled lazily by [`check`].
30#[derive(Debug, Clone, Deserialize)]
31pub struct PathPattern {
32    /// Regex applied to the object's path relative to `schema_dir`. Use
33    /// forward-slash separators in the regex.
34    pub regex: String,
35    /// Assertions checked when the regex matches.
36    #[serde(default)]
37    pub assertions: Vec<Assertion>,
38}
39
40/// One assertion on a matched path. See module docs for semantics.
41#[derive(Debug, Clone, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum Assertion {
44    /// `qname.schema == captures["schema"]`.
45    SchemaMatchesCapture,
46    /// `qname.name == captures["name"]`.
47    NameMatchesCapture,
48    /// `kind_name(object) == map[captures["kind"]]`. Map the captured kind
49    /// fragment to one of the object kinds (`schema`, `table`, `index`,
50    /// `sequence`).
51    KindMatchesCapture {
52        /// Captured kind fragment โ†’ expected object kind.
53        allowed_values: BTreeMap<String, String>,
54    },
55    /// The matched file must contain exactly one object.
56    OneObjectPerFile,
57}
58
59/// Run the custom-profile rules.
60pub fn check(profile: &CustomProfile, tree: &SourceTree, schema_dir: &Path) -> Vec<Finding> {
61    let mut out = Vec::new();
62    let compiled: Vec<_> = profile
63        .patterns
64        .iter()
65        .map(|p| {
66            (
67                Regex::new(&p.regex).map_err(|e| (p.regex.clone(), e.to_string())),
68                p,
69            )
70        })
71        .collect();
72
73    // Surface bad regexes as findings (one per pattern).
74    for (compiled_re, _) in &compiled {
75        if let Err((re_str, err)) = compiled_re {
76            out.push(Finding::error(
77                "custom_profile_invalid_regex",
78                format!("regex `{re_str}` failed to compile: {err}"),
79            ));
80        }
81    }
82
83    let by_file = tree.objects_by_file();
84
85    for (key, loc) in &tree.object_locations {
86        let rel = loc
87            .file
88            .strip_prefix(schema_dir)
89            .unwrap_or(loc.file.as_path());
90        let rel_string = rel.to_string_lossy().replace('\\', "/");
91
92        let mut matched_any = false;
93        for (compiled_re, pattern) in &compiled {
94            let Ok(re) = compiled_re else { continue };
95            if let Some(caps) = re.captures(&rel_string) {
96                matched_any = true;
97                for a in &pattern.assertions {
98                    if let Some(finding) = check_assertion(a, key, &caps, loc, &by_file) {
99                        out.push(finding);
100                    }
101                }
102                break;
103            }
104        }
105
106        if !matched_any {
107            out.push(
108                Finding::error(
109                    "custom_profile_unmatched_path",
110                    format!("path `{rel_string}` did not match any custom-profile pattern"),
111                )
112                .at(loc.clone()),
113            );
114        }
115    }
116
117    out
118}
119
120fn check_assertion(
121    assertion: &Assertion,
122    key: &ObjectKey,
123    caps: &regex::Captures<'_>,
124    loc: &crate::parse::SourceLocation,
125    by_file: &std::collections::HashMap<std::path::PathBuf, Vec<ObjectKey>>,
126) -> Option<Finding> {
127    match assertion {
128        Assertion::SchemaMatchesCapture => {
129            let captured = caps.name("schema")?.as_str();
130            if key.schema().as_str() == captured {
131                None
132            } else {
133                Some(
134                    Finding::error(
135                        "custom_profile_schema_mismatch",
136                        format!(
137                            "object `{key}` schema `{}` does not match captured `{captured}`",
138                            key.schema()
139                        ),
140                    )
141                    .at(loc.clone()),
142                )
143            }
144        }
145        Assertion::NameMatchesCapture => {
146            let captured = caps.name("name")?.as_str();
147            if key.bare_name().as_str() == captured {
148                None
149            } else {
150                Some(
151                    Finding::error(
152                        "custom_profile_name_mismatch",
153                        format!(
154                            "object `{key}` name `{}` does not match captured `{captured}`",
155                            key.bare_name()
156                        ),
157                    )
158                    .at(loc.clone()),
159                )
160            }
161        }
162        Assertion::KindMatchesCapture { allowed_values } => {
163            let captured = caps.name("kind")?.as_str();
164            let expected = allowed_values.get(captured);
165            match expected {
166                Some(want) if want == key.kind_name() => None,
167                Some(want) => Some(
168                    Finding::error(
169                        "custom_profile_kind_mismatch",
170                        format!(
171                            "object `{key}` is a {actual}, but its path implies kind `{captured}` โ†’ `{want}`",
172                            actual = key.kind_name()
173                        ),
174                    )
175                    .at(loc.clone()),
176                ),
177                None => Some(
178                    Finding::error(
179                        "custom_profile_kind_unknown",
180                        format!(
181                            "captured kind `{captured}` is not in the allowed_values map",
182                        ),
183                    )
184                    .at(loc.clone()),
185                ),
186            }
187        }
188        Assertion::OneObjectPerFile => {
189            let count = by_file.get(&loc.file).map_or(0, Vec::len);
190            if count <= 1 {
191                None
192            } else {
193                Some(Finding::error(
194                    "custom_profile_one_object_per_file",
195                    format!(
196                        "file `{}` contains {count} objects (custom profile pattern requires one)",
197                        loc.file.display(),
198                    ),
199                ))
200            }
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::identifier::{Identifier, QualifiedName};
209    use crate::ir::catalog::Catalog;
210    use crate::ir::table::Table;
211    use crate::parse::SourceLocation;
212    use std::collections::HashMap;
213    use std::path::PathBuf;
214
215    fn id(s: &str) -> Identifier {
216        Identifier::from_unquoted(s).unwrap()
217    }
218    fn qn(s: &str, n: &str) -> QualifiedName {
219        QualifiedName::new(id(s), id(n))
220    }
221    fn loc(p: &str) -> SourceLocation {
222        SourceLocation::new(PathBuf::from(p), 1, 1)
223    }
224
225    fn schema_mirror_equivalent() -> CustomProfile {
226        let mut map = BTreeMap::new();
227        map.insert("tables".to_string(), "table".to_string());
228        map.insert("indexes".to_string(), "index".to_string());
229        map.insert("sequences".to_string(), "sequence".to_string());
230        CustomProfile {
231            patterns: vec![PathPattern {
232                regex: r"^schema/(?P<schema>[^/]+)/(?P<kind>tables|indexes|sequences)/(?P<name>[^/]+)\.sql$"
233                    .into(),
234                assertions: vec![
235                    Assertion::SchemaMatchesCapture,
236                    Assertion::NameMatchesCapture,
237                    Assertion::KindMatchesCapture {
238                        allowed_values: map,
239                    },
240                    Assertion::OneObjectPerFile,
241                ],
242            }],
243        }
244    }
245
246    #[test]
247    fn schema_mirror_equivalent_passes_compliant_tree() {
248        let mut c = Catalog::empty();
249        c.tables.push(Table {
250            qname: qn("app", "users"),
251            columns: vec![],
252            constraints: vec![],
253            partition_by: None,
254            partition_of: None,
255            comment: None,
256            owner: None,
257            grants: vec![],
258            rls_enabled: false,
259            rls_forced: false,
260            policies: vec![],
261            storage: crate::ir::reloptions::TableStorageOptions::default(),
262            access_method: None,
263        });
264        let mut locs = HashMap::new();
265        locs.insert(
266            ObjectKey::Table(qn("app", "users")),
267            loc("schema/app/tables/users.sql"),
268        );
269        let tree = SourceTree::new(c, locs);
270        let f = check(&schema_mirror_equivalent(), &tree, Path::new(""));
271        assert!(f.is_empty(), "got: {f:?}");
272    }
273
274    #[test]
275    fn schema_mismatch_flagged() {
276        let mut c = Catalog::empty();
277        c.tables.push(Table {
278            qname: qn("app", "users"),
279            columns: vec![],
280            constraints: vec![],
281            partition_by: None,
282            partition_of: None,
283            comment: None,
284            owner: None,
285            grants: vec![],
286            rls_enabled: false,
287            rls_forced: false,
288            policies: vec![],
289            storage: crate::ir::reloptions::TableStorageOptions::default(),
290            access_method: None,
291        });
292        let mut locs = HashMap::new();
293        locs.insert(
294            ObjectKey::Table(qn("app", "users")),
295            loc("schema/other/tables/users.sql"),
296        );
297        let tree = SourceTree::new(c, locs);
298        let f = check(&schema_mirror_equivalent(), &tree, Path::new(""));
299        assert!(f.iter().any(|x| x.rule == "custom_profile_schema_mismatch"));
300    }
301
302    #[test]
303    fn unmatched_path_flagged() {
304        let mut c = Catalog::empty();
305        c.tables.push(Table {
306            qname: qn("app", "users"),
307            columns: vec![],
308            constraints: vec![],
309            partition_by: None,
310            partition_of: None,
311            comment: None,
312            owner: None,
313            grants: vec![],
314            rls_enabled: false,
315            rls_forced: false,
316            policies: vec![],
317            storage: crate::ir::reloptions::TableStorageOptions::default(),
318            access_method: None,
319        });
320        let mut locs = HashMap::new();
321        locs.insert(
322            ObjectKey::Table(qn("app", "users")),
323            loc("schema/whatever.sql"),
324        );
325        let tree = SourceTree::new(c, locs);
326        let f = check(&schema_mirror_equivalent(), &tree, Path::new(""));
327        assert!(f.iter().any(|x| x.rule == "custom_profile_unmatched_path"));
328    }
329}