|
| 1 | +//! Expected results and assertions must not define or read each other, even through helpers. |
| 2 | +
|
| 3 | +use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; |
| 4 | + |
| 5 | +use polyglot_sql::{Dialect, DialectType, Expression, ExpressionWalk}; |
| 6 | +use serde_json::Value; |
| 7 | +use sqlbuild_sqltext::sql_scan::main::dialect_non_code_end::dialect_non_code_end; |
| 8 | +use sqlbuild_sqltext::sql_scan::models::{LexicalSyntax, Unclosed}; |
| 9 | + |
| 10 | +use crate::compiler::_helpers::polyglot::parse_options::guarded_parse_options; |
| 11 | +use crate::compiler::constants::{ALIAS_FIELD, CTES_FIELD, NAME_FIELD}; |
| 12 | + |
| 13 | +const DIRECT_DEPENDENCY_PATH_LENGTH: usize = 2; |
| 14 | +const TABLE_KIND: &str = "table"; |
| 15 | +const IDENTIFIER_QUOTES: &[u8] = b"\"`"; |
| 16 | + |
| 17 | +/// The check CTEs of one file and the labels its messages name. |
| 18 | +pub(crate) struct IndependenceCheck<'a> { |
| 19 | + pub(crate) ctes: &'a [(String, String)], |
| 20 | + pub(crate) expected_prefix: &'a str, |
| 21 | + pub(crate) assertion_prefix: &'a str, |
| 22 | + pub(crate) file_label: &'a str, |
| 23 | + pub(crate) context_label: &'a str, |
| 24 | +} |
| 25 | + |
| 26 | +/// The first independence error of `check`, if any. |
| 27 | +pub(crate) fn independence_error( |
| 28 | + check: &IndependenceCheck<'_>, |
| 29 | + syntax: &LexicalSyntax, |
| 30 | +) -> Option<String> { |
| 31 | + let mut names_by_key: HashMap<String, &str> = HashMap::new(); |
| 32 | + for (name, _) in check.ctes { |
| 33 | + names_by_key.insert(casefold(name), name); |
| 34 | + } |
| 35 | + let keys_with_prefix = |prefix: &str| -> BTreeSet<String> { |
| 36 | + names_by_key |
| 37 | + .iter() |
| 38 | + .filter(|(_, name)| name.starts_with(prefix)) |
| 39 | + .map(|(key, _)| key.clone()) |
| 40 | + .collect() |
| 41 | + }; |
| 42 | + let expected: BTreeSet<String> = keys_with_prefix(check.expected_prefix); |
| 43 | + let assertions: BTreeSet<String> = keys_with_prefix(check.assertion_prefix); |
| 44 | + for (name, body) in check.ctes { |
| 45 | + let key: String = casefold(name); |
| 46 | + let (prohibited_prefix, prohibited_label) = if expected.contains(&key) { |
| 47 | + (check.assertion_prefix, "assertion") |
| 48 | + } else if assertions.contains(&key) { |
| 49 | + (check.expected_prefix, "expected result") |
| 50 | + } else { |
| 51 | + continue; |
| 52 | + }; |
| 53 | + if !casefold(body).contains(&casefold(prohibited_prefix)) { |
| 54 | + continue; |
| 55 | + } |
| 56 | + if let Some(nested) = defined_cte_names(body) |
| 57 | + .into_iter() |
| 58 | + .find(|nested| nested.starts_with(prohibited_prefix)) |
| 59 | + { |
| 60 | + return Some(format!( |
| 61 | + "{} '{}' check CTE '{name}' must not define {prohibited_label} CTE '{nested}'; \ |
| 62 | + expected results and assertions must be independent", |
| 63 | + check.context_label, check.file_label |
| 64 | + )); |
| 65 | + } |
| 66 | + } |
| 67 | + if expected.is_empty() || assertions.is_empty() { |
| 68 | + return None; |
| 69 | + } |
| 70 | + let mut dependencies: HashMap<String, Vec<String>> = HashMap::new(); |
| 71 | + for (name, body) in check.ctes { |
| 72 | + let references = match known_cte_references(body, &names_by_key, syntax) { |
| 73 | + Ok(references) => references, |
| 74 | + Err(message) => return Some(message), |
| 75 | + }; |
| 76 | + dependencies.insert(casefold(name), references); |
| 77 | + } |
| 78 | + for (origins, prohibited) in [(&expected, &assertions), (&assertions, &expected)] { |
| 79 | + for origin in origins { |
| 80 | + let Some(path) = dependency_path(origin, prohibited, &dependencies) else { |
| 81 | + continue; |
| 82 | + }; |
| 83 | + let rendered: Vec<&str> = path.iter().map(|key| names_by_key[key]).collect(); |
| 84 | + let through: String = if rendered.len() > DIRECT_DEPENDENCY_PATH_LENGTH { |
| 85 | + format!( |
| 86 | + " through {}", |
| 87 | + rendered[1..rendered.len() - 1] |
| 88 | + .iter() |
| 89 | + .map(|name| format!("'{name}'")) |
| 90 | + .collect::<Vec<String>>() |
| 91 | + .join(" -> ") |
| 92 | + ) |
| 93 | + } else { |
| 94 | + String::new() |
| 95 | + }; |
| 96 | + return Some(format!( |
| 97 | + "{} '{}' check CTE '{}' must not depend on '{}'{through}; expected results and \ |
| 98 | + assertions must be independent", |
| 99 | + check.context_label, |
| 100 | + check.file_label, |
| 101 | + rendered[0], |
| 102 | + rendered[rendered.len() - 1] |
| 103 | + )); |
| 104 | + } |
| 105 | + } |
| 106 | + None |
| 107 | +} |
| 108 | + |
| 109 | +/// `str.casefold`, through Rust's Unicode lower-case mapping. |
| 110 | +fn casefold(text: &str) -> String { |
| 111 | + text.to_lowercase() |
| 112 | +} |
| 113 | + |
| 114 | +/// `parse_one(sql, dialect="generic")` under SQLBuild's complexity guard, or `None` on error. |
| 115 | +fn parse_one(sql: &str) -> Option<Expression> { |
| 116 | + let Ok(options) = guarded_parse_options() else { |
| 117 | + return None; |
| 118 | + }; |
| 119 | + let Ok(mut statements) = Dialect::get(DialectType::Generic).parse_with_options(sql, &options) |
| 120 | + else { |
| 121 | + return None; |
| 122 | + }; |
| 123 | + (statements.len() == 1).then(|| statements.remove(0)) |
| 124 | +} |
| 125 | + |
| 126 | +/// Every CTE alias defined anywhere in `sql`, in the order `to_dict()` lists them. |
| 127 | +fn defined_cte_names(sql: &str) -> Vec<String> { |
| 128 | + let Some(parsed) = parse_one(sql) else { |
| 129 | + return Vec::new(); |
| 130 | + }; |
| 131 | + let Ok(value) = serde_json::to_value(&parsed) else { |
| 132 | + return Vec::new(); |
| 133 | + }; |
| 134 | + collect_cte_names(&value, Vec::new()) |
| 135 | +} |
| 136 | + |
| 137 | +/// `names` followed by each new CTE alias under `value`, depth first. |
| 138 | +fn collect_cte_names(value: &Value, mut names: Vec<String>) -> Vec<String> { |
| 139 | + match value { |
| 140 | + Value::Object(fields) => { |
| 141 | + if let Some(Value::Array(ctes)) = fields.get(CTES_FIELD) { |
| 142 | + for cte in ctes { |
| 143 | + if let Some(Value::String(name)) = cte |
| 144 | + .get(ALIAS_FIELD) |
| 145 | + .filter(|alias| alias.is_object()) |
| 146 | + .and_then(|alias| alias.get(NAME_FIELD)) |
| 147 | + && !names.contains(name) |
| 148 | + { |
| 149 | + names.push(name.clone()); |
| 150 | + } |
| 151 | + } |
| 152 | + } |
| 153 | + fields |
| 154 | + .values() |
| 155 | + .fold(names, |names, child| collect_cte_names(child, names)) |
| 156 | + } |
| 157 | + Value::Array(items) => items |
| 158 | + .iter() |
| 159 | + .fold(names, |names, item| collect_cte_names(item, names)), |
| 160 | + _ => names, |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +/// The keys of the file's CTEs that `sql` reads, in reading order. |
| 165 | +fn known_cte_references( |
| 166 | + sql: &str, |
| 167 | + names_by_key: &HashMap<String, &str>, |
| 168 | + syntax: &LexicalSyntax, |
| 169 | +) -> Result<Vec<String>, String> { |
| 170 | + let folded: String = casefold(sql); |
| 171 | + if !names_by_key.keys().any(|key| folded.contains(key.as_str())) { |
| 172 | + return Ok(Vec::new()); |
| 173 | + } |
| 174 | + let Some(parsed) = parse_one(sql) else { |
| 175 | + return identifier_references(sql, names_by_key, syntax); |
| 176 | + }; |
| 177 | + let mut references: Vec<String> = Vec::new(); |
| 178 | + for table in parsed |
| 179 | + .dfs() |
| 180 | + .skip(1) |
| 181 | + .filter(|node| node.variant_name() == TABLE_KIND) |
| 182 | + { |
| 183 | + references = push_known(references, casefold(table.get_name()), names_by_key); |
| 184 | + } |
| 185 | + Ok(references) |
| 186 | +} |
| 187 | + |
| 188 | +/// The identifier scan used when Polyglot cannot parse a body. |
| 189 | +fn identifier_references( |
| 190 | + sql: &str, |
| 191 | + names_by_key: &HashMap<String, &str>, |
| 192 | + syntax: &LexicalSyntax, |
| 193 | +) -> Result<Vec<String>, String> { |
| 194 | + let bytes: &[u8] = sql.as_bytes(); |
| 195 | + let mut references: Vec<String> = Vec::new(); |
| 196 | + let mut index: usize = 0; |
| 197 | + while let Some(character) = sql[index..].chars().next() { |
| 198 | + match dialect_non_code_end(bytes, index, syntax) { |
| 199 | + Err(construct) => return Err(unclosed_message(construct)), |
| 200 | + Ok(Some(end)) if IDENTIFIER_QUOTES.contains(&bytes[index]) => { |
| 201 | + let quote: &str = &sql[index..=index]; |
| 202 | + let name: String = sql[index + 1..end - 1].replace("e.repeat(2), quote); |
| 203 | + references = push_known(references, casefold(&name), names_by_key); |
| 204 | + index = end; |
| 205 | + } |
| 206 | + Ok(Some(end)) => index = end, |
| 207 | + Ok(None) if character.is_alphabetic() || character == '_' => { |
| 208 | + let end: usize = sql[index..] |
| 209 | + .char_indices() |
| 210 | + .skip(1) |
| 211 | + .find(|(_, next)| !(next.is_alphanumeric() || *next == '_')) |
| 212 | + .map_or(sql.len(), |(offset, _)| index + offset); |
| 213 | + references = push_known(references, casefold(&sql[index..end]), names_by_key); |
| 214 | + index = end; |
| 215 | + } |
| 216 | + Ok(None) => index += character.len_utf8(), |
| 217 | + } |
| 218 | + } |
| 219 | + Ok(references) |
| 220 | +} |
| 221 | + |
| 222 | +/// `references` with `key` appended when it names a file CTE not yet read. |
| 223 | +fn push_known( |
| 224 | + mut references: Vec<String>, |
| 225 | + key: String, |
| 226 | + names_by_key: &HashMap<String, &str>, |
| 227 | +) -> Vec<String> { |
| 228 | + if names_by_key.contains_key(&key) && !references.contains(&key) { |
| 229 | + references.push(key); |
| 230 | + } |
| 231 | + references |
| 232 | +} |
| 233 | + |
| 234 | +fn unclosed_message(construct: Unclosed) -> String { |
| 235 | + let construct: &str = match construct { |
| 236 | + Unclosed::BlockComment => "block comment", |
| 237 | + Unclosed::Quote => "quoted string", |
| 238 | + Unclosed::Parenthesis => "parenthesis", |
| 239 | + }; |
| 240 | + format!("SQL test contains an unclosed {construct}") |
| 241 | +} |
| 242 | + |
| 243 | +/// The shortest dependency path from `origin` to a prohibited CTE, breadth first. |
| 244 | +fn dependency_path( |
| 245 | + origin: &str, |
| 246 | + prohibited: &BTreeSet<String>, |
| 247 | + dependencies: &HashMap<String, Vec<String>>, |
| 248 | +) -> Option<Vec<String>> { |
| 249 | + let mut pending: VecDeque<Vec<String>> = VecDeque::from([vec![origin.to_owned()]]); |
| 250 | + let mut visited: HashSet<String> = HashSet::from([origin.to_owned()]); |
| 251 | + while let Some(path) = pending.pop_front() { |
| 252 | + let last: &String = path.last()?; |
| 253 | + for dependency in dependencies.get(last).into_iter().flatten() { |
| 254 | + let mut candidate: Vec<String> = path.clone(); |
| 255 | + candidate.push(dependency.clone()); |
| 256 | + if prohibited.contains(dependency) { |
| 257 | + return Some(candidate); |
| 258 | + } |
| 259 | + if visited.insert(dependency.clone()) { |
| 260 | + pending.push_back(candidate); |
| 261 | + } |
| 262 | + } |
| 263 | + } |
| 264 | + None |
| 265 | +} |
0 commit comments