pgevolve_core/lint/profile/
custom.rs1use 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#[derive(Debug, Clone, Deserialize)]
24pub struct CustomProfile {
25 pub patterns: Vec<PathPattern>,
27}
28
29#[derive(Debug, Clone, Deserialize)]
31pub struct PathPattern {
32 pub regex: String,
35 #[serde(default)]
37 pub assertions: Vec<Assertion>,
38}
39
40#[derive(Debug, Clone, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum Assertion {
44 SchemaMatchesCapture,
46 NameMatchesCapture,
48 KindMatchesCapture {
52 allowed_values: BTreeMap<String, String>,
54 },
55 OneObjectPerFile,
57}
58
59pub 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 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: ®ex::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}